Compare commits

...

25 commits

Author SHA1 Message Date
Bas Nijholt
ea73228ef8
Merge branch 'main' into add-watched-lights 2023-04-27 12:59:37 -07:00
Bas Nijholt
f20135b120
Merge branch 'main' into add-watched-lights 2023-04-26 19:18:34 -07:00
Benjamin Auquite
7069d693b8
Merge branch 'main' into add-watched-lights 2023-04-10 12:52:19 -05:00
Benjamin Auquite
adde5b5645 fix pytest 2023-04-10 10:34:24 -05:00
Benjamin Auquite
2c5eaec2d3 Revert "Update const.py"
This reverts commit e773af07ca.
2023-04-10 07:27:25 -05:00
Benjamin Auquite
e773af07ca Update const.py 2023-04-10 07:20:23 -05:00
Benjamin Auquite
c167f59ccd Update docs 2023-04-10 06:09:14 -05:00
Benjamin Auquite
6220df57d1 fix the test 2023-04-10 05:59:40 -05:00
Benjamin Auquite
ff292c2c1b Merge branch 'add-watched-lights' of https://github.com/basnijholt/adaptive-lighting into add-watched-lights 2023-04-10 05:39:17 -05:00
Benjamin Auquite
4fdf42bea1 small fixes 2023-04-10 05:39:05 -05:00
github-actions[bot]
19d4468f63 Update README.md, strings.json, and services.yaml 2023-04-10 10:36:54 +00:00
Benjamin Auquite
70c90181f0
Merge b6a92650a9 into e30b7debe5 2023-04-10 10:34:49 +00:00
Benjamin Auquite
b6a92650a9 Merge branch 'add-watched-lights' of https://github.com/basnijholt/adaptive-lighting into add-watched-lights 2023-04-10 05:34:43 -05:00
Benjamin Auquite
784eed09b3 squash merge alt_detect_method again 2023-04-10 05:34:33 -05:00
github-actions[bot]
ae04ab49a8 Update README.md, strings.json, and services.yaml 2023-04-10 10:34:12 +00:00
Benjamin Auquite
cfbd65f976
Merge 3c5ea73341 into e30b7debe5 2023-04-10 10:32:34 +00:00
Benjamin Auquite
3c5ea73341 Update switch.py 2023-04-10 05:32:27 -05:00
Benjamin Auquite
2f750694d9 Merge branch 'add-watched-lights' of https://github.com/basnijholt/adaptive-lighting into add-watched-lights 2023-04-10 05:31:13 -05:00
Benjamin Auquite
429251b45f update docs 2023-04-10 05:31:04 -05:00
github-actions[bot]
7a9dc8f24a Update README.md, strings.json, and services.yaml 2023-04-10 10:29:37 +00:00
Benjamin Auquite
37de4244b1
Merge d52ea53dcc into e30b7debe5 2023-04-10 10:28:01 +00:00
Benjamin Auquite
d52ea53dcc Merge branch 'main' into add-watched-lights 2023-04-10 05:27:43 -05:00
Benjamin Auquite
dbc47c8a36 Update switch.py 2023-04-10 05:26:11 -05:00
Benjamin Auquite
2db675a434 merge with alt_detect_method
required for this PR
2023-04-10 05:25:44 -05:00
Benjamin Auquite
dced97b5ca merge with origin\main 2023-04-10 05:21:51 -05:00
6 changed files with 297 additions and 125 deletions

View file

@ -91,8 +91,9 @@ The YAML and frontend configuration methods support all of the options listed be
<!-- START_OUTPUT --> <!-- START_OUTPUT -->
<!-- THIS CONTENT IS AUTOMATICALLY GENERATED --> <!-- THIS CONTENT IS AUTOMATICALLY GENERATED -->
| 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 |
| `watched_lights` | Use this dictionary of lights to check for manually controlled events in addition to the main lights 🌟 Example: {light.watch_light: light.main_light} will fire manually controlled events to light.main_light | `{}` | list of `entity_id`s |
| `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` |
| `include_config_in_attributes` | Show all options as attributes on the switch in Home Assistant when set to `true`. 📝 | `False` | `bool` | | `include_config_in_attributes` | Show all options as attributes on the switch in Home Assistant when set to `true`. 📝 | `False` | `bool` |
| `initial_transition` | Duration of the first transition when lights turn from `off` to `on` in seconds. ⏲️ | `1` | `float` 0-6553 | | `initial_transition` | Duration of the first transition when lights turn from `off` to `on` in seconds. ⏲️ | `1` | `float` 0-6553 |
@ -116,6 +117,7 @@ The YAML and frontend configuration methods support all of the options listed be
| `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` |
| `only_once` | Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄 | `False` | `bool` | | `only_once` | Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄 | `False` | `bool` |
| `take_over_control` | Disable Adaptive Lighting if another source calls `light.turn_on` while lights are on and being adapted. Note that this calls `homeassistant.update_entity` every `interval`! 🔒 | `True` | `bool` | | `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` |
| `alt_detect_method` | alt_detect_method: When true, will check for any significant changes in the opposite direction of where adaptive-lighting tried to adapt last. This is an alternative to 'detect_non_ha_changes' (default: false) | `False` | `bool` |
| `detect_non_ha_changes` | Detect non-`light.turn_on` state changes and stop adapting lights. Requires `take_over_control`. 🕵️ | `False` | `bool` | | `detect_non_ha_changes` | Detect non-`light.turn_on` state changes and stop adapting lights. Requires `take_over_control`. 🕵️ | `False` | `bool` |
| `separate_turn_on_commands` | Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀 | `False` | `bool` | | `separate_turn_on_commands` | Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀 | `False` | `bool` |
| `send_split_delay` | Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️ | `0` | `int` 0-10000 | | `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 |

View file

@ -33,6 +33,12 @@ DOCS[CONF_DETECT_NON_HA_CHANGES] = (
"Requires `take_over_control`. 🕵️" "Requires `take_over_control`. 🕵️"
) )
CONF_ALT_DETECT_METHOD, DEFAULT_ALT_DETECT_METHOD = "alt_detect_method", False
DOCS[CONF_ALT_DETECT_METHOD] = (
"alt_detect_method: When true, will check for any significant changes in the opposite direction"
" of where adaptive-lighting tried to adapt last."
" This is an alternative to 'detect_non_ha_changes' (default: false)"
)
CONF_INCLUDE_CONFIG_IN_ATTRIBUTES, DEFAULT_INCLUDE_CONFIG_IN_ATTRIBUTES = ( CONF_INCLUDE_CONFIG_IN_ATTRIBUTES, DEFAULT_INCLUDE_CONFIG_IN_ATTRIBUTES = (
"include_config_in_attributes", "include_config_in_attributes",
False, False,
@ -176,6 +182,15 @@ DOCS[CONF_AUTORESET_CONTROL] = (
"Set to 0 to disable. ⏲️" "Set to 0 to disable. ⏲️"
) )
CONF_WATCHED_LIGHTS, DEFAULT_WATCHED_LIGHTS = "watched_lights", {}
DOCS[CONF_WATCHED_LIGHTS] = (
"Use this dictionary of lights to check for manually controlled events"
" in addition to the main lights 🌟"
" Requires `alt_detect_method` to be True."
" Example: `watched_lights: {light.watch_light: light.main_light}` will fire"
" manually controlled events to `light.main_light`"
)
SLEEP_MODE_SWITCH = "sleep_mode_switch" SLEEP_MODE_SWITCH = "sleep_mode_switch"
ADAPT_COLOR_SWITCH = "adapt_color_switch" ADAPT_COLOR_SWITCH = "adapt_color_switch"
ADAPT_BRIGHTNESS_SWITCH = "adapt_brightness_switch" ADAPT_BRIGHTNESS_SWITCH = "adapt_brightness_switch"
@ -225,6 +240,7 @@ def int_between(min_int, max_int):
VALIDATION_TUPLES = [ VALIDATION_TUPLES = [
(CONF_LIGHTS, DEFAULT_LIGHTS, cv.entity_ids), (CONF_LIGHTS, DEFAULT_LIGHTS, cv.entity_ids),
(CONF_WATCHED_LIGHTS, DEFAULT_WATCHED_LIGHTS, dict),
(CONF_PREFER_RGB_COLOR, DEFAULT_PREFER_RGB_COLOR, bool), (CONF_PREFER_RGB_COLOR, DEFAULT_PREFER_RGB_COLOR, bool),
(CONF_INCLUDE_CONFIG_IN_ATTRIBUTES, DEFAULT_INCLUDE_CONFIG_IN_ATTRIBUTES, bool), (CONF_INCLUDE_CONFIG_IN_ATTRIBUTES, DEFAULT_INCLUDE_CONFIG_IN_ATTRIBUTES, bool),
(CONF_INITIAL_TRANSITION, DEFAULT_INITIAL_TRANSITION, VALID_TRANSITION), (CONF_INITIAL_TRANSITION, DEFAULT_INITIAL_TRANSITION, VALID_TRANSITION),
@ -262,6 +278,7 @@ VALIDATION_TUPLES = [
(CONF_SUNSET_OFFSET, DEFAULT_SUNSET_OFFSET, int), (CONF_SUNSET_OFFSET, DEFAULT_SUNSET_OFFSET, int),
(CONF_ONLY_ONCE, DEFAULT_ONLY_ONCE, bool), (CONF_ONLY_ONCE, DEFAULT_ONLY_ONCE, bool),
(CONF_TAKE_OVER_CONTROL, DEFAULT_TAKE_OVER_CONTROL, bool), (CONF_TAKE_OVER_CONTROL, DEFAULT_TAKE_OVER_CONTROL, bool),
(CONF_ALT_DETECT_METHOD, DEFAULT_ALT_DETECT_METHOD, bool),
(CONF_DETECT_NON_HA_CHANGES, DEFAULT_DETECT_NON_HA_CHANGES, bool), (CONF_DETECT_NON_HA_CHANGES, DEFAULT_DETECT_NON_HA_CHANGES, bool),
(CONF_SEPARATE_TURN_ON_COMMANDS, DEFAULT_SEPARATE_TURN_ON_COMMANDS, bool), (CONF_SEPARATE_TURN_ON_COMMANDS, DEFAULT_SEPARATE_TURN_ON_COMMANDS, bool),
(CONF_SEND_SPLIT_DELAY, DEFAULT_SEND_SPLIT_DELAY, int_between(0, 10000)), (CONF_SEND_SPLIT_DELAY, DEFAULT_SEND_SPLIT_DELAY, int_between(0, 10000)),

View file

@ -20,6 +20,7 @@
"description": "All settings for a Adaptive Lighting component. The option names correspond with the YAML settings. No options are shown if you have this entry defined in YAML.", "description": "All settings for a Adaptive Lighting component. The option names correspond with the YAML settings. No options are shown if you have this entry defined in YAML.",
"data": { "data": {
"lights": "lights: List of light entity_ids to be controlled (may be empty). 🌟", "lights": "lights: List of light entity_ids to be controlled (may be empty). 🌟",
"watched_lights": "watched_lights: Use this dictionary of lights to check for manually controlled events in addition to the main lights 🌟 Example: {light.watch_light: light.main_light} will fire manually controlled events to light.main_light",
"prefer_rgb_color": "prefer_rgb_color: Whether to prefer RGB color adjustment over light color temperature when possible. 🌈", "prefer_rgb_color": "prefer_rgb_color: Whether to prefer RGB color adjustment over light color temperature when possible. 🌈",
"include_config_in_attributes": "include_config_in_attributes: Show all options as attributes on the switch in Home Assistant when set to `true`. 📝", "include_config_in_attributes": "include_config_in_attributes: Show all options as attributes on the switch in Home Assistant when set to `true`. 📝",
"initial_transition": "initial_transition: Duration of the first transition when lights turn from `off` to `on` in seconds. ⏲️", "initial_transition": "initial_transition: Duration of the first transition when lights turn from `off` to `on` in seconds. ⏲️",
@ -43,6 +44,7 @@
"sunset_offset": "sunset_offset: Adjust sunset time with a positive or negative offset in seconds. ⏰", "sunset_offset": "sunset_offset: Adjust sunset time with a positive or negative offset in 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`). 🔄",
"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: 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`! 🔒",
"alt_detect_method": "alt_detect_method: alt_detect_method: When true, will check for any significant changes in the opposite direction of where adaptive-lighting tried to adapt last. This is an alternative to 'detect_non_ha_changes' (default: false)",
"detect_non_ha_changes": "detect_non_ha_changes: Detect non-`light.turn_on` state changes and stop adapting lights. Requires `take_over_control`. 🕵️", "detect_non_ha_changes": "detect_non_ha_changes: Detect non-`light.turn_on` state changes and stop adapting lights. Requires `take_over_control`. 🕵️",
"separate_turn_on_commands": "separate_turn_on_commands: Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀", "separate_turn_on_commands": "separate_turn_on_commands: Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀",
"send_split_delay": "send_split_delay: Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️", "send_split_delay": "send_split_delay: Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️",

View file

@ -104,6 +104,7 @@ from .const import (
ATTR_TURN_ON_OFF_LISTENER, ATTR_TURN_ON_OFF_LISTENER,
CONF_ADAPT_DELAY, CONF_ADAPT_DELAY,
CONF_ADAPT_UNTIL_SLEEP, CONF_ADAPT_UNTIL_SLEEP,
CONF_ALT_DETECT_METHOD,
CONF_AUTORESET_CONTROL, CONF_AUTORESET_CONTROL,
CONF_DETECT_NON_HA_CHANGES, CONF_DETECT_NON_HA_CHANGES,
CONF_INCLUDE_CONFIG_IN_ATTRIBUTES, CONF_INCLUDE_CONFIG_IN_ATTRIBUTES,
@ -134,6 +135,7 @@ from .const import (
CONF_TRANSITION, CONF_TRANSITION,
CONF_TURN_ON_LIGHTS, CONF_TURN_ON_LIGHTS,
CONF_USE_DEFAULTS, CONF_USE_DEFAULTS,
CONF_WATCHED_LIGHTS,
CONST_COLOR, CONST_COLOR,
DOMAIN, DOMAIN,
EXTRA_VALIDATION, EXTRA_VALIDATION,
@ -708,6 +710,15 @@ def color_difference_redmean(
return math.sqrt(red_term + green_term + blue_term) return math.sqrt(red_term + green_term + blue_term)
def check_direction_change(last: int, current: int, last_adapt_value: int) -> bool:
_LOGGER.debug("compare direction: current value %s to last value %s", current, last)
if last_adapt_value < last: # Value adapting down
return current > last or current < last_adapt_value
elif last_adapt_value > last: # Value adapting up
return current < last or current > last_adapt_value
return False
# All comparisons should be done with RGB since # All comparisons should be done with RGB since
# converting anything to color temp is inaccurate. # converting anything to color temp is inaccurate.
def _convert_attributes(attributes: dict[str, Any]) -> dict[str, Any]: def _convert_attributes(attributes: dict[str, Any]) -> dict[str, Any]:
@ -750,6 +761,7 @@ def _attributes_have_changed(
adapt_brightness: bool, adapt_brightness: bool,
adapt_color: bool, adapt_color: bool,
context: Context, context: Context,
last_adapt_attempt=None,
) -> bool: ) -> bool:
if adapt_color: if adapt_color:
old_attributes, new_attributes = _add_missing_attributes( old_attributes, new_attributes = _add_missing_attributes(
@ -764,6 +776,24 @@ def _attributes_have_changed(
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:
if last_adapt_attempt:
changed = check_direction_change(
last_brightness,
current_brightness,
last_adapt_attempt[ATTR_BRIGHTNESS],
)
_LOGGER.debug(
"altdetect: Brightness of '%s' changed from %s to %s intended %s with"
" context.id='%s' Significant? %s",
light,
last_brightness,
current_brightness,
last_adapt_attempt[ATTR_BRIGHTNESS],
context.id,
changed,
)
return changed
else:
_LOGGER.debug( _LOGGER.debug(
"Brightness of '%s' significantly changed from %s to %s with" "Brightness of '%s' significantly changed from %s to %s with"
" context.id='%s'", " context.id='%s'",
@ -782,6 +812,24 @@ def _attributes_have_changed(
last_color_temp = old_attributes[ATTR_COLOR_TEMP_KELVIN] last_color_temp = old_attributes[ATTR_COLOR_TEMP_KELVIN]
current_color_temp = new_attributes[ATTR_COLOR_TEMP_KELVIN] current_color_temp = new_attributes[ATTR_COLOR_TEMP_KELVIN]
if abs(current_color_temp - last_color_temp) > COLOR_TEMP_CHANGE: if abs(current_color_temp - last_color_temp) > COLOR_TEMP_CHANGE:
if last_adapt_attempt:
changed = check_direction_change(
last_color_temp,
current_color_temp,
last_adapt_attempt[ATTR_COLOR_TEMP_KELVIN],
)
_LOGGER.debug(
"altdetect: Color temperature of '%s' changed from %s to %s intended %s with"
" context.id='%s' Significant? %s",
light,
last_color_temp,
current_color_temp,
last_adapt_attempt[ATTR_COLOR_TEMP_KELVIN],
context.id,
changed,
)
return changed
else:
_LOGGER.debug( _LOGGER.debug(
"Color temperature of '%s' significantly changed from %s to %s with" "Color temperature of '%s' significantly changed from %s to %s with"
" context.id='%s'", " context.id='%s'",
@ -910,13 +958,20 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
self._transition = data[CONF_TRANSITION] self._transition = data[CONF_TRANSITION]
self._adapt_delay = data[CONF_ADAPT_DELAY] self._adapt_delay = data[CONF_ADAPT_DELAY]
self._send_split_delay = data[CONF_SEND_SPLIT_DELAY] self._send_split_delay = data[CONF_SEND_SPLIT_DELAY]
self._watched_lights = data[CONF_WATCHED_LIGHTS]
self._take_over_control = data[CONF_TAKE_OVER_CONTROL] self._take_over_control = data[CONF_TAKE_OVER_CONTROL]
self._alt_detect_method = data[CONF_ALT_DETECT_METHOD]
self._detect_non_ha_changes = data[CONF_DETECT_NON_HA_CHANGES] self._detect_non_ha_changes = data[CONF_DETECT_NON_HA_CHANGES]
if not data[CONF_TAKE_OVER_CONTROL] and data[CONF_DETECT_NON_HA_CHANGES]: if not data[CONF_TAKE_OVER_CONTROL] and (
_LOGGER.warning( data[CONF_ALT_DETECT_METHOD] or data[CONF_DETECT_NON_HA_CHANGES]
"%s: Config mismatch: 'detect_non_ha_changes: true' " ):
"requires 'take_over_control' to be enabled. Adjusting config " _LOGGER.warn(
"and continuing setup with `take_over_control: true`.", "%s: Config mismatch: 'alt_detect_method: true'"
" OR 'detect_non_ha_changes: true' are set in config, however required"
" variable 'take_over_control' is turned off. Please check your"
" configuration to ensure desired functionality. We will now"
" enable 'take_over_control' and continue setting up the"
" adaptive-lighting integration normally.",
self._name, self._name,
) )
self._take_over_control = True self._take_over_control = True
@ -1270,6 +1325,8 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
transition: int | None, transition: int | None,
force: bool, force: bool,
context: Context | None, context: Context | None,
adapt_brightness: bool | None = None,
adapt_color: bool | None = None,
) -> None: ) -> None:
assert context is not None assert context is not None
_LOGGER.debug( _LOGGER.debug(
@ -1284,13 +1341,17 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
adapt_brightness = self.adapt_brightness_switch.is_on adapt_brightness = self.adapt_brightness_switch.is_on
adapt_color = self.adapt_color_switch.is_on adapt_color = self.adapt_color_switch.is_on
for light in lights: all_lights = {k: k for k in lights}
if not is_on(self.hass, light): all_lights.update(self._watched_lights)
for wlight, mlight in all_lights.items():
if not is_on(self.hass, mlight):
continue continue
manually_controlled = self.turn_on_off_listener.is_manually_controlled( manually_controlled = self.turn_on_off_listener.is_manually_controlled(
self, self,
light, wlight,
mlight,
force, force,
adapt_brightness, adapt_brightness,
adapt_color, adapt_color,
@ -1301,7 +1362,8 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
and not force and not force
and await self.turn_on_off_listener.significant_change( and await self.turn_on_off_listener.significant_change(
self, self,
light, wlight,
mlight,
adapt_brightness, adapt_brightness,
adapt_color, adapt_color,
context, context,
@ -1313,13 +1375,15 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
_LOGGER.debug( _LOGGER.debug(
"%s: '%s' is being manually controlled, stop adapting, context.id=%s.", "%s: '%s' is being manually controlled, stop adapting, context.id=%s.",
self._name, self._name,
light, mlight,
context.id, context.id,
) )
else: else:
_fire_manual_control_event(self, light, context) _fire_manual_control_event(self, mlight, context)
else: else:
await self._adapt_light(light, transition, force=force, context=context) await self._adapt_light(
wlight, transition, force=force, context=context
)
async def _sleep_mode_switch_state_event(self, event: Event) -> None: async def _sleep_mode_switch_state_event(self, event: Event) -> None:
if not match_switch_state_event(event, (STATE_ON, STATE_OFF)): if not match_switch_state_event(event, (STATE_ON, STATE_OFF)):
@ -1922,18 +1986,19 @@ class TurnOnOffListener:
def is_manually_controlled( def is_manually_controlled(
self, self,
switch: AdaptiveSwitch, switch: AdaptiveSwitch,
light: str, wlight: str,
mlight: str,
force: bool, force: bool,
adapt_brightness: bool, adapt_brightness: bool,
adapt_color: bool, adapt_color: bool,
) -> bool: ) -> bool:
"""Check if the light has been 'on' and is now manually controlled.""" """Check if the light has been 'on' and is now manually controlled."""
manual_control = self.manual_control.setdefault(light, False) manual_control = self.manual_control.setdefault(wlight, False)
if manual_control: if manual_control:
# Manually controlled until light is turned on and off # Manually controlled until light is turned on and off
return True return True
turn_on_event = self.turn_on_event.get(light) turn_on_event = self.turn_on_event.get(wlight)
if ( if (
turn_on_event is not None turn_on_event is not None
and not is_our_context(turn_on_event.context) and not is_our_context(turn_on_event.context)
@ -1946,13 +2011,13 @@ class TurnOnOffListener:
# Light was already on and 'light.turn_on' was not called by # Light was already on and 'light.turn_on' was not called by
# the adaptive_lighting integration. # the adaptive_lighting integration.
manual_control = True manual_control = True
_fire_manual_control_event(switch, light, turn_on_event.context) _fire_manual_control_event(switch, wlight, turn_on_event.context)
_LOGGER.debug( _LOGGER.debug(
"'%s' was already on and 'light.turn_on' was not called by the" "'%s' was already on and 'light.turn_on' was not called by the"
" adaptive_lighting integration (context.id='%s'), the Adaptive" " adaptive_lighting integration (context.id='%s'), the Adaptive"
" Lighting will stop adapting the light until the switch or the" " Lighting will stop adapting the light until the switch or the"
" light turns off and then on again.", " light turns off and then on again.",
light, mlight,
turn_on_event.context.id, turn_on_event.context.id,
) )
return manual_control return manual_control
@ -1960,7 +2025,8 @@ class TurnOnOffListener:
async def significant_change( async def significant_change(
self, self,
switch: AdaptiveSwitch, switch: AdaptiveSwitch,
light: str, wlight: str,
mlight: str,
adapt_brightness: bool, adapt_brightness: bool,
adapt_color: bool, adapt_color: bool,
context: Context, context: Context,
@ -1972,35 +2038,77 @@ class TurnOnOffListener:
detected, we mark the light as 'manually controlled' until the light detected, we mark the light as 'manually controlled' until the light
or switch is turned 'off' and 'on' again. or switch is turned 'off' and 'on' again.
""" """
last_service_data = self.last_service_data.get(light) last_service_data = self.last_service_data.get(wlight)
if last_service_data is None: if last_service_data is None:
return return
compare_to = functools.partial( compare_to = functools.partial(
_attributes_have_changed, _attributes_have_changed,
light=light, light=wlight,
adapt_brightness=adapt_brightness, adapt_brightness=adapt_brightness,
adapt_color=adapt_color, adapt_color=adapt_color,
context=context, context=context,
) )
if switch._alt_detect_method or wlight != mlight:
old_states: list[State] = self.last_state_change[wlight]
_LOGGER.debug("Total state changes detected: %s", len(old_states))
_LOGGER.debug(
"%s: 'alt_detect_method: true', check all state changes made to light %s",
switch._name,
wlight,
)
for index, old_state in enumerate(old_states):
# The first entry of old_states should always be the
# same as last_service_data[light], and can be ignored.
if index <= 1:
continue
_LOGGER.debug(
"%s: checking for a manual change between index %s and %s...",
switch._name,
index,
index - 1,
)
prior_state = old_states[index - 1]
if compare_to(
old_attributes=prior_state.attributes,
new_attributes=old_state.attributes,
last_adapt_attempt=last_service_data,
):
_LOGGER.info(
"Found unexpected state_change event for %s nr. %s (context.id=%s)"
" old_state=%s\nprior_state=%s",
wlight,
index,
context.id,
old_state,
prior_state,
)
_LOGGER.info(
"We will now set %s as manually controlled. (context.id=%s)",
wlight,
context.id,
)
return True
# 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
# can happen e.g. using zigbee2mqtt with 'report: false' in device settings. # can happen e.g. using zigbee2mqtt with 'report: false' in device settings.
if switch._detect_non_ha_changes: if switch._detect_non_ha_changes:
if wlight != mlight:
return # only supported with alt_detect_method
_LOGGER.debug( _LOGGER.debug(
"%s: 'detect_non_ha_changes: true', calling update_entity(%s)" "%s: 'detect_non_ha_changes: true', calling update_entity(%s)"
" and check if it's last adapt succeeded.", " and check if it's last adapt succeeded.",
switch._name, switch._name,
light, wlight,
) )
# This update_entity probably isn't necessary now that we're checking # This update_entity probably isn't necessary now that we're checking
# if transitions finished from our last adapt. # if transitions finished from our last adapt.
await self.hass.helpers.entity_component.async_update_entity(light) await self.hass.helpers.entity_component.async_update_entity(wlight)
refreshed_state = self.hass.states.get(light) refreshed_state = self.hass.states.get(wlight)
_LOGGER.debug( _LOGGER.debug(
"%s: Current state of %s: %s", "%s: Current state of %s: %s",
switch._name, switch._name,
light, wlight,
refreshed_state, refreshed_state,
) )
changed = compare_to( changed = compare_to(
@ -2010,7 +2118,7 @@ class TurnOnOffListener:
if changed: if changed:
_LOGGER.debug( _LOGGER.debug(
"State of '%s' didn't change wrt 'last_service_data' (context.id=%s)", "State of '%s' didn't change wrt 'last_service_data' (context.id=%s)",
light, wlight,
context.id, context.id,
) )
return True return True
@ -2018,7 +2126,7 @@ class TurnOnOffListener:
"%s: Light '%s' correctly matches our last adapt's service data, continuing..." "%s: Light '%s' correctly matches our last adapt's service data, continuing..."
" context.id=%s.", " context.id=%s.",
switch._name, switch._name,
light, wlight,
context.id, context.id,
) )
return False return False

View file

@ -21,6 +21,7 @@
"description": "All settings for a Adaptive Lighting component. The option names correspond with the YAML settings. No options are shown if you have the adaptive_lighting entry defined in your YAML configuration.", "description": "All settings for a Adaptive Lighting component. The option names correspond with the YAML settings. No options are shown if you have the adaptive_lighting entry defined in your YAML configuration.",
"data": { "data": {
"lights": "lights: List of light entity_ids to be controlled (may be empty). 🌟", "lights": "lights: List of light entity_ids to be controlled (may be empty). 🌟",
"watched_lights": "watched_lights: Use this dictionary of lights to check for manually controlled events in addition to the main lights 🌟 Example: {light.watch_light: light.main_light} will fire manually controlled events to light.main_light",
"prefer_rgb_color": "prefer_rgb_color: Whether to prefer RGB color adjustment over light color temperature when possible. 🌈", "prefer_rgb_color": "prefer_rgb_color: Whether to prefer RGB color adjustment over light color temperature when possible. 🌈",
"include_config_in_attributes": "include_config_in_attributes: Show all options as attributes on the switch in Home Assistant when set to `true`. 📝", "include_config_in_attributes": "include_config_in_attributes: Show all options as attributes on the switch in Home Assistant when set to `true`. 📝",
"initial_transition": "initial_transition: Duration of the first transition when lights turn from `off` to `on` in seconds. ⏲️", "initial_transition": "initial_transition: Duration of the first transition when lights turn from `off` to `on` in seconds. ⏲️",
@ -44,6 +45,7 @@
"sunset_offset": "sunset_offset: Adjust sunset time with a positive or negative offset in seconds. ⏰", "sunset_offset": "sunset_offset: Adjust sunset time with a positive or negative offset in 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`). 🔄",
"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: 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`! 🔒",
"alt_detect_method": "alt_detect_method: alt_detect_method: When true, will check for any significant changes in the opposite direction of where adaptive-lighting tried to adapt last. This is an alternative to 'detect_non_ha_changes' (default: false)",
"detect_non_ha_changes": "detect_non_ha_changes: Detect non-`light.turn_on` state changes and stop adapting lights. Requires `take_over_control`. 🕵️", "detect_non_ha_changes": "detect_non_ha_changes: Detect non-`light.turn_on` state changes and stop adapting lights. Requires `take_over_control`. 🕵️",
"separate_turn_on_commands": "separate_turn_on_commands: Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀", "separate_turn_on_commands": "separate_turn_on_commands: Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀",
"send_split_delay": "send_split_delay: Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️", "send_split_delay": "send_split_delay: Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️",

View file

@ -12,6 +12,7 @@ from homeassistant.components.adaptive_lighting.const import (
ADAPT_BRIGHTNESS_SWITCH, ADAPT_BRIGHTNESS_SWITCH,
ADAPT_COLOR_SWITCH, ADAPT_COLOR_SWITCH,
ATTR_TURN_ON_OFF_LISTENER, ATTR_TURN_ON_OFF_LISTENER,
CONF_ALT_DETECT_METHOD,
CONF_AUTORESET_CONTROL, CONF_AUTORESET_CONTROL,
CONF_DETECT_NON_HA_CHANGES, CONF_DETECT_NON_HA_CHANGES,
CONF_INITIAL_TRANSITION, CONF_INITIAL_TRANSITION,
@ -23,6 +24,7 @@ from homeassistant.components.adaptive_lighting.const import (
CONF_SUNRISE_OFFSET, CONF_SUNRISE_OFFSET,
CONF_SUNRISE_TIME, CONF_SUNRISE_TIME,
CONF_SUNSET_TIME, CONF_SUNSET_TIME,
CONF_TAKE_OVER_CONTROL,
CONF_TRANSITION, CONF_TRANSITION,
CONF_TURN_ON_LIGHTS, CONF_TURN_ON_LIGHTS,
CONF_USE_DEFAULTS, CONF_USE_DEFAULTS,
@ -221,7 +223,9 @@ async def setup_lights_and_switch(hass, extra_conf=None):
CONF_SUNSET_TIME: datetime.time(SUNSET.hour), CONF_SUNSET_TIME: datetime.time(SUNSET.hour),
CONF_INITIAL_TRANSITION: 0, CONF_INITIAL_TRANSITION: 0,
CONF_TRANSITION: 0, CONF_TRANSITION: 0,
CONF_DETECT_NON_HA_CHANGES: True, CONF_ALT_DETECT_METHOD: False,
CONF_DETECT_NON_HA_CHANGES: False,
CONF_TAKE_OVER_CONTROL: True,
CONF_PREFER_RGB_COLOR: False, CONF_PREFER_RGB_COLOR: False,
CONF_MIN_COLOR_TEMP: 2500, # to not coincide with sleep_color_temp CONF_MIN_COLOR_TEMP: 2500, # to not coincide with sleep_color_temp
**(extra_conf or {}), **(extra_conf or {}),
@ -748,7 +752,7 @@ async def test_manual_control(hass):
@pytest.mark.dependency(depends=[*GLOBAL_TEST_DEPENDENCIES, "test_manual_control"]) @pytest.mark.dependency(depends=[*GLOBAL_TEST_DEPENDENCIES, "test_manual_control"])
async def test_auto_reset_manual_control(hass): async def test_auto_reset_manual_control(hass):
switch, (light, *_) = await setup_lights_and_switch( switch, (light, *_) = await setup_lights_and_switch(
hass, {CONF_AUTORESET_CONTROL: 0.1} hass, {CONF_AUTORESET_CONTROL: 0.2}
) )
context = switch.create_context("test") # needs to be passed to update method context = switch.create_context("test") # needs to be passed to update method
manual_control = switch.turn_on_off_listener.manual_control manual_control = switch.turn_on_off_listener.manual_control
@ -993,7 +997,7 @@ async def test_state_change_handlers(hass):
# [Config options]: # [Config options]:
transition_used = 2 transition_used = 2
total_events = 5 total_events = 6
async def set_brightness(val: int): async def set_brightness(val: int):
# 'Unsafe' set but we know what we're doing. # 'Unsafe' set but we know what we're doing.
@ -1114,11 +1118,24 @@ async def test_state_change_handlers(hass):
assert listener.transition_timers.get(ENTITY_LIGHT) assert listener.transition_timers.get(ENTITY_LIGHT)
# 5. Execute some checks during a transition # 5. Execute some checks during a transition
_LOGGER.debug("Test detect_non_ha_changes:")
for i in range(2):
if i == 0:
_LOGGER.debug("Test detect_non_ha_changes before a transition:")
switch._take_over_control = True switch._take_over_control = True
assert switch._take_over_control assert switch._take_over_control
switch._detect_non_ha_changes = True switch._detect_non_ha_changes = True
assert switch._detect_non_ha_changes assert switch._detect_non_ha_changes
switch._alt_detect_method = False
assert not switch._alt_detect_method
elif i == 1:
_LOGGER.debug("Test alt_detect_method before a transition:")
switch._take_over_control = True
assert switch._take_over_control
switch._detect_non_ha_changes = False
assert not switch._detect_non_ha_changes
switch._alt_detect_method = True
assert switch._alt_detect_method
await asyncio.sleep(transition_used / 3) await asyncio.sleep(transition_used / 3)
# Ensure the timer still exists # Ensure the timer still exists
timer = listener.transition_timers.get(ENTITY_LIGHT) timer = listener.transition_timers.get(ENTITY_LIGHT)
@ -1135,10 +1152,30 @@ async def test_state_change_handlers(hass):
# 6. Assert everything after the transition finishes. # 6. Assert everything after the transition finishes.
await asyncio.sleep(transition_used) await asyncio.sleep(transition_used)
for i in range(2):
if i == 0:
_LOGGER.debug("Test detect_non_ha_changes after a transition:")
switch._take_over_control = True
assert switch._take_over_control
switch._detect_non_ha_changes = True
assert switch._detect_non_ha_changes
switch._alt_detect_method = False
assert not switch._alt_detect_method
elif i == 1:
_LOGGER.debug("Test alt_detect_method after a transition:")
switch._take_over_control = True
assert switch._take_over_control
switch._detect_non_ha_changes = False
assert not switch._detect_non_ha_changes
switch._alt_detect_method = True
assert switch._alt_detect_method
assert listener.last_state_change.get(ENTITY_LIGHT) assert listener.last_state_change.get(ENTITY_LIGHT)
if i == 1:
total_events = 2
assert len(listener.last_state_change[ENTITY_LIGHT]) == total_events assert len(listener.last_state_change[ENTITY_LIGHT]) == total_events
# Timer should be done and reset now. # Timer should be done and reset now.
# This is the assert that I can't fix.
timer = listener.transition_timers.get(ENTITY_LIGHT) timer = listener.transition_timers.get(ENTITY_LIGHT)
assert not timer or not timer.is_running() assert not timer or not timer.is_running()
@ -1163,8 +1200,12 @@ async def test_state_change_handlers(hass):
# On next update ENTITY_LIGHT should be marked as manually controlled # On next update ENTITY_LIGHT should be marked as manually controlled
await update(force=False) await update(force=False)
assert switch.turn_on_off_listener.last_service_data.get(ENTITY_LIGHT) is not None assert (
assert switch.turn_on_off_listener.last_state_change.get(ENTITY_LIGHT) is not None switch.turn_on_off_listener.last_service_data.get(ENTITY_LIGHT) is not None
)
assert (
switch.turn_on_off_listener.last_state_change.get(ENTITY_LIGHT) is not None
)
assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT]