fix: make zero brightness adaptation terminal

This commit is contained in:
Bas Nijholt 2026-09-06 09:12:31 -07:00
commit c373e86043
9 changed files with 333 additions and 8 deletions

View file

@ -134,7 +134,7 @@ The YAML and frontend configuration methods support all of the options listed be
| `min_color_temp` | Warmest color temperature in Kelvin. 🔥 | `2000` | `int` 1000-10000 |
| `max_color_temp` | Coldest color temperature in Kelvin. ❄️ | `5500` | `int` 1000-10000 |
| `prefer_rgb_color` | Whether to prefer RGB color adjustment over light color temperature when possible. 🌈 | `False` | `bool` |
| `sleep_brightness` | Brightness percentage of lights in sleep mode. 😴 | `1` | `int` 1-100 |
| `sleep_brightness` | Brightness percentage of lights in sleep mode. Set to 0 to turn lights off when brightness adaptation runs in sleep mode. 😴 | `1` | `int` 0-100 |
| `sleep_rgb_or_color_temp` | Use either `"rgb_color"` or `"color_temp"` in sleep mode. 🌙 | `color_temp` | one of `['color_temp', 'rgb_color']` |
| `sleep_color_temp` | Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is `color_temp`) in Kelvin. 😴 | `1000` | `int` 1000-10000 |
| `sleep_rgb_color` | RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is "rgb_color"). 🌈 | `[255, 56, 0]` | RGB color |

View file

@ -130,6 +130,10 @@ def _is_attribute_satisfied(key: str, value: Any, attributes: dict[str, Any]) ->
if not isinstance(current, (int, float)) or not isinstance(value, (int, float)):
return value == current
if key == ATTR_BRIGHTNESS:
# Zero is an off command, so it must not be treated as equivalent to a
# nearby nonzero brightness in either direction.
if value == 0 or current == 0:
return value == current
return abs(value - current) <= BRIGHTNESS_TOLERANCE
if key == ATTR_COLOR_TEMP_KELVIN and value > 0 and current > 0:
# Compare in mired space: most integrations quantize color temperature
@ -283,6 +287,14 @@ def prepare_adaptation_data(
entity_id,
service_data,
)
if service_data.get(ATTR_BRIGHTNESS) == 0:
# Home Assistant treats brightness zero as turn-off. Make it terminal:
# color is meaningless while off and must not become a later turn-on.
service_data = {
key: service_data[key]
for key in (ATTR_ENTITY_ID, ATTR_BRIGHTNESS, ATTR_TRANSITION)
if key in service_data
}
service_datas = _split_service_call_data(service_data) if split else [service_data]
service_datas_length = len(service_datas)

View file

@ -117,7 +117,8 @@ DOCS[CONF_SEPARATE_TURN_ON_COMMANDS] = (
CONF_SLEEP_BRIGHTNESS, DEFAULT_SLEEP_BRIGHTNESS = "sleep_brightness", 1
DOCS[CONF_SLEEP_BRIGHTNESS] = (
"Brightness percentage of lights in sleep mode. Set to 0 to prevent lights from turning on. 😴"
"Brightness percentage of lights in sleep mode. Set to 0 to turn lights off "
"when brightness adaptation runs in sleep mode. 😴"
)
CONF_SLEEP_COLOR_TEMP, DEFAULT_SLEEP_COLOR_TEMP = "sleep_color_temp", 1000

View file

@ -152,7 +152,7 @@ change_switch_settings:
selector:
boolean: null
sleep_brightness:
description: Brightness percentage of lights in sleep mode. Set to 0 to prevent lights from turning on. 😴
description: Brightness percentage of lights in sleep mode. Set to 0 to turn lights off when brightness adaptation runs in sleep mode. 😴
required: false
example: 1
selector:

View file

@ -39,7 +39,7 @@
"data_description": {
"interval": "Frequency to adapt the lights, in seconds. 🔄",
"transition": "Duration of transition when lights change, in seconds. 🕑",
"sleep_brightness": "Brightness percentage of lights in sleep mode. 😴",
"sleep_brightness": "Brightness percentage of lights in sleep mode. Set to 0 to turn lights off when brightness adaptation runs in sleep mode. 😴",
"sleep_color_temp": "Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is `color_temp`) in Kelvin. 😴"
},
"sections": {
@ -219,7 +219,7 @@
"name": "send_split_delay"
},
"sleep_brightness": {
"description": "Brightness percentage of lights in sleep mode. 😴",
"description": "Brightness percentage of lights in sleep mode. Set to 0 to turn lights off when brightness adaptation runs in sleep mode. 😴",
"name": "sleep_brightness"
},
"sleep_rgb_or_color_temp": {

View file

@ -40,7 +40,7 @@
"data_description": {
"interval": "Frequency to adapt the lights, in seconds. 🔄",
"transition": "Duration of transition when lights change, in seconds. 🕑",
"sleep_brightness": "Brightness percentage of lights in sleep mode. 😴",
"sleep_brightness": "Brightness percentage of lights in sleep mode. Set to 0 to turn lights off when brightness adaptation runs in sleep mode. 😴",
"sleep_color_temp": "Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is `color_temp`) in Kelvin. 😴"
},
"sections": {
@ -220,7 +220,7 @@
"name": "send_split_delay"
},
"sleep_brightness": {
"description": "Brightness percentage of lights in sleep mode. 😴",
"description": "Brightness percentage of lights in sleep mode. Set to 0 to turn lights off when brightness adaptation runs in sleep mode. 😴",
"name": "sleep_brightness"
},
"sleep_rgb_or_color_temp": {

View file

@ -43,7 +43,7 @@ All configuration options are listed below with their default values. These opti
| `min_color_temp` | Warmest color temperature in Kelvin. 🔥 | `2000` | `int` 1000-10000 |
| `max_color_temp` | Coldest color temperature in Kelvin. ❄️ | `5500` | `int` 1000-10000 |
| `prefer_rgb_color` | Whether to prefer RGB color adjustment over light color temperature when possible. 🌈 | `False` | `bool` |
| `sleep_brightness` | Brightness percentage of lights in sleep mode. 😴 | `1` | `int` 1-100 |
| `sleep_brightness` | Brightness percentage of lights in sleep mode. Set to 0 to turn lights off when brightness adaptation runs in sleep mode. 😴 | `1` | `int` 0-100 |
| `sleep_rgb_or_color_temp` | Use either `"rgb_color"` or `"color_temp"` in sleep mode. 🌙 | `color_temp` | one of `['color_temp', 'rgb_color']` |
| `sleep_color_temp` | Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is `color_temp`) in Kelvin. 😴 | `1000` | `int` 1000-10000 |
| `sleep_rgb_color` | RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is "rgb_color"). 🌈 | `[255, 56, 0]` | RGB color |

View file

@ -108,6 +108,26 @@ async def test_split_service_call_data(input_data, expected_data_list):
State("light.test", STATE_ON, {ATTR_BRIGHTNESS: 227}),
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 230, ATTR_TRANSITION: 2},
),
(
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 0, ATTR_TRANSITION: 2},
State("light.test", STATE_ON, {ATTR_BRIGHTNESS: 1}),
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 0, ATTR_TRANSITION: 2},
),
(
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 0, ATTR_TRANSITION: 2},
State("light.test", STATE_ON, {ATTR_BRIGHTNESS: 2}),
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 0, ATTR_TRANSITION: 2},
),
(
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 1, ATTR_TRANSITION: 2},
State("light.test", STATE_ON, {ATTR_BRIGHTNESS: 0}),
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 1, ATTR_TRANSITION: 2},
),
(
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 0, ATTR_TRANSITION: 2},
State("light.test", STATE_ON, {ATTR_BRIGHTNESS: 0}),
{ATTR_ENTITY_ID: "light.test", ATTR_TRANSITION: 2},
),
(
{
ATTR_ENTITY_ID: "light.test",
@ -165,6 +185,10 @@ async def test_split_service_call_data(input_data, expected_data_list):
"keep attributes whose values differ from the state",
"remove brightness within quantization tolerance (0-99 device scale)",
"keep brightness outside quantization tolerance",
"keep zero target across power boundary from brightness one",
"keep zero target across power boundary from brightness two",
"keep positive target across power boundary from zero",
"remove zero target when brightness is already zero",
"remove color temp within one mired (round-converting integration)",
"remove color temp within one mired (floor-converting HA core helpers)",
"remove color temp within one mired (6500 K)",
@ -347,6 +371,24 @@ async def test_create_service_call_data_iterator(
],
0.7,
),
(
{
ATTR_ENTITY_ID: "light.test",
ATTR_BRIGHTNESS: 0,
ATTR_COLOR_TEMP_KELVIN: 4000,
ATTR_TRANSITION: 10,
},
True,
False,
[
{
ATTR_ENTITY_ID: "light.test",
ATTR_BRIGHTNESS: 0,
ATTR_TRANSITION: 10,
},
],
1.2,
),
(
{
ATTR_ENTITY_ID: "light.test",
@ -383,6 +425,7 @@ async def test_create_service_call_data_iterator(
ids=[
"service data passed through",
"service data split",
"zero brightness is one terminal command",
"service data filtered",
"service data split and filtered",
],
@ -416,6 +459,31 @@ async def test_prepare_adaptation_data(
assert generated_service_datas == service_datas_expected
async def test_prepare_zero_after_shared_brightness_was_applied(hass_states_mock):
"""A shared zero-brightness call leaves no per-light color follow-up."""
data = prepare_adaptation_data(
hass_states_mock,
"light.test",
Context(id="test-id"),
10,
0.2,
{
ATTR_ENTITY_ID: "light.test",
ATTR_BRIGHTNESS: 0,
ATTR_COLOR_TEMP_KELVIN: 4000,
ATTR_TRANSITION: 10,
},
split=True,
filter_by_state=False,
force=False,
already_applied=LightControlAttributes.BRIGHTNESS,
)
assert [item async for item in data.service_call_datas] == []
assert data.max_length == 0
assert data.attributes is LightControlAttributes.NONE
@pytest.fixture(name="hass_states_mock")
def fixture_hass_states_mock():
"""Mocks a HA state machine which returns a mock state."""

View file

@ -47,6 +47,7 @@ from homeassistant.components.adaptive_lighting.const import (
CONF_RESET_MANUAL_CONTROL_ON_SLEEP_MODE_CHANGE,
CONF_SEPARATE_TURN_ON_COMMANDS,
CONF_SKIP_REDUNDANT_COMMANDS,
CONF_SLEEP_BRIGHTNESS,
CONF_SLEEP_RGB_OR_COLOR_TEMP,
CONF_SLEEP_TRANSITION,
CONF_SUNRISE_OFFSET,
@ -4969,3 +4970,246 @@ async def test_forced_split_apply_stays_off(hass, off_action, cleanup):
assert ATTR_BRIGHTNESS in turn_on_events[0].data["service_data"]
assert ATTR_COLOR_TEMP_KELVIN not in turn_on_events[0].data["service_data"]
assert hass.states.get(ENTITY_LIGHT_3).state == STATE_OFF
async def _finish_zero_sleep_adaptations(hass, switch):
"""Wait until zero-sleep adaptation calls have settled."""
await hass.async_block_till_done(wait_background_tasks=True)
if switch.manager.adaptation_tasks:
await asyncio.gather(*tuple(switch.manager.adaptation_tasks))
await hass.async_block_till_done(wait_background_tasks=True)
def _light_turn_on_payloads(events, entity_id):
"""Return turn-on payloads that target one light."""
payloads = []
for event in events:
if (
event.data["domain"] != LIGHT_DOMAIN
or event.data["service"] != SERVICE_TURN_ON
):
continue
service_data = event.data["service_data"]
target = service_data[ATTR_ENTITY_ID]
if target == entity_id or (not isinstance(target, str) and entity_id in target):
payloads.append(service_data)
return payloads
@pytest.mark.parametrize("intercept", [False, True])
@pytest.mark.parametrize("split", [False, True])
async def test_zero_sleep_bare_turn_on_is_terminal(hass, intercept, split):
"""A bare turn-on ends with one color-free zero-brightness command."""
switch, (light, *_) = await setup_lights_and_switch(
hass,
{
CONF_INTERCEPT: intercept,
CONF_SEPARATE_TURN_ON_COMMANDS: split,
CONF_SLEEP_BRIGHTNESS: 0,
},
)
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: light.entity_id},
blocking=True,
)
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: switch.sleep_mode_switch.entity_id},
blocking=True,
)
await _finish_zero_sleep_adaptations(hass, switch)
events = []
remove_listener = hass.bus.async_listen(EVENT_CALL_SERVICE, events.append)
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: light.entity_id},
blocking=True,
)
await _finish_zero_sleep_adaptations(hass, switch)
remove_listener()
payloads = _light_turn_on_payloads(events, light.entity_id)
zero_payloads = [data for data in payloads if data.get(ATTR_BRIGHTNESS) == 0]
if intercept:
assert payloads == [{ATTR_ENTITY_ID: light.entity_id}]
else:
assert payloads[0] == {ATTR_ENTITY_ID: light.entity_id}
assert len(payloads) == 2
assert zero_payloads == [
{ATTR_ENTITY_ID: light.entity_id, ATTR_BRIGHTNESS: 0},
]
assert all(ATTR_COLOR_TEMP_KELVIN not in data for data in payloads)
assert all(ATTR_RGB_COLOR not in data for data in payloads)
assert hass.states.get(light.entity_id).state == STATE_OFF
@pytest.mark.parametrize("split", [False, True])
async def test_zero_sleep_enter_and_exit_does_not_restore_light(hass, split):
"""Entering zero sleep turns a light off; exiting leaves it off."""
switch, (light, *_) = await setup_lights_and_switch(
hass,
{
CONF_SEPARATE_TURN_ON_COMMANDS: split,
CONF_SLEEP_BRIGHTNESS: 0,
},
)
assert hass.states.get(light.entity_id).state == STATE_ON
events = []
remove_listener = hass.bus.async_listen(EVENT_CALL_SERVICE, events.append)
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: switch.sleep_mode_switch.entity_id},
blocking=True,
)
await _finish_zero_sleep_adaptations(hass, switch)
enter_payloads = _light_turn_on_payloads(events, light.entity_id)
assert len(enter_payloads) == 1
assert enter_payloads[0][ATTR_BRIGHTNESS] == 0
assert hass.states.get(light.entity_id).state == STATE_OFF
events.clear()
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: switch.sleep_mode_switch.entity_id},
blocking=True,
)
await _finish_zero_sleep_adaptations(hass, switch)
remove_listener()
assert _light_turn_on_payloads(events, light.entity_id) == []
assert hass.states.get(light.entity_id).state == STATE_OFF
@pytest.mark.parametrize("brightness", [1, 2])
@pytest.mark.parametrize("split", [False, True])
async def test_zero_sleep_is_not_redundant_with_dimmed_light(
hass,
brightness,
split,
):
"""Brightness quantization tolerance does not suppress a required off."""
switch, (light, *_) = await setup_lights_and_switch(
hass,
{
CONF_SEPARATE_TURN_ON_COMMANDS: split,
CONF_SKIP_REDUNDANT_COMMANDS: True,
CONF_SLEEP_BRIGHTNESS: 0,
},
)
set_light_brightness(light, brightness)
light.async_write_ha_state()
await hass.async_block_till_done()
events = []
remove_listener = hass.bus.async_listen(EVENT_CALL_SERVICE, events.append)
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: switch.sleep_mode_switch.entity_id},
blocking=True,
)
await _finish_zero_sleep_adaptations(hass, switch)
remove_listener()
payloads = _light_turn_on_payloads(events, light.entity_id)
assert [data[ATTR_BRIGHTNESS] for data in payloads] == [0]
assert hass.states.get(light.entity_id).state == STATE_OFF
@pytest.mark.parametrize("split", [False, True])
async def test_zero_sleep_command_keeps_full_transition(hass, split, cleanup):
"""The terminal zero command keeps the configured sleep transition."""
switch, lights = await setup_lights_and_switch(
hass,
{
CONF_SEPARATE_TURN_ON_COMMANDS: split,
CONF_SLEEP_BRIGHTNESS: 0,
CONF_SLEEP_TRANSITION: 1,
},
all_lights=True,
)
light = lights[2]
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: light.entity_id},
blocking=True,
)
await _finish_zero_sleep_adaptations(hass, switch)
events = []
remove_listener = hass.bus.async_listen(EVENT_CALL_SERVICE, events.append)
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: switch.sleep_mode_switch.entity_id},
blocking=True,
)
await _finish_zero_sleep_adaptations(hass, switch)
remove_listener()
payloads = _light_turn_on_payloads(events, light.entity_id)
assert payloads == [
{
ATTR_ENTITY_ID: light.entity_id,
ATTR_BRIGHTNESS: 0,
ATTR_TRANSITION: 1,
},
]
async def test_zero_sleep_multi_light_intercept_has_no_follow_up(hass):
"""A shared intercepted zero command needs no per-light color call."""
lights = await setup_lights(hass)
managed = [lights[0].entity_id, lights[1].entity_id]
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: managed},
blocking=True,
)
_, switch = await setup_switch(
hass,
{
CONF_LIGHTS: managed,
CONF_INTERCEPT: True,
CONF_MULTI_LIGHT_INTERCEPT: True,
CONF_SEPARATE_TURN_ON_COMMANDS: True,
CONF_INITIAL_TRANSITION: 0,
CONF_SLEEP_BRIGHTNESS: 0,
},
)
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: switch.sleep_mode_switch.entity_id},
blocking=True,
)
await _finish_zero_sleep_adaptations(hass, switch)
events = []
remove_listener = hass.bus.async_listen(EVENT_CALL_SERVICE, events.append)
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: managed},
blocking=True,
)
await _finish_zero_sleep_adaptations(hass, switch)
remove_listener()
payloads = [
event.data["service_data"]
for event in events
if event.data["domain"] == LIGHT_DOMAIN
and event.data["service"] == SERVICE_TURN_ON
]
assert payloads == [{ATTR_ENTITY_ID: managed}]
assert all(hass.states.get(entity_id).state == STATE_OFF for entity_id in managed)