diff --git a/README.md b/README.md index 01e4387a..14f2d044 100644 --- a/README.md +++ b/README.md @@ -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` | 1–100 % | 1 | RestoreNumber | | `number.kitchen_min_color_temp` | 1000–10000 K | 100 | RestoreNumber | | `number.kitchen_max_color_temp` | 1000–10000 K | 100 | RestoreNumber | +| `number.kitchen_ramp_half_width` | 5–120 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 diff --git a/custom_components/adaptive_lighting/const.py b/custom_components/adaptive_lighting/const.py index 6144849e..643e0c97 100644 --- a/custom_components/adaptive_lighting/const.py +++ b/custom_components/adaptive_lighting/const.py @@ -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 diff --git a/custom_components/adaptive_lighting/manifest.json b/custom_components/adaptive_lighting/manifest.json index 6a70d9e0..eb76f667 100644 --- a/custom_components/adaptive_lighting/manifest.json +++ b/custom_components/adaptive_lighting/manifest.json @@ -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" } diff --git a/custom_components/adaptive_lighting/number.py b/custom_components/adaptive_lighting/number.py index 04c79ec0..72c2d952 100644 --- a/custom_components/adaptive_lighting/number.py +++ b/custom_components/adaptive_lighting/number.py @@ -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._min_brightness`` - ``number._max_brightness`` - ``number._min_color_temp`` - ``number._max_color_temp`` +- ``number._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() diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index ee9ae543..f68780af 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -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, diff --git a/tests/test_number_platform.py b/tests/test_number_platform.py index 9d66c52c..1b26fadc 100644 --- a/tests/test_number_platform.py +++ b/tests/test_number_platform.py @@ -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