Add minimum brightness automation and blueprint

This commit is contained in:
Bas Nijholt 2026-09-06 10:40:06 -07:00
commit 48854d1080
4 changed files with 379 additions and 0 deletions

View file

@ -316,6 +316,56 @@ This is a top-level `configuration.yaml` example. The timer clears manual contro
</details>
<details markdown="1">
<summary>Turn a light off when its adaptive brightness target reaches the minimum.</summary>
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 0255 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.
</details>
<details markdown="1">
<summary>Set sunrise and sunset from an alarm.</summary>

View file

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

View file

@ -60,6 +60,56 @@ This is a top-level `configuration.yaml` example. The timer clears manual contro
</details>
<details markdown="1">
<summary>Turn a light off when its adaptive brightness target reaches the minimum.</summary>
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 0255 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.
</details>
<details markdown="1">
<summary>Set sunrise and sunset from an alarm.</summary>

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,
@ -146,6 +150,194 @@ def _prepare_hass_startup(hass: HomeAssistant) -> None:
hass.set_state(CoreState.not_running)
@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]
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",
"light_entity": "light.living_room",
}
if request.param == "blueprint-custom-minimum":
inputs["minimum_pct"] = 10
return {
"alias": "Turn off at minimum",
"use_blueprint": {
"path": relative_path,
"input": inputs,
},
}
@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,
) -> None: