mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-09-10 13:54:04 +02:00
Fix pending adaptations after light or profile removal (#1587)
This commit is contained in:
parent
34356a6b56
commit
2f37b6ea40
2 changed files with 261 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue