New apps Added

This commit is contained in:
2026-07-08 10:43:39 -04:00
parent 3b1f4bbd75
commit fefc2c8b5c
1114 changed files with 406637 additions and 154 deletions
@@ -0,0 +1,35 @@
"""Domain interfaces - contracts for external interactions.
These abstract base classes define how the domain interacts with
the outside world without coupling to specific implementations.
"""
from __future__ import annotations
from .climate_data_reader_interface import IClimateDataReader
from .context_reader_interface import IContextReader
from .decision_strategy_interface import IDecisionStrategy
from .device_config_reader_interface import IDeviceConfigReader
from .environment_reader_interface import IEnvironmentReader
from .heating_cycle_service_interface import IHeatingCycleService
from .heating_cycle_storage_interface import IHeatingCycleStorage
from .historical_data_adapter_interface import IHistoricalDataAdapter
from .lhs_storage_interface import ILhsStorage
from .scheduler_commander_interface import ISchedulerCommander
from .scheduler_reader_interface import ISchedulerReader
from .timer_scheduler import ITimerScheduler
__all__ = [
"IClimateDataReader",
"ISchedulerReader",
"IEnvironmentReader",
"IContextReader",
"ILhsStorage",
"ISchedulerCommander",
"IDecisionStrategy",
"IHeatingCycleService",
"IHeatingCycleStorage",
"IDeviceConfigReader",
"IHistoricalDataAdapter",
"ITimerScheduler",
]
@@ -0,0 +1,72 @@
"""Climate data reader interface.
Unified interface for reading VTherm climate data: entity identification,
current heating slope, and heating active state. All three concerns target
the *same* VTherm entity and are therefore grouped into a single contract
to avoid unnecessary fragmentation.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
class IClimateDataReader(ABC):
"""Contract for reading climate data from a VTherm entity.
This interface unifies three previously separate readers
(IVThermMetadataReader, IHeatingSlopeReader, IHeatingStateReader)
that all operate on the same underlying VTherm climate entity.
Implementors must be stateless with respect to the returned values;
each call should read the current live state.
"""
@abstractmethod
def get_vtherm_entity_id(self) -> str:
"""Retrieve the VTherm (climate entity) ID.
Returns:
The VTherm entity ID (e.g., ``"climate.living_room_vtherm"``).
"""
@abstractmethod
def get_current_slope(self) -> float | None:
"""Retrieve the current heating slope in °C per hour.
The slope is read from the VTherm entity's attributes and represents
the instantaneous rate of indoor-temperature increase while heating
is active.
Returns:
Current heating slope as a float, or ``None`` when:
- The VTherm entity is unavailable.
- The ``slope`` attribute is missing or cannot be parsed.
"""
@abstractmethod
def is_heating_active(self) -> bool:
"""Return whether heating is currently active on the VTherm.
Heating is typically considered active when:
1. ``hvac_mode`` is ``"heat"``, **and**
2. ``current_temperature < target_temperature``.
Returns:
``True`` when the VTherm is actively heating, ``False`` otherwise
(including when the entity is unavailable).
"""
@abstractmethod
def get_current_target_temperature(self) -> float | None:
"""Retrieve the current target temperature from the VTherm entity.
Reads the real-time target temperature set on the VTherm climate entity.
This is used, for example, to resolve a target temperature for native HA
schedule entities that do not store a temperature themselves.
Returns:
Current target temperature in °C, or ``None`` when:
- The VTherm entity is unavailable.
- The temperature attribute is missing or cannot be parsed.
"""
@@ -0,0 +1,39 @@
"""Environment context reader interface."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
class IContextReader(ABC):
"""Contract for accessing environment metadata and adapter context.
This interface is used by the application layer for historical data
adapter orchestration. It intentionally avoids Home Assistant imports.
"""
@abstractmethod
def get_hass(self) -> Any:
"""Retrieve the Home Assistant instance for adapter orchestration."""
pass
@abstractmethod
def get_humidity_in_entity_id(self) -> str | None:
"""Retrieve the indoor humidity sensor entity ID."""
pass
@abstractmethod
def get_humidity_out_entity_id(self) -> str | None:
"""Retrieve the outdoor humidity sensor entity ID."""
pass
@abstractmethod
def get_outdoor_temp_entity_id(self) -> str | None:
"""Retrieve the outdoor temperature sensor entity ID."""
pass
@abstractmethod
def get_cloud_cover_entity_id(self) -> str | None:
"""Retrieve the cloud coverage sensor entity ID."""
pass
@@ -0,0 +1,52 @@
"""Decision strategy interface for heating control."""
from __future__ import annotations
from abc import ABC, abstractmethod
from ..value_objects import EnvironmentState, HeatingDecision
class IDecisionStrategy(ABC):
"""Contract for heating decision strategies.
This interface allows different decision-making approaches:
- Simple rule-based decisions (no ML required)
- ML-based decisions (requires IHP-ML-Models)
- Hybrid approaches combining both
By abstracting the decision logic, we make the HeatingPilot
agnostic to the complexity of the underlying decision process.
"""
@abstractmethod
async def decide_heating_action(
self,
environment: EnvironmentState,
) -> HeatingDecision:
"""Decide what heating action to take.
Args:
environment: Current environmental conditions
Returns:
A heating decision with the action to take
"""
pass
@abstractmethod
async def check_overshoot_risk(
self,
environment: EnvironmentState,
current_slope: float,
) -> HeatingDecision:
"""Check if heating should stop to prevent overshooting.
Args:
environment: Current environmental conditions
current_slope: Current heating rate in °C/hour
Returns:
Decision to stop heating if overshoot is detected
"""
pass
@@ -0,0 +1,154 @@
"""Device configuration reader interface."""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
@dataclass(frozen=True)
class DeviceConfig:
"""Complete configuration for an IHP device.
This is an immutable value object that holds all configuration parameters
for a single IHP device. It is created by HADeviceConfigReader from
Home Assistant config entries.
Attributes:
# Required fields
device_id: Unique identifier for the device (typically config_entry.entry_id)
vtherm_entity_id: Entity ID of the virtual thermostat (climate entity)
# Optional entity IDs (environmental sensors)
scheduler_entities: List of entity IDs for scheduled events (switches)
humidity_in_entity_id: Entity ID for indoor humidity sensor (optional)
humidity_out_entity_id: Entity ID for outdoor humidity sensor (optional)
cloud_cover_entity_id: Entity ID for cloud coverage sensor (optional)
# Learning and data retention parameters
lhs_retention_days: Number of days to retain learned heating slope data
dead_time_minutes: Dead time in minutes (delay before heating becomes effective)
auto_learning: If True, automatically learn parameters from heating cycles
# Cycle detection parameters
temp_delta_threshold: Temperature delta threshold for cycle detection (°C)
cycle_split_duration_minutes: Duration to split heating cycles (0 = disabled)
min_cycle_duration_minutes: Minimum valid cycle duration
max_cycle_duration_minutes: Maximum valid cycle duration
# IHP control state
ihp_enabled: If True, IHP preheating is active; if False, IHP is paused
task_range_days: Number of days covered by each Recorder extraction task (tune to machine power)
anticipation_recalc_tolerance_minutes: Absolute time delta threshold (minutes) used
to decide whether an active preheating should be
canceled and rescheduled.
safety_shutoff_grace_minutes: Duration in minutes of the grace period for brief heating
interruptions (safety/frost mode). Interruptions shorter than
this threshold do not terminate an in-progress cycle, avoiding
bogus dead-time and slope values. Set 0 to disable.
"""
# Required fields
device_id: str
vtherm_entity_id: str
# Optional entity IDs
scheduler_entities: list[str]
humidity_in_entity_id: str | None = None
humidity_out_entity_id: str | None = None
temperature_out_entity_id: str | None = None
cloud_cover_entity_id: str | None = None
# Learning and data retention
lhs_retention_days: int = 30
dead_time_minutes: float = 0.0
auto_learning: bool = True
# Cycle detection parameters
temp_delta_threshold: float = 0.2
cycle_split_duration_minutes: int = 0
min_cycle_duration_minutes: int = 5
max_cycle_duration_minutes: int = 300
# IHP enabled state
ihp_enabled: bool = True
task_range_days: int = 7
anticipation_recalc_tolerance_minutes: int = 15
safety_shutoff_grace_minutes: int = 10
def __post_init__(self) -> None:
"""Validate configuration values after initialization.
Raises:
ValueError: If any configuration value is invalid
"""
# Validate required fields
if not self.device_id or not isinstance(self.device_id, str):
raise ValueError("device_id must be a non-empty string")
if not self.vtherm_entity_id or not isinstance(self.vtherm_entity_id, str):
raise ValueError("vtherm_entity_id must be a non-empty string")
# Validate scheduler_entities is a list
if not isinstance(self.scheduler_entities, list):
raise ValueError("scheduler_entities must be a list")
# Validate numeric ranges
if self.lhs_retention_days < 0:
raise ValueError("lhs_retention_days must be at least 0")
if self.dead_time_minutes < 0:
raise ValueError("dead_time_minutes must be at least 0")
if self.temp_delta_threshold < 0:
raise ValueError("temp_delta_threshold must be at least 0")
if self.cycle_split_duration_minutes < 0:
raise ValueError("cycle_split_duration_minutes must be at least 0")
if self.min_cycle_duration_minutes < 1:
raise ValueError("min_cycle_duration_minutes must be at least 1")
if self.max_cycle_duration_minutes <= self.min_cycle_duration_minutes:
raise ValueError("max_cycle_duration_minutes must be > min_cycle_duration_minutes")
if self.task_range_days < 1:
raise ValueError("task_range_days must be at least 1")
if self.anticipation_recalc_tolerance_minutes < 1:
raise ValueError("anticipation_recalc_tolerance_minutes must be at least 1")
if self.safety_shutoff_grace_minutes < 0:
raise ValueError("safety_shutoff_grace_minutes must be at least 0")
class IDeviceConfigReader(ABC):
"""Contract for reading device configuration.
Implementations should retrieve configuration for a specific IHP device,
including entity IDs for climate control, scheduling, and environmental sensors.
"""
@abstractmethod
async def get_device_config(self, device_id: str) -> DeviceConfig:
"""Retrieve configuration for a specific device.
Args:
device_id: The device identifier to retrieve configuration for
Returns:
DeviceConfig with all necessary entity mappings
Raises:
ValueError: If device_id is not found or configuration is invalid
"""
pass
@abstractmethod
async def get_all_device_ids(self) -> list[str]:
"""Retrieve list of all configured device IDs.
Returns:
List of configured device IDs
"""
pass
@@ -0,0 +1,80 @@
"""Interface for entity attribute mapping and extraction.
This interface defines the contract for translating entity attributes
into domain value objects and concepts.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from ..value_objects.entity_attribute_mapping import (
AttributeConcept,
EntityAttributeDescriptor,
)
class IEntityAttributeMapper(ABC):
"""Contract for mapping entity attributes to domain concepts.
Implementations handle the complexity of different entity types
(VTherm with nested structures, generic climate entities, etc.)
while presenting a unified interface to the domain layer.
"""
@abstractmethod
def detect_entity_type(
self,
entity_id: str,
) -> EntityAttributeDescriptor:
"""Detect and describe an entity's attribute structure.
This method inspects the entity and determines:
- What type of entity it is (VTherm, climate, etc.)
- What attributes it actually provides
- Which mapping should be used for it
Args:
entity_id: The Home Assistant entity_id to analyze
Returns:
EntityAttributeDescriptor with entity info and appropriate mapping
Raises:
ValueError: If entity type cannot be determined or is unsupported
"""
pass
@abstractmethod
def extract_attribute_value(
self,
attributes: dict[str, Any],
concept: AttributeConcept,
) -> Any | None:
"""Extract a value from entity attributes using the concept mapping.
Tries multiple possible attribute paths (in priority order) to find
the value, supporting different entity structures transparently.
Args:
attributes: The entity's attributes dict
concept: The domain concept to extract
Returns:
The extracted value (could be float, string, bool, etc.) or None if not found
Raises:
ValueError: If concept is required but not found
"""
pass
@abstractmethod
def get_supported_concepts(self) -> list[AttributeConcept]:
"""Get list of domain concepts this mapper can extract.
Returns:
List of AttributeConcept values supported by this mapper
"""
pass
@@ -0,0 +1,52 @@
"""Environment reader interface.
Contract for reading current environmental conditions from external
data sources (e.g., Home Assistant entities).
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from ..value_objects import EnvironmentState
class IEnvironmentReader(ABC):
"""Contract for reading environmental conditions.
This interface defines how the domain accesses environmental data
(temperatures, humidity, cloud coverage, etc.) without coupling to
Home Assistant.
Implementations of this interface translate Home Assistant entity states
into domain value objects (EnvironmentState), enabling pure business logic
testing and maintaining clear architectural separation.
Edge Cases:
- Missing sensors: Methods return None gracefully
- Stale data: Implementations should handle validation
- Entity not found: Return None, not raise exceptions
"""
@abstractmethod
async def get_current_environment(self) -> EnvironmentState | None:
"""Retrieve current environmental conditions.
Reads environmental data from entity states and converts to a
domain EnvironmentState value object.
Returns:
EnvironmentState with current conditions (indoor_temperature,
outdoor_temp, humidity, cloud_coverage, timestamp), or None
if required data (indoor_temperature, humidity, outdoor_temp)
is unavailable.
Edge Cases:
- Returns None if VTherm entity is missing
- Returns None if indoor_temperature cannot be read
- Sensor-specific fallbacks:
- outdoor_temp: Falls back to indoor_temperature if unavailable
- indoor_humidity: Uses 50% default if unavailable
- outdoor_humidity, cloud_coverage: Optional (can be None)
"""
pass
@@ -0,0 +1,37 @@
"""Interface for heating cycle extraction service."""
from __future__ import annotations
from abc import ABC, abstractmethod
from datetime import datetime
from ..value_objects.heating import HeatingCycle
from ..value_objects.historical_data import HistoricalDataSet
class IHeatingCycleService(ABC):
"""Abstract interface for extracting heating cycles from historical data."""
@abstractmethod
async def extract_heating_cycles(
self,
device_id: str,
history_data_set: HistoricalDataSet,
start_time: datetime,
end_time: datetime,
cycle_split_duration_minutes: int | None = 0,
) -> list[HeatingCycle]:
"""Extract heating cycles from a HistoricalDataSet within a given time range.
Args:
device_id: The device identifier for the cycles
history_data_set: A HistoricalDataSet containing all necessary raw sensor data.
start_time: The start of the time range for cycle extraction.
end_time: The end of the time range for cycle extraction.
cycle_split_duration_minutes: Duration in minutes to split long cycles
into smaller sub-cycles for granular analysis. If 0 or None, no splitting.
Returns:
A list of HeatingCycle value objects.
"""
pass
@@ -0,0 +1,131 @@
"""Interface for heating cycle storage."""
from __future__ import annotations
from abc import ABC, abstractmethod
from datetime import date, datetime
from ..value_objects.heating import HeatingCycle
from ..value_objects.heating_cycle_cache_data import HeatingCycleCacheData
class IHeatingCycleStorage(ABC):
"""Contract for persisting and retrieving heating cycle data.
Implementations of this interface handle storage and retrieval of
heating cycles with incremental update support to avoid repeatedly
scanning the entire Home Assistant recorder history.
"""
@abstractmethod
async def get_cache_data(self, device_id: str) -> HeatingCycleCacheData | None:
"""Get cached cycle data for a device.
Returns the complete cache data including cycles and metadata.
Returns None if no cache exists for the device.
Args:
device_id: The device identifier
Returns:
HeatingCycleCacheData if cache exists, None otherwise
"""
pass
@abstractmethod
async def append_cycles(
self,
device_id: str,
new_cycles: list[HeatingCycle],
search_end_time: datetime,
retention_days: int | None = None,
) -> None:
"""Append new cycles to the cache and update search timestamp.
This method adds new cycles to the existing cache (if any) and updates
the last_search_time to track where the next incremental search should begin.
Automatically handles deduplication based on cycle start_time.
Args:
device_id: The device identifier
new_cycles: List of new cycles to append
search_end_time: Timestamp marking the end of this search period
retention_days: Optional retention days to store with cache metadata
"""
pass
@abstractmethod
async def prune_old_cycles(
self,
device_id: str,
reference_time: datetime,
) -> bool:
"""Remove cycles older than the retention period.
Args:
device_id: The device identifier
reference_time: Time to calculate retention from
Returns:
True if any cycles were actually removed, False otherwise
"""
pass
@abstractmethod
async def clear_cache(self, device_id: str) -> None:
"""Clear all cached cycles for a device.
This resets the learning system to its initial state for the device.
Args:
device_id: The device identifier
"""
pass
@abstractmethod
async def get_last_search_time(self, device_id: str) -> datetime | None:
"""Get the timestamp of the last cycle search.
This is used to determine the start time for the next incremental search.
Returns None if no previous search has been performed.
Args:
device_id: The device identifier
Returns:
UTC timestamp of last search, or None if no cache exists
"""
pass
@abstractmethod
async def append_explored_dates(
self,
device_id: str,
explored_dates: set[date],
) -> None:
"""Mark dates as explored (whether they contained cycles or not).
This prevents re-extracting days that have already been examined,
making explored_dates the single source of truth for coverage.
Args:
device_id: The device identifier
explored_dates: Set of dates to mark as explored
"""
pass
@abstractmethod
async def get_oldest_explored_date(self, device_id: str) -> date | None:
"""Return the oldest date in explored_dates for this device.
Used by the progressive backfill scheduler to determine the next
historical period to extract. Returns None if no dates have been
explored yet (e.g. first startup before any extraction completes).
Args:
device_id: The device identifier
Returns:
The oldest explored date, or None if explored_dates is empty
"""
pass
@@ -0,0 +1,78 @@
"""Historical data adapter interface."""
from __future__ import annotations
from abc import ABC, abstractmethod
from datetime import datetime
from ..value_objects import HistoricalDataKey, HistoricalDataSet
class IHistoricalDataAdapter(ABC):
"""Contract for adapting Home Assistant historical data into HistoricalDataSet.
Implementations of this interface retrieve historical data from Home Assistant
for different entity types (climate, sensor, weather) and transform them into
a standardized HistoricalDataSet format.
"""
@abstractmethod
async def fetch_historical_data(
self,
entity_id: str,
data_key: HistoricalDataKey,
start_time: datetime,
end_time: datetime,
) -> HistoricalDataSet:
"""Fetch historical data for an entity and convert to HistoricalDataSet.
Args:
entity_id: The Home Assistant entity ID (e.g., "climate.living_room")
data_key: The HistoricalDataKey to use for categorizing the measurements
start_time: The start of the historical period
end_time: The end of the historical period
Returns:
A HistoricalDataSet containing the fetched and transformed data
Raises:
ValueError: If entity_id is invalid or data cannot be fetched
"""
pass
async def fetch_all_historical_data(
self,
entity_id: str,
start_time: datetime,
end_time: datetime,
) -> HistoricalDataSet:
"""Fetch historical data for all supported keys in a single call.
This default implementation calls fetch_historical_data once per
HistoricalDataKey, which may result in redundant recorder queries.
Subclasses should override this method to fetch the raw history once
and extract all supported keys from that single result.
Args:
entity_id: The Home Assistant entity ID (e.g., "climate.living_room")
start_time: The start of the historical period
end_time: The end of the historical period
Returns:
A HistoricalDataSet containing measurements for all supported keys
"""
combined_data: dict = {}
for data_key in HistoricalDataKey:
result = await self.fetch_historical_data(
entity_id=entity_id,
data_key=data_key,
start_time=start_time,
end_time=end_time,
)
if result and result.data:
for k, measurements in result.data.items():
if measurements:
if k not in combined_data:
combined_data[k] = []
combined_data[k].extend(measurements)
return HistoricalDataSet(data=combined_data)
@@ -0,0 +1,109 @@
"""LHS (Learned Heating Slope) storage interface."""
from __future__ import annotations
from abc import ABC, abstractmethod
from datetime import datetime
from ..value_objects.lhs_cache_entry import LHSCacheEntry
class ILhsStorage(ABC):
"""Contract for persisting learned heating slope (LHS) data.
Implementations of this interface handle storage and retrieval
of learned heating slopes (both global and contextual).
NOTE: Direct slope data persistence (save_slope_*) has been removed.
Slopes are now extracted directly from Home Assistant recorder via
HeatingCycleService. This interface now only provides access to the
global learned heating slope (LHS) and cleanup operations.
"""
@abstractmethod
async def get_learned_heating_slope(self) -> float:
"""Get the current learned heating slope (LHS).
This represents the system's best estimate of the heating rate
based on all historical data.
Returns:
The learned heating slope in °C/hour.
"""
pass
@abstractmethod
async def clear_slope_history(self) -> None:
"""Clear all learned slope data from history.
This resets the learning system to its initial state.
"""
pass
@abstractmethod
async def get_cached_global_lhs(self) -> LHSCacheEntry | None:
"""Return cached global LHS if available.
Returns:
LHSCacheEntry with global LHS value and timestamp, or None if not cached.
"""
pass
@abstractmethod
async def set_cached_global_lhs(self, lhs: float, updated_at: datetime) -> None:
"""Persist global LHS cache with timestamp.
Args:
lhs: The learned heating slope value in °C/hour.
updated_at: Timestamp when the LHS was calculated.
"""
pass
@abstractmethod
async def get_cached_contextual_lhs(self, hour: int) -> LHSCacheEntry | None:
"""Return cached contextual LHS for the given hour if available.
Args:
hour: Hour of day (0-23) for which to retrieve contextual LHS.
Returns:
LHSCacheEntry with contextual LHS value and timestamp, or None if not cached.
"""
pass
@abstractmethod
async def set_cached_contextual_lhs(self, hour: int, lhs: float, updated_at: datetime) -> None:
"""Persist contextual LHS cache for the given hour with timestamp.
Args:
hour: Hour of day (0-23) for which to cache the LHS.
lhs: The learned heating slope value in °C/hour for this hour.
updated_at: Timestamp when the LHS was calculated.
"""
pass
@abstractmethod
async def clear_contextual_cache(self) -> None:
"""Clear all cached contextual LHS entries."""
pass
@abstractmethod
async def get_learned_dead_time(self) -> float | None:
"""Get the current learned dead time in minutes.
Dead time is the delay between starting heat output and when the
indoor temperature begins rising noticeably.
Returns:
The learned dead time in minutes, or None if not yet learned.
"""
pass
@abstractmethod
async def set_learned_dead_time(self, dead_time: float | None) -> None:
"""Persist the learned dead time value.
Args:
dead_time: The learned dead time in minutes, or None to clear.
"""
pass
@@ -0,0 +1,37 @@
"""Scheduler commander interface."""
from __future__ import annotations
from abc import ABC, abstractmethod
from datetime import datetime
class ISchedulerCommander(ABC):
"""Contract for scheduler control actions.
Implementations of this interface execute scheduler commands using
the scheduler component's run_action service.
See: https://github.com/nielsfaber/scheduler-component/#schedulerrun_action
"""
@abstractmethod
async def run_action(self, target_time: datetime, scheduler_entity_id: str) -> None:
"""Trigger a scheduler action for a specific timeslot.
This will start heating in the mode configured in the scheduler
for the timeslot at the given time.
Args:
target_time: The time of the scheduler timeslot to trigger
"""
pass
@abstractmethod
async def cancel_action(self, scheduler_entity_id: str) -> None:
"""Cancel current scheduler action and return to current timeslot.
This is used to stop overshoot by reverting to the mode configured
for the current time (now()).
"""
pass
@@ -0,0 +1,38 @@
"""Scheduler reader interface."""
from __future__ import annotations
from abc import ABC, abstractmethod
from ..value_objects import ScheduledTimeslot
class ISchedulerReader(ABC):
"""Contract for reading scheduled heating timeslots.
Implementations of this interface retrieve schedule information
from external scheduling systems (e.g., Home Assistant scheduler).
See: https://github.com/nielsfaber/scheduler-component/#data-format
"""
@abstractmethod
async def get_next_timeslot(self) -> ScheduledTimeslot | None:
"""Retrieve the next scheduled heating timeslot.
Returns:
The next schedule timeslot, or None if no timeslots are scheduled.
"""
pass
@abstractmethod
async def is_scheduler_enabled(self, scheduler_entity_id: str) -> bool:
"""Check if a specific scheduler is enabled.
Args:
scheduler_entity_id: The scheduler entity ID to check
Returns:
True if the scheduler is enabled (state != "off"), False otherwise
"""
pass
@@ -0,0 +1,36 @@
"""Timer scheduler interface for anticipation triggering.
This interface abstracts timer scheduling operations, allowing the domain
to schedule anticipation triggers without depending on Home Assistant.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from datetime import datetime
from typing import Any, Callable, Coroutine
class ITimerScheduler(ABC):
"""Interface for scheduling timer-based callbacks.
This contract allows the application layer to schedule callbacks
at specific times without coupling to Home Assistant's event loop.
"""
@abstractmethod
def schedule_timer(
self,
target_time: datetime,
callback: Callable[[], Coroutine[Any, Any, Any]],
) -> Callable[[], None]:
"""Schedule a callback to execute at a specific time.
Args:
target_time: When to execute the callback
callback: Async function to execute at target_time
Returns:
Cancel function that can be called to cancel the timer
"""
pass