diff --git a/custom_components/versatile_thermostat/base_thermostat.py b/custom_components/versatile_thermostat/base_thermostat.py index 6d79628..4973707 100644 --- a/custom_components/versatile_thermostat/base_thermostat.py +++ b/custom_components/versatile_thermostat/base_thermostat.py @@ -197,6 +197,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity): "window_auto_open_threshold", "window_auto_close_threshold", "window_auto_max_duration", + "window_action", "motion_sensor_entity_id", "presence_sensor_entity_id", "power_sensor_entity_id", @@ -268,8 +269,8 @@ class BaseThermostat(ClimateEntity, RestoreEntity): self._window_auto_state = False self._window_auto_on = False self._window_auto_algo = None - # PR - Adding Window ByPass self._window_bypass_state = False + self._window_action = None self._current_tz = dt_util.get_time_zone(self._hass.config.time_zone) @@ -286,6 +287,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity): self._is_central_mode = None self._last_central_mode = None self._is_used_by_central_boiler = False + self.post_init(entry_infos) def clean_central_config_doublon(self, config_entry, central_config) -> dict: @@ -576,6 +578,10 @@ class BaseThermostat(ClimateEntity, RestoreEntity): entry_infos.get(CONF_USED_BY_CENTRAL_BOILER) is True ) + self._window_action = ( + entry_infos.get(CONF_WINDOW_ACTION) or CONF_WINDOW_TURN_OFF + ) + _LOGGER.debug( "%s - Creation of a new VersatileThermostat entity: unique_id=%s", self, @@ -1092,6 +1098,11 @@ class BaseThermostat(ClimateEntity, RestoreEntity): """Get the Window Bypass""" return self._window_bypass_state + @property + def window_action(self) -> bool | None: + """Get the Window Action""" + return self._window_action + @property def security_state(self) -> bool | None: """Get the security_state""" @@ -1481,29 +1492,14 @@ class BaseThermostat(ClimateEntity, RestoreEntity): self._window_state = new_state.state == STATE_ON - # PR - Adding Window ByPass _LOGGER.debug("%s - Window ByPass is : %s", self, self._window_bypass_state) if self._window_bypass_state: _LOGGER.info( "%s - Window ByPass is activated. Ignore window event", self ) else: - if not self._window_state: - _LOGGER.info( - "%s - Window is closed. Restoring hvac_mode '%s' if central_mode is not STOPPED", - self, - self._saved_hvac_mode, - ) - if self.last_central_mode != CENTRAL_MODE_STOPPED: - await self.restore_hvac_mode(True) - elif self._window_state: - _LOGGER.info( - "%s - Window is open. Set hvac_mode to '%s'", self, HVACMode.OFF - ) - if self.last_central_mode in [CENTRAL_MODE_AUTO, None]: - self.save_hvac_mode() + await self.change_window_detection_state(self._window_state) - await self.async_set_hvac_mode(HVACMode.OFF) self.update_custom_attributes() if new_state is None or old_state is None or new_state.state == old_state.state: @@ -1841,7 +1837,8 @@ class BaseThermostat(ClimateEntity, RestoreEntity): ) # Set attributes self._window_auto_state = False - await self.restore_hvac_mode(True) + await self.change_window_detection_state(self._window_auto_state) + # await self.restore_hvac_mode(True) if self._window_call_cancel: self._window_call_cancel() @@ -1901,8 +1898,9 @@ class BaseThermostat(ClimateEntity, RestoreEntity): ) # Set attributes self._window_auto_state = True - self.save_hvac_mode() - await self.async_set_hvac_mode(HVACMode.OFF) + await self.change_window_detection_state(self._window_auto_state) + # self.save_hvac_mode() + # await self.async_set_hvac_mode(HVACMode.OFF) # Arm the end trigger if self._window_call_cancel: @@ -2134,12 +2132,14 @@ class BaseThermostat(ClimateEntity, RestoreEntity): mode_cond = self._hvac_mode != HVACMode.OFF - api:VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api() - is_outdoor_checked = not api.safety_mode or api.safety_mode.get('check_outdoor_sensor') != False + api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api() + is_outdoor_checked = ( + not api.safety_mode + or api.safety_mode.get("check_outdoor_sensor") is not False + ) - temp_cond: bool = ( - delta_temp > self._security_delay_min - or (is_outdoor_checked and delta_ext_temp > self._security_delay_min) + temp_cond: bool = delta_temp > self._security_delay_min or ( + is_outdoor_checked and delta_ext_temp > self._security_delay_min ) climate_cond: bool = self.is_over_climate and self.hvac_action not in [ HVACAction.COOLING, @@ -2283,6 +2283,63 @@ class BaseThermostat(ClimateEntity, RestoreEntity): should have found the underlying climate to be operational""" return True + async def change_window_detection_state(self, new_state): + """Change the window detection state. + new_state is on if an open window have been detected or off else + """ + if not new_state: + _LOGGER.info( + "%s - Window is closed. Restoring hvac_mode '%s' if central_mode is not STOPPED", + self, + self._saved_hvac_mode, + ) + 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]: + if self.last_central_mode != CENTRAL_MODE_STOPPED: + await self.restore_hvac_mode(True) + else: + _LOGGER.error( + "%s - undefined window_action %s. Please open a bug in the github of this project with this log", + self, + self._window_action, + ) + else: + _LOGGER.info( + "%s - Window is open. Set hvac_mode to '%s'", self, HVACMode.OFF + ) + 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() + elif self._window_action in [ + CONF_WINDOW_FROST_TEMP, + CONF_WINDOW_ECO_TEMP, + ]: + self._saved_target_temp = self._target_temp + + if ( + self._window_action == CONF_WINDOW_FAN_ONLY + and HVACMode.FAN_ONLY in self.hvac_modes + ): + await self.async_set_hvac_mode(HVACMode.FAN_ONLY) + elif ( + self._window_action == CONF_WINDOW_FROST_TEMP + and self._presets.get(PRESET_FROST_PROTECTION) is not None + ): + await self._async_internal_set_temperature( + self.find_preset_temp(PRESET_FROST_PROTECTION) + ) + elif ( + self._window_action == CONF_WINDOW_ECO_TEMP + and self._presets.get(PRESET_ECO) is not None + ): + await self._async_internal_set_temperature( + self.find_preset_temp(PRESET_ECO) + ) + else: # default is to turn_off + await self.async_set_hvac_mode(HVACMode.OFF) + async def async_control_heating(self, force=False, _=None): """The main function used to run the calculation at each cycle""" @@ -2376,8 +2433,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity): self.get_preset_away_name(PRESET_COMFORT) ), "power_temp": self._power_temp, - # Already in super class - "target_temp": self.target_temperature, - # Already in super class - "current_temp": self._cur_temp, "target_temperature_step": self.target_temperature_step, "ext_current_temperature": self._cur_ext_temp, "ac_mode": self._ac_mode, @@ -2386,12 +2441,23 @@ class BaseThermostat(ClimateEntity, RestoreEntity): "saved_preset_mode": self._saved_preset_mode, "saved_target_temp": self._saved_target_temp, "saved_hvac_mode": self._saved_hvac_mode, - "window_state": self.window_state, + "motion_sensor_entity_id": self._motion_sensor_entity_id, "motion_state": self._motion_state, + "power_sensor_entity_id": self._power_sensor_entity_id, + "max_power_sensor_entity_id": self._max_power_sensor_entity_id, "overpowering_state": self.overpowering_state, + "presence_sensor_entity_id": self._presence_sensor_entity_id, "presence_state": self._presence_state, + "window_state": self.window_state, "window_auto_state": self.window_auto_state, "window_bypass_state": self._window_bypass_state, + "window_sensor_entity_id": self._window_sensor_entity_id, + "window_delay_sec": self._window_delay_sec, + "window_auto_enabled": self.is_window_auto_enabled, + "window_auto_open_threshold": self._window_auto_open_threshold, + "window_auto_close_threshold": self._window_auto_close_threshold, + "window_auto_max_duration": self._window_auto_max_duration, + "window_action": self.window_action, "security_delay_min": self._security_delay_min, "security_min_on_percent": self._security_min_on_percent, "security_default_on_percent": self._security_default_on_percent, @@ -2410,16 +2476,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity): .astimezone(self._current_tz) .isoformat(), "timezone": str(self._current_tz), - "window_sensor_entity_id": self._window_sensor_entity_id, - "window_delay_sec": self._window_delay_sec, - "window_auto_enabled": self.is_window_auto_enabled, - "window_auto_open_threshold": self._window_auto_open_threshold, - "window_auto_close_threshold": self._window_auto_close_threshold, - "window_auto_max_duration": self._window_auto_max_duration, - "motion_sensor_entity_id": self._motion_sensor_entity_id, - "presence_sensor_entity_id": self._presence_sensor_entity_id, - "power_sensor_entity_id": self._power_sensor_entity_id, - "max_power_sensor_entity_id": self._max_power_sensor_entity_id, "temperature_unit": self.temperature_unit, "is_device_active": self.is_device_active, "ema_temp": self._ema_temp, diff --git a/custom_components/versatile_thermostat/config_schema.py b/custom_components/versatile_thermostat/config_schema.py index f53b7d0..fc9562f 100644 --- a/custom_components/versatile_thermostat/config_schema.py +++ b/custom_components/versatile_thermostat/config_schema.py @@ -28,7 +28,9 @@ STEP_USER_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name CONF_THERMOSTAT_TYPE, default=CONF_THERMOSTAT_SWITCH ): selector.SelectSelector( selector.SelectSelectorConfig( - options=CONF_THERMOSTAT_TYPES, translation_key="thermostat_type" + options=CONF_THERMOSTAT_TYPES, + translation_key="thermostat_type", + mode="list", ) ) } @@ -125,6 +127,7 @@ STEP_THERMOSTAT_CLIMATE = vol.Schema( # pylint: disable=invalid-name selector.SelectSelectorConfig( options=CONF_AUTO_REGULATION_MODES, translation_key="auto_regulation_mode", + mode="dropdown", ) ), vol.Optional(CONF_AUTO_REGULATION_DTEMP, default=0.5): vol.Coerce(float), @@ -135,6 +138,7 @@ STEP_THERMOSTAT_CLIMATE = vol.Schema( # pylint: disable=invalid-name selector.SelectSelectorConfig( options=CONF_AUTO_FAN_MODES, translation_key="auto_fan_mode", + mode="dropdown", ) ), } @@ -212,12 +216,30 @@ STEP_CENTRAL_WINDOW_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name vol.Optional(CONF_WINDOW_AUTO_OPEN_THRESHOLD, default=3): vol.Coerce(float), vol.Optional(CONF_WINDOW_AUTO_CLOSE_THRESHOLD, default=0): vol.Coerce(float), vol.Optional(CONF_WINDOW_AUTO_MAX_DURATION, default=30): cv.positive_int, + vol.Optional( + CONF_WINDOW_ACTION, default=CONF_WINDOW_TURN_OFF + ): selector.SelectSelector( + selector.SelectSelectorConfig( + options=CONF_WINDOW_ACTIONS, + translation_key="window_action", + mode="dropdown", + ) + ), } ) STEP_CENTRAL_WINDOW_WO_AUTO_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name { vol.Optional(CONF_WINDOW_DELAY, default=30): cv.positive_int, + vol.Optional( + CONF_WINDOW_ACTION, default=CONF_WINDOW_TURN_OFF + ): selector.SelectSelector( + selector.SelectSelectorConfig( + options=CONF_WINDOW_ACTIONS, + translation_key="window_action", + mode="dropdown", + ) + ), } ) @@ -236,11 +258,19 @@ STEP_CENTRAL_MOTION_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name { vol.Optional(CONF_MOTION_DELAY, default=30): cv.positive_int, vol.Optional(CONF_MOTION_OFF_DELAY, default=300): cv.positive_int, - vol.Optional(CONF_MOTION_PRESET, default="comfort"): vol.In( - CONF_PRESETS_SELECTIONABLE + vol.Optional(CONF_MOTION_PRESET, default="comfort"): selector.SelectSelector( + selector.SelectSelectorConfig( + options=CONF_PRESETS_SELECTIONABLE, + translation_key="presets", + mode="dropdown", + ) ), - vol.Optional(CONF_NO_MOTION_PRESET, default="eco"): vol.In( - CONF_PRESETS_SELECTIONABLE + vol.Optional(CONF_NO_MOTION_PRESET, default="eco"): selector.SelectSelector( + selector.SelectSelectorConfig( + options=CONF_PRESETS_SELECTIONABLE, + translation_key="presets", + mode="dropdown", + ) ), } ) diff --git a/custom_components/versatile_thermostat/const.py b/custom_components/versatile_thermostat/const.py index 00cb671..871912b 100644 --- a/custom_components/versatile_thermostat/const.py +++ b/custom_components/versatile_thermostat/const.py @@ -132,6 +132,7 @@ CONF_CENTRAL_BOILER_ACTIVATION_SRV = "central_boiler_activation_service" CONF_CENTRAL_BOILER_DEACTIVATION_SRV = "central_boiler_deactivation_service" CONF_USED_BY_CENTRAL_BOILER = "used_by_controls_central_boiler" +CONF_WINDOW_ACTION = "window_action" DEFAULT_SHORT_EMA_PARAMS = { "max_alpha": 0.5, @@ -267,6 +268,7 @@ ALL_CONF = ( CONF_USED_BY_CENTRAL_BOILER, CONF_CENTRAL_BOILER_ACTIVATION_SRV, CONF_CENTRAL_BOILER_DEACTIVATION_SRV, + CONF_WINDOW_ACTION, ] + CONF_PRESETS_VALUES + CONF_PRESETS_AWAY_VALUES @@ -302,6 +304,18 @@ CONF_AUTO_FAN_MODES = [ CONF_AUTO_FAN_TURBO, ] +CONF_WINDOW_TURN_OFF = "window_turn_off" +CONF_WINDOW_FAN_ONLY = "window_fan_only" +CONF_WINDOW_FROST_TEMP = "window_frost_temp" +CONF_WINDOW_ECO_TEMP = "window_eco_temp" + +CONF_WINDOW_ACTIONS = [ + CONF_WINDOW_TURN_OFF, + CONF_WINDOW_FAN_ONLY, + CONF_WINDOW_FROST_TEMP, + CONF_WINDOW_ECO_TEMP, +] + SUPPORT_FLAGS = ClimateEntityFeature.TARGET_TEMPERATURE SERVICE_SET_PRESENCE = "set_presence" diff --git a/custom_components/versatile_thermostat/translations/fr.json b/custom_components/versatile_thermostat/translations/fr.json index 14bc041..eb6d894 100644 --- a/custom_components/versatile_thermostat/translations/fr.json +++ b/custom_components/versatile_thermostat/translations/fr.json @@ -130,7 +130,8 @@ "window_auto_open_threshold": "Seuil haut de chute de température pour la détection automatique (en °/heure)", "window_auto_close_threshold": "Seuil bas de chute de température pour la fin de détection automatique (en °/heure)", "window_auto_max_duration": "Durée maximum d'une extinction automatique (en min)", - "use_window_central_config": "Utiliser la configuration centrale des ouvertures" + "use_window_central_config": "Utiliser la configuration centrale des ouvertures", + "window_action": "Action" }, "data_description": { "window_sensor_entity_id": "Laissez vide si vous n'avez de détecteur et pour utiliser la détection automatique", @@ -138,7 +139,8 @@ "window_auto_open_threshold": "Valeur recommandée: entre 3 et 10. Laissez vide si vous n'utilisez pas la détection automatique", "window_auto_close_threshold": "Valeur recommandée: 0. Laissez vide si vous n'utilisez pas la détection automatique", "window_auto_max_duration": "Valeur recommandée: 60 (1 heure). Laissez vide si vous n'utilisez pas la détection automatique", - "use_window_central_config": "Cochez pour utiliser la configuration centrale des ouvertures. Décochez et saisissez les attributs pour utiliser une configuration spécifique des ouvertures" + "use_window_central_config": "Cochez pour utiliser la configuration centrale des ouvertures. Décochez et saisissez les attributs pour utiliser une configuration spécifique des ouvertures", + "window_action": "Action a effectuer si la fenêtre est détectée comme ouverte" } }, "motion": { @@ -368,7 +370,8 @@ "window_auto_open_threshold": "Seuil haut de chute de température pour la détection automatique (en °/heure)", "window_auto_close_threshold": "Seuil bas de chute de température pour la fin de détection automatique (en °/heure)", "window_auto_max_duration": "Durée maximum d'une extinction automatique (en min)", - "use_window_central_config": "Utiliser la configuration centrale des ouvertures" + "use_window_central_config": "Utiliser la configuration centrale des ouvertures", + "window_action": "Action" }, "data_description": { "window_sensor_entity_id": "Laissez vide si vous n'avez de détecteur et pour utiliser la détection automatique", @@ -376,7 +379,8 @@ "window_auto_open_threshold": "Valeur recommandée: entre 3 et 10. Laissez vide si vous n'utilisez pas la détection automatique", "window_auto_close_threshold": "Valeur recommandée: 0. Laissez vide si vous n'utilisez pas la détection automatique", "window_auto_max_duration": "Valeur recommandée: 60 (1 heure). Laissez vide si vous n'utilisez pas la détection automatique", - "use_window_central_config": "Cochez pour utiliser la configuration centrale des ouvertures. Décochez et saisissez les attributs pour utiliser une configuration spécifique des ouvertures" + "use_window_central_config": "Cochez pour utiliser la configuration centrale des ouvertures. Décochez et saisissez les attributs pour utiliser une configuration spécifique des ouvertures", + "window_action": "Action a effectuer si la fenêtre est détectée comme ouverte" } }, "motion": { @@ -510,6 +514,22 @@ "auto_fan_high": "Forte", "auto_fan_turbo": "Turbo" } + }, + "window_action": { + "options": { + "window_turn_off": "Eteindre", + "window_fan_only": "Ventilateur seul", + "window_frost_temp": "Hors gel", + "window_eco_temp": "Eco" + } + }, + "presets": { + "options": { + "frost": "Hors-gel", + "eco": "Eco", + "comfort": "Confort", + "boost": "Renforcé (boost)" + } } }, "entity": { diff --git a/tests/const.py b/tests/const.py index bb19197..5fe3c40 100644 --- a/tests/const.py +++ b/tests/const.py @@ -152,6 +152,7 @@ MOCK_WINDOW_AUTO_CONFIG = { CONF_WINDOW_AUTO_OPEN_THRESHOLD: 1.0, CONF_WINDOW_AUTO_CLOSE_THRESHOLD: 0.0, CONF_WINDOW_AUTO_MAX_DURATION: 5.0, + CONF_WINDOW_ACTION: CONF_WINDOW_FAN_ONLY, } MOCK_MOTION_CONFIG = { diff --git a/tests/test_window.py b/tests/test_window.py index 441d470..e43472f 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -6,6 +6,9 @@ from unittest.mock import patch, call, PropertyMock from datetime import datetime, timedelta from custom_components.versatile_thermostat.base_thermostat import BaseThermostat +from custom_components.versatile_thermostat.thermostat_climate import ( + ThermostatOverClimate, +) from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import logging.getLogger().setLevel(logging.DEBUG) @@ -46,6 +49,7 @@ async def test_window_management_time_not_enough( CONF_SECURITY_MIN_ON_PERCENT: 0.3, CONF_WINDOW_SENSOR: "binary_sensor.mock_window_sensor", CONF_WINDOW_DELAY: 0, # important to not been obliged to wait + CONF_WINDOW_ACTION: CONF_WINDOW_TURN_OFF, }, ) @@ -134,6 +138,7 @@ async def test_window_management_time_enough( CONF_SECURITY_MIN_ON_PERCENT: 0.3, CONF_WINDOW_SENSOR: "binary_sensor.mock_window_sensor", CONF_WINDOW_DELAY: 0, # important to not been obliged to wait + CONF_WINDOW_ACTION: CONF_WINDOW_TURN_OFF, }, ) @@ -242,7 +247,7 @@ async def test_window_management_time_enough( @pytest.mark.parametrize("expected_lingering_tasks", [True]) @pytest.mark.parametrize("expected_lingering_timers", [True]) async def test_window_auto_fast(hass: HomeAssistant, skip_hass_states_is_state): - """Test the Window management""" + """Test the auto Window management with fast slope down""" entry = MockConfigEntry( domain=DOMAIN, @@ -822,7 +827,6 @@ async def test_window_auto_no_on_percent( entity.remove_thermostat() -# PR - Adding Window Bypass @pytest.mark.parametrize("expected_lingering_tasks", [True]) @pytest.mark.parametrize("expected_lingering_timers", [True]) async def test_window_bypass(hass: HomeAssistant, skip_hass_states_is_state): @@ -1207,3 +1211,694 @@ async def test_window_bypass_reactivate(hass: HomeAssistant, skip_hass_states_is # Clean the entity entity.remove_thermostat() + + +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_window_action_fan_only(hass: HomeAssistant, skip_hass_states_is_state): + """Test the Window management with the fan_only option""" + + entry = MockConfigEntry( + domain=DOMAIN, + title="TheOverClimateMockName", + unique_id="uniqueId", + data={ + CONF_NAME: "TheOverClimateMockName", + CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_CLIMATE, + CONF_TEMP_SENSOR: "sensor.mock_temp_sensor", + CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor", + CONF_CYCLE_MIN: 5, + CONF_TEMP_MIN: 15, + CONF_TEMP_MAX: 30, + "eco_temp": 17, + "comfort_temp": 18, + "boost_temp": 19, + CONF_USE_WINDOW_FEATURE: True, + CONF_USE_MOTION_FEATURE: False, + CONF_USE_POWER_FEATURE: False, + CONF_USE_PRESENCE_FEATURE: False, + CONF_CLIMATE: "climate.mock_climate", + CONF_SECURITY_DELAY_MIN: 5, + CONF_SECURITY_MIN_ON_PERCENT: 0.3, + CONF_WINDOW_SENSOR: "binary_sensor.mock_window_sensor", + CONF_WINDOW_DELAY: 1, + CONF_WINDOW_ACTION: CONF_WINDOW_FAN_ONLY, + # CONF_WINDOW_AUTO_OPEN_THRESHOLD: 6, + # CONF_WINDOW_AUTO_CLOSE_THRESHOLD: 6, + # CONF_WINDOW_AUTO_MAX_DURATION: 1, # 0 will deactivate window auto detection + }, + ) + + tz = get_tz(hass) # pylint: disable=invalid-name + now: datetime = datetime.now(tz=tz) + + fake_underlying_climate = MockClimate( + hass=hass, + unique_id="mockUniqueId", + name="MockClimateName", + hvac_modes=[HVACMode.HEAT, HVACMode.COOL, HVACMode.FAN_ONLY], + ) + + # 1. intialize climate entity + with patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate", + return_value=fake_underlying_climate, + ): + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.LOADED + + entity: ThermostatOverClimate = search_entity( + hass, "climate.theoverclimatemockname", "climate" + ) + + assert entity + + assert entity.is_over_climate is True + assert entity.window_action == CONF_WINDOW_FAN_ONLY + + await entity.async_set_hvac_mode(HVACMode.HEAT) + assert entity.hvac_mode == HVACMode.HEAT + await entity.async_set_preset_mode(PRESET_COMFORT) + assert entity.preset_mode == PRESET_COMFORT + assert entity.target_temperature == 18 + + assert entity.window_state is STATE_OFF + + # 2. Open the window, condition of time is satisfied, check the thermostat and heater turns off + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ) as mock_send_event, patch( + "homeassistant.helpers.condition.state", return_value=True + ), patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.set_hvac_mode" + ) as mock_underlying_set_hvac_mode: + event_timestamp = now - timedelta(minutes=2) + try_window_condition = await send_window_change_event( + entity, True, False, event_timestamp + ) + await try_window_condition(None) + + assert mock_send_event.call_count == 1 + mock_send_event.assert_has_calls( + [ + call.send_event( + EventType.HVAC_MODE_EVENT, {"hvac_mode": HVACMode.FAN_ONLY} + ) + ] + ) + + # The underlying should be in OFF hvac_mode + assert mock_underlying_set_hvac_mode.call_count == 1 + mock_underlying_set_hvac_mode.assert_has_calls( + [ + call.set_hvac_mode(HVACMode.FAN_ONLY), + ] + ) + + assert entity.window_state == STATE_ON + # 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.preset_mode is PRESET_COMFORT + + # 3. Close the window + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ) as mock_send_event, patch( + "homeassistant.helpers.condition.state", return_value=True + ), patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.set_hvac_mode" + ) as mock_underlying_set_hvac_mode: + event_timestamp = now - timedelta(minutes=1) + try_function = await send_window_change_event( + entity, False, True, event_timestamp, sleep=False + ) + + await try_function(None) + + # Wait for initial delay of heater + await asyncio.sleep(0.3) + + assert entity.window_state == STATE_OFF + assert mock_send_event.call_count == 1 + mock_send_event.assert_has_calls( + [ + call.send_event( + EventType.HVAC_MODE_EVENT, {"hvac_mode": HVACMode.HEAT} + ), + ], + any_order=False, + ) + + # The underlying should be in OFF hvac_mode + assert mock_underlying_set_hvac_mode.call_count == 1 + mock_underlying_set_hvac_mode.assert_has_calls( + [ + call.set_hvac_mode(HVACMode.HEAT), + ] + ) + assert entity.hvac_mode is HVACMode.HEAT + assert entity.preset_mode is PRESET_COMFORT + + # Clean the entity + entity.remove_thermostat() + + +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_window_action_fan_only_ko( + hass: HomeAssistant, skip_hass_states_is_state +): + """Test the Window management with the fan_only option but the underlyings doesn't have the FAN_ONLY mode + So the VTherm switch to OFF which is the fallback mode""" + + entry = MockConfigEntry( + domain=DOMAIN, + title="TheOverClimateMockName", + unique_id="uniqueId", + data={ + CONF_NAME: "TheOverClimateMockName", + CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_CLIMATE, + CONF_TEMP_SENSOR: "sensor.mock_temp_sensor", + CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor", + CONF_CYCLE_MIN: 5, + CONF_TEMP_MIN: 15, + CONF_TEMP_MAX: 30, + "eco_temp": 17, + "comfort_temp": 18, + "boost_temp": 19, + CONF_USE_WINDOW_FEATURE: True, + CONF_USE_MOTION_FEATURE: False, + CONF_USE_POWER_FEATURE: False, + CONF_USE_PRESENCE_FEATURE: False, + CONF_CLIMATE: "climate.mock_climate", + CONF_SECURITY_DELAY_MIN: 5, + CONF_SECURITY_MIN_ON_PERCENT: 0.3, + CONF_WINDOW_SENSOR: "binary_sensor.mock_window_sensor", + CONF_WINDOW_DELAY: 1, + CONF_WINDOW_ACTION: CONF_WINDOW_FAN_ONLY, + # CONF_WINDOW_AUTO_OPEN_THRESHOLD: 6, + # CONF_WINDOW_AUTO_CLOSE_THRESHOLD: 6, + # CONF_WINDOW_AUTO_MAX_DURATION: 1, # 0 will deactivate window auto detection + }, + ) + + tz = get_tz(hass) # pylint: disable=invalid-name + now: datetime = datetime.now(tz=tz) + + fake_underlying_climate = MockClimate( + hass=hass, + unique_id="mockUniqueId", + name="MockClimateName", + hvac_modes=[HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO], + ) + + # 1. intialize climate entity + with patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate", + return_value=fake_underlying_climate, + ): + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.LOADED + + entity: ThermostatOverClimate = search_entity( + hass, "climate.theoverclimatemockname", "climate" + ) + + assert entity + + assert entity.is_over_climate is True + assert entity.window_action == CONF_WINDOW_FAN_ONLY + + await entity.async_set_hvac_mode(HVACMode.HEAT) + assert entity.hvac_mode == HVACMode.HEAT + await entity.async_set_preset_mode(PRESET_COMFORT) + assert entity.preset_mode == PRESET_COMFORT + assert entity.target_temperature == 18 + + assert entity.window_state is STATE_OFF + + # 2. Open the window, condition of time is satisfied, check the thermostat and heater turns off + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ) as mock_send_event, patch( + "homeassistant.helpers.condition.state", return_value=True + ), patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.set_hvac_mode" + ) as mock_underlying_set_hvac_mode: + event_timestamp = now - timedelta(minutes=2) + try_window_condition = await send_window_change_event( + entity, True, False, event_timestamp + ) + await try_window_condition(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})] + ) + + assert entity.window_state == STATE_ON + assert entity.hvac_mode is HVACMode.OFF + # The underlying should be in OFF hvac_mode + assert mock_underlying_set_hvac_mode.call_count == 1 + mock_underlying_set_hvac_mode.assert_has_calls( + [ + call.set_hvac_mode(HVACMode.OFF), + ] + ) + + assert entity._saved_hvac_mode is HVACMode.HEAT + assert entity.preset_mode is PRESET_COMFORT + + # 3. Close the window + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ) as mock_send_event, patch( + "homeassistant.helpers.condition.state", return_value=True + ), patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.set_hvac_mode" + ) as mock_underlying_set_hvac_mode: + event_timestamp = now - timedelta(minutes=1) + try_function = await send_window_change_event( + entity, False, True, event_timestamp, sleep=False + ) + + await try_function(None) + + # Wait for initial delay of heater + await asyncio.sleep(0.3) + + assert entity.window_state == STATE_OFF + assert mock_send_event.call_count == 1 + mock_send_event.assert_has_calls( + [ + call.send_event( + EventType.HVAC_MODE_EVENT, {"hvac_mode": HVACMode.HEAT} + ), + ], + any_order=False, + ) + + # The underlying should be in OFF hvac_mode + assert mock_underlying_set_hvac_mode.call_count == 1 + mock_underlying_set_hvac_mode.assert_has_calls( + [ + call.set_hvac_mode(HVACMode.HEAT), + ] + ) + assert entity.hvac_mode is HVACMode.HEAT + assert entity.preset_mode is PRESET_COMFORT + + # Clean the entity + entity.remove_thermostat() + + +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_window_action_eco_temp(hass: HomeAssistant, skip_hass_states_is_state): + """Test the Window management with the eco_temp option""" + + entry = MockConfigEntry( + domain=DOMAIN, + title="TheOverSwitchMockName", + unique_id="uniqueId", + data={ + CONF_NAME: "TheOverSwitchMockName", + CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH, + CONF_TEMP_SENSOR: "sensor.mock_temp_sensor", + CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor", + CONF_CYCLE_MIN: 5, + CONF_TEMP_MIN: 15, + CONF_TEMP_MAX: 30, + "eco_temp": 17, + "comfort_temp": 18, + "boost_temp": 21, + CONF_USE_WINDOW_FEATURE: True, + CONF_USE_MOTION_FEATURE: False, + CONF_USE_POWER_FEATURE: False, + CONF_USE_PRESENCE_FEATURE: False, + CONF_HEATER: "switch.mock_switch", + CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI, + CONF_TPI_COEF_INT: 0.3, + CONF_TPI_COEF_EXT: 0.01, + CONF_MINIMAL_ACTIVATION_DELAY: 30, + CONF_SECURITY_DELAY_MIN: 5, + CONF_SECURITY_MIN_ON_PERCENT: 0.3, + CONF_WINDOW_AUTO_OPEN_THRESHOLD: 0.1, + CONF_WINDOW_AUTO_CLOSE_THRESHOLD: 0.1, + CONF_WINDOW_AUTO_MAX_DURATION: 10, # Should be 0 for test + CONF_WINDOW_ACTION: CONF_WINDOW_ECO_TEMP, + }, + ) + + entity: BaseThermostat = await create_thermostat( + hass, entry, "climate.theoverswitchmockname" + ) + assert entity + + tz = get_tz(hass) # pylint: disable=invalid-name + now = datetime.now(tz) + + await entity.async_set_hvac_mode(HVACMode.HEAT) + await entity.async_set_preset_mode(PRESET_BOOST) + assert entity.hvac_mode is HVACMode.HEAT + assert entity.preset_mode is PRESET_BOOST + assert entity.overpowering_state is None + assert entity.target_temperature == 21 + + assert entity.window_state is STATE_OFF + assert entity.is_window_auto_enabled is True + + # 1. Initialize the slope algo with 2 measurements + event_timestamp = now + timedelta(minutes=1) + await send_temperature_change_event(entity, 19, event_timestamp) + event_timestamp = event_timestamp + timedelta(minutes=1) + await send_temperature_change_event(entity, 19, event_timestamp) + event_timestamp = event_timestamp + timedelta(minutes=1) + await send_temperature_change_event(entity, 19, event_timestamp) + + # 2. Make the temperature down + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ) as mock_send_event, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" + ) as mock_heater_on, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off" + ) as mock_heater_off, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active", + return_value=True, + ): + event_timestamp = event_timestamp + timedelta(minutes=1) + await send_temperature_change_event(entity, 19, event_timestamp) + + # The heater turns on + assert mock_send_event.call_count == 0 + assert entity.is_device_active is True + assert entity.hvac_mode is HVACMode.HEAT + assert entity.window_state is STATE_OFF + assert entity.window_auto_state is STATE_OFF + + # 3. send one degre down in one minute + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ) as mock_send_event, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" + ) as mock_heater_on, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off" + ) as mock_heater_off, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active", + return_value=True, + ): + event_timestamp = event_timestamp + timedelta(minutes=1) + await send_temperature_change_event(entity, 18, event_timestamp) + + # The heater turns on + assert mock_send_event.call_count == 1 + assert mock_heater_on.call_count == 0 + assert mock_heater_off.call_count == 0 + assert entity.last_temperature_slope == -6.24 + assert entity.window_auto_state == STATE_ON + assert entity.window_state == STATE_OFF + # No change on HVACMode + assert entity.hvac_mode is HVACMode.HEAT + # No change on preset + assert entity.preset_mode is PRESET_BOOST + # The eco temp + assert entity.target_temperature == 17 + + mock_send_event.assert_has_calls( + [ + call.send_event( + EventType.WINDOW_AUTO_EVENT, + {"type": "start", "cause": "slope alert", "curve_slope": -6.24}, + ), + ], + any_order=True, + ) + + # 4. send another 0.1 degre in one minute -> no change + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ) as mock_send_event, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" + ) as mock_heater_on, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off" + ) as mock_heater_off, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active", + new_callable=PropertyMock, + return_value=False, + ): + event_timestamp = event_timestamp + timedelta(minutes=1) + await send_temperature_change_event(entity, 17.9, event_timestamp) + + # The heater turns on + assert mock_send_event.call_count == 0 + assert mock_heater_on.call_count == 0 + assert mock_heater_off.call_count == 0 + assert round(entity.last_temperature_slope, 3) == -7.49 + assert entity.window_auto_state == STATE_ON + assert entity.hvac_mode is HVACMode.HEAT + # No change on preset + assert entity.preset_mode is PRESET_BOOST + # The eco temp + assert entity.target_temperature == 17 + + # 5. send another plus 1.1 degre in one minute -> restore state + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ) as mock_send_event, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" + ) as mock_heater_on, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off" + ) as mock_heater_off, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active", + new_callable=PropertyMock, + return_value=False, + ): + event_timestamp = event_timestamp + timedelta(minutes=1) + await send_temperature_change_event(entity, 19, event_timestamp) + + # The heater turns on + assert mock_send_event.call_count == 1 + mock_send_event.assert_has_calls( + [ + call.send_event( + EventType.WINDOW_AUTO_EVENT, + { + "type": "end", + "cause": "end of slope alert", + "curve_slope": 0.42, + }, + ), + ], + any_order=True, + ) + assert mock_heater_on.call_count == 0 + assert mock_heater_off.call_count == 0 + assert entity.last_temperature_slope == 0.42 + assert entity.window_auto_state == STATE_OFF + assert entity.hvac_mode is HVACMode.HEAT + # No change on preset + assert entity.preset_mode is PRESET_BOOST + # The eco temp + assert entity.target_temperature == 21 + + # Clean the entity + entity.remove_thermostat() + + +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_window_action_frost_temp(hass: HomeAssistant, skip_hass_states_is_state): + """Test the Window management with the frost_temp option""" + + entry = MockConfigEntry( + domain=DOMAIN, + title="TheOverSwitchMockName", + unique_id="uniqueId", + data={ + CONF_NAME: "TheOverSwitchMockName", + CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH, + CONF_TEMP_SENSOR: "sensor.mock_temp_sensor", + CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor", + CONF_CYCLE_MIN: 5, + CONF_TEMP_MIN: 15, + CONF_TEMP_MAX: 30, + "eco_temp": 17, + "comfort_temp": 18, + "boost_temp": 21, + "frost_temp": 10, + CONF_USE_WINDOW_FEATURE: True, + CONF_USE_MOTION_FEATURE: False, + CONF_USE_POWER_FEATURE: False, + CONF_USE_PRESENCE_FEATURE: False, + CONF_HEATER: "switch.mock_switch", + CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI, + CONF_TPI_COEF_INT: 0.3, + CONF_TPI_COEF_EXT: 0.01, + CONF_MINIMAL_ACTIVATION_DELAY: 30, + CONF_SECURITY_DELAY_MIN: 5, + CONF_SECURITY_MIN_ON_PERCENT: 0.3, + CONF_WINDOW_AUTO_OPEN_THRESHOLD: 0.1, + CONF_WINDOW_AUTO_CLOSE_THRESHOLD: 0.1, + CONF_WINDOW_AUTO_MAX_DURATION: 10, # Should be 0 for test + CONF_WINDOW_ACTION: CONF_WINDOW_FROST_TEMP, + }, + ) + + entity: BaseThermostat = await create_thermostat( + hass, entry, "climate.theoverswitchmockname" + ) + assert entity + + tz = get_tz(hass) # pylint: disable=invalid-name + now = datetime.now(tz) + + await entity.async_set_hvac_mode(HVACMode.HEAT) + await entity.async_set_preset_mode(PRESET_BOOST) + assert entity.hvac_mode is HVACMode.HEAT + assert entity.preset_mode is PRESET_BOOST + assert entity.overpowering_state is None + assert entity.target_temperature == 21 + + assert entity.window_state is STATE_OFF + assert entity.is_window_auto_enabled is True + + # 1. Initialize the slope algo with 2 measurements + event_timestamp = now + timedelta(minutes=1) + await send_temperature_change_event(entity, 19, event_timestamp) + event_timestamp = event_timestamp + timedelta(minutes=1) + await send_temperature_change_event(entity, 19, event_timestamp) + event_timestamp = event_timestamp + timedelta(minutes=1) + await send_temperature_change_event(entity, 19, event_timestamp) + + # 2. Make the temperature down + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ) as mock_send_event, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" + ) as mock_heater_on, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off" + ) as mock_heater_off, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active", + return_value=True, + ): + event_timestamp = event_timestamp + timedelta(minutes=1) + await send_temperature_change_event(entity, 19, event_timestamp) + + # The heater turns on + assert mock_send_event.call_count == 0 + assert entity.is_device_active is True + assert entity.hvac_mode is HVACMode.HEAT + assert entity.window_state is STATE_OFF + assert entity.window_auto_state is STATE_OFF + + # 3. send one degre down in one minute + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ) as mock_send_event, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" + ) as mock_heater_on, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off" + ) as mock_heater_off, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active", + return_value=True, + ): + event_timestamp = event_timestamp + timedelta(minutes=1) + await send_temperature_change_event(entity, 18, event_timestamp) + + # The heater turns on + assert mock_send_event.call_count == 1 + assert mock_heater_on.call_count == 0 + assert mock_heater_off.call_count == 0 + assert entity.last_temperature_slope == -6.24 + assert entity.window_auto_state == STATE_ON + assert entity.window_state == STATE_OFF + # No change on HVACMode + assert entity.hvac_mode is HVACMode.HEAT + # No change on preset + assert entity.preset_mode is PRESET_BOOST + # The eco temp + assert entity.target_temperature == 10 + + mock_send_event.assert_has_calls( + [ + call.send_event( + EventType.WINDOW_AUTO_EVENT, + {"type": "start", "cause": "slope alert", "curve_slope": -6.24}, + ), + ], + any_order=True, + ) + + # 4. send another 0.1 degre in one minute -> no change + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ) as mock_send_event, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" + ) as mock_heater_on, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off" + ) as mock_heater_off, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active", + new_callable=PropertyMock, + return_value=False, + ): + event_timestamp = event_timestamp + timedelta(minutes=1) + await send_temperature_change_event(entity, 17.9, event_timestamp) + + # The heater turns on + assert mock_send_event.call_count == 0 + assert mock_heater_on.call_count == 0 + assert mock_heater_off.call_count == 0 + assert round(entity.last_temperature_slope, 3) == -7.49 + assert entity.window_auto_state == STATE_ON + assert entity.hvac_mode is HVACMode.HEAT + # No change on preset + assert entity.preset_mode is PRESET_BOOST + # The eco temp + assert entity.target_temperature == 10 + + # 5. send another plus 1.1 degre in one minute -> restore state + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ) as mock_send_event, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" + ) as mock_heater_on, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off" + ) as mock_heater_off, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active", + new_callable=PropertyMock, + return_value=False, + ): + event_timestamp = event_timestamp + timedelta(minutes=1) + await send_temperature_change_event(entity, 19, event_timestamp) + + # The heater turns on + assert mock_send_event.call_count == 1 + mock_send_event.assert_has_calls( + [ + call.send_event( + EventType.WINDOW_AUTO_EVENT, + { + "type": "end", + "cause": "end of slope alert", + "curve_slope": 0.42, + }, + ), + ], + any_order=True, + ) + assert mock_heater_on.call_count == 0 + assert mock_heater_off.call_count == 0 + assert entity.last_temperature_slope == 0.42 + assert entity.window_auto_state == STATE_OFF + assert entity.hvac_mode is HVACMode.HEAT + # No change on preset + assert entity.preset_mode is PRESET_BOOST + # The eco temp + assert entity.target_temperature == 21 + + # Clean the entity + entity.remove_thermostat()