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