Propose add-output-sensors: 3 sensors per profile for recorder graphing

Promotes the master switch's current_brightness, current_color_temp, and
sun_position attributes to first-class sensor entities with
SensorStateClass.MEASUREMENT so HA's recorder graphs them as numerics.
This is the explicit complement to the killed add-lovelace-card: stock
apexcharts-card / History panel becomes the charting story once values
live on graphable entities.

Architecture: master switch publishes computed outputs to
hass.data[DOMAIN][entry_id]["outputs"] after each curve tick, then fires
a per-entry dispatcher signal. Sensors subscribe to the signal and read
from the cache. Single computation path; pure-reader sensors.

Naming: "Output brightness" / "Output color temp" disambiguate from
adapt-brightness switch and min/max number entities. "Sun position"
needs no prefix. sun_position is solar elevation in degrees (unit °)
sourced from the user's configured Sun2 / sensor.sun_* entity.

4 artifacts: proposal, design (8 decisions), spec (5 requirements, 13
scenarios), tasks (7 groups, 33 checkboxes). Strict-validate green.
This commit is contained in:
Casey 2026-05-18 10:07:50 +02:00
commit a6eb2dbe68
5 changed files with 423 additions and 0 deletions

View file

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

View file

@ -0,0 +1,180 @@
## 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.
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_position`), 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 (`current_brightness`, 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._async_update_attrs` (or wherever the integration currently sets `current_brightness` / `current_color_temp` / `sun_position`), publish the same three values to:
```python
hass.data[DOMAIN][entry.entry_id]["outputs"] = {
"output_brightness": <int 0-100>,
"output_color_temp": <int K>,
"sun_position": <float, degrees of solar 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 (`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).
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["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.
- **(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 position | `_sun_position` | `"Sun position"` | `"°"` | `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.
**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.
**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.
### 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_position", "name": "Sun position", "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: 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.
- **[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 (`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.
## 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).

View file

@ -0,0 +1,37 @@
## 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.
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, integer `%`, range 0100, `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, integer `K`, range 100010000, `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_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.
- **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 position`.
- **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 (`brightness` | `color_temp` | `sun_position`); 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,127 @@
## 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 position | `_sun_position` | `"Sun position"` | `"°"` | `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_position` 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_position` 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.
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
- **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
- **AND** `updated_at` SHALL be a `datetime` no older than the previous tick's `updated_at` value
#### 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
### 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 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"`.
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-position sensor | `"Sun position"` | `Dining MVP Sun position` |
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-position sensor's friendly name SHALL be exactly "Dining MVP Sun position"
#### 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,77 @@
<!--
Annotations:
R1R5 = 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"
D1D8 = Decisions in design.md
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
- [ ] 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]
## 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]
## 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]
## 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]
## 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_<name>_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]
## 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.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]
## 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]