469 lines
21 KiB
Python
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"
|