fix: synchronize sleep mode and mixed-light split timing (#1579)

* fix: synchronize sleep mode and mixed-light split timing

* docs: refresh generated sleep automation example
This commit is contained in:
Bas Nijholt 2026-09-06 21:50:32 +02:00 committed by GitHub
commit c5d216ea6f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 83 additions and 3 deletions

View file

@ -316,6 +316,7 @@ Also available as a [blueprint](https://github.com/basnijholt/adaptive-lighting/
```yaml
- alias: "Adaptive lighting: toggle 'sleep mode'"
mode: restart
trigger:
- platform: state
entity_id: input_boolean.sleep_mode

View file

@ -39,3 +39,4 @@ actions:
- action: "switch.turn_{{ sleep_mode }}"
target:
entity_id: !input sleep_switches
mode: restart

View file

@ -2277,6 +2277,7 @@ class AdaptiveLightingManager:
# state can change until the next call), so we just schedule it and let
# it sort out by itself.
already_applied = get_light_control_attributes(first_service_data)
shared_sleep_time = adaptation_data.sleep_time
for index, entity_id in enumerate(entity_ids):
self.set_proactively_adapting(call.context.id, entity_id)
if index:
@ -2292,6 +2293,9 @@ class AdaptiveLightingManager:
if adaptation_data is None or not adaptation_data.max_length:
continue
self.set_proactively_adapting(adaptation_data.context.id, entity_id)
# Every follow-up waits for the shared first command, even when a
# member's capabilities give it a different number of split phases.
adaptation_data.sleep_time = shared_sleep_time
adaptation_data.initial_sleep = True
# Don't await to avoid blocking the service call.

View file

@ -54,6 +54,7 @@ Also available as a [blueprint](https://github.com/basnijholt/adaptive-lighting/
```yaml
- alias: "Adaptive lighting: toggle 'sleep mode'"
mode: restart
trigger:
- platform: state
entity_id: input_boolean.sleep_mode

View file

@ -1237,6 +1237,57 @@ async def test_sleep_toggle_uses_fresh_profile_entity_ids(
assert state.state == STATE_OFF
async def test_sleep_toggle_tracks_rapid_helper_changes(
hass: HomeAssistant,
published_automation,
) -> None:
"""Keep every sleep switch synchronized when the helper changes rapidly."""
summary = (
'Toggle multiple Adaptive Lighting switches to "sleep mode" using an '
"<code>input_boolean.sleep_mode</code>."
)
sleep_switches = (
"switch.adaptive_lighting_living_room_sleep_mode",
"switch.adaptive_lighting_bedroom_sleep_mode",
)
automation_config = published_automation(
summary,
"sleep_mode.yaml",
{
"sleep_helper": "input_boolean.sleep_mode",
"sleep_switches": list(sleep_switches),
},
)
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 _setup_automation(hass, automation_config)
for _ in range(10):
await hass.services.async_call(
"input_boolean",
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: "input_boolean.sleep_mode"},
blocking=True,
)
await hass.services.async_call(
"input_boolean",
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: "input_boolean.sleep_mode"},
blocking=True,
)
await hass.async_block_till_done()
for entity_id in sleep_switches:
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_OFF
async def test_sleep_toggle_applies_restored_state_at_startup(
hass: HomeAssistant,
published_automation,

View file

@ -48,6 +48,7 @@ from homeassistant.components.adaptive_lighting.const import (
CONF_MULTI_LIGHT_INTERCEPT,
CONF_PREFER_RGB_COLOR,
CONF_RESET_MANUAL_CONTROL_ON_SLEEP_MODE_CHANGE,
CONF_SEND_SPLIT_DELAY,
CONF_SEPARATE_TURN_ON_COMMANDS,
CONF_SKIP_REDUNDANT_COMMANDS,
CONF_SLEEP_RGB_OR_COLOR_TEMP,
@ -96,6 +97,7 @@ from homeassistant.components.light import (
ATTR_XY_COLOR,
SERVICE_TURN_OFF,
ColorMode,
LightEntityFeature,
)
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
@ -5421,13 +5423,18 @@ async def test_split_command_stays_off_after_turn_off(hass, physical_off):
assert hass.states.get(ENTITY_LIGHT_3).state == STATE_OFF
@pytest.mark.parametrize("brightness_only_member", [0, 1])
@pytest.mark.parametrize(
("brightness_only_member", "initial_transition", "shared_transition"),
[(0, 0, 0), (1, 0, 0), (0, 0.4, 0.4), (1, 0.4, 0.2)],
)
async def test_multi_light_split_with_brightness_only_member(
hass,
brightness_only_member,
initial_transition,
shared_transition,
cleanup,
):
"""A brightness-only member must not consume another member's color command."""
"""Each member gets its color command after the shared brightness transition."""
lights = await setup_lights(hass, with_group=True)
members = ["light.light_4", "light.light_5"]
light = lights[3 + brightness_only_member]
@ -5439,6 +5446,9 @@ async def test_multi_light_split_with_brightness_only_member(
light._attr_supported_color_modes = {ColorMode.BRIGHTNESS}
light._attr_color_mode = ColorMode.BRIGHTNESS
light.async_write_ha_state()
for member in lights[3:5]:
member._attr_supported_features |= LightEntityFeature.TRANSITION
member.async_write_ha_state()
_, switch = await setup_switch(
hass,
{
@ -5446,7 +5456,8 @@ async def test_multi_light_split_with_brightness_only_member(
CONF_INTERCEPT: True,
CONF_MULTI_LIGHT_INTERCEPT: True,
CONF_SEPARATE_TURN_ON_COMMANDS: True,
CONF_INITIAL_TRANSITION: 0,
CONF_INITIAL_TRANSITION: initial_transition,
CONF_SEND_SPLIT_DELAY: 50,
},
)
_mock_sun_light_settings(
@ -5457,6 +5468,14 @@ async def test_multi_light_split_with_brightness_only_member(
"force_rgb_color": False,
},
)
call_times = []
loop = asyncio.get_running_loop()
async def record_call(event):
if event.data["domain"] == LIGHT_DOMAIN:
call_times.append(loop.time())
hass.bus.async_listen(EVENT_CALL_SERVICE, record_call)
events = await _turn_on_and_track_event_contexts(
hass,
"mixed_split",
@ -5471,6 +5490,9 @@ async def test_multi_light_split_with_brightness_only_member(
if ATTR_COLOR_TEMP_KELVIN in event.data["service_data"]
]
assert color_targets == [members[1 - brightness_only_member]]
assert len(call_times) == 2
# Leave room for event dispatch without accepting overlapping transitions.
assert call_times[1] - call_times[0] >= shared_transition + 0.05 - 0.02
for entity_id in members:
state = hass.states.get(entity_id)
assert state.state == STATE_ON