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:
Bas Nijholt 2026-09-06 19:59:56 +02:00 • committed by GitHub
commit f7b50b12d9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 772 additions and 10 deletions

View file

@ -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,

View file

@ -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