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

@ -46,6 +46,8 @@ def _type_to_str(type_: Any) -> str: # noqa: PLR0911
return "bool"
if isinstance(type_, vol.All):
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):
return f"one of `{type_.container}`"
if isinstance(type_, selector.SelectSelector):

View file

@ -3,7 +3,8 @@
import logging
from collections.abc import AsyncGenerator
from dataclasses import dataclass
from typing import Any, Literal
from enum import IntFlag, auto
from typing import Any
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
@ -12,6 +13,8 @@ from homeassistant.components.light import (
ATTR_BRIGHTNESS_STEP_PCT,
ATTR_COLOR_NAME,
ATTR_COLOR_TEMP_KELVIN,
ATTR_EFFECT,
ATTR_FLASH,
ATTR_HS_COLOR,
ATTR_RGB_COLOR,
ATTR_RGBW_COLOR,
@ -45,6 +48,41 @@ BRIGHTNESS_ATTRS = {
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]:
"""Splits the service data by the adapted attributes.
@ -145,7 +183,7 @@ class AdaptationData:
service_call_datas: AsyncGenerator[ServiceData]
force: bool
max_length: int
which: Literal["brightness", "color", "both"]
attributes: LightControlAttributes
initial_sleep: bool = False
async def next_service_call_data(self) -> ServiceData | None:
@ -161,7 +199,7 @@ class AdaptationData:
f"sleep_time={self.sleep_time}, "
f"force={self.force}, "
f"max_length={self.max_length}, "
f"which={self.which}, "
f"attributes={self.attributes}, "
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."""
def _identify_lighting_type(
def _identify_light_control_attributes(
service_data: ServiceData,
) -> Literal["brightness", "color", "both"]:
) -> LightControlAttributes:
"""Extract the 'which' attribute from the service data."""
has_brightness = ATTR_BRIGHTNESS in service_data
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:
return "brightness"
parameters |= LightControlAttributes.BRIGHTNESS
if has_color:
return "color"
msg = f"Invalid service_data, no brightness or color attributes found: {service_data=}"
raise NoColorOrBrightnessInServiceDataError(msg)
parameters |= LightControlAttributes.COLOR
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(
@ -220,7 +263,7 @@ def prepare_adaptation_data(
filter_by_state,
)
lighting_type = _identify_lighting_type(service_data)
attributes = _identify_light_control_attributes(service_data)
return AdaptationData(
entity_id=entity_id,
@ -229,5 +272,58 @@ def prepare_adaptation_data(
service_call_datas=service_data_iterator,
force=force,
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."""
from datetime import timedelta
from enum import Enum
from typing import Any
import homeassistant.helpers.config_validation as cv
@ -16,6 +17,14 @@ ICON_SLEEP = "mdi:sleep"
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. 📝"}
@ -34,6 +43,7 @@ DOCS[CONF_DETECT_NON_HA_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."
)
@ -188,9 +198,19 @@ DOCS[CONF_BRIGHTNESS_MODE_TIME_LIGHT] = (
CONF_TAKE_OVER_CONTROL, DEFAULT_TAKE_OVER_CONTROL = "take_over_control", True
DOCS[CONF_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`! 🔒"
"Pause adaptation of individual lights and hand over (manual) control to other sources that "
"issue `light.turn_on` calls for lights that are on. 🔒"
)
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
@ -284,8 +304,9 @@ DOCS_MANUAL_CONTROL = {
"light as being `manually controlled`. 📝",
CONF_LIGHTS: "entity_id(s) of lights, if not specified, all lights in the "
"switch are selected. 💡",
CONF_MANUAL_CONTROL: 'Whether to add ("true") or remove ("false") the '
'light from the "manual_control" list. 🔒',
CONF_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. 🔒",
}
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_LIGHT, DEFAULT_BRIGHTNESS_MODE_TIME_LIGHT, int),
(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_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_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
multiple: true
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
default: true
selector:
@ -220,13 +220,22 @@ change_switch_settings:
selector:
time: null
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
example: true
selector:
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:
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
example: false
selector:

View file

@ -52,8 +52,9 @@
"brightness_mode": "brightness_mode",
"brightness_mode_time_dark": "brightness_mode_time_dark",
"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`! 🔒",
"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": "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. 🔒",
"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",
"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. 🕵️",
@ -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_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. 📈📉.",
"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. ⏲️",
"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. ⏲️"
@ -144,7 +146,7 @@
"name": "lights"
},
"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"
}
}
@ -250,11 +252,15 @@
"name": "min_sunset_time"
},
"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"
},
"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": {
"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"
},
"transition": {

View file

@ -8,7 +8,7 @@ import logging
import zoneinfo
from copy import deepcopy
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.util.dt as dt_util
@ -17,8 +17,6 @@ import voluptuous as vol
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
ATTR_EFFECT,
ATTR_FLASH,
ATTR_RGB_COLOR,
ATTR_SUPPORTED_COLOR_MODES,
ATTR_TRANSITION,
@ -76,10 +74,12 @@ from homeassistant.util.color import (
)
from .adaptation_utils import (
BRIGHTNESS_ATTRS,
COLOR_ATTRS,
AdaptationData,
LightControlAttributes,
ServiceData,
get_light_control_attributes,
has_effect_attribute,
manual_control_event_attribute_to_flags,
prepare_adaptation_data,
)
from .color_and_brightness import SunLightSettings
@ -127,6 +127,7 @@ from .const import (
CONF_SUNSET_OFFSET,
CONF_SUNSET_TIME,
CONF_TAKE_OVER_CONTROL,
CONF_TAKE_OVER_CONTROL_MODE,
CONF_TRANSITION,
CONF_TURN_ON_LIGHTS,
CONF_USE_DEFAULTS,
@ -143,6 +144,7 @@ from .const import (
SLEEP_MODE_SWITCH,
TURNING_OFF_DELAY,
VALIDATION_TUPLES,
TakeOverControlMode,
apply_service_schema,
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
hass: HomeAssistant,
config_entry: ConfigEntry,
@ -497,9 +478,21 @@ async def async_setup_entry( # noqa: PLR0915
all_lights = switch.lights
else:
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:
_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:
switch.manager.reset(*all_lights)
if switch.is_on:
@ -753,36 +746,32 @@ def _attributes_have_changed(
light: str,
old_attributes: dict[str, Any],
new_attributes: dict[str, Any],
adapt_brightness: bool,
adapt_color: bool,
context: Context,
) -> bool:
) -> LightControlAttributes:
# 2023-11-19: HA core no longer removes light domain attributes when off
# so we must protect for `None` here
# see https://github.com/home-assistant/core/pull/101946
changed_attributes = LightControlAttributes.NONE
# Check for color mode changes BEFORE attribute conversion
# This detects external changes like Hue scenes switching from color_temp to RGB
# See: https://github.com/basnijholt/adaptive-lighting/issues/1275
if adapt_color and _has_color_mode_changed(
if _has_color_mode_changed(
light,
old_attributes,
new_attributes,
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,
)
if (
adapt_brightness
and old_attributes.get(ATTR_BRIGHTNESS)
and new_attributes.get(ATTR_BRIGHTNESS)
):
if old_attributes.get(ATTR_BRIGHTNESS) and new_attributes.get(ATTR_BRIGHTNESS):
last_brightness = old_attributes[ATTR_BRIGHTNESS]
current_brightness = new_attributes[ATTR_BRIGHTNESS]
if abs(current_brightness - last_brightness) > BRIGHTNESS_CHANGE:
@ -794,10 +783,10 @@ def _attributes_have_changed(
current_brightness,
context.id,
)
return True
changed_attributes |= LightControlAttributes.BRIGHTNESS
if (
adapt_color
LightControlAttributes.COLOR not in changed_attributes
and old_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,
context.id,
)
return True
changed_attributes |= LightControlAttributes.COLOR
if (
adapt_color
LightControlAttributes.COLOR not in changed_attributes
and old_attributes.get(ATTR_RGB_COLOR)
and new_attributes.get(ATTR_RGB_COLOR)
):
@ -831,9 +820,9 @@ def _attributes_have_changed(
current_rgb_color,
context.id,
)
return True
changed_attributes |= LightControlAttributes.COLOR
return False
return changed_attributes
class AdaptiveSwitch(SwitchEntity, RestoreEntity):
@ -937,6 +926,9 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
self._name,
)
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._adapt_only_on_bare_turn_on = data[CONF_ADAPT_ONLY_ON_BARE_TURN_ON]
self._auto_reset_manual_control_time = data[CONF_AUTORESET_CONTROL]
@ -1203,12 +1195,19 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
context: Context | None = None,
) -> AdaptationData | None:
"""Prepare `AdaptationData` for adapting a light."""
adaptation_attributes = self.manager.get_adaption_control_attributes(
self,
light,
)
if transition is None:
transition = self._transition
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:
adapt_color = self.adapt_color_switch.is_on
adapt_color = LightControlAttributes.COLOR in adaptation_attributes
if prefer_rgb_color is None:
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.
"""
# 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(
"%s: execute_cancellable_adaptation_calls with data: %s",
self._name,
@ -1384,9 +1383,9 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
# Execute adaptation calls within a task
try:
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
if data.which in ("both", "color"):
if LightControlAttributes.COLOR in data.attributes:
self.manager.adaptation_tasks_color[data.entity_id] = task
await task
except asyncio.CancelledError:
@ -1397,7 +1396,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
data,
)
async def _update_attrs_and_maybe_adapt_lights( # noqa: PLR0912
async def _update_attrs_and_maybe_adapt_lights(
self,
*,
context: Context,
@ -1468,49 +1467,26 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
if not filtered_lights:
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]] = []
for light in filtered_lights:
manually_controlled = (
self._take_over_control
and self.manager.is_manually_controlled(
self,
light,
force,
adapt_brightness,
adapt_color,
)
await self.manager.update_manually_controlled_from_untracked_change(
self,
light,
force,
context,
)
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(
"%s: '%s' is being manually controlled, stop adapting, context.id=%s.",
"%s: '%s' is being manually controlled, skip adaptation, context.id=%s.",
self._name,
light,
context.id,
)
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(
"%s: Calling _adapt_light from _update_attrs_and_maybe_adapt_lights:"
" '%s' with transition %s and context.id=%s",
@ -1553,7 +1529,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
entity_id,
event.context.id,
)
self.manager.mark_as_manual_control(entity_id)
self.manager.set_manual_control_attributes(entity_id)
return
if (
@ -1607,6 +1583,28 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
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):
"""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'
self.turn_off_locks: dict[str, asyncio.Lock] = {}
# 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
self.our_last_state_on_change: dict[str, list[State]] = {}
# Track last 'service_data' to 'light.turn_on' resulting from this integration
@ -1954,10 +1952,7 @@ class AdaptiveLightingManager:
# were skipped by us
return
if (
ATTR_EFFECT in service_data[CONF_PARAMS]
or ATTR_FLASH in service_data[CONF_PARAMS]
):
if has_effect_attribute(service_data[CONF_PARAMS]):
return
_LOGGER.debug(
@ -2204,10 +2199,26 @@ class AdaptiveLightingManager:
)
self.auto_reset_manual_control_times[light] = time
def mark_as_manual_control(self, light: str) -> None:
"""Mark a light as manually controlled."""
_LOGGER.debug("Marking '%s' as manually controlled.", light)
self.manual_control[light] = True
def get_manual_control_attributes(
self,
light: str,
) -> 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)
async def reset() -> None:
@ -2228,35 +2239,87 @@ class AdaptiveLightingManager:
transition=switch.initial_transition,
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)
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(
self,
light_id: str,
which: Literal["color", "brightness", "both"] = "both",
) -> None:
"""Cancel ongoing adaptation service calls for a specific light entity."""
brightness_task = self.adaptation_tasks_brightness.get(light_id)
color_task = self.adaptation_tasks_color.get(light_id)
if (
which in ("both", "brightness")
and brightness_task is not None
and not brightness_task.done()
):
if brightness_task is not None and not brightness_task.done():
_LOGGER.debug(
"Cancelled ongoing brightness adaptation calls (%s) for '%s'",
brightness_task,
light_id,
)
brightness_task.cancel()
if (
which in ("both", "color")
and color_task is not None
and color_task is not brightness_task
and not color_task.done()
):
if color_task is not None and not color_task.done():
_LOGGER.debug(
"Cancelled ongoing color adaptation calls (%s) for '%s'",
color_task,
@ -2269,7 +2332,11 @@ class AdaptiveLightingManager:
"""Reset the 'manual_control' status of the lights."""
for light in lights:
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):
timer.cancel()
self.our_last_state_on_change.pop(light, None)
@ -2319,11 +2386,29 @@ class AdaptiveLightingManager:
self.turn_off_event[eid] = event
self.reset(eid)
def on(eid: str, event: Event) -> None:
async def on(eid: str, event: Event) -> None:
task = self.sleep_tasks.get(eid)
if task is not None:
task.cancel()
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)
if (
timer is not None
@ -2351,7 +2436,7 @@ class AdaptiveLightingManager:
event.context.id,
)
for eid in entity_ids:
on(eid, event)
await on(eid, event)
elif service == SERVICE_TOGGLE:
_LOGGER.debug(
@ -2366,7 +2451,7 @@ class AdaptiveLightingManager:
if state.state == STATE_ON: # is turning off
off(eid, event)
elif state.state == STATE_OFF: # is turning on
on(eid, event)
await on(eid, event)
async def state_changed_event_listener(
self,
@ -2489,68 +2574,95 @@ class AdaptiveLightingManager:
event,
)
def is_manually_controlled(
async def update_manually_controlled_from_event(
self,
switch: AdaptiveSwitch,
light: str,
force: bool,
adapt_brightness: bool,
adapt_color: bool,
) -> bool:
"""Check if the light has been 'on' and is now manually controlled."""
manual_control = self.manual_control.setdefault(light, False)
if manual_control:
# Manually controlled until light is turned on and off
return True
) -> None:
"""Check if the light has been manually controlled by the latest turn on event."""
if not switch._take_over_control:
return
turn_on_event = self.turn_on_event.get(light)
if (
turn_on_event is not None
and not self.is_proactively_adapting(turn_on_event.context.id)
and not is_our_context(turn_on_event.context)
and not force
turn_on_event is None
or self.is_proactively_adapting(turn_on_event.context.id)
or is_our_context(turn_on_event.context)
or force
):
keys = turn_on_event.data[ATTR_SERVICE_DATA].keys()
if (
(adapt_color and COLOR_ATTRS.intersection(keys))
or (adapt_brightness and BRIGHTNESS_ATTRS.intersection(keys))
or (ATTR_FLASH in keys)
or (ATTR_EFFECT in keys)
):
# Light was already on and 'light.turn_on' was not called by
# the adaptive_lighting integration.
manual_control = True
_fire_manual_control_event(switch, light, turn_on_event.context)
_LOGGER.debug(
"'%s' was already on and 'light.turn_on' was not called by the"
" adaptive_lighting integration (context.id='%s'), the Adaptive"
" Lighting will stop adapting the light until the switch or the"
" light turns off and then on again.",
light,
turn_on_event.context.id,
)
return manual_control
return
turn_on_attributes = get_light_control_attributes(
turn_on_event.data[ATTR_SERVICE_DATA],
)
if not turn_on_attributes:
return
# Light was already on and 'light.turn_on' was not called by
# the adaptive_lighting integration.
self.add_manual_control_attributes(light, turn_on_attributes)
switch.fire_manual_control_event(light, turn_on_event.context)
_LOGGER.debug(
"'%s' was already on and 'light.turn_on' was not called by the"
" adaptive_lighting integration (context.id='%s'), the Adaptive"
" Lighting will stop adapting %s of the light until the switch or the"
" light turns off and then on again.",
light,
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(
self,
switch: AdaptiveSwitch,
light: str,
adapt_brightness: bool,
adapt_color: bool,
context: Context, # just for logging
) -> bool:
) -> LightControlAttributes:
"""Has the light made a significant change since last update.
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
detected, we mark the light as 'manually controlled' until the light
or switch is turned 'off' and 'on' again.
calling 'light.turn_on', so outside of Home Assistant.
"""
assert switch._detect_non_ha_changes
last_service_data = self.last_service_data.get(light)
if last_service_data is None:
return False
return LightControlAttributes.NONE
# Update state and check for a manual change not done in HA.
# Ensure HASS is correctly updating your light's state with
# light.turn_on calls if any problems arise. This
@ -2559,33 +2671,32 @@ class AdaptiveLightingManager:
refreshed_state = self.hass.states.get(light)
assert refreshed_state is not None
changed = _attributes_have_changed(
changed_attributes = _attributes_have_changed(
old_attributes=last_service_data,
new_attributes=refreshed_state.attributes,
light=light,
adapt_brightness=adapt_brightness,
adapt_color=adapt_color,
context=context,
)
if changed:
if changed_attributes:
_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,
light,
refreshed_state.attributes,
last_service_data,
context.id,
)
return True
_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
return changed_attributes
def _off_to_on_state_event_is_from_turn_on(
self,
@ -2745,12 +2856,12 @@ class AdaptiveLightingManager:
entity_id,
service_data,
)
if any(
attr in service_data
for attr in COLOR_ATTRS | BRIGHTNESS_ATTRS | {ATTR_EFFECT}
):
self.mark_as_manual_control(entity_id)
manual_control_attributes = get_light_control_attributes(service_data)
if manual_control_attributes:
self.set_manual_control_attributes(entity_id, manual_control_attributes)
return True
return False

View file

@ -53,8 +53,9 @@
"brightness_mode": "brightness_mode",
"brightness_mode_time_dark": "brightness_mode_time_dark",
"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`! 🔒",
"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": "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. 🔒",
"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",
"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. 🕵️",
@ -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_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. 📈📉.",
"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. ⏲️",
"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. ⏲️"
@ -145,7 +147,7 @@
"name": "lights"
},
"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"
}
}
@ -251,11 +253,15 @@
"name": "min_sunset_time"
},
"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"
},
"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": {
"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"
},
"transition": {