Implement add-runtime-ramp-width: live ramp half-width number entity

Fifth runtime number per profile (5-120 min, default 30 = the old
RAMP_HALF_WIDTH_SECONDS constant). No options-flow field — the entity is
the only knob, meant to be driven seasonally from Node-RED. Two-tier
restore (restored -> default; no options tier, which would reset the
width on every unrelated options save). The curve and the sun-event
day-anchoring read the width once per tick and share the same value, so
widened evening ramps complete past the next_* sensor flip.

8 new tests; 164 passing. Bump to 2.5.0-cdit.1.
This commit is contained in:
Casey 2026-06-07 22:30:58 +02:00
commit a602a05f22
6 changed files with 356 additions and 20 deletions

View file

@ -133,7 +133,7 @@
A Home Assistant custom component that adapts light brightness and color
temperature to a sun-driven tanh curve. Each profile owns a set of lights
and exposes three switches, four live-tunable sliders, and three graphable
and exposes three switches, five live-tunable sliders, and three graphable
sensors per profile.
## Install
@ -173,7 +173,7 @@ For a profile named `Kitchen`:
| `switch.kitchen_brightness` | Toggle brightness adaptation only |
| `switch.kitchen_color` | Toggle color-temperature adaptation only |
### Number entities (4) — live-tunable sliders
### Number entities (5) — live-tunable sliders
| Entity | Range | Step | Persist |
|---|---|---|---|
@ -181,11 +181,20 @@ For a profile named `Kitchen`:
| `number.kitchen_max_brightness` | 1100 % | 1 | RestoreNumber |
| `number.kitchen_min_color_temp` | 100010000 K | 100 | RestoreNumber |
| `number.kitchen_max_color_temp` | 100010000 K | 100 | RestoreNumber |
| `number.kitchen_ramp_half_width` | 5120 min | 1 | RestoreNumber |
Slider position is the runtime truth — the curve reads from the entity on
every tick. No integration reload on slider change. Values survive HA
restarts. The options dialog re-seeds these values from the entities on
open and overwrites them on save (explicit gesture wins).
restarts. The options dialog re-seeds the four range values from the
entities on open and overwrites them on save (explicit gesture wins).
**Ramp half-width** sets how long the curve takes to transition at each sun
event: the ramp spans the event ± this value, so the total transition is
twice the slider (default 30 min = the classic 1-hour tanh window). It has
**no options-dialog field** — the entity is the only knob, by design: it's
meant to be driven from automation (e.g., a Node-RED seasonal flow that
widens summer dusks and tightens winter ones). One value drives both the
sunrise and sunset ramps.
### Sensor entities (3) — graphable outputs

View file

@ -20,8 +20,17 @@ DOMAIN = "adaptive_lighting"
# 30 minutes is the eye-friendly circadian-transition sweet spot; long enough
# to feel gradual, short enough to stay in-window across solstice-to-equinox
# sunrise drift. Documented in design.md decision 15.
# Since add-runtime-ramp-width this is the FALLBACK: the live value comes
# from the per-profile ramp half-width number entity (default 30 min).
RAMP_HALF_WIDTH_SECONDS = 1800
# Runtime ramp half-width entity (add-runtime-ramp-width R1, R2, D2, D3).
# Deliberately NOT in VALIDATION_TUPLES: there is no options-flow field —
# the number entity is the only surface, with the constant above as the
# unavailable-entity fallback. Unit is minutes; the curve consumes seconds.
CONF_RAMP_HALF_WIDTH = "ramp_half_width"
DEFAULT_RAMP_HALF_WIDTH_MIN = 30 # minutes; == RAMP_HALF_WIDTH_SECONDS / 60
# CDiT config-entry schema version. Bumped from upstream's implicit v1.
# An entry with version < CONFIG_ENTRY_VERSION fails async_setup_entry with
# a "recreate this entry" message — see design.md decision 4.
@ -216,6 +225,22 @@ RANGE_ENTITIES: list[dict[str, Any]] = [
},
]
# Ramp half-width number-entity declaration (add-runtime-ramp-width R1, D2).
# Kept OUT of RANGE_ENTITIES: it has no `entry.options` mirror, so the
# options-seeding code paths that iterate the four range fields must not
# pick it up. One value drives BOTH the sunrise and sunset ramps (D1); the
# total transition is 2x this value. Unit is minutes; curve consumes seconds.
RAMP_WIDTH_ENTITY: dict[str, Any] = {
"field_key": CONF_RAMP_HALF_WIDTH,
"default": DEFAULT_RAMP_HALF_WIDTH_MIN,
"name": "Ramp half-width",
"native_min": 5,
"native_max": 120,
"step": 1,
"unit": "min",
"icon": "mdi:transition",
}
# Output sensor declarations (add-output-sensors / R1, D4, D7).
# Each dict drives one read-only `sensor` entity per AL profile. The `key`
# becomes both the sensor's unique-id suffix AND the cache-dict key in

View file

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

View file

@ -1,19 +1,25 @@
"""Number platform for the Adaptive Lighting integration (CDiT fork).
Each config entry exposes four live-tunable sliders that own the runtime
Each config entry exposes five 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``
- ``number.<profile>_ramp_half_width``
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
integration, and the resulting fresh range 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.
The ramp half-width entity (``add-runtime-ramp-width``) has NO
``entry.options`` mirror it is the only surface for the curve's
transition width, so its restore precedence is simply restored-value
default (30 min). Total transition duration is twice the half-width.
"""
from __future__ import annotations
@ -28,7 +34,7 @@ from homeassistant.components.number import (
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.entity import DeviceInfo
from .const import DOMAIN, RANGE_ENTITIES
from .const import DOMAIN, RAMP_WIDTH_ENTITY, RANGE_ENTITIES
if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
@ -43,8 +49,8 @@ async def async_setup_entry(
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Create the four range entities for this config entry."""
entities = [
"""Create the four range entities plus the ramp-width entity."""
entities: list[RestoreNumber] = [
AdaptiveRangeNumber(
entry=config_entry,
field_key=row["field_key"],
@ -59,6 +65,7 @@ async def async_setup_entry(
)
for row in RANGE_ENTITIES
]
entities.append(AdaptiveRampWidthNumber(entry=config_entry))
async_add_entities(entities)
@ -173,3 +180,67 @@ class AdaptiveRangeNumber(RestoreNumber):
"""
self._attr_native_value = value
self.async_write_ha_state()
class AdaptiveRampWidthNumber(RestoreNumber):
"""Live-tunable ramp half-width for the curve's two sun-event ramps.
Unlike the four range entities there is NO ``entry.options`` mirror
this entity is the only surface for the width (add-runtime-ramp-width
D2), intended to be driven seasonally from Node-RED. Restore precedence
is therefore two-tier: restored value default. It must NOT inherit
``AdaptiveRangeNumber``'s three-tier logic, which prefers the options
value after every options-flow save and would silently reset the width
to 30 on each unrelated save.
"""
_attr_has_entity_name = True
_attr_mode = NumberMode.SLIDER
_attr_should_poll = False
def __init__(self, *, entry: ConfigEntry) -> None:
"""Initialise the ramp half-width entity from its const declaration."""
self._entry = entry
self._field_key: str = RAMP_WIDTH_ENTITY["field_key"]
self._default: float = float(RAMP_WIDTH_ENTITY["default"])
self._attr_name = RAMP_WIDTH_ENTITY["name"]
self._attr_translation_key = self._field_key
self._attr_unique_id = f"{entry.entry_id}_{self._field_key}"
self._attr_native_min_value = RAMP_WIDTH_ENTITY["native_min"]
self._attr_native_max_value = RAMP_WIDTH_ENTITY["native_max"]
self._attr_native_step = RAMP_WIDTH_ENTITY["step"]
self._attr_native_unit_of_measurement = RAMP_WIDTH_ENTITY["unit"]
self._attr_icon = RAMP_WIDTH_ENTITY["icon"]
self._attr_native_value = self._default
@property
def suggested_object_id(self) -> str | None:
"""Pin the entity_id slug to the field key (``_ramp_half_width``)."""
return self._field_key
@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,
)
async def async_added_to_hass(self) -> None:
"""Seed with two-tier precedence: restored value, else default 30."""
await super().async_added_to_hass()
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 = self._default
return
try:
self._attr_native_value = float(last_state.state)
except (TypeError, ValueError):
self._attr_native_value = self._default
async def async_set_native_value(self, value: float) -> None:
"""Persist to entity state only — never to ``entry.options``."""
self._attr_native_value = value
self.async_write_ha_state()

View file

@ -99,6 +99,7 @@ from .const import (
CONF_MIN_COLOR_TEMP,
CONF_MULTI_LIGHT_INTERCEPT,
CONF_PREFER_RGB_COLOR,
CONF_RAMP_HALF_WIDTH,
CONF_SEND_SPLIT_DELAY,
CONF_SEPARATE_TURN_ON_COMMANDS,
CONF_SKIP_REDUNDANT_COMMANDS,
@ -870,12 +871,20 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
data,
)
def _today_sun_events(self) -> tuple[datetime.datetime, datetime.datetime] | None:
def _today_sun_events(
self,
half_width: int,
) -> tuple[datetime.datetime, datetime.datetime] | None:
"""Read the sunrise and sunset entities and return today's events.
Day-anchoring (the `sensor.sun_next_rising` flipped-to-tomorrow
case) is delegated to `anchor_sun_events`; returns None if either
entity is missing or has an unparseable timestamp.
`half_width` MUST be the same value the curve evaluation uses this
tick (callers pass `sun_light_settings.ramp_half_width_seconds`) so
the post-sunset anchoring tail always matches the active ramp
width see add-runtime-ramp-width design D5.
"""
sunrise_state = self.hass.states.get(self._sunrise_entity)
sunset_state = self.hass.states.get(self._sunset_entity)
@ -914,7 +923,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
t_sunrise,
t_sunset,
now=dt_util.utcnow(),
half_width=RAMP_HALF_WIDTH_SECONDS,
half_width=half_width,
)
def _get_runtime_range(self, field_key: str) -> int:
@ -954,13 +963,48 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
)
return int(fallback)
def _get_runtime_ramp_width_seconds(self) -> int:
"""Read the live ramp half-width from its number entity, in seconds.
Same registry-lookup pattern as `_get_runtime_range`, but the entity
stores minutes and has no `entry.options` mirror the fallback is
the `RAMP_HALF_WIDTH_SECONDS` constant (spec R2, design D2/D4 of
add-runtime-ramp-width). Deliberately a dedicated helper: folding
unit conversion and a constant fallback into `_get_runtime_range`
would obscure both call patterns.
"""
registry = entity_registry.async_get(self.hass)
unique_id = f"{self._config_entry.entry_id}_{CONF_RAMP_HALF_WIDTH}"
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)) * 60
except (TypeError, ValueError):
pass
_LOGGER.debug(
"%s: ramp half-width entity unavailable for '%s' "
"(unique_id=%s) — falling back to RAMP_HALF_WIDTH_SECONDS=%s",
self._name,
CONF_RAMP_HALF_WIDTH,
unique_id,
RAMP_HALF_WIDTH_SECONDS,
)
return RAMP_HALF_WIDTH_SECONDS
@property
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).
Reads the four runtime range values and the ramp half-width on every
property access so curve evaluations always see the latest slider
positions. The dataclass init cost is microseconds cheap enough to
do every tick (D8).
"""
return SunLightSettings(
name=self._name,
@ -968,7 +1012,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
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,
ramp_half_width_seconds=self._get_runtime_ramp_width_seconds(),
)
@property
@ -1250,11 +1294,14 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
return None
# The switch might be off and not have _settings set.
events = self._today_sun_events()
# Build the settings ONCE so the curve and the day-anchoring share
# the same ramp half-width within this tick (D5).
sun_settings = self.sun_light_settings
events = self._today_sun_events(sun_settings.ramp_half_width_seconds)
if events is None:
return None
t_sunrise, t_sunset = events
self._settings = self.sun_light_settings.get_settings(
self._settings = sun_settings.get_settings(
transition,
t_sunrise,
t_sunset,
@ -1523,11 +1570,14 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
force,
)
assert self.is_on
events = self._today_sun_events()
# Single settings build per tick: curve and day-anchoring must share
# the same ramp half-width (D5).
sun_settings = self.sun_light_settings
events = self._today_sun_events(sun_settings.ramp_half_width_seconds)
if events is not None:
t_sunrise, t_sunset = events
self._settings.update(
self.sun_light_settings.get_settings(
sun_settings.get_settings(
transition,
t_sunrise,
t_sunset,

View file

@ -10,6 +10,7 @@ import datetime
import logging
from unittest.mock import patch
import homeassistant.util.dt as dt_util
import pytest
from homeassistant.components.number import NumberMode
from homeassistant.const import CONF_NAME
@ -418,3 +419,183 @@ async def test_curve_math_falls_back_on_unavailable(hass, caplog) -> None:
assert settings.max_brightness == 88 # fell back to options
# DEBUG log mentions the missing entity
assert "max_brightness" in caplog.text
# ---------------------------------------------------------------------------
# add-runtime-ramp-width — fifth entity: registration, bounds, naming
# ---------------------------------------------------------------------------
async def test_five_number_entities_registered(hass) -> None:
"""R1: the entry owns five number entities incl. ramp_half_width."""
entry = await _setup_entry(hass)
registry = er.async_get(hass)
for field_key in (*FIELD_KEYS, "ramp_half_width"):
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}"
# The ramp-width entity shares the switches' device.
ent_reg = er.async_get(hass)
master_eid = ent_reg.async_get_entity_id("switch", DOMAIN, PROFILE_NAME)
master_dev_id = ent_reg.async_get(master_eid).device_id
ramp_eid = _resolve_entity_id(hass, entry, "ramp_half_width")
assert ent_reg.async_get(ramp_eid).device_id == master_dev_id
async def test_ramp_width_entity_attributes(hass) -> None:
"""R1: minute bounds 5-120, step 1, slider mode, composed name."""
entry = await _setup_entry(hass)
eid = _resolve_entity_id(hass, entry, "ramp_half_width")
assert eid == f"number.{PROFILE_NAME}_ramp_half_width"
state = hass.states.get(eid)
assert state is not None
attrs = state.attributes
assert attrs["min"] == 5.0
assert attrs["max"] == 120.0
assert attrs["step"] == 1
assert attrs["unit_of_measurement"] == "min"
assert attrs["mode"] == NumberMode.SLIDER
assert attrs["friendly_name"] == f"{PROFILE_NAME} Ramp half-width"
# ---------------------------------------------------------------------------
# add-runtime-ramp-width — default + restore semantics (two-tier, no options)
# ---------------------------------------------------------------------------
async def test_ramp_width_defaults_to_thirty(hass) -> None:
"""R2: a fresh profile comes up at 30 minutes (== prior constant)."""
entry = await _setup_entry(hass)
eid = _resolve_entity_id(hass, entry, "ramp_half_width")
assert float(hass.states.get(eid).state) == 30.0
async def test_ramp_width_restores_after_restart(hass) -> None:
"""R2: restored value wins over the default; no entry.options tier."""
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_NAME: PROFILE_NAME},
options={},
version=CONFIG_ENTRY_VERSION,
)
entry.add_to_hass(hass)
fake_state = State(f"number.{PROFILE_NAME}_ramp_half_width", "75")
fake_extra = {"native_value": 75.0, "native_unit_of_measurement": "min"}
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, "ramp_half_width")
assert float(hass.states.get(eid).state) == 75.0
async def test_ramp_width_slider_change_no_reload(hass) -> None:
"""R2: moving the width slider must not unload/setup the entry."""
entry = await _setup_entry(hass)
eid = _resolve_entity_id(hass, entry, "ramp_half_width")
snapshot_options = dict(entry.options)
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": 60},
blocking=True,
)
await hass.async_block_till_done()
assert setup_spy.call_count == 0
assert unload_spy.call_count == 0
assert dict(entry.options) == snapshot_options
# ---------------------------------------------------------------------------
# add-runtime-ramp-width — curve read path (minutes -> seconds) + fallback
# ---------------------------------------------------------------------------
async def test_curve_uses_live_ramp_width(hass) -> None:
"""R2/R3: entity at 60 -> next settings build carries 3600 seconds."""
entry = await _setup_entry(hass)
eid = _resolve_entity_id(hass, entry, "ramp_half_width")
await hass.services.async_call(
"number",
"set_value",
{"entity_id": eid, "value": 60},
blocking=True,
)
al_switch = hass.data[DOMAIN][entry.entry_id]["switch"]
settings = al_switch.sun_light_settings
assert settings.ramp_half_width_seconds == 3600
async def test_curve_ramp_width_falls_back_to_constant(hass, caplog) -> None:
"""R2: unavailable entity -> 1800-second constant + DEBUG log."""
entry = await _setup_entry(hass)
eid = _resolve_entity_id(hass, entry, "ramp_half_width")
hass.states.async_remove(eid)
al_switch = hass.data[DOMAIN][entry.entry_id]["switch"]
caplog.set_level(logging.DEBUG)
settings = al_switch.sun_light_settings
assert settings.ramp_half_width_seconds == 1800
assert "ramp_half_width" in caplog.text
# ---------------------------------------------------------------------------
# add-runtime-ramp-width — D5: anchoring tail matches the widened ramp
# ---------------------------------------------------------------------------
async def test_widened_evening_ramp_survives_sensor_flip(hass) -> None:
"""R2/D5: width 60, sensors flipped to tomorrow, 45 min past sunset.
The day-anchoring must use the live width: with the old hardcoded
1800-second tail this instant would anchor to tomorrow and snap the
curve to minimum; with the live 3600-second width the down-ramp is
still in progress.
"""
entry = await _setup_entry(hass)
eid = _resolve_entity_id(hass, entry, "ramp_half_width")
await hass.services.async_call(
"number",
"set_value",
{"entity_id": eid, "value": 60},
blocking=True,
)
now = dt_util.utcnow()
sunset_today = now - datetime.timedelta(minutes=45)
sunrise_today = sunset_today - datetime.timedelta(hours=16)
one_day = datetime.timedelta(days=1)
# Both `next_*` sensors have already flipped to tomorrow's events.
hass.states.async_set(
"sensor.sun_next_rising",
(sunrise_today + one_day).isoformat(),
)
hass.states.async_set(
"sensor.sun_next_setting",
(sunset_today + one_day).isoformat(),
)
al_switch = hass.data[DOMAIN][entry.entry_id]["switch"]
settings = al_switch.sun_light_settings
assert settings.ramp_half_width_seconds == 3600
events = al_switch._today_sun_events(settings.ramp_half_width_seconds)
assert events is not None
t_sunrise, t_sunset = events
assert t_sunset == sunset_today # anchored back to today
assert t_sunrise == sunrise_today
brightness = settings.brightness_pct(now, t_sunrise, t_sunset)
assert settings.min_brightness < brightness < settings.max_brightness