mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-09-16 00:34:04 +02:00
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:
parent
0fd0328039
commit
a602a05f22
6 changed files with 356 additions and 20 deletions
17
README.md
17
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue