diff --git a/README-fr.md b/README-fr.md index f629343..cbd9a21 100644 --- a/README-fr.md +++ b/README-fr.md @@ -532,7 +532,7 @@ Ce service permet de forcer l'état de présence indépendamment du capteur de p Le code pour appeler ce service est le suivant : ``` -service : thermostat_polyvalent.set_presence +service : versatile_thermostat.set_presence Les données: présence : "off" cible: @@ -547,7 +547,7 @@ Vous pouvez modifier l'une ou les deux températures (lorsqu'elles sont présent Utilisez le code suivant pour régler la température du préréglage : ``` -service : thermostat_polyvalent.set_preset_temperature +service : versatile_thermostat.set_preset_temperature date: preset : boost temperature : 17,8 @@ -576,8 +576,8 @@ Si le thermostat est en mode ``security`` les nouveaux paramètres sont appliqu Pour changer les paramètres de sécurité utilisez le code suivant : ``` -service : thermostat_polyvalent.set_security -date: +service : versatile_thermostat.set_security +data: min_on_percent: "0.5" default_on_percent: "0.1" delay_min: 60 @@ -585,6 +585,19 @@ target: entity_id : climate.my_thermostat ``` +## ByPass Window Check +Ce service permet d'activer ou non un bypass de la vérification des fenetres. +Il permet de continuer à chauffer même si la fenetre est detectée ouverte. +Mis à ``true`` les modifications de status de la fenetre n'auront plus d'effet sur le thermostat, remis à ``false`` cela s'assurera de désactiver le thermostat si la fenetre est toujours ouverte. + +Pour changer le paramètre de bypass utilisez le code suivant : +``` +service : versatile_thermostat.set_window_bypass +data: + window_bypass: true +target: + entity_id : climate.my_thermostat + # Notifications Les évènements marquant du thermostat sont notifiés par l'intermédiaire du bus de message. Les évènements notifiés sont les suivants: diff --git a/README.md b/README.md index f17c893..23d752b 100644 --- a/README.md +++ b/README.md @@ -564,13 +564,24 @@ If the thermostat is in ``security`` mode the new settings are applied immediate To change the security settings use the following code: ``` service : thermostat_polyvalent.set_security -date: +data: min_on_percent: "0.5" default_on_percent: "0.1" delay_min: 60 target: entity_id : climate.my_thermostat ``` +## ByPass Window Check +This service is used to bypass the window check implemented to stop thermostat when an open window is detected. +When set to ``true`` window event won't have any effect on the thermostat, when set back to ``false`` it will make sure to disable the thermostat if window is still open. + +To change the bypass setting use the following code: +``` +service : thermostat_polyvalent.set_window_bypass +data: + window_bypass: true +target: + entity_id : climate.my_thermostat # Notifications Significant thermostat events are notified via the message bus. diff --git a/custom_components/versatile_thermostat/base_thermostat.py b/custom_components/versatile_thermostat/base_thermostat.py index d4d483b..d3076fb 100644 --- a/custom_components/versatile_thermostat/base_thermostat.py +++ b/custom_components/versatile_thermostat/base_thermostat.py @@ -130,6 +130,8 @@ class BaseThermostat(ClimateEntity, RestoreEntity): _motion_state: bool _presence_state: bool _window_auto_state: bool + #PR - Adding Window ByPass + _window_bypass_state: bool _underlyings: list[UnderlyingEntity] _last_change_time: datetime @@ -229,6 +231,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._current_tz = dt_util.get_time_zone(self._hass.config.time_zone) @@ -961,6 +965,12 @@ class BaseThermostat(ClimateEntity, RestoreEntity): """Get the window_auto_state""" return STATE_ON if self._window_auto_state else STATE_OFF + #PR - Adding Window ByPass + @property + def window_bypass_state(self) -> bool | None: + """Get the Window Bypass""" + return self._window_bypass_state + @property def security_state(self) -> bool | None: """Get the security_state""" @@ -1308,7 +1318,16 @@ class BaseThermostat(ClimateEntity, RestoreEntity): _LOGGER.debug("%s - no change in window state. Forget the event") return + self._window_state = new_state.state + + #PR - Adding Window ByPass + _LOGGER.debug("%s - Window ByPass is : %s", self, self._window_bypass_state) + if self._window_bypass_state: + _LOGGER.info("Window ByPass is activated. Ignore window event") + self.update_custom_attributes() + return + if self._window_state == STATE_OFF: _LOGGER.info( "%s - Window is closed. Restoring hvac_mode '%s'", @@ -2107,6 +2126,8 @@ class BaseThermostat(ClimateEntity, RestoreEntity): "overpowering_state": self._overpowering_state, "presence_state": self._presence_state, "window_auto_state": self._window_auto_state, + #PR - Adding Window ByPass + "window_bypass_state": self._window_bypass_state, "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, @@ -2224,6 +2245,26 @@ class BaseThermostat(ClimateEntity, RestoreEntity): await self.async_control_heating() self.update_custom_attributes() + #PR - Adding Window ByPass + async def service_set_window_bypass_state(self, window_bypass): + """Called by a service call: + service: versatile_thermostat.set_window_bypass + data: + window_bypass: True + target: + entity_id: climate.thermostat_1 + """ + _LOGGER.info("%s - Calling service_set_window_bypass, window_bypass: %s", self, window_bypass) + self._window_bypass_state = window_bypass + if not self._window_bypass_state and self._window_state == STATE_ON: + _LOGGER.info("%s - Last window state was open & ByPass is now off. Set hvac_mode to '%s'", self, HVACMode.OFF) + self.save_hvac_mode() + await self.async_set_hvac_mode(HVACMode.OFF) + if self._window_bypass_state and self._window_state == STATE_ON: + _LOGGER.info("%s - Last window state was open & ByPass is now on. Set hvac_mode to last available mode", self) + await self.restore_hvac_mode(True) + self.update_custom_attributes() + def send_event(self, event_type: EventType, data: dict): """Send an event""" _LOGGER.info("%s - Sending event %s with data: %s", self, event_type, data) diff --git a/custom_components/versatile_thermostat/binary_sensor.py b/custom_components/versatile_thermostat/binary_sensor.py index c86a23e..19c2e7b 100644 --- a/custom_components/versatile_thermostat/binary_sensor.py +++ b/custom_components/versatile_thermostat/binary_sensor.py @@ -38,7 +38,7 @@ async def async_setup_entry( unique_id = entry.entry_id name = entry.data.get(CONF_NAME) - entities = [SecurityBinarySensor(hass, unique_id, name, entry.data)] + entities = [SecurityBinarySensor(hass, unique_id, name, entry.data),WindowByPassBinarySensor(hass, unique_id, name, entry.data)] if entry.data.get(CONF_USE_MOTION_FEATURE): entities.append(MotionBinarySensor(hass, unique_id, name, entry.data)) if entry.data.get(CONF_USE_WINDOW_FEATURE): @@ -238,3 +238,38 @@ class PresenceBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity): return "mdi:home-account" else: return "mdi:nature-people" + +#PR - Adding Window ByPass +class WindowByPassBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity): + """Representation of a BinarySensor which exposes the Window ByPass state""" + + def __init__( + self, hass: HomeAssistant, unique_id, name, entry_infos + ) -> None: # pylint: disable=unused-argument + """Initialize the WindowByPass Binary sensor""" + super().__init__(hass, unique_id, entry_infos.get(CONF_NAME)) + self._attr_name = "Window bypass" + self._attr_unique_id = f"{self._device_name}_window_bypass_state" + self._attr_is_on = False + + @callback + async def async_my_climate_changed(self, event: Event = None): + """Called when my climate have change""" + _LOGGER.debug("%s - climate state change", self._attr_unique_id) + old_state = self._attr_is_on + if self.my_climate.window_bypass_state in [True, False]: + self._attr_is_on = self.my_climate.window_bypass_state + if old_state != self._attr_is_on: + self.async_write_ha_state() + return + + @property + def device_class(self) -> BinarySensorDeviceClass | None: + return BinarySensorDeviceClass.RUNNING + + @property + def icon(self) -> str | None: + if self._attr_is_on: + return "mdi:window-shutter-cog" + else: + return "mdi:window-shutter-auto" \ No newline at end of file diff --git a/custom_components/versatile_thermostat/climate.py b/custom_components/versatile_thermostat/climate.py index d10ae71..103a36d 100644 --- a/custom_components/versatile_thermostat/climate.py +++ b/custom_components/versatile_thermostat/climate.py @@ -24,6 +24,8 @@ from .const import ( SERVICE_SET_PRESENCE, SERVICE_SET_PRESET_TEMPERATURE, SERVICE_SET_SECURITY, + #PR - Adding Window ByPass + SERVICE_SET_WINDOW_BYPASS, CONF_THERMOSTAT_TYPE, CONF_THERMOSTAT_SWITCH, CONF_THERMOSTAT_CLIMATE, @@ -98,3 +100,13 @@ async def async_setup_entry( }, "service_set_security", ) + + #PR - Adding Window ByPass + platform.async_register_entity_service( + SERVICE_SET_WINDOW_BYPASS, + { + vol.Required("window_bypass"): vol.In([True, False] + ), + }, + "service_set_window_bypass_state", + ) diff --git a/custom_components/versatile_thermostat/const.py b/custom_components/versatile_thermostat/const.py index 7929489..202aaf8 100644 --- a/custom_components/versatile_thermostat/const.py +++ b/custom_components/versatile_thermostat/const.py @@ -202,6 +202,8 @@ SUPPORT_FLAGS = ClimateEntityFeature.TARGET_TEMPERATURE SERVICE_SET_PRESENCE = "set_presence" SERVICE_SET_PRESET_TEMPERATURE = "set_preset_temperature" SERVICE_SET_SECURITY = "set_security" +#PR - Adding Window ByPass +SERVICE_SET_WINDOW_BYPASS = "set_window_bypass" DEFAULT_SECURITY_MIN_ON_PERCENT = 0.5 DEFAULT_SECURITY_DEFAULT_ON_PERCENT = 0.1 diff --git a/custom_components/versatile_thermostat/services.yaml b/custom_components/versatile_thermostat/services.yaml index b32536b..e57381a 100644 --- a/custom_components/versatile_thermostat/services.yaml +++ b/custom_components/versatile_thermostat/services.yaml @@ -122,3 +122,19 @@ set_security: step: 0.05 unit_of_measurement: "%" mode: slider + +set_window_bypass: + name: Set Window ByPass + description: Bypass the window state to enable heating with window open. + target: + entity: + integration: versatile_thermostat + fields: + window_bypass: + name: Window ByPass + description: ByPass value + required: true + advanced: false + default: true + selector: + boolean: \ No newline at end of file diff --git a/tests/test_window.py b/tests/test_window.py index 6b513b0..64de8a6 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -671,3 +671,136 @@ async def test_window_auto_no_on_percent( # Clean the entity 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 +): + """Test the Window management when bypass enabled""" + + 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": 19, + 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_SENSOR: "binary_sensor.mock_window_sensor", + CONF_WINDOW_DELAY: 0, # important to not been obliged to wait + }, + ) + + entity: VersatileThermostat = await create_thermostat( + hass, entry, "climate.theoverswitchmockname" + ) + assert entity + + tpi_algo = entity._prop_algorithm + assert tpi_algo + + 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 == 19 + + assert entity.window_state is None + + # change temperature to force turning on the heater + 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=False, + ): + await send_temperature_change_event(entity, 15, datetime.now()) + + # Heater shoud turn-on + assert mock_heater_on.call_count >= 1 + assert mock_heater_off.call_count == 0 + assert mock_send_event.call_count == 0 + + #Set Window ByPass to true + entity._window_bypass_state = True + + # 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( + "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( + "homeassistant.helpers.condition.state", return_value=True + ) as mock_condition, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active", + return_value=True, + ): + await send_window_change_event(entity, True, False, datetime.now()) + + assert mock_send_event.call_count == 0 + + # Heater should not be on + assert mock_heater_on.call_count == 0 + # One call in set_hvac_mode turn_off and one call in the control_heating for security + assert mock_heater_off.call_count == 0 + assert mock_condition.call_count == 1 + assert entity.hvac_mode is HVACMode.HEAT + assert entity.window_state == STATE_ON + + # Close the window + 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( + "homeassistant.helpers.condition.state", return_value=True + ) as mock_condition, patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active", + return_value=False, + ): + try_function = await send_window_change_event( + entity, False, True, datetime.now(), 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_heater_on.call_count == 0 + assert mock_send_event.call_count == 0 + assert entity.hvac_mode is HVACMode.HEAT + assert entity.preset_mode is PRESET_BOOST + + # Clean the entity + entity.remove_thermostat() \ No newline at end of file