diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index aa2fe42b..7bf17ba3 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -1697,8 +1697,10 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): if ( old_state is not None and old_state.state == STATE_UNAVAILABLE - and not self._detect_non_ha_changes - and self.manager.last_service_call_was_turn_off(entity_id) + and self.manager.last_service_call_was_turn_off( + entity_id, + after=event if self._detect_non_ha_changes else None, + ) ): _LOGGER.debug( "%s: Skipping recovery of '%s' after a light.turn_off call", @@ -2856,7 +2858,10 @@ class AdaptiveLightingManager: self.reset(entity_id, reset_manual_control=False) lock = self.turn_off_locks.setdefault(entity_id, asyncio.Lock()) async with lock: - if old_off and await self.just_turned_off(entity_id): + if ( + old_unavailable + and self._off_to_on_event_is_during_turn_off(entity_id, event) + ) or (old_off and await self.just_turned_off(entity_id)): # Stop if a rapid 'off' → 'on' → 'off' happens. _LOGGER.debug( "Cancelling adjusting lights for %s", @@ -2876,12 +2881,19 @@ class AdaptiveLightingManager: event, ) - def last_service_call_was_turn_off(self, entity_id: str) -> bool: + def last_service_call_was_turn_off( + self, + entity_id: str, + *, + after: Event | None = None, + ) -> bool: """Return whether the most recent tracked service call turned a light off.""" turn_on = self.turn_on_event.get(entity_id) turn_off = self.turn_off_event.get(entity_id) - return turn_off is not None and ( - turn_on is None or turn_off.time_fired > turn_on.time_fired + return ( + turn_off is not None + and (turn_on is None or turn_off.time_fired > turn_on.time_fired) + and (after is None or turn_off.time_fired > after.time_fired) ) async def update_manually_controlled_from_event( diff --git a/tests/test_switch.py b/tests/test_switch.py index 32366bc8..e01302f4 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -5435,18 +5435,82 @@ async def test_unavailable_light_recovery_preserves_recent_turn_off(hass): assert hass.states.get(ENTITY_LIGHT_1).attributes[ATTR_BRIGHTNESS] == 200 -@pytest.mark.parametrize("cancel_reason", ["light_turn_off", "profile_disabled"]) +@pytest.mark.parametrize(("elapsed", "should_adapt"), [(0, False), (11, True)]) +async def test_unavailable_recovery_respects_fresh_turn_off_window( + hass, + freezer, + elapsed, + should_adapt, +): + """Recovery must not reverse an active fade, but adapts after it expires.""" + await setup_lights_and_switch( + hass, + { + CONF_TAKE_OVER_CONTROL: True, + CONF_DETECT_NON_HA_CHANGES: True, + CONF_INITIAL_TRANSITION: 0, + CONF_MIN_BRIGHTNESS: 50, + CONF_MAX_BRIGHTNESS: 50, + }, + ) + turn_off_context = Context() + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_LIGHT_1, ATTR_TRANSITION: 10}, + blocking=True, + context=turn_off_context, + ) + await hass.async_block_till_done() + + attributes = dict(hass.states.get(ENTITY_LIGHT_1).attributes) + attributes[ATTR_BRIGHTNESS] = 200 + hass.states.async_set( + ENTITY_LIGHT_1, + STATE_UNAVAILABLE, + attributes, + context=turn_off_context, + ) + await hass.async_block_till_done() + freezer.tick(elapsed) + + calls = _track_adaptive_light_calls(hass) + hass.states.async_set( + ENTITY_LIGHT_1, + STATE_ON, + attributes, + context=turn_off_context, + ) + await hass.async_block_till_done() + + if should_adapt: + assert calls[-1][ATTR_BRIGHTNESS] == 128 + else: + assert not calls + + +@pytest.mark.parametrize( + ("cancel_reason", "detect_non_ha_changes", "expected_brightness"), + [ + ("light_turn_off", False, 200), + ("light_turn_off", True, 200), + ("turn_off_then_turn_on", True, 128), + ("profile_disabled", False, 200), + ], +) async def test_unavailable_light_recovery_cancelled_during_delay( hass, monkeypatch, cancel_reason, + detect_non_ha_changes, + expected_brightness, ): """A changed off intent during recovery delay prevents adaptation.""" switch, _ = await setup_lights_and_switch( hass, { CONF_TAKE_OVER_CONTROL: False, - CONF_DETECT_NON_HA_CHANGES: False, + CONF_DETECT_NON_HA_CHANGES: detect_non_ha_changes, CONF_ADAPT_DELAY: 0.1234, CONF_INITIAL_TRANSITION: 0, CONF_MIN_BRIGHTNESS: 50, @@ -5472,7 +5536,7 @@ async def test_unavailable_light_recovery_cancelled_during_delay( monkeypatch.setattr(asyncio, "sleep", controlled_sleep) hass.states.async_set(ENTITY_LIGHT_1, STATE_ON, attributes) await entered_delay.wait() - if cancel_reason == "light_turn_off": + if cancel_reason in ("light_turn_off", "turn_off_then_turn_on"): hass.bus.async_fire( EVENT_CALL_SERVICE, { @@ -5488,13 +5552,30 @@ async def test_unavailable_light_recovery_cancelled_during_delay( await original_sleep(0) assert hass.states.get(ENTITY_LIGHT_1).state == STATE_ON assert switch.manager.last_service_call_was_turn_off(ENTITY_LIGHT_1) + if cancel_reason == "turn_off_then_turn_on": + hass.bus.async_fire( + EVENT_CALL_SERVICE, + { + "domain": LIGHT_DOMAIN, + "service": SERVICE_TURN_ON, + "service_data": {ATTR_ENTITY_ID: ENTITY_LIGHT_1}, + }, + context=Context(id="turn_on_during_recovery_delay"), + ) + await original_sleep(0) + assert not switch.manager.last_service_call_was_turn_off( + ENTITY_LIGHT_1, + ) else: await switch.async_turn_off() assert not switch.is_on release_delay.set() await hass.async_block_till_done() - assert hass.states.get(ENTITY_LIGHT_1).attributes[ATTR_BRIGHTNESS] == 200 + assert ( + hass.states.get(ENTITY_LIGHT_1).attributes[ATTR_BRIGHTNESS] + == expected_brightness + ) @pytest.mark.parametrize("intercept", [True, False])