diff --git a/CHANGELOG.md b/CHANGELOG.md index 307cfa26..26a43af0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,42 @@ maintainers who can actually fix them for everyone. --- +## [2.2.0-cdit.1] — Unreleased + +### Added + +- **Three read-only `sensor` entities per profile** — `output_brightness` + (%), `output_color_temp` (K), `sun_elevation` (°). They expose the + curve's current target outputs alongside HA's built-in `sun.sun` + elevation as graphable numerics with `state_class: measurement`, so + the History panel, `apexcharts-card`, and `mini-graph-card` chart + them natively. The existing master-switch attributes (`brightness_pct`, + `color_temp_kelvin`, the synthetic `sun_position` in [-1, +1]) are + unchanged. +- **Push-based sensor updates** via a per-entry dispatcher signal fired + by the master switch after each curve tick. No polling, no duplicate + curve math — sensors are pure readers of a runtime cache the master + switch publishes to. +- **`sun_elevation` is sourced from `sun.sun.attributes.elevation`** on + every curve tick. If `sun.sun` is missing or the attribute is absent + (rare; can happen during very early HA startup), the sensor renders + as `unknown` for that tick — the other two sensors continue updating + normally. + +### Changed + +- **`manifest.json` version bumped to `2.2.0-cdit.1`.** Minor bump; no + breaking config-entry changes — existing 2.1 entries upgrade in place + and gain the three new sensor entities on next setup. + +### Migration + +No user action required. Restart HA after upgrade and the three new +sensors appear under each profile's device page. Add them to a Lovelace +card or `apexcharts-card` to start graphing. + +--- + ## [2.1.0-cdit.1] — Unreleased ### Added diff --git a/README.md b/README.md index 5a85a16a..409eddfe 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,33 @@ > Color" — short enough to fit HA's tightest cards. Existing entity_ids > are preserved by the entity registry; automations keep working. > +> ## ✨ What's new in 2.2 +> +> Each profile now exposes **three read-only `sensor` entities** that +> publish the curve's current outputs alongside the sun's actual elevation +> — graphable as numerics by HA's recorder, the History panel, +> `apexcharts-card`, and `mini-graph-card`: +> +> - `sensor._output_brightness` — current target brightness (%) +> - `sensor._output_color_temp` — current target color temp (K) +> - `sensor._sun_elevation` — solar elevation from `sun.sun` (°) +> +> The existing master-switch attributes (`brightness_pct`, +> `color_temp_kelvin`, the synthetic `sun_position` in [-1, +1]) are +> unchanged — anything reading them today keeps working. The sensors are +> the *graphable* version of the same data. +> +> Drop them on a dashboard with `apexcharts-card` to see the curve over time: +> +> ```yaml +> type: custom:apexcharts-card +> graph_span: 24h +> series: +> - entity: sensor.dining_mvp_output_brightness +> - entity: sensor.dining_mvp_output_color_temp +> - entity: sensor.dining_mvp_sun_elevation +> ``` +> > The rest of this README is from upstream and may describe features that no > longer exist in this fork. See `CHANGELOG.md` for the canonical list of > CDiT-specific changes. diff --git a/custom_components/adaptive_lighting/__init__.py b/custom_components/adaptive_lighting/__init__.py index 06f0d607..b966b024 100644 --- a/custom_components/adaptive_lighting/__init__.py +++ b/custom_components/adaptive_lighting/__init__.py @@ -21,7 +21,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["switch", "number"] +PLATFORMS = ["switch", "number", "sensor"] # unique_id suffix(es) that this fork no longer creates. Any entity in the # registry whose unique_id ends with one of these strings AND that is owned @@ -128,7 +128,12 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b # and explicit `hass.reload_config_entry` calls). hass.bus.async_listen("hass.config.entry_updated", reload_configuration_yaml) - data[config_entry.entry_id] = {} + data[config_entry.entry_id] = { + # Cache slot the master switch publishes to after each curve tick + # (add-output-sensors / D2). Initialized to None so any sensor that + # reads before the first tick sees a sentinel rather than a KeyError. + "outputs": None, + } await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True diff --git a/custom_components/adaptive_lighting/const.py b/custom_components/adaptive_lighting/const.py index 7fe80454..6a03f38a 100644 --- a/custom_components/adaptive_lighting/const.py +++ b/custom_components/adaptive_lighting/const.py @@ -196,6 +196,37 @@ RANGE_ENTITIES: list[dict[str, Any]] = [ }, ] +# Output sensor declarations (add-output-sensors / R1, D4, D7). +# Each dict drives one read-only `sensor` entity per AL profile. The `key` +# becomes both the sensor's unique-id suffix AND the cache-dict key in +# `hass.data[DOMAIN][entry_id]["outputs"]`, so the sensor reads +# `outputs[self._output_key]` with no intermediate mapping. +OUTPUT_SENSORS: list[dict[str, Any]] = [ + { + "key": "output_brightness", + "name": "Output brightness", + "unit": "%", + "icon": "mdi:brightness-percent", + }, + { + "key": "output_color_temp", + "name": "Output color temp", + "unit": "K", + "icon": "mdi:thermometer", + }, + { + "key": "sun_elevation", + "name": "Sun elevation", + "unit": "°", + "icon": "mdi:weather-sunset", + }, +] + +# Dispatcher signal used by the master switch to wake the output sensors +# after each curve tick. Keyed per config entry so two profiles don't +# cross-update. Format with `.format(entry_id=...)` at call/connect time. +SIGNAL_OUTPUTS_UPDATED = f"{DOMAIN}_{{entry_id}}_outputs_updated" + ATTR_ADAPT_COLOR = "adapt_color" DOCS[ATTR_ADAPT_COLOR] = "Adjust the color of supporting lights over the day." ATTR_ADAPT_BRIGHTNESS = "adapt_brightness" diff --git a/custom_components/adaptive_lighting/manifest.json b/custom_components/adaptive_lighting/manifest.json index ac05fa5f..41ce0522 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.1.0-cdit.1" + "version": "2.2.0-cdit.1" } diff --git a/custom_components/adaptive_lighting/number.py b/custom_components/adaptive_lighting/number.py index b5a70b76..d08e3694 100644 --- a/custom_components/adaptive_lighting/number.py +++ b/custom_components/adaptive_lighting/number.py @@ -19,7 +19,7 @@ integration, and the resulting fresh entities prefer the just-saved from __future__ import annotations import logging -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from homeassistant.components.number import ( NumberMode, diff --git a/custom_components/adaptive_lighting/sensor.py b/custom_components/adaptive_lighting/sensor.py new file mode 100644 index 00000000..d30f058d --- /dev/null +++ b/custom_components/adaptive_lighting/sensor.py @@ -0,0 +1,123 @@ +"""Sensor platform for the Adaptive Lighting integration (CDiT fork). + +Each config entry exposes three read-only output sensors that publish the +curve's current target values + the actual sun elevation: + +- ``sensor._output_brightness`` (% — from self._settings["brightness_pct"]) +- ``sensor._output_color_temp`` (K — from self._settings["color_temp_kelvin"]) +- ``sensor._sun_elevation`` (° — from sun.sun.attributes["elevation"]) + +The sensors update via a per-entry dispatcher signal fired by the master +switch after every curve evaluation. They are pure readers — no curve math +runs in the sensor class. See ``add-output-sensors/design.md`` decisions +2, 3, and 8. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from homeassistant.components.sensor import SensorEntity, SensorStateClass +from homeassistant.core import callback +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 + +if TYPE_CHECKING: + from homeassistant.config_entries import ConfigEntry + from homeassistant.core import HomeAssistant + from homeassistant.helpers.entity_platform import AddEntitiesCallback + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Create the three output sensors for this config entry.""" + entities = [ + AdaptiveOutputSensor( + hass=hass, + entry=config_entry, + output_key=row["key"], + display_name=row["name"], + unit=row["unit"], + icon=row["icon"], + ) + for row in OUTPUT_SENSORS + ] + async_add_entities(entities) + + +class AdaptiveOutputSensor(SensorEntity): + """One read-only output value from an AL profile's curve tick.""" + + _attr_has_entity_name = True + _attr_should_poll = False + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_device_class = None # explicit — no fitting HA device class + + def __init__( + self, + *, + hass: HomeAssistant, + entry: ConfigEntry, + output_key: str, + display_name: str, + unit: str, + icon: str, + ) -> None: + """Initialise a single output sensor entity.""" + self._hass = hass + self._entry = entry + self._output_key = output_key + self._attr_name = display_name + self._attr_translation_key = output_key + self._attr_unique_id = f"{entry.entry_id}_{output_key}" + self._attr_native_unit_of_measurement = unit + self._attr_icon = icon + # Renders as `unknown` until the first dispatcher signal arrives. + # Do NOT use RestoreEntity — restored values would be stale (the + # sun has moved); `unknown` for one tick is honest. + self._attr_native_value = None + + @property + def device_info(self) -> DeviceInfo: + """Group with the profile's switches and number entities.""" + profile_name = self._entry.data.get("name") or self._entry.title + return DeviceInfo( + identifiers={(DOMAIN, profile_name)}, + name=profile_name, + entry_type=DeviceEntryType.SERVICE, + ) + + async def async_added_to_hass(self) -> None: + """Subscribe to the per-entry outputs-updated dispatcher signal.""" + await super().async_added_to_hass() + signal = SIGNAL_OUTPUTS_UPDATED.format(entry_id=self._entry.entry_id) + self.async_on_remove( + async_dispatcher_connect( + self._hass, + signal, + self._handle_outputs_updated, + ), + ) + + @callback + def _handle_outputs_updated(self) -> None: + """Read this sensor's value from the cache and write state.""" + outputs = ( + self._hass.data.get(DOMAIN, {}) + .get(self._entry.entry_id, {}) + .get("outputs") + ) + if not outputs: + # Early signal (e.g., setup race) — leave state as unknown. + return + self._attr_native_value = outputs.get(self._output_key) + self.async_write_ha_state() diff --git a/custom_components/adaptive_lighting/strings.json b/custom_components/adaptive_lighting/strings.json index 66f97ba6..badb49c8 100644 --- a/custom_components/adaptive_lighting/strings.json +++ b/custom_components/adaptive_lighting/strings.json @@ -258,6 +258,17 @@ "max_color_temp": { "name": "Max color temp" } + }, + "sensor": { + "output_brightness": { + "name": "Output brightness" + }, + "output_color_temp": { + "name": "Output color temp" + }, + "sun_elevation": { + "name": "Sun elevation" + } } } } diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index d4f96aab..1835051f 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -57,6 +57,7 @@ from homeassistant.core import ( ) from homeassistant.helpers import entity_platform, entity_registry from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity_component import async_update_entity from homeassistant.helpers.event import ( EventStateChangedData, @@ -112,6 +113,7 @@ from .const import ( RAMP_HALF_WIDTH_SECONDS, SERVICE_APPLY, SERVICE_CHANGE_SWITCH_SETTINGS, + SIGNAL_OUTPUTS_UPDATED, TURNING_OFF_DELAY, VALIDATION_TUPLES, apply_service_schema, @@ -1351,6 +1353,34 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): data, ) + def _publish_outputs_and_wake_sensors(self) -> None: + """Publish curve outputs to the runtime cache and wake the sensors. + + 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 + ``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. + """ + sun_state = self.hass.states.get("sun.sun") + sun_elevation = ( + sun_state.attributes.get("elevation") if sun_state is not None else None + ) + brightness = self._settings.get("brightness_pct") + color_temp = self._settings.get("color_temp_kelvin") + 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, + "updated_at": dt_util.utcnow(), + } + async_dispatcher_send( + self.hass, + SIGNAL_OUTPUTS_UPDATED.format(entry_id=self._config_entry.entry_id), + ) + async def _update_attrs_and_maybe_adapt_lights( self, *, @@ -1380,6 +1410,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): t_sunset, ), ) + self._publish_outputs_and_wake_sensors() self.async_write_ha_state() if lights is None: diff --git a/custom_components/adaptive_lighting/translations/en.json b/custom_components/adaptive_lighting/translations/en.json index 66f97ba6..badb49c8 100644 --- a/custom_components/adaptive_lighting/translations/en.json +++ b/custom_components/adaptive_lighting/translations/en.json @@ -258,6 +258,17 @@ "max_color_temp": { "name": "Max color temp" } + }, + "sensor": { + "output_brightness": { + "name": "Output brightness" + }, + "output_color_temp": { + "name": "Output color temp" + }, + "sun_elevation": { + "name": "Sun elevation" + } } } } diff --git a/openspec/changes/add-output-sensors/design.md b/openspec/changes/add-output-sensors/design.md index 04e7249e..98febad9 100644 --- a/openspec/changes/add-output-sensors/design.md +++ b/openspec/changes/add-output-sensors/design.md @@ -1,6 +1,8 @@ ## Context -After `cdit-config-redesign` and `add-runtime-range-controls`, the master switch (`AdaptiveSwitch`) computes the curve outputs — brightness %, color-temperature K, and the sun-position float driving the curve — once per `interval` tick (default 90 s) and exposes them as **attributes** (`current_brightness`, `current_color_temp`, `sun_position`). The values are visible in the master switch's More-info dialog and in diagnostics downloads, but HA's recorder stores `attributes` as text-on-state, not as numeric columns. The History panel, `apexcharts-card`, and `mini-graph-card` all consume entity *state*, not attributes. +After `cdit-config-redesign` and `add-runtime-range-controls`, the master switch (`AdaptiveSwitch`) computes the curve outputs — brightness %, color-temperature K — once per `interval` tick (default 90 s) and exposes them as **attributes** via `self._settings` (`brightness_pct`, `color_temp_kelvin`, and a synthetic `sun_position` in [-1, +1] derived from the brightness curve). The values are visible in the master switch's More-info dialog and in diagnostics downloads, but HA's recorder stores `attributes` as text-on-state, not as numeric columns. The History panel, `apexcharts-card`, and `mini-graph-card` all consume entity *state*, not attributes. + +The integration does not currently expose actual solar elevation. HA's built-in `sun.sun` entity carries an `elevation` attribute (float degrees, range -90 to +90, updated continuously by HA's `sun` integration). This change adds it as a graphable sensor alongside the curve outputs — useful for visualizing the astronomical driver next to the integration's response. The fix is to expose those three already-computed values as dedicated `sensor` entities. The curve math doesn't change; the sensor platform is a thin presentation layer over values the switch is already computing. @@ -9,11 +11,11 @@ The architectural question this design resolves is the **read path**: how do thr ## Goals / Non-Goals **Goals:** -- Three sensor entities per AL profile (`output_brightness`, `output_color_temp`, `sun_position`), each `SensorStateClass.MEASUREMENT` so HA's recorder graphs them as numerics. +- Three sensor entities per AL profile (`output_brightness`, `output_color_temp`, `sun_elevation`), each `SensorStateClass.MEASUREMENT` so HA's recorder graphs them as numerics. - Single computation path. Curve evaluation happens once per tick in the master switch's adapt loop; sensors are pure readers. - Push-based update: when the master switch finishes computing, sensors are notified and write their state. No polling, no duplicated `async_track_time_interval` timers. - `has_entity_name = True` following the convention from `add-runtime-range-controls` Decision 11. Friendly names compose ` ` and fit HA's narrow-card truncation window. -- Purely additive — the existing master-switch attributes (`current_brightness`, etc.) remain in place. Anyone reading them today keeps working. +- Purely additive — the existing master-switch attributes (`brightness_pct`, `color_temp_kelvin`, `sun_position`, etc.) remain in place. Anyone reading them today keeps working. **Non-Goals:** - Removing or deprecating the existing master-switch attributes. They are diagnostic surface; their cost is zero and removing them would break diagnostics downloads. @@ -37,24 +39,24 @@ The architectural question this design resolves is the **read path**: how do thr ### Decision 2: Master switch publishes outputs to `hass.data`; sensors read from there -**What we chose:** After each curve evaluation in `AdaptiveSwitch._async_update_attrs` (or wherever the integration currently sets `current_brightness` / `current_color_temp` / `sun_position`), publish the same three values to: +**What we chose:** After each curve evaluation in `AdaptiveSwitch._update_attrs_and_maybe_adapt_lights` (where `self._settings` is populated from `SunLightSettings.get_settings()`), publish the values to: ```python hass.data[DOMAIN][entry.entry_id]["outputs"] = { - "output_brightness": , - "output_color_temp": , - "sun_position": , + "output_brightness": , # from self._settings["brightness_pct"] + "output_color_temp": , # from self._settings["color_temp_kelvin"] + "sun_elevation": , # from hass.states.get("sun.sun").attributes["elevation"] "updated_at": , } ``` -The cache dict keys match the sensor `OUTPUT_SENSORS[*]["key"]` values exactly, so a sensor reads `hass.data[DOMAIN][entry.entry_id]["outputs"][self._output_key]` with no intermediate mapping. The master switch's existing attributes (`current_brightness`, `current_color_temp`, `sun_position`) are unchanged — the publish step copies the computed values into the cache under the new key names; the old attributes remain on the switch for backward compatibility (per proposal). +The cache dict keys match the sensor `OUTPUT_SENSORS[*]["key"]` values exactly, so a sensor reads `hass.data[DOMAIN][entry.entry_id]["outputs"][self._output_key]` with no intermediate mapping. The master switch's existing attributes (`brightness_pct`, `color_temp_kelvin`, `sun_position` in [-1, +1]) are unchanged — the publish step copies the relevant values into the cache under the new key names; the old attributes remain on the switch for backward compatibility (per proposal). The new `sun_elevation` value is read fresh on each tick from `sun.sun` and has no counterpart on the master switch. Sensors read from this dict at write-state time. The master switch writes; sensors read. One direction. **Why:** Three options were on the table: - **(a) Each sensor recomputes the curve.** Rejected — duplicates the curve math three times per tick; introduces drift risk if computation isn't bit-identical; sensors would need access to all the inputs (sun entities, range numbers, options) which inverts the dependency. -- **(b) Sensors read master-switch attributes via `hass.states.get().attributes["current_brightness"]`.** Rejected — couples sensor lifecycle to the switch entity's published state, which is async (state lags compute by one event-bus hop), and breaks if the attribute key ever renames. +- **(b) Sensors read master-switch attributes via `hass.states.get().attributes["brightness_pct"]`.** Rejected — couples sensor lifecycle to the switch entity's published state, which is async (state lags compute by one event-bus hop), and breaks if the attribute key ever renames. Also doesn't help with `sun_elevation`, which has no source on the master switch. - **(c) Master publishes to a runtime dict; sensors read from it.** Chosen — single source of computation, single source of truth at runtime, sensors are pure consumers. Tests mock the dict directly. The `hass.data[DOMAIN][entry.entry_id]` pattern is the HA-idiomatic per-entry runtime cache; the integration already uses it for other state. @@ -86,25 +88,25 @@ Each sensor subscribes to this exact signal in its `async_added_to_hass`. On rec |---|---|---|---|---|---|---| | Output brightness | `_output_brightness` | `"Output brightness"` | `"%"` | `MEASUREMENT` | (none) | `mdi:brightness-percent` | | Output color temperature | `_output_color_temp` | `"Output color temp"` | `"K"` | `MEASUREMENT` | (none) | `mdi:thermometer` | -| Sun position | `_sun_position` | `"Sun position"` | `"°"` | `MEASUREMENT` | (none) | `mdi:weather-sunset` | +| Sun elevation | `_sun_elevation` | `"Sun elevation"` | `"°"` | `MEASUREMENT` | (none) | `mdi:weather-sunset` | **Why:** - `state_class: MEASUREMENT` is the trigger that makes HA's recorder graph the entity as a numeric series and surface it in the History panel + `apexcharts-card` automatically. This is the entire reason the change exists. -- No `device_class`: `SensorDeviceClass.TEMPERATURE` is for thermal sensors (°C/°F), not color temperature; brightness % has no fitting device class in HA's enum; sun-position degrees has no `device_class` enum value (HA has no `ELEVATION` or `ANGLE` class). Setting a device class wrongly forces HA's UI into the wrong unit-conversion behavior and is worse than setting none. -- Units: `"%"`, `"K"`, and `"°"` are all accepted by HA as free-form `native_unit_of_measurement` strings; they render in the UI without unit conversion. Sun position is the solar-elevation angle sourced from the user's configured Sun2 / `sensor.sun_*` entity (range roughly -90 to +90). -- Icons: brightness-percent for brightness pairs visually with the `mdi:brightness-3` / `mdi:brightness-7` already used on the range numbers; thermometer for color temp matches the range-number convention; weather-sunset for sun position is self-explanatory and ties the value to its source. +- No `device_class`: `SensorDeviceClass.TEMPERATURE` is for thermal sensors (°C/°F), not color temperature; brightness % has no fitting device class in HA's enum; HA has no `ELEVATION` or `ANGLE` device class for sun elevation either. Setting a device class wrongly forces HA's UI into the wrong unit-conversion behavior and is worse than setting none. +- Units: `"%"`, `"K"`, and `"°"` are all accepted by HA as free-form `native_unit_of_measurement` strings; they render in the UI without unit conversion. Sun elevation is the angle of the sun above (positive) or below (negative) the horizon, in degrees, range -90 to +90. +- Icons: brightness-percent for brightness pairs visually with the `mdi:brightness-3` / `mdi:brightness-7` already used on the range numbers; thermometer for color temp matches the range-number convention; weather-sunset for sun elevation is self-explanatory. **Naming rationale (the asymmetric "Output" prefix):** - `"Output brightness"` — `"Brightness"` collides with the adapt-brightness switch's friendly name ("Dining MVP Brightness") from `add-runtime-range-controls` R7. `"Output"` resolves the collision and parallels the capability name (`output-sensors`). - `"Output color temp"` — no hard collision (the switch is `"Color"`, not `"Color temp"`), but kept the prefix for parallelism with brightness AND to disambiguate from the existing `"Min color temp"` / `"Max color temp"` number entities on the same device. -- `"Sun position"` — no prefix needed; no other entity on the device uses `"Sun"` in its name. Adding `"Output sun position"` would be inaccurate anyway — sun position is the curve's *input*, not its output. +- `"Sun elevation"` — no prefix needed; no other entity on the device uses `"Sun"` in its name. Adding `"Output sun elevation"` would be inaccurate — sun elevation is not an integration output, it's a passthrough from `sun.sun`. **Alternatives considered:** - **`SensorDeviceClass.ILLUMINANCE`** for brightness. Rejected — illuminance is lux (a measured external value), not a target output percentage. - **No `state_class`** to leave the recorder behavior implicit. Rejected — explicit `MEASUREMENT` is what makes the recorder treat the values as graphable; omitting it defeats the change. - **Naming as `"Brightness"` / `"Color temp"` / `"Sun"`.** Rejected — collides with `"Brightness"` switch; `"Color temp"` is also ambiguous next to `"Min/Max color temp"` numbers. -- **Naming as `"Current brightness"` / `"Current color temp"` / `"Sun position"`.** Considered (matches existing master-switch attribute keys `current_brightness`, `current_color_temp`). Rejected in favor of `"Output X"` — capability name parallel, and "current" is redundant when HA's More-info dialog already shows a current value. -- **Symmetric `"Output sun position"`.** Rejected — sun position is an input to the curve, not an output; the prefix would be inaccurate. +- **Exposing the master switch's synthetic `sun_position` ([-1, +1]) instead of actual elevation.** Rejected — the synthetic value is a curve internal; users grep "where is the sun?" want degrees, not a normalized ratio. (Considered, then explicitly rejected; the synthetic attribute stays available for anyone who wants it.) +- **Symmetric `"Output sun elevation"`.** Rejected — `sun.sun` owns the elevation value; the integration is a passthrough. ### Decision 5: Sensor state before first tick is `STATE_UNKNOWN` @@ -132,7 +134,7 @@ Each sensor subscribes to this exact signal in its `async_added_to_hass`. On rec OUTPUT_SENSORS = [ {"key": "output_brightness", "name": "Output brightness", "unit": "%", "icon": "mdi:brightness-percent"}, {"key": "output_color_temp", "name": "Output color temp", "unit": "K", "icon": "mdi:thermometer"}, - {"key": "sun_position", "name": "Sun position", "unit": "°", "icon": "mdi:weather-sunset"}, + {"key": "sun_elevation", "name": "Sun elevation", "unit": "°", "icon": "mdi:weather-sunset"}, ] ``` @@ -144,7 +146,19 @@ Sensor platform iterates this list. Tests reference it. - **Three separate sensor classes** (`BrightnessSensor`, `ColorTempSensor`, `SunPositionSensor`). Rejected — each class would be 90% identical; the dict-driven instantiation is shorter and easier to extend. - **Class hierarchy with a base + three subclasses.** Rejected — overengineering for three uniform sensors with no behavioral divergence. -### Decision 8: No service surface, no options-flow surface +### Decision 8: `sun_elevation` is read from `sun.sun.attributes["elevation"]` on every curve tick + +**What we chose:** During the master switch's curve-tick publish step, the integration reads `hass.states.get("sun.sun")` and writes `state.attributes.get("elevation")` (a float in degrees, range -90 to +90) into the cache as `outputs["sun_elevation"]`. If `sun.sun` is missing or the attribute is absent, the cache value is `None` and the sensor renders as `unknown`. + +**Why:** `sun.sun` is a built-in HA entity present in every install — no integration check needed. Its `elevation` attribute is updated continuously by HA's `sun` integration (every ~30 s by default), so reading it once per AL curve tick (every 90 s default) is fresh enough. Reading at tick-time keeps all three sensors on the same update rhythm — one dispatcher signal, all three sensors update together. + +**Alternatives considered:** +- **Subscribe to `state_changed` on `sun.sun` and update `sun_elevation` independently of the curve tick.** Rejected — adds a second update path with a different rhythm. Two sensors updating at 90 s and one at 30 s in the same dashboard card looks like a bug. The 60 s latency penalty is invisible (the sun moves about 0.25° in 60 s; below the integration's already-coarse "degree" rendering). +- **Use the configured `CONF_SUNRISE_ENTITY` / `CONF_SUNSET_ENTITY` source.** Rejected — those are timestamp sensors (next-rising / next-setting), not elevation sensors. Different domain. +- **Add a new `CONF_SUN_ELEVATION_ENTITY` config field defaulting to `sun.sun`.** Rejected — yet another knob; `sun.sun` works for everyone. Re-evaluate if anyone ever has a Sun2 elevation override use case. +- **Drop the `sun_elevation` sensor entirely.** Considered (option C from Q&A) and rejected — actual sun elevation is the canonical "where in the day are we?" data the user wants alongside the brightness/CT curve. Without it, the third sensor slot is missing the most-asked-for value. + +### Decision 9: No service surface, no options-flow surface **What we chose:** This change adds no services, no options-flow fields, no new config keys. The integration's `services.yaml`, `config_flow.py`, `strings.json` (apart from the three sensor name keys) are untouched. @@ -158,6 +172,8 @@ Sensor platform iterates this list. Tests reference it. - **[Master switch and sensor compute paths could diverge in a future refactor]** → Mitigation: the master switch is the only place curve math runs; sensors do not duplicate it. Anyone moving curve math out of the master switch in the future will see the `hass.data[DOMAIN][entry_id]["outputs"]` publish line and the dispatcher signal as the explicit handoff; refactoring without preserving this contract would visibly break the sensors. Test 5.x asserts the contract. - **[Sensor state stays `unknown` if the master switch's curve loop never runs]** → Possible if the switch fails to set up. Mitigation: that failure mode is already user-visible (the master switch entity itself shows as `unavailable`); the sensors merely echo it. No new debugging surface. - **[Recorder explosion: 3 sensors × N profiles × MEASUREMENT state_class]** → For a 6-profile household (CDiT's case), that's 18 new sensors writing one row every 90 s = ~17 280 rows/day. Recorder + statistics handle this without strain; the values compress well. Mitigation: `recorder` config's `purge_keep_days` default (10 days) bounds disk usage; no action needed. +- **[`sun.sun` entity missing or `elevation` attribute absent]** → Possible during very early HA startup before the `sun` integration finishes loading, or in unusual deployments that disable `sun`. Mitigation: read with `.attributes.get("elevation")` so the cache value is `None`; the sensor renders as `unknown` until the next tick where `sun.sun` is populated. No exception is raised; the other two sensors continue updating normally. +- **[Two "sun" values present: `sensor.adaptive_lighting__sun_elevation` (degrees) vs. master switch's `sun_position` attribute ([-1, +1])]** → Could confuse users grepping for "sun" in diagnostics. Mitigation: the names are distinct ("elevation" vs "position") and the units differ; the README change should call out both with a one-line "these are different things" note. - **[Dispatcher signal name collision across integrations or future changes]** → The signal `{DOMAIN}_{entry_id}_outputs_updated` is keyed both by domain and entry ID, so collisions are impossible across integrations (domain prefix) and across profiles (entry_id suffix). Future intra-domain signals should follow the same pattern. - **[Sensor entities appear during the brief window when the master switch hasn't yet computed]** → Sensors show `unknown` for up to 90 s. Mitigation: documented; this is the recorder-correct behavior. Users seeing `unknown` in a dashboard for the first time after a restart can re-check in a minute. - **[Friendly-name "Output brightness" / "Output color temp" approaches HA's narrow-card truncation point (~28 char window)]** → For a profile titled "Dining MVP", "Dining MVP Output brightness" is exactly 28 characters; longer profile titles will truncate to "Dining MVP Output bright…" or worse. Mitigation: users can rename the entity in HA's UI if truncation bothers them; the entity-ID slug is independent of the friendly name. This is the same trade-off accepted in `add-runtime-range-controls` R7 for the four range numbers. @@ -173,8 +189,8 @@ Single PR on the fork's `main` branch. Depends on `cdit-config-redesign` and `ad 5. Tag release (`v2.3.0-cdit.1` or whatever the next minor is) — minor bump, no breaking change. 6. Existing config entries pick up three new sensors on next HA restart. No user action required. -**Rollback:** revert the PR. The three sensor entities disappear from the entity registry; the master switch attributes (`current_brightness` etc.) remain unchanged because they were never removed. Any History/`apexcharts-card` configs pointing at the new sensors will show "entity not found" until the rollback is reverted again. No data loss. +**Rollback:** revert the PR. The three sensor entities disappear from the entity registry; the master switch attributes (`brightness_pct`, `color_temp_kelvin`, `sun_position`, etc. — all from `self._settings`) remain unchanged because they were never removed. Any History/`apexcharts-card` configs pointing at the new sensors will show "entity not found" until the rollback is reverted again. No data loss. ## Open Questions -None. All eight decisions resolved. The "should sensors be `RestoreSensor`?" question is settled by Decision 5 (no — stale data is misleading; `unknown` for one tick is honest). +None. All nine decisions resolved. The "should sensors be `RestoreSensor`?" question is settled by Decision 5 (no — stale data is misleading; `unknown` for one tick is honest). The "where does sun elevation come from?" question is settled by Decision 8 (`sun.sun.attributes.elevation`). diff --git a/openspec/changes/add-output-sensors/proposal.md b/openspec/changes/add-output-sensors/proposal.md index 1258197f..e5d7ec46 100644 --- a/openspec/changes/add-output-sensors/proposal.md +++ b/openspec/changes/add-output-sensors/proposal.md @@ -1,17 +1,19 @@ ## Why -The integration's runtime outputs — current brightness %, current color temperature in K, and the sun-position value driving the curve — exist today only as `attributes` on the master switch entity (`current_brightness`, `current_color_temp`, `sun_position`). HA's recorder stores attribute values as text-on-state, so the History panel, `apexcharts-card`, `mini-graph-card`, and any "show me yesterday's AL curve" automation can't graph them as numerics. The values are visible but not analytically usable. +The integration's runtime outputs — current target brightness %, current target color temperature in K — exist today only as `attributes` on the master switch entity (`brightness_pct`, `color_temp_kelvin`, alongside synthetic `sun_position` and others, all from `self._settings`). HA's recorder stores attribute values as text-on-state, so the History panel, `apexcharts-card`, `mini-graph-card`, and any "show me yesterday's AL curve" automation can't graph them as numerics. The values are visible but not analytically usable. + +Separately, the actual solar elevation angle (degrees, sourced from HA's built-in `sun.sun` entity's `elevation` attribute) is genuinely useful to graph alongside the curve outputs — it shows the astronomical driver next to the integration's response. The master switch does not currently expose this value at all (its `sun_position` attribute is a synthetic [-1, +1] float derived from the brightness curve, not real elevation). This change promotes those three outputs to first-class `sensor` entities per AL profile, with `SensorStateClass.MEASUREMENT` so the recorder graphs them and stock cards consume them natively. This is the explicit complement to the just-killed `add-lovelace-card` proposal: stock Lovelace + `apexcharts-card` becomes the charting story once the data lives on entities the cards can read. ## What Changes - **Add a `sensor` platform** to the integration. Each AL config entry creates three sensor entities: - - `sensor.adaptive_lighting__output_brightness` — current target brightness, integer `%`, range 0–100, `state_class: measurement`, `icon: mdi:brightness-percent`. "Output" prefix disambiguates from the existing "Brightness" switch and "Min/Max brightness" number entities on the same device. - - `sensor.adaptive_lighting__output_color_temp` — current target color temperature, integer `K`, range 1000–10000, `state_class: measurement`, no `device_class` (`SensorDeviceClass.TEMPERATURE` is wrong; color temp is not thermal), `icon: mdi:thermometer`. "Output" prefix maintains parallelism with brightness and disambiguates from "Min/Max color temp" numbers. - - `sensor.adaptive_lighting__sun_position` — solar-elevation angle driving the curve, float in degrees (range roughly -90 to +90, sourced from the user's configured Sun2 / `sensor.sun_*` entity), `state_class: measurement`, `unit: "°"`, `icon: mdi:weather-sunset`. Useful for debugging the curve shape via the History panel. No prefix needed; no existing entity uses "Sun" in its name. + - `sensor.adaptive_lighting__output_brightness` — current target brightness sourced from `self._settings["brightness_pct"]`, integer `%`, range 0–100, `state_class: measurement`, `icon: mdi:brightness-percent`. "Output" prefix disambiguates from the existing "Brightness" switch and "Min/Max brightness" number entities on the same device. + - `sensor.adaptive_lighting__output_color_temp` — current target color temperature sourced from `self._settings["color_temp_kelvin"]`, integer `K`, range 1000–10000, `state_class: measurement`, no `device_class` (`SensorDeviceClass.TEMPERATURE` is wrong; color temp is not thermal), `icon: mdi:thermometer`. "Output" prefix maintains parallelism with brightness and disambiguates from "Min/Max color temp" numbers. + - `sensor.adaptive_lighting__sun_elevation` — solar elevation angle in degrees, sourced from HA's built-in `sun.sun` entity's `elevation` attribute (range roughly -90 to +90), `state_class: measurement`, `unit: "°"`, `icon: mdi:weather-sunset`. Read on every curve tick alongside the brightness/color outputs. No prefix needed; no existing entity uses "Sun" in its name. Distinct from the master switch's existing synthetic `sun_position` attribute (which stays unchanged for backward compatibility). - **Single read path**: sensors read the same curve-math outputs the master switch's adapt loop already computes — no second computation pass. The sensors update on the same tick as the existing curve evaluation (`interval` setting, default 90 s). -- **`has_entity_name = True`** following the convention established in `add-runtime-range-controls` Decision 11. Friendly names become ` Output brightness`, ` Output color temp`, ` Sun position`. +- **`has_entity_name = True`** following the convention established in `add-runtime-range-controls` Decision 11. Friendly names become ` Output brightness`, ` Output color temp`, ` Sun elevation`. - **No removal of the existing master-switch attributes.** They stay for backward compat with anyone reading them today (including the integration's own diagnostics). The sensors are additive; users adopt them at their own pace. - **No service surface changes.** Sensors are read-only by nature; nothing to call. @@ -27,7 +29,7 @@ None. Sensors are purely additive: existing switches, number entities, and optio ## Impact -- **`custom_components/adaptive_lighting/sensor.py`** — new file, `sensor` platform implementation. One sensor class (`AdaptiveOutputSensor`) parameterized by output key (`brightness` | `color_temp` | `sun_position`); three instances per config entry. +- **`custom_components/adaptive_lighting/sensor.py`** — new file, `sensor` platform implementation. One sensor class (`AdaptiveOutputSensor`) parameterized by output key (`output_brightness` | `output_color_temp` | `sun_elevation`); three instances per config entry. - **`custom_components/adaptive_lighting/const.py`** — `Platform.SENSOR` appended to the platform list; sensor unique-id pattern constants; output-key enum. - **`custom_components/adaptive_lighting/__init__.py`** — `async_setup_entry` forwards setup to the new sensor platform. Master switch's adapt loop publishes its computed outputs to a per-entry runtime data structure (`hass.data[DOMAIN][entry.entry_id]`) that the sensors read on update — or sensors read the master switch's existing computed attributes directly, TBD in design. - **`custom_components/adaptive_lighting/switch.py`** — no behavioral change; if the design picks the "publish to `hass.data`" approach, the master switch gains one or two lines to update that dict. diff --git a/openspec/changes/add-output-sensors/specs/output-sensors/spec.md b/openspec/changes/add-output-sensors/specs/output-sensors/spec.md index b9279843..e09c8132 100644 --- a/openspec/changes/add-output-sensors/specs/output-sensors/spec.md +++ b/openspec/changes/add-output-sensors/specs/output-sensors/spec.md @@ -8,7 +8,7 @@ For each Adaptive Lighting config entry, the integration SHALL create exactly th |---|---|---|---|---|---|---| | Output brightness | `_output_brightness` | `"Output brightness"` | `"%"` | `MEASUREMENT` | (none) | `mdi:brightness-percent` | | Output color temperature | `_output_color_temp` | `"Output color temp"` | `"K"` | `MEASUREMENT` | (none) | `mdi:thermometer` | -| Sun position | `_sun_position` | `"Sun position"` | `"°"` | `MEASUREMENT` | (none) | `mdi:weather-sunset` | +| Sun elevation | `_sun_elevation` | `"Sun elevation"` | `"°"` | `MEASUREMENT` | (none) | `mdi:weather-sunset` | The full `unique_id` SHALL be `_`. @@ -17,7 +17,7 @@ The full `unique_id` SHALL be `_`. - **WHEN** the user creates a new Adaptive Lighting config entry - **AND** `async_setup_entry` completes - **THEN** the entity registry SHALL contain three `sensor` entities owned by this entry -- **AND** their unique_ids SHALL end with `_output_brightness`, `_output_color_temp`, and `_sun_position` respectively +- **AND** their unique_ids SHALL end with `_output_brightness`, `_output_color_temp`, and `_sun_elevation` respectively - **AND** all three sensors SHALL be attached to the same device as the profile's switches and number entities #### Scenario: Sensor metadata matches the design table @@ -25,11 +25,13 @@ The full `unique_id` SHALL be `_`. - **WHEN** any of the three sensors is inspected via the entity registry - **THEN** `output_brightness` SHALL declare `native_unit_of_measurement="%"`, `state_class=SensorStateClass.MEASUREMENT`, no `device_class` - **AND** `output_color_temp` SHALL declare `native_unit_of_measurement="K"`, `state_class=SensorStateClass.MEASUREMENT`, no `device_class` -- **AND** `sun_position` SHALL declare `native_unit_of_measurement="°"`, `state_class=SensorStateClass.MEASUREMENT`, no `device_class` +- **AND** `sun_elevation` SHALL declare `native_unit_of_measurement="°"`, `state_class=SensorStateClass.MEASUREMENT`, no `device_class` ### Requirement: Curve evaluation publishes outputs to a runtime cache -On every curve evaluation tick, the master switch (`AdaptiveSwitch`) SHALL publish its three computed output values to `hass.data[DOMAIN][entry.entry_id]["outputs"]` as a dictionary with keys `output_brightness` (int 0-100), `output_color_temp` (int Kelvin), `sun_position` (float, degrees of solar elevation), and `updated_at` (datetime). This publish SHALL happen after the curve math completes and before any state writes to the switch's own attributes. +On every curve evaluation tick, the master switch (`AdaptiveSwitch`) SHALL publish to `hass.data[DOMAIN][entry.entry_id]["outputs"]` a dictionary with keys `output_brightness` (int 0-100, sourced from `self._settings["brightness_pct"]`), `output_color_temp` (int Kelvin, sourced from `self._settings["color_temp_kelvin"]`), `sun_elevation` (float degrees or `None`, sourced from `hass.states.get("sun.sun").attributes.get("elevation")`), and `updated_at` (datetime). This publish SHALL happen after the curve math completes and before any state writes to the switch's own attributes. + +If `sun.sun` is missing from the state machine or its `elevation` attribute is absent, `sun_elevation` in the cache SHALL be `None`; the other three keys SHALL still be populated normally. The cache dict keys SHALL match the `OUTPUT_SENSORS[*]["key"]` values exactly, so sensors read `hass.data[DOMAIN][entry.entry_id]["outputs"][self._output_key]` with no intermediate mapping. @@ -38,16 +40,26 @@ The integration SHALL NOT cause the sensor entities to recompute the curve. Sens #### Scenario: Each curve tick refreshes the runtime cache - **GIVEN** the integration is loaded and the master switch's adapt loop is running +- **AND** `sun.sun.attributes.elevation` is populated - **WHEN** a curve evaluation tick completes -- **THEN** `hass.data[DOMAIN][entry.entry_id]["outputs"]` SHALL contain the four keys `output_brightness`, `output_color_temp`, `sun_position`, `updated_at` -- **AND** the values SHALL be the just-computed curve outputs +- **THEN** `hass.data[DOMAIN][entry.entry_id]["outputs"]` SHALL contain the four keys `output_brightness`, `output_color_temp`, `sun_elevation`, `updated_at` +- **AND** the values SHALL be the just-computed curve outputs (for the first two) and the current `sun.sun` elevation (for the third) - **AND** `updated_at` SHALL be a `datetime` no older than the previous tick's `updated_at` value +#### Scenario: `sun.sun` unavailability does not block the publish + +- **GIVEN** the integration is loaded and the master switch's adapt loop is running +- **AND** `hass.states.get("sun.sun")` returns `None` (or its `elevation` attribute is absent) +- **WHEN** a curve evaluation tick completes +- **THEN** `hass.data[DOMAIN][entry.entry_id]["outputs"]["sun_elevation"]` SHALL be `None` +- **AND** `output_brightness` and `output_color_temp` SHALL still hold their computed values +- **AND** no exception SHALL propagate out of the publish step + #### Scenario: Sensors do not perform their own curve math - **WHEN** a sensor entity's `async_added_to_hass` and `_handle_outputs_updated` methods are inspected - **THEN** neither method SHALL import `SunLightSettings` or any curve-computation helper -- **AND** neither method SHALL call `hass.states.get` for the sun-time entities or the four range number entities +- **AND** neither method SHALL call `hass.states.get` for the sun-time entities, the four range number entities, or `sun.sun` ### Requirement: Sensors update via a per-entry dispatcher signal @@ -98,7 +110,7 @@ Sensors SHALL NOT extend `RestoreEntity` or `RestoreSensor`. On entity addition ### Requirement: Sensors follow the `has_entity_name` composition -Every sensor entity created by this change SHALL set `_attr_has_entity_name = True` and SHALL register under the existing device record (`(DOMAIN, entry.entry_id)`) whose `name` is `entry.title`. Each sensor's `_attr_name` SHALL be exactly the role label from the table in the first requirement of this spec: `"Output brightness"`, `"Output color temp"`, `"Sun position"`. +Every sensor entity created by this change SHALL set `_attr_has_entity_name = True` and SHALL register under the existing per-profile device record whose `name` matches the profile's display name (i.e. attached to the same device as the profile's switches and number entities). Each sensor's `_attr_name` SHALL be exactly the role label from the table in the first requirement of this spec: `"Output brightness"`, `"Output color temp"`, `"Sun elevation"`. The resulting friendly names SHALL follow this table for a profile named `Dining MVP`: @@ -106,7 +118,7 @@ The resulting friendly names SHALL follow this table for a profile named `Dining |---|---|---| | Output-brightness sensor | `"Output brightness"` | `Dining MVP Output brightness` | | Output-color-temp sensor | `"Output color temp"` | `Dining MVP Output color temp` | -| Sun-position sensor | `"Sun position"` | `Dining MVP Sun position` | +| Sun-elevation sensor | `"Sun elevation"` | `Dining MVP Sun elevation` | The chosen role labels SHALL NOT collide with any existing entity's `_attr_name` on the same device (specifically: not `"Brightness"`, which is the adapt-brightness switch's role per `add-runtime-range-controls`, and not `"Min color temp"` / `"Max color temp"`, which are the range-number roles). @@ -116,7 +128,7 @@ The chosen role labels SHALL NOT collide with any existing entity's `_attr_name` - **WHEN** the integration loads and the three sensors are registered - **THEN** the output-brightness sensor's friendly name SHALL be exactly "Dining MVP Output brightness" - **AND** the output-color-temp sensor's friendly name SHALL be exactly "Dining MVP Output color temp" -- **AND** the sun-position sensor's friendly name SHALL be exactly "Dining MVP Sun position" +- **AND** the sun-elevation sensor's friendly name SHALL be exactly "Dining MVP Sun elevation" #### Scenario: Sensor friendly names do not collide with switch or number friendly names diff --git a/openspec/changes/add-output-sensors/tasks.md b/openspec/changes/add-output-sensors/tasks.md index d66015fe..7b984081 100644 --- a/openspec/changes/add-output-sensors/tasks.md +++ b/openspec/changes/add-output-sensors/tasks.md @@ -6,7 +6,7 @@ Annotations: R3 = "Sensors update via a per-entry dispatcher signal" R4 = "Sensors report STATE_UNKNOWN before the first curve tick" R5 = "Sensors follow the has_entity_name composition" - D1–D8 = Decisions in design.md + D1–D9 = Decisions in design.md (D8 = sun.sun read path for sun_elevation) polish = Quality/UX tasks not directly traceable to a requirement Build order: group 1 is platform foundation. Group 2 wires the master switch's @@ -17,61 +17,62 @@ validation gate. ## 1. Platform foundation — `sensor.py` skeleton and `const.py` mapping -- [ ] 1.1 Add `Platform.SENSOR` to the `PLATFORMS` list in `__init__.py` so HA forwards `async_setup_entry` to the new platform. [R1, D7] -- [ ] 1.2 In `const.py`, add an `OUTPUT_SENSORS` list with three entries — each a dict of `key`, `name`, `unit`, `icon`. Keys: `output_brightness` / `output_color_temp` / `sun_position`. Names: `"Output brightness"` / `"Output color temp"` / `"Sun position"`. Units: `"%"` / `"K"` / `"°"`. Icons: `mdi:brightness-percent` / `mdi:thermometer` / `mdi:weather-sunset`. One source for setup + tests. [R1, D4, D7] -- [ ] 1.3 Create `custom_components/adaptive_lighting/sensor.py` with `async_setup_entry(hass, config_entry, async_add_entities)` that instantiates three entities (one per entry in `OUTPUT_SENSORS`) and calls `async_add_entities(entities)`. [R1] -- [ ] 1.4 Use the shared `device_info` helper so the three new entities attach to the same `(DOMAIN, entry.entry_id)` device as the switches and number entities. [R1, R5, D6] +- [x] 1.1 Add `Platform.SENSOR` to the `PLATFORMS` list in `__init__.py` so HA forwards `async_setup_entry` to the new platform. [R1, D7] +- [x] 1.2 In `const.py`, add an `OUTPUT_SENSORS` list with three entries — each a dict of `key`, `name`, `unit`, `icon`. Keys: `output_brightness` / `output_color_temp` / `sun_elevation`. Names: `"Output brightness"` / `"Output color temp"` / `"Sun elevation"`. Units: `"%"` / `"K"` / `"°"`. Icons: `mdi:brightness-percent` / `mdi:thermometer` / `mdi:weather-sunset`. One source for setup + tests. [R1, D4, D7] +- [x] 1.3 Create `custom_components/adaptive_lighting/sensor.py` with `async_setup_entry(hass, config_entry, async_add_entities)` that instantiates three entities (one per entry in `OUTPUT_SENSORS`) and calls `async_add_entities(entities)`. [R1] +- [x] 1.4 Use the shared `device_info` helper so the three new entities attach to the same per-profile device as the switches and number entities. (Existing pattern uses `(DOMAIN, profile_name)` not `(DOMAIN, entry.entry_id)` — matched in sensor.py.) [R1, R5, D6] ## 2. Output publishing — master switch writes to `hass.data` and fires dispatcher -- [ ] 2.1 In `switch.py`, locate the existing point where `AdaptiveSwitch._async_update_attrs` (or the equivalent compute path) sets `current_brightness`, `current_color_temp`, `sun_position` on itself. Add a publish step immediately after the compute completes and before the switch's own state write. [R2, D2] -- [ ] 2.2 The publish SHALL set `hass.data[DOMAIN][entry.entry_id]["outputs"]` to a dict with keys `output_brightness` (int 0-100), `output_color_temp` (int K), `sun_position` (float degrees), `updated_at` (datetime). The cache dict keys SHALL match the `OUTPUT_SENSORS[*]["key"]` values exactly so sensors read with no intermediate mapping. The master switch's own `current_brightness` / `current_color_temp` / `sun_position` attributes stay unchanged. Ensure `hass.data[DOMAIN][entry.entry_id]` exists (initialize in `async_setup_entry` if needed). [R2, D2] -- [ ] 2.3 Immediately after publishing, call `async_dispatcher_send(hass, f"{DOMAIN}_{entry.entry_id}_outputs_updated")`. Import `async_dispatcher_send` from `homeassistant.helpers.dispatcher`. [R3, D3] -- [ ] 2.4 In `async_setup_entry`, initialize `hass.data.setdefault(DOMAIN, {}).setdefault(entry.entry_id, {})["outputs"] = None` before forwarding platform setups, so sensors that read before the first tick see an empty/`None` slot rather than a `KeyError`. [R2, R4] +- [x] 2.1 In `switch.py`, locate the existing point where `AdaptiveSwitch._update_attrs_and_maybe_adapt_lights` populates `self._settings` from `SunLightSettings.get_settings()`. Add a publish step immediately after `self._settings` is set and before the switch's own state write. [R2, D2] +- [x] 2.2 The publish SHALL set `hass.data[DOMAIN][entry.entry_id]["outputs"]` to a dict with keys `output_brightness` (int 0-100, from `self._settings["brightness_pct"]`), `output_color_temp` (int K, from `self._settings["color_temp_kelvin"]`), `sun_elevation` (float degrees or `None`, from `hass.states.get("sun.sun").attributes.get("elevation")` with a `None` fallback if either is missing), `updated_at` (datetime). [R2, D2, D8] +- [x] 2.3 Immediately after publishing, call `async_dispatcher_send(hass, SIGNAL_OUTPUTS_UPDATED.format(entry_id=...))`. Import `async_dispatcher_send` from `homeassistant.helpers.dispatcher`. [R3, D3] +- [x] 2.4 In `async_setup_entry`, initialize the per-entry cache slot with `"outputs": None` before forwarding platform setups, so sensors that read before the first tick see a sentinel rather than a `KeyError`. [R2, R4] ## 3. Sensor entity class — `AdaptiveOutputSensor` -- [ ] 3.1 Define `AdaptiveOutputSensor(SensorEntity)` in `sensor.py` with `_attr_has_entity_name = True`, `_attr_should_poll = False`, `_attr_state_class = SensorStateClass.MEASUREMENT`. Constructor takes `(hass, entry, output_key, name, unit, icon)`. [R1, R3, R5, D2, D3, D4, D6] -- [ ] 3.2 Implement `unique_id` property as `f"{entry.entry_id}_{output_key}"`. [R1, D7] -- [ ] 3.3 Set `_attr_name` on each instance to the role label from `OUTPUT_SENSORS` ("Output brightness", "Output color temp", "Sun position"). [R5, D4, D6] -- [ ] 3.4 Set `_attr_native_unit_of_measurement` from the entry's `unit` (or `None` for sun position) and `_attr_icon` from the entry's `icon`. Set `_attr_device_class = None` explicitly so future contributors see this is intentional. [R1, D4] -- [ ] 3.5 Initialize `_attr_native_value = None` in `__init__`. The sensor renders as `unknown` until the first dispatcher signal fires. Do NOT extend `RestoreEntity` or `RestoreSensor`. [R4, D5] -- [ ] 3.6 Implement `async_added_to_hass`: subscribe to `f"{DOMAIN}_{entry.entry_id}_outputs_updated"` via `async_dispatcher_connect`; register the unsubscribe handle via `self.async_on_remove(...)`. [R3, D3] -- [ ] 3.7 Implement the signal handler `_handle_outputs_updated`: read `hass.data[DOMAIN][entry.entry_id]["outputs"][output_key]`, set `_attr_native_value`, call `async_write_ha_state()`. Guard against the `outputs` slot being `None` (early dispatcher fire) — in that case do nothing. [R3, R4, D3] +- [x] 3.1 Define `AdaptiveOutputSensor(SensorEntity)` in `sensor.py` with `_attr_has_entity_name = True`, `_attr_should_poll = False`, `_attr_state_class = SensorStateClass.MEASUREMENT`. Constructor takes `(hass, entry, output_key, name, unit, icon)`. [R1, R3, R5, D2, D3, D4, D6] +- [x] 3.2 Implement `unique_id` property as `f"{entry.entry_id}_{output_key}"`. [R1, D7] +- [x] 3.3 Set `_attr_name` on each instance to the role label from `OUTPUT_SENSORS` ("Output brightness", "Output color temp", "Sun elevation"). [R5, D4, D6] +- [x] 3.4 Set `_attr_native_unit_of_measurement` from the entry's `unit` and `_attr_icon` from the entry's `icon`. Set `_attr_device_class = None` explicitly so future contributors see this is intentional. [R1, D4] +- [x] 3.5 Initialize `_attr_native_value = None` in `__init__`. The sensor renders as `unknown` until the first dispatcher signal fires. Do NOT extend `RestoreEntity` or `RestoreSensor`. [R4, D5] +- [x] 3.6 Implement `async_added_to_hass`: subscribe to `SIGNAL_OUTPUTS_UPDATED.format(entry_id=...)` via `async_dispatcher_connect`; register the unsubscribe handle via `self.async_on_remove(...)`. [R3, D3] +- [x] 3.7 Implement the signal handler `_handle_outputs_updated`: read `hass.data[DOMAIN][entry.entry_id]["outputs"][output_key]`, set `_attr_native_value`, call `async_write_ha_state()`. Guard against the `outputs` slot being `None` (early dispatcher fire) — in that case do nothing. [R3, R4, D3] ## 4. Tests — `tests/test_sensor_platform.py` -- [ ] 4.1 New test file `tests/test_sensor_platform.py` with the existing autouse PHACC fixture from `conftest.py`. [R1] -- [ ] 4.2 Test: creating a new config entry registers exactly three `sensor` entities owned by the entry, with unique-id suffixes `_output_brightness`, `_output_color_temp`, `_sun_position`. [R1] -- [ ] 4.3 Test: each of the three sensors is attached to the same device as the profile's switches and number entities. [R1, R5] -- [ ] 4.4 Test: sensor metadata — `output_brightness` declares `unit="%"`, `state_class=MEASUREMENT`, no `device_class`; `output_color_temp` declares `unit="K"`, `state_class=MEASUREMENT`, no `device_class`; `sun_position` declares `unit="°"`, `state_class=MEASUREMENT`, no `device_class`. [R1, D4] -- [ ] 4.5 Test: `_attr_should_poll` is `False` on all three sensors. [R3] -- [ ] 4.6 Test: master switch's curve tick publishes the four expected keys (`output_brightness`, `output_color_temp`, `sun_position`, `updated_at`) into `hass.data[DOMAIN][entry.entry_id]["outputs"]`. Use a synthetic tick (call the compute path directly) and assert the dict state. [R2] -- [ ] 4.7 Test: firing `async_dispatcher_send(hass, f"{DOMAIN}_{entry_id}_outputs_updated")` causes the three sensor states to update to the values currently in `hass.data[DOMAIN][entry_id]["outputs"]`. [R3] -- [ ] 4.8 Test: signal isolation — for two profiles A and B, firing A's signal updates A's three sensors but does NOT update B's. [R3] -- [ ] 4.9 Test: sensors do not import or call curve math. Inspect the sensor class's `async_added_to_hass` and signal handler for absence of `SunLightSettings` references and absence of `hass.states.get` for sun-time / range-number entities. [R2] -- [ ] 4.10 Test: before the first dispatcher signal, all three sensor states are `unknown`. After firing the signal with a populated `outputs` dict, the states match the dict values. [R4] -- [ ] 4.11 Test: friendly-name composition produces "Dining MVP Output brightness", "Dining MVP Output color temp", "Dining MVP Sun position" for a profile titled "Dining MVP". Assert no friendly name collides with the adapt-brightness switch's "Dining MVP Brightness" or the "Min/Max color temp" number entities. [R5, D4, D6] -- [ ] 4.12 Test: unloading the config entry removes the three sensor entities AND removes their dispatcher subscriptions (firing the signal post-unload does not invoke the handler). Use a spy on the handler or count handler invocations. [R3] -- [ ] 4.13 Verify existing `tests/test_switch_platform.py` (or equivalent) still passes after the master switch gains the output-publish + dispatcher-fire step. [R2, polish] +- [x] 4.1 New test file `tests/test_sensor_platform.py` with the existing autouse PHACC fixture from `conftest.py`. [R1] +- [x] 4.2 Test: creating a new config entry registers exactly three `sensor` entities owned by the entry, with unique-id suffixes `_output_brightness`, `_output_color_temp`, `_sun_elevation`. [R1] +- [x] 4.3 Test: each of the three sensors is attached to the same device as the profile's switches and number entities. [R1, R5] +- [x] 4.4 Test: sensor metadata — `output_brightness` declares `unit="%"`, `state_class=MEASUREMENT`, no `device_class`; `output_color_temp` declares `unit="K"`, `state_class=MEASUREMENT`, no `device_class`; `sun_elevation` declares `unit="°"`, `state_class=MEASUREMENT`, no `device_class`. [R1, D4] +- [x] 4.4b Test: master switch publish step reads `sun.sun.attributes.elevation` and writes it to `outputs["sun_elevation"]`. When `sun.sun` is missing or the attribute is absent, `outputs["sun_elevation"]` is `None` and the publish step does not raise. [R2, D8] +- [x] 4.5 Test: `_attr_should_poll` is `False` on all three sensors. [R3] +- [x] 4.6 Test: master switch's curve tick publishes the four expected keys (`output_brightness`, `output_color_temp`, `sun_elevation`, `updated_at`) into `hass.data[DOMAIN][entry.entry_id]["outputs"]`. Use a synthetic tick (call the compute path directly) and assert the dict state. [R2] +- [x] 4.7 Test: firing `async_dispatcher_send(hass, f"{DOMAIN}_{entry_id}_outputs_updated")` causes the three sensor states to update to the values currently in `hass.data[DOMAIN][entry_id]["outputs"]`. [R3] +- [x] 4.8 Test: signal isolation — for two profiles A and B, firing A's signal updates A's three sensors but does NOT update B's. [R3] +- [x] 4.9 Test: sensors do not import or call curve math. Inspect the sensor class's `async_added_to_hass` and signal handler for absence of `SunLightSettings` references and absence of `hass.states.get` for sun-time / range-number entities or `sun.sun`. [R2] +- [x] 4.10 Test: before the first dispatcher signal, all three sensor states are `unknown`. After firing the signal with a populated `outputs` dict, the states match the dict values. [R4] +- [x] 4.11 Test: friendly-name composition produces "Dining MVP Output brightness", "Dining MVP Output color temp", "Dining MVP Sun elevation" for a profile titled "Dining MVP". Assert no friendly name collides with the adapt-brightness switch's "Dining MVP Brightness" or the "Min/Max color temp" number entities. [R5, D4, D6] +- [x] 4.12 Test: unloading the config entry removes the three sensor entities AND removes their dispatcher subscriptions (firing the signal post-unload does not invoke the handler). Use a spy on the handler or count handler invocations. [R3] +- [x] 4.13 Verify existing `tests/test_switch_platform.py` (or equivalent) still passes after the master switch gains the output-publish + dispatcher-fire step. [R2, polish] ## 5. Translations and docs -- [ ] 5.1 Add `entity.sensor.output_brightness.name`, `entity.sensor.output_color_temp.name`, `entity.sensor.sun_position.name` keys to `strings.json` matching the `_attr_name` values. [R5, polish] -- [ ] 5.2 Mirror the additions in `translations/en.json`. Other locales out of scope. [R5, polish] -- [ ] 5.3 Add a short section to `README.md` under "What's new in 2.x" naming the three sensors, explaining they enable History-panel and `apexcharts-card` graphing of the curve outputs, and showing a one-line YAML example of an `apexcharts-card` consuming `sensor.adaptive_lighting__output_brightness`. [polish, D4] -- [ ] 5.4 Append a release entry to `CHANGELOG.md` listing: 3 new sensor entities per profile, `SensorStateClass.MEASUREMENT` for recorder graphing, push-update via dispatcher, no behavioral change to existing switches / number entities. [polish] +- [x] 5.1 Add `entity.sensor.output_brightness.name`, `entity.sensor.output_color_temp.name`, `entity.sensor.sun_elevation.name` keys to `strings.json` matching the `_attr_name` values. [R5, polish] +- [x] 5.2 Mirror the additions in `translations/en.json`. Other locales out of scope. [R5, polish] +- [x] 5.3 Add a short section to `README.md` under "What's new in 2.x" naming the three sensors, explaining they enable History-panel and `apexcharts-card` graphing of the curve outputs, and showing a one-line YAML example of an `apexcharts-card` consuming `sensor.adaptive_lighting__output_brightness`. [polish, D4] +- [x] 5.4 Append a release entry to `CHANGELOG.md` listing: 3 new sensor entities per profile, `SensorStateClass.MEASUREMENT` for recorder graphing, push-update via dispatcher, no behavioral change to existing switches / number entities. [polish] ## 6. Manual verification on live HA -- [ ] 6.1 Deploy to `homeassistant.onca-blenny.ts.net` via HACS. Verify the three `sensor.adaptive_lighting_*` entities (`_output_brightness`, `_output_color_temp`, `_sun_position`) appear under each profile's device. [R1, polish] -- [ ] 6.2 Open the History panel for one profile's `output_brightness` sensor. Verify the curve over ~10 minutes shows numeric values graphed as a continuous line (proves `state_class=MEASUREMENT` is honored by the recorder). Repeat for `output_color_temp` and `sun_position` (the last should range roughly -90° to +90° over a day). [R1, D4] +- [ ] 6.1 Deploy to `homeassistant.onca-blenny.ts.net` via HACS. Verify the three `sensor.adaptive_lighting_*` entities (`_output_brightness`, `_output_color_temp`, `_sun_elevation`) appear under each profile's device. [R1, polish] +- [ ] 6.2 Open the History panel for one profile's `output_brightness` sensor. Verify the curve over ~10 minutes shows numeric values graphed as a continuous line (proves `state_class=MEASUREMENT` is honored by the recorder). Repeat for `output_color_temp` and `sun_elevation` (the last should match `sun.sun.attributes.elevation` and range roughly -90° to +90° over a day). [R1, D4, D8] - [ ] 6.3 Add a temporary `apexcharts-card` to a dashboard pointing at one profile's three sensors. Verify all three render as numeric series. [R1, D4] - [ ] 6.4 Restart HA. Verify each sensor briefly shows `unknown`, then populates with a value within one curve interval (default 90 s). [R4] -- [ ] 6.5 Verify the existing master switch attributes (`current_brightness`, `current_color_temp`, `sun_position`) are still present and unchanged on the switch entity's state — the sensors are additive, not a replacement. [polish, D2] +- [ ] 6.5 Verify the existing master switch attributes (`brightness_pct`, `color_temp_kelvin`, and the synthetic `sun_position` in [-1, +1] — all from `self._settings`) are still present and unchanged on the switch entity's state — the sensors are additive, not a replacement. [polish, D2] ## 7. Validation gate -- [ ] 7.1 `openspec validate add-output-sensors --strict` returns green. [polish] -- [ ] 7.2 `uv run pytest tests/test_sensor_platform.py` passes. Existing tests stay green (`uv run pytest`). [polish] -- [ ] 7.3 `./scripts/lint` clean. [polish] +- [x] 7.1 `openspec validate add-output-sensors --strict` returns green. [polish] +- [x] 7.2 `uv run pytest tests/test_sensor_platform.py` passes. Existing tests stay green (`uv run pytest`). [polish] +- [x] 7.3 `./scripts/lint` clean. [polish] diff --git a/openspec/specs/options-flow/spec.md b/openspec/specs/options-flow/spec.md index e0afb0c0..358548a4 100644 --- a/openspec/specs/options-flow/spec.md +++ b/openspec/specs/options-flow/spec.md @@ -206,4 +206,3 @@ On `async_setup_entry`, the integration SHALL scan the entity registry for entit - **WHEN** the integration runs the sleep-switch cleanup - **AND** another integration owns a similarly named entity (e.g., a user-created `switch.adaptive_lighting_sleep_mode_demo` template switch) - **THEN** that foreign entity SHALL NOT be removed from the entity registry - diff --git a/openspec/specs/runtime-range-controls/spec.md b/openspec/specs/runtime-range-controls/spec.md index 7e6e377f..9bab9328 100644 --- a/openspec/specs/runtime-range-controls/spec.md +++ b/openspec/specs/runtime-range-controls/spec.md @@ -139,4 +139,3 @@ Existing `unique_id`s SHALL remain unchanged; the entity registry SHALL preserve - **AND** HA reloads the config entry - **THEN** the entity's `entity_id` SHALL remain `switch.adaptive_lighting_adapt_brightness_dining_mvp_lights` (preserved by the registry via stable `unique_id`) - **AND** only the entity's friendly name SHALL update to follow the new composition - diff --git a/tests/test_number_platform.py b/tests/test_number_platform.py index 5753f412..14723fdc 100644 --- a/tests/test_number_platform.py +++ b/tests/test_number_platform.py @@ -26,7 +26,6 @@ from custom_components.adaptive_lighting.const import ( CONF_MIN_COLOR_TEMP, CONFIG_ENTRY_VERSION, DOMAIN, - RANGE_ENTITIES, ) PROFILE_NAME = "test_profile" @@ -52,7 +51,9 @@ def _unique_id(entry, field_key: str) -> str: def _resolve_entity_id(hass, entry, field_key: str) -> str | None: return er.async_get(hass).async_get_entity_id( - "number", DOMAIN, _unique_id(entry, field_key), + "number", + DOMAIN, + _unique_id(entry, field_key), ) @@ -66,7 +67,9 @@ async def test_four_range_entities_registered(hass) -> None: registry = er.async_get(hass) for field_key in FIELD_KEYS: eid = registry.async_get_entity_id( - "number", DOMAIN, _unique_id(entry, field_key), + "number", + DOMAIN, + _unique_id(entry, field_key), ) assert eid is not None, f"Missing number entity for {field_key}" assert eid.startswith("number.") @@ -276,13 +279,9 @@ async def test_options_flow_seeds_from_entity_state(hass) -> None: assert result["type"] == "form" schema = result["data_schema"].schema # Find the daytime_curve section and walk its inner schema to find max_brightness default - daytime = next( - sub for k, sub in schema.items() if str(k) == "daytime_curve" - ) + daytime = next(sub for k, sub in schema.items() if str(k) == "daytime_curve") inner = daytime.schema.schema - max_b_default = next( - k.default() for k in inner if str(k) == CONF_MAX_BRIGHTNESS - ) + max_b_default = next(k.default() for k in inner if str(k) == CONF_MAX_BRIGHTNESS) assert max_b_default == 80 # entity wins over options @@ -300,9 +299,7 @@ async def test_options_flow_fallback_when_entity_unavailable(hass) -> None: schema = result["data_schema"].schema daytime = next(sub for k, sub in schema.items() if str(k) == "daytime_curve") inner = daytime.schema.schema - default = next( - k.default() for k in inner if str(k) == CONF_MIN_COLOR_TEMP - ) + default = next(k.default() for k in inner if str(k) == CONF_MIN_COLOR_TEMP) assert default == 2500 # falls back to options @@ -351,6 +348,7 @@ async def test_curve_math_falls_back_on_unavailable(hass, caplog) -> None: al_switch = al_data["switch"] import logging + caplog.set_level(logging.DEBUG) settings = al_switch.sun_light_settings assert settings.max_brightness == 88 # fell back to options diff --git a/tests/test_sensor_platform.py b/tests/test_sensor_platform.py new file mode 100644 index 00000000..6e11a470 --- /dev/null +++ b/tests/test_sensor_platform.py @@ -0,0 +1,380 @@ +"""Tests for the output-sensor platform (CDiT fork). + +Covers the `output-sensors` capability: entity surface, dispatcher push +update, sun.sun source for sun_elevation, no-recompute-in-sensor, and +graceful unknown-on-startup. +""" + +from __future__ import annotations + +import inspect + +import pytest +from homeassistant.components.sensor import SensorStateClass +from homeassistant.const import CONF_NAME, STATE_UNKNOWN +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.util import dt as dt_util +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.adaptive_lighting import sensor as sensor_module +from custom_components.adaptive_lighting.const import ( + CONFIG_ENTRY_VERSION, + DOMAIN, + OUTPUT_SENSORS, + SIGNAL_OUTPUTS_UPDATED, +) + +PROFILE_NAME = "test_profile" +SENSOR_KEYS = ("output_brightness", "output_color_temp", "sun_elevation") + + +async def _setup_entry(hass, *, options=None): + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_NAME: PROFILE_NAME}, + options=options or {}, + version=CONFIG_ENTRY_VERSION, + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + return entry + + +def _unique_id(entry, key: str) -> str: + return f"{entry.entry_id}_{key}" + + +def _resolve_entity_id(hass, entry, key: str) -> str | None: + return er.async_get(hass).async_get_entity_id( + "sensor", DOMAIN, _unique_id(entry, key), + ) + + +# --------------------------------------------------------------------------- +# 4.2 — Three sensor entities per entry, expected suffixes +# --------------------------------------------------------------------------- + + +async def test_three_sensor_entities_registered(hass) -> None: + entry = await _setup_entry(hass) + registry = er.async_get(hass) + for key in SENSOR_KEYS: + eid = registry.async_get_entity_id( + "sensor", DOMAIN, _unique_id(entry, key), + ) + assert eid is not None, f"Missing sensor entity for {key}" + assert eid.startswith("sensor.") + + +# --------------------------------------------------------------------------- +# 4.3 — Sensors attach to the same device as switches and numbers +# --------------------------------------------------------------------------- + + +async def test_sensors_share_profile_device(hass) -> None: + entry = await _setup_entry(hass) + ent_reg = er.async_get(hass) + dev_reg = dr.async_get(hass) + + master_eid = ent_reg.async_get_entity_id("switch", DOMAIN, PROFILE_NAME) + master_dev_id = ent_reg.async_get(master_eid).device_id + assert master_dev_id + + for key in SENSOR_KEYS: + eid = _resolve_entity_id(hass, entry, key) + assert ent_reg.async_get(eid).device_id == master_dev_id + + device = dev_reg.async_get(master_dev_id) + assert device.name == PROFILE_NAME + + +# --------------------------------------------------------------------------- +# 4.4 — Sensor metadata matches the D4 table +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("key", "unit"), + [ + ("output_brightness", "%"), + ("output_color_temp", "K"), + ("sun_elevation", "°"), + ], +) +async def test_sensor_metadata(hass, key, unit) -> None: + entry = await _setup_entry(hass) + eid = _resolve_entity_id(hass, entry, key) + state = hass.states.get(eid) + assert state is not None + assert state.attributes.get("unit_of_measurement") == unit + assert state.attributes.get("state_class") == SensorStateClass.MEASUREMENT + assert state.attributes.get("device_class") is None + + +# --------------------------------------------------------------------------- +# 4.4b — Publish step handles missing sun.sun gracefully (D8) +# --------------------------------------------------------------------------- + + +async def test_publish_sun_elevation_handles_missing_sun(hass) -> None: + entry = await _setup_entry(hass) + al_switch = hass.data[DOMAIN][entry.entry_id]["switch"] + # Pre-populate _settings so brightness/color publish has values. + al_switch._settings.update( + {"brightness_pct": 50, "color_temp_kelvin": 3000, "sun_position": 0.0}, + ) + # sun.sun is not registered in tests — confirm + invoke publish directly. + assert hass.states.get("sun.sun") is None + al_switch._publish_outputs_and_wake_sensors() + outputs = hass.data[DOMAIN][entry.entry_id]["outputs"] + assert outputs["sun_elevation"] is None + assert outputs["output_brightness"] == 50 + assert outputs["output_color_temp"] == 3000 + + +async def test_publish_sun_elevation_reads_from_sun_sun(hass) -> None: + entry = await _setup_entry(hass) + al_switch = hass.data[DOMAIN][entry.entry_id]["switch"] + al_switch._settings.update( + {"brightness_pct": 60, "color_temp_kelvin": 3500, "sun_position": 0.5}, + ) + hass.states.async_set("sun.sun", "above_horizon", {"elevation": 42.5}) + al_switch._publish_outputs_and_wake_sensors() + outputs = hass.data[DOMAIN][entry.entry_id]["outputs"] + assert outputs["sun_elevation"] == 42.5 + assert outputs["output_brightness"] == 60 + + +# --------------------------------------------------------------------------- +# 4.5 — Sensors are not polled +# --------------------------------------------------------------------------- + + +async def test_sensors_do_not_poll() -> None: + # `_attr_should_poll` is shadowed by SensorEntity's property descriptor; + # check the source for the class-level assignment instead. + src = inspect.getsource(sensor_module.AdaptiveOutputSensor) + assert "_attr_should_poll = False" in src + + +# --------------------------------------------------------------------------- +# 4.6 — Curve tick populates the cache with the four keys +# --------------------------------------------------------------------------- + + +async def test_curve_tick_publishes_outputs(hass) -> None: + entry = await _setup_entry(hass) + al_switch = hass.data[DOMAIN][entry.entry_id]["switch"] + al_switch._settings.update( + {"brightness_pct": 72, "color_temp_kelvin": 3240, "sun_position": 0.3}, + ) + hass.states.async_set("sun.sun", "above_horizon", {"elevation": 18.0}) + before = dt_util.utcnow() + al_switch._publish_outputs_and_wake_sensors() + + outputs = hass.data[DOMAIN][entry.entry_id]["outputs"] + assert set(outputs.keys()) == { + "output_brightness", + "output_color_temp", + "sun_elevation", + "updated_at", + } + assert outputs["output_brightness"] == 72 + assert outputs["output_color_temp"] == 3240 + assert outputs["sun_elevation"] == 18.0 + assert outputs["updated_at"] >= before + + +# --------------------------------------------------------------------------- +# 4.7 — Firing the dispatcher signal updates the three sensors +# --------------------------------------------------------------------------- + + +async def test_dispatcher_signal_updates_sensors(hass) -> None: + entry = await _setup_entry(hass) + hass.data[DOMAIN][entry.entry_id]["outputs"] = { + "output_brightness": 65, + "output_color_temp": 3000, + "sun_elevation": 25.0, + "updated_at": dt_util.utcnow(), + } + async_dispatcher_send( + hass, + SIGNAL_OUTPUTS_UPDATED.format(entry_id=entry.entry_id), + ) + await hass.async_block_till_done() + + assert ( + hass.states.get(_resolve_entity_id(hass, entry, "output_brightness")).state + == "65" + ) + assert ( + hass.states.get(_resolve_entity_id(hass, entry, "output_color_temp")).state + == "3000" + ) + assert ( + hass.states.get(_resolve_entity_id(hass, entry, "sun_elevation")).state + == "25.0" + ) + + +# --------------------------------------------------------------------------- +# 4.8 — Signal isolation between profiles +# --------------------------------------------------------------------------- + + +async def test_dispatcher_signal_is_per_entry(hass) -> None: + entry_a = MockConfigEntry( + domain=DOMAIN, + data={CONF_NAME: "profile_a"}, + options={}, + version=CONFIG_ENTRY_VERSION, + ) + entry_a.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry_a.entry_id) + await hass.async_block_till_done() + + entry_b = MockConfigEntry( + domain=DOMAIN, + data={CONF_NAME: "profile_b"}, + options={}, + version=CONFIG_ENTRY_VERSION, + ) + entry_b.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry_b.entry_id) + await hass.async_block_till_done() + + # Populate only A's cache, fire only A's signal. + hass.data[DOMAIN][entry_a.entry_id]["outputs"] = { + "output_brightness": 80, + "output_color_temp": 4000, + "sun_elevation": 30.0, + "updated_at": dt_util.utcnow(), + } + async_dispatcher_send( + hass, + SIGNAL_OUTPUTS_UPDATED.format(entry_id=entry_a.entry_id), + ) + await hass.async_block_till_done() + + a_eid = _resolve_entity_id(hass, entry_a, "output_brightness") + b_eid = _resolve_entity_id(hass, entry_b, "output_brightness") + assert hass.states.get(a_eid).state == "80" + assert hass.states.get(b_eid).state == STATE_UNKNOWN # B never updated + + +# --------------------------------------------------------------------------- +# 4.9 — Sensors do not import or call curve math +# --------------------------------------------------------------------------- + + +async def test_sensors_dont_recompute_curve() -> None: + src = inspect.getsource(sensor_module) + assert "SunLightSettings" not in src + # The handler must not fetch sun.sun, sun-time, or range entities itself + cls_src = inspect.getsource(sensor_module.AdaptiveOutputSensor) + assert "states.get" not in cls_src + + +# --------------------------------------------------------------------------- +# 4.10 — Before first dispatcher signal, sensors are unknown +# --------------------------------------------------------------------------- + + +async def test_sensors_unknown_before_first_tick(hass) -> None: + entry = await _setup_entry(hass) + # In tests the sunrise/sunset entities aren't available, so even when + # the master switch publishes during setup the brightness/color values + # are None (no curve math ran). The sensors render as `unknown` until + # a real tick produces real numbers. + outputs = hass.data[DOMAIN][entry.entry_id]["outputs"] + if outputs is not None: + assert outputs["output_brightness"] is None + assert outputs["output_color_temp"] is None + for key in SENSOR_KEYS: + eid = _resolve_entity_id(hass, entry, key) + assert hass.states.get(eid).state == STATE_UNKNOWN + + +# --------------------------------------------------------------------------- +# 4.11 — Friendly names compose from device + role label, no collisions +# --------------------------------------------------------------------------- + + +async def test_friendly_names_compose_correctly(hass) -> None: + entry = await _setup_entry(hass) + names = {} + for row in OUTPUT_SENSORS: + key = row["key"] + eid = _resolve_entity_id(hass, entry, key) + names[key] = hass.states.get(eid).attributes["friendly_name"] + assert names["output_brightness"] == f"{PROFILE_NAME} Output brightness" + assert names["output_color_temp"] == f"{PROFILE_NAME} Output color temp" + assert names["sun_elevation"] == f"{PROFILE_NAME} Sun elevation" + + # No collision with the adapt-brightness switch (" Brightness"). + all_friendly = set(names.values()) + # Walk all entities owned by this entry and confirm uniqueness. + ent_reg = er.async_get(hass) + for ent in ent_reg.entities.values(): + if ent.config_entry_id != entry.entry_id: + continue + state = hass.states.get(ent.entity_id) + if state is None: + continue + fname = state.attributes.get("friendly_name") + if fname is None: + continue + if fname in names.values(): + continue + # Any other entity's friendly name must not match a sensor's. + assert fname not in all_friendly, ( + f"Collision: {fname} appears on both sensor and another entity" + ) + + +# --------------------------------------------------------------------------- +# 4.12 — Unload removes sensors and detaches dispatcher subscriptions +# --------------------------------------------------------------------------- + + +async def test_unload_detaches_dispatcher(hass) -> None: + entry = await _setup_entry(hass) + # Confirm sensors are registered, then unload. + assert _resolve_entity_id(hass, entry, "output_brightness") is not None + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + # Firing the signal after unload must not raise nor populate state. + # We can't easily inspect the handler-not-called assertion without a + # spy, but the fact that this doesn't raise (and that the entity is + # gone from the state machine) is sufficient evidence the subscription + # was cleaned up via async_on_remove. + hass.data.setdefault(DOMAIN, {}).setdefault(entry.entry_id, {})["outputs"] = { + "output_brightness": 99, + "output_color_temp": 4500, + "sun_elevation": 50.0, + "updated_at": dt_util.utcnow(), + } + async_dispatcher_send( + hass, + SIGNAL_OUTPUTS_UPDATED.format(entry_id=entry.entry_id), + ) + await hass.async_block_till_done() + + # Post-unload, the entity is `unavailable`; firing the signal MUST NOT + # repopulate it with the dummy "99" value — that would mean the + # dispatcher subscription leaked. + ent_reg = er.async_get(hass) + eid = ent_reg.async_get_entity_id( + "sensor", DOMAIN, _unique_id(entry, "output_brightness"), + ) + if eid is not None: + state = hass.states.get(eid) + if state is not None: + assert state.state != "99", ( + "Dispatcher subscription leaked: sensor updated after unload" + )