From c5d216ea6f07221281f77c05ebb0029c4f55298b Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 6 Sep 2026 21:50:32 +0200 Subject: [PATCH] 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 --- README.md | 1 + blueprints/automation/sleep_mode.yaml | 1 + custom_components/adaptive_lighting/switch.py | 4 ++ docs/automation-examples.md | 1 + tests/test_automation_examples.py | 51 +++++++++++++++++++ tests/test_switch.py | 28 ++++++++-- 6 files changed, 83 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 3f924fb8..1ea941fd 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/blueprints/automation/sleep_mode.yaml b/blueprints/automation/sleep_mode.yaml index fe57079b..62225aed 100644 --- a/blueprints/automation/sleep_mode.yaml +++ b/blueprints/automation/sleep_mode.yaml @@ -39,3 +39,4 @@ actions: - action: "switch.turn_{{ sleep_mode }}" target: entity_id: !input sleep_switches +mode: restart diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index c85087a9..d784f325 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -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. diff --git a/docs/automation-examples.md b/docs/automation-examples.md index ec5c2831..87ca3aa1 100644 --- a/docs/automation-examples.md +++ b/docs/automation-examples.md @@ -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 diff --git a/tests/test_automation_examples.py b/tests/test_automation_examples.py index b500f41e..28bd130a 100644 --- a/tests/test_automation_examples.py +++ b/tests/test_automation_examples.py @@ -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 ' + "input_boolean.sleep_mode." + ) + 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, diff --git a/tests/test_switch.py b/tests/test_switch.py index 0b9129e3..d27aa5f6 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -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