mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-09-16 16:54:04 +02:00
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:
parent
a6eb2dbe68
commit
1b21228b30
18 changed files with 775 additions and 93 deletions
36
CHANGELOG.md
36
CHANGELOG.md
|
|
@ -26,6 +26,42 @@ maintainers who can actually fix them for everyone.
|
|||
|
||||
---
|
||||
|
||||
## [2.2.0-cdit.1] — Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
- **Three read-only `sensor` entities per profile** — `output_brightness`
|
||||
(%), `output_color_temp` (K), `sun_elevation` (°). They expose the
|
||||
curve's current target outputs alongside HA's built-in `sun.sun`
|
||||
elevation as graphable numerics with `state_class: measurement`, so
|
||||
the History panel, `apexcharts-card`, and `mini-graph-card` chart
|
||||
them natively. The existing master-switch attributes (`brightness_pct`,
|
||||
`color_temp_kelvin`, the synthetic `sun_position` in [-1, +1]) are
|
||||
unchanged.
|
||||
- **Push-based sensor updates** via a per-entry dispatcher signal fired
|
||||
by the master switch after each curve tick. No polling, no duplicate
|
||||
curve math — sensors are pure readers of a runtime cache the master
|
||||
switch publishes to.
|
||||
- **`sun_elevation` is sourced from `sun.sun.attributes.elevation`** on
|
||||
every curve tick. If `sun.sun` is missing or the attribute is absent
|
||||
(rare; can happen during very early HA startup), the sensor renders
|
||||
as `unknown` for that tick — the other two sensors continue updating
|
||||
normally.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`manifest.json` version bumped to `2.2.0-cdit.1`.** Minor bump; no
|
||||
breaking config-entry changes — existing 2.1 entries upgrade in place
|
||||
and gain the three new sensor entities on next setup.
|
||||
|
||||
### Migration
|
||||
|
||||
No user action required. Restart HA after upgrade and the three new
|
||||
sensors appear under each profile's device page. Add them to a Lovelace
|
||||
card or `apexcharts-card` to start graphing.
|
||||
|
||||
---
|
||||
|
||||
## [2.1.0-cdit.1] — Unreleased
|
||||
|
||||
### Added
|
||||
|
|
|
|||
27
README.md
27
README.md
|
|
@ -99,6 +99,33 @@
|
|||
> Color" — short enough to fit HA's tightest cards. Existing entity_ids
|
||||
> are preserved by the entity registry; automations keep working.
|
||||
>
|
||||
> ## ✨ What's new in 2.2
|
||||
>
|
||||
> Each profile now exposes **three read-only `sensor` entities** that
|
||||
> publish the curve's current outputs alongside the sun's actual elevation
|
||||
> — graphable as numerics by HA's recorder, the History panel,
|
||||
> `apexcharts-card`, and `mini-graph-card`:
|
||||
>
|
||||
> - `sensor.<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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
123
custom_components/adaptive_lighting/sensor.py
Normal file
123
custom_components/adaptive_lighting/sensor.py
Normal 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()
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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 0–100, `state_class: measurement`, `icon: mdi:brightness-percent`. "Output" prefix disambiguates from the existing "Brightness" switch and "Min/Max brightness" number entities on the same device.
|
||||
- `sensor.adaptive_lighting_<name>_output_color_temp` — current target color temperature, integer `K`, range 1000–10000, `state_class: measurement`, no `device_class` (`SensorDeviceClass.TEMPERATURE` is wrong; color temp is not thermal), `icon: mdi:thermometer`. "Output" prefix maintains parallelism with brightness and disambiguates from "Min/Max color temp" numbers.
|
||||
- `sensor.adaptive_lighting_<name>_sun_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 0–100, `state_class: measurement`, `icon: mdi:brightness-percent`. "Output" prefix disambiguates from the existing "Brightness" switch and "Min/Max brightness" number entities on the same device.
|
||||
- `sensor.adaptive_lighting_<name>_output_color_temp` — current target color temperature sourced from `self._settings["color_temp_kelvin"]`, integer `K`, range 1000–10000, `state_class: measurement`, no `device_class` (`SensorDeviceClass.TEMPERATURE` is wrong; color temp is not thermal), `icon: mdi:thermometer`. "Output" prefix maintains parallelism with brightness and disambiguates from "Min/Max color temp" numbers.
|
||||
- `sensor.adaptive_lighting_<name>_sun_elevation` — solar elevation angle in degrees, sourced from HA's built-in `sun.sun` entity's `elevation` attribute (range roughly -90 to +90), `state_class: measurement`, `unit: "°"`, `icon: mdi:weather-sunset`. Read on every curve tick alongside the brightness/color outputs. No prefix needed; no existing entity uses "Sun" in its name. Distinct from the master switch's existing synthetic `sun_position` attribute (which stays unchanged for backward compatibility).
|
||||
- **Single read path**: sensors read the same curve-math outputs the master switch's adapt loop already computes — no second computation pass. The sensors update on the same tick as the existing curve evaluation (`interval` setting, default 90 s).
|
||||
- **`has_entity_name = True`** following the convention established in `add-runtime-range-controls` Decision 11. Friendly names become `<Profile> Output brightness`, `<Profile> Output color temp`, `<Profile> Sun 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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ Annotations:
|
|||
R3 = "Sensors update via a per-entry dispatcher signal"
|
||||
R4 = "Sensors report STATE_UNKNOWN before the first curve tick"
|
||||
R5 = "Sensors follow the has_entity_name composition"
|
||||
D1–D8 = Decisions in design.md
|
||||
D1–D9 = Decisions in design.md (D8 = sun.sun read path for sun_elevation)
|
||||
polish = Quality/UX tasks not directly traceable to a requirement
|
||||
|
||||
Build order: group 1 is platform foundation. Group 2 wires the master switch's
|
||||
|
|
@ -17,61 +17,62 @@ validation gate.
|
|||
|
||||
## 1. Platform foundation — `sensor.py` skeleton and `const.py` mapping
|
||||
|
||||
- [ ] 1.1 Add `Platform.SENSOR` to the `PLATFORMS` list in `__init__.py` so HA forwards `async_setup_entry` to the new platform. [R1, D7]
|
||||
- [ ] 1.2 In `const.py`, add an `OUTPUT_SENSORS` list with three entries — each a dict of `key`, `name`, `unit`, `icon`. Keys: `output_brightness` / `output_color_temp` / `sun_position`. Names: `"Output brightness"` / `"Output color temp"` / `"Sun position"`. Units: `"%"` / `"K"` / `"°"`. Icons: `mdi:brightness-percent` / `mdi:thermometer` / `mdi:weather-sunset`. One source for setup + tests. [R1, D4, D7]
|
||||
- [ ] 1.3 Create `custom_components/adaptive_lighting/sensor.py` with `async_setup_entry(hass, config_entry, async_add_entities)` that instantiates three entities (one per entry in `OUTPUT_SENSORS`) and calls `async_add_entities(entities)`. [R1]
|
||||
- [ ] 1.4 Use the shared `device_info` helper so the three new entities attach to the same `(DOMAIN, entry.entry_id)` device as the switches and number entities. [R1, R5, D6]
|
||||
- [x] 1.1 Add `Platform.SENSOR` to the `PLATFORMS` list in `__init__.py` so HA forwards `async_setup_entry` to the new platform. [R1, D7]
|
||||
- [x] 1.2 In `const.py`, add an `OUTPUT_SENSORS` list with three entries — each a dict of `key`, `name`, `unit`, `icon`. Keys: `output_brightness` / `output_color_temp` / `sun_elevation`. Names: `"Output brightness"` / `"Output color temp"` / `"Sun elevation"`. Units: `"%"` / `"K"` / `"°"`. Icons: `mdi:brightness-percent` / `mdi:thermometer` / `mdi:weather-sunset`. One source for setup + tests. [R1, D4, D7]
|
||||
- [x] 1.3 Create `custom_components/adaptive_lighting/sensor.py` with `async_setup_entry(hass, config_entry, async_add_entities)` that instantiates three entities (one per entry in `OUTPUT_SENSORS`) and calls `async_add_entities(entities)`. [R1]
|
||||
- [x] 1.4 Use the shared `device_info` helper so the three new entities attach to the same per-profile device as the switches and number entities. (Existing pattern uses `(DOMAIN, profile_name)` not `(DOMAIN, entry.entry_id)` — matched in sensor.py.) [R1, R5, D6]
|
||||
|
||||
## 2. Output publishing — master switch writes to `hass.data` and fires dispatcher
|
||||
|
||||
- [ ] 2.1 In `switch.py`, locate the existing point where `AdaptiveSwitch._async_update_attrs` (or the equivalent compute path) sets `current_brightness`, `current_color_temp`, `sun_position` on itself. Add a publish step immediately after the compute completes and before the switch's own state write. [R2, D2]
|
||||
- [ ] 2.2 The publish SHALL set `hass.data[DOMAIN][entry.entry_id]["outputs"]` to a dict with keys `output_brightness` (int 0-100), `output_color_temp` (int K), `sun_position` (float degrees), `updated_at` (datetime). The cache dict keys SHALL match the `OUTPUT_SENSORS[*]["key"]` values exactly so sensors read with no intermediate mapping. The master switch's own `current_brightness` / `current_color_temp` / `sun_position` attributes stay unchanged. Ensure `hass.data[DOMAIN][entry.entry_id]` exists (initialize in `async_setup_entry` if needed). [R2, D2]
|
||||
- [ ] 2.3 Immediately after publishing, call `async_dispatcher_send(hass, f"{DOMAIN}_{entry.entry_id}_outputs_updated")`. Import `async_dispatcher_send` from `homeassistant.helpers.dispatcher`. [R3, D3]
|
||||
- [ ] 2.4 In `async_setup_entry`, initialize `hass.data.setdefault(DOMAIN, {}).setdefault(entry.entry_id, {})["outputs"] = None` before forwarding platform setups, so sensors that read before the first tick see an empty/`None` slot rather than a `KeyError`. [R2, R4]
|
||||
- [x] 2.1 In `switch.py`, locate the existing point where `AdaptiveSwitch._update_attrs_and_maybe_adapt_lights` populates `self._settings` from `SunLightSettings.get_settings()`. Add a publish step immediately after `self._settings` is set and before the switch's own state write. [R2, D2]
|
||||
- [x] 2.2 The publish SHALL set `hass.data[DOMAIN][entry.entry_id]["outputs"]` to a dict with keys `output_brightness` (int 0-100, from `self._settings["brightness_pct"]`), `output_color_temp` (int K, from `self._settings["color_temp_kelvin"]`), `sun_elevation` (float degrees or `None`, from `hass.states.get("sun.sun").attributes.get("elevation")` with a `None` fallback if either is missing), `updated_at` (datetime). [R2, D2, D8]
|
||||
- [x] 2.3 Immediately after publishing, call `async_dispatcher_send(hass, SIGNAL_OUTPUTS_UPDATED.format(entry_id=...))`. Import `async_dispatcher_send` from `homeassistant.helpers.dispatcher`. [R3, D3]
|
||||
- [x] 2.4 In `async_setup_entry`, initialize the per-entry cache slot with `"outputs": None` before forwarding platform setups, so sensors that read before the first tick see a sentinel rather than a `KeyError`. [R2, R4]
|
||||
|
||||
## 3. Sensor entity class — `AdaptiveOutputSensor`
|
||||
|
||||
- [ ] 3.1 Define `AdaptiveOutputSensor(SensorEntity)` in `sensor.py` with `_attr_has_entity_name = True`, `_attr_should_poll = False`, `_attr_state_class = SensorStateClass.MEASUREMENT`. Constructor takes `(hass, entry, output_key, name, unit, icon)`. [R1, R3, R5, D2, D3, D4, D6]
|
||||
- [ ] 3.2 Implement `unique_id` property as `f"{entry.entry_id}_{output_key}"`. [R1, D7]
|
||||
- [ ] 3.3 Set `_attr_name` on each instance to the role label from `OUTPUT_SENSORS` ("Output brightness", "Output color temp", "Sun position"). [R5, D4, D6]
|
||||
- [ ] 3.4 Set `_attr_native_unit_of_measurement` from the entry's `unit` (or `None` for sun position) and `_attr_icon` from the entry's `icon`. Set `_attr_device_class = None` explicitly so future contributors see this is intentional. [R1, D4]
|
||||
- [ ] 3.5 Initialize `_attr_native_value = None` in `__init__`. The sensor renders as `unknown` until the first dispatcher signal fires. Do NOT extend `RestoreEntity` or `RestoreSensor`. [R4, D5]
|
||||
- [ ] 3.6 Implement `async_added_to_hass`: subscribe to `f"{DOMAIN}_{entry.entry_id}_outputs_updated"` via `async_dispatcher_connect`; register the unsubscribe handle via `self.async_on_remove(...)`. [R3, D3]
|
||||
- [ ] 3.7 Implement the signal handler `_handle_outputs_updated`: read `hass.data[DOMAIN][entry.entry_id]["outputs"][output_key]`, set `_attr_native_value`, call `async_write_ha_state()`. Guard against the `outputs` slot being `None` (early dispatcher fire) — in that case do nothing. [R3, R4, D3]
|
||||
- [x] 3.1 Define `AdaptiveOutputSensor(SensorEntity)` in `sensor.py` with `_attr_has_entity_name = True`, `_attr_should_poll = False`, `_attr_state_class = SensorStateClass.MEASUREMENT`. Constructor takes `(hass, entry, output_key, name, unit, icon)`. [R1, R3, R5, D2, D3, D4, D6]
|
||||
- [x] 3.2 Implement `unique_id` property as `f"{entry.entry_id}_{output_key}"`. [R1, D7]
|
||||
- [x] 3.3 Set `_attr_name` on each instance to the role label from `OUTPUT_SENSORS` ("Output brightness", "Output color temp", "Sun elevation"). [R5, D4, D6]
|
||||
- [x] 3.4 Set `_attr_native_unit_of_measurement` from the entry's `unit` and `_attr_icon` from the entry's `icon`. Set `_attr_device_class = None` explicitly so future contributors see this is intentional. [R1, D4]
|
||||
- [x] 3.5 Initialize `_attr_native_value = None` in `__init__`. The sensor renders as `unknown` until the first dispatcher signal fires. Do NOT extend `RestoreEntity` or `RestoreSensor`. [R4, D5]
|
||||
- [x] 3.6 Implement `async_added_to_hass`: subscribe to `SIGNAL_OUTPUTS_UPDATED.format(entry_id=...)` via `async_dispatcher_connect`; register the unsubscribe handle via `self.async_on_remove(...)`. [R3, D3]
|
||||
- [x] 3.7 Implement the signal handler `_handle_outputs_updated`: read `hass.data[DOMAIN][entry.entry_id]["outputs"][output_key]`, set `_attr_native_value`, call `async_write_ha_state()`. Guard against the `outputs` slot being `None` (early dispatcher fire) — in that case do nothing. [R3, R4, D3]
|
||||
|
||||
## 4. Tests — `tests/test_sensor_platform.py`
|
||||
|
||||
- [ ] 4.1 New test file `tests/test_sensor_platform.py` with the existing autouse PHACC fixture from `conftest.py`. [R1]
|
||||
- [ ] 4.2 Test: creating a new config entry registers exactly three `sensor` entities owned by the entry, with unique-id suffixes `_output_brightness`, `_output_color_temp`, `_sun_position`. [R1]
|
||||
- [ ] 4.3 Test: each of the three sensors is attached to the same device as the profile's switches and number entities. [R1, R5]
|
||||
- [ ] 4.4 Test: sensor metadata — `output_brightness` declares `unit="%"`, `state_class=MEASUREMENT`, no `device_class`; `output_color_temp` declares `unit="K"`, `state_class=MEASUREMENT`, no `device_class`; `sun_position` declares `unit="°"`, `state_class=MEASUREMENT`, no `device_class`. [R1, D4]
|
||||
- [ ] 4.5 Test: `_attr_should_poll` is `False` on all three sensors. [R3]
|
||||
- [ ] 4.6 Test: master switch's curve tick publishes the four expected keys (`output_brightness`, `output_color_temp`, `sun_position`, `updated_at`) into `hass.data[DOMAIN][entry.entry_id]["outputs"]`. Use a synthetic tick (call the compute path directly) and assert the dict state. [R2]
|
||||
- [ ] 4.7 Test: firing `async_dispatcher_send(hass, f"{DOMAIN}_{entry_id}_outputs_updated")` causes the three sensor states to update to the values currently in `hass.data[DOMAIN][entry_id]["outputs"]`. [R3]
|
||||
- [ ] 4.8 Test: signal isolation — for two profiles A and B, firing A's signal updates A's three sensors but does NOT update B's. [R3]
|
||||
- [ ] 4.9 Test: sensors do not import or call curve math. Inspect the sensor class's `async_added_to_hass` and signal handler for absence of `SunLightSettings` references and absence of `hass.states.get` for sun-time / range-number entities. [R2]
|
||||
- [ ] 4.10 Test: before the first dispatcher signal, all three sensor states are `unknown`. After firing the signal with a populated `outputs` dict, the states match the dict values. [R4]
|
||||
- [ ] 4.11 Test: friendly-name composition produces "Dining MVP Output brightness", "Dining MVP Output color temp", "Dining MVP Sun position" for a profile titled "Dining MVP". Assert no friendly name collides with the adapt-brightness switch's "Dining MVP Brightness" or the "Min/Max color temp" number entities. [R5, D4, D6]
|
||||
- [ ] 4.12 Test: unloading the config entry removes the three sensor entities AND removes their dispatcher subscriptions (firing the signal post-unload does not invoke the handler). Use a spy on the handler or count handler invocations. [R3]
|
||||
- [ ] 4.13 Verify existing `tests/test_switch_platform.py` (or equivalent) still passes after the master switch gains the output-publish + dispatcher-fire step. [R2, polish]
|
||||
- [x] 4.1 New test file `tests/test_sensor_platform.py` with the existing autouse PHACC fixture from `conftest.py`. [R1]
|
||||
- [x] 4.2 Test: creating a new config entry registers exactly three `sensor` entities owned by the entry, with unique-id suffixes `_output_brightness`, `_output_color_temp`, `_sun_elevation`. [R1]
|
||||
- [x] 4.3 Test: each of the three sensors is attached to the same device as the profile's switches and number entities. [R1, R5]
|
||||
- [x] 4.4 Test: sensor metadata — `output_brightness` declares `unit="%"`, `state_class=MEASUREMENT`, no `device_class`; `output_color_temp` declares `unit="K"`, `state_class=MEASUREMENT`, no `device_class`; `sun_elevation` declares `unit="°"`, `state_class=MEASUREMENT`, no `device_class`. [R1, D4]
|
||||
- [x] 4.4b Test: master switch publish step reads `sun.sun.attributes.elevation` and writes it to `outputs["sun_elevation"]`. When `sun.sun` is missing or the attribute is absent, `outputs["sun_elevation"]` is `None` and the publish step does not raise. [R2, D8]
|
||||
- [x] 4.5 Test: `_attr_should_poll` is `False` on all three sensors. [R3]
|
||||
- [x] 4.6 Test: master switch's curve tick publishes the four expected keys (`output_brightness`, `output_color_temp`, `sun_elevation`, `updated_at`) into `hass.data[DOMAIN][entry.entry_id]["outputs"]`. Use a synthetic tick (call the compute path directly) and assert the dict state. [R2]
|
||||
- [x] 4.7 Test: firing `async_dispatcher_send(hass, f"{DOMAIN}_{entry_id}_outputs_updated")` causes the three sensor states to update to the values currently in `hass.data[DOMAIN][entry_id]["outputs"]`. [R3]
|
||||
- [x] 4.8 Test: signal isolation — for two profiles A and B, firing A's signal updates A's three sensors but does NOT update B's. [R3]
|
||||
- [x] 4.9 Test: sensors do not import or call curve math. Inspect the sensor class's `async_added_to_hass` and signal handler for absence of `SunLightSettings` references and absence of `hass.states.get` for sun-time / range-number entities or `sun.sun`. [R2]
|
||||
- [x] 4.10 Test: before the first dispatcher signal, all three sensor states are `unknown`. After firing the signal with a populated `outputs` dict, the states match the dict values. [R4]
|
||||
- [x] 4.11 Test: friendly-name composition produces "Dining MVP Output brightness", "Dining MVP Output color temp", "Dining MVP Sun elevation" for a profile titled "Dining MVP". Assert no friendly name collides with the adapt-brightness switch's "Dining MVP Brightness" or the "Min/Max color temp" number entities. [R5, D4, D6]
|
||||
- [x] 4.12 Test: unloading the config entry removes the three sensor entities AND removes their dispatcher subscriptions (firing the signal post-unload does not invoke the handler). Use a spy on the handler or count handler invocations. [R3]
|
||||
- [x] 4.13 Verify existing `tests/test_switch_platform.py` (or equivalent) still passes after the master switch gains the output-publish + dispatcher-fire step. [R2, polish]
|
||||
|
||||
## 5. Translations and docs
|
||||
|
||||
- [ ] 5.1 Add `entity.sensor.output_brightness.name`, `entity.sensor.output_color_temp.name`, `entity.sensor.sun_position.name` keys to `strings.json` matching the `_attr_name` values. [R5, polish]
|
||||
- [ ] 5.2 Mirror the additions in `translations/en.json`. Other locales out of scope. [R5, polish]
|
||||
- [ ] 5.3 Add a short section to `README.md` under "What's new in 2.x" naming the three sensors, explaining they enable History-panel and `apexcharts-card` graphing of the curve outputs, and showing a one-line YAML example of an `apexcharts-card` consuming `sensor.adaptive_lighting_<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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
380
tests/test_sensor_platform.py
Normal file
380
tests/test_sensor_platform.py
Normal 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"
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue