Files
HomeAssistantVS/custom_components/intelligent_heating_pilot/__init__.py
T
2026-07-08 10:43:39 -04:00

447 lines
18 KiB
Python

"""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_<device_name>_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())