Added VT Thermometer to HACS

This commit is contained in:
2026-06-16 10:33:21 -04:00
parent 887feaa50a
commit 3b1f4bbd75
116 changed files with 37607 additions and 14 deletions
+1 -1
View File
@@ -1 +1 @@
{"pid": 71, "version": 1, "ha_version": "2026.6.3", "start_ts": 1781531265.230539}
{"pid": 71, "version": 1, "ha_version": "2026.6.3", "start_ts": 1781553089.670326}
+1 -1
View File
@@ -288,7 +288,7 @@
conditions:
- condition: time
after: '10:00:00'
before: '21:00:00'
before: '20:00:00'
- condition: state
entity_id: light.playroom_light
state: 'off'
@@ -0,0 +1,349 @@
"""The Versatile Thermostat integration."""
from __future__ import annotations
from typing import Dict
import asyncio
import logging
from vtherm_api.log_collector import get_vtherm_logger
import voluptuous as vol
import homeassistant.helpers.config_validation as cv
from homeassistant.const import SERVICE_RELOAD, EVENT_HOMEASSISTANT_STARTED
from homeassistant.config_entries import ConfigEntry, ConfigType
from homeassistant.core import HomeAssistant, CoreState, callback
from homeassistant.helpers.service import async_register_admin_service
from homeassistant.const import UnitOfTemperature
from vtherm_api.log_collector import VThermLogHandler, async_export_logs, async_register_log_download_endpoint, DEFAULT_MAX_AGE_HOURS
from .base_thermostat import BaseThermostat
from .const import * # pylint: disable=wildcard-import, unused-wildcard-import
from .vtherm_central_api import VersatileThermostatAPI
_LOGGER = get_vtherm_logger(__name__)
SELF_REGULATION_PARAM_SCHEMA = {
vol.Required("kp"): vol.Coerce(float),
vol.Required("ki"): vol.Coerce(float),
vol.Required("k_ext"): vol.Coerce(float),
vol.Required("offset_max"): vol.Coerce(float),
vol.Required("accumulated_error_threshold"): vol.Coerce(float),
"overheat_protection": vol.Coerce(bool),
}
EMA_PARAM_SCHEMA = {
vol.Required("max_alpha"): vol.Coerce(float),
vol.Required("halflife_sec"): vol.Coerce(float),
vol.Required("precision"): cv.positive_int,
}
SAFETY_MODE_PARAM_SCHEMA = {
vol.Required("check_outdoor_sensor"): bool,
}
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
{
CONF_AUTO_REGULATION_EXPERT: vol.Schema(SELF_REGULATION_PARAM_SCHEMA),
CONF_SHORT_EMA_PARAMS: vol.Schema(EMA_PARAM_SCHEMA),
CONF_SAFETY_MODE: vol.Schema(SAFETY_MODE_PARAM_SCHEMA),
vol.Optional(CONF_MAX_ON_PERCENT): vol.Coerce(float),
vol.Optional(CONF_LOG_BUFFER_MAX_AGE_HOURS, default=DEFAULT_MAX_AGE_HOURS): cv.positive_int,
}
),
},
extra=vol.ALLOW_EXTRA,
)
async def async_setup(
hass: HomeAssistant, config: ConfigType
): # pylint: disable=unused-argument
"""Initialisation de l'intégration"""
_LOGGER.info(
"Initializing %s integration with config: %s",
DOMAIN,
config.get(DOMAIN),
)
async def _handle_reload(_):
"""The reload callback"""
await reload_all_vtherm(hass)
hass.data.setdefault(DOMAIN, {})
api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(hass)
# L'argument config contient votre fichier configuration.yaml
vtherm_config = config.get(DOMAIN)
if vtherm_config is not None:
api.set_global_config(vtherm_config)
else:
_LOGGER.info("No global config from configuration.yaml available")
# Listen HA starts to initialize all links between
@callback
async def _async_startup_internal(*_):
_LOGGER.info(
"VersatileThermostat - HA is started, initialize all links between VTherm entities"
)
await api.init_vtherm_links()
await api.notify_central_mode_change()
if hass.state == CoreState.running:
await _async_startup_internal()
else:
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _async_startup_internal)
async_register_admin_service(
hass,
DOMAIN,
SERVICE_RELOAD,
_handle_reload,
)
# Initialize log collector
# Note: Single shared instance stored in hass.data[DOMAIN] is used for all VTherm loggers.
# This ensures that all log records are centralized in one ring buffer, regardless of reload.
if "log_handler" not in hass.data[DOMAIN]:
max_age_hours = (
vtherm_config.get(CONF_LOG_BUFFER_MAX_AGE_HOURS, DEFAULT_MAX_AGE_HOURS)
if vtherm_config is not None
else DEFAULT_MAX_AGE_HOURS
)
log_handler = VThermLogHandler(max_age_hours=max_age_hours)
hass.data[DOMAIN]["log_handler"] = log_handler
_LOGGER.info("VTherm log collector initialized (buffer: %d h)", max_age_hours)
# Register the HTTP endpoint for log downloads
# This provides a /api/versatile_thermostat/logs/<filename> endpoint
await async_register_log_download_endpoint(hass)
return True
async def reload_all_vtherm(hass):
"""Handle reload service call."""
_LOGGER.info("Service %s.reload called: reloading integration", DOMAIN)
current_entries = hass.config_entries.async_entries(DOMAIN)
reload_tasks = [
hass.config_entries.async_reload(entry.entry_id) for entry in current_entries
]
await asyncio.gather(*reload_tasks)
api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(hass)
if api:
await api.central_boiler_manager.reload_central_boiler_entities_list()
await api.init_vtherm_links()
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Versatile Thermostat from a config entry."""
name = entry.data.get(CONF_NAME)
_LOGGER.debug(
"%s - Calling async_setup_entry entry: entry_id='%s', value='%s'",
name,
entry.entry_id,
entry.data,
)
api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(hass)
api.add_entry(entry)
entry.async_on_unload(entry.add_update_listener(update_listener))
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
if hass.state == CoreState.running:
await api.init_vtherm_links(entry.entry_id)
return True
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Update listener."""
_LOGGER.debug(
"Calling update_listener entry: entry_id='%s', value='%s'",
entry.entry_id,
entry.data,
)
# If a component has set this flag, skip the reload (data is still persisted to disk).
api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(hass)
if api and api.skip_reload_on_config_update:
_LOGGER.debug(
"update_listener: skipping reload for entry '%s' (skip_reload_on_config_update=True)",
entry.entry_id,
)
return
if entry.data.get(CONF_THERMOSTAT_TYPE) == CONF_THERMOSTAT_CENTRAL_CONFIG:
await reload_all_vtherm(hass)
else:
await hass.config_entries.async_reload(entry.entry_id)
# Reload the central boiler list of entities
api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(hass)
if api:
await api.central_boiler_manager.reload_central_boiler_entities_list()
await api.init_vtherm_links(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(hass)
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
if api:
if entry.data.get(CONF_THERMOSTAT_TYPE) == CONF_THERMOSTAT_CENTRAL_CONFIG:
api.reset_central_config()
api.remove_entry(entry)
await api.central_boiler_manager.reload_central_boiler_entities_list()
return unload_ok
# Example migration function
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry):
"""Migrate old entry."""
_LOGGER.debug(
"Migrating from version %s/%s", config_entry.version, config_entry.minor_version
)
def calculate_version(major, minor):
return major * 100 + minor
current_version = calculate_version(CONFIG_VERSION, CONFIG_MINOR_VERSION)
version = calculate_version(config_entry.version, config_entry.minor_version)
if version != current_version:
_LOGGER.debug(
"Migration to %s/%s is needed", CONFIG_VERSION, CONFIG_MINOR_VERSION
)
new = {**config_entry.data}
thermostat_type = config_entry.data.get(CONF_THERMOSTAT_TYPE)
# Unit test with no version comes with 101 (version=1 and minor_version=1)
if version <= 200:
# Migration of central config thermostat to add new features flags
if thermostat_type == CONF_THERMOSTAT_CENTRAL_CONFIG:
new[CONF_USE_WINDOW_FEATURE] = True
new[CONF_USE_MOTION_FEATURE] = True
new[CONF_USE_POWER_FEATURE] = new.get(CONF_POWER_SENSOR, None) is not None
new[CONF_USE_PRESENCE_FEATURE] = new.get(CONF_PRESENCE_SENSOR, None) is not None
new[CONF_USE_CENTRAL_BOILER_FEATURE] = new.get("add_central_boiler_control", False) or new.get(CONF_USE_CENTRAL_BOILER_FEATURE, False)
if config_entry.data.get(CONF_UNDERLYING_LIST, None) is None:
underlying_list = []
if thermostat_type == CONF_THERMOSTAT_SWITCH:
underlying_list = [
config_entry.data.get(CONF_HEATER, None),
config_entry.data.get(CONF_HEATER_2, None),
config_entry.data.get(CONF_HEATER_3, None),
config_entry.data.get(CONF_HEATER_4, None),
]
elif thermostat_type == CONF_THERMOSTAT_CLIMATE:
underlying_list = [
config_entry.data.get(CONF_CLIMATE, None),
config_entry.data.get(CONF_CLIMATE_2, None),
config_entry.data.get(CONF_CLIMATE_3, None),
config_entry.data.get(CONF_CLIMATE_4, None),
]
elif thermostat_type == CONF_THERMOSTAT_VALVE:
underlying_list = [
config_entry.data.get(CONF_VALVE, None),
config_entry.data.get(CONF_VALVE_2, None),
config_entry.data.get(CONF_VALVE_3, None),
config_entry.data.get(CONF_VALVE_4, None),
]
new[CONF_UNDERLYING_LIST] = [entity for entity in underlying_list if entity is not None]
for key in [
CONF_HEATER,
CONF_HEATER_2,
CONF_HEATER_3,
CONF_HEATER_4,
CONF_CLIMATE,
CONF_CLIMATE_2,
CONF_CLIMATE_3,
CONF_CLIMATE_4,
CONF_VALVE,
CONF_VALVE_2,
CONF_VALVE_3,
CONF_VALVE_4,
]:
new.pop(key, None)
# Migration 2.0 to 2.1 -> rename security parameters into safety
for key in [
"security_delay_min",
"security_min_on_percent",
"security_default_on_percent",
]:
new_key = key.replace("security_", "safety_")
old_value = config_entry.data.get(key, None)
if old_value is not None:
new[new_key] = old_value
new.pop(key, None)
# Version 201 (add auto TPI parameters). Cumul with previous migration if needed
if version <= 201:
# Migration 2.1 to 2.2 -> add auto TPI parameters with default values
new[CONF_AUTO_TPI_MODE] = False
new[CONF_AUTO_TPI_CALCULATION_METHOD] = AUTO_TPI_METHOD_AVG
new[CONF_AUTO_TPI_EMA_ALPHA] = 0.15
new[CONF_AUTO_TPI_AVG_INITIAL_WEIGHT] = 1
new[CONF_AUTO_TPI_EMA_DECAY_RATE] = 0.08
new[CONF_AUTO_TPI_HEATER_HEATING_TIME] = 5
new[CONF_AUTO_TPI_HEATER_COOLING_TIME] = 5
new[CONF_AUTO_TPI_HEATING_POWER] = 1.0
new[CONF_AUTO_TPI_COOLING_POWER] = 1.0
# migrate CONF_OFFSET_CALIBRATION_LIST if present into CONF_SYNC_DEVICE_INTERNAL_TEMP_LIST
offset_calib_list = config_entry.data.get(CONF_OFFSET_CALIBRATION_LIST, None)
if offset_calib_list is not None and len(offset_calib_list) > 0:
sync_device_internal_temp_list = []
for offset in offset_calib_list:
if offset is not None:
sync_device_internal_temp_list.append(offset)
new[CONF_SYNC_ENTITY_LIST] = sync_device_internal_temp_list
new.pop(CONF_OFFSET_CALIBRATION_LIST, None)
new[CONF_SYNC_WITH_CALIBRATION] = True
new[CONF_SYNC_DEVICE_INTERNAL_TEMP] = True
else:
new[CONF_SYNC_WITH_CALIBRATION] = False
new[CONF_SYNC_DEVICE_INTERNAL_TEMP] = False
if version <= 202:
_LOGGER.info("Cleaning up obsolete Auto TPI keys")
new.pop("auto_tpi_max_coef_int", None)
new.pop("auto_tpi_enable_update_config", None)
new.pop("auto_tpi_enable_notification", None)
new.pop("auto_tpi_keep_ext_learning", None)
new.pop("auto_tpi_continuous_learning", None)
# Update the config entry with migrated data
hass.config_entries.async_update_entry(
config_entry,
data=new,
version=CONFIG_VERSION,
minor_version=CONFIG_MINOR_VERSION
)
_LOGGER.info("Migration to version %s.%s successful", CONFIG_VERSION, CONFIG_MINOR_VERSION)
else:
_LOGGER.info("No migration needed")
return True
@@ -0,0 +1,335 @@
# 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 vtherm_api.log_collector import get_vtherm_logger
from datetime import datetime
from typing import Literal
from .vtherm_hvac_mode import VThermHvacMode, VThermHvacMode_HEAT, VThermHvacMode_COOL
from .const import (
AUTO_START_STOP_LEVEL_NONE,
AUTO_START_STOP_LEVEL_FAST,
AUTO_START_STOP_LEVEL_MEDIUM,
AUTO_START_STOP_LEVEL_SLOW,
AUTO_START_STOP_LEVEL_VERY_SLOW,
TYPE_AUTO_START_STOP_LEVELS,
)
_LOGGER = get_vtherm_logger(__name__)
# Some constant to make algorithm depending of level
DT_MIN = {
AUTO_START_STOP_LEVEL_NONE: 0, # Not used
AUTO_START_STOP_LEVEL_VERY_SLOW: 60,
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_VERY_SLOW: 20, # 20 cycle above 1° or 10 cycle above 2°, ...
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
_last_switch_date: datetime | None = None
def __init__(self, level: TYPE_AUTO_START_STOP_LEVELS, vtherm_name: str):
"""Initalize a new algorithm with the right constants"""
self._vtherm_name: str = vtherm_name
self._last_calculation_date: datetime | None = None
self._last_switch_date: datetime | None = None
self._init_level(level)
self._last_should_be_off: bool = False
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 should_be_turned_off(self, requested_hvac_mode: VThermHvacMode, target_temp: float, current_temp: float, slope_min: float | None, now: datetime) -> bool | None:
"""Check auto-start/stop state should be triggered
Return True if the device should be turned off, False if the device should not be turned off
"""
if self._level == AUTO_START_STOP_LEVEL_NONE:
_LOGGER.debug(
"%s - auto-start/stop is disabled",
self,
)
self._last_should_be_off = False
return self._last_should_be_off
_LOGGER.debug(
"%s - calculate_action: requested_hvac_mode=%s, target_temp=%s, current_temp=%s, slope_min=%s at %s",
self,
requested_hvac_mode,
target_temp,
current_temp,
slope_min,
now,
)
if requested_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,
)
self._last_should_be_off = False
return self._last_should_be_off
# 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 self._last_should_be_off
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
# Calculate the number of minute from last_switch
nb_minutes_since_last_switch = 999
if self._last_switch_date is not None:
nb_minutes_since_last_switch = (
now - self._last_switch_date
).total_seconds() / 60
# Check to turn-off
# When we hit the threshold, that mean we can turn off
previous_should_be_off = self._last_should_be_off
if requested_hvac_mode == VThermHvacMode_HEAT:
if self._accumulated_error <= -self._error_threshold and temp_at_dt >= target_temp + TEMP_HYSTERESIS and nb_minutes_since_last_switch >= self._dt:
_LOGGER.info(
"%s - auto-start/stop: We need to stop, there is no need for heating for a long time.",
self,
)
self._last_should_be_off = True
elif temp_at_dt <= target_temp - TEMP_HYSTERESIS and nb_minutes_since_last_switch >= self._dt:
_LOGGER.info(
"%s - auto-start/stop: We need to start heating.",
self,
)
self._last_should_be_off = False
elif requested_hvac_mode == VThermHvacMode_COOL:
if self._accumulated_error >= self._error_threshold and temp_at_dt <= target_temp - TEMP_HYSTERESIS and nb_minutes_since_last_switch >= self._dt:
_LOGGER.info(
"%s - We need to stop, there is no need for cooling for a long time.",
self,
)
self._last_should_be_off = True
elif temp_at_dt >= target_temp + TEMP_HYSTERESIS and nb_minutes_since_last_switch >= self._dt:
_LOGGER.info(
"%s - We need to start cooling.",
self,
)
self._last_should_be_off = False
_LOGGER.debug("%s - nothing to do, requested hvac_mode is %s", self, requested_hvac_mode)
if previous_should_be_off != self._last_should_be_off:
self._last_switch_date = now
return self._last_should_be_off
# def calculate_action(
# self,
# hvac_mode: VThermHvacMode | None,
# saved_hvac_mode: VThermHvacMode | 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"""
# # Check to turn-off
# # When we hit the threshold, that mean we can turn off
# if hvac_mode == VThermHvacMode_HEAT:
# if (
# self._accumulated_error <= -self._error_threshold
# and temp_at_dt >= target_temp + TEMP_HYSTERESIS
# and nb_minutes_since_last_switch >= self._dt
# ):
# _LOGGER.info(
# "%s - We need to stop, there is no need for heating for a long time.",
# self,
# )
# self._last_switch_date = now
# 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 == VThermHvacMode_COOL:
# if (
# self._accumulated_error >= self._error_threshold
# and temp_at_dt <= target_temp - TEMP_HYSTERESIS
# and nb_minutes_since_last_switch >= self._dt
# ):
# _LOGGER.info(
# "%s - We need to stop, there is no need for cooling for a long time.",
# self,
# )
# self._last_switch_date = now
# 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 == VThermHvacMode_OFF and saved_hvac_mode == VThermHvacMode_HEAT:
# if (
# temp_at_dt <= target_temp - TEMP_HYSTERESIS
# and nb_minutes_since_last_switch >= self._dt
# ):
# _LOGGER.info(
# "%s - We need to start, because it will be time to heat",
# self,
# )
# self._last_switch_date = now
# 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 == VThermHvacMode_OFF and saved_hvac_mode == VThermHvacMode_COOL:
# if (
# temp_at_dt >= target_temp + TEMP_HYSTERESIS
# and nb_minutes_since_last_switch >= self._dt
# ):
# _LOGGER.info(
# "%s - We need to start, because it will be time to cool",
# self,
# )
# self._last_switch_date = now
# 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)
def reset_switch_delay(self):
"""Reset the switch delay to allow immediate restart.
Should be called when target temperature changes significantly (preset change, manual temp change).
This prevents the VTherm from staying off when the user explicitly requests a higher temperature.
"""
_LOGGER.debug("%s - Resetting switch delay to allow immediate start/stop", self)
self._last_switch_date = None
# Also reset _last_calculation_date to allow immediate recalculation
# This is needed because the check for too-rapid calculations would otherwise
# ignore the next call if it happens within 24 seconds
self._last_calculation_date = None
# Reset the last decision so a user-initiated preset/temperature change always exits
# the auto-stop state. Without this, if neither the "turn on" nor the "turn off"
# condition is met (e.g. room temp near target with a slightly positive slope),
# _last_should_be_off would stay True and the VTherm would remain stopped even though
# the user explicitly asked for a different preset.
self._last_should_be_off = False
@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
@property
def last_switch_date(self) -> datetime | None:
"""Get the last of the last switch"""
return self._last_switch_date
def __str__(self) -> str:
return f"AutoStartStopDetectionAlgorithm-{self._vtherm_name}"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,134 @@
""" A base class for all VTherm entities"""
import logging
from vtherm_api.log_collector import get_vtherm_logger
from datetime import timedelta
from homeassistant.core import HomeAssistant, callback, Event
from homeassistant.components.climate import ClimateEntity
from homeassistant.components.climate.const import DOMAIN as CLIMATE_DOMAIN
from homeassistant.const import EntityCategory
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.event import async_track_state_change_event, async_call_later
from .const import DOMAIN, DEVICE_MANUFACTURER
from .base_thermostat import BaseThermostat
_LOGGER = get_vtherm_logger(__name__)
class VersatileThermostatBaseEntity(Entity):
"""A base class for all entities"""
_my_climate: BaseThermostat
hass: HomeAssistant
_config_id: str
_device_name: str
def __init__(self, hass: HomeAssistant, config_id, device_name) -> None:
"""The CTOR"""
self.hass = hass
self._config_id = config_id
self._device_name = device_name
# self._attr_entity_category = EntityCategory.DIAGNOSTIC
self._my_climate = None
self._cancel_call = None
self._attr_has_entity_name = True
@property
def should_poll(self) -> bool:
"""Do not poll for those entities"""
return False
@property
def my_climate(self) -> BaseThermostat | None:
"""Returns my climate if found"""
if not self._my_climate:
self._my_climate = self.find_my_versatile_thermostat()
if self._my_climate:
# Only the first time
self.my_climate_is_initialized()
return self._my_climate
@property
def device_info(self) -> DeviceInfo:
"""Return the device info."""
return DeviceInfo(
entry_type=None,
identifiers={(DOMAIN, self._config_id)},
name=self._device_name,
manufacturer=DEVICE_MANUFACTURER,
model=DOMAIN,
)
def find_my_versatile_thermostat(self) -> BaseThermostat:
"""Find the underlying climate entity"""
try:
component: EntityComponent[ClimateEntity] = self.hass.data[CLIMATE_DOMAIN]
for entity in list(component.entities):
# _LOGGER.debug("Device_info is %s", entity.device_info)
if entity.device_info == self.device_info:
# _LOGGER.debug("Found %s!", entity)
return entity
except KeyError:
pass
return None
@callback
async def async_added_to_hass(self):
"""Listen to my climate state change"""
# Check delay condition
async def try_find_climate(_):
_LOGGER.debug(
"%s - Calling VersatileThermostatBaseEntity.async_added_to_hass", self
)
mcl = self.my_climate
if mcl:
if self._cancel_call:
self._cancel_call()
self._cancel_call = None
self.async_on_remove(
async_track_state_change_event(
self.hass,
[mcl.entity_id],
self.async_my_climate_changed,
)
)
else:
_LOGGER.debug("%s - no entity to listen. Try later", self)
self._cancel_call = async_call_later(
self.hass, timedelta(seconds=1), try_find_climate
)
await try_find_climate(None)
@callback
def my_climate_is_initialized(self):
"""Called when the associated climate is initialized"""
return
@callback
async def async_my_climate_changed(
self, event: Event
): # pylint: disable=unused-argument
"""Called when my climate have change
This method aims to be overridden to take the status change
"""
return
@property
def entity_category(self):
if not self.my_climate:
return EntityCategory.DIAGNOSTIC
return None
def __str__(self) -> str:
if self._my_climate is not None:
return f"{self._my_climate}-{self.__class__.__name__}"
else:
return f"UnknownVTherm-{self.__class__.__name__}"
@@ -0,0 +1,71 @@
""" Implements a base Feature Manager for Versatile Thermostat """
from typing import Any
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from vtherm_api.log_collector import get_vtherm_logger
from .const import * # pylint: disable=wildcard-import, unused-wildcard-import
from .commons_type import ConfigData
from .config_schema import * # pylint: disable=wildcard-import, unused-wildcard-import
_LOGGER = get_vtherm_logger(__name__)
class BaseFeatureManager:
"""A base class for all feature"""
def __init__(self, vtherm: Any, hass: HomeAssistant, name: str = None):
"""Init of a featureManager"""
self._vtherm = vtherm
self._name = vtherm.name if vtherm else name
self._active_listener: list[CALLBACK_TYPE] = []
self._hass = hass
def post_init(self, entry_infos: ConfigData):
"""Initialize the attributes of the FeatureManager"""
raise NotImplementedError()
async def start_listening(self):
"""Start listening the underlying entity"""
raise NotImplementedError()
def stop_listening(self) -> bool:
"""stop listening to the sensor"""
while self._active_listener:
self._active_listener.pop()()
self._active_listener = []
async def refresh_state(self):
"""Refresh the state and return True if a change have been made"""
return False
def add_listener(self, func: CALLBACK_TYPE) -> None:
"""Add a listener to the list of active listener"""
self._active_listener.append(func)
def restore_state(self, old_state) -> None:
"""Restore state from old state."""
@property
def is_configured(self) -> bool:
"""True if the FeatureManager is fully configured"""
raise NotImplementedError()
@property
def name(self) -> str:
"""The name"""
return self._name if self._name else "Unnamed FeatureManager"
@property
def hass(self) -> HomeAssistant:
"""The HA instance"""
return self._hass
@property
def is_detected(self) -> bool:
"""True if the FeatureManager is detected. Should be implemented by the child class"""
raise NotImplementedError()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,477 @@
""" Implements the VersatileThermostat binary sensors component """
# pylint: disable=unused-argument, line-too-long
import logging
from vtherm_api.log_collector import get_vtherm_logger
from homeassistant.core import (
HomeAssistant,
callback,
Event,
# CoreState,
)
from homeassistant.const import STATE_ON, STATE_OFF
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.components.binary_sensor import (
BinarySensorEntity,
BinarySensorDeviceClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .vtherm_central_api import VersatileThermostatAPI
from .base_entity import VersatileThermostatBaseEntity
from .const import (
DOMAIN,
DEVICE_MANUFACTURER,
CONF_NAME,
CONF_USE_POWER_FEATURE,
CONF_USE_PRESENCE_FEATURE,
CONF_USE_MOTION_FEATURE,
CONF_USE_WINDOW_FEATURE,
CONF_USE_HEATING_FAILURE_DETECTION_FEATURE,
CONF_THERMOSTAT_TYPE,
CONF_THERMOSTAT_CENTRAL_CONFIG,
CONF_USE_CENTRAL_BOILER_FEATURE,
overrides,
EventType,
)
_LOGGER = get_vtherm_logger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the VersatileThermostat binary sensors with config flow."""
unique_id = entry.entry_id
name = entry.data.get(CONF_NAME)
_LOGGER.debug("%s - Calling async_setup_entry entry=%s, data=%s", name, entry.entry_id, entry.data)
vt_type = entry.data.get(CONF_THERMOSTAT_TYPE)
entities = None
if vt_type == CONF_THERMOSTAT_CENTRAL_CONFIG:
if entry.data.get(CONF_USE_CENTRAL_BOILER_FEATURE):
# we capture here the configuration for central boiler feature
api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(hass)
api.central_boiler_manager.post_init(entry.data)
entities = [
CentralBoilerBinarySensor(hass, unique_id, name, entry.data),
]
else:
entities = [
SafetyBinarySensor(hass, unique_id, name, entry.data),
WindowByPassBinarySensor(hass, unique_id, name, entry.data),
]
if entry.data.get(CONF_USE_MOTION_FEATURE):
entities.append(MotionBinarySensor(hass, unique_id, name, entry.data))
if entry.data.get(CONF_USE_WINDOW_FEATURE):
entities.append(WindowBinarySensor(hass, unique_id, name, entry.data))
if entry.data.get(CONF_USE_PRESENCE_FEATURE):
entities.append(PresenceBinarySensor(hass, unique_id, name, entry.data))
if entry.data.get(CONF_USE_POWER_FEATURE):
entities.append(OverpoweringBinarySensor(hass, unique_id, name, entry.data))
if entry.data.get(CONF_USE_HEATING_FAILURE_DETECTION_FEATURE):
entities.append(HeatingFailureBinarySensor(hass, unique_id, name, entry.data))
if entities:
async_add_entities(entities, True)
class SafetyBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity):
"""Representation of a BinarySensor which exposes the security state"""
_attr_has_entity_name = True
_attr_translation_key = "safety_state"
def __init__(
self,
hass: HomeAssistant,
unique_id,
name, # pylint: disable=unused-argument
entry_infos,
) -> None:
"""Initialize the SafetyState Binary sensor"""
super().__init__(hass, unique_id, name)
self._attr_unique_id = f"{self._device_name}_safety_state"
self._attr_is_on = False
@callback
async def async_my_climate_changed(self, event: Event = None):
"""Called when my climate have change"""
# _LOGGER.debug("%s - climate state change", self._attr_unique_id)
old_state = self._attr_is_on
self._attr_is_on = self.my_climate.safety_manager.is_safety_detected
if old_state != self._attr_is_on:
self.async_write_ha_state()
return
@property
def device_class(self) -> BinarySensorDeviceClass | None:
return BinarySensorDeviceClass.SAFETY
@property
def icon(self) -> str | None:
if self._attr_is_on:
return "mdi:shield-alert"
else:
return "mdi:shield-check-outline"
class HeatingFailureBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity):
"""Representation of a BinarySensor which exposes the heating failure state"""
_attr_has_entity_name = True
_attr_translation_key = "heating_failure_state"
def __init__(
self,
hass: HomeAssistant,
unique_id,
name, # pylint: disable=unused-argument
entry_infos,
) -> None:
"""Initialize the HeatingFailure Binary sensor"""
super().__init__(hass, unique_id, name)
self._attr_unique_id = f"{self._device_name}_heating_failure_state"
self._attr_is_on = False
@callback
async def async_my_climate_changed(self, event: Event = None):
"""Called when my climate have change"""
old_state = self._attr_is_on
self._attr_is_on = self.my_climate.heating_failure_detection_manager.is_failure_detected
if old_state != self._attr_is_on:
self.async_write_ha_state()
return
@property
def device_class(self) -> BinarySensorDeviceClass | None:
return BinarySensorDeviceClass.PROBLEM
@property
def icon(self) -> str | None:
if self._attr_is_on:
return "mdi:radiator-off"
else:
return "mdi:radiator"
class OverpoweringBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity):
"""Representation of a BinarySensor which exposes the overpowering state"""
_attr_has_entity_name = True
_attr_translation_key = "overpowering_state"
def __init__(
self,
hass: HomeAssistant,
unique_id,
name, # pylint: disable=unused-argument
entry_infos,
) -> None:
"""Initialize the OverpoweringState Binary sensor"""
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
self._attr_unique_id = f"{self._device_name}_overpowering_state"
self._attr_is_on = False
@callback
async def async_my_climate_changed(self, event: Event = None):
"""Called when my climate have change"""
# _LOGGER.debug("%s - climate state change", self._attr_unique_id)
old_state = self._attr_is_on
self._attr_is_on = self.my_climate.overpowering_state is STATE_ON
if old_state != self._attr_is_on:
self.async_write_ha_state()
return
@property
def device_class(self) -> BinarySensorDeviceClass | None:
return BinarySensorDeviceClass.POWER
@property
def icon(self) -> str | None:
if self._attr_is_on:
return "mdi:flash-alert-outline"
else:
return "mdi:flash-outline"
class WindowBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity):
"""Representation of a BinarySensor which exposes the window state"""
_attr_has_entity_name = True
_attr_translation_key = "window_state"
def __init__(
self,
hass: HomeAssistant,
unique_id,
name, # pylint: disable=unused-argument
entry_infos,
) -> None:
"""Initialize the WindowState Binary sensor"""
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
self._attr_unique_id = f"{self._device_name}_window_state"
self._attr_is_on = False
@callback
async def async_my_climate_changed(self, event: Event = None):
"""Called when my climate have change"""
# _LOGGER.debug("%s - climate state change", self._attr_unique_id)
old_state = self._attr_is_on
# Issue 120 - only take defined presence value
if self.my_climate.window_state in [
STATE_ON,
STATE_OFF,
] or self.my_climate.window_auto_state in [STATE_ON, STATE_OFF]:
self._attr_is_on = (
self.my_climate.window_state == STATE_ON
or self.my_climate.window_auto_state == STATE_ON
)
if old_state != self._attr_is_on:
self.async_write_ha_state()
return
@property
def device_class(self) -> BinarySensorDeviceClass | None:
return BinarySensorDeviceClass.WINDOW
@property
def icon(self) -> str | None:
if self._attr_is_on:
if self.my_climate.window_state == STATE_ON:
return "mdi:window-open-variant"
else:
return "mdi:window-open"
else:
return "mdi:window-closed-variant"
class MotionBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity):
"""Representation of a BinarySensor which exposes the motion state"""
_attr_has_entity_name = True
_attr_translation_key = "motion_state"
def __init__(
self,
hass: HomeAssistant,
unique_id,
name, # pylint: disable=unused-argument
entry_infos,
) -> None:
"""Initialize the MotionState Binary sensor"""
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
self._attr_unique_id = f"{self._device_name}_motion_state"
self._attr_is_on = False
@callback
async def async_my_climate_changed(self, event: Event = None):
"""Called when my climate have change"""
# _LOGGER.debug("%s - climate state change", self._attr_unique_id)
old_state = self._attr_is_on
# Issue 120 - only take defined presence value
if self.my_climate.motion_state in [STATE_ON, STATE_OFF]:
self._attr_is_on = self.my_climate.motion_state == STATE_ON
if old_state != self._attr_is_on:
self.async_write_ha_state()
return
@property
def device_class(self) -> BinarySensorDeviceClass | None:
return BinarySensorDeviceClass.MOTION
@property
def icon(self) -> str | None:
if self._attr_is_on:
return "mdi:motion-sensor"
else:
return "mdi:motion-sensor-off"
class PresenceBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity):
"""Representation of a BinarySensor which exposes the presence state"""
_attr_has_entity_name = True
_attr_translation_key = "presence_state"
def __init__(
self,
hass: HomeAssistant,
unique_id,
name, # pylint: disable=unused-argument
entry_infos,
) -> None:
"""Initialize the PresenceState Binary sensor"""
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
self._attr_unique_id = f"{self._device_name}_presence_state"
self._attr_is_on = False
@callback
async def async_my_climate_changed(self, event: Event = None):
"""Called when my climate have change"""
# _LOGGER.debug("%s - climate state change", self._attr_unique_id)
old_state = self._attr_is_on
# Issue 120 - only take defined presence value
if self.my_climate.presence_state in [STATE_ON, STATE_OFF]:
self._attr_is_on = self.my_climate.presence_state == STATE_ON
if old_state != self._attr_is_on:
self.async_write_ha_state()
return
@property
def device_class(self) -> BinarySensorDeviceClass | None:
return BinarySensorDeviceClass.PRESENCE
@property
def icon(self) -> str | None:
if self._attr_is_on:
return "mdi:home-account"
else:
return "mdi:nature-people"
class WindowByPassBinarySensor(VersatileThermostatBaseEntity, BinarySensorEntity):
"""Representation of a BinarySensor which exposes the Window ByPass state"""
_attr_has_entity_name = True
_attr_translation_key = "window_bypass_state"
def __init__(
self,
hass: HomeAssistant,
unique_id,
name, # pylint: disable=unused-argument
entry_infos,
) -> None:
"""Initialize the WindowByPass Binary sensor"""
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
self._attr_unique_id = f"{self._device_name}_window_bypass_state"
self._attr_is_on = False
@callback
async def async_my_climate_changed(self, event: Event = None):
"""Called when my climate have change"""
# _LOGGER.debug("%s - climate state change", self._attr_unique_id)
old_state = self._attr_is_on
if self.my_climate.is_window_bypass in [True, False]:
self._attr_is_on = self.my_climate.is_window_bypass
if old_state != self._attr_is_on:
self.async_write_ha_state()
return
@property
def device_class(self) -> BinarySensorDeviceClass | None:
return BinarySensorDeviceClass.RUNNING
@property
def icon(self) -> str | None:
if self._attr_is_on:
return "mdi:window-shutter-cog"
else:
return "mdi:window-shutter-auto"
class CentralBoilerBinarySensor(BinarySensorEntity):
"""Representation of a BinarySensor which exposes the Central Boiler state"""
_attr_has_entity_name = True
_attr_translation_key = "central_boiler_state"
_entity_component_unrecorded_attributes = BinarySensorEntity._entity_component_unrecorded_attributes.union( # pylint: disable=protected-access
frozenset({"is_central_boiler_configured", "is_central_boiler_ready", "central_boiler_manager"})
)
def __init__(
self,
hass: HomeAssistant,
unique_id,
name, # pylint: disable=unused-argument
entry_infos,
) -> None:
"""Initialize the CentralBoiler Binary sensor"""
self._config_id = unique_id
self._attr_unique_id = "central_boiler_state"
self._attr_is_on = False
self._device_name = entry_infos.get(CONF_NAME)
self._entry_infos = entry_infos
self._hass = hass
@property
def device_info(self) -> DeviceInfo:
"""Return the device info."""
return DeviceInfo(
entry_type=None,
identifiers={(DOMAIN, self._config_id)},
name=self._device_name,
manufacturer=DEVICE_MANUFACTURER,
model=DOMAIN,
)
@property
def device_class(self) -> BinarySensorDeviceClass | None:
return BinarySensorDeviceClass.RUNNING
@property
def icon(self) -> str | None:
if self._attr_is_on:
return "mdi:water-boiler"
else:
return "mdi:water-boiler-off"
@overrides
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(self._hass)
api.central_boiler_manager.register_central_boiler(self)
# Listen to central boiler events
self.async_on_remove(
self._hass.bus.async_listen(
EventType.CENTRAL_BOILER_EVENT.value,
self._handle_central_boiler_event,
)
)
self.update_custom_attributes()
self.async_write_ha_state()
@callback
def _handle_central_boiler_event(self, event):
"""Handle central boiler event to update internal state."""
_LOGGER.debug("%s - Received central boiler event: %s", self, event.data)
if "central_boiler" in event.data:
new_state = event.data["central_boiler"]
if self._attr_is_on != new_state:
self._attr_is_on = new_state
self.refresh_custom_attributes()
def refresh_custom_attributes(self):
"""Refresh the custom attributes"""
self.update_custom_attributes()
self.async_write_ha_state()
def update_custom_attributes(self):
"""Update the custom extra attributes for the entity"""
self._attr_extra_state_attributes = {"central_boiler_state": STATE_ON if self._attr_is_on else STATE_OFF}
api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api()
cb_manager = api.central_boiler_manager
cb_manager.add_custom_attributes(self._attr_extra_state_attributes)
def __str__(self):
return f"VersatileThermostat-{self.name}"
@@ -0,0 +1,231 @@
""" Implements the VersatileThermostat climate component """
import logging
from vtherm_api.log_collector import get_vtherm_logger
import voluptuous as vol
from homeassistant.core import HomeAssistant, SupportsResponse
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers import selector
from homeassistant.helpers.entity_platform import AddEntitiesCallback
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.reload import async_setup_reload_service
from homeassistant.helpers import entity_platform
from homeassistant.const import (
CONF_NAME,
STATE_ON,
STATE_OFF,
STATE_HOME,
STATE_NOT_HOME,
)
from .const import * # pylint: disable=wildcard-import,unused-wildcard-import
from .thermostat_switch import ThermostatOverSwitch
from .thermostat_climate import ThermostatOverClimate
from .thermostat_valve import ThermostatOverValve
from .thermostat_climate_valve import ThermostatOverClimateValve
from .vtherm_central_api import VersatileThermostatAPI
_LOGGER = get_vtherm_logger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the VersatileThermostat thermostat with config flow."""
unique_id = entry.entry_id
name = entry.data.get(CONF_NAME)
_LOGGER.debug("%s - Calling async_setup_entry entry=%s, data=%s", name, entry.entry_id, entry.data)
await async_setup_reload_service(hass, DOMAIN, PLATFORMS)
vt_type = entry.data.get(CONF_THERMOSTAT_TYPE)
have_valve_regulation = (
entry.data.get(CONF_AUTO_REGULATION_MODE) == CONF_AUTO_REGULATION_VALVE
)
if vt_type == CONF_THERMOSTAT_CENTRAL_CONFIG:
# Initialize the central power manager
vtherm_api = VersatileThermostatAPI.get_vtherm_api(hass)
vtherm_api.reset_central_config()
vtherm_api.central_power_manager.post_init(entry.data)
return
# Instantiate the right base class
entity = None
if vt_type == CONF_THERMOSTAT_SWITCH:
entity = ThermostatOverSwitch(hass, unique_id, name, entry.data)
elif vt_type == CONF_THERMOSTAT_CLIMATE:
if have_valve_regulation is True:
entity = ThermostatOverClimateValve(hass, unique_id, name, entry.data)
else:
entity = ThermostatOverClimate(hass, unique_id, name, entry.data)
elif vt_type == CONF_THERMOSTAT_VALVE:
entity = ThermostatOverValve(hass, unique_id, name, entry.data)
else:
_LOGGER.error(
"Cannot create Versatile Thermostat name=%s of type %s which is unknown",
name,
vt_type,
)
return
async_add_entities([entity], True)
# Add services
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
SERVICE_SET_PRESENCE,
{
vol.Required("presence"): vol.In(
[STATE_ON, STATE_OFF, STATE_HOME, STATE_NOT_HOME]
),
},
"service_set_presence",
)
platform.async_register_entity_service(
SERVICE_SET_SAFETY,
{
vol.Optional("delay_min"): cv.positive_int,
vol.Optional("min_on_percent"): vol.Coerce(float),
vol.Optional("default_on_percent"): vol.Coerce(float),
},
"service_set_safety",
)
platform.async_register_entity_service(
SERVICE_SET_WINDOW_BYPASS,
{
vol.Required("window_bypass"): vol.In([True, False]),
},
"service_set_window_bypass_state",
)
platform.async_register_entity_service(
SERVICE_SET_AUTO_REGULATION_MODE,
{
vol.Required("auto_regulation_mode"): vol.In(
["None", "Light", "Medium", "Strong", "Slow", "Expert"]
),
},
"service_set_auto_regulation_mode",
)
platform.async_register_entity_service(
SERVICE_SET_AUTO_FAN_MODE,
{
vol.Required("auto_fan_mode"): vol.In(
["None", "Low", "Medium", "High", "Turbo"]
),
},
"service_set_auto_fan_mode",
)
platform.async_register_entity_service(
SERVICE_SET_HVAC_MODE_SLEEP,
{},
"service_set_hvac_mode_sleep",
)
platform.async_register_entity_service(
SERVICE_LOCK,
{
vol.Optional("code"): cv.string,
},
"service_lock",
)
platform.async_register_entity_service(
SERVICE_UNLOCK,
{
vol.Optional("code"): cv.string,
},
"service_unlock",
)
platform.async_register_entity_service(
SERVICE_SET_TPI_PARAMETERS,
{
vol.Optional(CONF_TPI_COEF_INT): selector.NumberSelector(selector.NumberSelectorConfig(min=0.0, max=10.0, step=0.01, mode=selector.NumberSelectorMode.BOX)),
vol.Optional(CONF_TPI_COEF_EXT): selector.NumberSelector(selector.NumberSelectorConfig(min=0.0, max=1.0, step=0.001, mode=selector.NumberSelectorMode.BOX)),
vol.Optional(CONF_MINIMAL_ACTIVATION_DELAY): cv.positive_int,
vol.Optional(CONF_MINIMAL_DEACTIVATION_DELAY): cv.positive_int,
vol.Optional(CONF_TPI_THRESHOLD_LOW): selector.NumberSelector(selector.NumberSelectorConfig(min=-10.0, max=10.0, step=0.1, mode=selector.NumberSelectorMode.BOX)),
vol.Optional(CONF_TPI_THRESHOLD_HIGH): selector.NumberSelector(selector.NumberSelectorConfig(min=-10.0, max=10.0, step=0.1, mode=selector.NumberSelectorMode.BOX)),
},
"service_set_tpi_parameters",
)
platform.async_register_entity_service(
SERVICE_SET_AUTO_TPI_MODE,
{
vol.Required("auto_tpi_mode"): vol.In([True, False]),
vol.Optional("reinitialise", default=True): vol.In([True, False]),
vol.Optional("allow_kint_boost_on_stagnation", default=False): vol.In(
[True, False]
),
vol.Optional(
"allow_kext_compensation_on_overshoot", default=False
): vol.In([True, False]),
},
"service_set_auto_tpi_mode",
)
platform.async_register_entity_service(
SERVICE_AUTO_TPI_CALIBRATE_CAPACITY,
{
vol.Optional("start_date"): selector.DateTimeSelector(),
vol.Optional("end_date"): selector.DateTimeSelector(),
vol.Optional("save_to_config", default=False): vol.In([True, False]),
vol.Optional("min_power_threshold", default=95): selector.NumberSelector(
selector.NumberSelectorConfig(min=80, max=100, step=1, mode=selector.NumberSelectorMode.SLIDER)
),
},
"service_auto_tpi_calibrate_capacity",
supports_response=SupportsResponse.OPTIONAL,
)
platform.async_register_entity_service(
SERVICE_SET_TIMED_PRESET,
{
vol.Required("preset"): cv.string,
vol.Required("duration_minutes"): selector.NumberSelector(
selector.NumberSelectorConfig(min=1, max=1440, step=1, mode=selector.NumberSelectorMode.BOX)
),
},
"service_set_timed_preset",
)
platform.async_register_entity_service(
SERVICE_CANCEL_TIMED_PRESET,
{},
"service_cancel_timed_preset",
)
platform.async_register_entity_service(
SERVICE_RECALIBRATE_VALVES,
{
vol.Required("delay_seconds"): vol.All(vol.Coerce(int), vol.Range(min=0, max=300)),
},
"service_recalibrate_valves",
supports_response=SupportsResponse.OPTIONAL,
)
platform.async_register_entity_service(
SERVICE_DOWNLOAD_LOGS,
{
vol.Optional("log_level", default="DEBUG"): vol.In(
["DEBUG", "INFO", "WARNING", "ERROR"]
),
vol.Optional("period_start"): selector.DateTimeSelector(),
vol.Optional("period_end"): selector.DateTimeSelector(),
},
"service_download_logs",
)
@@ -0,0 +1,196 @@
""" Some usefull commons class """
# pylint: disable=line-too-long
import logging
from vtherm_api.log_collector import get_vtherm_logger
import warnings
from homeassistant.core import HomeAssistant
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers import entity_registry as er
from .const import ServiceConfigurationError, DOMAIN
_LOGGER = get_vtherm_logger(__name__)
def round_to_nearest(n: float, x: float) -> float:
"""Round a number to the nearest x (which should be decimal but not null)
Example:
nombre1 = 3.2
nombre2 = 4.7
x = 0.3
nombre_arrondi1 = round_to_nearest(nombre1, x)
nombre_arrondi2 = round_to_nearest(nombre2, x)
print(nombre_arrondi1) # Output: 3.3
print(nombre_arrondi2) # Output: 4.6
"""
assert x > 0
return round(n * (1 / x)) / (1 / x)
def check_and_extract_service_configuration(service_config) -> dict:
"""Raise a ServiceConfigurationError. In return you have a dict formatted like follows.
Example if you call with 'climate.central_boiler/climate.set_temperature/temperature:10':
{
"service_domain": "climate",
"service_name": "set_temperature",
"entity_id": "climate.central_boiler",
"entity_domain": "climate",
"entity_name": "central_boiler",
"data": {
"temperature": "10"
},
"attribute_name": "temperature",
"attribute_value: "10"
}
For this example 'switch.central_boiler/switch.turn_off' you will have this:
{
"service_domain": "switch",
"service_name": "turn_off",
"entity_id": "switch.central_boiler",
"entity_domain": "switch",
"entity_name": "central_boiler",
"data": { },
}
All values are striped (white space are removed) and are string
"""
ret = {}
if service_config is None:
return ret
parties = service_config.split("/")
if len(parties) < 2:
raise ServiceConfigurationError(
f"Incorrect service configuration. Service {service_config} should be formatted with: 'entity_name/service_name[/data]'. See README for more information."
)
entity_id = parties[0]
service_name = parties[1]
service_infos = service_name.split(".")
if len(service_infos) != 2:
raise ServiceConfigurationError(
f"Incorrect service configuration. The service {service_config} should be formatted like: 'domain.service_name' (ex: 'switch.turn_on'). See README for more information."
)
ret.update(
{
"service_domain": service_infos[0].strip(),
"service_name": service_infos[1].strip(),
}
)
entity_infos = entity_id.split(".")
if len(entity_infos) != 2:
raise ServiceConfigurationError(
f"Incorrect service configuration. The entity_id {entity_id} should be formatted like: 'domain.entity_name' (ex: 'switch.central_boiler_switch'). See README for more information."
)
ret.update(
{
"entity_domain": entity_infos[0].strip(),
"entity_name": entity_infos[1].strip(),
"entity_id": entity_id.strip(),
}
)
if len(parties) == 3:
data = parties[2]
if len(data) > 0:
data_infos = None
data_infos = data.split(":")
if (
len(data_infos) != 2
or len(data_infos[0]) <= 0
or len(data_infos[1]) <= 0
):
raise ServiceConfigurationError(
f"Incorrect service configuration. The data {data} should be formatted like: 'attribute:value' (ex: 'value:25'). See README for more information."
)
ret.update(
{
"attribute_name": data_infos[0].strip(),
"attribute_value": data_infos[1].strip(),
"data": {data_infos[0].strip(): data_infos[1].strip()},
}
)
else:
raise ServiceConfigurationError(
f"Incorrect service configuration. The data {data} should be formatted like: 'attribute:value' (ex: 'value:25'). See README for more information."
)
else:
ret.update({"data": {}})
_LOGGER.debug(
"check_and_extract_service_configuration(%s) gives '%s'", service_config, ret
)
return ret
def deprecated(message):
"""A decorator to indicate that the method/attribut is deprecated"""
def decorator(func):
def wrapper(*args, **kwargs):
warnings.warn(
f"{func.__name__} is deprecated: {message}",
DeprecationWarning,
stacklevel=2,
)
return func(*args, **kwargs)
return wrapper
return decorator
def write_event_log(logger: logging.Logger, vtherm: "BaseThermostat", message: str):
"""Write an event log entry for the thermostat."""
logger.info("%s - ---------------------> NEW EVENT: %s --------------------------------------------------------------", vtherm, message)
async def cleanup_orphan_entity(
hass: HomeAssistant,
entry: ConfigEntry,
domain: str,
device_name: str,
unique_id_suffix: str,
) -> None:
"""Remove an orphan entity from entity registry if it exists but is no longer needed.
This generic function can be used for any entity type that needs to be
conditionally created/removed based on configuration changes.
Args:
hass: The Home Assistant instance.
entry: The config entry for the thermostat.
domain: The entity domain (e.g., "sensor", "binary_sensor", "switch").
device_name: The device name used to build the unique_id.
unique_id_suffix: The suffix appended to the device name (e.g., "auto_tpi_learning").
"""
registry = er.async_get(hass)
# Build the expected unique_id for the entity
expected_unique_id = f"{device_name}_{unique_id_suffix}"
# Find entity by unique_id within this config entry
entity_id = registry.async_get_entity_id(
domain, DOMAIN, expected_unique_id
)
if entity_id:
entity_entry = registry.async_get(entity_id)
if entity_entry and entity_entry.config_entry_id == entry.entry_id:
_LOGGER.debug(
"Removing orphan %s entity %s from registry (feature disabled)",
domain,
entity_id
)
registry.async_remove(entity_id)
@@ -0,0 +1,2 @@
# import logging
from vtherm_api.commons_type import ConfigData
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,588 @@
""" All the schemas for ConfigFlow validation"""
import voluptuous as vol
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import selector
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
from homeassistant.components.input_boolean import (
DOMAIN as INPUT_BOOLEAN_DOMAIN,
)
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.components.input_number import (
DOMAIN as INPUT_NUMBER_DOMAIN,
)
from homeassistant.components.select import (
DOMAIN as SELECT_DOMAIN,
)
from homeassistant.components.input_select import (
DOMAIN as INPUT_SELECT_DOMAIN,
)
from homeassistant.components.input_datetime import (
DOMAIN as INPUT_DATETIME_DOMAIN,
)
from homeassistant.components.person import DOMAIN as PERSON_DOMAIN
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
from .const import * # pylint: disable=wildcard-import, unused-wildcard-import
def get_prop_function_options() -> list[str]:
"""Return proportional algorithm choices exposed in VT flows."""
options = [PROPORTIONAL_FUNCTION_TPI]
try:
from .vtherm_central_api import VersatileThermostatAPI # pylint: disable=import-outside-toplevel
api = VersatileThermostatAPI.get_vtherm_api()
if api is not None and hasattr(api, "list_prop_algorithms"):
for name in api.list_prop_algorithms():
if name not in options:
options.append(name)
except Exception: # pylint: disable=broad-except
pass
return options
def build_step_thermostat_switch_schema() -> vol.Schema:
"""Build the proportional switch thermostat schema with dynamic algorithms."""
return vol.Schema(
{
vol.Required(CONF_UNDERLYING_LIST): selector.EntitySelector(
selector.EntitySelectorConfig(domain=[SWITCH_DOMAIN, INPUT_BOOLEAN_DOMAIN, SELECT_DOMAIN, INPUT_SELECT_DOMAIN, CLIMATE_DOMAIN], multiple=True),
),
vol.Optional(CONF_HEATER_KEEP_ALIVE): cv.positive_int,
vol.Required(CONF_PROP_FUNCTION, default=PROPORTIONAL_FUNCTION_TPI): vol.In(
get_prop_function_options()
),
vol.Optional(CONF_AC_MODE, default=False): cv.boolean,
vol.Optional(CONF_INVERSE_SWITCH, default=False): cv.boolean,
vol.Optional("on_command_text"): vol.In([]),
vol.Optional(CONF_VSWITCH_ON_CMD_LIST): selector.TextSelector(selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT, multiple=True)),
vol.Optional("off_command_text"): vol.In([]),
vol.Optional(CONF_VSWITCH_OFF_CMD_LIST): selector.TextSelector(selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT, multiple=True)),
}
)
def build_step_thermostat_valve_schema() -> vol.Schema:
"""Build the valve thermostat schema with dynamic algorithms."""
return vol.Schema(
{
vol.Required(CONF_UNDERLYING_LIST): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=[NUMBER_DOMAIN, INPUT_NUMBER_DOMAIN], multiple=True
),
),
vol.Required(CONF_PROP_FUNCTION, default=PROPORTIONAL_FUNCTION_TPI): vol.In(
get_prop_function_options()
),
vol.Optional(CONF_AC_MODE, default=False): cv.boolean,
vol.Optional(CONF_AUTO_REGULATION_DTEMP, default=10): vol.Coerce(float),
vol.Optional(CONF_AUTO_REGULATION_PERIOD_MIN, default=5): cv.positive_int,
}
)
def build_step_valve_regulation_schema() -> vol.Schema:
"""Build the valve regulation schema with dynamic algorithms."""
return vol.Schema(
{
vol.Required(CONF_OPENING_DEGREE_LIST): selector.EntitySelector(
selector.EntitySelectorConfig(domain=[NUMBER_DOMAIN, INPUT_NUMBER_DOMAIN], multiple=True),
),
vol.Optional(CONF_CLOSING_DEGREE_LIST): selector.EntitySelector(
selector.EntitySelectorConfig(domain=[NUMBER_DOMAIN, INPUT_NUMBER_DOMAIN], multiple=True),
),
vol.Required(CONF_PROP_FUNCTION, default=PROPORTIONAL_FUNCTION_TPI): vol.In(
get_prop_function_options()
),
vol.Optional(CONF_OPENING_THRESHOLD_DEGREE, default=0): cv.positive_int,
vol.Optional(CONF_MIN_OPENING_DEGREES, default=""): str,
vol.Optional(CONF_MAX_OPENING_DEGREES, default=""): str,
vol.Optional(CONF_MAX_CLOSING_DEGREE, default=100): cv.positive_int,
}
)
STEP_USER_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name
{
vol.Required(
CONF_THERMOSTAT_TYPE, default=CONF_THERMOSTAT_SWITCH
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=CONF_THERMOSTAT_TYPES,
translation_key="thermostat_type",
mode="list",
)
)
}
)
STEP_MAIN_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name
{
vol.Required(CONF_NAME): cv.string,
vol.Required(CONF_TEMP_SENSOR): selector.EntitySelector(
selector.EntitySelectorConfig(domain=[SENSOR_DOMAIN, INPUT_NUMBER_DOMAIN, NUMBER_DOMAIN]),
),
vol.Optional(CONF_LAST_SEEN_TEMP_SENSOR): selector.EntitySelector(
selector.EntitySelectorConfig(domain=[SENSOR_DOMAIN, INPUT_DATETIME_DOMAIN, NUMBER_DOMAIN]),
),
vol.Required(CONF_CYCLE_MIN, default=5): selector.NumberSelector(selector.NumberSelectorConfig(min=1, max=1000, step=1, mode=selector.NumberSelectorMode.BOX)),
vol.Optional(CONF_DEVICE_POWER, default="1"): vol.Coerce(float),
vol.Required(CONF_USE_MAIN_CENTRAL_CONFIG, default=True): cv.boolean,
vol.Optional(CONF_USE_CENTRAL_MODE, default=True): cv.boolean,
vol.Required(CONF_USED_BY_CENTRAL_BOILER, default=False): cv.boolean,
}
)
STEP_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_HEATING_FAILURE_DETECTION_FEATURE, default=False): cv.boolean,
}
)
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_CLIMATE_VALVE_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_HEATING_FAILURE_DETECTION_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,
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_CENTRAL_BOILER_FEATURE, default=False): cv.boolean,
vol.Optional(CONF_USE_HEATING_FAILURE_DETECTION_FEATURE, default=False): cv.boolean,
}
)
STEP_CENTRAL_MAIN_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name
{
vol.Required(CONF_EXTERNAL_TEMP_SENSOR): selector.EntitySelector(
selector.EntitySelectorConfig(domain=[SENSOR_DOMAIN, INPUT_NUMBER_DOMAIN]),
),
vol.Required(CONF_TEMP_MIN, default=7): vol.Coerce(float),
vol.Required(CONF_TEMP_MAX, default=35): vol.Coerce(float),
vol.Required(CONF_STEP_TEMPERATURE, default=0.1): vol.Coerce(float),
}
)
STEP_CENTRAL_SPEC_MAIN_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name
{
vol.Required(CONF_EXTERNAL_TEMP_SENSOR): selector.EntitySelector(
selector.EntitySelectorConfig(domain=[SENSOR_DOMAIN, INPUT_NUMBER_DOMAIN]),
),
vol.Required(CONF_TEMP_MIN, default=7): vol.Coerce(float),
vol.Required(CONF_TEMP_MAX, default=35): vol.Coerce(float),
vol.Required(CONF_STEP_TEMPERATURE, default=0.1): vol.Coerce(float),
}
)
STEP_CENTRAL_BOILER_SCHEMA = vol.Schema(
{
vol.Required(CONF_CENTRAL_BOILER_ACTIVATION_DELAY_SEC, default=0): selector.NumberSelector(
selector.NumberSelectorConfig(min=0, max=600, step=10, mode=selector.NumberSelectorMode.BOX)
),
vol.Optional(CONF_CENTRAL_BOILER_ACTIVATION_SRV, default=""): str,
vol.Optional(CONF_CENTRAL_BOILER_DEACTIVATION_SRV, default=""): str,
vol.Optional(CONF_KEEP_ALIVE_BOILER_DELAY_SEC, default=DEFAULT_KEEP_ALIVE_BOILER_DELAY_SEC): selector.NumberSelector(
selector.NumberSelectorConfig(min=0, max=3600, step=10, mode=selector.NumberSelectorMode.BOX, unit_of_measurement="s")
),
}
)
STEP_THERMOSTAT_SWITCH = build_step_thermostat_switch_schema() # pylint: disable=invalid-name
STEP_THERMOSTAT_CLIMATE = vol.Schema( # pylint: disable=invalid-name
{
vol.Required(CONF_UNDERLYING_LIST): selector.EntitySelector(
selector.EntitySelectorConfig(domain=CLIMATE_DOMAIN, multiple=True),
),
vol.Optional(CONF_AC_MODE, default=False): cv.boolean,
vol.Optional(CONF_SYNC_DEVICE_INTERNAL_TEMP, default=False): cv.boolean,
vol.Optional(CONF_AUTO_REGULATION_MODE, default=CONF_AUTO_REGULATION_NONE): selector.SelectSelector(
selector.SelectSelectorConfig(
options=CONF_AUTO_REGULATION_MODES,
translation_key="auto_regulation_mode",
mode="dropdown",
)
),
vol.Optional(CONF_AUTO_REGULATION_DTEMP, default=0.5): vol.Coerce(float),
vol.Optional(CONF_AUTO_REGULATION_PERIOD_MIN, default=5): cv.positive_int,
vol.Optional(CONF_AUTO_FAN_MODE, default=CONF_AUTO_FAN_HIGH): selector.SelectSelector(
selector.SelectSelectorConfig(
options=CONF_AUTO_FAN_MODES,
translation_key="auto_fan_mode",
mode="dropdown",
)
),
vol.Optional(CONF_AUTO_REGULATION_USE_DEVICE_TEMP, default=False): cv.boolean,
}
)
STEP_THERMOSTAT_VALVE = build_step_thermostat_valve_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_VALVE_REGULATION = build_step_valve_regulation_schema() # pylint: disable=invalid-name
STEP_SYNC_DEVICE_INTERNAL_TEMP = vol.Schema( # pylint: disable=invalid-name
{
vol.Optional(CONF_SYNC_WITH_CALIBRATION, default=True): cv.boolean,
vol.Required(CONF_SYNC_ENTITY_LIST): selector.EntitySelector(
selector.EntitySelectorConfig(domain=[NUMBER_DOMAIN, INPUT_NUMBER_DOMAIN], multiple=True),
),
}
)
STEP_TPI_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name
{
vol.Required(CONF_USE_TPI_CENTRAL_CONFIG, default=True): cv.boolean,
}
)
STEP_CENTRAL_TPI_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name
{
vol.Required(CONF_TPI_COEF_INT, default=0.6): selector.NumberSelector(
selector.NumberSelectorConfig(
min=0.0, max=10.0, step="any", mode=selector.NumberSelectorMode.BOX
)
),
vol.Required(CONF_TPI_COEF_EXT, default=0.01): selector.NumberSelector(
selector.NumberSelectorConfig(
min=0.0, max=1.0, step="any", mode=selector.NumberSelectorMode.BOX
)
),
vol.Required(CONF_MINIMAL_ACTIVATION_DELAY, default=10): cv.positive_int,
vol.Required(CONF_MINIMAL_DEACTIVATION_DELAY, default=0): cv.positive_int,
vol.Optional(CONF_TPI_THRESHOLD_LOW, default=0): selector.NumberSelector(
selector.NumberSelectorConfig(
min=-10.0, max=10.0, step=0.1, mode=selector.NumberSelectorMode.BOX
)
),
vol.Optional(CONF_TPI_THRESHOLD_HIGH, default=0): selector.NumberSelector(
selector.NumberSelectorConfig(
min=-10.0, max=10.0, step=0.1, mode=selector.NumberSelectorMode.BOX
)
),
vol.Optional(CONF_AUTO_TPI_MODE, default=False): cv.boolean,
}
)
# Duplicating schema for central config
# as we don't want to display the use_auto_tpi checkbox
# in central config but only in device config.
STEP_CENTRAL_TPI_DATA_SCHEMA_CENTRAL = vol.Schema( # pylint: disable=invalid-name
{
vol.Required(CONF_TPI_COEF_INT, default=0.6): selector.NumberSelector(
selector.NumberSelectorConfig(
min=0.0, max=10.0, step=0.01, mode=selector.NumberSelectorMode.BOX
)
),
vol.Required(CONF_TPI_COEF_EXT, default=0.01): selector.NumberSelector(
selector.NumberSelectorConfig(
min=0.0, max=1.0, step=0.001, mode=selector.NumberSelectorMode.BOX
)
),
vol.Required(CONF_MINIMAL_ACTIVATION_DELAY, default=10): cv.positive_int,
vol.Required(CONF_MINIMAL_DEACTIVATION_DELAY, default=0): cv.positive_int,
vol.Optional(CONF_TPI_THRESHOLD_LOW, default=0): selector.NumberSelector(
selector.NumberSelectorConfig(
min=-10.0, max=10.0, step=0.1, mode=selector.NumberSelectorMode.BOX
)
),
vol.Optional(CONF_TPI_THRESHOLD_HIGH, default=0): selector.NumberSelector(
selector.NumberSelectorConfig(
min=-10.0, max=10.0, step=0.1, mode=selector.NumberSelectorMode.BOX
)
),
}
)
STEP_PRESETS_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name
{
vol.Required(CONF_USE_PRESETS_CENTRAL_CONFIG, default=True): cv.boolean,
}
)
STEP_WINDOW_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name
{
vol.Optional(CONF_WINDOW_SENSOR): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=[BINARY_SENSOR_DOMAIN, INPUT_BOOLEAN_DOMAIN]
),
),
vol.Required(CONF_USE_WINDOW_CENTRAL_CONFIG, default=True): cv.boolean,
}
)
STEP_CENTRAL_WINDOW_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name
{
vol.Optional(CONF_WINDOW_DELAY, default=30): cv.positive_int,
vol.Optional(CONF_WINDOW_OFF_DELAY, default=30): cv.positive_int,
vol.Optional(CONF_WINDOW_AUTO_OPEN_THRESHOLD, default=3): vol.Coerce(float),
vol.Optional(CONF_WINDOW_AUTO_CLOSE_THRESHOLD, default=0): vol.Coerce(float),
vol.Optional(CONF_WINDOW_AUTO_MAX_DURATION, default=30): cv.positive_int,
vol.Optional(CONF_WINDOW_ACTION, default=CONF_WINDOW_TURN_OFF): selector.SelectSelector(
selector.SelectSelectorConfig(
options=CONF_WINDOW_ACTIONS,
translation_key="window_action",
mode="dropdown",
)
),
}
)
STEP_CENTRAL_WINDOW_WO_AUTO_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name
{
vol.Optional(CONF_WINDOW_DELAY, default=30): cv.positive_int,
vol.Optional(CONF_WINDOW_OFF_DELAY, default=30): cv.positive_int,
vol.Optional(CONF_WINDOW_ACTION, default=CONF_WINDOW_TURN_OFF): selector.SelectSelector(
selector.SelectSelectorConfig(
options=CONF_WINDOW_ACTIONS,
translation_key="window_action",
mode="dropdown",
)
),
}
)
STEP_MOTION_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name
{
vol.Required(CONF_MOTION_SENSOR): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=[BINARY_SENSOR_DOMAIN, INPUT_BOOLEAN_DOMAIN]
),
),
vol.Required(CONF_USE_MOTION_CENTRAL_CONFIG, default=True): cv.boolean,
}
)
STEP_CENTRAL_MOTION_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name
{
vol.Optional(CONF_MOTION_DELAY, default=30): cv.positive_int,
vol.Optional(CONF_MOTION_OFF_DELAY, default=300): cv.positive_int,
vol.Optional(CONF_MOTION_PRESET, default="comfort"): selector.SelectSelector(
selector.SelectSelectorConfig(
options=CONF_PRESETS_SELECTIONABLE,
translation_key="presets",
mode="dropdown",
)
),
vol.Optional(CONF_NO_MOTION_PRESET, default="eco"): selector.SelectSelector(
selector.SelectSelectorConfig(
options=CONF_PRESETS_SELECTIONABLE,
translation_key="presets",
mode="dropdown",
)
),
}
)
STEP_CENTRAL_POWER_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name
{
vol.Required(CONF_POWER_SENSOR): selector.EntitySelector(
selector.EntitySelectorConfig(domain=[SENSOR_DOMAIN, INPUT_NUMBER_DOMAIN]),
),
vol.Required(CONF_MAX_POWER_SENSOR): selector.EntitySelector(
selector.EntitySelectorConfig(domain=[SENSOR_DOMAIN, INPUT_NUMBER_DOMAIN]),
),
vol.Optional(CONF_PRESET_POWER, default="13"): vol.Coerce(float),
}
)
STEP_NON_CENTRAL_POWER_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name
{
vol.Optional(CONF_PRESET_POWER, default="13"): vol.Coerce(float),
}
)
STEP_POWER_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name
{
vol.Required(CONF_USE_POWER_CENTRAL_CONFIG, default=True): cv.boolean,
}
)
STEP_CENTRAL_PRESENCE_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name
{
vol.Required(CONF_PRESENCE_SENSOR): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=[
PERSON_DOMAIN,
BINARY_SENSOR_DOMAIN,
INPUT_BOOLEAN_DOMAIN,
]
),
)
},
)
STEP_PRESENCE_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name
{
vol.Required(CONF_USE_PRESENCE_CENTRAL_CONFIG, default=True): cv.boolean,
}
)
STEP_CENTRAL_ADVANCED_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name
{
vol.Required(CONF_SAFETY_DELAY_MIN, default=60): cv.positive_int,
vol.Required(
CONF_SAFETY_MIN_ON_PERCENT,
default=DEFAULT_SAFETY_MIN_ON_PERCENT,
): vol.Coerce(float),
vol.Required(
CONF_SAFETY_DEFAULT_ON_PERCENT,
default=DEFAULT_SAFETY_DEFAULT_ON_PERCENT,
): vol.Coerce(float),
vol.Optional(CONF_REPAIR_INCORRECT_STATE, default=False): cv.boolean,
}
)
STEP_ADVANCED_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name
{
vol.Required(CONF_USE_ADVANCED_CENTRAL_CONFIG, default=True): cv.boolean,
}
)
STEP_LOCK_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name
{
vol.Required(CONF_USE_LOCK_CENTRAL_CONFIG, default=True): cv.boolean,
}
)
STEP_CENTRAL_LOCK_DATA_SCHEMA = vol.Schema( # pylint: disable=invalid-name
{
vol.Optional(CONF_LOCK_CODE): cv.string,
vol.Optional(CONF_LOCK_USERS, default=True): cv.boolean,
vol.Optional(CONF_LOCK_AUTOMATIONS, default=True): cv.boolean,
vol.Optional(CONF_AUTO_RELOCK_SEC, default=30): vol.Coerce(int),
}
)
STEP_CENTRAL_HEATING_FAILURE_DETECTION_SCHEMA = vol.Schema( # pylint: disable=invalid-name
{
vol.Required(
CONF_HEATING_FAILURE_THRESHOLD,
default=DEFAULT_HEATING_FAILURE_THRESHOLD,
): selector.NumberSelector(selector.NumberSelectorConfig(min=0.0, max=1.0, step=0.01, mode=selector.NumberSelectorMode.BOX)),
vol.Required(
CONF_COOLING_FAILURE_THRESHOLD,
default=DEFAULT_COOLING_FAILURE_THRESHOLD,
): selector.NumberSelector(selector.NumberSelectorConfig(min=0.0, max=1.0, step=0.01, mode=selector.NumberSelectorMode.BOX)),
vol.Required(
CONF_HEATING_FAILURE_DETECTION_DELAY,
default=DEFAULT_HEATING_FAILURE_DETECTION_DELAY,
): cv.positive_int,
vol.Required(
CONF_TEMPERATURE_CHANGE_TOLERANCE,
default=DEFAULT_TEMPERATURE_CHANGE_TOLERANCE,
): selector.NumberSelector(selector.NumberSelectorConfig(min=0.0, max=5.0, step=0.1, mode=selector.NumberSelectorMode.BOX)),
vol.Optional(
CONF_FAILURE_DETECTION_ENABLE_TEMPLATE,
): selector.TemplateSelector(),
}
)
STEP_HEATING_FAILURE_DETECTION_SCHEMA = vol.Schema( # pylint: disable=invalid-name
{
vol.Required(CONF_USE_HEATING_FAILURE_DETECTION_CENTRAL_CONFIG, default=True): cv.boolean,
}
)
STEP_AUTO_TPI_CONFIGURATION_SCHEMA = vol.Schema(
{
vol.Required(
CONF_AUTO_TPI_LEARNING_TYPE, default=AUTO_TPI_LEARNING_TYPE_DISCOVERY
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=CONF_AUTO_TPI_LEARNING_TYPES,
translation_key="auto_tpi_learning_type",
mode="dropdown",
)
),
vol.Required(CONF_AUTO_TPI_AGGRESSIVENESS, default=1.0): selector.NumberSelector(
selector.NumberSelectorConfig(
min=0.5, max=1.0, step=0.01, mode=selector.NumberSelectorMode.SLIDER
)
),
vol.Optional(CONF_AUTO_TPI_HEATER_HEATING_TIME, default=5): cv.positive_int,
vol.Optional(CONF_AUTO_TPI_HEATER_COOLING_TIME, default=5): cv.positive_int,
vol.Required(CONF_AUTO_TPI_HEATING_POWER, default=0.0): vol.Coerce(float),
vol.Optional(CONF_AUTO_TPI_CONTINUOUS_KEXT, default=False): cv.boolean,
vol.Optional(
CONF_AUTO_TPI_ENABLE_ADVANCED_SETTINGS, default=False
): cv.boolean,
}
)
STEP_AUTO_TPI_AVG_SETTINGS_SCHEMA = vol.Schema(
{
vol.Required(CONF_AUTO_TPI_AVG_INITIAL_WEIGHT, default=1): cv.positive_int,
}
)
STEP_AUTO_TPI_EMA_SETTINGS_SCHEMA = vol.Schema(
{
vol.Required(CONF_AUTO_TPI_EMA_ALPHA, default=0.15): selector.NumberSelector(
selector.NumberSelectorConfig(
min=0.0, max=1.0, step=0.01, mode=selector.NumberSelectorMode.BOX
)
),
vol.Required(
CONF_AUTO_TPI_EMA_DECAY_RATE, default=0.08
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=0.0, max=1.0, step=0.01, mode=selector.NumberSelectorMode.BOX
)
),
vol.Required(CONF_AUTO_TPI_CONTINUOUS_KEXT_ALPHA, default=0.04): selector.NumberSelector(
selector.NumberSelectorConfig(
min=0.005, max=0.2, step=0.005, mode=selector.NumberSelectorMode.BOX
)
),
}
)
@@ -0,0 +1,691 @@
# pylint: disable=line-too-long, disable=unused-import
"""Constants for the Versatile Thermostat integration."""
import logging
import math
from typing import Literal
from datetime import datetime
from enum import Enum
from homeassistant.const import STATE_UNKNOWN, STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.const import CONF_NAME, Platform
from homeassistant.components.climate.const import ClimateEntityFeature # pylint: disable=unused-import
from homeassistant.exceptions import HomeAssistantError
from homeassistant.util import dt as dt_util
from vtherm_api.const import DOMAIN, VTHERM_API_NAME, DEVICE_MANUFACTURER, DEVICE_MODEL, get_tz, NowClass, EventType
from vtherm_api.log_collector import get_vtherm_logger
PROPORTIONAL_FUNCTION_TPI = "tpi"
from .vtherm_preset import VThermPreset, VThermPresetWithAC, VThermPresetWithAway, VThermPresetWithACAway, PRESET_TEMP_SUFFIX, PRESET_AWAY_SUFFIX # pylint: disable=unused-import
from .vtherm_hvac_mode import (
VThermHvacMode,
VThermHvacMode_COOL,
VThermHvacMode_HEAT,
VThermHvacMode_DRY,
VThermHvacMode_OFF,
VThermHvacMode_HEAT_COOL,
VThermHvacMode_SLEEP,
VThermHvacMode_AUTO,
VThermHvacMode_FAN_ONLY,
to_ha_hvac_mode,
from_ha_hvac_mode,
) # pylint: disable=unused-import
from .vtherm_state import VThermState # pylint: disable=unused-import
_LOGGER = get_vtherm_logger(__name__)
CONFIG_VERSION = 2
CONFIG_MINOR_VERSION = 3
DEVICE_MANUFACTURER = "JMCOLLIN"
DEVICE_MODEL = "Versatile Thermostat"
DOMAIN = "versatile_thermostat"
# The order is important.
PLATFORMS: list[Platform] = [
Platform.SELECT,
Platform.CLIMATE,
Platform.SENSOR,
# Number should be after CLIMATE
Platform.NUMBER,
Platform.BINARY_SENSOR,
Platform.SWITCH,
]
CONF_UNDERLYING_LIST = "underlying_entity_ids"
CONF_HEATER_KEEP_ALIVE = "heater_keep_alive"
CONF_TEMP_SENSOR = "temperature_sensor_entity_id"
CONF_LAST_SEEN_TEMP_SENSOR = "last_seen_temperature_sensor_entity_id"
CONF_EXTERNAL_TEMP_SENSOR = "external_temperature_sensor_entity_id"
CONF_POWER_SENSOR = "power_sensor_entity_id"
CONF_MAX_POWER_SENSOR = "max_power_sensor_entity_id"
CONF_WINDOW_SENSOR = "window_sensor_entity_id"
CONF_MOTION_SENSOR = "motion_sensor_entity_id"
CONF_DEVICE_POWER = "device_power"
CONF_CYCLE_MIN = "cycle_min"
CONF_PROP_FUNCTION = "proportional_function"
CONF_WINDOW_DELAY = "window_delay"
CONF_WINDOW_OFF_DELAY = "window_off_delay"
CONF_MOTION_DELAY = "motion_delay"
CONF_MOTION_OFF_DELAY = "motion_off_delay"
CONF_MOTION_PRESET = "motion_preset"
CONF_NO_MOTION_PRESET = "no_motion_preset"
CONF_TPI_COEF_INT = "tpi_coef_int"
CONF_TPI_COEF_EXT = "tpi_coef_ext"
CONF_TPI_THRESHOLD_LOW = "tpi_threshold_low"
CONF_TPI_THRESHOLD_HIGH = "tpi_threshold_high"
CONF_PRESENCE_SENSOR = "presence_sensor_entity_id"
CONF_PRESET_POWER = "power_temp"
CONF_MINIMAL_ACTIVATION_DELAY = "minimal_activation_delay"
CONF_MINIMAL_DEACTIVATION_DELAY = "minimal_deactivation_delay"
CONF_TEMP_MIN = "temp_min"
CONF_TEMP_MAX = "temp_max"
CONF_SAFETY_DELAY_MIN = "safety_delay_min"
CONF_SAFETY_MIN_ON_PERCENT = "safety_min_on_percent"
CONF_SAFETY_DEFAULT_ON_PERCENT = "safety_default_on_percent"
CONF_THERMOSTAT_TYPE = "thermostat_type"
CONF_THERMOSTAT_CENTRAL_CONFIG = "thermostat_central_config"
CONF_THERMOSTAT_SWITCH = "thermostat_over_switch"
CONF_THERMOSTAT_CLIMATE = "thermostat_over_climate"
CONF_THERMOSTAT_VALVE = "thermostat_over_valve"
CONF_USE_WINDOW_FEATURE = "use_window_feature"
CONF_USE_MOTION_FEATURE = "use_motion_feature"
CONF_USE_PRESENCE_FEATURE = "use_presence_feature"
CONF_USE_POWER_FEATURE = "use_power_feature"
CONF_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"
CONF_WINDOW_AUTO_MAX_DURATION = "window_auto_max_duration"
CONF_AUTO_REGULATION_MODE = "auto_regulation_mode"
CONF_AUTO_REGULATION_NONE = "auto_regulation_none"
CONF_AUTO_REGULATION_VALVE = "auto_regulation_valve"
CONF_AUTO_REGULATION_SLOW = "auto_regulation_slow"
CONF_AUTO_REGULATION_LIGHT = "auto_regulation_light"
CONF_AUTO_REGULATION_MEDIUM = "auto_regulation_medium"
CONF_AUTO_REGULATION_STRONG = "auto_regulation_strong"
CONF_AUTO_REGULATION_EXPERT = "auto_regulation_expert"
CONF_AUTO_REGULATION_DTEMP = "auto_regulation_dtemp"
CONF_AUTO_REGULATION_PERIOD_MIN = "auto_regulation_periode_min"
CONF_AUTO_REGULATION_USE_DEVICE_TEMP = "auto_regulation_use_device_temp"
CONF_INVERSE_SWITCH = "inverse_switch_command"
CONF_AUTO_FAN_MODE = "auto_fan_mode"
CONF_AUTO_FAN_NONE = "auto_fan_none"
CONF_AUTO_FAN_LOW = "auto_fan_low"
CONF_AUTO_FAN_MEDIUM = "auto_fan_medium"
CONF_AUTO_FAN_HIGH = "auto_fan_high"
CONF_AUTO_FAN_TURBO = "auto_fan_turbo"
CONF_STEP_TEMPERATURE = "step_temperature"
CONF_OFFSET_CALIBRATION_LIST = "offset_calibration_entity_ids"
CONF_OPENING_DEGREE_LIST = "opening_degree_entity_ids"
CONF_CLOSING_DEGREE_LIST = "closing_degree_entity_ids"
CONF_MIN_OPENING_DEGREES = "min_opening_degrees"
CONF_MAX_OPENING_DEGREES = "max_opening_degrees"
CONF_MAX_CLOSING_DEGREE = "max_closing_degree"
CONF_OPENING_THRESHOLD_DEGREE = "opening_threshold_degree"
CONF_SYNC_DEVICE_INTERNAL_TEMP = "sync_device_internal_temp"
CONF_SYNC_WITH_CALIBRATION = "sync_with_calibration"
CONF_SYNC_ENTITY_LIST = "sync_entity_ids"
CONF_USE_HEATING_FAILURE_DETECTION_FEATURE = "use_heating_failure_detection_feature"
CONF_USE_HEATING_FAILURE_DETECTION_CENTRAL_CONFIG = "use_heating_failure_detection_central_config"
CONF_HEATING_FAILURE_THRESHOLD = "heating_failure_threshold"
CONF_COOLING_FAILURE_THRESHOLD = "cooling_failure_threshold"
CONF_HEATING_FAILURE_DETECTION_DELAY = "heating_failure_detection_delay"
CONF_TEMPERATURE_CHANGE_TOLERANCE = "temperature_change_tolerance"
CONF_FAILURE_DETECTION_ENABLE_TEMPLATE = "failure_detection_enable_template"
CONF_LOCK_CODE = "lock_code"
CONF_LOCK_USERS = "lock_users"
CONF_LOCK_AUTOMATIONS = "lock_automations"
CONF_AUTO_RELOCK_SEC = "auto_relock_sec"
CONF_REPAIR_INCORRECT_STATE = "repair_incorrect_state"
CONF_VSWITCH_ON_CMD_LIST = "vswitch_on_command"
CONF_VSWITCH_OFF_CMD_LIST = "vswitch_off_command"
# Deprecated
CONF_HEATER = "heater_entity_id"
CONF_HEATER_2 = "heater_entity2_id"
CONF_HEATER_3 = "heater_entity3_id"
CONF_HEATER_4 = "heater_entity4_id"
CONF_CLIMATE = "climate_entity_id"
CONF_CLIMATE_2 = "climate_entity2_id"
CONF_CLIMATE_3 = "climate_entity3_id"
CONF_CLIMATE_4 = "climate_entity4_id"
CONF_VALVE = "valve_entity_id"
CONF_VALVE_2 = "valve_entity2_id"
CONF_VALVE_3 = "valve_entity3_id"
CONF_VALVE_4 = "valve_entity4_id"
CONF_AUTO_TPI_MODE = "auto_tpi_mode"
CONF_AUTO_TPI_LEARNING_TYPE = "auto_tpi_learning_type"
AUTO_TPI_LEARNING_TYPE_DISCOVERY = "discovery"
AUTO_TPI_LEARNING_TYPE_FINE_TUNING = "fine_tuning"
CONF_AUTO_TPI_LEARNING_TYPES = [
AUTO_TPI_LEARNING_TYPE_DISCOVERY,
AUTO_TPI_LEARNING_TYPE_FINE_TUNING,
]
CONF_AUTO_TPI_ENABLE_ADVANCED_SETTINGS = "auto_tpi_enable_advanced_settings"
CONF_AUTO_TPI_HEATER_HEATING_TIME = "heater_heating_time"
CONF_AUTO_TPI_HEATER_COOLING_TIME = "heater_cooling_time"
CONF_AUTO_TPI_CALCULATION_METHOD = "auto_tpi_calculation_method"
AUTO_TPI_METHOD_AVG = "average"
AUTO_TPI_METHOD_EMA = "ema"
CONF_AUTO_TPI_CALCULATION_METHODS = [AUTO_TPI_METHOD_AVG, AUTO_TPI_METHOD_EMA]
CONF_AUTO_TPI_EMA_ALPHA = "auto_tpi_ema_alpha"
CONF_AUTO_TPI_AVG_INITIAL_WEIGHT = "auto_tpi_avg_initial_weight"
CONF_AUTO_TPI_HEATING_POWER = "auto_tpi_heating_rate"
CONF_AUTO_TPI_COOLING_POWER = "auto_tpi_cooling_rate"
CONF_AUTO_TPI_AGGRESSIVENESS = "auto_tpi_aggressiveness"
CONF_AUTO_TPI_EMA_DECAY_RATE = "auto_tpi_ema_decay_rate"
CONF_AUTO_TPI_CONTINUOUS_KEXT = "auto_tpi_continuous_kext"
CONF_AUTO_TPI_CONTINUOUS_KEXT_ALPHA = "auto_tpi_continuous_kext_alpha"
# Global params into configuration.yaml
CONF_SHORT_EMA_PARAMS = "short_ema_params"
CONF_SAFETY_MODE = "safety_mode"
CONF_MAX_ON_PERCENT = "max_on_percent"
CONF_LOG_BUFFER_MAX_AGE_HOURS = "log_buffer_max_age_hours"
CONF_USE_MAIN_CENTRAL_CONFIG = "use_main_central_config"
CONF_USE_TPI_CENTRAL_CONFIG = "use_tpi_central_config"
CONF_USE_WINDOW_CENTRAL_CONFIG = "use_window_central_config"
CONF_USE_MOTION_CENTRAL_CONFIG = "use_motion_central_config"
CONF_USE_POWER_CENTRAL_CONFIG = "use_power_central_config"
CONF_USE_PRESENCE_CENTRAL_CONFIG = "use_presence_central_config"
CONF_USE_PRESETS_CENTRAL_CONFIG = "use_presets_central_config"
CONF_USE_ADVANCED_CENTRAL_CONFIG = "use_advanced_central_config"
CONF_USE_LOCK_CENTRAL_CONFIG = "use_lock_central_config"
CONF_USE_CENTRAL_MODE = "use_central_mode"
CONF_CENTRAL_BOILER_ACTIVATION_SRV = "central_boiler_activation_service"
CONF_CENTRAL_BOILER_DEACTIVATION_SRV = "central_boiler_deactivation_service"
CONF_CENTRAL_BOILER_ACTIVATION_DELAY_SEC = "central_boiler_activation_delay_sec"
CONF_KEEP_ALIVE_BOILER_DELAY_SEC = "keep_alive_boiler_delay_sec"
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_VERY_SLOW = "auto_start_stop_very_slow"
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_VERY_SLOW,
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_VERY_SLOW,
AUTO_START_STOP_LEVEL_NONE,
]
HVAC_OFF_REASON_NAME = "hvac_off_reason"
HVAC_OFF_REASON_MANUAL = "hvac_off_manual"
HVAC_OFF_REASON_AUTO_START_STOP = "hvac_off_auto_start_stop"
HVAC_OFF_REASON_WINDOW_DETECTION = "hvac_off_window_detection"
HVAC_OFF_REASON_SLEEP_MODE = "hvac_off_sleep_mode"
HVAC_OFF_REASON_SAFETY = "hvac_off_safety_detection"
HVAC_OFF_REASON_CENTRAL_MODE = "hvac_off_central_mode"
HVAC_OFF_REASONS = Literal[ # pylint: disable=invalid-name
HVAC_OFF_REASON_MANUAL, HVAC_OFF_REASON_AUTO_START_STOP, HVAC_OFF_REASON_WINDOW_DETECTION, HVAC_OFF_REASON_SLEEP_MODE, HVAC_OFF_REASON_SAFETY
]
DEFAULT_SHORT_EMA_PARAMS = {
"max_alpha": 0.5,
# In sec
"halflife_sec": 300,
"precision": 2,
}
CONF_PRESETS = {
p: f"{p}{PRESET_TEMP_SUFFIX}"
for p in (
VThermPreset.FROST,
VThermPreset.ECO,
VThermPreset.COMFORT,
VThermPreset.BOOST,
)
}
CONF_PRESETS_WITH_AC = {
p: f"{p}{PRESET_TEMP_SUFFIX}"
for p in (
VThermPreset.FROST,
VThermPreset.ECO,
VThermPreset.COMFORT,
VThermPreset.BOOST,
VThermPresetWithAC.ECO,
VThermPresetWithAC.COMFORT,
VThermPresetWithAC.BOOST,
)
}
CONF_PRESETS_AWAY = {
p: f"{p}{PRESET_TEMP_SUFFIX}"
for p in (
VThermPresetWithAway.FROST,
VThermPresetWithAway.ECO,
VThermPresetWithAway.COMFORT,
VThermPresetWithAway.BOOST,
)
}
CONF_PRESETS_AWAY_WITH_AC = {
p: f"{p}{PRESET_TEMP_SUFFIX}"
for p in (
VThermPresetWithAway.FROST,
VThermPresetWithAway.ECO,
VThermPresetWithAway.COMFORT,
VThermPresetWithAway.BOOST,
VThermPresetWithACAway.ECO,
VThermPresetWithACAway.COMFORT,
VThermPresetWithACAway.BOOST,
)
}
CONF_PRESETS_SELECTIONABLE = [
VThermPreset.FROST,
VThermPreset.ECO,
VThermPreset.COMFORT,
VThermPreset.BOOST,
]
CONF_PRESETS_VALUES = list(CONF_PRESETS.values())
CONF_PRESETS_AWAY_VALUES = list(CONF_PRESETS_AWAY.values())
CONF_PRESETS_WITH_AC_VALUES = list(CONF_PRESETS_WITH_AC.values())
CONF_PRESETS_AWAY_WITH_AC_VALUES = list(CONF_PRESETS_AWAY_WITH_AC.values())
ALL_CONF = (
[
CONF_NAME,
CONF_HEATER_KEEP_ALIVE,
CONF_TEMP_SENSOR,
CONF_EXTERNAL_TEMP_SENSOR,
CONF_POWER_SENSOR,
CONF_MAX_POWER_SENSOR,
CONF_WINDOW_SENSOR,
CONF_WINDOW_DELAY,
CONF_WINDOW_OFF_DELAY,
CONF_WINDOW_AUTO_OPEN_THRESHOLD,
CONF_WINDOW_AUTO_CLOSE_THRESHOLD,
CONF_WINDOW_AUTO_MAX_DURATION,
CONF_MOTION_SENSOR,
CONF_MOTION_DELAY,
CONF_MOTION_PRESET,
CONF_NO_MOTION_PRESET,
CONF_DEVICE_POWER,
CONF_CYCLE_MIN,
CONF_PROP_FUNCTION,
CONF_TPI_COEF_INT,
CONF_TPI_COEF_EXT,
CONF_TPI_THRESHOLD_LOW,
CONF_TPI_THRESHOLD_HIGH,
CONF_AUTO_TPI_HEATER_HEATING_TIME,
CONF_AUTO_TPI_HEATER_COOLING_TIME,
CONF_PRESENCE_SENSOR,
CONF_MINIMAL_ACTIVATION_DELAY,
CONF_MINIMAL_DEACTIVATION_DELAY,
CONF_TEMP_MIN,
CONF_TEMP_MAX,
CONF_SAFETY_DELAY_MIN,
CONF_SAFETY_MIN_ON_PERCENT,
CONF_SAFETY_DEFAULT_ON_PERCENT,
CONF_THERMOSTAT_TYPE,
CONF_THERMOSTAT_SWITCH,
CONF_THERMOSTAT_CLIMATE,
CONF_USE_WINDOW_FEATURE,
CONF_USE_MOTION_FEATURE,
CONF_USE_PRESENCE_FEATURE,
CONF_USE_POWER_FEATURE,
CONF_USE_CENTRAL_BOILER_FEATURE,
CONF_AC_MODE,
CONF_AUTO_REGULATION_MODE,
CONF_AUTO_REGULATION_DTEMP,
CONF_AUTO_REGULATION_PERIOD_MIN,
CONF_AUTO_REGULATION_USE_DEVICE_TEMP,
CONF_INVERSE_SWITCH,
CONF_AUTO_FAN_MODE,
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_CENTRAL_MODE,
CONF_USED_BY_CENTRAL_BOILER,
CONF_CENTRAL_BOILER_ACTIVATION_SRV,
CONF_CENTRAL_BOILER_DEACTIVATION_SRV,
CONF_CENTRAL_BOILER_ACTIVATION_DELAY_SEC,
CONF_KEEP_ALIVE_BOILER_DELAY_SEC,
CONF_WINDOW_ACTION,
CONF_STEP_TEMPERATURE,
CONF_MIN_OPENING_DEGREES,
CONF_MAX_OPENING_DEGREES,
CONF_MAX_CLOSING_DEGREE,
CONF_OPENING_THRESHOLD_DEGREE,
CONF_AUTO_TPI_CALCULATION_METHOD,
CONF_AUTO_TPI_EMA_ALPHA,
CONF_AUTO_TPI_AVG_INITIAL_WEIGHT,
CONF_AUTO_TPI_HEATING_POWER,
CONF_AUTO_TPI_COOLING_POWER,
CONF_AUTO_TPI_EMA_DECAY_RATE,
CONF_AUTO_TPI_CONTINUOUS_KEXT,
CONF_AUTO_TPI_CONTINUOUS_KEXT_ALPHA,
CONF_AUTO_TPI_LEARNING_TYPE,
CONF_AUTO_TPI_ENABLE_ADVANCED_SETTINGS,
CONF_SYNC_DEVICE_INTERNAL_TEMP,
CONF_SYNC_WITH_CALIBRATION,
CONF_USE_HEATING_FAILURE_DETECTION_FEATURE,
CONF_USE_HEATING_FAILURE_DETECTION_CENTRAL_CONFIG,
CONF_HEATING_FAILURE_THRESHOLD,
CONF_COOLING_FAILURE_THRESHOLD,
CONF_HEATING_FAILURE_DETECTION_DELAY,
CONF_TEMPERATURE_CHANGE_TOLERANCE,
CONF_FAILURE_DETECTION_ENABLE_TEMPLATE,
CONF_REPAIR_INCORRECT_STATE,
]
+ CONF_PRESETS_VALUES
+ CONF_PRESETS_AWAY_VALUES
+ CONF_PRESETS_WITH_AC_VALUES
+ CONF_PRESETS_AWAY_WITH_AC_VALUES,
)
CONF_FUNCTIONS = [
PROPORTIONAL_FUNCTION_TPI,
]
CONF_AUTO_REGULATION_MODES = [
CONF_AUTO_REGULATION_NONE,
CONF_AUTO_REGULATION_VALVE,
CONF_AUTO_REGULATION_LIGHT,
CONF_AUTO_REGULATION_MEDIUM,
CONF_AUTO_REGULATION_STRONG,
CONF_AUTO_REGULATION_SLOW,
CONF_AUTO_REGULATION_EXPERT,
]
CONF_THERMOSTAT_TYPES = [
CONF_THERMOSTAT_CENTRAL_CONFIG,
CONF_THERMOSTAT_SWITCH,
CONF_THERMOSTAT_CLIMATE,
CONF_THERMOSTAT_VALVE,
]
CONF_AUTO_FAN_MODES = [
CONF_AUTO_FAN_NONE,
CONF_AUTO_FAN_LOW,
CONF_AUTO_FAN_MEDIUM,
CONF_AUTO_FAN_HIGH,
CONF_AUTO_FAN_TURBO,
]
CONF_WINDOW_TURN_OFF = "window_turn_off"
CONF_WINDOW_FAN_ONLY = "window_fan_only"
CONF_WINDOW_FROST_TEMP = "window_frost_temp"
CONF_WINDOW_ECO_TEMP = "window_eco_temp"
CONF_WINDOW_ACTIONS = [
CONF_WINDOW_TURN_OFF,
CONF_WINDOW_FAN_ONLY,
CONF_WINDOW_FROST_TEMP,
CONF_WINDOW_ECO_TEMP,
]
SUPPORT_FLAGS = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.TURN_OFF
| ClimateEntityFeature.TURN_ON
)
SERVICE_SET_PRESENCE = "set_presence"
SERVICE_SET_SAFETY = "set_safety"
SERVICE_SET_WINDOW_BYPASS = "set_window_bypass"
SERVICE_SET_AUTO_REGULATION_MODE = "set_auto_regulation_mode"
SERVICE_SET_AUTO_FAN_MODE = "set_auto_fan_mode"
SERVICE_SET_HVAC_MODE_SLEEP = "set_hvac_mode_sleep"
SERVICE_LOCK = "lock"
SERVICE_UNLOCK = "unlock"
SERVICE_SET_TPI_PARAMETERS = "set_tpi_parameters"
SERVICE_SET_AUTO_TPI_MODE = "set_auto_tpi_mode"
SERVICE_AUTO_TPI_CALIBRATE_CAPACITY = "auto_tpi_calibrate_capacity"
AUTO_TPI_EVENT = "versatile_thermostat_auto_tpi_event"
SERVICE_SET_TIMED_PRESET = "set_timed_preset"
SERVICE_CANCEL_TIMED_PRESET = "cancel_timed_preset"
SERVICE_RECALIBRATE_VALVES = "recalibrate_valves"
SERVICE_DOWNLOAD_LOGS = "download_logs"
DEFAULT_SAFETY_MIN_ON_PERCENT = 0.5
DEFAULT_SAFETY_DEFAULT_ON_PERCENT = 0.1
# Repair incorrect state defaults
DEFAULT_REPAIR_INCORRECT_STATE = False
REPAIR_MAX_ATTEMPTS = 5
REPAIR_MIN_DELAY_AFTER_INIT_SEC = 30
# Central boiler keep-alive defaults
DEFAULT_KEEP_ALIVE_BOILER_DELAY_SEC = 0
# Heating failure detection defaults
DEFAULT_HEATING_FAILURE_THRESHOLD = 0.9 # 90%
DEFAULT_COOLING_FAILURE_THRESHOLD = 0.0 # 0%
DEFAULT_HEATING_FAILURE_DETECTION_DELAY = 15 # 15 minutes
DEFAULT_TEMPERATURE_CHANGE_TOLERANCE = 0.5 # 0.5°C
ATTR_TOTAL_ENERGY = "total_energy"
ATTR_MEAN_POWER_CYCLE = "mean_cycle_power"
AUTO_FAN_DTEMP_THRESHOLD = 2
AUTO_FAN_DEACTIVATED_MODES = ["mute", "quiet", "low", "quiet", "1", "auto"]
CENTRAL_CONFIG_NAME = "Central configuration"
CENTRAL_MODE_AUTO = "Auto"
CENTRAL_MODE_STOPPED = "Stopped"
CENTRAL_MODE_HEAT_ONLY = "Heat only"
CENTRAL_MODE_COOL_ONLY = "Cool only"
CENTRAL_MODE_FROST_PROTECTION = "Frost protection"
CENTRAL_MODES = [
CENTRAL_MODE_AUTO,
CENTRAL_MODE_STOPPED,
CENTRAL_MODE_HEAT_ONLY,
CENTRAL_MODE_COOL_ONLY,
CENTRAL_MODE_FROST_PROTECTION,
]
ATTR_CURRENT_STATE = "current_state"
ATTR_REQUESTED_STATE = "requested_state"
MSG_OVERPOWERING_DETECTED = "overpowering_detected"
MSG_SAFETY_DETECTED = "safety_detected"
MSG_TARGET_TEMP_WINDOW_ECO = "target_temp_window_eco"
MSG_TARGET_TEMP_WINDOW_FROST = "target_temp_window_frost"
MSG_TARGET_TEMP_POWER = "target_temp_power"
MSG_TARGET_TEMP_CENTRAL_MODE = "target_temp_central_mode"
MSG_TARGET_TEMP_ACTIVITY_DETECTED = "target_temp_activity_detected"
MSG_TARGET_TEMP_ACTIVITY_NOT_DETECTED = "target_temp_activity_not_detected"
MSG_TARGET_TEMP_ABSENCE_DETECTED = "target_temp_absence_detected"
MSG_TARGET_TEMP_TIMED_PRESET = "target_temp_timed_preset"
MSG_NOT_INITIALIZED = "not_initialized"
# A special regulation parameter suggested by @Maia here: https://github.com/jmcollin78/versatile_thermostat/discussions/154
class RegulationParamSlow:
"""Light parameters for slow latency regulation"""
kp: float = (
0.2 # 20% of the current internal regulation offset are caused by the current difference of target temperature and room temperature
)
ki: float = (
0.8 / 288.0
) # 80% of the current internal regulation offset are caused by the average offset of the past 24 hours
k_ext: float = (
1.0 / 25.0
) # this will add 1°C to the offset when it's 25°C colder outdoor than indoor
offset_max: float = 2.0 # limit to a final offset of -2°C to +2°C
accumulated_error_threshold: float = (
2.0 * 288
) # this allows up to 2°C long term offset in both directions
overheat_protection: bool = True
class RegulationParamLight:
"""Light parameters for regulation"""
kp: float = 0.2
ki: float = 0.05
k_ext: float = 0.05
offset_max: float = 1.5
accumulated_error_threshold: float = 10
overheat_protection: bool = True
class RegulationParamMedium:
"""Light parameters for regulation"""
kp: float = 0.3
ki: float = 0.05
k_ext: float = 0.1
offset_max: float = 2
accumulated_error_threshold: float = 20
overheat_protection: bool = True
class RegulationParamStrong:
"""Strong parameters for regulation
A set of parameters which doesn't take into account the external temp
and concentrate to internal temp error + accumulated error.
This should work for cold external conditions which else generates
high external_offset"""
kp: float = 0.4
ki: float = 0.08
k_ext: float = 0.0
offset_max: float = 5
accumulated_error_threshold: float = 50
overheat_protection: bool = True
# Not used now
class RegulationParamVeryStrong:
"""Strong parameters for regulation"""
kp: float = 0.6
ki: float = 0.1
k_ext: float = 0.2
offset_max: float = 8
accumulated_error_threshold: float = 80
overheat_protection: bool = True
def send_vtherm_event(hass, event_type: EventType, entity, data: dict):
"""Send an event"""
_LOGGER.info("%s - Sending event %s with data: %s", entity, event_type, data)
data["entity_id"] = entity.entity_id
data["name"] = entity.name
data["state_attributes"] = entity.state_attributes
hass.bus.fire(event_type.value, data)
def get_safe_float(hass, entity_id: str):
"""Get a safe float state value for an entity.
Return None if entity is not available"""
if entity_id is None or not (state := hass.states.get(entity_id)) or state.state in [None, "None", STATE_UNAVAILABLE, STATE_UNKNOWN]:
return None
return get_safe_float_value(state.state)
def get_safe_float_value(value):
"""Get a safe float value.
Return None if value is not a valid float"""
try:
float_val = float(value)
return None if math.isinf(float_val) or not math.isfinite(float_val) else float_val
except (ValueError, TypeError):
return None
class UnknownEntity(HomeAssistantError):
"""Error to indicate there is an unknown entity_id given."""
class WindowOpenDetectionMethod(HomeAssistantError):
"""Error to indicate there is an error in the window open detection method given."""
class NoCentralConfig(HomeAssistantError):
"""Error to indicate that we try to use a central configuration but no VTherm of type CENTRAL CONFIGURATION has been found"""
class ServiceConfigurationError(HomeAssistantError):
"""Error in the service configuration to control the central boiler"""
class ConfigurationNotCompleteError(HomeAssistantError):
"""Error the configuration is not complete"""
class ValveRegulationNbEntitiesIncorrect(HomeAssistantError):
"""Error to indicate there is an error in the configuration of the TRV with valve regulation.
The number of specific entities is incorrect."""
class SyncDeviceInternalTempNbEntitiesIncorrect(HomeAssistantError):
"""Error to indicate there is an error in the configuration of the TRV with synchronize device internal temperature.
The number of specific entities is incorrect."""
class ValveRegulationMinOpeningDegreesIncorrect(HomeAssistantError):
"""Error to indicate that the minimal opening degrees is not a list of int separated by coma"""
class ValveRegulationMaxOpeningDegreesIncorrect(HomeAssistantError):
"""Error to indicate that the maximal opening degrees is not a list of int separated by coma"""
class ValveRegulationMinMaxOpeningDegreesIncorrect(HomeAssistantError):
"""Error to indicate that max_opening_degrees must be greater than min_opening_degrees for each underlying"""
class VirtualSwitchConfigurationIncorrect(HomeAssistantError):
"""Error when a virtual switch is not configured correctly"""
class LockCodeIncorrect(HomeAssistantError):
"""Error when a lock code is not configured correctly"""
class overrides: # pylint: disable=invalid-name
"""An annotation to inform overrides"""
def __init__(self, func):
self.func = func
def __get__(self, instance, owner):
return self.func.__get__(instance, owner)
def __call__(self, *args, **kwargs):
raise RuntimeError(f"Method {self.func.__name__} should have been overridden")
@@ -0,0 +1,783 @@
"""CycleScheduler: orchestrates cycles for multiple underlyings within a master cycle.
For switches: manages staggered ON/OFF PWM timing to minimize overlap
and smooth electrical load.
For valves: passthrough mode that calls set_valve_open_percent() directly
without temporal scheduling.
"""
import logging
import time
from vtherm_api.log_collector import get_vtherm_logger
from typing import Any, Callable
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from homeassistant.helpers.event import async_call_later
from .vtherm_hvac_mode import VThermHvacMode, VThermHvacMode_OFF
from .cycle_tick_logic import (
UnderlyingCycleState,
compute_circular_offsets,
compute_target_state,
evaluate_need_on,
evaluate_need_off,
compute_e_eff,
)
_LOGGER = get_vtherm_logger(__name__)
def calculate_cycle_times(
on_percent: float,
cycle_min: int,
minimal_activation_delay: int | None = 0,
minimal_deactivation_delay: int | None = 0,
) -> tuple[int, int, bool]:
"""Convert on_percent to on_time_sec and off_time_sec.
Applies minimal activation and deactivation delays to avoid
very short on/off periods that may damage equipment or be ineffective.
Args:
on_percent: The calculated heating percentage (0.0 to 1.0)
cycle_min: The cycle duration in minutes
minimal_activation_delay: Minimum on time in seconds (below this, turn off)
minimal_deactivation_delay: Minimum off time in seconds (below this, stay on)
Returns:
Tuple of (on_time_sec, off_time_sec, forced_by_timing)
- forced_by_timing: True if min_on or min_off delays modified the percentage significantly.
"""
min_on = minimal_activation_delay if minimal_activation_delay is not None else 0
min_off = minimal_deactivation_delay if minimal_deactivation_delay is not None else 0
on_percent = max(0.0, min(1.0, on_percent))
cycle_sec = cycle_min * 60
on_time_sec = on_percent * cycle_sec
forced_by_timing = False
if on_time_sec > 0 and on_time_sec < min_on:
on_time_sec = 0
forced_by_timing = True
off_time_sec = cycle_sec - on_time_sec
if on_time_sec < cycle_sec and off_time_sec < min_off:
on_time_sec = cycle_sec
off_time_sec = 0
forced_by_timing = True
return int(on_time_sec), int(off_time_sec), forced_by_timing
class CycleScheduler:
"""Orchestrates cycles for multiple underlyings within a master cycle.
For switches: all underlyings operate within the same time window.
ON periods are staggered using computed offsets to minimize overlap.
For valves: passthrough mode — calls set_valve_open_percent() directly.
"""
def __init__(
self,
hass: HomeAssistant,
thermostat: Any,
underlyings: list,
cycle_duration_sec: float,
min_activation_delay: int = 0,
min_deactivation_delay: int = 0,
):
self._hass = hass
self._thermostat = thermostat
self._underlyings = underlyings
self._cycle_duration_sec = cycle_duration_sec
self.min_activation_delay: int = min_activation_delay
self.min_deactivation_delay: int = min_deactivation_delay
self._tick_unsub: CALLBACK_TYPE | None = None
self._cycle_end_unsub: CALLBACK_TYPE | None = None
self._on_cycle_start_callbacks: list[Callable] = []
self._on_cycle_end_callbacks: list[Callable] = []
# Current cycle parameters (for repeat at cycle end)
self._current_hvac_mode: VThermHvacMode | None = None
self._current_on_time_sec: float = 0
self._current_off_time_sec: float = 0
self._current_on_percent: float = 0
# Active cycle parameters describe what is physically being executed
# right now. They intentionally differ from _current_* when a running
# cycle receives non-forced updates that should only apply to the next
# repeat at cycle end.
self._active_hvac_mode: VThermHvacMode | None = None
self._active_on_time_sec: float = 0
self._active_off_time_sec: float = 0
self._active_on_percent: float = 0
self._states: list[UnderlyingCycleState] = []
self._penalty: float = 0.0
self._cycle_start_time: float = 0.0
# For valves, keep the applied-power segments within the current
# master cycle so realized e_eff reflects real mid-cycle updates.
self._valve_cycle_trace: list[tuple[float, float]] = []
# Guard flags to keep is_cycle_running=True during async yield points
# where timers are not yet (re-)installed:
# _is_cancelling: True while cancel_cycle() awaits _fire_cycle_end_callbacks()
# _is_starting: True while start_cycle() awaits callbacks or device-control
# operations before the new timers are set
self._is_cancelling: bool = False
self._is_starting: bool = False
# Detect valve mode from underlying types
self._is_valve_mode: bool = self._detect_valve_mode()
@property
def is_cycle_running(self) -> bool:
"""Return True if a cycle is currently scheduled or in a lifecycle transition.
_is_cancelling stays True while cancel_cycle() awaits _fire_cycle_end_callbacks(),
preventing concurrent start_cycle(force=False) from seeing is_cycle_running=False
and starting a duplicate cycle during that window (Race 1).
_is_starting stays True while start_cycle() has finished cancellation but has not
yet finished device-control setup (including _fire_cycle_start_callbacks and
_start_cycle_switch/_start_cycle_valve), preventing concurrent start_cycle(force=False)
from starting a duplicate cycle during that window (Race 2).
"""
return self._tick_unsub is not None or self._cycle_end_unsub is not None or self._is_cancelling or self._is_starting
@property
def is_valve_mode(self) -> bool:
"""Return True if managing valve underlyings (passthrough mode)."""
return self._is_valve_mode
def _detect_valve_mode(self) -> bool:
"""Detect if underlyings are valves by checking entity_type."""
from .underlyings import UnderlyingEntityType # pylint: disable=import-outside-toplevel
if not self._underlyings:
return False
return self._underlyings[0].entity_type in (
UnderlyingEntityType.VALVE,
UnderlyingEntityType.VALVE_REGULATION,
)
def register_cycle_start_callback(self, callback: Callable):
"""Register a callback to be called at the start of each master cycle.
Callback signature: async def callback(on_time_sec, off_time_sec, on_percent, hvac_mode)
"""
self._on_cycle_start_callbacks.append(callback)
def register_cycle_end_callback(self, callback: Callable[[float], Any]):
"""Register a callback to be called at the end of each master cycle."""
self._on_cycle_end_callbacks.append(callback)
def _set_pending_cycle(
self,
hvac_mode: VThermHvacMode | None,
on_time_sec: float,
off_time_sec: float,
on_percent: float,
) -> None:
"""Store parameters that the next cycle repeat must use."""
self._current_hvac_mode = hvac_mode
self._current_on_time_sec = on_time_sec
self._current_off_time_sec = off_time_sec
self._current_on_percent = on_percent
def _set_active_cycle(
self,
hvac_mode: VThermHvacMode | None,
on_time_sec: float,
off_time_sec: float,
on_percent: float,
) -> None:
"""Store parameters of the master cycle currently being executed."""
self._active_hvac_mode = hvac_mode
self._active_on_time_sec = on_time_sec
self._active_off_time_sec = off_time_sec
self._active_on_percent = on_percent
@staticmethod
def _same_cycle_request(
hvac_mode_a: VThermHvacMode | None,
on_time_sec_a: float,
off_time_sec_a: float,
on_percent_a: float,
hvac_mode_b: VThermHvacMode | None,
on_time_sec_b: float,
off_time_sec_b: float,
on_percent_b: float,
) -> bool:
"""Return True when two cycle requests are effectively identical."""
return (
hvac_mode_a == hvac_mode_b
and on_time_sec_a == on_time_sec_b
and off_time_sec_a == off_time_sec_b
and abs(on_percent_a - on_percent_b) <= 1e-9
)
async def start_cycle(
self,
hvac_mode: VThermHvacMode,
on_percent: float,
force: bool = False,
_from_cycle_end: bool = False,
):
"""Start a new master cycle for all underlyings.
Computes on_time_sec and off_time_sec from on_percent, applying
min_activation_delay and min_deactivation_delay constraints.
Args:
hvac_mode: Current HVAC mode.
on_percent: Power percentage as a fraction (0.0 to 1.0).
force: If True, cancel any running cycle and restart immediately.
_from_cycle_end: Internal flag — True when called from _on_master_cycle_end.
"""
cycle_min = self._cycle_duration_sec / 60
on_time_sec, off_time_sec, _ = calculate_cycle_times(
on_percent,
cycle_min,
self.min_activation_delay,
self.min_deactivation_delay,
)
realized_on_percent = on_time_sec / self._cycle_duration_sec if self._cycle_duration_sec > 0 else 0.0
# Always update thermostat timing attributes immediately so sensors
# reflect the latest computed value, even when the cycle returns early.
self._thermostat._on_time_sec = on_time_sec
self._thermostat._off_time_sec = off_time_sec
if self.is_cycle_running and not force:
if self._is_valve_mode:
# Valve mode must keep the current master-cycle window for
# learning callbacks, but the physical valve command still has
# to follow each new regulation result immediately.
_LOGGER.debug(
"%s - Valve cycle already running, applying immediate update: "
"on_time=%.0f, off_time=%.0f, on_percent=%.2f",
self._thermostat,
on_time_sec,
off_time_sec,
realized_on_percent,
)
await self._update_running_valve_cycle(
hvac_mode,
on_time_sec,
off_time_sec,
realized_on_percent,
)
return
if self._active_on_time_sec > 0:
# A real cycle is actively running — don't interrupt it.
# Just update stored params so the next auto-repeat uses them.
_LOGGER.debug(
"%s - Cycle already running (on_time=%.0fs), skipping (force=%s). "
"Updating params for next repeat: on_time=%.0f, off_time=%.0f, on_percent=%.2f",
self._thermostat,
self._active_on_time_sec,
force,
on_time_sec,
off_time_sec,
realized_on_percent,
)
self._set_pending_cycle(hvac_mode, on_time_sec, off_time_sec, realized_on_percent)
return
# Current cycle is idle (on_time=0, device off).
# Keep it running if the requested idle cycle is unchanged; otherwise
# cancel it and allow the new cycle to start immediately.
if self._same_cycle_request(
self._active_hvac_mode,
self._active_on_time_sec,
self._active_off_time_sec,
self._active_on_percent,
hvac_mode,
on_time_sec,
off_time_sec,
realized_on_percent,
):
_LOGGER.debug(
"%s - Current cycle is idle and unchanged, keeping existing cycle",
self._thermostat,
)
self._set_pending_cycle(hvac_mode, on_time_sec, off_time_sec, realized_on_percent)
return
_LOGGER.debug(
"%s - Current cycle is idle (on_time=0), replacing with changed cycle",
self._thermostat,
)
await self._cancel_cycle_impl()
# Store current cycle parameters for repeat
self._set_pending_cycle(hvac_mode, on_time_sec, off_time_sec, realized_on_percent)
self._set_active_cycle(hvac_mode, on_time_sec, off_time_sec, realized_on_percent)
# _is_starting guards the Race window: after _cancel_cycle_impl() cleared all
# timers and flags, but before the new timers are set by _start_cycle_switch() or
# _start_cycle_valve(). During this window, is_cycle_running must return True to
# prevent concurrent start_cycle(force=False) from starting a duplicate full cycle.
self._is_starting = True
try:
# Fire cycle start callbacks with realized percent so learners see actual applied power
await self._fire_cycle_start_callbacks(on_time_sec, off_time_sec, realized_on_percent, hvac_mode)
if self._is_valve_mode:
await self._start_cycle_valve(hvac_mode)
else:
await self._start_cycle_switch(hvac_mode, on_time_sec, off_time_sec, realized_on_percent)
finally:
self._is_starting = False
async def apply_valve_update(
self,
hvac_mode: VThermHvacMode,
on_percent: float,
) -> None:
"""Apply a deferred valve recompute without running a full control cycle."""
if not self._is_valve_mode:
return
cycle_min = self._cycle_duration_sec / 60
on_time_sec, off_time_sec, _ = calculate_cycle_times(
on_percent,
cycle_min,
self.min_activation_delay,
self.min_deactivation_delay,
)
realized_on_percent = on_time_sec / self._cycle_duration_sec if self._cycle_duration_sec > 0 else 0.0
self._thermostat._on_time_sec = on_time_sec
self._thermostat._off_time_sec = off_time_sec
self._set_pending_cycle(hvac_mode, on_time_sec, off_time_sec, realized_on_percent)
if self.is_cycle_running:
await self._update_running_valve_cycle(
hvac_mode,
on_time_sec,
off_time_sec,
realized_on_percent,
)
return
self._set_active_cycle(hvac_mode, on_time_sec, off_time_sec, realized_on_percent)
await self._apply_valve_command(hvac_mode, on_time_sec, off_time_sec)
async def _update_running_valve_cycle(
self,
hvac_mode: VThermHvacMode,
on_time_sec: float,
off_time_sec: float,
on_percent: float,
) -> None:
"""Apply a valve update while preserving the current master-cycle window."""
self._set_pending_cycle(hvac_mode, on_time_sec, off_time_sec, on_percent)
self._set_active_cycle(hvac_mode, on_time_sec, off_time_sec, on_percent)
await self._apply_valve_command(hvac_mode, on_time_sec, off_time_sec)
self._append_valve_cycle_trace(on_percent)
async def _apply_valve_command(
self,
hvac_mode: VThermHvacMode,
on_time_sec: float,
off_time_sec: float,
) -> None:
"""Apply the latest valve command to all underlyings immediately."""
for under in self._underlyings:
under._on_time_sec = on_time_sec
under._off_time_sec = off_time_sec
under._hvac_mode = hvac_mode
await under.set_valve_open_percent()
def _reset_valve_cycle_trace(self, on_percent: float) -> None:
"""Start a new valve trace for the current master cycle."""
self._valve_cycle_trace = [(0.0, max(0.0, min(1.0, on_percent)))]
def _append_valve_cycle_trace(self, on_percent: float) -> None:
"""Append a new applied-power segment for a running valve cycle."""
if self._cycle_start_time <= 0:
self._reset_valve_cycle_trace(on_percent)
return
applied_on_percent = max(0.0, min(1.0, on_percent))
if self._valve_cycle_trace:
last_offset, last_on_percent = self._valve_cycle_trace[-1]
if abs(last_on_percent - applied_on_percent) <= 1e-9:
return
else:
last_offset = 0.0
offset = min(
max(0.0, time.time() - self._cycle_start_time),
self._cycle_duration_sec,
)
offset = max(offset, last_offset)
self._valve_cycle_trace.append((offset, applied_on_percent))
async def _start_cycle_valve(self, hvac_mode: VThermHvacMode):
"""Valve passthrough: call set_valve_open_percent() on each underlying.
Valves don't need temporal ON/OFF scheduling. They just need
their open percentage updated. A master cycle window is still kept so
cycle callbacks remain available to SmartPI in valve-based setups.
"""
self._cycle_start_time = time.time()
await self._apply_valve_command(
hvac_mode,
self._current_on_time_sec,
self._current_off_time_sec,
)
self._reset_valve_cycle_trace(self._current_on_percent)
self._cycle_end_unsub = async_call_later(
self._hass,
self._cycle_duration_sec,
self._on_master_cycle_end,
)
async def _start_cycle_switch(
self,
hvac_mode: VThermHvacMode,
on_time_sec: float,
off_time_sec: float,
on_percent: float,
):
"""Switch True Tick scheduling: initialize cycle and start ticking."""
# Update on_time/off_time on each underlying for keep-alive and monitoring
for under in self._underlyings:
under._on_time_sec = on_time_sec
under._off_time_sec = off_time_sec
under._hvac_mode = hvac_mode
if hvac_mode == VThermHvacMode_OFF or on_time_sec <= 0:
# Turn off all underlyings
for under in self._underlyings:
if under.is_device_active:
await under.turn_off()
under._should_be_on = False
# Keep a real master-cycle start time so the next automatic restart
# reports a full elapsed_ratio instead of looking interrupted with 0 s elapsed.
self._cycle_start_time = time.time()
# Schedule next cycle evaluation
self._cycle_end_unsub = async_call_later(self._hass, self._cycle_duration_sec, self._on_master_cycle_end)
return
if on_time_sec >= self._cycle_duration_sec:
# 100% power: Turn on all underlyings unconditionally to enforce state
for under in self._underlyings:
await under.turn_on()
under._should_be_on = True
# Keep a real master-cycle start time for the same reason as 0% cycles.
self._cycle_start_time = time.time()
# Schedule next cycle evaluation
self._cycle_end_unsub = async_call_later(self._hass, self._cycle_duration_sec, self._on_master_cycle_end)
return
self._init_cycle(on_percent)
# Start ticking immediately with is_initial=True to enforce state
await self._tick(_is_initial=True)
# Also ensure master cycle end is scheduled independently to wrap up the cycle
self._cycle_end_unsub = async_call_later(self._hass, self._cycle_duration_sec, self._on_master_cycle_end)
def _init_cycle(self, on_percent: float):
"""Initialize states and penalty for the new cycle.
Uses circular offsets for evenly distributed power across underlyings,
with natural wrap-around for smooth load distribution.
"""
self._penalty = 0.0
self._cycle_start_time = time.time()
n = len(self._underlyings)
on_time = self._cycle_duration_sec * on_percent
offsets = compute_circular_offsets(self._cycle_duration_sec, n)
self._states = []
for i, under in enumerate(self._underlyings):
state = UnderlyingCycleState(under, offsets[i])
state.on_t = offsets[i]
state.on_time = on_time
state.off_t = (state.on_t + state.on_time) % self._cycle_duration_sec
self._states.append(state)
_LOGGER.debug(
"%s - Initialized true tick cycle: on_percent=%.2f, offsets=%s",
self._thermostat, on_percent, offsets
)
async def _tick(self, _now=None, _is_initial: bool = False):
"""Evaluate all underlyings and schedule the next tick.
When _is_initial=True (called at cycle start), current_t is forced to 0.0
to avoid floating-point drift, and the is_device_active check is skipped so
the desired state is always enforced unconditionally on the first tick.
"""
from homeassistant.util import dt as dt_util
now = time.time()
current_t = 0.0 if _is_initial else (now - self._cycle_start_time)
if not _is_initial and current_t >= self._cycle_duration_sec:
# We reached the end of the cycle; let the master cycle end handle it.
return
next_global_tick = self._cycle_duration_sec - current_t
for state in self._states:
under = state.underlying
target_is_on, next_tick, state_duration = compute_target_state(
state.on_t, state.off_t, current_t, self._cycle_duration_sec
)
# Update global next tick to the earliest upcoming event
time_to_next = next_tick - current_t
if time_to_next > 0 and time_to_next < next_global_tick:
next_global_tick = time_to_next
under_dt = 0.0
if under.last_change:
under_dt = (dt_util.utcnow() - under.last_change).total_seconds()
else:
under_dt = 999999.0 # Safe large value if no history
if target_is_on:
if not under.is_device_active:
action, new_on_t, pen_delta = evaluate_need_on(
under_dt, state_duration,
self.min_deactivation_delay, self.min_activation_delay,
state.on_t, current_t
)
if action == 'turn_on':
_LOGGER.info(
"%s - tick turn_on (state_duration=%.1fs, initial=%s)",
under, state_duration, _is_initial
)
try:
await under.turn_on()
under._should_be_on = True
except Exception as err:
_LOGGER.error("%s - tick turn_on failed: %s", under, err)
elif action == 'skip' and new_on_t is not None:
_LOGGER.debug(
"%s - tick skip turn_on (racollage), on_t shifted %.1f -> %.1f, penalty=%.1f",
under, state.on_t, new_on_t, pen_delta
)
state.on_t = new_on_t
self._penalty += pen_delta
resched_time = new_on_t - current_t
if 0 < resched_time < next_global_tick:
next_global_tick = resched_time
elif _is_initial:
# Enforce state unconditionally on the first tick
if not under.is_device_active:
await under.turn_on()
under._should_be_on = True
elif not target_is_on:
if under.is_device_active:
action, new_off_t, pen_delta = evaluate_need_off(
under_dt, state_duration,
self.min_activation_delay, self.min_deactivation_delay,
state.off_t, current_t
)
if action == 'turn_off':
_LOGGER.info(
"%s - tick turn_off (state_duration=%.1fs, initial=%s)",
under, state_duration, _is_initial
)
try:
await under.turn_off()
under._should_be_on = False
except Exception as err:
_LOGGER.error("%s - tick turn_off failed: %s", under, err)
elif action == 'skip' and new_off_t is not None:
_LOGGER.debug(
"%s - tick skip turn_off (racollage), off_t shifted %.1f -> %.1f, penalty=%.1f",
under, state.off_t, new_off_t, pen_delta
)
state.off_t = new_off_t
self._penalty += pen_delta
resched_time = new_off_t - current_t
if 0 < resched_time < next_global_tick:
next_global_tick = resched_time
elif _is_initial:
# Enforce state unconditionally on the first tick
if under.is_device_active:
try:
await under.turn_off()
under._should_be_on = False
except Exception as err:
_LOGGER.error("%s - initial turn_off failed: %s", under, err)
# Ensure we do not schedule too fast (< 0.1s)
next_global_tick = max(0.1, next_global_tick)
# Schedule next tick
self._tick_unsub = async_call_later(self._hass, next_global_tick, self._tick)
async def _on_master_cycle_end(self, _now):
"""Called at the end of the master cycle. Restart with the same parameters.
The cycle end callback (e_eff) is fired by cancel_cycle(), which is called
inside start_cycle(force=True). This ensures exactly one callback per cycle
end regardless of how the cycle terminates.
"""
if not self.is_cycle_running:
return
# Increment energy counter
self._thermostat.incremente_energy()
# Restart cycle — cancel_cycle() inside start_cycle will fire the end callback.
await self.start_cycle(
self._current_hvac_mode,
self._current_on_percent,
force=True,
_from_cycle_end=True,
)
def shutdown(self):
"""Cancel pending timers immediately without firing end-of-cycle callbacks.
Must be called synchronously when the entity is being removed from HA so
that leftover async_call_later handles cannot fire after the new entity
(potentially with a different cycle duration) has already started.
"""
if self._tick_unsub:
self._tick_unsub()
self._tick_unsub = None
if self._cycle_end_unsub:
self._cycle_end_unsub()
self._cycle_end_unsub = None
self._valve_cycle_trace = []
self._set_pending_cycle(None, 0, 0, 0.0)
self._set_active_cycle(None, 0, 0, 0.0)
self._is_cancelling = False
self._is_starting = False
async def cancel_cycle(self):
"""Cancel the current cycle if one is running."""
await self._cancel_cycle_impl()
async def _cancel_cycle_impl(self):
"""Internal cancel logic, shared by cancel_cycle() and start_cycle()."""
was_running = self.is_cycle_running
# Set before unsubscribing so that is_cycle_running stays True
# throughout this coroutine, even during the async yield below (Race 1 guard).
self._is_cancelling = True
if self._tick_unsub:
self._tick_unsub()
self._tick_unsub = None
if self._cycle_end_unsub:
self._cycle_end_unsub()
self._cycle_end_unsub = None
elapsed_sec = time.time() - self._cycle_start_time if self._cycle_start_time > 0 else 0
# Fire end-of-cycle callback for cycles that ran long enough.
# This includes normal ends (via _on_master_cycle_end -> start_cycle(force=True))
# and mid-cycle interruptions (force restart on setpoint change, etc.).
# During this await, _is_cancelling=True keeps is_cycle_running=True so any
# concurrent start_cycle(force=False) call takes the update path, not full start.
if was_running and elapsed_sec > 1.0:
realized_e_eff = self._calculate_realized_e_eff(elapsed_sec)
elapsed_ratio = min(1.0, elapsed_sec / self._cycle_duration_sec) if self._cycle_duration_sec > 0 else 1.0
_LOGGER.debug("%s - cycle end: elapsed_sec=%.1f, realized_e_eff=%.3f, elapsed_ratio=%.2f", self._thermostat, elapsed_sec, realized_e_eff, elapsed_ratio)
await self._fire_cycle_end_callbacks(realized_e_eff, elapsed_ratio)
self._states = []
self._valve_cycle_trace = []
self._set_pending_cycle(None, 0, 0, 0.0)
self._set_active_cycle(None, 0, 0, 0.0)
self._cycle_start_time = 0.0
# Reset only after all state is cleared so the guard stays active until fully done.
self._is_cancelling = False
_LOGGER.debug("%s - Cycle cancelled", self._thermostat)
def _calculate_realized_e_eff(self, elapsed_sec: float) -> float:
"""Calculate the actual effective power applied over the given elapsed time."""
if not self._underlyings or elapsed_sec <= 0:
return 0.0
if self._is_valve_mode:
if not self._valve_cycle_trace:
return max(0.0, min(1.0, self._active_on_percent))
weighted_power = 0.0
for idx, (start_offset, on_percent) in enumerate(self._valve_cycle_trace):
if start_offset >= elapsed_sec:
break
end_offset = elapsed_sec
if idx + 1 < len(self._valve_cycle_trace):
end_offset = min(self._valve_cycle_trace[idx + 1][0], elapsed_sec)
if end_offset > start_offset:
weighted_power += (end_offset - start_offset) * on_percent
return max(0.0, min(1.0, weighted_power / elapsed_sec))
# When _states is empty the cycle ran at either 0% or 100% (no tick scheduling).
# Infer from _active_on_time_sec: if it covers the full duration, e_eff = 1.0.
if not self._states:
if self._active_on_time_sec >= self._cycle_duration_sec:
return 1.0
return 0.0
t_on_actual = 0.0
for state in self._states:
if state.off_t >= state.on_t:
start_on = min(state.on_t, elapsed_sec)
end_on = min(state.off_t, elapsed_sec)
if end_on > start_on:
t_on_actual += (end_on - start_on)
else:
end_on_1 = min(state.off_t, elapsed_sec)
t_on_actual += end_on_1
start_on_2 = min(state.on_t, elapsed_sec)
end_on_2 = elapsed_sec
if end_on_2 > start_on_2:
t_on_actual += (end_on_2 - start_on_2)
# e_eff is the true instantaneous duty cycle over the elapsed window.
e_eff = max(0.0, t_on_actual - self._penalty) / (elapsed_sec * len(self._underlyings))
return max(0.0, min(1.0, e_eff))
async def _fire_cycle_start_callbacks(
self, on_time_sec, off_time_sec, on_percent, hvac_mode
):
"""Fire all registered cycle start callbacks."""
for callback in self._on_cycle_start_callbacks:
try:
await callback(
on_time_sec=on_time_sec,
off_time_sec=off_time_sec,
on_percent=on_percent,
hvac_mode=hvac_mode,
)
except Exception as ex:
_LOGGER.warning(
"%s - Error calling cycle start callback %s: %s",
self._thermostat,
callback,
ex,
)
async def _fire_cycle_end_callbacks(self, e_eff: float, elapsed_ratio: float = 1.0):
"""Fire all registered cycle end callbacks with e_eff and elapsed_ratio."""
cycle_duration_min = self._cycle_duration_sec / 60.0
for callback in self._on_cycle_end_callbacks:
try:
await callback(
e_eff=e_eff,
elapsed_ratio=elapsed_ratio,
cycle_duration_min=cycle_duration_min,
)
except Exception as ex:
_LOGGER.warning(
"%s - Error calling cycle end callback %s: %s",
self._thermostat,
callback,
ex,
)
@@ -0,0 +1,127 @@
"""Pure logic for the Versatile Thermostat True Tick Cycle Scheduler."""
class UnderlyingCycleState:
"""Per-underlying state tracking for a single cycle."""
def __init__(self, underlying, offset: float):
"""Initialize the state with an underlying reference and a fixed circular offset."""
self.underlying = underlying
self.offset = offset
self.on_t: float = 0.0
self.off_t: float = 0.0
self.on_time: float = 0.0
def compute_circular_offsets(cycle_duration_sec: float, n: int) -> list[float]:
"""Compute evenly-spaced circular offsets for n underlyings.
Returns:
List of start offsets in seconds for each underlying.
"""
if n <= 1:
return [0.0] * n
step = cycle_duration_sec / n
return [round(i * step, 1) for i in range(n)]
def compute_target_state(
on_t: float, off_t: float, current_t: float, cycle_duration: float
) -> tuple[bool, float, float]:
"""Determine the theoretical target state and the next tick timestamp.
Returns:
(target_is_on, next_tick, state_duration)
"""
if off_t > on_t: # ON is confined in [on_t, off_t)
if current_t < on_t:
target = False
next_tick = on_t
elif current_t < off_t:
target = True
next_tick = off_t
else:
target = False
next_tick = cycle_duration
else: # ON wraps around: [0, off_t) U [on_t, cycle_end)
if current_t < off_t:
target = True
next_tick = off_t
elif current_t < on_t:
target = False
next_tick = on_t
else:
target = True
next_tick = cycle_duration
# Note (off_t == on_t falls in wrap-around condition):
# This works safely because upstream guards in cycle_scheduler._start_cycle_switch
# handle `on_time == 0` and `on_time == cycle_duration` before evaluating ticks.
state_duration = next_tick - current_t
return target, next_tick, state_duration
def evaluate_need_on(
under_dt: float, state_duration: float,
min_deactivation: float, min_activation: float,
on_t: float, current_t: float,
) -> tuple[str, float | None, float]:
"""Evaluate whether to actually turn ON (need_on) according to constraints.
Returns:
(action, new_on_t_or_none, penalty_delta)
action is 'turn_on' or 'skip'
"""
if under_dt >= min_deactivation and state_duration > min_activation:
return 'turn_on', None, 0.0
# CAS RACOLLAGE (Skip this turn ON)
new_on_t = max(0.0, on_t - state_duration)
penalty_delta = state_duration
if (new_on_t - current_t) < (min_deactivation - under_dt):
new_on_t = current_t + (min_deactivation - under_dt)
delay = new_on_t - current_t
penalty_delta = delay if delay < state_duration else state_duration
return 'skip', new_on_t, penalty_delta
def evaluate_need_off(
under_dt: float, state_duration: float,
min_activation: float, min_deactivation: float,
off_t: float, current_t: float,
) -> tuple[str, float | None, float]:
"""Evaluate whether to actually turn OFF (need_off) according to constraints.
Returns:
(action, new_off_t_or_none, penalty_delta)
action is 'turn_off' or 'skip'
"""
if under_dt >= min_activation and state_duration > min_deactivation:
return 'turn_off', None, 0.0
# CAS RACOLLAGE (Skip this turn OFF)
new_off_t = max(0.0, off_t - state_duration)
penalty_delta = -state_duration
if (new_off_t - current_t) < (min_activation - under_dt):
new_off_t = current_t + (min_activation - under_dt)
delay = new_off_t - current_t
penalty_delta = -delay if delay < state_duration else -state_duration
return 'skip', new_off_t, penalty_delta
def compute_e_eff(
on_percent: float, penalty: float,
cycle_duration: float, n_underlyings: int,
) -> float:
"""Compute effective power ratio (e_eff) at the end of the cycle."""
if n_underlyings == 0 or cycle_duration <= 0:
return 0.0
full_on_t = cycle_duration * n_underlyings
e_eff = (full_on_t * on_percent - penalty) / full_on_t
return max(0.0, min(1.0, e_eff))
@@ -0,0 +1,93 @@
# pylint: disable=line-too-long
"""The Estimated Mobile Average calculation used for temperature slope
and maybe some others feature"""
import logging
from vtherm_api.log_collector import get_vtherm_logger
import math
from datetime import datetime, tzinfo
_LOGGER = get_vtherm_logger(__name__)
MIN_TIME_DECAY_SEC = 0
# MAX_ALPHA:
# As for the EMA calculation of irregular time series, I've seen that it might be useful to
# have an upper limit for alpha in case the last measurement was too long ago.
# For example when using a half life of 10 minutes a measurement that is 60 minutes ago
# (if there's nothing inbetween) would contribute to the smoothed value with 1,5%,
# giving the current measurement 98,5% relevance. It could be wise to limit the alpha to e.g. 4x the half life (=0.9375).
class ExponentialMovingAverage:
"""A class that will do the Estimated Mobile Average calculation"""
def __init__(
self,
vterm_name: str,
halflife: float,
timezone: tzinfo,
precision: int = 3,
max_alpha: float = 0.5,
):
"""The halflife is the duration in secondes of a normal cycle"""
self._halflife: float = halflife
self._timezone = timezone
self._current_ema: float = None
self._last_timestamp: datetime = datetime.now(self._timezone)
self._name = vterm_name
self._precision = precision
self._max_alpha = max_alpha
def __str__(self) -> str:
return f"EMA-{self._name}"
def calculate_ema(self, measurement: float, timestamp: datetime) -> float | None:
"""Calculate the new EMA from a new measurement measured at timestamp
Return the EMA or None if all parameters are not initialized now
"""
if measurement is None or timestamp is None:
_LOGGER.warning(
"%s - Cannot calculate EMA: measurement and timestamp are mandatory. This message can be normal at startup but should not persist",
self,
)
return measurement
if self._current_ema is None:
_LOGGER.debug(
"%s - First init of the EMA",
self,
)
self._current_ema = measurement
self._last_timestamp = timestamp
return self._current_ema
time_decay = (timestamp - self._last_timestamp).total_seconds()
if time_decay < MIN_TIME_DECAY_SEC:
_LOGGER.debug(
"%s - time_decay %s is too small (< %s). Forget the measurement",
self,
time_decay,
MIN_TIME_DECAY_SEC,
)
return self._current_ema
alpha = 1 - math.exp(math.log(0.5) * time_decay / self._halflife)
# capping alpha to avoid gap if last measurement was long time ago
alpha = min(alpha, self._max_alpha)
new_ema = alpha * measurement + (1 - alpha) * self._current_ema
self._last_timestamp = timestamp
self._current_ema = new_ema
_LOGGER.debug(
"%s - timestamp=%s alpha=%.2f measurement=%.2f current_ema=%.2f new_ema=%.2f",
self,
timestamp,
alpha,
measurement,
self._current_ema,
new_ema,
)
return round(self._current_ema, self._precision)
@@ -0,0 +1,318 @@
""" Implements the Auto-start/stop Feature Manager """
# pylint: disable=line-too-long
import logging
from datetime import datetime
from vtherm_api.log_collector import get_vtherm_logger
from typing import Any
from homeassistant.core import (
HomeAssistant,
callback,
)
from .const import * # pylint: disable=wildcard-import, unused-wildcard-import
from .commons_type import ConfigData
from .commons import write_event_log
from .base_manager import BaseFeatureManager
from .auto_start_stop_algorithm import (
AutoStartStopDetectionAlgorithm,
)
_LOGGER = get_vtherm_logger(__name__)
class FeatureAutoStartStopManager(BaseFeatureManager):
"""The implementation of the AutoStartStop feature"""
unrecorded_attributes = frozenset(
{
"auto_start_stop_level",
"auto_start_stop_dtmin",
"auto_start_stop_enable",
"auto_start_stop_accumulated_error",
"auto_start_stop_accumulated_error_threshold",
"auto_start_stop_last_switch_date",
}
)
def __init__(self, vtherm: Any, hass: HomeAssistant):
"""Init of a featureManager"""
super().__init__(vtherm, hass)
self._auto_start_stop_level: str = AUTO_START_STOP_LEVEL_NONE
self._auto_start_stop_algo: AutoStartStopDetectionAlgorithm | None = None
self._is_configured: bool = False
self._is_auto_start_stop_enabled: bool = False
self._is_auto_stop_detected: bool = False
@overrides
def post_init(self, entry_infos: ConfigData):
"""Reinit of the manager"""
if not self._vtherm.is_over_climate or self._vtherm.have_valve_regulation:
return
use_auto_start_stop = entry_infos.get(CONF_USE_AUTO_START_STOP_FEATURE, False)
if use_auto_start_stop:
self._auto_start_stop_level = (
entry_infos.get(CONF_AUTO_START_STOP_LEVEL, None)
or AUTO_START_STOP_LEVEL_NONE
)
self._is_configured = True
else:
self._auto_start_stop_level = AUTO_START_STOP_LEVEL_NONE
self._is_configured = False
# Initialize the enable flag synchronously so the manager is operational
# without having to wait for the AutoStartStopEnable switch's
# async_added_to_hass callback (which is racy under test load).
# The switch's async_added_to_hass will later override this value
# from the persisted state if needed.
# This must mirror the default in switch.AutoStartStopEnable.
self._is_auto_start_stop_enabled = 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
)
# Fix an eventual incoherent state
if self._vtherm.is_on and self._vtherm.hvac_off_reason == HVAC_OFF_REASON_AUTO_START_STOP:
self._vtherm.hvac_off_reason = None
@overrides
async def start_listening(self):
"""Start listening the underlying entity"""
@overrides
def stop_listening(self):
"""Stop listening and remove the eventual timer still running"""
@overrides
async def refresh_state(self) -> bool:
"""Check the auto-start-stop and an eventual action
Return True is auto stop is detected"""
if not self._is_configured or not self._is_auto_start_stop_enabled:
_LOGGER.debug("%s - auto start/stop is disabled (or not configured)", self)
self._is_auto_stop_detected = False
else:
# Do the auto-start-stop calculation
slope = (self._vtherm.last_temperature_slope or 0) / 60 # to have the slope in °/min
should_be_off = self._auto_start_stop_algo.should_be_turned_off(
self._vtherm.requested_state.hvac_mode,
self._vtherm.target_temperature,
self._vtherm.current_temperature,
slope,
self._vtherm.now,
)
_LOGGER.debug("%s - auto_start_stop should be off is %s", self, should_be_off)
if should_be_off:
_LOGGER.info("%s - VTherm should be OFF due to auto-start-stop conditions", self)
# self._vtherm.set_hvac_off_reason(HVAC_OFF_REASON_AUTO_START_STOP)
# await self._vtherm.async_turn_off()
# Send an event if vtherm is on
if not self._is_auto_stop_detected:
self._vtherm.send_event(
event_type=EventType.AUTO_START_STOP_EVENT,
data={
"type": "stop",
"name": self.name,
"cause": "Auto stop conditions reached",
"hvac_mode": str(VThermHvacMode_OFF),
"saved_hvac_mode": str(self._vtherm.requested_state.hvac_mode),
"target_temperature": self._vtherm.target_temperature,
"current_temperature": self._vtherm.current_temperature,
"temperature_slope": round(slope, 3),
"accumulated_error": self._auto_start_stop_algo.accumulated_error,
"accumulated_error_threshold": self._auto_start_stop_algo.accumulated_error_threshold,
},
)
self._is_auto_stop_detected = True
else:
_LOGGER.info("%s - VTherm should be ON due to auto-start-stop conditions", self)
# await self._vtherm.async_turn_on()
# Send an event
if self._is_auto_stop_detected:
self._vtherm.send_event(
event_type=EventType.AUTO_START_STOP_EVENT,
data={
"type": "start",
"name": self.name,
"cause": "Auto start conditions reached",
"hvac_mode": str(self._vtherm.requested_state.hvac_mode),
"saved_hvac_mode": str(self._vtherm.requested_state.hvac_mode),
"target_temperature": self._vtherm.target_temperature,
"current_temperature": self._vtherm.current_temperature,
"temperature_slope": round(slope, 3),
"accumulated_error": self._auto_start_stop_algo.accumulated_error,
"accumulated_error_threshold": self._auto_start_stop_algo.accumulated_error_threshold,
},
)
self._is_auto_stop_detected = False
# returns True if we should stop
return self._is_auto_stop_detected
async def refresh_and_update_if_changed(self) -> bool:
"""Refresh the auto start/stop state and update_states of VTherm if changed
Returns True if the state has changed, False otherwise"""
old_auto_start_stop: bool = self.is_auto_stop_detected
if old_auto_start_stop != await self.refresh_state():
write_event_log(_LOGGER, self._vtherm, f"Auto start/stop state changed from {old_auto_start_stop} to {self.is_auto_stop_detected}")
self._vtherm.requested_state.force_changed()
await self._vtherm.update_states(force=True)
return True
return False
async def set_auto_start_stop_enable(self, is_enabled: bool):
"""Enable/Disable the auto-start/stop feature"""
if self._is_auto_start_stop_enabled != is_enabled:
self._is_auto_start_stop_enabled = is_enabled
# Send an event if the vtherm was off due to auto-start/stop and enable has been set to false
if not is_enabled and self._vtherm.hvac_mode == VThermHvacMode_OFF and self._vtherm.hvac_off_reason == HVAC_OFF_REASON_AUTO_START_STOP:
_LOGGER.debug("%s - the vtherm is off cause auto-start/stop and enable have been set to false -> starts the VTherm")
# Send an event
self._vtherm.send_event(
event_type=EventType.AUTO_START_STOP_EVENT,
data={
"type": "start",
"name": self.name,
"cause": "Auto start stop disabled",
"hvac_mode": str(self._vtherm.requested_state.hvac_mode),
"saved_hvac_mode": str(self._vtherm.requested_state.hvac_mode),
"target_temperature": self._vtherm.target_temperature,
"current_temperature": self._vtherm.current_temperature,
"temperature_slope": round(self._vtherm.last_temperature_slope or 0, 3),
"accumulated_error": self._auto_start_stop_algo.accumulated_error,
"accumulated_error_threshold": self._auto_start_stop_algo.accumulated_error_threshold,
},
)
await self.refresh_state()
self._vtherm.requested_state.force_changed()
await self._vtherm.update_states(True)
self._vtherm.update_custom_attributes()
@callback
@overrides
def restore_state(self, old_state) -> None:
"""Restore the auto start/stop manager state after a Home Assistant restart.
Restores:
- _is_auto_stop_detected: so the VTherm stays off if it was auto-stopped
- algo._accumulated_error: so the algorithm continues from where it left off
- algo._last_switch_date: to prevent rapid ON/OFF switching after restart
- algo._last_should_be_off: kept in sync with _is_auto_stop_detected
"""
if old_state is None:
return
manager_attr = old_state.attributes.get("auto_start_stop_manager")
if not manager_attr:
return
# Restore the manager-level detected state
self._is_auto_stop_detected = bool(manager_attr.get("is_auto_stop_detected", False))
# Restore algorithm intermediate state
if self._auto_start_stop_algo is not None:
accumulated_error = manager_attr.get("auto_start_stop_accumulated_error")
if accumulated_error is not None:
self._auto_start_stop_algo._accumulated_error = float(accumulated_error)
last_switch_date_raw = manager_attr.get("auto_start_stop_last_switch_date")
if last_switch_date_raw is not None:
if isinstance(last_switch_date_raw, str):
try:
self._auto_start_stop_algo._last_switch_date = datetime.fromisoformat(last_switch_date_raw)
except (ValueError, TypeError) as err:
_LOGGER.warning("%s - restore_state: could not parse last_switch_date '%s': %s", self, last_switch_date_raw, err)
else:
self._auto_start_stop_algo._last_switch_date = last_switch_date_raw
# Keep algo's internal flag in sync with the manager state
self._auto_start_stop_algo._last_should_be_off = self._is_auto_stop_detected
_LOGGER.info(
"%s - restore_state: is_auto_stop_detected=%s, accumulated_error=%s",
self,
self._is_auto_stop_detected,
manager_attr.get("auto_start_stop_accumulated_error"),
)
def add_custom_attributes(self, extra_state_attributes: dict[str, Any]):
"""Add some custom attributes"""
extra_state_attributes.update(
{
"is_auto_start_stop_configured": self.is_configured,
}
)
if self.is_configured:
extra_state_attributes.update(
{
"auto_start_stop_manager": {
"auto_start_stop_enable": self.auto_start_stop_enable,
"auto_start_stop_level": self._auto_start_stop_algo.level,
"auto_start_stop_dtmin": self._auto_start_stop_algo.dt_min,
"auto_start_stop_accumulated_error": self._auto_start_stop_algo.accumulated_error,
"auto_start_stop_accumulated_error_threshold": self._auto_start_stop_algo.accumulated_error_threshold,
"auto_start_stop_last_switch_date": self._auto_start_stop_algo.last_switch_date,
"is_auto_stop_detected": self.is_auto_stop_detected,
}
}
)
@property
def is_auto_stop_detected(self) -> bool:
"""Return True if the auto-start/stop feature is detected"""
return self._is_auto_stop_detected
@property
def is_detected(self) -> bool:
"""Return True if the auto-start/stop feature is detected"""
return self._is_auto_stop_detected
@overrides
@property
def is_configured(self) -> bool:
"""Return True of the aiuto-start/stop feature is configured"""
return self._is_configured
@property
def auto_start_stop_level(self) -> str:
"""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
@property
def is_auto_stopped(self) -> bool:
"""Returns the is vtherm is stopped and reason is AUTO_START_STOP"""
return self._vtherm.hvac_mode == VThermHvacMode_OFF and self._vtherm.hvac_off_reason == HVAC_OFF_REASON_AUTO_START_STOP
def reset_switch_delay(self):
"""Reset the switch delay in the algorithm to allow immediate restart.
Should be called when target temperature changes significantly.
"""
if self._auto_start_stop_algo:
self._auto_start_stop_algo.reset_switch_delay()
def __str__(self):
return f"AutoStartStopManager-{self.name}"
@@ -0,0 +1,468 @@
"""This module manages the central boiler feature of the Versatile Thermostat integration."""
from datetime import timedelta
from typing import Any
from homeassistant.core import (
HomeAssistant,
HomeAssistantError,
)
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.event import (
async_call_later,
async_track_state_change_event,
async_track_time_interval,
)
from .base_manager import BaseFeatureManager
from .commons import check_and_extract_service_configuration, write_event_log
from .const import * # pylint: disable=wildcard-import, unused-wildcard-import
from vtherm_api.log_collector import get_vtherm_logger
_LOGGER = get_vtherm_logger(__name__)
class FeatureCentralBoilerManager(BaseFeatureManager):
"""The implementation of the Central Boiler Feature Manager for Versatile Thermostat"""
def __init__(self, hass: HomeAssistant, vtherm_api: Any):
"""Initialize the FeatureCentralBoilerManager."""
super().__init__(vtherm_api, hass)
self._vtherm_api = vtherm_api
self._is_configured: bool = False
self._is_ready: bool = False
self._is_on: bool | None = None
self._service_activate: dict | None = None
self._service_deactivate: dict | None = None
self._central_boiler_entity = None
self._nb_active_device_threshold_number_entity = None
self._total_power_active_threshold_number_entity = None
self._nb_active_device_number_entity = None
self._total_power_active_entity = None
self._all_boiler_entities: list[Entity] = []
self._activation_delay_sec: int = 0
self._call_later_handle = None
# Keep-alive tracking
self._keep_alive_boiler_state_enabled: bool = False
self._keep_alive_boiler_delay_sec: int = 0
self._time_interval_listener_cancel = None
self._last_activated_service: dict | None = None
@overrides
def post_init(self, entry_infos: dict):
"""Reinit of the manager"""
self._service_activate = check_and_extract_service_configuration(
entry_infos.get(CONF_CENTRAL_BOILER_ACTIVATION_SRV)
)
self._service_deactivate = check_and_extract_service_configuration(
entry_infos.get(CONF_CENTRAL_BOILER_DEACTIVATION_SRV)
)
self._activation_delay_sec = entry_infos.get(CONF_CENTRAL_BOILER_ACTIVATION_DELAY_SEC, 0)
self._keep_alive_boiler_delay_sec = entry_infos.get(CONF_KEEP_ALIVE_BOILER_DELAY_SEC, DEFAULT_KEEP_ALIVE_BOILER_DELAY_SEC)
self._last_activated_service = None
self._is_configured = bool(self._service_activate or self._service_deactivate)
self._keep_alive_boiler_state_enabled = self._keep_alive_boiler_delay_sec > 0 and self._is_configured
@overrides
async def start_listening(self, force: bool = False):
"""Initialize the listening of state change of VTherms"""
# Listen to all VTherm state change
boiler_entity_ids = self._get_all_boiler_entity_ids(force=force)
if self.is_ready:
self.stop_listening()
listener_cancel = async_track_state_change_event(
self._hass,
boiler_entity_ids,
self.calculate_central_boiler_state,
)
_LOGGER.debug(
"%s - entities to get the nb of active VTherm are %s",
self,
boiler_entity_ids,
)
self.add_listener(listener_cancel)
# Add periodic keep-alive if delay is configured
if self._keep_alive_boiler_state_enabled:
interval_listener_cancel = async_track_time_interval(
self._hass,
self._keep_alive_boiler,
timedelta(seconds=self._keep_alive_boiler_delay_sec),
)
self._time_interval_listener_cancel = interval_listener_cancel
self.add_listener(interval_listener_cancel)
_LOGGER.debug(
"%s - Keep-alive boiler enabled (every %d seconds)",
self,
self._keep_alive_boiler_delay_sec,
)
await self.calculate_central_boiler_state(None)
else:
_LOGGER.debug("%s - no VTherm could controls the central boiler", self)
@property
def is_configured(self) -> bool:
"""True if the FeatureManager is fully configured"""
return self._is_configured
@property
def is_ready(self) -> bool:
"""True if the FeatureManager is fully configured and has all registered entities"""
return self._is_ready
@property
def is_on(self) -> bool | None:
"""Return True if the central boiler is on. Return None if the state is unknown (at startup for example)"""
return self._is_on
@property
def is_detected(self) -> bool:
"""Return True if the central boiler is detected"""
return self._is_on if self._is_on is not None else False
async def calculate_central_boiler_state(self, _):
"""Calculate the central boiler state depending on all VTherm that
controls this central boiler"""
_LOGGER.debug("%s - calculating the new central boiler state", self)
def _send_vtherm_event(data: dict):
send_vtherm_event(
hass=self._hass,
event_type=EventType.CENTRAL_BOILER_EVENT,
entity=self.central_boiler_entity,
data=data,
)
async def _activate_later(_):
"""Activate the central boiler after a delay"""
if self._call_later_handle:
self._call_later_handle()
self._call_later_handle = None
# the condition may have changed during the delay, so we check it again before activating the boiler
active = self.is_nb_active_active_for_boiler_exceeded or self.is_total_power_active_for_boiler_exceeded
if not active:
_LOGGER.info("%s - central boiler delayed activation cancelled because condition is not valid anymore", self)
return
_LOGGER.info("%s - central boiler will be turned on", self)
try:
await self.call_service(self._service_activate)
_LOGGER.info("%s - central boiler have been turned on after delay", self)
self._is_on = True
if self._keep_alive_boiler_state_enabled:
self._last_activated_service = self._service_activate
_send_vtherm_event(
data={"central_boiler": True},
)
except HomeAssistantError as err:
_LOGGER.error(
"%s - Impossible to activate boiler due to error %s. "
"Central boiler will not being controlled by VTherm. "
"Please check your service configuration. Cf. README.",
self,
err,
)
return
if not self.is_ready:
_LOGGER.warning(
"%s - the central boiler manager is not ready. Central boiler state cannot be calculated",
self,
)
return False
active = self.is_nb_active_active_for_boiler_exceeded or self.is_total_power_active_for_boiler_exceeded
if self._is_on != active:
try:
if active:
write_event_log(
_LOGGER,
self,
f"Central boiler is being turned on (nb_active= {self.nb_active_device_for_boiler}/{self.nb_active_device_for_boiler_threshold},"
"total_power= {self.total_power_active_for_boiler}/{self.total_power_active_for_boiler_threshold})",
)
if self._call_later_handle:
_LOGGER.debug("%s - central boiler activation is already scheduled", self)
return
if self._activation_delay_sec > 0:
self._call_later_handle = async_call_later(self._hass, timedelta(seconds=self._activation_delay_sec), _activate_later)
_send_vtherm_event(
data={"delayed_activation_sec": self._activation_delay_sec},
)
else:
await _activate_later(None)
else:
write_event_log(
_LOGGER,
self,
f"Central boiler is being turned off (nb_active= {self.nb_active_device_for_boiler}/{self.nb_active_device_for_boiler_threshold},"
"total_power= {self.total_power_active_for_boiler}/{self.total_power_active_for_boiler_threshold})",
)
# Cancel any pending activation
if self._call_later_handle:
self._call_later_handle()
self._call_later_handle = None
# call deactivation service
await self.call_service(self._service_deactivate)
_LOGGER.info("%s - central boiler have been turned off", self)
self._is_on = active
if self._keep_alive_boiler_state_enabled:
self._last_activated_service = self._service_deactivate
_send_vtherm_event(
data={"central_boiler": active},
)
except HomeAssistantError as err:
_LOGGER.error(
"%s - Impossible to activate/deactivate boiler due to error %s. "
"Central boiler will not being controlled by VTherm. "
"Please check your service configuration. Cf. README.",
self,
err,
)
async def _keep_alive_boiler(self, _=None):
"""Send keep-alive command to maintain boiler state (called periodically if delay is configured)"""
if self._keep_alive_boiler_delay_sec <= 0:
return
# If we have a pending activation (after delay), don't send keep-alive yet
if self._call_later_handle is not None:
return
# Send keep-alive command for the last activated service
if self._last_activated_service:
try:
await self.call_service(self._last_activated_service)
entity_id = self._last_activated_service.get("entity_id", "unknown")
_LOGGER.debug(
"%s - Keep-alive boiler command sent (entity %s)",
self,
entity_id,
)
except HomeAssistantError as err:
_LOGGER.warning(
"%s - Failed to send keep-alive command: %s",
self,
err,
)
async def call_service(self, service_config: dict):
"""Make a call to a service if correctly configured"""
if not service_config:
return
_LOGGER.debug(
"%s - Calling central boiler service %s with data %s",
self,
f"{service_config['service_domain']}.{service_config['service_name']}",
service_config.get("data", {}),
)
await self._hass.services.async_call(
service_config["service_domain"],
service_config["service_name"],
service_data=service_config["data"],
target={
"entity_id": service_config["entity_id"],
},
)
async def reload_central_boiler_binary_listener(self):
"""Reloads the BinarySensor entity which listen to the number of
active devices and the thresholds entities"""
if self._nb_active_device_number_entity:
await self._nb_active_device_number_entity.listen_vtherms_entities()
if self._total_power_active_entity:
await self._total_power_active_entity.listen_vtherms_entities()
async def reload_central_boiler_entities_list(self):
"""Reload the central boiler list of entities if a central boiler is used"""
if self._nb_active_device_number_entity is not None:
await self._nb_active_device_number_entity.listen_vtherms_entities()
def add_custom_attributes(self, extra_state_attributes: dict[str, Any]) -> None:
"""Add custom attributes to the attributes dict."""
extra_state_attributes.update(
{
"is_central_boiler_configured": self._is_configured,
"is_central_boiler_ready": self._is_ready,
}
)
if self._is_ready:
extra_state_attributes.update(
{
"central_boiler_manager": {
"is_on": self._is_on,
"activation_scheduled": self._call_later_handle is not None,
"delayed_activation_sec": self._activation_delay_sec,
"nb_active_device_for_boiler": self.nb_active_device_for_boiler,
"nb_active_device_for_boiler_threshold": self.nb_active_device_for_boiler_threshold,
"total_power_active_for_boiler": self.total_power_active_for_boiler,
"total_power_active_for_boiler_threshold": self.total_power_active_for_boiler_threshold,
"service_activate": self._service_activate,
"service_deactivate": self._service_deactivate,
"keep_alive_boiler_delay_sec": self._keep_alive_boiler_delay_sec,
}
}
)
def register_central_boiler(self, central_boiler_entity):
"""Register the central boiler entity. This is used by the CentralBoilerBinarySensor
class to register itself at creation"""
self._central_boiler_entity = central_boiler_entity
self.hass.create_task(self.start_listening(force=True))
def register_central_boiler_activation_number_threshold(self, threshold_number_entity):
"""register the number entities needed for boiler activation"""
self._nb_active_device_threshold_number_entity = threshold_number_entity
self.hass.create_task(self.start_listening(force=True))
def register_central_boiler_power_activation_threshold(self, power_threshold_number_entity):
"""register the power entities needed for boiler activation"""
self._total_power_active_threshold_number_entity = power_threshold_number_entity
self.hass.create_task(self.start_listening(force=True))
def register_nb_device_active_boiler(self, nb_active_number_entity):
"""register the two number entities needed for boiler activation"""
self._nb_active_device_number_entity = nb_active_number_entity
self.hass.create_task(self.start_listening(force=True))
def register_total_power_active_boiler(self, total_power_active_entity):
"""register the two number entities needed for boiler activation"""
self._total_power_active_entity = total_power_active_entity
self.hass.create_task(self.start_listening(force=True))
@property
def central_boiler_entity(self):
"""Get the central boiler binary_sensor entity"""
return self._central_boiler_entity
@property
def nb_active_device_for_boiler(self):
"""Returns the number of active VTherm which have an
influence on boiler"""
if self._nb_active_device_number_entity is None:
return None
else:
return self._nb_active_device_number_entity.native_value
@property
def nb_active_device_for_boiler_threshold(self):
"""Returns the number of active VTherm entity which have an
influence on boiler"""
if self._nb_active_device_threshold_number_entity is None:
return None
return int(self._nb_active_device_threshold_number_entity.native_value)
@property
def total_power_active_for_boiler(self):
"""Returns the total power of active VTherm which have an
influence on boiler"""
if self._total_power_active_entity is None:
return None
else:
return self._total_power_active_entity.native_value
@property
def total_power_active_for_boiler_threshold(self):
"""Returns the number of active VTherm entity which have an
influence on boiler"""
if self._total_power_active_threshold_number_entity is None:
return None
return int(self._total_power_active_threshold_number_entity.native_value)
@property
def is_nb_active_active_for_boiler_exceeded(self) -> bool:
"""Returns True if the number of active VTherm for boiler
have exceeded the threshold"""
if self.nb_active_device_for_boiler is None or self.nb_active_device_for_boiler_threshold is None or self.nb_active_device_for_boiler_threshold == 0:
return False
return self.nb_active_device_for_boiler >= self.nb_active_device_for_boiler_threshold
@property
def is_total_power_active_for_boiler_exceeded(self) -> bool:
"""Returns True if the total power of active VTherm for boiler
have exceeded the threshold"""
if self.total_power_active_for_boiler is None or self.total_power_active_for_boiler_threshold is None or self.total_power_active_for_boiler_threshold == 0:
return False
total_power = self.total_power_active_for_boiler
power_threshold = self.total_power_active_for_boiler_threshold
return total_power >= power_threshold
def _get_all_boiler_entity_ids(self, force=False) -> list[str]:
"""Returns the list of all VTherm entity ids which have an influence
on the central boiler"""
if self._is_configured and not force:
return self._all_boiler_entities
self._all_boiler_entities = []
if self._nb_active_device_threshold_number_entity:
self._all_boiler_entities.append(self._nb_active_device_threshold_number_entity.entity_id)
if self._total_power_active_threshold_number_entity:
self._all_boiler_entities.append(self._total_power_active_threshold_number_entity.entity_id)
if self._nb_active_device_number_entity:
self._all_boiler_entities.append(self._nb_active_device_number_entity.entity_id)
if self._total_power_active_entity:
self._all_boiler_entities.append(self._total_power_active_entity.entity_id)
old_ready = self._is_ready
self._is_ready = self.is_configured and len(self._all_boiler_entities) == 4 and self._central_boiler_entity is not None
if not self._is_ready and self.is_configured:
_LOGGER.warning(
"%s - central boiler manager is not fully configured. Found only %d/4 entities and central boiler entity=%s. Central boiler control will not work properly. This could a temporary message at startup.",
self,
len(self._all_boiler_entities),
self._central_boiler_entity,
)
return []
if not self._is_ready and not self.is_configured:
# Silence warning if the feature is not configured (user doesn't want it)
_LOGGER.debug(
"%s - central boiler manager is not configured. Ignoring initialization.",
self,
)
return []
if self._is_ready != old_ready and self._central_boiler_entity:
# Notify the central boiler entity that the manager is now ready
self.hass.create_task(self.refresh_central_boiler_custom_attributes())
return self._all_boiler_entities
async def refresh_central_boiler_custom_attributes(self):
"""Refresh the custom attributes of the central boiler entity"""
if self._central_boiler_entity:
self._central_boiler_entity.refresh_custom_attributes()
@property
def nb_device_active_for_boiler_entity(self):
"""Returns the entity if the sensor which gives the number of active VTherm which have an
influence on boiler"""
return self._nb_active_device_number_entity
# For testing purpose
def _set_nb_active_device_threshold(self, value: int) -> None:
"""Set the number of active device threshold"""
if self._nb_active_device_threshold_number_entity:
self._nb_active_device_threshold_number_entity.set_native_value(value)
def _set_total_power_active_threshold(self, value: int) -> None:
"""Set the total power of active device threshold"""
if self._total_power_active_threshold_number_entity:
self._total_power_active_threshold_number_entity.set_native_value(value)
def __str__(self):
return "FeatureCentralBoilerManager"
@@ -0,0 +1,362 @@
""" Implements a central Power Feature Manager for Versatile Thermostat """
import logging
from vtherm_api.log_collector import get_vtherm_logger
from typing import Any
from functools import cmp_to_key
from datetime import timedelta
from homeassistant.const import STATE_OFF
from homeassistant.core import HomeAssistant, Event, callback
from homeassistant.helpers.event import (
async_track_state_change_event,
EventStateChangedData,
async_call_later,
)
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.components.climate import (
ClimateEntity,
DOMAIN as CLIMATE_DOMAIN,
)
from .const import * # pylint: disable=wildcard-import, unused-wildcard-import
from .commons import write_event_log
from .commons_type import ConfigData
from .base_manager import BaseFeatureManager
# circular dependency
# from .base_thermostat import BaseThermostat
MIN_DTEMP_SECS = 20
_LOGGER = get_vtherm_logger(__name__)
class FeatureCentralPowerManager(BaseFeatureManager):
"""A central Power feature manager"""
def __init__(self, hass: HomeAssistant, vtherm_api: Any):
"""Init of a featureManager"""
super().__init__(None, hass, "centralPowerManager")
self._hass: HomeAssistant = hass
self._vtherm_api = vtherm_api # no type due to circular reference
self._is_configured: bool = False
self._power_sensor_entity_id: str | None = None
self._max_power_sensor_entity_id: str | None = None
self._current_power: float | None = None
self._current_max_power: float | None = None
self._power_temp: float | None = None
self._cancel_calculate_shedding_call = None
self._started_vtherm_total_power_by_id: dict[str, float] = {}
# Not used now
self._last_shedding_date = None
self._state = False
def post_init(self, entry_infos: ConfigData):
"""Gets the configuration parameters"""
central_config = self._vtherm_api.find_central_configuration()
if not central_config:
_LOGGER.info("%s - No central configuration is found. Power management will be deactivated", self)
return
self._power_sensor_entity_id = entry_infos.get(CONF_POWER_SENSOR)
self._max_power_sensor_entity_id = entry_infos.get(CONF_MAX_POWER_SENSOR)
self._power_temp = entry_infos.get(CONF_PRESET_POWER)
self._is_configured = False
self._current_power = None
self._current_max_power = None
if (
entry_infos.get(CONF_USE_POWER_FEATURE, False)
and self._max_power_sensor_entity_id
and self._power_sensor_entity_id
and self._power_temp
):
self._is_configured = True
self._started_vtherm_total_power_by_id = {}
else:
_LOGGER.info("%s - Power management is not fully configured and will be deactivated", self)
async def start_listening(self):
"""Start listening the power sensor"""
if not self._is_configured:
return
self.stop_listening()
self.add_listener(
async_track_state_change_event(
self.hass,
[self._power_sensor_entity_id],
self._power_sensor_changed,
)
)
self.add_listener(
async_track_state_change_event(
self.hass,
[self._max_power_sensor_entity_id],
self._max_power_sensor_changed,
)
)
@callback
async def _power_sensor_changed(self, event: Event[EventStateChangedData]):
"""Handle power changes."""
write_event_log(_LOGGER, self, f"Receive power sensor state {event.data.get('new_state').state if event.data.get('new_state') else None}")
self._started_vtherm_total_power_by_id = {}
await self.refresh_state()
@callback
async def _max_power_sensor_changed(self, event: Event[EventStateChangedData]):
"""Handle power max changes."""
write_event_log(_LOGGER, self, f"Receive max power sensor state {event.data.get('new_state').state if event.data.get('new_state') else None}")
await self.refresh_state()
@overrides
async def refresh_state(self) -> bool:
"""Tries to get the last state from sensor
Returns True if a change has been made"""
async def _calculate_shedding_internal(_):
_LOGGER.debug("%s - Do the shedding calculation", self)
await self.calculate_shedding()
if self._cancel_calculate_shedding_call:
self._cancel_calculate_shedding_call()
self._cancel_calculate_shedding_call = None
if not self._is_configured:
return False
# Retrieve current power
new_power = get_safe_float(self._hass, self._power_sensor_entity_id)
power_changed = new_power is not None and self._current_power != new_power
if power_changed:
self._current_power = new_power
_LOGGER.debug("%s - New current power has been retrieved: %.3f", self, self._current_power)
# Retrieve max power
new_max_power = get_safe_float(self._hass, self._max_power_sensor_entity_id)
max_power_changed = new_max_power is not None and self._current_max_power != new_max_power
if max_power_changed:
self._current_max_power = new_max_power
_LOGGER.debug("%s - New current max power has been retrieved: %.3f", self, self._current_max_power)
# Schedule shedding calculation if there's any change
if power_changed or max_power_changed:
if not self._cancel_calculate_shedding_call:
self._cancel_calculate_shedding_call = async_call_later(self.hass, timedelta(seconds=MIN_DTEMP_SECS), _calculate_shedding_internal)
return True
return False
# For testing purpose only, do an immediate shedding calculation
async def _do_immediate_shedding(self):
"""Do an immmediate shedding calculation if a timer was programmed.
Else, do nothing"""
if self._cancel_calculate_shedding_call:
self._cancel_calculate_shedding_call()
self._cancel_calculate_shedding_call = None
await self.calculate_shedding()
async def calculate_shedding(self):
"""Do the shedding calculation and set/unset VTherm into overpowering state"""
if not self.is_configured or self.current_max_power is None or self.current_power is None:
return
changed_vtherm = []
_LOGGER.debug("%s - -------- Start of calculate_shedding", self)
# Find all VTherms
available_power = self.current_max_power - self.current_power
vtherms_sorted = self.find_all_vtherm_with_power_management_sorted_by_dtemp()
# shedding only
if available_power < 0:
_LOGGER.debug(
"%s - The available power is is < 0 (%s). Set overpowering only for list: %s",
self,
available_power,
vtherms_sorted,
)
# we will set overpowering for the nearest target temp first
total_power_gain = 0
for vtherm in vtherms_sorted:
if vtherm.is_device_active and not vtherm.power_manager.is_overpowering_detected:
device_power = vtherm.power_manager.device_power
total_power_gain += device_power
_LOGGER.info("vtherm %s should be in overpowering state (device_power=%.2f)", vtherm.name, device_power)
await vtherm.power_manager.set_overpowering(True, device_power)
changed_vtherm.append(vtherm)
_LOGGER.debug("%s - after vtherm %s total_power_gain=%s, available_power=%s", self, vtherm.name, total_power_gain, available_power)
if total_power_gain >= -available_power:
_LOGGER.debug("%s - We have found enough vtherm to set to overpowering", self)
break
# unshedding only
else:
vtherms_sorted.reverse()
_LOGGER.debug("%s - The available power is is > 0 (%s). Do a complete shedding/un-shedding calculation for list: %s", self, available_power, vtherms_sorted)
total_power_added = 0
for vtherm in vtherms_sorted:
# We want to do always unshedding in order to initialize the state
# so we cannot use is_overpowering_detected which test also UNKNOWN and UNAVAILABLE
if vtherm.power_manager.overpowering_state == STATE_OFF:
continue
power_consumption_max = device_power = vtherm.power_manager.device_power
# calculate the power_consumption_max
if vtherm.on_percent is not None:
power_consumption_max = max(
device_power / vtherm.nb_underlying_entities,
device_power * vtherm.on_percent,
)
_LOGGER.debug(
"%s - vtherm %s power_consumption_max is %s (device_power=%s, overclimate=%s)", self, vtherm.name, power_consumption_max, device_power, vtherm.is_over_climate
)
# or not ... is for initializing the overpowering state if not already done
if total_power_added + power_consumption_max < available_power or not vtherm.power_manager.is_overpowering_detected:
# we count the unshedding only if the VTherm was in shedding
if vtherm.power_manager.is_overpowering_detected:
_LOGGER.info("%s - vtherm %s should not be in overpowering state (power_consumption_max=%.2f)", self, vtherm.name, power_consumption_max)
total_power_added += power_consumption_max
await vtherm.power_manager.set_overpowering(False)
changed_vtherm.append(vtherm)
if total_power_added >= available_power:
_LOGGER.debug("%s - We have found enough vtherm to set to non-overpowering", self)
break
_LOGGER.debug("%s - after vtherm %s total_power_added=%s, available_power=%s", self, vtherm.name, total_power_added, available_power)
# We have set the evenual new state, fr
for vtherm in changed_vtherm:
vtherm.requested_state.force_changed()
await vtherm.update_states(force=True)
self._last_shedding_date = self._vtherm_api.now
# calculate a state as true if one of the VTherm is in shedding
self._state = any(vtherm.power_manager.is_overpowering_detected for vtherm in vtherms_sorted)
_LOGGER.debug("%s - -------- End of calculate_shedding", self)
def get_climate_components_entities(self) -> list:
"""Get all VTherms entitites"""
vtherms = []
component: EntityComponent[ClimateEntity] = self._hass.data.get(
CLIMATE_DOMAIN, None
)
if component:
for entity in list(component.entities):
# A little hack to test if the climate is a VTherm. Cannot use isinstance
# due to circular dependency of BaseThermostat
if (
entity.device_info
and entity.device_info.get("model", None) == DOMAIN
):
vtherms.append(entity)
return vtherms
def find_all_vtherm_with_power_management_sorted_by_dtemp(
self,
) -> list:
"""Returns all the VTherms with power management activated"""
entities = self.get_climate_components_entities()
vtherms = [
vtherm
for vtherm in entities
if vtherm.power_manager.is_configured and vtherm.is_on
]
# sort the result with the min temp difference first. A and B should be BaseThermostat class
def cmp_temps(a, b) -> int:
diff_a = float("inf")
diff_b = float("inf")
a_target = a.target_temperature if not a.power_manager.is_overpowering_detected else a.requested_state.target_temperature
b_target = b.target_temperature if not b.power_manager.is_overpowering_detected else b.requested_state.target_temperature
if a.current_temperature is not None and a_target is not None:
diff_a = a_target - a.current_temperature
if b.current_temperature is not None and b_target is not None:
diff_b = b_target - b.current_temperature
if diff_a == diff_b:
return 0
return 1 if diff_a > diff_b else -1
vtherms.sort(key=cmp_to_key(cmp_temps))
return vtherms
def get_started_vtherm_power(self, reservation_key: str) -> float:
"""Return the reserved started power for a given underlying key."""
return self._started_vtherm_total_power_by_id.get(reservation_key, 0.0)
def set_started_vtherm_power(self, reservation_key: str, started_power: float):
"""Set the temporary reserved power for a given underlying key.
This reservation is used between two power sensor measurements to avoid
allowing several underlyings to start simultaneously based on the same
stale sensor reading.
"""
if started_power > 0:
self._started_vtherm_total_power_by_id[reservation_key] = started_power
else:
self._started_vtherm_total_power_by_id.pop(reservation_key, None)
_LOGGER.debug(
"%s - started_vtherm_total_power is now %s (%s)",
self,
self.started_vtherm_total_power,
self._started_vtherm_total_power_by_id,
)
@property
def is_configured(self) -> bool:
"""True if the FeatureManager is fully configured"""
return self._is_configured
@property
def current_power(self) -> float | None:
"""Return the current power from sensor"""
return self._current_power
@property
def current_max_power(self) -> float | None:
"""Return the current power from sensor"""
return self._current_max_power
@property
def power_temperature(self) -> float | None:
"""Return the power temperature"""
return self._power_temp
@property
def power_sensor_entity_id(self) -> float | None:
"""Return the power sensor entity id"""
return self._power_sensor_entity_id
@property
def max_power_sensor_entity_id(self) -> float | None:
"""Return the max power sensor entity id"""
return self._max_power_sensor_entity_id
@property
def started_vtherm_total_power(self) -> float | None:
"""Return the started_vtherm_total_power"""
return sum(self._started_vtherm_total_power_by_id.values())
@property
def is_detected(self) -> bool:
"""True if the central power management is detected"""
return self._state
def __str__(self):
return "CentralPowerManager"
@@ -0,0 +1,615 @@
# pylint: disable=line-too-long
""" Implements the Heating Failure Detection as a Feature Manager"""
import logging
from vtherm_api.log_collector import get_vtherm_logger
from typing import Any
from datetime import datetime, timedelta
from homeassistant.const import (
STATE_ON,
STATE_OFF,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.template import Template
from .const import (
CONF_USE_HEATING_FAILURE_DETECTION_FEATURE,
CONF_HEATING_FAILURE_THRESHOLD,
CONF_COOLING_FAILURE_THRESHOLD,
CONF_HEATING_FAILURE_DETECTION_DELAY,
CONF_TEMPERATURE_CHANGE_TOLERANCE,
CONF_FAILURE_DETECTION_ENABLE_TEMPLATE,
DEFAULT_HEATING_FAILURE_THRESHOLD,
DEFAULT_COOLING_FAILURE_THRESHOLD,
DEFAULT_HEATING_FAILURE_DETECTION_DELAY,
DEFAULT_TEMPERATURE_CHANGE_TOLERANCE,
EventType,
overrides,
)
from .commons import write_event_log
from .commons_type import ConfigData
from .base_manager import BaseFeatureManager
from .vtherm_hvac_mode import VThermHvacMode_OFF, VThermHvacMode_HEAT
_LOGGER = get_vtherm_logger(__name__)
class FeatureHeatingFailureDetectionManager(BaseFeatureManager):
"""The implementation of the Heating Failure Detection feature
This feature detects:
1. Heating failure: high on_percent but temperature not increasing
2. Cooling failure: on_percent at 0 but temperature still increasing
When a failure is detected on a thermostat with valve underlyings,
root cause analysis is performed by comparing each underlying's
should_device_be_active (commanded state) vs is_device_active (real state)
to determine if the failure is caused by a stuck valve.
"""
unrecorded_attributes = frozenset(
{
"heating_failure_threshold",
"cooling_failure_threshold",
"heating_failure_detection_delay",
"temperature_change_tolerance",
"is_heating_failure_detection_configured",
"failure_detection_enable_template",
}
)
def __init__(self, vtherm: Any, hass: HomeAssistant):
"""Init of a featureManager"""
super().__init__(vtherm, hass)
self._is_configured: bool = False
self._heating_failure_threshold: float = DEFAULT_HEATING_FAILURE_THRESHOLD
self._cooling_failure_threshold: float = DEFAULT_COOLING_FAILURE_THRESHOLD
self._heating_failure_detection_delay: int = DEFAULT_HEATING_FAILURE_DETECTION_DELAY
self._temperature_change_tolerance: float = DEFAULT_TEMPERATURE_CHANGE_TOLERANCE
self._failure_detection_enable_template: Template | None = None
# State tracking
self._heating_failure_state: str = STATE_UNAVAILABLE
self._cooling_failure_state: str = STATE_UNAVAILABLE
# Temperature tracking for failure detection
self._last_check_time: datetime | None = None
self._last_check_temperature: float | None = None
self._high_power_start_time: datetime | None = None
self._zero_power_start_time: datetime | None = None
@overrides
def post_init(self, entry_infos: ConfigData):
"""Reinit of the manager"""
use_feature = entry_infos.get(CONF_USE_HEATING_FAILURE_DETECTION_FEATURE, False)
if not use_feature:
self._is_configured = False
self._heating_failure_state = STATE_UNAVAILABLE
self._cooling_failure_state = STATE_UNAVAILABLE
return
self._heating_failure_threshold = entry_infos.get(
CONF_HEATING_FAILURE_THRESHOLD,
DEFAULT_HEATING_FAILURE_THRESHOLD
)
self._cooling_failure_threshold = entry_infos.get(
CONF_COOLING_FAILURE_THRESHOLD,
DEFAULT_COOLING_FAILURE_THRESHOLD
)
self._heating_failure_detection_delay = entry_infos.get(
CONF_HEATING_FAILURE_DETECTION_DELAY,
DEFAULT_HEATING_FAILURE_DETECTION_DELAY
)
self._temperature_change_tolerance = entry_infos.get(CONF_TEMPERATURE_CHANGE_TOLERANCE, DEFAULT_TEMPERATURE_CHANGE_TOLERANCE)
# Initialize the enable template if provided
template_str = entry_infos.get(CONF_FAILURE_DETECTION_ENABLE_TEMPLATE)
if template_str:
self._failure_detection_enable_template = Template(template_str, self._hass)
else:
self._failure_detection_enable_template = None
self._is_configured = True
self._heating_failure_state = STATE_UNKNOWN
self._cooling_failure_state = STATE_UNKNOWN
@overrides
async def start_listening(self):
"""Start listening the underlying entity"""
# No external entity to listen to for this feature
@overrides
def stop_listening(self):
"""Stop listening and remove the eventual timer still running"""
def _is_detection_enabled_by_template(self) -> bool:
"""Evaluate the enable template to determine if detection should be active.
Returns True if detection is enabled (template returns True or no template configured).
Returns False if template returns False.
If template evaluation fails, returns True (detection enabled) as a safety measure.
"""
if self._failure_detection_enable_template is None:
# No template configured, detection is always enabled
return True
try:
result = self._failure_detection_enable_template.async_render()
# Handle various truthy/falsy values
if isinstance(result, bool):
return result
if isinstance(result, str):
return result.lower() in ("true", "1", "yes", "on")
return bool(result)
except Exception as err: # pylint: disable=broad-except
_LOGGER.warning(
"%s - Error evaluating failure_detection_enable_template: %s. Detection remains enabled.",
self, err
)
# On error, enable detection as a safety measure
return True
@overrides
async def refresh_state(self) -> bool:
"""Check for heating/cooling failures
Return True if a failure is detected"""
if not self._is_configured:
_LOGGER.debug("%s - heating failure detection is disabled (or not configured)", self)
return False
# Only check for VTherms with proportional algorithm
if not self._vtherm.has_prop:
_LOGGER.debug("%s - heating failure detection skipped (no proportional algorithm)", self)
return False
if self._vtherm.requested_state.hvac_mode == VThermHvacMode_OFF:
self._reset_tracking()
self._heating_failure_state = STATE_OFF
self._cooling_failure_state = STATE_OFF
_LOGGER.debug("%s - heating failure detection is OFF because requested_state is OFF", self)
return False
# Check if detection is enabled by template
if not self._is_detection_enabled_by_template():
_LOGGER.debug("%s - heating failure detection is disabled by template", self)
# Reset tracking when template disables detection
self._reset_tracking()
# Keep states at OFF when disabled by template (not UNAVAILABLE since feature is configured)
if self._heating_failure_state == STATE_ON:
self._heating_failure_state = STATE_OFF
self._send_heating_failure_event("heating_failure_end", 0, 0, self._vtherm.current_temperature or 0)
if self._cooling_failure_state == STATE_ON:
self._cooling_failure_state = STATE_OFF
self._send_cooling_failure_event("cooling_failure_end", 0, 0, self._vtherm.current_temperature or 0)
return False
now = self._vtherm.now
# Get current values
current_temp = self._vtherm.current_temperature
on_percent = self._vtherm.on_percent
if current_temp is None or on_percent is None:
_LOGGER.debug("%s - heating failure detection skipped (no temp or on_percent)", self)
return False
# Determine the mode (heating or cooling)
is_heating_mode = self._vtherm.vtherm_hvac_mode == VThermHvacMode_HEAT
# Initialize tracking if needed
if self._last_check_time is None:
self._last_check_time = now
self._last_check_temperature = current_temp
return False
detection_delay_td = timedelta(minutes=self._heating_failure_detection_delay)
# Check for heating and cooling failures
if is_heating_mode:
self._check_heating_failure(now, current_temp, on_percent, detection_delay_td)
self._check_cooling_failure(now, current_temp, on_percent, detection_delay_td)
# Initialize states if still unknown
if self._heating_failure_state == STATE_UNKNOWN:
self._heating_failure_state = STATE_OFF
if self._cooling_failure_state == STATE_UNKNOWN:
self._cooling_failure_state = STATE_OFF
return self.is_failure_detected
def _check_heating_failure(self, now: datetime, current_temp: float, on_percent: float, detection_delay_td: timedelta):
"""Check for HEATING failure:
High on_percent (>= threshold) but temperature not increasing"""
old_heating_failure = self._heating_failure_state == STATE_ON
if on_percent >= self._heating_failure_threshold:
if self._high_power_start_time is None:
self._high_power_start_time = now
self._last_check_temperature = current_temp
_LOGGER.debug(
"%s - Starting high power tracking (on_percent=%.2f >= threshold=%.2f)",
self, on_percent, self._heating_failure_threshold
)
else:
elapsed = now - self._high_power_start_time
if elapsed >= detection_delay_td:
# Check if temperature has increased enough (above tolerance)
if self._last_check_temperature is not None:
temp_diff = current_temp - self._last_check_temperature
if temp_diff < self._temperature_change_tolerance:
# Temperature not increasing enough - heating failure detected
if not old_heating_failure:
_LOGGER.warning(
"%s - Heating failure detected: on_percent=%.2f%%, temp_diff=%.2f° (tolerance=%.2f°) over %d minutes",
self,
on_percent * 100,
temp_diff,
self._temperature_change_tolerance,
self._heating_failure_detection_delay,
)
self._heating_failure_state = STATE_ON
self._send_heating_failure_event("heating_failure_start", on_percent, temp_diff, current_temp)
else:
# Temperature is increasing enough, reset if we were in failure
if old_heating_failure:
_LOGGER.info("%s - Heating failure ended: temperature is now increasing", self)
self._heating_failure_state = STATE_OFF
self._send_heating_failure_event("heating_failure_end", on_percent, temp_diff, current_temp)
self._high_power_start_time = now
self._last_check_temperature = current_temp
else:
# Not in high power, reset tracking
if self._high_power_start_time is not None:
self._high_power_start_time = None
if old_heating_failure:
self._heating_failure_state = STATE_OFF
self._send_heating_failure_event("heating_failure_end", on_percent, 0, current_temp)
def _check_cooling_failure(self, now: datetime, current_temp: float, on_percent: float, detection_delay_td: timedelta):
"""Check for COOLING failure:
on_percent at 0 (or <= cooling threshold) but temperature still increasing"""
old_cooling_failure = self._cooling_failure_state == STATE_ON
if on_percent <= self._cooling_failure_threshold:
if self._zero_power_start_time is None:
self._zero_power_start_time = now
self._last_check_temperature = current_temp
_LOGGER.debug(
"%s - Starting zero power tracking (on_percent=%.2f <= threshold=%.2f)",
self, on_percent, self._cooling_failure_threshold
)
else:
elapsed = now - self._zero_power_start_time
if elapsed >= detection_delay_td:
# Check if temperature is increasing above tolerance
if self._last_check_temperature is not None:
temp_diff = current_temp - self._last_check_temperature
if temp_diff > self._temperature_change_tolerance:
# Temperature still increasing despite no heating - cooling failure
if not old_cooling_failure:
_LOGGER.warning(
"%s - Cooling failure detected: on_percent=%.2f%%, temp_diff=+%.2f° (tolerance=%.2f°) over %d minutes",
self,
on_percent * 100,
temp_diff,
self._temperature_change_tolerance,
self._heating_failure_detection_delay,
)
self._cooling_failure_state = STATE_ON
self._send_cooling_failure_event("cooling_failure_start", on_percent, temp_diff, current_temp)
else:
# Temperature is stable or decreasing, reset if we were in failure
if old_cooling_failure:
_LOGGER.info("%s - Cooling failure ended: temperature is now stable or decreasing", self)
self._cooling_failure_state = STATE_OFF
self._send_cooling_failure_event("cooling_failure_end", on_percent, temp_diff, current_temp)
self._zero_power_start_time = now
self._last_check_temperature = current_temp
else:
# Not at zero power, reset tracking
if self._zero_power_start_time is not None:
self._zero_power_start_time = None
if old_cooling_failure:
self._cooling_failure_state = STATE_OFF
self._send_cooling_failure_event("cooling_failure_end", on_percent, 0, current_temp)
def _reset_tracking(self):
"""Reset all tracking variables"""
self._last_check_time = None
self._last_check_temperature = None
self._high_power_start_time = None
self._zero_power_start_time = None
def _diagnose_root_cause(self, failure_type: str) -> dict[str, Any]:
"""Diagnose the root cause of a failure by checking valve underlyings.
For thermostats with valve underlyings (over_valve or over_climate_valve),
compares the requested state (should_device_be_active) with the real state
(is_device_active) to determine if a stuck valve is the cause.
Args:
failure_type: "heating" or "cooling"
Returns:
A dict with root_cause, root_cause_entity_id, and root_cause_details
"""
result = {
"root_cause": "not_identified",
"root_cause_entity_id": None,
"root_cause_details": [],
}
if not hasattr(self._vtherm, '_underlyings'):
return result
from .underlyings import UnderlyingValve
stuck_valves = []
for under in self._vtherm._underlyings:
if not isinstance(under, UnderlyingValve):
continue
should_active = under.should_device_be_active
is_active = under.is_device_active
if should_active is None or is_active is None:
continue
if should_active and not is_active:
stuck_valves.append({
"entity_id": under.entity_id,
"type": "valve_stuck_closed",
"requested": "open",
"actual": "closed",
})
elif not should_active and is_active:
stuck_valves.append({
"entity_id": under.entity_id,
"type": "valve_stuck_open",
"requested": "closed",
"actual": "open",
})
if stuck_valves:
if failure_type == "heating":
valve_stuck_type = "valve_stuck_closed"
else:
valve_stuck_type = "valve_stuck_open"
matching = [v for v in stuck_valves if v["type"] == valve_stuck_type]
if matching:
result["root_cause"] = valve_stuck_type
result["root_cause_entity_id"] = matching[0]["entity_id"]
result["root_cause_details"] = matching
else:
result["root_cause"] = stuck_valves[0]["type"]
result["root_cause_entity_id"] = stuck_valves[0]["entity_id"]
result["root_cause_details"] = stuck_valves
return result
def _send_heating_failure_event(self, event_type: str, on_percent: float, temp_diff: float, current_temp: float):
"""Send a heating failure event"""
is_enabled_by_template = self._is_detection_enabled_by_template()
root_cause_info = self._diagnose_root_cause("heating") if event_type == "heating_failure_start" else {
"root_cause": "not_identified",
"root_cause_entity_id": None,
"root_cause_details": [],
}
# Log the event
if event_type == "heating_failure_start":
write_event_log(
_LOGGER, self._vtherm,
f"Heating failure detected: on_percent={on_percent*100:.0f}%, temp_diff={temp_diff:.2f}°, root_cause={root_cause_info['root_cause']}"
)
else:
write_event_log(
_LOGGER, self._vtherm, f"Heating failure ended: on_percent={on_percent*100:.0f}%, temp_diff={temp_diff:.2f}°, template_enabled={is_enabled_by_template}"
)
self._vtherm.send_event(
EventType.HEATING_FAILURE_EVENT,
{
"type": event_type,
"failure_type": "heating",
"on_percent": on_percent,
"temperature_difference": temp_diff,
"current_temp": current_temp,
"target_temp": self._vtherm.target_temperature,
"threshold": self._heating_failure_threshold,
"detection_delay_min": self._heating_failure_detection_delay,
"is_enabled_by_template": is_enabled_by_template,
"root_cause": root_cause_info["root_cause"],
"root_cause_entity_id": root_cause_info["root_cause_entity_id"],
"root_cause_details": root_cause_info["root_cause_details"],
},
)
def _send_cooling_failure_event(self, event_type: str, on_percent: float, temp_diff: float, current_temp: float):
"""Send a cooling failure event"""
is_enabled_by_template = self._is_detection_enabled_by_template()
root_cause_info = self._diagnose_root_cause("cooling") if event_type == "cooling_failure_start" else {
"root_cause": "not_identified",
"root_cause_entity_id": None,
"root_cause_details": [],
}
# Log the event
if event_type == "cooling_failure_start":
write_event_log(
_LOGGER, self._vtherm,
f"Cooling failure detected: on_percent={on_percent*100:.0f}%, temp_diff=+{temp_diff:.2f}°, root_cause={root_cause_info['root_cause']}"
)
else:
write_event_log(
_LOGGER, self._vtherm, f"Cooling failure ended: on_percent={on_percent*100:.0f}%, temp_diff={temp_diff:.2f}°, template_enabled={is_enabled_by_template}"
)
self._vtherm.send_event(
EventType.HEATING_FAILURE_EVENT,
{
"type": event_type,
"failure_type": "cooling",
"on_percent": on_percent,
"temperature_difference": temp_diff,
"current_temp": current_temp,
"target_temp": self._vtherm.target_temperature,
"threshold": self._cooling_failure_threshold,
"detection_delay_min": self._heating_failure_detection_delay,
"is_enabled_by_template": is_enabled_by_template,
"root_cause": root_cause_info["root_cause"],
"root_cause_entity_id": root_cause_info["root_cause_entity_id"],
"root_cause_details": root_cause_info["root_cause_details"],
},
)
def add_custom_attributes(self, extra_state_attributes: dict[str, Any]):
"""Add some custom attributes"""
extra_state_attributes.update(
{
"is_heating_failure_detection_configured": self._is_configured,
}
)
if self._is_configured:
# Calculate remaining time for heating failure detection
heating_tracking_info = self._get_tracking_info(self._high_power_start_time, "heating")
# Calculate remaining time for cooling failure detection
cooling_tracking_info = self._get_tracking_info(self._zero_power_start_time, "cooling")
# Get template status
template_str = None
template_enabled = True
if self._failure_detection_enable_template is not None:
template_str = self._failure_detection_enable_template.template
template_enabled = self._is_detection_enabled_by_template()
root_cause_info = {}
if self._heating_failure_state == STATE_ON:
root_cause_info = self._diagnose_root_cause("heating")
elif self._cooling_failure_state == STATE_ON:
root_cause_info = self._diagnose_root_cause("cooling")
extra_state_attributes.update(
{
"heating_failure_detection_manager": {
"heating_failure_state": self._heating_failure_state,
"cooling_failure_state": self._cooling_failure_state,
"heating_failure_threshold": self._heating_failure_threshold,
"cooling_failure_threshold": self._cooling_failure_threshold,
"detection_delay_min": self._heating_failure_detection_delay,
"temperature_change_tolerance": self._temperature_change_tolerance,
"failure_detection_enable_template": template_str,
"is_detection_enabled_by_template": template_enabled,
"heating_tracking": heating_tracking_info,
"cooling_tracking": cooling_tracking_info,
"root_cause": root_cause_info.get("root_cause"),
"root_cause_entity_id": root_cause_info.get("root_cause_entity_id"),
}
}
)
def _get_tracking_info(self, start_time: datetime | None, tracking_type: str) -> dict[str, Any]:
"""Get tracking information for heating or cooling failure detection
Args:
start_time: The start time of the tracking (high_power or zero_power)
tracking_type: "heating" or "cooling" for logging purposes
Returns:
A dictionary with tracking status information
"""
if start_time is None:
return {
"is_tracking": False,
"initial_temperature": None,
"current_temperature": None,
"remaining_time_min": None,
"elapsed_time_min": None,
}
now = self._vtherm.now
elapsed = now - start_time
elapsed_minutes = elapsed.total_seconds() / 60
remaining_minutes = max(0, self._heating_failure_detection_delay - elapsed_minutes)
return {
"is_tracking": True,
"initial_temperature": self._last_check_temperature,
"current_temperature": self._vtherm.current_temperature,
"remaining_time_min": round(remaining_minutes, 1),
"elapsed_time_min": round(elapsed_minutes, 1),
}
@overrides
@property
def is_configured(self) -> bool:
"""Return True if the heating failure detection feature is configured"""
return self._is_configured
@property
def is_failure_detected(self) -> bool:
"""Returns True if any failure is currently detected"""
return self._heating_failure_state == STATE_ON or self._cooling_failure_state == STATE_ON
@property
def is_detected(self) -> bool:
"""Return the overall state of the feature manager based on failure states"""
return self.is_failure_detected
@property
def is_heating_failure_detected(self) -> bool:
"""Returns True if a heating failure is detected"""
return self._heating_failure_state == STATE_ON
@property
def is_cooling_failure_detected(self) -> bool:
"""Returns True if a cooling failure is detected"""
return self._cooling_failure_state == STATE_ON
@property
def heating_failure_state(self) -> str:
"""Returns the heating failure state: STATE_ON, STATE_OFF, STATE_UNKNOWN, STATE_UNAVAILABLE"""
return self._heating_failure_state
@property
def cooling_failure_state(self) -> str:
"""Returns the cooling failure state: STATE_ON, STATE_OFF, STATE_UNKNOWN, STATE_UNAVAILABLE"""
return self._cooling_failure_state
@property
def heating_failure_threshold(self) -> float:
"""Returns the heating failure threshold"""
return self._heating_failure_threshold
@property
def cooling_failure_threshold(self) -> float:
"""Returns the cooling failure threshold"""
return self._cooling_failure_threshold
@property
def detection_delay_min(self) -> int:
"""Returns the detection delay in minutes"""
return self._heating_failure_detection_delay
@property
def temperature_change_tolerance(self) -> float:
"""Returns the temperature change tolerance in degrees"""
return self._temperature_change_tolerance
def __str__(self):
return f"HeatingFailureDetectionManager-{self.name}"
@@ -0,0 +1,152 @@
""" This module manages the lock feature of the Versatile Thermostat integration. """
import logging
from datetime import timedelta
from vtherm_api.log_collector import get_vtherm_logger
from typing import Any
from homeassistant.core import (
HomeAssistant,
callback,
)
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.event import async_call_later
from .const import * # pylint: disable=wildcard-import, unused-wildcard-import
from .commons_type import ConfigData
from .base_manager import BaseFeatureManager
_LOGGER = get_vtherm_logger(__name__)
class FeatureLockManager(BaseFeatureManager):
""" The implementation of the Lock Feature Manager for Versatile Thermostat """
def __init__(self, vtherm: Any, hass: HomeAssistant):
"""Initialize the FeatureLockManager."""
super().__init__(vtherm, hass)
self._is_configured: bool = False
self._lock_users: bool = True
self._lock_automations: bool = True
self._lock_code: str | None = None
self._is_locked: bool = False
self._auto_relock_sec: int = 30
self._cancel_auto_relock = None
@overrides
def post_init(self, entry_infos: ConfigData):
"""Reinit of the manager"""
self._lock_users = entry_infos.get(CONF_LOCK_USERS, True)
self._lock_automations = entry_infos.get(CONF_LOCK_AUTOMATIONS, True)
self._lock_code = entry_infos.get(CONF_LOCK_CODE)
self._auto_relock_sec = int(entry_infos.get(CONF_AUTO_RELOCK_SEC, 30) or 0)
self._is_configured = self._lock_users or self._lock_automations
@overrides
def restore_state(self, old_state) -> None:
"""Restore locks from old state."""
if old_state is not None:
self._is_locked = bool(old_state.attributes.get("specific_states", {}).get("is_locked", False))
@overrides
async def start_listening(self):
"""Nothing to listen here"""
@property
def is_configured(self) -> bool:
"""True if the FeatureManager is fully configured"""
return self._is_configured
@property
def is_locked(self) -> bool:
"""Return True if the thermostat is locked."""
return self._is_locked
@property
def is_detected(self) -> bool:
"""Return the overall state of the feature manager based on failure states"""
return self.is_locked
def check_is_locked(self, function_name: str) -> bool:
"""Check if the thermostat is locked."""
context = getattr(self._vtherm, "_context", None)
source_is_user = context and context.user_id is not None
source_is_automation = not source_is_user
if self._is_locked and (
(self._lock_users and source_is_user)
or (self._lock_automations and source_is_automation)
):
_LOGGER.info(
"%s - Blocked external call to %s while locked (source=%s)",
self,
function_name,
"user" if source_is_user else "automation/unknown",
)
return True
return False
def _validate_lock_code(self, code: str | None) -> bool:
"""Validate the provided code against the configured lock code."""
if self._lock_code:
if not code or str(code) != str(self._lock_code):
_LOGGER.error("%s - Lock code validation failed", self)
raise HomeAssistantError(f"Lock code validation failed: {code}")
return True
def change_lock_state(self, locked: bool, code: str | None = None) -> None:
"""Set the internal lock state."""
if self._validate_lock_code(code):
if self._cancel_auto_relock:
self._cancel_auto_relock()
self._cancel_auto_relock = None
self._is_locked = locked
_LOGGER.info("%s - Lock state set to %s", self, locked)
if not locked and self._auto_relock_sec > 0:
self._cancel_auto_relock = async_call_later(
self.hass,
timedelta(seconds=self._auto_relock_sec),
self._do_auto_relock,
)
_LOGGER.info(
"%s - Auto-relock scheduled in %s seconds",
self,
self._auto_relock_sec,
)
return True
return False
@callback
def _do_auto_relock(self, _now) -> None:
"""Callback triggered by the auto-relock timer."""
self._cancel_auto_relock = None
self._is_locked = True
_LOGGER.info(
"%s - Auto-relock triggered after %s seconds", self, self._auto_relock_sec
)
self._vtherm.update_custom_attributes()
self._vtherm.async_write_ha_state()
@property
def has_lock_settings_enabled(self) -> bool:
"""Return True if any lock setting is enabled."""
return self._is_configured and (self._lock_users or self._lock_automations)
def add_custom_attributes(self, extra_state_attributes: dict[str, Any]) -> None:
"""Add custom attributes to the attributes dict."""
extra_state_attributes.update(
{
"is_lock_configured": self._is_configured,
}
)
if self._is_configured:
extra_state_attributes.update(
{
"lock_manager": {
"is_locked": self._is_locked,
"lock_users": self._lock_users,
"lock_automations": self._lock_automations,
"lock_code": bool(self._lock_code),
"auto_relock_sec": self._auto_relock_sec,
}
})
@@ -0,0 +1,340 @@
""" Implements the Motion Feature Manager """
# pylint: disable=line-too-long
from typing import Any
from datetime import timedelta
from homeassistant.const import (
STATE_ON,
STATE_OFF,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import (
HomeAssistant,
callback,
Event,
)
from homeassistant.helpers.event import (
async_track_state_change_event,
EventStateChangedData,
async_call_later,
)
from homeassistant.exceptions import ConditionError
from homeassistant.helpers import condition
from vtherm_api.log_collector import get_vtherm_logger
from .vtherm_preset import VThermPreset
from .const import * # pylint: disable=wildcard-import, unused-wildcard-import
from .commons import write_event_log
from .commons_type import ConfigData
from .base_manager import BaseFeatureManager
_LOGGER = get_vtherm_logger(__name__)
class FeatureMotionManager(BaseFeatureManager):
"""The implementation of the Motion feature"""
unrecorded_attributes = frozenset(
{
"motion_sensor_entity_id",
"is_motion_configured",
"motion_delay_sec",
"motion_off_delay_sec",
"motion_preset",
"no_motion_preset",
}
)
def __init__(self, vtherm: Any, hass: HomeAssistant):
"""Init of a featureManager"""
super().__init__(vtherm, hass)
self._motion_state: str = STATE_UNAVAILABLE
self._motion_sensor_entity_id: str = None
self._motion_delay_sec: int | None = 0
self._motion_off_delay_sec: int | None = 0
self._motion_preset: str | None = None
self._no_motion_preset: str | None = None
self._is_configured: bool = False
self._motion_call_cancel: callable = None
@overrides
def post_init(self, entry_infos: ConfigData):
"""Reinit of the manager"""
self.dearm_motion_timer()
self._motion_sensor_entity_id = entry_infos.get(CONF_MOTION_SENSOR, None)
self._motion_delay_sec = entry_infos.get(CONF_MOTION_DELAY, 0)
self._motion_off_delay_sec = entry_infos.get(CONF_MOTION_OFF_DELAY, None)
if not self._motion_off_delay_sec:
self._motion_off_delay_sec = self._motion_delay_sec
self._motion_preset = entry_infos.get(CONF_MOTION_PRESET)
self._no_motion_preset = entry_infos.get(CONF_NO_MOTION_PRESET)
if (
self._motion_sensor_entity_id is not None
and self._motion_preset is not None
and self._no_motion_preset is not None
):
self._is_configured = True
self._motion_state = STATE_UNKNOWN
@overrides
async def start_listening(self):
"""Start listening the underlying entity"""
if self._is_configured:
self.stop_listening()
self.add_listener(
async_track_state_change_event(
self.hass,
[self._motion_sensor_entity_id],
self._motion_sensor_changed,
)
)
@overrides
def stop_listening(self):
"""Stop listening and remove the eventual timer still running"""
self.dearm_motion_timer()
super().stop_listening()
def dearm_motion_timer(self):
"""Dearm the eventual motion time running"""
if self._motion_call_cancel:
self._motion_call_cancel()
self._motion_call_cancel = None
@overrides
async def refresh_state(self) -> bool:
"""Tries to get the last state from sensor
Returns True if a change has been made"""
ret = False
if self._is_configured:
motion_state = self.hass.states.get(self._motion_sensor_entity_id)
if motion_state and motion_state.state not in (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
):
_LOGGER.debug(
"%s - Motion state have been retrieved: %s",
self,
self._motion_state,
)
# recalculate the right target_temp in activity mode
ret = await self.update_motion_state(motion_state.state) # , False)
return ret
@callback
async def _motion_sensor_changed(self, event: Event[EventStateChangedData]):
"""Handle motion sensor changes."""
new_state = event.data.get("new_state")
_LOGGER.info(
"%s - Motion changed. Event.new_state is %s, _attr_preset_mode=%s, activity=%s",
self,
new_state,
self._vtherm.preset_mode,
VThermPreset.ACTIVITY,
)
if new_state is None or new_state.state not in (STATE_OFF, STATE_ON):
return
# Check delay condition
async def try_motion_condition(_):
self.dearm_motion_timer()
try:
delay = (
self._motion_delay_sec
if new_state.state == STATE_ON
else self._motion_off_delay_sec
)
long_enough = condition.state(
self.hass,
self._motion_sensor_entity_id,
new_state.state,
timedelta(seconds=delay),
)
except ConditionError:
long_enough = False
if not long_enough:
_LOGGER.debug(
"Motion delay condition is not satisfied (the sensor have change its state during the delay). Check motion sensor state"
)
# Get sensor current state
motion_state = self.hass.states.get(self._motion_sensor_entity_id)
_LOGGER.debug(
"%s - motion_state=%s, new_state.state=%s",
self,
motion_state.state,
new_state.state,
)
if (
motion_state.state == new_state.state
and new_state.state == STATE_ON
):
_LOGGER.debug(
"%s - the motion sensor is finally 'on' after the delay", self
)
long_enough = True
else:
long_enough = False
if long_enough:
_LOGGER.debug("%s - Motion delay condition is satisfied", self)
await self.update_motion_state(new_state.state)
else:
await self.update_motion_state(
STATE_ON if new_state.state == STATE_OFF else STATE_OFF
)
im_on = self._motion_state == STATE_ON
delay_running = self._motion_call_cancel is not None
event_on = new_state.state == STATE_ON
def arm():
"""Arm the timer"""
delay = (
self._motion_delay_sec
if new_state.state == STATE_ON
else self._motion_off_delay_sec
)
self._motion_call_cancel = async_call_later(
self.hass, timedelta(seconds=delay), try_motion_condition
)
# if I'm off
if not im_on:
if event_on and not delay_running:
_LOGGER.debug(
"%s - Arm delay cause i'm off and event is on and no delay is running",
self,
)
arm()
return try_motion_condition
# Ignore the event
_LOGGER.debug("%s - Event ignored cause i'm already off", self)
return None
else: # I'm On
if not event_on and not delay_running:
_LOGGER.info("%s - Arm delay cause i'm on and event is off", self)
arm()
return try_motion_condition
if event_on and delay_running:
_LOGGER.debug(
"%s - Desarm off delay cause i'm on and event is on and a delay is running",
self,
)
self.dearm_motion_timer()
return None
# Ignore the event
_LOGGER.debug("%s - Event ignored cause i'm already on", self)
return None
async def update_motion_state(self, new_state: str = None) -> bool: # , recalculate: bool = True) -> bool:
"""Update the value of the motion sensor and update the VTherm state accordingly
Return true if a change has been made"""
_LOGGER.info("%s - Updating motion state. New state is %s", self, new_state)
old_motion_state = self._motion_state
if new_state is not None:
self._motion_state = STATE_ON if new_state == STATE_ON else STATE_OFF
if old_motion_state != self._motion_state:
write_event_log(_LOGGER, self._vtherm, f"Motion state changed from {old_motion_state} to {self._motion_state}")
self._vtherm.requested_state.force_changed()
await self._vtherm.update_states(True)
return True
return False
def get_current_motion_preset(self) -> str:
"""Calculate and return the current motion preset"""
return (
self._motion_preset
if self._motion_state == STATE_ON
else self._no_motion_preset
)
def add_custom_attributes(self, extra_state_attributes: dict[str, Any]):
"""Add some custom attributes"""
extra_state_attributes.update(
{
"is_motion_configured": self._is_configured,
}
)
if self._is_configured:
extra_state_attributes.update(
{
"motion_manager": {
"motion_sensor_entity_id": self._motion_sensor_entity_id,
"motion_state": self._motion_state,
"motion_delay_sec": self._motion_delay_sec,
"motion_off_delay_sec": self._motion_off_delay_sec,
"motion_preset": self._motion_preset,
"no_motion_preset": self._no_motion_preset,
}
}
)
@overrides
@property
def is_configured(self) -> bool:
"""Return True of the motion is configured"""
return self._is_configured
@property
def motion_state(self) -> str | None:
"""Return the current motion state STATE_ON or STATE_OFF
or STATE_UNAVAILABLE if not configured"""
if not self._is_configured:
return STATE_UNAVAILABLE
return self._motion_state
@property
def is_motion_detected(self) -> bool:
"""Return true if the motion is configured and motion sensor is OFF"""
return self._is_configured and self._motion_state in [
STATE_ON,
]
@property
def is_detected(self) -> bool:
"""Return the overall state of the feature manager based on motion states"""
return self.is_motion_detected
@property
def motion_sensor_entity_id(self) -> str | None:
"""Return the entity ID of the motion sensor."""
return self._motion_sensor_entity_id
@property
def motion_delay_sec(self) -> int:
"""Return the motion delay"""
return self._motion_delay_sec
@property
def motion_off_delay_sec(self) -> int:
"""Return motion delay off"""
return self._motion_off_delay_sec
@property
def motion_preset(self) -> str | None:
"""Return motion preset"""
return self._motion_preset
@property
def no_motion_preset(self) -> str | None:
"""Return no motion preset"""
return self._no_motion_preset
def __str__(self):
return f"MotionManager-{self.name}"
@@ -0,0 +1,368 @@
""" Implements the Power Feature Manager """
# pylint: disable=line-too-long
from typing import Any
from homeassistant.const import (
STATE_ON,
STATE_OFF,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import (
HomeAssistant,
)
from vtherm_api.log_collector import get_vtherm_logger
from .const import * # pylint: disable=wildcard-import, unused-wildcard-import
from .commons import write_event_log, round_to_nearest
from .commons_type import ConfigData
from .base_manager import BaseFeatureManager
from .vtherm_central_api import VersatileThermostatAPI
_LOGGER = get_vtherm_logger(__name__)
class FeaturePowerManager(BaseFeatureManager):
"""The implementation of the Power feature"""
unrecorded_attributes = frozenset(
{
"power_sensor_entity_id",
"max_power_sensor_entity_id",
"is_power_configured",
"device_power",
"power_temp",
"current_power",
"current_max_power",
}
)
def __init__(self, vtherm: Any, hass: HomeAssistant):
"""Init of a featureManager"""
super().__init__(vtherm, hass)
self._power_temp: float | None = None
self._overpowering_state: str | None = None
self._is_configured: bool = False
self._device_power: float = 0
self._use_power_feature: bool = False
@overrides
def post_init(self, entry_infos: ConfigData):
"""Reinit of the manager"""
# Power management
self._power_temp = entry_infos.get(CONF_PRESET_POWER)
self._device_power = entry_infos.get(CONF_DEVICE_POWER) or 0
self._use_power_feature = entry_infos.get(CONF_USE_POWER_FEATURE, False)
self._is_configured = False
@overrides
async def start_listening(self):
"""Start listening the underlying entity. There is nothing to listen"""
central_power_configuration = (
VersatileThermostatAPI.get_vtherm_api().central_power_manager.is_configured
)
if self._use_power_feature and self._device_power and central_power_configuration:
self._is_configured = True
# Try to restore _overpowering_state from previous state
old_state = await self._vtherm.async_get_last_state()
self._overpowering_state = (
STATE_ON if old_state is not None and hasattr(old_state, "attributes") and old_state.attributes.get("overpowering_state") == STATE_ON else STATE_UNKNOWN
)
else:
if self._use_power_feature:
if not central_power_configuration:
_LOGGER.warning(
"%s - Power management is not fully configured. You have to configure the central configuration power",
self,
)
else:
_LOGGER.warning(
"%s - Power management is not fully configured. You have to configure the power feature of the VTherm",
self,
)
def add_custom_attributes(self, extra_state_attributes: dict[str, Any]):
"""Add some custom attributes"""
vtherm_api = VersatileThermostatAPI.get_vtherm_api()
extra_state_attributes.update(
{
"is_power_configured": self.is_configured,
}
)
if self._is_configured:
extra_state_attributes.update(
{
"power_manager": {
"power_sensor_entity_id": vtherm_api.central_power_manager.power_sensor_entity_id,
"max_power_sensor_entity_id": vtherm_api.central_power_manager.max_power_sensor_entity_id,
"overpowering_state": self.overpowering_state,
"device_power": self._device_power,
"power_temp": self._power_temp,
"current_power": vtherm_api.central_power_manager.current_power,
"current_max_power": vtherm_api.central_power_manager.current_max_power,
"mean_cycle_power": self.mean_cycle_power,
}
}
)
else:
extra_state_attributes.update(
{
"power_manager": {
"device_power": self._device_power,
"mean_cycle_power": self.mean_cycle_power,
}
}
)
async def check_power_available(
self, reservation_key: str | None = None
) -> tuple[bool, float]:
"""Check if the Vtherm can be started considering overpowering.
Returns True if no overpowering conditions are found.
If True the vtherm power is written into the temporay vtherm started
"""
vtherm_api = VersatileThermostatAPI.get_vtherm_api()
if (
not self._is_configured
or not vtherm_api.central_power_manager.is_configured
):
return True, 0
effective_reservation_key = reservation_key or self._default_power_reservation_key
current_power = vtherm_api.central_power_manager.current_power
current_max_power = vtherm_api.central_power_manager.current_max_power
started_vtherm_total_power = vtherm_api.central_power_manager.started_vtherm_total_power
current_started_power = vtherm_api.central_power_manager.get_started_vtherm_power(
effective_reservation_key
)
if (
current_power is None
or current_max_power is None
or self._device_power is None
):
_LOGGER.warning(
"%s - power not valued. check_power_available not available", self
)
return True, 0
_LOGGER.debug(
"%s - overpowering check: power=%.3f, max_power=%.3f heater power=%.3f",
self,
current_power,
current_max_power,
self._device_power,
)
startup_power = self.calculate_underlying_startup_power()
ret = (
current_power
+ started_vtherm_total_power
- current_started_power
+ startup_power
) < current_max_power
if not ret:
_LOGGER.info(
"%s - there is not enough power available power=%.3f, max_power=%.3f started_power=%.3f current_started_power=%.3f startup_power=%.3f heater power=%.3f reservation_key=%s",
self,
current_power,
current_max_power,
started_vtherm_total_power,
current_started_power,
startup_power,
self._device_power,
effective_reservation_key,
)
return ret, startup_power
def calculate_underlying_startup_power(self) -> float:
"""Calculate the incremental startup power for a single underlying.
This is the power to *reserve* between two power sensor measurements
when a new underlying is about to be turned on. It is deliberately
distinct from ``calculate_power_consumption_max()`` (used for global
shedding decisions) and intentionally ignores ``on_percent``: startup
is an instantaneous decision, not a cycle-average one.
"""
if not self._device_power:
return 0
# over_climate and mono-underlying over_switch: if the device is
# already active, its load is already reflected in current_power, so
# no additional reservation is needed (returning device_power here
# would double-count the load between two sensor refreshes).
if self._vtherm.is_over_climate:
return 0 if self._vtherm.is_device_active else self._device_power
if self._vtherm.nb_underlying_entities <= 1:
return 0 if self._vtherm.is_device_active else self._device_power
# Multi-underlying over_switch: each underlying contributes an
# independent incremental slice, regardless of whether the VTherm is
# globally "active". Starting a 2nd underlying while the 1st is on
# must still reserve its own device_power/n slice.
return self._device_power / self._vtherm.nb_underlying_entities
def calculate_power_consumption_max(self) -> float:
"""Calculate the maximum power consumption"""
power_consumption_max = 0
if not self._vtherm.is_device_active:
if self._vtherm.is_over_climate:
power_consumption_max = self._device_power
else:
# if on_percent is not defined, we consider that the device can consume all its power in the worst case
on_percent = self._vtherm.safe_on_percent if self._vtherm.safe_on_percent is not None else 1
power_consumption_max = max(
self._device_power / self._vtherm.nb_underlying_entities,
self._device_power * on_percent,
)
return power_consumption_max
def add_power_consumption_to_central_power_manager(
self,
reservation_key: str | None = None,
):
"""
Add the current power consumption to the central power manager.
"""
vtherm_api = VersatileThermostatAPI.get_vtherm_api()
if not self._is_configured or not vtherm_api.central_power_manager.is_configured:
return
startup_power = self.calculate_underlying_startup_power()
vtherm_api.central_power_manager.set_started_vtherm_power(
reservation_key or self._default_power_reservation_key,
startup_power,
)
def sub_power_consumption_to_central_power_manager(
self,
reservation_key: str | None = None,
):
"""
Substract the current power consumption to the central power manager.
"""
vtherm_api = VersatileThermostatAPI.get_vtherm_api()
if not self._is_configured or not vtherm_api.central_power_manager.is_configured:
return
vtherm_api.central_power_manager.set_started_vtherm_power(
reservation_key or self._default_power_reservation_key,
0,
)
async def set_overpowering(self, overpowering: bool, power_consumption_max: float = 0):
"""Force the overpowering state for the VTherm"""
vtherm_api = VersatileThermostatAPI.get_vtherm_api()
current_power = vtherm_api.central_power_manager.current_power
current_max_power = vtherm_api.central_power_manager.current_max_power
if overpowering and not self.is_overpowering_detected:
write_event_log(_LOGGER, self._vtherm, "Overpowering is detected")
_LOGGER.warning("%s - overpowering is detected.", self)
self._overpowering_state = STATE_ON
await self._vtherm.async_underlying_entity_turn_off()
self._vtherm.send_event(
EventType.POWER_EVENT,
{
"type": "start",
"current_power": current_power,
"device_power": self._device_power,
"current_max_power": current_max_power,
"current_power_consumption": power_consumption_max,
},
)
elif not overpowering and self.is_overpowering_detected:
write_event_log(_LOGGER, self._vtherm, "End of overpowering is detected")
_LOGGER.warning("%s - end of overpowering is detected.", self)
self._overpowering_state = STATE_OFF
self._vtherm.send_event(
EventType.POWER_EVENT,
{
"type": "end",
"current_power": current_power,
"device_power": self._device_power,
"current_max_power": current_max_power,
},
)
elif not overpowering and self._overpowering_state != STATE_OFF:
# just set to not overpowering the state which was not set
self._overpowering_state = STATE_OFF
else:
# Nothing to do (already in the right state)
return
# self._vtherm.update_custom_attributes()
@overrides
@property
def is_configured(self) -> bool:
"""Return True of the presence is configured"""
return self._is_configured
@property
def overpowering_state(self) -> str | None:
"""Return the current overpowering state STATE_ON or STATE_OFF
or STATE_UNAVAILABLE if not configured"""
if not self._is_configured:
return STATE_UNAVAILABLE
return self._overpowering_state
@property
def is_overpowering_detected(self) -> bool:
"""Return True if the Vtherm is in overpowering state"""
return self._overpowering_state == STATE_ON
@property
def is_detected(self) -> bool:
"""Return the overall state of the feature manager based on detection states"""
return self.is_overpowering_detected
@property
def power_temperature(self) -> float | None:
"""Return the power temperature"""
return self._power_temp
@property
def device_power(self) -> float:
"""Return the device power"""
return self._device_power
@property
def mean_cycle_power(self) -> float | None:
"""Returns the mean power consumption during the cycle"""
if not self._device_power:
return None
if self._vtherm.proportional_algorithm:
algo_on_percent = self._vtherm.proportional_algorithm.on_percent
if algo_on_percent is None:
return None
return float(round_to_nearest(self._device_power * algo_on_percent, 0.01))
if self._vtherm.is_over_climate:
return self._device_power if self._vtherm.is_device_active else 0.0
return None
@property
def _default_power_reservation_key(self) -> str:
"""Return a stable fallback key for temporary power reservations."""
return getattr(self._vtherm, "entity_id", None) or self._vtherm.name
def __str__(self):
return f"PowerManager-{self.name}"
@@ -0,0 +1,180 @@
""" Implements the Presence Feature Manager """
# pylint: disable=line-too-long
from typing import Any
from homeassistant.const import (
STATE_ON,
STATE_OFF,
STATE_HOME,
STATE_NOT_HOME,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import (
HomeAssistant,
callback,
Event,
)
from homeassistant.helpers.event import (
async_track_state_change_event,
EventStateChangedData,
)
from vtherm_api.log_collector import get_vtherm_logger
from .const import * # pylint: disable=wildcard-import, unused-wildcard-import
from .commons import write_event_log
from .commons_type import ConfigData
from .vtherm_preset import VThermPreset
from .base_manager import BaseFeatureManager
_LOGGER = get_vtherm_logger(__name__)
class FeaturePresenceManager(BaseFeatureManager):
"""The implementation of the Presence feature"""
unrecorded_attributes = frozenset(
{
"presence_sensor_entity_id",
"is_presence_configured",
}
)
def __init__(self, vtherm: Any, hass: HomeAssistant):
"""Init of a featureManager"""
super().__init__(vtherm, hass)
self._presence_state: str = STATE_UNAVAILABLE
self._presence_sensor_entity_id: str = None
self._is_configured: bool = False
@overrides
def post_init(self, entry_infos: ConfigData):
"""Reinit of the manager"""
self._presence_sensor_entity_id = entry_infos.get(CONF_PRESENCE_SENSOR)
if (
entry_infos.get(CONF_USE_PRESENCE_FEATURE, False)
and self._presence_sensor_entity_id is not None
):
self._is_configured = True
self._presence_state = STATE_UNKNOWN
@overrides
async def start_listening(self):
"""Start listening the underlying entity"""
if self._is_configured:
self.stop_listening()
self.add_listener(
async_track_state_change_event(
self.hass,
[self._presence_sensor_entity_id],
self._presence_sensor_changed,
)
)
@overrides
async def refresh_state(self) -> bool:
"""Tries to get the last state from sensor
Returns True if a change has been made"""
ret = False
if self._is_configured:
# try to acquire presence entity state
presence_state = self.hass.states.get(self._presence_sensor_entity_id)
if presence_state and presence_state.state not in (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
):
ret = await self.update_presence(presence_state.state)
_LOGGER.debug(
"%s - Presence have been retrieved: %s",
self,
presence_state.state,
)
return ret
@callback
async def _presence_sensor_changed(self, event: Event[EventStateChangedData]):
"""Handle presence changes."""
new_state = event.data.get("new_state")
write_event_log(_LOGGER, self._vtherm, f"Presence sensor changed to state {new_state.state if new_state else None}")
if new_state is None:
return
return await self.update_presence(new_state.state)
async def update_presence(self, new_state: str):
"""Update the value of the presence sensor and update the VTherm state accordingly"""
_LOGGER.info("%s - Updating presence. New state is %s", self, new_state)
old_presence_state = self._presence_state
self._presence_state = STATE_ON if new_state in (STATE_ON, STATE_HOME) else STATE_OFF
if new_state is None or new_state not in (
STATE_OFF,
STATE_ON,
STATE_HOME,
STATE_NOT_HOME,
):
self._presence_state = STATE_UNKNOWN
if old_presence_state != self._presence_state:
self._vtherm.requested_state.force_changed()
await self._vtherm.update_states(True)
return True
return False
def add_custom_attributes(self, extra_state_attributes: dict[str, Any]):
"""Add some custom attributes"""
extra_state_attributes.update(
{
"is_presence_configured": self._is_configured,
}
)
if self._is_configured:
extra_state_attributes.update(
{
"presence_manager": {
"presence_sensor_entity_id": self._presence_sensor_entity_id,
"presence_state": self._presence_state,
}
}
)
@overrides
@property
def is_configured(self) -> bool:
"""Return True of the presence is configured"""
return self._is_configured
@property
def presence_state(self) -> str | None:
"""Return the current presence state STATE_ON or STATE_OFF
or STATE_UNAVAILABLE if not configured"""
if not self._is_configured:
return STATE_UNAVAILABLE
return self._presence_state
@property
def is_absence_detected(self) -> bool:
"""Return true if the presence is configured and presence sensor is OFF"""
return self._is_configured and self._presence_state in [
STATE_NOT_HOME,
STATE_OFF,
]
@property
def is_detected(self) -> bool:
"""Return the overall state of the feature manager based on presence states"""
return not self.is_absence_detected
@property
def presence_sensor_entity_id(self) -> bool:
"""Return true if the presence is configured and presence sensor is OFF"""
return self._presence_sensor_entity_id
def __str__(self):
return f"PresenceManager-{self.name}"
@@ -0,0 +1,170 @@
# pylint: disable=line-too-long
"""Implements the Repair Incorrect State feature as a Feature Manager"""
from typing import Any
from datetime import datetime, timezone
from homeassistant.core import HomeAssistant
from .const import (
CONF_REPAIR_INCORRECT_STATE,
DEFAULT_REPAIR_INCORRECT_STATE,
REPAIR_MAX_ATTEMPTS,
REPAIR_MIN_DELAY_AFTER_INIT_SEC,
overrides,
)
from vtherm_api.log_collector import get_vtherm_logger
from .commons_type import ConfigData
from .base_manager import BaseFeatureManager
_LOGGER = get_vtherm_logger(__name__)
class FeatureRepairIncorrectStateManager(BaseFeatureManager):
"""Detects and repairs discrepancies between VTherm's desired state
and the actual state of underlying entities.
On each control heating cycle, if the feature is enabled, it compares
the desired state (should_device_be_active) with the actual state
(is_device_active) for each underlying entity. If they differ, it
re-emits the desired command. The number of consecutive repairs is
capped at REPAIR_MAX_ATTEMPTS to prevent infinite loops.
The feature only activates at least REPAIR_MIN_DELAY_AFTER_INIT_SEC
seconds after VTherm has become fully operational (is_ready).
"""
unrecorded_attributes = frozenset(
{
"is_repair_incorrect_state_configured",
}
)
def __init__(self, vtherm: Any, hass: HomeAssistant):
"""Init of a FeatureManager"""
super().__init__(vtherm, hass)
self._is_configured: bool = False
self._ready_start_time: datetime | None = None
self._consecutive_repair_count: int = 0
@overrides
def post_init(self, entry_infos: ConfigData):
"""Reinit of the manager"""
self._is_configured = entry_infos.get(
CONF_REPAIR_INCORRECT_STATE, DEFAULT_REPAIR_INCORRECT_STATE
)
self._ready_start_time = None
self._consecutive_repair_count = 0
@overrides
async def start_listening(self):
"""Start listening - no external entity to monitor for this feature"""
@overrides
def stop_listening(self):
"""Stop listening - nothing to clean up for this feature"""
@property
@overrides
def is_configured(self) -> bool:
"""True if the feature is enabled"""
return self._is_configured
async def check_and_repair(self) -> bool:
"""Check all underlyings for state discrepancies and repair if needed.
Called on each control heating cycle.
Returns True if at least one repair was performed, False otherwise.
"""
if not self._is_configured:
return False
if not self._vtherm.is_ready:
# Reset so the delay restarts if VTherm loses readiness
self._ready_start_time = None
return False
now = datetime.now(timezone.utc)
# Record the first time VTherm becomes ready
if self._ready_start_time is None:
self._ready_start_time = now
_LOGGER.debug(
"%s - RepairIncorrectStateManager: VTherm just became ready, "
"waiting %ds before activating",
self._vtherm.name,
REPAIR_MIN_DELAY_AFTER_INIT_SEC,
)
return False
# Wait for the minimum delay after init
elapsed = (now - self._ready_start_time).total_seconds()
if elapsed < REPAIR_MIN_DELAY_AFTER_INIT_SEC:
return False
# Stop if the maximum consecutive repair count is reached
if self._consecutive_repair_count >= REPAIR_MAX_ATTEMPTS:
_LOGGER.error(
"%s - RepairIncorrectStateManager: maximum repair attempts (%d) " "reached. Stopped attempting repairs to avoid infinite loop.",
self._vtherm.name,
REPAIR_MAX_ATTEMPTS,
)
self._consecutive_repair_count += 1
if self._consecutive_repair_count >= 2 * REPAIR_MAX_ATTEMPTS:
_LOGGER.info(
"%s - RepairIncorrectStateManager: consecutive repair count has doubled the max attempts, resetting the counter to allow new repair attempts.",
self._vtherm.name,
)
self._consecutive_repair_count = 0
else:
return False
repaired = False
# build a list of underlying to repair as the vtherm.underlyings concatened to the list of vtherm.underlyings_valve_regulation if it exists
for underlying in self._vtherm.all_underlying_entities:
_LOGGER.debug("%s - RepairIncorrectStateManager: checking underlying %s for state discrepancies", self, underlying.entity_id)
repaired_this = await underlying.check_and_repair()
if repaired_this:
_LOGGER.warning(
"%s - RepairIncorrectStateManager: underlying %s was repaired. Consecutive repairs so far: %d",
self._vtherm.name,
underlying.entity_id,
self._consecutive_repair_count + 1,
)
repaired = True
if repaired:
self._consecutive_repair_count += 1
else:
self._consecutive_repair_count = 0
return repaired
def add_custom_attributes(self, extra_state_attributes: dict[str, Any]):
"""Add custom attributes for diagnostics"""
extra_state_attributes.update(
{
"is_repair_incorrect_state_configured": self._is_configured,
}
)
if self._is_configured:
extra_state_attributes.update(
{
"repair_incorrect_state_manager": {
"consecutive_repair_count": self._consecutive_repair_count,
"max_attempts": REPAIR_MAX_ATTEMPTS,
"min_delay_after_init_sec": REPAIR_MIN_DELAY_AFTER_INIT_SEC,
}
}
)
@property
def is_detected(self) -> bool:
"""Return the overall state of the feature manager based on repair attempts"""
return self._consecutive_repair_count > 0
def __str__(self):
return f"RepairIncorrectStateManager-{self.name}"
@@ -0,0 +1,322 @@
# pylint: disable=line-too-long
""" Implements the Safety as a Feature Manager"""
from typing import Any
from homeassistant.const import (
STATE_ON,
STATE_OFF,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant
from homeassistant.components.climate import HVACAction
from .const import * # pylint: disable=wildcard-import, unused-wildcard-import
from .commons import write_event_log
from .commons_type import ConfigData
from .base_manager import BaseFeatureManager
from .vtherm_central_api import VersatileThermostatAPI
from .vtherm_hvac_mode import VThermHvacMode
from vtherm_api.log_collector import get_vtherm_logger
_LOGGER = get_vtherm_logger(__name__)
class FeatureSafetyManager(BaseFeatureManager):
"""The implementation of the Safety feature"""
unrecorded_attributes = frozenset(
{
"safety_delay_min",
"safety_min_on_percent",
"safety_default_on_percent",
"is_safety_configured",
}
)
def __init__(self, vtherm: Any, hass: HomeAssistant):
"""Init of a featureManager"""
super().__init__(vtherm, hass)
self._is_configured: bool = False
self._safety_delay_min = None
self._safety_min_on_percent = None
self._safety_default_on_percent = None
self._safety_state = STATE_UNAVAILABLE
self._is_outdoor_checked = True
@overrides
def post_init(self, entry_infos: ConfigData):
"""Reinit of the manager"""
self._safety_delay_min = entry_infos.get(CONF_SAFETY_DELAY_MIN)
self._safety_min_on_percent = (
entry_infos.get(CONF_SAFETY_MIN_ON_PERCENT)
if entry_infos.get(CONF_SAFETY_MIN_ON_PERCENT) is not None
else DEFAULT_SAFETY_MIN_ON_PERCENT
)
self._safety_default_on_percent = (
entry_infos.get(CONF_SAFETY_DEFAULT_ON_PERCENT)
if entry_infos.get(CONF_SAFETY_DEFAULT_ON_PERCENT) is not None
else DEFAULT_SAFETY_DEFAULT_ON_PERCENT
)
if (
self._safety_delay_min is not None
and self._safety_default_on_percent is not None
and self._safety_default_on_percent is not None
):
self._safety_state = STATE_UNKNOWN
self._is_configured = True
api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(self.hass)
self._is_outdoor_checked = not api.safety_mode or api.safety_mode.get("check_outdoor_sensor") is not False
_LOGGER.info("%s - is_outdoor_checked is %s", self, self._is_outdoor_checked)
@overrides
async def start_listening(self):
"""Start listening the underlying entity"""
@overrides
def stop_listening(self):
"""Stop listening and remove the eventual timer still running"""
@overrides
async def refresh_state(self) -> bool:
"""Check the safety and an eventual action
Return True is safety should be active"""
if not self._is_configured:
_LOGGER.debug("%s - safety is disabled (or not configured)", self)
return False
if self._vtherm.requested_state.hvac_mode == VThermHvacMode_OFF:
self._safety_state = STATE_OFF
_LOGGER.debug("%s - safety is OFF because requested_state is OFF", self)
return False
now = self._vtherm.now
current_tz = dt_util.get_time_zone(self._hass.config.time_zone)
is_safety_detected = self.is_safety_detected
delta_temp = (
now - self._vtherm.last_temperature_measure.replace(tzinfo=current_tz)
).total_seconds() / 60.0
delta_ext_temp = (
now - self._vtherm.last_ext_temperature_measure.replace(tzinfo=current_tz)
).total_seconds() / 60.0
mode_cond = self._vtherm.hvac_mode != VThermHvacMode_OFF
temp_cond: bool = delta_temp > self._safety_delay_min or (self._is_outdoor_checked and delta_ext_temp > self._safety_delay_min)
climate_cond: bool = (
self._vtherm.is_over_climate
and self._vtherm.hvac_action
not in [
HVACAction.COOLING,
HVACAction.IDLE,
]
)
switch_cond: bool = (
not self._vtherm.is_over_climate
and self._vtherm.has_prop
and self._vtherm.proportional_algorithm is not None
and self._vtherm.proportional_algorithm.calculated_on_percent
>= self._safety_min_on_percent
)
_LOGGER.debug(
"%s - checking safety delta_temp=%.1f delta_ext_temp=%.1f mod_cond=%s temp_cond=%s climate_cond=%s switch_cond=%s",
self,
delta_temp,
delta_ext_temp,
mode_cond,
temp_cond,
climate_cond,
switch_cond,
)
# Issue 99 - a climate is regulated by the device itself and not by VTherm. So a VTherm should never be in safety !
should_climate_be_in_safety = False # temp_cond and climate_cond
should_switch_be_in_safety = temp_cond and switch_cond
should_be_in_safety = should_climate_be_in_safety or should_switch_be_in_safety
should_start_safety = mode_cond and not is_safety_detected and should_be_in_safety
# attr_preset_mode is not necessary normaly. It is just here to be sure
should_stop_safety = is_safety_detected and not should_be_in_safety
# Logging and event
if should_start_safety:
if should_climate_be_in_safety:
_LOGGER.warning(
"%s - No temperature received for more than %.1f minutes (dt=%.1f, dext=%.1f) and underlying climate is %s. Setting it into safety mode",
self,
self._safety_delay_min,
delta_temp,
delta_ext_temp,
self._vtherm.hvac_action,
)
elif should_switch_be_in_safety:
_LOGGER.warning(
"%s - No temperature received for more than %.1f minutes (dt=%.1f, dext=%.1f) and on_percent (%.2f %%) is over defined value (%.2f %%). Set it into safety mode",
self,
self._safety_delay_min,
delta_temp,
delta_ext_temp,
self._vtherm.proportional_algorithm.on_percent * 100,
self._safety_min_on_percent * 100 if self._safety_min_on_percent is not None else 0,
)
self._vtherm.send_event(
EventType.TEMPERATURE_EVENT,
{
"last_temperature_measure": self._vtherm.last_temperature_measure.replace(
tzinfo=current_tz
).isoformat(),
"last_ext_temperature_measure": self._vtherm.last_ext_temperature_measure.replace(
tzinfo=current_tz
).isoformat(),
"current_temp": self._vtherm.current_temperature,
"current_ext_temp": self._vtherm.current_outdoor_temperature,
"target_temp": self._vtherm.target_temperature,
},
)
# Start safety mode
if should_start_safety:
write_event_log(_LOGGER, self._vtherm, "Starting safety mode")
self._safety_state = STATE_ON
# self._vtherm.save_hvac_mode()
# self._vtherm.save_preset_mode()
if self._vtherm.has_prop:
self._vtherm.set_safety(self._safety_default_on_percent)
self._vtherm.send_event(
EventType.SAFETY_EVENT,
{
"type": "start",
"last_temperature_measure": self._vtherm.last_temperature_measure.replace(tzinfo=current_tz).isoformat(),
"last_ext_temperature_measure": self._vtherm.last_ext_temperature_measure.replace(tzinfo=current_tz).isoformat(),
"current_temp": self._vtherm.current_temperature,
"current_ext_temp": self._vtherm.current_outdoor_temperature,
"target_temp": self._vtherm.target_temperature,
},
)
# Stop safety mode
elif should_stop_safety:
write_event_log(_LOGGER, self._vtherm, "Ending safety mode")
_LOGGER.warning("%s - End of safety mode.", self)
self._safety_state = STATE_OFF
if self._vtherm.has_prop:
self._vtherm.unset_safety()
self._vtherm.send_event(
EventType.SAFETY_EVENT,
{
"type": "end",
"last_temperature_measure": self._vtherm.last_temperature_measure.replace(tzinfo=current_tz).isoformat(),
"last_ext_temperature_measure": self._vtherm.last_ext_temperature_measure.replace(tzinfo=current_tz).isoformat(),
"current_temp": self._vtherm.current_temperature,
"current_ext_temp": self._vtherm.current_outdoor_temperature,
"target_temp": self._vtherm.target_temperature,
},
)
# Initialize the safety_state if not already done
elif not should_be_in_safety and self._safety_state in [STATE_UNKNOWN]:
self._safety_state = STATE_OFF
return self._safety_state == STATE_ON
async def _async_update_states_later(self, _=None):
"""Called at next tick to update states without recursion"""
self._vtherm.requested_state.force_changed()
await self._vtherm.update_states(force=True)
async def refresh_and_update_if_changed(self) -> bool:
"""Refresh the safety state and update_states of VTherm if changed
Returns True if the state has changed, False otherwise"""
old_safety: bool = self.is_safety_detected
if old_safety != await self.refresh_state():
# issue 1450 - schedule update_states at next tick to avoid recursion
self._hass.async_create_task(self._async_update_states_later())
return True
return False
def add_custom_attributes(self, extra_state_attributes: dict[str, Any]):
"""Add some custom attributes"""
extra_state_attributes.update(
{
"is_safety_configured": self._is_configured,
}
)
if self._is_configured:
extra_state_attributes.update(
{
"safety_manager": {
"safety_state": self._safety_state,
"safety_delay_min": self._safety_delay_min,
"safety_min_on_percent": self._safety_min_on_percent,
"safety_default_on_percent": self._safety_default_on_percent,
}
}
)
@property
def is_configured(self) -> bool:
"""Return True of the safety feature is configured"""
return self._is_configured
def set_safety_delay_min(self, safety_delay_min):
"""Set the delay min"""
self._safety_delay_min = safety_delay_min
def set_safety_min_on_percent(self, safety_min_on_percent):
"""Set the min on percent"""
self._safety_min_on_percent = safety_min_on_percent
def set_safety_default_on_percent(self, safety_default_on_percent):
"""Set the default on_percent"""
self._safety_default_on_percent = safety_default_on_percent
@property
def is_safety_detected(self) -> bool:
"""Returns the is vtherm is in safety mode"""
return self._safety_state == STATE_ON
@property
def safety_state(self) -> str:
"""Returns the safety state: STATE_ON, STATE_OFF, STATE_UNKWNON, STATE_UNAVAILABLE"""
return self._safety_state
@property
def safety_delay_min(self) -> bool:
"""Returns the safety delay min"""
return self._safety_delay_min
@property
def safety_min_on_percent(self) -> bool:
"""Returns the safety min on percent"""
return self._safety_min_on_percent
@property
def safety_default_on_percent(self) -> bool:
"""Returns the safety safety_default_on_percent"""
return self._safety_default_on_percent
@property
def is_detected(self) -> bool:
"""Return the overall state of the feature manager based on safety states"""
return self.is_safety_detected
def __str__(self):
return f"SafetyManager-{self.name}"
@@ -0,0 +1,316 @@
""" Implements the Timed Preset Feature Manager """
# pylint: disable=line-too-long
from datetime import datetime, timedelta
from typing import Any
from homeassistant.core import (
HomeAssistant,
callback,
)
from homeassistant.helpers.event import async_track_point_in_time
from vtherm_api.log_collector import get_vtherm_logger
from .const import * # pylint: disable=wildcard-import, unused-wildcard-import
from .commons_type import ConfigData
from .commons import write_event_log
from .base_manager import BaseFeatureManager
from .vtherm_preset import VThermPreset
_LOGGER = get_vtherm_logger(__name__)
class FeatureTimedPresetManager(BaseFeatureManager):
"""The implementation of the TimedPreset feature.
This feature allows forcing a preset for a given duration.
When the duration expires, the original preset (from requested_state) is restored.
"""
unrecorded_attributes = frozenset(
{
"timed_preset_manager",
}
)
def __init__(self, vtherm: Any, hass: HomeAssistant):
"""Init of a featureManager"""
super().__init__(vtherm, hass)
self._is_timed_preset_active: bool = False
self._timed_preset: VThermPreset | None = None
self._original_preset: VThermPreset | None = None
self._timed_preset_end_time: datetime | None = None
self._cancel_timer: Any | None = None
@overrides
def post_init(self, entry_infos: ConfigData):
"""Reinit of the manager"""
# The timed preset feature is always available, no configuration needed
pass
@overrides
async def start_listening(self):
"""Start listening - nothing to listen to for this feature"""
pass
@overrides
def stop_listening(self):
"""Stop listening and remove the eventual timer still running"""
self._cancel_timed_preset_timer()
super().stop_listening()
@callback
def restore_state(self, old_state: Any):
"""Implement the restoration hook to re-populate timed presets after restart."""
# 1. Retrieve the persistence dictionary from attributes
# Matches the key used in add_custom_attributes
manager_attr = old_state.attributes.get("timed_preset_manager")
if not manager_attr or not manager_attr.get("is_active"):
return
end_time_str = manager_attr.get("end_time")
preset_str = manager_attr.get("preset")
original_preset_str = manager_attr.get("original_preset")
if not end_time_str or not preset_str:
return
try:
# 2. Re-parse the end_time and preset mode
end_time = datetime.fromisoformat(end_time_str)
now = self._vtherm.now
# 3. Re-populate internal manager variables
self._is_timed_preset_active = True
self._timed_preset = VThermPreset(preset_str)
self._original_preset = VThermPreset(original_preset_str) if original_preset_str else None
self._timed_preset_end_time = end_time
# 4. Reschedule the expiration task if time remains
if end_time > now:
# While the timed preset is active, requested_state must carry it so
# other managers like auto start/stop recalculate from the forced preset.
self._vtherm.requested_state.set_preset(self._timed_preset)
_LOGGER.info("%s - Resuming timed preset %s. Reverting at %s", self, preset_str, end_time)
self._cancel_timer = async_track_point_in_time(
self._hass,
self._async_timed_preset_expired,
self._timed_preset_end_time,
)
else:
_LOGGER.info("%s - Timed preset expired during downtime. Cleanup will follow.", self)
# The existing safety check in refresh_state() will handle the cleanup
# and revert to requested_state during the startup cycle.
except (ValueError, TypeError) as err:
_LOGGER.error("%s - Failed to restore timed preset state: %s", self, err)
@overrides
async def refresh_state(self) -> bool:
"""Check if the timed preset is still active.
Return True if timed preset is active"""
if not self._is_timed_preset_active:
return False
# Check if the timer has expired (safety check in case timer callback failed)
if self._timed_preset_end_time and self._vtherm.now >= self._timed_preset_end_time:
_LOGGER.debug("%s - timed preset has expired (safety check)", self)
await self._end_timed_preset()
return False
return self._is_timed_preset_active
async def set_timed_preset(self, preset: VThermPreset, duration_minutes: float) -> bool:
"""Set a preset for a given duration in minutes.
Args:
preset: The preset to apply temporarily
duration_minutes: The duration in minutes
Returns:
True if the timed preset was set successfully, False otherwise
"""
if duration_minutes <= 0:
_LOGGER.warning("%s - duration must be positive, got %s", self, duration_minutes)
return False
if preset not in self._vtherm.vtherm_preset_modes and preset not in [VThermPreset.NONE]:
_LOGGER.warning("%s - preset %s is not available for this thermostat", self, preset)
return False
# Cancel any existing timer
self._cancel_timed_preset_timer()
# Capture the original preset before overriding it
if not self._is_timed_preset_active:
self._original_preset = self._vtherm.requested_state.preset
# Store the timed preset information
self._timed_preset = preset
self._timed_preset_end_time = self._vtherm.now + timedelta(minutes=duration_minutes)
self._is_timed_preset_active = True
# Keep requested_state aligned with the timed preset while it is active so
# state recalculation uses the forced preset even if HVAC is currently off.
self._vtherm.requested_state.set_preset(preset)
# Schedule the end of timed preset
self._cancel_timer = async_track_point_in_time(
self._hass,
self._async_timed_preset_expired,
self._timed_preset_end_time,
)
write_event_log(
_LOGGER,
self._vtherm,
f"Timed preset started: {preset} for {duration_minutes} minutes until {self._timed_preset_end_time}",
)
# Send an event
self._vtherm.send_event(
event_type=EventType.TIMED_PRESET_EVENT,
data={
"type": "start",
"name": self.name,
"preset": str(preset),
"duration_minutes": duration_minutes,
"end_time": self._timed_preset_end_time.isoformat(),
"original_preset": str(self._original_preset),
},
)
# Force update of the thermostat state
self._vtherm.requested_state.force_changed()
await self._vtherm.update_states(force=True)
self._vtherm.update_custom_attributes()
return True
async def cancel_timed_preset(self) -> bool:
"""Cancel the current timed preset if active.
Returns:
True if a timed preset was cancelled, False if none was active
"""
if not self._is_timed_preset_active:
return False
await self._end_timed_preset(cancelled=True)
return True
@callback
async def _async_timed_preset_expired(self, _: datetime):
"""Called when the timed preset timer expires."""
_LOGGER.debug("%s - timed preset timer expired", self)
await self._end_timed_preset()
async def _end_timed_preset(self, cancelled: bool = False):
"""End the timed preset and restore the original preset."""
if not self._is_timed_preset_active:
return
old_preset = self._timed_preset
# Cancel the timer if still active
self._cancel_timed_preset_timer()
# Restore the original preset explicitly (handles the post-restart case)
if self._original_preset is not None:
self._vtherm.requested_state.set_preset(self._original_preset)
# Reset state
self._is_timed_preset_active = False
self._timed_preset = None
self._original_preset = None
self._timed_preset_end_time = None
write_event_log(
_LOGGER,
self._vtherm,
f"Timed preset ended: {old_preset} {'(cancelled)' if cancelled else '(expired)'}",
)
# Send an event
self._vtherm.send_event(
event_type=EventType.TIMED_PRESET_EVENT,
data={
"type": "end",
"name": self.name,
"preset": str(old_preset),
"cause": "cancelled" if cancelled else "expired",
"restored_preset": str(self._vtherm.requested_state.preset),
},
)
# Force update of the thermostat state to restore original preset
self._vtherm.requested_state.force_changed()
await self._vtherm.update_states(force=True)
self._vtherm.update_custom_attributes()
def _cancel_timed_preset_timer(self):
"""Cancel the timed preset timer if active."""
if self._cancel_timer:
self._cancel_timer()
self._cancel_timer = None
def add_custom_attributes(self, extra_state_attributes: dict[str, Any]):
"""Add some custom attributes"""
extra_state_attributes.update(
{
"timed_preset_manager": {
"is_active": self._is_timed_preset_active,
"preset": str(self._timed_preset) if self._timed_preset else None,
"original_preset": str(self._original_preset) if self._original_preset else None,
"end_time": self._timed_preset_end_time.isoformat() if self._timed_preset_end_time else None,
"remaining_time_min": self.remaining_time_min,
}
}
)
@property
def remaining_time_min(self) -> int:
"""Return the remaining time in minutes, or 0 if not active or expired."""
if not self._is_timed_preset_active or not self._timed_preset_end_time:
return 0
remaining = self._timed_preset_end_time - self._vtherm.now
remaining_minutes = remaining.total_seconds() / 60
return max(0, round(remaining_minutes))
@property
def is_timed_preset_active(self) -> bool:
"""Return True if a timed preset is currently active."""
return self._is_timed_preset_active
@property
def timed_preset(self) -> VThermPreset | None:
"""Return the current timed preset, or None if not active."""
return self._timed_preset if self._is_timed_preset_active else None
@property
def timed_preset_end_time(self) -> datetime | None:
"""Return the end time of the timed preset, or None if not active."""
return self._timed_preset_end_time if self._is_timed_preset_active else None
@overrides
@property
def is_configured(self) -> bool:
"""Return True - timed preset feature is always available."""
return True
@property
def is_detected(self) -> bool:
"""Return the overall state of the feature manager based on timed preset states"""
return self.is_timed_preset_active
def __str__(self):
return f"TimedPresetManager-{self.name}"
@@ -0,0 +1,485 @@
""" Implements the Window Feature Manager """
# pylint: disable=line-too-long
from typing import Any
from datetime import timedelta
from homeassistant.const import (
STATE_ON,
STATE_OFF,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import (
HomeAssistant,
callback,
Event,
)
from homeassistant.helpers.event import (
async_track_state_change_event,
EventStateChangedData,
async_call_later,
)
from homeassistant.exceptions import ConditionError
from homeassistant.helpers import condition
from vtherm_api.log_collector import get_vtherm_logger
from .const import * # pylint: disable=wildcard-import, unused-wildcard-import
from .commons import write_event_log
from .commons_type import ConfigData
from .vtherm_hvac_mode import VThermHvacMode
from .base_manager import BaseFeatureManager
from .open_window_algorithm import WindowOpenDetectionAlgorithm
_LOGGER = get_vtherm_logger(__name__)
class FeatureWindowManager(BaseFeatureManager):
"""The implementation of the Window feature"""
unrecorded_attributes = frozenset(
{
"window_sensor_entity_id",
"is_window_configured",
"window_delay_sec",
"window_off_delay_sec",
"window_auto_configured",
"window_auto_open_threshold",
"window_auto_close_threshold",
"window_auto_max_duration",
"window_action",
}
)
def __init__(self, vtherm: Any, hass: HomeAssistant):
"""Init of a featureManager"""
super().__init__(vtherm, hass)
self._window_sensor_entity_id: str = None
self._window_state: str = STATE_UNAVAILABLE
self._window_auto_open_threshold: float = 0
self._window_auto_close_threshold: float = 0
self._window_auto_max_duration: int = 0
self._window_auto_state: bool = False
self._window_auto_algo: WindowOpenDetectionAlgorithm = None
self._is_window_bypass: bool = False
self._window_action: str = None
self._window_delay_sec: int | None = 0
self._window_off_delay_sec: int | None = 0
self._is_configured: bool = False
self._is_window_auto_configured: bool = False
self._window_call_cancel: callable = None
@overrides
def post_init(self, entry_infos: ConfigData):
"""Reinit of the manager"""
self.dearm_window_timer()
self._window_auto_state = STATE_UNAVAILABLE
self._window_state = STATE_UNAVAILABLE
self._window_sensor_entity_id = entry_infos.get(CONF_WINDOW_SENSOR)
self._window_delay_sec = entry_infos.get(CONF_WINDOW_DELAY)
# default is the WINDOW_ON delay if not configured
self._window_off_delay_sec = entry_infos.get(CONF_WINDOW_OFF_DELAY, self._window_delay_sec)
self._window_action = (
entry_infos.get(CONF_WINDOW_ACTION) or CONF_WINDOW_TURN_OFF
)
self._window_auto_open_threshold = entry_infos.get(
CONF_WINDOW_AUTO_OPEN_THRESHOLD
)
self._window_auto_close_threshold = entry_infos.get(
CONF_WINDOW_AUTO_CLOSE_THRESHOLD
)
self._window_auto_max_duration = entry_infos.get(CONF_WINDOW_AUTO_MAX_DURATION)
use_window_feature = entry_infos.get(CONF_USE_WINDOW_FEATURE, False)
if ( # pylint: disable=too-many-boolean-expressions
use_window_feature
and self._window_sensor_entity_id is None
and self._window_auto_open_threshold is not None
and self._window_auto_open_threshold > 0.0
and self._window_auto_close_threshold is not None
and self._window_auto_max_duration is not None
and self._window_auto_max_duration > 0
and self._window_action is not None
):
self._is_window_auto_configured = True
self._window_auto_state = STATE_UNKNOWN
self._window_auto_algo = WindowOpenDetectionAlgorithm(
alert_threshold=self._window_auto_open_threshold,
end_alert_threshold=self._window_auto_close_threshold,
vtherm=self._vtherm,
)
if self._is_window_auto_configured or (
use_window_feature
and self._window_sensor_entity_id is not None
and self._window_delay_sec is not None
and self._window_action is not None
):
self._is_configured = True
self._window_state = STATE_UNKNOWN
@overrides
async def start_listening(self):
"""Start listening the underlying entity"""
# Try to get last window bypass state
old_state = await self._vtherm.async_get_last_state()
self._is_window_bypass = old_state is not None and hasattr(old_state, "attributes") and old_state.attributes.get("is_window_bypass") is True
if self._is_configured:
self.stop_listening()
if self._window_sensor_entity_id:
self.add_listener(
async_track_state_change_event(
self.hass,
[self._window_sensor_entity_id],
self._window_sensor_changed,
)
)
@overrides
def stop_listening(self):
"""Stop listening and remove the eventual timer still running"""
self.dearm_window_timer()
super().stop_listening()
def dearm_window_timer(self):
"""Dearm the eventual motion time running"""
if self._window_call_cancel:
self._window_call_cancel()
self._window_call_cancel = None
@overrides
async def refresh_state(self) -> bool:
"""Tries to get the last state from sensor
Returns True if a change has been made"""
ret = False
if self._is_configured and self._window_sensor_entity_id is not None:
window_state = self.hass.states.get(self._window_sensor_entity_id)
if window_state and window_state.state not in (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
):
_LOGGER.debug(
"%s - Window state have been retrieved: %s",
self,
self._window_state,
)
# recalculate the right target_temp in activity mode
ret = await self.update_window_state(window_state.state)
return ret
@callback
async def _window_sensor_changed(self, event: Event[EventStateChangedData]):
"""Handle window sensor changes."""
new_state = event.data.get("new_state")
old_state = event.data.get("old_state")
write_event_log(_LOGGER, self._vtherm, f"Window sensor changed to state {new_state.state if new_state else None}")
# Check delay condition
async def try_window_condition(_):
try:
long_enough = condition.state(
self._hass,
self._window_sensor_entity_id,
new_state.state,
timedelta(seconds=delay),
)
except ConditionError:
long_enough = False
if not long_enough:
_LOGGER.debug("%s - Window delay condition is not satisfied. Ignore window event", self)
self._window_state = old_state.state or STATE_OFF
return
_LOGGER.debug("%s - Window delay condition is satisfied", self)
if self._window_state == new_state.state:
_LOGGER.debug("%s - no change in window state. Forget the event", self)
return
_LOGGER.debug("%s - Window ByPass is : %s", self, self._is_window_bypass)
if self._is_window_bypass:
_LOGGER.info(
"%s - Window ByPass is activated. Ignore window event", self
)
# We change the state but we don't apply the change
self._window_state = new_state.state
else:
await self.update_window_state(new_state.state)
self._vtherm.update_custom_attributes()
delay = self._window_delay_sec if new_state.state == STATE_ON else self._window_off_delay_sec
if new_state is None or old_state is None or new_state.state == old_state.state:
return try_window_condition
self.dearm_window_timer()
self._window_call_cancel = async_call_later(self.hass, timedelta(seconds=delay), try_window_condition)
# For testing purpose we need to access the inner function
return try_window_condition
async def update_window_state(self, new_state: str | None = None, bypass: bool = False) -> bool:
"""Change the window detection state.
new_state is on if an open window have been detected or off else
return True if the state have changed
"""
# No changes
if (old_state := self._window_state) == new_state and not bypass:
return False
# Windows is now closed
if new_state != STATE_ON:
write_event_log(_LOGGER, self._vtherm, "Window is detected as closed.")
# Window is now opened
else:
write_event_log(_LOGGER, self._vtherm, f"Window is detected as open ({self._window_action})")
self._window_state = new_state
if old_state != new_state:
if self._vtherm.auto_start_stop_manager:
# because window may have an impact on auto-start/stop
await self._vtherm.auto_start_stop_manager.refresh_state()
self._vtherm.requested_state.force_changed()
await self._vtherm.update_states(True)
return True
return False
async def manage_window_auto(self, in_cycle=False) -> callable:
"""The management of the window auto feature
Returns the dearm function used to deactivate the window auto"""
async def dearm_window_auto(_):
"""Callback that will be called after end of WINDOW_AUTO_MAX_DURATION"""
_LOGGER.info("Unset window auto because MAX_DURATION is exceeded")
await deactivate_window_auto(auto=True)
async def deactivate_window_auto(auto=False):
"""Deactivation of the Window auto state"""
_LOGGER.warning(
"%s - End auto detection of open window slope=%.3f", self, slope
)
# Send an event
cause = "max duration expiration" if auto else "end of slope alert"
self._vtherm.send_event(
EventType.WINDOW_AUTO_EVENT,
{"type": "end", "cause": cause, "curve_slope": slope},
)
# Set attributes
self._window_auto_state = STATE_OFF
await self.update_window_state(self._window_auto_state)
# await self.restore_hvac_mode(True)
self.dearm_window_timer()
if not self._window_auto_algo:
return None
if in_cycle:
slope = self._window_auto_algo.check_age_last_measurement(
temperature=self._vtherm.ema_temperature,
datetime_now=self._vtherm.now,
)
else:
slope = self._window_auto_algo.add_temp_measurement(
temperature=self._vtherm.ema_temperature,
datetime_measure=self._vtherm.last_temperature_measure,
)
_LOGGER.debug(
"%s - Window auto is on, check the alert. last slope is %.3f",
self,
slope if slope is not None else 0.0,
)
if self.is_window_bypass or not self._is_window_auto_configured:
_LOGGER.debug(
"%s - Window auto event is ignored because bypass is ON or window auto detection is disabled",
self,
)
return None
if self._window_auto_algo.is_window_open_detected() and self._window_auto_state in [STATE_UNKNOWN, STATE_OFF] and self._vtherm.hvac_mode != VThermHvacMode_OFF:
if (
self._vtherm.has_prop
and self._vtherm.proportional_algorithm # Added check to avoid initialization issues
and self._vtherm.safe_on_percent <= 0.0
):
_LOGGER.info(
"%s - Start auto detection of open window slope=%.3f but no heating detected (on_percent<=0). Forget the event",
self,
slope,
)
return dearm_window_auto
_LOGGER.warning(
"%s - Start auto detection of open window slope=%.3f", self, slope
)
# Send an event
self._vtherm.send_event(
EventType.WINDOW_AUTO_EVENT,
{"type": "start", "cause": "slope alert", "curve_slope": slope},
)
# Set attributes
self._window_auto_state = STATE_ON
await self.update_window_state(self._window_auto_state)
# Arm the end trigger
self.dearm_window_timer()
self._window_call_cancel = async_call_later(
self.hass,
timedelta(minutes=self._window_auto_max_duration),
dearm_window_auto,
)
elif (
self._window_auto_algo.is_window_close_detected()
and self._window_auto_state == STATE_ON
):
await deactivate_window_auto(False)
# For testing purpose we need to return the inner function
return dearm_window_auto
def add_custom_attributes(self, extra_state_attributes: dict[str, Any]):
"""Add some custom attributes"""
extra_state_attributes.update(
{
"is_window_configured": self.is_configured,
"is_window_auto_configured": self.is_window_auto_configured,
}
)
if self.is_configured or self.is_window_auto_configured:
extra_state_attributes.update(
{
"window_manager": {
"window_state": self.window_state,
"window_auto_state": self.window_auto_state,
"window_action": self.window_action,
"is_window_bypass": self._is_window_bypass,
"window_sensor_entity_id": self._window_sensor_entity_id,
"window_delay_sec": self._window_delay_sec,
"window_off_delay_sec": self._window_off_delay_sec,
"window_auto_open_threshold": self._window_auto_open_threshold,
"window_auto_close_threshold": self._window_auto_close_threshold,
"window_auto_max_duration": self._window_auto_max_duration,
}
}
)
async def set_window_bypass(self, window_bypass: bool) -> bool:
"""Set the window bypass flag
Return True if state have been changed"""
self._is_window_bypass = window_bypass
_LOGGER.info("%s - Last window state was %s & ByPass is now %s.",self,self._window_state,self._is_window_bypass,)
self._vtherm.requested_state.force_changed()
await self._vtherm.update_states(True)
@overrides
@property
def is_configured(self) -> bool:
"""Return True of the window feature is configured"""
return self._is_configured
@property
def is_window_auto_configured(self) -> bool:
"""Return True of the window automatic detection is configured"""
return self._is_window_auto_configured
@property
def window_state(self) -> str | None:
"""Return the current window state STATE_ON or STATE_OFF
or STATE_UNAVAILABLE if not configured"""
if not self._is_configured:
return STATE_UNAVAILABLE
return self._window_state
@property
def window_auto_state(self) -> str | None:
"""Return the current window auto state STATE_ON or STATE_OFF
or STATE_UNAVAILABLE if not configured"""
if not self._is_configured:
return STATE_UNAVAILABLE
return self._window_auto_state
@property
def is_window_bypass(self) -> str | None:
"""Return True if the window bypass is activated"""
if not self._is_configured:
return False
return self._is_window_bypass
@property
def is_window_detected(self) -> bool:
"""Return true if the window is configured and open and bypass is not ON"""
return self._is_configured and (
self._window_state == STATE_ON or self._window_auto_state == STATE_ON
) and not self._is_window_bypass
@property
def window_sensor_entity_id(self) -> bool:
"""Return true if the presence is configured and presence sensor is OFF"""
return self._window_sensor_entity_id
@property
def window_delay_sec(self) -> bool:
"""Return the window on delay"""
return self._window_delay_sec
@property
def window_off_delay_sec(self) -> bool:
"""Return the window off delay"""
return self._window_off_delay_sec
@property
def window_action(self) -> bool:
"""Return the window action"""
return self._window_action
@property
def window_auto_open_threshold(self) -> bool:
"""Return the window_auto_open_threshold"""
return self._window_auto_open_threshold
@property
def window_auto_close_threshold(self) -> bool:
"""Return the window_auto_close_threshold"""
return self._window_auto_close_threshold
@property
def window_auto_max_duration(self) -> bool:
"""Return the window_auto_max_duration"""
return self._window_auto_max_duration
@property
def last_slope(self) -> float:
"""Return the last slope (in °C/hour)"""
if not self._window_auto_algo:
return None
return self._window_auto_algo.last_slope
@property
def is_detected(self) -> bool:
"""Return the overall state of the feature manager based on window state"""
return self.is_window_detected
def __str__(self):
return f"WindowManager-{self.name}"
@@ -0,0 +1,18 @@
{
"entity": {
"climate": {
"versatile_thermostat": {
"state_attributes": {
"preset_mode": {
"state": {
"shedding": "mdi:power-plug-off",
"safety": "mdi:shield-alert",
"none": "mdi:knob",
"frost": "mdi:snowflake"
}
}
}
}
}
}
}
@@ -0,0 +1,139 @@
"""Building blocks for the heater switch keep-alive feature.
The heater switch keep-alive feature consists of regularly refreshing the state
of directly controlled switches at a configurable interval (regularly turning the
switch 'on' or 'off' again even if it is already turned 'on' or 'off'), just like
the keep_alive setting of Home Assistant's Generic Thermostat integration:
https://www.home-assistant.io/integrations/generic_thermostat/
"""
import logging
from vtherm_api.log_collector import get_vtherm_logger
from collections.abc import Awaitable, Callable
from datetime import timedelta, datetime
from time import monotonic
from homeassistant.core import HomeAssistant, CALLBACK_TYPE
from homeassistant.helpers.event import async_track_time_interval
_LOGGER = get_vtherm_logger(__name__)
class BackoffTimer:
"""Exponential backoff timer with a non-blocking polling-style implementation.
Usage example:
timer = BackoffTimer(multiplier=1.5, upper_limit_sec=600)
while some_condition:
if timer.is_ready():
do_something()
"""
def __init__(
self,
*,
multiplier=2.0,
lower_limit_sec=30,
upper_limit_sec=86400,
initially_ready=True,
):
"""Initialize a BackoffTimer instance.
Args:
multiplier (int, optional): Period multiplier applied when is_ready() is True.
lower_limit_sec (int, optional): Initial backoff period in seconds.
upper_limit_sec (int, optional): Maximum backoff period in seconds.
initially_ready (bool, optional): Whether is_ready() should return True the
first time it is called, or after a call to reset().
"""
self._multiplier = multiplier
self._lower_limit_sec = lower_limit_sec
self._upper_limit_sec = upper_limit_sec
self._initially_ready = initially_ready
self._timestamp = 0
self._period_sec = self._lower_limit_sec
@property
def in_progress(self) -> bool:
"""Whether the backoff timer is in progress (True after a call to is_ready())."""
return bool(self._timestamp)
def reset(self):
"""Reset a BackoffTimer instance."""
self._timestamp = 0
self._period_sec = self._lower_limit_sec
def is_ready(self) -> bool:
"""Check whether an exponentially increasing period of time has passed.
Whenever is_ready() returns True, the timer period is multiplied so that
it takes longer until is_ready() returns True again.
Returns:
bool: True if enough time has passed since one of the following events,
in relation to an instance of this class:
- The last time when this method returned True, if it ever did.
- Or else, when this method was first called after a call to reset().
- Or else, when this method was first called.
False otherwise.
"""
now = monotonic()
if self._timestamp == 0:
self._timestamp = now
return self._initially_ready
elif now - self._timestamp >= self._period_sec:
self._timestamp = now
self._period_sec = max(
self._lower_limit_sec,
min(self._upper_limit_sec, self._period_sec * self._multiplier),
)
return True
return False
class IntervalCaller:
"""Repeatedly call a given async action function at a given regular interval.
Convenience wrapper around Home Assistant's `async_track_time_interval` function.
"""
def __init__(self, hass: HomeAssistant, interval_sec: float) -> None:
self._hass = hass
self._interval_sec = interval_sec
self._remove_handle: CALLBACK_TYPE | None = None
self.backoff_timer = BackoffTimer()
@property
def interval_sec(self) -> float:
"""Return the calling interval in seconds."""
return self._interval_sec
def cancel(self):
"""Cancel the regular calls to the action function."""
if self._remove_handle:
self._remove_handle()
self._remove_handle = None
def set_async_action(self, action: Callable[[], Awaitable[None]]):
"""Set the async action function to be called at regular intervals."""
if not self._interval_sec:
return
self.cancel()
async def callback(_time: datetime):
try:
_LOGGER.debug(
"IntervalCaller - Calling keep-alive action '%s' (%ss interval)",
action.__name__,
self._interval_sec,
)
await action()
except Exception as e: # pylint: disable=broad-exception-caught
_LOGGER.error(e)
self.cancel()
self._remove_handle = async_track_time_interval(
self._hass, callback, timedelta(seconds=self._interval_sec)
)
@@ -0,0 +1,27 @@
{
"domain": "versatile_thermostat",
"name": "Versatile Thermostat",
"after_dependencies": [
"recorder",
"http"
],
"codeowners": [
"@jmcollin78"
],
"config_flow": true,
"dependencies": [],
"documentation": "https://github.com/jmcollin78/versatile_thermostat",
"homekit": {},
"integration_type": "device",
"iot_class": "calculated",
"issue_tracker": "https://github.com/jmcollin78/versatile_thermostat/issues",
"quality_scale": "silver",
"requirements": [
"numpy",
"scipy",
"vtherm_api>=0.3.0"
],
"ssdp": [],
"version": "10.0.2",
"zeroconf": []
}
@@ -0,0 +1,560 @@
# pylint: disable=unused-argument
""" Implements the VersatileThermostat select component """
import logging
from vtherm_api.log_collector import get_vtherm_logger
# from homeassistant.const import EVENT_HOMEASSISTANT_START
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, CoreState # , callback
from homeassistant.components.number import (
NumberEntity,
NumberMode,
NumberDeviceClass,
DOMAIN as NUMBER_DOMAIN,
DEFAULT_MAX_VALUE,
DEFAULT_MIN_VALUE,
DEFAULT_STEP,
)
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import slugify
from .vtherm_central_api import VersatileThermostatAPI
from .base_entity import VersatileThermostatBaseEntity
from .const import (
DOMAIN,
DEVICE_MANUFACTURER,
CONF_NAME,
CONF_THERMOSTAT_TYPE,
CONF_THERMOSTAT_CENTRAL_CONFIG,
CONF_TEMP_MIN,
CONF_TEMP_MAX,
CONF_STEP_TEMPERATURE,
CONF_AC_MODE,
CONF_PRESETS_VALUES,
CONF_PRESETS_WITH_AC_VALUES,
CONF_PRESETS_AWAY_VALUES,
CONF_PRESETS_AWAY_WITH_AC_VALUES,
CONF_USE_PRESETS_CENTRAL_CONFIG,
CONF_USE_PRESENCE_FEATURE,
CONF_USE_CENTRAL_BOILER_FEATURE,
overrides,
CONF_USE_MAIN_CENTRAL_CONFIG,
)
from .vtherm_preset import VThermPreset, VThermPresetWithAC, VThermPresetWithAway, VThermPresetWithACAway, PRESET_TEMP_SUFFIX, PRESET_AWAY_SUFFIX
PRESET_ICON_MAPPING = {
VThermPreset.FROST + PRESET_TEMP_SUFFIX: "mdi:snowflake-thermometer",
VThermPreset.ECO + PRESET_TEMP_SUFFIX: "mdi:leaf",
VThermPreset.COMFORT + PRESET_TEMP_SUFFIX: "mdi:sofa",
VThermPreset.BOOST + PRESET_TEMP_SUFFIX: "mdi:rocket-launch",
VThermPresetWithAC.ECO + PRESET_TEMP_SUFFIX: "mdi:leaf-circle-outline",
VThermPresetWithAC.COMFORT + PRESET_TEMP_SUFFIX: "mdi:sofa-outline",
VThermPresetWithAC.BOOST + PRESET_TEMP_SUFFIX: "mdi:rocket-launch-outline",
VThermPresetWithAway.FROST + PRESET_TEMP_SUFFIX: "mdi:snowflake-thermometer",
VThermPresetWithAway.ECO + PRESET_TEMP_SUFFIX: "mdi:leaf",
VThermPresetWithAway.COMFORT + PRESET_TEMP_SUFFIX: "mdi:sofa",
VThermPresetWithAway.BOOST + PRESET_TEMP_SUFFIX: "mdi:rocket-launch",
VThermPresetWithACAway.ECO + PRESET_TEMP_SUFFIX: "mdi:leaf-circle-outline",
VThermPresetWithACAway.COMFORT + PRESET_TEMP_SUFFIX: "mdi:sofa-outline",
VThermPresetWithACAway.BOOST + PRESET_TEMP_SUFFIX: "mdi:rocket-launch-outline",
}
_LOGGER = get_vtherm_logger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the VersatileThermostat selects with config flow."""
unique_id = entry.entry_id
name = entry.data.get(CONF_NAME)
_LOGGER.debug("%s - Calling async_setup_entry entry=%s, data=%s", name, entry.entry_id, entry.data)
vt_type = entry.data.get(CONF_THERMOSTAT_TYPE)
# is_central_boiler = entry.data.get(CONF_USE_CENTRAL_BOILER_FEATURE)
entities = []
if vt_type != CONF_THERMOSTAT_CENTRAL_CONFIG:
# Creates non central temperature entities
if not entry.data.get(CONF_USE_PRESETS_CENTRAL_CONFIG, False):
if entry.data.get(CONF_AC_MODE, False):
for preset in CONF_PRESETS_WITH_AC_VALUES:
_LOGGER.debug(
"%s - configuring Number non central, AC, non AWAY for preset %s",
name,
preset,
)
entities.append(
TemperatureNumber(
hass, unique_id, name, preset, True, False, entry.data
)
)
else:
for preset in CONF_PRESETS_VALUES:
_LOGGER.debug(
"%s - configuring Number non central, non AC, non AWAY for preset %s",
name,
preset,
)
entities.append(
TemperatureNumber(
hass, unique_id, name, preset, False, False, entry.data
)
)
if entry.data.get(CONF_USE_PRESENCE_FEATURE, False) is True:
if entry.data.get(CONF_AC_MODE, False):
for preset in CONF_PRESETS_AWAY_WITH_AC_VALUES:
_LOGGER.debug(
"%s - configuring Number non central, AC, AWAY for preset %s",
name,
preset,
)
entities.append(TemperatureNumber(hass, unique_id, name, preset, True, True, entry.data))
else:
for preset in CONF_PRESETS_AWAY_VALUES:
_LOGGER.debug(
"%s - configuring Number non central, non AC, AWAY for preset %s",
name,
preset,
)
entities.append(TemperatureNumber(hass, unique_id, name, preset, False, True, entry.data))
# For central config only
else:
if entry.data.get(CONF_USE_CENTRAL_BOILER_FEATURE):
entities.append(ActivateBoilerThresholdNumber(hass, unique_id, name, entry.data))
entities.append(ActivateBoilerPowerThresholdNumber(hass, unique_id, name, entry.data))
for preset in CONF_PRESETS_WITH_AC_VALUES:
_LOGGER.debug(
"%s - configuring Number central, AC, non AWAY for preset %s",
name,
preset,
)
entities.append(
CentralConfigTemperatureNumber(
hass, unique_id, name, preset, True, False, entry.data
)
)
for preset in CONF_PRESETS_AWAY_WITH_AC_VALUES:
_LOGGER.debug(
"%s - configuring Number central, AC, AWAY for preset %s", name, preset
)
entities.append(
CentralConfigTemperatureNumber(
hass, unique_id, name, preset, True, True, entry.data
)
)
if len(entities) > 0:
async_add_entities(entities, True)
class ActivateBoilerThresholdNumber(
NumberEntity, RestoreEntity
): # pylint: disable=abstract-method
"""Representation of the threshold of the number of VTherm
which should be active to activate the boiler"""
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
"""Initialize the energy sensor"""
self._hass = hass
self._config_id = unique_id
self._device_name = entry_infos.get(CONF_NAME)
self._attr_name = "Number activation threshold"
self._attr_unique_id = "boiler_activation_threshold"
self._attr_value = self._attr_native_value = 0 # default value
self._attr_native_min_value = 0
self._attr_native_max_value = 9
self._attr_step = 1 # default value
self._attr_mode = NumberMode.AUTO
@property
def icon(self) -> str | None:
if isinstance(self._attr_native_value, int):
val = int(self._attr_native_value)
return f"mdi:numeric-{val}-box-outline"
else:
return "mdi:numeric-0-box-outline"
@property
def device_info(self) -> DeviceInfo:
"""Return the device info."""
return DeviceInfo(
entry_type=None,
identifiers={(DOMAIN, self._config_id)},
name=self._device_name,
manufacturer=DEVICE_MANUFACTURER,
model=DOMAIN,
)
@overrides
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(self._hass)
api.central_boiler_manager.register_central_boiler_activation_number_threshold(self)
old_state: CoreState = await self.async_get_last_state()
_LOGGER.debug(
"%s - Calling async_added_to_hass old_state is %s", self, old_state
)
if old_state is not None:
self._attr_value = self._attr_native_value = int(float(old_state.state))
@overrides
def set_native_value(self, value: float) -> None:
"""Change the value"""
int_value = int(value)
old_value = int(self._attr_native_value)
if int_value == old_value:
return
self._attr_value = self._attr_native_value = int_value
self.hass.create_task(VersatileThermostatAPI.get_vtherm_api(self._hass).central_boiler_manager.refresh_central_boiler_custom_attributes())
def __str__(self):
return f"VersatileThermostat-{self.name}"
class ActivateBoilerPowerThresholdNumber(NumberEntity, RestoreEntity): # pylint: disable=abstract-method
"""Representation of the threshold of the total power of VTherm
which should be active to activate the boiler"""
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
"""Initialize the energy sensor"""
self._hass = hass
self._config_id = unique_id
self._device_name = entry_infos.get(CONF_NAME)
self._attr_name = "Power activation threshold"
self._attr_unique_id = "boiler_power_activation_threshold"
self._attr_value = self._attr_native_value = 0 # default value
self._attr_native_min_value = 0
self._attr_native_max_value = 10000 # for people who works in Watts
self._attr_native_step = 0.1
self._attr_mode = NumberMode.AUTO
@property
def icon(self) -> str | None:
return "mdi:water-boiler-auto"
@property
def device_info(self) -> DeviceInfo:
"""Return the device info."""
return DeviceInfo(
entry_type=None,
identifiers={(DOMAIN, self._config_id)},
name=self._device_name,
manufacturer=DEVICE_MANUFACTURER,
model=DOMAIN,
)
@overrides
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(self._hass)
api.central_boiler_manager.register_central_boiler_power_activation_threshold(self)
old_state: CoreState = await self.async_get_last_state()
_LOGGER.debug("%s - Calling async_added_to_hass old_state is %s", self, old_state)
if old_state is not None:
self._attr_value = self._attr_native_value = float(old_state.state)
@overrides
def set_native_value(self, value: float) -> None:
"""Change the value"""
float_value = float(value)
old_value = float(self._attr_native_value)
if float_value == old_value:
return
self._attr_value = self._attr_native_value = float_value
self.hass.create_task(VersatileThermostatAPI.get_vtherm_api(self._hass).central_boiler_manager.refresh_central_boiler_custom_attributes())
def __str__(self):
return f"VersatileThermostat-{self.name}"
class CentralConfigTemperatureNumber(
NumberEntity, RestoreEntity
): # pylint: disable=abstract-method
"""Representation of one temperature number"""
_attr_has_entity_name = True
def __init__(
self,
hass: HomeAssistant,
unique_id,
name,
preset_name,
is_ac,
is_away,
entry_infos,
) -> None:
"""Initialize the temperature with entry_infos if available. Else
the restoration will do the trick."""
self._config_id = unique_id
self._device_name = name
# self._attr_name = name
self._attr_translation_key = preset_name
self.entity_id = f"{NUMBER_DOMAIN}.{slugify(name)}_preset_{preset_name}"
self._attr_unique_id = f"central_configuration_preset_{preset_name}"
self._attr_device_class = NumberDeviceClass.TEMPERATURE
self._attr_native_unit_of_measurement = hass.config.units.temperature_unit
self._attr_native_step = entry_infos.get(CONF_STEP_TEMPERATURE, 0.5)
self._attr_native_min_value = entry_infos.get(CONF_TEMP_MIN)
self._attr_native_max_value = entry_infos.get(CONF_TEMP_MAX)
# Initialize the values if included into the entry_infos. This will do
# the temperature migration. Else the temperature will be restored from
# previous value
# TODO remove this after the next major release and just keep the init min/max
temp = None
if (temp := entry_infos.get(preset_name, None)) is not None:
self._attr_value = self._attr_native_value = temp
else:
if entry_infos.get(CONF_AC_MODE) is True:
self._attr_native_value = self._attr_native_max_value
else:
self._attr_native_value = self._attr_native_min_value
self._attr_mode = NumberMode.BOX
self._preset_name = preset_name
self._is_away = is_away
self._is_ac = is_ac
@property
def icon(self) -> str | None:
return PRESET_ICON_MAPPING[self._preset_name]
@property
def device_info(self) -> DeviceInfo:
"""Return the device info."""
return DeviceInfo(
entry_type=None,
identifiers={(DOMAIN, self._config_id)},
name=self._device_name,
manufacturer=DEVICE_MANUFACTURER,
model=DOMAIN,
)
@overrides
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
# register the temp entity for this device and preset
api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(self.hass)
api.register_temperature_number(self._config_id, self._preset_name, self)
# Restore value from previous one if exists
old_state: CoreState = await self.async_get_last_state()
_LOGGER.debug(
"%s - Calling async_added_to_hass old_state is %s", self, old_state
)
try:
if old_state is not None and ((value := float(old_state.state)) > 0):
self._attr_value = self._attr_native_value = value
except ValueError:
pass
@overrides
async def async_set_native_value(self, value: float) -> None:
"""The value have change from the Number Entity in UI"""
float_value = float(value)
old_value = (
None if self._attr_native_value is None else float(self._attr_native_value)
)
if float_value == old_value:
return
self._attr_value = self._attr_native_value = float_value
# persist the value
self.async_write_ha_state()
api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(self.hass)
api.register_temperature_number(self._config_id, self._preset_name, self)
# We have to reload all VTherm for which uses the central configuration
# Update the VTherms which have temperature in central config
self.hass.create_task(api.init_vtherm_preset_with_central())
def __str__(self):
return f"VersatileThermostat-{self.name}"
@property
def native_unit_of_measurement(self) -> str | None:
"""The unit of measurement"""
return self.hass.config.units.temperature_unit
class TemperatureNumber( # pylint: disable=abstract-method
VersatileThermostatBaseEntity, NumberEntity, RestoreEntity
):
"""Representation of one temperature number"""
_attr_has_entity_name = True
def __init__(
self,
hass: HomeAssistant,
unique_id,
name,
preset_name,
is_ac,
is_away,
entry_infos,
) -> None:
"""Initialize the temperature with entry_infos if available. Else
the restoration will do the trick."""
super().__init__(hass, unique_id, name)
self._attr_translation_key = preset_name
self.entity_id = f"{NUMBER_DOMAIN}.{slugify(name)}_preset_{preset_name}"
self._attr_unique_id = f"{self._device_name}_preset_{preset_name}"
self._attr_device_class = NumberDeviceClass.TEMPERATURE
self._attr_entity_category = EntityCategory.CONFIG
self._attr_native_unit_of_measurement = hass.config.units.temperature_unit
self._has_central_main_attributes = entry_infos.get(
CONF_USE_MAIN_CENTRAL_CONFIG, False
)
self.init_min_max_step(entry_infos)
# Initialize the values if included into the entry_infos. This will do
# the temperature migration.
temp = None
if (temp := entry_infos.get(preset_name, None)) is not None:
self._attr_value = self._attr_native_value = temp
else:
if entry_infos.get(CONF_AC_MODE) is True:
self._attr_native_value = self._attr_native_max_value
else:
self._attr_native_value = self._attr_native_min_value
self._attr_mode = NumberMode.BOX
self._preset_name = preset_name
self._canonical_preset_name = preset_name.replace(
PRESET_TEMP_SUFFIX, ""
).replace(PRESET_AWAY_SUFFIX, "")
self._is_away = is_away
self._is_ac = is_ac
@property
def icon(self) -> str | None:
return PRESET_ICON_MAPPING[self._preset_name]
@overrides
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
# register the temp entity for this device and preset
api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(self.hass)
api.register_temperature_number(self._config_id, self._preset_name, self)
old_state: CoreState = await self.async_get_last_state()
_LOGGER.debug(
"%s - Calling async_added_to_hass old_state is %s", self, old_state
)
try:
if old_state is not None and ((value := float(old_state.state)) > 0):
self._attr_value = self._attr_native_value = value
except ValueError:
pass
@overrides
def my_climate_is_initialized(self):
"""Called when the associated climate is initialized"""
self._attr_native_step = self.my_climate.target_temperature_step
self._attr_native_min_value = self.my_climate.min_temp
self._attr_native_max_value = self.my_climate.max_temp
return
@overrides
async def async_set_native_value(self, value: float) -> None:
"""Change the value"""
if self.my_climate is None:
_LOGGER.warning(
"%s - cannot change temperature because VTherm is not initialized", self
)
return
float_value = float(value)
old_value = (
None if self._attr_native_value is None else float(self._attr_native_value)
)
if float_value == old_value:
return
self._attr_value = self._attr_native_value = float_value
self.async_write_ha_state()
# Update the VTherm temp
self.hass.create_task(
self.my_climate.set_preset_temperature(
self._canonical_preset_name,
self._attr_native_value if not self._is_away else None,
self._attr_native_value if self._is_away else None,
)
)
# We set the min, max and step from central config if relevant because it is possible
# that central config was not loaded at startup
self.init_min_max_step()
def __str__(self):
return f"VersatileThermostat-{self.name}"
@property
def native_unit_of_measurement(self) -> str | None:
"""The unit of measurement"""
if not self.my_climate:
return self.hass.config.units.temperature_unit
return self.my_climate.temperature_unit
def init_min_max_step(self, entry_infos=None):
"""Initialize min, max and step value from config or from central config"""
if self._has_central_main_attributes:
vthermapi: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api()
central_config = vthermapi.find_central_configuration()
if central_config:
self._attr_native_step = central_config.data.get(CONF_STEP_TEMPERATURE)
self._attr_native_min_value = central_config.data.get(CONF_TEMP_MIN)
self._attr_native_max_value = central_config.data.get(CONF_TEMP_MAX)
return
if entry_infos:
self._attr_native_step = entry_infos.get(
CONF_STEP_TEMPERATURE, DEFAULT_STEP
)
self._attr_native_min_value = entry_infos.get(
CONF_TEMP_MIN, DEFAULT_MIN_VALUE
)
self._attr_native_max_value = entry_infos.get(
CONF_TEMP_MAX, DEFAULT_MAX_VALUE
)
@@ -0,0 +1,154 @@
# pylint: disable=line-too-long
""" This file implements the Open Window by temperature algorithm
This algo works the following way:
- each time a new temperature is measured
- calculate the slope of the temperature curve. For this we calculate the slope(t) = 1/2 slope(t-1) + 1/2 * dTemp / dt
- if the slope is lower than a threshold the window opens alert is notified
- if the slope regain positive the end of the window open alert is notified
"""
import logging
from vtherm_api.log_collector import get_vtherm_logger
from datetime import datetime
_LOGGER = get_vtherm_logger(__name__)
# To filter bad values
MIN_DELTA_T_SEC = 0 # two temp mesure should be > 0 sec
MAX_SLOPE_VALUE = (
120 # slope cannot be > 2°/min or < -2°/min -> else this is an aberrant point
)
MAX_DURATION_MIN = 30 # a fake data point is added in the cycle if last measurement was older than 30 min
MIN_NB_POINT = 4 # do not calculate slope until we have enough point
class WindowOpenDetectionAlgorithm:
"""The class that implements the algorithm listed above"""
def __init__(self, alert_threshold, end_alert_threshold, vtherm=None) -> None:
"""Initalize a new algorithm with the both threshold"""
self._alert_threshold: float = alert_threshold
self._end_alert_threshold: float = end_alert_threshold
self._last_slope: float | None = None
self._last_datetime: datetime = None
self._last_temperature: float | None = None
self._nb_point: int = 0
self._vtherm = vtherm
def check_age_last_measurement(self, temperature, datetime_now) -> float:
""" " Check if last measurement is old and add
a fake measurement point if this is the case
"""
if self._last_datetime is None:
return self.add_temp_measurement(temperature, datetime_now)
delta_t_sec = float((datetime_now - self._last_datetime).total_seconds()) / 60.0
if delta_t_sec >= MAX_DURATION_MIN:
return self.add_temp_measurement(temperature, datetime_now, False)
else:
# do nothing
return self._last_slope
def add_temp_measurement(
self, temperature: float, datetime_measure: datetime, store_date: bool = True
) -> float:
"""Add a new temperature measurement
returns the last slope
"""
if self._last_datetime is None or self._last_temperature is None:
_LOGGER.debug("%s - First initialisation", self)
self._last_datetime = datetime_measure
self._last_temperature = temperature
self._nb_point = self._nb_point + 1
return None
_LOGGER.debug(
"%s - We are already initialized slope=%s last_temp=%0.2f",
self,
self._last_slope,
self._last_temperature,
)
lspe = self._last_slope
delta_t_sec = float((datetime_measure - self._last_datetime).total_seconds())
delta_t = delta_t_sec / 60.0
if delta_t_sec <= MIN_DELTA_T_SEC:
_LOGGER.debug(
"%s - Delta t is %d < %d which should be not possible. We don't consider this value",
self,
delta_t_sec,
MIN_DELTA_T_SEC,
)
return lspe
delta_t_hour = delta_t / 60.0
delta_temp = float(temperature - self._last_temperature)
new_slope = delta_temp / delta_t_hour
if new_slope > MAX_SLOPE_VALUE or new_slope < -MAX_SLOPE_VALUE:
_LOGGER.debug(
"%s - New_slope is abs(%.2f) > %.2f which should be not possible. We don't consider this value",
self,
new_slope,
MAX_SLOPE_VALUE,
)
return lspe
if self._last_slope is None:
self._last_slope = round(new_slope, 2)
else:
self._last_slope = round((0.2 * self._last_slope) + (0.8 * new_slope), 2)
# if we are in cycle check and so adding a fake datapoint, we don't store the event datetime
# so that, when we will receive a real temperature point we will not calculate a wrong slope
if store_date:
self._last_datetime = datetime_measure
self._last_temperature = temperature
self._nb_point = self._nb_point + 1
_LOGGER.debug(
"%s - delta_t=%.3f delta_temp=%.3f new_slope=%.3f last_slope=%s slope=%.3f nb_point=%s",
self,
delta_t,
delta_temp,
new_slope,
lspe,
self._last_slope,
self._nb_point,
)
return self._last_slope
def is_window_open_detected(self) -> bool:
"""True if the last calculated slope is under (because negative value) the _alert_threshold"""
if self._alert_threshold is None:
return False
if self._nb_point < MIN_NB_POINT or self._last_slope is None:
return False
return self._last_slope < -self._alert_threshold
def is_window_close_detected(self) -> bool:
"""True if the last calculated slope is above (cause negative) the _end_alert_threshold"""
if self._end_alert_threshold is None:
return False
if self._nb_point < MIN_NB_POINT or self._last_slope is None:
return False
return self._last_slope >= self._end_alert_threshold
@property
def last_slope(self) -> float:
"""Return the last calculated slope"""
return self._last_slope
def __str__(self) -> str:
if self._vtherm and hasattr(self._vtherm, "name"):
return f"{self._vtherm.name}-WindowOpenDetectionAlgorithm"
else:
return f"UnknownVTherm-WindowOpenDetectionAlgorithm"
@@ -0,0 +1,71 @@
""" This class aims to calculate the opening/closing degree of a valve.
See: https://github.com/jmcollin78/versatile_thermostat/issues/1220 """
import logging
from vtherm_api.log_collector import get_vtherm_logger
_LOGGER = get_vtherm_logger(__name__)
class OpeningClosingDegreeCalculation:
"""Class to calculate the opening/closing degree of a valve."""
@staticmethod
def calculate_opening_closing_degree(
brut_valve_open_percent: float,
min_opening_degree: float,
max_closing_degree: float,
max_opening_degree: float,
opening_threshold: float
) -> float:
"""
Calculate the opening/closing degree based on parameters. See explanation on README
Args:
brut_valve_open_percent: Raw valve opening percentage (0-100)
min_opening_degree: Minimum opening degree
max_closing_degree: Maximum closing degree
max_opening_degree: Maximum opening degree
opening_threshold: Opening threshold
Returns:
float: The calculated opening degree
float: The calculated closing degree
"""
_LOGGER.debug(
"Calculate opening/closing degree - Input: brut_valve_open_percent=%.2f, min_opening_degree=%.2f, max_closing_degree=%.2f",
brut_valve_open_percent,
min_opening_degree,
max_closing_degree
)
# for direct test. Already done in underlyings.py
if min_opening_degree >= max_opening_degree:
min_opening_degree = opening_threshold
# clamp the brut_valve_open_percent to be within 0 and 100
brut_valve_open_percent = max(0, min(100, brut_valve_open_percent))
# normalize to 0-1 range
bvop = brut_valve_open_percent / 100.0
min_od = min_opening_degree / 100.0
max_cd = max_closing_degree / 100.0
max_od = max_opening_degree / 100.0
ot = opening_threshold / 100.0
# if heating need is >= opening_threshold (and heating is > 0) -> open and calculate with interpolation,
if bvop >= ot and bvop > 0:
# interpolation is just here to normalize the max opening which can be != 100. Some TRV has a max which not 100
slope = (max_od - min_od) / (1 - ot)
calculated_degree = min_od + slope * (bvop - ot)
else:
calculated_degree = 1 - max_cd
# set to base 100
calculated_degree = round(calculated_degree * 100)
_LOGGER.debug(
"Calculate opening/closing degree - Output: calculated_degree=%.2f %%",
calculated_degree,
)
return calculated_degree, 100 - calculated_degree
@@ -0,0 +1,124 @@
# pylint: disable=line-too-long
""" The PI algorithm implementation """
import logging
from vtherm_api.log_collector import get_vtherm_logger
_LOGGER = get_vtherm_logger(__name__)
class PITemperatureRegulator:
"""A class implementing a PI Algorithm
PI algorithms calculate a target temperature by adding an offset which is calculating as follow:
- offset = kp * error + ki * accumulated_error
To use it you must:
- instanciate the class and gives the algorithm parameters: kp, ki, offset_max, accumulated_error_threshold, overheat_protection
- call calculate_regulated_temperature with the internal, external temperature and time_delta. Time_delta is 1.0 for a standard regulation cycle.
- call set_target_temp when the target temperature change.
"""
def __init__(
self,
target_temp: float,
kp: float,
ki: float,
k_ext: float,
offset_max: float,
accumulated_error_threshold: float,
overheat_protection: bool,
):
self.target_temp: float = target_temp
self.kp: float = kp # proportionnel gain
self.ki: float = ki # integral gain
self.k_ext: float = k_ext # exterior gain
self.offset_max: float = offset_max
self.accumulated_error: float = 0
self.accumulated_error_threshold: float = accumulated_error_threshold
self.overheat_protection: bool = overheat_protection
def reset_accumulated_error(self):
"""Reset the accumulated error"""
self.accumulated_error = 0
def set_accumulated_error(self, accumulated_error):
"""Allow to persist and restore the accumulated_error"""
self.accumulated_error = accumulated_error
def set_target_temp(self, target_temp):
"""Set the new target_temp"""
self.target_temp = target_temp
# Discussion #191. After a target change we should reset the accumulated error which is certainly wrong now.
# Discussion #384. Finally don't reset the accumulated error but smoothly reset it if the sign is inversed
# if self.accumulated_error < 0:
# self.accumulated_error = 0
def calculate_regulated_temperature(self, room_temp: float | None, external_temp: float | None, time_delta: float): # pylint: disable=unused-argument
"""Calculate a new target_temp given some temperature"""
if room_temp is None:
_LOGGER.warning(
"Temporarily skipping the self-regulation algorithm while the configured sensor for room temperature is unavailable"
)
return self.target_temp
if external_temp is None:
_LOGGER.warning(
"Temporarily skipping the self-regulation algorithm while the configured sensor for outdoor temperature is unavailable"
)
return self.target_temp
if time_delta > 2.0:
# When the HVAC mode is off (by on/off or manual change), the PI algorithm is not run. time_delta can be
# very high and broke the algo.
# It should never be higher than 1 on normal run but sometime the asyncio have a small latency on system
# with low performance. Allowing a value a little over 1 give a better response.
# Until 2.0, the resulted offset should be good. Upper it can unbalance the regulation so it is caped to
# 1.0.
_LOGGER.info(
"The time delta (%.2f) is too high for the self-regulation algorithm. Capping to 1.0.",
time_delta,
)
time_delta = 1.0
# Calculate the error factor (P)
error = self.target_temp - room_temp
# Calculate the sum of error (I)
# Discussion #384. Finally don't reset the accumulated error but smoothly reset it if the sign is inversed
# If the error have change its sign, reset smoothly the accumulated error
if self.overheat_protection and error * self.accumulated_error < 0:
self.accumulated_error = self.accumulated_error / (2.0 * time_delta)
self.accumulated_error += error * time_delta
# Capping of the error
self.accumulated_error = min(
self.accumulated_error_threshold,
max(-self.accumulated_error_threshold, self.accumulated_error),
)
# Calculate the offset (proportionnel + intégral)
offset = self.kp * error + self.ki * self.accumulated_error
# Calculate the exterior offset
offset_ext = self.k_ext * (room_temp - external_temp)
# Capping of offset
total_offset = offset + offset_ext
total_offset = min(self.offset_max, max(-self.offset_max, total_offset))
result = round(self.target_temp + total_offset, 1)
_LOGGER.debug(
"PITemperatureRegulator - Error: %.2f accumulated_error: %.2f (overheat protection %s and delta %.2f) offset: %.2f offset_ext: %.2f target_tem: %.1f regulatedTemp: %.1f",
error,
self.accumulated_error,
self.overheat_protection,
time_delta,
offset,
offset_ext,
self.target_temp,
result,
)
return result
@@ -0,0 +1,227 @@
""" The TPI calculation module """
# pylint: disable='line-too-long'
import logging
from vtherm_api.log_collector import get_vtherm_logger
from .vtherm_hvac_mode import VThermHvacMode, VThermHvacMode_OFF, VThermHvacMode_COOL, VThermHvacMode_SLEEP
_LOGGER = get_vtherm_logger(__name__)
def is_number(value):
"""check if value is a number"""
return isinstance(value, (int, float))
class TpiAlgorithm:
"""This class aims to do all calculation of the Proportional alogorithm"""
def __init__(
self,
tpi_coef_int,
tpi_coef_ext,
vtherm_entity_id: str = None,
max_on_percent: float = None,
tpi_threshold_low: float = 0.0,
tpi_threshold_high: float = 0.0,
) -> None:
"""Initialisation of the Proportional Algorithm"""
_LOGGER.debug(
"%s - Creation new TpiAlgorithm tpi_coef_int: %s, tpi_coef_ext: %s, tpi_threshold_low=%s, tpi_threshold_high=%s", # pylint: disable=line-too-long
vtherm_entity_id,
tpi_coef_int,
tpi_coef_ext,
tpi_threshold_low,
tpi_threshold_high,
)
# Issue 506 - check parameters
if (
vtherm_entity_id is None
or not is_number(tpi_coef_int)
or not is_number(tpi_coef_ext)
):
_LOGGER.error(
"%s - configuration is wrong. entity_id is %s, tpi_coef_int is %s, tpi_coef_ext is %s",
vtherm_entity_id,
vtherm_entity_id,
tpi_coef_int,
tpi_coef_ext,
)
raise TypeError(
"TPI parameters are not set correctly. VTherm will not work as expected. Please reconfigure it correctly. See previous log for values"
)
self._vtherm_entity_id = vtherm_entity_id
self._tpi_coef_int = tpi_coef_int
self._tpi_coef_ext = tpi_coef_ext
self._on_percent = 0
self._calculated_on_percent = 0
self._total_on_percent = 0
self._max_on_percent = max_on_percent
self._tpi_threshold_low = tpi_threshold_low
self._tpi_threshold_high = tpi_threshold_high
self._apply_threshold = tpi_threshold_low != 0.0 and tpi_threshold_high != 0.0
# True once calculate() has been called with a valid temperature.
# When False, on_percent returns None so the cycle scheduler
# does not touch an already-active switch at startup.
self._temperature_available: bool = False
def calculate(
self,
target_temp: float | None,
current_temp: float | None,
ext_current_temp: float | None,
slope: float | None,
hvac_mode: VThermHvacMode,
**kwargs,
):
"""Do the calculation of the duration"""
if target_temp is None or current_temp is None:
log = _LOGGER.debug if hvac_mode == VThermHvacMode_OFF else _LOGGER.warning
log(
"%s - Proportional algorithm: calculation is not possible cause target_temp (%s) or current_temp (%s) is null. Heating/cooling will be disabled. This could be normal at startup", # pylint: disable=line-too-long
self._vtherm_entity_id,
target_temp,
current_temp,
)
self._calculated_on_percent = 0
self._temperature_available = False
else:
self._temperature_available = True
if hvac_mode == VThermHvacMode_COOL:
delta_temp = current_temp - target_temp
delta_ext_temp = ext_current_temp - target_temp if ext_current_temp is not None else 0
slope = -slope if slope is not None else None
else:
delta_temp = target_temp - current_temp
delta_ext_temp = target_temp - ext_current_temp if ext_current_temp is not None else 0
# Apply thresholds
if (
# fmt: off
self._apply_threshold
and slope is not None
and ((slope > 0.0 and -delta_temp > self._tpi_threshold_high)
or (slope < 0.0 and -delta_temp > self._tpi_threshold_low))
# fmt: on
):
_LOGGER.debug(
"%s - Proportional algorithm: on_percent is forced to 0 cause current_temp (%.1f) is outside the thresholds (slope=%.1f, target_temp=%.1f, tpi_threshold_low=%.1f, tpi_threshold_high=%.1f). Heating/cooling will be disabled.", # pylint: disable=line-too-long
self._vtherm_entity_id,
current_temp,
slope,
target_temp,
self._tpi_threshold_low,
self._tpi_threshold_high,
)
self._calculated_on_percent = 0
else:
if hvac_mode not in [VThermHvacMode_OFF, VThermHvacMode_SLEEP]:
self._calculated_on_percent = self._tpi_coef_int * delta_temp + self._tpi_coef_ext * delta_ext_temp
# calculated on_time duration in seconds
if self._calculated_on_percent > 1:
self._calculated_on_percent = 1
if self._calculated_on_percent < 0:
self._calculated_on_percent = 0
if self._max_on_percent is not None and self._calculated_on_percent > self._max_on_percent:
self._calculated_on_percent = self._max_on_percent
else:
_LOGGER.debug(
"%s - Proportional algorithm: VTherm is off. Heating will be disabled",
self._vtherm_entity_id,
)
self._calculated_on_percent = 0
_LOGGER.debug(
"%s - heating percent calculated for current_temp %.1f, ext_current_temp %.1f and target_temp %.1f is %.2f", # pylint: disable=line-too-long
self._vtherm_entity_id,
current_temp if current_temp else -9999.0,
ext_current_temp if ext_current_temp else -9999.0,
target_temp if target_temp else -9999.0,
self._calculated_on_percent,
)
def update_parameters(
self,
tpi_coef_int=None,
tpi_coef_ext=None,
tpi_threshold_low=None,
tpi_threshold_high=None,
):
"""Update the parameters of the algorithm"""
if tpi_coef_int is not None:
self._tpi_coef_int = tpi_coef_int
if tpi_coef_ext is not None:
self._tpi_coef_ext = tpi_coef_ext
if tpi_threshold_low is not None:
self._tpi_threshold_low = tpi_threshold_low
if tpi_threshold_high is not None:
self._tpi_threshold_high = tpi_threshold_high
self._apply_threshold = self._tpi_threshold_low != 0.0 and self._tpi_threshold_high != 0.0
_LOGGER.debug(
"%s - Parameters updated. tpi_coef_int: %s, tpi_coef_ext: %s, tpi_threshold_low: %s, tpi_threshold_high: %s",
self._vtherm_entity_id,
self._tpi_coef_int,
self._tpi_coef_ext,
self._tpi_threshold_low,
self._tpi_threshold_high,
)
def update_realized_power(self, power_percent: float):
"""Update the realized power_percent.
This method is called by the VTherm when the power is modified by safety or other features
which clamps the value or force it to 0 or 1.
"""
self._total_on_percent = power_percent
if self._total_on_percent != self._calculated_on_percent:
_LOGGER.debug(
"%s - Realized power is different from calculated power. Calculated: %.2f, Realized: %.2f",
self._vtherm_entity_id,
self._calculated_on_percent,
self._total_on_percent,
)
@property
def on_percent(self) -> float | None:
"""Returns the percentage the heater must be ON.
Returns None when no valid temperature was available at the last calculate() call.
In that case callers should preserve the current switch state rather than forcing it off.
Note: This does NOT reflect safety overrides.
Use calculated_on_percent for display or check ThermostatProp.on_percent for the effective value.
"""
if not self._temperature_available:
return None
return round(self._calculated_on_percent, 2)
@property
def calculated_on_percent(self) -> float:
"""Returns the calculated percentage the heater must be ON
Calculated means NOT overriden even in safety mode
(1 means the heater will be always on, 0 never on)""" # pylint: disable=line-too-long
return round(self._calculated_on_percent, 2)
@property
def tpi_coef_int(self) -> float:
"""Returns the TPI coefficient int"""
return self._tpi_coef_int
@property
def tpi_coef_ext(self) -> float:
"""Returns the TPI coefficient ext"""
return self._tpi_coef_ext
@property
def tpi_threshold_low(self) -> float:
"""Returns the TPI threshold low"""
return self._tpi_threshold_low
@property
def tpi_threshold_high(self) -> float:
"""Returns the TPI threshold high"""
return self._tpi_threshold_high
@@ -0,0 +1,638 @@
# pylint: disable=line-too-long, abstract-method
"""TPI algorithm handler for ThermostatProp."""
import logging
from vtherm_api.log_collector import get_vtherm_logger
from typing import Any, TYPE_CHECKING
from datetime import datetime
from homeassistant.exceptions import ServiceValidationError
from .prop_algo_tpi import TpiAlgorithm
from .auto_tpi_manager import AutoTpiManager
from .const import * # pylint: disable=wildcard-import, unused-wildcard-import
from .vtherm_hvac_mode import VThermHvacMode_OFF, VThermHvacMode_HEAT, VThermHvacMode_COOL
from .vtherm_central_api import VersatileThermostatAPI
from .commons import write_event_log
from .cycle_scheduler import calculate_cycle_times
if TYPE_CHECKING:
from .thermostat_prop import ThermostatProp
_LOGGER = get_vtherm_logger(__name__)
class TPIHandler:
"""Handler for TPI-specific logic.
This class encapsulates all TPI-specific behavior and is used
via composition by ThermostatProp. It updates thermostat attributes
directly for backward compatibility with child classes.
"""
def __init__(self, thermostat: "ThermostatProp"):
"""Initialize handler with parent thermostat reference."""
self._thermostat = thermostat
self._auto_tpi_manager: AutoTpiManager | None = None
# Save default values for Auto TPI reset
self._default_coef_int: float = 0
self._default_coef_ext: float = 0
@property
def tpi_coef_int(self) -> float:
"""Return TPI internal coefficient from thermostat."""
return self._thermostat.tpi_coef_int
@property
def tpi_coef_ext(self) -> float:
"""Return TPI external coefficient from thermostat."""
return self._thermostat.tpi_coef_ext
@property
def tpi_threshold_low(self) -> float:
"""Return TPI low threshold from thermostat."""
return self._thermostat.tpi_threshold_low
@property
def tpi_threshold_high(self) -> float:
"""Return TPI high threshold from thermostat."""
return self._thermostat.tpi_threshold_high
@property
def minimal_activation_delay(self) -> int:
"""Return minimal activation delay from thermostat."""
return self._thermostat.minimal_activation_delay
@property
def minimal_deactivation_delay(self) -> int:
"""Return minimal deactivation delay from thermostat."""
return self._thermostat.minimal_deactivation_delay
@property
def proportional_function(self) -> str | None:
"""Return proportional function from thermostat."""
return self._thermostat.proportional_function
@property
def auto_tpi_manager(self) -> AutoTpiManager | None:
"""Return the Auto TPI manager."""
return self._auto_tpi_manager
def init_algorithm(self):
"""Initialize PropAlgorithm and AutoTpiManager.
Updates thermostat attributes directly for backward compatibility.
"""
t = self._thermostat
entry = t.entry_infos
# Read and set TPI-specific config on thermostat using public setters
t.tpi_coef_int = entry.get(CONF_TPI_COEF_INT)
t.tpi_coef_ext = entry.get(CONF_TPI_COEF_EXT)
t.tpi_threshold_low = entry.get(CONF_TPI_THRESHOLD_LOW, 0.0)
t.tpi_threshold_high = entry.get(CONF_TPI_THRESHOLD_HIGH, 0.0)
t.minimal_activation_delay = entry.get(CONF_MINIMAL_ACTIVATION_DELAY, 0)
t.minimal_deactivation_delay = entry.get(CONF_MINIMAL_DEACTIVATION_DELAY, 0)
# Save default values for Auto TPI reset
self._default_coef_int = t.tpi_coef_int
self._default_coef_ext = t.tpi_coef_ext
# Validation: thresholds - if one is 0 then both are 0
if t.tpi_threshold_low == 0.0 or t.tpi_threshold_high == 0.0:
t.tpi_threshold_low = 0.0
t.tpi_threshold_high = 0.0
# Validation: delays
if (t.minimal_activation_delay + t.minimal_deactivation_delay) / 60 > t.cycle_min:
_LOGGER.warning(
"%s - The sum of minimal_activation_delay (%s sec) and "
"minimal_deactivation_delay (%s sec) is greater than cycle_min (%s). "
"This can create some unexpected behavior. Please review your configuration",
t,
t.minimal_activation_delay,
t.minimal_deactivation_delay,
t.cycle_min,
)
# Validation: external temp sensor for TPI
if (
t.proportional_function == PROPORTIONAL_FUNCTION_TPI
and t.ext_temp_sensor_entity_id is None
):
_LOGGER.warning(
"Using TPI function but not external temperature sensor is set. "
"Removing the delta temp ext factor. "
"Thermostat will not be fully operational."
)
t.tpi_coef_ext = 0
# Create TpiAlgorithm on thermostat
t.prop_algorithm = TpiAlgorithm(
t.tpi_coef_int,
t.tpi_coef_ext,
t.name,
max_on_percent=t.max_on_percent,
tpi_threshold_low=t.tpi_threshold_low,
tpi_threshold_high=t.tpi_threshold_high,
)
# Initialize Auto TPI Manager from config
heater_heating_time = entry.get(CONF_AUTO_TPI_HEATER_HEATING_TIME, 5)
heater_cooling_time = entry.get(CONF_AUTO_TPI_HEATER_COOLING_TIME, 5)
calculation_method = entry.get(CONF_AUTO_TPI_CALCULATION_METHOD, AUTO_TPI_METHOD_EMA)
ema_alpha = entry.get(CONF_AUTO_TPI_EMA_ALPHA, 0.2)
avg_initial_weight = entry.get(CONF_AUTO_TPI_AVG_INITIAL_WEIGHT, 1)
heating_rate = entry.get(CONF_AUTO_TPI_HEATING_POWER, 1.0)
cooling_rate = entry.get(CONF_AUTO_TPI_COOLING_POWER, 1.0)
aggressiveness = entry.get(CONF_AUTO_TPI_AGGRESSIVENESS, 1.0)
continuous_kext = entry.get(CONF_AUTO_TPI_CONTINUOUS_KEXT, False)
continuous_kext_alpha = entry.get(CONF_AUTO_TPI_CONTINUOUS_KEXT_ALPHA, 0.04)
_LOGGER.info("%s - DEBUG: TPI coefficients from entry_infos: int=%.3f, ext=%.3f",
t, t.tpi_coef_int, t.tpi_coef_ext)
self._auto_tpi_manager = AutoTpiManager(
t.hass,
t.config_entry,
t.unique_id,
t.name,
t.cycle_min,
t.tpi_threshold_low,
t.tpi_threshold_high,
t.minimal_deactivation_delay,
coef_int=t.tpi_coef_int,
coef_ext=t.tpi_coef_ext,
heater_heating_time=heater_heating_time,
heater_cooling_time=heater_cooling_time,
calculation_method=calculation_method,
ema_alpha=ema_alpha,
avg_initial_weight=avg_initial_weight,
heating_rate=heating_rate,
cooling_rate=cooling_rate,
aggressiveness=aggressiveness,
continuous_kext=continuous_kext,
continuous_kext_alpha=continuous_kext_alpha,
)
self._auto_tpi_manager.set_is_vtherm_stopping_callback(lambda: t.is_removed)
_LOGGER.info("%s - DEBUG: AutoTpiManager initialized with defaults: int=%.3f, ext=%.3f",
t, self._auto_tpi_manager._default_coef_int, self._auto_tpi_manager._default_coef_ext)
async def async_added_to_hass(self):
"""Load Auto TPI data."""
t = self._thermostat
if self._auto_tpi_manager:
# Set entity_id for pre-bootstrap calibration sensor lookup
self._auto_tpi_manager._entity_id = t.entity_id
_LOGGER.info("%s - DEBUG: Before load_data - int=%.3f, ext=%.3f", t, t.tpi_coef_int, t.tpi_coef_ext)
await self._auto_tpi_manager.async_load_data()
# If we have learned parameters, apply them
learned_params = self._auto_tpi_manager.get_calculated_params()
if learned_params:
_LOGGER.info("%s - DEBUG: Learned params found: %s, learning_active=%s",
t, learned_params, self._auto_tpi_manager.learning_active)
if self._auto_tpi_manager.learning_active:
t.tpi_coef_int = learned_params.get(CONF_TPI_COEF_INT, t.tpi_coef_int)
t.tpi_coef_ext = learned_params.get(CONF_TPI_COEF_EXT, t.tpi_coef_ext)
_LOGGER.info("%s - Restored Auto TPI parameters: %s", t, learned_params)
else:
_LOGGER.info("%s - Auto TPI parameters found but not applied because learning is disabled", t)
_LOGGER.info("%s - DEBUG: After load_data - int=%.3f, ext=%.3f",
t, t.tpi_coef_int, t.tpi_coef_ext)
if self._auto_tpi_manager.learning_active:
# Security: if the feature is disabled in config, we must stop learning
if not t.entry_infos.get(CONF_AUTO_TPI_MODE, False):
_LOGGER.info("%s - Auto TPI learning was active but feature is disabled in config. Stopping learning.", t)
await self._auto_tpi_manager.stop_learning()
else:
_LOGGER.info("%s - Auto TPI learning is active (restored from storage)", t)
async def async_startup(self):
"""Startup actions."""
# No more active cycle loop to start, it's now handled passively in control_heating
pass
def remove(self):
"""Cleanup on removal."""
t = self._thermostat
if self._auto_tpi_manager:
t.hass.async_create_task(self._auto_tpi_manager.async_save_data())
def on_scheduler_ready(self, scheduler) -> None:
"""Register AutoTPI learning callbacks on the cycle scheduler."""
if self._auto_tpi_manager:
scheduler.register_cycle_start_callback(self._auto_tpi_manager.on_cycle_started)
scheduler.register_cycle_end_callback(self._auto_tpi_manager.on_cycle_completed)
def should_publish_intermediate(self) -> bool:
"""TPI always publishes the current control iteration."""
return True
def _is_central_boiler_off(self) -> bool:
"""Check if the central boiler is configured but currently off."""
t = self._thermostat
if not t.is_used_by_central_boiler:
return False
api = VersatileThermostatAPI.get_vtherm_api()
if api and api.central_boiler_manager:
return not api.central_boiler_manager.is_on
return False
async def _get_tpi_data(self) -> dict[str, Any]:
"""Calculate and return TPI cycle parameters."""
t = self._thermostat
# Feed current temperatures to AutoTpiManager BEFORE getting params
if self._auto_tpi_manager:
await self._auto_tpi_manager.update(
room_temp=t.current_temperature,
ext_temp=t.current_outdoor_temperature,
target_temp=t.target_temperature,
hvac_mode=str(t.vtherm_hvac_mode),
is_overpowering_detected=t.power_manager.is_overpowering_detected,
is_central_boiler_off=self._is_central_boiler_off(),
is_heating_failure=t.heating_failure_detection_manager.is_failure_detected,
)
# Sync coefficients from AutoTpiManager before calculating
if self._auto_tpi_manager and self._auto_tpi_manager.learning_active:
new_params = await self._auto_tpi_manager.calculate()
if new_params:
new_coef_int = new_params.get(CONF_TPI_COEF_INT)
new_coef_ext = new_params.get(CONF_TPI_COEF_EXT)
if new_coef_int != t.prop_algorithm.tpi_coef_int or \
new_coef_ext != t.prop_algorithm.tpi_coef_ext:
t.prop_algorithm.update_parameters(tpi_coef_int=new_coef_int, tpi_coef_ext=new_coef_ext)
_LOGGER.debug("%s - Synced TPI coeffs before cycle: int=%.3f, ext=%.3f",
t, new_coef_int, new_coef_ext)
# Force recalculation with potentially updated coefficients
t.recalculate()
# Calculate time (in seconds)
if t.prop_algorithm:
on_time_sec, off_time_sec, forced_by_timing = calculate_cycle_times(
t.on_percent,
t.cycle_min,
t.minimal_activation_delay,
t.minimal_deactivation_delay,
)
realized_percent = on_time_sec / (t.cycle_min * 60)
# Notify prop_algorithm if forced by timing (existing behavior)
if forced_by_timing:
if t.prop_algorithm and hasattr(t.prop_algorithm, "update_realized_power"):
t.prop_algorithm.update_realized_power(realized_percent)
else:
on_time_sec = 0
off_time_sec = 0
return {
"on_time_sec": on_time_sec,
"off_time_sec": off_time_sec,
"on_percent": t.safe_on_percent,
"hvac_mode": str(t.vtherm_hvac_mode),
}
async def control_heating(self, timestamp=None, force=False):
"""TPI-specific control heating logic."""
del timestamp
t = self._thermostat
# Feed the Auto TPI manager
if self._auto_tpi_manager:
# 1. Update manager's transient state
await self._auto_tpi_manager.update(
room_temp=t.current_temperature,
ext_temp=t.current_outdoor_temperature,
target_temp=t.target_temperature,
hvac_mode=str(t.vtherm_hvac_mode),
is_overpowering_detected=t.power_manager.is_overpowering_detected,
is_central_boiler_off=self._is_central_boiler_off(),
is_heating_failure=t.heating_failure_detection_manager.is_failure_detected,
)
# 2. Synchronize parameters if learning is active
new_params = await self._auto_tpi_manager.calculate()
if self._auto_tpi_manager.learning_active and new_params:
new_coef_int = new_params.get(CONF_TPI_COEF_INT)
new_coef_ext = new_params.get(CONF_TPI_COEF_EXT)
if new_coef_int is not None and new_coef_ext is not None:
if t.prop_algorithm:
# Update effective algo parameters
t.prop_algorithm.update_parameters(tpi_coef_int=new_coef_int, tpi_coef_ext=new_coef_ext)
# Keep thermostat attributes in sync
t.tpi_coef_int = new_coef_int
t.tpi_coef_ext = new_coef_ext
_LOGGER.debug("%s - Synced PropAlgorithm with current Auto TPI coeffs: int=%.3f, ext=%.3f",
t, new_coef_int, new_coef_ext)
# Stop here if we are off
if t.vtherm_hvac_mode == VThermHvacMode_OFF:
_LOGGER.debug("%s - End of cycle (HVAC_MODE_OFF)", t)
t._on_time_sec = 0
t._off_time_sec = int(t.cycle_min * 60)
if t.is_device_active:
await t.async_underlying_entity_turn_off()
elif t.cycle_scheduler and t.cycle_scheduler.is_cycle_running:
# The master scheduler may still hold a pending heat cycle while
# the physical device is already in its OFF phase.
await t.cycle_scheduler.cancel_cycle()
else:
on_percent = 0
if t.prop_algorithm:
on_percent = t.on_percent
if on_percent is None:
# Temperature sensor was not yet available at the last
# recalculate() call (e.g. HA restart before sensor comes
# back online). Preserve the current switch state instead of
# turning it off with on_percent=0 (bug 1884).
_LOGGER.info(
"%s - on_percent is None (temperature unavailable). " "Skipping cycle to preserve current switch state.",
t,
)
return
on_time_sec, off_time_sec, forced_by_timing = calculate_cycle_times(
on_percent,
t.cycle_min,
t.minimal_activation_delay,
t.minimal_deactivation_delay,
)
realized_percent = on_time_sec / (t.cycle_min * 60)
if forced_by_timing:
if t.prop_algorithm and hasattr(t.prop_algorithm, "update_realized_power"):
t.prop_algorithm.update_realized_power(realized_percent)
await t.cycle_scheduler.start_cycle(
t.vtherm_hvac_mode,
on_percent,
force,
)
async def on_state_changed(self, changed: bool):
"""Handle state changes."""
del changed
# Cycle management is now passive, no need to start/stop loop
pass
def update_attributes(self):
"""Add TPI-specific attributes to thermostat."""
t = self._thermostat
t._attr_extra_state_attributes["specific_states"].update({
"auto_tpi_state": "on" if self._auto_tpi_manager and self._auto_tpi_manager.learning_active else "off",
"auto_tpi_continuous_kext": "on" if self._auto_tpi_manager and self._auto_tpi_manager._continuous_kext else "off",
"auto_tpi_learning": (
self._auto_tpi_manager.get_filtered_state()
if self._auto_tpi_manager and (self._auto_tpi_manager.learning_active or self._auto_tpi_manager._continuous_kext)
else {}
),
})
t._attr_extra_state_attributes["configuration"].update({
"minimal_activation_delay_sec": t.minimal_activation_delay,
"minimal_deactivation_delay_sec": t.minimal_deactivation_delay,
})
async def _async_update_tpi_config_entry(self):
"""Update the config entry with current TPI parameters."""
t = self._thermostat
entry = t.hass.config_entries.async_get_entry(t.unique_id)
if entry:
new_data = entry.data.copy()
new_data[CONF_TPI_COEF_INT] = t.tpi_coef_int
new_data[CONF_TPI_COEF_EXT] = t.tpi_coef_ext
new_data[CONF_TPI_THRESHOLD_LOW] = t.tpi_threshold_low
new_data[CONF_TPI_THRESHOLD_HIGH] = t.tpi_threshold_high
new_data[CONF_MINIMAL_ACTIVATION_DELAY] = t.minimal_activation_delay
new_data[CONF_MINIMAL_DEACTIVATION_DELAY] = t.minimal_deactivation_delay
result = t.hass.config_entries.async_update_entry(entry, data=new_data)
_LOGGER.debug("%s - Config entry updated with new TPI params: %s", t, result)
async def service_set_tpi_parameters(
self,
tpi_coef_int: float | None = None,
tpi_coef_ext: float | None = None,
minimal_activation_delay: int | None = None,
minimal_deactivation_delay: int | None = None,
tpi_threshold_low: float | None = None,
tpi_threshold_high: float | None = None,
):
"""Service handler for set_tpi_parameters."""
t = self._thermostat
if t.lock_manager.check_is_locked("service_set_tpi_parameters"):
return
write_event_log(
_LOGGER,
t,
f"Calling SERVICE_SET_TPI_PARAMETERS, tpi_coef_int: {tpi_coef_int}, "
f"tpi_coef_ext: {tpi_coef_ext}"
f"minimal_activation_delay: {minimal_activation_delay}, "
f"minimal_deactivation_delay: {minimal_deactivation_delay}, "
f"tpi_threshold_low: {tpi_threshold_low}, "
f"tpi_threshold_high: {tpi_threshold_high}",
)
if t.prop_algorithm is None:
raise ServiceValidationError(f"{t} - No TPI algorithm configured for this thermostat.")
entry = t.hass.config_entries.async_get_entry(t.unique_id)
if not entry:
raise ServiceValidationError(f"{t} - No config entry has been found for this thermostat.")
if entry.data.get(CONF_USE_TPI_CENTRAL_CONFIG, False):
raise ServiceValidationError(f"{t} - Impossible to set TPI parameters when using central TPI configuration.")
# Update the algorithm with coefficients and thresholds
t.prop_algorithm.update_parameters(
tpi_coef_int,
tpi_coef_ext,
tpi_threshold_low,
tpi_threshold_high,
)
# Update thermostat attributes directly (handler updates them via setters)
t.tpi_coef_int = t.prop_algorithm.tpi_coef_int
t.tpi_coef_ext = t.prop_algorithm.tpi_coef_ext
t.tpi_threshold_low = t.prop_algorithm.tpi_threshold_low
t.tpi_threshold_high = t.prop_algorithm.tpi_threshold_high
# Update delays directly on thermostat (not in algo anymore)
if minimal_activation_delay is not None:
t.minimal_activation_delay = minimal_activation_delay
t.cycle_scheduler.min_activation_delay = t.minimal_activation_delay
if minimal_deactivation_delay is not None:
t.minimal_deactivation_delay = minimal_deactivation_delay
t.cycle_scheduler.min_deactivation_delay = t.minimal_deactivation_delay
await self._async_update_tpi_config_entry()
if t.is_removed:
_LOGGER.debug("%s - Entity is removed, stop service_set_tpi_parameters", t)
return
t.recalculate()
await t.async_control_heating(force=True)
async def service_set_auto_tpi_mode(
self,
auto_tpi_mode: bool,
reinitialise: bool = True,
allow_kint_boost_on_stagnation: bool = False,
allow_kext_compensation_on_overshoot: bool = False,
):
"""Service handler for set_auto_tpi_mode."""
t = self._thermostat
if t.proportional_function != PROPORTIONAL_FUNCTION_TPI:
raise ServiceValidationError(f"{t} - This service is only available for TPI algorithm.")
if not t.entry_infos.get(CONF_AUTO_TPI_MODE, False):
raise ServiceValidationError(f"{t} - Auto TPI is not enabled in configuration.")
write_event_log(
_LOGGER,
t,
f"Calling SERVICE_SET_AUTO_TPI_MODE, auto_tpi_mode: {auto_tpi_mode}, "
f"reinitialise: {reinitialise}, "
f"allow_kint_boost: {allow_kint_boost_on_stagnation}, "
f"allow_kext_overshoot: {allow_kext_compensation_on_overshoot}",
)
await self.async_set_auto_tpi_mode(
auto_tpi_mode,
reinitialise,
allow_kint_boost_on_stagnation,
allow_kext_compensation_on_overshoot,
)
async def service_auto_tpi_calibrate_capacity(
self,
save_to_config: bool,
min_power_threshold: int,
start_date: datetime | None = None,
end_date: datetime | None = None,
):
"""Service handler for auto_tpi_calibrate_capacity."""
t = self._thermostat
if t.proportional_function != PROPORTIONAL_FUNCTION_TPI:
raise ServiceValidationError(f"{t} - This service is only available for TPI algorithm.")
if not t.entry_infos.get(CONF_AUTO_TPI_MODE, False):
raise ServiceValidationError(f"{t} - Auto TPI is not enabled in configuration.")
write_event_log(_LOGGER, t, f"Calling SERVICE_AUTO_TPI_CALIBRATE_CAPACITY, save_to_config: {save_to_config}, start_date: {start_date}, end_date: {end_date}, min_power_threshold: {min_power_threshold}")
if not self._auto_tpi_manager:
raise ServiceValidationError(f"{t} - Auto TPI Manager not initialized, cannot calibrate capacity.")
result = await self._auto_tpi_manager.service_calibrate_capacity(
thermostat_entity_id=t.entity_id,
ext_temp_entity_id=t.ext_temp_sensor_entity_id,
save_to_config=save_to_config,
start_date=start_date,
end_date=end_date,
min_power_threshold=min_power_threshold / 100.0,
)
if result and result.get("success") and result.get("max_capacity"):
t.recalculate()
t.update_custom_attributes()
t.async_write_ha_state()
return result
async def async_set_auto_tpi_mode(
self,
auto_tpi_mode: bool,
reinitialise: bool = True,
allow_kint_boost: bool = False,
allow_kext_overshoot: bool = False,
):
"""Set the auto TPI mode."""
t = self._thermostat
_LOGGER.debug(
"%s - async_set_auto_tpi_mode called with auto_tpi_mode=%s, reinitialise=%s, kint_boost=%s, kext_overshoot=%s",
t,
auto_tpi_mode,
reinitialise,
allow_kint_boost,
allow_kext_overshoot,
)
if not self._auto_tpi_manager:
_LOGGER.warning("%s - Auto TPI Manager not initialized", t)
return
# Safety check: Prevent enabling learning if the feature is disabled in config
if auto_tpi_mode and not t.entry_infos.get(CONF_AUTO_TPI_MODE, False):
_LOGGER.warning("%s - Cannot start Auto TPI Learning: feature is disabled in configuration", t)
await self._auto_tpi_manager.stop_learning()
return
if auto_tpi_mode:
# Use the original configured default values
await self._auto_tpi_manager.start_learning(
coef_int=self._auto_tpi_manager._default_coef_int,
coef_ext=self._auto_tpi_manager._default_coef_ext,
reset_data=reinitialise,
allow_kint_boost=allow_kint_boost,
allow_kext_overshoot=allow_kext_overshoot,
)
# Sync PropAlgorithm with the configured coefficients
if t.prop_algorithm:
t.prop_algorithm.update_parameters(tpi_coef_int=t.tpi_coef_int, tpi_coef_ext=t.tpi_coef_ext)
_LOGGER.info("%s - PropAlgorithm synced with config: Kint=%.3f, Kext=%.3f",
t, t.tpi_coef_int, t.tpi_coef_ext)
# If we enable auto_tpi, we must disable central config for TPI
# Note: _entry_infos is a dict, we can update it directly
if t._entry_infos:
t._entry_infos[CONF_USE_TPI_CENTRAL_CONFIG] = False
# Persist the change to the config entry
entry = t.hass.config_entries.async_get_entry(t.unique_id)
if entry and entry.data.get(CONF_USE_TPI_CENTRAL_CONFIG, True):
new_data = entry.data.copy()
new_data[CONF_USE_TPI_CENTRAL_CONFIG] = False
t.hass.config_entries.async_update_entry(entry, data=new_data)
if t.is_removed:
_LOGGER.debug("%s - Entity is removed, stop async_set_auto_tpi_mode", t)
return
# Starting learning is sufficient, cycle processing is passive
pass
else:
await self._auto_tpi_manager.stop_learning()
# Apply configured coefficients to PropAlgorithm
if t.prop_algorithm:
t.prop_algorithm.update_parameters(tpi_coef_int=t.tpi_coef_int, tpi_coef_ext=t.tpi_coef_ext)
_LOGGER.info(
"%s - PropAlgorithm reset to config values: Kint=%.3f, Kext=%.3f",
t, t.tpi_coef_int, t.tpi_coef_ext
)
# Fire event to notify listeners
t.hass.bus.async_fire(
AUTO_TPI_EVENT,
{
"entity_id": t.entity_id,
"auto_tpi_mode": self._auto_tpi_manager.learning_active,
},
)
# Force update of state attributes
t.update_custom_attributes()
t.async_write_ha_state()
@@ -0,0 +1,126 @@
# pylint: disable=unused-argument
""" Implements the VersatileThermostat select component """
import logging
from vtherm_api.log_collector import get_vtherm_logger
from homeassistant.core import HomeAssistant
from homeassistant.components.select import SelectEntity
from homeassistant.helpers.device_registry import DeviceInfo, DeviceEntryType
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from custom_components.versatile_thermostat.base_thermostat import (
ConfigData,
)
from custom_components.versatile_thermostat.vtherm_central_api import VersatileThermostatAPI
from .const import (
DOMAIN,
DEVICE_MANUFACTURER,
CONF_NAME,
CONF_THERMOSTAT_TYPE,
CONF_THERMOSTAT_CENTRAL_CONFIG,
CENTRAL_MODE_AUTO,
CENTRAL_MODES,
overrides,
)
from .commons import write_event_log
_LOGGER = get_vtherm_logger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the VersatileThermostat selects with config flow."""
unique_id = entry.entry_id
name = entry.data.get(CONF_NAME)
_LOGGER.debug("%s - Calling async_setup_entry entry=%s, data=%s", name, entry.entry_id, entry.data)
vt_type = entry.data.get(CONF_THERMOSTAT_TYPE)
if vt_type != CONF_THERMOSTAT_CENTRAL_CONFIG:
return
entities = [
CentralModeSelect(hass, unique_id, name, entry.data),
]
async_add_entities(entities, True)
class CentralModeSelect(SelectEntity, RestoreEntity):
"""Representation of the central mode choice"""
def __init__(
self, hass: HomeAssistant, unique_id: str, name: str, entry_infos: ConfigData
):
"""Initialize the energy sensor"""
self._config_id = unique_id
self._device_name = entry_infos.get(CONF_NAME)
self._attr_name = "Central Mode"
self._attr_unique_id = "central_mode"
self._attr_options = CENTRAL_MODES
self._attr_current_option = CENTRAL_MODE_AUTO
@property
def icon(self) -> str:
return "mdi:form-select"
@property
def device_info(self) -> DeviceInfo:
"""Return the device info."""
return DeviceInfo(
entry_type=None,
identifiers={(DOMAIN, self._config_id)},
name=self._device_name,
manufacturer=DEVICE_MANUFACTURER,
model=DOMAIN,
)
@overrides
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
old_state = await self.async_get_last_state()
_LOGGER.debug(
"%s - Calling async_added_to_hass old_state is %s", self, old_state
)
if old_state is not None:
self._attr_current_option = old_state.state
api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(self.hass)
api.register_central_mode_select(self)
@overrides
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
old_option = self._attr_current_option
if option == old_option:
return
if option in CENTRAL_MODES:
write_event_log(_LOGGER, self, f"Central mode is being changed from {old_option} to {option}")
self._attr_current_option = option
await self.notify_central_mode_change(old_central_mode=old_option)
@overrides
def select_option(self, option: str) -> None:
"""Change the selected option"""
# Update the VTherms which have temperature in central config
self.hass.create_task(self.async_select_option(option))
async def notify_central_mode_change(self, old_central_mode: str | None = None):
"""Notify all VTherm that the central_mode have change"""
api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(self.hass)
# Update all VTherm states
await api.notify_central_mode_change(old_central_mode)
def __str__(self) -> str:
return f"VersatileThermostat-{self.name}"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,446 @@
reload:
name: Reload
description: Reload all Versatile Thermostat entities.
set_presence:
name: Set presence
description: Force the presence mode in thermostat
target:
entity:
integration: versatile_thermostat
fields:
presence:
name: Presence
description: Presence setting
required: true
advanced: false
example: "on"
default: "on"
selector:
select:
options:
- "on"
- "off"
- "home"
- "not_home"
set_safety:
name: Set safety
description: Change the safety parameters
target:
entity:
integration: versatile_thermostat
fields:
delay_min:
name: Delay in minutes
description: Maximum allowed delay in minutes between two temperature mesures
required: false
advanced: false
example: "30"
selector:
number:
min: 0
max: 9999
unit_of_measurement: "min"
mode: box
min_on_percent:
name: Minimal on_percent
description: Minimal heating percent value for safety preset activation
required: false
advanced: false
example: "0.5"
default: "0.5"
selector:
number:
min: 0
max: 1
step: 0.05
unit_of_measurement: "%"
mode: slider
default_on_percent:
name: on_percent used in safety mode
description: The default heating percent value in safety preset
required: false
advanced: false
example: "0.1"
default: "0.1"
selector:
number:
min: 0
max: 1
step: 0.05
unit_of_measurement: "%"
mode: slider
set_window_bypass:
name: Set Window ByPass
description: Bypass the window state to enable heating with window open.
target:
entity:
integration: versatile_thermostat
fields:
window_bypass:
name: Window ByPass
description: ByPass value
required: true
advanced: false
default: true
selector:
boolean:
set_auto_regulation_mode:
name: Set Auto Regulation mode
description: Change the mode of self-regulation (only for VTherm over climate)
target:
entity:
integration: versatile_thermostat
fields:
auto_regulation_mode:
name: Auto regulation mode
description: Possible values
required: true
advanced: false
default: true
selector:
select:
options:
- "None"
- "Light"
- "Medium"
- "Strong"
- "Slow"
- "Expert"
set_auto_fan_mode:
name: Set Auto Fan mode
description: Change the mode of auto-fan (only for VTherm over climate)
target:
entity:
integration: versatile_thermostat
fields:
auto_fan_mode:
name: Auto fan mode
description: Possible values
required: true
advanced: false
default: true
selector:
select:
options:
- "None"
- "Low"
- "Medium"
- "High"
- "Turbo"
set_hvac_mode_sleep:
name: Set HVAC Mode to Sleep
description: Turns a VTherm climate into seasonal sleep - OFF with valve 100% open (only for VTherm over climate with valve regulation).
target:
entity:
integration: versatile_thermostat
lock:
name: Lock
description: "Locks the thermostat, preventing any changes to its configuration from UI or automations."
target:
entity:
integration: versatile_thermostat
fields:
code:
name: Lock code
description: The optional lock code
required: false
advanced: false
example: "1234"
selector:
text:
type: password
unlock:
name: Unlock
description: "Unlocks the thermostat, allowing changes to its configuration."
target:
entity:
integration: versatile_thermostat
fields:
code:
name: Lock code
description: The optional lock code
required: false
advanced: false
example: "1234"
selector:
text:
type: password
set_tpi_parameters:
name: Set TPI parameters
description: Change the TPI parameters
target:
entity:
integration: versatile_thermostat
fields:
tpi_coef_int:
name: Coef Int
description: Coefficient Integral
required: false
advanced: false
example: "0.6"
selector:
number:
min: 0
max: 10
step: 0.01
mode: box
tpi_coef_ext:
name: Coef Ext
description: Coefficient External
required: false
advanced: false
example: "0.01"
selector:
number:
min: 0
max: 1
step: 0.001
mode: box
minimal_activation_delay:
name: Minimal Activation delay
description: Delay in seconds under which the equipment will not be activated
required: false
advanced: true
example: "10"
selector:
number:
min: 0
max: 1000
step: 1
mode: box
unit_of_measurement: "seconds"
minimal_deactivation_delay:
name: Minimal Deactivation delay
description: Delay in seconds under which the equipment will be kept active
required: false
advanced: true
example: "10"
selector:
number:
min: 0
max: 1000
step: 1
mode: box
unit_of_measurement: "seconds"
tpi_threshold_low:
name: Low TPI threshold
description: Threshold in ° under which the TPI algorithm will be on. 0 means no threshold
required: false
advanced: false
example: "0.1"
selector:
number:
min: -10
max: 10
step: 0.1
mode: box
unit_of_measurement: "°"
tpi_threshold_high:
name: High TPI threshold
description: Threshold in ° above which the TPI algorithm will be off. 0 means no threshold
required: false
advanced: false
example: "0.1"
selector:
number:
min: -10
max: 10
step: 0.1
mode: box
unit_of_measurement: "°"
set_auto_tpi_mode:
name: Set Auto TPI mode
description: Enable or disable the Auto TPI learning mode.
target:
entity:
integration: versatile_thermostat
domain:
- climate
fields:
auto_tpi_mode:
name: Auto TPI mode
description: Enable (true) or disable (false) the Auto TPI learning
required: true
advanced: false
default: true
selector:
boolean:
reinitialise:
name: Reset learning data
description: Reset all learning data when enabling Auto TPI mode (default is true)
required: false
advanced: false
default: true
selector:
boolean:
allow_kint_boost_on_stagnation:
name: Allow Kint boost
description: Allow boosting Kint when temperature stagnates despite demand (default false)
required: true
advanced: false
default: false
selector:
boolean:
allow_kext_compensation_on_overshoot:
name: Allow Kext compensation
description: Allow adjusting Kext when temperature overshoots (default false)
required: true
advanced: false
default: false
selector:
boolean:
auto_tpi_calibrate_capacity:
name: Auto Tpi Calibrate Capacity
description: Calibrate the heating/cooling capacity (in min/°) using linear regression on the entity's history data.
target:
entity:
integration: versatile_thermostat
domain:
- climate
fields:
start_date:
name: History from date
description: Date from which to retrieve history (default is 30 days ago)
required: false
advanced: false
example: "2023-11-01"
selector:
date:
end_date:
name: History to date
description: Date until which to retrieve history (default is now)
required: false
advanced: false
example: "2023-12-01"
selector:
date:
min_power_threshold:
name: Minimum Power Threshold
description: Minimum power percentage to consider a cycle as saturated (default 95%). Lower values (e.g., 90%) will include more cycles but may be less accurate.
required: true
default: 95
advanced: true
example: 95
selector:
number:
min: 80
max: 100
step: 1
unit_of_measurement: "%"
mode: slider
save_to_config:
name: Save to Config
description: Whether to apply the calculated capacity to the entity's configuration
required: true
advanced: false
example: true
selector:
boolean:
set_timed_preset:
name: Set Timed Preset
description: Force a preset for a given duration. After the duration expires, the original preset will be restored.
target:
entity:
integration: versatile_thermostat
domain:
- climate
fields:
preset:
name: Preset
description: The preset to apply temporarily
required: true
advanced: false
example: "boost"
selector:
text:
duration_minutes:
name: Duration (minutes)
description: Duration in minutes for which the preset will be active
required: true
advanced: false
example: 30
default: 30
selector:
number:
min: 1
max: 1440
step: 1
unit_of_measurement: "min"
mode: box
cancel_timed_preset:
name: Cancel Timed Preset
description: Cancel any active timed preset and restore the original preset.
target:
entity:
integration: versatile_thermostat
domain:
- climate
recalibrate_valves:
name: Recalibrate valves
description: Recalibrate opening/closing degrees for all valve underlyings of a VTherm (only over_climate_valve).
target:
entity:
integration: versatile_thermostat
domain:
- climate
fields:
delay_seconds:
name: Delay seconds
description: Delay in seconds between open and close step for each valve
required: true
example: 5
default: 60
selector:
number:
min: 30
max: 300
download_logs:
name: Download logs
description: Collect and download filtered logs for a VTherm entity.
target:
entity:
integration: versatile_thermostat
domain:
- climate
fields:
log_level:
name: Log level
description: Minimum log level to include
required: false
default: "DEBUG"
selector:
select:
options:
- "DEBUG"
- "INFO"
- "WARNING"
- "ERROR"
period_start:
name: Period start
description: Start of the extraction period (default is 60 minutes ago)
required: false
selector:
datetime:
period_end:
name: Period end
description: End of the extraction period (default is now)
required: false
selector:
datetime:
@@ -0,0 +1,303 @@
"""StateManager for Versatile Thermostat.
This class manages both the current and the requested state of a VTherm.
"""
import logging
from vtherm_api.log_collector import get_vtherm_logger
from typing import Optional, TYPE_CHECKING, Any
from .const import (
HVAC_OFF_REASON_SAFETY,
HVAC_OFF_REASON_MANUAL,
HVAC_OFF_REASON_WINDOW_DETECTION,
HVAC_OFF_REASON_AUTO_START_STOP,
HVAC_OFF_REASON_SLEEP_MODE,
HVAC_OFF_REASON_CENTRAL_MODE,
CONF_WINDOW_ECO_TEMP,
CONF_WINDOW_FAN_ONLY,
CONF_WINDOW_FROST_TEMP,
CONF_WINDOW_TURN_OFF,
CENTRAL_MODE_STOPPED,
CENTRAL_MODE_COOL_ONLY,
CENTRAL_MODE_HEAT_ONLY,
CENTRAL_MODE_FROST_PROTECTION,
ATTR_CURRENT_STATE,
ATTR_REQUESTED_STATE,
MSG_TARGET_TEMP_POWER,
MSG_TARGET_TEMP_WINDOW_ECO,
MSG_TARGET_TEMP_WINDOW_FROST,
MSG_TARGET_TEMP_CENTRAL_MODE,
MSG_TARGET_TEMP_ACTIVITY_DETECTED,
MSG_TARGET_TEMP_ACTIVITY_NOT_DETECTED,
MSG_TARGET_TEMP_ABSENCE_DETECTED,
MSG_TARGET_TEMP_TIMED_PRESET,
)
from .vtherm_state import VThermState
from .vtherm_hvac_mode import VThermHvacMode_OFF, VThermHvacMode_FAN_ONLY, VThermHvacMode_COOL, VThermHvacMode_HEAT, VThermHvacMode_SLEEP
from .vtherm_preset import VThermPreset
_LOGGER = get_vtherm_logger(__name__)
if TYPE_CHECKING:
from .base_thermostat import BaseThermostat
class StateManager:
"""Manages the current and requested state for a VTherm.
Attributes:
current_state: The actual state of the thermostat.
requested_state: The desired/requested state to be applied.
"""
def __init__(self):
"""Initialize the StateManager with empty initial states.
"""
self._current_state = VThermState(hvac_mode=VThermHvacMode_OFF, preset=VThermPreset.NONE)
self._requested_state = VThermState(hvac_mode=VThermHvacMode_OFF, preset=VThermPreset.NONE)
@property
def current_state(self) -> Optional[VThermState]:
"""Get or set the current state."""
return self._current_state
@property
def requested_state(self) -> Optional[VThermState]:
"""Get or set the requested state."""
return self._requested_state
async def calculate_current_state(self, vtherm: "BaseThermostat") -> bool:
"""Calculate and update the current state from the given base_thermostat.
Args:
vtherm: The thermostat object to use for calculation.
Returns:
bool: True or False according to rules to be defined later.
"""
if not vtherm:
return False
vtherm.set_temperature_reason(None)
change_hvac_mode = await self.calculate_current_hvac_mode(vtherm)
change_preset = await self.calculate_current_preset(vtherm)
change_target_temp = await self.calculate_current_target_temperature(vtherm)
return change_hvac_mode or change_preset or change_target_temp
async def calculate_current_hvac_mode(self, vtherm: "BaseThermostat") -> bool:
"""Calculate and update the current HVAC mode from the given base_thermostat.
- check if safety manager is detected has an impact on hvac_mode
- if not check if window manager has an impact on hvac_mode
- if not check if auto start/stop manager has an impact on hvac_mode
- else set hvac_mode to requested_state.hvac_mode
then publish an event if hvac_mode has changed
Args:
vtherm: The thermostat object to use for calculation.
Returns:
bool: True or False according to preceeding rules
"""
if not vtherm:
return False
# Implement HVAC mode calculation logic here
# overpowering never change the hvac_mode
# if vtherm.power_manager.is_overpowering_detected:
# First check safety
if vtherm.last_central_mode == CENTRAL_MODE_STOPPED:
self._current_state.set_hvac_mode(VThermHvacMode_OFF)
vtherm.set_hvac_off_reason(HVAC_OFF_REASON_CENTRAL_MODE)
elif vtherm.safety_manager.is_safety_detected and (vtherm.is_over_climate or vtherm.safety_manager.safety_default_on_percent <= 0.0):
self._current_state.set_hvac_mode(VThermHvacMode_OFF)
vtherm.set_hvac_off_reason(HVAC_OFF_REASON_SAFETY)
# then check if window is open
elif vtherm.window_manager.is_window_detected and self._requested_state.hvac_mode != VThermHvacMode_OFF:
if vtherm.window_manager.window_action == CONF_WINDOW_FAN_ONLY and VThermHvacMode_FAN_ONLY in vtherm.vtherm_hvac_modes:
self._current_state.set_hvac_mode(VThermHvacMode_FAN_ONLY)
elif vtherm.window_manager.window_action == CONF_WINDOW_TURN_OFF or (
vtherm.window_manager.window_action == CONF_WINDOW_FAN_ONLY and VThermHvacMode_FAN_ONLY not in vtherm.vtherm_hvac_modes
): # default is to turn_off
self._current_state.set_hvac_mode(VThermHvacMode_OFF)
vtherm.set_hvac_off_reason(HVAC_OFF_REASON_WINDOW_DETECTION)
elif vtherm.auto_start_stop_manager and vtherm.auto_start_stop_manager.is_auto_stop_detected and self._requested_state.hvac_mode != VThermHvacMode_OFF:
self._current_state.set_hvac_mode(VThermHvacMode_OFF)
vtherm.set_hvac_off_reason(HVAC_OFF_REASON_AUTO_START_STOP)
elif vtherm.last_central_mode == CENTRAL_MODE_COOL_ONLY and self._requested_state.hvac_mode != VThermHvacMode_OFF:
if VThermHvacMode_COOL in vtherm.vtherm_hvac_modes:
self._current_state.set_hvac_mode(VThermHvacMode_COOL)
else:
vtherm.set_hvac_off_reason(HVAC_OFF_REASON_CENTRAL_MODE)
self._current_state.set_hvac_mode(VThermHvacMode_OFF)
elif vtherm.last_central_mode == CENTRAL_MODE_HEAT_ONLY and self._requested_state.hvac_mode != VThermHvacMode_OFF:
if VThermHvacMode_HEAT in vtherm.vtherm_hvac_modes:
self._current_state.set_hvac_mode(VThermHvacMode_HEAT)
else:
vtherm.set_hvac_off_reason(HVAC_OFF_REASON_CENTRAL_MODE)
self._current_state.set_hvac_mode(VThermHvacMode_OFF)
elif vtherm.last_central_mode == CENTRAL_MODE_FROST_PROTECTION and self._requested_state.hvac_mode != VThermHvacMode_OFF:
preset_modes = vtherm.vtherm_preset_modes
if preset_modes is None or VThermPreset.FROST not in preset_modes or VThermHvacMode_HEAT not in vtherm.vtherm_hvac_modes:
self._current_state.set_hvac_mode(VThermHvacMode_OFF)
vtherm.set_hvac_off_reason(HVAC_OFF_REASON_CENTRAL_MODE)
elif vtherm.vtherm_hvac_mode != VThermHvacMode_HEAT and VThermHvacMode_HEAT in vtherm.vtherm_hvac_modes:
self._current_state.set_hvac_mode(VThermHvacMode_HEAT)
# all is fine set current_state = requested_state
else:
if self._current_state.hvac_mode == VThermHvacMode_OFF and self._requested_state.hvac_mode == VThermHvacMode_OFF:
_LOGGER.info("%s - already in OFF. Change the reason to MANUAL", vtherm)
vtherm.set_hvac_off_reason(HVAC_OFF_REASON_MANUAL if not vtherm.is_sleeping else HVAC_OFF_REASON_SLEEP_MODE)
self._current_state.set_hvac_mode(self._requested_state.hvac_mode)
# Calculate hvac_off_reason
if self._current_state.hvac_mode not in [VThermHvacMode_OFF, VThermHvacMode_SLEEP] and vtherm.hvac_off_reason is not None:
vtherm.set_hvac_off_reason(None)
elif self._current_state.hvac_mode == VThermHvacMode_SLEEP:
vtherm.set_hvac_off_reason(HVAC_OFF_REASON_SLEEP_MODE)
return self._current_state.is_hvac_mode_changed
async def calculate_current_preset(self, vtherm: "BaseThermostat") -> bool:
"""Calculate and update the current preset state from the given base_thermostat.
- check if power manager is detected has an impact on preset
- if not check if safety manager has an impact on preset
- if not check if timed preset manager has a timed preset active
- else set preset to requested_state.preset
Send an event if preset has changed
Args:
vtherm: The thermostat object to use for calculation.
Returns:
bool: True or False according to rules to be preceeding rules
"""
# check overpowering first
if vtherm.power_manager.is_overpowering_detected and self._current_state.hvac_mode != VThermHvacMode_OFF:
# turn off underlying and take the hvac_mode
self._current_state.set_preset(VThermPreset.POWER)
vtherm.set_temperature_reason(MSG_TARGET_TEMP_POWER)
# then check safety
elif vtherm.safety_manager.is_safety_detected and self._current_state.hvac_mode != VThermHvacMode_OFF:
self._current_state.set_preset(VThermPreset.SAFETY)
elif vtherm.last_central_mode == CENTRAL_MODE_FROST_PROTECTION:
preset_modes = vtherm.vtherm_preset_modes
if preset_modes is not None and VThermPreset.FROST in preset_modes and vtherm.vtherm_hvac_mode == VThermHvacMode_HEAT:
self._current_state.set_preset(VThermPreset.FROST)
vtherm.set_temperature_reason(MSG_TARGET_TEMP_CENTRAL_MODE)
# then check if a timed preset is active
elif vtherm.timed_preset_manager.is_timed_preset_active and self._current_state.hvac_mode != VThermHvacMode_OFF:
timed_preset = vtherm.timed_preset_manager.timed_preset
if timed_preset:
self._current_state.set_preset(timed_preset)
vtherm.set_temperature_reason(MSG_TARGET_TEMP_TIMED_PRESET)
# all is fine set current_state = requested_state
else:
self._current_state.set_preset(self._requested_state.preset)
return self._current_state.is_preset_changed
async def calculate_current_target_temperature(self, vtherm: "BaseThermostat") -> bool:
"""Calculate and update the current target temperature from the given base_thermostat.
- check if window manager is detected has an impact on target temperature
- if not check if presence manager has an impact on target temperature
- if not check if motion manager has an impact on target temperature
- else set target temperature to requested_state.target_temperature
Send an event if target temperature has changed
Args:
vtherm: The thermostat object to use for calculation.
Returns:
bool: True or False according to rules to be preceeding rules
"""
updated = False
window_action = vtherm.window_manager.window_action
# Handle window action correction for COOL mode (Issue #1987)
if vtherm.vtherm_hvac_mode == VThermHvacMode_COOL and window_action == CONF_WINDOW_FROST_TEMP:
_LOGGER.debug(
"%s - HVAC mode is COOL and window action is Frost, falling back to Eco",
vtherm,
)
window_action = CONF_WINDOW_ECO_TEMP
# note that window_manager.is_window_detected is False if bypass is on (so no need to test it here)
if vtherm.window_manager.is_window_detected:
if (window_action == CONF_WINDOW_FROST_TEMP and vtherm.is_preset_configured(VThermPreset.FROST)) or (
window_action == CONF_WINDOW_ECO_TEMP and vtherm.is_preset_configured(VThermPreset.ECO)
):
self._current_state.set_target_temperature(vtherm.find_preset_temp(VThermPreset.ECO if window_action == CONF_WINDOW_ECO_TEMP else VThermPreset.FROST))
vtherm.set_temperature_reason(MSG_TARGET_TEMP_WINDOW_ECO if window_action == CONF_WINDOW_ECO_TEMP else MSG_TARGET_TEMP_WINDOW_FROST)
updated = True
elif vtherm.motion_manager.is_configured and self._current_state.preset == VThermPreset.ACTIVITY:
new_preset = vtherm.motion_manager.get_current_motion_preset()
_LOGGER.debug("%s - motion will set new target preset: %s", self, new_preset)
self._current_state.set_target_temperature(vtherm.find_preset_temp(new_preset))
vtherm.set_temperature_reason(MSG_TARGET_TEMP_ACTIVITY_DETECTED if vtherm.motion_manager.is_motion_detected else MSG_TARGET_TEMP_ACTIVITY_NOT_DETECTED)
updated = True
elif vtherm.presence_manager.is_absence_detected:
if vtherm.vtherm_preset_mode != VThermPreset.NONE:
new_temp = vtherm.find_preset_temp(vtherm.vtherm_preset_mode)
_LOGGER.debug("%s - presence will set new target temperature: %.2f", self, new_temp)
self._current_state.set_target_temperature(new_temp)
vtherm.set_temperature_reason(MSG_TARGET_TEMP_ABSENCE_DETECTED)
updated = True
if not updated:
# calculate the temperature of the preset
self.update_current_temp_from_requested(vtherm)
# update requested state temperature to set it in concordance with preset
# 1379 - do not overwrite requested_target_temp to keep the last manual tempe.
# if self._requested_state.preset != VThermPreset.NONE:
# self._requested_state.set_target_temperature(vtherm.find_preset_temp(self._requested_state.preset))
return self._current_state.is_target_temperature_changed
def update_current_temp_from_requested(self, vtherm: "BaseThermostat"):
"""Update the current temperature from the requested state preset if any."""
if self._current_state.preset == VThermPreset.SAFETY:
self._current_state.set_target_temperature(vtherm.find_preset_temp(self._requested_state.preset))
elif self._current_state.preset != VThermPreset.NONE:
self._current_state.set_target_temperature(vtherm.find_preset_temp(self._current_state.preset))
elif self._requested_state.target_temperature is not None:
self._current_state.set_target_temperature(self._requested_state.target_temperature)
else:
# affect the min or max temp according to is_ac
if vtherm.ac_mode:
self._current_state.set_target_temperature(vtherm.max_temp)
else:
self._current_state.set_target_temperature(vtherm.min_temp)
def add_custom_attributes(self, extra_state_attributes: dict[str, Any]):
"""Add some custom attributes"""
extra_state_attributes.update(
{
ATTR_CURRENT_STATE: self.current_state.to_dict(),
ATTR_REQUESTED_STATE: self.requested_state.to_dict(),
}
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,173 @@
## pylint: disable=unused-argument
""" Implements the VersatileThermostat select component """
import logging
from vtherm_api.log_collector import get_vtherm_logger
from typing import Any
from homeassistant.core import HomeAssistant, callback
from homeassistant.const import EntityCategory
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 .base_entity import VersatileThermostatBaseEntity
from .const import * # pylint: disable=unused-wildcard-import,wildcard-import
_LOGGER = get_vtherm_logger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the VersatileThermostat switches with config flow."""
unique_id = entry.entry_id
name = entry.data.get(CONF_NAME)
_LOGGER.debug("%s - Calling async_setup_entry entry=%s, data=%s", name, entry.entry_id, entry.data)
vt_type = entry.data.get(CONF_THERMOSTAT_TYPE)
auto_start_stop_feature = entry.data.get(CONF_USE_AUTO_START_STOP_FEATURE)
entities = []
if vt_type == CONF_THERMOSTAT_CLIMATE:
entities.append(FollowUnderlyingTemperatureChange(hass, unique_id, name, entry))
if auto_start_stop_feature is True:
# Creates a switch to enable the auto-start/stop
enable_entity = AutoStartStopEnable(hass, unique_id, name, entry)
entities.append(enable_entity)
async_add_entities(entities, 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._attr_entity_category = EntityCategory.CONFIG
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-sleep"
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
await self.update_my_state_and_vtherm()
async 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
and self.my_climate.auto_start_stop_manager is not None
):
await self.my_climate.auto_start_stop_manager.set_auto_start_stop_enable(self._attr_is_on)
@callback
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
self._attr_is_on = True
await self.update_my_state_and_vtherm()
@callback
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
self._attr_is_on = False
await self.update_my_state_and_vtherm()
@overrides
def turn_off(self, **kwargs: Any):
self.hass.create_task(self.async_turn_off(**kwargs))
@overrides
def turn_on(self, **kwargs: Any):
self.hass.create_task(self.async_turn_on(**kwargs))
class FollowUnderlyingTemperatureChange(
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 = "Follow underlying temp change"
self._attr_unique_id = f"{self._device_name}_follow_underlying_temp_change"
self._attr_is_on = False
self._attr_entity_category = EntityCategory.CONFIG
@property
def icon(self) -> str | None:
"""The icon"""
return "mdi:content-copy"
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 = False
self.update_my_state_and_vtherm()
def update_my_state_and_vtherm(self):
"""Update the follow flag in my VTherm"""
self.async_write_ha_state()
if self.my_climate is not None:
self.my_climate.set_follow_underlying_temp_change(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()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,638 @@
# pylint: disable=line-too-long, too-many-lines, abstract-method
""" A climate with a direct valve regulation class """
import logging
from vtherm_api.log_collector import get_vtherm_logger
import asyncio
from datetime import datetime
from typing import Optional
from homeassistant.core import Event, HomeAssistant, State
from homeassistant.components.climate import HVACAction, HVACMode
from homeassistant.helpers.event import EventStateChangedData, async_call_later
from homeassistant.exceptions import ServiceValidationError
from .underlyings import UnderlyingValveRegulation, UnderlyingClimate
from .base_thermostat import ConfigData
from .thermostat_climate import ThermostatOverClimate
from .thermostat_prop import ThermostatProp
from .cycle_scheduler import CycleScheduler
from .const import * # pylint: disable=wildcard-import, unused-wildcard-import
from .commons import write_event_log
from .vtherm_hvac_mode import VThermHvacMode, VThermHvacMode_OFF, VThermHvacMode_SLEEP
# from .vtherm_central_api import VersatileThermostatAPI
_LOGGER = get_vtherm_logger(__name__)
class ThermostatOverClimateValve(ThermostatProp[UnderlyingClimate], ThermostatOverClimate):
"""This class represent a VTherm over a climate with a direct valve regulation"""
_entity_component_unrecorded_attributes = ThermostatOverClimate._entity_component_unrecorded_attributes.union( # pylint: disable=protected-access
frozenset(
{
"is_over_climate",
"vtherm_over_climate",
"vtherm_over_climate_valve",
}
)
)
def __init__(
self, hass: HomeAssistant, unique_id: str, name: str, entry_infos: ConfigData
):
"""Initialize the ThermostatOverClimateValve class"""
_LOGGER.debug("%s - creating a ThermostatOverClimateValve VTherm", name)
self._underlyings_valve_regulation: list[UnderlyingValveRegulation] = []
self._valve_open_percent: int | None = None
self._last_calculation_timestamp: datetime | None = None
self._auto_regulation_dpercent: float | None = None
self._auto_regulation_period_min: int | None = None
self._min_opening_degress: list[int] = []
self._max_closing_degree: int = 100
self._opening_threshold_degree: int = 0
self._recalibrate_lock: asyncio.Lock = asyncio.Lock()
self._climate_under_initialized: bool = False
self._valve_under_initialized: bool = False
super().__init__(hass, unique_id, name, entry_infos)
@overrides
def post_init(self, config_entry: ConfigData):
"""Initialize the Thermostat and underlyings
Beware that the underlyings list contains the climate which represent the TRV
but also the UnderlyingValveRegulation which reprensent the valve"""
super().post_init(config_entry)
self._auto_regulation_dpercent = (
config_entry.get(CONF_AUTO_REGULATION_DTEMP)
if config_entry.get(CONF_AUTO_REGULATION_DTEMP) is not None
else 0.0
)
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 0
)
opening_list = config_entry.get(CONF_OPENING_DEGREE_LIST)
closing_list = config_entry.get(CONF_CLOSING_DEGREE_LIST, [])
self._max_closing_degree = config_entry.get(CONF_MAX_CLOSING_DEGREE, 100)
self._opening_threshold_degree = config_entry.get(CONF_OPENING_THRESHOLD_DEGREE, 0)
regulation_threshold = config_entry.get(CONF_AUTO_REGULATION_DTEMP, 0)
self._min_opening_degrees = config_entry.get(CONF_MIN_OPENING_DEGREES, None)
min_opening_degrees_list = []
if self._min_opening_degrees:
min_opening_degrees_list = [
int(x.strip()) for x in self._min_opening_degrees.split(",")
]
self._max_opening_degrees = config_entry.get(CONF_MAX_OPENING_DEGREES, None)
max_opening_degrees_list = []
if self._max_opening_degrees:
max_opening_degrees_list = [
int(x.strip()) for x in self._max_opening_degrees.split(",")
]
for idx, _ in enumerate(config_entry.get(CONF_UNDERLYING_LIST)):
# number of opening should equal number of underlying
opening = opening_list[idx]
closing = closing_list[idx] if idx < len(closing_list) else None
self._opening_threshold_degree = max(self._opening_threshold_degree, regulation_threshold)
# TODO c'est pas possible ici
opening_entity = self._hass.states.get(opening)
under = UnderlyingValveRegulation(
hass=self._hass,
thermostat=self,
opening_degree_entity_id=opening,
closing_degree_entity_id=closing,
climate_underlying=self._underlyings[idx],
min_opening_degree=(min_opening_degrees_list[idx] if idx < len(min_opening_degrees_list) else 0),
# TODO pas bon
max_opening_degree=(max_opening_degrees_list[idx] if idx < len(max_opening_degrees_list) else opening_entity.attributes.get("max", 100) if opening_entity else 100),
max_closing_degree=self._max_closing_degree,
opening_threshold=self._opening_threshold_degree,
)
self._underlyings_valve_regulation.append(under)
self._bind_scheduler(CycleScheduler(
hass=self._hass,
thermostat=self,
underlyings=self._underlyings_valve_regulation,
cycle_duration_sec=self._cycle_min * 60,
min_activation_delay=self.minimal_activation_delay,
min_deactivation_delay=self.minimal_deactivation_delay,
))
async def init_underlyings_completed(self, under_entity_id: Optional[str] = None):
"""Called when an underlying is fully initialized
Caution: this method is called for the _underlyings of the ThermostatClimate but also for the underlyings_valve_regulation
We have to call the parent method only when the both underlyings are initialized"""
_LOGGER.debug("%s - init_underlyings_completed called for %s", self, under_entity_id)
if not self.is_ready:
return
_LOGGER.debug("%s - both climate and valve underlyings are initialized", self)
await super().init_underlyings_completed(under_entity_id)
async def async_startup(self, central_configuration):
"""Startup the Entity. Listen to the underlying state changes"""
await super().async_startup(central_configuration)
# Register the valve listener
for under in self._underlyings_valve_regulation:
_LOGGER.debug("%s - starting underlying valve regulation %s", self, under)
try:
under.startup()
_LOGGER.debug("%s - underlying valve regulation %s started successfully", self, under)
except Exception as ex: # pylint: disable=broad-except
_LOGGER.error("%s - Error starting underlying valve regulation %s: %s", self, under, ex)
@overrides
def restore_specific_previous_state(self, old_state: State):
"""Restore my specific attributes from previous state"""
super().restore_specific_previous_state(old_state)
if self.is_sleeping:
self.set_hvac_off_reason(HVAC_OFF_REASON_SLEEP_MODE)
@property
def is_initialized(self) -> bool:
"""Check if all underlyings and valve underlyings are initialized"""
if not super().is_initialized:
return False
for under in self._underlyings_valve_regulation:
if not under.is_initialized:
return False
return True
@overrides
def update_custom_attributes(self):
"""Custom attributes"""
super().update_custom_attributes()
valve_attributes = {}
for under in self._underlyings_valve_regulation:
valve_attributes.update(
{
under.entity_id: {
"hvac_action": under.hvac_action,
"percent_open": under.percent_open,
"last_sent_opening_value": under.last_sent_opening_value,
"min_opening_degree": under._min_opening_degree, # pylint: disable=protected-access
"max_opening_degree": under._max_opening_degree, # pylint: disable=protected-access
}
}
)
self._attr_extra_state_attributes["valve_open_percent"] = self.valve_open_percent
self._attr_extra_state_attributes["power_percent"] = self.power_percent
self._attr_extra_state_attributes["on_percent"] = self.safe_on_percent
self._attr_extra_state_attributes.update(
{
"vtherm_over_climate_valve": {
"have_valve_regulation": self.have_valve_regulation,
"valve_regulation": {
"underlyings_valve_regulation": [underlying.valve_entity_ids for underlying in self._underlyings_valve_regulation],
"on_percent": self.safe_on_percent,
"power_percent": self.power_percent,
"function": self._proportional_function,
"tpi_coef_int": self._tpi_coef_int,
"tpi_coef_ext": self._tpi_coef_ext,
"tpi_threshold_low": self._tpi_threshold_low,
"tpi_threshold_high": self._tpi_threshold_high,
"minimal_activation_delay": self._minimal_activation_delay,
"minimal_deactivation_delay": self._minimal_deactivation_delay,
"min_opening_degrees": self._min_opening_degrees,
"opening_threshold_degree": self._opening_threshold_degree,
"max_closing_degree": self._max_closing_degree,
"max_opening_degrees": self._max_opening_degrees,
"valve_open_percent": self.valve_open_percent,
"auto_regulation_dpercent": self._auto_regulation_dpercent,
"auto_regulation_period_min": self._auto_regulation_period_min,
"last_calculation_timestamp": (self._last_calculation_timestamp.astimezone(self._current_tz).isoformat() if self._last_calculation_timestamp else None),
},
"underlying_valves": valve_attributes,
}
}
)
# self.async_write_ha_state()
# _LOGGER.debug("%s - Calling update_custom_attributes: %s", self, self._attr_extra_state_attributes)
@overrides
def recalculate(self, force=False):
"""A utility function to force the calculation of a the algo and
update the custom attributes and write the state
"""
_LOGGER.debug("%s - recalculate the open percent", self)
self.stop_recalculate_later()
# For testing purpose. Should call _set_now() before
now = self.now
if self._last_calculation_timestamp is not None:
period = (now - self._last_calculation_timestamp).total_seconds() / 60
if not force and period < self._auto_regulation_period_min:
_LOGGER.info(
"%s - do not calculate TPI because regulation_period (%d) is not exceeded",
self,
period,
)
self.do_recalculate_later()
return
# Call parent TPI recalculate to perform the TPI algorithm calculation
super().recalculate(force)
if self.is_sleeping:
new_valve_percent = 100
else:
on_percent = self.safe_on_percent
if on_percent is None:
# Temperature not yet available; preserve the current valve position.
return
new_valve_percent = round(max(0, min(on_percent, 1)) * 100)
# Issue 533 - don't filter with dtemp if valve should be close. Else it will never close
if new_valve_percent < self._auto_regulation_dpercent:
new_valve_percent = 0
dpercent = new_valve_percent - self._valve_open_percent if self._valve_open_percent is not None else 0
if self._last_calculation_timestamp is not None and new_valve_percent > 0 and -1 * self._auto_regulation_dpercent <= dpercent < self._auto_regulation_dpercent:
_LOGGER.debug(
"%s - do not calculate TPI because regulation_dpercent (%.1f) is not exceeded",
self,
dpercent,
)
return
if (
self._last_calculation_timestamp is not None
and self._valve_open_percent == new_valve_percent
):
_LOGGER.debug("%s - no change in valve_open_percent.", self)
return
self._valve_open_percent = new_valve_percent
self._last_calculation_timestamp = now
def do_recalculate_later(self):
"""A utility function to set the valve open percent later on all underlyings"""
_LOGGER.debug("%s - do_recalculate_later call", self)
async def callback_recalculate(_):
"""Callback to set the valve percent"""
self.recalculate()
await self.async_control_heating(force=False)
self.update_custom_attributes()
self.async_write_ha_state()
self.stop_recalculate_later()
self._cancel_recalculate_later = async_call_later(self._hass, delay=20, action=callback_recalculate)
async def _send_regulated_temperature(self, force=False):
"""Sends the regulated temperature to all underlying"""
# if self.vtherm_hvac_mode == VThermHvacMode_OFF and not self._is_sleeping:
# _LOGGER.debug("%s - don't send regulated temperature cause VTherm is off ", self)
# return
if self.target_temperature is None:
_LOGGER.warning(
"%s - don't send regulated temperature cause VTherm target_temp (%s) is None. This should be a temporary warning message.",
self,
self.target_temperature,
)
return
if not force and not self.check_auto_regulation_period_min(self.now):
return
# Don't send temperature if hvac_mode is off
if self.vtherm_hvac_mode != VThermHvacMode_OFF:
for under in self._underlyings:
if self.target_temperature != under.last_sent_temperature:
await under.set_temperature(
self.target_temperature,
self._attr_max_temp,
self._attr_min_temp,
)
self._last_regulation_change = self.now
self.reset_last_change_time_from_vtherm()
_LOGGER.debug(
"%s - last_regulation_change is now: %s and last_change_from_vtherm is now: %s", self, self._last_regulation_change, self._last_change_time_from_vtherm
) # pylint: disable=protected-access
for under in self._underlyings_valve_regulation:
await under.set_valve_open_percent()
@overrides
def build_hvac_list(self) -> list[VThermHvacMode]:
"""Build the hvac list depending on ac_mode"""
if self._ac_mode:
return [VThermHvacMode_COOL, VThermHvacMode_SLEEP, VThermHvacMode_OFF]
else:
return [VThermHvacMode_HEAT, VThermHvacMode_SLEEP, VThermHvacMode_OFF]
@overrides
def incremente_energy(self):
"""increment the energy counter if device is active"""
if self._underlying_climate_start_hvac_action_date:
stop_power_date = self.now
delta = stop_power_date - self._underlying_climate_start_hvac_action_date
self._underlying_climate_delta_t = delta.total_seconds() / 3600.0
_LOGGER.debug("%s - underlying_climate_delta_t: %.4f hours", self, self._underlying_climate_delta_t)
# increment energy at the end of the cycle
super().incremente_energy()
self._underlying_climate_start_hvac_action_date = self.now
self._underlying_climate_mean_power_cycle = self.power_manager.mean_cycle_power
else:
_LOGGER.debug("%s - no underlying_climate_start_hvac_action_date to calculate energy", self)
@property
def have_valve_regulation(self) -> bool:
"""True if the Thermostat is regulated by valve"""
return True
@property
def valve_open_percent(self) -> int:
"""Gives the percentage of valve needed"""
if (self.vtherm_hvac_mode == VThermHvacMode_OFF and not self.is_sleeping) or self._valve_open_percent is None:
return 0
else:
return self._valve_open_percent
def calculate_hvac_action(self, under_list: list = None) -> HVACAction | None:
"""Returns the current hvac_action by checking all hvac_action of the _underlyings_valve_regulation"""
if self.is_sleeping:
self._attr_hvac_action = HVACAction.OFF
else:
super().calculate_hvac_action(self._underlyings_valve_regulation)
@property
def should_device_be_active(self) -> bool:
"""A hack to overrides the state from underlyings"""
if self.is_sleeping:
return False
for under in self._underlyings_valve_regulation:
if under.should_device_be_active:
return True
return False
@property
def device_actives(self) -> int:
"""Calculate the number of active devices"""
if self.is_sleeping:
return []
return [under.opening_degree_entity_id for under in self._underlyings_valve_regulation if under.is_device_active]
@property
def is_device_active(self) -> bool:
"""Returns true if one underlying is active"""
if ThermostatOverClimate.is_device_active.fget(self) is not True:
return False
for under in self._underlyings_valve_regulation:
if under.is_device_active:
return True
return False
@property
def activable_underlying_entities(self) -> list | None:
"""Returns the activable underlying entities for controling the central boiler"""
return self._underlyings_valve_regulation
@overrides
@property
def all_underlying_entities(self) -> list | None:
"""Returns all underlying entities for controling the central boiler"""
return self._underlyings + self._underlyings_valve_regulation
@overrides
@property
def is_sleeping(self) -> bool:
"""True if the thermostat is in sleep mode"""
return self.vtherm_hvac_mode == VThermHvacMode_SLEEP
@overrides
async def service_set_auto_regulation_mode(self, auto_regulation_mode: str):
"""This should not be possible in valve regulation mode"""
return
@overrides
async def service_set_hvac_mode_sleep(self):
"""Set the hvac_mode to SLEEP mode (valid only for over_climate with valve regulation):
service: versatile_thermostat.set_hvac_mode_sleep
target:
entity_id: climate.thermostat_1
"""
write_event_log(_LOGGER, self, "Calling SERVICE_SET_HVAC_MODE_SLEEP")
await self.async_set_hvac_mode(hvac_mode=VThermHvacMode_SLEEP)
@overrides
def choose_auto_fan_mode(self, auto_fan_mode: str):
"""Force no auto_fan for climate with valve regulation"""
self._current_auto_fan_mode = CONF_AUTO_FAN_NONE
self._auto_activated_fan_mode = self._auto_deactivated_fan_mode = None
@property
def vtherm_type(self) -> str | None:
"""Return the type of thermostat"""
return "over_climate_valve"
@overrides
async def async_set_hvac_mode(self, hvac_mode: VThermHvacMode, ignore_lock: bool = False):
"""Disable HVAC mode change during recalibration"""
if not self._recalibrate_lock.locked() or ignore_lock:
await super().async_set_hvac_mode(hvac_mode)
@overrides
async def underlying_changed( # pylint: disable=too-many-arguments
self,
under: UnderlyingClimate,
new_hvac_mode: VThermHvacMode | None,
new_hvac_action: HVACAction | None,
new_target_temp: float | None,
new_fan_mode: str | None,
new_state: State,
old_state: State,
):
"""Handle underlying climate changes only if not in recalibration"""
if not self._recalibrate_lock.locked():
return await super().underlying_changed(under, new_hvac_mode, new_hvac_action, new_target_temp, new_fan_mode, new_state, old_state)
_LOGGER.info("%s - ignore underlying climate change because recalibration is in progress", self)
@overrides
async def async_set_temperature(self, **kwargs):
"""Disable temperature change during recalibration"""
if not self._recalibrate_lock.locked():
return await super().async_set_temperature(**kwargs)
_LOGGER.info("%s - ignore temperature change because recalibration is in progress", self)
@overrides
async def async_set_preset_mode(self, preset_mode: str):
"""Disable preset mode change during recalibration"""
if not self._recalibrate_lock.locked():
return await super().async_set_preset_mode(preset_mode)
_LOGGER.info("%s - ignore preset mode change because recalibration is in progress", self)
async def service_recalibrate_valves(self, delay_seconds: int):
"""Start recalibration of valve opening/closing degrees for each underlying valve in background.
Steps:
1) memorize requested state
2) set VTherm hvac mode to OFF
3) for each valve: open to 100% (opening_degree=100, closing_degree=0), wait,
close to fully (opening_degree=0, closing_degree=100), wait
4) restore requested state
During this operation opening_threshold/min/max are ignored by sending
direct commands to the underlying number entities.
"""
if self.lock_manager.check_is_locked("service_recalibrate_valves"):
return {"message": "thermostat locked"}
write_event_log(_LOGGER, self, f"Calling SERVICE_RECALIBRATE_VALVES delay_seconds={delay_seconds}")
# Validate underlyings synchronously before launching background task
if not self._underlyings_valve_regulation:
raise ServiceValidationError(f"{self} - No valve regulation underlyings available")
# Build a short validation list and capture entity min/max per underlying
valves_config = []
for under in self._underlyings_valve_regulation:
opening = under.opening_degree_entity_id
closing = under.closing_degree_entity_id
if not opening:
raise ServiceValidationError(f"{self} - Underlying {under} must have opening degree entities configured")
opening_state = self._hass.states.get(opening)
# closing_state = self._hass.states.get(closing)
if opening_state is None:
raise ServiceValidationError(f"{self} - Opening entity {opening} not found for underlying {under}")
opening_min = opening_state.attributes.get("min", 0)
opening_max = opening_state.attributes.get("max", 100)
# closing_min = closing_state.attributes.get("min", 0)
# closing_max = closing_state.attributes.get("max", 100)
closing_min: int | None = None
closing_max: int | None = None
if closing:
closing_state = self._hass.states.get(closing)
if closing_state is None:
raise ServiceValidationError(f"{self} - Closing entity {closing} not found for underlying {under}")
closing_min = closing_state.attributes.get("min", 0)
closing_max = closing_state.attributes.get("max", 100)
valves_config.append(
{
"under": under,
"opening": opening,
"closing": closing,
"opening_min": opening_min,
"opening_max": opening_max,
"closing_min": closing_min if closing else None,
"closing_max": closing_max if closing else None,
}
)
# Memorize expected/requested state
expected_state = self.requested_state.to_dict() if self.requested_state is not None else None
# If a recalibration is already running, return immediately and do not schedule
if self._recalibrate_lock.locked():
_LOGGER.warning("Recalibration request refused: already running for %s", self.entity_id)
return {"message": "recalibrage en cours"}
# Define the background coroutine
async def _recalibrate_task():
async with self._recalibrate_lock:
try:
# Turn off vtherm
_LOGGER.info("%s - Recalibration - Stopping VTherm and turn on underlying climates and waiting for %s seconds", self, delay_seconds)
await self.async_set_hvac_mode(VThermHvacMode_OFF, ignore_lock=True)
await asyncio.sleep(5)
# Turn on the underlying climate
for under in self._underlyings:
await under.set_hvac_mode(HVACMode.HEAT)
await asyncio.sleep(delay_seconds)
_LOGGER.info("%s - Recalibration - Full opening of the valves and waiting for %s seconds", self, delay_seconds)
for cfg in valves_config:
under = cfg["under"]
opening = cfg["opening"]
closing = cfg["closing"]
open_val = cfg["opening_max"]
close_val = cfg["closing_min"]
_LOGGER.info("%s - Forcing opening=%s to %s and closing=%s to %s", self, opening, open_val, closing, close_val)
await under.send_value_to_number(opening, open_val)
if closing:
await under.send_value_to_number(closing, close_val)
await asyncio.sleep(delay_seconds)
_LOGGER.info("%s - Recalibration - Full closing of the valves and waiting for %s seconds", self, delay_seconds)
for cfg in valves_config:
under = cfg["under"]
opening = cfg["opening"]
closing = cfg["closing"]
open_val2 = cfg["opening_min"]
close_val2 = cfg["closing_max"]
_LOGGER.info("%s - Forcing opening=%s to %s and closing=%s to %s", self, opening, open_val2, closing, close_val2)
await under.send_value_to_number(opening, open_val2)
if closing:
await under.send_value_to_number(closing, close_val2)
await asyncio.sleep(delay_seconds)
except Exception as exc: # pylint: disable=broad-except
_LOGGER.error("%s - Error during recalibration: %s", self, exc)
# Restore requested state
_LOGGER.info("%s - Recalibration - Restoring requested state", self)
if expected_state:
try:
self.requested_state.set_state(
hvac_mode=expected_state.get("hvac_mode"),
target_temperature=expected_state.get("target_temperature"),
preset=expected_state.get("preset"),
)
self.requested_state.force_changed()
await self.update_states(force=True)
except Exception as ex: # pylint: disable=broad-except
_LOGGER.error("%s - Cannot restore requested state after recalibration: %s", self, ex)
# Launch background task and return immediately
try:
self.hass.async_create_task(_recalibrate_task())
except Exception: # pylint: disable=broad-except
# fallback
self._hass.create_task(_recalibrate_task())
return {"message": "calibrage en cours"}
@@ -0,0 +1,344 @@
# pylint: disable=line-too-long, abstract-method
"""Base class for proportional thermostats (TPI, SmartPI)."""
import logging
from vtherm_api.log_collector import get_vtherm_logger
from typing import Generic
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from .base_thermostat import BaseThermostat, ConfigData
from .underlyings import T
from .vtherm_hvac_mode import VThermHvacMode_OFF
from .const import CONF_PROP_FUNCTION, PROPORTIONAL_FUNCTION_TPI
_LOGGER = get_vtherm_logger(__name__)
class ThermostatProp(BaseThermostat[T], Generic[T]):
"""Base class for proportional thermostats.
This class provides the common infrastructure for proportional
control algorithms (TPI, SmartPI). Algorithm-specific logic is
delegated to a handler via composition.
Note: TPI-specific attributes (_tpi_coef_int, _proportional_function, etc.)
are inherited from BaseThermostat and updated by the handler during init.
"""
def __init__(self, hass: HomeAssistant, unique_id: str, name: str, entry_infos: ConfigData):
"""Initialize the proportional thermostat."""
# Handler for algorithm-specific logic (TPI or SmartPI)
self._algo_handler = None
self._on_time_sec: float | None = 0
self._off_time_sec: float | None = 0
self._safety_state: bool = False
self._safety_default_on_percent: float = 0.0
super().__init__(hass, unique_id, name, entry_infos)
# =========================================================================
# COMMON PROPERTIES
# =========================================================================
@property
def has_prop(self) -> bool:
"""True if the Thermostat uses a proportional algorithm (TPI, SmartPI)."""
return True
@property
def prop_algorithm(self):
"""Get the proportional algorithm."""
return self._prop_algorithm
@prop_algorithm.setter
def prop_algorithm(self, value):
"""Set the proportional algorithm."""
self._prop_algorithm = value
@property
def proportional_algorithm(self):
"""Get the proportional algorithm (alias)."""
return self._prop_algorithm
@property
def on_percent(self) -> float | None:
"""Returns the percentage the heater must be ON
In safety mode this value is overridden with the _default_on_percent.
Returns None when the temperature sensor was not available at the last
calculation. Callers must treat None as "temperature unknown — keep
the current switch state unchanged".
"""
if self._safety_state:
val = self._safety_default_on_percent
elif self._prop_algorithm:
val = self._prop_algorithm.on_percent
if val is None:
# Temperature was not available at last calculation.
# Propagate None so callers can skip touching the switch.
return None
else:
val = 0
# Clamp with max_on_percent
# issue 538 - clamping with max_on_percent should be done here
if self._max_on_percent is not None and val > self._max_on_percent:
val = self._max_on_percent
# Notify the algorithm of the realized power (if supported)
# Only if the value has been modified by safety or clamping
if self._prop_algorithm and hasattr(self._prop_algorithm, "update_realized_power"):
# Get what the algorithm proposes
algo_percent = self._prop_algorithm.on_percent
if algo_percent is not None and val != algo_percent:
self._prop_algorithm.update_realized_power(val)
return val
@property
def safe_on_percent(self) -> float:
"""Return the on_percent safe value.
Deprecated: use on_percent directly as it now handles safety.
"""
return self.on_percent
def set_safety(self, default_on_percent: float):
"""Set a default value for on_percent (used for safety mode)"""
_LOGGER.info("%s - Set safety to ON with default_on_percent=%s", self, default_on_percent)
self._safety_state = True
self._safety_default_on_percent = default_on_percent
def unset_safety(self):
"""Unset the safety mode"""
_LOGGER.info("%s - Set safety to OFF", self)
self._safety_state = False
@property
def auto_tpi_manager(self):
"""Return the Auto TPI manager from handler."""
return self._algo_handler.auto_tpi_manager if self._algo_handler else None
@property
def on_time_sec(self) -> float | None:
"""Return the on time in seconds"""
return self._on_time_sec
@property
def off_time_sec(self) -> float | None:
"""Return the off time in seconds"""
return self._off_time_sec
# =========================================================================
# LIFECYCLE METHODS - Delegate to handler
# =========================================================================
def post_init(self, config_entry: ConfigData):
"""Finish the initialization of the thermostat."""
super().post_init(config_entry)
# Initialize off_time to full cycle duration (on_percent=0 at startup)
self._off_time_sec = int(self._cycle_min * 60)
# Initialize the proportional function from config
# This allows selecting the correct handler (TPI, or other prop algorithms)
self._proportional_function = self._entry_infos.get(CONF_PROP_FUNCTION)
# For external algorithms, don't raise if not registered yet — will retry at startup.
self._init_algorithm_handler(
raise_if_missing=(self._proportional_function == PROPORTIONAL_FUNCTION_TPI)
)
def _init_algorithm_handler(self, raise_if_missing: bool = True) -> bool:
"""Initialize the algorithm handler based on proportional_function config.
Returns True if the handler was successfully initialized, False if the external
algorithm was not yet registered (only possible when raise_if_missing=False).
"""
# Import here to avoid circular imports
from .prop_handler_tpi import TPIHandler # pylint: disable=import-outside-toplevel
from .vtherm_central_api import VersatileThermostatAPI # pylint: disable=import-outside-toplevel
if self._proportional_function == PROPORTIONAL_FUNCTION_TPI:
self._algo_handler = TPIHandler(self)
self._algo_handler.init_algorithm()
return True
api = VersatileThermostatAPI.get_vtherm_api(self.hass)
factory = (
api.get_prop_algorithm(self._proportional_function)
if api is not None and hasattr(api, "get_prop_algorithm")
else None
)
if factory is not None:
self._algo_handler = factory.create(self)
self._algo_handler.init_algorithm()
return True
if raise_if_missing:
raise ValueError(
f"{self} - Unknown proportional function: {self._proportional_function}"
)
_LOGGER.warning(
"%s - External proportional algorithm '%s' not yet registered. Will retry at startup.",
self,
self._proportional_function,
)
return False
async def async_added_to_hass(self):
"""Run when entity about to be added."""
if self._algo_handler:
await self._algo_handler.async_added_to_hass()
await super().async_added_to_hass()
async def async_startup(self, central_configuration):
"""Startup the thermostat."""
# External algorithm plugins register after VT entities are created.
# async_startup is called after EVENT_HOMEASSISTANT_STARTED so all plugins
# are guaranteed to be loaded at this point.
if self._algo_handler is None:
if not self._init_algorithm_handler(raise_if_missing=True):
return
if self._cycle_scheduler is not None:
self._algo_handler.on_scheduler_ready(self._cycle_scheduler)
# Catch up on the lifecycle call that was skipped at entity creation.
await self._algo_handler.async_added_to_hass()
await super().async_startup(central_configuration)
if self._algo_handler:
await self._algo_handler.async_startup()
def remove_thermostat(self):
"""Called when the thermostat will be removed."""
if self._algo_handler:
self._algo_handler.remove()
super().remove_thermostat()
# =========================================================================
# COMMON METHODS
# =========================================================================
def recalculate(self, force=False):
"""Force the calculation of the algo and update attributes."""
if self._prop_algorithm:
self._prop_algorithm.calculate(
self.target_temperature,
self._cur_temp,
self._cur_ext_temp,
self.last_temperature_slope,
self.vtherm_hvac_mode or VThermHvacMode_OFF,
power_shedding=self.is_overpowering_detected,
off_reason=self.hvac_off_reason,
)
async def _control_heating_specific(self, timestamp=None, force=False):
"""Control heating using the algorithm handler."""
if self._algo_handler:
await self._algo_handler.control_heating(timestamp, force)
async def update_states(self, force=False):
"""Update states and delegate to handler."""
changed = await super().update_states(force)
if self._algo_handler:
# External proportional plugins may need to react to temperature
# crossings even when VT logical state did not change.
await self._algo_handler.on_state_changed(changed)
return changed
def update_custom_attributes(self):
"""Update custom attributes."""
super().update_custom_attributes()
if self._algo_handler and hasattr(self._algo_handler, "update_attributes"):
self._algo_handler.update_attributes()
# =========================================================================
# SERVICE METHODS - Delegate to handler
# =========================================================================
async def service_set_tpi_parameters(
self,
tpi_coef_int: float | None = None,
tpi_coef_ext: float | None = None,
minimal_activation_delay: int | None = None,
minimal_deactivation_delay: int | None = None,
tpi_threshold_low: float | None = None,
tpi_threshold_high: float | None = None,
):
"""Service: set TPI parameters."""
if hasattr(self._algo_handler, 'service_set_tpi_parameters'):
await self._algo_handler.service_set_tpi_parameters(
tpi_coef_int=tpi_coef_int,
tpi_coef_ext=tpi_coef_ext,
minimal_activation_delay=minimal_activation_delay,
minimal_deactivation_delay=minimal_deactivation_delay,
tpi_threshold_low=tpi_threshold_low,
tpi_threshold_high=tpi_threshold_high,
)
else:
raise ServiceValidationError(f"{self} - This service is only available for TPI algorithm.")
async def service_set_auto_tpi_mode(
self,
auto_tpi_mode: bool,
reinitialise: bool = True,
allow_kint_boost_on_stagnation: bool = False,
allow_kext_compensation_on_overshoot: bool = False,
):
"""Service: set Auto TPI mode."""
if hasattr(self._algo_handler, 'service_set_auto_tpi_mode'):
await self._algo_handler.service_set_auto_tpi_mode(
auto_tpi_mode=auto_tpi_mode,
reinitialise=reinitialise,
allow_kint_boost_on_stagnation=allow_kint_boost_on_stagnation,
allow_kext_compensation_on_overshoot=allow_kext_compensation_on_overshoot,
)
else:
raise ServiceValidationError(f"{self} - This service is only available for TPI algorithm.")
async def service_auto_tpi_calibrate_capacity(
self,
save_to_config: bool,
min_power_threshold: int,
start_date=None,
end_date=None,
):
"""Service: calibrate Auto TPI capacity."""
if hasattr(self._algo_handler, 'service_auto_tpi_calibrate_capacity'):
return await self._algo_handler.service_auto_tpi_calibrate_capacity(
save_to_config=save_to_config,
min_power_threshold=min_power_threshold,
start_date=start_date,
end_date=end_date,
)
else:
raise ServiceValidationError(f"{self} - This service is only available for TPI algorithm.")
async def async_set_auto_tpi_mode(
self,
auto_tpi_mode: bool,
reinitialise: bool = True,
allow_kint_boost: bool = False,
allow_kext_overshoot: bool = False,
):
"""Set the auto TPI mode."""
if hasattr(self._algo_handler, 'async_set_auto_tpi_mode'):
await self._algo_handler.async_set_auto_tpi_mode(
auto_tpi_mode=auto_tpi_mode,
reinitialise=reinitialise,
allow_kint_boost=allow_kint_boost,
allow_kext_overshoot=allow_kext_overshoot,
)
def _bind_scheduler(self, scheduler) -> None:
"""Store the CycleScheduler and notify the algo handler.
Called by concrete subclasses (ThermostatOverSwitch, etc.) immediately
after CycleScheduler construction. The handler registers whatever
callbacks it needs via on_scheduler_ready() the thermostat does not
need to know the details.
"""
self._cycle_scheduler = scheduler
if self._algo_handler:
self._algo_handler.on_scheduler_ready(scheduler)
@@ -0,0 +1,226 @@
# pylint: disable=line-too-long, abstract-method
""" A climate over switch classe """
import logging
from vtherm_api.log_collector import get_vtherm_logger
from datetime import timedelta
from homeassistant.core import Event, callback
from homeassistant.helpers.event import (
async_track_state_change_event,
async_track_time_interval,
EventStateChangedData,
)
from homeassistant.core import HomeAssistant
from .vtherm_hvac_mode import VThermHvacMode_OFF
from .const import (
CONF_UNDERLYING_LIST,
CONF_HEATER_KEEP_ALIVE,
CONF_INVERSE_SWITCH,
CONF_VSWITCH_ON_CMD_LIST,
CONF_VSWITCH_OFF_CMD_LIST,
PROPORTIONAL_FUNCTION_TPI,
overrides,
)
from .commons import write_event_log
from .base_thermostat import BaseThermostat, ConfigData
from .thermostat_prop import ThermostatProp
from .underlyings import UnderlyingSwitch
from .cycle_scheduler import CycleScheduler
_LOGGER = get_vtherm_logger(__name__)
class ThermostatOverSwitch(ThermostatProp[UnderlyingSwitch]):
"""Representation of a base class for a Versatile Thermostat over a switch."""
_entity_component_unrecorded_attributes = BaseThermostat._entity_component_unrecorded_attributes.union( # pylint: disable=protected-access
frozenset(
{
"is_over_switch",
"vtherm_over_switch",
}
)
)
def __init__(self, hass: HomeAssistant, unique_id, name, config_entry) -> None:
"""Initialize the thermostat over switch."""
self._is_inversed: bool | None = None
self._lst_vswitch_on: list[str] = []
self._lst_vswitch_off: list[str] = []
super().__init__(hass, unique_id, name, config_entry)
@property
def is_over_switch(self) -> bool:
"""True if the Thermostat is over_switch"""
return True
@property
def is_inversed(self) -> bool:
"""True if the switch is inversed (for pilot wire and diode)"""
return self._is_inversed is True
@overrides
def post_init(self, config_entry: ConfigData):
"""Initialize the Thermostat"""
super().post_init(config_entry)
self._is_inversed = config_entry.get(CONF_INVERSE_SWITCH) is True
lst_switches = config_entry.get(CONF_UNDERLYING_LIST)
self._lst_vswitch_on = config_entry.get(CONF_VSWITCH_ON_CMD_LIST, [])
self._lst_vswitch_off = config_entry.get(CONF_VSWITCH_OFF_CMD_LIST, [])
for idx, switch in enumerate(lst_switches):
vswitch_on = self._lst_vswitch_on[idx] if idx < len(self._lst_vswitch_on) else None
vswitch_off = self._lst_vswitch_off[idx] if idx < len(self._lst_vswitch_off) else None
self._underlyings.append(
UnderlyingSwitch(
hass=self._hass,
thermostat=self,
switch_entity_id=switch,
keep_alive_sec=config_entry.get(CONF_HEATER_KEEP_ALIVE, 0),
vswitch_on=vswitch_on,
vswitch_off=vswitch_off,
)
)
self._bind_scheduler(CycleScheduler(
hass=self._hass,
thermostat=self,
underlyings=self._underlyings,
cycle_duration_sec=self._cycle_min * 60,
min_activation_delay=self.minimal_activation_delay,
min_deactivation_delay=self.minimal_deactivation_delay,
))
self._should_relaunch_control_heating = False
@overrides
async def async_added_to_hass(self):
"""Run when entity about to be added."""
await super().async_added_to_hass()
# Add listener to all underlying entities
for switch in self._underlyings:
self.async_on_remove(async_track_state_change_event(self.hass, [switch.entity_id], self._async_switch_changed))
# Start the control_heating
# starts a cycle
self.async_on_remove(
async_track_time_interval(
self.hass,
self.async_control_heating,
interval=timedelta(minutes=self._cycle_min),
)
)
@overrides
def update_custom_attributes(self):
"""Custom attributes"""
super().update_custom_attributes()
under0: UnderlyingSwitch = self._underlyings[0]
# Standard attributes
attributes = {
"is_over_switch": self.is_over_switch,
"on_percent": self.safe_on_percent,
"power_percent": self.power_percent,
}
# Use pre-calculated on/off times from handler's control_heating
on_time_sec = self._on_time_sec
off_time_sec = self._off_time_sec
# Underlying specific attributes
vtherm_over_switch_attr = {
"is_inversed": self.is_inversed,
"keep_alive_sec": under0.keep_alive_sec,
"underlying_entities": [underlying.entity_id for underlying in self._underlyings],
"on_percent": self.safe_on_percent,
"power_percent": self.power_percent,
"on_time_sec": on_time_sec,
"off_time_sec": off_time_sec,
"function": self._proportional_function,
"vswitch_on_commands": self._lst_vswitch_on,
"vswitch_off_commands": self._lst_vswitch_off,
}
# Add TPI attributes if active
if self._proportional_function == PROPORTIONAL_FUNCTION_TPI:
vtherm_over_switch_attr.update({
"tpi_coef_int": self._tpi_coef_int,
"tpi_coef_ext": self._tpi_coef_ext,
"tpi_threshold_low": self._tpi_threshold_low,
"tpi_threshold_high": self._tpi_threshold_high,
"minimal_activation_delay": self._minimal_activation_delay,
"minimal_deactivation_delay": self._minimal_deactivation_delay,
})
attributes["vtherm_over_switch"] = vtherm_over_switch_attr
self._attr_extra_state_attributes.update(attributes)
# _LOGGER.debug("%s - Calling update_custom_attributes: %s", self, self._attr_extra_state_attributes)
@overrides
def incremente_energy(self):
"""increment the energy counter if device is active"""
if self.vtherm_hvac_mode == VThermHvacMode_OFF:
return
added_energy = 0
if self.power_manager.mean_cycle_power is not None:
# each underlying entity calculate its own energy. So we should divide by the number of underlying entities
# see #877
added_energy = self.power_manager.mean_cycle_power * float(self._cycle_min) / 60.0 / self.nb_underlying_entities
if self._total_energy is None:
self._total_energy = added_energy
_LOGGER.debug(
"%s - incremente_energy set energy is %s",
self,
self._total_energy,
)
else:
self._total_energy += added_energy
_LOGGER.debug(
"%s - incremente_energy increment energy is %s",
self,
self._total_energy,
)
self.update_custom_attributes()
self.async_write_ha_state()
_LOGGER.debug(
"%s - added energy is %.3f . Total energy is now: %.3f",
self,
added_energy,
self._total_energy,
)
@callback
def _async_switch_changed(self, event: Event[EventStateChangedData]):
"""Handle heater switch state changes."""
new_state = event.data.get("new_state")
old_state = event.data.get("old_state")
write_event_log(_LOGGER, self, f"Underlying switch state changed from {old_state.state if old_state else None} to {new_state.state if new_state else None}")
if new_state is None:
return
# #1654 - nno more needed now
# if old_state is None:
# self.hass.create_task(self._check_initial_state())
self.calculate_hvac_action()
self.update_custom_attributes()
self.async_write_ha_state()
@property
def vtherm_type(self) -> str | None:
"""Return the type of thermostat"""
return "over_switch"
@@ -0,0 +1,313 @@
# pylint: disable=line-too-long, abstract-method
""" A climate over switch classe """
import logging
from vtherm_api.log_collector import get_vtherm_logger
from datetime import timedelta, datetime
from homeassistant.helpers.event import (
async_track_state_change_event,
async_track_time_interval,
async_call_later,
EventStateChangedData,
)
from homeassistant.core import Event, HomeAssistant, callback
from .base_thermostat import BaseThermostat, ConfigData
from .thermostat_prop import ThermostatProp
from .const import * # pylint: disable=wildcard-import, unused-wildcard-import
from .commons import write_event_log
from .underlyings import UnderlyingValve
from .cycle_scheduler import CycleScheduler
from .vtherm_hvac_mode import VThermHvacMode_OFF
_LOGGER = get_vtherm_logger(__name__)
class ThermostatOverValve(ThermostatProp[UnderlyingValve]): # pylint: disable=abstract-method
"""Representation of a class for a Versatile Thermostat over a Valve"""
_entity_component_unrecorded_attributes = BaseThermostat._entity_component_unrecorded_attributes.union( # pylint: disable=protected-access
frozenset(
{
"is_over_valve",
"vtherm_over_valve",
}
)
)
def __init__(
self, hass: HomeAssistant, unique_id: str, name: str, config_entry: ConfigData
):
"""Initialize the thermostat over switch."""
self._valve_open_percent: int = 0
self._last_calculation_timestamp: datetime | None = None
self._auto_regulation_dpercent: float | None = None
self._auto_regulation_period_min: int | None = None
# Call to super must be done after initialization because it calls post_init at the end
super().__init__(hass, unique_id, name, config_entry)
@property
def is_over_valve(self) -> bool:
"""True if the Thermostat is over_valve"""
return True
@property
def valve_open_percent(self) -> int:
"""Gives the percentage of valve needed"""
if self.vtherm_hvac_mode is VThermHvacMode_OFF:
return 0
else:
return self._valve_open_percent
@overrides
def post_init(self, config_entry: ConfigData):
"""Initialize the Thermostat"""
super().post_init(config_entry)
self._auto_regulation_dpercent = (
config_entry.get(CONF_AUTO_REGULATION_DTEMP)
if config_entry.get(CONF_AUTO_REGULATION_DTEMP) is not None
else 0.0
)
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 0
)
lst_valves = config_entry.get(CONF_UNDERLYING_LIST)
for _, valve in enumerate(lst_valves):
self._underlyings.append(
UnderlyingValve(hass=self._hass, thermostat=self, valve_entity_id=valve)
)
self._bind_scheduler(CycleScheduler(
hass=self._hass,
thermostat=self,
underlyings=self._underlyings,
cycle_duration_sec=self._cycle_min * 60,
min_activation_delay=self.minimal_activation_delay,
min_deactivation_delay=self.minimal_deactivation_delay,
))
self._should_relaunch_control_heating = False
@overrides
async def async_added_to_hass(self):
"""Run when entity about to be added."""
await super().async_added_to_hass()
# Add listener to all underlying entities
for valve in self._underlyings:
self.async_on_remove(
async_track_state_change_event(
self.hass, [valve.entity_id], self._async_valve_changed
)
)
# Start the control_heating
# starts a cycle
self.async_on_remove(
async_track_time_interval(
self.hass,
self.async_control_heating,
interval=timedelta(minutes=self._cycle_min),
)
)
@callback
async def _async_valve_changed(self, event: Event[EventStateChangedData]):
"""Handle unerdlying valve state changes.
This method just log the change. It changes nothing to avoid loops.
"""
new_state = event.data.get("new_state")
self.calculate_hvac_action()
self.update_custom_attributes()
self.async_write_ha_state()
write_event_log(_LOGGER, self, f"Underlying valve state changed to {new_state}")
@overrides
def update_custom_attributes(self):
"""Custom attributes"""
super().update_custom_attributes()
self._attr_extra_state_attributes.update(
{
"is_over_valve": self.is_over_valve,
"on_percent": self.safe_on_percent,
"power_percent": self.power_percent,
"valve_open_percent": self.valve_open_percent,
"vtherm_over_valve": {
"valve_open_percent": self.valve_open_percent,
"underlying_entities": [underlying.entity_id for underlying in self._underlyings],
"on_percent": self.safe_on_percent,
"function": self._proportional_function,
"tpi_coef_int": self._tpi_coef_int,
"tpi_coef_ext": self._tpi_coef_ext,
"tpi_threshold_low": self._tpi_threshold_low,
"tpi_threshold_high": self._tpi_threshold_high,
"minimal_activation_delay": self._minimal_activation_delay,
"minimal_deactivation_delay": self._minimal_deactivation_delay,
"auto_regulation_dpercent": self._auto_regulation_dpercent,
"auto_regulation_period_min": self._auto_regulation_period_min,
"last_calculation_timestamp": (self._last_calculation_timestamp.astimezone(self._current_tz).isoformat() if self._last_calculation_timestamp else None),
"calculated_on_percent": (
self._prop_algorithm.calculated_on_percent
if self._prop_algorithm
else None
),
},
}
)
# _LOGGER.debug("%s - Calling update_custom_attributes: %s", self, self._attr_extra_state_attributes)
@overrides
def recalculate(self, force=False):
"""A utility function to force the calculation of a the algo and
update the custom attributes and write the state
"""
_LOGGER.debug("%s - recalculate the open percent", self)
self.stop_recalculate_later()
if self._auto_regulation_period_min is None or self._auto_regulation_dpercent is None:
_LOGGER.warning(
"%s - auto_regulation_period_min or auto_regulation_dpercent is not set. Stopping TPI calculation.",
self,
)
return
# For testing purpose. Should call _set_now() before
now = self.now
if self._last_calculation_timestamp is not None:
period = (now - self._last_calculation_timestamp).total_seconds() / 60
if not force and period < self._auto_regulation_period_min:
_LOGGER.info(
"%s - do not calculate TPI because regulation_period (%d) is not exceeded",
self,
period,
)
self.do_recalculate_later()
return
if self._prop_algorithm:
self._prop_algorithm.calculate(
self.target_temperature,
self._cur_temp,
self._cur_ext_temp,
self.last_temperature_slope,
self.vtherm_hvac_mode or VThermHvacMode_OFF,
)
current_on_percent = self.safe_on_percent
if current_on_percent is None:
# Temperature not yet available; preserve the current valve position.
return
new_valve_percent = round(
max(0, min(current_on_percent, 1)) * 100
)
# Issue 533 - don't filter with dtemp if valve should be close. Else it will never close
if new_valve_percent < self._auto_regulation_dpercent:
new_valve_percent = 0
dpercent = new_valve_percent - self.valve_open_percent
if (
new_valve_percent > 0
and -1 * self._auto_regulation_dpercent
<= dpercent
< self._auto_regulation_dpercent
):
_LOGGER.debug(
"%s - do not calculate TPI because regulation_dpercent (%.1f) is not exceeded",
self,
dpercent,
)
return
if self._valve_open_percent == new_valve_percent:
_LOGGER.debug("%s - no change in valve_open_percent.", self)
return
self._valve_open_percent = new_valve_percent
# Valve open percent is sent to underlyings by CycleScheduler
# in start_cycle (called from control_heating)
self._last_calculation_timestamp = now
# self.calculate_hvac_action()
self.update_custom_attributes()
self.async_write_ha_state()
def do_recalculate_later(self):
"""A utility function to set the valve open percent later on all underlyings"""
_LOGGER.debug("%s - do_recalculate_later call", self)
async def callback_recalculate(_):
"""Callback to set the valve percent"""
self.recalculate()
current_on_percent = self.safe_on_percent
if self._cycle_scheduler and current_on_percent is not None:
await self._cycle_scheduler.apply_valve_update(
self.vtherm_hvac_mode or VThermHvacMode_OFF,
current_on_percent,
)
self.update_custom_attributes()
self.async_write_ha_state()
self.stop_recalculate_later()
self._cancel_recalculate_later = async_call_later(self._hass, delay=20, action=callback_recalculate)
@overrides
def incremente_energy(self):
"""increment the energy counter if device is active"""
if self.vtherm_hvac_mode == VThermHvacMode_OFF:
return
added_energy = 0
if not self.is_over_climate and self.power_manager.mean_cycle_power is not None:
added_energy = (
self.power_manager.mean_cycle_power * float(self._cycle_min) / 60.0
)
if self._total_energy is None:
self._total_energy = added_energy
_LOGGER.debug(
"%s - incremente_energy set energy is %s",
self,
self._total_energy,
)
else:
self._total_energy += added_energy
_LOGGER.debug(
"%s - get_my_previous_state increment energy is %s",
self,
self._total_energy,
)
self.update_custom_attributes()
self.async_write_ha_state()
_LOGGER.debug(
"%s - added energy is %.3f . Total energy is now: %.3f",
self,
added_energy,
self._total_energy,
)
@property
def vtherm_type(self) -> str | None:
"""Return the type of thermostat"""
return "over_valve"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,580 @@
{
"title": "Διαμόρφωση Ευέλικτου Θερμοστάτη",
"config": {
"flow_title": "Διαμόρφωση Ευέλικτου Θερμοστάτη",
"step": {
"user": {
"title": "Προσθήκη νέου Ευέλικτου Θερμοστάτη",
"description": "Κύρια υποχρεωτικά χαρακτηριστικά",
"data": {
"name": "Όνομα",
"thermostat_type": "Τύπος Θερμοστάτη",
"temperature_sensor_entity_id": "Ταυτότητα οντότητας αισθητήρα θερμοκρασίας",
"external_temperature_sensor_entity_id": "Ταυτότητα οντότητας εξωτερικού αισθητήρα θερμοκρασίας",
"cycle_min": "Διάρκεια κύκλου (λεπτά)",
"temp_min": "Ελάχιστη επιτρεπτή θερμοκρασία",
"temp_max": "Μέγιστη επιτρεπτή θερμοκρασία",
"device_power": "Ισχύς συσκευής",
"use_window_feature": "Χρήση ανίχνευσης παραθύρου",
"use_motion_feature": "Χρήση ανίχνευσης κίνησης",
"use_power_feature": "Χρήση διαχείρισης ισχύος",
"use_presence_feature": "Χρήση ανίχνευσης παρουσίας"
},
"data_description": {
"cycle_min": "Minimum duration of one heating/cooling cycle in minutes to avoid too frequent switching",
"temp_min": "Minimum temperature allowed for the thermostat",
"temp_max": "Maximum temperature allowed for the thermostat",
"step_temperature": "Temperature step for setting the target temperature",
"temperature_sensor_entity_id": "Room temperature sensor entity id",
"last_seen_temperature_sensor_entity_id": "Last seen room temperature sensor entity id. Should be datetime sensor",
"external_temperature_sensor_entity_id": "Outdoor temperature sensor entity id. Not used if central configuration is selected"
}
},
"lock": {
"title": "Διαχείριση κλειδώματος",
"description": "Η λειτουργία κλειδώματος αποτρέπει αλλαγές στη διαμόρφωση ενός θερμοστάτη από το περιβάλλον χρήστη ή αυτοματισμούς, διατηρώντας παράλληλα τον θερμοστάτη σε λειτουργία.",
"data": {
"use_lock_central_config": "Χρήση κεντρικής διαμόρφωσης κλειδώματος",
"lock_code": "Ο κωδικός PIN για το ξεκλείδωμα του θερμοστάτη (4 ψηφία)",
"lock_users": "Το κλείδωμα θα αποτρέψει εντολές από χρήστες",
"lock_automations": "Το κλείδωμα θα αποτρέψει εντολές από αυτοματισμούς και ενσωματώσεις (π.χ. προγραμματιστής)"
},
"data_description": {
"use_lock_central_config": "Επιλέξτε για χρήση της κεντρικής διαμόρφωσης κλειδώματος. Αποεπιλέξτε για χρήση συγκεκριμένης διαμόρφωσης κλειδώματος για αυτόν τον VTherm",
"lock_code": "Ο κωδικός PIN για το ξεκλείδωμα του θερμοστάτη (4 ψηφία)"
}
},
"type": {
"title": "Συνδεδεμένες οντότητες",
"description": "Χαρακτηριστικά συνδεδεμένων οντοτήτων",
"data": {
"heater_entity_id": "1ος διακόπτης θερμαντήρα",
"heater_entity2_id": "2ος διακόπτης θερμαντήρα",
"heater_entity3_id": "3ος διακόπτης θερμαντήρα",
"heater_entity4_id": "4ος διακόπτης θερμαντήρα",
"proportional_function": "Αλγόριθμος",
"climate_entity_id": "1η υποκείμενη κλιματική οντότητα",
"climate_entity2_id": "2η υποκείμενη κλιματική οντότητα",
"climate_entity3_id": "3η υποκείμενη κλιματική οντότητα",
"climate_entity4_id": "4η υποκείμενη κλιματική οντότητα",
"ac_mode": "Λειτουργία AC",
"sync_device_internal_temp": "Συγχρονισμός εσωτερικής θερμοκρασίας συσκευής",
"valve_entity_id": "1ος αριθμός βαλβίδας",
"valve_entity2_id": "2ος αριθμός βαλβίδας",
"valve_entity3_id": "3ος αριθμός βαλβίδας",
"valve_entity4_id": "4ος αριθμός βαλβίδας",
"auto_regulation_mode": "Αυτόματη ρύθμιση",
"auto_regulation_dtemp": "Όριο ρύθμισης",
"auto_regulation_periode_min": "Ελάχιστη περίοδος ρύθμισης",
"inverse_switch_command": "Αντίστροφη εντολή διακόπτη",
"auto_fan_mode": "Auto fan mode"
},
"data_description": {
"heater_entity_id": "Υποχρεωτική ταυτότητα οντότητας θερμαντήρα",
"heater_entity2_id": "Προαιρετική 2η ταυτότητα οντότητας θερμαντήρα. Αφήστε κενό αν δεν χρησιμοποιείται",
"heater_entity3_id": "Προαιρετική 3η ταυτότητα οντότητας θερμαντήρα. Αφήστε κενό αν δεν χρησιμοποιείται",
"heater_entity4_id": "Προαιρετική 4η ταυτότητα οντότητας θερμαντήρα. Αφήστε κενό αν δεν χρησιμοποιείται",
"proportional_function": "Αλγόριθμος προς χρήση (άλλοι αλγόριθμοι μπορούν να εγκατασταθούν ως {plugins_link})",
"climate_entity_id": "Ταυτότητα υποκείμενης κλιματικής οντότητας",
"climate_entity2_id": "2η ταυτότητα υποκείμενης κλιματικής οντότητας",
"climate_entity3_id": "3η ταυτότητα υποκείμενης κλιματικής οντότητας",
"climate_entity4_id": "4η ταυτότητα υποκείμενης κλιματικής οντότητας",
"ac_mode": "Χρήση της λειτουργίας Κλιματισμού (AC)",
"sync_device_internal_temp": "Συγχρονισμός εσωτερικής θερμοκρασίας συσκευής",
"valve_entity_id": "1η ταυτότητα αριθμού βαλβίδας",
"valve_entity2_id": "2η ταυτότητα αριθμού βαλβίδας",
"valve_entity3_id": "3η ταυτότητα αριθμού βαλβίδας",
"valve_entity4_id": "4η ταυτότητα αριθμού βαλβίδας",
"auto_regulation_mode": "Αυτόματη προσαρμογή της στοχευμένης θερμοκρασίας",
"auto_regulation_dtemp": "Το όριο σε ° κάτω από το οποίο η αλλαγή θερμοκρασίας δεν θα αποστέλλεται",
"auto_regulation_periode_min": "Διάρκεια σε λεπτά μεταξύ δύο ενημερώσεων ρύθμισης",
"inverse_switch_command": "Για διακόπτη με πιλοτικό καλώδιο και δίοδο μπορεί να χρειαστεί να αντιστρέψετε την εντολή",
"auto_fan_mode": "Automatically activate fan when huge heating/cooling is necessary"
}
},
"tpi": {
"title": "TPI",
"description": "Χαρακτηριστικά Χρονικά Αναλογικού Ολοκληρωτικού (TPI)",
"data": {
"tpi_coef_int": "Συντελεστής για χρήση στη διαφορά εσωτερικής θερμοκρασίας",
"tpi_coef_ext": "Συντελεστής για χρήση στη διαφορά εξωτερικής θερμοκρασίας"
}
},
"presets": {
"title": "Προκαθορισμένα",
"description": "Για κάθε προκαθορισμένο, δώστε την επιθυμητή θερμοκρασία (0 για να αγνοηθεί το προκαθορισμένο)",
"data": {
"eco_temp": "Θερμοκρασία στο προκαθορισμένο Eco",
"comfort_temp": "Θερμοκρασία στο προκαθορισμένο Comfort",
"boost_temp": "Θερμοκρασία στο προκαθορισμένο Boost",
"frost_temp": "Θερμοκρασία στο προκαθορισμένο Frost protection",
"eco_ac_temp": "Θερμοκρασία στο προκαθορισμένο Eco για λειτουργία AC",
"comfort_ac_temp": "Θερμοκρασία στο προκαθορισμένο Comfort για λειτουργία AC",
"boost_ac_temp": "Θερμοκρασία στο προκαθορισμένο Boost για λειτουργία AC"
}
},
"window": {
"title": "Διαχείριση Παραθύρων",
"description": "Ανοίξτε τη διαχείριση παραθύρων.\nΑφήστε το αντίστοιχο entity_id κενό αν δεν χρησιμοποιείται\nΜπορείτε επίσης να ρυθμίσετε αυτόματη ανίχνευση ανοίγματος παραθύρου με βάση τη μείωση της θερμοκρασίας",
"data": {
"window_sensor_entity_id": "Ταυτότητα οντότητας αισθητήρα παραθύρου",
"window_delay": "Καθυστέρηση αισθητήρα παραθύρου (δευτερόλεπτα)",
"window_auto_open_threshold": "Κατώφλι μείωσης θερμοκρασίας για αυτόματη ανίχνευση ανοίγματος παραθύρου (σε °/λεπτό)",
"window_auto_close_threshold": "Κατώφλι αύξησης θερμοκρασίας για τέλος αυτόματης ανίχνευσης (σε °/λεπτό)",
"window_auto_max_duration": "Μέγιστη διάρκεια αυτόματης ανίχνευσης ανοίγματος παραθύρου (σε λεπτά)"
},
"data_description": {
"window_sensor_entity_id": "Αφήστε κενό αν δεν πρέπει να χρησιμοποιηθεί αισθητήρας παραθύρου",
"window_delay": "Η καθυστέρηση σε δευτερόλεπτα πριν ληφθεί υπόψη η ανίχνευση του αισθητήρα",
"window_auto_open_threshold": "Συνιστώμενη τιμή: μεταξύ 0.05 και 0.1. Αφήστε κενό αν δεν χρησιμοποιείται αυτόματη ανίχνευση ανοίγματος παραθύρου",
"window_auto_close_threshold": "Συνιστώμενη τιμή: 0. Αφήστε κενό αν δεν χρησιμοποιείται αυτόματη ανίχνευση ανοίγματος παραθύρου",
"window_auto_max_duration": "Συνιστώμενη τιμή: 60 (μία ώρα). Αφήστε κενό αν δεν χρησιμοποιείται αυτόματη ανίχνευση ανοίγματος παραθύρου"
}
},
"motion": {
"title": "Διαχείριση Κίνησης",
"description": "Διαχείριση αισθητήρα κίνησης. Το προκαθορισμένο μπορεί να αλλάζει αυτόματα ανάλογα με ανίχνευση κίνησης\nΑφήστε το αντίστοιχο entity_id κενό αν δεν χρησιμοποιείται.\nΟι επιλογές motion_preset και no_motion_preset πρέπει να οριστούν στο αντίστοιχο όνομα προκαθορισμένου",
"data": {
"motion_sensor_entity_id": "Ταυτότητα οντότητας αισθητήρα κίνησης",
"motion_delay": "Καθυστέρηση ενεργοποίησης",
"motion_off_delay": "Καθυστέρηση απενεργοποίησης",
"motion_preset": "Προκαθορισμένο κίνησης",
"no_motion_preset": "Προκαθορισμένο χωρίς κίνηση"
},
"data_description": {
"motion_sensor_entity_id": "Η ταυτότητα οντότητας του αισθητήρα κίνησης",
"motion_delay": "Καθυστέρηση ενεργοποίησης κίνησης (δευτερόλεπτα)",
"motion_off_delay": "Καθυστέρηση απενεργοποίησης κίνησης (δευτερόλεπτα)",
"motion_preset": "Το προκαθορισμένο που θα χρησιμοποιηθεί όταν ανιχνευθεί κίνηση",
"no_motion_preset": "Το προκαθορισμένο που θα χρησιμοποιηθεί όταν δεν ανιχνευθεί κίνηση"
}
},
"power": {
"title": "Διαχείριση Ενέργειας",
"description": "Χαρακτηριστικά διαχείρισης ενέργειας.\nΔίνει τον αισθητήρα ενέργειας και τον μέγιστο αισθητήρα ενέργειας του σπιτιού σας.\nΣτη συνέχεια καθορίστε την κατανάλωση ενέργειας του θερμαντήρα όταν είναι ενεργοποιημένος.\nΌλοι οι αισθητήρες και η ισχύς της συσκευής πρέπει να έχουν την ίδια μονάδα (kW ή W).\nΑφήστε το αντίστοιχο entity_id κενό αν δεν χρησιμοποιείται.",
"data": {
"power_sensor_entity_id": "Ταυτότητα οντότητας αισθητήρα ενέργειας",
"max_power_sensor_entity_id": "Ταυτότητα οντότητας αισθητήρα μέγιστης ενέργειας",
"power_temp": "Θερμοκρασία για Αποβολή Ενέργειας"
}
},
"presence": {
"title": "Διαχείριση Παρουσίας",
"description": "Χαρακτηριστικά διαχείρισης παρουσίας.\nΔίνει έναν αισθητήρα παρουσίας του σπιτιού σας (αληθές αν κάποιος είναι παρών).\nΣτη συνέχεια καθορίστε είτε το προκαθορισμένο που θα χρησιμοποιηθεί όταν ο αισθητήρας παρουσίας είναι ψευδής ή την απόκλιση στη θερμοκρασία που θα εφαρμοστεί.\nΑν δοθεί προκαθορισμένο, η απόκλιση δεν θα χρησιμοποιηθεί.\nΑφήστε το αντίστοιχο entity_id κενό αν δεν χρησιμοποιείται.",
"data": {
"presence_sensor_entity_id": "Ταυτότητα οντότητας αισθητήρα παρουσίας",
"eco_away_temp": "Θερμοκρασία στο προκαθορισμένο Eco όταν δεν υπάρχει παρουσία",
"comfort_away_temp": "Θερμοκρασία στο προκαθορισμένο Comfort όταν δεν υπάρχει παρουσία",
"boost_away_temp": "Θερμοκρασία στο προκαθορισμένο Boost όταν δεν υπάρχει παρουσία",
"frost_away_temp": "Θερμοκρασία στο προκαθορισμένο Frost protection όταν δεν υπάρχει παρουσία",
"eco_ac_away_temp": "Θερμοκρασία στο προκαθορισμένο Eco όταν δεν υπάρχει παρουσία σε λειτουργία AC",
"comfort_ac_away_temp": "Θερμοκρασία στο προκαθορισμένο Comfort όταν δεν υπάρχει παρουσία σε λειτουργία AC",
"boost_ac_away_temp": "Θερμοκρασία στο προκαθορισμένο Boost όταν δεν υπάρχει παρουσία σε λειτουργία AC"
}
},
"advanced": {
"title": "Προχωρημένες Παράμετροι",
"description": "Διαμόρφωση των προχωρημένων παραμέτρων. Αφήστε τις προεπιλεγμένες τιμές αν δεν γνωρίζετε τι κάνετε.\nΑυτές οι παράμετροι μπορούν να οδηγήσουν σε πολύ κακή ρύθμιση θερμοκρασίας ή ενέργειας.",
"data": {
"minimal_activation_delay": "Ελάχιστη καθυστέρηση ενεργοποίησης",
"safety_delay_min": "Καθυστέρηση ασφαλείας (σε λεπτά)",
"safety_min_on_percent": "Ελάχιστο ποσοστό ισχύος για ενεργοποίηση λειτουργίας ασφαλείας",
"safety_default_on_percent": "Ποσοστό ισχύος για χρήση σε λειτουργία ασφαλείας",
"repair_incorrect_state": "Επιδιόρθωση ακατάλληλης κατάστασης"
},
"data_description": {
"minimal_activation_delay": "Καθυστέρηση σε δευτερόλεπτα κάτω από την οποία η συσκευή δεν θα ενεργοποιηθεί",
"safety_delay_min": "Μέγιστη επιτρεπτή καθυστέρηση σε λεπτά μεταξύ δύο μετρήσεων θερμοκρασίας. Πέρα από αυτή την καθυστέρηση, ο θερμοστάτης θα μεταβεί σε κατάσταση ασφαλείας",
"safety_min_on_percent": "Ελάχιστη τιμή ποσοστού θέρμανσης για την ενεργοποίηση του προεπιλεγμένου ασφάλειας. Κάτω από αυτό το ποσοστό ισχύος το θερμοστάτη δεν θα πάει στο προεπιλεγμένο ασφάλειας.",
"safety_default_on_percent": "Η προεπιλεγμένη τιμή ποσοστού ισχύος θέρμανσης στο προεπιλεγμένο ασφάλειας. Ορίστε σε 0 για να απενεργοποιήσετε τη θερμάστρα στο παρόν ασφάλειας.",
"repair_incorrect_state": "Αυτόματη επιδιόρθωση της ακατάλληλης κατάστασης των υποκείμενων οντοτήτων όταν εντοπιστεί. Η λειτουργία θα ξαναστείλει την επιθυμητή εντολή εάν η κατάσταση της υποκείμενης συσκευής δεν ταιριάζει με την αναμενόμενη κατάσταση."
}
},
"sync_device_internal_temp": {
"title": "Synchronize device internal temperature",
"description": "Configuration to synchronize the internal temperature of the underlying devices with selected entities",
"data": {
"sync_with_calibration": "Apply offset calibration",
"sync_entity_ids": "Entities id used for synchronization"
},
"data_description": {
"sync_with_calibration": "Check to apply the offset calibration when synchronizing the internal temperature. Uncheck to directly copy the temperature from the selected entities",
"sync_entity_ids": "The list of the entities used to synchronize the internal temperature of the underlying devices. There should be one per underlying device. Should be a calibration entity if checkbox is selected or a temperature entity otherwise. Both are `number` entities."
}
},
"valve_regulation": {
"title": "Αυτορύθμιση με βαλβίδα",
"description": "Διαμόρφωση για αυτορύθμιση με άμεσο έλεγχο της βαλβίδας",
"data": {
"offset_calibration_entity_ids": "Οντότητες βαθμονόμησης μετατόπισης",
"opening_degree_entity_ids": "Οντότητες βαθμού ανοίγματος",
"closing_degree_entity_ids": "Οντότητες βαθμού κλεισίματος",
"proportional_function": "Αλγόριθμος",
"opening_threshold_degree": "Κατώφλι ανοίγματος",
"min_opening_degrees": "Ελάχιστοι βαθμοί ανοίγματος",
"max_opening_degrees": "Μέγιστος βαθμός ανοίγματος",
"max_closing_degree": "Μέγιστος βαθμός κλεισίματος"
},
"data_description": {
"offset_calibration_entity_ids": "Η λίστα των οντοτήτων 'βαθμονόμησης μετατόπισης'. Ορίστε το εάν το TRV σας διαθέτει την οντότητα για καλύτερη ρύθμιση. Θα πρέπει να υπάρχει ένα για κάθε υποκείμενη κλιματική οντότητα",
"opening_degree_entity_ids": "Η λίστα των οντοτήτων 'βαθμού ανοίγματος'. Θα πρέπει να υπάρχει ένα για κάθε υποκείμενη κλιματική οντότητα",
"closing_degree_entity_ids": "Η λίστα των οντοτήτων 'βαθμού κλεισίματος'. Ορίστε το εάν το TRV σας διαθέτει την οντότητα για καλύτερη ρύθμιση. Θα πρέπει να υπάρχει ένα για κάθε υποκείμενη κλιματική οντότητα",
"proportional_function": "Αλγόριθμος προς χρήση (άλλοι αλγόριθμοι μπορούν να εγκατασταθούν ως {plugins_link})",
"opening_threshold_degree": "Τιμή ανοίγματος της βαλβίδας κάτω από την οποία η βαλβίδα πρέπει να θεωρείται κλειστή (και τότε θα ισχύει το 'max_closing_degree')",
"min_opening_degrees": "Ελάχιστη τιμή βαθμού ανοίγματος για κάθε υποκείμενη συσκευή, διαχωρισμένη με κόμμα. Προεπιλεγμένη τιμή 0. Παράδειγμα: 20, 25, 30",
"max_opening_degrees": "Μέγιστη τιμή του βαθμού ανοίγματος. Η βαλβίδα δεν θα ανοίξει ποτέ περισσότερο από αυτήν την τιμή",
"max_closing_degree": "Μέγιστη τιμή του βαθμού κλεισίματος. Η βαλβίδα δεν θα κλείσει ποτέ περισσότερο από αυτήν την τιμή. Ορίστε το στο 100 για πλήρες κλείσιμο της βαλβίδας"
}
},
"heating_failure_detection": {
"title": "Heating failure detection",
"description": "Configure the heating failure detection feature. This detects anomalies when heating is expected but temperature doesn't increase, or when heating is off but temperature keeps rising.",
"data": {
"use_heating_failure_detection_feature": "Enable heating failure detection",
"use_heating_failure_detection_central_config": "Use central heating failure detection configuration",
"heating_failure_threshold": "Heating failure threshold",
"cooling_failure_threshold": "Cooling failure threshold",
"heating_failure_detection_delay": "Detection delay (minutes)",
"temperature_change_tolerance": "Temperature change tolerance (°C)",
"failure_detection_enable_template": "Detection enable template"
},
"data_description": {
"use_heating_failure_detection_feature": "Enable the detection of heating anomalies for VTherms with TPI",
"use_heating_failure_detection_central_config": "Check to use the central heating failure detection configuration. Uncheck to use specific parameters for this VTherm",
"heating_failure_threshold": "On percent threshold above which heating should cause temperature increase (0.9 = 90%)",
"cooling_failure_threshold": "On percent threshold below which temperature should not increase (0.0 = 0%)",
"heating_failure_detection_delay": "Time in minutes to wait before checking if temperature has changed as expected",
"temperature_change_tolerance": "Minimum temperature change in degrees to consider significant. Smaller changes are ignored to filter sensor noise (default: 0.5°C)",
"failure_detection_enable_template": "An optional Jinja2 template that must return True to enable detection. Useful to temporarily disable detection when an external heat source is active (e.g., `is_state('binary_sensor.wood_stove', 'off')` in double curly braces). If empty, detection is always enabled."
}
}
},
"error": {
"unknown": "Απροσδόκητο λάθος",
"unknown_entity": "Άγνωστο αναγνωριστικό οντότητας",
"window_open_detection_method": "Πρέπει να χρησιμοποιηθεί μόνο μία μέθοδος ανίχνευσης ανοιχτού παραθύρου. Χρησιμοποιήστε αισθητήρα ή αυτόματη ανίχνευση μέσω κατωφλίου θερμοκρασίας αλλά όχι και τα δύο",
"no_central_config": "You cannot check 'use central configuration' because no central configuration was found. You need to create a Versatile Thermostat of type 'Central Configuration' to use it.",
"service_configuration_format": "The format of the service configuration is wrong",
"valve_regulation_nb_entities_incorrect": "The number of valve entities for valve regulation should be equal to the number of underlyings",
"sync_device_internal_temp_nb_entities_incorrect": "The number of entities for synchronize device internal temperature should be equal to the number of underlyings",
"min_opening_degrees_format": "A comma separated list of positive integer is expected. Example: 20, 25, 30",
"max_opening_degrees_format": "Αναμένεται μια λίστα θετικών ακεραίων μεταξύ 0 και 100 χωρισμένη με κόμματα. Παράδειγμα: 80, 85, 90",
"min_max_opening_degrees_inconsistent": "Για κάθε υποκείμενη συσκευή, το max_opening_degrees πρέπει να είναι αυστηρά μεγαλύτερο από το min_opening_degrees. Ελέγξτε τη διαμόρφωσή σας.",
"vswitch_configuration_incorrect": "The command customization configuration is incorrect. It is required for underlying entities that are not switches, and the format must be service_name[/attribute:value]. More information is available in the README"
},
"abort": {
"already_configured": "Η συσκευή έχει ήδη ρυθμιστεί"
}
},
"options": {
"flow_title": "Διαμόρφωση Ευέλικτου Θερμοστάτη",
"step": {
"user": {
"title": "Προσθήκη νέου Ευέλικτου Θερμοστάτη",
"description": "Κύρια υποχρεωτικά χαρακτηριστικά",
"data": {
"name": "Όνομα",
"thermostat_type": "Τύπος θερμοστάτη",
"temperature_sensor_entity_id": "Ταυτότητα οντότητας αισθητήρα θερμοκρασίας",
"external_temperature_sensor_entity_id": "Ταυτότητα οντότητας εξωτερικού αισθητήρα θερμοκρασίας",
"cycle_min": "Διάρκεια κύκλου (λεπτά)",
"temp_min": "Ελάχιστη επιτρεπόμενη θερμοκρασία",
"temp_max": "Μέγιστη επιτρεπόμενη θερμοκρασία",
"device_power": "Ισχύς συσκευής (kW)",
"use_window_feature": "Χρήση ανίχνευσης παραθύρου",
"use_motion_feature": "Χρήση ανίχνευσης κίνησης",
"use_power_feature": "Χρήση διαχείρισης ενέργειας",
"use_presence_feature": "Χρήση ανίχνευσης παρουσίας"
},
"data_description": {
"cycle_min": "Minimum duration of one heating/cooling cycle in minutes to avoid too frequent switching",
"temp_min": "Minimum temperature allowed for the thermostat",
"temp_max": "Maximum temperature allowed for the thermostat",
"step_temperature": "Temperature step for setting the target temperature",
"temperature_sensor_entity_id": "Room temperature sensor entity id",
"last_seen_temperature_sensor_entity_id": "Last seen room temperature sensor entity id. Should be datetime sensor",
"external_temperature_sensor_entity_id": "Outdoor temperature sensor entity id. Not used if central configuration is selected"
}
},
"type": {
"title": "Συνδεδεμένες οντότητες",
"description": "Χαρακτηριστικά συνδεδεμένων οντοτήτων",
"data": {
"heater_entity_id": "1ος διακόπτης θερμαντήρα",
"heater_entity2_id": "2ος διακόπτης θερμαντήρα",
"heater_entity3_id": "3ος διακόπτης θερμαντήρα",
"heater_entity4_id": "4ος διακόπτης θερμαντήρα",
"proportional_function": "Αλγόριθμος",
"climate_entity_id": "1η υποκείμενη κλιματική οντότητα",
"climate_entity2_id": "2η υποκείμενη κλιματική οντότητα",
"climate_entity3_id": "3η υποκείμενη κλιματική οντότητα",
"climate_entity4_id": "4η υποκείμενη κλιματική οντότητα",
"ac_mode": "Λειτουργία AC",
"sync_device_internal_temp": "Συγχρονισμός εσωτερικής θερμοκρασίας συσκευής",
"valve_entity_id": "1ος αριθμός βαλβίδας",
"valve_entity2_id": "2ος αριθμός βαλβίδας",
"valve_entity3_id": "3ος αριθμός βαλβίδας",
"valve_entity4_id": "4ος αριθμός βαλβίδας",
"auto_regulation_mode": "Αυτορύθμιση",
"auto_regulation_dtemp": "Όριο ρύθμισης",
"auto_regulation_periode_min": "Ελάχιστη περίοδος ρύθμισης",
"inverse_switch_command": "Αντίστροφη εντολή διακόπτη",
"auto_fan_mode": "Auto fan mode"
},
"data_description": {
"heater_entity_id": "Υποχρεωτική ταυτότητα οντότητας θερμαντήρα",
"heater_entity2_id": "Προαιρετική ταυτότητα οντότητας 2ου θερμαντήρα. Αφήστε το κενό αν δεν χρησιμοποιείται",
"heater_entity3_id": "Προαιρετική ταυτότητα οντότητας 3ου θερμαντήρα. Αφήστε το κενό αν δεν χρησιμοποιείται",
"heater_entity4_id": "Προαιρετική ταυτότητα οντότητας 4ου θερμαντήρα. Αφήστε το κενό αν δεν χρησιμοποιείται",
"proportional_function": "Αλγόριθμος προς χρήση (άλλοι αλγόριθμοι μπορούν να εγκατασταθούν ως {plugins_link})",
"climate_entity_id": "Ταυτότητα οντότητας υποκείμενου κλίματος",
"climate_entity2_id": "Ταυτότητα οντότητας 2ου υποκείμενου κλίματος",
"climate_entity3_id": "Ταυτότητα οντότητας 3ου υποκείμενου κλίματος",
"climate_entity4_id": "Ταυτότητα οντότητας 4ου υποκείμενου κλίματος",
"ac_mode": "Χρήση της λειτουργίας Κλιματισμού (AC)",
"sync_device_internal_temp": "Συγχρονισμός εσωτερικής θερμοκρασίας συσκευής",
"valve_entity_id": "Ταυτότητα οντότητας 1ης βαλβίδας",
"valve_entity2_id": "Ταυτότητα οντότητας 2ης βαλβίδας",
"valve_entity3_id": "Ταυτότητα οντότητας 3ης βαλβίδας",
"valve_entity4_id": "Ταυτότητα οντότητας 4ης βαλβίδας",
"auto_regulation_mode": "Αυτόματη ρύθμιση της στοχευόμενης θερμοκρασίας",
"auto_regulation_dtemp": "Το κατώφλι σε °C κάτω από το οποίο η αλλαγή της θερμοκρασίας δεν θα αποστέλλεται",
"auto_regulation_periode_min": "Διάρκεια σε λεπτά μεταξύ δύο ενημερώσεων ρύθμισης",
"inverse_switch_command": "Για διακόπτες με πιλοτικό καλώδιο και δίοδο μπορεί να χρειαστεί να αντιστραφεί η εντολή",
"auto_fan_mode": "Automatically activate fan when huge heating/cooling is necessary"
}
},
"tpi": {
"title": "TPI",
"description": "Χαρακτηριστικά Χρονικού Αναλογικού Ολοκληρωτικού (TPI)",
"data": {
"tpi_coef_int": "Συντελεστής που θα χρησιμοποιηθεί για την εσωτερική διαφορά θερμοκρασίας",
"tpi_coef_ext": "Συντελεστής που θα χρησιμοποιηθεί για την εξωτερική διαφορά θερμοκρασίας"
}
},
"presets": {
"title": "Προεπιλογές",
"description": "Για κάθε προεπιλογή, δώστε τη στοχευόμενη θερμοκρασία (0 για να αγνοηθεί η προεπιλογή)",
"data": {
"eco_temp": "Θερμοκρασία στην οικονομική προεπιλογή",
"comfort_temp": "Θερμοκρασία στην άνετη προεπιλογή",
"boost_temp": "Θερμοκρασία στην ενισχυμένη προεπιλογή",
"frost_temp": "Θερμοκρασία στο προκαθορισμένο Frost protection",
"eco_ac_temp": "Θερμοκρασία στην οικονομική προεπιλογή για τη λειτουργία AC",
"comfort_ac_temp": "Θερμοκρασία στην άνετη προεπιλογή για τη λειτουργία AC",
"boost_ac_temp": "Θερμοκρασία στην ενισχυμένη προεπιλογή για τη λειτουργία AC"
}
},
"window": {
"title": "Διαχείριση παραθύρου",
"description": "Διαχείριση ανοιχτού παραθύρου.\nΑφήστε την αντίστοιχη ταυτότητα οντότητας κενή αν δεν χρησιμοποιείται\nΜπορείτε επίσης να διαμορφώσετε την αυτόματη ανίχνευση ανοίγματος παραθύρου βάσει της μείωσης της θερμοκρασίας",
"data": {
"window_sensor_entity_id": "Ταυτότητα οντότητας αισθητήρα παραθύρου",
"window_delay": "Καθυστέρηση αισθητήρα παραθύρου (δευτερόλεπτα)",
"window_auto_open_threshold": "Όριο μείωσης θερμοκρασίας για αυτόματη ανίχνευση ανοίγματος παραθύρου (σε °/λεπτό)",
"window_auto_close_threshold": "Όριο αύξησης θερμοκρασίας για τέλος αυτόματης ανίχνευσης (σε °/λεπτό)",
"window_auto_max_duration": "Μέγιστη διάρκεια αυτόματης ανίχνευσης ανοίγματος παραθύρου (σε λεπτά)"
},
"data_description": {
"window_sensor_entity_id": "Αφήστε κενό αν δεν πρέπει να χρησιμοποιηθεί αισθητήρας παραθύρου",
"window_delay": "Η καθυστέρηση σε δευτερόλεπτα πριν ληφθεί υπόψη η ανίχνευση αισθητήρα",
"window_auto_open_threshold": "Συνιστώμενη τιμή: μεταξύ 0.05 και 0.1. Αφήστε κενό αν δεν χρησιμοποιείται αυτόματη ανίχνευση ανοίγματος παραθύρου",
"window_auto_close_threshold": "Συνιστώμενη τιμή: 0. Αφήστε κενό αν δεν χρησιμοποιείται αυτόματη ανίχνευση ανοίγματος παραθύρου",
"window_auto_max_duration": "Συνιστώμενη τιμή: 60 (μία ώρα). Αφήστε κενό αν δεν χρησιμοποιείται αυτόματη ανίχνευση ανοίγματος παραθύρου"
}
},
"motion": {
"title": "Διαχείριση κίνησης",
"description": "Διαχείριση αισθητήρα κίνησης. Ο προεπιλεγμένος τρόπος μπορεί να αλλάξει αυτόματα ανάλογα με την ανίχνευση κίνησης\nΑφήστε το αντίστοιχο entity_id κενό αν δεν χρησιμοποιείται.\nΤα motion_preset και no_motion_preset πρέπει να οριστούν στο αντίστοιχο όνομα προεπιλογής",
"data": {
"motion_sensor_entity_id": "Ανιχνευτής κίνησης entity id",
"motion_delay": "Καθυστέρηση ενεργοποίησης",
"motion_off_delay": "Καθυστέρηση απενεργοποίησης",
"motion_preset": "Προεπιλογή κίνησης",
"no_motion_preset": "Προεπιλογή χωρίς κίνηση"
},
"data_description": {
"motion_sensor_entity_id": "Το entity id του ανιχνευτή κίνησης",
"motion_delay": "Καθυστέρηση ενεργοποίησης κίνησης (δευτερόλεπτα)",
"motion_off_delay": "Καθυστέρηση απενεργοποίησης κίνησης (δευτερόλεπτα)",
"motion_preset": "Η προεπιλογή που χρησιμοποιείται όταν ανιχνεύεται κίνηση",
"no_motion_preset": "Η προεπιλογή που χρησιμοποιείται όταν δεν ανιχνεύεται κίνηση"
}
},
"power": {
"title": "Διαχείριση Ενέργειας",
"description": "Χαρακτηριστικά διαχείρισης ενέργειας.\nΠαρέχει τον αισθητήρα ισχύος και τον μέγιστο αισθητήρα ισχύος του σπιτιού σας.\nΣτη συνέχεια καθορίστε την κατανάλωση ενέργειας του θερμαντήρα όταν είναι ενεργοποιημένος.\nΌλοι οι αισθητήρες και η ισχύς της συσκευής πρέπει να έχουν την ίδια μονάδα (kW ή W).\nΑφήστε το αντίστοιχο entity_id κενό εάν δεν χρησιμοποιείται.",
"data": {
"power_sensor_entity_id": "Ταυτότητα οντότητας αισθητήρα ισχύος",
"max_power_sensor_entity_id": "Ταυτότητα οντότητας αισθητήρα μέγιστης ισχύος",
"power_temp": "Θερμοκρασία για Μείωση Ισχύος"
}
},
"presence": {
"title": "Διαχείριση Παρουσίας",
"description": "Χαρακτηριστικά διαχείρισης παρουσίας.\nΠαρέχει έναν αισθητήρα παρουσίας του σπιτιού σας (αληθές εάν κάποιος είναι παρών).\nΣτη συνέχεια καθορίστε είτε το προεπιλεγμένο πρόγραμμα που θα χρησιμοποιηθεί όταν ο αισθητήρας παρουσίας είναι ψευδής είτε την θερμοκρασιακή διαφορά που θα εφαρμοστεί.\nΕάν δίνεται προεπιλογή, η διαφορά δεν θα χρησιμοποιηθεί.\nΑφήστε το αντίστοιχο entity_id κενό εάν δεν χρησιμοποιείται.",
"data": {
"presence_sensor_entity_id": "Ταυτότητα οντότητας αισθητήρα παρουσίας (αληθές είναι παρών)",
"eco_away_temp": "Θερμοκρασία στο πρόγραμμα Eco όταν δεν υπάρχει παρουσία",
"comfort_away_temp": "Θερμοκρασία στο πρόγραμμα Comfort όταν δεν υπάρχει παρουσία",
"boost_away_temp": "Θερμοκρασία στο πρόγραμμα Boost όταν δεν υπάρχει παρουσία",
"frost_away_temp": "Θερμοκρασία στο προκαθορισμένο Frost protection όταν δεν υπάρχει παρουσία",
"eco_ac_away_temp": "Θερμοκρασία στο πρόγραμμα Eco όταν δεν υπάρχει παρουσία σε λειτουργία AC",
"comfort_ac_away_temp": "Θερμοκρασία στο πρόγραμμα Comfort όταν δεν υπάρχει παρουσία σε λειτουργία AC",
"boost_ac_away_temp": "Θερμοκρασία στο πρόγραμμα Boost όταν δεν υπάρχει παρουσία σε λειτουργία AC"
}
},
"advanced": {
"title": "Προηγμένες Παράμετροι",
"description": "Διαμόρφωση των προηγμένων παραμέτρων. Αφήστε τις προεπιλεγμένες τιμές εάν δεν γνωρίζετε τι κάνετε.\nΑυτές οι παράμετροι μπορούν να οδηγήσουν σε πολύ κακή ρύθμιση θερμοκρασίας ή ενέργειας.",
"data": {
"minimal_activation_delay": "Ελάχιστη καθυστέρηση ενεργοποίησης",
"safety_delay_min": "Καθυστέρηση ασφαλείας (σε λεπτά)",
"safety_min_on_percent": "Ελάχιστο ποσοστό ισχύος για τη λειτουργία ασφαλείας",
"safety_default_on_percent": "Ποσοστό ισχύος που θα χρησιμοποιηθεί στη λειτουργία ασφαλείας",
"repair_incorrect_state": "Repair incorrect state",
"use_advanced_central_config": "Use central advanced configuration"
},
"data_description": {
"minimal_activation_delay": "Καθυστέρηση σε δευτερόλεπτα κάτω από την οποία ο εξοπλισμός δεν θα ενεργοποιηθεί",
"safety_delay_min": "Μέγιστη επιτρεπόμενη καθυστέρηση σε λεπτά μεταξύ δύο μετρήσεων θερμοκρασίας. Πάνω από αυτή την καθυστέρηση, ο θερμοστάτης θα μεταβεί σε κατάσταση ασφαλείας",
"safety_min_on_percent": "Ελάχιστη τιμή ποσοστού θέρμανσης για ενεργοποίηση του προεπιλεγμένου ασφαλείας. Κάτω από αυτό το ποσοστό ισχύος, ο θερμοστάτης δεν θα μεταβεί στο προεπιλεγμένο ασφαλείας",
"safety_default_on_percent": "Η προεπιλεγμένη τιμή ποσοστού ισχύος θέρμανσης στο προεπιλεγμένο ασφαλείας. Ορίστε σε 0 για να απενεργοποιήσετε τη θερμάστρα στο παρόν ασφαλείας",
"repair_incorrect_state": "Automatically repair incorrect state of underlying entities when detected. The feature will re-send the desired command if the underlying device state doesn't match the expected state.",
"use_advanced_central_config": "Check to use the central advanced configuration. Uncheck to use a specific advanced configuration for this VTherm"
}
},
"lock": {
"title": "Διαχείριση κλειδώματος",
"description": "Η λειτουργία κλειδώματος αποτρέπει αλλαγές στη διαμόρφωση ενός θερμοστάτη από το περιβάλλον χρήστη ή αυτοματισμούς, διατηρώντας παράλληλα τον θερμοστάτη σε λειτουργία.",
"data": {
"use_lock_central_config": "Χρήση κεντρικής διαμόρφωσης κλειδώματος",
"lock_code": "Ο κωδικός PIN για το ξεκλείδωμα του θερμοστάτη (4 ψηφία)",
"lock_users": "Το κλείδωμα θα αποτρέψει εντολές από χρήστες",
"lock_automations": "Το κλείδωμα θα αποτρέψει εντολές από αυτοματισμούς και ενσωματώσεις (π.χ. προγραμματιστής)"
},
"data_description": {
"use_lock_central_config": "Επιλέξτε για χρήση της κεντρικής διαμόρφωσης κλειδώματος. Αποεπιλέξτε για χρήση συγκεκριμένης διαμόρφωσης κλειδώματος για αυτόν τον VTherm",
"lock_code": "Ο κωδικός PIN για το ξεκλείδωμα του θερμοστάτη (4 ψηφία)"
}
},
"sync_device_internal_temp": {
"title": "Synchronize device internal temperature",
"description": "Configuration to synchronize the internal temperature of the underlying devices with selected entities",
"data": {
"sync_with_calibration": "Apply offset calibration",
"sync_entity_ids": "Entities id used for synchronization"
},
"data_description": {
"sync_with_calibration": "Check to apply the offset calibration when synchronizing the internal temperature. Uncheck to directly copy the temperature from the selected entities",
"sync_entity_ids": "The list of the entities used to synchronize the internal temperature of the underlying devices. There should be one per underlying device. Should be a calibration entity if checkbox is selected or a temperature entity otherwise. Both are `number` entities."
}
},
"valve_regulation": {
"title": "Αυτορύθμιση με βαλβίδα - {name}",
"description": "Διαμόρφωση για αυτορύθμιση με άμεσο έλεγχο της βαλβίδας",
"data": {
"offset_calibration_entity_ids": "Οντότητες βαθμονόμησης μετατόπισης",
"opening_degree_entity_ids": "Οντότητες βαθμού ανοίγματος",
"closing_degree_entity_ids": "Οντότητες βαθμού κλεισίματος",
"proportional_function": "Αλγόριθμος",
"opening_threshold_degree": "Κατώφλι ανοίγματος",
"min_opening_degrees": "Ελάχιστοι βαθμοί ανοίγματος",
"max_opening_degrees": "Μέγιστος βαθμός ανοίγματος",
"max_closing_degree": "Μέγιστος βαθμός κλεισίματος"
},
"data_description": {
"offset_calibration_entity_ids": "Η λίστα των οντοτήτων 'βαθμονόμησης μετατόπισης'. Ορίστε το εάν το TRV σας διαθέτει την οντότητα για καλύτερη ρύθμιση. Θα πρέπει να υπάρχει ένα για κάθε υποκείμενη κλιματική οντότητα",
"opening_degree_entity_ids": "Η λίστα των οντοτήτων 'βαθμού ανοίγματος'. Θα πρέπει να υπάρχει ένα για κάθε υποκείμενη κλιματική οντότητα",
"closing_degree_entity_ids": "Η λίστα των οντοτήτων 'βαθμού κλεισίματος'. Ορίστε το εάν το TRV σας διαθέτει την οντότητα για καλύτερη ρύθμιση. Θα πρέπει να υπάρχει ένα για κάθε υποκείμενη κλιματική οντότητα",
"proportional_function": "Αλγόριθμος προς χρήση (άλλοι αλγόριθμοι μπορούν να εγκατασταθούν ως {plugins_link})",
"opening_threshold_degree": "Τιμή ανοίγματος της βαλβίδας κάτω από την οποία η βαλβίδα πρέπει να θεωρείται κλειστή (και τότε θα ισχύει το 'max_closing_degree')",
"min_opening_degrees": "Ελάχιστη τιμή βαθμού ανοίγματος για κάθε υποκείμενη συσκευή, διαχωρισμένη με κόμμα. Προεπιλεγμένη τιμή 0. Παράδειγμα: 20, 25, 30",
"max_opening_degrees": "Μέγιστη τιμή του βαθμού ανοίγματος. Η βαλβίδα δεν θα ανοίξει ποτέ περισσότερο από αυτήν την τιμή",
"max_closing_degree": "Μέγιστη τιμή του βαθμού κλεισίματος. Η βαλβίδα δεν θα κλείσει ποτέ περισσότερο από αυτήν την τιμή. Ορίστε το στο 100 για πλήρες κλείσιμο της βαλβίδας"
}
},
"heating_failure_detection": {
"title": "Heating failure detection",
"description": "Configure the heating failure detection feature. This detects anomalies when heating is expected but temperature doesn't increase, or when heating is off but temperature keeps rising.",
"data": {
"use_heating_failure_detection_feature": "Enable heating failure detection",
"use_heating_failure_detection_central_config": "Use central heating failure detection configuration",
"heating_failure_threshold": "Heating failure threshold",
"cooling_failure_threshold": "Cooling failure threshold",
"heating_failure_detection_delay": "Detection delay (minutes)",
"temperature_change_tolerance": "Temperature change tolerance (°C)",
"failure_detection_enable_template": "Detection enable template"
},
"data_description": {
"use_heating_failure_detection_feature": "Enable the detection of heating anomalies for VTherms with TPI",
"use_heating_failure_detection_central_config": "Check to use the central heating failure detection configuration. Uncheck to use specific parameters for this VTherm",
"heating_failure_threshold": "On percent threshold above which heating should cause temperature increase (0.9 = 90%)",
"cooling_failure_threshold": "On percent threshold below which temperature should not increase (0.0 = 0%)",
"heating_failure_detection_delay": "Time in minutes to wait before checking if temperature has changed as expected",
"temperature_change_tolerance": "Minimum temperature change in degrees to consider significant. Smaller changes are ignored to filter sensor noise (default: 0.5°C)",
"failure_detection_enable_template": "An optional Jinja2 template that must return True to enable detection. Useful to temporarily disable detection when an external heat source is active (e.g., `is_state('binary_sensor.wood_stove', 'off')` in double curly braces). If empty, detection is always enabled."
}
}
},
"error": {
"unknown": "Απροσδόκητο λάθος",
"unknown_entity": "Άγνωστο αναγνωριστικό οντότητας",
"window_open_detection_method": "Πρέπει να χρησιμοποιηθεί μόνο μία μέθοδος ανίχνευσης ανοιχτού παραθύρου. Χρησιμοποιήστε αισθητήρα ή αυτόματη ανίχνευση μέσω κατωφλίου θερμοκρασίας αλλά όχι και τα δύο",
"no_central_config": "You cannot check 'use central configuration' because no central configuration was found. You need to create a Versatile Thermostat of type 'Central Configuration' to use it.",
"service_configuration_format": "The format of the service configuration is wrong",
"valve_regulation_nb_entities_incorrect": "The number of valve entities for valve regulation should be equal to the number of underlyings",
"sync_device_internal_temp_nb_entities_incorrect": "The number of entities for synchronize device internal temperature should be equal to the number of underlyings",
"min_opening_degrees_format": "A comma separated list of positive integer is expected. Example: 20, 25, 30",
"max_opening_degrees_format": "Αναμένεται μια λίστα θετικών ακεραίων μεταξύ 0 και 100 χωρισμένη με κόμματα. Παράδειγμα: 80, 85, 90",
"min_max_opening_degrees_inconsistent": "Για κάθε υποκείμενη συσκευή, το max_opening_degrees πρέπει να είναι αυστηρά μεγαλύτερο από το min_opening_degrees. Ελέγξτε τη διαμόρφωσή σας.",
"vswitch_configuration_incorrect": "The command customization configuration is incorrect. It is required for underlying entities that are not switches, and the format must be service_name[/attribute:value]. More information is available in the README"
},
"abort": {
"already_configured": "Η συσκευή έχει ήδη ρυθμιστεί"
}
},
"selector": {
"thermostat_type": {
"options": {
"thermostat_over_switch": "Θερμοστάτης πάνω σε διακόπτη",
"thermostat_over_climate": "Θερμοστάτης πάνω σε κλίμα",
"thermostat_over_valve": "Θερμοστάτης πάνω σε βαλβίδα"
}
},
"auto_regulation_mode": {
"options": {
"auto_regulation_slow": "Αργή",
"auto_regulation_strong": "Δυνατή",
"auto_regulation_medium": "Μέτρια",
"auto_regulation_light": "Ελαφριά",
"auto_regulation_expert": "Εμπειρογνώμων",
"auto_regulation_none": "Χωρίς αυτόματη ρύθμιση"
}
},
"auto_fan_mode": {
"options": {
"auto_fan_none": "No auto fan",
"auto_fan_low": "Low",
"auto_fan_medium": "Medium",
"auto_fan_high": "High",
"auto_fan_turbo": "Turbo"
}
}
},
"entity": {
"climate": {
"versatile_thermostat": {
"state_attributes": {
"preset_mode": {
"state": {
"power": "Μείωση",
"security": "Ασφάλεια",
"none": "Χειροκίνητο"
}
}
}
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More