# WashData - Home Assistant integration for appliance cycle monitoring via smart plugs. # Copyright (C) 2026 Lukas Bandura # SPDX-License-Identifier: AGPL-3.0-or-later # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published # by the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """WebSocket API commands for the WashData full-screen panel.""" from __future__ import annotations import asyncio import collections import functools import json import logging import os import re import time import uuid from typing import Any import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.storage import Store from homeassistant.util import dt as dt_util from .const import ( CONF_AUTO_LABEL_CONFIDENCE, CONF_NAME, CONF_COMPLETION_MIN_SECONDS, CONF_DEVICE_TYPE, CONF_DOOR_SENSOR_ENTITY, CONF_ANTI_WRINKLE_EXIT_POWER, CONF_ANTI_WRINKLE_MAX_POWER, CONF_DURATION_TOLERANCE, CONF_END_ENERGY_THRESHOLD, CONF_END_REPEAT_COUNT, CONF_ENERGY_SENSOR, CONF_EXTERNAL_END_TRIGGER, CONF_LEARNING_CONFIDENCE, CONF_LINKED_DEVICE, CONF_MAINTENANCE_REMINDER_CYCLES, CONF_MIN_OFF_GAP, CONF_MIN_POWER, CONF_NO_UPDATE_ACTIVE_TIMEOUT, CONF_OFF_DELAY, CONF_PROFILE_DURATION_TOLERANCE, CONF_PROFILE_MATCH_INTERVAL, CONF_PROFILE_MATCH_MAX_DURATION_RATIO, CONF_PROFILE_MATCH_MIN_DURATION_RATIO, CONF_PROFILE_MATCH_THRESHOLD, CONF_PROFILE_MIN_WARMUP_CYCLES, CONF_PROFILE_UNMATCH_THRESHOLD, CONF_PUMP_STUCK_DURATION, CONF_POWER_OFF_THRESHOLD_W, CONF_RUNNING_DEAD_ZONE, CONF_SAMPLING_INTERVAL, CONF_SMOOTHING_WINDOW, CONF_START_DURATION_THRESHOLD, CONF_START_THRESHOLD_W, CONF_STOP_THRESHOLD_W, CONF_SWITCH_ENTITY, CONF_WATCHDOG_INTERVAL, DEFAULT_DEVICE_TYPE, DEFAULT_MAINTENANCE_REMINDER_CYCLES, DEFAULT_MIN_POWER, DEFAULT_OFF_DELAY, DEFAULT_OFF_DELAY_BY_DEVICE, DEFAULT_RUNNING_DEAD_ZONE, DEVICE_TYPE_PUMP, MAINTENANCE_EVENT_TYPES, DEVICE_TYPES, DOMAIN, ENABLE_ML_SUGGESTIONS, ENABLE_ML_TRAINING, SHOW_ML_LAB, STATE_COLORS, ) from . import playground from . import task_registry from .cycle_detector import CycleDetectorConfig from .setup_advisor import compute_setup_phase from .ws_schema import WS_OPEN_RESPONSES, WS_RESPONSE_TYPES _LOGGER = logging.getLogger(__name__) # ─── WS response contract (Group H1) ──────────────────────────────────────────── # Debug-only validation of every send_result payload against the TypedDict # registered for its command in ws_schema.py. OFF by default so production has # ZERO overhead: _send_result forwards straight to connection.send_result unless # the flag is on. Enable by exporting HA_WASHDATA_WS_CONTRACT=1 before starting # HA, or by flipping ws_api._WS_CONTRACT_CHECK = True (tests do the latter). _WS_CONTRACT_CHECK: bool = bool(os.environ.get("HA_WASHDATA_WS_CONTRACT")) def _validate_ws_contract(command: str, data: Any) -> list[str]: """Return contract-violation messages for ``data`` vs its response TypedDict. Pure and defensive: an unknown command or a non-dict payload for a typed command is reported, missing required top-level keys are reported, and unexpected top-level keys are reported unless the command is registered as open-ended in ``WS_OPEN_RESPONSES``. Returns an empty list when everything checks out. Never raises. """ td = WS_RESPONSE_TYPES.get(command) if td is None: return [f"{command}: no response type registered"] if not isinstance(data, dict): return [f"{command}: response is {type(data).__name__}, expected dict"] required = set(getattr(td, "__required_keys__", ()) or ()) optional = set(getattr(td, "__optional_keys__", ()) or ()) keys = set(data.keys()) problems: list[str] = [] missing = required - keys if missing: problems.append(f"{command}: missing required keys {sorted(missing)}") if command not in WS_OPEN_RESPONSES: unexpected = keys - (required | optional) if unexpected: problems.append(f"{command}: unexpected keys {sorted(unexpected)}") return problems def _send_result( connection: websocket_api.ActiveConnection, msg_id: int, command: str, data: Any, ) -> None: """Send a WS result, validating its shape against the contract in debug mode. Behaviourally identical to ``connection.send_result(msg_id, data)``; the contract check is a no-op (never touched) unless ``_WS_CONTRACT_CHECK`` is on, and even then it only logs — it never mutates ``data`` nor raises, so it can never change what the client receives. """ if __debug__ and _WS_CONTRACT_CHECK: try: problems = _validate_ws_contract(command, data) if problems: _LOGGER.warning("WS contract mismatch: %s", "; ".join(problems)) except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("WS contract check failed for %s: %s", command, exc) connection.send_result(msg_id, data) # Fields too large or not serialisable to send over WebSocket. _CYCLE_STRIP_KEYS = frozenset({"power_data", "power_trace", "debug_data", "samples"}) # Settings keys that can be staged from suggestions. Mirrors the OptionsFlow's # _suggestion_keys_to_apply so the panel and the flow agree on what is tunable. _SUGGESTION_KEYS: tuple[str, ...] = ( CONF_MIN_POWER, CONF_OFF_DELAY, CONF_WATCHDOG_INTERVAL, CONF_NO_UPDATE_ACTIVE_TIMEOUT, CONF_SAMPLING_INTERVAL, CONF_PROFILE_MATCH_INTERVAL, CONF_AUTO_LABEL_CONFIDENCE, CONF_DURATION_TOLERANCE, CONF_PROFILE_DURATION_TOLERANCE, CONF_PROFILE_MATCH_MIN_DURATION_RATIO, CONF_PROFILE_MATCH_MAX_DURATION_RATIO, CONF_MIN_OFF_GAP, CONF_START_THRESHOLD_W, CONF_STOP_THRESHOLD_W, CONF_END_ENERGY_THRESHOLD, CONF_RUNNING_DEAD_ZONE, # Stage 1 detection suggestions CONF_SMOOTHING_WINDOW, CONF_START_DURATION_THRESHOLD, CONF_COMPLETION_MIN_SECONDS, CONF_LEARNING_CONFIDENCE, CONF_PROFILE_MATCH_THRESHOLD, CONF_END_REPEAT_COUNT, # Device-class-specific suggestions from reconcile_suggestions CONF_PROFILE_UNMATCH_THRESHOLD, CONF_POWER_OFF_THRESHOLD_W, CONF_ANTI_WRINKLE_EXIT_POWER, CONF_ANTI_WRINKLE_MAX_POWER, CONF_PUMP_STUCK_DURATION, ) # Suggestion keys coerced to int when applied (mirrors the OptionsFlow). _SUGGESTION_INT_KEYS: frozenset[str] = frozenset({ CONF_OFF_DELAY, CONF_WATCHDOG_INTERVAL, CONF_NO_UPDATE_ACTIVE_TIMEOUT, CONF_PROFILE_MATCH_INTERVAL, CONF_MIN_OFF_GAP, CONF_RUNNING_DEAD_ZONE, CONF_SMOOTHING_WINDOW, CONF_COMPLETION_MIN_SECONDS, CONF_END_REPEAT_COUNT, CONF_PUMP_STUCK_DURATION, }) def _suggestion_equivalent(suggested: Any, current: Any) -> bool: """True when a suggested value is effectively the same as the current one. Numeric-tolerant so an int option (30) matches a float suggestion (30.0); a missing current value never counts as equivalent (so it still surfaces). Used to hide suggestions that would not change anything. """ if current is None: return False try: return abs(float(suggested) - float(current)) < 1e-6 except (TypeError, ValueError): return str(suggested) == str(current) # Settings surfaced in the ML Lab side-by-side comparison: key -> (label, unit). # Order defines display order. Only keys either engine produces are shown. _ML_COMPARE_SETTINGS: tuple[tuple[str, str, str], ...] = ( (CONF_OFF_DELAY, "Off Delay", "s"), (CONF_END_REPEAT_COUNT, "End Repeat Count", "x"), (CONF_AUTO_LABEL_CONFIDENCE, "Auto-label Confidence", ""), (CONF_STOP_THRESHOLD_W, "Stop Threshold", "W"), (CONF_START_THRESHOLD_W, "Start Threshold", "W"), (CONF_END_ENERGY_THRESHOLD, "End Energy Threshold", "Wh"), (CONF_RUNNING_DEAD_ZONE, "Running Dead Zone", "s"), (CONF_MIN_POWER, "Min Power", "W"), (CONF_MIN_OFF_GAP, "Min Off Gap", "s"), (CONF_DURATION_TOLERANCE, "Duration Tolerance", ""), ) def _downsample(samples: Any, max_points: int = 240) -> list[list[float]]: """Reduce a [(offset_s, watts), ...] series to <= max_points via striding. Keeps the first and last samples so the time axis is preserved. Power curves can hold thousands of points; the panel only needs enough to draw a faithful line, and WebSocket payloads should stay lean. """ try: pairs = list(samples or []) except TypeError: return [] n = len(pairs) if n == 0: return [] def _pt(item: Any) -> list[float]: return [round(float(item[0]), 2), round(float(item[1]), 1)] if n <= max_points: return [_pt(it) for it in pairs] step = n / float(max_points) out: list[list[float]] = [] last_i = -1 idx = 0.0 while int(idx) < n: i = int(idx) if i != last_i: out.append(_pt(pairs[i])) last_i = i idx += step last_pt = _pt(pairs[-1]) if not out or out[-1][0] != last_pt[0]: out.append(last_pt) return out async def _recorder_power(hass: HomeAssistant, entity_id: str, start_dt: Any) -> list[tuple[float, float]]: """Raw (unix_ts, watts) readings for entity_id from start_dt to now, via the recorder.""" try: from homeassistant.components.recorder import ( # pylint: disable=import-outside-toplevel get_instance, history, ) except Exception: # pylint: disable=broad-exception-caught return [] end_dt = dt_util.now() # tz-aware; use dt_util.now() per the datetime convention def _query() -> list[tuple[float, float]]: res = history.state_changes_during_period( hass, start_dt, end_dt, entity_id, include_start_time_state=True ) rows: list[tuple[float, float]] = [] for s in res.get(entity_id, []) or []: try: rows.append((s.last_changed.timestamp(), round(float(s.state), 1))) except (ValueError, TypeError): continue return rows try: return await get_instance(hass).async_add_executor_job(_query) except Exception: # pylint: disable=broad-exception-caught return [] def _cycle_kwh(c: dict[str, Any]) -> float | None: """Cycle energy in kWh for display. Prefers the external meter's reading (``energy_meter_wh``, issue #316) when a cycle recorded one, otherwise the integrated ``energy_wh``. Cycles store energy in Wh; convert to kWh. """ wh = c.get("energy_meter_wh") if wh is None: wh = c.get("energy_wh") if wh is not None: try: return round(float(wh) / 1000.0, 4) except (TypeError, ValueError): pass return c.get("energy_kwh") # ─── Helpers ────────────────────────────────────────────────────────────────── def _get_manager(hass: HomeAssistant, entry_id: str) -> Any | None: domain_data: dict[str, Any] = hass.data.get(DOMAIN, {}) return domain_data.get(entry_id) if isinstance(domain_data, dict) else None # Per-entry serialization lock for the heavy, multi-await store-mutating WS # handlers (process_recording / reprocess_history / import_config). Holding one # lock for the whole operation prevents two of them from interleaving and # clobbering each other's persisted state or colliding on cycle IDs. Stored on # hass.data (not module-global) so each lock is created inside — and bound to — # the running event loop, which keeps it correct across test event loops. _WS_WRITE_LOCKS_KEY = f"{DOMAIN}_ws_write_locks" def _entry_write_lock(hass: HomeAssistant, entry_id: str) -> asyncio.Lock: """Return the shared per-entry write lock, creating it on first use.""" locks: dict[str, asyncio.Lock] = hass.data.setdefault(_WS_WRITE_LOCKS_KEY, {}) lock = locks.get(entry_id) if lock is None: lock = asyncio.Lock() locks[entry_id] = lock return lock def _get_entry(hass: HomeAssistant, entry_id: str) -> Any | None: return next( (e for e in hass.config_entries.async_entries(DOMAIN) if e.entry_id == entry_id), None, ) def _err_not_found(connection: websocket_api.ActiveConnection, msg_id: int, entry_id: str) -> None: connection.send_error(msg_id, "not_found", f"No active WashData manager for entry {entry_id!r}") def _strip_cycle(c: dict[str, Any]) -> dict[str, Any]: return {k: v for k, v in c.items() if k not in _CYCLE_STRIP_KEYS} # Option keys that are identity/transient churn and are never recorded in the # settings changelog (D7): name/title edits flow through the separate `title` # kwarg, and suggestion application uses its own apply_suggestions command. _CHANGELOG_SKIP_KEYS = frozenset({CONF_NAME}) # Identity keys that must NEVER be persisted into entry.options; they are # partitioned out of any submitted/imported option payload before it is saved. # In this integration only the display name is a pure options-forbidden identity # key -- it is carried by the config entry title. device_type / power_sensor / # min_power deliberately live in entry.options post-3.6 (config_flow writes them # there and the manager resolves them options-first), so they are NOT listed # here; relocating them to entry.data would shadow the option-first reads. _OPTIONS_IDENTITY_KEYS = frozenset({CONF_NAME}) def _json_safe(value: Any) -> Any: """Best-effort coercion of an option value to a JSON-serializable form.""" try: json.dumps(value) return value except (TypeError, ValueError): return str(value) def _diff_option_changes( old_effective: dict[str, Any], submitted: dict[str, Any] ) -> list[dict[str, Any]]: """Diff submitted option values against the pre-update effective options. Returns one changelog entry ``{"key", "old", "new", "timestamp"}`` per key whose value genuinely changed, skipping identity/transient keys. Only keys present in ``submitted`` are considered so unrelated merged options never produce spurious entries. ``old=None`` with a real ``new`` value is recorded; an unchanged ``None -> None`` is not. """ ts = dt_util.now().isoformat() changes: list[dict[str, Any]] = [] for key, new_val in submitted.items(): if key in _CHANGELOG_SKIP_KEYS: continue old_val = old_effective.get(key) if old_val == new_val: continue changes.append( { "key": str(key), "old": _json_safe(old_val), "new": _json_safe(new_val), "timestamp": ts, } ) return changes # ─── Panel config + RBAC ──────────────────────────────────────────────────────── _PANEL_STORE_VERSION = 1 _PANEL_STORE_FILE = "ha_washdata_panel" _PANEL_DATA_KEY = "ha_washdata_panel_cfg" _LEVEL_RANK = {"none": 0, "read": 1, "edit": 2, "full": 3} _PANEL_TABS = ("status", "history", "profiles", "settings", "tools", "panel", "ml_lab", "playground") # Valid values for the two per-user string prefs (validated in ws_set_user_prefs). _PREF_DATE_FORMATS = ("relative", "absolute") # lang_override: empty string clears it (fall back to system language); otherwise a # BCP-47-ish tag (e.g. "en", "pt-BR", "sr-Latn"). Kept as a bounded pattern rather # than coupling the WS handler to the translations/panel/ language file list — # the panel already falls back to system language for any tag it can't load. _PREF_LANG_TAG_RE = re.compile(r"^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$") # Commands that require 'full' (destructive or full-data export/import). _FULL_COMMANDS = frozenset({ "wipe_history", "import_config", "export_config", "clear_debug_data", "reprocess_history", "trigger_ml_training", # Reverting on-device models / matcher tuning discards learned state -> full access. "revert_matching_config", "revert_ml_models", }) # Commands allowed for any authenticated user regardless of device permissions. _OPEN_COMMANDS = frozenset({ "get_constants", "get_panel_config", "set_user_prefs", }) # Admin-only commands. # Commands that require administrator access ALWAYS — even when RBAC is disabled # (default), where every authenticated user otherwise resolves to "full". These are # destructive/global: they can wipe stored data, overwrite config, read/write files # on disk, or reprocess the whole history. A non-admin HA user must not reach them. _ADMIN_COMMANDS = frozenset({ "set_panel_config", "get_logs", "wipe_history", "import_config", "export_config", "reprocess_history", "clear_debug_data", # Global community-store mutations: these change the ONE integration-wide GitHub # connection / online flag shared by every entry, so an editor of a single device # must not be able to connect, disconnect, or toggle online for the whole install. "store_connect", "store_disconnect", "store_set_online", "store_set_prefs", }) # Mutating commands intentionally allowed at the 'read' level. Picking the live # program is a benign runtime action (it changes detection, not stored data), so # read users may use the Status program selector. The Playground simulation is a # read-only what-if replay (it never persists anything) whose name does not start # with get_, so it is whitelisted here to gate at the 'read' level. _READ_WRITE_COMMANDS = frozenset({ "set_program", "run_playground_cycle_detail", "run_playground_history", "run_playground_sweep", # Background-task registry: read-level runtime actions (watch progress, fetch # a what-if/maintenance result, or stop a task). None mutate stored data. "list_tasks", "subscribe_tasks", "cancel_task", "get_task_result", "start_playground_history", "start_playground_sweep", "start_playground_cycle_detail", # Community store: read-only browse is read-level (writes below default to 'edit'). "store_status", "store_search_devices", "store_list_brands", "store_get_profiles", "store_get_cycles", "store_get_device_quality", "store_get_device_profiles", }) _LOG_BUFFER_KEY = "ha_washdata_log_buffer" _LOG_LEVELS = {"DEBUG": 10, "INFO": 20, "WARNING": 30, "ERROR": 40, "CRITICAL": 50} class _RingLogHandler(logging.Handler): """In-memory ring buffer of recent ha_washdata log records for the Logs page.""" def __init__(self, maxlen: int = 500) -> None: super().__init__() self.records: collections.deque = collections.deque(maxlen=maxlen) def emit(self, record: logging.LogRecord) -> None: try: self.records.append({ "ts": record.created, "level": record.levelname, "logger": record.name.split(".")[-1], "device": getattr(record, "wd_device", None), "msg": record.getMessage(), }) except Exception: # pylint: disable=broad-exception-caught pass def _default_panel_cfg() -> dict[str, Any]: return { "panel": {"poll_interval_s": 5, "default_tab": "status", "hidden_tabs": []}, "rbac": {"enabled": False, "default_level": "none", "users": {}}, "prefs": {}, } async def async_load_panel_config(hass: HomeAssistant) -> None: """Load (once) the panel-global config + RBAC store into hass.data.""" if _PANEL_DATA_KEY in hass.data: return store = Store(hass, _PANEL_STORE_VERSION, _PANEL_STORE_FILE) cfg = _default_panel_cfg() try: loaded = await store.async_load() if isinstance(loaded, dict): if isinstance(loaded.get("panel"), dict): cfg["panel"].update(loaded["panel"]) if isinstance(loaded.get("rbac"), dict): for k in ("enabled", "default_level", "users"): if k in loaded["rbac"]: cfg["rbac"][k] = loaded["rbac"][k] if not isinstance(cfg["rbac"].get("users"), dict): cfg["rbac"]["users"] = {} if isinstance(loaded.get("prefs"), dict): cfg["prefs"] = loaded["prefs"] except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.warning("Failed to load panel config, using defaults: %s", exc) hass.data[_PANEL_DATA_KEY] = {"store": store, "data": cfg} if _LOG_BUFFER_KEY not in hass.data: handler = _RingLogHandler() handler.setLevel(logging.DEBUG) wd_logger = logging.getLogger("custom_components.ha_washdata") wd_logger.addHandler(handler) # The ring handler captures at the logger's configured effective level # (HA's default is INFO, so lifecycle activity shows out of the box). We do # NOT raise the logger level here: doing so would override a user who set # this integration to WARNING and leak INFO records into home-assistant.log. # To see more in the panel Logs view, set the integration's log level in HA. hass.data[_LOG_BUFFER_KEY] = handler def _panel_data(hass: HomeAssistant) -> dict[str, Any]: holder = hass.data.get(_PANEL_DATA_KEY) return holder["data"] if holder else _default_panel_cfg() async def _save_panel_data(hass: HomeAssistant) -> None: holder = hass.data.get(_PANEL_DATA_KEY) if holder: await holder["store"].async_save(holder["data"]) def _effective_level(hass: HomeAssistant, user: Any, entry_id: str | None) -> str: """Resolve a user's access level for a device (none/read/edit/full).""" if user is None: return "none" if getattr(user, "is_admin", False): return "full" rbac = _panel_data(hass).get("rbac", {}) if not rbac.get("enabled"): return "full" # RBAC disabled -> unrestricted (original behavior) u = (rbac.get("users") or {}).get(user.id) if isinstance(u, dict): if entry_id and entry_id in (u.get("devices") or {}): return u["devices"][entry_id] return u.get("default", "none") return rbac.get("default_level", "none") # Background-task commands that may reference a task by id with no device context. _TASK_COMMANDS = frozenset({"list_tasks", "subscribe_tasks", "cancel_task", "get_task_result"}) def _rbac_ok(hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]) -> bool: """Authorize a command for the calling user; sends an error and returns False if denied.""" user = getattr(connection, "user", None) if user is None: connection.send_error(msg["id"], "unauthorized", "No authenticated user") return False if getattr(user, "is_admin", False): return True cmd = str(msg.get("type", "")).split("/", 1)[-1] if cmd in _ADMIN_COMMANDS: connection.send_error(msg["id"], "forbidden", "Administrator access required") return False if cmd in _OPEN_COMMANDS: return True entry_id = msg.get("entry_id") # Background-task commands can address a task by id without an entry_id. Under RBAC, # a non-admin must not read/cancel a task on a device they aren't authorized for by # simply omitting entry_id (which would otherwise fall through the allow-all below). # When RBAC is disabled this block is skipped entirely, so default behavior is intact. if not entry_id and cmd in _TASK_COMMANDS and _panel_data(hass).get("rbac", {}).get("enabled"): task_id = msg.get("task_id") if task_id: from . import task_registry # pylint: disable=import-outside-toplevel task = task_registry.get_registry(hass).get(task_id) entry_id = getattr(task, "entry_id", None) if task is not None else None if not entry_id: connection.send_error(msg["id"], "forbidden", "Task not found or not authorized") return False else: # list_tasks / subscribe_tasks with no device context: require a device so # the result set can be authorized (the panel passes entry_id per device). connection.send_error(msg["id"], "forbidden", "You need to specify a device") return False if not entry_id: return True # no device context and not admin/open: harmless read-style command if cmd in _READ_WRITE_COMMANDS: required = "read" elif cmd in _FULL_COMMANDS: required = "full" elif cmd.startswith("get_"): required = "read" else: required = "edit" have = _effective_level(hass, user, entry_id) if _LEVEL_RANK.get(have, 0) >= _LEVEL_RANK[required]: return True connection.send_error(msg["id"], "forbidden", f"You need {required} access to this device") return False def _guard(handler: Any) -> Any: """Wrap a websocket handler with an RBAC check. Uses functools.wraps so the websocket_command/async_response markers and schema attributes carry over verbatim, keeping sync (@callback) and async (@async_response) handlers working unchanged. """ @functools.wraps(handler) def wrapper(hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]) -> Any: if not _rbac_ok(hass, connection, msg): return None return handler(hass, connection, msg) return wrapper def _sanitize_panel(p: dict[str, Any], current: dict[str, Any]) -> dict[str, Any]: out = dict(current) if "poll_interval_s" in p: try: out["poll_interval_s"] = max(2, min(60, int(p["poll_interval_s"]))) except (TypeError, ValueError): pass if p.get("default_tab") in _PANEL_TABS: out["default_tab"] = p["default_tab"] if isinstance(p.get("hidden_tabs"), list): out["hidden_tabs"] = [t for t in p["hidden_tabs"] if t in _PANEL_TABS and t not in ("status", "panel")] return out def _sanitize_rbac(r: dict[str, Any]) -> dict[str, Any]: levels = set(_LEVEL_RANK) dlevel = r.get("default_level", "none") out: dict[str, Any] = { "enabled": bool(r.get("enabled", False)), "default_level": dlevel if dlevel in levels else "none", "users": {}, } for uid, u in (r.get("users") or {}).items(): if not isinstance(u, dict): continue d = u.get("default", "none") devices = {str(eid): lvl for eid, lvl in (u.get("devices") or {}).items() if lvl in levels} out["users"][str(uid)] = {"default": d if d in levels else "none", "devices": devices} return out # ─── Registration ───────────────────────────────────────────────────────────── @callback # ─── Community store (online features) ───────────────────────────────────────── def _store_ctx(hass: HomeAssistant, entry_id: str) -> tuple[Any, dict[str, Any]] | None: """Return (manager, options) when online features are enabled (global), else None.""" from .store import online_features_enabled entry = _get_entry(hass, entry_id) manager = _get_manager(hass, entry_id) if entry is None or manager is None: return None if not online_features_enabled(hass): return None return manager, dict(entry.options) @websocket_api.websocket_command({vol.Required("type"): "ha_washdata/store_status", vol.Required("entry_id"): str}) @websocket_api.async_response async def ws_store_status(hass, connection, msg): from .store import online_features_enabled manager = _get_manager(hass, msg["entry_id"]) if manager is None: _err_not_found(connection, msg["id"], msg["entry_id"]) return if not online_features_enabled(hass): _send_result(connection, msg["id"], "store_status", {"enabled": False}) return _send_result(connection, msg["id"], "store_status", manager.store_bridge.status()) @websocket_api.websocket_command({ vol.Required("type"): "ha_washdata/store_connect", vol.Required("entry_id"): str, vol.Required("refresh_token"): str, vol.Required("uid"): str, vol.Optional("name"): vol.Any(str, None), }) @websocket_api.async_response async def ws_store_connect(hass, connection, msg): ctx = _store_ctx(hass, msg["entry_id"]) if ctx is None: _send_result(connection, msg["id"], "store_connect", {"disabled": True}) return manager, _ = ctx res = await manager.store_bridge.connect(msg["refresh_token"], msg["uid"], msg.get("name")) _send_result(connection, msg["id"], "store_connect", res) @websocket_api.websocket_command({vol.Required("type"): "ha_washdata/store_disconnect", vol.Required("entry_id"): str}) @websocket_api.async_response async def ws_store_disconnect(hass, connection, msg): ctx = _store_ctx(hass, msg["entry_id"]) if ctx is None: _send_result(connection, msg["id"], "store_disconnect", {"disabled": True}) return manager, _ = ctx _send_result(connection, msg["id"], "store_disconnect", await manager.store_bridge.disconnect()) @websocket_api.websocket_command({ vol.Required("type"): "ha_washdata/store_search_devices", vol.Required("entry_id"): str, vol.Optional("query"): vol.Any(str, None), vol.Optional("appliance_type"): vol.Any(str, None), vol.Optional("model_query"): vol.Any(str, None), vol.Optional("include_pending"): bool, }) @websocket_api.async_response async def ws_store_search_devices(hass, connection, msg): ctx = _store_ctx(hass, msg["entry_id"]) if ctx is None: _send_result(connection, msg["id"], "store_search_devices", {"disabled": True}) return manager, _ = ctx items = await manager.store_bridge.search_devices( msg.get("query"), msg.get("appliance_type"), model_query=msg.get("model_query"), include_pending=bool(msg.get("include_pending", False)), ) _send_result(connection, msg["id"], "store_search_devices", {"items": items}) @websocket_api.websocket_command({ vol.Required("type"): "ha_washdata/store_list_brands", vol.Required("entry_id"): str, vol.Optional("query"): vol.Any(str, None), vol.Optional("include_pending"): bool, }) @websocket_api.async_response async def ws_store_list_brands(hass, connection, msg): ctx = _store_ctx(hass, msg["entry_id"]) if ctx is None: _send_result(connection, msg["id"], "store_list_brands", {"disabled": True}) return manager, _ = ctx items = await manager.store_bridge.list_brands(msg.get("query"), include_pending=bool(msg.get("include_pending", True))) _send_result(connection, msg["id"], "store_list_brands", {"items": items}) @websocket_api.websocket_command({ vol.Required("type"): "ha_washdata/store_get_device_quality", vol.Required("entry_id"): str, vol.Required("device_id"): str, }) @websocket_api.async_response async def ws_store_get_device_quality(hass, connection, msg): ctx = _store_ctx(hass, msg["entry_id"]) if ctx is None: _send_result(connection, msg["id"], "store_get_device_quality", {"disabled": True}) return manager, _ = ctx _send_result(connection, msg["id"], "store_get_device_quality", await manager.store_bridge.get_device_quality(msg["device_id"])) @websocket_api.websocket_command({ vol.Required("type"): "ha_washdata/store_get_device_profiles", vol.Required("entry_id"): str, vol.Required("brand"): str, vol.Required("model"): str, vol.Required("appliance_type"): str, }) @websocket_api.async_response async def ws_store_get_device_profiles(hass, connection, msg): ctx = _store_ctx(hass, msg["entry_id"]) if ctx is None: _send_result(connection, msg["id"], "store_get_device_profiles", {"disabled": True}) return manager, _ = ctx res = await manager.store_bridge.device_profiles(msg["brand"], msg["model"], msg["appliance_type"]) _send_result(connection, msg["id"], "store_get_device_profiles", res) @websocket_api.websocket_command({ vol.Required("type"): "ha_washdata/store_confirm_device", vol.Required("entry_id"): str, vol.Required("device_id"): str, }) @websocket_api.async_response async def ws_store_confirm_device(hass, connection, msg): ctx = _store_ctx(hass, msg["entry_id"]) if ctx is None: _send_result(connection, msg["id"], "store_confirm_device", {"disabled": True}) return manager, _ = ctx _send_result(connection, msg["id"], "store_confirm_device", await manager.store_bridge.confirm_device(msg["device_id"])) @websocket_api.websocket_command({ vol.Required("type"): "ha_washdata/store_rate_device", vol.Required("entry_id"): str, vol.Required("device_id"): str, vol.Required("rating"): vol.All(int, vol.Range(min=1, max=5)), }) @websocket_api.async_response async def ws_store_rate_device(hass, connection, msg): ctx = _store_ctx(hass, msg["entry_id"]) if ctx is None: _send_result(connection, msg["id"], "store_rate_device", {"disabled": True}) return manager, _ = ctx _send_result(connection, msg["id"], "store_rate_device", await manager.store_bridge.rate_device(msg["device_id"], int(msg["rating"]))) @websocket_api.websocket_command({ vol.Required("type"): "ha_washdata/store_set_online", vol.Required("entry_id"): str, vol.Required("enabled"): bool, }) @websocket_api.async_response async def ws_store_set_online(hass, connection, msg): """Enable/disable online features integration-wide (device-agnostic).""" from . import store_account await store_account.async_set_online(hass, bool(msg["enabled"])) manager = _get_manager(hass, msg["entry_id"]) if manager is not None: manager.notify_update() _send_result(connection, msg["id"], "store_set_online", {"enabled": store_account.online_enabled(hass)}) @websocket_api.websocket_command({ vol.Required("type"): "ha_washdata/store_set_prefs", vol.Required("entry_id"): str, vol.Required("prefs"): dict, }) @websocket_api.async_response async def ws_store_set_prefs(hass, connection, msg): """Merge integration-wide community-store preferences (only known keys).""" from . import store_account prefs = await store_account.async_set_prefs(hass, msg.get("prefs") or {}) manager = _get_manager(hass, msg["entry_id"]) if manager is not None: manager.notify_update() _send_result(connection, msg["id"], "store_set_prefs", {"prefs": prefs}) @websocket_api.websocket_command({ vol.Required("type"): "ha_washdata/store_get_profiles", vol.Required("entry_id"): str, vol.Required("device_id"): str, }) @websocket_api.async_response async def ws_store_get_profiles(hass, connection, msg): ctx = _store_ctx(hass, msg["entry_id"]) if ctx is None: _send_result(connection, msg["id"], "store_get_profiles", {"disabled": True}) return manager, _ = ctx items = await manager.store_bridge.get_profiles(msg["device_id"]) _send_result(connection, msg["id"], "store_get_profiles", {"items": items}) @websocket_api.websocket_command({ vol.Required("type"): "ha_washdata/store_get_cycles", vol.Required("entry_id"): str, vol.Required("profile_id"): str, }) @websocket_api.async_response async def ws_store_get_cycles(hass, connection, msg): ctx = _store_ctx(hass, msg["entry_id"]) if ctx is None: _send_result(connection, msg["id"], "store_get_cycles", {"disabled": True}) return manager, _ = ctx items = await manager.store_bridge.get_cycles(msg["profile_id"]) _send_result(connection, msg["id"], "store_get_cycles", {"items": items}) @websocket_api.websocket_command({ vol.Required("type"): "ha_washdata/store_import_cycle", vol.Required("entry_id"): str, vol.Required("cycle_id"): str, vol.Optional("target_profile"): vol.Any(str, None), vol.Optional("new_profile_name"): vol.Any(str, None), }) @websocket_api.async_response async def ws_store_import_cycle(hass, connection, msg): ctx = _store_ctx(hass, msg["entry_id"]) if ctx is None: _send_result(connection, msg["id"], "store_import_cycle", {"disabled": True}) return manager, _ = ctx res = await manager.store_bridge.import_cycle( msg["cycle_id"], msg.get("target_profile"), msg.get("new_profile_name") ) manager.notify_update() _send_result(connection, msg["id"], "store_import_cycle", res) @websocket_api.websocket_command({ vol.Required("type"): "ha_washdata/store_upload_cycle", vol.Required("entry_id"): str, vol.Required("local_cycle_id"): str, vol.Required("program"): str, vol.Optional("description"): vol.Any(str, None), }) @websocket_api.async_response async def ws_store_upload_cycle(hass, connection, msg): from .const import CONF_STORE_BRAND, CONF_STORE_MODEL, CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE ctx = _store_ctx(hass, msg["entry_id"]) if ctx is None: _send_result(connection, msg["id"], "store_upload_cycle", {"disabled": True}) return manager, opts = ctx brand = str(opts.get(CONF_STORE_BRAND) or "").strip() model = str(opts.get(CONF_STORE_MODEL) or "").strip() if not brand or not model: _send_result(connection, msg["id"], "store_upload_cycle", {"error": "no_appliance_declared"}) return appliance = opts.get(CONF_DEVICE_TYPE, manager.config_entry.data.get(CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE)) res = await manager.store_bridge.share_cycle( msg["local_cycle_id"], msg["program"], brand, model, appliance, description=msg.get("description") or "", ) _send_result(connection, msg["id"], "store_upload_cycle", res) @websocket_api.websocket_command({ vol.Required("type"): "ha_washdata/store_upload_device", vol.Required("entry_id"): str, vol.Required("items"): [dict], vol.Optional("include_phases"): [str], vol.Optional("include_settings"): bool, }) @websocket_api.async_response async def ws_store_upload_device(hass, connection, msg): """Share a whole-device bundle. Brand/model/type come from this device's options; ``items`` = the panel's tree selection ``[{local_cycle_id, program}]``; ``include_phases`` = programs whose phase map should ride along; ``include_settings`` bundles the allow-listed recognition/matching settings.""" from .const import ( CONF_STORE_BRAND, CONF_STORE_MODEL, CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE, SHAREABLE_SETTING_KEYS, ) ctx = _store_ctx(hass, msg["entry_id"]) if ctx is None: _send_result(connection, msg["id"], "store_upload_device", {"disabled": True}) return manager, opts = ctx brand = str(opts.get(CONF_STORE_BRAND) or "").strip() model = str(opts.get(CONF_STORE_MODEL) or "").strip() if not brand or not model: _send_result(connection, msg["id"], "store_upload_device", {"error": "no_appliance_declared"}) return appliance = opts.get(CONF_DEVICE_TYPE, manager.config_entry.data.get(CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE)) settings = None if msg.get("include_settings"): # Only the allow-listed numeric thresholds; the WS layer owns entry.options. settings = {k: opts[k] for k in SHAREABLE_SETTING_KEYS if k in opts} res = await manager.store_bridge.share_device( brand, model, appliance, msg["items"], include_phases=msg.get("include_phases"), settings=settings, ) _send_result(connection, msg["id"], "store_upload_device", res) @websocket_api.websocket_command({ vol.Required("type"): "ha_washdata/store_download_device", vol.Required("entry_id"): str, vol.Required("device_id"): str, vol.Optional("include_settings"): bool, }) @websocket_api.async_response async def ws_store_download_device(hass, connection, msg): """Adopt a whole-device bundle into this device's reference cycles (merge/upsert). When ``include_settings`` is set, also apply the bundle's allow-listed recognition/matching settings onto this device's options (overwrites live tuning).""" from .const import CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE, SHAREABLE_SETTING_KEYS ctx = _store_ctx(hass, msg["entry_id"]) if ctx is None: _send_result(connection, msg["id"], "store_download_device", {"disabled": True}) return manager, opts = ctx device_type = opts.get(CONF_DEVICE_TYPE, manager.config_entry.data.get(CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE)) res = await manager.store_bridge.download_device(msg["device_id"], device_type) settings_applied = 0 if msg.get("include_settings"): bundle_settings = res.get("settings") if isinstance(res.get("settings"), dict) else {} # Accept only allow-listed, numeric (non-bool) values - matching what the # upload side ever writes - so a malformed/hostile bundle can't inject a # string/list/bool into this device's live options. filtered = { k: v for k, v in bundle_settings.items() if k in SHAREABLE_SETTING_KEYS and isinstance(v, (int, float)) and not isinstance(v, bool) } entry = _get_entry(hass, msg["entry_id"]) if filtered and entry is not None: hass.config_entries.async_update_entry(entry, options={**entry.options, **filtered}) settings_applied = len(filtered) res = {**res, "settings_applied": settings_applied} manager.notify_update() _send_result(connection, msg["id"], "store_download_device", res) @websocket_api.websocket_command({ vol.Required("type"): "ha_washdata/get_shareable_cycles", vol.Required("entry_id"): str, }) @websocket_api.async_response async def ws_get_shareable_cycles(hass, connection, msg): """All recorded/golden reference cycles eligible to share (the share-device tree), plus the subset of programs that carry a local phase map (for the phase toggle).""" manager = _get_manager(hass, msg["entry_id"]) if manager is None: _err_not_found(connection, msg["id"], msg["entry_id"]) return store = manager.profile_store items = store.get_shareable_cycles() # All known profiles (not just those with shareable cycles) so the panel can show # profiles that exist but have no golden/recorded cycles yet, with guidance. all_programs = sorted(store.get_profiles().keys()) # Phase toggle covers ALL profiles that have a phase map, not just those with # shareable cycles — so users with phases but no reference cycles yet still see # the toggle (shown as a dimmed no-cycle row in the share tree). phase_programs = sorted(p for p in all_programs if store.get_profile_phase_ranges(p)) _send_result(connection, msg["id"], "get_shareable_cycles", {"items": items, "phase_programs": phase_programs, "all_programs": all_programs}) def async_register_commands(hass: HomeAssistant) -> None: """Register all WebSocket commands for the WashData panel. Every handler is wrapped in _guard so RBAC is enforced centrally and no command can accidentally ship unprotected. """ handlers = [ ws_get_devices, ws_get_device_cycles, # Settings ws_get_options, ws_set_options, ws_get_settings_changelog, ws_get_setup_status, # Profiles ws_get_profiles, ws_create_profile, ws_rename_profile, ws_delete_profile, ws_rebuild_envelopes, ws_get_profile_phases, ws_set_profile_phases, # Profile groups (Stage 5) ws_get_profile_groups, ws_save_profile_group, ws_rename_profile_group, ws_delete_profile_group, # Maintenance log (Group E) ws_get_maintenance_log, ws_add_maintenance_event, ws_delete_maintenance_event, # Cycles ws_label_cycle, ws_delete_cycle, ws_auto_label_cycles, # Phase catalog ws_get_phase_catalog, ws_create_phase, ws_update_phase, ws_delete_phase, # Recording ws_get_recording_state, ws_start_recording, ws_stop_recording, ws_process_recording, ws_discard_recording, # Feedbacks ws_get_feedbacks, ws_resolve_feedback, ws_dismiss_all_feedbacks, # Diagnostics ws_get_diagnostics, ws_reprocess_history, ws_clear_debug_data, ws_wipe_history, ws_export_config, ws_import_config, # Shared constants ws_get_constants, # Suggestions ws_get_suggestions, ws_apply_suggestions, ws_clear_suggestions, ws_run_suggestion_analysis, # Cycle curve / interactive editing ws_get_cycle_power_data, ws_trim_cycle, ws_analyze_split, ws_apply_split, ws_apply_merge, # Profile envelope / member cycles ws_get_profile_envelope, ws_get_profile_cycles, # Panel config + RBAC ws_get_panel_config, ws_set_panel_config, ws_set_user_prefs, # Logs ws_get_logs, # Live power history ws_get_power_history, # Manual program selection ws_set_program, # Live match debug ws_get_match_debug, # ML Lab shadow-mode comparison ws_get_ml_comparison, # ML Lab review write-back (Stage 4b) ws_set_ml_review, # On-device ML training (status + manual trigger + matcher-tuning revert + models revert) ws_get_ml_training_status, ws_trigger_ml_training, ws_revert_matching_config, ws_revert_ml_models, # Cycle controls (pause / resume / force-stop) ws_pause_cycle, ws_resume_cycle, ws_terminate_cycle, # Playground (F3): DTW visualizer ws_get_dtw_debug, # Playground redesign: faithful single-cycle sim + history table + sweep ws_run_playground_cycle_detail, ws_run_playground_history, ws_run_playground_sweep, # Background-task registry (progress / cancel / reconnect-safe results) ws_list_tasks, ws_subscribe_tasks, ws_cancel_task, ws_get_task_result, # Playground batch/sweep as detached registry-tracked tasks ws_start_playground_history, ws_start_playground_sweep, ws_start_playground_cycle_detail, # Community store (online features): status/connect/disconnect/browse/import/upload ws_store_status, ws_store_connect, ws_store_disconnect, ws_store_search_devices, ws_store_get_profiles, ws_store_get_cycles, ws_store_import_cycle, ws_store_upload_cycle, # Device-bundle sharing (Stage 1): upload a whole device + adopt one ws_store_upload_device, ws_store_download_device, # Local reference cycles eligible to share (share-device tree source) ws_get_shareable_cycles, # Community catalog: brand list, device quality, confirm/rate, global online toggle ws_store_list_brands, ws_store_get_device_quality, ws_store_get_device_profiles, ws_store_confirm_device, ws_store_rate_device, ws_store_set_online, ws_store_set_prefs, ] for handler in handlers: websocket_api.async_register_command(hass, _guard(handler)) # ─── Devices ────────────────────────────────────────────────────────────────── @websocket_api.websocket_command({vol.Required("type"): "ha_washdata/get_devices"}) @callback def ws_get_devices( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Return all WashData config entries with their live state (RBAC-filtered).""" entries = hass.config_entries.async_entries(DOMAIN) domain_data: dict[str, Any] = hass.data.get(DOMAIN, {}) user = getattr(connection, "user", None) devices: list[dict[str, Any]] = [] for entry in entries: level = _effective_level(hass, user, entry.entry_id) if level == "none": continue # device hidden from this user by RBAC manager = domain_data.get(entry.entry_id) if isinstance(domain_data, dict) else None info: dict[str, Any] = { "entry_id": entry.entry_id, "perm": level, "title": entry.title, "detector_state": "unknown", "sub_state": None, "current_program": None, "time_remaining_s": None, "total_duration_s": None, "current_power_w": None, "cycle_progress_pct": None, "suggestions_count": 0, "feedback_count": 0, "recording": False, "is_user_paused": False, "manual_program": False, "options": dict(entry.options), } if manager is not None: try: detector = getattr(manager, "detector", None) if detector is not None: info["detector_state"] = detector.state info["sub_state"] = detector.sub_state program: str | None = getattr(manager, "_current_program", None) if program in (None, "off", "unknown", "detecting...", "restored..."): program = None info["current_program"] = program info["manual_program"] = bool(getattr(manager, "manual_program_active", False)) info["time_remaining_s"] = getattr(manager, "_time_remaining", None) info["total_duration_s"] = getattr(manager, "_total_duration", None) power = getattr(manager, "_current_power", None) info["current_power_w"] = round(float(power), 2) if power is not None else None progress = getattr(manager, "_cycle_progress", None) if progress is not None: info["cycle_progress_pct"] = round(float(progress), 1) store = getattr(manager, "profile_store", None) if store is not None: try: raw = store.get_suggestions() or {} info["suggestions_count"] = sum( 1 for k in _SUGGESTION_KEYS if isinstance(raw.get(k), dict) and raw[k].get("value") is not None ) except Exception: # pylint: disable=broad-exception-caught pass try: info["feedback_count"] = len(store.get_pending_feedback() or {}) except Exception: # pylint: disable=broad-exception-caught pass info["is_user_paused"] = bool(getattr(manager, "is_user_paused", False)) recorder = getattr(manager, "recorder", None) if recorder is not None: info["recording"] = bool(getattr(recorder, "is_recording", False)) except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("Error reading manager state for entry %s: %s", entry.entry_id, exc) devices.append(info) _send_result(connection, msg["id"], "get_devices", {"devices": devices}) @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/get_device_cycles", vol.Required("entry_id"): str, vol.Optional("limit", default=50): vol.All(int, vol.Range(min=1, max=200)), vol.Optional("offset", default=0): vol.All(int, vol.Range(min=0)), } ) @callback def ws_get_device_cycles( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Return a page of recent cycles for a device, stripping large binary fields. Cycles are returned most-recent-first and sliced ``[offset : offset+limit]`` so the panel can page. ``total`` is the device's full cycle count and ``has_more`` is True when cycles remain beyond the returned window. """ entry_id: str = msg["entry_id"] limit: int = msg.get("limit", 50) offset: int = msg.get("offset", 0) manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return cycles: list[dict[str, Any]] = [] reference_cycles: list[dict[str, Any]] = [] total = 0 try: store = getattr(manager, "profile_store", None) if store is not None: raw: list[Any] = store.get_past_cycles() total = len(raw) # History order is oldest-first in storage; present most-recent-first # and slice the requested page. offset=0 is identical to the legacy # reversed(raw[-limit:]) behaviour. ordered = list(reversed(raw)) window = ordered[offset:offset + limit] for c in window: cycles.append(_strip_cycle(c)) # Imported store recordings are a small, bounded set kept out of the # paginated `cycles`/`total` (they never enter usage stats). Return # them once, on the first page, tagged so the panel can badge them # and route edits/deletes correctly. if offset == 0: for c in reversed(store.get_reference_cycles()): ref = _strip_cycle(c) ref["is_reference"] = True reference_cycles.append(ref) except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("Error fetching cycles for entry %s: %s", entry_id, exc) has_more = (offset + len(cycles)) < total _send_result(connection, msg["id"], "get_device_cycles", { "entry_id": entry_id, "cycles": cycles, "reference_cycles": reference_cycles, "total": total, "has_more": has_more, }, ) # ─── Settings ───────────────────────────────────────────────────────────────── @websocket_api.websocket_command( {vol.Required("type"): "ha_washdata/get_options", vol.Required("entry_id"): str} ) @callback def ws_get_options( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Return merged data+options for a config entry.""" entry = _get_entry(hass, msg["entry_id"]) if not entry: connection.send_error(msg["id"], "not_found", f"Entry {msg['entry_id']!r} not found") return options = {**entry.data, **entry.options} _send_result(connection, msg["id"], "get_options", {"options": options}) @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/set_options", vol.Required("entry_id"): str, vol.Required("options"): dict, } ) @websocket_api.async_response async def ws_set_options( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Persist updated options and trigger an entry reload.""" entry = _get_entry(hass, msg["entry_id"]) if not entry: connection.send_error(msg["id"], "not_found", f"Entry {msg['entry_id']!r} not found") return # Build the new options from the *existing* options plus the submitted # values only. Never spread entry.data in: that would copy identity and # data-only keys (name, initial_profile, stale creation-time identity) into # options where they don't belong. Tunables (including device_type / # power_sensor / min_power, which live in options post-3.6) are preserved # from entry.options and overridden by the submission. new_options = {**entry.options, **msg["options"]} # Capture the submitted display name for the entry title before it is # stripped out of options below. submitted_name = new_options.get(CONF_NAME) # Mirror the OptionsFlow save-time normalization so the panel can never # persist stale or invalid values: # - a cleared selector (entity / linked device / trigger) becomes None so # the link or subscription is removed rather than left dangling; # - pump-only keys are dropped for non-pump device types; # - the transient "apply suggestions" flag is never stored. for key in ( CONF_EXTERNAL_END_TRIGGER, CONF_DOOR_SENSOR_ENTITY, CONF_LINKED_DEVICE, CONF_SWITCH_ENTITY, CONF_ENERGY_SENSOR, ): if key in new_options and not new_options[key]: new_options[key] = None # Resolve the effective device type option-first (submission -> existing # options -> data -> default) so the pump-only key is dropped correctly even # when the submission omits device_type. effective_device_type = new_options.get( CONF_DEVICE_TYPE, entry.data.get(CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE) ) if effective_device_type != DEVICE_TYPE_PUMP: new_options.pop(CONF_PUMP_STUCK_DURATION, None) # Partition identity out of options: the display name is carried by the # entry title, never persisted in options (matches the config-flow invariant # that CONF_NAME is absent from options). for key in _OPTIONS_IDENTITY_KEYS: new_options.pop(key, None) update_kwargs: dict[str, Any] = {"options": new_options} if isinstance(submitted_name, str) and submitted_name.strip(): update_kwargs["title"] = submitted_name.strip() # Settings change history (D7): diff the pre-update effective options against # the post-normalization values, but only for keys the user actually # submitted, and persist BEFORE async_update_entry (which schedules a reload # that rebuilds the store). A changelog failure must never block the save. try: old_effective = {**entry.data, **entry.options} submitted_post = { k: new_options[k] for k in msg["options"] if k in new_options } changes = _diff_option_changes(old_effective, submitted_post) if changes: manager = _get_manager(hass, msg["entry_id"]) store = getattr(manager, "profile_store", None) if manager else None if store is not None: await store.async_record_settings_changes(changes) except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug( "Settings changelog recording failed for %s: %s", msg["entry_id"], exc ) # When online features are disabled, clear the persisted store account so the # user's identity isn't silently retained after they opt out. from .const import CONF_ENABLE_ONLINE_FEATURES # pylint: disable=import-outside-toplevel was_online = bool(entry.options.get(CONF_ENABLE_ONLINE_FEATURES, False)) now_online = bool(new_options.get(CONF_ENABLE_ONLINE_FEATURES, False)) if was_online and not now_online: try: manager = _get_manager(hass, msg["entry_id"]) store = getattr(manager, "profile_store", None) if manager else None if store is not None: await store.clear_store_account() await store.async_save() except Exception: # pylint: disable=broad-exception-caught pass hass.config_entries.async_update_entry(entry, **update_kwargs) _send_result(connection, msg["id"], "set_options", {"success": True}) @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/get_settings_changelog", vol.Required("entry_id"): str, } ) @websocket_api.async_response async def ws_get_settings_changelog( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Return the settings-change history for a device (most-recent-first).""" entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return changelog: list[dict[str, Any]] = [] try: store = getattr(manager, "profile_store", None) if store is not None: changelog = store.get_settings_changelog() except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug( "Error fetching settings changelog for entry %s: %s", entry_id, exc ) _send_result(connection, msg["id"], "get_settings_changelog", {"changelog": changelog}) @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/get_setup_status", vol.Required("entry_id"): str, } ) @websocket_api.async_response async def ws_get_setup_status( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Return the current setup phase for the adoption guidance card.""" manager = _get_manager(hass, msg["entry_id"]) if not manager: connection.send_error(msg["id"], "not_found", "Device not found") return # Gather user's skipped steps from user prefs skipped_steps: dict[str, str | None] = {} user = getattr(connection, "user", None) if user: holder = hass.data.get(_PANEL_DATA_KEY) if holder: prefs = holder["data"].get("prefs", {}).get(user.id, {}) for k, v in prefs.items(): if k.startswith("setup_skip_"): skipped_steps[k] = v # Gather store data (executor-safe reads) store = manager.profile_store profile_names = list(store._data.get("profiles", {}).keys()) past_cycles = store._data.get("past_cycles", []) ref_names: set[str] = set() for rc in store._data.get("reference_cycles", []): if rc.get("profile_name"): ref_names.add(rc["profile_name"]) coverage_gap = await hass.async_add_executor_job(store.suggest_coverage_gaps) # suggestions: read from the store (cheap dict lookup; heavy computation happens # in the SuggestionEngine background task, not here). suggestions = list((store.get_suggestions() or {}).values()) pg_data = store._data.get("profile_groups", {}) pending_groups = (pg_data.get("suggestions") or []) if isinstance(pg_data, dict) else [] device_type = manager.device_type result = compute_setup_phase( device_type=device_type, profile_names=profile_names, past_cycles=past_cycles, ref_profile_names=ref_names, coverage_gap=coverage_gap, suggestions=suggestions, profile_groups=pending_groups, skipped_steps=skipped_steps, now=dt_util.now(), ) _send_result(connection, msg["id"], "get_setup_status", { "phase": result.phase, "message_key": result.message_key, "message_params": result.message_params, "cta_label_key": result.cta_label_key, "cta_action": result.cta_action, "secondary_label_key": result.secondary_label_key, "secondary_action": result.secondary_action, "skippable": result.skippable, "dismissible": result.dismissible, "step_key": result.step_key, }) # ─── Profiles ───────────────────────────────────────────────────────────────── @websocket_api.websocket_command( {vol.Required("type"): "ha_washdata/get_profiles", vol.Required("entry_id"): str} ) @websocket_api.async_response async def ws_get_profiles( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Return all profiles for a device.""" entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return def _compute_stats() -> dict[str, Any]: profiles: list[dict[str, Any]] = [] try: profiles = manager.profile_store.list_profiles() except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("Error listing profiles for %s: %s", entry_id, exc) health: dict[str, dict] = {} try: health = manager.profile_store.compute_profile_health() except Exception: # pylint: disable=broad-exception-caught pass trends: dict[str, dict] = {} try: trends = manager.profile_store.compute_profile_trends() except Exception: # pylint: disable=broad-exception-caught pass coverage_gaps: dict[str, Any] = {} try: coverage_gaps = manager.profile_store.suggest_coverage_gaps() except Exception: # pylint: disable=broad-exception-caught pass advisories: list[dict] = [] try: advisories = manager.profile_store.compute_profile_advisories() except Exception: # pylint: disable=broad-exception-caught pass return { "profiles": profiles, "profile_health": health, "profile_trends": trends, "coverage_gaps": coverage_gaps, "profile_advisories": advisories, } stats = await hass.async_add_executor_job(_compute_stats) _send_result(connection, msg["id"], "get_profiles", stats) @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/create_profile", vol.Required("entry_id"): str, vol.Required("name"): str, vol.Optional("reference_cycle"): vol.Any(str, None), vol.Optional("manual_duration_min"): vol.Any(vol.Coerce(float), None), } ) @websocket_api.async_response async def ws_create_profile( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Create a new profile, optionally seeded from a cycle.""" entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return name = str(msg["name"]).strip() if not name: connection.send_error(msg["id"], "invalid_format", "Profile name must not be empty") return ref_cycle = msg.get("reference_cycle") manual_mins = msg.get("manual_duration_min") avg_duration = float(manual_mins) * 60.0 if manual_mins and float(manual_mins) > 0 else None try: await manager.profile_store.create_profile_standalone( name, ref_cycle if ref_cycle not in (None, "none", "") else None, avg_duration=avg_duration, ) manager.notify_update() _send_result(connection, msg["id"], "create_profile", {"success": True, "name": name}) except ValueError as exc: connection.send_error(msg["id"], "profile_exists", str(exc)) except Exception as exc: # pylint: disable=broad-exception-caught connection.send_error(msg["id"], "unknown_error", str(exc)) @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/rename_profile", vol.Required("entry_id"): str, vol.Required("profile_name"): str, vol.Required("new_name"): str, vol.Optional("manual_duration_min"): vol.Any(vol.Coerce(float), None), } ) @websocket_api.async_response async def ws_rename_profile( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Rename a profile and optionally update its manual duration.""" entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return new_name = str(msg["new_name"]).strip() if not new_name: connection.send_error(msg["id"], "invalid_format", "New name must not be empty") return manual_mins = msg.get("manual_duration_min") avg_duration = float(manual_mins) * 60.0 if manual_mins and float(manual_mins) > 0 else None try: await manager.profile_store.update_profile( msg["profile_name"], new_name, avg_duration=avg_duration ) manager.notify_update() _send_result(connection, msg["id"], "rename_profile", {"success": True}) except ValueError as exc: connection.send_error(msg["id"], "rename_failed", str(exc)) except Exception as exc: # pylint: disable=broad-exception-caught connection.send_error(msg["id"], "unknown_error", str(exc)) @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/delete_profile", vol.Required("entry_id"): str, vol.Required("profile_name"): str, vol.Optional("unlabel_cycles", default=True): bool, } ) @websocket_api.async_response async def ws_delete_profile( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Delete a profile, optionally removing cycle labels.""" entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return try: await manager.profile_store.delete_profile( msg["profile_name"], msg.get("unlabel_cycles", True) ) manager.notify_update() _send_result(connection, msg["id"], "delete_profile", {"success": True}) except Exception as exc: # pylint: disable=broad-exception-caught connection.send_error(msg["id"], "unknown_error", str(exc)) # ─── Profile groups (Stage 5) ────────────────────────────────────────────── @websocket_api.websocket_command( {vol.Required("type"): "ha_washdata/get_profile_groups", vol.Required("entry_id"): str} ) @websocket_api.async_response async def ws_get_profile_groups( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Return profile groups with member lists, cohesion, and near-duplicate suggestions the user can act on.""" from .const import GROUP_MIN_COHESION # pylint: disable=import-outside-toplevel entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return store = manager.profile_store groups = [] for name, g in store.get_profile_groups().items(): members = list(g.get("members") or []) if len(members) >= 2: coh = await hass.async_add_executor_job(store.group_cohesion, members) else: coh = 1.0 groups.append({ "name": name, "members": members, "cohesion": round(coh, 3), "cohesive": coh >= GROUP_MIN_COHESION, # False => not aggregated by matcher; UI warns }) suggestions = await hass.async_add_executor_job(store.suggest_profile_groups) _send_result(connection, msg["id"], "get_profile_groups", { "groups": groups, "min_cohesion": GROUP_MIN_COHESION, "suggestions": suggestions, }) @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/save_profile_group", vol.Required("entry_id"): str, vol.Required("name"): str, vol.Required("members"): [str], } ) @websocket_api.async_response async def ws_save_profile_group( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Create a group or replace an existing group's members.""" entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return try: store = manager.profile_store if msg["name"] in store.get_profile_groups(): await store.set_profile_group_members(msg["name"], msg["members"]) else: await store.create_profile_group(msg["name"], msg["members"]) manager.notify_update() _send_result(connection, msg["id"], "save_profile_group", {"success": True}) except Exception as exc: # pylint: disable=broad-exception-caught connection.send_error(msg["id"], "unknown_error", str(exc)) @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/rename_profile_group", vol.Required("entry_id"): str, vol.Required("name"): str, vol.Required("new_name"): str, } ) @websocket_api.async_response async def ws_rename_profile_group( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return try: await manager.profile_store.rename_profile_group(msg["name"], msg["new_name"]) manager.notify_update() _send_result(connection, msg["id"], "rename_profile_group", {"success": True}) except Exception as exc: # pylint: disable=broad-exception-caught connection.send_error(msg["id"], "unknown_error", str(exc)) @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/delete_profile_group", vol.Required("entry_id"): str, vol.Required("name"): str, } ) @websocket_api.async_response async def ws_delete_profile_group( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return try: await manager.profile_store.delete_profile_group(msg["name"]) manager.notify_update() _send_result(connection, msg["id"], "delete_profile_group", {"success": True}) except Exception as exc: # pylint: disable=broad-exception-caught connection.send_error(msg["id"], "unknown_error", str(exc)) @websocket_api.websocket_command( {vol.Required("type"): "ha_washdata/rebuild_envelopes", vol.Required("entry_id"): str} ) @callback def ws_rebuild_envelopes( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Rebuild power-profile envelopes for all profiles. Runs as a detached, registry-tracked task that rebuilds one profile per step (each DTW pass already offloads to the executor): rebuilding every profile serially inside the WS request stalled low-power hosts for the whole run (issue #311). Progress/result come via the task registry.""" entry_id: str = msg["entry_id"] if _get_manager(hass, entry_id) is None: _err_not_found(connection, msg["id"], entry_id) return reg = task_registry.get_registry(hass) task = reg.create( entry_id, "rebuild", "Rebuilding envelopes", label_key="task.rebuild.envelopes", label_params={}, ) _raw = hass.async_create_task(_rebuild_envelopes_task(hass, task, entry_id)) if _raw is not None: reg.link_asyncio_task(task.id, _raw) _send_result(connection, msg["id"], "rebuild_envelopes", {"task_id": task.id}) async def _rebuild_envelopes_task(hass: HomeAssistant, task: Any, entry_id: str) -> None: """Detached runner: rebuild every profile's envelope one at a time, reporting progress and honouring cancel, so the loop breathes between profiles.""" reg = task_registry.get_registry(hass) manager = _get_manager(hass, entry_id) if manager is None: reg.finish(task, state=task_registry.STATE_ERROR, error="device unavailable") return store = manager.profile_store lock = _entry_write_lock(hass, entry_id) acquired = False try: await lock.acquire() acquired = True names = list(store.get_profiles().keys()) reg.update(task, total=len(names), done=0) rebuilt = 0 for i, name in enumerate(names): if task.cancel_requested: break try: if await store.async_rebuild_envelope(name): rebuilt += 1 except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("Envelope rebuild failed for %s/%s: %s", entry_id, name, exc) reg.update(task, done=i + 1) if _get_manager(hass, entry_id) is manager: manager.notify_update() reg.finish( task, state=task_registry.STATE_CANCELLED if task.cancel_requested else task_registry.STATE_DONE, result={"success": True, "rebuilt": rebuilt}, ) except asyncio.CancelledError: reg.finish(task, state=task_registry.STATE_CANCELLED) raise except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.warning("Rebuild-envelopes task failed for %s: %s", entry_id, exc) reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc)) finally: if acquired: lock.release() @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/get_profile_phases", vol.Required("entry_id"): str, vol.Required("profile_name"): str, } ) @callback def ws_get_profile_phases( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Return phase ranges assigned to a profile.""" entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return phases: list[dict[str, Any]] = [] try: phases = manager.profile_store.get_profile_phase_ranges(msg["profile_name"]) except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("Error getting profile phases: %s", exc) _send_result(connection, msg["id"], "get_profile_phases", {"phases": phases or []}) @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/set_profile_phases", vol.Required("entry_id"): str, vol.Required("profile_name"): str, vol.Required("phases"): list, } ) @websocket_api.async_response async def ws_set_profile_phases( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Save phase ranges for a profile.""" entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return try: await manager.profile_store.async_set_profile_phase_ranges( msg["profile_name"], msg["phases"] ) _send_result(connection, msg["id"], "set_profile_phases", {"success": True}) except Exception as exc: # pylint: disable=broad-exception-caught connection.send_error(msg["id"], "unknown_error", str(exc)) # ─── Maintenance log (Group E) ────────────────────────────────────────────── @websocket_api.websocket_command( {vol.Required("type"): "ha_washdata/get_maintenance_log", vol.Required("entry_id"): str} ) @websocket_api.async_response async def ws_get_maintenance_log( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Return the maintenance log, due reminders, event types, and reminder config.""" entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return reminders = dict(DEFAULT_MAINTENANCE_REMINDER_CYCLES) cfg = manager.config_entry.options.get(CONF_MAINTENANCE_REMINDER_CYCLES) if isinstance(cfg, dict): reminders.update(cfg) _send_result(connection, msg["id"], "get_maintenance_log", { "log": manager.profile_store.get_maintenance_log(), "due": manager.maintenance_due, "event_types": list(MAINTENANCE_EVENT_TYPES), "reminders": reminders, }) @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/add_maintenance_event", vol.Required("entry_id"): str, vol.Required("event_type"): str, vol.Optional("date"): vol.Any(str, None), vol.Optional("notes"): str, } ) @websocket_api.async_response async def ws_add_maintenance_event( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Log a maintenance event (requires edit access via central RBAC guard).""" entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return try: event = await manager.profile_store.async_add_maintenance_event( msg["event_type"], msg.get("date"), msg.get("notes", "") ) manager.notify_update() _send_result(connection, msg["id"], "add_maintenance_event", {"success": True, "event": event}) except ValueError as exc: connection.send_error(msg["id"], "invalid_format", str(exc)) except Exception as exc: # pylint: disable=broad-exception-caught connection.send_error(msg["id"], "unknown_error", str(exc)) @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/delete_maintenance_event", vol.Required("entry_id"): str, vol.Required("event_id"): str, } ) @websocket_api.async_response async def ws_delete_maintenance_event( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Delete a maintenance event by id (requires edit access via central RBAC guard).""" entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return try: removed = await manager.profile_store.async_delete_maintenance_event(msg["event_id"]) if removed: manager.notify_update() _send_result(connection, msg["id"], "delete_maintenance_event", {"success": removed}) except Exception as exc: # pylint: disable=broad-exception-caught connection.send_error(msg["id"], "unknown_error", str(exc)) # ─── Cycles ─────────────────────────────────────────────────────────────────── @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/label_cycle", vol.Required("entry_id"): str, vol.Required("cycle_id"): str, vol.Optional("profile_name"): vol.Any(str, None), vol.Optional("new_profile_name"): vol.Any(str, None), } ) @websocket_api.async_response async def ws_label_cycle( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Assign (or remove) a profile label from a cycle. profile_name=None removes the label. profile_name='__create_new__' + new_profile_name creates and assigns. """ entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return cycle_id: str = msg["cycle_id"] profile_name: str | None = msg.get("profile_name") new_profile_name: str | None = msg.get("new_profile_name") try: if profile_name == "__create_new__": if not new_profile_name or not new_profile_name.strip(): connection.send_error(msg["id"], "invalid_format", "New profile name required") return await manager.profile_store.create_profile(new_profile_name.strip(), cycle_id) else: await manager.profile_store.assign_profile_to_cycle(cycle_id, profile_name) manager.notify_update() _send_result(connection, msg["id"], "label_cycle", {"success": True}) except ValueError as exc: connection.send_error(msg["id"], "label_failed", str(exc)) except Exception as exc: # pylint: disable=broad-exception-caught connection.send_error(msg["id"], "unknown_error", str(exc)) @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/delete_cycle", vol.Required("entry_id"): str, vol.Required("cycle_id"): str, } ) @websocket_api.async_response async def ws_delete_cycle( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Delete a single cycle.""" entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return try: await manager.profile_store.delete_cycle(msg["cycle_id"]) manager.notify_update() _send_result(connection, msg["id"], "delete_cycle", {"success": True}) except Exception as exc: # pylint: disable=broad-exception-caught connection.send_error(msg["id"], "unknown_error", str(exc)) @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/auto_label_cycles", vol.Required("entry_id"): str, vol.Optional("confidence_threshold", default=0.75): vol.All( vol.Coerce(float), vol.Range(min=0.5, max=0.95) ), } ) @websocket_api.async_response async def ws_auto_label_cycles( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Auto-label all cycles with matched profiles above the confidence threshold.""" entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return threshold: float = msg.get("confidence_threshold", 0.75) try: await manager.profile_store.auto_label_cycles(threshold, overwrite=True) manager.notify_update() _send_result(connection, msg["id"], "auto_label_cycles", {"success": True}) except Exception as exc: # pylint: disable=broad-exception-caught connection.send_error(msg["id"], "unknown_error", str(exc)) # ─── Phase catalog ──────────────────────────────────────────────────────────── @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/get_phase_catalog", vol.Required("entry_id"): str, vol.Optional("device_type"): vol.Any(str, None), } ) @callback def ws_get_phase_catalog( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Return phase catalog for a device type (or all types).""" entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return device_type: str | None = msg.get("device_type") or getattr(manager, "device_type", None) phases: list[dict[str, Any]] = [] try: phases = manager.profile_store.list_phase_catalog(device_type or "") except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("Error listing phase catalog: %s", exc) _send_result(connection, msg["id"], "get_phase_catalog", {"phases": phases, "device_type": device_type}) @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/create_phase", vol.Required("entry_id"): str, vol.Required("device_type"): str, vol.Required("name"): str, vol.Optional("description", default=""): str, } ) @websocket_api.async_response async def ws_create_phase( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Create a custom phase in the catalog.""" entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return try: await manager.profile_store.async_create_custom_phase( msg["device_type"], msg["name"], msg.get("description", "") ) _send_result(connection, msg["id"], "create_phase", {"success": True}) except ValueError as exc: connection.send_error(msg["id"], "duplicate_phase", str(exc)) except Exception as exc: # pylint: disable=broad-exception-caught connection.send_error(msg["id"], "unknown_error", str(exc)) @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/update_phase", vol.Required("entry_id"): str, vol.Required("phase_id"): str, vol.Required("new_name"): str, vol.Optional("description", default=""): str, } ) @websocket_api.async_response async def ws_update_phase( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Rename/update a phase in the catalog.""" entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return try: await manager.profile_store.async_update_custom_phase( msg["phase_id"], msg["new_name"], msg.get("description", "") ) _send_result(connection, msg["id"], "update_phase", {"success": True}) except ValueError as exc: connection.send_error(msg["id"], "phase_not_found", str(exc)) except Exception as exc: # pylint: disable=broad-exception-caught connection.send_error(msg["id"], "unknown_error", str(exc)) @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/delete_phase", vol.Required("entry_id"): str, vol.Required("phase_id"): str, } ) @websocket_api.async_response async def ws_delete_phase( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Delete a custom phase from the catalog.""" entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return try: await manager.profile_store.async_delete_custom_phase(msg["phase_id"]) _send_result(connection, msg["id"], "delete_phase", {"success": True}) except Exception as exc: # pylint: disable=broad-exception-caught connection.send_error(msg["id"], "unknown_error", str(exc)) # ─── Recording ──────────────────────────────────────────────────────────────── @websocket_api.websocket_command( {vol.Required("type"): "ha_washdata/get_recording_state", vol.Required("entry_id"): str} ) @callback def ws_get_recording_state( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Return current recording state for a device.""" entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return recorder = getattr(manager, "recorder", None) if recorder is None: _send_result(connection, msg["id"], "get_recording_state", {"state": "unavailable"}) return is_recording: bool = getattr(recorder, "is_recording", False) last_run: dict[str, Any] | None = getattr(recorder, "last_run", None) info: dict[str, Any] = {"state": "recording" if is_recording else "idle"} if is_recording: info["duration_s"] = int(getattr(recorder, "current_duration", 0)) buf = getattr(recorder, "_buffer", []) info["sample_count"] = len(buf) elif last_run: info["state"] = "stopped" info["sample_count"] = len(last_run.get("data", [])) info["start_time"] = last_run.get("start_time") info["end_time"] = last_run.get("end_time") try: start_str = last_run.get("start_time") end_str = last_run.get("end_time") if start_str and end_str: start = dt_util.parse_datetime(start_str) end = dt_util.parse_datetime(end_str) if start and end: info["duration_s"] = int((end - start).total_seconds()) except Exception: # pylint: disable=broad-exception-caught pass _send_result(connection, msg["id"], "get_recording_state", info) @websocket_api.websocket_command( {vol.Required("type"): "ha_washdata/start_recording", vol.Required("entry_id"): str} ) @websocket_api.async_response async def ws_start_recording( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Start manual recording mode.""" entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return try: await manager.async_start_recording() _send_result(connection, msg["id"], "start_recording", {"success": True}) except Exception as exc: # pylint: disable=broad-exception-caught connection.send_error(msg["id"], "unknown_error", str(exc)) @websocket_api.websocket_command( {vol.Required("type"): "ha_washdata/stop_recording", vol.Required("entry_id"): str} ) @websocket_api.async_response async def ws_stop_recording( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Stop recording mode.""" entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return try: await manager.async_stop_recording() _send_result(connection, msg["id"], "stop_recording", {"success": True}) except Exception as exc: # pylint: disable=broad-exception-caught connection.send_error(msg["id"], "unknown_error", str(exc)) @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/process_recording", vol.Required("entry_id"): str, vol.Required("profile_name"): str, vol.Required("save_mode"): vol.In(["new_profile", "existing_profile"]), vol.Optional("head_trim", default=0.0): vol.Coerce(float), vol.Optional("tail_trim", default=0.0): vol.Coerce(float), } ) @websocket_api.async_response async def ws_process_recording( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Trim and save a completed recording to a profile.""" entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return recorder = getattr(manager, "recorder", None) if not recorder: connection.send_error(msg["id"], "no_recording", "No completed recording to process") return head_trim: float = msg.get("head_trim", 0.0) tail_trim: float = msg.get("tail_trim", 0.0) profile_name: str = msg["profile_name"].strip() save_mode: str = msg["save_mode"] if not profile_name: connection.send_error(msg["id"], "invalid_format", "Profile name must not be empty") return # Serialize the whole claim+persist under the per-entry write lock so two # concurrent process_recording calls cannot both consume the same recording # (which would double-persist and collide on cycle IDs). The claim is the # read of ``last_run`` *inside* the lock; it is cleared only after a # successful persist, so a failure leaves the recording intact for retry. async with _entry_write_lock(hass, entry_id): last_run = getattr(recorder, "last_run", None) if not last_run: connection.send_error(msg["id"], "no_recording", "No completed recording to process") return data = last_run.get("data", []) try: rec_start_str = last_run.get("start_time") rec_end_str = last_run.get("end_time") parsed: list[tuple[float, float]] = [] for item in data: t_str, p = (item[0], item[1]) if isinstance(item, (list, tuple)) else (None, None) if t_str: t = dt_util.parse_datetime(str(t_str)) if t: parsed.append((t.timestamp(), float(p or 0))) data_start_ts = parsed[0][0] if parsed else 0.0 data_end_ts = parsed[-1][0] if parsed else 0.0 if rec_start_str: parsed_dt = dt_util.parse_datetime(rec_start_str) if parsed_dt is None: connection.send_error(msg["id"], "invalid_format", "Invalid recording start_time format") return start_ts = parsed_dt.timestamp() else: start_ts = data_start_ts if rec_end_str: parsed_dt = dt_util.parse_datetime(rec_end_str) if parsed_dt is None: connection.send_error(msg["id"], "invalid_format", "Invalid recording end_time format") return end_ts = parsed_dt.timestamp() else: end_ts = data_end_ts if parsed: start_ts = min(start_ts, data_start_ts) end_ts = max(end_ts, data_end_ts) keep_start = start_ts + head_trim keep_end = end_ts - tail_trim duration = keep_end - keep_start trimmed_data = [ (dt_util.utc_from_timestamp(t).isoformat(), p) for t, p in parsed if keep_start <= t <= keep_end ] # Reject a trim that removes everything before it can persist a # corrupt cycle: an inverted/empty window (end <= start) or a window # that keeps no samples. if keep_end <= keep_start: connection.send_error( msg["id"], "invalid_format", "Trim removes the entire recording (end is at or before start)", ) return if not trimmed_data: connection.send_error( msg["id"], "invalid_format", "Trim leaves no power data", ) return cycle_data: dict[str, Any] = { # High-entropy ID so two recordings processed in the same second # (or a retry) never collide on an existing cycle ID. "id": f"rec_{int(time.time())}_{uuid.uuid4().hex[:8]}", "start_time": dt_util.utc_from_timestamp(keep_start).isoformat(), "end_time": dt_util.utc_from_timestamp(keep_end).isoformat(), "duration": duration, "profile_name": profile_name, "power_data": trimmed_data, "status": "completed", "meta": {"source": "recorder", "original_samples": len(data)}, # A recorded cycle is a hand-picked clean example -> it IS the golden # reference. Prefill the single ml_review flag (recorded == golden; # no separate "recorded" field) so it seeds matching immediately. "ml_review": { "golden": True, "quality": "good", "reviewed_at": dt_util.now().isoformat(), }, } if save_mode == "new_profile": await manager.profile_store.create_profile_standalone(profile_name) await manager.profile_store.async_add_cycle(cycle_data) await manager.profile_store.async_rebuild_envelope(profile_name) await manager.profile_store.async_save() # Clear only after a successful persist so the recording is consumed # exactly once; a failure above keeps it available for another try. await recorder.clear_last_run() # Re-validate the manager is still live after the awaited persist chain # (create/add/rebuild/save/clear): if the entry was reloaded meanwhile # the original manager is detached and its notify would target stale # state. Mirror the ws_reprocess_history guard. current_manager = _get_manager(hass, entry_id) if current_manager is not manager: _LOGGER.warning( "Manager replaced during recording persist for %s; skipping notify", entry_id, ) _send_result(connection, msg["id"], "process_recording", {"success": True}) return manager.notify_update() _send_result(connection, msg["id"], "process_recording", {"success": True}) except Exception as exc: # pylint: disable=broad-exception-caught connection.send_error(msg["id"], "unknown_error", str(exc)) @websocket_api.websocket_command( {vol.Required("type"): "ha_washdata/discard_recording", vol.Required("entry_id"): str} ) @websocket_api.async_response async def ws_discard_recording( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Discard the last completed recording.""" entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return try: recorder = getattr(manager, "recorder", None) if recorder: await recorder.clear_last_run() _send_result(connection, msg["id"], "discard_recording", {"success": True}) except Exception as exc: # pylint: disable=broad-exception-caught connection.send_error(msg["id"], "unknown_error", str(exc)) # ─── Learning feedbacks ─────────────────────────────────────────────────────── @websocket_api.websocket_command( {vol.Required("type"): "ha_washdata/get_feedbacks", vol.Required("entry_id"): str} ) @callback def ws_get_feedbacks( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Return all pending learning feedbacks.""" entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return feedbacks: list[dict[str, Any]] = [] try: pending: dict[str, Any] = manager.profile_store.get_pending_feedback() feedbacks = sorted( [{"cycle_id": cid, **item} for cid, item in pending.items()], key=lambda x: x.get("created_at", ""), reverse=True, ) except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("Error fetching feedbacks for %s: %s", entry_id, exc) _send_result(connection, msg["id"], "get_feedbacks", {"feedbacks": feedbacks}) @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/resolve_feedback", vol.Required("entry_id"): str, vol.Required("cycle_id"): str, vol.Required("action"): vol.In(["confirm", "correct", "ignore", "delete"]), vol.Optional("corrected_profile"): vol.Any(str, None), vol.Optional("corrected_duration_min"): vol.Any(vol.Coerce(float), None), } ) @websocket_api.async_response async def ws_resolve_feedback( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Resolve a pending learning feedback.""" entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return cycle_id: str = msg["cycle_id"] action: str = msg["action"] try: if action == "delete": await manager.profile_store.delete_cycle(cycle_id) elif hasattr(manager, "learning_manager"): corrected_duration_min = msg.get("corrected_duration_min") corrected_duration_s = ( int(float(corrected_duration_min) * 60) if corrected_duration_min is not None else None ) await manager.learning_manager.async_submit_cycle_feedback( cycle_id=cycle_id, user_confirmed=(action == "confirm"), corrected_profile=msg.get("corrected_profile") if action == "correct" else None, corrected_duration=corrected_duration_s, dismiss=(action == "ignore"), ) else: connection.send_error(msg["id"], "not_available", "Learning manager not available") return manager.notify_update() _send_result(connection, msg["id"], "resolve_feedback", {"success": True}) except Exception as exc: # pylint: disable=broad-exception-caught connection.send_error(msg["id"], "unknown_error", str(exc)) @websocket_api.websocket_command( {vol.Required("type"): "ha_washdata/dismiss_all_feedbacks", vol.Required("entry_id"): str} ) @websocket_api.async_response async def ws_dismiss_all_feedbacks( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Dismiss all pending learning feedbacks.""" entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return try: pending: dict[str, Any] = manager.profile_store.get_pending_feedback() if pending and hasattr(manager, "learning_manager"): cycle_ids = list(pending.keys()) for cid in cycle_ids: await manager.learning_manager.async_submit_cycle_feedback( cycle_id=cid, user_confirmed=False, corrected_profile=None, corrected_duration=None, dismiss=True, ) manager.notify_update() _send_result(connection, msg["id"], "dismiss_all_feedbacks", {"success": True, "dismissed": len(pending)}) except Exception as exc: # pylint: disable=broad-exception-caught connection.send_error(msg["id"], "unknown_error", str(exc)) # ─── Diagnostics ────────────────────────────────────────────────────────────── @websocket_api.websocket_command( {vol.Required("type"): "ha_washdata/get_diagnostics", vol.Required("entry_id"): str} ) @websocket_api.async_response async def ws_get_diagnostics( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Return storage statistics for a device.""" entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return try: stats = await manager.profile_store.get_storage_stats() _send_result(connection, msg["id"], "get_diagnostics", {"stats": stats}) except Exception as exc: # pylint: disable=broad-exception-caught connection.send_error(msg["id"], "unknown_error", str(exc)) async def _reprocess_task(hass: HomeAssistant, task: Any, entry_id: str) -> None: """Detached runner for the full "Process history" pass, reporting phase-level progress to the task registry and storing the summary as the result. Survives a dropped socket; the panel re-attaches via the registry.""" reg = task_registry.get_registry(hass) manager = _get_manager(hass, entry_id) if manager is None: reg.finish(task, state=task_registry.STATE_ERROR, error="device unavailable") return store = manager.profile_store summary: dict[str, Any] = {"success": True} # Serialize under the per-entry write lock: this multi-await pass rematches, # retrains and rewrites the store, and must not interleave with a concurrent # import / recording persist for the same entry. lock = _entry_write_lock(hass, entry_id) acquired = False try: await lock.acquire() acquired = True if task.cancel_requested: reg.finish(task, state=task_registry.STATE_CANCELLED) return reg.update(task, total=5, done=0, label="Reprocessing: matching cycles", label_key="task.reprocess.matching") summary["count"] = await store.async_reprocess_all_data() if task.cancel_requested: reg.finish(task, state=task_registry.STATE_CANCELLED, result=summary) return reg.update(task, done=1, label="Reprocessing: backfilling golden", label_key="task.reprocess.golden") try: summary["golden_backfilled"] = await store.async_backfill_recorded_golden() except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("golden backfill failed for %s: %s", entry_id, exc) if task.cancel_requested: reg.finish(task, state=task_registry.STATE_CANCELLED, result=summary) return reg.update(task, done=2, label="Reprocessing: suggestions", label_key="task.reprocess.suggestions") learning = getattr(manager, "learning_manager", None) if learning is not None and hasattr(learning, "async_run_full_analysis"): try: res = await learning.async_run_full_analysis() summary["suggestions"] = (res or {}).get("count", 0) except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("suggestion analysis failed for %s: %s", entry_id, exc) reg.update(task, done=3, label="Reprocessing: ML training", label_key="task.reprocess.ml_training") if ENABLE_ML_TRAINING and not task.cancel_requested: try: tr = await manager.async_run_ml_training(force=True) summary["ml_training"] = { "ok": bool(tr.get("ok")), "promoted": tr.get("promoted", []), "reason": tr.get("reason"), } except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("ML training failed for %s: %s", entry_id, exc) if task.cancel_requested: reg.finish(task, state=task_registry.STATE_CANCELLED, result=summary) return reg.update(task, done=4, label="Reprocessing: cycle health", label_key="task.reprocess.health") # Recompute per-cycle health against the (possibly retrained) model. # Skip when training already recomputed it (a promotion refreshes health). if not (summary.get("ml_training", {}) or {}).get("promoted"): try: summary["health_recomputed"] = await manager.async_recompute_cycle_health() except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("health recompute failed for %s: %s", entry_id, exc) reg.update(task, done=5) # Re-validate the manager is still live after a long chain of awaits; if the # entry was reloaded, the original manager is detached and must not notify. if _get_manager(hass, entry_id) is manager: manager.notify_update() reg.finish(task, state=task_registry.STATE_DONE, result=summary) except asyncio.CancelledError: reg.finish(task, state=task_registry.STATE_CANCELLED) raise except Exception as exc: # pylint: disable=broad-exception-caught # Task-level failure: log at WARNING so it is visible in the default HA log # and the panel Logs view (sub-step failures above stay at debug on purpose). _LOGGER.warning("Reprocess task failed for %s: %s", entry_id, exc) reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc)) finally: if acquired: lock.release() @websocket_api.websocket_command( {vol.Required("type"): "ha_washdata/reprocess_history", vol.Required("entry_id"): str} ) @callback def ws_reprocess_history( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Kick off the full "Process history" pass as a detached, registry-tracked task; returns its id immediately. Runs, in order: reprocess (rematch + rebuild envelopes) -> backfill golden -> refresh suggestions -> on-device ML training (when enabled) -> recompute cycle health. Progress + result via the registry.""" entry_id: str = msg["entry_id"] if _get_manager(hass, entry_id) is None: _err_not_found(connection, msg["id"], entry_id) return reg = task_registry.get_registry(hass) task = reg.create(entry_id, "reprocess", "Reprocessing") _raw = hass.async_create_task(_reprocess_task(hass, task, entry_id)) if _raw is not None: reg.link_asyncio_task(task.id, _raw) _send_result(connection, msg["id"], "reprocess_history", {"task_id": task.id}) @websocket_api.websocket_command( {vol.Required("type"): "ha_washdata/clear_debug_data", vol.Required("entry_id"): str} ) @websocket_api.async_response async def ws_clear_debug_data( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Clear stored debug traces.""" entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return try: count = await manager.profile_store.async_clear_debug_data() _send_result(connection, msg["id"], "clear_debug_data", {"success": True, "count": count}) except Exception as exc: # pylint: disable=broad-exception-caught connection.send_error(msg["id"], "unknown_error", str(exc)) @websocket_api.websocket_command( {vol.Required("type"): "ha_washdata/wipe_history", vol.Required("entry_id"): str} ) @websocket_api.async_response async def ws_wipe_history( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Wipe all cycles and profiles (destructive).""" entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return try: await manager.profile_store.clear_all_data() manager.notify_update() _send_result(connection, msg["id"], "wipe_history", {"success": True}) except Exception as exc: # pylint: disable=broad-exception-caught connection.send_error(msg["id"], "unknown_error", str(exc)) @websocket_api.websocket_command( {vol.Required("type"): "ha_washdata/export_config", vol.Required("entry_id"): str} ) @websocket_api.async_response async def ws_export_config( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Export profiles and cycles as a JSON string.""" entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return entry = _get_entry(hass, entry_id) try: payload = manager.profile_store.export_data( entry_data=dict(entry.data) if entry else {}, entry_options=dict(entry.options) if entry else {}, ) # Offload serialization to executor — power traces can be megabytes json_str = await hass.async_add_executor_job( lambda: json.dumps(payload, indent=2) ) _send_result(connection, msg["id"], "export_config", {"json_data": json_str}) except Exception as exc: # pylint: disable=broad-exception-caught connection.send_error(msg["id"], "unknown_error", str(exc)) @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/import_config", vol.Required("entry_id"): str, vol.Required("json_data"): str, } ) @websocket_api.async_response async def ws_import_config( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Import profiles and cycles from a JSON string.""" entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return # Serialize the whole import under the per-entry write lock so it cannot # interleave with a concurrent reprocess / recording persist (which would # corrupt the store the import is rewriting). async with _entry_write_lock(hass, entry_id): try: payload = await hass.async_add_executor_job(json.loads, msg["json_data"]) config_updates = await manager.profile_store.async_import_data(payload) # Re-validate after the awaits — the entry may have been reloaded # (a new manager + store) during the import. Persisting through the # detached manager/entry would clobber the live one, so re-fetch both # and bail out if the manager is no longer the live one. current_manager = _get_manager(hass, entry_id) if current_manager is not manager: _LOGGER.warning( "Manager replaced during import for %s; aborting notify", entry_id ) _send_result(connection, msg["id"], "import_config", {"success": True}) return entry = _get_entry(hass, entry_id) if entry and config_updates: entry_options_updates = dict(config_updates.get("entry_options", {})) # Identity must never be persisted into options; the display name # rides the entry title. device_type/power_sensor/min_power stay # in options and are applied as tunables. for key in _OPTIONS_IDENTITY_KEYS: entry_options_updates.pop(key, None) if entry_options_updates: # Apply the imported tunables on top of the current options; # never spread entry.data into options. new_options = {**entry.options, **entry_options_updates} hass.config_entries.async_update_entry(entry, options=new_options) # NB: config_updates["entry_data"] is intentionally NOT written to # entry.data. export_data ships the raw, un-redacted entry.data of # the *source* device (its power_sensor and other identity), so # blindly applying it would hijack this device's sensor binding. # Identity changes must go through the reconfigure flow. manager.notify_update() _send_result(connection, msg["id"], "import_config", {"success": True}) except json.JSONDecodeError as exc: connection.send_error(msg["id"], "invalid_json", str(exc)) except Exception as exc: # pylint: disable=broad-exception-caught connection.send_error(msg["id"], "unknown_error", str(exc)) # ─── Shared constants ───────────────────────────────────────────────────────── @websocket_api.websocket_command({vol.Required("type"): "ha_washdata/get_constants"}) @callback def ws_get_constants( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Return shared display constants so the panel hardcodes nothing. Device-type and state labels are localised client-side via hass.localize against the integration translations; the values/colors here are the canonical fallback and the single source for state colors. """ device_types = [ {"id": key, "label": label} for key, label in DEVICE_TYPES.items() ] from .const import STORE_WEB_ORIGIN from . import store_account from .const import ( # pylint: disable=import-outside-toplevel DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO, DEFAULT_PROFILE_MATCH_MAX_DURATION_RATIO, DEFAULT_DTW_BANDWIDTH, MATCH_CORR_WEIGHT, MATCH_KEEP_MIN_SCORE, MATCH_DTW_BLEND, MATCH_DTW_ENSEMBLE_W, MATCH_DDTW_DIST_SCALE, MATCH_DTW_REFINE_TOP_N, MATCH_DURATION_WEIGHT, MATCH_ENERGY_WEIGHT, MATCH_DURATION_SCALE, MATCH_ENERGY_SCALE, ) _send_result(connection, msg["id"], "get_constants", { "device_types": device_types, "state_colors": dict(STATE_COLORS), "ml_lab_enabled": SHOW_ML_LAB, "ml_suggestions_enabled": ENABLE_ML_SUGGESTIONS, "ml_training_available": ENABLE_ML_TRAINING, "PROFILE_MIN_WARMUP_CYCLES": CONF_PROFILE_MIN_WARMUP_CYCLES, # Canonical matcher defaults for the Playground's matcher-param fields, so # the panel's _PG_MATCH_DEFAULTS table cannot silently drift from const.py. # The panel keeps that table only as an offline fallback. "pg_match_defaults": { "profile_match_min_duration_ratio": DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO, "profile_match_max_duration_ratio": DEFAULT_PROFILE_MATCH_MAX_DURATION_RATIO, "corr_weight": MATCH_CORR_WEIGHT, "keep_min_score": MATCH_KEEP_MIN_SCORE, "dtw_bandwidth": DEFAULT_DTW_BANDWIDTH, "dtw_blend": MATCH_DTW_BLEND, "dtw_ensemble_w": MATCH_DTW_ENSEMBLE_W, "dtw_ddtw_scale": MATCH_DDTW_DIST_SCALE, "dtw_refine_top_n": MATCH_DTW_REFINE_TOP_N, "duration_weight": MATCH_DURATION_WEIGHT, "energy_weight": MATCH_ENERGY_WEIGHT, "duration_scale": MATCH_DURATION_SCALE, "energy_scale": MATCH_ENERGY_SCALE, }, # Community store: the panel opens /connect.html for the GitHub # handoff and validates postMessage against new URL(origin).origin. "store_online_available": True, # Online features are integration-wide (device-agnostic), set in the gear menu. "store_online_enabled": store_account.online_enabled(hass), "store_web_origin": STORE_WEB_ORIGIN, # Community-store display/behaviour preferences (declarative; see # store_account._DEFAULT_PREFS + the panel's _STORE_PREFS list). "store_prefs": store_account.get_prefs(hass), }, ) # ─── Suggestions ────────────────────────────────────────────────────────────── @websocket_api.websocket_command( {vol.Required("type"): "ha_washdata/get_suggestions", vol.Required("entry_id"): str} ) @callback def ws_get_suggestions( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Return applicable tuning suggestions with current vs suggested values.""" entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return entry = _get_entry(hass, entry_id) merged: dict[str, Any] = {**entry.data, **entry.options} if entry else {} out: list[dict[str, Any]] = [] try: raw: dict[str, Any] = manager.profile_store.get_suggestions() or {} for key in _SUGGESTION_KEYS: item = raw.get(key) if not isinstance(item, dict) or item.get("value") is None: continue val = item["value"] suggested = ( int(float(val)) if key in _SUGGESTION_INT_KEYS else round(float(val), 4) ) current = merged.get(key) # Hide suggestions that would not change the current value. if _suggestion_equivalent(suggested, current): continue out.append( { "key": key, "suggested": suggested, "reason": item.get("reason", ""), # Localization sidecars: panel renders _t(reason_key, # reason_params, reason). Absent on old/reconciled entries. "reason_key": item.get("reason_key"), "reason_params": item.get("reason_params"), # Structured excluded-cycle summary; the panel renders it as a # localized note appended to the reason. Absent/empty on most. "exclusions": item.get("exclusions"), "current": current, "updated": item.get("updated"), } ) except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("Error reading suggestions for %s: %s", entry_id, exc) _send_result(connection, msg["id"], "get_suggestions", {"suggestions": out}) @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/apply_suggestions", vol.Required("entry_id"): str, vol.Required("keys"): [str], } ) @websocket_api.async_response async def ws_apply_suggestions( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Stage selected suggested values into options, then clear suggestions.""" entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return entry = _get_entry(hass, entry_id) if not entry: connection.send_error(msg["id"], "not_found", f"Entry {entry_id!r} not found") return try: raw: dict[str, Any] = manager.profile_store.get_suggestions() or {} updates: dict[str, Any] = {} for key in msg["keys"]: if key not in _SUGGESTION_KEYS: continue item = raw.get(key) if not isinstance(item, dict) or item.get("value") is None: continue val = item["value"] updates[key] = ( int(float(val)) if key in _SUGGESTION_INT_KEYS else float(val) ) if updates: # Clear before updating the entry: async_update_entry schedules a # reload that rebuilds the store, so persist the cleared state first. cycle_count = len(manager.profile_store.get_past_cycles()) manager.profile_store.set_suggestion_apply_cycle_count(cycle_count) await manager.profile_store.clear_suggestions() # Suggested values are all tunables -> layer them onto the existing # options; never spread entry.data into options. new_options = {**entry.options, **updates} hass.config_entries.async_update_entry(entry, options=new_options) manager.notify_update() _send_result(connection, msg["id"], "apply_suggestions", {"success": True, "applied": list(updates.keys())} ) except Exception as exc: # pylint: disable=broad-exception-caught connection.send_error(msg["id"], "unknown_error", str(exc)) @websocket_api.websocket_command( {vol.Required("type"): "ha_washdata/clear_suggestions", vol.Required("entry_id"): str} ) @websocket_api.async_response async def ws_clear_suggestions( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Discard all pending tuning suggestions for a device.""" entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return try: await manager.profile_store.clear_suggestions() manager.notify_update() _send_result(connection, msg["id"], "clear_suggestions", {"success": True}) except Exception as exc: # pylint: disable=broad-exception-caught connection.send_error(msg["id"], "unknown_error", str(exc)) @websocket_api.websocket_command( {vol.Required("type"): "ha_washdata/run_suggestion_analysis", vol.Required("entry_id"): str} ) @websocket_api.async_response async def ws_run_suggestion_analysis( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Run all suggestion passes on demand (manual 'analyze now' trigger).""" entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return learning = getattr(manager, "learning_manager", None) if learning is None or not hasattr(learning, "async_run_full_analysis"): connection.send_error(msg["id"], "unavailable", "Suggestion analysis unavailable") return try: result = await learning.async_run_full_analysis() manager.notify_update() _send_result(connection, msg["id"], "run_suggestion_analysis", {"success": True, **(result or {})}) except Exception as exc: # pylint: disable=broad-exception-caught connection.send_error(msg["id"], "unknown_error", str(exc)) # ─── Cycle curve / interactive editing ───────────────────────────────────────── @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/get_cycle_power_data", vol.Required("entry_id"): str, vol.Required("cycle_id"): str, } ) @websocket_api.async_response async def ws_get_cycle_power_data( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Return a single cycle's downsampled power curve plus its metadata.""" entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return cycle_id: str = msg["cycle_id"] samples: list[Any] = [] meta: dict[str, Any] = {} try: store = manager.profile_store samples = store.get_cycle_power_data(cycle_id) cycle = next( (c for c in store.get_past_cycles() if c.get("id") == cycle_id), None ) if cycle is None: # Imported store recordings live in a separate list. cycle = next( (c for c in store.get_reference_cycles() if c.get("id") == cycle_id), None, ) if cycle: meta = { "start_time": cycle.get("start_time"), "end_time": cycle.get("end_time"), "duration": cycle.get("duration"), "profile_name": cycle.get("profile_name"), "status": cycle.get("status"), "energy_kwh": _cycle_kwh(cycle), # Imported store recordings are read-only in the inspector (no # trim/relabel/review -- they never enter usage stats). "is_reference": str(cycle.get("meta", {}).get("source", "")).startswith("store"), } # Transient artifacts (door-open pauses, out-of-band dips/spikes) for # graph markers. Prefer the value frozen at cycle end; compute on the # fly for older cycles that predate artifact storage. artifacts = cycle.get("artifacts") if artifacts is None and cycle.get("profile_name") and samples: # Offload CPU-intensive NumPy work to executor thread artifacts = await hass.async_add_executor_job( store.detect_cycle_artifacts, cycle["profile_name"], samples ) meta["artifacts"] = artifacts or [] # HA restart gaps recorded during this cycle (for panel shading). meta["restart_gaps"] = cycle.get("restart_gaps") or [] except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("Error getting cycle power data %s: %s", cycle_id, exc) _send_result(connection, msg["id"], "get_cycle_power_data", { "cycle_id": cycle_id, "samples": _downsample(samples), "full_duration_s": round(float(samples[-1][0]), 1) if samples else 0.0, **meta, }, ) @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/trim_cycle", vol.Required("entry_id"): str, vol.Required("cycle_id"): str, vol.Required("start_s"): vol.Coerce(float), vol.Required("end_s"): vol.Coerce(float), } ) @callback def ws_trim_cycle( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Trim a cycle's power data to the [start_s, end_s] offset window. Runs as a detached, registry-tracked task (returns a task_id immediately): the trim recomputes the signature and rebuilds the profile envelope, which stalls low-power hosts if held on the event loop for the whole WS request (issue #311). Progress/result come via subscribe_tasks/get_task_result.""" entry_id: str = msg["entry_id"] if _get_manager(hass, entry_id) is None: _err_not_found(connection, msg["id"], entry_id) return reg = task_registry.get_registry(hass) task = reg.create( entry_id, "trim", "Trimming cycle", label_key="task.trim.apply", label_params={}, ) _raw = hass.async_create_task(_trim_task( hass, task, entry_id, msg["cycle_id"], float(msg["start_s"]), float(msg["end_s"]), )) if _raw is not None: reg.link_asyncio_task(task.id, _raw) _send_result(connection, msg["id"], "trim_cycle", {"task_id": task.id}) async def _trim_task( hass: HomeAssistant, task: Any, entry_id: str, cycle_id: str, start_s: float, end_s: float, ) -> None: """Detached runner for a cycle trim (recompute + single-profile envelope rebuild). Serialized under the per-entry write lock like reprocess.""" reg = task_registry.get_registry(hass) manager = _get_manager(hass, entry_id) if manager is None: reg.finish(task, state=task_registry.STATE_ERROR, error="device unavailable") return store = manager.profile_store lock = _entry_write_lock(hass, entry_id) acquired = False try: await lock.acquire() acquired = True if task.cancel_requested: reg.finish(task, state=task_registry.STATE_CANCELLED) return reg.update(task, total=1, done=0) ok = await store.trim_cycle_power_data(cycle_id, start_s, end_s) reg.update(task, done=1) if not ok: reg.finish(task, state=task_registry.STATE_ERROR, error="trim_failed") return if _get_manager(hass, entry_id) is manager: manager.notify_update() reg.finish(task, state=task_registry.STATE_DONE, result={"success": True}) except asyncio.CancelledError: reg.finish(task, state=task_registry.STATE_CANCELLED) raise except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.warning("Trim task failed for %s: %s", entry_id, exc) reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc)) finally: if acquired: lock.release() @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/analyze_split", vol.Required("entry_id"): str, vol.Required("cycle_id"): str, vol.Optional("gap_seconds", default=900): vol.All( int, vol.Range(min=30, max=21600) ), } ) @websocket_api.async_response async def ws_analyze_split( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Auto-detect split boundaries for a cycle; return the curve and offsets.""" entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return store = manager.profile_store cycle_id: str = msg["cycle_id"] gap: int = msg.get("gap_seconds", 900) try: cycle = next( (c for c in store.get_past_cycles() if c.get("id") == cycle_id), None ) if not cycle: connection.send_error(msg["id"], "not_found", f"Cycle {cycle_id!r} not found") return segs = await hass.async_add_executor_job( store.analyze_split_sync, cycle, gap, 2.0 ) samples = store.get_cycle_power_data(cycle_id) split_offsets = ( [round(float(s[1]), 1) for s in segs[:-1]] if segs and len(segs) > 1 else [] ) _send_result(connection, msg["id"], "analyze_split", { "segments": [ [round(float(a), 1), round(float(b), 1)] for a, b in (segs or []) ], "split_offsets": split_offsets, "samples": _downsample(samples), "full_duration_s": round(float(samples[-1][0]), 1) if samples else 0.0, }, ) except Exception as exc: # pylint: disable=broad-exception-caught connection.send_error(msg["id"], "unknown_error", str(exc)) @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/apply_split", vol.Required("entry_id"): str, vol.Required("cycle_id"): str, vol.Required("split_offsets"): [vol.Coerce(float)], vol.Optional("segment_profiles"): list, } ) @callback def ws_apply_split( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Split a cycle at the given offsets, optionally labeling each segment. Cheap validation (cycle exists, at least two segments) runs synchronously so a bad request fails fast; the heavy apply (per-segment extraction + affected envelope rebuilds + save) then runs as a detached, registry-tracked task so it never holds the event loop for the whole request on low-power hosts (issue #311). Progress/result come via subscribe_tasks/get_task_result.""" entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return store = manager.profile_store cycle_id: str = msg["cycle_id"] cycle = next( (c for c in store.get_past_cycles() if c.get("id") == cycle_id), None ) if not cycle: connection.send_error(msg["id"], "not_found", f"Cycle {cycle_id!r} not found") return offsets = [float(o) for o in msg["split_offsets"]] seg_bounds = store.build_split_segments_from_offsets(cycle, offsets) if len(seg_bounds) < 2: connection.send_error( msg["id"], "split_failed", "Split points did not produce at least two segments", ) return profiles = msg.get("segment_profiles") or [] segments: list[dict[str, Any]] = [] for i, (seg_start, seg_end) in enumerate(seg_bounds): prof = profiles[i] if i < len(profiles) else None if prof in ("", "none", "__none__"): prof = None segments.append( {"start": float(seg_start), "end": float(seg_end), "profile": prof} ) reg = task_registry.get_registry(hass) task = reg.create( entry_id, "split", "Splitting cycle", label_key="task.split.apply", label_params={}, ) _raw = hass.async_create_task(_apply_split_task(hass, task, entry_id, cycle_id, segments)) if _raw is not None: reg.link_asyncio_task(task.id, _raw) _send_result(connection, msg["id"], "apply_split", {"task_id": task.id}) async def _apply_split_task( hass: HomeAssistant, task: Any, entry_id: str, cycle_id: str, segments: list[dict[str, Any]], ) -> None: """Detached runner for a cycle split. Rebuilds only the affected profiles' envelopes (done inside apply_split_interactive). Serialized under the per-entry write lock like reprocess.""" reg = task_registry.get_registry(hass) manager = _get_manager(hass, entry_id) if manager is None: reg.finish(task, state=task_registry.STATE_ERROR, error="device unavailable") return store = manager.profile_store lock = _entry_write_lock(hass, entry_id) acquired = False try: await lock.acquire() acquired = True if task.cancel_requested: reg.finish(task, state=task_registry.STATE_CANCELLED) return reg.update(task, total=1, done=0) new_ids = await store.apply_split_interactive(cycle_id, segments) reg.update(task, done=1) if _get_manager(hass, entry_id) is manager: manager.notify_update() reg.finish( task, state=task_registry.STATE_DONE, result={"success": True, "new_ids": new_ids}, ) except asyncio.CancelledError: reg.finish(task, state=task_registry.STATE_CANCELLED) raise except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.warning("Apply-split task failed for %s: %s", entry_id, exc) reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc)) finally: if acquired: lock.release() @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/apply_merge", vol.Required("entry_id"): str, vol.Required("cycle_ids"): [str], vol.Optional("target_profile"): vol.Any(str, None), vol.Optional("new_profile_name"): vol.Any(str, None), } ) @callback def ws_apply_merge( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Merge two or more cycles into one, optionally labeling the result. Cheap validation runs synchronously; the merge (gap-fill + signature recompute + affected-profile envelope rebuilds + save) then runs as a detached, registry-tracked task so it never holds the event loop for the whole request on low-power hosts (issue #311). Progress/result via the task registry.""" entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return ids: list[str] = msg["cycle_ids"] if len(ids) < 2: connection.send_error( msg["id"], "merge_failed", "Select at least two cycles to merge" ) return target = msg.get("target_profile") new_name: str | None = None if target == "__create_new__": new_name = (msg.get("new_profile_name") or "").strip() if not new_name: connection.send_error(msg["id"], "invalid_format", "New profile name required") return elif target in ("", "none", "__none__"): target = None reg = task_registry.get_registry(hass) task = reg.create( entry_id, "merge", "Merging cycles", label_key="task.merge.apply", label_params={}, ) _raw = hass.async_create_task(_apply_merge_task(hass, task, entry_id, ids, target, new_name)) if _raw is not None: reg.link_asyncio_task(task.id, _raw) _send_result(connection, msg["id"], "apply_merge", {"task_id": task.id}) async def _apply_merge_task( hass: HomeAssistant, task: Any, entry_id: str, ids: list[str], target: str | None, new_name: str | None, ) -> None: """Detached runner for a cycle merge. Rebuilds only the affected profiles' envelopes (done inside apply_merge_interactive). Serialized under the per-entry write lock like reprocess.""" reg = task_registry.get_registry(hass) manager = _get_manager(hass, entry_id) if manager is None: reg.finish(task, state=task_registry.STATE_ERROR, error="device unavailable") return store = manager.profile_store lock = _entry_write_lock(hass, entry_id) acquired = False try: await lock.acquire() acquired = True if task.cancel_requested: reg.finish(task, state=task_registry.STATE_CANCELLED) return reg.update(task, total=1, done=0) created_new = False if new_name: # Raises ValueError if the name already exists, so reaching the next # line guarantees we created a brand-new (empty) profile that must be # rolled back if the merge below fails. await store.create_profile_standalone(new_name) created_new = True target = new_name new_id = await store.apply_merge_interactive(ids, target) reg.update(task, done=1) if not new_id: if created_new and new_name: # Merge rejected the cycle set (stale/invalid id, unparseable start # time, ...). Delete the empty profile we just created so a failed # merge doesn't orphan a cycle-less profile in the store. await store.delete_profile(new_name, unlabel_cycles=False) reg.finish(task, state=task_registry.STATE_ERROR, error="merge_failed") return if _get_manager(hass, entry_id) is manager: manager.notify_update() reg.finish( task, state=task_registry.STATE_DONE, result={"success": True, "new_id": new_id}, ) except asyncio.CancelledError: reg.finish(task, state=task_registry.STATE_CANCELLED) raise except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.warning("Apply-merge task failed for %s: %s", entry_id, exc) reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc)) finally: if acquired: lock.release() # ─── Profile envelope / member cycles ────────────────────────────────────────── @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/get_profile_envelope", vol.Required("entry_id"): str, vol.Required("profile_name"): str, } ) @callback def ws_get_profile_envelope( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Return a profile's averaged power envelope (downsampled) and stats.""" entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return env_out: dict[str, Any] | None = None try: env = manager.profile_store.get_envelope(msg["profile_name"]) if env: env_out = { "avg": _downsample(env.get("avg") or []), "min": _downsample(env.get("min") or []), "max": _downsample(env.get("max") or []), "target_duration": env.get("target_duration"), "avg_energy": env.get("avg_energy"), "duration_std_dev": env.get("duration_std_dev"), "cycle_count": env.get("cycle_count"), } except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("Error getting envelope for %s: %s", msg.get("profile_name"), exc) _send_result(connection, msg["id"], "get_profile_envelope", {"envelope": env_out}) @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/get_profile_cycles", vol.Required("entry_id"): str, vol.Required("profile_name"): str, vol.Optional("limit", default=150): vol.All(int, vol.Range(min=1, max=400)), } ) @websocket_api.async_response async def ws_get_profile_cycles( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Return cycles labeled with a profile, each with a downsampled curve. Powers the history-cleanup spaghetti view: the panel overlays every curve and lets the user delete outliers. Colors are assigned client-side. """ entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return profile_name: str = msg["profile_name"] limit: int = msg.get("limit", 150) def _collect() -> list[dict[str, Any]]: out: list[dict[str, Any]] = [] try: store = manager.profile_store matched = [ c for c in store.get_past_cycles() if c.get("profile_name") == profile_name ] for c in matched[-limit:]: cid = c.get("id") samples = store.get_cycle_power_data(cid) if cid else [] out.append( { "cycle_id": cid, "start_time": c.get("start_time"), "duration": c.get("duration"), "status": c.get("status"), "energy_kwh": _cycle_kwh(c), "samples": _downsample(samples, 160), } ) except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("Error getting profile cycles for %s: %s", profile_name, exc) return out result = await hass.async_add_executor_job(_collect) _send_result(connection, msg["id"], "get_profile_cycles", {"cycles": result}) # ─── Panel config + RBAC commands ────────────────────────────────────────────── @websocket_api.websocket_command({vol.Required("type"): "ha_washdata/get_panel_config"}) @websocket_api.async_response async def ws_get_panel_config( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Return panel settings + the caller's prefs; admins also get RBAC + user list.""" user = getattr(connection, "user", None) cfg = _panel_data(hass) is_admin = bool(getattr(user, "is_admin", False)) uid = getattr(user, "id", "") or "" out: dict[str, Any] = { "panel": dict(cfg.get("panel", {})), "is_admin": is_admin, "user": {"id": uid, "name": getattr(user, "name", None)}, "prefs": dict((cfg.get("prefs") or {}).get(uid, {})), } if is_admin: rbac = cfg.get("rbac", {}) out["rbac"] = { "enabled": bool(rbac.get("enabled", False)), "default_level": rbac.get("default_level", "none"), "users": { k: {"default": v.get("default", "none"), "devices": dict(v.get("devices") or {})} for k, v in (rbac.get("users") or {}).items() }, } users: list[dict[str, Any]] = [] try: for u in await hass.auth.async_get_users(): if u.system_generated or not u.is_active: continue users.append({"id": u.id, "name": u.name or "Unnamed user", "is_admin": bool(u.is_admin)}) except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("Could not list users: %s", exc) out["users"] = users _send_result(connection, msg["id"], "get_panel_config", out) @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/set_panel_config", vol.Optional("panel"): dict, vol.Optional("rbac"): dict, } ) @websocket_api.async_response async def ws_set_panel_config( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Persist panel settings and/or RBAC config (admin only; enforced by _guard).""" holder = hass.data.get(_PANEL_DATA_KEY) if not holder: await async_load_panel_config(hass) holder = hass.data.get(_PANEL_DATA_KEY) cfg = holder["data"] try: if isinstance(msg.get("panel"), dict): cfg["panel"] = _sanitize_panel(msg["panel"], cfg.get("panel", {})) if isinstance(msg.get("rbac"), dict): cfg["rbac"] = _sanitize_rbac(msg["rbac"]) await _save_panel_data(hass) _send_result(connection, msg["id"], "set_panel_config", {"success": True}) except Exception as exc: # pylint: disable=broad-exception-caught connection.send_error(msg["id"], "unknown_error", str(exc)) @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/set_user_prefs", vol.Required("prefs"): dict, } ) @websocket_api.async_response async def ws_set_user_prefs( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Persist the calling user's own view preferences (any authenticated user).""" user = getattr(connection, "user", None) if user is None: connection.send_error(msg["id"], "unauthorized", "No authenticated user") return holder = hass.data.get(_PANEL_DATA_KEY) if not holder: await async_load_panel_config(hass) holder = hass.data.get(_PANEL_DATA_KEY) cfg = holder["data"] prefs = cfg.setdefault("prefs", {}) cur = dict(prefs.get(user.id, {})) p = msg["prefs"] if p.get("default_tab") in _PANEL_TABS: cur["default_tab"] = p["default_tab"] for k in ("show_expected", "show_raw", "show_raw_active", "show_debug", "onboarding_dismissed"): if k in p: cur[k] = bool(p[k]) # F2: per-user Basic/Advanced settings disclosure level. if p.get("settings_level") in ("basic", "advanced"): cur["settings_level"] = p["settings_level"] # Display prefs: cycle date format + panel language override (paired with the # panel's save-prefs payload; without these they would be silently dropped). if p.get("date_format") in _PREF_DATE_FORMATS: cur["date_format"] = p["date_format"] if "lang_override" in p: lang = p["lang_override"] if lang == "": cur.pop("lang_override", None) # empty clears -> system default elif isinstance(lang, str) and _PREF_LANG_TAG_RE.match(lang): cur["lang_override"] = lang # Allow setup guidance skip keys: setup_skip_ -> "never" | ISO timestamp for k, v in p.items(): if isinstance(k, str) and k.startswith("setup_skip_"): if v is None: cur.pop(k, None) elif v == "never" or (isinstance(v, str) and len(v) <= 40): cur[k] = v prefs[user.id] = cur await _save_panel_data(hass) _send_result(connection, msg["id"], "set_user_prefs", {"success": True}) @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/get_match_debug", vol.Required("entry_id"): str, } ) @callback def ws_get_match_debug( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Return the latest live match result for the Status debug panel. Confidence, ambiguity flag, and the ranked candidate list (from the last in-cycle match attempt). Empty until the first match runs. """ entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return out: dict[str, Any] = {"confidence": None, "ambiguous": False, "candidates": []} try: mr = getattr(manager, "_last_match_result", None) conf = getattr(manager, "_last_match_confidence", None) out["confidence"] = round(float(conf), 4) if conf is not None else None out["ambiguous"] = bool(getattr(manager, "_last_match_ambiguous", False)) if mr is not None: out["candidates"] = manager.profile_store.get_match_candidates_summary(mr, 5) except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("Error building match debug for %s: %s", entry_id, exc) _send_result(connection, msg["id"], "get_match_debug", out) @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/set_program", vol.Required("entry_id"): str, vol.Required("program"): vol.Any(str, None), } ) @callback def ws_set_program( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Manually set the active program, or clear back to auto-detect. Drives the same manager methods as the program-select entity. """ entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return try: prog = msg.get("program") if not prog or prog in ("auto_detect", "__auto__", "none"): manager.clear_manual_program() else: manager.set_manual_program(prog) manager.notify_update() _send_result(connection, msg["id"], "set_program", {"success": True}) except Exception as exc: # pylint: disable=broad-exception-caught connection.send_error(msg["id"], "unknown_error", str(exc)) @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/get_power_history", vol.Required("entry_id"): str, vol.Optional("with_raw", default=False): bool, } ) @websocket_api.async_response async def ws_get_power_history( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Return the live power trace for the status chart, held server-side. While a cycle runs ``live`` is the in-progress cycle trace (offsets from cycle start, so it lines up with the matched profile envelope); otherwise it is the recent readings. When ``with_raw`` is set and a cycle is running, ``raw`` is the configured power-sensor entity's recorder history over the cycle window, so the real socket data can be compared side by side with the integration's processed/sampled trace. Server-held so it survives a browser refresh, and the cycle trace survives an HA restart via state restore. """ entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return with_raw = bool(msg.get("with_raw")) out: dict[str, Any] = {"cycle_active": False, "cycle_elapsed_s": 0.0, "live": [], "raw": [], "restart_gaps": []} try: detector = getattr(manager, "detector", None) diag = getattr(manager, "diag_buffer", None) trace = detector.get_power_trace() if detector else [] cycle_start = getattr(detector, "current_cycle_start", None) if detector else None if cycle_start and trace: start_dt = trace[0][0] live = [[round((t - start_dt).total_seconds(), 1), round(float(p), 1)] for t, p in trace] out["cycle_active"] = True out["live"] = _downsample(live) out["cycle_elapsed_s"] = live[-1][0] if live else 0.0 out["cycle_start_iso"] = start_dt.isoformat() out["restart_gaps"] = list(getattr(manager, "_restart_gaps", [])) if with_raw: ent = getattr(manager, "power_sensor_entity_id", None) if ent: samples = await _recorder_power(hass, ent, start_dt) start_ts = start_dt.timestamp() out["raw"] = _downsample( [[max(0.0, round(ts - start_ts, 1)), w] for ts, w in samples], 400 ) elif diag is not None: recent = diag.power_samples(time.time() - 900.0) if recent: base = recent[0][0] out["live"] = _downsample([[round(ts - base, 1), round(float(w), 1)] for ts, w in recent]) except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("Error building power history for %s: %s", entry_id, exc) _send_result(connection, msg["id"], "get_power_history", out) @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/get_logs", vol.Optional("level"): vol.Any(str, None), vol.Optional("limit", default=200): vol.All(int, vol.Range(min=1, max=500)), } ) @callback def ws_get_logs( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Return recent ha_washdata log records (admin only; enforced by _guard).""" handler = hass.data.get(_LOG_BUFFER_KEY) recs = list(handler.records) if handler else [] level = msg.get("level") if level and level in _LOG_LEVELS: minl = _LOG_LEVELS[level] recs = [r for r in recs if _LOG_LEVELS.get(r["level"], 0) >= minl] limit = msg.get("limit", 200) _send_result(connection, msg["id"], "get_logs", {"logs": recs[-limit:]}) # ─── ML Lab (shadow-mode comparison) ────────────────────────────────────────── def _ml_median(values: list[float]) -> float | None: if not values: return None s = sorted(values) n = len(s) return s[n // 2] if n % 2 else (s[n // 2 - 1] + s[n // 2]) / 2 def _compute_cycle_events( points: list[tuple[float, float]], expectation: dict[str, float], end_feat_fn: Any, end_score_fn: Any, max_events: int = 20, ) -> list[dict[str, Any]]: """Find all low-power events in a trace and score each with the ML end detector. Each event represents a contiguous below-threshold power segment. Events that were followed by power resumption are classified as pauses (``is_end=False``); the last event is marked as the end trigger (``is_end=True``). Motor-cycling appliances (washing machines) produce tens of micro-dips per cycle from drum motor switching. A 30 s minimum filters these while still capturing genuine pause events which are typically > 1 min. """ _MIN_EVENT_S = 30.0 if not points or len(points) < 4: return [] powers = [p for _, p in points] peak = max(powers) if powers else 0.0 low_thresh = max(1.0, 0.05 * peak) events: list[dict[str, Any]] = [] in_low = False seg_start_s = 0.0 for i, (offset_s, pwr) in enumerate(points): if not in_low and pwr < low_thresh: in_low = True seg_start_s = offset_s elif in_low and pwr >= low_thresh: seg_end_s = points[i - 1][0] if i > 0 else offset_s low_run_s = seg_end_s - seg_start_s if low_run_s >= _MIN_EVENT_S: ml_conf: float | None = None try: feat = end_feat_fn(points[:i], expectation) if feat is not None: ml_conf = round(float(end_score_fn(feat)), 3) except Exception: # pylint: disable=broad-exception-caught pass events.append({"offset_s": float(round(seg_start_s, 0)), "low_run_s": float(round(low_run_s, 0)), "ml_end_confidence": ml_conf, "is_end": False}) in_low = False if len(events) >= max_events: break if in_low: low_run_s = points[-1][0] - seg_start_s if low_run_s >= _MIN_EVENT_S: ml_conf = None try: feat = end_feat_fn(points, expectation) if feat is not None: ml_conf = round(float(end_score_fn(feat)), 3) except Exception: # pylint: disable=broad-exception-caught pass events.append({"offset_s": float(round(seg_start_s, 0)), "low_run_s": float(round(low_run_s, 0)), "ml_end_confidence": ml_conf, "is_end": True}) if events and not events[-1]["is_end"]: events[-1]["is_end"] = True return events[:max_events] def _health_model_sig(store: Any) -> str: """Signature of the active health/end models, so persisted per-cycle health can be invalidated (recomputed) whenever the model changes or is retrained. """ versions = store.get_ml_model_versions() or {} parts = [] for cap in ("quality", "end"): v = versions.get(cap) ts = v.get("trained_at") if isinstance(v, dict) else None parts.append(f"{cap}:{ts or 'base'}") return "|".join(parts) def _compute_ml_comparison( store: Any, current_off_delay: int, *, force_recompute: bool = False ) -> dict[str, Any]: """Build the ML shadow-mode comparison report (CPU-intensive; runs in executor). Iterates all stored cycles, extracts features, and scores them against the embedded ML models. Original detection logic is completely untouched; this is read-only analysis for the ML Lab panel page. Per-cycle health (quality + end scores, labels and the events timeline) is **persisted** on each cycle under ``ml_health`` keyed by the active model signature. Subsequent calls reuse the cached value instead of re-scoring on every panel load; it is only recomputed when the model changes (signature mismatch), when ``force_recompute`` is set (reprocess / maintenance / processing trigger), or for cycles that have never been assessed. The caller persists the store when ``result['_health_dirty']`` is true. """ # Lazy imports so this module loads instantly even when ML deps are absent. try: from .ml.engine import resolve_scorer from .ml.feature_extraction import latest_end_event_features, quality_features from .profile_store import decompress_power_data except Exception: # pylint: disable=broad-exception-caught return {"enabled": False, "error": "ML models not available", "cycles": [], "settings_comparison": {}} # Prefer on-device trained models when present, else the embedded baseline. quality_score_fn, quality_source = resolve_scorer("quality", store) end_score_fn, end_source = resolve_scorer("end", store) if quality_score_fn is None and end_score_fn is None: return {"enabled": False, "error": "ML models not available", "cycles": [], "settings_comparison": {}} model_sig = _health_model_sig(store) health_dirty = False health_updates: dict[str, Any] = {} cycles: list[Any] = store.get_past_cycles() # --- Build per-profile statistics from cycle history --- raw_stats: dict[str, dict[str, list[float]]] = {} for cycle in cycles: name = cycle.get("profile_name") if not name: continue s = raw_stats.setdefault(name, {"durations": [], "energies": [], "peaks": []}) dur = cycle.get("duration") energy = cycle.get("energy_wh") peak = cycle.get("max_power") if dur is not None: s["durations"].append(float(dur)) if energy is not None: s["energies"].append(float(energy)) if peak is not None: s["peaks"].append(float(peak)) profile_medians: dict[str, dict[str, float]] = {} for name, s in raw_stats.items(): profile_medians[name] = { "duration_s": _ml_median(s["durations"]) or 1800.0, "energy_wh": _ml_median(s["energies"]) or 500.0, "peak_w": _ml_median(s["peaks"]) or 500.0, "count": len(s["durations"]), } # --- Evaluate cycles (newest 200 for scoring/display; all for pause analysis) --- # `cycles` is oldest-first (new cycles are appended at the end), so the newest # 200 are the tail. Score / persist health for only that window; the pause # scan below still runs over every cycle. intra_pauses: list[float] = [] evaluated: list[dict[str, Any]] = [] recent_start_idx = max(0, len(cycles) - 200) for idx, cycle in enumerate(cycles): profile_name: str | None = cycle.get("profile_name") duration: float = float(cycle.get("duration") or 0) energy_wh: float = float(cycle.get("energy_wh") or 0) status: str = cycle.get("status", "completed") # A stored confidence of 0 most likely means the field wasn't recorded # (manually-labeled or pre-confidence cycles), not that the match was bad. # Treat 0 as "unknown" so we don't poison every quality feature with # worst-case proxies. raw_conf = cycle.get("match_confidence") conf_known: bool = isinstance(raw_conf, (int, float)) and raw_conf > 0 match_conf: float = float(raw_conf) if conf_known else 0.0 # Proxy feature values for quality scoring. When confidence is known, # derive distance/margin/fit from it. When unknown, use neutral values # so the model scores on trace shape alone. if conf_known: proxy_dist = max(0.0, 1.0 - match_conf) proxy_margin = match_conf proxy_fit = match_conf else: proxy_dist, proxy_margin, proxy_fit = 0.25, 0.30, 0.75 points = decompress_power_data(cycle) # Collect intra-cycle pauses for off_delay recommendation (all cycles). if points and len(points) >= 4 and status == "completed": powers = [p for _, p in points] peak = max(powers) if powers else 0.0 low_thresh = max(1.0, 0.05 * peak) in_pause = False pause_start_s = 0.0 for offset_s, pwr in points: if not in_pause and pwr < low_thresh: in_pause = True pause_start_s = offset_s elif in_pause and pwr >= low_thresh: pause_dur = offset_s - pause_start_s if 5.0 <= pause_dur <= 1800.0: intra_pauses.append(pause_dur) in_pause = False # Only score and display the newest 200 cycles (the tail of the # oldest-first list); older cycles still contributed pauses above. if idx < recent_start_idx: continue # Reuse persisted per-cycle health when it was computed against the # current model (unless a recompute is forced). This is what keeps the # panel from re-scoring every cycle on every load. cached = cycle.get("ml_health") if ( not force_recompute and isinstance(cached, dict) and cached.get("model_sig") == model_sig ): ml_quality = cached.get("score") quality_label = cached.get("label", "no_data") ml_end_conf = cached.get("end_score") end_label = cached.get("end_label", "no_event") events = cached.get("events") or [] else: ml_quality = None if profile_name and profile_name in profile_medians: pm = profile_medians[profile_name] try: feat = quality_features( points=points, profile_median_duration_s=pm["duration_s"], profile_median_energy_wh=pm["energy_wh"], profile_median_peak_w=pm["peak_w"], profile_distance=proxy_dist, label_margin=proxy_margin, profile_fit_score=proxy_fit, flag_count=0, ) if quality_score_fn is not None: ml_quality = float(quality_score_fn(feat)) except Exception: # pylint: disable=broad-exception-caught pass expectation: dict[str, float] = {} if profile_name and profile_name in profile_medians: pm = profile_medians[profile_name] expectation = {"duration": pm["duration_s"], "energy": pm["energy_wh"], "peak": pm["peak_w"]} ml_end_conf = None if expectation and points: try: end_feat = latest_end_event_features(points, expectation) if end_feat is not None and end_score_fn is not None: ml_end_conf = float(end_score_fn(end_feat)) except Exception: # pylint: disable=broad-exception-caught pass # Per-cycle events timeline for the modal events = [] if expectation and points and end_score_fn is not None: events = _compute_cycle_events(points, expectation, latest_end_event_features, end_score_fn) if ml_quality is None: quality_label = "no_data" elif ml_quality < 0.3: quality_label = "ok" elif ml_quality < 0.6: quality_label = "uncertain" else: quality_label = "review" if ml_end_conf is None: end_label = "no_event" elif ml_end_conf >= 0.6: end_label = "likely_end" elif ml_end_conf >= 0.35: end_label = "uncertain" else: end_label = "likely_pause" # Collect freshly-computed health for the event-loop to apply # back to the live store dicts (avoids mutating from executor thread). cycle_id_key = cycle.get("id", "") if cycle_id_key: health_updates[cycle_id_key] = { "score": round(ml_quality, 3) if ml_quality is not None else None, "label": quality_label, "end_score": round(ml_end_conf, 3) if ml_end_conf is not None else None, "end_label": end_label, "events": events, "model_sig": model_sig, "at": dt_util.now().isoformat(), } health_dirty = True start_raw = cycle.get("start_time", "") evaluated.append({ "id": cycle.get("id", ""), "start_time": start_raw if isinstance(start_raw, str) else "", "duration_s": round(duration, 0), "status": status, "profile_name": profile_name, "match_confidence": round(match_conf, 3), "confidence_known": conf_known, "energy_wh": round(energy_wh, 1), "ml_quality_score": round(ml_quality, 3) if ml_quality is not None else None, "ml_quality_label": quality_label, "ml_end_confidence": round(ml_end_conf, 3) if ml_end_conf is not None else None, "ml_end_label": end_label, "has_power_data": len(points) > 0, "events": events, "ml_review": cycle.get("ml_review") or {}, }) # Panel expects most-recent-first ordering; the loop appended oldest-first. evaluated.reverse() # --- Settings comparison: off_delay --- settings_comparison: dict[str, Any] = {} ml_off_delay: int | None = None pause_p95: float | None = None if intra_pauses: intra_pauses.sort() p95_idx = min(int(len(intra_pauses) * 0.95), len(intra_pauses) - 1) pause_p95 = intra_pauses[p95_idx] ml_off_delay = max(60, int(pause_p95) + 60) original_suggestions = store.get_suggestions() or {} orig_off = original_suggestions.get(CONF_OFF_DELAY) settings_comparison["off_delay"] = { "key": CONF_OFF_DELAY, "label": "Off Delay", "unit": "s", "current_value": current_off_delay, "original_suggestion": orig_off.get("value") if isinstance(orig_off, dict) else None, "original_reason": orig_off.get("reason") if isinstance(orig_off, dict) else None, "ml_recommendation": ml_off_delay, "ml_reasoning": ( f"p95 intra-cycle pause: {int(pause_p95)}s + 60s buffer = {ml_off_delay}s " f"(from {len(intra_pauses)} observed pauses)" if ml_off_delay is not None else "Not enough pause data to recommend" ), "pause_count": len(intra_pauses), } return { "enabled": True, "cycle_count": len(cycles), "evaluated_count": sum(1 for e in evaluated if e["ml_quality_score"] is not None), "cycles": evaluated, "settings_comparison": settings_comparison, "model_source": {"quality": quality_source, "end": end_source}, "_health_dirty": health_dirty, "_health_updates": health_updates, "profile_stats": { name: {"count": int(m["count"]), "median_duration_s": int(m["duration_s"]), "median_energy_wh": round(m["energy_wh"], 1)} for name, m in profile_medians.items() }, } def _build_settings_comparison(manager: Any, merged: dict[str, Any]) -> dict[str, Any]: """Build the enriched Classic-vs-ML settings comparison (executor-safe). Runs both the classic :class:`SuggestionEngine` and the :class:`MLSuggestionEngine` against clean cycle history and merges their recommendations with the current option values. Gated by the caller behind ``ENABLE_ML_SUGGESTIONS``. """ try: from .suggestion_engine import ( # pylint: disable=import-outside-toplevel MLSuggestionEngine, select_clean_cycles, ) except Exception: # pylint: disable=broad-exception-caught return {} learning = getattr(manager, "learning_manager", None) classic = getattr(learning, "suggestion_engine", None) if classic is None: return {} stop_thr = classic._current_stop_threshold(merged) # pylint: disable=protected-access raw_cycles = classic.profile_store.get_past_cycles() # Classic recommendations pooled from every classic pass. classic_vals: dict[str, Any] = {} for producer in ( classic.generate_detection_suggestions, classic.generate_model_suggestions, ): try: classic_vals.update(producer() or {}) except Exception: # pylint: disable=broad-exception-caught pass try: classic_vals.update(classic.run_batch_simulation(raw_cycles) or {}) except Exception: # pylint: disable=broad-exception-caught pass # Classic off_delay via the (Stage 2) pause analysis. try: clean, _excl = select_clean_cycles(raw_cycles[-200:], stop_threshold_w=stop_thr) device_floor = DEFAULT_OFF_DELAY_BY_DEVICE.get( classic.device_type, DEFAULT_OFF_DELAY ) if classic.device_type else DEFAULT_OFF_DELAY od = classic._suggest_off_delay_from_pauses(clean, stop_thr, device_floor) # pylint: disable=protected-access if od is not None: classic_vals[CONF_OFF_DELAY] = { "value": od[0], "reason": od[1], "reason_key": od[2], "reason_params": od[3], } except Exception: # pylint: disable=broad-exception-caught pass # ML recommendations. ml_vals: dict[str, Any] = {} try: ml_vals = MLSuggestionEngine(classic).generate_ml_suggestions() or {} except Exception: # pylint: disable=broad-exception-caught pass def _val(entry: Any) -> Any: return entry.get("value") if isinstance(entry, dict) else None def _reason(entry: Any) -> str: return entry.get("reason", "") if isinstance(entry, dict) else "" def _reason_key(entry: Any) -> str | None: return entry.get("reason_key") if isinstance(entry, dict) else None def _reason_params(entry: Any) -> dict[str, Any] | None: return entry.get("reason_params") if isinstance(entry, dict) else None comparison: dict[str, Any] = {} for key, label, unit in _ML_COMPARE_SETTINGS: cv, mv = classic_vals.get(key), ml_vals.get(key) if cv is None and mv is None: continue current = merged.get(key) classic_value = _val(cv) ml_value = _val(mv) # Hide a recommendation that would not change the current value. if classic_value is not None and _suggestion_equivalent(classic_value, current): classic_value = None if ml_value is not None and _suggestion_equivalent(ml_value, current): ml_value = None if classic_value is None and ml_value is None: continue comparison[key] = { "key": key, # ``label`` is the English fallback; the panel renders the field's own # localized setting label (setting..label) so this is not shown # directly. Reasons carry _key/_params sidecars for _t() rendering. "label": label, "unit": unit, "current_value": current, "classic_value": classic_value, "classic_reason": _reason(cv) if classic_value is not None else "", "classic_reason_key": _reason_key(cv) if classic_value is not None else None, "classic_reason_params": _reason_params(cv) if classic_value is not None else None, "ml_value": ml_value, "ml_reason": _reason(mv) if ml_value is not None else "", "ml_reason_key": _reason_key(mv) if ml_value is not None else None, "ml_reason_params": _reason_params(mv) if ml_value is not None else None, } return comparison @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/get_ml_comparison", vol.Required("entry_id"): str, } ) @websocket_api.async_response async def ws_get_ml_comparison( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Return the ML shadow-mode comparison report for the ML Lab panel page. Runs the embedded ML models against stored cycle history and compares with the existing detection/suggestion logic. Read-only: no side effects on the proven algorithms. """ entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return try: store = manager.profile_store entry = _get_entry(hass, entry_id) merged: dict[str, Any] = {**(entry.data if entry else {}), **(entry.options if entry else {})} current_off_delay = int(merged.get(CONF_OFF_DELAY, 120)) result = await hass.async_add_executor_job( _compute_ml_comparison, store, current_off_delay ) # Apply any freshly-computed per-cycle health updates on the event loop # (the executor must not mutate live store dicts directly), then persist. health_updates = result.pop("_health_updates", {}) result.pop("_health_dirty", False) if health_updates: for cycle in store.get_past_cycles(): cid = cycle.get("id", "") if cid and cid in health_updates: cycle["ml_health"] = health_updates[cid] await store.async_save() result["ml_suggestions_enabled"] = ENABLE_ML_SUGGESTIONS # Stage 3: replace the basic off_delay-only comparison with the full # Classic-vs-ML settings table when ML suggestions are unlocked. if ENABLE_ML_SUGGESTIONS: enriched = await hass.async_add_executor_job( _build_settings_comparison, manager, merged ) if enriched: result["settings_comparison"] = enriched _send_result(connection, msg["id"], "get_ml_comparison", result) except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.warning("ML comparison failed for %s: %s", entry_id, exc) connection.send_error(msg["id"], "unknown_error", str(exc)) @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/get_ml_training_status", vol.Required("entry_id"): str, } ) @websocket_api.async_response async def ws_get_ml_training_status( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Return on-device ML training status for the Tuning > ML Training panel.""" from .const import ( # pylint: disable=import-outside-toplevel CONF_ML_TRAINING_ENABLED, CONF_ML_TRAINING_HOUR, CONF_ML_TRAINING_INTERVAL_DAYS, CONF_ML_TRAINING_MIN_CYCLES, DEFAULT_ML_TRAINING_ENABLED, DEFAULT_ML_TRAINING_HOUR, DEFAULT_ML_TRAINING_INTERVAL_DAYS, DEFAULT_ML_TRAINING_MIN_CYCLES, MATCH_CORR_WEIGHT, MATCH_DTW_ENSEMBLE_W, MATCH_DURATION_WEIGHT, MATCH_ENERGY_WEIGHT, ) entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return entry = _get_entry(hass, entry_id) merged: dict[str, Any] = {**(entry.data if entry else {}), **(entry.options if entry else {})} store = manager.profile_store versions = store.get_ml_model_versions() or {} last = manager._last_ml_training_at() # pylint: disable=protected-access # Plain-language names + a one-line "what it does" for each capability, so the # panel never has to show raw internal keys to non-ML users. _cap_labels = { "end": ("Cycle-end detection", "Knowing when a cycle has truly finished"), "quality": ("Cycle quality check", "Spotting mis-detected or corrupted cycles"), "live_match": ("Program matching", "Identifying the running program sooner"), "remaining_time": ("Time-remaining estimate", "Predicting how long is left"), "total_energy": ("Energy estimate", "Predicting total energy and cost"), } models: dict[str, Any] = {} for cap, v in versions.items(): if not isinstance(v, dict): continue spec = v.get("spec") if isinstance(v.get("spec"), dict) else {} label, blurb = _cap_labels.get(cap, (cap, "")) info: dict[str, Any] = { "trained_at": v.get("trained_at"), "cycle_count": v.get("cycle_count"), "kind": spec.get("kind"), # ``label``/``blurb`` are the English fallbacks; the panel renders # _t(label_key/blurb_key, {}, fallback). Keyed by capability so a # missing key falls back cleanly to the English text. "label": label, "label_key": f"ml.cap_label.{cap}" if cap in _cap_labels else None, "blurb": blurb, "blurb_key": f"ml.cap_blurb.{cap}" if cap in _cap_labels else None, } # Raw metric numbers so the panel can render a humanized quality indicator # (a bar + word) with the exact figure on hover: held-out AUC for # classifiers, MAE-vs-naive for regressors. if v.get("new_auc") is not None: info["auc"] = round(float(v["new_auc"]), 4) info["metric"] = f"AUC {float(v['new_auc']):.2f} on held-out data" info["metric_key"] = "ml.metric_auc" info["metric_params"] = {"auc": f"{float(v['new_auc']):.2f}"} elif v.get("model_mae") is not None and v.get("naive_mae") is not None: info["model_mae"] = round(float(v["model_mae"]), 5) info["naive_mae"] = round(float(v["naive_mae"]), 5) info["metric"] = f"error {float(v['model_mae']):.3f} vs {float(v['naive_mae']):.3f} baseline" info["metric_key"] = "ml.metric_mae" info["metric_params"] = { "model": f"{float(v['model_mae']):.3f}", "baseline": f"{float(v['naive_mae']):.3f}", } models[cap] = info # Fit trend across recent training runs (drift): compare the mean held-out # score of the most-recent third of runs to the oldest third, respecting each # metric's direction. Only meaningful with a few runs of history. history = store.get_ml_training_history() for cap, info in models.items(): series = history.get(cap) if isinstance(history, dict) else None if not isinstance(series, list) or len(series) < 4: continue scores = [float(e["score"]) for e in series if isinstance(e, dict) and "score" in e] if len(scores) < 4: continue higher_better = bool(series[-1].get("higher_better", True)) third = max(1, len(scores) // 3) old_mean = sum(scores[:third]) / third new_mean = sum(scores[-third:]) / third if abs(old_mean) < 1e-9: continue rel = (new_mean - old_mean) / abs(old_mean) if not higher_better: rel = -rel # for error metrics, a decrease is an improvement info["trend"] = "improving" if rel > 0.03 else "declining" if rel < -0.03 else "steady" # Matcher scoring-weight tuning (Stage 4/5): current shipped defaults, the # on-device tuned record (if promoted), and which set is actually in use. tuned_rec = store.get_matching_config() tuned_cfg = tuned_rec.get("config") if isinstance(tuned_rec, dict) else None matching = { "defaults": { "corr_weight": MATCH_CORR_WEIGHT, "duration_weight": MATCH_DURATION_WEIGHT, "energy_weight": MATCH_ENERGY_WEIGHT, "dtw_ensemble_w": MATCH_DTW_ENSEMBLE_W, }, "tuned": tuned_rec or None, "active": "tuned" if tuned_cfg else "default", } _send_result(connection, msg["id"], "get_ml_training_status", { "available": ENABLE_ML_TRAINING, "enabled": bool(merged.get(CONF_ML_TRAINING_ENABLED, DEFAULT_ML_TRAINING_ENABLED)), "running": bool(getattr(manager, "_ml_training_running", False)), "last_trained": last.isoformat() if last else None, "cycle_count": len(store.get_past_cycles()), "min_cycles": int(merged.get(CONF_ML_TRAINING_MIN_CYCLES, DEFAULT_ML_TRAINING_MIN_CYCLES)), "interval_days": int(merged.get(CONF_ML_TRAINING_INTERVAL_DAYS, DEFAULT_ML_TRAINING_INTERVAL_DAYS)), "hour": int(merged.get(CONF_ML_TRAINING_HOUR, DEFAULT_ML_TRAINING_HOUR)), "on_device_models": models, "matching": matching, }, ) async def _ml_training_task(hass: HomeAssistant, task: Any, entry_id: str) -> None: """Detached runner for on-device ML training; stores the summary as the result. NOT a WS handler: it is a plain coroutine kicked off via ``hass.async_create_task`` by ``ws_trigger_ml_training``. It must carry no ``@websocket_command`` / ``@async_response`` decorators (those would rewrite it into a sync handler that returns ``None``, so the direct call would pass ``None`` to ``async_create_task``).""" reg = task_registry.get_registry(hass) manager = _get_manager(hass, entry_id) if manager is None: reg.finish(task, state=task_registry.STATE_ERROR, error="device unavailable") return # Serialize under the per-entry write lock (same lock _reprocess_task uses, and # reprocess itself runs ML training): training rewrites the store, so two runs # for the same entry must not interleave. lock = _entry_write_lock(hass, entry_id) acquired = False try: await lock.acquire() acquired = True summary = await manager.async_run_ml_training(force=True) reg.finish(task, state=task_registry.STATE_DONE, result=summary) except asyncio.CancelledError: reg.finish(task, state=task_registry.STATE_CANCELLED) raise except Exception as exc: # pylint: disable=broad-exception-caught # Task-level failure: log at WARNING so it surfaces in the default HA log and # the panel Logs view (not swallowed at debug like a routine sub-step miss). _LOGGER.warning("ML training task failed for %s: %s", entry_id, exc) reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc)) finally: if acquired: lock.release() @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/trigger_ml_training", vol.Required("entry_id"): str, } ) @callback def ws_trigger_ml_training( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Kick off on-device ML training as a detached, registry-tracked task (manual, bypasses the schedule guards); returns its id. Result via the registry.""" entry_id: str = msg["entry_id"] if _get_manager(hass, entry_id) is None: _err_not_found(connection, msg["id"], entry_id) return if not ENABLE_ML_TRAINING: connection.send_error(msg["id"], "not_available", "ML training is not enabled in this build") return reg = task_registry.get_registry(hass) task = reg.create(entry_id, "ml_training", "Learning") _raw = hass.async_create_task(_ml_training_task(hass, task, entry_id)) if _raw is not None: reg.link_asyncio_task(task.id, _raw) _send_result(connection, msg["id"], "trigger_ml_training", {"task_id": task.id}) @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/revert_matching_config", vol.Required("entry_id"): str, } ) @websocket_api.async_response async def ws_revert_matching_config( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Revert the matcher's scoring weights to the shipped defaults. Drops the on-device tuned override so matching falls back to the const defaults. The next training pass may re-promote a new one if it wins. """ entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return await manager.profile_store.clear_matching_config() manager.notify_update() _send_result(connection, msg["id"], "revert_matching_config", {"success": True}) @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/revert_ml_models", vol.Required("entry_id"): str, } ) @websocket_api.async_response async def ws_revert_ml_models( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Revert all on-device trained models to the shipped embedded baselines. Drops every promoted spec in ``ml_model_versions`` so ``resolve_scorer`` / ``resolve_regressor`` fall back to the baseline (or, for the baseline-less remaining-time regressor, become inert). The next training pass may re-promote models if they beat the baseline again. Mirrors ``revert_matching_config`` for the matcher weights. """ entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return await manager.profile_store.clear_ml_model_versions() manager.notify_update() _send_result(connection, msg["id"], "revert_ml_models", {"success": True}) _ML_REVIEW_QUALITIES = {"", "good", "bad", "unusable"} @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/set_ml_review", vol.Required("entry_id"): str, vol.Required("cycle_id"): str, vol.Optional("quality"): vol.In(sorted(_ML_REVIEW_QUALITIES)), vol.Optional("golden"): bool, vol.Optional("tags"): [str], vol.Optional("notes"): str, } ) @websocket_api.async_response async def ws_set_ml_review( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Attach an ML-Lab review (quality / golden / tags / notes) to a cycle. This is the write-back that turns the read-only shadow view into a feedback loop: reviews become strong training labels for the on-device quality model. """ entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return try: await manager.profile_store.set_cycle_review( msg["cycle_id"], quality=msg.get("quality"), golden=msg.get("golden"), tags=msg.get("tags"), notes=msg.get("notes"), ) manager.notify_update() _send_result(connection, msg["id"], "set_ml_review", {"success": True}) except ValueError as exc: connection.send_error(msg["id"], "not_found", str(exc)) except Exception as exc: # pylint: disable=broad-exception-caught connection.send_error(msg["id"], "unknown_error", str(exc)) # ── Cycle Controls ──────────────────────────────────────────────────────────── @websocket_api.websocket_command( {vol.Required("type"): "ha_washdata/pause_cycle", vol.Required("entry_id"): str} ) @websocket_api.async_response async def ws_pause_cycle( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """User-pause the active cycle.""" entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return ok = await manager.async_pause_cycle() _send_result(connection, msg["id"], "pause_cycle", {"ok": bool(ok)}) @websocket_api.websocket_command( {vol.Required("type"): "ha_washdata/resume_cycle", vol.Required("entry_id"): str} ) @websocket_api.async_response async def ws_resume_cycle( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Resume a user-paused cycle.""" entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return ok = await manager.async_resume_cycle() _send_result(connection, msg["id"], "resume_cycle", {"ok": bool(ok)}) @websocket_api.websocket_command( {vol.Required("type"): "ha_washdata/terminate_cycle", vol.Required("entry_id"): str} ) @websocket_api.async_response async def ws_terminate_cycle( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Force-terminate the active cycle.""" entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return await manager.async_terminate_cycle() _send_result(connection, msg["id"], "terminate_cycle", {"ok": True}) # ─── Playground (F3): headless what-if replay + DTW visualizer ────────────────── def _playground_base_config(manager: Any, entry: Any) -> CycleDetectorConfig: """Resolve the device's live CycleDetectorConfig as the simulation base. Prefers the running detector's config (already merged with every default); falls back to a minimal config derived from the entry's effective options so the Playground still works if the detector is not yet initialised. """ detector = getattr(manager, "detector", None) cfg = getattr(detector, "config", None) if isinstance(cfg, CycleDetectorConfig): return cfg opts: dict[str, Any] = {} if entry is not None: opts = {**getattr(entry, "data", {}), **getattr(entry, "options", {})} min_power = float(opts.get(CONF_MIN_POWER, DEFAULT_MIN_POWER) or DEFAULT_MIN_POWER) return CycleDetectorConfig( min_power=min_power, off_delay=int(opts.get(CONF_OFF_DELAY, DEFAULT_OFF_DELAY)), device_type=str(opts.get(CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE)), completion_min_seconds=int(opts.get(CONF_COMPLETION_MIN_SECONDS, 600)), end_repeat_count=int(opts.get(CONF_END_REPEAT_COUNT, 1)), min_off_gap=int(opts.get(CONF_MIN_OFF_GAP, 60)), running_dead_zone=int(opts.get(CONF_RUNNING_DEAD_ZONE, DEFAULT_RUNNING_DEAD_ZONE)), start_threshold_w=float(opts.get(CONF_START_THRESHOLD_W, min_power)), stop_threshold_w=float( opts.get(CONF_STOP_THRESHOLD_W, min_power * 0.6 if min_power else 2.0) ), ) def _playground_context(hass: HomeAssistant, entry_id: str): """Return (manager, store, base_config, options, price) for a Playground call, or None (after sending the appropriate error) when unavailable.""" entry = _get_entry(hass, entry_id) manager = _get_manager(hass, entry_id) if manager is None: return None store = getattr(manager, "profile_store", None) if store is None: return None base_config = _playground_base_config(manager, entry) options = {} if entry is not None: options = {**getattr(entry, "data", {}), **getattr(entry, "options", {})} try: price = manager._resolve_energy_price() # noqa: SLF001 except Exception: # pylint: disable=broad-exception-caught price = None return manager, store, base_config, options, price @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/run_playground_cycle_detail", vol.Required("entry_id"): str, vol.Required("cycle_id"): str, vol.Optional("settings_override", default=dict): dict, } ) @websocket_api.async_response async def ws_run_playground_cycle_detail( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Faithful single-cycle simulation timeline (series + events + alerts + outcome) for the Playground "Simulate" view. Read-only what-if.""" entry_id: str = msg["entry_id"] ctx = _playground_context(hass, entry_id) if ctx is None: _err_not_found(connection, msg["id"], entry_id) return _manager, store, base_config, options, price = ctx try: cycle_id = msg["cycle_id"] override = dict(msg.get("settings_override") or {}) # The store lookup + replay run together in the executor so no store # access happens on the event loop. payload = await hass.async_add_executor_job( playground.simulate_cycle_detail_by_id, store, cycle_id, base_config, override, options, price, ) if isinstance(payload, dict) and payload.get("error") == "not_found": connection.send_error(msg["id"], "not_found", "Cycle not found") return _send_result(connection, msg["id"], "run_playground_cycle_detail", payload) except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("Playground cycle detail failed for %s: %s", entry_id, exc) connection.send_error(msg["id"], "unknown_error", str(exc)) @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/run_playground_history", vol.Required("entry_id"): str, vol.Optional("cycle_ids", default=list): [str], vol.Optional("settings_override", default=dict): dict, vol.Optional("concurrency", default=25): vol.Coerce(int), } ) @websocket_api.async_response async def ws_run_playground_history( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Per-cycle results table (+ before/after diff when settings_override is set) for the Playground "Test on history" view. Read-only what-if.""" entry_id: str = msg["entry_id"] ctx = _playground_context(hass, entry_id) if ctx is None: _err_not_found(connection, msg["id"], entry_id) return _manager, store, base_config, options, price = ctx try: cycle_ids = list(msg.get("cycle_ids") or []) override = dict(msg.get("settings_override") or {}) # Bound the batch size to the same safe cap as the backend (and sweep), # so an oversized caller value can't request unbounded replay work. concurrency = max(1, min(playground.MAX_BATCH_CYCLES, int(msg.get("concurrency", 25)))) payload = await hass.async_add_executor_job( playground.run_playground_history, store, cycle_ids, base_config, override, options, price, concurrency, ) _send_result(connection, msg["id"], "run_playground_history", payload) except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("Playground history failed for %s: %s", entry_id, exc) connection.send_error(msg["id"], "unknown_error", str(exc)) #: Max sweep points per axis - caps the grid so a caller can't request an #: unbounded number of full-history replays (values x values_y x cycles). _MAX_SWEEP_VALUES = 20 @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/run_playground_sweep", vol.Required("entry_id"): str, vol.Required("param"): str, vol.Required("values"): vol.All([vol.Coerce(float)], vol.Length(min=1, max=_MAX_SWEEP_VALUES)), vol.Required("objective"): str, vol.Optional("cycle_ids", default=list): [str], vol.Optional("concurrency", default=15): vol.Coerce(int), vol.Optional("param_y"): str, vol.Optional("values_y"): vol.All([vol.Coerce(float)], vol.Length(max=_MAX_SWEEP_VALUES)), } ) @websocket_api.async_response async def ws_run_playground_sweep( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Objective-driven parameter sweep (1D curve or 2D heatmap) for the Playground "Sweep" view. Read-only what-if.""" entry_id: str = msg["entry_id"] ctx = _playground_context(hass, entry_id) if ctx is None: _err_not_found(connection, msg["id"], entry_id) return _manager, store, base_config, options, price = ctx param_y = msg.get("param_y") values_y = list(msg["values_y"]) if msg.get("values_y") else None # A 2D sweep needs BOTH the second parameter and its values, or neither. if bool(param_y) != bool(values_y): connection.send_error( msg["id"], "invalid_format", "param_y and values_y must both be provided for a 2D sweep, or both omitted", ) return try: cycle_ids = list(msg.get("cycle_ids") or []) concurrency = max(1, min(playground.MAX_BATCH_CYCLES, int(msg.get("concurrency", 15)))) payload = await hass.async_add_executor_job( playground.run_playground_sweep, store, cycle_ids, base_config, msg["param"], list(msg.get("values") or []), msg["objective"], options, price, concurrency, param_y, values_y, ) _send_result(connection, msg["id"], "run_playground_sweep", payload) except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("Playground sweep failed for %s: %s", entry_id, exc) connection.send_error(msg["id"], "unknown_error", str(exc)) @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/get_dtw_debug", vol.Required("entry_id"): str, vol.Required("cycle_id"): str, vol.Optional("profile_name"): vol.Any(str, None), } ) @websocket_api.async_response async def ws_get_dtw_debug( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Return the Stage 2 / DTW / Stage 4 score breakdown + resampled traces + DTW warp path for one cycle vs one profile (defaults to the cycle's label).""" entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return store = getattr(manager, "profile_store", None) if store is None: connection.send_error(msg["id"], "unavailable", "Profile store unavailable") return try: payload = await hass.async_add_executor_job( playground.dtw_debug_payload, store, msg["cycle_id"], msg.get("profile_name"), ) if isinstance(payload, dict) and payload.get("error"): connection.send_error( msg["id"], payload["error"], payload.get("detail", payload["error"]) ) return _send_result(connection, msg["id"], "get_dtw_debug", payload) except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("DTW debug failed for %s: %s", entry_id, exc) connection.send_error(msg["id"], "unknown_error", str(exc)) # ─── Background-task registry (progress / cancel / reconnect-safe results) ────── @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/list_tasks", vol.Optional("entry_id"): vol.Any(str, None), } ) @callback def ws_list_tasks( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Snapshot of active + recently-finished background tasks (for reconnect rehydration / a one-shot refresh). Optionally filtered to one device.""" reg = task_registry.get_registry(hass) _send_result(connection, msg["id"], "list_tasks", {"tasks": reg.snapshot(msg.get("entry_id"))}) @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/subscribe_tasks", vol.Optional("entry_id"): vol.Any(str, None), } ) @callback def ws_subscribe_tasks( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Live push of task progress. Sends the current snapshot as `task` events on subscribe, then one event per change until the client unsubscribes / the socket closes. The client dedupes by id and keeps the latest `updated_at`.""" reg = task_registry.get_registry(hass) entry_id = msg.get("entry_id") iden = msg["id"] @callback def _forward(snap: dict[str, Any]) -> None: if entry_id and snap.get("entry_id") != entry_id: return connection.send_message( websocket_api.event_message(iden, {"type": "task", "task": snap}) ) connection.subscriptions[iden] = reg.add_listener(_forward) connection.send_result(iden) for snap in reg.snapshot(entry_id): connection.send_message( websocket_api.event_message(iden, {"type": "task", "task": snap}) ) @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/cancel_task", vol.Required("task_id"): str, } ) @callback def ws_cancel_task( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Request cancellation of a running task (consumers stop at the next chunk).""" reg = task_registry.get_registry(hass) _send_result(connection, msg["id"], "cancel_task", {"cancelled": reg.cancel(msg["task_id"])}) @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/get_task_result", vol.Required("task_id"): str, } ) @callback def ws_get_task_result( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Fetch a finished task's stored result (reloadable after a tab switch / reconnect until the task is evicted).""" reg = task_registry.get_registry(hass) task = reg.get(msg["task_id"]) if task is None: connection.send_error(msg["id"], "not_found", "Task not found") return _send_result(connection, msg["id"], "get_task_result", task.snapshot(include_result=True)) # -- Playground batch/sweep as detached, registry-tracked tasks ----------------- # The heavy replay runs in the executor CHUNK-BY-CHUNK (awaiting between chunks so # the event loop breathes and the executor thread is freed), updating the task's # progress and checking its cancel flag. Because the task is detached # (async_create_task), it survives a dropped socket; the panel re-attaches via # subscribe_tasks and reads the result with get_task_result. _PG_HISTORY_CHUNK = 2 async def _pg_history_task( hass: HomeAssistant, task: Any, entry_id: str, cycle_ids: list[str], override: dict[str, Any] | None, ) -> None: reg = task_registry.get_registry(hass) ctx = _playground_context(hass, entry_id) if ctx is None: reg.finish(task, state=task_registry.STATE_ERROR, error="device unavailable") return _manager, store, base_config, options, price = ctx try: past = await hass.async_add_executor_job(lambda: list(store.get_past_cycles() or [])) by_id = {c.get("id"): c for c in past if isinstance(c, dict)} if cycle_ids: ids = [c for c in cycle_ids if c in by_id] else: ids = [c.get("id") for c in past[-playground.DEFAULT_RECENT_CYCLES:]] ids = [i for i in ids[:playground.MAX_BATCH_CYCLES] if i] # Build match snapshots once — they are store-derived and identical for # every chunk; rebuilding per chunk is O(n_profiles) wasted work. prebuilt = await hass.async_add_executor_job(playground._build_match_snapshots, store) reg.update(task, total=len(ids)) rows: list[dict[str, Any]] = [] base_rows: list[dict[str, Any]] = [] for i in range(0, len(ids), _PG_HISTORY_CHUNK): if task.cancel_requested: break chunk = ids[i:i + _PG_HISTORY_CHUNK] r = await hass.async_add_executor_job( playground.run_playground_history, store, chunk, base_config, override, options, price, len(chunk), prebuilt, ) rows.extend(r.get("rows") or []) base_rows.extend(r.get("baseline_rows") or []) reg.update(task, done=min(len(ids), i + len(chunk))) payload = playground.finalize_history(rows, base_rows, bool(override)) payload["partial"] = task.cancel_requested reg.finish( task, state=task_registry.STATE_CANCELLED if task.cancel_requested else task_registry.STATE_DONE, result=payload, ) except asyncio.CancelledError: reg.finish(task, state=task_registry.STATE_CANCELLED) raise except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("Playground history task failed for %s: %s", entry_id, exc) reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc)) async def _pg_sweep_task( hass: HomeAssistant, task: Any, entry_id: str, param: str, values: list[float], objective: str, param_y: str | None, values_y: list[float] | None, ) -> None: reg = task_registry.get_registry(hass) ctx = _playground_context(hass, entry_id) if ctx is None: reg.finish(task, state=task_registry.STATE_ERROR, error="device unavailable") return _manager, store, base_config, options, price = ctx try: past = await hass.async_add_executor_job(lambda: list(store.get_past_cycles() or [])) ids = [c.get("id") for c in past[-playground.DEFAULT_RECENT_CYCLES:] if isinstance(c, dict)] ids = [i for i in ids[:playground.MAX_BATCH_CYCLES] if i] n = max(1, len(ids)) # Build match snapshots once — identical for every sweep value/cell. prebuilt = await hass.async_add_executor_job(playground._build_match_snapshots, store) if param_y and values_y: reg.update(task, total=len(values) * len(values_y)) grid: list[list[float | None]] = [[None] * len(values) for _ in values_y] current: dict[str, Any] = {} done = 0 cancelled = False for j, vy in enumerate(values_y): for i, vx in enumerate(values): if task.cancel_requested: cancelled = True break r = await hass.async_add_executor_job( playground.run_playground_sweep, store, ids, base_config, param, [vx], objective, options, price, n, param_y, [vy], prebuilt, ) cell = (r.get("grid") or [[None]])[0] grid[j][i] = cell[0] if cell else None if r.get("current"): current = r["current"] done += 1 reg.update(task, done=done) if cancelled: break payload = playground.finalize_sweep_2d(param, param_y, objective, values, values_y, grid, current) else: reg.update(task, total=len(values)) points: list[dict[str, Any]] = [] current_value: Any = None for i, vx in enumerate(values): if task.cancel_requested: break r = await hass.async_add_executor_job( playground.run_playground_sweep, store, ids, base_config, param, [vx], objective, options, price, n, None, None, prebuilt, ) points.extend(r.get("points") or []) if r.get("current_value") is not None: current_value = r["current_value"] reg.update(task, done=i + 1) payload = playground.finalize_sweep_1d(param, objective, points, current_value) payload["partial"] = task.cancel_requested reg.finish( task, state=task_registry.STATE_CANCELLED if task.cancel_requested else task_registry.STATE_DONE, result=payload, ) except asyncio.CancelledError: reg.finish(task, state=task_registry.STATE_CANCELLED) raise except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("Playground sweep task failed for %s: %s", entry_id, exc) reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc)) @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/start_playground_history", vol.Required("entry_id"): str, vol.Optional("cycle_ids", default=list): [str], vol.Optional("settings_override", default=dict): dict, } ) @callback def ws_start_playground_history( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Kick off a detached, registry-tracked Test-on-history replay; returns the task id immediately. Progress/result come via subscribe_tasks/get_task_result.""" entry_id = msg["entry_id"] if _playground_context(hass, entry_id) is None: _err_not_found(connection, msg["id"], entry_id) return reg = task_registry.get_registry(hass) task = reg.create(entry_id, "pg_history", "Test on history") override = dict(msg.get("settings_override") or {}) or None cycle_ids = list(msg.get("cycle_ids") or []) _raw = hass.async_create_task(_pg_history_task(hass, task, entry_id, cycle_ids, override)) if _raw is not None: reg.link_asyncio_task(task.id, _raw) _send_result(connection, msg["id"], "start_playground_history", {"task_id": task.id}) @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/start_playground_sweep", vol.Required("entry_id"): str, vol.Required("param"): str, vol.Required("values"): [vol.Coerce(float)], vol.Required("objective"): str, vol.Optional("param_y"): vol.Any(str, None), vol.Optional("values_y"): vol.Any([vol.Coerce(float)], None), } ) @callback def ws_start_playground_sweep( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Kick off a detached, registry-tracked Optimize sweep; returns the task id.""" entry_id = msg["entry_id"] if _playground_context(hass, entry_id) is None: _err_not_found(connection, msg["id"], entry_id) return param_y = msg.get("param_y") values_y = msg.get("values_y") if bool(param_y) != bool(values_y): connection.send_error(msg["id"], "invalid_format", "param_y and values_y must be set together") return reg = task_registry.get_registry(hass) task = reg.create( entry_id, "pg_sweep", f"Optimize: {msg['param']}", label_key="task.pg_sweep.optimize", label_params={"param": msg["param"]}, ) _raw = hass.async_create_task(_pg_sweep_task( hass, task, entry_id, msg["param"], list(msg.get("values") or []), msg["objective"], param_y, list(values_y) if values_y else None, )) if _raw is not None: reg.link_asyncio_task(task.id, _raw) _send_result(connection, msg["id"], "start_playground_sweep", {"task_id": task.id}) # Readings replayed per executor job for the single-cycle detail sim. The event # loop breathes between chunks; a ~233min/5s dishwasher cycle (~2800 readings) # becomes ~11 short jobs instead of one multi-minute GIL-holding call (issue #311). _PG_DETAIL_CHUNK = 250 async def _pg_detail_task( hass: HomeAssistant, task: Any, entry_id: str, cycle_id: str, override: dict[str, Any] | None, ) -> None: reg = task_registry.get_registry(hass) ctx = _playground_context(hass, entry_id) if ctx is None: reg.finish(task, state=task_registry.STATE_ERROR, error="device unavailable") return _manager, store, base_config, options, price = ctx try: sim = await hass.async_add_executor_job( playground.build_cycle_detail_sim_by_id, store, cycle_id, base_config, override, options, price, ) if isinstance(sim, dict): # {"error": ...} marker (not_found / build failure) if sim.get("error") == "not_found": reg.finish(task, state=task_registry.STATE_ERROR, error="not_found") else: reg.finish(task, state=task_registry.STATE_ERROR, error=str(sim.get("error"))) return if not sim.ready: reg.finish(task, state=task_registry.STATE_DONE, result=sim.empty_payload()) return total = sim.n_readings reg.update(task, total=total) for i in range(0, total, _PG_DETAIL_CHUNK): if task.cancel_requested: break await hass.async_add_executor_job(sim.step, i, i + _PG_DETAIL_CHUNK) reg.update(task, done=min(total, i + _PG_DETAIL_CHUNK)) if not task.cancel_requested: await hass.async_add_executor_job(sim.run_tail) payload = await hass.async_add_executor_job(sim.finalize) payload["partial"] = task.cancel_requested reg.finish( task, state=task_registry.STATE_CANCELLED if task.cancel_requested else task_registry.STATE_DONE, result=payload, ) except asyncio.CancelledError: reg.finish(task, state=task_registry.STATE_CANCELLED) raise except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("Playground detail task failed for %s: %s", entry_id, exc) reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc)) @websocket_api.websocket_command( { vol.Required("type"): "ha_washdata/start_playground_cycle_detail", vol.Required("entry_id"): str, vol.Required("cycle_id"): str, vol.Optional("settings_override", default=dict): dict, } ) @callback def ws_start_playground_cycle_detail( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Kick off a detached, registry-tracked single-cycle "Simulate" replay; returns the task id immediately. The heavy per-5s replay runs chunk-by-chunk in the executor so a long cycle no longer stalls Home Assistant (issue #311). Progress/result come via subscribe_tasks/get_task_result.""" entry_id = msg["entry_id"] if _playground_context(hass, entry_id) is None: _err_not_found(connection, msg["id"], entry_id) return reg = task_registry.get_registry(hass) task = reg.create( entry_id, "pg_detail", "Simulate cycle", label_key="task.pg_detail.simulate", label_params={}, ) override = dict(msg.get("settings_override") or {}) _raw = hass.async_create_task( _pg_detail_task(hass, task, entry_id, msg["cycle_id"], override) ) if _raw is not None: reg.link_asyncio_task(task.id, _raw) _send_result(connection, msg["id"], "start_playground_cycle_detail", {"task_id": task.id})