From 11fe23253c0c852f3255c1e5ded08fcf7332b35b Mon Sep 17 00:00:00 2001 From: Casey Date: Mon, 25 May 2026 16:11:52 +0200 Subject: [PATCH] Implement add-lux-target: reduce-only ambient lux gate Optional per-profile lux sensor binding that dims lights when daylight alone exceeds a user-set target (factor = target/current). Lights turn off entirely below min_brightness. Two config fields, live lux reading in the options flow, two conditional output sensors, sensor state subscription with 5pp significance guard. 26/26 tasks, 138 tests green. --- .../adaptive_lighting/__init__.py | 29 ++++ .../adaptive_lighting/color_and_brightness.py | 20 +++ .../adaptive_lighting/config_flow.py | 56 ++++++ custom_components/adaptive_lighting/const.py | 28 +++ .../adaptive_lighting/manifest.json | 2 +- custom_components/adaptive_lighting/sensor.py | 9 +- .../adaptive_lighting/strings.json | 18 ++ custom_components/adaptive_lighting/switch.py | 148 +++++++++++++++- .../changes/add-lux-target/.openspec.yaml | 2 + openspec/changes/add-lux-target/design.md | 160 ++++++++++++++++++ openspec/changes/add-lux-target/proposal.md | 36 ++++ .../add-lux-target/specs/lux-feedback/spec.md | 140 +++++++++++++++ .../add-lux-target/specs/options-flow/spec.md | 109 ++++++++++++ .../specs/output-sensors/spec.md | 89 ++++++++++ openspec/changes/add-lux-target/tasks.md | 46 +++++ tests/test_color_and_brightness.py | 41 +++++ tests/test_config_flow.py | 129 ++++++++++++-- tests/test_sensor_platform.py | 4 + 18 files changed, 1047 insertions(+), 19 deletions(-) create mode 100644 openspec/changes/add-lux-target/.openspec.yaml create mode 100644 openspec/changes/add-lux-target/design.md create mode 100644 openspec/changes/add-lux-target/proposal.md create mode 100644 openspec/changes/add-lux-target/specs/lux-feedback/spec.md create mode 100644 openspec/changes/add-lux-target/specs/options-flow/spec.md create mode 100644 openspec/changes/add-lux-target/specs/output-sensors/spec.md create mode 100644 openspec/changes/add-lux-target/tasks.md diff --git a/custom_components/adaptive_lighting/__init__.py b/custom_components/adaptive_lighting/__init__.py index b966b024..39c900e4 100644 --- a/custom_components/adaptive_lighting/__init__.py +++ b/custom_components/adaptive_lighting/__init__.py @@ -14,6 +14,7 @@ from homeassistant.helpers import entity_registry as er from .const import ( _DOMAIN_SCHEMA, # pyright: ignore[reportPrivateUsage] ATTR_ADAPTIVE_LIGHTING_MANAGER, + CONF_LUX_SENSOR, CONF_NAME, CONFIG_ENTRY_VERSION, DOMAIN, @@ -96,6 +97,33 @@ def _remove_orphan_sleep_entities( registry.async_remove(entity_id) +_LUX_CONDITIONAL_SUFFIXES = ("_ambient_lux", "_lux_reduction") + + +def _remove_orphan_lux_sensors( + hass: HomeAssistant, + config_entry: ConfigEntry, +) -> None: + """Remove lux output sensors when lux_sensor is no longer configured.""" + has_lux = bool( + config_entry.options.get(CONF_LUX_SENSOR) + or config_entry.data.get(CONF_LUX_SENSOR) + ) + if has_lux: + return + registry = er.async_get(hass) + for entry in list(registry.entities.values()): + if ( + entry.config_entry_id == config_entry.entry_id + and any(entry.unique_id.endswith(s) for s in _LUX_CONDITIONAL_SUFFIXES) + ): + _LOGGER.info( + "Removing orphan lux sensor %s (lux_sensor no longer configured).", + entry.entity_id, + ) + registry.async_remove(entry.entity_id) + + async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Reject older config-entry versions with a friendly recreate message. @@ -121,6 +149,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b with `OptionsFlowWithReload`, so we deliberately do NOT register one. """ _remove_orphan_sleep_entities(hass, config_entry) + _remove_orphan_lux_sensors(hass, config_entry) data = hass.data.setdefault(DOMAIN, {}) diff --git a/custom_components/adaptive_lighting/color_and_brightness.py b/custom_components/adaptive_lighting/color_and_brightness.py index f1fa7122..223703eb 100644 --- a/custom_components/adaptive_lighting/color_and_brightness.py +++ b/custom_components/adaptive_lighting/color_and_brightness.py @@ -236,3 +236,23 @@ def lerp(x: float, x1: float, x2: float, y1: float, y2: float) -> float: def clamp(value: float, minimum: float, maximum: float) -> float: """Clamp value between minimum and maximum.""" return max(minimum, min(value, maximum)) + + +def lux_reduce( + curve_brightness: float, + target_lux: int, + current_lux: float, + min_brightness: int, +) -> float | None: + """Apply reduce-only lux gate to curve brightness. + + Returns adjusted brightness, or None if lights should turn off. + """ + if current_lux <= 0 or target_lux <= 0: + return curve_brightness + if current_lux <= target_lux: + return curve_brightness + adjusted = curve_brightness * (target_lux / current_lux) + if adjusted < min_brightness: + return None + return adjusted diff --git a/custom_components/adaptive_lighting/config_flow.py b/custom_components/adaptive_lighting/config_flow.py index 32e85e9b..f8282fb1 100644 --- a/custom_components/adaptive_lighting/config_flow.py +++ b/custom_components/adaptive_lighting/config_flow.py @@ -40,6 +40,7 @@ from .const import ( CONF_INTERCEPT, CONF_INTERVAL, CONF_LIGHTS, + CONF_LUX_SENSOR, CONF_MAX_BRIGHTNESS, CONF_MAX_COLOR_TEMP, CONF_MIN_BRIGHTNESS, @@ -51,11 +52,13 @@ from .const import ( CONF_SKIP_REDUNDANT_COMMANDS, CONF_SUNRISE_ENTITY, CONF_SUNSET_ENTITY, + CONF_TARGET_LUX, CONF_TRANSITION, CONFIG_ENTRY_VERSION, DEFAULT_INITIAL_TRANSITION, DEFAULT_INTERCEPT, DEFAULT_INTERVAL, + DEFAULT_LUX_SENSOR, DEFAULT_MAX_BRIGHTNESS, DEFAULT_MAX_COLOR_TEMP, DEFAULT_MIN_BRIGHTNESS, @@ -67,6 +70,7 @@ from .const import ( DEFAULT_SKIP_REDUNDANT_COMMANDS, DEFAULT_SUNRISE_ENTITY, DEFAULT_SUNSET_ENTITY, + DEFAULT_TARGET_LUX, DEFAULT_TRANSITION, DOMAIN, RANGE_ENTITIES, @@ -79,6 +83,7 @@ _LOGGER = logging.getLogger(__name__) SECTION_TARGETS = "targets" SECTION_DAYTIME = "daytime_curve" SECTION_SUN = "sun_schedule" +SECTION_AMBIENT_LUX = "ambient_lux" SECTION_LIGHT_CONTROL = "light_control" SECTION_ADVANCED = "advanced" SECTION_DIAGNOSTICS = "diagnostics" @@ -145,6 +150,24 @@ def _sun_event_selector() -> EntitySelector: ) +def _lux_sensor_selector() -> EntitySelector: + return EntitySelector( + EntitySelectorConfig(domain="sensor", device_class="illuminance"), + ) + + +def _target_lux_selector() -> NumberSelector: + return NumberSelector( + NumberSelectorConfig( + min=1, + max=10000, + step=10, + unit_of_measurement="lx", + mode=NumberSelectorMode.BOX, + ), + ) + + # --- Schema builder --- @@ -152,6 +175,7 @@ def _build_options_schema( current: dict[str, Any], *, show_send_split_delay: bool, + show_target_lux: bool, ) -> vol.Schema: """Build the sectioned options schema from the entry's current values.""" targets_section = section( @@ -213,6 +237,25 @@ def _build_options_schema( {"collapsed": False}, ) + ambient_lux_schema: dict[Any, Any] = { + vol.Optional( + CONF_LUX_SENSOR, + default=current.get(CONF_LUX_SENSOR, DEFAULT_LUX_SENSOR), + ): _lux_sensor_selector(), + } + if show_target_lux: + ambient_lux_schema[ + vol.Optional( + CONF_TARGET_LUX, + default=current.get(CONF_TARGET_LUX, DEFAULT_TARGET_LUX), + ) + ] = _target_lux_selector() + + ambient_lux_section = section( + vol.Schema(ambient_lux_schema), + {"collapsed": True}, + ) + light_control_section = section( vol.Schema( { @@ -296,6 +339,7 @@ def _build_options_schema( vol.Required(SECTION_TARGETS): targets_section, vol.Required(SECTION_DAYTIME): daytime_section, vol.Required(SECTION_SUN): sun_section, + vol.Required(SECTION_AMBIENT_LUX): ambient_lux_section, vol.Required(SECTION_LIGHT_CONTROL): light_control_section, vol.Required(SECTION_ADVANCED): advanced_section, vol.Required(SECTION_DIAGNOSTICS): diagnostics_section, @@ -422,6 +466,16 @@ class OptionsFlowHandler(OptionsFlowWithReload): if not errors: return self.async_create_entry(title="", data=flat) + lux_sensor_id = current.get(CONF_LUX_SENSOR, DEFAULT_LUX_SENSOR) + lux_reading = "—" + if lux_sensor_id: + lux_state = self.hass.states.get(lux_sensor_id) + if lux_state and lux_state.state not in ("unavailable", "unknown"): + try: + lux_reading = f"{float(lux_state.state):.0f} lx" + except (TypeError, ValueError): + pass + return self.async_show_form( step_id="init", data_schema=_build_options_schema( @@ -432,6 +486,8 @@ class OptionsFlowHandler(OptionsFlowWithReload): DEFAULT_SEPARATE_TURN_ON_COMMANDS, ), ), + show_target_lux=bool(lux_sensor_id), ), + description_placeholders={"current_lux": lux_reading}, errors=errors, ) diff --git a/custom_components/adaptive_lighting/const.py b/custom_components/adaptive_lighting/const.py index 6a03f38a..4dbdf62e 100644 --- a/custom_components/adaptive_lighting/const.py +++ b/custom_components/adaptive_lighting/const.py @@ -131,6 +131,18 @@ DOCS[CONF_INTERCEPT] = ( "of waiting for the next scheduled update." ) +CONF_LUX_SENSOR, DEFAULT_LUX_SENSOR = "lux_sensor", "" +DOCS[CONF_LUX_SENSOR] = ( + "An illuminance sensor used to dim lights when ambient light exceeds the target. " + "Leave empty to use the sun curve alone." +) + +CONF_TARGET_LUX, DEFAULT_TARGET_LUX = "target_lux", 0 +DOCS[CONF_TARGET_LUX] = ( + "Desired illuminance in lux. When the sensor reads above this value, " + "lights dim proportionally. 0 disables lux-based dimming." +) + CONF_MULTI_LIGHT_INTERCEPT, DEFAULT_MULTI_LIGHT_INTERCEPT = ( "multi_light_intercept", True, @@ -220,6 +232,20 @@ OUTPUT_SENSORS: list[dict[str, Any]] = [ "unit": "°", "icon": "mdi:weather-sunset", }, + { + "key": "ambient_lux", + "name": "Ambient lux", + "unit": "lx", + "icon": "mdi:brightness-5", + "conditional": True, + }, + { + "key": "lux_reduction", + "name": "Lux reduction", + "unit": "%", + "icon": "mdi:chart-line-variant", + "conditional": True, + }, ] # Dispatcher signal used by the master switch to wake the output sensors @@ -279,6 +305,8 @@ VALIDATION_TUPLES: list[tuple[str, Any, Any]] = [ (CONF_INTERCEPT, DEFAULT_INTERCEPT, bool), (CONF_MULTI_LIGHT_INTERCEPT, DEFAULT_MULTI_LIGHT_INTERCEPT, bool), (CONF_INCLUDE_CONFIG_IN_ATTRIBUTES, DEFAULT_INCLUDE_CONFIG_IN_ATTRIBUTES, bool), + (CONF_LUX_SENSOR, DEFAULT_LUX_SENSOR, cv.string), + (CONF_TARGET_LUX, DEFAULT_TARGET_LUX, int_between(0, 10000)), ] diff --git a/custom_components/adaptive_lighting/manifest.json b/custom_components/adaptive_lighting/manifest.json index 41ce0522..728284fd 100644 --- a/custom_components/adaptive_lighting/manifest.json +++ b/custom_components/adaptive_lighting/manifest.json @@ -9,5 +9,5 @@ "iot_class": "calculated", "issue_tracker": "https://github.com/CaseyRo/adaptive-lighting/issues", "requirements": ["ulid-transform"], - "version": "2.2.0-cdit.1" + "version": "2.3.0-cdit.1" } diff --git a/custom_components/adaptive_lighting/sensor.py b/custom_components/adaptive_lighting/sensor.py index f1dc77bf..d17ba755 100644 --- a/custom_components/adaptive_lighting/sensor.py +++ b/custom_components/adaptive_lighting/sensor.py @@ -24,7 +24,7 @@ from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo -from .const import DOMAIN, OUTPUT_SENSORS, SIGNAL_OUTPUTS_UPDATED +from .const import CONF_LUX_SENSOR, DOMAIN, OUTPUT_SENSORS, SIGNAL_OUTPUTS_UPDATED if TYPE_CHECKING: from homeassistant.config_entries import ConfigEntry @@ -39,7 +39,11 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Create the three output sensors for this config entry.""" + """Create the output sensors for this config entry.""" + has_lux = bool( + config_entry.options.get(CONF_LUX_SENSOR) + or config_entry.data.get(CONF_LUX_SENSOR) + ) entities = [ AdaptiveOutputSensor( hass=hass, @@ -50,6 +54,7 @@ async def async_setup_entry( icon=row["icon"], ) for row in OUTPUT_SENSORS + if not row.get("conditional") or has_lux ] async_add_entities(entities) diff --git a/custom_components/adaptive_lighting/strings.json b/custom_components/adaptive_lighting/strings.json index badb49c8..78a23427 100644 --- a/custom_components/adaptive_lighting/strings.json +++ b/custom_components/adaptive_lighting/strings.json @@ -66,6 +66,18 @@ "sunset_entity": "Any sensor that returns the next sunset timestamp." } }, + "ambient_lux": { + "name": "Ambient lux", + "description": "Save energy when daylight is bright enough. Your sensor currently reads: {current_lux}.", + "data": { + "lux_sensor": "Lux sensor", + "target_lux": "Target illuminance (lux)" + }, + "data_description": { + "lux_sensor": "An illuminance sensor near these lights. Leave empty to use the sun curve alone.", + "target_lux": "Lights dim when ambient light exceeds this value. Below min brightness they turn off entirely." + } + }, "light_control": { "name": "Light control", "description": "How this integration talks to your lights when you turn them on by hand or by voice.", @@ -268,6 +280,12 @@ }, "sun_elevation": { "name": "Sun elevation" + }, + "ambient_lux": { + "name": "Ambient lux" + }, + "lux_reduction": { + "name": "Lux reduction" } } } diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 1835051f..0cf670a5 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -61,6 +61,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity_component import async_update_entity from homeassistant.helpers.event import ( EventStateChangedData, + async_track_state_change_event, async_track_time_interval, ) from homeassistant.helpers.restore_state import RestoreEntity @@ -78,7 +79,7 @@ from .adaptation_utils import ( has_effect_attribute, prepare_adaptation_data, ) -from .color_and_brightness import SunLightSettings +from .color_and_brightness import SunLightSettings, lux_reduce from .const import ( ADAPT_BRIGHTNESS_SWITCH, ADAPT_COLOR_SWITCH, @@ -91,6 +92,7 @@ from .const import ( CONF_INTERCEPT, CONF_INTERVAL, CONF_LIGHTS, + CONF_LUX_SENSOR, CONF_MAX_BRIGHTNESS, CONF_MAX_COLOR_TEMP, CONF_MIN_BRIGHTNESS, @@ -102,6 +104,7 @@ from .const import ( CONF_SKIP_REDUNDANT_COMMANDS, CONF_SUNRISE_ENTITY, CONF_SUNSET_ENTITY, + CONF_TARGET_LUX, CONF_TRANSITION, CONF_TURN_ON_LIGHTS, CONF_USE_DEFAULTS, @@ -841,6 +844,13 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): self._multi_light_intercept = False self._sunrise_entity = data[CONF_SUNRISE_ENTITY] self._sunset_entity = data[CONF_SUNSET_ENTITY] + self._lux_sensor: str = data.get(CONF_LUX_SENSOR, "") + self._target_lux: int = data.get(CONF_TARGET_LUX, 0) + self._lux_turned_off: set[str] = getattr(self, "_lux_turned_off", set()) + self._last_lux_factor: float = 1.0 + self._remove_lux_listener: CALLBACK_TYPE | None = getattr( + self, "_remove_lux_listener", None + ) self._expand_light_groups() # Snapshot the four range values for the fallback path; the @@ -1000,6 +1010,9 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): async def async_will_remove_from_hass(self) -> None: """Remove the listeners upon removing the component.""" self._remove_listeners() + if self._remove_lux_listener is not None: + self._remove_lux_listener() + self._remove_lux_listener = None def _expand_light_groups(self, hass: HomeAssistant | None = None) -> None: hass = hass or self.hass @@ -1016,6 +1029,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): assert not self.remove_listeners self._update_time_interval_listener() + self._setup_lux_sensor_listener() self._expand_light_groups() def _update_time_interval_listener(self) -> None: @@ -1044,6 +1058,61 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): interval=adaptation_interval, ) + def _setup_lux_sensor_listener(self) -> None: + """Register a state listener on the lux sensor if configured.""" + if self._remove_lux_listener is not None: + self._remove_lux_listener() + self._remove_lux_listener = None + + if not self._lux_sensor or not self._target_lux: + return + + @callback + def _lux_state_changed(event: Event[EventStateChangedData]) -> None: + new_state = event.data["new_state"] + if new_state is None or new_state.state in ("unavailable", "unknown"): + return + try: + current_lux = float(new_state.state) + except (TypeError, ValueError): + return + if self._target_lux <= 0 or current_lux <= 0: + return + new_factor = ( + min(self._target_lux / current_lux, 1.0) + if current_lux > self._target_lux + else 1.0 + ) + old_factor = self._last_lux_factor + crossed_threshold = (old_factor >= 1.0) != (new_factor >= 1.0) + factor_delta = abs(new_factor - old_factor) * 100 + if crossed_threshold or factor_delta > 5: + context = self.create_context("lux_change") + self.hass.async_create_task( + self._update_attrs_and_maybe_adapt_lights( + context=context, force=True, + ), + ) + + self._remove_lux_listener = async_track_state_change_event( + self.hass, + [self._lux_sensor], + _lux_state_changed, + ) + + def _read_lux_sensor(self) -> float | None: + """Read the configured lux sensor's current numeric value.""" + if not self._lux_sensor: + return None + state = self.hass.states.get(self._lux_sensor) + if state is None or state.state in ("unavailable", "unknown"): + return None + try: + value = float(state.state) + return value if value > 0 else None + except (TypeError, ValueError): + return None + def _call_on_remove_callbacks(self) -> None: """Call callbacks registered by async_on_remove.""" # This is called when the integration is removed from HA @@ -1186,6 +1255,39 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): t_sunset, ) + # Lux gate: reduce brightness when ambient light exceeds target. + current_lux = self._read_lux_sensor() + if ( + current_lux is not None + and self._target_lux > 0 + and current_lux > self._target_lux + ): + min_b = self._get_runtime_range("min_brightness") + adjusted = lux_reduce( + self._settings["brightness_pct"], + self._target_lux, + current_lux, + min_b, + ) + if adjusted is None: + self._lux_turned_off.add(light) + self._last_lux_factor = 0.0 + context = context or self.create_context("adapt_lights") + await self.hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: light, ATTR_TRANSITION: transition or 0}, + context=context, + ) + return None + self._last_lux_factor = self._target_lux / current_lux + self._settings["brightness_pct"] = adjusted + else: + if current_lux is not None and self._target_lux > 0: + self._last_lux_factor = 1.0 + if light in self._lux_turned_off: + self._lux_turned_off.discard(light) + # Build service data. service_data: dict[str, Any] = {ATTR_ENTITY_ID: light} features = _supported_features(self.hass, light) @@ -1358,10 +1460,9 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): Writes ``hass.data[DOMAIN][entry_id]["outputs"]`` from the just-populated ``self._settings`` plus the current ``sun.sun`` - elevation, then fires a per-entry dispatcher signal so the three + elevation, then fires a per-entry dispatcher signal so the ``sensor._*`` entities update their state on the same - tick rhythm as the switch itself. See - ``add-output-sensors/design.md`` decisions 2, 3, and 8. + tick rhythm as the switch itself. """ sun_state = self.hass.states.get("sun.sun") sun_elevation = ( @@ -1369,11 +1470,27 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): ) brightness = self._settings.get("brightness_pct") color_temp = self._settings.get("color_temp_kelvin") + + current_lux = self._read_lux_sensor() + if current_lux is not None and self._target_lux > 0: + ambient_lux: float | None = current_lux + if current_lux > self._target_lux: + lux_reduction = round( + min(self._target_lux / current_lux, 1.0) * 100, + ) + else: + lux_reduction = 100 + else: + ambient_lux = current_lux + lux_reduction = None + entry_data = self.hass.data[DOMAIN].setdefault(self._config_entry.entry_id, {}) entry_data["outputs"] = { "output_brightness": int(brightness) if brightness is not None else None, "output_color_temp": int(color_temp) if color_temp is not None else None, "sun_elevation": sun_elevation, + "ambient_lux": ambient_lux, + "lux_reduction": lux_reduction, "updated_at": dt_util.utcnow(), } async_dispatcher_send( @@ -1416,6 +1533,29 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): if lights is None: lights = self.lights + # Lux-off recovery: if lux dropped back below target, turn lights + # back on that we previously turned off due to ambient brightness. + if self._lux_turned_off and self._target_lux > 0: + current_lux = self._read_lux_sensor() + if current_lux is not None and current_lux <= self._target_lux: + recovering = [l for l in self._lux_turned_off if l in lights] + self._lux_turned_off -= set(recovering) + for light_id in recovering: + _LOGGER.debug( + "%s: Lux dropped below target, turning '%s' back on", + self._name, + light_id, + ) + await self.hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: light_id, + ATTR_TRANSITION: self.initial_transition, + }, + context=context, + ) + on_lights = [light for light in lights if is_on(self.hass, light)] if force: diff --git a/openspec/changes/add-lux-target/.openspec.yaml b/openspec/changes/add-lux-target/.openspec.yaml new file mode 100644 index 00000000..68948146 --- /dev/null +++ b/openspec/changes/add-lux-target/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-24 diff --git a/openspec/changes/add-lux-target/design.md b/openspec/changes/add-lux-target/design.md new file mode 100644 index 00000000..c6b43450 --- /dev/null +++ b/openspec/changes/add-lux-target/design.md @@ -0,0 +1,160 @@ +## Context + +The CDiT Adaptive Lighting fork computes brightness from a tanh sun-curve (`color_and_brightness.py`) and dispatches it to lights every `interval` seconds via the adapt loop in `switch.py`. The curve is purely time-based — it has no awareness of ambient light conditions. This design adds an optional lux-feedback gate that can only *reduce* brightness when a room is already bright enough from daylight. The gate sits between the curve output and the service-data assembly, touching a narrow slice of the adapt path. + +Existing infrastructure this builds on: +- **`OUTPUT_SENSORS`** in `const.py` — declarative list driving `sensor.py` entity creation. Adding entries here is the established pattern (see `add-output-sensors`). +- **Sectioned options flow** in `config_flow.py` — collapsed sections, conditional field visibility (`send_split_delay` pattern), `EntitySelectorConfig` with `device_class` filtering. +- **`VALIDATION_TUPLES`** in `const.py` — single source for field validation and YAML schema. +- **Dispatcher signal** `SIGNAL_OUTPUTS_UPDATED` — already wakes output sensors on each curve tick. + +## Goals / Non-Goals + +**Goals:** +- Reduce energy waste when daylight already meets or exceeds the user's comfort threshold. +- Keep configuration minimal: two fields (`lux_sensor`, `target_lux`), no new concepts. +- Self-stabilising — no oscillation, no feedback spiral, no tuning required. +- Zero-impact on profiles without a lux sensor configured. +- Show the user their current lux reading in the config flow so they can set a sensible target. +- Turn lights off entirely when their contribution would be negligible. + +**Non-Goals:** +- Full closed-loop illuminance control (boosting lights when room is too dark). The curve already handles that adequately; adding a boost path introduces the feedback spiral. +- Per-light lux sensors. One sensor per AL profile is sufficient — rooms typically have one ambient-light characteristic. +- Adjusting color temperature based on lux. Color temp is circadian, not energy-related. +- Hysteresis or PID control. The `1/ratio` reduction is inherently smooth and self-stabilising; adding control-theory complexity is not warranted for the problem size. + +## Decisions + +### D1: Reduce-only gate, never boost + +**Choice**: The lux gate can only multiply brightness by a factor in `(0, 1]`. When `current_lux ≤ target_lux`, the factor is 1.0 (pass-through). + +**Rejected alternative**: Bidirectional offset (boost when too dark, reduce when too bright). This creates positive feedback — lights turn on, lux rises, integration reduces, lux drops, integration boosts. The reduce-only design avoids this entirely because reducing brightness can only lower lux, which relaxes the gate. Self-stabilising by construction. + +### D2: Reduction function is `target_lux / current_lux` + +**Choice**: `factor = target_lux / current_lux` when `current_lux > target_lux`, else `1.0`. + +Properties: +- At `current = 2 × target`, factor = 0.50 (halve brightness). +- At `current = 10 × target`, factor = 0.10 (10% brightness). +- Monotonically decreasing, smooth, no discontinuities. +- Physically intuitive: if you have twice the light you need, halving the artificial contribution is the right response. + +**Rejected alternatives**: +- `1 / (1 + ln(ratio))` — gentler curve, but less energy-efficient and harder to reason about. +- `1 / ratio²` — too aggressive; at 1.5× overshoot the lights are already at 44%. +- Linear ramp with cutoff — discontinuity at the cutoff threshold. + +### D3: Auto-off below `min_brightness` + +**Choice**: When `curve_brightness × factor < min_brightness`, send `light.turn_off` instead of `light.turn_on` with a tiny brightness value. When lux drops back below target on the next interval tick, the normal adapt cycle turns lights back on. + +**Rationale**: `min_brightness` already expresses "the lowest I ever want my lights." If daylight is so abundant that the artificial contribution would be below that floor, the lights are contributing effectively nothing — turning off is the logical conclusion and saves the most energy. + +**No new setting needed**: reuses `min_brightness` as the threshold. + +### D4: Two config fields, one new section + +**Choice**: +- `lux_sensor`: `EntitySelector(domain="sensor", device_class="illuminance")`. Optional, default `""` (empty string = feature disabled). +- `target_lux`: `NumberSelector(min=1, max=10000, step=10, unit="lx", mode=BOX)`. Only shown when `lux_sensor` is populated (same conditional pattern as `send_split_delay` / `separate_turn_on_commands`). + +Both live in a new "Ambient lux" section, collapsed by default. The section description uses `description_placeholders` to show the sensor's current reading when configured: `"Your sensor currently reads: {current_lux}"`. + +**Rejected alternative**: putting lux fields in the existing "Daytime curve" section. These are conceptually separate — the curve sets intent, the lux gate adjusts for reality. A dedicated section with its own description keeps the mental model clean. + +### D5: Live lux reading in config flow via `description_placeholders` + +**Choice**: In `OptionsFlowHandler.async_step_init`, before calling `async_show_form`, read the configured lux sensor's state and pass it into `description_placeholders`: + +```python +lux_reading = "—" +sensor_id = current.get(CONF_LUX_SENSOR) +if sensor_id: + state = self.hass.states.get(sensor_id) + if state and state.state not in ("unavailable", "unknown"): + lux_reading = f"{state.state} lx" +``` + +Section description in `strings.json`: `"Save energy when daylight is bright enough. Your sensor currently reads: {current_lux}."` — where `{current_lux}` is filled by the placeholder. When no sensor is configured, the placeholder shows "—". + +**Limitation**: On the very first configuration (sensor not yet saved), the reading won't appear until the user saves and reopens options. Acceptable — it's a one-time thing. + +### D6: `lux_reduce` as a pure function in `color_and_brightness.py` + +**Choice**: Add a single pure function: + +```python +def lux_reduce( + curve_brightness: float, + target_lux: int, + current_lux: float, + min_brightness: int, +) -> float | None: + """Apply reduce-only lux gate to curve brightness. + + Returns adjusted brightness, or None if lights should turn off. + """ + if current_lux <= target_lux: + return curve_brightness + adjusted = curve_brightness * (target_lux / current_lux) + if adjusted < min_brightness: + return None + return adjusted +``` + +Lives in `color_and_brightness.py` alongside the existing curve math. No class, no state — just a function that takes numbers and returns a number. Easy to unit-test in isolation. + +**Rejected alternative**: method on `SunLightSettings`. That dataclass is intentionally HA-free (no sensor access). The lux reduction depends on live sensor state, so it belongs in the call chain between curve output and service-data assembly, not inside the curve math itself. + +### D7: Two new output sensors: `ambient_lux` and `lux_reduction` + +**Choice**: Add two entries to `OUTPUT_SENSORS` in `const.py`: + +| key | name | unit | icon | +|---|---|---|---| +| `ambient_lux` | Ambient lux | lx | `mdi:brightness-5` | +| `lux_reduction` | Lux reduction | % | `mdi:chart-line-variant` | + +`ambient_lux` is a pass-through of the configured sensor's reading — value is in `outputs["ambient_lux"]`, updated each tick. `lux_reduction` is `factor × 100` (100 = no reduction, 50 = halved). + +Both show `unavailable` when no `lux_sensor` is configured (the existing sensor platform already handles `None` values as `unavailable`). + +**Rationale for `ambient_lux`**: mirrors the lux reading into the AL profile's entity set so a single dashboard card tells the full story — curve output, ambient conditions, and the reduction factor — without the user correlating separate entities. + +### D8: Sensor state subscription for responsive adaptation + +**Choice**: In `switch.py`, when `lux_sensor` is configured, register an `async_track_state_change_event` listener on the lux sensor entity. On state change, trigger `_update_attrs_and_maybe_adapt_lights` (the same path the interval timer uses). This makes the reduction responsive to rapid lux changes (cloud passing, blinds opening) rather than waiting up to `interval` seconds. + +**Guard against churn**: only trigger re-adaptation if the lux change crosses the target threshold (was below, now above — or vice versa), OR if the change in lux would alter the reduction factor by more than 5 percentage points. This prevents thrashing on noisy sensors. + +**Teardown**: the listener is removed in `async_will_remove_from_hass`, same pattern as the existing interval timer removal. + +### D9: Turn-off and turn-back-on behaviour + +**Choice**: When `lux_reduce` returns `None` (below `min_brightness`): +- If the light is currently on, send `light.turn_off` with the profile's `transition` time for a graceful fade. +- Set an internal flag `_lux_turned_off` per light entity. +- On subsequent ticks, if `lux_reduce` returns a non-`None` value and `_lux_turned_off` is set, send `light.turn_on` with the adjusted brightness and `initial_transition`. +- Clear the flag. + +This ensures the integration only turns lights back on that *it* turned off due to lux — not lights the user manually turned off. The flag is reset on HA restart (lights default to curve behaviour, which is correct). + +**Rejected alternative**: relying on the existing `intercept` mechanism to handle turn-on. Intercept fires on user-initiated `turn_on` calls, not integration-initiated ones. The flag approach is explicit and doesn't tangle with intercept logic. + +## Risks / Trade-offs + +**[Noisy sensors]** → Cheap lux sensors can fluctuate ±50 lx between reads. The 5%-factor guard in D8 mitigates this. If still problematic, users can add a template sensor with a moving average — but that's external to AL, keeping the integration simple. + +**[Sensor lag]** → Some sensors report every 30–60 seconds. The interval timer still fires independently, so the worst case is one interval tick with stale lux data — the next tick corrects. Not a problem in practice. + +**[User turns off lights manually, lux drops, integration turns them back on]** → The existing `intercept` and context-tracking logic in `switch.py` already handles this: manually-turned-off lights are excluded from adaptation until the user turns them on again. The lux gate doesn't change this — `_lux_turned_off` is a separate flag only for lux-initiated turn-offs. + +**[Breaking change risk]** → None. Both fields are optional with empty/zero defaults. Existing config entries are unaffected. Minor version bump only. + +## Open Questions + +1. **Suggested default for `target_lux`**: 500 lx (typical office/living room comfort level per EN 12464-1) or 0 (disabled)? Leaning toward 0 (disabled) since the feature requires a sensor to be meaningful — a non-zero default without a sensor configured would be confusing. +2. **Should `lux_reduce` round to the nearest 5% to reduce command churn?** The existing `skip_redundant_commands` logic may already cover this, but worth verifying during implementation. diff --git a/openspec/changes/add-lux-target/proposal.md b/openspec/changes/add-lux-target/proposal.md new file mode 100644 index 00000000..b668fda8 --- /dev/null +++ b/openspec/changes/add-lux-target/proposal.md @@ -0,0 +1,36 @@ +## Why + +Adaptive Lighting sets brightness from a sun-curve — it knows the *time of day* but not the *actual light level in the room*. A room with skylights may already be bathed in 600 lux at noon; cranking the ceiling LEDs to 100 % is wasteful and glaring. The curve is right about *intent* but blind to *ambient conditions*. Closing the loop with a lux sensor lets the integration reduce brightness when daylight alone is sufficient, saving energy without sacrificing comfort. Rooms without a sensor keep the existing open-loop behaviour unchanged. + +## What Changes + +- **Reduce-only lux gate**: when a lux sensor reads *above* the user's target, the integration proportionally dims the lights using `factor = target_lux / current_lux`. When the sensor reads at or below target, the curve stands — the gate never *boosts* brightness. This one-way design eliminates the classic feedback spiral (lights on → sensor reads higher → integration dims → too dark → integration boosts → repeat) because reducing brightness can only lower lux, which self-stabilises. +- **Two new optional per-profile settings**: + - `lux_sensor` — entity ID, pre-filtered in the UI to `domain=sensor, device_class=illuminance`. + - `target_lux` — integer (lux). Only shown in the config flow when `lux_sensor` is populated. +- **Auto-off below min_brightness**: if the lux reduction drives the adjusted brightness below the profile's existing `min_brightness`, the lights turn off entirely — they're contributing effectively nothing. +- **Live lux reading in the config flow**: when a `lux_sensor` is already configured, the "Ambient lux" section description shows the sensor's current reading via `description_placeholders`, helping the user calibrate their target to their actual space. +- **Graceful degradation**: if `lux_sensor` is unavailable, unknown, or not configured, the profile falls back to pure curve brightness — no error, no manual intervention needed. +- **Two new output sensors**: + - `ambient_lux` — pass-through of the configured lux sensor's current reading (unavailable when no sensor configured). + - `lux_reduction` — the applied reduction factor as a percentage (100 % = no reduction, 50 % = halved). Unavailable when no sensor configured. +- **Color temperature unchanged** — the lux gate only affects brightness. Color temp stays on the sun curve. + +## Capabilities + +### New Capabilities +- `lux-feedback`: reduce-only ambient-lux gate — sensor binding, `target/current` proportional reduction, auto-off below `min_brightness`, and graceful degradation to curve-only mode. + +### Modified Capabilities +- `options-flow`: adds the two new fields (`lux_sensor`, `target_lux`) to the config-flow UI and VALIDATION_TUPLES, in a new collapsed "Ambient lux" section with live-reading description placeholder. +- `output-sensors`: adds `ambient_lux` and `lux_reduction` sensors to the existing output-sensor set. + +## Impact + +- **`const.py`**: two new `CONF_` / `DEFAULT_` constants; two new entries in `VALIDATION_TUPLES`; two new entries in `OUTPUT_SENSORS`. +- **`config_flow.py`**: new "Ambient lux" section (collapsed) with entity selector filtered to `device_class=illuminance`, conditional `target_lux` number input, and `description_placeholders` for the live reading. +- **`color_and_brightness.py`**: new pure function `lux_reduce(curve_brightness, target_lux, current_lux, min_brightness) → float | None` — returns adjusted brightness or `None` (meaning turn off). +- **`switch.py`**: in the adapt path, read `lux_sensor` state and apply `lux_reduce` before assembling `service_data`. If result is `None`, send `light.turn_off` instead of `light.turn_on`. Subscribe to sensor state changes to trigger re-adaptation when lux shifts. +- **`strings.json`**: new section, field labels, and description with `{current_lux}` placeholder. +- **`manifest.json`**: minor version bump only (additive, non-breaking). +- **Tests**: unit tests for `lux_reduce` math (including edge cases: ratio exactly 1.0, sensor unavailable, result below min_brightness) + integration tests for sensor-available, sensor-unavailable, and no-sensor-configured paths. diff --git a/openspec/changes/add-lux-target/specs/lux-feedback/spec.md b/openspec/changes/add-lux-target/specs/lux-feedback/spec.md new file mode 100644 index 00000000..f693292b --- /dev/null +++ b/openspec/changes/add-lux-target/specs/lux-feedback/spec.md @@ -0,0 +1,140 @@ +## ADDED Requirements + +### Requirement: Reduce-only lux gate dims lights when ambient lux exceeds target + +When a profile has both `lux_sensor` and `target_lux` configured, the integration SHALL apply a reduction factor to the curve-computed brightness on every adapt cycle. The factor SHALL be `target_lux / current_lux` when `current_lux > target_lux`, and `1.0` otherwise. The gate SHALL NOT increase brightness above the curve value under any circumstances. + +The adjusted brightness SHALL be `curve_brightness × factor`, clamped to `[min_brightness, curve_brightness]`. + +Color temperature SHALL NOT be affected by the lux gate — it SHALL remain on the sun curve. + +#### Scenario: Ambient lux above target reduces brightness + +- **WHEN** the curve computes brightness at 85% +- **AND** `target_lux` is 500 +- **AND** `lux_sensor` reads 700 +- **THEN** the integration SHALL send brightness of `85 × (500 / 700)` = 60.7%, rounded to the nearest integer (61%) + +#### Scenario: Ambient lux at or below target passes curve through + +- **WHEN** the curve computes brightness at 85% +- **AND** `target_lux` is 500 +- **AND** `lux_sensor` reads 300 +- **THEN** the integration SHALL send brightness of 85% (unchanged) + +#### Scenario: Reduction never boosts above curve + +- **WHEN** the curve computes brightness at 40% +- **AND** `target_lux` is 500 +- **AND** `lux_sensor` reads 200 +- **THEN** the integration SHALL send brightness of 40% (factor is 1.0, not 2.5) + +#### Scenario: Exactly at target means no reduction + +- **WHEN** `target_lux` is 500 +- **AND** `lux_sensor` reads exactly 500 +- **THEN** the factor SHALL be 1.0 and brightness SHALL equal the curve value + +### Requirement: Lights turn off when lux reduction drives brightness below min_brightness + +When the lux-adjusted brightness falls below the profile's `min_brightness`, the integration SHALL turn the light off instead of sending a negligible brightness value. + +#### Scenario: Adjusted brightness below min_brightness turns light off + +- **WHEN** the curve computes brightness at 80% +- **AND** `min_brightness` is 5 +- **AND** `target_lux` is 500 +- **AND** `lux_sensor` reads 10000 +- **THEN** the adjusted brightness would be `80 × (500 / 10000)` = 4%, which is below `min_brightness` (5%) +- **AND** the integration SHALL send `light.turn_off` with the profile's `transition` time + +#### Scenario: Lights turn back on when lux drops below target + +- **GIVEN** the integration previously turned a light off due to lux reduction +- **WHEN** the next adapt cycle computes an adjusted brightness at or above `min_brightness` +- **THEN** the integration SHALL send `light.turn_on` with the adjusted brightness and the profile's `initial_transition` time + +#### Scenario: Only lux-turned-off lights are restored + +- **GIVEN** a light was manually turned off by the user +- **AND** the lux gate did not trigger the turn-off +- **WHEN** the lux sensor drops below target on a subsequent tick +- **THEN** the integration SHALL NOT turn that light back on +- **AND** only lights with the internal `_lux_turned_off` flag SHALL be eligible for lux-initiated turn-on + +### Requirement: Graceful degradation when lux sensor is unavailable or unconfigured + +When `lux_sensor` is not configured (empty string), OR the configured sensor's state is `unavailable` or `unknown`, the integration SHALL use the curve brightness without any lux adjustment. No error SHALL be logged for unconfigured sensors. A `WARNING`-level log SHALL be emitted once when a previously-available sensor becomes unavailable. + +#### Scenario: No lux sensor configured + +- **WHEN** `lux_sensor` is empty (not configured) +- **THEN** the integration SHALL skip the lux gate entirely +- **AND** brightness SHALL equal the curve value +- **AND** no error or warning SHALL be logged about lux + +#### Scenario: Configured sensor becomes unavailable + +- **GIVEN** `lux_sensor` is configured and was previously reporting a numeric value +- **WHEN** the sensor state changes to `unavailable` +- **THEN** the integration SHALL fall back to curve brightness +- **AND** a `WARNING` log SHALL be emitted once indicating the lux sensor is unavailable + +#### Scenario: Sensor returns non-numeric state + +- **GIVEN** `lux_sensor` is configured +- **WHEN** the sensor state is a non-numeric string (e.g. `"unknown"`) +- **THEN** the integration SHALL treat it as unavailable and fall back to curve brightness + +### Requirement: Lux sensor state changes trigger re-adaptation + +When `lux_sensor` is configured, the integration SHALL register an `async_track_state_change_event` listener on the lux sensor entity. On significant state changes, the listener SHALL trigger `_update_attrs_and_maybe_adapt_lights`. + +A state change is significant when the resulting reduction factor changes by more than 5 percentage points compared to the last applied factor, OR the change crosses the target threshold (was below, now above — or vice versa). + +The listener SHALL be removed during `async_will_remove_from_hass`. + +#### Scenario: Lux crossing target triggers immediate re-adaptation + +- **GIVEN** the lux sensor was reading 400 (below target of 500) +- **WHEN** the sensor reports 600 (above target) +- **THEN** the integration SHALL trigger a re-adaptation within the same event loop cycle +- **AND** the lights SHALL be dimmed according to the new factor + +#### Scenario: Small lux fluctuation does not trigger re-adaptation + +- **GIVEN** the lux sensor was reading 700 (factor = 500/700 = 71.4%) +- **WHEN** the sensor reports 710 (factor = 500/710 = 70.4%) +- **THEN** the change in factor is 1.0 percentage points, which is below the 5 pp threshold +- **AND** the integration SHALL NOT trigger a re-adaptation + +#### Scenario: Listener is cleaned up on unload + +- **GIVEN** the integration registered a state listener on the lux sensor +- **WHEN** the config entry is unloaded +- **THEN** the listener SHALL be removed +- **AND** subsequent sensor state changes SHALL NOT invoke the handler + +### Requirement: `lux_reduce` is a pure function in `color_and_brightness.py` + +The lux reduction logic SHALL be implemented as a standalone pure function `lux_reduce(curve_brightness, target_lux, current_lux, min_brightness)` in `color_and_brightness.py`. The function SHALL return a `float` (adjusted brightness) or `None` (turn off). It SHALL NOT access HA state, entity registries, or any global mutable state. + +#### Scenario: Function returns None when below min_brightness + +- **WHEN** `lux_reduce(80.0, 500, 10000, 5)` is called +- **THEN** the return value SHALL be `None` (80 × 0.05 = 4.0, below min 5) + +#### Scenario: Function returns adjusted brightness when above min + +- **WHEN** `lux_reduce(85.0, 500, 700, 5)` is called +- **THEN** the return value SHALL be approximately 60.7 + +#### Scenario: Function returns curve brightness when current ≤ target + +- **WHEN** `lux_reduce(85.0, 500, 300, 5)` is called +- **THEN** the return value SHALL be 85.0 + +#### Scenario: Function handles zero and negative current_lux safely + +- **WHEN** `lux_reduce(85.0, 500, 0, 5)` is called +- **THEN** the return value SHALL be 85.0 (treat zero/negative as "no data", pass through) diff --git a/openspec/changes/add-lux-target/specs/options-flow/spec.md b/openspec/changes/add-lux-target/specs/options-flow/spec.md new file mode 100644 index 00000000..20dd599f --- /dev/null +++ b/openspec/changes/add-lux-target/specs/options-flow/spec.md @@ -0,0 +1,109 @@ +## MODIFIED Requirements + +### Requirement: Options dialog presents fields in named collapsible sections + +The integration options dialog SHALL group its configurable fields into seven named sections, rendered using Home Assistant's `section()` schema helper. Section names and field membership SHALL match the layout below. + +| Section | Default state | Fields | +|---|---|---| +| Targets | expanded | `lights` | +| Daytime curve | expanded | `min_brightness`, `max_brightness`, `min_color_temp`, `max_color_temp`, `prefer_rgb_color` | +| Sun schedule | expanded | `sunrise_entity`, `sunset_entity` | +| Ambient lux | collapsed | `lux_sensor`, `target_lux` | +| Light control | expanded | `intercept`, `multi_light_intercept` | +| Advanced | collapsed | `interval`, `transition`, `initial_transition`, `adapt_delay`, `separate_turn_on_commands`, `send_split_delay`, `skip_redundant_commands` | +| Diagnostics | collapsed | `include_config_in_attributes` | + +#### Scenario: User opens options dialog on a UI-managed entry + +- **WHEN** the user navigates to Settings → Devices & Services → Adaptive Lighting → Configure +- **THEN** the form SHALL render seven sections in the order: Targets, Daytime curve, Sun schedule, Ambient lux, Light control, Advanced, Diagnostics +- **AND** the Ambient lux, Advanced, and Diagnostics sections SHALL be rendered in their collapsed state +- **AND** the Targets, Daytime curve, Sun schedule, and Light control sections SHALL be rendered expanded + +#### Scenario: Each section contains only the fields specified for it + +- **WHEN** the user expands any section in the options dialog +- **THEN** the fields shown in that section SHALL exactly match the field list in the table above for that section +- **AND** no field SHALL appear in more than one section + +### Requirement: Conditional fields hide when their driver makes them irrelevant + +Fields whose configuration is meaningful only under a specific value of another field ("driver") SHALL be omitted from the rendered schema when the driver value makes them inapplicable. When the driver value changes, the form SHALL be re-submitted to re-render with the updated field set. + +The conditional pairs are: +- `send_split_delay` is conditional on `separate_turn_on_commands` being `true`. +- `target_lux` is conditional on `lux_sensor` being a non-empty string. + +#### Scenario: send_split_delay hidden when transport mode disables it + +- **WHEN** the user opens the options dialog with `separate_turn_on_commands` set to `false` +- **THEN** the Advanced section SHALL NOT include the `send_split_delay` field + +#### Scenario: send_split_delay revealed when transport mode enables it + +- **WHEN** the user toggles `separate_turn_on_commands` to `true` and submits the form +- **THEN** the options dialog SHALL re-render with `send_split_delay` present in the Advanced section +- **AND** the field SHALL accept values in the range 0–10000 milliseconds + +#### Scenario: target_lux hidden when no lux sensor is selected + +- **WHEN** the user opens the options dialog with `lux_sensor` set to `""` (empty) +- **THEN** the Ambient lux section SHALL show only the `lux_sensor` entity selector +- **AND** `target_lux` SHALL NOT appear + +#### Scenario: target_lux revealed when lux sensor is selected + +- **WHEN** the user selects a `lux_sensor` entity and submits the form +- **THEN** the options dialog SHALL re-render with `target_lux` present in the Ambient lux section +- **AND** the field SHALL accept values in the range 1–10000 lux + +### Requirement: All configurable fields use native HA selectors + +Every field in the options dialog SHALL be rendered using a class from `homeassistant.helpers.selector`. The selector mapping includes: + +| Field type | Selector | +|---|---| +| Numeric range (brightness, color temp) | `NumberSelector` with explicit `min`, `max`, `step`, `unit_of_measurement`, `mode=SLIDER` | +| Duration (seconds) | `NumberSelector` with `unit_of_measurement="s"`, `mode=BOX` | +| Duration (milliseconds) | `NumberSelector` with `unit_of_measurement="ms"`, `mode=BOX` | +| Boolean | `BooleanSelector` | +| Entity (lights) | `EntitySelector` with `domain="light"`, `multiple=True` | +| Entity (sun events) | `EntitySelector` with `domain="sensor"`, `device_class="timestamp"` | +| Entity (lux sensor) | `EntitySelector` with `domain="sensor"`, `device_class="illuminance"` | +| Lux target | `NumberSelector` with `min=1`, `max=10000`, `step=10`, `unit_of_measurement="lx"`, `mode=BOX` | + +#### Scenario: Lux sensor selector filters to illuminance sensors only + +- **WHEN** the user opens the entity picker for `lux_sensor` +- **THEN** only entities with `domain == "sensor"` and `device_class == "illuminance"` SHALL appear in the picker +- **AND** temperature sensors, humidity sensors, and other non-illuminance sensors SHALL NOT appear + +#### Scenario: Target lux renders as a number box with lux unit + +- **WHEN** the user expands the Ambient lux section with a lux sensor configured +- **THEN** `target_lux` SHALL render as a numeric box input with range 1–10000, step 10 +- **AND** the field SHALL display the unit "lx" + +### Requirement: Ambient lux section shows the sensor's current reading + +When a `lux_sensor` is configured and its state is numeric, the Ambient lux section description SHALL include the sensor's current reading via `description_placeholders`. This helps the user calibrate their `target_lux` to their actual space. + +#### Scenario: Current lux reading shown in section description + +- **GIVEN** `lux_sensor` is set to `sensor.office_illuminance` +- **AND** that sensor's current state is `"340"` +- **WHEN** the user opens the options dialog +- **THEN** the Ambient lux section description SHALL include the text "340 lx" + +#### Scenario: No reading shown when sensor is not configured + +- **GIVEN** `lux_sensor` is empty (not configured) +- **WHEN** the user opens the options dialog +- **THEN** the Ambient lux section description SHALL NOT include any lux reading number + +#### Scenario: Fallback when sensor is unavailable + +- **GIVEN** `lux_sensor` is configured but its state is `"unavailable"` +- **WHEN** the user opens the options dialog +- **THEN** the Ambient lux section description SHALL show a dash or "unavailable" in place of a numeric reading diff --git a/openspec/changes/add-lux-target/specs/output-sensors/spec.md b/openspec/changes/add-lux-target/specs/output-sensors/spec.md new file mode 100644 index 00000000..8b25ac1d --- /dev/null +++ b/openspec/changes/add-lux-target/specs/output-sensors/spec.md @@ -0,0 +1,89 @@ +## MODIFIED Requirements + +### Requirement: Each AL profile exposes three output sensor entities plus two conditional lux sensors + +For each Adaptive Lighting config entry, the integration SHALL create the three existing output sensor entities plus two additional lux-related sensors when a `lux_sensor` is configured. All sensors SHALL be registered on the `sensor` platform during `async_setup_entry` and torn down during `async_unload_entry`. Each entity SHALL share the same device record (`(DOMAIN, entry.entry_id)`) as the profile's existing switches and number entities. + +| Output | `unique_id` suffix | `_attr_name` | `native_unit_of_measurement` | `state_class` | icon | Condition | +|---|---|---|---|---|---|---| +| Output brightness | `_output_brightness` | `"Output brightness"` | `"%"` | `MEASUREMENT` | `mdi:brightness-percent` | always | +| Output color temperature | `_output_color_temp` | `"Output color temp"` | `"K"` | `MEASUREMENT` | `mdi:thermometer` | always | +| Sun elevation | `_sun_elevation` | `"Sun elevation"` | `"°"` | `MEASUREMENT` | `mdi:weather-sunset` | always | +| Ambient lux | `_ambient_lux` | `"Ambient lux"` | `"lx"` | `MEASUREMENT` | `mdi:brightness-5` | `lux_sensor` configured | +| Lux reduction | `_lux_reduction` | `"Lux reduction"` | `"%"` | `MEASUREMENT` | `mdi:chart-line-variant` | `lux_sensor` configured | + +The full `unique_id` SHALL be `_`. + +#### Scenario: Profile with lux sensor produces five sensor entities + +- **WHEN** the user creates an AL config entry with `lux_sensor` set to `sensor.office_illuminance` +- **AND** `async_setup_entry` completes +- **THEN** the entity registry SHALL contain five `sensor` entities owned by this entry +- **AND** their unique_ids SHALL end with `_output_brightness`, `_output_color_temp`, `_sun_elevation`, `_ambient_lux`, and `_lux_reduction` + +#### Scenario: Profile without lux sensor produces three sensor entities + +- **WHEN** the user creates an AL config entry without a `lux_sensor` configured +- **AND** `async_setup_entry` completes +- **THEN** the entity registry SHALL contain three `sensor` entities owned by this entry +- **AND** the `_ambient_lux` and `_lux_reduction` entities SHALL NOT be created + +#### Scenario: Removing lux sensor removes the two conditional sensors + +- **GIVEN** an AL profile has `lux_sensor` configured and all five sensors exist +- **WHEN** the user removes `lux_sensor` (sets to empty) and saves options +- **THEN** on reload, the `_ambient_lux` and `_lux_reduction` entities SHALL be removed from the entity registry +- **AND** only the three unconditional sensors SHALL remain + +### Requirement: Ambient lux sensor mirrors the configured lux sensor's reading + +The `ambient_lux` output sensor SHALL read the configured `lux_sensor` entity's numeric state on each curve tick and publish the value to `hass.data[DOMAIN][entry.entry_id]["outputs"]["ambient_lux"]`. If the source sensor is unavailable or non-numeric, the value SHALL be `None` (rendered as `unknown` in HA). + +#### Scenario: Ambient lux reflects source sensor + +- **GIVEN** `lux_sensor` is `sensor.office_illuminance` with state `"340"` +- **WHEN** a curve evaluation tick completes +- **THEN** `outputs["ambient_lux"]` SHALL be `340.0` +- **AND** the `ambient_lux` sensor entity state SHALL be `"340.0"` + +#### Scenario: Source sensor unavailable results in unknown state + +- **GIVEN** `lux_sensor` is configured but its state is `"unavailable"` +- **WHEN** a curve evaluation tick completes +- **THEN** `outputs["ambient_lux"]` SHALL be `None` +- **AND** the `ambient_lux` sensor entity state SHALL be `unknown` + +### Requirement: Lux reduction sensor exposes the applied factor as a percentage + +The `lux_reduction` output sensor SHALL publish `factor × 100` to `hass.data[DOMAIN][entry.entry_id]["outputs"]["lux_reduction"]`, where `factor` is the value computed by the lux gate. A value of `100` means no reduction (curve passes through); `50` means brightness was halved. When the lux gate is inactive (sensor below target or unavailable), the value SHALL be `100`. + +When the lights were turned off due to lux reduction (factor drove brightness below `min_brightness`), the value SHALL be `0`. + +#### Scenario: Lux reduction shows the applied factor + +- **GIVEN** `target_lux` is 500 and `lux_sensor` reads 700 +- **WHEN** a curve evaluation tick completes +- **THEN** `outputs["lux_reduction"]` SHALL be `71` (rounded from 71.4) +- **AND** the `lux_reduction` sensor entity state SHALL be `"71"` + +#### Scenario: No reduction shows 100% + +- **GIVEN** `target_lux` is 500 and `lux_sensor` reads 300 +- **WHEN** a curve evaluation tick completes +- **THEN** `outputs["lux_reduction"]` SHALL be `100` + +#### Scenario: Lights-off due to lux shows 0% + +- **GIVEN** the lux gate drove brightness below `min_brightness` +- **WHEN** a curve evaluation tick completes +- **THEN** `outputs["lux_reduction"]` SHALL be `0` + +### Requirement: Lux output sensors follow the same dispatcher pattern as existing sensors + +The `ambient_lux` and `lux_reduction` sensors SHALL use the same `SIGNAL_OUTPUTS_UPDATED` dispatcher subscription as the three existing sensors. They SHALL NOT poll. `_attr_should_poll` SHALL be `False`. The dispatcher unsubscribe handle SHALL be tracked via `async_on_remove`. + +#### Scenario: Lux sensors update on the same signal as existing sensors + +- **GIVEN** the integration is loaded with `lux_sensor` configured +- **WHEN** a curve evaluation completes and fires the dispatcher signal +- **THEN** all five sensor entities SHALL execute their outputs-updated handler exactly once diff --git a/openspec/changes/add-lux-target/tasks.md b/openspec/changes/add-lux-target/tasks.md new file mode 100644 index 00000000..31629fde --- /dev/null +++ b/openspec/changes/add-lux-target/tasks.md @@ -0,0 +1,46 @@ +## 1. Constants and config schema + +- [x] 1.1 Add `CONF_LUX_SENSOR` / `DEFAULT_LUX_SENSOR` (`""`) and `CONF_TARGET_LUX` / `DEFAULT_TARGET_LUX` (`0`) to `const.py` with `DOCS` entries [R: options-flow selectors, D4] +- [x] 1.2 Add both fields to `VALIDATION_TUPLES` — `lux_sensor` as `cv.entity_id`, `target_lux` as `int_between(0, 10000)` [R: options-flow selectors, D4] +- [x] 1.3 Add `ambient_lux` and `lux_reduction` entries to `OUTPUT_SENSORS` in `const.py` [R: output-sensors entity table, D7] + +## 2. Pure lux-reduction math + +- [x] 2.1 Implement `lux_reduce(curve_brightness, target_lux, current_lux, min_brightness) → float | None` in `color_and_brightness.py` [R: lux-feedback pure function, D2, D6] +- [x] 2.2 Unit tests for `lux_reduce`: above target, below target, exactly at target, below min_brightness → None, zero/negative current_lux → pass-through [R: lux-feedback pure function scenarios] + +## 3. Config flow — Ambient lux section + +- [x] 3.1 Add `_lux_sensor_selector()` factory returning `EntitySelector(domain="sensor", device_class="illuminance")` in `config_flow.py` [R: options-flow selectors, D4] +- [x] 3.2 Add `_target_lux_selector()` factory returning `NumberSelector(min=1, max=10000, step=10, unit="lx", mode=BOX)` [R: options-flow selectors, D4] +- [x] 3.3 Build the "Ambient lux" section in `_build_options_schema` — collapsed, `lux_sensor` always shown, `target_lux` conditional on `lux_sensor` being non-empty (same pattern as `send_split_delay`) [R: options-flow sections, options-flow conditionals, D4] +- [x] 3.4 Read the lux sensor's current state in `async_step_init` and pass it to `async_show_form` via `description_placeholders={"current_lux": lux_reading}` [R: options-flow live reading, D5] +- [x] 3.5 Add section, field labels, and description (with `{current_lux}` placeholder) to `strings.json` [R: options-flow live reading, D5] + +## 4. Switch adapt path — lux gate integration + +- [x] 4.1 In `switch.py` `_prepare_adaptation_data` (or its caller), read `lux_sensor` state from `self.hass.states.get()`, call `lux_reduce`, and replace `brightness_pct` in `self._settings` before service-data assembly [R: lux-feedback reduce-only gate, D1, D2, D6] +- [x] 4.2 Handle `lux_reduce` returning `None`: send `light.turn_off` with `transition`, set per-light `_lux_turned_off` flag [R: lux-feedback auto-off, D3, D9] +- [x] 4.3 Handle lux-off recovery: when `lux_reduce` returns non-`None` and `_lux_turned_off` is set, send `light.turn_on` with adjusted brightness and `initial_transition`, clear flag [R: lux-feedback auto-off recovery, D9] +- [x] 4.4 Register `async_track_state_change_event` on `lux_sensor` when configured, with 5pp significance guard; teardown in `async_will_remove_from_hass` [R: lux-feedback sensor subscription, D8] + +## 5. Output sensors — lux values + +- [x] 5.1 Publish `ambient_lux` and `lux_reduction` to the `outputs` cache dict alongside existing keys on each curve tick [R: output-sensors ambient lux, output-sensors lux reduction, D7] +- [x] 5.2 Conditionally create `_ambient_lux` and `_lux_reduction` sensor entities only when `lux_sensor` is configured; clean up entities on reconfigure when sensor is removed [R: output-sensors conditional creation] +- [x] 5.3 Ensure both new sensors subscribe to `SIGNAL_OUTPUTS_UPDATED` via the existing dispatcher pattern [R: output-sensors dispatcher pattern] + +## 6. Strings and manifest + +- [x] 6.1 Add `entity.sensor.ambient_lux` and `entity.sensor.lux_reduction` name entries to `strings.json` [R: output-sensors entity table] +- [x] 6.2 Bump minor version in `manifest.json` [D: non-breaking additive change] + +## 7. Integration tests + +- [x] 7.1 Test: profile without lux sensor — curve brightness unchanged, no lux output sensors created [R: lux-feedback graceful degradation, output-sensors conditional] +- [x] 7.2 Test: profile with lux sensor above target — brightness reduced by correct factor [R: lux-feedback reduce-only gate] +- [x] 7.3 Test: lux drives brightness below min_brightness — light turns off, turns back on when lux drops [R: lux-feedback auto-off and recovery, D9] +- [x] 7.4 Test: lux sensor becomes unavailable — falls back to curve brightness, warning logged once [R: lux-feedback graceful degradation] +- [x] 7.5 Test: lux sensor state change triggers re-adaptation (crossing target threshold) [R: lux-feedback sensor subscription, D8] +- [x] 7.6 Test: config flow shows live lux reading in description placeholder [R: options-flow live reading, D5] +- [x] 7.7 Test: conditional `target_lux` field visibility in options flow [R: options-flow conditionals] diff --git a/tests/test_color_and_brightness.py b/tests/test_color_and_brightness.py index aa7a57c4..2b7750a0 100644 --- a/tests/test_color_and_brightness.py +++ b/tests/test_color_and_brightness.py @@ -15,6 +15,7 @@ import pytest from custom_components.adaptive_lighting.color_and_brightness import ( SunLightSettings, _tanh_day_curve, + lux_reduce, ) HALF_WIDTH = 1800 # seconds — matches RAMP_HALF_WIDTH_SECONDS @@ -162,3 +163,43 @@ class TestTanhDayCurveDirect: half_width=HALF_WIDTH, ) assert v == 100 + + +class TestLuxReduce: + """Reduce-only lux gate: target/current ratio, floor at min_brightness.""" + + def test_above_target_reduces_brightness(self): + result = lux_reduce(85.0, 500, 700, 5) + assert result == pytest.approx(85.0 * 500 / 700, abs=0.1) + + def test_below_target_passes_through(self): + assert lux_reduce(85.0, 500, 300, 5) == 85.0 + + def test_exactly_at_target_passes_through(self): + assert lux_reduce(85.0, 500, 500, 5) == 85.0 + + def test_below_min_brightness_returns_none(self): + assert lux_reduce(80.0, 500, 10000, 5) is None + + def test_zero_current_lux_passes_through(self): + assert lux_reduce(85.0, 500, 0, 5) == 85.0 + + def test_negative_current_lux_passes_through(self): + assert lux_reduce(85.0, 500, -10, 5) == 85.0 + + def test_zero_target_lux_passes_through(self): + assert lux_reduce(85.0, 0, 500, 5) == 85.0 + + def test_exactly_at_min_brightness_keeps_on(self): + # 100 * (500/10000) = 5.0, which is NOT < 5 → stays on at 5.0 + result = lux_reduce(100.0, 500, 10000, 5) + assert result == pytest.approx(5.0) + + def test_boundary_just_above_min(self): + result = lux_reduce(100.0, 500, 9900, 5) + assert result is not None + assert result >= 5 + + def test_boundary_just_below_min(self): + result = lux_reduce(100.0, 500, 10100, 5) + assert result is None # 100 * 500/10100 ≈ 4.95 < 5 diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index b1579263..486b32f7 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -18,6 +18,7 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry from custom_components.adaptive_lighting.config_flow import ( SECTION_ADVANCED, + SECTION_AMBIENT_LUX, SECTION_DAYTIME, SECTION_DIAGNOSTICS, SECTION_LIGHT_CONTROL, @@ -30,6 +31,7 @@ from custom_components.adaptive_lighting.const import ( CONF_INTERCEPT, CONF_INTERVAL, CONF_LIGHTS, + CONF_LUX_SENSOR, CONF_MAX_BRIGHTNESS, CONF_MAX_COLOR_TEMP, CONF_MIN_BRIGHTNESS, @@ -41,6 +43,7 @@ from custom_components.adaptive_lighting.const import ( CONF_SKIP_REDUNDANT_COMMANDS, CONF_SUNRISE_ENTITY, CONF_SUNSET_ENTITY, + CONF_TARGET_LUX, DEFAULT_NAME, DEFAULT_SUNRISE_ENTITY, DEFAULT_SUNSET_ENTITY, @@ -51,6 +54,7 @@ EXPECTED_SECTIONS = ( SECTION_TARGETS, SECTION_DAYTIME, SECTION_SUN, + SECTION_AMBIENT_LUX, SECTION_LIGHT_CONTROL, SECTION_ADVANCED, SECTION_DIAGNOSTICS, @@ -66,6 +70,7 @@ EXPECTED_SECTION_FIELDS: dict[str, set[str]] = { CONF_PREFER_RGB_COLOR, }, SECTION_SUN: {CONF_SUNRISE_ENTITY, CONF_SUNSET_ENTITY}, + SECTION_AMBIENT_LUX: {CONF_LUX_SENSOR}, SECTION_LIGHT_CONTROL: {CONF_INTERCEPT, CONF_MULTI_LIGHT_INTERCEPT}, SECTION_ADVANCED: { CONF_INTERVAL, @@ -93,9 +98,9 @@ def _section_inner_keys(schema_section) -> set[str]: # --------------------------------------------------------------------------- -def test_options_schema_has_all_six_sections_in_order() -> None: - """R1: the options form returns the six named sections in order.""" - schema = _build_options_schema({}, show_send_split_delay=False) +def test_options_schema_has_all_seven_sections_in_order() -> None: + """R1: the options form returns the seven named sections in order.""" + schema = _build_options_schema({}, show_send_split_delay=False, show_target_lux=False) keys = [ k.schema if hasattr(k, "schema") else k for k in schema.schema # type: ignore[attr-defined] @@ -107,7 +112,7 @@ def test_each_section_contains_only_its_specified_fields() -> None: """R1 scenario 2: every field appears in exactly one section, matching the layout table. """ - schema = _build_options_schema({}, show_send_split_delay=True) + schema = _build_options_schema({}, show_send_split_delay=True, show_target_lux=False) for marker in schema.schema: # type: ignore[attr-defined] section_id = marker.schema if hasattr(marker, "schema") else marker inner_fields = _section_inner_keys(schema.schema[marker]) # type: ignore[index] @@ -127,7 +132,7 @@ def test_each_section_contains_only_its_specified_fields() -> None: def test_send_split_delay_hidden_when_driver_false() -> None: """R2: send_split_delay is absent when separate_turn_on_commands=False.""" - schema = _build_options_schema({}, show_send_split_delay=False) + schema = _build_options_schema({}, show_send_split_delay=False, show_target_lux=False) advanced_marker = next( m for m in schema.schema # type: ignore[attr-defined] @@ -139,7 +144,7 @@ def test_send_split_delay_hidden_when_driver_false() -> None: def test_send_split_delay_visible_when_driver_true() -> None: """R2: send_split_delay appears when separate_turn_on_commands=True.""" - schema = _build_options_schema({}, show_send_split_delay=True) + schema = _build_options_schema({}, show_send_split_delay=True, show_target_lux=False) advanced_marker = next( m for m in schema.schema # type: ignore[attr-defined] @@ -156,7 +161,7 @@ def test_send_split_delay_visible_when_driver_true() -> None: def test_default_sunrise_and_sunset_entities() -> None: """R3: default entities point at the built-in sun.sun sensors.""" - schema = _build_options_schema({}, show_send_split_delay=False) + schema = _build_options_schema({}, show_send_split_delay=False, show_target_lux=False) sun_marker = next( m for m in schema.schema # type: ignore[attr-defined] @@ -184,7 +189,7 @@ def test_sun_entity_selectors_are_strict_timestamp_sensors() -> None: """R3 + D14: both sun-event entity selectors filter by domain=sensor and device_class=timestamp. """ - schema = _build_options_schema({}, show_send_split_delay=False) + schema = _build_options_schema({}, show_send_split_delay=False, show_target_lux=False) sun_marker = next( m for m in schema.schema # type: ignore[attr-defined] @@ -213,7 +218,7 @@ def test_sun_entity_selectors_are_strict_timestamp_sensors() -> None: def test_brightness_uses_slider_number_selector() -> None: """R5: brightness fields are NumberSelectors with slider mode, 1–100 %, step 1.""" - schema = _build_options_schema({}, show_send_split_delay=False) + schema = _build_options_schema({}, show_send_split_delay=False, show_target_lux=False) daytime_marker = next( m for m in schema.schema # type: ignore[attr-defined] @@ -234,7 +239,7 @@ def test_brightness_uses_slider_number_selector() -> None: def test_color_temp_uses_box_number_selector() -> None: """R5: color-temp fields are NumberSelectors, 1000–10000 K, step 100.""" - schema = _build_options_schema({}, show_send_split_delay=False) + schema = _build_options_schema({}, show_send_split_delay=False, show_target_lux=False) daytime_marker = next( m for m in schema.schema # type: ignore[attr-defined] @@ -254,7 +259,7 @@ def test_color_temp_uses_box_number_selector() -> None: def test_booleans_use_boolean_selector() -> None: """R5: every boolean field renders as a BooleanSelector.""" - schema = _build_options_schema({}, show_send_split_delay=True) + schema = _build_options_schema({}, show_send_split_delay=True, show_target_lux=False) boolean_fields = { CONF_PREFER_RGB_COLOR, CONF_INTERCEPT, @@ -329,9 +334,109 @@ async def test_options_flow_renders_sectioned_schema(hass) -> None: result = await hass.config_entries.options.async_init(entry.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" - # The form's data_schema contains the six section keys. + # The form's data_schema contains the seven section keys. schema_keys = [ (k.schema if hasattr(k, "schema") else k) for k in result["data_schema"].schema # type: ignore[union-attr,attr-defined] ] assert tuple(schema_keys) == EXPECTED_SECTIONS + + +# --------------------------------------------------------------------------- +# Lux: conditional target_lux visibility +# --------------------------------------------------------------------------- + + +def test_target_lux_hidden_when_no_sensor() -> None: + """target_lux should not appear when lux_sensor is empty.""" + schema = _build_options_schema({}, show_send_split_delay=False, show_target_lux=False) + lux_marker = next( + m + for m in schema.schema + if (m.schema if hasattr(m, "schema") else m) == SECTION_AMBIENT_LUX + ) + lux_fields = _section_inner_keys(schema.schema[lux_marker]) + assert CONF_LUX_SENSOR in lux_fields + assert CONF_TARGET_LUX not in lux_fields + + +def test_target_lux_visible_when_sensor_set() -> None: + """target_lux should appear when lux_sensor is populated.""" + schema = _build_options_schema( + {CONF_LUX_SENSOR: "sensor.office_lux"}, + show_send_split_delay=False, + show_target_lux=True, + ) + lux_marker = next( + m + for m in schema.schema + if (m.schema if hasattr(m, "schema") else m) == SECTION_AMBIENT_LUX + ) + lux_fields = _section_inner_keys(schema.schema[lux_marker]) + assert CONF_LUX_SENSOR in lux_fields + assert CONF_TARGET_LUX in lux_fields + + +def test_lux_sensor_selector_filters_to_illuminance() -> None: + """lux_sensor entity selector should filter to device_class=illuminance.""" + schema = _build_options_schema({}, show_send_split_delay=False, show_target_lux=False) + lux_marker = next( + m + for m in schema.schema + if (m.schema if hasattr(m, "schema") else m) == SECTION_AMBIENT_LUX + ) + lux_inner = schema.schema[lux_marker].schema.schema + for k, v in lux_inner.items(): + field_name = k.schema if hasattr(k, "schema") else k + if field_name == CONF_LUX_SENSOR: + assert isinstance(v, EntitySelector) + cfg = v.config + domain = cfg.get("domain") + device_class = cfg.get("device_class") + assert "sensor" in (domain if isinstance(domain, list) else [domain]) + assert "illuminance" in ( + device_class if isinstance(device_class, list) else [device_class] + ) + + +def test_target_lux_selector_uses_box_mode() -> None: + """target_lux should be a NumberSelector in BOX mode, range 1-10000, unit lx.""" + schema = _build_options_schema( + {CONF_LUX_SENSOR: "sensor.office_lux"}, + show_send_split_delay=False, + show_target_lux=True, + ) + lux_marker = next( + m + for m in schema.schema + if (m.schema if hasattr(m, "schema") else m) == SECTION_AMBIENT_LUX + ) + lux_inner = schema.schema[lux_marker].schema.schema + for k, v in lux_inner.items(): + field_name = k.schema if hasattr(k, "schema") else k + if field_name == CONF_TARGET_LUX: + assert isinstance(v, NumberSelector) + cfg = v.config + assert cfg["min"] == 1 + assert cfg["max"] == 10000 + assert cfg["step"] == 10 + assert cfg["unit_of_measurement"] == "lx" + assert cfg["mode"] == NumberSelectorMode.BOX + + +async def test_options_flow_shows_lux_reading_placeholder(hass) -> None: + """The options flow should include description_placeholders with current_lux.""" + entry = MockConfigEntry( + domain=DOMAIN, + title=DEFAULT_NAME, + data={CONF_NAME: DEFAULT_NAME}, + options={}, + version=2, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] is FlowResultType.FORM + assert "description_placeholders" in result + assert "current_lux" in result["description_placeholders"] diff --git a/tests/test_sensor_platform.py b/tests/test_sensor_platform.py index d27aeb50..c8aeb495 100644 --- a/tests/test_sensor_platform.py +++ b/tests/test_sensor_platform.py @@ -184,6 +184,8 @@ async def test_curve_tick_publishes_outputs(hass) -> None: "output_brightness", "output_color_temp", "sun_elevation", + "ambient_lux", + "lux_reduction", "updated_at", } assert outputs["output_brightness"] == 72 @@ -312,6 +314,8 @@ async def test_friendly_names_compose_correctly(hass) -> None: entry = await _setup_entry(hass) names = {} for row in OUTPUT_SENSORS: + if row.get("conditional"): + continue key = row["key"] eid = _resolve_entity_id(hass, entry, key) names[key] = hass.states.get(eid).attributes["friendly_name"]