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

317 lines
12 KiB
Python

""" Implements the Timed Preset Feature Manager """
# pylint: disable=line-too-long
from datetime import datetime, timedelta
from typing import Any
from homeassistant.core import (
HomeAssistant,
callback,
)
from homeassistant.helpers.event import async_track_point_in_time
from vtherm_api.log_collector import get_vtherm_logger
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 .vtherm_preset import VThermPreset
_LOGGER = get_vtherm_logger(__name__)
class FeatureTimedPresetManager(BaseFeatureManager):
"""The implementation of the TimedPreset feature.
This feature allows forcing a preset for a given duration.
When the duration expires, the original preset (from requested_state) is restored.
"""
unrecorded_attributes = frozenset(
{
"timed_preset_manager",
}
)
def __init__(self, vtherm: Any, hass: HomeAssistant):
"""Init of a featureManager"""
super().__init__(vtherm, hass)
self._is_timed_preset_active: bool = False
self._timed_preset: VThermPreset | None = None
self._original_preset: VThermPreset | None = None
self._timed_preset_end_time: datetime | None = None
self._cancel_timer: Any | None = None
@overrides
def post_init(self, entry_infos: ConfigData):
"""Reinit of the manager"""
# The timed preset feature is always available, no configuration needed
pass
@overrides
async def start_listening(self):
"""Start listening - nothing to listen to for this feature"""
pass
@overrides
def stop_listening(self):
"""Stop listening and remove the eventual timer still running"""
self._cancel_timed_preset_timer()
super().stop_listening()
@callback
def restore_state(self, old_state: Any):
"""Implement the restoration hook to re-populate timed presets after restart."""
# 1. Retrieve the persistence dictionary from attributes
# Matches the key used in add_custom_attributes
manager_attr = old_state.attributes.get("timed_preset_manager")
if not manager_attr or not manager_attr.get("is_active"):
return
end_time_str = manager_attr.get("end_time")
preset_str = manager_attr.get("preset")
original_preset_str = manager_attr.get("original_preset")
if not end_time_str or not preset_str:
return
try:
# 2. Re-parse the end_time and preset mode
end_time = datetime.fromisoformat(end_time_str)
now = self._vtherm.now
# 3. Re-populate internal manager variables
self._is_timed_preset_active = True
self._timed_preset = VThermPreset(preset_str)
self._original_preset = VThermPreset(original_preset_str) if original_preset_str else None
self._timed_preset_end_time = end_time
# 4. Reschedule the expiration task if time remains
if end_time > now:
# While the timed preset is active, requested_state must carry it so
# other managers like auto start/stop recalculate from the forced preset.
self._vtherm.requested_state.set_preset(self._timed_preset)
_LOGGER.info("%s - Resuming timed preset %s. Reverting at %s", self, preset_str, end_time)
self._cancel_timer = async_track_point_in_time(
self._hass,
self._async_timed_preset_expired,
self._timed_preset_end_time,
)
else:
_LOGGER.info("%s - Timed preset expired during downtime. Cleanup will follow.", self)
# The existing safety check in refresh_state() will handle the cleanup
# and revert to requested_state during the startup cycle.
except (ValueError, TypeError) as err:
_LOGGER.error("%s - Failed to restore timed preset state: %s", self, err)
@overrides
async def refresh_state(self) -> bool:
"""Check if the timed preset is still active.
Return True if timed preset is active"""
if not self._is_timed_preset_active:
return False
# Check if the timer has expired (safety check in case timer callback failed)
if self._timed_preset_end_time and self._vtherm.now >= self._timed_preset_end_time:
_LOGGER.debug("%s - timed preset has expired (safety check)", self)
await self._end_timed_preset()
return False
return self._is_timed_preset_active
async def set_timed_preset(self, preset: VThermPreset, duration_minutes: float) -> bool:
"""Set a preset for a given duration in minutes.
Args:
preset: The preset to apply temporarily
duration_minutes: The duration in minutes
Returns:
True if the timed preset was set successfully, False otherwise
"""
if duration_minutes <= 0:
_LOGGER.warning("%s - duration must be positive, got %s", self, duration_minutes)
return False
if preset not in self._vtherm.vtherm_preset_modes and preset not in [VThermPreset.NONE]:
_LOGGER.warning("%s - preset %s is not available for this thermostat", self, preset)
return False
# Cancel any existing timer
self._cancel_timed_preset_timer()
# Capture the original preset before overriding it
if not self._is_timed_preset_active:
self._original_preset = self._vtherm.requested_state.preset
# Store the timed preset information
self._timed_preset = preset
self._timed_preset_end_time = self._vtherm.now + timedelta(minutes=duration_minutes)
self._is_timed_preset_active = True
# Keep requested_state aligned with the timed preset while it is active so
# state recalculation uses the forced preset even if HVAC is currently off.
self._vtherm.requested_state.set_preset(preset)
# Schedule the end of timed preset
self._cancel_timer = async_track_point_in_time(
self._hass,
self._async_timed_preset_expired,
self._timed_preset_end_time,
)
write_event_log(
_LOGGER,
self._vtherm,
f"Timed preset started: {preset} for {duration_minutes} minutes until {self._timed_preset_end_time}",
)
# Send an event
self._vtherm.send_event(
event_type=EventType.TIMED_PRESET_EVENT,
data={
"type": "start",
"name": self.name,
"preset": str(preset),
"duration_minutes": duration_minutes,
"end_time": self._timed_preset_end_time.isoformat(),
"original_preset": str(self._original_preset),
},
)
# Force update of the thermostat state
self._vtherm.requested_state.force_changed()
await self._vtherm.update_states(force=True)
self._vtherm.update_custom_attributes()
return True
async def cancel_timed_preset(self) -> bool:
"""Cancel the current timed preset if active.
Returns:
True if a timed preset was cancelled, False if none was active
"""
if not self._is_timed_preset_active:
return False
await self._end_timed_preset(cancelled=True)
return True
@callback
async def _async_timed_preset_expired(self, _: datetime):
"""Called when the timed preset timer expires."""
_LOGGER.debug("%s - timed preset timer expired", self)
await self._end_timed_preset()
async def _end_timed_preset(self, cancelled: bool = False):
"""End the timed preset and restore the original preset."""
if not self._is_timed_preset_active:
return
old_preset = self._timed_preset
# Cancel the timer if still active
self._cancel_timed_preset_timer()
# Restore the original preset explicitly (handles the post-restart case)
if self._original_preset is not None:
self._vtherm.requested_state.set_preset(self._original_preset)
# Reset state
self._is_timed_preset_active = False
self._timed_preset = None
self._original_preset = None
self._timed_preset_end_time = None
write_event_log(
_LOGGER,
self._vtherm,
f"Timed preset ended: {old_preset} {'(cancelled)' if cancelled else '(expired)'}",
)
# Send an event
self._vtherm.send_event(
event_type=EventType.TIMED_PRESET_EVENT,
data={
"type": "end",
"name": self.name,
"preset": str(old_preset),
"cause": "cancelled" if cancelled else "expired",
"restored_preset": str(self._vtherm.requested_state.preset),
},
)
# Force update of the thermostat state to restore original preset
self._vtherm.requested_state.force_changed()
await self._vtherm.update_states(force=True)
self._vtherm.update_custom_attributes()
def _cancel_timed_preset_timer(self):
"""Cancel the timed preset timer if active."""
if self._cancel_timer:
self._cancel_timer()
self._cancel_timer = None
def add_custom_attributes(self, extra_state_attributes: dict[str, Any]):
"""Add some custom attributes"""
extra_state_attributes.update(
{
"timed_preset_manager": {
"is_active": self._is_timed_preset_active,
"preset": str(self._timed_preset) if self._timed_preset else None,
"original_preset": str(self._original_preset) if self._original_preset else None,
"end_time": self._timed_preset_end_time.isoformat() if self._timed_preset_end_time else None,
"remaining_time_min": self.remaining_time_min,
}
}
)
@property
def remaining_time_min(self) -> int:
"""Return the remaining time in minutes, or 0 if not active or expired."""
if not self._is_timed_preset_active or not self._timed_preset_end_time:
return 0
remaining = self._timed_preset_end_time - self._vtherm.now
remaining_minutes = remaining.total_seconds() / 60
return max(0, round(remaining_minutes))
@property
def is_timed_preset_active(self) -> bool:
"""Return True if a timed preset is currently active."""
return self._is_timed_preset_active
@property
def timed_preset(self) -> VThermPreset | None:
"""Return the current timed preset, or None if not active."""
return self._timed_preset if self._is_timed_preset_active else None
@property
def timed_preset_end_time(self) -> datetime | None:
"""Return the end time of the timed preset, or None if not active."""
return self._timed_preset_end_time if self._is_timed_preset_active else None
@overrides
@property
def is_configured(self) -> bool:
"""Return True - timed preset feature is always available."""
return True
@property
def is_detected(self) -> bool:
"""Return the overall state of the feature manager based on timed preset states"""
return self.is_timed_preset_active
def __str__(self):
return f"TimedPresetManager-{self.name}"