diff --git a/openspec/changes/add-output-sensors/.openspec.yaml b/openspec/changes/add-output-sensors/.openspec.yaml new file mode 100644 index 00000000..66da1ae9 --- /dev/null +++ b/openspec/changes/add-output-sensors/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-17 diff --git a/openspec/changes/add-output-sensors/design.md b/openspec/changes/add-output-sensors/design.md new file mode 100644 index 00000000..04e7249e --- /dev/null +++ b/openspec/changes/add-output-sensors/design.md @@ -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 ` ` 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": , + "output_color_temp": , + "sun_position": , + "updated_at": , +} +``` + +The cache dict keys match the sensor `OUTPUT_SENSORS[*]["key"]` values exactly, so a sensor reads `hass.data[DOMAIN][entry.entry_id]["outputs"][self._output_key]` with no intermediate mapping. The master switch's existing attributes (`current_brightness`, `current_color_temp`, `sun_position`) are unchanged — the publish step copies the computed values into the cache under the new key names; the old attributes remain on the switch for backward compatibility (per proposal). + +Sensors read from this dict at write-state time. The master switch writes; sensors read. One direction. + +**Why:** Three options were on the table: +- **(a) Each sensor recomputes the curve.** Rejected — duplicates the curve math three times per tick; introduces drift risk if computation isn't bit-identical; sensors would need access to all the inputs (sun entities, range numbers, options) which inverts the dependency. +- **(b) Sensors read master-switch attributes via `hass.states.get().attributes["current_brightness"]`.** Rejected — couples sensor lifecycle to the switch entity's published state, which is async (state lags compute by one event-bus hop), and breaks if the attribute key ever renames. +- **(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 ` <_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). diff --git a/openspec/changes/add-output-sensors/proposal.md b/openspec/changes/add-output-sensors/proposal.md new file mode 100644 index 00000000..1258197f --- /dev/null +++ b/openspec/changes/add-output-sensors/proposal.md @@ -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__output_brightness` — current target brightness, integer `%`, range 0–100, `state_class: measurement`, `icon: mdi:brightness-percent`. "Output" prefix disambiguates from the existing "Brightness" switch and "Min/Max brightness" number entities on the same device. + - `sensor.adaptive_lighting__output_color_temp` — current target color temperature, integer `K`, range 1000–10000, `state_class: measurement`, no `device_class` (`SensorDeviceClass.TEMPERATURE` is wrong; color temp is not thermal), `icon: mdi:thermometer`. "Output" prefix maintains parallelism with brightness and disambiguates from "Min/Max color temp" numbers. + - `sensor.adaptive_lighting__sun_position` — solar-elevation angle driving the curve, float in degrees (range roughly -90 to +90, sourced from the user's configured Sun2 / `sensor.sun_*` entity), `state_class: measurement`, `unit: "°"`, `icon: mdi:weather-sunset`. Useful for debugging the curve shape via the History panel. No prefix needed; no existing entity uses "Sun" in its name. +- **Single read path**: sensors read the same curve-math outputs the master switch's adapt loop already computes — no second computation pass. The sensors update on the same tick as the existing curve evaluation (`interval` setting, default 90 s). +- **`has_entity_name = True`** following the convention established in `add-runtime-range-controls` Decision 11. Friendly names become ` Output brightness`, ` Output color temp`, ` Sun position`. +- **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). diff --git a/openspec/changes/add-output-sensors/specs/output-sensors/spec.md b/openspec/changes/add-output-sensors/specs/output-sensors/spec.md new file mode 100644 index 00000000..b9279843 --- /dev/null +++ b/openspec/changes/add-output-sensors/specs/output-sensors/spec.md @@ -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 `_`. + +#### 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 diff --git a/openspec/changes/add-output-sensors/tasks.md b/openspec/changes/add-output-sensors/tasks.md new file mode 100644 index 00000000..d66015fe --- /dev/null +++ b/openspec/changes/add-output-sensors/tasks.md @@ -0,0 +1,77 @@ + + +## 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__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]