From 89c90adde040dab49882416e1dcb270212423411 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Fri, 28 Jul 2023 15:17:17 -0700 Subject: [PATCH] Only start adapting on `light.turn_on` when `detect_non_ha_changes: false` to prevent unwanted light turn ons (#663) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fixes in maybe_cancel_adjusting to possibly fix accidental turn on * simplify logic in maybe_cancel_adjusting * Add logging statements * only control if turn_on called * more logs * add TODO * Add _state_event_is_from_our_turn_on * Ignore off->on state switches that are not accociated with light.turn_on * improve logging * rename * Update docs * Update README.md, strings.json, and services.yaml * Add caution message to README * Change order of emojis * Update README.md, strings.json, and services.yaml * Check that platform is not None * log the call * fix args * Do not re-add already added configs * Do not re-add already added configs * Use async_remove * remove unused code * [pre-commit.ci] pre-commit autoupdate (#627) updates: - [github.com/astral-sh/ruff-pre-commit: v0.0.279 → v0.0.280](https://github.com/astral-sh/ruff-pre-commit/compare/v0.0.279...v0.0.280) - [github.com/psf/black: 23.3.0 → 23.7.0](https://github.com/psf/black/compare/23.3.0...23.7.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Bas Nijholt * Extra logging statement * Add to README * log context_id * return right indent * move comment * Skip on self.manager.is_proactively_adapting --------- Co-authored-by: github-actions[bot] Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- README.md | 74 +++---- custom_components/adaptive_lighting/const.py | 7 +- .../adaptive_lighting/hass_utils.py | 6 +- .../adaptive_lighting/services.yaml | 2 +- .../adaptive_lighting/strings.json | 4 +- custom_components/adaptive_lighting/switch.py | 182 +++++++++++------- .../adaptive_lighting/translations/en.json | 4 +- tests/test_switch.py | 2 +- 8 files changed, 171 insertions(+), 110 deletions(-) diff --git a/README.md b/README.md index 20a21dad..d1d7aa16 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,11 @@ Adaptive Lighting is designed to automatically detect when you or another source When this occurs, the affected light is marked as "manually controlled," and Adaptive Lighting will not make further adjustments until the light is turned off and back on or reset using the `adaptive_lighting.set_manual_control` service call. This feature is available when `take_over_control` is enabled. -Additionally, enabling detect_non_ha_changes allows Adaptive Lighting to detect all state changes, including those made outside of Home Assistant, by comparing the light's state to its previously used settings. +Additionally, enabling `detect_non_ha_changes` allows Adaptive Lighting to detect all state changes, including those made outside of Home Assistant, by comparing the light's state to its previously used settings. The `adaptive_lighting.manual_control` event is fired when a light is marked as "manually controlled," allowing for integration with automations 🤖. +> ⚠️ **_Caution: Some lights might falsely indicate an 'on' state, which could result in lights turning on unexpectedly. Disable `detect_non_ha_changes` if you encounter such issues._** + ## :books: Table of Contents @@ -91,38 +93,38 @@ The YAML and frontend configuration methods support all of the options listed be -| Variable name | Description | Default | Type | -|:-------------------------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:---------------|:-------------------------------------| -| `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` | -| `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 | -| `sleep_transition` | Duration of transition when "sleep mode" is toggled in seconds. 😴 | `1` | `float` 0-6553 | -| `transition` | Duration of transition when lights change, in seconds. 🕑 | `45` | `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` | -| `interval` | Frequency to adapt the lights, in seconds. 🔄 | `90` | `int > 0` | -| `min_brightness` | Minimum brightness percentage. 💡 | `1` | `int` 1-100 | -| `max_brightness` | Maximum brightness percentage. 💡 | `100` | `int` 1-100 | -| `min_color_temp` | Warmest color temperature in Kelvin. 🔥 | `2000` | `int` 1000-10000 | -| `max_color_temp` | Coldest color temperature in Kelvin. ❄️ | `5500` | `int` 1000-10000 | -| `sleep_brightness` | Brightness percentage of lights in sleep mode. 😴 | `1` | `int` 1-100 | -| `sleep_rgb_or_color_temp` | Use either `"rgb_color"` or `"color_temp"` in sleep mode. 🌙 | `color_temp` | one of `['color_temp', 'rgb_color']` | -| `sleep_color_temp` | Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is `color_temp`) in Kelvin. 😴 | `1000` | `int` 1000-10000 | -| `sleep_rgb_color` | RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is "rgb_color"). 🌈 | `[255, 56, 0]` | RGB color | -| `sunrise_time` | Set a fixed time (HH:MM:SS) for sunrise. 🌅 | `None` | `str` | -| `max_sunrise_time` | Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier real sunrises. 🌅 | `None` | `str` | -| `sunrise_offset` | Adjust sunrise time with a positive or negative offset in seconds. ⏰ | `0` | `int` | -| `sunset_time` | Set a fixed time (HH:MM:SS) for sunset. 🌇 | `None` | `str` | -| `min_sunset_time` | Set the earliest virtual sunset time (HH:MM:SS), allowing for later real sunsets. 🌇 | `None` | `str` | -| `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` | -| `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` | -| `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` | -| `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 | -| `skip_redundant_commands` | Skip sending adaptation commands whose target state already equals the light's known state. Minimizes network traffic and improves the adaptation responsivity in some situations. Disable if physical light states get out of sync with HA's recorded state. | `False` | `bool` | +| Variable name | Description | Default | Type | +|:-------------------------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:---------------|:-------------------------------------| +| `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` | +| `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 | +| `sleep_transition` | Duration of transition when "sleep mode" is toggled in seconds. 😴 | `1` | `float` 0-6553 | +| `transition` | Duration of transition when lights change, in seconds. 🕑 | `45` | `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` | +| `interval` | Frequency to adapt the lights, in seconds. 🔄 | `90` | `int > 0` | +| `min_brightness` | Minimum brightness percentage. 💡 | `1` | `int` 1-100 | +| `max_brightness` | Maximum brightness percentage. 💡 | `100` | `int` 1-100 | +| `min_color_temp` | Warmest color temperature in Kelvin. 🔥 | `2000` | `int` 1000-10000 | +| `max_color_temp` | Coldest color temperature in Kelvin. ❄️ | `5500` | `int` 1000-10000 | +| `sleep_brightness` | Brightness percentage of lights in sleep mode. 😴 | `1` | `int` 1-100 | +| `sleep_rgb_or_color_temp` | Use either `"rgb_color"` or `"color_temp"` in sleep mode. 🌙 | `color_temp` | one of `['color_temp', 'rgb_color']` | +| `sleep_color_temp` | Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is `color_temp`) in Kelvin. 😴 | `1000` | `int` 1000-10000 | +| `sleep_rgb_color` | RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is "rgb_color"). 🌈 | `[255, 56, 0]` | RGB color | +| `sunrise_time` | Set a fixed time (HH:MM:SS) for sunrise. 🌅 | `None` | `str` | +| `max_sunrise_time` | Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier real sunrises. 🌅 | `None` | `str` | +| `sunrise_offset` | Adjust sunrise time with a positive or negative offset in seconds. ⏰ | `0` | `int` | +| `sunset_time` | Set a fixed time (HH:MM:SS) for sunset. 🌇 | `None` | `str` | +| `min_sunset_time` | Set the earliest virtual sunset time (HH:MM:SS), allowing for later real sunsets. 🌇 | `None` | `str` | +| `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` | +| `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` | +| `detect_non_ha_changes` | Detects and halts adaptations for non-`light.turn_on` state changes. Needs `take_over_control` enabled. 🕵️ Caution: ⚠️ Some lights might falsely indicate an 'on' state, which could result in lights turning on unexpectedly. Disable this feature if you encounter such issues. | `False` | `bool` | +| `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 | +| `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 | +| `skip_redundant_commands` | Skip sending adaptation commands whose target state already equals the light's known state. Minimizes network traffic and improves the adaptation responsivity in some situations. Disable if physical light states get out of sync with HA's recorded state. | `False` | `bool` | @@ -341,7 +343,11 @@ Adaptive Lighting sends more commands to lights than a typical human user would. - Unresponsive lights. - Home Assistant reporting incorrect light states, causing Adaptive Lighting to inadvertently turn lights back on. -Most issues that appear to be caused by Adaptive Lighting are actually due to unrelated problems. Addressing these issues will significantly improve your Home Assistant experience. +Most issues that appear to be caused by Adaptive Lighting are actually due to unrelated problems. +Addressing these issues will significantly improve your Home Assistant experience. + +In case lights are suddenly turning on by themselves, this is most likely due to the light incorrectly reporting an "on" state to Home Assistant, leading to an undesired Adaptive Lighting action. +To prevent adapting in cases *where the state of the light is suddenly "on" and only adapt if there is an associated `light.turn_on` service call*, set `detect_non_ha_changes: false`. #### :signal_strength: WiFi Networks diff --git a/custom_components/adaptive_lighting/const.py b/custom_components/adaptive_lighting/const.py index 64b96583..94e9a50b 100644 --- a/custom_components/adaptive_lighting/const.py +++ b/custom_components/adaptive_lighting/const.py @@ -29,8 +29,11 @@ CONF_DETECT_NON_HA_CHANGES, DEFAULT_DETECT_NON_HA_CHANGES = ( False, ) DOCS[CONF_DETECT_NON_HA_CHANGES] = ( - "Detect non-`light.turn_on` state changes and stop adapting lights. " - "Requires `take_over_control`. 🕵️" + "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." ) CONF_INCLUDE_CONFIG_IN_ATTRIBUTES, DEFAULT_INCLUDE_CONFIG_IN_ATTRIBUTES = ( diff --git a/custom_components/adaptive_lighting/hass_utils.py b/custom_components/adaptive_lighting/hass_utils.py index cb9b8ab4..ba5bb8b0 100644 --- a/custom_components/adaptive_lighting/hass_utils.py +++ b/custom_components/adaptive_lighting/hass_utils.py @@ -53,7 +53,11 @@ def setup_service_call_interceptor( call.data = ReadOnlyDict(data) except Exception as e: # noqa: BLE001 # Blindly catch all exceptions to avoid breaking light.turn_on - _LOGGER.error("Error in service_func_proxy: %s", e) + _LOGGER.error( + "Error for call '%s' in service_func_proxy: '%s'", + call.data, + e, + ) # Call original service handler with processed data await existing_service.job.target(call) diff --git a/custom_components/adaptive_lighting/services.yaml b/custom_components/adaptive_lighting/services.yaml index cd25811b..5b857b32 100644 --- a/custom_components/adaptive_lighting/services.yaml +++ b/custom_components/adaptive_lighting/services.yaml @@ -226,7 +226,7 @@ change_switch_settings: selector: boolean: null detect_non_ha_changes: - description: Detect non-`light.turn_on` state changes and stop adapting lights. Requires `take_over_control`. 🕵️ + 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.' required: false example: false selector: diff --git a/custom_components/adaptive_lighting/strings.json b/custom_components/adaptive_lighting/strings.json index d142c7b7..b6d62b0b 100644 --- a/custom_components/adaptive_lighting/strings.json +++ b/custom_components/adaptive_lighting/strings.json @@ -43,7 +43,7 @@ "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`). 🔄", "take_over_control": "take_over_control: Disable Adaptive Lighting if another source calls `light.turn_on` while lights are on and being adapted. Note that this calls `homeassistant.update_entity` every `interval`! 🔒", - "detect_non_ha_changes": "detect_non_ha_changes: Detect non-`light.turn_on` state changes and stop adapting lights. Requires `take_over_control`. 🕵️", + "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.", "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. ⏲️", "adapt_delay": "adapt_delay: Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️", @@ -215,7 +215,7 @@ "name": "take_over_control" }, "detect_non_ha_changes": { - "description": "Detect non-`light.turn_on` state changes and stop adapting lights. Requires `take_over_control`. 🕵️", + "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.", "name": "detect_non_ha_changes" }, "transition": { diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index bd530f36..7cacf435 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -594,6 +594,7 @@ async def async_setup_entry( # noqa: PLR0915 if k not in skip: args[vol.Optional(k)] = valid platform = entity_platform.current_platform.get() + assert platform is not None platform.async_register_entity_service( SERVICE_CHANGE_SWITCH_SETTINGS, args, @@ -855,9 +856,9 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): self._icon = ICON_MAIN self._state = None - # Tracks 'off' → 'on' state changes - self._on_to_off_event: dict[str, Event] = {} # Tracks 'on' → 'off' state changes + self._on_to_off_event: dict[str, Event] = {} + # Tracks 'off' → 'on' state changes self._off_to_on_event: dict[str, Event] = {} # Locks that prevent light adjusting when waiting for a light to 'turn_off' self._locks: dict[str, asyncio.Lock] = {} @@ -1443,10 +1444,11 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): else: _LOGGER.debug( "%s: Calling _adapt_light from _update_attrs_and_maybe_adapt_lights:" - " '%s' with transition %s", + " '%s' with transition %s and context.id=%s", self._name, light, transition, + context.id, ) await self._adapt_light(light, transition, context=context) @@ -1471,12 +1473,24 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): old_state = event.data.get("old_state") new_state = event.data.get("new_state") entity_id = event.data.get("entity_id") - if ( - old_state is not None - and old_state.state == STATE_OFF - and new_state is not None - and new_state.state == STATE_ON - ): + + if old_state is None or new_state is None: + return + + if old_state.state == STATE_ON and new_state.state == STATE_OFF: + # Tracks 'on' → 'off' state changes + self._on_to_off_event[entity_id] = event + self.manager.reset(entity_id) + _LOGGER.debug( + "%s: Detected an 'on' → 'off' event for '%s' with context.id='%s'", + self._name, + entity_id, + event.context.id, + ) + + if old_state.state == STATE_OFF and new_state.state == STATE_ON: + # Tracks 'off' → 'on' state changes + self._off_to_on_event[entity_id] = event _LOGGER.debug( "%s: Detected an 'off' → 'on' event for '%s' with context.id='%s'", self._name, @@ -1484,13 +1498,36 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): event.context.id, ) + if ( + not self._detect_non_ha_changes + and not self.manager.is_proactively_adapting(event.context.id) + and not self.manager._off_to_on_state_event_is_from_turn_on( + entity_id, + event, + ) + ): + # If we don't detect non-HA changes, we're only adjusting lights that + # were turned on by HA. If the light was turned on by something else, + # we don't adjust it (e.g., when HA suddenly reports it as on). + # Sometimes the light incorrectly reports itself as on when it's + # actually off. This code path will ensure that the light is + # not controlled by Adaptive Lighting. + _LOGGER.debug( + "%s: Ignoring 'off' → 'on' event for '%s' with context.id='%s'" + " because 'light.turn_on' was not called by HA and" + " 'detect_non_ha_changes' is False", + self._name, + entity_id, + event.context.id, + ) + self.manager.mark_as_manual_control(entity_id) + return + if event.context.parent_id and not self.manager.is_proactively_adapting( event.context.id, ): self.manager.reset(entity_id, reset_manual_control=False) - # Tracks 'off' → 'on' state changes - self._off_to_on_event[entity_id] = event lock = self._locks.setdefault(entity_id, asyncio.Lock()) async with lock: if await self.manager.maybe_cancel_adjusting( @@ -1527,15 +1564,6 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): force=True, context=self.create_context("light_event", parent=event.context), ) - elif ( - old_state is not None - and old_state.state == STATE_ON - and new_state is not None - and new_state.state == STATE_OFF - ): - # Tracks 'off' → 'on' state changes - self._on_to_off_event[entity_id] = event - self.manager.reset(entity_id) class SimpleSwitch(SwitchEntity, RestoreEntity): @@ -2365,6 +2393,8 @@ class AdaptiveLightingManager: detected, we mark the light as 'manually controlled' until the light or switch is turned 'off' and 'on' again. """ + assert switch._detect_non_ha_changes + last_service_data = self.last_service_data.get(light) if last_service_data is None: return None @@ -2379,44 +2409,49 @@ class AdaptiveLightingManager: # Ensure HASS is correctly updating your light's state with # light.turn_on calls if any problems arise. This # can happen e.g. using zigbee2mqtt with 'report: false' in device settings. - if switch._detect_non_ha_changes: + await self.hass.helpers.entity_component.async_update_entity(light) + refreshed_state = self.hass.states.get(light) + + changed = compare_to( + old_attributes=last_service_data, + new_attributes=refreshed_state.attributes, + ) + if changed: _LOGGER.debug( - "%s: 'detect_non_ha_changes: true', calling update_entity(%s)" - " and check if it's last adapt succeeded.", + "%s: State attributes of '%s' (%s) didn't change wrt 'last_service_data' (%s) (context.id=%s)", switch._name, light, + refreshed_state.attributes, + last_service_data, + context.id, ) - # This update_entity probably isn't necessary now that we're checking - # if transitions finished from our last adapt. - await self.hass.helpers.entity_component.async_update_entity(light) - refreshed_state = self.hass.states.get(light) - _LOGGER.debug( - "%s: Current state of %s: %s", - switch._name, - light, - refreshed_state, - ) - changed = compare_to( - old_attributes=last_service_data, - new_attributes=refreshed_state.attributes, - ) - if changed: - _LOGGER.debug( - "State of '%s' didn't change wrt 'last_service_data' (context.id=%s)", - light, - context.id, - ) - return True + return True _LOGGER.debug( - "%s: Light '%s' correctly matches our last adapt's service data, continuing..." - " context.id=%s.", + "%s: State attributes of '%s' (%s) changed wrt 'last_service_data' (%s) (context.id=%s)", switch._name, light, + refreshed_state.attributes, + last_service_data, context.id, ) return False - async def maybe_cancel_adjusting( # noqa: PLR0911, PLR0912 + def _off_to_on_state_event_is_from_turn_on( + self, + entity_id: str, + off_to_on_event: Event, + ) -> bool: + # Adaptive Lighting should never turn on lights itself + assert not is_our_context(off_to_on_event.context) + turn_on_event: Event | None = self.turn_on_event.get(entity_id) + id_off_to_on = off_to_on_event.context.id + return ( + turn_on_event is not None + and id_off_to_on is not None + and id_off_to_on == turn_on_event.context.id + ) + + async def maybe_cancel_adjusting( # noqa: PLR0911 self, entity_id: str, off_to_on_event: Event, @@ -2435,7 +2470,11 @@ class AdaptiveLightingManager: adjust the lights. """ if on_to_off_event is None: - # No state change has been registered before. + _LOGGER.debug( + "maybe_cancel_adjusting: No 'on' → 'off' state change has been registered before for '%s'." + " It's possible that the light was already on when Home Assistant was turned on.", + entity_id, + ) return False id_on_to_off = on_to_off_event.context.id @@ -2446,18 +2485,10 @@ class AdaptiveLightingManager: else: transition = None - turn_on_event = self.turn_on_event.get(entity_id) - if turn_on_event is None: - # This means that the light never got a 'turn_on' call that we - # registered. I am not 100% sure why this happens, but it does. - # This is a fix for #170 and #232. - return False - id_turn_on = turn_on_event.context.id - - id_off_to_on = off_to_on_event.context.id - - if id_off_to_on == id_turn_on and id_off_to_on is not None: - # State change 'off' → 'on' triggered by 'light.turn_on'. + if self._off_to_on_state_event_is_from_turn_on(entity_id, off_to_on_event): + _LOGGER.debug( + "maybe_cancel_adjusting: State change 'off' → 'on' triggered by 'light.turn_on'", + ) return False if ( @@ -2476,6 +2507,11 @@ class AdaptiveLightingManager: delta_time = (dt_util.utcnow() - on_to_off_event.time_fired).total_seconds() if delta_time > delay: + _LOGGER.debug( + "maybe_cancel_adjusting: delta_time='%s' > delay='%s'", + delta_time, + delay, + ) return False # Here we could just `return True` but because we want to prevent any updates @@ -2484,23 +2520,33 @@ class AdaptiveLightingManager: # is 'off' or the time has passed. delay -= delta_time # delta_time has passed since the 'off' → 'on' event - _LOGGER.debug("Waiting with adjusting '%s' for %s", entity_id, delay) - + _LOGGER.debug( + "maybe_cancel_adjusting: Waiting with adjusting '%s' for %s", + entity_id, + delay, + ) + total_sleep = 0 for _ in range(3): # It can happen that the actual transition time is longer than the # specified time in the 'turn_off' service. coro = asyncio.sleep(delay) + total_sleep += delay task = self.sleep_tasks[entity_id] = asyncio.ensure_future(coro) try: await task except asyncio.CancelledError: # 'light.turn_on' has been called _LOGGER.debug( - "Sleep task is cancelled due to 'light.turn_on('%s')' call", + "maybe_cancel_adjusting: Sleep task is cancelled due to 'light.turn_on('%s')' call", entity_id, ) return False if not is_on(self.hass, entity_id): + _LOGGER.debug( + "maybe_cancel_adjusting: '%s' is off after %s seconds, cancelling adaptation", + entity_id, + total_sleep, + ) return True delay = TURNING_OFF_DELAY # next time only wait this long @@ -2511,10 +2557,12 @@ class AdaptiveLightingManager: return True # Now we assume that the lights are still on and they were intended - # to be on. In case this still gives problems for some, we might - # choose to **only** adapt on 'light.turn_on' events and ignore - # other 'off' → 'on' state switches resulting from polling. That - # would mean we 'return True' here. + # to be on. + _LOGGER.debug( + "maybe_cancel_adjusting: '%s' is still on after %s seconds, assuming it was intended to be on", + entity_id, + total_sleep, + ) return False diff --git a/custom_components/adaptive_lighting/translations/en.json b/custom_components/adaptive_lighting/translations/en.json index 199c333c..358f7cbe 100644 --- a/custom_components/adaptive_lighting/translations/en.json +++ b/custom_components/adaptive_lighting/translations/en.json @@ -44,7 +44,7 @@ "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`). 🔄", "take_over_control": "take_over_control: Disable Adaptive Lighting if another source calls `light.turn_on` while lights are on and being adapted. Note that this calls `homeassistant.update_entity` every `interval`! 🔒", - "detect_non_ha_changes": "detect_non_ha_changes: Detect non-`light.turn_on` state changes and stop adapting lights. Requires `take_over_control`. 🕵️", + "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.", "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. ⏲️", "adapt_delay": "adapt_delay: Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️", @@ -216,7 +216,7 @@ "name": "take_over_control" }, "detect_non_ha_changes": { - "description": "Detect non-`light.turn_on` state changes and stop adapting lights. Requires `take_over_control`. 🕵️", + "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.", "name": "detect_non_ha_changes" }, "transition": { diff --git a/tests/test_switch.py b/tests/test_switch.py index 55aa30ea..328e493f 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -1478,7 +1478,7 @@ async def test_proactive_adaptation_with_separate_commands(hass): ) # Expect two service calls - assert len(event_context_ids) == 2 + assert len(event_context_ids) == 2, event_context_ids assert event_context_ids[0] == "test_context" assert is_our_context_id(event_context_ids[1])