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