New apps Added
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
"""Infrastructure layer for Home Assistant integration.
|
||||
|
||||
This layer contains Home Assistant-specific implementations of domain interfaces.
|
||||
All homeassistant.* imports are allowed ONLY in this layer.
|
||||
"""
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,33 @@
|
||||
"""Adapters implementing domain interfaces using Home Assistant APIs.
|
||||
|
||||
This module contains thin adapter classes that translate between Home Assistant
|
||||
entities/services and domain value objects. Adapters contain NO business logic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .climate_commander import HAClimateCommander
|
||||
from .climate_data_reader import HAClimateDataReader
|
||||
from .context_reader import HAContextReader
|
||||
from .environment_reader import HAEnvironmentReader
|
||||
from .heating_cycle_storage import HAHeatingCycleStorage
|
||||
from .lhs_storage import HALhsStorage
|
||||
from .scheduler_commander import HASchedulerCommander
|
||||
from .scheduler_reader import HASchedulerReader
|
||||
from .sensor_data_reader import HASensorDataReader
|
||||
from .timer_scheduler import HATimerScheduler
|
||||
from .weather_data_reader import HAWeatherDataReader
|
||||
|
||||
__all__ = [
|
||||
"HAClimateCommander",
|
||||
"HAClimateDataReader",
|
||||
"HAEnvironmentReader",
|
||||
"HAContextReader",
|
||||
"HALhsStorage",
|
||||
"HASchedulerCommander",
|
||||
"HASchedulerReader",
|
||||
"HAHeatingCycleStorage",
|
||||
"HATimerScheduler",
|
||||
"HASensorDataReader",
|
||||
"HAWeatherDataReader",
|
||||
]
|
||||
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.
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.
+213
@@ -0,0 +1,213 @@
|
||||
"""Base class for entity attribute mappers.
|
||||
|
||||
Provides common functionality for mapping entity attributes to domain concepts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from abc import abstractmethod
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ...domain.interfaces.entity_attribute_mapper_interface import IEntityAttributeMapper
|
||||
from ...domain.value_objects.entity_attribute_mapping import (
|
||||
AttributeConcept,
|
||||
EntityAttributeDescriptor,
|
||||
EntityAttributeMapping,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BaseEntityAttributeMapper(IEntityAttributeMapper):
|
||||
"""Base implementation for entity attribute mappers.
|
||||
|
||||
Provides common logic for:
|
||||
- Navigating nested attribute paths
|
||||
- Type conversion
|
||||
- Fallback path handling
|
||||
"""
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the mapper.
|
||||
|
||||
Args:
|
||||
hass: Home Assistant instance
|
||||
"""
|
||||
self._hass = hass
|
||||
|
||||
@abstractmethod
|
||||
def _get_mapping(self) -> EntityAttributeMapping:
|
||||
"""Get the attribute mapping for this mapper type.
|
||||
|
||||
Must be implemented by subclasses.
|
||||
|
||||
Returns:
|
||||
EntityAttributeMapping with entity-specific attribute paths
|
||||
"""
|
||||
pass
|
||||
|
||||
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
|
||||
"""
|
||||
mapping = self._get_mapping()
|
||||
return list(mapping.mappings.keys())
|
||||
|
||||
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
|
||||
- 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
|
||||
"""
|
||||
_LOGGER.debug("Detecting entity type for %s", entity_id)
|
||||
|
||||
# Get current state
|
||||
state = self._hass.states.get(entity_id)
|
||||
if not state:
|
||||
raise ValueError(f"Entity {entity_id} not found in Home Assistant")
|
||||
|
||||
# Collect all attribute names (flat and nested)
|
||||
detected_attributes = self._collect_attribute_names(state.attributes)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Detected attributes for %s: %s",
|
||||
entity_id,
|
||||
detected_attributes,
|
||||
)
|
||||
|
||||
mapping = self._get_mapping()
|
||||
return EntityAttributeDescriptor(
|
||||
entity_id=entity_id,
|
||||
entity_type=mapping.entity_type,
|
||||
detected_attributes=detected_attributes,
|
||||
mapping=mapping,
|
||||
)
|
||||
|
||||
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 not supported by this mapper
|
||||
"""
|
||||
mapping = self._get_mapping()
|
||||
paths = mapping.get_attribute_paths(concept)
|
||||
|
||||
if not paths:
|
||||
raise ValueError(
|
||||
f"Mapper for {mapping.entity_name} does not support concept {concept.value}"
|
||||
)
|
||||
|
||||
# Try each possible path in priority order
|
||||
for path in paths:
|
||||
value = self._get_nested_attribute(attributes, path.path)
|
||||
if value is not None:
|
||||
_LOGGER.debug(
|
||||
"Extracted %s from path '%s': %s",
|
||||
concept.value,
|
||||
path.path,
|
||||
value,
|
||||
)
|
||||
return value
|
||||
|
||||
# Try fallback path if available
|
||||
if path.fallback_path:
|
||||
value = self._get_nested_attribute(attributes, path.fallback_path)
|
||||
if value is not None:
|
||||
_LOGGER.debug(
|
||||
"Extracted %s from fallback path '%s': %s",
|
||||
concept.value,
|
||||
path.fallback_path,
|
||||
value,
|
||||
)
|
||||
return value
|
||||
|
||||
# Not found in any path
|
||||
_LOGGER.debug(
|
||||
"Could not extract %s from attributes (tried paths: %s)",
|
||||
concept.value,
|
||||
[p.path for p in paths],
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _get_nested_attribute(
|
||||
attributes: dict[str, Any],
|
||||
path: str,
|
||||
) -> Any | None:
|
||||
"""Navigate a dot-separated path through nested attributes.
|
||||
|
||||
Args:
|
||||
attributes: Root attributes dict
|
||||
path: Dot-separated path (e.g., "specific_states.temperature")
|
||||
|
||||
Returns:
|
||||
Value at the path or None if not found
|
||||
"""
|
||||
current: Any = attributes
|
||||
for key in path.split("."):
|
||||
if isinstance(current, dict):
|
||||
current = current.get(key)
|
||||
if current is None:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
return current
|
||||
|
||||
@staticmethod
|
||||
def _collect_attribute_names(
|
||||
attributes: dict[str, Any],
|
||||
prefix: str = "",
|
||||
) -> set[str]:
|
||||
"""Recursively collect all attribute names (including nested).
|
||||
|
||||
Args:
|
||||
attributes: Attributes dict to scan
|
||||
prefix: Prefix for nested attributes (used internally)
|
||||
|
||||
Returns:
|
||||
Set of all attribute names with dot notation for nested ones
|
||||
"""
|
||||
names = set()
|
||||
for key, value in attributes.items():
|
||||
full_name = f"{prefix}{key}" if not prefix else f"{prefix}.{key}"
|
||||
names.add(full_name)
|
||||
|
||||
# Recurse into nested dicts (but not too deep to avoid explosion)
|
||||
if isinstance(value, dict) and len(prefix.split(".")) < 3:
|
||||
names.update(BaseEntityAttributeMapper._collect_attribute_names(value, full_name))
|
||||
|
||||
return names
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
"""Base class for Home Assistant storage adapters.
|
||||
|
||||
This module provides a unified base class for storage adapters that use
|
||||
Home Assistant's Store helper. It implements common patterns like lazy loading,
|
||||
timezone-aware datetime handling, and caching control.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Generic, TypeVar
|
||||
|
||||
from homeassistant.helpers.storage import Store
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Storage version
|
||||
STORAGE_VERSION = 1
|
||||
|
||||
# Type variable for generic data structure
|
||||
TData = TypeVar("TData")
|
||||
|
||||
|
||||
class BaseHAStorageAdapter(ABC, Generic[TData]):
|
||||
"""Abstract base class for Home Assistant storage adapters.
|
||||
|
||||
This class provides common functionality for storage adapters:
|
||||
- Lazy loading with caching (_loaded flag)
|
||||
- Timezone-aware datetime parsing and serialization
|
||||
- Caching control based on retention_days
|
||||
- Centralized data persistence
|
||||
|
||||
Subclasses must implement:
|
||||
- _get_default_data(): Returns the default data structure
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry_id: str,
|
||||
storage_key: str | None = None,
|
||||
retention_days: int = 30,
|
||||
) -> None:
|
||||
"""Initialize the base storage adapter.
|
||||
|
||||
Args:
|
||||
hass: Home Assistant instance
|
||||
entry_id: Config entry ID for scoped storage
|
||||
storage_key: Custom storage key (optional, derived from class name if not provided)
|
||||
retention_days: Number of days to retain cached data (0 disables caching)
|
||||
"""
|
||||
self._hass = hass
|
||||
self._entry_id = entry_id
|
||||
self._retention_days = retention_days
|
||||
self._loaded = False
|
||||
|
||||
# Create storage key if not provided
|
||||
if storage_key is None:
|
||||
# Use class name as storage key (e.g., HALhsStorage -> ha_lhs_storage)
|
||||
storage_key = self.__class__.__name__.lower()
|
||||
|
||||
# Initialize Store
|
||||
self._store = Store(hass, STORAGE_VERSION, f"{storage_key}_{entry_id}")
|
||||
self._data: TData = self._get_default_data()
|
||||
|
||||
@abstractmethod
|
||||
def _get_default_data(self) -> TData:
|
||||
"""Return the default data structure for this storage adapter.
|
||||
|
||||
Subclasses must implement this method to provide the initial
|
||||
data structure when storage is empty.
|
||||
|
||||
Returns:
|
||||
The default data structure
|
||||
"""
|
||||
pass
|
||||
|
||||
async def _ensure_loaded(self) -> None:
|
||||
"""Ensure storage data is loaded (lazy loading pattern).
|
||||
|
||||
This method loads data from storage only once (on first call).
|
||||
Subsequent calls use the cached data (_loaded flag).
|
||||
"""
|
||||
if self._loaded:
|
||||
return
|
||||
|
||||
_LOGGER.debug("Loading storage data for %s", self.__class__.__name__)
|
||||
|
||||
stored_data = await self._store.async_load()
|
||||
if stored_data is not None:
|
||||
self._data = stored_data
|
||||
_LOGGER.debug("Loaded existing storage data (version %d)", STORAGE_VERSION)
|
||||
else:
|
||||
# Initialize with default structure
|
||||
self._data = self._get_default_data()
|
||||
_LOGGER.debug("Initialized new storage with default data")
|
||||
|
||||
self._loaded = True
|
||||
|
||||
async def _save_data(self) -> None:
|
||||
"""Persist current data to storage.
|
||||
|
||||
This method saves the current _data to Home Assistant's Store.
|
||||
Should be called after any modification to _data.
|
||||
"""
|
||||
await self._store.async_save(self._data)
|
||||
_LOGGER.debug("Saved storage data for %s", self.__class__.__name__)
|
||||
|
||||
def _parse_datetime(self, dt_string: str) -> datetime:
|
||||
"""Parse an ISO datetime string to a timezone-aware datetime object.
|
||||
|
||||
This method ensures that all datetime objects are timezone-aware.
|
||||
If the input string represents a naive datetime (no timezone),
|
||||
UTC timezone is automatically added.
|
||||
|
||||
Args:
|
||||
dt_string: ISO 8601 datetime string (e.g., "2025-12-18T14:30:00+00:00")
|
||||
|
||||
Returns:
|
||||
Timezone-aware datetime object
|
||||
|
||||
Raises:
|
||||
ValueError: If the datetime string is invalid or empty
|
||||
"""
|
||||
if not dt_string:
|
||||
raise ValueError("Datetime string cannot be empty")
|
||||
|
||||
dt = datetime.fromisoformat(dt_string)
|
||||
|
||||
# Ensure timezone-aware: add UTC if naive
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
return dt
|
||||
|
||||
def _serialize_datetime(self, dt: datetime) -> str:
|
||||
"""Serialize a datetime object to ISO 8601 format string.
|
||||
|
||||
Args:
|
||||
dt: Datetime object (preferably timezone-aware)
|
||||
|
||||
Returns:
|
||||
ISO 8601 formatted string (e.g., "2025-12-18T14:30:00+00:00")
|
||||
"""
|
||||
return dt.isoformat()
|
||||
|
||||
def _is_caching_disabled(self) -> bool:
|
||||
"""Check if caching is disabled based on retention_days setting.
|
||||
|
||||
Caching is considered disabled when retention_days is set to 0.
|
||||
|
||||
Returns:
|
||||
True if caching is disabled, False otherwise
|
||||
"""
|
||||
return self._retention_days == 0
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
"""Home Assistant climate commander adapter.
|
||||
|
||||
This adapter implements the ability to control climate entities (VTherm)
|
||||
from the domain layer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .utils import get_entity_name
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HAClimateCommander:
|
||||
"""Controls climate entities (VTherm) from domain layer.
|
||||
|
||||
This adapter translates domain heating control commands into
|
||||
Home Assistant climate service calls. No business logic.
|
||||
"""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, climate_entity_id: str) -> None:
|
||||
"""Initialize the climate commander.
|
||||
|
||||
Args:
|
||||
hass: Home Assistant instance
|
||||
climate_entity_id: The VTherm/climate entity to control
|
||||
"""
|
||||
self._hass = hass
|
||||
self._climate_entity_id = climate_entity_id
|
||||
self._device_name = get_entity_name(hass, climate_entity_id)
|
||||
|
||||
async def set_temperature(self, target_temp: float) -> None:
|
||||
"""Set target temperature for the climate entity.
|
||||
|
||||
Args:
|
||||
target_temp: Target temperature in Celsius
|
||||
"""
|
||||
_LOGGER.info("[%s] Setting temperature to %.1f°C", self._device_name, target_temp)
|
||||
|
||||
await self._hass.services.async_call(
|
||||
"climate",
|
||||
"set_temperature",
|
||||
{
|
||||
"entity_id": self._climate_entity_id,
|
||||
"temperature": target_temp,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
async def set_hvac_mode(self, mode: str) -> None:
|
||||
"""Set HVAC mode (heat, off, etc.).
|
||||
|
||||
Args:
|
||||
mode: HVAC mode (heat, off, auto, etc.)
|
||||
"""
|
||||
_LOGGER.info("[%s] Setting HVAC mode to %s", self._device_name, mode)
|
||||
|
||||
await self._hass.services.async_call(
|
||||
"climate",
|
||||
"set_hvac_mode",
|
||||
{
|
||||
"entity_id": self._climate_entity_id,
|
||||
"hvac_mode": mode,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
async def turn_off(self) -> None:
|
||||
"""Turn off heating."""
|
||||
await self.set_hvac_mode("off")
|
||||
|
||||
async def turn_on_heat(self, target_temp: float) -> None:
|
||||
"""Turn on heating with target temperature.
|
||||
|
||||
Args:
|
||||
target_temp: Target temperature in Celsius
|
||||
"""
|
||||
await self.set_hvac_mode("heat")
|
||||
await self.set_temperature(target_temp)
|
||||
+565
@@ -0,0 +1,565 @@
|
||||
"""Home Assistant climate data reader adapter.
|
||||
|
||||
Unified adapter combining real-time state reading and historical data access
|
||||
for VTherm climate entities. Merges the former ClimateDataAdapter (historical)
|
||||
and HAClimateDataReader (real-time) into a single cohesive adapter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ...domain.interfaces.climate_data_reader_interface import IClimateDataReader
|
||||
from ...domain.interfaces.historical_data_adapter_interface import IHistoricalDataAdapter
|
||||
from ...domain.value_objects import (
|
||||
HistoricalDataKey,
|
||||
HistoricalDataSet,
|
||||
HistoricalMeasurement,
|
||||
)
|
||||
from ...domain.value_objects.entity_attribute_mapping import AttributeConcept
|
||||
from ..vtherm_compat import get_vtherm_attribute
|
||||
from .entity_attribute_mapper_registry import EntityAttributeMapperRegistry
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from ..recorder_queue import RecorderAccessQueue
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Mapping from HistoricalDataKey to the corresponding domain concept
|
||||
DATA_KEY_TO_CONCEPT: dict[HistoricalDataKey, AttributeConcept] = {
|
||||
HistoricalDataKey.INDOOR_TEMP: AttributeConcept.CURRENT_TEMPERATURE,
|
||||
HistoricalDataKey.TARGET_TEMP: AttributeConcept.TARGET_TEMPERATURE,
|
||||
HistoricalDataKey.HEATING_STATE: AttributeConcept.HVAC_ACTION,
|
||||
HistoricalDataKey.INDOOR_HUMIDITY: AttributeConcept.INDOOR_HUMIDITY,
|
||||
HistoricalDataKey.OUTDOOR_TEMP: AttributeConcept.OUTDOOR_TEMPERATURE,
|
||||
HistoricalDataKey.OUTDOOR_HUMIDITY: AttributeConcept.OUTDOOR_HUMIDITY,
|
||||
HistoricalDataKey.CLOUD_COVERAGE: AttributeConcept.CLOUD_COVERAGE,
|
||||
}
|
||||
|
||||
|
||||
class HAClimateDataReader(IClimateDataReader, IHistoricalDataAdapter):
|
||||
"""Unified adapter for VTherm climate data (real-time + historical).
|
||||
|
||||
Combines the responsibilities of:
|
||||
- Real-time state reading (IClimateDataReader): entity_id, slope, heating_active
|
||||
- Historical data access (IHistoricalDataAdapter): fetch_historical_data
|
||||
|
||||
Uses RecorderAccessQueue (MANDATORY) to serialize database access and prevent
|
||||
Home Assistant performance degradation when multiple IHP instances query
|
||||
historical data simultaneously.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
recorder_queue: RecorderAccessQueue,
|
||||
vtherm_entity_id: str,
|
||||
) -> None:
|
||||
"""Initialize the climate data reader.
|
||||
|
||||
Args:
|
||||
hass: Home Assistant instance
|
||||
recorder_queue: Shared FIFO queue to serialize recorder access (MANDATORY)
|
||||
vtherm_entity_id: VTherm climate entity ID
|
||||
(e.g., ``"climate.living_room_vtherm"``).
|
||||
"""
|
||||
self._hass = hass
|
||||
self._recorder_queue = recorder_queue
|
||||
self._vtherm_entity_id = vtherm_entity_id
|
||||
self._mapper_registry = EntityAttributeMapperRegistry(hass)
|
||||
_LOGGER.debug(
|
||||
"Initialized HAClimateDataReader for %s with mandatory RecorderAccessQueue",
|
||||
vtherm_entity_id,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# IClimateDataReader implementation (real-time state)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_vtherm_entity_id(self) -> str:
|
||||
"""Return the VTherm climate entity ID."""
|
||||
return self._vtherm_entity_id
|
||||
|
||||
def get_current_slope(self) -> float | None:
|
||||
"""Get current heating slope from VTherm.
|
||||
|
||||
Reads real-time state (does NOT use RecorderAccessQueue).
|
||||
|
||||
Returns:
|
||||
Current slope in °C/h, or ``None`` if not available.
|
||||
"""
|
||||
vtherm_state = self._hass.states.get(self._vtherm_entity_id)
|
||||
if not vtherm_state:
|
||||
return None
|
||||
|
||||
slope_raw = get_vtherm_attribute(vtherm_state, "slope")
|
||||
if slope_raw is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return float(slope_raw)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
def is_heating_active(self) -> bool:
|
||||
"""Check if heating is currently active.
|
||||
|
||||
Reads real-time state (does NOT use RecorderAccessQueue).
|
||||
|
||||
Heating is active when:
|
||||
1. ``hvac_mode == "heat"``
|
||||
2. ``current_temperature < target_temperature``
|
||||
|
||||
Returns:
|
||||
``True`` when actively heating, ``False`` otherwise.
|
||||
"""
|
||||
vtherm_state = self._hass.states.get(self._vtherm_entity_id)
|
||||
if not vtherm_state:
|
||||
return False
|
||||
|
||||
hvac_mode = vtherm_state.state
|
||||
if hvac_mode != "heat":
|
||||
return False
|
||||
|
||||
current_temp = get_vtherm_attribute(vtherm_state, "current_temperature")
|
||||
target_temp = get_vtherm_attribute(vtherm_state, "temperature")
|
||||
|
||||
if current_temp is None or target_temp is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
return float(current_temp) < float(target_temp)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
def get_current_target_temperature(self) -> float | None:
|
||||
"""Retrieve the current target temperature from the VTherm entity.
|
||||
|
||||
Reads the live state (does NOT use RecorderAccessQueue).
|
||||
|
||||
Returns:
|
||||
Current target temperature in °C, or ``None`` if the entity is
|
||||
unavailable or the temperature attribute cannot be parsed.
|
||||
"""
|
||||
vtherm_state = self._hass.states.get(self._vtherm_entity_id)
|
||||
if not vtherm_state:
|
||||
_LOGGER.debug("VTherm entity not found when reading target temperature: %s", self._vtherm_entity_id)
|
||||
return None
|
||||
|
||||
for key in ("temperature", "target_temperature", "target_temp"):
|
||||
value = get_vtherm_attribute(vtherm_state, key)
|
||||
if value is not None:
|
||||
try:
|
||||
temp = float(value)
|
||||
if temp > 0:
|
||||
return temp
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
_LOGGER.debug("Could not read target temperature from VTherm %s", self._vtherm_entity_id)
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# IHistoricalDataAdapter implementation (historical data)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def fetch_historical_data(
|
||||
self,
|
||||
entity_id: str,
|
||||
data_key: HistoricalDataKey,
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
) -> HistoricalDataSet:
|
||||
"""Fetch historical data for a climate entity.
|
||||
|
||||
Uses flexible attribute mapping to extract the requested data
|
||||
from the entity's attributes, regardless of entity type.
|
||||
|
||||
USES RecorderAccessQueue to serialize database access.
|
||||
|
||||
Args:
|
||||
entity_id: The climate entity ID (e.g., "climate.living_room")
|
||||
data_key: The HistoricalDataKey to use (INDOOR_TEMP, TARGET_TEMP, HEATING_STATE)
|
||||
start_time: Start of historical period
|
||||
end_time: End of historical period
|
||||
|
||||
Returns:
|
||||
HistoricalDataSet with extracted climate data
|
||||
|
||||
Raises:
|
||||
ValueError: If entity_id is invalid or has missing required attributes
|
||||
"""
|
||||
_LOGGER.debug(
|
||||
"Fetching climate history for %s (data_key: %s) from %s to %s",
|
||||
entity_id,
|
||||
data_key.value,
|
||||
start_time,
|
||||
end_time,
|
||||
)
|
||||
|
||||
# Get mapper for this entity
|
||||
try:
|
||||
mapper = self._mapper_registry.get_mapper_for_entity(entity_id)
|
||||
_LOGGER.debug("Using mapper: %s", type(mapper).__name__)
|
||||
except ValueError as err:
|
||||
_LOGGER.warning(
|
||||
"Cannot fetch historical data for %s: %s. Entity may not exist or may not be configured.",
|
||||
entity_id,
|
||||
err,
|
||||
)
|
||||
raise
|
||||
|
||||
# Map the data_key to a domain concept
|
||||
concept = DATA_KEY_TO_CONCEPT.get(data_key)
|
||||
if not concept:
|
||||
_LOGGER.debug(
|
||||
"No concept mapping for data_key %s, skipping",
|
||||
data_key.value,
|
||||
)
|
||||
return HistoricalDataSet(data={})
|
||||
|
||||
# Check if mapper supports this concept before fetching history
|
||||
supported_concepts = mapper.get_supported_concepts()
|
||||
if concept not in supported_concepts:
|
||||
_LOGGER.debug(
|
||||
"Mapper %s does not support concept %s for data_key %s, skipping",
|
||||
type(mapper).__name__,
|
||||
concept.value,
|
||||
data_key.value,
|
||||
)
|
||||
return HistoricalDataSet(data={})
|
||||
|
||||
# Get historical data from Home Assistant
|
||||
try:
|
||||
historical_records = await self._fetch_history(
|
||||
entity_id,
|
||||
start_time,
|
||||
end_time,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
# Shutdown in progress - abort gracefully
|
||||
_LOGGER.debug("History fetch cancelled (shutdown in progress) for %s", entity_id)
|
||||
raise ValueError(f"Cannot fetch history for entity {entity_id}") from None
|
||||
except asyncio.TimeoutError:
|
||||
# Timeout waiting for database - likely DB is shutdown
|
||||
_LOGGER.error("Timeout fetching history for %s (DB may be shutdown)", entity_id)
|
||||
raise ValueError(f"Cannot fetch history for entity {entity_id}") from None
|
||||
except Exception as exc:
|
||||
_LOGGER.error("Failed to fetch history for %s: %s", entity_id, exc)
|
||||
raise ValueError(f"Cannot fetch history for entity {entity_id}") from exc
|
||||
|
||||
if not historical_records:
|
||||
_LOGGER.warning("No history found for %s", entity_id)
|
||||
return HistoricalDataSet(data={})
|
||||
|
||||
measurements: list[HistoricalMeasurement] = []
|
||||
|
||||
for record in historical_records:
|
||||
timestamp = self._parse_timestamp(record)
|
||||
attributes = record.get("attributes", {})
|
||||
entity_id_from_record = record.get("entity_id", entity_id)
|
||||
|
||||
# Extract value using the flexible mapper
|
||||
try:
|
||||
value = mapper.extract_attribute_value(attributes, concept)
|
||||
except ValueError:
|
||||
_LOGGER.debug("Could not extract %s from attributes", concept.value)
|
||||
value = None
|
||||
|
||||
# Add measurement if value was extracted
|
||||
if value is not None:
|
||||
# For float concepts, ensure we have a numeric value
|
||||
if concept in [
|
||||
AttributeConcept.CURRENT_TEMPERATURE,
|
||||
AttributeConcept.TARGET_TEMPERATURE,
|
||||
]:
|
||||
value = self._safe_float(value)
|
||||
if value is None:
|
||||
continue
|
||||
|
||||
measurements.append(
|
||||
HistoricalMeasurement(
|
||||
timestamp=timestamp,
|
||||
value=value,
|
||||
attributes=attributes,
|
||||
entity_id=entity_id_from_record,
|
||||
)
|
||||
)
|
||||
|
||||
# Build result
|
||||
data: dict[HistoricalDataKey, list[HistoricalMeasurement]] = {}
|
||||
if measurements:
|
||||
data[data_key] = measurements
|
||||
|
||||
_LOGGER.debug(
|
||||
"Extracted %d measurements for %s with key %s using %s",
|
||||
len(measurements),
|
||||
entity_id,
|
||||
data_key.value,
|
||||
type(mapper).__name__,
|
||||
)
|
||||
|
||||
return HistoricalDataSet(data=data)
|
||||
|
||||
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 recorder query.
|
||||
|
||||
Overrides the default implementation to fetch raw history ONCE and extract
|
||||
all supported HistoricalDataKey values from the single result, avoiding
|
||||
redundant recorder queries.
|
||||
|
||||
Args:
|
||||
entity_id: The climate entity ID (e.g., "climate.living_room")
|
||||
start_time: Start of historical period
|
||||
end_time: End of historical period
|
||||
|
||||
Returns:
|
||||
HistoricalDataSet with measurements for all supported data keys
|
||||
|
||||
Raises:
|
||||
ValueError: If entity_id is invalid or history cannot be retrieved
|
||||
"""
|
||||
_LOGGER.debug(
|
||||
"Fetching all climate history for %s from %s to %s (single recorder query)",
|
||||
entity_id,
|
||||
start_time,
|
||||
end_time,
|
||||
)
|
||||
|
||||
# Get mapper for this entity
|
||||
try:
|
||||
mapper = self._mapper_registry.get_mapper_for_entity(entity_id)
|
||||
_LOGGER.debug("Using mapper: %s", type(mapper).__name__)
|
||||
except ValueError as err:
|
||||
_LOGGER.error("Cannot select mapper for %s: %s", entity_id, err)
|
||||
raise
|
||||
|
||||
# Fetch raw history ONCE from the recorder
|
||||
try:
|
||||
historical_records = await self._fetch_history(entity_id, start_time, end_time)
|
||||
except asyncio.CancelledError:
|
||||
_LOGGER.debug("History fetch cancelled (shutdown in progress) for %s", entity_id)
|
||||
raise ValueError(f"Cannot fetch history for entity {entity_id}") from None
|
||||
except asyncio.TimeoutError:
|
||||
_LOGGER.error("Timeout fetching history for %s (DB may be shutdown)", entity_id)
|
||||
raise ValueError(f"Cannot fetch history for entity {entity_id}") from None
|
||||
except Exception as exc:
|
||||
_LOGGER.error("Failed to fetch history for %s: %s", entity_id, exc)
|
||||
raise ValueError(f"Cannot fetch history for entity {entity_id}") from exc
|
||||
|
||||
if not historical_records:
|
||||
_LOGGER.debug("No history found for %s", entity_id)
|
||||
return HistoricalDataSet(data={})
|
||||
|
||||
supported_concepts = mapper.get_supported_concepts()
|
||||
data: dict[HistoricalDataKey, list[HistoricalMeasurement]] = {}
|
||||
|
||||
# Keys needed by domain services (heating_cycle_service uses these)
|
||||
_ESSENTIAL_ATTR_KEYS = {"hvac_action", "hvac_mode"}
|
||||
|
||||
# Extract all supported data keys from the single set of records
|
||||
for data_key, concept in DATA_KEY_TO_CONCEPT.items():
|
||||
if concept not in supported_concepts:
|
||||
continue
|
||||
|
||||
measurements: list[HistoricalMeasurement] = []
|
||||
for record in historical_records:
|
||||
timestamp = self._parse_timestamp(record)
|
||||
full_attributes = record.get("attributes", {})
|
||||
entity_id_from_record = record.get("entity_id", entity_id)
|
||||
|
||||
try:
|
||||
value = mapper.extract_attribute_value(full_attributes, concept)
|
||||
except ValueError:
|
||||
value = None
|
||||
|
||||
if value is not None:
|
||||
if concept in [
|
||||
AttributeConcept.CURRENT_TEMPERATURE,
|
||||
AttributeConcept.TARGET_TEMPERATURE,
|
||||
]:
|
||||
value = self._safe_float(value)
|
||||
if value is None:
|
||||
continue
|
||||
|
||||
# Only keep essential attributes to avoid holding the full
|
||||
# HA State attribute blob in memory (OOM prevention).
|
||||
slim_attributes = {
|
||||
k: full_attributes[k] for k in _ESSENTIAL_ATTR_KEYS if k in full_attributes
|
||||
}
|
||||
|
||||
measurements.append(
|
||||
HistoricalMeasurement(
|
||||
timestamp=timestamp,
|
||||
value=value,
|
||||
attributes=slim_attributes,
|
||||
entity_id=entity_id_from_record,
|
||||
)
|
||||
)
|
||||
|
||||
if measurements:
|
||||
data[data_key] = measurements
|
||||
|
||||
# Release the raw recorder data now that extraction is complete
|
||||
del historical_records
|
||||
|
||||
total = sum(len(v) for v in data.values())
|
||||
_LOGGER.debug(
|
||||
"Extracted %d total measurements for %s across %d keys using %s",
|
||||
total,
|
||||
entity_id,
|
||||
len(data),
|
||||
type(mapper).__name__,
|
||||
)
|
||||
|
||||
return HistoricalDataSet(data=data)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Private helper methods
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _parse_timestamp(record: dict[str, Any]) -> datetime:
|
||||
"""Parse timestamp from history record.
|
||||
|
||||
Args:
|
||||
record: Historical record from Home Assistant
|
||||
|
||||
Returns:
|
||||
Parsed datetime object
|
||||
"""
|
||||
# Home Assistant provides ISO format string timestamps
|
||||
timestamp_str = record.get("last_changed", record.get("last_updated"))
|
||||
|
||||
if isinstance(timestamp_str, str):
|
||||
# Parse ISO format string (e.g., "2024-01-15T12:00:00+00:00")
|
||||
# Remove timezone info for simplicity
|
||||
if "+" in timestamp_str:
|
||||
timestamp_str = timestamp_str.split("+")[0]
|
||||
elif "Z" in timestamp_str:
|
||||
timestamp_str = timestamp_str.replace("Z", "")
|
||||
|
||||
return datetime.fromisoformat(timestamp_str)
|
||||
|
||||
# If already a datetime, return as-is
|
||||
if isinstance(timestamp_str, datetime):
|
||||
return timestamp_str
|
||||
|
||||
# Fallback: return current time if no timestamp found
|
||||
return datetime.now()
|
||||
|
||||
@staticmethod
|
||||
def _safe_float(value: Any) -> float | None:
|
||||
"""Safely convert value to float.
|
||||
|
||||
Args:
|
||||
value: Value to convert
|
||||
|
||||
Returns:
|
||||
Float value or None if conversion fails
|
||||
"""
|
||||
try:
|
||||
return float(value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
async def _fetch_history(
|
||||
self,
|
||||
entity_id: str,
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Fetch historical data from Home Assistant Recorder.
|
||||
|
||||
CRITICAL: Uses RecorderAccessQueue to serialize database access.
|
||||
This is a FIFO queue shared across all IHP instances to prevent
|
||||
overwhelming the recorder during startup or cache refresh.
|
||||
|
||||
Memory optimization: converts State objects to lightweight dicts
|
||||
immediately and releases the original recorder result to prevent
|
||||
holding large HA State blobs in memory (OOM prevention for 8+ devices).
|
||||
|
||||
Args:
|
||||
entity_id: The entity ID
|
||||
start_time: Start of historical period
|
||||
end_time: End of historical period
|
||||
|
||||
Returns:
|
||||
List of historical records from Home Assistant
|
||||
"""
|
||||
from functools import partial
|
||||
|
||||
from homeassistant.components.recorder import get_instance, history
|
||||
|
||||
# Use Home Assistant's get_significant_states function from recorder
|
||||
# Must run in recorder executor to avoid blocking and comply with HA best practices
|
||||
# Use partial to properly pass keyword arguments
|
||||
get_states_func = partial(
|
||||
history.get_significant_states,
|
||||
self._hass,
|
||||
start_time,
|
||||
end_time,
|
||||
entity_ids=[entity_id],
|
||||
)
|
||||
|
||||
# Serialize recorder access via shared FIFO queue (MANDATORY)
|
||||
async with self._recorder_queue.lock:
|
||||
_LOGGER.debug("Acquired recorder lock for climate entity %s", entity_id)
|
||||
history_dict = await get_instance(self._hass).async_add_executor_job(get_states_func)
|
||||
|
||||
# Extract records for our entity - returns list of State objects or dicts
|
||||
state_list = history_dict.get(entity_id, [])
|
||||
# Release the full history dict immediately to free memory
|
||||
del history_dict
|
||||
|
||||
# Convert State objects to lightweight dicts, keeping only the
|
||||
# attributes that downstream code actually needs (hvac_action,
|
||||
# hvac_mode, and the mapper-extracted values). This avoids
|
||||
# holding the full HA attribute blob (20-30 keys per VTherm state
|
||||
# change) in memory across 8 devices × 7 days of data.
|
||||
result = []
|
||||
for state in state_list:
|
||||
if isinstance(state, dict):
|
||||
result.append(state)
|
||||
else:
|
||||
# Extract only essential attributes from the State object
|
||||
raw_attrs = state.attributes
|
||||
slim_attrs = {}
|
||||
for key in (
|
||||
"hvac_action",
|
||||
"hvac_mode",
|
||||
"current_temperature",
|
||||
"temperature",
|
||||
"target_temperature",
|
||||
"humidity",
|
||||
):
|
||||
val = raw_attrs.get(key)
|
||||
if val is not None:
|
||||
slim_attrs[key] = val
|
||||
# VTherm specific_states may contain nested data we need
|
||||
specific = raw_attrs.get("specific_states")
|
||||
if specific is not None:
|
||||
slim_attrs["specific_states"] = specific
|
||||
|
||||
result.append(
|
||||
{
|
||||
"entity_id": state.entity_id,
|
||||
"state": state.state,
|
||||
"attributes": slim_attrs,
|
||||
"last_changed": state.last_changed,
|
||||
"last_updated": state.last_updated,
|
||||
}
|
||||
)
|
||||
# Release the original state list to free LazyState objects
|
||||
del state_list
|
||||
return result
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Home Assistant environment context reader adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from ...domain.interfaces import IContextReader
|
||||
|
||||
|
||||
class HAContextReader(IContextReader):
|
||||
"""Exposes HA context and sensor metadata for historical data adapters."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
outdoor_temp_entity_id: str | None = None,
|
||||
humidity_in_entity_id: str | None = None,
|
||||
humidity_out_entity_id: str | None = None,
|
||||
cloud_cover_entity_id: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize the context reader.
|
||||
|
||||
Args:
|
||||
hass: Home Assistant instance
|
||||
outdoor_temp_entity_id: Outdoor temperature sensor (optional)
|
||||
humidity_in_entity_id: Indoor humidity sensor (optional)
|
||||
humidity_out_entity_id: Outdoor humidity sensor (optional)
|
||||
cloud_cover_entity_id: Cloud coverage sensor (optional)
|
||||
"""
|
||||
self._hass = hass
|
||||
self._outdoor_temp_entity_id = outdoor_temp_entity_id
|
||||
self._humidity_in_entity_id = humidity_in_entity_id
|
||||
self._humidity_out_entity_id = humidity_out_entity_id
|
||||
self._cloud_cover_entity_id = cloud_cover_entity_id
|
||||
|
||||
def get_hass(self) -> Any:
|
||||
"""Return the Home Assistant instance for adapters."""
|
||||
return self._hass
|
||||
|
||||
def get_humidity_in_entity_id(self) -> str | None:
|
||||
"""Return the indoor humidity sensor entity id (optional)."""
|
||||
return self._humidity_in_entity_id
|
||||
|
||||
def get_humidity_out_entity_id(self) -> str | None:
|
||||
"""Return the outdoor humidity sensor entity id (optional)."""
|
||||
return self._humidity_out_entity_id
|
||||
|
||||
def get_outdoor_temp_entity_id(self) -> str | None:
|
||||
"""Return the outdoor temperature sensor entity id (optional)."""
|
||||
return self._outdoor_temp_entity_id
|
||||
|
||||
def get_cloud_cover_entity_id(self) -> str | None:
|
||||
"""Return the cloud coverage sensor entity id (optional)."""
|
||||
return self._cloud_cover_entity_id
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
"""Home Assistant device configuration reader adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ...const import (
|
||||
CONF_ANTICIPATION_RECALC_TOLERANCE_MINUTES,
|
||||
CONF_AUTO_LEARNING,
|
||||
CONF_CLOUD_COVER_ENTITY,
|
||||
CONF_CYCLE_SPLIT_DURATION_MINUTES,
|
||||
CONF_DATA_RETENTION_DAYS,
|
||||
CONF_DEAD_TIME_MINUTES,
|
||||
CONF_HUMIDITY_IN_ENTITY,
|
||||
CONF_HUMIDITY_OUT_ENTITY,
|
||||
CONF_IHP_ENABLED,
|
||||
CONF_LHS_RETENTION_DAYS,
|
||||
CONF_MAX_CYCLE_DURATION_MINUTES,
|
||||
CONF_MIN_CYCLE_DURATION_MINUTES,
|
||||
CONF_SAFETY_SHUTOFF_GRACE_MINUTES,
|
||||
CONF_SCHEDULER_ENTITIES,
|
||||
CONF_TASK_RANGE_DAYS,
|
||||
CONF_TEMP_DELTA_THRESHOLD,
|
||||
CONF_VTHERM_ENTITY,
|
||||
DEFAULT_ANTICIPATION_RECALC_TOLERANCE_MINUTES,
|
||||
DEFAULT_AUTO_LEARNING,
|
||||
DEFAULT_CYCLE_SPLIT_DURATION_MINUTES,
|
||||
DEFAULT_DEAD_TIME_MINUTES,
|
||||
DEFAULT_LHS_RETENTION_DAYS,
|
||||
DEFAULT_MAX_CYCLE_DURATION_MINUTES,
|
||||
DEFAULT_MIN_CYCLE_DURATION_MINUTES,
|
||||
DEFAULT_SAFETY_SHUTOFF_GRACE_MINUTES,
|
||||
DEFAULT_TASK_RANGE_DAYS,
|
||||
DEFAULT_TEMP_DELTA_THRESHOLD,
|
||||
)
|
||||
from ...domain.interfaces.device_config_reader_interface import DeviceConfig, IDeviceConfigReader
|
||||
from ...utils.config_helpers import as_bool
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HADeviceConfigReader(IDeviceConfigReader):
|
||||
"""Home Assistant implementation of device configuration reader.
|
||||
|
||||
Reads configuration from Home Assistant config entries for IHP devices.
|
||||
Since IHP is a single-device integration per config entry, device_id
|
||||
corresponds to the config entry ID.
|
||||
"""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
|
||||
"""Initialize the device config reader.
|
||||
|
||||
Args:
|
||||
hass: Home Assistant instance
|
||||
config_entry: The config entry for this IHP integration instance
|
||||
"""
|
||||
self._hass = hass
|
||||
self._config_entry = config_entry
|
||||
|
||||
async def get_device_config(self, device_id: str) -> DeviceConfig:
|
||||
"""Retrieve configuration for a specific device.
|
||||
|
||||
Args:
|
||||
device_id: The device identifier (corresponds to config entry ID)
|
||||
|
||||
Returns:
|
||||
DeviceConfig with all necessary entity mappings and parameters
|
||||
|
||||
Raises:
|
||||
ValueError: If device_id doesn't match or configuration is invalid
|
||||
"""
|
||||
_LOGGER.debug("Retrieving device configuration for device_id=%s", device_id)
|
||||
|
||||
# In the IHP architecture, device_id corresponds to the config entry ID
|
||||
if device_id != self._config_entry.entry_id:
|
||||
raise ValueError(
|
||||
f"Device ID {device_id} not found. "
|
||||
f"This integration instance manages device {self._config_entry.entry_id}"
|
||||
)
|
||||
|
||||
# Extract configuration with options override support
|
||||
config = dict(self._config_entry.data)
|
||||
options = dict(self._config_entry.options or {})
|
||||
|
||||
vtherm_entity = self._get_config_value(config, options, CONF_VTHERM_ENTITY)
|
||||
if not vtherm_entity:
|
||||
raise ValueError("Missing required vtherm_entity_id in configuration")
|
||||
|
||||
scheduler_entities = self._get_config_value(config, options, CONF_SCHEDULER_ENTITIES) or []
|
||||
if isinstance(scheduler_entities, str):
|
||||
scheduler_entities = [scheduler_entities]
|
||||
scheduler_entities = list(scheduler_entities) if scheduler_entities else []
|
||||
|
||||
humidity_in = self._get_config_value(config, options, CONF_HUMIDITY_IN_ENTITY)
|
||||
humidity_out = self._get_config_value(config, options, CONF_HUMIDITY_OUT_ENTITY)
|
||||
cloud_cover = self._get_config_value(config, options, CONF_CLOUD_COVER_ENTITY)
|
||||
|
||||
# Support backward compatibility: CONF_DATA_RETENTION_DAYS takes precedence, fallback to CONF_LHS_RETENTION_DAYS
|
||||
data_retention = self._get_config_value(config, options, CONF_DATA_RETENTION_DAYS)
|
||||
if data_retention is None:
|
||||
data_retention = self._get_config_value(config, options, CONF_LHS_RETENTION_DAYS)
|
||||
lhs_retention_days = int(
|
||||
data_retention if data_retention is not None else DEFAULT_LHS_RETENTION_DAYS
|
||||
)
|
||||
|
||||
dead_time = self._get_config_value(config, options, CONF_DEAD_TIME_MINUTES)
|
||||
dead_time_minutes = float(dead_time if dead_time is not None else DEFAULT_DEAD_TIME_MINUTES)
|
||||
|
||||
auto_learning_value = self._get_config_value(config, options, CONF_AUTO_LEARNING)
|
||||
auto_learning = bool(
|
||||
auto_learning_value if auto_learning_value is not None else DEFAULT_AUTO_LEARNING
|
||||
)
|
||||
|
||||
# Heating cycle detection parameters
|
||||
temp_delta = self._get_config_value(config, options, CONF_TEMP_DELTA_THRESHOLD)
|
||||
temp_delta_threshold = float(
|
||||
temp_delta if temp_delta is not None else DEFAULT_TEMP_DELTA_THRESHOLD
|
||||
)
|
||||
|
||||
cycle_split = self._get_config_value(config, options, CONF_CYCLE_SPLIT_DURATION_MINUTES)
|
||||
cycle_split_duration_minutes = int(
|
||||
cycle_split if cycle_split is not None else DEFAULT_CYCLE_SPLIT_DURATION_MINUTES
|
||||
)
|
||||
|
||||
min_cycle = self._get_config_value(config, options, CONF_MIN_CYCLE_DURATION_MINUTES)
|
||||
min_cycle_duration_minutes = int(
|
||||
min_cycle if min_cycle is not None else DEFAULT_MIN_CYCLE_DURATION_MINUTES
|
||||
)
|
||||
|
||||
max_cycle = self._get_config_value(config, options, CONF_MAX_CYCLE_DURATION_MINUTES)
|
||||
max_cycle_duration_minutes = int(
|
||||
max_cycle if max_cycle is not None else DEFAULT_MAX_CYCLE_DURATION_MINUTES
|
||||
)
|
||||
|
||||
# IHP enabled state (default to True for backward compatibility)
|
||||
ihp_enabled_value = self._get_config_value(config, options, CONF_IHP_ENABLED)
|
||||
ihp_enabled = as_bool(ihp_enabled_value, default=True)
|
||||
|
||||
task_range = self._get_config_value(config, options, CONF_TASK_RANGE_DAYS)
|
||||
task_range_days = int(task_range if task_range is not None else DEFAULT_TASK_RANGE_DAYS)
|
||||
|
||||
recalc_tolerance = self._get_config_value(
|
||||
config,
|
||||
options,
|
||||
CONF_ANTICIPATION_RECALC_TOLERANCE_MINUTES,
|
||||
)
|
||||
anticipation_recalc_tolerance_minutes = int(
|
||||
recalc_tolerance
|
||||
if recalc_tolerance is not None
|
||||
else DEFAULT_ANTICIPATION_RECALC_TOLERANCE_MINUTES
|
||||
)
|
||||
|
||||
safety_grace = self._get_config_value(config, options, CONF_SAFETY_SHUTOFF_GRACE_MINUTES)
|
||||
safety_shutoff_grace_minutes = int(
|
||||
safety_grace if safety_grace is not None else DEFAULT_SAFETY_SHUTOFF_GRACE_MINUTES
|
||||
)
|
||||
|
||||
device_config = DeviceConfig(
|
||||
device_id=device_id,
|
||||
vtherm_entity_id=vtherm_entity,
|
||||
scheduler_entities=scheduler_entities,
|
||||
humidity_in_entity_id=humidity_in,
|
||||
humidity_out_entity_id=humidity_out,
|
||||
cloud_cover_entity_id=cloud_cover,
|
||||
lhs_retention_days=lhs_retention_days,
|
||||
dead_time_minutes=dead_time_minutes,
|
||||
auto_learning=auto_learning,
|
||||
temp_delta_threshold=temp_delta_threshold,
|
||||
cycle_split_duration_minutes=cycle_split_duration_minutes,
|
||||
min_cycle_duration_minutes=min_cycle_duration_minutes,
|
||||
max_cycle_duration_minutes=max_cycle_duration_minutes,
|
||||
ihp_enabled=ihp_enabled,
|
||||
task_range_days=task_range_days,
|
||||
anticipation_recalc_tolerance_minutes=anticipation_recalc_tolerance_minutes,
|
||||
safety_shutoff_grace_minutes=safety_shutoff_grace_minutes,
|
||||
)
|
||||
|
||||
_LOGGER.debug("Retrieved device configuration: %s", device_config)
|
||||
return device_config
|
||||
|
||||
async def get_all_device_ids(self) -> list[str]:
|
||||
"""Retrieve list of all configured device IDs.
|
||||
|
||||
In IHP architecture, there's typically one device per config entry,
|
||||
so this returns a single-element list.
|
||||
|
||||
Returns:
|
||||
List containing the config entry ID
|
||||
"""
|
||||
return [self._config_entry.entry_id]
|
||||
|
||||
@staticmethod
|
||||
def _get_config_value(config: dict[str, Any], options: dict[str, Any], key: str) -> Any:
|
||||
"""Get configuration value with options override support.
|
||||
|
||||
Options take precedence over config data.
|
||||
"""
|
||||
if key in options:
|
||||
return options.get(key)
|
||||
else:
|
||||
return config.get(key)
|
||||
|
||||
@staticmethod
|
||||
def _get_scheduler_entities(config: dict[str, Any], options: dict[str, Any]) -> list[str]:
|
||||
"""Extract scheduler entities from configuration.
|
||||
|
||||
Returns list of entity IDs, or empty list if not found.
|
||||
"""
|
||||
scheduler_entities = (
|
||||
options.get(CONF_SCHEDULER_ENTITIES) or config.get(CONF_SCHEDULER_ENTITIES) or []
|
||||
)
|
||||
return list(scheduler_entities) if scheduler_entities else []
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
"""Registry for entity attribute mappers.
|
||||
|
||||
Manages mapper implementations and provides automatic detection logic
|
||||
to select the appropriate mapper for a given entity.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ...domain.interfaces.entity_attribute_mapper_interface import IEntityAttributeMapper
|
||||
from .generic_climate_attribute_mapper import GenericClimateAttributeMapper
|
||||
from .vtherm_attribute_mapper import VThermAttributeMapper
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EntityAttributeMapperRegistry:
|
||||
"""Registry for detecting and retrieving entity attribute mappers.
|
||||
|
||||
Automatically detects VTherm, generic climate, and other entity types,
|
||||
returning the appropriate mapper for each entity.
|
||||
"""
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the mapper registry.
|
||||
|
||||
Args:
|
||||
hass: Home Assistant instance
|
||||
"""
|
||||
self._hass = hass
|
||||
self._mappers: dict[str, IEntityAttributeMapper] = {}
|
||||
_LOGGER.debug("Initialized EntityAttributeMapperRegistry")
|
||||
|
||||
def get_mapper_for_entity(
|
||||
self,
|
||||
entity_id: str,
|
||||
) -> IEntityAttributeMapper:
|
||||
"""Get or create the appropriate mapper for an entity.
|
||||
|
||||
Auto-detects entity type and returns the correct mapper:
|
||||
- VTherm entities → VThermAttributeMapper
|
||||
- Generic climate → GenericClimateAttributeMapper
|
||||
- Others → raises ValueError
|
||||
|
||||
Caches mappers by entity_id for efficiency.
|
||||
|
||||
Args:
|
||||
entity_id: The entity_id to get a mapper for
|
||||
|
||||
Returns:
|
||||
IEntityAttributeMapper instance for this entity
|
||||
|
||||
Raises:
|
||||
ValueError: If entity type cannot be determined
|
||||
"""
|
||||
# Return cached mapper if available
|
||||
if entity_id in self._mappers:
|
||||
_LOGGER.debug("Using cached mapper for %s", entity_id)
|
||||
return self._mappers[entity_id]
|
||||
|
||||
_LOGGER.debug("Determining mapper for entity %s", entity_id)
|
||||
|
||||
# Try to detect entity type and select appropriate mapper
|
||||
mapper = self._select_mapper(entity_id)
|
||||
|
||||
# Cache for future use
|
||||
self._mappers[entity_id] = mapper
|
||||
_LOGGER.debug("Cached mapper for %s: %s", entity_id, type(mapper).__name__)
|
||||
|
||||
return mapper
|
||||
|
||||
def _select_mapper(
|
||||
self,
|
||||
entity_id: str,
|
||||
) -> IEntityAttributeMapper:
|
||||
"""Select the appropriate mapper for an entity.
|
||||
|
||||
Tries mappers in priority order until one succeeds.
|
||||
|
||||
Args:
|
||||
entity_id: The entity_id to select a mapper for
|
||||
|
||||
Returns:
|
||||
IEntityAttributeMapper instance
|
||||
|
||||
Raises:
|
||||
ValueError: If no mapper can handle this entity
|
||||
"""
|
||||
# Get entity state
|
||||
state = self._hass.states.get(entity_id)
|
||||
if not state:
|
||||
raise ValueError(f"Entity {entity_id} not found in Home Assistant")
|
||||
|
||||
# Priority order: VTherm first (most specific), then generic climate
|
||||
mappers_to_try = [
|
||||
VThermAttributeMapper(self._hass),
|
||||
GenericClimateAttributeMapper(self._hass),
|
||||
]
|
||||
|
||||
for mapper in mappers_to_try:
|
||||
try:
|
||||
# Try to detect entity type with this mapper
|
||||
# Note: detect_entity_type is sync in our implementation
|
||||
descriptor = mapper.detect_entity_type(entity_id)
|
||||
_LOGGER.debug(
|
||||
"Entity %s detected as %s",
|
||||
entity_id,
|
||||
descriptor.mapping.entity_name,
|
||||
)
|
||||
|
||||
# Check if mapper has required attributes for basic operation
|
||||
from ...domain.value_objects.entity_attribute_mapping import AttributeConcept
|
||||
|
||||
basic_concepts = [
|
||||
AttributeConcept.CURRENT_TEMPERATURE,
|
||||
AttributeConcept.TARGET_TEMPERATURE,
|
||||
]
|
||||
has_attrs, missing = descriptor.has_required_attributes(basic_concepts)
|
||||
|
||||
if has_attrs:
|
||||
return mapper
|
||||
|
||||
_LOGGER.debug(
|
||||
"Mapper %s missing attributes: %s",
|
||||
type(mapper).__name__,
|
||||
missing,
|
||||
)
|
||||
except Exception as err:
|
||||
_LOGGER.debug(
|
||||
"Mapper %s cannot handle %s: %s",
|
||||
type(mapper).__name__,
|
||||
entity_id,
|
||||
err,
|
||||
)
|
||||
|
||||
# No mapper could handle this entity
|
||||
raise ValueError(
|
||||
f"No attribute mapper found for entity {entity_id}. "
|
||||
f"Supported entity types: VTherm, generic climate. "
|
||||
f"Ensure the entity has required attributes (current_temperature, target_temperature, hvac_action)."
|
||||
)
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Clear the mapper cache.
|
||||
|
||||
Useful if entity attributes change and need to be re-detected.
|
||||
"""
|
||||
_LOGGER.debug("Clearing mapper cache")
|
||||
self._mappers.clear()
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
"""Entity validator for initialization checks.
|
||||
|
||||
Validates that selected entities have the required attributes before
|
||||
allowing the integration to start.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ...domain.value_objects.entity_attribute_mapping import AttributeConcept
|
||||
from .entity_attribute_mapper_registry import EntityAttributeMapperRegistry
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EntityAttributeValidator:
|
||||
"""Validates that entities have required attributes for heating cycle extraction.
|
||||
|
||||
This validator runs during initialization to catch configuration issues early,
|
||||
preventing cryptic runtime errors later.
|
||||
"""
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the validator.
|
||||
|
||||
Args:
|
||||
hass: Home Assistant instance
|
||||
"""
|
||||
self._hass = hass
|
||||
self._mapper_registry = EntityAttributeMapperRegistry(hass)
|
||||
|
||||
async def validate_entity_compatibility(
|
||||
self,
|
||||
entity_id: str,
|
||||
required_concepts: list[AttributeConcept] | None = None,
|
||||
) -> tuple[bool, list[str]]:
|
||||
"""Validate that an entity can provide required attributes.
|
||||
|
||||
Checks that:
|
||||
1. Entity exists in Home Assistant
|
||||
2. Entity type is supported (VTherm, generic climate, etc.)
|
||||
3. Entity has attributes needed for the concepts
|
||||
|
||||
Args:
|
||||
entity_id: The entity_id to validate
|
||||
required_concepts: List of concepts that must be supported.
|
||||
If None, uses default: CURRENT_TEMPERATURE, TARGET_TEMPERATURE, HEATING_ACTIVE
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid: bool, issues: list[str])
|
||||
- is_valid: True if entity passes all checks
|
||||
- issues: List of problems found (empty if valid)
|
||||
"""
|
||||
issues = []
|
||||
|
||||
# Set default required concepts if not provided
|
||||
if required_concepts is None:
|
||||
required_concepts = [
|
||||
AttributeConcept.CURRENT_TEMPERATURE,
|
||||
AttributeConcept.TARGET_TEMPERATURE,
|
||||
AttributeConcept.HEATING_ACTIVE,
|
||||
]
|
||||
|
||||
# Check entity exists
|
||||
state = self._hass.states.get(entity_id)
|
||||
if not state:
|
||||
issues.append(f"Entity {entity_id} not found in Home Assistant")
|
||||
return False, issues
|
||||
|
||||
# Try to detect entity type and get mapper
|
||||
try:
|
||||
mapper = self._mapper_registry.get_mapper_for_entity(entity_id)
|
||||
_LOGGER.info(
|
||||
"Validated entity %s: type=%s",
|
||||
entity_id,
|
||||
type(mapper).__name__,
|
||||
)
|
||||
except ValueError as err:
|
||||
issues.append(str(err))
|
||||
return False, issues
|
||||
|
||||
# Check that entity has required attributes
|
||||
try:
|
||||
descriptor = mapper.detect_entity_type(entity_id)
|
||||
except Exception as err:
|
||||
issues.append(f"Cannot analyze entity attributes: {err}")
|
||||
return False, issues
|
||||
|
||||
# Validate required concepts
|
||||
has_attrs, missing = descriptor.has_required_attributes(required_concepts)
|
||||
if not has_attrs:
|
||||
issues.extend(missing)
|
||||
return False, issues
|
||||
|
||||
# All checks passed
|
||||
return True, []
|
||||
|
||||
async def validate_vtherm_for_heating_extraction(
|
||||
self,
|
||||
entity_id: str,
|
||||
) -> tuple[bool, list[str]]:
|
||||
"""Validate a VTherm entity for heating cycle extraction.
|
||||
|
||||
Specifically checks that the entity provides the three essential
|
||||
measurements needed for LHS calculation:
|
||||
- Current indoor temperature
|
||||
- Target temperature
|
||||
- Whether heating is active
|
||||
|
||||
Args:
|
||||
entity_id: The VTherm entity_id to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid: bool, issues: list[str])
|
||||
"""
|
||||
return await self.validate_entity_compatibility(
|
||||
entity_id,
|
||||
required_concepts=[
|
||||
AttributeConcept.CURRENT_TEMPERATURE,
|
||||
AttributeConcept.TARGET_TEMPERATURE,
|
||||
AttributeConcept.HEATING_ACTIVE,
|
||||
],
|
||||
)
|
||||
|
||||
def clear_mapper_cache(self) -> None:
|
||||
"""Clear the mapper cache after entity config changes.
|
||||
|
||||
Call this if entity attributes change and need to be re-detected.
|
||||
"""
|
||||
self._mapper_registry.clear_cache()
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
"""Home Assistant environment reader adapter.
|
||||
|
||||
Reads environmental state from Home Assistant entities (temperatures, humidity, etc.)
|
||||
and converts them to domain value objects.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from ...domain.interfaces import IEnvironmentReader
|
||||
from ...domain.value_objects import EnvironmentState
|
||||
from ..vtherm_compat import get_vtherm_attribute
|
||||
from .utils import get_entity_name
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HAEnvironmentReader(IEnvironmentReader):
|
||||
"""Reads environmental conditions from Home Assistant entities.
|
||||
|
||||
Converts HA entity states into domain EnvironmentState value objects.
|
||||
No business logic - pure data translation.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
vtherm_entity_id: str,
|
||||
outdoor_temp_entity_id: str | None = None,
|
||||
humidity_in_entity_id: str | None = None,
|
||||
humidity_out_entity_id: str | None = None,
|
||||
cloud_cover_entity_id: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize the environment reader.
|
||||
|
||||
Args:
|
||||
hass: Home Assistant instance
|
||||
vtherm_entity_id: VTherm climate entity ID
|
||||
outdoor_temp_entity_id: Outdoor temperature sensor (optional)
|
||||
humidity_in_entity_id: Indoor humidity sensor (optional)
|
||||
humidity_out_entity_id: Outdoor humidity sensor (optional)
|
||||
cloud_cover_entity_id: Cloud coverage sensor (optional)
|
||||
"""
|
||||
self._hass = hass
|
||||
self._vtherm_entity_id = vtherm_entity_id
|
||||
self._outdoor_temp_entity_id = outdoor_temp_entity_id
|
||||
self._humidity_in_entity_id = humidity_in_entity_id
|
||||
self._humidity_out_entity_id = humidity_out_entity_id
|
||||
self._cloud_cover_entity_id = cloud_cover_entity_id
|
||||
self._device_name = get_entity_name(hass, vtherm_entity_id)
|
||||
|
||||
async def get_current_environment(self) -> EnvironmentState | None:
|
||||
"""Read current environmental state from HA entities.
|
||||
|
||||
Returns:
|
||||
EnvironmentState value object, or None if required data missing
|
||||
"""
|
||||
# Get current indoor temperature from VTherm
|
||||
vtherm_state = self._hass.states.get(self._vtherm_entity_id)
|
||||
if not vtherm_state:
|
||||
_LOGGER.warning("[%s] VTherm entity not found", self._device_name)
|
||||
return None
|
||||
|
||||
# Use v8.0.0+ compatible attribute access
|
||||
current_temp_raw = get_vtherm_attribute(vtherm_state, "current_temperature")
|
||||
if current_temp_raw is None:
|
||||
_LOGGER.warning("[%s] No current_temperature available", self._device_name)
|
||||
return None
|
||||
|
||||
try:
|
||||
current_temp = float(current_temp_raw)
|
||||
except (ValueError, TypeError):
|
||||
_LOGGER.warning("Invalid current_temperature: %s", current_temp_raw)
|
||||
return None
|
||||
|
||||
# Get outdoor temperature (required for EnvironmentState)
|
||||
outdoor_temp = self._get_float_state(self._outdoor_temp_entity_id)
|
||||
if outdoor_temp is None:
|
||||
# Fallback: use current temp if outdoor not available
|
||||
outdoor_temp = current_temp
|
||||
_LOGGER.debug("No outdoor temp available, using indoor temp as fallback")
|
||||
|
||||
# Get humidity (required for EnvironmentState)
|
||||
humidity = self._get_float_state(self._humidity_in_entity_id)
|
||||
if humidity is None:
|
||||
# Default fallback humidity
|
||||
humidity = 50.0
|
||||
_LOGGER.debug("No indoor humidity available, using default 50%%")
|
||||
|
||||
# Optional sensors
|
||||
outdoor_humidity = self._get_float_state(self._humidity_out_entity_id)
|
||||
cloud_coverage = self._get_float_state(self._cloud_cover_entity_id)
|
||||
|
||||
return EnvironmentState(
|
||||
indoor_temperature=current_temp,
|
||||
outdoor_temp=outdoor_temp,
|
||||
indoor_humidity=humidity,
|
||||
timestamp=dt_util.now(),
|
||||
outdoor_humidity=outdoor_humidity,
|
||||
cloud_coverage=cloud_coverage,
|
||||
)
|
||||
|
||||
def _get_float_state(self, entity_id: str | None) -> float | None:
|
||||
"""Safely get float value from entity state.
|
||||
|
||||
Args:
|
||||
entity_id: Entity ID to read
|
||||
|
||||
Returns:
|
||||
Float value or None
|
||||
"""
|
||||
if not entity_id:
|
||||
return None
|
||||
|
||||
state = self._hass.states.get(entity_id)
|
||||
if not state:
|
||||
return None
|
||||
|
||||
try:
|
||||
return float(state.state)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
"""Attribute mapper for standard Home Assistant climate entities.
|
||||
|
||||
Handles generic climate entities that follow Home Assistant's standard
|
||||
climate entity attributes (current_temperature, target_temperature, hvac_action).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from ...domain.value_objects.entity_attribute_mapping import (
|
||||
AttributeConcept,
|
||||
AttributePath,
|
||||
EntityAttributeMapping,
|
||||
)
|
||||
from .base_entity_attribute_mapper import BaseEntityAttributeMapper
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GenericClimateAttributeMapper(BaseEntityAttributeMapper):
|
||||
"""Mapper for generic Home Assistant climate entities.
|
||||
|
||||
Supports standard climate entities that follow the Home Assistant
|
||||
climate API conventions:
|
||||
- current_temperature: Current measured temperature
|
||||
- target_temperature: User-set target temperature
|
||||
- hvac_action: Current HVAC action (heating, cooling, idle, etc.)
|
||||
|
||||
This mapper is compatible with most climate integrations that don't
|
||||
have their own specialized attribute structures.
|
||||
"""
|
||||
|
||||
def _get_mapping(self) -> EntityAttributeMapping:
|
||||
"""Get generic climate attribute mapping.
|
||||
|
||||
Maps domain concepts to standard Home Assistant climate attributes.
|
||||
|
||||
Returns:
|
||||
EntityAttributeMapping configured for generic climate entities
|
||||
"""
|
||||
return EntityAttributeMapping(
|
||||
entity_type="climate",
|
||||
entity_name="Generic Climate Entity",
|
||||
mappings={
|
||||
# Current temperature as reported by the entity
|
||||
AttributeConcept.CURRENT_TEMPERATURE: [
|
||||
AttributePath(
|
||||
path="current_temperature",
|
||||
fallback_path=None,
|
||||
required=True,
|
||||
),
|
||||
],
|
||||
# Target temperature set by the user
|
||||
# Standard VTherm uses "temperature", generic climate uses "target_temperature"
|
||||
AttributeConcept.TARGET_TEMPERATURE: [
|
||||
AttributePath(
|
||||
path="temperature",
|
||||
fallback_path="target_temperature",
|
||||
required=True,
|
||||
),
|
||||
],
|
||||
# Whether heating is active
|
||||
# Determined from hvac_action
|
||||
AttributeConcept.HEATING_ACTIVE: [
|
||||
AttributePath(
|
||||
path="hvac_action",
|
||||
fallback_path=None,
|
||||
required=True,
|
||||
),
|
||||
],
|
||||
# Raw hvac_action string
|
||||
AttributeConcept.HVAC_ACTION: [
|
||||
AttributePath(
|
||||
path="hvac_action",
|
||||
fallback_path=None,
|
||||
required=False,
|
||||
),
|
||||
],
|
||||
},
|
||||
)
|
||||
+502
@@ -0,0 +1,502 @@
|
||||
"""Home Assistant heating cycle storage adapter.
|
||||
|
||||
This adapter implements IHeatingCycleStorage by using Home Assistant's storage helper
|
||||
to persist heating cycles with incremental update support.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from ...domain.interfaces import IHeatingCycleStorage
|
||||
from ...domain.value_objects import HeatingCycleCacheData
|
||||
from ...domain.value_objects.heating import HeatingCycle, TariffPeriodDetail
|
||||
from .base_ha_storage import BaseHAStorageAdapter
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Storage key
|
||||
STORAGE_KEY = "intelligent_heating_pilot_heating_cycle"
|
||||
|
||||
# Default retention for cycles (days)
|
||||
DEFAULT_RETENTION_DAYS = 30
|
||||
|
||||
|
||||
class HAHeatingCycleStorage(BaseHAStorageAdapter[dict[str, Any]], IHeatingCycleStorage):
|
||||
"""Home Assistant implementation of heating cycle storage.
|
||||
|
||||
Uses Home Assistant's Store helper to persist heating cycles with
|
||||
metadata for incremental updates. Cycles are stored per device
|
||||
and automatically pruned based on retention settings.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry_id: str,
|
||||
retention_days: int = DEFAULT_RETENTION_DAYS,
|
||||
) -> None:
|
||||
"""Initialize the heating cycle storage adapter.
|
||||
|
||||
Args:
|
||||
hass: Home Assistant instance
|
||||
entry_id: Config entry ID for scoped storage
|
||||
retention_days: Number of days to retain cycles (default: 30)
|
||||
"""
|
||||
super().__init__(
|
||||
hass=hass,
|
||||
entry_id=entry_id,
|
||||
storage_key=STORAGE_KEY,
|
||||
retention_days=retention_days,
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Initializing HAHeatingCycleStorage with entry_id=%s, retention_days=%s",
|
||||
entry_id,
|
||||
retention_days,
|
||||
)
|
||||
|
||||
def _get_default_data(self) -> dict[str, Any]:
|
||||
"""Return default data structure for heating cycle storage.
|
||||
|
||||
Returns:
|
||||
Empty dictionary for device cycle data
|
||||
"""
|
||||
return {}
|
||||
|
||||
async def get_cache_data(self, device_id: str) -> HeatingCycleCacheData | None:
|
||||
"""Get cached cycle data for a device.
|
||||
|
||||
Args:
|
||||
device_id: The device identifier
|
||||
|
||||
Returns:
|
||||
HeatingCycleCacheData if cache exists, None otherwise
|
||||
"""
|
||||
_LOGGER.debug("Entering HAHeatingCycleStorage.get_cache_data")
|
||||
_LOGGER.debug("Getting cache data for device_id=%s", device_id)
|
||||
|
||||
await self._ensure_loaded()
|
||||
|
||||
device_data = self._data.get(device_id)
|
||||
if not device_data:
|
||||
_LOGGER.debug("No cache found for device_id=%s", device_id)
|
||||
_LOGGER.debug("Exiting HAHeatingCycleStorage.get_cache_data")
|
||||
return None
|
||||
|
||||
# Deserialize cycles
|
||||
cycles = self._deserialize_heating_cycles(device_data.get("cycles", []))
|
||||
last_search_time_str = device_data.get("last_search_time")
|
||||
|
||||
if not last_search_time_str:
|
||||
_LOGGER.warning("Invalid last_search_time in cache for device %s", device_id)
|
||||
_LOGGER.debug("Exiting HAHeatingCycleStorage.get_cache_data")
|
||||
return None
|
||||
|
||||
try:
|
||||
last_search_time = self._parse_datetime(last_search_time_str)
|
||||
except ValueError as e:
|
||||
_LOGGER.warning("Failed to parse last_search_time for device %s: %s", device_id, e)
|
||||
_LOGGER.debug("Exiting HAHeatingCycleStorage.get_cache_data")
|
||||
return None
|
||||
|
||||
retention_days = device_data.get("retention_days", self._retention_days)
|
||||
|
||||
# Deserialize explored_dates (list of ISO date strings)
|
||||
explored_dates_str = device_data.get("explored_dates", [])
|
||||
explored_dates = set()
|
||||
for date_str in explored_dates_str:
|
||||
try:
|
||||
explored_dates.add(datetime.fromisoformat(date_str).date())
|
||||
except (ValueError, TypeError):
|
||||
_LOGGER.warning("Failed to parse explored date: %s", date_str)
|
||||
|
||||
cache_data = HeatingCycleCacheData(
|
||||
device_id=device_id,
|
||||
cycles=tuple(cycles),
|
||||
last_search_time=last_search_time,
|
||||
retention_days=retention_days,
|
||||
explored_dates=frozenset(explored_dates),
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Retrieved cache with %d cycles, last_search_time=%s",
|
||||
len(cycles),
|
||||
last_search_time,
|
||||
)
|
||||
_LOGGER.debug("Exiting HAHeatingCycleStorage.get_cache_data")
|
||||
|
||||
return cache_data
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
"""
|
||||
_LOGGER.debug("Entering HAHeatingCycleStorage.append_cycles")
|
||||
_LOGGER.debug(
|
||||
"Appending %d cycles for device_id=%s, search_end_time=%s, retention_days=%s",
|
||||
len(new_cycles),
|
||||
device_id,
|
||||
search_end_time,
|
||||
retention_days,
|
||||
)
|
||||
|
||||
await self._ensure_loaded()
|
||||
|
||||
# Get existing cache or initialize
|
||||
existing_cache = await self.get_cache_data(device_id)
|
||||
|
||||
existing_cycles = list(existing_cache.cycles) if existing_cache else []
|
||||
existing_explored_dates = set(existing_cache.explored_dates) if existing_cache else set()
|
||||
|
||||
# Deduplicate: Use (start_time, device_id) as key
|
||||
existing_keys = {(cycle.start_time, cycle.device_id) for cycle in existing_cycles}
|
||||
|
||||
# Add only new cycles
|
||||
unique_new_cycles = [
|
||||
cycle
|
||||
for cycle in new_cycles
|
||||
if (cycle.start_time, cycle.device_id) not in existing_keys
|
||||
]
|
||||
|
||||
# Combine and sort by start_time
|
||||
all_cycles = existing_cycles + unique_new_cycles
|
||||
all_cycles.sort(key=lambda c: c.start_time)
|
||||
|
||||
# Use provided retention_days or fall back to instance default
|
||||
stored_retention_days = (
|
||||
retention_days if retention_days is not None else self._retention_days
|
||||
)
|
||||
|
||||
# Serialize explored_dates as ISO date strings
|
||||
explored_dates_serialized = [d.isoformat() for d in existing_explored_dates]
|
||||
|
||||
# Update storage
|
||||
self._data[device_id] = {
|
||||
"cycles": self._serialize_heating_cycles(all_cycles),
|
||||
"last_search_time": self._serialize_datetime(search_end_time),
|
||||
"retention_days": stored_retention_days,
|
||||
"explored_dates": explored_dates_serialized,
|
||||
}
|
||||
|
||||
await self._save_data()
|
||||
|
||||
_LOGGER.debug(
|
||||
"Appended %d unique cycles (total now: %d) for device %s",
|
||||
len(unique_new_cycles),
|
||||
len(all_cycles),
|
||||
device_id,
|
||||
)
|
||||
_LOGGER.debug("Exiting HAHeatingCycleStorage.append_cycles")
|
||||
|
||||
async def append_explored_dates(
|
||||
self,
|
||||
device_id: str,
|
||||
explored_dates: set[date],
|
||||
) -> None:
|
||||
"""Mark dates as explored (even if no cycles were found).
|
||||
|
||||
This prevents re-extracting empty days indefinitely.
|
||||
|
||||
Args:
|
||||
device_id: The device identifier
|
||||
explored_dates: Set of dates to mark as explored
|
||||
"""
|
||||
_LOGGER.debug("Entering HAHeatingCycleStorage.append_explored_dates")
|
||||
_LOGGER.debug(
|
||||
"Appending %d explored dates for device_id=%s",
|
||||
len(explored_dates),
|
||||
device_id,
|
||||
)
|
||||
|
||||
await self._ensure_loaded()
|
||||
|
||||
# Get existing cache
|
||||
cache_data = await self.get_cache_data(device_id)
|
||||
if cache_data is None:
|
||||
_LOGGER.debug(
|
||||
"No cache exists for device %s, creating initial cache entry for explored dates",
|
||||
device_id,
|
||||
)
|
||||
explored_dates_serialized = [d.isoformat() for d in explored_dates]
|
||||
self._data[device_id] = {
|
||||
"cycles": [],
|
||||
"last_search_time": self._serialize_datetime(datetime.utcnow()),
|
||||
"retention_days": self._retention_days,
|
||||
"explored_dates": explored_dates_serialized,
|
||||
}
|
||||
await self._save_data()
|
||||
_LOGGER.debug(
|
||||
"Initialized cache with %d explored dates for device %s",
|
||||
len(explored_dates),
|
||||
device_id,
|
||||
)
|
||||
_LOGGER.debug("Exiting HAHeatingCycleStorage.append_explored_dates")
|
||||
return
|
||||
|
||||
# Merge explored dates
|
||||
merged_explored_dates = set(cache_data.explored_dates) | explored_dates
|
||||
explored_dates_serialized = [d.isoformat() for d in merged_explored_dates]
|
||||
|
||||
# Update storage (keep existing cycles and last_search_time)
|
||||
self._data[device_id] = {
|
||||
"cycles": self._serialize_heating_cycles(list(cache_data.cycles)),
|
||||
"last_search_time": self._serialize_datetime(cache_data.last_search_time),
|
||||
"retention_days": cache_data.retention_days,
|
||||
"explored_dates": explored_dates_serialized,
|
||||
}
|
||||
|
||||
await self._save_data()
|
||||
|
||||
_LOGGER.debug(
|
||||
"Appended explored dates (total now: %d) for device %s",
|
||||
len(merged_explored_dates),
|
||||
device_id,
|
||||
)
|
||||
_LOGGER.debug("Exiting HAHeatingCycleStorage.append_explored_dates")
|
||||
|
||||
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
|
||||
"""
|
||||
_LOGGER.debug("Entering HAHeatingCycleStorage.prune_old_cycles")
|
||||
_LOGGER.debug(
|
||||
"Pruning cycles for device_id=%s, reference_time=%s",
|
||||
device_id,
|
||||
reference_time,
|
||||
)
|
||||
|
||||
await self._ensure_loaded()
|
||||
|
||||
cache_data = await self.get_cache_data(device_id)
|
||||
if not cache_data:
|
||||
_LOGGER.debug("No cache to prune for device %s", device_id)
|
||||
_LOGGER.debug("Exiting HAHeatingCycleStorage.prune_old_cycles")
|
||||
return False
|
||||
|
||||
cutoff_time = reference_time - timedelta(days=cache_data.retention_days)
|
||||
|
||||
# Filter cycles within retention
|
||||
retained_cycles = [cycle for cycle in cache_data.cycles if cycle.start_time >= cutoff_time]
|
||||
|
||||
removed_count = len(cache_data.cycles) - len(retained_cycles)
|
||||
|
||||
if removed_count > 0:
|
||||
# Also prune explored_dates that are older than retention
|
||||
cutoff_date = cutoff_time.date()
|
||||
retained_explored_dates = {d for d in cache_data.explored_dates if d >= cutoff_date}
|
||||
explored_dates_serialized = [d.isoformat() for d in retained_explored_dates]
|
||||
|
||||
# Update storage
|
||||
self._data[device_id] = {
|
||||
"cycles": self._serialize_heating_cycles(list(retained_cycles)),
|
||||
"last_search_time": self._serialize_datetime(cache_data.last_search_time),
|
||||
"retention_days": cache_data.retention_days,
|
||||
"explored_dates": explored_dates_serialized,
|
||||
}
|
||||
|
||||
await self._save_data()
|
||||
|
||||
_LOGGER.debug(
|
||||
"Pruned %d cycles older than %s (retained %d)",
|
||||
removed_count,
|
||||
cutoff_time,
|
||||
len(retained_cycles),
|
||||
)
|
||||
_LOGGER.debug("Exiting HAHeatingCycleStorage.prune_old_cycles")
|
||||
return True
|
||||
|
||||
_LOGGER.debug("No cycles to prune for device %s", device_id)
|
||||
_LOGGER.debug("Exiting HAHeatingCycleStorage.prune_old_cycles")
|
||||
return False
|
||||
|
||||
async def clear_cache(self, device_id: str) -> None:
|
||||
"""Clear all cached cycles for a device.
|
||||
|
||||
Args:
|
||||
device_id: The device identifier
|
||||
"""
|
||||
_LOGGER.debug("Entering HAHeatingCycleStorage.clear_cache")
|
||||
_LOGGER.debug("Clearing cache for device_id=%s", device_id)
|
||||
|
||||
await self._ensure_loaded()
|
||||
|
||||
if device_id in self._data:
|
||||
del self._data[device_id]
|
||||
await self._save_data()
|
||||
_LOGGER.debug("Cleared cache for device %s", device_id)
|
||||
else:
|
||||
_LOGGER.debug("No cache to clear for device %s", device_id)
|
||||
|
||||
_LOGGER.debug("Exiting HAHeatingCycleStorage.clear_cache")
|
||||
|
||||
async def get_oldest_explored_date(self, device_id: str) -> date | None:
|
||||
"""Return the oldest date in explored_dates for this device, or None.
|
||||
|
||||
Used by the progressive backfill scheduler to know how far back
|
||||
extraction has reached so the next task_range_days step can be calculated.
|
||||
|
||||
Args:
|
||||
device_id: The device identifier
|
||||
|
||||
Returns:
|
||||
The oldest explored date, or None if explored_dates is empty
|
||||
"""
|
||||
_LOGGER.debug("Entering HAHeatingCycleStorage.get_oldest_explored_date")
|
||||
cache_data = await self.get_cache_data(device_id)
|
||||
result = (
|
||||
min(cache_data.explored_dates) if cache_data and cache_data.explored_dates else None
|
||||
)
|
||||
_LOGGER.debug("Oldest explored date for device %s: %s", device_id, result)
|
||||
_LOGGER.debug("Exiting HAHeatingCycleStorage.get_oldest_explored_date")
|
||||
return result
|
||||
|
||||
async def get_last_search_time(self, device_id: str) -> datetime | None:
|
||||
"""Get the timestamp of the last cycle search.
|
||||
|
||||
Args:
|
||||
device_id: The device identifier
|
||||
|
||||
Returns:
|
||||
UTC timestamp of last search, or None if no cache exists
|
||||
"""
|
||||
_LOGGER.debug("Entering HAHeatingCycleStorage.get_last_search_time")
|
||||
_LOGGER.debug("Getting last search time for device_id=%s", device_id)
|
||||
|
||||
cache_data = await self.get_cache_data(device_id)
|
||||
|
||||
result = cache_data.last_search_time if cache_data else None
|
||||
|
||||
_LOGGER.debug("Last search time for device %s: %s", device_id, result)
|
||||
_LOGGER.debug("Exiting HAHeatingCycleStorage.get_last_search_time")
|
||||
|
||||
return result
|
||||
|
||||
def _serialize_heating_cycles(self, cycles: list[HeatingCycle]) -> list[dict[str, Any]]:
|
||||
"""Serialize HeatingCycle objects to JSON-compatible dicts.
|
||||
|
||||
Args:
|
||||
cycles: List of HeatingCycle objects
|
||||
|
||||
Returns:
|
||||
List of serialized cycle dictionaries
|
||||
"""
|
||||
return [self._serialize_heating_cycle(cycle) for cycle in cycles]
|
||||
|
||||
def _serialize_heating_cycle(self, cycle: HeatingCycle) -> dict[str, Any]:
|
||||
"""Serialize a single HeatingCycle to a JSON-compatible dict.
|
||||
|
||||
Args:
|
||||
cycle: HeatingCycle object
|
||||
|
||||
Returns:
|
||||
Serialized cycle dictionary
|
||||
"""
|
||||
cycle_dict = {
|
||||
"device_id": cycle.device_id,
|
||||
"start_time": self._serialize_datetime(cycle.start_time),
|
||||
"end_time": self._serialize_datetime(cycle.end_time),
|
||||
"target_temp": cycle.target_temp,
|
||||
"end_temp": cycle.end_temp,
|
||||
"start_temp": cycle.start_temp,
|
||||
"tariff_details": None,
|
||||
"dead_time_cycle_minutes": cycle.dead_time_cycle_minutes,
|
||||
}
|
||||
|
||||
# Serialize tariff details if present
|
||||
if cycle.tariff_details:
|
||||
cycle_dict["tariff_details"] = [
|
||||
{
|
||||
"tariff_price_eur_per_kwh": td.tariff_price_eur_per_kwh,
|
||||
"energy_kwh": td.energy_kwh,
|
||||
"heating_duration_minutes": td.heating_duration_minutes,
|
||||
"cost_euro": td.cost_euro,
|
||||
}
|
||||
for td in cycle.tariff_details
|
||||
]
|
||||
|
||||
return cycle_dict
|
||||
|
||||
def _deserialize_heating_cycles(self, cycle_dicts: list[dict[str, Any]]) -> list[HeatingCycle]:
|
||||
"""Deserialize JSON-compatible dicts to HeatingCycle objects.
|
||||
|
||||
Args:
|
||||
cycle_dicts: List of serialized cycle dictionaries
|
||||
|
||||
Returns:
|
||||
List of HeatingCycle objects
|
||||
"""
|
||||
cycles = []
|
||||
for cycle_dict in cycle_dicts:
|
||||
try:
|
||||
cycle = self._deserialize_heating_cycle(cycle_dict)
|
||||
cycles.append(cycle)
|
||||
except (KeyError, ValueError, TypeError) as exc:
|
||||
_LOGGER.warning("Failed to deserialize cycle: %s", exc)
|
||||
continue
|
||||
|
||||
return cycles
|
||||
|
||||
def _deserialize_heating_cycle(self, cycle_dict: dict[str, Any]) -> HeatingCycle:
|
||||
"""Deserialize a single JSON-compatible dict to HeatingCycle object.
|
||||
|
||||
Args:
|
||||
cycle_dict: Serialized cycle dictionary
|
||||
|
||||
Returns:
|
||||
HeatingCycle object
|
||||
|
||||
Raises:
|
||||
KeyError, ValueError, TypeError: If deserialization fails
|
||||
"""
|
||||
# Deserialize tariff details if present
|
||||
tariff_details = None
|
||||
if cycle_dict.get("tariff_details"):
|
||||
tariff_details = [
|
||||
TariffPeriodDetail(
|
||||
tariff_price_eur_per_kwh=td["tariff_price_eur_per_kwh"],
|
||||
energy_kwh=td["energy_kwh"],
|
||||
heating_duration_minutes=td["heating_duration_minutes"],
|
||||
cost_euro=td["cost_euro"],
|
||||
)
|
||||
for td in cycle_dict["tariff_details"]
|
||||
]
|
||||
|
||||
return HeatingCycle(
|
||||
device_id=cycle_dict["device_id"],
|
||||
start_time=self._parse_datetime(cycle_dict["start_time"]),
|
||||
end_time=self._parse_datetime(cycle_dict["end_time"]),
|
||||
target_temp=cycle_dict["target_temp"],
|
||||
end_temp=cycle_dict["end_temp"],
|
||||
start_temp=cycle_dict["start_temp"],
|
||||
tariff_details=tariff_details,
|
||||
dead_time_cycle_minutes=cycle_dict.get("dead_time_cycle_minutes"),
|
||||
)
|
||||
@@ -0,0 +1,287 @@
|
||||
"""Home Assistant LHS (Learned Heating Slope) storage adapter.
|
||||
|
||||
This adapter implements ILhsStorage by using Home Assistant's storage helper
|
||||
to persist the learned heating slope (LHS).
|
||||
|
||||
NOTE: Individual slope data is no longer persisted here. Slopes are now extracted
|
||||
directly from Home Assistant recorder via HeatingCycleService.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from ...domain.interfaces import ILhsStorage
|
||||
from ...domain.value_objects.lhs_cache_entry import LHSCacheEntry
|
||||
from .base_ha_storage import BaseHAStorageAdapter
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Storage key
|
||||
STORAGE_KEY = "intelligent_heating_pilot_lhs"
|
||||
|
||||
# Default values
|
||||
DEFAULT_HEATING_SLOPE = 2.0 # °C/h - Conservative default
|
||||
|
||||
|
||||
class HALhsStorage(BaseHAStorageAdapter[dict[str, Any]], ILhsStorage):
|
||||
"""Home Assistant implementation of LHS storage.
|
||||
|
||||
Uses Home Assistant's Store helper to persist the learned heating slope (LHS).
|
||||
This is a simplified adapter that only stores the global LHS value.
|
||||
|
||||
Individual slope data extraction now comes from Home Assistant recorder
|
||||
via HeatingCycleService.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry_id: str,
|
||||
retention_days: int = 30,
|
||||
) -> None:
|
||||
"""Initialize the LHS storage adapter.
|
||||
|
||||
Args:
|
||||
hass: Home Assistant instance
|
||||
entry_id: Config entry ID for scoped storage
|
||||
retention_days: Number of days to retain LHS cache data.
|
||||
When 0, caching is disabled and default LHS is always used.
|
||||
"""
|
||||
super().__init__(
|
||||
hass=hass,
|
||||
entry_id=entry_id,
|
||||
storage_key=STORAGE_KEY,
|
||||
retention_days=retention_days,
|
||||
)
|
||||
|
||||
if self._is_caching_disabled():
|
||||
_LOGGER.debug(
|
||||
"LHS storage initialized with retention_days=0 (caching disabled for %s)",
|
||||
entry_id,
|
||||
)
|
||||
|
||||
def _get_default_data(self) -> dict[str, Any]:
|
||||
"""Return default data structure for LHS storage.
|
||||
|
||||
Returns:
|
||||
Default dictionary with cached_global_lhs and learned_dead_time
|
||||
"""
|
||||
return {
|
||||
"cached_global_lhs": None,
|
||||
"cached_contextual_lhs": {},
|
||||
"learned_dead_time": None,
|
||||
}
|
||||
|
||||
async def get_learned_heating_slope(self) -> float:
|
||||
"""Get the current learned heating slope (LHS).
|
||||
|
||||
Returns the global learned heating slope, or the default value if not available.
|
||||
When retention_days=0, caching is disabled and default value is always returned.
|
||||
This is now primarily used as a fallback when contextual LHS cannot be computed.
|
||||
|
||||
Returns:
|
||||
The learned heating slope in °C/hour.
|
||||
"""
|
||||
# When retention is disabled, return default immediately without loading
|
||||
if self._is_caching_disabled():
|
||||
_LOGGER.debug(
|
||||
"Retention disabled (retention_days=%d), returning default LHS: %.2f°C/h",
|
||||
self._retention_days,
|
||||
DEFAULT_HEATING_SLOPE,
|
||||
)
|
||||
return DEFAULT_HEATING_SLOPE
|
||||
|
||||
await self._ensure_loaded()
|
||||
|
||||
from typing import cast
|
||||
|
||||
from ...domain.constants import MINIMUM_REALISTIC_LHS
|
||||
|
||||
# Try to get the cached global LHS (set by update_global_lhs_from_cycles)
|
||||
cached_entry_data = self._data.get("cached_global_lhs")
|
||||
if cached_entry_data and isinstance(cached_entry_data, dict):
|
||||
cached_lhs = cached_entry_data.get("value")
|
||||
# Validate: LHS must be realistically positive (>= 0.5°C/h)
|
||||
if cached_lhs is not None and cached_lhs >= MINIMUM_REALISTIC_LHS:
|
||||
_LOGGER.debug(
|
||||
"Returning cached global LHS: %.2f°C/h",
|
||||
cached_lhs,
|
||||
)
|
||||
return cast(float, cached_lhs)
|
||||
elif cached_lhs is not None:
|
||||
_LOGGER.debug(
|
||||
"Cached global LHS is invalid (%.2f°C/h < %.2f°C/h), using default: %.2f°C/h",
|
||||
cached_lhs,
|
||||
MINIMUM_REALISTIC_LHS,
|
||||
DEFAULT_HEATING_SLOPE,
|
||||
)
|
||||
return DEFAULT_HEATING_SLOPE
|
||||
|
||||
# No cached global LHS available, use default
|
||||
_LOGGER.debug(
|
||||
"No cached global LHS available, using default: %.2f°C/h",
|
||||
DEFAULT_HEATING_SLOPE,
|
||||
)
|
||||
return DEFAULT_HEATING_SLOPE
|
||||
|
||||
async def clear_slope_history(self) -> None:
|
||||
"""Clear all learned slope data from history.
|
||||
|
||||
This resets the learning system to its initial state.
|
||||
"""
|
||||
await self._ensure_loaded()
|
||||
|
||||
_LOGGER.info("Clearing all learned slope history")
|
||||
self._data["cached_global_lhs"] = None
|
||||
self._data["cached_contextual_lhs"] = {}
|
||||
|
||||
await self._save_data()
|
||||
|
||||
async def get_cached_global_lhs(self) -> LHSCacheEntry | None:
|
||||
"""Return cached global LHS if available.
|
||||
|
||||
When retention_days=0 (caching disabled), always returns None.
|
||||
This forces the use of default LHS values.
|
||||
"""
|
||||
if self._is_caching_disabled():
|
||||
_LOGGER.debug(
|
||||
"Caching disabled (retention_days=0), returning None for cached global LHS"
|
||||
)
|
||||
return None
|
||||
|
||||
await self._ensure_loaded()
|
||||
return self._deserialize_lhs_cache_entry(self._data.get("cached_global_lhs"))
|
||||
|
||||
async def set_cached_global_lhs(self, lhs: float, updated_at: datetime) -> None:
|
||||
"""Persist global LHS cache with timestamp."""
|
||||
|
||||
await self._ensure_loaded()
|
||||
self._data["cached_global_lhs"] = self._serialize_lhs_cache_entry(lhs, updated_at)
|
||||
await self._save_data()
|
||||
|
||||
async def get_cached_contextual_lhs(self, hour: int) -> LHSCacheEntry | None:
|
||||
"""Return cached contextual LHS for the given hour if available."""
|
||||
|
||||
if self._is_caching_disabled():
|
||||
_LOGGER.debug(
|
||||
"Caching disabled (retention_days=0), returning None for cached contextual LHS"
|
||||
)
|
||||
return None
|
||||
|
||||
await self._ensure_loaded()
|
||||
contextual_cache = self._data.get("cached_contextual_lhs") or {}
|
||||
entry = contextual_cache.get(str(hour))
|
||||
return self._deserialize_lhs_cache_entry(entry, hour=hour)
|
||||
|
||||
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."""
|
||||
|
||||
if self._is_caching_disabled():
|
||||
_LOGGER.debug("Caching disabled (retention_days=0), skipping contextual LHS cache set")
|
||||
return
|
||||
|
||||
await self._ensure_loaded()
|
||||
contextual_cache = self._data.setdefault("cached_contextual_lhs", {})
|
||||
contextual_cache[str(hour)] = self._serialize_lhs_cache_entry(lhs, updated_at)
|
||||
await self._save_data()
|
||||
|
||||
async def clear_contextual_cache(self) -> None:
|
||||
"""Clear all cached contextual LHS entries."""
|
||||
|
||||
await self._ensure_loaded()
|
||||
self._data["cached_contextual_lhs"] = {}
|
||||
await self._save_data()
|
||||
|
||||
async def get_learned_dead_time(self) -> float | None:
|
||||
"""Get the learned dead time value from auto-learning.
|
||||
|
||||
Returns:
|
||||
Dead time in minutes, or None if not yet learned
|
||||
"""
|
||||
from typing import cast
|
||||
|
||||
await self._ensure_loaded()
|
||||
dead_time_entry = self._data.get("learned_dead_time")
|
||||
if dead_time_entry and isinstance(dead_time_entry, dict):
|
||||
value = dead_time_entry.get("value")
|
||||
if value is not None:
|
||||
return cast(float, value)
|
||||
return None
|
||||
|
||||
async def set_learned_dead_time(self, dead_time: float | None) -> None:
|
||||
"""Persist learned dead time value from auto-learning.
|
||||
|
||||
Args:
|
||||
dead_time: Dead time in minutes, or None to clear
|
||||
"""
|
||||
await self._ensure_loaded()
|
||||
if dead_time is None:
|
||||
self._data["learned_dead_time"] = None
|
||||
else:
|
||||
updated_at = datetime.now() if not hasattr(self, "_get_now") else self._get_now()
|
||||
try:
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
if dt_util is not None:
|
||||
updated_at = dt_util.now()
|
||||
except ImportError:
|
||||
pass
|
||||
self._data["learned_dead_time"] = {
|
||||
"value": dead_time,
|
||||
"updated_at": updated_at.isoformat()
|
||||
if isinstance(updated_at, datetime)
|
||||
else str(updated_at),
|
||||
}
|
||||
await self._save_data()
|
||||
_LOGGER.info("Learned dead time updated: %.1f minutes", dead_time or 0)
|
||||
|
||||
def _serialize_lhs_cache_entry(self, lhs: float, updated_at: datetime) -> dict[str, Any]:
|
||||
"""Serialize an LHS cache entry to a dictionary for storage.
|
||||
|
||||
Args:
|
||||
lhs: The LHS value to cache
|
||||
updated_at: The timestamp when the LHS was calculated
|
||||
|
||||
Returns:
|
||||
A dictionary representation suitable for JSON storage
|
||||
"""
|
||||
return {
|
||||
"value": lhs,
|
||||
"updated_at": self._serialize_datetime(updated_at),
|
||||
}
|
||||
|
||||
def _deserialize_lhs_cache_entry(
|
||||
self, data: dict[str, Any] | None, hour: int | None = None
|
||||
) -> LHSCacheEntry | None:
|
||||
"""Deserialize a stored cache entry into an LHSCacheEntry object.
|
||||
|
||||
Args:
|
||||
data: The stored dictionary data
|
||||
hour: Optional hour context for contextual LHS
|
||||
|
||||
Returns:
|
||||
An LHSCacheEntry object if data is valid, None otherwise
|
||||
"""
|
||||
if not data:
|
||||
return None
|
||||
|
||||
try:
|
||||
value = data.get("value")
|
||||
updated_at_str = data.get("updated_at")
|
||||
|
||||
if value is None or updated_at_str is None:
|
||||
return None
|
||||
|
||||
updated_at = self._parse_datetime(updated_at_str)
|
||||
return LHSCacheEntry(value=value, updated_at=updated_at, hour=hour)
|
||||
except (ValueError, TypeError, KeyError) as e:
|
||||
_LOGGER.warning("Failed to deserialize cached LHS entry: %s", e)
|
||||
return None
|
||||
+406
@@ -0,0 +1,406 @@
|
||||
"""Recording extraction queue for incremental, configurable-period data loading.
|
||||
|
||||
This module implements a sequential, asynchronous extraction queue that loads
|
||||
historical entity data from the Home Assistant Recorder one configurable period
|
||||
at a time. This prevents overwhelming the Recorder and keeps Home Assistant
|
||||
responsive during the initial cache population.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import logging
|
||||
from collections import deque
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
try:
|
||||
from homeassistant.util import dt as dt_util
|
||||
except ImportError:
|
||||
dt_util = None
|
||||
|
||||
from ...const import DEFAULT_TASK_RANGE_DAYS
|
||||
from ...domain.value_objects.historical_data import HistoricalDataSet
|
||||
from ...domain.value_objects.recording_extraction_task import (
|
||||
ExtractionTaskState,
|
||||
RecordingExtractionTask,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...domain.interfaces.heating_cycle_service_interface import IHeatingCycleService
|
||||
from ...domain.interfaces.historical_data_adapter_interface import IHistoricalDataAdapter
|
||||
from ...domain.value_objects.heating import HeatingCycle
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
QUEUE_YIELD_SECONDS = 10.0
|
||||
|
||||
|
||||
class RecordingExtractionQueue:
|
||||
"""Queue-based orchestrator for incremental Recorder data extraction.
|
||||
|
||||
This service manages asynchronous extraction of historical entity data
|
||||
from the Home Assistant Recorder, processing one configurable period at
|
||||
a time to:
|
||||
1. Prevent timeout/freezing of Home Assistant during large queries
|
||||
2. Load data incrementally into cache (progressive model availability)
|
||||
3. Respect the RecorderAccessQueue serialization (avoid concurrent access)
|
||||
|
||||
The extraction period length is configurable via `task_range_days` to allow
|
||||
users to tune the load according to their machine's capabilities.
|
||||
|
||||
Lifecycle:
|
||||
- populate_queue(start_date, end_date): Create extraction tasks
|
||||
- run_queue(): Execute tasks sequentially in the background
|
||||
- cancel_queue(): Stop ongoing extraction
|
||||
- get_progress(): Query extraction status
|
||||
|
||||
The extracted cycles are passed to a callback function for progressive
|
||||
cache population (do NOT wait for all extraction to complete before
|
||||
building ML models).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device_id: str,
|
||||
entity_id: str,
|
||||
historical_adapters: list[IHistoricalDataAdapter],
|
||||
heating_cycle_service: IHeatingCycleService | None = None,
|
||||
on_cycles_extracted: Callable[[list[HeatingCycle]], Awaitable[None] | None] | None = None,
|
||||
on_period_explored: Callable[[date, date], Awaitable[None] | None] | None = None,
|
||||
task_range_days: int = DEFAULT_TASK_RANGE_DAYS,
|
||||
extraction_semaphore: asyncio.Semaphore | None = None,
|
||||
) -> None:
|
||||
"""Initialize the extraction queue.
|
||||
|
||||
Args:
|
||||
device_id: IHP device identifier
|
||||
entity_id: Entity ID to extract data from (e.g. a climate or sensor entity)
|
||||
historical_adapters: List of adapters to fetch historical data
|
||||
heating_cycle_service: Service used to extract heating cycles from raw data
|
||||
on_cycles_extracted: Callback function called after each period's extraction
|
||||
with the extracted cycles list
|
||||
on_period_explored: Callback called after each period is explored
|
||||
with (start_date, end_date), regardless of cycle count
|
||||
task_range_days: Number of days covered by each extraction task.
|
||||
Increase to reduce task count (less pauses, more data per query).
|
||||
Decrease on low-powered machines. Default: 7.
|
||||
extraction_semaphore: Optional global semaphore to limit concurrent extractions
|
||||
across all devices (OOM prevention).
|
||||
"""
|
||||
self._device_id = device_id
|
||||
self._entity_id = entity_id
|
||||
self._historical_adapters = historical_adapters
|
||||
self._heating_cycle_service = heating_cycle_service
|
||||
self._on_cycles_extracted = on_cycles_extracted
|
||||
self._on_period_explored = on_period_explored
|
||||
self._extraction_semaphore = extraction_semaphore
|
||||
|
||||
if task_range_days < 1:
|
||||
raise ValueError(
|
||||
f"task_range_days must be at least 1 day for RecordingExtractionQueue; got {task_range_days}"
|
||||
)
|
||||
self._task_range_days = task_range_days
|
||||
|
||||
self._queue: deque[RecordingExtractionTask] = deque()
|
||||
self._is_running = False
|
||||
self._extraction_start_date: date | None = None
|
||||
self._extraction_end_date: date | None = None
|
||||
self._extracted_count = 0
|
||||
self._failed_count = 0
|
||||
self._cancel_requested = False
|
||||
|
||||
_LOGGER.debug(
|
||||
"Initialized RecordingExtractionQueue for device=%s, entity=%s, task_range_days=%d",
|
||||
device_id,
|
||||
entity_id,
|
||||
task_range_days,
|
||||
)
|
||||
|
||||
async def populate_queue(self, start_date: date, end_date: date) -> int:
|
||||
"""Populate the queue with extraction tasks covering the given date range.
|
||||
|
||||
Creates one RecordingExtractionTask per period of `task_range_days` days.
|
||||
Does NOT start extraction (call run_queue() to start).
|
||||
|
||||
Args:
|
||||
start_date: Start date (inclusive)
|
||||
end_date: End date (inclusive)
|
||||
|
||||
Returns:
|
||||
Number of tasks created
|
||||
|
||||
Raises:
|
||||
RuntimeError: If queue extraction is already running
|
||||
"""
|
||||
_LOGGER.debug("Entering RecordingExtractionQueue.populate_queue")
|
||||
|
||||
if self._is_running:
|
||||
_LOGGER.error("Cannot populate queue while extraction is running")
|
||||
raise RuntimeError("Cannot populate queue while extraction is running")
|
||||
|
||||
self._extraction_start_date = start_date
|
||||
self._extraction_end_date = end_date
|
||||
self._extracted_count = 0
|
||||
self._failed_count = 0
|
||||
self._cancel_requested = False
|
||||
|
||||
# Clear existing queue
|
||||
self._queue.clear()
|
||||
|
||||
# Create tasks, each covering task_range_days days
|
||||
current_date = start_date
|
||||
task_count = 0
|
||||
while current_date <= end_date:
|
||||
period_end = min(current_date + timedelta(days=self._task_range_days - 1), end_date)
|
||||
task = RecordingExtractionTask(
|
||||
start_date=current_date,
|
||||
end_date=period_end,
|
||||
device_id=self._device_id,
|
||||
state=ExtractionTaskState.PENDING,
|
||||
)
|
||||
self._queue.append(task)
|
||||
task_count += 1
|
||||
current_date += timedelta(days=self._task_range_days)
|
||||
|
||||
_LOGGER.info(
|
||||
"Populated extraction queue with %d tasks from %s to %s (period=%d days each)",
|
||||
task_count,
|
||||
start_date,
|
||||
end_date,
|
||||
self._task_range_days,
|
||||
)
|
||||
_LOGGER.debug("Exiting RecordingExtractionQueue.populate_queue")
|
||||
return task_count
|
||||
|
||||
async def run_queue(self) -> None:
|
||||
"""Execute all queued extraction tasks sequentially.
|
||||
|
||||
This is an asynchronous, long-running operation. When awaited, it will
|
||||
process the entire queue (or until cancelled) before returning, while
|
||||
cooperatively yielding control to the event loop between tasks to keep
|
||||
Home Assistant responsive.
|
||||
|
||||
Callers that must not be blocked until the queue completes should
|
||||
schedule this method as a background task, for example:
|
||||
|
||||
asyncio.create_task(queue.run_queue())
|
||||
|
||||
Extracted cycles for each period are passed to the callback function (if
|
||||
provided) for progressive cache population.
|
||||
Raises:
|
||||
RuntimeError: If extraction is already running
|
||||
"""
|
||||
_LOGGER.debug("Entering RecordingExtractionQueue.run_queue")
|
||||
|
||||
if self._is_running:
|
||||
_LOGGER.error("Extraction queue is already running")
|
||||
raise RuntimeError("Extraction queue is already running")
|
||||
|
||||
self._is_running = True
|
||||
_LOGGER.info(
|
||||
"Starting extraction queue: %d tasks from %s to %s",
|
||||
len(self._queue),
|
||||
self._extraction_start_date,
|
||||
self._extraction_end_date,
|
||||
)
|
||||
|
||||
# Yield once to ensure the task is visible as running before processing.
|
||||
await asyncio.sleep(0)
|
||||
|
||||
try:
|
||||
while self._queue and not self._cancel_requested:
|
||||
# Get next task
|
||||
task = self._queue.popleft()
|
||||
|
||||
_LOGGER.debug(
|
||||
"Processing extraction task for period=%s to %s (task %d/%d)",
|
||||
task.start_date,
|
||||
task.end_date,
|
||||
self._extracted_count + self._failed_count + 1,
|
||||
self._extracted_count + self._failed_count + len(self._queue) + 1,
|
||||
)
|
||||
|
||||
try:
|
||||
# Acquire global extraction semaphore if available.
|
||||
# This limits concurrent extractions across ALL devices to
|
||||
# prevent OOM kills when 8+ devices extract simultaneously.
|
||||
if self._extraction_semaphore is not None:
|
||||
_LOGGER.debug(
|
||||
"Waiting for extraction semaphore (device=%s)", self._device_id
|
||||
)
|
||||
await self._extraction_semaphore.acquire()
|
||||
|
||||
try:
|
||||
# Extract data for this period
|
||||
cycles = await self._extract_period(task.start_date, task.end_date)
|
||||
finally:
|
||||
if self._extraction_semaphore is not None:
|
||||
self._extraction_semaphore.release()
|
||||
|
||||
self._extracted_count += 1
|
||||
_LOGGER.info(
|
||||
"Extraction completed for period=%s to %s: %d cycles extracted",
|
||||
task.start_date,
|
||||
task.end_date,
|
||||
len(cycles),
|
||||
)
|
||||
|
||||
# Callback to progressively feed cache (sync or async)
|
||||
if self._on_cycles_extracted and cycles:
|
||||
result = self._on_cycles_extracted(cycles)
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
|
||||
# Callback to track period as explored (even if empty)
|
||||
if self._on_period_explored:
|
||||
result = self._on_period_explored(task.start_date, task.end_date)
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
|
||||
except Exception as exc:
|
||||
self._failed_count += 1
|
||||
_LOGGER.warning(
|
||||
"Extraction failed for period=%s to %s: %s",
|
||||
task.start_date,
|
||||
task.end_date,
|
||||
exc,
|
||||
)
|
||||
|
||||
# Mark the period as explored even on failure (e.g. Recorder returned
|
||||
# empty because data was purged). Without this, failed periods would be
|
||||
# retried on every restart indefinitely, wasting Recorder queries.
|
||||
if self._on_period_explored:
|
||||
try:
|
||||
result = self._on_period_explored(task.start_date, task.end_date)
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
except Exception as cb_exc:
|
||||
_LOGGER.warning(
|
||||
"on_period_explored callback failed after extraction error: %s",
|
||||
cb_exc,
|
||||
)
|
||||
|
||||
# Continue with next task despite failure
|
||||
continue
|
||||
|
||||
# Pause between tasks to let the Recorder breathe
|
||||
await asyncio.sleep(QUEUE_YIELD_SECONDS)
|
||||
|
||||
if self._cancel_requested:
|
||||
_LOGGER.info("Extraction queue cancelled by user")
|
||||
else:
|
||||
_LOGGER.info(
|
||||
"Extraction queue complete: %d extracted, %d failed",
|
||||
self._extracted_count,
|
||||
self._failed_count,
|
||||
)
|
||||
|
||||
finally:
|
||||
self._is_running = False
|
||||
_LOGGER.debug("Exiting RecordingExtractionQueue.run_queue")
|
||||
|
||||
async def cancel_queue(self) -> None:
|
||||
"""Cancel ongoing extraction.
|
||||
|
||||
Sets a flag to stop processing remaining tasks after the current
|
||||
task completes.
|
||||
"""
|
||||
_LOGGER.debug("Entering RecordingExtractionQueue.cancel_queue")
|
||||
_LOGGER.info("Cancellation requested for extraction queue")
|
||||
self._cancel_requested = True
|
||||
_LOGGER.debug("Exiting RecordingExtractionQueue.cancel_queue")
|
||||
|
||||
async def get_progress(self) -> tuple[int, int, bool]:
|
||||
"""Get current extraction progress.
|
||||
|
||||
Returns:
|
||||
A tuple of (extracted_count, total_count, is_running)
|
||||
"""
|
||||
total_count = self._extracted_count + self._failed_count + len(self._queue)
|
||||
return self._extracted_count, total_count, self._is_running
|
||||
|
||||
async def _extract_period(self, start_date: date, end_date: date) -> list[HeatingCycle]:
|
||||
"""Extract historical data for a given period from the Recorder.
|
||||
|
||||
Args:
|
||||
start_date: First day (inclusive) of the period to extract
|
||||
end_date: Last day (inclusive) of the period to extract
|
||||
|
||||
Returns:
|
||||
List of extracted HeatingCycle objects for the period
|
||||
|
||||
Raises:
|
||||
Exception: If extraction fails (will be caught by run_queue())
|
||||
"""
|
||||
_LOGGER.debug(
|
||||
"Extracting data from Recorder for period=%s to %s, device=%s, entity=%s",
|
||||
start_date,
|
||||
end_date,
|
||||
self._device_id,
|
||||
self._entity_id,
|
||||
)
|
||||
|
||||
if self._heating_cycle_service is None:
|
||||
raise RuntimeError("HeatingCycleService is required for extraction")
|
||||
|
||||
try:
|
||||
# Create time window for this period using HA local timezone to avoid
|
||||
# midnight boundary shifts on non-UTC installations.
|
||||
local_tz = dt_util.get_default_time_zone() if dt_util is not None else timezone.utc
|
||||
start_time = datetime.combine(start_date, time.min).replace(tzinfo=local_tz)
|
||||
end_time = datetime.combine(end_date, time.max).replace(tzinfo=local_tz)
|
||||
|
||||
combined_data: HistoricalDataSet = HistoricalDataSet(data={})
|
||||
|
||||
for adapter in self._historical_adapters:
|
||||
try:
|
||||
# Fetch all supported data keys in a single recorder query
|
||||
adapter_data = await adapter.fetch_all_historical_data(
|
||||
entity_id=self._entity_id,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
)
|
||||
if adapter_data is not None and adapter_data.data:
|
||||
for data_key, measurements in adapter_data.data.items():
|
||||
if measurements:
|
||||
if data_key not in combined_data.data:
|
||||
combined_data.data[data_key] = []
|
||||
combined_data.data[data_key].extend(measurements)
|
||||
except Exception as exc:
|
||||
_LOGGER.warning(
|
||||
"Failed to fetch historical data from adapter for %s to %s: %s",
|
||||
start_date,
|
||||
end_date,
|
||||
exc,
|
||||
)
|
||||
continue
|
||||
|
||||
historical_data_set = HistoricalDataSet(data=combined_data.data)
|
||||
|
||||
cycles = await self._heating_cycle_service.extract_heating_cycles(
|
||||
device_id=self._device_id,
|
||||
history_data_set=historical_data_set,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Extracted %d cycles from Recorder for %s to %s",
|
||||
len(cycles),
|
||||
start_date,
|
||||
end_date,
|
||||
)
|
||||
|
||||
return cycles
|
||||
|
||||
except Exception as exc:
|
||||
_LOGGER.warning(
|
||||
"Failed to extract cycles for period %s to %s: %s",
|
||||
start_date,
|
||||
end_date,
|
||||
exc,
|
||||
)
|
||||
raise
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
"""Home Assistant scheduler commander adapter.
|
||||
|
||||
This adapter implements ISchedulerCommander by calling Home Assistant
|
||||
scheduler services to trigger heating actions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from ...domain.interfaces.scheduler_commander_interface import ISchedulerCommander
|
||||
from .utils import get_entity_name
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Scheduler service configuration
|
||||
SCHEDULER_DOMAIN = "scheduler"
|
||||
SERVICE_RUN_ACTION = "run_action"
|
||||
|
||||
|
||||
class HASchedulerCommander(ISchedulerCommander):
|
||||
"""Home Assistant implementation of scheduler commander.
|
||||
|
||||
Executes scheduler commands using the scheduler-component's run_action service.
|
||||
See: https://github.com/nielsfaber/scheduler-component/#schedulerrun_action
|
||||
|
||||
This adapter contains NO business logic - it only translates domain
|
||||
requests into Home Assistant service calls.
|
||||
"""
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the scheduler commander adapter.
|
||||
|
||||
Args:
|
||||
hass: Home Assistant instance
|
||||
scheduler_entity_id: The scheduler entity ID to control
|
||||
"""
|
||||
self._hass = hass
|
||||
|
||||
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
|
||||
scheduler_entity_id: Scheduler entity ID to control
|
||||
|
||||
Raises:
|
||||
ValueError: If scheduler entity is not configured
|
||||
"""
|
||||
if not scheduler_entity_id:
|
||||
_LOGGER.error("Cannot run action: no scheduler entity configured")
|
||||
raise ValueError("Scheduler entity ID not configured")
|
||||
|
||||
# Format time as HH:MM for scheduler service
|
||||
trigger_time_str = target_time.strftime("%H:%M")
|
||||
|
||||
device_name = get_entity_name(self._hass, scheduler_entity_id)
|
||||
_LOGGER.info("[%s] Triggering scheduler action at time %s", device_name, trigger_time_str)
|
||||
|
||||
try:
|
||||
await self._hass.services.async_call(
|
||||
SCHEDULER_DOMAIN,
|
||||
SERVICE_RUN_ACTION,
|
||||
{
|
||||
"entity_id": scheduler_entity_id,
|
||||
"time": trigger_time_str,
|
||||
"skip_conditions": False, # Respect scheduler conditions
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
_LOGGER.debug("Scheduler action triggered successfully")
|
||||
except Exception as err:
|
||||
_LOGGER.error(
|
||||
"Failed to trigger scheduler action for %s: %s",
|
||||
scheduler_entity_id,
|
||||
err,
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
|
||||
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()).
|
||||
|
||||
Note: The scheduler-component doesn't have a direct "cancel" service.
|
||||
This implementation triggers the action for "now" which effectively
|
||||
reverts to the current scheduled state.
|
||||
|
||||
Args:
|
||||
scheduler_entity_id: Scheduler entity ID to control
|
||||
|
||||
Raises:
|
||||
ValueError: If scheduler entity is not configured
|
||||
"""
|
||||
if not scheduler_entity_id:
|
||||
_LOGGER.error("Cannot cancel action: no scheduler entity configured")
|
||||
raise ValueError("Scheduler entity ID not configured")
|
||||
|
||||
# Get current time
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
now = dt_util.now()
|
||||
current_time_str = now.strftime("%H:%M")
|
||||
|
||||
device_name = get_entity_name(self._hass, scheduler_entity_id)
|
||||
_LOGGER.info(
|
||||
"[%s] Canceling scheduler action by reverting to current time %s",
|
||||
device_name,
|
||||
current_time_str,
|
||||
)
|
||||
|
||||
try:
|
||||
await self._hass.services.async_call(
|
||||
SCHEDULER_DOMAIN,
|
||||
SERVICE_RUN_ACTION,
|
||||
{
|
||||
"entity_id": scheduler_entity_id,
|
||||
"time": current_time_str,
|
||||
"skip_conditions": False,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
_LOGGER.debug("Scheduler action canceled successfully")
|
||||
except Exception as err:
|
||||
_LOGGER.error(
|
||||
"Failed to cancel scheduler action for %s: %s",
|
||||
scheduler_entity_id,
|
||||
err,
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
+538
@@ -0,0 +1,538 @@
|
||||
"""Home Assistant scheduler reader adapter.
|
||||
|
||||
This adapter implements ISchedulerReader by reading from Home Assistant
|
||||
scheduler entities. It translates HA entity states into domain value objects.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from ...domain.interfaces.climate_data_reader_interface import IClimateDataReader
|
||||
from ...domain.interfaces.scheduler_reader_interface import ISchedulerReader
|
||||
from ...domain.value_objects import ScheduledTimeslot
|
||||
from ..vtherm_compat import get_vtherm_attribute
|
||||
from .utils import get_entity_name
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import State
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Default temperature used for native HA schedule entities that don't store temperature.
|
||||
# The actual temperature is resolved from the linked VTherm entity when available.
|
||||
_DEFAULT_NATIVE_SCHEDULE_TEMPERATURE: float = 20.0
|
||||
|
||||
|
||||
class HASchedulerReader(ISchedulerReader):
|
||||
"""Home Assistant implementation of scheduler reader.
|
||||
|
||||
Reads scheduled heating timeslots from Home Assistant scheduler entities.
|
||||
Supports the scheduler-component data format:
|
||||
https://github.com/nielsfaber/scheduler-component/#data-format
|
||||
|
||||
This adapter contains NO business logic - it only translates HA states
|
||||
to domain value objects.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
scheduler_entity_ids: list[str],
|
||||
vtherm_entity_id: str | None = None,
|
||||
climate_reader: IClimateDataReader | None = None,
|
||||
) -> None:
|
||||
"""Initialize the scheduler reader adapter.
|
||||
|
||||
Args:
|
||||
hass: Home Assistant instance
|
||||
scheduler_entity_ids: List of scheduler entity IDs to monitor
|
||||
vtherm_entity_id: Optional VTherm climate entity ID used to resolve
|
||||
preset temperatures (e.g., when actions use preset modes).
|
||||
climate_reader: Optional climate data reader used to resolve the
|
||||
current VTherm target temperature for native HA schedule entities
|
||||
(schedule.*) that do not store a temperature themselves.
|
||||
"""
|
||||
self._hass = hass
|
||||
self._scheduler_entity_ids = scheduler_entity_ids
|
||||
self._vtherm_entity_id = vtherm_entity_id
|
||||
self._climate_reader = climate_reader
|
||||
|
||||
async def get_next_timeslot(self) -> ScheduledTimeslot | None:
|
||||
"""Retrieve the next scheduled heating timeslot.
|
||||
|
||||
Scans all configured scheduler entities and returns the earliest
|
||||
upcoming timeslot with a valid time and temperature.
|
||||
|
||||
Supports both HACS Scheduler (switch.*) and native HA Schedule (schedule.*)
|
||||
entities. Native schedules use the next_event attribute and require special
|
||||
state-aware handling since "off" means "not in active timeslot", not disabled.
|
||||
|
||||
Returns:
|
||||
The next schedule timeslot, or None if no valid timeslots found
|
||||
or if no scheduler entities are configured.
|
||||
"""
|
||||
if not self._scheduler_entity_ids or len(self._scheduler_entity_ids) == 0:
|
||||
_LOGGER.debug("No scheduler entities configured - scheduler is optional")
|
||||
return None
|
||||
|
||||
chosen_time: datetime | None = None
|
||||
chosen_temp: float | None = None
|
||||
chosen_entity: str | None = None
|
||||
|
||||
for entity_id in self._scheduler_entity_ids:
|
||||
state = self._hass.states.get(entity_id)
|
||||
if not state:
|
||||
# Use debug level if HA is still starting up, warning otherwise
|
||||
device_name = get_entity_name(self._hass, entity_id)
|
||||
if self._hass.is_running:
|
||||
_LOGGER.warning("[%s] Scheduler entity not found", device_name)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"[%s] Scheduler entity not yet available (HA starting)", device_name
|
||||
)
|
||||
continue
|
||||
|
||||
# Native HA schedule entities (schedule.*) use next_event and state-aware logic
|
||||
if entity_id.startswith("schedule."):
|
||||
next_time, target_temp = self._extract_native_schedule_data(state)
|
||||
else:
|
||||
# Skip disabled HACS schedulers (state is "off")
|
||||
if state.state == "off":
|
||||
device_name = get_entity_name(self._hass, entity_id)
|
||||
_LOGGER.debug(
|
||||
"[%s] Scheduler is disabled (state: off), skipping", device_name
|
||||
)
|
||||
continue
|
||||
|
||||
# Extract next trigger time and target temperature (HACS format)
|
||||
next_time, target_temp = self._extract_timeslot_data(state)
|
||||
|
||||
if next_time and target_temp is not None:
|
||||
# Keep track of the earliest timeslot
|
||||
if not chosen_time or next_time < chosen_time:
|
||||
chosen_time = next_time
|
||||
chosen_temp = target_temp
|
||||
chosen_entity = entity_id
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Skipped scheduler %s: time=%s temp=%s", entity_id, next_time, target_temp
|
||||
)
|
||||
|
||||
if chosen_time and chosen_temp is not None and chosen_entity:
|
||||
device_name = get_entity_name(self._hass, chosen_entity)
|
||||
_LOGGER.info(
|
||||
"[%s] Next timeslot at %s (%.1f°C)",
|
||||
device_name,
|
||||
chosen_time.strftime("%H:%M"),
|
||||
chosen_temp,
|
||||
)
|
||||
return ScheduledTimeslot(
|
||||
target_time=chosen_time,
|
||||
target_temp=chosen_temp,
|
||||
timeslot_id=f"{chosen_entity}_{chosen_time.isoformat()}",
|
||||
scheduler_entity=chosen_entity,
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
"No valid scheduler timeslot found (check scheduler configuration and preset temperatures)"
|
||||
)
|
||||
return None
|
||||
|
||||
def _extract_timeslot_data(self, state: State) -> tuple[datetime | None, float | None]:
|
||||
"""Extract next trigger time and target temperature from scheduler state.
|
||||
|
||||
Supports multiple scheduler attribute layouts:
|
||||
- Standard: next_trigger + next_slot + actions
|
||||
- Fallback: next_entries with time and actions
|
||||
|
||||
Args:
|
||||
state: Home Assistant scheduler entity state
|
||||
|
||||
Returns:
|
||||
Tuple of (next_time, target_temp), either can be None if not found
|
||||
"""
|
||||
attrs = state.attributes
|
||||
|
||||
# Debug logging to understand the actual structure
|
||||
_LOGGER.debug(
|
||||
"Scheduler %s attributes: next_trigger=%s, next_slot=%s, actions=%s, next_entries=%s",
|
||||
state.entity_id,
|
||||
attrs.get("next_trigger"),
|
||||
attrs.get("next_slot"),
|
||||
type(attrs.get("actions")),
|
||||
type(attrs.get("next_entries")),
|
||||
)
|
||||
|
||||
# Try standard format first
|
||||
next_time = self._parse_next_trigger(attrs.get("next_trigger"))
|
||||
target_temp = self._extract_target_temp_standard(attrs)
|
||||
|
||||
# Fallback to next_entries format
|
||||
if not next_time or target_temp is None:
|
||||
next_time_fallback, target_temp_fallback = self._extract_from_next_entries(attrs)
|
||||
if not next_time:
|
||||
next_time = next_time_fallback
|
||||
if target_temp is None:
|
||||
target_temp = target_temp_fallback
|
||||
|
||||
return next_time, target_temp
|
||||
|
||||
def _parse_next_trigger(self, next_trigger_raw: str | None) -> datetime | None:
|
||||
"""Parse next_trigger attribute to datetime.
|
||||
|
||||
Args:
|
||||
next_trigger_raw: Raw next_trigger value from scheduler
|
||||
|
||||
Returns:
|
||||
Parsed datetime with timezone, or None if parsing fails
|
||||
"""
|
||||
if not next_trigger_raw:
|
||||
return None
|
||||
|
||||
# Try HA's robust datetime parser first
|
||||
parsed = dt_util.parse_datetime(str(next_trigger_raw))
|
||||
|
||||
# Fallback to ISO format parsing
|
||||
if parsed is None:
|
||||
try:
|
||||
parsed = datetime.fromisoformat(str(next_trigger_raw))
|
||||
except ValueError:
|
||||
_LOGGER.debug("Failed to parse next_trigger: %s", next_trigger_raw)
|
||||
return None
|
||||
|
||||
# Ensure timezone is set
|
||||
if parsed and parsed.tzinfo is None:
|
||||
parsed = dt_util.as_local(parsed)
|
||||
|
||||
return cast(datetime, parsed) if parsed else None
|
||||
|
||||
def _extract_target_temp_standard(self, attrs: dict) -> float | None:
|
||||
"""Extract target temperature from standard scheduler format.
|
||||
|
||||
Uses next_slot index to find the action in the actions list.
|
||||
|
||||
Args:
|
||||
attrs: Scheduler entity attributes
|
||||
|
||||
Returns:
|
||||
Target temperature in Celsius, or None if not found
|
||||
"""
|
||||
next_slot = attrs.get("next_slot")
|
||||
actions = attrs.get("actions")
|
||||
|
||||
if not isinstance(actions, list):
|
||||
return None
|
||||
|
||||
if not isinstance(next_slot, int) or next_slot < 0 or next_slot >= len(actions):
|
||||
return None
|
||||
|
||||
action = actions[next_slot]
|
||||
return self._extract_temp_from_action(action)
|
||||
|
||||
def _extract_from_next_entries(self, attrs: dict) -> tuple[datetime | None, float | None]:
|
||||
"""Extract time and temperature from next_entries fallback format.
|
||||
|
||||
Args:
|
||||
attrs: Scheduler entity attributes
|
||||
|
||||
Returns:
|
||||
Tuple of (next_time, target_temp)
|
||||
"""
|
||||
next_entries = attrs.get("next_entries")
|
||||
|
||||
if not isinstance(next_entries, list) or not next_entries:
|
||||
return None, None
|
||||
|
||||
entry = next_entries[0]
|
||||
|
||||
# Extract time
|
||||
time_raw = entry.get("time") or entry.get("start") or entry.get("trigger_time")
|
||||
next_time = self._parse_next_trigger(time_raw) if time_raw else None
|
||||
|
||||
# Extract temperature from first action
|
||||
entry_actions = entry.get("actions", [])
|
||||
target_temp = None
|
||||
if isinstance(entry_actions, list) and entry_actions:
|
||||
target_temp = self._extract_temp_from_action(entry_actions[0])
|
||||
|
||||
return next_time, target_temp
|
||||
|
||||
def _extract_temp_from_action(self, action: dict) -> float | None:
|
||||
"""Extract target temperature from a scheduler action.
|
||||
|
||||
Supports:
|
||||
- climate.set_temperature with direct temperature value
|
||||
- climate.set_preset_mode with preset mapped to temperature
|
||||
|
||||
Args:
|
||||
action: Scheduler action dictionary
|
||||
|
||||
Returns:
|
||||
Target temperature in Celsius, or None if not found
|
||||
"""
|
||||
if not isinstance(action, dict):
|
||||
return None
|
||||
|
||||
service = action.get("service") or action.get("service_call")
|
||||
data = action.get("data") or action.get("service_data") or {}
|
||||
|
||||
# Direct temperature setting
|
||||
if service == "climate.set_temperature":
|
||||
temp = data.get("temperature")
|
||||
if temp is not None:
|
||||
try:
|
||||
return float(temp)
|
||||
except (ValueError, TypeError):
|
||||
_LOGGER.warning("Invalid temperature in action: %s", temp)
|
||||
return None
|
||||
|
||||
# Preset mode requires mapping via VTherm attributes
|
||||
# This is handled by getting the current VTherm state when the preset is active
|
||||
if service == "climate.set_preset_mode":
|
||||
preset = data.get("preset_mode") or data.get("preset") or data.get("mode")
|
||||
if isinstance(preset, str):
|
||||
# Try resolving using VTherm attributes if available
|
||||
resolved = self._resolve_preset_temperature(preset)
|
||||
if resolved is not None:
|
||||
return resolved
|
||||
_LOGGER.debug(
|
||||
"Could not resolve preset '%s' to temperature (entity=%s)",
|
||||
preset,
|
||||
self._vtherm_entity_id,
|
||||
)
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
def _extract_native_schedule_data(self, state: State) -> tuple[datetime | None, float | None]:
|
||||
"""Extract next timeslot from a native HA schedule entity.
|
||||
|
||||
Native HA schedule entities (schedule.*) use the next_event attribute,
|
||||
which always points to the NEXT state change:
|
||||
- State = "off" → next_event = next ON time (use for preheating anticipation)
|
||||
- State = "on" → next_event = next OFF time (skip - already in active period)
|
||||
|
||||
Args:
|
||||
state: Native HA schedule entity state
|
||||
|
||||
Returns:
|
||||
Tuple of (next_time, target_temp), either can be None
|
||||
"""
|
||||
# Use the already-available state object to derive a friendly device name
|
||||
device_name = cast(str, state.attributes.get("friendly_name") or state.entity_id)
|
||||
|
||||
if state.state != "off":
|
||||
# Schedule is ON, next_event points to next OFF time - not useful for preheating
|
||||
_LOGGER.debug(
|
||||
"[%s] Native schedule is ON (next_event = next OFF time), skipping for preheating",
|
||||
device_name,
|
||||
)
|
||||
return None, None
|
||||
|
||||
# Schedule is OFF, next_event is the next ON time - use for preheating
|
||||
attrs = state.attributes
|
||||
next_time = self._parse_datetime_value(attrs.get("next_event"))
|
||||
if not next_time:
|
||||
_LOGGER.debug("[%s] Native schedule has no valid next_event attribute", device_name)
|
||||
return None, None
|
||||
|
||||
target_temp = self._get_native_schedule_temperature()
|
||||
_LOGGER.debug(
|
||||
"[%s] Native schedule next ON event at %s (%.1f°C)",
|
||||
device_name,
|
||||
next_time.strftime("%H:%M"),
|
||||
target_temp,
|
||||
)
|
||||
return next_time, target_temp
|
||||
|
||||
def _parse_datetime_value(self, value: object) -> datetime | None:
|
||||
"""Parse a datetime value that can be either a datetime object or an ISO string.
|
||||
|
||||
Native HA schedule entities can return next_event as either a datetime
|
||||
object or an ISO format string, so both formats must be handled.
|
||||
|
||||
Args:
|
||||
value: Value to parse (datetime object or string)
|
||||
|
||||
Returns:
|
||||
Parsed datetime with timezone, or None if parsing fails
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
if isinstance(value, datetime):
|
||||
# Already a datetime - ensure timezone is set
|
||||
if value.tzinfo is None:
|
||||
return dt_util.as_local(value)
|
||||
return value
|
||||
|
||||
# Try string parsing using existing method
|
||||
return self._parse_next_trigger(str(value))
|
||||
|
||||
def _get_native_schedule_temperature(self) -> float:
|
||||
"""Get target temperature for native HA schedule entities.
|
||||
|
||||
Since native HA schedules don't store temperature, retrieves it from the
|
||||
injected climate data reader (IClimateDataReader), which is the designated
|
||||
adapter for reading VTherm state. Falls back to
|
||||
_DEFAULT_NATIVE_SCHEDULE_TEMPERATURE if no climate reader is configured or
|
||||
the VTherm temperature cannot be resolved.
|
||||
|
||||
Returns:
|
||||
Target temperature in Celsius from VTherm, or the default value
|
||||
"""
|
||||
if self._climate_reader is None:
|
||||
_LOGGER.debug(
|
||||
"No climate reader configured for native schedule temperature resolution, "
|
||||
"using %.1f°C default",
|
||||
_DEFAULT_NATIVE_SCHEDULE_TEMPERATURE,
|
||||
)
|
||||
return _DEFAULT_NATIVE_SCHEDULE_TEMPERATURE
|
||||
|
||||
temp = self._climate_reader.get_current_target_temperature()
|
||||
if temp is not None:
|
||||
_LOGGER.debug(
|
||||
"Native schedule temperature resolved from climate reader: %.1f°C",
|
||||
temp,
|
||||
)
|
||||
return temp
|
||||
|
||||
_LOGGER.debug(
|
||||
"Could not resolve temperature from climate reader, using %.1f°C default",
|
||||
_DEFAULT_NATIVE_SCHEDULE_TEMPERATURE,
|
||||
)
|
||||
return _DEFAULT_NATIVE_SCHEDULE_TEMPERATURE
|
||||
|
||||
async def is_scheduler_enabled(self, scheduler_entity_id: str) -> bool:
|
||||
"""Check if a specific scheduler is enabled.
|
||||
|
||||
For native HA schedule entities (schedule.*), the "off" state means the
|
||||
schedule is not in an active timeslot — not that it is disabled. These
|
||||
entities are therefore always considered enabled *when the entity exists
|
||||
and is available*. Returns False if the entity cannot be found.
|
||||
|
||||
For HACS switch-based schedulers, a scheduler is considered enabled if
|
||||
its state is NOT "off". States like "on", "idle", "waiting" are enabled.
|
||||
|
||||
Args:
|
||||
scheduler_entity_id: The scheduler entity ID to check
|
||||
|
||||
Returns:
|
||||
True if the scheduler is enabled, False otherwise
|
||||
"""
|
||||
state = self._hass.states.get(scheduler_entity_id)
|
||||
if not state:
|
||||
_LOGGER.debug("Scheduler entity not found when checking state: %s", scheduler_entity_id)
|
||||
return False
|
||||
|
||||
# Native HA schedules: "off" means outside an active timeslot, not disabled
|
||||
if scheduler_entity_id.startswith("schedule."):
|
||||
if state.state == "unavailable":
|
||||
_LOGGER.debug("Native HA schedule %s is unavailable", scheduler_entity_id)
|
||||
return False
|
||||
_LOGGER.debug(
|
||||
"Native HA schedule %s is considered enabled (state: %s)",
|
||||
scheduler_entity_id,
|
||||
state.state,
|
||||
)
|
||||
return True
|
||||
|
||||
is_enabled = state.state != "off"
|
||||
_LOGGER.debug(
|
||||
"Scheduler %s state: %s (enabled: %s)", scheduler_entity_id, state.state, is_enabled
|
||||
)
|
||||
return cast(bool, is_enabled)
|
||||
|
||||
def _resolve_preset_temperature(self, preset: str) -> float | None:
|
||||
"""Resolve a preset name to a numeric temperature using VTherm attributes.
|
||||
|
||||
This uses the VTherm climate entity attributes as the source of truth. It
|
||||
attempts common attribute naming conventions, with v8.0.0+ compatibility
|
||||
using get_vtherm_attribute() to access both legacy and new data structures.
|
||||
|
||||
Args:
|
||||
preset: Preset mode name from the scheduler action
|
||||
|
||||
Returns:
|
||||
Temperature in Celsius, or None if it cannot be resolved.
|
||||
"""
|
||||
if not self._vtherm_entity_id:
|
||||
return None
|
||||
state = self._hass.states.get(self._vtherm_entity_id)
|
||||
if not state:
|
||||
_LOGGER.debug("VTherm entity not found: %s", self._vtherm_entity_id)
|
||||
return None
|
||||
|
||||
key = str(preset).lower().replace(" ", "_")
|
||||
|
||||
# First, try the preset_temperatures dict (VTherm v8.0.0+ format)
|
||||
preset_temps = get_vtherm_attribute(state, "preset_temperatures")
|
||||
if isinstance(preset_temps, dict):
|
||||
# VTherm uses format like "eco_temp", "boost_temp", "comfort_temp"
|
||||
# Try multiple key patterns
|
||||
preset_keys_to_try = [
|
||||
f"{key}_temp", # eco_temp, boost_temp
|
||||
f"{key}_temperature", # eco_temperature
|
||||
key, # eco, boost (fallback)
|
||||
preset.lower(), # Original lowercase
|
||||
]
|
||||
|
||||
for preset_key in preset_keys_to_try:
|
||||
if preset_key in preset_temps:
|
||||
try:
|
||||
temp_value = float(preset_temps[preset_key])
|
||||
# Ignore 0 values as they indicate uninitialized presets
|
||||
if temp_value > 0:
|
||||
_LOGGER.debug(
|
||||
"Resolved preset '%s' to %.1f°C (from %s)",
|
||||
preset,
|
||||
temp_value,
|
||||
preset_key,
|
||||
)
|
||||
return temp_value
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Skipping preset '%s' with 0°C (likely uninitialized)", preset
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
_LOGGER.debug(
|
||||
"Invalid preset_temperatures value for %s: %s",
|
||||
preset_key,
|
||||
preset_temps[preset_key],
|
||||
)
|
||||
|
||||
# Fallback: try common naming patterns with v8.0.0+ compatibility
|
||||
candidate_keys = [
|
||||
f"{key}_temperature",
|
||||
f"{key}_temp",
|
||||
f"temperature_{key}",
|
||||
f"temp_{key}",
|
||||
]
|
||||
|
||||
for k in candidate_keys:
|
||||
# Use get_vtherm_attribute to handle both legacy and v8.0.0+ formats
|
||||
value = get_vtherm_attribute(state, k)
|
||||
if value is not None:
|
||||
try:
|
||||
return float(value)
|
||||
except (ValueError, TypeError):
|
||||
_LOGGER.debug("Invalid preset attribute %s=%s", k, value)
|
||||
|
||||
# Fallback: if this preset is currently active, use current target temperature
|
||||
current_preset = get_vtherm_attribute(state, "preset_mode")
|
||||
if isinstance(current_preset, str) and current_preset.lower() == key:
|
||||
for target_key in ("temperature", "target_temperature", "target_temp"):
|
||||
value = get_vtherm_attribute(state, target_key)
|
||||
if value is not None:
|
||||
try:
|
||||
return float(value)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
return None
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
"""Home Assistant sensor data reader adapter.
|
||||
|
||||
Provides historical data access for sensor entities via HA Recorder.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ...domain.interfaces.historical_data_adapter_interface import IHistoricalDataAdapter
|
||||
from ...domain.value_objects import (
|
||||
HistoricalDataKey,
|
||||
HistoricalDataSet,
|
||||
HistoricalMeasurement,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from ..recorder_queue import RecorderAccessQueue
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HASensorDataReader(IHistoricalDataAdapter):
|
||||
"""Adapter for reading historical sensor data from Home Assistant.
|
||||
|
||||
Generic adapter supporting any sensor type (temperature, humidity, etc.)
|
||||
mapped to appropriate HistoricalDataKey values.
|
||||
|
||||
Uses RecorderAccessQueue (MANDATORY) to serialize database access and prevent
|
||||
Home Assistant performance degradation when multiple IHP instances query
|
||||
historical data simultaneously.
|
||||
"""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, recorder_queue: RecorderAccessQueue) -> None:
|
||||
"""Initialize the sensor data reader.
|
||||
|
||||
Args:
|
||||
hass: Home Assistant instance
|
||||
recorder_queue: Shared FIFO queue to serialize recorder access (MANDATORY)
|
||||
"""
|
||||
self._hass = hass
|
||||
self._recorder_queue = recorder_queue
|
||||
_LOGGER.debug("Initialized HASensorDataReader with mandatory RecorderAccessQueue")
|
||||
|
||||
async def fetch_historical_data(
|
||||
self,
|
||||
entity_id: str,
|
||||
data_key: HistoricalDataKey,
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
) -> HistoricalDataSet:
|
||||
"""Fetch historical data for a sensor entity.
|
||||
|
||||
USES RecorderAccessQueue to serialize database access.
|
||||
|
||||
Args:
|
||||
entity_id: Sensor entity ID (e.g., "sensor.indoor_temperature")
|
||||
data_key: HistoricalDataKey (e.g., OUTDOOR_TEMP, INDOOR_HUMIDITY)
|
||||
start_time: Start of historical period
|
||||
end_time: End of historical period
|
||||
|
||||
Returns:
|
||||
HistoricalDataSet with extracted sensor data
|
||||
|
||||
Raises:
|
||||
ValueError: If entity_id is invalid or history cannot be retrieved
|
||||
"""
|
||||
_LOGGER.debug(
|
||||
"Fetching sensor history for %s from %s to %s",
|
||||
entity_id,
|
||||
start_time,
|
||||
end_time,
|
||||
)
|
||||
|
||||
try:
|
||||
# Get historical data from Home Assistant
|
||||
historical_records = await self._fetch_history(
|
||||
entity_id,
|
||||
start_time,
|
||||
end_time,
|
||||
)
|
||||
except Exception as exc:
|
||||
_LOGGER.error("Failed to fetch history for %s: %s", entity_id, exc)
|
||||
raise ValueError(f"Cannot fetch history for entity {entity_id}") from exc
|
||||
|
||||
if not historical_records:
|
||||
_LOGGER.warning("No history found for %s", entity_id)
|
||||
return HistoricalDataSet(data={})
|
||||
|
||||
# Use the provided data_key to categorize measurements
|
||||
measurements: list[HistoricalMeasurement] = []
|
||||
|
||||
for record in historical_records:
|
||||
timestamp = self._parse_timestamp(record)
|
||||
state = record.get("state")
|
||||
attributes = record.get("attributes", {})
|
||||
entity_id_from_record = record.get("entity_id", entity_id)
|
||||
|
||||
# Try to convert state to numeric value - skip if not convertible
|
||||
numeric_value = self._safe_float(state)
|
||||
if numeric_value is None:
|
||||
_LOGGER.debug(
|
||||
"Skipping non-numeric sensor state '%s' for %s at %s",
|
||||
state,
|
||||
entity_id,
|
||||
timestamp,
|
||||
)
|
||||
continue
|
||||
|
||||
measurements.append(
|
||||
HistoricalMeasurement(
|
||||
timestamp=timestamp,
|
||||
value=numeric_value,
|
||||
attributes=attributes,
|
||||
entity_id=entity_id_from_record,
|
||||
)
|
||||
)
|
||||
|
||||
# Build result
|
||||
data: dict[HistoricalDataKey, list[HistoricalMeasurement]] = {}
|
||||
|
||||
if measurements:
|
||||
data[data_key] = measurements
|
||||
|
||||
_LOGGER.debug(
|
||||
"Extracted %d sensor measurements for %s",
|
||||
len(measurements),
|
||||
entity_id,
|
||||
)
|
||||
|
||||
return HistoricalDataSet(data=data)
|
||||
|
||||
@staticmethod
|
||||
def _parse_timestamp(record: dict[str, Any]) -> datetime:
|
||||
"""Parse timestamp from history record.
|
||||
|
||||
Args:
|
||||
record: Historical record from Home Assistant
|
||||
|
||||
Returns:
|
||||
Parsed datetime object
|
||||
"""
|
||||
timestamp_str = record.get("last_changed", record.get("last_updated"))
|
||||
|
||||
if isinstance(timestamp_str, str):
|
||||
# Parse ISO format string
|
||||
if "+" in timestamp_str:
|
||||
timestamp_str = timestamp_str.split("+")[0]
|
||||
elif "Z" in timestamp_str:
|
||||
timestamp_str = timestamp_str.replace("Z", "")
|
||||
|
||||
return datetime.fromisoformat(timestamp_str)
|
||||
|
||||
# If already a datetime, return as-is
|
||||
if isinstance(timestamp_str, datetime):
|
||||
return timestamp_str
|
||||
|
||||
# Fallback: return current time if no timestamp found
|
||||
return datetime.now()
|
||||
|
||||
@staticmethod
|
||||
def _safe_float(value: Any) -> float | None:
|
||||
"""Safely convert value to float.
|
||||
|
||||
Args:
|
||||
value: Value to convert
|
||||
|
||||
Returns:
|
||||
Float value or None if conversion fails
|
||||
"""
|
||||
try:
|
||||
return float(value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
async def _fetch_history(
|
||||
self,
|
||||
entity_id: str,
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Fetch historical data from Home Assistant Recorder.
|
||||
|
||||
CRITICAL: Uses RecorderAccessQueue to serialize database access.
|
||||
This is a FIFO queue shared across all IHP instances to prevent
|
||||
overwhelming the recorder during startup or cache refresh.
|
||||
|
||||
Args:
|
||||
entity_id: The entity ID
|
||||
start_time: Start of historical period
|
||||
end_time: End of historical period
|
||||
|
||||
Returns:
|
||||
List of historical records from Home Assistant
|
||||
"""
|
||||
from functools import partial
|
||||
|
||||
from homeassistant.components.recorder import get_instance, history
|
||||
|
||||
# Use Home Assistant's get_significant_states function from recorder
|
||||
# Must run in recorder executor to avoid blocking and comply with HA best practices
|
||||
# Use partial to properly pass keyword arguments
|
||||
get_states_func = partial(
|
||||
history.get_significant_states,
|
||||
self._hass,
|
||||
start_time,
|
||||
end_time,
|
||||
entity_ids=[entity_id],
|
||||
)
|
||||
|
||||
# Serialize recorder access via shared FIFO queue (MANDATORY)
|
||||
async with self._recorder_queue.lock:
|
||||
_LOGGER.debug("Acquired recorder lock for sensor entity %s", entity_id)
|
||||
history_dict = await get_instance(self._hass).async_add_executor_job(get_states_func)
|
||||
|
||||
# Extract records for our entity - returns list of State objects or dicts
|
||||
state_list = history_dict.get(entity_id, [])
|
||||
del history_dict
|
||||
|
||||
# Convert State objects to lightweight dicts (OOM prevention).
|
||||
# Sensors typically only have a state value, minimal attributes needed.
|
||||
result = []
|
||||
for state in state_list:
|
||||
if isinstance(state, dict):
|
||||
result.append(state)
|
||||
else:
|
||||
result.append(
|
||||
{
|
||||
"entity_id": state.entity_id,
|
||||
"state": state.state,
|
||||
"attributes": dict(state.attributes),
|
||||
"last_changed": state.last_changed,
|
||||
"last_updated": state.last_updated,
|
||||
}
|
||||
)
|
||||
del state_list
|
||||
return result
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
"""Home Assistant timer scheduler adapter.
|
||||
|
||||
This adapter implements ITimerScheduler using Home Assistant's
|
||||
async_track_point_in_time API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Coroutine, cast
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.event import async_track_point_in_time
|
||||
|
||||
from ...domain.interfaces import ITimerScheduler
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HATimerScheduler(ITimerScheduler):
|
||||
"""Home Assistant implementation of timer scheduler.
|
||||
|
||||
Uses Home Assistant's event loop to schedule callbacks at specific times.
|
||||
This adapter contains NO business logic - it only wraps HA's timer API.
|
||||
"""
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the timer scheduler adapter.
|
||||
|
||||
Args:
|
||||
hass: Home Assistant instance
|
||||
"""
|
||||
self._hass = hass
|
||||
|
||||
def schedule_timer(
|
||||
self,
|
||||
target_time: datetime,
|
||||
callback_func: 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_func: Async function to execute at target_time
|
||||
|
||||
Returns:
|
||||
Cancel function that can be called to cancel the timer
|
||||
"""
|
||||
|
||||
@callback
|
||||
def _timer_callback(_now: datetime) -> None:
|
||||
"""Wrapper callback that creates async task."""
|
||||
self._hass.async_create_task(callback_func())
|
||||
|
||||
# Schedule the timer and return the cancel function
|
||||
cancel_func = async_track_point_in_time(
|
||||
self._hass,
|
||||
_timer_callback,
|
||||
target_time,
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Timer scheduled for %s",
|
||||
target_time.isoformat(),
|
||||
)
|
||||
|
||||
return cast(Callable[[], None], cancel_func)
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Shared utility functions for infrastructure adapters."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import cast
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
|
||||
def get_entity_name(hass: HomeAssistant, entity_id: str) -> str:
|
||||
"""Get the friendly name of an entity, falling back to entity_id.
|
||||
|
||||
Args:
|
||||
hass: Home Assistant instance
|
||||
entity_id: Entity ID to get name for
|
||||
|
||||
Returns:
|
||||
Friendly name or entity_id if not found
|
||||
"""
|
||||
state = hass.states.get(entity_id)
|
||||
if state:
|
||||
return cast(str, state.attributes.get("friendly_name", entity_id))
|
||||
return entity_id
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
"""Attribute mapper for Versatile Thermostat (VTherm) entities.
|
||||
|
||||
Handles the complex attribute structure of VTherm with support for
|
||||
both legacy (pre-v8.0.0) and modern (v8.0.0+) versions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ...domain.value_objects.entity_attribute_mapping import (
|
||||
AttributeConcept,
|
||||
AttributePath,
|
||||
EntityAttributeMapping,
|
||||
)
|
||||
from .base_entity_attribute_mapper import BaseEntityAttributeMapper
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VThermAttributeMapper(BaseEntityAttributeMapper):
|
||||
"""Mapper for Versatile Thermostat (VTherm) entities.
|
||||
|
||||
VTherm (https://github.com/jmcollin78/versatile_thermostat) is a sophisticated
|
||||
virtual thermostat integration. Its attributes vary by version:
|
||||
|
||||
- **v8.0.0+**: Uses nested "specific_states" for many attributes
|
||||
- **Pre-v8.0.0**: Flat structure with attributes at root level
|
||||
|
||||
This mapper transparently handles both versions.
|
||||
|
||||
See: https://github.com/jmcollin78/versatile_thermostat/blob/main/documentation/fr/reference.md#attributs-personnalisés
|
||||
"""
|
||||
|
||||
def _get_mapping(self) -> EntityAttributeMapping:
|
||||
"""Get VTherm attribute mapping.
|
||||
|
||||
Maps domain concepts to VTherm's attribute structure with fallbacks
|
||||
for different versions.
|
||||
|
||||
Returns:
|
||||
EntityAttributeMapping configured for VTherm
|
||||
"""
|
||||
return EntityAttributeMapping(
|
||||
entity_type="climate",
|
||||
entity_name="VTherm (Versatile Thermostat)",
|
||||
mappings={
|
||||
# Current temperature as measured by VTherm
|
||||
# Tries standard climate attribute first, then VTherm specific ones
|
||||
AttributeConcept.CURRENT_TEMPERATURE: [
|
||||
AttributePath(
|
||||
path="current_temperature",
|
||||
fallback_path=None,
|
||||
required=True,
|
||||
),
|
||||
AttributePath(
|
||||
path="specific_states.current_temperature",
|
||||
fallback_path=None,
|
||||
required=False,
|
||||
),
|
||||
],
|
||||
# Target temperature set by user or automation
|
||||
# Standard climate attribute "temperature" is most reliable
|
||||
AttributeConcept.TARGET_TEMPERATURE: [
|
||||
AttributePath(
|
||||
path="temperature",
|
||||
fallback_path="target_temperature",
|
||||
required=True,
|
||||
),
|
||||
AttributePath(
|
||||
path="specific_states.target_temperature",
|
||||
fallback_path=None,
|
||||
required=False,
|
||||
),
|
||||
],
|
||||
# Whether heating is currently active
|
||||
# Determined from hvac_action attribute
|
||||
# In v8.0.0+, may be in specific_states
|
||||
AttributeConcept.HEATING_ACTIVE: [
|
||||
AttributePath(
|
||||
path="hvac_action",
|
||||
fallback_path=None,
|
||||
required=True,
|
||||
),
|
||||
AttributePath(
|
||||
path="specific_states.hvac_action",
|
||||
fallback_path=None,
|
||||
required=False,
|
||||
),
|
||||
],
|
||||
# Raw hvac_action string (for compatibility)
|
||||
AttributeConcept.HVAC_ACTION: [
|
||||
AttributePath(
|
||||
path="hvac_action",
|
||||
fallback_path=None,
|
||||
required=False,
|
||||
),
|
||||
AttributePath(
|
||||
path="specific_states.hvac_action",
|
||||
fallback_path=None,
|
||||
required=False,
|
||||
),
|
||||
],
|
||||
},
|
||||
)
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
"""Home Assistant weather data reader adapter.
|
||||
|
||||
Provides historical data access for weather entities via HA Recorder.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ...domain.interfaces.historical_data_adapter_interface import IHistoricalDataAdapter
|
||||
from ...domain.value_objects import (
|
||||
HistoricalDataKey,
|
||||
HistoricalDataSet,
|
||||
HistoricalMeasurement,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from ..recorder_queue import RecorderAccessQueue
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HAWeatherDataReader(IHistoricalDataAdapter):
|
||||
"""Adapter for reading historical weather data from Home Assistant.
|
||||
|
||||
Weather entities provide temperature, humidity, cloud coverage, etc.
|
||||
|
||||
Uses RecorderAccessQueue (MANDATORY) to serialize database access and prevent
|
||||
Home Assistant performance degradation when multiple IHP instances query
|
||||
historical data simultaneously.
|
||||
"""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, recorder_queue: RecorderAccessQueue) -> None:
|
||||
"""Initialize the weather data reader.
|
||||
|
||||
Args:
|
||||
hass: Home Assistant instance
|
||||
recorder_queue: Shared FIFO queue to serialize recorder access (MANDATORY)
|
||||
"""
|
||||
self._hass = hass
|
||||
self._recorder_queue = recorder_queue
|
||||
_LOGGER.debug("Initialized HAWeatherDataReader with mandatory RecorderAccessQueue")
|
||||
|
||||
async def fetch_historical_data(
|
||||
self,
|
||||
entity_id: str,
|
||||
data_key: HistoricalDataKey,
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
) -> HistoricalDataSet:
|
||||
"""Fetch historical data for a weather entity.
|
||||
|
||||
USES RecorderAccessQueue to serialize database access.
|
||||
|
||||
Args:
|
||||
entity_id: Weather entity ID (e.g., "weather.home")
|
||||
data_key: HistoricalDataKey (typically OUTDOOR_TEMP, OUTDOOR_HUMIDITY, CLOUD_COVERAGE)
|
||||
start_time: Start of historical period
|
||||
end_time: End of historical period
|
||||
|
||||
Returns:
|
||||
HistoricalDataSet with extracted weather data
|
||||
|
||||
Raises:
|
||||
ValueError: If entity_id is invalid or history cannot be retrieved
|
||||
"""
|
||||
_LOGGER.debug(
|
||||
"Fetching weather history for %s from %s to %s",
|
||||
entity_id,
|
||||
start_time,
|
||||
end_time,
|
||||
)
|
||||
|
||||
try:
|
||||
# Get historical data from Home Assistant
|
||||
historical_records = await self._fetch_history(
|
||||
entity_id,
|
||||
start_time,
|
||||
end_time,
|
||||
)
|
||||
except Exception as exc:
|
||||
_LOGGER.error("Failed to fetch history for %s: %s", entity_id, exc)
|
||||
raise ValueError(f"Cannot fetch history for entity {entity_id}") from exc
|
||||
|
||||
if not historical_records:
|
||||
_LOGGER.warning("No history found for %s", entity_id)
|
||||
return HistoricalDataSet(data={})
|
||||
|
||||
measurements: list[HistoricalMeasurement] = []
|
||||
|
||||
for record in historical_records:
|
||||
timestamp = self._parse_timestamp(record)
|
||||
state = record.get("state", "")
|
||||
attributes = record.get("attributes", {})
|
||||
entity_id_from_record = record.get("entity_id", entity_id)
|
||||
|
||||
# Create attributes dict with weather state
|
||||
enriched_attributes = {**attributes, "weather_state": state}
|
||||
|
||||
# Extract data based on requested data_key
|
||||
value = None
|
||||
if data_key == HistoricalDataKey.OUTDOOR_TEMP:
|
||||
# Extract outdoor temperature
|
||||
if "temperature" in attributes:
|
||||
value = self._safe_float(attributes["temperature"])
|
||||
|
||||
elif data_key == HistoricalDataKey.OUTDOOR_HUMIDITY:
|
||||
# Extract outdoor humidity
|
||||
if "humidity" in attributes:
|
||||
value = self._safe_float(attributes["humidity"])
|
||||
|
||||
elif data_key == HistoricalDataKey.CLOUD_COVERAGE:
|
||||
# Extract cloud coverage
|
||||
if "cloud_coverage" in attributes:
|
||||
value = self._safe_float(attributes["cloud_coverage"])
|
||||
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"Weather adapter does not support data_key %s for entity %s",
|
||||
data_key,
|
||||
entity_id,
|
||||
)
|
||||
continue
|
||||
|
||||
# Add measurement if value was extracted
|
||||
if value is not None:
|
||||
measurements.append(
|
||||
HistoricalMeasurement(
|
||||
timestamp=timestamp,
|
||||
value=value,
|
||||
attributes=enriched_attributes,
|
||||
entity_id=entity_id_from_record,
|
||||
)
|
||||
)
|
||||
|
||||
# Build result
|
||||
data: dict[HistoricalDataKey, list[HistoricalMeasurement]] = {}
|
||||
if measurements:
|
||||
data[data_key] = measurements
|
||||
|
||||
_LOGGER.debug(
|
||||
"Extracted %d measurements for %s with key %s",
|
||||
len(measurements),
|
||||
entity_id,
|
||||
data_key,
|
||||
)
|
||||
|
||||
return HistoricalDataSet(data=data)
|
||||
|
||||
@staticmethod
|
||||
def _parse_timestamp(record: dict[str, Any]) -> datetime:
|
||||
"""Parse timestamp from history record.
|
||||
|
||||
Args:
|
||||
record: Historical record from Home Assistant
|
||||
|
||||
Returns:
|
||||
Parsed datetime object
|
||||
"""
|
||||
timestamp_str = record.get("last_changed", record.get("last_updated"))
|
||||
|
||||
if isinstance(timestamp_str, str):
|
||||
# Parse ISO format string
|
||||
if "+" in timestamp_str:
|
||||
timestamp_str = timestamp_str.split("+")[0]
|
||||
elif "Z" in timestamp_str:
|
||||
timestamp_str = timestamp_str.replace("Z", "")
|
||||
|
||||
return datetime.fromisoformat(timestamp_str)
|
||||
|
||||
# If already a datetime, return as-is
|
||||
if isinstance(timestamp_str, datetime):
|
||||
return timestamp_str
|
||||
|
||||
# Fallback: return current time if no timestamp found
|
||||
return datetime.now()
|
||||
|
||||
@staticmethod
|
||||
def _safe_float(value: Any) -> float | None:
|
||||
"""Safely convert value to float.
|
||||
|
||||
Args:
|
||||
value: Value to convert
|
||||
|
||||
Returns:
|
||||
Float value or None if conversion fails
|
||||
"""
|
||||
try:
|
||||
return float(value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
async def _fetch_history(
|
||||
self,
|
||||
entity_id: str,
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Fetch historical data from Home Assistant Recorder.
|
||||
|
||||
CRITICAL: Uses RecorderAccessQueue to serialize database access.
|
||||
This is a FIFO queue shared across all IHP instances to prevent
|
||||
overwhelming the recorder during startup or cache refresh.
|
||||
|
||||
Args:
|
||||
entity_id: The entity ID
|
||||
start_time: Start of historical period
|
||||
end_time: End of historical period
|
||||
|
||||
Returns:
|
||||
List of historical records from Home Assistant
|
||||
"""
|
||||
from functools import partial
|
||||
|
||||
from homeassistant.components.recorder import get_instance, history
|
||||
|
||||
# Use Home Assistant's get_significant_states function from recorder
|
||||
# Must run in recorder executor to avoid blocking and comply with HA best practices
|
||||
# Use partial to properly pass keyword arguments
|
||||
get_states_func = partial(
|
||||
history.get_significant_states,
|
||||
self._hass,
|
||||
start_time,
|
||||
end_time,
|
||||
entity_ids=[entity_id],
|
||||
)
|
||||
|
||||
# Serialize recorder access via shared FIFO queue (MANDATORY)
|
||||
async with self._recorder_queue.lock:
|
||||
_LOGGER.debug("Acquired recorder lock for weather entity %s", entity_id)
|
||||
history_dict = await get_instance(self._hass).async_add_executor_job(get_states_func)
|
||||
|
||||
# Extract records for our entity - returns list of State objects or dicts
|
||||
state_list = history_dict.get(entity_id, [])
|
||||
del history_dict
|
||||
|
||||
# Convert State objects to lightweight dicts (OOM prevention)
|
||||
result = []
|
||||
for state in state_list:
|
||||
if isinstance(state, dict):
|
||||
result.append(state)
|
||||
else:
|
||||
raw_attrs = state.attributes
|
||||
slim_attrs = {}
|
||||
for key in ("temperature", "humidity", "cloud_coverage", "cloud_cover"):
|
||||
val = raw_attrs.get(key)
|
||||
if val is not None:
|
||||
slim_attrs[key] = val
|
||||
result.append(
|
||||
{
|
||||
"entity_id": state.entity_id,
|
||||
"state": state.state,
|
||||
"attributes": slim_attrs,
|
||||
"last_changed": state.last_changed,
|
||||
"last_updated": state.last_updated,
|
||||
}
|
||||
)
|
||||
del state_list
|
||||
return result
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
"""Strategy factory for creating decision strategies."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from ..const import DECISION_MODE_ML, DECISION_MODE_SIMPLE
|
||||
from ..domain.interfaces import ILhsStorage, ISchedulerReader
|
||||
from ..domain.interfaces.decision_strategy_interface import IDecisionStrategy
|
||||
from ..domain.services.ml_decision_strategy import MLDecisionStrategy
|
||||
from ..domain.services.simple_decision_strategy import SimpleDecisionStrategy
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DecisionStrategyFactory:
|
||||
"""Factory for creating decision strategies based on configuration.
|
||||
|
||||
This factory belongs in the infrastructure layer as it deals with
|
||||
Home Assistant configuration and instantiation of concrete strategy
|
||||
implementations.
|
||||
|
||||
The factory pattern ensures:
|
||||
- Single point of strategy creation
|
||||
- Easy testing with mocks
|
||||
- Configuration-driven behavior
|
||||
- No Home Assistant coupling in domain layer
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def create_strategy(
|
||||
mode: str,
|
||||
scheduler_reader: ISchedulerReader,
|
||||
model_storage: ILhsStorage,
|
||||
# hass: HomeAssistant, # TODO: Add when implementing ML client
|
||||
) -> IDecisionStrategy:
|
||||
"""Create the appropriate decision strategy based on mode.
|
||||
|
||||
Args:
|
||||
mode: Decision mode ('simple' or 'ml')
|
||||
scheduler_reader: Scheduler reader implementation
|
||||
model_storage: Model storage implementation
|
||||
# hass: Home Assistant instance (for ML API client)
|
||||
|
||||
Returns:
|
||||
Configured decision strategy implementation
|
||||
|
||||
Raises:
|
||||
ValueError: If mode is not recognized
|
||||
"""
|
||||
_LOGGER.debug(f"Creating decision strategy for mode: {mode}")
|
||||
|
||||
if mode == DECISION_MODE_SIMPLE:
|
||||
_LOGGER.debug("Instantiating SimpleDecisionStrategy")
|
||||
return SimpleDecisionStrategy(
|
||||
scheduler_reader=scheduler_reader,
|
||||
model_storage=model_storage,
|
||||
)
|
||||
|
||||
elif mode == DECISION_MODE_ML:
|
||||
_LOGGER.debug("Instantiating MLDecisionStrategy")
|
||||
|
||||
# TODO: Create ML client adapter when implementing
|
||||
# ml_client = MLApiAdapter(hass)
|
||||
|
||||
return MLDecisionStrategy(
|
||||
scheduler_reader=scheduler_reader,
|
||||
# ml_client=ml_client,
|
||||
)
|
||||
|
||||
else:
|
||||
error_msg = (
|
||||
f"Unknown decision mode: {mode}. "
|
||||
f"Valid modes: {DECISION_MODE_SIMPLE}, {DECISION_MODE_ML}"
|
||||
)
|
||||
_LOGGER.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
@@ -0,0 +1,457 @@
|
||||
"""Home Assistant event bridge - translates HA events to orchestrator calls.
|
||||
|
||||
This infrastructure component listens to HA entity state changes and delegates
|
||||
to the orchestrator.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
|
||||
from homeassistant.helpers.event import async_track_state_change_event
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .vtherm_compat import get_vtherm_attribute
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import datetime
|
||||
|
||||
from ..application import HeatingOrchestrator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Minimum change in monitored sensor value (humidity %, cloud coverage %) to
|
||||
# trigger a recalculation. Small fluctuations below this threshold are ignored.
|
||||
_MONITORED_ENTITY_CHANGE_THRESHOLD = 3.0
|
||||
|
||||
# Tolerance in seconds for anticipated_start_time comparison. Changes smaller
|
||||
# than this are not considered meaningful enough to re-publish the event.
|
||||
_ANTICIPATION_TIME_TOLERANCE_SECONDS = 60
|
||||
|
||||
|
||||
class HAEventBridge:
|
||||
"""Bridges Home Assistant events to application service.
|
||||
|
||||
This infrastructure component:
|
||||
- Listens to relevant HA entity state changes
|
||||
- Translates events to application service calls
|
||||
- Manages state change listeners lifecycle
|
||||
|
||||
NO business logic - pure event routing.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
orchestrator: HeatingOrchestrator,
|
||||
vtherm_entity_id: str,
|
||||
scheduler_entity_ids: list[str],
|
||||
monitored_entity_ids: list[str] | None = None,
|
||||
entry_id: str | None = None,
|
||||
get_ihp_enabled_func: Callable[[], bool] | None = None,
|
||||
) -> None:
|
||||
"""Initialize the event bridge.
|
||||
|
||||
Args:
|
||||
hass: Home Assistant instance
|
||||
orchestrator: Orchestrator to delegate to
|
||||
vtherm_entity_id: VTherm entity to monitor for slopes
|
||||
scheduler_entity_ids: Scheduler entities to monitor
|
||||
monitored_entity_ids: Additional entities to monitor (humidity, etc.)
|
||||
entry_id: Config entry ID for event filtering
|
||||
get_ihp_enabled_func: Callback function to get current IHP enabled state
|
||||
"""
|
||||
self._hass = hass
|
||||
self._orchestrator = orchestrator
|
||||
self._vtherm_entity_id = vtherm_entity_id
|
||||
self._scheduler_entity_ids = scheduler_entity_ids
|
||||
self._monitored_entity_ids = monitored_entity_ids or []
|
||||
self._get_ihp_enabled = get_ihp_enabled_func or (lambda: True)
|
||||
self._entry_id = entry_id
|
||||
|
||||
# Track all entities that should trigger updates
|
||||
self._tracked_entities = (
|
||||
[vtherm_entity_id] + scheduler_entity_ids + self._monitored_entity_ids
|
||||
)
|
||||
|
||||
# Listener cleanup callbacks
|
||||
self._listeners: list = []
|
||||
|
||||
# Debouncing state
|
||||
self._ignore_vtherm_until: datetime | None = None
|
||||
|
||||
# Deduplication: remember the last event data that was published so we
|
||||
# can skip firing when nothing meaningful has changed.
|
||||
self._last_published_data: dict | None = None
|
||||
|
||||
# Track active tasks for proper shutdown
|
||||
self._active_tasks: set = set()
|
||||
self._is_shutting_down = False
|
||||
self._recalculate_task: asyncio.Task | None = None
|
||||
self._recalculate_pending = False
|
||||
|
||||
def setup_listeners(self) -> None:
|
||||
"""Setup all event listeners."""
|
||||
|
||||
@callback
|
||||
def _on_entity_changed(event: Event[EventStateChangedData]) -> None:
|
||||
"""Handle entity state change events.
|
||||
|
||||
All listened entities trigger _recalculate_and_publish(), which routes
|
||||
to appropriate orchestrator method based on event source.
|
||||
"""
|
||||
entity_id = event.data.get("entity_id")
|
||||
|
||||
if entity_id not in self._tracked_entities:
|
||||
return
|
||||
|
||||
# VTherm-specific handling for slope learning
|
||||
if entity_id == self._vtherm_entity_id:
|
||||
self._handle_vtherm_change(event)
|
||||
elif entity_id in self._scheduler_entity_ids:
|
||||
# Only trigger for meaningful scheduler state changes
|
||||
self._trigger_recalculate_if_meaningful(
|
||||
self._has_meaningful_scheduler_change(event), entity_id
|
||||
)
|
||||
else:
|
||||
# Monitored entity (humidity, cloud cover) – apply threshold filter
|
||||
self._trigger_recalculate_if_meaningful(
|
||||
self._has_meaningful_monitored_change(event), entity_id
|
||||
)
|
||||
|
||||
# Register state change listener
|
||||
unsub = async_track_state_change_event(
|
||||
self._hass, self._tracked_entities, _on_entity_changed
|
||||
)
|
||||
self._listeners.append(unsub)
|
||||
|
||||
_LOGGER.debug("Event bridge tracking %d entities", len(self._tracked_entities))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Smart filtering helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _trigger_recalculate_if_meaningful(self, meaningful: bool, entity_id: str) -> None:
|
||||
"""Trigger recalculation when the entity change is meaningful.
|
||||
|
||||
Logs the outcome at DEBUG level to aid diagnostics without noise.
|
||||
"""
|
||||
if meaningful and not self._is_shutting_down:
|
||||
_LOGGER.debug("Entity %s changed meaningfully, triggering update", entity_id)
|
||||
self._request_recalculate()
|
||||
elif not meaningful:
|
||||
_LOGGER.debug("Entity %s change not actionable, skipping recalculation", entity_id)
|
||||
|
||||
def _request_recalculate(self) -> None:
|
||||
"""Request a recalculation with coalescing.
|
||||
|
||||
If a recalculation is already running, mark one pending pass instead of
|
||||
spawning additional concurrent tasks.
|
||||
"""
|
||||
if self._is_shutting_down:
|
||||
return
|
||||
|
||||
if self._recalculate_task is not None and not self._recalculate_task.done():
|
||||
self._recalculate_pending = True
|
||||
_LOGGER.debug("Recalculation already running, coalescing trigger")
|
||||
return
|
||||
|
||||
task = self._hass.async_create_task(self._run_recalculate_loop())
|
||||
self._recalculate_task = task
|
||||
self._active_tasks.add(task)
|
||||
task.add_done_callback(self._on_recalculate_task_done)
|
||||
|
||||
def _on_recalculate_task_done(self, task: asyncio.Task) -> None:
|
||||
"""Track recalculation task completion and cleanup references."""
|
||||
self._active_tasks.discard(task)
|
||||
if self._recalculate_task is task:
|
||||
self._recalculate_task = None
|
||||
|
||||
async def _run_recalculate_loop(self) -> None:
|
||||
"""Run recalculation and process one coalesced pending request if needed."""
|
||||
while not self._is_shutting_down:
|
||||
try:
|
||||
await self._recalculate_and_publish()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_LOGGER.error("Error during recalculation workflow: %s", exc, exc_info=True)
|
||||
|
||||
if self._recalculate_pending:
|
||||
self._recalculate_pending = False
|
||||
_LOGGER.debug("Processing coalesced recalculation trigger")
|
||||
continue
|
||||
break
|
||||
|
||||
def _has_meaningful_scheduler_change(self, event: Event[EventStateChangedData]) -> bool:
|
||||
"""Return True only when a scheduler state change is actionable for IHP.
|
||||
|
||||
Ignores attribute-only updates that do not affect the IHP schedule
|
||||
(e.g., internal counters, last_triggered, etc.). Only the following
|
||||
changes are considered meaningful:
|
||||
|
||||
- The enabled / disabled state (on / off) toggled.
|
||||
- The ``next_trigger`` timestamp changed (different upcoming slot).
|
||||
- The ``actions`` attribute changed (different target temperature / preset).
|
||||
"""
|
||||
old_state = event.data.get("old_state")
|
||||
new_state = event.data.get("new_state")
|
||||
|
||||
if not old_state or not new_state:
|
||||
return True
|
||||
|
||||
# Enabled / disabled toggle
|
||||
if old_state.state != new_state.state:
|
||||
return True
|
||||
|
||||
old_attrs = old_state.attributes
|
||||
new_attrs = new_state.attributes
|
||||
|
||||
# Next occurrence time changed
|
||||
if old_attrs.get("next_trigger") != new_attrs.get("next_trigger"):
|
||||
return True
|
||||
|
||||
# Target temperature / actions changed
|
||||
return bool(old_attrs.get("actions") != new_attrs.get("actions"))
|
||||
|
||||
def _has_meaningful_monitored_change(self, event: Event[EventStateChangedData]) -> bool:
|
||||
"""Return True only when a monitored sensor value changed significantly.
|
||||
|
||||
Tiny fluctuations in humidity or cloud coverage sensors are ignored.
|
||||
Availability transitions are always considered meaningful.
|
||||
"""
|
||||
old_state = event.data.get("old_state")
|
||||
new_state = event.data.get("new_state")
|
||||
|
||||
if not old_state or not new_state:
|
||||
return True
|
||||
|
||||
_unavailable = {"unavailable", "unknown"}
|
||||
old_unavailable = old_state.state in _unavailable
|
||||
new_unavailable = new_state.state in _unavailable
|
||||
|
||||
# Availability transition is always actionable
|
||||
if old_unavailable != new_unavailable:
|
||||
return True
|
||||
|
||||
# Both unavailable – nothing changed
|
||||
if old_unavailable and new_unavailable:
|
||||
return False
|
||||
|
||||
try:
|
||||
old_val = float(str(old_state.state))
|
||||
new_val = float(str(new_state.state))
|
||||
return abs(new_val - old_val) >= _MONITORED_ENTITY_CHANGE_THRESHOLD
|
||||
except (TypeError, ValueError):
|
||||
# Non-numeric state: trigger on any state string change
|
||||
return bool(old_state.state != new_state.state)
|
||||
|
||||
def _is_meaningful_change_from_last(self, new_data: dict) -> bool:
|
||||
"""Return True when *new_data* differs meaningfully from the last published event.
|
||||
|
||||
Prevents flooding sensors with identical or near-identical events.
|
||||
"""
|
||||
last = self._last_published_data
|
||||
if last is None:
|
||||
return True
|
||||
|
||||
# Core schedule fields – any difference is significant
|
||||
for key in ("next_schedule_time", "next_target_temperature", "scheduler_entity"):
|
||||
if new_data.get(key) != last.get(key):
|
||||
return True
|
||||
|
||||
# Current indoor temperature (0.1 °C tolerance)
|
||||
new_temp = new_data.get("current_temp")
|
||||
last_temp = last.get("current_temp")
|
||||
# Transition between "no data" and a real temperature is always meaningful
|
||||
if (new_temp is None) != (last_temp is None):
|
||||
return True
|
||||
if new_temp is not None and last_temp is not None and abs(new_temp - last_temp) >= 0.1:
|
||||
return True
|
||||
|
||||
# Learned heating slope (0.05 °C/h tolerance)
|
||||
new_lhs = new_data.get("learned_heating_slope")
|
||||
last_lhs = last.get("learned_heating_slope")
|
||||
# Transition between "no data" and a real slope is always meaningful
|
||||
if (new_lhs is None) != (last_lhs is None):
|
||||
return True
|
||||
if new_lhs is not None and last_lhs is not None and abs(new_lhs - last_lhs) >= 0.05:
|
||||
return True
|
||||
|
||||
# Anticipated start time (1-minute tolerance)
|
||||
new_start = new_data.get("anticipated_start_time")
|
||||
last_start = last.get("anticipated_start_time")
|
||||
|
||||
# If both are equal (including both None or identical strings/datetimes), no meaningful change
|
||||
if new_start == last_start:
|
||||
return False
|
||||
|
||||
# If one is None and the other is not, this is a meaningful change
|
||||
if new_start is None or last_start is None:
|
||||
return True
|
||||
|
||||
try:
|
||||
new_dt = dt_util.parse_datetime(new_start) if isinstance(new_start, str) else new_start
|
||||
last_dt = (
|
||||
dt_util.parse_datetime(last_start) if isinstance(last_start, str) else last_start
|
||||
)
|
||||
if new_dt is None or last_dt is None:
|
||||
return True
|
||||
if abs((new_dt - last_dt).total_seconds()) >= _ANTICIPATION_TIME_TOLERANCE_SECONDS:
|
||||
return True
|
||||
except (TypeError, ValueError, AttributeError):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _handle_vtherm_change(self, event: Event[EventStateChangedData]) -> None:
|
||||
"""Handle VTherm state changes (temperature filter + recalculation trigger).
|
||||
|
||||
Args:
|
||||
event: State change event
|
||||
"""
|
||||
if self._is_shutting_down:
|
||||
return
|
||||
|
||||
old_state = event.data.get("old_state")
|
||||
new_state = event.data.get("new_state")
|
||||
|
||||
if not old_state or not new_state:
|
||||
return
|
||||
|
||||
# Check if we should ignore (self-induced change)
|
||||
if self._ignore_vtherm_until and dt_util.now() < self._ignore_vtherm_until:
|
||||
_LOGGER.debug("Ignoring self-induced VTherm change")
|
||||
return
|
||||
|
||||
# Extract temperature changes (v8.0.0+ compatible)
|
||||
old_temp = get_vtherm_attribute(old_state, "current_temperature")
|
||||
new_temp = get_vtherm_attribute(new_state, "current_temperature")
|
||||
|
||||
if old_temp == new_temp:
|
||||
_LOGGER.debug("VTherm change but temperature unchanged, skipping")
|
||||
return
|
||||
|
||||
_LOGGER.debug("VTherm temperature changed: %s -> %s", old_temp, new_temp)
|
||||
self._request_recalculate()
|
||||
|
||||
async def _recalculate_and_publish(self) -> None:
|
||||
"""Recalculate anticipation and publish event for sensors if data changed.
|
||||
|
||||
Always fires the same unified event structure. Scheduling/clearing is
|
||||
expressed via None values (never a ``clear_values`` flag) so that all
|
||||
sensors share a single, consistent event shape.
|
||||
"""
|
||||
anticipation_data = await self._orchestrator.calculate_and_schedule_anticipation(
|
||||
ihp_enabled=self._get_ihp_enabled()
|
||||
)
|
||||
|
||||
if not anticipation_data:
|
||||
anticipation_data = {}
|
||||
|
||||
# Build unified event structure (same keys always, None for missing values)
|
||||
has_complete_data = (
|
||||
anticipation_data.get("anticipated_start_time") is not None
|
||||
and anticipation_data.get("next_schedule_time") is not None
|
||||
)
|
||||
|
||||
if has_complete_data:
|
||||
event_data: dict = {
|
||||
"entry_id": self._entry_id,
|
||||
"anticipated_start_time": anticipation_data["anticipated_start_time"].isoformat(),
|
||||
"next_schedule_time": anticipation_data["next_schedule_time"].isoformat(),
|
||||
"next_target_temperature": anticipation_data.get("next_target_temperature"),
|
||||
"anticipation_minutes": anticipation_data.get("anticipation_minutes"),
|
||||
"current_temp": anticipation_data.get("current_temp"),
|
||||
"learned_heating_slope": anticipation_data.get("learned_heating_slope"),
|
||||
"confidence_level": anticipation_data.get("confidence_level"),
|
||||
"scheduler_entity": anticipation_data.get("scheduler_entity"),
|
||||
}
|
||||
else:
|
||||
event_data = {
|
||||
"entry_id": self._entry_id,
|
||||
"anticipated_start_time": None,
|
||||
"next_schedule_time": None,
|
||||
"next_target_temperature": None,
|
||||
"anticipation_minutes": None,
|
||||
"current_temp": anticipation_data.get("current_temp"),
|
||||
"learned_heating_slope": anticipation_data.get("learned_heating_slope"),
|
||||
"confidence_level": None,
|
||||
"scheduler_entity": None,
|
||||
}
|
||||
|
||||
if self._is_meaningful_change_from_last(event_data):
|
||||
self._hass.bus.async_fire(
|
||||
"intelligent_heating_pilot_anticipation_calculated",
|
||||
event_data,
|
||||
)
|
||||
self._last_published_data = event_data
|
||||
_LOGGER.debug("Published anticipation event for sensors (data changed)")
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Skipped anticipation event: no meaningful change from last published data"
|
||||
)
|
||||
|
||||
def ignore_vtherm_changes_for(self, seconds: int = 10) -> None:
|
||||
"""Temporarily ignore VTherm changes (used after self-induced changes).
|
||||
|
||||
Args:
|
||||
seconds: How long to ignore changes
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
self._ignore_vtherm_until = dt_util.now() + timedelta(seconds=seconds)
|
||||
|
||||
async def async_cleanup(self) -> None:
|
||||
"""Cleanup all event listeners and cancel pending tasks.
|
||||
|
||||
This is called during integration unload to ensure:
|
||||
1. No more events trigger new calculations
|
||||
2. All pending tasks are cancelled with timeout
|
||||
"""
|
||||
_LOGGER.debug("Starting event bridge cleanup with %d active tasks", len(self._active_tasks))
|
||||
self._is_shutting_down = True
|
||||
|
||||
# Unsubscribe from all listeners to prevent new tasks
|
||||
for unsub in self._listeners:
|
||||
unsub()
|
||||
self._listeners.clear()
|
||||
|
||||
# Cancel all active tasks with timeout
|
||||
if self._active_tasks:
|
||||
_LOGGER.debug("Cancelling %d active recalculation tasks", len(self._active_tasks))
|
||||
for task in self._active_tasks:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
|
||||
# Wait for tasks to complete (with timeout to prevent hangs)
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(*self._active_tasks, return_exceptions=True),
|
||||
timeout=5.0, # 5-second timeout for all tasks
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
_LOGGER.warning(
|
||||
"Timeout waiting for %d tasks to shutdown; forcing cleanup",
|
||||
len(self._active_tasks),
|
||||
)
|
||||
finally:
|
||||
self._active_tasks.clear()
|
||||
|
||||
_LOGGER.debug("Event bridge cleanup completed")
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Synchronous cleanup for backwards compatibility.
|
||||
|
||||
Note: This is called by code that doesn't support async, but doesn't
|
||||
cancel pending tasks. Use async_cleanup() during normal unload.
|
||||
"""
|
||||
if self._is_shutting_down:
|
||||
return
|
||||
self._is_shutting_down = True
|
||||
for unsub in self._listeners:
|
||||
unsub()
|
||||
self._listeners.clear()
|
||||
_LOGGER.debug("Event bridge cleaned up (sync mode)")
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Recorder access queue for serializing Home Assistant recorder queries.
|
||||
|
||||
Provides a FIFO queue (asyncio.Lock) shared across all IHP device instances
|
||||
to prevent parallel recorder access that can overwhelm Home Assistant,
|
||||
especially at startup or during cache refresh with multiple IHP devices.
|
||||
|
||||
Also provides a global extraction semaphore to limit how many devices can
|
||||
run their extraction queues concurrently (OOM prevention on low-memory systems).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from ..const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
RECORDER_QUEUE_KEY = "recorder_queue"
|
||||
EXTRACTION_SEMAPHORE_KEY = "extraction_semaphore"
|
||||
# Maximum number of devices that can run extraction concurrently.
|
||||
# With 8 devices, this ensures at most 2 are processing recorder data
|
||||
# at the same time, preventing memory exhaustion during startup.
|
||||
MAX_CONCURRENT_EXTRACTIONS = 2
|
||||
|
||||
|
||||
class RecorderAccessQueue:
|
||||
"""FIFO queue for serializing recorder access across all IHP instances.
|
||||
|
||||
Uses an asyncio.Lock to ensure that only one IHP device queries the
|
||||
recorder at a time. asyncio.Lock is FIFO: waiters are served in the
|
||||
order they requested the lock.
|
||||
|
||||
This prevents performance issues when multiple IHP devices simultaneously
|
||||
query the recorder (e.g., at HA startup or during periodic cache refresh).
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the recorder access queue."""
|
||||
self._lock = asyncio.Lock()
|
||||
_LOGGER.debug("RecorderAccessQueue initialized")
|
||||
|
||||
@property
|
||||
def lock(self) -> asyncio.Lock:
|
||||
"""Return the asyncio.Lock for use as an async context manager.
|
||||
|
||||
Usage:
|
||||
async with recorder_queue.lock:
|
||||
# Perform recorder query
|
||||
"""
|
||||
return self._lock
|
||||
|
||||
|
||||
def get_recorder_queue(hass: HomeAssistant) -> RecorderAccessQueue:
|
||||
"""Get or create the shared RecorderAccessQueue for this HA instance.
|
||||
|
||||
The queue is stored in hass.data[DOMAIN][RECORDER_QUEUE_KEY] and shared
|
||||
across all IHP device entries to serialize recorder access.
|
||||
|
||||
Args:
|
||||
hass: Home Assistant instance
|
||||
|
||||
Returns:
|
||||
The shared RecorderAccessQueue instance
|
||||
"""
|
||||
domain_data = hass.data.setdefault(DOMAIN, {})
|
||||
|
||||
if RECORDER_QUEUE_KEY not in domain_data:
|
||||
domain_data[RECORDER_QUEUE_KEY] = RecorderAccessQueue()
|
||||
_LOGGER.debug("Created shared RecorderAccessQueue in hass.data[%s]", DOMAIN)
|
||||
|
||||
# Type cast for mypy since domain_data is typed as dict[str, Any]
|
||||
return domain_data[RECORDER_QUEUE_KEY] # type: ignore[no-any-return]
|
||||
|
||||
|
||||
def get_extraction_semaphore(hass: HomeAssistant) -> asyncio.Semaphore:
|
||||
"""Get or create the shared extraction semaphore for this HA instance.
|
||||
|
||||
Limits the number of devices that can run extraction queues concurrently.
|
||||
This prevents OOM kills when many devices start extracting recorder data
|
||||
at HA startup (each extraction loads historical State objects into memory).
|
||||
|
||||
Args:
|
||||
hass: Home Assistant instance
|
||||
|
||||
Returns:
|
||||
The shared asyncio.Semaphore instance
|
||||
"""
|
||||
domain_data = hass.data.setdefault(DOMAIN, {})
|
||||
|
||||
if EXTRACTION_SEMAPHORE_KEY not in domain_data:
|
||||
domain_data[EXTRACTION_SEMAPHORE_KEY] = asyncio.Semaphore(MAX_CONCURRENT_EXTRACTIONS)
|
||||
_LOGGER.debug(
|
||||
"Created shared extraction semaphore (max=%d) in hass.data[%s]",
|
||||
MAX_CONCURRENT_EXTRACTIONS,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
return domain_data[EXTRACTION_SEMAPHORE_KEY] # type: ignore[no-any-return]
|
||||
@@ -0,0 +1,13 @@
|
||||
"""REST API handlers for the Intelligent Heating Pilot integration.
|
||||
|
||||
NOTE: REST API endpoints are not used in IHP v0.5.0+.
|
||||
All functionality is exposed via Home Assistant services:
|
||||
- service: intelligent_heating_pilot.calculate_anticipated_start_time
|
||||
- service: intelligent_heating_pilot.reset_learning
|
||||
|
||||
See __init__.py for service implementations.
|
||||
|
||||
This file is kept for future REST API extensions if needed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Versatile Thermostat compatibility layer.
|
||||
|
||||
This module provides backward-compatible access to VTherm attributes
|
||||
to support both legacy versions (pre-v8.0.0) and new versions (v8.0.0+).
|
||||
|
||||
In v8.0.0+, many attributes were moved under a 'specific_states' nested object.
|
||||
This module abstracts that change to maintain compatibility.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import State
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_vtherm_attribute(
|
||||
state: State | None,
|
||||
attribute_name: str,
|
||||
default: Any = None,
|
||||
) -> Any:
|
||||
"""Get a VTherm attribute with backward compatibility.
|
||||
|
||||
Tries to read from the new nested path first (v8.0.0+):
|
||||
state.attributes["specific_states"][attribute_name]
|
||||
|
||||
Falls back to the legacy root path (pre-v8.0.0):
|
||||
state.attributes[attribute_name]
|
||||
|
||||
Args:
|
||||
state: The VTherm entity state object
|
||||
attribute_name: Name of the attribute to retrieve
|
||||
default: Default value if attribute not found
|
||||
|
||||
Returns:
|
||||
The attribute value, or default if not found
|
||||
|
||||
Examples:
|
||||
>>> # Works with v8.0.0+ (nested structure)
|
||||
>>> get_vtherm_attribute(state, "temperature_slope")
|
||||
0.04
|
||||
|
||||
>>> # Also works with pre-v8.0.0 (flat structure)
|
||||
>>> get_vtherm_attribute(state, "temperature_slope")
|
||||
0.04
|
||||
"""
|
||||
if not state or not state.attributes:
|
||||
return default
|
||||
|
||||
# Try new nested path first (v8.0.0+)
|
||||
specific_states = state.attributes.get("specific_states")
|
||||
if specific_states and isinstance(specific_states, dict):
|
||||
value = specific_states.get(attribute_name)
|
||||
if value is not None:
|
||||
_LOGGER.debug(
|
||||
"Found %s in specific_states (v8.0.0+ format): %s",
|
||||
attribute_name,
|
||||
value,
|
||||
)
|
||||
return value
|
||||
|
||||
# Fallback to legacy root path (pre-v8.0.0)
|
||||
value = state.attributes.get(attribute_name)
|
||||
if value is not None:
|
||||
_LOGGER.debug(
|
||||
"Found %s at root level (legacy format): %s",
|
||||
attribute_name,
|
||||
value,
|
||||
)
|
||||
return value
|
||||
|
||||
_LOGGER.debug(
|
||||
"Attribute %s not found in state %s, using default: %s",
|
||||
attribute_name,
|
||||
state.entity_id,
|
||||
default,
|
||||
)
|
||||
return default
|
||||
Reference in New Issue
Block a user