From f7b50b12d94be7c921e42f40fe03a67b7a264ecf Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 6 Sep 2026 19:59:56 +0200 Subject: [PATCH] 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 --- README.md | 67 ++++ blueprints/automation/daylight_limit.yaml | 112 ++++++ blueprints/automation/schedule_profile.yaml | 57 +++ blueprints/automation/sleep_mode.yaml | 41 +++ .../automation/turn_off_at_minimum.yaml | 87 +++++ docs/automation-examples.md | 67 ++++ tests/test_automation_examples.py | 346 +++++++++++++++++- tests/test_switch.py | 5 +- 8 files changed, 772 insertions(+), 10 deletions(-) create mode 100644 blueprints/automation/daylight_limit.yaml create mode 100644 blueprints/automation/schedule_profile.yaml create mode 100644 blueprints/automation/sleep_mode.yaml create mode 100644 blueprints/automation/turn_off_at_minimum.yaml diff --git a/README.md b/README.md index 4b90347a..af016c54 100644 --- a/README.md +++ b/README.md @@ -272,6 +272,17 @@ Replace every entity ID below with the IDs from your Home Assistant instance. Fr Blocks that begin with `- alias` are entries for `automations.yaml`. Blocks with a top-level `script:` or `adaptive_lighting:` key are complete `configuration.yaml` examples. If your configuration uses `script: !include scripts.yaml`, omit that outer key and place its contents in `scripts.yaml`. +Four examples also have blueprints with selectors, so you can configure them without editing YAML: + +| Blueprint | Purpose | +| --- | --- | +| [Sleep mode](https://github.com/basnijholt/adaptive-lighting/blob/main/blueprints/automation/sleep_mode.yaml) | Synchronize several profiles with one sleep-mode helper. | +| [Minimum brightness](https://github.com/basnijholt/adaptive-lighting/blob/main/blueprints/automation/turn_off_at_minimum.yaml) | Turn one light off when its target crosses down to the minimum. | +| [Schedule profile](https://github.com/basnijholt/adaptive-lighting/blob/main/blueprints/automation/schedule_profile.yaml) | Apply brightness and color temperature from Schedule helper blocks. | +| [Daylight limit](https://github.com/basnijholt/adaptive-lighting/blob/main/blueprints/automation/daylight_limit.yaml) | Lower maximum brightness in strong daylight. | + +Copy a blueprint's link into **Settings → Automations & scenes → Blueprints → Import Blueprint**, then create an automation from it. Read the matching example below for setup and behavior. Each blueprint is tested through Home Assistant alongside its YAML example. The built-in manual-control timeout needs no automation; the scripts below remain useful as actions in your own automations. + `change_switch_settings` updates a profile while its main switch is off, but lights are adapted only while that switch is on. It preserves manual-control flags, so manually controlled lights remain paused.
@@ -294,6 +305,8 @@ This is a top-level `configuration.yaml` example. The timer clears manual contro
Toggle multiple Adaptive Lighting switches to "sleep mode" using an input_boolean.sleep_mode. +Also available as a [blueprint](https://github.com/basnijholt/adaptive-lighting/blob/main/blueprints/automation/sleep_mode.yaml). Select an input boolean and the sleep-mode switches it should control. + ```yaml - alias: "Adaptive lighting: toggle 'sleep mode'" trigger: @@ -316,6 +329,56 @@ This is a top-level `configuration.yaml` example. The timer clears manual contro
+
+Turn a light off when its adaptive brightness target reaches the minimum. + +Prefer a form over editing YAML? Import the [blueprint](https://github.com/basnijholt/adaptive-lighting/blob/main/blueprints/automation/turn_off_at_minimum.yaml) in Home Assistant under **Settings → Automations & scenes → Blueprints → Import Blueprint**. Select your profile, its matching adapt brightness switch, one light managed by that profile, and its minimum brightness percentage. Create one automation per light. If you change the profile's minimum later, update the automation too. The blueprint and YAML example below have the same behavior. + +The Adaptive Lighting switch already exposes its calculated `brightness_pct` target. Use its state changes to choose a power policy in an automation; no custom event is needed. This example assumes `min_brightness: 1`. Change `minimum_pct` to match your profile, and replace the switch and light entity IDs with your own. + +The comparison uses the same rounded 0–255 brightness as an adaptation command. Comparing floating-point percentages for exact equality can miss the minimum between updates. This detects the calculated target reaching its minimum command, not the bulb finishing a transition or reaching its physical dimming limit. + +```yaml +- alias: "Adaptive lighting: turn off at minimum brightness" + mode: single + triggers: + - trigger: state + entity_id: switch.adaptive_lighting_living_room + attribute: brightness_pct + conditions: + - condition: state + entity_id: + - switch.adaptive_lighting_living_room + - switch.adaptive_lighting_living_room_adapt_brightness + state: "on" + - condition: template + value_template: >- + {% set minimum_pct = 1 %} + {% set minimum = (minimum_pct * 255 / 100) | round(0) %} + {% set before = trigger.from_state.attributes.get('brightness_pct') + if trigger.from_state else none %} + {% set after = trigger.to_state.attributes.get('brightness_pct') + if trigger.to_state else none %} + {{ is_number(before) and is_number(after) + and (before | float * 255 / 100) | round(0) > minimum + and (after | float * 255 / 100) | round(0) <= minimum }} + - condition: state + entity_id: light.living_room + state: "on" + - condition: template + value_template: >- + {{ 'light.living_room' not in + (state_attr('switch.adaptive_lighting_living_room', 'manual_control') or []) }} + actions: + - action: light.turn_off + target: + entity_id: light.living_room +``` + +This runs once when a valid target crosses down into the minimum range. It skips lights currently marked as manually controlled, does not repeatedly turn them off while the target remains low, and does not turn them back on later. Startup or re-enabling the profile while already at the minimum is not a new crossing. Sleep mode can also cause a crossing if its brightness is at or below the chosen minimum. Changing sleep mode clears manual control by default; set `reset_manual_control_on_sleep_mode_change: false` if you want to preserve it. For a bedtime-only policy, trigger directly on the sleep-mode switch changing to `on` instead. + +
+
Set sunrise and sunset from an alarm. @@ -342,6 +405,8 @@ script:
Use a Schedule helper as a step-based custom lighting profile. +Also available as a [blueprint](https://github.com/basnijholt/adaptive-lighting/blob/main/blueprints/automation/schedule_profile.yaml). Select the main profile switch and your Schedule helper. + Create a [Schedule helper](https://www.home-assistant.io/integrations/schedule/) named `Adaptive Lighting Profile`. Add time blocks with Additional data like this: ```yaml @@ -391,6 +456,8 @@ This creates step changes at block boundaries. It does not interpolate between s
Reduce daytime brightness when an illuminance sensor detects strong daylight. +Also available as a [blueprint](https://github.com/basnijholt/adaptive-lighting/blob/main/blueprints/automation/daylight_limit.yaml). Select the profile and sensor, then set the lux thresholds and brightness limits. The high lux threshold must exceed the low threshold; the blueprint does nothing if they are reversed or equal. + Keep a low configured `min_brightness` for late night and let an automation lower `max_brightness` while the room has ample daylight. Use a sensor that is not significantly affected by the controlled lights to avoid a feedback loop. ```yaml diff --git a/blueprints/automation/daylight_limit.yaml b/blueprints/automation/daylight_limit.yaml new file mode 100644 index 00000000..bc57d434 --- /dev/null +++ b/blueprints/automation/daylight_limit.yaml @@ -0,0 +1,112 @@ +blueprint: + name: "Adaptive Lighting: limit brightness in daylight" + description: >- + Lower a profile's maximum brightness in strong daylight and restore the + chosen normal maximum when daylight falls. Separate lux thresholds prevent + repeated changes near a single threshold. Use a sensor not significantly + affected by the controlled lights. Keep the daylight maximum at or above + the profile's minimum unless you want an inverted brightness curve. + Startup waits up to five minutes for a numeric sensor reading; a reading + between the thresholds leaves the configured maximum unchanged. + domain: automation + homeassistant: + min_version: "2025.9.0" + input: + adaptive_switch: + name: Adaptive Lighting profile + description: Select the main profile switch, not a sleep or adaptation switch. + selector: + entity: + filter: + domain: switch + integration: adaptive_lighting + illuminance_sensor: + name: Illuminance sensor + selector: + entity: + filter: + domain: sensor + device_class: illuminance + high_lux: + name: Strong daylight threshold + description: Must be greater than the low daylight threshold. + default: 300 + selector: + number: + min: 0 + max: 200000 + mode: box + unit_of_measurement: lx + low_lux: + name: Low daylight threshold + default: 200 + selector: + number: + min: 0 + max: 200000 + mode: box + unit_of_measurement: lx + daylight_maximum: + name: Maximum brightness in strong daylight + default: 30 + selector: + number: + min: 1 + max: 100 + mode: box + unit_of_measurement: "%" + normal_maximum: + name: Normal maximum brightness + default: 100 + selector: + number: + min: 1 + max: 100 + mode: box + unit_of_measurement: "%" + +mode: restart +variables: + illuminance_sensor: !input illuminance_sensor + high_lux: !input high_lux + low_lux: !input low_lux +triggers: + - trigger: numeric_state + entity_id: !input illuminance_sensor + above: !input high_lux + - trigger: numeric_state + entity_id: !input illuminance_sensor + below: !input low_lux + - trigger: homeassistant + event: start + id: startup +conditions: + - condition: template + value_template: "{{ high_lux > low_lux }}" +actions: + - if: + - condition: trigger + id: startup + then: + - wait_template: "{{ is_number(states(illuminance_sensor)) }}" + timeout: "00:05:00" + continue_on_timeout: false + - choose: + - conditions: + - condition: numeric_state + entity_id: !input illuminance_sensor + above: !input high_lux + sequence: + - action: adaptive_lighting.change_switch_settings + data: + entity_id: !input adaptive_switch + max_brightness: !input daylight_maximum + - conditions: + - condition: numeric_state + entity_id: !input illuminance_sensor + below: !input low_lux + sequence: + - action: adaptive_lighting.change_switch_settings + data: + entity_id: !input adaptive_switch + max_brightness: !input normal_maximum diff --git a/blueprints/automation/schedule_profile.yaml b/blueprints/automation/schedule_profile.yaml new file mode 100644 index 00000000..64ccf17a --- /dev/null +++ b/blueprints/automation/schedule_profile.yaml @@ -0,0 +1,57 @@ +blueprint: + name: "Adaptive Lighting: scheduled profile" + description: >- + Apply fixed brightness and color temperature from a Schedule helper's + brightness_pct and color_temp_kelvin attributes. Uses step changes, not + interpolation. Missing attributes fall back to 1% and 2000 K. Outside an + active block, restores ALL configured profile settings. Use this only if + other automations do not also change runtime settings on this profile. + Reapplies the active block on Home Assistant startup. Updates settings + while the profile is off without enabling it; preserves manual control. + domain: automation + homeassistant: + min_version: "2025.9.0" + input: + adaptive_switch: + name: Adaptive Lighting profile + description: Select the main profile switch, not a sleep or adaptation switch. + selector: + entity: + filter: + domain: switch + integration: adaptive_lighting + schedule_entity: + name: Schedule helper + description: Add brightness_pct (1–100) and color_temp_kelvin to each block's Additional data. + selector: + entity: + filter: + domain: schedule + +mode: restart +variables: + schedule_entity: !input schedule_entity +triggers: + - trigger: state + entity_id: !input schedule_entity + - trigger: homeassistant + event: start +actions: + - choose: + - conditions: + - condition: state + entity_id: !input schedule_entity + state: "on" + sequence: + - action: adaptive_lighting.change_switch_settings + data: + entity_id: !input adaptive_switch + min_brightness: "{{ state_attr(schedule_entity, 'brightness_pct') | int(1) }}" + max_brightness: "{{ state_attr(schedule_entity, 'brightness_pct') | int(1) }}" + min_color_temp: "{{ state_attr(schedule_entity, 'color_temp_kelvin') | int(2000) }}" + max_color_temp: "{{ state_attr(schedule_entity, 'color_temp_kelvin') | int(2000) }}" + default: + - action: adaptive_lighting.change_switch_settings + data: + entity_id: !input adaptive_switch + use_defaults: configuration diff --git a/blueprints/automation/sleep_mode.yaml b/blueprints/automation/sleep_mode.yaml new file mode 100644 index 00000000..fe57079b --- /dev/null +++ b/blueprints/automation/sleep_mode.yaml @@ -0,0 +1,41 @@ +blueprint: + name: "Adaptive Lighting: synchronize sleep mode" + description: >- + Keep the selected Adaptive Lighting sleep-mode switches in sync with an + input boolean, including its restored state at Home Assistant startup. + Unknown or unavailable helper states are ignored. Select only sleep-mode + switches, not the main profile or adaptation switches. + domain: automation + homeassistant: + min_version: "2025.9.0" + input: + sleep_helper: + name: Sleep-mode helper + selector: + entity: + filter: + domain: input_boolean + sleep_switches: + name: Adaptive Lighting sleep-mode switches + selector: + entity: + multiple: true + filter: + domain: switch + integration: adaptive_lighting + +triggers: + - trigger: state + entity_id: !input sleep_helper + - trigger: homeassistant + event: start +variables: + sleep_helper: !input sleep_helper + sleep_mode: "{{ states(sleep_helper) }}" +conditions: + - condition: template + value_template: "{{ sleep_mode in ['on', 'off'] }}" +actions: + - action: "switch.turn_{{ sleep_mode }}" + target: + entity_id: !input sleep_switches diff --git a/blueprints/automation/turn_off_at_minimum.yaml b/blueprints/automation/turn_off_at_minimum.yaml new file mode 100644 index 00000000..6593e398 --- /dev/null +++ b/blueprints/automation/turn_off_at_minimum.yaml @@ -0,0 +1,87 @@ +blueprint: + name: "Adaptive Lighting: turn off at minimum brightness" + description: >- + Turn one light off when its Adaptive Lighting target crosses down into the + chosen minimum brightness range. Compares rounded 0–255 commands, not the + bulb's physical dimming limit or transition completion. Skips lights currently + marked as manually controlled. Changing sleep mode clears manual control by + default; set reset_manual_control_on_sleep_mode_change to false in your + profile to preserve it. Does not turn lights back on or repeatedly turn them off + while the target stays low. Startup at the minimum does not trigger it. + Sleep mode can trigger it if its target crosses the chosen minimum. + Select a light managed by the chosen profile and its matching brightness switch. + domain: automation + homeassistant: + min_version: "2025.9.0" + input: + adaptive_switch: + name: Adaptive Lighting profile + description: Select the main Adaptive Lighting switch, not a sleep or adaptation switch. + selector: + entity: + filter: + domain: switch + integration: adaptive_lighting + brightness_switch: + name: Adapt brightness switch + description: Select the adapt brightness switch belonging to the same profile. + selector: + entity: + filter: + domain: switch + integration: adaptive_lighting + light_entity: + name: Light + description: Select one light managed by this profile. Create an automation for each light. + selector: + entity: + filter: + domain: light + minimum_pct: + name: Minimum brightness + description: Match the profile's min_brightness setting. Update this if that setting changes. + default: 1 + selector: + number: + min: 1 + max: 100 + step: 1 + unit_of_measurement: "%" + mode: box + +mode: single +variables: + adaptive_switch: !input adaptive_switch + light_entity: !input light_entity + minimum_pct: !input minimum_pct +triggers: + - trigger: state + entity_id: !input adaptive_switch + attribute: brightness_pct +conditions: + - condition: state + entity_id: !input adaptive_switch + state: "on" + - condition: state + entity_id: !input brightness_switch + state: "on" + - condition: template + value_template: >- + {% set minimum = (minimum_pct * 255 / 100) | round(0) %} + {% set before = trigger.from_state.attributes.get('brightness_pct') + if trigger.from_state else none %} + {% set after = trigger.to_state.attributes.get('brightness_pct') + if trigger.to_state else none %} + {{ is_number(before) and is_number(after) + and (before | float * 255 / 100) | round(0) > minimum + and (after | float * 255 / 100) | round(0) <= minimum }} + - condition: state + entity_id: !input light_entity + state: "on" + - condition: template + value_template: >- + {{ light_entity not in (state_attr(adaptive_switch, 'manual_control') or []) }} +actions: + - action: light.turn_off + target: + entity_id: !input light_entity diff --git a/docs/automation-examples.md b/docs/automation-examples.md index 9a8c21f3..5686c5d7 100644 --- a/docs/automation-examples.md +++ b/docs/automation-examples.md @@ -16,6 +16,17 @@ Replace every entity ID below with the IDs from your Home Assistant instance. Fr Blocks that begin with `- alias` are entries for `automations.yaml`. Blocks with a top-level `script:` or `adaptive_lighting:` key are complete `configuration.yaml` examples. If your configuration uses `script: !include scripts.yaml`, omit that outer key and place its contents in `scripts.yaml`. +Four examples also have blueprints with selectors, so you can configure them without editing YAML: + +| Blueprint | Purpose | +| --- | --- | +| [Sleep mode](https://github.com/basnijholt/adaptive-lighting/blob/main/blueprints/automation/sleep_mode.yaml) | Synchronize several profiles with one sleep-mode helper. | +| [Minimum brightness](https://github.com/basnijholt/adaptive-lighting/blob/main/blueprints/automation/turn_off_at_minimum.yaml) | Turn one light off when its target crosses down to the minimum. | +| [Schedule profile](https://github.com/basnijholt/adaptive-lighting/blob/main/blueprints/automation/schedule_profile.yaml) | Apply brightness and color temperature from Schedule helper blocks. | +| [Daylight limit](https://github.com/basnijholt/adaptive-lighting/blob/main/blueprints/automation/daylight_limit.yaml) | Lower maximum brightness in strong daylight. | + +Copy a blueprint's link into **Settings → Automations & scenes → Blueprints → Import Blueprint**, then create an automation from it. Read the matching example below for setup and behavior. Each blueprint is tested through Home Assistant alongside its YAML example. The built-in manual-control timeout needs no automation; the scripts below remain useful as actions in your own automations. + `change_switch_settings` updates a profile while its main switch is off, but lights are adapted only while that switch is on. It preserves manual-control flags, so manually controlled lights remain paused.
@@ -38,6 +49,8 @@ This is a top-level `configuration.yaml` example. The timer clears manual contro
Toggle multiple Adaptive Lighting switches to "sleep mode" using an input_boolean.sleep_mode. +Also available as a [blueprint](https://github.com/basnijholt/adaptive-lighting/blob/main/blueprints/automation/sleep_mode.yaml). Select an input boolean and the sleep-mode switches it should control. + ```yaml - alias: "Adaptive lighting: toggle 'sleep mode'" trigger: @@ -60,6 +73,56 @@ This is a top-level `configuration.yaml` example. The timer clears manual contro
+
+Turn a light off when its adaptive brightness target reaches the minimum. + +Prefer a form over editing YAML? Import the [blueprint](https://github.com/basnijholt/adaptive-lighting/blob/main/blueprints/automation/turn_off_at_minimum.yaml) in Home Assistant under **Settings → Automations & scenes → Blueprints → Import Blueprint**. Select your profile, its matching adapt brightness switch, one light managed by that profile, and its minimum brightness percentage. Create one automation per light. If you change the profile's minimum later, update the automation too. The blueprint and YAML example below have the same behavior. + +The Adaptive Lighting switch already exposes its calculated `brightness_pct` target. Use its state changes to choose a power policy in an automation; no custom event is needed. This example assumes `min_brightness: 1`. Change `minimum_pct` to match your profile, and replace the switch and light entity IDs with your own. + +The comparison uses the same rounded 0–255 brightness as an adaptation command. Comparing floating-point percentages for exact equality can miss the minimum between updates. This detects the calculated target reaching its minimum command, not the bulb finishing a transition or reaching its physical dimming limit. + +```yaml +- alias: "Adaptive lighting: turn off at minimum brightness" + mode: single + triggers: + - trigger: state + entity_id: switch.adaptive_lighting_living_room + attribute: brightness_pct + conditions: + - condition: state + entity_id: + - switch.adaptive_lighting_living_room + - switch.adaptive_lighting_living_room_adapt_brightness + state: "on" + - condition: template + value_template: >- + {% set minimum_pct = 1 %} + {% set minimum = (minimum_pct * 255 / 100) | round(0) %} + {% set before = trigger.from_state.attributes.get('brightness_pct') + if trigger.from_state else none %} + {% set after = trigger.to_state.attributes.get('brightness_pct') + if trigger.to_state else none %} + {{ is_number(before) and is_number(after) + and (before | float * 255 / 100) | round(0) > minimum + and (after | float * 255 / 100) | round(0) <= minimum }} + - condition: state + entity_id: light.living_room + state: "on" + - condition: template + value_template: >- + {{ 'light.living_room' not in + (state_attr('switch.adaptive_lighting_living_room', 'manual_control') or []) }} + actions: + - action: light.turn_off + target: + entity_id: light.living_room +``` + +This runs once when a valid target crosses down into the minimum range. It skips lights currently marked as manually controlled, does not repeatedly turn them off while the target remains low, and does not turn them back on later. Startup or re-enabling the profile while already at the minimum is not a new crossing. Sleep mode can also cause a crossing if its brightness is at or below the chosen minimum. Changing sleep mode clears manual control by default; set `reset_manual_control_on_sleep_mode_change: false` if you want to preserve it. For a bedtime-only policy, trigger directly on the sleep-mode switch changing to `on` instead. + +
+
Set sunrise and sunset from an alarm. @@ -86,6 +149,8 @@ script:
Use a Schedule helper as a step-based custom lighting profile. +Also available as a [blueprint](https://github.com/basnijholt/adaptive-lighting/blob/main/blueprints/automation/schedule_profile.yaml). Select the main profile switch and your Schedule helper. + Create a [Schedule helper](https://www.home-assistant.io/integrations/schedule/) named `Adaptive Lighting Profile`. Add time blocks with Additional data like this: ```yaml @@ -135,6 +200,8 @@ This creates step changes at block boundaries. It does not interpolate between s
Reduce daytime brightness when an illuminance sensor detects strong daylight. +Also available as a [blueprint](https://github.com/basnijholt/adaptive-lighting/blob/main/blueprints/automation/daylight_limit.yaml). Select the profile and sensor, then set the lux thresholds and brightness limits. The high lux threshold must exceed the low threshold; the blueprint does nothing if they are reversed or equal. + Keep a low configured `min_brightness` for late night and let an automation lower `max_brightness` while the room has ample daylight. Use a sensor that is not significantly affected by the controlled lights to avoid a feedback loop. ```yaml diff --git a/tests/test_automation_examples.py b/tests/test_automation_examples.py index e5f3c2d5..d0dc8e3e 100644 --- a/tests/test_automation_examples.py +++ b/tests/test_automation_examples.py @@ -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 ' "input_boolean.sleep_mode." ) - 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 ' "input_boolean.sleep_mode." ) - 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, diff --git a/tests/test_switch.py b/tests/test_switch.py index 733b1ef3..cfebc4f6 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -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