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

@ -90,37 +90,39 @@ 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 |
| `prefer_rgb_color` | Whether to prefer RGB color adjustment over light color temperature when possible. 🌈 | `False` | `bool` | | `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 |
| `include_config_in_attributes` | Show all options as attributes on the switch in Home Assistant when set to `true`. 📝 | `False` | `bool` | | `prefer_rgb_color` | Whether to prefer RGB color adjustment over light color temperature when possible. 🌈 | `False` | `bool` |
| `initial_transition` | Duration of the first transition when lights turn from `off` to `on` in seconds. ⏲️ | `1` | `float` 0-6553 | | `include_config_in_attributes` | Show all options as attributes on the switch in Home Assistant when set to `true`. 📝 | `False` | `bool` |
| `sleep_transition` | Duration of transition when "sleep mode" is toggled 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 |
| `transition` | Duration of transition when lights change, in seconds. 🕑 | `45` | `float` 0-6553 | | `sleep_transition` | Duration of transition when "sleep mode" is toggled in seconds. 😴 | `1` | `float` 0-6553 |
| `transition_until_sleep` | When enabled, Adaptive Lighting will treat sleep settings as the minimum, transitioning to these values after sunset. 🌙 | `False` | `bool` | | `transition` | Duration of transition when lights change, in seconds. 🕑 | `45` | `float` 0-6553 |
| `interval` | Frequency to adapt the lights, in seconds. 🔄 | `90` | `int > 0` | | `transition_until_sleep` | When enabled, Adaptive Lighting will treat sleep settings as the minimum, transitioning to these values after sunset. 🌙 | `False` | `bool` |
| `min_brightness` | Minimum brightness percentage. 💡 | `1` | `int` 1-100 | | `interval` | Frequency to adapt the lights, in seconds. 🔄 | `90` | `int > 0` |
| `max_brightness` | Maximum brightness percentage. 💡 | `100` | `int` 1-100 | | `min_brightness` | Minimum brightness percentage. 💡 | `1` | `int` 1-100 |
| `min_color_temp` | Warmest color temperature in Kelvin. 🔥 | `2000` | `int` 1000-10000 | | `max_brightness` | Maximum brightness percentage. 💡 | `100` | `int` 1-100 |
| `max_color_temp` | Coldest color temperature in Kelvin. ❄️ | `5500` | `int` 1000-10000 | | `min_color_temp` | Warmest color temperature in Kelvin. 🔥 | `2000` | `int` 1000-10000 |
| `sleep_brightness` | Brightness percentage of lights in sleep mode. 😴 | `1` | `int` 1-100 | | `max_color_temp` | Coldest color temperature in Kelvin. ❄️ | `5500` | `int` 1000-10000 |
| `sleep_rgb_or_color_temp` | Use either `"rgb_color"` or `"color_temp"` in sleep mode. 🌙 | `color_temp` | one of `['color_temp', 'rgb_color']` | | `sleep_brightness` | Brightness percentage of lights in sleep mode. 😴 | `1` | `int` 1-100 |
| `sleep_color_temp` | Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is `color_temp`) in Kelvin. 😴 | `1000` | `int` 1000-10000 | | `sleep_rgb_or_color_temp` | Use either `"rgb_color"` or `"color_temp"` in sleep mode. 🌙 | `color_temp` | one of `['color_temp', 'rgb_color']` |
| `sleep_rgb_color` | RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is "rgb_color"). 🌈 | `[255, 56, 0]` | RGB color | | `sleep_color_temp` | Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is `color_temp`) in Kelvin. 😴 | `1000` | `int` 1000-10000 |
| `sunrise_time` | Set a fixed time (HH:MM:SS) for sunrise. 🌅 | `None` | `str` | | `sleep_rgb_color` | RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is "rgb_color"). 🌈 | `[255, 56, 0]` | RGB color |
| `max_sunrise_time` | Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier real sunrises. 🌅 | `None` | `str` | | `sunrise_time` | Set a fixed time (HH:MM:SS) for sunrise. 🌅 | `None` | `str` |
| `sunrise_offset` | Adjust sunrise time with a positive or negative offset in seconds. ⏰ | `0` | `int` | | `max_sunrise_time` | Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier real sunrises. 🌅 | `None` | `str` |
| `sunset_time` | Set a fixed time (HH:MM:SS) for sunset. 🌇 | `None` | `str` | | `sunrise_offset` | Adjust sunrise time with a positive or negative offset in seconds. ⏰ | `0` | `int` |
| `min_sunset_time` | Set the earliest virtual sunset time (HH:MM:SS), allowing for later real sunsets. 🌇 | `None` | `str` | | `sunset_time` | Set a fixed time (HH:MM:SS) for sunset. 🌇 | `None` | `str` |
| `sunset_offset` | Adjust sunset time with a positive or negative offset in seconds. ⏰ | `0` | `int` | | `min_sunset_time` | Set the earliest virtual sunset time (HH:MM:SS), allowing for later real sunsets. 🌇 | `None` | `str` |
| `only_once` | Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄 | `False` | `bool` | | `sunset_offset` | Adjust sunset time with a positive or negative offset in seconds. ⏰ | `0` | `int` |
| `take_over_control` | Disable Adaptive Lighting if another source calls `light.turn_on` while lights are on and being adapted. Note that this calls `homeassistant.update_entity` every `interval`! 🔒 | `True` | `bool` | | `only_once` | Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄 | `False` | `bool` |
| `detect_non_ha_changes` | Detect non-`light.turn_on` state changes and stop adapting lights. Requires `take_over_control`. 🕵️ | `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` |
| `separate_turn_on_commands` | Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀 | `False` | `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` |
| `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 | | `detect_non_ha_changes` | Detect non-`light.turn_on` state changes and stop adapting lights. Requires `take_over_control`. 🕵️ | `False` | `bool` |
| `adapt_delay` | Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️ | `0` | `float > 0` | | `separate_turn_on_commands` | Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀 | `False` | `bool` |
| `autoreset_control_seconds` | Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️ | `0` | `int` 0-31536000 | | `send_split_delay` | Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️ | `0` | `int` 0-10000 |
| `adapt_delay` | Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️ | `0` | `float > 0` |
| `autoreset_control_seconds` | Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️ | `0` | `int` 0-31536000 |
<!-- END_OUTPUT --> <!-- END_OUTPUT -->

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,15 +776,33 @@ 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:
_LOGGER.debug( if last_adapt_attempt:
"Brightness of '%s' significantly changed from %s to %s with" changed = check_direction_change(
" context.id='%s'", last_brightness,
light, current_brightness,
last_brightness, last_adapt_attempt[ATTR_BRIGHTNESS],
current_brightness, )
context.id, _LOGGER.debug(
) "altdetect: Brightness of '%s' changed from %s to %s intended %s with"
return True " context.id='%s' Significant? %s",
light,
last_brightness,
current_brightness,
last_adapt_attempt[ATTR_BRIGHTNESS],
context.id,
changed,
)
return changed
else:
_LOGGER.debug(
"Brightness of '%s' significantly changed from %s to %s with"
" context.id='%s'",
light,
last_brightness,
current_brightness,
context.id,
)
return True
if ( if (
adapt_color adapt_color
@ -782,15 +812,33 @@ 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:
_LOGGER.debug( if last_adapt_attempt:
"Color temperature of '%s' significantly changed from %s to %s with" changed = check_direction_change(
" context.id='%s'", last_color_temp,
light, current_color_temp,
last_color_temp, last_adapt_attempt[ATTR_COLOR_TEMP_KELVIN],
current_color_temp, )
context.id, _LOGGER.debug(
) "altdetect: Color temperature of '%s' changed from %s to %s intended %s with"
return True " 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(
"Color temperature of '%s' significantly changed from %s to %s with"
" context.id='%s'",
light,
last_color_temp,
current_color_temp,
context.id,
)
return True
if ( if (
adapt_color adapt_color
@ -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,58 +1118,95 @@ 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:")
switch._take_over_control = True for i in range(2):
assert switch._take_over_control if i == 0:
switch._detect_non_ha_changes = True _LOGGER.debug("Test detect_non_ha_changes before a transition:")
assert switch._detect_non_ha_changes switch._take_over_control = True
await asyncio.sleep(transition_used / 3) assert switch._take_over_control
# Ensure the timer still exists switch._detect_non_ha_changes = True
timer = listener.transition_timers.get(ENTITY_LIGHT) assert switch._detect_non_ha_changes
assert timer and timer.is_running() switch._alt_detect_method = False
last_service_data = deepcopy(current_service_data) assert not switch._alt_detect_method
await update() elif i == 1:
assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] _LOGGER.debug("Test alt_detect_method before a transition:")
await update() switch._take_over_control = True
assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] assert switch._take_over_control
timer = listener.transition_timers.get(ENTITY_LIGHT) switch._detect_non_ha_changes = False
assert timer and timer.is_running() assert not switch._detect_non_ha_changes
# Ensure the light did not adapt during the transition. switch._alt_detect_method = True
assert last_service_data == current_service_data assert switch._alt_detect_method
await asyncio.sleep(transition_used / 3)
# Ensure the timer still exists
timer = listener.transition_timers.get(ENTITY_LIGHT)
assert timer and timer.is_running()
last_service_data = deepcopy(current_service_data)
await update()
assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT]
await update()
assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT]
timer = listener.transition_timers.get(ENTITY_LIGHT)
assert timer and timer.is_running()
# Ensure the light did not adapt during the transition.
assert last_service_data == current_service_data
# 6. Assert everything after the transition finishes. # 6. Assert everything after the transition finishes.
await asyncio.sleep(transition_used) await asyncio.sleep(transition_used)
assert listener.last_state_change.get(ENTITY_LIGHT)
assert len(listener.last_state_change[ENTITY_LIGHT]) == total_events
# Timer should be done and reset now.
# This is the assert that I can't fix.
timer = listener.transition_timers.get(ENTITY_LIGHT)
assert not timer or not timer.is_running()
# build last service data for i in range(2):
await update(force=False) 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
# force=True should not reset manual control. assert listener.last_state_change.get(ENTITY_LIGHT)
await turn_light(True, brightness=40) if i == 1:
await turn_light(True, brightness=20) total_events = 2
await update(force=False) assert len(listener.last_state_change[ENTITY_LIGHT]) == total_events
assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] # Timer should be done and reset now.
await update(force=True) timer = listener.transition_timers.get(ENTITY_LIGHT)
assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] assert not timer or not timer.is_running()
# turn light off then on should reset manual control. # build last service data
await turn_light(False) await update(force=False)
await turn_light(True)
assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT]
await turn_light(True, brightness=50) # force=True should not reset manual control.
_LOGGER.debug("Test: Brightness set to %s", 50) await turn_light(True, brightness=40)
await turn_light(True, brightness=20)
await update(force=False)
assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT]
await update(force=True)
assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT]
# On next update ENTITY_LIGHT should be marked as manually controlled # turn light off then on should reset manual control.
await update(force=False) await turn_light(False)
assert switch.turn_on_off_listener.last_service_data.get(ENTITY_LIGHT) is not None await turn_light(True)
assert switch.turn_on_off_listener.last_state_change.get(ENTITY_LIGHT) is not None assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT]
assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT]
await turn_light(True, brightness=50)
_LOGGER.debug("Test: Brightness set to %s", 50)
# On next update ENTITY_LIGHT should be marked as manually controlled
await update(force=False)
assert (
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]
@pytest.mark.dependency( @pytest.mark.dependency(