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..f482ae6 --- /dev/null +++ b/custom_components/versatile_thermostat/open_window_algorithm.py @@ -0,0 +1,117 @@ +""" 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__) + +# To filter bad values +MIN_DELTA_T_SEC = 10 # two temp mesure should be > 10 sec +MAX_SLOPE_VALUE = 2 # slope cannot be > 2 or < -2 -> else this is an aberrant point + + +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, + ) + lspe = self._last_slope + + delta_t_sec = float((datetime_measure - self._last_datetime).total_seconds()) + delta_t = delta_t_sec / 60.0 + if delta_t_sec <= MIN_DELTA_T_SEC: + _LOGGER.warning( + "Delta t is %d < %d which should be not possible. We don't consider this value", + delta_t_sec, + MIN_DELTA_T_SEC, + ) + return lspe + + delta_temp = float(temperature - self._last_temperature) + new_slope = delta_temp / delta_t + if new_slope > MAX_SLOPE_VALUE or new_slope < -MAX_SLOPE_VALUE: + _LOGGER.warning( + "New_slope is abs(%.2f) > %.2f which should be not possible. We don't consider this value", + new_slope, + MAX_SLOPE_VALUE, + ) + return lspe + + 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..abf8acc 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: between 0.05 and 0.1. 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/conftest.py b/custom_components/versatile_thermostat/tests/conftest.py index fd8f184..a7a8e16 100644 --- a/custom_components/versatile_thermostat/tests/conftest.py +++ b/custom_components/versatile_thermostat/tests/conftest.py @@ -78,6 +78,15 @@ def skip_hass_states_get_fixture(): yield +@pytest.fixture(name="skip_control_heating") +def skip_control_heating_fixture(): + """Skip the control_heating of VersatileThermostat""" + with patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat._async_control_heating" + ): + yield + + @pytest.fixture(name="skip_hass_states_is_state") def skip_hass_states_is_state_fixture(): """Skip the is_state in HomeAssistant""" diff --git a/custom_components/versatile_thermostat/tests/const.py b/custom_components/versatile_thermostat/tests/const.py index 7a80bdf..51242de 100644 --- a/custom_components/versatile_thermostat/tests/const.py +++ b/custom_components/versatile_thermostat/tests/const.py @@ -1,4 +1,5 @@ -from homeassistant.components.climate.const import ( +""" The commons const for all tests """ +from homeassistant.components.climate.const import ( # pylint: disable=unused-import PRESET_BOOST, PRESET_COMFORT, PRESET_ECO, @@ -30,6 +31,9 @@ from custom_components.versatile_thermostat.const import ( CONF_USE_PRESENCE_FEATURE, 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, @@ -95,6 +99,12 @@ MOCK_WINDOW_CONFIG = { CONF_WINDOW_DELAY: 10, } +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, +} + MOCK_MOTION_CONFIG = { CONF_MOTION_SENSOR: "input_boolean.motion_sensor", CONF_MOTION_DELAY: 10, diff --git a/custom_components/versatile_thermostat/tests/test_config_flow.py b/custom_components/versatile_thermostat/tests/test_config_flow.py index c386bf2..6f50e56 100644 --- a/custom_components/versatile_thermostat/tests/test_config_flow.py +++ b/custom_components/versatile_thermostat/tests/test_config_flow.py @@ -6,20 +6,7 @@ from homeassistant.config_entries import SOURCE_USER, ConfigEntry from custom_components.versatile_thermostat.const import DOMAIN -from .const import ( - MOCK_TH_OVER_SWITCH_USER_CONFIG, - MOCK_TH_OVER_CLIMATE_USER_CONFIG, - MOCK_TH_OVER_SWITCH_TYPE_CONFIG, - MOCK_TH_OVER_CLIMATE_TYPE_CONFIG, - MOCK_TH_OVER_SWITCH_TPI_CONFIG, - MOCK_PRESETS_CONFIG, - MOCK_WINDOW_CONFIG, - MOCK_MOTION_CONFIG, - MOCK_POWER_CONFIG, - MOCK_PRESENCE_CONFIG, - MOCK_ADVANCED_CONFIG, - MOCK_DEFAULT_FEATURE_CONFIG, -) +from .const import * # pylint: disable=wildcard-import, unused-wildcard-import async def test_show_form(hass: HomeAssistant) -> None: @@ -217,3 +204,168 @@ async def test_user_config_flow_over_climate(hass: HomeAssistant, skip_hass_stat assert result["result"].version == 1 assert result["result"].title == "TheOverClimateMockName" assert isinstance(result["result"], ConfigEntry) + + +async def test_user_config_flow_window_auto_ok( + hass: HomeAssistant, skip_hass_states_get, skip_control_heating +): + """Test the config flow with only window auto feature""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == SOURCE_USER + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + 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, + CONF_DEVICE_POWER: 1, + CONF_USE_WINDOW_FEATURE: True, + CONF_USE_MOTION_FEATURE: False, + CONF_USE_POWER_FEATURE: False, + CONF_USE_PRESENCE_FEATURE: False, + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "type" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_TH_OVER_SWITCH_TYPE_CONFIG + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "tpi" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_TH_OVER_SWITCH_TPI_CONFIG + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "presets" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_PRESETS_CONFIG + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "window" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_WINDOW_AUTO_CONFIG, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "advanced" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_ADVANCED_CONFIG + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert ( + result["data"] + == MOCK_TH_OVER_SWITCH_USER_CONFIG + | { + CONF_USE_MOTION_FEATURE: False, + CONF_USE_POWER_FEATURE: False, + CONF_USE_PRESENCE_FEATURE: False, + CONF_WINDOW_DELAY: 30, # the default value is added + CONF_USE_POWER_FEATURE: False, + CONF_USE_PRESENCE_FEATURE: False, + } + | MOCK_TH_OVER_SWITCH_TYPE_CONFIG + | MOCK_TH_OVER_SWITCH_TPI_CONFIG + | MOCK_PRESETS_CONFIG + | MOCK_WINDOW_AUTO_CONFIG + | MOCK_ADVANCED_CONFIG + ) + assert result["result"] + assert result["result"].domain == DOMAIN + assert result["result"].version == 1 + assert result["result"].title == "TheOverSwitchMockName" + assert isinstance(result["result"], ConfigEntry) + + +async def test_user_config_flow_window_auto_ko( + hass: HomeAssistant, skip_hass_states_get +): + """Test the config flow with window auto and window features -> not allowed""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == SOURCE_USER + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + 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, + CONF_DEVICE_POWER: 1, + CONF_USE_WINDOW_FEATURE: True, + CONF_USE_MOTION_FEATURE: False, + CONF_USE_POWER_FEATURE: False, + CONF_USE_PRESENCE_FEATURE: False, + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "type" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_TH_OVER_SWITCH_TYPE_CONFIG + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "tpi" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_TH_OVER_SWITCH_TPI_CONFIG + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "presets" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_PRESETS_CONFIG + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "window" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_WINDOW_AUTO_CONFIG | MOCK_WINDOW_CONFIG, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + # We should stay on window with an error + assert result["step_id"] == "window" + assert result["errors"] == { + "window_sensor_entity_id": "window_open_detection_method" + } 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..7d3c87c --- /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) # pylint: disable=invalid-name + 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 in one minute (value will be rejected) + 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) # pylint: disable=invalid-name + 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/tests/test_window.py b/custom_components/versatile_thermostat/tests/test_window.py index 8f198d6..fa3e4ae 100644 --- a/custom_components/versatile_thermostat/tests/test_window.py +++ b/custom_components/versatile_thermostat/tests/test_window.py @@ -1,7 +1,8 @@ """ Test the Window management """ -from unittest.mock import patch, call +import asyncio +from unittest.mock import patch, call, PropertyMock from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import -from datetime import datetime +from datetime import datetime, timedelta import logging @@ -11,7 +12,7 @@ logging.getLogger().setLevel(logging.DEBUG) async def test_window_management_time_not_enough( hass: HomeAssistant, skip_hass_states_is_state ): - """Test the Power management""" + """Test the Window management when time is not enough""" entry = MockConfigEntry( domain=DOMAIN, @@ -97,7 +98,7 @@ async def test_window_management_time_not_enough( async def test_window_management_time_enough( hass: HomeAssistant, skip_hass_states_is_state ): - """Test the Power management""" + """Test the Window management when time is enough""" entry = MockConfigEntry( domain=DOMAIN, @@ -197,3 +198,306 @@ async def test_window_management_time_enough( any_order=False, ) assert entity.preset_mode is PRESET_BOOST + + +async def test_window_auto_fast(hass: HomeAssistant, skip_hass_states_is_state): + """Test the Power management""" + + 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 + }, + ) + + entity: VersatileThermostat = await create_thermostat( + hass, entry, "climate.theoverswitchmockname" + ) + assert entity + + tz = get_tz(hass) # pylint: disable=invalid-name + now = datetime.now(tz) + + 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 == 21 + + assert entity.window_state is None + + # Make the temperature down + with patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + ) as mock_send_event, patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat._async_heater_turn_on" + ) as mock_heater_on, patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off" + ) as mock_heater_off, patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat._is_device_active", + return_value=True, + ): + event_timestamp = now - timedelta(minutes=4) + await send_temperature_change_event(entity, 19, event_timestamp) + + # The heater turns on + assert mock_send_event.call_count == 0 + assert mock_heater_on.call_count == 1 + assert entity.last_temperature_slope is None + assert entity._window_auto_algo.is_window_open_detected() is False + assert entity._window_auto_algo.is_window_close_detected() is False + assert entity.hvac_mode is HVACMode.HEAT + + # send one degre down in one minute + with patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + ) as mock_send_event, patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat._async_heater_turn_on" + ) as mock_heater_on, patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off" + ) as mock_heater_off, patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat._is_device_active", + return_value=True, + ): + event_timestamp = now - timedelta(minutes=3) + await send_temperature_change_event(entity, 18, event_timestamp) + + # The heater turns on + assert mock_send_event.call_count == 2 + assert mock_heater_on.call_count == 0 + assert mock_heater_off.call_count >= 1 + assert entity.last_temperature_slope == -1 + assert entity._window_auto_algo.is_window_open_detected() is True + assert entity._window_auto_algo.is_window_close_detected() is False + assert entity.window_auto_state == STATE_ON + assert entity.hvac_mode is HVACMode.OFF + + mock_send_event.assert_has_calls( + [ + call.send_event(EventType.HVAC_MODE_EVENT, {"hvac_mode": HVACMode.OFF}), + call.send_event( + EventType.WINDOW_AUTO_EVENT, + {"type": "start", "cause": "slope alert", "curve_slope": -1.0}, + ), + ], + any_order=True, + ) + + # send another 0.1 degre in one minute -> no change + with patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + ) as mock_send_event, patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat._async_heater_turn_on" + ) as mock_heater_on, patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off" + ) as mock_heater_off, patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat._is_device_active", + new_callable=PropertyMock, + return_value=False, + ): + event_timestamp = now - timedelta(minutes=2) + 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) == -0.1 * 0.5 - 1 * 0.5 + assert entity._window_auto_algo.is_window_open_detected() is True + assert entity._window_auto_algo.is_window_close_detected() is False + assert entity.window_auto_state == STATE_ON + assert entity.hvac_mode is HVACMode.OFF + + # send another plus 1.1 degre in one minute -> restore state + with patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + ) as mock_send_event, patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat._async_heater_turn_on" + ) as mock_heater_on, patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off" + ) as mock_heater_off, patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat._is_device_active", + new_callable=PropertyMock, + return_value=False, + ): + event_timestamp = now - timedelta(minutes=1) + await send_temperature_change_event(entity, 19, event_timestamp) + + # The heater turns on + assert mock_send_event.call_count == 2 + mock_send_event.assert_has_calls( + [ + call.send_event( + EventType.HVAC_MODE_EVENT, {"hvac_mode": HVACMode.HEAT} + ), + call.send_event( + EventType.WINDOW_AUTO_EVENT, + { + "type": "end", + "cause": "end of slope alert", + "curve_slope": 0.27500000000000036, + }, + ), + ], + any_order=True, + ) + assert mock_heater_on.call_count == 1 + assert mock_heater_off.call_count == 0 + assert round(entity.last_temperature_slope, 3) == 0.275 + assert entity._window_auto_algo.is_window_open_detected() is False + assert entity._window_auto_algo.is_window_close_detected() is True + assert entity.window_auto_state == STATE_OFF + assert entity.hvac_mode is HVACMode.HEAT + + +async def test_window_auto_auto_stop(hass: HomeAssistant, skip_hass_states_is_state): + """Test the Power management""" + + 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: 0, # Should be 0 for test + }, + ) + + entity: VersatileThermostat = await create_thermostat( + hass, entry, "climate.theoverswitchmockname" + ) + assert entity + + tz = get_tz(hass) # pylint: disable=invalid-name + now = datetime.now(tz) + + 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 == 21 + + assert entity.window_state is None + + # Make the temperature down + with patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + ) as mock_send_event, patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat._async_heater_turn_on" + ) as mock_heater_on, patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off" + ) as mock_heater_off, patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat._is_device_active", + return_value=True, + ): + event_timestamp = now - timedelta(minutes=4) + await send_temperature_change_event(entity, 19, event_timestamp) + + # The heater turns on + assert mock_heater_on.call_count == 1 + assert entity.last_temperature_slope is None + assert entity._window_auto_algo.is_window_open_detected() is False + assert entity._window_auto_algo.is_window_close_detected() is False + assert entity.hvac_mode is HVACMode.HEAT + + # send one degre down in one minute + with patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + ) as mock_send_event, patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat._async_heater_turn_on" + ) as mock_heater_on, patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off" + ) as mock_heater_off, patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat._is_device_active", + return_value=True, + ): + event_timestamp = now - timedelta(minutes=3) + await send_temperature_change_event(entity, 18, event_timestamp) + + # The heater turns on + assert mock_send_event.call_count == 2 + assert mock_heater_on.call_count == 0 + assert mock_heater_off.call_count >= 1 + assert entity.last_temperature_slope == -1 + assert entity._window_auto_algo.is_window_open_detected() is True + assert entity._window_auto_algo.is_window_close_detected() is False + assert entity.window_auto_state == STATE_ON + assert entity.hvac_mode is HVACMode.OFF + + # Waits for automatic disable + with patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat.send_event" + ) as mock_send_event, patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat._async_heater_turn_on" + ) as mock_heater_on, patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat._async_underlying_entity_turn_off" + ) as mock_heater_off, patch( + "custom_components.versatile_thermostat.climate.VersatileThermostat._is_device_active", + return_value=False, + ): + await asyncio.sleep(0.3) + + assert mock_heater_on.call_count == 1 + assert mock_heater_off.call_count == 0 + assert round(entity.last_temperature_slope, 3) == -1 + # Because the algorithm is not aware of the expiration, for the algo we are still in alert + assert entity._window_auto_algo.is_window_open_detected() is True + assert entity._window_auto_algo.is_window_close_detected() is False + assert entity.window_auto_state == STATE_OFF + assert entity.hvac_mode is HVACMode.HEAT + assert entity.preset_mode is PRESET_BOOST diff --git a/custom_components/versatile_thermostat/translations/en.json b/custom_components/versatile_thermostat/translations/en.json index 61980e0..abf8acc 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: between 0.05 and 0.1. 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..0f60cc9 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: entre 0.05 et 0.1. 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": {