139 lines
4.8 KiB
Python
139 lines
4.8 KiB
Python
"""Building blocks for the heater switch keep-alive feature.
|
|
|
|
The heater switch keep-alive feature consists of regularly refreshing the state
|
|
of directly controlled switches at a configurable interval (regularly turning the
|
|
switch 'on' or 'off' again even if it is already turned 'on' or 'off'), just like
|
|
the keep_alive setting of Home Assistant's Generic Thermostat integration:
|
|
https://www.home-assistant.io/integrations/generic_thermostat/
|
|
"""
|
|
|
|
import logging
|
|
from collections.abc import Awaitable, Callable
|
|
from datetime import timedelta, datetime
|
|
from time import monotonic
|
|
|
|
from homeassistant.core import HomeAssistant, CALLBACK_TYPE
|
|
from homeassistant.helpers.event import async_track_time_interval
|
|
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
class BackoffTimer:
|
|
"""Exponential backoff timer with a non-blocking polling-style implementation.
|
|
|
|
Usage example:
|
|
timer = BackoffTimer(multiplier=1.5, upper_limit_sec=600)
|
|
while some_condition:
|
|
if timer.is_ready():
|
|
do_something()
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
multiplier=2.0,
|
|
lower_limit_sec=30,
|
|
upper_limit_sec=86400,
|
|
initially_ready=True,
|
|
):
|
|
"""Initialize a BackoffTimer instance.
|
|
|
|
Args:
|
|
multiplier (int, optional): Period multiplier applied when is_ready() is True.
|
|
lower_limit_sec (int, optional): Initial backoff period in seconds.
|
|
upper_limit_sec (int, optional): Maximum backoff period in seconds.
|
|
initially_ready (bool, optional): Whether is_ready() should return True the
|
|
first time it is called, or after a call to reset().
|
|
"""
|
|
self._multiplier = multiplier
|
|
self._lower_limit_sec = lower_limit_sec
|
|
self._upper_limit_sec = upper_limit_sec
|
|
self._initially_ready = initially_ready
|
|
|
|
self._timestamp = 0
|
|
self._period_sec = self._lower_limit_sec
|
|
|
|
@property
|
|
def in_progress(self) -> bool:
|
|
"""Whether the backoff timer is in progress (True after a call to is_ready())."""
|
|
return bool(self._timestamp)
|
|
|
|
def reset(self):
|
|
"""Reset a BackoffTimer instance."""
|
|
self._timestamp = 0
|
|
self._period_sec = self._lower_limit_sec
|
|
|
|
def is_ready(self) -> bool:
|
|
"""Check whether an exponentially increasing period of time has passed.
|
|
|
|
Whenever is_ready() returns True, the timer period is multiplied so that
|
|
it takes longer until is_ready() returns True again.
|
|
Returns:
|
|
bool: True if enough time has passed since one of the following events,
|
|
in relation to an instance of this class:
|
|
- The last time when this method returned True, if it ever did.
|
|
- Or else, when this method was first called after a call to reset().
|
|
- Or else, when this method was first called.
|
|
False otherwise.
|
|
"""
|
|
now = monotonic()
|
|
if self._timestamp == 0:
|
|
self._timestamp = now
|
|
return self._initially_ready
|
|
elif now - self._timestamp >= self._period_sec:
|
|
self._timestamp = now
|
|
self._period_sec = max(
|
|
self._lower_limit_sec,
|
|
min(self._upper_limit_sec, self._period_sec * self._multiplier),
|
|
)
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
class IntervalCaller:
|
|
"""Repeatedly call a given async action function at a given regular interval.
|
|
|
|
Convenience wrapper around Home Assistant's `async_track_time_interval` function.
|
|
"""
|
|
|
|
def __init__(self, hass: HomeAssistant, interval_sec: float) -> None:
|
|
self._hass = hass
|
|
self._interval_sec = interval_sec
|
|
self._remove_handle: CALLBACK_TYPE | None = None
|
|
self.backoff_timer = BackoffTimer()
|
|
|
|
@property
|
|
def interval_sec(self) -> float:
|
|
"""Return the calling interval in seconds."""
|
|
return self._interval_sec
|
|
|
|
def cancel(self):
|
|
"""Cancel the regular calls to the action function."""
|
|
if self._remove_handle:
|
|
self._remove_handle()
|
|
self._remove_handle = None
|
|
|
|
def set_async_action(self, action: Callable[[], Awaitable[None]]):
|
|
"""Set the async action function to be called at regular intervals."""
|
|
if not self._interval_sec:
|
|
return
|
|
self.cancel()
|
|
|
|
async def callback(_time: datetime):
|
|
try:
|
|
_LOGGER.debug(
|
|
"Calling keep-alive action '%s' (%ss interval)",
|
|
action.__name__,
|
|
self._interval_sec,
|
|
)
|
|
await action()
|
|
except Exception as e: # pylint: disable=broad-exception-caught
|
|
_LOGGER.error(e)
|
|
self.cancel()
|
|
|
|
self._remove_handle = async_track_time_interval(
|
|
self._hass, callback, timedelta(seconds=self._interval_sec)
|
|
)
|