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

314 lines
11 KiB
Python

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