Compare commits
8 Commits
6.2.4.beta
...
6.2.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4873bfd27 | ||
|
|
b00dc09c80 | ||
|
|
da6d6cbce6 | ||
|
|
864e904e21 | ||
|
|
0ee4fe355d | ||
|
|
53dce224cd | ||
|
|
2fd60074c7 | ||
|
|
549423b313 |
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -14,6 +14,6 @@
|
|||||||
"quality_scale": "silver",
|
"quality_scale": "silver",
|
||||||
"requirements": [],
|
"requirements": [],
|
||||||
"ssdp": [],
|
"ssdp": [],
|
||||||
"version": "6.2.3",
|
"version": "6.2.7",
|
||||||
"zeroconf": []
|
"zeroconf": []
|
||||||
}
|
}
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
homeassistant==2024.4.3
|
homeassistant==2024.6.1
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user