224 files
This commit is contained in:
@@ -63,7 +63,6 @@ from .const import (
|
||||
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,
|
||||
@@ -76,7 +75,6 @@ from .const import (
|
||||
DEFAULT_MIN_POWER,
|
||||
DEFAULT_OFF_DELAY,
|
||||
DEFAULT_OFF_DELAY_BY_DEVICE,
|
||||
DEFAULT_RUNNING_DEAD_ZONE,
|
||||
DEVICE_TYPE_PUMP,
|
||||
MAINTENANCE_EVENT_TYPES,
|
||||
DEVICE_TYPES,
|
||||
@@ -94,6 +92,17 @@ from .ws_schema import WS_OPEN_RESPONSES, WS_RESPONSE_TYPES
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Read once at import time (manifest.json is static) so ws_get_constants never does
|
||||
# blocking I/O on the event loop. Falls back to "" if the file is absent.
|
||||
try:
|
||||
from pathlib import Path as _Path
|
||||
_INTEGRATION_VERSION: str = json.loads(
|
||||
(_Path(__file__).parent / "manifest.json").read_text(encoding="utf-8")
|
||||
).get("version", "")
|
||||
del _Path
|
||||
except Exception: # pragma: no cover # pylint: disable=broad-exception-caught
|
||||
_INTEGRATION_VERSION = ""
|
||||
|
||||
# ─── 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
|
||||
@@ -175,7 +184,6 @@ _SUGGESTION_KEYS: tuple[str, ...] = (
|
||||
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,
|
||||
@@ -198,7 +206,6 @@ _SUGGESTION_INT_KEYS: frozenset[str] = frozenset({
|
||||
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,
|
||||
@@ -229,7 +236,6 @@ _ML_COMPARE_SETTINGS: tuple[tuple[str, str, str], ...] = (
|
||||
(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", ""),
|
||||
@@ -436,6 +442,8 @@ _PREF_LANG_TAG_RE = re.compile(r"^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$")
|
||||
_FULL_COMMANDS = frozenset({
|
||||
"wipe_history", "import_config", "export_config", "clear_debug_data", "reprocess_history",
|
||||
"trigger_ml_training",
|
||||
# Selective export/import wizard (same full-data reach as the wholesale variants).
|
||||
"get_export_inventory", "analyze_import", "export_config_selective", "import_config_selective",
|
||||
# Reverting on-device models / matcher tuning discards learned state -> full access.
|
||||
"revert_matching_config", "revert_ml_models",
|
||||
})
|
||||
@@ -454,6 +462,10 @@ _ADMIN_COMMANDS = frozenset({
|
||||
"wipe_history",
|
||||
"import_config",
|
||||
"export_config",
|
||||
"get_export_inventory",
|
||||
"analyze_import",
|
||||
"export_config_selective",
|
||||
"import_config_selective",
|
||||
"reprocess_history",
|
||||
"clear_debug_data",
|
||||
# Global community-store mutations: these change the ONE integration-wide GitHub
|
||||
@@ -1072,6 +1084,9 @@ def async_register_commands(hass: HomeAssistant) -> None:
|
||||
# Diagnostics
|
||||
ws_get_diagnostics, ws_reprocess_history, ws_clear_debug_data,
|
||||
ws_wipe_history, ws_export_config, ws_import_config,
|
||||
# Selective export/import wizard (inventory + analyze + selective export/import)
|
||||
ws_get_export_inventory, ws_analyze_import,
|
||||
ws_export_config_selective, ws_import_config_selective,
|
||||
# Shared constants
|
||||
ws_get_constants,
|
||||
# Suggestions
|
||||
@@ -2076,9 +2091,17 @@ async def ws_label_cycle(
|
||||
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)
|
||||
applied_profile: str | None = new_profile_name.strip()
|
||||
await manager.profile_store.create_profile(applied_profile, cycle_id)
|
||||
else:
|
||||
applied_profile = profile_name
|
||||
await manager.profile_store.assign_profile_to_cycle(cycle_id, profile_name)
|
||||
# Manually (re)labelling a cycle awaiting verification IS the user's answer,
|
||||
# so resolve any pending feedback and drop it from the review queue (#331).
|
||||
if hasattr(manager, "learning_manager"):
|
||||
await manager.learning_manager.async_resolve_pending_from_label(
|
||||
cycle_id, applied_profile
|
||||
)
|
||||
manager.notify_update()
|
||||
_send_result(connection, msg["id"], "label_cycle", {"success": True})
|
||||
except ValueError as exc:
|
||||
@@ -2966,6 +2989,234 @@ async def ws_import_config(
|
||||
connection.send_error(msg["id"], "unknown_error", str(exc))
|
||||
|
||||
|
||||
# Soft cap on a pasted/uploaded import blob so a huge string can't wedge the
|
||||
# executor / event loop. Whole-store exports with full traces are a few MB; 64 MB
|
||||
# is comfortably above any legitimate payload.
|
||||
_MAX_IMPORT_JSON_BYTES = 64 * 1024 * 1024
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{vol.Required("type"): "ha_washdata/get_export_inventory", vol.Required("entry_id"): str}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_get_export_inventory(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Return this device's per-category data inventory for the export wizard tree."""
|
||||
entry_id: str = msg["entry_id"]
|
||||
manager = _get_manager(hass, entry_id)
|
||||
if manager is None:
|
||||
_err_not_found(connection, msg["id"], entry_id)
|
||||
return
|
||||
entry = _get_entry(hass, entry_id)
|
||||
try:
|
||||
opts = dict(entry.options) if entry else {}
|
||||
manifest = await hass.async_add_executor_job(
|
||||
manager.profile_store.get_export_inventory, opts
|
||||
)
|
||||
_send_result(connection, msg["id"], "get_export_inventory", {"manifest": manifest})
|
||||
except Exception as exc: # pylint: disable=broad-exception-caught
|
||||
connection.send_error(msg["id"], "unknown_error", str(exc))
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "ha_washdata/analyze_import",
|
||||
vol.Required("entry_id"): str,
|
||||
vol.Required("json_data"): str,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_analyze_import(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Parse a pasted/uploaded export and describe what it contains (no mutation).
|
||||
|
||||
Synchronous (not a detached task): parsing + walking a few-MB file is tens of ms.
|
||||
Returns ``{"manifest": {...}}``; a structural problem is reported inside the
|
||||
manifest as ``manifest.error`` so the panel can render it inline.
|
||||
"""
|
||||
from .const import CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE
|
||||
|
||||
entry_id: str = msg["entry_id"]
|
||||
manager = _get_manager(hass, entry_id)
|
||||
if manager is None:
|
||||
_err_not_found(connection, msg["id"], entry_id)
|
||||
return
|
||||
json_data: str = msg["json_data"]
|
||||
if len(json_data.encode("utf-8", "ignore")) > _MAX_IMPORT_JSON_BYTES:
|
||||
connection.send_error(msg["id"], "invalid_json", "Import payload is too large")
|
||||
return
|
||||
entry = _get_entry(hass, entry_id)
|
||||
device_type = ""
|
||||
if entry is not None:
|
||||
device_type = entry.options.get(
|
||||
CONF_DEVICE_TYPE, entry.data.get(CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE)
|
||||
)
|
||||
try:
|
||||
from .profile_store import build_import_manifest
|
||||
|
||||
local_names = list(manager.profile_store.get_profiles().keys())
|
||||
|
||||
def _analyze() -> dict[str, Any]:
|
||||
payload = json.loads(json_data)
|
||||
return build_import_manifest(
|
||||
payload, local_device_type=device_type, local_profile_names=local_names
|
||||
)
|
||||
|
||||
manifest = await hass.async_add_executor_job(_analyze)
|
||||
_send_result(connection, msg["id"], "analyze_import", {"manifest": manifest})
|
||||
except json.JSONDecodeError as exc:
|
||||
connection.send_error(msg["id"], "invalid_json", str(exc))
|
||||
except Exception as exc: # pylint: disable=broad-exception-caught
|
||||
connection.send_error(msg["id"], "unknown_error", str(exc))
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "ha_washdata/export_config_selective",
|
||||
vol.Required("entry_id"): str,
|
||||
vol.Required("selection"): dict,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_export_config_selective(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Export only the selected categories/items as a JSON string."""
|
||||
entry_id: str = msg["entry_id"]
|
||||
manager = _get_manager(hass, entry_id)
|
||||
if manager is None:
|
||||
_err_not_found(connection, msg["id"], entry_id)
|
||||
return
|
||||
entry = _get_entry(hass, entry_id)
|
||||
selection = msg["selection"]
|
||||
try:
|
||||
payload = manager.profile_store.export_data(
|
||||
entry_data=dict(entry.data) if entry else {},
|
||||
entry_options=dict(entry.options) if entry else {},
|
||||
selection=selection,
|
||||
)
|
||||
json_str = await hass.async_add_executor_job(
|
||||
lambda: json.dumps(payload, indent=2)
|
||||
)
|
||||
_send_result(
|
||||
connection, msg["id"], "export_config_selective", {"json_data": json_str}
|
||||
)
|
||||
except Exception as exc: # pylint: disable=broad-exception-caught
|
||||
connection.send_error(msg["id"], "unknown_error", str(exc))
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "ha_washdata/import_config_selective",
|
||||
vol.Required("entry_id"): str,
|
||||
vol.Required("json_data"): str,
|
||||
vol.Required("selection"): dict,
|
||||
vol.Optional("mode", default="merge"): vol.In(["merge", "replace"]),
|
||||
vol.Optional("conflict_resolutions", default=dict): dict,
|
||||
vol.Optional("cycle_destination", default="reference"): vol.In(
|
||||
["reference", "real_history"]
|
||||
),
|
||||
vol.Optional("apply_settings", default=True): bool,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_import_config_selective(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Selectively import chosen categories/items, merging into existing data."""
|
||||
from .const import CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE, SHAREABLE_SETTING_KEYS
|
||||
|
||||
entry_id: str = msg["entry_id"]
|
||||
manager = _get_manager(hass, entry_id)
|
||||
if manager is None:
|
||||
_err_not_found(connection, msg["id"], entry_id)
|
||||
return
|
||||
json_data: str = msg["json_data"]
|
||||
if len(json_data.encode("utf-8", "ignore")) > _MAX_IMPORT_JSON_BYTES:
|
||||
connection.send_error(msg["id"], "invalid_json", "Import payload is too large")
|
||||
return
|
||||
|
||||
# Serialize under the per-entry write lock so the import can't interleave with a
|
||||
# concurrent reprocess / recording persist (which would corrupt the store).
|
||||
async with _entry_write_lock(hass, entry_id):
|
||||
try:
|
||||
entry = _get_entry(hass, entry_id)
|
||||
device_type = ""
|
||||
if entry is not None:
|
||||
device_type = entry.options.get(
|
||||
CONF_DEVICE_TYPE, entry.data.get(CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE)
|
||||
)
|
||||
payload = await hass.async_add_executor_job(json.loads, json_data)
|
||||
summary = await manager.profile_store.async_import_data_selective(
|
||||
payload,
|
||||
selection=msg["selection"],
|
||||
mode=msg["mode"],
|
||||
conflict_resolutions=msg["conflict_resolutions"],
|
||||
cycle_destination=msg["cycle_destination"],
|
||||
apply_settings=msg["apply_settings"],
|
||||
local_device_type=device_type,
|
||||
)
|
||||
|
||||
# Re-validate after the awaits — the entry may have been reloaded (a new
|
||||
# manager + store) during the import. Persisting through the detached
|
||||
# manager/entry would clobber the live one, so bail out.
|
||||
current_manager = _get_manager(hass, entry_id)
|
||||
if current_manager is not manager:
|
||||
_LOGGER.warning(
|
||||
"Manager replaced during selective import for %s; aborting notify",
|
||||
entry_id,
|
||||
)
|
||||
_send_result(
|
||||
connection, msg["id"], "import_config_selective",
|
||||
{"success": True, "summary": summary},
|
||||
)
|
||||
return
|
||||
|
||||
# Apply the imported settings subset onto this device's options. Only the
|
||||
# SHAREABLE numeric allow-list ever reaches here (the store already filters),
|
||||
# and identity keys are stripped for defense-in-depth. entry.data is never
|
||||
# written (no power_sensor/device_type hijack).
|
||||
entry = _get_entry(hass, entry_id)
|
||||
settings = summary.get("settings") if isinstance(summary.get("settings"), dict) else {}
|
||||
settings_applied = 0
|
||||
if entry is not None and settings:
|
||||
filtered = {
|
||||
k: v for k, v in settings.items()
|
||||
if k in SHAREABLE_SETTING_KEYS
|
||||
and isinstance(v, (int, float)) and not isinstance(v, bool)
|
||||
}
|
||||
for key in _OPTIONS_IDENTITY_KEYS:
|
||||
filtered.pop(key, None)
|
||||
if filtered:
|
||||
hass.config_entries.async_update_entry(
|
||||
entry, options={**entry.options, **filtered}
|
||||
)
|
||||
settings_applied = len(filtered)
|
||||
summary = {**summary, "settings_applied": settings_applied}
|
||||
|
||||
manager.notify_update()
|
||||
_send_result(
|
||||
connection, msg["id"], "import_config_selective",
|
||||
{"success": True, "summary": summary},
|
||||
)
|
||||
except json.JSONDecodeError as exc:
|
||||
connection.send_error(msg["id"], "invalid_json", str(exc))
|
||||
except ValueError as exc:
|
||||
connection.send_error(msg["id"], "invalid_format", str(exc))
|
||||
except Exception as exc: # pylint: disable=broad-exception-caught
|
||||
connection.send_error(msg["id"], "unknown_error", str(exc))
|
||||
|
||||
|
||||
# ─── Shared constants ─────────────────────────────────────────────────────────
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): "ha_washdata/get_constants"})
|
||||
@@ -2985,6 +3236,7 @@ def ws_get_constants(
|
||||
{"id": key, "label": label}
|
||||
for key, label in DEVICE_TYPES.items()
|
||||
]
|
||||
from .frontend import BRAND_ICON_URL as _BRAND_ICON_URL, BRAND_ICON_REGISTERED_KEY as _BRAND_ICON_KEY # pylint: disable=import-outside-toplevel
|
||||
from .const import STORE_WEB_ORIGIN
|
||||
from . import store_account
|
||||
from .const import ( # pylint: disable=import-outside-toplevel
|
||||
@@ -3003,6 +3255,8 @@ def ws_get_constants(
|
||||
MATCH_ENERGY_SCALE,
|
||||
)
|
||||
_send_result(connection, msg["id"], "get_constants", {
|
||||
"version": _INTEGRATION_VERSION,
|
||||
"icon_url": _BRAND_ICON_URL if hass.data.get(_BRAND_ICON_KEY) else None,
|
||||
"device_types": device_types,
|
||||
"state_colors": dict(STATE_COLORS),
|
||||
"ml_lab_enabled": SHOW_ML_LAB,
|
||||
@@ -3962,7 +4216,19 @@ async def ws_get_power_history(
|
||||
cycle_start = getattr(detector, "current_cycle_start", None) if detector else None
|
||||
if cycle_start and trace:
|
||||
start_dt = trace[0][0]
|
||||
start_ts = start_dt.timestamp()
|
||||
live = [[round((t - start_dt).total_seconds(), 1), round(float(p), 1)] for t, p in trace]
|
||||
# Overlay any diag_buffer readings strictly newer than the last trace
|
||||
# point. During STATE_ENDING the detector trace can lag the raw sensor
|
||||
# by up to one sampling interval (readings are throttled; the diag_buffer
|
||||
# records every reading before the throttle), so the chart would appear
|
||||
# frozen at the last active reading until the next throttle-pass.
|
||||
if diag is not None and live:
|
||||
last_trace_ts = trace[-1][0].timestamp()
|
||||
for raw_ts, raw_w in diag.power_samples(last_trace_ts + 0.1):
|
||||
offset_s = round(raw_ts - start_ts, 1)
|
||||
if offset_s > live[-1][0]:
|
||||
live.append([offset_s, round(float(raw_w), 1)])
|
||||
out["cycle_active"] = True
|
||||
out["live"] = _downsample(live)
|
||||
out["cycle_elapsed_s"] = live[-1][0] if live else 0.0
|
||||
@@ -4923,7 +5189,6 @@ def _playground_base_config(manager: Any, entry: Any) -> CycleDetectorConfig:
|
||||
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)
|
||||
@@ -5449,6 +5714,8 @@ _PG_DETAIL_CHUNK = 250
|
||||
async def _pg_detail_task(
|
||||
hass: HomeAssistant, task: Any, entry_id: str,
|
||||
cycle_id: str, override: dict[str, Any] | None,
|
||||
stress_tail: bool = False,
|
||||
stress_idle_w: float | None = None,
|
||||
) -> None:
|
||||
reg = task_registry.get_registry(hass)
|
||||
ctx = _playground_context(hass, entry_id)
|
||||
@@ -5460,6 +5727,7 @@ async def _pg_detail_task(
|
||||
sim = await hass.async_add_executor_job(
|
||||
playground.build_cycle_detail_sim_by_id,
|
||||
store, cycle_id, base_config, override, options, price,
|
||||
stress_tail, stress_idle_w,
|
||||
)
|
||||
if isinstance(sim, dict): # {"error": ...} marker (not_found / build failure)
|
||||
if sim.get("error") == "not_found":
|
||||
@@ -5478,7 +5746,10 @@ async def _pg_detail_task(
|
||||
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)
|
||||
if sim.stress_tail:
|
||||
await hass.async_add_executor_job(sim.run_stress_tail)
|
||||
else:
|
||||
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(
|
||||
@@ -5500,6 +5771,8 @@ async def _pg_detail_task(
|
||||
vol.Required("entry_id"): str,
|
||||
vol.Required("cycle_id"): str,
|
||||
vol.Optional("settings_override", default=dict): dict,
|
||||
vol.Optional("stress_tail", default=False): bool,
|
||||
vol.Optional("stress_idle_w", default=None): vol.Any(vol.Coerce(float), None),
|
||||
}
|
||||
)
|
||||
@callback
|
||||
@@ -5522,8 +5795,10 @@ def ws_start_playground_cycle_detail(
|
||||
label_key="task.pg_detail.simulate", label_params={},
|
||||
)
|
||||
override = dict(msg.get("settings_override") or {})
|
||||
stress_tail = bool(msg.get("stress_tail", False))
|
||||
stress_idle_w = msg.get("stress_idle_w")
|
||||
_raw = hass.async_create_task(
|
||||
_pg_detail_task(hass, task, entry_id, msg["cycle_id"], override)
|
||||
_pg_detail_task(hass, task, entry_id, msg["cycle_id"], override, stress_tail, stress_idle_w)
|
||||
)
|
||||
if _raw is not None:
|
||||
reg.link_asyncio_task(task.id, _raw)
|
||||
|
||||
Reference in New Issue
Block a user