From 69df1fa0a4c9f3a505fb579e6288f47e7d038414 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 6 Sep 2026 14:10:22 -0700 Subject: [PATCH 1/8] fix: track mixed targets during light turn-off --- .../adaptive_lighting/hass_utils.py | 33 ++- custom_components/adaptive_lighting/switch.py | 100 +++++---- tests/test_switch.py | 197 ++++++++++++++++++ 3 files changed, 276 insertions(+), 54 deletions(-) diff --git a/custom_components/adaptive_lighting/hass_utils.py b/custom_components/adaptive_lighting/hass_utils.py index 550ae350..822e2e29 100644 --- a/custom_components/adaptive_lighting/hass_utils.py +++ b/custom_components/adaptive_lighting/hass_utils.py @@ -4,31 +4,30 @@ import logging from collections.abc import Awaitable, Callable from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import device_registry, entity_registry +from homeassistant.helpers.target import async_extract_referenced_entity_ids from homeassistant.util.read_only_dict import ReadOnlyDict +try: + from homeassistant.helpers.target import TargetSelection +except ImportError: # Compatibility with older Home Assistant releases + from homeassistant.helpers.target import TargetSelectorData as TargetSelection + from .adaptation_utils import ServiceData _LOGGER = logging.getLogger(__name__) -def area_entities(hass: HomeAssistant, area_id: str): - """Get all entities linked to an area.""" - ent_reg = entity_registry.async_get(hass) - entity_ids = [ - entry.entity_id - for entry in entity_registry.async_entries_for_area(ent_reg, area_id) - ] - dev_reg = device_registry.async_get(hass) - entity_ids.extend( - [ - entity.entity_id - for device in device_registry.async_entries_for_area(dev_reg, area_id) - for entity in entity_registry.async_entries_for_device(ent_reg, device.id) - if entity.area_id is None - ], +def target_entities( + hass: HomeAssistant, + service_data: ServiceData, +) -> set[str]: + """Resolve all directly and indirectly targeted entities without groups.""" + selected = async_extract_referenced_entity_ids( + hass, + TargetSelection(service_data), + expand_group=False, ) - return entity_ids + return selected.referenced | selected.indirectly_referenced def setup_service_call_interceptor( diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index d784f325..a22c8cb4 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -11,7 +11,6 @@ from copy import deepcopy from datetime import timedelta from typing import TYPE_CHECKING, Any -import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util import ulid_transform from homeassistant.components.light import ( @@ -32,8 +31,11 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( ATTR_AREA_ID, + ATTR_DEVICE_ID, ATTR_DOMAIN, ATTR_ENTITY_ID, + ATTR_FLOOR_ID, + ATTR_LABEL_ID, ATTR_SERVICE, ATTR_SERVICE_DATA, ATTR_SUPPORTED_FEATURES, @@ -149,7 +151,7 @@ from .const import ( change_switch_settings_schema, replace_none_str, ) -from .hass_utils import area_entities, setup_service_call_interceptor +from .hass_utils import setup_service_call_interceptor, target_entities from .helpers import ( clamp, color_difference_redmean, @@ -1970,13 +1972,6 @@ class AdaptiveLightingManager: self._context_cnt += 1 return context - def _is_excluded_from_area(self, entity_id: str) -> bool: - """Match Home Assistant's exclusions for indirect area targets.""" - entry = entity_registry.async_get(self.hass).async_get(entity_id) - return entry is not None and ( - entry.entity_category is not None or entry.hidden_by is not None - ) - def _separate_entity_ids( self, entity_ids: list[str], @@ -2160,8 +2155,14 @@ class AdaptiveLightingManager: entity_ids: list[str], ) -> dict[str, Any]: """Modify the service data to contain the entity IDs.""" - service_data.pop(ATTR_ENTITY_ID, None) - service_data.pop(ATTR_AREA_ID, None) + for target_key in ( + ATTR_ENTITY_ID, + ATTR_AREA_ID, + ATTR_DEVICE_ID, + ATTR_FLOOR_ID, + ATTR_LABEL_ID, + ): + service_data.pop(target_key, None) service_data[ATTR_ENTITY_ID] = entity_ids return service_data @@ -2633,31 +2634,11 @@ class AdaptiveLightingManager: records.pop(light, None) def _get_entity_list(self, service_data: ServiceData) -> list[str]: - if ATTR_ENTITY_ID in service_data: - return cv.ensure_list_csv(service_data[ATTR_ENTITY_ID]) - if ATTR_AREA_ID in service_data: - entity_ids: list[str] = [] - area_ids: list[str] = cv.ensure_list_csv(service_data[ATTR_AREA_ID]) - for area_id in area_ids: - area_entity_ids = area_entities(self.hass, area_id) - eids = [ - entity_id - for entity_id in area_entity_ids - if entity_id.startswith(LIGHT_DOMAIN) - and not self._is_excluded_from_area(entity_id) - ] - entity_ids.extend(eids) - _LOGGER.debug( - "Found entity_ids '%s' for area_id '%s'", - entity_ids, - area_id, - ) - return entity_ids - _LOGGER.debug( - "No entity_ids or area_ids found in service_data: %s", - service_data, + return sorted( + entity_id + for entity_id in target_entities(self.hass, service_data) + if entity_id.startswith(f"{LIGHT_DOMAIN}.") ) - return [] async def turn_on_off_event_listener(self, event: Event) -> None: """Track 'light.turn_off' and 'light.turn_on' service calls.""" @@ -3052,7 +3033,7 @@ class AdaptiveLightingManager: def _member_turn_on_explains_group_turn_on( self, entity_id: str, - on_to_off_event: Event[EventStateChangedData], + off_event: Event, off_to_on_event: Event[EventStateChangedData], ) -> bool: """Check if a light group's 'off' → 'on' is caused by a member's 'light.turn_on'. @@ -3072,7 +3053,7 @@ class AdaptiveLightingManager: member_turn_on = self.turn_on_event.get(member) if ( member_turn_on is not None - and on_to_off_event.time_fired + and off_event.time_fired < member_turn_on.time_fired <= off_to_on_event.time_fired ): @@ -3087,6 +3068,49 @@ class AdaptiveLightingManager: return True return False + def _off_to_on_event_is_during_turn_off( + self, + entity_id: str, + off_to_on_event: Event[EventStateChangedData], + ) -> bool: + """Check if a reported turn-on belongs to a recent turn-off window.""" + turn_off_event = self.turn_off_event.get(entity_id) + if ( + turn_off_event is None + or off_to_on_event.context.id != turn_off_event.context.id + ): + return False + + turn_on_event = self.turn_on_event.get(entity_id) + if ( + turn_on_event is not None + and turn_off_event.time_fired + < turn_on_event.time_fired + <= off_to_on_event.time_fired + ): + return False + if self._member_turn_on_explains_group_turn_on( + entity_id, + turn_off_event, + off_to_on_event, + ): + return False + + transition = turn_off_event.data[ATTR_SERVICE_DATA].get(ATTR_TRANSITION) + delay = max(transition or 0, TURNING_OFF_DELAY) + elapsed = (dt_util.utcnow() - turn_off_event.time_fired).total_seconds() + if not 0 <= elapsed <= delay: + return False + + _LOGGER.debug( + "just_turned_off: Fresh 'light.turn_off' for '%s' shares the" + " 'off' → 'on' context; ignoring the state during its %s second" + " transition window.", + entity_id, + delay, + ) + return True + async def just_turned_off( # noqa: PLR0911, PLR0912 self, entity_id: str, @@ -3105,6 +3129,8 @@ class AdaptiveLightingManager: """ off_to_on_event = self.off_to_on_event[entity_id] on_to_off_event = self.on_to_off_event.get(entity_id) + if self._off_to_on_event_is_during_turn_off(entity_id, off_to_on_event): + return True if on_to_off_event is None: _LOGGER.debug( diff --git a/tests/test_switch.py b/tests/test_switch.py index 1198f49f..5db7c9a5 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -108,6 +108,8 @@ from homeassistant.const import ( ATTR_AREA_ID, ATTR_DEVICE_ID, ATTR_ENTITY_ID, + ATTR_FLOOR_ID, + ATTR_LABEL_ID, ATTR_SUPPORTED_FEATURES, CONF_LIGHTS, CONF_NAME, @@ -4251,6 +4253,27 @@ def _turn_on_service_event(entity_ids: list[str], ts: float, context: Context) - ) +def _turn_off_service_event( + entity_ids: list[str], + ts: float, + context: Context, + transition: float, +) -> Event: + return Event( + EVENT_CALL_SERVICE, + { + "domain": LIGHT_DOMAIN, + "service": SERVICE_TURN_OFF, + "service_data": { + ATTR_ENTITY_ID: entity_ids, + ATTR_TRANSITION: transition, + }, + }, + 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. @@ -4309,6 +4332,154 @@ async def test_just_turned_off_group_context_reuse(hass, cleanup): assert await manager.just_turned_off(group) +def _register_mixed_target_lights( + hass, + device_registry, + floor_registry, + label_registry, +): + """Assign the three test lights to mixed indirect HA targets.""" + floor = floor_registry.async_create("Upstairs") + area_registry = ar.async_get(hass) + upstairs_area = area_registry.async_create( + "Upstairs room", + floor_id=floor.floor_id, + ) + hall_area = area_registry.async_create("Hall") + + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("test", "device-target")}, + ) + label = label_registry.async_create("Skipped light") + + registry = entity_registry.async_get(hass) + registry.async_update_entity(ENTITY_LIGHT_1, area_id=upstairs_area.id) + registry.async_update_entity(ENTITY_LIGHT_2, area_id=hall_area.id) + registry.async_update_entity( + ENTITY_LIGHT_3, + device_id=device.id, + labels={label.label_id}, + ) + return { + ATTR_FLOOR_ID: floor.floor_id, + ATTR_AREA_ID: hall_area.id, + ATTR_DEVICE_ID: device.id, + ATTR_LABEL_ID: label.label_id, + } + + +async def test_mixed_turn_off_targets_do_not_readapt_off_device_light( + hass, + device_registry, + floor_registry, + label_registry, + cleanup, +): + """A mixed-target turn-off must cover an already-off device light (#1069).""" + await setup_lights(hass) + targets = _register_mixed_target_lights( + hass, + device_registry, + floor_registry, + label_registry, + ) + targets.pop(ATTR_LABEL_ID) + + _, switch = await setup_switch( + hass, + { + CONF_LIGHTS: [ENTITY_LIGHT_1, ENTITY_LIGHT_2, ENTITY_LIGHT_3], + CONF_DETECT_NON_HA_CHANGES: True, + CONF_INTERCEPT: True, + CONF_INITIAL_TRANSITION: 0, + }, + ) + assert hass.states.is_state(ENTITY_LIGHT_3, STATE_OFF) + + turn_off_context = Context() + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + { + **targets, + ATTR_TRANSITION: 10, + }, + blocking=True, + context=turn_off_context, + ) + await hass.async_block_till_done() + + calls = _track_adaptive_light_calls(hass) + off_state = hass.states.get(ENTITY_LIGHT_3) + assert off_state is not None + hass.states.async_set( + ENTITY_LIGHT_3, + STATE_ON, + off_state.attributes, + context=turn_off_context, + ) + await hass.async_block_till_done() + + assert not calls + + +async def test_intercept_replaces_all_mixed_target_selectors( + hass, + device_registry, + floor_registry, + label_registry, + cleanup, +): + """A narrowed intercepted call must not retain indirect target selectors.""" + lights = await setup_lights(hass) + targets = _register_mixed_target_lights( + hass, + device_registry, + floor_registry, + label_registry, + ) + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: [ENTITY_LIGHT_1, ENTITY_LIGHT_2, ENTITY_LIGHT_3], + }, + blocking=True, + ) + await setup_switch( + hass, + { + CONF_LIGHTS: [ENTITY_LIGHT_1, ENTITY_LIGHT_2], + CONF_INTERCEPT: True, + CONF_MULTI_LIGHT_INTERCEPT: True, + CONF_INITIAL_TRANSITION: 0, + CONF_MIN_BRIGHTNESS: 50, + CONF_MAX_BRIGHTNESS: 50, + }, + ) + + with patch.object( + lights[2], + "async_turn_on", + wraps=lights[2].async_turn_on, + ) as skipped_turn_on: + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {**targets, ATTR_BRIGHTNESS: 200}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hass.states.get(ENTITY_LIGHT_1).attributes[ATTR_BRIGHTNESS] == 128 + assert hass.states.get(ENTITY_LIGHT_2).attributes[ATTR_BRIGHTNESS] == 128 + skipped_turn_on.assert_awaited_once() + assert skipped_turn_on.call_args.kwargs[ATTR_BRIGHTNESS] == 200 + + async def test_just_turned_off_same_automation_context(hass, cleanup): """'turn_off' and 'turn_on' from one automation share a context. @@ -4325,6 +4496,12 @@ async def test_just_turned_off_same_automation_context(hass, cleanup): now = dt_util.utcnow().timestamp() automation_context = Context() + manager.turn_off_event[ENTITY_LIGHT_1] = _turn_off_service_event( + [ENTITY_LIGHT_1], + now - 2, + automation_context, + transition=10, + ) manager.on_to_off_event[ENTITY_LIGHT_1] = _state_changed_event( ENTITY_LIGHT_1, now - 2, @@ -4363,6 +4540,26 @@ async def test_just_turned_off_same_automation_context(hass, cleanup): ) assert await manager.just_turned_off(ENTITY_LIGHT_1) + # A later physical turn-on has a fresh context and must not remain blocked by + # the old turn-off record after its transition window has elapsed. + manager.on_to_off_event[ENTITY_LIGHT_1] = _state_changed_event( + ENTITY_LIGHT_1, + now - 20, + automation_context, + ) + manager.turn_off_event[ENTITY_LIGHT_1] = _turn_off_service_event( + [ENTITY_LIGHT_1], + now - 20, + automation_context, + transition=10, + ) + 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) + 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).""" From da75a16d365b46474ec36de079e028a1ba0cf3d5 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 6 Sep 2026 14:13:29 -0700 Subject: [PATCH 2/8] Handle lights recovering from unavailable --- README.md | 2 +- custom_components/adaptive_lighting/switch.py | 43 ++- tests/test_switch.py | 324 ++++++++++++++++++ 3 files changed, 359 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 6f710516..c25b6250 100644 --- a/README.md +++ b/README.md @@ -768,7 +768,7 @@ If lights stop adapting after you turn them on with a physical switch or a Zigbe To adapt these turn-ons while still detecting later manual changes, enable `detect_non_ha_changes` and leave `manual_control_on_external_turn_on` disabled. This requires the light integration to report its state reliably. If you want Adaptive Lighting to keep adapting regardless of manual changes, disable `take_over_control` along with the options that require it: `detect_non_ha_changes`, `adapt_only_on_bare_turn_on`, and `manual_control_on_external_turn_on`. -This explains the physical-switch case in [#1056](https://github.com/basnijholt/adaptive-lighting/issues/1056), but not every report in that thread. If the light is not listed in `manual_control`, include diagnostics and debug logs from the failed turn-on when reporting it. Lights returning from `unavailable` after a power cut are a separate case from an `off` to `on` state change. +This explains the physical-switch case in [#1056](https://github.com/basnijholt/adaptive-lighting/issues/1056), but not every report in that thread. If the light is not listed in `manual_control`, include diagnostics and debug logs from the failed turn-on when reporting it. When a light returns directly from `unavailable` to `on`, Adaptive Lighting uses the same turn-on policy and, if adaptation is allowed, applies `adapt_delay` and `initial_transition` even with `only_once`. Existing `manual_control` remains set because availability alone cannot distinguish a power cycle from a temporary connection loss. #### :bulb: Lights Not Responding or Turning On by Themselves diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index d784f325..f7870cbd 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -47,6 +47,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, + STATE_UNAVAILABLE, ) from homeassistant.core import ( CALLBACK_TYPE, @@ -1564,9 +1565,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): # being turned off in 'interval' update, see #726 not self._detect_non_ha_changes and is_our_context(context, "interval") - and (turn_on := self.manager.turn_on_event.get(light)) - and (turn_off := self.manager.turn_off_event.get(light)) - and turn_off.time_fired > turn_on.time_fired + and self.manager.last_service_call_was_turn_off(light) ): _LOGGER.debug( "%s: Light '%s' was turned just turned off, context.id='%s'", @@ -1688,8 +1687,22 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): if self._adapt_delay > 0: 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: + # Runtime settings may disable the profile or retire its target while waiting. + if not self.is_on or entity_id not in self.lights: + return + + old_state = event.data["old_state"] + if ( + old_state is not None + and old_state.state == STATE_UNAVAILABLE + and not self._detect_non_ha_changes + and self.manager.last_service_call_was_turn_off(entity_id) + ): + _LOGGER.debug( + "%s: Skipping recovery of '%s' after a light.turn_off call", + self._name, + entity_id, + ) return await self._update_attrs_and_maybe_adapt_lights( @@ -2777,6 +2790,7 @@ class AdaptiveLightingManager: if old_state is not None and old_state.state == STATE_OFF else None ) + old_unavailable = old_state is not None and old_state.state == STATE_UNAVAILABLE if new_on: _LOGGER.debug( "Detected a '%s' 'state_changed' event: '%s' with context.id='%s'", @@ -2836,11 +2850,14 @@ class AdaptiveLightingManager: entity_id, event.context.id, ) - elif old_off and new_on: - # Tracks 'off' → 'on' state changes + elif (old_off or old_unavailable) and new_on: + # Treat a recovered light like one that was turned on, without + # treating the preceding loss of availability as a turn-off. self.off_to_on_event[entity_id] = event + old_state_name = STATE_OFF if old_off else STATE_UNAVAILABLE _LOGGER.debug( - "Detected an 'off' → 'on' event for '%s' with context.id='%s'", + "Detected an '%s' → 'on' event for '%s' with context.id='%s'", + old_state_name, entity_id, event.context.id, ) @@ -2858,7 +2875,7 @@ class AdaptiveLightingManager: self.reset(entity_id, reset_manual_control=False) lock = self.turn_off_locks.setdefault(entity_id, asyncio.Lock()) async with lock: - if await self.just_turned_off(entity_id): + if old_off and await self.just_turned_off(entity_id): # Stop if a rapid 'off' → 'on' → 'off' happens. _LOGGER.debug( "Cancelling adjusting lights for %s", @@ -2878,6 +2895,14 @@ class AdaptiveLightingManager: event, ) + def last_service_call_was_turn_off(self, entity_id: str) -> bool: + """Return whether the most recent tracked service call turned a light off.""" + turn_on = self.turn_on_event.get(entity_id) + turn_off = self.turn_off_event.get(entity_id) + return turn_off is not None and ( + turn_on is None or turn_off.time_fired > turn_on.time_fired + ) + async def update_manually_controlled_from_event( self, switch: AdaptiveSwitch, diff --git a/tests/test_switch.py b/tests/test_switch.py index 1198f49f..5d20ff84 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -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, @@ -117,6 +119,8 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, EntityCategory, ) from homeassistant.core import Context, CoreState, Event, HomeAssistant, State @@ -4976,6 +4980,326 @@ async def test_manual_control_on_external_turn_on_external_state_change( assert hass.states.get(ENTITY_LIGHT_1).attributes[ATTR_BRIGHTNESS] == 200 +async def _recover_light_from_unavailable(hass, brightness=200): + """Publish the state sequence emitted when a powered-off bulb reconnects.""" + attributes = dict(hass.states.get(ENTITY_LIGHT_1).attributes) + attributes[ATTR_BRIGHTNESS] = brightness + hass.states.async_set( + ENTITY_LIGHT_1, + STATE_UNAVAILABLE, + attributes, + context=Context(id="became_unavailable"), + ) + await hass.async_block_till_done() + hass.states.async_set( + ENTITY_LIGHT_1, + STATE_ON, + attributes, + context=Context(id="recovered_on"), + ) + await hass.async_block_till_done() + + +@pytest.mark.parametrize( + ("only_once", "take_over_control", "detect_non_ha_changes"), + [(False, True, True), (True, True, True), (False, False, False)], +) +async def test_unavailable_light_recovery_adapts_immediately( + hass, + only_once, + take_over_control, + detect_non_ha_changes, +): + """A trusted unavailable-to-on recovery is an initial adaptation (#307).""" + switch, _ = await setup_lights_and_switch( + hass, + { + CONF_TAKE_OVER_CONTROL: take_over_control, + CONF_DETECT_NON_HA_CHANGES: detect_non_ha_changes, + CONF_ONLY_ONCE: only_once, + CONF_INITIAL_TRANSITION: 0, + CONF_MIN_BRIGHTNESS: 50, + CONF_MAX_BRIGHTNESS: 50, + }, + ) + + await _recover_light_from_unavailable(hass) + + assert hass.states.get(ENTITY_LIGHT_1).attributes[ATTR_BRIGHTNESS] == 128 + assert ( + switch.manager.get_manual_control_attributes(ENTITY_LIGHT_1) + == LightControlAttributes.NONE + ) + + +@pytest.mark.parametrize( + ("detect_non_ha_changes", "external_turn_on_is_manual"), + [(False, False), (True, True)], +) +async def test_unavailable_light_recovery_respects_external_turn_on_policy( + hass, + detect_non_ha_changes, + external_turn_on_is_manual, +): + """Recovery follows the same opt-in policy as an unmatched turn-on.""" + switch, _ = await setup_lights_and_switch( + hass, + { + CONF_DETECT_NON_HA_CHANGES: detect_non_ha_changes, + CONF_MANUAL_CONTROL_ON_EXTERNAL_TURN_ON: external_turn_on_is_manual, + CONF_INITIAL_TRANSITION: 0, + CONF_MIN_BRIGHTNESS: 50, + CONF_MAX_BRIGHTNESS: 50, + }, + ) + + await _recover_light_from_unavailable(hass) + + assert hass.states.get(ENTITY_LIGHT_1).attributes[ATTR_BRIGHTNESS] == 200 + assert ( + switch.manager.get_manual_control_attributes(ENTITY_LIGHT_1) + == LightControlAttributes.ALL + ) + + +async def test_unavailable_light_recovery_preserves_manual_control(hass): + """A reconnect does not discard existing manual-control intent.""" + switch, _ = await setup_lights_and_switch( + hass, + { + CONF_DETECT_NON_HA_CHANGES: True, + CONF_INITIAL_TRANSITION: 0, + CONF_MIN_BRIGHTNESS: 50, + CONF_MAX_BRIGHTNESS: 50, + }, + ) + switch.manager.set_manual_control_attributes(ENTITY_LIGHT_1) + + await _recover_light_from_unavailable(hass) + + assert hass.states.get(ENTITY_LIGHT_1).attributes[ATTR_BRIGHTNESS] == 200 + assert ( + switch.manager.get_manual_control_attributes(ENTITY_LIGHT_1) + == LightControlAttributes.ALL + ) + + +async def test_unavailable_light_recovery_adapts_non_manual_attributes(hass): + """PAUSE_CHANGED keeps manual brightness while adapting color.""" + switch, lights = await setup_lights_and_switch( + hass, + { + CONF_TAKE_OVER_CONTROL_MODE: TakeOverControlMode.PAUSE_CHANGED.value, + CONF_DETECT_NON_HA_CHANGES: True, + CONF_INITIAL_TRANSITION: 0, + CONF_MIN_BRIGHTNESS: 50, + CONF_MAX_BRIGHTNESS: 50, + }, + ) + switch.manager.set_manual_control_attributes( + ENTITY_LIGHT_1, + LightControlAttributes.BRIGHTNESS, + ) + switch.manager.last_service_data.pop(ENTITY_LIGHT_1, None) + set_light_brightness(lights[0], 200) + + await _recover_light_from_unavailable(hass) + + assert hass.states.get(ENTITY_LIGHT_1).attributes[ATTR_BRIGHTNESS] == 200 + assert ( + switch.manager.get_manual_control_attributes(ENTITY_LIGHT_1) + == LightControlAttributes.BRIGHTNESS + ) + service_data = switch.manager.last_service_data[ENTITY_LIGHT_1] + assert ATTR_BRIGHTNESS not in service_data + assert ATTR_COLOR_TEMP_KELVIN in service_data + + +async def test_unavailable_light_recovery_ignores_disabled_profile(hass): + """A disabled profile does not act when one of its lights reconnects.""" + switch, _ = await setup_lights_and_switch( + hass, + { + CONF_DETECT_NON_HA_CHANGES: True, + CONF_INITIAL_TRANSITION: 0, + CONF_MIN_BRIGHTNESS: 50, + CONF_MAX_BRIGHTNESS: 50, + }, + ) + await switch.async_turn_off() + + await _recover_light_from_unavailable(hass) + + assert hass.states.get(ENTITY_LIGHT_1).attributes[ATTR_BRIGHTNESS] == 200 + + +async def test_unknown_to_on_does_not_trigger_recovery(hass): + """An unknown-to-on update remains outside unavailable recovery.""" + await setup_lights_and_switch( + hass, + { + CONF_TAKE_OVER_CONTROL: False, + CONF_INITIAL_TRANSITION: 0, + CONF_MIN_BRIGHTNESS: 50, + CONF_MAX_BRIGHTNESS: 50, + }, + ) + attributes = dict(hass.states.get(ENTITY_LIGHT_1).attributes) + attributes[ATTR_BRIGHTNESS] = 200 + hass.states.async_set(ENTITY_LIGHT_1, STATE_UNKNOWN, attributes) + await hass.async_block_till_done() + hass.states.async_set(ENTITY_LIGHT_1, STATE_ON, attributes) + await hass.async_block_till_done() + + assert hass.states.get(ENTITY_LIGHT_1).attributes[ATTR_BRIGHTNESS] == 200 + + +async def test_unavailable_light_is_skipped_by_interval(hass): + """Periodic updates do not send commands to unavailable lights.""" + switch, _ = await setup_lights_and_switch(hass) + attributes = dict(hass.states.get(ENTITY_LIGHT_1).attributes) + hass.states.async_set(ENTITY_LIGHT_1, STATE_UNAVAILABLE, attributes) + await hass.async_block_till_done() + switch.manager.last_service_data.pop(ENTITY_LIGHT_1, None) + + await switch._update_attrs_and_maybe_adapt_lights( + context=switch.create_context("interval"), + lights=[ENTITY_LIGHT_1], + ) + + assert ENTITY_LIGHT_1 not in switch.manager.last_service_data + + +@pytest.mark.parametrize( + ("turn_on_timestamp", "should_adapt"), + [(None, False), (1.0, False), (3.0, True)], +) +async def test_interval_respects_latest_turn_off_service( + hass, + turn_on_timestamp, + should_adapt, +): + """Interval adaptation follows the latest tracked on/off service intent.""" + switch, _ = await setup_lights_and_switch( + hass, + { + CONF_TAKE_OVER_CONTROL: False, + CONF_DETECT_NON_HA_CHANGES: False, + CONF_INITIAL_TRANSITION: 0, + }, + ) + manager = switch.manager + manager.last_service_data.pop(ENTITY_LIGHT_1, None) + manager.turn_off_event[ENTITY_LIGHT_1] = Event( + EVENT_CALL_SERVICE, + {}, + time_fired_timestamp=2.0, + ) + manager.turn_on_event.pop(ENTITY_LIGHT_1, None) + if turn_on_timestamp is not None: + manager.turn_on_event[ENTITY_LIGHT_1] = Event( + EVENT_CALL_SERVICE, + {}, + time_fired_timestamp=turn_on_timestamp, + ) + + await switch._update_attrs_and_maybe_adapt_lights( + context=switch.create_context("interval"), + lights=[ENTITY_LIGHT_1], + ) + + assert (ENTITY_LIGHT_1 in manager.last_service_data) is should_adapt + + +async def test_unavailable_light_recovery_preserves_recent_turn_off(hass): + """A false on report does not override a newer explicit turn-off.""" + switch, _ = await setup_lights_and_switch( + hass, + { + CONF_TAKE_OVER_CONTROL: False, + CONF_DETECT_NON_HA_CHANGES: False, + CONF_INITIAL_TRANSITION: 0, + CONF_MIN_BRIGHTNESS: 50, + CONF_MAX_BRIGHTNESS: 50, + }, + ) + switch.manager.turn_on_event.pop(ENTITY_LIGHT_1, None) + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_LIGHT_1}, + blocking=True, + ) + await hass.async_block_till_done() + assert ENTITY_LIGHT_1 not in switch.manager.turn_on_event + + await _recover_light_from_unavailable(hass) + + assert hass.states.get(ENTITY_LIGHT_1).attributes[ATTR_BRIGHTNESS] == 200 + + +@pytest.mark.parametrize("cancel_reason", ["light_turn_off", "profile_disabled"]) +async def test_unavailable_light_recovery_cancelled_during_delay( + hass, + monkeypatch, + cancel_reason, +): + """A changed off intent during recovery delay prevents adaptation.""" + switch, _ = await setup_lights_and_switch( + hass, + { + CONF_TAKE_OVER_CONTROL: False, + CONF_DETECT_NON_HA_CHANGES: False, + CONF_ADAPT_DELAY: 0.1234, + CONF_INITIAL_TRANSITION: 0, + CONF_MIN_BRIGHTNESS: 50, + CONF_MAX_BRIGHTNESS: 50, + }, + ) + attributes = dict(hass.states.get(ENTITY_LIGHT_1).attributes) + attributes[ATTR_BRIGHTNESS] = 200 + hass.states.async_set(ENTITY_LIGHT_1, STATE_UNAVAILABLE, attributes) + await hass.async_block_till_done() + + entered_delay = asyncio.Event() + release_delay = asyncio.Event() + original_sleep = asyncio.sleep + + async def controlled_sleep(delay, *args, **kwargs): + if delay == 0.1234: + entered_delay.set() + await release_delay.wait() + else: + await original_sleep(delay, *args, **kwargs) + + monkeypatch.setattr(asyncio, "sleep", controlled_sleep) + hass.states.async_set(ENTITY_LIGHT_1, STATE_ON, attributes) + await entered_delay.wait() + if cancel_reason == "light_turn_off": + hass.bus.async_fire( + EVENT_CALL_SERVICE, + { + "domain": LIGHT_DOMAIN, + "service": SERVICE_TURN_OFF, + "service_data": { + ATTR_ENTITY_ID: ENTITY_LIGHT_1, + ATTR_TRANSITION: 10, + }, + }, + context=Context(id="turn_off_during_recovery_delay"), + ) + await original_sleep(0) + assert hass.states.get(ENTITY_LIGHT_1).state == STATE_ON + assert switch.manager.last_service_call_was_turn_off(ENTITY_LIGHT_1) + else: + await switch.async_turn_off() + assert not switch.is_on + release_delay.set() + await hass.async_block_till_done() + + assert hass.states.get(ENTITY_LIGHT_1).attributes[ATTR_BRIGHTNESS] == 200 + + @pytest.mark.parametrize("intercept", [True, False]) async def test_manual_control_on_external_turn_on_keeps_non_ha_change_detection( hass, From 3b93032519ec3d0fe71314a5637e972ae5783df2 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 6 Sep 2026 14:18:45 -0700 Subject: [PATCH 3/8] Preserve turn-off intent during light recovery --- custom_components/adaptive_lighting/switch.py | 24 +++-- tests/test_switch.py | 89 ++++++++++++++++++- 2 files changed, 103 insertions(+), 10 deletions(-) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index aa2fe42b..7bf17ba3 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -1697,8 +1697,10 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): if ( old_state is not None and old_state.state == STATE_UNAVAILABLE - and not self._detect_non_ha_changes - and self.manager.last_service_call_was_turn_off(entity_id) + and self.manager.last_service_call_was_turn_off( + entity_id, + after=event if self._detect_non_ha_changes else None, + ) ): _LOGGER.debug( "%s: Skipping recovery of '%s' after a light.turn_off call", @@ -2856,7 +2858,10 @@ class AdaptiveLightingManager: self.reset(entity_id, reset_manual_control=False) lock = self.turn_off_locks.setdefault(entity_id, asyncio.Lock()) async with lock: - if old_off and await self.just_turned_off(entity_id): + if ( + old_unavailable + and self._off_to_on_event_is_during_turn_off(entity_id, event) + ) or (old_off and await self.just_turned_off(entity_id)): # Stop if a rapid 'off' → 'on' → 'off' happens. _LOGGER.debug( "Cancelling adjusting lights for %s", @@ -2876,12 +2881,19 @@ class AdaptiveLightingManager: event, ) - def last_service_call_was_turn_off(self, entity_id: str) -> bool: + def last_service_call_was_turn_off( + self, + entity_id: str, + *, + after: Event | None = None, + ) -> bool: """Return whether the most recent tracked service call turned a light off.""" turn_on = self.turn_on_event.get(entity_id) turn_off = self.turn_off_event.get(entity_id) - return turn_off is not None and ( - turn_on is None or turn_off.time_fired > turn_on.time_fired + return ( + turn_off is not None + and (turn_on is None or turn_off.time_fired > turn_on.time_fired) + and (after is None or turn_off.time_fired > after.time_fired) ) async def update_manually_controlled_from_event( diff --git a/tests/test_switch.py b/tests/test_switch.py index 32366bc8..e01302f4 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -5435,18 +5435,82 @@ async def test_unavailable_light_recovery_preserves_recent_turn_off(hass): assert hass.states.get(ENTITY_LIGHT_1).attributes[ATTR_BRIGHTNESS] == 200 -@pytest.mark.parametrize("cancel_reason", ["light_turn_off", "profile_disabled"]) +@pytest.mark.parametrize(("elapsed", "should_adapt"), [(0, False), (11, True)]) +async def test_unavailable_recovery_respects_fresh_turn_off_window( + hass, + freezer, + elapsed, + should_adapt, +): + """Recovery must not reverse an active fade, but adapts after it expires.""" + await setup_lights_and_switch( + hass, + { + CONF_TAKE_OVER_CONTROL: True, + CONF_DETECT_NON_HA_CHANGES: True, + CONF_INITIAL_TRANSITION: 0, + CONF_MIN_BRIGHTNESS: 50, + CONF_MAX_BRIGHTNESS: 50, + }, + ) + turn_off_context = Context() + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_LIGHT_1, ATTR_TRANSITION: 10}, + blocking=True, + context=turn_off_context, + ) + await hass.async_block_till_done() + + attributes = dict(hass.states.get(ENTITY_LIGHT_1).attributes) + attributes[ATTR_BRIGHTNESS] = 200 + hass.states.async_set( + ENTITY_LIGHT_1, + STATE_UNAVAILABLE, + attributes, + context=turn_off_context, + ) + await hass.async_block_till_done() + freezer.tick(elapsed) + + calls = _track_adaptive_light_calls(hass) + hass.states.async_set( + ENTITY_LIGHT_1, + STATE_ON, + attributes, + context=turn_off_context, + ) + await hass.async_block_till_done() + + if should_adapt: + assert calls[-1][ATTR_BRIGHTNESS] == 128 + else: + assert not calls + + +@pytest.mark.parametrize( + ("cancel_reason", "detect_non_ha_changes", "expected_brightness"), + [ + ("light_turn_off", False, 200), + ("light_turn_off", True, 200), + ("turn_off_then_turn_on", True, 128), + ("profile_disabled", False, 200), + ], +) async def test_unavailable_light_recovery_cancelled_during_delay( hass, monkeypatch, cancel_reason, + detect_non_ha_changes, + expected_brightness, ): """A changed off intent during recovery delay prevents adaptation.""" switch, _ = await setup_lights_and_switch( hass, { CONF_TAKE_OVER_CONTROL: False, - CONF_DETECT_NON_HA_CHANGES: False, + CONF_DETECT_NON_HA_CHANGES: detect_non_ha_changes, CONF_ADAPT_DELAY: 0.1234, CONF_INITIAL_TRANSITION: 0, CONF_MIN_BRIGHTNESS: 50, @@ -5472,7 +5536,7 @@ async def test_unavailable_light_recovery_cancelled_during_delay( monkeypatch.setattr(asyncio, "sleep", controlled_sleep) hass.states.async_set(ENTITY_LIGHT_1, STATE_ON, attributes) await entered_delay.wait() - if cancel_reason == "light_turn_off": + if cancel_reason in ("light_turn_off", "turn_off_then_turn_on"): hass.bus.async_fire( EVENT_CALL_SERVICE, { @@ -5488,13 +5552,30 @@ async def test_unavailable_light_recovery_cancelled_during_delay( await original_sleep(0) assert hass.states.get(ENTITY_LIGHT_1).state == STATE_ON assert switch.manager.last_service_call_was_turn_off(ENTITY_LIGHT_1) + if cancel_reason == "turn_off_then_turn_on": + hass.bus.async_fire( + EVENT_CALL_SERVICE, + { + "domain": LIGHT_DOMAIN, + "service": SERVICE_TURN_ON, + "service_data": {ATTR_ENTITY_ID: ENTITY_LIGHT_1}, + }, + context=Context(id="turn_on_during_recovery_delay"), + ) + await original_sleep(0) + assert not switch.manager.last_service_call_was_turn_off( + ENTITY_LIGHT_1, + ) else: await switch.async_turn_off() assert not switch.is_on release_delay.set() await hass.async_block_till_done() - assert hass.states.get(ENTITY_LIGHT_1).attributes[ATTR_BRIGHTNESS] == 200 + assert ( + hass.states.get(ENTITY_LIGHT_1).attributes[ATTR_BRIGHTNESS] + == expected_brightness + ) @pytest.mark.parametrize("intercept", [True, False]) From 97133597e3d7ed5c784151b77797a65e8e73a2c1 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 6 Sep 2026 14:21:33 -0700 Subject: [PATCH 4/8] docs: refresh generated recovery troubleshooting --- docs/troubleshooting.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index f0596563..57b617ce 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -51,7 +51,7 @@ If lights stop adapting after you turn them on with a physical switch or a Zigbe To adapt these turn-ons while still detecting later manual changes, enable `detect_non_ha_changes` and leave `manual_control_on_external_turn_on` disabled. This requires the light integration to report its state reliably. If you want Adaptive Lighting to keep adapting regardless of manual changes, disable `take_over_control` along with the options that require it: `detect_non_ha_changes`, `adapt_only_on_bare_turn_on`, and `manual_control_on_external_turn_on`. -This explains the physical-switch case in [#1056](https://github.com/basnijholt/adaptive-lighting/issues/1056), but not every report in that thread. If the light is not listed in `manual_control`, include diagnostics and debug logs from the failed turn-on when reporting it. Lights returning from `unavailable` after a power cut are a separate case from an `off` to `on` state change. +This explains the physical-switch case in [#1056](https://github.com/basnijholt/adaptive-lighting/issues/1056), but not every report in that thread. If the light is not listed in `manual_control`, include diagnostics and debug logs from the failed turn-on when reporting it. When a light returns directly from `unavailable` to `on`, Adaptive Lighting uses the same turn-on policy and, if adaptation is allowed, applies `adapt_delay` and `initial_transition` even with `only_once`. Existing `manual_control` remains set because availability alone cannot distinguish a power cycle from a temporary connection loss. #### :bulb: Lights Not Responding or Turning On by Themselves From e6dce70163cd678be055f7b682b963f6e25fad8e Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 6 Sep 2026 23:30:42 -0700 Subject: [PATCH 5/8] Fix pending adaptations after light or profile removal --- custom_components/adaptive_lighting/switch.py | 19 +- tests/test_switch.py | 245 ++++++++++++++++++ 2 files changed, 261 insertions(+), 3 deletions(-) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index a22c8cb4..871dcf03 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -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 diff --git a/tests/test_switch.py b/tests/test_switch.py index 5db7c9a5..6efe68d5 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -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] From 9f4089f3d847e0b8d6a0654c62713d7251ec9e5f Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 6 Sep 2026 23:40:31 -0700 Subject: [PATCH 6/8] Resume eligible light adaptation after availability returns --- README.md | 4 +- custom_components/adaptive_lighting/switch.py | 120 +++-- docs/troubleshooting.md | 4 +- tests/test_switch.py | 431 ++++++++++++++---- 4 files changed, 432 insertions(+), 127 deletions(-) diff --git a/README.md b/README.md index 9a8882cc..0cd1e46a 100644 --- a/README.md +++ b/README.md @@ -769,7 +769,9 @@ If lights stop adapting after you turn them on with a physical switch or a Zigbe To adapt these turn-ons while still detecting later manual changes, enable `detect_non_ha_changes` and leave `manual_control_on_external_turn_on` disabled. This requires the light integration to report its state reliably. If you want Adaptive Lighting to keep adapting regardless of manual changes, disable `take_over_control` along with the options that require it: `detect_non_ha_changes`, `adapt_only_on_bare_turn_on`, and `manual_control_on_external_turn_on`. -This explains the physical-switch case in [#1056](https://github.com/basnijholt/adaptive-lighting/issues/1056), but not every report in that thread. If the light is not listed in `manual_control`, include diagnostics and debug logs from the failed turn-on when reporting it. When a light returns directly from `unavailable` to `on`, Adaptive Lighting uses the same turn-on policy and, if adaptation is allowed, applies `adapt_delay` and `initial_transition` even with `only_once`. Existing `manual_control` remains set because availability alone cannot distinguish a power cycle from a temporary connection loss. +This explains the physical-switch case in [#1056](https://github.com/basnijholt/adaptive-lighting/issues/1056), but not every report in that thread. If the light is not listed in `manual_control`, include diagnostics and debug logs from the failed turn-on when reporting it. When a light returns directly from `unavailable` to `on`, Adaptive Lighting resumes eligible adaptation after `adapt_delay`, using `initial_transition`. Reconnection preserves existing manual overrides and their reset timeouts; it does not start a new adaptation cycle with `only_once`. A light still transitioning may wait until the next interval. A recent turn-off fade still prevents adaptation unless a newer turn-on overrides it. + +With `detect_non_ha_changes: true`, changed power-on brightness or color still follows normal manual-change detection and may pause adaptation. Availability alone cannot distinguish a physical power cycle from a temporary connection loss, so reconnection does not clear manual control or solve every power-cycle reset request in [#307](https://github.com/basnijholt/adaptive-lighting/issues/307). #### :bulb: Lights Not Responding or Turning On by Themselves diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 079849ff..ee649743 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -1520,13 +1520,14 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): data, ) - async def _update_attrs_and_maybe_adapt_lights( + async def _update_attrs_and_maybe_adapt_lights( # noqa: PLR0912 self, *, context: Context, lights: list[str] | None = None, transition: int | None = None, force: bool = False, + recovery_event: Event[EventStateChangedData] | None = None, ) -> None: assert context is not None _LOGGER.debug( @@ -1575,7 +1576,9 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): # being turned off in 'interval' update, see #726 not self._detect_non_ha_changes and is_our_context(context, "interval") - and self.manager.last_service_call_was_turn_off(light) + and (turn_on := self.manager.turn_on_event.get(light)) + and (turn_off := self.manager.turn_off_event.get(light)) + and turn_off.time_fired > turn_on.time_fired ): _LOGGER.debug( "%s: Light '%s' was turned just turned off, context.id='%s'", @@ -1599,6 +1602,13 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): context, ) + # Polling may yield before a cancellable adaptation task exists. + if recovery_event is not None and not self._recovery_is_current( + light, + recovery_event, + ): + continue + # Performance optimization: Skip adaptation task if all attributes are # manually controlled and the task wouldn't actually do anything. if self.manager.get_adaption_control_attributes(self, light).has_none(): @@ -1626,6 +1636,45 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): if tasks: await asyncio.gather(*tasks) + def _recovery_is_current( + self, + entity_id: str, + event: Event[EventStateChangedData], + ) -> bool: + """Reject superseded reconnects and current off intent.""" + current = self.hass.states.get(entity_id) + recovered = event.data["new_state"] + return ( + not self._removed + and self.is_on + and not self._only_once + and entity_id in self.lights + and current is not None + and current.state == STATE_ON + and recovered is not None + and current.last_changed == recovered.last_changed + and not self.manager.recovery_is_during_turn_off(entity_id) + ) + + async def _respond_to_recovery_event( + self, + entity_id: str, + event: Event[EventStateChangedData], + ) -> None: + """Resume eligible adaptation without starting a new turn-on cycle.""" + if self._only_once: + return + if self._adapt_delay > 0: + await asyncio.sleep(self._adapt_delay) + if not self._recovery_is_current(entity_id, event): + return + await self._update_attrs_and_maybe_adapt_lights( + context=self.create_context("recovery", parent=event.context), + lights=[entity_id], + transition=self.initial_transition, + recovery_event=event, + ) + async def _respond_to_off_to_on_event( self, entity_id: str, @@ -1701,22 +1750,6 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): if self._removed or not self.is_on or entity_id not in self.lights: return - old_state = event.data["old_state"] - if ( - old_state is not None - and old_state.state == STATE_UNAVAILABLE - and self.manager.last_service_call_was_turn_off( - entity_id, - after=event if self._detect_non_ha_changes else None, - ) - ): - _LOGGER.debug( - "%s: Skipping recovery of '%s' after a light.turn_off call", - self._name, - entity_id, - ) - return - await self._update_attrs_and_maybe_adapt_lights( context=self.create_context("light_event", parent=event.context), lights=[entity_id], @@ -2846,14 +2879,20 @@ class AdaptiveLightingManager: entity_id, event.context.id, ) - elif (old_off or old_unavailable) and new_on: - # Treat a recovered light like one that was turned on, without - # treating the preceding loss of availability as a turn-off. + elif old_unavailable and new_on: + if self.is_proactively_adapting(event.context.id): + return + for switch in _switches_with_lights( + self.hass, + [entity_id], + expand_light_groups=False, + ): + if switch.is_on: + await switch._respond_to_recovery_event(entity_id, event) + elif old_off and new_on: self.off_to_on_event[entity_id] = event - old_state_name = STATE_OFF if old_off else STATE_UNAVAILABLE _LOGGER.debug( - "Detected an '%s' → 'on' event for '%s' with context.id='%s'", - old_state_name, + "Detected an 'off' → 'on' event for '%s' with context.id='%s'", entity_id, event.context.id, ) @@ -2871,10 +2910,7 @@ class AdaptiveLightingManager: self.reset(entity_id, reset_manual_control=False) lock = self.turn_off_locks.setdefault(entity_id, asyncio.Lock()) async with lock: - if ( - old_unavailable - and self._off_to_on_event_is_during_turn_off(entity_id, event) - ) or (old_off and await self.just_turned_off(entity_id)): + if await self.just_turned_off(entity_id): # Stop if a rapid 'off' → 'on' → 'off' happens. _LOGGER.debug( "Cancelling adjusting lights for %s", @@ -2894,19 +2930,23 @@ class AdaptiveLightingManager: event, ) - def last_service_call_was_turn_off( - self, - entity_id: str, - *, - after: Event | None = None, - ) -> bool: - """Return whether the most recent tracked service call turned a light off.""" - turn_on = self.turn_on_event.get(entity_id) + def recovery_is_during_turn_off(self, entity_id: str) -> bool: + """Respect an active fade, regardless of the reconnect's context.""" turn_off = self.turn_off_event.get(entity_id) - return ( - turn_off is not None - and (turn_on is None or turn_off.time_fired > turn_on.time_fired) - and (after is None or turn_off.time_fired > after.time_fired) + if turn_off is None: + return False + transition = turn_off.data[ATTR_SERVICE_DATA].get(ATTR_TRANSITION) + elapsed = (dt_util.utcnow() - turn_off.time_fired).total_seconds() + if not 0 <= elapsed <= max(transition or 0, TURNING_OFF_DELAY): + return False + targets = [entity_id] + state = self.hass.states.get(entity_id) + if state is not None and _is_light_group(state): + targets.extend(state.attributes[ATTR_ENTITY_ID]) + return not any( + (turn_on := self.turn_on_event.get(target)) is not None + and turn_on.time_fired > turn_off.time_fired + for target in targets ) async def update_manually_controlled_from_event( diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 57b617ce..e3430207 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -51,7 +51,9 @@ If lights stop adapting after you turn them on with a physical switch or a Zigbe To adapt these turn-ons while still detecting later manual changes, enable `detect_non_ha_changes` and leave `manual_control_on_external_turn_on` disabled. This requires the light integration to report its state reliably. If you want Adaptive Lighting to keep adapting regardless of manual changes, disable `take_over_control` along with the options that require it: `detect_non_ha_changes`, `adapt_only_on_bare_turn_on`, and `manual_control_on_external_turn_on`. -This explains the physical-switch case in [#1056](https://github.com/basnijholt/adaptive-lighting/issues/1056), but not every report in that thread. If the light is not listed in `manual_control`, include diagnostics and debug logs from the failed turn-on when reporting it. When a light returns directly from `unavailable` to `on`, Adaptive Lighting uses the same turn-on policy and, if adaptation is allowed, applies `adapt_delay` and `initial_transition` even with `only_once`. Existing `manual_control` remains set because availability alone cannot distinguish a power cycle from a temporary connection loss. +This explains the physical-switch case in [#1056](https://github.com/basnijholt/adaptive-lighting/issues/1056), but not every report in that thread. If the light is not listed in `manual_control`, include diagnostics and debug logs from the failed turn-on when reporting it. When a light returns directly from `unavailable` to `on`, Adaptive Lighting resumes eligible adaptation after `adapt_delay`, using `initial_transition`. Reconnection preserves existing manual overrides and their reset timeouts; it does not start a new adaptation cycle with `only_once`. A light still transitioning may wait until the next interval. A recent turn-off fade still prevents adaptation unless a newer turn-on overrides it. + +With `detect_non_ha_changes: true`, changed power-on brightness or color still follows normal manual-change detection and may pause adaptation. Availability alone cannot distinguish a physical power cycle from a temporary connection loss, so reconnection does not clear manual control or solve every power-cycle reset request in [#307](https://github.com/basnijholt/adaptive-lighting/issues/307). #### :bulb: Lights Not Responding or Turning On by Themselves diff --git a/tests/test_switch.py b/tests/test_switch.py index d40e078a..d63759cc 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -40,6 +40,7 @@ from homeassistant.components.adaptive_lighting.const import ( CONF_DETECT_NON_HA_CHANGES, CONF_EXPAND_LIGHT_GROUPS, CONF_INITIAL_TRANSITION, + CONF_INTERVAL, CONF_MANUAL_CONTROL, CONF_MANUAL_CONTROL_ON_EXTERNAL_TURN_ON, CONF_MAX_BRIGHTNESS, @@ -5197,71 +5198,147 @@ async def _recover_light_from_unavailable(hass, brightness=200): await hass.async_block_till_done() -@pytest.mark.parametrize( - ("only_once", "take_over_control", "detect_non_ha_changes"), - [(False, True, True), (True, True, True), (False, False, False)], -) +@pytest.mark.parametrize("external_turn_on_is_manual", [False, True]) +@pytest.mark.parametrize("brightness", [128, 200]) async def test_unavailable_light_recovery_adapts_immediately( hass, - only_once, - take_over_control, - detect_non_ha_changes, + external_turn_on_is_manual, + brightness, ): - """A trusted unavailable-to-on recovery is an initial adaptation (#307).""" + """Reconnect keeps automatic control regardless of external turn-on policy.""" switch, _ = await setup_lights_and_switch( hass, { - CONF_TAKE_OVER_CONTROL: take_over_control, - CONF_DETECT_NON_HA_CHANGES: detect_non_ha_changes, - CONF_ONLY_ONCE: only_once, + CONF_MANUAL_CONTROL_ON_EXTERNAL_TURN_ON: external_turn_on_is_manual, + CONF_DETECT_NON_HA_CHANGES: False, CONF_INITIAL_TRANSITION: 0, CONF_MIN_BRIGHTNESS: 50, CONF_MAX_BRIGHTNESS: 50, }, ) - - await _recover_light_from_unavailable(hass) - + calls = _track_adaptive_light_calls(hass) + await _recover_light_from_unavailable(hass, brightness) assert hass.states.get(ENTITY_LIGHT_1).attributes[ATTR_BRIGHTNESS] == 128 assert ( switch.manager.get_manual_control_attributes(ENTITY_LIGHT_1) == LightControlAttributes.NONE ) + assert calls + calls.clear() + await switch._async_update_at_interval_action() + await hass.async_block_till_done() + assert any(call[ATTR_ENTITY_ID] == ENTITY_LIGHT_1 for call in calls) + + +async def test_unavailable_recovery_only_once_sends_no_commands(hass): + """Availability cannot establish a new only_once on cycle.""" + switch, _ = await setup_lights_and_switch( + hass, + { + CONF_ONLY_ONCE: True, + CONF_DETECT_NON_HA_CHANGES: True, + CONF_INITIAL_TRANSITION: 0, + }, + ) + calls = _track_adaptive_light_calls(hass) + await _recover_light_from_unavailable(hass) + assert not calls + assert ( + switch.manager.get_manual_control_attributes(ENTITY_LIGHT_1) + == LightControlAttributes.NONE + ) -@pytest.mark.parametrize( - ("detect_non_ha_changes", "external_turn_on_is_manual"), - [(False, False), (True, True)], -) -async def test_unavailable_light_recovery_respects_external_turn_on_policy( - hass, - detect_non_ha_changes, - external_turn_on_is_manual, -): - """Recovery follows the same opt-in policy as an unmatched turn-on.""" +async def test_reconnect_keeps_non_ha_manual_timer_updates(hass, freezer, cleanup): + """Preserved comparison data lets subsequent physical changes renew takeover.""" + switch, lights = await setup_lights_and_switch( + hass, + { + CONF_DETECT_NON_HA_CHANGES: True, + CONF_AUTORESET_CONTROL: 7200, + }, + ) + manager = switch.manager + + async def flush_physical_state(hass, entity_id): + for light in lights: + if light.entity_id == entity_id: + light.async_write_ha_state() + + with patch( + "homeassistant.components.adaptive_lighting.switch.async_update_entity", + new=flush_physical_state, + ): + set_light_brightness(lights[0], 20) + await switch._async_update_at_interval_action() + await hass.async_block_till_done() + assert ( + manager.get_manual_control_attributes(ENTITY_LIGHT_1) + == LightControlAttributes.BRIGHTNESS + ) + timer = manager.auto_reset_manual_control_timers[ENTITY_LIGHT_1] + before = timer.start_time + baseline = dict(manager.last_service_data[ENTITY_LIGHT_1]) + await _recover_light_from_unavailable(hass, 20) + assert manager.last_service_data[ENTITY_LIGHT_1] == baseline + assert timer.start_time == before + freezer.tick(90) + set_light_brightness(lights[0], 200) + await switch._async_update_at_interval_action() + await hass.async_block_till_done() + assert timer.start_time > before + + +async def test_unavailable_recovery_off_during_poll(hass, monkeypatch): + """Off arriving before child task registration must suppress recovery commands.""" switch, _ = await setup_lights_and_switch( hass, { - CONF_DETECT_NON_HA_CHANGES: detect_non_ha_changes, - CONF_MANUAL_CONTROL_ON_EXTERNAL_TURN_ON: external_turn_on_is_manual, + CONF_DETECT_NON_HA_CHANGES: True, CONF_INITIAL_TRANSITION: 0, - CONF_MIN_BRIGHTNESS: 50, - CONF_MAX_BRIGHTNESS: 50, }, ) + attributes = dict(hass.states.get(ENTITY_LIGHT_1).attributes) + hass.states.async_set(ENTITY_LIGHT_1, STATE_UNAVAILABLE, attributes) + await hass.async_block_till_done() + entered, release = asyncio.Event(), asyncio.Event() - await _recover_light_from_unavailable(hass) + async def gated_poll(hass, entity_id): + entered.set() + await release.wait() - assert hass.states.get(ENTITY_LIGHT_1).attributes[ATTR_BRIGHTNESS] == 200 - assert ( - switch.manager.get_manual_control_attributes(ENTITY_LIGHT_1) - == LightControlAttributes.ALL + monkeypatch.setattr( + "homeassistant.components.adaptive_lighting.switch.async_update_entity", + gated_poll, ) + calls = _track_adaptive_light_calls(hass) + hass.states.async_set(ENTITY_LIGHT_1, STATE_ON, attributes) + try: + await asyncio.wait_for(entered.wait(), 1) + hass.bus.async_fire( + EVENT_CALL_SERVICE, + { + "domain": LIGHT_DOMAIN, + "service": SERVICE_TURN_OFF, + "service_data": {ATTR_ENTITY_ID: ENTITY_LIGHT_1, ATTR_TRANSITION: 10}, + }, + context=Context(), + ) + await asyncio.sleep(0) + assert ( + switch.manager.turn_off_event[ENTITY_LIGHT_1].time_fired + > switch.manager.turn_on_event[ENTITY_LIGHT_1].time_fired + ) + assert hass.states.get(ENTITY_LIGHT_1).state == STATE_ON + finally: + release.set() + await hass.async_block_till_done() + assert not calls async def test_unavailable_light_recovery_preserves_manual_control(hass): """A reconnect does not discard existing manual-control intent.""" - switch, _ = await setup_lights_and_switch( + switch, lights = await setup_lights_and_switch( hass, { CONF_DETECT_NON_HA_CHANGES: True, @@ -5270,6 +5347,7 @@ async def test_unavailable_light_recovery_preserves_manual_control(hass): CONF_MAX_BRIGHTNESS: 50, }, ) + set_light_brightness(lights[0], 200) switch.manager.set_manual_control_attributes(ENTITY_LIGHT_1) await _recover_light_from_unavailable(hass) @@ -5367,47 +5445,6 @@ async def test_unavailable_light_is_skipped_by_interval(hass): assert ENTITY_LIGHT_1 not in switch.manager.last_service_data -@pytest.mark.parametrize( - ("turn_on_timestamp", "should_adapt"), - [(None, False), (1.0, False), (3.0, True)], -) -async def test_interval_respects_latest_turn_off_service( - hass, - turn_on_timestamp, - should_adapt, -): - """Interval adaptation follows the latest tracked on/off service intent.""" - switch, _ = await setup_lights_and_switch( - hass, - { - CONF_TAKE_OVER_CONTROL: False, - CONF_DETECT_NON_HA_CHANGES: False, - CONF_INITIAL_TRANSITION: 0, - }, - ) - manager = switch.manager - manager.last_service_data.pop(ENTITY_LIGHT_1, None) - manager.turn_off_event[ENTITY_LIGHT_1] = Event( - EVENT_CALL_SERVICE, - {}, - time_fired_timestamp=2.0, - ) - manager.turn_on_event.pop(ENTITY_LIGHT_1, None) - if turn_on_timestamp is not None: - manager.turn_on_event[ENTITY_LIGHT_1] = Event( - EVENT_CALL_SERVICE, - {}, - time_fired_timestamp=turn_on_timestamp, - ) - - await switch._update_attrs_and_maybe_adapt_lights( - context=switch.create_context("interval"), - lights=[ENTITY_LIGHT_1], - ) - - assert (ENTITY_LIGHT_1 in manager.last_service_data) is should_adapt - - async def test_unavailable_light_recovery_preserves_recent_turn_off(hass): """A false on report does not override a newer explicit turn-off.""" switch, _ = await setup_lights_and_switch( @@ -5447,7 +5484,9 @@ async def test_unavailable_recovery_respects_fresh_turn_off_window( hass, { CONF_TAKE_OVER_CONTROL: True, - CONF_DETECT_NON_HA_CHANGES: True, + CONF_ONLY_ONCE: False, + CONF_INTERVAL: 3600, + CONF_DETECT_NON_HA_CHANGES: False, CONF_INITIAL_TRANSITION: 0, CONF_MIN_BRIGHTNESS: 50, CONF_MAX_BRIGHTNESS: 50, @@ -5479,7 +5518,7 @@ async def test_unavailable_recovery_respects_fresh_turn_off_window( ENTITY_LIGHT_1, STATE_ON, attributes, - context=turn_off_context, + context=Context(), ) await hass.async_block_till_done() @@ -5551,7 +5590,10 @@ async def test_unavailable_light_recovery_cancelled_during_delay( ) await original_sleep(0) assert hass.states.get(ENTITY_LIGHT_1).state == STATE_ON - assert switch.manager.last_service_call_was_turn_off(ENTITY_LIGHT_1) + assert ( + switch.manager.turn_off_event[ENTITY_LIGHT_1].time_fired + > switch.manager.turn_on_event[ENTITY_LIGHT_1].time_fired + ) if cancel_reason == "turn_off_then_turn_on": hass.bus.async_fire( EVENT_CALL_SERVICE, @@ -5563,8 +5605,9 @@ async def test_unavailable_light_recovery_cancelled_during_delay( context=Context(id="turn_on_during_recovery_delay"), ) await original_sleep(0) - assert not switch.manager.last_service_call_was_turn_off( - ENTITY_LIGHT_1, + assert ( + switch.manager.turn_on_event[ENTITY_LIGHT_1].time_fired + > switch.manager.turn_off_event[ENTITY_LIGHT_1].time_fired ) else: await switch.async_turn_off() @@ -6562,13 +6605,15 @@ async def test_profile_unloaded_during_split_delay(hass, monkeypatch): @pytest.mark.parametrize("unload_before_split", [False, True]) +@pytest.mark.parametrize("recovery", [False, True]) async def test_unloaded_polling_profile_preserves_other_split_adaptation( hass, monkeypatch, unload_before_split, + recovery, ): """A removed profile resuming a poll must not cancel another profile's work.""" - switch, _ = await setup_lights_and_switch(hass, {CONF_ONLY_ONCE: True}) + switch, _ = await setup_lights_and_switch(hass, {CONF_ONLY_ONCE: not recovery}) _, other = await setup_switch( hass, { @@ -6605,11 +6650,19 @@ async def test_unloaded_polling_profile_preserves_other_split_adaptation( ) monkeypatch.setattr(asyncio, "sleep", controlled_sleep) calls = _track_adaptive_light_calls(hass) + recovery_event = Event( + EVENT_STATE_CHANGED, + {"new_state": hass.states.get(ENTITY_LIGHT_1)}, + ) polling = hass.async_create_task( - switch._update_attrs_and_maybe_adapt_lights( - context=switch.create_context("test"), - lights=[ENTITY_LIGHT_1], - force=True, + ( + switch._respond_to_recovery_event(ENTITY_LIGHT_1, recovery_event) + if recovery + else 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) @@ -6635,3 +6688,211 @@ async def test_unloaded_polling_profile_preserves_other_split_adaptation( await hass.async_block_till_done() assert len(calls) == 2 assert ATTR_COLOR_TEMP_KELVIN in calls[-1] + + +@pytest.mark.parametrize("changed", [False, True]) +@pytest.mark.parametrize( + "mode", + [TakeOverControlMode.PAUSE_ALL, TakeOverControlMode.PAUSE_CHANGED], +) +async def test_recovery_retains_non_ha_detection(hass, changed, mode): + """Physical reconnect changes use normal detection and takeover mode.""" + switch, lights = await setup_lights_and_switch( + hass, + { + CONF_DETECT_NON_HA_CHANGES: True, + CONF_TAKE_OVER_CONTROL_MODE: mode.value, + CONF_MIN_BRIGHTNESS: 50, + CONF_MAX_BRIGHTNESS: 50, + }, + ) + brightness = 200 if changed else 128 + set_light_brightness(lights[0], brightness) + calls = _track_adaptive_light_calls(hass) + await _recover_light_from_unavailable(hass, brightness) + assert switch.manager.get_manual_control_attributes(ENTITY_LIGHT_1) == ( + LightControlAttributes.BRIGHTNESS if changed else LightControlAttributes.NONE + ) + assert hass.states.get(ENTITY_LIGHT_1).attributes[ATTR_BRIGHTNESS] == brightness + if changed and mode == TakeOverControlMode.PAUSE_ALL: + assert not calls + else: + assert calls + if changed: + assert all(ATTR_BRIGHTNESS not in call for call in calls) + + +@pytest.mark.parametrize( + "change", + ["churn", "attributes", "manual", "removed", "unloaded"], +) +async def test_recovery_revalidates_after_delay(hass, monkeypatch, freezer, change): + """Only the current on period and live profile may resume after waiting.""" + switch, _ = await setup_lights_and_switch( + hass, + { + CONF_DETECT_NON_HA_CHANGES: False, + CONF_ADAPT_DELAY: 0.1234, + CONF_MIN_BRIGHTNESS: 50, + CONF_MAX_BRIGHTNESS: 50, + }, + ) + attributes = dict(hass.states.get(ENTITY_LIGHT_1).attributes) + attributes[ATTR_BRIGHTNESS] = 200 + hass.states.async_set(ENTITY_LIGHT_1, STATE_UNAVAILABLE, attributes) + await hass.async_block_till_done() + entered = asyncio.Queue() + releases = [asyncio.Event(), asyncio.Event()] + original_sleep = asyncio.sleep + + async def gated_sleep(delay, *args, **kwargs): + if delay == 0.1234: + gate = releases[gated_sleep.count] + gated_sleep.count += 1 + entered.put_nowait(None) + await gate.wait() + else: + await original_sleep(delay, *args, **kwargs) + + gated_sleep.count = 0 + monkeypatch.setattr(asyncio, "sleep", gated_sleep) + calls = _track_adaptive_light_calls(hass) + hass.states.async_set(ENTITY_LIGHT_1, STATE_ON, attributes) + try: + await asyncio.wait_for(entered.get(), 1) + if change == "churn": + freezer.tick(1) + hass.states.async_set(ENTITY_LIGHT_1, STATE_UNAVAILABLE, attributes) + await original_sleep(0) + hass.states.async_set(ENTITY_LIGHT_1, STATE_ON, attributes) + await asyncio.wait_for(entered.get(), 1) + elif change == "attributes": + hass.states.async_set( + ENTITY_LIGHT_1, + STATE_ON, + {**attributes, ATTR_BRIGHTNESS: 199}, + ) + elif change == "manual": + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: ENTITY_LIGHT_1, + ATTR_BRIGHTNESS: 77, + }, + blocking=True, + ) + elif change == "removed": + switch.lights.remove(ENTITY_LIGHT_1) + else: + entry = next( + entry + for entry in hass.config_entries.async_entries(DOMAIN) + if entry.data[CONF_NAME] == DEFAULT_NAME + ) + assert await hass.config_entries.async_unload(entry.entry_id) + releases[0].set() + await original_sleep(0) + await original_sleep(0) + if change == "churn": + assert not calls + releases[1].set() + await hass.async_block_till_done() + if change in ("churn", "attributes"): + assert len(calls) == 1 + assert hass.states.get(ENTITY_LIGHT_1).attributes[ATTR_BRIGHTNESS] == 128 + else: + assert not calls + if change == "manual": + assert hass.states.get(ENTITY_LIGHT_1).attributes[ATTR_BRIGHTNESS] == 77 + finally: + for gate in releases: + gate.set() + await hass.async_block_till_done() + + +async def test_recovery_preserves_active_transition(hass, cleanup): + """Availability does not reset a transition that still suppresses adaptation.""" + switch, _ = await setup_lights_and_switch(hass, {CONF_DETECT_NON_HA_CHANGES: False}) + switch.manager.last_service_data[ENTITY_LIGHT_1][ATTR_TRANSITION] = 60 + switch.manager.start_transition_timer(ENTITY_LIGHT_1) + calls = _track_adaptive_light_calls(hass) + await _recover_light_from_unavailable(hass) + assert not calls + assert switch.manager.transition_timers[ENTITY_LIGHT_1].is_running() + + +@pytest.mark.parametrize("member_on", [False, True]) +async def test_recovery_newer_member_on_overrides_off_during_delay( + hass, + monkeypatch, + freezer, + member_on, +): + """A newer member turn-on overrides group off even after recovery began.""" + await setup_lights(hass, with_group=True) + group = "light.light_group" + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: group}, + blocking=True, + ) + _, switch = await setup_switch( + hass, + { + CONF_LIGHTS: [group, "light.light_4"], + CONF_EXPAND_LIGHT_GROUPS: False, + CONF_DETECT_NON_HA_CHANGES: False, + CONF_ADAPT_DELAY: 0.1234, + CONF_INITIAL_TRANSITION: 0, + CONF_TRANSITION: 0, + CONF_MIN_BRIGHTNESS: 50, + CONF_MAX_BRIGHTNESS: 50, + }, + ) + attributes = dict(hass.states.get(group).attributes) + hass.states.async_set(group, STATE_UNAVAILABLE, attributes) + await hass.async_block_till_done() + entered, release = asyncio.Event(), asyncio.Event() + original_sleep = asyncio.sleep + + async def gated_sleep(delay, *args, **kwargs): + if delay == 0.1234: + entered.set() + await release.wait() + else: + await original_sleep(delay, *args, **kwargs) + + monkeypatch.setattr(asyncio, "sleep", gated_sleep) + calls = _track_adaptive_light_calls(hass) + hass.states.async_set(group, STATE_ON, attributes) + await asyncio.wait_for(entered.wait(), 1) + freezer.tick(1) + hass.bus.async_fire( + EVENT_CALL_SERVICE, + { + "domain": LIGHT_DOMAIN, + "service": SERVICE_TURN_OFF, + "service_data": {ATTR_ENTITY_ID: group, ATTR_TRANSITION: 10}, + }, + context=Context(), + ) + await original_sleep(0) + if member_on: + freezer.tick(1) + hass.bus.async_fire( + EVENT_CALL_SERVICE, + { + "domain": LIGHT_DOMAIN, + "service": SERVICE_TURN_ON, + "service_data": {ATTR_ENTITY_ID: "light.light_4"}, + }, + context=Context(), + ) + await original_sleep(0) + release.set() + await hass.async_block_till_done() + assert bool(calls) is member_on + if member_on: + assert hass.states.get(group).attributes[ATTR_BRIGHTNESS] == 128 From ee835c98c74f96a47cf66da428f1a07a85279d46 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 6 Sep 2026 23:48:48 -0700 Subject: [PATCH 7/8] Test sibling recovery preserves group turn-off intent --- tests/test_switch.py | 63 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/tests/test_switch.py b/tests/test_switch.py index d63759cc..445bed27 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -6896,3 +6896,66 @@ async def test_recovery_newer_member_on_overrides_off_during_delay( assert bool(calls) is member_on if member_on: assert hass.states.get(group).attributes[ATTR_BRIGHTNESS] == 128 + + +@pytest.mark.parametrize( + ("turn_on_target", "should_adapt"), + [("light.light_4", False), ("light.light_5", True), ("light.light_group", True)], +) +async def test_recovery_sibling_turn_on_preserves_member_off_intent( + hass, + freezer, + turn_on_target, + should_adapt, +): + """Turning on one member must not revive a sibling during a group off fade.""" + await setup_lights(hass, with_group=True) + group, sibling = "light.light_group", "light.light_5" + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: group}, + blocking=True, + ) + _, switch = await setup_switch( + hass, + { + CONF_LIGHTS: [group], + CONF_DETECT_NON_HA_CHANGES: False, + CONF_TAKE_OVER_CONTROL: False, + CONF_INITIAL_TRANSITION: 0, + CONF_TRANSITION: 0, + CONF_INTERVAL: 3600, + CONF_MIN_BRIGHTNESS: 50, + CONF_MAX_BRIGHTNESS: 50, + }, + ) + attributes = dict(hass.states.get(sibling).attributes) + attributes[ATTR_BRIGHTNESS] = 200 + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: group, ATTR_TRANSITION: 30}, + blocking=True, + ) + await hass.async_block_till_done() + assert sibling in switch.manager.turn_off_event + freezer.tick(1) + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: turn_on_target}, + blocking=True, + ) + await hass.async_block_till_done() + assert hass.states.get(group).state == STATE_ON + assert hass.states.get(sibling).state == (STATE_ON if should_adapt else STATE_OFF) + calls = _track_adaptive_light_calls(hass) + hass.states.async_set(sibling, STATE_UNAVAILABLE, attributes, context=Context()) + await hass.async_block_till_done() + hass.states.async_set(sibling, STATE_ON, attributes, context=Context()) + await hass.async_block_till_done() + assert any(call[ATTR_ENTITY_ID] == sibling for call in calls) is should_adapt + assert hass.states.get(sibling).attributes[ATTR_BRIGHTNESS] == ( + 128 if should_adapt else 200 + ) From bd6e72403097be6fe3b0cb7b4bf5a75d0b2dfbef Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Tue, 8 Sep 2026 05:06:26 -0700 Subject: [PATCH 8/8] Normalize turn-off transitions during light recovery --- custom_components/adaptive_lighting/switch.py | 2 +- tests/test_switch.py | 25 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index e951f6b3..49252ce1 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -2948,7 +2948,7 @@ class AdaptiveLightingManager: turn_off = self.turn_off_event.get(entity_id) if turn_off is None: return False - transition = turn_off.data[ATTR_SERVICE_DATA].get(ATTR_TRANSITION) + transition = _turn_off_transition(turn_off) elapsed = (dt_util.utcnow() - turn_off.time_fired).total_seconds() if not 0 <= elapsed <= max(transition or 0, TURNING_OFF_DELAY): return False diff --git a/tests/test_switch.py b/tests/test_switch.py index 138004ee..8740992b 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -6922,6 +6922,31 @@ async def test_recovery_revalidates_after_delay(hass, monkeypatch, freezer, chan await hass.async_block_till_done() +@pytest.mark.parametrize( + ("transition", "window"), + [(10, 10), ("10", 10), (10000, 6553), ("10000", 6553), ("inf", 6553), (None, 5)], +) +async def test_recovery_normalizes_turn_off_transition( + hass, + cleanup, + transition, + window, +): + """Recovery suppression ends after the light service's normalized window.""" + switch, _ = await setup_lights_and_switch(hass) + manager = switch.manager + manager.turn_on_event.pop(ENTITY_LIGHT_1, None) + now = dt_util.utcnow().timestamp() + for elapsed, expected in [(window - 1, True), (window + 1, False)]: + manager.turn_off_event[ENTITY_LIGHT_1] = _turn_off_service_event( + [ENTITY_LIGHT_1], + now - elapsed, + Context(), + transition, + ) + assert manager.recovery_is_during_turn_off(ENTITY_LIGHT_1) is expected + + async def test_recovery_preserves_active_transition(hass, cleanup): """Availability does not reset a transition that still suppresses adaptation.""" switch, _ = await setup_lights_and_switch(hass, {CONF_DETECT_NON_HA_CHANGES: False})