mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-09-28 21:04:19 +02:00
Add validated blueprints for common automation examples (#1577)
* Add minimum brightness automation and blueprint * Ignore independent profile event order in test * Add tested blueprints for sleep, schedules, and daylight
This commit is contained in:
parent
e2a3aae416
commit
f7b50b12d9
8 changed files with 772 additions and 10 deletions
|
|
@ -4,6 +4,7 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
import re
|
||||
import shutil
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
|
@ -16,6 +17,9 @@ from homeassistant.components.adaptive_lighting.adaptation_utils import (
|
|||
LightControlAttributes,
|
||||
)
|
||||
from homeassistant.components.adaptive_lighting.const import (
|
||||
CONF_BRIGHTNESS_MODE,
|
||||
CONF_BRIGHTNESS_MODE_TIME_DARK,
|
||||
CONF_BRIGHTNESS_MODE_TIME_LIGHT,
|
||||
CONF_INITIAL_TRANSITION,
|
||||
CONF_LIGHTS,
|
||||
CONF_MAX_BRIGHTNESS,
|
||||
|
|
@ -31,6 +35,7 @@ from homeassistant.components.adaptive_lighting.const import (
|
|||
CONF_TRANSITION,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.components.blueprint.models import Blueprint
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_COLOR_TEMP_KELVIN,
|
||||
|
|
@ -52,6 +57,7 @@ from homeassistant.const import (
|
|||
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 homeassistant.util import yaml as yaml_util
|
||||
|
||||
from tests.common import async_fire_time_changed
|
||||
|
||||
|
|
@ -146,12 +152,247 @@ def _prepare_hass_startup(hass: HomeAssistant) -> None:
|
|||
hass.set_state(CoreState.not_running)
|
||||
|
||||
|
||||
def _blueprint_config(hass, tmp_path, filename, inputs, alias):
|
||||
"""Install an actual published blueprint for Home Assistant to load."""
|
||||
relative_path = f"adaptive_lighting/{filename}"
|
||||
hass.config.config_dir = str(tmp_path)
|
||||
destination = tmp_path / "blueprints" / "automation" / relative_path
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(README.parent / "blueprints" / "automation" / filename, destination)
|
||||
return {
|
||||
"alias": alias,
|
||||
"use_blueprint": {"path": relative_path, "input": inputs},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(params=["yaml", "blueprint"])
|
||||
def published_automation(hass: HomeAssistant, tmp_path: Path, request):
|
||||
"""Use the published YAML or blueprint with the same behavioral assertions."""
|
||||
|
||||
def config(summary, filename, inputs):
|
||||
yaml_config = _yaml_documents(summary)[-1]
|
||||
if request.param == "yaml":
|
||||
return yaml_config
|
||||
return _blueprint_config(
|
||||
hass,
|
||||
tmp_path,
|
||||
filename,
|
||||
inputs,
|
||||
yaml_config[0]["alias"],
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
sorted((README.parent / "blueprints" / "automation").glob("*.yaml")),
|
||||
ids=lambda path: path.stem,
|
||||
)
|
||||
def test_published_blueprint_schema(path: Path) -> None:
|
||||
"""Validate every published blueprint with Home Assistant's own schema."""
|
||||
blueprint = Blueprint(
|
||||
yaml_util.load_yaml(str(path)),
|
||||
expected_domain=automation.DOMAIN,
|
||||
schema=automation.config.AUTOMATION_BLUEPRINT_SCHEMA,
|
||||
)
|
||||
assert blueprint.validate() is None
|
||||
|
||||
|
||||
@pytest.fixture(params=["yaml", "blueprint", "blueprint-custom-minimum"])
|
||||
def minimum_automation_config(hass: HomeAssistant, tmp_path: Path, request):
|
||||
"""Run the same behavior checks against both published formats."""
|
||||
if request.param == "yaml":
|
||||
return _yaml_documents(
|
||||
"Turn a light off when its adaptive brightness target reaches the minimum.",
|
||||
)[0]
|
||||
inputs = {
|
||||
"adaptive_switch": "switch.adaptive_lighting_living_room",
|
||||
"brightness_switch": "switch.adaptive_lighting_living_room_adapt_brightness",
|
||||
"light_entity": "light.living_room",
|
||||
}
|
||||
if request.param == "blueprint-custom-minimum":
|
||||
inputs["minimum_pct"] = 10
|
||||
return _blueprint_config(
|
||||
hass,
|
||||
tmp_path,
|
||||
"turn_off_at_minimum.yaml",
|
||||
inputs,
|
||||
"Turn off at minimum",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("manual_control", [False, True])
|
||||
@pytest.mark.parametrize("trigger_kind", ["interval", "sleep"])
|
||||
@patch(
|
||||
"homeassistant.components.adaptive_lighting.color_and_brightness.utcnow",
|
||||
new=dt_util.utcnow,
|
||||
)
|
||||
async def test_minimum_brightness_power_automation(
|
||||
hass: HomeAssistant,
|
||||
freezer,
|
||||
manual_control: bool,
|
||||
trigger_kind: str,
|
||||
minimum_automation_config,
|
||||
) -> None:
|
||||
"""Catch exact-float comparisons, repeated power actions, or lost manual control."""
|
||||
minimum = (
|
||||
minimum_automation_config.get("use_blueprint", {})
|
||||
.get("input", {})
|
||||
.get("minimum_pct", 1)
|
||||
if isinstance(minimum_automation_config, dict)
|
||||
else 1
|
||||
)
|
||||
freezer.move_to(datetime(2026, 9, 6, 18, 58, tzinfo=dt_util.DEFAULT_TIME_ZONE))
|
||||
await _setup_template_lights(hass, ["Living Room"])
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: "light.living_room", ATTR_BRIGHTNESS: 77},
|
||||
blocking=True,
|
||||
)
|
||||
_, adaptive_switch = await setup_switch(
|
||||
hass,
|
||||
{
|
||||
CONF_NAME: "Living Room",
|
||||
CONF_LIGHTS: ["light.living_room"],
|
||||
CONF_MIN_BRIGHTNESS: minimum,
|
||||
CONF_MAX_BRIGHTNESS: 100,
|
||||
CONF_BRIGHTNESS_MODE: "linear",
|
||||
CONF_BRIGHTNESS_MODE_TIME_DARK: timedelta(hours=1),
|
||||
CONF_BRIGHTNESS_MODE_TIME_LIGHT: timedelta(hours=1),
|
||||
CONF_SUNRISE_TIME: "06:00:00",
|
||||
CONF_SUNSET_TIME: "18:00:00",
|
||||
CONF_TRANSITION: 0,
|
||||
CONF_INITIAL_TRANSITION: 0,
|
||||
},
|
||||
)
|
||||
if manual_control:
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
"set_manual_control",
|
||||
{ATTR_ENTITY_ID: adaptive_switch.entity_id, "manual_control": True},
|
||||
blocking=True,
|
||||
)
|
||||
await _setup_automation(hass, minimum_automation_config)
|
||||
off_calls = []
|
||||
|
||||
@callback
|
||||
def record_off(event: Event) -> None:
|
||||
if (
|
||||
event.data["domain"] == LIGHT_DOMAIN
|
||||
and event.data["service"] == SERVICE_TURN_OFF
|
||||
):
|
||||
off_calls.append(event.data["service_data"])
|
||||
|
||||
hass.bus.async_listen(EVENT_CALL_SERVICE, record_off)
|
||||
assert hass.states.get("light.living_room").state == STATE_ON
|
||||
assert adaptive_switch.extra_state_attributes["brightness_pct"] > minimum + 1
|
||||
|
||||
# The curve is above the minimum, but rounds to the same brightness command.
|
||||
freezer.move_to(datetime(2026, 9, 6, 18, 59, 50, tzinfo=dt_util.DEFAULT_TIME_ZONE))
|
||||
if trigger_kind == "sleep":
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: "switch.adaptive_lighting_living_room_sleep_mode"},
|
||||
blocking=True,
|
||||
)
|
||||
else:
|
||||
await adaptive_switch._async_update_at_interval_action()
|
||||
await hass.async_block_till_done()
|
||||
if trigger_kind == "sleep":
|
||||
assert adaptive_switch.extra_state_attributes["brightness_pct"] == 1
|
||||
else:
|
||||
assert (
|
||||
minimum
|
||||
< adaptive_switch.extra_state_attributes["brightness_pct"]
|
||||
< minimum + 0.2
|
||||
)
|
||||
# The default sleep-mode policy clears manual control before publishing its target.
|
||||
should_turn_off = not manual_control or trigger_kind == "sleep"
|
||||
assert hass.states.get("light.living_room").state == (
|
||||
STATE_OFF if should_turn_off else STATE_ON
|
||||
)
|
||||
assert len(off_calls) == int(should_turn_off)
|
||||
|
||||
# Further target changes inside the minimum command range do not retrigger.
|
||||
freezer.move_to(datetime(2026, 9, 6, 19, 1, tzinfo=dt_util.DEFAULT_TIME_ZONE))
|
||||
await adaptive_switch._async_update_at_interval_action()
|
||||
await hass.async_block_till_done()
|
||||
assert adaptive_switch.extra_state_attributes["brightness_pct"] == (
|
||||
1 if trigger_kind == "sleep" else minimum
|
||||
)
|
||||
assert len(off_calls) == int(should_turn_off)
|
||||
|
||||
if should_turn_off:
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: "light.living_room"},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get("light.living_room").state == STATE_ON
|
||||
assert len(off_calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("previous", [None, "unknown", "unavailable"])
|
||||
async def test_minimum_brightness_ignores_missing_previous_target(
|
||||
hass: HomeAssistant,
|
||||
previous: str | None,
|
||||
minimum_automation_config,
|
||||
) -> None:
|
||||
"""A missing target must not become a numeric crossing during recovery."""
|
||||
await _setup_template_lights(hass, ["Living Room"])
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: "light.living_room"},
|
||||
blocking=True,
|
||||
)
|
||||
_, adaptive_switch = await setup_switch(
|
||||
hass,
|
||||
{
|
||||
CONF_NAME: "Living Room",
|
||||
CONF_LIGHTS: ["light.living_room"],
|
||||
CONF_MIN_BRIGHTNESS: 1,
|
||||
CONF_MAX_BRIGHTNESS: 1,
|
||||
CONF_TRANSITION: 0,
|
||||
CONF_INITIAL_TRANSITION: 0,
|
||||
},
|
||||
)
|
||||
await _setup_automation(hass, minimum_automation_config)
|
||||
attributes = dict(hass.states.get(adaptive_switch.entity_id).attributes)
|
||||
assert attributes["brightness_pct"] == 1
|
||||
if previous is None:
|
||||
hass.states.async_remove(adaptive_switch.entity_id)
|
||||
else:
|
||||
hass.states.async_set(
|
||||
adaptive_switch.entity_id,
|
||||
previous,
|
||||
{**attributes, "brightness_pct": previous},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
hass.states.async_set(adaptive_switch.entity_id, STATE_ON, attributes)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get("light.living_room").state == STATE_ON
|
||||
|
||||
|
||||
async def test_schedule_profile_executes_blocks_and_restore(
|
||||
hass: HomeAssistant,
|
||||
published_automation,
|
||||
) -> 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]
|
||||
automation_config = published_automation(
|
||||
summary,
|
||||
"schedule_profile.yaml",
|
||||
{
|
||||
"adaptive_switch": "switch.adaptive_lighting_living_room",
|
||||
"schedule_entity": "schedule.adaptive_lighting_profile",
|
||||
},
|
||||
)
|
||||
_, adaptive_switch = await setup_switch(
|
||||
hass,
|
||||
{
|
||||
|
|
@ -220,11 +461,21 @@ async def test_schedule_profile_executes_blocks_and_restore(
|
|||
assert adaptive_switch._sun_light_settings.max_color_temp == 2750
|
||||
|
||||
|
||||
async def test_schedule_profile_reapplies_at_startup(hass: HomeAssistant) -> None:
|
||||
async def test_schedule_profile_reapplies_at_startup(
|
||||
hass: HomeAssistant,
|
||||
published_automation,
|
||||
) -> 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]
|
||||
automation_config = published_automation(
|
||||
summary,
|
||||
"schedule_profile.yaml",
|
||||
{
|
||||
"adaptive_switch": "switch.adaptive_lighting_living_room",
|
||||
"schedule_entity": "schedule.adaptive_lighting_profile",
|
||||
},
|
||||
)
|
||||
_, adaptive_switch = await setup_switch(hass, {CONF_NAME: "Living Room"})
|
||||
hass.states.async_set(
|
||||
"schedule.adaptive_lighting_profile",
|
||||
|
|
@ -241,12 +492,22 @@ async def test_schedule_profile_reapplies_at_startup(hass: HomeAssistant) -> Non
|
|||
assert adaptive_switch._sun_light_settings.max_color_temp == 2500
|
||||
|
||||
|
||||
async def test_lux_profile_executes_hysteresis(hass: HomeAssistant) -> None:
|
||||
async def test_lux_profile_executes_hysteresis(
|
||||
hass: HomeAssistant,
|
||||
published_automation,
|
||||
) -> 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]
|
||||
automation_config = published_automation(
|
||||
summary,
|
||||
"daylight_limit.yaml",
|
||||
{
|
||||
"adaptive_switch": "switch.adaptive_lighting_living_room",
|
||||
"illuminance_sensor": "sensor.living_room_illuminance",
|
||||
},
|
||||
)
|
||||
_, adaptive_switch = await setup_switch(
|
||||
hass,
|
||||
{CONF_NAME: "Living Room", CONF_MAX_BRIGHTNESS: 80},
|
||||
|
|
@ -273,13 +534,21 @@ async def test_lux_profile_executes_hysteresis(hass: HomeAssistant) -> None:
|
|||
|
||||
async def test_lux_profile_executes_unknown_recovery_at_startup(
|
||||
hass: HomeAssistant,
|
||||
published_automation,
|
||||
) -> 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]
|
||||
automation_config = published_automation(
|
||||
summary,
|
||||
"daylight_limit.yaml",
|
||||
{
|
||||
"adaptive_switch": "switch.adaptive_lighting_living_room",
|
||||
"illuminance_sensor": "sensor.living_room_illuminance",
|
||||
},
|
||||
)
|
||||
_, adaptive_switch = await setup_switch(
|
||||
hass,
|
||||
{CONF_NAME: "Living Room", CONF_MAX_BRIGHTNESS: 80},
|
||||
|
|
@ -302,6 +571,44 @@ async def test_lux_profile_executes_unknown_recovery_at_startup(
|
|||
assert adaptive_switch._sun_light_settings.max_brightness == 30
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("high_lux", "low_lux"), [(400, 250), (200, 300), (200, 200)])
|
||||
async def test_daylight_blueprint_custom_inputs(
|
||||
hass: HomeAssistant,
|
||||
tmp_path: Path,
|
||||
high_lux: int,
|
||||
low_lux: int,
|
||||
) -> None:
|
||||
"""Use selected entities and limits; invalid threshold order must do nothing."""
|
||||
config = _blueprint_config(
|
||||
hass,
|
||||
tmp_path,
|
||||
"daylight_limit.yaml",
|
||||
{
|
||||
"adaptive_switch": "switch.adaptive_lighting_office",
|
||||
"illuminance_sensor": "sensor.office_illuminance",
|
||||
"high_lux": high_lux,
|
||||
"low_lux": low_lux,
|
||||
"daylight_maximum": 20,
|
||||
"normal_maximum": 70,
|
||||
},
|
||||
"Custom daylight",
|
||||
)
|
||||
_, adaptive_switch = await setup_switch(
|
||||
hass,
|
||||
{CONF_NAME: "Office", CONF_MAX_BRIGHTNESS: 80},
|
||||
)
|
||||
hass.states.async_set("sensor.office_illuminance", "300")
|
||||
await _setup_automation(hass, config)
|
||||
assert hass.states.get("automation.custom_daylight") is not None
|
||||
valid_thresholds = high_lux > low_lux
|
||||
for lux, expected in [(500, 20), (300, 20), (100, 70)]:
|
||||
hass.states.async_set("sensor.office_illuminance", str(lux))
|
||||
await hass.async_block_till_done()
|
||||
assert adaptive_switch._sun_light_settings.max_brightness == (
|
||||
expected if valid_thresholds else 80
|
||||
)
|
||||
|
||||
|
||||
async def test_hue_script_applies_current_values_to_fresh_profile_targets(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
|
|
@ -574,13 +881,24 @@ async def test_autoreset_manual_control_uses_one_renewable_timer(
|
|||
|
||||
async def test_sleep_toggle_uses_fresh_profile_entity_ids(
|
||||
hass: HomeAssistant,
|
||||
published_automation,
|
||||
) -> 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]
|
||||
automation_config = published_automation(
|
||||
summary,
|
||||
"sleep_mode.yaml",
|
||||
{
|
||||
"sleep_helper": "input_boolean.sleep_mode",
|
||||
"sleep_switches": [
|
||||
"switch.adaptive_lighting_living_room_sleep_mode",
|
||||
"switch.adaptive_lighting_bedroom_sleep_mode",
|
||||
],
|
||||
},
|
||||
)
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
"input_boolean",
|
||||
|
|
@ -623,6 +941,7 @@ async def test_sleep_toggle_uses_fresh_profile_entity_ids(
|
|||
|
||||
async def test_sleep_toggle_applies_restored_state_at_startup(
|
||||
hass: HomeAssistant,
|
||||
published_automation,
|
||||
) -> None:
|
||||
"""Verify startup applies the input boolean's restored state."""
|
||||
_prepare_hass_startup(hass)
|
||||
|
|
@ -630,13 +949,24 @@ async def test_sleep_toggle_applies_restored_state_at_startup(
|
|||
'Toggle multiple Adaptive Lighting switches to "sleep mode" using an '
|
||||
"<code>input_boolean.sleep_mode</code>."
|
||||
)
|
||||
automation_config = _yaml_documents(summary)[0]
|
||||
automation_config = published_automation(
|
||||
summary,
|
||||
"sleep_mode.yaml",
|
||||
{
|
||||
"sleep_helper": "input_boolean.sleep_mode",
|
||||
"sleep_switches": [
|
||||
"switch.adaptive_lighting_living_room_sleep_mode",
|
||||
"switch.adaptive_lighting_bedroom_sleep_mode",
|
||||
],
|
||||
},
|
||||
)
|
||||
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 hass.services.async_call(
|
||||
"input_boolean",
|
||||
SERVICE_TURN_ON,
|
||||
|
|
|
|||
|
|
@ -1891,9 +1891,10 @@ async def test_shared_profiles_track_manual_brightness(
|
|||
hass.states.get(ENTITY_LIGHT_1).attributes[ATTR_BRIGHTNESS]
|
||||
== expected_brightness
|
||||
)
|
||||
assert [event.data[SWITCH_DOMAIN] for event in events] == [
|
||||
# Independent profiles may publish their events in either order.
|
||||
assert sorted(event.data[SWITCH_DOMAIN] for event in events) == sorted(
|
||||
profiles[name].entity_id for name in event_profiles
|
||||
]
|
||||
)
|
||||
assert all(event.context == context for event in events)
|
||||
assert all(
|
||||
event.data[CONF_MANUAL_CONTROL] == LightControlAttributes.BRIGHTNESS
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue