Files
HomeAssistantVS/custom_components/versatile_thermostat/thermostat_prop.py
T
2026-06-16 10:33:21 -04:00

345 lines
14 KiB
Python

# 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)