Added VT Thermometer to HACS
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user