Files
HomeAssistantVS/custom_components/ha_washdata/manager.py
T

9654 lines
456 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 re
import uuid
import asyncio
from asyncio import Task
from collections.abc import Callable, 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_state_report_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 voluptuous as vol
import homeassistant.helpers.event as evt
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import script as script_helper
from homeassistant.helpers import translation
from .const import (
DOMAIN,
CONF_POWER_SENSOR,
CONF_PROFILE_EVIDENCE_SOURCES,
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_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_ANTI_WRINKLE_IDLE_TIMEOUT,
CONF_DISHWASHER_END_SPIKE_QUIET_RELEASE,
DISHWASHER_END_SPIKE_QUIET_RELEASE_SECONDS,
CONF_SMART_TERMINATION_DURATION_RATIO,
CONF_ANTI_CREASE_FINALIZE_RATIO,
CONF_CURVE_PREROLL_SECONDS,
DEFAULT_ANTI_CREASE_FINALIZE_RATIO,
DEFAULT_CURVE_PREROLL_SECONDS,
DEFAULT_SMART_TERMINATION_DURATION_RATIO,
DEFAULT_SMART_TERMINATION_DURATION_RATIO_BY_DEVICE,
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_PROFILE_EVIDENCE_SOURCES,
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_ANTI_WRINKLE_IDLE_TIMEOUT,
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_ICON_COLOR,
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_LIVE_STICKY,
CONF_NOTIFY_LIVE_CLICK_ACTION,
CONF_NOTIFY_LIVE_SILENT,
DEFAULT_NOTIFY_LIVE_STICKY,
DEFAULT_NOTIFY_LIVE_CLICK_ACTION,
DEFAULT_NOTIFY_LIVE_SILENT,
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_PRICE_DYNAMIC,
DEFAULT_ENERGY_PRICE_DYNAMIC,
PRICE_TIMELINE_MAX_POINTS,
PRICE_TIMELINE_PRICE_DECIMALS,
CONF_ENERGY_SENSOR,
CONF_PEAK_RATE_THRESHOLD,
CONF_PEAK_RATE_MESSAGE,
DEFAULT_PEAK_RATE_MESSAGE,
CONF_DOOR_SENSOR_ENTITY,
CONF_DOOR_OPENS_AT_END,
CONF_DOOR_END_DWELL_SECONDS,
DEFAULT_DOOR_OPENS_AT_END,
DEFAULT_DOOR_END_DWELL_SECONDS,
CONF_PAUSE_CUTS_POWER,
CONF_SWITCH_ENTITY,
CONF_NOTIFY_UNLOAD_DELAY_MINUTES,
CONF_NOTIFY_UNLOAD_MESSAGE,
CONF_NOTIFY_UNLOAD_REPEAT,
DEFAULT_NOTIFY_UNLOAD_DELAY_MINUTES,
DEFAULT_NOTIFY_UNLOAD_MESSAGE,
DEFAULT_NOTIFY_UNLOAD_REPEAT,
NOTIFY_UNLOAD_REPEAT_MAX_REMINDERS,
CONF_UNLOAD_CONFIRM_ENTITY,
CONF_UNLOAD_TRACK_WITHOUT_DOOR,
UNLOAD_CONFIRM_REPLAY_GRACE_S,
DEFAULT_UNLOAD_TRACK_WITHOUT_DOOR,
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,
resolve_sampling_interval_default,
resolve_watchdog_interval_default,
resolve_start_duration_default,
resolve_smart_termination_duration_ratio_default,
CONF_MATCH_PERSISTENCE,
DEFAULT_MATCH_PERSISTENCE,
DEFAULT_MATCH_REVERT_RATIO,
MATCH_DECISIVE_MARGIN,
MATCH_LABEL_MIN_MARGIN,
DEFAULT_AUTO_TUNE_NOISE_EVENTS_THRESHOLD,
DEFAULT_DEVICE_TYPE,
DEFAULT_START_DURATION_THRESHOLD,
DEFAULT_END_REPEAT_COUNT,
resolve_min_off_gap_default,
DEFAULT_UNMATCHED_WATCHDOG_CEILING,
DEFAULT_UNMATCHED_WATCHDOG_CEILING_BY_DEVICE,
DEFAULT_MAX_DEFERRAL_SECONDS,
ENDING_HARD_FINALIZE_MIN_QUIET_S,
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,
terminal_high_for_guards,
)
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,
compact_price_timeline,
cycle_cost,
)
from .recorder import CycleRecorder
from .diag_buffer import DiagBuffer
from .log_utils import DeviceLoggerAdapter
# Per-entity anchors for the unload-confirm replay window (register items 367, 368).
# `{entity_id: datetime}` in `hass.data`, so it survives entry reloads, resets on an
# HA restart, and never carries over between different configured entities.
_UNLOAD_CONFIRM_ANCHOR_KEY = f"{DOMAIN}_unload_confirm_anchors"
from .options_utils import option_float, option_int
from .time_utils import power_data_to_offsets
from . import analysis
from . import progress as progress_mod
from . import notification_rules as notif_rules
from .phase_segmenter import phase_matching_enabled
from .frontend import PANEL_URL_PATH
_LOGGER = logging.getLogger(__name__)
# Sentinel "message" understood by the Home Assistant companion app as "dismiss the
# card carrying this tag" rather than as text to display. It is only meaningful
# alongside a `tag`, and only on mobile_app targets - see _send_notification_service,
# which must never deliver it as a visible message.
_CLEAR_NOTIFICATION_MARKER = "clear_notification"
# A notification accent colour (#454) typed without its leading "#". Matches the
# three CSS hex forms the companion apps accept (RGB, RRGGBB, AARRGGBB) so the
# "#" can be added back; anything else is left alone.
_HEX_COLOR_RE = re.compile(r"[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8}")
# 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"}
)
# Detector states in which the power sensor must not be swapped out. Every state
# with an in-flight cycle, plus ANTI_WRINKLE: its tumble pulses are still being
# attributed to the cycle that just finished, so re-pointing the listener there
# would splice a different appliance into that tail.
_SENSOR_SWAP_BLOCKED_STATES = frozenset(
{
STATE_STARTING,
STATE_RUNNING,
STATE_PAUSED,
STATE_ENDING,
STATE_ANTI_WRINKLE,
}
)
# States in which a cycle is under way, so a manually picked program applies to it
# right now rather than being armed for the next one (#411). ANTI_WRINKLE is
# excluded on purpose: its tumble pulses belong to the cycle that already ended,
# so a program chosen there is meant for the next run.
_CYCLE_IN_PROGRESS_STATES = frozenset(
{
STATE_STARTING,
STATE_RUNNING,
STATE_PAUSED,
STATE_ENDING,
}
)
# Device classes and units that prove a configured "energy price entity" is not a
# price at all (#439). The panel's picker lists every `sensor.`, so the obvious
# mistake is to point it at the plug's own energy counter - and because a price
# entity outranks the static price, the cycle is then costed at
# `kWh_used * current_meter_reading`, which looks like a plausible number and is
# nonsense. A price is never measured in W or kWh; `monetary` and unitless or
# "EUR/kWh"-style sensors are left alone.
_NON_PRICE_DEVICE_CLASSES = frozenset(
{"energy", "energy_storage", "power", "gas", "water", "current", "voltage"}
)
_NON_PRICE_UNITS = frozenset(
{"w", "kw", "mw", "wh", "kwh", "mwh", "va", "kva", "varh", "a", "ma", "v", "mv"}
)
def _finite_power(raw: Any) -> float | None:
"""Parse a power sensor's state string, rejecting non-finite values.
``float()`` accepts ``"nan"``, ``"inf"`` and ``"infinity"``, and a power
reading is compared against thresholds exactly like the options
``options_utils.option_float`` already guards: every comparison against
``nan`` is False, so a single such reading does not raise, it silently
switches gates OFF. With ``_current_power`` set to ``nan`` both the
unmatched-cycle watchdog (`< start_threshold_w`) and the high-power silence
deferral (`> min_power`) evaluate False, so a running cycle loses the guards
that decide whether it ends at all.
Returns None for anything unusable, which is the "sensor is non-numeric"
outcome every caller already handles. A real meter never reports either
value, so no valid reading changes behaviour.
"""
try:
power = float(raw)
except (TypeError, ValueError, OverflowError):
return None
if not math.isfinite(power):
return None
return power
def _coerce_price_timeline(raw: Any) -> list[tuple[float, float]]:
"""Coerce a persisted price timeline back into ``(ts, price)`` tuples (#426).
JSON round-trips the pairs as lists, and a hand-edited or truncated store must
not be able to break cycle restoration, so anything unparseable is dropped
rather than raised on.
"""
result: list[tuple[float, float]] = []
if not isinstance(raw, (list, tuple)):
return result
for entry in raw:
if not isinstance(entry, (list, tuple)) or len(entry) < 2:
continue
try:
result.append((float(entry[0]), float(entry[1])))
except (TypeError, ValueError):
continue
result.sort(key=lambda item: item[0])
return result
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",
"clickAction",
"url",
"subtitle",
"content_state",
"activity",
"silent",
"push",
)
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_live_sticky = DEFAULT_NOTIFY_LIVE_STICKY
self._notify_live_click_action = DEFAULT_NOTIFY_LIVE_CLICK_ACTION
self._notify_live_silent = DEFAULT_NOTIFY_LIVE_SILENT
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
# Dynamic energy price timeline for the current cycle (#426): the price in
# force at each point of the cycle, as ``(unix_ts, price_per_kwh)`` pairs,
# appended by the price-entity listener below. Absolute timestamps, not
# offsets: the stored cycle's start time can end up later than the detector's
# (leading-zero trim, a split), and converting once at cycle end against the
# figure actually persisted is the only way the two axes cannot drift apart.
# Survives a restart via the active-cycle snapshot; any price change that
# happened while HA was down is recovered from the recorder at cycle end.
self._price_timeline: list[tuple[float, float]] = []
self._remove_price_listener: Callable[[], None] | None = None
# Entity id already reported as "not a price" (#439), so the rejection is
# logged once per misconfiguration instead of on every cycle.
self._warned_price_entity: 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
# Auto-open dishwasher (#342): a sustained door-open at cycle end finalizes
# the cycle after a dwell instead of setting the sticky user-pause.
self._door_opens_at_end: bool = bool(
config_entry.options.get(CONF_DOOR_OPENS_AT_END, DEFAULT_DOOR_OPENS_AT_END)
)
self._door_end_dwell_seconds: int = int(
config_entry.options.get(CONF_DOOR_END_DWELL_SECONDS, DEFAULT_DOOR_END_DWELL_SECONDS)
)
self._remove_door_end_dwell: Any = None
self._remove_door_sensor_listener = None
# Unload confirmation without a door sensor (#451): an entity whose
# activation means "unloaded", and/or a plain opt-in for the Mark Unloaded
# button and the mark_unloaded service.
self._unload_confirm_entity: str | None = config_entry.options.get(
CONF_UNLOAD_CONFIRM_ENTITY
) or None
self._unload_track_without_door: bool = bool(
config_entry.options.get(
CONF_UNLOAD_TRACK_WITHOUT_DOOR, DEFAULT_UNLOAD_TRACK_WITHOUT_DOOR
)
)
self._remove_unload_confirm_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
)
)
# Repeat the unload reminder until dismissed / door-open (opt-in, #374).
self._notify_unload_repeat: bool = bool(
config_entry.options.get(
CONF_NOTIFY_UNLOAD_REPEAT, DEFAULT_NOTIFY_UNLOAD_REPEAT
)
)
# Set when the user taps the reminder's "stop reminding" action; timestamp of
# the last reminder sent, used to pace the repeats; and the mobile action
# listener remover (mirrors the timer-pause interactive-notification wiring).
self._unload_nag_dismissed: bool = False
self._last_unload_nag_time: datetime | None = None
self._unload_nag_count: int = 0 # repeat-mode safety bound (#374)
self._remove_unload_action_listener: Any | None = None
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"
# #446: the live progress updates need their OWN tag, because on iOS a Live
# Activity is a separate UI surface from the notification and is ended only
# by `clear_notification` with the activity's tag. While this was an alias
# for the lifecycle tag there was no way to end it: clearing would have
# dismissed the finished card that shares the tag, which is why the cycle-end
# path deliberately skipped the service clear - and so the activity was never
# ended at all. Reporter's lock screen sat frozen at 98% / 0:00 for an hour
# after the cycle finished, and on an earlier run the chronometer counted
# upward to 4:12:20; it survives until Apple's ~8 h expiry or a manual
# dismiss. Handover keeps the mobile app to one visible entry at a time: the
# first live tick clears the lifecycle tag (dropping the start alert), and
# cycle end clears this one after the finished alert has been delivered.
self._live_notification_tag = f"ha_washdata_{self.entry_id}_live"
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
self._banked_tail_repair_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)
)
# Stage-4 energy discriminator: integrated energy for WM/washer-dryer,
# mean power elsewhere (see analysis.stage4_energy_mode).
self.profile_store.energy_mode = analysis.stage4_energy_mode(self.device_type)
# Which cycle categories may shape a profile. The store cannot read entry
# options, so the manager pushes this in (same as energy_mode above).
self.profile_store.evidence_sources = config_entry.options.get(
CONF_PROFILE_EVIDENCE_SOURCES, DEFAULT_PROFILE_EVIDENCE_SOURCES
)
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))
# Device-scaled ceiling for the unmatched (expected == 0) zombie guard (#404).
# Not a user option; purely a function of device_type, so it is recomputed
# alongside device_type on reconfigure.
self._unmatched_watchdog_ceiling = float(
DEFAULT_UNMATCHED_WATCHDOG_CEILING_BY_DEVICE.get(
self.device_type, DEFAULT_UNMATCHED_WATCHDOG_CEILING
)
)
# Coerced here rather than at the point of use. Both thresholds are compared
# against a match confidence inside the cycle-end tail, and that tail runs as
# a spawned task: a non-numeric option (an import file is hand-editable, and
# strip_null_options only removes nulls) raised there instead, killing the task
# before async_add_cycle and losing the whole cycle. Same #389 failure shape,
# one step later. Falls back to the default rather than to 0, which would
# silently auto-label everything.
self._learning_confidence = option_float(
config_entry.options.get(CONF_LEARNING_CONFIDENCE, DEFAULT_LEARNING_CONFIDENCE),
DEFAULT_LEARNING_CONFIDENCE,
)
self._duration_tolerance = config_entry.options.get(
CONF_DURATION_TOLERANCE, DEFAULT_DURATION_TOLERANCE
)
self._auto_label_confidence = option_float(
config_entry.options.get(CONF_AUTO_LABEL_CONFIDENCE, DEFAULT_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_live_sticky = bool(
config_entry.options.get(
CONF_NOTIFY_LIVE_STICKY, DEFAULT_NOTIFY_LIVE_STICKY
)
)
self._notify_live_click_action = str(
config_entry.options.get(
CONF_NOTIFY_LIVE_CLICK_ACTION, DEFAULT_NOTIFY_LIVE_CLICK_ACTION
)
or ""
).strip()
self._notify_live_silent = bool(
config_entry.options.get(
CONF_NOTIFY_LIVE_SILENT, DEFAULT_NOTIFY_LIVE_SILENT
)
)
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,
resolve_start_duration_default(self.device_type),
)
)
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,
end_repeat_count=end_repeat_count,
min_off_gap=int(
config_entry.options.get(
CONF_MIN_OFF_GAP, resolve_min_off_gap_default(self.device_type)
)
),
# Read here as well as on reload (item 351): the reload path was the
# only writer, so until the user next saved a setting the detector ran
# a tolerance of 0.25 no matter what the panel showed - and the
# deferral ceiling in `_should_defer_finish` reads it live.
profile_duration_tolerance=float(
config_entry.options.get(
CONF_PROFILE_DURATION_TOLERANCE, DEFAULT_PROFILE_DURATION_TOLERANCE
)
),
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
)
),
# `profile_match_threshold` was stored on the ProfileStore and never read
# anywhere, so the #288 workaround (raise it so near-duplicate profiles
# stop being trusted mid-cycle) silently did nothing. Wire it to the gate
# it was always documented to control; the default is the value that used
# to be hard-coded there, so nothing changes unless it was tuned.
match_confidence_threshold=float(
config_entry.options.get(
CONF_PROFILE_MATCH_THRESHOLD, DEFAULT_PROFILE_MATCH_THRESHOLD
)
),
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
)
),
anti_wrinkle_idle_timeout=float(
config_entry.options.get(
CONF_ANTI_WRINKLE_IDLE_TIMEOUT, DEFAULT_ANTI_WRINKLE_IDLE_TIMEOUT
)
),
dishwasher_end_spike_quiet_release=float(
config_entry.options.get(
CONF_DISHWASHER_END_SPIKE_QUIET_RELEASE,
DISHWASHER_END_SPIKE_QUIET_RELEASE_SECONDS,
)
),
# #393: resolve the device-type default HERE (not in the gate) so the
# field always carries a real float - playground.effective_settings()
# skips a None-valued field, which would desync the sim from the detector.
smart_termination_duration_ratio=float(
config_entry.options.get(
CONF_SMART_TERMINATION_DURATION_RATIO,
resolve_smart_termination_duration_ratio_default(self.device_type),
)
),
# #429: same reasoning, different gate - this one gates the finalise
# into STATE_ANTI_WRINKLE, not Smart Termination. Scalar default: the
# safe value is per-machine, not per-device-type.
anti_crease_finalize_ratio=float(
config_entry.options.get(
CONF_ANTI_CREASE_FINALIZE_RATIO,
DEFAULT_ANTI_CREASE_FINALIZE_RATIO,
)
),
# #430: 0 = off, which is the default and leaves the stored-duration
# convention exactly as it was.
curve_preroll_seconds=float(
config_entry.options.get(
CONF_CURVE_PREROLL_SECONDS, DEFAULT_CURVE_PREROLL_SECONDS
)
),
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,
# #378: without this the detector keeps the WASHING_MACHINE default and
# every non-washing-machine device runs the washing-machine detection
# path (the whole dishwasher/#43 branch is otherwise dead in production).
device_type=self.device_type,
)
self._config = config
def profile_matcher_wrapper(
readings: list[tuple[datetime, float]],
) -> tuple[str | None, float, float, str | None] | None:
"""Wraps profile store matching logic with detector callback signature.
The real match is offloaded to an async task that calls
``detector.update_match`` later, so this returns ``None`` in that case -
the detector's contract is "None == async offload, I'll be called back"
(see ``_try_profile_match``). It must NOT return a placeholder tuple:
a non-empty tuple is truthy, so the detector would feed it straight into
``update_match`` on every match tick, spuriously logging the
"invalid raw_expected_duration 0.0" debug line and momentarily zeroing
``_last_match_confidence`` between real async updates. Only the manual
override path below returns a genuine synchronous tuple.
"""
# 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,
)
# Elements 9 and 10 matter even here. update_match CLEARS
# _matched_tail_power and _matched_terminal_high for any tuple
# shorter than this, on the sound reasoning that a newly matched
# profile must not inherit the previous one's tail - but a manual
# pin names its profile, so the answer is to supply that profile's
# own values rather than nothing. Left empty, the #364 tail guard
# and the #399 anti-crease spin wait both sat inert for every
# hand-picked program, so a washer could finalize in the quiet
# before its terminal spin and record the spin as a second cycle.
# Elements 5-8 stay False: a manual pin is certain by definition,
# so there is no mismatch or ambiguity to report.
terminal_high = self._terminal_high_for_guards(self._current_program)
return (
self._current_program,
1.0,
expected_duration,
manual_phase or "Manual",
False,
False,
False,
False,
self.profile_store.profile_tail_power(self._current_program),
terminal_high,
# Element 11 (register item 297): same reasoning as elements 9 and 10 - a
# manual pin names its profile, so it must supply that
# profile's own measurements rather than leaving the guard
# inert. Without it a hand-picked program would still bank
# Smart Termination's confirmation delay as cycle time.
self.profile_store.profile_terminal_quiet_seconds(
self._current_program
),
)
if not readings:
return 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
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_report_listener = None # state_reported (unchanged re-reports) #363/#329
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,
resolve_watchdog_interval_default(self.device_type),
)
)
# option_int, not a bare int(): this runs in __init__, so a hand-edited
# import putting a non-numeric or oversized value here raised before the
# manager existed and the entry could never finish setup. The floor of 1 is
# what `SuggestionEngine` already applies to the same key - and it has to,
# because its interval cap is computed FROM this number, so an unclamped 0
# here would have the suggestion describe a persistence the matcher is not
# using. Zero also disables the gate rather than tightening it: every
# `counter >= 0` is true, so the first match commits.
self._match_persistence = option_int(
config_entry.options.get(CONF_MATCH_PERSISTENCE, DEFAULT_MATCH_PERSISTENCE),
DEFAULT_MATCH_PERSISTENCE,
minimum=1,
)
self._sampling_interval = float(
config_entry.options.get(
CONF_SAMPLING_INTERVAL,
resolve_sampling_interval_default(self.device_type),
)
)
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
# Where this run maps onto its matched profile's envelope, 0-1, from the
# DTW alignment that already runs for the verified-pause decision. Visible
# only: nothing reads it back. None until an alignment has produced one.
self._envelope_position: float | None = None
# 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
# Stage-5 companion to the above (item 206): what the SELECTED group member
# earned on its own curve, vs the group's score in _last_match_confidence.
# None for every non-group match, and the label gate then behaves exactly as
# before. Kept as a separate field rather than replacing the confidence,
# because the confidence is what the detector's end-detection gates read.
self._last_member_confidence: float | None = None
# 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
# The user's standing program choice for the current or next cycle (#411).
# Rehydrated from the store during setup so arming survives a restart.
self._armed_program: str | None = None
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
# in_progress: this is the live match on a cycle that is still running,
# so Stage 4 grades each candidate on the same elapsed stretch instead
# of on its complete duration/energy (#400). The two final-match paths
# (_run_final_match_from_cycle_data, _async_process_cycle_end) leave it
# off - there the cycle really is complete.
result = await self.profile_store.async_match_profile(
readings,
current_duration,
in_progress=True,
)
# 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
# How far clear of the runner-up the winner is. Measured over 594
# cycles x 10 checkpoints, this separates right from wrong far better
# than the absolute score does mid-cycle (AUC 0.773 vs 0.535), which is
# why the mid-cycle switch below keys on it. Register item 305.
# Measured against the best OTHER candidate rather than by list index:
# Stage-5 group collapsing rebuilds the result, so `best_profile` is not
# guaranteed to be `candidates[0]`.
match_margin = 1.0
_runner_up = None
for c in result.candidates:
if c.get("name") == profile_name:
continue
try:
cs = float(c.get("score", 0.0) or 0.0)
except (TypeError, ValueError):
continue
if _runner_up is None or cs > _runner_up:
_runner_up = cs
if _runner_up is not None:
match_margin = float(confidence) - _runner_up
# 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")
):
# Decisive Margin Override: bypass persistence when the winner is
# far clear of the runner-up (register item 305).
#
# This replaces a "High Confidence Override" keyed on
# `confidence > 0.8`, whose premise - a very strong match needs no
# confirmation - is backwards mid-cycle. Mid-run the query is a
# PREFIX, and a prefix of a long programme looks exactly like a
# *finished* short one, so a score above 0.8 measured 31.5% correct
# (n=73) against 69.6% for the 0.6-0.8 band it was skipping the wait
# for; those cases pick a shorter programme 46% of the time (vs 16.5%
# at large). Replaying all 594 cycles through this switching logic,
# the old rule was also **unreachable in practice** - its outcomes
# land within 0.2pp of having no override at all.
#
# The margin is the signal that works: mid-cycle AUC 0.773 vs 0.535
# for the absolute score. Replayed, keying the bypass on it lifts
# end-of-cycle correctness 70.4% -> 72.6% (16 cycles better, 3 worse,
# McNemar p = 0.0044) for 0.14 displayed switches per cycle against
# 0.07. The sweep is monotone, so this is the conservative end of an
# accuracy/stability trade: 0.05 -> +6.7pp at 0.27 flips/cycle,
# 0.08 -> +5.1, 0.10 -> +3.0, 0.12 -> +2.2, 0.15 -> +1.7.
# The `> current_program_score` guard measured neutral (it never binds
# at this margin) and is kept because switching to something scoring
# below what is already displayed is never right.
# The 1.0 sentinel `match_margin` carries when nothing else scored
# is LOAD-BEARING, not a gap. Requiring a real runner-up here was
# tried (PR #448 round 6) and measured on
# `devtools/decisive_margin_eval.py` over 1977 mid-cycle
# checkpoints from the real corpus: a single surviving candidate
# occurs at 2.58% of them and is the **correct** programme
# **94.0% (47/50)** of the time, against **77.8% (669/860)** for
# the real-margin bypass it would have been held to. Stage 1/2
# rejecting every other profile is strong evidence, not absent
# evidence, so making those checkpoints wait for persistence
# delays the matcher's most reliable signal. Reverted.
if (
match_margin > MATCH_DECISIVE_MARGIN
and confidence > current_program_score
):
should_switch = True
switch_reason = (
f"decisive_margin (margin {match_margin:.3f} > "
f"{MATCH_DECISIVE_MARGIN}, {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._last_member_confidence = result.member_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
self._matched_profile_duration = self._profile_duration(matched_duration)
avg_duration = self._matched_profile_duration or 0.0
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
self._last_member_confidence = result.member_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. Compare the mapped
# position against the envelope's OWN time span (not avg_duration,
# a differently-derived trimmed mean): mapped_time is capped at the
# grid span, so span/avg_duration < 1 would make the 0.95 release
# unreachable and the cycle would hang to the deferral cap (#348).
try:
span = self.profile_store.envelope_time_span(current_matched)
if span > 0:
# The same ratio the release below tests, kept for the
# state attribute. It is the only continuous "how far
# through this programme are we" figure the integration
# has that is independent of elapsed time, so it stays
# meaningful when a run over- or under-shoots its mean.
self._envelope_position = round(
min(1.0, max(0.0, mapped_time / span)), 3
)
if span > 0 and (mapped_time / span) > 0.95:
verified_pause = False
self._logger.info(
"Smart Termination: near end of profile (%.0f/%.0fs). Releasing pause lock.",
mapped_time, span,
)
else:
# Diagnostic (#346): the release is held; show how far the
# trace mapped vs the 95%% release point (no behaviour change).
self._logger.debug(
"Smart Termination held for %s: mapped %.0f/%.0fs (%.0f%%) below 95%% release%s",
current_matched, mapped_time, span,
(100.0 * mapped_time / span) if span > 0 else 0.0,
"" if span > 0 else " (envelope span unavailable)",
)
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
# --- Sustained-quiet release of an auto-detected pause (issue #375) ---
# An envelope-verified pause bridges a genuine low-power phase (e.g. a
# dishwasher's passive drying). When the appliance instead goes truly
# silent at the real end, the envelope alignment can keep re-confirming
# against a long near-zero drying tail baked into the profile by earlier
# force-stopped cycles, and Smart Termination's >95%-of-span release is
# unreachable because the trace goes quiet BEFORE that learned tail ends.
# The flag then freezes True and every ENDING finalize backstop (all
# gated on `not _verified_pause`) is defeated, so the cycle hangs for
# hours until the watchdog's multi-hour silence limit force-ends it.
# Release the auto-pause once the cycle has completed its expected
# duration AND has been continuously sub-threshold for the finalize
# quiet floor: the drying (if any) is over, so let the normal end path
# finalize. This mirrors the dishwasher `quiet_released` gate the
# detector already trusts in `_should_defer_finish`. A real user pause
# is authoritative and re-asserted below, so it is never released here.
expected_dur = self.detector.expected_duration_seconds
# Gap-free tally only: a telemetry outage is unobserved time and must
# not satisfy the quiet floor that releases the auto-detected pause
# (mirrors the detector's dishwasher quiet-release gates). Fall back to
# the plain tally if the attribute is missing (older detector).
time_below = getattr(
self.detector,
"_time_below_threshold_gapfree",
getattr(self.detector, "_time_below_threshold", 0.0),
)
if (
verified_pause
and not self._is_user_paused
and expected_dur > 0
and current_duration >= expected_dur
and time_below >= ENDING_HARD_FINALIZE_MIN_QUIET_S
):
self._logger.info(
"Releasing auto-detected pause for %s: reached expected "
"duration (%.0fs >= %.0fs) and sustained-quiet %.0fs - "
"allowing normal cycle finish (issue #375).",
current_matched or self._current_program,
current_duration,
expected_dur,
time_below,
)
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
self._last_member_confidence = result.member_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 = self._profile_duration(
prof.get("avg_duration")
)
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)
# Element 8 is the narrow #288-only prefix verdict and element 9 the
# matched profile's own tail power level, both for the #364 guards;
# element 10 is its terminal high-power block for the #399 anti-crease
# guard; element 11 is its measured post-activity quiet span, which
# bounds the tail Smart Termination may bank (register item 297). The detector
# tolerates shorter tuples, so other callers stay valid.
terminal_high = self._terminal_high_for_guards(profile_name)
self.detector.update_match(
(profile_name, confidence, matched_duration, phase_name,
result.is_confident_mismatch, result.is_ambiguous,
result.is_prefix_ambiguous,
result.is_prefix_ambiguous_full_shape,
self.profile_store.profile_tail_power(profile_name) if profile_name else None,
terminal_high,
# Element 11 (register item 297): the matched profile's measured post-activity
# quiet span, which bounds how much of Smart Termination's
# confirmation delay _keep_tail_cap may store as cycle time.
self.profile_store.profile_terminal_quiet_seconds(profile_name)
if profile_name else None,
# Element 12 (register item 330): the longest expected duration
# still in play across the candidates. The ENDING fallback gate
# raises its bar to this while the match is ambiguous, instead of
# refusing to shorten at all.
# From the FULL candidate population, carried on the result -
# `result.candidates` is `candidates[:5]` and would hide the
# very programme `_match_prefix_ambiguous` is warning about.
float(getattr(result, "longest_candidate_duration_s", 0.0) or 0.0))
)
# --- 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):
_restore_power = _finite_power(state.state)
if _restore_power is None:
# Not numeric, or nan/inf: treat as 0W and do not restore by power.
# Leaving power_is_valid False is what keeps a non-finite reading out
# of the restore decision, whose comparisons would silently be False.
self._logger.debug(
"Power sensor %s state %r is not a finite number during "
"restoration; treating as 0W and not restoring by power",
self.power_sensor_entity_id,
getattr(state, "state", None),
)
else:
current_power = _restore_power
power_is_valid = True
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)
# Anti-wrinkle keepalive anchor (#339). The keepalive in
# _handle_state_expiry needs a "sensor last spoke" timestamp, but
# _last_real_reading_time is only ever set by a live reading, so a
# restart into ANTI_WRINKLE with an already-silent plug leaves it
# None and the keepalive can never fire -- pinning the mode until
# the next cycle. The snapshot save is driven by real readings, so
# last_save is the best available proxy. Scoped to ANTI_WRINKLE so
# no other timer sees a synthetic anchor.
if self.detector.state == STATE_ANTI_WRINKLE and last_save:
self._last_real_reading_time = last_save
# 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"
)
# Restore the dynamic price timeline (#426). Prices recorded
# before the restart stay valid; a change that happened while HA
# was down is recovered from the recorder at cycle end, guided by
# the restart gap recorded a few lines below.
self._price_timeline = _coerce_price_timeline(
active_snapshot_to_restore.get("price_timeline")
)
# 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..."
# Re-pin a manual program override across the restart (#404
# secondary bug). _manual_program_active was restored above, but
# _matched_profile_duration was not; without this the manual
# matcher tuple would feed expected_duration=0 on the next tick,
# wipe the detector's matched_profile, and drop the cycle to
# "detecting..." + the unmatched watchdog guard. The duration is
# re-read from the (possibly since-learned) profile, so an empty
# profile stays active with a None duration rather than being lost.
if self._manual_program_active:
manual_name = (
active_snapshot_to_restore.get("manual_program_name")
or self.detector.matched_profile
)
profile = (
self.profile_store.get_profile(manual_name)
if manual_name
else None
)
if profile is not None:
self._current_program = manual_name
self._matched_profile_duration = self._profile_duration(
profile.get("avg_duration")
)
self._logger.info(
"Restored manual program override: %s (duration=%.0fs)",
manual_name,
self._matched_profile_duration or 0.0,
)
else:
# The chosen profile was deleted while HA was down.
self._manual_program_active = False
self._logger.info(
"Manual program %r no longer exists after restart; "
"reverting to auto-detect",
manual_name,
)
# 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)
# Auto-open dishwasher: arm the dwell if we restored into ENDING
# with the door already open. _on_state_change is not called
# during restoration, so the transition guard there would not fire.
self._maybe_arm_door_end_dwell_if_open()
# 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_repair_banked_tails(self) -> None:
"""One-time repair of cycles that banked the end-of-cycle confirmation
delay as cycle time (register item 297).
Runs in the background after setup. Never raises: the store method already
swallows its own failures and leaves the history untouched, and this
wrapper exists only so a scheduling error cannot surface as an unhandled
task exception.
"""
try:
result = await self.profile_store.async_repair_banked_tails(
float(self.detector.config.stop_threshold_w), self.device_type
)
if result.get("repaired"):
self._logger.info(
"Repaired %d of %d stored cycles that had banked the "
"end-of-cycle confirmation delay (%.0f min reclaimed); profile "
"averages and envelopes rebuilt.",
result["repaired"],
result["examined"],
result["reclaimed_s"] / 60.0,
)
except Exception as exc: # pylint: disable=broad-exception-caught
self._logger.warning("Banked-tail repair could not run: %s", exc)
def async_schedule_banked_tail_repair(self) -> None:
"""Run the one-time banked-tail repair now if the marker is armed.
`async_setup` is one caller, and covers the storage migration that arms
the marker at v12->v13. An **import** arms it too - both
`async_import_data` and `async_import_data_selective` set it for a payload
old enough to carry banked tails - and an import does not reliably reload
the entry: the WS handlers only call `async_update_entry` when the payload
brings options with it, so a cycles-only import, or any selective import
with `apply_settings=False`, left the marker set and the imported tails
feeding `avg_duration`, the ETA and Smart Termination until the next
restart.
Cheap when there is nothing to do - the marker is the whole test, and the
repair clears it. A second call while the first is still in flight is a
no-op rather than a second walk of the history: the repair only clears the
marker at the end, so the check alone would not stop two concurrent runs
from rebuilding the same envelopes.
"""
if self.profile_store.banked_tail_repair_pending() is not True:
return
existing = self._banked_tail_repair_task
if existing is not None and not existing.done():
return
self._banked_tail_repair_task = self._spawn_tracked(
self._async_repair_banked_tails()
)
def _terminal_high_for_guards(self, profile_name: str | None) -> Any:
"""Element 10: the matched profile's last high-power block, or None.
Two callers with two different bars, and the bar has to travel with the
block (register item 351):
* **anti-crease** (#399) measures against ``anti_wrinkle_max_power``, the
dryer's "a tumble is below this" level. Only meaningful while
anti-wrinkle is on, and it sends a triple.
* **the standby-band finalise** (#296 / #445) shares the same predicate,
and used to get nothing at all: element 10 was supplied ONLY when
anti-wrinkle was enabled, and `DEFAULT_ANTI_WRINKLE_ENABLED` is False,
so `_anticrease_spin_pending` returned False immediately and a washer
could finalise on the quiet plateau before its final spin - recording
that spin as a second cycle. Measured over the 273-cycle replay corpus:
the standby band fires on 14 cycles, 11 of them with the guard inert,
and 6 of those 11 have a reading above `min_power` still ahead, i.e.
would split. This arms it against a share of the cycle's own peak, the
same `STANDBY_BAND_MAX_FRACTION` the plateau test uses, which recovers
5 of the 6 for one extra bounded wait. Sent as a QUAD so
`_high_power_seconds_since` counts against that bar too.
Returns None when nothing applies, which leaves the guard exactly as
inert as it was - the fail-open direction every input here takes.
"""
# One implementation, shared with the Playground's sim tuple - see
# `cycle_detector.terminal_high_for_guards` for why it is not inlined here.
return terminal_high_for_guards(
self.profile_store,
self.detector.config,
getattr(self.detector, "_cycle_max_power", 0.0),
profile_name,
)
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.",
"unload_dismiss_action_title": "Stop reminding",
"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
# Re-scope custom phases stranded under another device type (#450). Cheap,
# idempotent and saves only on a change, so it runs on every setup rather
# than behind a one-shot marker: a reconfigure that changes device_type
# would otherwise strand the phases all over again.
try:
rescoped = await self.profile_store.async_repair_custom_phase_scope(
self.device_type
)
if rescoped:
self._logger.info(
"Re-scoped %d custom phase(s) that were stored under another "
"device type and could not be shown, edited or deleted.",
rescoped,
)
except Exception: # pylint: disable=broad-exception-caught
self._logger.exception("Failed re-scoping custom phases for %s", self.entry_id)
# 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 (state changes AND unchanged re-reports)
self._subscribe_power_sensor()
# 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()
# Rehydrate a program armed before a restart (#411). Arming is an
# idle-time action, so the wait between picking a program and starting the
# machine can easily span a Home Assistant restart.
try:
self._armed_program = self.profile_store.get_armed_program()
if self._armed_program:
self._logger.info(
"Program %r is armed for the next cycle", self._armed_program
)
except Exception: # pylint: disable=broad-exception-caught
self._armed_program = None
# 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):
# A non-finite reading is skipped entirely, which leaves the cache
# unset, i.e. the pre-#409 behaviour. Seeding it with a nan instead
# would be PERMANENT: _resync_power_from_state returns early precisely
# when the sensor is non-finite, so the healing path could never
# overwrite it, and every later watchdog comparison would be False.
power = _finite_power(state.state)
if power is not None:
try:
now = dt_util.now()
self.detector.process_reading(power, now)
# Seed the reading cache from the sensor itself (#409). The
# reading was already fed to the detector; leaving the manager's
# own cache unset meant every reload started with
# _current_power = 0 and _last_reading_time = None, so (a) the
# power tile/entity reported a value the sensor never had until
# the next event and (b) the watchdog - which returns early while
# _last_reading_time is None - could neither keepalive nor close a
# restored cycle whose plug went silent across the reload.
self._current_power = power
self._last_reading_time = now
self._last_real_reading_time = (
getattr(state, "last_reported", None) or state.last_updated
)
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 the unload confirmation entity (if configured, #451)
await self._setup_unload_confirm_listener()
# Subscribe to the dynamic energy price entity (if configured, #426)
await self._setup_price_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()
# One-time repair of cycles that banked Smart Termination's confirmation
# delay as cycle time (register item 297). Flagged by the v12->v13 storage
# migration and done here rather than in the migration itself, because
# deciding where a cycle's real activity ended needs stop_threshold_w and
# that lives in entry.options. Idempotent, marked done in the store, and it
# never raises - a failed repair leaves the history untouched.
# `is True` rather than a truthiness check: the flag is written as a real
# bool by the migration, so anything else here is a stub or a hand-edited
# store and must not trigger a rewrite of the user's history.
#
# LAST in async_setup, deliberately. The repair's own cycle loop takes no
# awaits, but it then awaits an envelope rebuild per touched profile, and
# every await hands the loop back to the rest of setup - which rewrites the
# very cycles it is rebuilding from. `async_repair_profile_samples` can
# drop a profile or re-point its sample, and
# `async_migrate_cycles_to_compressed` replaces `power_data` wholesale.
# Running last also means those legacy ISO-offset traces are already
# converted and therefore trimmable: started earlier, such a cycle gets its
# duration corrected and its trace left as it was, because `_safe_offset`
# rejects an ISO string and `kept` comes back empty.
# Backgrounded, not awaited. It walks up to 200 stored traces and rebuilds
# envelopes, and anything awaited inside async_setup is billed to the
# integration's reported startup time (register item 158 / #408). Nothing
# needs it before the first cycle ends. Tracked, because it writes to the
# ProfileStore: an untracked task would keep writing to the store a reload
# had already swapped out.
self.async_schedule_banked_tail_repair()
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 in _SENSOR_SWAP_BLOCKED_STATES:
# Skip the sensor change but continue with the other config
# updates: returning here would silently drop every setting
# saved alongside the sensor in the same submission.
self._logger.warning(
"Cannot change power sensor from %s to %s while the "
"detector is in state %s. Please wait for the current "
"cycle to complete before changing the power sensor.",
self.power_sensor_entity_id,
new_sensor,
d_state,
)
else:
self._logger.info(
"Power sensor changed: %s -> %s", self.power_sensor_entity_id, new_sensor
)
self.power_sensor_entity_id = new_sensor
# Re-attach change + report listeners to the new sensor
# (helper removes the old ones first).
self._subscribe_power_sensor()
# 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):
_reload_power = _finite_power(state.state)
if _reload_power is not None:
self.detector.process_reading(_reload_power, dt_util.now())
else:
self._logger.debug(
"Initial power value for %s after config reload is not a "
"finite number: %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
# Recompute the device-scaled unmatched-guard ceiling (#404): it tracks
# device_type, which the reconfigure flow can change.
self._unmatched_watchdog_ceiling = float(
DEFAULT_UNMATCHED_WATCHDOG_CEILING_BY_DEVICE.get(
self.device_type, DEFAULT_UNMATCHED_WATCHDOG_CEILING
)
)
# 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))
# Device-resolved, like the constructor: the default is 8 min on a washing
# machine and an hour on a dishwasher, so falling back to the scalar would
# silently shorten the bridge on a reload.
new_min_off_gap = int(
config_entry.options.get(
CONF_MIN_OFF_GAP, resolve_min_off_gap_default(self.device_type)
)
)
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
)
)
# Keep the detector's copy in sync so a panel edit takes effect without a
# restart, exactly like min_duration_ratio below.
self.detector.config.match_confidence_threshold = float(
config_entry.options.get(
CONF_PROFILE_MATCH_THRESHOLD, DEFAULT_PROFILE_MATCH_THRESHOLD
)
)
self.profile_store.dtw_bandwidth = float(
config_entry.options.get(CONF_DTW_BANDWIDTH, DEFAULT_DTW_BANDWIDTH)
)
# Re-plumbed on every reload, not just at construction (issue #407): this is
# a panel checkbox, and an options change reloads in place without rebuilding
# the store, so without this line the toggle only took effect on an HA restart.
self.profile_store.save_debug_traces = config_entry.options.get(
CONF_SAVE_DEBUG_TRACES, False
)
# Stage-4 energy discriminator: integrated energy for WM/washer-dryer,
# mean power elsewhere (see analysis.stage4_energy_mode).
self.profile_store.energy_mode = analysis.stage4_energy_mode(self.device_type)
# Which cycle categories may shape a profile. Changing it changes every
# profile's curve, so rebuild them all now: envelopes are otherwise only rebuilt
# on a cycle end or a label change, so the user would tick the box and see
# nothing happen for days.
_prev_evidence = self.profile_store.evidence_sources
self.profile_store.evidence_sources = config_entry.options.get(
CONF_PROFILE_EVIDENCE_SOURCES, DEFAULT_PROFILE_EVIDENCE_SOURCES
)
if self.profile_store.evidence_sources != _prev_evidence:
self._logger.info(
"Profile evidence sources changed %s -> %s; rebuilding all envelopes",
list(_prev_evidence), list(self.profile_store.evidence_sources),
)
# Tracked, not fire-and-forget: this writes to the ProfileStore, so a
# reload/unload mid-rebuild must be able to cancel it before the store is
# swapped out (see _spawn_tracked).
self._spawn_tracked(self.profile_store.async_rebuild_all_envelopes())
# 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,
resolve_start_duration_default(self.device_type),
)
)
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_anti_wrinkle_idle_timeout = float(
config_entry.options.get(
CONF_ANTI_WRINKLE_IDLE_TIMEOUT, DEFAULT_ANTI_WRINKLE_IDLE_TIMEOUT
)
)
new_dishwasher_end_spike_quiet_release = float(
config_entry.options.get(
CONF_DISHWASHER_END_SPIKE_QUIET_RELEASE,
DISHWASHER_END_SPIKE_QUIET_RELEASE_SECONDS,
)
)
new_smart_termination_duration_ratio = float(
config_entry.options.get(
CONF_SMART_TERMINATION_DURATION_RATIO,
resolve_smart_termination_duration_ratio_default(self.device_type),
)
)
new_anti_crease_finalize_ratio = float(
config_entry.options.get(
CONF_ANTI_CREASE_FINALIZE_RATIO,
DEFAULT_ANTI_CREASE_FINALIZE_RATIO,
)
)
new_curve_preroll_seconds = float(
config_entry.options.get(
CONF_CURVE_PREROLL_SECONDS, DEFAULT_CURVE_PREROLL_SECONDS
)
)
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
# #378: keep the detector's device_type in sync when the appliance type
# is changed in the UI, so the correct detection branch runs after reload.
self.detector.config.device_type = self.device_type
self.detector.config.min_power = new_min_power
self.detector.config.off_delay = new_off_delay
self.detector.config.min_off_gap = new_min_off_gap
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.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.anti_wrinkle_idle_timeout = new_anti_wrinkle_idle_timeout
self.detector.config.dishwasher_end_spike_quiet_release = new_dishwasher_end_spike_quiet_release
self.detector.config.smart_termination_duration_ratio = new_smart_termination_duration_ratio
self.detector.config.anti_crease_finalize_ratio = new_anti_crease_finalize_ratio
self.detector.config.curve_preroll_seconds = new_curve_preroll_seconds
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_live_sticky = bool(
config_entry.options.get(
CONF_NOTIFY_LIVE_STICKY, DEFAULT_NOTIFY_LIVE_STICKY
)
)
self._notify_live_click_action = str(
config_entry.options.get(
CONF_NOTIFY_LIVE_CLICK_ACTION, DEFAULT_NOTIFY_LIVE_CLICK_ACTION
)
or ""
).strip()
self._notify_live_silent = bool(
config_entry.options.get(
CONF_NOTIFY_LIVE_SILENT, DEFAULT_NOTIFY_LIVE_SILENT
)
)
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._door_opens_at_end = bool(
config_entry.options.get(CONF_DOOR_OPENS_AT_END, DEFAULT_DOOR_OPENS_AT_END)
)
self._door_end_dwell_seconds = int(
config_entry.options.get(CONF_DOOR_END_DWELL_SECONDS, DEFAULT_DOOR_END_DWELL_SECONDS)
)
self._notify_unload_delay_minutes = int(
config_entry.options.get(
CONF_NOTIFY_UNLOAD_DELAY_MINUTES, DEFAULT_NOTIFY_UNLOAD_DELAY_MINUTES
)
)
self._notify_unload_repeat = bool(
config_entry.options.get(
CONF_NOTIFY_UNLOAD_REPEAT, DEFAULT_NOTIFY_UNLOAD_REPEAT
)
)
self._unload_confirm_entity = config_entry.options.get(
CONF_UNLOAD_CONFIRM_ENTITY
) or None
self._unload_track_without_door = bool(
config_entry.options.get(
CONF_UNLOAD_TRACK_WITHOUT_DOOR, DEFAULT_UNLOAD_TRACK_WITHOUT_DOOR
)
)
# Re-subscribe to external cycle end trigger
await self._setup_external_end_trigger()
# Re-subscribe to door sensor. Cancel any dwell armed for the previous door
# config first — after a sensor/auto-open/dwell change the old sensor may no
# longer emit the close event that cancels it, so a stale timer could finalize
# the cycle on outdated config. Re-evaluate for the new configuration after.
self._cancel_door_end_dwell()
await self._setup_door_sensor_listener()
self._maybe_arm_door_end_dwell_if_open()
# Re-subscribe to the unload confirmation entity (#451).
await self._setup_unload_confirm_listener()
# Re-subscribe to the dynamic energy price entity (#426). A changed entity
# (or the toggle being turned off) takes effect from here on; the samples
# already recorded for a running cycle stay - they were true when taken.
await self._setup_price_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:
# Counters and timers only: a settings save is not a cycle
# boundary, so the live activity must stay "already started".
self._reset_live_notification_state(keep_activity_started=True)
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,
resolve_sampling_interval_default(self.device_type),
)
)
if old_sampling != new_sampling:
self._sampling_interval = new_sampling
self._logger.info(
"Updated sampling interval: %.1fs -> %.1fs", old_sampling, new_sampling
)
# Watchdog cadence: like sampling above, this was only read at construction, so a
# changed CONF_WATCHDOG_INTERVAL (or a device-type change selecting a new default)
# otherwise kept the old cadence until the manager was recreated. Re-arm an active
# watchdog so the new interval takes effect mid-cycle.
old_watchdog = self._watchdog_interval
new_watchdog = int(
config_entry.options.get(
CONF_WATCHDOG_INTERVAL,
resolve_watchdog_interval_default(self.device_type),
)
)
if old_watchdog != new_watchdog:
self._watchdog_interval = new_watchdog
self._logger.info(
"Updated watchdog interval: %ds -> %ds", old_watchdog, new_watchdog
)
if self._remove_watchdog: # active cycle: cancel and re-register at the new cadence
self._stop_watchdog()
self._start_watchdog()
# 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_report_listener:
self._remove_report_listener()
self._remove_report_listener = None
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_unload_confirm_listener:
self._remove_unload_confirm_listener()
self._remove_unload_confirm_listener = None
if self._remove_price_listener:
self._remove_price_listener()
self._remove_price_listener = None
self._cancel_door_end_dwell()
# Drop the repeat-unload-reminder dismiss action listener (#374).
self._remove_unload_dismiss_listener()
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
)
async def _setup_unload_confirm_listener(self) -> None:
"""Subscribe to the optional unload confirmation entity (#451).
Deliberately domain-agnostic: the point of the option is that a door sensor
is not available, so whatever the user already has - a Zigbee button
(``event.*`` or a ``sensor.*`` action), an ``input_button`` helper, a motion
sensor, a scene - can say "the load has been taken out".
"""
if self._remove_unload_confirm_listener:
self._remove_unload_confirm_listener()
self._remove_unload_confirm_listener = None
entity_id = self._unload_confirm_entity
if not entity_id:
return
self._logger.info("Setting up unload confirmation listener: %s", entity_id)
self._remove_unload_confirm_listener = async_track_state_change_event(
self.hass, [entity_id], self._handle_unload_confirm_change
)
# Anchor the replay window PER ENTITY, in `hass.data` so it survives entry
# reloads and resets on an HA restart.
#
# Not per subscribe: a settings save is a full entry reload here (the log
# shows a fresh `Manager init`), and re-arming on it would cost the user a
# press for two minutes after every save - the same lost-press bug this
# window exists beside, just narrower. A reload cannot produce a replay
# anyway, because MQTT is not reloaded with us and the entity keeps its
# state, so no `unknown -> value` transition occurs.
#
# But not per PROCESS either: keyed on the entity, a newly CONFIGURED
# confirmation entity gets its own window instead of inheriting an expired
# one from whatever was configured before it. Without that, pointing the
# option at a fresh `unknown` entity hours into a session left it with no
# protection at all, and its first retained value would clear a waiting
# Clean state. Old keys are left behind deliberately - the dict is bounded
# by the distinct entities a user has ever chosen here.
anchors = self.hass.data.setdefault(_UNLOAD_CONFIRM_ANCHOR_KEY, {})
if isinstance(anchors, dict):
anchors.setdefault(entity_id, dt_util.now())
async def _setup_price_listener(self) -> None:
"""Subscribe to the dynamic energy price entity (#426).
Only when a price *entity* is configured and dynamic pricing is on: a
static price cannot move, so there is nothing to track. Registered for the
entity's whole lifetime rather than per cycle - the appended samples are
gated on the detector being active, and a subscription that only exists
while a cycle runs would miss the price in force at the moment it starts.
"""
if self._remove_price_listener:
self._remove_price_listener()
self._remove_price_listener = None
if not self._dynamic_pricing_enabled():
return
entity_id = self._price_entity_id()
if not entity_id:
return
self._logger.debug("Setting up dynamic price listener: %s", entity_id)
self._remove_price_listener = async_track_state_change_event(
self.hass, [entity_id], self._handle_price_change
)
def _dynamic_pricing_enabled(self) -> bool:
"""Whether cost should be integrated against a moving price (#426)."""
options = self.config_entry.options
if not self._price_entity_id():
return False
return bool(
options.get(CONF_ENERGY_PRICE_DYNAMIC, DEFAULT_ENERGY_PRICE_DYNAMIC)
)
@callback
def _handle_price_change(self, event: Event[evt.EventStateChangedData]) -> None:
"""Record a price change onto the running cycle's timeline (#426)."""
new_state = event.data.get("new_state")
if new_state is None:
return
try:
price = float(new_state.state)
except (ValueError, TypeError):
# unknown/unavailable/non-numeric: carry the last known price forward
# rather than charging the cycle at zero for the outage.
return
self._append_price_sample(price)
def _append_price_sample(self, price: float | None) -> None:
"""Append ``price`` to the current cycle's timeline, deduplicated.
No-op when no cycle is running - the timeline describes one cycle - and
when the price is unchanged, so a template sensor that re-emits the same
number every few seconds costs one comparison and nothing else.
"""
if price is None:
return
if self.detector.state not in (
STATE_STARTING,
STATE_RUNNING,
STATE_PAUSED,
STATE_ENDING,
):
return
try:
value = round(float(price), PRICE_TIMELINE_PRICE_DECIMALS)
except (ValueError, TypeError):
return
if not math.isfinite(value):
# Same rule as _finite_power: "nan"/"inf" parse cleanly and would ride
# into the stored timeline. nan also defeats the dedup below, since it
# compares unequal to itself, so every report would append a point.
return
if self._price_timeline and self._price_timeline[-1][1] == value:
return
self._price_timeline.append((dt_util.now().timestamp(), value))
# Hard bound so a pathologically chatty price entity cannot grow the
# in-memory list without limit during a long cycle; the stored timeline is
# compacted again (by price step) at cycle end.
if len(self._price_timeline) > PRICE_TIMELINE_MAX_POINTS * 4:
self._price_timeline = [
(offset, price_val)
for offset, price_val in compact_price_timeline(
self._price_timeline,
max_points=PRICE_TIMELINE_MAX_POINTS * 2,
decimals=PRICE_TIMELINE_PRICE_DECIMALS,
)
]
def _start_price_timeline(self) -> None:
"""Open a fresh price timeline for a cycle that just started (#426)."""
self._price_timeline = []
if not self._dynamic_pricing_enabled():
return
self._append_price_sample(self._resolve_energy_price())
@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.mark_unloaded("door opened")
elif (
self._door_opens_at_end
and self.detector.state in (STATE_RUNNING, STATE_ENDING)
):
# Auto-open dishwasher (#342): the machine pops its door at the end.
# A sustained open means the cycle finished; a brief open (adding an
# item) does not. Arm a dwell timer instead of the sticky user-pause
# (which would strand the cycle in user_paused). If the door stays
# open past the dwell we finalize; if it closes first we cancel.
self._logger.debug(
"Door opened on auto-open device: arming %ss end dwell",
self._door_end_dwell_seconds,
)
self._arm_door_end_dwell()
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()
else:
# Door closed: cancel a pending auto-open finalize (it was a brief open,
# not the end-of-cycle door pop). No auto-resume otherwise (#342).
if self._remove_door_end_dwell is not None:
self._logger.debug("Door closed before end dwell: cancelling finalize")
self._cancel_door_end_dwell()
self._notify_update()
@callback
def _handle_unload_confirm_change(self, event: Event[evt.EventStateChangedData]) -> None:
"""Treat an activation of the unload confirmation entity as "unloaded" (#451).
Any change to a real state counts, because the entity is whatever the user
had to hand: an ``event.*`` button writes a fresh timestamp per press, an
``input_button`` the same, a ``sensor.*`` action sensor writes "single" and
resets.
Excluded: a transition *to* unknown/unavailable/``off``/empty (the release
half of a contact or motion sensor, or a device dropping off), a transition
*out of* ``unavailable`` (a flat battery coming back is not a press), and an
entity that has only just appeared (``old_state is None``, which is what a
restored last-press timestamp looks like on HA start).
**``unknown`` is the interesting one, and it is time-scoped rather than
excluded outright (register item 367).** A fresh ``event.*`` / ``button.*``
/ ``input_button.*`` sits at ``unknown`` until it is first pressed, so a
blanket exclusion swallowed the FIRST EVER press - and that reads as "the
feature does not work", which is the #445 failure one layer down. Accepting
it outright is not safe either: a z2m action sensor publishes its action as
a RETAINED MQTT message, replayed by the broker on reconnect, arriving as
precisely this transition. The restart case is already covered above, so
the only gap left is the moment just after we subscribe - hence
``UNLOAD_CONFIRM_REPLAY_GRACE_S`` from the subscription, after which an
``unknown -> value`` change is taken at face value.
"""
new_state = event.data.get("new_state")
old_state = event.data.get("old_state")
# No old state = the entity was just added or HA has just started. A button
# entity's restored last-press timestamp must not count as a press.
if new_state is None or old_state is None:
return
new_val = new_state.state
old_val = old_state.state
if new_val == old_val:
return
if new_val in ("unavailable", "unknown"):
# Going away RE-ARMS the window. The startup anchor alone only covers
# the reconnect that follows an HA restart; a broker restart hours
# later replays retained values just the same, and the entity passes
# through `unavailable`/`unknown` on its way out. Re-anchoring here
# means the value that comes back is judged as the replay it may well
# be. Costs nothing on the press path: a value arriving straight after
# `unavailable` is excluded outright either way.
self._rearm_unload_confirm_window()
return
if new_val in ("off", ""):
return
if old_val == "unavailable":
return
if old_val == "unknown" and self._in_unload_confirm_replay_window():
self._logger.debug(
"Ignoring %s -> %s within the unload-confirm replay window; a "
"retained value can arrive this soon after subscribing",
old_val,
new_val,
)
return
self.mark_unloaded(f"{self._unload_confirm_entity} -> {new_val}")
def _rearm_unload_confirm_window(self) -> None:
"""Restart the replay window for the configured entity.
Called when it drops to ``unavailable``/``unknown``, because whatever it
reports on the way back may be a retained value rather than a press.
"""
entity_id = self._unload_confirm_entity
if not entity_id:
return
anchors = self.hass.data.setdefault(_UNLOAD_CONFIRM_ANCHOR_KEY, {})
if isinstance(anchors, dict):
anchors[entity_id] = dt_util.now()
def _in_unload_confirm_replay_window(self) -> bool:
"""Whether this entity came back too recently to trust ``unknown -> value``.
Measured from the entity's own anchor: set when this process first
subscribed to it, and restarted every time it drops out (see
`_rearm_unload_confirm_window`). Survives entry reloads, resets on an HA
restart, and does not carry over between different configured entities.
Fails CLOSED (True) if the anchor is missing or unusable: "we do not know
when this entity came back" carries the same risk as "it just did".
"""
entity_id = self._unload_confirm_entity
anchors = self.hass.data.get(_UNLOAD_CONFIRM_ANCHOR_KEY)
anchor = anchors.get(entity_id) if isinstance(anchors, dict) else None
if not isinstance(anchor, datetime):
return True
try:
elapsed = (dt_util.now() - anchor).total_seconds()
except (TypeError, ValueError, OverflowError):
return True
return elapsed < UNLOAD_CONFIRM_REPLAY_GRACE_S
def mark_unloaded(self, source: str = "manual") -> bool:
"""Clear the Clean state: the load has been taken out (#153, #451).
Single owner of the "laundry retrieved" transition, shared by the door-open
handler, the unload confirmation entity, the Mark Unloaded button and the
``mark_unloaded`` service, so the four can never drift on what clearing it
entails. Idempotent: a confirmation arriving when nothing is waiting is a
no-op, which is what an automation that fires on every button press needs.
Returns True when a Clean state was actually cleared.
"""
if not self._is_clean_state:
return False
self._logger.debug("Unload confirmed (%s): clearing Clean state", source)
self._is_clean_state = False
self._clean_state_start = None
self._notified_clean_laundry = False
self._reset_unload_nag_tracking()
# 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()
return True
def _unload_confirmable_without_door(self) -> bool:
"""Whether unload can be confirmed with no door sensor configured (#451).
Also the opt-in for entering the Clean state at all on such a device: with
neither option set there would be no way to clear it, so the reminder would
nag until the progress-reset window expired.
"""
return bool(self._unload_confirm_entity) or self._unload_track_without_door
def _maybe_arm_door_end_dwell_if_open(self) -> None:
"""Arm the end-dwell timer when in RUNNING or ENDING with the door already open.
Called from both ``_on_state_change`` (live transition) and the
snapshot-restoration path (where ``_on_state_change`` is not invoked).
Matches the state set accepted by ``_handle_door_sensor_change`` so that
restoring a RUNNING snapshot with the door already open re-arms the dwell.
"""
if not (
self.detector.state in (STATE_RUNNING, STATE_ENDING)
and self._door_opens_at_end
and self._door_sensor_entity
and self._remove_door_end_dwell is None
):
return
door_state = self.hass.states.get(self._door_sensor_entity)
if door_state and door_state.state == "on":
self._logger.debug(
"Door already open on ENDING: arming %ss end dwell",
self._door_end_dwell_seconds,
)
self._arm_door_end_dwell()
def _arm_door_end_dwell(self) -> None:
"""(Re)arm the auto-open door-end dwell timer (#342)."""
self._cancel_door_end_dwell()
self._remove_door_end_dwell = async_call_later(
self.hass,
float(max(1, self._door_end_dwell_seconds)),
self._door_end_dwell_fired,
)
def _cancel_door_end_dwell(self) -> None:
"""Cancel a pending auto-open door-end dwell timer, if any (#342)."""
if self._remove_door_end_dwell is not None:
self._remove_door_end_dwell()
self._remove_door_end_dwell = None
@callback
def _door_end_dwell_fired(self, _now: Any) -> None:
"""The door stayed open past the dwell on an auto-open device: the cycle has
finished, so finalize it as completed (same path as the External End
Trigger). The dwell is normally cancelled on door-close, but re-validate the
live conditions here defensively — a close event could have been missed, or a
user pause could have landed mid-dwell — before finalizing (#342)."""
self._remove_door_end_dwell = None
door_state = (
self.hass.states.get(self._door_sensor_entity)
if self._door_sensor_entity
else None
)
if (
self.detector.state in (STATE_RUNNING, STATE_ENDING)
and self._door_opens_at_end
and not self._is_user_paused
and door_state is not None
and door_state.state == "on"
):
# The end-of-cycle door pop follows the power drop, so an appliance still
# drawing above the stop threshold means the door was opened mid-cycle
# (loading a dish and walking off) rather than at the end - finalizing
# there would record a running cycle as completed.
#
# Re-arm rather than abandon: the dwell is one-shot, so dropping it here
# would permanently lose the door-based finalize for a machine that just
# happens to be mid-pulse when the timer lands (fan/zeolite drying can
# draw with the door already popped), leaving the cycle to the power
# timeout that #342 exists to short-circuit. Re-arming is asymmetric: it
# can only ever delay the finalize, never skip it.
stop_thr = 0.0
try:
stop_thr = float(getattr(self.detector.config, "stop_threshold_w", 0.0) or 0.0)
except (TypeError, ValueError):
stop_thr = 0.0
if stop_thr > 0.0 and self._current_power >= stop_thr:
self._logger.debug(
"Door-end dwell fired but power %.1fW is still at/above the stop "
"threshold %.1fW: treating as a mid-cycle door open, re-arming",
self._current_power,
stop_thr,
)
self._arm_door_end_dwell()
return
self._logger.info(
"Door held open %ss on auto-open device: finalizing cycle",
self._door_end_dwell_seconds,
)
self.detector.user_stop()
self._notify_update()
else:
self._logger.debug(
"Door-end dwell fired but conditions no longer hold "
"(state=%s, door=%s, user_paused=%s): not finalizing",
self.detector.state,
getattr(door_state, "state", None),
self._is_user_paused,
)
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
self._flush_pending_notifications(person_entity_id, person_name)
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")
def _external_end_trigger_available(self) -> bool:
"""True when an authoritative external end trigger is wired and reporting.
Used by the unmatched zombie guard (#404): if the user has configured an
external end-of-cycle binary sensor and it currently has a usable state, an
authoritative end signal already exists, so the time-based failsafe would only
do harm (it would truncate a long programme that the trigger will end cleanly).
Returns False if disabled, unconfigured, or the entity is missing/unavailable.
"""
if not self.config_entry.options.get(CONF_EXTERNAL_END_TRIGGER_ENABLED, False):
return False
entity_id = self.config_entry.options.get(CONF_EXTERNAL_END_TRIGGER, "")
if not entity_id:
return False
state = self.hass.states.get(entity_id)
return state is not None and state.state not in ("unavailable", "unknown")
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}
# Snapshot on the event loop before any executor offload (training +
# matcher tuning): get_past_cycles() returns the live mutable list, so a
# concurrent cycle add / retention trim could otherwise change the input
# mid-run.
cycles = list(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, self.device_type
)
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))
async def async_recompute_cycle_costs(self) -> int:
"""Recost stored cycles from the recorder's price history (#426).
Existing cycles were costed at the single price in force when they ended -
including everything imported from raw history (#344), which was costed at
whatever the tariff happened to be at import time. Where the recorder still
holds the price entity's history, those cycles can be recosted properly
after the fact. Returns the number of cycles rewritten.
Deliberately conservative, because it overwrites a figure the user has
already seen:
* only ``past_cycles`` and ``backfill_cycles`` - reference cycles are other
people's recordings and were never the user's energy to pay for;
* only cycles the recorder can actually answer for (a price row at or before
the cycle's start); anything older than the recorder's retention keeps the
cost it has;
* cycles already costed dynamically are left alone, so the pass is
idempotent and never re-derives a live-tracked timeline from a coarser
recorder view.
"""
if not self._dynamic_pricing_enabled():
return 0
candidates: list[tuple[dict[str, Any], datetime, datetime]] = []
for cycle in list(self.profile_store.get_past_cycles()) + list(
self.profile_store.get_backfill_cycles()
):
if cycle.get("energy_price_mode") == "dynamic" and cycle.get("price_timeline"):
continue
start_dt = dt_util.parse_datetime(str(cycle.get("start_time") or ""))
end_dt = dt_util.parse_datetime(str(cycle.get("end_time") or ""))
# An ISO string without an offset parses naive, which every record this
# device writes is not, but an import or a hand-edited file can be. Read
# as UTC, the same way the odometer scan does: one such cycle otherwise
# raises on the aware `horizon` comparison below, and the WS caller only
# debug-logs that, so the whole pass silently recosts nothing.
if start_dt is not None and start_dt.tzinfo is None:
start_dt = start_dt.replace(tzinfo=dt_util.UTC)
if end_dt is not None and end_dt.tzinfo is None:
end_dt = end_dt.replace(tzinfo=dt_util.UTC)
if start_dt is None or end_dt is None or end_dt <= start_dt:
continue
candidates.append((cycle, start_dt, end_dt))
if not candidates:
return 0
# Bound the query to what the recorder can still answer. Reading further
# back returns nothing but makes the executor walk the whole retained
# window of a frequently-updating price entity for it.
keep_days = 10.0
try:
from homeassistant.components.recorder import get_instance # noqa: PLC0415
keep_days = float(getattr(get_instance(self.hass), "keep_days", 10) or 10)
except Exception: # noqa: BLE001 - recorder optional; the default stands
pass
horizon = dt_util.now() - timedelta(days=min(max(keep_days, 1.0), 365.0))
candidates = [c for c in candidates if c[2] >= horizon]
if not candidates:
return 0
window_start = min(start for _, start, _ in candidates)
window_end = max(end for _, _, end in candidates)
rows = await self._async_price_history(max(window_start, horizon), window_end)
if not rows:
return 0
updated = 0
for cycle, start_dt, end_dt in candidates:
start_ts = start_dt.timestamp()
end_ts = end_dt.timestamp()
# Require an anchor at or before the cycle: without one the first known
# price would be back-applied to energy bought before it existed.
anchor: tuple[float, float] | None = None
window: list[tuple[float, float]] = []
for ts, price in rows:
if ts <= start_ts:
# Keep only the newest pre-start row. Older ones all collapse
# onto offset 0 and, once there are more of them than
# PRICE_TIMELINE_MAX_POINTS, compaction can spend the whole
# budget on prices this cycle never ran at and evict the real
# anchor or an in-cycle transition.
anchor = (ts, price)
elif ts <= end_ts:
window.append((ts, price))
if anchor is None:
continue
window.insert(0, anchor)
points = compact_price_timeline(
[(max(0.0, ts - start_ts), price) for ts, price in window],
max_points=PRICE_TIMELINE_MAX_POINTS,
decimals=PRICE_TIMELINE_PRICE_DECIMALS,
)
if not points:
continue
result = self._cost_from_timeline(cycle, points)
if result is None:
continue
cost, effective_price = result
cycle["cost"] = round(cost, 4)
cycle["energy_price"] = round(effective_price, 6)
cycle["energy_price_mode"] = "dynamic"
cycle["price_timeline"] = [[round(offset, 1), price] for offset, price in points]
updated += 1
if updated:
await self.profile_store.async_save()
self._logger.info("Recosted %d cycle(s) from recorder price history", updated)
return updated
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 _subscribe_power_sensor(self) -> None:
"""(Re)subscribe to the power sensor's state changes AND unchanged reports.
HA fires EVENT_STATE_CHANGED only when the value (or attributes) change.
A plug that periodically re-reports the same value (Tasmota TelePeriod,
Zigbee max reporting interval) fires EVENT_STATE_REPORTED instead, which a
state_changed-only subscription never receives. Missing those reports means
a flat sub-threshold tail never advances the detector's end-of-cycle timer
(#363) and a finished cycle lags by the plug's reporting interval (#329).
Both events route into the same handler. Per HA, a single write fires either
state_changed (value/attrs differ) or state_reported (unchanged), never both,
so there is no double-counting. Report events carry ``new_state`` without an
``old_state``; ``_async_power_changed`` already tolerates a missing old_state.
"""
if self._remove_listener:
self._remove_listener()
if self._remove_report_listener:
self._remove_report_listener()
self._remove_listener = async_track_state_change_event(
self.hass, [self.power_sensor_entity_id], self._async_power_changed
)
self._remove_report_listener = async_track_state_report_event(
self.hass, [self.power_sensor_entity_id], self._async_power_changed
)
@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
power = _finite_power(new_state.state)
if power is None:
return
# Capture every raw sensor reading before any throttling or processing.
# Use the sensor's own report timestamp so the trace reflects when the plug
# actually reported the value, not when we received it. last_reported
# advances on every write (incl. unchanged re-reports #363), whereas
# last_updated only advances on a value change, so an unchanged re-report
# would otherwise be stamped with a stale time.
report_ts = getattr(new_state, "last_reported", None) or new_state.last_updated
self.diag_buffer.record_power(power, report_ts)
# 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 1–5 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
):
_prev = _finite_power(old_state.state)
if _prev is not None:
prev_raw_power = _prev
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 - only while a cycle is
# active (#394). An appliance is idle ~98% of the time; running the 5-min
# auto-tune pass and training the sample-interval cadence model on the
# standby heartbeat is constant background work (a store rewrite every few
# minutes) that buys nothing AND skews every operational suggestion, since
# the idle publish-on-change heartbeat is not the in-cycle sampling
# cadence those suggestions are sized from. The detector below still
# receives EVERY reading, so the next cycle's start is never missed - only
# the learning call is gated.
if self.detector.state in (
STATE_STARTING,
STATE_RUNNING,
STATE_PAUSED,
STATE_ENDING,
):
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
self._last_member_confidence = result.member_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)."""
# Anti-wrinkle keepalive (#339): the mode's idle-timeout and 2 h safety cap
# only advance inside CycleDetector.process_reading, and the watchdog is
# stopped for the whole anti-wrinkle tail. A publish-on-change plug can send
# one final 0 W reading and then go fully silent, so with no further events
# the mode is pinned in ANTI_WRINKLE for hours. This timer keeps ticking, so
# when the real sensor has been silent longer than off_delay we inject a
# synthetic 0 W reading, letting the detector's own logic exit the mode.
# 0 W and NOT the sensor's last value - unlike the two watchdog sites;
# the block comment at the injection site below has the reason. Gate
# on _last_real_reading_time (a genuine tumble pulse still resets the idle
# timer via the normal handler) and never bump it here, so real silence stays
# detectable and a still-reporting plug drives itself.
if self.detector.state == STATE_ANTI_WRINKLE:
last_real = self._last_real_reading_time
if (
last_real is not None
and (now - last_real).total_seconds() > self._off_delay
):
_ka_w, _ka_obs = self._keepalive_reading()
self._logger.debug(
"Anti-wrinkle keepalive: sensor silent for %.0fs (> off_delay %ss), "
"injecting 0 W (sensor last read %.2fW, observed=%s) so the "
"idle/2h-cap timer can advance",
(now - last_real).total_seconds(),
self._off_delay,
_ka_w,
_ka_obs,
)
# 0 W here, NOT the sensor's last value - deliberately different
# from the two watchdog sites. This keepalive's whole contract is
# "silence means idle": the detector only advances
# `_anti_wrinkle_idle_time` while `power < effective_exit`
# (`cycle_detector.py:1499`), so injecting a stale tumble pulse
# freezes the timer this call exists to advance - and can start a
# new-cycle burst candidate on top. The round-11 argument for
# carrying the real value does not reach here either: appends to
# `_power_readings` happen in STARTING / RUNNING / PAUSED /
# ENDING only, so nothing from this site enters the stored trace
# and there is no fabricated sample to worry about.
self.detector.process_reading(
0.0, now, synthetic=True, observed=_ka_obs
)
self._notify_update()
return
if (
not self._cycle_completed_time
or self.detector.state == STATE_RUNNING
or self.detector.state == STATE_DELAY_WAIT
):
# Cycle is running or not completed, don't reset
return
# Keep the cached power honest in the terminal states too (#409). The
# watchdog is stopped here, so nothing else refreshes it: the panel/entity
# would keep reporting the last value seen before the cycle ended, and
# power-based Off detection (#284) - which compares that same cache against
# power_off_threshold_w - could never see the appliance being switched off.
# Display/decision cache only; the detector is not fed in a terminal state.
self._resync_power_from_state(now, feed_detector=False)
time_since_complete = (now - self._cycle_completed_time).total_seconds()
# Clean laundry nag notification. In repeat mode (#374) the reminder re-fires
# every delay-minutes and carries a "stop reminding" action; otherwise it is a
# single one-shot (the default, unchanged).
if (
self._is_clean_state
and self._clean_state_start is not None
and self._notify_unload_delay_minutes > 0
and not self._unload_nag_dismissed
):
delay_s = self._notify_unload_delay_minutes * 60
first_due = (
not self._notified_clean_laundry
and (now - self._clean_state_start).total_seconds() >= delay_s
)
repeat_due = (
self._notify_unload_repeat
and self._notified_clean_laundry
and self._unload_nag_count < NOTIFY_UNLOAD_REPEAT_MAX_REMINDERS
and self._last_unload_nag_time is not None
and (now - self._last_unload_nag_time).total_seconds() >= delay_s
)
if first_due or repeat_due:
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,
)
extra_vars: dict[str, Any] = {"tag": self._clean_tag}
if self._notify_unload_repeat:
# Actionable "stop reminding" button + sticky so the user can
# end the repeats from the notification (mobile_app targets;
# ignored by other platforms). Door-open still ends it too.
extra_vars["actions"] = [
{
"action": self._unload_dismiss_action_id,
"title": self._timer_ui_strings.get(
"unload_dismiss_action_title", "Stop reminding"
),
}
]
extra_vars["sticky"] = "true"
sent = self._dispatch_notification(
msg,
event_type=NOTIFY_EVENT_CLEAN,
extra_vars=extra_vars,
)
if sent:
self._notified_clean_laundry = True
self._last_unload_nag_time = now
self._unload_nag_count += 1
if self._notify_unload_repeat:
self._ensure_unload_dismiss_listener()
self._logger.info(
"Sent clean laundry nag notification (%.0f min after "
"cycle end)%s",
time_since_complete / 60,
" [repeat]" if repeat_due else "",
)
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
self._last_unload_nag_time = now
self._unload_nag_count += 1
if self._notify_unload_repeat:
self._ensure_unload_dismiss_listener()
else:
self._notified_clean_laundry = True
self._last_unload_nag_time = now
self._unload_nag_count += 1
# 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. In repeat mode the hold persists across every repeat
# until the user dismisses it or opens the door. Both expiry modes below honour
# it (as does the power-off one-shot timer).
nag_pending = self._unload_nag_active(now)
# 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._reset_unload_nag_tracking()
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._unload_nag_active(dt_util.now()):
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()
def _keepalive_reading(self) -> tuple[float, bool]:
"""The ``(power, observed)`` pair a watchdog keepalive should carry.
Two things the watchdog used to get wrong, in one place because they
come from the same read:
* **The value.** A synthetic reading is appended to ``_power_readings``
like any other (there is no guard at the append sites), so it lands in
the stored ``power_data``. Injecting a hard ``0.0`` therefore writes a
sample the appliance never produced. On a machine that idles ABOVE its
stop threshold - the #445 pathology - that fabricates a quiet tail and
silently defeats ``detect_standby_above_stop``, which reads exactly
that final sample. The sensor's own last reported value is the honest
one, and for a machine that really is at 0 W it IS 0.0, so this only
differs where the old value was a lie.
* **Whether it was observed.** ``_resync_power_from_state`` returns
early when the sensor is unavailable / unknown / non-finite, but the
watchdog injects on the silence interval alone. An unread sensor is an
outage, and the gap-free tally must not count quiet nobody saw.
"""
live = self._live_power_state()
return (live[0], True) if live is not None else (0.0, False)
def _live_power_state(self) -> tuple[float, datetime] | None:
"""Return ``(power, report_ts)`` from the power sensor's CURRENT state.
``hass.states`` is the authoritative record of what the plug last said, and
unlike the manager's own cache it cannot go stale: it is written on every
report, including the ones this manager deliberately drops (the
sampling-interval throttle) or never receives (an event lost across a
reload). ``last_reported`` advances on every write - unchanged re-reports
included - so it is the honest "when did the sensor last speak" clock.
Returns None when the sensor is missing, unavailable or non-numeric.
"""
state = self.hass.states.get(self.power_sensor_entity_id)
if state is None or state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE):
return None
power = _finite_power(state.state)
if power is None:
return None
report_ts = getattr(state, "last_reported", None) or state.last_updated
if not isinstance(report_ts, datetime):
return None
return power, report_ts
def _resync_power_from_state(self, now: datetime, feed_detector: bool) -> None:
"""Re-anchor the cached power on the sensor's live state (#409).
``_current_power`` is otherwise a pure event cache: one dropped or missed
state event and it stays wrong indefinitely, because nothing ever compares
it against the sensor again. That single stale number then drives the
watchdog's high-power branch, the low-power keepalive gate and the #284
power-off detection, so the divergence does not merely show a wrong value
in the panel - it decides whether a cycle ends at all.
Called from the two periodic timers (the in-cycle watchdog and the terminal
state-expiry poll), so the cache can never be more than one tick out of
step with the sensor. When ``feed_detector`` is set and the sensor has
reported since our last real reading, that report is also handed to the
detector: it is a genuine observation we simply never processed. Timestamped
with ``now`` rather than the report time so the detector's dt can never run
backwards (a negative dt is discarded, taking the reading with it).
"""
live = self._live_power_state()
if live is None:
return
power, report_ts = live
missed = (
self._last_real_reading_time is None
or report_ts > self._last_real_reading_time
)
if feed_detector and missed:
self._logger.debug(
"Resync: sensor reported %.2fW at %s but the last processed reading "
"was %s (cached %.2fW); processing it now",
power,
report_ts,
self._last_real_reading_time,
self._current_power,
)
self.detector.process_reading(power, now)
self._last_reading_time = now
self._last_real_reading_time = report_ts
self._current_power = power
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
# Re-anchor the cached power on the sensor before any branch below reads it
# (#409): every decision from here on (keepalive vs force-end, low- vs
# high-power handling) is made against _current_power, and an event cache
# that has drifted from the sensor turns those decisions into fiction.
self._resync_power_from_state(now, feed_detector=True)
if self.detector.state not in (
STATE_RUNNING, STATE_STARTING, STATE_PAUSED, STATE_ENDING
):
# A missed reading can legitimately end the cycle (that is the point of
# picking it up). Every other injection path in this method returns
# immediately for the same reason: none of the branches below are
# meaningful once the cycle is over.
self._notify_update()
return
# Refresh the remaining-time / progress estimate on the watchdog cadence
# (sampling-derived: max(30, 2*sampling+1)s), independently of incoming power
# events. A publish-on-change plug emits nothing during a flat low-power tail
# (e.g. a dishwasher's ~30 min drying phase at 0 W), so the event-driven
# _update_remaining_only never runs and the displayed countdown freezes at
# whatever value it last showed. The estimate is wall-clock based
# (net_elapsed_seconds), so this tick advances it correctly with zero new
# readings; it no-ops until a profile is matched. Kept ahead of the
# keepalive/force-end branches below so even a verified-pause drying tail
# (which skips those branches) still ticks down.
if self.detector.state in (STATE_RUNNING, STATE_PAUSED, STATE_ENDING):
self._update_remaining_only()
self._notify_update()
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, or a hand-created profile has no learned avg_duration yet).
# This is a failsafe against a stuck FALSE START, not a cap on real programmes.
# The detector already hard-caps any cycle at 8h (cycle_detector 28800s), so
# this only ever fires *earlier*, and only when the cycle looks like it is not
# really running any more. Its old form force-ended on elapsed time alone, which
# truncated genuine long runs (issue #404: a >4h washer-dryer wash+dry drawing
# hundreds of watts was cut off at exactly 4h; the empty profile it was learning
# against was then stored force_stopped/truncated and could never learn its true
# duration, so every subsequent run repeated the deadlock). Three gates keep it
# honest without losing the false-start failsafe:
# - a device-scaled ceiling (wet/long appliances get a longer fuse), never
# above the detector's 8h absolute cap;
# - only when the appliance is effectively idle (current draw below the
# running threshold) - a cycle still pulling real power is not a stuck false
# start (reporter's evidence: the plug never dropped below 64 W);
# - skipped when an authoritative external end trigger is wired and reporting,
# since that signal will end the cycle cleanly.
# 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 guard.
elif (
expected == 0
and not self._is_user_paused
and not _verified_pause_zombie
and elapsed > self._unmatched_watchdog_ceiling
and self._current_power < self.detector.config.start_threshold_w
and not self._external_end_trigger_available()
and self.detector.state in (
STATE_STARTING, STATE_RUNNING, STATE_PAUSED, STATE_ENDING
)
):
self._logger.warning(
"Watchdog: Unmatched idle cycle exceeded %.0fs (elapsed %.0fs, "
"power %.1fW). Force-ending.",
self._unmatched_watchdog_ceiling,
elapsed,
self._current_power,
)
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) - on the WATCHDOG cadence (#427).
#
# This used to be two gates: real-update silence past
# `no_update_active_timeout`, else any-update silence past
# `off_delay`. Both are *stall-detection* timeouts, sized at roughly
# `p95_cadence * 20`; neither has anything to do with how fast the
# end accumulator should be advanced. A publish-on-change plug going
# quiet at standby is not a stall - it is the exact condition this
# keepalive exists for - and it was precisely then that nothing was
# injected for minutes at a time.
#
# Measured on the #427 reporter's v0.5.6 cycle (AEG L8FE74485,
# no_update_active_timeout 387 s, watchdog_interval 30 s): the
# accumulator froze twice, 383 s before PAUSED and ~390 s inside
# ENDING - about 13 of the reported ~20 minutes was nothing but "no
# reading arrived, so no gate was evaluated".
#
# This CANNOT end a cycle early. `_time_below_threshold` accumulates
# wall-clock `dt` between readings, so the total after N seconds of
# quiet is the same whether that arrived as one reading or twenty;
# injecting more often changes only how promptly a crossing is
# noticed, never the value compared against `effective_off_delay`.
# With the fix the reporter's cycle finishes 8.2 min after the last
# active reading, which is exactly their configured
# `max(off_delay 480, min_off_gap 480)`.
#
# A verified pause is no longer excluded, and that is not a loosening:
# the old 3b gate injected during verified pauses anyway (just on the
# slower off_delay cadence), and every guard that a verified pause is
# meant to hold off - the ENDING hard finalize, the terminal-drop
# finalize, the zombie killer - reads `_verified_pause` directly and
# is untouched by how often we sample.
if time_since_real_update > self._watchdog_interval:
_ka_w, _ka_obs = self._keepalive_reading()
self._logger.debug(
"Watchdog: Low-power sensor silence (%.0fs > watchdog interval "
"%ss). Injecting %.2fW keepalive (observed=%s) to advance "
"accumulator.",
time_since_real_update,
self._watchdog_interval,
_ka_w,
_ka_obs,
)
# Do NOT update _last_real_reading_time here, and tell the
# detector this reading is ours: it must still advance the
# quiet timers (that is the whole point of injecting it) but
# must not count as the sensor having reported (items 238, 289).
self.detector.process_reading(
_ka_w, now, synthetic=True, observed=_ka_obs
)
self._last_reading_time = now
self._current_power = _ka_w
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
_ka_w, _ka_obs = self._keepalive_reading()
self._logger.debug(
"Watchdog: Silence at low power (%.0fs). Injecting %.2fW "
"(observed=%s).",
time_since_any_update, _ka_w, _ka_obs,
)
self.detector.process_reading(
_ka_w, now, synthetic=True, observed=_ka_obs
)
self._last_reading_time = now
self._current_power = _ka_w
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:
# Silence at high power buys the cycle more time, but it must NOT
# be turned into data (#409). This branch used to re-feed the
# cached power to the detector on every tick, which
# - wrote a reading the sensor never reported into the cycle
# trace, producing a dead-flat non-zero tail that inflates
# the stored duration and energy and skews matching;
# - reset _time_below_threshold and _last_active_time, so the
# detector's own end-of-cycle timers could never accumulate
# and the fabricated tail was self-sustaining until this
# limit expired hours later (force_stopped);
# - fed that inflated duration back into the profile once the
# cycle was labelled, growing avg_duration and therefore
# `limit`, so each subsequent stuck cycle ran longer still.
# The keepalive was never what kept the cycle alive - the
# detector only ends a cycle on quiet time it has actually
# observed - so deferring the force-end is enough. The trace
# keeps an honest hole (integrate_wh drops outage-sized
# segments), _update_remaining_only above keeps the ETA ticking
# on wall-clock, and the resync at the top of this method picks
# the real value up the moment the plug speaks again.
self._logger.info(
"Watchdog: High power (%.1fW) stale (%.0fs, sensor silent "
"%.0fs). Deferring end (elapsed %.0fs < limit %.0fs).",
self._current_power,
time_since_any_update,
time_since_real_update,
elapsed,
limit,
)
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._reset_unload_nag_tracking()
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
# Confidence belongs to the match that produced it, so it cannot
# carry into the next cycle. It was only ever assigned by a real
# match update, never cleared, and the cycle-end tail stamps it onto
# cycle_data["match_confidence"] - so a cycle that never matched
# (most obviously one running a hand-pinned program, where
# _update_estimates returns early and the matcher never runs)
# persisted the PREVIOUS cycle's confidence as its own. That number
# then feeds _compute_cycle_quality_score and the learning feedback,
# i.e. fabricated match provenance on a cycle that has none - the
# #400 class of bug. Zero means "no opinion" and is not stored.
self._last_match_confidence = 0.0
self._last_member_confidence = None
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()
# Open the dynamic price timeline (#426) with the price in force
# right now. No-op when no price entity is configured.
self._start_price_timeline()
# 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._reset_unload_nag_tracking()
# A program the user armed while idle - or pinned during STARTING,
# which the reset above would otherwise have wiped - takes effect
# here (#411). Placed after the reset for two reasons: the duration
# it sets must not be nulled a line later, and it applies the pin,
# which refreshes the estimate - so it has to run once the pause
# totals belong to THIS cycle. Reading them a few lines earlier
# computed the new cycle's first ETA from the previous cycle's
# paused seconds, which the back-to-back case makes reachable: when
# the cycle-end tail returns early on the new-cycle token guard, it
# never clears them either, and the live progress notification is
# interval-throttled, so that wrong ETA sits on the phone until the
# next allowed tick. Still before the start event, so the event
# carries the real program name rather than "detecting...".
self._consume_armed_program()
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()
# Auto-open dishwasher: arm the dwell if ENDING while the door is already
# open (door event missed, or door popped just as ENDING fired) (#342).
self._maybe_arm_door_end_dwell_if_open()
# 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()
self._cancel_door_end_dwell() # Discard stale auto-open dwell (#342)
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
# Freeze the price timeline alongside the token, for the same reason: the
# back-to-back start that changes the token also calls _start_price_timeline,
# which replaces this cycle's timeline with the NEW cycle's opening sample
# before the task reaches the costing step (#426).
end_price_timeline = list(self._price_timeline)
if not self._is_shutdown:
self._cycle_end_task = self._spawn_tracked(
self._async_process_cycle_end(
cycle_data,
cycle_token=end_token,
price_timeline=end_price_timeline,
)
)
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 _price_entity_reject_reason(self, entity_id: str) -> str | None:
"""Why ``entity_id`` cannot be a price per kWh, or None (#439).
Only *positive* evidence rejects: an entity that has not loaded yet carries
no attributes, and refusing it would silence a perfectly good tariff sensor
that HA sets up after us.
"""
options = self.config_entry.options
if entity_id == self.power_sensor_entity_id:
return "it is this device's power sensor"
if entity_id == options.get(CONF_ENERGY_SENSOR):
return "it is this device's energy meter"
state = self.hass.states.get(entity_id)
if state is None:
return None
device_class = str(state.attributes.get("device_class") or "").strip().lower()
if device_class in _NON_PRICE_DEVICE_CLASSES:
return f"its device class is '{device_class}'"
unit = str(state.attributes.get("unit_of_measurement") or "").strip().lower()
if unit in _NON_PRICE_UNITS:
return f"its unit is '{unit}'"
return None
def _price_entity_id(self) -> str | None:
"""The configured price entity, or None when it is provably not a price.
Guards the single trap the cost feature has (#439): the panel picker lists
every sensor, a price entity outranks the static price, and pointing it at
the plug's own kWh counter silently charges every cycle
``energy * meter_reading`` instead of ``energy * tariff``. Rejecting it here
- rather than in the panel alone - also repairs entries that are already
misconfigured, which fall back to the static price.
"""
entity_id = self.config_entry.options.get(CONF_ENERGY_PRICE_ENTITY)
if not entity_id:
return None
reason = self._price_entity_reject_reason(entity_id)
if reason is None:
self._warned_price_entity = None
return entity_id
if self._warned_price_entity != entity_id:
self._warned_price_entity = entity_id
self._logger.warning(
"Energy price entity %s is not a price per kWh (%s); ignoring it and "
"using the static energy price instead. Set a tariff sensor there, or "
"clear the field to cost cycles at the static price",
entity_id,
reason,
)
return None
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 = self._price_entity_id()
if price_entity:
state = self.hass.states.get(price_entity)
if state is not None:
try:
value = float(state.state)
except (ValueError, TypeError):
pass
else:
# A non-finite reading is treated as no reading, exactly like an
# unparseable one: returning it would freeze an infinite cost
# onto the cycle (register item 211).
if math.isfinite(value):
return value
static = options.get(CONF_ENERGY_PRICE_STATIC)
if static is not None:
try:
value = float(static)
except (ValueError, TypeError):
pass
else:
if math.isfinite(value):
return value
return None
async def _async_price_history(
self, start: datetime, end: datetime
) -> list[tuple[float, float]]:
"""``(unix_ts, price)`` rows for the price entity over a window, via the
recorder (#426).
Used to recover price changes the live listener could not see - the period
HA was down mid-cycle, a cycle that predates the feature, a device whose
history was imported from raw recorder data. Returns ``[]`` on any failure
(recorder disabled, entity excluded from recording, data purged) so the
caller falls back to whatever it already had.
"""
entity_id = self._price_entity_id()
if not entity_id:
return []
try:
from homeassistant.components.recorder import ( # noqa: PLC0415
get_instance,
history,
)
except Exception: # noqa: BLE001 - recorder is an optional component
return []
def _query() -> list[tuple[float, float]]:
res = history.state_changes_during_period(
self.hass, start, end, entity_id, include_start_time_state=True
)
start_ts = start.timestamp()
rows: list[tuple[float, float]] = []
for state in res.get(entity_id, []) or []:
try:
price = float(state.state)
except (ValueError, TypeError):
# unknown/unavailable: the previous price stays in force.
continue
ts = state.last_changed.timestamp()
# include_start_time_state hands back the state in force at the
# window start, whose last_changed can predate it by hours. Clamp
# so it anchors the timeline instead of sorting before the cycle.
rows.append((max(ts, start_ts), price))
rows.sort(key=lambda item: item[0])
return rows
try:
return await get_instance(self.hass).async_add_executor_job(_query)
except Exception as exc: # noqa: BLE001 - cost must never break cycle end
self._logger.debug("Price history lookup failed for %s: %s", entity_id, exc)
return []
async def _async_apply_cycle_cost(
self,
cycle_data: dict[str, Any],
price_timeline: list[tuple[float, float]] | None = None,
) -> None:
"""Freeze ``cost`` / ``energy_price`` onto a finished cycle (#426).
Dynamic mode integrates the stored power trace against the price timeline
recorded while the cycle ran; ``energy_price`` then carries the *effective*
price per kWh the cycle paid (cost / kWh), which is the only figure that
stays meaningful once the tariff moved. ``energy_price_mode`` says which of
the two produced the number so the panel can label it honestly.
Falls back to the single current price whenever dynamic costing cannot
produce an answer - no price entity, the toggle off, no timeline, or a
trace with no energy in it. Never raises: a cost figure is display-only and
must not be able to lose a finished cycle.
"""
price = self._resolve_energy_price()
if self._dynamic_pricing_enabled():
try:
if await self._async_apply_dynamic_cost(
cycle_data, price_timeline=price_timeline
):
return
except Exception as exc: # noqa: BLE001 - never break cycle storage
self._logger.debug("Dynamic cost calculation failed: %s", exc)
if price is not None:
cycle_data["energy_price"] = price
cycle_data["energy_price_mode"] = "fixed"
cycle_data["cost"] = round(
self._cycle_report_energy_wh(cycle_data) / 1000.0 * price, 4
)
async def _async_apply_dynamic_cost(
self,
cycle_data: dict[str, Any],
price_timeline: list[tuple[float, float]] | None = None,
) -> bool:
"""Cost the cycle against its price timeline. True when it succeeded.
``price_timeline`` is the finished cycle's own history, frozen at cycle end.
Falling back to the live ``_price_timeline`` is only correct while no new
cycle has started since.
"""
start_dt = dt_util.parse_datetime(str(cycle_data.get("start_time") or ""))
end_dt = dt_util.parse_datetime(str(cycle_data.get("end_time") or ""))
if start_dt is None or end_dt is None or end_dt <= start_dt:
return False
timeline = list(
self._price_timeline if price_timeline is None else price_timeline
)
# The live listener is complete whenever HA stayed up for the whole cycle.
# Consult the recorder only when it cannot have been: nothing recorded at
# all (the feature was switched on mid-cycle), or a restart gap where price
# changes would have gone unseen.
if not timeline or cycle_data.get("restart_gaps"):
recorded = await self._async_price_history(start_dt, end_dt)
if recorded:
# The recorder saw the downtime too, so it supersedes rather than
# merges - interleaving the two would double-count a change that
# both captured at slightly different timestamps.
timeline = recorded
if not timeline:
return False
start_ts = start_dt.timestamp()
duration = (end_dt - start_dt).total_seconds()
points = compact_price_timeline(
[(ts - start_ts, price) for ts, price in timeline],
max_points=PRICE_TIMELINE_MAX_POINTS,
decimals=PRICE_TIMELINE_PRICE_DECIMALS,
)
# A price recorded before the trace's first sample still sets the opening
# price; clamp rather than drop it, and discard anything past the end.
points = [(max(0.0, offset), price) for offset, price in points if offset <= duration]
points = compact_price_timeline(
points, max_points=PRICE_TIMELINE_MAX_POINTS,
decimals=PRICE_TIMELINE_PRICE_DECIMALS,
)
if not points:
return False
result = self._cost_from_timeline(cycle_data, points)
if result is None:
return False
cost, effective_price = result
cycle_data["cost"] = round(cost, 4)
cycle_data["energy_price"] = round(effective_price, 6)
cycle_data["energy_price_mode"] = "dynamic"
cycle_data["price_timeline"] = [
[round(offset, 1), price] for offset, price in points
]
return True
def _cost_from_timeline(
self, cycle_data: dict[str, Any], points: list[tuple[float, float]]
) -> tuple[float, float] | None:
"""``(cost, effective_price)`` for a cycle and its price timeline, or None.
Pure apart from reading the cycle; the meter-vs-integrated decision is the
same ``_cycle_report_energy_wh`` every other user-facing energy figure uses,
so the cost and the kWh shown beside it are computed from one number.
"""
# decompress_power_data, not the raw list: a cycle stored before the
# offset migration still carries ISO timestamps, and recosting must work
# on exactly the cycles that are old enough to need it.
points_xy = decompress_power_data(cast(Any, cycle_data))
if len(points_xy) < 2:
return None
timestamps = np.asarray([t for t, _ in points_xy], dtype=float)
power = np.asarray([p for _, p in points_xy], dtype=float)
return cycle_cost(
timestamps,
power,
points,
max_gap_s=energy_gap_threshold_s(timestamps),
report_wh=self._cycle_report_energy_wh(cycle_data),
)
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
# Persist the chosen program name too (#404 secondary bug): the detector
# snapshot's matched_profile is wiped on the first post-restart match tick
# because _matched_profile_duration is not restored, so the name must be
# carried explicitly to re-pin the override.
snapshot["manual_program_name"] = (
self._current_program if self._manual_program_active else None
)
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
# Dynamic price timeline (#426): absolute (unix_ts, price) pairs, as lists
# so the JSON round-trip is lossless.
snapshot["price_timeline"] = [
[ts, price] for ts, price in self._price_timeline
]
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,
price_timeline: list[tuple[float, float]] | 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).
``price_timeline`` is that same cycle's tariff history, frozen at the same
moment and for the same reason; None means "read the live one".
"""
# 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
member_confidence = self._last_member_confidence
matched_profile_duration = self._matched_profile_duration
# The number EVERY label / persistence decision for this cycle gates on.
#
# A Stage-5 group win reports the group's score, i.e. the best-scoring
# SIBLING's, while the member the cycle would be labelled as is chosen
# separately by integrated energy - so the two can be different profiles
# (item 206; measured 16.7% of whole-cycle group wins, gap up to 0.157).
# A label makes the cycle evidence for that ONE member, so it has to clear
# the bar on its own blended score, not on its sibling's. Both are blended
# pipeline scores over the same trace, so they compare directly.
#
# Computed once here because three gates read it and they must agree: the
# cycle-end gate below, the post-cycle auto-label pass (via
# MatchResult.label_confidence on its own fresh match), and the learning
# handoff - whose `_maybe_request_feedback` can auto-label without ever
# asking the user, so a member that fell short of `auto_label_confidence`
# on its own score has to be queued for confirmation instead.
#
# `member_confidence` is None for every non-group match, which leaves this
# exactly equal to the `match_confidence or 0.0` passed down before.
label_confidence = float(match_confidence or 0.0)
if member_confidence is not None:
label_confidence = min(label_confidence, float(member_confidence))
manual_program = self._manual_program_active
cycle_anomaly = self._cycle_anomaly
overrun_ratio = self._overrun_ratio
# Record which program this cycle ran - but only when we actually know it.
#
# A label is not a display value. It makes the cycle *evidence* for that
# profile (the async_rebuild_envelope right after the persist below), which
# moves the profile's avg_duration / target_duration - and those are what arm
# Smart Termination and the anti-crease finalize. So a weak guess recorded as
# fact seeds the next mis-detection: #400 measured a program's target_duration
# snapping to the duration of a fragment mis-assigned to it.
#
# The bar is the learning threshold, i.e. the ladder the panel already enforces
# (unmatch < match < learning < auto-label): a match that is not confident
# enough for WashData to ASK about is not confident enough to record as fact.
# Below it the cycle stays unlabelled and the auto-label pass below gets its
# turn on the COMPLETE trace at its own (higher) threshold, which is the better
# judge - previously any live commit, or a final match scraping its 0.15 floor,
# pre-empted that path entirely.
#
# A hand-picked program bypasses the gate: it is the user's own statement, not a
# guess. It is stamped "manual" rather than "auto_match" for the same reason -
# "auto_match" means "the matcher guessed this", which is what lets bulk
# auto-labelling overwrite a label later.
if (
program
and program not in ("off", "detecting...", "restored...")
and program in self.profile_store.get_profiles()
):
# `label_confidence` (computed above) is the member-aware number, not
# the group's. See its definition for why a label may not rely on a
# sibling's score.
if label_confidence > 0:
# Recorded whether or not we label, so the panel can show what
# WashData suspected without the cycle claiming it as its program.
cycle_data["match_confidence"] = label_confidence
# How far clear of the runner-up the winner finished. The absolute
# score is a weak guide to being right (AUC 0.625) where this margin
# is a strong one (0.792), and labelling is the asymmetric decision:
# a wrong label reshapes avg_duration and every future estimate,
# while a missed one only asks the user. Register item 310.
#
# A separate constant from MATCH_AMBIGUITY_MARGIN on purpose - that
# one also gates Smart Termination, so widening it would defer
# cycle ends and undo item 306.
#
# The margin describes the RESULT's own winner, and that is not
# always `program`. `match_result` is `_last_match_result`, which the
# live matcher overwrites on every run whether or not a switch
# commits, while `program` (`_current_program`) only moves through
# the persistence / decisive-margin / consistency paths. Inside a
# persistence window the newest result can have challenger X winning
# while P is still displayed - and then `ambiguity_margin` says how
# far X leads ITS runner-up, which may be P itself. Reading it as
# evidence for P inverts the gate: the more decisively X won, the
# more readily P got labelled. Require the margin's owner to be the
# programme being labelled. The post-cycle gate below has no such
# problem, because there `res.best_profile` is what it labels.
_margin = getattr(match_result, "ambiguity_margin", None)
_margin_owner = (
getattr(match_result, "best_profile", None)
if match_result is not None
else program
)
# A result with NO winner is not a challenger. `best_profile is None`
# comes with `confidence` and `ambiguity_margin` both 0.0, so it
# carries no evidence about any programme - yet the owner check below
# treats it as "someone else won" and reports `%r` as None. The
# outcome is right either way (no label), the stated reason is not.
# Drop the margin too, so nothing downstream can read a
# no-winner 0.0 as a measured one; a real challenger keeps its margin.
_no_winner = match_result is not None and _margin_owner is None
if _no_winner:
_margin = None
_margin_ok = (
not _no_winner
and _margin_owner == program
and (_margin is None or float(_margin) >= MATCH_LABEL_MIN_MARGIN)
)
if manual_program:
cycle_data["profile_name"] = program
cycle_data["label_source"] = "manual"
elif label_confidence >= float(self._learning_confidence or 0.0) and _margin_ok:
cycle_data["profile_name"] = program
cycle_data["label_source"] = "auto_match"
elif (
label_confidence >= float(self._learning_confidence or 0.0)
and _margin_owner != program
):
# Distinct branch: `_margin_ok` is False for two different
# reasons and the message below only describes one of them. When
# the owner differs, `_margin` is the CHALLENGER's lead and can
# be large, so that message reads "only 0.400 clear of the next
# candidate, under the 0.08 a label needs" - self-contradictory,
# and it hides the actual reason.
if _no_winner:
self._logger.info(
"Not labeling cycle as '%s': the latest match produced no "
"winner at all, so there is no margin to judge it by. It "
"stays unlabelled rather than reshaping '%s' on the "
"confidence of an earlier tick.",
program, program,
)
else:
self._logger.info(
"Not labeling cycle as '%s': the latest match was won by "
"%r, so its margin is evidence about that program, not "
"this one. It stays unlabelled rather than reshaping '%s' "
"on a number that was never measured for it.",
program, _margin_owner, program,
)
elif label_confidence >= float(self._learning_confidence or 0.0):
self._logger.info(
"Not labeling cycle as '%s': confident enough (%.2f) but only "
"%.3f clear of the next candidate, under the %.2f a label needs. "
"It stays unlabelled rather than reshaping that program's "
"statistics on a coin flip.",
program, label_confidence, float(_margin or 0.0),
MATCH_LABEL_MIN_MARGIN,
)
else:
self._logger.info(
"Not labeling cycle as '%s': match confidence %.2f is below the "
"learning threshold %.2f, so it stays unlabelled rather than "
"reshaping that program's statistics.%s",
program,
label_confidence,
float(self._learning_confidence or 0.0),
""
if member_confidence is None
or float(member_confidence) >= float(match_confidence or 0.0)
else (
f" (its profile group scored {float(match_confidence or 0.0):.2f}, "
f"but that was a different member of the group)"
),
)
# 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"]
)
# label_confidence == confidence for everything except a Stage-5 group
# win whose selected member scored below the sibling that set the group's
# score (item 206). This is the highest-stakes gate of the three: it
# labels without ever asking the user.
# The SAME margin gate the live label gate applies (item 310). The
# comment above calls this pass "the better judge" because it sees the
# complete trace at a higher confidence threshold - but confidence is
# the weak axis (AUC 0.625 against the margin's 0.792), and this path
# runs precisely when the live gate declined, margin refusals included.
# Without it a cycle refused a label for finishing too close to the
# runner-up was relabelled here a few lines later, on the same data.
_post_margin = getattr(res, "ambiguity_margin", None)
_post_margin_ok = (
_post_margin is None or float(_post_margin) >= MATCH_LABEL_MIN_MARGIN
)
if (
res.best_profile
and res.label_confidence >= self._auto_label_confidence
and _post_margin_ok
):
cycle_data["profile_name"] = res.best_profile
cycle_data["label_source"] = "auto_label_post"
cycle_data["match_confidence"] = float(res.label_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.label_confidence,
)
elif res.best_profile and res.label_confidence >= self._auto_label_confidence:
self._logger.info(
"Not post-cycle labeling as '%s': confident enough (%.2f) but "
"only %.3f clear of the next candidate, under the %.2f a label "
"needs. It is offered for confirmation instead.",
res.best_profile,
res.label_confidence,
float(_post_margin or 0.0),
MATCH_LABEL_MIN_MARGIN,
)
# 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:
# Offloaded: both reach `analysis.align_trace_to_envelope`,
# which re-derives the envelope's DTW warp (item 324). The
# cost matrix is vectorised and bounded, but a bounded NumPy
# DTW is still CPU work and this runs on the event loop at
# every cycle end. The WS twin `expected_curve_for_cycle`
# already goes through the executor; these two did not.
# One job, not two: they share `_pts` and must describe the
# same alignment of the same cycle.
def _conformance_and_artifacts() -> tuple[Any, Any]:
return (
self.profile_store.compute_envelope_conformance(_ep, _pts),
self.profile_store.detect_cycle_artifacts(_ep, _pts),
)
conformance_rec, artifacts = await self.hass.async_add_executor_job(
_conformance_and_artifacts
)
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.
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. With a dynamic tariff this is the
# power trace integrated against the price in force at each moment (#426);
# otherwise the single price in effect NOW. Either way it is frozen here, so
# later price changes never rewrite historical costs.
await self._async_apply_cycle_cost(cycle_data, price_timeline=price_timeline)
# 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",
# A chatty dynamic tariff can add hundreds of entries (#426); the
# cost and effective price it produced ride along instead.
"price_timeline",
}
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. No service-level clear
# here: the activity is ended below, AFTER the finished notification has
# been delivered, so the lock screen is never momentarily empty. The
# action-based clear marker still fires for action templates.
# _clear_live_progress_notification resets _live_activity_started, so the
# flag has to be read before it runs (#446).
#
# Gated on the cycle token, the same test the terminal-state reset below
# uses. Everything here runs AFTER the persistence / envelope / cost /
# lifetime-energy awaits, and a new cycle can start during them: its
# `_on_state_change` calls `_reset_live_notification_state()` and its
# first live tick sets `_live_activity_started` again. Ungated, this tail
# then reads the NEW cycle's flag, purges the NEW cycle's live counters
# and pending start entries, and - because `_live_notification_tag` is
# per DEVICE, not per cycle - ends the activity the new cycle is running.
# The user watches it vanish and its start card get cleared a second
# time, and the next tick restarts it. If a newer cycle owns the tag,
# leave the activity alone: it continues on the same tag.
_same_cycle = (
cycle_token is None or self._ranking_snapshot_cycle_id == cycle_token
)
live_activity_running = _same_cycle and self._live_activity_started
if _same_cycle:
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: retained, but it is NOT what ends the activity - there is
# no `activity` key in the companion notification API and this
# was never acted on (#446). Kept because it is inert and this
# code cannot be exercised against a real device here; the
# documented clear below is the mechanism that works.
"activity": "end",
},
)
# #446: end the iOS Live Activity now that the finished alert has gone out.
# Only when one was actually started, so a device that never ran an activity
# gets no stray service call.
if live_activity_running:
self._end_live_activity()
# 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=label_confidence,
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
# A pin is for the cycle it was made for, so it does not carry over (#411).
self.clear_armed_program()
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
self._reset_unload_nag_tracking()
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"
)
elif self._unload_confirmable_without_door():
# No door sensor, but the user opted into confirming the unload some
# other way (#451): a button entity, or the Mark Unloaded button /
# service driven by their own automation.
self._is_clean_state = True
self._clean_state_start = dt_util.now()
self._logger.debug(
"Cycle ended, unload confirmation configured: 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 _log_notification(
self,
event_type: str | None,
message: str,
*,
targets: str = "",
deferred_reason: str | None = None,
delivered: bool = True,
) -> None:
"""Emit log lines for a notification's send / defer / drop.
Every user-facing notification funnels through ``_dispatch_notification``,
so this is the single place that records what WashData notified about,
where it went, and whether it was delivered, deferred (quiet-hours /
presence hold), or dropped.
Log contract (regression-locked by ``test_manager_notification_logging``):
- **INFO** ``"Notification sent (<event>)"`` — one line per discrete
notification (start/finish/milestone/clean/pause/…). Target list and
message body are omitted to prevent entity-ID PII from leaking into
bug-report logs.
- **DEBUG** ``"Notification sent (<event>) via <targets>: <summary>"`` —
full target list and truncated message body for troubleshooting.
- **DEBUG** for live-progress ticks (high-frequency in-place updates).
- **DEBUG** for deferred (quiet-hours/presence hold) and not-delivered paths.
Deferred items are re-dispatched when the hold clears and log again as
"sent" on actual delivery.
"""
label = event_type or "notification"
# Countdown / finish messages can span multiple lines - collapse to one.
summary = " ".join(str(message).split())
if len(summary) > 200:
summary = summary[:197] + "..."
is_live = event_type == NOTIFY_EVENT_LIVE
if deferred_reason:
self._logger.debug(
"Notification deferred (%s) - %s: %s", label, deferred_reason, summary
)
elif not delivered:
self._logger.debug(
"Notification not delivered (%s) - no matching target: %s",
label, summary,
)
elif is_live:
self._logger.debug(
"Notification sent (%s) via %s: %s", label, targets, summary
)
else:
self._logger.info("Notification sent (%s)", label)
self._logger.debug(
"Notification sent (%s) via %s: %s", label, targets, summary
)
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}
tap_target = self._notification_tap_target()
if tap_target and message != _CLEAR_NOTIFICATION_MARKER:
# A dismiss marker is a command, not a card - it has nothing to tap.
variables["clickAction"] = tap_target
variables["url"] = tap_target
extra_vars = {
**(extra_vars or {}),
"clickAction": tap_target,
"url": tap_target,
}
# 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
self._log_notification(event_type, message, deferred_reason="quiet hours")
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
self._log_notification(
event_type, message, deferred_reason="nobody home"
)
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:
self._log_notification(event_type, message, targets="actions")
return True
service_sent = self._send_notification_service(
message,
services=services,
title=title,
icon=icon,
event_type=event_type,
extra_vars=extra_vars,
)
if actions_sent or service_sent:
targets: list[str] = []
if actions_sent:
targets.append("actions")
if service_sent:
# _send_notification_service falls back to a persistent
# notification only when no notify services are configured.
targets.extend(services if services else ["persistent_notification"])
self._log_notification(
event_type, message, targets=", ".join(targets)
)
else:
self._log_notification(event_type, message, delivered=False)
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
icon_color = self._notification_icon_color()
# 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))
# #435: the companion app reads the notification icon from
# `notification_icon`, NOT from `icon` - so the configured mdi icon was
# being sent under a key no companion platform looks at. `icon` stays in
# the base payload for the platforms that do use it (notify.html5 and
# friends); the mobile-only alias is added here. The same key now covers
# both platforms: Android draws it in the status bar, and iOS renders it
# as a communication-notification avatar in place of the app icon from
# companion app 2026.8.0 (home-assistant/iOS#4672). Older iOS builds
# ignore the key rather than failing, so there is nothing to gate on.
if icon and self._is_mobile_notify_service(notify_service):
svc_data["notification_icon"] = icon
# #454: with a washer, a dryer and a dishwasher live at once, every
# card on the Lock Screen looks the same. One configured colour maps
# to the three keys the companion apps actually read: `color` is the
# Android notification accent, `notification_icon_color` tints the iOS
# icon glyph, and `progress_bar_color` recolours the iOS Live Activity
# bar (it falls back to notification_icon_color, but is set explicitly
# so the two stay in step). Mobile-only, same as the icon above; unset
# leaves the payload byte-identical to before.
#
# iOS is where this reliably shows. On Android the companion app does
# `builder.color = parseColor(notification_icon_color ?: color)` and
# never calls setColorized, so Android 12+ applies it per-OEM: a Pixel
# tints the icon, Samsung One UI shows nothing. Nothing we can send
# changes that, which is why the setting's help text says so (item 372).
# Note the app reads the iOS-named key FIRST - harmless only because
# both carry one value here, so do not let them diverge.
if icon_color and self._is_mobile_notify_service(notify_service):
svc_data["color"] = icon_color
svc_data["notification_icon_color"] = icon_color
svc_data["progress_bar_color"] = icon_color
state = (
self.hass.states.get(notify_service)
if notify_service.startswith("notify.")
else None
)
# A notify *entity* (has a state, domain "notify") only registers the
# entity service notify.send_message — NOT a legacy notify.<object_id>
# domain service. So route entity targets through send_message
# regardless of svc_data; that schema accepts only entity_id/message/
# title, so drop any unsupported extras (icon, mobile/iOS keys) rather
# than fall through to a legacy service that would raise ServiceNotFound
# and silently drop the notification. Legacy notify.mobile_app_* targets
# have no entity state (state is None) and correctly take the else path.
if state is not None and getattr(state, "domain", None) == "notify":
# A dismiss marker is carried *by* its tag, and send_message cannot
# carry one - delivering it anyway would show the user a card whose
# body literally reads "clear_notification". There is no way to
# dismiss an entity-target card, so skip the send entirely. (Before
# entity targets were routed here, these calls always had a tag and
# so took the legacy-service path, where the tag works.)
if message == _CLEAR_NOTIFICATION_MARKER and ev.get("tag"):
self._logger.debug(
"Notify entity %s: skipping dismiss marker (tag=%s) - "
"notify.send_message cannot carry a tag, so it would be "
"delivered as visible text",
notify_service, ev.get("tag"),
)
continue
if svc_data:
self._logger.debug(
"Notify entity %s: dropping %d unsupported payload key(s) "
"(%s) - notify.send_message accepts only message/title",
notify_service, len(svc_data), ", ".join(sorted(svc_data)),
)
service_data: dict[str, Any] = {
"entity_id": notify_service,
"message": message,
}
if title:
service_data["title"] = title
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)
)
# `title` is OPTIONAL in notify's service schema but validated as a
# string, so passing it as None fails validation outright
# ("string value is None at 'title'") and the call never reaches the
# platform. _dispatch_notification always resolves a title, but the
# four dismiss-marker senders do not pass one - so every tag clear
# (live-activity end, lifecycle hand-over, clean reminder, timer
# pause) was rejected before delivery and nothing was ever
# dismissed on the phone (#446 follow-up).
service_data = {"message": message}
if title is not None:
service_data["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
# A dismiss marker is not content: if no target could carry it, there is
# nothing to show. Falling through would post a persistent-notification
# card whose body reads "clear_notification" - the same leak the entity
# branch above guards against, just via the other exit. The callers that
# send this marker dismiss their own persistent notification separately
# (_pn_dismiss), so nothing is lost by returning early.
if message == _CLEAR_NOTIFICATION_MARKER:
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:
# Validate through cv.SCRIPT_SCHEMA before handing the sequence to
# Script, because that is what turns a templated `data` value into
# a Template: `cv.template_complex` converts the strings that look
# like templates, and at run time `render_complex` renders only
# Template instances and passes plain strings through untouched.
# Built straight from the stored options - as this did - every
# `{{ device }}` in a user's action was delivered to their phone
# as the literal text `{{ device }}`, which makes the documented
# notification variables useless. Found by the test box's
# check_notify_actions.sh; register item 323.
self._notify_script = script_helper.Script(
self.hass,
cv.SCRIPT_SCHEMA(actions),
name=f"{self.config_entry.title} notification",
domain=DOMAIN,
logger=_LOGGER,
)
except vol.Invalid as err:
self._logger.error(
"Invalid notification action configuration for %s: %s",
self.config_entry.title,
err,
)
return False
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
self._flush_pending_notifications(
new_state.entity_id,
new_state.name
or new_state.attributes.get("friendly_name", new_state.entity_id),
)
def _flush_pending_notifications(
self, person_entity_id: str | None, person_name: str | None
) -> None:
"""Deliver every notification presence gating queued, and record it.
Two callers reach this: a person arriving home, and the listener finding
somebody already home when it (re-)attaches after a reload. They were
two copies of the same loop and drifted - only one of them recorded that
a Live Activity had started, so a queued live card delivered by the other
left `_live_activity_started` False, the cycle-end tail skipped
`_end_live_activity()`, and the card stayed frozen on the phone (#446).
One body now, so they cannot disagree again.
"""
pending: list[dict[str, Any]] = list(self._pending_notifications)
self._pending_notifications = []
# The #446 handover has to run BEFORE the queue is delivered, not after
# the live entry inside it. It `_send_tag_clear`s `_lifecycle_tag`, and on
# this path entries that RIDE that tag are delivered first: a deferred
# LIVE entry replaces any earlier live one and is appended last (see
# `_dispatch_notification`), so a `pre_complete` reminder queued earlier in
# the cycle sits ahead of it. Flushed in order, the reminder was delivered
# and then dismissed off the phone a moment later by the handover - and it
# is a `priority: high` "nearly done" card, i.e. the one worth having.
# The direct path cannot hit this: there the first live tick happens early,
# long before any reminder exists. Only presence deferral can put the two
# in this order.
handover_done = False
def _deliver(entry: dict[str, Any]) -> None:
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()
# The queued entry carries the same `activity: "start"` the direct
# paths send, so the phone has a live activity either way and the
# cycle-end teardown has to know about it (#446). Recorded only
# once the dispatch actually SENT, which is why the flag is not set
# next to the hoisted handover: a failed delivery would otherwise
# claim an activity that is not on the phone.
if handover_done:
self._live_activity_started = True
else:
self._record_live_activity_started()
if not self._live_activity_started and any(
entry.get("event_type") == NOTIFY_EVENT_LIVE for entry in pending
):
# START entries go out FIRST, *before* the clear, and are not dropped.
# "Superseded by the live activity" is true only of a mobile target
# that also receives the live card: `_send_tag_clear` returns early
# when `_notify_live_services` is empty and only ever addresses that
# list, while START has its own `_notify_start_services` and may be a
# telegram or e-mail target the clear can never reach - and a
# notification ACTION fires on delivery, so an automation branching on
# `event_type == "start"` needs the dispatch to happen at all.
# Delivering them ahead of the clear gets every case right at once: a
# mobile start card is delivered and then swept up by the handover
# exactly as it was before, and every other target simply keeps its
# notification.
# SPLIT the queue at the last START; do not pull STARTs forward.
# Round 32 hoisted them to the front, which silently reordered every
# lifecycle-tagged entry queued BEFORE one - and FINISH survives the
# `_clear_live_progress_notification` purge (which drops LIVE, START
# and `pre_complete`, not FINISH). So `[FINISH(A), START(B), LIVE(B)]`
# is reachable: nobody home, cycle A ends, cycle B starts. Hoisting
# sent START(B) first, cleared the tag, and then delivered FINISH(A)
# AFTER it, leaving "cycle A finished" pinned to the lifecycle tag for
# the whole of cycle B while B's own start card had already been
# cleared. Delivering in order gets it right for free: FINISH(A)
# lands, START(B) replaces it on the same tag, and the handover then
# clears the one card that is left.
_last_start = max(
(
i
for i, e in enumerate(pending)
if e.get("event_type") == NOTIFY_EVENT_START
),
default=-1,
)
_head = pending[: _last_start + 1]
for entry in [
e for e in _head if e.get("event_type") != NOTIFY_EVENT_LIVE
]:
_deliver(entry)
# A LIVE entry sitting before that START (possible across cycles, since
# the dedup only re-appends within one queue) still belongs after the
# clear, with the rest of the tail.
pending = [
e for e in _head if e.get("event_type") == NOTIFY_EVENT_LIVE
] + pending[_last_start + 1 :]
self._hand_over_lifecycle_to_live_activity()
handover_done = True
for entry in pending:
_deliver(entry)
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._envelope_position = None
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, *, keep_activity_started: bool = False
) -> None:
"""Reset per-cycle live notification counters and timers.
``keep_activity_started`` exists for the single caller that is NOT a cycle
boundary. Every other call sites here is one - cycle start, cycle end, and
the live-progress clear - so resetting the flag is exactly right for them.
`async_reload_config` is different: it re-arms live notifications MID-cycle
when the user saves any option, and clearing the flag there makes the next
live tick look like the first of a new cycle. Three things follow, all
wrong, and none of them visible from this function alone:
* `_record_live_activity_started` re-runs the #446 handover, which
`_send_tag_clear`s `_lifecycle_tag` - and the pre-completion reminder
rides that same tag at `priority: high`, so an already-delivered
reminder is dismissed off the user's phone.
* `_apply_live_notification_prefs` gates `silent` / `push` on this flag, so
the next live update alerts audibly even with `notify_live_silent` on:
#417 re-entering through the reload door.
* the handover is a once-per-cycle event by design, and a settings save
does not start a cycle.
Preserving is safe in both directions: the flag is only ever kept at the
value it already had, so a reload that ENABLES live notifications mid-cycle
still leaves it False and the handover still runs on the first real tick.
"""
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
if not keep_activity_started:
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")
def _notification_tap_target(self) -> str:
"""Where a tap on this device's notifications should land (#438).
Blank (the shipped default) resolves to this appliance's own panel deep link
``/ha-washdata?device=<entry_id>``, so a notification about the dryer opens
the dryer instead of whichever appliance the panel happened to show last.
The entry id is used rather than the title because it survives a rename, and
it needs no URL escaping. A user-supplied value wins, and the literal
``none`` turns the tap target off entirely.
Emitted as both ``clickAction`` (Android) and ``url`` (iOS), which are the
two companion-app keys for the same thing; both are mobile-only and are
filtered to ``mobile_app_*`` targets by ``_mobile_service_extras``.
"""
configured = (self._notify_live_click_action or "").strip()
if configured:
return "" if configured.lower() == "none" else configured
return f"/{PANEL_URL_PATH}?device={self.entry_id}"
def _notification_icon_color(self) -> str | None:
"""Resolve the per-device notification accent colour (#454).
Returns ``None`` when unset, which is the shipped default and leaves the
payload exactly as it was. A bare hex value is accepted without the leading
``#`` because that is how a user pastes one out of a colour picker; anything
else (a CSS colour name, which Android accepts and iOS ignores) is passed
through untouched rather than rejected - a colour the companion app does not
understand is ignored by the app, so there is nothing to fail on here.
"""
configured = str(
self.config_entry.options.get(CONF_NOTIFY_ICON_COLOR) or ""
).strip()
if not configured:
return None
if _HEX_COLOR_RE.fullmatch(configured):
return f"#{configured}"
return configured
@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 _apply_live_notification_prefs(self, extra_vars: dict[str, Any]) -> None:
"""Inject the user's live-notification data keys (#347, #417).
``sticky`` keeps the live notification on screen when tapped. It is a
mobile-only key forwarded only to ``mobile_app_*`` live targets, and its
default (off) adds nothing, so the payload is byte-identical to before
unless the user opts in. The tap target itself is no longer applied here:
it belongs to every event type, so ``_dispatch_notification`` injects
``_notification_tap_target()`` centrally instead (#438).
``silent`` (#417) marks a *refresh* of the running Live Activity as a
non-alerting, lower-priority push, which is what stops iOS playing a sound and
vibrating on every interval tick; ``push.interruption-level: passive`` is the
companion's generic quiet key and covers the case where the update is rendered
as an ordinary banner instead. Neither is applied to the update that STARTS the
activity: that one is the "cycle is now on your Lock Screen" cue and stays
audible (per the companion docs ``silent`` has no effect there in any case).
"""
if self._notify_live_sticky:
extra_vars["sticky"] = "true"
if self._notify_live_silent and self._live_activity_started:
extra_vars["silent"] = True
extra_vars["push"] = {"interruption-level": "passive"}
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
# #437: a match landing is not the same as an ETA existing.
# _update_remaining_only() is throttled to one estimate per 5 s, so the tick
# that accepts a match can find _matched_profile_duration set while
# _time_remaining is still None - and the progress branch below renders that
# `float(self._time_remaining or 0.0)` as remaining 0, i.e. elapsed == total
# (a full 100 % bar) and minutes_left == 1 ("less than 1 minute") at the very
# start of the cycle. Require a real estimate; the waiting latch keeps that
# tick silent rather than re-sending the waiting message.
has_profile_match = bool(
self._matched_profile_duration
and self._matched_profile_duration > 0
and self._time_remaining is not None
)
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"
self._apply_live_notification_prefs(waiting_extra_vars)
sent = self._dispatch_notification(
msg,
event_type=NOTIFY_EVENT_LIVE,
extra_vars=waiting_extra_vars,
)
self._live_waiting_notification_sent = sent
if sent:
self._record_live_activity_started()
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,
)
)
self._apply_live_notification_prefs(extra_vars)
sent = self._dispatch_notification(
msg,
event_type=NOTIFY_EVENT_LIVE,
extra_vars=extra_vars,
)
if sent:
self._record_live_activity_started()
if chronometer_overrun:
self._live_chronometer_overrun_sent = True
else:
self._live_notification_sent_count += 1
self._last_live_notification_time = now
def _send_tag_clear(self, tag: str) -> None:
"""Send the companion app's documented ``clear_notification`` for ``tag``.
This is the ONLY way to end an iOS Live Activity (HA companion docs, "Live
Activities and Live Updates"): an activity is started with
``live_update: true``, updated by re-sending the same tag, and ended by
this. There is no ``activity`` key in that API - the one this integration
sent on the finished notification was never acted on, which is #446.
"""
if not self._notify_live_services:
return
self._send_notification_service(
_CLEAR_NOTIFICATION_MARKER,
services=self._notify_live_services,
event_type=NOTIFY_EVENT_LIVE,
extra_vars={"tag": tag},
)
def _record_live_activity_started(self) -> None:
"""Mark that a live activity is running, handing over the lifecycle card.
Three paths deliver the first live notification of a cycle - the waiting
card, the progress card, and ``_handle_notify_person_change`` releasing
either of them after presence gating deferred it - and all three have to
record it identically. The deferred one did not, so a cycle whose only
live delivery came through presence left ``_live_activity_started`` False,
the cycle-end path skipped ``_end_live_activity``, and the activity stayed
frozen on the phone: #446's own bug, reached from the other side.
"""
if not self._live_activity_started:
self._hand_over_lifecycle_to_live_activity()
self._live_activity_started = True
def _hand_over_lifecycle_to_live_activity(self) -> None:
"""Drop the lifecycle-tagged card as the live activity takes over (#446).
Live updates used to share the lifecycle tag, so each one replaced the
start alert in place and the mobile app showed a single entry. With the
live activity on its own tag that replacement no longer happens, so clear
the lifecycle tag explicitly the first time an activity starts. Two things
fall out of it for free: an Android user still sees one entry rather than
a stale "cycle started" beside the live one, and a Live Activity left
running under the OLD shared tag by a pre-0.5.7 build is ended here, so
the upgrade heals a frozen card instead of stranding it.
"""
self._send_tag_clear(self._lifecycle_tag)
def _end_live_activity(self) -> None:
"""End the iOS Live Activity at cycle end (#446).
Called AFTER the finished notification has been dispatched, so the lock
screen is never momentarily empty: the finished alert lands on the
lifecycle tag, then the activity on its own tag goes away.
"""
self._send_tag_clear(self._live_notification_tag)
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 caller ends the activity
itself, after the finished notification has been delivered - see
``_end_live_activity`` (#446). Only the pending-purge, the action-based
clear marker (kept for backward compatibility with custom action templates)
and the state reset run here.
On shutdown (``clear_services=True``) the two tags are NOT treated alike,
and the difference is load-bearing (register item 350(c)). The live tag is
cleared unconditionally, because a Live Activity left behind counts its
chronometer into negative numbers once nothing updates it. The lifecycle
tag is cleared only while ``self.detector.state`` is in
``_CYCLE_IN_PROGRESS_STATES``: it carries the FINISHED alert, so an unload
or a restart after a cycle ended used to dismiss the very card the user was
still reading. "No finished notification follows" is true of a cycle in
progress and false of one already over - while both tags shared a value
that could not be distinguished, and now it can. See the comment at the
clear itself before making this unconditional again.
"""
# 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_MARKER, # 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:
# The live tag unconditionally: it carries a Live Activity with a
# chronometer that goes negative once nothing updates it, so a stale
# one is worse than none (#446).
self._send_tag_clear(self._live_notification_tag)
# The lifecycle tag only while a cycle is actually running. The
# finished alert uses this SAME tag, so on the shutdown path - a HA
# restart or an entry unload after a cycle ended - clearing it
# unconditionally dismisses the finished card the user still wants.
# "No finished notification follows" is true of a cycle in progress
# and false of one already over; while the two tags were the same
# value this could not be distinguished, and now it can.
if self.detector.state in _CYCLE_IN_PROGRESS_STATES:
self._send_tag_clear(self._lifecycle_tag)
# 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 the repeat-reminder dismiss action listener too, so no stale mobile
# action stays wired once the reminder is gone (#374).
self._remove_unload_dismiss_listener()
# 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
]
# Only mobile_app targets understand the "clear_notification" marker;
# non-mobile targets (email, Telegram, etc.) would receive it as a
# literal message. Mirror the pattern from _cancel_timer_mobile_notification.
mobile_services = [
s for s in self._get_services_for_event(NOTIFY_EVENT_CLEAN)
if self._is_mobile_notify_service(s)
]
if not mobile_services and not self._notify_actions:
return
if self._notify_actions:
self._run_notification_actions(
{
"device": self.config_entry.title,
"program": "",
"message": _CLEAR_NOTIFICATION_MARKER,
"title": "",
"icon": None,
"event_type": NOTIFY_EVENT_CLEAN,
"person_entity_id": None,
"person_name": None,
"tag": self._clean_tag,
}
)
if mobile_services:
self._send_notification_service(
_CLEAR_NOTIFICATION_MARKER,
services=mobile_services,
event_type=NOTIFY_EVENT_CLEAN,
extra_vars={"tag": self._clean_tag},
)
@property
def _unload_dismiss_action_id(self) -> str:
"""Stable mobile action ID for the unload-reminder dismiss button, per device."""
return f"UNLOAD_STOP_WD_{self.entry_id[:8].upper()}"
def _unload_nag_active(self, now: datetime) -> bool:
"""Whether the terminal state must be held alive for the unload reminder.
Default (one-shot) behaviour: hold only until the single reminder is due, so
the 30-min progress reset / power-based Off cannot clear the Clean state before
the reminder fires. Repeat mode (``CONF_NOTIFY_UNLOAD_REPEAT``, #374): hold
indefinitely so the reminder keeps re-firing, until the user dismisses it from
the notification or opens the door (both release the hold).
"""
if (
not self._is_clean_state
or self._notify_unload_delay_minutes <= 0
or self._cycle_completed_time is None
):
return False
if self._notify_unload_repeat:
# Only hold while a delivery channel exists — otherwise no reminder (and
# no dismiss button) is ever sent (dispatch is gated on the same
# condition), so the hold would strand the Clean state forever.
# Also bounded by NOTIFY_UNLOAD_REPEAT_MAX_REMINDERS: the "Stop
# reminding" button is mobile_app-only, so a non-mobile target leaves
# the door sensor as the only escape, and a sensor that never reports
# open would pin Clean state (and suppress power-based Off) forever.
return (
bool(self._notify_finish_services or self._notify_actions)
and not self._unload_nag_dismissed
and self._unload_nag_count < NOTIFY_UNLOAD_REPEAT_MAX_REMINDERS
)
return (
not self._notified_clean_laundry
and (now - self._cycle_completed_time).total_seconds()
< self._notify_unload_delay_minutes * 60
)
def _ensure_unload_dismiss_listener(self) -> None:
"""Register the mobile action listener for the reminder's dismiss button (once).
Mirrors the timer-pause interactive notification wiring: a single
``mobile_app_notification_action`` listener that matches this device's action
ID, marks the reminder dismissed, and clears the delivered card.
"""
if self._remove_unload_action_listener is not None:
return
action_id = self._unload_dismiss_action_id
@callback
def _on_unload_action(event: Any) -> None:
if event.data.get("action") == action_id:
self._logger.debug(
"Unload reminder dismissed via notification action"
)
self._unload_nag_dismissed = True
self._clear_clean_notification()
self._notify_update()
self._remove_unload_action_listener = self.hass.bus.async_listen(
"mobile_app_notification_action",
_on_unload_action,
)
def _remove_unload_dismiss_listener(self) -> None:
"""Drop the unload-reminder dismiss action listener, if registered."""
if self._remove_unload_action_listener is not None:
self._remove_unload_action_listener()
self._remove_unload_action_listener = None
def _reset_unload_nag_tracking(self) -> None:
"""Reset repeat-reminder tracking and drop the dismiss listener.
Called wherever the Clean state is cleared so the next cycle's reminder starts
fresh and no stale mobile action listener leaks.
"""
self._remove_unload_dismiss_listener()
self._unload_nag_dismissed = False
self._last_unload_nag_time = None
self._unload_nag_count = 0
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
live_cost = self._live_cost_so_far(trace)
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,
cost_so_far=live_cost[0] if live_cost else None,
cost_so_far_wh=live_cost[1] if live_cost else None,
)
self._projected_energy_wh = wh
self._projected_cost = cost
def _live_cost_so_far(
self, trace: list[tuple[datetime, float]]
) -> tuple[float, float] | None:
"""``(cost, charged_wh)`` incurred so far at the prices the cycle ran through.
Returns ``None`` when dynamic pricing is off or nothing has been recorded
yet, which puts :func:`progress.projected_energy` back on the flat-price
formula. The second element is the energy that cost was charged for, which
the projection must subtract instead of ``energy_so_far``: this integrates
the trace, while ``energy_so_far`` is the detector's per-reading
accumulator, and the two treat outages and sub-threshold intervals
differently. Never raises.
"""
if not self._dynamic_pricing_enabled() or not self._price_timeline:
return None
try:
if len(trace) < 2 or self._cycle_start_time is None:
return None
start_ts = self._cycle_start_time.timestamp()
timestamps = np.asarray([t.timestamp() - start_ts for t, _ in trace], dtype=float)
power = np.asarray([p for _, p in trace], dtype=float)
points = compact_price_timeline(
[(ts - start_ts, price) for ts, price in self._price_timeline],
max_points=PRICE_TIMELINE_MAX_POINTS,
decimals=PRICE_TIMELINE_PRICE_DECIMALS,
)
max_gap_s = energy_gap_threshold_s(timestamps)
result = cycle_cost(timestamps, power, points, max_gap_s=max_gap_s)
if result is None:
return None
# integrate_wh is the same total the price segments sum to, by the
# documented contract of integrate_wh_by_price.
return result[0], float(integrate_wh(timestamps, power, max_gap_s=max_gap_s))
except Exception: # noqa: BLE001 - projection must never break estimates
return None
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
self._envelope_position = None
return
now = dt_util.now()
# The 5 s throttle guards the heavy phase estimate, but the FIRST estimate
# after a match must not wait it out (#437): the match callback calls this
# and then _check_live_progress_notification(), which now stays in the
# waiting branch while _time_remaining is None. Bypassing once per match
# means the live notification switches to a real countdown immediately
# instead of holding the waiting message for another interval.
first_estimate_after_match = (
self._time_remaining is None
and bool(self._matched_profile_duration)
and self._matched_profile_duration > 0
)
prev_estimate_at = self._last_phase_estimate_time
if (
prev_estimate_at
and (now - prev_estimate_at).total_seconds() < 5.0
and not first_estimate_after_match
):
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)
# An async match result can land AFTER the detector has closed the cycle:
# the cycle start is cleared by then, so elapsed reads 0 while the matched
# duration and the smoothed progress are still set. The back-calculation
# below would then publish a full fresh "remaining" (raw progress 0 damped
# against a ~90% EMA) over the finished cycle's terminal values - measured
# live: remaining jumped from 15 min to 28 min and total duration from
# 164 min to 28 min, 7 s before the finish notification. There is nothing to
# estimate without an open cycle, and the terminal values must stand.
if duration_so_far <= 0.0:
return
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._envelope_position = None
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,
# Real gap since the previous estimate, so the progress EMA keeps its
# time constant instead of its step count - a plug that reports every
# 30 s must not lag 6x further behind than one reporting every 5 s.
dt_seconds=(
(now - prev_estimate_at).total_seconds() if prev_estimate_at else None
),
)
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_MARKER,
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,
quiet_threshold_w=float(
getattr(self.detector.config, "stop_threshold_w", 0.0) or 0.0
),
)
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 envelope_position(self) -> float | None:
"""How far this run has mapped onto its profile's envelope, 0-1, or None.
Produced by the DTW alignment that runs for the verified-pause decision,
so it is only refreshed while power is below the stop threshold and a
profile is matched - which is exactly the phase where elapsed time says
least (a dishwasher sitting in its drying phase). Visible only; no
detection path reads it.
"""
return self._envelope_position
@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.
Prefers the sensor's live state over the event cache (#409): the cache is
only refreshed while a cycle is active or expiring, so an idle appliance
whose plug reports rarely would otherwise show whatever value was last seen
- which is what users compared against their HA sensor and found wrong.
"""
live = self._live_power_state()
return live[0] if live is not None else 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 lifetime_cycle_count(self) -> int:
"""Cycles this appliance has run, ever - the odometer behind the count sensor.
Distinct from :attr:`cycle_count`, which is ``len(retained history)`` and is
the right basis for "do I have enough data yet" gates. This one only ever
rises: it survives retention trimming, record deletion and a data wipe, so an
"every N cycles" maintenance schedule (ours or an external integration's) can
be built on it (#414).
"""
return self._lifetime_cycle_count()
@property
def manual_program_active(self) -> bool:
"""Return True if a manual program override is active."""
return getattr(self, "_manual_program_active", False)
@property
def armed_program(self) -> str | None:
"""The program pinned for the next cycle, when one is not under way (#411)."""
return getattr(self, "_armed_program", None)
def _resolve_profiles(self) -> dict[str, Any]:
"""Return the stored profiles mapping, falling back to the raw store dict.
Shared by the manual-program set and re-arm paths so both agree on what
counts as an existing program. Never raises.
"""
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):
return cast(dict[str, Any], profiles_raw)
profiles_fallback = getattr(self.profile_store, "_data", {}).get("profiles", {})
return (
cast(dict[str, Any], profiles_fallback)
if isinstance(profiles_fallback, dict)
else {}
)
def set_manual_program(self, profile_name: str) -> bool:
"""Pin a program to the current cycle, or arm it for the next one (#411).
Returns True when the choice was accepted. It used to return silently
unless the detector was exactly in ``running``, which meant selecting a
program on an idle appliance (the overwhelmingly common case: the panel
offers the dropdown at all times) did nothing at all, reported success to
the caller because there was no return value to test, and logged nothing.
The selection then snapped back to auto-detect on the next refresh.
Now every state is accepted. While a cycle is under way the program is
applied to it immediately; otherwise it is armed and applied the moment
the next cycle starts, which is what someone picking a program on an idle
machine means by it. The only rejection left is a program that does not
exist, and that one is reported rather than swallowed.
"""
profiles = self._resolve_profiles()
if profile_name not in profiles:
self._logger.warning("Cannot set manual program: '%s' not found", profile_name)
return False
in_progress = self.detector.state in _CYCLE_IN_PROGRESS_STATES
# The arm survives only where the next cycle still needs it. Applied to a
# cycle already under way the pin belongs to THAT cycle, and leaving it armed
# let a back-to-back load inherit it: the cycle-end tail does try to clear it
# ("a pin is for the cycle it was made for") but sits behind the new-cycle
# token guard and returns early in exactly that case, so _consume_armed_program
# would stamp an unrelated cycle `label_source = "manual"` and let it reshape
# that program's envelope.
#
# STARTING is the one in-progress state that must KEEP the arm: the
# STARTING -> RUNNING transition resets the live pin as it starts the new
# cycle, and _consume_armed_program is what puts it back.
keep_arm = (not in_progress) or self.detector.state == STATE_STARTING
armed = profile_name if keep_arm else None
# Resolved before persisting, so this is ONE store write. Setting and then
# clearing scheduled two async_set_armed_program tasks and two saves for
# every mid-cycle pin.
self._armed_program = armed
self._persist_armed_program(armed)
if in_progress:
self._apply_manual_program(profile_name, profiles.get(profile_name))
else:
self._logger.info(
"Program %r armed; it will be applied when the next cycle starts",
profile_name,
)
return True
@staticmethod
def _profile_duration(value: Any) -> float | None:
"""A profile's expected duration in seconds, or None when unusable.
None is the field's declared "unknown" and every reader of
``_matched_profile_duration`` already guards for it. Non-finite is rejected
for the same reason ``_finite_power`` rejects it: ``inf`` survives a plain
``> 0`` test, and ``sensor.py``'s ``int(time_remaining / 60)`` then raises
OverflowError on every update. A profile written by this device is always
finite; an imported or hand-edited one need not be (register items 211/229).
OverflowError is caught alongside the rest because ``json`` keeps an
oversized integer literal as an unbounded ``int``: ``float(10**400)``
raises instead of returning ``inf``, so the non-finite filter below is
never reached and the raise escapes a ``@callback`` WS handler. ``1e400``
parses to ``inf`` and is the case the filter covers; the two are different
inputs (register items 279/280).
"""
try:
avg = float(value)
except (TypeError, ValueError, OverflowError):
return None
if not math.isfinite(avg) or avg <= 0:
return None
return avg
def _apply_manual_program(
self, profile_name: str, profile: dict[str, Any] | None
) -> None:
"""Pin *profile_name* to the cycle in progress. Shared by set and re-arm."""
self._current_program = profile_name
self._manual_program_active = True
# Update expected duration immediately. A profile with nothing learned yet
# (hand-created, or imported before its first cycle) must CLEAR the duration
# rather than leave the previously matched program's behind: the pin is
# applied mid-cycle, so the ETA and progress would go on describing the
# program the user just replaced. None is this field's established "unknown"
# value and every reader guards for it. Same shape as the restart path that
# re-pins a manual program (see the #404 secondary-bug block above), which
# already got this right.
avg = self._profile_duration(profile.get("avg_duration")) if profile else None
self._matched_profile_duration = avg
if avg:
self._logger.info(
"Manual program set to %s, duration=%.0fs", profile_name, avg
)
else:
self._logger.info(
"Manual program set to %s; it has no learned duration yet, so the "
"time estimate stays unknown until it does",
profile_name,
)
# Refresh whatever estimate the current state exposes, then publish. Runs for
# the cleared case too, so a stale remaining time is not left on display until
# the next tick.
#
# Every live state, not just RUNNING: set_manual_program applies the pin in
# PAUSED and ENDING as well (_CYCLE_IN_PROGRESS_STATES), and neither
# select.py nor _update_remaining_only publishes on its own, so on the
# select-entity path nothing reached the sensors at all. The phase
# estimator's 5 s throttle is bypassed because this is a user action, not a
# tick, and the value it invalidates is on screen right now.
#
# STARTING is deliberately excluded from the refresh: it exposes no estimate,
# and _update_estimates() treats it as a dead state - it would reset
# _current_program to "off" and undo the pin we just applied.
state = self.detector.state
if state in (STATE_RUNNING, STATE_PAUSED, STATE_ENDING):
self._last_phase_estimate_time = None
if state == STATE_RUNNING:
self._update_estimates()
else:
self._update_remaining_only()
self._notify_update()
def _persist_armed_program(self, profile_name: str | None) -> None:
"""Persist the armed program without blocking the caller. Never raises."""
try:
# Tracked, not bare async_create_task: this writes to the ProfileStore
# and saves, so a reload/unload that swaps the store out mid-flight must
# be able to cancel it rather than let it write to the stale one. That is
# the documented rule for every store-touching fire-and-forget in this
# class; this call site was the one that did not follow it.
self._spawn_tracked(
self.profile_store.async_set_armed_program(profile_name)
)
except Exception: # pylint: disable=broad-exception-caught
self._logger.debug("Could not persist the armed program", exc_info=True)
def clear_armed_program(self) -> bool:
"""Drop any program armed for the next cycle. True if one was armed.
`_armed_program` is the authoritative copy; the store key is only there so
an arm survives a restart. So every site that retires an arm has to clear
BOTH, and there were three inline copies of this pair plus one place that
cleared only the store - the wipe (`clear_all_data` pops `armed_program`,
`ws_wipe_history` never touched the field), which left a pre-wipe pin ready
to be re-applied as soon as a profile of the same name existed again.
"""
if self._armed_program is None:
return False
self._armed_program = None
self._persist_armed_program(None)
return True
def _consume_armed_program(self) -> bool:
"""Apply an armed program to the cycle that just started, if there is one.
Called from the new-cycle reset, which is also what would otherwise wipe a
program pinned during STARTING. Returns True when one was applied, so the
caller knows not to fall back to "detecting...".
"""
name = self._armed_program
if not name:
return False
profiles = self._resolve_profiles()
if name not in profiles:
# Deleted between arming and starting: drop it rather than pinning a
# program that no longer exists.
self._logger.info(
"Armed program %r no longer exists; reverting to auto-detect", name
)
self.clear_armed_program()
return False
self._apply_manual_program(name, profiles.get(name))
self.clear_armed_program()
self._logger.info("Applied armed program %r to the cycle just started", name)
return True
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
# A user pause overrides an auto-open dwell: cancel it so the pending timer
# can't finalize the cycle out from under the pause (#342).
self._cancel_door_end_dwell()
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 the manual program override, live or merely armed (#411).
Previously bailed out unless a pin was active on a running cycle, which
left an armed program stuck: picking "Auto-detect" on an idle appliance
could not undo a choice made a moment earlier.
"""
had_arm = self._armed_program is not None
if had_arm:
self._armed_program = None
self._persist_armed_program(None)
if not self._manual_program_active:
if had_arm:
self._notify_update()
self._logger.info("Armed program cleared, reverting to auto-detection")
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)