Archive add-output-sensors: 41/41 + new main spec

Sensors are live on Tailscale HA, the change is complete end-to-end.
Moves the change to openspec/changes/archive/2026-05-21-add-output-sensors/
and promotes the delta spec to openspec/specs/output-sensors/spec.md
as a new capability (5 requirements, 13 scenarios).
This commit is contained in:
Casey 2026-05-21 10:34:44 +02:00
commit d31d1928e5
6 changed files with 148 additions and 5 deletions

View file

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-17

View file

@ -0,0 +1,196 @@
## Context
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.
The architectural question this design resolves is the **read path**: how do three sensor entities access the master switch's computed output without (a) recomputing the curve themselves, (b) tightly coupling to the switch's internal attribute API, or (c) introducing a third source of truth?
## Goals / Non-Goals
**Goals:**
- 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 `<Profile> <Role>` and fit HA's narrow-card truncation window.
- 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.
- Per-light sensors (one sensor per controlled light entity). The output values are profile-level, not light-level.
- Historical aggregations (daily min/max, weekly averages). Recorder + statistics-card handles this externally once the values exist as sensor state.
- Mired-scale parallel sensors for color temp. Skipped per the proposal's open-question answer; if anyone ever needs mired, they can compute `1_000_000 / K` in a template.
- `device_class` on any of the three sensors. `SensorDeviceClass.TEMPERATURE` would be wrong for color temp (it's not thermal); brightness % has no fitting device class; sun-position is unitless. Setting a device_class wrongly is worse than setting none.
- Configurable update cadence separate from the curve tick. The curve interval governs both.
## Decisions
### Decision 1: Capability slug is `output-sensors`, scoped tight
**What we chose:** This change defines one new capability — `output-sensors` — covering the three sensor entities, their state-class configuration, the dispatcher-push update path, and the `has_entity_name` composition for sensors. No existing capability is modified.
**Why:** Matches the pattern from `cdit-config-redesign` Decision 10 and `add-runtime-range-controls` Decision 10. Tight scope keeps spec scenarios independently testable. "Output sensors" pairs cleanly with "runtime range controls" (the inputs that bound the curve) — together they form the read/write surface for the curve's value space.
**Alternatives considered:**
- **Roll into `runtime-range-controls`** (since both are entity-platform additions). Rejected — that capability is archived; reopening it conflates inputs with outputs and complicates the spec history.
- **Generic `sensors` capability** anticipating future sensor entities. Rejected — premature. Each future sensor addition can get its own focused capability.
### Decision 2: Master switch publishes outputs to `hass.data`; sensors read from there
**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": <int 0-100>, # from self._settings["brightness_pct"]
"output_color_temp": <int K>, # from self._settings["color_temp_kelvin"]
"sun_elevation": <float degrees | None>, # from hass.states.get("sun.sun").attributes["elevation"]
"updated_at": <datetime>,
}
```
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(<master>).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.
**Alternatives considered:** Above.
### Decision 3: Push updates via `async_dispatcher_send` keyed by entry_id
**What we chose:** After publishing to `hass.data`, the master switch calls:
```python
async_dispatcher_send(hass, f"{DOMAIN}_{entry.entry_id}_outputs_updated")
```
Each sensor subscribes to this exact signal in its `async_added_to_hass`. On receiving the signal, the sensor reads the relevant key from `hass.data[DOMAIN][entry.entry_id]["outputs"]` and calls `async_write_ha_state()`.
**Why:** Push beats poll: the data has already been computed when the signal fires, so the sensor's write is O(1) dict lookup + state write. No timer drift, no polling jitter. Keying the signal by `entry.entry_id` prevents profile-A's sensor from waking up when profile-B ticks (a global `DOMAIN`-level signal would do that).
**Alternatives considered:**
- **`should_poll = True` and a 30 s poll loop.** Rejected — polling cadence wouldn't align with the curve tick; sensors would update at a different rhythm than the values they expose; HA would also schedule three polling tasks per profile.
- **Each sensor schedules `async_track_time_interval` matching the curve `interval`.** Rejected — duplicates the master switch's scheduler; if the interval changes mid-run (via options-flow save), three independent timers need to be re-registered.
- **Sensor subscribes to the master switch's `state_changed` event.** Rejected — the master switch's state is on/off; its attributes change without a state_changed firing in HA's strict sense (attribute-only changes don't always emit). Brittle.
### Decision 4: All three sensors use `SensorStateClass.MEASUREMENT`, no `device_class`
**What we chose:**
| Sensor | `unique_id` suffix | `_attr_name` | unit | state_class | device_class | icon |
|---|---|---|---|---|---|---|
| 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 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; 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 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.
- **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`
**What we chose:** Sensors do not extend `RestoreEntity`. On HA restart, `_attr_native_value` is `None` until the first curve evaluation publishes a value. The state appears as `unknown` in the UI for at most one `interval` (default 90 s) after startup.
**Why:** Restoring a stale value would be misleading — the sensor's whole purpose is to expose *current* curve output. A restored "65%" from before the restart is wrong if the sun has moved since. Showing `unknown` is honest. The window is short (≤90 s by default).
**Alternatives considered:**
- **`RestoreSensor` for continuity.** Rejected — restored value is by definition stale; the recorder already has the historical value for graphing purposes (it's stored persistently).
- **Compute an immediate first value during `async_added_to_hass` by reading the master switch's attributes.** Rejected — couples sensor setup ordering to switch setup ordering (which is the foot-gun Decision 2 explicitly avoids).
### Decision 6: Sensors use `has_entity_name = True`, same device record as switches and numbers
**What we chose:** Every sensor sets `_attr_has_entity_name = True` and attaches to the existing per-profile device record (`(DOMAIN, entry.entry_id)`). HA composes friendly names as `<entry.title> <_attr_name>`. Per Decision 4, the names become "Dining MVP Current brightness", "Dining MVP Current color temp", "Dining MVP Sun position".
**Why:** Consistency with `add-runtime-range-controls` Decision 11. All ten entities per profile (3 switches + 4 numbers + 3 sensors) appear on one device card; their friendly names compose uniformly; entity-ID slugs follow HA's standard pattern.
**Alternatives considered:** None worth listing — this is just applying the established convention.
### Decision 7: Platform forwarded in `__init__.py`; no new `const.py` constants beyond enumeration
**What we chose:** In `__init__.py`, `PLATFORMS` becomes `[Platform.SWITCH, Platform.NUMBER, Platform.SENSOR]`. In `const.py`, add an `OUTPUT_SENSORS` mapping (or three explicit dicts) capturing per-sensor metadata — exactly mirroring the `RANGE_ENTITIES` pattern from `add-runtime-range-controls`:
```python
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_elevation", "name": "Sun elevation", "unit": "°", "icon": "mdi:weather-sunset"},
]
```
Sensor platform iterates this list. Tests reference it.
**Why:** Same data-driven pattern as the range entities — one source for setup + tests, no per-sensor class proliferation. Three instances of one class (`AdaptiveOutputSensor`) parameterized by the dict entry.
**Alternatives considered:**
- **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: `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.
**Why:** Sensors are read-only. Nothing to configure. The cadence is governed by the existing `interval` option, which already exists. Adding a "show sun_position" toggle, for example, is dead-code complexity — anyone who doesn't want the entity can hide it via HA's entity registry.
**Alternatives considered:**
- **Option to disable individual sensors.** Rejected — HA's entity registry already allows hiding entities per-user. Don't reinvent.
## Risks / Trade-offs
- **[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_<name>_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.
## Migration Plan
Single PR on the fork's `main` branch. Depends on `cdit-config-redesign` and `add-runtime-range-controls` (both archived). Additive — no breaking changes.
1. Add the `sensor` platform per the decisions above.
2. Wire the master switch's compute loop to publish outputs + fire the dispatcher signal.
3. Add the three sensor entities, the `OUTPUT_SENSORS` mapping in `const.py`, the new `strings.json` keys.
4. Add `tests/test_sensor_platform.py` covering entity creation, state-class assertions, push-update flow, fallback to `unknown`, and friendly-name composition.
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 (`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 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`).

View file

@ -0,0 +1,39 @@
## Why
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_<name>_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_<name>_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_<name>_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 `<Profile> Output brightness`, `<Profile> Output color temp`, `<Profile> 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.
## Capabilities
### New Capabilities
- `output-sensors`: per-profile sensor entities exposing the curve's current outputs (brightness %, color temp K, sun position float). Covers entity creation, the read path from curve math, state-class configuration for recorder graphing, naming convention, and removal lifecycle.
### Modified Capabilities
None. Sensors are purely additive: existing switches, number entities, and options flow are unchanged.
## Impact
- **`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.
- **`tests/test_sensor_platform.py`** — new file. Tests: entity creation on first setup; sensor state matches curve output; state_class and unit attributes are correct; removal cleans up entities; sensors survive an options-flow save (reload via `OptionsFlowWithReload`); `has_entity_name` produces the expected friendly names.
- **No new runtime dependencies.** No external services. No new config fields.
**Sequencing**: depends on `cdit-config-redesign` (3-switch model, `has_entity_name` convention) and `add-runtime-range-controls` (the curve math reads from the four `number` entities, which is what these sensors expose the output of). Both archived. No other in-flight dependencies. Net version bump: minor (`v2.2.0-cdit.1` or similar — no breaking change, the existing master-switch attributes stay).

View file

@ -0,0 +1,139 @@
## ADDED Requirements
### Requirement: Each AL profile exposes three output sensor entities
For each Adaptive Lighting config entry, the integration SHALL create exactly three `sensor` entities exposing the curve's computed outputs. The entities 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` | `device_class` | icon |
|---|---|---|---|---|---|---|
| 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 elevation | `_sun_elevation` | `"Sun elevation"` | `"°"` | `MEASUREMENT` | (none) | `mdi:weather-sunset` |
The full `unique_id` SHALL be `<entry.entry_id>_<suffix>`.
#### Scenario: A new config entry produces three sensor entities
- **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_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
- **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_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 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.
The integration SHALL NOT cause the sensor entities to recompute the curve. Sensors are pure readers of the published cache.
#### 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_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, the four range number entities, or `sun.sun`
### Requirement: Sensors update via a per-entry dispatcher signal
After publishing outputs to the runtime cache, the master switch SHALL emit a dispatcher signal `f"{DOMAIN}_{entry.entry_id}_outputs_updated"` via `homeassistant.helpers.dispatcher.async_dispatcher_send`. Each of the three sensors SHALL subscribe to this exact signal in its `async_added_to_hass` method. On receiving the signal, the sensor SHALL read its key from `hass.data[DOMAIN][entry.entry_id]["outputs"]`, update `_attr_native_value`, and call `async_write_ha_state()`.
Sensors SHALL NOT poll. `_attr_should_poll` SHALL be `False`.
The dispatcher unsubscribe handle SHALL be tracked via `async_on_remove` so that listener cleanup happens automatically on entity removal or integration reload.
#### Scenario: Curve tick wakes all three sensors
- **GIVEN** the integration is loaded with the master switch's adapt loop running
- **WHEN** a curve evaluation completes and fires the dispatcher signal
- **THEN** each of the three sensor entities SHALL execute its outputs-updated handler exactly once
- **AND** the three sensor states SHALL reflect the values just written to `hass.data[DOMAIN][entry.entry_id]["outputs"]`
#### Scenario: Per-entry signal isolation
- **GIVEN** two AL profiles A and B are both loaded
- **WHEN** profile A's curve tick fires its signal `f"{DOMAIN}_{entry_a.entry_id}_outputs_updated"`
- **THEN** profile A's three sensors SHALL update their state
- **AND** profile B's three sensors SHALL NOT execute their outputs-updated handler
#### Scenario: Sensor cleans up its dispatcher subscription on removal
- **GIVEN** an AL profile's sensors are subscribed to the dispatcher signal
- **WHEN** the config entry is unloaded
- **THEN** each sensor's dispatcher subscription SHALL be removed via the `async_on_remove`-registered unsubscribe handle
- **AND** subsequent fires of the dispatcher signal (during HA shutdown sequencing) SHALL NOT invoke the sensor's outputs-updated handler
### Requirement: Sensors report `STATE_UNKNOWN` before the first curve tick
Sensors SHALL NOT extend `RestoreEntity` or `RestoreSensor`. On entity addition (HA startup, integration reload, or config-entry creation), `_attr_native_value` SHALL be `None` until the first dispatcher signal fires after the first curve evaluation. HA will render `_attr_native_value=None` as the state value `unknown`.
#### Scenario: Fresh setup shows unknown until first tick
- **GIVEN** Home Assistant has just started and the integration is loading
- **WHEN** the three sensor entities first appear in the state machine
- **AND** the master switch has not yet completed its first curve evaluation
- **THEN** each sensor's state SHALL be `unknown`
#### Scenario: First curve tick after restart populates sensor state
- **GIVEN** the three sensors are in state `unknown` immediately after HA restart
- **WHEN** the master switch's first post-restart curve evaluation fires the dispatcher signal
- **THEN** each sensor's state SHALL update to its corresponding value from `hass.data[DOMAIN][entry.entry_id]["outputs"]`
- **AND** none of the sensors SHALL retain `unknown` after this tick
### 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 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`:
| Sensor | `_attr_name` | Friendly name |
|---|---|---|
| Output-brightness sensor | `"Output brightness"` | `Dining MVP Output brightness` |
| Output-color-temp sensor | `"Output color temp"` | `Dining MVP Output color temp` |
| 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).
#### Scenario: Friendly names compose from device name + sensor role
- **GIVEN** an AL profile is configured with display name "Dining MVP"
- **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-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
- **GIVEN** an AL profile exposes its three switches and four range numbers (per `add-runtime-range-controls` R7) and its three new output sensors
- **WHEN** all ten entities' friendly names are inspected
- **THEN** no two entities SHALL share the same friendly name
- **AND** specifically the adapt-brightness switch ("Dining MVP Brightness") and the output-brightness sensor ("Dining MVP Output brightness") SHALL be distinguishable strings
- **AND** the output-color-temp sensor ("Dining MVP Output color temp") SHALL be distinguishable from the "Min color temp" and "Max color temp" number entities

View file

@ -0,0 +1,78 @@
<!--
Annotations:
R1–R5 = ADDED Requirements in specs/output-sensors/spec.md
R1 = "Each AL profile exposes three output sensor entities"
R2 = "Curve evaluation publishes outputs to a runtime cache"
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–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
output-publish + dispatcher fire. Group 3 implements the sensor class. Group 4 is
tests. Group 5 is strings + docs. Group 6 is manual verification. Group 7 is the
validation gate.
-->
## 1. Platform foundation — `sensor.py` skeleton and `const.py` mapping
- [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
- [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`
- [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`
- [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
- [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_<name>_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
- [x] 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]
- [x] 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]
- [x] 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]
- [x] 6.4 Restart HA. Verify each sensor briefly shows `unknown`, then populates with a value within one curve interval (default 90 s). [R4]
- [x] 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
- [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]