diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml index e0893e9..556e55f 100644 --- a/.devcontainer/configuration.yaml +++ b/.devcontainer/configuration.yaml @@ -6,6 +6,7 @@ logger: custom_components.versatile_thermostat: info custom_components.versatile_thermostat.underlyings: info custom_components.versatile_thermostat.climate: info + custom_components.versatile_thermostat.base_thermostat: debug # If you need to debug uncommment the line below (doc: https://www.home-assistant.io/integrations/debugpy/) debugpy: @@ -166,6 +167,13 @@ climate: heater: input_boolean.fake_heater_switch3 target_sensor: input_number.fake_temperature_sensor1 +input_datetime: + fake_last_seen: + name: Last seen temp sensor + icon: mdi:update + has_date: true + has_time: true + recorder: commit_interval: 1 include: diff --git a/custom_components/versatile_thermostat/base_thermostat.py b/custom_components/versatile_thermostat/base_thermostat.py index e601d4f..6eb332d 100644 --- a/custom_components/versatile_thermostat/base_thermostat.py +++ b/custom_components/versatile_thermostat/base_thermostat.py @@ -68,6 +68,7 @@ from .const import ( DEVICE_MANUFACTURER, CONF_POWER_SENSOR, CONF_TEMP_SENSOR, + CONF_LAST_SEEN_TEMP_SENSOR, CONF_EXTERNAL_TEMP_SENSOR, CONF_MAX_POWER_SENSOR, CONF_WINDOW_SENSOR, @@ -242,6 +243,9 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): self._motion_call_cancel = None self._cur_temp = None self._ac_mode = None + self._temp_sensor_entity_id = None + self._last_seen_temp_sensor_entity_id = None + self._ext_temp_sensor_entity_id = None self._last_ext_temperature_measure = None self._last_temperature_measure = None self._cur_ext_temp = None @@ -393,6 +397,9 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): self._proportional_function = entry_infos.get(CONF_PROP_FUNCTION) self._temp_sensor_entity_id = entry_infos.get(CONF_TEMP_SENSOR) + self._last_seen_temp_sensor_entity_id = entry_infos.get( + CONF_LAST_SEEN_TEMP_SENSOR + ) self._ext_temp_sensor_entity_id = entry_infos.get(CONF_EXTERNAL_TEMP_SENSOR) self._power_sensor_entity_id = entry_infos.get(CONF_POWER_SENSOR) self._max_power_sensor_entity_id = entry_infos.get(CONF_MAX_POWER_SENSOR) @@ -574,6 +581,15 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): ) ) + if self._last_seen_temp_sensor_entity_id: + self.async_on_remove( + async_track_state_change_event( + self.hass, + [self._last_seen_temp_sensor_entity_id], + self._async_last_seen_temperature_changed, + ) + ) + if self._ext_temp_sensor_entity_id: self.async_on_remove( async_track_state_change_event( @@ -1445,6 +1461,44 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): await self.async_control_heating(force=False) return dearm_window_auto + @callback + async def _async_last_seen_temperature_changed(self, event: Event): + """Handle last seen temperature sensor changes.""" + new_state: State = event.data.get("new_state") + _LOGGER.debug( + "%s - Last seen temperature changed. Event.new_state is %s", + self, + new_state, + ) + if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): + return + + # try to extract the datetime (from state) + try: + # Convertir la chaîne au format ISO 8601 en objet datetime + self._last_temperature_measure = self.get_last_updated_date_or_now( + new_state + ) + self.reset_last_change_time() + _LOGGER.debug( + "%s - new last_temperature_measure is now: %s", + self, + self._last_temperature_measure, + ) + + # try to restart if we were in safety mode + if self._security_state: + await self.check_safety() + + except ValueError as err: + # La conversion a échoué, la chaîne n'est pas au format ISO 8601 + _LOGGER.warning( + "%s - impossible to convert last seen datetime %s. Error is: %s", + self, + new_state.state, + err, + ) + async def _async_ext_temperature_changed(self, event: Event): """Handle external temperature opf the sensor changes.""" new_state: State = event.data.get("new_state") diff --git a/custom_components/versatile_thermostat/config_schema.py b/custom_components/versatile_thermostat/config_schema.py index a43016d..b12c67c 100644 --- a/custom_components/versatile_thermostat/config_schema.py +++ b/custom_components/versatile_thermostat/config_schema.py @@ -16,6 +16,10 @@ from homeassistant.components.input_number import ( DOMAIN as INPUT_NUMBER_DOMAIN, ) +from homeassistant.components.input_datetime import ( + DOMAIN as INPUT_DATETIME_DOMAIN, +) + from homeassistant.components.person import DOMAIN as PERSON_DOMAIN from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN @@ -42,6 +46,11 @@ STEP_MAIN_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name vol.Required(CONF_TEMP_SENSOR): selector.EntitySelector( selector.EntitySelectorConfig(domain=[SENSOR_DOMAIN, INPUT_NUMBER_DOMAIN]), ), + vol.Optional(CONF_LAST_SEEN_TEMP_SENSOR): selector.EntitySelector( + selector.EntitySelectorConfig( + domain=[SENSOR_DOMAIN, INPUT_DATETIME_DOMAIN] + ), + ), vol.Required(CONF_CYCLE_MIN, default=5): cv.positive_int, vol.Optional(CONF_DEVICE_POWER, default="1"): vol.Coerce(float), vol.Required(CONF_USE_MAIN_CENTRAL_CONFIG, default=True): cv.boolean, diff --git a/custom_components/versatile_thermostat/const.py b/custom_components/versatile_thermostat/const.py index 60125c0..5e7df19 100644 --- a/custom_components/versatile_thermostat/const.py +++ b/custom_components/versatile_thermostat/const.py @@ -59,6 +59,7 @@ CONF_HEATER_3 = "heater_entity3_id" CONF_HEATER_4 = "heater_entity4_id" CONF_HEATER_KEEP_ALIVE = "heater_keep_alive" CONF_TEMP_SENSOR = "temperature_sensor_entity_id" +CONF_LAST_SEEN_TEMP_SENSOR = "last_seen_temperature_sensor_entity_id" CONF_EXTERNAL_TEMP_SENSOR = "external_temperature_sensor_entity_id" CONF_POWER_SENSOR = "power_sensor_entity_id" CONF_MAX_POWER_SENSOR = "max_power_sensor_entity_id" diff --git a/custom_components/versatile_thermostat/strings.json b/custom_components/versatile_thermostat/strings.json index 5a8ee9a..e6a2275 100644 --- a/custom_components/versatile_thermostat/strings.json +++ b/custom_components/versatile_thermostat/strings.json @@ -37,7 +37,8 @@ "data": { "name": "Name", "thermostat_type": "Thermostat type", - "temperature_sensor_entity_id": "Room temperature sensor entity id", + "temperature_sensor_entity_id": "Room temperature", + "last_seen_temperature_sensor_entity_id": "Last seen room temperature datetime", "external_temperature_sensor_entity_id": "Outdoor temperature sensor entity id", "cycle_min": "Cycle duration (minutes)", "temp_min": "Minimum temperature allowed", @@ -49,6 +50,8 @@ "used_by_controls_central_boiler": "Used by central boiler. Check if this VTherm should have control on the central boiler" }, "data_description": { + "temperature_sensor_entity_id": "Room temperature sensor entity id", + "last_seen_temperature_sensor_entity_id": "Last seen room temperature sensor entity id. Should be datetime sensor", "external_temperature_sensor_entity_id": "Outdoor temperature sensor entity id. Not used if central configuration is selected" } }, @@ -269,7 +272,8 @@ "data": { "name": "Name", "thermostat_type": "Thermostat type", - "temperature_sensor_entity_id": "Room temperature sensor entity id", + "temperature_sensor_entity_id": "Room temperature", + "last_seen_temperature_sensor_entity_id": "Last seen room temperature datetime", "external_temperature_sensor_entity_id": "Outdoor temperature sensor entity id", "cycle_min": "Cycle duration (minutes)", "temp_min": "Minimum temperature allowed", @@ -281,6 +285,8 @@ "used_by_controls_central_boiler": "Used by central boiler. Check if this VTherm should have control on the central boiler" }, "data_description": { + "temperature_sensor_entity_id": "Room temperature sensor entity id", + "last_seen_temperature_sensor_entity_id": "Last seen room temperature sensor entity id. Should be datetime sensor", "external_temperature_sensor_entity_id": "Outdoor temperature sensor entity id. Not used if central configuration is selected" } }, @@ -572,4 +578,4 @@ } } } -} +} \ No newline at end of file diff --git a/custom_components/versatile_thermostat/translations/en.json b/custom_components/versatile_thermostat/translations/en.json index 5a8ee9a..e6a2275 100644 --- a/custom_components/versatile_thermostat/translations/en.json +++ b/custom_components/versatile_thermostat/translations/en.json @@ -37,7 +37,8 @@ "data": { "name": "Name", "thermostat_type": "Thermostat type", - "temperature_sensor_entity_id": "Room temperature sensor entity id", + "temperature_sensor_entity_id": "Room temperature", + "last_seen_temperature_sensor_entity_id": "Last seen room temperature datetime", "external_temperature_sensor_entity_id": "Outdoor temperature sensor entity id", "cycle_min": "Cycle duration (minutes)", "temp_min": "Minimum temperature allowed", @@ -49,6 +50,8 @@ "used_by_controls_central_boiler": "Used by central boiler. Check if this VTherm should have control on the central boiler" }, "data_description": { + "temperature_sensor_entity_id": "Room temperature sensor entity id", + "last_seen_temperature_sensor_entity_id": "Last seen room temperature sensor entity id. Should be datetime sensor", "external_temperature_sensor_entity_id": "Outdoor temperature sensor entity id. Not used if central configuration is selected" } }, @@ -269,7 +272,8 @@ "data": { "name": "Name", "thermostat_type": "Thermostat type", - "temperature_sensor_entity_id": "Room temperature sensor entity id", + "temperature_sensor_entity_id": "Room temperature", + "last_seen_temperature_sensor_entity_id": "Last seen room temperature datetime", "external_temperature_sensor_entity_id": "Outdoor temperature sensor entity id", "cycle_min": "Cycle duration (minutes)", "temp_min": "Minimum temperature allowed", @@ -281,6 +285,8 @@ "used_by_controls_central_boiler": "Used by central boiler. Check if this VTherm should have control on the central boiler" }, "data_description": { + "temperature_sensor_entity_id": "Room temperature sensor entity id", + "last_seen_temperature_sensor_entity_id": "Last seen room temperature sensor entity id. Should be datetime sensor", "external_temperature_sensor_entity_id": "Outdoor temperature sensor entity id. Not used if central configuration is selected" } }, @@ -572,4 +578,4 @@ } } } -} +} \ No newline at end of file diff --git a/custom_components/versatile_thermostat/translations/fr.json b/custom_components/versatile_thermostat/translations/fr.json index 11dfb04..ab42f6e 100644 --- a/custom_components/versatile_thermostat/translations/fr.json +++ b/custom_components/versatile_thermostat/translations/fr.json @@ -37,8 +37,9 @@ "data": { "name": "Nom", "thermostat_type": "Type de thermostat", - "temperature_sensor_entity_id": "Température sensor entity id", - "external_temperature_sensor_entity_id": "Température exterieure sensor entity id", + "temperature_sensor_entity_id": "Capteur de température", + "last_seen_temperature_sensor_entity_id": "Dernière vue capteur de température", + "external_temperature_sensor_entity_id": "Capteur de température exterieure", "cycle_min": "Durée du cycle (minutes)", "temp_min": "Température minimale permise", "temp_max": "Température maximale permise", @@ -49,7 +50,9 @@ "used_by_controls_central_boiler": "Utilisé par la chaudière centrale. Cochez si ce VTherm doit contrôler la chaudière centrale." }, "data_description": { - "external_temperature_sensor_entity_id": "Entity id du capteur de température extérieure." + "temperature_sensor_entity_id": "Id d'entité du capteur de température", + "last_seen_temperature_sensor_entity_id": "Id d'entité du capteur donnant la date et heure de dernière vue capteur de température. L'état doit être au format date heure (ex: 2024-03-31T17:07:03+00:00)", + "external_temperature_sensor_entity_id": "Entity id du capteur de température extérieure. Non utilisé si une configuration centrale est définie" } }, "features": { @@ -281,19 +284,22 @@ "data": { "name": "Nom", "thermostat_type": "Type de thermostat", - "temperature_sensor_entity_id": "Température sensor entity id", - "external_temperature_sensor_entity_id": "Température exterieure sensor entity id", + "temperature_sensor_entity_id": "Capteur de température", + "last_seen_temperature_sensor_entity_id": "Dernière vue capteur de température", + "external_temperature_sensor_entity_id": "Capteur de température exterieure", "cycle_min": "Durée du cycle (minutes)", "temp_min": "Température minimale permise", "temp_max": "Température maximale permise", "step_temperature": "Pas de température", "device_power": "Puissance de l'équipement", "use_central_mode": "Autoriser le controle par une entity centrale ('nécessite une config. centrale`). Cochez pour autoriser le contrôle du VTherm par la liste déroulante 'central_mode' de l'entité configuration centrale.", - "use_main_central_config": "Utiliser la configuration centrale supplémentaire. Cochez pour utiliser la configuration centrale supplémentaire (température externe, min, max, pas, ...).", + "use_main_central_config": "Utiliser la configuration centrale supplémentaire. Cochez pour utiliser la configuration centrale supplémentaire (température externe, min, max, pas, ...)", "used_by_controls_central_boiler": "Utilisé par la chaudière centrale. Cochez si ce VTherm doit contrôler la chaudière centrale." }, "data_description": { - "external_temperature_sensor_entity_id": "Entity id du capteur de température extérieure. N'est pas utilisé si la configuration centrale est utilisée." + "temperature_sensor_entity_id": "Id d'entité du capteur de température", + "last_seen_temperature_sensor_entity_id": "Id d'entité du capteur donnant la date et heure de dernière vue capteur de température. L'état doit être au format date heure (ex: 2024-03-31T17:07:03+00:00)", + "external_temperature_sensor_entity_id": "Entity id du capteur de température extérieure. Non utilisé si une configuration centrale est définie" } }, "features": { diff --git a/tests/commons.py b/tests/commons.py index 6ddec15..0768cd4 100644 --- a/tests/commons.py +++ b/tests/commons.py @@ -580,6 +580,31 @@ async def send_temperature_change_event( return dearm_window_auto +async def send_last_seen_temperature_change_event( + entity: BaseThermostat, date, sleep=True +): + """Sending a new last seen event simulating a change on last seen temperature sensor""" + _LOGGER.info( + "------- Testu: sending send_last_seen_temperature_change_event, date=%s on %s", + date, + entity, + ) + last_seen_event = Event( + EVENT_STATE_CHANGED, + { + "new_state": State( + entity_id=entity.entity_id, + state=date, + last_changed=date, + last_updated=date, + ) + }, + ) + await entity._async_last_seen_temperature_changed(last_seen_event) + if sleep: + await asyncio.sleep(0.1) + + async def send_ext_temperature_change_event( entity: BaseThermostat, new_temp, date, sleep=True ): diff --git a/tests/test_last_seen.py b/tests/test_last_seen.py new file mode 100644 index 0000000..2c2e01e --- /dev/null +++ b/tests/test_last_seen.py @@ -0,0 +1,139 @@ +# pylint: disable=wildcard-import, unused-wildcard-import, protected-access, unused-argument, line-too-long + +""" Test the Security featrure """ +from unittest.mock import patch, call +from datetime import timedelta, datetime +import logging + +from custom_components.versatile_thermostat.thermostat_climate import ( + ThermostatOverClimate, +) +from custom_components.versatile_thermostat.thermostat_switch import ( + ThermostatOverSwitch, +) +from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import + + +logging.getLogger().setLevel(logging.DEBUG) + + +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_last_seen_feature(hass: HomeAssistant, skip_hass_states_is_state): + """Test the last_ssen feature + 1. creates a thermostat and check that security is off + 2. activate security feature when date is expired + 3. change the last seen sensor + 4. check that security is off + """ + + tz = get_tz(hass) # pylint: disable=invalid-name + + entry = MockConfigEntry( + domain=DOMAIN, + title="TheOverSwitchMockName", + unique_id="uniqueId", + data={ + "name": "TheOverSwitchMockName", + "thermostat_type": "thermostat_over_switch", + "temperature_sensor_entity_id": "sensor.mock_temp_sensor", + "last_seen_temperature_sensor_entity_id": "sensor.mock_last_seen_temp_sensor", + "external_temperature_sensor_entity_id": "sensor.mock_ext_temp_sensor", + "cycle_min": 5, + "temp_min": 15, + "temp_max": 30, + "frost_temp": 7, + "eco_temp": 17, + "comfort_temp": 18, + "boost_temp": 19, + "use_window_feature": False, + "use_motion_feature": False, + "use_power_feature": False, + "use_presence_feature": False, + "heater_entity_id": "switch.mock_switch", + "proportional_function": "tpi", + "tpi_coef_int": 0.3, + "tpi_coef_ext": 0.01, + "minimal_activation_delay": 30, + "security_delay_min": 5, # 5 minutes + "security_min_on_percent": 0.2, + "security_default_on_percent": 0.1, + }, + ) + + # 1. creates a thermostat and check that security is off + now: datetime = datetime.now(tz=tz) + entity: ThermostatOverSwitch = await create_thermostat( + hass, entry, "climate.theoverswitchmockname" + ) + assert entity + + assert entity._security_state is False + assert entity.preset_mode is not PRESET_SECURITY + assert entity._last_ext_temperature_measure is not None + assert entity._last_temperature_measure is not None + assert (entity._last_temperature_measure.astimezone(tz) - now).total_seconds() < 1 + assert ( + entity._last_ext_temperature_measure.astimezone(tz) - now + ).total_seconds() < 1 + + # set a preset + assert entity.preset_mode is PRESET_NONE + await entity.async_set_preset_mode(PRESET_COMFORT) + assert entity.preset_mode is PRESET_COMFORT + + # Turn On the thermostat + assert entity.hvac_mode == HVACMode.OFF + await entity.async_set_hvac_mode(HVACMode.HEAT) + assert entity.hvac_mode == HVACMode.HEAT + + # 2. activate security feature when date is expired + 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: + event_timestamp = now - timedelta(minutes=6) + + # set temperature to 15 so that on_percent will be > security_min_on_percent (0.2) + await send_temperature_change_event(entity, 15, event_timestamp) + assert entity.security_state is True + assert entity.preset_mode == PRESET_SECURITY + + assert mock_send_event.call_count == 3 + mock_send_event.assert_has_calls( + [ + call.send_event(EventType.PRESET_EVENT, {"preset": PRESET_SECURITY}), + call.send_event( + EventType.TEMPERATURE_EVENT, + { + "last_temperature_measure": event_timestamp.isoformat(), + "last_ext_temperature_measure": entity._last_ext_temperature_measure.isoformat(), + "current_temp": 15, + "current_ext_temp": None, + "target_temp": 18, + }, + ), + call.send_event( + EventType.SECURITY_EVENT, + { + "type": "start", + "last_temperature_measure": event_timestamp.isoformat(), + "last_ext_temperature_measure": entity._last_ext_temperature_measure.isoformat(), + "current_temp": 15, + "current_ext_temp": None, + "target_temp": 18, + }, + ), + ], + any_order=True, + ) + + assert mock_heater_on.call_count == 1 + + # 3. change the last seen sensor + event_timestamp = now - timedelta(minutes=4) + await send_last_seen_temperature_change_event(entity, event_timestamp) + assert entity.security_state is False + assert entity.preset_mode is PRESET_COMFORT + assert entity._last_temperature_measure == event_timestamp