Fix pending adaptations after light or profile removal (#1587)

This commit is contained in:
Bas Nijholt 2026-09-07 09:03:03 +02:00 committed by GitHub
commit 2f37b6ea40
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 261 additions and 3 deletions

View file

@ -886,6 +886,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
assert hass is not None
self.hass = hass
self.manager = manager
self._removed = False
self.sleep_mode_switch = sleep_mode_switch
self.adapt_color_switch = adapt_color_switch
self.adapt_brightness_switch = adapt_brightness_switch
@ -1078,6 +1079,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
async def async_will_remove_from_hass(self) -> None:
"""Remove the listeners upon removing the component."""
self._removed = True
self._remove_listeners()
def _resolve_lights(self, lights: list[str] | None = None) -> list[str]:
@ -1433,6 +1435,9 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
if not is_first_call or data.initial_sleep:
await asyncio.sleep(data.sleep_time)
if self._removed:
return
# Instead of directly iterating the generator in the while-loop, we get
# the next item here after the sleep to make sure it incorporates state
# changes which happened during the sleep.
@ -1488,6 +1493,9 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
Wraps the sequence of service calls in a task that can be cancelled from elsewhere, e.g.,
to cancel an ongoing adaptation when a light is turned off.
"""
if self._removed:
return
# Prevent overlap of multiple adaptation sequences
self.manager.cancel_ongoing_adaptation_calls(data.entity_id)
_LOGGER.debug(
@ -1691,7 +1699,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
await asyncio.sleep(self._adapt_delay)
# Runtime settings may retire this profile's target while the event waits.
if entity_id not in self.lights:
if self._removed or entity_id not in self.lights:
return
await self._update_attrs_and_maybe_adapt_lights(
@ -1705,7 +1713,8 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
self,
event: Event[EventStateChangedData],
) -> None:
if not _is_state_event(event, (STATE_ON, STATE_OFF)):
new_state = event.data.get("new_state")
if new_state is None or new_state.state not in (STATE_ON, STATE_OFF):
_LOGGER.debug("%s: Ignoring sleep event %s", self._name, event)
return
_LOGGER.debug(
@ -2730,7 +2739,7 @@ class AdaptiveLightingManager:
elif state.state == STATE_OFF: # is turning on
await on(eid, event)
async def state_changed_event_listener(
async def state_changed_event_listener( # noqa: PLR0912
self,
event: Event[EventStateChangedData],
) -> None:
@ -2808,6 +2817,10 @@ class AdaptiveLightingManager:
new_on.context.id,
)
if old_on and not new_on:
# Availability loss invalidates pending commands, not manual state.
self.cancel_ongoing_adaptation_calls(entity_id)
if old_on and new_off:
# Tracks 'on' → 'off' state changes
self.on_to_off_event[entity_id] = event

View file

@ -30,6 +30,7 @@ from homeassistant.components.adaptive_lighting.const import (
ATTR_ADAPT_BRIGHTNESS,
ATTR_ADAPT_COLOR,
ATTR_ADAPTIVE_LIGHTING_MANAGER,
CONF_ADAPT_DELAY,
CONF_ADAPT_ONLY_ON_BARE_TURN_ON,
CONF_ADAPT_UNTIL_SLEEP,
CONF_AUTORESET_CONTROL,
@ -46,6 +47,7 @@ from homeassistant.components.adaptive_lighting.const import (
CONF_MIN_BRIGHTNESS,
CONF_MIN_COLOR_TEMP,
CONF_MULTI_LIGHT_INTERCEPT,
CONF_ONLY_ONCE,
CONF_PREFER_RGB_COLOR,
CONF_RESET_MANUAL_CONTROL_ON_SLEEP_MODE_CHANGE,
CONF_SEND_SPLIT_DELAY,
@ -119,6 +121,7 @@ from homeassistant.const import (
SERVICE_TURN_ON,
STATE_OFF,
STATE_ON,
STATE_UNAVAILABLE,
EntityCategory,
)
from homeassistant.core import Context, CoreState, Event, HomeAssistant, State
@ -5988,3 +5991,245 @@ async def test_shared_profiles_keep_independent_sun_schedules(
noon = hass.states.get(ENTITY_LIGHT_1)
assert noon.attributes[ATTR_BRIGHTNESS] == 77
assert noon.attributes[ATTR_COLOR_TEMP_KELVIN] > 2000
@pytest.mark.parametrize("via_unavailable", [False, True])
async def test_split_adaptation_cancelled_after_physical_off(
hass,
monkeypatch,
via_unavailable,
):
"""Pending split commands must not resurrect a physically switched-off light."""
switch, _ = await setup_lights_and_switch(
hass,
{
CONF_DETECT_NON_HA_CHANGES: True,
CONF_ONLY_ONCE: True,
CONF_SEPARATE_TURN_ON_COMMANDS: True,
CONF_SEND_SPLIT_DELAY: 1234,
CONF_INITIAL_TRANSITION: 0,
CONF_MIN_BRIGHTNESS: 50,
CONF_MAX_BRIGHTNESS: 50,
},
)
state = hass.states.get(ENTITY_LIGHT_1)
hass.states.async_set(ENTITY_LIGHT_1, STATE_OFF, state.attributes)
await hass.async_block_till_done()
# Isolate the split-command lifetime from the separate turn-off debounce.
monkeypatch.setattr(
switch.manager,
"just_turned_off",
AsyncMock(return_value=False),
)
entered, release = asyncio.Event(), asyncio.Event()
original_sleep = asyncio.sleep
async def controlled_sleep(delay, *args, **kwargs):
if delay == 1.234:
entered.set()
await release.wait()
else:
await original_sleep(delay, *args, **kwargs)
monkeypatch.setattr(asyncio, "sleep", controlled_sleep)
calls = _track_adaptive_light_calls(hass)
hass.states.async_set(ENTITY_LIGHT_1, STATE_ON, state.attributes)
await asyncio.wait_for(entered.wait(), 2)
assert len(calls) == 1
if via_unavailable:
hass.states.async_set(ENTITY_LIGHT_1, STATE_UNAVAILABLE, state.attributes)
await original_sleep(0)
hass.states.async_set(ENTITY_LIGHT_1, STATE_OFF, state.attributes)
await original_sleep(0)
release.set()
await hass.async_block_till_done()
assert len(calls) == 1, f"Physical OFF resurrected by split command: {calls}"
assert hass.states.get(ENTITY_LIGHT_1).state == STATE_OFF
@pytest.mark.parametrize("remaining_profile", [False, True])
async def test_profile_unloaded_during_adapt_delay(
hass,
monkeypatch,
remaining_profile,
):
"""A removed profile must not send commands after its adaptation delay."""
switch, _ = await setup_lights_and_switch(
hass,
{
CONF_DETECT_NON_HA_CHANGES: True,
CONF_ONLY_ONCE: True,
CONF_ADAPT_DELAY: 0.1234,
},
)
if remaining_profile:
_, other = await setup_switch(
hass,
{
CONF_NAME: "remaining",
CONF_LIGHTS: [ENTITY_LIGHT_1],
CONF_ONLY_ONCE: True,
CONF_INITIAL_TRANSITION: 0,
},
)
await other.async_turn_off()
state = hass.states.get(ENTITY_LIGHT_1)
hass.states.async_set(ENTITY_LIGHT_1, STATE_OFF, state.attributes)
await hass.async_block_till_done()
monkeypatch.setattr(
switch.manager,
"just_turned_off",
AsyncMock(return_value=False),
)
entered, release = asyncio.Event(), asyncio.Event()
original_sleep = asyncio.sleep
async def controlled_sleep(delay, *args, **kwargs):
if delay == 0.1234:
entered.set()
await release.wait()
else:
await original_sleep(delay, *args, **kwargs)
monkeypatch.setattr(asyncio, "sleep", controlled_sleep)
calls = _track_adaptive_light_calls(hass)
hass.states.async_set(ENTITY_LIGHT_1, STATE_ON, state.attributes)
await asyncio.wait_for(entered.wait(), 2)
entry = hass.config_entries.async_entries(DOMAIN)[0]
await hass.config_entries.async_unload(entry.entry_id)
calls.clear()
release.set()
await hass.async_block_till_done()
assert calls == []
if remaining_profile:
await other.async_turn_on()
await other._update_attrs_and_maybe_adapt_lights(
context=other.create_context("test"),
lights=[ENTITY_LIGHT_1],
force=True,
)
await hass.async_block_till_done()
assert calls
assert hass.states.get(ENTITY_LIGHT_1).state == STATE_ON
async def test_profile_unloaded_during_split_delay(hass, monkeypatch):
"""Removed profiles must not send remaining split commands."""
switch, _ = await setup_lights_and_switch(
hass,
{
CONF_DETECT_NON_HA_CHANGES: True,
CONF_ONLY_ONCE: True,
CONF_SEPARATE_TURN_ON_COMMANDS: True,
CONF_SEND_SPLIT_DELAY: 1234,
CONF_INITIAL_TRANSITION: 0,
CONF_MIN_BRIGHTNESS: 50,
CONF_MAX_BRIGHTNESS: 50,
},
)
state = hass.states.get(ENTITY_LIGHT_1)
hass.states.async_set(ENTITY_LIGHT_1, STATE_OFF, state.attributes)
await hass.async_block_till_done()
# Isolate the split-command lifetime from the separate turn-off debounce.
monkeypatch.setattr(
switch.manager,
"just_turned_off",
AsyncMock(return_value=False),
)
entered, release = asyncio.Event(), asyncio.Event()
original_sleep = asyncio.sleep
async def controlled_sleep(delay, *args, **kwargs):
if delay == 1.234:
entered.set()
await release.wait()
else:
await original_sleep(delay, *args, **kwargs)
monkeypatch.setattr(asyncio, "sleep", controlled_sleep)
calls = _track_adaptive_light_calls(hass)
hass.states.async_set(ENTITY_LIGHT_1, STATE_ON, state.attributes)
await asyncio.wait_for(entered.wait(), 2)
assert len(calls) == 1
entry = hass.config_entries.async_entries(DOMAIN)[0]
assert await hass.config_entries.async_unload(entry.entry_id)
release.set()
await hass.async_block_till_done()
assert len(calls) == 1
assert hass.states.get(ENTITY_LIGHT_1).state == STATE_ON
@pytest.mark.parametrize("unload_before_split", [False, True])
async def test_unloaded_polling_profile_preserves_other_split_adaptation(
hass,
monkeypatch,
unload_before_split,
):
"""A removed profile resuming a poll must not cancel another profile's work."""
switch, _ = await setup_lights_and_switch(hass, {CONF_ONLY_ONCE: True})
_, other = await setup_switch(
hass,
{
CONF_NAME: "remaining",
CONF_LIGHTS: [ENTITY_LIGHT_1],
CONF_ONLY_ONCE: True,
CONF_SEPARATE_TURN_ON_COMMANDS: True,
CONF_SEND_SPLIT_DELAY: 1234,
CONF_INITIAL_TRANSITION: 0,
},
)
poll_entered, poll_release = asyncio.Event(), asyncio.Event()
split_entered, split_release = asyncio.Event(), asyncio.Event()
original_update = switch.manager.update_manually_controlled_from_untracked_change
original_sleep = asyncio.sleep
async def delayed_update(profile, *args, **kwargs):
if profile is switch:
poll_entered.set()
await poll_release.wait()
await original_update(profile, *args, **kwargs)
async def controlled_sleep(delay, *args, **kwargs):
if delay == 1.234:
split_entered.set()
await split_release.wait()
else:
await original_sleep(delay, *args, **kwargs)
monkeypatch.setattr(
switch.manager,
"update_manually_controlled_from_untracked_change",
delayed_update,
)
monkeypatch.setattr(asyncio, "sleep", controlled_sleep)
calls = _track_adaptive_light_calls(hass)
polling = hass.async_create_task(
switch._update_attrs_and_maybe_adapt_lights(
context=switch.create_context("test"),
lights=[ENTITY_LIGHT_1],
force=True,
),
)
await asyncio.wait_for(poll_entered.wait(), 2)
entry = hass.config_entries.async_entries(DOMAIN)[0]
if unload_before_split:
assert await hass.config_entries.async_unload(entry.entry_id)
adapting = hass.async_create_task(
other._adapt_light(
ENTITY_LIGHT_1,
other.create_context("test"),
0,
force=True,
),
)
await asyncio.wait_for(split_entered.wait(), 2)
assert len(calls) == 1
if not unload_before_split:
assert await hass.config_entries.async_unload(entry.entry_id)
poll_release.set()
await polling
split_release.set()
await adapting
await hass.async_block_till_done()
assert len(calls) == 2
assert ATTR_COLOR_TEMP_KELVIN in calls[-1]