diff --git a/custom_components/versatile_thermostat/binary_sensor.py b/custom_components/versatile_thermostat/binary_sensor.py index 3383992..364d754 100644 --- a/custom_components/versatile_thermostat/binary_sensor.py +++ b/custom_components/versatile_thermostat/binary_sensor.py @@ -64,10 +64,8 @@ class SecurityBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity): @callback async def async_my_climate_changed(self, event: Event = None): """Called when my climate have change""" - _LOGGER.debug( - "%s - climate state change", - event.origin.name if event and event.origin else None, - ) + _LOGGER.debug("%s - climate state change", self._attr_unique_id) + old_state = self._attr_is_on self._attr_is_on = self.my_climate.security_state is True if old_state != self._attr_is_on: @@ -99,10 +97,8 @@ class OverpoweringBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity @callback async def async_my_climate_changed(self, event: Event = None): """Called when my climate have change""" - _LOGGER.debug( - "%s - climate state change", - event.origin.name if event and event.origin else None, - ) + _LOGGER.debug("%s - climate state change", self._attr_unique_id) + old_state = self._attr_is_on self._attr_is_on = self.my_climate.overpowering_state is True if old_state != self._attr_is_on: @@ -134,12 +130,13 @@ class WindowBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity): @callback async def async_my_climate_changed(self, event: Event = None): """Called when my climate have change""" - _LOGGER.debug( - "%s - climate state change", - event.origin.name if event and event.origin else None, - ) + _LOGGER.debug("%s - climate state change", self._attr_unique_id) + old_state = self._attr_is_on - self._attr_is_on = self.my_climate.window_state == STATE_ON + self._attr_is_on = ( + self.my_climate.window_state == STATE_ON + or self.my_climate.window_auto_state == STATE_ON + ) if old_state != self._attr_is_on: self.async_write_ha_state() return @@ -151,7 +148,10 @@ class WindowBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity): @property def icon(self) -> str | None: if self._attr_is_on: - return "mdi:window-open-variant" + if self.my_climate.window_state == STATE_ON: + return "mdi:window-open-variant" + else: + return "mdi:window-open" else: return "mdi:window-closed-variant" @@ -169,10 +169,7 @@ class MotionBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity): @callback async def async_my_climate_changed(self, event: Event = None): """Called when my climate have change""" - _LOGGER.debug( - "%s - climate state change", - event.origin.name if event and event.origin else None, - ) + _LOGGER.debug("%s - climate state change", self._attr_unique_id) old_state = self._attr_is_on self._attr_is_on = self.my_climate.motion_state == STATE_ON if old_state != self._attr_is_on: @@ -205,10 +202,7 @@ class PresenceBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity): async def async_my_climate_changed(self, event: Event = None): """Called when my climate have change""" - _LOGGER.debug( - "%s - climate state change", - event.origin.name if event and event.origin else None, - ) + _LOGGER.debug("%s - climate state change", self._attr_unique_id) old_state = self._attr_is_on self._attr_is_on = self.my_climate.presence_state == STATE_ON if old_state != self._attr_is_on: diff --git a/custom_components/versatile_thermostat/climate.py b/custom_components/versatile_thermostat/climate.py index 1723356..6b295e5 100644 --- a/custom_components/versatile_thermostat/climate.py +++ b/custom_components/versatile_thermostat/climate.py @@ -103,6 +103,9 @@ from .const import ( CONF_MAX_POWER_SENSOR, CONF_WINDOW_SENSOR, CONF_WINDOW_DELAY, + CONF_WINDOW_AUTO_CLOSE_THRESHOLD, + CONF_WINDOW_AUTO_OPEN_THRESHOLD, + CONF_WINDOW_AUTO_MAX_DURATION, CONF_MOTION_SENSOR, CONF_MOTION_DELAY, CONF_MOTION_PRESET, @@ -144,6 +147,7 @@ from .const import ( ) from .prop_algorithm import PropAlgorithm +from .open_window_algorithm import WindowOpenDetectionAlgorithm _LOGGER = logging.getLogger(__name__) @@ -216,6 +220,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): _motion_state: bool _presence_state: bool _security_state: bool + _window_auto_state: bool def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: """Initialize the thermostat.""" @@ -270,6 +275,13 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): self._underlying_climate_start_hvac_action_date = None self._underlying_climate_delta_t = 0 + self._window_auto_open_threshold = 0 + self._window_auto_close_threshold = 0 + self._window_auto_max_duration = 0 + self._window_auto_state = False + self._window_auto_on = False + self._window_auto_algo = None + self._current_tz = dt_util.get_time_zone(self._hass.config.time_zone) self.post_init(entry_infos) @@ -340,6 +352,27 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): self._max_power_sensor_entity_id = entry_infos.get(CONF_MAX_POWER_SENSOR) self._window_sensor_entity_id = entry_infos.get(CONF_WINDOW_SENSOR) self._window_delay_sec = entry_infos.get(CONF_WINDOW_DELAY) + + self._window_auto_open_threshold = entry_infos.get( + CONF_WINDOW_AUTO_OPEN_THRESHOLD + ) + self._window_auto_close_threshold = entry_infos.get( + CONF_WINDOW_AUTO_CLOSE_THRESHOLD + ) + self._window_auto_max_duration = entry_infos.get(CONF_WINDOW_AUTO_MAX_DURATION) + self._window_auto_on = ( + self._window_auto_open_threshold is not None + and self._window_auto_open_threshold > 0.0 + and self._window_auto_close_threshold is not None + and self._window_auto_max_duration is not None + and self._window_auto_max_duration > 0 + ) + self._window_auto_state = False + self._window_auto_algo = WindowOpenDetectionAlgorithm( + alert_threshold=self._window_auto_open_threshold, + end_alert_threshold=self._window_auto_close_threshold, + ) + self._motion_sensor_entity_id = entry_infos.get(CONF_MOTION_SENSOR) self._motion_delay_sec = entry_infos.get(CONF_MOTION_DELAY) self._motion_preset = entry_infos.get(CONF_MOTION_PRESET) @@ -1044,6 +1077,11 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): """Get the window_state""" return self._window_state + @property + def window_auto_state(self) -> bool | None: + """Get the window_auto_state""" + return STATE_ON if self._window_auto_state else STATE_OFF + @property def security_state(self) -> bool | None: """Get the security_state""" @@ -1074,6 +1112,41 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): """Get the last external temperature datetime""" return self._last_ext_temperature_mesure + @property + def preset_mode(self) -> str | None: + """Return the current preset mode, e.g., home, away, temp. + + Requires ClimateEntityFeature.PRESET_MODE. + """ + return self._attr_preset_mode + + @property + def preset_modes(self) -> list[str] | None: + """Return a list of available preset modes. + + Requires ClimateEntityFeature.PRESET_MODE. + """ + return self._attr_preset_modes + + @property + def is_over_climate(self) -> bool | None: + """return True is the thermostat is over a climate + or False is over switch""" + return self._is_over_climate + + @property + def last_temperature_slope(self) -> float | None: + """Return the last temperature slope curve if any""" + if not self._window_auto_algo: + return None + else: + return self._window_auto_algo.last_slope + + @property + def is_window_auto_enabled(self) -> bool: + """True if the Window auto feature is enabled""" + return self._window_auto_on + def turn_aux_heat_on(self) -> None: """Turn auxiliary heater on.""" if self._is_over_climate and self._underlying_climate: @@ -1102,28 +1175,6 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): raise NotImplementedError() - @property - def preset_mode(self) -> str | None: - """Return the current preset mode, e.g., home, away, temp. - - Requires ClimateEntityFeature.PRESET_MODE. - """ - return self._attr_preset_mode - - @property - def preset_modes(self) -> list[str] | None: - """Return a list of available preset modes. - - Requires ClimateEntityFeature.PRESET_MODE. - """ - return self._attr_preset_modes - - @property - def is_over_climate(self) -> bool | None: - """return True is the thermostat is over a climate - or False is over switch""" - return self._is_over_climate - async def async_set_hvac_mode(self, hvac_mode): """Set new target hvac mode.""" _LOGGER.info("%s - Set hvac mode: %s", self, hvac_mode) @@ -1621,6 +1672,9 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): if self._security_state: await self.check_security() + # check window_auto + await self._async_manage_window_auto() + except ValueError as ex: _LOGGER.error("Unable to update temperature from sensor: %s", ex) @@ -1815,6 +1869,80 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): HA_DOMAIN, SERVICE_TURN_OFF, data, context=self._context ) + async def _async_manage_window_auto(self): + """The management of the window auto feature""" + + async def dearm_window_auto(_): + """Callback that will be called after end of WINDOW_AUTO_MAX_DURATION""" + _LOGGER.info("Unset window auto because MAX_DURATION is exceeded") + await deactivate_window_auto(auto=True) + + async def deactivate_window_auto(auto=False): + """Deactivation of the Window auto state""" + _LOGGER.warning( + "%s - End auto detection of open window slope=%.3f", self, slope + ) + # Send an event + cause = "max duration expiration" if auto else "end of slope alert" + self.send_event( + EventType.WINDOW_AUTO_EVENT, + {"type": "end", "cause": cause, "curve_slope": slope}, + ) + # Set attributes + self._window_auto_state = False + await self.restore_hvac_mode() + + if self._window_call_cancel: + self._window_call_cancel() + self._window_call_cancel = None + + if not self._window_auto_algo: + return + + slope = self._window_auto_algo.add_temp_measurement( + temperature=self._cur_temp, datetime_measure=self._last_temperature_mesure + ) + _LOGGER.debug( + "%s - Window auto is on, check the alert. last slope is %.3f", + self, + slope if slope is not None else 0.0, + ) + if ( + self._window_auto_algo.is_window_open_detected() + and self._window_auto_state is False + ): + _LOGGER.warning( + "%s - Start auto detection of open window slope=%.3f", self, slope + ) + # Send an event + self.send_event( + EventType.WINDOW_AUTO_EVENT, + {"type": "start", "cause": "slope alert", "curve_slope": slope}, + ) + # Set attributes + self._window_auto_state = True + self.save_hvac_mode() + await self.async_set_hvac_mode(HVACMode.OFF) + + # Arm the end trigger + if self._window_call_cancel: + self._window_call_cancel() + self._window_call_cancel = None + self._window_call_cancel = async_call_later( + self.hass, + timedelta(minutes=self._window_auto_max_duration), + dearm_window_auto, + ) + + elif ( + self._window_auto_algo.is_window_close_detected() + and self._window_auto_state is True + ): + await deactivate_window_auto(False) + + # For testing purpose we need to return the inner function + return dearm_window_auto + def save_preset_mode(self): """Save the current preset mode to be restored later We never save a hidden preset mode diff --git a/custom_components/versatile_thermostat/commons.py b/custom_components/versatile_thermostat/commons.py index 7d58aa9..df349f9 100644 --- a/custom_components/versatile_thermostat/commons.py +++ b/custom_components/versatile_thermostat/commons.py @@ -58,7 +58,7 @@ class VersatileThermostatBaseEntity(Entity): try: component: EntityComponent[ClimateEntity] = self.hass.data[CLIMATE_DOMAIN] for entity in component.entities: - _LOGGER.debug("Device_info is %s", entity.device_info) + # _LOGGER.debug("Device_info is %s", entity.device_info) if entity.device_info == self.device_info: _LOGGER.debug("Found %s!", entity) return entity diff --git a/custom_components/versatile_thermostat/config_flow.py b/custom_components/versatile_thermostat/config_flow.py index a21da09..ae2017f 100644 --- a/custom_components/versatile_thermostat/config_flow.py +++ b/custom_components/versatile_thermostat/config_flow.py @@ -47,6 +47,9 @@ from .const import ( CONF_MAX_POWER_SENSOR, CONF_WINDOW_SENSOR, CONF_WINDOW_DELAY, + CONF_WINDOW_AUTO_MAX_DURATION, + CONF_WINDOW_AUTO_CLOSE_THRESHOLD, + CONF_WINDOW_AUTO_OPEN_THRESHOLD, CONF_MOTION_SENSOR, CONF_MOTION_DELAY, CONF_MOTION_PRESET, @@ -79,6 +82,7 @@ from .const import ( CONF_USE_POWER_FEATURE, CONF_THERMOSTAT_TYPES, UnknownEntity, + WindowOpenDetectionMethod, ) _LOGGER = logging.getLogger(__name__) @@ -167,7 +171,9 @@ class VersatileThermostatBaseConfigFlow(FlowHandler): is_empty: bool = not bool(infos) # Fix features selection depending to infos self._infos[CONF_USE_WINDOW_FEATURE] = ( - is_empty or self._infos.get(CONF_WINDOW_SENSOR) is not None + is_empty + or self._infos.get(CONF_WINDOW_SENSOR) is not None + or self._infos.get(CONF_WINDOW_AUTO_OPEN_THRESHOLD) is not None ) self._infos[CONF_USE_MOTION_FEATURE] = ( is_empty or self._infos.get(CONF_MOTION_SENSOR) is not None @@ -195,12 +201,11 @@ class VersatileThermostatBaseConfigFlow(FlowHandler): domain=[SENSOR_DOMAIN, INPUT_NUMBER_DOMAIN] ), ), - # vol.In(temp_sensors), vol.Required(CONF_EXTERNAL_TEMP_SENSOR): selector.EntitySelector( selector.EntitySelectorConfig( domain=[SENSOR_DOMAIN, INPUT_NUMBER_DOMAIN] ), - ), # vol.In(temp_sensors), + ), vol.Required(CONF_CYCLE_MIN, default=5): cv.positive_int, vol.Required(CONF_TEMP_MIN, default=7): vol.Coerce(float), vol.Required(CONF_TEMP_MAX, default=35): vol.Coerce(float), @@ -218,7 +223,7 @@ class VersatileThermostatBaseConfigFlow(FlowHandler): selector.EntitySelectorConfig( domain=[SWITCH_DOMAIN, INPUT_BOOLEAN_DOMAIN] ), - ), # vol.In(switches), + ), vol.Required( CONF_PROP_FUNCTION, default=PROPORTIONAL_FUNCTION_TPI ): vol.In( @@ -233,7 +238,7 @@ class VersatileThermostatBaseConfigFlow(FlowHandler): { vol.Required(CONF_CLIMATE): selector.EntitySelector( selector.EntitySelectorConfig(domain=CLIMATE_DOMAIN), - ), # vol.In(climates), + ), } ) @@ -257,8 +262,11 @@ class VersatileThermostatBaseConfigFlow(FlowHandler): selector.EntitySelectorConfig( domain=[BINARY_SENSOR_DOMAIN, INPUT_BOOLEAN_DOMAIN] ), - ), # vol.In(window_sensors), + ), vol.Optional(CONF_WINDOW_DELAY, default=30): cv.positive_int, + vol.Optional(CONF_WINDOW_AUTO_OPEN_THRESHOLD): vol.Coerce(float), + vol.Optional(CONF_WINDOW_AUTO_CLOSE_THRESHOLD): vol.Coerce(float), + vol.Optional(CONF_WINDOW_AUTO_MAX_DURATION): cv.positive_int, } ) @@ -268,7 +276,7 @@ class VersatileThermostatBaseConfigFlow(FlowHandler): selector.EntitySelectorConfig( domain=[BINARY_SENSOR_DOMAIN, INPUT_BOOLEAN_DOMAIN] ), - ), # vol.In(window_sensors), + ), vol.Optional(CONF_MOTION_DELAY, default=30): cv.positive_int, vol.Optional(CONF_MOTION_PRESET, default="comfort"): vol.In( CONF_PRESETS_SELECTIONABLE @@ -285,12 +293,12 @@ class VersatileThermostatBaseConfigFlow(FlowHandler): selector.EntitySelectorConfig( domain=[SENSOR_DOMAIN, INPUT_NUMBER_DOMAIN] ), - ), # vol.In(power_sensors), + ), vol.Optional(CONF_MAX_POWER_SENSOR): selector.EntitySelector( selector.EntitySelectorConfig( domain=[SENSOR_DOMAIN, INPUT_NUMBER_DOMAIN] ), - ), # vol.In(power_sensors), + ), vol.Optional(CONF_PRESET_POWER, default="13"): vol.Coerce(float), } ) @@ -305,7 +313,7 @@ class VersatileThermostatBaseConfigFlow(FlowHandler): INPUT_BOOLEAN_DOMAIN, ] ), - ), # vol.In(presence_sensors), + ), } ).extend( { @@ -357,6 +365,19 @@ class VersatileThermostatBaseConfigFlow(FlowHandler): ) raise UnknownEntity(conf) + # Check that only one window feature is used + ws = data.get(CONF_WINDOW_SENSOR) # pylint: disable=invalid-name + waot = data.get(CONF_WINDOW_AUTO_OPEN_THRESHOLD) + wact = data.get(CONF_WINDOW_AUTO_CLOSE_THRESHOLD) + wamd = data.get(CONF_WINDOW_AUTO_MAX_DURATION) + if ws is not None and ( + waot is not None or wact is not None or wamd is not None + ): + _LOGGER.error( + "Only one window detection method should be used. Use window_sensor or auto window open detection but not both" + ) + raise WindowOpenDetectionMethod(CONF_WINDOW_SENSOR) + def merge_user_input(self, data_schema: vol.Schema, user_input: dict): """For each schema entry not in user_input, set or remove values in infos""" self._infos.update(user_input) @@ -387,6 +408,8 @@ class VersatileThermostatBaseConfigFlow(FlowHandler): await self.validate_input(user_input) except UnknownEntity as err: errors[str(err)] = "unknown_entity" + except WindowOpenDetectionMethod as err: + errors[str(err)] = "window_open_detection_method" except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" diff --git a/custom_components/versatile_thermostat/const.py b/custom_components/versatile_thermostat/const.py index 3038752..d8326fb 100644 --- a/custom_components/versatile_thermostat/const.py +++ b/custom_components/versatile_thermostat/const.py @@ -61,6 +61,9 @@ CONF_USE_WINDOW_FEATURE = "use_window_feature" CONF_USE_MOTION_FEATURE = "use_motion_feature" CONF_USE_PRESENCE_FEATURE = "use_presence_feature" CONF_USE_POWER_FEATURE = "use_power_feature" +CONF_WINDOW_AUTO_OPEN_THRESHOLD = "window_auto_open_threshold" +CONF_WINDOW_AUTO_CLOSE_THRESHOLD = "window_auto_close_threshold" +CONF_WINDOW_AUTO_MAX_DURATION = "window_auto_max_duration" CONF_PRESETS = { p: f"{p}_temp" @@ -97,6 +100,9 @@ ALL_CONF = ( CONF_MAX_POWER_SENSOR, CONF_WINDOW_SENSOR, CONF_WINDOW_DELAY, + CONF_WINDOW_AUTO_OPEN_THRESHOLD, + CONF_WINDOW_AUTO_CLOSE_THRESHOLD, + CONF_WINDOW_AUTO_MAX_DURATION, CONF_MOTION_SENSOR, CONF_MOTION_DELAY, CONF_MOTION_PRESET, @@ -153,7 +159,12 @@ class EventType(Enum): TEMPERATURE_EVENT: str = "versatile_thermostat_temperature_event" HVAC_MODE_EVENT: str = "versatile_thermostat_hvac_mode_event" PRESET_EVENT: str = "versatile_thermostat_preset_event" + WINDOW_AUTO_EVENT: str = "versatile_thermostat_window_auto_event" class UnknownEntity(HomeAssistantError): """Error to indicate there is an unknown entity_id given.""" + + +class WindowOpenDetectionMethod(HomeAssistantError): + """Error to indicate there is an error in the window open detection method given.""" diff --git a/custom_components/versatile_thermostat/open_window_algorithm.py b/custom_components/versatile_thermostat/open_window_algorithm.py new file mode 100644 index 0000000..e18eb34 --- /dev/null +++ b/custom_components/versatile_thermostat/open_window_algorithm.py @@ -0,0 +1,102 @@ +""" This file implements the Open Window by temperature algorithm + This algo works the following way: + - each time a new temperature is measured + - calculate the slope of the temperature curve. For this we calculate the slope(t) = 1/2 slope(t-1) + 1/2 * dTemp / dt + - if the slope is lower than a threshold the window opens alert is notified + - if the slope regain positive the end of the window open alert is notified +""" + +import logging +from datetime import datetime + +_LOGGER = logging.getLogger(__name__) + + +class WindowOpenDetectionAlgorithm: + """The class that implements the algorithm listed above""" + + _alert_threshold: float + _end_alert_threshold: float + _last_slope: float + _last_datetime: datetime + _last_temperature: float + + def __init__(self, alert_threshold, end_alert_threshold) -> None: + """Initalize a new algorithm with the both threshold""" + self._alert_threshold = alert_threshold + self._end_alert_threshold = end_alert_threshold + self._last_slope = None + self._last_datetime = None + + def add_temp_measurement( + self, temperature: float, datetime_measure: datetime + ) -> float: + """Add a new temperature measurement + returns the last slope + """ + if self._last_datetime is None or self._last_temperature is None: + _LOGGER.debug("First initialisation") + self._last_datetime = datetime_measure + self._last_temperature = temperature + return None + + _LOGGER.debug( + "We are already initialized slope=%s last_temp=%0.2f", + self._last_slope, + self._last_temperature, + ) + delta_t = float((datetime_measure - self._last_datetime).total_seconds() / 60.0) + if delta_t <= 0: + _LOGGER.warning( + "Delta t is 0 or < 0 which should be not possible. We stop here the open window detection algorithm" + ) + return None + + delta_temp = float(temperature - self._last_temperature) + new_slope = delta_temp / delta_t + + lspe = self._last_slope + if self._last_slope is None: + self._last_slope = new_slope + else: + self._last_slope = (0.5 * self._last_slope) + (0.5 * new_slope) + + self._last_datetime = datetime_measure + self._last_temperature = temperature + + _LOGGER.debug( + "delta_t=%.3f delta_temp=%.3f new_slope=%.3f last_slope=%s slope=%.3f", + delta_t, + delta_temp, + new_slope, + lspe, + self._last_slope, + ) + return self._last_slope + + def is_window_open_detected(self) -> bool: + """True if the last calculated slope is under (because negative value) the _alert_threshold""" + if self._alert_threshold is None: + return False + + return ( + self._last_slope < -self._alert_threshold + if self._last_slope is not None + else False + ) + + def is_window_close_detected(self) -> bool: + """True if the last calculated slope is above (cause negative) the _end_alert_threshold""" + if self._end_alert_threshold is None: + return False + + return ( + self._last_slope >= self._end_alert_threshold + if self._last_slope is not None + else False + ) + + @property + def last_slope(self) -> float: + """Return the last calculated slope""" + return self._last_slope diff --git a/custom_components/versatile_thermostat/requirements_test.txt b/custom_components/versatile_thermostat/requirements_test.txt index 4a1fb9a..17e1f2b 100644 --- a/custom_components/versatile_thermostat/requirements_test.txt +++ b/custom_components/versatile_thermostat/requirements_test.txt @@ -1,3 +1,4 @@ # -r requirements_dev.txt # aiodiscover +ulid_transform pytest-homeassistant-custom-component \ No newline at end of file diff --git a/custom_components/versatile_thermostat/sensor.py b/custom_components/versatile_thermostat/sensor.py index 670474e..f3226de 100644 --- a/custom_components/versatile_thermostat/sensor.py +++ b/custom_components/versatile_thermostat/sensor.py @@ -46,6 +46,7 @@ async def async_setup_entry( entities = [ LastTemperatureSensor(hass, unique_id, name, entry.data), LastExtTemperatureSensor(hass, unique_id, name, entry.data), + TemperatureSlopeSensor(hass, unique_id, name, entry.data), ] if entry.data.get(CONF_DEVICE_POWER): entities.append(EnergySensor(hass, unique_id, name, entry.data)) @@ -72,10 +73,7 @@ class EnergySensor(VersatileThermostatBaseEntity, SensorEntity): @callback async def async_my_climate_changed(self, event: Event = None): """Called when my climate have change""" - _LOGGER.debug( - "%s - climate state change", - event.origin.name if event and event.origin else None, - ) + _LOGGER.debug("%s - climate state change", self._attr_unique_id) if math.isnan(self.my_climate.total_energy) or math.isinf( self.my_climate.total_energy @@ -130,10 +128,7 @@ class MeanPowerSensor(VersatileThermostatBaseEntity, SensorEntity): @callback async def async_my_climate_changed(self, event: Event = None): """Called when my climate have change""" - _LOGGER.debug( - "%s - climate state change", - event.origin.name if event and event.origin else None, - ) + _LOGGER.debug("%s - climate state change", self._attr_unique_id) if math.isnan(float(self.my_climate.mean_cycle_power)) or math.isinf( self.my_climate.mean_cycle_power @@ -190,10 +185,7 @@ class OnPercentSensor(VersatileThermostatBaseEntity, SensorEntity): @callback async def async_my_climate_changed(self, event: Event = None): """Called when my climate have change""" - _LOGGER.debug( - "%s - climate state change", - event.origin.name if event and event.origin else None, - ) + _LOGGER.debug("%s - climate state change", self._attr_unique_id) on_percent = ( float(self.my_climate.proportional_algorithm.on_percent) @@ -245,10 +237,7 @@ class OnTimeSensor(VersatileThermostatBaseEntity, SensorEntity): @callback async def async_my_climate_changed(self, event: Event = None): """Called when my climate have change""" - _LOGGER.debug( - "%s - climate state change", - event.origin.name if event and event.origin else None, - ) + _LOGGER.debug("%s - climate state change", self._attr_unique_id) on_time = ( float(self.my_climate.proportional_algorithm.on_time_sec) @@ -293,10 +282,7 @@ class OffTimeSensor(VersatileThermostatBaseEntity, SensorEntity): @callback async def async_my_climate_changed(self, event: Event = None): """Called when my climate have change""" - _LOGGER.debug( - "%s - climate state change", - event.origin.name if event and event.origin else None, - ) + _LOGGER.debug("%s - climate state change", self._attr_unique_id) off_time = ( float(self.my_climate.proportional_algorithm.off_time_sec) @@ -341,10 +327,7 @@ class LastTemperatureSensor(VersatileThermostatBaseEntity, SensorEntity): @callback async def async_my_climate_changed(self, event: Event = None): """Called when my climate have change""" - _LOGGER.debug( - "%s - climate state change", - event.origin.name if event and event.origin else None, - ) + _LOGGER.debug("%s - climate state change", self._attr_unique_id) old_state = self._attr_native_value self._attr_native_value = self.my_climate.last_temperature_mesure @@ -373,10 +356,7 @@ class LastExtTemperatureSensor(VersatileThermostatBaseEntity, SensorEntity): @callback async def async_my_climate_changed(self, event: Event = None): """Called when my climate have change""" - _LOGGER.debug( - "%s - climate state change", - event.origin.name if event and event.origin else None, - ) + _LOGGER.debug("%s - climate state change", self._attr_unique_id) old_state = self._attr_native_value self._attr_native_value = self.my_climate.last_ext_temperature_mesure @@ -391,3 +371,56 @@ class LastExtTemperatureSensor(VersatileThermostatBaseEntity, SensorEntity): @property def device_class(self) -> SensorDeviceClass | None: return SensorDeviceClass.TIMESTAMP + + +class TemperatureSlopeSensor(VersatileThermostatBaseEntity, SensorEntity): + """Representation of a sensor which exposes the temperature slope curve""" + + def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: + """Initialize the slope sensor""" + super().__init__(hass, unique_id, entry_infos.get(CONF_NAME)) + self._attr_name = "Temperature slope" + self._attr_unique_id = f"{self._device_name}_temperature_slope" + + @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) + + last_slope = self.my_climate.last_temperature_slope + if last_slope is None: + return + + if math.isnan(last_slope) or math.isinf(last_slope): + raise ValueError(f"Sensor has illegal state {last_slope}") + + old_state = self._attr_native_value + self._attr_native_value = round(last_slope, self.suggested_display_precision) + if old_state != self._attr_native_value: + self.async_write_ha_state() + return + + @property + def icon(self) -> str | None: + if self._attr_native_value is None or self._attr_native_value == 0: + return "mdi:thermometer" + elif self._attr_native_value > 0: + return "mdi:thermometer-chevron-up" + else: + return "mdi:thermometer-chevron-down" + + @property + def state_class(self) -> SensorStateClass | None: + return SensorStateClass.MEASUREMENT + + @property + def native_unit_of_measurement(self) -> str | None: + if not self.my_climate: + return None + + return self.my_climate.temperature_unit + "/min" + + @property + def suggested_display_precision(self) -> int | None: + """Return the suggested number of decimal digits for display.""" + return 2 diff --git a/custom_components/versatile_thermostat/strings.json b/custom_components/versatile_thermostat/strings.json index 4c866fd..c4cb09b 100644 --- a/custom_components/versatile_thermostat/strings.json +++ b/custom_components/versatile_thermostat/strings.json @@ -49,10 +49,19 @@ }, "window": { "title": "Window management", - "description": "Open window management.\nLeave corresponding entity_id empty if not used.", + "description": "Open window management.\nLeave corresponding entity_id empty if not used\nYou can also configure automatic window open detection based on temperature decrease", "data": { "window_sensor_entity_id": "Window sensor entity id", - "window_delay": "Window delay (seconds)" + "window_delay": "Window sensor delay (seconds)", + "window_auto_open_threshold": "Temperature decrease threshold for automatic window open detection (in °/min)", + "window_auto_close_threshold": "Temperature increase threshold for end of automatic detection (in °/min)", + "window_auto_max_duration": "Maximum duration of automatic window open detection (in min)" + }, + "data_description": { + "window_sensor_entity_id": "Leave empty if no window sensor should be use", + "window_auto_open_threshold": "Recommended value: 0.5. Leave empty if automatic window open detection is not use", + "window_auto_close_threshold": "Recommended value: 0. Leave empty if automatic window open detection is not use", + "window_auto_max_duration": "Recommended value: 60 (one hour). Leave empty if automatic window open detection is not use" } }, "motion": { @@ -97,7 +106,8 @@ }, "error": { "unknown": "Unexpected error", - "unknown_entity": "Unknown entity id" + "unknown_entity": "Unknown entity id", + "window_open_detection_method": "Only one window open detection method should be used. Use sensor or automatic detection through temperature threshold but not both" }, "abort": { "already_configured": "Device is already configured" @@ -151,11 +161,20 @@ } }, "window": { - "title": "Window management", - "description": "Open window management.\nLeave corresponding entity_id empty if not used.", + "title": "[%key:component::versatile_thermostat::config::step::window::title%]", + "description": "[%key:component::versatile_thermostat::config::step::window::description%]", "data": { - "window_sensor_entity_id": "Window sensor entity id", - "window_delay": "Window delay (seconds)" + "window_sensor_entity_id": "[%key:component::versatile_thermostat::config::step::window::data::window_sensor_entity_id%]", + "window_delay": "[%key:component::versatile_thermostat::config::step::window::data::window_delay%]", + "window_auto_open_threshold": "[%key:component::versatile_thermostat::config::step::window::data::window_auto_open_threshold%]", + "window_auto_close_threshold": "[%key:component::versatile_thermostat::config::step::window::data::window_auto_close_threshold%]", + "window_auto_max_duration": "[%key:component::versatile_thermostat::config::step::window::data::window_auto_max_duration%]" + }, + "data_description": { + "window_sensor_entity_id": "[%key:component::versatile_thermostat::config::step::window::data_description::window_sensor_entity_id%]", + "window_auto_open_threshold": "[%key:component::versatile_thermostat::config::step::window::data_description::window_auto_open_threshold%]", + "window_auto_close_threshold": "[%key:component::versatile_thermostat::config::step::window::data_description::window_auto_close_threshold%]", + "window_auto_max_duration": "[%key:component::versatile_thermostat::config::step::window::data_description::window_auto_max_duration%]" } }, "motion": { @@ -199,11 +218,12 @@ } }, "error": { - "unknown": "Unexpected error", - "unknown_entity": "Unknown entity id" + "unknown": "[%key:component::versatile_thermostat::config::error::unknown%]", + "unknown_entity": "[%key:component::versatile_thermostat::config::error::unknown_entity%]", + "window_open_detection_method": "[%key:component::versatile_thermostat::config::error::window_open_detection_method%]" }, "abort": { - "already_configured": "Device is already configured" + "already_configured": "[%key:component::versatile_thermostat::config::abort::already_configured%]" } }, "selector": { diff --git a/custom_components/versatile_thermostat/tests/test_open_window_algo.py b/custom_components/versatile_thermostat/tests/test_open_window_algo.py new file mode 100644 index 0000000..4fdb4d1 --- /dev/null +++ b/custom_components/versatile_thermostat/tests/test_open_window_algo.py @@ -0,0 +1,134 @@ +""" Test the OpenWindow algorithm """ + +from datetime import datetime, timedelta +from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import +from ..open_window_algorithm import WindowOpenDetectionAlgorithm + + +async def test_open_window_algo( + hass: HomeAssistant, + skip_hass_states_is_state, +): + """Tests the Algo""" + + the_algo = WindowOpenDetectionAlgorithm(1.0, 0.0) + assert the_algo.last_slope is None + + tz = get_tz(hass) + now = datetime.now(tz) + + event_timestamp = now - timedelta(minutes=5) + last_slope = the_algo.add_temp_measurement( + temperature=10, datetime_measure=event_timestamp + ) + + # We need at least 2 measurement + assert last_slope is None + assert the_algo.last_slope is None + assert the_algo.is_window_close_detected() is False + assert the_algo.is_window_open_detected() is False + + event_timestamp = now - timedelta(minutes=4) + last_slope = the_algo.add_temp_measurement( + temperature=10, datetime_measure=event_timestamp + ) + + # No slope because same temperature + assert last_slope == 0 + assert the_algo.last_slope == 0 + assert the_algo.is_window_close_detected() is True + assert the_algo.is_window_open_detected() is False + + event_timestamp = now - timedelta(minutes=3) + last_slope = the_algo.add_temp_measurement( + temperature=9, datetime_measure=event_timestamp + ) + + # A slope is calculated + assert last_slope == -0.5 + assert the_algo.last_slope == -0.5 + assert the_algo.is_window_close_detected() is False + assert the_algo.is_window_open_detected() is False + + # A new temperature with 2 degre less + event_timestamp = now - timedelta(minutes=2) + last_slope = the_algo.add_temp_measurement( + temperature=7, datetime_measure=event_timestamp + ) + + # A slope is calculated + assert last_slope == -0.5 / 2.0 - 2.0 / 2.0 + assert the_algo.last_slope == -1.25 + assert the_algo.is_window_close_detected() is False + assert the_algo.is_window_open_detected() is True + + # A new temperature with 1 degre less + event_timestamp = now - timedelta(minutes=1) + last_slope = the_algo.add_temp_measurement( + temperature=6, datetime_measure=event_timestamp + ) + + # A slope is calculated + assert last_slope == -1.25 / 2 - 1.0 / 2.0 + assert the_algo.last_slope == -1.125 + assert the_algo.is_window_close_detected() is False + assert the_algo.is_window_open_detected() is True + + # A new temperature with 0 degre less + event_timestamp = now - timedelta(minutes=0) + last_slope = the_algo.add_temp_measurement( + temperature=6, datetime_measure=event_timestamp + ) + + # A slope is calculated + assert last_slope == -1.125 / 2 + assert the_algo.last_slope == -1.125 / 2 + assert the_algo.is_window_close_detected() is False + assert the_algo.is_window_open_detected() is False + + # A new temperature with 1 degre more + event_timestamp = now + timedelta(minutes=1) + last_slope = the_algo.add_temp_measurement( + temperature=7, datetime_measure=event_timestamp + ) + + # A slope is calculated + assert last_slope == -1.125 / 4 + 0.5 + assert the_algo.last_slope == 0.21875 + assert the_algo.is_window_close_detected() is True + assert the_algo.is_window_open_detected() is False + + +async def test_open_window_algo_wrong( + hass: HomeAssistant, + skip_hass_states_is_state, +): + """Tests the Algo with wrong date""" + the_algo = WindowOpenDetectionAlgorithm(1.0, 0.0) + assert the_algo.last_slope is None + + tz = get_tz(hass) + now = datetime.now(tz) + + event_timestamp = now - timedelta(minutes=5) + last_slope = the_algo.add_temp_measurement( + temperature=10, datetime_measure=event_timestamp + ) + + # We need at least 2 measurement + assert last_slope is None + assert the_algo.last_slope is None + assert the_algo.is_window_close_detected() is False + assert the_algo.is_window_open_detected() is False + + # The next datetime_measurement cannot be in the past + event_timestamp = now - timedelta(minutes=6) + last_slope = the_algo.add_temp_measurement( + temperature=18, datetime_measure=event_timestamp + ) + + # No slope because same temperature + assert last_slope is None + assert the_algo.last_slope is None + assert the_algo.is_window_close_detected() is False + assert the_algo.is_window_open_detected() is False diff --git a/custom_components/versatile_thermostat/translations/en.json b/custom_components/versatile_thermostat/translations/en.json index 61980e0..c4cb09b 100644 --- a/custom_components/versatile_thermostat/translations/en.json +++ b/custom_components/versatile_thermostat/translations/en.json @@ -49,10 +49,19 @@ }, "window": { "title": "Window management", - "description": "Open window management.\nLeave corresponding entity_id empty if not used.", + "description": "Open window management.\nLeave corresponding entity_id empty if not used\nYou can also configure automatic window open detection based on temperature decrease", "data": { "window_sensor_entity_id": "Window sensor entity id", - "window_delay": "Window delay (seconds)" + "window_delay": "Window sensor delay (seconds)", + "window_auto_open_threshold": "Temperature decrease threshold for automatic window open detection (in °/min)", + "window_auto_close_threshold": "Temperature increase threshold for end of automatic detection (in °/min)", + "window_auto_max_duration": "Maximum duration of automatic window open detection (in min)" + }, + "data_description": { + "window_sensor_entity_id": "Leave empty if no window sensor should be use", + "window_auto_open_threshold": "Recommended value: 0.5. Leave empty if automatic window open detection is not use", + "window_auto_close_threshold": "Recommended value: 0. Leave empty if automatic window open detection is not use", + "window_auto_max_duration": "Recommended value: 60 (one hour). Leave empty if automatic window open detection is not use" } }, "motion": { @@ -97,7 +106,8 @@ }, "error": { "unknown": "Unexpected error", - "unknown_entity": "Unknown entity id" + "unknown_entity": "Unknown entity id", + "window_open_detection_method": "Only one window open detection method should be used. Use sensor or automatic detection through temperature threshold but not both" }, "abort": { "already_configured": "Device is already configured" @@ -151,11 +161,20 @@ } }, "window": { - "title": "Window management", - "description": "Open window management.\nLeave corresponding entity_id empty if not used.", + "title": "[%key:component::versatile_thermostat::config::step::window::title%]", + "description": "[%key:component::versatile_thermostat::config::step::window::description%]", "data": { - "window_sensor_entity_id": "Window sensor entity id", - "window_delay": "Window delay (seconds)" + "window_sensor_entity_id": "[%key:component::versatile_thermostat::config::step::window::data::window_sensor_entity_id%]", + "window_delay": "[%key:component::versatile_thermostat::config::step::window::data::window_delay%]", + "window_auto_open_threshold": "[%key:component::versatile_thermostat::config::step::window::data::window_auto_open_threshold%]", + "window_auto_close_threshold": "[%key:component::versatile_thermostat::config::step::window::data::window_auto_close_threshold%]", + "window_auto_max_duration": "[%key:component::versatile_thermostat::config::step::window::data::window_auto_max_duration%]" + }, + "data_description": { + "window_sensor_entity_id": "[%key:component::versatile_thermostat::config::step::window::data_description::window_sensor_entity_id%]", + "window_auto_open_threshold": "[%key:component::versatile_thermostat::config::step::window::data_description::window_auto_open_threshold%]", + "window_auto_close_threshold": "[%key:component::versatile_thermostat::config::step::window::data_description::window_auto_close_threshold%]", + "window_auto_max_duration": "[%key:component::versatile_thermostat::config::step::window::data_description::window_auto_max_duration%]" } }, "motion": { @@ -192,18 +211,19 @@ "description": "Configuration of advanced parameters. Leave the default values if you don't know what you are doing.\nThis parameters can lead to a very bad temperature or power regulation.", "data": { "minimal_activation_delay": "Delay in secondes under which the equipment will not be activated", - "security_delay_min": "Maximum allowed delay in minutes between two temperature mesures. Above this delay, the thermostat will turn to a sceurity off state", + "security_delay_min": "Maximum allowed delay in minutes between two temperature mesures. Above this delay, the thermostat will turn to a security off state", "security_min_on_percent": "Minimal heating percent value for security preset activation. Below this amount of on_percent the thermostat won't go into security preset", "security_default_on_percent": "The default heating percent value in security preset. Set to 0 to switch off heater in security present" } } }, "error": { - "unknown": "Unexpected error", - "unknown_entity": "Unknown entity id" + "unknown": "[%key:component::versatile_thermostat::config::error::unknown%]", + "unknown_entity": "[%key:component::versatile_thermostat::config::error::unknown_entity%]", + "window_open_detection_method": "[%key:component::versatile_thermostat::config::error::window_open_detection_method%]" }, "abort": { - "already_configured": "Device is already configured" + "already_configured": "[%key:component::versatile_thermostat::config::abort::already_configured%]" } }, "selector": { diff --git a/custom_components/versatile_thermostat/translations/fr.json b/custom_components/versatile_thermostat/translations/fr.json index 35afcde..7fdbe05 100644 --- a/custom_components/versatile_thermostat/translations/fr.json +++ b/custom_components/versatile_thermostat/translations/fr.json @@ -50,8 +50,17 @@ "title": "Gestion d'une ouverture", "description": "Coupe le radiateur si l'ouverture est ouverte.\nLaissez l'entity id vide si non utilisé.", "data": { - "window_sensor_entity_id": "Ouverture sensor entity id", - "window_delay": "Délai avant extinction (seconds)" + "window_sensor_entity_id": "Détecteur d'ouverture (entity id)", + "window_delay": "Délai avant extinction (seconds)", + "window_auto_open_threshold": "seuil haut de chute de température pour la détection automatique (en °/min)", + "window_auto_close_threshold": "Seuil bas de chute de température pour la fin de détection automatique (en °/min)", + "window_auto_max_duration": "Durée maximum d'une extinction automatique (en min)" + }, + "data_description": { + "window_sensor_entity_id": "Laissez vide si vous n'avez de détecteur", + "window_auto_open_threshold": "Valeur recommandée: 0.5. 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" } }, "motion": { @@ -96,7 +105,8 @@ }, "error": { "unknown": "Erreur inattendue", - "unknown_entity": "entity id inconnu" + "unknown_entity": "entity id inconnu", + "window_open_detection_method": "Une seule méthode de détection des ouvertures ouvertes doit être utilisée. Utilisez le détecteur d'ouverture ou les seuils de température mais pas les deux." }, "abort": { "already_configured": "Le device est déjà configuré" @@ -151,11 +161,20 @@ } }, "window": { - "title": "Gestion d'une ouverture", - "description": "Coupe le radiateur si l'ouverture est ouverte.\nLaissez l'entity id vide si non utilisé.", + "title": "[%key:component::versatile_thermostat::config::step::window::title%]", + "description": "[%key:component::versatile_thermostat::config::step::window::description%]", "data": { - "window_sensor_entity_id": "Ouverture sensor entity id", - "window_delay": "Délai avant extinction (seconds)" + "window_sensor_entity_id": "[%key:component::versatile_thermostat::config::step::window::data::window_sensor_entity_id%]", + "window_delay": "[%key:component::versatile_thermostat::config::step::window::data::window_delay%]", + "window_auto_open_threshold": "[%key:component::versatile_thermostat::config::step::window::data::window_auto_open_threshold%]", + "window_auto_close_threshold": "[%key:component::versatile_thermostat::config::step::window::data::window_auto_close_threshold%]", + "window_auto_max_duration": "[%key:component::versatile_thermostat::config::step::window::data::window_auto_max_duration%]" + }, + "data_description": { + "window_sensor_entity_id": "[%key:component::versatile_thermostat::config::step::window::data_description::window_sensor_entity_id%]", + "window_auto_open_threshold": "[%key:component::versatile_thermostat::config::step::window::data_description::window_auto_open_threshold%]", + "window_auto_close_threshold": "[%key:component::versatile_thermostat::config::step::window::data_description::window_auto_close_threshold%]", + "window_auto_max_duration": "[%key:component::versatile_thermostat::config::step::window::data_description::window_auto_max_duration%]" } }, "motion": { @@ -199,11 +218,12 @@ } }, "error": { - "unknown": "Erreur inattendue", - "unknown_entity": "entity id inconnu" + "unknown": "[%key:component::versatile_thermostat::config::error::unknown%]", + "unknown_entity": "[%key:component::versatile_thermostat::config::error::unknown_entity%]", + "window_open_detection_method": "[%key:component::versatile_thermostat::config::error::window_open_detection_method%]" }, "abort": { - "already_configured": "Le device est déjà configuré" + "already_configured": "[%key:component::versatile_thermostat::config::abort::already_configured%]" } }, "selector": {