Added VT Thermometer to HACS
This commit is contained in:
+1
-1
@@ -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
@@ -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
|
||||
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
Reference in New Issue
Block a user