mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-09-11 22:34:04 +02:00
Correctly wait for transitions (#510)
* Add auto_reset_manual_control with async timer * cherry-pick wait for transition stuff * Update switch.py * not renamed in this branch yet. * Update switch.py * update tests * Update switch.py * merge related fix * cleanup * Revert "cleanup" This reverts commit3aa2f3242b. * Update switch.py * Update switch.py * Update switch.py * Small refactor * Move test to old position for better diffs * Revert "Small refactor" This reverts commitb986b3f77c. * Update README.md * fix the test last_state_change isn't updated quick enough. * #510 changes (#516) * Change (WIP) * Update test_switch.py * Refactor * Revert "Revert "Small refactor"" This reverts commit3731c99936. * Update README.md * Fix the test * Bump to 1.9.0 (#518) * Basic community fixes PR (#460) * Fixes #423 #423 * Do not adapt lights turned on with custom payloads. * Update switch.py * Issue fixes #423, #378, #403, #449 * quickly test #274 * Revert feature requests, this branch only has fixes. Reverted FR 274 * pre-commit fix * Create automerge.yaml * test * Delete automerge.yaml My bad. * Fix #460 and #408 * see @basnijholt 's comment in #450. * @basnijholt requested changes. --------- Co-authored-by: Bas Nijholt <bas@nijho.lt> * Undo accidental changes introduced in #509, but adds the changes from #460 (#521) * Release 1.9.1 (#522) * Bump to 1.9.1 * Add CODEOWNERS --------- Co-authored-by: Benjamin Auquite <halomastar@gmail.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * No need to wrap the reset * Remove unused attrs * Shorter log message * revert unrelated tests change * remove unused function * Use patch * Bump to 1.10.0 --------- Co-authored-by: Bas Nijholt <bas@nijho.lt> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Bas Nijholt <basnijholt@gmail.com>
This commit is contained in:
parent
b730c7cc90
commit
c7f44e472f
3 changed files with 211 additions and 119 deletions
|
|
@ -8,5 +8,5 @@
|
|||
"iot_class": "calculated",
|
||||
"issue_tracker": "https://github.com/basnijholt/adaptive-lighting/issues",
|
||||
"requirements": [],
|
||||
"version": "1.9.1"
|
||||
"version": "1.10.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from __future__ import annotations
|
|||
import asyncio
|
||||
import base64
|
||||
import bisect
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable, Coroutine
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
import datetime
|
||||
|
|
@ -802,10 +802,19 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
self._only_once = data[CONF_ONLY_ONCE]
|
||||
self._prefer_rgb_color = data[CONF_PREFER_RGB_COLOR]
|
||||
self._separate_turn_on_commands = data[CONF_SEPARATE_TURN_ON_COMMANDS]
|
||||
self._take_over_control = data[CONF_TAKE_OVER_CONTROL]
|
||||
self._transition = data[CONF_TRANSITION]
|
||||
self._adapt_delay = data[CONF_ADAPT_DELAY]
|
||||
self._send_split_delay = data[CONF_SEND_SPLIT_DELAY]
|
||||
self._take_over_control = data[CONF_TAKE_OVER_CONTROL]
|
||||
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]:
|
||||
_LOGGER.warning(
|
||||
"%s: Config mismatch: 'detect_non_ha_changes: true' "
|
||||
"requires 'take_over_control' to be enabled. Adjusting config "
|
||||
"and continuing setup with `take_over_control: true`.",
|
||||
self._name,
|
||||
)
|
||||
self._take_over_control = True
|
||||
self._auto_reset_manual_control_time = data[CONF_AUTORESET_CONTROL]
|
||||
self._expand_light_groups() # updates manual control timers
|
||||
_loc = get_astral_location(self.hass)
|
||||
|
|
@ -1128,11 +1137,23 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
)
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
if lights is None:
|
||||
lights = self._lights
|
||||
if (self._only_once and not force) or not lights:
|
||||
|
||||
if not force and self._only_once:
|
||||
return
|
||||
await self._adapt_lights(lights, transition, force, context)
|
||||
|
||||
filtered_lights = []
|
||||
for light in lights:
|
||||
# Don't adapt lights that haven't finished prior transitions.
|
||||
if force or not self.turn_on_off_listener.transition_timers.get(light):
|
||||
filtered_lights.append(light)
|
||||
|
||||
if not filtered_lights:
|
||||
return
|
||||
|
||||
await self._adapt_lights(filtered_lights, transition, force, context)
|
||||
|
||||
async def _adapt_lights(
|
||||
self,
|
||||
|
|
@ -1532,8 +1553,6 @@ class TurnOnOffListener:
|
|||
self.sleep_tasks: dict[str, asyncio.Task] = {}
|
||||
# Tracks which lights are manually controlled
|
||||
self.manual_control: dict[str, bool] = {}
|
||||
# Counts the number of times (in a row) a light had a changed state.
|
||||
self.cnt_significant_changes: dict[str, int] = defaultdict(int)
|
||||
# Track 'state_changed' events of self.lights resulting from this integration
|
||||
self.last_state_change: dict[str, list[State]] = {}
|
||||
# Track last 'service_data' to 'light.turn_on' resulting from this integration
|
||||
|
|
@ -1543,9 +1562,8 @@ class TurnOnOffListener:
|
|||
self.auto_reset_manual_control_timers: dict[str, _AsyncSingleShotTimer] = {}
|
||||
self.auto_reset_manual_control_times: dict[str, float] = {}
|
||||
|
||||
# When a state is different `max_cnt_significant_changes` times in a row,
|
||||
# mark it as manually_controlled.
|
||||
self.max_cnt_significant_changes = 2
|
||||
# Track light transitions
|
||||
self.transition_timers: dict[str, _AsyncSingleShotTimer] = {}
|
||||
|
||||
self.remove_listener = self.hass.bus.async_listen(
|
||||
EVENT_CALL_SERVICE, self.turn_on_off_event_listener
|
||||
|
|
@ -1554,6 +1572,56 @@ class TurnOnOffListener:
|
|||
EVENT_STATE_CHANGED, self.state_changed_event_listener
|
||||
)
|
||||
|
||||
def _handle_timer(
|
||||
self,
|
||||
light: str,
|
||||
timers_dict: dict[str, _AsyncSingleShotTimer],
|
||||
delay: float | None,
|
||||
reset_coroutine: Callable[[], Coroutine[Any, Any, None]],
|
||||
) -> None:
|
||||
timer = timers_dict.get(light)
|
||||
if timer is not None:
|
||||
if delay is None: # Timer object exists, but should not anymore
|
||||
timer.cancel()
|
||||
timers_dict.pop(light)
|
||||
else: # Timer object already exists, just update the delay and restart it
|
||||
timer.delay = delay
|
||||
timer.start()
|
||||
elif delay is not None: # Timer object does not exist, create it
|
||||
timer = _AsyncSingleShotTimer(delay, reset_coroutine)
|
||||
timers_dict[light] = timer
|
||||
timer.start()
|
||||
|
||||
def start_transition_timer(self, light: str) -> None:
|
||||
"""Mark a light as manually controlled."""
|
||||
_LOGGER.debug("Start transition timer for %s", light)
|
||||
last_service_data = self.last_service_data
|
||||
if (
|
||||
not last_service_data
|
||||
or light not in last_service_data
|
||||
or ATTR_TRANSITION not in last_service_data[light]
|
||||
):
|
||||
return
|
||||
|
||||
delay = last_service_data[light][ATTR_TRANSITION]
|
||||
|
||||
async def reset():
|
||||
_LOGGER.debug(
|
||||
"Transition finished for light %s",
|
||||
light,
|
||||
)
|
||||
switches = _get_switches_with_lights(self.hass, [light])
|
||||
for switch in switches:
|
||||
if not switch.is_on:
|
||||
continue
|
||||
await switch._update_attrs_and_maybe_adapt_lights(
|
||||
[light],
|
||||
force=False,
|
||||
context=switch.create_context("transit"),
|
||||
)
|
||||
|
||||
self._handle_timer(light, self.transition_timers, delay, reset)
|
||||
|
||||
def set_auto_reset_manual_control_times(self, lights: list[str], time: float):
|
||||
"""Set the time after which the lights are automatically reset."""
|
||||
if time == 0:
|
||||
|
|
@ -1576,40 +1644,28 @@ class TurnOnOffListener:
|
|||
_LOGGER.debug("Marking '%s' as manually controlled.", light)
|
||||
self.manual_control[light] = True
|
||||
delay = self.auto_reset_manual_control_times.get(light)
|
||||
timer = self.auto_reset_manual_control_timers.get(light)
|
||||
if timer is not None:
|
||||
if delay is None: # Timer object exists, but should not anymore
|
||||
timer.cancel()
|
||||
self.auto_reset_manual_control_timers.pop(light)
|
||||
else: # Timer object already exists, just update the delay and restart it
|
||||
timer.delay = delay
|
||||
timer.start()
|
||||
elif delay is not None: # Timer object does not exist, create it
|
||||
|
||||
async def reset():
|
||||
self.reset(light)
|
||||
switches = _get_switches_with_lights(self.hass, [light])
|
||||
for switch in switches:
|
||||
if not switch.is_on:
|
||||
continue
|
||||
# pylint: disable=protected-access
|
||||
await switch._update_attrs_and_maybe_adapt_lights(
|
||||
[light],
|
||||
transition=switch._initial_transition,
|
||||
force=True,
|
||||
context=switch.create_context("autoreset"),
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Auto resetting 'manual_control' status of '%s' because"
|
||||
" it was not manually controlled for %s seconds.",
|
||||
light,
|
||||
delay,
|
||||
async def reset():
|
||||
self.reset(light)
|
||||
switches = _get_switches_with_lights(self.hass, [light])
|
||||
for switch in switches:
|
||||
if not switch.is_on:
|
||||
continue
|
||||
await switch._update_attrs_and_maybe_adapt_lights(
|
||||
[light],
|
||||
transition=switch._initial_transition,
|
||||
force=True,
|
||||
context=switch.create_context("autoreset"),
|
||||
)
|
||||
assert not self.manual_control[light]
|
||||
_LOGGER.debug(
|
||||
"Auto resetting 'manual_control' status of '%s' because"
|
||||
" it was not manually controlled for %s seconds.",
|
||||
light,
|
||||
delay,
|
||||
)
|
||||
assert not self.manual_control[light]
|
||||
|
||||
timer = _AsyncSingleShotTimer(delay, reset)
|
||||
self.auto_reset_manual_control_timers[light] = timer
|
||||
timer.start()
|
||||
self._handle_timer(light, self.auto_reset_manual_control_timers, delay, reset)
|
||||
|
||||
def reset(self, *lights, reset_manual_control=True) -> None:
|
||||
"""Reset the 'manual_control' status of the lights."""
|
||||
|
|
@ -1621,7 +1677,6 @@ class TurnOnOffListener:
|
|||
timer.cancel()
|
||||
self.last_state_change.pop(light, None)
|
||||
self.last_service_data.pop(light, None)
|
||||
self.cnt_significant_changes[light] = 0
|
||||
|
||||
async def turn_on_off_event_listener(self, event: Event) -> None:
|
||||
"""Track 'light.turn_off' and 'light.turn_on' service calls."""
|
||||
|
|
@ -1700,11 +1755,7 @@ class TurnOnOffListener:
|
|||
new_state.context.id,
|
||||
)
|
||||
|
||||
if (
|
||||
new_state is not None
|
||||
and new_state.state == STATE_ON
|
||||
and is_our_context(new_state.context)
|
||||
):
|
||||
if new_state is not None and new_state.state == STATE_ON:
|
||||
# It is possible to have multiple state change events with the same context.
|
||||
# This can happen because a `turn_on.light(brightness_pct=100, transition=30)`
|
||||
# event leads to an instant state change of
|
||||
|
|
@ -1717,21 +1768,29 @@ class TurnOnOffListener:
|
|||
# incorrect 'min_kelvin' and 'max_kelvin', which happens e.g., for
|
||||
# Philips Hue White GU10 Bluetooth lights).
|
||||
old_state: list[State] | None = self.last_state_change.get(entity_id)
|
||||
if (
|
||||
old_state is not None
|
||||
and old_state[0].context.id == new_state.context.id
|
||||
):
|
||||
# If there is already a state change event from this event (with this
|
||||
# context) then append it to the already existing list.
|
||||
_LOGGER.debug(
|
||||
"State change event of '%s' is already in 'self.last_state_change' (%s)"
|
||||
" adding this state also",
|
||||
entity_id,
|
||||
new_state.context.id,
|
||||
)
|
||||
if is_our_context(new_state.context):
|
||||
if (
|
||||
old_state is not None
|
||||
and old_state[0].context.id == new_state.context.id
|
||||
):
|
||||
_LOGGER.debug(
|
||||
"TurnOnOffListener: State change event of '%s' is already"
|
||||
" in 'self.last_state_change' (%s)"
|
||||
" adding this state also",
|
||||
entity_id,
|
||||
new_state.context.id,
|
||||
)
|
||||
self.last_state_change[entity_id].append(new_state)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"TurnOnOffListener: New adapt '%s' found for %s",
|
||||
new_state,
|
||||
entity_id,
|
||||
)
|
||||
self.last_state_change[entity_id] = [new_state]
|
||||
self.start_transition_timer(entity_id)
|
||||
elif old_state is not None:
|
||||
self.last_state_change[entity_id].append(new_state)
|
||||
else:
|
||||
self.last_state_change[entity_id] = [new_state]
|
||||
|
||||
def is_manually_controlled(
|
||||
self,
|
||||
|
|
@ -1786,64 +1845,58 @@ class TurnOnOffListener:
|
|||
detected, we mark the light as 'manually controlled' until the light
|
||||
or switch is turned 'off' and 'on' again.
|
||||
"""
|
||||
if light not in self.last_state_change:
|
||||
return False
|
||||
old_states: list[State] = self.last_state_change[light]
|
||||
await self.hass.helpers.entity_component.async_update_entity(light)
|
||||
new_state = self.hass.states.get(light)
|
||||
last_service_data = self.last_service_data.get(light)
|
||||
if last_service_data is None:
|
||||
return
|
||||
compare_to = functools.partial(
|
||||
_attributes_have_changed,
|
||||
light=light,
|
||||
new_attributes=new_state.attributes,
|
||||
adapt_brightness=adapt_brightness,
|
||||
adapt_color=adapt_color,
|
||||
context=context,
|
||||
)
|
||||
for index, old_state in enumerate(old_states):
|
||||
changed = compare_to(old_attributes=old_state.attributes)
|
||||
if not changed:
|
||||
_LOGGER.debug(
|
||||
"State of '%s' didn't change wrt change event nr. %s (context.id=%s)",
|
||||
light,
|
||||
index,
|
||||
context.id,
|
||||
)
|
||||
break
|
||||
|
||||
last_service_data = self.last_service_data.get(light)
|
||||
if changed and last_service_data is not None:
|
||||
# It can happen that the state change events that are associated
|
||||
# with the last 'light.turn_on' call by this integration were not
|
||||
# final states. Possibly a later EVENT_STATE_CHANGED happened, where
|
||||
# the correct target brightness/color was reached.
|
||||
changed = compare_to(old_attributes=last_service_data)
|
||||
if not changed:
|
||||
# Update state and check for a manual change not done in HA.
|
||||
# Ensure HASS is correctly updating your light's state with
|
||||
# light.turn_on calls if any problems arise. This
|
||||
# can happen e.g. using zigbee2mqtt with 'report: false' in device settings.
|
||||
if switch._detect_non_ha_changes:
|
||||
_LOGGER.debug(
|
||||
"%s: 'detect_non_ha_changes: true', calling update_entity(%s)"
|
||||
" and check if it's last adapt succeeded.",
|
||||
switch._name,
|
||||
light,
|
||||
)
|
||||
# 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,
|
||||
)
|
||||
|
||||
n_changes = self.cnt_significant_changes[light]
|
||||
if changed:
|
||||
self.cnt_significant_changes[light] += 1
|
||||
if n_changes >= self.max_cnt_significant_changes:
|
||||
# Only mark a light as significantly changing, if changed==True
|
||||
# N times in a row. We do this because sometimes a state changes
|
||||
# happens only *after* a new update interval has already started.
|
||||
self.mark_as_manual_control(light)
|
||||
_fire_manual_control_event(switch, light, context, is_async=False)
|
||||
else:
|
||||
if n_changes > 1:
|
||||
_LOGGER.debug(
|
||||
"State of '%s' had 'cnt_significant_changes=%s' but the state"
|
||||
" changed to the expected settings now",
|
||||
light,
|
||||
n_changes,
|
||||
)
|
||||
self.cnt_significant_changes[light] = 0
|
||||
|
||||
return changed
|
||||
return True
|
||||
_LOGGER.debug(
|
||||
"%s: Light '%s' correctly matches our last adapt's service data, continuing..."
|
||||
" context.id=%s.",
|
||||
switch._name,
|
||||
light,
|
||||
context.id,
|
||||
)
|
||||
return False
|
||||
|
||||
async def maybe_cancel_adjusting(
|
||||
self, entity_id: str, off_to_on_event: Event, on_to_off_event: Event | None
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ from homeassistant.config_entries import ConfigEntryState
|
|||
from homeassistant.const import (
|
||||
ATTR_AREA_ID,
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_SUPPORTED_FEATURES,
|
||||
CONF_LIGHTS,
|
||||
CONF_NAME,
|
||||
CONF_PLATFORM,
|
||||
|
|
@ -769,25 +770,63 @@ async def test_significant_change(hass):
|
|||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
switch, (bed_light_instance, *_) = await setup_lights_and_switch(hass)
|
||||
async def set_brightness(val: int):
|
||||
hass.states.async_set(
|
||||
ENTITY_LIGHT, "on", {ATTR_BRIGHTNESS: val, ATTR_SUPPORTED_FEATURES: 1}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
switch, _ = await setup_lights_and_switch(hass)
|
||||
_LOGGER.debug("Test detect_non_ha_changes:")
|
||||
switch._take_over_control = True
|
||||
assert switch._take_over_control
|
||||
switch._detect_non_ha_changes = True
|
||||
assert switch._detect_non_ha_changes
|
||||
|
||||
# build last service data
|
||||
await update(force=False)
|
||||
|
||||
# force=True should not reset manual control.
|
||||
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]
|
||||
|
||||
# turn light off then on should reset manual control.
|
||||
await turn_light(False)
|
||||
await turn_light(True)
|
||||
await update(force=True) # removes manual control
|
||||
assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT]
|
||||
|
||||
# Change brightness by setting state (not using 'light.turn_on')
|
||||
attributes = hass.states.get(ENTITY_LIGHT).attributes
|
||||
new_attributes = attributes.copy()
|
||||
new_brightness = (attributes[ATTR_BRIGHTNESS] + 100) % 255
|
||||
new_attributes[ATTR_BRIGHTNESS] = new_brightness
|
||||
bed_light_instance._brightness = new_brightness
|
||||
# Assert last_service_data got filled from update()
|
||||
await update(force=True)
|
||||
assert switch.turn_on_off_listener.last_service_data.get(ENTITY_LIGHT) is not None
|
||||
for _ in range(switch.turn_on_off_listener.max_cnt_significant_changes):
|
||||
|
||||
# Simulate a transition to 255 where the update() is already using brightness 255.
|
||||
await set_brightness(240)
|
||||
await set_brightness(244)
|
||||
await set_brightness(247)
|
||||
await set_brightness(250)
|
||||
|
||||
# last_state_change should have our state changes.
|
||||
# Change brightness by async_set (not using 'light.turn_on')
|
||||
new_brightness = 50
|
||||
await set_brightness(new_brightness)
|
||||
_LOGGER.debug("Test: Brightness set to %s", new_brightness)
|
||||
|
||||
# mock homeassistant.core.HomeAssistant.helpers.entity_component.async_update_entity
|
||||
# Otherwise what happens is update_entity() refreshes the state to the last call of
|
||||
# light.turn_on(). This is because we are not using hass.states.async_set() to
|
||||
# set the brightness of the light. We mock `async_update_ha_state` because
|
||||
# `async_update_entity` calls it.
|
||||
with patch("homeassistant.helpers.entity.Entity.async_update_ha_state"):
|
||||
# On next update ENTITY_LIGHT should be marked as manually controlled
|
||||
await update(force=False)
|
||||
assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT]
|
||||
# On next update the light should be marked as manually controlled
|
||||
await update(force=False)
|
||||
# TODO: the state should be `bool(manual_control) is True`
|
||||
assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT]
|
||||
assert (
|
||||
switch.turn_on_off_listener.last_service_data.get(ENTITY_LIGHT) is not None
|
||||
)
|
||||
assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT]
|
||||
|
||||
|
||||
def test_color_difference_redmean():
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue