# 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 copy
import functools
import json
import logging
import math
import os
import re
import time
import uuid
from datetime import timedelta
from typing import Any
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.storage import Store
from homeassistant.util import dt as dt_util
from .const import (
coerce_numeric_option,
drop_invalid_numeric_options,
numeric_option_keys,
CONF_NAME,
CONF_COMPLETION_MIN_SECONDS,
CONF_DEVICE_TYPE,
CONF_DISHWASHER_END_SPIKE_QUIET_RELEASE,
CONF_SMART_TERMINATION_DURATION_RATIO,
CONF_ANTI_CREASE_FINALIZE_RATIO,
ANTI_CREASE_FINALIZE_RATIO_MIN,
ANTI_CREASE_FINALIZE_RATIO_MAX,
CONF_CURVE_PREROLL_SECONDS,
CONF_DOOR_SENSOR_ENTITY,
CONF_UNLOAD_CONFIRM_ENTITY,
CONF_ANTI_WRINKLE_EXIT_POWER,
CONF_ANTI_WRINKLE_MAX_POWER,
CONF_END_ENERGY_THRESHOLD,
CONF_ENERGY_SENSOR,
CONF_EXTERNAL_END_TRIGGER,
CONF_LINKED_DEVICE,
CONF_MAINTENANCE_REMINDER_CYCLES,
CONF_MIN_OFF_GAP,
CONF_MIN_POWER,
CONF_NO_UPDATE_ACTIVE_TIMEOUT,
CONF_OFF_DELAY,
CONF_PROFILE_MATCH_INTERVAL,
CONF_PROFILE_MATCH_MAX_DURATION_RATIO,
CONF_PROFILE_MATCH_MIN_DURATION_RATIO,
CONF_PROFILE_MIN_WARMUP_CYCLES,
CONF_PUMP_STUCK_DURATION,
CONF_POWER_OFF_THRESHOLD_W,
CONF_POWER_SENSOR,
CONF_ENERGY_PRICE_ENTITY,
CONF_NOTIFY_PEOPLE,
CONF_SAMPLING_INTERVAL,
CONF_START_DURATION_THRESHOLD,
CONF_START_THRESHOLD_W,
CONF_STOP_THRESHOLD_W,
CONF_SWITCH_ENTITY,
CONF_WATCHDOG_INTERVAL,
DEFAULT_DEVICE_TYPE,
DISHWASHER_END_SPIKE_QUIET_RELEASE_SECONDS,
DEFAULT_ANTI_CREASE_FINALIZE_RATIO,
DEFAULT_PROFILE_MATCH_MAX_DURATION_RATIO,
CURVE_PREROLL_MAX_SECONDS,
resolve_sampling_interval_default,
resolve_watchdog_interval_default,
resolve_start_duration_default,
resolve_smart_termination_duration_ratio_default,
MAINTENANCE_CUSTOM_TASK_MAX,
MAINTENANCE_INTERVAL_CYCLES_MAX,
MAINTENANCE_INTERVAL_DAYS_MAX,
MAINTENANCE_TASK_NAME_MAX,
resolve_min_off_gap_default,
resolve_off_delay_default,
DEVICE_TYPE_PUMP,
MAINTENANCE_EVENT_TYPES,
PLAYGROUND_PRESET_MAX,
ML_HEALTH_MIN_TRACE_POINTS,
DEVICE_TYPES,
DOMAIN,
ENABLE_ML_TRAINING,
HISTORY_IMPORT_CHUNK_BYTES,
HISTORY_IMPORT_CHUNK_SAMPLES,
HISTORY_IMPORT_MAX_BYTES,
HISTORY_IMPORT_MAX_ROWS,
HISTORY_IMPORT_MAX_SEGMENTS,
HISTORY_IMPORT_MAX_TOTAL_CYCLES,
HISTORY_IMPORT_RECORDER_EMPTY_DAY_STOP,
HISTORY_IMPORT_RECORDER_MAX_DAYS,
SHOW_ML_LAB,
STATE_COLORS,
)
from . import history_import
from . import playground
from . import task_registry
from .cycle_detector import (
CycleDetectorConfig,
)
from .detector_config import (
build_detector_config,
effective_option_values,
inverted_threshold_pair,
)
from .maintenance import editor_types, effective_reminders
from .options_utils import strip_null_options
from .setup_advisor import compute_setup_phase
from .ws_schema import WS_OPEN_RESPONSES, WS_RESPONSE_TYPES
_LOGGER = logging.getLogger(__name__)
# Populated once during async_setup_entry (stored in hass.data["ha_washdata_version"])
# so ws_get_constants never does blocking I/O on the event loop. A module-level
# read_text() here would run on the event loop the first time ws_api is imported
# inside async_setup_entry (#328/#335).
_INTEGRATION_VERSION: str = ""
# ─── 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.
# The keys the suggestion engine can still produce. A stored suggestion for any
# other key - one written by an older version for a setting no longer suggested
# (sampling_interval, smoothing_window, start debounce, the confidence ladder, the
# tolerances, end_repeat_count; audit SUGGEST-01/04/12) - is never shown or applied.
_SUGGESTION_KEYS: tuple[str, ...] = (
CONF_MIN_POWER,
CONF_OFF_DELAY,
CONF_WATCHDOG_INTERVAL,
CONF_NO_UPDATE_ACTIVE_TIMEOUT,
# Held back from 2026-10-04 (audit SUGGEST-21) until register item 469(b): a
# shorter interval let an ambiguous tick land in the end wait and delay the end
# (6.7 -> 23.2 min on one cycle). `match_rules.hold_in_ending` closes that.
CONF_PROFILE_MATCH_INTERVAL,
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_COMPLETION_MIN_SECONDS,
# Device-class-specific suggestions from reconcile_suggestions
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_COMPLETION_MIN_SECONDS,
CONF_PUMP_STUCK_DURATION,
})
def _coerce_suggested(key: str, val: Any) -> Any:
"""Round a raw suggestion value the way the UI would apply it.
Int keys are floored to int, everything else rounded to 4 dp - the same
coercion ``ws_get_suggestions`` does before the equivalence test. The device
pill's badge filter must use it too, or a value like 30.4 for an int key with
a current 30 is hidden in the Settings list (30 == 30) but still counted on
the pill, leaving a badge the user cannot clear. Returns the raw value
unchanged if it is not numeric.
"""
try:
return int(float(val)) if key in _SUGGESTION_INT_KEYS else round(float(val), 4)
except (TypeError, ValueError, OverflowError):
return val
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, OverflowError):
return str(suggested) == str(current)
def _visible_suggestions(
store: Any, merged: dict[str, Any], device_type: str
) -> list[tuple[str, dict[str, Any], Any, Any]]:
"""``(key, stored item, suggested, current)`` for every suggestion worth showing.
The ONE filter behind the Settings list, the Overview card, Apply-all and
the setup advisor, which had drifted (audit SUGGEST-11/17): a muted key could
be re-created by the reconcile cascade and applied by Apply-all, and the setup
card counted stale keys nothing would show. Drops keys the engine no longer
produces, muted keys, and values equal to what the key already RUNS with - the
effective default when it is unset, not ``None`` (SUGGEST-10).
"""
raw = store.get_suggestions() or {}
try:
muted = set(store.get_locked_suggestions() or [])
except Exception: # pylint: disable=broad-exception-caught
muted = set()
effective = effective_option_values(merged, device_type)
out: list[tuple[str, dict[str, Any], Any, Any]] = []
for key in _SUGGESTION_KEYS:
item = raw.get(key)
if key in muted or not isinstance(item, dict) or item.get("value") is None:
continue
suggested = _coerce_suggested(key, item["value"])
current = merged.get(key)
if current is None:
current = effective.get(key)
if _suggestion_equivalent(suggested, current):
continue
out.append((key, item, suggested, current))
return out
def _threshold_pair_message(start: float, stop: float) -> str:
"""The error a write that would invert the threshold pair is refused with (item 515)."""
return (
f"Stop Threshold ({stop:g} W) must be below Start Threshold ({start:g} W). "
"Nothing was saved; change both together."
)
def _without_inverted_threshold_pair(
entry: ConfigEntry, updates: dict[str, Any], source: str
) -> dict[str, Any]:
"""An import's settings, less its threshold pair when that would invert this
device's (item 515): the device keeps its own pair, the rest still applies.
An import is someone else's tuning (an export, a store bundle), so a refused
pair is logged rather than failing the import that carries profiles and cycles.
"""
merged = {**entry.data, **entry.options}
device_type = merged.get(CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE)
pair = inverted_threshold_pair(merged, {**merged, **updates}, device_type)
if pair is None:
return updates
kept = {
k: v for k, v in updates.items()
if k not in (CONF_START_THRESHOLD_W, CONF_STOP_THRESHOLD_W)
}
if inverted_threshold_pair(merged, {**merged, **kept}, device_type) is not None:
kept.pop(CONF_MIN_POWER, None) # it sets an unset threshold's default
_LOGGER.warning(
"%s for %s: not applying its thresholds (start %g W, stop %g W): the stop "
"threshold must be below the start threshold; the device keeps its own",
source, entry.title, pair[0], pair[1],
)
return kept
def _downsample(samples: Any, max_points: int = 240) -> list[list[float]]:
"""Reduce a [(offset_s, watts), ...] series to ~max_points, preserving extrema.
Splits the series into contiguous buckets and keeps each bucket's MIN and MAX
power sample (in time order), plus the global first and last. Two points per
bucket, so ~max_points/2 buckets keeps the payload at the same budget striding
used. Unlike striding this never drops a single-sample load peak (it is its
bucket's max) or a lone 0 W self-shutdown sample between two non-zero readings
(a zero is always its bucket's min) - the signals that carry the meaning.
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]
nbuckets = max(1, max_points // 2)
keep: set[int] = {0, n - 1}
for b in range(nbuckets):
lo = (b * n) // nbuckets
hi = ((b + 1) * n) // nbuckets
if hi <= lo:
continue
min_i = max_i = lo
min_v = max_v = float(pairs[lo][1])
for i in range(lo + 1, hi):
v = float(pairs[i][1])
if v < min_v:
min_v, min_i = v, i
elif v > max_v:
max_v, max_i = v, i
keep.add(min_i)
keep.add(max_i)
return [_pt(pairs[i]) for i in sorted(keep)]
async def _recorder_power(
hass: HomeAssistant,
entity_id: str,
start_dt: Any,
*,
end_dt: Any = None,
keep_unavailable: bool = False,
) -> list[tuple[float, float | None]]:
"""Raw (unix_ts, watts) readings for entity_id over a window, via the recorder.
``end_dt`` defaults to now (the live chart overlay's use). Passing it lets a caller
read the history in bounded windows instead of one unbounded query - a month of
5-second data is millions of rows in a single recorder-executor job.
A row that is not a number (``unavailable``/``unknown``) is skipped, unless
``keep_unavailable`` asks for it as ``(ts, None)``: the cycle-context view
(register item 513) breaks its line there instead of holding the last reading
across an outage. Watts are never None without it.
"""
try:
from homeassistant.components.recorder import ( # pylint: disable=import-outside-toplevel
get_instance,
history,
)
except Exception: # pylint: disable=broad-exception-caught
return []
# tz-aware; use dt_util.now() per the datetime convention
window_end = end_dt if end_dt is not None else dt_util.now()
def _query() -> list[tuple[float, float | None]]:
res = history.state_changes_during_period(
hass, start_dt, window_end, entity_id, include_start_time_state=True
)
rows: list[tuple[float, float | None]] = []
start_ts = start_dt.timestamp() if hasattr(start_dt, "timestamp") else None
for s in res.get(entity_id, []) or []:
try:
ts = s.last_changed.timestamp()
# include_start_time_state yields the state in force at the window
# start, whose last_changed can predate it by days. Clamp it to the
# window so a caller reading day-by-day windows does not see a huge
# synthetic leading gap (or the same reading twice).
if start_ts is not None and ts < start_ts:
ts = start_ts
rows.append((ts, round(float(s.state), 1)))
except (ValueError, TypeError, OverflowError):
if keep_unavailable:
try:
gap_ts = s.last_changed.timestamp()
except (AttributeError, TypeError, ValueError, OverflowError):
continue
if start_ts is not None and gap_ts < start_ts:
gap_ts = start_ts
rows.append((gap_ts, None))
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, OverflowError):
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"
_WS_OPTIONS_LOCKS_KEY = f"{DOMAIN}_ws_options_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 _entry_options_lock(hass: HomeAssistant, entry_id: str) -> asyncio.Lock:
"""Per-entry lock for ``entry.options`` read-record-update sections only.
Deliberately NOT ``_entry_write_lock``. That one is held for the whole run
of the long detached tasks - ``_reprocess_task`` across rematching,
suggestions, ML training, recosting and health recompute;
``_ml_training_task`` across a full training run; ``_rebuild_envelopes_task``
across every profile - so putting a Settings save behind it means the save
blocks for as long as the task takes, which on a slow host is minutes with
no feedback to the user. Serialising the option writers against each other
is all the race needs.
**Lock order where both are held: write lock first, then this one.** The
import handlers are the only place that happens, and they follow it, so the
pair cannot deadlock.
"""
locks: dict[str, asyncio.Lock] = hass.data.setdefault(_WS_OPTIONS_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}
def _cycle_capabilities(cycle: dict[str, Any], origin: str) -> dict[str, Any]:
"""What the panel may offer for one cycle, given which list it lives in.
Three separate answers, because "not a real cycle" is not one capability:
* ``is_reference`` - it lives outside ``past_cycles``, so it feeds envelopes and the
matcher but never usage statistics. Always derived from list membership, never from
a ``meta.source`` string: the cycle list and the inspector both answer through here,
and they must not be able to disagree.
* ``labelable`` - a profile can be assigned to it. True everywhere: naming the
programs found in imported history (#344) is the entire point of that feature, and
the store labels a non-real cycle in place.
* ``editable`` - trim / split / review apply. Real cycles only; those store functions
operate on ``past_cycles`` and would silently no-op elsewhere.
``origin`` is the list name from :meth:`ProfileStore.find_stored_cycle`
(``past`` / ``reference`` / ``backfill``) and is passed through so the UI can say
where a cycle came from - a curated community-store template and a segment the
importer detected in the user's own history warrant different wording.
"""
if origin == "past":
return {"is_reference": False, "labelable": True, "editable": True}
return {
"is_reference": True,
"labelable": True,
"editable": False,
"cycle_origin": origin or "reference",
}
# 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})
# Option keys whose value names an entity or device on THIS Home Assistant.
# They are ordinary tunables when the panel writes them (the user picks from a
# selector listing their own entities), but an *imported* export carries the
# SOURCE system's ids, and applying those re-points this device at entities that
# do not exist here. ``power_sensor`` is the severe one: the integration goes
# silently dead, state stuck at "off" and current_power at 0, with nothing in the
# log but one INFO line. ``ws_import_config`` already refuses to write the
# exporter's ``entry.data`` for exactly this reason - "blindly applying it would
# hijack this device's sensor binding" - but the same keys live in entry.options
# post-3.6, so they arrived through the other door. Found by the test box
# (devtools/testbox) on its first run; see register item 317.
#
# Deliberately NOT here: the notify_*_services lists. They also name the source
# user's targets, but they are shown plainly in the panel's Notifications
# section, and carrying them is usually the point when migrating your own setup.
_IMPORT_LOCAL_BINDING_KEYS = _OPTIONS_IDENTITY_KEYS | frozenset({
CONF_POWER_SENSOR,
CONF_ENERGY_SENSOR,
CONF_ENERGY_PRICE_ENTITY,
CONF_DOOR_SENSOR_ENTITY,
CONF_UNLOAD_CONFIRM_ENTITY,
CONF_SWITCH_ENTITY,
CONF_EXTERNAL_END_TRIGGER,
CONF_LINKED_DEVICE,
CONF_NOTIFY_PEOPLE,
})
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, OverflowError):
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
async def _record_option_changes(
hass: HomeAssistant, entry: Any, updates: dict[str, Any], source: str
) -> None:
"""Record an option write in the settings changelog (#442).
``ws_set_options`` did this inline and was the only writer that did, so every
other path that writes ``entry.options`` was invisible in the history and its
values could not be reverted per setting. The one the reporter hit is "Apply
all": nine tunables changed at once with nothing recorded, and the previous
values were only recoverable because they happened to have an older
diagnostics dump.
Must be awaited BEFORE ``async_update_entry``, which schedules a reload that
rebuilds the store. Never raises: a changelog failure must not cost the write
it is describing (same contract as the inline version).
"""
if not updates:
return
try:
old_effective = {**getattr(entry, "data", {}), **getattr(entry, "options", {})}
changes = _diff_option_changes(old_effective, updates)
if not changes:
return
manager = _get_manager(hass, entry.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): %s",
getattr(entry, "entry_id", "?"),
source,
exc,
)
# ─── 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})*$")
# cycle_context_min: the cycle chart's recorder-history length per device, in
# minutes (register item 513). The panel offers exactly these; 0 = off.
_PREF_CYCLE_CONTEXT_MINUTES = (0, 5, 10, 30, 60)
# 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",
# Selective export/import wizard (same full-data reach as the wholesale variants).
"get_export_inventory", "analyze_import", "export_config_selective", "import_config_selective",
# Undo of a replace import: a whole-store replace like the import itself.
"undo_import",
# Reverting on-device models discards learned state -> full access.
"revert_ml_models",
# Rewrites the appliance's lifetime odometer, which drives maintenance schedules.
"set_lifetime_cycle_count",
# Historical power-data import: ingests a whole power history and writes cycles.
"history_import_begin", "history_import_chunk", "history_import_recorder",
"start_history_import_scan", "apply_history_import",
})
# 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",
"get_export_inventory",
"analyze_import",
"export_config_selective",
"import_config_selective",
"undo_import",
"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",
# They publish under that same install-wide account (audit PLATFORM-11): with
# RBAC off, any non-admin could otherwise post under the admin's identity.
"store_upload_cycle",
"store_upload_device",
"store_rate_device",
"store_confirm_device",
# Historical power-data import: reads a whole recorder history (or an uploaded
# export of one) and writes cycles into the store, so it is admin-only even with
# RBAC disabled, exactly like the selective import wizard.
"history_import_begin",
"history_import_chunk",
"history_import_recorder",
"start_history_import_scan",
"apply_history_import",
})
# 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({
# Picking the live program stays a read-level exception by design, although
# the pick is stored as the cycle's label at cycle end (audit PLATFORM-11
# proposed edit; the maintainer kept it open to every user of the device).
"set_program",
# Background-task registry: read-level runtime actions (watch progress, fetch
# a what-if/maintenance result, or stop a task). None mutate stored data.
"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_profiles",
"store_get_catalog_entry",
# NB: store_refresh_catalog is deliberately NOT here. It drops the install-wide
# catalog cache in the shared StoreClient, so a read-level user could bust the
# 1-hour TTL that protects the free-tier read budget on repeat. It defaults to
# 'edit', like the other install-wide store actions above read level.
})
_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({"subscribe_tasks", "cancel_task", "get_task_result"})
# Cancelling a task needs the level its START needed (audit PLATFORM-11): a read
# user could stop an admin's history import or reprocess. Read-level kinds are the
# what-if Playground runs; admin kinds are started by admin-only commands.
_READ_TASK_KINDS = frozenset({"pg_history", "pg_sweep", "pg_detail"})
_ADMIN_TASK_KINDS = frozenset({"reprocess", "history_import", "history_import_apply"})
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:
# 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 cmd == "cancel_task" and msg.get("task_id"):
from . import task_registry # pylint: disable=import-outside-toplevel
task = task_registry.get_registry(hass).get(msg["task_id"])
kind = getattr(task, "kind", None)
if kind in _ADMIN_TASK_KINDS:
connection.send_error(msg["id"], "forbidden", "Administrator access required")
return False
if kind not in _READ_TASK_KINDS:
entry_id = entry_id or getattr(task, "entry_id", None)
if entry_id and _LEVEL_RANK.get(
_effective_level(hass, user, entry_id), 0
) < _LEVEL_RANK["edit"]:
connection.send_error(
msg["id"], "forbidden", "You need edit access to this 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, OverflowError):
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 ─────────────────────────────────────────────────────────────
# ─── 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_catalog_entry", 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_catalog_entry(hass, connection, msg):
"""Resolve just this appliance's catalog brand + device documents, by id.
Two point reads, replacing the brand list + device list the settings form used to
download purely to locate these two rows (measured: 128 documents, 119 KB). The
pickers still fetch the full lists, but only when the user opens one.
"""
ctx = _store_ctx(hass, msg["entry_id"])
if ctx is None:
_send_result(connection, msg["id"], "store_get_catalog_entry", {"disabled": True})
return
manager, _ = ctx
res = await manager.store_bridge.catalog_entry(msg["brand"], msg["model"], msg["appliance_type"])
_send_result(connection, msg["id"], "store_get_catalog_entry", res)
@websocket_api.websocket_command({
vol.Required("type"): "ha_washdata/store_refresh_catalog", vol.Required("entry_id"): str,
})
@websocket_api.async_response
async def ws_store_refresh_catalog(hass, connection, msg):
"""Drop the cached catalog so the next browse re-reads the store.
The cache is deliberately long-lived (the catalog is near-static and every read is
charged against a shared free-tier budget), so this is the escape hatch for "someone
told me my brand was just approved".
"""
ctx = _store_ctx(hass, msg["entry_id"])
if ctx is None:
_send_result(connection, msg["id"], "store_refresh_catalog", {"disabled": True})
return
manager, _ = ctx
_send_result(connection, msg["id"], "store_refresh_catalog", manager.store_bridge.refresh_catalog())
@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"])
# None = the store could not be reached, not "no shared programs" (audit STORE-09).
_send_result(connection, msg["id"], "store_get_profiles",
{"items": items} if items is not None else {"items": [], "error": "store_unreachable"})
@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} if items is not None else {"items": [], "error": "store_unreachable"})
@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
# Under the write lock imports, reprocess and merges hold (audit STORE-11): a
# replace-mode selective import resets reference_cycles mid-download otherwise.
async with _entry_write_lock(hass, msg["entry_id"]):
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,
sanitize_shared_settings,
)
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 = sanitize_shared_settings(dict(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
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))
# A registry task under the write lock (audit STORE-10/11): a bundle import ran
# 7-61 s inside this request, unlocked against reprocess and imports.
reg = task_registry.get_registry(hass)
task = reg.create(
msg["entry_id"], "store_download", "Downloading from the store",
label_key="task.store_download.running",
)
raw = hass.async_create_task(_store_download_task(
hass, task, msg["entry_id"], manager, msg["device_id"], device_type,
bool(msg.get("include_settings")),
))
if raw is not None:
reg.link_asyncio_task(task.id, raw)
_send_result(connection, msg["id"], "store_download_device", {"task_id": task.id})
async def _store_download_task(
hass: HomeAssistant, task: Any, entry_id: str, manager: Any, device_id: str,
device_type: str, include_settings: bool,
) -> None:
reg = task_registry.get_registry(hass)
try:
async with _entry_write_lock(hass, entry_id):
if _get_manager(hass, entry_id) is not manager:
reg.finish(task, state=task_registry.STATE_ERROR, error="device reloaded")
return
res = await manager.store_bridge.download_device(
device_id, device_type,
progress=lambda done, total: reg.update(task, done=done, total=total),
should_cancel=lambda: task.cancel_requested,
)
res = {**res, "settings_applied": await _apply_store_settings(
hass, entry_id, res, include_settings,
)}
manager.notify_update()
reg.finish(
task,
state=task_registry.STATE_CANCELLED if res.get("cancelled")
else task_registry.STATE_DONE,
result=res,
)
except Exception as exc: # pylint: disable=broad-exception-caught
reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc))
async def _apply_store_settings(
hass: HomeAssistant, entry_id: str, res: dict[str, Any], include_settings: bool,
) -> int:
"""Write a bundle's opted-in settings; returns how many were applied."""
from .const import sanitize_shared_settings # noqa: PLC0415
settings_applied = 0
if 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 = sanitize_shared_settings(bundle_settings)
entry = _get_entry(hass, entry_id)
if filtered and entry is not None:
# Same critical section as ws_set_options: the changelog snapshot and
# the options write have to be atomic per entry, or a concurrent
# writer records the same "old" value and one of the two updates is
# silently lost (#442 follow-up).
async with _entry_options_lock(hass, entry_id):
filtered = _without_inverted_threshold_pair(
entry, filtered, "store_download"
) # item 515
await _record_option_changes(hass, entry, filtered, "store_download")
hass.config_entries.async_update_entry(
entry, options={**entry.options, **filtered}
)
settings_applied = len(filtered)
return settings_applied
@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,
ws_set_lifetime_cycle_count,
ws_add_maintenance_task, ws_update_maintenance_task, ws_delete_maintenance_task,
# 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,
# Selective export/import wizard (inventory + analyze + selective export/import)
ws_get_export_inventory, ws_analyze_import,
ws_export_config_selective, ws_import_config_selective,
# "Undo last import" (register item 195)
ws_undo_import,
# Shared constants
ws_get_constants,
# Suggestions
ws_get_suggestions, ws_apply_suggestions, ws_clear_suggestions, ws_run_suggestion_analysis,
ws_set_suggestion_lock,
# Cycle curve / interactive editing
ws_get_cycle_power_data, ws_trim_cycle, ws_analyze_split, ws_apply_split, ws_apply_merge,
# Recorder history around a stored cycle, display only (item 513)
ws_get_cycle_context,
# 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 + models revert)
ws_get_ml_training_status, ws_trigger_ml_training, ws_revert_ml_models,
# Cycle controls (pause / resume / force-stop)
ws_pause_cycle, ws_resume_cycle, ws_terminate_cycle,
# Playground settings control panel: live values + named presets
ws_get_playground_settings, ws_save_playground_preset, ws_delete_playground_preset,
# Background-task registry (progress / cancel / reconnect-safe results)
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_profiles,
ws_store_confirm_device, ws_store_rate_device, ws_store_set_online,
ws_store_set_prefs,
# Catalog identity badges (point reads) + manual cache refresh
ws_store_get_catalog_entry, ws_store_refresh_catalog,
# Historical power-data import: staged ingest, background scan, apply
ws_history_import_begin, ws_history_import_chunk, ws_history_import_recorder,
ws_start_history_import_scan, ws_apply_history_import,
]
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,
# Declared non-optional in DeviceInfo, so it is defaulted HERE rather
# than at each exit: a device with no loaded manager never reaches
# the probe at all, which is the case the contract test caught.
"standby_above_stop": None,
"current_program": None,
"time_remaining_s": None,
"total_duration_s": None,
"expected_duration_s": None,
"current_power_w": None,
"cycle_progress_pct": None,
"suggestions_count": 0,
# Which keys those are, so the panel can merge them with the
# Calibrated (ML) recommendations it computes client-side without
# double-counting a key both engines suggest.
"suggestion_keys": [],
"feedback_count": 0,
"recording": False,
"is_user_paused": False,
"manual_program": False,
"armed_program": None,
# Defaulted here rather than only in the manager branch below: both are
# declared non-optional by DeviceInfo (ws_schema.py) and ws-types.d.ts,
# and the branch is skipped for a manager-less entry (mid-setup, stale,
# or a setup that failed) as well as short-circuited by its own except.
"envelope_position": None,
# Top two of an undecided live match (MATCH-DECIDE-15): display only.
"match_uncertainty": None,
# Merged data+options, matching ``ws_get_options`` and the
# options-first resolution in WashDataManager (#450). An entry added
# after its last schema migration carries the structural keys in
# ``entry.data`` only, so serving bare options handed the panel a
# device_type fallback that did not match the running manager.
"options": {**entry.data, **entry.options, CONF_NAME: entry.title},
# Device-resolved defaults for the cadence/ratio fields (#396/#393) so the
# device-list conflict/suggestion badges score an unset field against the
# value the integration would actually use, matching the Settings tab.
"option_defaults": _resolved_option_defaults(
{**entry.data, **entry.options}.get(CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE)
),
}
if manager is not None:
try:
detector = getattr(manager, "detector", None)
if detector is not None:
# What the entities show (item 501): a hidden standby re-probe
# stays "off" here too instead of flickering the Status card.
info["detector_state"] = getattr(detector, "exposed_state", detector.state)
# Matching it (#452: "Stalled" while a stalled cycle shows paused).
info["sub_state"] = getattr(detector, "exposed_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))
# A program pinned for the NEXT cycle (#411). Kept separate from
# current_program so an idle device does not claim to be running one.
info["armed_program"] = getattr(manager, "armed_program", None)
info["time_remaining_s"] = getattr(manager, "_time_remaining", None)
info["total_duration_s"] = getattr(manager, "_total_duration", None)
# The matched program's expected length: the span the phase sensor
# maps progress onto, so the Status timeline names the same phase
# (audit PROGRESS-10).
info["expected_duration_s"] = getattr(
manager, "_matched_profile_duration", None
)
# Read through the property, not the raw cache (#409): it falls back
# to the sensor's live state so the panel can never show a power the
# sensor never reported.
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)
# Where the run maps onto the matched profile's own curve (item 269).
# Independent of elapsed time, so it stays meaningful when a cycle
# over- or under-runs; refreshed only during low-power phases, which
# the panel's tooltip says.
info["envelope_position"] = getattr(manager, "envelope_position", None)
# "Uncertain: X or Y, ~N% sure" on the Status card while the live
# match is undecided (MATCH-DECIDE-15). The sensor state keeps its
# raw `detecting...` value, which automations compare against.
_unc = getattr(manager, "match_uncertainty", None)
info["match_uncertainty"] = _unc if isinstance(_unc, dict) else None
store = getattr(manager, "profile_store", None)
if store is not None:
# Assigned per entry and OUTSIDE both probes. It used to be
# set inside the suggestion-badge `try`, after
# `store.get_suggestions()`: if that raised, `merged` kept
# the PREVIOUS entry's options and the standby probe below
# read another device's stop threshold - or, on the first
# entry, raised NameError into a debug-level log.
merged = {**entry.data, **entry.options}
try:
# The same filter as the Settings list, so the Overview
# card can never disagree with the Settings tab banner.
keys = [
k for k, _i, _s, _c in _visible_suggestions(
store, merged,
getattr(manager, "device_type", None) or DEFAULT_DEVICE_TYPE,
)
]
info["suggestion_keys"] = keys
info["suggestions_count"] = len(keys)
except Exception: # pylint: disable=broad-exception-caught
pass
try:
# Imported here rather than at module scope to keep the
# ws_api import graph free of suggestion_engine, as every
# other use in this file does.
from .suggestion_engine import ( # pylint: disable=import-outside-toplevel
detect_standby_above_stop,
)
# #445 cause 1: an appliance whose standby draw sits ABOVE
# stop_threshold_w can never finish a cycle on its own,
# because the off delay only starts once power is below it.
# Surfaced as its own attention card rather than folded into
# a suggestion: no threshold value can fix the case where the
# appliance's idle and working power are the same level, so
# this explains rather than proposes.
# Resolved in steps, not as an inline `.get(key, default)`:
# Python evaluates that default eagerly, so a detector
# without a bound `config` raised AttributeError before the
# option was even consulted - and the handler below turned
# that into a silently absent advisory.
_stop_w = merged.get(CONF_STOP_THRESHOLD_W)
if _stop_w is None:
_cfg = getattr(getattr(manager, "detector", None), "config", None)
_stop_w = getattr(_cfg, "stop_threshold_w", 0.0)
info["standby_above_stop"] = detect_standby_above_stop(
store.get_past_cycles() or [],
float(_stop_w or 0.0),
)
except Exception: # pylint: disable=broad-exception-caught
# Logged, not swallowed silently: this advisory shipped dead
# for a round because a NameError here was indistinguishable
# from "no pattern found".
_LOGGER.debug("standby_above_stop probe failed", exc_info=True)
# Key already defaulted to None where `info` is built.
try:
# Count only pending feedback whose cycle still exists, so the
# badge cannot outrun the review list after a cycle is deleted,
# merged or split (#362). Defense-in-depth on top of the prune in
# those mutation paths; also self-heals pre-existing orphans.
_pend = store.get_pending_feedback() or {}
_live = {c.get("id") for c in store.get_past_cycles()}
info["feedback_count"] = sum(1 for cid in _pend if cid in _live)
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)),
vol.Optional("imported_offset"): 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.
Real cycles are returned most-recent-first and sliced ``[offset : offset+limit]``
so the panel can page. ``total`` is the device's real cycle count and
``has_more`` is True when real cycles remain beyond the returned window.
Imported cycles (``reference_cycles`` + ``backfill_cycles``) page on their own
cursor, ``imported_offset``, over one newest-start-first list of both, with the
same ``limit``; ``imported_total`` / ``imported_has_more`` describe it. They keep
their own cursor because they stay out of ``total`` (they never enter usage
stats), so folding them into ``offset`` would change what ``total`` counts.
A client that omits ``imported_offset`` gets the pre-paging behaviour: every
imported cycle on ``offset == 0`` and none after (register item 129a).
"""
entry_id: str = msg["entry_id"]
limit: int = msg.get("limit", 50)
offset: int = msg.get("offset", 0)
imported_offset: int | None = msg.get("imported_offset")
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]] = []
backfill_cycles: list[dict[str, Any]] = []
total = 0
imported_total = 0
imported_end = 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 and cycles recovered from raw history are kept
# out of `cycles`/`total` (they never enter usage stats), tagged so the
# panel can badge them and route edits/deletes correctly. They travel in
# separate arrays because they are separate categories: a curated community
# template and an auto-detected segment from the user's own past are not
# the same claim about a cycle.
imported = _imported_cycles_newest_first(store)
imported_total = len(imported)
if imported_offset is not None:
imported_window = imported[imported_offset:imported_offset + limit]
imported_end = imported_offset + len(imported_window)
elif offset == 0:
imported_window = imported
imported_end = imported_total
else:
imported_window = []
imported_end = imported_total
for c, origin in imported_window:
item = _strip_cycle(c)
item.update(_cycle_capabilities(c, origin))
(reference_cycles if origin == "reference" else backfill_cycles).append(item)
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,
"backfill_cycles": backfill_cycles,
"total": total,
"has_more": has_more,
"imported_total": imported_total,
"imported_has_more": imported_end < imported_total,
},
)
def _imported_cycles_newest_first(store: Any) -> list[tuple[dict[str, Any], str]]:
"""Reference + backfill cycles as ``(cycle, origin)``, newest start first.
One ordering for both lists so a page holds the most recent imports whatever their
category. Storage order (reversed, newest-added first) breaks ties and places
cycles whose ``start_time`` does not parse last, so the order is total and stable
across requests, which paging needs.
"""
rows: list[tuple[dict[str, Any], str]] = []
for origin, getter in (
("reference", "get_reference_cycles"),
("backfill", "get_backfill_cycles"),
):
seq = getattr(store, getter)()
if not isinstance(seq, list):
continue
rows.extend((c, origin) for c in reversed(seq) if isinstance(c, dict))
def _start_ts(row: tuple[dict[str, Any], str]) -> float:
value = row[0].get("start_time")
try:
if isinstance(value, (int, float)) and not isinstance(value, bool):
return float(value) # legacy numeric unix timestamp
parsed = dt_util.parse_datetime(str(value or ""))
# Aware stamps only: a naive one would compare in local time.
if parsed is not None and parsed.tzinfo is not None:
return parsed.timestamp()
except (TypeError, ValueError, OverflowError, OSError):
pass
return float("-inf")
# sorted() is stable, so equal starts keep the newest-added-first order above.
return sorted(rows, key=_start_ts, reverse=True)
# ─── Settings ─────────────────────────────────────────────────────────────────
def _resolved_option_defaults(device_type: str) -> dict[str, Any]:
"""Device-resolved defaults for the cadence/ratio settings whose default varies
by device type (#396/#393).
The panel uses these as the render/conflict-check/suggestion-comparison fallback
for an unset field, so it shows - and validates against - the value the
integration would actually use, not a static schema literal that would spuriously
trip (or silently miss) the watchdog>=2*sampling / start_duration>=sampling rules
on a coarse-sampling device type. Shared by ws_get_options (current device) and
ws_get_devices (per device) so the two never diverge.
"""
return {
CONF_SAMPLING_INTERVAL: resolve_sampling_interval_default(device_type),
CONF_WATCHDOG_INTERVAL: resolve_watchdog_interval_default(device_type),
CONF_START_DURATION_THRESHOLD: resolve_start_duration_default(device_type),
CONF_SMART_TERMINATION_DURATION_RATIO: (
resolve_smart_termination_duration_ratio_default(device_type)
),
# #429: deliberately NOT device-resolved (the safe value is a property of
# the individual machine), but the panel pre-populates it from the same
# payload, so it is published here rather than left to the JS default.
CONF_ANTI_CREASE_FINALIZE_RATIO: DEFAULT_ANTI_CREASE_FINALIZE_RATIO,
# Item 311 raised this to 1.8 in Python and the panel's schema literal
# stayed at 1.5, so an entry without the key rendered 1.5 while the
# matcher used 1.8. Not device-resolved either; published for the same
# reason as the line above, so the constant is the only source and the
# two cannot drift again. Round 17 removed the migration seed that had
# been hiding this for legacy entries.
CONF_PROFILE_MATCH_MAX_DURATION_RATIO: (
DEFAULT_PROFILE_MATCH_MAX_DURATION_RATIO
),
# #445: the pair that decides when a cycle is allowed to end. The detector
# waits max(off_delay, min_off_gap), so an unset min_off_gap silently
# raises a hand-lowered off_delay to the per-device prior - and with the
# key absent here the panel rendered an empty field and the cross-field
# rule's `!= null` guard short-circuited, so nothing anywhere showed the
# number that was actually governing the wait.
CONF_MIN_OFF_GAP: resolve_min_off_gap_default(device_type),
CONF_OFF_DELAY: resolve_off_delay_default(device_type),
}
@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
# #422: the display name is carried by ``entry.title``, never by options -
# ``_merge_structural_options`` and ``_OPTIONS_IDENTITY_KEYS`` strip CONF_NAME
# back out on every save. ``entry.data[CONF_NAME]`` is therefore a fossil from
# ``async_create_entry`` that no rename path updates, and being data-only it is
# the one key nothing in options can shadow. Pin it to the title so Settings ->
# Basic shows the same name as the device list (which serves entry.title) and a
# save can never echo the creation-time name back over the current one.
options = {**entry.data, **entry.options, CONF_NAME: entry.title}
# Device-resolved defaults for the cadence settings whose defaults vary by
# device type (#396). The panel uses these as the render/conflict-check
# fallback for an unset field so it shows (and validates against) the value the
# integration would actually use - not a static schema literal that would
# spuriously trip the panel's own watchdog>=2*sampling / start_duration>=sampling
# rules on a coarse-sampling device type.
device_type = options.get(CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE)
_send_result(
connection,
msg["id"],
"get_options",
{"options": options, "defaults": _resolved_option_defaults(device_type)},
)
@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
# Serialised per entry (#442 follow-up): the snapshot below, the changelog
# write and `async_update_entry` must be one critical section. Without it
# two concurrent writers both read the same `entry.options`, both record the
# same "old" value in the settings history, and the second update silently
# overwrites the first - so a later per-setting revert restores a value that
# was never current. The import handlers already hold this lock; the option
# writers did not. acquire/release rather than `async with` so the handler
# keeps its early-return validation paths. The OPTIONS lock, not the write
# lock: see `_entry_options_lock` for why a save must not queue behind a
# reprocess or an ML training run.
lock = _entry_options_lock(hass, msg["entry_id"])
await lock.acquire()
try:
# 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"]}
# A numeric setting must be a finite number (audit PLATFORM-13): stored,
# "abc" raised in the manager's constructor and the entry never set up
# again. A bad value drops the key so its default applies - the contract
# the per-key blocks below already follow; None / "" reach their reset path.
_numeric = numeric_option_keys()
for _key, _val in msg["options"].items():
if _key not in _numeric or _val is None or _val == "":
continue
_num = coerce_numeric_option(_val, _numeric[_key])
if _num is None:
new_options.pop(_key, None)
else:
new_options[_key] = _num
# 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_UNLOAD_CONFIRM_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)
# Numeric-finite validation for fields that the cycle-detector float()-casts at
# build time; coerce bad submissions to the compiled default so storage stays clean.
if CONF_DISHWASHER_END_SPIKE_QUIET_RELEASE in new_options:
try:
_qr = float(new_options[CONF_DISHWASHER_END_SPIKE_QUIET_RELEASE])
if not math.isfinite(_qr):
raise ValueError("non-finite")
new_options[CONF_DISHWASHER_END_SPIKE_QUIET_RELEASE] = _qr
# OverflowError too, same reason as the anti-crease block below
# (register item 194): an unbounded int from JSON raises on float()
# rather than returning inf, and uncaught it fails the WHOLE save
# with unknown_error instead of dropping this one key.
except (TypeError, ValueError, OverflowError):
new_options[CONF_DISHWASHER_END_SPIKE_QUIET_RELEASE] = (
DISHWASHER_END_SPIKE_QUIET_RELEASE_SECONDS
)
# Smart-Termination duration ratio (#393): fraction of expected duration, so it
# is meaningless outside [0.50, 1.00] - clamp valid submissions to the range.
# An empty or non-numeric value drops the key so the device-type default
# (resolved in the config builder, 0.99 dishwasher / 0.98 other) applies again;
# coercing to a single scalar default here would be wrong for dishwashers.
if CONF_SMART_TERMINATION_DURATION_RATIO in new_options:
_raw_str = new_options[CONF_SMART_TERMINATION_DURATION_RATIO]
if _raw_str in (None, ""):
new_options.pop(CONF_SMART_TERMINATION_DURATION_RATIO, None)
else:
try:
_str = float(_raw_str)
if not math.isfinite(_str):
raise ValueError("non-finite")
new_options[CONF_SMART_TERMINATION_DURATION_RATIO] = min(
1.0, max(0.5, _str)
)
# OverflowError too, see the anti-crease block below.
except (TypeError, ValueError, OverflowError):
new_options.pop(CONF_SMART_TERMINATION_DURATION_RATIO, None)
# #429: the anti-crease finalise ratio is the same kind of value against the
# same kind of mean, and equally meaningless outside [0.50, 1.00]. An empty or
# non-numeric submission drops the key so DEFAULT_ANTI_CREASE_FINALIZE_RATIO
# applies again.
if CONF_ANTI_CREASE_FINALIZE_RATIO in new_options:
_raw_ac = new_options[CONF_ANTI_CREASE_FINALIZE_RATIO]
if _raw_ac in (None, ""):
new_options.pop(CONF_ANTI_CREASE_FINALIZE_RATIO, None)
else:
try:
_ac = float(_raw_ac)
if not math.isfinite(_ac):
raise ValueError("non-finite")
new_options[CONF_ANTI_CREASE_FINALIZE_RATIO] = min(
ANTI_CREASE_FINALIZE_RATIO_MAX,
max(ANTI_CREASE_FINALIZE_RATIO_MIN, _ac),
)
# OverflowError too (register item 194): json parses an integer literal
# of any length into an unbounded int, and float() on one of those raises
# rather than returning inf. Uncaught it becomes ERR_UNKNOWN_ERROR and
# the whole save fails, instead of this key falling back to its default.
except (TypeError, ValueError, OverflowError):
new_options.pop(CONF_ANTI_CREASE_FINALIZE_RATIO, None)
# #430: seconds, 0 = off. Clamped to [0, CURVE_PREROLL_MAX_SECONDS] so a
# mistyped value cannot drag minutes of unrelated standby into a curve; empty
# or non-numeric drops the key and restores the default (off).
if CONF_CURVE_PREROLL_SECONDS in new_options:
_raw_pr = new_options[CONF_CURVE_PREROLL_SECONDS]
if _raw_pr in (None, ""):
new_options.pop(CONF_CURVE_PREROLL_SECONDS, None)
else:
try:
_pr = float(_raw_pr)
if not math.isfinite(_pr):
raise ValueError("non-finite")
new_options[CONF_CURVE_PREROLL_SECONDS] = min(
CURVE_PREROLL_MAX_SECONDS, max(0.0, _pr)
)
except (TypeError, ValueError, OverflowError):
new_options.pop(CONF_CURVE_PREROLL_SECONDS, None)
# A None outside the clearable selectors means "not set", not a value: the
# per-setting Revert sends the changelog's `old`, which is null for a setting
# never saved before. Stored, it would survive options.get(key, DEFAULT) and
# break the float()/int() casts at setup, so drop the key and let the default
# apply again; this also cleans nulls persisted by earlier builds.
_pre_strip_keys = set(new_options)
new_options = strip_null_options(new_options)
dropped_null_keys = _pre_strip_keys - set(new_options)
# 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)
# Register item 515: a save must not leave the stop threshold at or above
# the start threshold. The panel's own conflict check holds such an edit
# back, but the Playground publish/sweep, the per-setting Revert and any
# other client wrote it straight through. An entry already running an
# inverted pair keeps saving every other setting (see the helper).
_inverted = inverted_threshold_pair(
{**entry.data, **entry.options},
{**entry.data, **new_options},
effective_device_type,
)
if _inverted is not None:
connection.send_error(
msg["id"], "invalid_threshold_pair", _threshold_pair_message(*_inverted)
)
return
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.
# Through the shared helper, not a second copy of it. The per-setting
# Revert trusts this history, so the two writers have to follow one
# contract - and `_record_option_changes`'s own docstring already says it
# replaces the inline version that used to live here. Keys dropped by the
# null-strip above are still recorded as a change to None ("reverted to
# unset"); a None -> None no-op is skipped inside `_diff_option_changes`.
submitted_post = {
k: new_options.get(k)
for k in msg["options"]
if k in new_options or k in dropped_null_keys
}
await _record_option_changes(hass, entry, submitted_post, "set_options")
# 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
# Warning, not a silent pass: the user asked for this identity to
# be removed, and a bare `pass` here leaves it in storage with no
# trace. A bare `pass` is also what hid the #445 standby advisory
# being dead for a whole review round (register item 325).
_LOGGER.warning(
"Could not clear the store account for %s after online "
"features were disabled; the stored identity may remain",
msg["entry_id"],
exc_info=True,
)
hass.config_entries.async_update_entry(entry, **update_kwargs)
_send_result(connection, msg["id"], "set_options", {"success": True})
finally:
lock.release()
@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())
# The evidence view, split back into its three lists: every advisor check asks
# "can this profile be matched", which is what has_real_profiles answers with the
# same view, so the card's phase0 coincides with the manager skipping matching.
# Backfilled history is this machine's own (item 129d); before, only past_cycles
# were read and an import-only device was told to start recording.
evidence = {id(c) for c in store.iter_evidence_cycles()}
past_cycles = [c for c in store.get_past_cycles() if id(c) in evidence]
backfill_cycles = [c for c in store.get_backfill_cycles() if id(c) in evidence]
known_profiles = set(profile_names)
ref_names: set[str] = {
rc["profile_name"]
for rc in store.get_reference_cycles()
if id(rc) in evidence and rc.get("profile_name") in known_profiles
}
# Read from a loop-side snapshot: the executor must not iterate live store
# dicts (audit PERF-10).
coverage_gap = await hass.async_add_executor_job(
_store_read_snapshot(store).suggest_coverage_gaps
)
# suggestions: read from the store (cheap dict lookup; heavy computation happens
# in the SuggestionEngine background task, not here).
_entry = _get_entry(hass, msg["entry_id"])
_merged = {**_entry.data, **_entry.options} if _entry is not None else {}
suggestions = [
item for _k, item, _s, _c in _visible_suggestions(
store, _merged, getattr(manager, "device_type", None) or DEFAULT_DEVICE_TYPE
)
]
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,
skipped_steps=skipped_steps,
now=dt_util.now(),
backfill_cycles=backfill_cycles,
)
_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,
})
def _store_read_snapshot(store: Any) -> Any:
"""A copy of ``store`` an executor job can read while the loop keeps writing.
Loop-only. Shallow-copies the store object and gives it a ``_data`` whose
containers are copies two levels deep: every top-level list and dict, and every
dict inside them (each cycle, profile, envelope). The executor can then iterate
what it reads while the loop appends a cycle, adds a profile or sets a key on a
cycle (audit PERF-10, same class as register items 82 and 264). Large values
(power traces, envelope arrays) are shared, not copied: the loop replaces them
rather than mutating them in place. Cache attributes the store's methods set on
``self`` land on the copy and are dropped with it. Anything without a real
``_data`` dict (a test double) is returned as is.
"""
data = getattr(store, "_data", None)
if not isinstance(data, dict):
return store
def _copy(value: Any) -> Any:
if isinstance(value, list):
return [dict(v) if isinstance(v, dict) else v for v in value]
if isinstance(value, dict):
return {k: (dict(v) if isinstance(v, dict) else v) for k, v in value.items()}
return value
snapshot = copy.copy(store)
snapshot._data = {key: _copy(value) for key, value in data.items()} # pylint: disable=protected-access
return snapshot
# ─── 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
# Everything below reads the store from an executor thread while the loop keeps
# appending cycles and rebuilding envelopes, and each block swallows its own
# error - so "dictionary changed size during iteration" used to show up as a
# silently empty health/trends/advisories panel (audit PERF-10). The executor
# reads a snapshot taken here, on the loop; list_profiles is cached and stays
# on the loop.
profiles: list[dict[str, Any]] = []
try:
profiles = manager.profile_store.list_profiles()
# #158: the panel shows a computed duration read-only (rows are copies).
learned = manager.profile_store.learned_duration_profiles()
for row in profiles:
row["duration_learned"] = row.get("name") in learned
except Exception as exc: # pylint: disable=broad-exception-caught
_LOGGER.debug("Error listing profiles for %s: %s", entry_id, exc)
store = _store_read_snapshot(manager.profile_store)
def _compute_stats() -> dict[str, Any]:
health: dict[str, dict] = {}
try:
health = store.compute_profile_health()
except Exception: # pylint: disable=broad-exception-caught
pass
trends: dict[str, dict] = {}
try:
trends = store.compute_profile_trends()
except Exception: # pylint: disable=broad-exception-caught
pass
# Coverage gaps: unlabelled recent cycles that look like one program the user
# has not created. Measured by devtools/advisory_usefulness_eval.py: 92-95%
# of its clusters are the missing program, and it stays silent on intact
# stores, so the Profiles tab shows them (audit UI-13, register item 432).
coverage_gaps: dict[str, Any] = {}
try:
coverage_gaps = store.suggest_coverage_gaps()
except Exception: # pylint: disable=broad-exception-caught
pass
advisories: list[dict] = []
try:
# Reuse the health/trends computed above instead of recomputing both.
advisories = store.compute_profile_advisories(health, trends)
except Exception: # pylint: disable=broad-exception-caught
pass
# How each programme ENDS, measured from its own cycles. Read-only: no
# detection path consults it, and `consistency` is there to be read first,
# because the event it describes is present in only some runs of the same
# programme (register item 238).
terminal: dict[str, Any] = {}
try:
for _profile in profiles:
# `profiles` is list_profiles(): a list of profile DICTS, not names.
# Passing the dict straight in compared it against each cycle's
# `profile_name` string, which can never match, so the feature
# returned {} for every device while raising nothing.
_name = _profile.get("name") if isinstance(_profile, dict) else None
if not _name:
continue
_sig = store.compute_profile_terminal_signature(_name)
if _sig is not None:
terminal[_name] = _sig
except Exception: # pylint: disable=broad-exception-caught
terminal = {}
# How often the matcher labelled one of this appliance's own cycles with
# each program (STORE-21): the panel shows it on imported program cards so
# an import that never fits can be pruned. Local only, no store writes.
matcher_counts: dict[str, int] = {}
try:
matcher_counts = store.matcher_label_counts()
except Exception: # pylint: disable=broad-exception-caught
matcher_counts = {}
return {
"profiles": profiles,
"profile_health": health,
"profile_trends": trends,
"coverage_gaps": coverage_gaps,
"profile_advisories": advisories,
"profile_terminal": terminal,
"profile_matcher_counts": matcher_counts,
}
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 and cohesion. (Near-duplicate group
suggestions were deleted in 0.5.8, register item 432.)"""
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
})
# Group suggestions were computed on every Profiles visit and rendered nowhere
# (audit UI-13), so they are no longer sent.
_send_result(connection, msg["id"], "get_profile_groups", {
"groups": groups,
"min_cohesion": GROUP_MIN_COHESION,
})
@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
device_type = manager.device_type
# The reminders that actually apply (#461): the device type's preset until the
# config is saved, the saved config after. Until #461 this merged the washer
# defaults over the saved config, so a partial config showed rows the manager
# never evaluated.
reminders = effective_reminders(
device_type, manager.config_entry.options.get(CONF_MAINTENANCE_REMINDER_CYCLES)
)
store = manager.profile_store
status = store.get_maintenance_status(reminders)
_send_result(connection, msg["id"], "get_maintenance_log", {
"log": store.get_maintenance_log(),
"due": [row["id"] for row in status if row["due"]],
# The built-in types this device's editor offers (device-type aware).
"event_types": editor_types(device_type, reminders),
"reminders": reminders,
# Cycles run since each task was last done, and the odometer they are
# measured against (#414). Computed backend-side before this and never
# sent, so the panel could show "due" but never "how close".
"cycles_since": {
evt: store.cycles_since_maintenance(evt) for evt in MAINTENANCE_EVENT_TYPES
},
"custom_tasks": store.get_maintenance_tasks(),
# Every active reminder (built-in and custom) with its progress.
"status": status,
"lifetime_cycle_count": manager.lifetime_cycle_count,
"limits": {
"tasks_max": MAINTENANCE_CUSTOM_TASK_MAX,
"name_max": MAINTENANCE_TASK_NAME_MAX,
"cycles_max": MAINTENANCE_INTERVAL_CYCLES_MAX,
"days_max": MAINTENANCE_INTERVAL_DAYS_MAX,
},
})
@websocket_api.websocket_command(
{
vol.Required("type"): "ha_washdata/set_lifetime_cycle_count",
vol.Required("entry_id"): str,
vol.Required("count"): vol.All(vol.Coerce(int), vol.Range(min=0, max=1000000)),
}
)
@websocket_api.async_response
async def ws_set_lifetime_cycle_count(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Correct the appliance's lifetime cycle odometer (requires full access).
The one sanctioned way the count moves other than a cycle completing (#414).
It exists because WashData cannot always tell a real short run from an
artefact, and because an appliance may have run for years before WashData was
installed - rather than asking "was that a real cycle?" on every deletion, the
user corrects the total once. Recorded in the settings changelog.
"""
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
count = int(msg["count"])
# Serialize the whole read-check-mutate-save-rollback under the per-entry
# write lock. The rollback below restores the value read at the start, so two
# corrections that interleave across the save could see the earlier one's
# failure undo the later one's success (A reads 10, B reads and writes 4000
# successfully, A's save fails and A restores 10). Holding the lock across the
# await is the point; taking it only around the mutation would not help.
async with _entry_write_lock(hass, msg["entry_id"]):
previous = store.get_lifetime_cycle_count()
# A stored record is evidence of a run, so the odometer can never read below
# the number of records on hand - that floor is what get_lifetime_cycle_count
# applies. Without this check the setter accepted a lower value, the getter
# masked it while the history was long, and it then surfaced later once
# records were deleted: a correction that appeared to do nothing and took
# effect retroactively, i.e. the odometer regression #414 exists to prevent.
# Refused with the floor named rather than silently clamped, so the user is
# told why their number was not taken.
floor = len(store.get_past_cycles())
if count < floor:
connection.send_error(
msg["id"],
"invalid_format",
f"Cannot set the lifetime count below the {floor} cycle records "
f"currently stored; delete records first or choose {floor} or more",
)
return
store.set_lifetime_cycle_count(count, force=True)
try:
# ONE save for both mutations: async_record_settings_changes persists the
# store, and the new count is already staged in memory. Saving separately
# beforehand meant a changelog failure reported unknown_error on a
# correction that had in fact been written, and left `previous` reading
# the new value on a retry - recording old == new.
await store.async_record_settings_changes(
[{"key": "lifetime_cycle_count", "old": previous, "new": count}]
)
except Exception:
# Undo only OUR write. A cycle completing during the await bumps the
# same counter (manager._async_process_cycle_end -> lifetime energy
# save), and that path deliberately does not take this lock: it lives
# in the manager, on the hot cycle-end path, and reaching into the WS
# layer's lock from there would invert the layering for a window this
# narrow. Compare-and-swap keeps the rollback honest without it - if
# the value is no longer what we wrote, someone else owns it now and
# restoring `previous` would discard their increment.
if store.get_lifetime_cycle_count() == count:
store.set_lifetime_cycle_count(previous, force=True)
raise
# Re-validate the manager is still live after the awaited save: a reload
# during it detaches this manager (and its per-entry lock, which
# async_unload_entry pops), so notifying it would target stale state and
# reporting its store's count would show the panel a number from a store
# nothing reads any more. Mirrors the guard the recording-persist and import
# handlers use. Reported as success because the save itself did happen; the
# count comes from whatever store is live now.
current_manager = _get_manager(hass, entry_id)
if current_manager is not manager:
_LOGGER.warning(
"Manager replaced during lifetime-count correction for %s; "
"skipping notify", entry_id,
)
else:
manager.notify_update()
live_store = (
current_manager.profile_store if current_manager is not None else store
)
_send_result(
connection,
msg["id"],
"set_lifetime_cycle_count",
{"success": True, "lifetime_cycle_count": live_store.get_lifetime_cycle_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/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))
# Custom maintenance tasks (#461). Edit level, like the maintenance log: a task is
# a reminder the user defines, never a change to detection or stored cycles.
# Intervals are validated by the store (whole numbers, bounded) so the WS layer,
# the panel and any future service share one rule; a refusal is invalid_format.
_TASK_INTERVAL = vol.Any(None, int, float, str)
@websocket_api.websocket_command(
{
vol.Required("type"): "ha_washdata/add_maintenance_task",
vol.Required("entry_id"): str,
vol.Required("name"): str,
vol.Optional("cycles"): _TASK_INTERVAL,
vol.Optional("days"): _TASK_INTERVAL,
}
)
@websocket_api.async_response
async def ws_add_maintenance_task(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Create a custom maintenance task (free-text name, cycles and/or days)."""
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:
task = await manager.profile_store.async_add_maintenance_task(
msg["name"], msg.get("cycles"), msg.get("days")
)
manager.notify_update()
_send_result(connection, msg["id"], "add_maintenance_task", {"success": True, "task": task})
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/update_maintenance_task",
vol.Required("entry_id"): str,
vol.Required("task_id"): str,
vol.Optional("name"): str,
vol.Optional("cycles"): _TASK_INTERVAL,
vol.Optional("days"): _TASK_INTERVAL,
}
)
@websocket_api.async_response
async def ws_update_maintenance_task(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Rename a custom maintenance task or change its intervals.
An omitted field is left as it is; an explicit ``null`` interval switches
that axis off, the same as 0.
"""
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 _interval(key: str) -> Any:
# The store reads None as "leave it"; on the wire null means "off".
if key not in msg:
return None
return 0 if msg[key] is None else msg[key]
try:
task = await manager.profile_store.async_update_maintenance_task(
msg["task_id"],
name=msg.get("name"),
cycles=_interval("cycles"),
days=_interval("days"),
)
manager.notify_update()
_send_result(connection, msg["id"], "update_maintenance_task", {"success": True, "task": task})
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_task",
vol.Required("entry_id"): str,
vol.Required("task_id"): str,
}
)
@websocket_api.async_response
async def ws_delete_maintenance_task(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Remove a custom maintenance task. Its log entries stay, with their name."""
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_task(msg["task_id"])
if removed:
manager.notify_update()
_send_result(connection, msg["id"], "delete_maintenance_task", {"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
applied_profile: str | None = new_profile_name.strip()
await manager.profile_store.create_profile(applied_profile, cycle_id)
else:
applied_profile = profile_name
await manager.profile_store.assign_profile_to_cycle(cycle_id, profile_name)
# Manually (re)labelling a cycle awaiting verification IS the user's answer,
# so resolve any pending feedback and drop it from the review queue (#331).
if hasattr(manager, "learning_manager"):
await manager.learning_manager.async_resolve_pending_from_label(
cycle_id, applied_profile
)
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))
# The panel's bulk auto-label threshold range (the modal input's min/max).
AUTO_LABEL_MIN_THRESHOLD = 0.5
AUTO_LABEL_MAX_THRESHOLD = 0.95
def configured_auto_label_threshold(entry: ConfigEntry | None) -> float:
"""The device's Auto-Label Confidence, clamped to the bulk pass's range.
The bulk pass and the cycle-end label both put a guess on a cycle with no
confirmation, so they share the user's one bar for that. Never raises.
"""
from .const import ( # pylint: disable=import-outside-toplevel
CONF_AUTO_LABEL_CONFIDENCE,
DEFAULT_AUTO_LABEL_CONFIDENCE,
)
merged = {**(entry.data if entry else {}), **(entry.options if entry else {})}
try:
value = float(merged.get(CONF_AUTO_LABEL_CONFIDENCE, DEFAULT_AUTO_LABEL_CONFIDENCE))
except (TypeError, ValueError, OverflowError):
value = DEFAULT_AUTO_LABEL_CONFIDENCE
if not math.isfinite(value):
value = DEFAULT_AUTO_LABEL_CONFIDENCE
return min(max(value, AUTO_LABEL_MIN_THRESHOLD), AUTO_LABEL_MAX_THRESHOLD)
@websocket_api.websocket_command(
{
vol.Required("type"): "ha_washdata/auto_label_cycles",
vol.Required("entry_id"): str,
vol.Optional("confidence_threshold"): vol.All(
vol.Coerce(float),
vol.Range(min=AUTO_LABEL_MIN_THRESHOLD, max=AUTO_LABEL_MAX_THRESHOLD),
),
}
)
@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.
Without a threshold the device's own Auto-Label Confidence applies (audit
UI-10: the panel used to send a fixed 0.75, below the 0.9 default).
"""
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 = msg.get("confidence_threshold")
if threshold is None:
threshold = configured_auto_label_threshold(_get_entry(hass, entry_id))
task, _raw = start_auto_label_task(hass, entry_id, float(threshold))
_send_result(connection, msg["id"], "auto_label_cycles", {"task_id": task.id})
def start_auto_label_task(
hass: HomeAssistant, entry_id: str, threshold: float
) -> tuple[Any, asyncio.Task[Any] | None]:
"""Start the one auto-label runner (panel and service) as a registry task.
It re-matches every unlabelled stored cycle - 56 s for 201 cycles - so it runs
detached with progress and cancel, under the per-entry write lock that
reprocess, merge and imports hold (audit PLATFORM-05). Never overwrites (audit
MANAGER-01 / UI-10).
"""
reg = task_registry.get_registry(hass)
task = reg.create(
entry_id, "auto_label", "Auto-labelling cycles", label_key="task.auto_label.running",
)
raw = hass.async_create_task(_auto_label_task(hass, task, entry_id, threshold))
if raw is not None:
reg.link_asyncio_task(task.id, raw)
return task, raw
async def _auto_label_task(
hass: HomeAssistant, task: Any, entry_id: str, threshold: float
) -> None:
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
try:
async with _entry_write_lock(hass, entry_id):
if _get_manager(hass, entry_id) is not manager:
reg.finish(task, state=task_registry.STATE_ERROR, error="device reloaded")
return
stats = await manager.profile_store.auto_label_cycles(
threshold,
overwrite=False,
progress=lambda done, total: reg.update(task, done=done, total=total),
should_cancel=lambda: task.cancel_requested,
)
manager.notify_update()
reg.finish(
task,
state=task_registry.STATE_CANCELLED if stats.get("cancelled")
else task_registry.STATE_DONE,
result=stats,
)
except Exception as exc: # pylint: disable=broad-exception-caught
reg.finish(task, state=task_registry.STATE_ERROR, 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,
# Optional since #450: an omitted or empty device_type resolves to
# ``manager.device_type``, the exact value ``ws_get_phase_catalog``
# lists against, so create and list can never disagree about scope.
vol.Optional("device_type", default=""): 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
# #450: a panel served by an entry whose device_type lives only in
# ``entry.data`` used to send the ``'washing_machine'`` fallback here, so the
# phase was stored under a scope the catalog never lists - invisible, yet
# still tripping the duplicate check on the next attempt.
device_type = str(msg.get("device_type") or "").strip() or str(
getattr(manager, "device_type", "") or ""
).strip()
# ...and refuse rather than store under an empty scope. `manager.device_type`
# reads `options.get(CONF_DEVICE_TYPE, data.get(..., DEFAULT))`, and `.get`
# hands back a persisted empty string or null verbatim instead of the default
# - the #389 class that `strip_null_options` exists for. An empty scope is the
# worst outcome available here, not a harmless one: `list_phase_catalog` never
# lists it, so the phase is invisible, yet it still trips the duplicate check
# on the next attempt, so the user cannot create it again either. That is
# exactly the #450 symptom the comment above describes, reached from the
# entry's own options rather than from the panel's payload.
if not device_type:
connection.send_error(
msg["id"],
"invalid_device_type",
"This entry has no usable device type, so a phase created now could "
"not be listed again. Re-save the device settings and retry.",
)
return
try:
await manager.profile_store.async_create_custom_phase(
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()
# The restore point of the last replace import rides along on the fetch that
# already fills the Export / Import card, so the panel needs no second call.
undo = await manager.profile_store.async_get_pre_import_snapshot()
_send_result(
connection, msg["id"], "get_diagnostics", {"stats": stats, "import_undo": undo}
)
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=6, 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: energy costs",
label_key="task.reprocess.costs")
# Recost stored cycles against the recorder's price history (#426). Cheap
# and a no-op unless a dynamic price entity is configured, so it runs
# unconditionally rather than behind yet another switch.
try:
summary["costs_recomputed"] = await manager.async_recompute_cycle_costs()
except Exception as exc: # pylint: disable=broad-exception-caught
_LOGGER.debug("cost recompute 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=5, 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=6)
# 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) -> recost cycles from recorder price history -> 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()
# clear_all_data pops the persisted arm, but the manager's in-memory field is
# the authoritative one, so the pin has to be retired there too or the next
# cycle re-applies a program from before the wipe.
manager.clear_armed_program()
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))
def _export_json(payload: dict[str, Any]) -> str:
"""Serialise an export payload compactly, the way the store is written to disk.
``json.dumps(indent=2)`` put every number of every power trace on its own
indented line, and now that no cycle is dropped (item 463) that is one
multi-megabyte WebSocket frame per click (audit PERF-11). Home Assistant's
orjson encoder is the one ``Store`` saves with, so anything persisted
serialises the same way, and ``import_config``'s ``json.loads`` reads it back
unchanged. It also runs as one C call, so the event loop cannot mutate the
shallow-copied store under it mid-encode the way it could between the
pure-Python encoder's steps.
"""
from homeassistant.helpers.json import json_dumps # pylint: disable=import-outside-toplevel
return json_dumps(payload)
@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(_export_json, payload)
_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))
async def async_apply_imported_entry_options(
hass: HomeAssistant,
entry: ConfigEntry,
config_updates: dict[str, Any],
source: str,
) -> None:
"""Apply an import payload's tunables to ``entry.options``, the one safe way.
Shared by the ``import_config`` WS command and the ``import_config`` service,
which had drifted: the service still rebound the device to the EXPORTER's
power and door sensors (the item-317 "integration silently goes dead"
failure), skipped the options lock and wrote no settings changelog
(audit PLATFORM-02).
Identity never lands in options and local entity/device bindings are dropped:
an import carries the exporter's ids. ``config_updates["entry_data"]`` is
deliberately NOT written to ``entry.data`` - it is the source device's raw,
un-redacted identity; identity changes go through the reconfigure flow.
The caller holds the per-entry write lock; order is always write -> options.
"""
entry_options_updates = dict(config_updates.get("entry_options", {}) or {})
for key in _IMPORT_LOCAL_BINDING_KEYS:
entry_options_updates.pop(key, None)
if not entry_options_updates:
return
async with _entry_options_lock(hass, entry.entry_id):
# Read INSIDE the lock: a snapshot taken before the `async with` suspended
# would silently revert a ws_set_options that committed while we waited.
# A persisted null survives options.get(key, DEFAULT) and breaks setup
# (#389), so the same write-boundary strip as ws_set_options applies.
entry_options_updates = _without_inverted_threshold_pair(
entry, entry_options_updates, source
)
new_options = strip_null_options({**entry.options, **entry_options_updates})
# ...and a non-numeric numeric setting is dropped, same reason (PLATFORM-13).
new_options, _ = drop_invalid_numeric_options(new_options)
await _record_option_changes(hass, entry, entry_options_updates, source)
hass.config_entries.async_update_entry(entry, options=new_options)
@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"])
pre_entry = _get_entry(hass, entry_id)
config_updates = await manager.profile_store.async_import_data(
payload,
# Kept with the restore point so an undo puts them back (item 195).
entry_options=dict(pre_entry.options) if pre_entry else None,
)
result = {
"success": True,
"restore_point_saved": bool(config_updates.get("restore_point_saved")),
}
# 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", result)
return
entry = _get_entry(hass, entry_id)
if entry and config_updates:
await async_apply_imported_entry_options(
hass, entry, config_updates, "import_config"
)
# An old payload re-arms the one-time banked-tail repair, and the
# options write above is the only thing here that could reload the
# entry - so an import that carries no options would otherwise leave
# the imported tails inflating avg_duration until the next restart.
manager.async_schedule_banked_tail_repair()
manager.notify_update()
_send_result(connection, msg["id"], "import_config", result)
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))
# Soft cap on a pasted/uploaded import blob so a huge string can't wedge the
# executor / event loop. Whole-store exports with full traces are a few MB; 64 MB
# is comfortably above any legitimate payload.
_MAX_IMPORT_JSON_BYTES = 64 * 1024 * 1024
@websocket_api.websocket_command(
{vol.Required("type"): "ha_washdata/get_export_inventory", vol.Required("entry_id"): str}
)
@websocket_api.async_response
async def ws_get_export_inventory(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Return this device's per-category data inventory for the export wizard tree."""
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:
opts = dict(entry.options) if entry else {}
# Run the inventory scan synchronously on the event loop. It's a fast walk
# (counts + small per-cycle {id,date,duration} dicts) and staying on the loop
# is inherently safe from concurrent mutation - offloading it to an executor
# would read the live, mutable store lists from another thread and could raise
# "list changed size during iteration" without a full (expensive) snapshot.
manifest = manager.profile_store.get_export_inventory(opts)
_send_result(connection, msg["id"], "get_export_inventory", {"manifest": manifest})
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/analyze_import",
vol.Required("entry_id"): str,
vol.Required("json_data"): str,
}
)
@websocket_api.async_response
async def ws_analyze_import(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Parse a pasted/uploaded export and describe what it contains (no mutation).
Synchronous (not a detached task): parsing + walking a few-MB file is tens of ms.
Returns ``{"manifest": {...}}``; a structural problem is reported inside the
manifest as ``manifest.error`` so the panel can render it inline.
"""
from .const import CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE
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
json_data: str = msg["json_data"]
if len(json_data.encode("utf-8", "ignore")) > _MAX_IMPORT_JSON_BYTES:
connection.send_error(msg["id"], "invalid_json", "Import payload is too large")
return
entry = _get_entry(hass, entry_id)
device_type = ""
if entry is not None:
device_type = entry.options.get(
CONF_DEVICE_TYPE, entry.data.get(CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE)
)
try:
from .profile_store import build_import_manifest
local_names = list(manager.profile_store.get_profiles().keys())
def _analyze() -> dict[str, Any]:
payload = json.loads(json_data)
return build_import_manifest(
payload, local_device_type=device_type, local_profile_names=local_names
)
manifest = await hass.async_add_executor_job(_analyze)
_send_result(connection, msg["id"], "analyze_import", {"manifest": manifest})
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))
@websocket_api.websocket_command(
{
vol.Required("type"): "ha_washdata/export_config_selective",
vol.Required("entry_id"): str,
vol.Required("selection"): dict,
}
)
@websocket_api.async_response
async def ws_export_config_selective(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Export only the selected categories/items 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)
selection = msg["selection"]
try:
payload = manager.profile_store.export_data(
entry_data=dict(entry.data) if entry else {},
entry_options=dict(entry.options) if entry else {},
selection=selection,
)
json_str = await hass.async_add_executor_job(_export_json, payload)
_send_result(
connection, msg["id"], "export_config_selective", {"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_selective",
vol.Required("entry_id"): str,
vol.Required("json_data"): str,
vol.Required("selection"): dict,
vol.Optional("mode", default="merge"): vol.In(["merge", "replace"]),
vol.Optional("conflict_resolutions", default=dict): dict,
vol.Optional("cycle_destination", default="reference"): vol.In(
["reference", "real_history"]
),
vol.Optional("apply_settings", default=True): bool,
}
)
@websocket_api.async_response
async def ws_import_config_selective(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Selectively import chosen categories/items, merging into existing data."""
from .const import CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE, sanitize_shared_settings
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
json_data: str = msg["json_data"]
if len(json_data.encode("utf-8", "ignore")) > _MAX_IMPORT_JSON_BYTES:
connection.send_error(msg["id"], "invalid_json", "Import payload is too large")
return
# Serialize under the per-entry write lock so the import can't interleave with a
# concurrent reprocess / recording persist (which would corrupt the store).
async with _entry_write_lock(hass, entry_id):
try:
entry = _get_entry(hass, entry_id)
device_type = ""
if entry is not None:
device_type = entry.options.get(
CONF_DEVICE_TYPE, entry.data.get(CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE)
)
payload = await hass.async_add_executor_job(json.loads, json_data)
summary = await manager.profile_store.async_import_data_selective(
payload,
selection=msg["selection"],
mode=msg["mode"],
conflict_resolutions=msg["conflict_resolutions"],
cycle_destination=msg["cycle_destination"],
apply_settings=msg["apply_settings"],
local_device_type=device_type,
# Kept with the restore point of a destructive import so an undo puts
# them back (item 195); never applied to the entry from here.
entry_options=dict(entry.options) if entry is not None else None,
)
# 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 bail out.
current_manager = _get_manager(hass, entry_id)
if current_manager is not manager:
_LOGGER.warning(
"Manager replaced during selective import for %s; aborting notify",
entry_id,
)
_send_result(
connection, msg["id"], "import_config_selective",
{"success": True, "summary": summary},
)
return
# Apply the imported settings subset onto this device's options. Only the
# SHAREABLE numeric allow-list ever reaches here (the store already filters),
# and identity keys are stripped for defense-in-depth. entry.data is never
# written (no power_sensor/device_type hijack).
entry = _get_entry(hass, entry_id)
settings = summary.get("settings") if isinstance(summary.get("settings"), dict) else {}
settings_applied = 0
if entry is not None and settings:
filtered = sanitize_shared_settings(settings)
for key in _OPTIONS_IDENTITY_KEYS:
filtered.pop(key, None)
if filtered:
# Nested inside the write lock this handler already holds;
# order is always write -> options, so no deadlock.
async with _entry_options_lock(hass, entry_id):
filtered = _without_inverted_threshold_pair(
entry, filtered, "store_device_package"
) # item 515
await _record_option_changes(
hass, entry, filtered, "store_device_package"
)
hass.config_entries.async_update_entry(
entry, options={**entry.options, **filtered}
)
settings_applied = len(filtered)
summary = {**summary, "settings_applied": settings_applied}
# Same as the wholesale path: `apply_settings=False` (and any import
# whose settings subset comes back empty) skips the options write
# above, so nothing reloads the entry and the re-armed repair would
# wait for a restart.
manager.async_schedule_banked_tail_repair()
manager.notify_update()
_send_result(
connection, msg["id"], "import_config_selective",
{"success": True, "summary": summary},
)
except json.JSONDecodeError as exc:
connection.send_error(msg["id"], "invalid_json", str(exc))
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))
async def _async_restore_entry_options(
hass: HomeAssistant, entry: ConfigEntry, snapshot_options: dict[str, Any]
) -> None:
"""Put ``entry.options`` back to what they were before the undone import.
Exactly the snapshot's options, so a key the import added goes away again, with
one exception: this device's own entity/device bindings keep their CURRENT
values. The import never wrote them (``_IMPORT_LOCAL_BINDING_KEYS``), so any
change to them since was the user's own, and reverting it could re-point the
device at a sensor it no longer uses. Same write-boundary hygiene as the import.
"""
async with _entry_options_lock(hass, entry.entry_id):
current = dict(entry.options)
restored = {
k: v for k, v in snapshot_options.items() if k not in _IMPORT_LOCAL_BINDING_KEYS
}
restored.update({k: current[k] for k in _IMPORT_LOCAL_BINDING_KEYS if k in current})
restored = strip_null_options(restored)
restored, _ = drop_invalid_numeric_options(restored)
if restored == current:
return
await _record_option_changes(
hass, entry, {k: v for k, v in restored.items() if current.get(k) != v},
"undo_import",
)
hass.config_entries.async_update_entry(entry, options=restored)
@websocket_api.websocket_command(
{vol.Required("type"): "ha_washdata/undo_import", vol.Required("entry_id"): str}
)
@websocket_api.async_response
async def ws_undo_import(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Undo the last replace import: restore the store and options saved before it.
The restore point is consumed, so the panel's undo affordance disappears once
this succeeds. No restore point is a clean ``not_found`` error (register item 195).
"""
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
# A restore rewrites the whole store, so it takes the same per-entry write lock
# as the imports and cannot interleave with a reprocess / recording persist.
async with _entry_write_lock(hass, entry_id):
try:
restored = await manager.profile_store.async_restore_pre_import_snapshot()
result = {
"success": True,
"summary": {
"restored_from": restored.get("restored_from"),
"counts": restored.get("counts") or {},
},
}
# Re-validate after the awaits, as the imports do: the entry may have
# been reloaded and the options belong to the live one.
if _get_manager(hass, entry_id) is not manager:
_LOGGER.warning(
"Manager replaced during import undo for %s; aborting notify", entry_id
)
_send_result(connection, msg["id"], "undo_import", result)
return
entry = _get_entry(hass, entry_id)
snapshot_options = restored.get("entry_options")
if entry is not None and isinstance(snapshot_options, dict):
await _async_restore_entry_options(hass, entry, snapshot_options)
manager.async_schedule_banked_tail_repair()
manager.notify_update()
_send_result(connection, msg["id"], "undo_import", result)
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))
# ─── 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 .frontend import BRAND_ICON_URL as _BRAND_ICON_URL, BRAND_ICON_REGISTERED_KEY as _BRAND_ICON_KEY # pylint: disable=import-outside-toplevel
from .const import STORE_WEB_ORIGIN
from . import store_account
_send_result(connection, msg["id"], "get_constants", {
"version": hass.data.get("ha_washdata_version", _INTEGRATION_VERSION),
"icon_url": _BRAND_ICON_URL if hass.data.get(_BRAND_ICON_KEY) else None,
"device_types": device_types,
"state_colors": dict(STATE_COLORS),
"ml_lab_enabled": SHOW_ML_LAB,
"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. Single source:
# playground.MATCH_DEFAULTS_BY_OPTION (also used by effective_settings).
"pg_match_defaults": dict(playground.MATCH_DEFAULTS_BY_OPTION),
# 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:
for key, item, suggested, current in _visible_suggestions(
manager.profile_store, merged,
getattr(manager, "device_type", None) or DEFAULT_DEVICE_TYPE,
):
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)
try:
locked = manager.profile_store.get_locked_suggestions()
except Exception: # pylint: disable=broad-exception-caught
locked = []
_send_result(
connection, msg["id"], "get_suggestions",
{"suggestions": out, "locked_suggestions": locked},
)
@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:
merged = {**entry.data, **entry.options}
# Only what the Settings list would show: a muted key re-created by the
# reconcile cascade used to be applied here (audit SUGGEST-11).
visible = {
key: item
for key, item, _s, _c in _visible_suggestions(
manager.profile_store, merged,
getattr(manager, "device_type", None) or DEFAULT_DEVICE_TYPE,
)
}
updates: dict[str, Any] = {}
for key in msg["keys"]:
item = visible.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)
)
# Item 515: the reconciler keeps a stored start/stop pair ordered, but
# applying one of the two alone (a subset, a muted cascade, or a pair
# reconciled against options that changed since) could still invert it.
_inverted = inverted_threshold_pair(
merged,
{**merged, **updates},
getattr(manager, "device_type", None) or DEFAULT_DEVICE_TYPE,
)
if _inverted is not None:
connection.send_error(
msg["id"], "invalid_threshold_pair", _threshold_pair_message(*_inverted)
)
return
if updates:
# Clear before updating the entry: async_update_entry schedules a
# reload that rebuilds the store, so persist the cleared state first.
# The odometer, like the cooldown check (audit SUGGEST-05): at the
# retention cap len(past_cycles) never grows past the stamp again.
cycle_count = manager.profile_store.get_lifetime_cycle_count()
manager.profile_store.set_suggestion_apply_cycle_count(cycle_count)
# Same critical section as ws_set_options (#442 follow-up): the
# changelog snapshot and the options write must be atomic per entry.
async with _entry_options_lock(hass, entry_id):
# Record BEFORE clear_suggestions/async_update_entry: both persist,
# and the reload the latter schedules rebuilds the store (#442).
await _record_option_changes(hass, entry, updates, "apply_suggestions")
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/set_suggestion_lock",
vol.Required("entry_id"): str,
vol.Required("key"): str,
vol.Required("locked"): bool,
}
)
@websocket_api.async_response
async def ws_set_suggestion_lock(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Lock or unlock a tuning suggestion key so the auto-tuner stops (or resumes)
proposing it (#343)."""
entry_id: str = msg["entry_id"]
key: str = msg["key"]
# Only LOCKING needs a key the engine can suggest. Unlocking must accept any
# stored key: a mute on a setting that 0.5.8 stopped suggesting (the confidence
# thresholds, sampling interval, ...) could otherwise never be cleared, and
# "Reset muted" failed on it forever.
if msg["locked"] and key not in _SUGGESTION_KEYS:
connection.send_error(msg["id"], "invalid_key", f"Unknown suggestion key: {key!r}")
return
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_suggestion_locked(key, bool(msg["locked"]))
manager.notify_update()
_send_result(
connection, msg["id"], "set_suggestion_lock",
{"success": True, "locked_suggestions": manager.profile_store.get_locked_suggestions()},
)
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, origin = store.find_stored_cycle(cycle_id)
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),
**_cycle_capabilities(cycle, origin),
}
# 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
)
# Only with a trace to draw them on (#459).
meta["artifacts"] = (artifacts or []) if samples else []
# 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)
_ds = _downsample(samples)
# The matched profile's expected curve, projected onto THIS cycle's time axis
# through the same alignment the artifact/conformance comparison used, so the
# overlay, the trace and the artifact shading all share one axis. Computed on
# the full trace but emitted at the thinned x values the panel plots.
if meta.get("profile_name") and len(_ds) >= 4 and len(samples) >= 4:
try:
meta["expected"] = await hass.async_add_executor_job(
manager.profile_store.expected_curve_for_cycle,
meta["profile_name"],
samples,
[float(p[0]) for p in _ds],
)
except Exception as exc: # pylint: disable=broad-exception-caught
_LOGGER.debug("Expected curve for cycle %s failed: %s", cycle_id, exc)
_send_result(connection, msg["id"], "get_cycle_power_data", {
"cycle_id": cycle_id,
"samples": _ds,
# Declare thinning (#395) so a gap from decimation is not mistaken for
# a gap from a sensor that stopped reporting: the panel can show
# "N of M samples" and disambiguate the two.
"sample_count": len(samples),
"decimated": len(_ds) < len(samples),
"full_duration_s": round(float(samples[-1][0]), 1) if samples else 0.0,
**meta,
},
)
# Cycle context (register item 513, discussion #463): the power sensor's recorder
# history just before and just after a stored cycle, for the cycle chart to draw
# greyed either side of the stored trace. The cap bounds the two recorder queries.
_CYCLE_CONTEXT_MAX_S = 3600.0
_CYCLE_CONTEXT_MAX_POINTS = 300
def _cycle_context_points(
rows: list[tuple[float, float | None]],
origin_ts: float,
lo_ts: float,
hi_ts: float,
*,
hi_inclusive: bool,
max_points: int = _CYCLE_CONTEXT_MAX_POINTS,
) -> list[list[float | None]]:
"""Recorder rows inside a window as ``[offset_s, watts]`` from ``origin_ts``.
``watts`` is None at an unavailable (or non-finite) row, so the panel breaks the
line there rather than holding the last reading across an outage. Each run
between two such rows is thinned on its own (``_downsample`` keeps a run's
extrema), so thinning never bridges a gap.
"""
inside = [
(ts, w) for ts, w in rows
if lo_ts <= ts and (ts <= hi_ts if hi_inclusive else ts < hi_ts)
]
total = sum(1 for _ts, w in inside if w is not None) or 1
out: list[list[float | None]] = []
run: list[tuple[float, float]] = []
def _flush() -> None:
if run:
budget = max(4, round(max_points * len(run) / total))
out.extend(_downsample([(ts - origin_ts, w) for ts, w in run], budget))
run.clear()
for ts, w in inside:
if w is None or not math.isfinite(float(w)):
_flush()
if not out or out[-1][1] is not None:
out.append([round(ts - origin_ts, 2), None])
continue
run.append((ts, float(w)))
_flush()
return out
@websocket_api.websocket_command(
{
vol.Required("type"): "ha_washdata/get_cycle_context",
vol.Required("entry_id"): str,
vol.Required("cycle_id"): str,
vol.Optional("before_s", default=600.0): vol.Coerce(float),
vol.Optional("after_s", default=600.0): vol.Coerce(float),
}
)
@websocket_api.async_response
async def ws_get_cycle_context(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""The power sensor's recorder history around one stored cycle (item 513).
``before`` covers ``[start - before_s, start)`` and ``after`` covers
``[trace end, trace end + after_s]`` (clamped to now), both as
``[offset_s, watts]`` from the cycle's stored start: the axis the cycle chart
already plots, so the panel draws them either side of the stored trace.
Display only: it reads the recorder and the cycle's start and trace end, writes
nothing, and nothing it returns reaches duration, energy, matching or envelopes.
Only for a cycle this device observed live (``past``): a community-store
reference was recorded on someone else's machine and a backfill came from an
imported history, so this plug's recorder at that time says nothing about
either. ``available`` is False while the recorder holds no reading for either
window (it keeps 10 days by default; an excluded entity has none), and
``reason`` says why.
"""
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 _window(key: str) -> float:
value = float(msg.get(key) or 0.0)
if not math.isfinite(value):
return 0.0
return max(0.0, min(_CYCLE_CONTEXT_MAX_S, value))
cycle_id: str = msg["cycle_id"]
before_s, after_s = _window("before_s"), _window("after_s")
out: dict[str, Any] = {
"cycle_id": cycle_id,
"available": False,
"reason": None,
"entity_id": None,
"before_s": before_s,
"after_s": after_s,
"trace_end_s": 0.0,
"after_end_s": 0.0,
"before": [],
"after": [],
}
try:
store = manager.profile_store
cycle, origin = store.find_stored_cycle(cycle_id)
entity_id = getattr(manager, "power_sensor_entity_id", None)
start_raw = cycle.get("start_time") if cycle else None
start_dt = dt_util.parse_datetime(str(start_raw)) if start_raw else None
if cycle is None:
out["reason"] = "not_found"
elif origin != "past":
out["reason"] = "not_live"
elif not entity_id:
out["reason"] = "no_sensor"
elif start_dt is None:
out["reason"] = "no_start"
else:
out["entity_id"] = entity_id
start_dt = dt_util.as_utc(start_dt)
start_ts = start_dt.timestamp()
samples = store.get_cycle_power_data(cycle_id)
try:
trace_end = float(samples[-1][0]) if samples else float(cycle.get("duration") or 0.0)
except (TypeError, ValueError, OverflowError):
trace_end = 0.0
trace_end = max(0.0, trace_end) if math.isfinite(trace_end) else 0.0
out["trace_end_s"] = round(trace_end, 2)
if before_s > 0:
lo = start_ts - before_s
rows = await _recorder_power(
hass, entity_id, start_dt - timedelta(seconds=before_s),
end_dt=start_dt, keep_unavailable=True,
)
out["before"] = _cycle_context_points(
rows, start_ts, lo, start_ts, hi_inclusive=False
)
end_dt = start_dt + timedelta(seconds=trace_end)
after_end_dt = min(end_dt + timedelta(seconds=after_s), dt_util.utcnow())
if after_s > 0 and after_end_dt > end_dt:
rows = await _recorder_power(
hass, entity_id, end_dt, end_dt=after_end_dt, keep_unavailable=True,
)
out["after"] = _cycle_context_points(
rows, start_ts, end_dt.timestamp(), after_end_dt.timestamp(),
hi_inclusive=True,
)
out["after_end_s"] = round(after_end_dt.timestamp() - start_ts, 2)
out["available"] = any(p[1] is not None for p in out["before"] + out["after"])
if not out["available"]:
out["reason"] = "no_history"
except Exception as exc: # pylint: disable=broad-exception-caught
_LOGGER.debug("Error building cycle context for %s: %s", cycle_id, exc)
out.update(available=False, reason="no_history", before=[], after=[])
_send_result(connection, msg["id"], "get_cycle_context", out)
@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 []
)
_ds = _downsample(samples)
_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": _ds,
# Declare thinning (#395); see ws_get_cycle_power_data.
"sample_count": len(samples),
"decimated": len(_ds) < len(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",
# Settings: reveal the internal tuning fields (audit UI-02).
"show_internal"):
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"]
# Panel font-size multiplier (accessibility). Coerce + clamp to safe bounds so a
# bad value can never break rendering; without this it was silently dropped and
# the setting vanished on refresh.
if "font_scale" in p:
try:
cur["font_scale"] = max(0.7, min(2.0, float(p["font_scale"])))
except (TypeError, ValueError, OverflowError):
cur.pop("font_scale", None)
# 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
# Cycle chart context length per device (item 513): {entry_id: minutes}. Merged
# into the stored map, so a panel only has to send the device it changed.
ctx = p.get("cycle_context_min")
if isinstance(ctx, dict):
ctx_map = dict(cur.get("cycle_context_min") or {})
for eid, minutes in ctx.items():
if not isinstance(eid, str) or not eid or len(eid) > 64:
continue
if isinstance(minutes, bool) or minutes not in _PREF_CYCLE_CONTEXT_MINUTES:
continue
ctx_map[eid] = int(minutes)
# Bounded: one key per config entry, never more than any real install has.
cur["cycle_context_min"] = dict(list(ctx_map.items())[-64:])
# 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:
# All three from the SAME result (audit MATCH-DECIDE-14). The confidence
# used to be `_last_match_confidence`, the committed program's (moved only
# when the switching rules commit or confirm it), shown beside the newest
# result's ambiguity flag and candidates.
mr = getattr(manager, "_last_match_result", None)
if mr is not None:
conf = getattr(mr, "confidence", None)
out["confidence"] = round(float(conf), 4) if conf is not None else None
out["ambiguous"] = bool(getattr(mr, "is_ambiguous", False))
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()
elif not manager.set_manual_program(prog):
# Only a program that does not exist can fail now (#411). It used to
# fail for the far more common reason of no cycle being under way, and
# this line reported success anyway because the manager returned None
# either way, so the panel showed a confirmation and then quietly
# reverted the dropdown.
connection.send_error(
msg["id"], "not_found", f"No such program: {prog}"
)
return
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]
start_ts = start_dt.timestamp()
live = [[round((t - start_dt).total_seconds(), 1), round(float(p), 1)] for t, p in trace]
# Overlay any diag_buffer readings strictly newer than the last trace
# point. During STATE_ENDING the detector trace can lag the raw sensor
# by up to one sampling interval (readings are throttled; the diag_buffer
# records every reading before the throttle), so the chart would appear
# frozen at the last active reading until the next throttle-pass.
if diag is not None and live:
last_trace_ts = trace[-1][0].timestamp()
for raw_ts, raw_w in diag.power_samples(last_trace_ts + 0.1):
offset_s = round(raw_ts - start_ts, 1)
if offset_s > live[-1][0]:
live.append([offset_s, round(float(raw_w), 1)])
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]
# Signature persisted with each cycle's ``ml_health``: the shipped quality/end
# baselines. It used to carry an on-device model's ``trained_at`` so a retrain
# invalidated the cache; no on-device classifier exists since 0.5.8, and the
# value is unchanged so health already persisted stays valid.
_HEALTH_MODEL_SIG = "quality:base|end:base"
def _compute_ml_comparison(
store: Any, *, 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": []}
# The shipped embedded baselines (no on-device classifier exists since 0.5.8).
quality_score_fn, quality_source = resolve_scorer("quality")
end_score_fn, end_source = resolve_scorer("end")
if quality_score_fn is None and end_score_fn is None:
return {"enabled": False, "error": "ML models not available", "cycles": []}
model_sig = _HEALTH_MODEL_SIG
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 the newest 200 cycles ---
# `cycles` is oldest-first (new cycles are appended at the end), so the newest
# 200 are the tail; older ones are skipped before their trace is decompressed.
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
if idx < recent_start_idx:
continue
points = decompress_power_data(cycle)
# 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")
has_trace = len(points) >= ML_HEALTH_MIN_TRACE_POINTS
if not has_trace:
# #459: a cycle whose trace was pruned by retention cannot be scored.
# `quality_features` falls back to a `has_trace = 0` row that no model
# was trained on (the baseline sits ~38 sigma away from it), so every
# pruned cycle came back as ~0.99 / "review" at the next forced
# recompute and joined the review queue. Keep the last assessment that
# was made WITH a trace; anything else (never scored, or scored after
# the trace was gone, which is what the nightly run did until now) is
# "no data". Checked before the cache so polluted entries heal on the
# next panel load rather than at the next maintenance run.
if isinstance(cached, dict) and cached.get("has_trace"):
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
quality_label = "no_data"
ml_end_conf = None
end_label = "no_event"
events = []
cycle_id_key = cycle.get("id", "")
if cycle_id_key and not (
isinstance(cached, dict)
and cached.get("label") == "no_data"
and cached.get("score") is None
):
health_updates[cycle_id_key] = {
"score": None,
"label": "no_data",
"end_score": None,
"end_label": "no_event",
"events": [],
"model_sig": model_sig,
"has_trace": False,
"at": dt_util.now().isoformat(),
}
health_dirty = True
elif (
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,
# #459: marks a score as trace-backed, so it survives the
# trace being pruned later instead of being re-scored blind.
"has_trace": True,
"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": has_trace,
"events": events,
"ml_review": cycle.get("ml_review") or {},
})
# Panel expects most-recent-first ordering; the loop appended oldest-first.
evaluated.reverse()
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,
"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()
},
}
@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
result = await hass.async_add_executor_job(_compute_ml_comparison, store)
# 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()
_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,
)
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 = {
"total_energy": ("Energy estimate", "Predicting total energy and cost"),
}
# Only what is trained on-device AND consumed is listed, which since 0.5.8 is
# `total_energy` alone. `end` is no longer trained (its consumer is frozen
# off, audit ML-05/11) and `remaining_time` / `live_match` / `quality` lost
# consumer and training; storage v17 drops all four records, so an older
# import is the only way one can still be here.
live_caps = set(_cap_labels)
models: dict[str, Any] = {}
for cap, v in versions.items():
if not isinstance(v, dict) or cap not in live_caps:
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: MAE vs the naive estimate
# (every listed capability is a regressor since 0.5.8).
if 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}",
}
# How many cycles the metric was measured on (audit ML-20); absent on a
# record promoted before the count was stored.
if isinstance(v.get("held_out_cycles"), int):
info["held_out_cycles"] = int(v["held_out_cycles"])
models[cap] = info
# Fit trend across the PROMOTED runs (drift): compare the mean held-out score
# of the most-recent third of the models that were put in use to the oldest
# third, respecting each metric's direction. A candidate that was rejected
# never served, so its score says nothing about the model in use (audit
# ML-20); entries from before that rule carry no `promoted` and are skipped.
# Only meaningful with a few promotions 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):
continue
promoted_runs = [
e for e in series
if isinstance(e, dict) and e.get("promoted") is True and "score" in e
]
scores = [float(e["score"]) for e in promoted_runs]
if len(scores) < 4:
continue
higher_better = bool(promoted_runs[-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"
# The last run per capability, so the panel can say why nothing was (re)learnt
# (audit ML-20). Only runs recorded with `promoted` qualify.
last_run: dict[str, Any] = {}
for cap in live_caps:
series = history.get(cap) if isinstance(history, dict) else None
entry = series[-1] if isinstance(series, list) and series else None
if not isinstance(entry, dict) or "promoted" not in entry:
continue
run: dict[str, Any] = {"ts": entry.get("ts"), "promoted": bool(entry["promoted"])}
if not run["promoted"]:
params = entry.get("reason_params")
run["reason_code"] = str(entry.get("reason_code") or "unknown")
run["reason_params"] = dict(params) if isinstance(params, dict) else {}
run["reason"] = str(entry.get("reason") or "")
last_run[cap] = run
_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,
"last_run": last_run,
},
)
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_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_regressor``
becomes inert for the baseline-less ``total_energy`` regressor. The next training pass may
re-promote models if they beat the baseline again.
"""
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.
The write-back from the read-only shadow view. (Until 0.5.8 a review also
labelled the on-device quality model, whose training was removed.)
"""
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
# The same builder the manager uses (audit F2): this fallback had its own
# defaults (min_off_gap 60 s for every device, scalar completion floor) and
# gave a not-yet-initialised device a configuration no detector runs.
options = dict(getattr(entry, "options", {}) or {}) if entry is not None else {}
data = dict(getattr(entry, "data", {}) or {}) if entry is not None else {}
device_type = str(
options.get(CONF_DEVICE_TYPE, data.get(CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE))
)
return build_detector_config(options, data, device_type)
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
# ─── Playground settings control panel (live values + presets) ─────────────────
def _playground_preset_list(store: Any) -> list[dict[str, Any]]:
"""Presets as a name-sorted list for the panel dropdown.
Each stored record's values are passed through the same allow-list a save
uses, so a preset written before 0.5.8 still loads: keys the Playground no
longer offers (the Stage 2-4 matcher weights) are dropped from the view while
the stored record stays untouched.
"""
presets = store.get_playground_presets()
out: list[dict[str, Any]] = []
for name, record in presets.items():
if not isinstance(record, dict):
continue
out.append({
"name": name,
"values": playground.sanitize_setting_values(record.get("values")),
"created_at": record.get("created_at"),
"updated_at": record.get("updated_at"),
})
out.sort(key=lambda p: str(p["name"]).lower())
return out
@websocket_api.websocket_command(
{
vol.Required("type"): "ha_washdata/get_playground_settings",
vol.Required("entry_id"): str,
vol.Optional("include_suggestions", default=True): bool,
}
)
@websocket_api.async_response
async def ws_get_playground_settings(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Return the device's LIVE effective Playground settings plus saved presets.
``effective`` is read back off the same live detector/matcher config the
simulation uses, so the control panel always opens on what the integration is
really running - never on a stale schema default. ``publishable`` lists the
keys the panel may write back to the config entry.
``include_suggestions=False`` skips the auto-tuner suggestions. They exist only to
label the "Load suggested" button, so the panel opens without them and fetches them
in the background. Defaults to True so every other caller is unaffected.
"""
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:
match_config = playground._matching_config(store) # noqa: SLF001
except Exception: # pylint: disable=broad-exception-caught
match_config = {}
include_suggestions = bool(msg.get("include_suggestions", True))
# Classic suggestions from the store (periodic analysis results), filtered to
# keys the Playground actually exposes — cheap dict read, no executor needed.
raw_sugg: dict[str, Any] = {}
try:
raw_sugg = store.get_suggestions() if include_suggestions else {}
raw_sugg = raw_sugg or {}
except Exception as exc: # pylint: disable=broad-exception-caught
_LOGGER.debug(
"Could not read Playground classic suggestions for %s: %s", entry_id, exc
)
classic_sugg: dict[str, Any] = {}
for key in playground.SETTING_KEYS:
item = raw_sugg.get(key)
if isinstance(item, dict) and item.get("value") is not None:
val = item["value"]
try:
classic_sugg[key] = int(float(val)) if key in _SUGGESTION_INT_KEYS else round(float(val), 4)
except (TypeError, ValueError, OverflowError):
pass
_send_result(connection, msg["id"], "get_playground_settings", {
"effective": playground.effective_settings(base_config, match_config),
"presets": _playground_preset_list(store),
"publishable": sorted(playground.PUBLISHABLE_SETTING_KEYS),
"preset_limit": PLAYGROUND_PRESET_MAX,
"classic_suggestions": classic_sugg,
})
@websocket_api.websocket_command(
{
vol.Required("type"): "ha_washdata/save_playground_preset",
vol.Required("entry_id"): str,
vol.Required("name"): str,
vol.Required("values"): dict,
}
)
@websocket_api.async_response
async def ws_save_playground_preset(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Save (or overwrite) a named snapshot of the Playground's settings."""
entry_id: str = msg["entry_id"]
manager = _get_manager(hass, entry_id)
store = getattr(manager, "profile_store", None) if manager else None
if store is None:
_err_not_found(connection, msg["id"], entry_id)
return
values = playground.sanitize_setting_values(msg["values"])
try:
await store.async_save_playground_preset(msg["name"], values)
except ValueError as exc:
connection.send_error(msg["id"], "invalid_format", str(exc))
return
except Exception as exc: # pylint: disable=broad-exception-caught
# The store write can fail (disk full, permissions). Without this the
# exception escapes the handler and the client never gets a reply, so the
# panel's save button spins forever instead of reporting the failure.
_LOGGER.warning("Saving playground preset failed for %s: %s", entry_id, exc)
connection.send_error(msg["id"], "unknown_error", str(exc))
return
_send_result(connection, msg["id"], "save_playground_preset", {
"success": True,
"presets": _playground_preset_list(store),
})
@websocket_api.websocket_command(
{
vol.Required("type"): "ha_washdata/delete_playground_preset",
vol.Required("entry_id"): str,
vol.Required("name"): str,
}
)
@websocket_api.async_response
async def ws_delete_playground_preset(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Delete a saved Playground settings preset."""
entry_id: str = msg["entry_id"]
manager = _get_manager(hass, entry_id)
store = getattr(manager, "profile_store", None) if manager else None
if store is None:
_err_not_found(connection, msg["id"], entry_id)
return
try:
removed = await store.async_delete_playground_preset(msg["name"])
except Exception as exc: # pylint: disable=broad-exception-caught
_LOGGER.warning("Deleting playground preset failed for %s: %s", entry_id, exc)
connection.send_error(msg["id"], "unknown_error", str(exc))
return
_send_result(connection, msg["id"], "delete_playground_preset", {
"success": removed,
"presets": _playground_preset_list(store),
})
# ─── Background-task registry (progress / cancel / reconnect-safe results) ──────
@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
# One batch replay (Test on history / Optimize) per device at a time (audit
# PLATFORM-04): each is minutes of executor CPU on a Pi, and nothing stopped a
# reconnecting panel or a script from stacking them. A second is refused with
# `task_busy`; a new Simulate supersedes the previous one instead (PLAYGROUND-12).
_PG_BATCH_KINDS = frozenset({"pg_history", "pg_sweep"})
def _pg_running(reg: Any, entry_id: str, kinds: frozenset[str]) -> list[str]:
"""Ids of this entry's running tasks of ``kinds``."""
return [
t["id"] for t in reg.snapshot(entry_id)
if t.get("state") == task_registry.STATE_RUNNING and t.get("kind") in kinds
]
def _pg_refuse_busy(hass: HomeAssistant, connection: Any, msg_id: int, entry_id: str) -> bool:
"""Send ``task_busy`` and return True while a batch replay runs for the entry."""
if not _pg_running(task_registry.get_registry(hass), entry_id, _PG_BATCH_KINDS):
return False
connection.send_error(
msg_id, "task_busy",
"A Test-on-history or Optimize run is already in progress for this device",
)
return True
async def _pg_history_task(
hass: HomeAssistant, task: Any, entry_id: str,
cycle_ids: list[str], override: dict[str, Any] | None,
count: int | None = 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:
selected = await hass.async_add_executor_job(
playground._select_cycles, store, cycle_ids or None, count # noqa: SLF001
)
ids = [c.get("id") for c in selected if c.get("id")]
# 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,
cycle_ids: list[str] | None = None,
count: int | None = 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:
# The cycles the panel's "Last N" picked (audit PLAYGROUND-18), else the
# most recent ones; the selection rule is the playground's own.
selected = await hass.async_add_executor_job(
playground._select_cycles, store, cycle_ids or None, count # noqa: SLF001
)
ids = [c.get("id") for c in selected if c.get("id")]
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)
reg.update(task, total=len(values) + 1)
# The current settings on the same cycles: what a value has to beat, and
# the early-end / split / detection floor none may lose (PLAYGROUND-08).
baseline = await hass.async_add_executor_job(
playground.sweep_baseline,
store, ids, base_config, objective, options, price, prebuilt,
)
reg.update(task, done=1)
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,
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 + 2)
payload = playground.finalize_sweep_1d(
param, objective, points, current_value, baseline
)
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,
# "Last N" (audit PLAYGROUND-18): the most recent N, newest first.
vol.Optional("count"): vol.All(
vol.Coerce(int), vol.Range(min=1, max=playground.MAX_BATCH_CYCLES)
),
}
)
@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
if _pg_refuse_busy(hass, 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, msg.get("count"))
)
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,
# Capped like the old one-shot command's 20x20 (audit PLAYGROUND-16).
vol.Required("values"): vol.All([vol.Coerce(float)], vol.Length(max=20)),
vol.Required("objective"): str,
# Which cycles (audit PLAYGROUND-18): the panel's "Last N" as `count`, the
# most recent N; explicit ids win; neither = the most recent 20.
vol.Optional("cycle_ids", default=list): [str],
vol.Optional("count"): vol.All(
vol.Coerce(int), vol.Range(min=1, max=playground.MAX_BATCH_CYCLES)
),
}
)
@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
if _pg_refuse_busy(hass, connection, msg["id"], entry_id):
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"], list(msg.get("cycle_ids") or []), msg.get("count"),
))
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)
# A new Simulate replaces the previous one: its result would be dropped by the
# panel anyway, and it kept replaying to the end (PLAYGROUND-12 / PLATFORM-04).
for stale in _pg_running(reg, entry_id, frozenset({"pg_detail"})):
reg.cancel(stale)
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})
# ─── Historical power-data import (issue #344) ─────────────────────────────────
#
# Four steps, because the data is large and the answer is the user's to approve:
#
# 1. ingest - `history_import_begin` + `history_import_chunk` stage CSV text, or
# `history_import_recorder` fills the same buffer from the recorder.
# Chunked because Home Assistant builds its WebSocket with aiohttp's
# default 4 MiB frame cap, and ten days of 5-second data is 5-8 MB of
# text; an over-cap frame is not rejected, it closes the connection.
# 2. scan - `start_history_import_scan` replays the stream through fresh detectors
# as a detached registry task, chunk by chunk (see history_import.py for
# why a raw stream cannot be fed to one detector).
# 3. review - the panel shows one row per candidate; the whole traces never cross
# the wire (they would blow the same frame cap).
# 4. apply - `apply_history_import` persists the accepted rows into
# `backfill_cycles`.
#
# Parsing lives in Python, not in the panel, so one implementation is under test.
_HISTORY_IMPORT_KEY = f"{DOMAIN}_history_import"
def _history_staging(hass: HomeAssistant) -> dict[str, dict[str, Any]]:
"""Per-entry staging area for an in-flight import, keyed by entry_id.
One slot per entry: a new upload replaces the previous one, so an abandoned 8 MB
paste cannot accumulate. Cleared by :func:`async_clear_history_import` on unload.
"""
return hass.data.setdefault(_HISTORY_IMPORT_KEY, {})
def async_clear_history_import(hass: HomeAssistant, entry_id: str) -> None:
"""Drop any staged upload and scan result for an entry (called on unload)."""
_history_staging(hass).pop(entry_id, None)
def _history_slot(hass: HomeAssistant, entry_id: str, token: str) -> dict[str, Any] | None:
slot = _history_staging(hass).get(entry_id)
if not isinstance(slot, dict) or slot.get("token") != token:
return None
return slot
@websocket_api.websocket_command(
{
vol.Required("type"): "ha_washdata/history_import_begin",
vol.Required("entry_id"): str,
}
)
@callback
def ws_history_import_begin(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Open a staging slot for a CSV upload and return the token chunks must carry."""
entry_id: str = msg["entry_id"]
if _get_manager(hass, entry_id) is None:
_err_not_found(connection, msg["id"], entry_id)
return
token = uuid.uuid4().hex
_history_staging(hass)[entry_id] = {
"token": token,
"chunks": [],
"bytes": 0,
"next_seq": 0,
"source": "csv",
}
_send_result(connection, msg["id"], "history_import_begin", {
"token": token,
"max_bytes": HISTORY_IMPORT_MAX_BYTES,
"chunk_bytes": HISTORY_IMPORT_CHUNK_BYTES,
})
@websocket_api.websocket_command(
{
vol.Required("type"): "ha_washdata/history_import_chunk",
vol.Required("entry_id"): str,
vol.Required("token"): str,
vol.Required("seq"): int,
vol.Required("text"): str,
}
)
@callback
def ws_history_import_chunk(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Append one chunk of CSV text to the staging slot.
Sequence-checked: a dropped or re-ordered chunk would splice the file silently, so a
mismatch is an error the panel can restart from rather than a corrupt import.
"""
entry_id: str = msg["entry_id"]
slot = _history_slot(hass, entry_id, msg["token"])
if slot is None:
connection.send_error(msg["id"], "not_found", "No upload in progress; start again")
return
if int(msg["seq"]) != slot["next_seq"]:
connection.send_error(
msg["id"], "invalid_format",
f"Out-of-order chunk {msg['seq']}, expected {slot['next_seq']}",
)
return
text: str = msg["text"]
size = len(text.encode("utf-8", "ignore"))
if slot["bytes"] + size > HISTORY_IMPORT_MAX_BYTES:
_history_staging(hass).pop(entry_id, None)
connection.send_error(msg["id"], "invalid_format", "Upload is too large")
return
slot["chunks"].append(text)
slot["bytes"] += size
slot["next_seq"] += 1
_send_result(connection, msg["id"], "history_import_chunk", {
"received_bytes": slot["bytes"],
"next_seq": slot["next_seq"],
})
@websocket_api.websocket_command(
{
vol.Required("type"): "ha_washdata/history_import_recorder",
vol.Required("entry_id"): str,
# Either a start date (what the panel sends: "import since ") or a plain
# day count. `start_date` wins when both are present.
vol.Optional("start_date"): vol.Any(None, str),
vol.Optional("days", default=10): vol.All(
vol.Coerce(int), vol.Range(min=1, max=HISTORY_IMPORT_RECORDER_MAX_DAYS)
),
}
)
@websocket_api.async_response
async def ws_history_import_recorder(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Fill the staging slot from Home Assistant's own recorder.
The entity is always the device's configured power sensor, never client-supplied:
this command reads arbitrary history, and letting the caller name the entity would
make it an information-disclosure hole.
Read one day at a time. `state_changes_during_period` has no row cap, and a single
query over a long window materialises the whole result in one recorder-executor job.
Home Assistant purges states after `purge_keep_days` (10 by default), so a request
reaching further back simply returns fewer rows - that is reported, not an error.
"""
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
entity_id = getattr(manager, "power_sensor_entity_id", None)
if not entity_id:
connection.send_error(msg["id"], "not_found", "This device has no power sensor configured")
return
now = dt_util.now()
days = int(msg.get("days") or 10)
start_date = msg.get("start_date")
if start_date:
# "Import since " - resolve to a day count so the windowed read below is
# unchanged. The date is read as a LOCAL calendar day (it comes from a date
# picker, where the user means their own midnight), and clamped to the supported
# range: a future date reads today, an older one is capped at the maximum.
try:
parsed_date = dt_util.parse_date(str(start_date))
except Exception: # pylint: disable=broad-exception-caught
parsed_date = None
if parsed_date is None:
connection.send_error(msg["id"], "invalid_format", "Not a valid start date")
return
span = (now.date() - parsed_date).days + 1
days = max(1, min(HISTORY_IMPORT_RECORDER_MAX_DAYS, span))
rows: list[tuple[float, float]] = []
# Walk BACKWARDS from today, not forwards from the oldest requested day: the window can
# now be years (a recorder configured to keep full-resolution states that long), and
# starting at the far end would issue thousands of empty queries before reaching any
# data - and, on truncation, would keep the OLDEST rows rather than the most recent.
# `samples_from_readings` sorts, so the accumulation order does not matter downstream.
empty_run = 0
# The loop can stop early (empty-day run, or the row cap), so the requested `days` is
# not what was read. Track the oldest window actually queried and report THAT, or the
# panel would name a range it never looked at.
oldest_queried = now
queried_days = 0
for day in range(1, days + 1):
window_start = now - timedelta(days=day)
window_end = now - timedelta(days=day - 1)
day_rows = await _recorder_power(hass, entity_id, window_start, end_dt=window_end)
oldest_queried = window_start
queried_days += 1
if day_rows:
empty_run = 0
rows.extend(day_rows)
else:
# Inside the retention window a day always yields at least the carried
# start-time state, so a run of empty days means the recorder is purged past
# here and every further query would be wasted.
empty_run += 1
if empty_run >= HISTORY_IMPORT_RECORDER_EMPTY_DAY_STOP:
break
if len(rows) > HISTORY_IMPORT_MAX_ROWS:
break
token = uuid.uuid4().hex
_history_staging(hass)[entry_id] = {
"token": token,
"chunks": [],
"bytes": 0,
"next_seq": 0,
"source": "recorder",
"rows": rows[:HISTORY_IMPORT_MAX_ROWS],
"entity_id": entity_id,
# Carried into the parse report, so the review step says the read was cut.
"truncated": len(rows) > HISTORY_IMPORT_MAX_ROWS,
}
_send_result(connection, msg["id"], "history_import_recorder", {
"token": token,
"rows": len(rows[:HISTORY_IMPORT_MAX_ROWS]),
"entity_id": entity_id,
# Both describe the window actually read, not the one asked for: counted from
# the loop itself, so an early stop cannot report days that were never queried.
"days": queried_days,
"start_date": oldest_queried.date().isoformat(),
"truncated": len(rows) > HISTORY_IMPORT_MAX_ROWS,
})
def _history_samples(
slot: dict[str, Any], entity_id: str | None, parser: Any = None
) -> Any:
"""Turn a staging slot into samples, whichever way it was filled.
Executor-side: CSV parsing is pure Python over megabytes of text, so the scan task
steps a ``HistoryCsvParser`` across jobs first and hands it in (``parser``).
Returns either a ``(samples, report)`` pair or an ``{"error": ...}`` marker.
"""
if slot.get("source") == "recorder":
# `parser` is then a stepped RecorderReadings (audit PLAYGROUND-11).
samples = (
parser.result()
if parser is not None
else history_import.samples_from_readings(slot.get("rows") or [])
)
if len(samples) < 2:
return {"error": "no_readings"}
# The same report a CSV gets, so the review step shows the span, the peak,
# the kW hint and the row-limit cut for a recorder read too (PLAYGROUND-11).
report = history_import.ParsedHistory(
samples=samples,
entity_id=slot.get("entity_id"),
rows_total=len(samples),
rows_parsed=len(samples),
truncated=bool(slot.get("truncated")),
).report()
report["source"] = "recorder"
return samples, report
parsed = (
parser.result()
if parser is not None
else history_import.parse_history_csv(
"".join(slot.get("chunks") or []), entity_id=entity_id
)
)
if isinstance(parsed, dict):
return parsed
report = parsed.report()
report["source"] = "csv"
return parsed.samples, report
async def _history_import_scan_task(
hass: HomeAssistant, task: Any, entry_id: str, token: str
) -> None:
"""Replay a staged history stream, chunk by chunk, into candidate cycles.
The full traces are held in the staging slot rather than in the task result: the
result is served verbatim by `get_task_result`, and a few hundred traces would exceed
the WebSocket frame cap and take the connection down. The result carries only preview
rows, which is also all the review UI needs.
"""
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
slot = _history_slot(hass, entry_id, token)
if slot is None:
reg.finish(task, state=task_registry.STATE_ERROR, error="upload expired")
return
# Segmentation is only as good as the thresholds it runs with, and
# `_playground_base_config`'s fallback uses the *scalar* defaults (min_off_gap 60,
# off_delay 180) rather than the per-device ones - which on a dishwasher would cut
# the stream at every drying pause and produce nothing but fragments. Refuse rather
# than scan against the wrong thresholds.
if not isinstance(getattr(getattr(manager, "detector", None), "config", None), CycleDetectorConfig):
reg.finish(task, state=task_registry.STATE_ERROR, error="detector_unavailable")
return
try:
entity_id = getattr(manager, "power_sensor_entity_id", None)
parser = None
if slot.get("source") != "recorder":
# Parsed a slice per executor job, not in one (audit PLAYGROUND-11).
parser = await hass.async_add_executor_job(functools.partial(
history_import.HistoryCsvParser,
"".join(slot.get("chunks") or []), entity_id=entity_id,
))
reg.update(task, total=parser.rows_estimate)
while not parser.finished:
if task.cancel_requested:
reg.finish(task, state=task_registry.STATE_CANCELLED)
return
await hass.async_add_executor_job(parser.step, history_import.PARSE_STEP_ROWS)
reg.update(task, done=min(parser.rows_estimate, parser.out.rows_total))
else:
# The recorder rows are converted a slice per job too.
parser = history_import.RecorderReadings(slot.get("rows") or [])
reg.update(task, total=parser.rows_estimate)
while not parser.finished:
if task.cancel_requested:
reg.finish(task, state=task_registry.STATE_CANCELLED)
return
await hass.async_add_executor_job(parser.step, history_import.PARSE_STEP_ROWS)
reg.update(task, done=parser.done)
parsed = await hass.async_add_executor_job(_history_samples, slot, entity_id, parser)
if isinstance(parsed, dict):
reg.finish(task, state=task_registry.STATE_ERROR, error=str(parsed.get("error")))
return
samples, report = parsed
sampling_interval = options.get(CONF_SAMPLING_INTERVAL)
# Blocks, gates and densification, a slice per job (audit PLAYGROUND-11).
builder = history_import.ScanBuilder(
samples, base_config,
sampling_interval_s=sampling_interval, parse_report=report,
)
while not builder.finished:
if task.cancel_requested:
reg.finish(task, state=task_registry.STATE_CANCELLED)
return
await hass.async_add_executor_job(
builder.step, history_import.SCAN_BUILD_STEP_SAMPLES
)
runner = builder.result()
if isinstance(runner, dict):
# A stream with nothing usable in it is a *result*, not a failure: the panel
# explains which spans were skipped and why (six months of hourly averages
# is the common case), so the user is not left staring at "0 cycles".
reg.finish(task, state=task_registry.STATE_DONE, result={
"segments": [],
"skipped": runner.get("skipped") or [],
"parse": runner.get("parse") or report,
"found": 0,
"error": runner.get("error"),
})
return
reg.update(task, total=runner.total)
while not runner.finished:
if task.cancel_requested:
break
await hass.async_add_executor_job(runner.step, HISTORY_IMPORT_CHUNK_SAMPLES)
reg.update(task, done=min(runner.total, runner.done))
payload = await hass.async_add_executor_job(
functools.partial(runner.finalize, partial=task.cancel_requested)
)
# The replay is unmatched, so a dishwasher's timeout finish banked its whole
# end wait (audit PLAYGROUND-07): cut it by the programme it matches.
if not task.cancel_requested:
await history_import.async_import_tail_cuts(
manager.profile_store, base_config, payload.get("cycles") or [],
options, payload.get("segments") or [],
)
# A candidate that overlaps a cycle WashData already holds is that cycle
# replayed from raw history: show it, but never pre-tick it (audit
# PLAYGROUND-05 - 81% of them slipped past the exact start/duration key, and
# a labelled copy double-weights the real cycle in its envelope).
history_import.mark_already_recorded(
payload.get("segments") or [],
history_import.stored_intervals(manager.profile_store.iter_stored_cycles()),
)
# Split the payload: traces stay server-side, keyed by this task so a reconnect
# can still apply them; only the preview rows travel.
cycles = payload.pop("cycles", [])
current = _history_slot(hass, entry_id, token)
if current is not None:
current["scan_task_id"] = task.id
current["cycles"] = cycles
current.pop("chunks", None) # the raw text is no longer needed
current.pop("rows", None)
payload["token"] = token
payload["settings"] = {
"min_power": base_config.min_power,
"off_delay": base_config.off_delay,
"min_off_gap": base_config.min_off_gap,
"device_type": base_config.device_type,
}
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
# Task-level failure at WARNING like the other task runners, so it lands in the
# default HA log and the panel Logs view (sub-step failures stay at debug).
_LOGGER.warning("History-import scan 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_history_import_scan",
vol.Required("entry_id"): str,
vol.Required("token"): str,
}
)
@callback
def ws_start_history_import_scan(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Kick off the replay as a detached, registry-tracked task."""
entry_id: str = msg["entry_id"]
if _get_manager(hass, entry_id) is None:
_err_not_found(connection, msg["id"], entry_id)
return
if _history_slot(hass, entry_id, msg["token"]) is None:
connection.send_error(msg["id"], "not_found", "No upload in progress; start again")
return
reg = task_registry.get_registry(hass)
task = reg.create(
entry_id, "history_import", "Scanning power history",
label_key="task.history_import.scanning",
)
_raw = hass.async_create_task(
_history_import_scan_task(hass, task, entry_id, msg["token"])
)
if _raw is not None:
reg.link_asyncio_task(task.id, _raw)
_send_result(connection, msg["id"], "start_history_import_scan", {"task_id": task.id})
async def _history_import_apply_task(
hass: HomeAssistant, task: Any, entry_id: str, scan_task_id: str, accept: list[int]
) -> None:
"""Persist the accepted candidates into ``backfill_cycles``."""
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
scan = reg.get(scan_task_id)
# Ownership check: get_task_result is not entry-scoped, so without this a stale or
# foreign task id could write another device's cycles into this store.
if scan is None or scan.entry_id != entry_id or scan.kind != "history_import":
reg.finish(task, state=task_registry.STATE_ERROR, error="scan_expired")
return
try:
async with _entry_write_lock(hass, entry_id):
if _get_manager(hass, entry_id) is not manager:
reg.finish(task, state=task_registry.STATE_ERROR, error="device reloaded")
return
# Validate AND read the staging slot under the lock, not before it: two
# concurrent applies must not both pass a pre-lock check and each persist the
# same candidate (one whose dedup_key is None bypasses existing_dedup_keys),
# and the slot must not be swapped between the check and the read.
slot = _history_staging(hass).get(entry_id)
if not isinstance(slot, dict) or slot.get("scan_task_id") != scan_task_id:
# The registry keeps only the last 30 finished tasks per entry, and an
# entry reload clears the staging area, so a scan can legitimately be
# gone by now.
reg.finish(task, state=task_registry.STATE_ERROR, error="scan_expired")
return
cycles: list[dict[str, Any]] = list(slot.get("cycles") or [])
# Dedupe the client-supplied indices (order-preserving): a repeated index
# would visit the same candidate twice, and one whose dedup_key is None is
# not caught by the in-loop dedup set, so it would store a second copy.
wanted = list(dict.fromkeys(i for i in accept if 0 <= i < len(cycles)))
if not wanted:
reg.finish(
task, state=task_registry.STATE_DONE,
result={"imported": 0, "duplicates": 0},
)
return
store = manager.profile_store
target = store.get_backfill_cycles()
room = max(0, HISTORY_IMPORT_MAX_TOTAL_CYCLES - len(target))
existing = history_import.existing_dedup_keys(store.iter_stored_cycles())
# Time overlap, not just the exact key: the same run recorded live and
# replayed from history rarely agrees to the second (PLAYGROUND-05).
intervals = history_import.stored_intervals(store.iter_stored_cycles())
id_pool = {c.get("id") for c in target if isinstance(c, dict)}
imported = 0
duplicates = 0
reg.update(task, total=len(wanted))
for done, index in enumerate(wanted, start=1):
if task.cancel_requested:
break
raw = cycles[index]
duration = history_import.effective_duration(raw)
key = history_import.dedup_key(raw.get("start_time"), duration)
if (key is not None and key in existing) or history_import.overlaps_stored(
raw.get("start_time"), duration, intervals
):
duplicates += 1
reg.update(task, done=done)
continue
if imported >= room:
break
stored = history_import.build_backfill_cycle(raw)
store._add_cycle_data( # noqa: SLF001 - the bulk insert primitive
stored,
target=target,
id_pool=id_pool,
)
if raw.get("tail_cut_s"):
# The banked-tail repair's own trim (PLAYGROUND-07).
store._apply_repaired_duration(stored, float(raw["tail_cut_s"])) # noqa: SLF001
if key is not None:
existing.add(key)
interval = history_import.stored_intervals([{**raw, "duration": duration}])
if interval:
intervals = sorted([*intervals, *interval])
imported += 1
reg.update(task, done=done)
if imported:
await store.async_save()
# "capped" means the cap decided where we stopped - not the user cancelling,
# and not the duplicates that were legitimately skipped.
capped = not task.cancel_requested and imported < (len(wanted) - duplicates)
# Read the total inside the lock: another task could append to the same
# backfill list after the lock releases, inflating a count read outside it.
total_backfill = len(target)
# Consume the slot we applied - but only if it is still that same object.
# A fresh upload (which does not take this lock) can replace the slot during
# the async_save() await above; clearing unconditionally would discard it.
if _history_staging(hass).get(entry_id) is slot:
async_clear_history_import(hass, entry_id)
manager.notify_update()
reg.finish(
task,
state=task_registry.STATE_CANCELLED if task.cancel_requested else task_registry.STATE_DONE,
result={
"imported": imported,
"duplicates": duplicates,
"capped": capped,
"total_backfill": total_backfill,
},
)
except asyncio.CancelledError:
reg.finish(task, state=task_registry.STATE_CANCELLED)
raise
except Exception as exc: # pylint: disable=broad-exception-caught
# A failed apply is a failed data write; log at WARNING like the other task
# runners so it is visible in the default HA log and the panel Logs view.
_LOGGER.warning("History-import apply 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/apply_history_import",
vol.Required("entry_id"): str,
vol.Required("scan_task_id"): str,
vol.Required("accept"): list,
}
)
@callback
def ws_apply_history_import(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Persist the candidates the user kept, as a detached, registry-tracked task."""
entry_id: str = msg["entry_id"]
if _get_manager(hass, entry_id) is None:
_err_not_found(connection, msg["id"], entry_id)
return
accept: list[int] = []
for item in msg["accept"][:HISTORY_IMPORT_MAX_SEGMENTS]:
try:
accept.append(int(item))
except (TypeError, ValueError, OverflowError):
continue
reg = task_registry.get_registry(hass)
task = reg.create(
entry_id, "history_import_apply", "Importing cycles",
label_key="task.history_import.importing",
)
_raw = hass.async_create_task(
_history_import_apply_task(hass, task, entry_id, msg["scan_task_id"], accept)
)
if _raw is not None:
reg.link_asyncio_task(task.id, _raw)
_send_result(connection, msg["id"], "apply_history_import", {"task_id": task.id})