From 52275bfee3fb56edd43fbb2f45a04b71e1ca4952 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 27 Mar 2023 23:39:52 -0700 Subject: [PATCH 01/18] Add auto_reset_manual_control with async timer --- custom_components/adaptive_lighting/const.py | 7 ++ custom_components/adaptive_lighting/switch.py | 89 ++++++++++++++++++- tests/test_switch.py | 33 +++++++ 3 files changed, 126 insertions(+), 3 deletions(-) diff --git a/custom_components/adaptive_lighting/const.py b/custom_components/adaptive_lighting/const.py index f4b751e5..ef06c080 100644 --- a/custom_components/adaptive_lighting/const.py +++ b/custom_components/adaptive_lighting/const.py @@ -73,6 +73,8 @@ CONF_ADAPT_DELAY, DEFAULT_ADAPT_DELAY = "adapt_delay", 0 TURNING_OFF_DELAY = 5 CONF_SEND_SPLIT_DELAY, DEFAULT_SEND_SPLIT_DELAY = "send_split_delay", 0 +CONF_AUTORESET_CONTROL, DEFAULT_AUTORESET_CONTROL = "autoreset_control_time", 0 + def int_between(min_int, max_int): """Return an integer between 'min_int' and 'max_int'.""" @@ -121,6 +123,11 @@ VALIDATION_TUPLES = [ (CONF_SEPARATE_TURN_ON_COMMANDS, DEFAULT_SEPARATE_TURN_ON_COMMANDS, bool), (CONF_SEND_SPLIT_DELAY, DEFAULT_SEND_SPLIT_DELAY, int_between(0, 10000)), (CONF_ADAPT_DELAY, DEFAULT_ADAPT_DELAY, cv.positive_float), + ( + CONF_AUTORESET_CONTROL, + DEFAULT_AUTORESET_CONTROL, + int_between(0, 7 * 24 * 60 * 60), # 7 days max + ), ] diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 895b4908..33b10293 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -97,6 +97,7 @@ from .const import ( ATTR_ADAPT_COLOR, ATTR_TURN_ON_OFF_LISTENER, CONF_ADAPT_DELAY, + CONF_AUTORESET_CONTROL, CONF_DETECT_NON_HA_CHANGES, CONF_INCLUDE_CONFIG_IN_ATTRIBUTES, CONF_INITIAL_TRANSITION, @@ -496,7 +497,7 @@ async def async_setup_entry( all_lights = _expand_light_groups(this_switch.hass, lights) if service_call.data[CONF_MANUAL_CONTROL]: for light in all_lights: - this_switch.turn_on_off_listener.manual_control[light] = True + this_switch.turn_on_off_listener.mark_as_manual_control(light) _fire_manual_control_event(this_switch, light, service_call.context) else: this_switch.turn_on_off_listener.reset(*all_lights) @@ -843,6 +844,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): self._transition = data[CONF_TRANSITION] self._adapt_delay = data[CONF_ADAPT_DELAY] self._send_split_delay = data[CONF_SEND_SPLIT_DELAY] + self._auto_reset_manual_control_time = data[CONF_AUTORESET_CONTROL] _loc = get_astral_location(self.hass) if isinstance(_loc, tuple): # Astral v2.2 @@ -916,6 +918,9 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): def _expand_light_groups(self) -> None: all_lights = _expand_light_groups(self.hass, self._lights) self.turn_on_off_listener.lights.update(all_lights) + self.turn_on_off_listener.set_auto_reset_manual_control_times( + all_lights, self._auto_reset_manual_control_time + ) self._lights = list(all_lights) async def _setup_listeners(self, _=None) -> None: @@ -1549,6 +1554,10 @@ class TurnOnOffListener: # Track last 'service_data' to 'light.turn_on' resulting from this integration self.last_service_data: dict[str, dict[str, Any]] = {} + # Track auto reset of manual_control + self.auto_reset_manual_control_timers: dict[str, _AsyncSingleShotTimer] = {} + self.auto_reset_manual_control_times: dict[str, float | None] = {} + # When a state is different `max_cnt_significant_changes` times in a row, # mark it as manually_controlled. self.max_cnt_significant_changes = 2 @@ -1560,11 +1569,55 @@ class TurnOnOffListener: EVENT_STATE_CHANGED, self.state_changed_event_listener ) + def set_auto_reset_manual_control_times(self, lights, time): + """Set the time after which the lights are automatically reset.""" + for light in lights: + if (old_time := self.auto_reset_manual_control_times.get(light)) and ( + old_time != time + ): + _LOGGER.info( + "Setting auto_reset_manual_control for '%s' from %s seconds to %s seconds." + " This might happen because the light is in multiple swiches.", + light, + old_time, + time, + ) + self.auto_reset_manual_control_times[light] = time + + def mark_as_manual_control(self, light: str) -> None: + """Mark a light as manually controlled.""" + self.manual_control[light] = True + delay = self.auto_reset_manual_control_times.get(light) + if timer := self.auto_reset_manual_control_timers.get(light): + if delay is None: # Timer object exists, but should not anymore + timer.cancel() + self.auto_reset_manual_control_timers.pop(light) + else: # Timer object already exists, just update the delay and restart it + timer.delay = delay + timer.start() + elif delay is not None: # Timer object does not exist, create it + + def reset(): + self.reset(light) + _LOGGER.debug( + "Auto resetting 'manual_control' status of '%s' because" + " it was not manually controlled for %s seconds.", + light, + delay, + ) + + timer = _AsyncSingleShotTimer(delay, reset) + self.auto_reset_manual_control_timers[light] = timer + timer.start() + def reset(self, *lights, reset_manual_control=True) -> None: """Reset the 'manual_control' status of the lights.""" for light in lights: if reset_manual_control: self.manual_control[light] = False + timer = self.auto_reset_manual_control_timers.pop(light, None) + if timer: + timer.cancel() self.last_state_change.pop(light, None) self.last_service_data.pop(light, None) self.cnt_significant_changes[light] = 0 @@ -1697,7 +1750,7 @@ class TurnOnOffListener: ): # Light was already on and 'light.turn_on' was not called by # the adaptive_lighting integration. - manual_control = self.manual_control[light] = True + manual_control = self.mark_as_manual_control(light) _fire_manual_control_event(switch, light, turn_on_event.context) _LOGGER.debug( "'%s' was already on and 'light.turn_on' was not called by the" @@ -1769,7 +1822,7 @@ class TurnOnOffListener: # Only mark a light as significantly changing, if changed==True # N times in a row. We do this because sometimes a state changes # happens only *after* a new update interval has already started. - self.manual_control[light] = True + self.mark_as_manual_control(light) _fire_manual_control_event(switch, light, context, is_async=False) else: if n_changes > 1: @@ -1880,3 +1933,33 @@ class TurnOnOffListener: # other 'off' → 'on' state switches resulting from polling. That # would mean we 'return True' here. return False + + +class _AsyncSingleShotTimer: + def __init__(self, delay, callback): + """Initialize the timer.""" + self.delay = delay + self.callback = callback + self.task = None + + async def _run(self): + """Run the timer. Don't call this directly, use start() instead.""" + await asyncio.sleep(self.delay) + if self.callback: + if asyncio.iscoroutinefunction(self.callback): + await self.callback() + else: + self.callback() + + def start(self): + """Start the timer.""" + if self.task is not None and not self.task.done(): + self.task.cancel() + + self.task = asyncio.create_task(self._run()) + + def cancel(self): + """Cancel the timer.""" + if self.task: + self.task.cancel() + self.callback = None diff --git a/tests/test_switch.py b/tests/test_switch.py index 8584bb1f..cc7afec2 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -10,6 +10,7 @@ from homeassistant.components.adaptive_lighting.const import ( ADAPT_BRIGHTNESS_SWITCH, ADAPT_COLOR_SWITCH, ATTR_TURN_ON_OFF_LISTENER, + CONF_AUTORESET_CONTROL, CONF_DETECT_NON_HA_CHANGES, CONF_INITIAL_TRANSITION, CONF_MANUAL_CONTROL, @@ -584,6 +585,38 @@ async def test_manual_control(hass): assert all([not manual_control[eid] for eid in switch._lights]) +async def test_auto_reset_manual_control(hass): + switch, (light, *_) = await setup_lights_and_switch( + hass, {CONF_AUTORESET_CONTROL: 0.1} + ) + context = switch.create_context("test") # needs to be passed to update method + manual_control = switch.turn_on_off_listener.manual_control + + async def update(): + await switch._update_attrs_and_maybe_adapt_lights(transition=0, context=context) + await hass.async_block_till_done() + + async def turn_light(state, **kwargs): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON if state else SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_LIGHT, **kwargs}, + blocking=True, + ) + await hass.async_block_till_done() + await update() + _LOGGER.debug("Turn light %s to state %s, to %s", ENTITY_LIGHT, state, kwargs) + + def increased_brightness(): + return (light._brightness + 100) % 255 + + await turn_light(True, brightness=increased_brightness()) + assert manual_control[ENTITY_LIGHT] + await asyncio.sleep(0.3) + await update() + assert not manual_control[ENTITY_LIGHT] + + async def test_apply_service(hass): """Test adaptive_lighting.apply service.""" switch, (_, _, light) = await setup_lights_and_switch(hass) From 8b2fcf93be5caf95c87903e518a675b2eabbafef Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Sun, 2 Apr 2023 23:04:26 -0500 Subject: [PATCH 02/18] cherry-pick wait for transition stuff --- custom_components/adaptive_lighting/const.py | 2 +- custom_components/adaptive_lighting/switch.py | 184 +++++++++++------- tests/test_switch.py | 4 + 3 files changed, 122 insertions(+), 68 deletions(-) diff --git a/custom_components/adaptive_lighting/const.py b/custom_components/adaptive_lighting/const.py index 6de071cb..5b8142c5 100644 --- a/custom_components/adaptive_lighting/const.py +++ b/custom_components/adaptive_lighting/const.py @@ -177,7 +177,7 @@ CONF_USE_DEFAULTS = "use_defaults" TURNING_OFF_DELAY = 5 -CONF_AUTORESET_CONTROL, DEFAULT_AUTORESET_CONTROL = "autoreset_control_time", 0 +CONF_AUTORESET_CONTROL, DEFAULT_AUTORESET_CONTROL = "autoreset_control_seconds", 0 def int_between(min_int, max_int): diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 5cf17122..949ec6b1 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -817,10 +817,22 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): self._only_once = data[CONF_ONLY_ONCE] self._prefer_rgb_color = data[CONF_PREFER_RGB_COLOR] self._separate_turn_on_commands = data[CONF_SEPARATE_TURN_ON_COMMANDS] - self._take_over_control = data[CONF_TAKE_OVER_CONTROL] self._transition = data[CONF_TRANSITION] self._adapt_delay = data[CONF_ADAPT_DELAY] self._send_split_delay = data[CONF_SEND_SPLIT_DELAY] + self._take_over_control = data[CONF_TAKE_OVER_CONTROL] + self._detect_non_ha_changes = data[CONF_DETECT_NON_HA_CHANGES] + if not data[CONF_TAKE_OVER_CONTROL] and (data[CONF_DETECT_NON_HA_CHANGES]): + _LOGGER.warn( + "%s: Config mismatch: 'detect_non_ha_changes: true' " + " are set in config, however required" + " variable 'take_over_control' is turned off. Please check your" + " configuration to ensure desired functionality. We will now" + " enable 'take_over_control' and continue setting up the" + " adaptive-lighting integration normally.", + self._name, + ) + self._take_over_control = True self._auto_reset_manual_control_time = data[CONF_AUTORESET_CONTROL] _loc = get_astral_location(self.hass) if isinstance(_loc, tuple): @@ -1546,6 +1558,49 @@ class TurnOnOffListener: EVENT_STATE_CHANGED, self.state_changed_event_listener ) + def start_transition_timer(self, light: str) -> None: + """Mark a light as manually controlled.""" + _LOGGER.debug("Start transition timer for %s", light) + last_service_data = self.last_service_data + if ( + not last_service_data + or light not in last_service_data + or ATTR_TRANSITION not in last_service_data[light] + ): + return False + + delay = last_service_data[light][ATTR_TRANSITION] + timer = self.transition_timers.get(light) + if timer is not None: + if delay is None: # Timer object exists, but should not anymore + timer.cancel() + self.transition_timers.pop(light) + else: # Timer object already exists, just update the delay and restart it + timer.delay = delay + timer.start() + elif delay is not None: # Timer object does not exist, create it + + async def reset(): + _LOGGER.debug( + "Transition finished for light %s", + light, + ) + # This part is optional, we could just wait for the next interval. + switches = _get_switches_with_lights(self.hass, [light]) + for switch in switches: + if not switch.is_on: + continue + # pylint: disable=protected-access + await switch._update_attrs_and_maybe_adapt_lights( + [light], + force=False, + context=switch.create_context("transit"), + ) + + timer = _AsyncSingleShotTimer(delay, reset) + self.transition_timers[light] = timer + timer.start() + def set_auto_reset_manual_control_times(self, lights: list[str], time: float): """Set the time after which the lights are automatically reset.""" if time == 0: @@ -1613,7 +1668,6 @@ class TurnOnOffListener: timer.cancel() self.last_state_change.pop(light, None) self.last_service_data.pop(light, None) - self.cnt_significant_changes[light] = 0 async def turn_on_off_event_listener(self, event: Event) -> None: """Track 'light.turn_off' and 'light.turn_on' service calls.""" @@ -1692,11 +1746,7 @@ class TurnOnOffListener: new_state.context.id, ) - if ( - new_state is not None - and new_state.state == STATE_ON - and is_our_context(new_state.context) - ): + if new_state is not None and new_state.state == STATE_ON: # It is possible to have multiple state change events with the same context. # This can happen because a `turn_on.light(brightness_pct=100, transition=30)` # event leads to an instant state change of @@ -1709,21 +1759,29 @@ class TurnOnOffListener: # incorrect 'min_kelvin' and 'max_kelvin', which happens e.g., for # Philips Hue White GU10 Bluetooth lights). old_state: list[State] | None = self.last_state_change.get(entity_id) - if ( - old_state is not None - and old_state[0].context.id == new_state.context.id - ): - # If there is already a state change event from this event (with this - # context) then append it to the already existing list. - _LOGGER.debug( - "State change event of '%s' is already in 'self.last_state_change' (%s)" - " adding this state also", - entity_id, - new_state.context.id, - ) + if is_our_context(new_state.context): + if ( + old_state is not None + and old_state[0].context.id == new_state.context.id + ): + _LOGGER.debug( + "TurnOnOffListener: State change event of '%s' is already" + " in 'self.last_state_change' (%s)" + " adding this state also", + entity_id, + new_state.context.id, + ) + self.last_state_change[entity_id].append(new_state) + else: + _LOGGER.debug( + "TurnOnOffListener: New adapt '%s' found for %s", + new_state, + entity_id, + ) + self.last_state_change[entity_id] = [new_state] + self.start_transition_timer(entity_id) + elif old_state is not None: self.last_state_change[entity_id].append(new_state) - else: - self.last_state_change[entity_id] = [new_state] def is_manually_controlled( self, @@ -1778,64 +1836,56 @@ class TurnOnOffListener: detected, we mark the light as 'manually controlled' until the light or switch is turned 'off' and 'on' again. """ - if light not in self.last_state_change: - return False - old_states: list[State] = self.last_state_change[light] - await self.hass.helpers.entity_component.async_update_entity(light) - new_state = self.hass.states.get(light) + last_service_data = self.last_service_data.get(light) + if last_service_data is None: + return compare_to = functools.partial( _attributes_have_changed, light=light, - new_attributes=new_state.attributes, adapt_brightness=adapt_brightness, adapt_color=adapt_color, context=context, ) - for index, old_state in enumerate(old_states): - changed = compare_to(old_attributes=old_state.attributes) - if not changed: - _LOGGER.debug( - "State of '%s' didn't change wrt change event nr. %s (context.id=%s)", - light, - index, - context.id, - ) - break - - last_service_data = self.last_service_data.get(light) - if changed and last_service_data is not None: - # It can happen that the state change events that are associated - # with the last 'light.turn_on' call by this integration were not - # final states. Possibly a later EVENT_STATE_CHANGED happened, where - # the correct target brightness/color was reached. - changed = compare_to(old_attributes=last_service_data) - if not changed: + # Update state and check for a manual change not done in HA. + # Ensure HASS is correctly updating your light's state with + # light.turn_on calls if any problems arise. This + # can happen e.g. using zigbee2mqtt with 'report: false' in device settings. + if switch._detect_non_ha_changes: + _LOGGER.debug( + "%s: 'detect_non_ha_changes: true', calling update_entity(%s)" + " and check if it's last adapt succeeded.", + switch._name, + light, + ) + # This update_entity probably isn't necessary now that we're checking + # if transitions finished from our last adapt. + await self.hass.helpers.entity_component.async_update_entity(light) + refreshed_state = self.hass.states.get(light) + _LOGGER.debug( + "%s: Current state of %s: %s", + switch._name, + light, + refreshed_state, + ) + changed = compare_to( + old_attributes=last_service_data, + new_attributes=refreshed_state.attributes, + ) + if changed: _LOGGER.debug( "State of '%s' didn't change wrt 'last_service_data' (context.id=%s)", light, context.id, ) - - n_changes = self.cnt_significant_changes[light] - if changed: - self.cnt_significant_changes[light] += 1 - if n_changes >= self.max_cnt_significant_changes: - # Only mark a light as significantly changing, if changed==True - # N times in a row. We do this because sometimes a state changes - # happens only *after* a new update interval has already started. - self.mark_as_manual_control(light) - _fire_manual_control_event(switch, light, context, is_async=False) - else: - if n_changes > 1: - _LOGGER.debug( - "State of '%s' had 'cnt_significant_changes=%s' but the state" - " changed to the expected settings now", - light, - n_changes, - ) - self.cnt_significant_changes[light] = 0 - - return changed + return True + _LOGGER.debug( + "%s: Light '%s' correctly matches our last adapt's service data, continuing..." + " context.id=%s.", + switch._name, + light, + context.id, + ) + return False async def maybe_cancel_adjusting( self, entity_id: str, off_to_on_event: Event, on_to_off_event: Event | None diff --git a/tests/test_switch.py b/tests/test_switch.py index 5b74c94c..c36dbbee 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -777,6 +777,10 @@ async def test_significant_change(hass): for _ in range(switch.turn_on_off_listener.max_cnt_significant_changes): await update(force=False) assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + # Check that the brightness was not changed since setting it manually + assert ( + new_brightness == hass.states.get(ENTITY_LIGHT).attributes[ATTR_BRIGHTNESS] + ) # On next update the light should be marked as manually controlled await update(force=False) # TODO: the state should be `bool(manual_control) is True` From 22bae9ed84fd0a03d7f578a2cd1d33b2d97aa314 Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Sun, 2 Apr 2023 23:08:35 -0500 Subject: [PATCH 03/18] Update switch.py --- custom_components/adaptive_lighting/switch.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 949ec6b1..203fcb4e 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -1141,11 +1141,24 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): ) ) self.async_write_ha_state() + + # Return here if there's no lights to adapt. if lights is None: lights = self._lights - if (self._only_once and not force) or not lights: + if not len(lights): return - await self._adapt_lights(lights, transition, force, context) + + if not force: + if self._only_once: + return + for light in lights: + # Don't adapt lights that haven't finished prior transitions. + if self.turn_on_off_listener.transition_timers.get(light): + lights.remove(light) + + await self._update_manual_control_and_maybe_adapt( + lights, transition, force, context + ) async def _adapt_lights( self, From 8e847e71eb5c47f23fbc9783cf17d5f229f6b29e Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Sun, 2 Apr 2023 23:15:53 -0500 Subject: [PATCH 04/18] not renamed in this branch yet. --- custom_components/adaptive_lighting/switch.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 203fcb4e..fa5e493c 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -1156,9 +1156,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): if self.turn_on_off_listener.transition_timers.get(light): lights.remove(light) - await self._update_manual_control_and_maybe_adapt( - lights, transition, force, context - ) + await self._adapt_lights(lights, transition, force, context) async def _adapt_lights( self, From 30c62bf759947356c16413baece9237bb934db42 Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Sun, 2 Apr 2023 23:28:58 -0500 Subject: [PATCH 05/18] Update switch.py --- custom_components/adaptive_lighting/switch.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index fa5e493c..74d7cc9c 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -1558,6 +1558,9 @@ class TurnOnOffListener: self.auto_reset_manual_control_timers: dict[str, _AsyncSingleShotTimer] = {} self.auto_reset_manual_control_times: dict[str, float] = {} + # Track light transitions + self.transition_timers: dict[str, _AsyncSingleShotTimer] = {} + # When a state is different `max_cnt_significant_changes` times in a row, # mark it as manually_controlled. self.max_cnt_significant_changes = 2 From 120efff71e3e95c394ee00263ffbb5a9d267af89 Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Sun, 2 Apr 2023 23:43:30 -0500 Subject: [PATCH 06/18] update tests --- tests/test_switch.py | 145 +++++++++++++++++++++++++++++-------------- 1 file changed, 99 insertions(+), 46 deletions(-) diff --git a/tests/test_switch.py b/tests/test_switch.py index c36dbbee..488e084f 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -21,6 +21,7 @@ from homeassistant.components.adaptive_lighting.const import ( CONF_SUNRISE_OFFSET, CONF_SUNRISE_TIME, CONF_SUNSET_TIME, + CONF_TAKE_OVER_CONTROL, CONF_TRANSITION, CONF_TURN_ON_LIGHTS, CONF_USE_DEFAULTS, @@ -56,6 +57,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_AREA_ID, ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, CONF_LIGHTS, CONF_NAME, CONF_PLATFORM, @@ -197,7 +199,8 @@ async def setup_lights_and_switch(hass, extra_conf=None): CONF_SUNSET_TIME: datetime.time(SUNSET.hour), CONF_INITIAL_TRANSITION: 0, CONF_TRANSITION: 0, - CONF_DETECT_NON_HA_CHANGES: True, + CONF_DETECT_NON_HA_CHANGES: False, + CONF_TAKE_OVER_CONTROL: True, CONF_PREFER_RGB_COLOR: False, CONF_MIN_COLOR_TEMP: 2500, # to not coincide with sleep_color_temp **(extra_conf or {}), @@ -692,6 +695,101 @@ async def test_apply_service(hass): assert old_state[ATTR_COLOR_TEMP_KELVIN] == new_state[ATTR_COLOR_TEMP_KELVIN] +async def test_significant_change(hass): + """Test significant change.""" + + async def turn_light(state, **kwargs): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON if state else SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_LIGHT, **kwargs}, + blocking=True, + ) + await hass.async_block_till_done() + + async def update(force): + await switch._update_attrs_and_maybe_adapt_lights( + transition=0, + context=switch.create_context("test"), + force=force, + ) + await hass.async_block_till_done() + + async def change_switch_settings(**kwargs): + await hass.services.async_call( + DOMAIN, + SERVICE_CHANGE_SWITCH_SETTINGS, + { + ATTR_ENTITY_ID: ENTITY_SWITCH, + **kwargs, + }, + blocking=True, + ) + await hass.async_block_till_done() + + async def set_brightness(val: int): + hass.states.async_set( + ENTITY_LIGHT, "on", {ATTR_BRIGHTNESS: val, ATTR_SUPPORTED_FEATURES: 1} + ) + await hass.async_block_till_done() + + async def do_nothing(entity_id): + _LOGGER.debug("update entity successfully replaced for %s", entity_id) + return None + + switch, (bed_light_instance, *_) = await setup_lights_and_switch(hass) + _LOGGER.debug("Test detect_non_ha_changes:") + switch._take_over_control = True + assert switch._take_over_control + switch._detect_non_ha_changes = True + assert switch._detect_non_ha_changes + switch._alt_detect_method = False + assert not switch._alt_detect_method + + # build last service data + await update(force=False) + + # force=True should not reset manual control. + await turn_light(True, brightness=40) + await turn_light(True, brightness=20) + await update(force=False) + assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + await update(force=True) + assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + + # turn light off then on should reset manual control. + await turn_light(False) + await turn_light(True) + assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + + # Assert last_service_data got filled from update() + # Assert last_state_change got filled from update() + await update(force=True) + assert switch.turn_on_off_listener.last_service_data.get(ENTITY_LIGHT) is not None + assert switch.turn_on_off_listener.last_state_change.get(ENTITY_LIGHT) is not None + + # Simulate a transition to 255 where the update() is already using brightness 255. + await set_brightness(240) + await set_brightness(244) + await set_brightness(247) + await set_brightness(250) + + # last_state_change should have our state changes. + # Change brightness by async_set (not using 'light.turn_on') + new_brightness = 50 + await set_brightness(new_brightness) + _LOGGER.debug("Test: Brightness set to %s", new_brightness) + + # Override update_entity() to do nothing. Otherwise what happens is + # update_entity() refreshes the state to the last call of + # light.turn_on(). + switch.hass.helpers.entity_component.async_update_entity = do_nothing + # On next update ENTITY_LIGHT should be marked as manually controlled + await update(force=False) + assert ENTITY_LIGHT in switch.turn_on_off_listener.last_state_change + assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + + async def test_switch_off_on_off(hass): """Test switch rapid off_on_off.""" @@ -742,51 +840,6 @@ async def test_switch_off_on_off(hass): assert state == STATE_OFF -async def test_significant_change(hass): - """Test significant change.""" - - async def turn_light(state, **kwargs): - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON if state else SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: ENTITY_LIGHT, **kwargs}, - blocking=True, - ) - await hass.async_block_till_done() - - async def update(force): - await switch._update_attrs_and_maybe_adapt_lights( - transition=0, - context=switch.create_context("test"), - force=force, - ) - await hass.async_block_till_done() - - switch, (bed_light_instance, *_) = await setup_lights_and_switch(hass) - await turn_light(True) - await update(force=True) # removes manual control - assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] - - # Change brightness by setting state (not using 'light.turn_on') - attributes = hass.states.get(ENTITY_LIGHT).attributes - new_attributes = attributes.copy() - new_brightness = (attributes[ATTR_BRIGHTNESS] + 100) % 255 - new_attributes[ATTR_BRIGHTNESS] = new_brightness - bed_light_instance._brightness = new_brightness - assert switch.turn_on_off_listener.last_service_data.get(ENTITY_LIGHT) is not None - for _ in range(switch.turn_on_off_listener.max_cnt_significant_changes): - await update(force=False) - assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] - # Check that the brightness was not changed since setting it manually - assert ( - new_brightness == hass.states.get(ENTITY_LIGHT).attributes[ATTR_BRIGHTNESS] - ) - # On next update the light should be marked as manually controlled - await update(force=False) - # TODO: the state should be `bool(manual_control) is True` - assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] - - def test_color_difference_redmean(): """Test color_difference_redmean function.""" for _ in range(10): From 1571dfaeda6f41fcfb2530e9ae3f47fc9ecb701c Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Sun, 2 Apr 2023 23:46:20 -0500 Subject: [PATCH 07/18] Update switch.py --- custom_components/adaptive_lighting/switch.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 74d7cc9c..5d708d60 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -1891,6 +1891,8 @@ class TurnOnOffListener: light, context.id, ) + self.manual_control[light] = True + _fire_manual_control_event(switch, light, context, is_async=False) return True _LOGGER.debug( "%s: Light '%s' correctly matches our last adapt's service data, continuing..." From 732fc17c7f291ffaf78dd118465bd982b4cd0b6d Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Sun, 2 Apr 2023 23:56:08 -0500 Subject: [PATCH 08/18] merge related fix --- custom_components/adaptive_lighting/const.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/custom_components/adaptive_lighting/const.py b/custom_components/adaptive_lighting/const.py index 5b8142c5..eb29fa13 100644 --- a/custom_components/adaptive_lighting/const.py +++ b/custom_components/adaptive_lighting/const.py @@ -177,8 +177,6 @@ CONF_USE_DEFAULTS = "use_defaults" TURNING_OFF_DELAY = 5 -CONF_AUTORESET_CONTROL, DEFAULT_AUTORESET_CONTROL = "autoreset_control_seconds", 0 - def int_between(min_int, max_int): """Return an integer between 'min_int' and 'max_int'.""" From 3aa2f3242b06370c0f84248d2c2c21a2556bb5ec Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Mon, 3 Apr 2023 00:55:21 -0500 Subject: [PATCH 09/18] cleanup --- custom_components/adaptive_lighting/switch.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 5d708d60..55479d71 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -1761,6 +1761,9 @@ class TurnOnOffListener: ) if new_state is not None and new_state.state == STATE_ON: + old_state: list[State] | None = self.last_state_change.get(entity_id) + if old_state is None: + return # It is possible to have multiple state change events with the same context. # This can happen because a `turn_on.light(brightness_pct=100, transition=30)` # event leads to an instant state change of @@ -1772,12 +1775,8 @@ class TurnOnOffListener: # called with a color_temp outside of its range (and HA reports the # incorrect 'min_kelvin' and 'max_kelvin', which happens e.g., for # Philips Hue White GU10 Bluetooth lights). - old_state: list[State] | None = self.last_state_change.get(entity_id) if is_our_context(new_state.context): - if ( - old_state is not None - and old_state[0].context.id == new_state.context.id - ): + if old_state[0].context.id == new_state.context.id: _LOGGER.debug( "TurnOnOffListener: State change event of '%s' is already" " in 'self.last_state_change' (%s)" @@ -1794,7 +1793,7 @@ class TurnOnOffListener: ) self.last_state_change[entity_id] = [new_state] self.start_transition_timer(entity_id) - elif old_state is not None: + else: self.last_state_change[entity_id].append(new_state) def is_manually_controlled( From 836595051d0f0ca467d53c93f430fb8beead6ac2 Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Mon, 3 Apr 2023 01:02:42 -0500 Subject: [PATCH 10/18] Revert "cleanup" This reverts commit 3aa2f3242b06370c0f84248d2c2c21a2556bb5ec. --- custom_components/adaptive_lighting/switch.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 55479d71..5d708d60 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -1761,9 +1761,6 @@ class TurnOnOffListener: ) if new_state is not None and new_state.state == STATE_ON: - old_state: list[State] | None = self.last_state_change.get(entity_id) - if old_state is None: - return # It is possible to have multiple state change events with the same context. # This can happen because a `turn_on.light(brightness_pct=100, transition=30)` # event leads to an instant state change of @@ -1775,8 +1772,12 @@ class TurnOnOffListener: # called with a color_temp outside of its range (and HA reports the # incorrect 'min_kelvin' and 'max_kelvin', which happens e.g., for # Philips Hue White GU10 Bluetooth lights). + old_state: list[State] | None = self.last_state_change.get(entity_id) if is_our_context(new_state.context): - if old_state[0].context.id == new_state.context.id: + if ( + old_state is not None + and old_state[0].context.id == new_state.context.id + ): _LOGGER.debug( "TurnOnOffListener: State change event of '%s' is already" " in 'self.last_state_change' (%s)" @@ -1793,7 +1794,7 @@ class TurnOnOffListener: ) self.last_state_change[entity_id] = [new_state] self.start_transition_timer(entity_id) - else: + elif old_state is not None: self.last_state_change[entity_id].append(new_state) def is_manually_controlled( From cebf31a203991d12184a3652ab8544ad18d0aca5 Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Mon, 3 Apr 2023 01:35:53 -0500 Subject: [PATCH 11/18] Update switch.py --- custom_components/adaptive_lighting/switch.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 5d708d60..a6fea1b2 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -1145,8 +1145,6 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): # Return here if there's no lights to adapt. if lights is None: lights = self._lights - if not len(lights): - return if not force: if self._only_once: @@ -1156,6 +1154,9 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): if self.turn_on_off_listener.transition_timers.get(light): lights.remove(light) + if not len(lights): + return + await self._adapt_lights(lights, transition, force, context) async def _adapt_lights( From 2f0bad82ffbd55a3dc29fb809cf710c2d0859225 Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Mon, 3 Apr 2023 01:40:29 -0500 Subject: [PATCH 12/18] Update switch.py --- custom_components/adaptive_lighting/switch.py | 1 - 1 file changed, 1 deletion(-) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index a6fea1b2..f083adaa 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -1142,7 +1142,6 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): ) self.async_write_ha_state() - # Return here if there's no lights to adapt. if lights is None: lights = self._lights From 4479761ad720c9852f5288d2de2d260f4176b459 Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Mon, 3 Apr 2023 01:50:52 -0500 Subject: [PATCH 13/18] Update switch.py --- custom_components/adaptive_lighting/switch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index f083adaa..81bb365d 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -1891,7 +1891,7 @@ class TurnOnOffListener: light, context.id, ) - self.manual_control[light] = True + self.mark_as_manual_control(light) _fire_manual_control_event(switch, light, context, is_async=False) return True _LOGGER.debug( From b986b3f77cba921cbfb07647dea1d6f876883dd4 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 3 Apr 2023 00:20:12 -0700 Subject: [PATCH 14/18] Small refactor --- custom_components/adaptive_lighting/switch.py | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 81bb365d..eb343edc 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -1145,18 +1145,19 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): if lights is None: lights = self._lights - if not force: - if self._only_once: - return - for light in lights: - # Don't adapt lights that haven't finished prior transitions. - if self.turn_on_off_listener.transition_timers.get(light): - lights.remove(light) - - if not len(lights): + if not force and self._only_once: return - await self._adapt_lights(lights, transition, force, context) + filtered_lights = [] + for light in lights: + # Don't adapt lights that haven't finished prior transitions. + if force or not self.turn_on_off_listener.transition_timers.get(light): + filtered_lights.append(light) + + if not filtered_lights: + return + + await self._adapt_lights(filtered_lights, transition, force, context) async def _adapt_lights( self, From b4c6bf8a255dd32987e91f53cadc4b491d89a8dc Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 3 Apr 2023 00:22:44 -0700 Subject: [PATCH 15/18] Move test to old position for better diffs --- tests/test_switch.py | 100 +++++++++++++++++++++---------------------- 1 file changed, 50 insertions(+), 50 deletions(-) diff --git a/tests/test_switch.py b/tests/test_switch.py index 488e084f..8462122e 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -695,6 +695,56 @@ async def test_apply_service(hass): assert old_state[ATTR_COLOR_TEMP_KELVIN] == new_state[ATTR_COLOR_TEMP_KELVIN] +async def test_switch_off_on_off(hass): + """Test switch rapid off_on_off.""" + + async def turn_light(state, **kwargs): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON if state else SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_LIGHT, **kwargs}, + blocking=True, + ) + await hass.async_block_till_done() + + async def update(): + await switch._update_attrs_and_maybe_adapt_lights( + transition=0, context=switch.create_context("test") + ) + await hass.async_block_till_done() + + switch, _ = await setup_lights_and_switch(hass) + + for turn_light_state_at_end in [True, False]: + # Turn light on + await turn_light(True) + # Turn light off with transition + await turn_light(False, transition=1) + + assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + # Set state to on after a second (like happens IRL) + await asyncio.sleep(1e-3) + hass.states.async_set(ENTITY_LIGHT, STATE_ON) + # Set state to off after a second (like happens IRL) + await asyncio.sleep(1e-3) + hass.states.async_set(ENTITY_LIGHT, STATE_OFF) + + # Now we test whether the sleep task is there + assert ENTITY_LIGHT in switch.turn_on_off_listener.sleep_tasks + sleep_task = switch.turn_on_off_listener.sleep_tasks[ENTITY_LIGHT] + assert not sleep_task.cancelled() + + # A 'light.turn_on' event should cancel that task + await turn_light(turn_light_state_at_end) + await update() + state = hass.states.get(ENTITY_LIGHT).state + if turn_light_state_at_end: + assert sleep_task.cancelled() + assert state == STATE_ON + else: + assert state == STATE_OFF + + async def test_significant_change(hass): """Test significant change.""" @@ -790,56 +840,6 @@ async def test_significant_change(hass): assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] -async def test_switch_off_on_off(hass): - """Test switch rapid off_on_off.""" - - async def turn_light(state, **kwargs): - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON if state else SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: ENTITY_LIGHT, **kwargs}, - blocking=True, - ) - await hass.async_block_till_done() - - async def update(): - await switch._update_attrs_and_maybe_adapt_lights( - transition=0, context=switch.create_context("test") - ) - await hass.async_block_till_done() - - switch, _ = await setup_lights_and_switch(hass) - - for turn_light_state_at_end in [True, False]: - # Turn light on - await turn_light(True) - # Turn light off with transition - await turn_light(False, transition=1) - - assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] - # Set state to on after a second (like happens IRL) - await asyncio.sleep(1e-3) - hass.states.async_set(ENTITY_LIGHT, STATE_ON) - # Set state to off after a second (like happens IRL) - await asyncio.sleep(1e-3) - hass.states.async_set(ENTITY_LIGHT, STATE_OFF) - - # Now we test whether the sleep task is there - assert ENTITY_LIGHT in switch.turn_on_off_listener.sleep_tasks - sleep_task = switch.turn_on_off_listener.sleep_tasks[ENTITY_LIGHT] - assert not sleep_task.cancelled() - - # A 'light.turn_on' event should cancel that task - await turn_light(turn_light_state_at_end) - await update() - state = hass.states.get(ENTITY_LIGHT).state - if turn_light_state_at_end: - assert sleep_task.cancelled() - assert state == STATE_ON - else: - assert state == STATE_OFF - - def test_color_difference_redmean(): """Test color_difference_redmean function.""" for _ in range(10): From 3731c9993676b0a831af12da25bfd8c07ff2a148 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 3 Apr 2023 00:32:33 -0700 Subject: [PATCH 16/18] Revert "Small refactor" This reverts commit b986b3f77cba921cbfb07647dea1d6f876883dd4. --- custom_components/adaptive_lighting/switch.py | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index eb343edc..81bb365d 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -1145,19 +1145,18 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): if lights is None: lights = self._lights - if not force and self._only_once: + if not force: + if self._only_once: + return + for light in lights: + # Don't adapt lights that haven't finished prior transitions. + if self.turn_on_off_listener.transition_timers.get(light): + lights.remove(light) + + if not len(lights): return - filtered_lights = [] - for light in lights: - # Don't adapt lights that haven't finished prior transitions. - if force or not self.turn_on_off_listener.transition_timers.get(light): - filtered_lights.append(light) - - if not filtered_lights: - return - - await self._adapt_lights(filtered_lights, transition, force, context) + await self._adapt_lights(lights, transition, force, context) async def _adapt_lights( self, From a649198cf48643cea07943bbd944e599a0920963 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 3 Apr 2023 07:38:29 +0000 Subject: [PATCH 17/18] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c354153d..e228e73d 100644 --- a/README.md +++ b/README.md @@ -96,10 +96,10 @@ The YAML and frontend configuration methods support all of the options listed be | `lights` | List of light entities to be controlled by Adaptive Lighting (may be empty). 🌟 | `[]` | list of `entity_id`s | | `prefer_rgb_color` | Use RGB color adjustment instead of native light color temperature. 🌈 | `False` | `bool` | | `include_config_in_attributes` | Show all options as attributes on the switch in Home Assistant when set to `true`. 📝 | `False` | `bool` | -| `adapt_until_sleep` | When `true`, Adaptive Lighting will use the sleep settings as the minimum, and transition to these values past the sunset | `False` | `bool` | | `initial_transition` | Duration of the first transition when lights turn from `off` to `on`. ⏲️ | `1` | `float` 0-6553 | | `sleep_transition` | Duration of transition when 'sleep mode' is toggled. 😴 | `1` | `float` 0-6553 | | `transition` | Duration of transition when lights change, in seconds. 🕑 | `45` | `float` 0-6553 | +| `transition_until_sleep` | When checked, Adaptive Lighting will use the sleep settings as the minimum, and transition to these values past the sunset | `False` | `bool` | | `interval` | Frequency to adapt the lights, in seconds. 🔄 | `90` | `int > 0` | | `min_brightness` | Minimum brightness percentage. 💡 | `1` | `int` 1-100 | | `max_brightness` | Maximum brightness percentage. 💡 | `100` | `int` 1-100 | From 96f59f090033ca62528c988bd4ae3ac8c5c4e5f4 Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Mon, 3 Apr 2023 03:29:38 -0500 Subject: [PATCH 18/18] fix the test last_state_change isn't updated quick enough. --- tests/test_switch.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/test_switch.py b/tests/test_switch.py index 8462122e..c48594ba 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -793,8 +793,6 @@ async def test_significant_change(hass): assert switch._take_over_control switch._detect_non_ha_changes = True assert switch._detect_non_ha_changes - switch._alt_detect_method = False - assert not switch._alt_detect_method # build last service data await update(force=False) @@ -813,10 +811,8 @@ async def test_significant_change(hass): assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] # Assert last_service_data got filled from update() - # Assert last_state_change got filled from update() await update(force=True) assert switch.turn_on_off_listener.last_service_data.get(ENTITY_LIGHT) is not None - assert switch.turn_on_off_listener.last_state_change.get(ENTITY_LIGHT) is not None # Simulate a transition to 255 where the update() is already using brightness 255. await set_brightness(240) @@ -836,7 +832,7 @@ async def test_significant_change(hass): switch.hass.helpers.entity_component.async_update_entity = do_nothing # On next update ENTITY_LIGHT should be marked as manually controlled await update(force=False) - assert ENTITY_LIGHT in switch.turn_on_off_listener.last_state_change + assert switch.turn_on_off_listener.last_service_data.get(ENTITY_LIGHT) is not None assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT]