New apps Added
This commit is contained in:
@@ -0,0 +1,740 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user