Merge remote-tracking branch 'origin/main' into fix/group-target-resolution

# Conflicts:
#	custom_components/adaptive_lighting/strings.json
#	custom_components/adaptive_lighting/translations/de.json
#	custom_components/adaptive_lighting/translations/en.json
#	tests/test_switch.py
This commit is contained in:
Bas Nijholt 2026-09-06 09:10:30 -07:00
commit 7284ebeaea
84 changed files with 7326 additions and 2037 deletions

View file

@ -1,8 +1,16 @@
# Developer notes for the tests directory
To run the tests, check out the [CI configuration](../.github/workflows/pytest.yml) to see how they are executed in the CI pipeline.
To run the tests, check out the [CI configuration](../.github/workflows/pytest.yaml) to see how they are executed in the CI pipeline.
Alternatively, you can use the provided Docker image to run the tests locally or run them with VS Code directly in the dev container.
## Coverage reports
Open a `pytest` workflow run in GitHub Actions to see line and branch coverage in each job's summary. Download its `coverage-<Home Assistant version>-py<Python version>` artifact for the XML and JSON reports and the browsable HTML report. After extracting it, open `htmlcov/index.html` to inspect missing lines and branches. Supported stable Home Assistant versions require at least 89% line coverage and 80% branch coverage. The `dev` job reports coverage without enforcing these floors.
Coverage measures executed code, not whether assertions would catch a bug. Add tests for observable behavior: emitted light commands, final states, manual-control events, and timer expiry. The integration suite runs inside Home Assistant with simulated lights; it does not establish physical-device behavior. It also does not execute every documentation generator included in the package's coverage total.
The tests in `test_automation_examples.py` load YAML directly from `README.md` and execute it through Home Assistant's automation and script engines. Edit the README source when changing those examples, then run `./scripts/update-generated-content` to update the documentation pages.
## Prerequisites
Before running tests with Docker, you need a local Home Assistant core checkout with symlinks:

View file

@ -95,14 +95,82 @@ async def test_split_service_call_data(input_data, expected_data_list):
),
(
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 10, ATTR_TRANSITION: 2},
State("light.test", STATE_ON, {ATTR_BRIGHTNESS: 11}),
State("light.test", STATE_ON, {ATTR_BRIGHTNESS: 13}),
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 10, ATTR_TRANSITION: 2},
),
(
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 230, ATTR_TRANSITION: 2},
State("light.test", STATE_ON, {ATTR_BRIGHTNESS: 229}),
{ATTR_ENTITY_ID: "light.test", ATTR_TRANSITION: 2},
),
(
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 230, ATTR_TRANSITION: 2},
State("light.test", STATE_ON, {ATTR_BRIGHTNESS: 227}),
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 230, ATTR_TRANSITION: 2},
),
(
{
ATTR_ENTITY_ID: "light.test",
ATTR_COLOR_TEMP_KELVIN: 5500,
ATTR_TRANSITION: 2,
},
State("light.test", STATE_ON, {ATTR_COLOR_TEMP_KELVIN: 5495}),
{ATTR_ENTITY_ID: "light.test", ATTR_TRANSITION: 2},
),
(
{
ATTR_ENTITY_ID: "light.test",
ATTR_COLOR_TEMP_KELVIN: 5500,
ATTR_TRANSITION: 2,
},
State("light.test", STATE_ON, {ATTR_COLOR_TEMP_KELVIN: 5524}),
{ATTR_ENTITY_ID: "light.test", ATTR_TRANSITION: 2},
),
(
{
ATTR_ENTITY_ID: "light.test",
ATTR_COLOR_TEMP_KELVIN: 6500,
ATTR_TRANSITION: 2,
},
State("light.test", STATE_ON, {ATTR_COLOR_TEMP_KELVIN: 6494}),
{ATTR_ENTITY_ID: "light.test", ATTR_TRANSITION: 2},
),
(
{
ATTR_ENTITY_ID: "light.test",
ATTR_COLOR_TEMP_KELVIN: 5500,
ATTR_TRANSITION: 2,
},
State("light.test", STATE_ON, {ATTR_COLOR_TEMP_KELVIN: 5400}),
{
ATTR_ENTITY_ID: "light.test",
ATTR_COLOR_TEMP_KELVIN: 5500,
ATTR_TRANSITION: 2,
},
),
(
{ATTR_ENTITY_ID: "light.test", ATTR_HS_COLOR: (30.0, 40.0)},
State("light.test", STATE_ON, {ATTR_HS_COLOR: (30.0, 40.0)}),
{ATTR_ENTITY_ID: "light.test"},
),
(
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 10},
State("light.test", STATE_ON, {ATTR_BRIGHTNESS: None}),
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 10},
),
],
ids=[
"pass all attributes on empty state",
"remove attributes whose values equal the state",
"keep attributes whose values differ from the state",
"remove brightness within quantization tolerance (0-99 device scale)",
"keep brightness outside quantization tolerance",
"remove color temp within one mired (round-converting integration)",
"remove color temp within one mired (floor-converting HA core helpers)",
"remove color temp within one mired (6500 K)",
"keep color temp more than one mired away",
"remove non-numeric attributes on exact equality",
"keep attribute when state value is None",
],
)
async def test_remove_redundant_attributes(
@ -167,18 +235,18 @@ async def test_has_relevant_service_data_attributes(
[],
),
(
[{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 11}],
[{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 15}],
True,
[{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 11}],
[{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 15}],
),
(
[
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 11},
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 15},
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 22},
],
True,
[
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 11},
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 15},
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 22},
],
),
@ -192,6 +260,11 @@ async def test_has_relevant_service_data_attributes(
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 22},
],
),
(
[{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 11}],
True,
[],
),
],
ids=[
"single item passed through without filtering",
@ -201,6 +274,7 @@ async def test_has_relevant_service_data_attributes(
"filter keeps item with relevant attribute that is different from state",
"filter keeps two items with relevant attributes that are different from state",
"filter removes item that equals state and keeps items that differs from state",
"filter removes item with relevant attribute within tolerance of the state",
],
)
async def test_create_service_call_data_iterator(
@ -480,3 +554,50 @@ def test_get_light_control_attributes(
):
"""Test determination of light control attributes."""
assert get_light_control_attributes(service_data) == expected_flags
@pytest.mark.parametrize(
("already_applied", "expected"),
[
(
LightControlAttributes.BRIGHTNESS,
[
{
ATTR_ENTITY_ID: "light.test",
ATTR_COLOR_TEMP_KELVIN: 3448,
ATTR_TRANSITION: 1,
},
],
),
(
LightControlAttributes.COLOR,
[{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 171, ATTR_TRANSITION: 1}],
),
(LightControlAttributes.ALL, []),
],
)
async def test_remaining_split_commands_preserve_transition(
hass,
already_applied,
expected,
):
"""Removing the shared command must not redistribute its transition time."""
data = prepare_adaptation_data(
hass,
"light.test",
Context(),
transition=2,
split_delay=0.1,
service_data={
ATTR_ENTITY_ID: "light.test",
ATTR_BRIGHTNESS: 171,
ATTR_COLOR_TEMP_KELVIN: 3448,
ATTR_TRANSITION: 2,
},
split=True,
filter_by_state=False,
force=False,
already_applied=already_applied,
)
assert [command async for command in data.service_call_datas] == expected
assert data.sleep_time == 1.1

View file

@ -0,0 +1,691 @@
"""Execute the automation examples published in README.md."""
from __future__ import annotations
import asyncio
import re
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import TYPE_CHECKING
from unittest.mock import patch
import pytest
import yaml
from homeassistant.components import automation, script
from homeassistant.components.adaptive_lighting.adaptation_utils import (
LightControlAttributes,
)
from homeassistant.components.adaptive_lighting.const import (
CONF_INITIAL_TRANSITION,
CONF_LIGHTS,
CONF_MAX_BRIGHTNESS,
CONF_MAX_COLOR_TEMP,
CONF_MIN_BRIGHTNESS,
CONF_MIN_COLOR_TEMP,
CONF_NAME,
CONF_SLEEP_BRIGHTNESS,
CONF_SLEEP_COLOR_TEMP,
CONF_SLEEP_RGB_OR_COLOR_TEMP,
CONF_SUNRISE_TIME,
CONF_SUNSET_TIME,
CONF_TRANSITION,
DOMAIN,
)
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
ATTR_RGB_COLOR,
)
from homeassistant.components.light import (
DOMAIN as LIGHT_DOMAIN,
)
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.const import (
ATTR_ENTITY_ID,
EVENT_CALL_SERVICE,
EVENT_STATE_CHANGED,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
STATE_OFF,
STATE_ON,
)
from homeassistant.core import CoreState, Event, HomeAssistant, State, callback
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from tests.common import async_fire_time_changed
from .test_switch import setup_switch
if TYPE_CHECKING:
from collections.abc import Callable
README = Path(__file__).resolve().parents[1] / "README.md"
def _yaml_documents(summary: str) -> list[object]:
"""Load every YAML fence from one README details block."""
readme = README.read_text()
details = re.search(
rf"<summary>{re.escape(summary)}</summary>(.*?)</details>",
readme,
flags=re.DOTALL,
)
assert details is not None, f"README example not found: {summary}"
blocks = re.findall(r"```yaml\n(.*?)```", details.group(1), flags=re.DOTALL)
assert blocks, f"README example has no YAML: {summary}"
return [yaml.safe_load(block) for block in blocks]
async def _setup_automation(hass: HomeAssistant, config: object) -> None:
"""Load an extracted automation through Home Assistant."""
assert await async_setup_component(
hass,
automation.DOMAIN,
{automation.DOMAIN: config},
)
await hass.async_block_till_done()
async def _setup_script(hass: HomeAssistant, config: dict) -> None:
"""Load an extracted script through Home Assistant."""
assert await async_setup_component(hass, script.DOMAIN, config)
await hass.async_block_till_done()
async def _setup_template_lights(
hass: HomeAssistant,
names: list[str],
) -> None:
"""Create color-temperature and RGB lights with documented entity IDs."""
lights = [
{
"name": name,
"unique_id": name.lower().replace(" ", "_"),
"turn_on": None,
"turn_off": None,
"set_level": None,
"set_temperature": None,
"set_rgb": None,
}
for name in names
]
assert await async_setup_component(
hass,
"template",
{"template": {"light": lights}},
)
await hass.async_block_till_done()
def _state_waiter(
hass: HomeAssistant,
entity_id: str,
predicate: Callable[[State], bool],
) -> tuple[asyncio.Future[State], Callable[[], None]]:
"""Return a future that resolves when an entity state matches a predicate."""
future = hass.loop.create_future()
@callback
def state_changed(event: Event) -> None:
new_state = event.data.get("new_state")
if (
not future.done()
and new_state is not None
and new_state.entity_id == entity_id
and predicate(new_state)
):
future.set_result(new_state)
remove_listener = hass.bus.async_listen(EVENT_STATE_CHANGED, state_changed)
return future, remove_listener
def _prepare_hass_startup(hass: HomeAssistant) -> None:
"""Reset the standard running test fixture to exercise a real HA start."""
hass.set_state(CoreState.not_running)
async def test_schedule_profile_executes_blocks_and_restore(
hass: HomeAssistant,
) -> None:
"""Catch ignored attribute changes, incomplete restore, or switch coupling."""
summary = "Use a Schedule helper as a step-based custom lighting profile."
automation_config = _yaml_documents(summary)[-1]
_, adaptive_switch = await setup_switch(
hass,
{
CONF_NAME: "Living Room",
CONF_LIGHTS: ["light.manually_controlled"],
CONF_MIN_BRIGHTNESS: 8,
CONF_MAX_BRIGHTNESS: 88,
CONF_MIN_COLOR_TEMP: 2100,
CONF_MAX_COLOR_TEMP: 5100,
},
)
adaptive_switch.manager.set_manual_control_attributes(
"light.manually_controlled",
LightControlAttributes.BRIGHTNESS,
)
hass.states.async_set(
"schedule.adaptive_lighting_profile",
STATE_OFF,
)
await _setup_automation(hass, automation_config)
hass.states.async_set(
"schedule.adaptive_lighting_profile",
STATE_ON,
{"brightness_pct": 20, "color_temp_kelvin": 2500},
)
await hass.async_block_till_done()
assert adaptive_switch._sun_light_settings.min_brightness == 20
assert adaptive_switch._sun_light_settings.max_brightness == 20
assert adaptive_switch._sun_light_settings.min_color_temp == 2500
assert adaptive_switch._sun_light_settings.max_color_temp == 2500
hass.states.async_set(
"schedule.adaptive_lighting_profile",
STATE_ON,
{"brightness_pct": 60, "color_temp_kelvin": 4000},
)
await hass.async_block_till_done()
assert adaptive_switch._sun_light_settings.min_brightness == 60
assert adaptive_switch._sun_light_settings.max_brightness == 60
assert adaptive_switch._sun_light_settings.min_color_temp == 4000
assert adaptive_switch._sun_light_settings.max_color_temp == 4000
hass.states.async_set("schedule.adaptive_lighting_profile", STATE_OFF)
await hass.async_block_till_done()
assert adaptive_switch._sun_light_settings.min_brightness == 8
assert adaptive_switch._sun_light_settings.max_brightness == 88
assert adaptive_switch._sun_light_settings.min_color_temp == 2100
assert adaptive_switch._sun_light_settings.max_color_temp == 5100
assert adaptive_switch.manager.manual_control["light.manually_controlled"]
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: adaptive_switch.entity_id},
blocking=True,
)
hass.states.async_set(
"schedule.adaptive_lighting_profile",
STATE_ON,
{"brightness_pct": 35, "color_temp_kelvin": 2750},
)
await hass.async_block_till_done()
assert adaptive_switch.is_on is False
assert adaptive_switch._sun_light_settings.min_brightness == 35
assert adaptive_switch._sun_light_settings.max_color_temp == 2750
async def test_schedule_profile_reapplies_at_startup(hass: HomeAssistant) -> None:
"""Verify startup applies the already-active schedule block."""
_prepare_hass_startup(hass)
summary = "Use a Schedule helper as a step-based custom lighting profile."
automation_config = _yaml_documents(summary)[-1]
_, adaptive_switch = await setup_switch(hass, {CONF_NAME: "Living Room"})
hass.states.async_set(
"schedule.adaptive_lighting_profile",
STATE_ON,
{"brightness_pct": 20, "color_temp_kelvin": 2500},
)
await _setup_automation(hass, automation_config)
await hass.async_start()
await hass.async_block_till_done()
assert adaptive_switch._sun_light_settings.min_brightness == 20
assert adaptive_switch._sun_light_settings.max_brightness == 20
assert adaptive_switch._sun_light_settings.min_color_temp == 2500
assert adaptive_switch._sun_light_settings.max_color_temp == 2500
async def test_lux_profile_executes_hysteresis(hass: HomeAssistant) -> None:
"""Catch missing threshold actions or changes inside the dead band."""
summary = (
"Reduce daytime brightness when an illuminance sensor detects strong daylight."
)
automation_config = _yaml_documents(summary)[0]
_, adaptive_switch = await setup_switch(
hass,
{CONF_NAME: "Living Room", CONF_MAX_BRIGHTNESS: 80},
)
hass.states.async_set("sensor.living_room_illuminance", "250")
await _setup_automation(hass, automation_config)
hass.states.async_set("sensor.living_room_illuminance", "350")
await hass.async_block_till_done()
assert adaptive_switch._sun_light_settings.max_brightness == 30
hass.states.async_set("sensor.living_room_illuminance", "250")
await hass.async_block_till_done()
assert adaptive_switch._sun_light_settings.max_brightness == 30
hass.states.async_set("sensor.living_room_illuminance", "150")
await hass.async_block_till_done()
assert adaptive_switch._sun_light_settings.max_brightness == 100
hass.states.async_set("sensor.living_room_illuminance", "250")
await hass.async_block_till_done()
assert adaptive_switch._sun_light_settings.max_brightness == 100
async def test_lux_profile_executes_unknown_recovery_at_startup(
hass: HomeAssistant,
) -> None:
"""Catch a startup hang or failure to recover from an unknown sensor."""
_prepare_hass_startup(hass)
summary = (
"Reduce daytime brightness when an illuminance sensor detects strong daylight."
)
automation_config = _yaml_documents(summary)[0]
_, adaptive_switch = await setup_switch(
hass,
{CONF_NAME: "Living Room", CONF_MAX_BRIGHTNESS: 80},
)
hass.states.async_set("sensor.living_room_illuminance", "unknown")
await _setup_automation(hass, automation_config)
waiting, remove_listener = _state_waiter(
hass,
"automation.adaptive_lighting_limit_brightness_in_daylight",
lambda state: state.attributes.get("current") == 1,
)
await hass.async_start()
await asyncio.wait_for(waiting, timeout=1)
remove_listener()
assert adaptive_switch._sun_light_settings.max_brightness == 80
hass.states.async_set("sensor.living_room_illuminance", "350")
await hass.async_block_till_done()
assert adaptive_switch._sun_light_settings.max_brightness == 30
async def test_hue_script_applies_current_values_to_fresh_profile_targets(
hass: HomeAssistant,
) -> None:
"""Catch an invalid script wrapper or a one-shot apply that skips off lights."""
summary = "Turn on Hue-controlled lights with the current Adaptive Lighting values."
script_config = _yaml_documents(summary)[0]
await _setup_template_lights(
hass,
["Living Room Ceiling", "Living Room Table"],
)
_, adaptive_switch = await setup_switch(
hass,
{
CONF_NAME: "Living Room",
CONF_LIGHTS: [
"light.living_room_ceiling",
"light.living_room_table",
],
CONF_SUNRISE_TIME: "06:00:00",
CONF_SUNSET_TIME: "18:00:00",
CONF_MIN_BRIGHTNESS: 10,
CONF_MAX_BRIGHTNESS: 80,
CONF_MIN_COLOR_TEMP: 2000,
CONF_MAX_COLOR_TEMP: 5000,
CONF_INITIAL_TRANSITION: 0,
CONF_TRANSITION: 0,
},
)
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: adaptive_switch.entity_id},
blocking=True,
)
adaptive_switch.manager.set_manual_control_attributes(
"light.living_room_ceiling",
LightControlAttributes.BRIGHTNESS | LightControlAttributes.COLOR,
)
await _setup_script(hass, script_config)
noon = datetime(2026, 9, 6, 12, tzinfo=dt_util.DEFAULT_TIME_ZONE).astimezone(UTC)
with patch(
"homeassistant.components.adaptive_lighting.color_and_brightness.utcnow",
return_value=noon,
):
await hass.services.async_call(
script.DOMAIN,
"living_room_adaptive_lighting",
blocking=True,
)
await hass.async_block_till_done()
for entity_id in ("light.living_room_ceiling", "light.living_room_table"):
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_ON
assert state.attributes[ATTR_BRIGHTNESS] == 204
assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 5000
assert adaptive_switch.is_on is False
assert adaptive_switch.manager.manual_control["light.living_room_ceiling"]
async def test_rgb_bedtime_script_applies_stage_then_restores_configuration(
hass: HomeAssistant,
) -> None:
"""Catch a wrong fresh child ID, delayed first stage, or missing restoration."""
summary = "Use a fixed RGB stage before sleep mode."
script_config = _yaml_documents(summary)[0]
await _setup_template_lights(hass, ["Bedroom"])
_, adaptive_switch = await setup_switch(
hass,
{
CONF_NAME: "Bedroom",
CONF_LIGHTS: ["light.bedroom"],
CONF_SLEEP_BRIGHTNESS: 7,
CONF_SLEEP_COLOR_TEMP: 2300,
CONF_SLEEP_RGB_OR_COLOR_TEMP: "color_temp",
CONF_INITIAL_TRANSITION: 0,
CONF_TRANSITION: 0,
},
)
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: "light.bedroom"},
blocking=True,
)
adaptive_switch.manager.set_manual_control_attributes(
"light.bedroom",
LightControlAttributes.ALL,
)
await _setup_script(hass, script_config)
light_staged, remove_light_listener = _state_waiter(
hass,
"light.bedroom",
lambda state: (
state.attributes.get(ATTR_BRIGHTNESS) == 51
and state.attributes.get(ATTR_RGB_COLOR) == (255, 56, 0)
),
)
sleep_enabled, remove_sleep_listener = _state_waiter(
hass,
"switch.adaptive_lighting_bedroom_sleep_mode",
lambda state: state.state == STATE_ON,
)
await hass.services.async_call(
script.DOMAIN,
"adaptive_lighting_bedtime",
blocking=False,
)
await asyncio.wait_for(asyncio.gather(light_staged, sleep_enabled), timeout=1)
remove_light_listener()
remove_sleep_listener()
# Let the script continue from the completed switch action into its delay.
await asyncio.sleep(0)
sleep_state = hass.states.get("switch.adaptive_lighting_bedroom_sleep_mode")
assert sleep_state is not None
assert sleep_state.state == STATE_ON
assert adaptive_switch._sun_light_settings.sleep_brightness == 20
assert adaptive_switch._sun_light_settings.sleep_rgb_or_color_temp == "rgb_color"
assert adaptive_switch._sun_light_settings.sleep_rgb_color == [255, 56, 0]
assert not adaptive_switch.manager.manual_control["light.bedroom"]
bedroom_state = hass.states.get("light.bedroom")
assert bedroom_state is not None
assert bedroom_state.attributes[ATTR_BRIGHTNESS] == 51
assert bedroom_state.attributes[ATTR_RGB_COLOR] == (255, 56, 0)
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=30))
await hass.async_block_till_done()
assert adaptive_switch._sun_light_settings.sleep_brightness == 7
assert adaptive_switch._sun_light_settings.sleep_rgb_or_color_temp == "color_temp"
assert adaptive_switch._sun_light_settings.sleep_color_temp == 2300
bedroom_state = hass.states.get("light.bedroom")
assert bedroom_state is not None
assert bedroom_state.attributes[ATTR_COLOR_TEMP_KELVIN] == pytest.approx(
2300,
abs=5,
)
async def test_fixed_virtual_day_curve_and_power_automation(
hass: HomeAssistant,
freezer,
) -> None:
"""Catch a broken cross-midnight curve or power-trigger branch."""
summary = "Run a fixed virtual day across midnight."
integration_config, automation_config = _yaml_documents(summary)
await _setup_template_lights(hass, ["Indoor Garden"])
assert isinstance(integration_config, dict)
assert await async_setup_component(hass, DOMAIN, integration_config)
await hass.async_block_till_done()
entry = hass.config_entries.async_entries(DOMAIN)[0]
adaptive_switch = hass.data[DOMAIN][entry.entry_id][SWITCH_DOMAIN]
expected_brightness = {
datetime(2026, 9, 6, 16, tzinfo=dt_util.DEFAULT_TIME_ZONE): 10,
datetime(2026, 9, 6, 22, tzinfo=dt_util.DEFAULT_TIME_ZONE): 100,
datetime(2026, 9, 7, 4, tzinfo=dt_util.DEFAULT_TIME_ZONE): 10,
}
for now, expected in expected_brightness.items():
assert adaptive_switch._sun_light_settings.brightness_pct(
now,
False,
) == pytest.approx(
expected,
)
freezer.move_to(
datetime(2026, 9, 6, 15, 59, tzinfo=dt_util.DEFAULT_TIME_ZONE),
)
await _setup_automation(hass, automation_config)
await hass.async_start()
async_fire_time_changed(
hass,
datetime(2026, 9, 6, 16, tzinfo=dt_util.DEFAULT_TIME_ZONE).astimezone(UTC),
)
await hass.async_block_till_done()
garden_state = hass.states.get("light.indoor_garden")
assert garden_state is not None
assert garden_state.state == STATE_ON
async_fire_time_changed(
hass,
datetime(2026, 9, 7, 4, tzinfo=dt_util.DEFAULT_TIME_ZONE).astimezone(UTC),
)
await hass.async_block_till_done()
garden_state = hass.states.get("light.indoor_garden")
assert garden_state is not None
assert garden_state.state == STATE_OFF
@pytest.mark.parametrize(
("start_hour", "expected_state"),
[(10, STATE_OFF), (22, STATE_ON)],
)
async def test_fixed_virtual_day_reconciles_power_at_startup(
hass: HomeAssistant,
freezer,
start_hour: int,
expected_state: str,
) -> None:
"""Catch a power schedule that misses a trigger while HA is offline."""
_prepare_hass_startup(hass)
summary = "Run a fixed virtual day across midnight."
_, automation_config = _yaml_documents(summary)
await _setup_template_lights(hass, ["Indoor Garden"])
await _setup_automation(hass, automation_config)
freezer.move_to(
datetime(2026, 9, 6, start_hour, tzinfo=dt_util.DEFAULT_TIME_ZONE),
)
await hass.async_start()
await hass.async_block_till_done()
garden_state = hass.states.get("light.indoor_garden")
assert garden_state is not None
assert garden_state.state == expected_state
async def test_autoreset_manual_control_uses_one_renewable_timer(
hass: HomeAssistant,
) -> None:
"""Validate the documented built-in timeout and its renewal behavior."""
summary = "Automatically reset manual control after one hour."
integration_config = _yaml_documents(summary)[0]
await _setup_template_lights(hass, ["Living Room"])
assert isinstance(integration_config, dict)
assert await async_setup_component(hass, DOMAIN, integration_config)
await hass.async_block_till_done()
entry = hass.config_entries.async_entries(DOMAIN)[0]
adaptive_switch = hass.data[DOMAIN][entry.entry_id][SWITCH_DOMAIN]
assert adaptive_switch._auto_reset_manual_control_time == 3600
service_data = {
ATTR_ENTITY_ID: adaptive_switch.entity_id,
CONF_LIGHTS: ["light.living_room"],
"manual_control": True,
}
await hass.services.async_call(
DOMAIN,
"set_manual_control",
service_data,
blocking=True,
)
first_timer = adaptive_switch.manager.auto_reset_manual_control_timers[
"light.living_room"
]
first_task = first_timer.task
await hass.services.async_call(
DOMAIN,
"set_manual_control",
service_data,
blocking=True,
)
renewed_timer = adaptive_switch.manager.auto_reset_manual_control_timers[
"light.living_room"
]
await asyncio.sleep(0)
assert renewed_timer is first_timer
assert renewed_timer.task is not first_task
assert first_task is not None
assert first_task.cancelled()
assert adaptive_switch.manager.manual_control["light.living_room"]
await renewed_timer.callback()
assert not adaptive_switch.manager.manual_control["light.living_room"]
async def test_sleep_toggle_uses_fresh_profile_entity_ids(
hass: HomeAssistant,
) -> None:
"""Execute state triggers against fresh child entity IDs."""
summary = (
'Toggle multiple Adaptive Lighting switches to "sleep mode" using an '
"<code>input_boolean.sleep_mode</code>."
)
automation_config = _yaml_documents(summary)[0]
assert await async_setup_component(
hass,
"input_boolean",
{"input_boolean": {"sleep_mode": {}}},
)
await setup_switch(hass, {CONF_NAME: "Living Room"})
await setup_switch(hass, {CONF_NAME: "Bedroom"})
await _setup_automation(hass, automation_config)
await hass.services.async_call(
"input_boolean",
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: "input_boolean.sleep_mode"},
blocking=True,
)
await hass.async_block_till_done()
for entity_id in (
"switch.adaptive_lighting_living_room_sleep_mode",
"switch.adaptive_lighting_bedroom_sleep_mode",
):
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_ON
await hass.services.async_call(
"input_boolean",
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: "input_boolean.sleep_mode"},
blocking=True,
)
await hass.async_block_till_done()
for entity_id in (
"switch.adaptive_lighting_living_room_sleep_mode",
"switch.adaptive_lighting_bedroom_sleep_mode",
):
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_OFF
async def test_sleep_toggle_applies_restored_state_at_startup(
hass: HomeAssistant,
) -> None:
"""Verify startup applies the input boolean's restored state."""
_prepare_hass_startup(hass)
summary = (
'Toggle multiple Adaptive Lighting switches to "sleep mode" using an '
"<code>input_boolean.sleep_mode</code>."
)
automation_config = _yaml_documents(summary)[0]
assert await async_setup_component(
hass,
"input_boolean",
{"input_boolean": {"sleep_mode": {}}},
)
await setup_switch(hass, {CONF_NAME: "Living Room"})
await hass.services.async_call(
"input_boolean",
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: "input_boolean.sleep_mode"},
blocking=True,
)
await _setup_automation(hass, automation_config)
await hass.async_start()
await hass.async_block_till_done()
state = hass.states.get("switch.adaptive_lighting_living_room_sleep_mode")
assert state is not None
assert state.state == STATE_ON
async def test_alarm_script_updates_its_profile_once(
hass: HomeAssistant,
freezer,
) -> None:
"""Execute the alarm script and verify one profile update with a 12-hour day."""
summary = "Set sunrise and sunset from an alarm."
script_config = _yaml_documents(summary)[0]
freezer.move_to(
datetime(2026, 11, 1, 0, 30, tzinfo=dt_util.DEFAULT_TIME_ZONE),
)
_, adaptive_switch = await setup_switch(hass, {CONF_NAME: "Alarm Lights"})
await _setup_script(hass, script_config)
calls = []
def record_service_call(event) -> None:
if (
event.data["domain"] == DOMAIN
and event.data["service"] == "change_switch_settings"
):
calls.append(event)
hass.bus.async_listen(EVENT_CALL_SERVICE, record_service_call)
await hass.services.async_call(
script.DOMAIN,
"set_adaptive_lighting_alarm_times",
blocking=True,
)
await hass.async_block_till_done()
assert len(calls) == 1
sunrise = adaptive_switch._sun_light_settings.sunrise_time
sunset = adaptive_switch._sun_light_settings.sunset_time
assert sunrise is not None
assert sunset is not None
sunrise_seconds = sunrise.hour * 3600 + sunrise.minute * 60 + sunrise.second
sunset_seconds = sunset.hour * 3600 + sunset.minute * 60 + sunset.second
assert (sunset_seconds - sunrise_seconds) % (24 * 3600) == 12 * 3600

View file

@ -1,15 +1,19 @@
import datetime as dt
import zoneinfo
import astral.sun
import pytest
from astral import LocationInfo
from astral.location import Location
from homeassistant.components.adaptive_lighting.color_and_brightness import (
_POLAR_SUN_EVENT_OFFSET,
SunEvent,
SunEvents,
SunLightSettings,
clamp,
)
# Create a mock astral_location object
# Create a mock astral location object (its `.observer` is passed to `SunEvents`)
location = Location(LocationInfo())
LAT_LONG_TZS = [
@ -40,7 +44,7 @@ def test_replace_time(tzinfo_and_location):
tzinfo, location = tzinfo_and_location
sun_events = SunEvents(
name="test",
astral_location=location,
astral_observer=location.observer,
sunrise_time=None,
min_sunrise_time=None,
max_sunrise_time=None,
@ -61,7 +65,7 @@ def test_sunrise_without_offset(tzinfo_and_location):
sun_events = SunEvents(
name="test",
astral_location=location,
astral_observer=location.observer,
sunrise_time=None,
min_sunrise_time=None,
max_sunrise_time=None,
@ -79,7 +83,7 @@ def test_sun_position_no_fixed_sunset_and_sunrise(tzinfo_and_location):
tzinfo, location = tzinfo_and_location
sun_events = SunEvents(
name="test",
astral_location=location,
astral_observer=location.observer,
sunrise_time=None,
min_sunrise_time=None,
max_sunrise_time=None,
@ -107,7 +111,7 @@ def test_sun_position_fixed_sunset_and_sunrise(tzinfo_and_location):
tzinfo, location = tzinfo_and_location
sun_events = SunEvents(
name="test",
astral_location=location,
astral_observer=location.observer,
sunrise_time=dt.time(6, 0),
min_sunrise_time=None,
max_sunrise_time=None,
@ -134,7 +138,7 @@ def test_noon_and_midnight(tzinfo_and_location):
tzinfo, location = tzinfo_and_location
sun_events = SunEvents(
name="test",
astral_location=location,
astral_observer=location.observer,
sunrise_time=None,
min_sunrise_time=None,
max_sunrise_time=None,
@ -153,7 +157,7 @@ def test_sun_events(tzinfo_and_location):
tzinfo, location = tzinfo_and_location
sun_events = SunEvents(
name="test",
astral_location=location,
astral_observer=location.observer,
sunrise_time=None,
min_sunrise_time=None,
max_sunrise_time=None,
@ -173,7 +177,7 @@ def test_prev_and_next_events(tzinfo_and_location):
tzinfo, location = tzinfo_and_location
sun_events = SunEvents(
name="test",
astral_location=location,
astral_observer=location.observer,
sunrise_time=None,
min_sunrise_time=None,
max_sunrise_time=None,
@ -193,7 +197,7 @@ def test_closest_event(tzinfo_and_location):
tzinfo, location = tzinfo_and_location
sun_events = SunEvents(
name="test",
astral_location=location,
astral_observer=location.observer,
sunrise_time=None,
min_sunrise_time=None,
max_sunrise_time=None,
@ -207,3 +211,304 @@ def test_closest_event(tzinfo_and_location):
event_name, ts = sun_events.closest_event(sunrise)
assert event_name == SunEvent.SUNRISE
assert ts == location.sunrise(sunrise.date()).timestamp()
def _make_brightness_settings(
tzinfo,
location,
*,
min_brightness,
max_brightness,
brightness_mode,
):
"""Build a SunLightSettings with only the fields brightness_pct() needs."""
return SunLightSettings(
name="test",
astral_observer=location.observer,
adapt_until_sleep=False,
max_brightness=max_brightness,
max_color_temp=6500,
min_brightness=min_brightness,
min_color_temp=2000,
sleep_brightness=1,
sleep_rgb_or_color_temp="color_temp",
sleep_color_temp=2000,
sleep_rgb_color=(255, 56, 0),
sunrise_time=None,
min_sunrise_time=None,
max_sunrise_time=None,
sunset_time=None,
min_sunset_time=None,
max_sunset_time=None,
brightness_mode_time_dark=dt.timedelta(minutes=30),
brightness_mode_time_light=dt.timedelta(minutes=30),
brightness_mode=brightness_mode,
timezone=tzinfo,
)
def test_clamp_handles_inverted_bounds():
"""A user can intentionally set min_brightness > max_brightness for an
inverted timescale (#1421, e.g. a porch light that should be brighter at
night than during the day). clamp() must still bound the value between
whichever of the two is actually smaller/larger, not silently collapse
to `minimum` for every input the way `max(minimum, min(value, maximum))`
does when minimum > maximum.
"""
assert clamp(50, 100, 15) == 50
assert clamp(0, 100, 15) == 15
assert clamp(200, 100, 15) == 100
def test_clamp_normal_bounds_unaffected():
"""The ordinary min <= max case must keep behaving exactly as before."""
assert clamp(50, 0, 100) == 50
assert clamp(-10, 0, 100) == 0
assert clamp(150, 0, 100) == 100
@pytest.mark.parametrize("brightness_mode", ["linear", "tanh"])
def test_brightness_pct_varies_with_inverted_brightness_bounds(
tzinfo_and_location,
brightness_mode,
):
"""#1421: with min_brightness > max_brightness, linear/tanh modes got
stuck returning min_brightness for every sample, because the final
`clamp(brightness, self.min_brightness, self.max_brightness)` call
collapsed to `minimum` regardless of the computed value. Sampling a few
points around sunrise must show the brightness actually move instead of
being pinned to one value.
"""
tzinfo, location = tzinfo_and_location
settings = _make_brightness_settings(
tzinfo,
location,
min_brightness=100,
max_brightness=15,
brightness_mode=brightness_mode,
)
sunrise = location.sunrise(dt.datetime(2022, 6, 1).date())
samples = [
settings.brightness_pct(
sunrise + dt.timedelta(minutes=offset),
is_sleep=False,
)
for offset in (-20, -10, 0, 10, 20)
]
assert len({round(value) for value in samples}) > 1, samples
assert all(15 <= value <= 100 for value in samples), samples
# Tromsø, Norway (69.6°N) has polar night (Nov-Jan) and midnight sun (May-Jul).
TROMSO = Location(
LocationInfo(
name="Tromsø",
region="Norway",
timezone="Europe/Oslo",
latitude=69.6489,
longitude=18.9551,
),
)
POLAR_NIGHT_DATE = dt.date(2026, 1, 7)
MIDNIGHT_SUN_DATE = dt.date(2026, 7, 7)
MCMURDO = Location(
LocationInfo(
name="McMurdo Station",
region="Antarctica",
timezone="Antarctica/McMurdo",
latitude=-77.8419,
longitude=166.6863,
),
)
def _polar_sun_events(location=TROMSO, **kwargs):
defaults = {
"name": "test",
"astral_observer": location.observer,
"sunrise_time": None,
"min_sunrise_time": None,
"max_sunrise_time": None,
"sunset_time": None,
"min_sunset_time": None,
"max_sunset_time": None,
"timezone": zoneinfo.ZoneInfo(location.timezone),
}
return SunEvents(**{**defaults, **kwargs})
def test_polar_night_synthesizes_short_day():
# `astral` cannot compute sunrise/sunset (the sun never rises), see #1485
with pytest.raises(ValueError): # noqa: PT011
astral.sun.sunrise(TROMSO.observer, POLAR_NIGHT_DATE)
sun_events = _polar_sun_events()
noon = astral.sun.noon(TROMSO.observer, POLAR_NIGHT_DATE)
assert sun_events.sunrise(POLAR_NIGHT_DATE) == noon - _POLAR_SUN_EVENT_OFFSET
assert sun_events.sunset(POLAR_NIGHT_DATE) == noon + _POLAR_SUN_EVENT_OFFSET
def test_midnight_sun_synthesizes_short_night():
# `astral` cannot compute sunrise/sunset (the sun never sets), see #1485
with pytest.raises(ValueError): # noqa: PT011
astral.sun.sunset(TROMSO.observer, MIDNIGHT_SUN_DATE)
sun_events = _polar_sun_events()
midnight = astral.sun.midnight(TROMSO.observer, MIDNIGHT_SUN_DATE)
next_midnight = astral.sun.midnight(
TROMSO.observer,
MIDNIGHT_SUN_DATE + dt.timedelta(days=1),
)
assert sun_events.sunrise(MIDNIGHT_SUN_DATE) == midnight + _POLAR_SUN_EVENT_OFFSET
assert (
sun_events.sunset(MIDNIGHT_SUN_DATE) == next_midnight - _POLAR_SUN_EVENT_OFFSET
)
@pytest.mark.parametrize(
("date", "midnight_sun"),
[(dt.date(2026, 1, 7), True), (dt.date(2026, 7, 7), False)],
)
def test_polar_fallback_handles_southern_hemisphere(date, midnight_sun):
sun_events = _polar_sun_events(MCMURDO)
noon = astral.sun.noon(MCMURDO.observer, date)
midnight = astral.sun.midnight(MCMURDO.observer, date)
next_midnight = astral.sun.midnight(MCMURDO.observer, date + dt.timedelta(days=1))
if midnight_sun:
assert sun_events.sunrise(date) == midnight + _POLAR_SUN_EVENT_OFFSET
assert sun_events.sunset(date) == next_midnight - _POLAR_SUN_EVENT_OFFSET
else:
assert sun_events.sunrise(date) == noon - _POLAR_SUN_EVENT_OFFSET
assert sun_events.sunset(date) == noon + _POLAR_SUN_EVENT_OFFSET
def test_boundary_day_with_real_sunrise_and_synthetic_sunset():
# At the start of the midnight sun period, `astral` computes a real
# sunrise for this date but raises for sunset (this exact date depends on
# astral's numerics). The synthetic sunset must stay consistent with the
# nearly 24-hour day instead of collapsing into a polar-night day.
date = dt.date(2026, 5, 18)
astral.sun.sunrise(TROMSO.observer, date) # does not raise
with pytest.raises(ValueError): # noqa: PT011
astral.sun.sunset(TROMSO.observer, date)
sun_events = _polar_sun_events()
day_length = sun_events.sunset(date) - sun_events.sunrise(date)
assert day_length > dt.timedelta(hours=22)
@pytest.mark.parametrize("date", [POLAR_NIGHT_DATE, MIDNIGHT_SUN_DATE])
def test_sun_position_on_polar_days(date):
sun_events = _polar_sun_events()
datetime = dt.datetime(date.year, date.month, date.day, tzinfo=dt.timezone.utc)
noon, midnight = sun_events.noon_and_midnight(datetime)
assert sun_events.sun_position(noon) == 1
assert sun_events.sun_position(midnight) == -1
assert sun_events.sun_position(sun_events.sunrise(date)) == 0
assert sun_events.sun_position(sun_events.sunset(date)) == 0
def test_polar_night_min_max_times_shape_the_synthetic_day():
# The (min/max)_(sunrise/sunset)_time options apply on top of the
# synthetic sun events, so users can still shape their schedule.
sun_events = _polar_sun_events(
max_sunrise_time=dt.time(9, 0),
min_sunset_time=dt.time(17, 0),
timezone=dt.timezone.utc,
)
expected_sunrise = dt.datetime(2026, 1, 7, 9, 0, tzinfo=dt.timezone.utc)
expected_sunset = dt.datetime(2026, 1, 7, 17, 0, tzinfo=dt.timezone.utc)
assert sun_events.sunrise(POLAR_NIGHT_DATE) == expected_sunrise
assert sun_events.sunset(POLAR_NIGHT_DATE) == expected_sunset
@pytest.mark.parametrize("date", [POLAR_NIGHT_DATE, MIDNIGHT_SUN_DATE])
@pytest.mark.parametrize(
("sunrise_offset", "sunset_offset"),
[
(dt.timedelta(hours=-20), dt.timedelta(hours=-20)),
(dt.timedelta(hours=-20), dt.timedelta(hours=20)),
(dt.timedelta(hours=20), dt.timedelta(hours=-20)),
(dt.timedelta(hours=20), dt.timedelta(hours=20)),
],
)
def test_polar_offsets_cannot_invert_event_order(
date,
sunrise_offset,
sunset_offset,
):
sun_events = _polar_sun_events(
sunrise_offset=sunrise_offset,
sunset_offset=sunset_offset,
)
events = dict(
sun_events.sun_events(dt.datetime.combine(date, dt.time(), tzinfo=dt.UTC)),
)
midnight = dt.datetime.fromtimestamp(events[SunEvent.MIDNIGHT], tz=dt.UTC)
next_midnight = astral.sun.midnight(TROMSO.observer, date + dt.timedelta(days=1))
noon = dt.datetime.fromtimestamp(events[SunEvent.NOON], tz=dt.UTC)
sunrise = dt.datetime.fromtimestamp(events[SunEvent.SUNRISE], tz=dt.UTC)
sunset = dt.datetime.fromtimestamp(events[SunEvent.SUNSET], tz=dt.UTC)
assert midnight < sunrise < noon < sunset < next_midnight
def test_polar_fallback_applies_offsets_within_solar_anchors():
offset = dt.timedelta(minutes=15)
plain = _polar_sun_events()
shifted = _polar_sun_events(
sunrise_offset=offset,
sunset_offset=offset,
)
assert (
shifted.sunrise(MIDNIGHT_SUN_DATE) - plain.sunrise(MIDNIGHT_SUN_DATE) == offset
)
assert shifted.sunset(MIDNIGHT_SUN_DATE) - plain.sunset(MIDNIGHT_SUN_DATE) == offset
def test_sun_position_all_year_in_polar_region():
# Covers the transitions into and out of polar night and midnight sun;
# `sun_position` internally validates the order of the sun events.
sun_events = _polar_sun_events()
datetime = dt.datetime(2026, 1, 1, tzinfo=dt.timezone.utc)
end = dt.datetime(2027, 1, 1, tzinfo=dt.timezone.utc)
while datetime < end:
position = sun_events.sun_position(datetime)
assert -1 <= position <= 1
datetime += dt.timedelta(hours=8)
@pytest.mark.parametrize("date", [POLAR_NIGHT_DATE, MIDNIGHT_SUN_DATE])
def test_brightness_and_color_on_polar_days(date):
settings = SunLightSettings(
name="test",
astral_observer=TROMSO.observer,
adapt_until_sleep=False,
max_brightness=100,
max_color_temp=5500,
min_brightness=30,
min_color_temp=2000,
sleep_brightness=1,
sleep_rgb_or_color_temp="color_temp",
sleep_color_temp=1000,
sleep_rgb_color=(255, 56, 0),
sunrise_time=None,
min_sunrise_time=None,
max_sunrise_time=None,
sunset_time=None,
min_sunset_time=None,
max_sunset_time=None,
brightness_mode_time_dark=dt.timedelta(hours=1),
brightness_mode_time_light=dt.timedelta(hours=1),
timezone=zoneinfo.ZoneInfo("Europe/Oslo"),
)
datetime = dt.datetime(date.year, date.month, date.day, tzinfo=dt.timezone.utc)
noon, midnight = settings.sun.noon_and_midnight(datetime)
at_noon = settings.brightness_and_color(noon, is_sleep=False)
assert at_noon["brightness_pct"] == 100
assert at_noon["color_temp_kelvin"] == 5500
at_midnight = settings.brightness_and_color(midnight, is_sleep=False)
assert at_midnight["brightness_pct"] == 30
assert at_midnight["color_temp_kelvin"] == 2000

View file

@ -1,6 +1,17 @@
"""Test Adaptive Lighting config flow."""
import json
import pytest
import voluptuous as vol
try:
from probatio import to_field_list
except ImportError:
from voluptuous_serialize import convert as to_field_list
from homeassistant.components.adaptive_lighting.const import (
BASIC_OPTIONS,
CONF_INITIAL_TRANSITION,
CONF_SUNRISE_TIME,
CONF_SUNSET_TIME,
DEFAULT_NAME,
@ -10,12 +21,34 @@ from homeassistant.components.adaptive_lighting.const import (
)
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import CONF_NAME
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.data_entry_flow import FlowResultType, section
from homeassistant.helpers import config_validation as cv
from tests.common import MockConfigEntry
DEFAULT_DATA = {key: default for key, default, _ in VALIDATION_TUPLES}
# Split DEFAULT_DATA into basic and advanced for section-based input
BASIC_DATA = {key: value for key, value in DEFAULT_DATA.items() if key in BASIC_OPTIONS}
ADVANCED_DATA = {
key: value for key, value in DEFAULT_DATA.items() if key not in BASIC_OPTIONS
}
def _schema_defaults(schema: vol.Schema) -> dict[str, object]:
"""Return the defaults from a voluptuous schema."""
return {
key.schema: key.default() if callable(key.default) else key.default
for key in schema.schema
}
def _advanced_section(result) -> section:
"""Return the advanced options section from a flow result."""
advanced = result["data_schema"].schema["advanced"]
assert isinstance(advanced, section)
return advanced
async def test_flow_manual_configuration(hass):
"""Test that config flow works."""
@ -53,7 +86,7 @@ async def test_import_success(hass):
async def test_options(hass):
"""Test updating options."""
"""Test updating options with collapsible sections."""
entry = MockConfigEntry(
domain=DOMAIN,
title=DEFAULT_NAME,
@ -68,20 +101,79 @@ async def test_options(hass):
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "init"
data = DEFAULT_DATA.copy()
data[CONF_SUNRISE_TIME] = NONE_STR
data[CONF_SUNSET_TIME] = NONE_STR
# Build input with advanced options nested in "advanced" section
advanced_data = ADVANCED_DATA.copy()
advanced_data[CONF_INITIAL_TRANSITION] = 23
advanced_data[CONF_SUNRISE_TIME] = NONE_STR
advanced_data[CONF_SUNSET_TIME] = NONE_STR
basic_data = {**BASIC_DATA, "min_brightness": 12}
user_input = {
**basic_data,
"advanced": advanced_data,
}
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input=data,
user_input=user_input,
)
assert result["type"] == FlowResultType.CREATE_ENTRY
for key, value in data.items():
# Verify flattened data is saved correctly
expected_data = {**basic_data, **advanced_data}
for key, value in expected_data.items():
assert result["data"][key] == value
assert "advanced" not in result["data"]
async def test_incorrect_options(hass):
"""Test updating incorrect options."""
# Starting the flow again must load the saved flat options into both parts
# of the sectioned form.
result = await hass.config_entries.options.async_init(entry.entry_id)
assert _schema_defaults(result["data_schema"])["min_brightness"] == 12
assert (
_schema_defaults(_advanced_section(result).schema)[CONF_INITIAL_TRANSITION]
== 23
)
async def test_options_schema_has_each_setting_once(hass):
"""Test that basic and advanced options partition all settings."""
entry = MockConfigEntry(
domain=DOMAIN,
title=DEFAULT_NAME,
data={CONF_NAME: DEFAULT_NAME, "interval": 120, "min_brightness": 7},
options={"min_brightness": 12},
)
entry.add_to_hass(hass)
result = await hass.config_entries.options.async_init(entry.entry_id)
schema = result["data_schema"].schema
advanced = _advanced_section(result)
assert advanced.options == {"collapsed": True}
assert {key.schema for key in schema if key.schema != "advanced"} == BASIC_OPTIONS
assert {key.schema for key in advanced.schema.schema} == set(
DEFAULT_DATA,
) - BASIC_OPTIONS
assert _schema_defaults(result["data_schema"])["interval"] == 120
assert _schema_defaults(result["data_schema"])["min_brightness"] == 12
serialized_schema = to_field_list(
result["data_schema"],
custom_serializer=cv.custom_serializer,
)
json.dumps(serialized_schema)
serialized_advanced = next(
field for field in serialized_schema if field["name"] == "advanced"
)
assert serialized_advanced["type"] == "expandable"
assert serialized_advanced["expanded"] is False
assert {field["name"] for field in serialized_advanced["schema"]} == set(
DEFAULT_DATA,
) - BASIC_OPTIONS
@pytest.mark.parametrize("lights", [[], ["light.missing"]])
async def test_incorrect_options(hass, lights):
"""Test updating incorrect options in advanced section."""
entry = MockConfigEntry(
domain=DOMAIN,
title=DEFAULT_NAME,
@ -93,12 +185,30 @@ async def test_incorrect_options(hass):
await hass.config_entries.async_setup(entry.entry_id)
result = await hass.config_entries.options.async_init(entry.entry_id)
data = DEFAULT_DATA.copy()
data[CONF_SUNRISE_TIME] = "yolo"
data[CONF_SUNSET_TIME] = "yolo"
# Build input with invalid advanced options nested in section
advanced_data = ADVANCED_DATA.copy()
advanced_data[CONF_SUNRISE_TIME] = "yolo"
advanced_data[CONF_SUNSET_TIME] = "yolo"
basic_data = {**BASIC_DATA, "min_brightness": 12, "lights": lights}
user_input = {
**basic_data,
"advanced": advanced_data,
}
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input=data,
user_input=user_input,
)
# Should show form with errors
assert result["type"] == FlowResultType.FORM
expected_errors = {"base": "option_error"}
if lights:
expected_errors["lights"] = "entity_missing"
assert result["errors"] == expected_errors
assert _schema_defaults(result["data_schema"])["lights"] == lights
assert _schema_defaults(result["data_schema"])["min_brightness"] == 12
assert (
_schema_defaults(_advanced_section(result).schema)[CONF_SUNRISE_TIME] == "yolo"
)
@ -145,6 +255,10 @@ async def test_options_flow_for_yaml_import(hass):
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "init"
assert result.get("data_schema") is None
assert result["description_placeholders"] == {
"docs_url": "https://github.com/basnijholt/adaptive-lighting#readme",
"webapp_url": "https://basnijholt.github.io/adaptive-lighting",
}
async def test_menu_shown_when_entries_exist(hass):

View file

@ -1,12 +1,21 @@
"""Tests for Adaptive Lighting integration."""
import pytest
import voluptuous.error
from homeassistant.components import adaptive_lighting
from homeassistant.components.adaptive_lighting.const import (
CONF_LIGHTS,
DEFAULT_NAME,
SERVICE_APPLY,
SERVICE_CHANGE_SWITCH_SETTINGS,
SERVICE_SET_MANUAL_CONTROL,
UNDO_UPDATE_LISTENER,
)
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_NAME
from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import service
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
@ -53,3 +62,126 @@ async def test_unload_entry(hass):
assert entry.state == ConfigEntryState.NOT_LOADED
assert adaptive_lighting.DOMAIN not in hass.data
async def test_services_survive_entry_unload_and_reload(hass):
"""Test integration services remain registered across entry lifecycle."""
assert await async_setup_component(hass, adaptive_lighting.DOMAIN, {})
service_names = (
SERVICE_APPLY,
SERVICE_CHANGE_SWITCH_SETTINGS,
SERVICE_SET_MANUAL_CONTROL,
)
services = hass.services.async_services()[adaptive_lighting.DOMAIN]
assert SERVICE_APPLY in services
assert SERVICE_SET_MANUAL_CONTROL in services
if hasattr(service, "async_register_platform_entity_service"):
assert SERVICE_CHANGE_SWITCH_SETTINGS in services
else:
assert SERVICE_CHANGE_SWITCH_SETTINGS not in services
entry = MockConfigEntry(
domain=adaptive_lighting.DOMAIN,
data={CONF_NAME: DEFAULT_NAME},
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
registered = {
name: hass.services.async_services()[adaptive_lighting.DOMAIN][name]
for name in service_names
}
switch = hass.data[adaptive_lighting.DOMAIN][entry.entry_id][SWITCH_DOMAIN]
assert await hass.config_entries.async_unload(entry.entry_id)
for name in service_names:
assert (
hass.services.async_services()[adaptive_lighting.DOMAIN][name]
is registered[name]
)
with pytest.raises(ServiceValidationError, match="No Adaptive Lighting"):
await hass.services.async_call(
adaptive_lighting.DOMAIN,
SERVICE_APPLY,
{ATTR_ENTITY_ID: switch.entity_id},
blocking=True,
)
assert await hass.config_entries.async_setup(entry.entry_id)
for name in service_names:
assert (
hass.services.async_services()[adaptive_lighting.DOMAIN][name]
is registered[name]
)
await hass.services.async_call(
adaptive_lighting.DOMAIN,
SERVICE_CHANGE_SWITCH_SETTINGS,
{ATTR_ENTITY_ID: switch.entity_id},
blocking=True,
)
async def test_service_call_without_loaded_entry(hass):
"""Test global services reject calls when no profile is loaded."""
assert await async_setup_component(hass, adaptive_lighting.DOMAIN, {})
with pytest.raises(ServiceValidationError, match="No Adaptive Lighting"):
await hass.services.async_call(
adaptive_lighting.DOMAIN,
SERVICE_APPLY,
{CONF_LIGHTS: ["light.test"]},
blocking=True,
)
pending_entry = MockConfigEntry(
domain=adaptive_lighting.DOMAIN,
data={CONF_NAME: "pending"},
)
pending_entry.add_to_hass(hass)
hass.data[adaptive_lighting.DOMAIN] = {pending_entry.entry_id: {}}
with pytest.raises(ServiceValidationError, match="not found in any switch"):
await hass.services.async_call(
adaptive_lighting.DOMAIN,
SERVICE_APPLY,
{CONF_LIGHTS: ["light.test"]},
blocking=True,
)
async def test_apply_rejects_unknown_light(hass):
"""Test the apply service rejects an unknown light target."""
entry = MockConfigEntry(
domain=adaptive_lighting.DOMAIN,
data={CONF_NAME: DEFAULT_NAME},
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
with pytest.raises(ServiceValidationError, match="not found in any switch"):
await hass.services.async_call(
adaptive_lighting.DOMAIN,
SERVICE_APPLY,
{CONF_LIGHTS: ["light.does_not_exist"]},
blocking=True,
)
async def test_change_switch_settings_requires_entity_target(hass):
"""Test change_switch_settings rejects a missing entity target."""
entry = MockConfigEntry(
domain=adaptive_lighting.DOMAIN,
data={CONF_NAME: DEFAULT_NAME},
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
with pytest.raises(
voluptuous.error.MultipleInvalid,
match=r"must contain at least one of entity_id.*area_id",
):
await hass.services.async_call(
adaptive_lighting.DOMAIN,
SERVICE_CHANGE_SWITCH_SETTINGS,
{},
blocking=True,
)

File diff suppressed because it is too large Load diff