diff --git a/README.md b/README.md index 8f9d208e..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: @@ -392,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 @@ -441,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/docs/automation-examples.md b/docs/automation-examples.md index 2db315ae..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: @@ -136,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 @@ -185,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 d9471a5b..d0dc8e3e 100644 --- a/tests/test_automation_examples.py +++ b/tests/test_automation_examples.py @@ -35,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, @@ -56,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 @@ -150,6 +152,53 @@ 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.""" @@ -157,14 +206,6 @@ def minimum_automation_config(hass: HomeAssistant, tmp_path: Path, request): return _yaml_documents( "Turn a light off when its adaptive brightness target reaches the minimum.", )[0] - relative_path = "adaptive_lighting/turn_off_at_minimum.yaml" - hass.config.config_dir = str(tmp_path) - destination = tmp_path / "blueprints" / "automation" / relative_path - destination.parent.mkdir(parents=True) - shutil.copyfile( - README.parent / "blueprints" / "automation" / "turn_off_at_minimum.yaml", - destination, - ) inputs = { "adaptive_switch": "switch.adaptive_lighting_living_room", "brightness_switch": "switch.adaptive_lighting_living_room_adapt_brightness", @@ -172,13 +213,13 @@ def minimum_automation_config(hass: HomeAssistant, tmp_path: Path, request): } if request.param == "blueprint-custom-minimum": inputs["minimum_pct"] = 10 - return { - "alias": "Turn off at minimum", - "use_blueprint": { - "path": relative_path, - "input": inputs, - }, - } + return _blueprint_config( + hass, + tmp_path, + "turn_off_at_minimum.yaml", + inputs, + "Turn off at minimum", + ) @pytest.mark.parametrize("manual_control", [False, True]) @@ -340,10 +381,18 @@ async def test_minimum_brightness_ignores_missing_previous_target( 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, { @@ -412,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", @@ -433,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}, @@ -465,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}, @@ -494,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: @@ -766,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", @@ -815,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) @@ -822,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,