mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-09-12 06:44:04 +02:00
Add minimum brightness automation and blueprint
This commit is contained in:
parent
e2a3aae416
commit
48854d1080
4 changed files with 379 additions and 0 deletions
50
README.md
50
README.md
|
|
@ -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 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.
|
||||
|
||||
</details>
|
||||
|
||||
<details markdown="1">
|
||||
<summary>Set sunrise and sunset from an alarm.</summary>
|
||||
|
||||
|
|
|
|||
87
blueprints/automation/turn_off_at_minimum.yaml
Normal file
87
blueprints/automation/turn_off_at_minimum.yaml
Normal 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 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
|
||||
|
|
@ -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 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.
|
||||
|
||||
</details>
|
||||
|
||||
<details markdown="1">
|
||||
<summary>Set sunrise and sunset from an alarm.</summary>
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue