feat: skip redundant adaptation commands (#615)

* feat: skip redundant adaptation commands

* update README

* style

* Remove "experimental"

---------

Co-authored-by: Bas Nijholt <basnijholt@gmail.com>
Co-authored-by: Bas Nijholt <bas@nijho.lt>
This commit is contained in:
Mario Guggenberger 2023-07-03 00:26:07 +02:00 committed by GitHub
commit 72e140e293
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 617 additions and 201 deletions

View file

@ -91,37 +91,38 @@ The YAML and frontend configuration methods support all of the options listed be
<!-- START_OUTPUT -->
<!-- THIS CONTENT IS AUTOMATICALLY GENERATED -->
| 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` |
<!-- END_OUTPUT -->

View file

@ -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)

View file

@ -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"

View file

@ -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."
}
}
},

View file

@ -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)):

View file

@ -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."
}
}
},

View file

@ -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."
}
}
},

View file

@ -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

View file

@ -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