Updated apps

This commit is contained in:
2026-07-20 22:52:35 -04:00
parent 28a8cb98f6
commit a0c3271743
1164 changed files with 94781 additions and 6892 deletions
+455 -133
View File
@@ -40,9 +40,12 @@ from .const import (
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,
@@ -57,7 +60,9 @@ from .const import (
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,
@@ -82,6 +87,7 @@ from .const import (
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__)
@@ -175,6 +181,12 @@ _SUGGESTION_KEYS: tuple[str, ...] = (
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).
@@ -188,6 +200,7 @@ _SUGGESTION_INT_KEYS: frozenset[str] = frozenset({
CONF_SMOOTHING_WINDOW,
CONF_COMPLETION_MIN_SECONDS,
CONF_END_REPEAT_COUNT,
CONF_PUMP_STUCK_DURATION,
})
@@ -288,8 +301,15 @@ async def _recorder_power(hass: HomeAssistant, entity_id: str, start_dt: Any) ->
def _cycle_kwh(c: dict[str, Any]) -> float | None:
"""Cycle energy in kWh. Cycles store energy as ``energy_wh``; convert."""
wh = c.get("energy_wh")
"""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)
@@ -449,7 +469,6 @@ _ADMIN_COMMANDS = frozenset({
# with get_, so it is whitelisted here to gate at the 'read' level.
_READ_WRITE_COMMANDS = frozenset({
"set_program",
"run_playground_simulation",
"run_playground_cycle_detail",
"run_playground_history",
"run_playground_sweep",
@@ -461,6 +480,7 @@ _READ_WRITE_COMMANDS = frozenset({
"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",
@@ -1030,6 +1050,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
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,
@@ -1076,8 +1097,8 @@ def async_register_commands(hass: HomeAssistant) -> None:
ws_revert_ml_models,
# Cycle controls (pause / resume / force-stop)
ws_pause_cycle, ws_resume_cycle, ws_terminate_cycle,
# Playground (F3): headless what-if replay + DTW visualizer
ws_run_playground_simulation, ws_get_dtw_debug,
# 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,
@@ -1085,6 +1106,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
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,
@@ -1319,6 +1341,7 @@ async def ws_set_options(
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
@@ -1413,6 +1436,79 @@ async def ws_get_settings_changelog(
_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(
@@ -1721,24 +1817,67 @@ async def ws_delete_profile_group(
@websocket_api.websocket_command(
{vol.Required("type"): "ha_washdata/rebuild_envelopes", vol.Required("entry_id"): str}
)
@websocket_api.async_response
async def ws_rebuild_envelopes(
@callback
def ws_rebuild_envelopes(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Rebuild power-profile envelopes for all profiles."""
"""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"]
manager = _get_manager(hass, entry_id)
if manager is None:
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={},
)
hass.async_create_task(_rebuild_envelopes_task(hass, task, entry_id))
_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)
await lock.acquire()
try:
await manager.profile_store.async_rebuild_all_envelopes()
_send_result(connection, msg["id"], "rebuild_envelopes", {"success": 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 Exception as exc: # pylint: disable=broad-exception-caught
connection.send_error(msg["id"], "unknown_error", str(exc))
_LOGGER.warning("Rebuild-envelopes task failed for %s: %s", entry_id, exc)
reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc))
finally:
lock.release()
@websocket_api.websocket_command(
@@ -2814,6 +2953,21 @@ def ws_get_constants(
]
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),
@@ -2821,6 +2975,24 @@ def ws_get_constants(
"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,
@@ -3082,32 +3254,61 @@ async def ws_get_cycle_power_data(
vol.Required("end_s"): vol.Coerce(float),
}
)
@websocket_api.async_response
async def ws_trim_cycle(
@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."""
"""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"]
manager = _get_manager(hass, entry_id)
if manager is None:
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={},
)
hass.async_create_task(_trim_task(
hass, task, entry_id, msg["cycle_id"], float(msg["start_s"]), float(msg["end_s"]),
))
_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)
await lock.acquire()
try:
ok = await manager.profile_store.trim_cycle_power_data(
msg["cycle_id"], float(msg["start_s"]), float(msg["end_s"])
)
if ok:
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()
_send_result(connection, msg["id"], "trim_cycle", {"success": True})
else:
connection.send_error(
msg["id"], "trim_failed", "Trim produced no data or cycle not found"
)
reg.finish(task, state=task_registry.STATE_DONE, result={"success": True})
except Exception as exc: # pylint: disable=broad-exception-caught
connection.send_error(msg["id"], "unknown_error", str(exc))
_LOGGER.warning("Trim task failed for %s: %s", entry_id, exc)
reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc))
finally:
lock.release()
@websocket_api.websocket_command(
@@ -3173,13 +3374,19 @@ async def ws_analyze_split(
vol.Optional("segment_profiles"): list,
}
)
@websocket_api.async_response
async def ws_apply_split(
@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."""
"""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:
@@ -3188,40 +3395,69 @@ async def ws_apply_split(
store = manager.profile_store
cycle_id: str = msg["cycle_id"]
try:
cycle = next(
(c for c in store.get_past_cycles() if c.get("id") == cycle_id), None
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",
)
if not cycle:
connection.send_error(msg["id"], "not_found", f"Cycle {cycle_id!r} not found")
return
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}
)
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={},
)
hass.async_create_task(_apply_split_task(hass, task, entry_id, cycle_id, segments))
_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)
await lock.acquire()
try:
reg.update(task, total=1, done=0)
new_ids = await store.apply_split_interactive(cycle_id, segments)
await store.async_rebuild_all_envelopes()
manager.notify_update()
_send_result(connection, msg["id"], "apply_split", {"success": True, "new_ids": new_ids})
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 Exception as exc: # pylint: disable=broad-exception-caught
connection.send_error(msg["id"], "unknown_error", str(exc))
_LOGGER.warning("Apply-split task failed for %s: %s", entry_id, exc)
reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc))
@websocket_api.websocket_command(
@@ -3233,20 +3469,24 @@ async def ws_apply_split(
vol.Optional("new_profile_name"): vol.Any(str, None),
}
)
@websocket_api.async_response
async def ws_apply_merge(
@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."""
"""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
store = manager.profile_store
ids: list[str] = msg["cycle_ids"]
if len(ids) < 2:
connection.send_error(
@@ -3255,29 +3495,69 @@ async def ws_apply_merge(
return
target = msg.get("target_profile")
try:
if target == "__create_new__":
name = (msg.get("new_profile_name") or "").strip()
if not name:
connection.send_error(
msg["id"], "invalid_format", "New profile name required"
)
return
await store.create_profile_standalone(name)
target = name
elif target in ("", "none", "__none__"):
target = None
new_id = await store.apply_merge_interactive(ids, target)
if not new_id:
connection.send_error(msg["id"], "merge_failed", "Cycles could not be merged")
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
await store.async_rebuild_all_envelopes()
manager.notify_update()
_send_result(connection, msg["id"], "apply_merge", {"success": True, "new_id": new_id})
reg = task_registry.get_registry(hass)
task = reg.create(
entry_id, "merge", "Merging cycles", label_key="task.merge.apply", label_params={},
)
hass.async_create_task(_apply_merge_task(hass, task, entry_id, ids, target, new_name))
_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)
await lock.acquire()
try:
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 Exception as exc: # pylint: disable=broad-exception-caught
connection.send_error(msg["id"], "unknown_error", str(exc))
_LOGGER.warning("Apply-merge task failed for %s: %s", entry_id, exc)
reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc))
finally:
lock.release()
# ─── Profile envelope / member cycles ──────────────────────────────────────────
@@ -3492,6 +3772,13 @@ async def ws_set_user_prefs(
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})
@@ -4567,56 +4854,6 @@ def _playground_base_config(manager: Any, entry: Any) -> CycleDetectorConfig:
)
@websocket_api.websocket_command(
{
vol.Required("type"): "ha_washdata/run_playground_simulation",
vol.Required("entry_id"): str,
vol.Optional("cycle_ids", default=list): [str],
vol.Optional("settings_override", default=dict): dict,
vol.Optional("concurrency", default=1): vol.Coerce(int),
}
)
@websocket_api.async_response
async def ws_run_playground_simulation(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Replay stored cycles through a headless detector with overridden settings.
Returns ``{results: [...], summary: {...}}`` - a per-cycle event log +
outcome plus aggregate counts. Nothing is persisted; this is a pure what-if.
"""
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:
base_config = _playground_base_config(manager, _get_entry(hass, entry_id))
cycle_ids = list(msg.get("cycle_ids") or [])
settings_override = dict(msg.get("settings_override") or {})
concurrency = int(msg.get("concurrency", 1))
payload = await hass.async_add_executor_job(
playground.run_playground_batch,
store,
cycle_ids,
base_config,
settings_override,
concurrency,
)
_send_result(connection, msg["id"], "run_playground_simulation", payload)
except Exception as exc: # pylint: disable=broad-exception-caught
_LOGGER.debug("Playground simulation failed for %s: %s", entry_id, exc)
connection.send_error(msg["id"], "unknown_error", str(exc))
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."""
@@ -5108,3 +5345,88 @@ def ws_start_playground_sweep(
msg["objective"], param_y, list(values_y) if values_y else None,
))
_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 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 {})
hass.async_create_task(
_pg_detail_task(hass, task, entry_id, msg["cycle_id"], override)
)
_send_result(connection, msg["id"], "start_playground_cycle_detail", {"task_id": task.id})