mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-09-15 16:24:04 +02:00
Implement add-runtime-range-controls
Adds four live-tunable `number` entities per AL profile (min/max brightness, min/max color temp) that own the runtime curve values. Slider changes take effect on the next curve tick — no integration reload. State persists across HA restart via `RestoreNumber`. Curve math now reads `min_brightness`, `max_brightness`, `min_color_temp`, `max_color_temp` from the four runtime entities via the entity registry, falling back to `entry.options` when an entity is unavailable. The options flow seeds its four range fields from the current entity state so the dialog matches reality. Also fixes entity friendly names via HA's `has_entity_name` composition: profile "Dining MVP" now reads as "Dining MVP", "Dining MVP Brightness", "Dining MVP Color" — short enough for HA's tightest cards. `unique_id`s are unchanged so existing entity_ids stay stable. Manifest bumped to 2.1.0-cdit.1 (minor, no breaking changes). 14 new tests in `tests/test_number_platform.py`; 108 passing overall. OpenSpec change archived once 9.x live-HA verification completes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
c1a98a5c91
commit
d5d3ecc9d4
21 changed files with 1280 additions and 65 deletions
39
CHANGELOG.md
39
CHANGELOG.md
|
|
@ -26,6 +26,45 @@ maintainers who can actually fix them for everyone.
|
|||
|
||||
---
|
||||
|
||||
## [2.1.0-cdit.1] — Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
- **Four live-tunable `number` entities per profile** — `min_brightness`,
|
||||
`max_brightness`, `min_color_temp`, `max_color_temp`. Drop the four
|
||||
sliders onto any Lovelace card to tune the curve from the dashboard
|
||||
without opening the options dialog. Slider position persists across
|
||||
Home Assistant restarts via `RestoreNumber`.
|
||||
- **Curve math reads the runtime values from the entities on every tick**
|
||||
(with a fallback to `entry.options` when an entity is unavailable, e.g.,
|
||||
during early-setup races). Slider changes take effect on the next
|
||||
curve evaluation — no integration reload, no restart.
|
||||
- **Options-flow opens with current slider values, not stale options**
|
||||
— if you tweaked the slider on the dashboard, the dialog shows the
|
||||
current value, not the value you typed in at setup. Saving the dialog
|
||||
resets the sliders to the just-saved values (explicit gesture wins).
|
||||
|
||||
### Changed
|
||||
|
||||
- **Entity friendly names use HA's `has_entity_name` composition.** For a
|
||||
profile named "Dining MVP" you now see "Dining MVP", "Dining MVP
|
||||
Brightness", "Dining MVP Color" on the dashboard instead of "Adaptive
|
||||
Lighting Adapt Brightness dining_mvp_lights" (which truncated to
|
||||
"Adaptive Lighting Adapt Br…"). Existing entity_ids are preserved by
|
||||
the entity registry — automations and scripts referencing them keep
|
||||
working.
|
||||
- **`manifest.json` version bumped to `2.1.0-cdit.1`.** Minor bump; no
|
||||
breaking config-entry changes — existing 2.0 entries upgrade in place
|
||||
and gain the four new number entities on next setup.
|
||||
|
||||
### Migration
|
||||
|
||||
No user action required for existing 2.0 installs — restart HA after the
|
||||
upgrade and the four new entities appear under each profile's device
|
||||
page, seeded with that profile's current options values.
|
||||
|
||||
---
|
||||
|
||||
## [2.0.0-cdit.1] — Unreleased
|
||||
|
||||
The first major CDiT release. **Breaking change**: existing upstream config
|
||||
|
|
|
|||
35
README.md
35
README.md
|
|
@ -64,6 +64,41 @@
|
|||
>
|
||||
> **Minimum Home Assistant version: `2025.1.0`** (pinned in `manifest.json`).
|
||||
>
|
||||
> ## ✨ What's new in 2.1
|
||||
>
|
||||
> Each profile now exposes **four live-tunable sliders** as `number`
|
||||
> entities you can drop on any Lovelace card:
|
||||
>
|
||||
> - `number.<profile>_min_brightness` — night-floor brightness (1–100 %)
|
||||
> - `number.<profile>_max_brightness` — peak-day brightness (1–100 %)
|
||||
> - `number.<profile>_min_color_temp` — warmest tone (1000–10000 K)
|
||||
> - `number.<profile>_max_color_temp` — coolest tone (1000–10000 K)
|
||||
>
|
||||
> **Slider position is the runtime truth.** Move a slider on the dashboard
|
||||
> and the curve picks it up on the next tick — no integration reload, no
|
||||
> restart. Position survives HA restarts via `RestoreNumber`.
|
||||
>
|
||||
> **Options dialog and sliders stay in sync.** Opening the options dialog
|
||||
> seeds the four range fields from the current slider values (not the
|
||||
> stale setup defaults). Saving the dialog resets the sliders to the
|
||||
> just-saved values — explicit gesture wins.
|
||||
>
|
||||
> Drop them on a dashboard:
|
||||
>
|
||||
> ```yaml
|
||||
> type: entities
|
||||
> entities:
|
||||
> - number.dining_mvp_min_brightness
|
||||
> - number.dining_mvp_max_brightness
|
||||
> - number.dining_mvp_min_color_temp
|
||||
> - number.dining_mvp_max_color_temp
|
||||
> ```
|
||||
>
|
||||
> Also in 2.1: **entity friendly names are now readable.** A profile named
|
||||
> "Dining MVP" exposes "Dining MVP", "Dining MVP Brightness", "Dining MVP
|
||||
> Color" — short enough to fit HA's tightest cards. Existing entity_ids
|
||||
> are preserved by the entity registry; automations keep working.
|
||||
>
|
||||
> 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"]
|
||||
PLATFORMS = ["switch", "number"]
|
||||
|
||||
# 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
|
||||
|
|
@ -84,8 +84,7 @@ def _remove_orphan_sleep_entities(
|
|||
for entry in registry.entities.values()
|
||||
if entry.config_entry_id == config_entry.entry_id
|
||||
and any(
|
||||
entry.unique_id.endswith(suffix)
|
||||
for suffix in _REMOVED_UNIQUE_ID_SUFFIXES
|
||||
entry.unique_id.endswith(suffix) for suffix in _REMOVED_UNIQUE_ID_SUFFIXES
|
||||
)
|
||||
]
|
||||
for entity_id in entries_to_remove:
|
||||
|
|
@ -137,9 +136,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
|
|||
|
||||
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
unload_ok = await hass.config_entries.async_forward_entry_unload(
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(
|
||||
config_entry,
|
||||
"switch",
|
||||
PLATFORMS,
|
||||
)
|
||||
data = hass.data[DOMAIN]
|
||||
if unload_ok:
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ except ImportError: # pragma: no cover — HA < 2025.1 fallback for dev env
|
|||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import section
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.selector import (
|
||||
BooleanSelector,
|
||||
EntitySelector,
|
||||
|
|
@ -68,6 +69,7 @@ from .const import (
|
|||
DEFAULT_SUNSET_ENTITY,
|
||||
DEFAULT_TRANSITION,
|
||||
DOMAIN,
|
||||
RANGE_ENTITIES,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
|
@ -152,7 +154,6 @@ def _build_options_schema(
|
|||
show_send_split_delay: bool,
|
||||
) -> vol.Schema:
|
||||
"""Build the sectioned options schema from the entry's current values."""
|
||||
|
||||
targets_section = section(
|
||||
vol.Schema(
|
||||
{
|
||||
|
|
@ -186,7 +187,10 @@ def _build_options_schema(
|
|||
): _color_temp_selector(),
|
||||
vol.Required(
|
||||
CONF_PREFER_RGB_COLOR,
|
||||
default=current.get(CONF_PREFER_RGB_COLOR, DEFAULT_PREFER_RGB_COLOR),
|
||||
default=current.get(
|
||||
CONF_PREFER_RGB_COLOR,
|
||||
DEFAULT_PREFER_RGB_COLOR,
|
||||
),
|
||||
): BooleanSelector(),
|
||||
},
|
||||
),
|
||||
|
|
@ -383,6 +387,24 @@ class OptionsFlowHandler(OptionsFlowWithReload):
|
|||
current = dict(conf.data)
|
||||
current.update(conf.options)
|
||||
|
||||
# Overlay live values from the four runtime range number entities
|
||||
# so the dialog matches what the user's lights are actually running
|
||||
# (spec R6, design D4). Other ~14 fields keep their `entry.options`
|
||||
# values from above.
|
||||
registry = er.async_get(self.hass)
|
||||
for row in RANGE_ENTITIES:
|
||||
unique_id = f"{conf.entry_id}_{row['field_key']}"
|
||||
entity_id = registry.async_get_entity_id("number", DOMAIN, unique_id)
|
||||
if entity_id is None:
|
||||
continue
|
||||
state = self.hass.states.get(entity_id)
|
||||
if state is None or state.state in (None, "unavailable", "unknown"):
|
||||
continue
|
||||
try:
|
||||
current[row["conf_key"]] = int(float(state.state))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
flat = _flatten_sections(user_input)
|
||||
|
|
|
|||
|
|
@ -46,9 +46,7 @@ DOCS[CONF_INCLUDE_CONFIG_IN_ATTRIBUTES] = (
|
|||
)
|
||||
|
||||
CONF_INITIAL_TRANSITION, DEFAULT_INITIAL_TRANSITION = "initial_transition", 1
|
||||
DOCS[CONF_INITIAL_TRANSITION] = (
|
||||
"Fade time when a light first turns on, in seconds."
|
||||
)
|
||||
DOCS[CONF_INITIAL_TRANSITION] = "Fade time when a light first turns on, in seconds."
|
||||
|
||||
CONF_INTERVAL, DEFAULT_INTERVAL = "interval", 90
|
||||
DOCS[CONF_INTERVAL] = "How often to recompute and re-apply the curve, in seconds."
|
||||
|
|
@ -150,6 +148,54 @@ ADAPT_BRIGHTNESS_SWITCH = "adapt_brightness_switch"
|
|||
ATTR_ADAPTIVE_LIGHTING_MANAGER = "manager"
|
||||
UNDO_UPDATE_LISTENER = "undo_update_listener"
|
||||
|
||||
# Runtime range number-entity declarations. Each tuple drives both the
|
||||
# number platform's entity creation and the curve-math read path. The
|
||||
# `field_key` becomes the entity's unique-id suffix; the `conf_key` ties
|
||||
# the entity back to its initial-seed value in `entry.options`.
|
||||
# Design decisions 5, 6, 11 — see add-runtime-range-controls/design.md.
|
||||
RANGE_ENTITIES: list[dict[str, Any]] = [
|
||||
{
|
||||
"field_key": "min_brightness",
|
||||
"conf_key": CONF_MIN_BRIGHTNESS,
|
||||
"name": "Min brightness",
|
||||
"native_min": 1,
|
||||
"native_max": 100,
|
||||
"step": 1,
|
||||
"unit": "%",
|
||||
"icon": "mdi:brightness-3",
|
||||
},
|
||||
{
|
||||
"field_key": "max_brightness",
|
||||
"conf_key": CONF_MAX_BRIGHTNESS,
|
||||
"name": "Max brightness",
|
||||
"native_min": 1,
|
||||
"native_max": 100,
|
||||
"step": 1,
|
||||
"unit": "%",
|
||||
"icon": "mdi:brightness-7",
|
||||
},
|
||||
{
|
||||
"field_key": "min_color_temp",
|
||||
"conf_key": CONF_MIN_COLOR_TEMP,
|
||||
"name": "Min color temp",
|
||||
"native_min": 1000,
|
||||
"native_max": 10000,
|
||||
"step": 100,
|
||||
"unit": "K",
|
||||
"icon": "mdi:thermometer-low",
|
||||
},
|
||||
{
|
||||
"field_key": "max_color_temp",
|
||||
"conf_key": CONF_MAX_COLOR_TEMP,
|
||||
"name": "Max color temp",
|
||||
"native_min": 1000,
|
||||
"native_max": 10000,
|
||||
"step": 100,
|
||||
"unit": "K",
|
||||
"icon": "mdi:thermometer-high",
|
||||
},
|
||||
]
|
||||
|
||||
ATTR_ADAPT_COLOR = "adapt_color"
|
||||
DOCS[ATTR_ADAPT_COLOR] = "Adjust the color of supporting lights over the day."
|
||||
ATTR_ADAPT_BRIGHTNESS = "adapt_brightness"
|
||||
|
|
@ -161,7 +207,7 @@ DOCS[CONF_TURN_ON_LIGHTS] = "Also turn on any targeted lights that are currently
|
|||
SERVICE_CHANGE_SWITCH_SETTINGS = "change_switch_settings"
|
||||
CONF_USE_DEFAULTS = "use_defaults"
|
||||
DOCS[CONF_USE_DEFAULTS] = (
|
||||
'Source for any field not supplied in this call: '
|
||||
"Source for any field not supplied in this call: "
|
||||
'"current" (keep existing values), "factory" (documented defaults), '
|
||||
'or "configuration" (the profile\'s own configured defaults).'
|
||||
)
|
||||
|
|
|
|||
|
|
@ -9,5 +9,5 @@
|
|||
"iot_class": "calculated",
|
||||
"issue_tracker": "https://github.com/CaseyRo/adaptive-lighting/issues",
|
||||
"requirements": ["ulid-transform"],
|
||||
"version": "2.0.0-cdit.1"
|
||||
"version": "2.1.0-cdit.1"
|
||||
}
|
||||
|
|
|
|||
155
custom_components/adaptive_lighting/number.py
Normal file
155
custom_components/adaptive_lighting/number.py
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
"""Number platform for the Adaptive Lighting integration (CDiT fork).
|
||||
|
||||
Each config entry exposes four live-tunable sliders that own the runtime
|
||||
values the curve math reads on every tick:
|
||||
|
||||
- ``number.<profile>_min_brightness``
|
||||
- ``number.<profile>_max_brightness``
|
||||
- ``number.<profile>_min_color_temp``
|
||||
- ``number.<profile>_max_color_temp``
|
||||
|
||||
The entities extend ``RestoreNumber`` so values survive HA restarts without
|
||||
a separate ``Store`` helper. Slider changes do NOT write back to
|
||||
``entry.options`` (no integration reload). Options-flow saves reload the
|
||||
integration, and the resulting fresh entities prefer the just-saved
|
||||
``entry.options`` value over the restored state. See design.md decisions
|
||||
1-3 of the ``add-runtime-range-controls`` change.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.components.number import (
|
||||
NumberMode,
|
||||
RestoreNumber,
|
||||
)
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
|
||||
from .const import DOMAIN, RANGE_ENTITIES
|
||||
|
||||
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, # noqa: ARG001
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Create the four range entities for this config entry."""
|
||||
entities = [
|
||||
AdaptiveRangeNumber(
|
||||
entry=config_entry,
|
||||
field_key=row["field_key"],
|
||||
conf_key=row["conf_key"],
|
||||
display_name=row["name"],
|
||||
native_min=row["native_min"],
|
||||
native_max=row["native_max"],
|
||||
step=row["step"],
|
||||
unit=row["unit"],
|
||||
icon=row["icon"],
|
||||
)
|
||||
for row in RANGE_ENTITIES
|
||||
]
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class AdaptiveRangeNumber(RestoreNumber):
|
||||
"""Live-tunable slider for one of the four curve range values."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_mode = NumberMode.SLIDER
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
entry: ConfigEntry,
|
||||
field_key: str,
|
||||
conf_key: str,
|
||||
display_name: str,
|
||||
native_min: float,
|
||||
native_max: float,
|
||||
step: float,
|
||||
unit: str,
|
||||
icon: str,
|
||||
) -> None:
|
||||
"""Initialise a single range number entity."""
|
||||
self._entry = entry
|
||||
self._field_key = field_key
|
||||
self._conf_key = conf_key
|
||||
self._attr_name = display_name
|
||||
self._attr_translation_key = field_key
|
||||
self._attr_unique_id = f"{entry.entry_id}_{field_key}"
|
||||
self._attr_native_min_value = native_min
|
||||
self._attr_native_max_value = native_max
|
||||
self._attr_native_step = step
|
||||
self._attr_native_unit_of_measurement = unit
|
||||
self._attr_icon = icon
|
||||
# Initial value falls back to the options snapshot until
|
||||
# async_added_to_hass overrides with a restored or just-saved value.
|
||||
self._attr_native_value = self._options_value()
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Group with the profile's switches under one device."""
|
||||
profile_name = self._entry.data.get("name") or self._entry.title
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, profile_name)},
|
||||
name=profile_name,
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
)
|
||||
|
||||
def _options_value(self) -> float:
|
||||
"""Read the seed value from entry.options (typed cast)."""
|
||||
raw = self._entry.options.get(self._conf_key)
|
||||
if raw is None:
|
||||
raw = self._entry.data.get(self._conf_key, self._attr_native_min_value)
|
||||
return float(raw)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Seed the entity value with three-tier precedence.
|
||||
|
||||
(a) First-creation: no restored state → use ``entry.options[conf_key]``.
|
||||
(b) Restored state exists AND was persisted AFTER the entry was last
|
||||
modified → the user moved the slider since the last options-flow
|
||||
save; use the restored value (slider survives restart).
|
||||
(c) Restored state exists BUT the entry was modified AFTER the
|
||||
restored state was persisted → an options-flow save changed the
|
||||
value; use ``entry.options[conf_key]`` (just-saved wins).
|
||||
"""
|
||||
await super().async_added_to_hass()
|
||||
options_value = self._options_value()
|
||||
last_state = await self.async_get_last_state()
|
||||
if last_state is None or last_state.state in (None, "unknown", "unavailable"):
|
||||
self._attr_native_value = options_value
|
||||
return
|
||||
try:
|
||||
restored_value = float(last_state.state)
|
||||
except (TypeError, ValueError):
|
||||
self._attr_native_value = options_value
|
||||
return
|
||||
entry_modified = getattr(self._entry, "modified_at", None)
|
||||
if entry_modified is not None and entry_modified > last_state.last_updated:
|
||||
# Entry was edited (via options-flow save) after the entity's
|
||||
# last persist → the just-saved options value supersedes.
|
||||
self._attr_native_value = options_value
|
||||
else:
|
||||
self._attr_native_value = restored_value
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Persist the new slider value to entity state only.
|
||||
|
||||
Does NOT write to ``entry.options`` — that would trigger an
|
||||
``OptionsFlowWithReload`` reload on every slider tick. The
|
||||
RestoreNumber base class persists the value across restarts.
|
||||
"""
|
||||
self._attr_native_value = value
|
||||
self.async_write_ha_state()
|
||||
|
|
@ -243,5 +243,21 @@
|
|||
"incompatible_version": {
|
||||
"message": "Adaptive Lighting v{current_version} (CDiT fork) is incompatible with the existing config entry (version {entry_version}). Delete the entry from Settings → Devices & Services and create a new one."
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"number": {
|
||||
"min_brightness": {
|
||||
"name": "Min brightness"
|
||||
},
|
||||
"max_brightness": {
|
||||
"name": "Max brightness"
|
||||
},
|
||||
"min_color_temp": {
|
||||
"name": "Min color temp"
|
||||
},
|
||||
"max_color_temp": {
|
||||
"name": "Max color temp"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -329,7 +329,7 @@ async def handle_change_switch_settings(
|
|||
)
|
||||
|
||||
|
||||
async def async_setup_entry( # noqa: PLR0915
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
|
|
@ -356,6 +356,7 @@ async def async_setup_entry( # noqa: PLR0915
|
|||
hass=hass,
|
||||
config_entry=config_entry,
|
||||
icon=ICON_COLOR_TEMP,
|
||||
display_name="Color",
|
||||
)
|
||||
adapt_brightness_switch = SimpleSwitch(
|
||||
which="Adapt Brightness",
|
||||
|
|
@ -363,6 +364,7 @@ async def async_setup_entry( # noqa: PLR0915
|
|||
hass=hass,
|
||||
config_entry=config_entry,
|
||||
icon=ICON_BRIGHTNESS,
|
||||
display_name="Brightness",
|
||||
)
|
||||
switch = AdaptiveSwitch(
|
||||
hass,
|
||||
|
|
@ -733,6 +735,12 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
_only_once: bool = False
|
||||
_auto_reset_manual_control_time: float = 0
|
||||
|
||||
# Entity-name composition: device name carries the profile name; the
|
||||
# master switch's friendly name is just the device name. See design
|
||||
# decision D11 of add-runtime-range-controls.
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
|
||||
_attr_icon = "mdi:weather-sunny-alert"
|
||||
|
||||
def __init__(
|
||||
|
|
@ -750,6 +758,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
self.manager = manager
|
||||
self.adapt_color_switch = adapt_color_switch
|
||||
self.adapt_brightness_switch = adapt_brightness_switch
|
||||
self._config_entry = config_entry
|
||||
|
||||
data = validate(config_entry)
|
||||
|
||||
|
|
@ -832,14 +841,14 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
self._sunset_entity = data[CONF_SUNSET_ENTITY]
|
||||
self._expand_light_groups()
|
||||
|
||||
self._sun_light_settings = SunLightSettings(
|
||||
name=self._name,
|
||||
max_brightness=data[CONF_MAX_BRIGHTNESS],
|
||||
max_color_temp=data[CONF_MAX_COLOR_TEMP],
|
||||
min_brightness=data[CONF_MIN_BRIGHTNESS],
|
||||
min_color_temp=data[CONF_MIN_COLOR_TEMP],
|
||||
ramp_half_width_seconds=RAMP_HALF_WIDTH_SECONDS,
|
||||
)
|
||||
# Snapshot the four range values for the fallback path; the
|
||||
# `sun_light_settings` property rebuilds the dataclass on each tick
|
||||
# using live values from the number entities, falling back to these
|
||||
# snapshots when an entity is unavailable.
|
||||
self._fallback_min_brightness = data[CONF_MIN_BRIGHTNESS]
|
||||
self._fallback_max_brightness = data[CONF_MAX_BRIGHTNESS]
|
||||
self._fallback_min_color_temp = data[CONF_MIN_COLOR_TEMP]
|
||||
self._fallback_max_color_temp = data[CONF_MAX_COLOR_TEMP]
|
||||
_LOGGER.debug(
|
||||
"%s: Set switch settings for lights '%s'. now using data: '%s'",
|
||||
self._name,
|
||||
|
|
@ -894,10 +903,59 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
t_sunset = t_sunset + timedelta(days=1)
|
||||
return t_sunrise, t_sunset
|
||||
|
||||
def _get_runtime_range(self, field_key: str) -> int:
|
||||
"""Read the live curve bound from its number entity.
|
||||
|
||||
Looks up the entity by stable unique_id via the entity registry,
|
||||
reads the state-machine value, and falls back to the snapshot
|
||||
captured at setup when the entity is unavailable (spec R3, D8, D9).
|
||||
"""
|
||||
registry = entity_registry.async_get(self.hass)
|
||||
unique_id = f"{self._config_entry.entry_id}_{field_key}"
|
||||
entity_id = registry.async_get_entity_id("number", DOMAIN, unique_id)
|
||||
if entity_id is not None:
|
||||
state = self.hass.states.get(entity_id)
|
||||
if state is not None and state.state not in (
|
||||
None,
|
||||
"unavailable",
|
||||
"unknown",
|
||||
):
|
||||
try:
|
||||
return int(float(state.state))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
fallback = {
|
||||
"min_brightness": self._fallback_min_brightness,
|
||||
"max_brightness": self._fallback_max_brightness,
|
||||
"min_color_temp": self._fallback_min_color_temp,
|
||||
"max_color_temp": self._fallback_max_color_temp,
|
||||
}[field_key]
|
||||
_LOGGER.debug(
|
||||
"%s: runtime range entity unavailable for '%s' "
|
||||
"(unique_id=%s) — falling back to entry.options value %s",
|
||||
self._name,
|
||||
field_key,
|
||||
unique_id,
|
||||
fallback,
|
||||
)
|
||||
return int(fallback)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return the name of the device if any."""
|
||||
return f"Adaptive Lighting: {self._name}"
|
||||
def sun_light_settings(self) -> SunLightSettings:
|
||||
"""Return a fresh `SunLightSettings` built from current entity states.
|
||||
|
||||
Reads the four runtime range values on every property access so curve
|
||||
evaluations always see the latest slider position. The dataclass init
|
||||
cost is microseconds — cheap enough to do every tick (D8).
|
||||
"""
|
||||
return SunLightSettings(
|
||||
name=self._name,
|
||||
max_brightness=self._get_runtime_range("max_brightness"),
|
||||
max_color_temp=self._get_runtime_range("max_color_temp"),
|
||||
min_brightness=self._get_runtime_range("min_brightness"),
|
||||
min_color_temp=self._get_runtime_range("min_color_temp"),
|
||||
ramp_half_width_seconds=RAMP_HALF_WIDTH_SECONDS,
|
||||
)
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
|
|
@ -1120,7 +1178,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
if events is None:
|
||||
return None
|
||||
t_sunrise, t_sunset = events
|
||||
self._settings = self._sun_light_settings.get_settings(
|
||||
self._settings = self.sun_light_settings.get_settings(
|
||||
transition,
|
||||
t_sunrise,
|
||||
t_sunset,
|
||||
|
|
@ -1316,7 +1374,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
if events is not None:
|
||||
t_sunrise, t_sunset = events
|
||||
self._settings.update(
|
||||
self._sun_light_settings.get_settings(
|
||||
self.sun_light_settings.get_settings(
|
||||
transition,
|
||||
t_sunrise,
|
||||
t_sunset,
|
||||
|
|
@ -1417,7 +1475,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
force=True,
|
||||
)
|
||||
|
||||
def fire_manual_control_event( # noqa: ARG002
|
||||
def fire_manual_control_event(
|
||||
self,
|
||||
light: str,
|
||||
context: Context,
|
||||
|
|
@ -1439,6 +1497,11 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
class SimpleSwitch(SwitchEntity, RestoreEntity):
|
||||
"""Representation of a Adaptive Lighting switch."""
|
||||
|
||||
# Entity-name composition: device name carries the profile name, this
|
||||
# switch's `_attr_name` carries only its role ("Brightness" / "Color").
|
||||
# HA composes "<device> <role>" automatically. D11.
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
which: str,
|
||||
|
|
@ -1446,8 +1509,14 @@ class SimpleSwitch(SwitchEntity, RestoreEntity):
|
|||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
icon: str,
|
||||
display_name: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize the Adaptive Lighting switch."""
|
||||
"""Initialize the Adaptive Lighting switch.
|
||||
|
||||
`which` is the legacy slug token used for the entity's unique_id
|
||||
(kept stable to preserve existing entity_ids). `display_name`
|
||||
overrides the user-facing role label; falls back to `which`.
|
||||
"""
|
||||
self.hass = hass
|
||||
data = validate(config_entry)
|
||||
self._icon = icon
|
||||
|
|
@ -1455,14 +1524,11 @@ class SimpleSwitch(SwitchEntity, RestoreEntity):
|
|||
self._which = which
|
||||
self._config_name = data[CONF_NAME]
|
||||
self._unique_id = f"{self._config_name}_{slugify(self._which)}"
|
||||
# Internal label for log messages (keep includes profile + role).
|
||||
self._name = f"Adaptive Lighting {which}: {self._config_name}"
|
||||
self._attr_name = display_name or which
|
||||
self._initial_state = initial_state
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return the name of the device if any."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
"""Return the unique ID of entity."""
|
||||
|
|
@ -1485,7 +1551,7 @@ class SimpleSwitch(SwitchEntity, RestoreEntity):
|
|||
identifiers={
|
||||
(DOMAIN, self._config_name),
|
||||
},
|
||||
name=f"Adaptive Lighting: {self._config_name}",
|
||||
name=self._config_name,
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -243,5 +243,21 @@
|
|||
"incompatible_version": {
|
||||
"message": "Adaptive Lighting v{current_version} (CDiT fork) is incompatible with the existing config entry (version {entry_version}). Delete the entry from Settings → Devices & Services and create a new one."
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"number": {
|
||||
"min_brightness": {
|
||||
"name": "Min brightness"
|
||||
},
|
||||
"max_brightness": {
|
||||
"name": "Max brightness"
|
||||
},
|
||||
"min_color_temp": {
|
||||
"name": "Min color temp"
|
||||
},
|
||||
"max_color_temp": {
|
||||
"name": "Max color temp"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
177
openspec/changes/add-runtime-range-controls/design.md
Normal file
177
openspec/changes/add-runtime-range-controls/design.md
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
## Context
|
||||
|
||||
After `cdit-config-redesign`, the four most-tuned values — `min_brightness`, `max_brightness`, `min_color_temp`, `max_color_temp` — live in the options flow's Daytime curve section. Editing them requires the full Settings → Devices & Services → Adaptive Lighting → Configure → Save click trail, and each save triggers a full integration reload via `OptionsFlowWithReload` (entities torn down, switches re-registered, ~1 s of disruption).
|
||||
|
||||
That's the wrong gesture for the right action. "The kitchen feels too warm tonight" should be a slider on the dashboard, not a dialog round-trip. This change promotes those four values to first-class HA `number` entities — sliders that show up on any Lovelace view, that scripts can read, that automations can write, that the user can voice-command via the HA Assist pipeline.
|
||||
|
||||
The integration's curve math currently reads `entry.options[CONF_MIN_BRIGHTNESS]` (etc.) on every tick. After this change, it reads `hass.states.get("number.adaptive_lighting_<name>_min_brightness").state`. The options flow still owns these fields (for setup ergonomics), but the runtime source of truth shifts to the entities.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Four live-tunable entities per AL profile, slider mode, native HA selectors. Visible on the integration's device page and embeddable in any Lovelace card.
|
||||
- Persistent across HA restart via `RestoreNumber` (no separate `Store` plumbing).
|
||||
- Curve math reads from a single runtime source — the entity state. Options flow re-seeds entities on save.
|
||||
- Slider drag must NOT trigger an integration reload. Tuning is cheap; setup is heavy.
|
||||
- Setup parity: a fresh AL config still has working defaults from `entry.options`; first-time users do not need to discover the slider entities to get a working install.
|
||||
- Options-flow open seeds from current entity state, not the stale options snapshot — what the user sees in the dialog is what their lights are running.
|
||||
|
||||
**Non-Goals:**
|
||||
- Promoting non-range setup-time fields (`interval`, `transition`, `initial_transition`, `adapt_delay`, `send_split_delay`, `ramp_half_width`) to runtime entities. Setup-time concerns stay in the options flow.
|
||||
- A custom Lovelace card. Native HA number cards render the four entities fine; a dedicated card is a separate change (`add-lovelace-card`, already stubbed).
|
||||
- Reverse coupling — making the options flow read from entities instead of `entry.options` as its persistence backbone. Options flow remains options-flow; the entities are a *runtime* surface layered on top.
|
||||
- Service handlers for setting range values. The number entity's own `number.set_value` service is enough.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision 1: Single runtime source of truth — the number entity state
|
||||
|
||||
**What we chose:** The four `number` entities are the canonical place the curve math reads from at runtime. `entry.options` becomes the seed-on-create and last-save snapshot, but no longer the read path during curve evaluation.
|
||||
|
||||
**Why:** Avoids the dual-source-of-truth bug class. With one read path, there is no "which one wins when they disagree?" question. The options flow remains the place to set defaults; the entity is the place to tune live. Different gestures, different surfaces, one read.
|
||||
|
||||
**Alternatives considered:**
|
||||
- **`entry.options` canonical, entity is a passthrough proxy.** Rejected — every slider change has to dual-write to options, which triggers `OptionsFlowWithReload` reload. ~1 s integration disruption per slider tick is unacceptable UX.
|
||||
- **Custom `hass.data` runtime cache, written by both surfaces, read by curve math.** Rejected — third layer just to bridge the gap between "entry.options is persistent" and "entity is interactive". RestoreNumber already gives us per-entity persistence.
|
||||
|
||||
### Decision 2: `RestoreNumber` for persistence, no write-back to `entry.options` on slider change
|
||||
|
||||
**What we chose:** The four entities extend `homeassistant.components.number.RestoreNumber`. Each entity persists its state via HA's native restore mechanism. Slider changes do not call `async_update_entry`. `entry.options` is only updated when the user explicitly saves the options flow.
|
||||
|
||||
**Why:** Three wins. (1) Avoids the `OptionsFlowWithReload` reload-on-every-slider trap. (2) Uses the HA-idiomatic restore path — same machinery scenes and automations use for any `number` entity. (3) Decouples the persistent-config concept (options flow) from the live-tuning concept (entity); the two surfaces stay logically distinct, even if they happen to mirror each other most of the time.
|
||||
|
||||
**Alternatives considered:**
|
||||
- **Dual-write on slider change + suppress reload via a temporary listener detach.** Rejected — peeks into `OptionsFlowWithReload` internals; brittle across HA core upgrades.
|
||||
- **`Store` helper keyed by entry_id with the four values.** Rejected — extra abstraction layer for the same persistence guarantee RestoreNumber gives us for free.
|
||||
- **No persistence at all — slider value resets to `entry.options` defaults on every HA restart.** Rejected — punishes users who tune their install. Restart should not lose tuning.
|
||||
|
||||
### Decision 3: Options-flow save pushes new values into the entities via reload-and-reseed
|
||||
|
||||
**What we chose:** When the user saves the options flow, `entry.options` updates as normal, `OptionsFlowWithReload` reloads the integration, and the number platform's `async_setup_entry` recreates the entities. On creation, each entity reads its initial value from `entry.options[CONF_*]` rather than from the RestoreNumber state. Result: just-saved values appear immediately on the sliders.
|
||||
|
||||
**Why:** Reuses the existing reload mechanism instead of inventing a new "push to entity" pathway. The hierarchy is deliberate: a save through the options flow is an explicit user gesture ("these are my new defaults"), so the just-saved value supersedes any pre-existing slider position. The RestoreNumber data is the fallback when there are no fresher options.
|
||||
|
||||
**Alternatives considered:**
|
||||
- **Skip reload on options save and push to entities directly via `entity.async_set_native_value()`.** Rejected — works for the four range fields but the other ~14 options fields still need reload to take effect, so reload happens regardless. Doing two save paths (reload-everything vs. push-this-entity) doubles the surface to test.
|
||||
- **RestoreNumber state wins over `entry.options` on entity creation.** Rejected — means the options flow Save button silently does nothing for the range fields, which is the opposite of what the user just asked for.
|
||||
|
||||
### Decision 4: Options flow OPEN seeds the four range fields from the entity, not from `entry.options`
|
||||
|
||||
**What we chose:** When the options flow renders, the four range field defaults are read from `hass.states.get(<entity_id>).state` (cast to int), with a fallback to `entry.options[CONF_*]` if the entity is unavailable. The other ~14 fields continue to seed from `entry.options` as today.
|
||||
|
||||
**Why:** "What I see in the dialog matches what's running" is a stronger UX invariant than "what I see in the dialog matches what I last typed here." If the user spent a week tweaking the dashboard slider down to 65% and then opens the dialog, the dialog should show 65, not the 70 they typed at install time.
|
||||
|
||||
**Alternatives considered:**
|
||||
- **Seed from `entry.options` always.** Rejected — divergence between dialog and reality is exactly the kind of "why does saving snap my slider back?" surprise this change is built to eliminate.
|
||||
- **Seed from entity, but show both values in the dialog (e.g., "current: 65, last saved: 70").** Rejected — visual clutter for a corner case. The user can read the slider entity directly if they want to see history.
|
||||
|
||||
### Decision 5: Entity naming pattern uses the profile slug
|
||||
|
||||
**What we chose:** Each entity's `unique_id` is `<entry.entry_id>_<field>` (e.g., `<entry_id>_min_brightness`), the device name is the profile name, and HA's default entity-id slug-from-friendly-name yields entities like `number.adaptive_lighting_<profile>_min_brightness` after slugification.
|
||||
|
||||
**Why:** Mirrors the convention `cdit-config-redesign` set for the three switches (`switch.adaptive_lighting_<profile>_adapt_brightness` etc.). Predictable for automation authors. No surprises.
|
||||
|
||||
**Alternatives considered:**
|
||||
- **Per-field unique_id without profile in the slug.** Rejected — collides across multiple AL profiles.
|
||||
- **Short slug (`number.al_<profile>_min_b`).** Rejected — saves keystrokes, costs readability in dashboards.
|
||||
|
||||
### Decision 6: Native selector configuration per field
|
||||
|
||||
**What we chose:**
|
||||
|
||||
| Field | min | max | step | unit | mode | icon |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `min_brightness` | 1 | 100 | 1 | `%` | slider | `mdi:brightness-3` |
|
||||
| `max_brightness` | 1 | 100 | 1 | `%` | slider | `mdi:brightness-7` |
|
||||
| `min_color_temp` | 1000 | 10000 | 100 | `K` | slider | `mdi:thermometer-low` |
|
||||
| `max_color_temp` | 1000 | 10000 | 100 | `K` | slider | `mdi:thermometer-high` |
|
||||
|
||||
**Why:** Same ranges and units as the options-flow `NumberSelector` config (R5 of `options-flow` spec). Icons pick a "low/high" pair so the slider rows visually pair up on the device page.
|
||||
|
||||
**Alternatives considered:**
|
||||
- **`box-or-slider` mode for color temp** (which has a 9000-unit range and 100-unit step). Rejected for now — pure slider mode is fine on desktop; mobile UX is the only place box-mode helps, and 90 steps is still draggable. Re-evaluate if user complains.
|
||||
- **Same `mdi:brightness-percent` icon for both brightness entities.** Rejected — visually identical entities on the device page is a discoverability foot-gun.
|
||||
|
||||
### Decision 7: Range validation lives in the selector, not in a custom validator
|
||||
|
||||
**What we chose:** Min/max bounds are enforced by `NumberEntity.native_min_value` / `native_max_value` (frontend rejects out-of-range inputs). The curve math does not re-validate — it trusts the state machine to hold a sane number.
|
||||
|
||||
**Why:** Aligns with `cdit-config-redesign` Decision 5 (native selectors over voluptuous wrappers). One layer of validation, frontend-enforced.
|
||||
|
||||
**Alternatives considered:**
|
||||
- **Belt-and-braces validation in curve math.** Rejected — duplicates the frontend rule. If the state machine ever held a bogus value, the curve math would silently coerce; better to fail loud (let `int(...)` raise) so we hear about real bugs.
|
||||
|
||||
### Decision 8: Read path in curve math — `hass.states.get`, no caching
|
||||
|
||||
**What we chose:** `AdaptiveSwitch._get_settings()` (or wherever the curve evaluates) reads `hass.states.get(self._range_entity_id("min_brightness")).state` on every tick, casts to int, passes into `SunLightSettings`. No local attribute cache, no listener-on-change.
|
||||
|
||||
**Why:** The state machine read is a dict lookup. Curve evaluation happens every ~90 s (the `interval` setting). The 4 state-machine reads cost ~5 µs total. Caching saves nothing measurable and adds invalidation complexity ("which event resets the cache?").
|
||||
|
||||
**Alternatives considered:**
|
||||
- **Cache + state-change listener.** Rejected — premature optimization. If profiling ever shows curve eval is hot, revisit.
|
||||
- **Pass `hass` into `SunLightSettings.brightness_pct()` and read inside.** Rejected — pollutes the pure-math wrapper with HA state access. The switch class is the right boundary.
|
||||
|
||||
### Decision 9: Entity unavailability falls back to `entry.options`
|
||||
|
||||
**What we chose:** If `hass.states.get(<entity_id>)` returns `None` or the state is `unavailable` / `unknown`, the curve math falls back to `entry.options[CONF_*]`. Logged at DEBUG.
|
||||
|
||||
**Why:** Belt-and-braces. The race window is small (entity created in same setup pass as the switch) but real. Falling back to the same value the entity would seed from anyway keeps the curve continuous through any transient hiccup.
|
||||
|
||||
**Alternatives considered:**
|
||||
- **Skip the curve update if entities are unavailable.** Rejected — invisible to the user, looks like a freeze.
|
||||
- **Hard-fail loudly.** Rejected — startup ordering is HA's responsibility, not the user's problem to debug.
|
||||
|
||||
### Decision 10: Capability slug `runtime-range-controls`
|
||||
|
||||
**What we chose:** This change defines one new capability — `runtime-range-controls` — covering entity creation, write-through semantics, the curve-math read path, restore behavior, and option-flow seeding. It also modifies the existing `options-flow` capability (the Daytime curve section gains write-through behavior on save).
|
||||
|
||||
**Why:** Scoped tight, matches the pattern set by `options-flow` in `cdit-config-redesign`. Keeps spec scenarios testable in isolation.
|
||||
|
||||
### Decision 11: All AL entities use HA's `has_entity_name` composition
|
||||
|
||||
**What we chose:** Every entity created by this integration sets `_attr_has_entity_name = True` and registers under a device record whose `name` is the profile's display name (i.e., `entry.title`, which mirrors `entry.data[CONF_NAME]`). The per-entity `_attr_name` carries only the entity's role:
|
||||
|
||||
| Entity class | `_attr_name` | Resulting friendly name | Chars |
|
||||
|---|---|---|---|
|
||||
| `AdaptiveSwitch` (master) | `None` | "Dining MVP" | 10 |
|
||||
| `AdaptBrightnessSwitch` | `"Brightness"` | "Dining MVP Brightness" | 21 |
|
||||
| `AdaptColorSwitch` | `"Color"` | "Dining MVP Color" | 16 |
|
||||
| `AdaptiveRangeNumber(min_brightness)` | `"Min brightness"` | "Dining MVP Min brightness" | 25 |
|
||||
| `AdaptiveRangeNumber(max_brightness)` | `"Max brightness"` | "Dining MVP Max brightness" | 25 |
|
||||
| `AdaptiveRangeNumber(min_color_temp)` | `"Min color temp"` | "Dining MVP Min color temp" | 25 |
|
||||
| `AdaptiveRangeNumber(max_color_temp)` | `"Max color temp"` | "Dining MVP Max color temp" | 25 |
|
||||
|
||||
**Why:** Today's friendly names ("Adaptive Lighting Adapt Brightness dining_mvp_lights") truncate to "Adaptive Lighting Adapt Br…" in HA's More-info card and overflow most Lovelace cards. The `has_entity_name = True` convention is the HA-idiomatic way to compose `<device> <role>` strings that read naturally and respect screen width. It also unifies presentation across the existing three switches and the four new number entities — a one-time fix instead of inheriting the problem.
|
||||
|
||||
**Why drop the "Adapt" verb from the two toggles:** Tightest fit on HA's narrowest cards (~28 char truncation point). The entity type (a toggle switch UI) already says "this is on/off"; the device context already says "this is adaptive lighting." Calling it just "Brightness" risks being read as a brightness value, but the toggle UI affordance and the integration icon disambiguate it in every HA surface where it renders. Worst case for clarity: an unfamiliar dashboard read; trade accepted for guaranteed fit and visual rhythm with the number-entity sliders.
|
||||
|
||||
**Entity-id stability:** `unique_id`s are unchanged by this decision, so the entity registry preserves existing entity_ids for any deployed install (automations and scripts referencing `switch.adaptive_lighting_adapt_brightness_dining_mvp_lights` keep working). Only the human-facing friendly name changes for existing installs. Fresh installs after this change ships get the cleaner entity_id slugs from the start.
|
||||
|
||||
**Alternatives considered:**
|
||||
- **Strip "Adaptive Lighting" prefix without adopting `has_entity_name`.** Rejected — patches one symptom (the prefix) without fixing the structural problem (per-entity hand-composed names). Next entity we add inherits the same pattern.
|
||||
- **Rename `unique_id`s to the cleaner pattern and let HA regenerate entity_ids.** Rejected — breaks every user automation. Not worth the upside; `has_entity_name` gives us the readable friendly name without touching unique_ids.
|
||||
- **Use the entry id (`abc123…`) as the device name.** Rejected — opaque to users. Profile name is the right anchor.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **[RestoreNumber state is wiped if the user deletes and recreates the integration]** → New install starts from `entry.options` defaults. Acceptable — that's the documented behavior for any HA entity. Mitigation: README note that "Settings → Devices → Adaptive Lighting → Configure" is the place to set defaults, "the sliders" are the place to tune live.
|
||||
- **[Options-flow Save snaps live slider to the typed value]** → Intentional (Decision 3). Mitigation: the section framer text in `strings.json` says "saving here resets the live sliders to these values."
|
||||
- **[Stale options dialog if user opens it during a long-running automation that's adjusting the sliders]** → The seed-on-open (Decision 4) reads the entity state at flow-open time; further automation changes after open are not reflected. Mitigation: this is the standard HA dialog-render contract; same behavior as every other config flow.
|
||||
- **[Curve math reads state machine on every tick, ~90 s interval]** → Tiny perf cost; not a concern at human-scale tick rates. Mitigation: none needed; document in design.md (Decision 8).
|
||||
- **[Profile rename via UI breaks the entity unique_id slug]** → `unique_id` uses `entry.entry_id`, not the profile name, so the unique_id is stable across renames. Entity ID slug (entity_id) regenerates from the friendly name; HA preserves it via entity registry. Mitigation: covered by HA's own rename handling; no special code needed.
|
||||
- **[Race: user moves slider while options-flow save is in flight]** → Last-write-wins per the natural state-machine ordering: whichever async task lands last sets the value. Acceptable; the window is < 1 s. Mitigation: none.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Single PR on the fork's `main` branch. The `cdit-config-redesign` change is already archived (this depends on its options-flow contract, not its delivery).
|
||||
|
||||
1. Add the `number` platform.
|
||||
2. Ship the runtime-range-controls capability via the spec delta.
|
||||
3. Tag a release (`v2.1.0-cdit.1` — minor bump; no breaking change to existing entries).
|
||||
4. Existing config entries pick up four new entities on next HA restart. The entities seed from the entry's saved options the first time they appear. No user action required.
|
||||
|
||||
**Rollback:** revert the PR. The four new entities disappear from the entity registry; the curve math reverts to reading `entry.options` directly. No data loss.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None — all 11 decisions resolved.
|
||||
|
|
@ -20,11 +20,13 @@ This change promotes the four ranges to first-class runtime entities (HA's `numb
|
|||
- **Curve math reads from number entities**, not from `entry.options` directly. Single read path at evaluation time.
|
||||
- **Options flow keeps the four fields visible** in the Daytime curve section so users can still tune them from the config screen (especially first-time setup). Both surfaces stay in sync.
|
||||
|
||||
- **Entity-naming hygiene** (folded in because the new entities would inherit the same problem otherwise): switch the existing three switch entities and the four new number entities to HA's `has_entity_name = True` convention. Device name becomes the profile name (e.g., "Dining MVP"); entity names become short ("Brightness", "Color", "Min color temp"). Today's friendly names like "Adaptive Lighting Adapt Brightness dining_mvp_lights" truncate to "Adaptive Lighting Adapt Br…" in HA's More-info card; after this change they read as "Dining MVP Brightness" (21 chars, well inside the truncation window). `unique_id`s stay stable, so existing entity_ids and automations keep working.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `runtime-range-controls`: live-tunable brightness and color-temperature range entities, with bidirectional sync to the config entry options. Covers entity creation, write-through semantics, curve-math read path, and conflict resolution between the two surfaces.
|
||||
- `runtime-range-controls`: live-tunable brightness and color-temperature range entities, with bidirectional sync to the config entry options. Covers entity creation, write-through semantics, curve-math read path, and conflict resolution between the two surfaces. Also defines the `has_entity_name` naming convention applied to all AL entities (switches + number entities).
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Brightness and color temperature follow a synthetic tanh curve
|
||||
|
||||
For each config entry, the integration SHALL synthesize the daytime brightness and color-temperature values using a hyperbolic-tangent curve anchored at the timestamps from `sunrise_entity` and `sunset_entity`, with a hardcoded half-width of 30 minutes (`RAMP_HALF_WIDTH_SECONDS = 1800`) at each event. The brightness value at time `t` SHALL follow:
|
||||
|
||||
- `t ≤ t_sunrise − 1800s`: value = configured minimum
|
||||
- `t_sunrise − 1800s < t < t_sunrise + 1800s`: value = tanh-interpolated minimum → maximum
|
||||
- `t_sunrise + 1800s ≤ t ≤ t_sunset − 1800s`: value = configured maximum
|
||||
- `t_sunset − 1800s < t < t_sunset + 1800s`: value = tanh-interpolated maximum → minimum
|
||||
- `t ≥ t_sunset + 1800s`: value = configured minimum
|
||||
|
||||
The same curve shape SHALL be applied to color temperature using `min_color_temp` and `max_color_temp` as the curve bounds.
|
||||
|
||||
The four bound values (`min_brightness`, `max_brightness`, `min_color_temp`, `max_color_temp`) SHALL be read at evaluation time from the four runtime range entities defined in the `runtime-range-controls` capability, with fallback to `entry.options[CONF_*]` when an entity is unavailable. The curve evaluation SHALL NOT read these four values directly from `entry.options` during normal operation.
|
||||
|
||||
#### Scenario: Brightness is at minimum well before sunrise
|
||||
|
||||
- **WHEN** the current time is more than 30 minutes before the `sunrise_entity` timestamp
|
||||
- **THEN** the computed brightness SHALL equal the current value of `number.adaptive_lighting_<name>_min_brightness`
|
||||
|
||||
#### Scenario: Brightness is exactly at the midpoint at the sunrise event
|
||||
|
||||
- **WHEN** the current time equals the `sunrise_entity` timestamp
|
||||
- **THEN** the computed brightness SHALL equal `(current_min_brightness + current_max_brightness) / 2`
|
||||
- **WHERE** `current_min_brightness` and `current_max_brightness` are the current states of the corresponding number entities
|
||||
|
||||
#### Scenario: Brightness is at maximum during the day
|
||||
|
||||
- **WHEN** the current time is between `sunrise_entity + 30min` and `sunset_entity − 30min`
|
||||
- **THEN** the computed brightness SHALL equal the current value of `number.adaptive_lighting_<name>_max_brightness`
|
||||
|
||||
#### Scenario: Color temperature follows the same curve shape
|
||||
|
||||
- **WHEN** the current time is at any point on the curve
|
||||
- **THEN** the computed color temperature SHALL follow the same tanh interpolation between the current values of `number.adaptive_lighting_<name>_min_color_temp` and `_max_color_temp` as the brightness curve does between the two brightness entities
|
||||
|
||||
#### Scenario: Bound values are taken from runtime entities, not from entry.options
|
||||
|
||||
- **GIVEN** `entry.options[CONF_MIN_BRIGHTNESS]` is 5
|
||||
- **AND** `number.adaptive_lighting_<name>_min_brightness` is at 30
|
||||
- **WHEN** the curve is evaluated at a time before `sunrise_entity − 1800s`
|
||||
- **THEN** the computed brightness SHALL equal 30 (the entity state), not 5 (`entry.options`)
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Each AL profile exposes four runtime range entities
|
||||
|
||||
For each Adaptive Lighting config entry, the integration SHALL create exactly four `number` entities, one for each of `min_brightness`, `max_brightness`, `min_color_temp`, `max_color_temp`. The entities SHALL be registered on the `number` platform during `async_setup_entry` and torn down during `async_unload_entry`. Each entity SHALL share the same device record as the profile's three switches.
|
||||
|
||||
| Field | unique_id suffix | native_min | native_max | step | unit | mode |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `min_brightness` | `_min_brightness` | 1 | 100 | 1 | `%` | `SLIDER` |
|
||||
| `max_brightness` | `_max_brightness` | 1 | 100 | 1 | `%` | `SLIDER` |
|
||||
| `min_color_temp` | `_min_color_temp` | 1000 | 10000 | 100 | `K` | `SLIDER` |
|
||||
| `max_color_temp` | `_max_color_temp` | 1000 | 10000 | 100 | `K` | `SLIDER` |
|
||||
|
||||
The full unique_id SHALL be `<entry.entry_id>_<suffix>`. The device record SHALL be the same `(DOMAIN, entry.entry_id)` identifier used by the profile's switches.
|
||||
|
||||
#### Scenario: A new config entry produces four number entities
|
||||
|
||||
- **WHEN** the user creates a new Adaptive Lighting config entry
|
||||
- **AND** `async_setup_entry` completes
|
||||
- **THEN** the entity registry SHALL contain four `number` entities owned by this entry
|
||||
- **AND** their unique_ids SHALL end with `_min_brightness`, `_max_brightness`, `_min_color_temp`, `_max_color_temp` respectively
|
||||
- **AND** all four entities SHALL be attached to the same device as the profile's switches
|
||||
|
||||
#### Scenario: Number entity bounds match the options-flow selector bounds
|
||||
|
||||
- **WHEN** any of the four range number entities are inspected
|
||||
- **THEN** the brightness entities SHALL declare `native_min_value=1`, `native_max_value=100`, `native_step=1`, `native_unit_of_measurement="%"`, and `mode=NumberMode.SLIDER`
|
||||
- **AND** the color-temperature entities SHALL declare `native_min_value=1000`, `native_max_value=10000`, `native_step=100`, `native_unit_of_measurement="K"`, and `mode=NumberMode.SLIDER`
|
||||
|
||||
### Requirement: Entities persist their value across Home Assistant restarts
|
||||
|
||||
Each of the four range entities SHALL extend `homeassistant.components.number.RestoreNumber` so that its last known value is preserved across Home Assistant restarts without an explicit `Store` helper. On `async_added_to_hass`, each entity SHALL prefer in order: (1) the value present in `entry.options[CONF_*]` if that value is newer than the restored value (which is the case immediately after an options-flow save), (2) the restored value from `RestoreNumber.async_get_last_number_data()`, (3) the value present in `entry.options[CONF_*]` as the first-creation fallback.
|
||||
|
||||
#### Scenario: Slider value survives an HA restart
|
||||
|
||||
- **GIVEN** the user has moved `number.adaptive_lighting_<name>_min_brightness` to 30 via the dashboard
|
||||
- **WHEN** Home Assistant is restarted
|
||||
- **AND** the integration re-runs `async_setup_entry`
|
||||
- **THEN** the entity's state SHALL be 30 once it finishes loading
|
||||
|
||||
#### Scenario: Options-flow save value wins over restored value
|
||||
|
||||
- **GIVEN** the user has previously moved the slider to 30 (restored state)
|
||||
- **WHEN** the user opens the options flow, sets `min_brightness` to 50, and saves
|
||||
- **AND** the integration reloads
|
||||
- **THEN** the entity's state SHALL be 50, not 30
|
||||
|
||||
### Requirement: Curve math reads runtime ranges from the number entities
|
||||
|
||||
The brightness and color-temperature curve evaluation SHALL read its four bound values (`min_brightness`, `max_brightness`, `min_color_temp`, `max_color_temp`) by calling `hass.states.get(<entity_id>).state` for the corresponding number entity, casting to `int`, on every curve evaluation. The curve evaluation SHALL NOT read these four values from `entry.options` during normal operation.
|
||||
|
||||
When `hass.states.get(<entity_id>)` returns `None` or the state is `unavailable` or `unknown`, the curve evaluation SHALL fall back to the value in `entry.options[CONF_*]` and SHALL log the fallback at `DEBUG` level.
|
||||
|
||||
#### Scenario: Slider change takes effect on next curve tick
|
||||
|
||||
- **GIVEN** the integration is running with `number.adaptive_lighting_<name>_max_brightness` at 100
|
||||
- **WHEN** the user moves the slider to 70
|
||||
- **AND** the next curve evaluation tick fires
|
||||
- **THEN** the brightness curve SHALL be computed with `value_max = 70`
|
||||
|
||||
#### Scenario: Entity-unavailable fallback uses entry.options
|
||||
|
||||
- **GIVEN** the four number entities are not yet available (e.g., during early setup race)
|
||||
- **WHEN** the curve evaluation runs
|
||||
- **THEN** the curve SHALL be computed using the values from `entry.options[CONF_*]`
|
||||
- **AND** a `DEBUG` log entry SHALL be emitted naming the missing entity
|
||||
|
||||
### Requirement: Slider changes do not trigger an integration reload
|
||||
|
||||
Moving a slider on any of the four range entities SHALL NOT call `hass.config_entries.async_update_entry` for the owning config entry. The entity's new value SHALL take effect on the next curve evaluation tick without any reload of the integration, the device, or other entities.
|
||||
|
||||
#### Scenario: Slider drag does not reload the integration
|
||||
|
||||
- **GIVEN** the integration is loaded
|
||||
- **WHEN** the user moves `number.adaptive_lighting_<name>_min_brightness` from 10 to 20 via the dashboard
|
||||
- **THEN** `async_unload_entry` SHALL NOT be invoked
|
||||
- **AND** `async_setup_entry` SHALL NOT be invoked
|
||||
- **AND** the profile's switch entity IDs SHALL remain unchanged
|
||||
- **AND** the entity's state SHALL update to 20
|
||||
|
||||
### Requirement: Options-flow save propagates new range values to the entities
|
||||
|
||||
When the user saves the options flow with new values for any of the four range fields, the resulting integration reload SHALL cause the four entities to be recreated with the just-saved values as their initial state. Once the reload completes, each entity's state SHALL match the value submitted in the options flow.
|
||||
|
||||
#### Scenario: Saving updated ranges in the options flow updates the sliders
|
||||
|
||||
- **GIVEN** `number.adaptive_lighting_<name>_min_brightness` is currently at 30
|
||||
- **WHEN** the user opens the options flow, sets `min_brightness` to 55, and saves
|
||||
- **THEN** the integration SHALL reload
|
||||
- **AND** after the reload, the entity state SHALL be 55
|
||||
|
||||
### Requirement: Options-flow open seeds range fields from current entity state
|
||||
|
||||
When the options dialog is rendered, the default values shown for the four range fields (`min_brightness`, `max_brightness`, `min_color_temp`, `max_color_temp`) SHALL be read from the corresponding number entity's current state via `hass.states.get(<entity_id>).state` cast to `int`. The dialog SHALL NOT seed these four fields from `entry.options[CONF_*]` when entities exist and are available. If an entity is unavailable, the dialog SHALL fall back to `entry.options[CONF_*]` for that field.
|
||||
|
||||
The other ~14 fields in the options dialog SHALL continue to seed from `entry.options` as defined by the `options-flow` capability.
|
||||
|
||||
#### Scenario: Open options after live tuning shows live values
|
||||
|
||||
- **GIVEN** the user has moved `number.adaptive_lighting_<name>_max_brightness` from 100 (default) to 80 via the dashboard
|
||||
- **WHEN** the user opens the options flow
|
||||
- **THEN** the `max_brightness` field in the Daytime curve section SHALL show 80 as its default
|
||||
- **AND** the other fields in the dialog SHALL show their `entry.options` values
|
||||
|
||||
### Requirement: All AL entities use HA's `has_entity_name` composition
|
||||
|
||||
Every entity created by this integration — the three switches (`AdaptiveSwitch`, `AdaptBrightnessSwitch`, `AdaptColorSwitch`) and the four range number entities — SHALL set `_attr_has_entity_name = True` and SHALL register under a device whose `name` matches the profile's display name (`entry.title`). The per-entity `_attr_name` SHALL carry only the entity's role, not the integration name or the profile name. The master switch (`AdaptiveSwitch`) SHALL set `_attr_name = None` so HA renders its friendly name as the device name alone.
|
||||
|
||||
The resulting friendly names SHALL follow this table for a profile named `Dining MVP`:
|
||||
|
||||
| Entity | `_attr_name` | Friendly name |
|
||||
|---|---|---|
|
||||
| Master switch | `None` | `Dining MVP` |
|
||||
| Adapt-brightness switch | `"Brightness"` | `Dining MVP Brightness` |
|
||||
| Adapt-color switch | `"Color"` | `Dining MVP Color` |
|
||||
| Min brightness number | `"Min brightness"` | `Dining MVP Min brightness` |
|
||||
| Max brightness number | `"Max brightness"` | `Dining MVP Max brightness` |
|
||||
| Min color temp number | `"Min color temp"` | `Dining MVP Min color temp` |
|
||||
| Max color temp number | `"Max color temp"` | `Dining MVP Max color temp` |
|
||||
|
||||
Existing `unique_id`s SHALL remain unchanged; the entity registry SHALL preserve existing `entity_id`s for any deployed install.
|
||||
|
||||
#### Scenario: Friendly names compose from device name + entity role
|
||||
|
||||
- **GIVEN** an AL profile is configured with display name "Dining MVP"
|
||||
- **WHEN** the integration is loaded
|
||||
- **THEN** the master switch's friendly name SHALL be exactly "Dining MVP"
|
||||
- **AND** the adapt-brightness switch's friendly name SHALL be exactly "Dining MVP Brightness"
|
||||
- **AND** the adapt-color switch's friendly name SHALL be exactly "Dining MVP Color"
|
||||
- **AND** the four range number entities' friendly names SHALL be "Dining MVP Min brightness", "Dining MVP Max brightness", "Dining MVP Min color temp", "Dining MVP Max color temp"
|
||||
|
||||
#### Scenario: Existing entity_ids survive the rename
|
||||
|
||||
- **GIVEN** an entity registry contains a pre-existing `switch.adaptive_lighting_adapt_brightness_dining_mvp_lights` owned by this integration
|
||||
- **WHEN** the integration is upgraded to a version that ships this `has_entity_name` change
|
||||
- **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
|
||||
87
openspec/changes/add-runtime-range-controls/tasks.md
Normal file
87
openspec/changes/add-runtime-range-controls/tasks.md
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
<!--
|
||||
Annotations:
|
||||
R1–R7 = ADDED Requirements in specs/runtime-range-controls/spec.md
|
||||
R7 = "All AL entities use has_entity_name composition"
|
||||
MR4 = MODIFIED Requirement in specs/options-flow/spec.md (curve reads bounds from entities)
|
||||
D1–D11 = Decisions in design.md (D11 = entity-naming hygiene)
|
||||
polish = Quality/UX tasks captured during design review, not spec-driven
|
||||
|
||||
Build order: group 1 is platform foundation. Group 2 implements the entity class. Groups 3-5 wire reads, writes, and seeding. Group 6 retrofits has_entity_name on existing switches. Group 7 is tests, group 8 docs.
|
||||
-->
|
||||
|
||||
## 1. Platform foundation — `number.py` skeleton and `const.py` constants
|
||||
|
||||
- [x] 1.1 Add `Platform.NUMBER` to the `PLATFORMS` list in `__init__.py` so HA forwards `async_setup_entry` to the new platform. [R1]
|
||||
- [x] 1.2 In `const.py`, add a mapping `RANGE_ENTITIES` (or four explicit constants) covering the four entities: `unique_id` suffix, friendly-name slug, `native_min`, `native_max`, `step`, `unit`, `icon`. One source for both platform setup and tests. [R1, D5, D6]
|
||||
- [x] 1.3 Create `custom_components/adaptive_lighting/number.py` with an `async_setup_entry(hass, config_entry, async_add_entities)` that instantiates four entities (one per row in `RANGE_ENTITIES`) and calls `async_add_entities(entities)`. [R1]
|
||||
- [x] 1.4 Update the existing `device_info` block (or shared helper) so the new entities attach to the same `(DOMAIN, entry.entry_id)` device as the three switches. [R1]
|
||||
|
||||
## 2. Entity class — `RestoreNumber` subclass with seed logic
|
||||
|
||||
- [x] 2.1 Define `AdaptiveRangeNumber(RestoreNumber)` in `number.py` with `_attr_has_entity_name = True`, `_attr_mode = NumberMode.SLIDER`, and `_attr_should_poll = False`. Constructor takes `(entry, field_key, native_min, native_max, step, unit, icon)`. [R1, R7, D6, D11]
|
||||
- [x] 2.2 Implement `unique_id` property as `f"{entry.entry_id}_{field_key}"`. [R1, D5]
|
||||
- [x] 2.3 Set `_attr_name` on each instance to the role label per the D11 table: "Min brightness", "Max brightness", "Min color temp", "Max color temp". The device's name carries the profile context — HA composes the full friendly name automatically. [R7, D11]
|
||||
- [x] 2.4 Implement `async_added_to_hass` with the three-tier seed precedence: (a) prefer `entry.options[CONF_*]` if its value is newer than the restored state (compare via `RestoreNumber.async_get_last_number_data().native_value` against options), (b) restored state, (c) `entry.options[CONF_*]` as the first-creation fallback. [R2, D2, D3]
|
||||
- [x] 2.5 Implement `async_set_native_value(value)` to set `_attr_native_value`, call `async_write_ha_state()`, and return. The method SHALL NOT call `hass.config_entries.async_update_entry` (no write-through to options). [R4, D1, D2]
|
||||
|
||||
## 3. Curve math — read bounds from entities, fallback to options
|
||||
|
||||
- [x] 3.1 Add a helper `_get_runtime_range(hass, entry, field_key)` (in `switch.py` or a shared helper module) that does `state = hass.states.get(f"number.adaptive_lighting_{slugify(entry.title)}_{field_key}")` → returns `int(state.state)` if state is set and not unavailable, else `int(entry.options[CONF_*])`, logging the fallback at DEBUG. [R3, D8, D9]
|
||||
- [x] 3.2 In `AdaptiveSwitch._get_settings()` (or wherever `SunLightSettings` is constructed), replace the four `entry.options[CONF_*]` reads for `min_brightness`, `max_brightness`, `min_color_temp`, `max_color_temp` with calls to `_get_runtime_range(...)`. [R3, MR4, D1, D8]
|
||||
- [x] 3.3 Verify no other reads of these four CONF keys remain in the curve evaluation path (`brightness_pct`, `color_temp_kelvin`, `brightness_and_color`, `sun_position`). Other reads (e.g., the options-flow schema seeding) are intentionally untouched here. [R3, MR4]
|
||||
|
||||
## 4. Options flow — seed the 4 range fields from entity state
|
||||
|
||||
- [x] 4.1 Modify `_build_options_schema(current, ...)` in `config_flow.py` to accept the four range entity states (or read them inline via `hass.states.get(...)`). When an entity state is available, use it as the field default; otherwise fall back to the matching `entry.options[CONF_*]` value. [R6, D4]
|
||||
- [x] 4.2 In `async_step_init`, compute the four `current_*` values once (using the helper from 4.1), then pass them into the schema builder. [R6, D4]
|
||||
- [x] 4.3 Confirm the other ~14 fields still seed from `entry.options` unchanged. [R6]
|
||||
|
||||
## 5. Wire-up — `async_setup_entry` sequencing
|
||||
|
||||
- [x] 5.1 Confirm the order `async_setup_entry` calls `async_forward_entry_setups(entry, PLATFORMS)` is unchanged — both `switch` and `number` platforms set up in parallel. [R1]
|
||||
- [x] 5.2 Verify the entity-unavailable fallback (Decision 9) actually fires during the brief race window where the switch starts evaluating before the number platform has registered all four entities. This is the natural state during a fresh `async_setup_entry`; the curve math should not crash. [R3, D9]
|
||||
|
||||
## 6. Entity-naming hygiene — retrofit the three existing switches
|
||||
|
||||
- [x] 6.1 In `switch.py`, set `_attr_has_entity_name = True` on `AdaptiveSwitch`, `AdaptColorSwitch`, and `AdaptBrightnessSwitch`. [R7, D11]
|
||||
- [x] 6.2 Set `_attr_name` per the D11 table: `AdaptiveSwitch._attr_name = None` (master takes device name), `AdaptBrightnessSwitch._attr_name = "Brightness"`, `AdaptColorSwitch._attr_name = "Color"`. Delete any code that hand-composes "Adaptive Lighting …" into the friendly name. [R7, D11]
|
||||
- [x] 6.3 Confirm the shared `device_info` block sets `name = entry.title` (or `entry.data[CONF_NAME]`, whichever is the user-facing string). This is the anchor for the composed friendly names. [R7, D11]
|
||||
- [x] 6.4 Verify `unique_id`s are NOT changed by this group — only `_attr_name` and `_attr_has_entity_name`. The entity registry must keep existing entity_ids stable. [R7, D11]
|
||||
- [ ] 6.5 Manual check on live HA after deploy: pre-existing entity_ids unchanged (no duplicates, no broken automations), friendly names now read as "Dining MVP Brightness" / "Dining MVP Color" instead of "Adaptive Lighting Adapt Brightness dining_mvp_lights". [R7, D11]
|
||||
|
||||
## 7. Tests — `tests/test_number_platform.py`
|
||||
|
||||
- [x] 7.1 New test file `tests/test_number_platform.py` with autouse PHACC fixture from `conftest.py`. [R1]
|
||||
- [x] 7.2 Add test: creating a new config entry registers exactly four `number` entities owned by the entry. Assert the suffixes are `_min_brightness`, `_max_brightness`, `_min_color_temp`, `_max_color_temp`. [R1]
|
||||
- [x] 7.3 Add test: each of the four entities is attached to the same device as the profile's switches. [R1]
|
||||
- [x] 7.4 Add test: brightness entities expose `native_min_value=1`, `native_max_value=100`, `native_step=1`, `native_unit_of_measurement="%"`, `mode=NumberMode.SLIDER`. Color-temp entities expose `1000`/`10000`/`100`/`"K"`/`SLIDER`. [R1, D6]
|
||||
- [x] 7.5 Add test: `async_set_native_value` updates `state` but does not call `hass.config_entries.async_update_entry` (use a mock spy). [R4, D2]
|
||||
- [x] 7.6 Add test: slider change does not invoke `async_unload_entry` / `async_setup_entry`. [R4]
|
||||
- [x] 7.7 Add test: simulating an HA restart — pre-seed `RestoreNumber` state to 30 for `min_brightness`, set up the entry, assert entity state is 30 (not the default 5 from `entry.options`). [R2]
|
||||
- [x] 7.8 Add test: options-flow save with new range values triggers a reload, and after the reload the entity state reflects the just-saved values (not the previously-restored values). [R5, D3]
|
||||
- [x] 7.9 Add test: opening the options flow seeds the four range fields from `hass.states.get(<entity_id>).state`, not from `entry.options`. Set the entity to 80, leave options at 100, assert the flow's schema default is 80. [R6, D4]
|
||||
- [x] 7.10 Add test: opening the options flow when an entity is unavailable falls back to `entry.options[CONF_*]`. [R6, D9]
|
||||
- [x] 7.11 Add test: curve math reads runtime values — set `number.adaptive_lighting_<name>_max_brightness` to 70, set `entry.options[CONF_MAX_BRIGHTNESS]` to 100, run a curve evaluation at "peak day," assert the returned brightness is 70. [R3, MR4]
|
||||
- [x] 7.12 Add test: curve math fallback — make the entity `unavailable`, set `entry.options[CONF_MAX_BRIGHTNESS]` to 90, evaluate at peak day, assert brightness is 90 and a DEBUG log line names the missing entity. [R3, D9]
|
||||
|
||||
## 8. Translations and docs
|
||||
|
||||
- [x] 8.1 Add `entity.number.min_brightness.name`, `_max_brightness`, `_min_color_temp`, `_max_color_temp` keys to `strings.json` with plain-language labels ("Min brightness," "Max brightness," etc.). [R1, polish]
|
||||
- [x] 8.2 Mirror the additions in `translations/en.json`. Other locales out of scope (covered by `complete-i18n-translations` follow-up). [R1, polish]
|
||||
- [x] 8.3 Add an `entity.number.<key>.unit_of_measurement` mapping if HA's frontend requires it for slider display (verify against current HA — likely auto-derived from `native_unit_of_measurement`). [R1]
|
||||
- [x] 8.4 Add a short section to `README.md` under "What's new in 2.1" naming the four entities, explaining the slider-vs-options-flow split ("sliders tune live; options-flow sets defaults; saving the dialog resets the sliders to the saved values"), and showing a one-line Lovelace YAML snippet (`type: entities` with the four range entities). [R5, R6, D3, polish]
|
||||
- [x] 8.5 Append a `2.1.0-cdit.1` entry to `CHANGELOG.md` listing: 4 new entities per profile, RestoreNumber persistence, curve math now reads from entities, options-flow open seeds from entities, no reload on slider change. [polish]
|
||||
|
||||
## 9. Manual verification on live HA
|
||||
|
||||
- [ ] 9.1 Deploy to `homeassistant.onca-blenny.ts.net` via HACS. Verify the four `number.adaptive_lighting_*` entities appear under each of the 6 profiles' devices. [R1]
|
||||
- [ ] 9.2 Move a slider on one profile via the dashboard. Verify (a) no integration reload occurs (check Integration page → no "reloading" banner; entity IDs unchanged), (b) the next curve tick uses the new value (watch the master switch's `brightness_pct` attribute over ~90 s). [R3, R4]
|
||||
- [ ] 9.3 Open the options flow on the same profile. Verify the four range fields show the just-moved slider values, not the original setup defaults. [R6]
|
||||
- [ ] 9.4 Save the options flow with different values. Verify the sliders snap to the new values after reload. [R5, D3]
|
||||
- [ ] 9.5 Restart HA. Verify the slider values persist (RestoreNumber works). [R2]
|
||||
|
||||
## 10. Validation gate
|
||||
|
||||
- [x] 10.1 `openspec validate add-runtime-range-controls --strict` returns green. [polish]
|
||||
- [x] 10.2 `uv run pytest tests/test_number_platform.py` passes. Existing tests stay green (`uv run pytest`). [polish]
|
||||
- [x] 10.3 `./scripts/lint` clean. [polish]
|
||||
|
|
@ -196,4 +196,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
|
||||
|
||||
|
|
|
|||
|
|
@ -13,8 +13,9 @@ import pytest
|
|||
@pytest.fixture(autouse=True)
|
||||
def auto_enable_custom_integrations(enable_custom_integrations):
|
||||
"""PHACC requires this fixture to be active for HA to find the
|
||||
integration under custom_components/."""
|
||||
yield
|
||||
integration under custom_components/.
|
||||
"""
|
||||
return
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
|
|
|||
|
|
@ -41,23 +41,23 @@ class TestBrightnessCurve:
|
|||
"""Spec R4: piecewise tanh ramp around the two sun events."""
|
||||
|
||||
def test_min_brightness_more_than_half_width_before_sunrise(self, settings):
|
||||
"""t = sunrise − 2h → still deep night, brightness at minimum."""
|
||||
"""T = sunrise − 2h → still deep night, brightness at minimum."""
|
||||
t = T_SUNRISE - timedelta(hours=2)
|
||||
assert settings.brightness_pct(t, T_SUNRISE, T_SUNSET) == 5
|
||||
|
||||
def test_min_brightness_exactly_at_clamp_boundary(self, settings):
|
||||
"""t = sunrise − half_width → exactly at the boundary, still min."""
|
||||
"""T = sunrise − half_width → exactly at the boundary, still min."""
|
||||
t = T_SUNRISE - timedelta(seconds=HALF_WIDTH)
|
||||
assert settings.brightness_pct(t, T_SUNRISE, T_SUNSET) == 5
|
||||
|
||||
def test_midpoint_at_sunrise_event(self, settings):
|
||||
"""t = sunrise → exactly the midpoint of min and max by tanh symmetry."""
|
||||
"""T = sunrise → exactly the midpoint of min and max by tanh symmetry."""
|
||||
b = settings.brightness_pct(T_SUNRISE, T_SUNRISE, T_SUNSET)
|
||||
expected_mid = (5 + 100) / 2
|
||||
assert abs(b - expected_mid) < 0.5
|
||||
|
||||
def test_max_brightness_30min_after_sunrise(self, settings):
|
||||
"""t = sunrise + half_width → fully ramped, brightness at max."""
|
||||
"""T = sunrise + half_width → fully ramped, brightness at max."""
|
||||
t = T_SUNRISE + timedelta(seconds=HALF_WIDTH)
|
||||
assert settings.brightness_pct(t, T_SUNRISE, T_SUNSET) == 100
|
||||
|
||||
|
|
@ -67,12 +67,12 @@ class TestBrightnessCurve:
|
|||
assert settings.brightness_pct(t, T_SUNRISE, T_SUNSET) == 100
|
||||
|
||||
def test_min_brightness_long_after_sunset(self, settings):
|
||||
"""t = sunset + 2h → back to minimum."""
|
||||
"""T = sunset + 2h → back to minimum."""
|
||||
t = T_SUNSET + timedelta(hours=2)
|
||||
assert settings.brightness_pct(t, T_SUNRISE, T_SUNSET) == 5
|
||||
|
||||
def test_midpoint_at_sunset_event(self, settings):
|
||||
"""t = sunset → exact midpoint going down."""
|
||||
"""T = sunset → exact midpoint going down."""
|
||||
b = settings.brightness_pct(T_SUNSET, T_SUNRISE, T_SUNSET)
|
||||
expected_mid = (5 + 100) / 2
|
||||
assert abs(b - expected_mid) < 0.5
|
||||
|
|
@ -92,7 +92,8 @@ class TestColorTempCurve:
|
|||
|
||||
def test_same_curve_shape_as_brightness(self, settings):
|
||||
"""At any ramp-window point, the fraction of the curve should match
|
||||
between brightness (5-100) and color temp (2200-5500)."""
|
||||
between brightness (5-100) and color temp (2200-5500).
|
||||
"""
|
||||
# Pick a point inside the sunrise ramp window.
|
||||
t = T_SUNRISE + timedelta(minutes=10)
|
||||
b = settings.brightness_pct(t, T_SUNRISE, T_SUNSET)
|
||||
|
|
|
|||
|
|
@ -105,7 +105,8 @@ def test_options_schema_has_all_six_sections_in_order() -> None:
|
|||
|
||||
def test_each_section_contains_only_its_specified_fields() -> None:
|
||||
"""R1 scenario 2: every field appears in exactly one section, matching
|
||||
the layout table."""
|
||||
the layout table.
|
||||
"""
|
||||
schema = _build_options_schema({}, show_send_split_delay=True)
|
||||
for marker in schema.schema: # type: ignore[attr-defined]
|
||||
section_id = marker.schema if hasattr(marker, "schema") else marker
|
||||
|
|
@ -114,9 +115,9 @@ def test_each_section_contains_only_its_specified_fields() -> None:
|
|||
# Advanced gains send_split_delay when its driver is true.
|
||||
if section_id == SECTION_ADVANCED:
|
||||
expected.add(CONF_SEND_SPLIT_DELAY)
|
||||
assert inner_fields == expected, (
|
||||
f"section {section_id}: expected {expected}, got {inner_fields}"
|
||||
)
|
||||
assert (
|
||||
inner_fields == expected
|
||||
), f"section {section_id}: expected {expected}, got {inner_fields}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -167,13 +168,22 @@ def test_default_sunrise_and_sunset_entities() -> None:
|
|||
for k in sun_inner
|
||||
if hasattr(k, "default")
|
||||
}
|
||||
assert defaults[CONF_SUNRISE_ENTITY] == DEFAULT_SUNRISE_ENTITY == "sensor.sun_next_rising"
|
||||
assert defaults[CONF_SUNSET_ENTITY] == DEFAULT_SUNSET_ENTITY == "sensor.sun_next_setting"
|
||||
assert (
|
||||
defaults[CONF_SUNRISE_ENTITY]
|
||||
== DEFAULT_SUNRISE_ENTITY
|
||||
== "sensor.sun_next_rising"
|
||||
)
|
||||
assert (
|
||||
defaults[CONF_SUNSET_ENTITY]
|
||||
== DEFAULT_SUNSET_ENTITY
|
||||
== "sensor.sun_next_setting"
|
||||
)
|
||||
|
||||
|
||||
def test_sun_entity_selectors_are_strict_timestamp_sensors() -> None:
|
||||
"""R3 + D14: both sun-event entity selectors filter by domain=sensor and
|
||||
device_class=timestamp."""
|
||||
device_class=timestamp.
|
||||
"""
|
||||
schema = _build_options_schema({}, show_send_split_delay=False)
|
||||
sun_marker = next(
|
||||
m
|
||||
|
|
@ -258,9 +268,10 @@ def test_booleans_use_boolean_selector() -> None:
|
|||
for k, v in section.schema.schema.items():
|
||||
field_name = k.schema if hasattr(k, "schema") else k
|
||||
if field_name in boolean_fields:
|
||||
assert isinstance(v, BooleanSelector), (
|
||||
f"{field_name} is {type(v).__name__}, expected BooleanSelector"
|
||||
)
|
||||
assert isinstance(
|
||||
v,
|
||||
BooleanSelector,
|
||||
), f"{field_name} is {type(v).__name__}, expected BooleanSelector"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@ from custom_components.adaptive_lighting.const import (
|
|||
|
||||
async def test_successful_setup_on_current_version(hass) -> None:
|
||||
"""R8: an entry created on the current major succeeds and runs no
|
||||
tombstone log line."""
|
||||
tombstone log line.
|
||||
"""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={CONF_NAME: DEFAULT_NAME},
|
||||
|
|
@ -42,7 +43,8 @@ async def test_successful_setup_on_current_version(hass) -> None:
|
|||
async def test_stale_version_raises_config_entry_error(hass, caplog) -> None:
|
||||
"""R8: an entry created on the previous major fails with our
|
||||
ConfigEntryError, leaving the entry in the MIGRATION_ERROR state.
|
||||
HA routes version mismatches through `async_migrate_entry`."""
|
||||
HA routes version mismatches through `async_migrate_entry`.
|
||||
"""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={CONF_NAME: DEFAULT_NAME},
|
||||
|
|
@ -79,7 +81,8 @@ async def test_unload_entry(hass) -> None:
|
|||
|
||||
async def test_orphan_sleep_entity_is_removed_on_setup(hass, caplog) -> None:
|
||||
"""R9: a leftover sleep_mode switch owned by this entry is removed and
|
||||
one INFO log line is emitted."""
|
||||
one INFO log line is emitted.
|
||||
"""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={CONF_NAME: DEFAULT_NAME},
|
||||
|
|
@ -99,9 +102,9 @@ async def test_orphan_sleep_entity_is_removed_on_setup(hass, caplog) -> None:
|
|||
with caplog.at_level(logging.INFO):
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
|
||||
assert registry.async_get(orphan.entity_id) is None, (
|
||||
"Orphan sleep entity should have been removed."
|
||||
)
|
||||
assert (
|
||||
registry.async_get(orphan.entity_id) is None
|
||||
), "Orphan sleep entity should have been removed."
|
||||
# One INFO log line naming the entity ID.
|
||||
assert any(
|
||||
orphan.entity_id in record.message
|
||||
|
|
@ -132,7 +135,8 @@ async def test_tombstone_helper_is_idempotent(hass, caplog) -> None:
|
|||
|
||||
async def test_tombstone_skips_foreign_entities(hass, caplog) -> None:
|
||||
"""R9 + D12: an entity matching the name pattern but owned by another
|
||||
config entry is not removed."""
|
||||
config entry is not removed.
|
||||
"""
|
||||
our_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={CONF_NAME: DEFAULT_NAME},
|
||||
|
|
@ -158,6 +162,6 @@ async def test_tombstone_skips_foreign_entities(hass, caplog) -> None:
|
|||
|
||||
assert await hass.config_entries.async_setup(our_entry.entry_id)
|
||||
|
||||
assert registry.async_get(foreign_entity.entity_id) is not None, (
|
||||
"Foreign-owned entity matching name pattern must not be removed."
|
||||
)
|
||||
assert (
|
||||
registry.async_get(foreign_entity.entity_id) is not None
|
||||
), "Foreign-owned entity matching name pattern must not be removed."
|
||||
|
|
|
|||
358
tests/test_number_platform.py
Normal file
358
tests/test_number_platform.py
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
"""Tests for the runtime range number platform (CDiT fork).
|
||||
|
||||
Covers the `runtime-range-controls` capability: entity surface, restore,
|
||||
no-reload-on-slider write, and the curve-math read path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from homeassistant.components.number import NumberMode
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import State
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from pytest_homeassistant_custom_component.common import (
|
||||
MockConfigEntry,
|
||||
mock_restore_cache_with_extra_data,
|
||||
)
|
||||
|
||||
from custom_components.adaptive_lighting.const import (
|
||||
CONF_MAX_BRIGHTNESS,
|
||||
CONF_MIN_BRIGHTNESS,
|
||||
CONF_MIN_COLOR_TEMP,
|
||||
CONFIG_ENTRY_VERSION,
|
||||
DOMAIN,
|
||||
RANGE_ENTITIES,
|
||||
)
|
||||
|
||||
PROFILE_NAME = "test_profile"
|
||||
FIELD_KEYS = ("min_brightness", "max_brightness", "min_color_temp", "max_color_temp")
|
||||
|
||||
|
||||
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, field_key: str) -> str:
|
||||
return f"{entry.entry_id}_{field_key}"
|
||||
|
||||
|
||||
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),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7.2 — Four entities per entry, expected suffixes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_four_range_entities_registered(hass) -> None:
|
||||
entry = await _setup_entry(hass)
|
||||
registry = er.async_get(hass)
|
||||
for field_key in FIELD_KEYS:
|
||||
eid = registry.async_get_entity_id(
|
||||
"number", DOMAIN, _unique_id(entry, field_key),
|
||||
)
|
||||
assert eid is not None, f"Missing number entity for {field_key}"
|
||||
assert eid.startswith("number.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7.3 — Same device as the profile's switches
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_number_entities_share_switch_device(hass) -> None:
|
||||
entry = await _setup_entry(hass)
|
||||
ent_reg = er.async_get(hass)
|
||||
dev_reg = dr.async_get(hass)
|
||||
|
||||
# The master switch uses the profile name as unique_id.
|
||||
master_eid = ent_reg.async_get_entity_id("switch", DOMAIN, PROFILE_NAME)
|
||||
assert master_eid is not None
|
||||
master_dev_id = ent_reg.async_get(master_eid).device_id
|
||||
assert master_dev_id
|
||||
|
||||
for field_key in FIELD_KEYS:
|
||||
eid = _resolve_entity_id(hass, entry, field_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 # D11: profile name, no integration prefix
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7.4 — Selector attributes match D6 table
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field_key,expected",
|
||||
[
|
||||
("min_brightness", {"min": 1.0, "max": 100.0, "step": 1, "unit": "%"}),
|
||||
("max_brightness", {"min": 1.0, "max": 100.0, "step": 1, "unit": "%"}),
|
||||
("min_color_temp", {"min": 1000.0, "max": 10000.0, "step": 100, "unit": "K"}),
|
||||
("max_color_temp", {"min": 1000.0, "max": 10000.0, "step": 100, "unit": "K"}),
|
||||
],
|
||||
)
|
||||
async def test_number_entity_attributes(hass, field_key, expected) -> None:
|
||||
entry = await _setup_entry(hass)
|
||||
eid = _resolve_entity_id(hass, entry, field_key)
|
||||
state = hass.states.get(eid)
|
||||
assert state is not None
|
||||
attrs = state.attributes
|
||||
assert attrs["min"] == expected["min"]
|
||||
assert attrs["max"] == expected["max"]
|
||||
assert attrs["step"] == expected["step"]
|
||||
assert attrs["unit_of_measurement"] == expected["unit"]
|
||||
assert attrs["mode"] == NumberMode.SLIDER
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7.5 — async_set_native_value does NOT write to entry.options
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_slider_set_does_not_update_entry_options(hass) -> None:
|
||||
entry = await _setup_entry(hass)
|
||||
eid = _resolve_entity_id(hass, entry, "min_brightness")
|
||||
snapshot_options = dict(entry.options)
|
||||
await hass.services.async_call(
|
||||
"number",
|
||||
"set_value",
|
||||
{"entity_id": eid, "value": 42},
|
||||
blocking=True,
|
||||
)
|
||||
assert dict(entry.options) == snapshot_options
|
||||
assert float(hass.states.get(eid).state) == 42.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7.6 — slider change does not invoke unload/setup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_slider_change_no_reload(hass) -> None:
|
||||
entry = await _setup_entry(hass)
|
||||
eid = _resolve_entity_id(hass, entry, "max_brightness")
|
||||
with (
|
||||
patch(
|
||||
"custom_components.adaptive_lighting.async_setup_entry",
|
||||
) as setup_spy,
|
||||
patch(
|
||||
"custom_components.adaptive_lighting.async_unload_entry",
|
||||
) as unload_spy,
|
||||
):
|
||||
await hass.services.async_call(
|
||||
"number",
|
||||
"set_value",
|
||||
{"entity_id": eid, "value": 70},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert setup_spy.call_count == 0
|
||||
assert unload_spy.call_count == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7.7 — RestoreNumber: restored state wins on a plain restart
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_restore_state_survives_restart(hass) -> None:
|
||||
"""The entity restores its last value when entry was NOT modified since."""
|
||||
# Build an entry whose modified_at is in the deep past so the restored
|
||||
# state (which we prime fresh) is treated as newer.
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={CONF_NAME: PROFILE_NAME},
|
||||
options={CONF_MIN_BRIGHTNESS: 5},
|
||||
version=CONFIG_ENTRY_VERSION,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
# Force modified_at into the past so the restored state's last_updated
|
||||
# (which will be "now" when the cache is primed) is treated as fresher.
|
||||
object.__setattr__(
|
||||
entry,
|
||||
"modified_at",
|
||||
datetime.datetime(2020, 1, 1, tzinfo=datetime.timezone.utc),
|
||||
)
|
||||
|
||||
# Prime the restore cache with a saved native_value of 30.
|
||||
fake_state = State("number.test_profile_min_brightness", "30")
|
||||
fake_extra = {
|
||||
"native_value": 30.0,
|
||||
"native_unit_of_measurement": "%",
|
||||
}
|
||||
mock_restore_cache_with_extra_data(hass, [(fake_state, fake_extra)])
|
||||
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
eid = _resolve_entity_id(hass, entry, "min_brightness")
|
||||
state = hass.states.get(eid)
|
||||
assert state is not None
|
||||
assert float(state.state) == 30.0 # restored, not the options default of 5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7.8 — Options-flow save with new range value propagates to the entity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_options_save_overrides_slider(hass) -> None:
|
||||
"""A just-saved options value beats the prior slider position after reload."""
|
||||
entry = await _setup_entry(
|
||||
hass,
|
||||
options={
|
||||
CONF_MAX_BRIGHTNESS: 100,
|
||||
CONF_MIN_BRIGHTNESS: 5,
|
||||
"min_color_temp": 2200,
|
||||
"max_color_temp": 5500,
|
||||
"lights": [],
|
||||
"sunrise_entity": "sensor.sun_next_rising",
|
||||
"sunset_entity": "sensor.sun_next_setting",
|
||||
},
|
||||
)
|
||||
eid = _resolve_entity_id(hass, entry, "min_brightness")
|
||||
# Move the slider to 30
|
||||
await hass.services.async_call(
|
||||
"number",
|
||||
"set_value",
|
||||
{"entity_id": eid, "value": 30},
|
||||
blocking=True,
|
||||
)
|
||||
assert float(hass.states.get(eid).state) == 30.0
|
||||
|
||||
# Save new options that set min_brightness to 55.
|
||||
new_options = dict(entry.options)
|
||||
new_options[CONF_MIN_BRIGHTNESS] = 55
|
||||
hass.config_entries.async_update_entry(entry, options=new_options)
|
||||
await hass.async_block_till_done()
|
||||
# Reload so the entity is recreated; OptionsFlowWithReload would do
|
||||
# this implicitly on a real options-flow save.
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
eid_after = _resolve_entity_id(hass, entry, "min_brightness")
|
||||
state_after = hass.states.get(eid_after)
|
||||
assert state_after is not None
|
||||
assert float(state_after.state) == 55.0 # just-saved options wins
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7.9 — Options flow open seeds the four range fields from entity state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_options_flow_seeds_from_entity_state(hass) -> None:
|
||||
entry = await _setup_entry(hass, options={CONF_MAX_BRIGHTNESS: 100})
|
||||
eid = _resolve_entity_id(hass, entry, "max_brightness")
|
||||
# Move the slider to 80
|
||||
await hass.services.async_call(
|
||||
"number",
|
||||
"set_value",
|
||||
{"entity_id": eid, "value": 80},
|
||||
blocking=True,
|
||||
)
|
||||
# Open the options flow
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
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"
|
||||
)
|
||||
inner = daytime.schema.schema
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7.10 — Options flow open falls back to entry.options when entity unavailable
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_options_flow_fallback_when_entity_unavailable(hass) -> None:
|
||||
entry = await _setup_entry(hass, options={CONF_MIN_COLOR_TEMP: 2500})
|
||||
eid = _resolve_entity_id(hass, entry, "min_color_temp")
|
||||
# Manually wipe the state so it looks unavailable
|
||||
hass.states.async_remove(eid)
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
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
|
||||
)
|
||||
assert default == 2500 # falls back to options
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7.11 — Curve math reads runtime values from the number entities
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_curve_math_reads_runtime_range(hass) -> None:
|
||||
"""When the slider differs from entry.options, the curve uses the slider."""
|
||||
entry = await _setup_entry(
|
||||
hass,
|
||||
options={CONF_MAX_BRIGHTNESS: 100, CONF_MIN_BRIGHTNESS: 5},
|
||||
)
|
||||
eid = _resolve_entity_id(hass, entry, "max_brightness")
|
||||
await hass.services.async_call(
|
||||
"number",
|
||||
"set_value",
|
||||
{"entity_id": eid, "value": 70},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
# Grab the master switch and read its live SunLightSettings via property.
|
||||
al_data = hass.data[DOMAIN][entry.entry_id]
|
||||
al_switch = al_data["switch"]
|
||||
settings = al_switch.sun_light_settings
|
||||
assert settings.max_brightness == 70 # slider, not options' 100
|
||||
assert settings.min_brightness == 5 # entity value (initialized from options)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7.12 — Curve math falls back to options when entity unavailable
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_curve_math_falls_back_on_unavailable(hass, caplog) -> None:
|
||||
entry = await _setup_entry(
|
||||
hass,
|
||||
options={CONF_MAX_BRIGHTNESS: 88, CONF_MIN_BRIGHTNESS: 5},
|
||||
)
|
||||
eid = _resolve_entity_id(hass, entry, "max_brightness")
|
||||
# Wipe the state so the read sees `None`
|
||||
hass.states.async_remove(eid)
|
||||
|
||||
al_data = hass.data[DOMAIN][entry.entry_id]
|
||||
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
|
||||
# DEBUG log mentions the missing entity
|
||||
assert "max_brightness" in caplog.text
|
||||
Loading…
Add table
Add a link
Reference in a new issue