From a2186ecf223fbee0742f56a75a6f09e248d9d0a5 Mon Sep 17 00:00:00 2001 From: proscar87 <68169114+proscar87@users.noreply.github.com> Date: Sun, 6 Sep 2026 02:12:28 -0600 Subject: [PATCH] perf: stagger periodic adaptation updates (#1500) Spread recurring updates with a deterministic per-switch offset while keeping turn-on adaptation immediate. Register the delayed listener on Home Assistant's event loop and cover cancellation, reconfiguration, and cadence with real timer tests. Closes #939. Co-authored-by: Bas Nijholt --- custom_components/adaptive_lighting/switch.py | 37 ++++++-- tests/test_switch.py | 85 +++++++++++++++++++ 2 files changed, 117 insertions(+), 5 deletions(-) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index fb210744..44ad9c60 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio import datetime +import hashlib import logging import zoneinfo from copy import deepcopy @@ -62,6 +63,7 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_component import async_update_entity from homeassistant.helpers.event import ( EventStateChangedData, + async_call_later, async_track_state_change_event, async_track_time_interval, ) @@ -1081,6 +1083,17 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): self.remove_listeners.append(remove_sleep) self._expand_light_groups() + def _stagger_offset(self, adaptation_interval: timedelta) -> timedelta: + """Return a stable relative delay to spread periodic updates. + + Hashing the switch ID gives a best-effort spread without configuration. + It does not delay the immediate turn-on adaptation or guarantee a minimum + gap between switches. + """ + digest = hashlib.sha256(self.unique_id.encode()).digest() + fraction = int.from_bytes(digest[:8], byteorder="big") / 2**64 + return adaptation_interval * fraction + def _update_time_interval_listener(self) -> None: """Create or recreate the adaptation interval listener. @@ -1101,11 +1114,25 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): + timedelta(seconds=processing_overhead_time) ) - self.remove_interval = async_track_time_interval( - self.hass, - action=self._async_update_at_interval_action, - interval=adaptation_interval, - ) + @callback + def _start_periodic_listener(_now: datetime.datetime | None = None) -> None: + self.remove_interval = async_track_time_interval( + self.hass, + action=self._async_update_at_interval_action, + interval=adaptation_interval, + ) + + # Register after the offset. The first periodic tick is at offset + + # interval, then subsequent ticks keep the configured interval. + offset = self._stagger_offset(adaptation_interval) + if offset > timedelta(0): + self.remove_interval = async_call_later( + self.hass, + offset.total_seconds(), + _start_periodic_listener, + ) + else: + _start_periodic_listener() def _call_on_remove_callbacks(self) -> None: """Call callbacks registered by async_on_remove.""" diff --git a/tests/test_switch.py b/tests/test_switch.py index 148c8bd3..b96e56fe 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -1589,6 +1589,91 @@ async def test_async_update_at_interval_action(hass): await switch._async_update_at_interval_action() +async def test_stagger_offset_deterministic_and_bounded(hass): + """Test switches get stable relative delays within the interval.""" + interval = datetime.timedelta(seconds=90) + + _, switch_a = await setup_switch(hass, {CONF_NAME: "switch_a"}) + _, switch_b = await setup_switch(hass, {CONF_NAME: "switch_b"}) + + offset_a_1 = switch_a._stagger_offset(interval) + offset_a_2 = switch_a._stagger_offset(interval) + assert offset_a_1 == offset_a_2 + + offset_b = switch_b._stagger_offset(interval) + assert offset_a_1 != offset_b + + for offset in (offset_a_1, offset_b): + assert datetime.timedelta(0) <= offset < interval + + +async def test_disable_cancels_pending_stagger(hass): + """Test disabling the switch cancels delayed interval registration.""" + switch_module = "homeassistant.components.adaptive_lighting.switch" + with ( + patch( + f"{switch_module}.AdaptiveSwitch._stagger_offset", + return_value=datetime.timedelta(seconds=10), + ) as mock_offset, + patch( + f"{switch_module}.async_track_time_interval", + return_value=lambda: None, + ) as mock_track_interval, + ): + _, switch = await setup_switch(hass, {}) + mock_offset.return_value = datetime.timedelta(seconds=0.05) + switch._update_time_interval_listener() + await switch.async_turn_off() + await asyncio.sleep(0.1) + + mock_track_interval.assert_not_called() + + +async def test_reconfigure_replaces_stagger_and_preserves_interval(hass): + """Test the replacement starts at offset + interval and keeps its cadence.""" + calls: list[float] = [] + two_calls = asyncio.Event() + loop = asyncio.get_running_loop() + stagger = datetime.timedelta(seconds=0.2) + + async def record_interval(_now=None): + calls.append(loop.time()) + if len(calls) == 2: + two_calls.set() + + with patch( + "homeassistant.components.adaptive_lighting.switch.AdaptiveSwitch._stagger_offset", + return_value=datetime.timedelta(seconds=10), + ) as mock_offset: + _, switch = await setup_switch(hass, {}) + switch._interval = datetime.timedelta(0) + mock_offset.return_value = stagger + effective_interval = ( + switch._interval + + datetime.timedelta(milliseconds=switch._send_split_delay) + + datetime.timedelta(seconds=0.5) + ) + + with patch.object( + switch, + "_async_update_at_interval_action", + side_effect=record_interval, + ): + switch._update_time_interval_listener() + await asyncio.sleep(0.02) + + replacement_started = loop.time() + switch._update_time_interval_listener() + await asyncio.wait_for(two_calls.wait(), timeout=2) + await switch.async_turn_off() + + first_delay = calls[0] - replacement_started + interval_seconds = effective_interval.total_seconds() + expected_first_delay = interval_seconds + stagger.total_seconds() + assert expected_first_delay - 0.1 <= first_delay < expected_first_delay + 0.5 + assert interval_seconds - 0.1 <= calls[1] - calls[0] < interval_seconds + 0.5 + + @pytest.mark.parametrize("separate_turn_on_commands", (True, False)) async def test_separate_turn_on_commands(hass, separate_turn_on_commands): """Test 'separate_turn_on_commands' argument."""