2026-05-16 22:24:39 +02:00
|
|
|
"""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
|
2026-06-05 12:16:40 +02:00
|
|
|
import logging
|
2026-05-16 22:24:39 +02:00
|
|
|
from unittest.mock import patch
|
|
|
|
|
|
2026-06-07 22:30:58 +02:00
|
|
|
import homeassistant.util.dt as dt_util
|
2026-05-16 22:24:39 +02:00
|
|
|
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,
|
2026-05-31 21:41:57 +02:00
|
|
|
DEFAULT_MAX_BRIGHTNESS,
|
|
|
|
|
DEFAULT_MAX_COLOR_TEMP,
|
|
|
|
|
DEFAULT_MIN_BRIGHTNESS,
|
|
|
|
|
DEFAULT_MIN_COLOR_TEMP,
|
2026-05-16 22:24:39 +02:00
|
|
|
DOMAIN,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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(
|
Implement add-output-sensors: 36/41 tasks complete
Three sensor entities per AL profile expose the curve's current outputs
plus actual sun elevation as graphable numerics with
SensorStateClass.MEASUREMENT, so HA's recorder + apexcharts-card chart
them natively:
- sensor.<profile>_output_brightness (% — from self._settings)
- sensor.<profile>_output_color_temp (K — from self._settings)
- sensor.<profile>_sun_elevation (° — from sun.sun.attributes.elevation)
Architecture: master switch publishes computed outputs to
hass.data[DOMAIN][entry_id]["outputs"] after each curve tick, then fires
a per-entry dispatcher signal. Sensors subscribe and read from the cache.
Single computation path, push-based updates, no polling.
The existing master switch attributes (brightness_pct, color_temp_kelvin,
synthetic sun_position in [-1,+1]) are unchanged — sensors are purely
additive. Manifest bumped to 2.2.0-cdit.1 (minor; no breaking changes).
Artifact realignment along the way: Q2 answer assumed sun_position was
Sun2 elevation degrees, but reading color_and_brightness.py revealed
it's a synthetic float in [-1,+1] derived from the brightness curve.
Switched to pulling actual elevation from sun.sun (Decision 8 added),
renamed sensor from sun_position to sun_elevation. Friendly names use
asymmetric "Output" prefix to dodge collision with the adapt-brightness
switch and Min/Max color-temp numbers.
Remaining: group 6 (manual live-HA verification on
homeassistant.onca-blenny.ts.net) — requires HACS deploy.
15 new sensor tests pass; full suite green (123/123). openspec
validate --strict green. ruff clean on new files.
2026-05-21 10:11:52 +02:00
|
|
|
"number",
|
|
|
|
|
DOMAIN,
|
|
|
|
|
_unique_id(entry, field_key),
|
2026-05-16 22:24:39 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# 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(
|
Implement add-output-sensors: 36/41 tasks complete
Three sensor entities per AL profile expose the curve's current outputs
plus actual sun elevation as graphable numerics with
SensorStateClass.MEASUREMENT, so HA's recorder + apexcharts-card chart
them natively:
- sensor.<profile>_output_brightness (% — from self._settings)
- sensor.<profile>_output_color_temp (K — from self._settings)
- sensor.<profile>_sun_elevation (° — from sun.sun.attributes.elevation)
Architecture: master switch publishes computed outputs to
hass.data[DOMAIN][entry_id]["outputs"] after each curve tick, then fires
a per-entry dispatcher signal. Sensors subscribe and read from the cache.
Single computation path, push-based updates, no polling.
The existing master switch attributes (brightness_pct, color_temp_kelvin,
synthetic sun_position in [-1,+1]) are unchanged — sensors are purely
additive. Manifest bumped to 2.2.0-cdit.1 (minor; no breaking changes).
Artifact realignment along the way: Q2 answer assumed sun_position was
Sun2 elevation degrees, but reading color_and_brightness.py revealed
it's a synthetic float in [-1,+1] derived from the brightness curve.
Switched to pulling actual elevation from sun.sun (Decision 8 added),
renamed sensor from sun_position to sun_elevation. Friendly names use
asymmetric "Output" prefix to dodge collision with the adapt-brightness
switch and Min/Max color-temp numbers.
Remaining: group 6 (manual live-HA verification on
homeassistant.onca-blenny.ts.net) — requires HACS deploy.
15 new sensor tests pass; full suite green (123/123). openspec
validate --strict green. ruff clean on new files.
2026-05-21 10:11:52 +02:00
|
|
|
"number",
|
|
|
|
|
DOMAIN,
|
|
|
|
|
_unique_id(entry, field_key),
|
2026-05-16 22:24:39 +02:00
|
|
|
)
|
|
|
|
|
assert eid is not None, f"Missing number entity for {field_key}"
|
|
|
|
|
assert eid.startswith("number.")
|
|
|
|
|
|
|
|
|
|
|
2026-06-05 10:49:40 +02:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Naming — "lower/upper" display names, but entity_ids slug from field keys
|
|
|
|
|
# (suggested_object_id pin keeps new profiles consistent with pre-rename ones)
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_entity_ids_slug_from_field_keys(hass) -> None:
|
|
|
|
|
"""Renamed display names must not leak into entity_id slugs."""
|
|
|
|
|
entry = await _setup_entry(hass)
|
|
|
|
|
for field_key in FIELD_KEYS:
|
|
|
|
|
eid = _resolve_entity_id(hass, entry, field_key)
|
|
|
|
|
assert eid == f"number.{PROFILE_NAME}_{field_key}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_friendly_names_use_lower_upper_wording(hass) -> None:
|
|
|
|
|
"""Device page sorts alphabetically; lower/upper puts min above max."""
|
|
|
|
|
expected_roles = {
|
|
|
|
|
"min_brightness": "Brightness lower",
|
|
|
|
|
"max_brightness": "Brightness upper",
|
|
|
|
|
"min_color_temp": "Color temp lower",
|
|
|
|
|
"max_color_temp": "Color temp upper",
|
|
|
|
|
}
|
|
|
|
|
entry = await _setup_entry(hass)
|
|
|
|
|
for field_key, role in expected_roles.items():
|
|
|
|
|
eid = _resolve_entity_id(hass, entry, field_key)
|
|
|
|
|
state = hass.states.get(eid)
|
|
|
|
|
assert state.attributes["friendly_name"] == f"{PROFILE_NAME} {role}"
|
|
|
|
|
|
|
|
|
|
|
2026-05-16 22:24:39 +02:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# 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(
|
2026-06-05 12:16:40 +02:00
|
|
|
("field_key", "expected"),
|
2026-05-16 22:24:39 +02:00
|
|
|
[
|
|
|
|
|
("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
|
|
|
|
|
|
|
|
|
|
|
2026-05-31 21:41:57 +02:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# 7.4b — Fresh profile (only a name) seeds sensible DEFAULT_*, not native_min
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
("field_key", "expected_default"),
|
|
|
|
|
[
|
|
|
|
|
("min_brightness", DEFAULT_MIN_BRIGHTNESS),
|
|
|
|
|
("max_brightness", DEFAULT_MAX_BRIGHTNESS),
|
|
|
|
|
("min_color_temp", DEFAULT_MIN_COLOR_TEMP),
|
|
|
|
|
("max_color_temp", DEFAULT_MAX_COLOR_TEMP),
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
async def test_new_profile_seeds_sensible_defaults(
|
|
|
|
|
hass,
|
|
|
|
|
field_key,
|
|
|
|
|
expected_default,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""A profile created with only a name must come up at DEFAULT_*.
|
|
|
|
|
|
|
|
|
|
Regression: the seed previously fell back to the slider's native_min,
|
|
|
|
|
so a brand-new group ran at max_brightness=1% and a collapsed
|
|
|
|
|
1000-1000K color-temp range until the options dialog was saved once.
|
|
|
|
|
"""
|
|
|
|
|
entry = await _setup_entry(hass) # data={name}, options={}
|
|
|
|
|
eid = _resolve_entity_id(hass, entry, field_key)
|
|
|
|
|
state = hass.states.get(eid)
|
|
|
|
|
assert state is not None
|
|
|
|
|
assert float(state.state) == float(expected_default)
|
|
|
|
|
|
|
|
|
|
|
2026-05-16 22:24:39 +02:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# 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",
|
2026-06-05 12:16:40 +02:00
|
|
|
datetime.datetime(2020, 1, 1, tzinfo=datetime.UTC),
|
2026-05-16 22:24:39 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# 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
|
Implement add-output-sensors: 36/41 tasks complete
Three sensor entities per AL profile expose the curve's current outputs
plus actual sun elevation as graphable numerics with
SensorStateClass.MEASUREMENT, so HA's recorder + apexcharts-card chart
them natively:
- sensor.<profile>_output_brightness (% — from self._settings)
- sensor.<profile>_output_color_temp (K — from self._settings)
- sensor.<profile>_sun_elevation (° — from sun.sun.attributes.elevation)
Architecture: master switch publishes computed outputs to
hass.data[DOMAIN][entry_id]["outputs"] after each curve tick, then fires
a per-entry dispatcher signal. Sensors subscribe and read from the cache.
Single computation path, push-based updates, no polling.
The existing master switch attributes (brightness_pct, color_temp_kelvin,
synthetic sun_position in [-1,+1]) are unchanged — sensors are purely
additive. Manifest bumped to 2.2.0-cdit.1 (minor; no breaking changes).
Artifact realignment along the way: Q2 answer assumed sun_position was
Sun2 elevation degrees, but reading color_and_brightness.py revealed
it's a synthetic float in [-1,+1] derived from the brightness curve.
Switched to pulling actual elevation from sun.sun (Decision 8 added),
renamed sensor from sun_position to sun_elevation. Friendly names use
asymmetric "Output" prefix to dodge collision with the adapt-brightness
switch and Min/Max color-temp numbers.
Remaining: group 6 (manual live-HA verification on
homeassistant.onca-blenny.ts.net) — requires HACS deploy.
15 new sensor tests pass; full suite green (123/123). openspec
validate --strict green. ruff clean on new files.
2026-05-21 10:11:52 +02:00
|
|
|
daytime = next(sub for k, sub in schema.items() if str(k) == "daytime_curve")
|
2026-05-16 22:24:39 +02:00
|
|
|
inner = daytime.schema.schema
|
Implement add-output-sensors: 36/41 tasks complete
Three sensor entities per AL profile expose the curve's current outputs
plus actual sun elevation as graphable numerics with
SensorStateClass.MEASUREMENT, so HA's recorder + apexcharts-card chart
them natively:
- sensor.<profile>_output_brightness (% — from self._settings)
- sensor.<profile>_output_color_temp (K — from self._settings)
- sensor.<profile>_sun_elevation (° — from sun.sun.attributes.elevation)
Architecture: master switch publishes computed outputs to
hass.data[DOMAIN][entry_id]["outputs"] after each curve tick, then fires
a per-entry dispatcher signal. Sensors subscribe and read from the cache.
Single computation path, push-based updates, no polling.
The existing master switch attributes (brightness_pct, color_temp_kelvin,
synthetic sun_position in [-1,+1]) are unchanged — sensors are purely
additive. Manifest bumped to 2.2.0-cdit.1 (minor; no breaking changes).
Artifact realignment along the way: Q2 answer assumed sun_position was
Sun2 elevation degrees, but reading color_and_brightness.py revealed
it's a synthetic float in [-1,+1] derived from the brightness curve.
Switched to pulling actual elevation from sun.sun (Decision 8 added),
renamed sensor from sun_position to sun_elevation. Friendly names use
asymmetric "Output" prefix to dodge collision with the adapt-brightness
switch and Min/Max color-temp numbers.
Remaining: group 6 (manual live-HA verification on
homeassistant.onca-blenny.ts.net) — requires HACS deploy.
15 new sensor tests pass; full suite green (123/123). openspec
validate --strict green. ruff clean on new files.
2026-05-21 10:11:52 +02:00
|
|
|
max_b_default = next(k.default() for k in inner if str(k) == CONF_MAX_BRIGHTNESS)
|
2026-05-16 22:24:39 +02:00
|
|
|
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
|
Implement add-output-sensors: 36/41 tasks complete
Three sensor entities per AL profile expose the curve's current outputs
plus actual sun elevation as graphable numerics with
SensorStateClass.MEASUREMENT, so HA's recorder + apexcharts-card chart
them natively:
- sensor.<profile>_output_brightness (% — from self._settings)
- sensor.<profile>_output_color_temp (K — from self._settings)
- sensor.<profile>_sun_elevation (° — from sun.sun.attributes.elevation)
Architecture: master switch publishes computed outputs to
hass.data[DOMAIN][entry_id]["outputs"] after each curve tick, then fires
a per-entry dispatcher signal. Sensors subscribe and read from the cache.
Single computation path, push-based updates, no polling.
The existing master switch attributes (brightness_pct, color_temp_kelvin,
synthetic sun_position in [-1,+1]) are unchanged — sensors are purely
additive. Manifest bumped to 2.2.0-cdit.1 (minor; no breaking changes).
Artifact realignment along the way: Q2 answer assumed sun_position was
Sun2 elevation degrees, but reading color_and_brightness.py revealed
it's a synthetic float in [-1,+1] derived from the brightness curve.
Switched to pulling actual elevation from sun.sun (Decision 8 added),
renamed sensor from sun_position to sun_elevation. Friendly names use
asymmetric "Output" prefix to dodge collision with the adapt-brightness
switch and Min/Max color-temp numbers.
Remaining: group 6 (manual live-HA verification on
homeassistant.onca-blenny.ts.net) — requires HACS deploy.
15 new sensor tests pass; full suite green (123/123). openspec
validate --strict green. ruff clean on new files.
2026-05-21 10:11:52 +02:00
|
|
|
default = next(k.default() for k in inner if str(k) == CONF_MIN_COLOR_TEMP)
|
2026-05-16 22:24:39 +02:00
|
|
|
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"]
|
|
|
|
|
|
|
|
|
|
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
|
2026-06-07 22:30:58 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# 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
|