New apps Added
This commit is contained in:
@@ -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",
|
||||
]
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+72
@@ -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.
|
||||
"""
|
||||
+39
@@ -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
|
||||
+52
@@ -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
|
||||
+154
@@ -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
|
||||
+80
@@ -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
|
||||
+52
@@ -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
|
||||
+37
@@ -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
|
||||
+131
@@ -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
|
||||
+78
@@ -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)
|
||||
+109
@@ -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
|
||||
+37
@@ -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
|
||||
+38
@@ -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
|
||||
Reference in New Issue
Block a user