Compare commits

..

4 Commits

Author SHA1 Message Date
Jean-Marc Collin
f547647e24 With enable + tests + hysteresis in calculation 2024-11-01 10:24:35 +00:00
Jean-Marc Collin
5063374b97 Allow calculation even if slope is None 2024-10-31 22:29:30 +00:00
Jean-Marc Collin
461db8d86c Change algo to take slop into account 2024-10-31 21:57:55 +00:00
Jean-Marc Collin
38c4b067b1 FIX too much start/stop 2024-10-31 21:30:44 +00:00
7 changed files with 410 additions and 89 deletions

View File

@@ -3,7 +3,7 @@
""" """
import logging import logging
from datetime import datetime, timedelta from datetime import datetime
from typing import Literal from typing import Literal
from homeassistant.components.climate import HVACMode from homeassistant.components.climate import HVACMode
@@ -13,7 +13,6 @@ from .const import (
AUTO_START_STOP_LEVEL_FAST, AUTO_START_STOP_LEVEL_FAST,
AUTO_START_STOP_LEVEL_MEDIUM, AUTO_START_STOP_LEVEL_MEDIUM,
AUTO_START_STOP_LEVEL_SLOW, AUTO_START_STOP_LEVEL_SLOW,
CONF_AUTO_START_STOP_LEVELS,
TYPE_AUTO_START_STOP_LEVELS, TYPE_AUTO_START_STOP_LEVELS,
) )
@@ -31,6 +30,9 @@ DT_MIN = {
# the measurement cycle (2 min) # the measurement cycle (2 min)
CYCLE_SEC = 120 CYCLE_SEC = 120
# A temp hysteresis to avoid rapid OFF/ON
TEMP_HYSTERESIS = 0.5
ERROR_THRESHOLD = { ERROR_THRESHOLD = {
AUTO_START_STOP_LEVEL_NONE: 0, # Not used AUTO_START_STOP_LEVEL_NONE: 0, # Not used
AUTO_START_STOP_LEVEL_SLOW: 10, # 10 cycle above 1° or 5 cycle above 2°, ... AUTO_START_STOP_LEVEL_SLOW: 10, # 10 cycle above 1° or 5 cycle above 2°, ...
@@ -79,7 +81,7 @@ class AutoStartStopDetectionAlgorithm:
saved_hvac_mode: HVACMode | None, saved_hvac_mode: HVACMode | None,
target_temp: float, target_temp: float,
current_temp: float, current_temp: float,
slope_min: float, slope_min: float | None,
now: datetime, now: datetime,
) -> AUTO_START_STOP_ACTIONS: ) -> AUTO_START_STOP_ACTIONS:
"""Calculate an eventual action to do depending of the value in parameter""" """Calculate an eventual action to do depending of the value in parameter"""
@@ -90,18 +92,6 @@ class AutoStartStopDetectionAlgorithm:
) )
return AUTO_START_STOP_ACTION_NOTHING return AUTO_START_STOP_ACTION_NOTHING
if (
hvac_mode is None
or target_temp is None
or current_temp is None
or slope_min is None
):
_LOGGER.debug(
"%s - No all mandatory parameters are set. Disable auto-start/stop",
self,
)
return AUTO_START_STOP_ACTION_NOTHING
_LOGGER.debug( _LOGGER.debug(
"%s - calculate_action: hvac_mode=%s, saved_hvac_mode=%s, target_temp=%s, current_temp=%s, slope_min=%s at %s", "%s - calculate_action: hvac_mode=%s, saved_hvac_mode=%s, target_temp=%s, current_temp=%s, slope_min=%s at %s",
self, self,
@@ -113,14 +103,21 @@ class AutoStartStopDetectionAlgorithm:
now, now,
) )
if hvac_mode is None or target_temp is None or current_temp is None:
_LOGGER.debug(
"%s - No all mandatory parameters are set. Disable auto-start/stop",
self,
)
return AUTO_START_STOP_ACTION_NOTHING
# Calculate the error factor (P) # Calculate the error factor (P)
error = target_temp - current_temp error = target_temp - current_temp
# reduce the error considering the dt between the last measurement # reduce the error considering the dt between the last measurement
if self._last_calculation_date is not None: if self._last_calculation_date is not None:
dtmin = (now - self._last_calculation_date).total_seconds() / CYCLE_SEC dtmin = (now - self._last_calculation_date).total_seconds() / CYCLE_SEC
# ignore two calls too near (< 2,5 min) # ignore two calls too near (< 24 sec)
if dtmin <= 0.5: if dtmin <= 0.2:
_LOGGER.debug( _LOGGER.debug(
"%s - new calculation of auto_start_stop (%s) is too near of the last one (%s). Forget it", "%s - new calculation of auto_start_stop (%s) is too near of the last one (%s). Forget it",
self, self,
@@ -144,59 +141,73 @@ class AutoStartStopDetectionAlgorithm:
self._last_calculation_date = now self._last_calculation_date = now
temp_at_dt = current_temp + slope_min * self._dt
# Check to turn-off # Check to turn-off
# When we hit the threshold, that mean we can turn off # When we hit the threshold, that mean we can turn off
if hvac_mode == HVACMode.HEAT: if hvac_mode == HVACMode.HEAT:
if self._accumulated_error <= -self._error_threshold: if (
self._accumulated_error <= -self._error_threshold
and temp_at_dt >= target_temp + TEMP_HYSTERESIS
):
_LOGGER.info( _LOGGER.info(
"%s - We need to stop, there is no need for heating for a long time.", "%s - We need to stop, there is no need for heating for a long time.",
self,
) )
return AUTO_START_STOP_ACTION_OFF return AUTO_START_STOP_ACTION_OFF
else: else:
_LOGGER.debug( _LOGGER.debug("%s - nothing to do, we are heating", self)
"%s - nothing to do, we are heating",
)
return AUTO_START_STOP_ACTION_NOTHING return AUTO_START_STOP_ACTION_NOTHING
if hvac_mode == HVACMode.COOL: if hvac_mode == HVACMode.COOL:
if self._accumulated_error >= self._error_threshold: if (
self._accumulated_error >= self._error_threshold
and temp_at_dt <= target_temp - TEMP_HYSTERESIS
):
_LOGGER.info( _LOGGER.info(
"%s - We need to stop, there is no need for cooling for a long time.", "%s - We need to stop, there is no need for cooling for a long time.",
self,
) )
return AUTO_START_STOP_ACTION_OFF return AUTO_START_STOP_ACTION_OFF
else: else:
_LOGGER.debug( _LOGGER.debug(
"%s - nothing to do, we are cooling", "%s - nothing to do, we are cooling",
self,
) )
return AUTO_START_STOP_ACTION_NOTHING return AUTO_START_STOP_ACTION_NOTHING
# check to turn on # check to turn on
if hvac_mode == HVACMode.OFF and saved_hvac_mode == HVACMode.HEAT: if hvac_mode == HVACMode.OFF and saved_hvac_mode == HVACMode.HEAT:
if current_temp + slope_min * self._dt <= target_temp: if temp_at_dt <= target_temp - TEMP_HYSTERESIS:
_LOGGER.info( _LOGGER.info(
"%s - We need to start, because it will be time to heat", "%s - We need to start, because it will be time to heat",
self,
) )
return AUTO_START_STOP_ACTION_ON return AUTO_START_STOP_ACTION_ON
else: else:
_LOGGER.debug( _LOGGER.debug(
"%s - nothing to do, we don't need to heat soon", "%s - nothing to do, we don't need to heat soon",
self,
) )
return AUTO_START_STOP_ACTION_NOTHING return AUTO_START_STOP_ACTION_NOTHING
if hvac_mode == HVACMode.OFF and saved_hvac_mode == HVACMode.COOL: if hvac_mode == HVACMode.OFF and saved_hvac_mode == HVACMode.COOL:
if current_temp + slope_min * self._dt >= target_temp: if temp_at_dt >= target_temp + TEMP_HYSTERESIS:
_LOGGER.info( _LOGGER.info(
"%s - We need to start, because it will be time to cool", "%s - We need to start, because it will be time to cool",
self,
) )
return AUTO_START_STOP_ACTION_ON return AUTO_START_STOP_ACTION_ON
else: else:
_LOGGER.debug( _LOGGER.debug(
"%s - nothing to do, we don't need to cool soon", "%s - nothing to do, we don't need to cool soon",
self,
) )
return AUTO_START_STOP_ACTION_NOTHING return AUTO_START_STOP_ACTION_NOTHING
_LOGGER.debug( _LOGGER.debug(
"%s - nothing to do, no conditions applied", "%s - nothing to do, no conditions applied",
self,
) )
return AUTO_START_STOP_ACTION_NOTHING return AUTO_START_STOP_ACTION_NOTHING
@@ -214,6 +225,11 @@ class AutoStartStopDetectionAlgorithm:
"""Get the accumulated error value""" """Get the accumulated error value"""
return self._accumulated_error return self._accumulated_error
@property
def accumulated_error_threshold(self) -> float:
"""Get the accumulated error threshold value"""
return self._error_threshold
@property @property
def level(self) -> TYPE_AUTO_START_STOP_LEVELS: def level(self) -> TYPE_AUTO_START_STOP_LEVELS:
"""Get the level value""" """Get the level value"""

View File

@@ -199,6 +199,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
"is_device_active", "is_device_active",
"target_temperature_step", "target_temperature_step",
"is_used_by_central_boiler", "is_used_by_central_boiler",
"temperature_slope"
} }
) )
) )
@@ -2633,6 +2634,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
"is_device_active": self.is_device_active, "is_device_active": self.is_device_active,
"ema_temp": self._ema_temp, "ema_temp": self._ema_temp,
"is_used_by_central_boiler": self.is_used_by_central_boiler, "is_used_by_central_boiler": self.is_used_by_central_boiler,
"temperature_slope": round(self.last_temperature_slope or 0, 3),
} }
@callback @callback

View File

@@ -100,7 +100,7 @@ class SecurityBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity):
entry_infos, entry_infos,
) -> None: ) -> None:
"""Initialize the SecurityState Binary sensor""" """Initialize the SecurityState Binary sensor"""
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME)) super().__init__(hass, unique_id, name)
self._attr_name = "Security state" self._attr_name = "Security state"
self._attr_unique_id = f"{self._device_name}_security_state" self._attr_unique_id = f"{self._device_name}_security_state"
self._attr_is_on = False self._attr_is_on = False

View File

@@ -52,6 +52,7 @@ PLATFORMS: list[Platform] = [
# Number should be after CLIMATE # Number should be after CLIMATE
Platform.NUMBER, Platform.NUMBER,
Platform.BINARY_SENSOR, Platform.BINARY_SENSOR,
Platform.SWITCH,
] ]
CONF_HEATER = "heater_entity_id" CONF_HEATER = "heater_entity_id"

View File

@@ -0,0 +1,102 @@
## pylint: disable=unused-argument
""" Implements the VersatileThermostat select component """
import logging
from typing import Any
from homeassistant.core import HomeAssistant, callback
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .commons import VersatileThermostatBaseEntity
from .const import * # pylint: disable=unused-wildcard-import,wildcard-import
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the VersatileThermostat switches with config flow."""
_LOGGER.debug(
"Calling async_setup_entry entry=%s, data=%s", entry.entry_id, entry.data
)
unique_id = entry.entry_id
name = entry.data.get(CONF_NAME)
vt_type = entry.data.get(CONF_THERMOSTAT_TYPE)
auto_start_stop_feature = entry.data.get(CONF_USE_AUTO_START_STOP_FEATURE)
if vt_type == CONF_THERMOSTAT_CLIMATE and auto_start_stop_feature is True:
# Creates a switch to enable the auto-start/stop
enable_entity = AutoStartStopEnable(hass, unique_id, name, entry)
async_add_entities([enable_entity], True)
class AutoStartStopEnable(VersatileThermostatBaseEntity, SwitchEntity, RestoreEntity):
"""The that enables the ManagedDevice optimisation with"""
def __init__(
self, hass: HomeAssistant, unique_id: str, name: str, entry_infos: ConfigEntry
):
super().__init__(hass, unique_id, name)
self._attr_name = "Enable auto start/stop"
self._attr_unique_id = f"{self._device_name}_enable_auto_start_stop"
self._default_value = (
entry_infos.data.get(CONF_AUTO_START_STOP_LEVEL)
!= AUTO_START_STOP_LEVEL_NONE
)
self._attr_is_on = self._default_value
@property
def icon(self) -> str | None:
"""The icon"""
return "mdi:power-settings"
async def async_added_to_hass(self):
await super().async_added_to_hass()
# Récupérer le dernier état sauvegardé de l'entité
last_state = await self.async_get_last_state()
# Si l'état précédent existe, vous pouvez l'utiliser
if last_state is not None:
self._attr_is_on = last_state.state == "on"
else:
# If no previous state set it to false by default
self._attr_is_on = self._default_value
self.update_my_state_and_vtherm()
def update_my_state_and_vtherm(self):
"""Update the auto_start_stop_enable flag in my VTherm"""
self.async_write_ha_state()
if self.my_climate is not None:
self.my_climate.set_auto_start_stop_enable(self._attr_is_on)
@callback
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
self.turn_on()
@callback
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
self.turn_off()
@overrides
def turn_off(self, **kwargs: Any):
self._attr_is_on = False
self.update_my_state_and_vtherm()
@overrides
def turn_on(self, **kwargs: Any):
self._attr_is_on = True
self.update_my_state_and_vtherm()

View File

@@ -49,6 +49,7 @@ from .const import (
RegulationParamStrong, RegulationParamStrong,
AUTO_FAN_DTEMP_THRESHOLD, AUTO_FAN_DTEMP_THRESHOLD,
AUTO_FAN_DEACTIVATED_MODES, AUTO_FAN_DEACTIVATED_MODES,
CONF_USE_AUTO_START_STOP_FEATURE,
CONF_AUTO_START_STOP_LEVEL, CONF_AUTO_START_STOP_LEVEL,
AUTO_START_STOP_LEVEL_NONE, AUTO_START_STOP_LEVEL_NONE,
TYPE_AUTO_START_STOP_LEVELS, TYPE_AUTO_START_STOP_LEVELS,
@@ -91,6 +92,7 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
_auto_deactivated_fan_mode: str | None = None _auto_deactivated_fan_mode: str | None = None
_auto_start_stop_level: TYPE_AUTO_START_STOP_LEVELS = AUTO_START_STOP_LEVEL_NONE _auto_start_stop_level: TYPE_AUTO_START_STOP_LEVELS = AUTO_START_STOP_LEVEL_NONE
_auto_start_stop_algo: AutoStartStopDetectionAlgorithm | None = None _auto_start_stop_algo: AutoStartStopDetectionAlgorithm | None = None
_is_auto_start_stop_enabled: bool = False
_entity_component_unrecorded_attributes = ( _entity_component_unrecorded_attributes = (
BaseThermostat._entity_component_unrecorded_attributes.union( BaseThermostat._entity_component_unrecorded_attributes.union(
@@ -110,8 +112,10 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
"auto_deactivated_fan_mode", "auto_deactivated_fan_mode",
"auto_regulation_use_device_temp", "auto_regulation_use_device_temp",
"auto_start_stop_level", "auto_start_stop_level",
"auto_start_stop_dtemp",
"auto_start_stop_dtmin", "auto_start_stop_dtmin",
"auto_start_stop_enable",
"auto_start_stop_accumulated_error",
"auto_start_stop_accumulated_error_threshold",
} }
) )
) )
@@ -173,9 +177,14 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
CONF_AUTO_REGULATION_USE_DEVICE_TEMP, False CONF_AUTO_REGULATION_USE_DEVICE_TEMP, False
) )
self._auto_start_stop_level = config_entry.get( use_auto_start_stop = config_entry.get(CONF_USE_AUTO_START_STOP_FEATURE, False)
CONF_AUTO_START_STOP_LEVEL, AUTO_START_STOP_LEVEL_NONE if use_auto_start_stop:
) self._auto_start_stop_level = config_entry.get(
CONF_AUTO_START_STOP_LEVEL, AUTO_START_STOP_LEVEL_NONE
)
else:
self._auto_start_stop_level = AUTO_START_STOP_LEVEL_NONE
# Instanciate the auto start stop algo # Instanciate the auto start stop algo
self._auto_start_stop_algo = AutoStartStopDetectionAlgorithm( self._auto_start_stop_algo = AutoStartStopDetectionAlgorithm(
self._auto_start_stop_level, self.name self._auto_start_stop_level, self.name
@@ -572,12 +581,23 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
self.auto_regulation_use_device_temp self.auto_regulation_use_device_temp
) )
self._attr_extra_state_attributes["auto_start_stop_enable"] = (
self.auto_start_stop_enable
)
self._attr_extra_state_attributes["auto_start_stop_level"] = ( self._attr_extra_state_attributes["auto_start_stop_level"] = (
self._auto_start_stop_algo.level self._auto_start_stop_algo.level
) )
self._attr_extra_state_attributes["auto_start_stop_dtmin"] = ( self._attr_extra_state_attributes["auto_start_stop_dtmin"] = (
self._auto_start_stop_algo.dt_min self._auto_start_stop_algo.dt_min
) )
self._attr_extra_state_attributes["auto_start_stop_accumulated_error"] = (
self._auto_start_stop_algo.accumulated_error
)
self._attr_extra_state_attributes[
"auto_start_stop_accumulated_error_threshold"
] = self._auto_start_stop_algo.accumulated_error_threshold
self.async_write_ha_state() self.async_write_ha_state()
_LOGGER.debug( _LOGGER.debug(
@@ -898,56 +918,67 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
ret = await super().async_control_heating(force, _) ret = await super().async_control_heating(force, _)
# Check if we need to auto start/stop the Vtherm # Check if we need to auto start/stop the Vtherm
action = self._auto_start_stop_algo.calculate_action( if self.auto_start_stop_enable:
self.hvac_mode, slope = (
self._saved_hvac_mode, self.last_temperature_slope or 0
self.target_temperature, ) / 60 # to have the slope in °/min
self.current_temperature, action = self._auto_start_stop_algo.calculate_action(
self._window_auto_algo.last_slope, self.hvac_mode,
self.now self._saved_hvac_mode,
) self.target_temperature,
_LOGGER.debug("%s - auto_start_stop action is %s", self, action) self.current_temperature,
if action == AUTO_START_STOP_ACTION_OFF: slope,
_LOGGER.info( self.now,
"%s - Turning OFF the Vtherm due to auto-start-stop conditions", self
) )
await self.async_turn_off() _LOGGER.debug("%s - auto_start_stop action is %s", self, action)
if action == AUTO_START_STOP_ACTION_OFF:
_LOGGER.info(
"%s - Turning OFF the Vtherm due to auto-start-stop conditions",
self,
)
await self.async_turn_off()
# Send an event # Send an event
self.send_event( self.send_event(
event_type=EventType.AUTO_START_STOP_EVENT, event_type=EventType.AUTO_START_STOP_EVENT,
data={ data={
"type": "stop", "type": "stop",
"cause": "Auto stop conditions reached", "name": self.name,
"hvac_mode": self.hvac_mode, "cause": "Auto stop conditions reached",
"saved_hvac_mode": self._saved_hvac_mode, "hvac_mode": self.hvac_mode,
"target_temperature": self.target_temperature, "saved_hvac_mode": self._saved_hvac_mode,
"current_temperature": self.current_temperature, "target_temperature": self.target_temperature,
"temperature_slope": self._window_auto_algo.last_slope, "current_temperature": self.current_temperature,
}, "temperature_slope": round(slope, 3),
) },
)
# Stop here # Stop here
return ret return ret
elif action == AUTO_START_STOP_ACTION_ON: elif action == AUTO_START_STOP_ACTION_ON:
_LOGGER.info( _LOGGER.info(
"%s - Turning ON the Vtherm due to auto-start-stop conditions", self "%s - Turning ON the Vtherm due to auto-start-stop conditions", self
) )
await self.async_turn_on() await self.async_turn_on()
# Send an event # Send an event
self.send_event( self.send_event(
event_type=EventType.AUTO_START_STOP_EVENT, event_type=EventType.AUTO_START_STOP_EVENT,
data={ data={
"type": "start", "type": "start",
"cause": "Auto start conditions reached", "name": self.name,
"hvac_mode": self.hvac_mode, "cause": "Auto start conditions reached",
"saved_hvac_mode": self._saved_hvac_mode, "hvac_mode": self.hvac_mode,
"target_temperature": self.target_temperature, "saved_hvac_mode": self._saved_hvac_mode,
"current_temperature": self.current_temperature, "target_temperature": self.target_temperature,
"temperature_slope": self._window_auto_algo.last_slope, "current_temperature": self.current_temperature,
}, "temperature_slope": round(slope, 3),
) },
)
self.update_custom_attributes()
else:
_LOGGER.debug("%s - auto start/stop is disabled")
# Continue the normal async_control_heating # Continue the normal async_control_heating
@@ -959,6 +990,11 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
return ret return ret
def set_auto_start_stop_enable(self, is_enabled: bool):
"""Enable/Disable the auto-start/stop feature"""
self._is_auto_start_stop_enabled = is_enabled
self.update_custom_attributes()
@property @property
def auto_regulation_mode(self) -> str | None: def auto_regulation_mode(self) -> str | None:
"""Get the regulation mode""" """Get the regulation mode"""
@@ -1110,6 +1146,11 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
"""Return the auto start/stop level.""" """Return the auto start/stop level."""
return self._auto_start_stop_level return self._auto_start_stop_level
@property
def auto_start_stop_enable(self) -> bool:
"""Returns the auto_start_stop_enable"""
return self._is_auto_start_stop_enabled
@overrides @overrides
def init_underlyings(self): def init_underlyings(self):
"""Init the underlyings if not already done""" """Init the underlyings if not already done"""

View File

@@ -6,6 +6,7 @@ import logging
from unittest.mock import patch, call from unittest.mock import patch, call
from homeassistant.components.climate import HVACMode from homeassistant.components.climate import HVACMode
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from custom_components.versatile_thermostat.thermostat_climate import ( from custom_components.versatile_thermostat.thermostat_climate import (
ThermostatOverClimate, ThermostatOverClimate,
@@ -14,7 +15,6 @@ from custom_components.versatile_thermostat.auto_start_stop_algorithm import (
AutoStartStopDetectionAlgorithm, AutoStartStopDetectionAlgorithm,
AUTO_START_STOP_ACTION_NOTHING, AUTO_START_STOP_ACTION_NOTHING,
AUTO_START_STOP_ACTION_OFF, AUTO_START_STOP_ACTION_OFF,
AUTO_START_STOP_ACTION_ON,
) )
from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import
@@ -72,7 +72,7 @@ async def test_auto_start_stop_algo_slow_heat_off(hass: HomeAssistant):
assert ret == AUTO_START_STOP_ACTION_NOTHING assert ret == AUTO_START_STOP_ACTION_NOTHING
# 4 .No change on accumulated error because the new measure is too near the last one # 4 .No change on accumulated error because the new measure is too near the last one
now = now + timedelta(minutes=1) now = now + timedelta(seconds=11)
ret = algo.calculate_action( ret = algo.calculate_action(
hvac_mode=HVACMode.HEAT, hvac_mode=HVACMode.HEAT,
saved_hvac_mode=HVACMode.OFF, saved_hvac_mode=HVACMode.OFF,
@@ -225,6 +225,7 @@ async def test_auto_start_stop_none_vtherm(
CONF_USE_WINDOW_FEATURE: False, CONF_USE_WINDOW_FEATURE: False,
CONF_USE_MOTION_FEATURE: False, CONF_USE_MOTION_FEATURE: False,
CONF_USE_POWER_FEATURE: False, CONF_USE_POWER_FEATURE: False,
CONF_USE_AUTO_START_STOP_FEATURE: False,
CONF_USE_PRESENCE_FEATURE: True, CONF_USE_PRESENCE_FEATURE: True,
CONF_PRESENCE_SENSOR: "binary_sensor.presence_sensor", CONF_PRESENCE_SENSOR: "binary_sensor.presence_sensor",
CONF_CLIMATE: "climate.mock_climate", CONF_CLIMATE: "climate.mock_climate",
@@ -267,6 +268,12 @@ async def test_auto_start_stop_none_vtherm(
# 1. Vtherm auto-start/stop should be in NONE mode # 1. Vtherm auto-start/stop should be in NONE mode
assert vtherm.auto_start_stop_level == AUTO_START_STOP_LEVEL_NONE assert vtherm.auto_start_stop_level == AUTO_START_STOP_LEVEL_NONE
# 2. We should not find any switch Enable entity
assert (
search_entity(hass, "switch.overclimate_enable_auto_start_stop", SWITCH_DOMAIN)
is None
)
@pytest.mark.parametrize("expected_lingering_tasks", [True]) @pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [True]) @pytest.mark.parametrize("expected_lingering_timers", [True])
@@ -310,6 +317,7 @@ async def test_auto_start_stop_medium_heat_vtherm(
CONF_USE_WINDOW_FEATURE: False, CONF_USE_WINDOW_FEATURE: False,
CONF_USE_MOTION_FEATURE: False, CONF_USE_MOTION_FEATURE: False,
CONF_USE_POWER_FEATURE: False, CONF_USE_POWER_FEATURE: False,
CONF_USE_AUTO_START_STOP_FEATURE: True,
CONF_USE_PRESENCE_FEATURE: True, CONF_USE_PRESENCE_FEATURE: True,
CONF_PRESENCE_SENSOR: "binary_sensor.presence_sensor", CONF_PRESENCE_SENSOR: "binary_sensor.presence_sensor",
CONF_CLIMATE: "climate.mock_climate", CONF_CLIMATE: "climate.mock_climate",
@@ -350,8 +358,13 @@ async def test_auto_start_stop_medium_heat_vtherm(
assert vtherm._attr_extra_state_attributes["auto_start_stop_dtmin"] == 15 assert vtherm._attr_extra_state_attributes["auto_start_stop_dtmin"] == 15
# 1. Vtherm auto-start/stop should be in MEDIUM mode # 1. Vtherm auto-start/stop should be in MEDIUM mode and an enable entity should exists
assert vtherm.auto_start_stop_level == AUTO_START_STOP_LEVEL_MEDIUM assert vtherm.auto_start_stop_level == AUTO_START_STOP_LEVEL_MEDIUM
enable_entity = search_entity(
hass, "switch.overclimate_enable_auto_start_stop", SWITCH_DOMAIN
)
assert enable_entity is not None
assert enable_entity.state == STATE_ON
tz = get_tz(hass) # pylint: disable=invalid-name tz = get_tz(hass) # pylint: disable=invalid-name
now: datetime = datetime.now(tz=tz) now: datetime = datetime.now(tz=tz)
@@ -369,6 +382,8 @@ async def test_auto_start_stop_medium_heat_vtherm(
# 3. Set current temperature to 19 5 min later # 3. Set current temperature to 19 5 min later
now = now + timedelta(minutes=5) now = now + timedelta(minutes=5)
# reset accumulated error (only for testing)
vtherm._auto_start_stop_algo._accumulated_error = 0
with patch( with patch(
"custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event"
) as mock_send_event: ) as mock_send_event:
@@ -421,12 +436,13 @@ async def test_auto_start_stop_medium_heat_vtherm(
event_type=EventType.AUTO_START_STOP_EVENT, event_type=EventType.AUTO_START_STOP_EVENT,
data={ data={
"type": "stop", "type": "stop",
"name": "overClimate",
"cause": "Auto stop conditions reached", "cause": "Auto stop conditions reached",
"hvac_mode": HVACMode.OFF, "hvac_mode": HVACMode.OFF,
"saved_hvac_mode": HVACMode.HEAT, "saved_hvac_mode": HVACMode.HEAT,
"target_temperature": 19.0, "target_temperature": 19.0,
"current_temperature": 21.0, "current_temperature": 21.0,
"temperature_slope": 10.03, "temperature_slope": 0.167,
}, },
) )
] ]
@@ -480,12 +496,13 @@ async def test_auto_start_stop_medium_heat_vtherm(
event_type=EventType.AUTO_START_STOP_EVENT, event_type=EventType.AUTO_START_STOP_EVENT,
data={ data={
"type": "start", "type": "start",
"name": "overClimate",
"cause": "Auto start conditions reached", "cause": "Auto start conditions reached",
"hvac_mode": HVACMode.HEAT, "hvac_mode": HVACMode.HEAT,
"saved_hvac_mode": HVACMode.HEAT, # saved don't change "saved_hvac_mode": HVACMode.HEAT, # saved don't change
"target_temperature": 19.0, "target_temperature": 19.0,
"current_temperature": 18.0, "current_temperature": 18.0,
"temperature_slope": -2.06, "temperature_slope": -0.034,
}, },
) )
] ]
@@ -545,6 +562,7 @@ async def test_auto_start_stop_fast_ac_vtherm(
CONF_USE_WINDOW_FEATURE: False, CONF_USE_WINDOW_FEATURE: False,
CONF_USE_MOTION_FEATURE: False, CONF_USE_MOTION_FEATURE: False,
CONF_USE_POWER_FEATURE: False, CONF_USE_POWER_FEATURE: False,
CONF_USE_AUTO_START_STOP_FEATURE: True,
CONF_USE_PRESENCE_FEATURE: True, CONF_USE_PRESENCE_FEATURE: True,
CONF_PRESENCE_SENSOR: "binary_sensor.presence_sensor", CONF_PRESENCE_SENSOR: "binary_sensor.presence_sensor",
CONF_CLIMATE: "climate.mock_climate", CONF_CLIMATE: "climate.mock_climate",
@@ -599,11 +617,13 @@ async def test_auto_start_stop_fast_ac_vtherm(
await hass.async_block_till_done() await hass.async_block_till_done()
assert vtherm.target_temperature == 25.0 assert vtherm.target_temperature == 25.0
# VTherm should be heating # VTherm should be cooling
assert vtherm.hvac_mode == HVACMode.COOL assert vtherm.hvac_mode == HVACMode.COOL
# 3. Set current temperature to 19 5 min later # 3. Set current temperature to 19 5 min later
now = now + timedelta(minutes=5) now = now + timedelta(minutes=5)
# reset accumulated error for test
vtherm._auto_start_stop_algo._accumulated_error = 0
with patch( with patch(
"custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event"
) as mock_send_event: ) as mock_send_event:
@@ -611,7 +631,7 @@ async def test_auto_start_stop_fast_ac_vtherm(
await send_temperature_change_event(vtherm, 25, now, True) await send_temperature_change_event(vtherm, 25, now, True)
await hass.async_block_till_done() await hass.async_block_till_done()
# VTherm should still be heating # VTherm should still be cooling
assert vtherm.hvac_mode == HVACMode.COOL assert vtherm.hvac_mode == HVACMode.COOL
assert mock_send_event.call_count == 0 assert mock_send_event.call_count == 0
assert ( assert (
@@ -641,12 +661,13 @@ async def test_auto_start_stop_fast_ac_vtherm(
event_type=EventType.AUTO_START_STOP_EVENT, event_type=EventType.AUTO_START_STOP_EVENT,
data={ data={
"type": "stop", "type": "stop",
"name": "overClimate",
"cause": "Auto stop conditions reached", "cause": "Auto stop conditions reached",
"hvac_mode": HVACMode.OFF, "hvac_mode": HVACMode.OFF,
"saved_hvac_mode": HVACMode.COOL, "saved_hvac_mode": HVACMode.COOL,
"target_temperature": 25.0, "target_temperature": 25.0,
"current_temperature": 23.0, "current_temperature": 23.0,
"temperature_slope": -16.8, "temperature_slope": -0.28,
}, },
) )
] ]
@@ -664,18 +685,18 @@ async def test_auto_start_stop_fast_ac_vtherm(
) )
# 5. Set temperature to over the target, but slope is too low -> no change # 5. Set temperature to over the target, but slope is too low -> no change
now = now + timedelta(minutes=20) now = now + timedelta(minutes=30)
with patch( with patch(
"custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event"
) as mock_send_event: ) as mock_send_event:
vtherm._set_now(now) vtherm._set_now(now)
await send_temperature_change_event(vtherm, 26, now, True) await send_temperature_change_event(vtherm, 25.5, now, True)
await hass.async_block_till_done() await hass.async_block_till_done()
# accumulated_error = 2/2 + target - current = -1 x 20 min / 2 capped to 2 # accumulated_error = 2/2 + target - current = -1 x 20 min / 2 capped to 2
assert vtherm._auto_start_stop_algo.accumulated_error == -2 assert vtherm._auto_start_stop_algo.accumulated_error == -2
# VTherm should have been stopped # VTherm should stay stopped
assert vtherm.hvac_mode == HVACMode.OFF assert vtherm.hvac_mode == HVACMode.OFF
# a message should have been sent # a message should have been sent
assert mock_send_event.call_count == 0 assert mock_send_event.call_count == 0
@@ -702,12 +723,13 @@ async def test_auto_start_stop_fast_ac_vtherm(
event_type=EventType.AUTO_START_STOP_EVENT, event_type=EventType.AUTO_START_STOP_EVENT,
data={ data={
"type": "start", "type": "start",
"name": "overClimate",
"cause": "Auto start conditions reached", "cause": "Auto start conditions reached",
"hvac_mode": HVACMode.COOL, "hvac_mode": HVACMode.COOL,
"saved_hvac_mode": HVACMode.COOL, # saved don't change "saved_hvac_mode": HVACMode.COOL, # saved don't change
"target_temperature": 25.0, "target_temperature": 25.0,
"current_temperature": 26.5, "current_temperature": 26.5,
"temperature_slope": 5.74, "temperature_slope": 0.112,
}, },
) )
] ]
@@ -767,6 +789,7 @@ async def test_auto_start_stop_medium_heat_vtherm_preset_change(
CONF_USE_WINDOW_FEATURE: False, CONF_USE_WINDOW_FEATURE: False,
CONF_USE_MOTION_FEATURE: False, CONF_USE_MOTION_FEATURE: False,
CONF_USE_POWER_FEATURE: False, CONF_USE_POWER_FEATURE: False,
CONF_USE_AUTO_START_STOP_FEATURE: True,
CONF_USE_PRESENCE_FEATURE: True, CONF_USE_PRESENCE_FEATURE: True,
CONF_PRESENCE_SENSOR: "binary_sensor.presence_sensor", CONF_PRESENCE_SENSOR: "binary_sensor.presence_sensor",
CONF_CLIMATE: "climate.mock_climate", CONF_CLIMATE: "climate.mock_climate",
@@ -826,6 +849,7 @@ async def test_auto_start_stop_medium_heat_vtherm_preset_change(
# 3. Set current temperature to 21 5 min later to auto-stop # 3. Set current temperature to 21 5 min later to auto-stop
now = now + timedelta(minutes=5) now = now + timedelta(minutes=5)
vtherm._auto_start_stop_algo._accumulated_error = 0
with patch( with patch(
"custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event"
) as mock_send_event: ) as mock_send_event:
@@ -846,12 +870,13 @@ async def test_auto_start_stop_medium_heat_vtherm_preset_change(
event_type=EventType.AUTO_START_STOP_EVENT, event_type=EventType.AUTO_START_STOP_EVENT,
data={ data={
"type": "stop", "type": "stop",
"name": "overClimate",
"cause": "Auto stop conditions reached", "cause": "Auto stop conditions reached",
"hvac_mode": HVACMode.OFF, "hvac_mode": HVACMode.OFF,
"saved_hvac_mode": HVACMode.HEAT, "saved_hvac_mode": HVACMode.HEAT,
"target_temperature": 17.0, "target_temperature": 17.0,
"current_temperature": 19.0, "current_temperature": 19.0,
"temperature_slope": 18.0, "temperature_slope": 0.3,
}, },
) )
] ]
@@ -900,12 +925,13 @@ async def test_auto_start_stop_medium_heat_vtherm_preset_change(
event_type=EventType.AUTO_START_STOP_EVENT, event_type=EventType.AUTO_START_STOP_EVENT,
data={ data={
"type": "start", "type": "start",
"name": "overClimate",
"cause": "Auto start conditions reached", "cause": "Auto start conditions reached",
"hvac_mode": HVACMode.HEAT, "hvac_mode": HVACMode.HEAT,
"saved_hvac_mode": HVACMode.HEAT, # saved don't change "saved_hvac_mode": HVACMode.HEAT, # saved don't change
"target_temperature": 21.0, "target_temperature": 21.0,
"current_temperature": 17.0, "current_temperature": 17.0,
"temperature_slope": -5.19, "temperature_slope": -0.087,
}, },
) )
] ]
@@ -921,3 +947,136 @@ async def test_auto_start_stop_medium_heat_vtherm_preset_change(
) )
] ]
) )
@pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [True])
async def test_auto_start_stop_medium_heat_vtherm_preset_change_enable_false(
hass: HomeAssistant, skip_hass_states_is_state
):
"""Test than auto-start/stop restart a VTherm stopped upon preset_change (in fast mode)"""
# vtherm_api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(hass)
# The temperatures to set
temps = {
"frost": 7.0,
"eco": 17.0,
"comfort": 19.0,
"boost": 21.0,
"eco_ac": 27.0,
"comfort_ac": 25.0,
"boost_ac": 23.0,
"frost_away": 7.1,
"eco_away": 17.1,
"comfort_away": 19.1,
"boost_away": 21.1,
"eco_ac_away": 27.1,
"comfort_ac_away": 25.1,
"boost_ac_away": 23.1,
}
config_entry = MockConfigEntry(
domain=DOMAIN,
title="TheOverClimateMockName",
unique_id="overClimateUniqueId",
data={
CONF_NAME: "overClimate",
CONF_TEMP_SENSOR: "sensor.mock_temp_sensor",
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_CLIMATE,
CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor",
CONF_CYCLE_MIN: 5,
CONF_TEMP_MIN: 15,
CONF_TEMP_MAX: 30,
CONF_USE_WINDOW_FEATURE: False,
CONF_USE_MOTION_FEATURE: False,
CONF_USE_POWER_FEATURE: False,
CONF_USE_AUTO_START_STOP_FEATURE: True,
CONF_USE_PRESENCE_FEATURE: True,
CONF_PRESENCE_SENSOR: "binary_sensor.presence_sensor",
CONF_CLIMATE: "climate.mock_climate",
CONF_MINIMAL_ACTIVATION_DELAY: 30,
CONF_SECURITY_DELAY_MIN: 5,
CONF_SECURITY_MIN_ON_PERCENT: 0.3,
CONF_AUTO_FAN_MODE: CONF_AUTO_FAN_TURBO,
CONF_AC_MODE: True,
CONF_AUTO_START_STOP_LEVEL: AUTO_START_STOP_LEVEL_FAST,
},
)
fake_underlying_climate = MockClimate(
hass=hass,
unique_id="mock_climate",
name="mock_climate",
hvac_modes=[HVACMode.OFF, HVACMode.COOL, HVACMode.HEAT],
)
with patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate",
return_value=fake_underlying_climate,
):
vtherm: ThermostatOverClimate = await create_thermostat(
hass, config_entry, "climate.overclimate"
)
assert vtherm is not None
# Initialize all temps
await set_all_climate_preset_temp(hass, vtherm, temps, "overclimate")
# Check correct initialization of auto_start_stop attributes
assert (
vtherm._attr_extra_state_attributes["auto_start_stop_level"]
== AUTO_START_STOP_LEVEL_FAST
)
assert vtherm._attr_extra_state_attributes["auto_start_stop_dtmin"] == 7
# 1. Vtherm auto-start/stop should be in FAST mode and enable should be on
assert vtherm.auto_start_stop_level == AUTO_START_STOP_LEVEL_FAST
enable_entity = search_entity(
hass, "switch.overclimate_enable_auto_start_stop", SWITCH_DOMAIN
)
assert enable_entity is not None
assert enable_entity.state == STATE_ON
assert vtherm._attr_extra_state_attributes.get("auto_start_stop_enable") is True
# 2. set enable to false
enable_entity.turn_off()
await hass.async_block_till_done()
assert enable_entity.state == STATE_OFF
assert vtherm._attr_extra_state_attributes.get("auto_start_stop_enable") is False
tz = get_tz(hass) # pylint: disable=invalid-name
now: datetime = datetime.now(tz=tz)
# 3. Set mode to Heat and preset to Comfort
await send_presence_change_event(vtherm, True, False, now)
await send_temperature_change_event(vtherm, 16, now, True)
await vtherm.async_set_hvac_mode(HVACMode.HEAT)
await vtherm.async_set_preset_mode(PRESET_ECO)
await hass.async_block_till_done()
assert vtherm.target_temperature == 17.0
# VTherm should be heating
assert vtherm.hvac_mode == HVACMode.HEAT
# 3. Set current temperature to 21 5 min later to auto-stop
now = now + timedelta(minutes=5)
vtherm._auto_start_stop_algo._accumulated_error = 0
with patch(
"custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event"
) as mock_send_event:
vtherm._set_now(now)
await send_temperature_change_event(vtherm, 19, now, True)
await hass.async_block_till_done()
# VTherm should not have been stopped cause enable is false
assert vtherm.hvac_mode == HVACMode.HEAT
# Not calculated cause enable = false
assert vtherm._auto_start_stop_algo.accumulated_error == 0
# a message should have been sent
assert mock_send_event.call_count == 0