New apps Added

This commit is contained in:
2026-07-08 10:43:39 -04:00
parent 3b1f4bbd75
commit fefc2c8b5c
1114 changed files with 406637 additions and 154 deletions
@@ -0,0 +1,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.
"""
@@ -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",
]
@@ -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
@@ -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
@@ -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)
@@ -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
@@ -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 []
@@ -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()
@@ -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()
@@ -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
@@ -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,
),
],
},
)
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
),
],
},
)
@@ -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
@@ -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