From 52275bfee3fb56edd43fbb2f45a04b71e1ca4952 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 27 Mar 2023 23:39:52 -0700 Subject: [PATCH 01/27] 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/27] 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/27] 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 85eb85cab632e676218bd06d70e414aa86af3024 Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Sun, 2 Apr 2023 23:09:21 -0500 Subject: [PATCH 04/27] merge wait_for_transition --- 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 ad04e39d4ed086d488ad4d02cbae011aaed8babf Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Sun, 2 Apr 2023 23:14:04 -0500 Subject: [PATCH 05/27] Update switch.py --- custom_components/adaptive_lighting/switch.py | 78 ++++++++++--------- 1 file changed, 43 insertions(+), 35 deletions(-) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 203fcb4e..47e97f69 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -1030,9 +1030,6 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): if lock is not None and lock.locked(): _LOGGER.debug("%s: '%s' is locked", self._name, light) return - service_data = {ATTR_ENTITY_ID: light} - features = _supported_features(self.hass, light) - if transition is None: transition = self._transition if adapt_brightness is None: @@ -1042,14 +1039,17 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): if prefer_rgb_color is None: prefer_rgb_color = self._prefer_rgb_color - if "transition" in features: - service_data[ATTR_TRANSITION] = transition - # The switch might be off and not have _settings set. self._settings = self._sun_light_settings.get_settings( self.sleep_mode_switch.is_on, transition ) + # Build service data. + service_data = {ATTR_ENTITY_ID: light} + features = _supported_features(self.hass, light) + + if "transition" in features: + service_data[ATTR_TRANSITION] = transition if "brightness" in features and adapt_brightness: brightness = round(255 * self._settings["brightness_pct"] / 100) service_data[ATTR_BRIGHTNESS] = brightness @@ -1076,19 +1076,6 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): service_data[ATTR_RGB_COLOR] = self._settings["rgb_color"] context = context or self.create_context("adapt_lights") - if ( - self._take_over_control - and self._detect_non_ha_changes - and not force - and await self.turn_on_off_listener.significant_change( - self, - light, - adapt_brightness, - adapt_color, - context, - ) - ): - return self.turn_on_off_listener.last_service_data[light] = service_data async def turn_on(service_data): @@ -1160,42 +1147,63 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): lights, transition, force, context ) - async def _adapt_lights( + async def _update_manual_control_and_maybe_adapt( self, lights: list[str], transition: int | None, force: bool, context: Context | None, + adapt_brightness: bool | None = None, + adapt_color: bool | None = None, ) -> None: assert context is not None _LOGGER.debug( - "%s: '_adapt_lights(%s, %s, force=%s, context.id=%s)' called", + "%s: '_update_manual_control_and_maybe_adapt(%s, %s, force=%s, context.id=%s)' called", self.name, lights, transition, force, context.id, ) + + if adapt_brightness is None: + adapt_brightness = self.adapt_brightness_switch.is_on + if adapt_color is None: + adapt_color = self.adapt_color_switch.is_on + for light in lights: if not is_on(self.hass, light): continue - if ( - self._take_over_control - and self.turn_on_off_listener.is_manually_controlled( + + if self._take_over_control: + if self.turn_on_off_listener.is_manually_controlled( self, light, force, - self.adapt_brightness_switch.is_on, - self.adapt_color_switch.is_on, - ) - ): - _LOGGER.debug( - "%s: '%s' is being manually controlled, stop adapting, context.id=%s.", - self._name, - light, - context.id, - ) - continue + adapt_brightness, + adapt_color, + context, + ): + _LOGGER.debug( + "%s: '%s' is being manually controlled, stop adapting, context.id=%s.", + self._name, + light, + context.id, + ) + continue + if ( + (self._detect_non_ha_changes or self._alt_detect_method) + and not force + and await self.turn_on_off_listener.significant_change( + self, + light, + adapt_brightness, + adapt_color, + context, + ) + ): + _fire_manual_control_event(self, light, context, is_async=False) + continue await self._adapt_light(light, transition, force=force, context=context) async def _sleep_mode_switch_state_event(self, event: Event) -> None: From 8e847e71eb5c47f23fbc9783cf17d5f229f6b29e Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Sun, 2 Apr 2023 23:15:53 -0500 Subject: [PATCH 06/27] 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 07/27] 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 08/27] 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 09/27] 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 a6641d0daf41e13fe854ad6989237532f4c241b8 Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Sun, 2 Apr 2023 23:51:24 -0500 Subject: [PATCH 10/27] Update switch.py --- custom_components/adaptive_lighting/switch.py | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 912ecfa3..865f6fc5 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -1142,17 +1142,6 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): # 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._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 ) @@ -1192,7 +1181,6 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): force, adapt_brightness, adapt_color, - context, ): _LOGGER.debug( "%s: '%s' is being manually controlled, stop adapting, context.id=%s.", @@ -1202,7 +1190,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): ) continue if ( - (self._detect_non_ha_changes or self._alt_detect_method) + self._detect_non_ha_changes and not force and await self.turn_on_off_listener.significant_change( self, From 732fc17c7f291ffaf78dd118465bd982b4cd0b6d Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Sun, 2 Apr 2023 23:56:08 -0500 Subject: [PATCH 11/27] 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 12/27] 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 13/27] 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 f453968258344e8b55fa1206ad64d33d48ef14eb Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Mon, 3 Apr 2023 01:04:05 -0500 Subject: [PATCH 14/27] 0.1 sometimes fails the test --- tests/test_switch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_switch.py b/tests/test_switch.py index 488e084f..30686001 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -590,7 +590,7 @@ async def test_manual_control(hass): async def test_auto_reset_manual_control(hass): switch, (light, *_) = await setup_lights_and_switch( - hass, {CONF_AUTORESET_CONTROL: 0.1} + hass, {CONF_AUTORESET_CONTROL: 0.2} ) context = switch.create_context("test") # needs to be passed to update method manual_control = switch.turn_on_off_listener.manual_control From 6e9fb0cc0d0090628641f1c7a8f814f4d4e22976 Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Mon, 3 Apr 2023 01:32:46 -0500 Subject: [PATCH 15/27] not in this pr yet --- 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 865f6fc5..d1331d59 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -1200,7 +1200,6 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): context, ) ): - _fire_manual_control_event(self, light, context, is_async=False) continue await self._adapt_light(light, transition, force=force, context=context) From cebf31a203991d12184a3652ab8544ad18d0aca5 Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Mon, 3 Apr 2023 01:35:53 -0500 Subject: [PATCH 16/27] 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 6b2fbf7c9bbce70a5d47a2c37baa21c228ac15a6 Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Mon, 3 Apr 2023 01:35:53 -0500 Subject: [PATCH 17/27] Update switch.py --- custom_components/adaptive_lighting/switch.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index d1331d59..e4685d63 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -1129,11 +1129,8 @@ 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 not len(lights): - return if not force: if self._only_once: @@ -1142,6 +1139,9 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): # 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 + await self._update_manual_control_and_maybe_adapt( lights, transition, force, context ) From 2f0bad82ffbd55a3dc29fb809cf710c2d0859225 Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Mon, 3 Apr 2023 01:40:29 -0500 Subject: [PATCH 18/27] 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 19/27] 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 9c8d322135c20d2a1fcd6273609c53253127894b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 4 Apr 2023 00:59:20 +0000 Subject: [PATCH 20/27] Update README.md, strings.json, and services.yaml --- README.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/README.md b/README.md index 7f810c01..c7a873d0 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,37 @@ The YAML and frontend configuration methods support all of the options listed be +| Variable name | Description | Default | Type | +|:-------------------------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:---------------|:-------------------------------------| +| `lights` | List of light entity_ids to be controlled (may be empty). 🌟 | `[]` | list of `entity_id`s | +| `prefer_rgb_color` | Whether to prefer RGB color adjustment over light color temperature when possible. 🌈 | `False` | `bool` | +| `include_config_in_attributes` | Show all options as attributes on the switch in Home Assistant when set to `true`. 📝 | `False` | `bool` | +| `initial_transition` | Duration of the first transition when lights turn from `off` to `on` in seconds. ⏲️ | `1` | `float` 0-6553 | +| `sleep_transition` | Duration of transition when "sleep mode" is toggled in seconds. 😴 | `1` | `float` 0-6553 | +| `transition` | Duration of transition when lights change, in seconds. 🕑 | `45` | `float` 0-6553 | +| `transition_until_sleep` | When enabled, Adaptive Lighting will treat sleep settings as the minimum, transitioning to these values after 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 | +| `min_color_temp` | Warmest color temperature in Kelvin. 🔥 | `2000` | `int` 1000-10000 | +| `max_color_temp` | Coldest color temperature in Kelvin. ❄️ | `5500` | `int` 1000-10000 | +| `sleep_brightness` | Brightness percentage of lights in sleep mode. 😴 | `1` | `int` 1-100 | +| `sleep_rgb_or_color_temp` | Use either `"rgb_color"` or `"color_temp"` in sleep mode. 🌙 | `color_temp` | one of `['color_temp', 'rgb_color']` | +| `sleep_color_temp` | Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is `color_temp`) in Kelvin. 😴 | `1000` | `int` 1000-10000 | +| `sleep_rgb_color` | RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is "rgb_color"). 🌈 | `[255, 56, 0]` | RGB color | +| `sunrise_time` | Set a fixed time (HH:MM:SS) for sunrise. 🌅 | `None` | `str` | +| `max_sunrise_time` | Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier real sunrises. 🌅 | `None` | `str` | +| `sunrise_offset` | Adjust sunrise time with a positive or negative offset in seconds. ⏰ | `0` | `int` | +| `sunset_time` | Set a fixed time (HH:MM:SS) for sunset. 🌇 | `None` | `str` | +| `min_sunset_time` | Set the earliest virtual sunset time (HH:MM:SS), allowing for later real sunsets. 🌇 | `None` | `str` | +| `sunset_offset` | Adjust sunset time with a positive or negative offset in seconds. ⏰ | `0` | `int` | +| `only_once` | Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄 | `False` | `bool` | +| `take_over_control` | Disable Adaptive Lighting if another source calls `light.turn_on` while lights are on and being adapted. Note that this calls `homeassistant.update_entity` every `interval`! 🔒 | `True` | `bool` | +| `detect_non_ha_changes` | Detect non-`light.turn_on` state changes and stop adapting lights. Requires `take_over_control`. 🕵️ | `False` | `bool` | +| `separate_turn_on_commands` | Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀 | `False` | `bool` | +| `send_split_delay` | Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️ | `0` | `int` 0-10000 | +| `adapt_delay` | Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️ | `0` | `float > 0` | +| `autoreset_control_seconds` | Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️ | `0` | `int` 0-31536000 | From 21f8beac8d0c4224e9c90170835e02887987281b Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Mon, 3 Apr 2023 20:04:53 -0500 Subject: [PATCH 21/27] slight refactor --- custom_components/adaptive_lighting/switch.py | 20 +++++-------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 540196d9..68029ce8 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -1128,28 +1128,18 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): if lights is None: lights = self._lights - if not force and self._only_once: - 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) - 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 self.turn_on_off_listener.transition_timers.get(light): + filtered_lights.append(light) + else: + filtered_lights = lights + if not filtered_lights: return await self._update_manual_control_and_maybe_adapt( From c3c0eaac2e087397ac9606e6f689fea0d3b53aae Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Mon, 3 Apr 2023 20:07:36 -0500 Subject: [PATCH 22/27] Update switch.py --- custom_components/adaptive_lighting/switch.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 68029ce8..467b6222 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -381,6 +381,7 @@ def _fire_manual_control_event( switch.entity_id, light, ) + switch.turn_on_off_listener.mark_as_manual_control(light) fire( f"{DOMAIN}.manual_control", {ATTR_ENTITY_ID: light, SWITCH_DOMAIN: switch.entity_id}, @@ -476,7 +477,6 @@ async def async_setup_entry( all_lights = _expand_light_groups(switch.hass, lights) if service_call.data[CONF_MANUAL_CONTROL]: for light in all_lights: - switch.turn_on_off_listener.mark_as_manual_control(light) _fire_manual_control_event(switch, light, service_call.context) else: switch.turn_on_off_listener.reset(*all_lights) @@ -1832,7 +1832,7 @@ class TurnOnOffListener: ): # Light was already on and 'light.turn_on' was not called by # the adaptive_lighting integration. - manual_control = self.mark_as_manual_control(light) + manual_control = True _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" @@ -1900,8 +1900,6 @@ class TurnOnOffListener: light, context.id, ) - self.mark_as_manual_control(light) - _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 20854f1dc0cdf0ca1f01c4c474345b1053c43637 Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Mon, 3 Apr 2023 20:09:43 -0500 Subject: [PATCH 23/27] Update switch.py --- custom_components/adaptive_lighting/switch.py | 1 + 1 file changed, 1 insertion(+) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 467b6222..4817f984 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -1200,6 +1200,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): context, ) ): + _fire_manual_control_event(self, light, context, is_async=False) continue await self._adapt_light(light, transition, force=force, context=context) From 8285564015b8479a61d7801712270db8bea0317d Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 3 Apr 2023 23:43:09 -0700 Subject: [PATCH 24/27] Possible refactor of _update_attrs_and_maybe_adapt_lights --- custom_components/adaptive_lighting/switch.py | 65 ++++++++++--------- 1 file changed, 33 insertions(+), 32 deletions(-) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 4817f984..1ddaf2c8 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -1129,21 +1129,20 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): if lights is None: lights = self._lights - filtered_lights = [] - if not force: - if self._only_once: - return - for light in lights: - # Don't adapt lights that haven't finished prior transitions. - if not self.turn_on_off_listener.transition_timers.get(light): - filtered_lights.append(light) - else: - filtered_lights = lights + if not force and self._only_once: + return + + filtered_lights = [ + light + for light in lights + if force or not self.turn_on_off_listener.transition_timers.get(light) + ] + if not filtered_lights: return await self._update_manual_control_and_maybe_adapt( - lights, transition, force, context + filtered_lights, transition, force, context ) async def _update_manual_control_and_maybe_adapt( @@ -1165,43 +1164,45 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): context.id, ) - if adapt_brightness is None: - adapt_brightness = self.adapt_brightness_switch.is_on - if adapt_color is None: - adapt_color = self.adapt_color_switch.is_on + adapt_brightness = adapt_brightness or self.adapt_brightness_switch.is_on + adapt_color = adapt_color or self.adapt_color_switch.is_on for light in lights: if not is_on(self.hass, light): continue - if self._take_over_control: - if self.turn_on_off_listener.is_manually_controlled( + manually_controlled = self.turn_on_off_listener.is_manually_controlled( + self, + light, + force, + adapt_brightness, + adapt_color, + ) + + significant_change = ( + self._detect_non_ha_changes + and not force + and await self.turn_on_off_listener.significant_change( self, light, - force, adapt_brightness, adapt_color, - ): + context, + ) + ) + + if self._take_over_control and (manually_controlled or significant_change): + if manually_controlled: _LOGGER.debug( "%s: '%s' is being manually controlled, stop adapting, context.id=%s.", self._name, light, context.id, ) - continue - if ( - self._detect_non_ha_changes - and not force - and await self.turn_on_off_listener.significant_change( - self, - light, - adapt_brightness, - adapt_color, - context, - ) - ): + else: _fire_manual_control_event(self, light, context, is_async=False) - continue + continue + await self._adapt_light(light, transition, force=force, context=context) async def _sleep_mode_switch_state_event(self, event: Event) -> None: From 441cb1ff5cc45eb3438f7e367e8f22cea061ba73 Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Sat, 8 Apr 2023 13:06:28 -0500 Subject: [PATCH 25/27] cleaned up --- custom_components/adaptive_lighting/switch.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index d410f28e..d241ad31 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -1234,8 +1234,6 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): transition: int | None, force: bool, context: Context | None, - adapt_brightness: bool | None = None, - adapt_color: bool | None = None, ) -> None: assert context is not None _LOGGER.debug( @@ -1247,10 +1245,8 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): context.id, ) - if adapt_brightness is None: - adapt_brightness = self.adapt_brightness_switch.is_on - if adapt_color is None: - adapt_color = self.adapt_color_switch.is_on + adapt_brightness = self.adapt_brightness_switch.is_on + adapt_color = self.adapt_color_switch.is_on for light in lights: if not is_on(self.hass, light): @@ -1282,7 +1278,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): context, ) ): - _fire_manual_control_event(self, light, context, is_async=False) + _fire_manual_control_event(self, light, context) continue await self._adapt_light(light, transition, force=force, context=context) From 11b268148b098af4f790a6f41c8b87b14ea0748a Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Sat, 8 Apr 2023 13:11:54 -0500 Subject: [PATCH 26/27] Revert "cleaned up" This reverts commit 441cb1ff5cc45eb3438f7e367e8f22cea061ba73. --- custom_components/adaptive_lighting/switch.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index d241ad31..d410f28e 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -1234,6 +1234,8 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): transition: int | None, force: bool, context: Context | None, + adapt_brightness: bool | None = None, + adapt_color: bool | None = None, ) -> None: assert context is not None _LOGGER.debug( @@ -1245,8 +1247,10 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): context.id, ) - adapt_brightness = self.adapt_brightness_switch.is_on - adapt_color = self.adapt_color_switch.is_on + if adapt_brightness is None: + adapt_brightness = self.adapt_brightness_switch.is_on + if adapt_color is None: + adapt_color = self.adapt_color_switch.is_on for light in lights: if not is_on(self.hass, light): @@ -1278,7 +1282,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): context, ) ): - _fire_manual_control_event(self, light, context) + _fire_manual_control_event(self, light, context, is_async=False) continue await self._adapt_light(light, transition, force=force, context=context) From b9273d2d060eb4350571d54130d3a187b69160fd Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Sat, 8 Apr 2023 13:26:39 -0500 Subject: [PATCH 27/27] Revert "Revert "cleaned up"" This reverts commit 11b268148b098af4f790a6f41c8b87b14ea0748a. --- custom_components/adaptive_lighting/switch.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 8b40e9b9..fd894489 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -1234,8 +1234,6 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): transition: int | None, force: bool, context: Context | None, - adapt_brightness: bool | None = None, - adapt_color: bool | None = None, ) -> None: assert context is not None _LOGGER.debug( @@ -1247,8 +1245,8 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): context.id, ) - adapt_brightness = adapt_brightness or self.adapt_brightness_switch.is_on - adapt_color = adapt_color or self.adapt_color_switch.is_on + adapt_brightness = self.adapt_brightness_switch.is_on + adapt_color = self.adapt_color_switch.is_on for light in lights: if not is_on(self.hass, light): @@ -1283,7 +1281,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): context.id, ) else: - _fire_manual_control_event(self, light, context, is_async=False) + _fire_manual_control_event(self, light, context) continue await self._adapt_light(light, transition, force=force, context=context)