Compare commits

..

22 Commits

Author SHA1 Message Date
Jean-Marc Collin
70f91f3cbe Issue #524 switch from cool to heat don't change the target temp (#529)
* Preparation tests ok

* Fixed

---------

Co-authored-by: Jean-Marc Collin <jean-marc.collin-extern@renault.com>
2024-10-08 17:06:16 +02:00
Jean-Marc Collin
668053b352 Fix unit_test 2024-10-07 05:22:57 +00:00
Jean-Marc Collin
6ff9ff1ee5 Fix variables in error log 2024-10-07 04:52:33 +00:00
Jean-Marc Collin
3f95ed74f4 FIX TypeError: '>' not supported between instances of 'float' and 'NoneType' error message 2024-10-06 09:04:47 +00:00
Jean-Marc Collin
6e42904ddf Issue #518 - Fix ThermostatOverClimate object has no attribute __attr_preset_modes 2024-10-06 08:58:58 +00:00
Jean-Marc Collin
4c1fc396fb Issue #500 - check feature is use central config is checked (#513)
Co-authored-by: Jean-Marc Collin <jean-marc.collin-extern@renault.com>
2024-09-29 11:34:39 +02:00
Jean-Marc Collin
d6ec7a86be issue #506 - Add some check to verify tpi algorithm parameters are correctly set. (#512)
Co-authored-by: Jean-Marc Collin <jean-marc.collin-extern@renault.com>
2024-09-29 09:54:39 +02:00
Jean-Marc Collin
a3f8715fe5 HA 2024.9.3 and issue 508 (#510)
* HA 2024.9.3 and issue 508

* Fix strings trailing spaces

---------

Co-authored-by: Jean-Marc Collin <jean-marc.collin-extern@renault.com>
2024-09-28 19:36:46 +02:00
domozer
a1a9a8bbab Update README-fr.md (#487)
Remplacement de "Awesome Thermostat" par "Versatile Thermostat" dans la section "Encore mieux avec le composant Scheduler !"

Idem pour le fichier README.md
2024-07-23 19:24:56 +02:00
Jean-Marc Collin
d5c5869276 Update settings 2024-07-17 06:50:01 +00:00
Jean-Marc Collin
c4b03f8c1e Update manifest.json 2024-07-07 16:49:22 +02:00
Paulo Ferreira de Castro
ac206a949f Fix Home Assistant deprecation warnings (EventType, helpers.service) (#484)
* Type hints: Replace deprecated helpers.typing.EventType with core.Event

* Replace deprecated use of hass.helpers.service.async_register_admin_service
2024-07-07 16:47:30 +02:00
Jean-Marc Collin
4bccb746b8 Release 6.2.8 2024-07-02 05:18:29 +00:00
Jean-Marc Collin
e999705286 Issue 474 - TPI in AC mode is wrong 2024-07-02 05:17:14 +00:00
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
30 changed files with 1059 additions and 171 deletions

View File

@@ -4,7 +4,6 @@
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.formatOnSaveMode": "modifications" "editor.formatOnSaveMode": "modifications"
}, },
"pylint.lintOnChange": false,
"files.associations": { "files.associations": {
"*.yaml": "home-assistant" "*.yaml": "home-assistant"
}, },

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
@@ -1202,9 +1202,9 @@ Une carte spéciale pour le Versatile Thermostat a été développée (sur la ba
## Encore mieux avec le composant Scheduler ! ## Encore mieux avec le composant Scheduler !
Afin de profiter de toute la puissance du Versatile Thermostat, je vous invite à l'utiliser avec https://github.com/nielsfaber/scheduler-component Afin de profiter de toute la puissance du Versatile Thermostat, je vous invite à l'utiliser avec https://github.com/nielsfaber/scheduler-component
En effet, le composant scheduler propose une gestion de la base climatique sur les modes prédéfinis. Cette fonctionnalité a un intérêt limité avec le thermostat générique mais elle devient très puissante avec le thermostat Awesome : En effet, le composant scheduler propose une gestion de la base climatique sur les modes prédéfinis. Cette fonctionnalité a un intérêt limité avec le thermostat générique mais elle devient très puissante avec le Versatile Thermostat :
À partir d'ici, je suppose que vous avez installé Awesome Thermostat et Scheduler Component. À partir d'ici, je suppose que vous avez installé Versatile Thermostat et Scheduler Component.
Dans Scheduler, ajoutez un planning : Dans Scheduler, ajoutez un planning :

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

@@ -13,6 +13,7 @@ from homeassistant.const import SERVICE_RELOAD, EVENT_HOMEASSISTANT_STARTED
from homeassistant.config_entries import ConfigEntry, ConfigType from homeassistant.config_entries import ConfigEntry, ConfigType
from homeassistant.core import HomeAssistant, CoreState, callback from homeassistant.core import HomeAssistant, CoreState, callback
from homeassistant.helpers.service import async_register_admin_service
from .base_thermostat import BaseThermostat from .base_thermostat import BaseThermostat
@@ -115,7 +116,8 @@ async def async_setup(
else: else:
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _async_startup_internal) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _async_startup_internal)
hass.helpers.service.async_register_admin_service( async_register_admin_service(
hass,
DOMAIN, DOMAIN,
SERVICE_RELOAD, SERVICE_RELOAD,
_handle_reload, _handle_reload,

View File

@@ -13,7 +13,6 @@ from homeassistant.util import dt as dt_util
from homeassistant.core import ( from homeassistant.core import (
HomeAssistant, HomeAssistant,
callback, callback,
CoreState,
Event, Event,
State, State,
) )
@@ -22,7 +21,6 @@ from homeassistant.components.climate import ClimateEntity
from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.device_registry import DeviceInfo, DeviceEntryType from homeassistant.helpers.device_registry import DeviceInfo, DeviceEntryType
from homeassistant.helpers.typing import EventType as HASSEventType
from homeassistant.helpers.event import ( from homeassistant.helpers.event import (
async_track_state_change_event, async_track_state_change_event,
@@ -58,7 +56,6 @@ from homeassistant.const import (
STATE_UNKNOWN, STATE_UNKNOWN,
STATE_OFF, STATE_OFF,
STATE_ON, STATE_ON,
EVENT_HOMEASSISTANT_START,
STATE_HOME, STATE_HOME,
STATE_NOT_HOME, STATE_NOT_HOME,
) )
@@ -300,7 +297,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
self._presets: dict[str, Any] = {} # presets self._presets: dict[str, Any] = {} # presets
self._presets_away: dict[str, Any] = {} # presets_away self._presets_away: dict[str, Any] = {} # presets_away
self._attr_preset_modes: list[str] | None self._attr_preset_modes: list[str] = []
self._use_central_config_temperature = False self._use_central_config_temperature = False
@@ -737,37 +734,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
@@ -1238,7 +1235,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
) )
# If AC is on maybe we have to change the temperature in force mode, but not in frost mode (there is no Frost protection possible in AC mode) # If AC is on maybe we have to change the temperature in force mode, but not in frost mode (there is no Frost protection possible in AC mode)
if self._hvac_mode == HVACMode.COOL and self.preset_mode != PRESET_NONE: if self._hvac_mode in [HVACMode.COOL, HVACMode.HEAT, HVACMode.HEAT_COOL] and self.preset_mode != PRESET_NONE:
if self.preset_mode != PRESET_FROST_PROTECTION: if self.preset_mode != PRESET_FROST_PROTECTION:
await self._async_set_preset_mode_internal(self.preset_mode, True) await self._async_set_preset_mode_internal(self.preset_mode, True)
else: else:
@@ -1377,11 +1374,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 +1651,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 +1664,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)
) )
@@ -1780,7 +1787,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
_LOGGER.error("Unable to update external temperature from sensor: %s", ex) _LOGGER.error("Unable to update external temperature from sensor: %s", ex)
@callback @callback
async def _async_power_changed(self, event: HASSEventType[EventStateChangedData]): async def _async_power_changed(self, event: Event[EventStateChangedData]):
"""Handle power changes.""" """Handle power changes."""
_LOGGER.debug("Thermostat %s - Receive new Power event", self.name) _LOGGER.debug("Thermostat %s - Receive new Power event", self.name)
_LOGGER.debug(event) _LOGGER.debug(event)
@@ -1806,9 +1813,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
_LOGGER.error("Unable to update current_power from sensor: %s", ex) _LOGGER.error("Unable to update current_power from sensor: %s", ex)
@callback @callback
async def _async_max_power_changed( async def _async_max_power_changed(self, event: Event[EventStateChangedData]):
self, event: HASSEventType[EventStateChangedData]
):
"""Handle power max changes.""" """Handle power max changes."""
_LOGGER.debug("Thermostat %s - Receive new Power Max event", self.name) _LOGGER.debug("Thermostat %s - Receive new Power Max event", self.name)
_LOGGER.debug(event) _LOGGER.debug(event)
@@ -1833,9 +1838,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
_LOGGER.error("Unable to update current_power from sensor: %s", ex) _LOGGER.error("Unable to update current_power from sensor: %s", ex)
@callback @callback
async def _async_presence_changed( async def _async_presence_changed(self, event: Event[EventStateChangedData]):
self, event: HASSEventType[EventStateChangedData]
):
"""Handle presence changes.""" """Handle presence changes."""
new_state = event.data.get("new_state") new_state = event.data.get("new_state")
_LOGGER.info( _LOGGER.info(
@@ -1896,16 +1899,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,
@@ -2171,7 +2181,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
new_central_mode, new_central_mode,
) )
first_init = self._last_central_mode == None first_init = self._last_central_mode is None
self._last_central_mode = new_central_mode self._last_central_mode = new_central_mode

View File

@@ -72,6 +72,13 @@ async def async_setup_entry(
entity = ThermostatOverClimate(hass, unique_id, name, entry.data) entity = ThermostatOverClimate(hass, unique_id, name, entry.data)
elif vt_type == CONF_THERMOSTAT_VALVE: elif vt_type == CONF_THERMOSTAT_VALVE:
entity = ThermostatOverValve(hass, unique_id, name, entry.data) entity = ThermostatOverValve(hass, unique_id, name, entry.data)
else:
_LOGGER.error(
"Cannot create Versatile Thermostat name=%s of type %s which is unknown",
name,
vt_type,
)
return
async_add_entities([entity], True) async_add_entities([entity], True)

View File

@@ -99,30 +99,31 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
def _init_feature_flags(self, _): def _init_feature_flags(self, _):
"""Fix features selection depending to infos""" """Fix features selection depending to infos"""
is_empty: bool = False # TODO remove this not bool(infos)
is_central_config = ( is_central_config = (
self._infos.get(CONF_THERMOSTAT_TYPE) == CONF_THERMOSTAT_CENTRAL_CONFIG self._infos.get(CONF_THERMOSTAT_TYPE) == CONF_THERMOSTAT_CENTRAL_CONFIG
) )
self._infos[CONF_USE_WINDOW_FEATURE] = ( self._infos[CONF_USE_WINDOW_FEATURE] = (
is_empty self._infos.get(CONF_USE_WINDOW_CENTRAL_CONFIG)
or self._infos.get(CONF_WINDOW_SENSOR) is not None or self._infos.get(CONF_WINDOW_SENSOR) is not None
or self._infos.get(CONF_WINDOW_AUTO_OPEN_THRESHOLD) is not None or self._infos.get(CONF_WINDOW_AUTO_OPEN_THRESHOLD) is not None
) )
self._infos[CONF_USE_MOTION_FEATURE] = ( self._infos[CONF_USE_MOTION_FEATURE] = self._infos.get(
is_empty CONF_USE_MOTION_FEATURE
or self._infos.get(CONF_MOTION_SENSOR) is not None ) and (self._infos.get(CONF_MOTION_SENSOR) is not None or is_central_config)
or is_central_config
) self._infos[CONF_USE_POWER_FEATURE] = self._infos.get(
self._infos[CONF_USE_POWER_FEATURE] = is_empty or ( CONF_USE_POWER_CENTRAL_CONFIG
) or (
self._infos.get(CONF_POWER_SENSOR) is not None self._infos.get(CONF_POWER_SENSOR) is not None
and self._infos.get(CONF_MAX_POWER_SENSOR) is not None and self._infos.get(CONF_MAX_POWER_SENSOR) is not None
) )
self._infos[CONF_USE_PRESENCE_FEATURE] = ( self._infos[CONF_USE_PRESENCE_FEATURE] = (
is_empty or self._infos.get(CONF_PRESENCE_SENSOR) is not None self._infos.get(CONF_USE_PRESENCE_CENTRAL_CONFIG)
or self._infos.get(CONF_PRESENCE_SENSOR) is not None
) )
self._infos[CONF_USE_CENTRAL_BOILER_FEATURE] = is_empty or ( self._infos[CONF_USE_CENTRAL_BOILER_FEATURE] = (
self._infos.get(CONF_CENTRAL_BOILER_ACTIVATION_SRV) is not None self._infos.get(CONF_CENTRAL_BOILER_ACTIVATION_SRV) is not None
and self._infos.get(CONF_CENTRAL_BOILER_DEACTIVATION_SRV) is not None and self._infos.get(CONF_CENTRAL_BOILER_DEACTIVATION_SRV) is not None
) )

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.3.0",
"zeroconf": [] "zeroconf": []
} }

View File

@@ -14,6 +14,10 @@ PROPORTIONAL_MIN_DURATION_SEC = 10
FUNCTION_TYPE = [PROPORTIONAL_FUNCTION_ATAN, PROPORTIONAL_FUNCTION_LINEAR] FUNCTION_TYPE = [PROPORTIONAL_FUNCTION_ATAN, PROPORTIONAL_FUNCTION_LINEAR]
def is_number(value):
return isinstance(value, (int, float))
class PropAlgorithm: class PropAlgorithm:
"""This class aims to do all calculation of the Proportional alogorithm""" """This class aims to do all calculation of the Proportional alogorithm"""
@@ -36,6 +40,30 @@ class PropAlgorithm:
cycle_min, cycle_min,
minimal_activation_delay, minimal_activation_delay,
) )
# Issue 506 - check parameters
if (
vtherm_entity_id is None
or not is_number(tpi_coef_int)
or not is_number(tpi_coef_ext)
or not is_number(cycle_min)
or not is_number(minimal_activation_delay)
or function_type != PROPORTIONAL_FUNCTION_TPI
):
_LOGGER.error(
"%s - configuration is wrong. function_type=%s, entity_id is %s, tpi_coef_int is %s, tpi_coef_ext is %s, cycle_min is %s, minimal_activation_delay is %s",
vtherm_entity_id,
function_type,
vtherm_entity_id,
tpi_coef_int,
tpi_coef_ext,
cycle_min,
minimal_activation_delay,
)
raise TypeError(
f"TPI parameters are not set correctly. VTherm will not work as expected. Please reconfigure it correctly. See previous log for values"
)
self._vtherm_entity_id = vtherm_entity_id self._vtherm_entity_id = vtherm_entity_id
self._function = function_type self._function = function_type
self._tpi_coef_int = tpi_coef_int self._tpi_coef_int = tpi_coef_int
@@ -70,9 +98,9 @@ class PropAlgorithm:
if hvac_mode == HVACMode.COOL: if hvac_mode == HVACMode.COOL:
delta_temp = current_temp - target_temp delta_temp = current_temp - target_temp
delta_ext_temp = ( delta_ext_temp = (
ext_current_temp ext_current_temp - target_temp
if ext_current_temp is not None if ext_current_temp is not None
else 0 - target_temp else 0
) )
else: else:
delta_temp = target_temp - current_temp delta_temp = target_temp - current_temp

View File

@@ -90,7 +90,7 @@
"auto_regulation_periode_min": "Regulation minimum period", "auto_regulation_periode_min": "Regulation minimum period",
"auto_regulation_use_device_temp": "Use internal temperature of the underlying", "auto_regulation_use_device_temp": "Use internal temperature of the underlying",
"inverse_switch_command": "Inverse switch command", "inverse_switch_command": "Inverse switch command",
"auto_fan_mode": " Auto fan mode" "auto_fan_mode": "Auto fan mode"
}, },
"data_description": { "data_description": {
"heater_entity_id": "Mandatory heater entity id", "heater_entity_id": "Mandatory heater entity id",
@@ -113,7 +113,7 @@
"auto_regulation_periode_min": "Duration in minutes between two regulation update", "auto_regulation_periode_min": "Duration in minutes between two regulation update",
"auto_regulation_use_device_temp": "Use the eventual internal temperature sensor of the underlying to speedup the self-regulation", "auto_regulation_use_device_temp": "Use the eventual internal temperature sensor of the underlying to speedup the self-regulation",
"inverse_switch_command": "For switch with pilot wire and diode you may need to inverse the command", "inverse_switch_command": "For switch with pilot wire and diode you may need to inverse the command",
"auto_fan_mode": " Automatically activate fan when huge heating/cooling is necessary" "auto_fan_mode": "Automatically activate fan when huge heating/cooling is necessary"
} }
}, },
"tpi": { "tpi": {
@@ -325,7 +325,7 @@
"auto_regulation_periode_min": "Regulation minimum period", "auto_regulation_periode_min": "Regulation minimum period",
"auto_regulation_use_device_temp": "Use internal temperature of the underlying", "auto_regulation_use_device_temp": "Use internal temperature of the underlying",
"inverse_switch_command": "Inverse switch command", "inverse_switch_command": "Inverse switch command",
"auto_fan_mode": " Auto fan mode" "auto_fan_mode": "Auto fan mode"
}, },
"data_description": { "data_description": {
"heater_entity_id": "Mandatory heater entity id", "heater_entity_id": "Mandatory heater entity id",
@@ -348,7 +348,7 @@
"auto_regulation_periode_min": "Duration in minutes between two regulation update", "auto_regulation_periode_min": "Duration in minutes between two regulation update",
"auto_regulation_use_device_temp": "Use the eventual internal temperature sensor of the underlying to speedup the self-regulation", "auto_regulation_use_device_temp": "Use the eventual internal temperature sensor of the underlying to speedup the self-regulation",
"inverse_switch_command": "For switch with pilot wire and diode you may need to invert the command", "inverse_switch_command": "For switch with pilot wire and diode you may need to invert the command",
"auto_fan_mode": " Automatically activate fan when huge heating/cooling is necessary" "auto_fan_mode": "Automatically activate fan when huge heating/cooling is necessary"
} }
}, },
"tpi": { "tpi": {

View File

@@ -3,13 +3,12 @@
import logging import logging
from datetime import timedelta, datetime from datetime import timedelta, datetime
from homeassistant.core import HomeAssistant, State, callback from homeassistant.core import Event, HomeAssistant, State, callback
from homeassistant.helpers.event import ( from homeassistant.helpers.event import (
async_track_state_change_event, async_track_state_change_event,
async_track_time_interval, async_track_time_interval,
EventStateChangedData, EventStateChangedData,
) )
from homeassistant.helpers.typing import EventType as HASSEventType
from homeassistant.components.climate import ( from homeassistant.components.climate import (
HVACAction, HVACAction,
HVACMode, HVACMode,
@@ -143,7 +142,17 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
"""Sends the regulated temperature to all underlying""" """Sends the regulated temperature to all underlying"""
if self.hvac_mode == HVACMode.OFF: if self.hvac_mode == HVACMode.OFF:
_LOGGER.debug("%s - don't send regulated temperature cause VTherm is off ") _LOGGER.debug(
"%s - don't send regulated temperature cause VTherm is off ", self
)
return
if self.target_temperature is None:
_LOGGER.warning(
"%s - don't send regulated temperature cause VTherm target_temp (%s) is None. This should be a temporary warning message.",
self,
self.target_temperature,
)
return return
_LOGGER.info( _LOGGER.info(
@@ -168,12 +177,19 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
_LOGGER.info("%s - regulation calculation will be done", self) _LOGGER.info("%s - regulation calculation will be done", self)
new_regulated_temp = round_to_nearest( # use _attr_target_temperature_step to round value if _auto_regulation_dtemp is equal to 0
self._regulation_algo.calculate_regulated_temperature( regulation_step = self._auto_regulation_dtemp if self._auto_regulation_dtemp else self._attr_target_temperature_step
self.current_temperature, self._cur_ext_temp _LOGGER.debug("%s - usage regulation_step: %.2f ", self, regulation_step)
),
self._auto_regulation_dtemp, if self.current_temperature is not None:
) new_regulated_temp = round_to_nearest(
self._regulation_algo.calculate_regulated_temperature(
self.current_temperature, self._cur_ext_temp
),
regulation_step,
)
else:
new_regulated_temp = self.target_temperature
dtemp = new_regulated_temp - self._regulated_target_temp dtemp = new_regulated_temp - self._regulated_target_temp
if not force and abs(dtemp) < self._auto_regulation_dtemp: if not force and abs(dtemp) < self._auto_regulation_dtemp:
@@ -198,8 +214,10 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
offset_temp = 0 offset_temp = 0
device_temp = 0 device_temp = 0
if ( if (
# current_temperature is set
self.current_temperature is not None
# regulation can use the device_temp # regulation can use the device_temp
self.auto_regulation_use_device_temp and self.auto_regulation_use_device_temp
# and we have access to the device temp # and we have access to the device temp
and (device_temp := under.underlying_current_temperature) is not None and (device_temp := under.underlying_current_temperature) is not None
# and target is not reach (ie we need regulation) # and target is not reach (ie we need regulation)
@@ -216,7 +234,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",
@@ -594,7 +612,7 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
) )
@callback @callback
async def _async_climate_changed(self, event: HASSEventType[EventStateChangedData]): async def _async_climate_changed(self, event: Event[EventStateChangedData]):
"""Handle unerdlying climate state changes. """Handle unerdlying climate state changes.
This method takes the underlying values and update the VTherm with them. This method takes the underlying values and update the VTherm with them.
To avoid loops (issues #121 #101 #95 #99), we discard the event if it is received To avoid loops (issues #121 #101 #95 #99), we discard the event if it is received
@@ -894,10 +912,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

@@ -2,12 +2,11 @@
""" A climate over switch classe """ """ A climate over switch classe """
import logging import logging
from homeassistant.core import callback from homeassistant.core import Event, callback
from homeassistant.helpers.event import ( from homeassistant.helpers.event import (
async_track_state_change_event, async_track_state_change_event,
EventStateChangedData, EventStateChangedData,
) )
from homeassistant.helpers.typing import EventType as HASSEventType
from homeassistant.components.climate import HVACMode from homeassistant.components.climate import HVACMode
from .const import ( from .const import (
@@ -212,7 +211,7 @@ class ThermostatOverSwitch(BaseThermostat[UnderlyingSwitch]):
) )
@callback @callback
def _async_switch_changed(self, event: HASSEventType[EventStateChangedData]): def _async_switch_changed(self, event: Event[EventStateChangedData]):
"""Handle heater switch state changes.""" """Handle heater switch state changes."""
new_state = event.data.get("new_state") new_state = event.data.get("new_state")
old_state = event.data.get("old_state") old_state = event.data.get("old_state")

View File

@@ -8,8 +8,7 @@ from homeassistant.helpers.event import (
async_track_time_interval, async_track_time_interval,
EventStateChangedData, EventStateChangedData,
) )
from homeassistant.helpers.typing import EventType as HASSEventType from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.core import HomeAssistant, callback
from homeassistant.components.climate import HVACMode from homeassistant.components.climate import HVACMode
from .base_thermostat import BaseThermostat, ConfigData from .base_thermostat import BaseThermostat, ConfigData
@@ -149,7 +148,7 @@ class ThermostatOverValve(BaseThermostat[UnderlyingValve]): # pylint: disable=a
) )
@callback @callback
async def _async_valve_changed(self, event: HASSEventType[EventStateChangedData]): async def _async_valve_changed(self, event: Event[EventStateChangedData]):
"""Handle unerdlying valve state changes. """Handle unerdlying valve state changes.
This method just log the change. It changes nothing to avoid loops. This method just log the change. It changes nothing to avoid loops.
""" """

View File

@@ -43,7 +43,7 @@
"auto_regulation_dtemp": "Όριο ρύθμισης", "auto_regulation_dtemp": "Όριο ρύθμισης",
"auto_regulation_periode_min": "Ελάχιστη περίοδος ρύθμισης", "auto_regulation_periode_min": "Ελάχιστη περίοδος ρύθμισης",
"inverse_switch_command": "Αντίστροφη εντολή διακόπτη", "inverse_switch_command": "Αντίστροφη εντολή διακόπτη",
"auto_fan_mode": " Auto fan mode" "auto_fan_mode": "Auto fan mode"
}, },
"data_description": { "data_description": {
"heater_entity_id": "Υποχρεωτική ταυτότητα οντότητας θερμαντήρα", "heater_entity_id": "Υποχρεωτική ταυτότητα οντότητας θερμαντήρα",
@@ -64,7 +64,7 @@
"auto_regulation_dtemp": "Το όριο σε ° κάτω από το οποίο η αλλαγή θερμοκρασίας δεν θα αποστέλλεται", "auto_regulation_dtemp": "Το όριο σε ° κάτω από το οποίο η αλλαγή θερμοκρασίας δεν θα αποστέλλεται",
"auto_regulation_periode_min": "Διάρκεια σε λεπτά μεταξύ δύο ενημερώσεων ρύθμισης", "auto_regulation_periode_min": "Διάρκεια σε λεπτά μεταξύ δύο ενημερώσεων ρύθμισης",
"inverse_switch_command": "Για διακόπτη με πιλοτικό καλώδιο και δίοδο μπορεί να χρειαστεί να αντιστρέψετε την εντολή", "inverse_switch_command": "Για διακόπτη με πιλοτικό καλώδιο και δίοδο μπορεί να χρειαστεί να αντιστρέψετε την εντολή",
"auto_fan_mode": " Automatically activate fan when huge heating/cooling is necessary" "auto_fan_mode": "Automatically activate fan when huge heating/cooling is necessary"
} }
}, },
"tpi": { "tpi": {
@@ -216,7 +216,7 @@
"auto_regulation_dtemp": "Όριο ρύθμισης", "auto_regulation_dtemp": "Όριο ρύθμισης",
"auto_regulation_periode_min": "Ελάχιστη περίοδος ρύθμισης", "auto_regulation_periode_min": "Ελάχιστη περίοδος ρύθμισης",
"inverse_switch_command": "Αντίστροφη εντολή διακόπτη", "inverse_switch_command": "Αντίστροφη εντολή διακόπτη",
"auto_fan_mode": " Auto fan mode" "auto_fan_mode": "Auto fan mode"
}, },
"data_description": { "data_description": {
"heater_entity_id": "Υποχρεωτική ταυτότητα οντότητας θερμαντήρα", "heater_entity_id": "Υποχρεωτική ταυτότητα οντότητας θερμαντήρα",
@@ -237,7 +237,7 @@
"auto_regulation_dtemp": "Το κατώφλι σε °C κάτω από το οποίο η αλλαγή της θερμοκρασίας δεν θα αποστέλλεται", "auto_regulation_dtemp": "Το κατώφλι σε °C κάτω από το οποίο η αλλαγή της θερμοκρασίας δεν θα αποστέλλεται",
"auto_regulation_periode_min": "Διάρκεια σε λεπτά μεταξύ δύο ενημερώσεων ρύθμισης", "auto_regulation_periode_min": "Διάρκεια σε λεπτά μεταξύ δύο ενημερώσεων ρύθμισης",
"inverse_switch_command": "Για διακόπτες με πιλοτικό καλώδιο και δίοδο μπορεί να χρειαστεί να αντιστραφεί η εντολή", "inverse_switch_command": "Για διακόπτες με πιλοτικό καλώδιο και δίοδο μπορεί να χρειαστεί να αντιστραφεί η εντολή",
"auto_fan_mode": " Automatically activate fan when huge heating/cooling is necessary" "auto_fan_mode": "Automatically activate fan when huge heating/cooling is necessary"
} }
}, },
"tpi": { "tpi": {

View File

@@ -90,7 +90,7 @@
"auto_regulation_periode_min": "Regulation minimum period", "auto_regulation_periode_min": "Regulation minimum period",
"auto_regulation_use_device_temp": "Use internal temperature of the underlying", "auto_regulation_use_device_temp": "Use internal temperature of the underlying",
"inverse_switch_command": "Inverse switch command", "inverse_switch_command": "Inverse switch command",
"auto_fan_mode": " Auto fan mode" "auto_fan_mode": "Auto fan mode"
}, },
"data_description": { "data_description": {
"heater_entity_id": "Mandatory heater entity id", "heater_entity_id": "Mandatory heater entity id",
@@ -113,7 +113,7 @@
"auto_regulation_periode_min": "Duration in minutes between two regulation update", "auto_regulation_periode_min": "Duration in minutes between two regulation update",
"auto_regulation_use_device_temp": "Use the eventual internal temperature sensor of the underlying to speedup the self-regulation", "auto_regulation_use_device_temp": "Use the eventual internal temperature sensor of the underlying to speedup the self-regulation",
"inverse_switch_command": "For switch with pilot wire and diode you may need to inverse the command", "inverse_switch_command": "For switch with pilot wire and diode you may need to inverse the command",
"auto_fan_mode": " Automatically activate fan when huge heating/cooling is necessary" "auto_fan_mode": "Automatically activate fan when huge heating/cooling is necessary"
} }
}, },
"tpi": { "tpi": {
@@ -325,7 +325,7 @@
"auto_regulation_periode_min": "Regulation minimum period", "auto_regulation_periode_min": "Regulation minimum period",
"auto_regulation_use_device_temp": "Use internal temperature of the underlying", "auto_regulation_use_device_temp": "Use internal temperature of the underlying",
"inverse_switch_command": "Inverse switch command", "inverse_switch_command": "Inverse switch command",
"auto_fan_mode": " Auto fan mode" "auto_fan_mode": "Auto fan mode"
}, },
"data_description": { "data_description": {
"heater_entity_id": "Mandatory heater entity id", "heater_entity_id": "Mandatory heater entity id",
@@ -348,7 +348,7 @@
"auto_regulation_periode_min": "Duration in minutes between two regulation update", "auto_regulation_periode_min": "Duration in minutes between two regulation update",
"auto_regulation_use_device_temp": "Use the eventual internal temperature sensor of the underlying to speedup the self-regulation", "auto_regulation_use_device_temp": "Use the eventual internal temperature sensor of the underlying to speedup the self-regulation",
"inverse_switch_command": "For switch with pilot wire and diode you may need to invert the command", "inverse_switch_command": "For switch with pilot wire and diode you may need to invert the command",
"auto_fan_mode": " Automatically activate fan when huge heating/cooling is necessary" "auto_fan_mode": "Automatically activate fan when huge heating/cooling is necessary"
} }
}, },
"tpi": { "tpi": {

View File

@@ -337,7 +337,7 @@
"auto_regulation_periode_min": "Période minimale de régulation", "auto_regulation_periode_min": "Période minimale de régulation",
"auto_regulation_use_device_temp": "Utiliser la température interne du sous-jacent", "auto_regulation_use_device_temp": "Utiliser la température interne du sous-jacent",
"inverse_switch_command": "Inverser la commande", "inverse_switch_command": "Inverser la commande",
"auto_fan_mode": " Auto fan mode" "auto_fan_mode": "Auto fan mode"
}, },
"data_description": { "data_description": {
"heater_entity_id": "Entity id du 1er radiateur obligatoire", "heater_entity_id": "Entity id du 1er radiateur obligatoire",

View File

@@ -42,7 +42,7 @@
"valve_entity4_id": "Quarta valvola", "valve_entity4_id": "Quarta valvola",
"auto_regulation_mode": "Autoregolamentazione", "auto_regulation_mode": "Autoregolamentazione",
"inverse_switch_command": "Comando inverso", "inverse_switch_command": "Comando inverso",
"auto_fan_mode": " Auto fan mode" "auto_fan_mode": "Auto fan mode"
}, },
"data_description": { "data_description": {
"heater_entity_id": "Entity id obbligatoria del primo riscaldatore", "heater_entity_id": "Entity id obbligatoria del primo riscaldatore",
@@ -62,7 +62,7 @@
"valve_entity4_id": "Entity id della quarta valvola", "valve_entity4_id": "Entity id della quarta valvola",
"auto_regulation_mode": "Regolazione automatica della temperatura target", "auto_regulation_mode": "Regolazione automatica della temperatura target",
"inverse_switch_command": "Inverte il controllo dell'interruttore per un'installazione con filo pilota e diodo", "inverse_switch_command": "Inverte il controllo dell'interruttore per un'installazione con filo pilota e diodo",
"auto_fan_mode": " Automatically activate fan when huge heating/cooling is necessary" "auto_fan_mode": "Automatically activate fan when huge heating/cooling is necessary"
} }
}, },
"tpi": { "tpi": {
@@ -206,7 +206,7 @@
"valve_entity4_id": "Quarta valvola", "valve_entity4_id": "Quarta valvola",
"auto_regulation_mode": "Autoregolamentazione", "auto_regulation_mode": "Autoregolamentazione",
"inverse_switch_command": "Comando inverso", "inverse_switch_command": "Comando inverso",
"auto_fan_mode": " Auto fan mode" "auto_fan_mode": "Auto fan mode"
}, },
"data_description": { "data_description": {
"heater_entity_id": "Entity id obbligatoria del primo riscaldatore", "heater_entity_id": "Entity id obbligatoria del primo riscaldatore",
@@ -226,7 +226,7 @@
"valve_entity4_id": "Entity id della quarta valvola", "valve_entity4_id": "Entity id della quarta valvola",
"auto_regulation_mode": "Autoregolamentazione", "auto_regulation_mode": "Autoregolamentazione",
"inverse_switch_command": "Inverte il controllo dell'interruttore per un'installazione con filo pilota e diodo", "inverse_switch_command": "Inverte il controllo dell'interruttore per un'installazione con filo pilota e diodo",
"auto_fan_mode": " Automatically activate fan when huge heating/cooling is necessary" "auto_fan_mode": "Automatically activate fan when huge heating/cooling is necessary"
} }
}, },
"tpi": { "tpi": {

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):
@@ -590,12 +612,22 @@ class UnderlyingClimate(UnderlyingEntity):
if not self.is_initialized: if not self.is_initialized:
return return
data = { # Issue 508 we have to take care of service set_temperature or set_range
ATTR_ENTITY_ID: self._entity_id, target_temp = self.cap_sent_value(temperature)
"temperature": self.cap_sent_value(temperature), if (
"target_temp_high": max_temp, ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
"target_temp_low": min_temp, in self._underlying_climate.supported_features
} ):
data = {
ATTR_ENTITY_ID: self._entity_id,
"target_temp_high": target_temp,
"target_temp_low": target_temp,
}
else:
data = {
ATTR_ENTITY_ID: self._entity_id,
"temperature": target_temp,
}
await self._hass.services.async_call( await self._hass.services.async_call(
CLIMATE_DOMAIN, CLIMATE_DOMAIN,
@@ -704,7 +736,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 +763,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.9.3"
} }

View File

@@ -1 +1 @@
homeassistant==2024.4.3 homeassistant==2024.9.3

View File

@@ -435,6 +435,86 @@ class MagicMockClimate(MagicMock):
return 19 return 19
class MagicMockClimateWithTemperatureRange(MagicMock):
"""A Magic Mock class for a underlying climate entity"""
@property
def temperature_unit(self): # pylint: disable=missing-function-docstring
return UnitOfTemperature.CELSIUS
@property
def hvac_mode(self): # pylint: disable=missing-function-docstring
return HVACMode.HEAT
@property
def hvac_action(self): # pylint: disable=missing-function-docstring
return HVACAction.IDLE
@property
def target_temperature(self): # pylint: disable=missing-function-docstring
return 15
@property
def current_temperature(self): # pylint: disable=missing-function-docstring
return 14
@property
def target_temperature_step( # pylint: disable=missing-function-docstring
self,
) -> float | None:
return 0.5
@property
def target_temperature_high( # pylint: disable=missing-function-docstring
self,
) -> float | None:
return 35
@property
def target_temperature_low( # pylint: disable=missing-function-docstring
self,
) -> float | None:
return 7
@property
def hvac_modes( # pylint: disable=missing-function-docstring
self,
) -> list[str] | None:
return [HVACMode.HEAT, HVACMode.OFF, HVACMode.COOL]
@property
def fan_modes( # pylint: disable=missing-function-docstring
self,
) -> list[str] | None:
return None
@property
def swing_modes( # pylint: disable=missing-function-docstring
self,
) -> list[str] | None:
return None
@property
def fan_mode(self) -> str | None: # pylint: disable=missing-function-docstring
return None
@property
def swing_mode(self) -> str | None: # pylint: disable=missing-function-docstring
return None
@property
def supported_features(self): # pylint: disable=missing-function-docstring
return ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
@property
def min_temp(self): # pylint: disable=missing-function-docstring
return 10
@property
def max_temp(self): # pylint: disable=missing-function-docstring
return 31
class MockSwitch(SwitchEntity): class MockSwitch(SwitchEntity):
"""A fake switch to be used instead real switch""" """A fake switch to be used instead real switch"""
@@ -907,3 +987,8 @@ async def set_climate_preset_temp(
) )
if temp_entity: if temp_entity:
await temp_entity.async_set_native_value(temp) await temp_entity.async_set_native_value(temp)
else:
_LOGGER.warning(
"commons tests set_cliamte_preset_temp: cannot find number entity with entity_id '%s'",
number_entity_id,
)

View File

@@ -53,18 +53,6 @@ async def test_over_climate_regulation(
return_value=fake_underlying_climate, return_value=fake_underlying_climate,
): ):
entity = await create_thermostat(hass, entry, "climate.theoverclimatemockname") entity = await create_thermostat(hass, entry, "climate.theoverclimatemockname")
# entry.add_to_hass(hass)
# await hass.config_entries.async_setup(entry.entry_id)
# assert entry.state is ConfigEntryState.LOADED
#
# def find_my_entity(entity_id) -> ClimateEntity:
# """Find my new entity"""
# component: EntityComponent[ClimateEntity] = hass.data[CLIMATE_DOMAIN]
# for entity in component.entities:
# if entity.entity_id == entity_id:
# return entity
#
# entity: ThermostatOverClimate = find_my_entity("climate.theoverclimatemockname")
assert entity assert entity
assert isinstance(entity, ThermostatOverClimate) assert isinstance(entity, ThermostatOverClimate)
@@ -163,18 +151,6 @@ async def test_over_climate_regulation_ac_mode(
return_value=fake_underlying_climate, return_value=fake_underlying_climate,
): ):
entity = await create_thermostat(hass, entry, "climate.theoverclimatemockname") entity = await create_thermostat(hass, entry, "climate.theoverclimatemockname")
# entry.add_to_hass(hass)
# await hass.config_entries.async_setup(entry.entry_id)
# assert entry.state is ConfigEntryState.LOADED
#
# def find_my_entity(entity_id) -> ClimateEntity:
# """Find my new entity"""
# component: EntityComponent[ClimateEntity] = hass.data[CLIMATE_DOMAIN]
# for entity in component.entities:
# if entity.entity_id == entity_id:
# return entity
#
# entity: ThermostatOverClimate = find_my_entity("climate.theoverclimatemockname")
assert entity assert entity
assert isinstance(entity, ThermostatOverClimate) assert isinstance(entity, ThermostatOverClimate)
@@ -544,3 +520,112 @@ 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

@@ -12,6 +12,18 @@ from homeassistant.components.climate import (
SERVICE_SET_TEMPERATURE, SERVICE_SET_TEMPERATURE,
) )
from custom_components.versatile_thermostat.config_flow import (
VersatileThermostatBaseConfigFlow,
)
from custom_components.versatile_thermostat.thermostat_valve import ThermostatOverValve
from custom_components.versatile_thermostat.thermostat_climate import (
ThermostatOverClimate,
)
from custom_components.versatile_thermostat.thermostat_switch import (
ThermostatOverSwitch,
)
from .commons import * from .commons import *
logging.getLogger().setLevel(logging.DEBUG) logging.getLogger().setLevel(logging.DEBUG)
@@ -657,8 +669,8 @@ async def test_bug_272(
{ {
"entity_id": "climate.mock_climate", "entity_id": "climate.mock_climate",
"temperature": 17.5, "temperature": 17.5,
"target_temp_high": 30, # "target_temp_high": 30,
"target_temp_low": 15, # "target_temp_low": 15,
}, },
), ),
] ]
@@ -687,8 +699,8 @@ async def test_bug_272(
{ {
"entity_id": "climate.mock_climate", "entity_id": "climate.mock_climate",
"temperature": 15, # the minimum acceptable "temperature": 15, # the minimum acceptable
"target_temp_high": 30, # "target_temp_high": 30,
"target_temp_low": 15, # "target_temp_low": 15,
}, },
), ),
] ]
@@ -714,8 +726,8 @@ async def test_bug_272(
{ {
"entity_id": "climate.mock_climate", "entity_id": "climate.mock_climate",
"temperature": 19, # the maximum acceptable "temperature": 19, # the maximum acceptable
"target_temp_high": 30, # "target_temp_high": 30,
"target_temp_low": 15, # "target_temp_low": 15,
}, },
), ),
] ]
@@ -924,3 +936,278 @@ async def test_bug_339(
assert api.nb_active_device_for_boiler == 1 assert api.nb_active_device_for_boiler == 1
entity.remove_thermostat() entity.remove_thermostat()
@pytest.mark.parametrize("expected_lingering_timers", [True])
async def test_bug_508(
hass: HomeAssistant,
skip_hass_states_is_state,
skip_turn_on_off_heater,
skip_send_event,
):
"""Test that it not possible to set the target temperature under the min_temp setting"""
tz = get_tz(hass) # pylint: disable=invalid-name
now: datetime = datetime.now(tz=tz)
entry = MockConfigEntry(
domain=DOMAIN,
title="TheOverClimateMockName",
unique_id="uniqueId",
# default value are min 15°, max 31°, step 0.1
data=PARTIAL_CLIMATE_CONFIG, # 5 minutes security delay
)
# Min_temp is 10 and max_temp is 31 and features contains TARGET_TEMPERATURE_RANGE
fake_underlying_climate = MagicMockClimateWithTemperatureRange()
with patch(
"custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event"
), patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate",
return_value=fake_underlying_climate,
), patch(
"homeassistant.core.ServiceRegistry.async_call"
) as mock_service_call:
entity = await create_thermostat(hass, entry, "climate.theoverclimatemockname")
assert entity
assert entity.name == "TheOverClimateMockName"
assert entity.is_over_climate is True
assert entity.hvac_mode is HVACMode.OFF
# The VTherm value and not the underlying value
assert entity.target_temperature_step == 0.1
assert entity.target_temperature == entity.min_temp
assert entity.is_regulated is True
assert mock_service_call.call_count == 0
# Set the hvac_mode to HEAT
await entity.async_set_hvac_mode(HVACMode.HEAT)
# Not In the accepted interval -> should be converted into 10 (the min) and send with target_temp_high and target_temp_low
await entity.async_set_temperature(temperature=8.5)
# MagicMock climate is already HEAT by default. So there is no SET_HAVC_MODE call
assert mock_service_call.call_count == 1
mock_service_call.assert_has_calls(
[
call.async_call(
"climate",
SERVICE_SET_TEMPERATURE,
{
"entity_id": "climate.mock_climate",
# "temperature": 17.5,
"target_temp_high": 10,
"target_temp_low": 10,
},
),
]
)
with patch("homeassistant.core.ServiceRegistry.async_call") as mock_service_call:
# Not In the accepted interval -> should be converted into 10 (the min) and send with target_temp_high and target_temp_low
await entity.async_set_temperature(temperature=32)
# MagicMock climate is already HEAT by default. So there is no SET_HAVC_MODE call
assert mock_service_call.call_count == 1
mock_service_call.assert_has_calls(
[
call.async_call(
"climate",
SERVICE_SET_TEMPERATURE,
{
"entity_id": "climate.mock_climate",
"target_temp_high": 31,
"target_temp_low": 31,
},
),
]
)
@pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [True])
async def test_bug_500_1(hass: HomeAssistant, init_vtherm_api) -> None:
"""Test that the form is served with no input"""
config = {
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH,
CONF_USE_WINDOW_CENTRAL_CONFIG: True,
CONF_USE_POWER_CENTRAL_CONFIG: True,
CONF_USE_PRESENCE_CENTRAL_CONFIG: True,
CONF_USE_MOTION_FEATURE: True,
CONF_MOTION_SENSOR: "sensor.theMotionSensor",
}
flow = VersatileThermostatBaseConfigFlow(config)
assert flow._infos[CONF_USE_WINDOW_FEATURE] is True
assert flow._infos[CONF_USE_POWER_FEATURE] is True
assert flow._infos[CONF_USE_PRESENCE_FEATURE] is True
assert flow._infos[CONF_USE_MOTION_FEATURE] is True
@pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [True])
async def test_bug_500_2(hass: HomeAssistant, init_vtherm_api) -> None:
"""Test that the form is served with no input"""
config = {
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH,
CONF_USE_WINDOW_CENTRAL_CONFIG: False,
CONF_USE_POWER_CENTRAL_CONFIG: False,
CONF_USE_PRESENCE_CENTRAL_CONFIG: False,
CONF_USE_MOTION_FEATURE: False,
}
flow = VersatileThermostatBaseConfigFlow(config)
assert flow._infos[CONF_USE_WINDOW_FEATURE] is False
assert flow._infos[CONF_USE_POWER_FEATURE] is False
assert flow._infos[CONF_USE_PRESENCE_FEATURE] is False
assert flow._infos[CONF_USE_MOTION_FEATURE] is False
@pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [True])
async def test_bug_500_3(hass: HomeAssistant, init_vtherm_api) -> None:
"""Test that the form is served with no input"""
config = {
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH,
CONF_USE_WINDOW_CENTRAL_CONFIG: False,
CONF_WINDOW_SENSOR: "sensor.theWindowSensor",
CONF_USE_POWER_CENTRAL_CONFIG: False,
CONF_POWER_SENSOR: "sensor.thePowerSensor",
CONF_MAX_POWER_SENSOR: "sensor.theMaxPowerSensor",
CONF_USE_PRESENCE_CENTRAL_CONFIG: False,
CONF_PRESENCE_SENSOR: "sensor.thePresenceSensor",
CONF_USE_MOTION_FEATURE: True, # motion sensor need to be checked AND a motion sensor set
CONF_MOTION_SENSOR: "sensor.theMotionSensor",
}
flow = VersatileThermostatBaseConfigFlow(config)
assert flow._infos[CONF_USE_WINDOW_FEATURE] is True
assert flow._infos[CONF_USE_POWER_FEATURE] is True
assert flow._infos[CONF_USE_PRESENCE_FEATURE] is True
assert flow._infos[CONF_USE_MOTION_FEATURE] is True
@pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [True])
async def test_bug_524(hass: HomeAssistant, skip_hass_states_is_state):
"""Test when switching from Cool to Heat the new temperature in Heat mode should be used"""
vtherm_api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(hass)
# The temperatures to set
temps = {
"frost": 7.0,
"eco": 17.0,
"comfort": 19.0,
"boost": 21.0,
"eco_ac": 27.0,
"comfort_ac": 25.0,
"boost_ac": 23.0,
"frost_away": 7.1,
"eco_away": 17.1,
"comfort_away": 19.1,
"boost_away": 21.1,
"eco_ac_away": 27.1,
"comfort_ac_away": 25.1,
"boost_ac_away": 23.1,
}
config_entry = MockConfigEntry(
domain=DOMAIN,
title="TheOverClimateMockName",
unique_id="overClimateUniqueId",
data={
CONF_NAME: "overClimate",
CONF_TEMP_SENSOR: "sensor.mock_temp_sensor",
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_CLIMATE,
CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor",
CONF_CYCLE_MIN: 5,
CONF_TEMP_MIN: 15,
CONF_TEMP_MAX: 30,
CONF_USE_WINDOW_FEATURE: False,
CONF_USE_MOTION_FEATURE: False,
CONF_USE_POWER_FEATURE: False,
CONF_USE_PRESENCE_FEATURE: True,
CONF_PRESENCE_SENSOR: "binary_sensor.presence_sensor",
CONF_CLIMATE: "climate.mock_climate",
CONF_MINIMAL_ACTIVATION_DELAY: 30,
CONF_SECURITY_DELAY_MIN: 5,
CONF_SECURITY_MIN_ON_PERCENT: 0.3,
CONF_AUTO_FAN_MODE: CONF_AUTO_FAN_TURBO,
CONF_AC_MODE: True,
},
# | temps,
)
fake_underlying_climate = MockClimate(
hass=hass,
unique_id="mock_climate",
name="mock_climate",
hvac_modes=[HVACMode.OFF, HVACMode.COOL, HVACMode.HEAT, HVACMode.FAN_ONLY],
)
with patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate",
return_value=fake_underlying_climate,
):
vtherm: ThermostatOverClimate = await create_thermostat(
hass, config_entry, "climate.overclimate"
)
assert vtherm is not None
# We search for NumberEntities
for preset_name, value in temps.items():
await set_climate_preset_temp(vtherm, preset_name, value)
temp_entity: NumberEntity = search_entity(
hass,
"number.overclimate_preset_" + preset_name + PRESET_TEMP_SUFFIX,
NUMBER_DOMAIN,
)
assert temp_entity
# Because set_value is not implemented in Number class (really don't understand why...)
assert temp_entity.state == value
# 1. Set mode to Heat and preset to Comfort
await send_presence_change_event(vtherm, True, False, datetime.now())
await vtherm.async_set_hvac_mode(HVACMode.HEAT)
await vtherm.async_set_preset_mode(PRESET_COMFORT)
await hass.async_block_till_done()
assert vtherm.target_temperature == 19.0
# 2. Only change the HVAC_MODE (and keep preset to comfort)
await vtherm.async_set_hvac_mode(HVACMode.COOL)
await hass.async_block_till_done()
assert vtherm.target_temperature == 25.0
# 3. Only change the HVAC_MODE (and keep preset to comfort)
await vtherm.async_set_hvac_mode(HVACMode.HEAT)
await hass.async_block_till_done()
assert vtherm.target_temperature == 19.0
# 4. Change presence to off
await send_presence_change_event(vtherm, False, True, datetime.now())
await hass.async_block_till_done()
assert vtherm.target_temperature == 19.1
# 5. Change hvac_mode to AC
await vtherm.async_set_hvac_mode(HVACMode.COOL)
await hass.async_block_till_done()
assert vtherm.target_temperature == 25.1
# 6. Change presence to on
await send_presence_change_event(vtherm, True, False, datetime.now())
await hass.async_block_till_done()
assert vtherm.target_temperature == 25

View File

@@ -731,7 +731,7 @@ async def test_update_central_boiler_state_simple_climate(
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate", "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate",
return_value=climate1, return_value=climate1,
): ):
entity: ThermostatOverValve = await create_thermostat( entity: ThermostatOverClimate = await create_thermostat(
hass, entry, "climate.theoverclimatemockname" hass, entry, "climate.theoverclimatemockname"
) )
assert entity assert entity

View File

@@ -5,9 +5,6 @@ from unittest.mock import patch, call
from datetime import timedelta, datetime from datetime import timedelta, datetime
import logging import logging
from custom_components.versatile_thermostat.thermostat_climate import (
ThermostatOverClimate,
)
from custom_components.versatile_thermostat.thermostat_switch import ( from custom_components.versatile_thermostat.thermostat_switch import (
ThermostatOverSwitch, ThermostatOverSwitch,
) )

View File

@@ -254,6 +254,9 @@ async def test_over_switch_deactivate_preset(
CONF_HEATER_KEEP_ALIVE: 0, CONF_HEATER_KEEP_ALIVE: 0,
CONF_SECURITY_DELAY_MIN: 10, CONF_SECURITY_DELAY_MIN: 10,
CONF_MINIMAL_ACTIVATION_DELAY: 10, CONF_MINIMAL_ACTIVATION_DELAY: 10,
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
CONF_TPI_COEF_INT: 0.6,
CONF_TPI_COEF_EXT: 0.01,
}, },
) )

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()

View File

@@ -79,6 +79,7 @@ async def test_add_number_for_central_config(
CONF_SECURITY_MIN_ON_PERCENT: 0.5, CONF_SECURITY_MIN_ON_PERCENT: 0.5,
CONF_SECURITY_DEFAULT_ON_PERCENT: 0.2, CONF_SECURITY_DEFAULT_ON_PERCENT: 0.2,
CONF_USE_CENTRAL_BOILER_FEATURE: False, CONF_USE_CENTRAL_BOILER_FEATURE: False,
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
} }
| temps, | temps,
) )
@@ -156,6 +157,7 @@ async def test_add_number_for_central_config_without_temp(
CONF_TEMP_MAX: 30, CONF_TEMP_MAX: 30,
CONF_TPI_COEF_INT: 0.5, CONF_TPI_COEF_INT: 0.5,
CONF_TPI_COEF_EXT: 0.02, CONF_TPI_COEF_EXT: 0.02,
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
CONF_WINDOW_DELAY: 15, CONF_WINDOW_DELAY: 15,
CONF_WINDOW_AUTO_OPEN_THRESHOLD: 4, CONF_WINDOW_AUTO_OPEN_THRESHOLD: 4,
CONF_WINDOW_AUTO_CLOSE_THRESHOLD: 1, CONF_WINDOW_AUTO_CLOSE_THRESHOLD: 1,
@@ -250,6 +252,7 @@ async def test_add_number_for_central_config_without_temp_ac_mode(
CONF_AC_MODE: True, CONF_AC_MODE: True,
CONF_TPI_COEF_INT: 0.5, CONF_TPI_COEF_INT: 0.5,
CONF_TPI_COEF_EXT: 0.02, CONF_TPI_COEF_EXT: 0.02,
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
CONF_WINDOW_DELAY: 15, CONF_WINDOW_DELAY: 15,
CONF_WINDOW_AUTO_OPEN_THRESHOLD: 4, CONF_WINDOW_AUTO_OPEN_THRESHOLD: 4,
CONF_WINDOW_AUTO_CLOSE_THRESHOLD: 1, CONF_WINDOW_AUTO_CLOSE_THRESHOLD: 1,
@@ -343,6 +346,7 @@ async def test_add_number_for_central_config_without_temp_restore(
CONF_AC_MODE: False, CONF_AC_MODE: False,
CONF_TPI_COEF_INT: 0.5, CONF_TPI_COEF_INT: 0.5,
CONF_TPI_COEF_EXT: 0.02, CONF_TPI_COEF_EXT: 0.02,
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
CONF_WINDOW_DELAY: 15, CONF_WINDOW_DELAY: 15,
CONF_WINDOW_AUTO_OPEN_THRESHOLD: 4, CONF_WINDOW_AUTO_OPEN_THRESHOLD: 4,
CONF_WINDOW_AUTO_CLOSE_THRESHOLD: 1, CONF_WINDOW_AUTO_CLOSE_THRESHOLD: 1,
@@ -441,6 +445,7 @@ async def test_add_number_for_over_switch_use_central(
CONF_AC_MODE: False, CONF_AC_MODE: False,
CONF_TPI_COEF_INT: 0.5, CONF_TPI_COEF_INT: 0.5,
CONF_TPI_COEF_EXT: 0.02, CONF_TPI_COEF_EXT: 0.02,
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
CONF_USE_PRESENCE_CENTRAL_CONFIG: True, CONF_USE_PRESENCE_CENTRAL_CONFIG: True,
CONF_USE_ADVANCED_CENTRAL_CONFIG: True, CONF_USE_ADVANCED_CENTRAL_CONFIG: True,
CONF_USE_MAIN_CENTRAL_CONFIG: True, CONF_USE_MAIN_CENTRAL_CONFIG: True,
@@ -666,6 +671,7 @@ async def test_add_number_for_over_switch_use_central_presets_and_restore(
CONF_AC_MODE: False, CONF_AC_MODE: False,
CONF_TPI_COEF_INT: 0.5, CONF_TPI_COEF_INT: 0.5,
CONF_TPI_COEF_EXT: 0.02, CONF_TPI_COEF_EXT: 0.02,
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
CONF_CYCLE_MIN: 5, CONF_CYCLE_MIN: 5,
CONF_HEATER: "switch.mock_switch1", CONF_HEATER: "switch.mock_switch1",
CONF_USE_PRESENCE_FEATURE: True, CONF_USE_PRESENCE_FEATURE: True,
@@ -788,6 +794,7 @@ async def test_change_central_config_temperature(
CONF_TEMP_MAX: 30, CONF_TEMP_MAX: 30,
CONF_TPI_COEF_INT: 0.5, CONF_TPI_COEF_INT: 0.5,
CONF_TPI_COEF_EXT: 0.02, CONF_TPI_COEF_EXT: 0.02,
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
CONF_CYCLE_MIN: 5, CONF_CYCLE_MIN: 5,
CONF_VALVE: "switch.mock_valve", CONF_VALVE: "switch.mock_valve",
CONF_USE_PRESENCE_FEATURE: True, CONF_USE_PRESENCE_FEATURE: True,
@@ -823,6 +830,7 @@ async def test_change_central_config_temperature(
CONF_TPI_COEF_INT: 0.5, CONF_TPI_COEF_INT: 0.5,
CONF_TPI_COEF_EXT: 0.02, CONF_TPI_COEF_EXT: 0.02,
CONF_CYCLE_MIN: 5, CONF_CYCLE_MIN: 5,
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
CONF_VALVE: "switch.mock_valve", CONF_VALVE: "switch.mock_valve",
CONF_USE_PRESENCE_FEATURE: True, CONF_USE_PRESENCE_FEATURE: True,
CONF_USE_PRESENCE_CENTRAL_CONFIG: False, CONF_USE_PRESENCE_CENTRAL_CONFIG: False,
@@ -905,6 +913,7 @@ async def test_change_vtherm_temperature(
CONF_TPI_COEF_INT: 0.5, CONF_TPI_COEF_INT: 0.5,
CONF_TPI_COEF_EXT: 0.02, CONF_TPI_COEF_EXT: 0.02,
CONF_CYCLE_MIN: 5, CONF_CYCLE_MIN: 5,
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
CONF_VALVE: "switch.mock_valve", CONF_VALVE: "switch.mock_valve",
CONF_USE_PRESENCE_FEATURE: True, CONF_USE_PRESENCE_FEATURE: True,
CONF_USE_PRESENCE_CENTRAL_CONFIG: True, CONF_USE_PRESENCE_CENTRAL_CONFIG: True,
@@ -939,6 +948,7 @@ async def test_change_vtherm_temperature(
CONF_TPI_COEF_INT: 0.5, CONF_TPI_COEF_INT: 0.5,
CONF_TPI_COEF_EXT: 0.02, CONF_TPI_COEF_EXT: 0.02,
CONF_CYCLE_MIN: 5, CONF_CYCLE_MIN: 5,
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
CONF_VALVE: "switch.mock_valve", CONF_VALVE: "switch.mock_valve",
CONF_USE_PRESENCE_FEATURE: True, CONF_USE_PRESENCE_FEATURE: True,
CONF_USE_PRESENCE_CENTRAL_CONFIG: False, CONF_USE_PRESENCE_CENTRAL_CONFIG: False,
@@ -1022,6 +1032,7 @@ async def test_change_vtherm_temperature_with_presence(
CONF_TEMP_MAX: 30, CONF_TEMP_MAX: 30,
CONF_TPI_COEF_INT: 0.5, CONF_TPI_COEF_INT: 0.5,
CONF_TPI_COEF_EXT: 0.02, CONF_TPI_COEF_EXT: 0.02,
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
CONF_CYCLE_MIN: 5, CONF_CYCLE_MIN: 5,
CONF_AC_MODE: True, CONF_AC_MODE: True,
CONF_VALVE: "switch.mock_valve", CONF_VALVE: "switch.mock_valve",
@@ -1063,6 +1074,7 @@ async def test_change_vtherm_temperature_with_presence(
CONF_TEMP_MAX: 30, CONF_TEMP_MAX: 30,
CONF_TPI_COEF_INT: 0.5, CONF_TPI_COEF_INT: 0.5,
CONF_TPI_COEF_EXT: 0.02, CONF_TPI_COEF_EXT: 0.02,
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
CONF_CYCLE_MIN: 5, CONF_CYCLE_MIN: 5,
CONF_VALVE: "switch.mock_valve", CONF_VALVE: "switch.mock_valve",
CONF_USE_PRESENCE_FEATURE: True, CONF_USE_PRESENCE_FEATURE: True,

View File

@@ -3,7 +3,10 @@
from homeassistant.components.climate import HVACMode from homeassistant.components.climate import HVACMode
from custom_components.versatile_thermostat.base_thermostat import BaseThermostat from custom_components.versatile_thermostat.base_thermostat import BaseThermostat
from custom_components.versatile_thermostat.prop_algorithm import PropAlgorithm from custom_components.versatile_thermostat.prop_algorithm import (
PropAlgorithm,
PROPORTIONAL_FUNCTION_TPI,
)
from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import
@@ -121,3 +124,123 @@ async def test_tpi_calculation(
assert tpi_algo.calculated_on_percent == 0 assert tpi_algo.calculated_on_percent == 0
assert tpi_algo.on_time_sec == 0 assert tpi_algo.on_time_sec == 0
assert tpi_algo.off_time_sec == 300 assert tpi_algo.off_time_sec == 300
@pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [True])
async def test_wrong_tpi_parameters(
hass: HomeAssistant, skip_hass_states_is_state: None
): # pylint: disable=unused-argument
"""Test the wrong TPI parameters"""
# Nominal case
try:
algo = PropAlgorithm(
PROPORTIONAL_FUNCTION_TPI,
0.6,
0.01,
5,
1,
"entity_id",
)
# We should not be there
assert True
except TypeError as e:
# the normal case
assert False
# Test TPI function
try:
algo = PropAlgorithm(
"WRONG",
1,
0,
2,
3,
"entity_id",
)
# We should not be there
assert False
except TypeError as e:
# the normal case
pass
# Test coef_int
try:
algo = PropAlgorithm(
PROPORTIONAL_FUNCTION_TPI,
None,
0,
2,
3,
"entity_id",
)
# We should not be there
assert False
except TypeError as e:
# the normal case
pass
# Test coef_ext
try:
algo = PropAlgorithm(
PROPORTIONAL_FUNCTION_TPI,
0.6,
None,
2,
3,
"entity_id",
)
# We should not be there
assert False
except TypeError as e:
# the normal case
pass
# Test cycle_min
try:
algo = PropAlgorithm(
PROPORTIONAL_FUNCTION_TPI,
0.6,
0.00001,
None,
3,
"entity_id",
)
# We should not be there
assert False
except TypeError as e:
# the normal case
pass
# Test minimal_activation_delay
try:
algo = PropAlgorithm(
PROPORTIONAL_FUNCTION_TPI,
0.6,
0.00001,
0,
None,
"entity_id",
)
# We should not be there
assert False
except TypeError as e:
# the normal case
pass
# Test vtherm_entity_id
try:
algo = PropAlgorithm(
PROPORTIONAL_FUNCTION_TPI,
0.6,
0.00001,
0,
12,
None,
)
# We should not be there
assert False
except TypeError as e:
# the normal case
pass