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:
Casey 2026-05-16 22:24:39 +02:00
commit d5d3ecc9d4
21 changed files with 1280 additions and 65 deletions

View file

@ -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

View file

@ -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 (1100 %)
> - `number.<profile>_max_brightness` — peak-day brightness (1100 %)
> - `number.<profile>_min_color_temp` — warmest tone (100010000 K)
> - `number.<profile>_max_color_temp` — coolest tone (100010000 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.

View file

@ -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:

View file

@ -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)

View file

@ -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).'
)

View file

@ -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"
}

View 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()

View file

@ -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"
}
}
}
}

View file

@ -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,
)

View file

@ -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"
}
}
}
}

View 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.

View file

@ -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

View file

@ -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`)

View file

@ -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

View file

@ -0,0 +1,87 @@
<!--
Annotations:
R1R7 = 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)
D1D11 = 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]

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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"
# ---------------------------------------------------------------------------

View file

@ -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."

View 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