6578 lines
291 KiB
Python
6578 lines
291 KiB
Python
# WashData - Home Assistant integration for appliance cycle monitoring via smart plugs.
|
||
# Copyright (C) 2026 Lukas Bandura
|
||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||
#
|
||
# This program is free software: you can redistribute it and/or modify
|
||
# it under the terms of the GNU Affero General Public License as published
|
||
# by the Free Software Foundation, either version 3 of the License, or
|
||
# (at your option) any later version.
|
||
#
|
||
# This program is distributed in the hope that it will be useful,
|
||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||
# GNU Affero General Public License for more details.
|
||
#
|
||
# You should have received a copy of the GNU Affero General Public License
|
||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||
"""Manager for WashData."""
|
||
|
||
# pylint: disable=broad-exception-caught
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import hashlib
|
||
import inspect
|
||
import math
|
||
import uuid
|
||
import asyncio
|
||
from asyncio import Task
|
||
from collections.abc import Coroutine
|
||
from datetime import datetime, timedelta
|
||
from typing import TYPE_CHECKING, Any, cast
|
||
import numpy as np
|
||
|
||
if TYPE_CHECKING:
|
||
from .store import StoreBridge
|
||
|
||
from homeassistant.config_entries import ConfigEntry
|
||
from homeassistant.core import Context, Event, HomeAssistant, State, callback
|
||
from homeassistant.helpers.event import (
|
||
async_call_later,
|
||
async_track_state_change_event,
|
||
async_track_time_interval,
|
||
)
|
||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||
from homeassistant.exceptions import HomeAssistantError
|
||
from homeassistant.const import STATE_UNAVAILABLE, STATE_HOME
|
||
from homeassistant.util import dt as dt_util
|
||
import homeassistant.helpers.event as evt
|
||
from homeassistant.helpers import script as script_helper
|
||
from homeassistant.helpers import translation
|
||
|
||
from .const import (
|
||
DOMAIN,
|
||
CONF_POWER_SENSOR,
|
||
CONF_MIN_POWER,
|
||
CONF_OFF_DELAY,
|
||
CONF_NOTIFY_SERVICE,
|
||
CONF_NOTIFY_ACTIONS,
|
||
CONF_NOTIFY_START_SERVICES,
|
||
CONF_NOTIFY_FINISH_SERVICES,
|
||
CONF_NOTIFY_LIVE_SERVICES,
|
||
CONF_NOTIFY_CYCLE_TIMERS,
|
||
CONF_NOTIFY_PEOPLE,
|
||
CONF_NOTIFY_ONLY_WHEN_HOME,
|
||
CONF_NOTIFY_FIRE_EVENTS,
|
||
CONF_NOTIFY_EVENTS,
|
||
CONF_NO_UPDATE_ACTIVE_TIMEOUT,
|
||
CONF_LOW_POWER_NO_UPDATE_TIMEOUT, # Import new constant
|
||
CONF_SMOOTHING_WINDOW,
|
||
CONF_PROFILE_DURATION_TOLERANCE,
|
||
CONF_INTERRUPTED_MIN_SECONDS,
|
||
CONF_PROGRESS_RESET_DELAY,
|
||
CONF_LEARNING_CONFIDENCE,
|
||
CONF_DURATION_TOLERANCE,
|
||
CONF_AUTO_LABEL_CONFIDENCE,
|
||
CONF_AUTO_MAINTENANCE,
|
||
CONF_MAINTENANCE_REMINDER_CYCLES,
|
||
DEFAULT_MAINTENANCE_REMINDER_CYCLES,
|
||
CONF_PROFILE_MATCH_INTERVAL,
|
||
CONF_PROFILE_MATCH_MIN_DURATION_RATIO,
|
||
CONF_PROFILE_MATCH_MAX_DURATION_RATIO,
|
||
CONF_MAX_PAST_CYCLES,
|
||
CONF_MAX_FULL_TRACES_PER_PROFILE,
|
||
CONF_MAX_FULL_TRACES_UNLABELED,
|
||
CONF_WATCHDOG_INTERVAL,
|
||
CONF_AUTO_TUNE_NOISE_EVENTS_THRESHOLD,
|
||
CONF_COMPLETION_MIN_SECONDS,
|
||
CONF_NOTIFY_BEFORE_END_MINUTES,
|
||
CONF_PROFILE_MATCH_THRESHOLD,
|
||
CONF_PROFILE_UNMATCH_THRESHOLD,
|
||
CONF_DEVICE_TYPE,
|
||
CONF_START_DURATION_THRESHOLD,
|
||
CONF_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_DELAY_START_DETECT_ENABLED,
|
||
CONF_DELAY_CONFIRM_SECONDS,
|
||
CONF_DELAY_TIMEOUT_HOURS,
|
||
CONF_PUMP_STUCK_DURATION,
|
||
DEFAULT_PUMP_STUCK_DURATION,
|
||
EVENT_PUMP_STUCK,
|
||
DEVICE_TYPE_PUMP,
|
||
SIGNAL_WASHER_UPDATE,
|
||
NOTIFY_EVENT_START,
|
||
NOTIFY_EVENT_FINISH,
|
||
NOTIFY_EVENT_LIVE,
|
||
NOTIFY_EVENT_CLEAN,
|
||
NOTIFY_EVENT_TIMER,
|
||
EVENT_CYCLE_STARTED,
|
||
EVENT_CYCLE_ENDED,
|
||
DEFAULT_MIN_POWER,
|
||
DEFAULT_OFF_DELAY,
|
||
DEFAULT_NO_UPDATE_ACTIVE_TIMEOUT,
|
||
DEFAULT_NO_UPDATE_ACTIVE_TIMEOUT_BY_DEVICE,
|
||
DEFAULT_SMOOTHING_WINDOW,
|
||
DEFAULT_PROFILE_DURATION_TOLERANCE,
|
||
DEFAULT_INTERRUPTED_MIN_SECONDS,
|
||
DEFAULT_COMPLETION_MIN_SECONDS,
|
||
DEFAULT_NOTIFY_BEFORE_END_MINUTES,
|
||
DEFAULT_PROFILE_MATCH_THRESHOLD,
|
||
DEFAULT_PROFILE_UNMATCH_THRESHOLD,
|
||
DEFAULT_SAMPLING_INTERVAL,
|
||
DEFAULT_PROGRESS_RESET_DELAY,
|
||
DEFAULT_POWER_OFF_THRESHOLD_W,
|
||
DEFAULT_POWER_OFF_DELAY,
|
||
DEFAULT_LEARNING_CONFIDENCE,
|
||
DEFAULT_DURATION_TOLERANCE,
|
||
DEFAULT_AUTO_LABEL_CONFIDENCE,
|
||
DEFAULT_AUTO_MAINTENANCE,
|
||
DEFAULT_PROFILE_MATCH_INTERVAL,
|
||
DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO,
|
||
DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO_BY_DEVICE,
|
||
DEFAULT_ANTI_WRINKLE_ENABLED,
|
||
DEFAULT_ANTI_WRINKLE_MAX_POWER,
|
||
DEFAULT_ANTI_WRINKLE_MAX_DURATION,
|
||
DEFAULT_ANTI_WRINKLE_EXIT_POWER,
|
||
DEFAULT_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_START_MESSAGE,
|
||
CONF_NOTIFY_FINISH_MESSAGE,
|
||
CONF_NOTIFY_PRE_COMPLETE_MESSAGE,
|
||
CONF_NOTIFY_LIVE_INTERVAL_SECONDS,
|
||
CONF_NOTIFY_LIVE_OVERRUN_PERCENT,
|
||
CONF_NOTIFY_LIVE_CHRONOMETER,
|
||
CONF_NOTIFY_REMINDER_MESSAGE,
|
||
CONF_NOTIFY_TIMEOUT_SECONDS,
|
||
CONF_NOTIFY_CHANNEL,
|
||
CONF_NOTIFY_FINISH_CHANNEL,
|
||
CONF_ENERGY_PRICE_STATIC,
|
||
CONF_ENERGY_PRICE_ENTITY,
|
||
CONF_ENERGY_SENSOR,
|
||
CONF_PEAK_RATE_THRESHOLD,
|
||
CONF_PEAK_RATE_MESSAGE,
|
||
DEFAULT_PEAK_RATE_MESSAGE,
|
||
CONF_DOOR_SENSOR_ENTITY,
|
||
CONF_PAUSE_CUTS_POWER,
|
||
CONF_SWITCH_ENTITY,
|
||
CONF_NOTIFY_UNLOAD_DELAY_MINUTES,
|
||
CONF_NOTIFY_UNLOAD_MESSAGE,
|
||
DEFAULT_NOTIFY_UNLOAD_DELAY_MINUTES,
|
||
DEFAULT_NOTIFY_UNLOAD_MESSAGE,
|
||
CONF_NOTIFY_MILESTONES,
|
||
CONF_NOTIFY_MILESTONE_MESSAGE,
|
||
DEFAULT_NOTIFY_MILESTONES,
|
||
DEFAULT_NOTIFY_MILESTONE_MESSAGE,
|
||
STATE_CLEAN,
|
||
STATE_FINISHED,
|
||
STATE_INTERRUPTED,
|
||
STATE_FORCE_STOPPED,
|
||
DEFAULT_NOTIFY_TITLE,
|
||
DEFAULT_NOTIFY_START_MESSAGE,
|
||
DEFAULT_NOTIFY_FINISH_MESSAGE,
|
||
DEFAULT_NOTIFY_PRE_COMPLETE_MESSAGE,
|
||
DEFAULT_NOTIFY_LIVE_WAITING_MESSAGE,
|
||
DEFAULT_NOTIFY_ONLY_WHEN_HOME,
|
||
DEFAULT_NOTIFY_FIRE_EVENTS,
|
||
DEFAULT_NOTIFY_LIVE_INTERVAL_SECONDS,
|
||
DEFAULT_NOTIFY_LIVE_OVERRUN_PERCENT,
|
||
DEFAULT_NOTIFY_LIVE_CHRONOMETER,
|
||
DEFAULT_NOTIFY_REMINDER_MESSAGE,
|
||
DEFAULT_NOTIFY_TIMEOUT_SECONDS,
|
||
DEFAULT_NOTIFY_CHANNEL,
|
||
DEFAULT_NOTIFY_FINISH_CHANNEL,
|
||
|
||
DEFAULT_MAX_FULL_TRACES_UNLABELED,
|
||
DEFAULT_DTW_BANDWIDTH,
|
||
DEFAULT_WATCHDOG_INTERVAL,
|
||
CONF_MATCH_PERSISTENCE,
|
||
DEFAULT_MATCH_PERSISTENCE,
|
||
DEFAULT_MATCH_REVERT_RATIO,
|
||
DEFAULT_AUTO_TUNE_NOISE_EVENTS_THRESHOLD,
|
||
DEFAULT_DEVICE_TYPE,
|
||
DEFAULT_START_DURATION_THRESHOLD,
|
||
DEFAULT_END_REPEAT_COUNT,
|
||
DEFAULT_MIN_OFF_GAP,
|
||
DEFAULT_MIN_OFF_GAP_BY_DEVICE,
|
||
DEFAULT_MAX_DEFERRAL_SECONDS,
|
||
DEFAULT_START_ENERGY_THRESHOLDS_BY_DEVICE,
|
||
DEFAULT_END_ENERGY_THRESHOLD,
|
||
DEVICE_COMPLETION_THRESHOLDS,
|
||
CYCLE_UNDERRUN_ANOMALY_RATIO,
|
||
ENERGY_ANOMALY_Z_THRESHOLD,
|
||
TERMINAL_DROP_MIN_CLEAN_CYCLES,
|
||
TERMINAL_DROP_MIN_QUIET_SPAN_S,
|
||
TERMINAL_DROP_EARLINESS_RATIO,
|
||
TERMINAL_DROP_MIN_PEAK_RATIO,
|
||
TERMINAL_DROP_PEAK_FAMILIAR_TOL,
|
||
ML_MATCH_COMMIT_THRESHOLD,
|
||
STATE_RUNNING,
|
||
STATE_OFF,
|
||
STATE_STARTING,
|
||
STATE_PAUSED,
|
||
STATE_USER_PAUSED,
|
||
STATE_ENDING,
|
||
STATE_ANTI_WRINKLE,
|
||
STATE_DELAY_WAIT,
|
||
STATE_IDLE,
|
||
STATE_UNKNOWN,
|
||
)
|
||
from .cycle_detector import CycleDetector, CycleDetectorConfig
|
||
from .learning import LearningManager
|
||
from .profile_store import (
|
||
ProfileStore,
|
||
decompress_power_data,
|
||
is_terminal_drop,
|
||
terminal_drop_baseline,
|
||
)
|
||
from .signal_processing import integrate_wh, energy_gap_threshold_s
|
||
from .recorder import CycleRecorder
|
||
from .diag_buffer import DiagBuffer
|
||
from .log_utils import DeviceLoggerAdapter
|
||
from .time_utils import power_data_to_offsets
|
||
from . import progress as progress_mod
|
||
from . import notification_rules as notif_rules
|
||
from .phase_segmenter import phase_matching_enabled
|
||
|
||
_LOGGER = logging.getLogger(__name__)
|
||
|
||
# Finish-type notification events that would wake someone and are therefore gated by
|
||
# the quiet-hours (do-not-disturb) window. Live-progress ticks (NOTIFY_EVENT_LIVE)
|
||
# and the start notification (NOTIFY_EVENT_START) are intentionally excluded.
|
||
_QUIET_HOURS_EVENT_TYPES = frozenset(
|
||
{NOTIFY_EVENT_FINISH, NOTIFY_EVENT_CLEAN, "pre_complete"}
|
||
)
|
||
|
||
|
||
# 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,
|
||
}
|
||
)
|
||
|
||
|
||
def _sanitize_ranking(raw_list: list[dict[str, Any]], limit: int = 5) -> list[dict[str, Any]]:
|
||
"""Top-N ranking candidates stripped of the heavy `current`/`sample` power
|
||
arrays, safe to persist on cycle_data and to include in the 32KB-limited
|
||
EVENT_CYCLE_ENDED payload."""
|
||
out: list[dict[str, Any]] = []
|
||
for cand in (raw_list or [])[:limit]:
|
||
out.append({
|
||
"name": cand.get("name"),
|
||
"score": round(float(cand.get("score", 0.0)), 3),
|
||
"profile_duration": cand.get("profile_duration"),
|
||
})
|
||
return out
|
||
|
||
# Notification-data keys that may only be forwarded to mobile_app_* notify targets.
|
||
# Strict-schema platforms (e.g. Signal) reject unknown keys, so these are added per
|
||
# service only when the target is a mobile app. Includes the iOS Live Activity
|
||
# enrichment keys (subtitle/content_state/activity) so they never reach non-mobile
|
||
# platforms.
|
||
_MOBILE_ONLY_EXTRA_KEYS = (
|
||
"tag",
|
||
"timeout",
|
||
"channel",
|
||
"priority",
|
||
"actions",
|
||
"sticky",
|
||
"subtitle",
|
||
"content_state",
|
||
"activity",
|
||
)
|
||
|
||
|
||
def _pn_create(
|
||
hass: HomeAssistant,
|
||
message: str,
|
||
*,
|
||
title: str | None = None,
|
||
notification_id: str | None = None,
|
||
) -> None:
|
||
"""Best-effort persistent notification creation.
|
||
|
||
Deliberately goes through ``hass.components.persistent_notification`` rather than
|
||
a direct ``homeassistant.components.persistent_notification`` import: the test
|
||
suite stubs out the whole ``homeassistant`` module and mocks this dynamic
|
||
attribute, so a direct import would both fail under test and bypass those mocks.
|
||
Failures are logged at debug (not silently swallowed) so a stuck notification is
|
||
at least visible in the logs.
|
||
"""
|
||
try:
|
||
components = getattr(cast(Any, hass), "components", None)
|
||
pn = getattr(cast(Any, components), "persistent_notification", None)
|
||
if pn is None:
|
||
return
|
||
result = pn.async_create(message, title=title, notification_id=notification_id)
|
||
if inspect.iscoroutine(result):
|
||
hass.async_create_task(result)
|
||
except Exception: # noqa: BLE001 - best-effort; surface the failure in logs
|
||
_LOGGER.debug("persistent_notification create failed (id=%s)", notification_id, exc_info=True)
|
||
|
||
|
||
def _pn_dismiss(hass: HomeAssistant, notification_id: str) -> None:
|
||
"""Best-effort persistent notification dismissal.
|
||
|
||
Uses the ``hass.components`` accessor for the same test-mocking reason as
|
||
:func:`_pn_create`; failures are logged at debug rather than swallowed.
|
||
"""
|
||
try:
|
||
components = getattr(cast(Any, hass), "components", None)
|
||
pn = getattr(cast(Any, components), "persistent_notification", None)
|
||
if pn is None:
|
||
return
|
||
result = pn.async_dismiss(notification_id)
|
||
if inspect.iscoroutine(result):
|
||
hass.async_create_task(result)
|
||
except Exception: # noqa: BLE001 - best-effort; surface the failure in logs
|
||
_LOGGER.debug("persistent_notification dismiss failed (id=%s)", notification_id, exc_info=True)
|
||
|
||
|
||
class WashDataManager:
|
||
"""Manages a single washing machine instance."""
|
||
|
||
@property
|
||
def store_bridge(self) -> "StoreBridge":
|
||
"""Lazy community-store bridge (kept for the entry so the token cache persists)."""
|
||
if self._store_bridge is None:
|
||
from .store import StoreBridge
|
||
self._store_bridge = StoreBridge(self.hass, self.profile_store)
|
||
return self._store_bridge
|
||
|
||
def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
|
||
"""Initialize the manager."""
|
||
self.hass = hass
|
||
self.config_entry = config_entry
|
||
self.entry_id = config_entry.entry_id
|
||
self._logger = DeviceLoggerAdapter(_LOGGER, config_entry.title)
|
||
self.diag_buffer = DiagBuffer(config_entry.title)
|
||
|
||
# Prioritize options -> data for power sensor (allows changing it)
|
||
self.power_sensor_entity_id = config_entry.options.get(
|
||
CONF_POWER_SENSOR, config_entry.data.get(CONF_POWER_SENSOR)
|
||
)
|
||
self.device_type = config_entry.options.get(
|
||
CONF_DEVICE_TYPE,
|
||
config_entry.data.get(CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE),
|
||
)
|
||
|
||
# Initialize attributes to satisfy pylint
|
||
self._off_delay = float(DEFAULT_OFF_DELAY)
|
||
self._no_update_active_timeout = float(DEFAULT_NO_UPDATE_ACTIVE_TIMEOUT)
|
||
self._low_power_no_update_timeout = 3600.0 # Default 1h
|
||
self._notify_before_end_minutes = float(DEFAULT_NOTIFY_BEFORE_END_MINUTES)
|
||
self._notify_start_services: list[str] = []
|
||
self._notify_finish_services: list[str] = []
|
||
self._notify_live_services: list[str] = []
|
||
self._notify_actions: list[dict[str, Any]] = []
|
||
self._notify_script: Any = None # cached Script; invalidated on options reload
|
||
self._notify_people: list[str] = []
|
||
self._notify_cycle_timers: list[dict[str, Any]] = []
|
||
self._fired_cycle_timers: set[int] = set()
|
||
self._timer_pause_pn_id: str | None = None
|
||
self._timer_pause_mobile_tag: str | None = None
|
||
self._remove_timer_action_listener: Any | None = None
|
||
self._timer_ui_strings: dict[str, str] = {}
|
||
self._notify_only_when_home = DEFAULT_NOTIFY_ONLY_WHEN_HOME
|
||
self._notify_fire_events = DEFAULT_NOTIFY_FIRE_EVENTS
|
||
self._notify_live_interval_seconds = DEFAULT_NOTIFY_LIVE_INTERVAL_SECONDS
|
||
self._notify_live_overrun_percent = DEFAULT_NOTIFY_LIVE_OVERRUN_PERCENT
|
||
self._notify_live_chronometer = DEFAULT_NOTIFY_LIVE_CHRONOMETER
|
||
self._notify_timeout_seconds = DEFAULT_NOTIFY_TIMEOUT_SECONDS
|
||
self._pending_notifications: list[dict[str, Any]] = []
|
||
# Quiet-hours (do-not-disturb) hold queue + release timer. Finish-type
|
||
# notifications that would fire inside the window are parked here and flushed
|
||
# at the end of the window by a single async_call_later timer.
|
||
self._quiet_pending_notifications: list[dict[str, Any]] = []
|
||
self._remove_quiet_hours_timer: Any | None = None
|
||
self._remove_notify_people_listener = None
|
||
self._live_notification_sent_count = 0
|
||
|
||
# HA restart gap tracking: gaps in the power trace caused by integration
|
||
# restarts during an active cycle. Each entry is a dict:
|
||
# start_ts: ISO timestamp of gap start (= last snapshot save time)
|
||
# end_ts: ISO timestamp of gap end (= restoration time)
|
||
# gap_seconds: duration in seconds
|
||
# profile: matched profile name at restoration time, or None
|
||
# match_confidence: match confidence at restoration time, or None
|
||
# Cleared and stored into cycle_data["restart_gaps"] at cycle end.
|
||
# Matching always uses real readings only; this list is for display/anomaly.
|
||
self._restart_gaps: list[dict[str, Any]] = []
|
||
|
||
# External energy-meter snapshot for the current cycle (issue #316).
|
||
# Captured at cycle start, read back at cycle end for the start->end delta.
|
||
# Both survive a restart via the active-cycle snapshot.
|
||
self._energy_meter_start: float | None = None
|
||
self._energy_meter_source: str | None = None
|
||
|
||
# Pause tracking (user-triggered)
|
||
self._user_pause_start: datetime | None = None
|
||
self._total_user_paused_seconds: float = 0.0
|
||
self._is_user_paused: bool = False
|
||
self._pause_cuts_power: bool = bool(
|
||
config_entry.options.get(CONF_PAUSE_CUTS_POWER, False)
|
||
)
|
||
|
||
# Door sensor + clean state
|
||
self._door_sensor_entity: str | None = config_entry.options.get(
|
||
CONF_DOOR_SENSOR_ENTITY
|
||
) or None
|
||
self._remove_door_sensor_listener = None
|
||
self._is_clean_state: bool = False
|
||
self._clean_state_start: datetime | None = None
|
||
self._notified_clean_laundry: bool = False
|
||
# Set by _dispatch_notification when a call is queued for later (quiet
|
||
# hours / presence) rather than sent; read by dedup-flag callers.
|
||
self._last_dispatch_deferred: bool = False
|
||
self._notify_unload_delay_minutes: int = int(
|
||
config_entry.options.get(
|
||
CONF_NOTIFY_UNLOAD_DELAY_MINUTES, DEFAULT_NOTIFY_UNLOAD_DELAY_MINUTES
|
||
)
|
||
)
|
||
self._live_notification_cap = 0
|
||
self._last_live_notification_time: datetime | None = None
|
||
self._live_waiting_notification_sent = False
|
||
self._live_chronometer_overrun_sent = False
|
||
# iOS Live Activity: whether the "start" lifecycle marker has been emitted on
|
||
# the first live notification of the current cycle. Reset per cycle.
|
||
self._live_activity_started = False
|
||
# Single per-device identity shared by start/live/reminder/finished so each
|
||
# replaces the previous on the mobile app (and collapses to one entry on the
|
||
# persistent-notification fallback). The clean-laundry nag uses its own tag
|
||
# since it fires up to an hour after finish and should not clobber the thread.
|
||
self._lifecycle_tag = f"ha_washdata_{self.entry_id}_lifecycle"
|
||
self._lifecycle_pn_id = self._lifecycle_tag
|
||
self._clean_tag = f"ha_washdata_{self.entry_id}_clean"
|
||
# Backwards-compatible alias for existing live-notification call sites/tests.
|
||
self._live_notification_tag = self._lifecycle_tag
|
||
self._start_event_fired = False
|
||
self._cycle_start_time: datetime | None = None
|
||
# Per-cycle UUID used to key ranking snapshots; prevents cross-contamination
|
||
# between cycles that happen to share the same second-resolution start_time.
|
||
self._ranking_snapshot_cycle_id: str = ""
|
||
|
||
# State
|
||
self._current_power = 0.0
|
||
# Power-based Off detection (issue #284): timestamp at which power first fell
|
||
# below the power-off threshold while in a terminal state. None = not currently
|
||
# below (or feature disabled). Cleared on new cycle / when power rises.
|
||
self._power_off_below_since: datetime | None = None
|
||
# One-shot cancellable timer armed when power first drops below the power-off
|
||
# threshold, so the terminal->Off reset fires promptly after power_off_delay
|
||
# instead of waiting for the next 60s expiry poll. Cancelled on power rise /
|
||
# nag hold / terminal reset / new cycle.
|
||
self._remove_power_off_timer: Any | None = None
|
||
self._last_reading_time: datetime | None = None
|
||
self._last_real_reading_time: datetime | None = None # Track last real sensor update
|
||
self._noise_events: list[datetime] = []
|
||
self._noise_max_powers: list[float] = []
|
||
self._last_match_result = None
|
||
self._last_phase_estimate_time = None
|
||
self._sample_intervals: list[float] = []
|
||
self._sample_interval_stats: dict[str, Any] = {}
|
||
self._matching_task: Task[Any] | None = None
|
||
self._cycle_end_task: Task[Any] | None = None
|
||
# Detached store-touching tasks (matching trigger, active-cycle clear,
|
||
# post-cycle processing) tracked so async_shutdown can cancel them before a
|
||
# reload/unload swaps the ProfileStore out from under them.
|
||
self._background_tasks: set[Task[Any]] = set()
|
||
self._is_shutdown: bool = False
|
||
self._last_state_save = 0.0
|
||
self._last_cycle_end_time: datetime | None = None
|
||
self._remove_state_expiry_timer = None
|
||
|
||
# Components
|
||
match_threshold = config_entry.options.get(
|
||
CONF_PROFILE_MATCH_THRESHOLD, DEFAULT_PROFILE_MATCH_THRESHOLD
|
||
)
|
||
unmatch_threshold = config_entry.options.get(
|
||
CONF_PROFILE_UNMATCH_THRESHOLD, DEFAULT_PROFILE_UNMATCH_THRESHOLD
|
||
)
|
||
self._unmatch_threshold = unmatch_threshold
|
||
|
||
self.profile_store = ProfileStore(
|
||
hass,
|
||
self.entry_id,
|
||
min_duration_ratio=config_entry.options.get(
|
||
CONF_PROFILE_MATCH_MIN_DURATION_RATIO,
|
||
DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO_BY_DEVICE.get(
|
||
self.device_type, DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO
|
||
),
|
||
),
|
||
max_duration_ratio=config_entry.options.get(
|
||
CONF_PROFILE_MATCH_MAX_DURATION_RATIO,
|
||
DEFAULT_PROFILE_MATCH_MAX_DURATION_RATIO,
|
||
),
|
||
save_debug_traces=config_entry.options.get(CONF_SAVE_DEBUG_TRACES, False),
|
||
match_threshold=match_threshold,
|
||
unmatch_threshold=unmatch_threshold,
|
||
device_name=config_entry.title,
|
||
)
|
||
self.profile_store.dtw_bandwidth = float(
|
||
config_entry.options.get(CONF_DTW_BANDWIDTH, DEFAULT_DTW_BANDWIDTH)
|
||
)
|
||
self.learning_manager = LearningManager(
|
||
hass, self.entry_id, self.profile_store, self.device_type,
|
||
device_name=config_entry.title,
|
||
)
|
||
self.recorder = CycleRecorder(hass, self.entry_id, device_name=config_entry.title)
|
||
self._store_bridge: Any = None # lazy community-store bridge (online features)
|
||
|
||
# Priority: Options > Data > Default
|
||
min_power = config_entry.options.get(
|
||
CONF_MIN_POWER, config_entry.data.get(CONF_MIN_POWER, DEFAULT_MIN_POWER)
|
||
)
|
||
off_delay = config_entry.options.get(
|
||
CONF_OFF_DELAY, config_entry.data.get(CONF_OFF_DELAY, DEFAULT_OFF_DELAY)
|
||
)
|
||
progress_reset_delay = config_entry.options.get(
|
||
CONF_PROGRESS_RESET_DELAY, DEFAULT_PROGRESS_RESET_DELAY
|
||
)
|
||
self._no_update_active_timeout = float(
|
||
config_entry.options.get(
|
||
CONF_NO_UPDATE_ACTIVE_TIMEOUT,
|
||
DEFAULT_NO_UPDATE_ACTIVE_TIMEOUT,
|
||
)
|
||
)
|
||
self._low_power_no_update_timeout = float(
|
||
config_entry.options.get(CONF_LOW_POWER_NO_UPDATE_TIMEOUT, 3600.0)
|
||
)
|
||
self._off_delay = float(config_entry.options.get(CONF_OFF_DELAY, DEFAULT_OFF_DELAY))
|
||
self._learning_confidence = config_entry.options.get(
|
||
CONF_LEARNING_CONFIDENCE, DEFAULT_LEARNING_CONFIDENCE
|
||
)
|
||
self._duration_tolerance = config_entry.options.get(
|
||
CONF_DURATION_TOLERANCE, DEFAULT_DURATION_TOLERANCE
|
||
)
|
||
self._auto_label_confidence = config_entry.options.get(
|
||
CONF_AUTO_LABEL_CONFIDENCE, DEFAULT_AUTO_LABEL_CONFIDENCE
|
||
)
|
||
|
||
self._profile_match_interval = int(
|
||
config_entry.options.get(
|
||
CONF_PROFILE_MATCH_INTERVAL, DEFAULT_PROFILE_MATCH_INTERVAL
|
||
)
|
||
)
|
||
self._notify_before_end_minutes = int(
|
||
config_entry.options.get(
|
||
CONF_NOTIFY_BEFORE_END_MINUTES, DEFAULT_NOTIFY_BEFORE_END_MINUTES
|
||
)
|
||
)
|
||
self._load_notify_services(config_entry)
|
||
self._notify_actions = list(
|
||
cast(list[dict[str, Any]], config_entry.options.get(CONF_NOTIFY_ACTIONS, []) or [])
|
||
)
|
||
self._notify_people = list(
|
||
config_entry.options.get(CONF_NOTIFY_PEOPLE, []) or []
|
||
)
|
||
self._notify_only_when_home = bool(
|
||
config_entry.options.get(
|
||
CONF_NOTIFY_ONLY_WHEN_HOME, DEFAULT_NOTIFY_ONLY_WHEN_HOME
|
||
)
|
||
)
|
||
self._notify_fire_events = bool(
|
||
config_entry.options.get(CONF_NOTIFY_FIRE_EVENTS, DEFAULT_NOTIFY_FIRE_EVENTS)
|
||
)
|
||
self._notify_live_interval_seconds = int(
|
||
config_entry.options.get(
|
||
CONF_NOTIFY_LIVE_INTERVAL_SECONDS,
|
||
DEFAULT_NOTIFY_LIVE_INTERVAL_SECONDS,
|
||
)
|
||
)
|
||
self._notify_live_overrun_percent = int(
|
||
config_entry.options.get(
|
||
CONF_NOTIFY_LIVE_OVERRUN_PERCENT,
|
||
DEFAULT_NOTIFY_LIVE_OVERRUN_PERCENT,
|
||
)
|
||
)
|
||
self._notify_live_chronometer = bool(
|
||
config_entry.options.get(
|
||
CONF_NOTIFY_LIVE_CHRONOMETER,
|
||
DEFAULT_NOTIFY_LIVE_CHRONOMETER,
|
||
)
|
||
)
|
||
self._notify_timeout_seconds = int(
|
||
config_entry.options.get(
|
||
CONF_NOTIFY_TIMEOUT_SECONDS, DEFAULT_NOTIFY_TIMEOUT_SECONDS
|
||
)
|
||
)
|
||
|
||
# Advanced options
|
||
smoothing_window = int(config_entry.options.get("smoothing_window", 5))
|
||
interrupted_min_seconds = int(
|
||
config_entry.options.get("interrupted_min_seconds", 150)
|
||
)
|
||
# Get device specific default for completion threshold
|
||
device_default_completion = DEVICE_COMPLETION_THRESHOLDS.get(
|
||
self.device_type, DEFAULT_COMPLETION_MIN_SECONDS
|
||
)
|
||
completion_min_seconds = int(
|
||
config_entry.options.get(
|
||
CONF_COMPLETION_MIN_SECONDS, device_default_completion
|
||
)
|
||
)
|
||
|
||
start_duration_threshold = float(
|
||
config_entry.options.get(
|
||
CONF_START_DURATION_THRESHOLD, DEFAULT_START_DURATION_THRESHOLD
|
||
)
|
||
)
|
||
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,
|
||
DEFAULT_MIN_OFF_GAP_BY_DEVICE.get(
|
||
self.device_type, DEFAULT_MIN_OFF_GAP
|
||
),
|
||
)
|
||
),
|
||
start_energy_threshold=float(
|
||
config_entry.options.get(
|
||
CONF_START_ENERGY_THRESHOLD,
|
||
DEFAULT_START_ENERGY_THRESHOLDS_BY_DEVICE.get(self.device_type, 0.2)
|
||
)
|
||
),
|
||
end_energy_threshold=float(
|
||
config_entry.options.get(CONF_END_ENERGY_THRESHOLD, DEFAULT_END_ENERGY_THRESHOLD)
|
||
),
|
||
start_threshold_w=float(
|
||
config_entry.options.get(
|
||
CONF_START_THRESHOLD_W,
|
||
float(min_power) + max(1.0, 0.1 * float(min_power)),
|
||
)
|
||
),
|
||
stop_threshold_w=float(
|
||
config_entry.options.get(
|
||
CONF_STOP_THRESHOLD_W,
|
||
float(min_power) * 0.6 if float(min_power) > 0 else 2.0,
|
||
)
|
||
),
|
||
power_off_threshold_w=float(
|
||
config_entry.options.get(
|
||
CONF_POWER_OFF_THRESHOLD_W, DEFAULT_POWER_OFF_THRESHOLD_W
|
||
)
|
||
),
|
||
power_off_delay=float(
|
||
config_entry.options.get(CONF_POWER_OFF_DELAY, DEFAULT_POWER_OFF_DELAY)
|
||
),
|
||
min_duration_ratio=float(
|
||
config_entry.options.get(
|
||
CONF_PROFILE_MATCH_MIN_DURATION_RATIO,
|
||
DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO_BY_DEVICE.get(
|
||
self.device_type, DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO
|
||
),
|
||
)
|
||
),
|
||
match_interval=int(
|
||
config_entry.options.get(
|
||
CONF_PROFILE_MATCH_INTERVAL, DEFAULT_PROFILE_MATCH_INTERVAL
|
||
)
|
||
),
|
||
anti_wrinkle_enabled=bool(
|
||
config_entry.options.get(
|
||
CONF_ANTI_WRINKLE_ENABLED, DEFAULT_ANTI_WRINKLE_ENABLED
|
||
)
|
||
),
|
||
anti_wrinkle_max_power=float(
|
||
config_entry.options.get(
|
||
CONF_ANTI_WRINKLE_MAX_POWER, DEFAULT_ANTI_WRINKLE_MAX_POWER
|
||
)
|
||
),
|
||
anti_wrinkle_max_duration=float(
|
||
config_entry.options.get(
|
||
CONF_ANTI_WRINKLE_MAX_DURATION, DEFAULT_ANTI_WRINKLE_MAX_DURATION
|
||
)
|
||
),
|
||
anti_wrinkle_exit_power=float(
|
||
config_entry.options.get(
|
||
CONF_ANTI_WRINKLE_EXIT_POWER, DEFAULT_ANTI_WRINKLE_EXIT_POWER
|
||
)
|
||
),
|
||
anti_wrinkle_idle_timeout=float(
|
||
config_entry.options.get(
|
||
CONF_ANTI_WRINKLE_IDLE_TIMEOUT, DEFAULT_ANTI_WRINKLE_IDLE_TIMEOUT
|
||
)
|
||
),
|
||
delay_detect_enabled=bool(
|
||
config_entry.options.get(
|
||
CONF_DELAY_START_DETECT_ENABLED, DEFAULT_DELAY_START_DETECT_ENABLED
|
||
)
|
||
),
|
||
delay_confirm_seconds=float(
|
||
config_entry.options.get(
|
||
CONF_DELAY_CONFIRM_SECONDS, DEFAULT_DELAY_CONFIRM_SECONDS
|
||
)
|
||
),
|
||
delay_timeout_seconds=float(
|
||
config_entry.options.get(
|
||
CONF_DELAY_TIMEOUT_HOURS, DEFAULT_DELAY_TIMEOUT_HOURS
|
||
)
|
||
) * 3600.0,
|
||
)
|
||
self._config = config
|
||
|
||
|
||
def profile_matcher_wrapper(
|
||
readings: list[tuple[datetime, float]],
|
||
) -> tuple[str | None, float, float, str | None]:
|
||
"""Wraps profile store matching logic with detector callback signature.
|
||
|
||
Returns: None (async offload)
|
||
"""
|
||
# Manual program override
|
||
if self._manual_program_active and self._current_program:
|
||
elapsed_seconds = 0.0
|
||
if len(readings) > 1:
|
||
elapsed_seconds = max(
|
||
0.0,
|
||
(readings[-1][0] - readings[0][0]).total_seconds(),
|
||
)
|
||
|
||
expected_duration = float(self._matched_profile_duration or 0.0)
|
||
manual_phase = self.profile_store.check_phase_match(
|
||
self._current_program,
|
||
elapsed_seconds,
|
||
)
|
||
return (
|
||
self._current_program,
|
||
1.0,
|
||
expected_duration,
|
||
manual_phase or "Manual",
|
||
)
|
||
|
||
if not readings:
|
||
return (None, 0.0, 0.0, None)
|
||
|
||
# Snapshotted for thread safety indirectly by task logic
|
||
# We don't need a wrapper task if we unify with _update_estimates matching
|
||
# but for now let's keep the detector callback as a trigger
|
||
self._spawn_tracked(self._async_perform_combined_matching(readings))
|
||
return (None, 0.0, 0.0, None)
|
||
|
||
self.detector = CycleDetector(
|
||
config,
|
||
self._on_state_change,
|
||
self._on_cycle_end,
|
||
profile_matcher=profile_matcher_wrapper,
|
||
device_name=config_entry.title,
|
||
end_confidence_provider=self._ml_end_confidence,
|
||
terminal_drop_provider=self._terminal_drop_provider,
|
||
)
|
||
self._ml_end_expectation_cache: tuple[str, dict[str, float]] | None = None
|
||
# (cycle_count, earliest_quiet_offset|None, peak_range|None) for the
|
||
# terminal-drop baselines; keyed by cycle count so it auto-invalidates
|
||
# when history grows.
|
||
self._terminal_drop_cache: (
|
||
tuple[int, float | None, tuple[float, float] | None] | None
|
||
) = None
|
||
# Cycle count an executor refresh of the baseline is in-flight/done for, so
|
||
# the loop never recomputes it (issue #311) and never double-schedules.
|
||
self._terminal_drop_refresh_n: int | None = None
|
||
|
||
self._remove_listener = None
|
||
self._remove_external_trigger_listener = None # External cycle end trigger
|
||
self._remove_watchdog = None
|
||
self._watchdog_interval = int(
|
||
config_entry.options.get(CONF_WATCHDOG_INTERVAL, DEFAULT_WATCHDOG_INTERVAL)
|
||
)
|
||
self._match_persistence = int(
|
||
config_entry.options.get(CONF_MATCH_PERSISTENCE, DEFAULT_MATCH_PERSISTENCE)
|
||
)
|
||
self._sampling_interval = float(
|
||
config_entry.options.get(CONF_SAMPLING_INTERVAL, DEFAULT_SAMPLING_INTERVAL)
|
||
)
|
||
self._noise_events_threshold = int(
|
||
config_entry.options.get(
|
||
CONF_AUTO_TUNE_NOISE_EVENTS_THRESHOLD,
|
||
DEFAULT_AUTO_TUNE_NOISE_EVENTS_THRESHOLD,
|
||
)
|
||
)
|
||
self._current_program: str = "off"
|
||
self._time_remaining: float | None = None
|
||
self._total_duration: float | None = None
|
||
self._last_total_duration_update: datetime | None = None
|
||
self._cycle_progress: float = 0.0
|
||
self._smoothed_progress: float = 0.0 # Smoothed progress tracking for EMA
|
||
# Live projected total energy/cost for the running cycle (None until a
|
||
# reliable progress estimate exists). Derived from accumulated energy and
|
||
# the (ML-blended) progress fraction; surfaced as progress-sensor attrs.
|
||
self._projected_energy_wh: float | None = None
|
||
self._projected_cost: float | None = None
|
||
# Runtime overrun anomaly (soft, visible; never a notification). "none"
|
||
# or "overrun" once a running cycle exceeds its matched profile's expected
|
||
# duration by CYCLE_OVERRUN_ANOMALY_RATIO. Surfaced as a state-sensor attr
|
||
# and frozen onto the cycle at end for panel badging.
|
||
self._cycle_anomaly: str = "none"
|
||
self._overrun_ratio: float = 0.0
|
||
# Post-cycle anomaly cache: holds energy/underrun anomaly from the last
|
||
# completed cycle so sensor attributes can surface them after idle.
|
||
self._last_cycle_post_anomaly: dict = {}
|
||
self._cycle_completed_time: datetime | None = None # Track when cycle finished
|
||
self._progress_reset_delay: int = int(
|
||
progress_reset_delay
|
||
) # Reset progress after idle
|
||
self._last_reading_time: datetime | None = None
|
||
self._current_power: float = 0.0
|
||
self._last_estimate_time: datetime | None = None
|
||
self._last_match_ambiguous: bool = False
|
||
self._matched_profile_duration: float | None = None
|
||
self._last_match_confidence: float = 0.0
|
||
# Sample interval tracking (seconds) for adaptive timing
|
||
# Profile matching duration tolerance (0.25 = ±25%)
|
||
self._profile_duration_tolerance: float = float(
|
||
config_entry.options.get("profile_duration_tolerance", 0.25)
|
||
)
|
||
|
||
|
||
self._remove_maintenance_scheduler = None
|
||
self._remove_ml_training_scheduler = None
|
||
self._ml_training_failures = 0 # consecutive gate failures for auto-disable
|
||
self._ml_training_running = False # True while a training run is in flight
|
||
self._profile_sample_repair_stats: dict[str, int] | None = None
|
||
|
||
self._last_suggestion_update: datetime | None = None
|
||
|
||
# Pump Monitor state
|
||
self._pump_stuck_duration: int = int(
|
||
config_entry.options.get(CONF_PUMP_STUCK_DURATION, DEFAULT_PUMP_STUCK_DURATION)
|
||
)
|
||
self._pump_stuck: bool = False # True once the stuck threshold has fired for this cycle
|
||
|
||
self._manual_program_active: bool = False
|
||
self._notified_start: bool = False
|
||
self._notified_pre_completion: bool = False
|
||
self._last_match_result: Any = None # Stores full MatchResult object
|
||
self._score_history: dict[str, list[float]] = {} # Tracks recent scores for trend analysis
|
||
self._match_persistence_counter: dict[str, int] = {} # Tracks consecutive matches
|
||
self._unmatch_persistence_counter: int = 0 # Tracks consecutive low-confidence matches
|
||
self._current_match_candidate: str | None = None # Pending profile name
|
||
|
||
async def _async_perform_combined_matching(
|
||
self, readings: list[tuple[datetime, float]]
|
||
) -> None:
|
||
"""PRIMARY matching task: Updates both Manager and Detector using best method."""
|
||
self._logger.debug(
|
||
"Matching trigger: readings=%d, task_exists=%s",
|
||
len(readings) if readings else 0,
|
||
getattr(self, "_matching_task", None) is not None
|
||
)
|
||
# Prevent concurrent matching tasks
|
||
current_task = self._matching_task
|
||
if current_task is not None and not current_task.done():
|
||
self._logger.debug("Matching skipped: previous task still running")
|
||
return
|
||
|
||
try:
|
||
if not readings:
|
||
self._logger.debug("Matching skipped: no readings")
|
||
return
|
||
|
||
# Skip match entirely when no real profiles exist — nothing to match against.
|
||
if not self.profile_store.has_real_profiles:
|
||
self._logger.debug("Matching skipped: no real profiles configured yet")
|
||
return
|
||
|
||
self._matching_task = self.hass.async_create_task(self._async_do_perform_matching(readings))
|
||
except Exception as e:
|
||
self._logger.error("Perform combined matching trigger failed: %s", e)
|
||
|
||
async def _async_do_perform_matching(self, readings: list[tuple[datetime, float]]) -> None:
|
||
"""Inner task to handle actual matching logic."""
|
||
try:
|
||
end_time = readings[-1][0]
|
||
start_time = readings[0][0]
|
||
current_duration = (end_time - start_time).total_seconds()
|
||
|
||
# 1. RUN BETTER ASYNC MATCHING
|
||
result = await self.profile_store.async_match_profile(
|
||
readings,
|
||
current_duration
|
||
)
|
||
|
||
# 2. UPDATE MANAGER STATE (Estimates, Program Name, etc.)
|
||
self._last_match_result = result
|
||
self._last_match_ambiguous = result.is_ambiguous
|
||
|
||
profile_name = result.best_profile
|
||
confidence = result.confidence
|
||
matched_duration = result.expected_duration
|
||
phase_name = result.matched_phase
|
||
|
||
# --- Switching Logic (Temporal Persistence) ---
|
||
should_switch = False
|
||
switch_reason = ""
|
||
|
||
# Identify current program score from results
|
||
current_program_score = 0.0
|
||
for c in result.candidates:
|
||
if c.get("name") == self._current_program:
|
||
current_program_score = c.get("score", 0.0)
|
||
break
|
||
|
||
# CASE: Divergence Detection (Score Drop)
|
||
# If current matched program has a significant drop from its own peak score,
|
||
# we should consider unmatching it even if it's still the "best" candidate.
|
||
if (
|
||
self._current_program not in ("detecting...", "off", "starting", "unknown")
|
||
and profile_name == self._current_program
|
||
):
|
||
history: list[float] = self._score_history.get(self._current_program, [])
|
||
if len(history) > 3:
|
||
peak_score = max(history)
|
||
# If score drops by more than 40% from peak AND is below threshold, unmatch.
|
||
# This catches divergence faster than waiting for fixed unmatch_threshold.
|
||
if confidence < peak_score * (1.0 - DEFAULT_MATCH_REVERT_RATIO):
|
||
self._unmatch_persistence_counter += 1
|
||
if self._unmatch_persistence_counter >= self._match_persistence:
|
||
self._current_program = "detecting..."
|
||
self._matched_profile_duration = None
|
||
self._unmatch_persistence_counter = 0
|
||
self._logger.info(
|
||
"Divergence detected for profile '%s' (confidence %.3f < 60%% of peak %.3f). "
|
||
"Reverting to detection.",
|
||
profile_name, confidence, peak_score
|
||
)
|
||
# Reset profile_name so Case 3 doesn't re-trigger
|
||
profile_name = "detecting..."
|
||
|
||
# Update persistence for the best profile
|
||
if profile_name and profile_name != "detecting...":
|
||
self._match_persistence_counter[profile_name] = self._match_persistence_counter.get(profile_name, 0) + 1
|
||
|
||
# Check if this is the same candidate as before
|
||
if profile_name != self._current_match_candidate:
|
||
# Reset counter for old candidate if it wasn't locked in
|
||
self._current_match_candidate = profile_name
|
||
self._match_persistence_counter[profile_name] = 1
|
||
else:
|
||
self._current_match_candidate = None
|
||
|
||
is_persistent = profile_name and self._match_persistence_counter.get(profile_name, 0) >= self._match_persistence
|
||
|
||
# --- Live-match features: compute always for ranking history + ML gate ---
|
||
# Features are cheap scalars derived from the current trace. We compute
|
||
# them whenever there is a non-ambiguous candidate so they can be recorded
|
||
# as a training snapshot regardless of whether ML models are opted in.
|
||
# The opt-in ML commit check then uses the same features when enabled.
|
||
ml_commit_score: float | None = None
|
||
_live_feat: dict[str, float] | None = None
|
||
_top2_score: float | None = None
|
||
if (
|
||
profile_name
|
||
and profile_name != "detecting..."
|
||
and not result.is_ambiguous
|
||
and self._current_program in ("detecting...",)
|
||
):
|
||
try:
|
||
from .ml.feature_extraction import live_match_features # noqa: PLC0415
|
||
|
||
first_ts = readings[0][0]
|
||
pts = [
|
||
((ts - first_ts).total_seconds(), float(pw))
|
||
for ts, pw in readings
|
||
]
|
||
top1_dist = max(0.0, 1.0 - confidence)
|
||
top2_raw: float | None = None
|
||
if len(result.candidates) > 1:
|
||
s2 = result.candidates[1].get("score", 0.0) or 0.0
|
||
top2_raw = max(0.0, 1.0 - float(s2))
|
||
_top2_score = float(result.candidates[1].get("score", 0.0) or 0.0)
|
||
n_profiles = len(self.profile_store.get_profiles())
|
||
_live_feat = live_match_features(
|
||
points=pts,
|
||
elapsed_s=current_duration,
|
||
top1_distance=top1_dist,
|
||
top2_distance=top2_raw,
|
||
top1_median_duration_s=float(result.expected_duration or 0),
|
||
candidate_count=max(1, n_profiles),
|
||
)
|
||
# --- ML early-commit gate (opt-in) ---
|
||
# When the user has opted into experimental ML models, query the
|
||
# live_match_commit model for P(top-1 is correct).
|
||
try:
|
||
from .ml.engine import ml_models_enabled, resolve_scorer # noqa: PLC0415
|
||
if ml_models_enabled(self.config_entry.options):
|
||
match_fn, _ = resolve_scorer("live_match", self.profile_store)
|
||
if match_fn is not None:
|
||
ml_commit_score = float(match_fn(_live_feat))
|
||
self._logger.debug(
|
||
"Live-match ML commit score for '%s': %.3f (threshold %.2f)",
|
||
profile_name,
|
||
ml_commit_score,
|
||
ML_MATCH_COMMIT_THRESHOLD,
|
||
)
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
except Exception: # noqa: BLE001 - ML must never break matching
|
||
pass
|
||
|
||
# --- Record ranking snapshot for live_match on-device training ---
|
||
# Snapshot is recorded unconditionally (not gated on ML opt-in) so that
|
||
# training data accumulates even before the user enables ML models.
|
||
# Confirmed labels are back-filled at cycle end.
|
||
if _live_feat is not None and self._cycle_start_time:
|
||
try:
|
||
self.profile_store.record_match_ranking_snapshot(
|
||
start_time_iso=self._cycle_start_time.isoformat(),
|
||
features=_live_feat,
|
||
top1_profile=profile_name or "",
|
||
top1_score=float(confidence),
|
||
top2_score=_top2_score,
|
||
candidate_count=max(1, len(self.profile_store.get_profiles())),
|
||
cycle_id=self._ranking_snapshot_cycle_id,
|
||
)
|
||
except Exception: # noqa: BLE001 - never break matching
|
||
pass
|
||
|
||
ml_early_commit = (
|
||
ml_commit_score is not None
|
||
and ml_commit_score >= ML_MATCH_COMMIT_THRESHOLD
|
||
and confidence >= 0.30
|
||
)
|
||
|
||
# Case 1: Initial Match from "detecting..."
|
||
if (
|
||
profile_name
|
||
and confidence >= 0.15
|
||
and (not result.is_ambiguous or is_persistent)
|
||
and (not self._matched_profile_duration or self._current_program == "detecting...")
|
||
):
|
||
if is_persistent:
|
||
should_switch = True
|
||
switch_reason = f"initial_match (persistent {self._match_persistence_counter[profile_name]}x)"
|
||
elif ml_early_commit:
|
||
should_switch = True
|
||
switch_reason = (
|
||
f"initial_match (ML commit score {ml_commit_score:.3f} >= {ML_MATCH_COMMIT_THRESHOLD})"
|
||
)
|
||
else:
|
||
self._logger.debug(
|
||
"Match persistence: %s at %d/%d matches. Stay at detecting...",
|
||
profile_name, self._match_persistence_counter.get(profile_name, 0), self._match_persistence
|
||
)
|
||
|
||
# Case 2: Mid-cycle override (different profile)
|
||
elif (
|
||
profile_name
|
||
and self._current_program != profile_name
|
||
and self._current_program not in ("detecting...", "off", "starting", "unknown")
|
||
):
|
||
# High Confidence Override: Bypass persistence if match is VERY strong
|
||
if confidence > 0.8 and (confidence - current_program_score) > 0.15:
|
||
should_switch = True
|
||
switch_reason = f"high_confidence_override ({confidence:.3f} vs {current_program_score:.3f})"
|
||
|
||
# Normal Switch: Requires persistence AND either better score + trend
|
||
elif is_persistent:
|
||
if confidence > current_program_score and self._analyze_trend(profile_name):
|
||
# Add a minimum score gap for mid-cycle switching (0.05) to prevent flapping
|
||
if (confidence - current_program_score) > 0.05:
|
||
should_switch = True
|
||
switch_reason = f"positive_trend_persistent ({confidence:.3f} > {current_program_score:.3f})"
|
||
|
||
# Case 3: Unmatching (confidence drop)
|
||
elif (
|
||
self._current_program not in ("detecting...", "off", "starting", "unknown")
|
||
and profile_name == self._current_program
|
||
and confidence < self._unmatch_threshold
|
||
):
|
||
self._unmatch_persistence_counter += 1
|
||
is_unmatch_persistent = self._unmatch_persistence_counter >= self._match_persistence
|
||
|
||
if is_unmatch_persistent:
|
||
self._current_program = "detecting..."
|
||
self._matched_profile_duration = None
|
||
self._unmatch_persistence_counter = 0
|
||
self._logger.info(
|
||
"Unmatched profile '%s' (confidence %.3f < threshold %.3f persistent %dx). "
|
||
"Reverting to detection.",
|
||
profile_name,
|
||
confidence,
|
||
self._unmatch_threshold,
|
||
self._match_persistence
|
||
)
|
||
else:
|
||
self._logger.debug(
|
||
"Unmatch persistence: %s at %d/%d low-confidence matches. Stay at %s...",
|
||
profile_name, self._unmatch_persistence_counter, self._match_persistence, profile_name
|
||
)
|
||
|
||
# Reset unmatch counter if confidence is healthy
|
||
# AND we didn't just detect a divergence
|
||
elif (
|
||
profile_name == self._current_program
|
||
and confidence >= self._unmatch_threshold
|
||
and not (len(self._score_history.get(self._current_program, [])) > 3 and confidence < max(self._score_history[self._current_program]) * (1.0 - DEFAULT_MATCH_REVERT_RATIO))
|
||
):
|
||
self._unmatch_persistence_counter = 0
|
||
|
||
if should_switch:
|
||
if profile_name is None:
|
||
self._current_program = "detecting..."
|
||
else:
|
||
self._current_program = profile_name
|
||
self._last_match_confidence = confidence
|
||
self._unmatch_persistence_counter = 0 # Reset on switch
|
||
if profile_name in self._match_persistence_counter:
|
||
self._match_persistence_counter[profile_name] = self._match_persistence # Lock it in
|
||
|
||
avg_duration = float(matched_duration)
|
||
self._matched_profile_duration = avg_duration if avg_duration > 0 else None
|
||
self._logger.info(
|
||
"Switching to profile '%s' (reason: %s). Expected duration: %.0fs (%smin)",
|
||
profile_name, switch_reason, avg_duration, int(avg_duration / 60),
|
||
)
|
||
elif profile_name == self._current_program:
|
||
# Same program, but update confidence for sensors
|
||
self._last_match_confidence = confidence
|
||
elif not self._matched_profile_duration:
|
||
self._current_program = "detecting..."
|
||
|
||
self._last_estimate_time = dt_util.now()
|
||
|
||
# Update score history for all candidates to track trends
|
||
for cand in result.candidates:
|
||
cname = cand.get("name")
|
||
if cname:
|
||
history = self._score_history.setdefault(cname, [])
|
||
history.append(float(cand.get("score", 0.0)))
|
||
if len(history) > 20:
|
||
history.pop(0)
|
||
|
||
# Note: _update_remaining_only() and notify move to end of flow
|
||
|
||
# 3. UPDATE DETECTOR (Envelopes, Deferral, State Transitions)
|
||
current_matched = self.detector.matched_profile
|
||
verified_pause = getattr(self.detector, "_verified_pause", False)
|
||
current_power = readings[-1][1] if readings else 0.0
|
||
|
||
# --- Envelope Verification for Mismatches & Pauses ---
|
||
# Check alignment if we have a match and power is low, to confirm if
|
||
# this is a legitimate (auto-detected) pause or a mismatch. Skipped
|
||
# while the user has explicitly paused (issue #306): the user pause is
|
||
# authoritative and must not be re-judged by the envelope heuristic
|
||
# (see the verified_pause override below).
|
||
stop_thresh = float(self.detector.config.stop_threshold_w)
|
||
if current_matched and current_power < stop_thresh and not self._is_user_paused:
|
||
formatted = power_data_to_offsets(cast(list[list[Any] | tuple[Any, ...]], readings))
|
||
try:
|
||
profile_store_any = cast(Any, self.profile_store)
|
||
verify_alignment = profile_store_any.async_verify_alignment
|
||
is_confirmed, mapped_time, _ = (
|
||
await verify_alignment(current_matched, formatted)
|
||
)
|
||
except Exception as e: # pylint: disable=broad-exception-caught
|
||
self._logger.error(
|
||
"Alignment verification crashed for profile %s: %s",
|
||
current_matched, e, exc_info=True
|
||
)
|
||
is_confirmed = False
|
||
mapped_time = 0.0
|
||
|
||
if is_confirmed:
|
||
if not verified_pause:
|
||
self._logger.info(
|
||
"Envelope verified expected low power phase for %s. Enabling verified pause.",
|
||
current_matched
|
||
)
|
||
verified_pause = True
|
||
# Smart Termination within Envelope block
|
||
try:
|
||
profile = self.profile_store.get_profile(current_matched)
|
||
if profile:
|
||
avg_dur = profile.get("avg_duration", 0)
|
||
if avg_dur > 0 and (mapped_time / avg_dur) > 0.95:
|
||
verified_pause = False
|
||
self._logger.info("Smart Termination: Near end of profile. Releasing pause lock.")
|
||
except Exception as e:
|
||
self._logger.debug("Smart Termination alignment verification failed: %s", e)
|
||
else:
|
||
if verified_pause:
|
||
self._logger.info(
|
||
"Envelope indicates UNEXPECTED low power for %s. Disabling verified pause.",
|
||
current_matched
|
||
)
|
||
verified_pause = False
|
||
|
||
# --- High Power Clear ---
|
||
stop_threshold = getattr(self.detector.config, "stop_threshold_w", 5.0)
|
||
|
||
if current_power > stop_threshold * 10:
|
||
verified_pause = False
|
||
|
||
# A user-initiated pause (Pause Cycle button, or the door-open soft
|
||
# pause) stays in force until the user resumes (issue #306). The
|
||
# heuristics above only govern *auto-detected* low-power phases; without
|
||
# this override they clear verified_pause and the cycle is finalized
|
||
# (leaving the "Paused by user" state, e.g. a dishwasher closes at the
|
||
# 1 h min-off-gap timeout) instead of waiting for Resume. Re-asserting
|
||
# here also repairs the flag after a restart, since the detector state
|
||
# snapshot does not persist _verified_pause.
|
||
if self._is_user_paused:
|
||
verified_pause = True
|
||
|
||
# --- Consistency Override ---
|
||
# If envelope verified or mismatched, ensure manager program matches
|
||
if profile_name != self._current_program and (verified_pause or result.is_confident_mismatch):
|
||
if profile_name:
|
||
self._current_program = profile_name
|
||
self._last_match_confidence = confidence
|
||
# Try to fetch duration if we switched back to matched
|
||
try:
|
||
prof = self.profile_store.get_profile(profile_name)
|
||
if prof:
|
||
self._matched_profile_duration = float(prof.get("avg_duration", 0))
|
||
except Exception as e:
|
||
self._logger.debug("Failed to fetch profile duration on switch: %s", e)
|
||
else:
|
||
self._current_program = "detecting..."
|
||
self._matched_profile_duration = None
|
||
|
||
# --- HEURISTICS (Descriptive Phases) ---
|
||
if not phase_name:
|
||
if self.device_type == "dishwasher" and self.detector.is_waiting_low_power():
|
||
phase_name = "Drying"
|
||
elif self.device_type == "washing_machine" and current_power > 200:
|
||
phase_name = "Spinning"
|
||
elif self.device_type == "washing_machine" and self.detector.is_waiting_low_power():
|
||
phase_name = "Rinsing/Soaking"
|
||
|
||
# Push updates to detector
|
||
self.detector.set_verified_pause(verified_pause)
|
||
self.detector.update_match(
|
||
(profile_name, confidence, matched_duration, phase_name,
|
||
result.is_confident_mismatch, result.is_ambiguous,
|
||
result.is_prefix_ambiguous)
|
||
)
|
||
|
||
# --- LOGGING (Unified) ---
|
||
self._logger.info(
|
||
"Profile match attempt: name=%s, confidence=%.3f, duration=%.0fs, samples=%d",
|
||
profile_name, confidence, current_duration, len(readings),
|
||
)
|
||
|
||
self._update_remaining_only()
|
||
|
||
# --- START NOTIFICATION LOGIC ---
|
||
# Fallback for restart-recovery: fires only if the immediate notification in
|
||
# _on_state_change was missed (e.g., HA restarted mid-cycle before snapshot).
|
||
if not getattr(self, "_notified_start", False):
|
||
if self._notify_fire_events and not self._start_event_fired:
|
||
self.hass.bus.async_fire(
|
||
EVENT_CYCLE_STARTED,
|
||
{
|
||
"entry_id": self.entry_id,
|
||
"device_name": self.config_entry.title,
|
||
"device_type": self.device_type,
|
||
"program": self._current_program,
|
||
"start_time": (
|
||
self._cycle_start_time or dt_util.now()
|
||
).isoformat(),
|
||
},
|
||
)
|
||
self._start_event_fired = True
|
||
|
||
if self._notify_start_services or self._notify_actions:
|
||
msg_template = self.config_entry.options.get(
|
||
CONF_NOTIFY_START_MESSAGE, DEFAULT_NOTIFY_START_MESSAGE
|
||
)
|
||
msg = self._safe_format_template(
|
||
msg_template,
|
||
fallback_template=DEFAULT_NOTIFY_START_MESSAGE,
|
||
device=self.config_entry.title,
|
||
program=self._current_program,
|
||
)
|
||
# B4: append a peak-rate advisory tip when the current price is
|
||
# at/above the configured threshold. Purely informational.
|
||
tip = self._peak_rate_tip(
|
||
self.config_entry.options, self._resolve_energy_price()
|
||
)
|
||
if tip:
|
||
msg = f"{msg}\n{tip}"
|
||
|
||
self._dispatch_notification(
|
||
msg,
|
||
event_type=NOTIFY_EVENT_START,
|
||
extra_vars={
|
||
"program": self._current_program,
|
||
"tag": self._lifecycle_tag,
|
||
},
|
||
)
|
||
self._notified_start = True
|
||
self._logger.info("Sent start notification for program '%s'", self._current_program)
|
||
|
||
# Ensure pre-completion notifications never precede cycle-start signaling.
|
||
self._check_pre_completion_notification()
|
||
|
||
self._check_live_progress_notification()
|
||
self._notify_update()
|
||
|
||
except Exception as e:
|
||
self._logger.error("Perform combined matching failed: %s", e, exc_info=True)
|
||
|
||
@property
|
||
def top_candidates(self) -> list[dict[str, Any]]:
|
||
"""Return a lightweight list of top candidates from the last match."""
|
||
if not self._last_match_result:
|
||
return []
|
||
|
||
# Get raw list from ranking (best) or candidates
|
||
raw_list: list[dict[str, Any]] = []
|
||
if hasattr(self._last_match_result, "ranking") and self._last_match_result.ranking:
|
||
raw_list = self._last_match_result.ranking
|
||
elif hasattr(self._last_match_result, "candidates"):
|
||
raw_list = self._last_match_result.candidates
|
||
|
||
# SANITIZE: Remove heavy power arrays before sending to Home Assistant attributes
|
||
return _sanitize_ranking(raw_list)
|
||
|
||
@property
|
||
def phase_description(self) -> str:
|
||
"""Return a description of the current phase.
|
||
|
||
Prefers the *functional* progress-driven phase (the visual per-profile
|
||
phase configurator's ranges, indexed by the live ML-blended progress) so
|
||
the readout stays accurate even when a cycle runs longer/shorter than the
|
||
profile's nominal timeline. Falls back to the matcher's phase, then the
|
||
detector sub-state/state.
|
||
"""
|
||
live = self._current_phase_from_progress()
|
||
if live:
|
||
return live
|
||
if self._last_match_result and self._last_match_result.matched_phase:
|
||
return self._last_match_result.matched_phase
|
||
if self.detector.sub_state:
|
||
return self.detector.sub_state
|
||
return self.detector.state
|
||
|
||
def _current_phase_from_progress(self) -> str | None:
|
||
"""Live phase from the profile's configured ranges + ML-blended progress.
|
||
|
||
This is the *merge* of the visual phase configurator with the runtime
|
||
estimator: one phase definition (the per-profile ranges the user draws),
|
||
indexed by the smoothed progress fraction rather than raw elapsed seconds,
|
||
so overrun/underrun cycles still name the phase correctly. Returns None
|
||
(caller falls back) when not running, no profile is matched, or the
|
||
profile has no configured phase ranges. Never raises.
|
||
"""
|
||
return progress_mod.current_phase(
|
||
self.profile_store,
|
||
self.detector.state,
|
||
self._current_program,
|
||
self._cycle_progress,
|
||
)
|
||
|
||
@property
|
||
def match_ambiguity(self) -> bool:
|
||
"""Return True if the last match was ambiguous."""
|
||
if self._last_match_result and hasattr(self._last_match_result, "is_ambiguous"):
|
||
return self._last_match_result.is_ambiguous
|
||
return False
|
||
|
||
@property
|
||
def last_ambiguity_margin(self) -> float | None:
|
||
"""Return the score margin between top-1 and top-2 candidates, or None."""
|
||
result = self._last_match_result
|
||
if result is None:
|
||
return None
|
||
return getattr(result, "ambiguity_margin", None)
|
||
|
||
# Note: last_match_details property is defined later in the class
|
||
# It returns MatchResult from _last_match_result
|
||
async def _attempt_state_restoration(self) -> None:
|
||
"""Attempt to restore active cycle state from storage."""
|
||
active_snapshot = self.profile_store.get_active_cycle()
|
||
|
||
# Check current power state first
|
||
state = self.hass.states.get(self.power_sensor_entity_id)
|
||
current_power = 0.0
|
||
power_is_valid = False
|
||
|
||
if state and state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE):
|
||
try:
|
||
current_power = float(state.state)
|
||
power_is_valid = True
|
||
except (ValueError, TypeError):
|
||
# Power sensor state is not numeric during restoration; treat as 0W
|
||
self._logger.debug(
|
||
"Power sensor %s state %r is not numeric during restoration; "
|
||
"treating as 0W and not restoring by power",
|
||
self.power_sensor_entity_id,
|
||
getattr(state, "state", None),
|
||
)
|
||
|
||
should_restore = False
|
||
active_snapshot_to_restore: dict[str, Any] | None = (
|
||
active_snapshot if isinstance(active_snapshot, dict) else None
|
||
)
|
||
|
||
# Helper to check if a snapshot is viable
|
||
def is_viable_restore(last_save_time: datetime) -> bool:
|
||
now = dt_util.now()
|
||
# Handle timezone mismatch gracefully
|
||
if last_save_time.tzinfo is None:
|
||
# Assume naive means local system time, convert to aware
|
||
last_save_time = last_save_time.replace(tzinfo=now.tzinfo)
|
||
|
||
age = (now - last_save_time).total_seconds()
|
||
|
||
# Unconditional restore window (30 mins)
|
||
if age < 1800:
|
||
return True
|
||
# Extended window if power is confirmed HIGH (60 mins)
|
||
if (
|
||
age < 3600
|
||
and power_is_valid
|
||
and current_power >= self._config.min_power
|
||
):
|
||
return True
|
||
return False
|
||
|
||
last_save = self.profile_store.get_last_active_save()
|
||
if last_save and last_save.tzinfo is None:
|
||
# Normalize naive legacy timestamps to system time
|
||
last_save = last_save.replace(tzinfo=dt_util.now().tzinfo)
|
||
|
||
if active_snapshot_to_restore is not None and last_save and is_viable_restore(last_save):
|
||
should_restore = True
|
||
age = (dt_util.now() - last_save).total_seconds()
|
||
age = (dt_util.now() - last_save).total_seconds()
|
||
self._logger.info(
|
||
"Found recently saved active cycle (last_save=%s, age=%.0fs), restoring...",
|
||
last_save,
|
||
age
|
||
)
|
||
# strict extension logic unless the user wants to enforce it.
|
||
active_snapshot_to_restore["sub_state"] = (
|
||
active_snapshot_to_restore.get("sub_state") or "Restored"
|
||
)
|
||
# NOTE: We disable dynamic min duration enforcement on recovery since we
|
||
# might have missed data
|
||
active_snapshot_to_restore["dynamic_min_duration"] = None
|
||
|
||
# FALLBACK: Resurrection Logic
|
||
if not should_restore:
|
||
past_cycles = self.profile_store.get_past_cycles()
|
||
if past_cycles:
|
||
last_cycle = past_cycles[-1]
|
||
last_end_str = last_cycle.get("end_time")
|
||
if last_end_str:
|
||
last_end = dt_util.parse_datetime(last_end_str)
|
||
if last_end:
|
||
gap = (dt_util.now() - last_end).total_seconds()
|
||
is_recent = gap < 1200 # 20 mins
|
||
status = last_cycle.get("status")
|
||
|
||
if is_recent and status != "completed":
|
||
self._logger.info(
|
||
"Found recent interrupted cycle in history "
|
||
"(id=%s, gap=%.0fs). Resurrecting...",
|
||
last_cycle["id"],
|
||
gap,
|
||
)
|
||
try:
|
||
power_data = decompress_power_data(last_cycle)
|
||
if power_data:
|
||
# decompress_power_data returns (offset_seconds, watts)
|
||
# tuples, but restore_state_snapshot parses reading[0]
|
||
# as an ISO datetime. Convert offsets to absolute ISO
|
||
# timestamps (base = cycle start) so the resurrected
|
||
# trace is not silently dropped (B4).
|
||
_res_start = dt_util.parse_datetime(
|
||
last_cycle["start_time"]
|
||
)
|
||
if _res_start is not None:
|
||
if _res_start.tzinfo is None:
|
||
_res_start = _res_start.replace(
|
||
tzinfo=dt_util.now().tzinfo
|
||
)
|
||
power_readings = [
|
||
(
|
||
(
|
||
_res_start
|
||
+ timedelta(seconds=float(off))
|
||
).isoformat(),
|
||
p,
|
||
)
|
||
for off, p in power_data
|
||
]
|
||
else:
|
||
power_readings = power_data
|
||
active_snapshot_to_restore = {
|
||
# Reconstruct basic running state
|
||
"state": "running",
|
||
"sub_state": "Resurrected",
|
||
"current_cycle_start": last_cycle["start_time"],
|
||
"last_active_time": last_cycle["end_time"],
|
||
"low_power_start": None,
|
||
"cycle_max_power": (
|
||
max([p for _, p in power_data])
|
||
if power_data
|
||
else 0
|
||
),
|
||
"power_readings": power_readings,
|
||
"ma_buffer": (
|
||
[p for _, p in power_data[-10:]]
|
||
if power_data
|
||
else []
|
||
),
|
||
"end_condition_count": 0,
|
||
"extension_count": 0,
|
||
"dynamic_min_duration": None,
|
||
"matched_profile": last_cycle.get(
|
||
"profile_name"
|
||
),
|
||
}
|
||
should_restore = True
|
||
past_cycles.pop()
|
||
await self.profile_store.async_save()
|
||
except Exception as e:
|
||
self._logger.error("Failed to resurrect cycle: %s", e)
|
||
|
||
if should_restore and active_snapshot_to_restore:
|
||
try:
|
||
# A cycle paused by the user while still in STARTING is promoted
|
||
# to PAUSED before restoration so that (a) the false-start abort
|
||
# cannot fire on the first low-power reading, and (b) the PAUSED
|
||
# branch of the restore block below re-applies the user-pause state
|
||
# and re-asserts verified_pause (issue #306).
|
||
if (
|
||
active_snapshot_to_restore.get("state") == STATE_STARTING
|
||
and active_snapshot_to_restore.get("is_user_paused")
|
||
):
|
||
active_snapshot_to_restore = {
|
||
**active_snapshot_to_restore,
|
||
"state": STATE_PAUSED,
|
||
}
|
||
|
||
self.detector.restore_state_snapshot(active_snapshot_to_restore)
|
||
|
||
# Restore if in any active state (Running, Paused, Ending)
|
||
if self.detector.state in (STATE_RUNNING, STATE_PAUSED, STATE_ENDING):
|
||
# Restore manual program flag if present
|
||
self._manual_program_active = active_snapshot_to_restore.get(
|
||
"manual_program", False
|
||
)
|
||
|
||
# Restore the external energy-meter snapshot (issue #316) so a
|
||
# restart mid-cycle keeps an accurate start->end delta.
|
||
self._energy_meter_start = active_snapshot_to_restore.get(
|
||
"energy_meter_start"
|
||
)
|
||
self._energy_meter_source = active_snapshot_to_restore.get(
|
||
"energy_meter_source"
|
||
)
|
||
|
||
# If we restored into a low-power state, ensure we don't
|
||
# immediately quit. For now we just log this; the cycle
|
||
# detector's off_delay will handle actual shutdown.
|
||
if power_is_valid and current_power < self._config.min_power:
|
||
self._logger.debug(
|
||
"Restored active cycle in low-power state "
|
||
"(power=%.2fW < min_power=%.2fW); waiting for "
|
||
"detector off_delay before marking as finished",
|
||
current_power,
|
||
self._config.min_power,
|
||
)
|
||
|
||
if self.detector.matched_profile:
|
||
self._current_program = self.detector.matched_profile
|
||
self._logger.info(
|
||
"Restored/Resurrected washer cycle with profile: %s",
|
||
self._current_program,
|
||
)
|
||
else:
|
||
self._current_program = "detecting..."
|
||
|
||
# Restore persisted start-notification/event flags from snapshot.
|
||
self._notified_start = bool(
|
||
active_snapshot_to_restore.get("notified_start", False)
|
||
)
|
||
self._start_event_fired = bool(
|
||
active_snapshot_to_restore.get("start_event_fired", False)
|
||
)
|
||
|
||
# Restore user-pause state from snapshot.
|
||
self._is_user_paused = bool(
|
||
active_snapshot_to_restore.get("is_user_paused", False)
|
||
)
|
||
_pause_start_raw = active_snapshot_to_restore.get("user_pause_start")
|
||
self._user_pause_start = (
|
||
dt_util.parse_datetime(_pause_start_raw)
|
||
if isinstance(_pause_start_raw, str) and _pause_start_raw
|
||
else None
|
||
)
|
||
self._total_user_paused_seconds = float(
|
||
active_snapshot_to_restore.get("total_user_paused_seconds", 0.0)
|
||
)
|
||
# get_state_snapshot() does not persist the detector's
|
||
# verified-pause flag, so a user-paused cycle would otherwise be
|
||
# finalized on the first ENDING timeout after a restart (issue
|
||
# #306). Re-assert it so the pause survives the reload.
|
||
if self._is_user_paused:
|
||
self.detector.set_verified_pause(True)
|
||
|
||
# Record the restart gap so the Cycles tab can shade it and
|
||
# anomaly detection can surface it. Only meaningful when
|
||
# last_save is known and the dark period exceeds 30 s.
|
||
# Matching always uses real readings only (Option B from the
|
||
# gap-fill analysis); synthetic fill is intentionally NOT added
|
||
# to _power_readings to prevent circular-bias inflation.
|
||
if last_save:
|
||
gap_end = dt_util.now()
|
||
gap_secs = (gap_end - last_save).total_seconds()
|
||
if gap_secs > 30:
|
||
self._restart_gaps.append({
|
||
"start_ts": last_save.isoformat(),
|
||
"end_ts": gap_end.isoformat(),
|
||
"gap_seconds": round(gap_secs, 1),
|
||
"profile": self.detector.matched_profile,
|
||
"match_confidence": getattr(
|
||
self.detector, "match_confidence", None
|
||
),
|
||
})
|
||
self._logger.info(
|
||
"HA restart gap recorded: %.0fs (%.1f min) in active cycle; "
|
||
"power trace has a hole — no synthetic fill (matching integrity)",
|
||
gap_secs,
|
||
gap_secs / 60,
|
||
)
|
||
|
||
self._start_watchdog()
|
||
else:
|
||
await self.profile_store.async_clear_active_cycle()
|
||
except Exception as err:
|
||
self._logger.warning("Failed to restore active cycle: %s, clearing", err)
|
||
await self.profile_store.async_clear_active_cycle()
|
||
else:
|
||
if last_save:
|
||
age = (dt_util.now() - last_save).total_seconds()
|
||
self._logger.info("Active cycle too stale (age=%.0fs), clearing", age)
|
||
await self.profile_store.async_clear_active_cycle()
|
||
|
||
async def async_setup(self) -> None:
|
||
"""Set up the manager."""
|
||
await self.profile_store.async_load()
|
||
try:
|
||
_trans = await translation.async_get_translations(
|
||
self.hass, self.hass.config.language, "options", {DOMAIN}
|
||
)
|
||
# Cache of manager-side fixed UI-string templates resolved from the
|
||
# options.error.* translation namespace (timer notifications, the
|
||
# duration-vs-typical finish variable, and the live "waiting" message).
|
||
# The inline English mirrors the strings.json values so the fallback is
|
||
# never the sole source and the code stays behaviour-identical in English.
|
||
self._timer_ui_strings = {
|
||
k: _trans.get(f"component.{DOMAIN}.options.error.{k}", v)
|
||
for k, v in {
|
||
"timer_default_message": "{device}: {minutes} min timer",
|
||
"timer_pause_action_title": "Resume Cycle",
|
||
"timer_pause_body_suffix": "The cycle is paused. Open the WashData panel to resume.",
|
||
"vs_typical_longer": "{pct}% longer than usual",
|
||
"vs_typical_shorter": "{pct}% shorter than usual",
|
||
"notify_live_waiting_message": "{device}: No profile matched yet.",
|
||
}.items()
|
||
}
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
# Apply configurable duration tolerance to profile store
|
||
try:
|
||
self.profile_store.set_duration_tolerance(self._profile_duration_tolerance)
|
||
self.profile_store.set_retention_limits(
|
||
max_past_cycles=int(
|
||
self.config_entry.options.get(
|
||
CONF_MAX_PAST_CYCLES, DEFAULT_MAX_PAST_CYCLES
|
||
)
|
||
),
|
||
max_full_traces_per_profile=int(
|
||
self.config_entry.options.get(
|
||
CONF_MAX_FULL_TRACES_PER_PROFILE,
|
||
DEFAULT_MAX_FULL_TRACES_PER_PROFILE,
|
||
)
|
||
),
|
||
max_full_traces_unlabeled=int(
|
||
self.config_entry.options.get(
|
||
CONF_MAX_FULL_TRACES_UNLABELED,
|
||
DEFAULT_MAX_FULL_TRACES_UNLABELED,
|
||
)
|
||
),
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
# Repair broken sample_cycle_id references (can happen after aggressive retention)
|
||
try:
|
||
stats = await self.profile_store.async_repair_profile_samples()
|
||
self._profile_sample_repair_stats = stats
|
||
if stats.get("profiles_repaired", 0) or stats.get(
|
||
"cycles_labeled_as_sample", 0
|
||
):
|
||
self._logger.warning(
|
||
"Repaired profile sample references for %s: %s",
|
||
self.entry_id,
|
||
stats,
|
||
)
|
||
await self.profile_store.async_save()
|
||
except Exception:
|
||
self._logger.exception(
|
||
"Failed repairing profile sample references for %s", self.entry_id
|
||
)
|
||
|
||
# Subscribe to power sensor updates
|
||
self._remove_listener = async_track_state_change_event(
|
||
self.hass, [self.power_sensor_entity_id], self._async_power_changed
|
||
)
|
||
|
||
# Attempt to restore state (BEFORE starting listener)
|
||
await self._attempt_state_restoration()
|
||
|
||
# Restore last cycle end time to ensure ghost cycle suppression works after restart
|
||
try:
|
||
cycles = self.profile_store.get_past_cycles()
|
||
if cycles:
|
||
# Find last completed cycle with a valid end time
|
||
for cycle in reversed(cycles):
|
||
if cycle.get("end_time") and cycle.get("status") == "completed":
|
||
ts = dt_util.parse_datetime(cycle["end_time"])
|
||
if ts:
|
||
self._last_cycle_end_time = ts
|
||
self._logger.debug("Restored last cycle end time: %s", ts)
|
||
break
|
||
except Exception: # pylint: disable=broad-exception-caught
|
||
self._logger.debug("Failed to restore last cycle end time")
|
||
|
||
# Load recorder state
|
||
await self.recorder.async_load()
|
||
|
||
# Force initial update from current state (in case it's already stable)
|
||
state = self.hass.states.get(self.power_sensor_entity_id)
|
||
if state and state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE):
|
||
try:
|
||
power = float(state.state)
|
||
now = dt_util.now()
|
||
self.detector.process_reading(power, now)
|
||
except (ValueError, TypeError):
|
||
pass
|
||
|
||
# Trigger migration/compression of old cycle format
|
||
# This is safe to run repeatedly (it skips already compressed cycles)
|
||
await self.profile_store.async_migrate_cycles_to_compressed()
|
||
|
||
# Backfill match_confidence for labeled cycles that predate the field
|
||
self.hass.async_create_task(
|
||
self.profile_store.async_backfill_match_confidence()
|
||
)
|
||
|
||
# Subscribe to external cycle end trigger (if enabled)
|
||
await self._setup_external_end_trigger()
|
||
|
||
# Subscribe to door sensor (if configured)
|
||
await self._setup_door_sensor_listener()
|
||
|
||
# Subscribe to person presence changes for notification gating
|
||
await self._setup_notify_people_listener()
|
||
|
||
# Register schedulers (maintenance + ML training). These are also re-
|
||
# registered on every config reload; calling them here ensures they
|
||
# survive HA restarts without requiring the user to re-save settings.
|
||
await self._setup_maintenance_scheduler()
|
||
self._setup_ml_training_scheduler()
|
||
|
||
def _load_notify_services(self, config_entry: ConfigEntry) -> None:
|
||
"""Load notification service lists, migrating legacy single-service config."""
|
||
self._notify_start_services = list(config_entry.options.get(CONF_NOTIFY_START_SERVICES, []) or [])
|
||
self._notify_finish_services = list(config_entry.options.get(CONF_NOTIFY_FINISH_SERVICES, []) or [])
|
||
self._notify_live_services = list(config_entry.options.get(CONF_NOTIFY_LIVE_SERVICES, []) or [])
|
||
raw_timers = config_entry.options.get(CONF_NOTIFY_CYCLE_TIMERS, []) or []
|
||
self._notify_cycle_timers = [
|
||
t for t in raw_timers
|
||
if isinstance(t, dict) and isinstance(t.get("offset_minutes"), (int, float)) and t["offset_minutes"] > 0
|
||
]
|
||
# Backward compat: migrate old single notify_service + notify_events to new per-event lists
|
||
if not (self._notify_start_services or self._notify_finish_services or self._notify_live_services):
|
||
_old_svc = config_entry.options.get(CONF_NOTIFY_SERVICE, "")
|
||
_old_events = list(config_entry.options.get(CONF_NOTIFY_EVENTS, []) or [])
|
||
if _old_svc:
|
||
if not _old_events or NOTIFY_EVENT_START in _old_events:
|
||
self._notify_start_services = [_old_svc]
|
||
if not _old_events or NOTIFY_EVENT_FINISH in _old_events:
|
||
self._notify_finish_services = [_old_svc]
|
||
if not _old_events or NOTIFY_EVENT_LIVE in _old_events:
|
||
self._notify_live_services = [_old_svc]
|
||
|
||
async def async_reload_config(self, config_entry: ConfigEntry) -> None:
|
||
"""
|
||
Reload configuration options without interrupting running cycle detection.
|
||
|
||
Updates detector config in-place.
|
||
Handles Power Sensor entity change by reconnecting listener.
|
||
"""
|
||
self._logger.info("Reloading configuration for %s", self.entry_id)
|
||
# Replace reference
|
||
self.config_entry = config_entry
|
||
|
||
# Check if power sensor changed
|
||
new_sensor = config_entry.options.get(
|
||
CONF_POWER_SENSOR, config_entry.data.get(CONF_POWER_SENSOR)
|
||
)
|
||
if new_sensor and new_sensor != self.power_sensor_entity_id:
|
||
# Block sensor changes when a cycle is active to prevent inconsistent state
|
||
d_state = self.detector.state
|
||
self._logger.debug(
|
||
"Reloading config: detector.state=%r (type=%s), RUNNING=%r",
|
||
d_state,
|
||
type(d_state),
|
||
STATE_RUNNING,
|
||
)
|
||
if d_state 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
|
||
# Remove old listener
|
||
if self._remove_listener:
|
||
self._remove_listener()
|
||
# Attach new listener
|
||
self._remove_listener = async_track_state_change_event(
|
||
self.hass, [self.power_sensor_entity_id], self._async_power_changed
|
||
)
|
||
# Force update from new sensor
|
||
state = self.hass.states.get(self.power_sensor_entity_id)
|
||
if state and state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE):
|
||
try:
|
||
power = float(state.state)
|
||
self.detector.process_reading(power, dt_util.now())
|
||
except ValueError:
|
||
self._logger.debug(
|
||
"Initial power value for %s after config reload is not numeric: %r",
|
||
self.power_sensor_entity_id,
|
||
state.state,
|
||
)
|
||
|
||
# Update device type
|
||
self.device_type = config_entry.options.get(
|
||
CONF_DEVICE_TYPE,
|
||
config_entry.data.get(CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE),
|
||
)
|
||
# Propagate to learning pipeline (captured at construction time)
|
||
self.learning_manager.device_type = self.device_type
|
||
self.learning_manager.suggestion_engine.device_type = self.device_type
|
||
|
||
# Update detector config in-place
|
||
old_min_power = self.detector.config.min_power
|
||
old_off_delay = self.detector.config.off_delay
|
||
old_smoothing = self.detector.config.smoothing_window
|
||
old_interrupted_min = self.detector.config.interrupted_min_seconds
|
||
|
||
# Get new values from config
|
||
new_min_power = float(
|
||
config_entry.options.get(CONF_MIN_POWER, DEFAULT_MIN_POWER)
|
||
)
|
||
new_off_delay = int(config_entry.options.get(CONF_OFF_DELAY, DEFAULT_OFF_DELAY))
|
||
new_smoothing = int(
|
||
config_entry.options.get(CONF_SMOOTHING_WINDOW, DEFAULT_SMOOTHING_WINDOW)
|
||
)
|
||
new_interrupted_min = int(
|
||
config_entry.options.get(
|
||
CONF_INTERRUPTED_MIN_SECONDS, DEFAULT_INTERRUPTED_MIN_SECONDS
|
||
)
|
||
)
|
||
self.detector.config.match_interval = int(
|
||
config_entry.options.get(
|
||
CONF_PROFILE_MATCH_INTERVAL, DEFAULT_PROFILE_MATCH_INTERVAL
|
||
)
|
||
)
|
||
self.profile_store.dtw_bandwidth = float(
|
||
config_entry.options.get(CONF_DTW_BANDWIDTH, DEFAULT_DTW_BANDWIDTH)
|
||
)
|
||
|
||
# Device default
|
||
dev_def = DEVICE_COMPLETION_THRESHOLDS.get(
|
||
self.device_type, DEFAULT_COMPLETION_MIN_SECONDS
|
||
)
|
||
new_completion_min = int(
|
||
config_entry.options.get(CONF_COMPLETION_MIN_SECONDS, dev_def)
|
||
)
|
||
|
||
new_start_threshold = float(
|
||
config_entry.options.get(
|
||
CONF_START_DURATION_THRESHOLD, DEFAULT_START_DURATION_THRESHOLD
|
||
)
|
||
)
|
||
new_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_delay_detect_enabled = bool(
|
||
config_entry.options.get(
|
||
CONF_DELAY_START_DETECT_ENABLED, DEFAULT_DELAY_START_DETECT_ENABLED
|
||
)
|
||
)
|
||
new_delay_confirm_seconds = float(
|
||
config_entry.options.get(
|
||
CONF_DELAY_CONFIRM_SECONDS, DEFAULT_DELAY_CONFIRM_SECONDS
|
||
)
|
||
)
|
||
new_delay_timeout_seconds = float(
|
||
config_entry.options.get(
|
||
CONF_DELAY_TIMEOUT_HOURS, DEFAULT_DELAY_TIMEOUT_HOURS
|
||
)
|
||
) * 3600.0
|
||
|
||
# Apply all detector config updates
|
||
self.detector.config.min_power = new_min_power
|
||
self.detector.config.off_delay = new_off_delay
|
||
self.detector.config.smoothing_window = new_smoothing
|
||
self.detector.config.interrupted_min_seconds = new_interrupted_min
|
||
self.detector.config.completion_min_seconds = new_completion_min
|
||
self.detector.config.start_duration_threshold = new_start_threshold
|
||
self.detector.config.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.delay_detect_enabled = new_delay_detect_enabled
|
||
self.detector.config.delay_confirm_seconds = new_delay_confirm_seconds
|
||
self.detector.config.delay_timeout_seconds = new_delay_timeout_seconds
|
||
|
||
# Pump Monitor setting
|
||
self._pump_stuck_duration = int(
|
||
config_entry.options.get(CONF_PUMP_STUCK_DURATION, DEFAULT_PUMP_STUCK_DURATION)
|
||
)
|
||
|
||
if (
|
||
old_min_power != new_min_power
|
||
or old_off_delay != new_off_delay
|
||
or old_smoothing != new_smoothing
|
||
or old_interrupted_min != new_interrupted_min
|
||
):
|
||
self._logger.info(
|
||
"Updated detector config: min_power %.1fW->%.1fW, off_delay %ds->%ds, "
|
||
"smoothing %d->%d, interrupted_min %ds->%ds",
|
||
old_min_power,
|
||
new_min_power,
|
||
old_off_delay,
|
||
new_off_delay,
|
||
old_smoothing,
|
||
new_smoothing,
|
||
old_interrupted_min,
|
||
new_interrupted_min,
|
||
)
|
||
|
||
# Update profile matching parameters
|
||
old_min_ratio, old_max_ratio = self.profile_store.get_duration_ratio_limits()
|
||
|
||
new_min_ratio = float(
|
||
config_entry.options.get(
|
||
CONF_PROFILE_MATCH_MIN_DURATION_RATIO,
|
||
DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO_BY_DEVICE.get(
|
||
self.device_type, DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO
|
||
),
|
||
)
|
||
)
|
||
new_max_ratio = float(
|
||
config_entry.options.get(
|
||
CONF_PROFILE_MATCH_MAX_DURATION_RATIO,
|
||
DEFAULT_PROFILE_MATCH_MAX_DURATION_RATIO,
|
||
)
|
||
)
|
||
|
||
if old_min_ratio != new_min_ratio or old_max_ratio != new_max_ratio:
|
||
self.profile_store.set_duration_ratio_limits(
|
||
min_ratio=new_min_ratio, max_ratio=new_max_ratio
|
||
)
|
||
# Keep the detector's copy in sync: _should_defer_finish() reads
|
||
# detector.config.min_duration_ratio, which otherwise keeps the
|
||
# construction-time value until a restart (diverging from the matcher).
|
||
self.detector.config.min_duration_ratio = new_min_ratio
|
||
self._logger.info(
|
||
"Updated duration ratios: min %.2f→%.2f, max %.2f→%.2f",
|
||
old_min_ratio,
|
||
new_min_ratio,
|
||
old_max_ratio,
|
||
new_max_ratio,
|
||
)
|
||
|
||
# Update match interval
|
||
old_interval = self._profile_match_interval
|
||
new_interval = int(
|
||
config_entry.options.get(
|
||
CONF_PROFILE_MATCH_INTERVAL, DEFAULT_PROFILE_MATCH_INTERVAL
|
||
)
|
||
)
|
||
if old_interval != new_interval:
|
||
self._profile_match_interval = new_interval
|
||
self._logger.info("Updated match interval: %ds→%ds", old_interval, new_interval)
|
||
|
||
# Update other configurable options
|
||
self._profile_duration_tolerance = float(
|
||
config_entry.options.get(
|
||
CONF_PROFILE_DURATION_TOLERANCE, DEFAULT_PROFILE_DURATION_TOLERANCE
|
||
)
|
||
)
|
||
|
||
|
||
# Update notification settings
|
||
self._load_notify_services(config_entry)
|
||
self._notify_actions = list(
|
||
cast(list[dict[str, Any]], config_entry.options.get(CONF_NOTIFY_ACTIONS, []) or [])
|
||
)
|
||
self._notify_script = None
|
||
self._notify_people = list(
|
||
config_entry.options.get(CONF_NOTIFY_PEOPLE, []) or []
|
||
)
|
||
self._notify_only_when_home = bool(
|
||
config_entry.options.get(
|
||
CONF_NOTIFY_ONLY_WHEN_HOME, DEFAULT_NOTIFY_ONLY_WHEN_HOME
|
||
)
|
||
)
|
||
self._notify_fire_events = bool(
|
||
config_entry.options.get(CONF_NOTIFY_FIRE_EVENTS, DEFAULT_NOTIFY_FIRE_EVENTS)
|
||
)
|
||
self._notify_before_end_minutes = int(
|
||
config_entry.options.get(
|
||
CONF_NOTIFY_BEFORE_END_MINUTES, DEFAULT_NOTIFY_BEFORE_END_MINUTES
|
||
)
|
||
)
|
||
self._notify_live_interval_seconds = int(
|
||
config_entry.options.get(
|
||
CONF_NOTIFY_LIVE_INTERVAL_SECONDS,
|
||
DEFAULT_NOTIFY_LIVE_INTERVAL_SECONDS,
|
||
)
|
||
)
|
||
self._notify_live_overrun_percent = int(
|
||
config_entry.options.get(
|
||
CONF_NOTIFY_LIVE_OVERRUN_PERCENT,
|
||
DEFAULT_NOTIFY_LIVE_OVERRUN_PERCENT,
|
||
)
|
||
)
|
||
self._notify_live_chronometer = bool(
|
||
config_entry.options.get(
|
||
CONF_NOTIFY_LIVE_CHRONOMETER,
|
||
DEFAULT_NOTIFY_LIVE_CHRONOMETER,
|
||
)
|
||
)
|
||
self._notify_timeout_seconds = int(
|
||
config_entry.options.get(
|
||
CONF_NOTIFY_TIMEOUT_SECONDS, DEFAULT_NOTIFY_TIMEOUT_SECONDS
|
||
)
|
||
)
|
||
|
||
# Reload door sensor / pause config
|
||
self._pause_cuts_power = bool(config_entry.options.get(CONF_PAUSE_CUTS_POWER, False))
|
||
self._door_sensor_entity = config_entry.options.get(CONF_DOOR_SENSOR_ENTITY) or None
|
||
self._notify_unload_delay_minutes = int(
|
||
config_entry.options.get(
|
||
CONF_NOTIFY_UNLOAD_DELAY_MINUTES, DEFAULT_NOTIFY_UNLOAD_DELAY_MINUTES
|
||
)
|
||
)
|
||
|
||
# Re-subscribe to external cycle end trigger
|
||
await self._setup_external_end_trigger()
|
||
|
||
# Re-subscribe to door sensor
|
||
await self._setup_door_sensor_listener()
|
||
|
||
# Re-subscribe to person presence changes for notification gating
|
||
await self._setup_notify_people_listener()
|
||
|
||
# If a cycle is currently active and live notifications are now enabled,
|
||
# reset counters and fire the first live notification immediately so the
|
||
# user doesn't have to wait for the next power sensor poll.
|
||
if self.detector.state in (STATE_RUNNING, STATE_PAUSED, STATE_ENDING):
|
||
if self._notify_live_services or self._notify_actions:
|
||
self._reset_live_notification_state()
|
||
self._check_live_progress_notification()
|
||
|
||
# Trigger entity updates to reflect any changes
|
||
async_dispatcher_send(self.hass, f"ha_washdata_update_{self.entry_id}")
|
||
|
||
if self.detector:
|
||
self.detector.config.profile_duration_tolerance = self._profile_duration_tolerance
|
||
|
||
# Schedule midnight maintenance if enabled
|
||
await self._setup_maintenance_scheduler()
|
||
|
||
# Schedule on-device ML retraining if enabled (Stage 4, gated)
|
||
self._setup_ml_training_scheduler()
|
||
|
||
# Update sampling interval
|
||
old_sampling = self._sampling_interval
|
||
new_sampling = float(
|
||
config_entry.options.get(CONF_SAMPLING_INTERVAL, DEFAULT_SAMPLING_INTERVAL)
|
||
)
|
||
if old_sampling != new_sampling:
|
||
self._sampling_interval = new_sampling
|
||
self._logger.info(
|
||
"Updated sampling interval: %.1fs -> %.1fs", old_sampling, new_sampling
|
||
)
|
||
|
||
# RESTORE STATE (only if recent enough, otherwise treat as stale)
|
||
await self._attempt_state_restoration()
|
||
|
||
self._logger.info("Configuration reloaded successfully")
|
||
|
||
def _spawn_tracked(self, coro: Coroutine[Any, Any, Any]) -> Task[Any]:
|
||
"""Create a detached task and track it so shutdown can cancel it.
|
||
|
||
Use for fire-and-forget tasks that touch the ProfileStore (matching
|
||
trigger, active-cycle clear, post-cycle processing): if a reload/unload
|
||
swaps the store out mid-flight, an untracked task would keep writing to the
|
||
stale store. The task auto-removes itself from the set when it finishes.
|
||
"""
|
||
task = self.hass.async_create_task(coro)
|
||
# Real HA always returns a Task; guard for degenerate returns (e.g. a
|
||
# mocked hass in tests) so tracking never breaks the caller.
|
||
if task is not None and hasattr(task, "add_done_callback"):
|
||
self._background_tasks.add(task)
|
||
task.add_done_callback(self._background_tasks.discard)
|
||
return task
|
||
|
||
async def async_shutdown(self) -> None:
|
||
"""Shutdown."""
|
||
self._is_shutdown = True
|
||
# Cancel in-flight matching and cycle-end tasks so they don't race a
|
||
# freshly-loaded ProfileStore on reload_config_entry.
|
||
_to_await: list[Task[Any]] = []
|
||
if self._matching_task and not self._matching_task.done():
|
||
self._matching_task.cancel()
|
||
_to_await.append(self._matching_task)
|
||
if self._cycle_end_task and not self._cycle_end_task.done():
|
||
self._cycle_end_task.cancel()
|
||
_to_await.append(self._cycle_end_task)
|
||
# Cancel every other tracked detached task (matching trigger, active-cycle
|
||
# clear, post-cycle processing) for the same reason.
|
||
for task in list(self._background_tasks):
|
||
if not task.done():
|
||
task.cancel()
|
||
_to_await.append(task)
|
||
# Drain cancelled tasks so they don't race the freshly-reloaded ProfileStore.
|
||
if _to_await:
|
||
await asyncio.gather(*_to_await, return_exceptions=True)
|
||
if self._remove_listener:
|
||
self._remove_listener()
|
||
if self._remove_external_trigger_listener:
|
||
self._remove_external_trigger_listener()
|
||
if self._remove_door_sensor_listener:
|
||
self._remove_door_sensor_listener()
|
||
self._remove_door_sensor_listener = None
|
||
if self._remove_notify_people_listener:
|
||
self._remove_notify_people_listener()
|
||
self._remove_notify_people_listener = None
|
||
self._pending_notifications = []
|
||
# Cancel any pending quiet-hours release timer so it doesn't fire after unload.
|
||
self._cancel_quiet_hours_timer()
|
||
self._quiet_pending_notifications = []
|
||
# Cancel the power-off one-shot reset timer so it can't fire post-unload.
|
||
self._cancel_power_off_timer()
|
||
if self._remove_watchdog:
|
||
self._remove_watchdog()
|
||
if (
|
||
hasattr(self, "_remove_state_expiry_timer")
|
||
and self._remove_state_expiry_timer
|
||
):
|
||
self._remove_state_expiry_timer()
|
||
if self._remove_maintenance_scheduler:
|
||
self._remove_maintenance_scheduler()
|
||
if self._remove_ml_training_scheduler:
|
||
self._remove_ml_training_scheduler()
|
||
self._remove_ml_training_scheduler = None
|
||
|
||
self.diag_buffer.uninstall()
|
||
|
||
# Dismiss the timer-pause notification so it doesn't linger on mobile or
|
||
# sidebar after HA restarts / integration unloads.
|
||
try:
|
||
self._clear_timer_pause_notification()
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
|
||
# Dismiss any active live/progress notification so it doesn't linger on
|
||
# mobile devices across HA restarts or integration unloads with a stale
|
||
# (and eventually negative) chronometer.
|
||
try:
|
||
self._clear_live_progress_notification()
|
||
except Exception: # noqa: BLE001
|
||
self._logger.debug("Failed to clear live notification on shutdown", exc_info=True)
|
||
|
||
# Save active state before shutdown
|
||
if self.detector.state in {STATE_RUNNING, STATE_PAUSED, STATE_STARTING, STATE_ENDING}:
|
||
snapshot = self._augment_active_snapshot(self.detector.get_state_snapshot())
|
||
await self.profile_store.async_save_active_cycle(snapshot)
|
||
|
||
self._last_reading_time = None
|
||
|
||
async def _setup_external_end_trigger(self) -> None:
|
||
"""Set up listener for external cycle end trigger binary sensor."""
|
||
# Remove existing listener if any
|
||
if self._remove_external_trigger_listener:
|
||
self._remove_external_trigger_listener()
|
||
self._remove_external_trigger_listener = None
|
||
|
||
# Check if enabled
|
||
enabled = self.config_entry.options.get(
|
||
CONF_EXTERNAL_END_TRIGGER_ENABLED, False
|
||
)
|
||
if not enabled:
|
||
self._logger.debug("External cycle end trigger is disabled")
|
||
return
|
||
|
||
# Get entity ID
|
||
entity_id = self.config_entry.options.get(CONF_EXTERNAL_END_TRIGGER, "")
|
||
if not entity_id:
|
||
self._logger.debug("External cycle end trigger: no entity configured")
|
||
return
|
||
|
||
self._logger.info(
|
||
"Setting up external cycle end trigger: %s", entity_id
|
||
)
|
||
|
||
# Subscribe to state changes
|
||
self._remove_external_trigger_listener = async_track_state_change_event(
|
||
self.hass, [entity_id], self._handle_external_trigger_change
|
||
)
|
||
|
||
async def _setup_door_sensor_listener(self) -> None:
|
||
"""Set up listener for optional door sensor binary sensor."""
|
||
if self._remove_door_sensor_listener:
|
||
self._remove_door_sensor_listener()
|
||
self._remove_door_sensor_listener = None
|
||
|
||
entity_id = self._door_sensor_entity
|
||
if not entity_id:
|
||
self._logger.debug("Door sensor not configured")
|
||
return
|
||
|
||
self._logger.info("Setting up door sensor listener: %s", entity_id)
|
||
self._remove_door_sensor_listener = async_track_state_change_event(
|
||
self.hass, [entity_id], self._handle_door_sensor_change
|
||
)
|
||
|
||
@callback
|
||
def _handle_door_sensor_change(self, event: Event[evt.EventStateChangedData]) -> None:
|
||
"""Handle door sensor state changes.
|
||
|
||
Opening the door during an active cycle confirms an intentional pause (verified_pause).
|
||
Opening the door after a cycle clears the 'Clean' state.
|
||
Note: door closing does NOT auto-resume a cycle - the user must do this explicitly.
|
||
"""
|
||
new_state = event.data.get("new_state")
|
||
old_state = event.data.get("old_state")
|
||
|
||
if new_state is None:
|
||
return
|
||
|
||
new_val = new_state.state
|
||
old_val = old_state.state if old_state else None
|
||
|
||
# Ignore unavailability transitions
|
||
if new_val in ("unavailable", "unknown") or (
|
||
old_val in ("unavailable", "unknown")
|
||
):
|
||
return
|
||
|
||
door_open = new_val == "on" # binary_sensor: on = open
|
||
|
||
if door_open:
|
||
if self._is_clean_state:
|
||
# User opened the door after the cycle - laundry retrieved
|
||
self._logger.debug("Door opened: clearing Clean state")
|
||
self._is_clean_state = False
|
||
self._clean_state_start = None
|
||
self._notified_clean_laundry = False
|
||
# Dismiss a delivered clean reminder (and purge any queued ones)
|
||
# so it does not linger on the phone after the laundry is taken.
|
||
self._clear_clean_notification()
|
||
self._notify_update()
|
||
elif self.detector.state in (STATE_RUNNING, STATE_STARTING, STATE_PAUSED, STATE_ENDING):
|
||
# Door opened during active cycle → soft pause confirmation
|
||
self._logger.debug(
|
||
"Door opened during active cycle: setting verified_pause=True"
|
||
)
|
||
self.detector.set_verified_pause(True)
|
||
if not self._is_user_paused:
|
||
self._is_user_paused = True
|
||
self._user_pause_start = dt_util.now()
|
||
self._notify_update()
|
||
# Door closing is intentionally not handled - no auto-resume
|
||
|
||
async def _setup_notify_people_listener(self) -> None:
|
||
"""Set up listener for person presence changes used by notification gating."""
|
||
if self._remove_notify_people_listener:
|
||
self._remove_notify_people_listener()
|
||
self._remove_notify_people_listener = None
|
||
|
||
if self._notify_only_when_home and self._notify_people:
|
||
self._remove_notify_people_listener = async_track_state_change_event(
|
||
self.hass, self._notify_people, self._handle_notify_person_change
|
||
)
|
||
# If someone is already home when (re-)attaching, flush any queued
|
||
# notifications immediately so they aren't stranded.
|
||
if self._pending_notifications and self._is_any_notify_person_home():
|
||
person_entity_id: str | None = None
|
||
person_name: str | None = None
|
||
for eid in self._notify_people:
|
||
state = self.hass.states.get(eid)
|
||
if state and state.state == STATE_HOME:
|
||
person_entity_id = eid
|
||
person_name = state.name or state.attributes.get(
|
||
"friendly_name", eid
|
||
)
|
||
break
|
||
pending = list(self._pending_notifications)
|
||
self._pending_notifications = []
|
||
for entry in pending:
|
||
self._dispatch_notification(
|
||
entry["message"],
|
||
title=entry.get("title"),
|
||
icon=entry.get("icon"),
|
||
event_type=entry.get("event_type"),
|
||
person_entity_id=person_entity_id,
|
||
person_name=person_name,
|
||
extra_vars=entry.get("extra_vars"),
|
||
allow_deferral=False,
|
||
allow_presence_deferral=False,
|
||
)
|
||
else:
|
||
self._pending_notifications = []
|
||
|
||
@callback
|
||
def _handle_external_trigger_change(self, event: Event[evt.EventStateChangedData]) -> None:
|
||
"""Handle external trigger sensor state change."""
|
||
new_state = event.data.get("new_state")
|
||
old_state = event.data.get("old_state")
|
||
|
||
if new_state is None:
|
||
return
|
||
|
||
inverted = self.config_entry.options.get(
|
||
CONF_EXTERNAL_END_TRIGGER_INVERTED, False
|
||
)
|
||
|
||
new_value = new_state.state
|
||
old_value = old_state.state if old_state else None
|
||
|
||
# Ignore unavailability/unknown transitions (reconnects, disconnects)
|
||
if old_value is None or old_value in ("unavailable", "unknown") or new_value in (
|
||
"unavailable",
|
||
"unknown",
|
||
):
|
||
return
|
||
|
||
# Determine if triggered based on inversion setting
|
||
triggered = False
|
||
if not inverted:
|
||
# Normal: Trigger on transition to "on"
|
||
if new_value == "on" and old_value != "on":
|
||
triggered = True
|
||
else:
|
||
# Inverted: Trigger on transition to "off"
|
||
if new_value == "off" and old_value != "off":
|
||
triggered = True
|
||
|
||
if triggered:
|
||
self._logger.info(
|
||
"External cycle end trigger activated by %s (inverted=%s)",
|
||
event.data.get("entity_id"),
|
||
inverted
|
||
)
|
||
# End cycle with "completed" status (not interrupted)
|
||
if self.detector.state in (STATE_ANTI_WRINKLE, STATE_DELAY_WAIT):
|
||
self.detector.reset(STATE_OFF)
|
||
self._logger.info("%s exited via external trigger", self.detector.state)
|
||
elif self.detector.state != STATE_OFF:
|
||
self.detector.user_stop()
|
||
self._logger.info("Cycle completed via external trigger")
|
||
|
||
async def _setup_maintenance_scheduler(self) -> None:
|
||
"""Set up daily maintenance task at midnight."""
|
||
auto_maintenance = self.config_entry.options.get(
|
||
CONF_AUTO_MAINTENANCE,
|
||
self.config_entry.data.get(CONF_AUTO_MAINTENANCE, DEFAULT_AUTO_MAINTENANCE),
|
||
)
|
||
|
||
# Cancel existing scheduler if any
|
||
if self._remove_maintenance_scheduler:
|
||
self._remove_maintenance_scheduler()
|
||
self._remove_maintenance_scheduler = None
|
||
|
||
if not auto_maintenance:
|
||
self._logger.debug("Auto-maintenance disabled")
|
||
return
|
||
|
||
async def run_maintenance(_now: datetime | None = None) -> None:
|
||
"""Run maintenance task."""
|
||
self._logger.info("Running scheduled maintenance")
|
||
try:
|
||
stats = await self.profile_store.async_run_maintenance()
|
||
self._logger.info("Maintenance completed: %s", stats)
|
||
# Refresh persisted cycle health as part of nightly maintenance.
|
||
await self.async_recompute_cycle_health()
|
||
except Exception as err: # pylint: disable=broad-exception-caught
|
||
self._logger.error("Maintenance failed: %s", err, exc_info=True)
|
||
|
||
# Fire daily at local midnight with a single, cleanly-cancellable handle.
|
||
# async_track_time_change auto-repeats every day, so there is no manual
|
||
# rescheduling that could leak handles or double-register the callback.
|
||
self._remove_maintenance_scheduler = evt.async_track_time_change(
|
||
self.hass, run_maintenance, hour=0, minute=0, second=0
|
||
)
|
||
self._logger.info("Scheduled daily maintenance at local midnight")
|
||
|
||
def _setup_ml_training_scheduler(self) -> None:
|
||
"""Schedule the daily on-device ML retraining (Stage 4, gated).
|
||
|
||
Uses ``async_track_time_change`` which fires every day at the configured
|
||
hour with a single, cleanly-cancellable handle (no manual rescheduling).
|
||
No-op unless the ``ENABLE_ML_TRAINING`` build flag and the per-device
|
||
opt-in are both set.
|
||
"""
|
||
from .const import (
|
||
ENABLE_ML_TRAINING,
|
||
CONF_ML_TRAINING_ENABLED,
|
||
CONF_ML_TRAINING_HOUR,
|
||
DEFAULT_ML_TRAINING_ENABLED,
|
||
DEFAULT_ML_TRAINING_HOUR,
|
||
)
|
||
|
||
if self._remove_ml_training_scheduler:
|
||
self._remove_ml_training_scheduler()
|
||
self._remove_ml_training_scheduler = None
|
||
|
||
if not ENABLE_ML_TRAINING:
|
||
return
|
||
opts = {**self.config_entry.data, **self.config_entry.options}
|
||
if not opts.get(CONF_ML_TRAINING_ENABLED, DEFAULT_ML_TRAINING_ENABLED):
|
||
self._logger.debug("On-device ML training disabled")
|
||
return
|
||
|
||
try:
|
||
hour = int(opts.get(CONF_ML_TRAINING_HOUR, DEFAULT_ML_TRAINING_HOUR))
|
||
except (TypeError, ValueError):
|
||
hour = DEFAULT_ML_TRAINING_HOUR
|
||
hour = max(0, min(23, hour))
|
||
|
||
async def _scheduled(_now: datetime) -> None:
|
||
await self.async_run_ml_training(force=False)
|
||
|
||
self._remove_ml_training_scheduler = evt.async_track_time_change(
|
||
self.hass, _scheduled, hour=hour, minute=0, second=0
|
||
)
|
||
self._logger.info("Scheduled on-device ML training daily at %02d:00", hour)
|
||
|
||
async def async_run_ml_training(self, force: bool = False) -> dict[str, Any]:
|
||
"""Retrain the ML models from this device's own cycles (gated + guarded).
|
||
|
||
Returns a summary dict. ``force`` bypasses the min-cycle / interval /
|
||
idle guards (used by the manual service). Never raises to the caller.
|
||
"""
|
||
from .const import (
|
||
ENABLE_ML_TRAINING,
|
||
CONF_ML_TRAINING_MIN_CYCLES,
|
||
CONF_ML_TRAINING_INTERVAL_DAYS,
|
||
DEFAULT_ML_TRAINING_MIN_CYCLES,
|
||
DEFAULT_ML_TRAINING_INTERVAL_DAYS,
|
||
EVENT_ML_TRAINING_COMPLETE,
|
||
)
|
||
|
||
if not ENABLE_ML_TRAINING:
|
||
return {"ok": False, "reason": "ml_training_disabled"}
|
||
|
||
opts = {**self.config_entry.data, **self.config_entry.options}
|
||
cycles = self.profile_store.get_past_cycles()
|
||
|
||
if not force:
|
||
# Don't train mid-cycle; wait for a quiet moment.
|
||
if self.detector and self.detector.state in {
|
||
STATE_RUNNING, STATE_PAUSED, STATE_STARTING, STATE_ENDING
|
||
}:
|
||
self._logger.debug("Skipping scheduled ML training: device active")
|
||
return {"ok": False, "reason": "device_active"}
|
||
min_cycles = int(opts.get(CONF_ML_TRAINING_MIN_CYCLES, DEFAULT_ML_TRAINING_MIN_CYCLES))
|
||
if len(cycles) < min_cycles:
|
||
self._logger.debug(
|
||
"Skipping scheduled ML training: need %d cycles, have %d",
|
||
min_cycles,
|
||
len(cycles),
|
||
)
|
||
return {"ok": False, "reason": f"need {min_cycles} cycles, have {len(cycles)}"}
|
||
# Respect the minimum retrain interval.
|
||
interval_days = int(
|
||
opts.get(CONF_ML_TRAINING_INTERVAL_DAYS, DEFAULT_ML_TRAINING_INTERVAL_DAYS)
|
||
)
|
||
last = self._last_ml_training_at()
|
||
if last is not None:
|
||
age_days = (dt_util.now() - last).total_seconds() / 86400.0
|
||
if age_days < interval_days:
|
||
self._logger.debug(
|
||
"Skipping scheduled ML training: retrained %.1fd ago (<%dd)",
|
||
age_days,
|
||
interval_days,
|
||
)
|
||
return {"ok": False, "reason": f"retrained {age_days:.1f}d ago (<{interval_days}d)"}
|
||
|
||
if self._ml_training_running:
|
||
return {"ok": False, "reason": "already_running"}
|
||
self._ml_training_running = True
|
||
self.notify_update()
|
||
try:
|
||
from .ml.training_task import async_run_training
|
||
|
||
summary = await async_run_training(self.hass, self)
|
||
except Exception as err: # noqa: BLE001 - training must never break the integration
|
||
self._logger.error("On-device ML training failed: %s", err, exc_info=True)
|
||
return {"ok": False, "reason": "exception", "error": str(err)}
|
||
finally:
|
||
self._ml_training_running = False
|
||
self.notify_update()
|
||
|
||
promoted = list(summary.get("promoted", {}).keys())
|
||
if promoted:
|
||
self._ml_training_failures = 0
|
||
# Consumers (ML Lab, MLSuggestionEngine) read the trained specs live
|
||
# from the store via ml.engine.resolve_scorer, so no refresh is needed.
|
||
self._logger.info("On-device ML training promoted models: %s", promoted)
|
||
else:
|
||
self._ml_training_failures += 1
|
||
self._logger.info(
|
||
"On-device ML training produced no promotable models (attempt %d)",
|
||
self._ml_training_failures,
|
||
)
|
||
|
||
# Stage 4/5: tune the matcher's scoring weights from this device's own
|
||
# cycles (same held-out promotion discipline as the models). Independent
|
||
# of model promotion; runs on every training pass.
|
||
matching = await self._tune_matching_config(cycles)
|
||
|
||
# Record that training *ran* now, regardless of whether anything was
|
||
# promoted, so "Last trained" advances on every run (a run that doesn't
|
||
# beat the baseline previously left the timestamp stuck at the last
|
||
# promotion). Never let a persistence hiccup break the run.
|
||
try:
|
||
_run_iso = dt_util.now().isoformat()
|
||
await self.profile_store.set_ml_last_training_run(_run_iso)
|
||
# Track each capability's held-out score over time (drift/fit trend).
|
||
await self.profile_store.append_ml_training_history(
|
||
_run_iso, summary.get("results", [])
|
||
)
|
||
except Exception as err: # noqa: BLE001
|
||
self._logger.debug("Failed to persist last-training-run timestamp: %s", err)
|
||
|
||
self.hass.bus.async_fire(
|
||
EVENT_ML_TRAINING_COMPLETE,
|
||
{
|
||
"entry_id": self.entry_id,
|
||
"device_name": self.config_entry.title,
|
||
"promoted": promoted,
|
||
"results": summary.get("results", []),
|
||
"matching": matching,
|
||
},
|
||
)
|
||
# A promoted model changes the health-model signature, so recompute the
|
||
# persisted per-cycle health now rather than lazily on the next view.
|
||
if promoted:
|
||
try:
|
||
await self.async_recompute_cycle_health()
|
||
except Exception as err: # noqa: BLE001
|
||
self._logger.debug("Post-training health recompute failed: %s", err)
|
||
|
||
return {
|
||
"ok": True,
|
||
"promoted": promoted,
|
||
"results": summary.get("results", []),
|
||
"matching": matching,
|
||
}
|
||
|
||
async def _tune_matching_config(self, cycles: list[dict[str, Any]]) -> dict[str, Any]:
|
||
"""Tune + (if it beats the shipped defaults on a held-out split) persist
|
||
the matcher's scoring weights for this device. Executor-offloaded and
|
||
never raises. Returns the tuner status dict for logging / the UI event.
|
||
"""
|
||
try:
|
||
from .ml.matching_tuner import tune_matching_config
|
||
|
||
result = await self.hass.async_add_executor_job(tune_matching_config, cycles)
|
||
except Exception as err: # noqa: BLE001 - tuning must never break training
|
||
self._logger.debug("Matching-config tuning failed: %s", err)
|
||
return {"promoted": False, "reason": "exception", "error": str(err)}
|
||
|
||
if result.get("promoted") and result.get("config"):
|
||
record = {
|
||
"config": result["config"],
|
||
"trained_at": dt_util.now().isoformat(),
|
||
"cycle_count": len(cycles),
|
||
"baseline_test_top1": result.get("baseline_test_top1"),
|
||
"tuned_test_top1": result.get("tuned_test_top1"),
|
||
}
|
||
await self.profile_store.set_matching_config(record)
|
||
self._logger.info(
|
||
"On-device matcher tuning promoted (top-1 %.3f -> %.3f): %s",
|
||
result.get("baseline_test_top1") or 0.0,
|
||
result.get("tuned_test_top1") or 0.0,
|
||
result["config"],
|
||
)
|
||
else:
|
||
self._logger.debug(
|
||
"On-device matcher tuning not promoted: %s", result.get("reason")
|
||
)
|
||
return result
|
||
|
||
async def async_recompute_cycle_health(self) -> int:
|
||
"""Recompute + persist per-cycle ML health against the current model.
|
||
|
||
Health is cached on each cycle and only recomputed at defined triggers
|
||
(this method): on-device retraining, scheduled auto-maintenance and the
|
||
Diagnostics "Process History" action. Panel loads reuse the cache. Runs
|
||
the CPU work in an executor and never raises to the caller.
|
||
"""
|
||
import functools # pylint: disable=import-outside-toplevel
|
||
|
||
try:
|
||
from .ws_api import _compute_ml_comparison # pylint: disable=import-outside-toplevel
|
||
except Exception: # pylint: disable=broad-exception-caught
|
||
return 0
|
||
opts = {**self.config_entry.data, **self.config_entry.options}
|
||
off_delay = int(opts.get(CONF_OFF_DELAY, DEFAULT_OFF_DELAY))
|
||
try:
|
||
result = await self.hass.async_add_executor_job(
|
||
functools.partial(
|
||
_compute_ml_comparison, self.profile_store, off_delay, force_recompute=True
|
||
)
|
||
)
|
||
except Exception as err: # noqa: BLE001
|
||
self._logger.debug("Cycle-health recompute failed: %s", err)
|
||
return 0
|
||
health_updates = result.get("_health_updates", {})
|
||
if health_updates:
|
||
for cycle in self.profile_store.get_past_cycles():
|
||
cid = cycle.get("id")
|
||
if cid in health_updates:
|
||
cycle["ml_health"] = health_updates[cid]
|
||
if result.get("_health_dirty") or health_updates:
|
||
await self.profile_store.async_save()
|
||
return int(result.get("evaluated_count", 0))
|
||
|
||
def _last_ml_training_at(self) -> datetime | None:
|
||
"""When on-device training last *ran* (not just last promoted a model).
|
||
|
||
Prefers the persisted last-run timestamp so a manual/scheduled run that
|
||
produced no promotable model still advances "Last trained" and the retrain
|
||
interval. Falls back to the newest promoted model's ``trained_at`` for
|
||
installs from before run-time tracking existed.
|
||
"""
|
||
run_iso = self.profile_store.get_ml_last_training_run()
|
||
if isinstance(run_iso, str):
|
||
parsed = dt_util.parse_datetime(run_iso)
|
||
if parsed is not None:
|
||
return parsed
|
||
latest: datetime | None = None
|
||
for record in (self.profile_store.get_ml_model_versions() or {}).values():
|
||
ts = record.get("trained_at") if isinstance(record, dict) else None
|
||
if not isinstance(ts, str):
|
||
continue
|
||
try:
|
||
parsed = dt_util.parse_datetime(ts)
|
||
except (ValueError, TypeError):
|
||
parsed = None
|
||
if parsed is not None and (latest is None or parsed > latest):
|
||
latest = parsed
|
||
return latest
|
||
|
||
@callback
|
||
def _async_power_changed(self, event: Any) -> None:
|
||
"""Handle power sensor state change."""
|
||
event_data = cast(dict[str, Any], getattr(event, "data", {}))
|
||
new_state = cast(State | None, event_data.get("new_state"))
|
||
if new_state is None or new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE):
|
||
return
|
||
|
||
try:
|
||
power = float(new_state.state)
|
||
except ValueError:
|
||
return
|
||
|
||
# Capture every raw sensor reading before any throttling or processing.
|
||
# Use the sensor's own last_updated timestamp so the trace reflects
|
||
# when the plug actually reported the value, not when we received it.
|
||
self.diag_buffer.record_power(power, new_state.last_updated)
|
||
|
||
# RECORD MODE INTERCEPTION
|
||
if self.recorder.is_recording:
|
||
self.recorder.process_reading(power)
|
||
self._current_power = power
|
||
self._last_reading_time = dt_util.now()
|
||
self._notify_update()
|
||
return
|
||
|
||
now = dt_util.now()
|
||
|
||
# Throttle updates to avoid CPU overload on noisy sensors.
|
||
# Low-power readings bypass throttling when:
|
||
# (a) a cycle is active (RUNNING/ENDING/PAUSED) — critical end-of-cycle signal, or
|
||
# (b) this is a genuine power DROP from above min_power — captures power-off events
|
||
# that occur before the detector has processed the previous above-threshold reading.
|
||
# Without the guard, an idle device at 0W fires an update on every sensor poll (typically
|
||
# every 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
|
||
):
|
||
try:
|
||
prev_raw_power = float(old_state.state)
|
||
except ValueError:
|
||
pass
|
||
is_low_power = power < min_p and (
|
||
self.detector.state in (STATE_RUNNING, STATE_PAUSED, STATE_ENDING)
|
||
or prev_raw_power >= min_p # genuine drop from active power
|
||
)
|
||
|
||
if (
|
||
not is_low_power
|
||
and self._last_reading_time
|
||
and (now - self._last_reading_time).total_seconds() < self._sampling_interval
|
||
):
|
||
return
|
||
|
||
# Track observed power readings for learning
|
||
self.learning_manager.process_power_reading(power, now, self._last_reading_time)
|
||
self._last_reading_time = now
|
||
self._last_real_reading_time = now # Track real update
|
||
self._current_power = power
|
||
self.detector.process_reading(power, now)
|
||
|
||
if self._cycle_start_time is None and self.detector.current_cycle_start is not None:
|
||
self._cycle_start_time = self.detector.current_cycle_start
|
||
|
||
# If running (or paused/ending), try to match profile and update estimates
|
||
if self.detector.state in (
|
||
STATE_RUNNING,
|
||
STATE_PAUSED,
|
||
STATE_ENDING,
|
||
STATE_STARTING,
|
||
):
|
||
self._update_estimates()
|
||
# Periodically save state every 60s to avoid flash wear
|
||
# We need a tracker.
|
||
self._check_state_save(now)
|
||
|
||
self._notify_update()
|
||
|
||
def _check_state_save(self, now: datetime) -> None:
|
||
"""Periodically save active state."""
|
||
last_save = getattr(self, "_last_state_save", None)
|
||
if not last_save or (now - last_save).total_seconds() > 60:
|
||
# Fire and forget save task
|
||
# Inject manual program flag into snapshot before saving
|
||
snapshot = self._augment_active_snapshot(self.detector.get_state_snapshot())
|
||
|
||
self.hass.async_create_task(
|
||
self.profile_store.async_save_active_cycle(snapshot)
|
||
)
|
||
self._last_state_save = now
|
||
|
||
async def _run_final_match_from_cycle_data(self, cycle_data: dict[str, Any]) -> None:
|
||
"""Run final profile match using the cycle's power data before it's saved.
|
||
|
||
This is called from _on_cycle_end when _current_program is still 'detecting...'
|
||
to ensure we try matching with complete cycle data before persistence.
|
||
"""
|
||
# Cycle data from detector stores power_data as [[offset_seconds, power], ...],
|
||
# where offsets are relative to cycle start.
|
||
power_data = cycle_data.get("power_data", [])
|
||
duration = cycle_data.get("duration", 0)
|
||
|
||
if not power_data or len(power_data) < 10:
|
||
self._logger.debug("Insufficient power data for final match (< 10 readings)")
|
||
return
|
||
|
||
# power_data is already in [[offset_seconds, power], ...] format for matching.
|
||
self._logger.info(
|
||
"Running final match from cycle data: %s samples, %.0fs duration",
|
||
len(power_data),
|
||
duration,
|
||
)
|
||
|
||
result = await self.profile_store.async_match_profile(power_data, duration)
|
||
profile_name = result.best_profile
|
||
confidence = result.confidence
|
||
|
||
# Store result for debug data
|
||
self._last_match_result = result
|
||
|
||
# Accept match at lower threshold since cycle is complete
|
||
# Also ignore ambiguity for completed cycles - pick the best match
|
||
if profile_name and confidence >= 0.15:
|
||
self._logger.info(
|
||
"Final match from cycle data: '%s' with confidence %.3f",
|
||
profile_name,
|
||
confidence,
|
||
)
|
||
self._current_program = profile_name
|
||
self._last_match_confidence = confidence
|
||
else:
|
||
self._logger.info(
|
||
"No confident match from cycle data (best: %s, conf=%.3f)",
|
||
profile_name,
|
||
confidence,
|
||
)
|
||
|
||
def _start_watchdog(self) -> None:
|
||
"""Start the watchdog timer when a cycle begins."""
|
||
if self._remove_watchdog:
|
||
return # Already running
|
||
|
||
interval = self._watchdog_interval
|
||
self._logger.debug(
|
||
"Starting watchdog timer (configured=%ss)",
|
||
self._watchdog_interval,
|
||
)
|
||
self._remove_watchdog = async_track_time_interval(
|
||
self.hass, self._watchdog_check_stuck_cycle, timedelta(seconds=interval)
|
||
)
|
||
|
||
def _stop_watchdog(self) -> None:
|
||
"""Stop the watchdog timer when cycle ends."""
|
||
if self._remove_watchdog:
|
||
self._logger.debug("Stopping watchdog timer")
|
||
self._remove_watchdog()
|
||
self._remove_watchdog = None
|
||
|
||
def _start_state_expiry_timer(self) -> None:
|
||
"""Start timer to reset state to OFF and progress to 0% after idle period."""
|
||
if not hasattr(self, "_remove_state_expiry_timer"):
|
||
self._remove_state_expiry_timer = None
|
||
|
||
if self._remove_state_expiry_timer:
|
||
return # Already running
|
||
|
||
self._logger.debug(
|
||
"Starting state expiry timer (will reset after %ss)",
|
||
self._progress_reset_delay,
|
||
)
|
||
self._remove_state_expiry_timer = async_track_time_interval(
|
||
self.hass,
|
||
self._handle_state_expiry,
|
||
timedelta(seconds=60), # Check every minute
|
||
)
|
||
|
||
def _stop_state_expiry_timer(self) -> None:
|
||
"""Stop the state expiry timer."""
|
||
if (
|
||
hasattr(self, "_remove_state_expiry_timer")
|
||
and self._remove_state_expiry_timer
|
||
):
|
||
self._logger.debug("Stopping state expiry timer")
|
||
self._remove_state_expiry_timer()
|
||
self._remove_state_expiry_timer = None
|
||
|
||
async def _handle_state_expiry(self, now: datetime) -> None:
|
||
"""Check if state and progress should be reset (auto-expiration)."""
|
||
if (
|
||
not self._cycle_completed_time
|
||
or self.detector.state == STATE_RUNNING
|
||
or self.detector.state == STATE_ANTI_WRINKLE
|
||
or self.detector.state == STATE_DELAY_WAIT
|
||
):
|
||
# Cycle is running or not completed, don't reset
|
||
return
|
||
|
||
time_since_complete = (now - self._cycle_completed_time).total_seconds()
|
||
|
||
# Clean laundry nag notification
|
||
if (
|
||
self._is_clean_state
|
||
and not self._notified_clean_laundry
|
||
and self._clean_state_start is not None
|
||
and self._notify_unload_delay_minutes > 0
|
||
):
|
||
time_in_clean = (now - self._clean_state_start).total_seconds()
|
||
if time_in_clean >= self._notify_unload_delay_minutes * 60:
|
||
if self._notify_finish_services or self._notify_actions:
|
||
duration_min = int(time_since_complete / 60)
|
||
msg_template = self.config_entry.options.get(
|
||
CONF_NOTIFY_UNLOAD_MESSAGE, DEFAULT_NOTIFY_UNLOAD_MESSAGE
|
||
)
|
||
msg = self._safe_format_template(
|
||
msg_template,
|
||
fallback_template=DEFAULT_NOTIFY_UNLOAD_MESSAGE,
|
||
device=self.config_entry.title,
|
||
duration=duration_min,
|
||
delay=self._notify_unload_delay_minutes,
|
||
)
|
||
sent = self._dispatch_notification(
|
||
msg,
|
||
event_type=NOTIFY_EVENT_CLEAN,
|
||
extra_vars={"tag": self._clean_tag},
|
||
)
|
||
if sent:
|
||
self._notified_clean_laundry = True
|
||
self._logger.info(
|
||
"Sent clean laundry nag notification (%.0f min after cycle end)",
|
||
time_since_complete / 60,
|
||
)
|
||
elif self._last_dispatch_deferred:
|
||
# Held for quiet-hours / presence delivery; the queued copy
|
||
# fires later. Mark handled so the 60s expiry tick doesn't
|
||
# enqueue a duplicate nag every minute for the whole window.
|
||
self._notified_clean_laundry = True
|
||
else:
|
||
self._notified_clean_laundry = True
|
||
|
||
# Defer leaving the terminal state while a clean-state unload notification is
|
||
# still pending. Without this guard the 30-min progress reset (or an early
|
||
# power-off) fires before the unload nag, clearing _is_clean_state before the
|
||
# notification can fire. Both expiry modes below honour it.
|
||
nag_pending = (
|
||
self._is_clean_state
|
||
and not self._notified_clean_laundry
|
||
and self._notify_unload_delay_minutes > 0
|
||
and time_since_complete < self._notify_unload_delay_minutes * 60
|
||
)
|
||
|
||
# Power-based Off detection (issue #284): opt-in, and only valid when the
|
||
# threshold sits below stop_threshold_w (so it cannot fire while a cycle could
|
||
# still be running, and cannot re-trigger the #267 spin-down ghost cycle). It is
|
||
# evaluated ONLY in a terminal state; active states never reach here because
|
||
# _cycle_completed_time is None until cycle end.
|
||
cfg = self.detector.config
|
||
pot = cfg.power_off_threshold_w
|
||
stop_w = cfg.stop_threshold_w
|
||
power_off_enabled = (
|
||
isinstance(pot, (int, float))
|
||
and isinstance(stop_w, (int, float))
|
||
and 0.0 < pot < stop_w
|
||
and self.detector.state
|
||
in (STATE_FINISHED, STATE_INTERRUPTED, STATE_FORCE_STOPPED)
|
||
)
|
||
|
||
if power_off_enabled:
|
||
# Power owns the Off transition. The classic timer still zeroes the progress
|
||
# bar after progress_reset_delay, but the terminal state PERSISTS until the
|
||
# machine is actually switched off (no timer fallback, by design: a machine
|
||
# whose standby never drops below the threshold stays "Finished").
|
||
if (
|
||
time_since_complete > self._progress_reset_delay
|
||
and self._cycle_progress != 0.0
|
||
):
|
||
self._cycle_progress = 0.0
|
||
self._notify_update()
|
||
|
||
if nag_pending:
|
||
# Hold the terminal state (and pause power sampling) until the nag fires.
|
||
self._power_off_below_since = None
|
||
self._cancel_power_off_timer()
|
||
return
|
||
|
||
if self._current_power < cfg.power_off_threshold_w:
|
||
if self._power_off_below_since is None:
|
||
self._power_off_below_since = now
|
||
# Arm a precise one-shot reset instead of waiting for the next
|
||
# 60s poll (the poll below stays as a backstop).
|
||
self._arm_power_off_timer(cfg.power_off_delay)
|
||
elif (
|
||
now - self._power_off_below_since
|
||
).total_seconds() >= cfg.power_off_delay:
|
||
self._logger.debug(
|
||
"Power-based Off: %.2fW below %.2fW for >= %.0fs in %s. "
|
||
"Resetting to OFF.",
|
||
self._current_power,
|
||
cfg.power_off_threshold_w,
|
||
cfg.power_off_delay,
|
||
self.detector.state,
|
||
)
|
||
self._reset_terminal_to_off()
|
||
else:
|
||
# Power rose back above the threshold: restart the debounce window.
|
||
self._power_off_below_since = None
|
||
self._cancel_power_off_timer()
|
||
return
|
||
|
||
# Timer-based Off (feature disabled): classic behaviour, unchanged.
|
||
self._power_off_below_since = None
|
||
self._cancel_power_off_timer()
|
||
if time_since_complete > self._progress_reset_delay:
|
||
if nag_pending:
|
||
return
|
||
# Auto-expire the "Finished" (or other terminal) state
|
||
self._logger.debug(
|
||
"State expiry: cycle idle for %.0fs (threshold: %ss). Resetting to OFF.",
|
||
time_since_complete,
|
||
self._progress_reset_delay,
|
||
)
|
||
self._reset_terminal_to_off()
|
||
|
||
def _reset_terminal_to_off(self) -> None:
|
||
"""Return a terminal state (Finished/Interrupted/Force-Stopped, incl. the Clean
|
||
overlay) to OFF and clear all post-cycle bookkeeping.
|
||
|
||
Single owner of the terminal -> OFF transition, shared by the timer-based and
|
||
the power-based (issue #284) expiry paths so the two can never diverge.
|
||
"""
|
||
self._cycle_progress = 0.0
|
||
self._cycle_completed_time = None
|
||
# Clear the Clean overlay too, or check_state() keeps reporting "Clean".
|
||
self._is_clean_state = False
|
||
self._clean_state_start = None
|
||
self._notified_clean_laundry = False
|
||
self._power_off_below_since = None
|
||
self._cancel_power_off_timer()
|
||
self.detector.reset(STATE_OFF)
|
||
self._stop_state_expiry_timer()
|
||
self._notify_update()
|
||
|
||
def _cancel_power_off_timer(self) -> None:
|
||
"""Cancel the pending power-off one-shot reset timer, if armed."""
|
||
if self._remove_power_off_timer is not None:
|
||
self._remove_power_off_timer()
|
||
self._remove_power_off_timer = None
|
||
|
||
def _arm_power_off_timer(self, delay: float) -> None:
|
||
"""Arm a single cancellable one-shot power-off reset timer.
|
||
|
||
Fires ``delay`` seconds after power first fell below the power-off
|
||
threshold, so the terminal->Off transition does not have to wait for the
|
||
next 60s expiry poll. The callback re-verifies the condition before acting,
|
||
so a timer left armed after power rose (before the next poll cancels it) is
|
||
a harmless no-op.
|
||
"""
|
||
self._cancel_power_off_timer()
|
||
|
||
@callback
|
||
def _fire(_now: datetime) -> None:
|
||
self._remove_power_off_timer = None
|
||
self._power_off_timer_check()
|
||
|
||
self._remove_power_off_timer = async_call_later(
|
||
self.hass, max(0.0, float(delay)), _fire
|
||
)
|
||
|
||
def _power_off_timer_check(self) -> None:
|
||
"""One-shot power-off timer callback: reset to Off only if still valid."""
|
||
cfg = self.detector.config
|
||
pot = cfg.power_off_threshold_w
|
||
stop_w = cfg.stop_threshold_w
|
||
# Same enable + terminal-state guard as the poll path.
|
||
if not (
|
||
isinstance(pot, (int, float))
|
||
and isinstance(stop_w, (int, float))
|
||
and 0.0 < pot < stop_w
|
||
and self.detector.state
|
||
in (STATE_FINISHED, STATE_INTERRUPTED, STATE_FORCE_STOPPED)
|
||
):
|
||
self._power_off_below_since = None
|
||
return
|
||
# Re-verify the below-threshold debounce (power may have risen since arming).
|
||
if self._power_off_below_since is None or self._current_power >= pot:
|
||
return
|
||
if (
|
||
dt_util.now() - self._power_off_below_since
|
||
).total_seconds() < cfg.power_off_delay:
|
||
return
|
||
# Honour the clean-laundry unload nag hold (mirrors the poll path).
|
||
if (
|
||
self._is_clean_state
|
||
and not self._notified_clean_laundry
|
||
and self._notify_unload_delay_minutes > 0
|
||
and self._cycle_completed_time is not None
|
||
and (dt_util.now() - self._cycle_completed_time).total_seconds()
|
||
< self._notify_unload_delay_minutes * 60
|
||
):
|
||
return
|
||
self._logger.debug(
|
||
"Power-based Off (one-shot timer): %.2fW below %.2fW for >= %.0fs in %s. "
|
||
"Resetting to OFF.",
|
||
self._current_power,
|
||
pot,
|
||
cfg.power_off_delay,
|
||
self.detector.state,
|
||
)
|
||
self._reset_terminal_to_off()
|
||
|
||
async def _watchdog_check_stuck_cycle(self, now: datetime) -> None:
|
||
"""Watchdog: check if cycle is stuck (no updates for too long)."""
|
||
if self.detector.state not in (STATE_RUNNING, STATE_STARTING, STATE_PAUSED, STATE_ENDING):
|
||
return
|
||
|
||
if not self._last_reading_time:
|
||
return
|
||
|
||
time_since_any_update = (now - self._last_reading_time).total_seconds()
|
||
|
||
# Calculate time since REAL update (if available, else fallback to any update)
|
||
last_real = self._last_real_reading_time or self._last_reading_time
|
||
time_since_real_update = (now - last_real).total_seconds()
|
||
|
||
elapsed = self.detector.get_elapsed_seconds()
|
||
expected = getattr(self.detector, "expected_duration_seconds", 0)
|
||
|
||
# 0a. PUMP STUCK DETECTION (Pump Monitor only)
|
||
# If a pump cycle has been running longer than the configured stuck threshold,
|
||
# fire a single warning event so the user can wire an automation/alert.
|
||
# Skip while user-paused or detector-verified-pause to avoid false positives.
|
||
_verified_pause = getattr(self.detector, "_verified_pause", False)
|
||
if self.device_type == DEVICE_TYPE_PUMP and not self._pump_stuck and not self._is_user_paused and not _verified_pause:
|
||
adjusted_elapsed = elapsed - self._total_user_paused_seconds
|
||
if adjusted_elapsed >= self._pump_stuck_duration:
|
||
self._pump_stuck = True
|
||
self._logger.warning(
|
||
"Pump stuck detected: cycle has been running for %.0fs net "
|
||
"(threshold: %ds). Firing %s event.",
|
||
adjusted_elapsed,
|
||
self._pump_stuck_duration,
|
||
EVENT_PUMP_STUCK,
|
||
)
|
||
self.hass.bus.async_fire(
|
||
EVENT_PUMP_STUCK,
|
||
{
|
||
"device": self.config_entry.title,
|
||
"entry_id": self.entry_id,
|
||
"elapsed_seconds": round(adjusted_elapsed),
|
||
"threshold_seconds": self._pump_stuck_duration,
|
||
},
|
||
)
|
||
self._notify_update()
|
||
|
||
# 0. ZOMBIE KILLER (Hard Limit)
|
||
# If cycle has run significantly longer than expected (300%), kill it.
|
||
# Only applies if we have a profile match. Skip while user-paused or
|
||
# detector-verified-pause to avoid killing legitimately paused cycles.
|
||
_verified_pause_zombie = getattr(self.detector, "_verified_pause", False)
|
||
if (
|
||
expected > 0
|
||
and not self._is_user_paused
|
||
and not _verified_pause_zombie
|
||
):
|
||
adjusted_elapsed = elapsed - self._total_user_paused_seconds
|
||
if adjusted_elapsed > (expected * 3.0) and adjusted_elapsed > 14400:
|
||
self._logger.warning(
|
||
"Watchdog: Zombie cycle detected (%.0fs net > 300%% of expected %.0fs). Force-ending.",
|
||
adjusted_elapsed, expected
|
||
)
|
||
self.detector.force_end(now)
|
||
self._current_power = 0.0 # Force 0W
|
||
self._notify_update()
|
||
return
|
||
|
||
# Secondary zombie guard for unmatched cycles (expected == 0 when no profile
|
||
# has been matched). The detector hard-caps RUNNING at 8h but a chatty sensor
|
||
# that never goes silent can keep the watchdog from reaching the end state.
|
||
# Kill here after 4h so a stuck false-start doesn't run indefinitely.
|
||
elif (
|
||
expected == 0
|
||
and not self._is_user_paused
|
||
and not _verified_pause_zombie
|
||
and elapsed > 14400
|
||
# Gate on the active detector state, not _current_program: the latter is
|
||
# only set to "detecting..." on the RUNNING transition, so a cycle stuck
|
||
# in STARTING keeps a stale program and would never hit this 4h guard.
|
||
and self.detector.state in (
|
||
STATE_STARTING, STATE_RUNNING, STATE_PAUSED, STATE_ENDING
|
||
)
|
||
):
|
||
self._logger.warning(
|
||
"Watchdog: Unmatched cycle exceeded 4h (%.0fs). Force-ending.",
|
||
elapsed,
|
||
)
|
||
self.detector.force_end(now)
|
||
self._current_power = 0.0
|
||
self._notify_update()
|
||
return
|
||
|
||
# 1. GHOST CYCLE SUPPRESSOR
|
||
# If we are "detecting" for more than 10 minutes and haven't seen an update for 5 minutes,
|
||
# it's likely a pump-out spike or an accidental start (ghost cycle).
|
||
# We end it aggressively ONLY if it started shortly after another cycle ended (Suspicious Window).
|
||
cycle_start = self.detector.current_cycle_start
|
||
is_suspicious = False
|
||
if cycle_start and self._last_cycle_end_time:
|
||
# Dishwashers have a drain pump-out that fires 3-8 min after the main
|
||
# cycle ends; use a wider suspicious window so the ghost suppressor can
|
||
# catch it without false-positives on washing machines / dryers.
|
||
suspicious_window = 600 if self.device_type == "dishwasher" else 180
|
||
if (cycle_start - self._last_cycle_end_time).total_seconds() < suspicious_window:
|
||
is_suspicious = True
|
||
|
||
# For dishwashers in the suspicious window, kill pump-out ghosts faster.
|
||
# Pump-outs last 1-3 min then go silent; the standard 10-min wait allows
|
||
# them to accumulate too much runtime before suppression fires.
|
||
dishwasher_pump_out = (
|
||
is_suspicious
|
||
and self.device_type == "dishwasher"
|
||
and elapsed > 180 # 3 minutes
|
||
and time_since_real_update > 60 # 1 minute of silence
|
||
)
|
||
|
||
if (
|
||
self._current_program == "detecting..."
|
||
and is_suspicious
|
||
and (
|
||
dishwasher_pump_out
|
||
or (elapsed > 600 and time_since_real_update > 300)
|
||
)
|
||
):
|
||
self._logger.warning(
|
||
"Watchdog: Ghost cycle suppressed (within suspicious window). Detecting for %.0fs with %.0fs silence.",
|
||
elapsed, time_since_real_update
|
||
)
|
||
self.detector.force_end(now)
|
||
self._current_power = 0.0
|
||
self._notify_update()
|
||
return
|
||
|
||
# --- LOW POWER HANDLING ---
|
||
# If we are in a low power state (waiting for off_delay or drying profile),
|
||
# we treat silence leniently. We inject keepalives until the stricter
|
||
# low_power_no_update_timeout is reached.
|
||
|
||
# Dishwashers can have very long silent drying phases (up to 2h)
|
||
# We use the device-specific timeout as the floor for this effective timeout.
|
||
# The floor is applied unconditionally - dishwashers have passive drying phases
|
||
# even when no profile has been matched yet. The original restriction to matched
|
||
# cycles caused premature kills: with the default 3600s timeout, an unmatched
|
||
# dishwasher cycle was killed ~1h after the last sensor update, while the
|
||
# physical drying phase could still have 1-2h of silent runtime remaining.
|
||
low_power_floor = DEFAULT_NO_UPDATE_ACTIVE_TIMEOUT_BY_DEVICE.get(
|
||
self.device_type, 0
|
||
)
|
||
effective_low_power_timeout = max(
|
||
low_power_floor, self._low_power_no_update_timeout
|
||
)
|
||
|
||
# Profile-Aware Extension:
|
||
# If we have a matched profile, ensure we don't kill during the expected duration.
|
||
if expected > 0 and elapsed < expected:
|
||
# Extend timeout to cover the remaining expected duration + buffer
|
||
remaining = expected - elapsed
|
||
# Allow silence up to remaining + 1800s (30m buffer for drying/pause)
|
||
extended_timeout = remaining + 1800
|
||
if extended_timeout > effective_low_power_timeout:
|
||
effective_low_power_timeout = extended_timeout
|
||
|
||
# Verified Pause Extension:
|
||
# If the manager/store has confirmed this is a legitimate pause (e.g. Drying),
|
||
# allow even more leniency up to the global deferral limit.
|
||
if getattr(self.detector, "_verified_pause", False):
|
||
# Allow silence up to DEFAULT_MAX_DEFERRAL_SECONDS (default 2h) + buffer
|
||
pause_limit = DEFAULT_MAX_DEFERRAL_SECONDS + 1800
|
||
if pause_limit > effective_low_power_timeout:
|
||
effective_low_power_timeout = pause_limit
|
||
self._logger.debug(
|
||
"Watchdog: Extending timeout to %.0fs due to verified pause",
|
||
effective_low_power_timeout
|
||
)
|
||
|
||
if self.detector.is_waiting_low_power():
|
||
|
||
# 2. Staleness Check
|
||
if time_since_real_update > effective_low_power_timeout:
|
||
self._logger.warning(
|
||
"Watchdog: Force-ending cycle. Low-power state stale for %.0fs (> %.0fs).",
|
||
time_since_real_update,
|
||
effective_low_power_timeout
|
||
)
|
||
self.detector.force_end(now)
|
||
self._last_reading_time = now
|
||
self._current_power = 0.0
|
||
self._notify_update()
|
||
return
|
||
|
||
# 3. Injection Check (Keepalive)
|
||
# 3a. Honour the user-configured no_update_active_timeout for low-power silence.
|
||
# Publish-on-change sensors go completely silent once they stabilise at a low
|
||
# standby value (e.g. 1 W). The existing off_delay-based injection fires
|
||
# every 2 watchdog ticks, which is fine with a short watchdog interval but can
|
||
# take many minutes with a larger one. Respecting no_update_active_timeout
|
||
# here gives users a predictable upper bound on how long a cycle lingers after
|
||
# the appliance reaches standby, consistent with what the setting implies.
|
||
# Verified pauses (e.g. dishwasher drying confirmed by envelope) are excluded
|
||
# so that legitimate long silent phases are not prematurely terminated.
|
||
if (
|
||
not getattr(self.detector, "_verified_pause", False)
|
||
and time_since_real_update > self._no_update_active_timeout
|
||
):
|
||
self._logger.debug(
|
||
"Watchdog: Low-power real-update silence (%.0fs) > no_update_active_timeout (%.0fs). "
|
||
"Injecting 0W keepalive to advance accumulator.",
|
||
time_since_real_update,
|
||
self._no_update_active_timeout,
|
||
)
|
||
self.detector.process_reading(0.0, now)
|
||
self._last_reading_time = now
|
||
self._current_power = 0.0
|
||
self._notify_update()
|
||
return
|
||
|
||
# 3b. Fallback: inject 0W when any-update silence exceeds off_delay.
|
||
# This keeps the accumulator moving even when no_update_active_timeout has
|
||
# not been exceeded (e.g. the user left it at the default 600 s).
|
||
if time_since_any_update > self._config.off_delay:
|
||
self._logger.debug(
|
||
"Watchdog: Low power silence (%.0fs). Injecting 0W keepalive.",
|
||
time_since_any_update
|
||
)
|
||
# Ensure we handle the injection cleanly
|
||
# Do NOT update _last_real_reading_time here
|
||
self.detector.process_reading(0.0, now)
|
||
self._last_reading_time = now # Resets 'any' timer so we don't spam
|
||
self._current_power = 0.0
|
||
self._notify_update()
|
||
return
|
||
|
||
return
|
||
|
||
# Fallback for old "Case 1.5" logic (Low Power but NOT is_waiting_low_power)
|
||
# Check this BEFORE High Power timeout to prevent trapping "Not Yet Waiting" states
|
||
# Inject as soon as the earliest of: off_delay silence OR no_update_active_timeout.
|
||
if self._current_power <= self.detector.config.min_power and (
|
||
time_since_any_update > self._config.off_delay
|
||
or time_since_real_update > self._no_update_active_timeout
|
||
):
|
||
# Treating as start of low power wait
|
||
self._logger.debug("Watchdog: Silence at low power (%.0fs). Injecting 0W.", time_since_any_update)
|
||
self.detector.process_reading(0.0, now)
|
||
self._last_reading_time = now
|
||
self._current_power = 0.0
|
||
self._notify_update()
|
||
return
|
||
|
||
# --- HIGH POWER HANDLING (Normal) ---
|
||
# If power is high, we expect frequent updates.
|
||
|
||
if time_since_any_update > self._no_update_active_timeout:
|
||
|
||
# Check if high power (running)
|
||
if self._current_power > self.detector.config.min_power:
|
||
# Allow extended silence if within reasonable cycle bounds
|
||
expected = getattr(self.detector, "expected_duration_seconds", 0)
|
||
elapsed = self.detector.get_elapsed_seconds()
|
||
limit = (expected + 14400) if expected > 0 else 14400 # 4h default
|
||
|
||
if elapsed < limit:
|
||
self._logger.info(
|
||
"Watchdog: High power (%.1fW) stale (%.0fs). Injecting refresh.",
|
||
self._current_power, time_since_any_update
|
||
)
|
||
self.detector.process_reading(self._current_power, now)
|
||
self._last_reading_time = now
|
||
self._notify_update()
|
||
return
|
||
|
||
# If we get here, it's truly stuck/offline
|
||
self._logger.warning(
|
||
"Watchdog: Force-ending cycle. Active state stale for %.0fs (> timeout).",
|
||
time_since_any_update
|
||
)
|
||
self.detector.force_end(now)
|
||
self._current_power = 0.0 # FIX: Reset current power
|
||
self._notify_update()
|
||
return
|
||
|
||
|
||
def _on_state_change(self, old_state: str, new_state: str) -> None:
|
||
"""Handle state change from detector."""
|
||
self._logger.debug("Washer state changed: %s -> %s", old_state, new_state)
|
||
self.diag_buffer.record_state(
|
||
old_state, new_state, self._current_program, dt_util.now()
|
||
)
|
||
# A new cycle starting while we are still showing the completed/Clean
|
||
# overlay (the progress-reset window) must clear that overlay and cancel
|
||
# the expiry timer right away, so the UI leaves "Finished" and the unload
|
||
# nag stops immediately instead of waiting for the reset window - and so
|
||
# the expiry timer cannot race the new cycle and reset us to OFF (#267).
|
||
if new_state == STATE_STARTING and self._cycle_completed_time is not None:
|
||
self._cycle_completed_time = None
|
||
self._is_clean_state = False
|
||
self._clean_state_start = None
|
||
self._notified_clean_laundry = False
|
||
self._cycle_progress = 0.0
|
||
self._power_off_below_since = None
|
||
self._cancel_power_off_timer()
|
||
self._stop_state_expiry_timer()
|
||
if new_state == STATE_RUNNING:
|
||
new_cycle_detected = old_state in (STATE_OFF, STATE_STARTING, STATE_UNKNOWN)
|
||
# Only reset estimates if we are truly starting a NEW cycle (from off or starting)
|
||
# If we transition from PAUSED or ENDING, it's a resume - keep estimates!
|
||
if new_cycle_detected:
|
||
self._cycle_completed_time = None
|
||
self._stop_state_expiry_timer()
|
||
|
||
self._current_program = "detecting..."
|
||
self._manual_program_active = False
|
||
self._notified_pre_completion = False
|
||
self._time_remaining = None
|
||
self._total_duration = None
|
||
self._cycle_progress = 0
|
||
self._matched_profile_duration = None
|
||
self._last_estimate_time = None
|
||
self._score_history = {} # Reset score history on new cycle
|
||
self._match_persistence_counter = {} # Reset persistence counter
|
||
self._unmatch_persistence_counter = 0 # Reset unmatch counter
|
||
self._current_match_candidate = None # Reset candidate
|
||
self._notified_start = False # Reset start notification state
|
||
self._start_event_fired = False
|
||
self._last_cycle_post_anomaly = {} # Clear previous cycle's anomaly cache
|
||
self._cycle_start_time = self.detector.current_cycle_start or dt_util.now()
|
||
self._ranking_snapshot_cycle_id = str(uuid.uuid4())
|
||
self._reset_live_notification_state()
|
||
# Snapshot the external energy meter (issue #316) so cycle end can
|
||
# take an accurate start->end delta. No-op when none is configured.
|
||
self._snapshot_energy_meter_start()
|
||
|
||
# Reset pause tracking and clean state for new cycle
|
||
self._is_user_paused = False
|
||
self._user_pause_start = None
|
||
self._total_user_paused_seconds = 0.0
|
||
self._is_clean_state = False
|
||
self._fired_cycle_timers = set()
|
||
self._clear_timer_pause_notification()
|
||
self._clean_state_start = None
|
||
self._notified_clean_laundry = False
|
||
|
||
self._start_watchdog() # Start watchdog when cycle starts
|
||
|
||
# Fire the start event immediately on cycle detection so listeners always
|
||
# receive it, even when no profile match occurs yet.
|
||
if self._notify_fire_events:
|
||
self.hass.bus.async_fire(
|
||
EVENT_CYCLE_STARTED,
|
||
{
|
||
"entry_id": self.entry_id,
|
||
"device_name": self.config_entry.title,
|
||
"device_type": self.device_type,
|
||
"program": self._current_program or "unknown",
|
||
"start_time": self._cycle_start_time.isoformat(),
|
||
},
|
||
)
|
||
self._start_event_fired = True
|
||
# Mark the start fully handled ONLY when there is no push to send, so
|
||
# the restart-recovery fallback does not re-enter for event-only
|
||
# configs. When a push service/action IS configured, leave
|
||
# _notified_start False so the push block below still fires: a config
|
||
# with both events and push must get both (event delivery is tracked
|
||
# separately by _start_event_fired).
|
||
if not (self._notify_start_services or self._notify_actions):
|
||
self._notified_start = True
|
||
|
||
# Fire push notification immediately - do not wait for profile matching.
|
||
if not self._notified_start and (self._notify_start_services or self._notify_actions):
|
||
msg_template = self.config_entry.options.get(
|
||
CONF_NOTIFY_START_MESSAGE, DEFAULT_NOTIFY_START_MESSAGE
|
||
)
|
||
msg = self._safe_format_template(
|
||
msg_template,
|
||
fallback_template=DEFAULT_NOTIFY_START_MESSAGE,
|
||
device=self.config_entry.title,
|
||
program=self._current_program,
|
||
)
|
||
# B4: append a peak-rate advisory tip when the current price is
|
||
# at/above the configured threshold. Purely informational.
|
||
tip = self._peak_rate_tip(
|
||
self.config_entry.options, self._resolve_energy_price()
|
||
)
|
||
if tip:
|
||
msg = f"{msg}\n{tip}"
|
||
self._dispatch_notification(
|
||
msg,
|
||
event_type=NOTIFY_EVENT_START,
|
||
extra_vars={
|
||
"program": self._current_program,
|
||
"tag": self._lifecycle_tag,
|
||
},
|
||
)
|
||
self._notified_start = True
|
||
self._logger.info(
|
||
"Sent start notification for program '%s'", self._current_program
|
||
)
|
||
self._check_pre_completion_notification()
|
||
else:
|
||
self._logger.debug("Cycle resumed from %s, preserving estimates", old_state)
|
||
# Ensure watchdog is running
|
||
self._start_watchdog()
|
||
|
||
# Stop watchdog when transitioning to OFF from any active state
|
||
if new_state == STATE_OFF:
|
||
self._stop_watchdog() # Stop watchdog regardless of previous state
|
||
self._cycle_start_time = None
|
||
|
||
self._notify_update()
|
||
|
||
def _discard_cycle_cleanup(self) -> None:
|
||
"""Discard cleanup for a ghost/noise blip that is never persisted.
|
||
|
||
The detector still transitions into a terminal state (FINISHED/INTERRUPTED)
|
||
when it fires the cycle-end callback, and a live active-cycle snapshot may be
|
||
sitting in the store. The normal cycle-end tail clears that snapshot and arms
|
||
the terminal-state expiry so the UI returns to Off; a suppressed ghost skips
|
||
that tail, so mirror the essential parts here — otherwise the device is
|
||
stranded in a terminal state with a stale active snapshot until the next
|
||
cycle. Deliberately does NOT persist, notify, or run the learning pipeline.
|
||
"""
|
||
self._spawn_tracked(self.profile_store.async_clear_active_cycle())
|
||
# Anchor the terminal state so _handle_state_expiry (and power-off) can act,
|
||
# then arm the expiry timer that resets terminal -> Off after the reset delay.
|
||
self._cycle_completed_time = dt_util.now()
|
||
self._start_state_expiry_timer()
|
||
|
||
def _on_cycle_end(self, cycle_data: dict[str, Any]) -> None:
|
||
"""Handle cycle end - clear all active timers and state."""
|
||
duration = cycle_data["duration"]
|
||
max_power = cycle_data.get("max_power", 0)
|
||
|
||
# IMMEDIATELY stop all active timers when cycle determined to have ended
|
||
self._stop_watchdog() # Stop active cycle watchdog
|
||
self._stop_state_expiry_timer() # Cancel any pending progress reset
|
||
self._clear_timer_pause_notification()
|
||
prev_cycle_end_time = self._last_cycle_end_time
|
||
self._last_cycle_end_time = dt_util.now()
|
||
self._pump_stuck = False # Reset for next pump cycle
|
||
|
||
# Auto-Tune: Check for ghost cycles (short duration AND low energy)
|
||
# Ghost = duration < 60s AND total energy < 0.05 Wh (avoids killing pump-out spikes)
|
||
power_data = cycle_data.get("power_data", [])
|
||
cycle_energy_wh = 0.0
|
||
if power_data and len(power_data) >= 2:
|
||
valid: list[tuple[float, float]] = []
|
||
for p in power_data:
|
||
try:
|
||
valid.append((float(p[0]), float(p[1])))
|
||
except (TypeError, ValueError, IndexError):
|
||
pass
|
||
if len(valid) >= 2:
|
||
try:
|
||
valid.sort(key=lambda x: x[0])
|
||
ts = np.array([v[0] for v in valid])
|
||
ps = np.array([v[1] for v in valid])
|
||
# Shared trapezoidal integrator with a data-driven outage gap
|
||
# (single source with ProfileStore.add_cycle).
|
||
cycle_energy_wh = integrate_wh(
|
||
ts, ps, max_gap_s=energy_gap_threshold_s(ts)
|
||
)
|
||
except (TypeError, ValueError, ArithmeticError):
|
||
cycle_energy_wh = 0.0
|
||
|
||
# Ghost cycle: short AND low energy (real cycles have energy even if short).
|
||
# Suppress it exactly like the dishwasher pump-out branch below: feed the
|
||
# auto-tune counter but do NOT store it or run the cycle-end pipeline. Without
|
||
# the return a sub-60 s / sub-0.05 Wh blip would fall through to persistence
|
||
# and the (un-gated) finish notification, firing a phantom "cycle finished".
|
||
if duration < 60 and cycle_energy_wh < 0.05:
|
||
self._handle_noise_cycle(max_power)
|
||
self._discard_cycle_cleanup()
|
||
return # Do not store this as a real cycle
|
||
if self.device_type == "dishwasher" and prev_cycle_end_time is not None:
|
||
# Pump-out suppression: dishwashers end cycles with a brief drain pump
|
||
# (typically 30-300 s, < 1 Wh) a few minutes after the main cycle
|
||
# finishes. If a short, low-energy cycle starts within 10 minutes of
|
||
# the previous cycle, treat it as a pump-out ghost and do not store it.
|
||
cycle_start_str = cycle_data.get("start_time")
|
||
cycle_start_dt = (
|
||
dt_util.parse_datetime(cycle_start_str) if cycle_start_str else None
|
||
)
|
||
if cycle_start_dt is not None:
|
||
gap = (cycle_start_dt - prev_cycle_end_time).total_seconds()
|
||
if 0 < gap < 600 and duration < 300 and cycle_energy_wh < 1.0:
|
||
self._logger.info(
|
||
"Suppressing dishwasher pump-out ghost: "
|
||
"gap=%.0fs, duration=%.0fs, energy=%.3f Wh",
|
||
gap,
|
||
duration,
|
||
cycle_energy_wh,
|
||
)
|
||
self._handle_noise_cycle(max_power)
|
||
self._discard_cycle_cleanup()
|
||
return # Do not store this as a real cycle
|
||
|
||
# Store energy for notification and persistence (calculated above for ghost detection)
|
||
cycle_data["energy_wh"] = round(cycle_energy_wh, 3)
|
||
|
||
# External energy meter (issue #316): when an accurate start->end delta is
|
||
# available, record it alongside the integrated value and mark the source.
|
||
# The integrated energy_wh above is left untouched so matching / ML / anomaly
|
||
# stats stay internally consistent; only user-facing figures prefer the meter.
|
||
cycle_data["energy_source"] = "integration"
|
||
meter_wh = self._compute_meter_energy_wh()
|
||
if meter_wh is not None:
|
||
cycle_data["energy_meter_wh"] = round(meter_wh, 3)
|
||
cycle_data["energy_source"] = "meter"
|
||
|
||
# Schedule heavy post-processing asynchronously. Capture this cycle's identity
|
||
# token so the async tail can tell if a NEW cycle started while it was awaiting
|
||
# (power changes are handled synchronously, so a back-to-back load can drive the
|
||
# detector into a fresh RUNNING before post-processing completes). See B1 in
|
||
# _async_process_cycle_end.
|
||
end_token = self._ranking_snapshot_cycle_id
|
||
if not self._is_shutdown:
|
||
self._cycle_end_task = self._spawn_tracked(
|
||
self._async_process_cycle_end(cycle_data, cycle_token=end_token)
|
||
)
|
||
|
||
def _ml_end_confidence(
|
||
self, points: list[tuple[float, float]], expected_duration: float
|
||
) -> float | None:
|
||
"""Opt-in ML end-guard provider handed to the CycleDetector.
|
||
|
||
Returns P(the latest low-power event is the true cycle end) from the
|
||
shipped or on-device-trained cycle-end model, or ``None`` when ML models
|
||
are disabled for this device, no profile is matched, or the model /
|
||
features are unavailable. ``None`` means the detector keeps its existing
|
||
power/energy-based behavior, so this can only ever *defer* a completion.
|
||
"""
|
||
try:
|
||
from .ml.engine import ml_models_enabled, resolve_scorer
|
||
|
||
if not ml_models_enabled(self.config_entry.options):
|
||
return None
|
||
profile_name = self._current_program
|
||
if (
|
||
not profile_name
|
||
or profile_name in ("off", "detecting...", "restored...")
|
||
or profile_name not in self.profile_store.get_profiles()
|
||
):
|
||
return None
|
||
end_fn, _ = resolve_scorer("end", self.profile_store)
|
||
if end_fn is None:
|
||
return None
|
||
expectation = self._profile_end_expectation(profile_name, expected_duration)
|
||
if expectation is None:
|
||
return None
|
||
from .ml.feature_extraction import latest_end_event_features
|
||
|
||
features = latest_end_event_features(points, expectation)
|
||
if features is None:
|
||
return None
|
||
return float(end_fn(features))
|
||
except Exception as err: # noqa: BLE001 - ML must never break detection
|
||
self._logger.debug("ML end-guard scoring skipped: %s", err)
|
||
return None
|
||
|
||
def _profile_end_expectation(
|
||
self, profile_name: str, expected_duration: float
|
||
) -> dict[str, float] | None:
|
||
"""Median duration/energy/peak for a matched profile, for end features.
|
||
|
||
Cached per profile so the guard does not re-decompress history on every
|
||
low-power reading during ENDING. The detector's authoritative expected
|
||
duration overrides the median when available.
|
||
"""
|
||
expectation, self._ml_end_expectation_cache = progress_mod.profile_end_expectation(
|
||
self.profile_store,
|
||
profile_name,
|
||
expected_duration,
|
||
self._ml_end_expectation_cache,
|
||
)
|
||
return expectation
|
||
|
||
def _terminal_drop_provider(
|
||
self, points: list[tuple[float, float]], expected_duration: float
|
||
) -> bool:
|
||
"""Opt-in terminal-drop detector handed to the CycleDetector.
|
||
|
||
Returns ``True`` when the current low-power event is a hard cliff-to-~0
|
||
that began at an elapsed offset EARLIER than this device has ever
|
||
legitimately gone quiet (learned from its own completed cycles) - i.e. an
|
||
anomalously-early drop that is almost certainly a real stop (plug pulled /
|
||
cancelled), not a soak pause. The detector then finalizes quickly instead
|
||
of waiting out the full soak-bridging ``min_off_gap``.
|
||
|
||
A very early drop is below the matcher's duration gate, so match
|
||
confidence is not available to confirm familiarity; instead the cycle's
|
||
**power level** must be one this device has produced before (see
|
||
``is_terminal_drop``) - a cycle drawing power unlike anything in its
|
||
history is treated as a possible new program and deferred.
|
||
|
||
Returns ``False`` (keep the proven slow path) when the ML/anomaly opt-in
|
||
is off, there is too little history to trust the baseline, the cycle
|
||
looks novel, or the drop is not anomalously early. Never raises - the
|
||
anomaly signal must never break detection.
|
||
"""
|
||
try:
|
||
from .ml.engine import ml_models_enabled
|
||
|
||
if not ml_models_enabled(self.config_entry.options):
|
||
return False
|
||
earliest, peak_range = self._terminal_drop_baseline()
|
||
return is_terminal_drop(
|
||
points,
|
||
earliest,
|
||
peak_range,
|
||
float(self.detector.config.stop_threshold_w),
|
||
TERMINAL_DROP_EARLINESS_RATIO,
|
||
TERMINAL_DROP_MIN_PEAK_RATIO,
|
||
TERMINAL_DROP_PEAK_FAMILIAR_TOL,
|
||
)
|
||
except Exception as err: # noqa: BLE001 - anomaly signal must never break detection
|
||
self._logger.debug("Terminal-drop detection skipped: %s", err)
|
||
return False
|
||
|
||
def _terminal_drop_baseline(self) -> tuple[float | None, tuple[float, float] | None]:
|
||
"""Cached (earliest-quiet-offset, historical-peak-range) for this device.
|
||
|
||
Both are learned from the device's completed cycles and used by the
|
||
terminal-drop detector (anomaly + familiarity gates). Keyed by cycle
|
||
count so it refreshes as history grows.
|
||
|
||
The recompute decompresses every completed trace, which is too heavy to run
|
||
on the event loop inside the detector's reading path (issue #311). So this
|
||
NEVER recomputes synchronously: on a miss/stale cache it schedules an
|
||
executor refresh and serves the last known baseline in the meantime (one
|
||
cycle stale is harmless for an anomaly heuristic). Until the first refresh
|
||
lands there is no baseline, so it returns ``(None, None)`` and
|
||
``is_terminal_drop`` defers to the proven slow end-detection."""
|
||
cycles = self.profile_store.get_past_cycles()
|
||
n = len(cycles)
|
||
cache = self._terminal_drop_cache
|
||
if cache is not None and cache[0] == n:
|
||
return cache[1], cache[2]
|
||
self._schedule_terminal_drop_refresh(n)
|
||
if cache is not None:
|
||
return cache[1], cache[2]
|
||
return None, None
|
||
|
||
def _schedule_terminal_drop_refresh(self, n: int) -> None:
|
||
"""Kick a one-shot executor refresh of the terminal-drop baseline for the
|
||
current cycle count, unless one is already in-flight/done for it."""
|
||
if self._terminal_drop_refresh_n == n:
|
||
return
|
||
self._terminal_drop_refresh_n = n
|
||
self.hass.async_create_task(self._async_refresh_terminal_drop_baseline(n))
|
||
|
||
async def _async_refresh_terminal_drop_baseline(self, n: int) -> None:
|
||
"""Recompute the baseline off the event loop and cache it. Never raises -
|
||
the anomaly signal must never break detection."""
|
||
try:
|
||
# Snapshot the list on the loop before handing it to the executor.
|
||
cycles = list(self.profile_store.get_past_cycles())
|
||
stop_threshold = float(self.detector.config.stop_threshold_w)
|
||
earliest, peak_range = await self.hass.async_add_executor_job(
|
||
terminal_drop_baseline,
|
||
cycles,
|
||
stop_threshold,
|
||
TERMINAL_DROP_MIN_QUIET_SPAN_S,
|
||
TERMINAL_DROP_MIN_CLEAN_CYCLES,
|
||
)
|
||
self._terminal_drop_cache = (len(cycles), earliest, peak_range)
|
||
except Exception as err: # noqa: BLE001 - anomaly signal must never break detection
|
||
self._logger.debug("Terminal-drop baseline refresh failed: %s", err)
|
||
# Allow a later reading to retry the refresh for this count.
|
||
if self._terminal_drop_refresh_n == n:
|
||
self._terminal_drop_refresh_n = None
|
||
|
||
def _ml_progress_percent(
|
||
self,
|
||
trace: list[tuple[datetime, float]],
|
||
profile_name: str,
|
||
) -> float | None:
|
||
"""ML completion-fraction estimate (0-100) for the running cycle, or None.
|
||
|
||
Uses the on-device ``remaining_time`` regressor (a ``standardized_linear``
|
||
head with no shipped baseline) to predict how far through the cycle we
|
||
are, learning this device's own progress curve rather than assuming the
|
||
matched profile's median duration. Gated on the ML opt-in and only active
|
||
once training has promoted a regressor; otherwise returns ``None`` so the
|
||
caller keeps the proven phase-aware estimate untouched. Never raises.
|
||
"""
|
||
return progress_mod.ml_progress_percent(
|
||
self.profile_store,
|
||
self.config_entry.options,
|
||
float(self._matched_profile_duration or 0.0),
|
||
trace,
|
||
profile_name,
|
||
self._profile_end_expectation,
|
||
self._logger,
|
||
)
|
||
|
||
def _ml_energy_total(
|
||
self,
|
||
trace: list[tuple[datetime, float]],
|
||
profile_name: str,
|
||
) -> float | None:
|
||
"""Predicted total cycle energy (Wh) from the on-device ``total_energy``
|
||
regressor, or None.
|
||
|
||
The regressor predicts the *energy-completion fraction* (energy so far ÷
|
||
final energy); total = ``energy_so_far / fraction``. Because energy
|
||
accumulates non-linearly (heating front-loads it), this is more stable —
|
||
especially early — than dividing accumulated energy by *time* progress
|
||
(the fallback in :meth:`_update_projected_energy`). Same gating as the
|
||
remaining-time regressor: opt-in, inert until a model is promoted. Never
|
||
raises.
|
||
"""
|
||
return progress_mod.ml_energy_total(
|
||
self.profile_store,
|
||
self.config_entry.options,
|
||
float(self._matched_profile_duration or 0.0),
|
||
trace,
|
||
profile_name,
|
||
self._profile_end_expectation,
|
||
self._logger,
|
||
)
|
||
|
||
def _compute_cycle_quality_score(
|
||
self,
|
||
cycle_data: dict[str, Any],
|
||
past_cycles_snapshot: list[dict[str, Any]] | None = None,
|
||
) -> None:
|
||
"""Score a just-finished cycle with the hybrid_curve_quality model (opt-in).
|
||
|
||
When ML models are enabled for this device, computes P(cycle is a problem)
|
||
and stores it under ``cycle_data["ml_quality_score"]``. A high score means
|
||
the cycle may be mis-detected or corrupt; the learning manager uses it to
|
||
downgrade auto-labeling to a feedback request so the user can confirm.
|
||
Never raises — scoring failure is silently ignored to keep cycle storage safe.
|
||
"""
|
||
try:
|
||
from .ml.engine import ml_models_enabled, resolve_scorer
|
||
from .ml.feature_extraction import quality_features
|
||
|
||
if not ml_models_enabled(self.config_entry.options):
|
||
return
|
||
quality_fn, _ = resolve_scorer("quality", self.profile_store)
|
||
if quality_fn is None:
|
||
return
|
||
|
||
profile_name = cycle_data.get("profile_name")
|
||
if not profile_name:
|
||
return
|
||
|
||
points = decompress_power_data(cycle_data)
|
||
if not points or len(points) < 4:
|
||
return
|
||
|
||
# Build profile median stats from stored labeled cycles.
|
||
durations: list[float] = []
|
||
energies: list[float] = []
|
||
peaks: list[float] = []
|
||
# This function runs in an executor thread and the event loop may
|
||
# append cycles concurrently; iterating (or even copying) the live list
|
||
# here is a data race. Prefer the snapshot taken on the event loop at
|
||
# the call site; only fall back to a local copy for direct callers.
|
||
cycles = (
|
||
past_cycles_snapshot
|
||
if past_cycles_snapshot is not None
|
||
else list(self.profile_store.get_past_cycles())
|
||
)
|
||
for c in cycles:
|
||
if c.get("profile_name") != profile_name:
|
||
continue
|
||
if c.get("duration") is not None:
|
||
durations.append(float(c["duration"]))
|
||
if c.get("energy_wh") is not None:
|
||
energies.append(float(c["energy_wh"]))
|
||
if c.get("max_power") is not None:
|
||
peaks.append(float(c["max_power"]))
|
||
|
||
if not durations:
|
||
return
|
||
|
||
med_dur = float(np.median(durations))
|
||
med_energy = float(np.median(energies)) if energies else 500.0
|
||
med_peak = float(np.median(peaks)) if peaks else 500.0
|
||
|
||
match_conf = float(cycle_data.get("match_confidence") or 0.0)
|
||
conf_known = match_conf > 0
|
||
proxy_dist = max(0.0, 1.0 - match_conf) if conf_known else 0.25
|
||
proxy_margin = match_conf if conf_known else 0.30
|
||
proxy_fit = match_conf if conf_known else 0.75
|
||
|
||
feat = quality_features(
|
||
points=points,
|
||
profile_median_duration_s=med_dur,
|
||
profile_median_energy_wh=med_energy,
|
||
profile_median_peak_w=med_peak,
|
||
profile_distance=proxy_dist,
|
||
label_margin=proxy_margin,
|
||
profile_fit_score=proxy_fit,
|
||
flag_count=len(cycle_data.get("artifacts", [])),
|
||
)
|
||
score = round(float(quality_fn(feat)), 3)
|
||
cycle_data["ml_quality_score"] = score
|
||
self._logger.debug(
|
||
"ML quality score (profile=%s): %.3f", profile_name, score
|
||
)
|
||
except Exception: # noqa: BLE001 - never break cycle storage
|
||
pass
|
||
|
||
def _resolve_energy_price(self) -> float | None:
|
||
"""Current energy price per kWh, or None when none is configured.
|
||
|
||
A price entity (e.g. a dynamic tariff) takes precedence over the static
|
||
value. Used to freeze each cycle's cost at completion time.
|
||
"""
|
||
options = self.config_entry.options
|
||
price_entity = options.get(CONF_ENERGY_PRICE_ENTITY)
|
||
if price_entity:
|
||
state = self.hass.states.get(price_entity)
|
||
if state is not None:
|
||
try:
|
||
return float(state.state)
|
||
except (ValueError, TypeError):
|
||
pass
|
||
static = options.get(CONF_ENERGY_PRICE_STATIC)
|
||
if static is not None:
|
||
try:
|
||
return float(static)
|
||
except (ValueError, TypeError):
|
||
pass
|
||
return None
|
||
|
||
def _read_energy_meter(self) -> tuple[float, str] | None:
|
||
"""Read the configured external energy meter, normalized to Wh.
|
||
|
||
Returns ``(value_wh, entity_id)`` or ``None`` when no meter is configured
|
||
or its reading is not usable (unknown/unavailable/non-numeric state, or an
|
||
unrecognised unit). Never raises -- every failure path returns ``None`` so
|
||
the caller falls back to the integrated energy (issue #316).
|
||
"""
|
||
entity_id = self.config_entry.options.get(CONF_ENERGY_SENSOR)
|
||
if not entity_id:
|
||
return None
|
||
state = self.hass.states.get(entity_id)
|
||
if state is None or state.state in (None, "unknown", "unavailable", ""):
|
||
return None
|
||
try:
|
||
value = float(state.state)
|
||
except (ValueError, TypeError):
|
||
return None
|
||
unit = str(state.attributes.get("unit_of_measurement") or "").strip().lower()
|
||
# Normalize to Wh. An unrecognised unit is treated as unusable so a
|
||
# mis-configured entity falls back rather than reporting a wrong figure.
|
||
scale = {"wh": 1.0, "kwh": 1000.0, "mwh": 1_000_000.0}.get(unit)
|
||
if scale is None:
|
||
return None
|
||
return value * scale, entity_id
|
||
|
||
def _snapshot_energy_meter_start(self) -> None:
|
||
"""Capture the meter reading at cycle start (issue #316)."""
|
||
snap = self._read_energy_meter()
|
||
if snap is None:
|
||
self._energy_meter_start = None
|
||
self._energy_meter_source = None
|
||
else:
|
||
self._energy_meter_start, self._energy_meter_source = snap
|
||
|
||
def _compute_meter_energy_wh(self) -> float | None:
|
||
"""Cycle energy from the external meter's start->end delta, or None.
|
||
|
||
Falls back (returns ``None``) when: no start was captured; no meter is
|
||
configured now; the configured entity differs from the one snapshotted at
|
||
start (source changed mid-cycle -- no cross-meter delta); the current
|
||
reading is unusable; or the delta is <= 0 (counter reset or stuck plug).
|
||
"""
|
||
start = self._energy_meter_start
|
||
source = self._energy_meter_source
|
||
if start is None or source is None:
|
||
return None
|
||
cur = self._read_energy_meter()
|
||
if cur is None:
|
||
return None
|
||
cur_wh, cur_source = cur
|
||
if cur_source != source:
|
||
return None
|
||
delta = cur_wh - start
|
||
if delta <= 0:
|
||
return None
|
||
return delta
|
||
|
||
@staticmethod
|
||
def _cycle_report_energy_wh(cycle_data: dict[str, Any]) -> float:
|
||
"""User-facing reported energy (Wh): meter value if present, else integrated.
|
||
|
||
The integrated ``energy_wh`` is always stored and used internally (matching,
|
||
ML, anomaly, envelopes); only cost/lifetime/notifications/panel display
|
||
prefer the more accurate meter figure when one was captured (issue #316).
|
||
"""
|
||
meter = cycle_data.get("energy_meter_wh")
|
||
if meter is not None:
|
||
try:
|
||
return float(meter)
|
||
except (ValueError, TypeError):
|
||
pass
|
||
try:
|
||
return float(cycle_data.get("energy_wh", 0.0))
|
||
except (ValueError, TypeError):
|
||
return 0.0
|
||
|
||
def _augment_active_snapshot(self, snapshot: dict[str, Any]) -> dict[str, Any]:
|
||
"""Add manager-owned fields to a detector snapshot before persisting.
|
||
|
||
The detector snapshot only carries detector state; these fields are owned
|
||
by the manager and must survive a restart alongside it. Kept in one place
|
||
so every save site (shutdown, periodic, pause, resume) stays consistent.
|
||
"""
|
||
snapshot["manual_program"] = self._manual_program_active
|
||
snapshot["notified_start"] = self._notified_start
|
||
snapshot["start_event_fired"] = self._start_event_fired
|
||
snapshot["is_user_paused"] = self._is_user_paused
|
||
snapshot["user_pause_start"] = (
|
||
self._user_pause_start.isoformat() if self._user_pause_start else None
|
||
)
|
||
snapshot["total_user_paused_seconds"] = self._total_user_paused_seconds
|
||
snapshot["energy_meter_start"] = self._energy_meter_start
|
||
snapshot["energy_meter_source"] = self._energy_meter_source
|
||
return snapshot
|
||
|
||
@staticmethod
|
||
def _format_vs_typical(
|
||
duration: float,
|
||
median: float | None,
|
||
*,
|
||
longer_template: str = "{pct}% longer than usual",
|
||
shorter_template: str = "{pct}% shorter than usual",
|
||
) -> str:
|
||
"""Human comparison of a cycle's duration to its profile median.
|
||
|
||
Returns "" when there is no usable median or the difference is under 1%.
|
||
This fills the ``vs_typical`` variable of the finish-message template. The
|
||
text itself is fixed (not user-editable), so it is localizable: callers pass
|
||
the resolved ``options.error.vs_typical_*`` templates; the English defaults
|
||
here mirror strings.json and are used as the resilient fallback.
|
||
"""
|
||
try:
|
||
if not median or float(median) <= 0:
|
||
return ""
|
||
pct = round((float(duration) - float(median)) / float(median) * 100)
|
||
except (ValueError, TypeError, ZeroDivisionError):
|
||
return ""
|
||
try:
|
||
if pct >= 1:
|
||
return longer_template.format(pct=pct)
|
||
if pct <= -1:
|
||
return shorter_template.format(pct=abs(pct))
|
||
except (KeyError, IndexError, ValueError):
|
||
# Malformed translation template; fall back to the English default.
|
||
if pct >= 1:
|
||
return f"{pct}% longer than usual"
|
||
if pct <= -1:
|
||
return f"{abs(pct)}% shorter than usual"
|
||
return ""
|
||
|
||
def _peak_rate_tip(self, options: dict[str, Any], price: float | None) -> str:
|
||
"""Return a peak-rate advisory tip for the start notification, or "".
|
||
|
||
Appended only when a positive ``peak_rate_threshold`` is configured and the
|
||
current price meets/exceeds it. Purely informational — no scheduling or
|
||
appliance control. Never raises; a bad threshold is skipped silently.
|
||
"""
|
||
try:
|
||
raw = options.get(CONF_PEAK_RATE_THRESHOLD)
|
||
if raw in (None, ""):
|
||
return ""
|
||
threshold = float(raw)
|
||
if threshold <= 0 or price is None or float(price) < threshold:
|
||
return ""
|
||
tip_template = options.get(CONF_PEAK_RATE_MESSAGE) or DEFAULT_PEAK_RATE_MESSAGE
|
||
return self._safe_format_template(
|
||
tip_template,
|
||
fallback_template=DEFAULT_PEAK_RATE_MESSAGE,
|
||
device=self.config_entry.title,
|
||
price=f"{float(price):.3f}",
|
||
)
|
||
except (ValueError, TypeError):
|
||
return ""
|
||
|
||
async def _async_process_cycle_end(
|
||
self, cycle_data: dict[str, Any], cycle_token: str | None = None
|
||
) -> None:
|
||
"""Process cycle completion asynchronously (heavy tasks).
|
||
|
||
``cycle_token`` is the ``_ranking_snapshot_cycle_id`` captured when this cycle
|
||
ended. The terminal-state reset at the tail is skipped if a new cycle has
|
||
started since (token changed), so back-to-back cycles are not clobbered (B1).
|
||
"""
|
||
|
||
# FINAL PROFILE MATCH: If still detecting, try one last match with complete cycle data
|
||
if self._current_program in ("detecting...", "restored..."):
|
||
await self._run_final_match_from_cycle_data(cycle_data)
|
||
|
||
# B1: freeze THIS cycle's live context into immutable locals now, before the
|
||
# persistence / auto-label / lifetime-energy awaits below. A new cycle can
|
||
# start synchronously during any of those awaits (a back-to-back load drives
|
||
# the detector into a fresh RUNNING via _on_state_change, which rolls
|
||
# _current_program back to "detecting..." and resets the match fields). The
|
||
# tail (event payload, finish notification, learning inputs) must describe the
|
||
# cycle that just finished, not whatever the live fields hold by the time each
|
||
# await returns. The final match above is what determines these values for
|
||
# this cycle, so capture right after it.
|
||
program = self._current_program
|
||
match_result = self._last_match_result
|
||
match_confidence = self._last_match_confidence
|
||
matched_profile_duration = self._matched_profile_duration
|
||
cycle_anomaly = self._cycle_anomaly
|
||
overrun_ratio = self._overrun_ratio
|
||
|
||
# If we had a runtime match, attach the profile name for persistence
|
||
if (
|
||
program
|
||
and program not in ("off", "detecting...", "restored...")
|
||
and program in self.profile_store.get_profiles()
|
||
):
|
||
cycle_data["profile_name"] = program
|
||
cycle_data["label_source"] = "auto_match"
|
||
if match_confidence:
|
||
cycle_data["match_confidence"] = float(match_confidence)
|
||
|
||
# Attach extensive debug data if available (and configured)
|
||
if match_result:
|
||
ranking = getattr(match_result, "ranking", [])
|
||
# Top-5 ranking stored unconditionally (small, high training value).
|
||
# SANITIZE: strip heavy current/sample arrays — this field is NOT in the
|
||
# EVENT_CYCLE_ENDED exclusion set, so it must stay small (32KB limit).
|
||
cycle_data["match_ranking_top5"] = _sanitize_ranking(ranking)
|
||
cycle_data["debug_data"] = {
|
||
"ranking": ranking,
|
||
"details": getattr(match_result, "debug_details", {}),
|
||
"ambiguous": getattr(match_result, "is_ambiguous", False),
|
||
}
|
||
|
||
# Post-Cycle Auto-Labeling (if not already matched)
|
||
# Offload this match too if needed
|
||
if not cycle_data.get("profile_name") and self._auto_label_confidence > 0:
|
||
res = await self.profile_store.async_match_profile(
|
||
cycle_data["power_data"], cycle_data["duration"]
|
||
)
|
||
if res.best_profile and res.confidence >= self._auto_label_confidence:
|
||
cycle_data["profile_name"] = res.best_profile
|
||
cycle_data["label_source"] = "auto_label_post"
|
||
cycle_data["match_confidence"] = float(res.confidence)
|
||
# Top-5 from post-cycle match (may differ from live match ranking).
|
||
# Sanitize: strip heavy current/sample arrays (these fields are NOT
|
||
# in the fired-event exclusion set, so they must stay small to keep
|
||
# EVENT_CYCLE_ENDED under HA's 32KB event-data limit).
|
||
cycle_data["match_ranking_top5"] = _sanitize_ranking(
|
||
getattr(res, "ranking", [])
|
||
)
|
||
self._logger.info(
|
||
"Post-cycle auto-labeled as '%s' (confidence: %.2f)",
|
||
res.best_profile,
|
||
res.confidence,
|
||
)
|
||
|
||
# Back-fill confirmed label on any ranking snapshots captured during this cycle
|
||
# so the live_match on-device trainer can use them as labelled examples.
|
||
_start_iso = cycle_data.get("start_time")
|
||
_confirmed_profile = cycle_data.get("profile_name")
|
||
if _start_iso and _confirmed_profile:
|
||
try:
|
||
self.profile_store.confirm_match_ranking_snapshots(
|
||
_start_iso,
|
||
_confirmed_profile,
|
||
# Use the token captured when THIS cycle ended, not the live
|
||
# field, which may already have rolled to a newly-started cycle
|
||
# during the awaits above (else this cycle's snapshots go
|
||
# unlabelled and a new cycle's snapshot gets mislabelled).
|
||
cycle_id=cycle_token or None,
|
||
)
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
|
||
# Compute envelope conformance for the matched profile.
|
||
# Stored as cycle_data["envelope_conformance"] so the panel and quality
|
||
# gate can display/use it. Only runs when we have a profile + power trace.
|
||
_ep = cycle_data.get("profile_name")
|
||
_pd = cycle_data.get("power_data")
|
||
if _ep and isinstance(_pd, list) and len(_pd) >= 4:
|
||
try:
|
||
from .time_utils import power_data_to_offsets # noqa: PLC0415
|
||
_pts = [(float(o), float(p)) for o, p in power_data_to_offsets(_pd, _start_iso)]
|
||
if len(_pts) >= 4:
|
||
conformance_rec = self.profile_store.compute_envelope_conformance(_ep, _pts)
|
||
if conformance_rec is not None:
|
||
cycle_data["envelope_conformance"] = conformance_rec.get("conformance")
|
||
# Transient artifacts (door-open pauses, out-of-band dips/spikes)
|
||
# for graph markers + a Cycles-list badge; [] when none.
|
||
artifacts = self.profile_store.detect_cycle_artifacts(_ep, _pts)
|
||
if artifacts:
|
||
cycle_data["artifacts"] = artifacts
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
|
||
# Freeze the runtime overrun anomaly onto the cycle for panel badging.
|
||
# "overrun" means the cycle ran materially longer than its matched
|
||
# profile's typical duration; purely informational (never a notification).
|
||
if cycle_anomaly and cycle_anomaly != "none":
|
||
cycle_data["anomaly"] = cycle_anomaly
|
||
if overrun_ratio > 0:
|
||
cycle_data["overrun_ratio"] = round(float(overrun_ratio), 3)
|
||
|
||
# A1: Underrun check — computed post-cycle only, not a live signal.
|
||
# Only applied when no runtime anomaly was detected (underrun and overrun are mutually exclusive).
|
||
try:
|
||
if not cycle_data.get("anomaly") or cycle_data["anomaly"] == "none":
|
||
_uc_profile = cycle_data.get("profile_name")
|
||
_uc_dur = float(cycle_data.get("duration", 0))
|
||
if _uc_profile and _uc_dur > 0:
|
||
_uc_median = self.profile_store.get_profile_median_duration(_uc_profile)
|
||
if (
|
||
isinstance(_uc_median, (int, float))
|
||
and not isinstance(_uc_median, bool)
|
||
and _uc_median > 0
|
||
and _uc_dur < _uc_median * CYCLE_UNDERRUN_ANOMALY_RATIO
|
||
):
|
||
cycle_data["anomaly"] = "underrun"
|
||
cycle_data["underrun_ratio"] = round(_uc_dur / _uc_median, 3)
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
|
||
# A2: Energy spike/low anomaly — stored separately from duration anomaly.
|
||
try:
|
||
_ea_profile = cycle_data.get("profile_name")
|
||
_ea_energy = float(cycle_data.get("energy_wh", 0))
|
||
if _ea_profile and _ea_energy > 0:
|
||
_ea_stats = self.profile_store.get_profile_energy_stats(_ea_profile)
|
||
if (
|
||
isinstance(_ea_stats, dict)
|
||
and isinstance(_ea_stats.get("std_wh"), (int, float))
|
||
and _ea_stats["std_wh"] > 0
|
||
):
|
||
_ea_z = (_ea_energy - _ea_stats["avg_wh"]) / _ea_stats["std_wh"]
|
||
cycle_data["energy_z_score"] = round(_ea_z, 2)
|
||
if _ea_z > ENERGY_ANOMALY_Z_THRESHOLD:
|
||
cycle_data["energy_anomaly"] = "energy_spike"
|
||
elif _ea_z < -ENERGY_ANOMALY_Z_THRESHOLD:
|
||
cycle_data["energy_anomaly"] = "energy_low"
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
|
||
# Cache post-cycle anomaly data so sensor attributes surface it while idle.
|
||
self._last_cycle_post_anomaly = {
|
||
k: cycle_data[k]
|
||
for k in ("anomaly", "underrun_ratio", "energy_anomaly", "energy_z_score")
|
||
if k in cycle_data
|
||
}
|
||
|
||
# Store any HA restart gaps that occurred during this cycle.
|
||
# The panel shades these regions in the power trace and shows a badge.
|
||
# Matching always uses real readings only (no synthetic fill in power_data).
|
||
# Copy them onto the cycle now but keep the source list intact until the
|
||
# cycle is confirmed persisted (below) — clearing here would lose them if
|
||
# async_add_cycle() fails.
|
||
restart_gaps_snapshot: list[dict[str, Any]] | None = None
|
||
if self._restart_gaps:
|
||
restart_gaps_snapshot = list(self._restart_gaps)
|
||
cycle_data["restart_gaps"] = restart_gaps_snapshot
|
||
|
||
# Freeze the energy cost onto the cycle using the price in effect NOW, so
|
||
# later price changes never rewrite historical costs. Stored as a number
|
||
# (currency per the configured price's unit); absent when no price is set.
|
||
price = self._resolve_energy_price()
|
||
if price is not None:
|
||
cycle_data["energy_price"] = price
|
||
cycle_data["cost"] = round(
|
||
self._cycle_report_energy_wh(cycle_data) / 1000.0 * price, 4
|
||
)
|
||
|
||
# Score cycle quality with the ML model before persisting so the score is
|
||
# stored on the cycle record and available to the learning manager immediately.
|
||
# Must run BEFORE async_add_cycle so get_past_cycles() inside the scorer does
|
||
# not yet include the current cycle, keeping reference statistics uncontaminated.
|
||
# Only opted-in devices reach the scorer, and only then is it offloaded to the
|
||
# executor: on a long trace its NumPy feature extraction is O(N) and must not
|
||
# block the event loop (mirrors the profile matcher). Gating here avoids a
|
||
# pointless thread-hop for the default (ML-off) case where the scorer no-ops.
|
||
# The scorer mutates only cycle_data (nothing else touches it here) and never
|
||
# raises, so this is executor-safe.
|
||
from .ml.engine import ml_models_enabled # noqa: PLC0415
|
||
|
||
if ml_models_enabled(self.config_entry.options):
|
||
# Snapshot past_cycles on the event loop before offloading: iterating
|
||
# (or copying) the live list inside the executor races the loop
|
||
# appending this just-finished cycle.
|
||
past_cycles_snapshot = list(self.profile_store.get_past_cycles())
|
||
await self.hass.async_add_executor_job(
|
||
self._compute_cycle_quality_score, cycle_data, past_cycles_snapshot
|
||
)
|
||
|
||
# Add cycle to store immediately (still sync but offloadable parts optimized
|
||
# internally if possible)
|
||
# Note: add_cycle is mostly safe (signature calc is O(N) but fast enough for
|
||
# single cycle).
|
||
# We could offload signature calc to analysis logic if really needed, but let's
|
||
# stick to match profile optimization first.
|
||
cycle_persisted = False
|
||
try:
|
||
await self.profile_store.async_add_cycle(cycle_data)
|
||
cycle_persisted = True
|
||
# The cycle (with its restart_gaps) is now durably stored, so it is safe
|
||
# to drop the live buffer. Doing this only after a confirmed persist means
|
||
# a failed save keeps the gaps for the next cycle-end attempt.
|
||
if restart_gaps_snapshot is not None:
|
||
self._restart_gaps.clear()
|
||
profile_name = cycle_data.get("profile_name")
|
||
if profile_name:
|
||
await self.profile_store.async_rebuild_envelope(profile_name)
|
||
except Exception as e: # pylint: disable=broad-exception-caught
|
||
self._logger.error("Failed to add cycle to store: %s", e)
|
||
|
||
# C2: bump the persisted lifetime cycle counter. Unlike ``cycle_count``
|
||
# (== len(history), which regresses when history is trimmed/merged), this
|
||
# monotonic counter only ever increments — and only on a real persisted
|
||
# cycle — so milestones stay correct across retention limits. Captured here
|
||
# for the milestone check below. The write is persisted by the lifetime-energy
|
||
# save immediately after (same store, one save).
|
||
prev_lifetime_count: int | None = None
|
||
cur_lifetime_count: int | None = None
|
||
if cycle_persisted:
|
||
try:
|
||
prev_lifetime_count = self._lifetime_cycle_count()
|
||
cur_lifetime_count = prev_lifetime_count + 1
|
||
# In-memory only; persisted by the batched lifetime-energy save below.
|
||
self.profile_store.set_lifetime_cycle_count(cur_lifetime_count)
|
||
except Exception: # noqa: BLE001 - counter must never break cycle end
|
||
prev_lifetime_count = None
|
||
cur_lifetime_count = None
|
||
|
||
# B1: accumulate lifetime energy for the HA Energy dashboard sensor. Runs
|
||
# exactly once per persisted cycle so the TOTAL_INCREASING meter never
|
||
# double-counts. Never breaks cycle end.
|
||
if cycle_persisted:
|
||
try:
|
||
await self.profile_store.async_add_lifetime_energy_wh(
|
||
self._cycle_report_energy_wh(cycle_data)
|
||
)
|
||
except Exception as e: # pylint: disable=broad-exception-caught
|
||
self._logger.debug("Failed to accumulate lifetime energy: %s", e)
|
||
|
||
# Ensure cycle has a stable ID even if store add failed (or did not mutate).
|
||
if not cycle_data.get("id"):
|
||
try:
|
||
unique_str = f"{cycle_data['start_time']}_{cycle_data['duration']}"
|
||
cycle_data["id"] = hashlib.sha256(unique_str.encode()).hexdigest()[:12]
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
|
||
# B1: only clear the active-cycle snapshot if it still belongs to THIS cycle.
|
||
# If a new cycle started during the awaits above, it now owns the active
|
||
# snapshot; clearing it here would strip the new cycle's restart-resilience.
|
||
if cycle_token is None or self._ranking_snapshot_cycle_id == cycle_token:
|
||
self._spawn_tracked(self.profile_store.async_clear_active_cycle())
|
||
|
||
# Auto post-process: merge fragmented cycles from last 3 hours
|
||
self._spawn_tracked(self._run_post_cycle_processing())
|
||
|
||
# Prepare cycle data for event (enrich if needed)
|
||
# IMPORTANT: Exclude large fields to prevent exceeding HA's 32KB event data limit
|
||
excluded_fields = {"power_data", "debug_data", "power_trace"}
|
||
event_cycle_data = {
|
||
k: v for k, v in cycle_data.items() if k not in excluded_fields
|
||
}
|
||
event_cycle_data["device_type"] = self.device_type
|
||
# Add program if missing or generic (use THIS cycle's captured program, not
|
||
# the live field which may already belong to a newly-started cycle).
|
||
if "profile_name" not in event_cycle_data and program:
|
||
event_cycle_data["profile_name"] = program
|
||
|
||
if self._notify_fire_events:
|
||
self.hass.bus.async_fire(
|
||
EVENT_CYCLE_ENDED,
|
||
{
|
||
"entry_id": self.entry_id,
|
||
"device_name": self.config_entry.title,
|
||
"cycle_data": event_cycle_data,
|
||
"program": event_cycle_data.get("profile_name", "unknown"),
|
||
"duration": event_cycle_data.get("duration"),
|
||
"start_time": event_cycle_data.get("start_time"),
|
||
"end_time": event_cycle_data.get("end_time") or dt_util.now().isoformat(),
|
||
},
|
||
)
|
||
|
||
# Purge pending live entries and reset counters, but don't send a service-level
|
||
# clear: the finished notification below reuses the lifecycle tag and replaces
|
||
# the live card in place (sending a clear first would cause a dismiss/recreate
|
||
# flicker). The action-based clear marker still fires for action templates.
|
||
self._clear_live_progress_notification(clear_services=False)
|
||
|
||
# Send notification if enabled
|
||
if self._notify_finish_services or self._notify_actions:
|
||
msg_template = self.config_entry.options.get(CONF_NOTIFY_FINISH_MESSAGE, DEFAULT_NOTIFY_FINISH_MESSAGE)
|
||
duration_min = int(cycle_data['duration'] / 60)
|
||
program_name = event_cycle_data.get("profile_name", "unknown")
|
||
|
||
energy_kwh = round(self._cycle_report_energy_wh(cycle_data) / 1000, 3)
|
||
|
||
# Reuse the cost frozen onto the cycle above (same price resolution).
|
||
cost_val = cycle_data.get("cost")
|
||
cost_str = f"{cost_val:.2f}" if cost_val is not None else ""
|
||
|
||
# B3: extra finish-notification template variables. All are safe to
|
||
# ignore in a template — str.format drops unused kwargs.
|
||
time_finished = dt_util.now().strftime("%H:%M")
|
||
# Prefer the monotonic lifetime counter (falls back to the retained count).
|
||
finished_cycle_count = (
|
||
cur_lifetime_count if cur_lifetime_count is not None else self.cycle_count
|
||
)
|
||
vs_typical = ""
|
||
matched_name = cycle_data.get("profile_name")
|
||
if matched_name:
|
||
_median = self.profile_store.get_profile_median_duration(matched_name)
|
||
vs_typical = self._format_vs_typical(
|
||
cycle_data.get("duration", 0.0),
|
||
_median,
|
||
longer_template=self._timer_ui_strings.get(
|
||
"vs_typical_longer", "{pct}% longer than usual"
|
||
),
|
||
shorter_template=self._timer_ui_strings.get(
|
||
"vs_typical_shorter", "{pct}% shorter than usual"
|
||
),
|
||
)
|
||
|
||
msg = self._safe_format_template(
|
||
msg_template,
|
||
fallback_template=DEFAULT_NOTIFY_FINISH_MESSAGE,
|
||
device=self.config_entry.title,
|
||
duration=duration_min,
|
||
program=program_name,
|
||
energy_kwh=f"{energy_kwh:.3f}",
|
||
cost=cost_str,
|
||
time_finished=time_finished,
|
||
cycle_count=finished_cycle_count,
|
||
vs_typical=vs_typical,
|
||
)
|
||
self._dispatch_notification(
|
||
msg,
|
||
event_type=NOTIFY_EVENT_FINISH,
|
||
extra_vars={
|
||
"duration_minutes": duration_min,
|
||
"duration_seconds": cycle_data["duration"],
|
||
"program": program_name,
|
||
"energy_kwh": energy_kwh,
|
||
"cost": cost_str,
|
||
"time_finished": time_finished,
|
||
"cycle_count": finished_cycle_count,
|
||
"vs_typical": vs_typical,
|
||
# Same lifecycle tag as start/live so the finished alert replaces
|
||
# the live notification in place. No live_update/alert_once here,
|
||
# so the companion app surfaces it with sound.
|
||
"tag": self._lifecycle_tag,
|
||
# C3: end the iOS Live Activity (mobile_app_* only downstream).
|
||
"activity": "end",
|
||
},
|
||
)
|
||
|
||
# C2: milestone (cycle-count achievement) notification. Fires at most once per
|
||
# cycle, only when the cycle actually persisted (so the lifetime count is real)
|
||
# and a finish delivery channel is configured. Respects quiet hours via
|
||
# _dispatch_notification's finish-type gate.
|
||
if cycle_persisted:
|
||
self._maybe_notify_milestone(prev_lifetime_count, cur_lifetime_count)
|
||
|
||
# Request user feedback if we had a confident match.
|
||
# AND perform learning analysis on the completed cycle.
|
||
# IMPORTANT: this must happen before we clear match state.
|
||
# Only run when the cycle was actually persisted — an unpersisted cycle
|
||
# has no store entry to reference, so a pending-feedback record would
|
||
# dangle forever. Use THIS cycle's captured match context (not the live
|
||
# fields, which may already belong to a newly-started cycle after the awaits).
|
||
if cycle_persisted:
|
||
self.learning_manager.process_cycle_end(
|
||
cycle_data,
|
||
detected_profile=program,
|
||
confidence=match_confidence or 0.0,
|
||
predicted_duration=matched_profile_duration,
|
||
match_result=match_result,
|
||
)
|
||
|
||
# B1: a new cycle may have started while the heavy post-processing above was
|
||
# awaiting. If so, the manager's live-cycle fields (_current_program,
|
||
# _cycle_start_time, _ranking_snapshot_cycle_id, progress) now belong to the
|
||
# NEW cycle. Zeroing them here — and re-arming the state-expiry timer — used to
|
||
# clobber the running cycle and, once it hit PAUSED/ENDING past the reset delay,
|
||
# reset it to Off mid-run. Detect the new cycle via the identity token and skip
|
||
# the terminal-state reset; cycle A was already persisted/learned/notified above.
|
||
if cycle_token is not None and self._ranking_snapshot_cycle_id != cycle_token:
|
||
self._logger.debug(
|
||
"Cycle-end post-processing completed after a new cycle started "
|
||
"(token %s -> %s); skipping terminal-state reset to preserve the "
|
||
"live cycle.",
|
||
cycle_token,
|
||
self._ranking_snapshot_cycle_id,
|
||
)
|
||
self._notify_update()
|
||
return
|
||
|
||
# Clear all state and timers - zero everything out
|
||
self._current_program = "off"
|
||
self._manual_program_active = False
|
||
self._notified_pre_completion = False
|
||
self._time_remaining = None
|
||
self._matched_profile_duration = None
|
||
self._last_estimate_time = None
|
||
self._last_match_result = None # Clear so phase sensor resets to "Off" (issue #192)
|
||
self._cycle_progress = 100.0 # 100% = cycle complete
|
||
self._cycle_completed_time = dt_util.now()
|
||
self._cycle_start_time = None
|
||
self._ranking_snapshot_cycle_id = ""
|
||
self._reset_live_notification_state()
|
||
|
||
# Reset pause tracking for the next cycle
|
||
self._is_user_paused = False
|
||
self._user_pause_start = None
|
||
self._total_user_paused_seconds = 0.0
|
||
|
||
# Enter Clean state if door sensor is configured and door is currently closed
|
||
self._is_clean_state = False
|
||
self._clean_state_start = None
|
||
self._notified_clean_laundry = False
|
||
if self._door_sensor_entity:
|
||
door_state = self.hass.states.get(self._door_sensor_entity)
|
||
if door_state and door_state.state == "off": # binary_sensor: off = closed
|
||
self._is_clean_state = True
|
||
self._clean_state_start = dt_util.now()
|
||
self._logger.debug(
|
||
"Cycle ended with door closed: entering Clean state"
|
||
)
|
||
|
||
# Start progress reset timer to go back to 0% after user unload window
|
||
self._start_state_expiry_timer()
|
||
|
||
self._notify_update()
|
||
|
||
@property
|
||
def profile_sample_repair_stats(self) -> dict[str, int] | None:
|
||
"""Return statistics from profile sample repair operation."""
|
||
return self._profile_sample_repair_stats
|
||
|
||
@property
|
||
def suggestions(self) -> dict[str, Any]:
|
||
"""Suggested settings computed by learning/heuristics (never auto-applied)."""
|
||
return self.profile_store.get_suggestions()
|
||
|
||
# ------------------------------------------------------------------
|
||
# C1 - Quiet hours (do-not-disturb window)
|
||
# ------------------------------------------------------------------
|
||
def _quiet_hours_bounds(self) -> tuple[int, int] | None:
|
||
"""Return validated (start_hour, end_hour) or None when the feature is off.
|
||
|
||
Off when either hour is unset/None/non-int/out-of-range, or start == end.
|
||
"""
|
||
return notif_rules.quiet_hours_bounds(self.config_entry.options)
|
||
|
||
def _in_quiet_hours(self, when: datetime | None = None) -> bool:
|
||
"""Return True when ``when`` (default now) falls inside the quiet window.
|
||
|
||
Supports windows that wrap midnight (start > end, e.g. 22 -> 7 means
|
||
22:00-06:59). The end hour is exclusive at the hour granularity, so a window
|
||
of start=22, end=7 covers hours 22, 23, 0..6.
|
||
"""
|
||
return notif_rules.in_quiet_hours(
|
||
self._quiet_hours_bounds(), when or dt_util.now()
|
||
)
|
||
|
||
def _seconds_until_quiet_end(self, when: datetime | None = None) -> float:
|
||
"""Seconds from ``when`` until the next end-of-quiet-window boundary (end:00).
|
||
|
||
Returns 0.0 when the feature is off or when not currently in quiet hours.
|
||
"""
|
||
return notif_rules.seconds_until_quiet_end(
|
||
self._quiet_hours_bounds(), when or dt_util.now()
|
||
)
|
||
|
||
def _queue_quiet_hours_notification(
|
||
self,
|
||
message: str,
|
||
*,
|
||
title: str | None,
|
||
icon: str | None,
|
||
event_type: str | None,
|
||
extra_vars: dict[str, Any] | None,
|
||
) -> None:
|
||
"""Park a finish-type notification until the quiet window ends."""
|
||
self._quiet_pending_notifications.append(
|
||
{
|
||
"message": message,
|
||
"title": title,
|
||
"icon": icon,
|
||
"event_type": event_type,
|
||
"extra_vars": extra_vars,
|
||
}
|
||
)
|
||
self._schedule_quiet_hours_flush()
|
||
|
||
def _schedule_quiet_hours_flush(self) -> None:
|
||
"""(Re)arm the single async_call_later timer that flushes the quiet queue."""
|
||
if self._remove_quiet_hours_timer is not None:
|
||
# A timer is already pending; keep it (all queued items share one release).
|
||
return
|
||
delay = self._seconds_until_quiet_end()
|
||
if delay <= 0:
|
||
# Not actually in quiet hours (defensive) -> flush immediately.
|
||
self._flush_quiet_hours_notifications()
|
||
return
|
||
|
||
@callback
|
||
def _fire(_now: datetime) -> None:
|
||
self._remove_quiet_hours_timer = None
|
||
self._flush_quiet_hours_notifications()
|
||
|
||
self._remove_quiet_hours_timer = async_call_later(self.hass, delay, _fire)
|
||
|
||
def _flush_quiet_hours_notifications(self) -> None:
|
||
"""Deliver every queued quiet-hours notification (same service/message/tag)."""
|
||
if self._remove_quiet_hours_timer is not None:
|
||
self._remove_quiet_hours_timer()
|
||
self._remove_quiet_hours_timer = None
|
||
if not self._quiet_pending_notifications:
|
||
return
|
||
pending = list(self._quiet_pending_notifications)
|
||
self._quiet_pending_notifications = []
|
||
for entry in pending:
|
||
# Disable ONLY the quiet-hours re-hold (the window is closing), but keep
|
||
# presence gating on: if nobody is home and notify_only_when_home is set,
|
||
# the item must stay queued in the presence queue rather than fire into an
|
||
# empty house. (Previously allow_deferral=False disabled both, delivering
|
||
# to nobody.)
|
||
self._dispatch_notification(
|
||
entry["message"],
|
||
title=entry.get("title"),
|
||
icon=entry.get("icon"),
|
||
event_type=entry.get("event_type"),
|
||
extra_vars=entry.get("extra_vars"),
|
||
allow_deferral=False,
|
||
allow_presence_deferral=True,
|
||
)
|
||
|
||
def _cancel_quiet_hours_timer(self) -> None:
|
||
"""Cancel the pending quiet-hours release timer (shutdown/unload)."""
|
||
if self._remove_quiet_hours_timer is not None:
|
||
self._remove_quiet_hours_timer()
|
||
self._remove_quiet_hours_timer = None
|
||
|
||
# ------------------------------------------------------------------
|
||
# C2 - Milestone (cycle-count achievement) notifications
|
||
# ------------------------------------------------------------------
|
||
@staticmethod
|
||
def _milestone_crossed(
|
||
prev_count: int, cur_count: int, milestones: Any
|
||
) -> int | None:
|
||
"""Return the milestone just crossed, or None.
|
||
|
||
A milestone ``m`` is crossed when ``prev_count < m <= cur_count``. Empty or
|
||
malformed ``milestones`` is a no-op (returns None). If several are crossed in
|
||
one step the largest is returned so a single, most-significant notification
|
||
fires.
|
||
"""
|
||
return notif_rules.milestone_crossed(prev_count, cur_count, milestones)
|
||
|
||
def _lifetime_cycle_count(self) -> int:
|
||
"""Persisted monotonic lifetime completed-cycle count.
|
||
|
||
Unlike ``cycle_count`` (== len(retained history)), this only ever increments
|
||
and never regresses when history is trimmed/merged, so it is the correct basis
|
||
for milestone crossings. Falls back to ``cycle_count`` if the persisted value
|
||
is unavailable. Never raises.
|
||
"""
|
||
try:
|
||
return self.profile_store.get_lifetime_cycle_count()
|
||
except Exception: # noqa: BLE001
|
||
try:
|
||
return int(self.cycle_count)
|
||
except Exception: # noqa: BLE001
|
||
return 0
|
||
|
||
def _maybe_notify_milestone(
|
||
self, prev_count: int | None = None, cur_count: int | None = None
|
||
) -> int | None:
|
||
"""Fire one milestone notification if the lifetime count just crossed one.
|
||
|
||
Called at cycle end AFTER the cycle has persisted. ``prev_count``/``cur_count``
|
||
are the persisted lifetime counter's values from before/after this cycle's
|
||
persist; when omitted they are resolved from the persisted counter
|
||
(previous = current - 1). Using the monotonic lifetime counter (not
|
||
``cycle_count`` == len(history)) keeps milestones correct across retention
|
||
trims/merges. Returns the crossed milestone value (for tests/logging) or None.
|
||
Never raises.
|
||
"""
|
||
try:
|
||
if not (self._notify_finish_services or self._notify_actions):
|
||
return None
|
||
milestones = self.config_entry.options.get(
|
||
CONF_NOTIFY_MILESTONES, DEFAULT_NOTIFY_MILESTONES
|
||
)
|
||
if cur_count is None:
|
||
cur_count = self._lifetime_cycle_count()
|
||
if prev_count is None:
|
||
prev_count = cur_count - 1
|
||
crossed = self._milestone_crossed(prev_count, cur_count, milestones)
|
||
if crossed is None:
|
||
return None
|
||
msg_template = self.config_entry.options.get(
|
||
CONF_NOTIFY_MILESTONE_MESSAGE, DEFAULT_NOTIFY_MILESTONE_MESSAGE
|
||
)
|
||
msg = self._safe_format_template(
|
||
msg_template,
|
||
fallback_template=DEFAULT_NOTIFY_MILESTONE_MESSAGE,
|
||
device=self.config_entry.title,
|
||
cycle_count=crossed,
|
||
)
|
||
self._dispatch_notification(
|
||
msg,
|
||
event_type=NOTIFY_EVENT_FINISH,
|
||
extra_vars={
|
||
"cycle_count": crossed,
|
||
# Distinct tag so a milestone alert does not clobber (or get
|
||
# clobbered by) the lifecycle finish thread.
|
||
"tag": f"{self._lifecycle_tag}_milestone",
|
||
},
|
||
)
|
||
self._logger.info(
|
||
"Sent milestone notification: %s cycles", crossed
|
||
)
|
||
return crossed
|
||
except Exception as err: # pylint: disable=broad-exception-caught
|
||
self._logger.debug("Milestone notification check failed: %s", err)
|
||
return None
|
||
|
||
# ------------------------------------------------------------------
|
||
# C3 - iOS Live Activity enrichment (HA Companion beta, mobile_app_* only)
|
||
# ------------------------------------------------------------------
|
||
@staticmethod
|
||
def _build_ios_live_activity_extras(
|
||
*,
|
||
state: str,
|
||
progress_pct: float,
|
||
eta_timestamp: Any,
|
||
program: str | None,
|
||
device: str,
|
||
activity: str | None = None,
|
||
) -> dict[str, Any]:
|
||
"""Build the iOS Live Activity payload additions (mobile-only keys).
|
||
|
||
Returns a dict containing ``content_state`` (always), ``subtitle`` (only when
|
||
a program is matched) and ``activity`` (only when a lifecycle marker is
|
||
supplied). These keys are forwarded to mobile_app_* targets only by
|
||
``_send_notification_service``; other platforms never receive them.
|
||
"""
|
||
try:
|
||
pct = int(round(float(progress_pct)))
|
||
except (TypeError, ValueError):
|
||
pct = 0
|
||
pct = max(0, min(100, pct))
|
||
extras: dict[str, Any] = {
|
||
"content_state": {
|
||
"state": state,
|
||
"progress_pct": pct,
|
||
"eta_timestamp": eta_timestamp,
|
||
"program": program or "",
|
||
"device": device,
|
||
}
|
||
}
|
||
if program:
|
||
extras["subtitle"] = program
|
||
if activity:
|
||
extras["activity"] = activity
|
||
return extras
|
||
|
||
@staticmethod
|
||
def _mobile_service_extras(
|
||
ev: dict[str, Any], notify_service: str | None
|
||
) -> dict[str, Any]:
|
||
"""Return extra_vars keys allowed only on mobile_app_* targets.
|
||
|
||
For non-mobile services this is always empty, so strict-schema platforms and
|
||
the iOS Live Activity enrichment keys stay isolated to mobile targets.
|
||
"""
|
||
if not WashDataManager._is_mobile_notify_service(notify_service):
|
||
return {}
|
||
return {k: ev[k] for k in _MOBILE_ONLY_EXTRA_KEYS if k in ev}
|
||
|
||
def _safe_format_template(
|
||
self,
|
||
template: Any,
|
||
*,
|
||
fallback_template: str | None = None,
|
||
**kwargs: Any,
|
||
) -> str:
|
||
"""Format templates safely and return a resilient fallback on any error."""
|
||
text_template = str(template)
|
||
try:
|
||
return text_template.format(**kwargs)
|
||
except Exception as err: # pylint: disable=broad-exception-caught
|
||
self._logger.debug(
|
||
"Failed to format notification template %r with %s: %s",
|
||
text_template,
|
||
kwargs,
|
||
err,
|
||
)
|
||
|
||
if fallback_template:
|
||
try:
|
||
return fallback_template.format(**kwargs)
|
||
except Exception as err: # pylint: disable=broad-exception-caught
|
||
self._logger.debug(
|
||
"Failed to format fallback notification template %r with %s: %s",
|
||
fallback_template,
|
||
kwargs,
|
||
err,
|
||
)
|
||
|
||
device = str(kwargs.get("device") or self.config_entry.title)
|
||
program = kwargs.get("program")
|
||
if program:
|
||
return f"{device}: {program}"
|
||
return device
|
||
|
||
def _get_services_for_event(self, event_type: str | None) -> list[str]:
|
||
"""Return the configured notify service list for the given event type."""
|
||
if event_type == NOTIFY_EVENT_START:
|
||
return self._notify_start_services
|
||
if event_type in (NOTIFY_EVENT_FINISH, "pre_complete", NOTIFY_EVENT_CLEAN):
|
||
return self._notify_finish_services
|
||
if event_type == NOTIFY_EVENT_LIVE:
|
||
return self._notify_live_services
|
||
if event_type == NOTIFY_EVENT_TIMER:
|
||
# Cycle timers go to all configured services (start union finish, deduped).
|
||
return list(dict.fromkeys(
|
||
self._notify_start_services + self._notify_finish_services
|
||
))
|
||
return []
|
||
|
||
def _resolve_channel(self, event_type: str | None) -> str | None:
|
||
"""Resolve the Android notification channel name for an event type.
|
||
|
||
Finished, the clean-laundry nag, and the pre-completion reminder route to the
|
||
dedicated finish channel (so they can carry their own sound), falling back to
|
||
the status channel. Start/live use the status channel. An empty configured
|
||
value means "omit channel" so existing setups are unchanged.
|
||
"""
|
||
status_channel = self.config_entry.options.get(
|
||
CONF_NOTIFY_CHANNEL, DEFAULT_NOTIFY_CHANNEL
|
||
)
|
||
finish_channel = self.config_entry.options.get(
|
||
CONF_NOTIFY_FINISH_CHANNEL, DEFAULT_NOTIFY_FINISH_CHANNEL
|
||
)
|
||
if event_type in (NOTIFY_EVENT_FINISH, NOTIFY_EVENT_CLEAN, "pre_complete"):
|
||
return (finish_channel or status_channel) or None
|
||
return status_channel or None
|
||
|
||
def _dispatch_notification(
|
||
self,
|
||
message: str,
|
||
*,
|
||
title: str | None = None,
|
||
icon: str | None = None,
|
||
event_type: str | None = None,
|
||
person_entity_id: str | None = None,
|
||
person_name: str | None = None,
|
||
extra_vars: dict[str, Any] | None = None,
|
||
allow_deferral: bool = True,
|
||
allow_presence_deferral: bool = True,
|
||
) -> bool:
|
||
"""Route notification via actions or notify service with optional gating.
|
||
|
||
``allow_deferral`` gates the quiet-hours (do-not-disturb) hold; a
|
||
quiet-window flush passes ``allow_deferral=False`` so the released item is
|
||
not re-held by the still-closing window. ``allow_presence_deferral`` gates
|
||
the "notify only when home" presence hold *independently* — a quiet-hours
|
||
flush must keep presence gating on (nobody home => stay queued), so it
|
||
leaves ``allow_presence_deferral=True``. Only the presence flush (which
|
||
runs *because* someone is now home) disables both.
|
||
"""
|
||
# Signals whether this call *queued* the notification for later delivery
|
||
# (quiet-hours / presence hold) instead of sending or dropping it. Callers
|
||
# that use a "fire once" dedup flag (e.g. the clean-laundry nag) must treat
|
||
# a deferral as handled, otherwise they re-queue a duplicate on every retry
|
||
# tick for the whole quiet/away window.
|
||
self._last_dispatch_deferred = False
|
||
if not title:
|
||
title_template = self.config_entry.options.get(CONF_NOTIFY_TITLE, DEFAULT_NOTIFY_TITLE)
|
||
title = self._safe_format_template(
|
||
title_template,
|
||
fallback_template=DEFAULT_NOTIFY_TITLE,
|
||
device=self.config_entry.title,
|
||
)
|
||
|
||
if not icon:
|
||
icon = self.config_entry.options.get(CONF_NOTIFY_ICON)
|
||
|
||
if person_entity_id is None and self._notify_people:
|
||
for candidate in self._notify_people:
|
||
state = self.hass.states.get(candidate)
|
||
if state and state.state == STATE_HOME:
|
||
person_entity_id = candidate
|
||
person_name = state.name or state.attributes.get(
|
||
"friendly_name", candidate
|
||
)
|
||
break
|
||
|
||
variables: dict[str, Any] = {
|
||
"device": self.config_entry.title,
|
||
"program": self._current_program,
|
||
"message": message,
|
||
"title": title,
|
||
"icon": icon,
|
||
"event_type": event_type,
|
||
"person_entity_id": person_entity_id,
|
||
"person_name": person_name,
|
||
}
|
||
if extra_vars:
|
||
variables.update(extra_vars)
|
||
|
||
# Channel + auto-dismiss timeout apply to every event type. Inject into both
|
||
# the action variables and the notify-service extra_vars so both delivery
|
||
# paths honour them. Empty channel / zero timeout are omitted (no-op default).
|
||
channel = self._resolve_channel(event_type)
|
||
if channel:
|
||
variables["channel"] = channel
|
||
extra_vars = {**(extra_vars or {}), "channel": channel}
|
||
if self._notify_timeout_seconds > 0:
|
||
variables["timeout"] = self._notify_timeout_seconds
|
||
extra_vars = {**(extra_vars or {}), "timeout": self._notify_timeout_seconds}
|
||
|
||
# Quiet hours (do-not-disturb): hold finish-type notifications that would
|
||
# wake someone and deliver them at the end of the window. Live-progress ticks
|
||
# and the start notification are never delayed. Guarded by allow_deferral so a
|
||
# quiet-window flush (allow_deferral=False) cannot re-defer.
|
||
if (
|
||
allow_deferral
|
||
and event_type in _QUIET_HOURS_EVENT_TYPES
|
||
and self._in_quiet_hours()
|
||
):
|
||
self._queue_quiet_hours_notification(
|
||
message,
|
||
title=title,
|
||
icon=icon,
|
||
event_type=event_type,
|
||
extra_vars=extra_vars,
|
||
)
|
||
self._last_dispatch_deferred = True
|
||
return False
|
||
|
||
if (
|
||
allow_presence_deferral
|
||
and self._notify_only_when_home
|
||
and self._notify_people
|
||
):
|
||
if not self._is_any_notify_person_home():
|
||
if event_type == NOTIFY_EVENT_LIVE:
|
||
self._pending_notifications = [
|
||
entry
|
||
for entry in self._pending_notifications
|
||
if entry.get("event_type") != NOTIFY_EVENT_LIVE
|
||
]
|
||
self._pending_notifications.append(
|
||
{
|
||
"message": message,
|
||
"title": title,
|
||
"icon": icon,
|
||
"event_type": event_type,
|
||
"extra_vars": extra_vars,
|
||
}
|
||
)
|
||
self._last_dispatch_deferred = True
|
||
return False
|
||
|
||
actions_sent = False
|
||
if self._notify_actions:
|
||
actions_sent = bool(self._run_notification_actions(variables))
|
||
|
||
# If actions fired and there are no per-event services, skip the
|
||
# service/persistent-notification path entirely.
|
||
services = self._get_services_for_event(event_type)
|
||
if actions_sent and not services:
|
||
return True
|
||
|
||
service_sent = self._send_notification_service(
|
||
message,
|
||
services=services,
|
||
title=title,
|
||
icon=icon,
|
||
event_type=event_type,
|
||
extra_vars=extra_vars,
|
||
)
|
||
return actions_sent or service_sent
|
||
|
||
def _send_notification_service(
|
||
self,
|
||
message: str,
|
||
*,
|
||
services: list[str],
|
||
title: str | None = None,
|
||
icon: str | None = None,
|
||
event_type: str | None = None,
|
||
extra_vars: dict[str, Any] | None = None,
|
||
) -> bool:
|
||
"""Send a notification to each configured notify service, or fall back to persistent notification."""
|
||
ev = extra_vars or {}
|
||
# Base payload shared by all notification platforms.
|
||
data: dict[str, Any] = {}
|
||
if icon:
|
||
data["icon"] = icon
|
||
|
||
# Live-progress-only payload keys (countdown, progress bar, throttle markers).
|
||
# Live updates are already gated to mobile_app targets by the guard below,
|
||
# so these keys never reach strict-schema platforms.
|
||
if event_type == NOTIFY_EVENT_LIVE:
|
||
for key in (
|
||
"progress",
|
||
"progress_max",
|
||
"live_update",
|
||
"alert_once",
|
||
"cycle_seconds",
|
||
"time_remaining_seconds",
|
||
"minutes_left",
|
||
"live_updates_sent",
|
||
"live_updates_cap",
|
||
"chronometer",
|
||
"when",
|
||
"countdown",
|
||
):
|
||
if key in ev:
|
||
data[key] = ev[key]
|
||
|
||
sent = False
|
||
for notify_service in services:
|
||
if event_type == NOTIFY_EVENT_LIVE and not self._is_mobile_notify_service(
|
||
notify_service
|
||
):
|
||
self._logger.debug(
|
||
"Skipping live notification for non-mobile notify service: %s",
|
||
notify_service,
|
||
)
|
||
continue
|
||
|
||
# Mobile-app-specific keys (tag/timeout/channel/priority) plus the iOS
|
||
# Live Activity enrichment keys (subtitle/content_state/activity) are
|
||
# rejected by some strict-schema platforms such as Signal Messenger.
|
||
# Only add them for mobile_app targets; all other platforms receive
|
||
# the base payload only.
|
||
svc_data = dict(data)
|
||
svc_data.update(self._mobile_service_extras(ev, notify_service))
|
||
|
||
state = (
|
||
self.hass.states.get(notify_service)
|
||
if notify_service.startswith("notify.")
|
||
else None
|
||
)
|
||
if state is not None and getattr(state, "domain", None) == "notify":
|
||
service_data: dict[str, Any] = {
|
||
"entity_id": notify_service,
|
||
"message": message,
|
||
}
|
||
if title:
|
||
service_data["title"] = title
|
||
if svc_data:
|
||
service_data["data"] = svc_data
|
||
self.hass.async_create_task(
|
||
self.hass.services.async_call(
|
||
"notify", "send_message", service_data
|
||
)
|
||
)
|
||
else:
|
||
domain, service = (
|
||
notify_service.split(".", 1)
|
||
if "." in notify_service
|
||
else ("notify", notify_service)
|
||
)
|
||
service_data = {"message": message, "title": title}
|
||
if svc_data:
|
||
service_data["data"] = svc_data
|
||
self.hass.async_create_task(
|
||
self.hass.services.async_call(domain, service, service_data)
|
||
)
|
||
sent = True
|
||
|
||
if not sent:
|
||
if event_type == NOTIFY_EVENT_LIVE:
|
||
return False
|
||
# Reuse the notification's tag as a stable persistent-notification id so
|
||
# the HA notifications tab collapses the lifecycle thread to one entry
|
||
# instead of accumulating a new card per cycle (issue #248/#249 clutter).
|
||
_pn_create(
|
||
self.hass,
|
||
message,
|
||
title=title,
|
||
notification_id=ev.get("tag"),
|
||
)
|
||
return True
|
||
|
||
return sent
|
||
|
||
def _run_notification_actions(self, variables: dict[str, Any]) -> bool:
|
||
"""Run configured notification actions."""
|
||
actions: list[dict[str, Any]] = self._notify_actions
|
||
if not actions:
|
||
return False
|
||
|
||
if self._notify_script is None:
|
||
try:
|
||
self._notify_script = script_helper.Script(
|
||
self.hass,
|
||
actions,
|
||
name=f"{self.config_entry.title} notification",
|
||
domain=DOMAIN,
|
||
logger=_LOGGER,
|
||
)
|
||
except (ValueError, TypeError, HomeAssistantError) as err:
|
||
self._logger.error(
|
||
"Invalid notification action configuration for %s: %s",
|
||
self.config_entry.title,
|
||
err,
|
||
)
|
||
return False
|
||
except Exception as err:
|
||
self._logger.exception(
|
||
"Unexpected error while building notification actions for %s: %s",
|
||
self.config_entry.title,
|
||
err,
|
||
)
|
||
return False
|
||
script = self._notify_script
|
||
|
||
try:
|
||
action_task = self.hass.async_create_task(
|
||
script.async_run(variables, context=Context())
|
||
)
|
||
# This method is synchronous, so the script runs fire-and-forget: a
|
||
# failure inside async_run() would otherwise land after we return True
|
||
# and be swallowed. Surface it via a done-callback so an action-only
|
||
# setup at least logs the drop. (A user-visible fallback would require
|
||
# awaiting, i.e. making the whole notification-dispatch chain async.)
|
||
def _log_action_failure(task: Task[Any]) -> None:
|
||
if task.cancelled():
|
||
return
|
||
exc = task.exception()
|
||
if exc is not None:
|
||
self._logger.warning(
|
||
"Notification action execution failed for %s: %s",
|
||
self.config_entry.title,
|
||
exc,
|
||
)
|
||
|
||
action_task.add_done_callback(_log_action_failure)
|
||
return True
|
||
except HomeAssistantError as err:
|
||
self._logger.warning(
|
||
"Notification action execution failed for %s: %s",
|
||
self.config_entry.title,
|
||
err,
|
||
)
|
||
return False
|
||
except Exception as err:
|
||
self._logger.exception(
|
||
"Unexpected error while scheduling notification actions for %s: %s",
|
||
self.config_entry.title,
|
||
err,
|
||
)
|
||
return False
|
||
|
||
def _is_any_notify_person_home(self) -> bool:
|
||
"""Return True when any configured person is home."""
|
||
for person_entity_id in self._notify_people:
|
||
state = self.hass.states.get(person_entity_id)
|
||
if state and state.state == STATE_HOME:
|
||
return True
|
||
return False
|
||
|
||
@callback
|
||
def _handle_notify_person_change(self, event: Event[evt.EventStateChangedData]) -> None:
|
||
"""Handle person state changes to release pending notifications."""
|
||
new_state = event.data.get("new_state")
|
||
if not new_state or new_state.state != STATE_HOME:
|
||
return
|
||
|
||
if not self._pending_notifications:
|
||
return
|
||
|
||
person_entity_id = new_state.entity_id
|
||
person_name = new_state.name or new_state.attributes.get(
|
||
"friendly_name", person_entity_id
|
||
)
|
||
pending: list[dict[str, Any]] = list(self._pending_notifications)
|
||
self._pending_notifications = []
|
||
for entry in pending:
|
||
sent = self._dispatch_notification(
|
||
entry["message"],
|
||
title=entry.get("title"),
|
||
icon=entry.get("icon"),
|
||
event_type=entry.get("event_type"),
|
||
person_entity_id=person_entity_id,
|
||
person_name=person_name,
|
||
extra_vars=entry.get("extra_vars"),
|
||
allow_deferral=False,
|
||
allow_presence_deferral=False,
|
||
)
|
||
if sent and entry.get("event_type") == NOTIFY_EVENT_LIVE:
|
||
ev_raw = entry.get("extra_vars")
|
||
ev: dict[str, Any] = ev_raw if isinstance(ev_raw, dict) else {}
|
||
if "progress" not in ev:
|
||
self._live_waiting_notification_sent = True
|
||
else:
|
||
self._live_notification_sent_count += 1
|
||
self._last_live_notification_time = dt_util.now()
|
||
|
||
def _handle_noise_cycle(self, max_power: float) -> None:
|
||
"""Handle a detected noise cycle."""
|
||
# Clean up old noise events > 24h
|
||
now = dt_util.now()
|
||
self._noise_events = [
|
||
t
|
||
for t in getattr(self, "_noise_events", [])
|
||
if (now - t).total_seconds() < 86400
|
||
]
|
||
self._noise_events.append(now)
|
||
|
||
# Track max power of noise
|
||
self._noise_max_powers = getattr(self, "_noise_max_powers", [])
|
||
self._noise_max_powers.append(max_power)
|
||
|
||
# If noise events exceed threshold in 24h, trigger tune
|
||
if len(self._noise_events) >= self._noise_events_threshold:
|
||
self.hass.async_create_task(self._tune_threshold())
|
||
|
||
async def _tune_threshold(self) -> None:
|
||
"""Increase the minimum power threshold."""
|
||
current_min = self.detector.config.min_power
|
||
|
||
# Calculate new suggested threshold
|
||
# Max of observed noise * 1.2 safety factor
|
||
noise_max = max(self._noise_max_powers)
|
||
new_min = noise_max * 1.2
|
||
|
||
# Cap absolute max to avoid runaway (e.g. 50W)
|
||
if new_min > 50.0:
|
||
new_min = 50.0
|
||
|
||
if new_min <= current_min:
|
||
# Clear events so we don't loop try to update
|
||
self._noise_events = []
|
||
self._noise_max_powers = []
|
||
return
|
||
|
||
self._logger.info(
|
||
"Auto-Tune suggestion: min_power from %.1fW -> %.1fW due to noise",
|
||
current_min,
|
||
new_min,
|
||
)
|
||
|
||
# Store a suggestion (do not mutate user-set options). The suggestion is
|
||
# surfaced in the panel (Settings suggestions banner / per-field pill);
|
||
# WashData intentionally does not raise a persistent notification here.
|
||
self.profile_store.set_suggestion(
|
||
CONF_MIN_POWER,
|
||
float(new_min),
|
||
f"Auto-tune: {len(self._noise_events)} ghost cycles detected in 24h",
|
||
)
|
||
await self.profile_store.async_save()
|
||
|
||
# Reset trackers
|
||
self._noise_events = []
|
||
self._noise_max_powers = []
|
||
|
||
def _update_estimates(self) -> None:
|
||
"""Update time remaining and profile estimates."""
|
||
if self.detector.state in (
|
||
STATE_OFF,
|
||
STATE_UNKNOWN,
|
||
STATE_IDLE,
|
||
STATE_STARTING,
|
||
STATE_ANTI_WRINKLE,
|
||
STATE_DELAY_WAIT,
|
||
):
|
||
self._current_program = "off"
|
||
self._time_remaining = None
|
||
self._total_duration = None
|
||
self._cycle_progress = 0.0
|
||
self._projected_energy_wh = None
|
||
self._projected_cost = None
|
||
self._cycle_anomaly = "none"
|
||
self._overrun_ratio = 0.0
|
||
self._last_match_result = None
|
||
self._notify_update()
|
||
return
|
||
|
||
now = dt_util.now()
|
||
|
||
# Throttle heavy matching to configured interval (default: 5 minutes)
|
||
effective_match_interval = self._profile_match_interval
|
||
if (
|
||
self._last_estimate_time
|
||
and (now - self._last_estimate_time).total_seconds()
|
||
< effective_match_interval
|
||
):
|
||
# Still update remaining/progress if we already have a match
|
||
self._update_remaining_only()
|
||
self._check_pre_completion_notification()
|
||
self._check_live_progress_notification()
|
||
return
|
||
|
||
# SKIP matching if manual program is active
|
||
if self._manual_program_active:
|
||
self._last_estimate_time = now # touch timestamp to throttle estimates loop
|
||
self._update_remaining_only()
|
||
# Also check notifications in loop
|
||
self._check_pre_completion_notification()
|
||
self._check_live_progress_notification()
|
||
self._notify_update()
|
||
return
|
||
|
||
# No matching task trigger here anymore!
|
||
# The detector callback handles it.
|
||
# Just update progress/remaining based on existing match.
|
||
self._update_remaining_only()
|
||
self._check_pre_completion_notification()
|
||
self._check_live_progress_notification()
|
||
self._notify_update()
|
||
|
||
# _async_run_matching removed in favor of _async_perform_combined_matching
|
||
|
||
def _analyze_trend(self, profile_name: str) -> bool:
|
||
"""Analyze score history to detect positive trend.
|
||
|
||
Returns True if score has increased in at least 7 of the last 10 intervals.
|
||
Requires at least 5 samples history to make a determination.
|
||
"""
|
||
history = self._score_history.get(profile_name, [])
|
||
if len(history) < 5:
|
||
return False
|
||
|
||
# Use last 11 points to get 10 intervals (or fewer if history short)
|
||
recent = history[-11:]
|
||
if len(recent) < 2:
|
||
return False
|
||
|
||
up_count = sum(1 for i in range(1, len(recent)) if recent[i] > recent[i - 1])
|
||
total_intervals = len(recent) - 1
|
||
|
||
# Proportional threshold (7/10 => 0.7)
|
||
return (up_count / total_intervals) >= 0.70
|
||
|
||
def _reset_live_notification_state(self) -> None:
|
||
"""Reset per-cycle live notification counters and timers."""
|
||
self._live_notification_sent_count = 0
|
||
self._live_notification_cap = 0
|
||
self._last_live_notification_time = None
|
||
self._live_waiting_notification_sent = False
|
||
self._live_chronometer_overrun_sent = False
|
||
self._live_activity_started = False
|
||
|
||
@staticmethod
|
||
def _is_mobile_notify_service(notify_service: str | None) -> bool:
|
||
"""Return True when configured notify target is a mobile app service."""
|
||
if not notify_service:
|
||
return False
|
||
_, service = (
|
||
notify_service.split(".", 1)
|
||
if "." in notify_service
|
||
else ("notify", notify_service)
|
||
)
|
||
return service.startswith("mobile_app")
|
||
|
||
@property
|
||
def _timer_pause_action_id(self) -> str:
|
||
"""Stable mobile action ID for timer-pause Resume button, unique per device."""
|
||
return f"RESUME_WD_{self.entry_id[:8].upper()}"
|
||
|
||
def _estimate_live_notification_cap(self) -> int:
|
||
"""Compute hard cap for live updates from estimated cycle duration and overrun margin."""
|
||
interval = max(30, int(self._notify_live_interval_seconds))
|
||
estimated_duration = float(
|
||
self._matched_profile_duration
|
||
or self._total_duration
|
||
or max(float(self.detector.get_elapsed_seconds()), float(interval))
|
||
)
|
||
estimated_updates = max(1, int(np.ceil(estimated_duration / interval)))
|
||
overrun_ratio = max(0, float(self._notify_live_overrun_percent)) / 100.0
|
||
return max(1, int(np.ceil(estimated_updates * (1.0 + overrun_ratio))))
|
||
|
||
def _check_live_progress_notification(self) -> None:
|
||
"""Send throttled live progress notifications for compatible mobile targets."""
|
||
if not self._notify_live_services and not self._notify_actions:
|
||
return
|
||
if self.detector.state not in (STATE_RUNNING, STATE_PAUSED, STATE_ENDING):
|
||
return
|
||
|
||
has_profile_match = bool(
|
||
self._matched_profile_duration and self._matched_profile_duration > 0
|
||
)
|
||
if has_profile_match:
|
||
# A profile has been matched - reset the waiting latch so future
|
||
# "no profile yet" phases (e.g. after a cycle restart) will send
|
||
# the waiting message again.
|
||
self._live_waiting_notification_sent = False
|
||
if not has_profile_match:
|
||
# Suppress the waiting notification when no profiles exist at all —
|
||
# the setup card explains the state instead.
|
||
if not self.profile_store.has_real_profiles:
|
||
return
|
||
if self._live_waiting_notification_sent:
|
||
return
|
||
|
||
# Fixed (non user-editable) live message: localize via the cached
|
||
# options.error template, falling back to the English default.
|
||
waiting_template = self._timer_ui_strings.get(
|
||
"notify_live_waiting_message", DEFAULT_NOTIFY_LIVE_WAITING_MESSAGE
|
||
)
|
||
msg = self._safe_format_template(
|
||
waiting_template,
|
||
fallback_template=DEFAULT_NOTIFY_LIVE_WAITING_MESSAGE,
|
||
device=self.config_entry.title,
|
||
program=self._current_program,
|
||
)
|
||
waiting_extra_vars: dict[str, Any] = {
|
||
"tag": self._live_notification_tag,
|
||
"live_update": True,
|
||
"alert_once": True,
|
||
}
|
||
# C3: mark the first live notification of the cycle so iOS can begin a
|
||
# Live Activity even before a profile is matched (mobile-only key).
|
||
if not self._live_activity_started:
|
||
waiting_extra_vars["activity"] = "start"
|
||
sent = self._dispatch_notification(
|
||
msg,
|
||
event_type=NOTIFY_EVENT_LIVE,
|
||
extra_vars=waiting_extra_vars,
|
||
)
|
||
self._live_waiting_notification_sent = sent
|
||
if sent:
|
||
self._live_activity_started = True
|
||
return
|
||
|
||
interval = max(30, int(self._notify_live_interval_seconds))
|
||
now = dt_util.now()
|
||
if self._last_live_notification_time and (
|
||
now - self._last_live_notification_time
|
||
).total_seconds() < interval:
|
||
return
|
||
|
||
cap_candidate = self._estimate_live_notification_cap()
|
||
if cap_candidate > self._live_notification_cap:
|
||
self._live_notification_cap = cap_candidate
|
||
|
||
total_seconds = int(
|
||
max(
|
||
1,
|
||
round(
|
||
float(
|
||
self._total_duration
|
||
or self._matched_profile_duration
|
||
or self.detector.get_elapsed_seconds()
|
||
)
|
||
),
|
||
)
|
||
)
|
||
remaining_seconds = int(max(0, round(float(self._time_remaining or 0.0))))
|
||
elapsed_seconds = max(0, total_seconds - remaining_seconds)
|
||
|
||
# When a chronometer notification is on the phone but the estimate has
|
||
# expired, bypass the cap once to replace the frozen "0:00" countdown
|
||
# with a plain text update so the user isn't left with a stale timer.
|
||
chronometer_overrun = (
|
||
self._notify_live_chronometer
|
||
and remaining_seconds <= 0
|
||
and self._live_notification_sent_count > 0
|
||
and not self._live_chronometer_overrun_sent
|
||
)
|
||
if not chronometer_overrun and self._live_notification_sent_count >= self._live_notification_cap:
|
||
return
|
||
minutes_left = max(1, math.ceil(remaining_seconds / 60))
|
||
|
||
msg_template = self.config_entry.options.get(
|
||
CONF_NOTIFY_PRE_COMPLETE_MESSAGE,
|
||
DEFAULT_NOTIFY_PRE_COMPLETE_MESSAGE,
|
||
)
|
||
msg = self._safe_format_template(
|
||
msg_template,
|
||
fallback_template=DEFAULT_NOTIFY_PRE_COMPLETE_MESSAGE,
|
||
device=self.config_entry.title,
|
||
minutes=minutes_left,
|
||
program=self._current_program,
|
||
)
|
||
|
||
extra_vars: dict[str, Any] = {
|
||
"tag": self._live_notification_tag,
|
||
"progress": elapsed_seconds,
|
||
"progress_max": total_seconds,
|
||
"live_update": True,
|
||
"alert_once": True,
|
||
"cycle_seconds": total_seconds,
|
||
"time_remaining_seconds": remaining_seconds,
|
||
"minutes_left": minutes_left,
|
||
"live_updates_sent": self._live_notification_sent_count + 1,
|
||
"live_updates_cap": self._live_notification_cap,
|
||
}
|
||
if self._notify_live_chronometer and remaining_seconds > 0:
|
||
extra_vars["chronometer"] = True
|
||
extra_vars["when"] = int(now.timestamp()) + remaining_seconds
|
||
extra_vars["countdown"] = True
|
||
|
||
# C3: iOS Live Activity enrichment. Derived from the SAME values feeding the
|
||
# flat progress/when keys above. Forwarded to mobile_app_* targets only (see
|
||
# _send_notification_service); non-mobile live targets are already skipped.
|
||
eta_timestamp = int(now.timestamp()) + remaining_seconds
|
||
progress_pct = (
|
||
100.0 * elapsed_seconds / total_seconds if total_seconds > 0 else 0.0
|
||
)
|
||
activity_marker = None if self._live_activity_started else "start"
|
||
extra_vars.update(
|
||
self._build_ios_live_activity_extras(
|
||
state="paused" if self.detector.state == STATE_PAUSED else "running",
|
||
progress_pct=progress_pct,
|
||
eta_timestamp=eta_timestamp,
|
||
program=self._current_program,
|
||
device=self.config_entry.title,
|
||
activity=activity_marker,
|
||
)
|
||
)
|
||
sent = self._dispatch_notification(
|
||
msg,
|
||
event_type=NOTIFY_EVENT_LIVE,
|
||
extra_vars=extra_vars,
|
||
)
|
||
if sent:
|
||
self._live_activity_started = True
|
||
if chronometer_overrun:
|
||
self._live_chronometer_overrun_sent = True
|
||
else:
|
||
self._live_notification_sent_count += 1
|
||
self._last_live_notification_time = now
|
||
|
||
def _clear_live_progress_notification(self, clear_services: bool = True) -> None:
|
||
"""Clear active live/progress notifications and purge stale deferred alerts.
|
||
|
||
On cycle finish (``clear_services=False``) the finished notification carries
|
||
the same lifecycle tag and replaces the live notification in place, so we must
|
||
NOT also send a service-level ``clear_notification`` (it would briefly dismiss
|
||
then re-create the card). The pending-purge, the action-based clear marker
|
||
(kept for backward compatibility with custom action templates), and the state
|
||
reset still run. On shutdown (``clear_services=True``) no finished notification
|
||
follows, so the explicit service clear is required to dismiss the live card.
|
||
"""
|
||
# Purge queued live-progress entries and stale start/pre-complete entries
|
||
# so a completed cycle cannot replay them later.
|
||
live_tag = self._live_notification_tag
|
||
self._pending_notifications = [
|
||
entry
|
||
for entry in self._pending_notifications
|
||
if not (
|
||
(
|
||
entry.get("event_type") == NOTIFY_EVENT_LIVE
|
||
and isinstance(entry.get("extra_vars"), dict)
|
||
and entry["extra_vars"].get("tag") == live_tag
|
||
and entry["extra_vars"].get("live_update") is True
|
||
)
|
||
or entry.get("event_type") in {NOTIFY_EVENT_START, "pre_complete"}
|
||
)
|
||
]
|
||
|
||
# Always emit the clear when the user has any live channel configured.
|
||
# The in-memory sent-count is unreliable after an HA restart (it resets
|
||
# to 0 while the notification still lives on the phone), and a no-op
|
||
# clear for a non-existent tag is harmless on the mobile_app side.
|
||
if not self._notify_live_services and not self._notify_actions:
|
||
self._reset_live_notification_state()
|
||
return
|
||
|
||
# Invoke notification actions to clear live notification in action-based setups
|
||
# Include full context variables expected by notification action handlers
|
||
self._run_notification_actions(
|
||
{
|
||
"device": self.config_entry.title,
|
||
"program": "", # Cleared marker
|
||
"message": "clear_notification", # Clear marker for action handlers
|
||
"title": "", # Clear title
|
||
"icon": None,
|
||
"event_type": NOTIFY_EVENT_LIVE,
|
||
"person_entity_id": None,
|
||
"person_name": None,
|
||
"tag": self._live_notification_tag,
|
||
"live_update": True,
|
||
"alert_once": True,
|
||
# C3: tell iOS to end the Live Activity (mobile-only key downstream).
|
||
"activity": "end",
|
||
}
|
||
)
|
||
|
||
if clear_services:
|
||
self._send_notification_service(
|
||
"clear_notification",
|
||
services=self._notify_live_services,
|
||
event_type=NOTIFY_EVENT_LIVE,
|
||
extra_vars={
|
||
"tag": self._live_notification_tag,
|
||
"live_update": True,
|
||
"alert_once": True,
|
||
# C3: iOS Live Activity end marker (mobile_app_* only downstream).
|
||
"activity": "end",
|
||
},
|
||
)
|
||
|
||
# Reset live-update state flags and counters.
|
||
self._reset_live_notification_state()
|
||
|
||
def _clear_clean_notification(self) -> None:
|
||
"""Dismiss a delivered clean-laundry reminder and purge any queued ones.
|
||
|
||
The clean nag uses its own tag (``_clean_tag``) rather than the lifecycle
|
||
tag, so nothing replaces it once the clean state resolves. Mirror the
|
||
lifecycle clear here so a delivered reminder is removed from the mobile
|
||
app instead of lingering. A clear for a non-existent tag is harmless, so
|
||
this runs whenever the user has any clean/finish delivery configured.
|
||
"""
|
||
# Drop any still-queued clean entries so they cannot replay later — from
|
||
# both the presence-hold queue and the quiet-hours queue (the nag can be
|
||
# deferred into either).
|
||
self._pending_notifications = [
|
||
n for n in self._pending_notifications
|
||
if n.get("event_type") != NOTIFY_EVENT_CLEAN
|
||
]
|
||
self._quiet_pending_notifications = [
|
||
n for n in self._quiet_pending_notifications
|
||
if n.get("event_type") != NOTIFY_EVENT_CLEAN
|
||
]
|
||
|
||
services = self._get_services_for_event(NOTIFY_EVENT_CLEAN)
|
||
if not services and not self._notify_actions:
|
||
return
|
||
|
||
if self._notify_actions:
|
||
self._run_notification_actions(
|
||
{
|
||
"device": self.config_entry.title,
|
||
"program": "",
|
||
"message": "clear_notification",
|
||
"title": "",
|
||
"icon": None,
|
||
"event_type": NOTIFY_EVENT_CLEAN,
|
||
"person_entity_id": None,
|
||
"person_name": None,
|
||
"tag": self._clean_tag,
|
||
}
|
||
)
|
||
if services:
|
||
self._send_notification_service(
|
||
"clear_notification",
|
||
services=services,
|
||
event_type=NOTIFY_EVENT_CLEAN,
|
||
extra_vars={"tag": self._clean_tag},
|
||
)
|
||
|
||
def _check_pre_completion_notification(self) -> None:
|
||
"""Check and send pre-completion notification."""
|
||
if notif_rules.should_notify_pre_completion(
|
||
self._notify_before_end_minutes,
|
||
self._notified_pre_completion,
|
||
self._time_remaining,
|
||
self._cycle_progress,
|
||
self._last_match_ambiguous,
|
||
):
|
||
# Send notification!
|
||
self._notified_pre_completion = True
|
||
|
||
# Distinct reminder message (not the live-update template) so the one-time
|
||
# "X minutes left" alert is not confused with the recurring live ticks that
|
||
# reuse CONF_NOTIFY_PRE_COMPLETE_MESSAGE.
|
||
msg_template = self.config_entry.options.get(
|
||
CONF_NOTIFY_REMINDER_MESSAGE, DEFAULT_NOTIFY_REMINDER_MESSAGE
|
||
)
|
||
minutes_left = self._notify_before_end_minutes
|
||
|
||
msg = self._safe_format_template(
|
||
msg_template,
|
||
fallback_template=DEFAULT_NOTIFY_REMINDER_MESSAGE,
|
||
device=self.config_entry.title,
|
||
minutes=minutes_left,
|
||
program=self._current_program,
|
||
)
|
||
self._dispatch_notification(
|
||
msg,
|
||
event_type="pre_complete",
|
||
extra_vars={
|
||
# Share the lifecycle tag so the reminder updates the live thread in
|
||
# place. No alert_once -> the companion app makes a sound once; it is
|
||
# routed to the finish channel (see _resolve_channel) for audibility.
|
||
"tag": self._lifecycle_tag,
|
||
"minutes_left": minutes_left,
|
||
"minutes": minutes_left,
|
||
"priority": "high",
|
||
},
|
||
)
|
||
self._logger.info("Sent pre-completion notification: %s", msg)
|
||
|
||
def _update_projected_energy(self) -> None:
|
||
"""Project total energy/cost for the running cycle.
|
||
|
||
Prefers the on-device ``total_energy`` regressor (which models energy's
|
||
non-linear accumulation); otherwise falls back to
|
||
``energy_so_far / progress_fraction`` (progress already carries the ML
|
||
remaining-time blend, so it personalizes to this device's real cycle
|
||
length). Cost uses the same price resolution that freezes each completed
|
||
cycle's cost, so a running estimate and the final frozen value are
|
||
consistent. Clears to ``None`` when progress is too low, there is no energy
|
||
yet, or projection would be implausible. Never raises — a projection
|
||
failure must not disturb the estimate loop.
|
||
"""
|
||
try:
|
||
trace = self.detector.get_power_trace()
|
||
energy_so_far = float(
|
||
getattr(self.detector, "_energy_since_idle_wh", 0.0) or 0.0
|
||
)
|
||
price = self._resolve_energy_price()
|
||
except Exception: # noqa: BLE001 - projection must never break estimates
|
||
self._projected_energy_wh = None
|
||
self._projected_cost = None
|
||
return
|
||
wh, cost = progress_mod.projected_energy(
|
||
self.profile_store,
|
||
self.config_entry.options,
|
||
float(self._matched_profile_duration or 0.0),
|
||
trace,
|
||
self._current_program,
|
||
float(self._cycle_progress or 0.0),
|
||
energy_so_far,
|
||
price,
|
||
self._profile_end_expectation,
|
||
self._logger,
|
||
)
|
||
self._projected_energy_wh = wh
|
||
self._projected_cost = cost
|
||
|
||
def _update_cycle_anomaly(self, duration_so_far: float) -> None:
|
||
"""Flag a *soft* runtime overrun anomaly for the running cycle.
|
||
|
||
Sets ``_overrun_ratio = elapsed / expected`` and ``_cycle_anomaly`` to
|
||
``"overrun"`` once the ratio crosses ``CYCLE_OVERRUN_ANOMALY_RATIO``. This
|
||
is purely a visible signal (state-sensor attribute + cycle metadata); it
|
||
never notifies and never terminates (the zombie-killer owns hard limits).
|
||
No-op / cleared when no profile duration is known. Never raises.
|
||
"""
|
||
self._overrun_ratio, self._cycle_anomaly = progress_mod.cycle_anomaly(
|
||
self._matched_profile_duration, duration_so_far
|
||
)
|
||
|
||
def _update_remaining_only(self) -> None:
|
||
"""Recompute remaining/progress using phase-aware estimation."""
|
||
# Throttle updates and only clear on truly dead states
|
||
if self.detector.state in (STATE_OFF, STATE_UNKNOWN, STATE_IDLE):
|
||
self._time_remaining = None
|
||
self._total_duration = None
|
||
self._cycle_progress = 0.0
|
||
self._smoothed_progress = 0.0
|
||
self._projected_energy_wh = None
|
||
self._projected_cost = None
|
||
self._cycle_anomaly = "none"
|
||
self._overrun_ratio = 0.0
|
||
return
|
||
|
||
now = dt_util.now()
|
||
if (
|
||
self._last_phase_estimate_time
|
||
and (now - self._last_phase_estimate_time).total_seconds() < 5.0
|
||
):
|
||
return
|
||
self._last_phase_estimate_time = now
|
||
|
||
# Use net elapsed (wall-clock minus user-paused time) for all time estimates
|
||
# so that paused time is excluded from progress / remaining / total duration.
|
||
duration_so_far = float(self.net_elapsed_seconds)
|
||
self._check_cycle_timers(duration_so_far)
|
||
|
||
if not (self._matched_profile_duration and self._matched_profile_duration > 0):
|
||
# No profile matched - don't provide misleading time estimates.
|
||
self._time_remaining = None
|
||
self._total_duration = None
|
||
self._cycle_progress = 0.0
|
||
self._smoothed_progress = 0.0
|
||
self._projected_energy_wh = None
|
||
self._projected_cost = None
|
||
self._cycle_anomaly = "none"
|
||
self._overrun_ratio = 0.0
|
||
self._logger.debug(
|
||
"No profile matched yet, elapsed=%smin", int(duration_so_far / 60)
|
||
)
|
||
return
|
||
|
||
# Compute the phase-aware and ML progress inputs via the manager's own
|
||
# wrappers (so per-call caching + test mocks apply), then hand them to the
|
||
# shared pure smoothing/back-calc in :mod:`progress` - the identical math
|
||
# the Playground simulation runs.
|
||
trace = self.detector.get_power_trace()
|
||
phase_result = None
|
||
if len(trace) >= 10 and self._current_program != "detecting...":
|
||
phase_result = self._estimate_phase_progress(
|
||
trace, duration_so_far, self._current_program
|
||
)
|
||
ml_pct = self._ml_progress_percent(trace, self._current_program)
|
||
|
||
# Opt-in phase-resolved ETA (washing machine / washer-dryer only). Segment
|
||
# the observed-so-far trace, match against cached per-profile phase profiles,
|
||
# and blend the per-role budget remaining into the estimate (progress.py
|
||
# owns the blend). Gated + guarded: any failure leaves the proven estimate
|
||
# untouched (phase_remaining_s stays None -> byte-identical behaviour).
|
||
phase_remaining_s: float | None = None
|
||
if (
|
||
len(trace) >= 10
|
||
and self._current_program not in ("detecting...", "off", None)
|
||
and phase_matching_enabled(self.config_entry.options, self.device_type)
|
||
):
|
||
pr = self.profile_store.phase_remaining(
|
||
trace, self.device_type, self._current_program
|
||
)
|
||
if pr is not None:
|
||
phase_remaining_s = pr.get("remaining_s")
|
||
|
||
result = progress_mod.compute_progress(
|
||
self.device_type,
|
||
float(self._matched_profile_duration),
|
||
duration_so_far,
|
||
self._smoothed_progress,
|
||
phase_result,
|
||
ml_pct,
|
||
self._logger,
|
||
phase_remaining_s=phase_remaining_s,
|
||
)
|
||
|
||
self._cycle_progress = result.progress
|
||
self._smoothed_progress = result.smoothed
|
||
self._time_remaining = result.remaining
|
||
self._total_duration = result.total
|
||
self._last_total_duration_update = now
|
||
self._update_projected_energy()
|
||
self._update_cycle_anomaly(duration_so_far)
|
||
|
||
def _check_cycle_timers(self, elapsed_seconds: float) -> None:
|
||
"""Fire any user-configured cycle timers whose offset has been reached."""
|
||
if not self._notify_cycle_timers:
|
||
return
|
||
if self.detector.state not in (STATE_RUNNING, STATE_PAUSED):
|
||
return
|
||
|
||
elapsed_minutes = elapsed_seconds / 60.0
|
||
for idx, timer in enumerate(self._notify_cycle_timers):
|
||
if idx in self._fired_cycle_timers:
|
||
continue
|
||
offset = float(timer.get("offset_minutes", 0))
|
||
if elapsed_minutes < offset:
|
||
continue
|
||
|
||
self._fired_cycle_timers.add(idx)
|
||
raw_msg = timer.get("message") or ""
|
||
fmt_kwargs = {
|
||
"device": self.config_entry.title,
|
||
"program": self._current_program or "",
|
||
"minutes": int(offset),
|
||
}
|
||
msg = self._safe_format_template(
|
||
raw_msg or self._timer_ui_strings.get("timer_default_message", "{device}: {minutes} min timer"),
|
||
**fmt_kwargs,
|
||
)
|
||
auto_pause = bool(timer.get("auto_pause", False))
|
||
timer_tag = f"{self._lifecycle_tag}_timer_{idx}"
|
||
if auto_pause:
|
||
# Defer the ENTIRE interactive notification until the pause takes
|
||
# effect. The Resume action + sticky flag (and the action listener
|
||
# that makes the button work) are all created together in
|
||
# _setup_timer_pause_notification only on pause success, so a
|
||
# no-op/failed pause never leaves a sticky "Resume Cycle" card with
|
||
# a dead button. _check_cycle_timers is sync, so bridge via a task.
|
||
self.hass.async_create_task(
|
||
self._async_auto_pause_and_notify(msg, timer_tag)
|
||
)
|
||
else:
|
||
self._dispatch_notification(
|
||
msg,
|
||
event_type=NOTIFY_EVENT_TIMER,
|
||
extra_vars={"tag": timer_tag},
|
||
allow_deferral=False,
|
||
allow_presence_deferral=False,
|
||
)
|
||
self._logger.info(
|
||
"Cycle timer #%d fired at %.0fs (%.1f min): %s",
|
||
idx, elapsed_seconds, offset, msg,
|
||
)
|
||
|
||
async def _async_auto_pause_and_notify(self, msg: str, tag: str) -> None:
|
||
"""Pause the cycle for an auto-pause timer, then show the pause UI on success.
|
||
|
||
The interactive pause notification is created only after the pause actually
|
||
takes effect, so a no-op/failed pause never leaves a stale "paused" card.
|
||
"""
|
||
if await self.async_pause_cycle():
|
||
self._setup_timer_pause_notification(msg, tag)
|
||
|
||
def _setup_timer_pause_notification(self, msg: str, tag: str) -> None:
|
||
"""Create the interactive pause notification: mobile card + HA sidebar + action listener.
|
||
|
||
Called from _async_auto_pause_and_notify only after async_pause_cycle() has
|
||
actually taken effect. Sends the interactive mobile notification (Resume
|
||
action + sticky), creates the HA persistent notification for sidebar
|
||
visibility, and registers the mobile action listener — all together, so the
|
||
Resume button always has a live listener behind it and only ever appears
|
||
when the cycle is genuinely paused.
|
||
"""
|
||
self._clear_timer_pause_notification()
|
||
|
||
# Interactive mobile notification — dispatched now (post-pause) rather than
|
||
# at timer-fire time, so a failed/no-op pause never shows a dead Resume card.
|
||
self._dispatch_notification(
|
||
msg,
|
||
event_type=NOTIFY_EVENT_TIMER,
|
||
extra_vars={
|
||
"tag": tag,
|
||
"actions": [
|
||
{
|
||
"action": self._timer_pause_action_id,
|
||
"title": self._timer_ui_strings.get(
|
||
"timer_pause_action_title", "Resume Cycle"
|
||
),
|
||
}
|
||
],
|
||
"sticky": "true",
|
||
},
|
||
allow_deferral=False,
|
||
allow_presence_deferral=False,
|
||
)
|
||
|
||
self._timer_pause_pn_id = tag
|
||
self._timer_pause_mobile_tag = tag
|
||
|
||
_body_suffix = self._timer_ui_strings.get(
|
||
"timer_pause_body_suffix", "The cycle is paused. Open the WashData panel to resume."
|
||
)
|
||
_pn_create(
|
||
self.hass,
|
||
f"{msg}\n\n{_body_suffix}",
|
||
title=f"WashData: {self.config_entry.title}",
|
||
notification_id=tag,
|
||
)
|
||
|
||
action_id = self._timer_pause_action_id
|
||
|
||
@callback
|
||
def _on_mobile_action(event: Any) -> None:
|
||
if event.data.get("action") == action_id:
|
||
self.hass.async_create_task(self.async_resume_cycle())
|
||
|
||
self._remove_timer_action_listener = self.hass.bus.async_listen(
|
||
"mobile_app_notification_action",
|
||
_on_mobile_action,
|
||
)
|
||
|
||
def _clear_timer_pause_notification(self) -> None:
|
||
"""Dismiss the active timer-pause notification (both HA persistent and mobile)."""
|
||
if self._remove_timer_action_listener is not None:
|
||
self._remove_timer_action_listener()
|
||
self._remove_timer_action_listener = None
|
||
|
||
if self._timer_pause_pn_id:
|
||
_pn_dismiss(self.hass, self._timer_pause_pn_id)
|
||
self._timer_pause_pn_id = None
|
||
|
||
if self._timer_pause_mobile_tag:
|
||
services = self._get_services_for_event(NOTIFY_EVENT_TIMER)
|
||
mobile_services = [s for s in services if self._is_mobile_notify_service(s)]
|
||
if mobile_services:
|
||
self._send_notification_service(
|
||
"clear_notification",
|
||
services=mobile_services,
|
||
event_type=NOTIFY_EVENT_TIMER,
|
||
extra_vars={"tag": self._timer_pause_mobile_tag},
|
||
)
|
||
self._timer_pause_mobile_tag = None
|
||
|
||
def _estimate_phase_progress(
|
||
self,
|
||
current_power_data: list[tuple[datetime, float]] | list[tuple[str, float]],
|
||
current_duration: float,
|
||
profile_name: str,
|
||
) -> tuple[float, float] | None:
|
||
"""Phase-aware progress estimate. Thin wrapper over :mod:`progress`."""
|
||
return progress_mod.estimate_phase_progress(
|
||
self.profile_store,
|
||
current_power_data,
|
||
current_duration,
|
||
profile_name,
|
||
self._logger,
|
||
)
|
||
|
||
def _notify_update(self) -> None:
|
||
"""Notify entities of update."""
|
||
async_dispatcher_send(self.hass, SIGNAL_WASHER_UPDATE.format(self.entry_id))
|
||
|
||
def notify_update(self) -> None:
|
||
"""Public method to notify entities of update."""
|
||
self._notify_update()
|
||
|
||
@property
|
||
def is_user_paused(self) -> bool:
|
||
"""Return True if cycle is currently user-paused."""
|
||
return self._is_user_paused
|
||
|
||
@property
|
||
def is_clean_state(self) -> bool:
|
||
"""Return True if machine is in Clean state (cycle ended, door not yet opened)."""
|
||
return self._is_clean_state
|
||
|
||
@property
|
||
def net_elapsed_seconds(self) -> float:
|
||
"""Elapsed seconds in the current cycle, excluding user-paused time."""
|
||
raw = float(self.detector.get_elapsed_seconds())
|
||
paused = self._total_user_paused_seconds
|
||
if self._user_pause_start is not None:
|
||
paused += (dt_util.now() - self._user_pause_start).total_seconds()
|
||
return max(0.0, raw - paused)
|
||
|
||
def check_state(self):
|
||
"""Return current detector state."""
|
||
if self.recorder.is_recording:
|
||
return STATE_RUNNING
|
||
# A completed cycle ends in STATE_FINISHED, not STATE_OFF; accept both
|
||
# or the door-sensor Clean state (#153) is never surfaced (#282).
|
||
if self._is_clean_state and self.detector.state in (
|
||
STATE_OFF,
|
||
STATE_FINISHED,
|
||
):
|
||
return STATE_CLEAN
|
||
if self._is_user_paused:
|
||
return STATE_USER_PAUSED
|
||
return self.detector.state
|
||
|
||
def list_phase_catalog(self, device_type: str) -> list[dict[str, Any]]:
|
||
"""Return the merged phase catalog for a device type."""
|
||
return self.profile_store.list_phase_catalog(device_type)
|
||
|
||
def get_profile_phase_ranges_for_device(
|
||
self,
|
||
profile_name: str,
|
||
device_type: str,
|
||
) -> list[dict[str, Any]]:
|
||
"""Return phase ranges assigned to a profile for a given device type."""
|
||
return self.profile_store.get_profile_phase_ranges_for_device(
|
||
profile_name,
|
||
device_type,
|
||
)
|
||
|
||
@property
|
||
def sub_state(self) -> str | None:
|
||
"""Return more granular state info (e.g. current phase)."""
|
||
if self.recorder.is_recording:
|
||
return "Recording"
|
||
return self.detector.sub_state
|
||
|
||
@property
|
||
def current_program(self):
|
||
"""Return the current program name."""
|
||
return self._current_program
|
||
|
||
@property
|
||
def time_remaining(self):
|
||
"""Return estimated time remaining in seconds."""
|
||
return self._time_remaining
|
||
|
||
@property
|
||
def total_duration(self) -> float | None:
|
||
"""Return total predicted duration in seconds."""
|
||
return self._total_duration
|
||
|
||
@property
|
||
def last_total_duration_update(self) -> datetime | None:
|
||
"""Return when total duration was last refined."""
|
||
return self._last_total_duration_update
|
||
|
||
@property
|
||
def cycle_progress(self):
|
||
"""Return cycle progress as a percentage."""
|
||
return self._cycle_progress
|
||
|
||
@property
|
||
def projected_energy_wh(self) -> float | None:
|
||
"""Projected total energy (Wh) for the running cycle, or None."""
|
||
return self._projected_energy_wh
|
||
|
||
@property
|
||
def projected_cost(self) -> float | None:
|
||
"""Projected total cost for the running cycle, or None when no price."""
|
||
return self._projected_cost
|
||
|
||
@property
|
||
def cycle_anomaly(self) -> str:
|
||
"""Runtime anomaly state for the current cycle ("none" | "overrun")."""
|
||
return self._cycle_anomaly
|
||
|
||
@property
|
||
def overrun_ratio(self) -> float:
|
||
"""Elapsed / expected duration for the running cycle (0.0 when unknown)."""
|
||
return self._overrun_ratio
|
||
|
||
@property
|
||
def last_cycle_post_anomaly(self) -> dict:
|
||
"""Post-cycle anomaly data from the last completed cycle.
|
||
|
||
Contains subset of keys present: anomaly (underrun/overrun/none),
|
||
underrun_ratio, energy_anomaly (energy_spike/energy_low), energy_z_score.
|
||
Empty dict when no completed cycle or no anomaly detected.
|
||
"""
|
||
return self._last_cycle_post_anomaly
|
||
|
||
@property
|
||
def restart_gaps(self) -> list[dict]:
|
||
"""HA restart gaps recorded during the current active cycle (may be empty)."""
|
||
return self._restart_gaps
|
||
|
||
@property
|
||
def maintenance_due(self) -> list[str]:
|
||
"""Maintenance event types whose reminder threshold has been reached (E2).
|
||
|
||
Surfaced as a state-sensor attribute + read by the panel banner. Never a
|
||
notification. Returns an empty list on any error.
|
||
"""
|
||
try:
|
||
cfg = self.config_entry.options.get(CONF_MAINTENANCE_REMINDER_CYCLES)
|
||
if not isinstance(cfg, dict) or not cfg:
|
||
cfg = DEFAULT_MAINTENANCE_REMINDER_CYCLES
|
||
return self.profile_store.get_maintenance_due(cfg)
|
||
except Exception: # noqa: BLE001
|
||
return []
|
||
|
||
@property
|
||
def current_power(self):
|
||
"""Return current power reading in watts."""
|
||
return self._current_power
|
||
|
||
@property
|
||
def cycle_start_time(self) -> datetime | None:
|
||
"""Return the start time of the current cycle."""
|
||
return self.detector.current_cycle_start
|
||
|
||
@property
|
||
def last_cycle_end_time(self) -> datetime | None:
|
||
"""Return when the most recent completed cycle ended (or None).
|
||
|
||
Set at cycle end and restored from stored history on startup. Consumed by
|
||
the conversation intent handler to answer "how long ago did it finish".
|
||
"""
|
||
return self._last_cycle_end_time
|
||
|
||
@property
|
||
def last_match_details(self) -> dict[str, Any] | None:
|
||
"""Return details of the last profile match."""
|
||
res = getattr(self, "_last_match_result", None)
|
||
return res.to_dict() if res else None
|
||
|
||
@property
|
||
def samples_recorded(self):
|
||
"""Return the number of power samples recorded in current cycle."""
|
||
return self.detector.samples_recorded
|
||
|
||
@property
|
||
def sample_interval_stats(self):
|
||
"""Return statistics about sampling intervals."""
|
||
return self._sample_interval_stats
|
||
|
||
@property
|
||
def pump_stuck(self) -> bool:
|
||
"""Return True if the pump stuck threshold has fired for the current cycle."""
|
||
return self._pump_stuck
|
||
|
||
@property
|
||
def pump_runs_today(self) -> int:
|
||
"""Return the number of completed pump cycles that started in the last 24 hours.
|
||
|
||
Counts all past cycles whose ``start_time`` falls within the rolling 24-hour
|
||
window ending now. Returns 0 for non-pump device types.
|
||
"""
|
||
if self.device_type != DEVICE_TYPE_PUMP:
|
||
return 0
|
||
cutoff = dt_util.now().timestamp() - 86400.0
|
||
count = 0
|
||
for cycle in self.profile_store.get_past_cycles():
|
||
start_raw = cycle.get("start_time")
|
||
if not start_raw:
|
||
continue
|
||
try:
|
||
if isinstance(start_raw, str):
|
||
parsed = dt_util.parse_datetime(start_raw)
|
||
if parsed is None:
|
||
continue
|
||
ts = parsed.timestamp()
|
||
else:
|
||
ts = float(start_raw)
|
||
if ts >= cutoff:
|
||
count += 1
|
||
except (TypeError, ValueError):
|
||
continue
|
||
return count
|
||
|
||
@property
|
||
def cycle_count(self) -> int:
|
||
"""Return the total number of completed cycles stored for this device."""
|
||
return len(self.profile_store.get_past_cycles())
|
||
|
||
@property
|
||
def lifetime_energy_kwh(self) -> float:
|
||
"""Lifetime accumulated energy (kWh) for the HA Energy dashboard sensor."""
|
||
return round(self.profile_store.get_lifetime_energy_wh() / 1000.0, 3)
|
||
|
||
@property
|
||
def manual_program_active(self) -> bool:
|
||
"""Return True if a manual program override is active."""
|
||
return getattr(self, "_manual_program_active", False)
|
||
|
||
def set_manual_program(self, profile_name: str) -> None:
|
||
"""Manually set the current program."""
|
||
if self.detector.state != "running":
|
||
return
|
||
profiles_raw: Any = None
|
||
try:
|
||
profiles_raw = self.profile_store.get_profiles()
|
||
except Exception: # pylint: disable=broad-exception-caught
|
||
profiles_raw = None
|
||
|
||
if isinstance(profiles_raw, dict):
|
||
profiles: dict[str, Any] = cast(dict[str, Any], profiles_raw)
|
||
else:
|
||
profiles_fallback = getattr(self.profile_store, "_data", {}).get(
|
||
"profiles", {}
|
||
)
|
||
profiles = (
|
||
cast(dict[str, Any], profiles_fallback)
|
||
if isinstance(profiles_fallback, dict)
|
||
else {}
|
||
)
|
||
|
||
if profile_name not in profiles:
|
||
self._logger.warning("Cannot set manual program: '%s' not found", profile_name)
|
||
return
|
||
|
||
self._current_program = profile_name
|
||
self._manual_program_active = True
|
||
|
||
# Update expected duration immediately
|
||
profile = profiles.get(profile_name)
|
||
if profile:
|
||
avg = float(profile.get("avg_duration", 0.0))
|
||
if avg > 0:
|
||
self._matched_profile_duration = avg
|
||
self._logger.info(
|
||
"Manual program set to %s, duration=%.0fs", profile_name, avg
|
||
)
|
||
|
||
# Update estimates if running
|
||
if self.detector.state == "running":
|
||
self._update_estimates()
|
||
|
||
async def async_pause_cycle(self) -> bool:
|
||
"""Pause the current cycle (user-triggered).
|
||
|
||
Sets verified_pause so the cycle is not finalized when power drops.
|
||
Optionally cuts power to the switch entity if CONF_PAUSE_CUTS_POWER is enabled.
|
||
|
||
Returns True if the cycle was paused, False if it was a no-op (wrong state).
|
||
"""
|
||
if self.detector.state not in (STATE_RUNNING, STATE_STARTING, STATE_PAUSED, STATE_ENDING):
|
||
self._logger.debug(
|
||
"async_pause_cycle: ignored (detector state=%s)", self.detector.state
|
||
)
|
||
return False
|
||
|
||
if self._is_user_paused:
|
||
self._logger.debug("async_pause_cycle: already user-paused, ignoring")
|
||
return False
|
||
|
||
self._logger.info("Cycle paused by user")
|
||
prev_verified = self.detector._verified_pause
|
||
self._is_user_paused = True
|
||
self._user_pause_start = dt_util.now()
|
||
self.detector.set_verified_pause(True)
|
||
|
||
if self._pause_cuts_power:
|
||
switch_entity = self.config_entry.options.get(
|
||
CONF_SWITCH_ENTITY
|
||
) or self.config_entry.data.get(CONF_SWITCH_ENTITY)
|
||
if switch_entity:
|
||
self._logger.info(
|
||
"pause_cuts_power: turning off switch %s", switch_entity
|
||
)
|
||
try:
|
||
await self.hass.services.async_call(
|
||
"switch", "turn_off", {"entity_id": switch_entity}, blocking=True
|
||
)
|
||
except HomeAssistantError as err:
|
||
self._logger.warning(
|
||
"pause_cuts_power: failed to turn off %s: %s - rolling back pause state",
|
||
switch_entity, err,
|
||
)
|
||
self._is_user_paused = False
|
||
self._user_pause_start = None
|
||
self.detector.set_verified_pause(prev_verified)
|
||
return False
|
||
|
||
snapshot = self._augment_active_snapshot(self.detector.get_state_snapshot())
|
||
self.hass.async_create_task(self.profile_store.async_save_active_cycle(snapshot))
|
||
self._notify_update()
|
||
return True
|
||
|
||
async def async_resume_cycle(self) -> bool:
|
||
"""Resume a user-paused cycle.
|
||
|
||
Accumulates elapsed paused time and clears the verified pause flag.
|
||
Optionally restores power via the switch entity if CONF_PAUSE_CUTS_POWER is enabled.
|
||
|
||
Returns True if the cycle was resumed, False if it was a no-op (not paused).
|
||
"""
|
||
if not self._is_user_paused:
|
||
self._logger.debug("async_resume_cycle: not user-paused, ignoring")
|
||
return False
|
||
|
||
now = dt_util.now()
|
||
prev_pause_start = self._user_pause_start
|
||
accumulated = (
|
||
(now - prev_pause_start).total_seconds()
|
||
if prev_pause_start is not None else 0.0
|
||
)
|
||
|
||
self._total_user_paused_seconds += accumulated
|
||
self._user_pause_start = None
|
||
self._is_user_paused = False
|
||
self.detector.set_verified_pause(False)
|
||
self._logger.info(
|
||
"Cycle resumed by user (total paused: %.0fs)", self._total_user_paused_seconds
|
||
)
|
||
|
||
if self._pause_cuts_power:
|
||
switch_entity = self.config_entry.options.get(
|
||
CONF_SWITCH_ENTITY
|
||
) or self.config_entry.data.get(CONF_SWITCH_ENTITY)
|
||
if switch_entity:
|
||
self._logger.info(
|
||
"pause_cuts_power: turning on switch %s", switch_entity
|
||
)
|
||
try:
|
||
await self.hass.services.async_call(
|
||
"switch", "turn_on", {"entity_id": switch_entity}, blocking=True
|
||
)
|
||
except HomeAssistantError as err:
|
||
self._logger.warning(
|
||
"pause_cuts_power: failed to turn on %s: %s - rolling back resume state",
|
||
switch_entity, err,
|
||
)
|
||
self._total_user_paused_seconds -= accumulated
|
||
self._user_pause_start = prev_pause_start
|
||
self._is_user_paused = True
|
||
self.detector.set_verified_pause(True)
|
||
return False
|
||
|
||
# Dismiss the interactive pause notification only after the resume (incl. the
|
||
# switch turn-on) has actually succeeded — a rolled-back resume above returns
|
||
# early with the card still up, matching the real (still-paused) state.
|
||
self._clear_timer_pause_notification()
|
||
|
||
snapshot = self._augment_active_snapshot(self.detector.get_state_snapshot())
|
||
self.hass.async_create_task(self.profile_store.async_save_active_cycle(snapshot))
|
||
self._notify_update()
|
||
return True
|
||
|
||
async def async_terminate_cycle(self) -> None:
|
||
"""Force terminate the current cycle via user request."""
|
||
self._logger.warning("Force terminating cycle by user request")
|
||
|
||
# Trigger natural cycle end via detector
|
||
# This will call _on_cycle_end callback, which handles:
|
||
# - Saving to profile store
|
||
# - Clearing active cycle persistence
|
||
# - Post-processing/Merging
|
||
# - Notifications
|
||
self.detector.user_stop()
|
||
|
||
# We DO NOT clear manager state manually here (e.g. self._current_program)
|
||
# because we want the UI to show the "Clean" state with the just-finished
|
||
# program info. The standard reset timers in _on_cycle_end /
|
||
# _async_power_changed will handle cleanup after delay.
|
||
|
||
# Force a state update to reflect the change immediately
|
||
self._notify_update()
|
||
|
||
async def async_start_recording(self) -> None:
|
||
"""Start manual recording of a cycle."""
|
||
if self.recorder.is_recording:
|
||
self._logger.warning("Already recording")
|
||
return
|
||
|
||
# Ensure we are in a clean state (stop any running cycle first?)
|
||
# If running, user should probably stop it? Or force stop?
|
||
# Plan said "unregulated", so we just start recording.
|
||
# But if cycle_detector thinks it's running, we should probably "pause" it
|
||
# or just override state. My override in checks_state handles UI.
|
||
# But should we clear current program?
|
||
if self.detector.state != "off":
|
||
self._logger.info("Forcing detector reset before recording")
|
||
self.detector.reset()
|
||
|
||
await self.recorder.start_recording()
|
||
self._notify_update()
|
||
|
||
async def async_stop_recording(self) -> None:
|
||
"""Stop manual recording."""
|
||
if not self.recorder.is_recording:
|
||
return
|
||
|
||
await self.recorder.stop_recording()
|
||
self._notify_update()
|
||
|
||
def clear_manual_program(self) -> None:
|
||
"""Clear manual program override."""
|
||
if not self._manual_program_active:
|
||
return
|
||
|
||
self._manual_program_active = False
|
||
# If running, revert to detecting so auto-detection can resume?
|
||
if self.detector.state == "running":
|
||
self._current_program = "detecting..."
|
||
self._matched_profile_duration = None
|
||
self._update_estimates() # Trigger immediate re-detection attempt
|
||
else:
|
||
# If not running, clear the forced program
|
||
self._current_program = "off"
|
||
self._matched_profile_duration = None
|
||
|
||
self._notify_update()
|
||
self._logger.info("Manual program cleared, reverting to auto-detection")
|
||
|
||
async def _run_post_cycle_processing(self) -> None:
|
||
"""Run post-cycle processing (merge fragments, split anomalies)."""
|
||
try:
|
||
# User Feedback: Use 5 hour lookback and configured gap settings
|
||
stats = await self.profile_store.async_run_maintenance()
|
||
|
||
# Log significant actions
|
||
merged = stats.get("merged_cycles", 0)
|
||
split = stats.get("split_cycles", 0)
|
||
if merged > 0 or split > 0:
|
||
self._logger.info(
|
||
"Post-cycle processing: Merged %s, Split %s cycle(s)", merged, split
|
||
)
|
||
|
||
# Note: async_run_maintenance saves automatically if changes occur
|
||
except Exception as e: # pylint: disable=broad-exception-caught
|
||
self._logger.error("Post-cycle processing failed: %s", e) |