mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-09-24 10:54:19 +02:00
fix: don't cancel adaptation when a light group turns on via a member with a reused context (#1483)
* fix: don't cancel adaptation when a light group turns on via a member with a reused context (#1378) When a member of a light group is turned on (e.g., by a motion sensor automation) while the group is off, the group turns on as a side effect, but Home Assistant may reuse the context of the earlier turn_off call for the group's state change. just_turned_off() saw matching context IDs and treated the state change as a polling artifact, cancelling adaptation. - Check whether the off->on state change comes from a light.turn_on call before the matching-context polling-artifact check, so automations that turn a light off and back on with a single (automation) context adapt correctly. - For light groups, allow adaptation when a member's turn_on event falls between the group's on->off and off->on state changes, bounded on both sides so stale member events are never treated as explanatory. - Document that integration-level groups (e.g., Zigbee2MQTT groups) should not be nested inside HA Light Groups managed by Adaptive Lighting. * fix: time-bound the same-context turn_on check instead of reordering Address review findings: - Reordering the turn_on-service check above the matching-context check reintroduced stale-event false negatives: turn_on_event entries are never cleaned up, so a 'turn_on -> delay -> turn_off(transition)' automation (one shared context) would defeat the polling-artifact guard and AL could turn a light back on right after it was turned off. Restore main's check order and instead add a time-bounded own-turn_on check inside the matching-context branch, symmetric with the group-member check. This also avoids emitting the 'should not happen' warning for self-context polling artifacts. - Add a regression test for the stale same-context turn_on case. - Add an end-to-end test driving the event-bus listeners for the #1378 scenario (group kept in manager.lights, as in the reported setups). - Docs: drop the inaccurate 'expands only one level deep' claim; explain that integration-level groups cannot be expanded and nested groups make tracking unpredictable.
This commit is contained in:
parent
d4d3d50ada
commit
3638fb3013
4 changed files with 282 additions and 1 deletions
|
|
@ -2429,6 +2429,211 @@ async def test_light_group(
|
|||
assert len(events) == 3
|
||||
|
||||
|
||||
def _state_changed_event(entity_id: str, ts: float, context: Context) -> Event:
|
||||
return Event(
|
||||
EVENT_STATE_CHANGED,
|
||||
{"entity_id": entity_id},
|
||||
time_fired_timestamp=ts,
|
||||
context=context,
|
||||
)
|
||||
|
||||
|
||||
def _turn_on_service_event(entity_ids: list[str], ts: float, context: Context) -> Event:
|
||||
return Event(
|
||||
EVENT_CALL_SERVICE,
|
||||
{
|
||||
"domain": LIGHT_DOMAIN,
|
||||
"service": SERVICE_TURN_ON,
|
||||
"service_data": {ATTR_ENTITY_ID: entity_ids},
|
||||
},
|
||||
time_fired_timestamp=ts,
|
||||
context=context,
|
||||
)
|
||||
|
||||
|
||||
async def test_just_turned_off_group_context_reuse(hass, cleanup):
|
||||
"""Group 'off' → 'on' with a reused 'turn_off' context must still adapt.
|
||||
|
||||
When a member of a light group is turned on (e.g., by a motion sensor
|
||||
automation) while the group is off, the group turns on as a side effect,
|
||||
but Home Assistant may reuse the context of the earlier 'turn_off' call
|
||||
for the group's state change. `just_turned_off` used to treat this as a
|
||||
polling artifact and cancel adaptation.
|
||||
|
||||
Regression test for https://github.com/basnijholt/adaptive-lighting/issues/1378
|
||||
"""
|
||||
await setup_lights(hass, with_group=True)
|
||||
_, switch = await setup_switch(hass, {CONF_LIGHTS: ["light.light_group"]})
|
||||
await hass.async_block_till_done()
|
||||
manager = switch.manager
|
||||
|
||||
group = "light.light_group"
|
||||
member = "light.light_4"
|
||||
now = dt_util.utcnow().timestamp()
|
||||
turn_off_context = Context()
|
||||
|
||||
# The group was turned off 2 seconds ago...
|
||||
manager.on_to_off_event[group] = _state_changed_event(
|
||||
group,
|
||||
now - 2,
|
||||
turn_off_context,
|
||||
)
|
||||
# ...then an automation turned on a member light with a fresh context...
|
||||
manager.turn_on_event[member] = _turn_on_service_event(
|
||||
[member],
|
||||
now - 0.5,
|
||||
Context(),
|
||||
)
|
||||
# ...which turned the group back on, but HA reused the old turn_off context.
|
||||
manager.off_to_on_event[group] = _state_changed_event(
|
||||
group,
|
||||
now,
|
||||
turn_off_context,
|
||||
)
|
||||
|
||||
# The member's turn_on explains the group's turn-on: adaptation must proceed.
|
||||
assert not await manager.just_turned_off(group)
|
||||
|
||||
# A member turn_on from *before* the group was turned off does not explain
|
||||
# the group's turn-on: this must still be treated as a polling artifact.
|
||||
manager.turn_on_event[member] = _turn_on_service_event(
|
||||
[member],
|
||||
now - 10,
|
||||
Context(),
|
||||
)
|
||||
assert await manager.just_turned_off(group)
|
||||
|
||||
# Without any member turn_on event, the matching context IDs must still be
|
||||
# treated as a polling artifact.
|
||||
del manager.turn_on_event[member]
|
||||
assert await manager.just_turned_off(group)
|
||||
|
||||
|
||||
async def test_just_turned_off_same_automation_context(hass, cleanup):
|
||||
"""'turn_off' and 'turn_on' from one automation share a context.
|
||||
|
||||
An automation calling 'light.turn_off' and later 'light.turn_on' reuses
|
||||
its own context for both service calls, so the 'on' → 'off' and
|
||||
'off' → 'on' state changes have matching context IDs. The turn_on service
|
||||
call must take precedence over the matching-context polling-artifact check.
|
||||
"""
|
||||
await setup_lights(hass)
|
||||
_, switch = await setup_switch(hass, {CONF_LIGHTS: [ENTITY_LIGHT_1]})
|
||||
await hass.async_block_till_done()
|
||||
manager = switch.manager
|
||||
|
||||
now = dt_util.utcnow().timestamp()
|
||||
automation_context = Context()
|
||||
|
||||
manager.on_to_off_event[ENTITY_LIGHT_1] = _state_changed_event(
|
||||
ENTITY_LIGHT_1,
|
||||
now - 2,
|
||||
automation_context,
|
||||
)
|
||||
manager.turn_on_event[ENTITY_LIGHT_1] = _turn_on_service_event(
|
||||
[ENTITY_LIGHT_1],
|
||||
now - 0.5,
|
||||
automation_context,
|
||||
)
|
||||
manager.off_to_on_event[ENTITY_LIGHT_1] = _state_changed_event(
|
||||
ENTITY_LIGHT_1,
|
||||
now,
|
||||
automation_context,
|
||||
)
|
||||
assert not await manager.just_turned_off(ENTITY_LIGHT_1)
|
||||
|
||||
# A stale turn_on with an unrelated context does not explain the
|
||||
# 'off' → 'on' state change: still a polling artifact.
|
||||
manager.turn_on_event[ENTITY_LIGHT_1] = _turn_on_service_event(
|
||||
[ENTITY_LIGHT_1],
|
||||
now - 10,
|
||||
Context(),
|
||||
)
|
||||
assert await manager.just_turned_off(ENTITY_LIGHT_1)
|
||||
|
||||
# A stale turn_on *sharing the automation's context* but fired before the
|
||||
# 'on' → 'off' state change (i.e., 'turn_on' → delay → 'turn_off' in one
|
||||
# automation run) does not explain the 'off' → 'on' state change either:
|
||||
# `turn_on_event` entries are never cleaned up, so without the time bounds
|
||||
# this would defeat the polling-artifact detection.
|
||||
manager.turn_on_event[ENTITY_LIGHT_1] = _turn_on_service_event(
|
||||
[ENTITY_LIGHT_1],
|
||||
now - 10,
|
||||
automation_context,
|
||||
)
|
||||
assert await manager.just_turned_off(ENTITY_LIGHT_1)
|
||||
|
||||
|
||||
async def test_just_turned_off_group_context_reuse_end_to_end(hass, cleanup):
|
||||
"""Drive the issue #1378 scenario through the real event bus listeners.
|
||||
|
||||
Unlike `test_just_turned_off_group_context_reuse`, which calls
|
||||
`just_turned_off` directly, this test fires the service and state-changed
|
||||
events on the bus. Light groups are normally expanded out of
|
||||
`manager.lights`, but they can remain tracked in real setups (e.g., when a
|
||||
group is nested inside another configured group or is unavailable during
|
||||
setup), which is the configuration under which issue #1378 was reported.
|
||||
"""
|
||||
await setup_lights(hass, with_group=True)
|
||||
_, switch = await setup_switch(hass, {CONF_LIGHTS: ["light.light_group"]})
|
||||
await hass.async_block_till_done()
|
||||
manager = switch.manager
|
||||
|
||||
group = "light.light_group"
|
||||
member = "light.light_4"
|
||||
assert member in manager.lights
|
||||
# Simulate a setup in which the group entity itself remains tracked.
|
||||
manager.lights.add(group)
|
||||
|
||||
turn_off_context = Context()
|
||||
# The group was turned off...
|
||||
hass.bus.async_fire(
|
||||
EVENT_STATE_CHANGED,
|
||||
{
|
||||
"entity_id": group,
|
||||
"old_state": State(group, STATE_ON),
|
||||
"new_state": State(group, STATE_OFF),
|
||||
},
|
||||
context=turn_off_context,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert group in manager.on_to_off_event
|
||||
|
||||
# ...then an automation turned on a member light with a fresh context...
|
||||
hass.bus.async_fire(
|
||||
EVENT_CALL_SERVICE,
|
||||
{
|
||||
"domain": LIGHT_DOMAIN,
|
||||
"service": SERVICE_TURN_ON,
|
||||
"service_data": {ATTR_ENTITY_ID: [member]},
|
||||
},
|
||||
context=Context(),
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert member in manager.turn_on_event
|
||||
|
||||
# ...which turned the group back on, but HA reused the old turn_off context.
|
||||
with patch.object(
|
||||
AdaptiveSwitch,
|
||||
"_respond_to_off_to_on_event",
|
||||
AsyncMock(),
|
||||
) as respond:
|
||||
hass.bus.async_fire(
|
||||
EVENT_STATE_CHANGED,
|
||||
{
|
||||
"entity_id": group,
|
||||
"old_state": State(group, STATE_OFF),
|
||||
"new_state": State(group, STATE_ON),
|
||||
},
|
||||
context=turn_off_context,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Adaptation must not have been cancelled as a polling artifact.
|
||||
respond.assert_called_once()
|
||||
assert respond.call_args[0][0] == group
|
||||
|
||||
|
||||
@pytest.mark.parametrize("brightness_mode", ["linear", "tanh"])
|
||||
@pytest.mark.parametrize(("dark", "light"), ([900, 1800], [1800, 900], [1800, 1800]))
|
||||
async def test_brightness_mode(hass, brightness_mode, dark, light):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue