.higher` / `.lower`): when the two values diverge, a `` line is rendered below the value row. The `_t()` call uses the key name and the direction of each value relative to the other:
```js
const calIsHigher = mN > cN;
const calImpact = t(`suggestion.impact.${key}.${calIsHigher ? 'higher' : 'lower'}`, {}, '');
const obsImpact = t(`suggestion.impact.${key}.${calIsHigher ? 'lower' : 'higher'}`, {}, '');
```
The impact line is omitted when no translation key exists for the setting (the `_t()` fallback is `''`). Covered settings: `stop_threshold_w`, `start_threshold_w`, `off_delay`, `min_off_gap`, `auto_label_confidence`, `profile_match_threshold`, `watchdog_interval`, `no_update_active_timeout`, `learning_confidence`, `min_power`, `duration_tolerance`, `end_energy_threshold`, `end_repeat_count`, `running_dead_zone`.
#### Translations
All conflict message strings, the save-blocked toast (`toast.settings_conflicts`), the cascade toast (`conflict.cascade_toast`), the Revert button label and tooltips (`btn.revert_settings`, `btn.revert_settings_tip`, `btn.revert_settings_tip_none`), and the revert success toast (`toast.settings_reverted`) are fully translated across all 35 supported panel languages via `_t()`. The suggestion label and impact strings live under the `suggestion.*` key namespace in `translations/panel/en.json` and are likewise translated across all 35 languages. English strings serve as the `_t()` fallback when a translation is missing.
#### Pending settings across section switches
`this._pendingSettings` is a plain JS object on the panel component that accumulates unsaved form edits so they survive section tab switches within the Settings tab. Before each section re-render, `_snapshotFormToPending(sr)` reads every live form field (number inputs, checkboxes, entity-lists, timer-lists, JSON blobs) into `_pendingSettings`. `_htmlSettings()` then builds its working options object as `Object.assign({}, this._opts, this._pendingSettings)`, so every field initialises from the most-recently-typed value rather than the last-saved `this._opts`. `_readSettingsFormValues(sr)` uses `_pendingSettings` as a fallback for keys whose DOM inputs are off-screen, ensuring cross-section conflict checks see current pending values instead of stale saved state. `_conflictKeysFromOpts()` now delegates to `_conflictKeysForOpts(opts)`, which accepts an arbitrary options dict; both the conflict-validation path and the device-card badge call this helper with the appropriate dict. `_saveSettings()` merges `_pendingSettings` before `_cascadePending` entries and DOM values, so no pending change is silently dropped. `_pendingSettings` is cleared on successful save, Revert, Refresh, device switch, and navigating away from the Settings tab.
This mechanism fixes two related bugs: (1) values typed in a section were discarded when the user switched to another section, because each switch rebuilt the DOM from `this._opts`; (2) saving was blocked by a conflict in a section the user was not currently viewing, even when their pending edits in that section were already consistent, because conflict validation used stale saved values for off-screen fields.
#### Conflict badge on device cards and conflict banner in Settings
**Device-card conflict badge**: the device-selector bar loop calls `_conflictCountForOpts(d.options)` for each device in the `ws_get_devices` payload. When the count is >= 1, a `... N` badge (red tint via `.wd-dbadge.conf`) is rendered before the existing suggestion/feedback badges, giving the user a visible signal that a device has unresolved conflicts without opening each device's settings. Because each device's options are already in the payload, no extra WebSocket round-trip is needed.
**Conflict banner**: `_htmlSettings()` calls `_conflictKeysFromOpts()` and, when any conflicts are active, prepends a styled red banner above the section switcher. The banner text is `_t('conflict.settings_banner', {count: conflictKeys.size}, ...)` and a **Go to first** button fires the `conf-goto-section` action. The handler iterates `_SETTINGS_SECTIONS` in order, tests whether any field in each section appears in the conflict key set, and switches the section tab to the first match. Translation keys: `conflict.settings_banner` (with `{count}` placeholder), `conflict.settings_banner_btn`. Both keys are translated across all 35 supported panel languages.
#### WashData-initiated persistent notifications removed
WashData no longer posts unsolicited Home Assistant persistent notifications for its own learning/tuning activity. Three separate paths were removed:
1. **Suggestion-ready notification** — `_async_send_suggestions_ready_notification()` and its call site in `_apply_suggestions_and_notify()` were removed from `learning.py`.
2. **Cycle-verification feedback notification** — `_async_send_feedback_notification()` and its call site in `learning._maybe_request_feedback()` were removed. This was previously gated by the `suppress_feedback_notifications` option, which is now also gone: the constant (`CONF_/DEFAULT_SUPPRESS_FEEDBACK_NOTIFICATIONS`) was removed from `const.py`, the field was removed from the panel Settings schema, and the key is stripped from existing config entries by `async_migrate_entry` (no data lost). Cycles needing verification are surfaced in the panel's **Cycles review queue** via `request_cycle_verification`.
3. **Ghost-cycle auto-tune notification** — the notification dispatch in `manager._auto_tune_min_power` (the "detected ghost cycles / suggested min_power change" message, sent via user notify channels or `_pn_create`) was removed. The `min_power` suggestion is still stored via `profile_store.set_suggestion` and surfaced in the panel's Settings suggestions banner / per-field pill.
The corresponding translation keys — `suggestions_ready_notification_title/message`, `feedback_notification_title/message`, and `auto_tune_suggestion`/`auto_tune_title`/`auto_tune_fallback` — were removed from `strings.json`, `translations/en.json`, and all other supported language files, and `suppress_feedback_notifications` was removed from every `translations/panel/*.json`. The user-configured start/finish/live/timer notification channels (and the peak-rate start tip) are unaffected.
---
## 10. Detection, Energy, Notifications, Maintenance & Panel Additions
A batch of feature groups (roadmap A-H) layered on top of the 0.5.0 architecture. All are additive: new statistics, store keys, sensors, WS commands, and panel surfaces that never change the proven detection/matching core.
### A. Detection & Intelligence Accuracy
**A1 - Underrun anomaly.** The mirror image of the runtime overrun signal (§ Runtime Overrun Anomaly). A completed cycle whose duration is below `CYCLE_UNDERRUN_ANOMALY_RATIO` (0.55) of its matched profile's median is frozen with `cycle_data["anomaly"] = "underrun"` plus `underrun_ratio`. It shares the `anomaly` field with `overrun`, so the two are mutually exclusive on one cycle. Surfaced as a ⚡ badge in the Cycles list. Pure statistics, no notification, never terminates a cycle.
**A2 - Energy anomaly.** At cycle end, the cycle's energy is compared against the matched profile's own energy history via a z-score (requires `>= 3` labelled cycles for a stable mean/stdev). When `|z| > ENERGY_ANOMALY_Z_THRESHOLD` (2.5), `cycle_data["energy_anomaly"]` is set to `"energy_spike"` or `"energy_low"` with `energy_z_score`. It is a **separate field** from `anomaly`, so a cycle can be simultaneously an overrun and an energy spike. Rendered as 🔺 / 🔻 badges in the Cycles list.
**A3 - Unlabeled cluster suggestions.** `suggest_coverage_gaps` (§ 3c) now additionally runs pairwise correlation of the recent *unmatched* cycles within each duration bucket and emits `profile_suggestions` (a cluster is kept when its average intra-cluster similarity `>= 0.75`). Returned alongside `coverage_gaps` in `ws_get_profiles`; drives a one-click **Create profile from N cycles** button in the Profiles coverage-gap banner.
**A4 - Profile warm-up.** A profile with fewer than `CONF_PROFILE_MIN_WARMUP_CYCLES` (5) labelled cycles is not yet trustworthy, so auto-labelling against it is suppressed and the match routes to a manual-confirm feedback request instead. The profile card shows a "Still learning (n/5)" badge. The threshold is exposed to the panel via `get_constants` as `PROFILE_MIN_WARMUP_CYCLES`.
**A5 - Shape drift.** `compute_profile_health` (§ 3b) gains two fields when a profile has `>= SHAPE_DRIFT_MIN_CYCLES` (10) traced cycles: `shape_drift_correlation` (the Pearson r between the *average envelope of the earliest third* of the cycles and that of the *latest third*, both resampled onto a common grid via the new `signal_processing.resample_to_n`) and `shape_drift` (bool, true when `r < SHAPE_DRIFT_THRESHOLD`, 0.85). A drifting profile raises a maintenance advisory in its detail view. Complementary to duration/energy trends: it catches a change in curve *shape* even when duration is steady.
### B. Energy, Cost & Sustainability
**B1 - HA Energy dashboard entity.** A new `sensor._energy_total` (`device_class=ENERGY`, `state_class=TOTAL_INCREASING`, unit kWh, 3-decimal precision) reports lifetime energy so Home Assistant's Energy dashboard ingests it automatically. The running total is persisted in the store as `lifetime_energy_wh` and bumped **once per completed cycle** in `manager._on_cycle_end` (`ProfileStore.async_add_lifetime_energy_wh`). Read via `ProfileStore.get_lifetime_energy_wh` / `manager.lifetime_energy_kwh`.
**B2 - Per-profile average cost.** `ProfileStore.list_profiles()` now computes `avg_cost` / `total_cost` from each member cycle's frozen `cost` (falling back to `None` when no priced cycles exist). Shown on profile cards and in the profile panel in the HA currency.
**B3 - Finish-notification template variables.** `manager._on_cycle_end` builds three new placeholders for the finish message and mirrors them into the fired event data: `{time_finished}` (`dt_util.now().strftime("%H:%M")`), `{cycle_count}` (`self.cycle_count`), and `{vs_typical}` from `manager._format_vs_typical(duration, median)` - a deterministic "N% longer/shorter than usual" against the profile median, empty when there is no usable median or the delta is under 1%.
**B4 - Peak-rate awareness.** `manager._peak_rate_tip(options, price)` returns a one-line advisory appended to the **start** notification when `CONF_PEAK_RATE_THRESHOLD` (`peak_rate_threshold`) is positive and the resolved current price is at or above it. Text comes from `CONF_PEAK_RATE_MESSAGE` (`DEFAULT_PEAK_RATE_MESSAGE = "Running at peak rate ({price}/kWh)."`, vars `{device}` / `{price}`). Purely informational - no scheduling or appliance control; never raises (a bad threshold is skipped).
### C. Notification & Alerting
**C1 - Quiet hours.** `notify_quiet_start_hour` / `notify_quiet_end_hour` (`0-23`; unset or equal = off) define a do-not-disturb window that correctly handles windows crossing midnight. Finish-type notifications (finished, clean-laundry nag, pre-completion/reminder, and the C2 milestone) that would fire inside the window are queued and delivered at the window's end; live-progress ticks and the start notification are **never** delayed.
**C2 - Cycle milestones.** `CONF_NOTIFY_MILESTONES` (`DEFAULT_NOTIFY_MILESTONES = [50, 100, 500, 1000]`) is a list of lifetime completed-cycle counts. After a cycle persists the manager fires a single milestone notification when the **monotonic lifetime counter** (`manager._lifetime_cycle_count()`, backed by the persisted `lifetime_cycle_count` store key - it only ever increments and never regresses when history is trimmed, unlike `cycle_count == len(retained history)`) crosses a threshold, using `CONF_NOTIFY_MILESTONE_MESSAGE` (`DEFAULT_NOTIFY_MILESTONE_MESSAGE = "{device} has completed {cycle_count} cycles!"`, vars `{device}` / `{cycle_count}`). The storage v9 migration seeds `lifetime_cycle_count` from existing history so an upgraded install does not re-cross milestones it already passed. Respects quiet hours; no new notification type or entity.
**C3 - iOS Live Activity enrichment.** For `mobile_app_*` **live** targets only, the notification `data` gains `subtitle` (program), a `content_state` dict `{state, progress_pct, eta_timestamp, program, device}`, and `activity` start/end lifecycle markers so a companion-app Live Activity can begin and end with the cycle. Gated on the target prefix, so Android and strict-schema notify platforms never receive the extra keys.
### D. Panel & UI
- **D1 - Live phase timeline.** Below the Status progress bar, the matched profile's phase ranges are rendered as color-coded segments with a live cursor. Falls back to the plain bar when the matched profile has no phases.
- **D2 - Duration sparkline.** Profile cards draw a mini sparkline of the last ~10 cycle durations (hidden under 3 cycles).
- **D3 - Cycles pagination.** `ws_get_device_cycles` gained an `offset` parameter and returns `total` + `has_more` (`has_more = (offset + len(cycles)) < total`); the panel adds a **Load more** button.
- **D4 - Undo toast.** Cycle and profile deletion show a 10s undo toast; the actual delete only fires after the window closes (client-side deferral).
- **D5 - Modal keyboard handling.** Escape closes the open modal and Tab/Shift+Tab are trapped within it (modal accessibility). The earlier letter/`?` tab-navigation shortcuts were removed: the panel's shadow root only receives keydown while focus is inside it (e.g. an open modal), so global shortcuts fired before any modal opened never reached the handler.
- **D6 - Bulk relabel.** The Cycles multi-select toolbar can reassign many cycles to one profile in a single action.
- **D7 - Settings change history.** Every options save appends `{key, old, new, timestamp}` to the `settings_changelog` store key (capped at `SETTINGS_CHANGELOG_MAX` = 50). New WS command `get_settings_changelog`; changed fields render a dot with a "Changed from X to Y on {date}" tooltip plus a Settings history table.
### E. Appliance Health & Predictive Maintenance
**E1 - Maintenance log.** A per-device `maintenance_log` store key holds `{id, date, event_type, notes}` records. `MAINTENANCE_EVENT_TYPES` = descale / filter_clean / drum_clean / bearing_service / other. WS commands: `get_maintenance_log`, `add_maintenance_event`, `delete_maintenance_event` (`ProfileStore.get_maintenance_log` / `async_add_maintenance_event` / `async_delete_maintenance_event`). A new **Maintenance** sub-tab under Advanced offers an add form, timeline list, and reminder editor. A logged event of a matching type within the last 30 days suppresses the corresponding drift/health advisory.
**E2 - Service reminders.** `CONF_MAINTENANCE_REMINDER_CYCLES` (`DEFAULT_MAINTENANCE_REMINDER_CYCLES = {descale: 30, filter_clean: 50, drum_clean: 100}`; bearing_service / other default off). `ProfileStore.get_maintenance_due(reminder_cfg)` returns the list of event types whose cycles-since-last-logged-event has reached/exceeded the threshold; `manager.maintenance_due` surfaces it as a `maintenance_due` attribute on the state sensor and a panel banner. No new notification type.
### F. Onboarding, Settings Disclosure & Playground
**F1 - Setup Card / adoption guidance (0.5.1).** The old first-run onboarding card, the
coverage-gap banner, and the Profiles-tab "Recommendations" banner were **consolidated into a
single Setup Card** on the Overview tab. Its content is driven by `setup_advisor.py`
(`compute_setup_phase` -> the `get_setup_status` WS command) as a six-phase state machine:
| Phase | Situation | Card guidance |
|-------|-----------|---------------|
| **0** | No profiles at all | Record/label a first program, or "Browse community setups" (Store CTA) |
| **1c** | `reference_cycles` exist but no self-recorded cycles (store-download-only) | Run the appliance once so the downloaded template can be confirmed against your machine; advances on the first non-ambiguous match |
| **2** | Building coverage (few labelled cycles) | Keep running/labelling programs to widen coverage |
| **3** | Coverage good, tuning opportunities | Review suggested settings / resolve pending feedback |
| **4** | Healthy | Card goes quiet (can resurface amber on drift) |
The card supports snooze / dismiss / hide-guidance (persisted per user via panel prefs) and
resurfaces (amber) when a new actionable condition appears. The **config flow was simplified**
in 0.5.1: the old "Create First Profile" step was removed, so setup is now purely device
type + power sensor + min power, with the Setup Card taking over from there.
**Threshold mode (no profiles).** When a device has **no real profiles**, WashData skips the
periodic match poll entirely and suppresses the "No profile matched yet" notification - a fresh
Threshold Device (or a not-yet-trained appliance) no longer nags about the absence of a match.
**F2 - Basic / Advanced settings.** A per-user `settings_level` preference (`basic` | `advanced`) drives a Basic|Advanced toggle atop the Settings tab. Basic renders ~12 highest-impact fields; Advanced renders everything. It is purely a visibility filter - hidden fields keep their stored values.
**F3 - Playground tab (unified workbench).** A top-level Playground tab backed by `playground.py`, built as **one workbench** rather than mutually-exclusive modes: a single interactive power graph is always the centrepiece; a shared detection/matching settings panel and a bottom "Across your cycles" drawer both feed it. **Single source of truth:** the whole tab reuses the *exact* production code - the real `CycleDetector`, the real Stage 1-4 matcher, `progress.py` (progress/remaining/phase/energy) and `notification_rules.py` (notification decisions). There is no client-side detection copy (the former `_pgComputeDetection` JS state machine and the static `remaining = totalDur - elapsed` countdown were deleted).
- **The graph (always present).** Pick a cycle (profile defaults to **auto-detect** so the sim shows what the matcher picks), press **Run** (or **Cancel**); one WS call (`run_playground_cycle_detail` → `playground.simulate_cycle_detail`) returns a full timeline: a per-5s `series` (state, model progress/remaining, live confidence, phase, energy, projected energy/cost), a typed `events` log (`detected`, `match_commit`/`match_changed`/`match_ambiguous`, `notify_start`/`notify_pre_complete`/`notify_finish`/`notify_milestone`/`notify_held`, `finished`), `alerts`, and an `outcome`. Alerts now include **end-detection flags**: `timeout_end` (ended only by the low-power off-delay, not smart prediction - warn when also unmatched) and `would_run_indefinitely` (`TerminationReason.FORCE_STOPPED` - never ended on its own), so an auto-detected cycle's end behaviour is visible. `_pgDrawCanvas` renders the detector state band + phase bands + draggable thresholds and **event pins**: heads sit in a band ABOVE the plot (`_PG_PIN_BAND_H`) on stems down to the real event time, nudged apart on collision; hovering a head (hit-tested via `_pgEventHits`) shows a tooltip with `_pgEventDescription(type)`. The graph is interactive (hover readout of from-start / to-end / power, scroll to zoom, drag to pan, double-click to reset via `_pgView`/`_pgHoverT`/`_pgMap`); no JS replay. Dragging a threshold or editing a param debounces a re-run (`_pgRerunDetail`). **Match faithfulness:** the detail `_matcher` reports a **persistence-gated committed** match (mirrors the manager: a candidate must be non-ambiguous top-1 for `match_persistence` consecutive matches before it commits, and the committed match is held) rather than the raw per-interval top-1 — so the series/events show no phantom mid-cycle profile flips. The detector still receives the raw top-1 (detection unchanged). The analysis panel shows **Match confidence** (the committed matcher score, same as the strip) separately from **Envelope fit** (the `get_dtw_debug` envelope score) so the two aren't confused.
- **Settings panel (shared).** `_pgOverrideFields()` groups detection triggers / timing / edge-cases / **program matching**. `build_sim_config` applies the detection keys (including `start_duration_threshold` / `abrupt_drop_watts` / `interrupted_min_seconds`, which used to be shown but silently dropped), and `apply_match_overrides` maps the matching options onto the matcher config. The **program-matching** group exposes the whole pipeline for experimentation: the two real, user-settable duration-gate options (`profile_match_min/max_duration_ratio`, Stage 1 - a value found here can be applied for real) **plus** the Stage 2-4 scoring / DTW knobs (`corr_weight`, `keep_min_score`, `dtw_bandwidth`, `dtw_blend`, `dtw_ensemble_w`, `dtw_ddtw_scale`, `dtw_refine_top_n`, `duration_weight`, `energy_weight`, `duration_scale`, `energy_scale`). The scoring knobs are **sandbox-only** - they are not persistent settings, so they only shape the simulation's match config (built fresh and never saved); they let a power user A/B how each stage scores their own cycles. **Save to settings** (`_pgApplyToSettings`) copies staged *real* overrides into the device's live options via `set_options`. (Stage-5 group-cohesion and Stage-6 phase parameters are not yet exposed - they are not config-driven in the sim path.)
- **Drawer: Test on history** - `_pgRunHistory` replays the last N cycles in small chunks (`run_playground_history` per 2-cycle slice) so each executor job is short and the event loop breathes between them (responsive UI, other data keeps loading), driving a determinate progress bar (`_htmlPgBatchBar`/`_pgUpdateBatchBar`, advanced by direct DOM writes so it is never lost) with a working **Cancel** (`_pgBatchCancel`). Rows accumulate client-side; the summary + before/after `diff` are aggregated in JS (`_pgHistorySummary`/`_pgHistoryDiff`, mirroring the backend counters - trivial counting of server-computed per-cycle results). Clicking a row calls `_pgSelectCycle(cid)`, loading that cycle **into the graph above in place** (auto-detect profile, `_pgLoadSeq` supersede, row highlight, scroll into view) - no page switch.
- **Drawer: Optimize** - `_pgRunSweep2` chunks the sweep too: one value per WS call (1D) or one grid cell per call (2D), same progress bar + Cancel; `objective_metric` (`match_accuracy`, `end_timing_accuracy`, `false_end_rate`, `median_overrun` = |median ratio − 1|, `ambiguity_rate`) is still computed server-side per call, and the best value/cell is selected client-side (`_pgSweepBest1D`/`_pgSweepBest2D`, mirroring `_sweep_is_better`). "Apply best" persists the value and stages it into the graph.
The drawer sub-tabs use `data-subtab` (not `data-tab`) so they don't collide with the main-tab router that binds every `[data-tab]`. **Responsiveness:** the long batch/sweep work is CPU-bound pure-Python; a single monolithic executor job would hold the GIL for tens of seconds and starve the event loop (freezing the whole card), so the panel drives it as many small executor-offloaded calls and yields between them. Performance: `_build_match_snapshots` is built once per call and threaded via `prebuilt=`; batch/sweep pass `compute_series=False`. WS commands (`run_playground_cycle_detail` / `run_playground_history` / `run_playground_sweep`, all in `_READ_WRITE_COMMANDS`, executor-offloaded, bounded: concurrency clamped to `MAX_BATCH_CYCLES`, sweep axes capped at `_MAX_SWEEP_VALUES`, `param_y`/`values_y` required together). WS commands re-register on every `async_setup_entry` (idempotent), so newly-added commands work after a plain integration reload — no full Home Assistant restart required. The `get_dtw_debug` overlay still backs the graph.
**F4 - Background-task registry (reconnect-safe progress/cancel/results).** Long
operations no longer run tied to a WS request or a client loop. `task_registry.py`
holds a per-`hass` `TaskRegistry` of `Task`s (id, `entry_id`, kind, label,
done/total, `progress()`/`eta_s()`, state, result, cancel flag). WS: `list_tasks`
(reconnect snapshot), `subscribe_tasks` (live push; re-hydrates automatically on
reconnect because HA replays the subscribe), `cancel_task`, `get_task_result`
(reconnect-safe result, retained for the last `_MAX_FINISHED` tasks). Playground
history/optimize kick off via `start_playground_history` / `start_playground_sweep`
-> a detached `hass.async_create_task` runner (`_pg_history_task`/`_pg_sweep_task`)
that chunks the replay across many small executor jobs (event loop breathes; no GIL
freeze), calls `reg.update` between chunks, polls `task.cancel_requested`, and
stores the finalized payload (`playground.finalize_history`/`finalize_sweep_1d/2d`,
reusing the one-shot aggregation helpers). The panel subscribes once in `_boot`
(`_onTaskEvent` -> `this._tasks`, deduped by `updated_at`), renders one header
**activity pill** per running task (`_htmlTaskPills`, updated in place via
`_updateTaskPills`) with device + action + % + ETA + Cancel, drives the drawer
progress bar from the tracked task, and loads the result on completion
(`_pgFinishTask` -> `get_task_result`). A `get_task_result` poll fallback
(`_pgPollTask`) covers backends/mocks without live subscription. Process
history/reprocess (`_reprocess_task`) and on-device ML training
(`_ml_training_task`) also run through the registry, sharing the same
registry/task/locking/settlement pattern but differing in progress reporting:
reprocess reports **phase-by-phase progress** (matching -> golden -> suggestions
-> training -> health, `done`/`total`=5), while ML training runs **to completion
in one pass** with no interim percentage (indeterminate pill). Both are detached
plain coroutines kicked via `hass.async_create_task`
(NOT WS handlers, so they carry no `@websocket_command`/`@async_response` - a
regression test enforces this), serialized per entry under `_entry_write_lock`.
After a panel reload their completion re-refreshes the ML/diagnostics views via
`_settleTaskCallback` -> `_autoSettleAdopted`, and the pills render any kind.
**Cycle-management background tasks (0.5.1).** The panel's destructive/heavy cycle-editing
operations - **trim** (`trim_cycle`), **split** (`apply_split`), **merge** (`apply_merge`),
and **rebuild envelopes** (`rebuild_envelopes`) - were moved onto the same registry. They now
return a `StartTaskResponse` (a `task_id`) immediately and run as chunked background tasks with
a header progress pill and Cancel, instead of blocking the WS request while re-integrating
energy and rebuilding envelopes. This fixes the frozen-panel behaviour (issue #311): a backgrounded
tab or a dropped socket no longer orphans or loses the operation, and the result is read back
reconnect-safe via `get_task_result`. There are **nine task kinds** in total: playground
history / sweep / cycle-detail, reprocess, ML training, rebuild-envelopes, trim, split, merge.
(Note: the `task_registry` wiring lives entirely in `ws_api.py`; `manager.py` runs its own
long jobs, e.g. scheduled ML training and health recompute, as plain executor/`async_create_task`
jobs outside the registry.)
### G. Conversation intents (Assist)
`intents.py` defines a single `HaWashdataStatus` intent (`INTENT_STATUS`), registered once per HA instance from `async_setup_entry` via `async_setup_intents` (no import-time side effects). The handler answers "is my washer done / how long left" from live manager/sensor state ("still running, about N minutes left" / "finished N minutes ago" / "not running"), resolving the device by an optional `{name}` slot. `conversation` is added to `manifest.json` dependencies. Because HA has no runtime API for a custom integration to inject sentences into the built-in conversation agent, trigger sentences are wired by the user via a `/custom_sentences/en/ha_washdata.yaml` pack (one file per language); the intent is fireable immediately from automations / `intent_script` / the Assist pipeline. (Roadmap G2, a community profile library, is deferred / not shipped.)
### H. Type-safe WS API contract (developer)
`ws_schema.py` is the single source of truth for the panel's WebSocket surface: a `TypedDict` response per command plus a `WS_COMMANDS` request registry covering all **99 commands**. A debug-only `_send_result` wrapper validates outgoing responses against the declared shape when `HA_WASHDATA_WS_CONTRACT=1` (zero overhead otherwise). `devtools/generate_ws_types.py` regenerates `www/ws-types.d.ts` (TypeScript types) and `docs/WS_API.md` (auto-generated command reference). `tests/test_ws_contract.py` fails if a command is added or removed without updating the contract, keeping `ws_api.py`, `ws_schema.py`, and the generated docs in sync.