Files
Home-Assistant/custom_components/versatile_thermostat/feature_auto_start_stop_manager.py
T
2026-06-16 10:33:21 -04:00

319 lines
14 KiB
Python

""" Implements the Auto-start/stop Feature Manager """
# pylint: disable=line-too-long
import logging
from datetime import datetime
from vtherm_api.log_collector import get_vtherm_logger
from typing import Any
from homeassistant.core import (
HomeAssistant,
callback,
)
from .const import * # pylint: disable=wildcard-import, unused-wildcard-import
from .commons_type import ConfigData
from .commons import write_event_log
from .base_manager import BaseFeatureManager
from .auto_start_stop_algorithm import (
AutoStartStopDetectionAlgorithm,
)
_LOGGER = get_vtherm_logger(__name__)
class FeatureAutoStartStopManager(BaseFeatureManager):
"""The implementation of the AutoStartStop feature"""
unrecorded_attributes = frozenset(
{
"auto_start_stop_level",
"auto_start_stop_dtmin",
"auto_start_stop_enable",
"auto_start_stop_accumulated_error",
"auto_start_stop_accumulated_error_threshold",
"auto_start_stop_last_switch_date",
}
)
def __init__(self, vtherm: Any, hass: HomeAssistant):
"""Init of a featureManager"""
super().__init__(vtherm, hass)
self._auto_start_stop_level: str = AUTO_START_STOP_LEVEL_NONE
self._auto_start_stop_algo: AutoStartStopDetectionAlgorithm | None = None
self._is_configured: bool = False
self._is_auto_start_stop_enabled: bool = False
self._is_auto_stop_detected: bool = False
@overrides
def post_init(self, entry_infos: ConfigData):
"""Reinit of the manager"""
if not self._vtherm.is_over_climate or self._vtherm.have_valve_regulation:
return
use_auto_start_stop = entry_infos.get(CONF_USE_AUTO_START_STOP_FEATURE, False)
if use_auto_start_stop:
self._auto_start_stop_level = (
entry_infos.get(CONF_AUTO_START_STOP_LEVEL, None)
or AUTO_START_STOP_LEVEL_NONE
)
self._is_configured = True
else:
self._auto_start_stop_level = AUTO_START_STOP_LEVEL_NONE
self._is_configured = False
# Initialize the enable flag synchronously so the manager is operational
# without having to wait for the AutoStartStopEnable switch's
# async_added_to_hass callback (which is racy under test load).
# The switch's async_added_to_hass will later override this value
# from the persisted state if needed.
# This must mirror the default in switch.AutoStartStopEnable.
self._is_auto_start_stop_enabled = self._auto_start_stop_level != AUTO_START_STOP_LEVEL_NONE
# Instanciate the auto start stop algo
self._auto_start_stop_algo = AutoStartStopDetectionAlgorithm(
self._auto_start_stop_level, self.name
)
# Fix an eventual incoherent state
if self._vtherm.is_on and self._vtherm.hvac_off_reason == HVAC_OFF_REASON_AUTO_START_STOP:
self._vtherm.hvac_off_reason = None
@overrides
async def start_listening(self):
"""Start listening the underlying entity"""
@overrides
def stop_listening(self):
"""Stop listening and remove the eventual timer still running"""
@overrides
async def refresh_state(self) -> bool:
"""Check the auto-start-stop and an eventual action
Return True is auto stop is detected"""
if not self._is_configured or not self._is_auto_start_stop_enabled:
_LOGGER.debug("%s - auto start/stop is disabled (or not configured)", self)
self._is_auto_stop_detected = False
else:
# Do the auto-start-stop calculation
slope = (self._vtherm.last_temperature_slope or 0) / 60 # to have the slope in °/min
should_be_off = self._auto_start_stop_algo.should_be_turned_off(
self._vtherm.requested_state.hvac_mode,
self._vtherm.target_temperature,
self._vtherm.current_temperature,
slope,
self._vtherm.now,
)
_LOGGER.debug("%s - auto_start_stop should be off is %s", self, should_be_off)
if should_be_off:
_LOGGER.info("%s - VTherm should be OFF due to auto-start-stop conditions", self)
# self._vtherm.set_hvac_off_reason(HVAC_OFF_REASON_AUTO_START_STOP)
# await self._vtherm.async_turn_off()
# Send an event if vtherm is on
if not self._is_auto_stop_detected:
self._vtherm.send_event(
event_type=EventType.AUTO_START_STOP_EVENT,
data={
"type": "stop",
"name": self.name,
"cause": "Auto stop conditions reached",
"hvac_mode": str(VThermHvacMode_OFF),
"saved_hvac_mode": str(self._vtherm.requested_state.hvac_mode),
"target_temperature": self._vtherm.target_temperature,
"current_temperature": self._vtherm.current_temperature,
"temperature_slope": round(slope, 3),
"accumulated_error": self._auto_start_stop_algo.accumulated_error,
"accumulated_error_threshold": self._auto_start_stop_algo.accumulated_error_threshold,
},
)
self._is_auto_stop_detected = True
else:
_LOGGER.info("%s - VTherm should be ON due to auto-start-stop conditions", self)
# await self._vtherm.async_turn_on()
# Send an event
if self._is_auto_stop_detected:
self._vtherm.send_event(
event_type=EventType.AUTO_START_STOP_EVENT,
data={
"type": "start",
"name": self.name,
"cause": "Auto start conditions reached",
"hvac_mode": str(self._vtherm.requested_state.hvac_mode),
"saved_hvac_mode": str(self._vtherm.requested_state.hvac_mode),
"target_temperature": self._vtherm.target_temperature,
"current_temperature": self._vtherm.current_temperature,
"temperature_slope": round(slope, 3),
"accumulated_error": self._auto_start_stop_algo.accumulated_error,
"accumulated_error_threshold": self._auto_start_stop_algo.accumulated_error_threshold,
},
)
self._is_auto_stop_detected = False
# returns True if we should stop
return self._is_auto_stop_detected
async def refresh_and_update_if_changed(self) -> bool:
"""Refresh the auto start/stop state and update_states of VTherm if changed
Returns True if the state has changed, False otherwise"""
old_auto_start_stop: bool = self.is_auto_stop_detected
if old_auto_start_stop != await self.refresh_state():
write_event_log(_LOGGER, self._vtherm, f"Auto start/stop state changed from {old_auto_start_stop} to {self.is_auto_stop_detected}")
self._vtherm.requested_state.force_changed()
await self._vtherm.update_states(force=True)
return True
return False
async def set_auto_start_stop_enable(self, is_enabled: bool):
"""Enable/Disable the auto-start/stop feature"""
if self._is_auto_start_stop_enabled != is_enabled:
self._is_auto_start_stop_enabled = is_enabled
# Send an event if the vtherm was off due to auto-start/stop and enable has been set to false
if not is_enabled and self._vtherm.hvac_mode == VThermHvacMode_OFF and self._vtherm.hvac_off_reason == HVAC_OFF_REASON_AUTO_START_STOP:
_LOGGER.debug("%s - the vtherm is off cause auto-start/stop and enable have been set to false -> starts the VTherm")
# Send an event
self._vtherm.send_event(
event_type=EventType.AUTO_START_STOP_EVENT,
data={
"type": "start",
"name": self.name,
"cause": "Auto start stop disabled",
"hvac_mode": str(self._vtherm.requested_state.hvac_mode),
"saved_hvac_mode": str(self._vtherm.requested_state.hvac_mode),
"target_temperature": self._vtherm.target_temperature,
"current_temperature": self._vtherm.current_temperature,
"temperature_slope": round(self._vtherm.last_temperature_slope or 0, 3),
"accumulated_error": self._auto_start_stop_algo.accumulated_error,
"accumulated_error_threshold": self._auto_start_stop_algo.accumulated_error_threshold,
},
)
await self.refresh_state()
self._vtherm.requested_state.force_changed()
await self._vtherm.update_states(True)
self._vtherm.update_custom_attributes()
@callback
@overrides
def restore_state(self, old_state) -> None:
"""Restore the auto start/stop manager state after a Home Assistant restart.
Restores:
- _is_auto_stop_detected: so the VTherm stays off if it was auto-stopped
- algo._accumulated_error: so the algorithm continues from where it left off
- algo._last_switch_date: to prevent rapid ON/OFF switching after restart
- algo._last_should_be_off: kept in sync with _is_auto_stop_detected
"""
if old_state is None:
return
manager_attr = old_state.attributes.get("auto_start_stop_manager")
if not manager_attr:
return
# Restore the manager-level detected state
self._is_auto_stop_detected = bool(manager_attr.get("is_auto_stop_detected", False))
# Restore algorithm intermediate state
if self._auto_start_stop_algo is not None:
accumulated_error = manager_attr.get("auto_start_stop_accumulated_error")
if accumulated_error is not None:
self._auto_start_stop_algo._accumulated_error = float(accumulated_error)
last_switch_date_raw = manager_attr.get("auto_start_stop_last_switch_date")
if last_switch_date_raw is not None:
if isinstance(last_switch_date_raw, str):
try:
self._auto_start_stop_algo._last_switch_date = datetime.fromisoformat(last_switch_date_raw)
except (ValueError, TypeError) as err:
_LOGGER.warning("%s - restore_state: could not parse last_switch_date '%s': %s", self, last_switch_date_raw, err)
else:
self._auto_start_stop_algo._last_switch_date = last_switch_date_raw
# Keep algo's internal flag in sync with the manager state
self._auto_start_stop_algo._last_should_be_off = self._is_auto_stop_detected
_LOGGER.info(
"%s - restore_state: is_auto_stop_detected=%s, accumulated_error=%s",
self,
self._is_auto_stop_detected,
manager_attr.get("auto_start_stop_accumulated_error"),
)
def add_custom_attributes(self, extra_state_attributes: dict[str, Any]):
"""Add some custom attributes"""
extra_state_attributes.update(
{
"is_auto_start_stop_configured": self.is_configured,
}
)
if self.is_configured:
extra_state_attributes.update(
{
"auto_start_stop_manager": {
"auto_start_stop_enable": self.auto_start_stop_enable,
"auto_start_stop_level": self._auto_start_stop_algo.level,
"auto_start_stop_dtmin": self._auto_start_stop_algo.dt_min,
"auto_start_stop_accumulated_error": self._auto_start_stop_algo.accumulated_error,
"auto_start_stop_accumulated_error_threshold": self._auto_start_stop_algo.accumulated_error_threshold,
"auto_start_stop_last_switch_date": self._auto_start_stop_algo.last_switch_date,
"is_auto_stop_detected": self.is_auto_stop_detected,
}
}
)
@property
def is_auto_stop_detected(self) -> bool:
"""Return True if the auto-start/stop feature is detected"""
return self._is_auto_stop_detected
@property
def is_detected(self) -> bool:
"""Return True if the auto-start/stop feature is detected"""
return self._is_auto_stop_detected
@overrides
@property
def is_configured(self) -> bool:
"""Return True of the aiuto-start/stop feature is configured"""
return self._is_configured
@property
def auto_start_stop_level(self) -> str:
"""Return the auto start/stop level."""
return self._auto_start_stop_level
@property
def auto_start_stop_enable(self) -> bool:
"""Returns the auto_start_stop_enable"""
return self._is_auto_start_stop_enabled
@property
def is_auto_stopped(self) -> bool:
"""Returns the is vtherm is stopped and reason is AUTO_START_STOP"""
return self._vtherm.hvac_mode == VThermHvacMode_OFF and self._vtherm.hvac_off_reason == HVAC_OFF_REASON_AUTO_START_STOP
def reset_switch_delay(self):
"""Reset the switch delay in the algorithm to allow immediate restart.
Should be called when target temperature changes significantly.
"""
if self._auto_start_stop_algo:
self._auto_start_stop_algo.reset_switch_delay()
def __str__(self):
return f"AutoStartStopManager-{self.name}"