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 <basnijholt@gmail.com>
This commit is contained in:
proscar87 2026-09-06 02:12:28 -06:00 • committed by GitHub
commit a2186ecf22
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 117 additions and 5 deletions

View file

@ -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."""

View file

@ -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."""