"""The Intelligent Heating Pilot integration - DDD Architecture. This module sets up the integration using a clean DDD architecture: - Domain: Pure business logic (entities, value objects, services) - Infrastructure: HA adapters (readers, commanders, event bridge) - Application: Use case orchestration The coordinator here is reduced to a thin setup/teardown manager. """ from __future__ import annotations import hashlib import logging import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, Platform from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv from homeassistant.helpers import entity_registry as er from homeassistant.helpers.event import async_track_point_in_time from homeassistant.util import dt as dt_util from .const import CONF_IHP_ENABLED, DOMAIN, SERVICE_CALCULATE_ANTICIPATED_START_TIME from .heating_application import HeatingApplication from .utils.config_helpers import as_bool from .view import async_register_http_views _LOGGER = logging.getLogger(__name__) PLATFORMS: list[str] = [Platform.SENSOR, Platform.SWITCH] _STARTUP_UPDATE_BASE_DELAY_SECONDS = 5 _STARTUP_UPDATE_JITTER_SECONDS = 55 _STARTUP_EXTRACTION_BASE_DELAY_SECONDS = 120 _STARTUP_EXTRACTION_JITTER_SECONDS = 600 _LATE_UPDATE_BASE_DELAY_SECONDS = 30 _LATE_UPDATE_JITTER_SECONDS = 90 def _stable_jitter_seconds(seed: str, spread_seconds: int) -> int: """Return deterministic jitter in [0, spread_seconds] for a given seed.""" if spread_seconds <= 0: return 0 digest = hashlib.blake2s(seed.encode("utf-8"), digest_size=4).digest() value = int.from_bytes(digest, "big") return value % (spread_seconds + 1) async def async_setup(hass: HomeAssistant, config: dict) -> bool: """Set up the Intelligent Heating Pilot component.""" hass.data.setdefault(DOMAIN, {}) # Initialize shared recorder access queue (FIFO) for all IHP devices. # This must be created before any device entry setup to ensure all adapters # share the same queue and recorder access is serialized across instances. from .infrastructure.recorder_queue import get_recorder_queue get_recorder_queue(hass) # Store hass in http app context for REST API views to access it hass.http.app["hass"] = hass # Register HTTP views once at the integration level (not per device) await async_register_http_views(hass) return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Intelligent Heating Pilot from a config entry. REFACTORED: Now uses HADeviceConfigReader to extract configuration and injects DeviceConfig into the Coordinator instead of passing config_entry directly. Args: hass: Home Assistant instance entry: Config entry to set up Returns: True if setup succeeded """ _LOGGER.debug("Setting up IHP config entry: %s", entry.entry_id) hass.data.setdefault(DOMAIN, {}) # ============================================================================ # REFACTORING: Create device config reader and extract configuration # ============================================================================ # Instead of passing config_entry to the Coordinator, we: # 1. Create HADeviceConfigReader (infrastructure adapter) # 2. Read DeviceConfig from config_entry (data + options) # 3. Inject DeviceConfig into Coordinator (dependency injection) from .infrastructure.adapters.device_config_reader import HADeviceConfigReader # Create device configuration reader (infrastructure layer) device_config_reader = HADeviceConfigReader(hass, entry) # Read complete device configuration (applies "options override data" logic) try: device_config = await device_config_reader.get_device_config(entry.entry_id) except ValueError as err: _LOGGER.error("Failed to load device configuration: %s", err) return False _LOGGER.debug( "Loaded device configuration: vtherm=%s, schedulers=%d, ihp_enabled=%s", device_config.vtherm_entity_id, len(device_config.scheduler_entities), device_config.ihp_enabled, ) # ============================================================================ # Create and load coordinator with injected configuration # ============================================================================ # NEW: Coordinator ONLY receives device_config (pure DDD) # config_entry is passed later via setup_config_entry_access for options updates coordinator = HeatingApplication(hass, device_config) coordinator.setup_config_entry_access(entry) coordinator._options_snapshot = dict(entry.options) await coordinator.async_load() # Store coordinator hass.data[DOMAIN][entry.entry_id] = coordinator # Setup event listeners coordinator.setup_listeners() def _schedule_startup_work() -> None: """Schedule startup work with deterministic per-entry staggering. This avoids synchronized bursts when many IHP entries start together. """ extraction_delay = _STARTUP_EXTRACTION_BASE_DELAY_SECONDS + _stable_jitter_seconds( f"{entry.entry_id}:extract", _STARTUP_EXTRACTION_JITTER_SECONDS ) update_delay = _STARTUP_UPDATE_BASE_DELAY_SECONDS + _stable_jitter_seconds( f"{entry.entry_id}:update", _STARTUP_UPDATE_JITTER_SECONDS ) @callback def _run_extraction(_now): hass.async_create_task(coordinator.async_initialize_cycle_extraction()) @callback def _run_update(_now): hass.async_create_task(coordinator.async_update()) entry.async_on_unload( async_track_point_in_time( hass, _run_update, dt_util.now() + dt_util.dt.timedelta(seconds=update_delay), ) ) entry.async_on_unload( async_track_point_in_time( hass, _run_extraction, dt_util.now() + dt_util.dt.timedelta(seconds=extraction_delay), ) ) _LOGGER.info( "[%s] Startup workload staggered: update in %ss, extraction in %ss", entry.entry_id, update_delay, extraction_delay, ) # Wait for HA to be fully started before initializing cycle extraction and first update # This ensures all entities (especially VTherm and scheduler entities) are available @callback def _ha_started(_event): _LOGGER.info( "[%s] HA started, initializing cycle extraction and triggering updates", entry.entry_id ) _schedule_startup_work() # Schedule initial tasks asynchronously to avoid blocking config flow # This prevents HA watchdog restart during device creation with scheduler if hass.is_running: _LOGGER.debug("[%s] HA already running, scheduling staggered startup work", entry.entry_id) _schedule_startup_work() else: _LOGGER.debug("[%s] Waiting for HA start event before initialization", entry.entry_id) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _ha_started) # Small delayed update for late attribute population @callback def _delayed_update(_now): _LOGGER.debug("[%s] Delayed update", entry.entry_id) hass.async_create_task(coordinator.async_update()) late_update_delay = _LATE_UPDATE_BASE_DELAY_SECONDS + _stable_jitter_seconds( f"{entry.entry_id}:late_update", _LATE_UPDATE_JITTER_SECONDS ) entry.async_on_unload( async_track_point_in_time( hass, _delayed_update, dt_util.now() + dt_util.dt.timedelta(seconds=late_update_delay), ) ) # Register options update listener entry.async_on_unload(entry.add_update_listener(async_update_options)) # Register services (once per integration, not per device) def _resolve_coordinator(entity_id: str) -> HeatingApplication: """Resolve an IHP coordinator from an entity ID. Looks up the entity in the entity registry to find its config entry, then returns the corresponding HeatingApplication coordinator. Raises: ServiceValidationError: If the entity or its coordinator cannot be found, so callers receive immediate actionable feedback in the HA UI. """ entity_reg = er.async_get(hass) entity_entry = entity_reg.async_get(entity_id) if not entity_entry: raise ServiceValidationError( f"Entity '{entity_id}' was not found in the Home Assistant entity registry. " "Please provide a valid IHP sensor entity ID, for example: " "sensor.intelligent_heating_pilot__anticipated_start_time." ) coord = hass.data[DOMAIN].get(entity_entry.config_entry_id) if not coord or not isinstance(coord, HeatingApplication): raise ServiceValidationError( f"No IHP device found for entity '{entity_id}' " f"(config_entry_id={entity_entry.config_entry_id}). " "Make sure the IHP integration is properly configured." ) return coord if not hass.services.has_service(DOMAIN, "reset_learning"): async def handle_reset_learning(call: ServiceCall) -> None: """Handle reset_learning service. Resolves the target IHP device from the entity_id provided in the service call, then delegates to that device's orchestrator. This allows callers to choose which device to reset when multiple IHP config entries exist. """ _LOGGER.debug("Entering handle_reset_learning") coord = _resolve_coordinator(call.data["entity_id"]) await coord._orchestrator.reset_all_learning_data( device_id=coord.get_device_id() ) _LOGGER.info( "reset_learning: learning data cleared for device %s", coord.get_device_id() ) reset_learning_schema = vol.Schema( { vol.Required("entity_id"): cv.entity_id, } ) hass.services.async_register( DOMAIN, "reset_learning", handle_reset_learning, schema=reset_learning_schema, ) if not hass.services.has_service(DOMAIN, SERVICE_CALCULATE_ANTICIPATED_START_TIME): async def handle_calculate_anticipated_start_time(call: ServiceCall): """Handle calculate_anticipated_start_time service. This service calculates the anticipated start time for a given IHP device to reach a target temperature at a specified time. Delegates to orchestrator's calculate_anticipation_only() - no business logic here. """ _LOGGER.debug("Entering handle_calculate_anticipated_start_time") # Extract service call parameters entity_id = call.data["entity_id"] target_time = call.data["target_time"] target_temp = call.data.get("target_temp") # Ensure target_time has timezone if target_time.tzinfo is None: target_time = dt_util.as_local(target_time) # Resolve the coordinator for this device device_coordinator = _resolve_coordinator(entity_id) # Get target_temp from service call or VTherm if target_temp is None: # Try to get target temp from VTherm using public accessor vtherm_entity_id = device_coordinator.get_vtherm_entity() if vtherm_entity_id: vtherm_state = hass.states.get(vtherm_entity_id) if vtherm_state: target_temp = vtherm_state.attributes.get("temperature") if target_temp is not None: target_temp = float(target_temp) # Delegate to orchestrator (pure routing - no business logic) anticipation_data = await device_coordinator._orchestrator.calculate_anticipation_only( target_time=target_time, target_temp=target_temp, ) # Extract fields from anticipation_data (which is already structured) anticipated_start_time = anticipation_data.get("anticipated_start_time") response_target_time = anticipation_data.get("next_schedule_time") or target_time response_target_temp = anticipation_data.get("next_target_temperature") if response_target_temp is None: response_target_temp = target_temp if anticipated_start_time is None: _LOGGER.warning("Could not calculate anticipated start time (insufficient data)") # Return structure with None values return { "anticipated_start_time": None, "target_time": response_target_time.isoformat(), "target_temp": response_target_temp, "current_temp": anticipation_data.get("current_temp"), "estimated_duration_minutes": None, "learned_heating_slope": anticipation_data.get("learned_heating_slope"), "confidence_level": None, } _LOGGER.info( "Service calculate_anticipated_start_time: " "anticipated_start=%s, target_time=%s, target_temp=%.1f°C, " "current_temp=%.1f°C, LHS=%.2f°C/h, confidence=%.2f", anticipated_start_time.isoformat(), response_target_time.isoformat(), response_target_temp or 0.0, anticipation_data.get("current_temp") or 0.0, anticipation_data.get("learned_heating_slope") or 0.0, anticipation_data.get("confidence_level") or 0.0, ) # Return the result as service response data return { "anticipated_start_time": anticipated_start_time.isoformat(), "target_time": response_target_time.isoformat(), "target_temp": response_target_temp, "current_temp": anticipation_data.get("current_temp"), "estimated_duration_minutes": anticipation_data.get("estimated_duration_minutes"), "learned_heating_slope": anticipation_data.get("learned_heating_slope"), "confidence_level": anticipation_data.get("confidence_level"), } # Define service schema calculate_anticipated_start_time_schema = vol.Schema( { vol.Required("entity_id"): cv.entity_id, vol.Required("target_time"): cv.datetime, vol.Optional("target_temp"): vol.Coerce(float), } ) hass.services.async_register( DOMAIN, SERVICE_CALCULATE_ANTICIPATED_START_TIME, handle_calculate_anticipated_start_time, schema=calculate_anticipated_start_time_schema, supports_response=SupportsResponse.ONLY, ) # Forward setup to platforms await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" coordinator = hass.data[DOMAIN].get(entry.entry_id) if coordinator: await coordinator.async_cleanup() if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) return bool(unload_ok) async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: """Update options and reload integration.""" from .const import CONF_DATA_RETENTION_DAYS coordinator: HeatingApplication | None = hass.data[DOMAIN].get(entry.entry_id) previous_options = ( dict(getattr(coordinator, "_options_snapshot", {}) or {}) if coordinator else {} ) previous_no_toggle = {k: v for k, v in previous_options.items() if k != CONF_IHP_ENABLED} current_no_toggle = {k: v for k, v in entry.options.items() if k != CONF_IHP_ENABLED} ihp_enabled = as_bool(entry.options.get(CONF_IHP_ENABLED), default=True) # Check if only retention days changed (reconfiguration of cycle refresh) retention_only_changed = ( coordinator is not None and previous_no_toggle != current_no_toggle and len(set(previous_no_toggle.keys()) ^ set(current_no_toggle.keys())) == 1 and CONF_DATA_RETENTION_DAYS in (set(previous_no_toggle.keys()) ^ set(current_no_toggle.keys())) ) if retention_only_changed: # Handle retention change without reload new_retention_days = int(entry.options.get(CONF_DATA_RETENTION_DAYS, 10)) _LOGGER.info( "[%s] Data retention changed to %d days, updating cycle refresh", entry.entry_id, new_retention_days, ) if coordinator: coordinator._options_snapshot = dict(entry.options) coordinator._ihp_enabled = ihp_enabled await coordinator.async_notify_retention_change(new_retention_days) return # If only the ihp_enabled flag changed, skip full reload if coordinator and previous_no_toggle == current_no_toggle: _LOGGER.info("[%s] Options updated (ihp_enabled only), skipping reload", entry.entry_id) coordinator._options_snapshot = dict(entry.options) coordinator._ihp_enabled = ihp_enabled await coordinator.async_update() return _LOGGER.info("[%s] Options updated, reloading", entry.entry_id) if coordinator: coordinator._options_snapshot = dict(entry.options) await hass.config_entries.async_reload(entry.entry_id) # Schedule async update after reload (non-blocking) coordinator = hass.data[DOMAIN].get(entry.entry_id) if coordinator: hass.async_create_task(coordinator.async_update())