Individual manual control of brightness and color (#1356)

* refactor: introduce light control parameter enum

* refactor: replace manual control flag with parameter enum

* test: update deprecated color temp attribute

* build: set execution bits on task scripts

* feat: individual manual control of brightness and color

* test: add tests for individual manual control evaluation

* fix: sequential manual changes not always detected

If multiple attributes of a light were changed within an interval, only the last change was detected because the check in the interval only used the latest event. For example, if there was a brightness change and a following color change, only the color attribute was detected as manually controlled. To fix this, the manual control attribute flags are now set directly from the event handler so that all events are processed.

* fix: invalid service description

* docs: fix missing space in config description

* refactor: pluralize multivalued bitmask enum name
This commit is contained in:
Mario Guggenberger 2025-12-23 07:55:16 +01:00 committed by GitHub
commit f84ee445b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 846 additions and 293 deletions

View file

@ -105,46 +105,47 @@ The YAML and frontend configuration methods support all of the options listed be
<!-- OUTPUT:START --> <!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. --> <!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
| Variable name | Description | Default | Type | | Variable name | Description | Default | Type |
|:-------------------------------|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:---------------|:---------------------------------------| |:-------------------------------|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:---------------|:----------------------------------------|
| `lights` | List of light entity_ids to be controlled (may be empty). 🌟 | `[]` | list of `entity_id`s | | `lights` | List of light entity_ids to be controlled (may be empty). 🌟 | `[]` | list of `entity_id`s |
| `interval` | Frequency to adapt the lights, in seconds. 🔄 | `90` | `int > 0` | | `interval` | Frequency to adapt the lights, in seconds. 🔄 | `90` | `int > 0` |
| `transition` | Duration of transition when lights change, in seconds. 🕑 | `45` | `float` 0-6553 | | `transition` | Duration of transition when lights change, in seconds. 🕑 | `45` | `float` 0-6553 |
| `initial_transition` | Duration of the first transition when lights turn from `off` to `on` in seconds. ⏲️ | `1` | `float` 0-6553 | | `initial_transition` | Duration of the first transition when lights turn from `off` to `on` in seconds. ⏲️ | `1` | `float` 0-6553 |
| `min_brightness` | Minimum brightness percentage. 💡 | `1` | `int` 1-100 | | `min_brightness` | Minimum brightness percentage. 💡 | `1` | `int` 1-100 |
| `max_brightness` | Maximum brightness percentage. 💡 | `100` | `int` 1-100 | | `max_brightness` | Maximum brightness percentage. 💡 | `100` | `int` 1-100 |
| `min_color_temp` | Warmest color temperature in Kelvin. 🔥 | `2000` | `int` 1000-10000 | | `min_color_temp` | Warmest color temperature in Kelvin. 🔥 | `2000` | `int` 1000-10000 |
| `max_color_temp` | Coldest color temperature in Kelvin. ❄️ | `5500` | `int` 1000-10000 | | `max_color_temp` | Coldest color temperature in Kelvin. ❄️ | `5500` | `int` 1000-10000 |
| `prefer_rgb_color` | Whether to prefer RGB color adjustment over light color temperature when possible. 🌈 | `False` | `bool` | | `prefer_rgb_color` | Whether to prefer RGB color adjustment over light color temperature when possible. 🌈 | `False` | `bool` |
| `sleep_brightness` | Brightness percentage of lights in sleep mode. 😴 | `1` | `int` 1-100 | | `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_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_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 | | `sleep_rgb_color` | RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is "rgb_color"). 🌈 | `[255, 56, 0]` | RGB color |
| `sleep_transition` | Duration of transition when "sleep mode" is toggled in seconds. 😴 | `1` | `float` 0-6553 | | `sleep_transition` | Duration of transition when "sleep mode" is toggled in seconds. 😴 | `1` | `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` | | `transition_until_sleep` | When enabled, Adaptive Lighting will treat sleep settings as the minimum, transitioning to these values after sunset. 🌙 | `False` | `bool` |
| `sunrise_time` | Set a fixed time (HH:MM:SS) for sunrise. 🌅 | `None` | `str` | | `sunrise_time` | Set a fixed time (HH:MM:SS) for sunrise. 🌅 | `None` | `str` |
| `min_sunrise_time` | Set the earliest virtual sunrise time (HH:MM:SS), allowing for later sunrises. 🌅 | `None` | `str` | | `min_sunrise_time` | Set the earliest virtual sunrise time (HH:MM:SS), allowing for later sunrises. 🌅 | `None` | `str` |
| `max_sunrise_time` | Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier sunrises. 🌅 | `None` | `str` | | `max_sunrise_time` | Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier sunrises. 🌅 | `None` | `str` |
| `sunrise_offset` | Adjust sunrise time with a positive or negative offset in seconds. ⏰ | `0` | `int` | | `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` | | `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 sunsets. 🌇 | `None` | `str` | | `min_sunset_time` | Set the earliest virtual sunset time (HH:MM:SS), allowing for later sunsets. 🌇 | `None` | `str` |
| `max_sunset_time` | Set the latest virtual sunset time (HH:MM:SS), allowing for earlier sunsets. 🌇 | `None` | `str` | | `max_sunset_time` | Set the latest virtual sunset time (HH:MM:SS), allowing for earlier sunsets. 🌇 | `None` | `str` |
| `sunset_offset` | Adjust sunset time with a positive or negative offset in seconds. ⏰ | `0` | `int` | | `sunset_offset` | Adjust sunset time with a positive or negative offset in seconds. ⏰ | `0` | `int` |
| `brightness_mode` | Brightness mode to use. Possible values are `default`, `linear`, and `tanh` (uses `brightness_mode_time_dark` and `brightness_mode_time_light`). 📈 | `default` | one of `['default', 'linear', 'tanh']` | | `brightness_mode` | Brightness mode to use. Possible values are `default`, `linear`, and `tanh` (uses `brightness_mode_time_dark` and `brightness_mode_time_light`). 📈 | `default` | one of `['default', 'linear', 'tanh']` |
| `brightness_mode_time_dark` | (Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness before/after sunrise/sunset. 📈📉 | `900` | `int` | | `brightness_mode_time_dark` | (Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness before/after sunrise/sunset. 📈📉 | `900` | `int` |
| `brightness_mode_time_light` | (Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness after/before sunrise/sunset. 📈📉. | `3600` | `int` | | `brightness_mode_time_light` | (Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness after/before sunrise/sunset. 📈📉. | `3600` | `int` |
| `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` | | `take_over_control` | Pause adaptation of individual lights and hand over (manual) control to other sources that issue `light.turn_on` calls for lights that are on. 🔒 | `True` | `bool` |
| `detect_non_ha_changes` | Detects and halts adaptations for non-`light.turn_on` state changes. Needs `take_over_control` enabled. 🕵️ Caution: ⚠️ Some lights might falsely indicate an 'on' state, which could result in lights turning on unexpectedly. Disable this feature if you encounter such issues. | `False` | `bool` | | `take_over_control_mode` | The adaptation pausing mode when other sources change brightness and/or color of lights. `pause_all` always pauses both brightness and color adaptation. `pause_changed` pauses the adaptation of only the changed attributes and continues adapting unchanged attributes, e.g., continues color adaptation when only brightness was changed. | `pause_all` | one of `['pause_all', 'pause_changed']` |
| `autoreset_control_seconds` | Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️ | `0` | `int` 0-31536000 | | `detect_non_ha_changes` | Detects and halts adaptations for non-`light.turn_on` state changes. Needs `take_over_control` enabled. 🕵️ Caution: ⚠️ Some lights might falsely indicate an 'on' state, which could result in lights turning on unexpectedly. Note that this calls `homeassistant.update_entity` every `interval`! Disable this feature if you encounter such issues. | `False` | `bool` |
| `only_once` | Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄 | `False` | `bool` | | `autoreset_control_seconds` | Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️ | `0` | `int` 0-31536000 |
| `adapt_only_on_bare_turn_on` | When turning lights on initially. If set to `true`, AL adapts only if `light.turn_on` is invoked without specifying color or brightness. ❌🌈 This e.g., prevents adaptation when activating a scene. If `false`, AL adapts regardless of the presence of color or brightness in the initial `service_data`. Needs `take_over_control` enabled. 🕵️ | `False` | `bool` | | `only_once` | Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄 | `False` | `bool` |
| `separate_turn_on_commands` | Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀 | `False` | `bool` | | `adapt_only_on_bare_turn_on` | When turning lights on initially. If set to `true`, AL adapts only if `light.turn_on` is invoked without specifying color or brightness. ❌🌈 This e.g., prevents adaptation when activating a scene. If `false`, AL adapts regardless of the presence of color or brightness in the initial `service_data`. Needs `take_over_control` enabled. 🕵️ | `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 | | `separate_turn_on_commands` | Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀 | `False` | `bool` |
| `adapt_delay` | Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️ | `0` | `float > 0` | | `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 |
| `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` | | `adapt_delay` | Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️ | `0` | `float > 0` |
| `intercept` | Intercept and adapt `light.turn_on` calls to enabling instantaneous color and brightness adaptation. 🏎️ Disable for lights that do not support `light.turn_on` with color and brightness. | `True` | `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. Requires `intercept` to be enabled. | `True` | `bool` | | `intercept` | Intercept and adapt `light.turn_on` calls to enabling instantaneous color and brightness adaptation. 🏎️ Disable for lights that do not support `light.turn_on` with color and brightness. | `True` | `bool` |
| `include_config_in_attributes` | Show all options as attributes on the switch in Home Assistant when set to `true`. 📝 | `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. Requires `intercept` to be enabled. | `True` | `bool` |
| `include_config_in_attributes` | Show all options as attributes on the switch in Home Assistant when set to `true`. 📝 | `False` | `bool` |
<!-- OUTPUT:END --> <!-- OUTPUT:END -->
@ -210,11 +211,11 @@ adaptive_lighting:
<!-- OUTPUT:START --> <!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. --> <!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
| Service data attribute | Description | Required | Type | | Service data attribute | Description | Required | Type |
|:-------------------------|:-----------------------------------------------------------------------------------------------|:-----------|:---------------------| |:-------------------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-----------|:-----------------------------------------|
| `entity_id` | The `entity_id` of the switch in which to (un)mark the light as being `manually controlled`. 📝 | ✅ | list of `entity_id`s | | `entity_id` | The `entity_id` of the switch in which to (un)mark the light as being `manually controlled`. 📝 | ✅ | list of `entity_id`s |
| `lights` | entity_id(s) of lights, if not specified, all lights in the switch are selected. 💡 | ❌ | list of `entity_id`s | | `lights` | entity_id(s) of lights, if not specified, all lights in the switch are selected. 💡 | ❌ | list of `entity_id`s |
| `manual_control` | Whether to add ("true") or remove ("false") the light from the "manual_control" list. 🔒 | ❌ | bool | | `manual_control` | Whether to add ("true") or remove ("false") all adapted attributes of the light from the "manual_control" list, or the name of an attribute for selective addition. 🔒 | ❌ | bool or one of `['brightness', 'color']` |
<!-- OUTPUT:END --> <!-- OUTPUT:END -->
#### `adaptive_lighting.change_switch_settings` #### `adaptive_lighting.change_switch_settings`

View file

@ -46,6 +46,8 @@ def _type_to_str(type_: Any) -> str: # noqa: PLR0911
return "bool" return "bool"
if isinstance(type_, vol.All): if isinstance(type_, vol.All):
return _format_voluptuous_instance(type_) return _format_voluptuous_instance(type_)
if isinstance(type_, vol.Any):
return " or ".join(_type_to_str(t) for t in type_.validators)
if isinstance(type_, vol.In): if isinstance(type_, vol.In):
return f"one of `{type_.container}`" return f"one of `{type_.container}`"
if isinstance(type_, selector.SelectSelector): if isinstance(type_, selector.SelectSelector):

View file

@ -3,7 +3,8 @@
import logging import logging
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Literal from enum import IntFlag, auto
from typing import Any
from homeassistant.components.light import ( from homeassistant.components.light import (
ATTR_BRIGHTNESS, ATTR_BRIGHTNESS,
@ -12,6 +13,8 @@ from homeassistant.components.light import (
ATTR_BRIGHTNESS_STEP_PCT, ATTR_BRIGHTNESS_STEP_PCT,
ATTR_COLOR_NAME, ATTR_COLOR_NAME,
ATTR_COLOR_TEMP_KELVIN, ATTR_COLOR_TEMP_KELVIN,
ATTR_EFFECT,
ATTR_FLASH,
ATTR_HS_COLOR, ATTR_HS_COLOR,
ATTR_RGB_COLOR, ATTR_RGB_COLOR,
ATTR_RGBW_COLOR, ATTR_RGBW_COLOR,
@ -45,6 +48,41 @@ BRIGHTNESS_ATTRS = {
ServiceData = dict[str, Any] ServiceData = dict[str, Any]
class LightControlAttributes(IntFlag):
"""Attributes of lights that the adaptation engine can control."""
NONE = 0
BRIGHTNESS = auto()
COLOR = auto()
ALL = BRIGHTNESS | COLOR
def __str__(self) -> str:
"""Return a string representation of the attributes."""
if self == LightControlAttributes.NONE:
return "NONE"
return "|".join(
member.name
for member in type(self)
if member is not LightControlAttributes.NONE
and member in self
and member.name is not None
)
def has_any(self) -> bool:
"""Determine whether any attribute is selected."""
return self != LightControlAttributes.NONE
def has_none(self) -> bool:
"""Determine whether no attribute is selected."""
return self == LightControlAttributes.NONE
def has_all(self) -> bool:
"""Determine whether all attributes are selected."""
return (self & LightControlAttributes.ALL) == LightControlAttributes.ALL
def _split_service_call_data(service_data: ServiceData) -> list[ServiceData]: def _split_service_call_data(service_data: ServiceData) -> list[ServiceData]:
"""Splits the service data by the adapted attributes. """Splits the service data by the adapted attributes.
@ -145,7 +183,7 @@ class AdaptationData:
service_call_datas: AsyncGenerator[ServiceData] service_call_datas: AsyncGenerator[ServiceData]
force: bool force: bool
max_length: int max_length: int
which: Literal["brightness", "color", "both"] attributes: LightControlAttributes
initial_sleep: bool = False initial_sleep: bool = False
async def next_service_call_data(self) -> ServiceData | None: async def next_service_call_data(self) -> ServiceData | None:
@ -161,7 +199,7 @@ class AdaptationData:
f"sleep_time={self.sleep_time}, " f"sleep_time={self.sleep_time}, "
f"force={self.force}, " f"force={self.force}, "
f"max_length={self.max_length}, " f"max_length={self.max_length}, "
f"which={self.which}, " f"attributes={self.attributes}, "
f"initial_sleep={self.initial_sleep}" f"initial_sleep={self.initial_sleep}"
")" ")"
) )
@ -171,20 +209,25 @@ class NoColorOrBrightnessInServiceDataError(Exception):
"""Exception raised when no color or brightness attributes are found in service data.""" """Exception raised when no color or brightness attributes are found in service data."""
def _identify_lighting_type( def _identify_light_control_attributes(
service_data: ServiceData, service_data: ServiceData,
) -> Literal["brightness", "color", "both"]: ) -> LightControlAttributes:
"""Extract the 'which' attribute from the service data.""" """Extract the 'which' attribute from the service data."""
has_brightness = ATTR_BRIGHTNESS in service_data has_brightness = ATTR_BRIGHTNESS in service_data
has_color = any(attr in service_data for attr in COLOR_ATTRS) has_color = any(attr in service_data for attr in COLOR_ATTRS)
if has_brightness and has_color:
return "both" parameters = LightControlAttributes.NONE
if has_brightness: if has_brightness:
return "brightness" parameters |= LightControlAttributes.BRIGHTNESS
if has_color: if has_color:
return "color" parameters |= LightControlAttributes.COLOR
msg = f"Invalid service_data, no brightness or color attributes found: {service_data=}"
raise NoColorOrBrightnessInServiceDataError(msg) if parameters == LightControlAttributes.NONE:
msg = f"Invalid service_data, no brightness or color attributes found: {service_data=}"
raise NoColorOrBrightnessInServiceDataError(msg)
return parameters
def prepare_adaptation_data( def prepare_adaptation_data(
@ -220,7 +263,7 @@ def prepare_adaptation_data(
filter_by_state, filter_by_state,
) )
lighting_type = _identify_lighting_type(service_data) attributes = _identify_light_control_attributes(service_data)
return AdaptationData( return AdaptationData(
entity_id=entity_id, entity_id=entity_id,
@ -229,5 +272,58 @@ def prepare_adaptation_data(
service_call_datas=service_data_iterator, service_call_datas=service_data_iterator,
force=force, force=force,
max_length=service_datas_length, max_length=service_datas_length,
which=lighting_type, attributes=attributes,
) )
def manual_control_event_attribute_to_flags(
manual_control_attribute: bool | str,
) -> LightControlAttributes:
"""Convert manual control event data to light control attributes."""
if isinstance(manual_control_attribute, bool) and manual_control_attribute:
return LightControlAttributes.ALL
if manual_control_attribute == "brightness":
return LightControlAttributes.BRIGHTNESS
if manual_control_attribute == "color":
return LightControlAttributes.COLOR
return LightControlAttributes.NONE
def has_brightness_attribute(
service_data: ServiceData,
) -> bool:
"""Determine whether the service data contains brightness attributes."""
return any(attr in BRIGHTNESS_ATTRS for attr in service_data)
def has_color_attribute(
service_data: ServiceData,
) -> bool:
"""Determine whether the service data contains color attributes."""
return any(attr in COLOR_ATTRS for attr in service_data)
def has_effect_attribute(
service_data: ServiceData,
) -> bool:
"""Determine whether the service data contains effect attributes."""
return ATTR_FLASH in service_data or ATTR_EFFECT in service_data
def get_light_control_attributes(
service_data: ServiceData,
) -> LightControlAttributes:
"""Get the light control attributes affected by the service call data."""
parameters = LightControlAttributes.NONE
if has_brightness_attribute(service_data):
parameters |= LightControlAttributes.BRIGHTNESS
if has_color_attribute(service_data):
parameters |= LightControlAttributes.COLOR
if has_effect_attribute(service_data):
parameters |= LightControlAttributes.BRIGHTNESS
parameters |= LightControlAttributes.COLOR
return parameters

View file

@ -1,6 +1,7 @@
"""Constants for the Adaptive Lighting integration.""" """Constants for the Adaptive Lighting integration."""
from datetime import timedelta from datetime import timedelta
from enum import Enum
from typing import Any from typing import Any
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
@ -16,6 +17,14 @@ ICON_SLEEP = "mdi:sleep"
DOMAIN = "adaptive_lighting" DOMAIN = "adaptive_lighting"
class TakeOverControlMode(Enum):
"""Modes for pausing adaptation when control of a light is taken over externally."""
PAUSE_ALL = "pause_all"
PAUSE_CHANGED = "pause_changed"
DOCS = {CONF_ENTITY_ID: "Entity ID of the switch. 📝"} DOCS = {CONF_ENTITY_ID: "Entity ID of the switch. 📝"}
@ -34,6 +43,7 @@ DOCS[CONF_DETECT_NON_HA_CHANGES] = (
"Needs `take_over_control` enabled. 🕵️ " "Needs `take_over_control` enabled. 🕵️ "
"Caution: ⚠️ Some lights might falsely indicate an 'on' state, which could result " "Caution: ⚠️ Some lights might falsely indicate an 'on' state, which could result "
"in lights turning on unexpectedly. " "in lights turning on unexpectedly. "
"Note that this calls `homeassistant.update_entity` every `interval`! "
"Disable this feature if you encounter such issues." "Disable this feature if you encounter such issues."
) )
@ -188,9 +198,19 @@ DOCS[CONF_BRIGHTNESS_MODE_TIME_LIGHT] = (
CONF_TAKE_OVER_CONTROL, DEFAULT_TAKE_OVER_CONTROL = "take_over_control", True CONF_TAKE_OVER_CONTROL, DEFAULT_TAKE_OVER_CONTROL = "take_over_control", True
DOCS[CONF_TAKE_OVER_CONTROL] = ( DOCS[CONF_TAKE_OVER_CONTROL] = (
"Disable Adaptive Lighting if another source calls `light.turn_on` while lights " "Pause adaptation of individual lights and hand over (manual) control to other sources that "
"are on and being adapted. Note that this calls `homeassistant.update_entity` " "issue `light.turn_on` calls for lights that are on. 🔒"
"every `interval`! 🔒" )
CONF_TAKE_OVER_CONTROL_MODE, DEFAULT_TAKE_OVER_CONTROL_MODE = (
"take_over_control_mode",
TakeOverControlMode.PAUSE_ALL.value,
)
DOCS[CONF_TAKE_OVER_CONTROL_MODE] = (
"The adaptation pausing mode when other sources change brightness and/or color of lights. "
"`pause_all` always pauses both brightness and color adaptation. "
"`pause_changed` pauses the adaptation of only the changed attributes and continues adapting "
"unchanged attributes, e.g., continues color adaptation when only brightness was changed."
) )
CONF_TRANSITION, DEFAULT_TRANSITION = "transition", 45 CONF_TRANSITION, DEFAULT_TRANSITION = "transition", 45
@ -284,8 +304,9 @@ DOCS_MANUAL_CONTROL = {
"light as being `manually controlled`. 📝", "light as being `manually controlled`. 📝",
CONF_LIGHTS: "entity_id(s) of lights, if not specified, all lights in the " CONF_LIGHTS: "entity_id(s) of lights, if not specified, all lights in the "
"switch are selected. 💡", "switch are selected. 💡",
CONF_MANUAL_CONTROL: 'Whether to add ("true") or remove ("false") the ' CONF_MANUAL_CONTROL: 'Whether to add ("true") or remove ("false") all '
'light from the "manual_control" list. 🔒', 'adapted attributes of the light from the "manual_control" list, or the '
"name of an attribute for selective addition. 🔒",
} }
DOCS_APPLY = { DOCS_APPLY = {
@ -351,6 +372,20 @@ VALIDATION_TUPLES: list[tuple[str, Any, Any]] = [
(CONF_BRIGHTNESS_MODE_TIME_DARK, DEFAULT_BRIGHTNESS_MODE_TIME_DARK, int), (CONF_BRIGHTNESS_MODE_TIME_DARK, DEFAULT_BRIGHTNESS_MODE_TIME_DARK, int),
(CONF_BRIGHTNESS_MODE_TIME_LIGHT, DEFAULT_BRIGHTNESS_MODE_TIME_LIGHT, int), (CONF_BRIGHTNESS_MODE_TIME_LIGHT, DEFAULT_BRIGHTNESS_MODE_TIME_LIGHT, int),
(CONF_TAKE_OVER_CONTROL, DEFAULT_TAKE_OVER_CONTROL, bool), (CONF_TAKE_OVER_CONTROL, DEFAULT_TAKE_OVER_CONTROL, bool),
(
CONF_TAKE_OVER_CONTROL_MODE,
DEFAULT_TAKE_OVER_CONTROL_MODE,
selector.SelectSelector( # type: ignore[arg-type]
selector.SelectSelectorConfig(
options=[
TakeOverControlMode.PAUSE_ALL.value,
TakeOverControlMode.PAUSE_CHANGED.value,
],
multiple=False,
mode=selector.SelectSelectorMode.DROPDOWN,
),
),
),
(CONF_DETECT_NON_HA_CHANGES, DEFAULT_DETECT_NON_HA_CHANGES, bool), (CONF_DETECT_NON_HA_CHANGES, DEFAULT_DETECT_NON_HA_CHANGES, bool),
( (
CONF_AUTORESET_CONTROL, CONF_AUTORESET_CONTROL,
@ -446,6 +481,9 @@ SET_MANUAL_CONTROL_SCHEMA = vol.Schema(
{ {
vol.Optional(CONF_ENTITY_ID): cv.entity_ids, # type: ignore[arg-type] vol.Optional(CONF_ENTITY_ID): cv.entity_ids, # type: ignore[arg-type]
vol.Optional(CONF_LIGHTS, default=[]): cv.entity_ids, # type: ignore[arg-type] vol.Optional(CONF_LIGHTS, default=[]): cv.entity_ids, # type: ignore[arg-type]
vol.Optional(CONF_MANUAL_CONTROL, default=True): cv.boolean, vol.Optional(CONF_MANUAL_CONTROL, default=True): vol.Any(
cv.boolean,
vol.In(["brightness", "color"]),
),
}, },
) )

View file

@ -57,7 +57,7 @@ set_manual_control:
domain: light domain: light
multiple: true multiple: true
manual_control: manual_control:
description: Whether to add ("true") or remove ("false") the light from the "manual_control" list. 🔒 description: Whether to add ("true") or remove ("false") all adapted attributes of the light from the "manual_control" list, or the name of an attribute for selective addition. 🔒
example: true example: true
default: true default: true
selector: selector:
@ -220,13 +220,22 @@ change_switch_settings:
selector: selector:
time: null time: null
take_over_control: take_over_control:
description: 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`! 🔒 description: Pause adaptation of individual lights and hand over (manual) control to other sources that issue `light.turn_on` calls for lights that are on. 🔒
required: false required: false
example: true example: true
selector: selector:
boolean: null boolean: null
take_over_control_mode:
description: The adaptation pausing mode when other sources change brightness and/or color of lights. `pause_all` always pauses both brightness and color adaptation. `pause_changed` pauses the adaptation of only the changed attributes and continues adapting unchanged attributes, e.g., continues color adaptation when only brightness was changed.
required: false
example: pause_changed
selector:
select:
options:
- pause_all
- pause_changed
detect_non_ha_changes: detect_non_ha_changes:
description: 'Detects and halts adaptations for non-`light.turn_on` state changes. Needs `take_over_control` enabled. 🕵️ Caution: ⚠️ Some lights might falsely indicate an ''on'' state, which could result in lights turning on unexpectedly. Disable this feature if you encounter such issues.' description: 'Detects and halts adaptations for non-`light.turn_on` state changes. Needs `take_over_control` enabled. 🕵️ Caution: ⚠️ Some lights might falsely indicate an ''on'' state, which could result in lights turning on unexpectedly. Note that this calls `homeassistant.update_entity` every `interval`! Disable this feature if you encounter such issues.'
required: false required: false
example: false example: false
selector: selector:

View file

@ -52,8 +52,9 @@
"brightness_mode": "brightness_mode", "brightness_mode": "brightness_mode",
"brightness_mode_time_dark": "brightness_mode_time_dark", "brightness_mode_time_dark": "brightness_mode_time_dark",
"brightness_mode_time_light": "brightness_mode_time_light", "brightness_mode_time_light": "brightness_mode_time_light",
"take_over_control": "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`! 🔒", "take_over_control": "take_over_control: Pause adaptation of individual lights and hand over (manual) control to other sources that issue `light.turn_on` calls for lights that are on. 🔒",
"detect_non_ha_changes": "detect_non_ha_changes: Detects and halts adaptations for non-`light.turn_on` state changes. Needs `take_over_control` enabled. 🕵️ Caution: ⚠️ Some lights might falsely indicate an 'on' state, which could result in lights turning on unexpectedly. Disable this feature if you encounter such issues.", "take_over_control_mode": "take_over_control_mode",
"detect_non_ha_changes": "detect_non_ha_changes: Detects and halts adaptations for non-`light.turn_on` state changes. Needs `take_over_control` enabled. 🕵️ Caution: ⚠️ Some lights might falsely indicate an 'on' state, which could result in lights turning on unexpectedly. Note that this calls `homeassistant.update_entity` every `interval`! Disable this feature if you encounter such issues.",
"autoreset_control_seconds": "autoreset_control_seconds", "autoreset_control_seconds": "autoreset_control_seconds",
"only_once": "only_once: Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄", "only_once": "only_once: Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄",
"adapt_only_on_bare_turn_on": "adapt_only_on_bare_turn_on: When turning lights on initially. If set to `true`, AL adapts only if `light.turn_on` is invoked without specifying color or brightness. ❌🌈 This e.g., prevents adaptation when activating a scene. If `false`, AL adapts regardless of the presence of color or brightness in the initial `service_data`. Needs `take_over_control` enabled. 🕵️", "adapt_only_on_bare_turn_on": "adapt_only_on_bare_turn_on: When turning lights on initially. If set to `true`, AL adapts only if `light.turn_on` is invoked without specifying color or brightness. ❌🌈 This e.g., prevents adaptation when activating a scene. If `false`, AL adapts regardless of the presence of color or brightness in the initial `service_data`. Needs `take_over_control` enabled. 🕵️",
@ -85,6 +86,7 @@
"brightness_mode": "Brightness mode to use. Possible values are `default`, `linear`, and `tanh` (uses `brightness_mode_time_dark` and `brightness_mode_time_light`). 📈", "brightness_mode": "Brightness mode to use. Possible values are `default`, `linear`, and `tanh` (uses `brightness_mode_time_dark` and `brightness_mode_time_light`). 📈",
"brightness_mode_time_dark": "(Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness before/after sunrise/sunset. 📈📉", "brightness_mode_time_dark": "(Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness before/after sunrise/sunset. 📈📉",
"brightness_mode_time_light": "(Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness after/before sunrise/sunset. 📈📉.", "brightness_mode_time_light": "(Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness after/before sunrise/sunset. 📈📉.",
"take_over_control_mode": "The adaptation pausing mode when other sources change brightness and/or color of lights. `pause_all` always pauses both brightness and color adaptation. `pause_changed` pauses the adaptation of only the changed attributes and continues adapting unchanged attributes, e.g., continues color adaptation when only brightness was changed.",
"autoreset_control_seconds": "Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️", "autoreset_control_seconds": "Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️",
"send_split_delay": "Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️", "send_split_delay": "Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️",
"adapt_delay": "Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️" "adapt_delay": "Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️"
@ -144,7 +146,7 @@
"name": "lights" "name": "lights"
}, },
"manual_control": { "manual_control": {
"description": "Whether to add (\"true\") or remove (\"false\") the light from the \"manual_control\" list. 🔒", "description": "Whether to add (\"true\") or remove (\"false\") all adapted attributes of the light from the \"manual_control\" list, or the name of an attribute for selective addition. 🔒",
"name": "manual_control" "name": "manual_control"
} }
} }
@ -250,11 +252,15 @@
"name": "min_sunset_time" "name": "min_sunset_time"
}, },
"take_over_control": { "take_over_control": {
"description": "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`! 🔒", "description": "Pause adaptation of individual lights and hand over (manual) control to other sources that issue `light.turn_on` calls for lights that are on. 🔒",
"name": "take_over_control" "name": "take_over_control"
}, },
"take_over_control_mode": {
"description": "The adaptation pausing mode when other sources change brightness and/or color of lights. `pause_all` always pauses both brightness and color adaptation. `pause_changed` pauses the adaptation of only the changed attributes and continues adapting unchanged attributes, e.g., continues color adaptation when only brightness was changed.",
"name": "take_over_control_mode"
},
"detect_non_ha_changes": { "detect_non_ha_changes": {
"description": "Detects and halts adaptations for non-`light.turn_on` state changes. Needs `take_over_control` enabled. 🕵️ Caution: ⚠️ Some lights might falsely indicate an 'on' state, which could result in lights turning on unexpectedly. Disable this feature if you encounter such issues.", "description": "Detects and halts adaptations for non-`light.turn_on` state changes. Needs `take_over_control` enabled. 🕵️ Caution: ⚠️ Some lights might falsely indicate an 'on' state, which could result in lights turning on unexpectedly. Note that this calls `homeassistant.update_entity` every `interval`! Disable this feature if you encounter such issues.",
"name": "detect_non_ha_changes" "name": "detect_non_ha_changes"
}, },
"transition": { "transition": {

View file

@ -8,7 +8,7 @@ import logging
import zoneinfo import zoneinfo
from copy import deepcopy from copy import deepcopy
from datetime import timedelta from datetime import timedelta
from typing import TYPE_CHECKING, Any, Literal from typing import TYPE_CHECKING, Any
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
import homeassistant.util.dt as dt_util import homeassistant.util.dt as dt_util
@ -17,8 +17,6 @@ import voluptuous as vol
from homeassistant.components.light import ( from homeassistant.components.light import (
ATTR_BRIGHTNESS, ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN, ATTR_COLOR_TEMP_KELVIN,
ATTR_EFFECT,
ATTR_FLASH,
ATTR_RGB_COLOR, ATTR_RGB_COLOR,
ATTR_SUPPORTED_COLOR_MODES, ATTR_SUPPORTED_COLOR_MODES,
ATTR_TRANSITION, ATTR_TRANSITION,
@ -76,10 +74,12 @@ from homeassistant.util.color import (
) )
from .adaptation_utils import ( from .adaptation_utils import (
BRIGHTNESS_ATTRS,
COLOR_ATTRS,
AdaptationData, AdaptationData,
LightControlAttributes,
ServiceData, ServiceData,
get_light_control_attributes,
has_effect_attribute,
manual_control_event_attribute_to_flags,
prepare_adaptation_data, prepare_adaptation_data,
) )
from .color_and_brightness import SunLightSettings from .color_and_brightness import SunLightSettings
@ -127,6 +127,7 @@ from .const import (
CONF_SUNSET_OFFSET, CONF_SUNSET_OFFSET,
CONF_SUNSET_TIME, CONF_SUNSET_TIME,
CONF_TAKE_OVER_CONTROL, CONF_TAKE_OVER_CONTROL,
CONF_TAKE_OVER_CONTROL_MODE,
CONF_TRANSITION, CONF_TRANSITION,
CONF_TURN_ON_LIGHTS, CONF_TURN_ON_LIGHTS,
CONF_USE_DEFAULTS, CONF_USE_DEFAULTS,
@ -143,6 +144,7 @@ from .const import (
SLEEP_MODE_SWITCH, SLEEP_MODE_SWITCH,
TURNING_OFF_DELAY, TURNING_OFF_DELAY,
VALIDATION_TUPLES, VALIDATION_TUPLES,
TakeOverControlMode,
apply_service_schema, apply_service_schema,
replace_none_str, replace_none_str,
) )
@ -359,27 +361,6 @@ async def handle_change_switch_settings(
) )
@callback
def _fire_manual_control_event(
switch: AdaptiveSwitch,
light: str,
context: Context,
) -> None:
"""Fire an event that 'light' is marked as manual_control."""
hass = switch.hass
_LOGGER.debug(
"'adaptive_lighting.manual_control' event fired for %s for light %s",
switch.entity_id,
light,
)
switch.manager.mark_as_manual_control(light)
hass.bus.async_fire(
f"{DOMAIN}.manual_control",
{ATTR_ENTITY_ID: light, SWITCH_DOMAIN: switch.entity_id},
context=context,
)
async def async_setup_entry( # noqa: PLR0915 async def async_setup_entry( # noqa: PLR0915
hass: HomeAssistant, hass: HomeAssistant,
config_entry: ConfigEntry, config_entry: ConfigEntry,
@ -497,9 +478,21 @@ async def async_setup_entry( # noqa: PLR0915
all_lights = switch.lights all_lights = switch.lights
else: else:
all_lights = _expand_light_groups(hass, lights) all_lights = _expand_light_groups(hass, lights)
if service_call.data[CONF_MANUAL_CONTROL]:
manual_attributes = manual_control_event_attribute_to_flags(
service_call.data[CONF_MANUAL_CONTROL],
)
if manual_attributes:
for light in all_lights: for light in all_lights:
_fire_manual_control_event(switch, light, service_call.context) switch.manager.set_manual_control_attributes(
light,
manual_attributes,
)
switch.fire_manual_control_event(
light,
service_call.context,
)
else: else:
switch.manager.reset(*all_lights) switch.manager.reset(*all_lights)
if switch.is_on: if switch.is_on:
@ -753,36 +746,32 @@ def _attributes_have_changed(
light: str, light: str,
old_attributes: dict[str, Any], old_attributes: dict[str, Any],
new_attributes: dict[str, Any], new_attributes: dict[str, Any],
adapt_brightness: bool,
adapt_color: bool,
context: Context, context: Context,
) -> bool: ) -> LightControlAttributes:
# 2023-11-19: HA core no longer removes light domain attributes when off # 2023-11-19: HA core no longer removes light domain attributes when off
# so we must protect for `None` here # so we must protect for `None` here
# see https://github.com/home-assistant/core/pull/101946 # see https://github.com/home-assistant/core/pull/101946
changed_attributes = LightControlAttributes.NONE
# Check for color mode changes BEFORE attribute conversion # Check for color mode changes BEFORE attribute conversion
# This detects external changes like Hue scenes switching from color_temp to RGB # This detects external changes like Hue scenes switching from color_temp to RGB
# See: https://github.com/basnijholt/adaptive-lighting/issues/1275 # See: https://github.com/basnijholt/adaptive-lighting/issues/1275
if adapt_color and _has_color_mode_changed( if _has_color_mode_changed(
light, light,
old_attributes, old_attributes,
new_attributes, new_attributes,
context, context,
): ):
return True changed_attributes |= LightControlAttributes.COLOR
if adapt_color: if LightControlAttributes.COLOR not in changed_attributes:
old_attributes, new_attributes = _add_missing_attributes( old_attributes, new_attributes = _add_missing_attributes(
old_attributes, old_attributes,
new_attributes, new_attributes,
) )
if ( if old_attributes.get(ATTR_BRIGHTNESS) and new_attributes.get(ATTR_BRIGHTNESS):
adapt_brightness
and old_attributes.get(ATTR_BRIGHTNESS)
and new_attributes.get(ATTR_BRIGHTNESS)
):
last_brightness = old_attributes[ATTR_BRIGHTNESS] last_brightness = old_attributes[ATTR_BRIGHTNESS]
current_brightness = new_attributes[ATTR_BRIGHTNESS] current_brightness = new_attributes[ATTR_BRIGHTNESS]
if abs(current_brightness - last_brightness) > BRIGHTNESS_CHANGE: if abs(current_brightness - last_brightness) > BRIGHTNESS_CHANGE:
@ -794,10 +783,10 @@ def _attributes_have_changed(
current_brightness, current_brightness,
context.id, context.id,
) )
return True changed_attributes |= LightControlAttributes.BRIGHTNESS
if ( if (
adapt_color LightControlAttributes.COLOR not in changed_attributes
and old_attributes.get(ATTR_COLOR_TEMP_KELVIN) and old_attributes.get(ATTR_COLOR_TEMP_KELVIN)
and new_attributes.get(ATTR_COLOR_TEMP_KELVIN) and new_attributes.get(ATTR_COLOR_TEMP_KELVIN)
): ):
@ -812,10 +801,10 @@ def _attributes_have_changed(
current_color_temp, current_color_temp,
context.id, context.id,
) )
return True changed_attributes |= LightControlAttributes.COLOR
if ( if (
adapt_color LightControlAttributes.COLOR not in changed_attributes
and old_attributes.get(ATTR_RGB_COLOR) and old_attributes.get(ATTR_RGB_COLOR)
and new_attributes.get(ATTR_RGB_COLOR) and new_attributes.get(ATTR_RGB_COLOR)
): ):
@ -831,9 +820,9 @@ def _attributes_have_changed(
current_rgb_color, current_rgb_color,
context.id, context.id,
) )
return True changed_attributes |= LightControlAttributes.COLOR
return False return changed_attributes
class AdaptiveSwitch(SwitchEntity, RestoreEntity): class AdaptiveSwitch(SwitchEntity, RestoreEntity):
@ -937,6 +926,9 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
self._name, self._name,
) )
self._take_over_control = True self._take_over_control = True
self._take_over_control_mode = TakeOverControlMode(
data[CONF_TAKE_OVER_CONTROL_MODE],
)
self._detect_non_ha_changes = data[CONF_DETECT_NON_HA_CHANGES] self._detect_non_ha_changes = data[CONF_DETECT_NON_HA_CHANGES]
self._adapt_only_on_bare_turn_on = data[CONF_ADAPT_ONLY_ON_BARE_TURN_ON] self._adapt_only_on_bare_turn_on = data[CONF_ADAPT_ONLY_ON_BARE_TURN_ON]
self._auto_reset_manual_control_time = data[CONF_AUTORESET_CONTROL] self._auto_reset_manual_control_time = data[CONF_AUTORESET_CONTROL]
@ -1203,12 +1195,19 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
context: Context | None = None, context: Context | None = None,
) -> AdaptationData | None: ) -> AdaptationData | None:
"""Prepare `AdaptationData` for adapting a light.""" """Prepare `AdaptationData` for adapting a light."""
adaptation_attributes = self.manager.get_adaption_control_attributes(
self,
light,
)
if transition is None: if transition is None:
transition = self._transition transition = self._transition
if adapt_brightness is None: if adapt_brightness is None:
adapt_brightness = self.adapt_brightness_switch.is_on adapt_brightness = (
LightControlAttributes.BRIGHTNESS in adaptation_attributes
)
if adapt_color is None: if adapt_color is None:
adapt_color = self.adapt_color_switch.is_on adapt_color = LightControlAttributes.COLOR in adaptation_attributes
if prefer_rgb_color is None: if prefer_rgb_color is None:
prefer_rgb_color = self._prefer_rgb_color prefer_rgb_color = self._prefer_rgb_color
@ -1375,7 +1374,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
to cancel an ongoing adaptation when a light is turned off. to cancel an ongoing adaptation when a light is turned off.
""" """
# Prevent overlap of multiple adaptation sequences # Prevent overlap of multiple adaptation sequences
self.manager.cancel_ongoing_adaptation_calls(data.entity_id, which=data.which) self.manager.cancel_ongoing_adaptation_calls(data.entity_id)
_LOGGER.debug( _LOGGER.debug(
"%s: execute_cancellable_adaptation_calls with data: %s", "%s: execute_cancellable_adaptation_calls with data: %s",
self._name, self._name,
@ -1384,9 +1383,9 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
# Execute adaptation calls within a task # Execute adaptation calls within a task
try: try:
task = asyncio.ensure_future(self._execute_adaptation_calls(data)) task = asyncio.ensure_future(self._execute_adaptation_calls(data))
if data.which in ("both", "brightness"): if LightControlAttributes.BRIGHTNESS in data.attributes:
self.manager.adaptation_tasks_brightness[data.entity_id] = task self.manager.adaptation_tasks_brightness[data.entity_id] = task
if data.which in ("both", "color"): if LightControlAttributes.COLOR in data.attributes:
self.manager.adaptation_tasks_color[data.entity_id] = task self.manager.adaptation_tasks_color[data.entity_id] = task
await task await task
except asyncio.CancelledError: except asyncio.CancelledError:
@ -1397,7 +1396,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
data, data,
) )
async def _update_attrs_and_maybe_adapt_lights( # noqa: PLR0912 async def _update_attrs_and_maybe_adapt_lights(
self, self,
*, *,
context: Context, context: Context,
@ -1468,49 +1467,26 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
if not filtered_lights: if not filtered_lights:
return return
adapt_brightness = self.adapt_brightness_switch.is_on
adapt_color = self.adapt_color_switch.is_on
assert isinstance(adapt_brightness, bool)
assert isinstance(adapt_color, bool)
tasks: list[asyncio.Task[None]] = [] tasks: list[asyncio.Task[None]] = []
for light in filtered_lights: for light in filtered_lights:
manually_controlled = ( await self.manager.update_manually_controlled_from_untracked_change(
self._take_over_control self,
and self.manager.is_manually_controlled( light,
self, force,
light, context,
force,
adapt_brightness,
adapt_color,
)
) )
if manually_controlled:
# Performance optimization: Skip adaptation task if all attributes are
# manually controlled and the task wouldn't actually do anything.
if self.manager.get_adaption_control_attributes(self, light).has_none():
_LOGGER.debug( _LOGGER.debug(
"%s: '%s' is being manually controlled, stop adapting, context.id=%s.", "%s: '%s' is being manually controlled, skip adaptation, context.id=%s.",
self._name, self._name,
light, light,
context.id, context.id,
) )
continue continue
significant_change = (
self._take_over_control
and self._detect_non_ha_changes
and not force
# Note: This call updates the state of the light
# so it might suddenly be off.
and await self.manager.significant_change(
self,
light,
adapt_brightness,
adapt_color,
context,
)
)
if significant_change:
_fire_manual_control_event(self, light, context)
continue
_LOGGER.debug( _LOGGER.debug(
"%s: Calling _adapt_light from _update_attrs_and_maybe_adapt_lights:" "%s: Calling _adapt_light from _update_attrs_and_maybe_adapt_lights:"
" '%s' with transition %s and context.id=%s", " '%s' with transition %s and context.id=%s",
@ -1553,7 +1529,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
entity_id, entity_id,
event.context.id, event.context.id,
) )
self.manager.mark_as_manual_control(entity_id) self.manager.set_manual_control_attributes(entity_id)
return return
if ( if (
@ -1607,6 +1583,28 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
force=True, force=True,
) )
def fire_manual_control_event(
self,
light: str,
context: Context,
) -> None:
"""Fire an event that 'light' is marked as manual_control."""
_LOGGER.debug(
"'adaptive_lighting.manual_control' event fired for %s for light %s",
self.entity_id,
light,
)
manual_attributes = self.manager.get_manual_control_attributes(light)
self.hass.bus.async_fire(
f"{DOMAIN}.manual_control",
{
ATTR_ENTITY_ID: light,
SWITCH_DOMAIN: self.entity_id,
CONF_MANUAL_CONTROL: manual_attributes,
},
context=context,
)
class SimpleSwitch(SwitchEntity, RestoreEntity): class SimpleSwitch(SwitchEntity, RestoreEntity):
"""Representation of a Adaptive Lighting switch.""" """Representation of a Adaptive Lighting switch."""
@ -1711,7 +1709,7 @@ class AdaptiveLightingManager:
# Locks that prevent light adjusting when waiting for a light to 'turn_off' # Locks that prevent light adjusting when waiting for a light to 'turn_off'
self.turn_off_locks: dict[str, asyncio.Lock] = {} self.turn_off_locks: dict[str, asyncio.Lock] = {}
# Tracks which lights are manually controlled # Tracks which lights are manually controlled
self.manual_control: dict[str, bool] = {} self.manual_control: dict[str, LightControlAttributes] = {}
# Track 'state_changed' events of self.lights resulting from this integration # Track 'state_changed' events of self.lights resulting from this integration
self.our_last_state_on_change: dict[str, list[State]] = {} self.our_last_state_on_change: dict[str, list[State]] = {}
# Track last 'service_data' to 'light.turn_on' resulting from this integration # Track last 'service_data' to 'light.turn_on' resulting from this integration
@ -1954,10 +1952,7 @@ class AdaptiveLightingManager:
# were skipped by us # were skipped by us
return return
if ( if has_effect_attribute(service_data[CONF_PARAMS]):
ATTR_EFFECT in service_data[CONF_PARAMS]
or ATTR_FLASH in service_data[CONF_PARAMS]
):
return return
_LOGGER.debug( _LOGGER.debug(
@ -2204,10 +2199,26 @@ class AdaptiveLightingManager:
) )
self.auto_reset_manual_control_times[light] = time self.auto_reset_manual_control_times[light] = time
def mark_as_manual_control(self, light: str) -> None: def get_manual_control_attributes(
"""Mark a light as manually controlled.""" self,
_LOGGER.debug("Marking '%s' as manually controlled.", light) light: str,
self.manual_control[light] = True ) -> LightControlAttributes:
"""Get the attributes for a light that are manually controlled."""
return self.manual_control.get(light, LightControlAttributes.NONE)
def set_manual_control_attributes(
self,
light: str,
attributes: LightControlAttributes = LightControlAttributes.ALL,
) -> None:
"""Mark attributes of a light as manually controlled."""
_LOGGER.debug(
"Light %s: Setting manual control attributes to %s (from %s).",
light,
attributes,
self.manual_control[light],
)
self.manual_control[light] = attributes
delay = self.auto_reset_manual_control_times.get(light) delay = self.auto_reset_manual_control_times.get(light)
async def reset() -> None: async def reset() -> None:
@ -2228,35 +2239,87 @@ class AdaptiveLightingManager:
transition=switch.initial_transition, transition=switch.initial_transition,
force=True, force=True,
) )
assert not self.manual_control[light] assert self.manual_control[light] == LightControlAttributes.NONE
self._handle_timer(light, self.auto_reset_manual_control_timers, delay, reset) self._handle_timer(light, self.auto_reset_manual_control_timers, delay, reset)
def add_manual_control_attributes(
self,
light: str,
attributes: LightControlAttributes,
) -> None:
"""Add attributes to the manual control status of a light."""
current = self.get_manual_control_attributes(light)
_LOGGER.debug(
"Light %s: Adding manual control attributes %s (current: %s).",
light,
attributes,
current,
)
new = current | attributes
self.set_manual_control_attributes(light, new)
def get_adaption_control_attributes(
self,
switch: AdaptiveSwitch,
light: str,
) -> LightControlAttributes:
"""Get the attributes that should be adapted for a light.
Determines the attributes that should actually be adapted from the attributes
marked as manually controlled, the state of adaptation switches, and the adaptation
configuration.
Example 1: When no attributes are marked as manually controlled and all adaptation
switches are on, all attributes are returned.
Example 2: When no attributes are marked as manually controlled and the brightness
adaptation switch is off, only the color attribute is returned.
Example 3: When only brightness is marked as manually controlled, but the configuration
specifies to pause all adaptations on manual change, no attributes are returned so that
color is also not adapted.
"""
denied_adaptation_attributes = self.get_manual_control_attributes(light)
if (
denied_adaptation_attributes.has_any()
and switch._take_over_control_mode == TakeOverControlMode.PAUSE_ALL
):
# Extend to pausing all only if there is at least one manually controlled attribute
denied_adaptation_attributes = LightControlAttributes.ALL
enabled_adaptation_attributes = (
LightControlAttributes.BRIGHTNESS
if switch.adapt_brightness_switch.is_on
else LightControlAttributes.NONE
) | (
LightControlAttributes.COLOR
if switch.adapt_color_switch.is_on
else LightControlAttributes.NONE
)
return (
LightControlAttributes.ALL
& ~denied_adaptation_attributes
& enabled_adaptation_attributes
)
def cancel_ongoing_adaptation_calls( def cancel_ongoing_adaptation_calls(
self, self,
light_id: str, light_id: str,
which: Literal["color", "brightness", "both"] = "both",
) -> None: ) -> None:
"""Cancel ongoing adaptation service calls for a specific light entity.""" """Cancel ongoing adaptation service calls for a specific light entity."""
brightness_task = self.adaptation_tasks_brightness.get(light_id) brightness_task = self.adaptation_tasks_brightness.get(light_id)
color_task = self.adaptation_tasks_color.get(light_id) color_task = self.adaptation_tasks_color.get(light_id)
if ( if brightness_task is not None and not brightness_task.done():
which in ("both", "brightness")
and brightness_task is not None
and not brightness_task.done()
):
_LOGGER.debug( _LOGGER.debug(
"Cancelled ongoing brightness adaptation calls (%s) for '%s'", "Cancelled ongoing brightness adaptation calls (%s) for '%s'",
brightness_task, brightness_task,
light_id, light_id,
) )
brightness_task.cancel() brightness_task.cancel()
if ( if color_task is not None and not color_task.done():
which in ("both", "color")
and color_task is not None
and color_task is not brightness_task
and not color_task.done()
):
_LOGGER.debug( _LOGGER.debug(
"Cancelled ongoing color adaptation calls (%s) for '%s'", "Cancelled ongoing color adaptation calls (%s) for '%s'",
color_task, color_task,
@ -2269,7 +2332,11 @@ class AdaptiveLightingManager:
"""Reset the 'manual_control' status of the lights.""" """Reset the 'manual_control' status of the lights."""
for light in lights: for light in lights:
if reset_manual_control: if reset_manual_control:
self.manual_control[light] = False _LOGGER.debug(
"Light %s: Clearing manual control attributes.",
light,
)
self.manual_control[light] = LightControlAttributes.NONE
if timer := self.auto_reset_manual_control_timers.pop(light, None): if timer := self.auto_reset_manual_control_timers.pop(light, None):
timer.cancel() timer.cancel()
self.our_last_state_on_change.pop(light, None) self.our_last_state_on_change.pop(light, None)
@ -2319,11 +2386,29 @@ class AdaptiveLightingManager:
self.turn_off_event[eid] = event self.turn_off_event[eid] = event
self.reset(eid) self.reset(eid)
def on(eid: str, event: Event) -> None: async def on(eid: str, event: Event) -> None:
task = self.sleep_tasks.get(eid) task = self.sleep_tasks.get(eid)
if task is not None: if task is not None:
task.cancel() task.cancel()
self.turn_on_event[eid] = event self.turn_on_event[eid] = event
try:
switch = _switch_with_lights(
self.hass,
[eid],
expand_light_groups=False,
)
await self.update_manually_controlled_from_event(
switch,
eid,
force=False,
)
except NoSwitchFoundError:
_LOGGER.debug(
"No switch found for entity_id='%s' in 'on' event listener",
eid,
)
timer = self.auto_reset_manual_control_timers.get(eid) timer = self.auto_reset_manual_control_timers.get(eid)
if ( if (
timer is not None timer is not None
@ -2351,7 +2436,7 @@ class AdaptiveLightingManager:
event.context.id, event.context.id,
) )
for eid in entity_ids: for eid in entity_ids:
on(eid, event) await on(eid, event)
elif service == SERVICE_TOGGLE: elif service == SERVICE_TOGGLE:
_LOGGER.debug( _LOGGER.debug(
@ -2366,7 +2451,7 @@ class AdaptiveLightingManager:
if state.state == STATE_ON: # is turning off if state.state == STATE_ON: # is turning off
off(eid, event) off(eid, event)
elif state.state == STATE_OFF: # is turning on elif state.state == STATE_OFF: # is turning on
on(eid, event) await on(eid, event)
async def state_changed_event_listener( async def state_changed_event_listener(
self, self,
@ -2489,68 +2574,95 @@ class AdaptiveLightingManager:
event, event,
) )
def is_manually_controlled( async def update_manually_controlled_from_event(
self, self,
switch: AdaptiveSwitch, switch: AdaptiveSwitch,
light: str, light: str,
force: bool, force: bool,
adapt_brightness: bool, ) -> None:
adapt_color: bool, """Check if the light has been manually controlled by the latest turn on event."""
) -> bool: if not switch._take_over_control:
"""Check if the light has been 'on' and is now manually controlled.""" return
manual_control = self.manual_control.setdefault(light, False)
if manual_control:
# Manually controlled until light is turned on and off
return True
turn_on_event = self.turn_on_event.get(light) turn_on_event = self.turn_on_event.get(light)
if ( if (
turn_on_event is not None turn_on_event is None
and not self.is_proactively_adapting(turn_on_event.context.id) or self.is_proactively_adapting(turn_on_event.context.id)
and not is_our_context(turn_on_event.context) or is_our_context(turn_on_event.context)
and not force or force
): ):
keys = turn_on_event.data[ATTR_SERVICE_DATA].keys() return
if (
(adapt_color and COLOR_ATTRS.intersection(keys)) turn_on_attributes = get_light_control_attributes(
or (adapt_brightness and BRIGHTNESS_ATTRS.intersection(keys)) turn_on_event.data[ATTR_SERVICE_DATA],
or (ATTR_FLASH in keys) )
or (ATTR_EFFECT in keys)
): if not turn_on_attributes:
# Light was already on and 'light.turn_on' was not called by return
# the adaptive_lighting integration.
manual_control = True # Light was already on and 'light.turn_on' was not called by
_fire_manual_control_event(switch, light, turn_on_event.context) # the adaptive_lighting integration.
_LOGGER.debug( self.add_manual_control_attributes(light, turn_on_attributes)
"'%s' was already on and 'light.turn_on' was not called by the" switch.fire_manual_control_event(light, turn_on_event.context)
" adaptive_lighting integration (context.id='%s'), the Adaptive" _LOGGER.debug(
" Lighting will stop adapting the light until the switch or the" "'%s' was already on and 'light.turn_on' was not called by the"
" light turns off and then on again.", " adaptive_lighting integration (context.id='%s'), the Adaptive"
light, " Lighting will stop adapting %s of the light until the switch or the"
turn_on_event.context.id, " light turns off and then on again.",
) light,
return manual_control turn_on_event.context.id,
turn_on_attributes,
)
async def update_manually_controlled_from_untracked_change(
self,
switch: AdaptiveSwitch,
light: str,
force: bool,
context: Context,
) -> None:
"""Check if the light has been manually controlled from an untracked change.
An untracked change is a change that has been made outsideof HA and is
therefore not visible through events.
"""
if not switch._take_over_control or not switch._detect_non_ha_changes or force:
return
# Note: This call updates the state of the light
# so it might suddenly be off.
significantly_changed_attributes = await self.significant_change(
switch,
light,
context,
)
if not significantly_changed_attributes:
return
self.add_manual_control_attributes(
light,
significantly_changed_attributes,
)
switch.fire_manual_control_event(light, context)
async def significant_change( async def significant_change(
self, self,
switch: AdaptiveSwitch, switch: AdaptiveSwitch,
light: str, light: str,
adapt_brightness: bool,
adapt_color: bool,
context: Context, # just for logging context: Context, # just for logging
) -> bool: ) -> LightControlAttributes:
"""Has the light made a significant change since last update. """Has the light made a significant change since last update.
This method will detect changes that were made to the light without This method will detect changes that were made to the light without
calling 'light.turn_on', so outside of Home Assistant. If a change is calling 'light.turn_on', so outside of Home Assistant.
detected, we mark the light as 'manually controlled' until the light
or switch is turned 'off' and 'on' again.
""" """
assert switch._detect_non_ha_changes assert switch._detect_non_ha_changes
last_service_data = self.last_service_data.get(light) last_service_data = self.last_service_data.get(light)
if last_service_data is None: if last_service_data is None:
return False return LightControlAttributes.NONE
# Update state and check for a manual change not done in HA. # Update state and check for a manual change not done in HA.
# Ensure HASS is correctly updating your light's state with # Ensure HASS is correctly updating your light's state with
# light.turn_on calls if any problems arise. This # light.turn_on calls if any problems arise. This
@ -2559,33 +2671,32 @@ class AdaptiveLightingManager:
refreshed_state = self.hass.states.get(light) refreshed_state = self.hass.states.get(light)
assert refreshed_state is not None assert refreshed_state is not None
changed = _attributes_have_changed( changed_attributes = _attributes_have_changed(
old_attributes=last_service_data, old_attributes=last_service_data,
new_attributes=refreshed_state.attributes, new_attributes=refreshed_state.attributes,
light=light, light=light,
adapt_brightness=adapt_brightness,
adapt_color=adapt_color,
context=context, context=context,
) )
if changed: if changed_attributes:
_LOGGER.debug( _LOGGER.debug(
"%s: State attributes of '%s' changed (%s) wrt 'last_service_data' (%s) (context.id=%s)", "%s: State attributes %s of '%s' changed (%s) wrt 'last_service_data' (%s) (context.id=%s)",
switch._name,
changed_attributes,
light,
refreshed_state.attributes,
last_service_data,
context.id,
)
else:
_LOGGER.debug(
"%s: State attributes of '%s' did not change (%s) wrt 'last_service_data' (%s) (context.id=%s)",
switch._name, switch._name,
light, light,
refreshed_state.attributes, refreshed_state.attributes,
last_service_data, last_service_data,
context.id, context.id,
) )
return True return changed_attributes
_LOGGER.debug(
"%s: State attributes of '%s' did not change (%s) wrt 'last_service_data' (%s) (context.id=%s)",
switch._name,
light,
refreshed_state.attributes,
last_service_data,
context.id,
)
return False
def _off_to_on_state_event_is_from_turn_on( def _off_to_on_state_event_is_from_turn_on(
self, self,
@ -2745,12 +2856,12 @@ class AdaptiveLightingManager:
entity_id, entity_id,
service_data, service_data,
) )
if any( manual_control_attributes = get_light_control_attributes(service_data)
attr in service_data
for attr in COLOR_ATTRS | BRIGHTNESS_ATTRS | {ATTR_EFFECT} if manual_control_attributes:
): self.set_manual_control_attributes(entity_id, manual_control_attributes)
self.mark_as_manual_control(entity_id)
return True return True
return False return False

View file

@ -53,8 +53,9 @@
"brightness_mode": "brightness_mode", "brightness_mode": "brightness_mode",
"brightness_mode_time_dark": "brightness_mode_time_dark", "brightness_mode_time_dark": "brightness_mode_time_dark",
"brightness_mode_time_light": "brightness_mode_time_light", "brightness_mode_time_light": "brightness_mode_time_light",
"take_over_control": "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`! 🔒", "take_over_control": "take_over_control: Pause adaptation of individual lights and hand over (manual) control to other sources that issue `light.turn_on` calls for lights that are on. 🔒",
"detect_non_ha_changes": "detect_non_ha_changes: Detects and halts adaptations for non-`light.turn_on` state changes. Needs `take_over_control` enabled. 🕵️ Caution: ⚠️ Some lights might falsely indicate an 'on' state, which could result in lights turning on unexpectedly. Disable this feature if you encounter such issues.", "take_over_control_mode": "take_over_control_mode",
"detect_non_ha_changes": "detect_non_ha_changes: Detects and halts adaptations for non-`light.turn_on` state changes. Needs `take_over_control` enabled. 🕵️ Caution: ⚠️ Some lights might falsely indicate an 'on' state, which could result in lights turning on unexpectedly. Note that this calls `homeassistant.update_entity` every `interval`! Disable this feature if you encounter such issues.",
"autoreset_control_seconds": "autoreset_control_seconds", "autoreset_control_seconds": "autoreset_control_seconds",
"only_once": "only_once: Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄", "only_once": "only_once: Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄",
"adapt_only_on_bare_turn_on": "adapt_only_on_bare_turn_on: When turning lights on initially. If set to `true`, AL adapts only if `light.turn_on` is invoked without specifying color or brightness. ❌🌈 This e.g., prevents adaptation when activating a scene. If `false`, AL adapts regardless of the presence of color or brightness in the initial `service_data`. Needs `take_over_control` enabled. 🕵️", "adapt_only_on_bare_turn_on": "adapt_only_on_bare_turn_on: When turning lights on initially. If set to `true`, AL adapts only if `light.turn_on` is invoked without specifying color or brightness. ❌🌈 This e.g., prevents adaptation when activating a scene. If `false`, AL adapts regardless of the presence of color or brightness in the initial `service_data`. Needs `take_over_control` enabled. 🕵️",
@ -86,6 +87,7 @@
"brightness_mode": "Brightness mode to use. Possible values are `default`, `linear`, and `tanh` (uses `brightness_mode_time_dark` and `brightness_mode_time_light`). 📈", "brightness_mode": "Brightness mode to use. Possible values are `default`, `linear`, and `tanh` (uses `brightness_mode_time_dark` and `brightness_mode_time_light`). 📈",
"brightness_mode_time_dark": "(Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness before/after sunrise/sunset. 📈📉", "brightness_mode_time_dark": "(Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness before/after sunrise/sunset. 📈📉",
"brightness_mode_time_light": "(Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness after/before sunrise/sunset. 📈📉.", "brightness_mode_time_light": "(Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness after/before sunrise/sunset. 📈📉.",
"take_over_control_mode": "The adaptation pausing mode when other sources change brightness and/or color of lights. `pause_all` always pauses both brightness and color adaptation. `pause_changed` pauses the adaptation of only the changed attributes and continues adapting unchanged attributes, e.g., continues color adaptation when only brightness was changed.",
"autoreset_control_seconds": "Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️", "autoreset_control_seconds": "Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️",
"send_split_delay": "Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️", "send_split_delay": "Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️",
"adapt_delay": "Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️" "adapt_delay": "Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️"
@ -145,7 +147,7 @@
"name": "lights" "name": "lights"
}, },
"manual_control": { "manual_control": {
"description": "Whether to add (\"true\") or remove (\"false\") the light from the \"manual_control\" list. 🔒", "description": "Whether to add (\"true\") or remove (\"false\") all adapted attributes of the light from the \"manual_control\" list, or the name of an attribute for selective addition. 🔒",
"name": "manual_control" "name": "manual_control"
} }
} }
@ -251,11 +253,15 @@
"name": "min_sunset_time" "name": "min_sunset_time"
}, },
"take_over_control": { "take_over_control": {
"description": "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`! 🔒", "description": "Pause adaptation of individual lights and hand over (manual) control to other sources that issue `light.turn_on` calls for lights that are on. 🔒",
"name": "take_over_control" "name": "take_over_control"
}, },
"take_over_control_mode": {
"description": "The adaptation pausing mode when other sources change brightness and/or color of lights. `pause_all` always pauses both brightness and color adaptation. `pause_changed` pauses the adaptation of only the changed attributes and continues adapting unchanged attributes, e.g., continues color adaptation when only brightness was changed.",
"name": "take_over_control_mode"
},
"detect_non_ha_changes": { "detect_non_ha_changes": {
"description": "Detects and halts adaptations for non-`light.turn_on` state changes. Needs `take_over_control` enabled. 🕵️ Caution: ⚠️ Some lights might falsely indicate an 'on' state, which could result in lights turning on unexpectedly. Disable this feature if you encounter such issues.", "description": "Detects and halts adaptations for non-`light.turn_on` state changes. Needs `take_over_control` enabled. 🕵️ Caution: ⚠️ Some lights might falsely indicate an 'on' state, which could result in lights turning on unexpectedly. Note that this calls `homeassistant.update_entity` every `interval`! Disable this feature if you encounter such issues.",
"name": "detect_non_ha_changes" "name": "detect_non_ha_changes"
}, },
"transition": { "transition": {

0
scripts/develop Normal file → Executable file
View file

0
scripts/lint Normal file → Executable file
View file

View file

@ -4,16 +4,26 @@ from unittest.mock import Mock
import pytest import pytest
from homeassistant.components.adaptive_lighting.adaptation_utils import ( from homeassistant.components.adaptive_lighting.adaptation_utils import (
LightControlAttributes,
ServiceData, ServiceData,
_create_service_call_data_iterator, _create_service_call_data_iterator,
_has_relevant_service_data_attributes, _has_relevant_service_data_attributes,
_remove_redundant_attributes, _remove_redundant_attributes,
_split_service_call_data, _split_service_call_data,
get_light_control_attributes,
has_brightness_attribute,
has_color_attribute,
has_effect_attribute,
manual_control_event_attribute_to_flags,
prepare_adaptation_data, prepare_adaptation_data,
) )
from homeassistant.components.light import ( from homeassistant.components.light import (
ATTR_BRIGHTNESS, ATTR_BRIGHTNESS,
ATTR_BRIGHTNESS_PCT,
ATTR_COLOR_TEMP_KELVIN, ATTR_COLOR_TEMP_KELVIN,
ATTR_EFFECT,
ATTR_FLASH,
ATTR_HS_COLOR,
ATTR_TRANSITION, ATTR_TRANSITION,
) )
from homeassistant.const import ATTR_ENTITY_ID, STATE_ON from homeassistant.const import ATTR_ENTITY_ID, STATE_ON
@ -338,3 +348,135 @@ def fixture_hass_states_mock():
hass = Mock() hass = Mock()
hass.states.get.return_value = Mock(attributes={ATTR_BRIGHTNESS: 10}) hass.states.get.return_value = Mock(attributes={ATTR_BRIGHTNESS: 10})
return hass return hass
@pytest.mark.parametrize(
("attribute", "expected_str", "has_any", "has_none", "has_all"),
[
(LightControlAttributes.NONE, "NONE", False, True, False),
(LightControlAttributes.BRIGHTNESS, "BRIGHTNESS", True, False, False),
(LightControlAttributes.COLOR, "COLOR", True, False, False),
(
LightControlAttributes.BRIGHTNESS | LightControlAttributes.COLOR,
"BRIGHTNESS|COLOR",
True,
False,
True,
),
(
LightControlAttributes.ALL,
"BRIGHTNESS|COLOR",
True,
False,
True,
),
],
)
def test_light_control_attribute_flags(
attribute: LightControlAttributes,
expected_str: str,
has_any: bool,
has_none: bool,
has_all: bool,
):
"""Test helper methods and string conversion for the light attribute flag."""
assert str(attribute) == expected_str
assert attribute.has_any() is has_any
assert attribute.has_none() is has_none
assert attribute.has_all() is has_all
@pytest.mark.parametrize(
("manual_control_attribute", "expected_flag"),
[
(True, LightControlAttributes.ALL),
(False, LightControlAttributes.NONE),
("brightness", LightControlAttributes.BRIGHTNESS),
("color", LightControlAttributes.COLOR),
("unsupported", LightControlAttributes.NONE),
],
)
def test_manual_control_event_attribute_to_flags(
manual_control_attribute: bool | str,
expected_flag: LightControlAttributes,
):
"""Test mapping of manual control events to attribute flags."""
assert (
manual_control_event_attribute_to_flags(manual_control_attribute)
== expected_flag
)
@pytest.mark.parametrize(
("service_data", "expected"),
[
({ATTR_BRIGHTNESS: 125}, True),
({ATTR_BRIGHTNESS_PCT: 50}, True),
({ATTR_BRIGHTNESS_PCT: 50, ATTR_COLOR_TEMP_KELVIN: 3500}, True),
({ATTR_BRIGHTNESS_PCT: 50, "unknown": "foo"}, True),
({ATTR_COLOR_TEMP_KELVIN: 3500}, False),
({}, False),
],
)
def test_has_brightness_attribute(service_data: ServiceData, expected: bool):
"""Test detection of brightness attributes in service data."""
assert has_brightness_attribute(service_data) is expected
@pytest.mark.parametrize(
("service_data", "expected"),
[
({ATTR_HS_COLOR: (10, 20)}, True),
({ATTR_COLOR_TEMP_KELVIN: 5000}, True),
({ATTR_COLOR_TEMP_KELVIN: 5000, ATTR_BRIGHTNESS: 125}, True),
({ATTR_COLOR_TEMP_KELVIN: 5000, "unknown": "foo"}, True),
({ATTR_BRIGHTNESS: 125}, False),
({}, False),
],
)
def test_has_color_attribute(service_data: ServiceData, expected: bool):
"""Test detection of color attributes in service data."""
assert has_color_attribute(service_data) is expected
@pytest.mark.parametrize(
("service_data", "expected"),
[
({ATTR_EFFECT: "colorloop"}, True),
({ATTR_FLASH: "short"}, True),
({ATTR_EFFECT: "colorloop", ATTR_FLASH: "short"}, True),
({ATTR_EFFECT: "colorloop", "unknown": "foo"}, True),
({}, False),
],
)
def test_has_effect_attribute(service_data: ServiceData, expected: bool):
"""Test detection of effect attributes in service data."""
assert has_effect_attribute(service_data) is expected
@pytest.mark.parametrize(
("service_data", "expected_flags"),
[
({ATTR_BRIGHTNESS: 1}, LightControlAttributes.BRIGHTNESS),
({ATTR_HS_COLOR: (1, 2)}, LightControlAttributes.COLOR),
(
{ATTR_BRIGHTNESS: 1, ATTR_HS_COLOR: (1, 2)},
LightControlAttributes.BRIGHTNESS | LightControlAttributes.COLOR,
),
(
{ATTR_EFFECT: "colorloop"},
LightControlAttributes.BRIGHTNESS | LightControlAttributes.COLOR,
),
(
{ATTR_FLASH: "short"},
LightControlAttributes.BRIGHTNESS | LightControlAttributes.COLOR,
),
({}, LightControlAttributes.NONE),
],
)
def test_get_light_control_attributes(
service_data: ServiceData,
expected_flags: LightControlAttributes,
):
"""Test determination of light control attributes."""
assert get_light_control_attributes(service_data) == expected_flags

View file

@ -18,6 +18,7 @@ import voluptuous.error
from flaky import flaky from flaky import flaky
from homeassistant.components.adaptive_lighting.adaptation_utils import ( from homeassistant.components.adaptive_lighting.adaptation_utils import (
AdaptationData, AdaptationData,
LightControlAttributes,
_create_service_call_data_iterator, _create_service_call_data_iterator,
) )
from homeassistant.components.adaptive_lighting.color_and_brightness import ( from homeassistant.components.adaptive_lighting.color_and_brightness import (
@ -60,6 +61,7 @@ from homeassistant.components.adaptive_lighting.const import (
SERVICE_SET_MANUAL_CONTROL, SERVICE_SET_MANUAL_CONTROL,
SLEEP_MODE_SWITCH, SLEEP_MODE_SWITCH,
UNDO_UPDATE_LISTENER, UNDO_UPDATE_LISTENER,
TakeOverControlMode,
) )
from homeassistant.components.adaptive_lighting.switch import ( from homeassistant.components.adaptive_lighting.switch import (
CONF_INTERCEPT, CONF_INTERCEPT,
@ -690,7 +692,7 @@ async def test_manual_control(
# Call light.turn_on for ENTITY_LIGHT_1 # Call light.turn_on for ENTITY_LIGHT_1
await turn_light(True, brightness=increased_brightness()) await turn_light(True, brightness=increased_brightness())
# Check that ENTITY_LIGHT_1 is manually controlled # Check that ENTITY_LIGHT_1 is manually controlled
assert manual_control[ENTITY_LIGHT_1] assert manual_control[ENTITY_LIGHT_1] == LightControlAttributes.BRIGHTNESS
# Test adaptive_lighting.set_manual_control # Test adaptive_lighting.set_manual_control
await change_manual_control(False) await change_manual_control(False)
# Check that ENTITY_LIGHT_1 is not manually controlled # Check that ENTITY_LIGHT_1 is not manually controlled
@ -703,11 +705,9 @@ async def test_manual_control(
assert not manual_control[ENTITY_LIGHT_1], manual_control assert not manual_control[ENTITY_LIGHT_1], manual_control
await turn_light(True, brightness=increased_brightness()) await turn_light(True, brightness=increased_brightness())
assert hass.states.get(ENTITY_LIGHT_1).state == STATE_ON assert hass.states.get(ENTITY_LIGHT_1).state == STATE_ON
if adapt_only_on_bare_turn_on: assert (
# Marks as manually controlled beacuse we turned it on with brightness manual_control[ENTITY_LIGHT_1] == LightControlAttributes.BRIGHTNESS
assert manual_control[ENTITY_LIGHT_1], manual_control ), manual_control
else:
assert not manual_control[ENTITY_LIGHT_1], manual_control
# Check that toggling (sleep mode) switch resets manual control # Check that toggling (sleep mode) switch resets manual control
for entity_id in [ENTITY_SWITCH, ENTITY_SLEEP_MODE_SWITCH]: for entity_id in [ENTITY_SWITCH, ENTITY_SLEEP_MODE_SWITCH]:
@ -722,7 +722,7 @@ async def test_manual_control(
await turn_light(False) await turn_light(False)
await change_manual_control(True) await change_manual_control(True)
await turn_light(True) await turn_light(True)
assert manual_control[ENTITY_LIGHT_1] assert manual_control[ENTITY_LIGHT_1] == LightControlAttributes.ALL
# Check that when 'adapt_brightness' is off, changing the brightness # Check that when 'adapt_brightness' is off, changing the brightness
# doesn't mark it as manually controlled but changing color_temp # doesn't mark it as manually controlled but changing color_temp
@ -732,7 +732,7 @@ async def test_manual_control(
assert not manual_control[ENTITY_LIGHT_1] assert not manual_control[ENTITY_LIGHT_1]
await switch.adapt_brightness_switch.async_turn_off() await switch.adapt_brightness_switch.async_turn_off()
await turn_light(True, brightness=increased_brightness()) await turn_light(True, brightness=increased_brightness())
assert not manual_control[ENTITY_LIGHT_1] assert manual_control[ENTITY_LIGHT_1] == LightControlAttributes.BRIGHTNESS
mired_range = (light.min_color_temp_kelvin, light.max_color_temp_kelvin) mired_range = (light.min_color_temp_kelvin, light.max_color_temp_kelvin)
kelvin_range = ( kelvin_range = (
color_temperature_mired_to_kelvin(mired_range[1]), color_temperature_mired_to_kelvin(mired_range[1]),
@ -743,7 +743,7 @@ async def test_manual_control(
True, True,
color_temp_kelvin=(light._attr_color_temp + 100) % ptp_kelvin, color_temp_kelvin=(light._attr_color_temp + 100) % ptp_kelvin,
) )
assert manual_control[ENTITY_LIGHT_1] assert manual_control[ENTITY_LIGHT_1] == LightControlAttributes.ALL
await switch.adapt_brightness_switch.async_turn_on() # turn on again await switch.adapt_brightness_switch.async_turn_on() # turn on again
# Check that when 'adapt_color' is off, changing the color # Check that when 'adapt_color' is off, changing the color
@ -753,10 +753,10 @@ async def test_manual_control(
await turn_light(True) await turn_light(True)
assert not manual_control[ENTITY_LIGHT_1] assert not manual_control[ENTITY_LIGHT_1]
await switch.adapt_color_switch.async_turn_off() await switch.adapt_color_switch.async_turn_off()
await turn_light(True, color_temp=increased_color_temp()) await turn_light(True, color_temp_kelvin=increased_color_temp())
assert not manual_control[ENTITY_LIGHT_1] assert manual_control[ENTITY_LIGHT_1] == LightControlAttributes.COLOR
await turn_light(True, brightness=increased_brightness()) await turn_light(True, brightness=increased_brightness())
assert manual_control[ENTITY_LIGHT_1] assert manual_control[ENTITY_LIGHT_1] == LightControlAttributes.ALL
# Check that when 'adapt_color' adapt_brightness are both off # Check that when 'adapt_color' adapt_brightness are both off
# nothing marks it as manually controlled # nothing marks it as manually controlled
@ -765,14 +765,14 @@ async def test_manual_control(
await switch.adapt_color_switch.async_turn_off() await switch.adapt_color_switch.async_turn_off()
await switch.adapt_brightness_switch.async_turn_off() await switch.adapt_brightness_switch.async_turn_off()
assert not manual_control[ENTITY_LIGHT_1] assert not manual_control[ENTITY_LIGHT_1]
await turn_light(True, color_temp=increased_color_temp()) await turn_light(True, color_temp_kelvin=increased_color_temp())
await turn_light(True, brightness=increased_brightness()) await turn_light(True, brightness=increased_brightness())
await turn_light( await turn_light(
True, True,
color_temp=increased_color_temp(), color_temp_kelvin=increased_color_temp(),
brightness=increased_brightness(), brightness=increased_brightness(),
) )
assert not manual_control[ENTITY_LIGHT_1] assert manual_control[ENTITY_LIGHT_1] == LightControlAttributes.ALL
# Turn switches on again # Turn switches on again
await switch.adapt_color_switch.async_turn_on() await switch.adapt_color_switch.async_turn_on()
await switch.adapt_brightness_switch.async_turn_on() await switch.adapt_brightness_switch.async_turn_on()
@ -800,6 +800,22 @@ async def test_manual_control(
assert hass.states.get(ENTITY_LIGHT_1).state == STATE_ON assert hass.states.get(ENTITY_LIGHT_1).state == STATE_ON
assert not manual_control[ENTITY_LIGHT_1] assert not manual_control[ENTITY_LIGHT_1]
# Check that manual control `True` sets all attributes
await change_manual_control(False)
assert not manual_control[ENTITY_LIGHT_1]
await change_manual_control(True)
assert manual_control[ENTITY_LIGHT_1] == LightControlAttributes.ALL
# Check that manual control `False` unsets all attributes
await change_manual_control(False)
assert not manual_control[ENTITY_LIGHT_1]
# Check that manual control attributes can be selectively set
await change_manual_control("brightness")
assert manual_control[ENTITY_LIGHT_1] == LightControlAttributes.BRIGHTNESS
await change_manual_control("color")
assert manual_control[ENTITY_LIGHT_1] == LightControlAttributes.COLOR
@flaky(max_runs=3, min_passes=1) @flaky(max_runs=3, min_passes=1)
async def test_auto_reset_manual_control(hass): async def test_auto_reset_manual_control(hass):
@ -833,7 +849,7 @@ async def test_auto_reset_manual_control(hass):
_LOGGER.debug("Start test auto reset manual control") _LOGGER.debug("Start test auto reset manual control")
await turn_light(True, brightness=1) await turn_light(True, brightness=1)
await turn_light(True, brightness=10) await turn_light(True, brightness=10)
assert manual_control[light.entity_id] assert manual_control[light.entity_id] == LightControlAttributes.BRIGHTNESS
assert ( assert (
switch.extra_state_attributes["autoreset_time_remaining"][light.entity_id] > 0 switch.extra_state_attributes["autoreset_time_remaining"][light.entity_id] > 0
) )
@ -856,6 +872,143 @@ async def test_auto_reset_manual_control(hass):
assert not manual_control[light.entity_id] assert not manual_control[light.entity_id]
async def test_adaptation_attribute_selection(hass):
"""Test the 'manual control' tracking."""
switch, (light, *_) = await setup_lights_and_switch(hass)
# Assert default settings
assert switch._take_over_control
assert switch._take_over_control_mode == TakeOverControlMode.PAUSE_ALL
# Check that PAUSE_ALL leads to adaptation of all attributes when none are manually controlled
assert (
switch.manager.get_manual_control_attributes(ENTITY_LIGHT_1)
== LightControlAttributes.NONE
)
assert (
switch.manager.get_adaption_control_attributes(switch, ENTITY_LIGHT_1)
== LightControlAttributes.ALL
)
# Check that PAUSE_ALL leads to no adaptation when a single attribute is manually controlled
switch.manager.add_manual_control_attributes(
ENTITY_LIGHT_1,
LightControlAttributes.BRIGHTNESS,
)
assert (
switch.manager.get_manual_control_attributes(ENTITY_LIGHT_1)
== LightControlAttributes.BRIGHTNESS
)
assert (
switch.manager.get_adaption_control_attributes(switch, ENTITY_LIGHT_1)
== LightControlAttributes.NONE
)
# Check that PAUSE_ALL leads to no adaptation when all attributes are manually controlled
switch.manager.add_manual_control_attributes(
ENTITY_LIGHT_1,
LightControlAttributes.COLOR,
)
assert (
switch.manager.get_manual_control_attributes(ENTITY_LIGHT_1)
== LightControlAttributes.ALL
)
assert (
switch.manager.get_adaption_control_attributes(switch, ENTITY_LIGHT_1)
== LightControlAttributes.NONE
)
switch._take_over_control_mode = TakeOverControlMode.PAUSE_CHANGED
switch.manager.set_manual_control_attributes(
ENTITY_LIGHT_1,
LightControlAttributes.NONE,
)
# Check that PAUSE_CHANGED leads to adaptation of all attributes when none are manually controlled
assert (
switch.manager.get_manual_control_attributes(ENTITY_LIGHT_1)
== LightControlAttributes.NONE
)
assert (
switch.manager.get_adaption_control_attributes(switch, ENTITY_LIGHT_1)
== LightControlAttributes.ALL
)
# Check that PAUSE_CHANGED leads to adaptation of the remaining non-manual attributes
switch.manager.add_manual_control_attributes(
ENTITY_LIGHT_1,
LightControlAttributes.BRIGHTNESS,
)
assert (
switch.manager.get_manual_control_attributes(ENTITY_LIGHT_1)
== LightControlAttributes.BRIGHTNESS
)
assert (
switch.manager.get_adaption_control_attributes(switch, ENTITY_LIGHT_1)
== LightControlAttributes.COLOR
)
# Check that PAUSE_CHANGED leads to no adaptation when all attributes are manually controlled
switch.manager.add_manual_control_attributes(
ENTITY_LIGHT_1,
LightControlAttributes.COLOR,
)
assert (
switch.manager.get_manual_control_attributes(ENTITY_LIGHT_1)
== LightControlAttributes.ALL
)
assert (
switch.manager.get_adaption_control_attributes(switch, ENTITY_LIGHT_1)
== LightControlAttributes.NONE
)
await switch.adapt_brightness_switch.async_turn_off()
# Check that with adapt_brightness off and PAUSE_CHANGED, only color is adapted when none are manually controlled
switch._take_over_control_mode = TakeOverControlMode.PAUSE_CHANGED
switch.manager.set_manual_control_attributes(
ENTITY_LIGHT_1,
LightControlAttributes.NONE,
)
assert (
switch.manager.get_adaption_control_attributes(switch, ENTITY_LIGHT_1)
== LightControlAttributes.COLOR
)
# Check that with adapt_brightness off and PAUSE_CHANGED, nothing is adapted when color is manually controlled
switch._take_over_control_mode = TakeOverControlMode.PAUSE_CHANGED
switch.manager.set_manual_control_attributes(
ENTITY_LIGHT_1,
LightControlAttributes.COLOR,
)
assert (
switch.manager.get_adaption_control_attributes(switch, ENTITY_LIGHT_1)
== LightControlAttributes.NONE
)
# Check that with adapt_brightness off and PAUSE_ALL, only color is adapted when none are manually controlled
switch._take_over_control_mode = TakeOverControlMode.PAUSE_ALL
switch.manager.set_manual_control_attributes(
ENTITY_LIGHT_1,
LightControlAttributes.NONE,
)
assert (
switch.manager.get_adaption_control_attributes(switch, ENTITY_LIGHT_1)
== LightControlAttributes.COLOR
)
# Check that with adapt_brightness off and PAUSE_ALL, nothing is adapted when color is manually controlled
switch._take_over_control_mode = TakeOverControlMode.PAUSE_ALL
switch.manager.set_manual_control_attributes(
ENTITY_LIGHT_1,
LightControlAttributes.COLOR,
)
assert (
switch.manager.get_adaption_control_attributes(switch, ENTITY_LIGHT_1)
== LightControlAttributes.NONE
)
async def test_apply_service(hass): async def test_apply_service(hass):
"""Test adaptive_lighting.apply service.""" """Test adaptive_lighting.apply service."""
switch, (_, _, light) = await setup_lights_and_switch(hass) switch, (_, _, light) = await setup_lights_and_switch(hass)
@ -994,8 +1147,6 @@ def test_attributes_have_changed():
} }
kwargs = { kwargs = {
"light": "light.test", "light": "light.test",
"adapt_brightness": True,
"adapt_color": True,
"context": Context(), "context": Context(),
} }
assert not _attributes_have_changed( assert not _attributes_have_changed(
@ -1229,9 +1380,15 @@ async def test_state_change_handlers(hass):
await turn_light(True, brightness=40) await turn_light(True, brightness=40)
await turn_light(True, brightness=20) await turn_light(True, brightness=20)
await update(force=False) await update(force=False)
assert switch.manager.manual_control[ENTITY_LIGHT_1] assert (
switch.manager.manual_control[ENTITY_LIGHT_1]
== LightControlAttributes.BRIGHTNESS
)
await update(force=True) await update(force=True)
assert switch.manager.manual_control[ENTITY_LIGHT_1] assert (
switch.manager.manual_control[ENTITY_LIGHT_1]
== LightControlAttributes.BRIGHTNESS
)
# turn light off then on should reset manual control. # turn light off then on should reset manual control.
await turn_light(False) await turn_light(False)
@ -1245,7 +1402,10 @@ async def test_state_change_handlers(hass):
await update(force=False) await update(force=False)
assert switch.manager.last_service_data.get(ENTITY_LIGHT_1) is not None assert switch.manager.last_service_data.get(ENTITY_LIGHT_1) is not None
assert switch.manager.our_last_state_on_change.get(ENTITY_LIGHT_1) is not None assert switch.manager.our_last_state_on_change.get(ENTITY_LIGHT_1) is not None
assert switch.manager.manual_control[ENTITY_LIGHT_1] assert (
switch.manager.manual_control[ENTITY_LIGHT_1]
== LightControlAttributes.BRIGHTNESS
)
def test_is_our_context(): def test_is_our_context():
@ -1345,7 +1505,7 @@ async def test_separate_turn_on_commands(hass, separate_turn_on_commands):
# check whether the brightness and color_temp change. # check whether the brightness and color_temp change.
context = switch.create_context("test") # needs to be passed to update method context = switch.create_context("test") # needs to be passed to update method
brightness = light.brightness brightness = light.brightness
color_temp = light.color_temp color_temp = light.color_temp_kelvin
await switch.sleep_mode_switch.async_turn_on() await switch.sleep_mode_switch.async_turn_on()
await switch._update_attrs_and_maybe_adapt_lights(context=context) await switch._update_attrs_and_maybe_adapt_lights(context=context)
await hass.async_block_till_done() await hass.async_block_till_done()
@ -1353,7 +1513,7 @@ async def test_separate_turn_on_commands(hass, separate_turn_on_commands):
# TODO: figure out why `light.brightness` is not updating # TODO: figure out why `light.brightness` is not updating
attrs = hass.states.get(light.entity_id).attributes attrs = hass.states.get(light.entity_id).attributes
sleep_brightness = attrs["brightness"] sleep_brightness = attrs["brightness"]
sleep_color_temp = attrs["color_temp"] sleep_color_temp = attrs["color_temp_kelvin"]
assert sleep_brightness != brightness assert sleep_brightness != brightness
assert sleep_color_temp != color_temp assert sleep_color_temp != color_temp
@ -1364,7 +1524,7 @@ async def test_separate_turn_on_commands(hass, separate_turn_on_commands):
attrs = hass.states.get(light.entity_id).attributes attrs = hass.states.get(light.entity_id).attributes
brightness = attrs["brightness"] brightness = attrs["brightness"]
color_temp = attrs["color_temp"] color_temp = attrs["color_temp_kelvin"]
assert sleep_brightness != brightness assert sleep_brightness != brightness
assert sleep_color_temp != color_temp assert sleep_color_temp != color_temp
@ -1538,7 +1698,7 @@ async def test_cancellable_service_calls_task(hass):
_create_service_call_data_iterator(hass, [service_data], False), _create_service_call_data_iterator(hass, [service_data], False),
force=False, force=False,
max_length=1, max_length=1,
which="both", attributes=LightControlAttributes.ALL,
) )
await switch.execute_cancellable_adaptation_calls(adaptation_data) await switch.execute_cancellable_adaptation_calls(adaptation_data)
@ -1948,7 +2108,7 @@ async def test_two_switches_for_single_light(hass):
assert light1.is_on assert light1.is_on
await turn_light(True, brightness=increased_brightness()) await turn_light(True, brightness=increased_brightness())
await turn_light(True, color_temp=increased_color_temp()) await turn_light(True, color_temp_kelvin=increased_color_temp())
attrs = hass.states.get(light1.entity_id).attributes attrs = hass.states.get(light1.entity_id).attributes
before_brightness = attrs[ATTR_BRIGHTNESS] before_brightness = attrs[ATTR_BRIGHTNESS]
@ -2381,12 +2541,9 @@ def test_attributes_have_changed_light_mode_switch():
context = Context() context = Context()
base_kwargs = { base_kwargs = {
"light": "light.test", "light": "light.test",
"adapt_brightness": True,
"context": context, "context": context,
} }
kwargs_adapt_color = base_kwargs
# Test 1: adapt_color=True - all mode changes should be detected
kwargs_adapt_color = {**base_kwargs, "adapt_color": True}
# color_temp → RGB # color_temp → RGB
assert _attributes_have_changed( assert _attributes_have_changed(
@ -2449,21 +2606,6 @@ def test_attributes_have_changed_light_mode_switch():
**kwargs_adapt_color, **kwargs_adapt_color,
), "Same XY should not be detected as change" ), "Same XY should not be detected as change"
# Test 2: adapt_color=False - mode changes should NOT be detected
kwargs_no_adapt = {**base_kwargs, "adapt_color": False}
assert not _attributes_have_changed(
old_attributes={ATTR_BRIGHTNESS: 128, ATTR_COLOR_TEMP_KELVIN: 4000},
new_attributes={ATTR_BRIGHTNESS: 128, ATTR_RGB_COLOR: (255, 0, 0)},
**kwargs_no_adapt,
), "Mode change should not be detected when adapt_color=False"
assert not _attributes_have_changed(
old_attributes={ATTR_BRIGHTNESS: 128, ATTR_RGB_COLOR: (255, 0, 0)},
new_attributes={ATTR_BRIGHTNESS: 128, ATTR_COLOR_TEMP_KELVIN: 4000},
**kwargs_no_adapt,
), "RGB → color_temp should not be detected when adapt_color=False"
# Regression tests for bugs found in PR #1348 by @protyposis # Regression tests for bugs found in PR #1348 by @protyposis
# See: https://github.com/basnijholt/adaptive-lighting/pull/1348 # See: https://github.com/basnijholt/adaptive-lighting/pull/1348