diff --git a/README.md b/README.md index bdfcd0e6..e0ba67fc 100644 --- a/README.md +++ b/README.md @@ -91,37 +91,38 @@ 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 | +| 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 | +| `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` | diff --git a/custom_components/adaptive_lighting/adaptation_utils.py b/custom_components/adaptive_lighting/adaptation_utils.py new file mode 100644 index 00000000..0cfbc7dd --- /dev/null +++ b/custom_components/adaptive_lighting/adaptation_utils.py @@ -0,0 +1,164 @@ +"""Utility functions for adaptation commands.""" +from collections.abc import AsyncGenerator +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_BRIGHTNESS_PCT, + ATTR_BRIGHTNESS_STEP, + ATTR_BRIGHTNESS_STEP_PCT, + ATTR_COLOR_NAME, + ATTR_COLOR_TEMP_KELVIN, + ATTR_HS_COLOR, + ATTR_RGB_COLOR, + ATTR_TRANSITION, + ATTR_XY_COLOR, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import Context, HomeAssistant, State + +COLOR_ATTRS = { # Should ATTR_PROFILE be in here? + ATTR_COLOR_NAME, + ATTR_COLOR_TEMP_KELVIN, + ATTR_HS_COLOR, + ATTR_RGB_COLOR, + ATTR_XY_COLOR, +} + +BRIGHTNESS_ATTRS = { + ATTR_BRIGHTNESS, + ATTR_BRIGHTNESS_PCT, + ATTR_BRIGHTNESS_STEP, + ATTR_BRIGHTNESS_STEP_PCT, +} + +ServiceData = dict[str, Any] + + +def _split_service_call_data(service_data: ServiceData) -> list[ServiceData]: + """Splits the service data by the adapted attributes, i.e., into separate data + items for brightness and color. + """ + + common_attrs = {ATTR_ENTITY_ID} + common_data = {k: service_data[k] for k in common_attrs if k in service_data} + + attributes_split_sequence = [BRIGHTNESS_ATTRS, COLOR_ATTRS] + service_datas = [] + + for attributes in attributes_split_sequence: + split_data = { + attribute: service_data[attribute] + for attribute in attributes + if service_data.get(attribute) + } + if split_data: + service_datas.append(common_data | split_data) + + # Distribute the transition duration across all service calls + if service_datas and (transition := service_data.get(ATTR_TRANSITION)) is not None: + transition = service_data[ATTR_TRANSITION] / len(service_datas) + + for service_data in service_datas: + service_data[ATTR_TRANSITION] = transition + + return service_datas + + +def _filter_service_data(service_data: ServiceData, state: State | None) -> ServiceData: + """Filter service data by removing attributes that already equal the given state. + + Removes all attributes from service call data whose values are already present + in the target entity's state.""" + + if not state: + return service_data + + filtered_service_data = { + k: service_data[k] + for k in service_data.keys() + if k not in state.attributes or service_data[k] != state.attributes[k] + } + + return filtered_service_data + + +def _has_relevant_service_data_attributes(service_data: ServiceData) -> bool: + """Determines whether the service data justifies an adaptation service call. + + A service call is not justified for data which does not contain any entries that + change relevant attributes of an adapting entity, e.g., brightness or color.""" + common_attrs = {ATTR_ENTITY_ID, ATTR_TRANSITION} + relevant_attrs = set(service_data) - common_attrs + + return bool(relevant_attrs) + + +async def _create_service_call_data_iterator( + hass: HomeAssistant, + service_datas: list[ServiceData], + filter_by_state: bool = False, +) -> AsyncGenerator[ServiceData, None]: + """Enumerates and filters a list of service datas on the fly. + + If filtering is enabled, every service data is filtered by the current state of + the related entity and only returned if it contains relevant data that justifies + a service call. + The main advantage of this generator over a list is that it applies the filter + at the time when the service data is read instead of up front. This gives greater + flexibility because entity states can change while the items are iterated. + """ + + for service_data in service_datas: + if filter_by_state and (entity_id := service_data.get(ATTR_ENTITY_ID)): + current_entity_state = hass.states.get(entity_id) + + # Filter data to remove attributes that equal the current state + if current_entity_state: + service_data = _filter_service_data(service_data, current_entity_state) + + # Emit service data if it still contains relevant attributes (else try next) + if _has_relevant_service_data_attributes(service_data): + yield service_data + else: + yield service_data + + +@dataclass +class AdaptationData: + """Holds all data required to execute an adaptation.""" + + entity_id: str + context: Context + sleep_time: float + service_call_datas: AsyncGenerator[ServiceData, None] + + async def next_service_call_data(self) -> ServiceData | None: + """Return data for the next service call, or none if no more data exists.""" + return await anext(self.service_call_datas, None) + + +def prepare_adaptation_data( + hass: HomeAssistant, + entity_id: str, + context: Context, + transition: float | None, + split_delay: float, + service_data: ServiceData, + split: bool, + filter_by_state: bool, +) -> AdaptationData: + service_datas = ( + [service_data] if not split else _split_service_call_data(service_data) + ) + + sleep_time = ( + transition / max(1, len(service_datas)) if transition is not None else 0 + ) + split_delay + + service_data_iterator = _create_service_call_data_iterator( + hass, service_datas, filter_by_state + ) + + return AdaptationData(entity_id, context, sleep_time, service_data_iterator) diff --git a/custom_components/adaptive_lighting/const.py b/custom_components/adaptive_lighting/const.py index 6d36ae2e..3aaf9dda 100644 --- a/custom_components/adaptive_lighting/const.py +++ b/custom_components/adaptive_lighting/const.py @@ -176,6 +176,17 @@ DOCS[CONF_AUTORESET_CONTROL] = ( "Set to 0 to disable. ⏲️" ) +CONF_SKIP_REDUNDANT_COMMANDS, DEFAULT_SKIP_REDUNDANT_COMMANDS = ( + "skip_redundant_commands", + False, +) +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. " + "Disable if physical light states get out of sync with HA's recorded state." +) + SLEEP_MODE_SWITCH = "sleep_mode_switch" ADAPT_COLOR_SWITCH = "adapt_color_switch" ADAPT_BRIGHTNESS_SWITCH = "adapt_brightness_switch" @@ -271,6 +282,11 @@ VALIDATION_TUPLES = [ DEFAULT_AUTORESET_CONTROL, int_between(0, 365 * 24 * 60 * 60), # 1 year max ), + ( + CONF_SKIP_REDUNDANT_COMMANDS, + DEFAULT_SKIP_REDUNDANT_COMMANDS, + bool, + ), ] CONST_COLOR = "color" diff --git a/custom_components/adaptive_lighting/strings.json b/custom_components/adaptive_lighting/strings.json index 54af5e11..00126027 100644 --- a/custom_components/adaptive_lighting/strings.json +++ b/custom_components/adaptive_lighting/strings.json @@ -47,7 +47,8 @@ "separate_turn_on_commands": "separate_turn_on_commands: Use separate `light.turn_on` calls for color and brightness, needed for some light types. πŸ”€", "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. ⏲️" + "autoreset_control_seconds": "autoreset_control_seconds: Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️", + "skip_redundant_commands": "Experimental: 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." } } }, diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 4d36fce1..a8707bcf 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -17,10 +17,6 @@ from typing import Any, Literal import astral from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_BRIGHTNESS_PCT, - ATTR_BRIGHTNESS_STEP, - ATTR_BRIGHTNESS_STEP_PCT, - ATTR_COLOR_NAME, ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, ATTR_MAX_COLOR_TEMP_KELVIN, @@ -96,6 +92,12 @@ import homeassistant.util.dt as dt_util import ulid_transform import voluptuous as vol +from .adaptation_utils import ( + BRIGHTNESS_ATTRS, + COLOR_ATTRS, + AdaptationData, + prepare_adaptation_data, +) from .const import ( ADAPT_BRIGHTNESS_SWITCH, ADAPT_COLOR_SWITCH, @@ -121,6 +123,7 @@ from .const import ( CONF_PREFER_RGB_COLOR, CONF_SEND_SPLIT_DELAY, CONF_SEPARATE_TURN_ON_COMMANDS, + CONF_SKIP_REDUNDANT_COMMANDS, CONF_SLEEP_BRIGHTNESS, CONF_SLEEP_COLOR_TEMP, CONF_SLEEP_RGB_COLOR, @@ -184,26 +187,10 @@ BRIGHTNESS_CHANGE = 25 # β‰ˆ10% of total range COLOR_TEMP_CHANGE = 100 # β‰ˆ3% of total range (2000-6500) RGB_REDMEAN_CHANGE = 80 # β‰ˆ10% of total range -COLOR_ATTRS = { # Should ATTR_PROFILE be in here? - ATTR_COLOR_NAME, - ATTR_COLOR_TEMP_KELVIN, - ATTR_HS_COLOR, - ATTR_RGB_COLOR, - ATTR_XY_COLOR, -} - -BRIGHTNESS_ATTRS = { - ATTR_BRIGHTNESS, - ATTR_BRIGHTNESS_PCT, - ATTR_BRIGHTNESS_STEP, - ATTR_BRIGHTNESS_STEP_PCT, -} # Keep a short domain version for the context instances (which can only be 36 chars) _DOMAIN_SHORT = "al" -ServiceData = dict[str, Any] - def _int_to_base36(num: int) -> str: """ @@ -282,42 +269,6 @@ def is_our_context(context: Context | None) -> bool: return f":{_DOMAIN_SHORT}:" in context.id -def _prepare_service_calls(service_data: ServiceData, split=False) -> list[ServiceData]: - """Prepares the service data for service calls. - - Processes the service_data according to the config flags, optionally splitting - it into multiple data items for the separate adaptation of different attributes. - Returns a list of service_datas that indicates the required service calls. If - no splitting is necessary, the output is a list with a single item. - """ - if not split: - return [service_data] - - common_attrs = {ATTR_ENTITY_ID} - common_data = {k: service_data[k] for k in common_attrs if k in service_data} - - attributes_split_sequence = [BRIGHTNESS_ATTRS, COLOR_ATTRS] - service_datas = [] - - for attributes in attributes_split_sequence: - split_data = { - attribute: service_data[attribute] - for attribute in attributes - if service_data.get(attribute) - } - if split_data: - service_datas.append(common_data | split_data) - - # Distribute the transition duration across all service calls - if service_datas and (transition := service_data.get(ATTR_TRANSITION)) is not None: - transition = service_data[ATTR_TRANSITION] / len(service_datas) - - for service_data in service_datas: - service_data[ATTR_TRANSITION] = transition - - return service_datas - - def _get_switches_with_lights( hass: HomeAssistant, lights: list[str] ) -> list[AdaptiveSwitch]: @@ -530,7 +481,6 @@ async def async_setup_entry( data[ATTR_ADAPT_BRIGHTNESS], data[ATTR_ADAPT_COLOR], data[CONF_PREFER_RGB_COLOR], - force=True, context=switch.create_context( "service", parent=service_call.context ), @@ -937,6 +887,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._expand_light_groups() # updates manual control timers _loc = get_astral_location(self.hass) if isinstance(_loc, tuple): @@ -1134,7 +1085,6 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): adapt_brightness: bool | None = None, adapt_color: bool | None = None, prefer_rgb_color: bool | None = None, - force: bool = False, context: Context | None = None, ) -> None: lock = self._locks.get(light) @@ -1160,7 +1110,8 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): features, supports_colors = _supported_features(self.hass, light) # Check transition == 0 to fix #378 - if ATTR_TRANSITION in features and transition > 0: + use_transition = ATTR_TRANSITION in features and transition > 0 + if use_transition: service_data[ATTR_TRANSITION] = transition if ATTR_BRIGHTNESS in features and adapt_brightness: brightness = round(255 * self._settings["brightness_pct"] / 100) @@ -1188,52 +1139,59 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): context = context or self.create_context("adapt_lights") - # See #80. Doesn't check if transitions differ but it does the job. - last_service_data = self.turn_on_off_listener.last_service_data - if not force and last_service_data.get(light) == service_data: - _LOGGER.debug( - "%s: Cancelling adapt to light %s, there's no new values to set (context.id='%s')", - self._name, - light, - context.id, - ) - return - else: - self.turn_on_off_listener.last_service_data[light] = service_data + self.turn_on_off_listener.last_service_data[light] = service_data - service_datas = _prepare_service_calls( - service_data, self._separate_turn_on_commands + data = prepare_adaptation_data( + self.hass, + light, + context, + transition if use_transition else 0, + self._send_split_delay / 1000.0, + service_data, + split=self._separate_turn_on_commands, + filter_by_state=self._skip_redundant_commands, ) - await self._make_cancellable_adaptation_calls(service_datas, context, light) - async def _make_adaptation_calls( - self, service_datas: list[ServiceData], context: Context - ): + await self._execute_cancellable_adaptation_calls(data) + + async def _execute_adaptation_calls(self, data: AdaptationData): """Executes a sequence of adaptation service calls for the given service datas.""" - for i, service_data in enumerate(service_datas): - is_first_call = i == 0 - # Sleep _between_ multiple service calls, but not before the first or a single one. + index = 0 + while True: + is_first_call = index == 0 + index += 1 + + # Sleep between multiple service calls. if not is_first_call: - await asyncio.sleep(service_data.get(ATTR_TRANSITION, 0)) - await asyncio.sleep(self._send_split_delay / 1000.0) + await asyncio.sleep(data.sleep_time) + + # Instead of directly iterating the generator in the while-loop, we get + # the next item here after the sleep to make sure it incorporates state + # changes which happened during the sleep. + service_data = await data.next_service_call_data() + + if not service_data: + # All service datas processed + break _LOGGER.debug( "%s: Scheduling 'light.turn_on' with the following 'service_data': %s" " with context.id='%s'", self._name, service_data, - context.id, + data.context.id, ) await self.hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, service_data, - context=context, + context=data.context, ) - async def _make_cancellable_adaptation_calls( - self, service_datas: list[ServiceData], context: Context, light_id: str + async def _execute_cancellable_adaptation_calls( + self, + data: AdaptationData, ): """Executes a cancellable sequence of adaptation service calls for the given service datas. @@ -1241,18 +1199,15 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): to cancel an ongoing adaptation when a light is turned off. """ # Prevent overlap of multiple adaptation sequences - self.turn_on_off_listener.cancel_ongoing_adaptation_calls(light_id) + self.turn_on_off_listener.cancel_ongoing_adaptation_calls(data.entity_id) # Execute adaptation calls within a task try: - task = self.turn_on_off_listener.adaptation_tasks[ - light_id - ] = asyncio.ensure_future( - self._make_adaptation_calls(service_datas, context) - ) + task = asyncio.ensure_future(self._execute_adaptation_calls(data)) + self.turn_on_off_listener.adaptation_tasks[data.entity_id] = task await task except asyncio.CancelledError: - _LOGGER.debug("Ongoing adaptation of %s cancelled", light_id) + _LOGGER.debug("Ongoing adaptation of %s cancelled", data.entity_id) async def _update_attrs_and_maybe_adapt_lights( self, @@ -1358,7 +1313,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): else: _fire_manual_control_event(self, light, context) else: - await self._adapt_light(light, transition, force=force, context=context) + await self._adapt_light(light, transition, context=context) async def _sleep_mode_switch_state_event(self, event: Event) -> None: if not match_switch_state_event(event, (STATE_ON, STATE_OFF)): diff --git a/custom_components/adaptive_lighting/translations/de.json b/custom_components/adaptive_lighting/translations/de.json index cc427138..79eb0df8 100644 --- a/custom_components/adaptive_lighting/translations/de.json +++ b/custom_components/adaptive_lighting/translations/de.json @@ -45,7 +45,8 @@ "take_over_control": "take_over_control, wenn irgendetwas wΓ€hrend ein Licht an ist außer Adaptive Lighting den Service 'light.turn_on' aufruft, stoppe die Anpassung des Lichtes (oder des Schalters) bis dieser wieder von off -> on geschaltet wird.", "detect_non_ha_changes": "detect_non_ha_changes, entdeckt alle Γ„nderungen ΓΌber 10% am Licht (auch außerhalb von HA gemacht), 'take_over_control' muss aktiviert sein (ruft 'homeassistant.update_entity' jede 'interval' auf!)", "transition": "transition, Wechselzeit in Sekunden", - "adapt_delay": "adapt_delay: Wartezeit (in Sekunden) zwischen Anschalten des Licht und der Anpassung durch Adaptive Lights. Kann Flackern vermeiden." + "adapt_delay": "adapt_delay: Wartezeit (in Sekunden) zwischen Anschalten des Licht und der Anpassung durch Adaptive Lights. Kann Flackern vermeiden.", + "skip_redundant_commands": "Keine Adaptierungsbefehle senden, deren erwΓΌnschter Status schon dem bekanntes Status von Lichtern entspricht. Minimiert die Netzwerkbelastung und verbessert die Adaptierung in manchen Situationen. Deaktiviert lassen falls der pysikalische Status der Lichter und der erkannte Status in HA nicht synchron bleiben." } } }, diff --git a/custom_components/adaptive_lighting/translations/en.json b/custom_components/adaptive_lighting/translations/en.json index 38fb7b0e..a930a8ce 100644 --- a/custom_components/adaptive_lighting/translations/en.json +++ b/custom_components/adaptive_lighting/translations/en.json @@ -48,7 +48,8 @@ "separate_turn_on_commands": "separate_turn_on_commands: Use separate `light.turn_on` calls for color and brightness, needed for some light types. πŸ”€", "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. ⏲️" + "autoreset_control_seconds": "autoreset_control_seconds: Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️", + "skip_redundant_commands": "Experimental: 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." } } }, diff --git a/tests/test_adaptation_utils.py b/tests/test_adaptation_utils.py new file mode 100644 index 00000000..b1cfcb5b --- /dev/null +++ b/tests/test_adaptation_utils.py @@ -0,0 +1,336 @@ +"""Tests for Adaptive Lighting utils.""" + +from unittest.mock import Mock + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + ATTR_TRANSITION, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_ON +from homeassistant.core import Context, State +import pytest + +from custom_components.adaptive_lighting.adaptation_utils import ( + ServiceData, + _create_service_call_data_iterator, + _filter_service_data, + _has_relevant_service_data_attributes, + _split_service_call_data, + prepare_adaptation_data, +) + + +@pytest.mark.parametrize( + "input_data,expected_data_list", + [ + ( + {"foo": 1}, + [], + ), + ( + {ATTR_BRIGHTNESS: 10}, + [{ATTR_BRIGHTNESS: 10}], + ), + ( + {ATTR_COLOR_TEMP_KELVIN: 3500}, + [{ATTR_COLOR_TEMP_KELVIN: 3500}], + ), + ( + {ATTR_ENTITY_ID: "foo", ATTR_BRIGHTNESS: 10}, + [{ATTR_ENTITY_ID: "foo", ATTR_BRIGHTNESS: 10}], + ), + ( + {ATTR_BRIGHTNESS: 10, ATTR_COLOR_TEMP_KELVIN: 3500}, + [{ATTR_BRIGHTNESS: 10}, {ATTR_COLOR_TEMP_KELVIN: 3500}], + ), + ( + {ATTR_BRIGHTNESS: 10, ATTR_COLOR_TEMP_KELVIN: 3500, ATTR_TRANSITION: 2}, + [ + {ATTR_BRIGHTNESS: 10, ATTR_TRANSITION: 1}, + {ATTR_COLOR_TEMP_KELVIN: 3500, ATTR_TRANSITION: 1}, + ], + ), + ( + {ATTR_TRANSITION: 1}, + [], + ), + ], + ids=[ + "remove irrelevant attributes", + "brightness only yields one service call", + "color only yields one service call", + "include entity ID", + "brightness and color are split into two with brightness first", + "transition time is distributed among service calls", + "ignore transition time without service calls", + ], +) +async def test_split_service_call_data(input_data, expected_data_list): + """Test splitting of service call data.""" + assert _split_service_call_data(input_data) == expected_data_list + + +@pytest.mark.parametrize( + "service_data,state,service_data_expected", + [ + ( + {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 10, ATTR_TRANSITION: 2}, + None, + {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 10, ATTR_TRANSITION: 2}, + ), + ( + {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 10, ATTR_TRANSITION: 2}, + State("light.test", STATE_ON), + {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 10, ATTR_TRANSITION: 2}, + ), + ( + {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 10, ATTR_TRANSITION: 2}, + State("light.test", STATE_ON, {ATTR_BRIGHTNESS: 10}), + {ATTR_ENTITY_ID: "light.test", ATTR_TRANSITION: 2}, + ), + ( + {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 10, ATTR_TRANSITION: 2}, + State("light.test", STATE_ON, {ATTR_BRIGHTNESS: 11}), + {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 10, ATTR_TRANSITION: 2}, + ), + ], + ids=[ + "pass all attributes on missing state", + "pass all attributes on empty state", + "remove attributes whose values equal the state", + "keep attributes whose values differ from the state", + ], +) +async def test_filter_service_data( + service_data: ServiceData, state: State | None, service_data_expected: ServiceData +): + """Test filtering of service data.""" + assert _filter_service_data(service_data, state) == service_data_expected + + +@pytest.mark.parametrize( + "service_data,expected_relevant", + [ + ( + {ATTR_ENTITY_ID: "light.test"}, + False, + ), + ( + {ATTR_TRANSITION: 2}, + False, + ), + ( + {ATTR_BRIGHTNESS: 10}, + True, + ), + ( + {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 10, ATTR_TRANSITION: 2}, + True, + ), + ], +) +async def test_has_relevant_service_data_attributes( + service_data: ServiceData, expected_relevant: bool +): + """Test the determination of relevancy of service data""" + assert _has_relevant_service_data_attributes(service_data) == expected_relevant + + +@pytest.mark.parametrize( + "service_datas,filter_by_state,service_datas_expected", + [ + ( + [{ATTR_ENTITY_ID: "light.test"}], + False, + [{ATTR_ENTITY_ID: "light.test"}], + ), + ( + [{ATTR_ENTITY_ID: "light.test"}, {ATTR_ENTITY_ID: "light.test2"}], + False, + [{ATTR_ENTITY_ID: "light.test"}, {ATTR_ENTITY_ID: "light.test2"}], + ), + ( + [{ATTR_ENTITY_ID: "light.test"}], + True, + [], + ), + ( + [{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 10}], + True, + [], + ), + ( + [{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 11}], + True, + [{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 11}], + ), + ( + [ + {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 11}, + {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 22}, + ], + True, + [ + {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 11}, + {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 22}, + ], + ), + ( + [ + {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 10}, + {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 22}, + ], + True, + [ + {ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 22}, + ], + ), + ], + ids=[ + "single item passed through without filtering", + "two items passed through without filtering", + "filter removes item without relevant attributes", + "filter removes item with relevant attribute that equals the state", + "filter keeps item with relevant attribute that is different from state", + "filter keeps two items with relevant attributes that are different from state", + "filter removes item that equals state and keeps items that differs from state", + ], +) +async def test_create_service_call_data_iterator( + service_datas: list[ServiceData], + filter_by_state: bool, + service_datas_expected: list[ServiceData], + hass_states_mock, +): + """Test the generator function for correct enumeration and filtering.""" + + generated_service_datas = [ + data + async for data in _create_service_call_data_iterator( + hass_states_mock, service_datas, filter_by_state + ) + ] + + assert generated_service_datas == service_datas_expected + assert ( + hass_states_mock.states.get.call_count == 0 + if not filter_by_state + else len(service_datas) + ) + + +@pytest.mark.parametrize( + "service_data,split,filter_by_state,service_datas_expected,sleep_time_expected", + [ + ( + { + ATTR_ENTITY_ID: "light.test", + ATTR_BRIGHTNESS: 10, + ATTR_COLOR_TEMP_KELVIN: 4000, + }, + False, + False, + [ + { + ATTR_ENTITY_ID: "light.test", + ATTR_BRIGHTNESS: 10, + ATTR_COLOR_TEMP_KELVIN: 4000, + } + ], + 1.2, + ), + ( + { + ATTR_ENTITY_ID: "light.test", + ATTR_BRIGHTNESS: 10, + ATTR_COLOR_TEMP_KELVIN: 4000, + }, + True, + False, + [ + { + ATTR_ENTITY_ID: "light.test", + ATTR_BRIGHTNESS: 10, + }, + { + ATTR_ENTITY_ID: "light.test", + ATTR_COLOR_TEMP_KELVIN: 4000, + }, + ], + 0.7, + ), + ( + { + ATTR_ENTITY_ID: "light.test", + ATTR_BRIGHTNESS: 10, + ATTR_COLOR_TEMP_KELVIN: 4000, + }, + False, + True, + [ + { + ATTR_ENTITY_ID: "light.test", + ATTR_COLOR_TEMP_KELVIN: 4000, + } + ], + 1.2, + ), + ( + { + ATTR_ENTITY_ID: "light.test", + ATTR_BRIGHTNESS: 10, + ATTR_COLOR_TEMP_KELVIN: 4000, + }, + True, + True, + [ + { + ATTR_ENTITY_ID: "light.test", + ATTR_COLOR_TEMP_KELVIN: 4000, + } + ], + 0.7, + ), + ], + ids=[ + "service data passed through", + "service data split", + "service data filtered", + "service data split and filtered", + ], +) +async def test_prepare_adaptation_data( + hass_states_mock, + service_data, + split, + filter_by_state, + service_datas_expected, + sleep_time_expected, +): + """Test creation of correct service data objects.""" + data = prepare_adaptation_data( + hass_states_mock, + "test.entity", + Context(id="test-id"), + 1, + 0.2, + service_data, + split, + filter_by_state, + ) + + generated_service_datas = [item async for item in data.service_call_datas] + + assert data.entity_id == "test.entity" + assert data.context.id == "test-id" + assert data.sleep_time == sleep_time_expected + assert generated_service_datas == service_datas_expected + + +@pytest.fixture(name="hass_states_mock") +def fixture_hass_states_mock(): + """Mocks a HA state machine which returns a mock state.""" + hass = Mock() + hass.states.get.return_value = Mock(attributes={ATTR_BRIGHTNESS: 10}) + return hass diff --git a/tests/test_switch.py b/tests/test_switch.py index 0e46f458..bb5d1c49 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -50,6 +50,10 @@ from pytest_homeassistant_custom_component.common import ( import ulid_transform import voluptuous.error +from custom_components.adaptive_lighting.adaptation_utils import ( + AdaptationData, + _create_service_call_data_iterator, +) from custom_components.adaptive_lighting.const import ( ADAPT_BRIGHTNESS_SWITCH, ADAPT_COLOR_SWITCH, @@ -84,7 +88,6 @@ from custom_components.adaptive_lighting.switch import ( _SUPPORT_OPTS, VALID_COLOR_MODES, _attributes_have_changed, - _prepare_service_calls, _supported_features, color_difference_redmean, create_context, @@ -1404,69 +1407,6 @@ async def test_change_switch_settings_service(hass): assert switch._sun_light_settings.min_color_temp == 2500 -@pytest.mark.parametrize( - "service_data_input,split,service_data_expected", - [ - ( - {"foo": 1, ATTR_BRIGHTNESS: 10, ATTR_TRANSITION: 2}, - False, - [{"foo": 1, ATTR_BRIGHTNESS: 10, ATTR_TRANSITION: 2}], - ), - ( - {"foo": 1}, - True, - [], - ), - ( - {ATTR_BRIGHTNESS: 10}, - True, - [{ATTR_BRIGHTNESS: 10}], - ), - ( - {ATTR_COLOR_TEMP_KELVIN: 3500}, - True, - [{ATTR_COLOR_TEMP_KELVIN: 3500}], - ), - ( - {ATTR_ENTITY_ID: "foo", ATTR_BRIGHTNESS: 10}, - True, - [{ATTR_ENTITY_ID: "foo", ATTR_BRIGHTNESS: 10}], - ), - ( - {ATTR_BRIGHTNESS: 10, ATTR_COLOR_TEMP_KELVIN: 3500}, - True, - [{ATTR_BRIGHTNESS: 10}, {ATTR_COLOR_TEMP_KELVIN: 3500}], - ), - ( - {ATTR_BRIGHTNESS: 10, ATTR_COLOR_TEMP_KELVIN: 3500, ATTR_TRANSITION: 2}, - True, - [ - {ATTR_BRIGHTNESS: 10, ATTR_TRANSITION: 1}, - {ATTR_COLOR_TEMP_KELVIN: 3500, ATTR_TRANSITION: 1}, - ], - ), - ( - {ATTR_TRANSITION: 1}, - True, - [], - ), - ], - ids=[ - "pass through when splitting is disabled", - "remove irrelevant attributes", - "brightness only yields one service call", - "color only yields one service call", - "include entity ID", - "brightness and color are split into two with brightness first", - "transition time is distributed among service calls", - "ignore transition time without service calls", - ], -) -async def test_prepare_service_calls(service_data_input, split, service_data_expected): - """Test the preparation of service calls, e.g., splitting.""" - assert _prepare_service_calls(service_data_input, split) == service_data_expected - - @pytest.mark.dependency(depends=GLOBAL_TEST_DEPENDENCIES) async def test_cancellable_service_calls_task(hass): """Test the creation and execution of the task that wraps adaptation service calls.""" @@ -1476,17 +1416,18 @@ async def test_cancellable_service_calls_task(hass): assert switch.turn_on_off_listener.adaptation_tasks.get(light.entity_id) is None - await switch._make_cancellable_adaptation_calls( - [ - { - ATTR_BRIGHTNESS: 10, - ATTR_COLOR_TEMP_KELVIN: 10, - ATTR_ENTITY_ID: light.entity_id, - } - ], - context, + service_data = { + ATTR_BRIGHTNESS: 10, + ATTR_COLOR_TEMP_KELVIN: 10, + ATTR_ENTITY_ID: light.entity_id, + } + adaptation_data = AdaptationData( light.entity_id, + context, + 0, + _create_service_call_data_iterator(hass, [service_data]), ) + await switch._execute_cancellable_adaptation_calls(adaptation_data) task = switch.turn_on_off_listener.adaptation_tasks.get(light.entity_id) assert task is not None