Files
2026-07-08 10:43:39 -04:00

741 lines
30 KiB
Python

"""Heating application entry point and DI container for Intelligent Heating Pilot."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
from .application.heating_cycle_lifecycle_manager import HeatingCycleLifecycleManager
from .application.heating_cycle_lifecycle_manager_factory import (
HeatingCycleLifecycleManagerFactory,
)
from .application.lhs_lifecycle_manager import LhsLifecycleManager
from .application.lhs_lifecycle_manager_factory import LhsLifecycleManagerFactory
from .application.orchestrator import HeatingOrchestrator
from .application.use_cases import (
CalculateAnticipationUseCase,
CheckOvershootRiskUseCase,
ControlPreheatingUseCase,
ScheduleAnticipationActionUseCase,
UpdateCacheDataUseCase,
)
from .const import CONF_IHP_ENABLED, DECISION_MODE_SIMPLE, DOMAIN, EVENT_DEAD_TIME_UPDATED
from .domain.interfaces.device_config_reader_interface import DeviceConfig
from .domain.services import (
ContextualLHSCalculatorService,
DeadTimeCalculationService,
GlobalLHSCalculatorService,
HeatingCycleService,
PredictionService,
)
from .infrastructure.adapters import (
HAClimateCommander,
HAClimateDataReader,
HAContextReader,
HAEnvironmentReader,
HAHeatingCycleStorage,
HALhsStorage,
HASchedulerCommander,
HASchedulerReader,
HATimerScheduler,
)
from .infrastructure.event_bridge import HAEventBridge
_LOGGER = logging.getLogger(__name__)
class HeatingApplication:
"""Heating application entry point and DI container.
This class:
- Creates and wires adapters
- Creates application service
- Setups event bridge
- Exposes data for sensors (via application service)
NO business logic - pure dependency injection and lifecycle management.
"""
def __init__(
self,
hass: HomeAssistant,
device_config: DeviceConfig,
) -> None:
"""Initialize the coordinator with dependency injection.
Args:
hass: Home Assistant instance
device_config: Complete device configuration (injected, not read from config_entry)
NOTE: config_entry is NOT passed here. This respects DDD principles:
- The domain/application layer does NOT depend on HA infrastructure (ConfigEntry)
- All configuration comes from the value object (DeviceConfig)
- For entry_id-based operations, use device_config.device_id
"""
_LOGGER.debug("Initializing HeatingApplication with injected DeviceConfig")
self.hass = hass
self._device_id = device_config.device_id # Store entry ID for adapter creation
# Store immutable device configuration
self._device_config = device_config
# Extract configuration from DeviceConfig (NOT from config_entry!)
# This is the key change: we read from the injected value object
self._vtherm_id = device_config.vtherm_entity_id
self._scheduler_ids = device_config.scheduler_entities
self._humidity_in_id = device_config.humidity_in_entity_id
self._humidity_out_id = device_config.humidity_out_entity_id
self._temperature_out_id = device_config.temperature_out_entity_id
self._cloud_cover_id = device_config.cloud_cover_entity_id
self._data_retention_days = device_config.lhs_retention_days
self._decision_mode = DECISION_MODE_SIMPLE
# Heating cycle detection parameters
self._temp_delta_threshold = device_config.temp_delta_threshold
self._cycle_split_duration_minutes = device_config.cycle_split_duration_minutes
self._min_cycle_duration_minutes = device_config.min_cycle_duration_minutes
self._max_cycle_duration_minutes = device_config.max_cycle_duration_minutes
self._dead_time_minutes = device_config.dead_time_minutes
self._auto_learning = device_config.auto_learning
self._safety_shutoff_grace_minutes = device_config.safety_shutoff_grace_minutes
# IHP enabled state
self._ihp_enabled = device_config.ihp_enabled
self._last_known_dead_time: float | None = None
# Infrastructure adapters
self._lhs_storage: HALhsStorage | None = None
self._cycle_storage: HAHeatingCycleStorage | None = None
self._scheduler_reader: HASchedulerReader | None = None
self._scheduler_commander: HASchedulerCommander | None = None
self._climate_commander: HAClimateCommander | None = None
self._environment_reader: HAEnvironmentReader | None = None
self._context_reader: HAContextReader | None = None
self._climate_data_reader: HAClimateDataReader | None = None
self._timer_scheduler: HATimerScheduler | None = None
# Orchestrator (coordinates use cases)
self._orchestrator: Any | None = None
# Lifecycle managers
self._heating_cycle_manager: HeatingCycleLifecycleManager | None = None
self._lhs_manager: LhsLifecycleManager | None = None
# Event bridge
self._event_bridge: HAEventBridge | None = None
# Cached data for sensors (refreshed by application service)
self._last_anticipation_data: dict[str, Any] | None = None
self._lhs_cache: float = 2.0 # Default global LHS
self._contextual_lhs_cache: dict[int, float] = {} # Contextual LHS by hour (0-23)
# Config entry for options updates (set later via setup_config_entry_access)
self._config_entry: ConfigEntry | None = None
self._options_snapshot: dict[str, Any] | None = None
async def async_load(self) -> None:
"""Load and initialize all components."""
# Create infrastructure adapters
self._lhs_storage = HALhsStorage(
self.hass, self._device_id, retention_days=self._data_retention_days
)
# Create cycle cache for incremental cycle extraction
self._cycle_storage = HAHeatingCycleStorage(
self.hass, self._device_id, retention_days=self._data_retention_days
)
# Import recorder queue for HAClimateDataReader
from .infrastructure.recorder_queue import get_recorder_queue
# Create climate data reader first so it can be injected into scheduler_reader
self._climate_data_reader = HAClimateDataReader(
self.hass, get_recorder_queue(self.hass), self._vtherm_id
)
self._scheduler_reader = HASchedulerReader(
self.hass,
self._scheduler_ids,
vtherm_entity_id=self._vtherm_id,
climate_reader=self._climate_data_reader,
)
self._scheduler_commander = HASchedulerCommander(self.hass)
self._climate_commander = HAClimateCommander(self.hass, self._vtherm_id)
self._environment_reader = HAEnvironmentReader(
self.hass,
self._vtherm_id,
outdoor_temp_entity_id=self._temperature_out_id,
humidity_in_entity_id=self._humidity_in_id,
humidity_out_entity_id=self._humidity_out_id,
cloud_cover_entity_id=self._cloud_cover_id,
)
self._context_reader = HAContextReader(
self.hass,
outdoor_temp_entity_id=self._temperature_out_id,
humidity_in_entity_id=self._humidity_in_id,
humidity_out_entity_id=self._humidity_out_id,
cloud_cover_entity_id=self._cloud_cover_id,
)
# Create timer scheduler adapter
self._timer_scheduler = HATimerScheduler(self.hass)
# Create domain services (they're stateless, can be created once)
heating_cycle_service = HeatingCycleService(
temp_delta_threshold=self._temp_delta_threshold,
cycle_split_duration_minutes=self._cycle_split_duration_minutes,
min_cycle_duration_minutes=self._min_cycle_duration_minutes,
max_cycle_duration_minutes=self._max_cycle_duration_minutes,
safety_shutoff_grace_minutes=self._safety_shutoff_grace_minutes,
)
global_lhs_calculator = GlobalLHSCalculatorService()
contextual_lhs_calculator = ContextualLHSCalculatorService()
# Create LHS lifecycle manager FIRST (required by heating_cycle_manager)
self._lhs_manager = LhsLifecycleManagerFactory.create(
model_storage=self._lhs_storage,
global_lhs_calculator=global_lhs_calculator,
contextual_lhs_calculator=contextual_lhs_calculator,
timer_scheduler=self._timer_scheduler,
)
# Create heating cycle lifecycle manager (after LHS manager is available)
self._heating_cycle_manager = HeatingCycleLifecycleManagerFactory.create(
hass=self.hass,
device_config=self._device_config,
heating_cycle_service=heating_cycle_service,
cycle_cache=self._cycle_storage,
timer_scheduler=self._timer_scheduler,
model_storage=self._lhs_storage,
lhs_lifecycle_manager=self._lhs_manager,
dead_time_updated_callback=self._fire_dead_time_updated_event,
on_extraction_complete_callback=self._on_extraction_complete,
)
# Create use cases for orchestrator
_LOGGER.debug("Creating use cases for orchestrator")
# Create services directly (they're simple domain services, not state-dependent)
prediction_service = PredictionService()
dead_time_calculator = DeadTimeCalculationService()
calculate_anticipation = CalculateAnticipationUseCase(
scheduler_reader=self._scheduler_reader,
environment_reader=self._environment_reader,
climate_data_reader=self._climate_data_reader,
heating_cycle_manager=self._heating_cycle_manager,
lhs_lifecycle_manager=self._lhs_manager,
prediction_service=prediction_service,
dead_time_calculator=dead_time_calculator,
auto_learning=self._auto_learning,
default_dead_time_minutes=self._dead_time_minutes,
lhs_storage=self._lhs_storage,
)
control_preheating = ControlPreheatingUseCase(
scheduler_commander=self._scheduler_commander,
)
schedule_anticipation_action = ScheduleAnticipationActionUseCase(
scheduler_reader=self._scheduler_reader,
scheduler_commander=self._scheduler_commander,
timer_scheduler=self._timer_scheduler,
control_preheating_use_case=control_preheating, # Delegate state management
anticipation_recalc_tolerance_minutes=self._device_config.anticipation_recalc_tolerance_minutes,
)
check_overshoot_risk = CheckOvershootRiskUseCase(
scheduler_reader=self._scheduler_reader,
environment_reader=self._environment_reader,
climate_data_reader=self._climate_data_reader,
control_preheating=control_preheating,
)
update_cache = UpdateCacheDataUseCase(
cycle_storage=self._cycle_storage,
lhs_storage=self._lhs_storage,
lhs_lifecycle_manager=self._lhs_manager,
)
# Create orchestrator
self._orchestrator = HeatingOrchestrator(
calculate_anticipation=calculate_anticipation,
control_preheating=control_preheating,
schedule_anticipation_action=schedule_anticipation_action,
check_overshoot_risk=check_overshoot_risk,
update_cache=update_cache,
)
_LOGGER.debug("Orchestrator created successfully")
# Create event bridge
monitored_entities = []
if self._humidity_in_id:
monitored_entities.append(self._humidity_in_id)
if self._humidity_out_id:
monitored_entities.append(self._humidity_out_id)
if self._cloud_cover_id:
monitored_entities.append(self._cloud_cover_id)
self._event_bridge = HAEventBridge(
self.hass,
self._orchestrator,
self._vtherm_id,
self._scheduler_ids,
monitored_entities,
entry_id=self._device_id,
get_ihp_enabled_func=self.is_ihp_enabled,
)
# Load initial data
if self._lhs_manager:
self._lhs_cache = await self._lhs_manager.get_global_lhs()
# Also load contextual LHS cache for all hours
await self._load_contextual_lhs_cache()
# NOTE: Cycle extraction is deferred to async_initialize_cycle_extraction()
# which is called after HA fully started (EVENT_HOMEASSISTANT_STARTED)
# to ensure VTherm entity is available before querying its history.
# See __init__.py async_setup_entry for the deferred initialization logic.
_LOGGER.info(
"[%s] Coordinator initialized (VTherm: %s, Schedulers: %d)",
self._device_id,
self._vtherm_id,
len(self._scheduler_ids),
)
# NOTE: Initial update is now deferred to async_setup_entry to avoid blocking
# the config flow during device creation (prevents HA watchdog restart).
# See async_setup_entry for the deferred update logic.
async def _load_contextual_lhs_cache(self) -> None:
"""Load contextual LHS values for all hours from storage.
This populates the synchronous cache with contextual LHS values
that were previously calculated and stored.
"""
if not self._lhs_manager:
return
try:
# Load contextual LHS for all 24 hours
for hour in range(24):
contextual_lhs = await self._lhs_manager.get_contextual_lhs(
target_time=dt_util.now().replace(hour=hour, minute=0, second=0, microsecond=0),
cycles=[], # Empty cycles - will use stored cache
)
if contextual_lhs is not None and contextual_lhs != 2.0: # 2.0 is default value
self._contextual_lhs_cache[hour] = contextual_lhs
_LOGGER.debug(
"Loaded contextual LHS from storage: hour %d = %.2f °C/h",
hour,
contextual_lhs,
)
except Exception as exc: # noqa: BLE001
_LOGGER.warning("Failed to load contextual LHS cache: %s", exc, exc_info=True)
def setup_config_entry_access(self, config_entry: ConfigEntry) -> None:
"""Set config_entry reference for options updates.
This is called after async_load to enable set_ihp_enabled to persist
state changes back to the config entry.
Args:
config_entry: The config entry for this device
"""
self._config_entry = config_entry
def setup_listeners(self) -> None:
"""Setup event listeners via event bridge."""
if self._event_bridge:
self._event_bridge.setup_listeners()
async def async_initialize_cycle_extraction(self) -> None:
"""Initialize cycle extraction and schedule 24h periodic refresh.
This method MUST be called after EVENT_HOMEASSISTANT_STARTED to ensure
the VTherm entity is available before querying its history.
This method:
- Verifies VTherm entity is available
- Performs initial extraction over retention window
- Schedules 24h periodic refresh timer
- Logs initialization with retention days
"""
_LOGGER.debug("Entering async_initialize_cycle_extraction for device=%s", self._device_id)
try:
# Check if cycle extraction is enabled
if self._data_retention_days <= 0:
_LOGGER.debug(
"Cycle extraction disabled (history_lookback_days=%d)",
self._data_retention_days,
)
return
# Sanity checks
if not self._device_config:
_LOGGER.warning("Cannot initialize cycle refresh: device_config not available")
return
if not self._heating_cycle_manager:
_LOGGER.warning("Cannot initialize cycle refresh: manager not available")
return
# Verify VTherm entity exists before attempting to query its history
vtherm_state = self.hass.states.get(self._vtherm_id)
if vtherm_state is None:
_LOGGER.error(
"Cannot initialize cycle extraction: VTherm entity %s not found. "
"Ensure the climate entity is loaded before IHP starts.",
self._vtherm_id,
)
return
_LOGGER.debug(
"Initializing cycle extraction: device_id=%s, retention=%d days",
self._device_config.device_id,
self._data_retention_days,
)
# Trigger cache refresh: schedules 24h timer and extracts missing days asynchronously
await self._heating_cycle_manager.refresh_heating_cycle_cache()
_LOGGER.info(
"Cycle extraction initialized: device=%s, retention=%d days (async)",
self._device_config.device_id,
self._data_retention_days,
)
_LOGGER.debug(
"Exiting async_initialize_cycle_extraction for device=%s", self._device_id
)
except Exception as err: # pylint: disable=broad-except
_LOGGER.error(
"Failed to initialize cycle extraction for %s: %s",
self._vtherm_id,
err,
exc_info=True,
)
async def _update_global_lhs_from_cycles(self, cycles: list) -> None:
"""Update global LHS in model storage from extracted cycles.
This method calculates the global learned heating slope (LHS) from
all extracted cycles and persists it to model storage for later use
as a fallback when no contextual data is available.
Args:
cycles: List of HeatingCycle objects
"""
if not cycles or not self._lhs_manager:
return
try:
global_lhs = await self._lhs_manager.update_global_lhs_from_cycles(cycles)
# Refresh cache so sensors reflect updated value
await self.refresh_caches()
_LOGGER.info(
"Updated global LHS from %d cycles: %.2f°C/h",
len(cycles),
global_lhs,
)
except Exception as exc: # pylint: disable=broad-except
_LOGGER.warning(
"Failed to update global LHS from cycles: %s",
exc,
exc_info=True,
)
async def async_notify_retention_change(self, new_retention_days: int) -> None:
"""Handle configuration change for retention/history lookback.
Called from config flow when history_lookback_days changes.
Args:
new_retention_days: New retention window in days
"""
_LOGGER.debug(
"Retention change notification: old=%d, new=%d",
self._data_retention_days,
new_retention_days,
)
try:
# Update stored retention days
self._data_retention_days = new_retention_days
# Delegate to use case for reconfiguration handling
if self._heating_cycle_manager:
await self._heating_cycle_manager.on_retention_change(new_retention_days)
else:
_LOGGER.debug("No active cycle manager; cannot propagate retention change")
# Note: LhsLifecycleManager receives cycles from HeatingCycleLifecycleManager
# No need to call on_retention_change directly since cascade handles it
except Exception as err: # pylint: disable=broad-except
_LOGGER.warning("Error handling retention change: %s", err, exc_info=True)
async def async_update(self) -> None:
"""Trigger anticipation calculation and cache results for sensors."""
if not self._orchestrator:
return
# Calculate and schedule via orchestrator (passing IHP enabled state)
anticipation_data = await self._orchestrator.calculate_and_schedule_anticipation(
ihp_enabled=self._ihp_enabled
)
# Cache for sensors
self._last_anticipation_data = anticipation_data
# Persist and notify dead_time when freshly calculated and different from last known value
new_dead_time = anticipation_data.get("dead_time") if anticipation_data else None
if new_dead_time is not None and new_dead_time != self._last_known_dead_time:
self._last_known_dead_time = new_dead_time
if self._lhs_storage:
await self._lhs_storage.set_learned_dead_time(new_dead_time)
self._fire_dead_time_updated_event(new_dead_time)
# Refresh LHS caches
if self._lhs_manager:
self._lhs_cache = await self._lhs_manager.get_global_lhs()
# Reload contextual LHS cache for the scheduled hour only (optimization)
if anticipation_data and anticipation_data.get("next_schedule_time") is not None:
next_schedule_time = anticipation_data.get("next_schedule_time")
if next_schedule_time:
scheduled_hour = next_schedule_time.hour
try:
contextual_lhs = await self._lhs_manager.get_contextual_lhs(
target_time=next_schedule_time,
cycles=[], # Empty cycles - will use stored cache
)
if contextual_lhs is not None and contextual_lhs != 2.0:
self._contextual_lhs_cache[scheduled_hour] = contextual_lhs
_LOGGER.debug(
"Updated contextual LHS cache: hour %d = %.2f °C/h",
scheduled_hour,
contextual_lhs,
)
except Exception as exc: # noqa: BLE001
_LOGGER.debug("Failed to update contextual LHS cache: %s", exc)
# Fire event for sensors
if anticipation_data:
# Always publish complete structure with None values for missing data
anticipated_start = anticipation_data.get("anticipated_start_time")
next_schedule = anticipation_data.get("next_schedule_time")
event_data = {
"entry_id": self._device_id,
"anticipated_start_time": anticipated_start.isoformat()
if anticipated_start
else None,
"next_schedule_time": next_schedule.isoformat() if next_schedule else None,
"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", ""),
}
self.hass.bus.async_fire(
f"{DOMAIN}_anticipation_calculated",
event_data,
)
def _on_extraction_complete(self) -> None:
"""Trigger sensor recalculation after background extraction completes."""
if self._event_bridge is not None:
self._event_bridge._request_recalculate()
def _fire_dead_time_updated_event(self, learned_dead_time: float) -> None:
"""Publish an event when learned dead time is persisted."""
_LOGGER.debug(
"Publishing dead time update for entry_id=%s: %.1f minutes",
self._device_id,
learned_dead_time,
)
self.hass.bus.async_fire(
EVENT_DEAD_TIME_UPDATED,
{
"entry_id": self._device_id,
"learned_dead_time": learned_dead_time,
},
)
async def refresh_caches(self) -> None:
"""Refresh cached LHS value used by sensors.
Called by sensors after an anticipation event to keep LHS in sync
when the event publication bypasses the coordinator's async_update path.
"""
if self._lhs_manager is None:
return
try:
# Reload cached global LHS value
self._lhs_cache = await self._lhs_manager.get_global_lhs()
except Exception: # noqa: BLE001
_LOGGER.debug("Failed to refresh LHS cache", exc_info=True)
# Sensor accessors (synchronous for sensor entities)
def get_learned_heating_slope(self) -> float:
"""Get cached global LHS for sensors."""
return self._lhs_cache
def get_contextual_learned_heating_slope(self, hour: int) -> float | None:
"""Get contextual LHS for a specific hour (from cache).
This returns the contextual LHS from the synchronously-accessible cache.
Falls back to global LHS if no contextual data is available for the hour.
Args:
hour: Hour of day (0-23)
Returns:
Contextual LHS for the hour, or global LHS as fallback
"""
if hour < 0 or hour > 23:
return self.get_learned_heating_slope()
try:
# Check contextual LHS cache first
if hour in self._contextual_lhs_cache:
contextual_value = self._contextual_lhs_cache[hour]
# Return None only if truly no data (None in cache means no contextual data for this hour)
if contextual_value is None:
return self.get_learned_heating_slope() # Fallback to global
return contextual_value
# Not in cache, fallback to global LHS
return self.get_learned_heating_slope()
except Exception: # noqa: BLE001
_LOGGER.debug("Failed to get LHS for hour %d", hour, exc_info=True)
return self.get_learned_heating_slope()
def is_ihp_enabled(self) -> bool:
"""Get IHP enabled state."""
return self._ihp_enabled
async def set_ihp_enabled(self, enabled: bool) -> None:
"""Set IHP enabled state.
Args:
enabled: True to enable IHP preheating, False to disable
"""
_LOGGER.info("Setting IHP enabled state to: %s", enabled)
self._ihp_enabled = enabled
# Persist state to config entry if available
# Note: async_update_options() listener handles the state application (cancel or reschedule)
if self._config_entry:
new_options = dict(self._config_entry.options) if self._config_entry.options else {}
new_options[CONF_IHP_ENABLED] = enabled
self.hass.config_entries.async_update_entry(self._config_entry, options=new_options)
def get_device_id(self) -> str:
"""Get device identifier (config entry ID)."""
return self._device_id
def get_vtherm_entity(self) -> str:
"""Get VTherm entity ID."""
return self._vtherm_id
def get_scheduler_entities(self) -> list[str]:
"""Get scheduler entity IDs."""
return self._scheduler_ids[:]
def is_auto_learning_enabled(self) -> bool:
"""Check if auto-learning is enabled.
Returns:
True if auto_learning is enabled in configuration
"""
return self._auto_learning
async def get_current_dead_time(self) -> float | None:
"""Get the current learned dead time value.
Returns the dead time value persisted from auto-learning.
Returns:
Dead time in minutes, or None if not yet learned
"""
_LOGGER.debug("Entering get_current_dead_time")
if not self._lhs_storage:
_LOGGER.debug("No LHS storage available")
return None
learned_dead_time = await self._lhs_storage.get_learned_dead_time()
_LOGGER.debug("Exiting get_current_dead_time: result=%s", learned_dead_time)
return learned_dead_time
async def get_effective_dead_time(self) -> float:
"""Get the effective dead time for heating predictions.
Returns either the auto-learned value or the user-configured value
depending on the auto_learning configuration flag.
Returns:
Dead time in minutes (configured value or learned value)
"""
_LOGGER.debug(
"Entering get_effective_dead_time: auto_learning=%s, configured=%.1f",
self._auto_learning,
self._dead_time_minutes,
)
if self._auto_learning:
# Use learned value if available, fall back to configured
learned_dead_time = await self.get_current_dead_time()
if learned_dead_time is not None:
_LOGGER.debug(
"Exiting get_effective_dead_time: using learned value=%.1f",
learned_dead_time,
)
return learned_dead_time
# Fall back to configured value (either auto_learning=False or no learned value yet)
_LOGGER.debug(
"Exiting get_effective_dead_time: using configured value=%.1f",
self._dead_time_minutes,
)
return self._dead_time_minutes
async def async_cleanup(self) -> None:
"""Cleanup coordinator resources.
Cancels timers, stops cycle extraction, and cleanup event bridge.
Called when coordinator is being unloaded.
"""
_LOGGER.debug("Cleaning up coordinator: device_id=%s", self._device_config.device_id)
try:
# Clean up event bridge first (cancels pending tasks)
if self._event_bridge:
await self._event_bridge.async_cleanup()
_LOGGER.debug("Event bridge cleanup completed")
# Stop cycle extraction and cancel timer
if self._heating_cycle_manager:
await self._heating_cycle_manager.cancel()
_LOGGER.debug("Cycle extraction cancelled")
except Exception as err: # pylint: disable=broad-except
_LOGGER.warning("Error during cleanup: %s", err, exc_info=True)