Reduce keep-alive warning logs when the controlled entity becomes unavailable (#476)

This commit is contained in:
Paulo Ferreira de Castro
2024-06-28 08:53:40 +01:00
committed by GitHub
parent 0ee4fe355d
commit 864e904e21
3 changed files with 232 additions and 11 deletions

View File

@@ -10,6 +10,7 @@ the keep_alive setting of Home Assistant's Generic Thermostat integration:
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
@@ -18,6 +19,79 @@ 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.
@@ -28,6 +102,7 @@ class IntervalCaller:
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:

View File

@@ -5,7 +5,7 @@ import logging
from typing import Any
from enum import StrEnum
from homeassistant.const import ATTR_ENTITY_ID, STATE_ON, UnitOfTemperature
from homeassistant.const import ATTR_ENTITY_ID, STATE_ON, STATE_UNAVAILABLE
from homeassistant.core import State
from homeassistant.exceptions import ServiceNotFound
@@ -253,6 +253,27 @@ class UnderlyingSwitch(UnderlyingEntity):
async def _keep_alive_callback(self):
"""Keep alive: Turn on if already turned on, turn off if already turned off."""
timer = self._keep_alive.backoff_timer
state: State | None = self._hass.states.get(self._entity_id)
# Normal, expected state.state values are "on" and "off". An absent
# underlying MQTT switch was observed to produce either state == None
# or state.state == STATE_UNAVAILABLE ("unavailable").
if state is None or state.state == STATE_UNAVAILABLE:
if timer.is_ready():
_LOGGER.warning(
"Entity %s is not available (state: %s). Will keep trying "
"keep alive calls, but won't log this condition every time.",
self._entity_id,
state.state if state else "None",
)
else:
if timer.in_progress:
timer.reset()
_LOGGER.warning(
"Entity %s has recovered (state: %s).",
self._entity_id,
state.state,
)
await (self.turn_on() if self.is_device_active else self.turn_off())
# @overrides this breaks some unit tests TypeError: object MagicMock can't be used in 'await' expression

View File

@@ -6,6 +6,7 @@ from unittest.mock import ANY, _Call, call, patch
from datetime import datetime, timedelta
from typing import cast
from custom_components.versatile_thermostat.keep_alive import BackoffTimer
from custom_components.versatile_thermostat.thermostat_switch import (
ThermostatOverSwitch,
)
@@ -52,6 +53,7 @@ class CommonMocks:
hass: HomeAssistant
thermostat: ThermostatOverSwitch
mock_is_state: MagicMock
mock_get_state: MagicMock
mock_service_call: MagicMock
mock_async_track_time_interval: MagicMock
mock_send_event: MagicMock
@@ -73,11 +75,14 @@ async def common_mocks(
thermostat = cast(ThermostatOverSwitch, await create_thermostat(
hass, config_entry, "climate.theoverswitchmockname"
))
with patch("homeassistant.core.StateMachine.get") as mock_get_state:
mock_get_state.return_value.state = "off"
yield CommonMocks(
config_entry=config_entry,
hass=hass,
thermostat=thermostat,
mock_is_state=mock_is_state,
mock_get_state=mock_get_state,
mock_service_call=mock_service_call,
mock_async_track_time_interval=mock_async_track_time_interval,
mock_send_event=mock_send_event,
@@ -256,3 +261,123 @@ class TestKeepAlive:
call("switch", SERVICE_TURN_OFF, {"entity_id": "switch.mock_switch"}),
],
)
class TestBackoffTimer:
"""Test the keep_alive.BackoffTimer helper class."""
def test_exponential_period_increase(self):
"""Test that consecutive calls to is_ready() produce increasing wait periods."""
with patch(
"custom_components.versatile_thermostat.keep_alive.monotonic"
) as mock_monotonic:
timer = BackoffTimer(
multiplier=2,
lower_limit_sec=30,
upper_limit_sec=86400,
initially_ready=True,
)
mock_monotonic.return_value = 100
assert timer.is_ready()
mock_monotonic.return_value = 129
assert not timer.is_ready()
mock_monotonic.return_value = 130
assert timer.is_ready()
mock_monotonic.return_value = 188
assert not timer.is_ready()
mock_monotonic.return_value = 189
assert not timer.is_ready()
mock_monotonic.return_value = 190
assert timer.is_ready()
mock_monotonic.return_value = 309
assert not timer.is_ready()
def test_the_upper_limit_option(self):
"""Test the timer.in_progress property and the effect of timer.reset()."""
with patch(
"custom_components.versatile_thermostat.keep_alive.monotonic"
) as mock_monotonic:
timer = BackoffTimer(
multiplier=2,
lower_limit_sec=30,
upper_limit_sec=50,
initially_ready=True,
)
mock_monotonic.return_value = 100
assert timer.is_ready()
mock_monotonic.return_value = 129
assert not timer.is_ready()
mock_monotonic.return_value = 130
assert timer.is_ready()
mock_monotonic.return_value = 178
assert not timer.is_ready()
mock_monotonic.return_value = 179
assert not timer.is_ready()
mock_monotonic.return_value = 180
assert timer.is_ready()
mock_monotonic.return_value = 229
assert not timer.is_ready()
mock_monotonic.return_value = 230
assert timer.is_ready()
def test_the_lower_limit_option(self):
"""Test the timer.in_progress property and the effect of timer.reset()."""
with patch(
"custom_components.versatile_thermostat.keep_alive.monotonic"
) as mock_monotonic:
timer = BackoffTimer(
multiplier=0.5,
lower_limit_sec=30,
upper_limit_sec=50,
initially_ready=True,
)
mock_monotonic.return_value = 100
assert timer.is_ready()
mock_monotonic.return_value = 129
assert not timer.is_ready()
mock_monotonic.return_value = 130
assert timer.is_ready()
mock_monotonic.return_value = 158
assert not timer.is_ready()
mock_monotonic.return_value = 159
assert not timer.is_ready()
mock_monotonic.return_value = 160
assert timer.is_ready()
def test_initial_is_ready_result(self):
"""Test that the first call to is_ready() produces the initially_ready option value."""
with patch(
"custom_components.versatile_thermostat.keep_alive.monotonic"
) as mock_monotonic:
for initial in [True, False]:
timer = BackoffTimer(
multiplier=2,
lower_limit_sec=30,
upper_limit_sec=86400,
initially_ready=initial,
)
mock_monotonic.return_value = 100
assert timer.is_ready() == initial
assert not timer.is_ready()
def test_in_progress_and_reset(self):
"""Test the timer.in_progress property and the effect of timer.reset()."""
with patch(
"custom_components.versatile_thermostat.keep_alive.monotonic"
) as mock_monotonic:
timer = BackoffTimer(
multiplier=2,
lower_limit_sec=30,
upper_limit_sec=86400,
initially_ready=True,
)
mock_monotonic.return_value = 100
assert not timer.in_progress
assert timer.is_ready()
assert timer.in_progress
assert not timer.is_ready()
timer.reset()
assert not timer.in_progress
assert timer.is_ready()
assert timer.in_progress
assert not timer.is_ready()