317 lines
12 KiB
Python
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}"
|