From 7d0f4b610acb5088125aff266ba3d9af10164b2f Mon Sep 17 00:00:00 2001 From: Ahmad Tawakol <2355493+ahmadtawakol@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:03:07 -0300 Subject: [PATCH] Fix TypeError when 'light.turn_off' is called with a string transition (#1589) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * Normalize turn-off transitions with the light service validator --------- Co-authored-by: Claude Opus 5 Co-authored-by: Bas Nijholt --- custom_components/adaptive_lighting/switch.py | 17 ++- tests/test_switch.py | 119 +++++++++++++++++- 2 files changed, 129 insertions(+), 7 deletions(-) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 871dcf03..db1c2d0b 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -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 diff --git a/tests/test_switch.py b/tests/test_switch.py index 65fb5c32..a3f5b689 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -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)