Compare commits

..

8 Commits

Author SHA1 Message Date
Jean-Marc Collin
b4873bfd27 FIX issue_479 (#480)
Co-authored-by: Jean-Marc Collin <jean-marc.collin-extern@renault.com>
2024-07-02 07:04:47 +02:00
Jean-Marc Collin
b00dc09c80 Update manifest.json 2024-06-30 09:05:14 +02:00
Jean-Marc Collin
da6d6cbce6 Release 6.2.6 2024-06-30 09:04:51 +02:00
Paulo Ferreira de Castro
864e904e21 Reduce keep-alive warning logs when the controlled entity becomes unavailable (#476) 2024-06-28 09:53:40 +02:00
cddu33
0ee4fe355d issue with ac and mouvement detection (#471)
* Update base_thermostat.py

issue with ac and mouvement detection

* issue ac with detection mouvement

modif class find_preset_temps for preset activity
2024-06-17 10:18:03 +02:00
Maxwell Gonsalves
53dce224cd Change VTherm temperature unit to HA's preferred unit. (#461)
* Change VTherm temperature unit to HA's preferred unit.

* fix pytest issue

* update current_temperature, explicitly convert temps (fixes pytest error)
2024-06-10 18:46:45 +02:00
Jean-Marc Collin
2fd60074c7 Beers ! 2024-05-28 08:47:14 +02:00
jkreiss-coexya
549423b313 Enhance temperature regulation when working with internal device temperature (#453)
* [feature/autoregulation-send-for-underlyingtemp] Do not forget regulation send when using underlying device temperature for offset

* [feature/autoregulation-send-for-underlyingtemp] Add unit test for dtemp = 0

* [feature/autoregulation-send-for-underlyingtemp] Test with dtemp lower than 0.5

* [feature/autoregulation-send-for-underlyingtemp] Comments
2024-05-13 08:27:52 +02:00
11 changed files with 428 additions and 69 deletions

View File

@@ -212,7 +212,7 @@ En conséquence toute la phase de paramètrage d'un VTherm a été profondemment
</details> </details>
# Merci pour la bière [buymecoffee](https://www.buymeacoffee.com/jmcollin78) # Merci pour la bière [buymecoffee](https://www.buymeacoffee.com/jmcollin78)
Un grand merci à @salabur, @pvince83, @bergoglio, @EPicLURcher, @ecolorado66, @Kriss1670, @maia, @f.maymil, @moutte69, @Jerome, @Gunnar M, @Greg.o, @John Burgess, @abyssmal, @capinfo26, @Helge, @MattG @Mexx62, @Someone, @Lajull, @giopeco, @fredericselier, @philpagan, @studiogriffanti, @Edwin, @Sebbou, @Gerard R., @John Burgess, @Sylvoliv, @cdenfert, @stephane.l pour les bières. Ca fait très plaisir et ça m'encourage à continuer ! Un grand merci à @salabur, @pvince83, @bergoglio, @EPicLURcher, @ecolorado66, @Kriss1670, @maia, @f.maymil, @moutte69, @Jerome, @Gunnar M, @Greg.o, @John Burgess, @abyssmal, @capinfo26, @Helge, @MattG @Mexx62, @Someone, @Lajull, @giopeco, @fredericselier, @philpagan, @studiogriffanti, @Edwin, @Sebbou, @Gerard R., @John Burgess, @Sylvoliv, @cdenfert, @stephane.l, @jms92100 pour les bières. Ca fait très plaisir et ça m'encourage à continuer !
# Quand l'utiliser et ne pas l'utiliser # Quand l'utiliser et ne pas l'utiliser

View File

@@ -213,7 +213,7 @@ Consequently, the entire configuration phase of a VTherm has been profoundly mod
</details> </details>
# Thanks for the beer [buymecoffee](https://www.buymeacoffee.com/jmcollin78) # Thanks for the beer [buymecoffee](https://www.buymeacoffee.com/jmcollin78)
Many thanks to @salabur, @pvince83, @bergoglio, @EPicLURcher, @ecolorado66, @Kriss1670, @maia, @f.maymil, @moutte69, @Jerome, @Gunnar M, @Greg.o, @John Burgess, @abyssmal, @capinfo26, @Helge, @MattG, @MattG, @Mexx62, @Someone, @Lajull, @giopeco, @fredericselier, @philpagan, @studiogriffanti, @Edwin, @Sebbou, @Gerard R., @John Burgess, @Sylvoliv, @cdenfert, @stephane.l for the beers. It's very nice and encourages me to continue! Many thanks to @salabur, @pvince83, @bergoglio, @EPicLURcher, @ecolorado66, @Kriss1670, @maia, @f.maymil, @moutte69, @Jerome, @Gunnar M, @Greg.o, @John Burgess, @abyssmal, @capinfo26, @Helge, @MattG, @MattG, @Mexx62, @Someone, @Lajull, @giopeco, @fredericselier, @philpagan, @studiogriffanti, @Edwin, @Sebbou, @Gerard R., @John Burgess, @Sylvoliv, @cdenfert, @stephane.l, @jms92100 for the beers. It's very nice and encourages me to continue!
# When to use / not use # When to use / not use
This thermostat can control 3 types of equipment: This thermostat can control 3 types of equipment:

View File

@@ -737,37 +737,37 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
) )
need_write_state = True need_write_state = True
# try to acquire window entity state # try to acquire window entity state
if self._window_sensor_entity_id: if self._window_sensor_entity_id:
window_state = self.hass.states.get(self._window_sensor_entity_id) window_state = self.hass.states.get(self._window_sensor_entity_id)
if window_state and window_state.state not in ( if window_state and window_state.state not in (
STATE_UNAVAILABLE, STATE_UNAVAILABLE,
STATE_UNKNOWN, STATE_UNKNOWN,
): ):
self._window_state = window_state.state == STATE_ON self._window_state = window_state.state == STATE_ON
_LOGGER.debug( _LOGGER.debug(
"%s - Window state have been retrieved: %s", "%s - Window state have been retrieved: %s",
self, self,
self._window_state, self._window_state,
) )
need_write_state = True need_write_state = True
# try to acquire motion entity state # try to acquire motion entity state
if self._motion_sensor_entity_id: if self._motion_sensor_entity_id:
motion_state = self.hass.states.get(self._motion_sensor_entity_id) motion_state = self.hass.states.get(self._motion_sensor_entity_id)
if motion_state and motion_state.state not in ( if motion_state and motion_state.state not in (
STATE_UNAVAILABLE, STATE_UNAVAILABLE,
STATE_UNKNOWN, STATE_UNKNOWN,
): ):
self._motion_state = motion_state.state self._motion_state = motion_state.state
_LOGGER.debug( _LOGGER.debug(
"%s - Motion state have been retrieved: %s", "%s - Motion state have been retrieved: %s",
self, self,
self._motion_state, self._motion_state,
) )
# recalculate the right target_temp in activity mode # recalculate the right target_temp in activity mode
await self._async_update_motion_temp() await self._async_update_motion_temp()
need_write_state = True need_write_state = True
if self._presence_on: if self._presence_on:
# try to acquire presence entity state # try to acquire presence entity state
@@ -1377,11 +1377,19 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
if preset_mode == PRESET_POWER: if preset_mode == PRESET_POWER:
return self._power_temp return self._power_temp
if preset_mode == PRESET_ACTIVITY: if preset_mode == PRESET_ACTIVITY:
motion_preset = ( if self._ac_mode and self._hvac_mode == HVACMode.COOL:
self._motion_preset motion_preset = (
if self._motion_state == STATE_ON self._motion_preset + PRESET_AC_SUFFIX
else self._no_motion_preset if self._motion_state == STATE_ON
) else self._no_motion_preset + PRESET_AC_SUFFIX
)
else:
motion_preset = (
self._motion_preset
if self._motion_state == STATE_ON
else self._no_motion_preset
)
if motion_preset in self._presets: if motion_preset in self._presets:
return self._presets[motion_preset] return self._presets[motion_preset]
else: else:
@@ -1646,6 +1654,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
_LOGGER.debug("%s - Motion delay condition is satisfied", self) _LOGGER.debug("%s - Motion delay condition is satisfied", self)
self._motion_state = new_state.state self._motion_state = new_state.state
if self._attr_preset_mode == PRESET_ACTIVITY: if self._attr_preset_mode == PRESET_ACTIVITY:
new_preset = ( new_preset = (
self._motion_preset self._motion_preset
if self._motion_state == STATE_ON if self._motion_state == STATE_ON
@@ -1658,6 +1667,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
) )
# We do not change the preset which is kept to ACTIVITY but only the target_temperature # We do not change the preset which is kept to ACTIVITY but only the target_temperature
# We take the presence into account # We take the presence into account
await self._async_internal_set_temperature( await self._async_internal_set_temperature(
self.find_preset_temp(new_preset) self.find_preset_temp(new_preset)
) )
@@ -1896,16 +1906,23 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
): ):
return return
new_preset = (
self._motion_preset
if self._motion_state == STATE_ON
else self._no_motion_preset
)
_LOGGER.info(
"%s - Motion condition have changes. New preset temp will be %s",
self,
new_preset,
)
# We do not change the preset which is kept to ACTIVITY but only the target_temperature
# We take the presence into account
await self._async_internal_set_temperature( await self._async_internal_set_temperature(
self._presets.get( self.find_preset_temp(new_preset)
(
self._motion_preset
if self._motion_state == STATE_ON
else self._no_motion_preset
),
None,
)
) )
_LOGGER.debug( _LOGGER.debug(
"%s - regarding motion, target_temp have been set to %.2f", "%s - regarding motion, target_temp have been set to %.2f",
self, self,

View File

@@ -10,6 +10,7 @@ the keep_alive setting of Home Assistant's Generic Thermostat integration:
import logging import logging
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from datetime import timedelta, datetime from datetime import timedelta, datetime
from time import monotonic
from homeassistant.core import HomeAssistant, CALLBACK_TYPE from homeassistant.core import HomeAssistant, CALLBACK_TYPE
from homeassistant.helpers.event import async_track_time_interval 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__) _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: class IntervalCaller:
"""Repeatedly call a given async action function at a given regular interval. """Repeatedly call a given async action function at a given regular interval.
@@ -28,6 +102,7 @@ class IntervalCaller:
self._hass = hass self._hass = hass
self._interval_sec = interval_sec self._interval_sec = interval_sec
self._remove_handle: CALLBACK_TYPE | None = None self._remove_handle: CALLBACK_TYPE | None = None
self.backoff_timer = BackoffTimer()
@property @property
def interval_sec(self) -> float: def interval_sec(self) -> float:

View File

@@ -14,6 +14,6 @@
"quality_scale": "silver", "quality_scale": "silver",
"requirements": [], "requirements": [],
"ssdp": [], "ssdp": [],
"version": "6.2.3", "version": "6.2.7",
"zeroconf": [] "zeroconf": []
} }

View File

@@ -168,11 +168,17 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
_LOGGER.info("%s - regulation calculation will be done", self) _LOGGER.info("%s - regulation calculation will be done", self)
# use _attr_target_temperature_step to round value if _auto_regulation_dtemp is equal to 0
regulation_step = self._auto_regulation_dtemp if self._auto_regulation_dtemp else self._attr_target_temperature_step
_LOGGER.debug("%s - usage of regulation_step: %.2f ",
self,
regulation_step)
new_regulated_temp = round_to_nearest( new_regulated_temp = round_to_nearest(
self._regulation_algo.calculate_regulated_temperature( self._regulation_algo.calculate_regulated_temperature(
self.current_temperature, self._cur_ext_temp self.current_temperature, self._cur_ext_temp
), ),
self._auto_regulation_dtemp, regulation_step,
) )
dtemp = new_regulated_temp - self._regulated_target_temp dtemp = new_regulated_temp - self._regulated_target_temp
@@ -216,7 +222,7 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
): ):
offset_temp = device_temp - self.current_temperature offset_temp = device_temp - self.current_temperature
target_temp = round_to_nearest(self.regulated_target_temp + offset_temp, self._auto_regulation_dtemp) target_temp = round_to_nearest(self.regulated_target_temp + offset_temp, regulation_step)
_LOGGER.debug( _LOGGER.debug(
"%s - The device offset temp for regulation is %.2f - internal temp is %.2f. New target is %.2f", "%s - The device offset temp for regulation is %.2f - internal temp is %.2f. New target is %.2f",
@@ -894,10 +900,7 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
@property @property
def temperature_unit(self) -> str: def temperature_unit(self) -> str:
"""Return the unit of measurement.""" """Return the unit of measurement."""
if self.underlying_entity(0): return self.hass.config.units.temperature_unit
return self.underlying_entity(0).temperature_unit
return self._unit
@property @property
def supported_features(self): def supported_features(self):

View File

@@ -5,7 +5,7 @@ import logging
from typing import Any from typing import Any
from enum import StrEnum 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.core import State
from homeassistant.exceptions import ServiceNotFound from homeassistant.exceptions import ServiceNotFound
@@ -30,6 +30,7 @@ from homeassistant.components.number import SERVICE_SET_VALUE
from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.event import async_call_later from homeassistant.helpers.event import async_call_later
from homeassistant.util.unit_conversion import TemperatureConverter
from .const import UnknownEntity, overrides from .const import UnknownEntity, overrides
from .keep_alive import IntervalCaller from .keep_alive import IntervalCaller
@@ -252,7 +253,28 @@ class UnderlyingSwitch(UnderlyingEntity):
async def _keep_alive_callback(self): async def _keep_alive_callback(self):
"""Keep alive: Turn on if already turned on, turn off if already turned off.""" """Keep alive: Turn on if already turned on, turn off if already turned off."""
await (self.turn_on() if self.is_device_active else self.turn_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 # @overrides this breaks some unit tests TypeError: object MagicMock can't be used in 'await' expression
async def turn_off(self): async def turn_off(self):
@@ -704,7 +726,7 @@ class UnderlyingClimate(UnderlyingEntity):
if not hasattr(self._underlying_climate, "current_temperature"): if not hasattr(self._underlying_climate, "current_temperature"):
return None return None
return self._underlying_climate.current_temperature return self._hass.states.get(self._entity_id).attributes.get("current_temperature")
def turn_aux_heat_on(self) -> None: def turn_aux_heat_on(self) -> None:
"""Turn auxiliary heater on.""" """Turn auxiliary heater on."""
@@ -731,8 +753,12 @@ class UnderlyingClimate(UnderlyingEntity):
self._underlying_climate.min_temp is not None self._underlying_climate.min_temp is not None
and self._underlying_climate is not None and self._underlying_climate is not None
): ):
min_val = self._underlying_climate.min_temp min_val = TemperatureConverter.convert(
max_val = self._underlying_climate.max_temp self._underlying_climate.min_temp, self._underlying_climate.temperature_unit, self._hass.config.units.temperature_unit
)
max_val = TemperatureConverter.convert(
self._underlying_climate.max_temp, self._underlying_climate.temperature_unit, self._hass.config.units.temperature_unit
)
new_value = max(min_val, min(value, max_val)) new_value = max(min_val, min(value, max_val))
else: else:

View File

@@ -3,5 +3,5 @@
"content_in_root": false, "content_in_root": false,
"render_readme": true, "render_readme": true,
"hide_default_branch": false, "hide_default_branch": false,
"homeassistant": "2024.4.3" "homeassistant": "2024.6.1"
} }

View File

@@ -1 +1 @@
homeassistant==2024.4.3 homeassistant==2024.6.1

View File

@@ -544,3 +544,116 @@ async def test_over_climate_regulation_use_device_temp(
), ),
] ]
) )
@pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [True])
async def test_over_climate_regulation_dtemp_null(
hass: HomeAssistant, skip_hass_states_is_state, skip_send_event
):
"""Test the regulation of an over climate thermostat with no Dtemp limitation"""
entry = MockConfigEntry(
domain=DOMAIN,
title="TheOverClimateMockName",
unique_id="uniqueId",
# This is include a medium regulation
data=PARTIAL_CLIMATE_AC_CONFIG | {CONF_AUTO_REGULATION_DTEMP: 0, CONF_STEP_TEMPERATURE: 0.1},
)
tz = get_tz(hass) # pylint: disable=invalid-name
now: datetime = datetime.now(tz=tz)
fake_underlying_climate = MockClimate(hass, "mockUniqueId", "MockClimateName", {})
# Creates the regulated VTherm over climate
# change temperature so that the heating will start
event_timestamp = now - timedelta(minutes=20)
with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now",
return_value=event_timestamp,
), patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate",
return_value=fake_underlying_climate,
):
entity = await create_thermostat(hass, entry, "climate.theoverclimatemockname")
assert entity
assert isinstance(entity, ThermostatOverClimate)
assert entity.name == "TheOverClimateMockName"
assert entity.is_over_climate is True
assert entity.is_regulated is True
# Activate the heating by changing HVACMode and temperature
# Select a hvacmode, presence and preset
await entity.async_set_hvac_mode(HVACMode.HEAT)
assert entity.hvac_mode is HVACMode.HEAT
assert entity.hvac_action == HVACAction.OFF
# change temperature so that the heating will start
await send_temperature_change_event(entity, 15, event_timestamp)
await send_ext_temperature_change_event(entity, 10, event_timestamp)
# set manual target temp
event_timestamp = now - timedelta(minutes=17)
with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now",
return_value=event_timestamp,
):
await entity.async_set_temperature(temperature=20)
fake_underlying_climate.set_hvac_action(
HVACAction.HEATING
) # simulate under cooling
assert entity.hvac_action == HVACAction.HEATING
assert entity.preset_mode == PRESET_NONE # Manual mode
# the regulated temperature should be lower
assert entity.regulated_target_temp > entity.target_temperature
assert (
entity.regulated_target_temp == 20 + 2.4
) # In medium we could go up to +3 degre
assert entity.hvac_action == HVACAction.HEATING
# change temperature so that the regulated temperature should slow down
event_timestamp = now - timedelta(minutes=15)
with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now",
return_value=event_timestamp,
):
await send_temperature_change_event(entity, 19, event_timestamp)
await send_ext_temperature_change_event(entity, 10, event_timestamp)
# the regulated temperature should be greater
assert entity.regulated_target_temp > entity.target_temperature
assert (
entity.regulated_target_temp == 20 + 0.9
)
# change temperature so that the regulated temperature should slow down
event_timestamp = now - timedelta(minutes=13)
with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now",
return_value=event_timestamp,
):
await send_temperature_change_event(entity, 20, event_timestamp)
await send_ext_temperature_change_event(entity, 10, event_timestamp)
# the regulated temperature should be greater
assert entity.regulated_target_temp > entity.target_temperature
assert (
entity.regulated_target_temp == 20 + 0.5
)
old_regulated_temp = entity.regulated_target_temp
# Test if a small temperature change is taken into account : change temperature so that dtemp < 0.5 and time is > period_min (+ 3min)
event_timestamp = now - timedelta(minutes=10)
with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now",
return_value=event_timestamp,
):
await send_temperature_change_event(entity, 19.6, event_timestamp)
await send_ext_temperature_change_event(entity, 10, event_timestamp)
# the regulated temperature should be greater. This does not work if dtemp is not null
assert entity.regulated_target_temp > old_regulated_temp

View File

@@ -6,6 +6,7 @@ from unittest.mock import ANY, _Call, call, patch
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import cast from typing import cast
from custom_components.versatile_thermostat.keep_alive import BackoffTimer
from custom_components.versatile_thermostat.thermostat_switch import ( from custom_components.versatile_thermostat.thermostat_switch import (
ThermostatOverSwitch, ThermostatOverSwitch,
) )
@@ -52,6 +53,7 @@ class CommonMocks:
hass: HomeAssistant hass: HomeAssistant
thermostat: ThermostatOverSwitch thermostat: ThermostatOverSwitch
mock_is_state: MagicMock mock_is_state: MagicMock
mock_get_state: MagicMock
mock_service_call: MagicMock mock_service_call: MagicMock
mock_async_track_time_interval: MagicMock mock_async_track_time_interval: MagicMock
mock_send_event: MagicMock mock_send_event: MagicMock
@@ -73,15 +75,18 @@ async def common_mocks(
thermostat = cast(ThermostatOverSwitch, await create_thermostat( thermostat = cast(ThermostatOverSwitch, await create_thermostat(
hass, config_entry, "climate.theoverswitchmockname" hass, config_entry, "climate.theoverswitchmockname"
)) ))
yield CommonMocks( with patch("homeassistant.core.StateMachine.get") as mock_get_state:
config_entry=config_entry, mock_get_state.return_value.state = "off"
hass=hass, yield CommonMocks(
thermostat=thermostat, config_entry=config_entry,
mock_is_state=mock_is_state, hass=hass,
mock_service_call=mock_service_call, thermostat=thermostat,
mock_async_track_time_interval=mock_async_track_time_interval, mock_is_state=mock_is_state,
mock_send_event=mock_send_event, 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,
)
# Clean the entity # Clean the entity
thermostat.remove_thermostat() thermostat.remove_thermostat()
@@ -256,3 +261,123 @@ class TestKeepAlive:
call("switch", SERVICE_TURN_OFF, {"entity_id": "switch.mock_switch"}), 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()