Files
Home-Assistant/custom_components/ha_washdata/manager.py
T
2026-06-14 02:01:49 -04:00

4553 lines
194 KiB
Python

"""Manager for WashData."""
# pylint: disable=broad-exception-caught
from __future__ import annotations
import logging
import hashlib
import inspect
import math
from asyncio import Task
from datetime import datetime, timedelta
from typing import Any, cast
import numpy as np
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import Context, Event, HomeAssistant, State, callback
from homeassistant.helpers.event import (
async_track_state_change_event,
async_track_time_interval,
)
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.exceptions import HomeAssistantError
from homeassistant.const import STATE_UNAVAILABLE, STATE_HOME
from homeassistant.util import dt as dt_util
import homeassistant.helpers.event as evt
from homeassistant.helpers import script as script_helper
from homeassistant.helpers import translation
from .const import (
DOMAIN,
CONF_POWER_SENSOR,
CONF_MIN_POWER,
CONF_OFF_DELAY,
CONF_NOTIFY_SERVICE,
CONF_NOTIFY_ACTIONS,
CONF_NOTIFY_START_SERVICES,
CONF_NOTIFY_FINISH_SERVICES,
CONF_NOTIFY_LIVE_SERVICES,
CONF_NOTIFY_PEOPLE,
CONF_NOTIFY_ONLY_WHEN_HOME,
CONF_NOTIFY_FIRE_EVENTS,
CONF_NOTIFY_EVENTS,
CONF_NO_UPDATE_ACTIVE_TIMEOUT,
CONF_LOW_POWER_NO_UPDATE_TIMEOUT, # Import new constant
CONF_SMOOTHING_WINDOW,
CONF_PROFILE_DURATION_TOLERANCE,
CONF_INTERRUPTED_MIN_SECONDS,
CONF_ABRUPT_DROP_WATTS,
CONF_ABRUPT_DROP_RATIO,
CONF_ABRUPT_HIGH_LOAD_FACTOR,
CONF_PROGRESS_RESET_DELAY,
CONF_LEARNING_CONFIDENCE,
CONF_DURATION_TOLERANCE,
CONF_AUTO_LABEL_CONFIDENCE,
CONF_AUTO_MAINTENANCE,
CONF_PROFILE_MATCH_INTERVAL,
CONF_PROFILE_MATCH_MIN_DURATION_RATIO,
CONF_PROFILE_MATCH_MAX_DURATION_RATIO,
CONF_MAX_PAST_CYCLES,
CONF_MAX_FULL_TRACES_PER_PROFILE,
CONF_MAX_FULL_TRACES_UNLABELED,
CONF_WATCHDOG_INTERVAL,
CONF_AUTO_TUNE_NOISE_EVENTS_THRESHOLD,
CONF_COMPLETION_MIN_SECONDS,
CONF_NOTIFY_BEFORE_END_MINUTES,
CONF_PROFILE_MATCH_THRESHOLD,
CONF_PROFILE_UNMATCH_THRESHOLD,
CONF_DEVICE_TYPE,
CONF_START_DURATION_THRESHOLD,
CONF_RUNNING_DEAD_ZONE,
CONF_END_REPEAT_COUNT,
CONF_MIN_OFF_GAP,
CONF_START_ENERGY_THRESHOLD,
CONF_END_ENERGY_THRESHOLD,
CONF_START_THRESHOLD_W,
CONF_STOP_THRESHOLD_W,
CONF_SAMPLING_INTERVAL,
CONF_SAVE_DEBUG_TRACES,
CONF_DTW_BANDWIDTH,
CONF_EXTERNAL_END_TRIGGER_ENABLED,
CONF_EXTERNAL_END_TRIGGER,
CONF_EXTERNAL_END_TRIGGER_INVERTED,
CONF_ANTI_WRINKLE_ENABLED,
CONF_ANTI_WRINKLE_MAX_POWER,
CONF_ANTI_WRINKLE_MAX_DURATION,
CONF_ANTI_WRINKLE_EXIT_POWER,
CONF_DELAY_START_DETECT_ENABLED,
CONF_DELAY_CONFIRM_SECONDS,
CONF_DELAY_TIMEOUT_HOURS,
CONF_PUMP_STUCK_DURATION,
DEFAULT_PUMP_STUCK_DURATION,
EVENT_PUMP_STUCK,
DEVICE_TYPE_PUMP,
SIGNAL_WASHER_UPDATE,
NOTIFY_EVENT_START,
NOTIFY_EVENT_FINISH,
NOTIFY_EVENT_LIVE,
NOTIFY_EVENT_CLEAN,
EVENT_CYCLE_STARTED,
EVENT_CYCLE_ENDED,
DEFAULT_MIN_POWER,
DEFAULT_OFF_DELAY,
DEFAULT_NO_UPDATE_ACTIVE_TIMEOUT,
DEFAULT_NO_UPDATE_ACTIVE_TIMEOUT_BY_DEVICE,
DEFAULT_SMOOTHING_WINDOW,
DEFAULT_PROFILE_DURATION_TOLERANCE,
DEFAULT_INTERRUPTED_MIN_SECONDS,
DEFAULT_ABRUPT_DROP_WATTS,
DEFAULT_ABRUPT_DROP_RATIO,
DEFAULT_ABRUPT_HIGH_LOAD_FACTOR,
DEFAULT_COMPLETION_MIN_SECONDS,
DEFAULT_NOTIFY_BEFORE_END_MINUTES,
DEFAULT_PROFILE_MATCH_THRESHOLD,
DEFAULT_PROFILE_UNMATCH_THRESHOLD,
DEFAULT_SAMPLING_INTERVAL,
DEFAULT_PROGRESS_RESET_DELAY,
DEFAULT_LEARNING_CONFIDENCE,
DEFAULT_DURATION_TOLERANCE,
DEFAULT_AUTO_LABEL_CONFIDENCE,
DEFAULT_AUTO_MAINTENANCE,
DEFAULT_PROFILE_MATCH_INTERVAL,
DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO,
DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO_BY_DEVICE,
DEFAULT_ANTI_WRINKLE_ENABLED,
DEFAULT_ANTI_WRINKLE_MAX_POWER,
DEFAULT_ANTI_WRINKLE_MAX_DURATION,
DEFAULT_ANTI_WRINKLE_EXIT_POWER,
DEFAULT_DELAY_START_DETECT_ENABLED,
DEFAULT_DELAY_CONFIRM_SECONDS,
DEFAULT_DELAY_TIMEOUT_HOURS,
DEFAULT_PROFILE_MATCH_MAX_DURATION_RATIO,
DEFAULT_MAX_PAST_CYCLES,
DEFAULT_MAX_FULL_TRACES_PER_PROFILE,
CONF_NOTIFY_TITLE,
CONF_NOTIFY_ICON,
CONF_NOTIFY_START_MESSAGE,
CONF_NOTIFY_FINISH_MESSAGE,
CONF_NOTIFY_PRE_COMPLETE_MESSAGE,
CONF_NOTIFY_LIVE_INTERVAL_SECONDS,
CONF_NOTIFY_LIVE_OVERRUN_PERCENT,
CONF_NOTIFY_LIVE_CHRONOMETER,
CONF_NOTIFY_REMINDER_MESSAGE,
CONF_NOTIFY_TIMEOUT_SECONDS,
CONF_NOTIFY_CHANNEL,
CONF_NOTIFY_FINISH_CHANNEL,
CONF_ENERGY_PRICE_STATIC,
CONF_ENERGY_PRICE_ENTITY,
CONF_DOOR_SENSOR_ENTITY,
CONF_PAUSE_CUTS_POWER,
CONF_SWITCH_ENTITY,
CONF_NOTIFY_UNLOAD_DELAY_MINUTES,
CONF_NOTIFY_UNLOAD_MESSAGE,
DEFAULT_NOTIFY_UNLOAD_DELAY_MINUTES,
DEFAULT_NOTIFY_UNLOAD_MESSAGE,
STATE_CLEAN,
DEFAULT_NOTIFY_TITLE,
DEFAULT_NOTIFY_START_MESSAGE,
DEFAULT_NOTIFY_FINISH_MESSAGE,
DEFAULT_NOTIFY_PRE_COMPLETE_MESSAGE,
DEFAULT_NOTIFY_LIVE_WAITING_MESSAGE,
DEFAULT_NOTIFY_ONLY_WHEN_HOME,
DEFAULT_NOTIFY_FIRE_EVENTS,
DEFAULT_NOTIFY_LIVE_INTERVAL_SECONDS,
DEFAULT_NOTIFY_LIVE_OVERRUN_PERCENT,
DEFAULT_NOTIFY_LIVE_CHRONOMETER,
DEFAULT_NOTIFY_REMINDER_MESSAGE,
DEFAULT_NOTIFY_TIMEOUT_SECONDS,
DEFAULT_NOTIFY_CHANNEL,
DEFAULT_NOTIFY_FINISH_CHANNEL,
DEFAULT_MAX_FULL_TRACES_UNLABELED,
DEFAULT_DTW_BANDWIDTH,
DEFAULT_WATCHDOG_INTERVAL,
CONF_MATCH_PERSISTENCE,
DEFAULT_MATCH_PERSISTENCE,
DEFAULT_MATCH_REVERT_RATIO,
DEFAULT_AUTO_TUNE_NOISE_EVENTS_THRESHOLD,
DEFAULT_DEVICE_TYPE,
DEFAULT_START_DURATION_THRESHOLD,
DEFAULT_RUNNING_DEAD_ZONE,
DEFAULT_END_REPEAT_COUNT,
DEFAULT_MIN_OFF_GAP,
DEFAULT_MIN_OFF_GAP_BY_DEVICE,
DEFAULT_MAX_DEFERRAL_SECONDS,
DEFAULT_START_ENERGY_THRESHOLDS_BY_DEVICE,
DEFAULT_END_ENERGY_THRESHOLD,
DEVICE_SMOOTHING_THRESHOLDS,
DEVICE_COMPLETION_THRESHOLDS,
STATE_RUNNING,
STATE_OFF,
STATE_STARTING,
STATE_PAUSED,
STATE_USER_PAUSED,
STATE_ENDING,
STATE_ANTI_WRINKLE,
STATE_DELAY_WAIT,
STATE_IDLE,
STATE_UNKNOWN,
)
from .cycle_detector import CycleDetector, CycleDetectorConfig
from .learning import LearningManager
from .profile_store import ProfileStore, decompress_power_data
from .recorder import CycleRecorder
from .diag_buffer import DiagBuffer
from .log_utils import DeviceLoggerAdapter
from .time_utils import power_data_to_offsets
_LOGGER = logging.getLogger(__name__)
def _pn_create(
hass: HomeAssistant,
message: str,
*,
title: str | None = None,
notification_id: str | None = None,
) -> None:
"""Best-effort persistent notification creation.
Tests stub out the entire `homeassistant` module, so we can't import
`homeassistant.components.persistent_notification` here.
"""
try:
components = getattr(cast(Any, hass), "components", None)
pn = getattr(cast(Any, components), "persistent_notification", None)
if pn is None:
return
result = pn.async_create(message, title=title, notification_id=notification_id)
if inspect.iscoroutine(result):
hass.async_create_task(result)
except Exception:
return
class WashDataManager:
"""Manages a single washing machine instance."""
def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
"""Initialize the manager."""
self.hass = hass
self.config_entry = config_entry
self.entry_id = config_entry.entry_id
self._logger = DeviceLoggerAdapter(_LOGGER, config_entry.title)
self.diag_buffer = DiagBuffer(config_entry.title)
# Prioritize options -> data for power sensor (allows changing it)
self.power_sensor_entity_id = config_entry.options.get(
CONF_POWER_SENSOR, config_entry.data.get(CONF_POWER_SENSOR)
)
self.device_type = config_entry.options.get(
CONF_DEVICE_TYPE,
config_entry.data.get(CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE),
)
# Initialize attributes to satisfy pylint
self._off_delay = float(DEFAULT_OFF_DELAY)
self._no_update_active_timeout = float(DEFAULT_NO_UPDATE_ACTIVE_TIMEOUT)
self._low_power_no_update_timeout = 3600.0 # Default 1h
self._notify_before_end_minutes = float(DEFAULT_NOTIFY_BEFORE_END_MINUTES)
self._notify_start_services: list[str] = []
self._notify_finish_services: list[str] = []
self._notify_live_services: list[str] = []
self._notify_actions: list[dict[str, Any]] = []
self._notify_people: list[str] = []
self._notify_only_when_home = DEFAULT_NOTIFY_ONLY_WHEN_HOME
self._notify_fire_events = DEFAULT_NOTIFY_FIRE_EVENTS
self._notify_live_interval_seconds = DEFAULT_NOTIFY_LIVE_INTERVAL_SECONDS
self._notify_live_overrun_percent = DEFAULT_NOTIFY_LIVE_OVERRUN_PERCENT
self._notify_live_chronometer = DEFAULT_NOTIFY_LIVE_CHRONOMETER
self._notify_timeout_seconds = DEFAULT_NOTIFY_TIMEOUT_SECONDS
self._pending_notifications: list[dict[str, Any]] = []
self._remove_notify_people_listener = None
self._live_notification_sent_count = 0
# Pause tracking (user-triggered)
self._user_pause_start: datetime | None = None
self._total_user_paused_seconds: float = 0.0
self._is_user_paused: bool = False
self._pause_cuts_power: bool = bool(
config_entry.options.get(CONF_PAUSE_CUTS_POWER, False)
)
# Door sensor + clean state
self._door_sensor_entity: str | None = config_entry.options.get(
CONF_DOOR_SENSOR_ENTITY
) or None
self._remove_door_sensor_listener = None
self._is_clean_state: bool = False
self._clean_state_start: datetime | None = None
self._notified_clean_laundry: bool = False
self._notify_unload_delay_minutes: int = int(
config_entry.options.get(
CONF_NOTIFY_UNLOAD_DELAY_MINUTES, DEFAULT_NOTIFY_UNLOAD_DELAY_MINUTES
)
)
self._live_notification_cap = 0
self._last_live_notification_time: datetime | None = None
self._live_waiting_notification_sent = False
self._live_chronometer_overrun_sent = False
# Single per-device identity shared by start/live/reminder/finished so each
# replaces the previous on the mobile app (and collapses to one entry on the
# persistent-notification fallback). The clean-laundry nag uses its own tag
# since it fires up to an hour after finish and should not clobber the thread.
self._lifecycle_tag = f"ha_washdata_{self.entry_id}_lifecycle"
self._lifecycle_pn_id = self._lifecycle_tag
self._clean_tag = f"ha_washdata_{self.entry_id}_clean"
# Backwards-compatible alias for existing live-notification call sites/tests.
self._live_notification_tag = self._lifecycle_tag
self._start_event_fired = False
self._cycle_start_time: datetime | None = None
# State
self._current_power = 0.0
self._last_reading_time: datetime | None = None
self._last_real_reading_time: datetime | None = None # Track last real sensor update
self._noise_events: list[datetime] = []
self._noise_max_powers: list[float] = []
self._last_match_result = None
self._last_phase_estimate_time = None
self._sample_intervals: list[float] = []
self._sample_interval_stats: dict[str, Any] = {}
self._matching_task: Task[Any] | None = None
self._last_state_save = 0.0
self._last_cycle_end_time: datetime | None = None
self._remove_state_expiry_timer = None
# Components
match_threshold = config_entry.options.get(
CONF_PROFILE_MATCH_THRESHOLD, DEFAULT_PROFILE_MATCH_THRESHOLD
)
unmatch_threshold = config_entry.options.get(
CONF_PROFILE_UNMATCH_THRESHOLD, DEFAULT_PROFILE_UNMATCH_THRESHOLD
)
self._unmatch_threshold = unmatch_threshold
self.profile_store = ProfileStore(
hass,
self.entry_id,
min_duration_ratio=config_entry.options.get(
CONF_PROFILE_MATCH_MIN_DURATION_RATIO,
DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO,
),
max_duration_ratio=config_entry.options.get(
CONF_PROFILE_MATCH_MAX_DURATION_RATIO,
DEFAULT_PROFILE_MATCH_MAX_DURATION_RATIO,
),
save_debug_traces=config_entry.options.get(CONF_SAVE_DEBUG_TRACES, False),
match_threshold=match_threshold,
unmatch_threshold=unmatch_threshold,
device_name=config_entry.title,
)
self.profile_store.dtw_bandwidth = float(
config_entry.options.get(CONF_DTW_BANDWIDTH, DEFAULT_DTW_BANDWIDTH)
)
self.learning_manager = LearningManager(
hass, self.entry_id, self.profile_store, self.device_type,
device_name=config_entry.title,
)
self.recorder = CycleRecorder(hass, self.entry_id, device_name=config_entry.title)
# Priority: Options > Data > Default
min_power = config_entry.options.get(
CONF_MIN_POWER, config_entry.data.get(CONF_MIN_POWER, DEFAULT_MIN_POWER)
)
off_delay = config_entry.options.get(
CONF_OFF_DELAY, config_entry.data.get(CONF_OFF_DELAY, DEFAULT_OFF_DELAY)
)
progress_reset_delay = config_entry.options.get(
CONF_PROGRESS_RESET_DELAY, DEFAULT_PROGRESS_RESET_DELAY
)
self._no_update_active_timeout = float(
config_entry.options.get(
CONF_NO_UPDATE_ACTIVE_TIMEOUT,
DEFAULT_NO_UPDATE_ACTIVE_TIMEOUT,
)
)
self._low_power_no_update_timeout = float(
config_entry.options.get(CONF_LOW_POWER_NO_UPDATE_TIMEOUT, 3600.0)
)
self._off_delay = float(config_entry.options.get(CONF_OFF_DELAY, DEFAULT_OFF_DELAY))
self._learning_confidence = config_entry.options.get(
CONF_LEARNING_CONFIDENCE, DEFAULT_LEARNING_CONFIDENCE
)
self._duration_tolerance = config_entry.options.get(
CONF_DURATION_TOLERANCE, DEFAULT_DURATION_TOLERANCE
)
self._auto_label_confidence = config_entry.options.get(
CONF_AUTO_LABEL_CONFIDENCE, DEFAULT_AUTO_LABEL_CONFIDENCE
)
self._profile_match_interval = int(
config_entry.options.get(
CONF_PROFILE_MATCH_INTERVAL, DEFAULT_PROFILE_MATCH_INTERVAL
)
)
self._notify_before_end_minutes = int(
config_entry.options.get(
CONF_NOTIFY_BEFORE_END_MINUTES, DEFAULT_NOTIFY_BEFORE_END_MINUTES
)
)
self._load_notify_services(config_entry)
self._notify_actions = list(
cast(list[dict[str, Any]], config_entry.options.get(CONF_NOTIFY_ACTIONS, []) or [])
)
self._notify_people = list(
config_entry.options.get(CONF_NOTIFY_PEOPLE, []) or []
)
self._notify_only_when_home = bool(
config_entry.options.get(
CONF_NOTIFY_ONLY_WHEN_HOME, DEFAULT_NOTIFY_ONLY_WHEN_HOME
)
)
self._notify_fire_events = bool(
config_entry.options.get(CONF_NOTIFY_FIRE_EVENTS, DEFAULT_NOTIFY_FIRE_EVENTS)
)
self._notify_live_interval_seconds = int(
config_entry.options.get(
CONF_NOTIFY_LIVE_INTERVAL_SECONDS,
DEFAULT_NOTIFY_LIVE_INTERVAL_SECONDS,
)
)
self._notify_live_overrun_percent = int(
config_entry.options.get(
CONF_NOTIFY_LIVE_OVERRUN_PERCENT,
DEFAULT_NOTIFY_LIVE_OVERRUN_PERCENT,
)
)
self._notify_live_chronometer = bool(
config_entry.options.get(
CONF_NOTIFY_LIVE_CHRONOMETER,
DEFAULT_NOTIFY_LIVE_CHRONOMETER,
)
)
self._notify_timeout_seconds = int(
config_entry.options.get(
CONF_NOTIFY_TIMEOUT_SECONDS, DEFAULT_NOTIFY_TIMEOUT_SECONDS
)
)
# Advanced options
smoothing_window = int(config_entry.options.get("smoothing_window", 5))
interrupted_min_seconds = int(
config_entry.options.get("interrupted_min_seconds", 150)
)
abrupt_drop_watts = float(config_entry.options.get("abrupt_drop_watts", 500.0))
abrupt_drop_ratio = float(config_entry.options.get("abrupt_drop_ratio", 0.6))
abrupt_high_load_factor = float(
config_entry.options.get("abrupt_high_load_factor", 5.0)
)
# Get device specific default for completion threshold
device_default_completion = DEVICE_COMPLETION_THRESHOLDS.get(
self.device_type, DEFAULT_COMPLETION_MIN_SECONDS
)
completion_min_seconds = int(
config_entry.options.get(
CONF_COMPLETION_MIN_SECONDS, device_default_completion
)
)
start_duration_threshold = float(
config_entry.options.get(
CONF_START_DURATION_THRESHOLD, DEFAULT_START_DURATION_THRESHOLD
)
)
running_dead_zone = int(
config_entry.options.get(CONF_RUNNING_DEAD_ZONE, DEFAULT_RUNNING_DEAD_ZONE)
)
end_repeat_count = int(
config_entry.options.get(CONF_END_REPEAT_COUNT, DEFAULT_END_REPEAT_COUNT)
)
self._logger.info(
"Manager init: min_power=%sW, off_delay=%ss, type=%s",
min_power,
off_delay,
self.device_type,
)
config = CycleDetectorConfig(
min_power=float(min_power),
off_delay=int(off_delay),
smoothing_window=smoothing_window,
interrupted_min_seconds=interrupted_min_seconds,
abrupt_drop_watts=abrupt_drop_watts,
abrupt_drop_ratio=abrupt_drop_ratio,
abrupt_high_load_factor=abrupt_high_load_factor,
completion_min_seconds=completion_min_seconds,
start_duration_threshold=start_duration_threshold,
running_dead_zone=running_dead_zone,
end_repeat_count=end_repeat_count,
min_off_gap=int(
config_entry.options.get(
CONF_MIN_OFF_GAP,
DEFAULT_MIN_OFF_GAP_BY_DEVICE.get(
self.device_type, DEFAULT_MIN_OFF_GAP
),
)
),
start_energy_threshold=float(
config_entry.options.get(
CONF_START_ENERGY_THRESHOLD,
DEFAULT_START_ENERGY_THRESHOLDS_BY_DEVICE.get(self.device_type, 0.2)
)
),
end_energy_threshold=float(
config_entry.options.get(CONF_END_ENERGY_THRESHOLD, DEFAULT_END_ENERGY_THRESHOLD)
),
start_threshold_w=float(
config_entry.options.get(
CONF_START_THRESHOLD_W,
float(min_power) + max(1.0, 0.1 * float(min_power)),
)
),
stop_threshold_w=float(
config_entry.options.get(
CONF_STOP_THRESHOLD_W,
float(min_power) * 0.6 if float(min_power) > 0 else 2.0,
)
),
min_duration_ratio=float(
config_entry.options.get(
CONF_PROFILE_MATCH_MIN_DURATION_RATIO,
DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO_BY_DEVICE.get(
self.device_type, DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO
),
)
),
match_interval=int(
config_entry.options.get(
CONF_PROFILE_MATCH_INTERVAL, DEFAULT_PROFILE_MATCH_INTERVAL
)
),
anti_wrinkle_enabled=bool(
config_entry.options.get(
CONF_ANTI_WRINKLE_ENABLED, DEFAULT_ANTI_WRINKLE_ENABLED
)
),
anti_wrinkle_max_power=float(
config_entry.options.get(
CONF_ANTI_WRINKLE_MAX_POWER, DEFAULT_ANTI_WRINKLE_MAX_POWER
)
),
anti_wrinkle_max_duration=float(
config_entry.options.get(
CONF_ANTI_WRINKLE_MAX_DURATION, DEFAULT_ANTI_WRINKLE_MAX_DURATION
)
),
anti_wrinkle_exit_power=float(
config_entry.options.get(
CONF_ANTI_WRINKLE_EXIT_POWER, DEFAULT_ANTI_WRINKLE_EXIT_POWER
)
),
delay_detect_enabled=bool(
config_entry.options.get(
CONF_DELAY_START_DETECT_ENABLED, DEFAULT_DELAY_START_DETECT_ENABLED
)
),
delay_confirm_seconds=float(
config_entry.options.get(
CONF_DELAY_CONFIRM_SECONDS, DEFAULT_DELAY_CONFIRM_SECONDS
)
),
delay_timeout_seconds=float(
config_entry.options.get(
CONF_DELAY_TIMEOUT_HOURS, DEFAULT_DELAY_TIMEOUT_HOURS
)
) * 3600.0,
)
self._config = config
def profile_matcher_wrapper(
readings: list[tuple[datetime, float]],
) -> tuple[str | None, float, float, str | None]:
"""Wraps profile store matching logic with detector callback signature.
Returns: None (async offload)
"""
# Manual program override
if self._manual_program_active and self._current_program:
elapsed_seconds = 0.0
if len(readings) > 1:
elapsed_seconds = max(
0.0,
(readings[-1][0] - readings[0][0]).total_seconds(),
)
expected_duration = float(self._matched_profile_duration or 0.0)
manual_phase = self.profile_store.check_phase_match(
self._current_program,
elapsed_seconds,
)
return (
self._current_program,
1.0,
expected_duration,
manual_phase or "Manual",
)
if not readings:
return (None, 0.0, 0.0, None)
# Snapshotted for thread safety indirectly by task logic
# We don't need a wrapper task if we unify with _update_estimates matching
# but for now let's keep the detector callback as a trigger
self.hass.async_create_task(self._async_perform_combined_matching(readings))
return (None, 0.0, 0.0, None)
self.detector = CycleDetector(
config,
self._on_state_change,
self._on_cycle_end,
profile_matcher=profile_matcher_wrapper,
device_name=config_entry.title,
)
self._remove_listener = None
self._remove_external_trigger_listener = None # External cycle end trigger
self._remove_watchdog = None
self._watchdog_interval = int(
config_entry.options.get(CONF_WATCHDOG_INTERVAL, DEFAULT_WATCHDOG_INTERVAL)
)
self._match_persistence = int(
config_entry.options.get(CONF_MATCH_PERSISTENCE, DEFAULT_MATCH_PERSISTENCE)
)
self._sampling_interval = float(
config_entry.options.get(CONF_SAMPLING_INTERVAL, DEFAULT_SAMPLING_INTERVAL)
)
self._noise_events_threshold = int(
config_entry.options.get(
CONF_AUTO_TUNE_NOISE_EVENTS_THRESHOLD,
DEFAULT_AUTO_TUNE_NOISE_EVENTS_THRESHOLD,
)
)
self._current_program: str = "off"
self._time_remaining: float | None = None
self._total_duration: float | None = None
self._last_total_duration_update: datetime | None = None
self._cycle_progress: float = 0.0
self._smoothed_progress: float = 0.0 # Smoothed progress tracking for EMA
self._cycle_completed_time: datetime | None = None # Track when cycle finished
self._progress_reset_delay: int = int(
progress_reset_delay
) # Reset progress after idle
self._last_reading_time: datetime | None = None
self._current_power: float = 0.0
self._last_estimate_time: datetime | None = None
self._last_match_ambiguous: bool = False
self._matched_profile_duration: float | None = None
self._last_match_confidence: float = 0.0
# Sample interval tracking (seconds) for adaptive timing
# Profile matching duration tolerance (0.25 = ±25%)
self._profile_duration_tolerance: float = float(
config_entry.options.get("profile_duration_tolerance", 0.25)
)
self._remove_maintenance_scheduler = None
self._profile_sample_repair_stats: dict[str, int] | None = None
self._last_suggestion_update: datetime | None = None
# Pump Monitor state
self._pump_stuck_duration: int = int(
config_entry.options.get(CONF_PUMP_STUCK_DURATION, DEFAULT_PUMP_STUCK_DURATION)
)
self._pump_stuck: bool = False # True once the stuck threshold has fired for this cycle
self._manual_program_active: bool = False
self._notified_start: bool = False
self._notified_pre_completion: bool = False
self._last_match_result: Any = None # Stores full MatchResult object
self._score_history: dict[str, list[float]] = {} # Tracks recent scores for trend analysis
self._match_persistence_counter: dict[str, int] = {} # Tracks consecutive matches
self._unmatch_persistence_counter: int = 0 # Tracks consecutive low-confidence matches
self._current_match_candidate: str | None = None # Pending profile name
async def _async_perform_combined_matching(
self, readings: list[tuple[datetime, float]]
) -> None:
"""PRIMARY matching task: Updates both Manager and Detector using best method."""
self._logger.debug(
"Matching trigger: readings=%d, task_exists=%s",
len(readings) if readings else 0,
getattr(self, "_matching_task", None) is not None
)
# Prevent concurrent matching tasks
current_task = self._matching_task
if current_task is not None and not current_task.done():
self._logger.debug("Matching skipped: previous task still running")
return
try:
if not readings:
self._logger.debug("Matching skipped: no readings")
return
self._matching_task = self.hass.async_create_task(self._async_do_perform_matching(readings))
except Exception as e:
self._logger.error("Perform combined matching trigger failed: %s", e)
async def _async_do_perform_matching(self, readings: list[tuple[datetime, float]]) -> None:
"""Inner task to handle actual matching logic."""
try:
end_time = readings[-1][0]
start_time = readings[0][0]
current_duration = (end_time - start_time).total_seconds()
# 1. RUN BETTER ASYNC MATCHING
result = await self.profile_store.async_match_profile(
readings,
current_duration
)
# 2. UPDATE MANAGER STATE (Estimates, Program Name, etc.)
self._last_match_result = result
self._last_match_ambiguous = result.is_ambiguous
profile_name = result.best_profile
confidence = result.confidence
matched_duration = result.expected_duration
phase_name = result.matched_phase
# --- Switching Logic (Temporal Persistence) ---
should_switch = False
switch_reason = ""
# Identify current program score from results
current_program_score = 0.0
for c in result.candidates:
if c.get("name") == self._current_program:
current_program_score = c.get("score", 0.0)
break
# CASE: Divergence Detection (Score Drop)
# If current matched program has a significant drop from its own peak score,
# we should consider unmatching it even if it's still the "best" candidate.
if (
self._current_program not in ("detecting...", "off", "starting", "unknown")
and profile_name == self._current_program
):
history: list[float] = self._score_history.get(self._current_program, [])
if len(history) > 3:
peak_score = max(history)
# If score drops by more than 40% from peak AND is below threshold, unmatch.
# This catches divergence faster than waiting for fixed unmatch_threshold.
if confidence < peak_score * (1.0 - DEFAULT_MATCH_REVERT_RATIO):
self._unmatch_persistence_counter += 1
if self._unmatch_persistence_counter >= self._match_persistence:
self._current_program = "detecting..."
self._matched_profile_duration = None
self._unmatch_persistence_counter = 0
self._logger.info(
"Divergence detected for profile '%s' (confidence %.3f < 60%% of peak %.3f). "
"Reverting to detection.",
profile_name, confidence, peak_score
)
# Reset profile_name so Case 3 doesn't re-trigger
profile_name = "detecting..."
# Update persistence for the best profile
if profile_name and profile_name != "detecting...":
self._match_persistence_counter[profile_name] = self._match_persistence_counter.get(profile_name, 0) + 1
# Check if this is the same candidate as before
if profile_name != self._current_match_candidate:
# Reset counter for old candidate if it wasn't locked in
self._current_match_candidate = profile_name
self._match_persistence_counter[profile_name] = 1
else:
self._current_match_candidate = None
is_persistent = profile_name and self._match_persistence_counter.get(profile_name, 0) >= self._match_persistence
# Case 1: Initial Match from "detecting..."
if (
profile_name
and confidence >= 0.15
and (not result.is_ambiguous or is_persistent)
and (not self._matched_profile_duration or self._current_program == "detecting...")
):
if is_persistent:
should_switch = True
switch_reason = f"initial_match (persistent {self._match_persistence_counter[profile_name]}x)"
else:
self._logger.debug(
"Match persistence: %s at %d/%d matches. Stay at detecting...",
profile_name, self._match_persistence_counter.get(profile_name, 0), self._match_persistence
)
# Case 2: Mid-cycle override (different profile)
elif (
profile_name
and self._current_program != profile_name
and self._current_program not in ("detecting...", "off", "starting", "unknown")
):
# High Confidence Override: Bypass persistence if match is VERY strong
if confidence > 0.8 and (confidence - current_program_score) > 0.15:
should_switch = True
switch_reason = f"high_confidence_override ({confidence:.3f} vs {current_program_score:.3f})"
# Normal Switch: Requires persistence AND either better score + trend
elif is_persistent:
if confidence > current_program_score and self._analyze_trend(profile_name):
# Add a minimum score gap for mid-cycle switching (0.05) to prevent flapping
if (confidence - current_program_score) > 0.05:
should_switch = True
switch_reason = f"positive_trend_persistent ({confidence:.3f} > {current_program_score:.3f})"
# Case 3: Unmatching (confidence drop)
elif (
self._current_program not in ("detecting...", "off", "starting", "unknown")
and profile_name == self._current_program
and confidence < self._unmatch_threshold
):
self._unmatch_persistence_counter += 1
is_unmatch_persistent = self._unmatch_persistence_counter >= self._match_persistence
if is_unmatch_persistent:
self._current_program = "detecting..."
self._matched_profile_duration = None
self._unmatch_persistence_counter = 0
self._logger.info(
"Unmatched profile '%s' (confidence %.3f < threshold %.3f persistent %dx). "
"Reverting to detection.",
profile_name,
confidence,
self._unmatch_threshold,
self._match_persistence
)
else:
self._logger.debug(
"Unmatch persistence: %s at %d/%d low-confidence matches. Stay at %s...",
profile_name, self._unmatch_persistence_counter, self._match_persistence, profile_name
)
# Reset unmatch counter if confidence is healthy
# AND we didn't just detect a divergence
elif (
profile_name == self._current_program
and confidence >= self._unmatch_threshold
and not (len(self._score_history.get(self._current_program, [])) > 3 and confidence < max(self._score_history[self._current_program]) * (1.0 - DEFAULT_MATCH_REVERT_RATIO))
):
self._unmatch_persistence_counter = 0
if should_switch:
if profile_name is None:
self._current_program = "detecting..."
else:
self._current_program = profile_name
self._last_match_confidence = confidence
self._unmatch_persistence_counter = 0 # Reset on switch
if profile_name in self._match_persistence_counter:
self._match_persistence_counter[profile_name] = self._match_persistence # Lock it in
avg_duration = float(matched_duration)
self._matched_profile_duration = avg_duration if avg_duration > 0 else None
self._logger.info(
"Switching to profile '%s' (reason: %s). Expected duration: %.0fs (%smin)",
profile_name, switch_reason, avg_duration, int(avg_duration / 60),
)
elif profile_name == self._current_program:
# Same program, but update confidence for sensors
self._last_match_confidence = confidence
elif not self._matched_profile_duration:
self._current_program = "detecting..."
self._last_estimate_time = dt_util.now()
# Update score history for all candidates to track trends
for cand in result.candidates:
cname = cand.get("name")
if cname:
history = self._score_history.setdefault(cname, [])
history.append(float(cand.get("score", 0.0)))
if len(history) > 20:
history.pop(0)
# Note: _update_remaining_only() and notify move to end of flow
# 3. UPDATE DETECTOR (Envelopes, Deferral, State Transitions)
current_matched = self.detector.matched_profile
verified_pause = getattr(self.detector, "_verified_pause", False)
current_power = readings[-1][1] if readings else 0.0
# --- Envelope Verification for Mismatches & Pauses ---
# ALWAYS check alignment if we have a match and power is low,
# to confirm if this is a legitimate pause or a mismatch.
stop_thresh = float(self.detector.config.stop_threshold_w)
if current_matched and current_power < stop_thresh:
formatted = power_data_to_offsets(cast(list[list[Any] | tuple[Any, ...]], readings))
try:
profile_store_any = cast(Any, self.profile_store)
verify_alignment = profile_store_any.async_verify_alignment
is_confirmed, mapped_time, _ = (
await verify_alignment(current_matched, formatted)
)
except Exception as e: # pylint: disable=broad-exception-caught
self._logger.error(
"Alignment verification crashed for profile %s: %s",
current_matched, e, exc_info=True
)
is_confirmed = False
mapped_time = 0.0
if is_confirmed:
if not verified_pause:
self._logger.info(
"Envelope verified expected low power phase for %s. Enabling verified pause.",
current_matched
)
verified_pause = True
# Smart Termination within Envelope block
try:
profile = self.profile_store.get_profile(current_matched)
if profile:
avg_dur = profile.get("avg_duration", 0)
if avg_dur > 0 and (mapped_time / avg_dur) > 0.95:
verified_pause = False
self._logger.info("Smart Termination: Near end of profile. Releasing pause lock.")
except Exception as e:
self._logger.debug("Smart Termination alignment verification failed: %s", e)
else:
if verified_pause:
self._logger.info(
"Envelope indicates UNEXPECTED low power for %s. Disabling verified pause.",
current_matched
)
verified_pause = False
# --- High Power Clear ---
stop_threshold = getattr(self.detector.config, "stop_threshold_w", 5.0)
if current_power > stop_threshold * 10:
verified_pause = False
# --- Consistency Override ---
# If envelope verified or mismatched, ensure manager program matches
if profile_name != self._current_program and (verified_pause or result.is_confident_mismatch):
if profile_name:
self._current_program = profile_name
self._last_match_confidence = confidence
# Try to fetch duration if we switched back to matched
try:
prof = self.profile_store.get_profile(profile_name)
if prof:
self._matched_profile_duration = float(prof.get("avg_duration", 0))
except Exception as e:
self._logger.debug("Failed to fetch profile duration on switch: %s", e)
else:
self._current_program = "detecting..."
self._matched_profile_duration = None
# --- HEURISTICS (Descriptive Phases) ---
if not phase_name:
if self.device_type == "dishwasher" and self.detector.is_waiting_low_power():
phase_name = "Drying"
elif self.device_type == "washing_machine" and current_power > 200:
phase_name = "Spinning"
elif self.device_type == "washing_machine" and self.detector.is_waiting_low_power():
phase_name = "Rinsing/Soaking"
elif self.device_type == "ev":
if current_power > 100:
phase_name = "Charging"
elif self.detector.is_waiting_low_power():
phase_name = "Maintenance"
# Push updates to detector
self.detector.set_verified_pause(verified_pause)
self.detector.update_match(
(profile_name, confidence, matched_duration, phase_name, result.is_confident_mismatch)
)
# --- LOGGING (Unified) ---
self._logger.info(
"Profile match attempt: name=%s, confidence=%.3f, duration=%.0fs, samples=%d",
profile_name, confidence, current_duration, len(readings),
)
self._update_remaining_only()
# --- START NOTIFICATION LOGIC ---
# Fallback for restart-recovery: fires only if the immediate notification in
# _on_state_change was missed (e.g., HA restarted mid-cycle before snapshot).
if not getattr(self, "_notified_start", False):
if self._notify_fire_events and not self._start_event_fired:
self.hass.bus.async_fire(
EVENT_CYCLE_STARTED,
{
"entry_id": self.entry_id,
"device_name": self.config_entry.title,
"device_type": self.device_type,
"program": self._current_program,
"start_time": (
self._cycle_start_time or dt_util.now()
).isoformat(),
},
)
self._start_event_fired = True
if self._notify_start_services or self._notify_actions:
msg_template = self.config_entry.options.get(
CONF_NOTIFY_START_MESSAGE, DEFAULT_NOTIFY_START_MESSAGE
)
msg = self._safe_format_template(
msg_template,
fallback_template=DEFAULT_NOTIFY_START_MESSAGE,
device=self.config_entry.title,
program=self._current_program,
)
self._dispatch_notification(
msg,
event_type=NOTIFY_EVENT_START,
extra_vars={
"program": self._current_program,
"tag": self._lifecycle_tag,
},
)
self._notified_start = True
self._logger.info("Sent start notification for program '%s'", self._current_program)
# Ensure pre-completion notifications never precede cycle-start signaling.
self._check_pre_completion_notification()
self._check_live_progress_notification()
self._notify_update()
except Exception as e:
self._logger.error("Perform combined matching failed: %s", e, exc_info=True)
@property
def top_candidates(self) -> list[dict[str, Any]]:
"""Return a lightweight list of top candidates from the last match."""
if not self._last_match_result:
return []
# Get raw list from ranking (best) or candidates
raw_list: list[dict[str, Any]] = []
if hasattr(self._last_match_result, "ranking") and self._last_match_result.ranking:
raw_list = self._last_match_result.ranking
elif hasattr(self._last_match_result, "candidates"):
raw_list = self._last_match_result.candidates
# SANITIZE: Remove heavy power arrays before sending to Home Assistant attributes
sanitized: list[dict[str, Any]] = []
for cand in raw_list[:5]:
sanitized.append({
"name": cand.get("name"),
"score": round(float(cand.get("score", 0.0)), 3),
"profile_duration": cand.get("profile_duration"),
# Explicitly exclude "current" and "sample" keys which are big lists
})
return sanitized
@property
def phase_description(self) -> str:
"""Return a description of the current phase."""
if self._last_match_result and self._last_match_result.matched_phase:
return self._last_match_result.matched_phase
if self.detector.sub_state:
return self.detector.sub_state
return self.detector.state
@property
def match_ambiguity(self) -> bool:
"""Return True if the last match was ambiguous."""
if self._last_match_result and hasattr(self._last_match_result, "is_ambiguous"):
return self._last_match_result.is_ambiguous
return False
# Note: last_match_details property is defined later in the class
# It returns MatchResult from _last_match_result
async def _attempt_state_restoration(self) -> None:
"""Attempt to restore active cycle state from storage."""
active_snapshot = self.profile_store.get_active_cycle()
# Check current power state first
state = self.hass.states.get(self.power_sensor_entity_id)
current_power = 0.0
power_is_valid = False
if state and state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE):
try:
current_power = float(state.state)
power_is_valid = True
except (ValueError, TypeError):
# Power sensor state is not numeric during restoration; treat as 0W
self._logger.debug(
"Power sensor %s state %r is not numeric during restoration; "
"treating as 0W and not restoring by power",
self.power_sensor_entity_id,
getattr(state, "state", None),
)
should_restore = False
active_snapshot_to_restore: dict[str, Any] | None = (
active_snapshot if isinstance(active_snapshot, dict) else None
)
# Helper to check if a snapshot is viable
def is_viable_restore(last_save_time: datetime) -> bool:
now = dt_util.now()
# Handle timezone mismatch gracefully
if last_save_time.tzinfo is None:
# Assume naive means local system time, convert to aware
last_save_time = last_save_time.replace(tzinfo=now.tzinfo)
age = (now - last_save_time).total_seconds()
# Unconditional restore window (30 mins)
if age < 1800:
return True
# Extended window if power is confirmed HIGH (60 mins)
if (
age < 3600
and power_is_valid
and current_power >= self._config.min_power
):
return True
return False
last_save = self.profile_store.get_last_active_save()
if last_save and last_save.tzinfo is None:
# Normalize naive legacy timestamps to system time
last_save = last_save.replace(tzinfo=dt_util.now().tzinfo)
if active_snapshot_to_restore is not None and last_save and is_viable_restore(last_save):
should_restore = True
age = (dt_util.now() - last_save).total_seconds()
age = (dt_util.now() - last_save).total_seconds()
self._logger.info(
"Found recently saved active cycle (last_save=%s, age=%.0fs), restoring...",
last_save,
age
)
# strict extension logic unless the user wants to enforce it.
active_snapshot_to_restore["sub_state"] = (
active_snapshot_to_restore.get("sub_state") or "Restored"
)
# NOTE: We disable dynamic min duration enforcement on recovery since we
# might have missed data
active_snapshot_to_restore["dynamic_min_duration"] = None
# FALLBACK: Resurrection Logic
if not should_restore:
past_cycles = self.profile_store.get_past_cycles()
if past_cycles:
last_cycle = past_cycles[-1]
last_end_str = last_cycle.get("end_time")
if last_end_str:
last_end = dt_util.parse_datetime(last_end_str)
if last_end:
gap = (dt_util.now() - last_end).total_seconds()
is_recent = gap < 1200 # 20 mins
status = last_cycle.get("status")
if is_recent and status != "completed":
self._logger.info(
"Found recent interrupted cycle in history "
"(id=%s, gap=%.0fs). Resurrecting...",
last_cycle["id"],
gap,
)
try:
power_data = decompress_power_data(last_cycle)
if power_data:
active_snapshot_to_restore = {
# Reconstruct basic running state
"state": "running",
"sub_state": "Resurrected",
"current_cycle_start": last_cycle["start_time"],
"last_active_time": last_cycle["end_time"],
"low_power_start": None,
"cycle_max_power": (
max([p for _, p in power_data])
if power_data
else 0
),
"power_readings": power_data,
"ma_buffer": (
[p for _, p in power_data[-10:]]
if power_data
else []
),
"end_condition_count": 0,
"extension_count": 0,
"dynamic_min_duration": None,
"matched_profile": last_cycle.get(
"profile_name"
),
}
should_restore = True
past_cycles.pop()
await self.profile_store.async_save()
except Exception as e:
self._logger.error("Failed to resurrect cycle: %s", e)
if should_restore and active_snapshot_to_restore:
try:
self.detector.restore_state_snapshot(active_snapshot_to_restore)
# Restore if in any active state (Running, Paused, Ending)
if self.detector.state in (STATE_RUNNING, STATE_PAUSED, STATE_ENDING):
# Restore manual program flag if present
self._manual_program_active = active_snapshot_to_restore.get(
"manual_program", False
)
# If we restored into a low-power state, ensure we don't
# immediately quit. For now we just log this; the cycle
# detector's off_delay will handle actual shutdown.
if power_is_valid and current_power < self._config.min_power:
self._logger.debug(
"Restored active cycle in low-power state "
"(power=%.2fW < min_power=%.2fW); waiting for "
"detector off_delay before marking as finished",
current_power,
self._config.min_power,
)
if self.detector.matched_profile:
self._current_program = self.detector.matched_profile
self._logger.info(
"Restored/Resurrected washer cycle with profile: %s",
self._current_program,
)
else:
self._current_program = "detecting..."
# Restore persisted start-notification/event flags from snapshot.
self._notified_start = bool(
active_snapshot_to_restore.get("notified_start", False)
)
self._start_event_fired = bool(
active_snapshot_to_restore.get("start_event_fired", False)
)
# Restore user-pause state from snapshot.
self._is_user_paused = bool(
active_snapshot_to_restore.get("is_user_paused", False)
)
_pause_start_raw = active_snapshot_to_restore.get("user_pause_start")
self._user_pause_start = (
dt_util.parse_datetime(_pause_start_raw)
if isinstance(_pause_start_raw, str) and _pause_start_raw
else None
)
self._total_user_paused_seconds = float(
active_snapshot_to_restore.get("total_user_paused_seconds", 0.0)
)
self._start_watchdog()
else:
await self.profile_store.async_clear_active_cycle()
except Exception as err:
self._logger.warning("Failed to restore active cycle: %s, clearing", err)
await self.profile_store.async_clear_active_cycle()
else:
if last_save:
age = (dt_util.now() - last_save).total_seconds()
self._logger.info("Active cycle too stale (age=%.0fs), clearing", age)
await self.profile_store.async_clear_active_cycle()
async def async_setup(self) -> None:
"""Set up the manager."""
await self.profile_store.async_load()
# Apply configurable duration tolerance to profile store
try:
self.profile_store.set_duration_tolerance(self._profile_duration_tolerance)
self.profile_store.set_retention_limits(
max_past_cycles=int(
self.config_entry.options.get(
CONF_MAX_PAST_CYCLES, DEFAULT_MAX_PAST_CYCLES
)
),
max_full_traces_per_profile=int(
self.config_entry.options.get(
CONF_MAX_FULL_TRACES_PER_PROFILE,
DEFAULT_MAX_FULL_TRACES_PER_PROFILE,
)
),
max_full_traces_unlabeled=int(
self.config_entry.options.get(
CONF_MAX_FULL_TRACES_UNLABELED,
DEFAULT_MAX_FULL_TRACES_UNLABELED,
)
),
)
except Exception:
pass
# Repair broken sample_cycle_id references (can happen after aggressive retention)
try:
stats = await self.profile_store.async_repair_profile_samples()
self._profile_sample_repair_stats = stats
if stats.get("profiles_repaired", 0) or stats.get(
"cycles_labeled_as_sample", 0
):
self._logger.warning(
"Repaired profile sample references for %s: %s",
self.entry_id,
stats,
)
await self.profile_store.async_save()
except Exception:
self._logger.exception(
"Failed repairing profile sample references for %s", self.entry_id
)
# Subscribe to power sensor updates
self._remove_listener = async_track_state_change_event(
self.hass, [self.power_sensor_entity_id], self._async_power_changed
)
# Attempt to restore state (BEFORE starting listener)
await self._attempt_state_restoration()
# Restore last cycle end time to ensure ghost cycle suppression works after restart
try:
cycles = self.profile_store.get_past_cycles()
if cycles:
# Find last completed cycle with a valid end time
for cycle in reversed(cycles):
if cycle.get("end_time") and cycle.get("status") == "completed":
ts = dt_util.parse_datetime(cycle["end_time"])
if ts:
self._last_cycle_end_time = ts
self._logger.debug("Restored last cycle end time: %s", ts)
break
except Exception: # pylint: disable=broad-exception-caught
self._logger.debug("Failed to restore last cycle end time")
# Load recorder state
await self.recorder.async_load()
# Force initial update from current state (in case it's already stable)
state = self.hass.states.get(self.power_sensor_entity_id)
if state and state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE):
try:
power = float(state.state)
now = dt_util.now()
self.detector.process_reading(power, now)
except (ValueError, TypeError):
pass
# Trigger migration/compression of old cycle format
# This is safe to run repeatedly (it skips already compressed cycles)
await self.profile_store.async_migrate_cycles_to_compressed()
# Backfill match_confidence for labeled cycles that predate the field
self.hass.async_create_task(
self.profile_store.async_backfill_match_confidence()
)
# Subscribe to external cycle end trigger (if enabled)
await self._setup_external_end_trigger()
# Subscribe to door sensor (if configured)
await self._setup_door_sensor_listener()
# Subscribe to person presence changes for notification gating
await self._setup_notify_people_listener()
def _load_notify_services(self, config_entry: ConfigEntry) -> None:
"""Load notification service lists, migrating legacy single-service config."""
self._notify_start_services = list(config_entry.options.get(CONF_NOTIFY_START_SERVICES, []) or [])
self._notify_finish_services = list(config_entry.options.get(CONF_NOTIFY_FINISH_SERVICES, []) or [])
self._notify_live_services = list(config_entry.options.get(CONF_NOTIFY_LIVE_SERVICES, []) or [])
# Backward compat: migrate old single notify_service + notify_events to new per-event lists
if not (self._notify_start_services or self._notify_finish_services or self._notify_live_services):
_old_svc = config_entry.options.get(CONF_NOTIFY_SERVICE, "")
_old_events = list(config_entry.options.get(CONF_NOTIFY_EVENTS, []) or [])
if _old_svc:
if not _old_events or NOTIFY_EVENT_START in _old_events:
self._notify_start_services = [_old_svc]
if not _old_events or NOTIFY_EVENT_FINISH in _old_events:
self._notify_finish_services = [_old_svc]
if not _old_events or NOTIFY_EVENT_LIVE in _old_events:
self._notify_live_services = [_old_svc]
async def async_reload_config(self, config_entry: ConfigEntry) -> None:
"""
Reload configuration options without interrupting running cycle detection.
Updates detector config in-place.
Handles Power Sensor entity change by reconnecting listener.
"""
self._logger.info("Reloading configuration for %s", self.entry_id)
# Replace reference
self.config_entry = config_entry
# Check if power sensor changed
new_sensor = config_entry.options.get(
CONF_POWER_SENSOR, config_entry.data.get(CONF_POWER_SENSOR)
)
if new_sensor and new_sensor != self.power_sensor_entity_id:
# Block sensor changes when a cycle is active to prevent inconsistent state
d_state = self.detector.state
self._logger.debug(
"Reloading config: detector.state=%r (type=%s), RUNNING=%r",
d_state,
type(d_state),
STATE_RUNNING,
)
if d_state == STATE_RUNNING:
self._logger.warning(
"Cannot change power sensor from %s to %s while a cycle "
"is active. Please wait for the current cycle to complete "
"before changing the power sensor.",
self.power_sensor_entity_id,
new_sensor,
)
# Skip sensor change but continue with other config updates
return
self._logger.info(
"Power sensor changed: %s -> %s", self.power_sensor_entity_id, new_sensor
)
self.power_sensor_entity_id = new_sensor
# Remove old listener
if self._remove_listener:
self._remove_listener()
# Attach new listener
self._remove_listener = async_track_state_change_event(
self.hass, [self.power_sensor_entity_id], self._async_power_changed
)
# Force update from new sensor
state = self.hass.states.get(self.power_sensor_entity_id)
if state and state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE):
try:
power = float(state.state)
self.detector.process_reading(power, dt_util.now())
except ValueError:
self._logger.debug(
"Initial power value for %s after config reload is not numeric: %r",
self.power_sensor_entity_id,
state.state,
)
# Update device type
self.device_type = config_entry.options.get(
CONF_DEVICE_TYPE,
config_entry.data.get(CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE),
)
# Propagate to learning pipeline (captured at construction time)
self.learning_manager.device_type = self.device_type
self.learning_manager.suggestion_engine.device_type = self.device_type
# Update detector config in-place
old_min_power = self.detector.config.min_power
old_off_delay = self.detector.config.off_delay
old_smoothing = self.detector.config.smoothing_window
old_interrupted_min = self.detector.config.interrupted_min_seconds
old_abrupt_drop_watts = self.detector.config.abrupt_drop_watts
old_abrupt_drop_ratio = self.detector.config.abrupt_drop_ratio
old_abrupt_high_load = self.detector.config.abrupt_high_load_factor
# Get new values from config
new_min_power = float(
config_entry.options.get(CONF_MIN_POWER, DEFAULT_MIN_POWER)
)
new_off_delay = int(config_entry.options.get(CONF_OFF_DELAY, DEFAULT_OFF_DELAY))
new_smoothing = int(
config_entry.options.get(CONF_SMOOTHING_WINDOW, DEFAULT_SMOOTHING_WINDOW)
)
new_interrupted_min = int(
config_entry.options.get(
CONF_INTERRUPTED_MIN_SECONDS, DEFAULT_INTERRUPTED_MIN_SECONDS
)
)
new_abrupt_drop_watts = float(
config_entry.options.get(CONF_ABRUPT_DROP_WATTS, DEFAULT_ABRUPT_DROP_WATTS)
)
new_abrupt_drop_ratio = float(
config_entry.options.get(CONF_ABRUPT_DROP_RATIO, DEFAULT_ABRUPT_DROP_RATIO)
)
self.detector.config.match_interval = int(
config_entry.options.get(
CONF_PROFILE_MATCH_INTERVAL, DEFAULT_PROFILE_MATCH_INTERVAL
)
)
self.profile_store.dtw_bandwidth = float(
config_entry.options.get(CONF_DTW_BANDWIDTH, DEFAULT_DTW_BANDWIDTH)
)
new_abrupt_high_load = float(
config_entry.options.get(
CONF_ABRUPT_HIGH_LOAD_FACTOR, DEFAULT_ABRUPT_HIGH_LOAD_FACTOR
)
)
# Device default
dev_def = DEVICE_COMPLETION_THRESHOLDS.get(
self.device_type, DEFAULT_COMPLETION_MIN_SECONDS
)
new_completion_min = int(
config_entry.options.get(CONF_COMPLETION_MIN_SECONDS, dev_def)
)
new_start_threshold = float(
config_entry.options.get(
CONF_START_DURATION_THRESHOLD, DEFAULT_START_DURATION_THRESHOLD
)
)
new_running_dead_zone = int(
config_entry.options.get(CONF_RUNNING_DEAD_ZONE, DEFAULT_RUNNING_DEAD_ZONE)
)
new_end_repeat_count = int(
config_entry.options.get(CONF_END_REPEAT_COUNT, DEFAULT_END_REPEAT_COUNT)
)
# Power Hysteresis Thresholds
new_start_threshold_w = float(
config_entry.options.get(
CONF_START_THRESHOLD_W,
float(new_min_power) + max(1.0, 0.1 * float(new_min_power)),
)
)
new_stop_threshold_w = float(
config_entry.options.get(
CONF_STOP_THRESHOLD_W,
max(0.0, float(new_min_power) - max(0.5, 0.1 * float(new_min_power))),
)
)
new_start_energy = float(
config_entry.options.get(
CONF_START_ENERGY_THRESHOLD,
DEFAULT_START_ENERGY_THRESHOLDS_BY_DEVICE.get(self.device_type, 0.2)
)
)
new_end_energy = float(
config_entry.options.get(CONF_END_ENERGY_THRESHOLD, DEFAULT_END_ENERGY_THRESHOLD)
)
new_anti_wrinkle_enabled = bool(
config_entry.options.get(
CONF_ANTI_WRINKLE_ENABLED, DEFAULT_ANTI_WRINKLE_ENABLED
)
)
new_anti_wrinkle_max_power = float(
config_entry.options.get(
CONF_ANTI_WRINKLE_MAX_POWER, DEFAULT_ANTI_WRINKLE_MAX_POWER
)
)
new_anti_wrinkle_max_duration = float(
config_entry.options.get(
CONF_ANTI_WRINKLE_MAX_DURATION, DEFAULT_ANTI_WRINKLE_MAX_DURATION
)
)
new_anti_wrinkle_exit_power = float(
config_entry.options.get(
CONF_ANTI_WRINKLE_EXIT_POWER, DEFAULT_ANTI_WRINKLE_EXIT_POWER
)
)
new_delay_detect_enabled = bool(
config_entry.options.get(
CONF_DELAY_START_DETECT_ENABLED, DEFAULT_DELAY_START_DETECT_ENABLED
)
)
new_delay_confirm_seconds = float(
config_entry.options.get(
CONF_DELAY_CONFIRM_SECONDS, DEFAULT_DELAY_CONFIRM_SECONDS
)
)
new_delay_timeout_seconds = float(
config_entry.options.get(
CONF_DELAY_TIMEOUT_HOURS, DEFAULT_DELAY_TIMEOUT_HOURS
)
) * 3600.0
# Apply all detector config updates
self.detector.config.min_power = new_min_power
self.detector.config.off_delay = new_off_delay
self.detector.config.smoothing_window = new_smoothing
self.detector.config.interrupted_min_seconds = new_interrupted_min
self.detector.config.abrupt_drop_watts = new_abrupt_drop_watts
self.detector.config.abrupt_drop_ratio = new_abrupt_drop_ratio
self.detector.config.abrupt_high_load_factor = new_abrupt_high_load
self.detector.config.completion_min_seconds = new_completion_min
self.detector.config.start_duration_threshold = new_start_threshold
self.detector.config.running_dead_zone = new_running_dead_zone
self.detector.config.end_repeat_count = new_end_repeat_count
self.detector.config.start_threshold_w = new_start_threshold_w
self.detector.config.stop_threshold_w = new_stop_threshold_w
self.detector.config.start_energy_threshold = new_start_energy
self.detector.config.end_energy_threshold = new_end_energy
self.detector.config.anti_wrinkle_enabled = new_anti_wrinkle_enabled
self.detector.config.anti_wrinkle_max_power = new_anti_wrinkle_max_power
self.detector.config.anti_wrinkle_max_duration = new_anti_wrinkle_max_duration
self.detector.config.anti_wrinkle_exit_power = new_anti_wrinkle_exit_power
self.detector.config.delay_detect_enabled = new_delay_detect_enabled
self.detector.config.delay_confirm_seconds = new_delay_confirm_seconds
self.detector.config.delay_timeout_seconds = new_delay_timeout_seconds
# Pump Monitor setting
self._pump_stuck_duration = int(
config_entry.options.get(CONF_PUMP_STUCK_DURATION, DEFAULT_PUMP_STUCK_DURATION)
)
if (
old_min_power != new_min_power
or old_off_delay != new_off_delay
or old_smoothing != new_smoothing
or old_interrupted_min != new_interrupted_min
or old_abrupt_drop_watts != new_abrupt_drop_watts
or old_abrupt_drop_ratio != new_abrupt_drop_ratio
or old_abrupt_high_load != new_abrupt_high_load
):
self._logger.info(
"Updated detector config: min_power %.1fW→%.1fW, off_delay %ds→%ds, "
"smoothing %d%d, interrupted_min %ds→%ds, abrupt_drop %.0fW→%.0fW, "
"abrupt_ratio %.2f%.2f, high_load %.1f%.1f",
old_min_power,
new_min_power,
old_off_delay,
new_off_delay,
old_smoothing,
new_smoothing,
old_interrupted_min,
new_interrupted_min,
old_abrupt_drop_watts,
new_abrupt_drop_watts,
old_abrupt_drop_ratio,
new_abrupt_drop_ratio,
old_abrupt_high_load,
new_abrupt_high_load,
)
# Update profile matching parameters
old_min_ratio, old_max_ratio = self.profile_store.get_duration_ratio_limits()
new_min_ratio = float(
config_entry.options.get(
CONF_PROFILE_MATCH_MIN_DURATION_RATIO,
DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO,
)
)
new_max_ratio = float(
config_entry.options.get(
CONF_PROFILE_MATCH_MAX_DURATION_RATIO,
DEFAULT_PROFILE_MATCH_MAX_DURATION_RATIO,
)
)
if old_min_ratio != new_min_ratio or old_max_ratio != new_max_ratio:
self.profile_store.set_duration_ratio_limits(
min_ratio=new_min_ratio, max_ratio=new_max_ratio
)
self._logger.info(
"Updated duration ratios: min %.2f%.2f, max %.2f%.2f",
old_min_ratio,
new_min_ratio,
old_max_ratio,
new_max_ratio,
)
# Update match interval
old_interval = self._profile_match_interval
new_interval = int(
config_entry.options.get(
CONF_PROFILE_MATCH_INTERVAL, DEFAULT_PROFILE_MATCH_INTERVAL
)
)
if old_interval != new_interval:
self._profile_match_interval = new_interval
self._logger.info("Updated match interval: %ds→%ds", old_interval, new_interval)
# Update other configurable options
self._profile_duration_tolerance = float(
config_entry.options.get(
CONF_PROFILE_DURATION_TOLERANCE, DEFAULT_PROFILE_DURATION_TOLERANCE
)
)
# Update notification settings
self._load_notify_services(config_entry)
self._notify_actions = list(
cast(list[dict[str, Any]], config_entry.options.get(CONF_NOTIFY_ACTIONS, []) or [])
)
self._notify_people = list(
config_entry.options.get(CONF_NOTIFY_PEOPLE, []) or []
)
self._notify_only_when_home = bool(
config_entry.options.get(
CONF_NOTIFY_ONLY_WHEN_HOME, DEFAULT_NOTIFY_ONLY_WHEN_HOME
)
)
self._notify_fire_events = bool(
config_entry.options.get(CONF_NOTIFY_FIRE_EVENTS, DEFAULT_NOTIFY_FIRE_EVENTS)
)
self._notify_before_end_minutes = int(
config_entry.options.get(
CONF_NOTIFY_BEFORE_END_MINUTES, DEFAULT_NOTIFY_BEFORE_END_MINUTES
)
)
self._notify_live_interval_seconds = int(
config_entry.options.get(
CONF_NOTIFY_LIVE_INTERVAL_SECONDS,
DEFAULT_NOTIFY_LIVE_INTERVAL_SECONDS,
)
)
self._notify_live_overrun_percent = int(
config_entry.options.get(
CONF_NOTIFY_LIVE_OVERRUN_PERCENT,
DEFAULT_NOTIFY_LIVE_OVERRUN_PERCENT,
)
)
self._notify_live_chronometer = bool(
config_entry.options.get(
CONF_NOTIFY_LIVE_CHRONOMETER,
DEFAULT_NOTIFY_LIVE_CHRONOMETER,
)
)
self._notify_timeout_seconds = int(
config_entry.options.get(
CONF_NOTIFY_TIMEOUT_SECONDS, DEFAULT_NOTIFY_TIMEOUT_SECONDS
)
)
# Reload door sensor / pause config
self._pause_cuts_power = bool(config_entry.options.get(CONF_PAUSE_CUTS_POWER, False))
self._door_sensor_entity = config_entry.options.get(CONF_DOOR_SENSOR_ENTITY) or None
self._notify_unload_delay_minutes = int(
config_entry.options.get(
CONF_NOTIFY_UNLOAD_DELAY_MINUTES, DEFAULT_NOTIFY_UNLOAD_DELAY_MINUTES
)
)
# Re-subscribe to external cycle end trigger
await self._setup_external_end_trigger()
# Re-subscribe to door sensor
await self._setup_door_sensor_listener()
# Re-subscribe to person presence changes for notification gating
await self._setup_notify_people_listener()
# If a cycle is currently active and live notifications are now enabled,
# reset counters and fire the first live notification immediately so the
# user doesn't have to wait for the next power sensor poll.
if self.detector.state in (STATE_RUNNING, STATE_PAUSED, STATE_ENDING):
if self._notify_live_services or self._notify_actions:
self._reset_live_notification_state()
self._check_live_progress_notification()
self._logger.info("Configuration reloaded successfully")
# Trigger entity updates to reflect any changes
async_dispatcher_send(self.hass, f"ha_washdata_update_{self.entry_id}")
if self.detector:
self.detector.config.profile_duration_tolerance = self._profile_duration_tolerance
# Schedule midnight maintenance if enabled
await self._setup_maintenance_scheduler()
# Update sampling interval
old_sampling = self._sampling_interval
new_sampling = float(
config_entry.options.get(CONF_SAMPLING_INTERVAL, DEFAULT_SAMPLING_INTERVAL)
)
if old_sampling != new_sampling:
self._sampling_interval = new_sampling
self._logger.info(
"Updated sampling interval: %.1fs -> %.1fs", old_sampling, new_sampling
)
# RESTORE STATE (only if recent enough, otherwise treat as stale)
await self._attempt_state_restoration()
self._logger.info("Configuration reloaded successfully")
async def async_shutdown(self) -> None:
"""Shutdown."""
if self._remove_listener:
self._remove_listener()
if self._remove_external_trigger_listener:
self._remove_external_trigger_listener()
if self._remove_door_sensor_listener:
self._remove_door_sensor_listener()
self._remove_door_sensor_listener = None
if self._remove_notify_people_listener:
self._remove_notify_people_listener()
self._remove_notify_people_listener = None
self._pending_notifications = []
if self._remove_watchdog:
self._remove_watchdog()
if (
hasattr(self, "_remove_state_expiry_timer")
and self._remove_state_expiry_timer
):
self._remove_state_expiry_timer()
if self._remove_maintenance_scheduler:
self._remove_maintenance_scheduler()
self.diag_buffer.uninstall()
# Dismiss any active live/progress notification so it doesn't linger on
# mobile devices across HA restarts or integration unloads with a stale
# (and eventually negative) chronometer.
try:
self._clear_live_progress_notification()
except Exception: # noqa: BLE001
self._logger.debug("Failed to clear live notification on shutdown", exc_info=True)
# Save active state before shutdown
if self.detector.state in {STATE_RUNNING, STATE_PAUSED, STATE_STARTING, STATE_ENDING}:
snapshot = self.detector.get_state_snapshot()
snapshot["manual_program"] = self._manual_program_active
snapshot["notified_start"] = self._notified_start
snapshot["start_event_fired"] = self._start_event_fired
snapshot["is_user_paused"] = self._is_user_paused
snapshot["user_pause_start"] = (
self._user_pause_start.isoformat() if self._user_pause_start else None
)
snapshot["total_user_paused_seconds"] = self._total_user_paused_seconds
await self.profile_store.async_save_active_cycle(snapshot)
self._last_reading_time = None
async def _setup_external_end_trigger(self) -> None:
"""Set up listener for external cycle end trigger binary sensor."""
# Remove existing listener if any
if self._remove_external_trigger_listener:
self._remove_external_trigger_listener()
self._remove_external_trigger_listener = None
# Check if enabled
enabled = self.config_entry.options.get(
CONF_EXTERNAL_END_TRIGGER_ENABLED, False
)
if not enabled:
self._logger.debug("External cycle end trigger is disabled")
return
# Get entity ID
entity_id = self.config_entry.options.get(CONF_EXTERNAL_END_TRIGGER, "")
if not entity_id:
self._logger.debug("External cycle end trigger: no entity configured")
return
self._logger.info(
"Setting up external cycle end trigger: %s", entity_id
)
# Subscribe to state changes
self._remove_external_trigger_listener = async_track_state_change_event(
self.hass, [entity_id], self._handle_external_trigger_change
)
async def _setup_door_sensor_listener(self) -> None:
"""Set up listener for optional door sensor binary sensor."""
if self._remove_door_sensor_listener:
self._remove_door_sensor_listener()
self._remove_door_sensor_listener = None
entity_id = self._door_sensor_entity
if not entity_id:
self._logger.debug("Door sensor not configured")
return
self._logger.info("Setting up door sensor listener: %s", entity_id)
self._remove_door_sensor_listener = async_track_state_change_event(
self.hass, [entity_id], self._handle_door_sensor_change
)
@callback
def _handle_door_sensor_change(self, event: Event[evt.EventStateChangedData]) -> None:
"""Handle door sensor state changes.
Opening the door during an active cycle confirms an intentional pause (verified_pause).
Opening the door after a cycle clears the 'Clean' state.
Note: door closing does NOT auto-resume a cycle - the user must do this explicitly.
"""
new_state = event.data.get("new_state")
old_state = event.data.get("old_state")
if new_state is None:
return
new_val = new_state.state
old_val = old_state.state if old_state else None
# Ignore unavailability transitions
if new_val in ("unavailable", "unknown") or (
old_val in ("unavailable", "unknown")
):
return
door_open = new_val == "on" # binary_sensor: on = open
if door_open:
if self._is_clean_state:
# User opened the door after the cycle - laundry retrieved
self._logger.debug("Door opened: clearing Clean state")
self._is_clean_state = False
self._clean_state_start = None
self._notified_clean_laundry = False
# Dismiss a delivered clean reminder (and purge any queued ones)
# so it does not linger on the phone after the laundry is taken.
self._clear_clean_notification()
self._notify_update()
elif self.detector.state in (STATE_RUNNING, STATE_STARTING, STATE_PAUSED, STATE_ENDING):
# Door opened during active cycle → soft pause confirmation
self._logger.debug(
"Door opened during active cycle: setting verified_pause=True"
)
self.detector.set_verified_pause(True)
if not self._is_user_paused:
self._is_user_paused = True
self._user_pause_start = dt_util.now()
self._notify_update()
# Door closing is intentionally not handled - no auto-resume
async def _setup_notify_people_listener(self) -> None:
"""Set up listener for person presence changes used by notification gating."""
if self._remove_notify_people_listener:
self._remove_notify_people_listener()
self._remove_notify_people_listener = None
if self._notify_only_when_home and self._notify_people:
self._remove_notify_people_listener = async_track_state_change_event(
self.hass, self._notify_people, self._handle_notify_person_change
)
# If someone is already home when (re-)attaching, flush any queued
# notifications immediately so they aren't stranded.
if self._pending_notifications and self._is_any_notify_person_home():
person_entity_id: str | None = None
person_name: str | None = None
for eid in self._notify_people:
state = self.hass.states.get(eid)
if state and state.state == STATE_HOME:
person_entity_id = eid
person_name = state.name or state.attributes.get(
"friendly_name", eid
)
break
pending = list(self._pending_notifications)
self._pending_notifications = []
for entry in pending:
self._dispatch_notification(
entry["message"],
title=entry.get("title"),
icon=entry.get("icon"),
event_type=entry.get("event_type"),
person_entity_id=person_entity_id,
person_name=person_name,
extra_vars=entry.get("extra_vars"),
allow_deferral=False,
)
else:
self._pending_notifications = []
@callback
def _handle_external_trigger_change(self, event: Event[evt.EventStateChangedData]) -> None:
"""Handle external trigger sensor state change."""
new_state = event.data.get("new_state")
old_state = event.data.get("old_state")
if new_state is None:
return
inverted = self.config_entry.options.get(
CONF_EXTERNAL_END_TRIGGER_INVERTED, False
)
new_value = new_state.state
old_value = old_state.state if old_state else None
# Ignore unavailability/unknown transitions (reconnects, disconnects)
if old_value is None or old_value in ("unavailable", "unknown") or new_value in (
"unavailable",
"unknown",
):
return
# Determine if triggered based on inversion setting
triggered = False
if not inverted:
# Normal: Trigger on transition to "on"
if new_value == "on" and old_value != "on":
triggered = True
else:
# Inverted: Trigger on transition to "off"
if new_value == "off" and old_value != "off":
triggered = True
if triggered:
self._logger.info(
"External cycle end trigger activated by %s (inverted=%s)",
event.data.get("entity_id"),
inverted
)
# End cycle with "completed" status (not interrupted)
if self.detector.state in (STATE_ANTI_WRINKLE, STATE_DELAY_WAIT):
self.detector.reset(STATE_OFF)
self._logger.info("%s exited via external trigger", self.detector.state)
elif self.detector.state != STATE_OFF:
self.detector.user_stop()
self._logger.info("Cycle completed via external trigger")
async def _setup_maintenance_scheduler(self) -> None:
"""Set up daily maintenance task at midnight."""
auto_maintenance = self.config_entry.options.get(
CONF_AUTO_MAINTENANCE,
self.config_entry.data.get(CONF_AUTO_MAINTENANCE, DEFAULT_AUTO_MAINTENANCE),
)
# Cancel existing scheduler if any
if self._remove_maintenance_scheduler:
self._remove_maintenance_scheduler()
self._remove_maintenance_scheduler = None
if not auto_maintenance:
self._logger.debug("Auto-maintenance disabled")
return
# Calculate next midnight
now = dt_util.now()
tomorrow = now + timedelta(days=1)
next_midnight = tomorrow.replace(hour=0, minute=0, second=0, microsecond=0)
# Schedule first run at midnight
async def run_maintenance(_now: datetime | None = None) -> None:
"""Run maintenance task."""
self._logger.info("Running scheduled maintenance")
try:
stats = await self.profile_store.async_run_maintenance()
self._logger.info("Maintenance completed: %s", stats)
except Exception as err:
self._logger.error("Maintenance failed: %s", err, exc_info=True)
# Use async_track_point_in_time for midnight, then reschedule daily
self._remove_maintenance_scheduler = evt.async_track_point_in_time(
self.hass, run_maintenance, next_midnight
)
self._logger.info("Scheduled maintenance at %s", next_midnight)
# Also schedule daily repeat after first run
async def maintenance_wrapper(_now: datetime) -> None:
await run_maintenance(_now)
# Reschedule for next day
next_run = dt_util.now() + timedelta(days=1)
next_run = next_run.replace(hour=0, minute=0, second=0, microsecond=0)
self._remove_maintenance_scheduler = evt.async_track_point_in_time(
self.hass, maintenance_wrapper, next_run
)
self._remove_maintenance_scheduler = evt.async_track_point_in_time(
self.hass, maintenance_wrapper, next_midnight
)
@callback
def _async_power_changed(self, event: Any) -> None:
"""Handle power sensor state change."""
event_data = cast(dict[str, Any], getattr(event, "data", {}))
new_state = cast(State | None, event_data.get("new_state"))
if new_state is None or new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE):
return
try:
power = float(new_state.state)
except ValueError:
return
# Capture every raw sensor reading before any throttling or processing.
# Use the sensor's own last_updated timestamp so the trace reflects
# when the plug actually reported the value, not when we received it.
self.diag_buffer.record_power(power, new_state.last_updated)
# RECORD MODE INTERCEPTION
if self.recorder.is_recording:
self.recorder.process_reading(power)
self._current_power = power
self._last_reading_time = dt_util.now()
self._notify_update()
return
now = dt_util.now()
# Throttle updates to avoid CPU overload on noisy sensors
# BUT always allow updates if power is below min_power (critical end-of-cycle signal).
min_p = float(self.detector.config.min_power)
is_low_power = power < min_p
if (
not is_low_power
and self._last_reading_time
and (now - self._last_reading_time).total_seconds() < self._sampling_interval
):
return
# Track observed power readings for learning
self.learning_manager.process_power_reading(power, now, self._last_reading_time)
self._last_reading_time = now
self._last_real_reading_time = now # Track real update
self._current_power = power
self.detector.process_reading(power, now)
if self._cycle_start_time is None and self.detector.current_cycle_start is not None:
self._cycle_start_time = self.detector.current_cycle_start
# If running (or paused/ending), try to match profile and update estimates
if self.detector.state in (
STATE_RUNNING,
STATE_PAUSED,
STATE_ENDING,
STATE_STARTING,
):
self._update_estimates()
# Periodically save state every 60s to avoid flash wear
# We need a tracker.
self._check_state_save(now)
self._notify_update()
def _check_state_save(self, now: datetime) -> None:
"""Periodically save active state."""
last_save = getattr(self, "_last_state_save", None)
if not last_save or (now - last_save).total_seconds() > 60:
# Fire and forget save task
# Inject manual program flag into snapshot before saving
snapshot = self.detector.get_state_snapshot()
snapshot["manual_program"] = self._manual_program_active
snapshot["notified_start"] = self._notified_start
snapshot["start_event_fired"] = self._start_event_fired
snapshot["is_user_paused"] = self._is_user_paused
snapshot["user_pause_start"] = (
self._user_pause_start.isoformat() if self._user_pause_start else None
)
snapshot["total_user_paused_seconds"] = self._total_user_paused_seconds
self.hass.async_create_task(
self.profile_store.async_save_active_cycle(snapshot)
)
self._last_state_save = now
async def _run_final_match_from_cycle_data(self, cycle_data: dict[str, Any]) -> None:
"""Run final profile match using the cycle's power data before it's saved.
This is called from _on_cycle_end when _current_program is still 'detecting...'
to ensure we try matching with complete cycle data before persistence.
"""
# Cycle data from detector stores power_data as [[offset_seconds, power], ...],
# where offsets are relative to cycle start.
power_data = cycle_data.get("power_data", [])
duration = cycle_data.get("duration", 0)
if not power_data or len(power_data) < 10:
self._logger.debug("Insufficient power data for final match (< 10 readings)")
return
# power_data is already in [[offset_seconds, power], ...] format for matching.
self._logger.info(
"Running final match from cycle data: %s samples, %.0fs duration",
len(power_data),
duration,
)
result = await self.profile_store.async_match_profile(power_data, duration)
profile_name = result.best_profile
confidence = result.confidence
# Store result for debug data
self._last_match_result = result
# Accept match at lower threshold since cycle is complete
# Also ignore ambiguity for completed cycles - pick the best match
if profile_name and confidence >= 0.15:
self._logger.info(
"Final match from cycle data: '%s' with confidence %.3f",
profile_name,
confidence,
)
self._current_program = profile_name
self._last_match_confidence = confidence
else:
self._logger.info(
"No confident match from cycle data (best: %s, conf=%.3f)",
profile_name,
confidence,
)
def _start_watchdog(self) -> None:
"""Start the watchdog timer when a cycle begins."""
if self._remove_watchdog:
return # Already running
interval = self._watchdog_interval
self._logger.debug(
"Starting watchdog timer (configured=%ss)",
self._watchdog_interval,
)
self._remove_watchdog = async_track_time_interval(
self.hass, self._watchdog_check_stuck_cycle, timedelta(seconds=interval)
)
def _stop_watchdog(self) -> None:
"""Stop the watchdog timer when cycle ends."""
if self._remove_watchdog:
self._logger.debug("Stopping watchdog timer")
self._remove_watchdog()
self._remove_watchdog = None
def _start_state_expiry_timer(self) -> None:
"""Start timer to reset state to OFF and progress to 0% after idle period."""
if not hasattr(self, "_remove_state_expiry_timer"):
self._remove_state_expiry_timer = None
if self._remove_state_expiry_timer:
return # Already running
self._logger.debug(
"Starting state expiry timer (will reset after %ss)",
self._progress_reset_delay,
)
self._remove_state_expiry_timer = async_track_time_interval(
self.hass,
self._handle_state_expiry,
timedelta(seconds=60), # Check every minute
)
def _stop_state_expiry_timer(self) -> None:
"""Stop the state expiry timer."""
if (
hasattr(self, "_remove_state_expiry_timer")
and self._remove_state_expiry_timer
):
self._logger.debug("Stopping state expiry timer")
self._remove_state_expiry_timer()
self._remove_state_expiry_timer = None
async def _handle_state_expiry(self, now: datetime) -> None:
"""Check if state and progress should be reset (auto-expiration)."""
if (
not self._cycle_completed_time
or self.detector.state == STATE_RUNNING
or self.detector.state == STATE_ANTI_WRINKLE
or self.detector.state == STATE_DELAY_WAIT
):
# Cycle is running or not completed, don't reset
return
time_since_complete = (now - self._cycle_completed_time).total_seconds()
# Clean laundry nag notification
if (
self._is_clean_state
and not self._notified_clean_laundry
and self._clean_state_start is not None
and self._notify_unload_delay_minutes > 0
):
time_in_clean = (now - self._clean_state_start).total_seconds()
if time_in_clean >= self._notify_unload_delay_minutes * 60:
if self._notify_finish_services or self._notify_actions:
duration_min = int(time_since_complete / 60)
msg_template = self.config_entry.options.get(
CONF_NOTIFY_UNLOAD_MESSAGE, DEFAULT_NOTIFY_UNLOAD_MESSAGE
)
msg = self._safe_format_template(
msg_template,
fallback_template=DEFAULT_NOTIFY_UNLOAD_MESSAGE,
device=self.config_entry.title,
duration=duration_min,
delay=self._notify_unload_delay_minutes,
)
sent = self._dispatch_notification(
msg,
event_type=NOTIFY_EVENT_CLEAN,
extra_vars={"tag": self._clean_tag},
)
if sent:
self._notified_clean_laundry = True
self._logger.info(
"Sent clean laundry nag notification (%.0f min after cycle end)",
time_since_complete / 60,
)
else:
self._notified_clean_laundry = True
if time_since_complete > self._progress_reset_delay:
# Defer the reset when a clean-state unload notification is still pending.
# Without this guard the 30-min progress reset fires before the 60-min
# unload nag, clearing _is_clean_state before the notification can fire.
if (
self._is_clean_state
and not self._notified_clean_laundry
and self._notify_unload_delay_minutes > 0
and time_since_complete < self._notify_unload_delay_minutes * 60
):
return
# Auto-expire the "Finished" (or other terminal) state
self._logger.debug(
"State expiry: cycle idle for %.0fs (threshold: %ss). Resetting to OFF.",
time_since_complete,
self._progress_reset_delay,
)
self._cycle_progress = 0.0
self._cycle_completed_time = None
# Clear clean state when progress expires
self._is_clean_state = False
self._clean_state_start = None
self._notified_clean_laundry = False
self.detector.reset(STATE_OFF)
self._stop_state_expiry_timer()
self._notify_update()
async def _watchdog_check_stuck_cycle(self, now: datetime) -> None:
"""Watchdog: check if cycle is stuck (no updates for too long)."""
if self.detector.state not in (STATE_RUNNING, STATE_STARTING, STATE_PAUSED, STATE_ENDING):
return
if not self._last_reading_time:
return
time_since_any_update = (now - self._last_reading_time).total_seconds()
# Calculate time since REAL update (if available, else fallback to any update)
last_real = self._last_real_reading_time or self._last_reading_time
time_since_real_update = (now - last_real).total_seconds()
elapsed = self.detector.get_elapsed_seconds()
expected = getattr(self.detector, "expected_duration_seconds", 0)
# 0a. PUMP STUCK DETECTION (Pump Monitor only)
# If a pump cycle has been running longer than the configured stuck threshold,
# fire a single warning event so the user can wire an automation/alert.
# Skip while user-paused or detector-verified-pause to avoid false positives.
_verified_pause = getattr(self.detector, "_verified_pause", False)
if self.device_type == DEVICE_TYPE_PUMP and not self._pump_stuck and not self._is_user_paused and not _verified_pause:
adjusted_elapsed = elapsed - self._total_user_paused_seconds
if adjusted_elapsed >= self._pump_stuck_duration:
self._pump_stuck = True
self._logger.warning(
"Pump stuck detected: cycle has been running for %.0fs net "
"(threshold: %ds). Firing %s event.",
adjusted_elapsed,
self._pump_stuck_duration,
EVENT_PUMP_STUCK,
)
self.hass.bus.async_fire(
EVENT_PUMP_STUCK,
{
"device": self.config_entry.title,
"entry_id": self.entry_id,
"elapsed_seconds": round(adjusted_elapsed),
"threshold_seconds": self._pump_stuck_duration,
},
)
self._notify_update()
# 0. ZOMBIE KILLER (Hard Limit)
# If cycle has run significantly longer than expected (300%), kill it.
# Only applies if we have a profile match. Skip while user-paused or
# detector-verified-pause to avoid killing legitimately paused cycles.
_verified_pause_zombie = getattr(self.detector, "_verified_pause", False)
if (
expected > 0
and not self._is_user_paused
and not _verified_pause_zombie
):
adjusted_elapsed = elapsed - self._total_user_paused_seconds
if adjusted_elapsed > (expected * 3.0) and adjusted_elapsed > 14400:
self._logger.warning(
"Watchdog: Zombie cycle detected (%.0fs net > 300%% of expected %.0fs). Force-ending.",
adjusted_elapsed, expected
)
self.detector.force_end(now)
self._current_power = 0.0 # Force 0W
self._notify_update()
return
# 1. GHOST CYCLE SUPPRESSOR
# If we are "detecting" for more than 10 minutes and haven't seen an update for 5 minutes,
# it's likely a pump-out spike or an accidental start (ghost cycle).
# We end it aggressively ONLY if it started shortly after another cycle ended (Suspicious Window).
cycle_start = self.detector.current_cycle_start
is_suspicious = False
if cycle_start and self._last_cycle_end_time:
# Dishwashers have a drain pump-out that fires 3-8 min after the main
# cycle ends; use a wider suspicious window so the ghost suppressor can
# catch it without false-positives on washing machines / dryers.
suspicious_window = 600 if self.device_type == "dishwasher" else 180
if (cycle_start - self._last_cycle_end_time).total_seconds() < suspicious_window:
is_suspicious = True
# For dishwashers in the suspicious window, kill pump-out ghosts faster.
# Pump-outs last 1-3 min then go silent; the standard 10-min wait allows
# them to accumulate too much runtime before suppression fires.
dishwasher_pump_out = (
is_suspicious
and self.device_type == "dishwasher"
and elapsed > 180 # 3 minutes
and time_since_real_update > 60 # 1 minute of silence
)
if (
self._current_program == "detecting..."
and is_suspicious
and (
dishwasher_pump_out
or (elapsed > 600 and time_since_real_update > 300)
)
):
self._logger.warning(
"Watchdog: Ghost cycle suppressed (within suspicious window). Detecting for %.0fs with %.0fs silence.",
elapsed, time_since_real_update
)
self.detector.force_end(now)
self._current_power = 0.0
self._notify_update()
return
# --- LOW POWER HANDLING ---
# If we are in a low power state (waiting for off_delay or drying profile),
# we treat silence leniently. We inject keepalives until the stricter
# low_power_no_update_timeout is reached.
# Dishwashers can have very long silent drying phases (up to 2h)
# We use the device-specific timeout as the floor for this effective timeout.
# The floor is applied unconditionally - dishwashers have passive drying phases
# even when no profile has been matched yet. The original restriction to matched
# cycles caused premature kills: with the default 3600s timeout, an unmatched
# dishwasher cycle was killed ~1h after the last sensor update, while the
# physical drying phase could still have 1-2h of silent runtime remaining.
low_power_floor = DEFAULT_NO_UPDATE_ACTIVE_TIMEOUT_BY_DEVICE.get(
self.device_type, 0
)
effective_low_power_timeout = max(
low_power_floor, self._low_power_no_update_timeout
)
# Profile-Aware Extension:
# If we have a matched profile, ensure we don't kill during the expected duration.
if expected > 0 and elapsed < expected:
# Extend timeout to cover the remaining expected duration + buffer
remaining = expected - elapsed
# Allow silence up to remaining + 1800s (30m buffer for drying/pause)
extended_timeout = remaining + 1800
if extended_timeout > effective_low_power_timeout:
effective_low_power_timeout = extended_timeout
# Verified Pause Extension:
# If the manager/store has confirmed this is a legitimate pause (e.g. Drying),
# allow even more leniency up to the global deferral limit.
if getattr(self.detector, "_verified_pause", False):
# Allow silence up to DEFAULT_MAX_DEFERRAL_SECONDS (default 2h) + buffer
pause_limit = DEFAULT_MAX_DEFERRAL_SECONDS + 1800
if pause_limit > effective_low_power_timeout:
effective_low_power_timeout = pause_limit
self._logger.debug(
"Watchdog: Extending timeout to %.0fs due to verified pause",
effective_low_power_timeout
)
if self.detector.is_waiting_low_power():
# 2. Staleness Check
if time_since_real_update > effective_low_power_timeout:
self._logger.warning(
"Watchdog: Force-ending cycle. Low-power state stale for %.0fs (> %.0fs).",
time_since_real_update,
effective_low_power_timeout
)
self.detector.force_end(now)
self._last_reading_time = now
self._current_power = 0.0
self._notify_update()
return
# 3. Injection Check (Keepalive)
# 3a. Honour the user-configured no_update_active_timeout for low-power silence.
# Publish-on-change sensors go completely silent once they stabilise at a low
# standby value (e.g. 1 W). The existing off_delay-based injection fires
# every 2 watchdog ticks, which is fine with a short watchdog interval but can
# take many minutes with a larger one. Respecting no_update_active_timeout
# here gives users a predictable upper bound on how long a cycle lingers after
# the appliance reaches standby, consistent with what the setting implies.
# Verified pauses (e.g. dishwasher drying confirmed by envelope) are excluded
# so that legitimate long silent phases are not prematurely terminated.
if (
not getattr(self.detector, "_verified_pause", False)
and time_since_real_update > self._no_update_active_timeout
):
self._logger.debug(
"Watchdog: Low-power real-update silence (%.0fs) > no_update_active_timeout (%.0fs). "
"Injecting 0W keepalive to advance accumulator.",
time_since_real_update,
self._no_update_active_timeout,
)
self.detector.process_reading(0.0, now)
self._last_reading_time = now
self._current_power = 0.0
self._notify_update()
return
# 3b. Fallback: inject 0W when any-update silence exceeds off_delay.
# This keeps the accumulator moving even when no_update_active_timeout has
# not been exceeded (e.g. the user left it at the default 600 s).
if time_since_any_update > self._config.off_delay:
self._logger.debug(
"Watchdog: Low power silence (%.0fs). Injecting 0W keepalive.",
time_since_any_update
)
# Ensure we handle the injection cleanly
# Do NOT update _last_real_reading_time here
self.detector.process_reading(0.0, now)
self._last_reading_time = now # Resets 'any' timer so we don't spam
self._current_power = 0.0
self._notify_update()
return
return
# Fallback for old "Case 1.5" logic (Low Power but NOT is_waiting_low_power)
# Check this BEFORE High Power timeout to prevent trapping "Not Yet Waiting" states
# Inject as soon as the earliest of: off_delay silence OR no_update_active_timeout.
if self._current_power <= self.detector.config.min_power and (
time_since_any_update > self._config.off_delay
or time_since_real_update > self._no_update_active_timeout
):
# Treating as start of low power wait
self._logger.debug("Watchdog: Silence at low power (%.0fs). Injecting 0W.", time_since_any_update)
self.detector.process_reading(0.0, now)
self._last_reading_time = now
self._current_power = 0.0
self._notify_update()
return
# --- HIGH POWER HANDLING (Normal) ---
# If power is high, we expect frequent updates.
if time_since_any_update > self._no_update_active_timeout:
# Check if high power (running)
if self._current_power > self.detector.config.min_power:
# Allow extended silence if within reasonable cycle bounds
expected = getattr(self.detector, "expected_duration_seconds", 0)
elapsed = self.detector.get_elapsed_seconds()
limit = (expected + 14400) if expected > 0 else 14400 # 4h default
if elapsed < limit:
self._logger.info(
"Watchdog: High power (%.1fW) stale (%.0fs). Injecting refresh.",
self._current_power, time_since_any_update
)
self.detector.process_reading(self._current_power, now)
self._last_reading_time = now
self._notify_update()
return
# If we get here, it's truly stuck/offline
self._logger.warning(
"Watchdog: Force-ending cycle. Active state stale for %.0fs (> timeout).",
time_since_any_update
)
self.detector.force_end(now)
self._current_power = 0.0 # FIX: Reset current power
self._notify_update()
return
def _on_state_change(self, old_state: str, new_state: str) -> None:
"""Handle state change from detector."""
self._logger.debug("Washer state changed: %s -> %s", old_state, new_state)
self.diag_buffer.record_state(
old_state, new_state, self._current_program, dt_util.now()
)
# A new cycle starting while we are still showing the completed/Clean
# overlay (the progress-reset window) must clear that overlay and cancel
# the expiry timer right away, so the UI leaves "Finished" and the unload
# nag stops immediately instead of waiting for the reset window - and so
# the expiry timer cannot race the new cycle and reset us to OFF (#267).
if new_state == STATE_STARTING and self._cycle_completed_time is not None:
self._cycle_completed_time = None
self._is_clean_state = False
self._clean_state_start = None
self._notified_clean_laundry = False
self._cycle_progress = 0.0
self._stop_state_expiry_timer()
if new_state == STATE_RUNNING:
new_cycle_detected = old_state in (STATE_OFF, STATE_STARTING, STATE_UNKNOWN)
# Only reset estimates if we are truly starting a NEW cycle (from off or starting)
# If we transition from PAUSED or ENDING, it's a resume - keep estimates!
if new_cycle_detected:
self._cycle_completed_time = None
self._stop_state_expiry_timer()
self._current_program = "detecting..."
self._manual_program_active = False
self._notified_pre_completion = False
self._time_remaining = None
self._total_duration = None
self._cycle_progress = 0
self._matched_profile_duration = None
self._last_estimate_time = None
self._score_history = {} # Reset score history on new cycle
self._match_persistence_counter = {} # Reset persistence counter
self._unmatch_persistence_counter = 0 # Reset unmatch counter
self._current_match_candidate = None # Reset candidate
self._notified_start = False # Reset start notification state
self._start_event_fired = False
self._cycle_start_time = self.detector.current_cycle_start or dt_util.now()
self._reset_live_notification_state()
# Reset pause tracking and clean state for new cycle
self._is_user_paused = False
self._user_pause_start = None
self._total_user_paused_seconds = 0.0
self._is_clean_state = False
self._clean_state_start = None
self._notified_clean_laundry = False
self._start_watchdog() # Start watchdog when cycle starts
# Fire the start event immediately on cycle detection so listeners always
# receive it, even when no profile match occurs yet.
if self._notify_fire_events:
self.hass.bus.async_fire(
EVENT_CYCLE_STARTED,
{
"entry_id": self.entry_id,
"device_name": self.config_entry.title,
"device_type": self.device_type,
"program": self._current_program or "unknown",
"start_time": self._cycle_start_time.isoformat(),
},
)
self._start_event_fired = True
# Fire push notification immediately - do not wait for profile matching.
if not self._notified_start and (self._notify_start_services or self._notify_actions):
msg_template = self.config_entry.options.get(
CONF_NOTIFY_START_MESSAGE, DEFAULT_NOTIFY_START_MESSAGE
)
msg = self._safe_format_template(
msg_template,
fallback_template=DEFAULT_NOTIFY_START_MESSAGE,
device=self.config_entry.title,
program=self._current_program,
)
self._dispatch_notification(
msg,
event_type=NOTIFY_EVENT_START,
extra_vars={
"program": self._current_program,
"tag": self._lifecycle_tag,
},
)
self._notified_start = True
self._logger.info(
"Sent start notification for program '%s'", self._current_program
)
self._check_pre_completion_notification()
else:
self._logger.debug("Cycle resumed from %s, preserving estimates", old_state)
# Ensure watchdog is running
self._start_watchdog()
# Stop watchdog when transitioning to OFF from any active state
if new_state == STATE_OFF:
self._stop_watchdog() # Stop watchdog regardless of previous state
self._cycle_start_time = None
self._notify_update()
def _on_cycle_end(self, cycle_data: dict[str, Any]) -> None:
"""Handle cycle end - clear all active timers and state."""
duration = cycle_data["duration"]
max_power = cycle_data.get("max_power", 0)
# IMMEDIATELY stop all active timers when cycle determined to have ended
self._stop_watchdog() # Stop active cycle watchdog
self._stop_state_expiry_timer() # Cancel any pending progress reset
prev_cycle_end_time = self._last_cycle_end_time
self._last_cycle_end_time = dt_util.now()
self._pump_stuck = False # Reset for next pump cycle
# Auto-Tune: Check for ghost cycles (short duration AND low energy)
# Ghost = duration < 60s AND total energy < 0.05 Wh (avoids killing pump-out spikes)
power_data = cycle_data.get("power_data", [])
cycle_energy_wh = 0.0
if power_data and len(power_data) >= 2:
valid: list[tuple[float, float]] = []
for p in power_data:
try:
valid.append((float(p[0]), float(p[1])))
except (TypeError, ValueError, IndexError):
pass
if len(valid) >= 2:
try:
valid.sort(key=lambda x: x[0])
ts = np.array([v[0] for v in valid])
ps = np.array([v[1] for v in valid])
dt_h = np.diff(ts) / 3600.0
_MAX_GAP_H = 1.0 # skip segments longer than 1 hour
mask = (dt_h > 0) & (dt_h <= _MAX_GAP_H)
avg_p = (ps[:-1] + ps[1:]) / 2
cycle_energy_wh = float(np.sum(avg_p[mask] * dt_h[mask]))
except (TypeError, ValueError, ArithmeticError):
cycle_energy_wh = 0.0
# Ghost cycle: short AND low energy (real cycles have energy even if short)
if duration < 60 and cycle_energy_wh < 0.05:
self._handle_noise_cycle(max_power)
elif self.device_type == "dishwasher" and prev_cycle_end_time is not None:
# Pump-out suppression: dishwashers end cycles with a brief drain pump
# (typically 30-300 s, < 1 Wh) a few minutes after the main cycle
# finishes. If a short, low-energy cycle starts within 10 minutes of
# the previous cycle, treat it as a pump-out ghost and do not store it.
cycle_start_str = cycle_data.get("start_time")
cycle_start_dt = (
dt_util.parse_datetime(cycle_start_str) if cycle_start_str else None
)
if cycle_start_dt is not None:
gap = (cycle_start_dt - prev_cycle_end_time).total_seconds()
if 0 < gap < 600 and duration < 300 and cycle_energy_wh < 1.0:
self._logger.info(
"Suppressing dishwasher pump-out ghost: "
"gap=%.0fs, duration=%.0fs, energy=%.3f Wh",
gap,
duration,
cycle_energy_wh,
)
self._handle_noise_cycle(max_power)
return # Do not store this as a real cycle
# Store energy for notification and persistence (calculated above for ghost detection)
cycle_data["energy_wh"] = round(cycle_energy_wh, 3)
# Schedule heavy post-processing asynchronously
self.hass.async_create_task(self._async_process_cycle_end(cycle_data))
async def _async_process_cycle_end(self, cycle_data: dict[str, Any]) -> None:
"""Process cycle completion asynchronously (heavy tasks)."""
# FINAL PROFILE MATCH: If still detecting, try one last match with complete cycle data
if self._current_program in ("detecting...", "restored..."):
await self._run_final_match_from_cycle_data(cycle_data)
# If we had a runtime match, attach the profile name for persistence
if (
self._current_program
and self._current_program not in ("off", "detecting...", "restored...")
and self._current_program in self.profile_store.get_profiles()
):
cycle_data["profile_name"] = self._current_program
if self._last_match_confidence:
cycle_data["match_confidence"] = float(self._last_match_confidence)
# Attach extensive debug data if available (and configured)
if self._last_match_result:
cycle_data["debug_data"] = {
"ranking": getattr(self._last_match_result, "ranking", []),
"details": getattr(self._last_match_result, "debug_details", {}),
"ambiguous": getattr(self._last_match_result, "is_ambiguous", False),
}
# Post-Cycle Auto-Labeling (if not already matched)
# Offload this match too if needed
if not cycle_data.get("profile_name") and self._auto_label_confidence > 0:
res = await self.profile_store.async_match_profile(
cycle_data["power_data"], cycle_data["duration"]
)
if res.best_profile and res.confidence >= self._auto_label_confidence:
cycle_data["profile_name"] = res.best_profile
cycle_data["match_confidence"] = float(res.confidence)
self._logger.info(
"Post-cycle auto-labeled as '%s' (confidence: %.2f)",
res.best_profile,
res.confidence,
)
# Add cycle to store immediately (still sync but offloadable parts optimized
# internally if possible)
# Note: add_cycle is mostly safe (signature calc is O(N) but fast enough for
# single cycle).
# We could offload signature calc to analysis logic if really needed, but let's
# stick to match profile optimization first.
try:
await self.profile_store.async_add_cycle(cycle_data)
profile_name = cycle_data.get("profile_name")
if profile_name:
await self.profile_store.async_rebuild_envelope(profile_name)
except Exception as e: # pylint: disable=broad-exception-caught
self._logger.error("Failed to add cycle to store: %s", e)
# Ensure cycle has a stable ID even if store add failed (or did not mutate).
if not cycle_data.get("id"):
try:
unique_str = f"{cycle_data['start_time']}_{cycle_data['duration']}"
cycle_data["id"] = hashlib.sha256(unique_str.encode()).hexdigest()[:12]
except Exception: # noqa: BLE001
pass
self.hass.async_create_task(self.profile_store.async_clear_active_cycle())
# Auto post-process: merge fragmented cycles from last 3 hours
self.hass.async_create_task(self._run_post_cycle_processing())
# Prepare cycle data for event (enrich if needed)
# IMPORTANT: Exclude large fields to prevent exceeding HA's 32KB event data limit
excluded_fields = {"power_data", "debug_data", "power_trace"}
event_cycle_data = {
k: v for k, v in cycle_data.items() if k not in excluded_fields
}
event_cycle_data["device_type"] = self.device_type
# Add program if missing or generic
if "profile_name" not in event_cycle_data and self._current_program:
event_cycle_data["profile_name"] = self._current_program
if self._notify_fire_events:
self.hass.bus.async_fire(
EVENT_CYCLE_ENDED,
{
"entry_id": self.entry_id,
"device_name": self.config_entry.title,
"cycle_data": event_cycle_data,
"program": event_cycle_data.get("profile_name", "unknown"),
"duration": event_cycle_data.get("duration"),
"start_time": event_cycle_data.get("start_time"),
"end_time": event_cycle_data.get("end_time") or dt_util.now().isoformat(),
},
)
# Purge pending live entries and reset counters, but don't send a service-level
# clear: the finished notification below reuses the lifecycle tag and replaces
# the live card in place (sending a clear first would cause a dismiss/recreate
# flicker). The action-based clear marker still fires for action templates.
self._clear_live_progress_notification(clear_services=False)
# Send notification if enabled
if self._notify_finish_services or self._notify_actions:
msg_template = self.config_entry.options.get(CONF_NOTIFY_FINISH_MESSAGE, DEFAULT_NOTIFY_FINISH_MESSAGE)
duration_min = int(cycle_data['duration'] / 60)
program_name = event_cycle_data.get("profile_name", "unknown")
energy_kwh = round(cycle_data.get("energy_wh", 0.0) / 1000, 3)
# Resolve energy price: entity takes precedence over static value
options = self.config_entry.options
price: float | None = None
price_entity = options.get(CONF_ENERGY_PRICE_ENTITY)
if price_entity:
state = self.hass.states.get(price_entity)
if state is not None:
try:
price = float(state.state)
except (ValueError, TypeError):
price = None
if price is None:
static = options.get(CONF_ENERGY_PRICE_STATIC)
if static is not None:
try:
price = float(static)
except (ValueError, TypeError):
price = None
cost_str = f"{energy_kwh * price:.2f}" if price is not None else ""
msg = self._safe_format_template(
msg_template,
fallback_template=DEFAULT_NOTIFY_FINISH_MESSAGE,
device=self.config_entry.title,
duration=duration_min,
program=program_name,
energy_kwh=f"{energy_kwh:.3f}",
cost=cost_str,
)
self._dispatch_notification(
msg,
event_type=NOTIFY_EVENT_FINISH,
extra_vars={
"duration_minutes": duration_min,
"duration_seconds": cycle_data["duration"],
"program": program_name,
"energy_kwh": energy_kwh,
"cost": cost_str,
# Same lifecycle tag as start/live so the finished alert replaces
# the live notification in place. No live_update/alert_once here,
# so the companion app surfaces it with sound.
"tag": self._lifecycle_tag,
},
)
# Request user feedback if we had a confident match.
# AND perform learning analysis on the completed cycle.
# IMPORTANT: this must happen before we clear match state.
self.learning_manager.process_cycle_end(
cycle_data,
detected_profile=self._current_program,
confidence=self._last_match_confidence or 0.0,
predicted_duration=self._matched_profile_duration,
match_result=self._last_match_result,
)
# Clear all state and timers - zero everything out
self._current_program = "off"
self._manual_program_active = False
self._notified_pre_completion = False
self._time_remaining = None
self._matched_profile_duration = None
self._last_estimate_time = None
self._last_match_result = None # Clear so phase sensor resets to "Off" (issue #192)
self._cycle_progress = 100.0 # 100% = cycle complete
self._cycle_completed_time = dt_util.now()
self._cycle_start_time = None
self._reset_live_notification_state()
# Reset pause tracking for the next cycle
self._is_user_paused = False
self._user_pause_start = None
self._total_user_paused_seconds = 0.0
# Enter Clean state if door sensor is configured and door is currently closed
self._is_clean_state = False
self._clean_state_start = None
self._notified_clean_laundry = False
if self._door_sensor_entity:
door_state = self.hass.states.get(self._door_sensor_entity)
if door_state and door_state.state == "off": # binary_sensor: off = closed
self._is_clean_state = True
self._clean_state_start = dt_util.now()
self._logger.debug(
"Cycle ended with door closed: entering Clean state"
)
# Start progress reset timer to go back to 0% after user unload window
self._start_state_expiry_timer()
self._notify_update()
@property
def profile_sample_repair_stats(self) -> dict[str, int] | None:
"""Return statistics from profile sample repair operation."""
return self._profile_sample_repair_stats
@property
def suggestions(self) -> dict[str, Any]:
"""Suggested settings computed by learning/heuristics (never auto-applied)."""
return self.profile_store.get_suggestions()
def _send_notification(self, message: str, title: str | None = None, icon: str | None = None) -> None:
"""Dispatch notification through actions or notify service."""
self._dispatch_notification(message, title=title, icon=icon)
def _safe_format_template(
self,
template: Any,
*,
fallback_template: str | None = None,
**kwargs: Any,
) -> str:
"""Format templates safely and return a resilient fallback on any error."""
text_template = str(template)
try:
return text_template.format(**kwargs)
except Exception as err: # pylint: disable=broad-exception-caught
self._logger.debug(
"Failed to format notification template %r with %s: %s",
text_template,
kwargs,
err,
)
if fallback_template:
try:
return fallback_template.format(**kwargs)
except Exception as err: # pylint: disable=broad-exception-caught
self._logger.debug(
"Failed to format fallback notification template %r with %s: %s",
fallback_template,
kwargs,
err,
)
device = str(kwargs.get("device") or self.config_entry.title)
program = kwargs.get("program")
if program:
return f"{device}: {program}"
return device
def _get_services_for_event(self, event_type: str | None) -> list[str]:
"""Return the configured notify service list for the given event type."""
if event_type == NOTIFY_EVENT_START:
return self._notify_start_services
if event_type in (NOTIFY_EVENT_FINISH, "pre_complete", NOTIFY_EVENT_CLEAN):
return self._notify_finish_services
if event_type == NOTIFY_EVENT_LIVE:
return self._notify_live_services
return []
def _resolve_channel(self, event_type: str | None) -> str | None:
"""Resolve the Android notification channel name for an event type.
Finished, the clean-laundry nag, and the pre-completion reminder route to the
dedicated finish channel (so they can carry their own sound), falling back to
the status channel. Start/live use the status channel. An empty configured
value means "omit channel" so existing setups are unchanged.
"""
status_channel = self.config_entry.options.get(
CONF_NOTIFY_CHANNEL, DEFAULT_NOTIFY_CHANNEL
)
finish_channel = self.config_entry.options.get(
CONF_NOTIFY_FINISH_CHANNEL, DEFAULT_NOTIFY_FINISH_CHANNEL
)
if event_type in (NOTIFY_EVENT_FINISH, NOTIFY_EVENT_CLEAN, "pre_complete"):
return (finish_channel or status_channel) or None
return status_channel or None
def _dispatch_notification(
self,
message: str,
*,
title: str | None = None,
icon: str | None = None,
event_type: str | None = None,
person_entity_id: str | None = None,
person_name: str | None = None,
extra_vars: dict[str, Any] | None = None,
allow_deferral: bool = True,
) -> bool:
"""Route notification via actions or notify service with optional gating."""
if not title:
title_template = self.config_entry.options.get(CONF_NOTIFY_TITLE, DEFAULT_NOTIFY_TITLE)
title = self._safe_format_template(
title_template,
fallback_template=DEFAULT_NOTIFY_TITLE,
device=self.config_entry.title,
)
if not icon:
icon = self.config_entry.options.get(CONF_NOTIFY_ICON)
if person_entity_id is None and self._notify_people:
for candidate in self._notify_people:
state = self.hass.states.get(candidate)
if state and state.state == STATE_HOME:
person_entity_id = candidate
person_name = state.name or state.attributes.get(
"friendly_name", candidate
)
break
variables: dict[str, Any] = {
"device": self.config_entry.title,
"program": self._current_program,
"message": message,
"title": title,
"icon": icon,
"event_type": event_type,
"person_entity_id": person_entity_id,
"person_name": person_name,
}
if extra_vars:
variables.update(extra_vars)
# Channel + auto-dismiss timeout apply to every event type. Inject into both
# the action variables and the notify-service extra_vars so both delivery
# paths honour them. Empty channel / zero timeout are omitted (no-op default).
channel = self._resolve_channel(event_type)
if channel:
variables["channel"] = channel
extra_vars = {**(extra_vars or {}), "channel": channel}
if self._notify_timeout_seconds > 0:
variables["timeout"] = self._notify_timeout_seconds
extra_vars = {**(extra_vars or {}), "timeout": self._notify_timeout_seconds}
if allow_deferral and self._notify_only_when_home and self._notify_people:
if not self._is_any_notify_person_home():
if event_type == NOTIFY_EVENT_LIVE:
self._pending_notifications = [
entry
for entry in self._pending_notifications
if entry.get("event_type") != NOTIFY_EVENT_LIVE
]
self._pending_notifications.append(
{
"message": message,
"title": title,
"icon": icon,
"event_type": event_type,
"extra_vars": extra_vars,
}
)
return False
actions_sent = False
if self._notify_actions:
actions_sent = bool(self._run_notification_actions(variables))
# If actions fired and there are no per-event services, skip the
# service/persistent-notification path entirely.
services = self._get_services_for_event(event_type)
if actions_sent and not services:
return True
service_sent = self._send_notification_service(
message,
services=services,
title=title,
icon=icon,
event_type=event_type,
extra_vars=extra_vars,
)
return actions_sent or service_sent
def _send_notification_service(
self,
message: str,
*,
services: list[str],
title: str | None = None,
icon: str | None = None,
event_type: str | None = None,
extra_vars: dict[str, Any] | None = None,
) -> bool:
"""Send a notification to each configured notify service, or fall back to persistent notification."""
data: dict[str, Any] = {}
if icon:
data["icon"] = icon
ev = extra_vars or {}
# Common payload keys forwarded for every event type so start/live/reminder/
# finished share a tag (replace each other) and honour timeout/channel/priority.
for key in ("tag", "timeout", "channel", "priority"):
if key in ev:
data[key] = ev[key]
# Live-progress-only payload keys (countdown, progress bar, throttle markers).
if event_type == NOTIFY_EVENT_LIVE:
for key in (
"progress",
"progress_max",
"live_update",
"alert_once",
"cycle_seconds",
"time_remaining_seconds",
"minutes_left",
"live_updates_sent",
"live_updates_cap",
"chronometer",
"when",
"countdown",
):
if key in ev:
data[key] = ev[key]
sent = False
for notify_service in services:
if event_type == NOTIFY_EVENT_LIVE and not self._is_mobile_notify_service(
notify_service
):
self._logger.debug(
"Skipping live notification for non-mobile notify service: %s",
notify_service,
)
continue
state = (
self.hass.states.get(notify_service)
if notify_service.startswith("notify.")
else None
)
if state is not None and getattr(state, "domain", None) == "notify":
service_data: dict[str, Any] = {
"entity_id": notify_service,
"message": message,
}
if title:
service_data["title"] = title
if data:
service_data["data"] = data
self.hass.async_create_task(
self.hass.services.async_call(
"notify", "send_message", service_data
)
)
else:
domain, service = (
notify_service.split(".", 1)
if "." in notify_service
else ("notify", notify_service)
)
service_data = {"message": message, "title": title}
if data:
service_data["data"] = data
self.hass.async_create_task(
self.hass.services.async_call(domain, service, service_data)
)
sent = True
if not sent:
if event_type == NOTIFY_EVENT_LIVE:
return False
# Reuse the notification's tag as a stable persistent-notification id so
# the HA notifications tab collapses the lifecycle thread to one entry
# instead of accumulating a new card per cycle (issue #248/#249 clutter).
_pn_create(
self.hass,
message,
title=title,
notification_id=ev.get("tag"),
)
return True
return sent
def _run_notification_actions(self, variables: dict[str, Any]) -> bool:
"""Run configured notification actions."""
actions: list[dict[str, Any]] = self._notify_actions
if not actions:
return False
try:
script = script_helper.Script(
self.hass,
actions,
name=f"{self.config_entry.title} notification",
domain=DOMAIN,
logger=_LOGGER,
)
except (ValueError, TypeError, HomeAssistantError) as err:
self._logger.error(
"Invalid notification action configuration for %s: %s",
self.config_entry.title,
err,
)
return False
except Exception as err:
self._logger.exception(
"Unexpected error while building notification actions for %s: %s",
self.config_entry.title,
err,
)
return False
try:
self.hass.async_create_task(
script.async_run(variables, context=Context())
)
return True
except HomeAssistantError as err:
self._logger.warning(
"Notification action execution failed for %s: %s",
self.config_entry.title,
err,
)
return False
except Exception as err:
self._logger.exception(
"Unexpected error while scheduling notification actions for %s: %s",
self.config_entry.title,
err,
)
return False
def _is_any_notify_person_home(self) -> bool:
"""Return True when any configured person is home."""
for person_entity_id in self._notify_people:
state = self.hass.states.get(person_entity_id)
if state and state.state == STATE_HOME:
return True
return False
@callback
def _handle_notify_person_change(self, event: Event[evt.EventStateChangedData]) -> None:
"""Handle person state changes to release pending notifications."""
new_state = event.data.get("new_state")
if not new_state or new_state.state != STATE_HOME:
return
if not self._pending_notifications:
return
person_entity_id = new_state.entity_id
person_name = new_state.name or new_state.attributes.get(
"friendly_name", person_entity_id
)
pending: list[dict[str, Any]] = list(self._pending_notifications)
self._pending_notifications = []
for entry in pending:
sent = self._dispatch_notification(
entry["message"],
title=entry.get("title"),
icon=entry.get("icon"),
event_type=entry.get("event_type"),
person_entity_id=person_entity_id,
person_name=person_name,
extra_vars=entry.get("extra_vars"),
allow_deferral=False,
)
if sent and entry.get("event_type") == NOTIFY_EVENT_LIVE:
ev_raw = entry.get("extra_vars")
ev: dict[str, Any] = ev_raw if isinstance(ev_raw, dict) else {}
if "progress" not in ev:
self._live_waiting_notification_sent = True
else:
self._live_notification_sent_count += 1
self._last_live_notification_time = dt_util.now()
def _handle_noise_cycle(self, max_power: float) -> None:
"""Handle a detected noise cycle."""
# Clean up old noise events > 24h
now = dt_util.now()
self._noise_events = [
t
for t in getattr(self, "_noise_events", [])
if (now - t).total_seconds() < 86400
]
self._noise_events.append(now)
# Track max power of noise
self._noise_max_powers = getattr(self, "_noise_max_powers", [])
self._noise_max_powers.append(max_power)
# If noise events exceed threshold in 24h, trigger tune
if len(self._noise_events) >= self._noise_events_threshold:
self.hass.async_create_task(self._tune_threshold())
async def _tune_threshold(self) -> None:
"""Increase the minimum power threshold."""
current_min = self.detector.config.min_power
# Calculate new suggested threshold
# Max of observed noise * 1.2 safety factor
noise_max = max(self._noise_max_powers)
new_min = noise_max * 1.2
# Cap absolute max to avoid runaway (e.g. 50W)
if new_min > 50.0:
new_min = 50.0
if new_min <= current_min:
# Clear events so we don't loop try to update
self._noise_events = []
self._noise_max_powers = []
return
self._logger.info(
"Auto-Tune suggestion: min_power from %.1fW -> %.1fW due to noise",
current_min,
new_min,
)
# Store a suggestion (do not mutate user-set options)
self.profile_store.set_suggestion(
CONF_MIN_POWER,
float(new_min),
f"Auto-tune: {len(self._noise_events)} ghost cycles detected in 24h",
)
await self.profile_store.async_save()
# Notify user - use finish services as the natural channel for device suggestions
_translations = await translation.async_get_translations(
self.hass, self.hass.config.language, "options", {DOMAIN}
)
_default_msg = (
"{device_type} {device_title} detected ghost cycles. "
"Suggested min_power change: {current_min}W -> {new_min}W "
"(not applied automatically)."
)
_default_title = "WashData Auto-Tune"
_msg_template = _translations.get(
f"component.{DOMAIN}.options.error.auto_tune_suggestion", _default_msg
)
_title = _translations.get(
f"component.{DOMAIN}.options.error.auto_tune_title", _default_title
)
message = _msg_template.format(
device_type=self.device_type,
device_title=self.config_entry.title,
current_min=f"{current_min:.1f}",
new_min=f"{new_min:.1f}",
)
if self._notify_finish_services or self._notify_start_services or self._notify_actions:
_event_type = (
NOTIFY_EVENT_FINISH if self._notify_finish_services
else NOTIFY_EVENT_START
)
self._dispatch_notification(message, title=_title, event_type=_event_type)
else:
_pn_create(self.hass, message, title=_title)
# Reset trackers
self._noise_events = []
self._noise_max_powers = []
def _update_estimates(self) -> None:
"""Update time remaining and profile estimates."""
if self.detector.state in (
STATE_OFF,
STATE_UNKNOWN,
STATE_IDLE,
STATE_STARTING,
STATE_ANTI_WRINKLE,
STATE_DELAY_WAIT,
):
self._current_program = "off"
self._time_remaining = None
self._total_duration = None
self._cycle_progress = 0.0
self._last_match_result = None
self._notify_update()
return
now = dt_util.now()
# Throttle heavy matching to configured interval (default: 5 minutes)
effective_match_interval = self._profile_match_interval
if (
self._last_estimate_time
and (now - self._last_estimate_time).total_seconds()
< effective_match_interval
):
# Still update remaining/progress if we already have a match
self._update_remaining_only()
self._check_pre_completion_notification()
self._check_live_progress_notification()
return
# SKIP matching if manual program is active
if self._manual_program_active:
self._last_estimate_time = now # touch timestamp to throttle estimates loop
self._update_remaining_only()
# Also check notifications in loop
self._check_pre_completion_notification()
self._check_live_progress_notification()
self._notify_update()
return
# No matching task trigger here anymore!
# The detector callback handles it.
# Just update progress/remaining based on existing match.
self._update_remaining_only()
self._check_pre_completion_notification()
self._check_live_progress_notification()
self._notify_update()
# _async_run_matching removed in favor of _async_perform_combined_matching
def _analyze_trend(self, profile_name: str) -> bool:
"""Analyze score history to detect positive trend.
Returns True if score has increased in at least 7 of the last 10 intervals.
Requires at least 5 samples history to make a determination.
"""
history = self._score_history.get(profile_name, [])
if len(history) < 5:
return False
# Use last 11 points to get 10 intervals (or fewer if history short)
recent = history[-11:]
if len(recent) < 2:
return False
up_count = sum(1 for i in range(1, len(recent)) if recent[i] > recent[i - 1])
total_intervals = len(recent) - 1
# Proportional threshold (7/10 => 0.7)
return (up_count / total_intervals) >= 0.70
def _reset_live_notification_state(self) -> None:
"""Reset per-cycle live notification counters and timers."""
self._live_notification_sent_count = 0
self._live_notification_cap = 0
self._last_live_notification_time = None
self._live_waiting_notification_sent = False
self._live_chronometer_overrun_sent = False
@staticmethod
def _is_mobile_notify_service(notify_service: str | None) -> bool:
"""Return True when configured notify target is a mobile app service."""
if not notify_service:
return False
_, service = (
notify_service.split(".", 1)
if "." in notify_service
else ("notify", notify_service)
)
return service.startswith("mobile_app")
def _estimate_live_notification_cap(self) -> int:
"""Compute hard cap for live updates from estimated cycle duration and overrun margin."""
interval = max(30, int(self._notify_live_interval_seconds))
estimated_duration = float(
self._matched_profile_duration
or self._total_duration
or max(float(self.detector.get_elapsed_seconds()), float(interval))
)
estimated_updates = max(1, int(np.ceil(estimated_duration / interval)))
overrun_ratio = max(0, float(self._notify_live_overrun_percent)) / 100.0
return max(1, int(np.ceil(estimated_updates * (1.0 + overrun_ratio))))
def _check_live_progress_notification(self) -> None:
"""Send throttled live progress notifications for compatible mobile targets."""
if not self._notify_live_services and not self._notify_actions:
return
if self.detector.state not in (STATE_RUNNING, STATE_PAUSED, STATE_ENDING):
return
has_profile_match = bool(
self._matched_profile_duration and self._matched_profile_duration > 0
)
if has_profile_match:
# A profile has been matched - reset the waiting latch so future
# "no profile yet" phases (e.g. after a cycle restart) will send
# the waiting message again.
self._live_waiting_notification_sent = False
if not has_profile_match:
if self._live_waiting_notification_sent:
return
msg = self._safe_format_template(
DEFAULT_NOTIFY_LIVE_WAITING_MESSAGE,
fallback_template=DEFAULT_NOTIFY_LIVE_WAITING_MESSAGE,
device=self.config_entry.title,
program=self._current_program,
)
sent = self._dispatch_notification(
msg,
event_type=NOTIFY_EVENT_LIVE,
extra_vars={
"tag": self._live_notification_tag,
"live_update": True,
"alert_once": True,
},
)
self._live_waiting_notification_sent = sent
return
interval = max(30, int(self._notify_live_interval_seconds))
now = dt_util.now()
if self._last_live_notification_time and (
now - self._last_live_notification_time
).total_seconds() < interval:
return
cap_candidate = self._estimate_live_notification_cap()
if cap_candidate > self._live_notification_cap:
self._live_notification_cap = cap_candidate
total_seconds = int(
max(
1,
round(
float(
self._total_duration
or self._matched_profile_duration
or self.detector.get_elapsed_seconds()
)
),
)
)
remaining_seconds = int(max(0, round(float(self._time_remaining or 0.0))))
elapsed_seconds = max(0, total_seconds - remaining_seconds)
# When a chronometer notification is on the phone but the estimate has
# expired, bypass the cap once to replace the frozen "0:00" countdown
# with a plain text update so the user isn't left with a stale timer.
chronometer_overrun = (
self._notify_live_chronometer
and remaining_seconds <= 0
and self._live_notification_sent_count > 0
and not self._live_chronometer_overrun_sent
)
if not chronometer_overrun and self._live_notification_sent_count >= self._live_notification_cap:
return
minutes_left = max(1, math.ceil(remaining_seconds / 60))
msg_template = self.config_entry.options.get(
CONF_NOTIFY_PRE_COMPLETE_MESSAGE,
DEFAULT_NOTIFY_PRE_COMPLETE_MESSAGE,
)
msg = self._safe_format_template(
msg_template,
fallback_template=DEFAULT_NOTIFY_PRE_COMPLETE_MESSAGE,
device=self.config_entry.title,
minutes=minutes_left,
program=self._current_program,
)
extra_vars: dict[str, Any] = {
"tag": self._live_notification_tag,
"progress": elapsed_seconds,
"progress_max": total_seconds,
"live_update": True,
"alert_once": True,
"cycle_seconds": total_seconds,
"time_remaining_seconds": remaining_seconds,
"minutes_left": minutes_left,
"live_updates_sent": self._live_notification_sent_count + 1,
"live_updates_cap": self._live_notification_cap,
}
if self._notify_live_chronometer and remaining_seconds > 0:
extra_vars["chronometer"] = True
extra_vars["when"] = int(now.timestamp()) + remaining_seconds
extra_vars["countdown"] = True
sent = self._dispatch_notification(
msg,
event_type=NOTIFY_EVENT_LIVE,
extra_vars=extra_vars,
)
if sent:
if chronometer_overrun:
self._live_chronometer_overrun_sent = True
else:
self._live_notification_sent_count += 1
self._last_live_notification_time = now
def _clear_live_progress_notification(self, clear_services: bool = True) -> None:
"""Clear active live/progress notifications and purge stale deferred alerts.
On cycle finish (``clear_services=False``) the finished notification carries
the same lifecycle tag and replaces the live notification in place, so we must
NOT also send a service-level ``clear_notification`` (it would briefly dismiss
then re-create the card). The pending-purge, the action-based clear marker
(kept for backward compatibility with custom action templates), and the state
reset still run. On shutdown (``clear_services=True``) no finished notification
follows, so the explicit service clear is required to dismiss the live card.
"""
# Purge queued live-progress entries and stale start/pre-complete entries
# so a completed cycle cannot replay them later.
live_tag = self._live_notification_tag
self._pending_notifications = [
entry
for entry in self._pending_notifications
if not (
(
entry.get("event_type") == NOTIFY_EVENT_LIVE
and isinstance(entry.get("extra_vars"), dict)
and entry["extra_vars"].get("tag") == live_tag
and entry["extra_vars"].get("live_update") is True
)
or entry.get("event_type") in {NOTIFY_EVENT_START, "pre_complete"}
)
]
# Always emit the clear when the user has any live channel configured.
# The in-memory sent-count is unreliable after an HA restart (it resets
# to 0 while the notification still lives on the phone), and a no-op
# clear for a non-existent tag is harmless on the mobile_app side.
if not self._notify_live_services and not self._notify_actions:
self._reset_live_notification_state()
return
# Invoke notification actions to clear live notification in action-based setups
# Include full context variables expected by notification action handlers
self._run_notification_actions(
{
"device": self.config_entry.title,
"program": "", # Cleared marker
"message": "clear_notification", # Clear marker for action handlers
"title": "", # Clear title
"icon": None,
"event_type": NOTIFY_EVENT_LIVE,
"person_entity_id": None,
"person_name": None,
"tag": self._live_notification_tag,
"live_update": True,
"alert_once": True,
}
)
if clear_services:
self._send_notification_service(
"clear_notification",
services=self._notify_live_services,
event_type=NOTIFY_EVENT_LIVE,
extra_vars={
"tag": self._live_notification_tag,
"live_update": True,
"alert_once": True,
},
)
# Reset live-update state flags and counters.
self._reset_live_notification_state()
def _clear_clean_notification(self) -> None:
"""Dismiss a delivered clean-laundry reminder and purge any queued ones.
The clean nag uses its own tag (``_clean_tag``) rather than the lifecycle
tag, so nothing replaces it once the clean state resolves. Mirror the
lifecycle clear here so a delivered reminder is removed from the mobile
app instead of lingering. A clear for a non-existent tag is harmless, so
this runs whenever the user has any clean/finish delivery configured.
"""
# Drop any still-queued clean entries so they cannot replay later.
self._pending_notifications = [
n for n in self._pending_notifications
if n.get("event_type") != NOTIFY_EVENT_CLEAN
]
services = self._get_services_for_event(NOTIFY_EVENT_CLEAN)
if not services and not self._notify_actions:
return
if self._notify_actions:
self._run_notification_actions(
{
"device": self.config_entry.title,
"program": "",
"message": "clear_notification",
"title": "",
"icon": None,
"event_type": NOTIFY_EVENT_CLEAN,
"person_entity_id": None,
"person_name": None,
"tag": self._clean_tag,
}
)
if services:
self._send_notification_service(
"clear_notification",
services=services,
event_type=NOTIFY_EVENT_CLEAN,
extra_vars={"tag": self._clean_tag},
)
def _check_pre_completion_notification(self) -> None:
"""Check and send pre-completion notification."""
if (
self._notify_before_end_minutes > 0
and not self._notified_pre_completion
and self._time_remaining is not None
and self._time_remaining <= (self._notify_before_end_minutes * 60)
and self._cycle_progress < 100
and not self._last_match_ambiguous
):
# Send notification!
self._notified_pre_completion = True
# Distinct reminder message (not the live-update template) so the one-time
# "X minutes left" alert is not confused with the recurring live ticks that
# reuse CONF_NOTIFY_PRE_COMPLETE_MESSAGE.
msg_template = self.config_entry.options.get(
CONF_NOTIFY_REMINDER_MESSAGE, DEFAULT_NOTIFY_REMINDER_MESSAGE
)
minutes_left = self._notify_before_end_minutes
msg = self._safe_format_template(
msg_template,
fallback_template=DEFAULT_NOTIFY_REMINDER_MESSAGE,
device=self.config_entry.title,
minutes=minutes_left,
program=self._current_program,
)
self._dispatch_notification(
msg,
event_type="pre_complete",
extra_vars={
# Share the lifecycle tag so the reminder updates the live thread in
# place. No alert_once -> the companion app makes a sound once; it is
# routed to the finish channel (see _resolve_channel) for audibility.
"tag": self._lifecycle_tag,
"minutes_left": minutes_left,
"minutes": minutes_left,
"priority": "high",
},
)
self._logger.info("Sent pre-completion notification: %s", msg)
def _update_remaining_only(self) -> None:
"""Recompute remaining/progress using phase-aware estimation."""
# Throttle updates and only clear on truly dead states
if self.detector.state in (STATE_OFF, STATE_UNKNOWN, STATE_IDLE):
self._time_remaining = None
self._total_duration = None
self._cycle_progress = 0.0
self._smoothed_progress = 0.0
return
now = dt_util.now()
if (
self._last_phase_estimate_time
and (now - self._last_phase_estimate_time).total_seconds() < 5.0
):
return
self._last_phase_estimate_time = now
# Use net elapsed (wall-clock minus user-paused time) for all time estimates
# so that paused time is excluded from progress / remaining / total duration.
duration_so_far = float(self.net_elapsed_seconds)
if self._matched_profile_duration and self._matched_profile_duration > 0:
# Get current power trace for phase analysis
trace = self.detector.get_power_trace()
# current_power_data = [(t.isoformat(), p) for t, p in trace]
# DEPRECATED: avoid O(N) conversion
# --- PHASE-AWARE ESTIMATION ---
if len(trace) >= 10 and self._current_program != "detecting...":
phase_result = self._estimate_phase_progress(
trace, duration_so_far, self._current_program
)
if phase_result is not None:
phase_progress, phase_variance = phase_result
# Smoothing: Exponential Moving Average
# If this is the first reliable estimate, snap to it.
# Otherwise, blend 20% new, 80% old.
if self._smoothed_progress == 0.0:
self._smoothed_progress = phase_progress
else:
current_smoothed = self._smoothed_progress
# Smart Time Prediction (Variance-Based Locking)
# If variance is high (e.g. > 50W std dev), this phase
# is unpredictable. DAMP HEAVILY.
# If variance is low (< 10W), trust the estimate more.
alpha = 0.2 # Default
if phase_variance > 100.0:
alpha = 0.05 # Very slow updates (mostly locked)
self._logger.debug(
"High variance phase (std=%.1fW), "
"locking time estimate (alpha=0.05)",
phase_variance,
)
elif phase_variance > 50.0:
alpha = 0.1
# Monotonicity check: don't let it jump BACKWARD
# significantly unless the profile changed (handled
# elsewhere). Allow small fluctuations, but prevent
# large drops. Use device-type-specific threshold to
# handle different cycle characteristics.
smoothing_threshold = DEVICE_SMOOTHING_THRESHOLDS.get(
self.device_type, 5.0
)
if phase_progress < current_smoothed - smoothing_threshold:
# Let's damp it heavily (keep mostly old value).
self._smoothed_progress = (current_smoothed * 0.95) + (
phase_progress * 0.05
)
self._logger.debug(
"Progress drop detected (%.1f%% < %.1f%% - %.1f%%), "
"applying heavy damping for %s",
phase_progress,
current_smoothed,
smoothing_threshold,
self.device_type,
)
else:
# Normal estimate update with dynamic alpha
self._smoothed_progress = (
self._smoothed_progress * (1.0 - alpha)
) + (phase_progress * alpha)
# Ensure we don't exceed 99% until actually finished
self._smoothed_progress = min(99.0, self._smoothed_progress)
# Update User-Facing Progress from Smoothed Value
self._cycle_progress = self._smoothed_progress
# Back-calculate "Time Remaining" from the smoothed progress
# exact_remaining = duration * (1 - progress)
# This prevents "progress says 90% but time says 20 mins" mismatch
remaining = self._matched_profile_duration * (
1.0 - (self._cycle_progress / 100.0)
)
self._time_remaining = max(0.0, remaining)
self._total_duration = duration_so_far + remaining
self._last_total_duration_update = now
self._logger.debug(
"Phase-aware estimate: raw=%.1f%%, smoothed=%.1f%%, remaining=%smin",
phase_progress,
self._cycle_progress,
int(remaining / 60),
)
return
# --- LINEAR FALLBACK (if phase analysis unavailable) ---
matched_dur = float(self._matched_profile_duration)
remaining = max(matched_dur - duration_so_far, 0.0)
progress = (duration_so_far / matched_dur) * 100.0
# Blend linear estimate into smoothed tracker too, to prevent
# jumps if we lose phase lock
if self._smoothed_progress > 0:
# Blend gently
self._smoothed_progress = (self._smoothed_progress * 0.9) + (
progress * 0.1
)
else:
self._smoothed_progress = progress
self._time_remaining = remaining
self._total_duration = duration_so_far + remaining
self._last_total_duration_update = now
self._cycle_progress = max(0.0, min(self._smoothed_progress, 100.0))
self._logger.debug(
"Linear estimate: remaining=%smin, progress=%.1f%%",
int(remaining / 60),
self._cycle_progress,
)
else:
# No profile matched - don't provide misleading time estimates
# Just show that we're detecting (no Smart Resume based on history)
self._time_remaining = None
self._total_duration = None
self._cycle_progress = 0.0
self._smoothed_progress = 0.0
self._logger.debug(
"No profile matched yet, elapsed=%smin", int(duration_so_far / 60)
)
def _estimate_phase_progress(
self,
current_power_data: list[tuple[datetime, float]] | list[tuple[str, float]],
current_duration: float,
profile_name: str,
) -> tuple[float, float] | None:
"""
Estimate cycle progress by analyzing which phase we're in.
Uses cached statistical envelope built from ALL cycles labeled with
this profile, normalized by TIME to account for different sampling rates.
Returns progress percentage (0-100) or None if estimation fails.
"""
# Get cached envelope (fast - already computed and stored)
envelope = self.profile_store.get_envelope(profile_name)
if envelope is None:
self._logger.debug("No envelope cached for profile %s", profile_name)
return None
# Convert cached lists back to numpy arrays
# Envelope curves are stored as [[t, y], ...] points, extract Y values only
try:
env_min = envelope.get("min", [])
env_max = envelope.get("max", [])
env_avg = envelope.get("avg", [])
env_std = envelope.get("std", [])
# Handle both formats: [[t, y], ...] (new) or [y, ...] (legacy)
def extract_y_values(data: list[Any]) -> np.ndarray[Any, np.dtype[np.float64]]:
if not data:
return np.array([], dtype=float)
first = data[0]
if isinstance(first, (list, tuple)):
first_seq = cast(list[Any] | tuple[Any, ...], first)
if len(first_seq) < 2:
return np.array([], dtype=float)
# New format: [[t, y], ...]
points = cast(list[list[Any] | tuple[Any, ...]], data)
return np.array([float(pt[1]) for pt in points], dtype=float)
# Legacy format: [y, ...]
scalars = cast(list[float | int], data)
return np.array(scalars, dtype=float)
envelope_arrays: dict[str, np.ndarray[Any, np.dtype[np.float64]]] = {
"min": extract_y_values(env_min),
"max": extract_y_values(env_max),
"avg": extract_y_values(env_avg),
"std": extract_y_values(env_std),
}
time_grid: np.ndarray[Any, np.dtype[np.float64]] = np.array(
envelope.get("time_grid", []), dtype=float
)
target_duration = float(envelope.get("target_duration", 0.0) or 0.0)
except (KeyError, ValueError, TypeError, IndexError) as e:
self._logger.warning("Invalid envelope format for %s: %s", profile_name, e)
return None
if len(time_grid) == 0 or target_duration <= 0:
if target_duration > 0 and len(envelope_arrays["avg"]) > 0:
# Reconstruct time_grid if missing (Legacy envelope support)
count = len(envelope_arrays["avg"])
time_grid = np.linspace(0, target_duration, count)
self._logger.debug(
"Reconstructed missing time_grid for %s (n=%d)",
profile_name,
count,
)
else:
self._logger.debug("Envelope missing time grid/duration, cannot estimate phase")
return None
# Extract power offsets from current cycle (any format → [offset, power])
current_offsets_list = power_data_to_offsets(
cast(list[list[Any] | tuple[Any, ...]], current_power_data)
)
current_offsets = np.array([o for o, _ in current_offsets_list])
current_values = np.array([p for _, p in current_offsets_list])
# Use sliding window on TIME, not sample count
# Look at last ~1 minute of data or 25% of expected duration, whichever is smaller
window_duration = min(60.0, target_duration * 0.25)
current_time = current_offsets[-1]
window_start_time = max(0, current_time - window_duration)
# Get current window (last N seconds of data)
window_mask = current_offsets >= window_start_time
current_window_values = current_values[window_mask]
if len(current_window_values) < 3:
self._logger.debug("Insufficient data in current window for phase estimation")
return None
best_progress: float | None = None
best_score = -1.0
in_bounds = False
best_time_window_start: float | None = None
# Search through envelope TIME grid for best matching position
for i in range(len(time_grid) - 1):
time_window_start = float(time_grid[i])
# Get envelope values for this time window
envelope_window_start = i
envelope_window_end = min(
i + len(current_window_values), len(envelope_arrays["avg"])
)
if envelope_window_end <= envelope_window_start:
continue
avg_window = envelope_arrays["avg"][
envelope_window_start:envelope_window_end
]
min_window = envelope_arrays["min"][
envelope_window_start:envelope_window_end
]
max_window = envelope_arrays["max"][
envelope_window_start:envelope_window_end
]
# Interpolate envelope to match current window length if needed
if len(avg_window) != len(current_window_values):
x_old = np.linspace(0, 1, len(avg_window))
x_new = np.linspace(0, 1, len(current_window_values))
avg_window = np.interp(x_new, x_old, avg_window)
min_window = np.interp(x_new, x_old, min_window)
max_window = np.interp(x_new, x_old, max_window)
# Check if current power is within expected bounds (±20% tolerance)
within_bounds = np.all(
(current_window_values >= min_window * 0.8)
& (current_window_values <= max_window * 1.2)
)
bounds_score = np.mean(
(current_window_values >= min_window)
& (current_window_values <= max_window)
)
# Calculate shape similarity to average
try:
if np.std(current_window_values) > 0 and np.std(avg_window) > 0:
correlation = np.corrcoef(current_window_values, avg_window)[0, 1]
else:
correlation = 0.0
# MAE against average
mae = np.mean(np.abs(current_window_values - avg_window))
max_power = max(np.max(avg_window), np.max(current_window_values), 1.0)
mae_normalized = 1.0 - min(mae / max_power, 1.0)
# Combined score: shape + amplitude + bounds compliance
score = (
0.4 * max(correlation, 0.0) # Shape matching
+ 0.3 * mae_normalized # Amplitude matching
+ 0.3 * bounds_score # Within expected range
)
# Penalize matches that are far from current elapsed time
# (assume linear progress is roughly correct). This prevents
# wild jumps in time remaining when patterns repeat
time_diff = abs(time_window_start - current_duration)
# Max penalty at 30% duration diff
time_penalty = min(1.0, time_diff / (target_duration * 0.3))
# Apply time penalty (reduce score by up to 40%)
score = score * (1.0 - 0.4 * time_penalty)
if score > best_score:
best_score = score
best_progress = (time_window_start / target_duration) * 100.0
in_bounds = within_bounds
best_time_window_start = float(time_window_start)
except Exception: # pylint: disable=broad-exception-caught
continue
if best_progress is None or best_score < 0.4:
self._logger.debug("Phase detection failed: best_score=%.3f", best_score)
return None
# Calculate variance for the best window (Smart Time Prediction)
# Low variance = high confidence in timing. High variance = low confidence.
best_variance = 0.0
if best_time_window_start is not None:
# Find index in time_grid again (approx)
# Optimization: store best_index in loop?
# Just map time back to index
idx_start = int((best_time_window_start / target_duration) * len(time_grid))
idx_end = min(
idx_start + len(current_window_values), len(envelope_arrays["std"])
)
if idx_end > idx_start:
window_std = envelope_arrays["std"][idx_start:idx_end]
if len(window_std) > 0:
best_variance = float(np.mean(window_std))
# Cap progress at 99% until actual completion
best_progress = max(0.0, min(best_progress, 99.0))
# Log with envelope metadata
cycle_count = envelope.get("cycle_count", 0)
avg_sample_rates_raw = envelope.get("sampling_rates", [1.0])
avg_sample_rates = (
cast(list[float | int], avg_sample_rates_raw)
if isinstance(avg_sample_rates_raw, list)
else [1.0]
)
avg_sample_rate = (
float(np.median(np.array(avg_sample_rates, dtype=float)))
if avg_sample_rates
else 1.0
)
tws = (
best_time_window_start
if best_time_window_start is not None
else float(current_duration)
)
if not in_bounds:
self._logger.debug(
"Phase detection: progress=%.1f%%, score=%.3f, var=%.1fW, "
"time=%.0f/%.0fs [OUT OF BOUNDS, %s cycles, avg_sample_rate=%.1fs]",
best_progress,
best_score,
best_variance,
tws,
target_duration,
cycle_count,
avg_sample_rate,
)
else:
self._logger.debug(
"Phase detection: progress=%.1f%%, score=%.3f, var=%.1fW, "
"time=%.0f/%.0fs [IN BOUNDS, %s cycles, avg_sample_rate=%.1fs]",
best_progress,
best_score,
best_variance,
tws,
target_duration,
cycle_count,
avg_sample_rate,
)
return (best_progress, best_variance)
def _notify_update(self) -> None:
"""Notify entities of update."""
async_dispatcher_send(self.hass, SIGNAL_WASHER_UPDATE.format(self.entry_id))
def notify_update(self) -> None:
"""Public method to notify entities of update."""
self._notify_update()
@property
def is_user_paused(self) -> bool:
"""Return True if cycle is currently user-paused."""
return self._is_user_paused
@property
def is_clean_state(self) -> bool:
"""Return True if machine is in Clean state (cycle ended, door not yet opened)."""
return self._is_clean_state
@property
def net_elapsed_seconds(self) -> float:
"""Elapsed seconds in the current cycle, excluding user-paused time."""
raw = float(self.detector.get_elapsed_seconds())
paused = self._total_user_paused_seconds
if self._user_pause_start is not None:
paused += (dt_util.now() - self._user_pause_start).total_seconds()
return max(0.0, raw - paused)
def check_state(self):
"""Return current detector state."""
if self.recorder.is_recording:
return STATE_RUNNING
if self._is_clean_state and self.detector.state == STATE_OFF:
return STATE_CLEAN
if self._is_user_paused:
return STATE_USER_PAUSED
return self.detector.state
def list_phase_catalog(self, device_type: str) -> list[dict[str, Any]]:
"""Return the merged phase catalog for a device type."""
return self.profile_store.list_phase_catalog(device_type)
def get_profile_phase_ranges_for_device(
self,
profile_name: str,
device_type: str,
) -> list[dict[str, Any]]:
"""Return phase ranges assigned to a profile for a given device type."""
return self.profile_store.get_profile_phase_ranges_for_device(
profile_name,
device_type,
)
@property
def sub_state(self) -> str | None:
"""Return more granular state info (e.g. current phase)."""
if self.recorder.is_recording:
return "Recording"
return self.detector.sub_state
@property
def current_program(self):
"""Return the current program name."""
return self._current_program
@property
def time_remaining(self):
"""Return estimated time remaining in seconds."""
return self._time_remaining
@property
def total_duration(self) -> float | None:
"""Return total predicted duration in seconds."""
return self._total_duration
@property
def last_total_duration_update(self) -> datetime | None:
"""Return when total duration was last refined."""
return self._last_total_duration_update
@property
def cycle_progress(self):
"""Return cycle progress as a percentage."""
return self._cycle_progress
@property
def current_power(self):
"""Return current power reading in watts."""
return self._current_power
@property
def cycle_start_time(self) -> datetime | None:
"""Return the start time of the current cycle."""
return self.detector.current_cycle_start
@property
def last_match_details(self) -> dict[str, Any] | None:
"""Return details of the last profile match."""
res = getattr(self, "_last_match_result", None)
return res.to_dict() if res else None
@property
def samples_recorded(self):
"""Return the number of power samples recorded in current cycle."""
return len(self.detector.get_power_trace())
@property
def sample_interval_stats(self):
"""Return statistics about sampling intervals."""
return self._sample_interval_stats
@property
def pump_stuck(self) -> bool:
"""Return True if the pump stuck threshold has fired for the current cycle."""
return self._pump_stuck
@property
def pump_runs_today(self) -> int:
"""Return the number of completed pump cycles that started in the last 24 hours.
Counts all past cycles whose ``start_time`` falls within the rolling 24-hour
window ending now. Returns 0 for non-pump device types.
"""
if self.device_type != DEVICE_TYPE_PUMP:
return 0
cutoff = dt_util.now().timestamp() - 86400.0
count = 0
for cycle in self.profile_store.get_past_cycles():
start_raw = cycle.get("start_time")
if not start_raw:
continue
try:
if isinstance(start_raw, str):
parsed = dt_util.parse_datetime(start_raw)
if parsed is None:
continue
ts = parsed.timestamp()
else:
ts = float(start_raw)
if ts >= cutoff:
count += 1
except (TypeError, ValueError):
continue
return count
@property
def cycle_count(self) -> int:
"""Return the total number of completed cycles stored for this device."""
return len(self.profile_store.get_past_cycles())
@property
def manual_program_active(self) -> bool:
"""Return True if a manual program override is active."""
return getattr(self, "_manual_program_active", False)
def set_manual_program(self, profile_name: str) -> None:
"""Manually set the current program."""
if self.detector.state != "running":
return
profiles_raw: Any = None
try:
profiles_raw = self.profile_store.get_profiles()
except Exception: # pylint: disable=broad-exception-caught
profiles_raw = None
if isinstance(profiles_raw, dict):
profiles: dict[str, Any] = cast(dict[str, Any], profiles_raw)
else:
profiles_fallback = getattr(self.profile_store, "_data", {}).get(
"profiles", {}
)
profiles = (
cast(dict[str, Any], profiles_fallback)
if isinstance(profiles_fallback, dict)
else {}
)
if profile_name not in profiles:
self._logger.warning("Cannot set manual program: '%s' not found", profile_name)
return
self._current_program = profile_name
self._manual_program_active = True
# Update expected duration immediately
profile = profiles.get(profile_name)
if profile:
avg = float(profile.get("avg_duration", 0.0))
if avg > 0:
self._matched_profile_duration = avg
self._logger.info(
"Manual program set to %s, duration=%.0fs", profile_name, avg
)
# Update estimates if running
if self.detector.state == "running":
self._update_estimates()
async def async_pause_cycle(self) -> None:
"""Pause the current cycle (user-triggered).
Sets verified_pause so the cycle is not finalized when power drops.
Optionally cuts power to the switch entity if CONF_PAUSE_CUTS_POWER is enabled.
"""
if self.detector.state not in (STATE_RUNNING, STATE_STARTING, STATE_PAUSED, STATE_ENDING):
self._logger.debug(
"async_pause_cycle: ignored (detector state=%s)", self.detector.state
)
return
if self._is_user_paused:
self._logger.debug("async_pause_cycle: already user-paused, ignoring")
return
self._logger.info("Cycle paused by user")
prev_verified = self.detector._verified_pause
self._is_user_paused = True
self._user_pause_start = dt_util.now()
self.detector.set_verified_pause(True)
if self._pause_cuts_power:
switch_entity = self.config_entry.options.get(
CONF_SWITCH_ENTITY
) or self.config_entry.data.get(CONF_SWITCH_ENTITY)
if switch_entity:
self._logger.info(
"pause_cuts_power: turning off switch %s", switch_entity
)
try:
await self.hass.services.async_call(
"switch", "turn_off", {"entity_id": switch_entity}, blocking=True
)
except HomeAssistantError as err:
self._logger.warning(
"pause_cuts_power: failed to turn off %s: %s - rolling back pause state",
switch_entity, err,
)
self._is_user_paused = False
self._user_pause_start = None
self.detector.set_verified_pause(prev_verified)
return
snapshot = self.detector.get_state_snapshot()
snapshot["manual_program"] = self._manual_program_active
snapshot["notified_start"] = self._notified_start
snapshot["start_event_fired"] = self._start_event_fired
snapshot["is_user_paused"] = self._is_user_paused
snapshot["user_pause_start"] = (
self._user_pause_start.isoformat() if self._user_pause_start else None
)
snapshot["total_user_paused_seconds"] = self._total_user_paused_seconds
self.hass.async_create_task(self.profile_store.async_save_active_cycle(snapshot))
self._notify_update()
async def async_resume_cycle(self) -> None:
"""Resume a user-paused cycle.
Accumulates elapsed paused time and clears the verified pause flag.
Optionally restores power via the switch entity if CONF_PAUSE_CUTS_POWER is enabled.
"""
if not self._is_user_paused:
self._logger.debug("async_resume_cycle: not user-paused, ignoring")
return
now = dt_util.now()
prev_pause_start = self._user_pause_start
accumulated = (
(now - prev_pause_start).total_seconds()
if prev_pause_start is not None else 0.0
)
self._total_user_paused_seconds += accumulated
self._user_pause_start = None
self._is_user_paused = False
self.detector.set_verified_pause(False)
self._logger.info(
"Cycle resumed by user (total paused: %.0fs)", self._total_user_paused_seconds
)
if self._pause_cuts_power:
switch_entity = self.config_entry.options.get(
CONF_SWITCH_ENTITY
) or self.config_entry.data.get(CONF_SWITCH_ENTITY)
if switch_entity:
self._logger.info(
"pause_cuts_power: turning on switch %s", switch_entity
)
try:
await self.hass.services.async_call(
"switch", "turn_on", {"entity_id": switch_entity}, blocking=True
)
except HomeAssistantError as err:
self._logger.warning(
"pause_cuts_power: failed to turn on %s: %s - rolling back resume state",
switch_entity, err,
)
self._total_user_paused_seconds -= accumulated
self._user_pause_start = prev_pause_start
self._is_user_paused = True
self.detector.set_verified_pause(True)
return
snapshot = self.detector.get_state_snapshot()
snapshot["manual_program"] = self._manual_program_active
snapshot["notified_start"] = self._notified_start
snapshot["start_event_fired"] = self._start_event_fired
snapshot["is_user_paused"] = self._is_user_paused
snapshot["user_pause_start"] = (
self._user_pause_start.isoformat() if self._user_pause_start else None
)
snapshot["total_user_paused_seconds"] = self._total_user_paused_seconds
self.hass.async_create_task(self.profile_store.async_save_active_cycle(snapshot))
self._notify_update()
async def async_terminate_cycle(self) -> None:
"""Force terminate the current cycle via user request."""
self._logger.warning("Force terminating cycle by user request")
# Trigger natural cycle end via detector
# This will call _on_cycle_end callback, which handles:
# - Saving to profile store
# - Clearing active cycle persistence
# - Post-processing/Merging
# - Notifications
self.detector.user_stop()
# We DO NOT clear manager state manually here (e.g. self._current_program)
# because we want the UI to show the "Clean" state with the just-finished
# program info. The standard reset timers in _on_cycle_end /
# _async_power_changed will handle cleanup after delay.
# Force a state update to reflect the change immediately
self._notify_update()
async def async_start_recording(self) -> None:
"""Start manual recording of a cycle."""
if self.recorder.is_recording:
self._logger.warning("Already recording")
return
# Ensure we are in a clean state (stop any running cycle first?)
# If running, user should probably stop it? Or force stop?
# Plan said "unregulated", so we just start recording.
# But if cycle_detector thinks it's running, we should probably "pause" it
# or just override state. My override in checks_state handles UI.
# But should we clear current program?
if self.detector.state != "off":
self._logger.info("Forcing detector reset before recording")
self.detector.reset()
await self.recorder.start_recording()
self._notify_update()
async def async_stop_recording(self) -> None:
"""Stop manual recording."""
if not self.recorder.is_recording:
return
await self.recorder.stop_recording()
self._notify_update()
def clear_manual_program(self) -> None:
"""Clear manual program override."""
if not self._manual_program_active:
return
self._manual_program_active = False
# If running, revert to detecting so auto-detection can resume?
if self.detector.state == "running":
self._current_program = "detecting..."
self._matched_profile_duration = None
self._update_estimates() # Trigger immediate re-detection attempt
else:
# If not running, clear the forced program
self._current_program = "off"
self._matched_profile_duration = None
self._notify_update()
self._logger.info("Manual program cleared, reverting to auto-detection")
async def _run_post_cycle_processing(self) -> None:
"""Run post-cycle processing (merge fragments, split anomalies)."""
try:
# User Feedback: Use 5 hour lookback and configured gap settings
stats = await self.profile_store.async_run_maintenance()
# Log significant actions
merged = stats.get("merged_cycles", 0)
split = stats.get("split_cycles", 0)
if merged > 0 or split > 0:
self._logger.info(
"Post-cycle processing: Merged %s, Split %s cycle(s)", merged, split
)
# Note: async_run_maintenance saves automatically if changes occur
except Exception as e: # pylint: disable=broad-exception-caught
self._logger.error("Post-cycle processing failed: %s", e)