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