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

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