Added VT Thermometer to HACS
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
""" 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}"
|
||||
Reference in New Issue
Block a user