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,24 @@
"""Use cases for Intelligent Heating Pilot.
This package contains use cases that encapsulate single business operations.
Each use case is a focused, testable unit of business logic.
Use cases follow the Single Responsibility Principle and are composed by
the HeatingOrchestrator to implement complex workflows.
"""
from __future__ import annotations
from .calculate_anticipation_use_case import CalculateAnticipationUseCase
from .check_overshoot_risk_use_case import CheckOvershootRiskUseCase
from .control_preheating_use_case import ControlPreheatingUseCase
from .schedule_anticipation_action_use_case import ScheduleAnticipationActionUseCase
from .update_cache_data_use_case import UpdateCacheDataUseCase
__all__ = [
"CalculateAnticipationUseCase",
"CheckOvershootRiskUseCase",
"ControlPreheatingUseCase",
"ScheduleAnticipationActionUseCase",
"UpdateCacheDataUseCase",
]
@@ -0,0 +1,314 @@
"""Calculate Anticipation Data Use Case.
This use case calculates the anticipated start time for preheating
based on current conditions and learned heating slopes.
This is a PURE CALCULATION use case - it does NOT schedule or trigger preheating.
For scheduling, use SchedulePreheatingUseCase.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from datetime import datetime
from ...domain.interfaces import (
IClimateDataReader,
IEnvironmentReader,
ILhsStorage,
ISchedulerReader,
)
from ...domain.services import DeadTimeCalculationService, PredictionService
from ..heating_cycle_lifecycle_manager import HeatingCycleLifecycleManager
from ..lhs_lifecycle_manager import LhsLifecycleManager
_LOGGER = logging.getLogger(__name__)
class CalculateAnticipationUseCase:
"""Use case for calculating anticipation data (NO preheating scheduling).
This use case encapsulates the pure calculation logic for:
1. Reading the next scheduled timeslot (or using provided target_time)
2. Getting heating cycles for LHS calculation
3. Calculating the anticipated start time based on learned heating slope
4. Returning anticipation data for display/sensors
This does NOT schedule or trigger any preheating action.
"""
def __init__(
self,
scheduler_reader: ISchedulerReader | None,
environment_reader: IEnvironmentReader,
climate_data_reader: IClimateDataReader,
heating_cycle_manager: HeatingCycleLifecycleManager,
lhs_lifecycle_manager: LhsLifecycleManager,
prediction_service: PredictionService,
dead_time_calculator: DeadTimeCalculationService,
auto_learning: bool = True,
default_dead_time_minutes: float = 0.0,
lhs_storage: ILhsStorage | None = None,
) -> None:
"""Initialize the use case.
Args:
scheduler_reader: Reads scheduled timeslots (optional for API-only usage)
environment_reader: Reads current environment conditions
climate_data_reader: Unified reader for VTherm metadata, slope and heating state
heating_cycle_manager: Manages heating cycle lifecycle
lhs_lifecycle_manager: Manages learned heating slopes
prediction_service: Predicts heating time
dead_time_calculator: Calculates dead time from cycles
auto_learning: Whether auto-learning is enabled
default_dead_time_minutes: Default dead time when not learned
lhs_storage: Optional persistent storage for learned values. When provided
and auto_learning is True, the stored learned dead time is used as a
fallback before the configured default, so that the persisted value
is restored immediately after a Home Assistant restart.
"""
_LOGGER.debug("Initializing CalculateAnticipationUseCase")
self._scheduler_reader = scheduler_reader
self._environment_reader = environment_reader
self._climate_data_reader = climate_data_reader
self._heating_cycle_manager = heating_cycle_manager
self._lhs_manager = lhs_lifecycle_manager
self._prediction_service = prediction_service
self._dead_time_calculator = dead_time_calculator
self._auto_learning = auto_learning
self._default_dead_time_minutes = default_dead_time_minutes
self._lhs_storage = lhs_storage
async def calculate_anticipation_datas(
self,
target_time: datetime | None = None,
target_temp: float | None = None,
) -> dict:
"""Calculate anticipation data without scheduling preheating.
Args:
target_time: Target time for heating. If None, uses next scheduled timeslot.
target_temp: Target temperature. If None, uses value from timeslot.
Returns:
Dict with anticipation data. Returns structure with None values for fields
that cannot be calculated.
"""
_LOGGER.debug(
"Entering CalculateAnticipationUseCase.calculate_anticipation_datas(target_time=%s, target_temp=%s)",
target_time.isoformat() if target_time else "None",
target_temp,
)
# Import default constant for LHS validation
from ...domain.constants import DEFAULT_LEARNED_SLOPE, MINIMUM_REALISTIC_LHS
# Determine target time and temp
timeslot = None
scheduler_entity = None
timeslot_id = None
# Always get environment data (for minimal return structure)
environment = await self._environment_reader.get_current_environment()
current_temp = environment.indoor_temperature if environment else None
# Always get global LHS (for minimal return structure)
global_lhs = await self._lhs_manager.get_global_lhs()
# Validate global LHS: must be realistically positive (>= 0.5°C/h)
if global_lhs is None or global_lhs < MINIMUM_REALISTIC_LHS:
_LOGGER.warning(
"Invalid global LHS (%.4f°C/h < %.2f°C/h), using default (%.2f°C/h)",
global_lhs or 0,
MINIMUM_REALISTIC_LHS,
DEFAULT_LEARNED_SLOPE,
)
global_lhs = DEFAULT_LEARNED_SLOPE
if target_time is None:
# Use scheduler to get next timeslot
if self._scheduler_reader is None:
_LOGGER.debug("No scheduler reader configured and no target_time provided")
# Return minimal data structure
return self._create_data_structure(
current_temp=current_temp,
learned_heating_slope=global_lhs,
)
timeslot = await self._scheduler_reader.get_next_timeslot()
if not timeslot:
_LOGGER.debug("No scheduled timeslot found")
# Return minimal data structure
return self._create_data_structure(
current_temp=current_temp,
learned_heating_slope=global_lhs,
)
target_time = timeslot.target_time
target_temp = timeslot.target_temp
scheduler_entity = timeslot.scheduler_entity
timeslot_id = timeslot.timeslot_id
else:
# Using provided target_time (API/REST usage without scheduler)
if target_temp is None:
_LOGGER.warning("target_time provided but target_temp is None")
# Return minimal data structure
return self._create_data_structure(
current_temp=current_temp,
learned_heating_slope=global_lhs,
)
# Get remaining environment data
outdoor_temp = environment.outdoor_temp if environment else None
humidity = environment.indoor_humidity if environment else None
cloud_coverage = environment.cloud_coverage if environment else None
# Get device ID
vtherm_id = self._climate_data_reader.get_vtherm_entity_id()
# Get heating cycles for LHS calculation
heating_cycles = await self._heating_cycle_manager.get_cycles_for_target_time(
device_id=vtherm_id,
target_time=target_time,
)
# Get contextual LHS
lhs = await self._lhs_manager.get_contextual_lhs(
target_time=target_time,
cycles=heating_cycles,
)
# Validate LHS: must be realistically positive (>= 0.5°C/h)
if lhs is None or lhs < MINIMUM_REALISTIC_LHS:
_LOGGER.warning(
"Invalid contextual LHS (%.4f°C/h < %.2f°C/h), using default (%.2f°C/h)",
lhs or 0,
MINIMUM_REALISTIC_LHS,
DEFAULT_LEARNED_SLOPE,
)
lhs = DEFAULT_LEARNED_SLOPE
# Calculate effective dead_time
if self._auto_learning and heating_cycles:
avg_dead_time = self._dead_time_calculator.calculate_average_dead_time(heating_cycles)
if avg_dead_time is not None and avg_dead_time > 0:
dead_time = avg_dead_time
_LOGGER.info(
"Learned dead_time from %d cycles: %.1f minutes",
len(heating_cycles),
dead_time,
)
else:
dead_time = await self._get_persisted_dead_time_or_default()
elif self._auto_learning:
# No cycles available yet (e.g. immediately after restart before extraction).
# Fall back to the persisted learned dead time so the correct value is used
# before the first cycle extraction completes.
dead_time = await self._get_persisted_dead_time_or_default()
else:
dead_time = self._default_dead_time_minutes
# Calculate prediction - let prediction_service handle None values
prediction = self._prediction_service.predict_heating_time(
current_temp=current_temp, # Pass None if unavailable
target_temp=target_temp,
outdoor_temp=outdoor_temp,
humidity=humidity,
learned_slope=lhs,
target_time=target_time,
cloud_coverage=cloud_coverage,
dead_time_minutes=dead_time,
)
_LOGGER.info(
"Calculated anticipation: start at %s (%.1f min) for target %.1f°C at %s (LHS: %.2f°C/h)",
prediction.anticipated_start_time.isoformat(),
prediction.estimated_duration_minutes,
target_temp,
target_time.isoformat(),
prediction.learned_heating_slope,
)
result = self._create_data_structure(
anticipated_start_time=prediction.anticipated_start_time,
next_schedule_time=target_time,
next_target_temperature=target_temp,
anticipation_minutes=prediction.estimated_duration_minutes,
current_temp=current_temp,
learned_heating_slope=prediction.learned_heating_slope,
confidence_level=prediction.confidence_level,
timeslot_id=timeslot_id,
scheduler_entity=scheduler_entity,
dead_time=dead_time,
)
_LOGGER.debug("Exiting calculate_anticipation_datas() -> %s", "data")
return result
def _create_data_structure(
self,
anticipated_start_time: datetime | None = None,
next_schedule_time: datetime | None = None,
next_target_temperature: float | None = None,
anticipation_minutes: float | None = None,
current_temp: float | None = None,
learned_heating_slope: float | None = None,
confidence_level: float | None = None,
timeslot_id: str | None = None,
scheduler_entity: str | None = None,
dead_time: float | None = None,
) -> dict:
"""Create data structure with provided values or None defaults.
Returns consistent structure with each field having a value or None.
"""
return {
"anticipated_start_time": anticipated_start_time,
"next_schedule_time": next_schedule_time,
"next_target_temperature": next_target_temperature,
"anticipation_minutes": anticipation_minutes,
"current_temp": current_temp,
"learned_heating_slope": learned_heating_slope,
"confidence_level": confidence_level,
"timeslot_id": timeslot_id,
"scheduler_entity": scheduler_entity,
"dead_time": dead_time,
}
async def _get_persisted_dead_time_or_default(self) -> float:
"""Return the persisted learned dead time or the configured default.
When auto_learning is enabled, this method tries to restore the last
learned dead time from persistent storage. This is the correct fallback
during startup (before cycle extraction completes) or when cycles do not
yield a valid dead time.
Returns:
Persisted learned dead time if available and positive, otherwise the
configured default dead time.
"""
if self._lhs_storage is not None:
try:
stored = await self._lhs_storage.get_learned_dead_time()
if stored is not None and stored > 0:
_LOGGER.debug(
"Using persisted learned dead_time: %.1f minutes", stored
)
return stored
if stored is not None and stored <= 0:
_LOGGER.debug(
"Ignoring non-positive persisted dead_time %.1f minutes; "
"falling back to configured default",
stored,
)
except Exception: # noqa: BLE001
_LOGGER.warning("Failed to read persisted dead time", exc_info=True)
_LOGGER.debug(
"No persisted dead_time found, using configured default: %.1f minutes",
self._default_dead_time_minutes,
)
return self._default_dead_time_minutes
@@ -0,0 +1,133 @@
"""Check Overshoot Risk Use Case.
This use case detects when heating will overshoot the target temperature
and cancels preheating to avoid overheating.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from ...domain.interfaces import (
IClimateDataReader,
IEnvironmentReader,
ISchedulerReader,
)
from .control_preheating_use_case import ControlPreheatingUseCase
_LOGGER = logging.getLogger(__name__)
class CheckOvershootRiskUseCase:
"""Use case for detecting overshoot risk.
This use case encapsulates the logic for:
1. Reading current environment and slope
2. Estimating temperature at target time
3. Canceling preheating if overshoot risk is detected
"""
def __init__(
self,
scheduler_reader: ISchedulerReader,
environment_reader: IEnvironmentReader,
climate_data_reader: IClimateDataReader,
control_preheating: ControlPreheatingUseCase,
overshoot_threshold_celsius: float = 0.5,
) -> None:
"""Initialize the use case.
Args:
scheduler_reader: Reads scheduled timeslots
environment_reader: Reads current environment conditions
climate_data_reader: Reads current heating slope
control_preheating: Cancels preheating when risk detected
overshoot_threshold_celsius: Temperature margin above target to consider as overshoot (°C)
"""
_LOGGER.debug(
"Initializing CheckOvershootRiskUseCase with threshold %.1f°C",
overshoot_threshold_celsius,
)
self._scheduler_reader = scheduler_reader
self._environment_reader = environment_reader
self._climate_data_reader = climate_data_reader
self._control_preheating = control_preheating
self._overshoot_threshold = overshoot_threshold_celsius
async def check_and_prevent_overshoot(self, scheduler_entity_id: str) -> bool:
"""Check for overshoot risk and cancel preheating if needed.
Args:
scheduler_entity_id: Scheduler entity tied to preheating
Returns:
True if overshoot detected and preheating canceled, False otherwise
"""
_LOGGER.debug(
"Entering CheckOvershootRiskUseCase.check_and_prevent_overshoot(scheduler=%s)",
scheduler_entity_id,
)
if not self._control_preheating.is_preheating_active():
_LOGGER.debug("Skipping overshoot check - preheating not active")
_LOGGER.debug("Exiting check_and_prevent_overshoot() -> False")
return False
timeslot = await self._scheduler_reader.get_next_timeslot()
if not timeslot:
_LOGGER.debug("Skipping overshoot check - no timeslot available")
_LOGGER.debug("Exiting check_and_prevent_overshoot() -> False")
return False
environment = await self._environment_reader.get_current_environment()
if not environment:
# Safety first: If we can't read temperature, assume overshoot risk
_LOGGER.warning("No environment data available - assuming overshoot risk for safety")
await self._control_preheating.cancel_preheating(scheduler_entity_id)
_LOGGER.info("Cancelled preheating due to missing environment data")
_LOGGER.debug("Exiting check_and_prevent_overshoot() -> True")
return True
current_slope = self._climate_data_reader.get_current_slope()
if current_slope is None or current_slope <= 0.0:
# Cannot check overshoot without valid slope data
_LOGGER.debug(
"Current slope unavailable (%.2f°C/h) - skipping overshoot check",
current_slope or 0.0,
)
_LOGGER.debug("Exiting check_and_prevent_overshoot() -> False")
return False
now = environment.timestamp
if now >= timeslot.target_time:
_LOGGER.debug("Skipping overshoot check - target time already passed")
_LOGGER.debug("Exiting check_and_prevent_overshoot() -> False")
return False
time_to_target_hours = (timeslot.target_time - now).total_seconds() / 3600.0
projected_temp = environment.indoor_temperature + (current_slope * time_to_target_hours)
overshoot_limit = timeslot.target_temp + self._overshoot_threshold
_LOGGER.debug(
"Overshoot check: current=%.1f°C projected=%.1f°C target=%.1f°C limit=%.1f°C",
environment.indoor_temperature,
projected_temp,
timeslot.target_temp,
overshoot_limit,
)
if projected_temp >= overshoot_limit:
_LOGGER.warning(
"Overshoot risk detected: projected %.1f°C exceeds limit %.1f°C",
projected_temp,
overshoot_limit,
)
await self._control_preheating.cancel_preheating(scheduler_entity_id)
_LOGGER.debug("Exiting check_and_prevent_overshoot() -> True")
return True
_LOGGER.debug("No overshoot risk detected")
_LOGGER.debug("Exiting check_and_prevent_overshoot() -> False")
return False
@@ -0,0 +1,137 @@
"""Control Preheating Use Case.
This use case controls the preheating state (start/cancel).
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from datetime import datetime
from ...domain.interfaces import ISchedulerCommander
_LOGGER = logging.getLogger(__name__)
class ControlPreheatingUseCase:
"""Use case for controlling preheating state.
This use case encapsulates operations for:
1. Starting preheating (trigger scheduler action)
2. Canceling preheating (revert to current scheduled state)
3. Tracking preheating state
"""
def __init__(
self,
scheduler_commander: ISchedulerCommander,
) -> None:
"""Initialize the use case.
Args:
scheduler_commander: Commands scheduler actions
"""
_LOGGER.debug("Initializing ControlPreheatingUseCase")
self._scheduler_commander = scheduler_commander
self._is_preheating_active = False
self._preheating_target_time: datetime | None = None
self._active_scheduler_entity: str | None = None
async def cancel_preheating(self, scheduler_entity_id: str | None = None) -> None:
"""Cancel active preheating and revert to current scheduled state.
Args:
scheduler_entity_id: Scheduler entity to cancel action on.
If None, uses the active scheduler entity.
"""
_LOGGER.debug(
"Entering ControlPreheatingUseCase.cancel_preheating(scheduler=%s)",
scheduler_entity_id,
)
effective_scheduler = scheduler_entity_id or self._active_scheduler_entity
if self._is_preheating_active:
if not effective_scheduler:
_LOGGER.warning("Cannot cancel preheating: no scheduler entity available")
_LOGGER.debug("Exiting ControlPreheatingUseCase.cancel_preheating() -> no-op")
return
_LOGGER.info(
"Canceling preheating for scheduler %s - reverting to current scheduled state",
effective_scheduler,
)
# Call cancel_action to revert thermostat to current time's preset/temperature
# Validation is handled by scheduler_commander
await self._scheduler_commander.cancel_action(effective_scheduler)
else:
_LOGGER.debug("No active preheating to cancel")
# Mark preheating as inactive (but keep target_time for state tracking)
# Target time may still be in future (e.g., cancelled due to overshoot)
self._is_preheating_active = False
# Note: _preheating_target_time and _active_scheduler_entity are NOT cleared
# They remain for state tracking and potential restart
_LOGGER.debug("Exiting ControlPreheatingUseCase.cancel_preheating()")
async def start_preheating(
self,
target_time: datetime,
target_temp: float,
scheduler_entity_id: str,
) -> None:
"""Start preheating by triggering scheduler action.
Args:
target_time: Target schedule time
target_temp: Target temperature
scheduler_entity_id: Scheduler entity to trigger
"""
_LOGGER.debug(
"Entering ControlPreheatingUseCase.start_preheating(target_time=%s, temp=%.1f, scheduler=%s)",
target_time.isoformat(),
target_temp,
scheduler_entity_id,
)
_LOGGER.info(
"Starting preheating for target %s (%.1f°C)",
target_time.isoformat(),
target_temp,
)
# Use scheduler's run_action to trigger the action
await self._scheduler_commander.run_action(target_time, scheduler_entity_id)
# Mark pre-heating as active
self._is_preheating_active = True
self._preheating_target_time = target_time
self._active_scheduler_entity = scheduler_entity_id
_LOGGER.debug("Exiting ControlPreheatingUseCase.start_preheating()")
def is_preheating_active(self) -> bool:
"""Check if preheating is currently active.
Returns:
True if preheating is active, False otherwise
"""
return self._is_preheating_active
def get_preheating_target_time(self) -> datetime | None:
"""Get the target time for active preheating.
Returns:
Target time if preheating is active, None otherwise
"""
return self._preheating_target_time
def get_active_scheduler_entity(self) -> str | None:
"""Get the active scheduler entity.
Returns:
Scheduler entity ID if active, None otherwise
"""
return self._active_scheduler_entity
@@ -0,0 +1,381 @@
"""Schedule Anticipation Action Use Case.
This use case encapsulates the complex logic for scheduling preheating based on
anticipation calculations, including revert logic when conditions change.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Callable
from homeassistant.util import dt as dt_util
from ...const import DEFAULT_ANTICIPATION_RECALC_TOLERANCE_MINUTES
if TYPE_CHECKING:
from datetime import datetime
from ...domain.interfaces import ISchedulerCommander, ISchedulerReader, ITimerScheduler
_LOGGER = logging.getLogger(__name__)
class ScheduleAnticipationActionUseCase:
"""Use case for scheduling anticipation actions.
This use case encapsulates the complex logic for:
1. Checking scheduler state
2. Handling revert logic when anticipated start is postponed significantly
3. Scheduling preheating timers
4. Triggering immediate preheating when needed
"""
def __init__(
self,
scheduler_reader: ISchedulerReader,
scheduler_commander: ISchedulerCommander,
timer_scheduler: ITimerScheduler,
control_preheating_use_case, # ControlPreheatingUseCase (avoid circular import)
anticipation_recalc_tolerance_minutes: int = DEFAULT_ANTICIPATION_RECALC_TOLERANCE_MINUTES,
) -> None:
"""Initialize the use case.
Args:
scheduler_reader: Reads scheduler state
scheduler_commander: Triggers scheduler actions
timer_scheduler: Schedules timer callbacks
control_preheating_use_case: Use case for managing preheating state
anticipation_recalc_tolerance_minutes: Min absolute delta in anticipated
start time required to cancel active preheating and reschedule
"""
_LOGGER.debug("Initializing ScheduleAnticipationActionUseCase")
self._scheduler_reader = scheduler_reader
self._scheduler_commander = scheduler_commander
self._timer_scheduler = timer_scheduler
self._control_preheating = control_preheating_use_case
# Scheduling-specific state (not preheating state - delegated to ControlPreheatingUseCase)
self._last_scheduled_time: datetime | None = None
self._last_scheduled_lhs: float | None = None
self._anticipation_timer_cancel: Callable[[], None] | None = None
self._preheating_target_temp: float | None = None # Temp only (time/active delegated)
self._anticipation_recalc_tolerance_seconds = anticipation_recalc_tolerance_minutes * 60
def set_preheating_temp(self, target_temp: float | None) -> None:
"""Update preheating target temperature.
Note: Preheating state (active, target_time) is managed by ControlPreheatingUseCase.
Args:
target_temp: Target temperature
"""
_LOGGER.debug("Setting preheating target temp: %.1f", target_temp or 0.0)
self._preheating_target_temp = target_temp
async def handle_anticipation_scheduling(
self,
anticipation_data: dict,
ihp_enabled: bool,
) -> None:
"""Handle the complete scheduling workflow based on anticipation data and IHP status.
This method contains all the business logic for deciding when to schedule/cancel
preheating based on data availability, IHP status, and current state.
Args:
anticipation_data: Calculated anticipation data
ihp_enabled: Whether IHP is enabled
"""
_LOGGER.debug(
"Entering handle_anticipation_scheduling(ihp_enabled=%s, has_data=%s)",
ihp_enabled,
anticipation_data.get("anticipated_start_time") is not None,
)
# Decision 1: No valid data - cancel everything
if anticipation_data.get("anticipated_start_time") is None:
_LOGGER.debug("No valid anticipation data - cancelling any active scheduling")
await self.cancel_action()
await self._control_preheating.cancel_preheating(
self._control_preheating.get_active_scheduler_entity()
or anticipation_data.get("scheduler_entity")
)
return
# Decision 2: IHP disabled - cancel but don't schedule
if not ihp_enabled:
_LOGGER.debug("IHP disabled - cancelling preheating if active")
await self._control_preheating.cancel_preheating(
self._control_preheating.get_active_scheduler_entity()
or anticipation_data.get("scheduler_entity")
)
await self.cancel_action()
return
# Decision 3: Target already reached (anticipation_minutes == 0) - clear state
if anticipation_data.get("anticipation_minutes") == 0:
_LOGGER.debug("Target reached - clearing anticipation state")
await self.cancel_action()
await self._control_preheating.cancel_preheating(
self._control_preheating.get_active_scheduler_entity()
or anticipation_data.get("scheduler_entity")
)
return
# Decision 4: No scheduler entity - skip scheduling
scheduler_entity = anticipation_data.get("scheduler_entity")
if not scheduler_entity:
_LOGGER.debug("No scheduler entity - skipping scheduling")
return
# Decision 5: Valid data + IHP enabled + scheduler available - schedule
await self.schedule_action(
anticipated_start=anticipation_data["anticipated_start_time"],
target_time=anticipation_data["next_schedule_time"],
target_temp=anticipation_data["next_target_temperature"],
scheduler_entity_id=scheduler_entity,
lhs=float(anticipation_data.get("learned_heating_slope") or 0.0),
)
async def schedule_action(
self,
anticipated_start: datetime,
target_time: datetime,
target_temp: float,
scheduler_entity_id: str,
lhs: float,
) -> None:
"""Schedule heating anticipation action.
This method handles all the scheduling logic including:
- Checking scheduler state
- Handling revert when anticipated time changes significantly
- Scheduling timers for future starts
- Triggering immediate preheating if start is in the past
Args:
anticipated_start: When to start preheating
target_time: Target schedule time
target_temp: Target temperature
scheduler_entity_id: Scheduler entity to trigger
lhs: Learned heating slope
"""
_LOGGER.debug(
"Entering ScheduleAnticipationActionUseCase.schedule_action("
"anticipated_start=%s, scheduler=%s)",
anticipated_start.isoformat(),
scheduler_entity_id,
)
now = dt_util.now()
# Check if scheduler is enabled
if not await self._scheduler_reader.is_scheduler_enabled(scheduler_entity_id):
_LOGGER.warning(
"Scheduler %s is disabled. Skipping anticipation scheduling.",
scheduler_entity_id,
)
active_scheduler = self._control_preheating.get_active_scheduler_entity()
if active_scheduler == scheduler_entity_id:
await self._clear_state()
return
# Handle revert logic: cancel active preheating only when anticipated start is pushed later
# by at least the configured tolerance.
if self._control_preheating.is_preheating_active():
preheating_target = self._control_preheating.get_preheating_target_time()
postponed_seconds = 0.0
if self._last_scheduled_time is not None:
postponed_seconds = (anticipated_start - self._last_scheduled_time).total_seconds()
if (
preheating_target == target_time
and self._last_scheduled_time is not None
and postponed_seconds >= self._anticipation_recalc_tolerance_seconds
):
delta_minutes = postponed_seconds / 60.0
_LOGGER.info(
"Anticipated start moved later significantly (delta: %.1f min, "
"threshold: %.1f min). Reverting active preheating and rescheduling.",
delta_minutes,
self._anticipation_recalc_tolerance_seconds / 60.0,
)
# Delegate cancellation to ControlPreheatingUseCase
await self._control_preheating.cancel_preheating(scheduler_entity_id)
# If target time reached, mark complete
if now >= target_time:
_LOGGER.info("Target time reached, preheating complete")
await self._clear_state()
return
# Update tracking
self._last_scheduled_time = anticipated_start
self._last_scheduled_lhs = lhs
# If anticipated start is in past but target is future, trigger now
if anticipated_start <= now < target_time:
if not self._control_preheating.is_preheating_active():
_LOGGER.info(
"Anticipated start %s is past, triggering preheating immediately",
anticipated_start.isoformat(),
)
# Delegate to ControlPreheatingUseCase
await self._control_preheating.start_preheating(
target_time, target_temp, scheduler_entity_id
)
else:
_LOGGER.debug(
"Already preheating, continuing through target time %s", target_time.isoformat()
)
_LOGGER.debug("Exiting schedule_action() -> immediate trigger")
return
# Both times in past - skip
if anticipated_start <= now and target_time <= now:
_LOGGER.debug("Both times are in past, skipping")
await self._cancel_timer()
return
# Schedule timer for future start only if preheating is not already active.
if not self._control_preheating.is_preheating_active():
await self._schedule_timer(
anticipated_start,
target_time,
target_temp,
scheduler_entity_id,
)
_LOGGER.debug("Exiting schedule_action() -> timer scheduled")
async def _schedule_timer(
self,
anticipated_start: datetime,
target_time: datetime,
target_temp: float,
scheduler_entity_id: str,
) -> None:
"""Schedule a timer to trigger preheating at anticipated start time.
Args:
anticipated_start: When timer should fire
target_time: Target schedule time
target_temp: Target temperature
scheduler_entity_id: Scheduler to trigger
"""
_LOGGER.debug(
"Scheduling timer: anticipated_start=%s, scheduler=%s",
anticipated_start.isoformat(),
scheduler_entity_id,
)
# Cancel existing timer
await self._cancel_timer()
# Create callback
async def _timer_callback() -> None:
"""Execute when timer fires."""
_LOGGER.info(
"Anticipation timer fired at %s for target %s (%.1f°C)",
dt_util.now().isoformat(),
target_time.isoformat(),
target_temp,
)
# Clear timer reference
self._anticipation_timer_cancel = None
# Trigger the action
await self._trigger_action(target_time, target_temp, scheduler_entity_id)
# Schedule the timer
self._anticipation_timer_cancel = self._timer_scheduler.schedule_timer(
anticipated_start,
_timer_callback,
)
now = dt_util.now()
wait_minutes = (anticipated_start - now).total_seconds() / 60.0
_LOGGER.info(
"Anticipation timer scheduled: will trigger at %s (in %.1f minutes)",
anticipated_start.isoformat(),
wait_minutes,
)
async def _trigger_action(
self,
target_time: datetime,
target_temp: float,
scheduler_entity_id: str,
) -> None:
"""Trigger the preheating action via scheduler.
Args:
target_time: Target time
target_temp: Target temperature
scheduler_entity_id: Scheduler entity
"""
_LOGGER.debug(
"Triggering anticipation action: target_time=%s, temp=%.1f°C",
target_time.isoformat(),
target_temp,
)
# Verify scheduler is still enabled
if not await self._scheduler_reader.is_scheduler_enabled(scheduler_entity_id):
_LOGGER.warning("Scheduler %s is disabled, cannot trigger action", scheduler_entity_id)
await self._clear_state()
return
# Delegate to ControlPreheatingUseCase
await self._control_preheating.start_preheating(
target_time, target_temp, scheduler_entity_id
)
self._preheating_target_temp = target_temp
_LOGGER.debug("Action triggered successfully")
async def _cancel_timer(self) -> None:
"""Cancel any active timer.
Note: Does NOT clear _preheating_target_time - that's preserved for state tracking.
"""
_LOGGER.debug("Entering cancel_timer")
if self._anticipation_timer_cancel:
_LOGGER.debug("Canceling active timer")
self._anticipation_timer_cancel()
self._anticipation_timer_cancel = None
# Note: Timer cancelled but target_time preserved (as per review feedback)
_LOGGER.debug("Exiting cancel_timer")
async def _clear_state(self) -> None:
"""Clear all tracking state."""
_LOGGER.debug("Clearing anticipation state")
await self._cancel_timer()
# Clear scheduling-specific state
self._preheating_target_temp = None
self._last_scheduled_time = None
self._last_scheduled_lhs = None
# Note: Preheating active/target_time managed by ControlPreheatingUseCase
async def cancel_action(self) -> None:
"""Cancel any active timer and clear state.
Called by orchestrator when disabling preheating or when conditions change.
"""
_LOGGER.debug("Canceling anticipation action")
await self._cancel_timer()
def get_preheating_state(self) -> tuple[bool, datetime | None, float | None]:
"""Get current preheating state.
Delegates to ControlPreheatingUseCase for state queries.
Returns:
Tuple of (is_active, target_time, target_temp)
"""
return (
self._control_preheating.is_preheating_active(),
self._control_preheating.get_preheating_target_time(),
self._preheating_target_temp,
)
@@ -0,0 +1,156 @@
"""Update Cache Data Use Case.
This use case manages the heating cycle cache:
- Get cache data
- Update/prune cache
- Reset cache completely
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from datetime import datetime
from ...domain.interfaces import IHeatingCycleStorage, ILhsStorage
from ...domain.value_objects import HeatingCycle, HeatingCycleCacheData
from ..lhs_lifecycle_manager import LhsLifecycleManager
_LOGGER = logging.getLogger(__name__)
class UpdateCacheDataUseCase:
"""Use case for managing heating cycle cache.
This use case encapsulates the logic for:
1. Getting cycles from cache
2. Updating cache (append new cycles, then prune old ones)
3. Recalculating LHS when cycles change
4. Resetting cache completely
"""
def __init__(
self,
cycle_storage: IHeatingCycleStorage,
lhs_storage: ILhsStorage,
lhs_lifecycle_manager: LhsLifecycleManager,
) -> None:
"""Initialize the use case.
Args:
cycle_storage: Heating cycle cache storage
lhs_storage: LHS storage for clearing
lhs_lifecycle_manager: Manages LHS recalculation
"""
_LOGGER.debug("Initializing UpdateCacheDataUseCase")
self._cycle_storage = cycle_storage
self._lhs_storage = lhs_storage
self._lhs_manager = lhs_lifecycle_manager
async def get_cache_data(self, device_id: str) -> HeatingCycleCacheData | None:
"""Get cache data for a device.
Args:
device_id: Device identifier
Returns:
Cache data if exists, None otherwise
"""
_LOGGER.debug(
"Entering UpdateCacheDataUseCase.get_cache_data(device_id=%s)",
device_id,
)
cache_data = await self._cycle_storage.get_cache_data(device_id)
_LOGGER.debug(
"Exiting UpdateCacheDataUseCase.get_cache_data() -> %s",
"data" if cache_data else "None",
)
return cache_data
async def append_cycles(
self,
device_id: str,
cycles: list[HeatingCycle],
reference_time: datetime,
) -> None:
"""Append new cycles to cache and prune old ones.
Args:
device_id: Device identifier
cycles: New heating cycles to append
reference_time: Reference time for pruning old cycles
"""
_LOGGER.debug(
"Entering UpdateCacheDataUseCase.append_cycles(device_id=%s, cycles=%d)",
device_id,
len(cycles),
)
# Append cycles to storage (storage handles deduplication)
await self._cycle_storage.append_cycles(device_id, cycles, reference_time)
_LOGGER.info(
"Appended %d cycles to cache for device %s",
len(cycles),
device_id,
)
# Prune old cycles based on retention
await self.prune_old_cycles(device_id, reference_time)
_LOGGER.debug("Exiting UpdateCacheDataUseCase.append_cycles()")
async def prune_old_cycles(
self,
device_id: str,
reference_time: datetime,
) -> None:
"""Prune cycles older than retention period and recalculate LHS.
Args:
device_id: Device identifier
reference_time: Reference time for retention calculation
"""
_LOGGER.debug(
"Entering UpdateCacheDataUseCase.prune_old_cycles(device_id=%s, reference_time=%s)",
device_id,
reference_time.isoformat(),
)
# Prune old cycles
await self._cycle_storage.prune_old_cycles(device_id, reference_time)
# Note: LHS is automatically updated when cycles change via event listeners
# No need to explicitly recalculate here
_LOGGER.info("Old cycles pruned, LHS will be recalculated automatically")
_LOGGER.debug("Exiting UpdateCacheDataUseCase.prune_old_cycles()")
async def reset_cache(self, device_id: str) -> None:
"""Reset cache completely for a device (both cycles and LHS).
This deletes all cached cycles and LHS data.
Args:
device_id: Device identifier
"""
_LOGGER.debug(
"Entering UpdateCacheDataUseCase.reset_cache(device_id=%s)",
device_id,
)
_LOGGER.info("Resetting heating cycle cache and LHS for device %s", device_id)
# Clear all cycle cache data
await self._cycle_storage.clear_cache(device_id)
_LOGGER.info("Heating cycle cache has been reset")
# Clear LHS data
await self._lhs_storage.clear_slope_history()
_LOGGER.info("LHS data has been reset")
_LOGGER.info("Cache and LHS have been reset for device %s", device_id)
_LOGGER.debug("Exiting UpdateCacheDataUseCase.reset_cache()")