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