From 1ef7ed507ee25a70a17dc83c5d58d1360da64447 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Thu, 3 Aug 2023 17:47:09 -0700 Subject: [PATCH] Implement call intercept for multiple lights (#679) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Implement call intercept for multiple lights * remove comment * skip if no eids * add comment * fix type * Fix skipped * indentation * Add logging and fix error * Fix for HA ≤2023.04 * simplify * remove unused ignores * Debug mode * Add test * Make test failing * rename switch * rename lights * Fix tests * Rename lights in tests * Remove unused dependencies * Improve tests * More tests * Remove the DEBUG_MODE * Add doc-string * Extra test * assert * extra test * Comments * fix * fix * expand light groups * more logging * sort * Revert is_proactively_adapting checks This reverts commit 39fd8f2be0f3d6bef27705d167b5eab8113d5709. * simplify the mapping * Revert "Revert is_proactively_adapting checks" This reverts commit 18803e8e507c9b44934e15320c1bbed7fa30b788. * test * no light groups * do not expand * Do not expand_light_groups in intercept * more logging * Fix * add comment * Add multi_light_intercept config option * Update README.md, strings.json, and services.yaml * add light group * fix platform * add simple test * turn off again * Test without take over control * improve test and fix it in one way * Fixes * add cleanup fixture * format * Update test_switch.py * add __str__ * remove unneeded call * simplify service_data construction * Generalize is_our_context * Fix multi_light_intercept: false * add comments * add docs * Update README.md, strings.json, and services.yaml * Add feature line * move function --------- Co-authored-by: github-actions[bot] --- .ruff.toml | 2 +- README.md | 6 +- .../adaptive_lighting/adaptation_utils.py | 14 + custom_components/adaptive_lighting/const.py | 13 +- .../adaptive_lighting/strings.json | 3 +- custom_components/adaptive_lighting/switch.py | 352 ++++++++++++---- .../adaptive_lighting/translations/en.json | 3 +- tests/test_switch.py | 377 ++++++++++++++++-- 8 files changed, 648 insertions(+), 122 deletions(-) diff --git a/.ruff.toml b/.ruff.toml index c1b6b784..1cfeeb74 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -16,7 +16,7 @@ ignore = [ "FBT002", # Boolean default value in function definition "FIX004", # Line contains HACK, consider resolving the issue "PD901", # df is a bad variable name. Be kinder to your future self. - "PERF203",# `try`-`except` within a loop incurs performance overhead + "PERF203", # `try`-`except` within a loop incurs performance overhead "PLR0913", # Too many arguments to function call (N > 5) "PLR2004", # Magic value used in comparison, consider replacing X with a constant variable "S101", # Use of assert detected diff --git a/README.md b/README.md index 9a70b89a..d48ee10d 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,9 @@ In addition to its regular mode, Adaptive Lighting also offers a "sleep mode" ## :bulb: Features +When initially turning on a light that is controlled by Adaptive Lighting, the `light.turn_on` service call is intercepted, and the light's brightness and color are automatically adjusted based on the sun's position. +After that, the light's brightness and color are automatically adjusted at a regular interval. + Adaptive Lighting provides four switches (using "living_room" as an example component name): - `switch.adaptive_lighting_living_room`: Turn Adaptive Lighting on or off and view current light settings through its attributes. @@ -124,7 +127,8 @@ The YAML and frontend configuration methods support all of the options listed be | `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 | -| `skip_redundant_commands` | Skip sending adaptation commands whose target state already equals the light's known state. Minimizes network traffic and improves the adaptation responsivity in some situations. Disable if physical light states get out of sync with HA's recorded state. | `False` | `bool` | +| `skip_redundant_commands` | Skip sending adaptation commands whose target state already equals the light's known state. Minimizes network traffic and improves the adaptation responsivity in some situations. 📉Disable if physical light states get out of sync with HA's recorded state. | `False` | `bool` | +| `multi_light_intercept` | Intercept and adapt `light.turn_on` calls that target multiple lights. ➗⚠️ This might result in splitting up a single `light.turn_on` call into multiple calls, e.g., when lights are in different switches. | `True` | `bool` | diff --git a/custom_components/adaptive_lighting/adaptation_utils.py b/custom_components/adaptive_lighting/adaptation_utils.py index 97914327..593a746b 100644 --- a/custom_components/adaptive_lighting/adaptation_utils.py +++ b/custom_components/adaptive_lighting/adaptation_utils.py @@ -150,6 +150,20 @@ class AdaptationData: """Return data for the next service call, or none if no more data exists.""" return await anext(self.service_call_datas, None) + def __str__(self) -> str: + """Return a string representation of the data.""" + return ( + f"{self.__class__.__name__}(" + f"entity_id={self.entity_id}, " + f"context_id={self.context.id}, " + f"sleep_time={self.sleep_time}, " + f"force={self.force}, " + f"max_length={self.max_length}, " + f"which={self.which}, " + f"initial_sleep={self.initial_sleep}" + ")" + ) + class NoColorOrBrightnessInServiceDataError(Exception): """Exception raised when no color or brightness attributes are found in service data.""" diff --git a/custom_components/adaptive_lighting/const.py b/custom_components/adaptive_lighting/const.py index 94e9a50b..d2b1dc1e 100644 --- a/custom_components/adaptive_lighting/const.py +++ b/custom_components/adaptive_lighting/const.py @@ -186,10 +186,20 @@ CONF_SKIP_REDUNDANT_COMMANDS, DEFAULT_SKIP_REDUNDANT_COMMANDS = ( DOCS[CONF_SKIP_REDUNDANT_COMMANDS] = ( "Skip sending adaptation commands whose target state already " "equals the light's known state. Minimizes network traffic and improves the " - "adaptation responsivity in some situations. " + "adaptation responsivity in some situations. 📉" "Disable if physical light states get out of sync with HA's recorded state." ) +CONF_MULTI_LIGHT_INTERCEPT, DEFAULT_MULTI_LIGHT_INTERCEPT = ( + "multi_light_intercept", + True, +) +DOCS[CONF_MULTI_LIGHT_INTERCEPT] = ( + "Intercept and adapt `light.turn_on` calls that target multiple lights. ➗" + "⚠️ This might result in splitting up a single `light.turn_on` call " + "into multiple calls, e.g., when lights are in different switches." +) + SLEEP_MODE_SWITCH = "sleep_mode_switch" ADAPT_COLOR_SWITCH = "adapt_color_switch" ADAPT_BRIGHTNESS_SWITCH = "adapt_brightness_switch" @@ -290,6 +300,7 @@ VALIDATION_TUPLES = [ DEFAULT_SKIP_REDUNDANT_COMMANDS, bool, ), + (CONF_MULTI_LIGHT_INTERCEPT, DEFAULT_MULTI_LIGHT_INTERCEPT, bool), ] diff --git a/custom_components/adaptive_lighting/strings.json b/custom_components/adaptive_lighting/strings.json index b6d62b0b..f60297c5 100644 --- a/custom_components/adaptive_lighting/strings.json +++ b/custom_components/adaptive_lighting/strings.json @@ -48,7 +48,8 @@ "send_split_delay": "send_split_delay: Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️", "adapt_delay": "adapt_delay: Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️", "autoreset_control_seconds": "autoreset_control_seconds: Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️", - "skip_redundant_commands": "skip_redundant_commands: Skip sending adaptation commands whose target state already equals the light's known state. Minimizes network traffic and improves the adaptation responsivity in some situations. Disable if physical light states get out of sync with HA's recorded state." + "skip_redundant_commands": "skip_redundant_commands: Skip sending adaptation commands whose target state already equals the light's known state. Minimizes network traffic and improves the adaptation responsivity in some situations. 📉Disable if physical light states get out of sync with HA's recorded state.", + "multi_light_intercept": "multi_light_intercept: Intercept and adapt `light.turn_on` calls that target multiple lights. ➗⚠️ This might result in splitting up a single `light.turn_on` call into multiple calls, e.g., when lights are in different switches." } } }, diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index fd0fb9d1..defe3b85 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -20,6 +20,7 @@ import ulid_transform import voluptuous as vol from homeassistant.components.light import ( ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_FLASH, @@ -119,6 +120,7 @@ from .const import ( CONF_MIN_BRIGHTNESS, CONF_MIN_COLOR_TEMP, CONF_MIN_SUNSET_TIME, + CONF_MULTI_LIGHT_INTERCEPT, CONF_ONLY_ONCE, CONF_PREFER_RGB_COLOR, CONF_SEND_SPLIT_DELAY, @@ -265,30 +267,39 @@ def create_context( return Context(id=context_id, parent_id=parent_id) -def is_our_context_id(context_id: str | None) -> bool: +def is_our_context_id(context_id: str | None, which: str | None = None) -> bool: """Check whether this integration created 'context_id'.""" if context_id is None: return False - return f":{_DOMAIN_SHORT}:" in context_id + + is_al = f":{_DOMAIN_SHORT}:" in context_id + if not is_al: + return False + if which is None: + return True + return f":{_remove_vowels(which)}:" in context_id -def is_our_context(context: Context | None) -> bool: +def is_our_context(context: Context | None, which: str | None = None) -> bool: """Check whether this integration created 'context'.""" if context is None: return False - return is_our_context_id(context.id) + return is_our_context_id(context.id, which) @bind_hass def _switches_with_lights( hass: HomeAssistant, lights: list[str], + expand_light_groups: bool = True, ) -> list[AdaptiveSwitch]: """Get all switches that control at least one of the lights passed.""" config_entries = hass.config_entries.async_entries(DOMAIN) data = hass.data[DOMAIN] switches = [] - all_check_lights = _expand_light_groups(hass, lights) + all_check_lights = ( + _expand_light_groups(hass, lights) if expand_light_groups else set(lights) + ) for config in config_entries: entry = data.get(config.entry_id) if entry is None: # entry might be disabled and therefore missing @@ -309,9 +320,10 @@ class NoSwitchFoundError(ValueError): def _switch_with_lights( hass: HomeAssistant, lights: list[str], + expand_light_groups: bool = True, ) -> AdaptiveSwitch: """Find the switch that controls the lights in 'lights'.""" - switches = _switches_with_lights(hass, lights) + switches = _switches_with_lights(hass, lights, expand_light_groups) if len(switches) == 1: return switches[0] if len(switches) > 1: @@ -643,7 +655,10 @@ def _is_state_event(event: Event, from_or_to_state: Iterable[str]): @bind_hass -def _expand_light_groups(hass: HomeAssistant, lights: list[str]) -> list[str]: +def _expand_light_groups( + hass: HomeAssistant, + lights: list[str], +) -> list[str]: all_lights = set() manager = hass.data[DOMAIN][ATTR_ADAPTIVE_LIGHTING_MANAGER] for light in lights: @@ -651,14 +666,18 @@ def _expand_light_groups(hass: HomeAssistant, lights: list[str]) -> list[str]: if state is None: _LOGGER.debug("State of %s is None", light) all_lights.add(light) - elif "entity_id" in state.attributes: # it's a light group + elif _is_light_group(state): group = state.attributes["entity_id"] manager.lights.discard(light) all_lights.update(group) _LOGGER.debug("Expanded %s to %s", light, group) else: all_lights.add(light) - return list(all_lights) + return sorted(all_lights) + + +def _is_light_group(state: State) -> bool: + return "entity_id" in state.attributes @bind_hass @@ -927,6 +946,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): self._take_over_control = True self._auto_reset_manual_control_time = data[CONF_AUTORESET_CONTROL] self._skip_redundant_commands = data[CONF_SKIP_REDUNDANT_COMMANDS] + self._multi_light_intercept = data[CONF_MULTI_LIGHT_INTERCEPT] self._expand_light_groups() # updates manual control timers location, _ = get_astral_location(self.hass) @@ -1272,11 +1292,6 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): prefer_rgb_color: bool | None = None, force: bool = False, ) -> None: - # This should never happen if it's been proactively adapted. - # The context.parent_id is the context.id of the service call that was intercepted - # and context.id here is from the resulting "light_event" event. - assert not self.manager.is_proactively_adapting(context.parent_id) - if (lock := self.manager.turn_off_locks.get(light)) and lock.locked(): _LOGGER.debug("%s: '%s' is locked", self._name, light) return @@ -1874,6 +1889,9 @@ class AdaptiveLightingManager: # Track light transitions self.transition_timers: dict[str, _AsyncSingleShotTimer] = {} + # Track _execute_cancellable_adaptation_calls tasks + self.adaptation_tasks = set() + # Setup listeners and its callbacks to remove them later self.listener_removers = [ self.hass.bus.async_listen( @@ -1958,65 +1976,235 @@ class AdaptiveLightingManager: for key in keys: self._proactively_adapting_contexts.pop(key) - async def _service_interceptor_turn_on_handler( # noqa: PLR0911 + async def _service_interceptor_turn_on_handler( # noqa: PLR0912, PLR0915 self, call: ServiceCall, data: ServiceData, - ): - # Don't adapt our own service calls - if is_our_context(call.context): + ) -> None: + """Intercept `light.turn_on` and `light.toggle` service calls and adapt them. + + It is possible that the calls are made for multiple lights at once, + which in turn might be in different switches or no switches at all. + If there are lights that are not all in a single switch, we need to + make multiple calls to `light.turn_on` with the correct entity IDs. + One of these calls can be intercepted and adapted, the others need to + be adapted by calling `_adapt_light` with the correct entity IDs or + by calling `light.turn_on` directly. + + We create a mapping from switch to entity IDs and keep a list + of skipped lights which are lights in no switches or in switches that + are off or lights that are already on. + + If there is only one switch and 0 skipped lights, we just intercept the + call directly. + + If there are multiple switches and skipped lights, we can adapt the call + for one of the switches to include only the lights in that switch and + need to call `_adapt_light` for the other switches with their + entity_ids. For skipped lights, we call light.turn_on directly with the + entity_ids and original service data. + + If there are only skipped lights, we can use the intercepted call + directly. + """ + is_skipped_hash = is_our_context(call.context, "skipped") + _LOGGER.debug( + "(0) _service_interceptor_turn_on_handler: call.context.id='%s', is_skipped_hash='%s'", + call.context.id, + is_skipped_hash, + ) + if is_our_context(call.context) and not is_skipped_hash: + # Don't adapt our own service calls, but do re-adapt calls that + # were skipped by us return if ATTR_EFFECT in data[CONF_PARAMS] or ATTR_FLASH in data[CONF_PARAMS]: return + _LOGGER.debug( + "(1) _service_interceptor_turn_on_handler: call='%s', data='%s'", + call, + data, + ) + entity_ids = self._get_entity_list(data) + # Note: we do not expand light groups anywhere in this method, instead + # we skip them and rely on the followup call that HA will make + # with the expanded entity IDs. - # For simplicity, only service calls affecting a single entity are currently handled. - # - # To add support for adapting multiple entities, the following properties - # need to hold for _all_ entities: - # - managed by this AL instance - # - not manually controlled - # - supporting the same relevant feature set - # - off state - if len(entity_ids) != 1: - return + # Create a mapping from switch to entity IDs + # AdaptiveSwitch.name → entity_ids mapping + switch_to_eids: dict[str, list[str]] = {} + # AdaptiveSwitch.name → AdaptiveSwitch mapping + switch_name_mapping: dict[str, AdaptiveSwitch] = {} + # Note: In HA≥2023.5, AdaptiveSwitch is hashable, so we can + # use dict[AdaptiveSwitch, list[str]] + skipped: list[str] = [] + for entity_id in entity_ids: + try: + switch = _switch_with_lights( + self.hass, + [entity_id], + # Do not expand light groups, because HA will make a separate light.turn_on + # call where the lights are expanded, and that call will be intercepted. + expand_light_groups=False, + ) + except NoSwitchFoundError: + # Needs to make the original call but without adaptation + skipped.append(entity_id) + _LOGGER.debug( + "No switch found for entity_id='%s', skipped='%s'", + entity_id, + skipped, + ) + else: + if ( + not switch.is_on + # Never adapt on light groups, because HA will make a separate light.turn_on + or _is_light_group(self.hass.states.get(entity_id)) + # Prevent adaptation of TURN_ON calls when light is already on, + # and of TOGGLE calls when toggling off. + or self.hass.states.is_state(entity_id, STATE_ON) + or self.manual_control.get(entity_id, False) + ): + _LOGGER.debug( + "Switch is off or light is already on for entity_id='%s', skipped='%s'" + " (is_on='%s', is_state='%s', manual_control='%s')", + entity_id, + skipped, + switch.is_on, + self.hass.states.is_state(entity_id, STATE_ON), + self.manual_control.get(entity_id, False), + ) + skipped.append(entity_id) + else: + switch_to_eids.setdefault(switch.name, []).append(entity_id) + switch_name_mapping[switch.name] = switch - entity_id = entity_ids[0] - - # Prevent adaptation of TURN_ON calls when light is already on, - # and of TOGGLE calls when toggling off. - if self.hass.states.is_state(entity_id, STATE_ON): - return - - if self.manual_control.get(entity_id, False): - return - - if data.get(ATTR_BRIGHTNESS) == 0: + # Check for `multi_light_intercept: true/false` + mli = [sw._multi_light_intercept for sw in switch_name_mapping.values()] + more_than_one_switch = len(switch_to_eids) > 1 + single_switch_with_multiple_lights = ( + len(switch_to_eids) == 1 and len(next(iter(switch_to_eids.values()))) > 1 + ) + switch_without_multi_light_intercept = not all(mli) + if more_than_one_switch and switch_without_multi_light_intercept: _LOGGER.warning( - "Turn-on call with zero brightness detected, Adaptive Lighting" - " intercepted this service_call and adjusted it. If you use this as" - " a brightness workaround, please remove it, it is no longer necessary", + "Multiple switches (%s) targeted, but not all have" + " `multi_light_intercept: true`, so skipping intercept" + " for all lights.", + switch_to_eids, ) + skipped = entity_ids + switch_to_eids = {} + elif ( + single_switch_with_multiple_lights and switch_without_multi_light_intercept + ): + _LOGGER.warning( + "Single switch with multiple lights targeted, but" + " `multi_light_intercept: true` is not set, so skipping intercept" + " for all lights.", + switch_to_eids, + ) + skipped = entity_ids + switch_to_eids = {} - try: - adaptive_switch = _switch_with_lights(self.hass, [entity_id]) - except NoSwitchFoundError: - # This might be a light that is not managed by this AL instance. + _LOGGER.debug( + "(2) _service_interceptor_turn_on_handler: switch_to_eids='%s', skipped='%s'", + switch_to_eids, + skipped, + ) + + def modify_service_data(service_data, entity_ids): + """Modify the service data to contain the entity IDs.""" + service_data.pop(ATTR_ENTITY_ID, None) + service_data.pop(ATTR_AREA_ID, None) + service_data[ATTR_ENTITY_ID] = entity_ids + return service_data + + # Intercept the call for first switch and call _adapt_light for the rest + has_intercepted = False # Can only intercept a turn_on call once + for adaptive_switch_name, _entity_ids in switch_to_eids.items(): + switch = switch_name_mapping[adaptive_switch_name] + transition = data[CONF_PARAMS].get( + ATTR_TRANSITION, + switch.initial_transition, + ) + if not has_intercepted: + _LOGGER.debug( + "(3) _service_interceptor_turn_on_handler: intercepting entity_ids='%s'", + _entity_ids, + ) + await self._service_interceptor_turn_on_single_light_handler( + entity_ids=_entity_ids, + switch=switch, + transition=transition, + call=call, + data=modify_service_data(data, _entity_ids), + ) + has_intercepted = True + continue + + for eid in _entity_ids: + # Must add a new context otherwise _adapt_light will bail out + context = switch.create_context("intercept") + self.clear_proactively_adapting(eid) + self.set_proactively_adapting(context.id, eid) + _LOGGER.debug( + "(4) _service_interceptor_turn_on_handler: calling `_adapt_light` with eid='%s', context='%s', transition='%s'", + eid, + context, + transition, + ) + await switch._adapt_light( + light=eid, + context=context, + transition=transition, + ) + + # Call light.turn_on service for skipped entities + if skipped: + if not has_intercepted: + assert set(skipped) == set(entity_ids) + return # The call will be intercepted with the original data + # Call light turn_on service for skipped entities + context = switch.create_context("skipped") _LOGGER.debug( - "No (or multiple) adaptive switch(es) found for entity %s," - " skipping adaptation by intercepting service call", - entity_id, + "(5) _service_interceptor_turn_on_handler: calling `light.turn_on` with skipped='%s', data: '%s', context='%s'", + skipped, + data, + context.id, + ) + # Need to expand light groups here because otherwise this interceptor loop will happen twice more + _LOGGER.debug( + "(6) _service_interceptor_turn_on_handler: calling `light.turn_on` with skipped='%s', data: '%s', context='%s'", + skipped, + data, + context.id, + ) + service_data = {ATTR_ENTITY_ID: skipped, **data[CONF_PARAMS]} + if ( + ATTR_COLOR_TEMP in service_data + and ATTR_COLOR_TEMP_KELVIN in service_data + ): + # ATTR_COLOR_TEMP and ATTR_COLOR_TEMP_KELVIN are mutually exclusive + del service_data[ATTR_COLOR_TEMP] + await self.hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + service_data, + blocking=True, + context=context, ) - return - - if not adaptive_switch.is_on: - return - - if entity_id not in adaptive_switch.lights: - return + async def _service_interceptor_turn_on_single_light_handler( + self, + entity_ids: list[str], + switch: AdaptiveSwitch, + transition: int, + call: ServiceCall, + data: ServiceData, + ): _LOGGER.debug( "Intercepted TURN_ON call with data %s (%s)", data, @@ -2024,18 +2212,13 @@ class AdaptiveLightingManager: ) # Reset because turning on the light, this also happens in - # `turn_on_off_event_listener`, however, this function is called + # `state_changed_event_listener`, however, this function is called # before that one. - self.reset(entity_id, reset_manual_control=False) + self.reset(*entity_ids, reset_manual_control=False) + for entity_id in entity_ids: + self.clear_proactively_adapting(entity_id) - self.clear_proactively_adapting(entity_id) - - transition = data[CONF_PARAMS].get( - ATTR_TRANSITION, - adaptive_switch.initial_transition, - ) - - adaptation_data = await adaptive_switch.prepare_adaptation_data( + adaptation_data = await switch.prepare_adaptation_data( entity_id, transition, ) @@ -2063,12 +2246,21 @@ class AdaptiveLightingManager: # We cannot know here whether there is another call to follow (since the # state can change until the next call), so we just schedule it and let # it sort out by itself. - self.set_proactively_adapting(call.context.id, entity_id) - self.set_proactively_adapting(adaptation_data.context.id, entity_id) + for entity_id in entity_ids: + self.set_proactively_adapting(call.context.id, entity_id) + self.set_proactively_adapting(adaptation_data.context.id, entity_id) adaptation_data.initial_sleep = True - _ = asyncio.create_task( # Don't await to avoid blocking the service call - adaptive_switch.execute_cancellable_adaptation_calls(adaptation_data), + + # Don't await to avoid blocking the service call. + # Assign to a variable only to await in tests. + self.adaptation_tasks.add( + asyncio.create_task( + switch.execute_cancellable_adaptation_calls(adaptation_data), + ), ) + # Remove tasks that are done + if done_tasks := [t for t in self.adaptation_tasks if t.done()]: + self.adaptation_tasks.difference_update(done_tasks) def _handle_timer( self, @@ -2092,14 +2284,22 @@ class AdaptiveLightingManager: def start_transition_timer(self, light: str) -> None: """Mark a light as manually controlled.""" - last_service_data = self.last_service_data[light] - last_transition = last_service_data.get(ATTR_TRANSITION) - if not last_transition: + last_service_data = self.last_service_data.get(light) + if last_service_data is None: _LOGGER.debug( - "No transition in last adapt for light %s, continuing...", + "No last service data for light %s, not starting timer.", light, ) return + + last_transition = last_service_data.get(ATTR_TRANSITION) + if not last_transition: + _LOGGER.debug( + "No transition in last adapt for light %s, not starting timer.", + light, + ) + return + _LOGGER.debug( "Start transition timer of %s seconds for light %s", last_transition, @@ -2198,8 +2398,7 @@ class AdaptiveLightingManager: 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 is not None: + if timer := self.auto_reset_manual_control_timers.pop(light, None): timer.cancel() self.our_last_state_on_change.pop(light, None) self.last_service_data.pop(light, None) @@ -2380,6 +2579,7 @@ class AdaptiveLightingManager: entity_id, event.context.id, ) + # Note: the reset below already happened in `_service_interceptor_turn_on_handler` return self.reset(entity_id, reset_manual_control=False) diff --git a/custom_components/adaptive_lighting/translations/en.json b/custom_components/adaptive_lighting/translations/en.json index 358f7cbe..7e58be17 100644 --- a/custom_components/adaptive_lighting/translations/en.json +++ b/custom_components/adaptive_lighting/translations/en.json @@ -49,7 +49,8 @@ "send_split_delay": "send_split_delay: Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️", "adapt_delay": "adapt_delay: Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️", "autoreset_control_seconds": "autoreset_control_seconds: Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️", - "skip_redundant_commands": "skip_redundant_commands: Skip sending adaptation commands whose target state already equals the light's known state. Minimizes network traffic and improves the adaptation responsivity in some situations. Disable if physical light states get out of sync with HA's recorded state." + "skip_redundant_commands": "skip_redundant_commands: Skip sending adaptation commands whose target state already equals the light's known state. Minimizes network traffic and improves the adaptation responsivity in some situations. 📉Disable if physical light states get out of sync with HA's recorded state.", + "multi_light_intercept": "multi_light_intercept: Intercept and adapt `light.turn_on` calls that target multiple lights. ➗⚠️ This might result in splitting up a single `light.turn_on` call into multiple calls, e.g., when lights are in different switches." } } }, diff --git a/tests/test_switch.py b/tests/test_switch.py index 4963209b..e3bc9126 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -1,6 +1,7 @@ """Tests for Adaptive Lighting switches.""" # pylint: disable=protected-access import asyncio +import itertools from copy import deepcopy import datetime import logging @@ -55,6 +56,7 @@ from custom_components.adaptive_lighting.adaptation_utils import ( from custom_components.adaptive_lighting.const import ( ADAPT_BRIGHTNESS_SWITCH, ADAPT_COLOR_SWITCH, + CONF_TAKE_OVER_CONTROL, ATTR_ADAPTIVE_LIGHTING_MANAGER, CONF_ADAPT_UNTIL_SLEEP, CONF_AUTORESET_CONTROL, @@ -64,6 +66,7 @@ from custom_components.adaptive_lighting.const import ( CONF_MAX_BRIGHTNESS, CONF_MIN_COLOR_TEMP, CONF_PREFER_RGB_COLOR, + CONF_MULTI_LIGHT_INTERCEPT, CONF_SEPARATE_TURN_ON_COMMANDS, CONF_SLEEP_RGB_OR_COLOR_TEMP, CONF_SUNRISE_OFFSET, @@ -90,6 +93,7 @@ from custom_components.adaptive_lighting.switch import ( _attributes_have_changed, color_difference_redmean, create_context, + AdaptiveLightingManager, is_our_context, is_our_context_id, lerp_color_hsv, @@ -141,6 +145,18 @@ def reset_time_zone(): dt_util.DEFAULT_TIME_ZONE = ORIG_TIMEZONE +@pytest.fixture +async def cleanup(hass): + yield + manager: AdaptiveLightingManager = hass.data[DOMAIN][ATTR_ADAPTIVE_LIGHTING_MANAGER] + for timer in manager.auto_reset_manual_control_timers.values(): + timer.cancel() + for timer in manager.transition_timers.values(): + timer.cancel() + for task in manager.adaptation_tasks: + task.cancel() + + async def setup_switch(hass, extra_data) -> tuple[MockConfigEntry, AdaptiveSwitch]: """Create the switch entry.""" entry = MockConfigEntry( @@ -159,51 +175,46 @@ async def setup_switch(hass, extra_data) -> tuple[MockConfigEntry, AdaptiveSwitc return entry, switch -async def setup_lights(hass: HomeAssistant): +async def setup_lights(hass: HomeAssistant, with_group: bool = False): """Set up 3 light entities using the 'template' platform.""" + n = 3 if not with_group else 5 # last 2 will be put in a group + template_lights = { + f"light_{i}": { + "unique_id": f"light_{i}", + "friendly_name": f"light_{i}", + "turn_on": None, + "turn_off": None, + "set_level": None, + "set_temperature": None, + "set_color": None, + } + for i in range(1, n + 1) + } + template_lights["light_3"]["supports_transition_template"] = True + platforms = [{"platform": "template", "lights": template_lights}] + + if with_group: + platforms.append( + { + "platform": "group", + "entities": ["light.light_4", "light.light_5"], + "name": "Light Group", + "unique_id": "light_group", + "all": "false", + } + ) + await async_setup_component( hass, LIGHT_DOMAIN, - { - LIGHT_DOMAIN: [ - { - "platform": "template", - "lights": { - "light_1": { - "friendly_name": "light_1", - "unique_id": "light_1", - "turn_on": None, - "turn_off": None, - "set_level": None, - "set_temperature": None, - "set_color": None, - }, - "light_2": { - "friendly_name": "light_2", - "unique_id": "light_2", - "turn_on": None, - "turn_off": None, - "set_level": None, - "set_temperature": None, - "set_color": None, - }, - "light_3": { - "friendly_name": "light_3", - "unique_id": "light_3", - "turn_on": None, - "turn_off": None, - "set_level": None, - "set_temperature": None, - "set_color": None, - "supports_transition_template": True, - }, - }, - }, - ] - }, + {LIGHT_DOMAIN: platforms}, ) - await hass.async_block_till_done() + + if with_group: + state = hass.states.get("light.light_group") + assert state.attributes["entity_id"] == ["light.light_4", "light.light_5"] + platform = async_get_platforms(hass, "template") lights = list(platform[0].entities.values()) @@ -1374,13 +1385,15 @@ async def test_service_calls_task_cancellation(hass): async def _turn_on_and_track_event_contexts( - hass: HomeAssistant, context_id: str, entity_id + hass: HomeAssistant, context_id: str, entity_id, return_full_events: bool = False ): context = Context(id=context_id) event_context_ids = [] + events = [] async def turn_on_off_event_listener(event: Event) -> None: event_context_ids.append(event.context.id) + events.append(event) hass.bus.async_listen(EVENT_CALL_SERVICE, turn_on_off_event_listener) @@ -1392,7 +1405,8 @@ async def _turn_on_and_track_event_contexts( context=context, ) await hass.async_block_till_done() - + if return_full_events: + return events return event_context_ids @@ -1540,6 +1554,148 @@ async def test_proactive_adaptation_transition_override(hass): switch.manager.cancel_ongoing_adaptation_calls(ENTITY_LIGHT_3) +async def setup_proactive_multiple_lights_two_switches(hass): + lights_instances = await setup_lights(hass) + # Setup switches + lights = [ + ENTITY_LIGHT_1, + ENTITY_LIGHT_2, + ENTITY_LIGHT_3, + ] + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: lights}, + blocking=True, + ) + defaults = { + CONF_SUNRISE_TIME: datetime.time(SUNRISE.hour), + CONF_SUNSET_TIME: datetime.time(SUNSET.hour), + CONF_INITIAL_TRANSITION: 0, + CONF_TRANSITION: 0, + CONF_DETECT_NON_HA_CHANGES: True, + CONF_PREFER_RGB_COLOR: False, + CONF_MIN_COLOR_TEMP: 2500, # to not coincide with sleep_color_temp} + INTERNAL_CONF_PROACTIVE_SERVICE_CALL_ADAPTATION: True, + } + _, switch1 = await setup_switch( + hass, {CONF_NAME: "switch1", CONF_LIGHTS: [ENTITY_LIGHT_1], **defaults} + ) + _, switch2 = await setup_switch( + hass, {CONF_NAME: "switch2", CONF_LIGHTS: [ENTITY_LIGHT_2], **defaults} + ) + assert hass.states.get(switch1.entity_id).state == STATE_ON + assert hass.states.get(switch2.entity_id).state == STATE_ON + assert all(hass.states.get(light).state == STATE_OFF for light in lights) + return lights, switch1, switch2 + + +async def test_proactive_multiple_lights_all_at_once(hass): + """Create switch and demo lights.""" + lights, switch1, switch2 = await setup_proactive_multiple_lights_two_switches(hass) + _LOGGER.debug("Start test_proactive_multiple_lights_all_at_once") + # Setup demo lights and turn on + events = await _turn_on_and_track_event_contexts( + hass, "test1", lights, return_full_events=True + ) + assert len(events) == 3, events + + # Original turn_on call that is intercepted + assert events[0].context.id == "test1" + assert events[0].data["service_data"][ATTR_ENTITY_ID] == lights + + # The `has_intercepted` path + assert events[1].data["service_data"][ATTR_ENTITY_ID] == ENTITY_LIGHT_2 + assert ":ntrc:" in events[1].context.id + + # The skipped lights, the one not in a switch + assert events[2].data["service_data"][ATTR_ENTITY_ID] == [ENTITY_LIGHT_3] + assert ":skpp:" in events[2].context.id + + assert switch1.manager.is_proactively_adapting("test1") + assert switch2.manager.is_proactively_adapting("test1") + + await hass.async_block_till_done() + + assert all(hass.states.get(light).state == STATE_ON for light in lights) + + # Turn on second time even though already on + events = await _turn_on_and_track_event_contexts( + hass, "test2", lights, return_full_events=True + ) + assert len(events) == 1, events + assert events[0].context.id == "test2" + + +async def test_proactive_multiple_lights_turn_on_non_managed_light(hass): + """Create switch and demo lights.""" + lights, switch1, switch2 = await setup_proactive_multiple_lights_two_switches(hass) + turn_ons = await _turn_on_and_track_event_contexts(hass, "test1", lights) + assert len(turn_ons) == 3, turn_ons + await hass.async_block_till_done() + assert all(hass.states.get(light).state == STATE_ON for light in lights) + + # Turn off ENTITY_LIGHT_3 (which is not in a switch), leaving the other two on + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_LIGHT_3}, + blocking=True, + context=Context(id="test2"), + ) + + # Now turn on all lights again, which means the code gets to "if skipped: if not has_intercepted:" + turn_ons = await _turn_on_and_track_event_contexts(hass, "test2", ENTITY_LIGHT_3) + assert len(turn_ons) == 1, turn_ons + + +async def test_proactive_multiple_lights_turn_on_managed_lights_only(hass): + """Create switch and demo lights.""" + lights, switch1, switch2 = await setup_proactive_multiple_lights_two_switches(hass) + _LOGGER.debug("Start test_proactive_multiple_lights_all_at_once") + # Setup demo lights and turn on + events = await _turn_on_and_track_event_contexts( + hass, "test1", lights[:-1], return_full_events=True + ) + assert len(events) == 2, events + + # Original turn_on call that is intercepted + assert events[0].context.id == "test1" + assert events[0].data["service_data"][ATTR_ENTITY_ID] == lights[:-1] + + # The `has_intercepted` path + assert events[1].data["service_data"][ATTR_ENTITY_ID] == ENTITY_LIGHT_2 + assert ":ntrc:" in events[1].context.id + assert ATTR_BRIGHTNESS in events[1].data["service_data"] + + +async def test_proactive_multiple_lights_one_switch_and_one_skipped(hass): + """Create switch and demo lights.""" + lights, switch1, switch2 = await setup_proactive_multiple_lights_two_switches(hass) + two_lights = [lights[0], lights[-1]] + _LOGGER.debug("Start test_proactive_multiple_lights_all_at_once") + # Setup demo lights and turn on + events = await _turn_on_and_track_event_contexts( + hass, "test1", two_lights, return_full_events=True + ) + assert len(events) == 2, events + + # Original turn_on call that is intercepted + assert events[0].context.id == "test1" + assert events[0].data["service_data"][ATTR_ENTITY_ID] == two_lights + + # The skipped lights, the one not in a switch + assert events[1].data["service_data"][ATTR_ENTITY_ID] == [ENTITY_LIGHT_3] + assert ":skpp:" in events[1].context.id + + assert switch1.manager.is_proactively_adapting("test1") + assert switch2.manager.is_proactively_adapting("test1") + + await hass.async_block_till_done() + + assert all(hass.states.get(light).state == STATE_ON for light in two_lights) + + async def test_two_switches_for_single_light(hass): """Test the case where someone has two switches for a single light. @@ -1697,3 +1853,142 @@ def test_lerp_color_hsv(): with pytest.raises(AssertionError): lerp_color_hsv((255, 0, 0), (0, 255, 0), 1.1) + + +@pytest.mark.parametrize("proactive_service_call_adaptation", [True, False]) +@pytest.mark.parametrize("take_over_control", [True, False]) +@pytest.mark.parametrize("multi_light_intercept", [True, False]) +async def test_light_group( + hass, + proactive_service_call_adaptation, + take_over_control, + multi_light_intercept, + cleanup, +): + lights = await setup_lights(hass, with_group=True) + all_entity_ids = [light.entity_id for light in lights] + entity_ids = all_entity_ids[:3] # the last two are in the group + entity_ids.append("light.light_group") + _, switch = await setup_switch( + hass, + { + CONF_LIGHTS: entity_ids, + INTERNAL_CONF_PROACTIVE_SERVICE_CALL_ADAPTATION: proactive_service_call_adaptation, + CONF_TAKE_OVER_CONTROL: take_over_control, + CONF_MULTI_LIGHT_INTERCEPT: multi_light_intercept, + }, + ) + await hass.async_block_till_done() + assert switch.is_on + assert all(eid in switch.lights for eid in all_entity_ids) + + # Set the brightness of the group twice, once to turn it on and once to + # trigger manual control + for _ in range(2): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.light_group", ATTR_BRIGHTNESS_PCT: 50}, + blocking=True, + ) + await hass.async_block_till_done() + + await switch._update_attrs_and_maybe_adapt_lights( + context=switch.create_context("test") + ) + await hass.async_block_till_done() + + if take_over_control: + assert switch.manager.manual_control["light.light_4"] + assert switch.manager.manual_control["light.light_5"] + else: + assert not switch.manager.manual_control["light.light_4"] + assert not switch.manager.manual_control["light.light_5"] + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.light_group"}, + blocking=True, + ) + await hass.async_block_till_done() + + assert not switch.manager.manual_control["light.light_4"] + assert not switch.manager.manual_control["light.light_5"] + events = await _turn_on_and_track_event_contexts( + hass, "testing", "light.light_group", return_full_events=True + ) + if proactive_service_call_adaptation and multi_light_intercept: + await asyncio.gather(*switch.manager.adaptation_tasks) + # Both lights should be adapted via interception, so with the original context + # [ + # "testing", # original call light 4 + # "testing", # original call light 5 + # ] + + assert events[0].data["service_data"][ATTR_ENTITY_ID] == "light.light_group" + assert events[0].context.id == "testing" + assert events[1].data["service_data"][ATTR_ENTITY_ID] == [ + "light.light_4", + "light.light_5", + ] + assert events[1].context.id == "testing" + else: + assert events[0].data["service_data"][ATTR_ENTITY_ID] == "light.light_group" + assert events[0].context.id == "testing" + assert events[1].data["service_data"][ATTR_ENTITY_ID] == [ + "light.light_4", + "light.light_5", + ] + assert events[1].context.id == "testing" + e1 = events[2].data["service_data"][ATTR_ENTITY_ID] + e2 = events[3].data["service_data"][ATTR_ENTITY_ID] + assert ( + e1 == "light.light_4" + and e2 == "light.light_5" + or e1 == "light.light_5" + and e2 == "light.light_4" + ) + assert ":lght:" in events[2].context.id + assert ":lght:" in events[3].context.id + assert len(events) == 4 + assert not switch.manager.is_proactively_adapting(events[0].context.id) + assert not switch.manager.is_proactively_adapting(events[1].context.id) + + # Turn off all lights, and then turn on all lights + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: all_entity_ids}, + blocking=True, + ) + await hass.async_block_till_done() + + # This turns on light_1, light_2, light_3, light_group (which is light_4 and light_5) + # This should result in the intercepted adaptation of light_1, light_2, light_3 + # and skip the light_group first. Then on a second light.turn_on where the + # light_group is expanded, with a :skpp: context_id, this goes trhough another iteration, + # and then the light_group is adapted. + events = await _turn_on_and_track_event_contexts( + hass, "testing", entity_ids, return_full_events=True + ) + if proactive_service_call_adaptation and multi_light_intercept: + await asyncio.gather(*switch.manager.adaptation_tasks) + # Original call + assert events[0].data["service_data"][ATTR_ENTITY_ID] == [ + "light.light_1", + "light.light_2", + "light.light_3", + "light.light_group", + ] + assert events[0].context.id == "testing" + # Skipped call with light_group + assert events[1].data["service_data"][ATTR_ENTITY_ID] == ["light.light_group"] + assert ":skpp:" in events[1].context.id + # HA automatically forwarded call with light_group expanded with same context + assert events[2].data["service_data"][ATTR_ENTITY_ID] == [ + "light.light_4", + "light.light_5", + ] + assert ":skpp:" in events[2].context.id + assert len(events) == 3