Skip to content

Commit c7f44e4

Browse files
th3w1zard1basnijholtgithub-actions[bot]
authored
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 commit 3aa2f32. * Update switch.py * Update switch.py * Update switch.py * Small refactor * Move test to old position for better diffs * Revert "Small refactor" This reverts commit b986b3f. * 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 commit 3731c99. * 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 <[email protected]> * 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 <[email protected]> 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 <[email protected]> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Bas Nijholt <[email protected]>
1 parent b730c7c commit c7f44e4

File tree

3 files changed

+211
-119
lines changed

3 files changed

+211
-119
lines changed

custom_components/adaptive_lighting/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,5 @@
88
"iot_class": "calculated",
99
"issue_tracker": "https://github.com/basnijholt/adaptive-lighting/issues",
1010
"requirements": [],
11-
"version": "1.9.1"
11+
"version": "1.10.0"
1212
}

custom_components/adaptive_lighting/switch.py

Lines changed: 157 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import asyncio
55
import base64
66
import bisect
7-
from collections import defaultdict
7+
from collections.abc import Callable, Coroutine
88
from copy import deepcopy
99
from dataclasses import dataclass
1010
import datetime
@@ -802,10 +802,19 @@ def _set_changeable_settings(
802802
self._only_once = data[CONF_ONLY_ONCE]
803803
self._prefer_rgb_color = data[CONF_PREFER_RGB_COLOR]
804804
self._separate_turn_on_commands = data[CONF_SEPARATE_TURN_ON_COMMANDS]
805-
self._take_over_control = data[CONF_TAKE_OVER_CONTROL]
806805
self._transition = data[CONF_TRANSITION]
807806
self._adapt_delay = data[CONF_ADAPT_DELAY]
808807
self._send_split_delay = data[CONF_SEND_SPLIT_DELAY]
808+
self._take_over_control = data[CONF_TAKE_OVER_CONTROL]
809+
self._detect_non_ha_changes = data[CONF_DETECT_NON_HA_CHANGES]
810+
if not data[CONF_TAKE_OVER_CONTROL] and data[CONF_DETECT_NON_HA_CHANGES]:
811+
_LOGGER.warning(
812+
"%s: Config mismatch: 'detect_non_ha_changes: true' "
813+
"requires 'take_over_control' to be enabled. Adjusting config "
814+
"and continuing setup with `take_over_control: true`.",
815+
self._name,
816+
)
817+
self._take_over_control = True
809818
self._auto_reset_manual_control_time = data[CONF_AUTORESET_CONTROL]
810819
self._expand_light_groups() # updates manual control timers
811820
_loc = get_astral_location(self.hass)
@@ -1128,11 +1137,23 @@ async def _update_attrs_and_maybe_adapt_lights(
11281137
)
11291138
)
11301139
self.async_write_ha_state()
1140+
11311141
if lights is None:
11321142
lights = self._lights
1133-
if (self._only_once and not force) or not lights:
1143+
1144+
if not force and self._only_once:
11341145
return
1135-
await self._adapt_lights(lights, transition, force, context)
1146+
1147+
filtered_lights = []
1148+
for light in lights:
1149+
# Don't adapt lights that haven't finished prior transitions.
1150+
if force or not self.turn_on_off_listener.transition_timers.get(light):
1151+
filtered_lights.append(light)
1152+
1153+
if not filtered_lights:
1154+
return
1155+
1156+
await self._adapt_lights(filtered_lights, transition, force, context)
11361157

11371158
async def _adapt_lights(
11381159
self,
@@ -1532,8 +1553,6 @@ def __init__(self, hass: HomeAssistant):
15321553
self.sleep_tasks: dict[str, asyncio.Task] = {}
15331554
# Tracks which lights are manually controlled
15341555
self.manual_control: dict[str, bool] = {}
1535-
# Counts the number of times (in a row) a light had a changed state.
1536-
self.cnt_significant_changes: dict[str, int] = defaultdict(int)
15371556
# Track 'state_changed' events of self.lights resulting from this integration
15381557
self.last_state_change: dict[str, list[State]] = {}
15391558
# Track last 'service_data' to 'light.turn_on' resulting from this integration
@@ -1543,9 +1562,8 @@ def __init__(self, hass: HomeAssistant):
15431562
self.auto_reset_manual_control_timers: dict[str, _AsyncSingleShotTimer] = {}
15441563
self.auto_reset_manual_control_times: dict[str, float] = {}
15451564

1546-
# When a state is different `max_cnt_significant_changes` times in a row,
1547-
# mark it as manually_controlled.
1548-
self.max_cnt_significant_changes = 2
1565+
# Track light transitions
1566+
self.transition_timers: dict[str, _AsyncSingleShotTimer] = {}
15491567

15501568
self.remove_listener = self.hass.bus.async_listen(
15511569
EVENT_CALL_SERVICE, self.turn_on_off_event_listener
@@ -1554,6 +1572,56 @@ def __init__(self, hass: HomeAssistant):
15541572
EVENT_STATE_CHANGED, self.state_changed_event_listener
15551573
)
15561574

1575+
def _handle_timer(
1576+
self,
1577+
light: str,
1578+
timers_dict: dict[str, _AsyncSingleShotTimer],
1579+
delay: float | None,
1580+
reset_coroutine: Callable[[], Coroutine[Any, Any, None]],
1581+
) -> None:
1582+
timer = timers_dict.get(light)
1583+
if timer is not None:
1584+
if delay is None: # Timer object exists, but should not anymore
1585+
timer.cancel()
1586+
timers_dict.pop(light)
1587+
else: # Timer object already exists, just update the delay and restart it
1588+
timer.delay = delay
1589+
timer.start()
1590+
elif delay is not None: # Timer object does not exist, create it
1591+
timer = _AsyncSingleShotTimer(delay, reset_coroutine)
1592+
timers_dict[light] = timer
1593+
timer.start()
1594+
1595+
def start_transition_timer(self, light: str) -> None:
1596+
"""Mark a light as manually controlled."""
1597+
_LOGGER.debug("Start transition timer for %s", light)
1598+
last_service_data = self.last_service_data
1599+
if (
1600+
not last_service_data
1601+
or light not in last_service_data
1602+
or ATTR_TRANSITION not in last_service_data[light]
1603+
):
1604+
return
1605+
1606+
delay = last_service_data[light][ATTR_TRANSITION]
1607+
1608+
async def reset():
1609+
_LOGGER.debug(
1610+
"Transition finished for light %s",
1611+
light,
1612+
)
1613+
switches = _get_switches_with_lights(self.hass, [light])
1614+
for switch in switches:
1615+
if not switch.is_on:
1616+
continue
1617+
await switch._update_attrs_and_maybe_adapt_lights(
1618+
[light],
1619+
force=False,
1620+
context=switch.create_context("transit"),
1621+
)
1622+
1623+
self._handle_timer(light, self.transition_timers, delay, reset)
1624+
15571625
def set_auto_reset_manual_control_times(self, lights: list[str], time: float):
15581626
"""Set the time after which the lights are automatically reset."""
15591627
if time == 0:
@@ -1576,40 +1644,28 @@ def mark_as_manual_control(self, light: str) -> None:
15761644
_LOGGER.debug("Marking '%s' as manually controlled.", light)
15771645
self.manual_control[light] = True
15781646
delay = self.auto_reset_manual_control_times.get(light)
1579-
timer = self.auto_reset_manual_control_timers.get(light)
1580-
if timer is not None:
1581-
if delay is None: # Timer object exists, but should not anymore
1582-
timer.cancel()
1583-
self.auto_reset_manual_control_timers.pop(light)
1584-
else: # Timer object already exists, just update the delay and restart it
1585-
timer.delay = delay
1586-
timer.start()
1587-
elif delay is not None: # Timer object does not exist, create it
15881647

1589-
async def reset():
1590-
self.reset(light)
1591-
switches = _get_switches_with_lights(self.hass, [light])
1592-
for switch in switches:
1593-
if not switch.is_on:
1594-
continue
1595-
# pylint: disable=protected-access
1596-
await switch._update_attrs_and_maybe_adapt_lights(
1597-
[light],
1598-
transition=switch._initial_transition,
1599-
force=True,
1600-
context=switch.create_context("autoreset"),
1601-
)
1602-
_LOGGER.debug(
1603-
"Auto resetting 'manual_control' status of '%s' because"
1604-
" it was not manually controlled for %s seconds.",
1605-
light,
1606-
delay,
1648+
async def reset():
1649+
self.reset(light)
1650+
switches = _get_switches_with_lights(self.hass, [light])
1651+
for switch in switches:
1652+
if not switch.is_on:
1653+
continue
1654+
await switch._update_attrs_and_maybe_adapt_lights(
1655+
[light],
1656+
transition=switch._initial_transition,
1657+
force=True,
1658+
context=switch.create_context("autoreset"),
16071659
)
1608-
assert not self.manual_control[light]
1660+
_LOGGER.debug(
1661+
"Auto resetting 'manual_control' status of '%s' because"
1662+
" it was not manually controlled for %s seconds.",
1663+
light,
1664+
delay,
1665+
)
1666+
assert not self.manual_control[light]
16091667

1610-
timer = _AsyncSingleShotTimer(delay, reset)
1611-
self.auto_reset_manual_control_timers[light] = timer
1612-
timer.start()
1668+
self._handle_timer(light, self.auto_reset_manual_control_timers, delay, reset)
16131669

16141670
def reset(self, *lights, reset_manual_control=True) -> None:
16151671
"""Reset the 'manual_control' status of the lights."""
@@ -1621,7 +1677,6 @@ def reset(self, *lights, reset_manual_control=True) -> None:
16211677
timer.cancel()
16221678
self.last_state_change.pop(light, None)
16231679
self.last_service_data.pop(light, None)
1624-
self.cnt_significant_changes[light] = 0
16251680

16261681
async def turn_on_off_event_listener(self, event: Event) -> None:
16271682
"""Track 'light.turn_off' and 'light.turn_on' service calls."""
@@ -1700,11 +1755,7 @@ async def state_changed_event_listener(self, event: Event) -> None:
17001755
new_state.context.id,
17011756
)
17021757

1703-
if (
1704-
new_state is not None
1705-
and new_state.state == STATE_ON
1706-
and is_our_context(new_state.context)
1707-
):
1758+
if new_state is not None and new_state.state == STATE_ON:
17081759
# It is possible to have multiple state change events with the same context.
17091760
# This can happen because a `turn_on.light(brightness_pct=100, transition=30)`
17101761
# event leads to an instant state change of
@@ -1717,21 +1768,29 @@ async def state_changed_event_listener(self, event: Event) -> None:
17171768
# incorrect 'min_kelvin' and 'max_kelvin', which happens e.g., for
17181769
# Philips Hue White GU10 Bluetooth lights).
17191770
old_state: list[State] | None = self.last_state_change.get(entity_id)
1720-
if (
1721-
old_state is not None
1722-
and old_state[0].context.id == new_state.context.id
1723-
):
1724-
# If there is already a state change event from this event (with this
1725-
# context) then append it to the already existing list.
1726-
_LOGGER.debug(
1727-
"State change event of '%s' is already in 'self.last_state_change' (%s)"
1728-
" adding this state also",
1729-
entity_id,
1730-
new_state.context.id,
1731-
)
1771+
if is_our_context(new_state.context):
1772+
if (
1773+
old_state is not None
1774+
and old_state[0].context.id == new_state.context.id
1775+
):
1776+
_LOGGER.debug(
1777+
"TurnOnOffListener: State change event of '%s' is already"
1778+
" in 'self.last_state_change' (%s)"
1779+
" adding this state also",
1780+
entity_id,
1781+
new_state.context.id,
1782+
)
1783+
self.last_state_change[entity_id].append(new_state)
1784+
else:
1785+
_LOGGER.debug(
1786+
"TurnOnOffListener: New adapt '%s' found for %s",
1787+
new_state,
1788+
entity_id,
1789+
)
1790+
self.last_state_change[entity_id] = [new_state]
1791+
self.start_transition_timer(entity_id)
1792+
elif old_state is not None:
17321793
self.last_state_change[entity_id].append(new_state)
1733-
else:
1734-
self.last_state_change[entity_id] = [new_state]
17351794

17361795
def is_manually_controlled(
17371796
self,
@@ -1786,64 +1845,58 @@ async def significant_change(
17861845
detected, we mark the light as 'manually controlled' until the light
17871846
or switch is turned 'off' and 'on' again.
17881847
"""
1789-
if light not in self.last_state_change:
1790-
return False
1791-
old_states: list[State] = self.last_state_change[light]
1792-
await self.hass.helpers.entity_component.async_update_entity(light)
1793-
new_state = self.hass.states.get(light)
1848+
last_service_data = self.last_service_data.get(light)
1849+
if last_service_data is None:
1850+
return
17941851
compare_to = functools.partial(
17951852
_attributes_have_changed,
17961853
light=light,
1797-
new_attributes=new_state.attributes,
17981854
adapt_brightness=adapt_brightness,
17991855
adapt_color=adapt_color,
18001856
context=context,
18011857
)
1802-
for index, old_state in enumerate(old_states):
1803-
changed = compare_to(old_attributes=old_state.attributes)
1804-
if not changed:
1805-
_LOGGER.debug(
1806-
"State of '%s' didn't change wrt change event nr. %s (context.id=%s)",
1807-
light,
1808-
index,
1809-
context.id,
1810-
)
1811-
break
1812-
1813-
last_service_data = self.last_service_data.get(light)
1814-
if changed and last_service_data is not None:
1815-
# It can happen that the state change events that are associated
1816-
# with the last 'light.turn_on' call by this integration were not
1817-
# final states. Possibly a later EVENT_STATE_CHANGED happened, where
1818-
# the correct target brightness/color was reached.
1819-
changed = compare_to(old_attributes=last_service_data)
1820-
if not changed:
1858+
# Update state and check for a manual change not done in HA.
1859+
# Ensure HASS is correctly updating your light's state with
1860+
# light.turn_on calls if any problems arise. This
1861+
# can happen e.g. using zigbee2mqtt with 'report: false' in device settings.
1862+
if switch._detect_non_ha_changes:
1863+
_LOGGER.debug(
1864+
"%s: 'detect_non_ha_changes: true', calling update_entity(%s)"
1865+
" and check if it's last adapt succeeded.",
1866+
switch._name,
1867+
light,
1868+
)
1869+
# This update_entity probably isn't necessary now that we're checking
1870+
# if transitions finished from our last adapt.
1871+
await self.hass.helpers.entity_component.async_update_entity(light)
1872+
refreshed_state = self.hass.states.get(light)
1873+
_LOGGER.debug(
1874+
"%s: Current state of %s: %s",
1875+
switch._name,
1876+
light,
1877+
refreshed_state,
1878+
)
1879+
changed = compare_to(
1880+
old_attributes=last_service_data,
1881+
new_attributes=refreshed_state.attributes,
1882+
)
1883+
if changed:
18211884
_LOGGER.debug(
18221885
"State of '%s' didn't change wrt 'last_service_data' (context.id=%s)",
18231886
light,
18241887
context.id,
18251888
)
1826-
1827-
n_changes = self.cnt_significant_changes[light]
1828-
if changed:
1829-
self.cnt_significant_changes[light] += 1
1830-
if n_changes >= self.max_cnt_significant_changes:
1831-
# Only mark a light as significantly changing, if changed==True
1832-
# N times in a row. We do this because sometimes a state changes
1833-
# happens only *after* a new update interval has already started.
18341889
self.mark_as_manual_control(light)
18351890
_fire_manual_control_event(switch, light, context, is_async=False)
1836-
else:
1837-
if n_changes > 1:
1838-
_LOGGER.debug(
1839-
"State of '%s' had 'cnt_significant_changes=%s' but the state"
1840-
" changed to the expected settings now",
1841-
light,
1842-
n_changes,
1843-
)
1844-
self.cnt_significant_changes[light] = 0
1845-
1846-
return changed
1891+
return True
1892+
_LOGGER.debug(
1893+
"%s: Light '%s' correctly matches our last adapt's service data, continuing..."
1894+
" context.id=%s.",
1895+
switch._name,
1896+
light,
1897+
context.id,
1898+
)
1899+
return False
18471900

18481901
async def maybe_cancel_adjusting(
18491902
self, entity_id: str, off_to_on_event: Event, on_to_off_event: Event | None

0 commit comments

Comments
 (0)