Skip to content

Commit f84ee44

Browse files
authored
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
1 parent 2f599d6 commit f84ee44

File tree

12 files changed

+846
-293
lines changed

12 files changed

+846
-293
lines changed

README.md

Lines changed: 46 additions & 45 deletions
Large diffs are not rendered by default.

custom_components/adaptive_lighting/_docs_helpers.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ def _type_to_str(type_: Any) -> str: # noqa: PLR0911
4646
return "bool"
4747
if isinstance(type_, vol.All):
4848
return _format_voluptuous_instance(type_)
49+
if isinstance(type_, vol.Any):
50+
return " or ".join(_type_to_str(t) for t in type_.validators)
4951
if isinstance(type_, vol.In):
5052
return f"one of `{type_.container}`"
5153
if isinstance(type_, selector.SelectSelector):

custom_components/adaptive_lighting/adaptation_utils.py

Lines changed: 109 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
import logging
44
from collections.abc import AsyncGenerator
55
from dataclasses import dataclass
6-
from typing import Any, Literal
6+
from enum import IntFlag, auto
7+
from typing import Any
78

89
from homeassistant.components.light import (
910
ATTR_BRIGHTNESS,
@@ -12,6 +13,8 @@
1213
ATTR_BRIGHTNESS_STEP_PCT,
1314
ATTR_COLOR_NAME,
1415
ATTR_COLOR_TEMP_KELVIN,
16+
ATTR_EFFECT,
17+
ATTR_FLASH,
1518
ATTR_HS_COLOR,
1619
ATTR_RGB_COLOR,
1720
ATTR_RGBW_COLOR,
@@ -45,6 +48,41 @@
4548
ServiceData = dict[str, Any]
4649

4750

51+
class LightControlAttributes(IntFlag):
52+
"""Attributes of lights that the adaptation engine can control."""
53+
54+
NONE = 0
55+
BRIGHTNESS = auto()
56+
COLOR = auto()
57+
58+
ALL = BRIGHTNESS | COLOR
59+
60+
def __str__(self) -> str:
61+
"""Return a string representation of the attributes."""
62+
if self == LightControlAttributes.NONE:
63+
return "NONE"
64+
65+
return "|".join(
66+
member.name
67+
for member in type(self)
68+
if member is not LightControlAttributes.NONE
69+
and member in self
70+
and member.name is not None
71+
)
72+
73+
def has_any(self) -> bool:
74+
"""Determine whether any attribute is selected."""
75+
return self != LightControlAttributes.NONE
76+
77+
def has_none(self) -> bool:
78+
"""Determine whether no attribute is selected."""
79+
return self == LightControlAttributes.NONE
80+
81+
def has_all(self) -> bool:
82+
"""Determine whether all attributes are selected."""
83+
return (self & LightControlAttributes.ALL) == LightControlAttributes.ALL
84+
85+
4886
def _split_service_call_data(service_data: ServiceData) -> list[ServiceData]:
4987
"""Splits the service data by the adapted attributes.
5088
@@ -145,7 +183,7 @@ class AdaptationData:
145183
service_call_datas: AsyncGenerator[ServiceData]
146184
force: bool
147185
max_length: int
148-
which: Literal["brightness", "color", "both"]
186+
attributes: LightControlAttributes
149187
initial_sleep: bool = False
150188

151189
async def next_service_call_data(self) -> ServiceData | None:
@@ -161,7 +199,7 @@ def __str__(self) -> str:
161199
f"sleep_time={self.sleep_time}, "
162200
f"force={self.force}, "
163201
f"max_length={self.max_length}, "
164-
f"which={self.which}, "
202+
f"attributes={self.attributes}, "
165203
f"initial_sleep={self.initial_sleep}"
166204
")"
167205
)
@@ -171,20 +209,25 @@ class NoColorOrBrightnessInServiceDataError(Exception):
171209
"""Exception raised when no color or brightness attributes are found in service data."""
172210

173211

174-
def _identify_lighting_type(
212+
def _identify_light_control_attributes(
175213
service_data: ServiceData,
176-
) -> Literal["brightness", "color", "both"]:
214+
) -> LightControlAttributes:
177215
"""Extract the 'which' attribute from the service data."""
178216
has_brightness = ATTR_BRIGHTNESS in service_data
179217
has_color = any(attr in service_data for attr in COLOR_ATTRS)
180-
if has_brightness and has_color:
181-
return "both"
218+
219+
parameters = LightControlAttributes.NONE
220+
182221
if has_brightness:
183-
return "brightness"
222+
parameters |= LightControlAttributes.BRIGHTNESS
184223
if has_color:
185-
return "color"
186-
msg = f"Invalid service_data, no brightness or color attributes found: {service_data=}"
187-
raise NoColorOrBrightnessInServiceDataError(msg)
224+
parameters |= LightControlAttributes.COLOR
225+
226+
if parameters == LightControlAttributes.NONE:
227+
msg = f"Invalid service_data, no brightness or color attributes found: {service_data=}"
228+
raise NoColorOrBrightnessInServiceDataError(msg)
229+
230+
return parameters
188231

189232

190233
def prepare_adaptation_data(
@@ -220,7 +263,7 @@ def prepare_adaptation_data(
220263
filter_by_state,
221264
)
222265

223-
lighting_type = _identify_lighting_type(service_data)
266+
attributes = _identify_light_control_attributes(service_data)
224267

225268
return AdaptationData(
226269
entity_id=entity_id,
@@ -229,5 +272,58 @@ def prepare_adaptation_data(
229272
service_call_datas=service_data_iterator,
230273
force=force,
231274
max_length=service_datas_length,
232-
which=lighting_type,
275+
attributes=attributes,
233276
)
277+
278+
279+
def manual_control_event_attribute_to_flags(
280+
manual_control_attribute: bool | str,
281+
) -> LightControlAttributes:
282+
"""Convert manual control event data to light control attributes."""
283+
if isinstance(manual_control_attribute, bool) and manual_control_attribute:
284+
return LightControlAttributes.ALL
285+
if manual_control_attribute == "brightness":
286+
return LightControlAttributes.BRIGHTNESS
287+
if manual_control_attribute == "color":
288+
return LightControlAttributes.COLOR
289+
return LightControlAttributes.NONE
290+
291+
292+
def has_brightness_attribute(
293+
service_data: ServiceData,
294+
) -> bool:
295+
"""Determine whether the service data contains brightness attributes."""
296+
return any(attr in BRIGHTNESS_ATTRS for attr in service_data)
297+
298+
299+
def has_color_attribute(
300+
service_data: ServiceData,
301+
) -> bool:
302+
"""Determine whether the service data contains color attributes."""
303+
return any(attr in COLOR_ATTRS for attr in service_data)
304+
305+
306+
def has_effect_attribute(
307+
service_data: ServiceData,
308+
) -> bool:
309+
"""Determine whether the service data contains effect attributes."""
310+
return ATTR_FLASH in service_data or ATTR_EFFECT in service_data
311+
312+
313+
def get_light_control_attributes(
314+
service_data: ServiceData,
315+
) -> LightControlAttributes:
316+
"""Get the light control attributes affected by the service call data."""
317+
parameters = LightControlAttributes.NONE
318+
319+
if has_brightness_attribute(service_data):
320+
parameters |= LightControlAttributes.BRIGHTNESS
321+
322+
if has_color_attribute(service_data):
323+
parameters |= LightControlAttributes.COLOR
324+
325+
if has_effect_attribute(service_data):
326+
parameters |= LightControlAttributes.BRIGHTNESS
327+
parameters |= LightControlAttributes.COLOR
328+
329+
return parameters

custom_components/adaptive_lighting/const.py

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Constants for the Adaptive Lighting integration."""
22

33
from datetime import timedelta
4+
from enum import Enum
45
from typing import Any
56

67
import homeassistant.helpers.config_validation as cv
@@ -16,6 +17,14 @@
1617

1718
DOMAIN = "adaptive_lighting"
1819

20+
21+
class TakeOverControlMode(Enum):
22+
"""Modes for pausing adaptation when control of a light is taken over externally."""
23+
24+
PAUSE_ALL = "pause_all"
25+
PAUSE_CHANGED = "pause_changed"
26+
27+
1928
DOCS = {CONF_ENTITY_ID: "Entity ID of the switch. 📝"}
2029

2130

@@ -34,6 +43,7 @@
3443
"Needs `take_over_control` enabled. 🕵️ "
3544
"Caution: ⚠️ Some lights might falsely indicate an 'on' state, which could result "
3645
"in lights turning on unexpectedly. "
46+
"Note that this calls `homeassistant.update_entity` every `interval`! "
3747
"Disable this feature if you encounter such issues."
3848
)
3949

@@ -188,9 +198,19 @@
188198

189199
CONF_TAKE_OVER_CONTROL, DEFAULT_TAKE_OVER_CONTROL = "take_over_control", True
190200
DOCS[CONF_TAKE_OVER_CONTROL] = (
191-
"Disable Adaptive Lighting if another source calls `light.turn_on` while lights "
192-
"are on and being adapted. Note that this calls `homeassistant.update_entity` "
193-
"every `interval`! 🔒"
201+
"Pause adaptation of individual lights and hand over (manual) control to other sources that "
202+
"issue `light.turn_on` calls for lights that are on. 🔒"
203+
)
204+
205+
CONF_TAKE_OVER_CONTROL_MODE, DEFAULT_TAKE_OVER_CONTROL_MODE = (
206+
"take_over_control_mode",
207+
TakeOverControlMode.PAUSE_ALL.value,
208+
)
209+
DOCS[CONF_TAKE_OVER_CONTROL_MODE] = (
210+
"The adaptation pausing mode when other sources change brightness and/or color of lights. "
211+
"`pause_all` always pauses both brightness and color adaptation. "
212+
"`pause_changed` pauses the adaptation of only the changed attributes and continues adapting "
213+
"unchanged attributes, e.g., continues color adaptation when only brightness was changed."
194214
)
195215

196216
CONF_TRANSITION, DEFAULT_TRANSITION = "transition", 45
@@ -284,8 +304,9 @@
284304
"light as being `manually controlled`. 📝",
285305
CONF_LIGHTS: "entity_id(s) of lights, if not specified, all lights in the "
286306
"switch are selected. 💡",
287-
CONF_MANUAL_CONTROL: 'Whether to add ("true") or remove ("false") the '
288-
'light from the "manual_control" list. 🔒',
307+
CONF_MANUAL_CONTROL: 'Whether to add ("true") or remove ("false") all '
308+
'adapted attributes of the light from the "manual_control" list, or the '
309+
"name of an attribute for selective addition. 🔒",
289310
}
290311

291312
DOCS_APPLY = {
@@ -351,6 +372,20 @@ def int_between(min_int: int, max_int: int) -> vol.All:
351372
(CONF_BRIGHTNESS_MODE_TIME_DARK, DEFAULT_BRIGHTNESS_MODE_TIME_DARK, int),
352373
(CONF_BRIGHTNESS_MODE_TIME_LIGHT, DEFAULT_BRIGHTNESS_MODE_TIME_LIGHT, int),
353374
(CONF_TAKE_OVER_CONTROL, DEFAULT_TAKE_OVER_CONTROL, bool),
375+
(
376+
CONF_TAKE_OVER_CONTROL_MODE,
377+
DEFAULT_TAKE_OVER_CONTROL_MODE,
378+
selector.SelectSelector( # type: ignore[arg-type]
379+
selector.SelectSelectorConfig(
380+
options=[
381+
TakeOverControlMode.PAUSE_ALL.value,
382+
TakeOverControlMode.PAUSE_CHANGED.value,
383+
],
384+
multiple=False,
385+
mode=selector.SelectSelectorMode.DROPDOWN,
386+
),
387+
),
388+
),
354389
(CONF_DETECT_NON_HA_CHANGES, DEFAULT_DETECT_NON_HA_CHANGES, bool),
355390
(
356391
CONF_AUTORESET_CONTROL,
@@ -446,6 +481,9 @@ def apply_service_schema(initial_transition: int = 1) -> vol.Schema:
446481
{
447482
vol.Optional(CONF_ENTITY_ID): cv.entity_ids, # type: ignore[arg-type]
448483
vol.Optional(CONF_LIGHTS, default=[]): cv.entity_ids, # type: ignore[arg-type]
449-
vol.Optional(CONF_MANUAL_CONTROL, default=True): cv.boolean,
484+
vol.Optional(CONF_MANUAL_CONTROL, default=True): vol.Any(
485+
cv.boolean,
486+
vol.In(["brightness", "color"]),
487+
),
450488
},
451489
)

custom_components/adaptive_lighting/services.yaml

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ set_manual_control:
5757
domain: light
5858
multiple: true
5959
manual_control:
60-
description: Whether to add ("true") or remove ("false") the light from the "manual_control" list. 🔒
60+
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. 🔒
6161
example: true
6262
default: true
6363
selector:
@@ -220,13 +220,22 @@ change_switch_settings:
220220
selector:
221221
time: null
222222
take_over_control:
223-
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`! 🔒
223+
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. 🔒
224224
required: false
225225
example: true
226226
selector:
227227
boolean: null
228+
take_over_control_mode:
229+
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.
230+
required: false
231+
example: pause_changed
232+
selector:
233+
select:
234+
options:
235+
- pause_all
236+
- pause_changed
228237
detect_non_ha_changes:
229-
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.'
238+
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.'
230239
required: false
231240
example: false
232241
selector:

custom_components/adaptive_lighting/strings.json

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,9 @@
5252
"brightness_mode": "brightness_mode",
5353
"brightness_mode_time_dark": "brightness_mode_time_dark",
5454
"brightness_mode_time_light": "brightness_mode_time_light",
55-
"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`! 🔒",
56-
"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.",
55+
"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. 🔒",
56+
"take_over_control_mode": "take_over_control_mode",
57+
"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.",
5758
"autoreset_control_seconds": "autoreset_control_seconds",
5859
"only_once": "only_once: Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄",
5960
"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 @@
8586
"brightness_mode": "Brightness mode to use. Possible values are `default`, `linear`, and `tanh` (uses `brightness_mode_time_dark` and `brightness_mode_time_light`). 📈",
8687
"brightness_mode_time_dark": "(Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness before/after sunrise/sunset. 📈📉",
8788
"brightness_mode_time_light": "(Ignored if `brightness_mode='default'`) The duration in seconds to ramp up/down the brightness after/before sunrise/sunset. 📈📉.",
89+
"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.",
8890
"autoreset_control_seconds": "Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️",
8991
"send_split_delay": "Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️",
9092
"adapt_delay": "Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️"
@@ -144,7 +146,7 @@
144146
"name": "lights"
145147
},
146148
"manual_control": {
147-
"description": "Whether to add (\"true\") or remove (\"false\") the light from the \"manual_control\" list. 🔒",
149+
"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. 🔒",
148150
"name": "manual_control"
149151
}
150152
}
@@ -250,11 +252,15 @@
250252
"name": "min_sunset_time"
251253
},
252254
"take_over_control": {
253-
"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`! 🔒",
255+
"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. 🔒",
254256
"name": "take_over_control"
255257
},
258+
"take_over_control_mode": {
259+
"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.",
260+
"name": "take_over_control_mode"
261+
},
256262
"detect_non_ha_changes": {
257-
"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.",
263+
"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.",
258264
"name": "detect_non_ha_changes"
259265
},
260266
"transition": {

0 commit comments

Comments
 (0)