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..d6700f6 --- /dev/null +++ b/custom_components/versatile_thermostat/auto_start_stop_algorithm.py @@ -0,0 +1,153 @@ +# 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 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, + CONF_AUTO_START_STOP_LEVELS, +) + + +_LOGGER = logging.getLogger(__name__) + +# attribute name should be equal to AUTO_START_STOP_LEVEL_xxx constants (in const.yaml) +DTEMP = { + AUTO_START_STOP_LEVEL_NONE: 99, + AUTO_START_STOP_LEVEL_SLOW: 3, + AUTO_START_STOP_LEVEL_MEDIUM: 2, + AUTO_START_STOP_LEVEL_FAST: 1, +} +DT_MIN = { + AUTO_START_STOP_LEVEL_NONE: 99, + AUTO_START_STOP_LEVEL_SLOW: 30, + AUTO_START_STOP_LEVEL_MEDIUM: 15, + AUTO_START_STOP_LEVEL_FAST: 7, +} + +AUTO_START_STOP_ACTION_OFF = "turnOff" +AUTO_START_STOP_ACTION_ON = "turnOn" +AUTO_START_STOP_ACTION_NOTHING = "nothing" +AUTO_START_STOP_ACTIONS = [ + 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 + _dtemp: float + _level: str + + def __init__(self, level: CONF_AUTO_START_STOP_LEVELS, vtherm_name) -> None: + """Initalize a new algorithm with the right constants""" + self._level = level + self._dt = DT_MIN[level] + self._dtemp = DTEMP[level] + self._vtherm_name = vtherm_name + + def calculate_action( + self, + hvac_mode: HVACMode | None, + saved_hvac_mode: HVACMode | None, + regulated_temp: float, + target_temp: float, + current_temp: float, + slope_min: float, + ) -> 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 + + if ( + hvac_mode is None + or regulated_temp is None + or target_temp is None + or current_temp is None + or slope_min is None + ): + _LOGGER.debug( + "%s - No all mandatory parameters are set. Disable auto-start/stop", + self, + ) + return AUTO_START_STOP_ACTION_NOTHING + + _LOGGER.debug( + "%s - calculate_action: hvac_mode=%s, saved_hvac_mode=%s, regulated_temp=%s, target_temp=%s, current_temp=%s, slope_min=%s", + self, + hvac_mode, + saved_hvac_mode, + regulated_temp, + target_temp, + current_temp, + slope_min, + ) + + if hvac_mode == HVACMode.HEAT: + if regulated_temp + self._dtemp <= target_temp and slope_min >= 0: + _LOGGER.info( + "%s - We need to stop, there is no need for heating for a long time.", + ) + return AUTO_START_STOP_ACTION_OFF + else: + _LOGGER.debug( + "%s - nothing to do, we are heating", + ) + return AUTO_START_STOP_ACTION_NOTHING + + if hvac_mode == HVACMode.COOL: + if regulated_temp - self._dtemp >= target_temp and slope_min <= 0: + _LOGGER.info( + "%s - We need to stop, there is no need for cooling for a long time.", + ) + return AUTO_START_STOP_ACTION_OFF + else: + _LOGGER.debug( + "%s - nothing to do, we are cooling", + ) + return AUTO_START_STOP_ACTION_NOTHING + + if hvac_mode == HVACMode.OFF and saved_hvac_mode == HVACMode.HEAT: + if current_temp + slope_min * self._dt <= target_temp: + _LOGGER.info( + "%s - We need to start, because it will be time to heat", + ) + return AUTO_START_STOP_ACTION_ON + else: + _LOGGER.debug( + "%s - nothing to do, we don't need to heat soon", + ) + return AUTO_START_STOP_ACTION_NOTHING + + if hvac_mode == HVACMode.OFF and saved_hvac_mode == HVACMode.COOL: + if current_temp + slope_min * self._dt >= target_temp: + _LOGGER.info( + "%s - We need to start, because it will be time to cool", + ) + return AUTO_START_STOP_ACTION_ON + else: + _LOGGER.debug( + "%s - nothing to do, we don't need to cool soon", + ) + return AUTO_START_STOP_ACTION_NOTHING + + _LOGGER.debug( + "%s - nothing to do, no conditions applied", + ) + return AUTO_START_STOP_ACTION_NOTHING + + def __str__(self) -> str: + return f"AutoStartStopDetectionAlgorithm-{self._vtherm_name}" diff --git a/custom_components/versatile_thermostat/config_flow.py b/custom_components/versatile_thermostat/config_flow.py index ead7339..60664a8 100644 --- a/custom_components/versatile_thermostat/config_flow.py +++ b/custom_components/versatile_thermostat/config_flow.py @@ -413,6 +413,11 @@ class VersatileThermostatBaseConfigFlow(FlowHandler): ]: menu_options.append("presets") + if self._infos[CONF_THERMOSTAT_TYPE] in [ + CONF_THERMOSTAT_CLIMATE, + ]: + menu_options.append("auto_start_stop") + if ( is_central_config and self._infos.get(CONF_USE_CENTRAL_BOILER_FEATURE) is True @@ -520,17 +525,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) 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..f9d9784 100644 --- a/custom_components/versatile_thermostat/const.py +++ b/custom_components/versatile_thermostat/const.py @@ -97,6 +97,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 +146,18 @@ 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 = "none" +AUTO_START_STOP_LEVEL_SLOW = "slow" +AUTO_START_STOP_LEVEL_MEDIUM = "medium" +AUTO_START_STOP_LEVEL_FAST = "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, +] + DEFAULT_SHORT_EMA_PARAMS = { "max_alpha": 0.5, # In sec diff --git a/custom_components/versatile_thermostat/strings.json b/custom_components/versatile_thermostat/strings.json index 7692c9e..3d015b2 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": { + "none": "No auto start/stop", + "slow": "Slow detection", + "medium": "Medium detection", + "fast": "Fast detection" + } } }, "entity": { diff --git a/custom_components/versatile_thermostat/translations/en.json b/custom_components/versatile_thermostat/translations/en.json index 7692c9e..3d015b2 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": { + "none": "No auto start/stop", + "slow": "Slow detection", + "medium": "Medium detection", + "fast": "Fast detection" + } } }, "entity": { diff --git a/custom_components/versatile_thermostat/translations/fr.json b/custom_components/versatile_thermostat/translations/fr.json index c891cc8..dae2483 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": { + "none": "No auto start/stop", + "slow": "Slow detection", + "medium": "Medium detection", + "fast": "Fast detection" + } } }, "entity": { diff --git a/tests/test_auto_start_stop.py b/tests/test_auto_start_stop.py new file mode 100644 index 0000000..d5453a6 --- /dev/null +++ b/tests/test_auto_start_stop.py @@ -0,0 +1,583 @@ +# 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 + +from homeassistant.components.climate import HVACMode + +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, + AUTO_START_STOP_ACTION_ON, +) +from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import + +logging.getLogger().setLevel(logging.DEBUG) + + +async def test_auto_start_stop_algo_slow(hass: HomeAssistant): + """Testing directly the algorithm in Slow level""" + algo: AutoStartStopDetectionAlgorithm = AutoStartStopDetectionAlgorithm( + AUTO_START_STOP_LEVEL_SLOW, "testu" + ) + + assert algo._dtemp == 3 + assert algo._dt == 30 + assert algo._vtherm_name == "testu" + + # 1. In heating we should stop + ret = algo.calculate_action( + hvac_mode=HVACMode.HEAT, + saved_hvac_mode=HVACMode.OFF, + regulated_temp=18, + target_temp=21, + current_temp=22, + slope_min=0.1, + ) + assert ret == AUTO_START_STOP_ACTION_OFF + + # 2. In heating we should do nothing + ret = algo.calculate_action( + hvac_mode=HVACMode.HEAT, + saved_hvac_mode=HVACMode.OFF, + regulated_temp=20, + target_temp=21, + current_temp=21, + slope_min=0.0, + ) + assert ret == AUTO_START_STOP_ACTION_NOTHING + + # 3. In Cooling we should stop + ret = algo.calculate_action( + hvac_mode=HVACMode.COOL, + saved_hvac_mode=HVACMode.OFF, + regulated_temp=24, + target_temp=21, + current_temp=22, + slope_min=-0.1, + ) + assert ret == AUTO_START_STOP_ACTION_OFF + + # 4. In Colling we should do nothing + ret = algo.calculate_action( + hvac_mode=HVACMode.COOL, + saved_hvac_mode=HVACMode.OFF, + regulated_temp=22, + target_temp=21, + current_temp=22, + slope_min=0.0, + ) + assert ret == AUTO_START_STOP_ACTION_NOTHING + + # 5. In Off, we should start heating + ret = algo.calculate_action( + hvac_mode=HVACMode.OFF, + saved_hvac_mode=HVACMode.HEAT, + regulated_temp=22, + target_temp=21, + current_temp=22, + slope_min=-0.1, + ) + assert ret == AUTO_START_STOP_ACTION_ON + + # 6. In Off we should not heat + ret = algo.calculate_action( + hvac_mode=HVACMode.OFF, + saved_hvac_mode=HVACMode.HEAT, + regulated_temp=23, + target_temp=21, + current_temp=24, + slope_min=0.5, + ) + assert ret == AUTO_START_STOP_ACTION_NOTHING + + # 7. In Off we still should not heat (slope too low) + ret = algo.calculate_action( + hvac_mode=HVACMode.OFF, + saved_hvac_mode=HVACMode.HEAT, + regulated_temp=22, + target_temp=21, + current_temp=22, + slope_min=-0.01, + ) + assert ret == AUTO_START_STOP_ACTION_NOTHING + + # 8. In Off, we should start cooling + ret = algo.calculate_action( + hvac_mode=HVACMode.OFF, + saved_hvac_mode=HVACMode.COOL, + regulated_temp=25, + target_temp=24, + current_temp=25, + slope_min=0.1, + ) + assert ret == AUTO_START_STOP_ACTION_ON + + # 9. In Off we should not cool + ret = algo.calculate_action( + hvac_mode=HVACMode.OFF, + saved_hvac_mode=HVACMode.COOL, + regulated_temp=20, + target_temp=24, + current_temp=21, + slope_min=0.01, + ) + assert ret == AUTO_START_STOP_ACTION_NOTHING + # 9.1 In Off and slow we should cool + ret = algo.calculate_action( + hvac_mode=HVACMode.OFF, + saved_hvac_mode=HVACMode.COOL, + regulated_temp=20, + target_temp=24, + current_temp=21, + slope_min=0.1, + ) + assert ret == AUTO_START_STOP_ACTION_ON + + # 10. In Off we still should not cool (slope too low) + ret = algo.calculate_action( + hvac_mode=HVACMode.OFF, + saved_hvac_mode=HVACMode.COOL, + regulated_temp=25, + target_temp=24, + current_temp=23, + slope_min=0.01, + ) + assert ret == AUTO_START_STOP_ACTION_NOTHING + + +async def test_auto_start_stop_algo_medium(hass: HomeAssistant): + """Testing directly the algorithm in Slow level""" + algo: AutoStartStopDetectionAlgorithm = AutoStartStopDetectionAlgorithm( + AUTO_START_STOP_LEVEL_MEDIUM, "testu" + ) + + assert algo._dtemp == 2 + assert algo._dt == 15 + assert algo._vtherm_name == "testu" + + # 1. In heating we should stop + ret = algo.calculate_action( + hvac_mode=HVACMode.HEAT, + saved_hvac_mode=HVACMode.OFF, + regulated_temp=18, + target_temp=21, + current_temp=22, + slope_min=0.1, + ) + assert ret == AUTO_START_STOP_ACTION_OFF + + # 2. In heating we should do nothing + ret = algo.calculate_action( + hvac_mode=HVACMode.HEAT, + saved_hvac_mode=HVACMode.OFF, + regulated_temp=20, + target_temp=21, + current_temp=21, + slope_min=0.0, + ) + assert ret == AUTO_START_STOP_ACTION_NOTHING + + # 3. In Cooling we should stop + ret = algo.calculate_action( + hvac_mode=HVACMode.COOL, + saved_hvac_mode=HVACMode.OFF, + regulated_temp=24, + target_temp=21, + current_temp=22, + slope_min=-0.1, + ) + assert ret == AUTO_START_STOP_ACTION_OFF + + # 4. In Colling we should do nothing + ret = algo.calculate_action( + hvac_mode=HVACMode.COOL, + saved_hvac_mode=HVACMode.OFF, + regulated_temp=22, + target_temp=21, + current_temp=22, + slope_min=0.0, + ) + assert ret == AUTO_START_STOP_ACTION_NOTHING + + # 5. In Off, we should start heating + ret = algo.calculate_action( + hvac_mode=HVACMode.OFF, + saved_hvac_mode=HVACMode.HEAT, + regulated_temp=22, + target_temp=21, + current_temp=22, + slope_min=-0.1, + ) + assert ret == AUTO_START_STOP_ACTION_ON + + # 6. In Off we should not heat + ret = algo.calculate_action( + hvac_mode=HVACMode.OFF, + saved_hvac_mode=HVACMode.HEAT, + regulated_temp=23, + target_temp=21, + current_temp=24, + slope_min=0.5, + ) + assert ret == AUTO_START_STOP_ACTION_NOTHING + + # 7. In Off we still should not heat (slope too low) + ret = algo.calculate_action( + hvac_mode=HVACMode.OFF, + saved_hvac_mode=HVACMode.HEAT, + regulated_temp=22, + target_temp=21, + current_temp=22, + slope_min=-0.01, + ) + assert ret == AUTO_START_STOP_ACTION_NOTHING + + # 8. In Off, we should start cooling + ret = algo.calculate_action( + hvac_mode=HVACMode.OFF, + saved_hvac_mode=HVACMode.COOL, + regulated_temp=25, + target_temp=24, + current_temp=25, + slope_min=0.1, + ) + assert ret == AUTO_START_STOP_ACTION_ON + + # 9. In Off we should not cool + ret = algo.calculate_action( + hvac_mode=HVACMode.OFF, + saved_hvac_mode=HVACMode.COOL, + regulated_temp=20, + target_temp=24, + current_temp=21, + slope_min=0.1, + ) + assert ret == AUTO_START_STOP_ACTION_NOTHING + + # 10. In Off we still should not cool (slope too low) + ret = algo.calculate_action( + hvac_mode=HVACMode.OFF, + saved_hvac_mode=HVACMode.COOL, + regulated_temp=25, + target_temp=24, + current_temp=23, + slope_min=0.01, + ) + assert ret == AUTO_START_STOP_ACTION_NOTHING + + +async def test_auto_start_stop_algo_high(hass: HomeAssistant): + """Testing directly the algorithm in Slow level""" + algo: AutoStartStopDetectionAlgorithm = AutoStartStopDetectionAlgorithm( + AUTO_START_STOP_LEVEL_FAST, "testu" + ) + + assert algo._dtemp == 1 + assert algo._dt == 7 + assert algo._vtherm_name == "testu" + + # 1. In heating we should stop + ret = algo.calculate_action( + hvac_mode=HVACMode.HEAT, + saved_hvac_mode=HVACMode.OFF, + regulated_temp=18, + target_temp=21, + current_temp=22, + slope_min=0.1, + ) + assert ret == AUTO_START_STOP_ACTION_OFF + + # 2. In heating and fast we should turn off + ret = algo.calculate_action( + hvac_mode=HVACMode.HEAT, + saved_hvac_mode=HVACMode.OFF, + regulated_temp=20, + target_temp=21, + current_temp=21, + slope_min=0.0, + ) + assert ret == AUTO_START_STOP_ACTION_OFF + + # 3. In Cooling we should stop + ret = algo.calculate_action( + hvac_mode=HVACMode.COOL, + saved_hvac_mode=HVACMode.OFF, + regulated_temp=24, + target_temp=21, + current_temp=22, + slope_min=-0.1, + ) + assert ret == AUTO_START_STOP_ACTION_OFF + + # 4. In Cooling and fast we should turn off + ret = algo.calculate_action( + hvac_mode=HVACMode.COOL, + saved_hvac_mode=HVACMode.OFF, + regulated_temp=22, + target_temp=21, + current_temp=22, + slope_min=0.0, + ) + assert ret == AUTO_START_STOP_ACTION_OFF + + # 5. In Off and fast , we should do nothing + ret = algo.calculate_action( + hvac_mode=HVACMode.OFF, + saved_hvac_mode=HVACMode.HEAT, + regulated_temp=22, + target_temp=21, + current_temp=22, + slope_min=-0.1, + ) + assert ret == AUTO_START_STOP_ACTION_NOTHING + + # 6. In Off we should not heat + ret = algo.calculate_action( + hvac_mode=HVACMode.OFF, + saved_hvac_mode=HVACMode.HEAT, + regulated_temp=23, + target_temp=21, + current_temp=24, + slope_min=0.5, + ) + assert ret == AUTO_START_STOP_ACTION_NOTHING + + # 7. In Off we still should not heat (slope too low) + ret = algo.calculate_action( + hvac_mode=HVACMode.OFF, + saved_hvac_mode=HVACMode.HEAT, + regulated_temp=22, + target_temp=21, + current_temp=22, + slope_min=-0.01, + ) + assert ret == AUTO_START_STOP_ACTION_NOTHING + + # 8. In Off, we should start cooling + ret = algo.calculate_action( + hvac_mode=HVACMode.OFF, + saved_hvac_mode=HVACMode.COOL, + regulated_temp=25, + target_temp=24, + current_temp=25, + slope_min=0.1, + ) + assert ret == AUTO_START_STOP_ACTION_ON + + # 9. In Off we should not cool + ret = algo.calculate_action( + hvac_mode=HVACMode.OFF, + saved_hvac_mode=HVACMode.COOL, + regulated_temp=20, + target_temp=24, + current_temp=21, + slope_min=0.1, + ) + assert ret == AUTO_START_STOP_ACTION_NOTHING + + # 10. In Off we still should not cool (slope too low) + ret = algo.calculate_action( + hvac_mode=HVACMode.OFF, + saved_hvac_mode=HVACMode.COOL, + regulated_temp=25, + target_temp=24, + current_temp=23, + slope_min=0.01, + ) + 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_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") + + # 1. Vtherm auto-start/stop should be in MEDIUM mode + assert vtherm.auto_start_stop_level == AUTO_START_STOP_LEVEL_NONE + + +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_auto_start_stop_medium_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_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") + + # 1. Vtherm auto-start/stop should be in MEDIUM mode + assert vtherm.auto_start_stop_level == AUTO_START_STOP_LEVEL_MEDIUM + + # 1. Set mode to Heat and preset to Comfort + await send_presence_change_event(vtherm, True, False, datetime.now()) + await vtherm.async_set_hvac_mode(HVACMode.HEAT) + await vtherm.async_set_preset_mode(PRESET_COMFORT) + await hass.async_block_till_done() + + assert vtherm.target_temperature == 19.0 + + # 2. Only change the HVAC_MODE (and keep preset to comfort) + await vtherm.async_set_hvac_mode(HVACMode.COOL) + await hass.async_block_till_done() + assert vtherm.target_temperature == 25.0 + + # 3. Only change the HVAC_MODE (and keep preset to comfort) + await vtherm.async_set_hvac_mode(HVACMode.HEAT) + await hass.async_block_till_done() + assert vtherm.target_temperature == 19.0 + + # 4. Change presence to off + await send_presence_change_event(vtherm, False, True, datetime.now()) + await hass.async_block_till_done() + assert vtherm.target_temperature == 19.1 + + # 5. Change hvac_mode to AC + await vtherm.async_set_hvac_mode(HVACMode.COOL) + await hass.async_block_till_done() + assert vtherm.target_temperature == 25.1 + + # 6. Change presence to on + await send_presence_change_event(vtherm, True, False, datetime.now()) + await hass.async_block_till_done() + assert vtherm.target_temperature == 25