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

469 lines
21 KiB
Python

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