Files
HomeAssistantVS/custom_components/ha_washdata/manager.py
T

6558 lines
290 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# WashData - Home Assistant integration for appliance cycle monitoring via smart plugs.
# Copyright (C) 2026 Lukas Bandura
# SPDX-License-Identifier: AGPL-3.0-or-later
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""Manager for WashData."""
# pylint: disable=broad-exception-caught
from __future__ import annotations
import logging
import hashlib
import inspect
import math
import uuid
import asyncio
from asyncio import Task
from collections.abc import Coroutine
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any, cast
import numpy as np
if TYPE_CHECKING:
from .store import StoreBridge
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import Context, Event, HomeAssistant, State, callback
from homeassistant.helpers.event import (
async_call_later,
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_CYCLE_TIMERS,
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_PROGRESS_RESET_DELAY,
CONF_LEARNING_CONFIDENCE,
CONF_DURATION_TOLERANCE,
CONF_AUTO_LABEL_CONFIDENCE,
CONF_AUTO_MAINTENANCE,
CONF_MAINTENANCE_REMINDER_CYCLES,
DEFAULT_MAINTENANCE_REMINDER_CYCLES,
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_POWER_OFF_THRESHOLD_W,
CONF_POWER_OFF_DELAY,
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,
NOTIFY_EVENT_TIMER,
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_COMPLETION_MIN_SECONDS,
DEFAULT_NOTIFY_BEFORE_END_MINUTES,
DEFAULT_PROFILE_MATCH_THRESHOLD,
DEFAULT_PROFILE_UNMATCH_THRESHOLD,
DEFAULT_SAMPLING_INTERVAL,
DEFAULT_PROGRESS_RESET_DELAY,
DEFAULT_POWER_OFF_THRESHOLD_W,
DEFAULT_POWER_OFF_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_ENERGY_SENSOR,
CONF_PEAK_RATE_THRESHOLD,
CONF_PEAK_RATE_MESSAGE,
DEFAULT_PEAK_RATE_MESSAGE,
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,
CONF_NOTIFY_MILESTONES,
CONF_NOTIFY_MILESTONE_MESSAGE,
DEFAULT_NOTIFY_MILESTONES,
DEFAULT_NOTIFY_MILESTONE_MESSAGE,
STATE_CLEAN,
STATE_FINISHED,
STATE_INTERRUPTED,
STATE_FORCE_STOPPED,
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_COMPLETION_THRESHOLDS,
CYCLE_UNDERRUN_ANOMALY_RATIO,
ENERGY_ANOMALY_Z_THRESHOLD,
TERMINAL_DROP_MIN_CLEAN_CYCLES,
TERMINAL_DROP_MIN_QUIET_SPAN_S,
TERMINAL_DROP_EARLINESS_RATIO,
TERMINAL_DROP_MIN_PEAK_RATIO,
TERMINAL_DROP_PEAK_FAMILIAR_TOL,
ML_MATCH_COMMIT_THRESHOLD,
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,
is_terminal_drop,
terminal_drop_baseline,
)
from .signal_processing import integrate_wh, energy_gap_threshold_s
from .recorder import CycleRecorder
from .diag_buffer import DiagBuffer
from .log_utils import DeviceLoggerAdapter
from .time_utils import power_data_to_offsets
from . import progress as progress_mod
from . import notification_rules as notif_rules
from .phase_segmenter import phase_matching_enabled
_LOGGER = logging.getLogger(__name__)
# Finish-type notification events that would wake someone and are therefore gated by
# the quiet-hours (do-not-disturb) window. Live-progress ticks (NOTIFY_EVENT_LIVE)
# and the start notification (NOTIFY_EVENT_START) are intentionally excluded.
_QUIET_HOURS_EVENT_TYPES = frozenset(
{NOTIFY_EVENT_FINISH, NOTIFY_EVENT_CLEAN, "pre_complete"}
)
def _sanitize_ranking(raw_list: list[dict[str, Any]], limit: int = 5) -> list[dict[str, Any]]:
"""Top-N ranking candidates stripped of the heavy `current`/`sample` power
arrays, safe to persist on cycle_data and to include in the 32KB-limited
EVENT_CYCLE_ENDED payload."""
out: list[dict[str, Any]] = []
for cand in (raw_list or [])[:limit]:
out.append({
"name": cand.get("name"),
"score": round(float(cand.get("score", 0.0)), 3),
"profile_duration": cand.get("profile_duration"),
})
return out
# Notification-data keys that may only be forwarded to mobile_app_* notify targets.
# Strict-schema platforms (e.g. Signal) reject unknown keys, so these are added per
# service only when the target is a mobile app. Includes the iOS Live Activity
# enrichment keys (subtitle/content_state/activity) so they never reach non-mobile
# platforms.
_MOBILE_ONLY_EXTRA_KEYS = (
"tag",
"timeout",
"channel",
"priority",
"actions",
"sticky",
"subtitle",
"content_state",
"activity",
)
def _pn_create(
hass: HomeAssistant,
message: str,
*,
title: str | None = None,
notification_id: str | None = None,
) -> None:
"""Best-effort persistent notification creation.
Deliberately goes through ``hass.components.persistent_notification`` rather than
a direct ``homeassistant.components.persistent_notification`` import: the test
suite stubs out the whole ``homeassistant`` module and mocks this dynamic
attribute, so a direct import would both fail under test and bypass those mocks.
Failures are logged at debug (not silently swallowed) so a stuck notification is
at least visible in the logs.
"""
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: # noqa: BLE001 - best-effort; surface the failure in logs
_LOGGER.debug("persistent_notification create failed (id=%s)", notification_id, exc_info=True)
def _pn_dismiss(hass: HomeAssistant, notification_id: str) -> None:
"""Best-effort persistent notification dismissal.
Uses the ``hass.components`` accessor for the same test-mocking reason as
:func:`_pn_create`; failures are logged at debug rather than swallowed.
"""
try:
components = getattr(cast(Any, hass), "components", None)
pn = getattr(cast(Any, components), "persistent_notification", None)
if pn is None:
return
result = pn.async_dismiss(notification_id)
if inspect.iscoroutine(result):
hass.async_create_task(result)
except Exception: # noqa: BLE001 - best-effort; surface the failure in logs
_LOGGER.debug("persistent_notification dismiss failed (id=%s)", notification_id, exc_info=True)
class WashDataManager:
"""Manages a single washing machine instance."""
@property
def store_bridge(self) -> "StoreBridge":
"""Lazy community-store bridge (kept for the entry so the token cache persists)."""
if self._store_bridge is None:
from .store import StoreBridge
self._store_bridge = StoreBridge(self.hass, self.profile_store)
return self._store_bridge
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_script: Any = None # cached Script; invalidated on options reload
self._notify_people: list[str] = []
self._notify_cycle_timers: list[dict[str, Any]] = []
self._fired_cycle_timers: set[int] = set()
self._timer_pause_pn_id: str | None = None
self._timer_pause_mobile_tag: str | None = None
self._remove_timer_action_listener: Any | None = None
self._timer_ui_strings: dict[str, 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]] = []
# Quiet-hours (do-not-disturb) hold queue + release timer. Finish-type
# notifications that would fire inside the window are parked here and flushed
# at the end of the window by a single async_call_later timer.
self._quiet_pending_notifications: list[dict[str, Any]] = []
self._remove_quiet_hours_timer: Any | None = None
self._remove_notify_people_listener = None
self._live_notification_sent_count = 0
# HA restart gap tracking: gaps in the power trace caused by integration
# restarts during an active cycle. Each entry is a dict:
# start_ts: ISO timestamp of gap start (= last snapshot save time)
# end_ts: ISO timestamp of gap end (= restoration time)
# gap_seconds: duration in seconds
# profile: matched profile name at restoration time, or None
# match_confidence: match confidence at restoration time, or None
# Cleared and stored into cycle_data["restart_gaps"] at cycle end.
# Matching always uses real readings only; this list is for display/anomaly.
self._restart_gaps: list[dict[str, Any]] = []
# External energy-meter snapshot for the current cycle (issue #316).
# Captured at cycle start, read back at cycle end for the start->end delta.
# Both survive a restart via the active-cycle snapshot.
self._energy_meter_start: float | None = None
self._energy_meter_source: str | None = None
# 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
# Set by _dispatch_notification when a call is queued for later (quiet
# hours / presence) rather than sent; read by dedup-flag callers.
self._last_dispatch_deferred: 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
# iOS Live Activity: whether the "start" lifecycle marker has been emitted on
# the first live notification of the current cycle. Reset per cycle.
self._live_activity_started = 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
# Per-cycle UUID used to key ranking snapshots; prevents cross-contamination
# between cycles that happen to share the same second-resolution start_time.
self._ranking_snapshot_cycle_id: str = ""
# State
self._current_power = 0.0
# Power-based Off detection (issue #284): timestamp at which power first fell
# below the power-off threshold while in a terminal state. None = not currently
# below (or feature disabled). Cleared on new cycle / when power rises.
self._power_off_below_since: datetime | None = None
# One-shot cancellable timer armed when power first drops below the power-off
# threshold, so the terminal->Off reset fires promptly after power_off_delay
# instead of waiting for the next 60s expiry poll. Cancelled on power rise /
# nag hold / terminal reset / new cycle.
self._remove_power_off_timer: Any | None = None
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._cycle_end_task: Task[Any] | None = None
# Detached store-touching tasks (matching trigger, active-cycle clear,
# post-cycle processing) tracked so async_shutdown can cancel them before a
# reload/unload swaps the ProfileStore out from under them.
self._background_tasks: set[Task[Any]] = set()
self._is_shutdown: bool = False
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_BY_DEVICE.get(
self.device_type, 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)
self._store_bridge: Any = None # lazy community-store bridge (online features)
# 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)
)
# 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,
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,
)
),
power_off_threshold_w=float(
config_entry.options.get(
CONF_POWER_OFF_THRESHOLD_W, DEFAULT_POWER_OFF_THRESHOLD_W
)
),
power_off_delay=float(
config_entry.options.get(CONF_POWER_OFF_DELAY, DEFAULT_POWER_OFF_DELAY)
),
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._spawn_tracked(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,
end_confidence_provider=self._ml_end_confidence,
terminal_drop_provider=self._terminal_drop_provider,
)
self._ml_end_expectation_cache: tuple[str, dict[str, float]] | None = None
# (cycle_count, earliest_quiet_offset|None, peak_range|None) for the
# terminal-drop baselines; keyed by cycle count so it auto-invalidates
# when history grows.
self._terminal_drop_cache: (
tuple[int, float | None, tuple[float, float] | None] | None
) = None
# Cycle count an executor refresh of the baseline is in-flight/done for, so
# the loop never recomputes it (issue #311) and never double-schedules.
self._terminal_drop_refresh_n: int | None = None
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
# Live projected total energy/cost for the running cycle (None until a
# reliable progress estimate exists). Derived from accumulated energy and
# the (ML-blended) progress fraction; surfaced as progress-sensor attrs.
self._projected_energy_wh: float | None = None
self._projected_cost: float | None = None
# Runtime overrun anomaly (soft, visible; never a notification). "none"
# or "overrun" once a running cycle exceeds its matched profile's expected
# duration by CYCLE_OVERRUN_ANOMALY_RATIO. Surfaced as a state-sensor attr
# and frozen onto the cycle at end for panel badging.
self._cycle_anomaly: str = "none"
self._overrun_ratio: float = 0.0
# Post-cycle anomaly cache: holds energy/underrun anomaly from the last
# completed cycle so sensor attributes can surface them after idle.
self._last_cycle_post_anomaly: dict = {}
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._remove_ml_training_scheduler = None
self._ml_training_failures = 0 # consecutive gate failures for auto-disable
self._ml_training_running = False # True while a training run is in flight
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
# Skip match entirely when no real profiles exist — nothing to match against.
if not self.profile_store.has_real_profiles:
self._logger.debug("Matching skipped: no real profiles configured yet")
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
# --- Live-match features: compute always for ranking history + ML gate ---
# Features are cheap scalars derived from the current trace. We compute
# them whenever there is a non-ambiguous candidate so they can be recorded
# as a training snapshot regardless of whether ML models are opted in.
# The opt-in ML commit check then uses the same features when enabled.
ml_commit_score: float | None = None
_live_feat: dict[str, float] | None = None
_top2_score: float | None = None
if (
profile_name
and profile_name != "detecting..."
and not result.is_ambiguous
and self._current_program in ("detecting...",)
):
try:
from .ml.feature_extraction import live_match_features # noqa: PLC0415
first_ts = readings[0][0]
pts = [
((ts - first_ts).total_seconds(), float(pw))
for ts, pw in readings
]
top1_dist = max(0.0, 1.0 - confidence)
top2_raw: float | None = None
if len(result.candidates) > 1:
s2 = result.candidates[1].get("score", 0.0) or 0.0
top2_raw = max(0.0, 1.0 - float(s2))
_top2_score = float(result.candidates[1].get("score", 0.0) or 0.0)
n_profiles = len(self.profile_store.get_profiles())
_live_feat = live_match_features(
points=pts,
elapsed_s=current_duration,
top1_distance=top1_dist,
top2_distance=top2_raw,
top1_median_duration_s=float(result.expected_duration or 0),
candidate_count=max(1, n_profiles),
)
# --- ML early-commit gate (opt-in) ---
# When the user has opted into experimental ML models, query the
# live_match_commit model for P(top-1 is correct).
try:
from .ml.engine import ml_models_enabled, resolve_scorer # noqa: PLC0415
if ml_models_enabled(self.config_entry.options):
match_fn, _ = resolve_scorer("live_match", self.profile_store)
if match_fn is not None:
ml_commit_score = float(match_fn(_live_feat))
self._logger.debug(
"Live-match ML commit score for '%s': %.3f (threshold %.2f)",
profile_name,
ml_commit_score,
ML_MATCH_COMMIT_THRESHOLD,
)
except Exception: # noqa: BLE001
pass
except Exception: # noqa: BLE001 - ML must never break matching
pass
# --- Record ranking snapshot for live_match on-device training ---
# Snapshot is recorded unconditionally (not gated on ML opt-in) so that
# training data accumulates even before the user enables ML models.
# Confirmed labels are back-filled at cycle end.
if _live_feat is not None and self._cycle_start_time:
try:
self.profile_store.record_match_ranking_snapshot(
start_time_iso=self._cycle_start_time.isoformat(),
features=_live_feat,
top1_profile=profile_name or "",
top1_score=float(confidence),
top2_score=_top2_score,
candidate_count=max(1, len(self.profile_store.get_profiles())),
cycle_id=self._ranking_snapshot_cycle_id,
)
except Exception: # noqa: BLE001 - never break matching
pass
ml_early_commit = (
ml_commit_score is not None
and ml_commit_score >= ML_MATCH_COMMIT_THRESHOLD
and confidence >= 0.30
)
# 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)"
elif ml_early_commit:
should_switch = True
switch_reason = (
f"initial_match (ML commit score {ml_commit_score:.3f} >= {ML_MATCH_COMMIT_THRESHOLD})"
)
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 ---
# Check alignment if we have a match and power is low, to confirm if
# this is a legitimate (auto-detected) pause or a mismatch. Skipped
# while the user has explicitly paused (issue #306): the user pause is
# authoritative and must not be re-judged by the envelope heuristic
# (see the verified_pause override below).
stop_thresh = float(self.detector.config.stop_threshold_w)
if current_matched and current_power < stop_thresh and not self._is_user_paused:
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
# A user-initiated pause (Pause Cycle button, or the door-open soft
# pause) stays in force until the user resumes (issue #306). The
# heuristics above only govern *auto-detected* low-power phases; without
# this override they clear verified_pause and the cycle is finalized
# (leaving the "Paused by user" state, e.g. a dishwasher closes at the
# 1 h min-off-gap timeout) instead of waiting for Resume. Re-asserting
# here also repairs the flag after a restart, since the detector state
# snapshot does not persist _verified_pause.
if self._is_user_paused:
verified_pause = True
# --- 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"
# 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, result.is_ambiguous,
result.is_prefix_ambiguous)
)
# --- 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,
)
# B4: append a peak-rate advisory tip when the current price is
# at/above the configured threshold. Purely informational.
tip = self._peak_rate_tip(
self.config_entry.options, self._resolve_energy_price()
)
if tip:
msg = f"{msg}\n{tip}"
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
return _sanitize_ranking(raw_list)
@property
def phase_description(self) -> str:
"""Return a description of the current phase.
Prefers the *functional* progress-driven phase (the visual per-profile
phase configurator's ranges, indexed by the live ML-blended progress) so
the readout stays accurate even when a cycle runs longer/shorter than the
profile's nominal timeline. Falls back to the matcher's phase, then the
detector sub-state/state.
"""
live = self._current_phase_from_progress()
if live:
return live
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
def _current_phase_from_progress(self) -> str | None:
"""Live phase from the profile's configured ranges + ML-blended progress.
This is the *merge* of the visual phase configurator with the runtime
estimator: one phase definition (the per-profile ranges the user draws),
indexed by the smoothed progress fraction rather than raw elapsed seconds,
so overrun/underrun cycles still name the phase correctly. Returns None
(caller falls back) when not running, no profile is matched, or the
profile has no configured phase ranges. Never raises.
"""
return progress_mod.current_phase(
self.profile_store,
self.detector.state,
self._current_program,
self._cycle_progress,
)
@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
@property
def last_ambiguity_margin(self) -> float | None:
"""Return the score margin between top-1 and top-2 candidates, or None."""
result = self._last_match_result
if result is None:
return None
return getattr(result, "ambiguity_margin", None)
# 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:
# decompress_power_data returns (offset_seconds, watts)
# tuples, but restore_state_snapshot parses reading[0]
# as an ISO datetime. Convert offsets to absolute ISO
# timestamps (base = cycle start) so the resurrected
# trace is not silently dropped (B4).
_res_start = dt_util.parse_datetime(
last_cycle["start_time"]
)
if _res_start is not None:
if _res_start.tzinfo is None:
_res_start = _res_start.replace(
tzinfo=dt_util.now().tzinfo
)
power_readings = [
(
(
_res_start
+ timedelta(seconds=float(off))
).isoformat(),
p,
)
for off, p in power_data
]
else:
power_readings = 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_readings,
"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:
# A cycle paused by the user while still in STARTING is promoted
# to PAUSED before restoration so that (a) the false-start abort
# cannot fire on the first low-power reading, and (b) the PAUSED
# branch of the restore block below re-applies the user-pause state
# and re-asserts verified_pause (issue #306).
if (
active_snapshot_to_restore.get("state") == STATE_STARTING
and active_snapshot_to_restore.get("is_user_paused")
):
active_snapshot_to_restore = {
**active_snapshot_to_restore,
"state": STATE_PAUSED,
}
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
)
# Restore the external energy-meter snapshot (issue #316) so a
# restart mid-cycle keeps an accurate start->end delta.
self._energy_meter_start = active_snapshot_to_restore.get(
"energy_meter_start"
)
self._energy_meter_source = active_snapshot_to_restore.get(
"energy_meter_source"
)
# 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)
)
# get_state_snapshot() does not persist the detector's
# verified-pause flag, so a user-paused cycle would otherwise be
# finalized on the first ENDING timeout after a restart (issue
# #306). Re-assert it so the pause survives the reload.
if self._is_user_paused:
self.detector.set_verified_pause(True)
# Record the restart gap so the Cycles tab can shade it and
# anomaly detection can surface it. Only meaningful when
# last_save is known and the dark period exceeds 30 s.
# Matching always uses real readings only (Option B from the
# gap-fill analysis); synthetic fill is intentionally NOT added
# to _power_readings to prevent circular-bias inflation.
if last_save:
gap_end = dt_util.now()
gap_secs = (gap_end - last_save).total_seconds()
if gap_secs > 30:
self._restart_gaps.append({
"start_ts": last_save.isoformat(),
"end_ts": gap_end.isoformat(),
"gap_seconds": round(gap_secs, 1),
"profile": self.detector.matched_profile,
"match_confidence": getattr(
self.detector, "match_confidence", None
),
})
self._logger.info(
"HA restart gap recorded: %.0fs (%.1f min) in active cycle; "
"power trace has a hole — no synthetic fill (matching integrity)",
gap_secs,
gap_secs / 60,
)
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()
try:
_trans = await translation.async_get_translations(
self.hass, self.hass.config.language, "options", {DOMAIN}
)
# Cache of manager-side fixed UI-string templates resolved from the
# options.error.* translation namespace (timer notifications, the
# duration-vs-typical finish variable, and the live "waiting" message).
# The inline English mirrors the strings.json values so the fallback is
# never the sole source and the code stays behaviour-identical in English.
self._timer_ui_strings = {
k: _trans.get(f"component.{DOMAIN}.options.error.{k}", v)
for k, v in {
"timer_default_message": "{device}: {minutes} min timer",
"timer_pause_action_title": "Resume Cycle",
"timer_pause_body_suffix": "The cycle is paused. Open the WashData panel to resume.",
"vs_typical_longer": "{pct}% longer than usual",
"vs_typical_shorter": "{pct}% shorter than usual",
"notify_live_waiting_message": "{device}: No profile matched yet.",
}.items()
}
except Exception: # noqa: BLE001
pass
# 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()
# Register schedulers (maintenance + ML training). These are also re-
# registered on every config reload; calling them here ensures they
# survive HA restarts without requiring the user to re-save settings.
await self._setup_maintenance_scheduler()
self._setup_ml_training_scheduler()
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 [])
raw_timers = config_entry.options.get(CONF_NOTIFY_CYCLE_TIMERS, []) or []
self._notify_cycle_timers = [
t for t in raw_timers
if isinstance(t, dict) and isinstance(t.get("offset_minutes"), (int, float)) and t["offset_minutes"] > 0
]
# 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
# 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
)
)
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)
)
# 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_power_off_threshold_w = float(
config_entry.options.get(
CONF_POWER_OFF_THRESHOLD_W, DEFAULT_POWER_OFF_THRESHOLD_W
)
)
new_power_off_delay = float(
config_entry.options.get(CONF_POWER_OFF_DELAY, DEFAULT_POWER_OFF_DELAY)
)
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.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.power_off_threshold_w = new_power_off_threshold_w
self.detector.config.power_off_delay = new_power_off_delay
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
):
self._logger.info(
"Updated detector config: min_power %.1fW->%.1fW, off_delay %ds->%ds, "
"smoothing %d->%d, interrupted_min %ds->%ds",
old_min_power,
new_min_power,
old_off_delay,
new_off_delay,
old_smoothing,
new_smoothing,
old_interrupted_min,
new_interrupted_min,
)
# 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_BY_DEVICE.get(
self.device_type, 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
)
# Keep the detector's copy in sync: _should_defer_finish() reads
# detector.config.min_duration_ratio, which otherwise keeps the
# construction-time value until a restart (diverging from the matcher).
self.detector.config.min_duration_ratio = new_min_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_script = None
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()
# 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()
# Schedule on-device ML retraining if enabled (Stage 4, gated)
self._setup_ml_training_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")
def _spawn_tracked(self, coro: Coroutine[Any, Any, Any]) -> Task[Any]:
"""Create a detached task and track it so shutdown can cancel it.
Use for fire-and-forget tasks that touch the ProfileStore (matching
trigger, active-cycle clear, post-cycle processing): if a reload/unload
swaps the store out mid-flight, an untracked task would keep writing to the
stale store. The task auto-removes itself from the set when it finishes.
"""
task = self.hass.async_create_task(coro)
# Real HA always returns a Task; guard for degenerate returns (e.g. a
# mocked hass in tests) so tracking never breaks the caller.
if task is not None and hasattr(task, "add_done_callback"):
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
return task
async def async_shutdown(self) -> None:
"""Shutdown."""
self._is_shutdown = True
# Cancel in-flight matching and cycle-end tasks so they don't race a
# freshly-loaded ProfileStore on reload_config_entry.
_to_await: list[Task[Any]] = []
if self._matching_task and not self._matching_task.done():
self._matching_task.cancel()
_to_await.append(self._matching_task)
if self._cycle_end_task and not self._cycle_end_task.done():
self._cycle_end_task.cancel()
_to_await.append(self._cycle_end_task)
# Cancel every other tracked detached task (matching trigger, active-cycle
# clear, post-cycle processing) for the same reason.
for task in list(self._background_tasks):
if not task.done():
task.cancel()
_to_await.append(task)
# Drain cancelled tasks so they don't race the freshly-reloaded ProfileStore.
if _to_await:
await asyncio.gather(*_to_await, return_exceptions=True)
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 = []
# Cancel any pending quiet-hours release timer so it doesn't fire after unload.
self._cancel_quiet_hours_timer()
self._quiet_pending_notifications = []
# Cancel the power-off one-shot reset timer so it can't fire post-unload.
self._cancel_power_off_timer()
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()
if self._remove_ml_training_scheduler:
self._remove_ml_training_scheduler()
self._remove_ml_training_scheduler = None
self.diag_buffer.uninstall()
# Dismiss the timer-pause notification so it doesn't linger on mobile or
# sidebar after HA restarts / integration unloads.
try:
self._clear_timer_pause_notification()
except Exception: # noqa: BLE001
pass
# 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._augment_active_snapshot(self.detector.get_state_snapshot())
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,
allow_presence_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
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)
# Refresh persisted cycle health as part of nightly maintenance.
await self.async_recompute_cycle_health()
except Exception as err: # pylint: disable=broad-exception-caught
self._logger.error("Maintenance failed: %s", err, exc_info=True)
# Fire daily at local midnight with a single, cleanly-cancellable handle.
# async_track_time_change auto-repeats every day, so there is no manual
# rescheduling that could leak handles or double-register the callback.
self._remove_maintenance_scheduler = evt.async_track_time_change(
self.hass, run_maintenance, hour=0, minute=0, second=0
)
self._logger.info("Scheduled daily maintenance at local midnight")
def _setup_ml_training_scheduler(self) -> None:
"""Schedule the daily on-device ML retraining (Stage 4, gated).
Uses ``async_track_time_change`` which fires every day at the configured
hour with a single, cleanly-cancellable handle (no manual rescheduling).
No-op unless the ``ENABLE_ML_TRAINING`` build flag and the per-device
opt-in are both set.
"""
from .const import (
ENABLE_ML_TRAINING,
CONF_ML_TRAINING_ENABLED,
CONF_ML_TRAINING_HOUR,
DEFAULT_ML_TRAINING_ENABLED,
DEFAULT_ML_TRAINING_HOUR,
)
if self._remove_ml_training_scheduler:
self._remove_ml_training_scheduler()
self._remove_ml_training_scheduler = None
if not ENABLE_ML_TRAINING:
return
opts = {**self.config_entry.data, **self.config_entry.options}
if not opts.get(CONF_ML_TRAINING_ENABLED, DEFAULT_ML_TRAINING_ENABLED):
self._logger.debug("On-device ML training disabled")
return
try:
hour = int(opts.get(CONF_ML_TRAINING_HOUR, DEFAULT_ML_TRAINING_HOUR))
except (TypeError, ValueError):
hour = DEFAULT_ML_TRAINING_HOUR
hour = max(0, min(23, hour))
async def _scheduled(_now: datetime) -> None:
await self.async_run_ml_training(force=False)
self._remove_ml_training_scheduler = evt.async_track_time_change(
self.hass, _scheduled, hour=hour, minute=0, second=0
)
self._logger.info("Scheduled on-device ML training daily at %02d:00", hour)
async def async_run_ml_training(self, force: bool = False) -> dict[str, Any]:
"""Retrain the ML models from this device's own cycles (gated + guarded).
Returns a summary dict. ``force`` bypasses the min-cycle / interval /
idle guards (used by the manual service). Never raises to the caller.
"""
from .const import (
ENABLE_ML_TRAINING,
CONF_ML_TRAINING_MIN_CYCLES,
CONF_ML_TRAINING_INTERVAL_DAYS,
DEFAULT_ML_TRAINING_MIN_CYCLES,
DEFAULT_ML_TRAINING_INTERVAL_DAYS,
EVENT_ML_TRAINING_COMPLETE,
)
if not ENABLE_ML_TRAINING:
return {"ok": False, "reason": "ml_training_disabled"}
opts = {**self.config_entry.data, **self.config_entry.options}
cycles = self.profile_store.get_past_cycles()
if not force:
# Don't train mid-cycle; wait for a quiet moment.
if self.detector and self.detector.state in {
STATE_RUNNING, STATE_PAUSED, STATE_STARTING, STATE_ENDING
}:
self._logger.debug("Skipping scheduled ML training: device active")
return {"ok": False, "reason": "device_active"}
min_cycles = int(opts.get(CONF_ML_TRAINING_MIN_CYCLES, DEFAULT_ML_TRAINING_MIN_CYCLES))
if len(cycles) < min_cycles:
self._logger.debug(
"Skipping scheduled ML training: need %d cycles, have %d",
min_cycles,
len(cycles),
)
return {"ok": False, "reason": f"need {min_cycles} cycles, have {len(cycles)}"}
# Respect the minimum retrain interval.
interval_days = int(
opts.get(CONF_ML_TRAINING_INTERVAL_DAYS, DEFAULT_ML_TRAINING_INTERVAL_DAYS)
)
last = self._last_ml_training_at()
if last is not None:
age_days = (dt_util.now() - last).total_seconds() / 86400.0
if age_days < interval_days:
self._logger.debug(
"Skipping scheduled ML training: retrained %.1fd ago (<%dd)",
age_days,
interval_days,
)
return {"ok": False, "reason": f"retrained {age_days:.1f}d ago (<{interval_days}d)"}
if self._ml_training_running:
return {"ok": False, "reason": "already_running"}
self._ml_training_running = True
self.notify_update()
try:
from .ml.training_task import async_run_training
summary = await async_run_training(self.hass, self)
except Exception as err: # noqa: BLE001 - training must never break the integration
self._logger.error("On-device ML training failed: %s", err, exc_info=True)
return {"ok": False, "reason": "exception", "error": str(err)}
finally:
self._ml_training_running = False
self.notify_update()
promoted = list(summary.get("promoted", {}).keys())
if promoted:
self._ml_training_failures = 0
# Consumers (ML Lab, MLSuggestionEngine) read the trained specs live
# from the store via ml.engine.resolve_scorer, so no refresh is needed.
self._logger.info("On-device ML training promoted models: %s", promoted)
else:
self._ml_training_failures += 1
self._logger.info(
"On-device ML training produced no promotable models (attempt %d)",
self._ml_training_failures,
)
# Stage 4/5: tune the matcher's scoring weights from this device's own
# cycles (same held-out promotion discipline as the models). Independent
# of model promotion; runs on every training pass.
matching = await self._tune_matching_config(cycles)
# Record that training *ran* now, regardless of whether anything was
# promoted, so "Last trained" advances on every run (a run that doesn't
# beat the baseline previously left the timestamp stuck at the last
# promotion). Never let a persistence hiccup break the run.
try:
_run_iso = dt_util.now().isoformat()
await self.profile_store.set_ml_last_training_run(_run_iso)
# Track each capability's held-out score over time (drift/fit trend).
await self.profile_store.append_ml_training_history(
_run_iso, summary.get("results", [])
)
except Exception as err: # noqa: BLE001
self._logger.debug("Failed to persist last-training-run timestamp: %s", err)
self.hass.bus.async_fire(
EVENT_ML_TRAINING_COMPLETE,
{
"entry_id": self.entry_id,
"device_name": self.config_entry.title,
"promoted": promoted,
"results": summary.get("results", []),
"matching": matching,
},
)
# A promoted model changes the health-model signature, so recompute the
# persisted per-cycle health now rather than lazily on the next view.
if promoted:
try:
await self.async_recompute_cycle_health()
except Exception as err: # noqa: BLE001
self._logger.debug("Post-training health recompute failed: %s", err)
return {
"ok": True,
"promoted": promoted,
"results": summary.get("results", []),
"matching": matching,
}
async def _tune_matching_config(self, cycles: list[dict[str, Any]]) -> dict[str, Any]:
"""Tune + (if it beats the shipped defaults on a held-out split) persist
the matcher's scoring weights for this device. Executor-offloaded and
never raises. Returns the tuner status dict for logging / the UI event.
"""
try:
from .ml.matching_tuner import tune_matching_config
result = await self.hass.async_add_executor_job(tune_matching_config, cycles)
except Exception as err: # noqa: BLE001 - tuning must never break training
self._logger.debug("Matching-config tuning failed: %s", err)
return {"promoted": False, "reason": "exception", "error": str(err)}
if result.get("promoted") and result.get("config"):
record = {
"config": result["config"],
"trained_at": dt_util.now().isoformat(),
"cycle_count": len(cycles),
"baseline_test_top1": result.get("baseline_test_top1"),
"tuned_test_top1": result.get("tuned_test_top1"),
}
await self.profile_store.set_matching_config(record)
self._logger.info(
"On-device matcher tuning promoted (top-1 %.3f -> %.3f): %s",
result.get("baseline_test_top1") or 0.0,
result.get("tuned_test_top1") or 0.0,
result["config"],
)
else:
self._logger.debug(
"On-device matcher tuning not promoted: %s", result.get("reason")
)
return result
async def async_recompute_cycle_health(self) -> int:
"""Recompute + persist per-cycle ML health against the current model.
Health is cached on each cycle and only recomputed at defined triggers
(this method): on-device retraining, scheduled auto-maintenance and the
Diagnostics "Process History" action. Panel loads reuse the cache. Runs
the CPU work in an executor and never raises to the caller.
"""
import functools # pylint: disable=import-outside-toplevel
try:
from .ws_api import _compute_ml_comparison # pylint: disable=import-outside-toplevel
except Exception: # pylint: disable=broad-exception-caught
return 0
opts = {**self.config_entry.data, **self.config_entry.options}
off_delay = int(opts.get(CONF_OFF_DELAY, DEFAULT_OFF_DELAY))
try:
result = await self.hass.async_add_executor_job(
functools.partial(
_compute_ml_comparison, self.profile_store, off_delay, force_recompute=True
)
)
except Exception as err: # noqa: BLE001
self._logger.debug("Cycle-health recompute failed: %s", err)
return 0
health_updates = result.get("_health_updates", {})
if health_updates:
for cycle in self.profile_store.get_past_cycles():
cid = cycle.get("id")
if cid in health_updates:
cycle["ml_health"] = health_updates[cid]
if result.get("_health_dirty") or health_updates:
await self.profile_store.async_save()
return int(result.get("evaluated_count", 0))
def _last_ml_training_at(self) -> datetime | None:
"""When on-device training last *ran* (not just last promoted a model).
Prefers the persisted last-run timestamp so a manual/scheduled run that
produced no promotable model still advances "Last trained" and the retrain
interval. Falls back to the newest promoted model's ``trained_at`` for
installs from before run-time tracking existed.
"""
run_iso = self.profile_store.get_ml_last_training_run()
if isinstance(run_iso, str):
parsed = dt_util.parse_datetime(run_iso)
if parsed is not None:
return parsed
latest: datetime | None = None
for record in (self.profile_store.get_ml_model_versions() or {}).values():
ts = record.get("trained_at") if isinstance(record, dict) else None
if not isinstance(ts, str):
continue
try:
parsed = dt_util.parse_datetime(ts)
except (ValueError, TypeError):
parsed = None
if parsed is not None and (latest is None or parsed > latest):
latest = parsed
return latest
@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.
# Low-power readings bypass throttling when:
# (a) a cycle is active (RUNNING/ENDING/PAUSED) — critical end-of-cycle signal, or
# (b) this is a genuine power DROP from above min_power — captures power-off events
# that occur before the detector has processed the previous above-threshold reading.
# Without the guard, an idle device at 0W fires an update on every sensor poll (typically
# every 15 s), flooding the detector with zero-value no-ops.
min_p = float(self.detector.config.min_power)
# For the "genuine drop" bypass, compare against the previous RAW sensor
# value (old_state), not _current_power: the latter is only updated after a
# reading passes the throttle, so a suppressed high reading would leave it
# low and the following low reading would be throttled too, missing a short
# high->low transition. old_state reflects the plug's actual prior value.
prev_raw_power = self._current_power
old_state = cast(State | None, event_data.get("old_state"))
if old_state is not None and old_state.state not in (
STATE_UNKNOWN, STATE_UNAVAILABLE
):
try:
prev_raw_power = float(old_state.state)
except ValueError:
pass
is_low_power = power < min_p and (
self.detector.state in (STATE_RUNNING, STATE_PAUSED, STATE_ENDING)
or prev_raw_power >= min_p # genuine drop from active power
)
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._augment_active_snapshot(self.detector.get_state_snapshot())
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,
)
elif self._last_dispatch_deferred:
# Held for quiet-hours / presence delivery; the queued copy
# fires later. Mark handled so the 60s expiry tick doesn't
# enqueue a duplicate nag every minute for the whole window.
self._notified_clean_laundry = True
else:
self._notified_clean_laundry = True
# Defer leaving the terminal state while a clean-state unload notification is
# still pending. Without this guard the 30-min progress reset (or an early
# power-off) fires before the unload nag, clearing _is_clean_state before the
# notification can fire. Both expiry modes below honour it.
nag_pending = (
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
)
# Power-based Off detection (issue #284): opt-in, and only valid when the
# threshold sits below stop_threshold_w (so it cannot fire while a cycle could
# still be running, and cannot re-trigger the #267 spin-down ghost cycle). It is
# evaluated ONLY in a terminal state; active states never reach here because
# _cycle_completed_time is None until cycle end.
cfg = self.detector.config
pot = cfg.power_off_threshold_w
stop_w = cfg.stop_threshold_w
power_off_enabled = (
isinstance(pot, (int, float))
and isinstance(stop_w, (int, float))
and 0.0 < pot < stop_w
and self.detector.state
in (STATE_FINISHED, STATE_INTERRUPTED, STATE_FORCE_STOPPED)
)
if power_off_enabled:
# Power owns the Off transition. The classic timer still zeroes the progress
# bar after progress_reset_delay, but the terminal state PERSISTS until the
# machine is actually switched off (no timer fallback, by design: a machine
# whose standby never drops below the threshold stays "Finished").
if (
time_since_complete > self._progress_reset_delay
and self._cycle_progress != 0.0
):
self._cycle_progress = 0.0
self._notify_update()
if nag_pending:
# Hold the terminal state (and pause power sampling) until the nag fires.
self._power_off_below_since = None
self._cancel_power_off_timer()
return
if self._current_power < cfg.power_off_threshold_w:
if self._power_off_below_since is None:
self._power_off_below_since = now
# Arm a precise one-shot reset instead of waiting for the next
# 60s poll (the poll below stays as a backstop).
self._arm_power_off_timer(cfg.power_off_delay)
elif (
now - self._power_off_below_since
).total_seconds() >= cfg.power_off_delay:
self._logger.debug(
"Power-based Off: %.2fW below %.2fW for >= %.0fs in %s. "
"Resetting to OFF.",
self._current_power,
cfg.power_off_threshold_w,
cfg.power_off_delay,
self.detector.state,
)
self._reset_terminal_to_off()
else:
# Power rose back above the threshold: restart the debounce window.
self._power_off_below_since = None
self._cancel_power_off_timer()
return
# Timer-based Off (feature disabled): classic behaviour, unchanged.
self._power_off_below_since = None
self._cancel_power_off_timer()
if time_since_complete > self._progress_reset_delay:
if nag_pending:
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._reset_terminal_to_off()
def _reset_terminal_to_off(self) -> None:
"""Return a terminal state (Finished/Interrupted/Force-Stopped, incl. the Clean
overlay) to OFF and clear all post-cycle bookkeeping.
Single owner of the terminal -> OFF transition, shared by the timer-based and
the power-based (issue #284) expiry paths so the two can never diverge.
"""
self._cycle_progress = 0.0
self._cycle_completed_time = None
# Clear the Clean overlay too, or check_state() keeps reporting "Clean".
self._is_clean_state = False
self._clean_state_start = None
self._notified_clean_laundry = False
self._power_off_below_since = None
self._cancel_power_off_timer()
self.detector.reset(STATE_OFF)
self._stop_state_expiry_timer()
self._notify_update()
def _cancel_power_off_timer(self) -> None:
"""Cancel the pending power-off one-shot reset timer, if armed."""
if self._remove_power_off_timer is not None:
self._remove_power_off_timer()
self._remove_power_off_timer = None
def _arm_power_off_timer(self, delay: float) -> None:
"""Arm a single cancellable one-shot power-off reset timer.
Fires ``delay`` seconds after power first fell below the power-off
threshold, so the terminal->Off transition does not have to wait for the
next 60s expiry poll. The callback re-verifies the condition before acting,
so a timer left armed after power rose (before the next poll cancels it) is
a harmless no-op.
"""
self._cancel_power_off_timer()
@callback
def _fire(_now: datetime) -> None:
self._remove_power_off_timer = None
self._power_off_timer_check()
self._remove_power_off_timer = async_call_later(
self.hass, max(0.0, float(delay)), _fire
)
def _power_off_timer_check(self) -> None:
"""One-shot power-off timer callback: reset to Off only if still valid."""
cfg = self.detector.config
pot = cfg.power_off_threshold_w
stop_w = cfg.stop_threshold_w
# Same enable + terminal-state guard as the poll path.
if not (
isinstance(pot, (int, float))
and isinstance(stop_w, (int, float))
and 0.0 < pot < stop_w
and self.detector.state
in (STATE_FINISHED, STATE_INTERRUPTED, STATE_FORCE_STOPPED)
):
self._power_off_below_since = None
return
# Re-verify the below-threshold debounce (power may have risen since arming).
if self._power_off_below_since is None or self._current_power >= pot:
return
if (
dt_util.now() - self._power_off_below_since
).total_seconds() < cfg.power_off_delay:
return
# Honour the clean-laundry unload nag hold (mirrors the poll path).
if (
self._is_clean_state
and not self._notified_clean_laundry
and self._notify_unload_delay_minutes > 0
and self._cycle_completed_time is not None
and (dt_util.now() - self._cycle_completed_time).total_seconds()
< self._notify_unload_delay_minutes * 60
):
return
self._logger.debug(
"Power-based Off (one-shot timer): %.2fW below %.2fW for >= %.0fs in %s. "
"Resetting to OFF.",
self._current_power,
pot,
cfg.power_off_delay,
self.detector.state,
)
self._reset_terminal_to_off()
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
# Secondary zombie guard for unmatched cycles (expected == 0 when no profile
# has been matched). The detector hard-caps RUNNING at 8h but a chatty sensor
# that never goes silent can keep the watchdog from reaching the end state.
# Kill here after 4h so a stuck false-start doesn't run indefinitely.
elif (
expected == 0
and not self._is_user_paused
and not _verified_pause_zombie
and elapsed > 14400
# Gate on the active detector state, not _current_program: the latter is
# only set to "detecting..." on the RUNNING transition, so a cycle stuck
# in STARTING keeps a stale program and would never hit this 4h guard.
and self.detector.state in (
STATE_STARTING, STATE_RUNNING, STATE_PAUSED, STATE_ENDING
)
):
self._logger.warning(
"Watchdog: Unmatched cycle exceeded 4h (%.0fs). Force-ending.",
elapsed,
)
self.detector.force_end(now)
self._current_power = 0.0
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._power_off_below_since = None
self._cancel_power_off_timer()
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._last_cycle_post_anomaly = {} # Clear previous cycle's anomaly cache
self._cycle_start_time = self.detector.current_cycle_start or dt_util.now()
self._ranking_snapshot_cycle_id = str(uuid.uuid4())
self._reset_live_notification_state()
# Snapshot the external energy meter (issue #316) so cycle end can
# take an accurate start->end delta. No-op when none is configured.
self._snapshot_energy_meter_start()
# 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._fired_cycle_timers = set()
self._clear_timer_pause_notification()
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
# Mark the start fully handled ONLY when there is no push to send, so
# the restart-recovery fallback does not re-enter for event-only
# configs. When a push service/action IS configured, leave
# _notified_start False so the push block below still fires: a config
# with both events and push must get both (event delivery is tracked
# separately by _start_event_fired).
if not (self._notify_start_services or self._notify_actions):
self._notified_start = 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,
)
# B4: append a peak-rate advisory tip when the current price is
# at/above the configured threshold. Purely informational.
tip = self._peak_rate_tip(
self.config_entry.options, self._resolve_energy_price()
)
if tip:
msg = f"{msg}\n{tip}"
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 _discard_cycle_cleanup(self) -> None:
"""Discard cleanup for a ghost/noise blip that is never persisted.
The detector still transitions into a terminal state (FINISHED/INTERRUPTED)
when it fires the cycle-end callback, and a live active-cycle snapshot may be
sitting in the store. The normal cycle-end tail clears that snapshot and arms
the terminal-state expiry so the UI returns to Off; a suppressed ghost skips
that tail, so mirror the essential parts here — otherwise the device is
stranded in a terminal state with a stale active snapshot until the next
cycle. Deliberately does NOT persist, notify, or run the learning pipeline.
"""
self._spawn_tracked(self.profile_store.async_clear_active_cycle())
# Anchor the terminal state so _handle_state_expiry (and power-off) can act,
# then arm the expiry timer that resets terminal -> Off after the reset delay.
self._cycle_completed_time = dt_util.now()
self._start_state_expiry_timer()
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
self._clear_timer_pause_notification()
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])
# Shared trapezoidal integrator with a data-driven outage gap
# (single source with ProfileStore.add_cycle).
cycle_energy_wh = integrate_wh(
ts, ps, max_gap_s=energy_gap_threshold_s(ts)
)
except (TypeError, ValueError, ArithmeticError):
cycle_energy_wh = 0.0
# Ghost cycle: short AND low energy (real cycles have energy even if short).
# Suppress it exactly like the dishwasher pump-out branch below: feed the
# auto-tune counter but do NOT store it or run the cycle-end pipeline. Without
# the return a sub-60 s / sub-0.05 Wh blip would fall through to persistence
# and the (un-gated) finish notification, firing a phantom "cycle finished".
if duration < 60 and cycle_energy_wh < 0.05:
self._handle_noise_cycle(max_power)
self._discard_cycle_cleanup()
return # Do not store this as a real cycle
if 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)
self._discard_cycle_cleanup()
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)
# External energy meter (issue #316): when an accurate start->end delta is
# available, record it alongside the integrated value and mark the source.
# The integrated energy_wh above is left untouched so matching / ML / anomaly
# stats stay internally consistent; only user-facing figures prefer the meter.
cycle_data["energy_source"] = "integration"
meter_wh = self._compute_meter_energy_wh()
if meter_wh is not None:
cycle_data["energy_meter_wh"] = round(meter_wh, 3)
cycle_data["energy_source"] = "meter"
# Schedule heavy post-processing asynchronously. Capture this cycle's identity
# token so the async tail can tell if a NEW cycle started while it was awaiting
# (power changes are handled synchronously, so a back-to-back load can drive the
# detector into a fresh RUNNING before post-processing completes). See B1 in
# _async_process_cycle_end.
end_token = self._ranking_snapshot_cycle_id
if not self._is_shutdown:
self._cycle_end_task = self._spawn_tracked(
self._async_process_cycle_end(cycle_data, cycle_token=end_token)
)
def _ml_end_confidence(
self, points: list[tuple[float, float]], expected_duration: float
) -> float | None:
"""Opt-in ML end-guard provider handed to the CycleDetector.
Returns P(the latest low-power event is the true cycle end) from the
shipped or on-device-trained cycle-end model, or ``None`` when ML models
are disabled for this device, no profile is matched, or the model /
features are unavailable. ``None`` means the detector keeps its existing
power/energy-based behavior, so this can only ever *defer* a completion.
"""
try:
from .ml.engine import ml_models_enabled, resolve_scorer
if not ml_models_enabled(self.config_entry.options):
return None
profile_name = self._current_program
if (
not profile_name
or profile_name in ("off", "detecting...", "restored...")
or profile_name not in self.profile_store.get_profiles()
):
return None
end_fn, _ = resolve_scorer("end", self.profile_store)
if end_fn is None:
return None
expectation = self._profile_end_expectation(profile_name, expected_duration)
if expectation is None:
return None
from .ml.feature_extraction import latest_end_event_features
features = latest_end_event_features(points, expectation)
if features is None:
return None
return float(end_fn(features))
except Exception as err: # noqa: BLE001 - ML must never break detection
self._logger.debug("ML end-guard scoring skipped: %s", err)
return None
def _profile_end_expectation(
self, profile_name: str, expected_duration: float
) -> dict[str, float] | None:
"""Median duration/energy/peak for a matched profile, for end features.
Cached per profile so the guard does not re-decompress history on every
low-power reading during ENDING. The detector's authoritative expected
duration overrides the median when available.
"""
expectation, self._ml_end_expectation_cache = progress_mod.profile_end_expectation(
self.profile_store,
profile_name,
expected_duration,
self._ml_end_expectation_cache,
)
return expectation
def _terminal_drop_provider(
self, points: list[tuple[float, float]], expected_duration: float
) -> bool:
"""Opt-in terminal-drop detector handed to the CycleDetector.
Returns ``True`` when the current low-power event is a hard cliff-to-~0
that began at an elapsed offset EARLIER than this device has ever
legitimately gone quiet (learned from its own completed cycles) - i.e. an
anomalously-early drop that is almost certainly a real stop (plug pulled /
cancelled), not a soak pause. The detector then finalizes quickly instead
of waiting out the full soak-bridging ``min_off_gap``.
A very early drop is below the matcher's duration gate, so match
confidence is not available to confirm familiarity; instead the cycle's
**power level** must be one this device has produced before (see
``is_terminal_drop``) - a cycle drawing power unlike anything in its
history is treated as a possible new program and deferred.
Returns ``False`` (keep the proven slow path) when the ML/anomaly opt-in
is off, there is too little history to trust the baseline, the cycle
looks novel, or the drop is not anomalously early. Never raises - the
anomaly signal must never break detection.
"""
try:
from .ml.engine import ml_models_enabled
if not ml_models_enabled(self.config_entry.options):
return False
earliest, peak_range = self._terminal_drop_baseline()
return is_terminal_drop(
points,
earliest,
peak_range,
float(self.detector.config.stop_threshold_w),
TERMINAL_DROP_EARLINESS_RATIO,
TERMINAL_DROP_MIN_PEAK_RATIO,
TERMINAL_DROP_PEAK_FAMILIAR_TOL,
)
except Exception as err: # noqa: BLE001 - anomaly signal must never break detection
self._logger.debug("Terminal-drop detection skipped: %s", err)
return False
def _terminal_drop_baseline(self) -> tuple[float | None, tuple[float, float] | None]:
"""Cached (earliest-quiet-offset, historical-peak-range) for this device.
Both are learned from the device's completed cycles and used by the
terminal-drop detector (anomaly + familiarity gates). Keyed by cycle
count so it refreshes as history grows.
The recompute decompresses every completed trace, which is too heavy to run
on the event loop inside the detector's reading path (issue #311). So this
NEVER recomputes synchronously: on a miss/stale cache it schedules an
executor refresh and serves the last known baseline in the meantime (one
cycle stale is harmless for an anomaly heuristic). Until the first refresh
lands there is no baseline, so it returns ``(None, None)`` and
``is_terminal_drop`` defers to the proven slow end-detection."""
cycles = self.profile_store.get_past_cycles()
n = len(cycles)
cache = self._terminal_drop_cache
if cache is not None and cache[0] == n:
return cache[1], cache[2]
self._schedule_terminal_drop_refresh(n)
if cache is not None:
return cache[1], cache[2]
return None, None
def _schedule_terminal_drop_refresh(self, n: int) -> None:
"""Kick a one-shot executor refresh of the terminal-drop baseline for the
current cycle count, unless one is already in-flight/done for it."""
if self._terminal_drop_refresh_n == n:
return
self._terminal_drop_refresh_n = n
self.hass.async_create_task(self._async_refresh_terminal_drop_baseline(n))
async def _async_refresh_terminal_drop_baseline(self, n: int) -> None:
"""Recompute the baseline off the event loop and cache it. Never raises -
the anomaly signal must never break detection."""
try:
# Snapshot the list on the loop before handing it to the executor.
cycles = list(self.profile_store.get_past_cycles())
stop_threshold = float(self.detector.config.stop_threshold_w)
earliest, peak_range = await self.hass.async_add_executor_job(
terminal_drop_baseline,
cycles,
stop_threshold,
TERMINAL_DROP_MIN_QUIET_SPAN_S,
TERMINAL_DROP_MIN_CLEAN_CYCLES,
)
self._terminal_drop_cache = (len(cycles), earliest, peak_range)
except Exception as err: # noqa: BLE001 - anomaly signal must never break detection
self._logger.debug("Terminal-drop baseline refresh failed: %s", err)
# Allow a later reading to retry the refresh for this count.
if self._terminal_drop_refresh_n == n:
self._terminal_drop_refresh_n = None
def _ml_progress_percent(
self,
trace: list[tuple[datetime, float]],
profile_name: str,
) -> float | None:
"""ML completion-fraction estimate (0-100) for the running cycle, or None.
Uses the on-device ``remaining_time`` regressor (a ``standardized_linear``
head with no shipped baseline) to predict how far through the cycle we
are, learning this device's own progress curve rather than assuming the
matched profile's median duration. Gated on the ML opt-in and only active
once training has promoted a regressor; otherwise returns ``None`` so the
caller keeps the proven phase-aware estimate untouched. Never raises.
"""
return progress_mod.ml_progress_percent(
self.profile_store,
self.config_entry.options,
float(self._matched_profile_duration or 0.0),
trace,
profile_name,
self._profile_end_expectation,
self._logger,
)
def _ml_energy_total(
self,
trace: list[tuple[datetime, float]],
profile_name: str,
) -> float | None:
"""Predicted total cycle energy (Wh) from the on-device ``total_energy``
regressor, or None.
The regressor predicts the *energy-completion fraction* (energy so far ÷
final energy); total = ``energy_so_far / fraction``. Because energy
accumulates non-linearly (heating front-loads it), this is more stable —
especially early — than dividing accumulated energy by *time* progress
(the fallback in :meth:`_update_projected_energy`). Same gating as the
remaining-time regressor: opt-in, inert until a model is promoted. Never
raises.
"""
return progress_mod.ml_energy_total(
self.profile_store,
self.config_entry.options,
float(self._matched_profile_duration or 0.0),
trace,
profile_name,
self._profile_end_expectation,
self._logger,
)
def _compute_cycle_quality_score(
self,
cycle_data: dict[str, Any],
past_cycles_snapshot: list[dict[str, Any]] | None = None,
) -> None:
"""Score a just-finished cycle with the hybrid_curve_quality model (opt-in).
When ML models are enabled for this device, computes P(cycle is a problem)
and stores it under ``cycle_data["ml_quality_score"]``. A high score means
the cycle may be mis-detected or corrupt; the learning manager uses it to
downgrade auto-labeling to a feedback request so the user can confirm.
Never raises — scoring failure is silently ignored to keep cycle storage safe.
"""
try:
from .ml.engine import ml_models_enabled, resolve_scorer
from .ml.feature_extraction import quality_features
if not ml_models_enabled(self.config_entry.options):
return
quality_fn, _ = resolve_scorer("quality", self.profile_store)
if quality_fn is None:
return
profile_name = cycle_data.get("profile_name")
if not profile_name:
return
points = decompress_power_data(cycle_data)
if not points or len(points) < 4:
return
# Build profile median stats from stored labeled cycles.
durations: list[float] = []
energies: list[float] = []
peaks: list[float] = []
# This function runs in an executor thread and the event loop may
# append cycles concurrently; iterating (or even copying) the live list
# here is a data race. Prefer the snapshot taken on the event loop at
# the call site; only fall back to a local copy for direct callers.
cycles = (
past_cycles_snapshot
if past_cycles_snapshot is not None
else list(self.profile_store.get_past_cycles())
)
for c in cycles:
if c.get("profile_name") != profile_name:
continue
if c.get("duration") is not None:
durations.append(float(c["duration"]))
if c.get("energy_wh") is not None:
energies.append(float(c["energy_wh"]))
if c.get("max_power") is not None:
peaks.append(float(c["max_power"]))
if not durations:
return
med_dur = float(np.median(durations))
med_energy = float(np.median(energies)) if energies else 500.0
med_peak = float(np.median(peaks)) if peaks else 500.0
match_conf = float(cycle_data.get("match_confidence") or 0.0)
conf_known = match_conf > 0
proxy_dist = max(0.0, 1.0 - match_conf) if conf_known else 0.25
proxy_margin = match_conf if conf_known else 0.30
proxy_fit = match_conf if conf_known else 0.75
feat = quality_features(
points=points,
profile_median_duration_s=med_dur,
profile_median_energy_wh=med_energy,
profile_median_peak_w=med_peak,
profile_distance=proxy_dist,
label_margin=proxy_margin,
profile_fit_score=proxy_fit,
flag_count=len(cycle_data.get("artifacts", [])),
)
score = round(float(quality_fn(feat)), 3)
cycle_data["ml_quality_score"] = score
self._logger.debug(
"ML quality score (profile=%s): %.3f", profile_name, score
)
except Exception: # noqa: BLE001 - never break cycle storage
pass
def _resolve_energy_price(self) -> float | None:
"""Current energy price per kWh, or None when none is configured.
A price entity (e.g. a dynamic tariff) takes precedence over the static
value. Used to freeze each cycle's cost at completion time.
"""
options = self.config_entry.options
price_entity = options.get(CONF_ENERGY_PRICE_ENTITY)
if price_entity:
state = self.hass.states.get(price_entity)
if state is not None:
try:
return float(state.state)
except (ValueError, TypeError):
pass
static = options.get(CONF_ENERGY_PRICE_STATIC)
if static is not None:
try:
return float(static)
except (ValueError, TypeError):
pass
return None
def _read_energy_meter(self) -> tuple[float, str] | None:
"""Read the configured external energy meter, normalized to Wh.
Returns ``(value_wh, entity_id)`` or ``None`` when no meter is configured
or its reading is not usable (unknown/unavailable/non-numeric state, or an
unrecognised unit). Never raises -- every failure path returns ``None`` so
the caller falls back to the integrated energy (issue #316).
"""
entity_id = self.config_entry.options.get(CONF_ENERGY_SENSOR)
if not entity_id:
return None
state = self.hass.states.get(entity_id)
if state is None or state.state in (None, "unknown", "unavailable", ""):
return None
try:
value = float(state.state)
except (ValueError, TypeError):
return None
unit = str(state.attributes.get("unit_of_measurement") or "").strip().lower()
# Normalize to Wh. An unrecognised unit is treated as unusable so a
# mis-configured entity falls back rather than reporting a wrong figure.
scale = {"wh": 1.0, "kwh": 1000.0, "mwh": 1_000_000.0}.get(unit)
if scale is None:
return None
return value * scale, entity_id
def _snapshot_energy_meter_start(self) -> None:
"""Capture the meter reading at cycle start (issue #316)."""
snap = self._read_energy_meter()
if snap is None:
self._energy_meter_start = None
self._energy_meter_source = None
else:
self._energy_meter_start, self._energy_meter_source = snap
def _compute_meter_energy_wh(self) -> float | None:
"""Cycle energy from the external meter's start->end delta, or None.
Falls back (returns ``None``) when: no start was captured; no meter is
configured now; the configured entity differs from the one snapshotted at
start (source changed mid-cycle -- no cross-meter delta); the current
reading is unusable; or the delta is <= 0 (counter reset or stuck plug).
"""
start = self._energy_meter_start
source = self._energy_meter_source
if start is None or source is None:
return None
cur = self._read_energy_meter()
if cur is None:
return None
cur_wh, cur_source = cur
if cur_source != source:
return None
delta = cur_wh - start
if delta <= 0:
return None
return delta
@staticmethod
def _cycle_report_energy_wh(cycle_data: dict[str, Any]) -> float:
"""User-facing reported energy (Wh): meter value if present, else integrated.
The integrated ``energy_wh`` is always stored and used internally (matching,
ML, anomaly, envelopes); only cost/lifetime/notifications/panel display
prefer the more accurate meter figure when one was captured (issue #316).
"""
meter = cycle_data.get("energy_meter_wh")
if meter is not None:
try:
return float(meter)
except (ValueError, TypeError):
pass
try:
return float(cycle_data.get("energy_wh", 0.0))
except (ValueError, TypeError):
return 0.0
def _augment_active_snapshot(self, snapshot: dict[str, Any]) -> dict[str, Any]:
"""Add manager-owned fields to a detector snapshot before persisting.
The detector snapshot only carries detector state; these fields are owned
by the manager and must survive a restart alongside it. Kept in one place
so every save site (shutdown, periodic, pause, resume) stays consistent.
"""
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
snapshot["energy_meter_start"] = self._energy_meter_start
snapshot["energy_meter_source"] = self._energy_meter_source
return snapshot
@staticmethod
def _format_vs_typical(
duration: float,
median: float | None,
*,
longer_template: str = "{pct}% longer than usual",
shorter_template: str = "{pct}% shorter than usual",
) -> str:
"""Human comparison of a cycle's duration to its profile median.
Returns "" when there is no usable median or the difference is under 1%.
This fills the ``vs_typical`` variable of the finish-message template. The
text itself is fixed (not user-editable), so it is localizable: callers pass
the resolved ``options.error.vs_typical_*`` templates; the English defaults
here mirror strings.json and are used as the resilient fallback.
"""
try:
if not median or float(median) <= 0:
return ""
pct = round((float(duration) - float(median)) / float(median) * 100)
except (ValueError, TypeError, ZeroDivisionError):
return ""
try:
if pct >= 1:
return longer_template.format(pct=pct)
if pct <= -1:
return shorter_template.format(pct=abs(pct))
except (KeyError, IndexError, ValueError):
# Malformed translation template; fall back to the English default.
if pct >= 1:
return f"{pct}% longer than usual"
if pct <= -1:
return f"{abs(pct)}% shorter than usual"
return ""
def _peak_rate_tip(self, options: dict[str, Any], price: float | None) -> str:
"""Return a peak-rate advisory tip for the start notification, or "".
Appended only when a positive ``peak_rate_threshold`` is configured and the
current price meets/exceeds it. Purely informational — no scheduling or
appliance control. Never raises; a bad threshold is skipped silently.
"""
try:
raw = options.get(CONF_PEAK_RATE_THRESHOLD)
if raw in (None, ""):
return ""
threshold = float(raw)
if threshold <= 0 or price is None or float(price) < threshold:
return ""
tip_template = options.get(CONF_PEAK_RATE_MESSAGE) or DEFAULT_PEAK_RATE_MESSAGE
return self._safe_format_template(
tip_template,
fallback_template=DEFAULT_PEAK_RATE_MESSAGE,
device=self.config_entry.title,
price=f"{float(price):.3f}",
)
except (ValueError, TypeError):
return ""
async def _async_process_cycle_end(
self, cycle_data: dict[str, Any], cycle_token: str | None = None
) -> None:
"""Process cycle completion asynchronously (heavy tasks).
``cycle_token`` is the ``_ranking_snapshot_cycle_id`` captured when this cycle
ended. The terminal-state reset at the tail is skipped if a new cycle has
started since (token changed), so back-to-back cycles are not clobbered (B1).
"""
# 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)
# B1: freeze THIS cycle's live context into immutable locals now, before the
# persistence / auto-label / lifetime-energy awaits below. A new cycle can
# start synchronously during any of those awaits (a back-to-back load drives
# the detector into a fresh RUNNING via _on_state_change, which rolls
# _current_program back to "detecting..." and resets the match fields). The
# tail (event payload, finish notification, learning inputs) must describe the
# cycle that just finished, not whatever the live fields hold by the time each
# await returns. The final match above is what determines these values for
# this cycle, so capture right after it.
program = self._current_program
match_result = self._last_match_result
match_confidence = self._last_match_confidence
matched_profile_duration = self._matched_profile_duration
cycle_anomaly = self._cycle_anomaly
overrun_ratio = self._overrun_ratio
# If we had a runtime match, attach the profile name for persistence
if (
program
and program not in ("off", "detecting...", "restored...")
and program in self.profile_store.get_profiles()
):
cycle_data["profile_name"] = program
cycle_data["label_source"] = "auto_match"
if match_confidence:
cycle_data["match_confidence"] = float(match_confidence)
# Attach extensive debug data if available (and configured)
if match_result:
ranking = getattr(match_result, "ranking", [])
# Top-5 ranking stored unconditionally (small, high training value).
# SANITIZE: strip heavy current/sample arrays — this field is NOT in the
# EVENT_CYCLE_ENDED exclusion set, so it must stay small (32KB limit).
cycle_data["match_ranking_top5"] = _sanitize_ranking(ranking)
cycle_data["debug_data"] = {
"ranking": ranking,
"details": getattr(match_result, "debug_details", {}),
"ambiguous": getattr(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["label_source"] = "auto_label_post"
cycle_data["match_confidence"] = float(res.confidence)
# Top-5 from post-cycle match (may differ from live match ranking).
# Sanitize: strip heavy current/sample arrays (these fields are NOT
# in the fired-event exclusion set, so they must stay small to keep
# EVENT_CYCLE_ENDED under HA's 32KB event-data limit).
cycle_data["match_ranking_top5"] = _sanitize_ranking(
getattr(res, "ranking", [])
)
self._logger.info(
"Post-cycle auto-labeled as '%s' (confidence: %.2f)",
res.best_profile,
res.confidence,
)
# Back-fill confirmed label on any ranking snapshots captured during this cycle
# so the live_match on-device trainer can use them as labelled examples.
_start_iso = cycle_data.get("start_time")
_confirmed_profile = cycle_data.get("profile_name")
if _start_iso and _confirmed_profile:
try:
self.profile_store.confirm_match_ranking_snapshots(
_start_iso,
_confirmed_profile,
# Use the token captured when THIS cycle ended, not the live
# field, which may already have rolled to a newly-started cycle
# during the awaits above (else this cycle's snapshots go
# unlabelled and a new cycle's snapshot gets mislabelled).
cycle_id=cycle_token or None,
)
except Exception: # noqa: BLE001
pass
# Compute envelope conformance for the matched profile.
# Stored as cycle_data["envelope_conformance"] so the panel and quality
# gate can display/use it. Only runs when we have a profile + power trace.
_ep = cycle_data.get("profile_name")
_pd = cycle_data.get("power_data")
if _ep and isinstance(_pd, list) and len(_pd) >= 4:
try:
from .time_utils import power_data_to_offsets # noqa: PLC0415
_pts = [(float(o), float(p)) for o, p in power_data_to_offsets(_pd, _start_iso)]
if len(_pts) >= 4:
conformance_rec = self.profile_store.compute_envelope_conformance(_ep, _pts)
if conformance_rec is not None:
cycle_data["envelope_conformance"] = conformance_rec.get("conformance")
# Transient artifacts (door-open pauses, out-of-band dips/spikes)
# for graph markers + a Cycles-list badge; [] when none.
artifacts = self.profile_store.detect_cycle_artifacts(_ep, _pts)
if artifacts:
cycle_data["artifacts"] = artifacts
except Exception: # noqa: BLE001
pass
# Freeze the runtime overrun anomaly onto the cycle for panel badging.
# "overrun" means the cycle ran materially longer than its matched
# profile's typical duration; purely informational (never a notification).
if cycle_anomaly and cycle_anomaly != "none":
cycle_data["anomaly"] = cycle_anomaly
if overrun_ratio > 0:
cycle_data["overrun_ratio"] = round(float(overrun_ratio), 3)
# A1: Underrun check — computed post-cycle only, not a live signal.
# Only applied when no runtime anomaly was detected (underrun and overrun are mutually exclusive).
try:
if not cycle_data.get("anomaly") or cycle_data["anomaly"] == "none":
_uc_profile = cycle_data.get("profile_name")
_uc_dur = float(cycle_data.get("duration", 0))
if _uc_profile and _uc_dur > 0:
_uc_median = self.profile_store.get_profile_median_duration(_uc_profile)
if (
isinstance(_uc_median, (int, float))
and not isinstance(_uc_median, bool)
and _uc_median > 0
and _uc_dur < _uc_median * CYCLE_UNDERRUN_ANOMALY_RATIO
):
cycle_data["anomaly"] = "underrun"
cycle_data["underrun_ratio"] = round(_uc_dur / _uc_median, 3)
except Exception: # noqa: BLE001
pass
# A2: Energy spike/low anomaly — stored separately from duration anomaly.
try:
_ea_profile = cycle_data.get("profile_name")
_ea_energy = float(cycle_data.get("energy_wh", 0))
if _ea_profile and _ea_energy > 0:
_ea_stats = self.profile_store.get_profile_energy_stats(_ea_profile)
if (
isinstance(_ea_stats, dict)
and isinstance(_ea_stats.get("std_wh"), (int, float))
and _ea_stats["std_wh"] > 0
):
_ea_z = (_ea_energy - _ea_stats["avg_wh"]) / _ea_stats["std_wh"]
cycle_data["energy_z_score"] = round(_ea_z, 2)
if _ea_z > ENERGY_ANOMALY_Z_THRESHOLD:
cycle_data["energy_anomaly"] = "energy_spike"
elif _ea_z < -ENERGY_ANOMALY_Z_THRESHOLD:
cycle_data["energy_anomaly"] = "energy_low"
except Exception: # noqa: BLE001
pass
# Cache post-cycle anomaly data so sensor attributes surface it while idle.
self._last_cycle_post_anomaly = {
k: cycle_data[k]
for k in ("anomaly", "underrun_ratio", "energy_anomaly", "energy_z_score")
if k in cycle_data
}
# Store any HA restart gaps that occurred during this cycle.
# The panel shades these regions in the power trace and shows a badge.
# Matching always uses real readings only (no synthetic fill in power_data).
# Copy them onto the cycle now but keep the source list intact until the
# cycle is confirmed persisted (below) — clearing here would lose them if
# async_add_cycle() fails.
restart_gaps_snapshot: list[dict[str, Any]] | None = None
if self._restart_gaps:
restart_gaps_snapshot = list(self._restart_gaps)
cycle_data["restart_gaps"] = restart_gaps_snapshot
# Freeze the energy cost onto the cycle using the price in effect NOW, so
# later price changes never rewrite historical costs. Stored as a number
# (currency per the configured price's unit); absent when no price is set.
price = self._resolve_energy_price()
if price is not None:
cycle_data["energy_price"] = price
cycle_data["cost"] = round(
self._cycle_report_energy_wh(cycle_data) / 1000.0 * price, 4
)
# Score cycle quality with the ML model before persisting so the score is
# stored on the cycle record and available to the learning manager immediately.
# Must run BEFORE async_add_cycle so get_past_cycles() inside the scorer does
# not yet include the current cycle, keeping reference statistics uncontaminated.
# Only opted-in devices reach the scorer, and only then is it offloaded to the
# executor: on a long trace its NumPy feature extraction is O(N) and must not
# block the event loop (mirrors the profile matcher). Gating here avoids a
# pointless thread-hop for the default (ML-off) case where the scorer no-ops.
# The scorer mutates only cycle_data (nothing else touches it here) and never
# raises, so this is executor-safe.
from .ml.engine import ml_models_enabled # noqa: PLC0415
if ml_models_enabled(self.config_entry.options):
# Snapshot past_cycles on the event loop before offloading: iterating
# (or copying) the live list inside the executor races the loop
# appending this just-finished cycle.
past_cycles_snapshot = list(self.profile_store.get_past_cycles())
await self.hass.async_add_executor_job(
self._compute_cycle_quality_score, cycle_data, past_cycles_snapshot
)
# 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.
cycle_persisted = False
try:
await self.profile_store.async_add_cycle(cycle_data)
cycle_persisted = True
# The cycle (with its restart_gaps) is now durably stored, so it is safe
# to drop the live buffer. Doing this only after a confirmed persist means
# a failed save keeps the gaps for the next cycle-end attempt.
if restart_gaps_snapshot is not None:
self._restart_gaps.clear()
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)
# C2: bump the persisted lifetime cycle counter. Unlike ``cycle_count``
# (== len(history), which regresses when history is trimmed/merged), this
# monotonic counter only ever increments — and only on a real persisted
# cycle — so milestones stay correct across retention limits. Captured here
# for the milestone check below. The write is persisted by the lifetime-energy
# save immediately after (same store, one save).
prev_lifetime_count: int | None = None
cur_lifetime_count: int | None = None
if cycle_persisted:
try:
prev_lifetime_count = self._lifetime_cycle_count()
cur_lifetime_count = prev_lifetime_count + 1
# In-memory only; persisted by the batched lifetime-energy save below.
self.profile_store.set_lifetime_cycle_count(cur_lifetime_count)
except Exception: # noqa: BLE001 - counter must never break cycle end
prev_lifetime_count = None
cur_lifetime_count = None
# B1: accumulate lifetime energy for the HA Energy dashboard sensor. Runs
# exactly once per persisted cycle so the TOTAL_INCREASING meter never
# double-counts. Never breaks cycle end.
if cycle_persisted:
try:
await self.profile_store.async_add_lifetime_energy_wh(
self._cycle_report_energy_wh(cycle_data)
)
except Exception as e: # pylint: disable=broad-exception-caught
self._logger.debug("Failed to accumulate lifetime energy: %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
# B1: only clear the active-cycle snapshot if it still belongs to THIS cycle.
# If a new cycle started during the awaits above, it now owns the active
# snapshot; clearing it here would strip the new cycle's restart-resilience.
if cycle_token is None or self._ranking_snapshot_cycle_id == cycle_token:
self._spawn_tracked(self.profile_store.async_clear_active_cycle())
# Auto post-process: merge fragmented cycles from last 3 hours
self._spawn_tracked(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 (use THIS cycle's captured program, not
# the live field which may already belong to a newly-started cycle).
if "profile_name" not in event_cycle_data and program:
event_cycle_data["profile_name"] = 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(self._cycle_report_energy_wh(cycle_data) / 1000, 3)
# Reuse the cost frozen onto the cycle above (same price resolution).
cost_val = cycle_data.get("cost")
cost_str = f"{cost_val:.2f}" if cost_val is not None else ""
# B3: extra finish-notification template variables. All are safe to
# ignore in a template — str.format drops unused kwargs.
time_finished = dt_util.now().strftime("%H:%M")
# Prefer the monotonic lifetime counter (falls back to the retained count).
finished_cycle_count = (
cur_lifetime_count if cur_lifetime_count is not None else self.cycle_count
)
vs_typical = ""
matched_name = cycle_data.get("profile_name")
if matched_name:
_median = self.profile_store.get_profile_median_duration(matched_name)
vs_typical = self._format_vs_typical(
cycle_data.get("duration", 0.0),
_median,
longer_template=self._timer_ui_strings.get(
"vs_typical_longer", "{pct}% longer than usual"
),
shorter_template=self._timer_ui_strings.get(
"vs_typical_shorter", "{pct}% shorter than usual"
),
)
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,
time_finished=time_finished,
cycle_count=finished_cycle_count,
vs_typical=vs_typical,
)
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,
"time_finished": time_finished,
"cycle_count": finished_cycle_count,
"vs_typical": vs_typical,
# 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,
# C3: end the iOS Live Activity (mobile_app_* only downstream).
"activity": "end",
},
)
# C2: milestone (cycle-count achievement) notification. Fires at most once per
# cycle, only when the cycle actually persisted (so the lifetime count is real)
# and a finish delivery channel is configured. Respects quiet hours via
# _dispatch_notification's finish-type gate.
if cycle_persisted:
self._maybe_notify_milestone(prev_lifetime_count, cur_lifetime_count)
# 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.
# Only run when the cycle was actually persisted — an unpersisted cycle
# has no store entry to reference, so a pending-feedback record would
# dangle forever. Use THIS cycle's captured match context (not the live
# fields, which may already belong to a newly-started cycle after the awaits).
if cycle_persisted:
self.learning_manager.process_cycle_end(
cycle_data,
detected_profile=program,
confidence=match_confidence or 0.0,
predicted_duration=matched_profile_duration,
match_result=match_result,
)
# B1: a new cycle may have started while the heavy post-processing above was
# awaiting. If so, the manager's live-cycle fields (_current_program,
# _cycle_start_time, _ranking_snapshot_cycle_id, progress) now belong to the
# NEW cycle. Zeroing them here — and re-arming the state-expiry timer — used to
# clobber the running cycle and, once it hit PAUSED/ENDING past the reset delay,
# reset it to Off mid-run. Detect the new cycle via the identity token and skip
# the terminal-state reset; cycle A was already persisted/learned/notified above.
if cycle_token is not None and self._ranking_snapshot_cycle_id != cycle_token:
self._logger.debug(
"Cycle-end post-processing completed after a new cycle started "
"(token %s -> %s); skipping terminal-state reset to preserve the "
"live cycle.",
cycle_token,
self._ranking_snapshot_cycle_id,
)
self._notify_update()
return
# 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._ranking_snapshot_cycle_id = ""
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()
# ------------------------------------------------------------------
# C1 - Quiet hours (do-not-disturb window)
# ------------------------------------------------------------------
def _quiet_hours_bounds(self) -> tuple[int, int] | None:
"""Return validated (start_hour, end_hour) or None when the feature is off.
Off when either hour is unset/None/non-int/out-of-range, or start == end.
"""
return notif_rules.quiet_hours_bounds(self.config_entry.options)
def _in_quiet_hours(self, when: datetime | None = None) -> bool:
"""Return True when ``when`` (default now) falls inside the quiet window.
Supports windows that wrap midnight (start > end, e.g. 22 -> 7 means
22:00-06:59). The end hour is exclusive at the hour granularity, so a window
of start=22, end=7 covers hours 22, 23, 0..6.
"""
return notif_rules.in_quiet_hours(
self._quiet_hours_bounds(), when or dt_util.now()
)
def _seconds_until_quiet_end(self, when: datetime | None = None) -> float:
"""Seconds from ``when`` until the next end-of-quiet-window boundary (end:00).
Returns 0.0 when the feature is off or when not currently in quiet hours.
"""
return notif_rules.seconds_until_quiet_end(
self._quiet_hours_bounds(), when or dt_util.now()
)
def _queue_quiet_hours_notification(
self,
message: str,
*,
title: str | None,
icon: str | None,
event_type: str | None,
extra_vars: dict[str, Any] | None,
) -> None:
"""Park a finish-type notification until the quiet window ends."""
self._quiet_pending_notifications.append(
{
"message": message,
"title": title,
"icon": icon,
"event_type": event_type,
"extra_vars": extra_vars,
}
)
self._schedule_quiet_hours_flush()
def _schedule_quiet_hours_flush(self) -> None:
"""(Re)arm the single async_call_later timer that flushes the quiet queue."""
if self._remove_quiet_hours_timer is not None:
# A timer is already pending; keep it (all queued items share one release).
return
delay = self._seconds_until_quiet_end()
if delay <= 0:
# Not actually in quiet hours (defensive) -> flush immediately.
self._flush_quiet_hours_notifications()
return
@callback
def _fire(_now: datetime) -> None:
self._remove_quiet_hours_timer = None
self._flush_quiet_hours_notifications()
self._remove_quiet_hours_timer = async_call_later(self.hass, delay, _fire)
def _flush_quiet_hours_notifications(self) -> None:
"""Deliver every queued quiet-hours notification (same service/message/tag)."""
if self._remove_quiet_hours_timer is not None:
self._remove_quiet_hours_timer()
self._remove_quiet_hours_timer = None
if not self._quiet_pending_notifications:
return
pending = list(self._quiet_pending_notifications)
self._quiet_pending_notifications = []
for entry in pending:
# Disable ONLY the quiet-hours re-hold (the window is closing), but keep
# presence gating on: if nobody is home and notify_only_when_home is set,
# the item must stay queued in the presence queue rather than fire into an
# empty house. (Previously allow_deferral=False disabled both, delivering
# to nobody.)
self._dispatch_notification(
entry["message"],
title=entry.get("title"),
icon=entry.get("icon"),
event_type=entry.get("event_type"),
extra_vars=entry.get("extra_vars"),
allow_deferral=False,
allow_presence_deferral=True,
)
def _cancel_quiet_hours_timer(self) -> None:
"""Cancel the pending quiet-hours release timer (shutdown/unload)."""
if self._remove_quiet_hours_timer is not None:
self._remove_quiet_hours_timer()
self._remove_quiet_hours_timer = None
# ------------------------------------------------------------------
# C2 - Milestone (cycle-count achievement) notifications
# ------------------------------------------------------------------
@staticmethod
def _milestone_crossed(
prev_count: int, cur_count: int, milestones: Any
) -> int | None:
"""Return the milestone just crossed, or None.
A milestone ``m`` is crossed when ``prev_count < m <= cur_count``. Empty or
malformed ``milestones`` is a no-op (returns None). If several are crossed in
one step the largest is returned so a single, most-significant notification
fires.
"""
return notif_rules.milestone_crossed(prev_count, cur_count, milestones)
def _lifetime_cycle_count(self) -> int:
"""Persisted monotonic lifetime completed-cycle count.
Unlike ``cycle_count`` (== len(retained history)), this only ever increments
and never regresses when history is trimmed/merged, so it is the correct basis
for milestone crossings. Falls back to ``cycle_count`` if the persisted value
is unavailable. Never raises.
"""
try:
return self.profile_store.get_lifetime_cycle_count()
except Exception: # noqa: BLE001
try:
return int(self.cycle_count)
except Exception: # noqa: BLE001
return 0
def _maybe_notify_milestone(
self, prev_count: int | None = None, cur_count: int | None = None
) -> int | None:
"""Fire one milestone notification if the lifetime count just crossed one.
Called at cycle end AFTER the cycle has persisted. ``prev_count``/``cur_count``
are the persisted lifetime counter's values from before/after this cycle's
persist; when omitted they are resolved from the persisted counter
(previous = current - 1). Using the monotonic lifetime counter (not
``cycle_count`` == len(history)) keeps milestones correct across retention
trims/merges. Returns the crossed milestone value (for tests/logging) or None.
Never raises.
"""
try:
if not (self._notify_finish_services or self._notify_actions):
return None
milestones = self.config_entry.options.get(
CONF_NOTIFY_MILESTONES, DEFAULT_NOTIFY_MILESTONES
)
if cur_count is None:
cur_count = self._lifetime_cycle_count()
if prev_count is None:
prev_count = cur_count - 1
crossed = self._milestone_crossed(prev_count, cur_count, milestones)
if crossed is None:
return None
msg_template = self.config_entry.options.get(
CONF_NOTIFY_MILESTONE_MESSAGE, DEFAULT_NOTIFY_MILESTONE_MESSAGE
)
msg = self._safe_format_template(
msg_template,
fallback_template=DEFAULT_NOTIFY_MILESTONE_MESSAGE,
device=self.config_entry.title,
cycle_count=crossed,
)
self._dispatch_notification(
msg,
event_type=NOTIFY_EVENT_FINISH,
extra_vars={
"cycle_count": crossed,
# Distinct tag so a milestone alert does not clobber (or get
# clobbered by) the lifecycle finish thread.
"tag": f"{self._lifecycle_tag}_milestone",
},
)
self._logger.info(
"Sent milestone notification: %s cycles", crossed
)
return crossed
except Exception as err: # pylint: disable=broad-exception-caught
self._logger.debug("Milestone notification check failed: %s", err)
return None
# ------------------------------------------------------------------
# C3 - iOS Live Activity enrichment (HA Companion beta, mobile_app_* only)
# ------------------------------------------------------------------
@staticmethod
def _build_ios_live_activity_extras(
*,
state: str,
progress_pct: float,
eta_timestamp: Any,
program: str | None,
device: str,
activity: str | None = None,
) -> dict[str, Any]:
"""Build the iOS Live Activity payload additions (mobile-only keys).
Returns a dict containing ``content_state`` (always), ``subtitle`` (only when
a program is matched) and ``activity`` (only when a lifecycle marker is
supplied). These keys are forwarded to mobile_app_* targets only by
``_send_notification_service``; other platforms never receive them.
"""
try:
pct = int(round(float(progress_pct)))
except (TypeError, ValueError):
pct = 0
pct = max(0, min(100, pct))
extras: dict[str, Any] = {
"content_state": {
"state": state,
"progress_pct": pct,
"eta_timestamp": eta_timestamp,
"program": program or "",
"device": device,
}
}
if program:
extras["subtitle"] = program
if activity:
extras["activity"] = activity
return extras
@staticmethod
def _mobile_service_extras(
ev: dict[str, Any], notify_service: str | None
) -> dict[str, Any]:
"""Return extra_vars keys allowed only on mobile_app_* targets.
For non-mobile services this is always empty, so strict-schema platforms and
the iOS Live Activity enrichment keys stay isolated to mobile targets.
"""
if not WashDataManager._is_mobile_notify_service(notify_service):
return {}
return {k: ev[k] for k in _MOBILE_ONLY_EXTRA_KEYS if k in ev}
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
if event_type == NOTIFY_EVENT_TIMER:
# Cycle timers go to all configured services (start union finish, deduped).
return list(dict.fromkeys(
self._notify_start_services + self._notify_finish_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,
allow_presence_deferral: bool = True,
) -> bool:
"""Route notification via actions or notify service with optional gating.
``allow_deferral`` gates the quiet-hours (do-not-disturb) hold; a
quiet-window flush passes ``allow_deferral=False`` so the released item is
not re-held by the still-closing window. ``allow_presence_deferral`` gates
the "notify only when home" presence hold *independently* — a quiet-hours
flush must keep presence gating on (nobody home => stay queued), so it
leaves ``allow_presence_deferral=True``. Only the presence flush (which
runs *because* someone is now home) disables both.
"""
# Signals whether this call *queued* the notification for later delivery
# (quiet-hours / presence hold) instead of sending or dropping it. Callers
# that use a "fire once" dedup flag (e.g. the clean-laundry nag) must treat
# a deferral as handled, otherwise they re-queue a duplicate on every retry
# tick for the whole quiet/away window.
self._last_dispatch_deferred = False
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}
# Quiet hours (do-not-disturb): hold finish-type notifications that would
# wake someone and deliver them at the end of the window. Live-progress ticks
# and the start notification are never delayed. Guarded by allow_deferral so a
# quiet-window flush (allow_deferral=False) cannot re-defer.
if (
allow_deferral
and event_type in _QUIET_HOURS_EVENT_TYPES
and self._in_quiet_hours()
):
self._queue_quiet_hours_notification(
message,
title=title,
icon=icon,
event_type=event_type,
extra_vars=extra_vars,
)
self._last_dispatch_deferred = True
return False
if (
allow_presence_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,
}
)
self._last_dispatch_deferred = True
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."""
ev = extra_vars or {}
# Base payload shared by all notification platforms.
data: dict[str, Any] = {}
if icon:
data["icon"] = icon
# Live-progress-only payload keys (countdown, progress bar, throttle markers).
# Live updates are already gated to mobile_app targets by the guard below,
# so these keys never reach strict-schema platforms.
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
# Mobile-app-specific keys (tag/timeout/channel/priority) plus the iOS
# Live Activity enrichment keys (subtitle/content_state/activity) are
# rejected by some strict-schema platforms such as Signal Messenger.
# Only add them for mobile_app targets; all other platforms receive
# the base payload only.
svc_data = dict(data)
svc_data.update(self._mobile_service_extras(ev, notify_service))
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 svc_data:
service_data["data"] = svc_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 svc_data:
service_data["data"] = svc_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
if self._notify_script is None:
try:
self._notify_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
script = self._notify_script
try:
action_task = self.hass.async_create_task(
script.async_run(variables, context=Context())
)
# This method is synchronous, so the script runs fire-and-forget: a
# failure inside async_run() would otherwise land after we return True
# and be swallowed. Surface it via a done-callback so an action-only
# setup at least logs the drop. (A user-visible fallback would require
# awaiting, i.e. making the whole notification-dispatch chain async.)
def _log_action_failure(task: Task[Any]) -> None:
if task.cancelled():
return
exc = task.exception()
if exc is not None:
self._logger.warning(
"Notification action execution failed for %s: %s",
self.config_entry.title,
exc,
)
action_task.add_done_callback(_log_action_failure)
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,
allow_presence_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). The suggestion is
# surfaced in the panel (Settings suggestions banner / per-field pill);
# WashData intentionally does not raise a persistent notification here.
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()
# 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._projected_energy_wh = None
self._projected_cost = None
self._cycle_anomaly = "none"
self._overrun_ratio = 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
self._live_activity_started = 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")
@property
def _timer_pause_action_id(self) -> str:
"""Stable mobile action ID for timer-pause Resume button, unique per device."""
return f"RESUME_WD_{self.entry_id[:8].upper()}"
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:
# Suppress the waiting notification when no profiles exist at all —
# the setup card explains the state instead.
if not self.profile_store.has_real_profiles:
return
if self._live_waiting_notification_sent:
return
# Fixed (non user-editable) live message: localize via the cached
# options.error template, falling back to the English default.
waiting_template = self._timer_ui_strings.get(
"notify_live_waiting_message", DEFAULT_NOTIFY_LIVE_WAITING_MESSAGE
)
msg = self._safe_format_template(
waiting_template,
fallback_template=DEFAULT_NOTIFY_LIVE_WAITING_MESSAGE,
device=self.config_entry.title,
program=self._current_program,
)
waiting_extra_vars: dict[str, Any] = {
"tag": self._live_notification_tag,
"live_update": True,
"alert_once": True,
}
# C3: mark the first live notification of the cycle so iOS can begin a
# Live Activity even before a profile is matched (mobile-only key).
if not self._live_activity_started:
waiting_extra_vars["activity"] = "start"
sent = self._dispatch_notification(
msg,
event_type=NOTIFY_EVENT_LIVE,
extra_vars=waiting_extra_vars,
)
self._live_waiting_notification_sent = sent
if sent:
self._live_activity_started = True
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
# C3: iOS Live Activity enrichment. Derived from the SAME values feeding the
# flat progress/when keys above. Forwarded to mobile_app_* targets only (see
# _send_notification_service); non-mobile live targets are already skipped.
eta_timestamp = int(now.timestamp()) + remaining_seconds
progress_pct = (
100.0 * elapsed_seconds / total_seconds if total_seconds > 0 else 0.0
)
activity_marker = None if self._live_activity_started else "start"
extra_vars.update(
self._build_ios_live_activity_extras(
state="paused" if self.detector.state == STATE_PAUSED else "running",
progress_pct=progress_pct,
eta_timestamp=eta_timestamp,
program=self._current_program,
device=self.config_entry.title,
activity=activity_marker,
)
)
sent = self._dispatch_notification(
msg,
event_type=NOTIFY_EVENT_LIVE,
extra_vars=extra_vars,
)
if sent:
self._live_activity_started = True
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,
# C3: tell iOS to end the Live Activity (mobile-only key downstream).
"activity": "end",
}
)
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,
# C3: iOS Live Activity end marker (mobile_app_* only downstream).
"activity": "end",
},
)
# 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 — from
# both the presence-hold queue and the quiet-hours queue (the nag can be
# deferred into either).
self._pending_notifications = [
n for n in self._pending_notifications
if n.get("event_type") != NOTIFY_EVENT_CLEAN
]
self._quiet_pending_notifications = [
n for n in self._quiet_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 notif_rules.should_notify_pre_completion(
self._notify_before_end_minutes,
self._notified_pre_completion,
self._time_remaining,
self._cycle_progress,
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_projected_energy(self) -> None:
"""Project total energy/cost for the running cycle.
Prefers the on-device ``total_energy`` regressor (which models energy's
non-linear accumulation); otherwise falls back to
``energy_so_far / progress_fraction`` (progress already carries the ML
remaining-time blend, so it personalizes to this device's real cycle
length). Cost uses the same price resolution that freezes each completed
cycle's cost, so a running estimate and the final frozen value are
consistent. Clears to ``None`` when progress is too low, there is no energy
yet, or projection would be implausible. Never raises — a projection
failure must not disturb the estimate loop.
"""
try:
trace = self.detector.get_power_trace()
energy_so_far = float(
getattr(self.detector, "_energy_since_idle_wh", 0.0) or 0.0
)
price = self._resolve_energy_price()
except Exception: # noqa: BLE001 - projection must never break estimates
self._projected_energy_wh = None
self._projected_cost = None
return
wh, cost = progress_mod.projected_energy(
self.profile_store,
self.config_entry.options,
float(self._matched_profile_duration or 0.0),
trace,
self._current_program,
float(self._cycle_progress or 0.0),
energy_so_far,
price,
self._profile_end_expectation,
self._logger,
)
self._projected_energy_wh = wh
self._projected_cost = cost
def _update_cycle_anomaly(self, duration_so_far: float) -> None:
"""Flag a *soft* runtime overrun anomaly for the running cycle.
Sets ``_overrun_ratio = elapsed / expected`` and ``_cycle_anomaly`` to
``"overrun"`` once the ratio crosses ``CYCLE_OVERRUN_ANOMALY_RATIO``. This
is purely a visible signal (state-sensor attribute + cycle metadata); it
never notifies and never terminates (the zombie-killer owns hard limits).
No-op / cleared when no profile duration is known. Never raises.
"""
self._overrun_ratio, self._cycle_anomaly = progress_mod.cycle_anomaly(
self._matched_profile_duration, duration_so_far
)
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
self._projected_energy_wh = None
self._projected_cost = None
self._cycle_anomaly = "none"
self._overrun_ratio = 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)
self._check_cycle_timers(duration_so_far)
if not (self._matched_profile_duration and self._matched_profile_duration > 0):
# No profile matched - don't provide misleading time estimates.
self._time_remaining = None
self._total_duration = None
self._cycle_progress = 0.0
self._smoothed_progress = 0.0
self._projected_energy_wh = None
self._projected_cost = None
self._cycle_anomaly = "none"
self._overrun_ratio = 0.0
self._logger.debug(
"No profile matched yet, elapsed=%smin", int(duration_so_far / 60)
)
return
# Compute the phase-aware and ML progress inputs via the manager's own
# wrappers (so per-call caching + test mocks apply), then hand them to the
# shared pure smoothing/back-calc in :mod:`progress` - the identical math
# the Playground simulation runs.
trace = self.detector.get_power_trace()
phase_result = None
if len(trace) >= 10 and self._current_program != "detecting...":
phase_result = self._estimate_phase_progress(
trace, duration_so_far, self._current_program
)
ml_pct = self._ml_progress_percent(trace, self._current_program)
# Opt-in phase-resolved ETA (washing machine / washer-dryer only). Segment
# the observed-so-far trace, match against cached per-profile phase profiles,
# and blend the per-role budget remaining into the estimate (progress.py
# owns the blend). Gated + guarded: any failure leaves the proven estimate
# untouched (phase_remaining_s stays None -> byte-identical behaviour).
phase_remaining_s: float | None = None
if (
len(trace) >= 10
and self._current_program not in ("detecting...", "off", None)
and phase_matching_enabled(self.config_entry.options, self.device_type)
):
pr = self.profile_store.phase_remaining(
trace, self.device_type, self._current_program
)
if pr is not None:
phase_remaining_s = pr.get("remaining_s")
result = progress_mod.compute_progress(
self.device_type,
float(self._matched_profile_duration),
duration_so_far,
self._smoothed_progress,
phase_result,
ml_pct,
self._logger,
phase_remaining_s=phase_remaining_s,
)
self._cycle_progress = result.progress
self._smoothed_progress = result.smoothed
self._time_remaining = result.remaining
self._total_duration = result.total
self._last_total_duration_update = now
self._update_projected_energy()
self._update_cycle_anomaly(duration_so_far)
def _check_cycle_timers(self, elapsed_seconds: float) -> None:
"""Fire any user-configured cycle timers whose offset has been reached."""
if not self._notify_cycle_timers:
return
if self.detector.state not in (STATE_RUNNING, STATE_PAUSED):
return
elapsed_minutes = elapsed_seconds / 60.0
for idx, timer in enumerate(self._notify_cycle_timers):
if idx in self._fired_cycle_timers:
continue
offset = float(timer.get("offset_minutes", 0))
if elapsed_minutes < offset:
continue
self._fired_cycle_timers.add(idx)
raw_msg = timer.get("message") or ""
fmt_kwargs = {
"device": self.config_entry.title,
"program": self._current_program or "",
"minutes": int(offset),
}
msg = self._safe_format_template(
raw_msg or self._timer_ui_strings.get("timer_default_message", "{device}: {minutes} min timer"),
**fmt_kwargs,
)
auto_pause = bool(timer.get("auto_pause", False))
timer_tag = f"{self._lifecycle_tag}_timer_{idx}"
if auto_pause:
# Defer the ENTIRE interactive notification until the pause takes
# effect. The Resume action + sticky flag (and the action listener
# that makes the button work) are all created together in
# _setup_timer_pause_notification only on pause success, so a
# no-op/failed pause never leaves a sticky "Resume Cycle" card with
# a dead button. _check_cycle_timers is sync, so bridge via a task.
self.hass.async_create_task(
self._async_auto_pause_and_notify(msg, timer_tag)
)
else:
self._dispatch_notification(
msg,
event_type=NOTIFY_EVENT_TIMER,
extra_vars={"tag": timer_tag},
allow_deferral=False,
allow_presence_deferral=False,
)
self._logger.info(
"Cycle timer #%d fired at %.0fs (%.1f min): %s",
idx, elapsed_seconds, offset, msg,
)
async def _async_auto_pause_and_notify(self, msg: str, tag: str) -> None:
"""Pause the cycle for an auto-pause timer, then show the pause UI on success.
The interactive pause notification is created only after the pause actually
takes effect, so a no-op/failed pause never leaves a stale "paused" card.
"""
if await self.async_pause_cycle():
self._setup_timer_pause_notification(msg, tag)
def _setup_timer_pause_notification(self, msg: str, tag: str) -> None:
"""Create the interactive pause notification: mobile card + HA sidebar + action listener.
Called from _async_auto_pause_and_notify only after async_pause_cycle() has
actually taken effect. Sends the interactive mobile notification (Resume
action + sticky), creates the HA persistent notification for sidebar
visibility, and registers the mobile action listener — all together, so the
Resume button always has a live listener behind it and only ever appears
when the cycle is genuinely paused.
"""
self._clear_timer_pause_notification()
# Interactive mobile notification — dispatched now (post-pause) rather than
# at timer-fire time, so a failed/no-op pause never shows a dead Resume card.
self._dispatch_notification(
msg,
event_type=NOTIFY_EVENT_TIMER,
extra_vars={
"tag": tag,
"actions": [
{
"action": self._timer_pause_action_id,
"title": self._timer_ui_strings.get(
"timer_pause_action_title", "Resume Cycle"
),
}
],
"sticky": "true",
},
allow_deferral=False,
allow_presence_deferral=False,
)
self._timer_pause_pn_id = tag
self._timer_pause_mobile_tag = tag
_body_suffix = self._timer_ui_strings.get(
"timer_pause_body_suffix", "The cycle is paused. Open the WashData panel to resume."
)
_pn_create(
self.hass,
f"{msg}\n\n{_body_suffix}",
title=f"WashData: {self.config_entry.title}",
notification_id=tag,
)
action_id = self._timer_pause_action_id
@callback
def _on_mobile_action(event: Any) -> None:
if event.data.get("action") == action_id:
self.hass.async_create_task(self.async_resume_cycle())
self._remove_timer_action_listener = self.hass.bus.async_listen(
"mobile_app_notification_action",
_on_mobile_action,
)
def _clear_timer_pause_notification(self) -> None:
"""Dismiss the active timer-pause notification (both HA persistent and mobile)."""
if self._remove_timer_action_listener is not None:
self._remove_timer_action_listener()
self._remove_timer_action_listener = None
if self._timer_pause_pn_id:
_pn_dismiss(self.hass, self._timer_pause_pn_id)
self._timer_pause_pn_id = None
if self._timer_pause_mobile_tag:
services = self._get_services_for_event(NOTIFY_EVENT_TIMER)
mobile_services = [s for s in services if self._is_mobile_notify_service(s)]
if mobile_services:
self._send_notification_service(
"clear_notification",
services=mobile_services,
event_type=NOTIFY_EVENT_TIMER,
extra_vars={"tag": self._timer_pause_mobile_tag},
)
self._timer_pause_mobile_tag = None
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:
"""Phase-aware progress estimate. Thin wrapper over :mod:`progress`."""
return progress_mod.estimate_phase_progress(
self.profile_store,
current_power_data,
current_duration,
profile_name,
self._logger,
)
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
# A completed cycle ends in STATE_FINISHED, not STATE_OFF; accept both
# or the door-sensor Clean state (#153) is never surfaced (#282).
if self._is_clean_state and self.detector.state in (
STATE_OFF,
STATE_FINISHED,
):
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 projected_energy_wh(self) -> float | None:
"""Projected total energy (Wh) for the running cycle, or None."""
return self._projected_energy_wh
@property
def projected_cost(self) -> float | None:
"""Projected total cost for the running cycle, or None when no price."""
return self._projected_cost
@property
def cycle_anomaly(self) -> str:
"""Runtime anomaly state for the current cycle ("none" | "overrun")."""
return self._cycle_anomaly
@property
def overrun_ratio(self) -> float:
"""Elapsed / expected duration for the running cycle (0.0 when unknown)."""
return self._overrun_ratio
@property
def last_cycle_post_anomaly(self) -> dict:
"""Post-cycle anomaly data from the last completed cycle.
Contains subset of keys present: anomaly (underrun/overrun/none),
underrun_ratio, energy_anomaly (energy_spike/energy_low), energy_z_score.
Empty dict when no completed cycle or no anomaly detected.
"""
return self._last_cycle_post_anomaly
@property
def restart_gaps(self) -> list[dict]:
"""HA restart gaps recorded during the current active cycle (may be empty)."""
return self._restart_gaps
@property
def maintenance_due(self) -> list[str]:
"""Maintenance event types whose reminder threshold has been reached (E2).
Surfaced as a state-sensor attribute + read by the panel banner. Never a
notification. Returns an empty list on any error.
"""
try:
cfg = self.config_entry.options.get(CONF_MAINTENANCE_REMINDER_CYCLES)
if not isinstance(cfg, dict) or not cfg:
cfg = DEFAULT_MAINTENANCE_REMINDER_CYCLES
return self.profile_store.get_maintenance_due(cfg)
except Exception: # noqa: BLE001
return []
@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_cycle_end_time(self) -> datetime | None:
"""Return when the most recent completed cycle ended (or None).
Set at cycle end and restored from stored history on startup. Consumed by
the conversation intent handler to answer "how long ago did it finish".
"""
return self._last_cycle_end_time
@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 self.detector.samples_recorded
@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 lifetime_energy_kwh(self) -> float:
"""Lifetime accumulated energy (kWh) for the HA Energy dashboard sensor."""
return round(self.profile_store.get_lifetime_energy_wh() / 1000.0, 3)
@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) -> bool:
"""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.
Returns True if the cycle was paused, False if it was a no-op (wrong state).
"""
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 False
if self._is_user_paused:
self._logger.debug("async_pause_cycle: already user-paused, ignoring")
return False
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 False
snapshot = self._augment_active_snapshot(self.detector.get_state_snapshot())
self.hass.async_create_task(self.profile_store.async_save_active_cycle(snapshot))
self._notify_update()
return True
async def async_resume_cycle(self) -> bool:
"""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.
Returns True if the cycle was resumed, False if it was a no-op (not paused).
"""
if not self._is_user_paused:
self._logger.debug("async_resume_cycle: not user-paused, ignoring")
return False
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 False
# Dismiss the interactive pause notification only after the resume (incl. the
# switch turn-on) has actually succeeded — a rolled-back resume above returns
# early with the card still up, matching the real (still-paused) state.
self._clear_timer_pause_notification()
snapshot = self._augment_active_snapshot(self.detector.get_state_snapshot())
self.hass.async_create_task(self.profile_store.async_save_active_cycle(snapshot))
self._notify_update()
return True
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)