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:
Bas Nijholt 2026-07-02 08:08:14 -07:00 • committed by GitHub
commit 3638fb3013
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 282 additions and 1 deletions

View file

@ -2746,7 +2746,45 @@ class AdaptiveLightingManager:
id_off_to_on = off_to_on_event.context.id
return turn_on_event is not None and id_off_to_on == turn_on_event.context.id
async def just_turned_off( # noqa: PLR0911
def _member_turn_on_explains_group_turn_on(
self,
entity_id: str,
on_to_off_event: Event[EventStateChangedData],
off_to_on_event: Event[EventStateChangedData],
) -> bool:
"""Check if a light group's 'off' → 'on' is caused by a member's 'light.turn_on'.
When a member of a light group is turned on while the group is off, the
group turns on as a side effect. Home Assistant may reuse the context of
an earlier 'light.turn_off' call for the group's state change (entities
keep their context for a few seconds), which makes the group's turn-on
look like a polling artifact of the turn-off.
See https://github.com/basnijholt/adaptive-lighting/issues/1378
"""
state = self.hass.states.get(entity_id)
if state is None or not _is_light_group(state):
return False
members: list[str] = state.attributes[ATTR_ENTITY_ID]
for member in members:
member_turn_on = self.turn_on_event.get(member)
if (
member_turn_on is not None
and on_to_off_event.time_fired
< member_turn_on.time_fired
<= off_to_on_event.time_fired
):
_LOGGER.debug(
"just_turned_off: Light group '%s' turned on because its member"
" '%s' was turned on (context.id='%s'), so this is a legitimate"
" turn-on, not a polling artifact.",
entity_id,
member,
member_turn_on.context.id,
)
return True
return False
async def just_turned_off( # noqa: PLR0911, PLR0912
self,
entity_id: str,
) -> bool:
@ -2774,6 +2812,34 @@ class AdaptiveLightingManager:
return False
if off_to_on_event.context.id == on_to_off_event.context.id:
# Matching context IDs usually mean a polling artifact (HA briefly
# reports 'on' while the light is still turning off). However, the
# context is also reused when e.g. one automation turns the light
# off and later back on, or when an integration writes the state
# with the entity's cached context. Only treat the state change as
# a legitimate turn-on if a 'light.turn_on' call for this light (or
# for a member of this light group) fired between the two state
# changes.
turn_on_event = self.turn_on_event.get(entity_id)
if (
turn_on_event is not None
and on_to_off_event.time_fired
< turn_on_event.time_fired
<= off_to_on_event.time_fired
):
_LOGGER.debug(
"just_turned_off: 'light.turn_on' was called for '%s' between its"
" 'on' → 'off' and 'off' → 'on' state changes, so this is a"
" legitimate turn-on, not a polling artifact.",
entity_id,
)
return False
if self._member_turn_on_explains_group_turn_on(
entity_id,
on_to_off_event,
off_to_on_event,
):
return False
_LOGGER.debug(
"just_turned_off: 'on' → 'off' state change has the same context.id as the"
" 'off' → 'on' state change for '%s'. This is probably a false positive.",