Fix TypeError when 'light.turn_off' is called with a string transition (#1589)

* Fix TypeError when 'light.turn_off' is called with a string transition

`EVENT_CALL_SERVICE` carries the *raw* service data, not the data
`light.turn_off`'s schema produced for the service handler, so its
`vol.Coerce(float)` never reaches `AdaptiveLightingManager`. A caller
passing `transition: "2"` — a template rendering to a string, or any
JSON payload where the value was quoted — therefore stores a `str` in
`turn_off_event`.

Both places that derive a delay from it compare it against an int:

    delay = max(transition or 0, TURNING_OFF_DELAY)  # during turn-off
    delay = max(transition, TURNING_OFF_DELAY)       # just_turned_off

which raises `TypeError: '>' not supported between instances of 'int'
and 'str'`. Because `just_turned_off` runs inside the state-change
listener task, the exception is swallowed: it surfaces only as
"Error doing job: Task exception was never retrieved (task: None)",
while the light quietly stops being adapted after that turn-off.

Read the transition through a helper that coerces to float. Schema
validation runs before the event fires, so whatever reaches the helper
is coercible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Normalize turn-off transitions with the light service validator

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Bas Nijholt <bas@nijho.lt>
This commit is contained in:
Ahmad Tawakol 2026-09-08 09:03:07 -03:00 committed by GitHub
commit 7d0f4b610a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 129 additions and 7 deletions

View file

@ -20,6 +20,7 @@ from homeassistant.components.light import (
ATTR_SUPPORTED_COLOR_MODES,
ATTR_TRANSITION,
ATTR_XY_COLOR,
VALID_TRANSITION,
ColorMode,
LightEntityFeature,
is_on,
@ -622,6 +623,18 @@ def _is_state_event(
)
def _turn_off_transition(turn_off_event: Event) -> float | None:
"""Normalize the raw event transition using the light service's validator.
Service-call events retain raw data after validation, so repeat the
service's coercion and clamping before calculating transition windows.
"""
transition = turn_off_event.data[ATTR_SERVICE_DATA].get(ATTR_TRANSITION)
if transition is None:
return None
return VALID_TRANSITION(transition)
def _expand_light_groups(
hass: HomeAssistant,
lights: list[str],
@ -3109,7 +3122,7 @@ class AdaptiveLightingManager:
):
return False
transition = turn_off_event.data[ATTR_SERVICE_DATA].get(ATTR_TRANSITION)
transition = _turn_off_transition(turn_off_event)
delay = max(transition or 0, TURNING_OFF_DELAY)
elapsed = (dt_util.utcnow() - turn_off_event.time_fired).total_seconds()
if not 0 <= elapsed <= delay:
@ -3193,7 +3206,7 @@ class AdaptiveLightingManager:
turn_off_event = self.turn_off_event.get(entity_id)
if turn_off_event is not None:
transition = turn_off_event.data[ATTR_SERVICE_DATA].get(ATTR_TRANSITION)
transition = _turn_off_transition(turn_off_event)
else:
transition = None

View file

@ -83,6 +83,7 @@ from homeassistant.components.adaptive_lighting.switch import (
SimpleSwitch,
_attributes_have_changed,
_expand_light_groups,
_turn_off_transition,
color_difference_redmean,
create_context,
is_our_context,
@ -112,6 +113,7 @@ from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_FLOOR_ID,
ATTR_LABEL_ID,
ATTR_SERVICE_DATA,
ATTR_SUPPORTED_FEATURES,
CONF_LIGHTS,
CONF_NAME,
@ -4262,17 +4264,17 @@ def _turn_off_service_event(
entity_ids: list[str],
ts: float,
context: Context,
transition: float,
transition: float | str | None,
) -> Event:
service_data = {ATTR_ENTITY_ID: entity_ids}
if transition is not None:
service_data[ATTR_TRANSITION] = transition
return Event(
EVENT_CALL_SERVICE,
{
"domain": LIGHT_DOMAIN,
"service": SERVICE_TURN_OFF,
"service_data": {
ATTR_ENTITY_ID: entity_ids,
ATTR_TRANSITION: transition,
},
"service_data": service_data,
},
time_fired_timestamp=ts,
context=context,
@ -4566,6 +4568,113 @@ async def test_just_turned_off_same_automation_context(hass, cleanup):
assert not await manager.just_turned_off(ENTITY_LIGHT_1)
@pytest.mark.parametrize(
("transition", "window"),
[(10, 10), (10.0, 10), ("10", 10), ("10000", 6553), ("inf", 6553), (None, 5)],
)
async def test_just_turned_off_normalized_transition(hass, cleanup, transition, window):
"""Both turn-off guards use coerced and clamped transition windows."""
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()
context = Context()
other_context = Context()
# Setting up the switch turns the light on, and that 'turn_on' would be read
# as the legitimate explanation for the 'off' → 'on' state changes below.
manager.turn_on_event.pop(ENTITY_LIGHT_1, None)
def set_events(turn_off_ts: float, off_to_on_context: Context) -> None:
manager.turn_off_event[ENTITY_LIGHT_1] = _turn_off_service_event(
[ENTITY_LIGHT_1],
turn_off_ts,
context,
transition=transition,
)
manager.on_to_off_event[ENTITY_LIGHT_1] = _state_changed_event(
ENTITY_LIGHT_1,
turn_off_ts,
other_context,
)
manager.off_to_on_event[ENTITY_LIGHT_1] = _state_changed_event(
ENTITY_LIGHT_1,
now,
off_to_on_context,
)
# A matching context is ignored within the normalized transition window.
set_events(now - window + 1, context)
assert await manager.just_turned_off(ENTITY_LIGHT_1)
# Past that window the same shape must stop matching.
set_events(now - window - 1, context)
assert not await manager.just_turned_off(ENTITY_LIGHT_1)
# `just_turned_off`'s own `max(transition, TURNING_OFF_DELAY)`: reached when
# the 'off' → 'on' state change carries a fresh context, so the check above
# returns early and the delay is computed from the 'on' → 'off' change.
manager.turn_off_event[ENTITY_LIGHT_1] = _turn_off_service_event(
[ENTITY_LIGHT_1],
now - window - 1,
context,
transition=transition,
)
manager.on_to_off_event[ENTITY_LIGHT_1] = _state_changed_event(
ENTITY_LIGHT_1,
now - window - 1,
context,
)
manager.off_to_on_event[ENTITY_LIGHT_1] = _state_changed_event(
ENTITY_LIGHT_1,
now,
Context(),
)
assert not await manager.just_turned_off(ENTITY_LIGHT_1)
@pytest.mark.parametrize(
("transition", "expected"),
[("2", 2.0), ("10000", 6553), ("inf", 6553), ("-2", 0), (None, None)],
)
async def test_turn_off_event_keeps_raw_transition(hass, cleanup, transition, expected):
"""Normalize raw event data to the same transition used by the light service."""
await setup_lights(hass)
_, switch = await setup_switch(hass, {CONF_LIGHTS: [ENTITY_LIGHT_1]})
await hass.async_block_till_done()
manager = switch.manager
service_data = {ATTR_ENTITY_ID: ENTITY_LIGHT_1}
if transition is not None:
service_data[ATTR_TRANSITION] = transition
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_OFF,
service_data,
blocking=True,
)
await hass.async_block_till_done()
event = manager.turn_off_event[ENTITY_LIGHT_1]
assert event.data[ATTR_SERVICE_DATA].get(ATTR_TRANSITION) == transition
assert _turn_off_transition(event) == expected
# A 'transition' that cannot be coerced is rejected by the schema, so it
# never reaches the listener.
manager.turn_off_event.pop(ENTITY_LIGHT_1)
with pytest.raises(voluptuous.error.MultipleInvalid):
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: ENTITY_LIGHT_1, ATTR_TRANSITION: "not-a-number"},
blocking=True,
)
await hass.async_block_till_done()
assert ENTITY_LIGHT_1 not in manager.turn_off_event
async def test_just_turned_off_group_context_reuse_end_to_end(hass, cleanup):
"""A tracked member turn-on explains a group's reused OFF context (#1378)."""
await setup_lights(hass, with_group=True)