New apps Added
This commit is contained in:
@@ -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",
|
||||
]
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+314
@@ -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
|
||||
+133
@@ -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
|
||||
+137
@@ -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
|
||||
+381
@@ -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,
|
||||
)
|
||||
+156
@@ -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()")
|
||||
Reference in New Issue
Block a user