Implement add-output-sensors: 36/41 tasks complete

Three sensor entities per AL profile expose the curve's current outputs
plus actual sun elevation as graphable numerics with
SensorStateClass.MEASUREMENT, so HA's recorder + apexcharts-card chart
them natively:

- sensor.<profile>_output_brightness   (% — from self._settings)
- sensor.<profile>_output_color_temp   (K — from self._settings)
- sensor.<profile>_sun_elevation       (° — from sun.sun.attributes.elevation)

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 and read from the cache.
Single computation path, push-based updates, no polling.

The existing master switch attributes (brightness_pct, color_temp_kelvin,
synthetic sun_position in [-1,+1]) are unchanged — sensors are purely
additive. Manifest bumped to 2.2.0-cdit.1 (minor; no breaking changes).

Artifact realignment along the way: Q2 answer assumed sun_position was
Sun2 elevation degrees, but reading color_and_brightness.py revealed
it's a synthetic float in [-1,+1] derived from the brightness curve.
Switched to pulling actual elevation from sun.sun (Decision 8 added),
renamed sensor from sun_position to sun_elevation. Friendly names use
asymmetric "Output" prefix to dodge collision with the adapt-brightness
switch and Min/Max color-temp numbers.

Remaining: group 6 (manual live-HA verification on
homeassistant.onca-blenny.ts.net) — requires HACS deploy.

15 new sensor tests pass; full suite green (123/123). openspec
validate --strict green. ruff clean on new files.
This commit is contained in:
Casey 2026-05-21 10:11:52 +02:00
commit 1b21228b30
18 changed files with 775 additions and 93 deletions

View file

@ -26,6 +26,42 @@ maintainers who can actually fix them for everyone.
---
## [2.2.0-cdit.1] — Unreleased
### Added
- **Three read-only `sensor` entities per profile**`output_brightness`
(%), `output_color_temp` (K), `sun_elevation` (°). They expose the
curve's current target outputs alongside HA's built-in `sun.sun`
elevation as graphable numerics with `state_class: measurement`, so
the History panel, `apexcharts-card`, and `mini-graph-card` chart
them natively. The existing master-switch attributes (`brightness_pct`,
`color_temp_kelvin`, the synthetic `sun_position` in [-1, +1]) are
unchanged.
- **Push-based sensor updates** via a per-entry dispatcher signal fired
by the master switch after each curve tick. No polling, no duplicate
curve math — sensors are pure readers of a runtime cache the master
switch publishes to.
- **`sun_elevation` is sourced from `sun.sun.attributes.elevation`** on
every curve tick. If `sun.sun` is missing or the attribute is absent
(rare; can happen during very early HA startup), the sensor renders
as `unknown` for that tick — the other two sensors continue updating
normally.
### Changed
- **`manifest.json` version bumped to `2.2.0-cdit.1`.** Minor bump; no
breaking config-entry changes — existing 2.1 entries upgrade in place
and gain the three new sensor entities on next setup.
### Migration
No user action required. Restart HA after upgrade and the three new
sensors appear under each profile's device page. Add them to a Lovelace
card or `apexcharts-card` to start graphing.
---
## [2.1.0-cdit.1] — Unreleased
### Added

View file

@ -99,6 +99,33 @@
> Color" — short enough to fit HA's tightest cards. Existing entity_ids
> are preserved by the entity registry; automations keep working.
>
> ## ✨ What's new in 2.2
>
> Each profile now exposes **three read-only `sensor` entities** that
> publish the curve's current outputs alongside the sun's actual elevation
> — graphable as numerics by HA's recorder, the History panel,
> `apexcharts-card`, and `mini-graph-card`:
>
> - `sensor.<profile>_output_brightness` — current target brightness (%)
> - `sensor.<profile>_output_color_temp` — current target color temp (K)
> - `sensor.<profile>_sun_elevation` — solar elevation from `sun.sun` (°)
>
> The existing master-switch attributes (`brightness_pct`,
> `color_temp_kelvin`, the synthetic `sun_position` in [-1, +1]) are
> unchanged — anything reading them today keeps working. The sensors are
> the *graphable* version of the same data.
>
> Drop them on a dashboard with `apexcharts-card` to see the curve over time:
>
> ```yaml
> type: custom:apexcharts-card
> graph_span: 24h
> series:
> - entity: sensor.dining_mvp_output_brightness
> - entity: sensor.dining_mvp_output_color_temp
> - entity: sensor.dining_mvp_sun_elevation
> ```
>
> The rest of this README is from upstream and may describe features that no
> longer exist in this fork. See `CHANGELOG.md` for the canonical list of
> CDiT-specific changes.

View file

@ -21,7 +21,7 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
PLATFORMS = ["switch", "number"]
PLATFORMS = ["switch", "number", "sensor"]
# unique_id suffix(es) that this fork no longer creates. Any entity in the
# registry whose unique_id ends with one of these strings AND that is owned
@ -128,7 +128,12 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
# and explicit `hass.reload_config_entry` calls).
hass.bus.async_listen("hass.config.entry_updated", reload_configuration_yaml)
data[config_entry.entry_id] = {}
data[config_entry.entry_id] = {
# Cache slot the master switch publishes to after each curve tick
# (add-output-sensors / D2). Initialized to None so any sensor that
# reads before the first tick sees a sentinel rather than a KeyError.
"outputs": None,
}
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
return True

View file

@ -196,6 +196,37 @@ RANGE_ENTITIES: list[dict[str, Any]] = [
},
]
# Output sensor declarations (add-output-sensors / R1, D4, D7).
# Each dict drives one read-only `sensor` entity per AL profile. The `key`
# becomes both the sensor's unique-id suffix AND the cache-dict key in
# `hass.data[DOMAIN][entry_id]["outputs"]`, so the sensor reads
# `outputs[self._output_key]` with no intermediate mapping.
OUTPUT_SENSORS: list[dict[str, Any]] = [
{
"key": "output_brightness",
"name": "Output brightness",
"unit": "%",
"icon": "mdi:brightness-percent",
},
{
"key": "output_color_temp",
"name": "Output color temp",
"unit": "K",
"icon": "mdi:thermometer",
},
{
"key": "sun_elevation",
"name": "Sun elevation",
"unit": "°",
"icon": "mdi:weather-sunset",
},
]
# Dispatcher signal used by the master switch to wake the output sensors
# after each curve tick. Keyed per config entry so two profiles don't
# cross-update. Format with `.format(entry_id=...)` at call/connect time.
SIGNAL_OUTPUTS_UPDATED = f"{DOMAIN}_{{entry_id}}_outputs_updated"
ATTR_ADAPT_COLOR = "adapt_color"
DOCS[ATTR_ADAPT_COLOR] = "Adjust the color of supporting lights over the day."
ATTR_ADAPT_BRIGHTNESS = "adapt_brightness"

View file

@ -9,5 +9,5 @@
"iot_class": "calculated",
"issue_tracker": "https://github.com/CaseyRo/adaptive-lighting/issues",
"requirements": ["ulid-transform"],
"version": "2.1.0-cdit.1"
"version": "2.2.0-cdit.1"
}

View file

@ -19,7 +19,7 @@ integration, and the resulting fresh entities prefer the just-saved
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING
from homeassistant.components.number import (
NumberMode,

View file

@ -0,0 +1,123 @@
"""Sensor platform for the Adaptive Lighting integration (CDiT fork).
Each config entry exposes three read-only output sensors that publish the
curve's current target values + the actual sun elevation:
- ``sensor.<profile>_output_brightness`` (% from self._settings["brightness_pct"])
- ``sensor.<profile>_output_color_temp`` (K from self._settings["color_temp_kelvin"])
- ``sensor.<profile>_sun_elevation`` (° from sun.sun.attributes["elevation"])
The sensors update via a per-entry dispatcher signal fired by the master
switch after every curve evaluation. They are pure readers no curve math
runs in the sensor class. See ``add-output-sensors/design.md`` decisions
2, 3, and 8.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from homeassistant.components.sensor import SensorEntity, SensorStateClass
from homeassistant.core import callback
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import DeviceInfo
from .const import DOMAIN, OUTPUT_SENSORS, SIGNAL_OUTPUTS_UPDATED
if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Create the three output sensors for this config entry."""
entities = [
AdaptiveOutputSensor(
hass=hass,
entry=config_entry,
output_key=row["key"],
display_name=row["name"],
unit=row["unit"],
icon=row["icon"],
)
for row in OUTPUT_SENSORS
]
async_add_entities(entities)
class AdaptiveOutputSensor(SensorEntity):
"""One read-only output value from an AL profile's curve tick."""
_attr_has_entity_name = True
_attr_should_poll = False
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_device_class = None # explicit — no fitting HA device class
def __init__(
self,
*,
hass: HomeAssistant,
entry: ConfigEntry,
output_key: str,
display_name: str,
unit: str,
icon: str,
) -> None:
"""Initialise a single output sensor entity."""
self._hass = hass
self._entry = entry
self._output_key = output_key
self._attr_name = display_name
self._attr_translation_key = output_key
self._attr_unique_id = f"{entry.entry_id}_{output_key}"
self._attr_native_unit_of_measurement = unit
self._attr_icon = icon
# Renders as `unknown` until the first dispatcher signal arrives.
# Do NOT use RestoreEntity — restored values would be stale (the
# sun has moved); `unknown` for one tick is honest.
self._attr_native_value = None
@property
def device_info(self) -> DeviceInfo:
"""Group with the profile's switches and number entities."""
profile_name = self._entry.data.get("name") or self._entry.title
return DeviceInfo(
identifiers={(DOMAIN, profile_name)},
name=profile_name,
entry_type=DeviceEntryType.SERVICE,
)
async def async_added_to_hass(self) -> None:
"""Subscribe to the per-entry outputs-updated dispatcher signal."""
await super().async_added_to_hass()
signal = SIGNAL_OUTPUTS_UPDATED.format(entry_id=self._entry.entry_id)
self.async_on_remove(
async_dispatcher_connect(
self._hass,
signal,
self._handle_outputs_updated,
),
)
@callback
def _handle_outputs_updated(self) -> None:
"""Read this sensor's value from the cache and write state."""
outputs = (
self._hass.data.get(DOMAIN, {})
.get(self._entry.entry_id, {})
.get("outputs")
)
if not outputs:
# Early signal (e.g., setup race) — leave state as unknown.
return
self._attr_native_value = outputs.get(self._output_key)
self.async_write_ha_state()

View file

@ -258,6 +258,17 @@
"max_color_temp": {
"name": "Max color temp"
}
},
"sensor": {
"output_brightness": {
"name": "Output brightness"
},
"output_color_temp": {
"name": "Output color temp"
},
"sun_elevation": {
"name": "Sun elevation"
}
}
}
}

View file

@ -57,6 +57,7 @@ from homeassistant.core import (
)
from homeassistant.helpers import entity_platform, entity_registry
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.entity_component import async_update_entity
from homeassistant.helpers.event import (
EventStateChangedData,
@ -112,6 +113,7 @@ from .const import (
RAMP_HALF_WIDTH_SECONDS,
SERVICE_APPLY,
SERVICE_CHANGE_SWITCH_SETTINGS,
SIGNAL_OUTPUTS_UPDATED,
TURNING_OFF_DELAY,
VALIDATION_TUPLES,
apply_service_schema,
@ -1351,6 +1353,34 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
data,
)
def _publish_outputs_and_wake_sensors(self) -> None:
"""Publish curve outputs to the runtime cache and wake the sensors.
Writes ``hass.data[DOMAIN][entry_id]["outputs"]`` from the
just-populated ``self._settings`` plus the current ``sun.sun``
elevation, then fires a per-entry dispatcher signal so the three
``sensor.<profile>_*`` entities update their state on the same
tick rhythm as the switch itself. See
``add-output-sensors/design.md`` decisions 2, 3, and 8.
"""
sun_state = self.hass.states.get("sun.sun")
sun_elevation = (
sun_state.attributes.get("elevation") if sun_state is not None else None
)
brightness = self._settings.get("brightness_pct")
color_temp = self._settings.get("color_temp_kelvin")
entry_data = self.hass.data[DOMAIN].setdefault(self._config_entry.entry_id, {})
entry_data["outputs"] = {
"output_brightness": int(brightness) if brightness is not None else None,
"output_color_temp": int(color_temp) if color_temp is not None else None,
"sun_elevation": sun_elevation,
"updated_at": dt_util.utcnow(),
}
async_dispatcher_send(
self.hass,
SIGNAL_OUTPUTS_UPDATED.format(entry_id=self._config_entry.entry_id),
)
async def _update_attrs_and_maybe_adapt_lights(
self,
*,
@ -1380,6 +1410,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
t_sunset,
),
)
self._publish_outputs_and_wake_sensors()
self.async_write_ha_state()
if lights is None:

View file

@ -258,6 +258,17 @@
"max_color_temp": {
"name": "Max color temp"
}
},
"sensor": {
"output_brightness": {
"name": "Output brightness"
},
"output_color_temp": {
"name": "Output color temp"
},
"sun_elevation": {
"name": "Sun elevation"
}
}
}
}

View file

@ -1,6 +1,8 @@
## Context
After `cdit-config-redesign` and `add-runtime-range-controls`, the master switch (`AdaptiveSwitch`) computes the curve outputs — brightness %, color-temperature K, and the sun-position float driving the curve — once per `interval` tick (default 90 s) and exposes them as **attributes** (`current_brightness`, `current_color_temp`, `sun_position`). The values are visible in the master switch's More-info dialog and in diagnostics downloads, but HA's recorder stores `attributes` as text-on-state, not as numeric columns. The History panel, `apexcharts-card`, and `mini-graph-card` all consume entity *state*, not attributes.
After `cdit-config-redesign` and `add-runtime-range-controls`, the master switch (`AdaptiveSwitch`) computes the curve outputs — brightness %, color-temperature K — once per `interval` tick (default 90 s) and exposes them as **attributes** via `self._settings` (`brightness_pct`, `color_temp_kelvin`, and a synthetic `sun_position` in [-1, +1] derived from the brightness curve). The values are visible in the master switch's More-info dialog and in diagnostics downloads, but HA's recorder stores `attributes` as text-on-state, not as numeric columns. The History panel, `apexcharts-card`, and `mini-graph-card` all consume entity *state*, not attributes.
The integration does not currently expose actual solar elevation. HA's built-in `sun.sun` entity carries an `elevation` attribute (float degrees, range -90 to +90, updated continuously by HA's `sun` integration). This change adds it as a graphable sensor alongside the curve outputs — useful for visualizing the astronomical driver next to the integration's response.
The fix is to expose those three already-computed values as dedicated `sensor` entities. The curve math doesn't change; the sensor platform is a thin presentation layer over values the switch is already computing.
@ -9,11 +11,11 @@ The architectural question this design resolves is the **read path**: how do thr
## Goals / Non-Goals
**Goals:**
- Three sensor entities per AL profile (`output_brightness`, `output_color_temp`, `sun_position`), each `SensorStateClass.MEASUREMENT` so HA's recorder graphs them as numerics.
- Three sensor entities per AL profile (`output_brightness`, `output_color_temp`, `sun_elevation`), each `SensorStateClass.MEASUREMENT` so HA's recorder graphs them as numerics.
- Single computation path. Curve evaluation happens once per tick in the master switch's adapt loop; sensors are pure readers.
- Push-based update: when the master switch finishes computing, sensors are notified and write their state. No polling, no duplicated `async_track_time_interval` timers.
- `has_entity_name = True` following the convention from `add-runtime-range-controls` Decision 11. Friendly names compose `<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.
- Purely additive — the existing master-switch attributes (`brightness_pct`, `color_temp_kelvin`, `sun_position`, etc.) remain in place. Anyone reading them today keeps working.
**Non-Goals:**
- Removing or deprecating the existing master-switch attributes. They are diagnostic surface; their cost is zero and removing them would break diagnostics downloads.
@ -37,24 +39,24 @@ The architectural question this design resolves is the **read path**: how do thr
### Decision 2: Master switch publishes outputs to `hass.data`; sensors read from there
**What we chose:** After each curve evaluation in `AdaptiveSwitch._async_update_attrs` (or wherever the integration currently sets `current_brightness` / `current_color_temp` / `sun_position`), publish the same three values to:
**What we chose:** After each curve evaluation in `AdaptiveSwitch._update_attrs_and_maybe_adapt_lights` (where `self._settings` is populated from `SunLightSettings.get_settings()`), publish the values to:
```python
hass.data[DOMAIN][entry.entry_id]["outputs"] = {
"output_brightness": <int 0-100>,
"output_color_temp": <int K>,
"sun_position": <float, degrees of solar elevation>,
"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 (`current_brightness`, `current_color_temp`, `sun_position`) are unchanged — the publish step copies the computed values into the cache under the new key names; the old attributes remain on the switch for backward compatibility (per proposal).
The cache dict keys match the sensor `OUTPUT_SENSORS[*]["key"]` values exactly, so a sensor reads `hass.data[DOMAIN][entry.entry_id]["outputs"][self._output_key]` with no intermediate mapping. The master switch's existing attributes (`brightness_pct`, `color_temp_kelvin`, `sun_position` in [-1, +1]) are unchanged — the publish step copies the relevant values into the cache under the new key names; the old attributes remain on the switch for backward compatibility (per proposal). The new `sun_elevation` value is read fresh on each tick from `sun.sun` and has no counterpart on the master switch.
Sensors read from this dict at write-state time. The master switch writes; sensors read. One direction.
**Why:** Three options were on the table:
- **(a) Each sensor recomputes the curve.** Rejected — duplicates the curve math three times per tick; introduces drift risk if computation isn't bit-identical; sensors would need access to all the inputs (sun entities, range numbers, options) which inverts the dependency.
- **(b) Sensors read master-switch attributes via `hass.states.get(<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.
- **(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.
@ -86,25 +88,25 @@ Each sensor subscribes to this exact signal in its `async_added_to_hass`. On rec
|---|---|---|---|---|---|---|
| Output brightness | `_output_brightness` | `"Output brightness"` | `"%"` | `MEASUREMENT` | (none) | `mdi:brightness-percent` |
| Output color temperature | `_output_color_temp` | `"Output color temp"` | `"K"` | `MEASUREMENT` | (none) | `mdi:thermometer` |
| Sun position | `_sun_position` | `"Sun position"` | `"°"` | `MEASUREMENT` | (none) | `mdi:weather-sunset` |
| Sun elevation | `_sun_elevation` | `"Sun elevation"` | `"°"` | `MEASUREMENT` | (none) | `mdi:weather-sunset` |
**Why:**
- `state_class: MEASUREMENT` is the trigger that makes HA's recorder graph the entity as a numeric series and surface it in the History panel + `apexcharts-card` automatically. This is the entire reason the change exists.
- No `device_class`: `SensorDeviceClass.TEMPERATURE` is for thermal sensors (°C/°F), not color temperature; brightness % has no fitting device class in HA's enum; sun-position degrees has no `device_class` enum value (HA has no `ELEVATION` or `ANGLE` class). Setting a device class wrongly forces HA's UI into the wrong unit-conversion behavior and is worse than setting none.
- Units: `"%"`, `"K"`, and `"°"` are all accepted by HA as free-form `native_unit_of_measurement` strings; they render in the UI without unit conversion. Sun position is the solar-elevation angle sourced from the user's configured Sun2 / `sensor.sun_*` entity (range roughly -90 to +90).
- Icons: brightness-percent for brightness pairs visually with the `mdi:brightness-3` / `mdi:brightness-7` already used on the range numbers; thermometer for color temp matches the range-number convention; weather-sunset for sun position is self-explanatory and ties the value to its source.
- No `device_class`: `SensorDeviceClass.TEMPERATURE` is for thermal sensors (°C/°F), not color temperature; brightness % has no fitting device class in HA's enum; HA has no `ELEVATION` or `ANGLE` device class for sun elevation either. Setting a device class wrongly forces HA's UI into the wrong unit-conversion behavior and is worse than setting none.
- Units: `"%"`, `"K"`, and `"°"` are all accepted by HA as free-form `native_unit_of_measurement` strings; they render in the UI without unit conversion. Sun elevation is the angle of the sun above (positive) or below (negative) the horizon, in degrees, range -90 to +90.
- Icons: brightness-percent for brightness pairs visually with the `mdi:brightness-3` / `mdi:brightness-7` already used on the range numbers; thermometer for color temp matches the range-number convention; weather-sunset for sun elevation is self-explanatory.
**Naming rationale (the asymmetric "Output" prefix):**
- `"Output brightness"``"Brightness"` collides with the adapt-brightness switch's friendly name ("Dining MVP Brightness") from `add-runtime-range-controls` R7. `"Output"` resolves the collision and parallels the capability name (`output-sensors`).
- `"Output color temp"` — no hard collision (the switch is `"Color"`, not `"Color temp"`), but kept the prefix for parallelism with brightness AND to disambiguate from the existing `"Min color temp"` / `"Max color temp"` number entities on the same device.
- `"Sun position"` — no prefix needed; no other entity on the device uses `"Sun"` in its name. Adding `"Output sun position"` would be inaccurate anyway — sun position is the curve's *input*, not its output.
- `"Sun elevation"` — no prefix needed; no other entity on the device uses `"Sun"` in its name. Adding `"Output sun elevation"` would be inaccurate — sun elevation is not an integration output, it's a passthrough from `sun.sun`.
**Alternatives considered:**
- **`SensorDeviceClass.ILLUMINANCE`** for brightness. Rejected — illuminance is lux (a measured external value), not a target output percentage.
- **No `state_class`** to leave the recorder behavior implicit. Rejected — explicit `MEASUREMENT` is what makes the recorder treat the values as graphable; omitting it defeats the change.
- **Naming as `"Brightness"` / `"Color temp"` / `"Sun"`.** Rejected — collides with `"Brightness"` switch; `"Color temp"` is also ambiguous next to `"Min/Max color temp"` numbers.
- **Naming as `"Current brightness"` / `"Current color temp"` / `"Sun position"`.** Considered (matches existing master-switch attribute keys `current_brightness`, `current_color_temp`). Rejected in favor of `"Output X"` — capability name parallel, and "current" is redundant when HA's More-info dialog already shows a current value.
- **Symmetric `"Output sun position"`.** Rejected — sun position is an input to the curve, not an output; the prefix would be inaccurate.
- **Exposing the master switch's synthetic `sun_position` ([-1, +1]) instead of actual elevation.** Rejected — the synthetic value is a curve internal; users grep "where is the sun?" want degrees, not a normalized ratio. (Considered, then explicitly rejected; the synthetic attribute stays available for anyone who wants it.)
- **Symmetric `"Output sun elevation"`.** Rejected — `sun.sun` owns the elevation value; the integration is a passthrough.
### Decision 5: Sensor state before first tick is `STATE_UNKNOWN`
@ -132,7 +134,7 @@ Each sensor subscribes to this exact signal in its `async_added_to_hass`. On rec
OUTPUT_SENSORS = [
{"key": "output_brightness", "name": "Output brightness", "unit": "%", "icon": "mdi:brightness-percent"},
{"key": "output_color_temp", "name": "Output color temp", "unit": "K", "icon": "mdi:thermometer"},
{"key": "sun_position", "name": "Sun position", "unit": "°", "icon": "mdi:weather-sunset"},
{"key": "sun_elevation", "name": "Sun elevation", "unit": "°", "icon": "mdi:weather-sunset"},
]
```
@ -144,7 +146,19 @@ Sensor platform iterates this list. Tests reference it.
- **Three separate sensor classes** (`BrightnessSensor`, `ColorTempSensor`, `SunPositionSensor`). Rejected — each class would be 90% identical; the dict-driven instantiation is shorter and easier to extend.
- **Class hierarchy with a base + three subclasses.** Rejected — overengineering for three uniform sensors with no behavioral divergence.
### Decision 8: No service surface, no options-flow surface
### Decision 8: `sun_elevation` is read from `sun.sun.attributes["elevation"]` on every curve tick
**What we chose:** During the master switch's curve-tick publish step, the integration reads `hass.states.get("sun.sun")` and writes `state.attributes.get("elevation")` (a float in degrees, range -90 to +90) into the cache as `outputs["sun_elevation"]`. If `sun.sun` is missing or the attribute is absent, the cache value is `None` and the sensor renders as `unknown`.
**Why:** `sun.sun` is a built-in HA entity present in every install — no integration check needed. Its `elevation` attribute is updated continuously by HA's `sun` integration (every ~30 s by default), so reading it once per AL curve tick (every 90 s default) is fresh enough. Reading at tick-time keeps all three sensors on the same update rhythm — one dispatcher signal, all three sensors update together.
**Alternatives considered:**
- **Subscribe to `state_changed` on `sun.sun` and update `sun_elevation` independently of the curve tick.** Rejected — adds a second update path with a different rhythm. Two sensors updating at 90 s and one at 30 s in the same dashboard card looks like a bug. The 60 s latency penalty is invisible (the sun moves about 0.25° in 60 s; below the integration's already-coarse "degree" rendering).
- **Use the configured `CONF_SUNRISE_ENTITY` / `CONF_SUNSET_ENTITY` source.** Rejected — those are timestamp sensors (next-rising / next-setting), not elevation sensors. Different domain.
- **Add a new `CONF_SUN_ELEVATION_ENTITY` config field defaulting to `sun.sun`.** Rejected — yet another knob; `sun.sun` works for everyone. Re-evaluate if anyone ever has a Sun2 elevation override use case.
- **Drop the `sun_elevation` sensor entirely.** Considered (option C from Q&A) and rejected — actual sun elevation is the canonical "where in the day are we?" data the user wants alongside the brightness/CT curve. Without it, the third sensor slot is missing the most-asked-for value.
### Decision 9: No service surface, no options-flow surface
**What we chose:** This change adds no services, no options-flow fields, no new config keys. The integration's `services.yaml`, `config_flow.py`, `strings.json` (apart from the three sensor name keys) are untouched.
@ -158,6 +172,8 @@ Sensor platform iterates this list. Tests reference it.
- **[Master switch and sensor compute paths could diverge in a future refactor]** → Mitigation: the master switch is the only place curve math runs; sensors do not duplicate it. Anyone moving curve math out of the master switch in the future will see the `hass.data[DOMAIN][entry_id]["outputs"]` publish line and the dispatcher signal as the explicit handoff; refactoring without preserving this contract would visibly break the sensors. Test 5.x asserts the contract.
- **[Sensor state stays `unknown` if the master switch's curve loop never runs]** → Possible if the switch fails to set up. Mitigation: that failure mode is already user-visible (the master switch entity itself shows as `unavailable`); the sensors merely echo it. No new debugging surface.
- **[Recorder explosion: 3 sensors × N profiles × MEASUREMENT state_class]** → For a 6-profile household (CDiT's case), that's 18 new sensors writing one row every 90 s = ~17 280 rows/day. Recorder + statistics handle this without strain; the values compress well. Mitigation: `recorder` config's `purge_keep_days` default (10 days) bounds disk usage; no action needed.
- **[`sun.sun` entity missing or `elevation` attribute absent]** → Possible during very early HA startup before the `sun` integration finishes loading, or in unusual deployments that disable `sun`. Mitigation: read with `.attributes.get("elevation")` so the cache value is `None`; the sensor renders as `unknown` until the next tick where `sun.sun` is populated. No exception is raised; the other two sensors continue updating normally.
- **[Two "sun" values present: `sensor.adaptive_lighting_<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.
@ -173,8 +189,8 @@ Single PR on the fork's `main` branch. Depends on `cdit-config-redesign` and `ad
5. Tag release (`v2.3.0-cdit.1` or whatever the next minor is) — minor bump, no breaking change.
6. Existing config entries pick up three new sensors on next HA restart. No user action required.
**Rollback:** revert the PR. The three sensor entities disappear from the entity registry; the master switch attributes (`current_brightness` etc.) remain unchanged because they were never removed. Any History/`apexcharts-card` configs pointing at the new sensors will show "entity not found" until the rollback is reverted again. No data loss.
**Rollback:** revert the PR. The three sensor entities disappear from the entity registry; the master switch attributes (`brightness_pct`, `color_temp_kelvin`, `sun_position`, etc. — all from `self._settings`) remain unchanged because they were never removed. Any History/`apexcharts-card` configs pointing at the new sensors will show "entity not found" until the rollback is reverted again. No data loss.
## Open Questions
None. All eight decisions resolved. The "should sensors be `RestoreSensor`?" question is settled by Decision 5 (no — stale data is misleading; `unknown` for one tick is honest).
None. All nine decisions resolved. The "should sensors be `RestoreSensor`?" question is settled by Decision 5 (no — stale data is misleading; `unknown` for one tick is honest). The "where does sun elevation come from?" question is settled by Decision 8 (`sun.sun.attributes.elevation`).

View file

@ -1,17 +1,19 @@
## Why
The integration's runtime outputs — current brightness %, current color temperature in K, and the sun-position value driving the curve — exist today only as `attributes` on the master switch entity (`current_brightness`, `current_color_temp`, `sun_position`). HA's recorder stores attribute values as text-on-state, so the History panel, `apexcharts-card`, `mini-graph-card`, and any "show me yesterday's AL curve" automation can't graph them as numerics. The values are visible but not analytically usable.
The integration's runtime outputs — current target brightness %, current target color temperature in K — exist today only as `attributes` on the master switch entity (`brightness_pct`, `color_temp_kelvin`, alongside synthetic `sun_position` and others, all from `self._settings`). HA's recorder stores attribute values as text-on-state, so the History panel, `apexcharts-card`, `mini-graph-card`, and any "show me yesterday's AL curve" automation can't graph them as numerics. The values are visible but not analytically usable.
Separately, the actual solar elevation angle (degrees, sourced from HA's built-in `sun.sun` entity's `elevation` attribute) is genuinely useful to graph alongside the curve outputs — it shows the astronomical driver next to the integration's response. The master switch does not currently expose this value at all (its `sun_position` attribute is a synthetic [-1, +1] float derived from the brightness curve, not real elevation).
This change promotes those three outputs to first-class `sensor` entities per AL profile, with `SensorStateClass.MEASUREMENT` so the recorder graphs them and stock cards consume them natively. This is the explicit complement to the just-killed `add-lovelace-card` proposal: stock Lovelace + `apexcharts-card` becomes the charting story once the data lives on entities the cards can read.
## What Changes
- **Add a `sensor` platform** to the integration. Each AL config entry creates three sensor entities:
- `sensor.adaptive_lighting_<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.
- `sensor.adaptive_lighting_<name>_output_brightness` — current target brightness sourced from `self._settings["brightness_pct"]`, 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 sourced from `self._settings["color_temp_kelvin"]`, 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_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 position`.
- **`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.
@ -27,7 +29,7 @@ None. Sensors are purely additive: existing switches, number entities, and optio
## Impact
- **`custom_components/adaptive_lighting/sensor.py`** — new file, `sensor` platform implementation. One sensor class (`AdaptiveOutputSensor`) parameterized by output key (`brightness` | `color_temp` | `sun_position`); three instances per config entry.
- **`custom_components/adaptive_lighting/sensor.py`** — new file, `sensor` platform implementation. One sensor class (`AdaptiveOutputSensor`) parameterized by output key (`output_brightness` | `output_color_temp` | `sun_elevation`); three instances per config entry.
- **`custom_components/adaptive_lighting/const.py`** — `Platform.SENSOR` appended to the platform list; sensor unique-id pattern constants; output-key enum.
- **`custom_components/adaptive_lighting/__init__.py`** — `async_setup_entry` forwards setup to the new sensor platform. Master switch's adapt loop publishes its computed outputs to a per-entry runtime data structure (`hass.data[DOMAIN][entry.entry_id]`) that the sensors read on update — or sensors read the master switch's existing computed attributes directly, TBD in design.
- **`custom_components/adaptive_lighting/switch.py`** — no behavioral change; if the design picks the "publish to `hass.data`" approach, the master switch gains one or two lines to update that dict.

View file

@ -8,7 +8,7 @@ For each Adaptive Lighting config entry, the integration SHALL create exactly th
|---|---|---|---|---|---|---|
| Output brightness | `_output_brightness` | `"Output brightness"` | `"%"` | `MEASUREMENT` | (none) | `mdi:brightness-percent` |
| Output color temperature | `_output_color_temp` | `"Output color temp"` | `"K"` | `MEASUREMENT` | (none) | `mdi:thermometer` |
| Sun position | `_sun_position` | `"Sun position"` | `"°"` | `MEASUREMENT` | (none) | `mdi:weather-sunset` |
| Sun elevation | `_sun_elevation` | `"Sun elevation"` | `"°"` | `MEASUREMENT` | (none) | `mdi:weather-sunset` |
The full `unique_id` SHALL be `<entry.entry_id>_<suffix>`.
@ -17,7 +17,7 @@ The full `unique_id` SHALL be `<entry.entry_id>_<suffix>`.
- **WHEN** the user creates a new Adaptive Lighting config entry
- **AND** `async_setup_entry` completes
- **THEN** the entity registry SHALL contain three `sensor` entities owned by this entry
- **AND** their unique_ids SHALL end with `_output_brightness`, `_output_color_temp`, and `_sun_position` respectively
- **AND** their unique_ids SHALL end with `_output_brightness`, `_output_color_temp`, and `_sun_elevation` respectively
- **AND** all three sensors SHALL be attached to the same device as the profile's switches and number entities
#### Scenario: Sensor metadata matches the design table
@ -25,11 +25,13 @@ The full `unique_id` SHALL be `<entry.entry_id>_<suffix>`.
- **WHEN** any of the three sensors is inspected via the entity registry
- **THEN** `output_brightness` SHALL declare `native_unit_of_measurement="%"`, `state_class=SensorStateClass.MEASUREMENT`, no `device_class`
- **AND** `output_color_temp` SHALL declare `native_unit_of_measurement="K"`, `state_class=SensorStateClass.MEASUREMENT`, no `device_class`
- **AND** `sun_position` SHALL declare `native_unit_of_measurement="°"`, `state_class=SensorStateClass.MEASUREMENT`, no `device_class`
- **AND** `sun_elevation` SHALL declare `native_unit_of_measurement="°"`, `state_class=SensorStateClass.MEASUREMENT`, no `device_class`
### Requirement: Curve evaluation publishes outputs to a runtime cache
On every curve evaluation tick, the master switch (`AdaptiveSwitch`) SHALL publish its three computed output values to `hass.data[DOMAIN][entry.entry_id]["outputs"]` as a dictionary with keys `output_brightness` (int 0-100), `output_color_temp` (int Kelvin), `sun_position` (float, degrees of solar elevation), and `updated_at` (datetime). This publish SHALL happen after the curve math completes and before any state writes to the switch's own attributes.
On every curve evaluation tick, the master switch (`AdaptiveSwitch`) SHALL publish to `hass.data[DOMAIN][entry.entry_id]["outputs"]` a dictionary with keys `output_brightness` (int 0-100, sourced from `self._settings["brightness_pct"]`), `output_color_temp` (int Kelvin, sourced from `self._settings["color_temp_kelvin"]`), `sun_elevation` (float degrees or `None`, sourced from `hass.states.get("sun.sun").attributes.get("elevation")`), and `updated_at` (datetime). This publish SHALL happen after the curve math completes and before any state writes to the switch's own attributes.
If `sun.sun` is missing from the state machine or its `elevation` attribute is absent, `sun_elevation` in the cache SHALL be `None`; the other three keys SHALL still be populated normally.
The cache dict keys SHALL match the `OUTPUT_SENSORS[*]["key"]` values exactly, so sensors read `hass.data[DOMAIN][entry.entry_id]["outputs"][self._output_key]` with no intermediate mapping.
@ -38,16 +40,26 @@ The integration SHALL NOT cause the sensor entities to recompute the curve. Sens
#### Scenario: Each curve tick refreshes the runtime cache
- **GIVEN** the integration is loaded and the master switch's adapt loop is running
- **AND** `sun.sun.attributes.elevation` is populated
- **WHEN** a curve evaluation tick completes
- **THEN** `hass.data[DOMAIN][entry.entry_id]["outputs"]` SHALL contain the four keys `output_brightness`, `output_color_temp`, `sun_position`, `updated_at`
- **AND** the values SHALL be the just-computed curve outputs
- **THEN** `hass.data[DOMAIN][entry.entry_id]["outputs"]` SHALL contain the four keys `output_brightness`, `output_color_temp`, `sun_elevation`, `updated_at`
- **AND** the values SHALL be the just-computed curve outputs (for the first two) and the current `sun.sun` elevation (for the third)
- **AND** `updated_at` SHALL be a `datetime` no older than the previous tick's `updated_at` value
#### Scenario: `sun.sun` unavailability does not block the publish
- **GIVEN** the integration is loaded and the master switch's adapt loop is running
- **AND** `hass.states.get("sun.sun")` returns `None` (or its `elevation` attribute is absent)
- **WHEN** a curve evaluation tick completes
- **THEN** `hass.data[DOMAIN][entry.entry_id]["outputs"]["sun_elevation"]` SHALL be `None`
- **AND** `output_brightness` and `output_color_temp` SHALL still hold their computed values
- **AND** no exception SHALL propagate out of the publish step
#### Scenario: Sensors do not perform their own curve math
- **WHEN** a sensor entity's `async_added_to_hass` and `_handle_outputs_updated` methods are inspected
- **THEN** neither method SHALL import `SunLightSettings` or any curve-computation helper
- **AND** neither method SHALL call `hass.states.get` for the sun-time entities or the four range number entities
- **AND** neither method SHALL call `hass.states.get` for the sun-time entities, the four range number entities, or `sun.sun`
### Requirement: Sensors update via a per-entry dispatcher signal
@ -98,7 +110,7 @@ Sensors SHALL NOT extend `RestoreEntity` or `RestoreSensor`. On entity addition
### Requirement: Sensors follow the `has_entity_name` composition
Every sensor entity created by this change SHALL set `_attr_has_entity_name = True` and SHALL register under the existing device record (`(DOMAIN, entry.entry_id)`) whose `name` is `entry.title`. Each sensor's `_attr_name` SHALL be exactly the role label from the table in the first requirement of this spec: `"Output brightness"`, `"Output color temp"`, `"Sun position"`.
Every sensor entity created by this change SHALL set `_attr_has_entity_name = True` and SHALL register under the existing per-profile device record whose `name` matches the profile's display name (i.e. attached to the same device as the profile's switches and number entities). Each sensor's `_attr_name` SHALL be exactly the role label from the table in the first requirement of this spec: `"Output brightness"`, `"Output color temp"`, `"Sun elevation"`.
The resulting friendly names SHALL follow this table for a profile named `Dining MVP`:
@ -106,7 +118,7 @@ The resulting friendly names SHALL follow this table for a profile named `Dining
|---|---|---|
| Output-brightness sensor | `"Output brightness"` | `Dining MVP Output brightness` |
| Output-color-temp sensor | `"Output color temp"` | `Dining MVP Output color temp` |
| Sun-position sensor | `"Sun position"` | `Dining MVP Sun position` |
| Sun-elevation sensor | `"Sun elevation"` | `Dining MVP Sun elevation` |
The chosen role labels SHALL NOT collide with any existing entity's `_attr_name` on the same device (specifically: not `"Brightness"`, which is the adapt-brightness switch's role per `add-runtime-range-controls`, and not `"Min color temp"` / `"Max color temp"`, which are the range-number roles).
@ -116,7 +128,7 @@ The chosen role labels SHALL NOT collide with any existing entity's `_attr_name`
- **WHEN** the integration loads and the three sensors are registered
- **THEN** the output-brightness sensor's friendly name SHALL be exactly "Dining MVP Output brightness"
- **AND** the output-color-temp sensor's friendly name SHALL be exactly "Dining MVP Output color temp"
- **AND** the sun-position sensor's friendly name SHALL be exactly "Dining MVP Sun position"
- **AND** the sun-elevation sensor's friendly name SHALL be exactly "Dining MVP Sun elevation"
#### Scenario: Sensor friendly names do not collide with switch or number friendly names

View file

@ -6,7 +6,7 @@ Annotations:
R3 = "Sensors update via a per-entry dispatcher signal"
R4 = "Sensors report STATE_UNKNOWN before the first curve tick"
R5 = "Sensors follow the has_entity_name composition"
D1D8 = Decisions in design.md
D1D9 = Decisions in design.md (D8 = sun.sun read path for sun_elevation)
polish = Quality/UX tasks not directly traceable to a requirement
Build order: group 1 is platform foundation. Group 2 wires the master switch's
@ -17,61 +17,62 @@ validation gate.
## 1. Platform foundation — `sensor.py` skeleton and `const.py` mapping
- [ ] 1.1 Add `Platform.SENSOR` to the `PLATFORMS` list in `__init__.py` so HA forwards `async_setup_entry` to the new platform. [R1, D7]
- [ ] 1.2 In `const.py`, add an `OUTPUT_SENSORS` list with three entries — each a dict of `key`, `name`, `unit`, `icon`. Keys: `output_brightness` / `output_color_temp` / `sun_position`. Names: `"Output brightness"` / `"Output color temp"` / `"Sun position"`. Units: `"%"` / `"K"` / `"°"`. Icons: `mdi:brightness-percent` / `mdi:thermometer` / `mdi:weather-sunset`. One source for setup + tests. [R1, D4, D7]
- [ ] 1.3 Create `custom_components/adaptive_lighting/sensor.py` with `async_setup_entry(hass, config_entry, async_add_entities)` that instantiates three entities (one per entry in `OUTPUT_SENSORS`) and calls `async_add_entities(entities)`. [R1]
- [ ] 1.4 Use the shared `device_info` helper so the three new entities attach to the same `(DOMAIN, entry.entry_id)` device as the switches and number entities. [R1, R5, D6]
- [x] 1.1 Add `Platform.SENSOR` to the `PLATFORMS` list in `__init__.py` so HA forwards `async_setup_entry` to the new platform. [R1, D7]
- [x] 1.2 In `const.py`, add an `OUTPUT_SENSORS` list with three entries — each a dict of `key`, `name`, `unit`, `icon`. Keys: `output_brightness` / `output_color_temp` / `sun_elevation`. Names: `"Output brightness"` / `"Output color temp"` / `"Sun elevation"`. Units: `"%"` / `"K"` / `"°"`. Icons: `mdi:brightness-percent` / `mdi:thermometer` / `mdi:weather-sunset`. One source for setup + tests. [R1, D4, D7]
- [x] 1.3 Create `custom_components/adaptive_lighting/sensor.py` with `async_setup_entry(hass, config_entry, async_add_entities)` that instantiates three entities (one per entry in `OUTPUT_SENSORS`) and calls `async_add_entities(entities)`. [R1]
- [x] 1.4 Use the shared `device_info` helper so the three new entities attach to the same per-profile device as the switches and number entities. (Existing pattern uses `(DOMAIN, profile_name)` not `(DOMAIN, entry.entry_id)` — matched in sensor.py.) [R1, R5, D6]
## 2. Output publishing — master switch writes to `hass.data` and fires dispatcher
- [ ] 2.1 In `switch.py`, locate the existing point where `AdaptiveSwitch._async_update_attrs` (or the equivalent compute path) sets `current_brightness`, `current_color_temp`, `sun_position` on itself. Add a publish step immediately after the compute completes and before the switch's own state write. [R2, D2]
- [ ] 2.2 The publish SHALL set `hass.data[DOMAIN][entry.entry_id]["outputs"]` to a dict with keys `output_brightness` (int 0-100), `output_color_temp` (int K), `sun_position` (float degrees), `updated_at` (datetime). The cache dict keys SHALL match the `OUTPUT_SENSORS[*]["key"]` values exactly so sensors read with no intermediate mapping. The master switch's own `current_brightness` / `current_color_temp` / `sun_position` attributes stay unchanged. Ensure `hass.data[DOMAIN][entry.entry_id]` exists (initialize in `async_setup_entry` if needed). [R2, D2]
- [ ] 2.3 Immediately after publishing, call `async_dispatcher_send(hass, f"{DOMAIN}_{entry.entry_id}_outputs_updated")`. Import `async_dispatcher_send` from `homeassistant.helpers.dispatcher`. [R3, D3]
- [ ] 2.4 In `async_setup_entry`, initialize `hass.data.setdefault(DOMAIN, {}).setdefault(entry.entry_id, {})["outputs"] = None` before forwarding platform setups, so sensors that read before the first tick see an empty/`None` slot rather than a `KeyError`. [R2, R4]
- [x] 2.1 In `switch.py`, locate the existing point where `AdaptiveSwitch._update_attrs_and_maybe_adapt_lights` populates `self._settings` from `SunLightSettings.get_settings()`. Add a publish step immediately after `self._settings` is set and before the switch's own state write. [R2, D2]
- [x] 2.2 The publish SHALL set `hass.data[DOMAIN][entry.entry_id]["outputs"]` to a dict with keys `output_brightness` (int 0-100, from `self._settings["brightness_pct"]`), `output_color_temp` (int K, from `self._settings["color_temp_kelvin"]`), `sun_elevation` (float degrees or `None`, from `hass.states.get("sun.sun").attributes.get("elevation")` with a `None` fallback if either is missing), `updated_at` (datetime). [R2, D2, D8]
- [x] 2.3 Immediately after publishing, call `async_dispatcher_send(hass, SIGNAL_OUTPUTS_UPDATED.format(entry_id=...))`. Import `async_dispatcher_send` from `homeassistant.helpers.dispatcher`. [R3, D3]
- [x] 2.4 In `async_setup_entry`, initialize the per-entry cache slot with `"outputs": None` before forwarding platform setups, so sensors that read before the first tick see a sentinel rather than a `KeyError`. [R2, R4]
## 3. Sensor entity class — `AdaptiveOutputSensor`
- [ ] 3.1 Define `AdaptiveOutputSensor(SensorEntity)` in `sensor.py` with `_attr_has_entity_name = True`, `_attr_should_poll = False`, `_attr_state_class = SensorStateClass.MEASUREMENT`. Constructor takes `(hass, entry, output_key, name, unit, icon)`. [R1, R3, R5, D2, D3, D4, D6]
- [ ] 3.2 Implement `unique_id` property as `f"{entry.entry_id}_{output_key}"`. [R1, D7]
- [ ] 3.3 Set `_attr_name` on each instance to the role label from `OUTPUT_SENSORS` ("Output brightness", "Output color temp", "Sun position"). [R5, D4, D6]
- [ ] 3.4 Set `_attr_native_unit_of_measurement` from the entry's `unit` (or `None` for sun position) and `_attr_icon` from the entry's `icon`. Set `_attr_device_class = None` explicitly so future contributors see this is intentional. [R1, D4]
- [ ] 3.5 Initialize `_attr_native_value = None` in `__init__`. The sensor renders as `unknown` until the first dispatcher signal fires. Do NOT extend `RestoreEntity` or `RestoreSensor`. [R4, D5]
- [ ] 3.6 Implement `async_added_to_hass`: subscribe to `f"{DOMAIN}_{entry.entry_id}_outputs_updated"` via `async_dispatcher_connect`; register the unsubscribe handle via `self.async_on_remove(...)`. [R3, D3]
- [ ] 3.7 Implement the signal handler `_handle_outputs_updated`: read `hass.data[DOMAIN][entry.entry_id]["outputs"][output_key]`, set `_attr_native_value`, call `async_write_ha_state()`. Guard against the `outputs` slot being `None` (early dispatcher fire) — in that case do nothing. [R3, R4, D3]
- [x] 3.1 Define `AdaptiveOutputSensor(SensorEntity)` in `sensor.py` with `_attr_has_entity_name = True`, `_attr_should_poll = False`, `_attr_state_class = SensorStateClass.MEASUREMENT`. Constructor takes `(hass, entry, output_key, name, unit, icon)`. [R1, R3, R5, D2, D3, D4, D6]
- [x] 3.2 Implement `unique_id` property as `f"{entry.entry_id}_{output_key}"`. [R1, D7]
- [x] 3.3 Set `_attr_name` on each instance to the role label from `OUTPUT_SENSORS` ("Output brightness", "Output color temp", "Sun elevation"). [R5, D4, D6]
- [x] 3.4 Set `_attr_native_unit_of_measurement` from the entry's `unit` and `_attr_icon` from the entry's `icon`. Set `_attr_device_class = None` explicitly so future contributors see this is intentional. [R1, D4]
- [x] 3.5 Initialize `_attr_native_value = None` in `__init__`. The sensor renders as `unknown` until the first dispatcher signal fires. Do NOT extend `RestoreEntity` or `RestoreSensor`. [R4, D5]
- [x] 3.6 Implement `async_added_to_hass`: subscribe to `SIGNAL_OUTPUTS_UPDATED.format(entry_id=...)` via `async_dispatcher_connect`; register the unsubscribe handle via `self.async_on_remove(...)`. [R3, D3]
- [x] 3.7 Implement the signal handler `_handle_outputs_updated`: read `hass.data[DOMAIN][entry.entry_id]["outputs"][output_key]`, set `_attr_native_value`, call `async_write_ha_state()`. Guard against the `outputs` slot being `None` (early dispatcher fire) — in that case do nothing. [R3, R4, D3]
## 4. Tests — `tests/test_sensor_platform.py`
- [ ] 4.1 New test file `tests/test_sensor_platform.py` with the existing autouse PHACC fixture from `conftest.py`. [R1]
- [ ] 4.2 Test: creating a new config entry registers exactly three `sensor` entities owned by the entry, with unique-id suffixes `_output_brightness`, `_output_color_temp`, `_sun_position`. [R1]
- [ ] 4.3 Test: each of the three sensors is attached to the same device as the profile's switches and number entities. [R1, R5]
- [ ] 4.4 Test: sensor metadata — `output_brightness` declares `unit="%"`, `state_class=MEASUREMENT`, no `device_class`; `output_color_temp` declares `unit="K"`, `state_class=MEASUREMENT`, no `device_class`; `sun_position` declares `unit="°"`, `state_class=MEASUREMENT`, no `device_class`. [R1, D4]
- [ ] 4.5 Test: `_attr_should_poll` is `False` on all three sensors. [R3]
- [ ] 4.6 Test: master switch's curve tick publishes the four expected keys (`output_brightness`, `output_color_temp`, `sun_position`, `updated_at`) into `hass.data[DOMAIN][entry.entry_id]["outputs"]`. Use a synthetic tick (call the compute path directly) and assert the dict state. [R2]
- [ ] 4.7 Test: firing `async_dispatcher_send(hass, f"{DOMAIN}_{entry_id}_outputs_updated")` causes the three sensor states to update to the values currently in `hass.data[DOMAIN][entry_id]["outputs"]`. [R3]
- [ ] 4.8 Test: signal isolation — for two profiles A and B, firing A's signal updates A's three sensors but does NOT update B's. [R3]
- [ ] 4.9 Test: sensors do not import or call curve math. Inspect the sensor class's `async_added_to_hass` and signal handler for absence of `SunLightSettings` references and absence of `hass.states.get` for sun-time / range-number entities. [R2]
- [ ] 4.10 Test: before the first dispatcher signal, all three sensor states are `unknown`. After firing the signal with a populated `outputs` dict, the states match the dict values. [R4]
- [ ] 4.11 Test: friendly-name composition produces "Dining MVP Output brightness", "Dining MVP Output color temp", "Dining MVP Sun position" for a profile titled "Dining MVP". Assert no friendly name collides with the adapt-brightness switch's "Dining MVP Brightness" or the "Min/Max color temp" number entities. [R5, D4, D6]
- [ ] 4.12 Test: unloading the config entry removes the three sensor entities AND removes their dispatcher subscriptions (firing the signal post-unload does not invoke the handler). Use a spy on the handler or count handler invocations. [R3]
- [ ] 4.13 Verify existing `tests/test_switch_platform.py` (or equivalent) still passes after the master switch gains the output-publish + dispatcher-fire step. [R2, polish]
- [x] 4.1 New test file `tests/test_sensor_platform.py` with the existing autouse PHACC fixture from `conftest.py`. [R1]
- [x] 4.2 Test: creating a new config entry registers exactly three `sensor` entities owned by the entry, with unique-id suffixes `_output_brightness`, `_output_color_temp`, `_sun_elevation`. [R1]
- [x] 4.3 Test: each of the three sensors is attached to the same device as the profile's switches and number entities. [R1, R5]
- [x] 4.4 Test: sensor metadata — `output_brightness` declares `unit="%"`, `state_class=MEASUREMENT`, no `device_class`; `output_color_temp` declares `unit="K"`, `state_class=MEASUREMENT`, no `device_class`; `sun_elevation` declares `unit="°"`, `state_class=MEASUREMENT`, no `device_class`. [R1, D4]
- [x] 4.4b Test: master switch publish step reads `sun.sun.attributes.elevation` and writes it to `outputs["sun_elevation"]`. When `sun.sun` is missing or the attribute is absent, `outputs["sun_elevation"]` is `None` and the publish step does not raise. [R2, D8]
- [x] 4.5 Test: `_attr_should_poll` is `False` on all three sensors. [R3]
- [x] 4.6 Test: master switch's curve tick publishes the four expected keys (`output_brightness`, `output_color_temp`, `sun_elevation`, `updated_at`) into `hass.data[DOMAIN][entry.entry_id]["outputs"]`. Use a synthetic tick (call the compute path directly) and assert the dict state. [R2]
- [x] 4.7 Test: firing `async_dispatcher_send(hass, f"{DOMAIN}_{entry_id}_outputs_updated")` causes the three sensor states to update to the values currently in `hass.data[DOMAIN][entry_id]["outputs"]`. [R3]
- [x] 4.8 Test: signal isolation — for two profiles A and B, firing A's signal updates A's three sensors but does NOT update B's. [R3]
- [x] 4.9 Test: sensors do not import or call curve math. Inspect the sensor class's `async_added_to_hass` and signal handler for absence of `SunLightSettings` references and absence of `hass.states.get` for sun-time / range-number entities or `sun.sun`. [R2]
- [x] 4.10 Test: before the first dispatcher signal, all three sensor states are `unknown`. After firing the signal with a populated `outputs` dict, the states match the dict values. [R4]
- [x] 4.11 Test: friendly-name composition produces "Dining MVP Output brightness", "Dining MVP Output color temp", "Dining MVP Sun elevation" for a profile titled "Dining MVP". Assert no friendly name collides with the adapt-brightness switch's "Dining MVP Brightness" or the "Min/Max color temp" number entities. [R5, D4, D6]
- [x] 4.12 Test: unloading the config entry removes the three sensor entities AND removes their dispatcher subscriptions (firing the signal post-unload does not invoke the handler). Use a spy on the handler or count handler invocations. [R3]
- [x] 4.13 Verify existing `tests/test_switch_platform.py` (or equivalent) still passes after the master switch gains the output-publish + dispatcher-fire step. [R2, polish]
## 5. Translations and docs
- [ ] 5.1 Add `entity.sensor.output_brightness.name`, `entity.sensor.output_color_temp.name`, `entity.sensor.sun_position.name` keys to `strings.json` matching the `_attr_name` values. [R5, polish]
- [ ] 5.2 Mirror the additions in `translations/en.json`. Other locales out of scope. [R5, polish]
- [ ] 5.3 Add a short section to `README.md` under "What's new in 2.x" naming the three sensors, explaining they enable History-panel and `apexcharts-card` graphing of the curve outputs, and showing a one-line YAML example of an `apexcharts-card` consuming `sensor.adaptive_lighting_<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]
- [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
- [ ] 6.1 Deploy to `homeassistant.onca-blenny.ts.net` via HACS. Verify the three `sensor.adaptive_lighting_*` entities (`_output_brightness`, `_output_color_temp`, `_sun_position`) appear under each profile's device. [R1, polish]
- [ ] 6.2 Open the History panel for one profile's `output_brightness` sensor. Verify the curve over ~10 minutes shows numeric values graphed as a continuous line (proves `state_class=MEASUREMENT` is honored by the recorder). Repeat for `output_color_temp` and `sun_position` (the last should range roughly -90° to +90° over a day). [R1, D4]
- [ ] 6.1 Deploy to `homeassistant.onca-blenny.ts.net` via HACS. Verify the three `sensor.adaptive_lighting_*` entities (`_output_brightness`, `_output_color_temp`, `_sun_elevation`) appear under each profile's device. [R1, polish]
- [ ] 6.2 Open the History panel for one profile's `output_brightness` sensor. Verify the curve over ~10 minutes shows numeric values graphed as a continuous line (proves `state_class=MEASUREMENT` is honored by the recorder). Repeat for `output_color_temp` and `sun_elevation` (the last should match `sun.sun.attributes.elevation` and range roughly -90° to +90° over a day). [R1, D4, D8]
- [ ] 6.3 Add a temporary `apexcharts-card` to a dashboard pointing at one profile's three sensors. Verify all three render as numeric series. [R1, D4]
- [ ] 6.4 Restart HA. Verify each sensor briefly shows `unknown`, then populates with a value within one curve interval (default 90 s). [R4]
- [ ] 6.5 Verify the existing master switch attributes (`current_brightness`, `current_color_temp`, `sun_position`) are still present and unchanged on the switch entity's state — the sensors are additive, not a replacement. [polish, D2]
- [ ] 6.5 Verify the existing master switch attributes (`brightness_pct`, `color_temp_kelvin`, and the synthetic `sun_position` in [-1, +1] — all from `self._settings`) are still present and unchanged on the switch entity's state — the sensors are additive, not a replacement. [polish, D2]
## 7. Validation gate
- [ ] 7.1 `openspec validate add-output-sensors --strict` returns green. [polish]
- [ ] 7.2 `uv run pytest tests/test_sensor_platform.py` passes. Existing tests stay green (`uv run pytest`). [polish]
- [ ] 7.3 `./scripts/lint` clean. [polish]
- [x] 7.1 `openspec validate add-output-sensors --strict` returns green. [polish]
- [x] 7.2 `uv run pytest tests/test_sensor_platform.py` passes. Existing tests stay green (`uv run pytest`). [polish]
- [x] 7.3 `./scripts/lint` clean. [polish]

View file

@ -206,4 +206,3 @@ On `async_setup_entry`, the integration SHALL scan the entity registry for entit
- **WHEN** the integration runs the sleep-switch cleanup
- **AND** another integration owns a similarly named entity (e.g., a user-created `switch.adaptive_lighting_sleep_mode_demo` template switch)
- **THEN** that foreign entity SHALL NOT be removed from the entity registry

View file

@ -139,4 +139,3 @@ Existing `unique_id`s SHALL remain unchanged; the entity registry SHALL preserve
- **AND** HA reloads the config entry
- **THEN** the entity's `entity_id` SHALL remain `switch.adaptive_lighting_adapt_brightness_dining_mvp_lights` (preserved by the registry via stable `unique_id`)
- **AND** only the entity's friendly name SHALL update to follow the new composition

View file

@ -26,7 +26,6 @@ from custom_components.adaptive_lighting.const import (
CONF_MIN_COLOR_TEMP,
CONFIG_ENTRY_VERSION,
DOMAIN,
RANGE_ENTITIES,
)
PROFILE_NAME = "test_profile"
@ -52,7 +51,9 @@ def _unique_id(entry, field_key: str) -> str:
def _resolve_entity_id(hass, entry, field_key: str) -> str | None:
return er.async_get(hass).async_get_entity_id(
"number", DOMAIN, _unique_id(entry, field_key),
"number",
DOMAIN,
_unique_id(entry, field_key),
)
@ -66,7 +67,9 @@ async def test_four_range_entities_registered(hass) -> None:
registry = er.async_get(hass)
for field_key in FIELD_KEYS:
eid = registry.async_get_entity_id(
"number", DOMAIN, _unique_id(entry, field_key),
"number",
DOMAIN,
_unique_id(entry, field_key),
)
assert eid is not None, f"Missing number entity for {field_key}"
assert eid.startswith("number.")
@ -276,13 +279,9 @@ async def test_options_flow_seeds_from_entity_state(hass) -> None:
assert result["type"] == "form"
schema = result["data_schema"].schema
# Find the daytime_curve section and walk its inner schema to find max_brightness default
daytime = next(
sub for k, sub in schema.items() if str(k) == "daytime_curve"
)
daytime = next(sub for k, sub in schema.items() if str(k) == "daytime_curve")
inner = daytime.schema.schema
max_b_default = next(
k.default() for k in inner if str(k) == CONF_MAX_BRIGHTNESS
)
max_b_default = next(k.default() for k in inner if str(k) == CONF_MAX_BRIGHTNESS)
assert max_b_default == 80 # entity wins over options
@ -300,9 +299,7 @@ async def test_options_flow_fallback_when_entity_unavailable(hass) -> None:
schema = result["data_schema"].schema
daytime = next(sub for k, sub in schema.items() if str(k) == "daytime_curve")
inner = daytime.schema.schema
default = next(
k.default() for k in inner if str(k) == CONF_MIN_COLOR_TEMP
)
default = next(k.default() for k in inner if str(k) == CONF_MIN_COLOR_TEMP)
assert default == 2500 # falls back to options
@ -351,6 +348,7 @@ async def test_curve_math_falls_back_on_unavailable(hass, caplog) -> None:
al_switch = al_data["switch"]
import logging
caplog.set_level(logging.DEBUG)
settings = al_switch.sun_light_settings
assert settings.max_brightness == 88 # fell back to options

View file

@ -0,0 +1,380 @@
"""Tests for the output-sensor platform (CDiT fork).
Covers the `output-sensors` capability: entity surface, dispatcher push
update, sun.sun source for sun_elevation, no-recompute-in-sensor, and
graceful unknown-on-startup.
"""
from __future__ import annotations
import inspect
import pytest
from homeassistant.components.sensor import SensorStateClass
from homeassistant.const import CONF_NAME, STATE_UNKNOWN
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.util import dt as dt_util
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.adaptive_lighting import sensor as sensor_module
from custom_components.adaptive_lighting.const import (
CONFIG_ENTRY_VERSION,
DOMAIN,
OUTPUT_SENSORS,
SIGNAL_OUTPUTS_UPDATED,
)
PROFILE_NAME = "test_profile"
SENSOR_KEYS = ("output_brightness", "output_color_temp", "sun_elevation")
async def _setup_entry(hass, *, options=None):
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_NAME: PROFILE_NAME},
options=options or {},
version=CONFIG_ENTRY_VERSION,
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
return entry
def _unique_id(entry, key: str) -> str:
return f"{entry.entry_id}_{key}"
def _resolve_entity_id(hass, entry, key: str) -> str | None:
return er.async_get(hass).async_get_entity_id(
"sensor", DOMAIN, _unique_id(entry, key),
)
# ---------------------------------------------------------------------------
# 4.2 — Three sensor entities per entry, expected suffixes
# ---------------------------------------------------------------------------
async def test_three_sensor_entities_registered(hass) -> None:
entry = await _setup_entry(hass)
registry = er.async_get(hass)
for key in SENSOR_KEYS:
eid = registry.async_get_entity_id(
"sensor", DOMAIN, _unique_id(entry, key),
)
assert eid is not None, f"Missing sensor entity for {key}"
assert eid.startswith("sensor.")
# ---------------------------------------------------------------------------
# 4.3 — Sensors attach to the same device as switches and numbers
# ---------------------------------------------------------------------------
async def test_sensors_share_profile_device(hass) -> None:
entry = await _setup_entry(hass)
ent_reg = er.async_get(hass)
dev_reg = dr.async_get(hass)
master_eid = ent_reg.async_get_entity_id("switch", DOMAIN, PROFILE_NAME)
master_dev_id = ent_reg.async_get(master_eid).device_id
assert master_dev_id
for key in SENSOR_KEYS:
eid = _resolve_entity_id(hass, entry, key)
assert ent_reg.async_get(eid).device_id == master_dev_id
device = dev_reg.async_get(master_dev_id)
assert device.name == PROFILE_NAME
# ---------------------------------------------------------------------------
# 4.4 — Sensor metadata matches the D4 table
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("key", "unit"),
[
("output_brightness", "%"),
("output_color_temp", "K"),
("sun_elevation", "°"),
],
)
async def test_sensor_metadata(hass, key, unit) -> None:
entry = await _setup_entry(hass)
eid = _resolve_entity_id(hass, entry, key)
state = hass.states.get(eid)
assert state is not None
assert state.attributes.get("unit_of_measurement") == unit
assert state.attributes.get("state_class") == SensorStateClass.MEASUREMENT
assert state.attributes.get("device_class") is None
# ---------------------------------------------------------------------------
# 4.4b — Publish step handles missing sun.sun gracefully (D8)
# ---------------------------------------------------------------------------
async def test_publish_sun_elevation_handles_missing_sun(hass) -> None:
entry = await _setup_entry(hass)
al_switch = hass.data[DOMAIN][entry.entry_id]["switch"]
# Pre-populate _settings so brightness/color publish has values.
al_switch._settings.update(
{"brightness_pct": 50, "color_temp_kelvin": 3000, "sun_position": 0.0},
)
# sun.sun is not registered in tests — confirm + invoke publish directly.
assert hass.states.get("sun.sun") is None
al_switch._publish_outputs_and_wake_sensors()
outputs = hass.data[DOMAIN][entry.entry_id]["outputs"]
assert outputs["sun_elevation"] is None
assert outputs["output_brightness"] == 50
assert outputs["output_color_temp"] == 3000
async def test_publish_sun_elevation_reads_from_sun_sun(hass) -> None:
entry = await _setup_entry(hass)
al_switch = hass.data[DOMAIN][entry.entry_id]["switch"]
al_switch._settings.update(
{"brightness_pct": 60, "color_temp_kelvin": 3500, "sun_position": 0.5},
)
hass.states.async_set("sun.sun", "above_horizon", {"elevation": 42.5})
al_switch._publish_outputs_and_wake_sensors()
outputs = hass.data[DOMAIN][entry.entry_id]["outputs"]
assert outputs["sun_elevation"] == 42.5
assert outputs["output_brightness"] == 60
# ---------------------------------------------------------------------------
# 4.5 — Sensors are not polled
# ---------------------------------------------------------------------------
async def test_sensors_do_not_poll() -> None:
# `_attr_should_poll` is shadowed by SensorEntity's property descriptor;
# check the source for the class-level assignment instead.
src = inspect.getsource(sensor_module.AdaptiveOutputSensor)
assert "_attr_should_poll = False" in src
# ---------------------------------------------------------------------------
# 4.6 — Curve tick populates the cache with the four keys
# ---------------------------------------------------------------------------
async def test_curve_tick_publishes_outputs(hass) -> None:
entry = await _setup_entry(hass)
al_switch = hass.data[DOMAIN][entry.entry_id]["switch"]
al_switch._settings.update(
{"brightness_pct": 72, "color_temp_kelvin": 3240, "sun_position": 0.3},
)
hass.states.async_set("sun.sun", "above_horizon", {"elevation": 18.0})
before = dt_util.utcnow()
al_switch._publish_outputs_and_wake_sensors()
outputs = hass.data[DOMAIN][entry.entry_id]["outputs"]
assert set(outputs.keys()) == {
"output_brightness",
"output_color_temp",
"sun_elevation",
"updated_at",
}
assert outputs["output_brightness"] == 72
assert outputs["output_color_temp"] == 3240
assert outputs["sun_elevation"] == 18.0
assert outputs["updated_at"] >= before
# ---------------------------------------------------------------------------
# 4.7 — Firing the dispatcher signal updates the three sensors
# ---------------------------------------------------------------------------
async def test_dispatcher_signal_updates_sensors(hass) -> None:
entry = await _setup_entry(hass)
hass.data[DOMAIN][entry.entry_id]["outputs"] = {
"output_brightness": 65,
"output_color_temp": 3000,
"sun_elevation": 25.0,
"updated_at": dt_util.utcnow(),
}
async_dispatcher_send(
hass,
SIGNAL_OUTPUTS_UPDATED.format(entry_id=entry.entry_id),
)
await hass.async_block_till_done()
assert (
hass.states.get(_resolve_entity_id(hass, entry, "output_brightness")).state
== "65"
)
assert (
hass.states.get(_resolve_entity_id(hass, entry, "output_color_temp")).state
== "3000"
)
assert (
hass.states.get(_resolve_entity_id(hass, entry, "sun_elevation")).state
== "25.0"
)
# ---------------------------------------------------------------------------
# 4.8 — Signal isolation between profiles
# ---------------------------------------------------------------------------
async def test_dispatcher_signal_is_per_entry(hass) -> None:
entry_a = MockConfigEntry(
domain=DOMAIN,
data={CONF_NAME: "profile_a"},
options={},
version=CONFIG_ENTRY_VERSION,
)
entry_a.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry_a.entry_id)
await hass.async_block_till_done()
entry_b = MockConfigEntry(
domain=DOMAIN,
data={CONF_NAME: "profile_b"},
options={},
version=CONFIG_ENTRY_VERSION,
)
entry_b.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry_b.entry_id)
await hass.async_block_till_done()
# Populate only A's cache, fire only A's signal.
hass.data[DOMAIN][entry_a.entry_id]["outputs"] = {
"output_brightness": 80,
"output_color_temp": 4000,
"sun_elevation": 30.0,
"updated_at": dt_util.utcnow(),
}
async_dispatcher_send(
hass,
SIGNAL_OUTPUTS_UPDATED.format(entry_id=entry_a.entry_id),
)
await hass.async_block_till_done()
a_eid = _resolve_entity_id(hass, entry_a, "output_brightness")
b_eid = _resolve_entity_id(hass, entry_b, "output_brightness")
assert hass.states.get(a_eid).state == "80"
assert hass.states.get(b_eid).state == STATE_UNKNOWN # B never updated
# ---------------------------------------------------------------------------
# 4.9 — Sensors do not import or call curve math
# ---------------------------------------------------------------------------
async def test_sensors_dont_recompute_curve() -> None:
src = inspect.getsource(sensor_module)
assert "SunLightSettings" not in src
# The handler must not fetch sun.sun, sun-time, or range entities itself
cls_src = inspect.getsource(sensor_module.AdaptiveOutputSensor)
assert "states.get" not in cls_src
# ---------------------------------------------------------------------------
# 4.10 — Before first dispatcher signal, sensors are unknown
# ---------------------------------------------------------------------------
async def test_sensors_unknown_before_first_tick(hass) -> None:
entry = await _setup_entry(hass)
# In tests the sunrise/sunset entities aren't available, so even when
# the master switch publishes during setup the brightness/color values
# are None (no curve math ran). The sensors render as `unknown` until
# a real tick produces real numbers.
outputs = hass.data[DOMAIN][entry.entry_id]["outputs"]
if outputs is not None:
assert outputs["output_brightness"] is None
assert outputs["output_color_temp"] is None
for key in SENSOR_KEYS:
eid = _resolve_entity_id(hass, entry, key)
assert hass.states.get(eid).state == STATE_UNKNOWN
# ---------------------------------------------------------------------------
# 4.11 — Friendly names compose from device + role label, no collisions
# ---------------------------------------------------------------------------
async def test_friendly_names_compose_correctly(hass) -> None:
entry = await _setup_entry(hass)
names = {}
for row in OUTPUT_SENSORS:
key = row["key"]
eid = _resolve_entity_id(hass, entry, key)
names[key] = hass.states.get(eid).attributes["friendly_name"]
assert names["output_brightness"] == f"{PROFILE_NAME} Output brightness"
assert names["output_color_temp"] == f"{PROFILE_NAME} Output color temp"
assert names["sun_elevation"] == f"{PROFILE_NAME} Sun elevation"
# No collision with the adapt-brightness switch ("<profile> Brightness").
all_friendly = set(names.values())
# Walk all entities owned by this entry and confirm uniqueness.
ent_reg = er.async_get(hass)
for ent in ent_reg.entities.values():
if ent.config_entry_id != entry.entry_id:
continue
state = hass.states.get(ent.entity_id)
if state is None:
continue
fname = state.attributes.get("friendly_name")
if fname is None:
continue
if fname in names.values():
continue
# Any other entity's friendly name must not match a sensor's.
assert fname not in all_friendly, (
f"Collision: {fname} appears on both sensor and another entity"
)
# ---------------------------------------------------------------------------
# 4.12 — Unload removes sensors and detaches dispatcher subscriptions
# ---------------------------------------------------------------------------
async def test_unload_detaches_dispatcher(hass) -> None:
entry = await _setup_entry(hass)
# Confirm sensors are registered, then unload.
assert _resolve_entity_id(hass, entry, "output_brightness") is not None
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
# Firing the signal after unload must not raise nor populate state.
# We can't easily inspect the handler-not-called assertion without a
# spy, but the fact that this doesn't raise (and that the entity is
# gone from the state machine) is sufficient evidence the subscription
# was cleaned up via async_on_remove.
hass.data.setdefault(DOMAIN, {}).setdefault(entry.entry_id, {})["outputs"] = {
"output_brightness": 99,
"output_color_temp": 4500,
"sun_elevation": 50.0,
"updated_at": dt_util.utcnow(),
}
async_dispatcher_send(
hass,
SIGNAL_OUTPUTS_UPDATED.format(entry_id=entry.entry_id),
)
await hass.async_block_till_done()
# Post-unload, the entity is `unavailable`; firing the signal MUST NOT
# repopulate it with the dummy "99" value — that would mean the
# dispatcher subscription leaked.
ent_reg = er.async_get(hass)
eid = ent_reg.async_get_entity_id(
"sensor", DOMAIN, _unique_id(entry, "output_brightness"),
)
if eid is not None:
state = hass.states.get(eid)
if state is not None:
assert state.state != "99", (
"Dispatcher subscription leaked: sensor updated after unload"
)