mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-09-25 03:14:44 +02:00
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:
parent
68c0e4db69
commit
a2186ecf22
2 changed files with 117 additions and 5 deletions
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue