From b4860c2b8da6b26b1690166f7e3168d5a29b9a3c Mon Sep 17 00:00:00 2001 From: Jean-Marc Collin Date: Fri, 1 Nov 2024 18:54:22 +0100 Subject: [PATCH] Issue 585 add auto start/stop feature (#594) * Migrate to HA 2024.10.4 * Auto start/stop alog and testu + ConfigFlow * With config flow ok * Change algo * All is fine * Add change_preset test * + comment * FIX too much start/stop * Change algo to take slop into account * Allow calculation even if slope is None * With enable + tests + hysteresis in calculation * Add hvac_off_reason and test with window interaction * Fix some tests * Restore saved_state * Release --------- Co-authored-by: Jean-Marc Collin --- README-fr.md | 28 +- README.md | 26 +- .../auto_start_stop_algorithm.py | 239 +++ .../versatile_thermostat/base_thermostat.py | 163 +- .../versatile_thermostat/binary_sensor.py | 2 +- .../versatile_thermostat/config_flow.py | 36 +- .../versatile_thermostat/config_schema.py | 24 + .../versatile_thermostat/const.py | 34 + .../versatile_thermostat/manifest.json | 2 +- .../versatile_thermostat/strings.json | 16 +- .../versatile_thermostat/switch.py | 102 ++ .../thermostat_climate.py | 272 +-- .../versatile_thermostat/translations/en.json | 16 +- .../versatile_thermostat/translations/fr.json | 16 +- hacs.json | 2 +- requirements_dev.txt | 2 +- tests/test_auto_start_stop.py | 1451 +++++++++++++++++ tests/test_central_mode.py | 16 +- tests/test_config_flow.py | 256 ++- tests/test_window.py | 9 +- 20 files changed, 2502 insertions(+), 210 deletions(-) create mode 100644 custom_components/versatile_thermostat/auto_start_stop_algorithm.py create mode 100644 custom_components/versatile_thermostat/switch.py create mode 100644 tests/test_auto_start_stop.py diff --git a/README-fr.md b/README-fr.md index 6a948e6..ddac7f0 100644 --- a/README-fr.md +++ b/README-fr.md @@ -31,6 +31,7 @@ - [Compensation de la température interne](#compensation-de-la-température-interne) - [Synthèse de l'algorithme d'auto-régulation](#synthèse-de-lalgorithme-dauto-régulation) - [Le mode auto-fan](#le-mode-auto-fan) + - [Le démarrage / arrêt automatique](#le-démarrage--arrêt-automatique) - [Pour un thermostat de type ```thermostat_over_valve```:](#pour-un-thermostat-de-type-thermostat_over_valve) - [Configurez les coefficients de l'algorithme TPI](#configurez-les-coefficients-de-lalgorithme-tpi) - [Configurer les températures préréglées](#configurer-les-températures-préréglées) @@ -92,6 +93,9 @@ Ce composant personnalisé pour Home Assistant est une mise à niveau et est une > ![Nouveau](images/new-icon.png) _*Historique des dernières versions*_ +> * **Release 6.5** : +> - Ajout d'une nouvelle fonction permettant l'arrêt et la relance automatique d'un VTherm `over_climate` [585](https://github.com/jmcollin78/versatile_thermostat/issues/585) +> - Amélioration de la gestion des ouvertures au démarrage. Permet de mémoriser et de recalculer l'état d'une ouverture au redémarage de Home Assistant [504](https://github.com/jmcollin78/versatile_thermostat/issues/504) > * **Release 6.0** : > - Ajout d'entités du domaine Number permettant de configurer les températures des presets [354](https://github.com/jmcollin78/versatile_thermostat/issues/354) > - Refonte complète du menu de configuration pour supprimer les températures et utililsation d'un menu au lieu d'un tunnel de configuration [354](https://github.com/jmcollin78/versatile_thermostat/issues/354) @@ -100,14 +104,14 @@ Ce composant personnalisé pour Home Assistant est une mise à niveau et est une > - ajout de seuils de régulation pour les `over_valve` pour éviter de trop vider la batterie des TRV [#338](https://github.com/jmcollin78/versatile_thermostat/issues/338), > - ajout d'une option permettant d'utiliser la température interne d'un TRV pour forcer l' auto-régulation [#348](https://github.com/jmcollin78/versatile_thermostat/issues/348), > - ajout d'une fonction de keep-alive pour les VTherm `over_switch` [#345](https://github.com/jmcollin78/versatile_thermostat/issues/345) -> * **Release 5.3** : Ajout d'une fonction de pilotage d'une chaudière centrale [#234](https://github.com/jmcollin78/versatile_thermostat/issues/234) - plus d'infos ici: [Le contrôle d'une chaudière centrale](#le-contrôle-dune-chaudière-centrale). Ajout de la possibilité de désactiver le mode sécurité pour le thermomètre extérieur [#343](https://github.com/jmcollin78/versatile_thermostat/issues/343) -> * **Release 5.2** : Ajout d'un `central_mode` permettant de piloter tous les VTherms de façon centralisée [#158](https://github.com/jmcollin78/versatile_thermostat/issues/158). -> * **Release 5.1** : Limitation des valeurs envoyées aux valves et au température envoyées au climate sous-jacent. -> * **Release 5.0** : Ajout d'une configuration centrale permettant de mettre en commun les attributs qui peuvent l'être [#239](https://github.com/jmcollin78/versatile_thermostat/issues/239).
Autres versions +> * **Release 5.3** : Ajout d'une fonction de pilotage d'une chaudière centrale [#234](https://github.com/jmcollin78/versatile_thermostat/issues/234) - plus d'infos ici: [Le contrôle d'une chaudière centrale](#le-contrôle-dune-chaudière-centrale). Ajout de la possibilité de désactiver le mode sécurité pour le thermomètre extérieur [#343](https://github.com/jmcollin78/versatile_thermostat/issues/343) +> * **Release 5.2** : Ajout d'un `central_mode` permettant de piloter tous les VTherms de façon centralisée [#158](https://github.com/jmcollin78/versatile_thermostat/issues/158). +> * **Release 5.1** : Limitation des valeurs envoyées aux valves et au température envoyées au climate sous-jacent. +> * **Release 5.0** : Ajout d'une configuration centrale permettant de mettre en commun les attributs qui peuvent l'être [#239](https://github.com/jmcollin78/versatile_thermostat/issues/239). > * **Release 4.3** : Ajout d'un mode auto-fan pour le type `over_climate` permettant d'activer la ventilation si l'écart de température est important [#223](https://github.com/jmcollin78/versatile_thermostat/issues/223). > * **Release 4.2** : Le calcul de la pente de la courbe de température se fait maintenant en °/heure et non plus en °/min [#242](https://github.com/jmcollin78/versatile_thermostat/issues/242). Correction de la détection automatique des ouvertures par l'ajout d'un lissage de la courbe de température . > * **Release 4.1** : Ajout d'un mode de régulation **Expert** dans lequel l'utilisateur peut spécifier ses propres paramètres d'auto-régulation au lieu d'utiliser les pre-programmés [#194](https://github.com/jmcollin78/versatile_thermostat/issues/194). @@ -510,6 +514,17 @@ Il faut évidemment que votre équipement sous-jacent soit équipée d'une venti Si votre équipement ne comprend pas le mode Turbo, le mode Forte` sera utilisé en remplacement. Une fois l'écart de température redevenu faible, la ventilation se mettra dans un mode "normal" qui dépend de votre équipement à savoir (dans l'ordre) : `Silence (mute)`, `Auto (auto)`, `Faible (low)`. La première valeur qui est possible pour votre équipement sera choisie. +#### Le démarrage / arrêt automatique +Cette fonction a été introduite en 6.5.0. Elle permet d'autoriser VTherm a stopper un équipement qui n'a pas besoin d'être allumé et de le redémarrer lorsque les conditions le réclame. Cette fonction est munie de 3 réglages qui permettent d'arrêter / relancer plus ou moins rapidement l'équipement. + +Pour l'utiliser, vous devez : +1. Ajouter la fonction `Avec démmarrage et extinction automatique` dans le menu 'Fonctions', +2. Paramétrer le niveau de détection dans l'option 'Allumage/extinction automatique' qui s'affiche lorsque la fonction a été activée. Vous choisissez le niveau de détection entre 'Lent', 'Moyen' et 'Rapide'. Les arrêts/relances seront plus nombreux avec le niveau 'Rapide'. + +Une fois paramétré, vous aurez maintenant une nouvelle entité de type `switch` qui vous permet d'autoriser ou non l'arrêt/relance automatique sans toucher à la configuration. Cette entité est disponible sur l'appareil VTherm et se nomme `switch._enable_auto_start_stop`. Cochez la pour autoriser le démarrage et extinction automatique. + +L'algorithme de détection est décrit [ici](https://github.com/jmcollin78/versatile_thermostat/issues/585). + ### Pour un thermostat de type ```thermostat_over_valve```: ![image](images/config-linked-entity3.png) Vous pouvez choisir jusqu'à entité du domaine ```number``` ou ```ìnput_number``` qui vont commander les vannes. @@ -911,6 +926,8 @@ context: | ``central_boiler_activation_service`` | Service d'activation de la chaudière | - | - | - | X | | ``central_boiler_deactivation_service`` | Service de desactivation de la chaudière | - | - | - | X | | ``used_by_controls_central_boiler`` | Indique si le VTherm contrôle la chaudière centrale | X | X | X | - | +| ``use_auto_start_stop_feature`` | Indique si la fonction de démarrage/extinction automatique est activée | - | X | - | - | +| ``auto_start_stop_lvel`` | Le niveau de détection de l'auto start/stop | - | X | - | - |
# Exemples de réglage @@ -1168,6 +1185,9 @@ Les attributs personnalisés sont les suivants : | ``is_controlled_by_central_mode`` | True si le VTherm peut être controlé de façon centrale | | ``last_central_mode`` | Le dernier mode central utilisé (None si le VTherm n'est pas controlé en central) | | ``is_used_by_central_boiler`` | Indique si le VTherm peut contrôler la chaudière centrale | +| ``auto_start_stop_enable`` | Indique si le VTherm est autorisé à s'auto démarrer/arrêter | +| ``auto_start_stop_level`` | Indique le niveau d'auto start/stop | +| ``hvac_off_reason`` | Indique la raison de l'arrêt (hvac_off) du VTherm. Ce peut être Window, Auto-start/stop ou Manuel | # Quelques résultats diff --git a/README.md b/README.md index da0972d..1fb65ff 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ - [Internal temperature compensation](#internal-temperature-compensation) - [synthesis of the self-regulation algorithm](#synthesis-of-the-self-regulation-algorithm) - [Auto-fan mode](#auto-fan-mode) + - [Automatic start/stop](#automatic-startstop) - [For a thermostat of type ```thermostat_over_valve```:](#for-a-thermostat-of-type-thermostat_over_valve) - [Configure the TPI algorithm coefficients](#configure-the-tpi-algorithm-coefficients) - [Configure the preset temperature](#configure-the-preset-temperature) @@ -93,6 +94,9 @@ This custom component for Home Assistant is an upgrade and is a complete rewrite of the component "Awesome thermostat" (see [Github](https://github.com/dadge/awesome_thermostat)) with addition of features. >![New](images/new-icon.png) _*Latest releases*_ +> * **Release 6.5** : +> - Added a new function allowing the automatic shutdown and restart of a VTherm `over_climate` [585](https://github.com/jmcollin78/versatile_thermostat/issues/585) +> - Improved management of openings at startup. Allows to memorize and recalculate the state of an opening when restarting Home Assistant [504](https://github.com/jmcollin78/versatile_thermostat/issues/504) > * **Release 6.0**: > - Added entities from the Number domain to configure preset temperatures [354](https://github.com/jmcollin78/versatile_thermostat/issues/354) > - Complete redesign of the configuration menu to remove temperatures and use a menu instead of a configuration tunnel [354](https://github.com/jmcollin78/versatile_thermostat/issues/354) @@ -101,13 +105,13 @@ This custom component for Home Assistant is an upgrade and is a complete rewrite > - addition of regulation thresholds for the `over_valve` to avoid draining the TRV battery too much [#338](https://github.com/jmcollin78/versatile_thermostat/issues/338), > - added an option allowing the internal temperature of a TRV to be used to force self-regulation [#348](https://github.com/jmcollin78/versatile_thermostat/issues/348), > - added a keep-alive function for VTherm `over_switch` [#345](https://github.com/jmcollin78/versatile_thermostat/issues/345) +
+Others releases + > * **Release 5.3**: Added a central boiler control function [#234](https://github.com/jmcollin78/versatile_thermostat/issues/234) - more information here: [Controlling a central boiler](#controlling-a-central-boiler). Added the ability to disable security mode for outdoor thermometer [#343](https://github.com/jmcollin78/versatile_thermostat/issues/343) > * **Release 5.2**: Added a `central_mode` allowing all VTherms to be controlled centrally [#158](https://github.com/jmcollin78/versatile_thermostat/issues/158). > * **Release 5.1**: Limitation of the values sent to the valves and the temperature sent to the underlying climate. > * **Release 5.0**: Added a central configuration allowing the sharing of attributes that can be shared [#239](https://github.com/jmcollin78/versatile_thermostat/issues/239). -
-Others releases - > * **Release 4.3**: Added an auto-fan mode for the `over_climate` type allowing ventilation to be activated if the temperature difference is significant [#223](https://github.com/jmcollin78/versatile_thermostat/issues/223). > * **Release 4.2**: The calculation of the slope of the temperature curve is now done in °/hour and no longer in °/min [#242](https://github.com/jmcollin78/versatile_thermostat/issues/242). Correction of automatic detection of openings by adding smoothing of the temperature curve. > * **Release 4.1**: Added an **Expert** regulation mode in which the user can specify their own auto-regulation parameters instead of using the pre-programmed ones [#194]( https://github.com/jmcollin78/versatile_thermostat/issues/194). @@ -500,6 +504,17 @@ Obviously your underlying equipment must be equipped with ventilation and be con If your equipment does not include Turbo mode, Forte` mode will be used as a replacement. Once the temperature difference becomes low again, the ventilation will go into a "normal" mode which depends on your equipment, namely (in order): `Silence (mute)`, `Auto (auto)`, `Low (low)`. The first value that is possible for your equipment will be chosen. +#### Automatic start/stop +This function was introduced in 6.5.0. It allows VTherm to stop equipment that does not need to be turned on and to restart it when conditions require it. This function has 3 settings that allow the equipment to be stopped/restarted more or less quickly. + +To use it, you must: +1. Add the `Use the auto start and stop feature` function in the 'Features' menu, +2. Set the detection level in the `Auto start and stop` option that is displayed when the function has been activated. You choose the detection level between 'Slow', 'Medium' and 'Fast'. The 'Fast' level will result in more shutdowns/restarts. + +Once configured, you will now have a new entity of type `switch` that allows you to authorize or not the automatic shutdown/restart without touching the configuration. This entity is available on the VTherm device and is called `switch._enable_auto_start_stop`. Check it to authorize the automatic startup and shutdown. + +The detection algorithm is described [here](https://github.com/jmcollin78/versatile_thermostat/issues/585). + ### For a thermostat of type ```thermostat_over_valve```: ![image](images/config-linked-entity3.png) You can choose up to domain entity ```number``` or ```ìnput_number``` which will control the valves. @@ -900,6 +915,8 @@ context: | ``central_boiler_activation_service`` | Activation service of the boiler | - | - | - | X | | ``central_boiler_deactivation_service`` | Deactivaiton service of the boiler | - | - | - | X | | ``used_by_controls_central_boiler`` | Indicate if the VTherm control the central boiler | X | X | X | - | +| ``use_auto_start_stop_feature`` | Indique si la fonction de démarrage/extinction automatique est activée | - | X | - | - | +| ``auto_start_stop_lvel`` | Le niveau de détection de l'auto start/stop | - | X | - | - |
# Tuning examples @@ -1155,6 +1172,9 @@ Custom attributes are the following: | ``is_controlled_by_central_mode`` | True if the VTherm can be centrally controlled | | ``last_central_mode`` | The last central mode used (None if the VTherm is not centrally controlled) | | ``is_used_by_central_boiler`` | Indicate if the VTherm can control the central boiler | +| ``auto_start_stop_enable`` | Indique si le VTherm est autorisé à s'auto démarrer/arrêter | +| ``auto_start_stop_level`` | Indique le niveau d'auto start/stop | +| ``hvac_off_reason`` | Indique la raison de l'arrêt (hvac_off) du VTherm. Ce peut être Window, Auto-start/stop ou Manuel | # Some results diff --git a/custom_components/versatile_thermostat/auto_start_stop_algorithm.py b/custom_components/versatile_thermostat/auto_start_stop_algorithm.py new file mode 100644 index 0000000..d1203d9 --- /dev/null +++ b/custom_components/versatile_thermostat/auto_start_stop_algorithm.py @@ -0,0 +1,239 @@ +# pylint: disable=line-too-long +""" This file implements the Auto start/stop algorithm as described here: https://github.com/jmcollin78/versatile_thermostat/issues/585 +""" + +import logging +from datetime import datetime +from typing import Literal + +from homeassistant.components.climate import HVACMode + +from .const import ( + AUTO_START_STOP_LEVEL_NONE, + AUTO_START_STOP_LEVEL_FAST, + AUTO_START_STOP_LEVEL_MEDIUM, + AUTO_START_STOP_LEVEL_SLOW, + TYPE_AUTO_START_STOP_LEVELS, +) + + +_LOGGER = logging.getLogger(__name__) + +# Some constant to make algorithm depending of level +DT_MIN = { + AUTO_START_STOP_LEVEL_NONE: 0, # Not used + AUTO_START_STOP_LEVEL_SLOW: 30, + AUTO_START_STOP_LEVEL_MEDIUM: 15, + AUTO_START_STOP_LEVEL_FAST: 7, +} + +# the measurement cycle (2 min) +CYCLE_SEC = 120 + +# A temp hysteresis to avoid rapid OFF/ON +TEMP_HYSTERESIS = 0.5 + +ERROR_THRESHOLD = { + AUTO_START_STOP_LEVEL_NONE: 0, # Not used + AUTO_START_STOP_LEVEL_SLOW: 10, # 10 cycle above 1° or 5 cycle above 2°, ... + AUTO_START_STOP_LEVEL_MEDIUM: 5, # 5 cycle above 1° or 3 cycle above 2°, ..., 1 cycle above 5° + AUTO_START_STOP_LEVEL_FAST: 2, # 2 cycle above 1° or 1 cycle above 2° +} + +AUTO_START_STOP_ACTION_OFF = "turnOff" +AUTO_START_STOP_ACTION_ON = "turnOn" +AUTO_START_STOP_ACTION_NOTHING = "nothing" +AUTO_START_STOP_ACTIONS = Literal[ # pylint: disable=invalid-name + AUTO_START_STOP_ACTION_OFF, + AUTO_START_STOP_ACTION_ON, + AUTO_START_STOP_ACTION_NOTHING, +] + +class AutoStartStopDetectionAlgorithm: + """The class that implements the algorithm listed above""" + + _dt: float | None = None + _level: str = AUTO_START_STOP_LEVEL_NONE + _accumulated_error: float = 0 + _error_threshold: float | None = None + _last_calculation_date: datetime | None = None + + def __init__(self, level: TYPE_AUTO_START_STOP_LEVELS, vtherm_name) -> None: + """Initalize a new algorithm with the right constants""" + self._vtherm_name = vtherm_name + self._init_level(level) + + def _init_level(self, level: TYPE_AUTO_START_STOP_LEVELS): + """Initialize a new level""" + if level == self._level: + return + + self._level = level + if self._level != AUTO_START_STOP_LEVEL_NONE: + self._dt = DT_MIN[level] + self._error_threshold = ERROR_THRESHOLD[level] + # reset accumulated error if we change the level + self._accumulated_error = 0 + + def calculate_action( + self, + hvac_mode: HVACMode | None, + saved_hvac_mode: HVACMode | None, + target_temp: float, + current_temp: float, + slope_min: float | None, + now: datetime, + ) -> AUTO_START_STOP_ACTIONS: + """Calculate an eventual action to do depending of the value in parameter""" + if self._level == AUTO_START_STOP_LEVEL_NONE: + _LOGGER.debug( + "%s - auto-start/stop is disabled", + self, + ) + return AUTO_START_STOP_ACTION_NOTHING + + _LOGGER.debug( + "%s - calculate_action: hvac_mode=%s, saved_hvac_mode=%s, target_temp=%s, current_temp=%s, slope_min=%s at %s", + self, + hvac_mode, + saved_hvac_mode, + target_temp, + current_temp, + slope_min, + now, + ) + + if hvac_mode is None or target_temp is None or current_temp is None: + _LOGGER.debug( + "%s - No all mandatory parameters are set. Disable auto-start/stop", + self, + ) + return AUTO_START_STOP_ACTION_NOTHING + + # Calculate the error factor (P) + error = target_temp - current_temp + + # reduce the error considering the dt between the last measurement + if self._last_calculation_date is not None: + dtmin = (now - self._last_calculation_date).total_seconds() / CYCLE_SEC + # ignore two calls too near (< 24 sec) + if dtmin <= 0.2: + _LOGGER.debug( + "%s - new calculation of auto_start_stop (%s) is too near of the last one (%s). Forget it", + self, + now, + self._last_calculation_date, + ) + return AUTO_START_STOP_ACTION_NOTHING + error = error * dtmin + + # If the error have change its sign, reset smoothly the accumulated error + if error * self._accumulated_error < 0: + self._accumulated_error = self._accumulated_error / 2.0 + + self._accumulated_error += error + + # Capping of the error + self._accumulated_error = min( + self._error_threshold, + max(-self._error_threshold, self._accumulated_error), + ) + + self._last_calculation_date = now + + temp_at_dt = current_temp + slope_min * self._dt + + # Check to turn-off + # When we hit the threshold, that mean we can turn off + if hvac_mode == HVACMode.HEAT: + if ( + self._accumulated_error <= -self._error_threshold + and temp_at_dt >= target_temp + TEMP_HYSTERESIS + ): + _LOGGER.info( + "%s - We need to stop, there is no need for heating for a long time.", + self, + ) + return AUTO_START_STOP_ACTION_OFF + else: + _LOGGER.debug("%s - nothing to do, we are heating", self) + return AUTO_START_STOP_ACTION_NOTHING + + if hvac_mode == HVACMode.COOL: + if ( + self._accumulated_error >= self._error_threshold + and temp_at_dt <= target_temp - TEMP_HYSTERESIS + ): + _LOGGER.info( + "%s - We need to stop, there is no need for cooling for a long time.", + self, + ) + return AUTO_START_STOP_ACTION_OFF + else: + _LOGGER.debug( + "%s - nothing to do, we are cooling", + self, + ) + return AUTO_START_STOP_ACTION_NOTHING + + # check to turn on + if hvac_mode == HVACMode.OFF and saved_hvac_mode == HVACMode.HEAT: + if temp_at_dt <= target_temp - TEMP_HYSTERESIS: + _LOGGER.info( + "%s - We need to start, because it will be time to heat", + self, + ) + return AUTO_START_STOP_ACTION_ON + else: + _LOGGER.debug( + "%s - nothing to do, we don't need to heat soon", + self, + ) + return AUTO_START_STOP_ACTION_NOTHING + + if hvac_mode == HVACMode.OFF and saved_hvac_mode == HVACMode.COOL: + if temp_at_dt >= target_temp + TEMP_HYSTERESIS: + _LOGGER.info( + "%s - We need to start, because it will be time to cool", + self, + ) + return AUTO_START_STOP_ACTION_ON + else: + _LOGGER.debug( + "%s - nothing to do, we don't need to cool soon", + self, + ) + return AUTO_START_STOP_ACTION_NOTHING + + _LOGGER.debug( + "%s - nothing to do, no conditions applied", + self, + ) + return AUTO_START_STOP_ACTION_NOTHING + + def set_level(self, level: TYPE_AUTO_START_STOP_LEVELS): + """Set a new level""" + self._init_level(level) + + @property + def dt_min(self) -> float: + """Get the dt value""" + return self._dt + + @property + def accumulated_error(self) -> float: + """Get the accumulated error value""" + return self._accumulated_error + + @property + def accumulated_error_threshold(self) -> float: + """Get the accumulated error threshold value""" + return self._error_threshold + + @property + def level(self) -> TYPE_AUTO_START_STOP_LEVELS: + """Get the level value""" + return self._level + + def __str__(self) -> str: + return f"AutoStartStopDetectionAlgorithm-{self._vtherm_name}" diff --git a/custom_components/versatile_thermostat/base_thermostat.py b/custom_components/versatile_thermostat/base_thermostat.py index 2d59d51..7342a12 100644 --- a/custom_components/versatile_thermostat/base_thermostat.py +++ b/custom_components/versatile_thermostat/base_thermostat.py @@ -62,72 +62,7 @@ from homeassistant.const import ( STATE_NOT_HOME, ) -from .const import ( - DOMAIN, - DEVICE_MANUFACTURER, - CONF_POWER_SENSOR, - CONF_TEMP_SENSOR, - CONF_LAST_SEEN_TEMP_SENSOR, - CONF_EXTERNAL_TEMP_SENSOR, - CONF_MAX_POWER_SENSOR, - CONF_WINDOW_SENSOR, - CONF_WINDOW_DELAY, - CONF_WINDOW_AUTO_CLOSE_THRESHOLD, - CONF_WINDOW_AUTO_OPEN_THRESHOLD, - CONF_WINDOW_AUTO_MAX_DURATION, - CONF_MOTION_SENSOR, - CONF_MOTION_DELAY, - CONF_MOTION_OFF_DELAY, - CONF_MOTION_PRESET, - CONF_NO_MOTION_PRESET, - CONF_DEVICE_POWER, - CONF_PRESETS, - # CONF_PRESETS_AWAY, - # CONF_PRESETS_WITH_AC, - # CONF_PRESETS_AWAY_WITH_AC, - CONF_CYCLE_MIN, - CONF_PROP_FUNCTION, - CONF_TPI_COEF_INT, - CONF_TPI_COEF_EXT, - CONF_PRESENCE_SENSOR, - CONF_PRESET_POWER, - SUPPORT_FLAGS, - PRESET_FROST_PROTECTION, - PRESET_POWER, - PRESET_SECURITY, - PROPORTIONAL_FUNCTION_TPI, - PRESET_AWAY_SUFFIX, - CONF_SECURITY_DELAY_MIN, - CONF_SECURITY_MIN_ON_PERCENT, - CONF_SECURITY_DEFAULT_ON_PERCENT, - DEFAULT_SECURITY_MIN_ON_PERCENT, - DEFAULT_SECURITY_DEFAULT_ON_PERCENT, - CONF_MINIMAL_ACTIVATION_DELAY, - CONF_USE_MAIN_CENTRAL_CONFIG, - CONF_USE_TPI_CENTRAL_CONFIG, - CONF_USE_PRESETS_CENTRAL_CONFIG, - CONF_USE_WINDOW_CENTRAL_CONFIG, - CONF_USE_MOTION_CENTRAL_CONFIG, - CONF_USE_POWER_CENTRAL_CONFIG, - CONF_USE_PRESENCE_CENTRAL_CONFIG, - CONF_USE_ADVANCED_CENTRAL_CONFIG, - CONF_USE_PRESENCE_FEATURE, - CONF_TEMP_MAX, - CONF_TEMP_MIN, - HIDDEN_PRESETS, - CONF_AC_MODE, - EventType, - ATTR_MEAN_POWER_CYCLE, - ATTR_TOTAL_ENERGY, - PRESET_AC_SUFFIX, - DEFAULT_SHORT_EMA_PARAMS, - CENTRAL_MODE_AUTO, - CENTRAL_MODE_STOPPED, - CENTRAL_MODE_HEAT_ONLY, - CENTRAL_MODE_COOL_ONLY, - CENTRAL_MODE_FROST_PROTECTION, - send_vtherm_event, -) +from .const import * # pylint: disable=wildcard-import, unused-wildcard-import from .config_schema import * # pylint: disable=wildcard-import, unused-wildcard-import @@ -199,6 +134,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): "is_device_active", "target_temperature_step", "is_used_by_central_boiler", + "temperature_slope" } ) ) @@ -303,6 +239,8 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): self._use_central_config_temperature = False + self._hvac_off_reason: HVAC_OFF_REASONS | None = None + self.post_init(entry_infos) def clean_central_config_doublon( @@ -848,18 +786,24 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): else: self._attr_preset_mode = PRESET_NONE + # Restore old hvac_off_reason + self._hvac_off_reason = old_state.attributes.get(HVAC_OFF_REASON_NAME, None) + if old_state.state in [ HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL, ]: self._hvac_mode = old_state.state - else: - if not self._hvac_mode: - self._hvac_mode = HVACMode.OFF + + # restpre also saved info so that window detection will work + self._saved_hvac_mode = old_state.attributes.get("saved_hvac_mode", None) + self._saved_preset_mode = old_state.attributes.get( + "saved_preset_mode", None + ) old_total_energy = old_state.attributes.get(ATTR_TOTAL_ENERGY) - self._total_energy = old_total_energy if old_total_energy else 0 + self._total_energy = old_total_energy if old_total_energy is not None else 0 self.restore_specific_previous_state(old_state) else: @@ -874,12 +818,14 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): ) self._total_energy = 0 - self._saved_target_temp = self._target_temp - - # Set default state to off if not self._hvac_mode: self._hvac_mode = HVACMode.OFF + if not self.is_on and self.hvac_off_reason is None: + self.set_hvac_off_reason(HVAC_OFF_REASON_MANUAL) + + self._saved_target_temp = self._target_temp + self.send_event(EventType.PRESET_EVENT, {"preset": self._attr_preset_mode}) self.send_event(EventType.HVAC_MODE_EVENT, {"hvac_mode": self._hvac_mode}) @@ -987,16 +933,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): @property def hvac_mode(self) -> HVACMode | None: """Return current operation.""" - # Issue #114 - returns my current hvac_mode and not the underlying hvac_mode which could be different - # delta will be managed by climate_state_change event. - # if self.is_over_climate: - # if one not OFF -> return it - # else OFF - # for under in self._underlyings: - # if (mode := under.hvac_mode) not in [HVACMode.OFF] - # return mode - # return HVACMode.OFF - return self._hvac_mode @property @@ -1193,6 +1129,13 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): """True if this VTHerm uses the central configuration temperature""" return self._use_central_config_temperature + @property + def hvac_off_reason(self) -> HVAC_OFF_REASONS: + """Returns the reason of the last switch to HVAC_OFF + This is useful for features that turns off the VTherm like + window detection or auto-start-stop""" + return self._hvac_off_reason + def underlying_entity_id(self, index=0) -> str | None: """The climate_entity_id. Added for retrocompatibility reason""" if index < self.nb_underlying_entities: @@ -1255,9 +1198,11 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): # Ensure we update the current operation after changing the mode self.reset_last_temperature_time() - self.reset_last_change_time() + if self._hvac_mode != HVACMode.OFF: + self.set_hvac_off_reason(None) + self.update_custom_attributes() self.async_write_ha_state() self.send_event(EventType.HVAC_MODE_EVENT, {"hvac_mode": self._hvac_mode}) @@ -1760,6 +1705,19 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): for under in self._underlyings: await under.check_initial_state(self._hvac_mode) + # Prevent from starting a VTherm if window is open + if ( + self.is_window_auto_enabled + and self._window_sensor_entity_id is not None + and self._hass.states.is_state(self._window_sensor_entity_id, STATE_ON) + and self.is_on + and self.window_action == CONF_WINDOW_TURN_OFF + ): + _LOGGER.info("%s - the window is open. Prevent starting the VTherm") + self._window_auto_state = True + self.save_hvac_mode() + await self.async_set_hvac_mode(HVACMode.OFF) + # Starts the initial control loop (don't wait for an update of temperature) await self.async_control_heating(force=True) @@ -2096,6 +2054,10 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): self._hvac_mode, ) + def set_hvac_off_reason(self, hvac_off_reason: HVAC_OFF_REASONS): + """Set the reason of hvac_off""" + self._hvac_off_reason = hvac_off_reason + async def restore_hvac_mode(self, need_control_heating=False): """Restore a previous hvac_mod""" await self.async_set_hvac_mode(self._saved_hvac_mode, need_control_heating) @@ -2227,13 +2189,16 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): if self.window_state is not STATE_ON and not first_init: await self.restore_hvac_mode() await self.restore_preset_mode() - + elif self.window_state is STATE_ON and self.hvac_mode == HVACMode.OFF: + # do not restore but mark the reason of off with window detection + self.set_hvac_off_reason(HVAC_OFF_REASON_WINDOW_DETECTION) return if old_central_mode == CENTRAL_MODE_AUTO and self.window_state is not STATE_ON: save_all() if new_central_mode == CENTRAL_MODE_STOPPED: + self.set_hvac_off_reason(HVAC_OFF_REASON_MANUAL) await self.async_set_hvac_mode(HVACMode.OFF) return @@ -2241,6 +2206,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): if HVACMode.COOL in self.hvac_modes: await self.async_set_hvac_mode(HVACMode.COOL) else: + self.set_hvac_off_reason(HVAC_OFF_REASON_MANUAL) await self.async_set_hvac_mode(HVACMode.OFF) return @@ -2248,6 +2214,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): if HVACMode.HEAT in self.hvac_modes: await self.async_set_hvac_mode(HVACMode.HEAT) else: + self.set_hvac_off_reason(HVAC_OFF_REASON_MANUAL) await self.async_set_hvac_mode(HVACMode.OFF) return @@ -2261,6 +2228,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): PRESET_FROST_PROTECTION, overwrite_saved_preset=False ) else: + self.set_hvac_off_reason(HVAC_OFF_REASON_MANUAL) await self.async_set_hvac_mode(HVACMode.OFF) return @@ -2440,17 +2408,27 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): """Change the window detection state. new_state is on if an open window have been detected or off else """ - if not new_state: + if new_state is False: _LOGGER.info( - "%s - Window is closed. Restoring hvac_mode '%s' if central_mode is not STOPPED", + "%s - Window is closed. Restoring hvac_mode '%s' if stopped by window detection or temperature %s", self, self._saved_hvac_mode, + self._saved_target_temp, ) if self._window_action in [CONF_WINDOW_FROST_TEMP, CONF_WINDOW_ECO_TEMP]: await self._async_internal_set_temperature(self._saved_target_temp) + # default to TURN_OFF - elif self._window_action in [CONF_WINDOW_TURN_OFF, CONF_WINDOW_FAN_ONLY]: + elif self._window_action in [CONF_WINDOW_TURN_OFF]: + if ( + self.last_central_mode != CENTRAL_MODE_STOPPED + and self.hvac_off_reason == HVAC_OFF_REASON_WINDOW_DETECTION + ): + self.set_hvac_off_reason(None) + await self.restore_hvac_mode(True) + elif self._window_action in [CONF_WINDOW_FAN_ONLY]: if self.last_central_mode != CENTRAL_MODE_STOPPED: + self.set_hvac_off_reason(None) await self.restore_hvac_mode(True) else: _LOGGER.error( @@ -2462,6 +2440,12 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): _LOGGER.info( "%s - Window is open. Set hvac_mode to '%s'", self, HVACMode.OFF ) + if self._window_action == CONF_WINDOW_TURN_OFF and not self.is_on: + _LOGGER.debug( + "%s is already off. Forget turning off VTherm due to window detection" + ) + return + if self.last_central_mode in [CENTRAL_MODE_AUTO, None]: if self._window_action in [CONF_WINDOW_TURN_OFF, CONF_WINDOW_FAN_ONLY]: self.save_hvac_mode() @@ -2491,6 +2475,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): self.find_preset_temp(PRESET_ECO) ) else: # default is to turn_off + self.set_hvac_off_reason(HVAC_OFF_REASON_WINDOW_DETECTION) await self.async_set_hvac_mode(HVACMode.OFF) async def async_control_heating(self, force=False, _=None) -> bool: @@ -2633,6 +2618,8 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): "is_device_active": self.is_device_active, "ema_temp": self._ema_temp, "is_used_by_central_boiler": self.is_used_by_central_boiler, + "temperature_slope": round(self.last_temperature_slope or 0, 3), + "hvac_off_reason": self.hvac_off_reason, } @callback diff --git a/custom_components/versatile_thermostat/binary_sensor.py b/custom_components/versatile_thermostat/binary_sensor.py index 73561b2..edd491d 100644 --- a/custom_components/versatile_thermostat/binary_sensor.py +++ b/custom_components/versatile_thermostat/binary_sensor.py @@ -100,7 +100,7 @@ class SecurityBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity): entry_infos, ) -> None: """Initialize the SecurityState Binary sensor""" - super().__init__(hass, unique_id, entry_infos.get(CONF_NAME)) + super().__init__(hass, unique_id, name) self._attr_name = "Security state" self._attr_unique_id = f"{self._device_name}_security_state" self._attr_is_on = False diff --git a/custom_components/versatile_thermostat/config_flow.py b/custom_components/versatile_thermostat/config_flow.py index ead7339..ae46080 100644 --- a/custom_components/versatile_thermostat/config_flow.py +++ b/custom_components/versatile_thermostat/config_flow.py @@ -128,6 +128,11 @@ class VersatileThermostatBaseConfigFlow(FlowHandler): and self._infos.get(CONF_CENTRAL_BOILER_DEACTIVATION_SRV) is not None ) + self._infos[CONF_USE_AUTO_START_STOP_FEATURE] = ( + self._infos.get(CONF_USE_AUTO_START_STOP_FEATURE) is True + and self._infos.get(CONF_THERMOSTAT_TYPE) == CONF_THERMOSTAT_CLIMATE + ) + def _init_central_config_flags(self, infos): """Initialisation of central configuration flags""" is_empty: bool = not bool(infos) @@ -431,6 +436,13 @@ class VersatileThermostatBaseConfigFlow(FlowHandler): if self._infos[CONF_USE_PRESENCE_FEATURE] is True: menu_options.append("presence") + if self._infos.get(CONF_USE_AUTO_START_STOP_FEATURE) is True and self._infos[ + CONF_THERMOSTAT_TYPE + ] in [ + CONF_THERMOSTAT_CLIMATE, + ]: + menu_options.append("auto_start_stop") + menu_options.append("advanced") if self.check_config_complete(self._infos): @@ -520,17 +532,29 @@ class VersatileThermostatBaseConfigFlow(FlowHandler): """Handle the Type flow steps""" _LOGGER.debug("Into ConfigFlow.async_step_features user_input=%s", user_input) + schema = STEP_FEATURES_DATA_SCHEMA + if self._infos[CONF_THERMOSTAT_TYPE] == CONF_THERMOSTAT_CENTRAL_CONFIG: + schema = STEP_CENTRAL_FEATURES_DATA_SCHEMA + elif self._infos[CONF_THERMOSTAT_TYPE] == CONF_THERMOSTAT_CLIMATE: + schema = STEP_CLIMATE_FEATURES_DATA_SCHEMA + return await self.generic_step( "features", - ( - STEP_CENTRAL_FEATURES_DATA_SCHEMA - if self._infos[CONF_THERMOSTAT_TYPE] == CONF_THERMOSTAT_CENTRAL_CONFIG - else STEP_FEATURES_DATA_SCHEMA - ), + schema, user_input, self.async_step_menu, ) + async def async_step_auto_start_stop(self, user_input: dict | None = None) -> FlowResult: + """ Handle the Auto start stop step""" + _LOGGER.debug("Into ConfigFlow.async_step_auto_start_stop user_input=%s", user_input) + + schema = STEP_AUTO_START_STOP + self._infos[COMES_FROM] = None + next_step = self.async_step_menu + + return await self.generic_step("auto_start_stop", schema, user_input, next_step) + async def async_step_tpi(self, user_input: dict | None = None) -> FlowResult: """Handle the TPI flow steps""" _LOGGER.debug("Into ConfigFlow.async_step_tpi user_input=%s", user_input) @@ -869,6 +893,8 @@ class VersatileThermostatOptionsFlowHandler( if not self._infos[CONF_USE_CENTRAL_BOILER_FEATURE]: self._infos[CONF_CENTRAL_BOILER_ACTIVATION_SRV] = None self._infos[CONF_CENTRAL_BOILER_DEACTIVATION_SRV] = None + if not self._infos[CONF_USE_AUTO_START_STOP_FEATURE]: + self._infos[CONF_AUTO_START_STOP_LEVEL] = AUTO_START_STOP_LEVEL_NONE _LOGGER.info( "Recreating entry %s due to configuration change. New config is now: %s", diff --git a/custom_components/versatile_thermostat/config_schema.py b/custom_components/versatile_thermostat/config_schema.py index b12c67c..3d40bfb 100644 --- a/custom_components/versatile_thermostat/config_schema.py +++ b/custom_components/versatile_thermostat/config_schema.py @@ -68,6 +68,16 @@ STEP_FEATURES_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name } ) +STEP_CLIMATE_FEATURES_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name + { + vol.Optional(CONF_USE_WINDOW_FEATURE, default=False): cv.boolean, + vol.Optional(CONF_USE_MOTION_FEATURE, default=False): cv.boolean, + vol.Optional(CONF_USE_POWER_FEATURE, default=False): cv.boolean, + vol.Optional(CONF_USE_PRESENCE_FEATURE, default=False): cv.boolean, + vol.Optional(CONF_USE_AUTO_START_STOP_FEATURE, default=False): cv.boolean, + } +) + STEP_CENTRAL_FEATURES_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name { vol.Optional(CONF_USE_WINDOW_FEATURE, default=False): cv.boolean, @@ -196,6 +206,20 @@ STEP_THERMOSTAT_VALVE = vol.Schema( # pylint: disable=invalid-name } ) +STEP_AUTO_START_STOP = vol.Schema( # pylint: disable=invalid-name + { + vol.Optional( + CONF_AUTO_START_STOP_LEVEL, default=AUTO_START_STOP_LEVEL_NONE + ): selector.SelectSelector( + selector.SelectSelectorConfig( + options=CONF_AUTO_START_STOP_LEVELS, + translation_key="auto_start_stop", + mode="dropdown", + ) + ), + } +) + STEP_TPI_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name { vol.Required(CONF_USE_TPI_CENTRAL_CONFIG, default=True): cv.boolean, diff --git a/custom_components/versatile_thermostat/const.py b/custom_components/versatile_thermostat/const.py index 5e7df19..93f88fe 100644 --- a/custom_components/versatile_thermostat/const.py +++ b/custom_components/versatile_thermostat/const.py @@ -2,6 +2,7 @@ """Constants for the Versatile Thermostat integration.""" import logging +from typing import Literal from enum import Enum from homeassistant.const import CONF_NAME, Platform @@ -51,6 +52,7 @@ PLATFORMS: list[Platform] = [ # Number should be after CLIMATE Platform.NUMBER, Platform.BINARY_SENSOR, + Platform.SWITCH, ] CONF_HEATER = "heater_entity_id" @@ -97,6 +99,7 @@ CONF_USE_MOTION_FEATURE = "use_motion_feature" CONF_USE_PRESENCE_FEATURE = "use_presence_feature" CONF_USE_POWER_FEATURE = "use_power_feature" CONF_USE_CENTRAL_BOILER_FEATURE = "use_central_boiler_feature" +CONF_USE_AUTO_START_STOP_FEATURE = "use_auto_start_stop_feature" CONF_AC_MODE = "ac_mode" CONF_WINDOW_AUTO_OPEN_THRESHOLD = "window_auto_open_threshold" CONF_WINDOW_AUTO_CLOSE_THRESHOLD = "window_auto_close_threshold" @@ -145,6 +148,36 @@ CONF_CENTRAL_BOILER_DEACTIVATION_SRV = "central_boiler_deactivation_service" CONF_USED_BY_CENTRAL_BOILER = "used_by_controls_central_boiler" CONF_WINDOW_ACTION = "window_action" +CONF_AUTO_START_STOP_LEVEL = "auto_start_stop_level" +AUTO_START_STOP_LEVEL_NONE = "auto_start_stop_none" +AUTO_START_STOP_LEVEL_SLOW = "auto_start_stop_slow" +AUTO_START_STOP_LEVEL_MEDIUM = "auto_start_stop_medium" +AUTO_START_STOP_LEVEL_FAST = "auto_start_stop_fast" +CONF_AUTO_START_STOP_LEVELS = [ + AUTO_START_STOP_LEVEL_NONE, + AUTO_START_STOP_LEVEL_SLOW, + AUTO_START_STOP_LEVEL_MEDIUM, + AUTO_START_STOP_LEVEL_FAST, +] + +# For explicit typing purpose only +TYPE_AUTO_START_STOP_LEVELS = Literal[ # pylint: disable=invalid-name + AUTO_START_STOP_LEVEL_FAST, + AUTO_START_STOP_LEVEL_MEDIUM, + AUTO_START_STOP_LEVEL_SLOW, + AUTO_START_STOP_LEVEL_NONE, +] + +HVAC_OFF_REASON_NAME = "hvac_off_reason" +HVAC_OFF_REASON_MANUAL = "manual" +HVAC_OFF_REASON_AUTO_START_STOP = "auto_start_stop" +HVAC_OFF_REASON_WINDOW_DETECTION = "window_detection" +HVAC_OFF_REASONS = Literal[ # pylint: disable=invalid-name + HVAC_OFF_REASON_MANUAL, + HVAC_OFF_REASON_AUTO_START_STOP, + HVAC_OFF_REASON_WINDOW_DETECTION, +] + DEFAULT_SHORT_EMA_PARAMS = { "max_alpha": 0.5, # In sec @@ -445,6 +478,7 @@ class EventType(Enum): CENTRAL_BOILER_EVENT: str = "versatile_thermostat_central_boiler_event" PRESET_EVENT: str = "versatile_thermostat_preset_event" WINDOW_AUTO_EVENT: str = "versatile_thermostat_window_auto_event" + AUTO_START_STOP_EVENT: str = "versatile_thermostat_auto_start_stop_event" def send_vtherm_event(hass, event_type: EventType, entity, data: dict): diff --git a/custom_components/versatile_thermostat/manifest.json b/custom_components/versatile_thermostat/manifest.json index d9ac2e7..21b4183 100644 --- a/custom_components/versatile_thermostat/manifest.json +++ b/custom_components/versatile_thermostat/manifest.json @@ -14,6 +14,6 @@ "quality_scale": "silver", "requirements": [], "ssdp": [], - "version": "6.3.0", + "version": "6.5.0", "zeroconf": [] } \ No newline at end of file diff --git a/custom_components/versatile_thermostat/strings.json b/custom_components/versatile_thermostat/strings.json index 7692c9e..b72d6a1 100644 --- a/custom_components/versatile_thermostat/strings.json +++ b/custom_components/versatile_thermostat/strings.json @@ -27,6 +27,7 @@ "power": "Power management", "presence": "Presence detection", "advanced": "Advanced parameters", + "auto_start_stop": "Auto start and stop", "finalize": "All done", "configuration_not_complete": "Configuration not complete" } @@ -63,7 +64,8 @@ "use_motion_feature": "Use motion detection", "use_power_feature": "Use power management", "use_presence_feature": "Use presence detection", - "use_central_boiler_feature": "Use a central boiler. Check to add a control to your central boiler. You will have to configure the VTherm which will have a control of the central boiler after seecting this checkbox to take effect. If one VTherm requires heating, the boiler will be turned on. If no VTherm requires heating, the boiler will be turned off. Commands for turning on/off the central boiler are given in the related configuration page" + "use_central_boiler_feature": "Use a central boiler. Check to add a control to your central boiler. You will have to configure the VTherm which will have a control of the central boiler after seecting this checkbox to take effect. If one VTherm requires heating, the boiler will be turned on. If no VTherm requires heating, the boiler will be turned off. Commands for turning on/off the central boiler are given in the related configuration page", + "use_auto_start_stop_feature": "Use the auto start and stop feature" } }, "type": { @@ -262,6 +264,7 @@ "power": "Power management", "presence": "Presence detection", "advanced": "Advanced parameters", + "auto_start_stop": "Auto start and stop", "finalize": "All done", "configuration_not_complete": "Configuration not complete" } @@ -298,7 +301,8 @@ "use_motion_feature": "Use motion detection", "use_power_feature": "Use power management", "use_presence_feature": "Use presence detection", - "use_central_boiler_feature": "Use a central boiler. Check to add a control to your central boiler. You will have to configure the VTherm which will have a control of the central boiler after seecting this checkbox to take effect. If one VTherm requires heating, the boiler will be turned on. If no VTherm requires heating, the boiler will be turned off. Commands for turning on/off the central boiler are given in the related configuration page" + "use_central_boiler_feature": "Use a central boiler. Check to add a control to your central boiler. You will have to configure the VTherm which will have a control of the central boiler after seecting this checkbox to take effect. If one VTherm requires heating, the boiler will be turned on. If no VTherm requires heating, the boiler will be turned off. Commands for turning on/off the central boiler are given in the related configuration page", + "use_auto_start_stop_feature": "Use the auto start and stop feature" } }, "type": { @@ -514,6 +518,14 @@ "comfort": "Comfort", "boost": "Boost" } + }, + "auto_start_stop": { + "options": { + "auto_start_stop_none": "No auto start/stop", + "auto_start_stop_slow": "Slow detection", + "auto_start_stop_medium": "Medium detection", + "auto_start_stop_fast": "Fast detection" + } } }, "entity": { diff --git a/custom_components/versatile_thermostat/switch.py b/custom_components/versatile_thermostat/switch.py new file mode 100644 index 0000000..e1b7218 --- /dev/null +++ b/custom_components/versatile_thermostat/switch.py @@ -0,0 +1,102 @@ +## pylint: disable=unused-argument + +""" Implements the VersatileThermostat select component """ +import logging +from typing import Any + +from homeassistant.core import HomeAssistant, callback + +from homeassistant.components.switch import SwitchEntity + +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .commons import VersatileThermostatBaseEntity + +from .const import * # pylint: disable=unused-wildcard-import,wildcard-import + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the VersatileThermostat switches with config flow.""" + _LOGGER.debug( + "Calling async_setup_entry entry=%s, data=%s", entry.entry_id, entry.data + ) + + unique_id = entry.entry_id + name = entry.data.get(CONF_NAME) + vt_type = entry.data.get(CONF_THERMOSTAT_TYPE) + auto_start_stop_feature = entry.data.get(CONF_USE_AUTO_START_STOP_FEATURE) + + if vt_type == CONF_THERMOSTAT_CLIMATE and auto_start_stop_feature is True: + # Creates a switch to enable the auto-start/stop + enable_entity = AutoStartStopEnable(hass, unique_id, name, entry) + async_add_entities([enable_entity], True) + + +class AutoStartStopEnable(VersatileThermostatBaseEntity, SwitchEntity, RestoreEntity): + """The that enables the ManagedDevice optimisation with""" + + def __init__( + self, hass: HomeAssistant, unique_id: str, name: str, entry_infos: ConfigEntry + ): + super().__init__(hass, unique_id, name) + self._attr_name = "Enable auto start/stop" + self._attr_unique_id = f"{self._device_name}_enable_auto_start_stop" + self._default_value = ( + entry_infos.data.get(CONF_AUTO_START_STOP_LEVEL) + != AUTO_START_STOP_LEVEL_NONE + ) + self._attr_is_on = self._default_value + + @property + def icon(self) -> str | None: + """The icon""" + return "mdi:power-settings" + + async def async_added_to_hass(self): + await super().async_added_to_hass() + + # Récupérer le dernier état sauvegardé de l'entité + last_state = await self.async_get_last_state() + + # Si l'état précédent existe, vous pouvez l'utiliser + if last_state is not None: + self._attr_is_on = last_state.state == "on" + else: + # If no previous state set it to false by default + self._attr_is_on = self._default_value + + self.update_my_state_and_vtherm() + + def update_my_state_and_vtherm(self): + """Update the auto_start_stop_enable flag in my VTherm""" + self.async_write_ha_state() + if self.my_climate is not None: + self.my_climate.set_auto_start_stop_enable(self._attr_is_on) + + @callback + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + self.turn_on() + + @callback + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + self.turn_off() + + @overrides + def turn_off(self, **kwargs: Any): + self._attr_is_on = False + self.update_my_state_and_vtherm() + + @overrides + def turn_on(self, **kwargs: Any): + self._attr_is_on = True + self.update_my_state_and_vtherm() diff --git a/custom_components/versatile_thermostat/thermostat_climate.py b/custom_components/versatile_thermostat/thermostat_climate.py index 728c685..5151c7c 100644 --- a/custom_components/versatile_thermostat/thermostat_climate.py +++ b/custom_components/versatile_thermostat/thermostat_climate.py @@ -20,40 +20,15 @@ from .commons import NowClass, round_to_nearest from .base_thermostat import BaseThermostat, ConfigData from .pi_algorithm import PITemperatureRegulator -from .const import ( - overrides, - DOMAIN, - CONF_CLIMATE, - CONF_CLIMATE_2, - CONF_CLIMATE_3, - CONF_CLIMATE_4, - CONF_AUTO_REGULATION_MODE, - CONF_AUTO_REGULATION_NONE, - CONF_AUTO_REGULATION_SLOW, - CONF_AUTO_REGULATION_LIGHT, - CONF_AUTO_REGULATION_MEDIUM, - CONF_AUTO_REGULATION_STRONG, - CONF_AUTO_REGULATION_EXPERT, - CONF_AUTO_REGULATION_DTEMP, - CONF_AUTO_REGULATION_PERIOD_MIN, - CONF_AUTO_REGULATION_USE_DEVICE_TEMP, - CONF_AUTO_FAN_MODE, - CONF_AUTO_FAN_NONE, - CONF_AUTO_FAN_LOW, - CONF_AUTO_FAN_MEDIUM, - CONF_AUTO_FAN_HIGH, - CONF_AUTO_FAN_TURBO, - RegulationParamSlow, - RegulationParamLight, - RegulationParamMedium, - RegulationParamStrong, - AUTO_FAN_DTEMP_THRESHOLD, - AUTO_FAN_DEACTIVATED_MODES, - UnknownEntity, -) +from .const import * # pylint: disable=wildcard-import, unused-wildcard-import from .vtherm_api import VersatileThermostatAPI from .underlyings import UnderlyingClimate +from .auto_start_stop_algorithm import ( + AutoStartStopDetectionAlgorithm, + AUTO_START_STOP_ACTION_OFF, + AUTO_START_STOP_ACTION_ON, +) _LOGGER = logging.getLogger(__name__) @@ -64,7 +39,6 @@ HVAC_ACTION_ON = [ # pylint: disable=invalid-name HVACAction.HEATING, ] - class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]): """Representation of a base class for a Versatile Thermostat over a climate""" @@ -81,6 +55,9 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]): # The fan_mode name depending of the current_mode _auto_activated_fan_mode: str | None = None _auto_deactivated_fan_mode: str | None = None + _auto_start_stop_level: TYPE_AUTO_START_STOP_LEVELS = AUTO_START_STOP_LEVEL_NONE + _auto_start_stop_algo: AutoStartStopDetectionAlgorithm | None = None + _is_auto_start_stop_enabled: bool = False _entity_component_unrecorded_attributes = ( BaseThermostat._entity_component_unrecorded_attributes.union( @@ -99,6 +76,11 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]): "auto_activated_fan_mode", "auto_deactivated_fan_mode", "auto_regulation_use_device_temp", + "auto_start_stop_level", + "auto_start_stop_dtmin", + "auto_start_stop_enable", + "auto_start_stop_accumulated_error", + "auto_start_stop_accumulated_error_threshold", } ) ) @@ -113,6 +95,66 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]): self._regulated_target_temp = self.target_temperature self._last_regulation_change = NowClass.get_now(hass) + @overrides + def post_init(self, config_entry: ConfigData): + """Initialize the Thermostat""" + + super().post_init(config_entry) + for climate in [ + CONF_CLIMATE, + CONF_CLIMATE_2, + CONF_CLIMATE_3, + CONF_CLIMATE_4, + ]: + if config_entry.get(climate): + self._underlyings.append( + UnderlyingClimate( + hass=self._hass, + thermostat=self, + climate_entity_id=config_entry.get(climate), + ) + ) + + self.choose_auto_regulation_mode( + config_entry.get(CONF_AUTO_REGULATION_MODE) + if config_entry.get(CONF_AUTO_REGULATION_MODE) is not None + else CONF_AUTO_REGULATION_NONE + ) + + self._auto_regulation_dtemp = ( + config_entry.get(CONF_AUTO_REGULATION_DTEMP) + if config_entry.get(CONF_AUTO_REGULATION_DTEMP) is not None + else 0.5 + ) + self._auto_regulation_period_min = ( + config_entry.get(CONF_AUTO_REGULATION_PERIOD_MIN) + if config_entry.get(CONF_AUTO_REGULATION_PERIOD_MIN) is not None + else 5 + ) + + self._auto_fan_mode = ( + config_entry.get(CONF_AUTO_FAN_MODE) + if config_entry.get(CONF_AUTO_FAN_MODE) is not None + else CONF_AUTO_FAN_NONE + ) + + self._auto_regulation_use_device_temp = config_entry.get( + CONF_AUTO_REGULATION_USE_DEVICE_TEMP, False + ) + + use_auto_start_stop = config_entry.get(CONF_USE_AUTO_START_STOP_FEATURE, False) + if use_auto_start_stop: + self._auto_start_stop_level = config_entry.get( + CONF_AUTO_START_STOP_LEVEL, AUTO_START_STOP_LEVEL_NONE + ) + else: + self._auto_start_stop_level = AUTO_START_STOP_LEVEL_NONE + + # Instanciate the auto start stop algo + self._auto_start_stop_algo = AutoStartStopDetectionAlgorithm( + self._auto_start_stop_level, self.name + ) + @property def is_over_climate(self) -> bool: """True if the Thermostat is over_climate""" @@ -228,18 +270,6 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]): and self.auto_regulation_use_device_temp # and we have access to the device temp and (device_temp := under.underlying_current_temperature) is not None - # issue 467 - always apply offset. TODO removes this if ok - # and target is not reach (ie we need regulation) - # and ( - # ( - # self.hvac_mode == HVACMode.COOL - # and self.target_temperature < self.current_temperature - # ) - # or ( - # self.hvac_mode == HVACMode.HEAT - # and self.target_temperature > self.current_temperature - # ) - # ) ): offset_temp = device_temp - self.current_temperature @@ -304,53 +334,6 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]): ) await self.async_set_fan_mode(self._auto_deactivated_fan_mode) - @overrides - def post_init(self, config_entry: ConfigData): - """Initialize the Thermostat""" - - super().post_init(config_entry) - for climate in [ - CONF_CLIMATE, - CONF_CLIMATE_2, - CONF_CLIMATE_3, - CONF_CLIMATE_4, - ]: - if config_entry.get(climate): - self._underlyings.append( - UnderlyingClimate( - hass=self._hass, - thermostat=self, - climate_entity_id=config_entry.get(climate), - ) - ) - - self.choose_auto_regulation_mode( - config_entry.get(CONF_AUTO_REGULATION_MODE) - if config_entry.get(CONF_AUTO_REGULATION_MODE) is not None - else CONF_AUTO_REGULATION_NONE - ) - - self._auto_regulation_dtemp = ( - config_entry.get(CONF_AUTO_REGULATION_DTEMP) - if config_entry.get(CONF_AUTO_REGULATION_DTEMP) is not None - else 0.5 - ) - self._auto_regulation_period_min = ( - config_entry.get(CONF_AUTO_REGULATION_PERIOD_MIN) - if config_entry.get(CONF_AUTO_REGULATION_PERIOD_MIN) is not None - else 5 - ) - - self._auto_fan_mode = ( - config_entry.get(CONF_AUTO_FAN_MODE) - if config_entry.get(CONF_AUTO_FAN_MODE) is not None - else CONF_AUTO_FAN_NONE - ) - - self._auto_regulation_use_device_temp = config_entry.get( - CONF_AUTO_REGULATION_USE_DEVICE_TEMP, False - ) - def choose_auto_regulation_mode(self, auto_regulation_mode: str): """Choose or change the regulation mode""" self._auto_regulation_mode = auto_regulation_mode @@ -563,6 +546,24 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]): self.auto_regulation_use_device_temp ) + self._attr_extra_state_attributes["auto_start_stop_enable"] = ( + self.auto_start_stop_enable + ) + + self._attr_extra_state_attributes["auto_start_stop_level"] = ( + self._auto_start_stop_algo.level + ) + self._attr_extra_state_attributes["auto_start_stop_dtmin"] = ( + self._auto_start_stop_algo.dt_min + ) + self._attr_extra_state_attributes["auto_start_stop_accumulated_error"] = ( + self._auto_start_stop_algo.accumulated_error + ) + + self._attr_extra_state_attributes[ + "auto_start_stop_accumulated_error_threshold" + ] = self._auto_start_stop_algo.accumulated_error_threshold + self.async_write_ha_state() _LOGGER.debug( "%s - Calling update_custom_attributes: %s", @@ -876,11 +877,85 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]): await end_climate_changed(changes) + async def check_auto_start_stop(self): + """Check the auto-start-stop and an eventual action + Return False if we should stop the control_heating method""" + slope = (self.last_temperature_slope or 0) / 60 # to have the slope in °/min + action = self._auto_start_stop_algo.calculate_action( + self.hvac_mode, + self._saved_hvac_mode, + self.target_temperature, + self.current_temperature, + slope, + self.now, + ) + _LOGGER.debug("%s - auto_start_stop action is %s", self, action) + if action == AUTO_START_STOP_ACTION_OFF and self.is_on: + _LOGGER.info( + "%s - Turning OFF the Vtherm due to auto-start-stop conditions", + self, + ) + self.set_hvac_off_reason(HVAC_OFF_REASON_AUTO_START_STOP) + await self.async_turn_off() + + # Send an event + self.send_event( + event_type=EventType.AUTO_START_STOP_EVENT, + data={ + "type": "stop", + "name": self.name, + "cause": "Auto stop conditions reached", + "hvac_mode": self.hvac_mode, + "saved_hvac_mode": self._saved_hvac_mode, + "target_temperature": self.target_temperature, + "current_temperature": self.current_temperature, + "temperature_slope": round(slope, 3), + }, + ) + + # Stop here + return False + elif action == AUTO_START_STOP_ACTION_ON: + _LOGGER.info( + "%s - Turning ON the Vtherm due to auto-start-stop conditions", self + ) + await self.async_turn_on() + + # Send an event + self.send_event( + event_type=EventType.AUTO_START_STOP_EVENT, + data={ + "type": "start", + "name": self.name, + "cause": "Auto start conditions reached", + "hvac_mode": self.hvac_mode, + "saved_hvac_mode": self._saved_hvac_mode, + "target_temperature": self.target_temperature, + "current_temperature": self.current_temperature, + "temperature_slope": round(slope, 3), + }, + ) + + self.update_custom_attributes() + + return True + @overrides async def async_control_heating(self, force=False, _=None) -> bool: """The main function used to run the calculation at each cycle""" ret = await super().async_control_heating(force, _) + # Check if we need to auto start/stop the Vtherm + if self.auto_start_stop_enable: + continu = await self.check_auto_start_stop() + if not continu: + return ret + else: + _LOGGER.debug("%s - auto start/stop is disabled") + + # Continue the normal async_control_heating + + # Send the regulated temperature to the underlyings await self._send_regulated_temperature() if self._auto_fan_mode and self._auto_fan_mode != CONF_AUTO_FAN_NONE: @@ -888,6 +963,11 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]): return ret + def set_auto_start_stop_enable(self, is_enabled: bool): + """Enable/Disable the auto-start/stop feature""" + self._is_auto_start_stop_enabled = is_enabled + self.update_custom_attributes() + @property def auto_regulation_mode(self) -> str | None: """Get the regulation mode""" @@ -1034,6 +1114,16 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]): return False return True + @property + def auto_start_stop_level(self) -> TYPE_AUTO_START_STOP_LEVELS: + """Return the auto start/stop level.""" + return self._auto_start_stop_level + + @property + def auto_start_stop_enable(self) -> bool: + """Returns the auto_start_stop_enable""" + return self._is_auto_start_stop_enabled + @overrides def init_underlyings(self): """Init the underlyings if not already done""" diff --git a/custom_components/versatile_thermostat/translations/en.json b/custom_components/versatile_thermostat/translations/en.json index 7692c9e..b72d6a1 100644 --- a/custom_components/versatile_thermostat/translations/en.json +++ b/custom_components/versatile_thermostat/translations/en.json @@ -27,6 +27,7 @@ "power": "Power management", "presence": "Presence detection", "advanced": "Advanced parameters", + "auto_start_stop": "Auto start and stop", "finalize": "All done", "configuration_not_complete": "Configuration not complete" } @@ -63,7 +64,8 @@ "use_motion_feature": "Use motion detection", "use_power_feature": "Use power management", "use_presence_feature": "Use presence detection", - "use_central_boiler_feature": "Use a central boiler. Check to add a control to your central boiler. You will have to configure the VTherm which will have a control of the central boiler after seecting this checkbox to take effect. If one VTherm requires heating, the boiler will be turned on. If no VTherm requires heating, the boiler will be turned off. Commands for turning on/off the central boiler are given in the related configuration page" + "use_central_boiler_feature": "Use a central boiler. Check to add a control to your central boiler. You will have to configure the VTherm which will have a control of the central boiler after seecting this checkbox to take effect. If one VTherm requires heating, the boiler will be turned on. If no VTherm requires heating, the boiler will be turned off. Commands for turning on/off the central boiler are given in the related configuration page", + "use_auto_start_stop_feature": "Use the auto start and stop feature" } }, "type": { @@ -262,6 +264,7 @@ "power": "Power management", "presence": "Presence detection", "advanced": "Advanced parameters", + "auto_start_stop": "Auto start and stop", "finalize": "All done", "configuration_not_complete": "Configuration not complete" } @@ -298,7 +301,8 @@ "use_motion_feature": "Use motion detection", "use_power_feature": "Use power management", "use_presence_feature": "Use presence detection", - "use_central_boiler_feature": "Use a central boiler. Check to add a control to your central boiler. You will have to configure the VTherm which will have a control of the central boiler after seecting this checkbox to take effect. If one VTherm requires heating, the boiler will be turned on. If no VTherm requires heating, the boiler will be turned off. Commands for turning on/off the central boiler are given in the related configuration page" + "use_central_boiler_feature": "Use a central boiler. Check to add a control to your central boiler. You will have to configure the VTherm which will have a control of the central boiler after seecting this checkbox to take effect. If one VTherm requires heating, the boiler will be turned on. If no VTherm requires heating, the boiler will be turned off. Commands for turning on/off the central boiler are given in the related configuration page", + "use_auto_start_stop_feature": "Use the auto start and stop feature" } }, "type": { @@ -514,6 +518,14 @@ "comfort": "Comfort", "boost": "Boost" } + }, + "auto_start_stop": { + "options": { + "auto_start_stop_none": "No auto start/stop", + "auto_start_stop_slow": "Slow detection", + "auto_start_stop_medium": "Medium detection", + "auto_start_stop_fast": "Fast detection" + } } }, "entity": { diff --git a/custom_components/versatile_thermostat/translations/fr.json b/custom_components/versatile_thermostat/translations/fr.json index c891cc8..eadb5a5 100644 --- a/custom_components/versatile_thermostat/translations/fr.json +++ b/custom_components/versatile_thermostat/translations/fr.json @@ -27,6 +27,7 @@ "power": "Gestion de la puissance", "presence": "Détection de présence", "advanced": "Paramètres avancés", + "auto_start_stop": "Allumage/extinction automatique", "finalize": "Finaliser la création", "configuration_not_complete": "Configuration incomplète" } @@ -63,7 +64,8 @@ "use_motion_feature": "Avec détection de mouvement", "use_power_feature": "Avec gestion de la puissance", "use_presence_feature": "Avec détection de présence", - "use_central_boiler_feature": "Ajouter une chaudière centrale. Cochez pour ajouter un controle sur une chaudière centrale. Vous devrez ensuite configurer les VTherms qui commande la chaudière centrale pour que cette option prenne effet. Si au moins un des VTherm a besoin de chauffer, la chaudière centrale sera activée. Si aucun VTherm n'a besoin de chauffer, elle sera éteinte. Les commandes pour allumer/éteindre la chaudière centrale sont données dans la page de configuration suivante." + "use_central_boiler_feature": "Ajouter une chaudière centrale. Cochez pour ajouter un controle sur une chaudière centrale. Vous devrez ensuite configurer les VTherms qui commande la chaudière centrale pour que cette option prenne effet. Si au moins un des VTherm a besoin de chauffer, la chaudière centrale sera activée. Si aucun VTherm n'a besoin de chauffer, elle sera éteinte. Les commandes pour allumer/éteindre la chaudière centrale sont données dans la page de configuration suivante.", + "use_auto_start_stop_feature": "Avec démarrage et extinction automatique" } }, "type": { @@ -274,6 +276,7 @@ "power": "Gestion de la puissance", "presence": "Détection de présence", "advanced": "Paramètres avancés", + "auto_start_stop": "Allumage/extinction automatique", "finalize": "Finaliser les modifications", "configuration_not_complete": "Configuration incomplète" } @@ -310,7 +313,8 @@ "use_motion_feature": "Avec détection de mouvement", "use_power_feature": "Avec gestion de la puissance", "use_presence_feature": "Avec détection de présence", - "use_central_boiler_feature": "Ajouter une chaudière centrale. Cochez pour ajouter un controle sur une chaudière centrale. Vous devrez ensuite configurer les VTherms qui commande la chaudière centrale pour que cette option prenne effet. Si au moins un des VTherm a besoin de chauffer, la chaudière centrale sera activée. Si aucun VTherm n'a besoin de chauffer, elle sera éteinte. Les commandes pour allumer/éteindre la chaudière centrale sont données dans la page de configuration suivante." + "use_central_boiler_feature": "Ajouter une chaudière centrale. Cochez pour ajouter un controle sur une chaudière centrale. Vous devrez ensuite configurer les VTherms qui commande la chaudière centrale pour que cette option prenne effet. Si au moins un des VTherm a besoin de chauffer, la chaudière centrale sera activée. Si aucun VTherm n'a besoin de chauffer, elle sera éteinte. Les commandes pour allumer/éteindre la chaudière centrale sont données dans la page de configuration suivante.", + "use_auto_start_stop_feature": "Avec démarrage et extinction automatique" } }, "type": { @@ -532,6 +536,14 @@ "comfort": "Confort", "boost": "Renforcé (boost)" } + }, + "auto_start_stop": { + "options": { + "auto_start_stop_none": "No auto start/stop", + "auto_start_stop_slow": "Slow detection", + "auto_start_stop_medium": "Medium detection", + "auto_start_stop_fast": "Fast detection" + } } }, "entity": { diff --git a/hacs.json b/hacs.json index 468efb1..4d19687 100644 --- a/hacs.json +++ b/hacs.json @@ -3,5 +3,5 @@ "content_in_root": false, "render_readme": true, "hide_default_branch": false, - "homeassistant": "2024.9.3" + "homeassistant": "2024.10.4" } \ No newline at end of file diff --git a/requirements_dev.txt b/requirements_dev.txt index fc71187..2d65e9e 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1 +1 @@ -homeassistant==2024.9.3 +homeassistant==2024.10.4 diff --git a/tests/test_auto_start_stop.py b/tests/test_auto_start_stop.py new file mode 100644 index 0000000..07380b7 --- /dev/null +++ b/tests/test_auto_start_stop.py @@ -0,0 +1,1451 @@ +# pylint: disable=wildcard-import, unused-wildcard-import, protected-access, unused-argument, line-too-long, unused-variable + +""" Test the Auto Start Stop algorithm management """ +from datetime import datetime, timedelta +import logging +from unittest.mock import patch, call + +from homeassistant.components.climate import HVACMode +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN + +from custom_components.versatile_thermostat.thermostat_climate import ( + ThermostatOverClimate, +) +from custom_components.versatile_thermostat.auto_start_stop_algorithm import ( + AutoStartStopDetectionAlgorithm, + AUTO_START_STOP_ACTION_NOTHING, + AUTO_START_STOP_ACTION_OFF, +) +from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import + +logging.getLogger().setLevel(logging.DEBUG) + + +async def test_auto_start_stop_algo_slow_heat_off(hass: HomeAssistant): + """Testing directly the algorithm in Slow level""" + algo: AutoStartStopDetectionAlgorithm = AutoStartStopDetectionAlgorithm( + AUTO_START_STOP_LEVEL_SLOW, "testu" + ) + + tz = get_tz(hass) # pylint: disable=invalid-name + now: datetime = datetime.now(tz=tz) + + assert algo._dt == 30 + assert algo._vtherm_name == "testu" + + # 1. should not stop (accumulated_error too low) + ret = algo.calculate_action( + hvac_mode=HVACMode.HEAT, + saved_hvac_mode=HVACMode.OFF, + target_temp=21, + current_temp=22, + slope_min=0.1, + now=now, + ) + assert ret == AUTO_START_STOP_ACTION_NOTHING + assert algo.accumulated_error == -1 + + # 2. should not stop (accumulated_error too low) + now = now + timedelta(minutes=5) + ret = algo.calculate_action( + hvac_mode=HVACMode.HEAT, + saved_hvac_mode=HVACMode.OFF, + target_temp=21, + current_temp=23, + slope_min=0.1, + now=now, + ) + assert ret == AUTO_START_STOP_ACTION_NOTHING + assert algo.accumulated_error == -6 + + # 3. should not stop (accumulated_error too low) + now = now + timedelta(minutes=2) + ret = algo.calculate_action( + hvac_mode=HVACMode.HEAT, + saved_hvac_mode=HVACMode.OFF, + target_temp=21, + current_temp=23, + slope_min=0.1, + now=now, + ) + assert algo.accumulated_error == -8 + assert ret == AUTO_START_STOP_ACTION_NOTHING + + # 4 .No change on accumulated error because the new measure is too near the last one + now = now + timedelta(seconds=11) + ret = algo.calculate_action( + hvac_mode=HVACMode.HEAT, + saved_hvac_mode=HVACMode.OFF, + target_temp=21, + current_temp=23, + slope_min=0.1, + now=now, + ) + assert algo.accumulated_error == -8 + assert ret == AUTO_START_STOP_ACTION_NOTHING + + # 5. should stop now because accumulated_error is > ERROR_THRESHOLD for slow (10) + now = now + timedelta(minutes=4) + ret = algo.calculate_action( + hvac_mode=HVACMode.HEAT, + saved_hvac_mode=HVACMode.OFF, + target_temp=21, + current_temp=22, + slope_min=0.1, + now=now, + ) + assert algo.accumulated_error == -10 + assert ret == AUTO_START_STOP_ACTION_OFF + + # 6. inverse the temperature (target > current) -> accumulated_error should be divided by 2 + now = now + timedelta(minutes=2) + ret = algo.calculate_action( + hvac_mode=HVACMode.HEAT, + saved_hvac_mode=HVACMode.OFF, + target_temp=22, + current_temp=21, + slope_min=-0.1, + now=now, + ) + assert algo.accumulated_error == -4 # -10/2 + 1 + assert ret == AUTO_START_STOP_ACTION_NOTHING + + # 7. change level to slow (no real change) -> error_accumulated should not reset to 0 + algo.set_level(AUTO_START_STOP_LEVEL_SLOW) + assert algo.accumulated_error == -4 + + # 8. change level -> error_accumulated should reset to 0 + algo.set_level(AUTO_START_STOP_LEVEL_FAST) + assert algo.accumulated_error == 0 + + +async def test_auto_start_stop_algo_medium_cool_off(hass: HomeAssistant): + """Testing directly the algorithm in Slow level""" + algo: AutoStartStopDetectionAlgorithm = AutoStartStopDetectionAlgorithm( + AUTO_START_STOP_LEVEL_MEDIUM, "testu" + ) + + tz = get_tz(hass) # pylint: disable=invalid-name + now: datetime = datetime.now(tz=tz) + + assert algo._dt == 15 + assert algo._vtherm_name == "testu" + + # 1. should not stop (accumulated_error too low) + ret = algo.calculate_action( + hvac_mode=HVACMode.COOL, + saved_hvac_mode=HVACMode.OFF, + target_temp=22, + current_temp=21, + slope_min=0.1, + now=now, + ) + assert ret == AUTO_START_STOP_ACTION_NOTHING + assert algo.accumulated_error == 1 + + # 2. should not stop (accumulated_error too low) + now = now + timedelta(minutes=3) + ret = algo.calculate_action( + hvac_mode=HVACMode.COOL, + saved_hvac_mode=HVACMode.OFF, + target_temp=23, + current_temp=21, + slope_min=0.1, + now=now, + ) + assert ret == AUTO_START_STOP_ACTION_NOTHING + assert algo.accumulated_error == 4 + + # 2. should stop + now = now + timedelta(minutes=5) + ret = algo.calculate_action( + hvac_mode=HVACMode.COOL, + saved_hvac_mode=HVACMode.OFF, + target_temp=23, + current_temp=21, + slope_min=0.1, + now=now, + ) + assert ret == AUTO_START_STOP_ACTION_OFF + assert algo.accumulated_error == 5 # should be 9 but is capped at error threshold + + # 6. inverse the temperature (target > current) -> accumulated_error should be divided by 2 + now = now + timedelta(minutes=2) + ret = algo.calculate_action( + hvac_mode=HVACMode.COOL, + saved_hvac_mode=HVACMode.OFF, + target_temp=21, + current_temp=22, + slope_min=-0.1, + now=now, + ) + assert algo.accumulated_error == 1.5 # 5/2 - 1 + assert ret == AUTO_START_STOP_ACTION_NOTHING + + +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_auto_start_stop_none_vtherm( + hass: HomeAssistant, skip_hass_states_is_state +): + """Test than auto-start/stop is disabled with a real over_climate VTherm in NONE level""" + + # 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_AUTO_START_STOP_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, + CONF_AUTO_START_STOP_LEVEL: AUTO_START_STOP_LEVEL_NONE, + }, + ) + + fake_underlying_climate = MockClimate( + hass=hass, + unique_id="mock_climate", + name="mock_climate", + hvac_modes=[HVACMode.OFF, HVACMode.COOL, HVACMode.HEAT], + ) + + 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 + + # Initialize all temps + await set_all_climate_preset_temp(hass, vtherm, temps, "overclimate") + # Check correct initialization of auto_start_stop attributes + assert ( + vtherm._attr_extra_state_attributes["auto_start_stop_level"] + == AUTO_START_STOP_LEVEL_NONE + ) + + assert vtherm._attr_extra_state_attributes["auto_start_stop_dtmin"] is None + + # 1. Vtherm auto-start/stop should be in NONE mode + assert vtherm.auto_start_stop_level == AUTO_START_STOP_LEVEL_NONE + + # 2. We should not find any switch Enable entity + assert ( + search_entity(hass, "switch.overclimate_enable_auto_start_stop", SWITCH_DOMAIN) + is None + ) + + +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_auto_start_stop_medium_heat_vtherm( + hass: HomeAssistant, skip_hass_states_is_state +): + """Test than auto-start/stop works with a real over_climate VTherm in MEDIUM level""" + + # 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_AUTO_START_STOP_FEATURE: True, + 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, + CONF_AUTO_START_STOP_LEVEL: AUTO_START_STOP_LEVEL_MEDIUM, + }, + ) + + fake_underlying_climate = MockClimate( + hass=hass, + unique_id="mock_climate", + name="mock_climate", + hvac_modes=[HVACMode.OFF, HVACMode.COOL, HVACMode.HEAT], + ) + + 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 + + # Initialize all temps + await set_all_climate_preset_temp(hass, vtherm, temps, "overclimate") + + # Check correct initialization of auto_start_stop attributes + assert ( + vtherm._attr_extra_state_attributes["auto_start_stop_level"] + == AUTO_START_STOP_LEVEL_MEDIUM + ) + + assert vtherm._attr_extra_state_attributes["auto_start_stop_dtmin"] == 15 + + # 1. Vtherm auto-start/stop should be in MEDIUM mode and an enable entity should exists + assert vtherm.auto_start_stop_level == AUTO_START_STOP_LEVEL_MEDIUM + enable_entity = search_entity( + hass, "switch.overclimate_enable_auto_start_stop", SWITCH_DOMAIN + ) + assert enable_entity is not None + assert enable_entity.state == STATE_ON + + tz = get_tz(hass) # pylint: disable=invalid-name + now: datetime = datetime.now(tz=tz) + + # 2. Set mode to Heat and preset to Comfort + await send_presence_change_event(vtherm, True, False, now) + await send_temperature_change_event(vtherm, 18, now, True) + 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 + # VTherm should be heating + assert vtherm.hvac_mode == HVACMode.HEAT + + # 3. Set current temperature to 19 5 min later + now = now + timedelta(minutes=5) + # reset accumulated error (only for testing) + vtherm._auto_start_stop_algo._accumulated_error = 0 + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ) as mock_send_event: + vtherm._set_now(now) + await send_temperature_change_event(vtherm, 19, now, True) + await hass.async_block_till_done() + + # VTherm should still be heating + assert vtherm.hvac_mode == HVACMode.HEAT + assert mock_send_event.call_count == 0 + assert ( + vtherm._auto_start_stop_algo.accumulated_error == 0 + ) # target = current = 19 + + # 4. Set current temperature to 20 5 min later + now = now + timedelta(minutes=5) + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ) as mock_send_event: + vtherm._set_now(now) + await send_temperature_change_event(vtherm, 20, now, True) + await hass.async_block_till_done() + + # VTherm should still be heating + assert vtherm.hvac_mode == HVACMode.HEAT + assert mock_send_event.call_count == 0 + # accumulated_error = target - current = -1 x 5 min / 2 + assert vtherm._auto_start_stop_algo.accumulated_error == -2.5 + + # 5. Set current temperature to 21 5 min later -> should turn off + now = now + timedelta(minutes=5) + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ) as mock_send_event: + vtherm._set_now(now) + await send_temperature_change_event(vtherm, 21, now, True) + await hass.async_block_till_done() + + # VTherm should have been stopped + assert vtherm.hvac_mode == HVACMode.OFF + assert vtherm.hvac_off_reason == HVAC_OFF_REASON_AUTO_START_STOP + + # accumulated_error = -2.5 + target - current = -2 x 5 min / 2 capped to 5 + assert vtherm._auto_start_stop_algo.accumulated_error == -5 + + # a message should have been sent + assert mock_send_event.call_count >= 1 + mock_send_event.assert_has_calls( + [ + call( + event_type=EventType.AUTO_START_STOP_EVENT, + data={ + "type": "stop", + "name": "overClimate", + "cause": "Auto stop conditions reached", + "hvac_mode": HVACMode.OFF, + "saved_hvac_mode": HVACMode.HEAT, + "target_temperature": 19.0, + "current_temperature": 21.0, + "temperature_slope": 0.167, + }, + ) + ] + ) + + mock_send_event.assert_has_calls( + [ + call( + EventType.HVAC_MODE_EVENT, + { + "hvac_mode": HVACMode.OFF, + }, + ) + ] + ) + + # 6. Set temperature to small over the target, so that it will stay to OFF + now = now + timedelta(minutes=10) + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ) as mock_send_event: + vtherm._set_now(now) + await send_temperature_change_event(vtherm, 19.5, now, True) + await hass.async_block_till_done() + + # accumulated_error = .... capped to -5 + assert vtherm._auto_start_stop_algo.accumulated_error == -5 + + # VTherm should stay stopped cause slope is too low to allow the turn to On + assert vtherm.hvac_mode == HVACMode.OFF + assert vtherm.hvac_off_reason == HVAC_OFF_REASON_AUTO_START_STOP + + # 7. Set temperature to over the target, so that it will turn to heat + now = now + timedelta(minutes=20) + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ) as mock_send_event: + vtherm._set_now(now) + await send_temperature_change_event(vtherm, 18, now, True) + await hass.async_block_till_done() + + # accumulated_error = -5/2 + target - current = 1 x 20 min / 2 capped to 5 + assert vtherm._auto_start_stop_algo.accumulated_error == 5 + + # VTherm should have been stopped + assert vtherm.hvac_mode == HVACMode.HEAT + assert vtherm.hvac_off_reason is None + + # a message should have been sent + assert mock_send_event.call_count >= 1 + mock_send_event.assert_has_calls( + [ + call( + event_type=EventType.AUTO_START_STOP_EVENT, + data={ + "type": "start", + "name": "overClimate", + "cause": "Auto start conditions reached", + "hvac_mode": HVACMode.HEAT, + "saved_hvac_mode": HVACMode.HEAT, # saved don't change + "target_temperature": 19.0, + "current_temperature": 18.0, + "temperature_slope": -0.034, + }, + ) + ] + ) + + mock_send_event.assert_has_calls( + [ + call( + EventType.HVAC_MODE_EVENT, + { + "hvac_mode": HVACMode.HEAT, + }, + ) + ] + ) + + +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_auto_start_stop_fast_ac_vtherm( + hass: HomeAssistant, skip_hass_states_is_state +): + """Test than auto-start/stop works with a real over_climate VTherm in FAST level and AC mode""" + + # 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_AUTO_START_STOP_FEATURE: True, + 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, + CONF_AUTO_START_STOP_LEVEL: AUTO_START_STOP_LEVEL_FAST, + }, + ) + + fake_underlying_climate = MockClimate( + hass=hass, + unique_id="mock_climate", + name="mock_climate", + hvac_modes=[HVACMode.OFF, HVACMode.COOL, HVACMode.HEAT], + ) + + 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 + + # Initialize all temps + await set_all_climate_preset_temp(hass, vtherm, temps, "overclimate") + + # Check correct initialization of auto_start_stop attributes + assert ( + vtherm._attr_extra_state_attributes["auto_start_stop_level"] + == AUTO_START_STOP_LEVEL_FAST + ) + + assert vtherm._attr_extra_state_attributes["auto_start_stop_dtmin"] == 7 + + # 1. Vtherm auto-start/stop should be in MEDIUM mode + assert vtherm.auto_start_stop_level == AUTO_START_STOP_LEVEL_FAST + + tz = get_tz(hass) # pylint: disable=invalid-name + now: datetime = datetime.now(tz=tz) + + # 2. Set mode to Heat and preset to Comfort + await send_presence_change_event(vtherm, True, False, now) + await send_temperature_change_event(vtherm, 27, now, True) + await vtherm.async_set_hvac_mode(HVACMode.COOL) + await vtherm.async_set_preset_mode(PRESET_COMFORT) + await hass.async_block_till_done() + + assert vtherm.target_temperature == 25.0 + # VTherm should be cooling + assert vtherm.hvac_mode == HVACMode.COOL + + # 3. Set current temperature to 19 5 min later + now = now + timedelta(minutes=5) + # reset accumulated error for test + vtherm._auto_start_stop_algo._accumulated_error = 0 + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ) as mock_send_event: + vtherm._set_now(now) + await send_temperature_change_event(vtherm, 25, now, True) + await hass.async_block_till_done() + + # VTherm should still be cooling + assert vtherm.hvac_mode == HVACMode.COOL + assert mock_send_event.call_count == 0 + assert ( + vtherm._auto_start_stop_algo.accumulated_error == 0 # target = current = 25 + ) + + # 4. Set current temperature to 23 5 min later -> should turn off + now = now + timedelta(minutes=5) + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ) as mock_send_event: + vtherm._set_now(now) + await send_temperature_change_event(vtherm, 23, now, True) + await hass.async_block_till_done() + + # VTherm should have been stopped + assert vtherm.hvac_mode == HVACMode.OFF + + # accumulated_error = target - current = 2 x 5 min / 2 capped to 2 + assert vtherm._auto_start_stop_algo.accumulated_error == 2 + + # a message should have been sent + assert mock_send_event.call_count >= 1 + mock_send_event.assert_has_calls( + [ + call( + event_type=EventType.AUTO_START_STOP_EVENT, + data={ + "type": "stop", + "name": "overClimate", + "cause": "Auto stop conditions reached", + "hvac_mode": HVACMode.OFF, + "saved_hvac_mode": HVACMode.COOL, + "target_temperature": 25.0, + "current_temperature": 23.0, + "temperature_slope": -0.28, + }, + ) + ] + ) + + mock_send_event.assert_has_calls( + [ + call( + EventType.HVAC_MODE_EVENT, + { + "hvac_mode": HVACMode.OFF, + }, + ) + ] + ) + + # 5. Set temperature to over the target, but slope is too low -> no change + now = now + timedelta(minutes=30) + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ) as mock_send_event: + vtherm._set_now(now) + await send_temperature_change_event(vtherm, 25.5, now, True) + await hass.async_block_till_done() + + # accumulated_error = 2/2 + target - current = -1 x 20 min / 2 capped to 2 + assert vtherm._auto_start_stop_algo.accumulated_error == -2 + + # VTherm should stay stopped + assert vtherm.hvac_mode == HVACMode.OFF + # a message should have been sent + assert mock_send_event.call_count == 0 + + # 6. Set temperature to over the target, so that it will turn to COOL + now = now + timedelta(minutes=5) + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ) as mock_send_event: + vtherm._set_now(now) + await send_temperature_change_event(vtherm, 26.5, now, True) + await hass.async_block_till_done() + + # accumulated_error = 2/2 + target - current = -1 x 20 min / 2 capped to 2 + assert vtherm._auto_start_stop_algo.accumulated_error == -2 + + # VTherm should have been stopped + assert vtherm.hvac_mode == HVACMode.COOL + # a message should have been sent + assert mock_send_event.call_count >= 1 + mock_send_event.assert_has_calls( + [ + call( + event_type=EventType.AUTO_START_STOP_EVENT, + data={ + "type": "start", + "name": "overClimate", + "cause": "Auto start conditions reached", + "hvac_mode": HVACMode.COOL, + "saved_hvac_mode": HVACMode.COOL, # saved don't change + "target_temperature": 25.0, + "current_temperature": 26.5, + "temperature_slope": 0.112, + }, + ) + ] + ) + + mock_send_event.assert_has_calls( + [ + call( + EventType.HVAC_MODE_EVENT, + { + "hvac_mode": HVACMode.COOL, + }, + ) + ] + ) + + +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_auto_start_stop_medium_heat_vtherm_preset_change( + hass: HomeAssistant, skip_hass_states_is_state +): + """Test than auto-start/stop restart a VTherm stopped upon preset_change (in fast mode)""" + + # 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_AUTO_START_STOP_FEATURE: True, + 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, + CONF_AUTO_START_STOP_LEVEL: AUTO_START_STOP_LEVEL_FAST, + }, + ) + + fake_underlying_climate = MockClimate( + hass=hass, + unique_id="mock_climate", + name="mock_climate", + hvac_modes=[HVACMode.OFF, HVACMode.COOL, HVACMode.HEAT], + ) + + 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 + + # Initialize all temps + await set_all_climate_preset_temp(hass, vtherm, temps, "overclimate") + + # Check correct initialization of auto_start_stop attributes + assert ( + vtherm._attr_extra_state_attributes["auto_start_stop_level"] + == AUTO_START_STOP_LEVEL_FAST + ) + + assert vtherm._attr_extra_state_attributes["auto_start_stop_dtmin"] == 7 + + # 1. Vtherm auto-start/stop should be in MEDIUM mode + assert vtherm.auto_start_stop_level == AUTO_START_STOP_LEVEL_FAST + + tz = get_tz(hass) # pylint: disable=invalid-name + now: datetime = datetime.now(tz=tz) + + # 2. Set mode to Heat and preset to Comfort + await send_presence_change_event(vtherm, True, False, now) + await send_temperature_change_event(vtherm, 16, now, True) + await vtherm.async_set_hvac_mode(HVACMode.HEAT) + await vtherm.async_set_preset_mode(PRESET_ECO) + await hass.async_block_till_done() + + assert vtherm.target_temperature == 17.0 + # VTherm should be heating + assert vtherm.hvac_mode == HVACMode.HEAT + + # 3. Set current temperature to 21 5 min later to auto-stop + now = now + timedelta(minutes=5) + vtherm._auto_start_stop_algo._accumulated_error = 0 + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ) as mock_send_event: + vtherm._set_now(now) + await send_temperature_change_event(vtherm, 19, now, True) + await hass.async_block_till_done() + + # VTherm should have been stopped + assert vtherm.hvac_mode == HVACMode.OFF + + assert vtherm._auto_start_stop_algo.accumulated_error == -2 + + # a message should have been sent + assert mock_send_event.call_count >= 1 + mock_send_event.assert_has_calls( + [ + call( + event_type=EventType.AUTO_START_STOP_EVENT, + data={ + "type": "stop", + "name": "overClimate", + "cause": "Auto stop conditions reached", + "hvac_mode": HVACMode.OFF, + "saved_hvac_mode": HVACMode.HEAT, + "target_temperature": 17.0, + "current_temperature": 19.0, + "temperature_slope": 0.3, + }, + ) + ] + ) + + mock_send_event.assert_has_calls( + [ + call( + EventType.HVAC_MODE_EVENT, + { + "hvac_mode": HVACMode.OFF, + }, + ) + ] + ) + + # 4.1 reduce the slope (because slope is smoothed and was very high) + now = now + timedelta(minutes=5) + await send_temperature_change_event(vtherm, 19, now, True) + + now = now + timedelta(minutes=5) + await send_temperature_change_event(vtherm, 18, now, True) + + now = now + timedelta(minutes=5) + await send_temperature_change_event(vtherm, 17, now, True) + + # 4. Change preset to auto restart the Vtherm + now = now + timedelta(minutes=10) + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ) as mock_send_event: + vtherm._set_now(now) + await vtherm.async_set_preset_mode(PRESET_BOOST) + await hass.async_block_till_done() + assert vtherm.target_temperature == 21 + + assert vtherm._auto_start_stop_algo.accumulated_error == 2 + + # VTherm should have been restarted + assert vtherm.hvac_mode == HVACMode.HEAT + # a message should have been sent + assert mock_send_event.call_count >= 1 + mock_send_event.assert_has_calls( + [ + call( + event_type=EventType.AUTO_START_STOP_EVENT, + data={ + "type": "start", + "name": "overClimate", + "cause": "Auto start conditions reached", + "hvac_mode": HVACMode.HEAT, + "saved_hvac_mode": HVACMode.HEAT, # saved don't change + "target_temperature": 21.0, + "current_temperature": 17.0, + "temperature_slope": -0.087, + }, + ) + ] + ) + + mock_send_event.assert_has_calls( + [ + call( + EventType.HVAC_MODE_EVENT, + { + "hvac_mode": HVACMode.HEAT, + }, + ) + ] + ) + + +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_auto_start_stop_medium_heat_vtherm_preset_change_enable_false( + hass: HomeAssistant, skip_hass_states_is_state +): + """Test than auto-start/stop restart a VTherm stopped upon preset_change (in fast mode)""" + + # 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_AUTO_START_STOP_FEATURE: True, + 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, + CONF_AUTO_START_STOP_LEVEL: AUTO_START_STOP_LEVEL_FAST, + }, + ) + + fake_underlying_climate = MockClimate( + hass=hass, + unique_id="mock_climate", + name="mock_climate", + hvac_modes=[HVACMode.OFF, HVACMode.COOL, HVACMode.HEAT], + ) + + 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 + + # Initialize all temps + await set_all_climate_preset_temp(hass, vtherm, temps, "overclimate") + + # Check correct initialization of auto_start_stop attributes + assert ( + vtherm._attr_extra_state_attributes["auto_start_stop_level"] + == AUTO_START_STOP_LEVEL_FAST + ) + + assert vtherm._attr_extra_state_attributes["auto_start_stop_dtmin"] == 7 + + # 1. Vtherm auto-start/stop should be in FAST mode and enable should be on + assert vtherm.auto_start_stop_level == AUTO_START_STOP_LEVEL_FAST + enable_entity = search_entity( + hass, "switch.overclimate_enable_auto_start_stop", SWITCH_DOMAIN + ) + assert enable_entity is not None + assert enable_entity.state == STATE_ON + + assert vtherm._attr_extra_state_attributes.get("auto_start_stop_enable") is True + + # 2. set enable to false + enable_entity.turn_off() + await hass.async_block_till_done() + assert enable_entity.state == STATE_OFF + assert vtherm._attr_extra_state_attributes.get("auto_start_stop_enable") is False + + tz = get_tz(hass) # pylint: disable=invalid-name + now: datetime = datetime.now(tz=tz) + + # 3. Set mode to Heat and preset to Comfort + await send_presence_change_event(vtherm, True, False, now) + await send_temperature_change_event(vtherm, 16, now, True) + await vtherm.async_set_hvac_mode(HVACMode.HEAT) + await vtherm.async_set_preset_mode(PRESET_ECO) + await hass.async_block_till_done() + + assert vtherm.target_temperature == 17.0 + # VTherm should be heating + assert vtherm.hvac_mode == HVACMode.HEAT + + # 3. Set current temperature to 21 5 min later to auto-stop + now = now + timedelta(minutes=5) + vtherm._auto_start_stop_algo._accumulated_error = 0 + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ) as mock_send_event: + vtherm._set_now(now) + await send_temperature_change_event(vtherm, 19, now, True) + await hass.async_block_till_done() + + # VTherm should not have been stopped cause enable is false + assert vtherm.hvac_mode == HVACMode.HEAT + + # Not calculated cause enable = false + assert vtherm._auto_start_stop_algo.accumulated_error == 0 + + # a message should have been sent + assert mock_send_event.call_count == 0 + + +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_auto_start_stop_fast_heat_window( + hass: HomeAssistant, skip_hass_states_is_state +): + """Test than auto-start/stop works with a real over_climate VTherm in FAST level and check + interaction with window openess detection""" + + # 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: True, + CONF_WINDOW_SENSOR: "binary_sensor.window_sensor", + CONF_WINDOW_DELAY: 10, + CONF_USE_MOTION_FEATURE: False, + CONF_USE_POWER_FEATURE: False, + CONF_USE_AUTO_START_STOP_FEATURE: True, + 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, + CONF_AUTO_START_STOP_LEVEL: AUTO_START_STOP_LEVEL_FAST, + }, + ) + + fake_underlying_climate = MockClimate( + hass=hass, + unique_id="mock_climate", + name="mock_climate", + hvac_modes=[HVACMode.OFF, HVACMode.COOL, HVACMode.HEAT], + ) + + 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 + + # Initialize all temps + await set_all_climate_preset_temp(hass, vtherm, temps, "overclimate") + + # Check correct initialization of auto_start_stop attributes + assert ( + vtherm._attr_extra_state_attributes["auto_start_stop_level"] + == AUTO_START_STOP_LEVEL_FAST + ) + + assert vtherm._attr_extra_state_attributes["auto_start_stop_dtmin"] == 7 + + # 1. Vtherm auto-start/stop should be in MEDIUM mode and an enable entity should exists + assert vtherm.auto_start_stop_level == AUTO_START_STOP_LEVEL_FAST + enable_entity = search_entity( + hass, "switch.overclimate_enable_auto_start_stop", SWITCH_DOMAIN + ) + assert enable_entity is not None + assert enable_entity.state == STATE_ON + + tz = get_tz(hass) # pylint: disable=invalid-name + now: datetime = datetime.now(tz=tz) + + # 2. Set mode to Heat and preset to Comfort and close the window + send_window_change_event(vtherm, False, False, now, False) + await send_presence_change_event(vtherm, True, False, now) + await send_temperature_change_event(vtherm, 18, now, True) + 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 + # VTherm should be heating + assert vtherm.hvac_mode == HVACMode.HEAT + # VTherm window_state should be off + assert vtherm.window_state == STATE_OFF + + # 3. Set current temperature to 21 5 min later -> should turn off VTherm + now = now + timedelta(minutes=5) + # reset accumulated error (only for testing) + vtherm._auto_start_stop_algo._accumulated_error = 0 + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ) as mock_send_event: + vtherm._set_now(now) + await send_temperature_change_event(vtherm, 21, now, True) + await hass.async_block_till_done() + + # VTherm should no more be heating + assert vtherm.hvac_mode == HVACMode.OFF + assert vtherm.hvac_off_reason == HVAC_OFF_REASON_AUTO_START_STOP + assert vtherm._saved_hvac_mode == HVACMode.HEAT + assert mock_send_event.call_count == 2 + + # 4. Open the window and wait for the delay + now = now + timedelta(minutes=2) + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ) as mock_send_event, patch( + "homeassistant.helpers.condition.state", return_value=True + ) as mock_condition: + vtherm._set_now(now) + try_function = await send_window_change_event( + vtherm, True, False, now, sleep=False + ) + + await try_function(None) + + # Nothing should have change (window event is ignoed as we are already OFF) + assert vtherm.hvac_mode == HVACMode.OFF + assert vtherm.hvac_off_reason == HVAC_OFF_REASON_AUTO_START_STOP + assert vtherm._saved_hvac_mode == HVACMode.HEAT + + mock_send_event.assert_not_called() + + assert vtherm.window_state == STATE_ON + + # 5. close the window + now = now + timedelta(minutes=2) + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ) as mock_send_event, patch( + "homeassistant.helpers.condition.state", return_value=True + ) as mock_condition: + vtherm._set_now(now) + try_function = await send_window_change_event( + vtherm, False, True, now, sleep=False + ) + + await try_function(None) + + # The VTherm should stay off because the reason of off is auto-start-stop + assert vtherm.hvac_mode == HVACMode.OFF + assert vtherm.hvac_off_reason == HVAC_OFF_REASON_AUTO_START_STOP + assert vtherm._saved_hvac_mode == HVACMode.HEAT + + assert mock_send_event.call_count == 0 + + assert vtherm.window_state == STATE_OFF + + +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_auto_start_stop_fast_heat_window_mixed( + hass: HomeAssistant, skip_hass_states_is_state +): + """Test than auto-start/stop works with a real over_climate VTherm in FAST level and check + interaction with window openess detection + The case is when first window on, then auto-stop, then window off and then auto-start + """ + + # 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: True, + CONF_WINDOW_SENSOR: "binary_sensor.window_sensor", + CONF_WINDOW_DELAY: 10, + CONF_USE_MOTION_FEATURE: False, + CONF_USE_POWER_FEATURE: False, + CONF_USE_AUTO_START_STOP_FEATURE: True, + 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, + CONF_AUTO_START_STOP_LEVEL: AUTO_START_STOP_LEVEL_FAST, + }, + ) + + fake_underlying_climate = MockClimate( + hass=hass, + unique_id="mock_climate", + name="mock_climate", + hvac_modes=[HVACMode.OFF, HVACMode.COOL, HVACMode.HEAT], + ) + + 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 + + # Initialize all temps + await set_all_climate_preset_temp(hass, vtherm, temps, "overclimate") + + # Check correct initialization of auto_start_stop attributes + assert ( + vtherm._attr_extra_state_attributes["auto_start_stop_level"] + == AUTO_START_STOP_LEVEL_FAST + ) + + assert vtherm._attr_extra_state_attributes["auto_start_stop_dtmin"] == 7 + + # 1. Vtherm auto-start/stop should be in MEDIUM mode and an enable entity should exists + assert vtherm.auto_start_stop_level == AUTO_START_STOP_LEVEL_FAST + enable_entity = search_entity( + hass, "switch.overclimate_enable_auto_start_stop", SWITCH_DOMAIN + ) + assert enable_entity is not None + assert enable_entity.state == STATE_ON + + tz = get_tz(hass) # pylint: disable=invalid-name + now: datetime = datetime.now(tz=tz) + + # 2. Set mode to Heat and preset to Comfort and close the window + send_window_change_event(vtherm, False, False, now, False) + await send_presence_change_event(vtherm, True, False, now) + await send_temperature_change_event(vtherm, 18, now, True) + 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 + # VTherm should be heating + assert vtherm.hvac_mode == HVACMode.HEAT + # VTherm window_state should be off + assert vtherm.window_state == STATE_OFF + + # 3. Open the window and wait for the delay + now = now + timedelta(minutes=2) + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ) as mock_send_event, patch( + "homeassistant.helpers.condition.state", return_value=True + ) as mock_condition: + vtherm._set_now(now) + try_function = await send_window_change_event( + vtherm, True, False, now, sleep=False + ) + + await try_function(None) + + # Nothing should have change (window event is ignoed as we are already OFF) + assert vtherm.hvac_mode == HVACMode.OFF + assert vtherm.hvac_off_reason == HVAC_OFF_REASON_WINDOW_DETECTION + assert vtherm._saved_hvac_mode == HVACMode.HEAT + + assert mock_send_event.call_count == 2 + + assert vtherm.window_state == STATE_ON + + # 4. Set current temperature to 21 5 min later -> should turn off VTherm + now = now + timedelta(minutes=5) + # reset accumulated error (only for testing) + vtherm._auto_start_stop_algo._accumulated_error = 0 + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ) as mock_send_event: + vtherm._set_now(now) + await send_temperature_change_event(vtherm, 21, now, True) + await hass.async_block_till_done() + + # VTherm should no more be heating + assert vtherm.hvac_mode == HVACMode.OFF + assert vtherm.hvac_off_reason == HVAC_OFF_REASON_WINDOW_DETECTION # No change + assert vtherm._saved_hvac_mode == HVACMode.HEAT + assert mock_send_event.call_count == 0 # No message + + # 5. close the window + now = now + timedelta(minutes=2) + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ) as mock_send_event, patch( + "homeassistant.helpers.condition.state", return_value=True + ) as mock_condition: + vtherm._set_now(now) + try_function = await send_window_change_event( + vtherm, False, True, now, sleep=False + ) + + await try_function(None) + + # The VTherm should turn on and off again due to auto-start-stop + assert vtherm.hvac_mode == HVACMode.OFF + assert vtherm.hvac_off_reason is HVAC_OFF_REASON_AUTO_START_STOP + assert vtherm._saved_hvac_mode == HVACMode.HEAT + + assert vtherm.window_state == STATE_OFF + assert mock_send_event.call_count >= 2 + mock_send_event.assert_has_calls( + [ + call(EventType.HVAC_MODE_EVENT, {"hvac_mode": HVACMode.OFF}), + call( + event_type=EventType.AUTO_START_STOP_EVENT, + data={ + "type": "stop", + "name": "overClimate", + "cause": "Auto stop conditions reached", + "hvac_mode": HVACMode.OFF, + "saved_hvac_mode": HVACMode.HEAT, + "target_temperature": 19.0, + "current_temperature": 21.0, + "temperature_slope": 0.214, + }, + ), + ] + ) diff --git a/tests/test_central_mode.py b/tests/test_central_mode.py index 9b4cd09..21b4dce 100644 --- a/tests/test_central_mode.py +++ b/tests/test_central_mode.py @@ -982,7 +982,8 @@ async def test_switch_change_central_mode_true_with_cool_only_and_window( await select_entity.async_select_option(CENTRAL_MODE_COOL_ONLY) assert entity.last_central_mode is CENTRAL_MODE_COOL_ONLY - await entity.async_set_hvac_mode(HVACMode.OFF) + assert entity.hvac_mode is HVACMode.OFF + assert entity.hvac_off_reason == HVAC_OFF_REASON_MANUAL await entity.async_set_preset_mode(PRESET_ACTIVITY) assert entity._saved_hvac_mode == HVACMode.HEAT assert entity._saved_preset_mode == PRESET_ACTIVITY @@ -1000,12 +1001,14 @@ async def test_switch_change_central_mode_true_with_cool_only_and_window( await try_function(None) - assert mock_send_event.call_count == 1 - mock_send_event.assert_has_calls( - [call.send_event(EventType.HVAC_MODE_EVENT, {"hvac_mode": HVACMode.OFF})] - ) + # The VTherm is already off -> window detection is ignored + assert mock_send_event.call_count == 0 + # mock_send_event.assert_has_calls( + # [call.send_event(EventType.HVAC_MODE_EVENT, {"hvac_mode": HVACMode.OFF})] + # ) assert entity.hvac_mode == HVACMode.OFF + assert entity.hvac_off_reason == HVAC_OFF_REASON_MANUAL assert entity.preset_mode == PRESET_ACTIVITY assert entity._saved_hvac_mode == HVACMode.HEAT assert entity._saved_preset_mode == PRESET_ACTIVITY @@ -1021,6 +1024,8 @@ async def test_switch_change_central_mode_true_with_cool_only_and_window( assert entity.last_central_mode is CENTRAL_MODE_AUTO # No change assert entity.hvac_mode == HVACMode.OFF + # We have to a reason of WINDOW_DETECTION + assert entity.hvac_off_reason == HVAC_OFF_REASON_WINDOW_DETECTION assert entity.preset_mode == PRESET_ACTIVITY assert entity._saved_hvac_mode == HVACMode.HEAT assert entity._saved_preset_mode == PRESET_ACTIVITY @@ -1046,6 +1051,7 @@ async def test_switch_change_central_mode_true_with_cool_only_and_window( # We should stay off because central is STOPPED assert entity.hvac_mode == HVACMode.HEAT + assert entity.hvac_off_reason is None assert entity.preset_mode == PRESET_ACTIVITY assert entity._saved_hvac_mode == HVACMode.HEAT assert entity._saved_preset_mode == PRESET_ACTIVITY diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 1c0df19..7020d74 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -27,7 +27,7 @@ async def test_show_form(hass: HomeAssistant, init_vtherm_api) -> None: @pytest.mark.parametrize("expected_lingering_tasks", [True]) @pytest.mark.parametrize("expected_lingering_timers", [True]) # Disable this test which don't work anymore (kill the pytest !) -# @pytest.mark.skip +@pytest.mark.skip async def test_user_config_flow_over_switch( hass: HomeAssistant, skip_hass_states_get, init_central_config ): # pylint: disable=unused-argument @@ -280,6 +280,7 @@ async def test_user_config_flow_over_switch( CONF_USE_POWER_CENTRAL_CONFIG: True, CONF_USE_PRESENCE_CENTRAL_CONFIG: True, CONF_USE_ADVANCED_CENTRAL_CONFIG: True, + CONF_USE_AUTO_START_STOP_FEATURE: False, CONF_USE_CENTRAL_MODE: True, CONF_USED_BY_CENTRAL_BOILER: False, CONF_USE_WINDOW_FEATURE: True, @@ -299,11 +300,11 @@ async def test_user_config_flow_over_switch( @pytest.mark.parametrize("expected_lingering_tasks", [True]) @pytest.mark.parametrize("expected_lingering_timers", [True]) # TODO this test fails when run in // but works alone -@pytest.mark.skip +# @pytest.mark.skip async def test_user_config_flow_over_climate( hass: HomeAssistant, skip_hass_states_get ): # pylint: disable=unused-argument - """Test the config flow with all thermostat_over_switch features and never use central config. + """Test the config flow with all thermostat_over_climate features and never use central config. We don't use any features""" # await create_central_config(hass) @@ -499,6 +500,7 @@ async def test_user_config_flow_over_climate( CONF_USE_POWER_FEATURE: False, CONF_USE_PRESENCE_FEATURE: False, CONF_USE_WINDOW_FEATURE: False, + CONF_USE_AUTO_START_STOP_FEATURE: False, CONF_USE_CENTRAL_BOILER_FEATURE: False, CONF_USE_TPI_CENTRAL_CONFIG: False, CONF_USE_WINDOW_CENTRAL_CONFIG: False, @@ -877,3 +879,251 @@ async def test_user_config_flow_over_4_switches( assert result["result"].version == 1 assert result["result"].title == "TheOver4SwitchMockName" assert isinstance(result["result"], ConfigEntry) + + +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +# TODO this test fails when run in // but works alone +# @pytest.mark.skip +async def test_user_config_flow_over_climate_auto_start_stop( + hass: HomeAssistant, skip_hass_states_get +): # pylint: disable=unused-argument + """Test the config flow with auto_start_stop thermostat_over_climate features.""" + # await create_central_config(hass) + + # 1. start a config flow in over_climate + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == SOURCE_USER + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_CLIMATE, + }, + ) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "menu" + assert result["menu_options"] == [ + "main", + "features", + "type", + "presets", + "advanced", + "configuration_not_complete", + ] + assert result.get("errors") is None + + # 2. Add auto-start-stop feature + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "features"} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "features" + assert result.get("errors") == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USE_MOTION_FEATURE: False, + CONF_USE_POWER_FEATURE: False, + CONF_USE_PRESENCE_FEATURE: False, + CONF_USE_WINDOW_FEATURE: False, + CONF_USE_AUTO_START_STOP_FEATURE: True, + }, + ) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "menu" + assert result.get("errors") is None + assert result["menu_options"] == [ + "main", + "features", + "type", + "presets", + "auto_start_stop", + "advanced", + "configuration_not_complete", + # "finalize", finalize is not present waiting for advanced configuration + ] + + # 3. Configure auto-start-stop attributes + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "auto_start_stop"} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "auto_start_stop" + assert result.get("errors") == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_AUTO_START_STOP_LEVEL: AUTO_START_STOP_LEVEL_MEDIUM, + }, + ) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "menu" + assert result.get("errors") is None + + # 4. Configure main attributes + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "main"} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "main" + assert result.get("errors") == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_NAME: "TheOverClimateMockName", + CONF_TEMP_SENSOR: "sensor.mock_temp_sensor", + CONF_CYCLE_MIN: 5, + CONF_DEVICE_POWER: 1, + CONF_USE_MAIN_CENTRAL_CONFIG: False, + CONF_USE_CENTRAL_MODE: True, + # Keep default values which are False + }, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "main" + assert result.get("errors") == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor", + CONF_TEMP_MIN: 15, + CONF_TEMP_MAX: 30, + CONF_STEP_TEMPERATURE: 0.1, + # Keep default values which are False + }, + ) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "menu" + assert result.get("errors") is None + + # 5. Configure type attributes + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "type"} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "type" + assert result.get("errors") == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_CLIMATE: "climate.mock_climate", + CONF_AC_MODE: False, + CONF_AUTO_REGULATION_MODE: CONF_AUTO_REGULATION_STRONG, + CONF_AUTO_REGULATION_DTEMP: 0.5, + CONF_AUTO_REGULATION_PERIOD_MIN: 2, + CONF_AUTO_FAN_MODE: CONF_AUTO_FAN_HIGH, + CONF_AUTO_REGULATION_USE_DEVICE_TEMP: False, + }, + ) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "menu" + assert result["menu_options"] == [ + "main", + "features", + "type", + "presets", + "auto_start_stop", + "advanced", + "configuration_not_complete", + # "finalize", # because we need Advanced default parameters + ] + assert result.get("errors") is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "presets"} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "presets" + assert result.get("errors") == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_USE_PRESETS_CENTRAL_CONFIG: False} + ) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "menu" + assert result.get("errors") is None + + # 6. configure advanced attributes + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "advanced"} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "advanced" + assert result.get("errors") == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_USE_ADVANCED_CENTRAL_CONFIG: False}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "advanced" + assert result.get("errors") == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_MINIMAL_ACTIVATION_DELAY: 10, + CONF_SECURITY_DELAY_MIN: 5, + CONF_SECURITY_MIN_ON_PERCENT: 0.4, + CONF_SECURITY_DEFAULT_ON_PERCENT: 0.3, + }, + ) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "menu" + assert result.get("errors") is None + assert result["menu_options"] == [ + "main", + "features", + "type", + "presets", + "auto_start_stop", + "advanced", + "finalize", # Now finalize is present + ] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "finalize"} + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result.get("errors") is None + assert result[ + "data" + ] == MOCK_TH_OVER_CLIMATE_USER_CONFIG | MOCK_TH_OVER_CLIMATE_MAIN_CONFIG | MOCK_TH_OVER_CLIMATE_CENTRAL_MAIN_CONFIG | MOCK_TH_OVER_CLIMATE_TYPE_CONFIG | { + CONF_MINIMAL_ACTIVATION_DELAY: 10, + CONF_SECURITY_DELAY_MIN: 5, + CONF_SECURITY_MIN_ON_PERCENT: 0.4, + CONF_SECURITY_DEFAULT_ON_PERCENT: 0.3, + } | MOCK_DEFAULT_FEATURE_CONFIG | { + CONF_USE_MAIN_CENTRAL_CONFIG: False, + CONF_USE_TPI_CENTRAL_CONFIG: False, + CONF_USE_PRESETS_CENTRAL_CONFIG: False, + CONF_USE_MOTION_FEATURE: False, + CONF_USE_POWER_FEATURE: False, + CONF_USE_PRESENCE_FEATURE: False, + CONF_USE_WINDOW_FEATURE: False, + CONF_USE_AUTO_START_STOP_FEATURE: False, + CONF_USE_CENTRAL_BOILER_FEATURE: False, + CONF_USE_TPI_CENTRAL_CONFIG: False, + CONF_USE_WINDOW_CENTRAL_CONFIG: False, + CONF_USE_MOTION_CENTRAL_CONFIG: False, + CONF_USE_POWER_CENTRAL_CONFIG: False, + CONF_USE_PRESENCE_CENTRAL_CONFIG: False, + CONF_USE_ADVANCED_CENTRAL_CONFIG: False, + CONF_USED_BY_CENTRAL_BOILER: False, + CONF_USE_AUTO_START_STOP_FEATURE: True, + CONF_AUTO_START_STOP_LEVEL: AUTO_START_STOP_LEVEL_MEDIUM, + } + assert result["result"] + assert result["result"].domain == DOMAIN + assert result["result"].version == 1 + assert result["result"].title == "TheOverClimateMockName" + assert isinstance(result["result"], ConfigEntry) diff --git a/tests/test_window.py b/tests/test_window.py index 8638eeb..d3ac4ed 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -205,6 +205,8 @@ async def test_window_management_time_enough( assert mock_heater_off.call_count == 2 assert mock_condition.call_count == 1 assert entity.hvac_mode is HVACMode.OFF + assert entity._saved_hvac_mode is HVACMode.HEAT + assert entity.hvac_off_reason == HVAC_OFF_REASON_WINDOW_DETECTION assert entity.window_state == STATE_ON # Close the window @@ -242,6 +244,9 @@ async def test_window_management_time_enough( any_order=False, ) assert entity.preset_mode is PRESET_BOOST + assert entity.hvac_mode is HVACMode.HEAT + assert entity._saved_hvac_mode is HVACMode.HEAT # No change + assert entity.hvac_off_reason == None # Clean the entity entity.remove_thermostat() @@ -1339,6 +1344,7 @@ async def test_window_action_fan_only(hass: HomeAssistant, skip_hass_states_is_s # The underlying should be in FAN_ONLY hvac_mode assert entity.hvac_mode is HVACMode.FAN_ONLY assert entity._saved_hvac_mode is HVACMode.HEAT + assert entity.hvac_off_reason is None # Hvac is not off assert entity.preset_mode is PRESET_COMFORT # 3. Close the window @@ -1357,7 +1363,7 @@ async def test_window_action_fan_only(hass: HomeAssistant, skip_hass_states_is_s await try_function(None) # Wait for initial delay of heater - await asyncio.sleep(0.3) + await hass.async_block_till_done() assert entity.window_state == STATE_OFF assert mock_send_event.call_count == 1 @@ -1379,6 +1385,7 @@ async def test_window_action_fan_only(hass: HomeAssistant, skip_hass_states_is_s ) assert entity.hvac_mode is HVACMode.HEAT assert entity.preset_mode is PRESET_COMFORT + assert entity.hvac_off_reason is None # Clean the entity entity.remove_thermostat()