Issue 585 add auto start/stop feature (#594)
* Migrate to HA 2024.10.4 * Auto start/stop alog and testu + ConfigFlow * With config flow ok * Change algo * All is fine * Add change_preset test * + comment * FIX too much start/stop * Change algo to take slop into account * Allow calculation even if slope is None * With enable + tests + hysteresis in calculation * Add hvac_off_reason and test with window interaction * Fix some tests * Restore saved_state * Release --------- Co-authored-by: Jean-Marc Collin <jean-marc.collin-extern@renault.com>
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
# pylint: disable=line-too-long
|
||||
""" This file implements the Auto start/stop algorithm as described here: https://github.com/jmcollin78/versatile_thermostat/issues/585
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from homeassistant.components.climate import HVACMode
|
||||
|
||||
from .const import (
|
||||
AUTO_START_STOP_LEVEL_NONE,
|
||||
AUTO_START_STOP_LEVEL_FAST,
|
||||
AUTO_START_STOP_LEVEL_MEDIUM,
|
||||
AUTO_START_STOP_LEVEL_SLOW,
|
||||
TYPE_AUTO_START_STOP_LEVELS,
|
||||
)
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Some constant to make algorithm depending of level
|
||||
DT_MIN = {
|
||||
AUTO_START_STOP_LEVEL_NONE: 0, # Not used
|
||||
AUTO_START_STOP_LEVEL_SLOW: 30,
|
||||
AUTO_START_STOP_LEVEL_MEDIUM: 15,
|
||||
AUTO_START_STOP_LEVEL_FAST: 7,
|
||||
}
|
||||
|
||||
# the measurement cycle (2 min)
|
||||
CYCLE_SEC = 120
|
||||
|
||||
# A temp hysteresis to avoid rapid OFF/ON
|
||||
TEMP_HYSTERESIS = 0.5
|
||||
|
||||
ERROR_THRESHOLD = {
|
||||
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_MEDIUM: 5, # 5 cycle above 1° or 3 cycle above 2°, ..., 1 cycle above 5°
|
||||
AUTO_START_STOP_LEVEL_FAST: 2, # 2 cycle above 1° or 1 cycle above 2°
|
||||
}
|
||||
|
||||
AUTO_START_STOP_ACTION_OFF = "turnOff"
|
||||
AUTO_START_STOP_ACTION_ON = "turnOn"
|
||||
AUTO_START_STOP_ACTION_NOTHING = "nothing"
|
||||
AUTO_START_STOP_ACTIONS = Literal[ # pylint: disable=invalid-name
|
||||
AUTO_START_STOP_ACTION_OFF,
|
||||
AUTO_START_STOP_ACTION_ON,
|
||||
AUTO_START_STOP_ACTION_NOTHING,
|
||||
]
|
||||
|
||||
class AutoStartStopDetectionAlgorithm:
|
||||
"""The class that implements the algorithm listed above"""
|
||||
|
||||
_dt: float | None = None
|
||||
_level: str = AUTO_START_STOP_LEVEL_NONE
|
||||
_accumulated_error: float = 0
|
||||
_error_threshold: float | None = None
|
||||
_last_calculation_date: datetime | None = None
|
||||
|
||||
def __init__(self, level: TYPE_AUTO_START_STOP_LEVELS, vtherm_name) -> None:
|
||||
"""Initalize a new algorithm with the right constants"""
|
||||
self._vtherm_name = vtherm_name
|
||||
self._init_level(level)
|
||||
|
||||
def _init_level(self, level: TYPE_AUTO_START_STOP_LEVELS):
|
||||
"""Initialize a new level"""
|
||||
if level == self._level:
|
||||
return
|
||||
|
||||
self._level = level
|
||||
if self._level != AUTO_START_STOP_LEVEL_NONE:
|
||||
self._dt = DT_MIN[level]
|
||||
self._error_threshold = ERROR_THRESHOLD[level]
|
||||
# reset accumulated error if we change the level
|
||||
self._accumulated_error = 0
|
||||
|
||||
def calculate_action(
|
||||
self,
|
||||
hvac_mode: HVACMode | None,
|
||||
saved_hvac_mode: HVACMode | None,
|
||||
target_temp: float,
|
||||
current_temp: float,
|
||||
slope_min: float | None,
|
||||
now: datetime,
|
||||
) -> AUTO_START_STOP_ACTIONS:
|
||||
"""Calculate an eventual action to do depending of the value in parameter"""
|
||||
if self._level == AUTO_START_STOP_LEVEL_NONE:
|
||||
_LOGGER.debug(
|
||||
"%s - auto-start/stop is disabled",
|
||||
self,
|
||||
)
|
||||
return AUTO_START_STOP_ACTION_NOTHING
|
||||
|
||||
_LOGGER.debug(
|
||||
"%s - calculate_action: hvac_mode=%s, saved_hvac_mode=%s, target_temp=%s, current_temp=%s, slope_min=%s at %s",
|
||||
self,
|
||||
hvac_mode,
|
||||
saved_hvac_mode,
|
||||
target_temp,
|
||||
current_temp,
|
||||
slope_min,
|
||||
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)
|
||||
error = target_temp - current_temp
|
||||
|
||||
# reduce the error considering the dt between the last measurement
|
||||
if self._last_calculation_date is not None:
|
||||
dtmin = (now - self._last_calculation_date).total_seconds() / CYCLE_SEC
|
||||
# ignore two calls too near (< 24 sec)
|
||||
if dtmin <= 0.2:
|
||||
_LOGGER.debug(
|
||||
"%s - new calculation of auto_start_stop (%s) is too near of the last one (%s). Forget it",
|
||||
self,
|
||||
now,
|
||||
self._last_calculation_date,
|
||||
)
|
||||
return AUTO_START_STOP_ACTION_NOTHING
|
||||
error = error * dtmin
|
||||
|
||||
# If the error have change its sign, reset smoothly the accumulated error
|
||||
if error * self._accumulated_error < 0:
|
||||
self._accumulated_error = self._accumulated_error / 2.0
|
||||
|
||||
self._accumulated_error += error
|
||||
|
||||
# Capping of the error
|
||||
self._accumulated_error = min(
|
||||
self._error_threshold,
|
||||
max(-self._error_threshold, self._accumulated_error),
|
||||
)
|
||||
|
||||
self._last_calculation_date = now
|
||||
|
||||
temp_at_dt = current_temp + slope_min * self._dt
|
||||
|
||||
# Check to turn-off
|
||||
# When we hit the threshold, that mean we can turn off
|
||||
if hvac_mode == HVACMode.HEAT:
|
||||
if (
|
||||
self._accumulated_error <= -self._error_threshold
|
||||
and temp_at_dt >= target_temp + TEMP_HYSTERESIS
|
||||
):
|
||||
_LOGGER.info(
|
||||
"%s - We need to stop, there is no need for heating for a long time.",
|
||||
self,
|
||||
)
|
||||
return AUTO_START_STOP_ACTION_OFF
|
||||
else:
|
||||
_LOGGER.debug("%s - nothing to do, we are heating", self)
|
||||
return AUTO_START_STOP_ACTION_NOTHING
|
||||
|
||||
if hvac_mode == HVACMode.COOL:
|
||||
if (
|
||||
self._accumulated_error >= self._error_threshold
|
||||
and temp_at_dt <= target_temp - TEMP_HYSTERESIS
|
||||
):
|
||||
_LOGGER.info(
|
||||
"%s - We need to stop, there is no need for cooling for a long time.",
|
||||
self,
|
||||
)
|
||||
return AUTO_START_STOP_ACTION_OFF
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"%s - nothing to do, we are cooling",
|
||||
self,
|
||||
)
|
||||
return AUTO_START_STOP_ACTION_NOTHING
|
||||
|
||||
# check to turn on
|
||||
if hvac_mode == HVACMode.OFF and saved_hvac_mode == HVACMode.HEAT:
|
||||
if temp_at_dt <= target_temp - TEMP_HYSTERESIS:
|
||||
_LOGGER.info(
|
||||
"%s - We need to start, because it will be time to heat",
|
||||
self,
|
||||
)
|
||||
return AUTO_START_STOP_ACTION_ON
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"%s - nothing to do, we don't need to heat soon",
|
||||
self,
|
||||
)
|
||||
return AUTO_START_STOP_ACTION_NOTHING
|
||||
|
||||
if hvac_mode == HVACMode.OFF and saved_hvac_mode == HVACMode.COOL:
|
||||
if temp_at_dt >= target_temp + TEMP_HYSTERESIS:
|
||||
_LOGGER.info(
|
||||
"%s - We need to start, because it will be time to cool",
|
||||
self,
|
||||
)
|
||||
return AUTO_START_STOP_ACTION_ON
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"%s - nothing to do, we don't need to cool soon",
|
||||
self,
|
||||
)
|
||||
return AUTO_START_STOP_ACTION_NOTHING
|
||||
|
||||
_LOGGER.debug(
|
||||
"%s - nothing to do, no conditions applied",
|
||||
self,
|
||||
)
|
||||
return AUTO_START_STOP_ACTION_NOTHING
|
||||
|
||||
def set_level(self, level: TYPE_AUTO_START_STOP_LEVELS):
|
||||
"""Set a new level"""
|
||||
self._init_level(level)
|
||||
|
||||
@property
|
||||
def dt_min(self) -> float:
|
||||
"""Get the dt value"""
|
||||
return self._dt
|
||||
|
||||
@property
|
||||
def accumulated_error(self) -> float:
|
||||
"""Get the accumulated error value"""
|
||||
return self._accumulated_error
|
||||
|
||||
@property
|
||||
def accumulated_error_threshold(self) -> float:
|
||||
"""Get the accumulated error threshold value"""
|
||||
return self._error_threshold
|
||||
|
||||
@property
|
||||
def level(self) -> TYPE_AUTO_START_STOP_LEVELS:
|
||||
"""Get the level value"""
|
||||
return self._level
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"AutoStartStopDetectionAlgorithm-{self._vtherm_name}"
|
||||
@@ -62,72 +62,7 @@ from homeassistant.const import (
|
||||
STATE_NOT_HOME,
|
||||
)
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
DEVICE_MANUFACTURER,
|
||||
CONF_POWER_SENSOR,
|
||||
CONF_TEMP_SENSOR,
|
||||
CONF_LAST_SEEN_TEMP_SENSOR,
|
||||
CONF_EXTERNAL_TEMP_SENSOR,
|
||||
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_OFF_DELAY,
|
||||
CONF_MOTION_PRESET,
|
||||
CONF_NO_MOTION_PRESET,
|
||||
CONF_DEVICE_POWER,
|
||||
CONF_PRESETS,
|
||||
# CONF_PRESETS_AWAY,
|
||||
# CONF_PRESETS_WITH_AC,
|
||||
# CONF_PRESETS_AWAY_WITH_AC,
|
||||
CONF_CYCLE_MIN,
|
||||
CONF_PROP_FUNCTION,
|
||||
CONF_TPI_COEF_INT,
|
||||
CONF_TPI_COEF_EXT,
|
||||
CONF_PRESENCE_SENSOR,
|
||||
CONF_PRESET_POWER,
|
||||
SUPPORT_FLAGS,
|
||||
PRESET_FROST_PROTECTION,
|
||||
PRESET_POWER,
|
||||
PRESET_SECURITY,
|
||||
PROPORTIONAL_FUNCTION_TPI,
|
||||
PRESET_AWAY_SUFFIX,
|
||||
CONF_SECURITY_DELAY_MIN,
|
||||
CONF_SECURITY_MIN_ON_PERCENT,
|
||||
CONF_SECURITY_DEFAULT_ON_PERCENT,
|
||||
DEFAULT_SECURITY_MIN_ON_PERCENT,
|
||||
DEFAULT_SECURITY_DEFAULT_ON_PERCENT,
|
||||
CONF_MINIMAL_ACTIVATION_DELAY,
|
||||
CONF_USE_MAIN_CENTRAL_CONFIG,
|
||||
CONF_USE_TPI_CENTRAL_CONFIG,
|
||||
CONF_USE_PRESETS_CENTRAL_CONFIG,
|
||||
CONF_USE_WINDOW_CENTRAL_CONFIG,
|
||||
CONF_USE_MOTION_CENTRAL_CONFIG,
|
||||
CONF_USE_POWER_CENTRAL_CONFIG,
|
||||
CONF_USE_PRESENCE_CENTRAL_CONFIG,
|
||||
CONF_USE_ADVANCED_CENTRAL_CONFIG,
|
||||
CONF_USE_PRESENCE_FEATURE,
|
||||
CONF_TEMP_MAX,
|
||||
CONF_TEMP_MIN,
|
||||
HIDDEN_PRESETS,
|
||||
CONF_AC_MODE,
|
||||
EventType,
|
||||
ATTR_MEAN_POWER_CYCLE,
|
||||
ATTR_TOTAL_ENERGY,
|
||||
PRESET_AC_SUFFIX,
|
||||
DEFAULT_SHORT_EMA_PARAMS,
|
||||
CENTRAL_MODE_AUTO,
|
||||
CENTRAL_MODE_STOPPED,
|
||||
CENTRAL_MODE_HEAT_ONLY,
|
||||
CENTRAL_MODE_COOL_ONLY,
|
||||
CENTRAL_MODE_FROST_PROTECTION,
|
||||
send_vtherm_event,
|
||||
)
|
||||
from .const import * # pylint: disable=wildcard-import, unused-wildcard-import
|
||||
|
||||
from .config_schema import * # pylint: disable=wildcard-import, unused-wildcard-import
|
||||
|
||||
@@ -199,6 +134,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
|
||||
"is_device_active",
|
||||
"target_temperature_step",
|
||||
"is_used_by_central_boiler",
|
||||
"temperature_slope"
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -303,6 +239,8 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
|
||||
|
||||
self._use_central_config_temperature = False
|
||||
|
||||
self._hvac_off_reason: HVAC_OFF_REASONS | None = None
|
||||
|
||||
self.post_init(entry_infos)
|
||||
|
||||
def clean_central_config_doublon(
|
||||
@@ -848,18 +786,24 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
|
||||
else:
|
||||
self._attr_preset_mode = PRESET_NONE
|
||||
|
||||
# Restore old hvac_off_reason
|
||||
self._hvac_off_reason = old_state.attributes.get(HVAC_OFF_REASON_NAME, None)
|
||||
|
||||
if old_state.state in [
|
||||
HVACMode.OFF,
|
||||
HVACMode.HEAT,
|
||||
HVACMode.COOL,
|
||||
]:
|
||||
self._hvac_mode = old_state.state
|
||||
else:
|
||||
if not self._hvac_mode:
|
||||
self._hvac_mode = HVACMode.OFF
|
||||
|
||||
# restpre also saved info so that window detection will work
|
||||
self._saved_hvac_mode = old_state.attributes.get("saved_hvac_mode", None)
|
||||
self._saved_preset_mode = old_state.attributes.get(
|
||||
"saved_preset_mode", None
|
||||
)
|
||||
|
||||
old_total_energy = old_state.attributes.get(ATTR_TOTAL_ENERGY)
|
||||
self._total_energy = old_total_energy if old_total_energy else 0
|
||||
self._total_energy = old_total_energy if old_total_energy is not None else 0
|
||||
|
||||
self.restore_specific_previous_state(old_state)
|
||||
else:
|
||||
@@ -874,12 +818,14 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
|
||||
)
|
||||
self._total_energy = 0
|
||||
|
||||
self._saved_target_temp = self._target_temp
|
||||
|
||||
# Set default state to off
|
||||
if not self._hvac_mode:
|
||||
self._hvac_mode = HVACMode.OFF
|
||||
|
||||
if not self.is_on and self.hvac_off_reason is None:
|
||||
self.set_hvac_off_reason(HVAC_OFF_REASON_MANUAL)
|
||||
|
||||
self._saved_target_temp = self._target_temp
|
||||
|
||||
self.send_event(EventType.PRESET_EVENT, {"preset": self._attr_preset_mode})
|
||||
self.send_event(EventType.HVAC_MODE_EVENT, {"hvac_mode": self._hvac_mode})
|
||||
|
||||
@@ -987,16 +933,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode | None:
|
||||
"""Return current operation."""
|
||||
# Issue #114 - returns my current hvac_mode and not the underlying hvac_mode which could be different
|
||||
# delta will be managed by climate_state_change event.
|
||||
# if self.is_over_climate:
|
||||
# if one not OFF -> return it
|
||||
# else OFF
|
||||
# for under in self._underlyings:
|
||||
# if (mode := under.hvac_mode) not in [HVACMode.OFF]
|
||||
# return mode
|
||||
# return HVACMode.OFF
|
||||
|
||||
return self._hvac_mode
|
||||
|
||||
@property
|
||||
@@ -1193,6 +1129,13 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
|
||||
"""True if this VTHerm uses the central configuration temperature"""
|
||||
return self._use_central_config_temperature
|
||||
|
||||
@property
|
||||
def hvac_off_reason(self) -> HVAC_OFF_REASONS:
|
||||
"""Returns the reason of the last switch to HVAC_OFF
|
||||
This is useful for features that turns off the VTherm like
|
||||
window detection or auto-start-stop"""
|
||||
return self._hvac_off_reason
|
||||
|
||||
def underlying_entity_id(self, index=0) -> str | None:
|
||||
"""The climate_entity_id. Added for retrocompatibility reason"""
|
||||
if index < self.nb_underlying_entities:
|
||||
@@ -1255,9 +1198,11 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
|
||||
|
||||
# Ensure we update the current operation after changing the mode
|
||||
self.reset_last_temperature_time()
|
||||
|
||||
self.reset_last_change_time()
|
||||
|
||||
if self._hvac_mode != HVACMode.OFF:
|
||||
self.set_hvac_off_reason(None)
|
||||
|
||||
self.update_custom_attributes()
|
||||
self.async_write_ha_state()
|
||||
self.send_event(EventType.HVAC_MODE_EVENT, {"hvac_mode": self._hvac_mode})
|
||||
@@ -1760,6 +1705,19 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
|
||||
for under in self._underlyings:
|
||||
await under.check_initial_state(self._hvac_mode)
|
||||
|
||||
# Prevent from starting a VTherm if window is open
|
||||
if (
|
||||
self.is_window_auto_enabled
|
||||
and self._window_sensor_entity_id is not None
|
||||
and self._hass.states.is_state(self._window_sensor_entity_id, STATE_ON)
|
||||
and self.is_on
|
||||
and self.window_action == CONF_WINDOW_TURN_OFF
|
||||
):
|
||||
_LOGGER.info("%s - the window is open. Prevent starting the VTherm")
|
||||
self._window_auto_state = True
|
||||
self.save_hvac_mode()
|
||||
await self.async_set_hvac_mode(HVACMode.OFF)
|
||||
|
||||
# Starts the initial control loop (don't wait for an update of temperature)
|
||||
await self.async_control_heating(force=True)
|
||||
|
||||
@@ -2096,6 +2054,10 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
|
||||
self._hvac_mode,
|
||||
)
|
||||
|
||||
def set_hvac_off_reason(self, hvac_off_reason: HVAC_OFF_REASONS):
|
||||
"""Set the reason of hvac_off"""
|
||||
self._hvac_off_reason = hvac_off_reason
|
||||
|
||||
async def restore_hvac_mode(self, need_control_heating=False):
|
||||
"""Restore a previous hvac_mod"""
|
||||
await self.async_set_hvac_mode(self._saved_hvac_mode, need_control_heating)
|
||||
@@ -2227,13 +2189,16 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
|
||||
if self.window_state is not STATE_ON and not first_init:
|
||||
await self.restore_hvac_mode()
|
||||
await self.restore_preset_mode()
|
||||
|
||||
elif self.window_state is STATE_ON and self.hvac_mode == HVACMode.OFF:
|
||||
# do not restore but mark the reason of off with window detection
|
||||
self.set_hvac_off_reason(HVAC_OFF_REASON_WINDOW_DETECTION)
|
||||
return
|
||||
|
||||
if old_central_mode == CENTRAL_MODE_AUTO and self.window_state is not STATE_ON:
|
||||
save_all()
|
||||
|
||||
if new_central_mode == CENTRAL_MODE_STOPPED:
|
||||
self.set_hvac_off_reason(HVAC_OFF_REASON_MANUAL)
|
||||
await self.async_set_hvac_mode(HVACMode.OFF)
|
||||
return
|
||||
|
||||
@@ -2241,6 +2206,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
|
||||
if HVACMode.COOL in self.hvac_modes:
|
||||
await self.async_set_hvac_mode(HVACMode.COOL)
|
||||
else:
|
||||
self.set_hvac_off_reason(HVAC_OFF_REASON_MANUAL)
|
||||
await self.async_set_hvac_mode(HVACMode.OFF)
|
||||
return
|
||||
|
||||
@@ -2248,6 +2214,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
|
||||
if HVACMode.HEAT in self.hvac_modes:
|
||||
await self.async_set_hvac_mode(HVACMode.HEAT)
|
||||
else:
|
||||
self.set_hvac_off_reason(HVAC_OFF_REASON_MANUAL)
|
||||
await self.async_set_hvac_mode(HVACMode.OFF)
|
||||
return
|
||||
|
||||
@@ -2261,6 +2228,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
|
||||
PRESET_FROST_PROTECTION, overwrite_saved_preset=False
|
||||
)
|
||||
else:
|
||||
self.set_hvac_off_reason(HVAC_OFF_REASON_MANUAL)
|
||||
await self.async_set_hvac_mode(HVACMode.OFF)
|
||||
return
|
||||
|
||||
@@ -2440,17 +2408,27 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
|
||||
"""Change the window detection state.
|
||||
new_state is on if an open window have been detected or off else
|
||||
"""
|
||||
if not new_state:
|
||||
if new_state is False:
|
||||
_LOGGER.info(
|
||||
"%s - Window is closed. Restoring hvac_mode '%s' if central_mode is not STOPPED",
|
||||
"%s - Window is closed. Restoring hvac_mode '%s' if stopped by window detection or temperature %s",
|
||||
self,
|
||||
self._saved_hvac_mode,
|
||||
self._saved_target_temp,
|
||||
)
|
||||
if self._window_action in [CONF_WINDOW_FROST_TEMP, CONF_WINDOW_ECO_TEMP]:
|
||||
await self._async_internal_set_temperature(self._saved_target_temp)
|
||||
|
||||
# default to TURN_OFF
|
||||
elif self._window_action in [CONF_WINDOW_TURN_OFF, CONF_WINDOW_FAN_ONLY]:
|
||||
elif self._window_action in [CONF_WINDOW_TURN_OFF]:
|
||||
if (
|
||||
self.last_central_mode != CENTRAL_MODE_STOPPED
|
||||
and self.hvac_off_reason == HVAC_OFF_REASON_WINDOW_DETECTION
|
||||
):
|
||||
self.set_hvac_off_reason(None)
|
||||
await self.restore_hvac_mode(True)
|
||||
elif self._window_action in [CONF_WINDOW_FAN_ONLY]:
|
||||
if self.last_central_mode != CENTRAL_MODE_STOPPED:
|
||||
self.set_hvac_off_reason(None)
|
||||
await self.restore_hvac_mode(True)
|
||||
else:
|
||||
_LOGGER.error(
|
||||
@@ -2462,6 +2440,12 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
|
||||
_LOGGER.info(
|
||||
"%s - Window is open. Set hvac_mode to '%s'", self, HVACMode.OFF
|
||||
)
|
||||
if self._window_action == CONF_WINDOW_TURN_OFF and not self.is_on:
|
||||
_LOGGER.debug(
|
||||
"%s is already off. Forget turning off VTherm due to window detection"
|
||||
)
|
||||
return
|
||||
|
||||
if self.last_central_mode in [CENTRAL_MODE_AUTO, None]:
|
||||
if self._window_action in [CONF_WINDOW_TURN_OFF, CONF_WINDOW_FAN_ONLY]:
|
||||
self.save_hvac_mode()
|
||||
@@ -2491,6 +2475,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
|
||||
self.find_preset_temp(PRESET_ECO)
|
||||
)
|
||||
else: # default is to turn_off
|
||||
self.set_hvac_off_reason(HVAC_OFF_REASON_WINDOW_DETECTION)
|
||||
await self.async_set_hvac_mode(HVACMode.OFF)
|
||||
|
||||
async def async_control_heating(self, force=False, _=None) -> bool:
|
||||
@@ -2633,6 +2618,8 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]):
|
||||
"is_device_active": self.is_device_active,
|
||||
"ema_temp": self._ema_temp,
|
||||
"is_used_by_central_boiler": self.is_used_by_central_boiler,
|
||||
"temperature_slope": round(self.last_temperature_slope or 0, 3),
|
||||
"hvac_off_reason": self.hvac_off_reason,
|
||||
}
|
||||
|
||||
@callback
|
||||
|
||||
@@ -100,7 +100,7 @@ class SecurityBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity):
|
||||
entry_infos,
|
||||
) -> None:
|
||||
"""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_unique_id = f"{self._device_name}_security_state"
|
||||
self._attr_is_on = False
|
||||
|
||||
@@ -128,6 +128,11 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
|
||||
and self._infos.get(CONF_CENTRAL_BOILER_DEACTIVATION_SRV) is not None
|
||||
)
|
||||
|
||||
self._infos[CONF_USE_AUTO_START_STOP_FEATURE] = (
|
||||
self._infos.get(CONF_USE_AUTO_START_STOP_FEATURE) is True
|
||||
and self._infos.get(CONF_THERMOSTAT_TYPE) == CONF_THERMOSTAT_CLIMATE
|
||||
)
|
||||
|
||||
def _init_central_config_flags(self, infos):
|
||||
"""Initialisation of central configuration flags"""
|
||||
is_empty: bool = not bool(infos)
|
||||
@@ -431,6 +436,13 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
|
||||
if self._infos[CONF_USE_PRESENCE_FEATURE] is True:
|
||||
menu_options.append("presence")
|
||||
|
||||
if self._infos.get(CONF_USE_AUTO_START_STOP_FEATURE) is True and self._infos[
|
||||
CONF_THERMOSTAT_TYPE
|
||||
] in [
|
||||
CONF_THERMOSTAT_CLIMATE,
|
||||
]:
|
||||
menu_options.append("auto_start_stop")
|
||||
|
||||
menu_options.append("advanced")
|
||||
|
||||
if self.check_config_complete(self._infos):
|
||||
@@ -520,17 +532,29 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
|
||||
"""Handle the Type flow steps"""
|
||||
_LOGGER.debug("Into ConfigFlow.async_step_features user_input=%s", user_input)
|
||||
|
||||
schema = STEP_FEATURES_DATA_SCHEMA
|
||||
if self._infos[CONF_THERMOSTAT_TYPE] == CONF_THERMOSTAT_CENTRAL_CONFIG:
|
||||
schema = STEP_CENTRAL_FEATURES_DATA_SCHEMA
|
||||
elif self._infos[CONF_THERMOSTAT_TYPE] == CONF_THERMOSTAT_CLIMATE:
|
||||
schema = STEP_CLIMATE_FEATURES_DATA_SCHEMA
|
||||
|
||||
return await self.generic_step(
|
||||
"features",
|
||||
(
|
||||
STEP_CENTRAL_FEATURES_DATA_SCHEMA
|
||||
if self._infos[CONF_THERMOSTAT_TYPE] == CONF_THERMOSTAT_CENTRAL_CONFIG
|
||||
else STEP_FEATURES_DATA_SCHEMA
|
||||
),
|
||||
schema,
|
||||
user_input,
|
||||
self.async_step_menu,
|
||||
)
|
||||
|
||||
async def async_step_auto_start_stop(self, user_input: dict | None = None) -> FlowResult:
|
||||
""" Handle the Auto start stop step"""
|
||||
_LOGGER.debug("Into ConfigFlow.async_step_auto_start_stop user_input=%s", user_input)
|
||||
|
||||
schema = STEP_AUTO_START_STOP
|
||||
self._infos[COMES_FROM] = None
|
||||
next_step = self.async_step_menu
|
||||
|
||||
return await self.generic_step("auto_start_stop", schema, user_input, next_step)
|
||||
|
||||
async def async_step_tpi(self, user_input: dict | None = None) -> FlowResult:
|
||||
"""Handle the TPI flow steps"""
|
||||
_LOGGER.debug("Into ConfigFlow.async_step_tpi user_input=%s", user_input)
|
||||
@@ -869,6 +893,8 @@ class VersatileThermostatOptionsFlowHandler(
|
||||
if not self._infos[CONF_USE_CENTRAL_BOILER_FEATURE]:
|
||||
self._infos[CONF_CENTRAL_BOILER_ACTIVATION_SRV] = None
|
||||
self._infos[CONF_CENTRAL_BOILER_DEACTIVATION_SRV] = None
|
||||
if not self._infos[CONF_USE_AUTO_START_STOP_FEATURE]:
|
||||
self._infos[CONF_AUTO_START_STOP_LEVEL] = AUTO_START_STOP_LEVEL_NONE
|
||||
|
||||
_LOGGER.info(
|
||||
"Recreating entry %s due to configuration change. New config is now: %s",
|
||||
|
||||
@@ -68,6 +68,16 @@ STEP_FEATURES_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name
|
||||
}
|
||||
)
|
||||
|
||||
STEP_CLIMATE_FEATURES_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name
|
||||
{
|
||||
vol.Optional(CONF_USE_WINDOW_FEATURE, default=False): cv.boolean,
|
||||
vol.Optional(CONF_USE_MOTION_FEATURE, default=False): cv.boolean,
|
||||
vol.Optional(CONF_USE_POWER_FEATURE, default=False): cv.boolean,
|
||||
vol.Optional(CONF_USE_PRESENCE_FEATURE, default=False): cv.boolean,
|
||||
vol.Optional(CONF_USE_AUTO_START_STOP_FEATURE, default=False): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
STEP_CENTRAL_FEATURES_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name
|
||||
{
|
||||
vol.Optional(CONF_USE_WINDOW_FEATURE, default=False): cv.boolean,
|
||||
@@ -196,6 +206,20 @@ STEP_THERMOSTAT_VALVE = vol.Schema( # pylint: disable=invalid-name
|
||||
}
|
||||
)
|
||||
|
||||
STEP_AUTO_START_STOP = vol.Schema( # pylint: disable=invalid-name
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_AUTO_START_STOP_LEVEL, default=AUTO_START_STOP_LEVEL_NONE
|
||||
): selector.SelectSelector(
|
||||
selector.SelectSelectorConfig(
|
||||
options=CONF_AUTO_START_STOP_LEVELS,
|
||||
translation_key="auto_start_stop",
|
||||
mode="dropdown",
|
||||
)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
STEP_TPI_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name
|
||||
{
|
||||
vol.Required(CONF_USE_TPI_CENTRAL_CONFIG, default=True): cv.boolean,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"""Constants for the Versatile Thermostat integration."""
|
||||
|
||||
import logging
|
||||
from typing import Literal
|
||||
|
||||
from enum import Enum
|
||||
from homeassistant.const import CONF_NAME, Platform
|
||||
@@ -51,6 +52,7 @@ PLATFORMS: list[Platform] = [
|
||||
# Number should be after CLIMATE
|
||||
Platform.NUMBER,
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.SWITCH,
|
||||
]
|
||||
|
||||
CONF_HEATER = "heater_entity_id"
|
||||
@@ -97,6 +99,7 @@ CONF_USE_MOTION_FEATURE = "use_motion_feature"
|
||||
CONF_USE_PRESENCE_FEATURE = "use_presence_feature"
|
||||
CONF_USE_POWER_FEATURE = "use_power_feature"
|
||||
CONF_USE_CENTRAL_BOILER_FEATURE = "use_central_boiler_feature"
|
||||
CONF_USE_AUTO_START_STOP_FEATURE = "use_auto_start_stop_feature"
|
||||
CONF_AC_MODE = "ac_mode"
|
||||
CONF_WINDOW_AUTO_OPEN_THRESHOLD = "window_auto_open_threshold"
|
||||
CONF_WINDOW_AUTO_CLOSE_THRESHOLD = "window_auto_close_threshold"
|
||||
@@ -145,6 +148,36 @@ CONF_CENTRAL_BOILER_DEACTIVATION_SRV = "central_boiler_deactivation_service"
|
||||
CONF_USED_BY_CENTRAL_BOILER = "used_by_controls_central_boiler"
|
||||
CONF_WINDOW_ACTION = "window_action"
|
||||
|
||||
CONF_AUTO_START_STOP_LEVEL = "auto_start_stop_level"
|
||||
AUTO_START_STOP_LEVEL_NONE = "auto_start_stop_none"
|
||||
AUTO_START_STOP_LEVEL_SLOW = "auto_start_stop_slow"
|
||||
AUTO_START_STOP_LEVEL_MEDIUM = "auto_start_stop_medium"
|
||||
AUTO_START_STOP_LEVEL_FAST = "auto_start_stop_fast"
|
||||
CONF_AUTO_START_STOP_LEVELS = [
|
||||
AUTO_START_STOP_LEVEL_NONE,
|
||||
AUTO_START_STOP_LEVEL_SLOW,
|
||||
AUTO_START_STOP_LEVEL_MEDIUM,
|
||||
AUTO_START_STOP_LEVEL_FAST,
|
||||
]
|
||||
|
||||
# For explicit typing purpose only
|
||||
TYPE_AUTO_START_STOP_LEVELS = Literal[ # pylint: disable=invalid-name
|
||||
AUTO_START_STOP_LEVEL_FAST,
|
||||
AUTO_START_STOP_LEVEL_MEDIUM,
|
||||
AUTO_START_STOP_LEVEL_SLOW,
|
||||
AUTO_START_STOP_LEVEL_NONE,
|
||||
]
|
||||
|
||||
HVAC_OFF_REASON_NAME = "hvac_off_reason"
|
||||
HVAC_OFF_REASON_MANUAL = "manual"
|
||||
HVAC_OFF_REASON_AUTO_START_STOP = "auto_start_stop"
|
||||
HVAC_OFF_REASON_WINDOW_DETECTION = "window_detection"
|
||||
HVAC_OFF_REASONS = Literal[ # pylint: disable=invalid-name
|
||||
HVAC_OFF_REASON_MANUAL,
|
||||
HVAC_OFF_REASON_AUTO_START_STOP,
|
||||
HVAC_OFF_REASON_WINDOW_DETECTION,
|
||||
]
|
||||
|
||||
DEFAULT_SHORT_EMA_PARAMS = {
|
||||
"max_alpha": 0.5,
|
||||
# In sec
|
||||
@@ -445,6 +478,7 @@ class EventType(Enum):
|
||||
CENTRAL_BOILER_EVENT: str = "versatile_thermostat_central_boiler_event"
|
||||
PRESET_EVENT: str = "versatile_thermostat_preset_event"
|
||||
WINDOW_AUTO_EVENT: str = "versatile_thermostat_window_auto_event"
|
||||
AUTO_START_STOP_EVENT: str = "versatile_thermostat_auto_start_stop_event"
|
||||
|
||||
|
||||
def send_vtherm_event(hass, event_type: EventType, entity, data: dict):
|
||||
|
||||
@@ -14,6 +14,6 @@
|
||||
"quality_scale": "silver",
|
||||
"requirements": [],
|
||||
"ssdp": [],
|
||||
"version": "6.3.0",
|
||||
"version": "6.5.0",
|
||||
"zeroconf": []
|
||||
}
|
||||
@@ -27,6 +27,7 @@
|
||||
"power": "Power management",
|
||||
"presence": "Presence detection",
|
||||
"advanced": "Advanced parameters",
|
||||
"auto_start_stop": "Auto start and stop",
|
||||
"finalize": "All done",
|
||||
"configuration_not_complete": "Configuration not complete"
|
||||
}
|
||||
@@ -63,7 +64,8 @@
|
||||
"use_motion_feature": "Use motion detection",
|
||||
"use_power_feature": "Use power management",
|
||||
"use_presence_feature": "Use presence detection",
|
||||
"use_central_boiler_feature": "Use a central boiler. Check to add a control to your central boiler. You will have to configure the VTherm which will have a control of the central boiler after seecting this checkbox to take effect. If one VTherm requires heating, the boiler will be turned on. If no VTherm requires heating, the boiler will be turned off. Commands for turning on/off the central boiler are given in the related configuration page"
|
||||
"use_central_boiler_feature": "Use a central boiler. Check to add a control to your central boiler. You will have to configure the VTherm which will have a control of the central boiler after seecting this checkbox to take effect. If one VTherm requires heating, the boiler will be turned on. If no VTherm requires heating, the boiler will be turned off. Commands for turning on/off the central boiler are given in the related configuration page",
|
||||
"use_auto_start_stop_feature": "Use the auto start and stop feature"
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
@@ -262,6 +264,7 @@
|
||||
"power": "Power management",
|
||||
"presence": "Presence detection",
|
||||
"advanced": "Advanced parameters",
|
||||
"auto_start_stop": "Auto start and stop",
|
||||
"finalize": "All done",
|
||||
"configuration_not_complete": "Configuration not complete"
|
||||
}
|
||||
@@ -298,7 +301,8 @@
|
||||
"use_motion_feature": "Use motion detection",
|
||||
"use_power_feature": "Use power management",
|
||||
"use_presence_feature": "Use presence detection",
|
||||
"use_central_boiler_feature": "Use a central boiler. Check to add a control to your central boiler. You will have to configure the VTherm which will have a control of the central boiler after seecting this checkbox to take effect. If one VTherm requires heating, the boiler will be turned on. If no VTherm requires heating, the boiler will be turned off. Commands for turning on/off the central boiler are given in the related configuration page"
|
||||
"use_central_boiler_feature": "Use a central boiler. Check to add a control to your central boiler. You will have to configure the VTherm which will have a control of the central boiler after seecting this checkbox to take effect. If one VTherm requires heating, the boiler will be turned on. If no VTherm requires heating, the boiler will be turned off. Commands for turning on/off the central boiler are given in the related configuration page",
|
||||
"use_auto_start_stop_feature": "Use the auto start and stop feature"
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
@@ -514,6 +518,14 @@
|
||||
"comfort": "Comfort",
|
||||
"boost": "Boost"
|
||||
}
|
||||
},
|
||||
"auto_start_stop": {
|
||||
"options": {
|
||||
"auto_start_stop_none": "No auto start/stop",
|
||||
"auto_start_stop_slow": "Slow detection",
|
||||
"auto_start_stop_medium": "Medium detection",
|
||||
"auto_start_stop_fast": "Fast detection"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
|
||||
102
custom_components/versatile_thermostat/switch.py
Normal file
102
custom_components/versatile_thermostat/switch.py
Normal 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()
|
||||
@@ -20,40 +20,15 @@ from .commons import NowClass, round_to_nearest
|
||||
from .base_thermostat import BaseThermostat, ConfigData
|
||||
from .pi_algorithm import PITemperatureRegulator
|
||||
|
||||
from .const import (
|
||||
overrides,
|
||||
DOMAIN,
|
||||
CONF_CLIMATE,
|
||||
CONF_CLIMATE_2,
|
||||
CONF_CLIMATE_3,
|
||||
CONF_CLIMATE_4,
|
||||
CONF_AUTO_REGULATION_MODE,
|
||||
CONF_AUTO_REGULATION_NONE,
|
||||
CONF_AUTO_REGULATION_SLOW,
|
||||
CONF_AUTO_REGULATION_LIGHT,
|
||||
CONF_AUTO_REGULATION_MEDIUM,
|
||||
CONF_AUTO_REGULATION_STRONG,
|
||||
CONF_AUTO_REGULATION_EXPERT,
|
||||
CONF_AUTO_REGULATION_DTEMP,
|
||||
CONF_AUTO_REGULATION_PERIOD_MIN,
|
||||
CONF_AUTO_REGULATION_USE_DEVICE_TEMP,
|
||||
CONF_AUTO_FAN_MODE,
|
||||
CONF_AUTO_FAN_NONE,
|
||||
CONF_AUTO_FAN_LOW,
|
||||
CONF_AUTO_FAN_MEDIUM,
|
||||
CONF_AUTO_FAN_HIGH,
|
||||
CONF_AUTO_FAN_TURBO,
|
||||
RegulationParamSlow,
|
||||
RegulationParamLight,
|
||||
RegulationParamMedium,
|
||||
RegulationParamStrong,
|
||||
AUTO_FAN_DTEMP_THRESHOLD,
|
||||
AUTO_FAN_DEACTIVATED_MODES,
|
||||
UnknownEntity,
|
||||
)
|
||||
from .const import * # pylint: disable=wildcard-import, unused-wildcard-import
|
||||
|
||||
from .vtherm_api import VersatileThermostatAPI
|
||||
from .underlyings import UnderlyingClimate
|
||||
from .auto_start_stop_algorithm import (
|
||||
AutoStartStopDetectionAlgorithm,
|
||||
AUTO_START_STOP_ACTION_OFF,
|
||||
AUTO_START_STOP_ACTION_ON,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -64,7 +39,6 @@ HVAC_ACTION_ON = [ # pylint: disable=invalid-name
|
||||
HVACAction.HEATING,
|
||||
]
|
||||
|
||||
|
||||
class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
|
||||
"""Representation of a base class for a Versatile Thermostat over a climate"""
|
||||
|
||||
@@ -81,6 +55,9 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
|
||||
# The fan_mode name depending of the current_mode
|
||||
_auto_activated_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_algo: AutoStartStopDetectionAlgorithm | None = None
|
||||
_is_auto_start_stop_enabled: bool = False
|
||||
|
||||
_entity_component_unrecorded_attributes = (
|
||||
BaseThermostat._entity_component_unrecorded_attributes.union(
|
||||
@@ -99,6 +76,11 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
|
||||
"auto_activated_fan_mode",
|
||||
"auto_deactivated_fan_mode",
|
||||
"auto_regulation_use_device_temp",
|
||||
"auto_start_stop_level",
|
||||
"auto_start_stop_dtmin",
|
||||
"auto_start_stop_enable",
|
||||
"auto_start_stop_accumulated_error",
|
||||
"auto_start_stop_accumulated_error_threshold",
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -113,6 +95,66 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
|
||||
self._regulated_target_temp = self.target_temperature
|
||||
self._last_regulation_change = NowClass.get_now(hass)
|
||||
|
||||
@overrides
|
||||
def post_init(self, config_entry: ConfigData):
|
||||
"""Initialize the Thermostat"""
|
||||
|
||||
super().post_init(config_entry)
|
||||
for climate in [
|
||||
CONF_CLIMATE,
|
||||
CONF_CLIMATE_2,
|
||||
CONF_CLIMATE_3,
|
||||
CONF_CLIMATE_4,
|
||||
]:
|
||||
if config_entry.get(climate):
|
||||
self._underlyings.append(
|
||||
UnderlyingClimate(
|
||||
hass=self._hass,
|
||||
thermostat=self,
|
||||
climate_entity_id=config_entry.get(climate),
|
||||
)
|
||||
)
|
||||
|
||||
self.choose_auto_regulation_mode(
|
||||
config_entry.get(CONF_AUTO_REGULATION_MODE)
|
||||
if config_entry.get(CONF_AUTO_REGULATION_MODE) is not None
|
||||
else CONF_AUTO_REGULATION_NONE
|
||||
)
|
||||
|
||||
self._auto_regulation_dtemp = (
|
||||
config_entry.get(CONF_AUTO_REGULATION_DTEMP)
|
||||
if config_entry.get(CONF_AUTO_REGULATION_DTEMP) is not None
|
||||
else 0.5
|
||||
)
|
||||
self._auto_regulation_period_min = (
|
||||
config_entry.get(CONF_AUTO_REGULATION_PERIOD_MIN)
|
||||
if config_entry.get(CONF_AUTO_REGULATION_PERIOD_MIN) is not None
|
||||
else 5
|
||||
)
|
||||
|
||||
self._auto_fan_mode = (
|
||||
config_entry.get(CONF_AUTO_FAN_MODE)
|
||||
if config_entry.get(CONF_AUTO_FAN_MODE) is not None
|
||||
else CONF_AUTO_FAN_NONE
|
||||
)
|
||||
|
||||
self._auto_regulation_use_device_temp = config_entry.get(
|
||||
CONF_AUTO_REGULATION_USE_DEVICE_TEMP, False
|
||||
)
|
||||
|
||||
use_auto_start_stop = config_entry.get(CONF_USE_AUTO_START_STOP_FEATURE, False)
|
||||
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
|
||||
self._auto_start_stop_algo = AutoStartStopDetectionAlgorithm(
|
||||
self._auto_start_stop_level, self.name
|
||||
)
|
||||
|
||||
@property
|
||||
def is_over_climate(self) -> bool:
|
||||
"""True if the Thermostat is over_climate"""
|
||||
@@ -228,18 +270,6 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
|
||||
and self.auto_regulation_use_device_temp
|
||||
# and we have access to the device temp
|
||||
and (device_temp := under.underlying_current_temperature) is not None
|
||||
# issue 467 - always apply offset. TODO removes this if ok
|
||||
# and target is not reach (ie we need regulation)
|
||||
# and (
|
||||
# (
|
||||
# self.hvac_mode == HVACMode.COOL
|
||||
# and self.target_temperature < self.current_temperature
|
||||
# )
|
||||
# or (
|
||||
# self.hvac_mode == HVACMode.HEAT
|
||||
# and self.target_temperature > self.current_temperature
|
||||
# )
|
||||
# )
|
||||
):
|
||||
offset_temp = device_temp - self.current_temperature
|
||||
|
||||
@@ -304,53 +334,6 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
|
||||
)
|
||||
await self.async_set_fan_mode(self._auto_deactivated_fan_mode)
|
||||
|
||||
@overrides
|
||||
def post_init(self, config_entry: ConfigData):
|
||||
"""Initialize the Thermostat"""
|
||||
|
||||
super().post_init(config_entry)
|
||||
for climate in [
|
||||
CONF_CLIMATE,
|
||||
CONF_CLIMATE_2,
|
||||
CONF_CLIMATE_3,
|
||||
CONF_CLIMATE_4,
|
||||
]:
|
||||
if config_entry.get(climate):
|
||||
self._underlyings.append(
|
||||
UnderlyingClimate(
|
||||
hass=self._hass,
|
||||
thermostat=self,
|
||||
climate_entity_id=config_entry.get(climate),
|
||||
)
|
||||
)
|
||||
|
||||
self.choose_auto_regulation_mode(
|
||||
config_entry.get(CONF_AUTO_REGULATION_MODE)
|
||||
if config_entry.get(CONF_AUTO_REGULATION_MODE) is not None
|
||||
else CONF_AUTO_REGULATION_NONE
|
||||
)
|
||||
|
||||
self._auto_regulation_dtemp = (
|
||||
config_entry.get(CONF_AUTO_REGULATION_DTEMP)
|
||||
if config_entry.get(CONF_AUTO_REGULATION_DTEMP) is not None
|
||||
else 0.5
|
||||
)
|
||||
self._auto_regulation_period_min = (
|
||||
config_entry.get(CONF_AUTO_REGULATION_PERIOD_MIN)
|
||||
if config_entry.get(CONF_AUTO_REGULATION_PERIOD_MIN) is not None
|
||||
else 5
|
||||
)
|
||||
|
||||
self._auto_fan_mode = (
|
||||
config_entry.get(CONF_AUTO_FAN_MODE)
|
||||
if config_entry.get(CONF_AUTO_FAN_MODE) is not None
|
||||
else CONF_AUTO_FAN_NONE
|
||||
)
|
||||
|
||||
self._auto_regulation_use_device_temp = config_entry.get(
|
||||
CONF_AUTO_REGULATION_USE_DEVICE_TEMP, False
|
||||
)
|
||||
|
||||
def choose_auto_regulation_mode(self, auto_regulation_mode: str):
|
||||
"""Choose or change the regulation mode"""
|
||||
self._auto_regulation_mode = auto_regulation_mode
|
||||
@@ -563,6 +546,24 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
|
||||
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._auto_start_stop_algo.level
|
||||
)
|
||||
self._attr_extra_state_attributes["auto_start_stop_dtmin"] = (
|
||||
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()
|
||||
_LOGGER.debug(
|
||||
"%s - Calling update_custom_attributes: %s",
|
||||
@@ -876,11 +877,85 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
|
||||
|
||||
await end_climate_changed(changes)
|
||||
|
||||
async def check_auto_start_stop(self):
|
||||
"""Check the auto-start-stop and an eventual action
|
||||
Return False if we should stop the control_heating method"""
|
||||
slope = (self.last_temperature_slope or 0) / 60 # to have the slope in °/min
|
||||
action = self._auto_start_stop_algo.calculate_action(
|
||||
self.hvac_mode,
|
||||
self._saved_hvac_mode,
|
||||
self.target_temperature,
|
||||
self.current_temperature,
|
||||
slope,
|
||||
self.now,
|
||||
)
|
||||
_LOGGER.debug("%s - auto_start_stop action is %s", self, action)
|
||||
if action == AUTO_START_STOP_ACTION_OFF and self.is_on:
|
||||
_LOGGER.info(
|
||||
"%s - Turning OFF the Vtherm due to auto-start-stop conditions",
|
||||
self,
|
||||
)
|
||||
self.set_hvac_off_reason(HVAC_OFF_REASON_AUTO_START_STOP)
|
||||
await self.async_turn_off()
|
||||
|
||||
# Send an event
|
||||
self.send_event(
|
||||
event_type=EventType.AUTO_START_STOP_EVENT,
|
||||
data={
|
||||
"type": "stop",
|
||||
"name": self.name,
|
||||
"cause": "Auto stop conditions reached",
|
||||
"hvac_mode": self.hvac_mode,
|
||||
"saved_hvac_mode": self._saved_hvac_mode,
|
||||
"target_temperature": self.target_temperature,
|
||||
"current_temperature": self.current_temperature,
|
||||
"temperature_slope": round(slope, 3),
|
||||
},
|
||||
)
|
||||
|
||||
# Stop here
|
||||
return False
|
||||
elif action == AUTO_START_STOP_ACTION_ON:
|
||||
_LOGGER.info(
|
||||
"%s - Turning ON the Vtherm due to auto-start-stop conditions", self
|
||||
)
|
||||
await self.async_turn_on()
|
||||
|
||||
# Send an event
|
||||
self.send_event(
|
||||
event_type=EventType.AUTO_START_STOP_EVENT,
|
||||
data={
|
||||
"type": "start",
|
||||
"name": self.name,
|
||||
"cause": "Auto start conditions reached",
|
||||
"hvac_mode": self.hvac_mode,
|
||||
"saved_hvac_mode": self._saved_hvac_mode,
|
||||
"target_temperature": self.target_temperature,
|
||||
"current_temperature": self.current_temperature,
|
||||
"temperature_slope": round(slope, 3),
|
||||
},
|
||||
)
|
||||
|
||||
self.update_custom_attributes()
|
||||
|
||||
return True
|
||||
|
||||
@overrides
|
||||
async def async_control_heating(self, force=False, _=None) -> bool:
|
||||
"""The main function used to run the calculation at each cycle"""
|
||||
ret = await super().async_control_heating(force, _)
|
||||
|
||||
# Check if we need to auto start/stop the Vtherm
|
||||
if self.auto_start_stop_enable:
|
||||
continu = await self.check_auto_start_stop()
|
||||
if not continu:
|
||||
return ret
|
||||
else:
|
||||
_LOGGER.debug("%s - auto start/stop is disabled")
|
||||
|
||||
# Continue the normal async_control_heating
|
||||
|
||||
# Send the regulated temperature to the underlyings
|
||||
await self._send_regulated_temperature()
|
||||
|
||||
if self._auto_fan_mode and self._auto_fan_mode != CONF_AUTO_FAN_NONE:
|
||||
@@ -888,6 +963,11 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
|
||||
|
||||
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
|
||||
def auto_regulation_mode(self) -> str | None:
|
||||
"""Get the regulation mode"""
|
||||
@@ -1034,6 +1114,16 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]):
|
||||
return False
|
||||
return True
|
||||
|
||||
@property
|
||||
def auto_start_stop_level(self) -> TYPE_AUTO_START_STOP_LEVELS:
|
||||
"""Return the 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
|
||||
def init_underlyings(self):
|
||||
"""Init the underlyings if not already done"""
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"power": "Power management",
|
||||
"presence": "Presence detection",
|
||||
"advanced": "Advanced parameters",
|
||||
"auto_start_stop": "Auto start and stop",
|
||||
"finalize": "All done",
|
||||
"configuration_not_complete": "Configuration not complete"
|
||||
}
|
||||
@@ -63,7 +64,8 @@
|
||||
"use_motion_feature": "Use motion detection",
|
||||
"use_power_feature": "Use power management",
|
||||
"use_presence_feature": "Use presence detection",
|
||||
"use_central_boiler_feature": "Use a central boiler. Check to add a control to your central boiler. You will have to configure the VTherm which will have a control of the central boiler after seecting this checkbox to take effect. If one VTherm requires heating, the boiler will be turned on. If no VTherm requires heating, the boiler will be turned off. Commands for turning on/off the central boiler are given in the related configuration page"
|
||||
"use_central_boiler_feature": "Use a central boiler. Check to add a control to your central boiler. You will have to configure the VTherm which will have a control of the central boiler after seecting this checkbox to take effect. If one VTherm requires heating, the boiler will be turned on. If no VTherm requires heating, the boiler will be turned off. Commands for turning on/off the central boiler are given in the related configuration page",
|
||||
"use_auto_start_stop_feature": "Use the auto start and stop feature"
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
@@ -262,6 +264,7 @@
|
||||
"power": "Power management",
|
||||
"presence": "Presence detection",
|
||||
"advanced": "Advanced parameters",
|
||||
"auto_start_stop": "Auto start and stop",
|
||||
"finalize": "All done",
|
||||
"configuration_not_complete": "Configuration not complete"
|
||||
}
|
||||
@@ -298,7 +301,8 @@
|
||||
"use_motion_feature": "Use motion detection",
|
||||
"use_power_feature": "Use power management",
|
||||
"use_presence_feature": "Use presence detection",
|
||||
"use_central_boiler_feature": "Use a central boiler. Check to add a control to your central boiler. You will have to configure the VTherm which will have a control of the central boiler after seecting this checkbox to take effect. If one VTherm requires heating, the boiler will be turned on. If no VTherm requires heating, the boiler will be turned off. Commands for turning on/off the central boiler are given in the related configuration page"
|
||||
"use_central_boiler_feature": "Use a central boiler. Check to add a control to your central boiler. You will have to configure the VTherm which will have a control of the central boiler after seecting this checkbox to take effect. If one VTherm requires heating, the boiler will be turned on. If no VTherm requires heating, the boiler will be turned off. Commands for turning on/off the central boiler are given in the related configuration page",
|
||||
"use_auto_start_stop_feature": "Use the auto start and stop feature"
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
@@ -514,6 +518,14 @@
|
||||
"comfort": "Comfort",
|
||||
"boost": "Boost"
|
||||
}
|
||||
},
|
||||
"auto_start_stop": {
|
||||
"options": {
|
||||
"auto_start_stop_none": "No auto start/stop",
|
||||
"auto_start_stop_slow": "Slow detection",
|
||||
"auto_start_stop_medium": "Medium detection",
|
||||
"auto_start_stop_fast": "Fast detection"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"power": "Gestion de la puissance",
|
||||
"presence": "Détection de présence",
|
||||
"advanced": "Paramètres avancés",
|
||||
"auto_start_stop": "Allumage/extinction automatique",
|
||||
"finalize": "Finaliser la création",
|
||||
"configuration_not_complete": "Configuration incomplète"
|
||||
}
|
||||
@@ -63,7 +64,8 @@
|
||||
"use_motion_feature": "Avec détection de mouvement",
|
||||
"use_power_feature": "Avec gestion de la puissance",
|
||||
"use_presence_feature": "Avec détection de présence",
|
||||
"use_central_boiler_feature": "Ajouter une chaudière centrale. Cochez pour ajouter un controle sur une chaudière centrale. Vous devrez ensuite configurer les VTherms qui commande la chaudière centrale pour que cette option prenne effet. Si au moins un des VTherm a besoin de chauffer, la chaudière centrale sera activée. Si aucun VTherm n'a besoin de chauffer, elle sera éteinte. Les commandes pour allumer/éteindre la chaudière centrale sont données dans la page de configuration suivante."
|
||||
"use_central_boiler_feature": "Ajouter une chaudière centrale. Cochez pour ajouter un controle sur une chaudière centrale. Vous devrez ensuite configurer les VTherms qui commande la chaudière centrale pour que cette option prenne effet. Si au moins un des VTherm a besoin de chauffer, la chaudière centrale sera activée. Si aucun VTherm n'a besoin de chauffer, elle sera éteinte. Les commandes pour allumer/éteindre la chaudière centrale sont données dans la page de configuration suivante.",
|
||||
"use_auto_start_stop_feature": "Avec démarrage et extinction automatique"
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
@@ -274,6 +276,7 @@
|
||||
"power": "Gestion de la puissance",
|
||||
"presence": "Détection de présence",
|
||||
"advanced": "Paramètres avancés",
|
||||
"auto_start_stop": "Allumage/extinction automatique",
|
||||
"finalize": "Finaliser les modifications",
|
||||
"configuration_not_complete": "Configuration incomplète"
|
||||
}
|
||||
@@ -310,7 +313,8 @@
|
||||
"use_motion_feature": "Avec détection de mouvement",
|
||||
"use_power_feature": "Avec gestion de la puissance",
|
||||
"use_presence_feature": "Avec détection de présence",
|
||||
"use_central_boiler_feature": "Ajouter une chaudière centrale. Cochez pour ajouter un controle sur une chaudière centrale. Vous devrez ensuite configurer les VTherms qui commande la chaudière centrale pour que cette option prenne effet. Si au moins un des VTherm a besoin de chauffer, la chaudière centrale sera activée. Si aucun VTherm n'a besoin de chauffer, elle sera éteinte. Les commandes pour allumer/éteindre la chaudière centrale sont données dans la page de configuration suivante."
|
||||
"use_central_boiler_feature": "Ajouter une chaudière centrale. Cochez pour ajouter un controle sur une chaudière centrale. Vous devrez ensuite configurer les VTherms qui commande la chaudière centrale pour que cette option prenne effet. Si au moins un des VTherm a besoin de chauffer, la chaudière centrale sera activée. Si aucun VTherm n'a besoin de chauffer, elle sera éteinte. Les commandes pour allumer/éteindre la chaudière centrale sont données dans la page de configuration suivante.",
|
||||
"use_auto_start_stop_feature": "Avec démarrage et extinction automatique"
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
@@ -532,6 +536,14 @@
|
||||
"comfort": "Confort",
|
||||
"boost": "Renforcé (boost)"
|
||||
}
|
||||
},
|
||||
"auto_start_stop": {
|
||||
"options": {
|
||||
"auto_start_stop_none": "No auto start/stop",
|
||||
"auto_start_stop_slow": "Slow detection",
|
||||
"auto_start_stop_medium": "Medium detection",
|
||||
"auto_start_stop_fast": "Fast detection"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
|
||||
Reference in New Issue
Block a user