-
-
Notifications
You must be signed in to change notification settings - Fork 41
Implementation Details
Note: Despite the name, WashData also works well for other appliances (e.g., dryers and dishwashers) as long as the power-draw cycle is reasonably predictable.
This document covers the complete implementation of all major features:
- Variable cycle duration support (configurable tolerance)
- Smart progress management (100% on complete, 0% after unload)
- Self-learning feedback system
- Export/Import with full settings transfer 4b. Import power history (#344)
- Auto-maintenance watchdog with switch control
- Robust Cycle State Machine (vNext)
- Reliability Features
The single big picture. Every subsequent diagram in this section zooms into one region of this map. Solid arrows are the primary data/control path; dotted arrows are opt-in or feedback paths. Boxes are grouped by subsystem (module ownership in parentheses).
flowchart TB
%% ---------------- INPUTS ----------------
subgraph IN["Inputs"]
PWR["Power sensor<br/>state_changed (W)"]
DOOR["Door sensor (optional)"]
USER["User actions<br/>panel / services / Assist"]
BOOT["HA start / reload<br/>state restore"]
end
%% ---------------- MANAGER INTAKE ----------------
subgraph MGR["Manager intake (manager.py)"]
PC["async_handle_power_change"]
DB["DiagBuffer<br/>24h rolling trace/state/log"]
THR["sampling throttle<br/>(low power always passes)"]
REC["record-mode intercept"]
WD["Watchdog<br/>0W keepalive · zombie/ghost force-end · timeouts"]
end
%% ---------------- DETECTOR ----------------
subgraph DET["CycleDetector state machine (cycle_detector.py)"]
SM["OFF -> STARTING -> RUNNING <-> PAUSED -> ENDING -> OFF<br/>(+ DELAY_WAIT, ANTI_WRINKLE, INTERRUPTED, FINISHED/CLEAN/FORCE_STOPPED)"]
GATE["dual start gate<br/>time_above & energy_since_idle"]
DYN["dynamic pause/end thresholds<br/>from p95 sample gap"]
DISH["dishwasher end-spike<br/>+ pump-out wait"]
EG["ML end-guard<br/>(defer only, opt-in)"]
TD["terminal-drop<br/>(shorten only, pure stats)"]
end
%% ---------------- MATCHING ----------------
subgraph MATCH["Matching, 5-min loop, executor (analysis.py + profile_store.py)"]
TRIG["match trigger (fire-and-forget)"]
WORK["compute_matches_worker<br/>S1 duration gate · S2 corr+MAE · S3 DTW · S4 dur/energy"]
G5["Stage 5 group collapse + member pick"]
PERS["persistence gate<br/>+ ML early-commit >= 0.85"]
AMB["ambiguity gate<br/>top1-top2 < 0.05"]
MR["MatchResult<br/>name · confidence · is_ambiguous · ranking"]
end
%% ---------------- PROGRESS / ETA ----------------
subgraph PROG["progress.py (single source of truth)"]
PH["estimate_phase_progress"]
MLP["ml_progress_percent<br/>(regressor blend)"]
PRM["phase_remaining<br/>phase_match.phase_eta (opt-in)"]
CP["compute_progress<br/>blend + EMA + monotonic + phase-ETA blend"]
OUT["remaining · progress% · phase · projected energy/cost"]
end
%% ---------------- CYCLE END ----------------
subgraph CEND["Cycle-end pipeline (manager.py + learning.py)"]
OCE["_on_cycle_end<br/>integrate_wh · ghost/pump-out suppression"]
APCE["_async_process_cycle_end<br/>persist · artifacts · conformance · anomalies · cost freeze"]
LEARN["learning._maybe_request_feedback<br/>auto-label vs feedback (quality+conformance+warmup)"]
REB["envelope rebuild + phase_profile cache"]
SNAP["confirm match-ranking snapshot"]
end
%% ---------------- NOTIFICATIONS ----------------
subgraph NOTIF["Notifications (notification_rules.py decides, manager delivers)"]
NR["decisions<br/>quiet hours · milestones · pre-completion"]
DEL["delivery<br/>per-event targets · presence · quiet queue · lifecycle tag"]
EVT["HA events<br/>cycle_started/ended · pump_stuck · ml_training_complete"]
end
%% ---------------- ENTITIES ----------------
subgraph ENTG["Entities (sensor/binary_sensor/select/button)"]
SIG["dispatcher signal<br/>ha_washdata_update_{entry}"]
ENTS["25 entities (state, program, remaining, progress, energy, ...)"]
end
%% ---------------- ML ----------------
subgraph MLS["ML subsystem (ml/, opt-in, NumPy-only)"]
RANK["ranking history + derived labels"]
TT["training_task (scheduled)<br/>promote by AUC / MAE margin"]
RS["resolve_scorer / resolve_regressor"]
MT["matching_tuner -> matching_config override"]
end
%% ---------------- PERSISTENCE ----------------
subgraph PST["ProfileStore, Store v11 (profile_store.py)"]
PS["profiles · past_cycles · reference_cycles<br/>groups · phases · ml_model_versions · matching_config"]
end
%% ---------------- GUIDANCE ----------------
subgraph GDE["Guidance"]
SUG["suggestion_engine<br/>clean cycles -> suggestions -> reconcile"]
SA["setup_advisor.compute_setup_phase -> Setup Card"]
end
%% ---------------- WS / PANEL ----------------
subgraph WSP["ws_api (106 cmds, RBAC) + panel (frontend.py)"]
WSC["WebSocket handlers"]
PANEL["Panel (7 tabs)"]
TR["task_registry<br/>playground · reprocess · ML · trim/split/merge/rebuild"]
PILL["header activity pills"]
end
%% ---------------- PLAYGROUND ----------------
subgraph PGB["Playground (playground.py, byte-identical replay)"]
SIM["simulate / history / sweep<br/>reuses real detector + matcher + progress"]
end
%% ---------------- COMMUNITY STORE ----------------
subgraph CST["Community Store (store*.py, opt-in)"]
SB["StoreBridge <-> Firestore"]
ADOPT["adopt -> reference_cycles"]
SHARE["share bundle"]
end
%% ---------------- EDGES ----------------
PWR --> PC
DOOR --> PC
BOOT --> PC
PC --> DB
PC --> THR --> REC --> SM
PC --> WD --> SM
SM --- GATE
SM --- DYN
SM --- DISH
EG -.->|defer| SM
TD -.->|shorten| SM
SM -->|running / interval| TRIG
TRIG --> WORK --> G5 --> PERS --> AMB --> MR
PS --> WORK
MR --> PROG
MR -.->|committed name| SM
PH --> CP
MLP --> CP
PRM --> CP
PS --> PRM
CP --> OUT --> SIG --> ENTS
SM -->|cycle end| OCE --> APCE
APCE --> PS
APCE --> LEARN --> REB --> PS
APCE --> SNAP --> RANK
APCE --> NR
OUT --> NR
NR --> DEL --> EVT
DEL --> ENTS
RANK --> TT --> PS
PS --> TT
PS --> RS
RS -.->|gated| EG
RS -.->|gated| PERS
RS -.->|gated| LEARN
RS -.->|gated| MLP
PS --> MT --> PS
PS --> SUG --> PANEL
PS --> SA --> PANEL
USER --> PANEL --> WSC --> PS
WSC --> TR --> PILL --> PANEL
TR --> SIM
SIM --> WORK
SIM --> CP
PANEL --> SB
SB --> ADOPT --> PS
PS --> SHARE --> SB
Reading the map, briefly: power readings flow left-to-right through the manager into the
detector; a 5-minute executor loop matches the running cycle against stored profiles;
progress.py turns the match into remaining/phase/energy which drive the entities and gate
notifications; cycle end persists the cycle, runs the learning router, and (opt-in) feeds the
ML training data. Everything the panel does goes through the WebSocket layer and, for long
jobs, the task registry. The Playground replays stored cycles through the same detector +
matcher + progress.py so its output matches live behaviour byte-for-byte. The opt-in ML
scorers/regressors and the Community Store are the only dotted (conditional) paths into the
core.
The whole runtime as one graph: every decision diamond and every loop-back. Diamonds are
decisions; -->|yes|/-->|no| (or labelled) edges are the branch taken; dotted edges are
opt-in/feedback paths, state transitions ("next reading"), and the recurring loops
(per-reading, 5-min match, watchdog poll, 60s state-expiry poll, PAUSED/ENDING resume).
flowchart TD
%% ================= POWER INGESTION =================
RDG(["Power sensor reading (t, W)"]) --> ING1{"unavailable / unknown / non-numeric?"}
ING1 -->|yes| DROP1["drop"]
ING1 -->|no| ING2["diag_buffer.record_power (raw, pre-throttle)"]
ING2 --> ING3{"recorder.is_recording?"}
ING3 -->|yes| REC["feed recorder - update power - notify"] --> DROP1
ING3 -->|no| ING4{"power >= min_power AND<br/>< sampling_interval since last?"}
ING4 -->|"yes (throttle)"| DROP1
ING4 -->|"no (low power bypasses)"| ING5["learning.process_power_reading"]
ING5 --> PR0["detector.process_reading(power, t)"]
%% ================= DETECTOR HOT PATH =================
PR0 --> D1{"dt = t - last < 0?"}
D1 -->|yes| DDROP["update last_t - return"]
D1 -->|no| D2{"manual-stop lockout active?"}
D2 -->|no| D4["update cadence (p95 dt) - MA buffer"]
D2 -->|yes| D3{"power < start_threshold_w?"}
D3 -->|yes| D3a["clear lockout"] --> D4
D3 -->|no| D3b{"lockout_high < 180s?"}
D3b -->|yes| DDROP2["swallow - return"]
D3b -->|no| D3c["release lockout (back-to-back load)"] --> D4
D4 --> D5{"state in OFF/DELAY_WAIT/STARTING/UNKNOWN?"}
D5 -->|yes| D6["threshold = start_threshold_w"]
D5 -->|no| D7["threshold = stop_threshold_w"]
D6 --> D8{"power >= threshold (is_high)?"}
D7 --> D8
D8 -->|yes| D9["time_above += dt - energy_since_idle += P*dt/3600"]
D8 -->|no| D10["time_below += dt"]
D9 --> DISP{"current state"}
D10 --> DISP
DISP -->|OFF| OFF1
DISP -->|ANTI_WRINKLE| AW1
DISP -->|DELAY_WAIT| DW1
DISP -->|STARTING| ST1
DISP -->|RUNNING| RUN1
DISP -->|PAUSED| PAU1
DISP -->|ENDING| END1
DISP -->|"FINISHED / INTERRUPTED / FORCE_STOPPED"| TERMx["terminal: manager owns -> OFF (see expiry)"]
%% ================= OFF =================
OFF1{"is_high?"}
OFF1 -->|yes| OFFstart["seed cycle -> STARTING"]
OFF1 -->|no| OFFdelay{"delay-start enabled AND<br/>stop_w < start_w AND power in band?"}
OFFdelay -->|yes| OFFband{"band_seconds >= delay_confirm?"}
OFFband -->|yes| OFFtoDW["-> DELAY_WAIT"]
OFFband -->|no| OFFwait["accumulate band - stay OFF"]
OFFdelay -->|no| OFFstay["stay OFF"]
OFFstart -.->|next reading| ST1
OFFtoDW -.->|next reading| DW1
%% ================= ANTI_WRINKLE =================
AW1{"burst peak > aw_max_power OR<br/>burst dur > aw_max_duration?"}
AW1 -->|yes| AWre["seed from burst -> STARTING"]
AW1 -->|no| AWidle{"idle >= max(end_thr,120s)<br/>OR in-state > 7200s?"}
AWidle -->|yes| AWoff["-> OFF"]
AWidle -->|no| AWstay["stay ANTI_WRINKLE"]
AWre -.->|next reading| ST1
%% ================= DELAY_WAIT =================
DW1{"power >= start_w?"}
DW1 -->|yes| DWstreak{"high streak >= start_duration?"}
DWstreak -->|yes| DWstart["seed cycle -> STARTING"]
DWstreak -->|no| DWanchor["anchor high - stay"]
DW1 -->|no| DWlow{"power < stop_w AND true_off >= 30s?"}
DWlow -->|yes| DWoff["-> OFF (cancelled)"]
DWlow -->|no| DWto{"in-state >= 8h?"}
DWto -->|yes| DWoff
DWto -->|no| DWstay["stay DELAY_WAIT"]
DWstart -.->|next reading| ST1
%% ================= STARTING =================
ST1{"time_above >= start_duration AND<br/>energy_since_idle >= start_energy?"}
ST1 -->|yes| STrun["-> RUNNING"]
ST1 -->|no| STabort{"not is_high AND time_below > 1s<br/>AND NOT verified_pause?"}
STabort -->|yes| SToff["-> OFF (false start)"]
STabort -->|no| STstay["stay STARTING"]
STrun -.->|next reading| RUN1
%% ================= RUNNING =================
RUN1{"time_below >= dynamic_pause_threshold<br/>(max 15s, 3x p95 dt)?"}
RUN1 -->|yes| RUNpause["force match -> PAUSED"]
RUN1 -->|no| RUNsafe{"in-state > 8h?"}
RUNsafe -->|yes| RUNfs["finish force_stopped"]
RUNsafe -->|no| RUNmatch["periodic match (match_interval)"]
RUNpause -.->|next reading| PAU1
RUNmatch -.->|5-min loop| MO1
%% ================= PAUSED =================
PAU1{"is_high?"}
PAU1 -->|yes| PAUresume["-> RUNNING (resume)"]
PAU1 -->|no| PAUend{"time_below >= dynamic_end_threshold?"}
PAUend -->|yes| PAUto["-> ENDING"]
PAUend -->|no| PAUmatch["periodic match - stay PAUSED"]
PAUresume -.->|resume loop| RUN1
PAUto -.->|next reading| END1
PAUmatch -.->|5-min loop| MO1
%% ================= ENDING =================
END1{"is_high reading?"}
END1 -->|yes| ENhi{"arm gate: expected<=0 OR<br/>duration >= expected x 0.85?"}
ENhi -->|yes| ENspike["record end-spike"]
ENhi -->|no| ENnoarm["mid-cycle: do not arm"]
ENspike --> ENterm{"terminal_spike?<br/>in-state>=120s; dishwasher near_expected 0.90"}
ENnoarm --> ENterm
ENterm -->|yes| ENstay["stay ENDING"]
ENterm -->|no| ENpast{"past_expected (>= 0.98)?"}
ENpast -->|yes| ENstay
ENpast -->|no| ENresume["-> RUNNING (genuine activity)"]
END1 -->|no| ENlow["periodic match"]
ENlow --> ENsmart{"Smart Termination: matched AND<br/>dur >= expected x smart_ratio AND conf >= 0.4<br/>AND !ambiguous AND !prefix_ambiguous<br/>AND in-state >= smart_debounce<br/>AND dishwasher pump-out wait done?"}
ENsmart -->|yes| ENfin1["finish completed (SMART, keep_tail)"]
ENsmart -->|no| ENtd{"Terminal-drop (opt-in): provider AND<br/>!verified_pause AND off_delay > 90 AND<br/>time_below >= 90 AND is_terminal_drop?"}
ENtd -->|yes| ENfin2["finish interrupted (TERMINAL_DROP)"]
ENtd -->|no| ENto{"time_below >= max(off_delay, min_off_gap)?"}
ENto -->|no| ENwait["stay ENDING"]
ENto -->|yes| ENwin{"recent_window energy <= end_energy_threshold?"}
ENwin -->|no| ENblock["blocked by energy gate - stay"]
ENwin -->|yes| ENdef["evaluate _should_defer_finish"]
ENresume -.->|resume loop| RUN1
ENstay -.->|resume possible| END1
ENlow -.->|5-min loop| MO1
%% ================= DEFER LADDER =================
ENdef --> DF1{"verified_pause?"}
DF1 -->|yes| DFdefer["DEFER (stay ENDING)"]
DF1 -->|no| DF2{"dishwasher AND dur < 1800s?"}
DF2 -->|yes| DFdefer
DF2 -->|no| DF3{"no profile OR expected <= 0?"}
DF3 -->|yes| DFfin["FINISH"]
DF3 -->|no| DF4{"dur > expected + 14400s (4h)?"}
DF4 -->|yes| DFfin
DF4 -->|no| DF5{"ML end-guard: conf >= 0.55 AND<br/>P(end) < 0.5 AND defer < 1800s?"}
DF5 -->|yes| DFdefer
DF5 -->|no| DF6{"dishwasher dur < expected x 0.85?"}
DF6 -->|yes| DFdefer
DF6 -->|no| DF7{"dishwasher pump-out wait?"}
DF7 -->|yes| DFdefer
DF7 -->|no| DF8{"conf < 0.55?"}
DF8 -->|yes| DFfin
DF8 -->|no| DF9{"dur < expected x min_duration_ratio?"}
DF9 -->|yes| DFdefer
DF9 -->|no| DFfin
DFdefer -.->|re-eval next reading| END1
DFfin --> FIN
ENfin1 --> FIN
ENfin2 --> FIN
RUNfs --> FIN
%% ================= FINISH =================
FIN["_finish_cycle"] --> FINstat{"duration < interrupted_min (150s)<br/>OR < completion_min (600s)?"}
FINstat -->|yes| FINint["status = interrupted"]
FINstat -->|no| FINcomp["status = completed"]
FINint --> FINcb["build offset power_data -> _on_cycle_end"]
FINcomp --> FINcb
FINcb --> CE1
%% ================= MATCH ORCHESTRATION (5-min loop) =================
MO1{"manual program active?"}
MO1 -->|yes| MOman["return (program, 1.0, dur, phase)"]
MO1 -->|no| MO2{"already matching OR no readings<br/>OR has_real_profiles == false?"}
MO2 -->|yes| MOskip["skip"]
MO2 -->|no| S1
%% ================= MATCH PIPELINE (Stages 1-5) =================
S1{"Stage 1: duration ratio in [min,max]?"}
S1 -->|no| S1rej["reject candidate"]
S1 -->|yes| S2["Stage 2: score = 0.45 corr + 0.55 peak-rel MAE"]
S2 --> S2k{"score >= keep_min (0.1)?"}
S2k -->|no| S1rej
S2k -->|yes| S3{"dtw_bandwidth > 0?"}
S3 -->|yes| S3d["Stage 3: DTW-lite top5 - blend 0.5/0.5 - re-sort"]
S3 -->|no| S4
S3d --> S4["Stage 4: + duration/energy agreement (0.22/0.22)"]
S4 --> S5{"cohesive group? (cohesion >= 0.80)"}
S5 -->|yes| S5g["collapse to aggregate - pick member"]
S5 -->|no| S5i["keep individual"]
S5g --> S5m{"member fit < 0.55 x group<br/>OR overrun > 1.05?"}
S5m -->|yes| S5dg["downgrade to uncertain"]
S5m -->|no| CMT
S5i --> CMT{"top1 score >= 0.4 commit?"}
S5dg --> CMT
CMT -->|no| MRunk["MatchResult: unknown / detecting"]
CMT -->|yes| AMBq{"ambiguous? top1 - top2 < 0.05"}
AMBq -->|yes| MRamb["uncertain -> feedback"]
AMBq -->|no| MRok["MatchResult: confident"]
MRok --> MO3
MRamb --> MO3
MRunk --> MO3
%% ================= PROGRAM SWITCHING =================
MO3{"current program?"}
MO3 -->|"detecting..."| SW1{"conf >= 0.15 AND !ambiguous?"}
SW1 -->|no| SWstay["stay detecting"]
SW1 -->|yes| SW2{"persistent (counter >= match_persistence)<br/>OR ML early-commit (score >= 0.85, conf >= 0.30)?"}
SW2 -->|yes| SWset["commit program"]
SW2 -->|no| SWinc["bump candidate counter - stay"]
MO3 -->|matched| SW3{"divergence: conf < peak x 0.6?"}
SW3 -->|yes| SW3p{"unmatch counter >= persistence?"}
SW3p -->|yes| SWrevert["revert to detecting"]
SW3p -->|no| SWincd["bump unmatch counter"]
SW3 -->|no| SW4{"override: conf > 0.8 AND gap > 0.15,<br/>or persistent + trend + gap > 0.05?"}
SW4 -->|yes| SWset
SW4 -->|no| SWkeep["keep current"]
SWset --> DUPD
SWrevert --> DUPD
SWkeep --> DUPD
MOman --> DUPD
%% ================= DETECTOR UPDATE + LIVE =================
DUPD["detector.update_match(name, conf, dur, phase,<br/>is_mismatch, is_ambiguous, is_prefix_ambiguous)"]
DUPD --> DUP2{"user paused?"}
DUP2 -->|yes| DUPvp["verified_pause = True (authoritative)"]
DUP2 -->|no| DUPenv{"envelope verify low-power phase?"}
DUPenv -->|yes| DUPvp
DUPenv -->|no| DUPclear["clear verified_pause"]
DUPvp --> UPR["_update_remaining_only"]
DUPclear --> UPR
DUPD -.->|expected_duration + conf feed| END1
%% ================= PROGRESS / ETA =================
UPR --> P1{"matched profile AND >= 10 pts?"}
P1 -->|no| Plin["linear fallback (alpha 0.10)"]
P1 -->|yes| Pph["phase-aware base"]
Pph --> P2{"ml_pct? (remaining_time regressor, opt-in)"}
Plin --> P2
P2 -->|yes| Pblend["blended = 0.5 base + 0.5 ml_pct"]
P2 -->|no| Pbase["base only"]
Pblend --> P3{"power variance tier?"}
Pbase --> P3
P3 -->|"< 50W"| Pa1["EMA alpha 0.20"]
P3 -->|"50-100W"| Pa2["EMA alpha 0.10"]
P3 -->|"> 100W"| Pa3["EMA alpha 0.05"]
Pa1 --> Pbd{"backward drop > device threshold?"}
Pa2 --> Pbd
Pa3 --> Pbd
Pbd -->|yes| Pdamp["heavy damp 95/5"]
Pbd -->|no| Pema["apply EMA"]
Pdamp --> Pph2{"phase_remaining_s set? (opt-in)"}
Pema --> Pph2
Pph2 -->|yes| Pblend2["phase_pct = elapsed/(elapsed+phase_s); blend into<br/>phase-progress before EMA/monotonic (f = base_progress/100)"]
Pph2 -->|no| Pbc["remaining = matched_dur x (1 - smoothed/100)"]
Pblend2 --> Pcap["cap progress 99 live"]
Pbc --> Pcap
Pcap --> PE{"progress >= 3% AND energy_so_far > 0?"}
PE -->|yes| PEml{"total_energy regressor? (opt-in)"}
PEml -->|yes| PEr["projected = regressor - max(., so_far)"]
PEml -->|no| PEl["projected = so_far / (progress/100)"]
PE -->|no| PEskip["no projection yet"]
Pcap --> ANm{"elapsed > expected x 1.5?"}
ANm -->|yes| ANov["cycle_anomaly = overrun (visible only)"]
ANm -->|no| ANok["no live anomaly"]
Pcap --> ENTS2["update entities via dispatcher signal"]
UPR --> PCq{"pre-complete: lead set AND not fired AND<br/>remaining <= lead AND progress < 100 AND !ambiguous?"}
PCq -->|yes| PCfire["fire pre-completion notification"]
PCq -->|no| PCno["skip"]
UPR --> LVq{"live progress: RUNNING/PAUSED/ENDING<br/>AND live services AND throttle ok?"}
LVq -->|yes| LVfire["update live card (bar + countdown)"]
LVq -->|no| LVno["skip"]
%% ================= CYCLE END =================
CE1["_on_cycle_end: stop timers - integrate_wh"] --> CEg{"ghost? dur < 60s AND energy < 0.05Wh<br/>OR dishwasher pump-out ghost?"}
CEg -->|yes| CEdisc["discard (no persist/notify/learn) - arm expiry"]
CEg -->|no| CE2["capture race token - schedule async tail"]
CE2 --> CE3{"still detecting?"}
CE3 -->|yes| CE3m["final match on full data (thr 0.15, ignore ambiguity)"]
CE3 -->|no| CE4
CE3m --> CE4["freeze locals (B1) - conformance - artifacts"]
CE4 --> CEan1{"duration < median x 0.55?"}
CEan1 -->|yes| CEun["anomaly = underrun"]
CEan1 -->|no| CEov{"elapsed > expected x 1.5?"}
CEov -->|yes| CEovr["anomaly = overrun"]
CEov -->|no| CEnoan["no duration anomaly"]
CE4 --> CEz{"|energy z-score| > 2.5?"}
CEz -->|yes| CEes["energy_anomaly (high/low)"]
CEz -->|no| CEze["energy normal"]
CE4 --> CEml{"ml_models_enabled?"}
CEml -->|yes| CEq["ML quality score (pre-persist)"]
CEml -->|no| CEnoq["skip quality"]
CEq --> CEadd
CEnoq --> CEadd["add_cycle - rebuild envelope - lifetime counters"]
CEadd --> LR1
CEadd --> EVend["fire EVENT_CYCLE_ENDED (exclude heavy)<br/>- finish notification - milestone"]
CEadd -.->|ranking + labels| MLtrain
%% ================= LEARNING ROUTING =================
LR1{"non-imported profile AND < 5 labeled cycles (warmup)?"}
LR1 -->|yes| LRreq["request verification (Cycles queue, no notification)"]
LR1 -->|no| LR2{"ML quality P(problem) >= 0.65?"}
LR2 -->|yes| LRreq
LR2 -->|no| LR3{"envelope conformance < 0.40?"}
LR3 -->|yes| LRreq
LR3 -->|no| LR4{"route conf >= auto_label (0.9)?"}
LR4 -->|yes| LRauto["auto-label - rebuild envelope"]
LR4 -->|no| LR5{"route conf >= learning (0.6)?"}
LR5 -->|yes| LRpend["pending feedback record"]
LR5 -->|no| LRskip["skip (log only)"]
LRauto --> CEreset
LRpend --> CEreset
LRreq --> CEreset
LRskip --> CEreset
CEreset{"back-to-back: race token changed?"}
CEreset -->|yes| CEkeep["keep new cycle's live fields (skip reset)"]
CEreset -->|no| CEoff["reset terminal - progress 100 - arm expiry timer"]
%% ================= NOTIFICATION DISPATCH =================
EVend --> NO1{"quiet hours? finish-type event AND<br/>in window AND allow_deferral?"}
NO1 -->|yes| NOq["queue -> flush at window end"]
NO1 -->|no| NO2{"presence: notify_only_when_home AND nobody home?"}
NO2 -->|yes| NOp["pending -> release on home"]
NO2 -->|no| NO3["run actions (script) + per-event services"]
NO3 --> NO4{"LIVE event?"}
NO4 -->|yes| NO4m{"target is mobile_app_*?"}
NO4m -->|no| NOskip["skip target"]
NO4m -->|yes| NOsend["send (progress / countdown / LiveActivity)"]
NO4 -->|no| NO5{"any service sent?"}
NO5 -->|no| NOpn["persistent-notification fallback"]
NO5 -->|yes| NOsend
NOq -.->|window end| NO2
NOp -.->|person home| NO3
%% ================= WATCHDOG (poll loop) =================
WD0(["watchdog tick (watchdog_interval)"]) --> WDp{"pump AND elapsed >= stuck_duration?"}
WDp -->|yes| WDpe["fire EVENT_PUMP_STUCK"]
WDp -->|no| WDz{"matched AND elapsed > expected x3 AND > 14400s?"}
WDz -->|yes| WDfe["force_end"]
WDz -->|no| WDg{"detecting AND within suspicious window?"}
WDg -->|yes| WDfe
WDg -->|no| WDl{"low power?"}
WDl -->|yes| WDlt{"stale > effective_timeout?"}
WDlt -->|yes| WDfe
WDlt -->|no| WDka["inject 0W keepalive"]
WDl -->|no| WDh{"silence > no_update_timeout?"}
WDh -->|yes| WDhw{"within expected + 14400s?"}
WDhw -->|yes| WDref["inject refresh reading"]
WDhw -->|no| WDfe
WDh -->|no| WDok["ok"]
WD0 -.->|loop| WD0
WDka -.->|advances detector| PR0
WDref -.-> PR0
WDfe --> FIN
%% ================= STATE EXPIRY (poll loop) =================
EX0(["expiry tick (60s, after cycle end)"]) --> EXn{"clean nag pending?"}
EXn -->|yes| EXhold["send nag once - hold terminal"]
EXn -->|no| EXpo{"power-off enabled? 0 < power_off_w < stop_w"}
EXpo -->|yes| EXpw{"power < threshold for power_off_delay?"}
EXpw -->|yes| EXoff["_reset_terminal_to_off"]
EXpw -->|no| EXwait["progress bar zeroes - terminal persists"]
EXpo -->|no| EXt{"progress_reset_delay elapsed?"}
EXt -->|yes| EXoff
EXt -->|no| EXwait
EX0 -.->|loop| EX0
CEoff -.->|arms| EX0
CEdisc -.->|arms| EX0
EXoff -.->|state = OFF| OFF1
%% ================= ML CONSUMERS (opt-in, gated) =================
MLtrain["training_task (scheduled)<br/>promote by AUC / MAE margin"]
MLtrain -.-> MLend["end scorer -> defer only"]
MLtrain -.-> MLcommit["live_match scorer -> early commit"]
MLtrain -.-> MLqual["quality scorer -> downgrade"]
MLtrain -.-> MLreg["remaining_time / total_energy regressors"]
MLtd["terminal-drop (pure stats, no model)"]
MLend -.->|"P(end) < 0.5"| DF5
MLcommit -.->|">= 0.85"| SW2
MLqual -.->|">= 0.65"| LR2
MLreg -.-> P2
MLtd -.-> ENtd
%% ================= PANEL / WS / TASKS / PLAYGROUND =================
USR(["user action (panel / service / Assist)"]) --> WS1{"RBAC allows?"}
WS1 -->|no| WSden["deny"]
WS1 -->|yes| WS2{"long op?"}
WS2 -->|yes| WS3["task_registry (progress / cancel / header pill)"]
WS2 -->|no| WS4["direct handler -> profile_store / manager"]
WS3 --> PGsim["Playground replay:<br/>same detector + matcher + progress"]
PGsim -.->|byte-identical| PR0
WS4 -.-> ENTS2
%% ================= COMMUNITY STORE (opt-in) =================
CS0{"online features enabled?"}
CS0 -->|yes| CSadopt["adopt -> reference_cycles"]
CS0 -->|yes| CSshare["share bundle -> Firestore"]
CSadopt -.->|feeds envelope + matcher| S1
%% ================= SETUP ADVISOR =================
SU0{"setup phase (setup_advisor)"}
SU0 -->|"none + reference-only"| SU1c["Phase 1c: run once to confirm download"]
SU0 -->|none| SU0p["Phase 0: record/label or browse store"]
SU0 -->|few labeled| SU2["Phase 2: build coverage"]
SU0 -->|good| SU3["Phase 3: tuning / groups / phases"]
SU0 -->|healthy| SU4["Phase 4: complete (quiet chip)"]
CEadd -.->|recompute| SU0
This high-level flow describes how a user interacts with the integration, from initial setup to daily use and feedback.
graph TD
A[Install Integration] --> B["Add Integration wizard<br/>(name · device type · power sensor · min power)"]
B --> W[Open WashData panel]
W --> SC["Setup Card guides the next step<br/>(setup_advisor phase 0-4)"]
SC --> C{Action?}
C -- "Create Profile" --> D["Create Profile (Profiles tab)"]
C -- "Record Cycle" --> E["Start Recording (Overview)"]
C -- "Normal Use (Recording mode: OFF)" --> F["Run Appliance"]
D -- "Recording mode: OFF" --> F
E --> G[Run Cycle]
G -- "Recording mode: ON" --> F
F -- "Recording mode: ON" --> H["Stop Recording (Overview)"]
H --> I[Save as Past Cycle]
I --> J[Create Profile from Cycle]
F -- "Recording mode: OFF" --> K[Detection Logic]
subgraph Detection
K -- "Match Found" --> L[Monitor Progress]
K -- "No Match" --> M["Track as 'Unknown'"]
end
L --> N[Cycle Complete]
M --> N
N --> O["Update Stats & Envelopes"]
O --> P{Check Confidence}
P -- "High (>=0.9)" --> Q[Auto-Label Cycle]
P -- "Medium (0.6-0.9)" --> R[Request Feedback]
P -- "Low (<0.6)" --> S[Log to History]
R --> T["User Confirms/Corrects"]
T --> U["System Learns"]
How raw power sensor data is processed into cycle states.
sequenceDiagram
participant Sensor as Power Sensor
participant Manager as WashDataManager
participant Detector as CycleDetector
participant Matcher as ProfileStore (executor)
Sensor->>Manager: State Change (Power W)
Manager->>Manager: diag_buffer.record + throttle (low power bypasses)
Manager->>Detector: process_reading(time, watts)
rect rgb(20, 20, 20)
Note over Detector: State Machine Logic
Detector->>Detector: Check Gates (Start/End Energy)
Detector->>Detector: Update State (OFF/RUNNING)
end
Detector-->>Manager: State Changed (e.g. STARTING -> RUNNING)
loop Every match_interval (~5 min) while RUNNING
Detector->>Manager: profile_matcher (fire-and-forget trigger)
Manager->>Matcher: async_match_profile (executor, NumPy)
Matcher->>Matcher: Stage 1-5 pipeline
Matcher-->>Manager: MatchResult (name, confidence, ambiguity)
Manager->>Manager: switching (persistence / ML commit) + progress.py
end
Manager->>Manager: dispatcher signal -> entities (publish-on-change)
The core finite state machine logic governing cycle lifecycle.
stateDiagram-v2
[*] --> OFF
OFF --> DELAY_WAIT: Standby band held (delay-start, opt-in)
DELAY_WAIT --> STARTING: Power >= start_threshold_w
DELAY_WAIT --> OFF: True-off or delay timeout
OFF --> STARTING: Power >= start_threshold_w (sustained)
STARTING --> RUNNING: time_above >= start_duration AND energy >= start_energy
STARTING --> OFF: Gates not met (spike)
RUNNING --> PAUSED: Power below pause threshold
PAUSED --> RUNNING: Power back above threshold
RUNNING --> ENDING: Low-power run begins
PAUSED --> ENDING: Low-power run sustained
ENDING --> RUNNING: Power/energy resumes (false end; ML end-guard may defer)
ENDING --> FINISHED: off_delay + energy gate, or Smart Termination near expected
ENDING --> INTERRUPTED: Short run (< completion_min), or terminal-drop fast finalize (opt-in)
ENDING --> FORCE_STOPPED: Watchdog / no-update timeout
FINISHED --> ANTI_WRINKLE: Dryer tumble pulses (opt-in)
ANTI_WRINKLE --> OFF: True-off after idle
FINISHED --> CLEAN: Door sensor configured, laundry not yet removed
CLEAN --> OFF: Door opened (unloaded); or power-off (opt-in, #284)
FINISHED --> OFF: Progress-reset delay (default); or power < power_off_threshold for power_off_delay (opt-in, #284)
INTERRUPTED --> OFF: Progress-reset delay; or power-off (#284)
FORCE_STOPPED --> OFF: Progress-reset delay; or power-off (#284)
FINISHED --> [*]
Terminal → Off is a single owner. All terminal states (FINISHED / INTERRUPTED / FORCE_STOPPED, plus the CLEAN overlay) return to OFF through one place,
manager._reset_terminal_to_off, driven by_handle_state_expiry. By default that fires after the Progress Reset Delay. When the opt-in Power Off Threshold (power_off_threshold_w, issue #284) is set belowstop_threshold_w, power-based Off takes over the transition: the terminal state persists until power stays below the threshold forpower_off_delay, and the progress-reset timer then only clears the progress bar (it no longer forces OFF).ANTI_WRINKLE → OFFkeeps its own idle/timeout logic in the detector and is unaffected.
The logic used to identify which profile matches the current cycle.
graph TD
A[Raw Power Data] --> B[Adaptive resample and align]
B --> C{Stage 1: Fast Reject}
C -- "Duration ratio < 0.10 or > 1.5" --> D[Discard]
C -- Pass --> E["Stage 2: Core Similarity (0.45 corr + 0.55 peak-relative MAE)"]
E --> F["Stage 3: DTW-Lite refine top 5 (ensemble; always on when band > 0)"]
F --> G["Stage 4: Duration + Energy agreement (0.22 each)"]
G --> H["Stage 5: Profile groups (collapse cohesive near-duplicates, pick member)"]
H --> I{"Top-1 score >= 0.4 commit threshold?"}
I -- No --> M["Unknown / Detecting..."]
I -- Yes --> J{"Ambiguous? top1 - top2 < 0.05"}
J -- Yes --> R["Uncertain -> request feedback"]
J -- No --> L[Match Confirmed]
How the system adapts to user corrections.
graph TD
A["Cycle complete + matched"] --> W{"Warm-up? profile has < 5 labelled cycles"}
W -- yes --> RQ["Route to Cycles review queue<br/>(no notification)"]
W -- no --> QG{"ML quality P(problem) >= 0.65?<br/>(opt-in)"}
QG -- yes --> RQ
QG -- no --> CF{"envelope conformance < 0.40?"}
CF -- yes --> RQ
CF -- no --> B{"route confidence?"}
B -- ">= 0.9 (auto_label)" --> C["Auto-label cycle"]
C --> D["Rebuild envelope + recompute stats"]
B -- "0.6 - 0.9 (learning)" --> RQ
B -- "< 0.6" --> J["Log to history (no action)"]
RQ --> G{"User acts in panel"}
G -- Confirm --> H["Confirm label + feedback record"]
G -- Correct --> I["Re-label cycle -> recompute duration stats<br/>from all labelled cycles (outlier-filtered)"]
H --> D
I --> D
The confidence ladder is the routing confidence: a warm-up profile, a suspicious ML quality score (opt-in), or low envelope conformance each downgrade an otherwise-high-confidence match to the review queue. Feedback is surfaced in the panel's Cycles tab, never as a notification. A correction re-labels the cycle and recomputes the profile's duration statistics from all its labelled cycles (outlier-filtered) - the old EWMA nudge was removed.
Problem: Real washers don't run for exact programmed times. Load size, water temperature, and soil level cause natural variance of 10-20%.
Solution: (the matcher tolerance is configurable; current default ±25% via profile_duration_tolerance)
- Mock socket simulates ±15% realistic duration variance
- Profile matching tolerates up to ±25% variance (was ±50%)
- Better real-world detection accuracy, fewer false negatives
Files Modified:
-
devtools/mqtt_mock_socket.py- Added--variabilityargument for realistic duration variance. -
custom_components/ha_washdata/profile_store.py- Updated duration tolerance and matching logic.
How It Works (Duration Filter):
# Profile matching logic (Initial Filter)
duration_ratio = actual_duration / expected_duration
# Accepts if within range (current default: 0.10 - 1.5; see const.py MATCH_* / CLAUDE.md)
# This prevents comparing apples to oranges (e.g. 30min vs 2h cycles)Testing:
python3 devtools/mqtt_mock_socket.py --speedup 720 --default LONG
# Watch for: [VARIANCE] Applied ±X.X% duration varianceWhy: Distinguish natural completions from abnormal endings and restarts.
Statuses:
- ✓
completed- Natural finish afteroff_delayin low-power wait. - ✓
force_stopped- Watchdog finalized while already in low-power wait; treated as success. - ✗
interrupted- Abnormal early end: very short run or abrupt power cliff that never recovers. - ⚠
resumed- Active cycle restored after HA restart.
Logic:
- Detector tracks low-power window and elapsed time;
force_end()maps tocompletedwhen low-power wait ≥off_delay, elseforce_stoppedand_should_mark_interruptedcan reclassify short/abrupt runs.
UI & Scoring:
- ✓ cases are considered successful; ✗ is flagged as abnormal; ⚠ retains reduced confidence.
Problem:
- Users need a single catalog to manage phases across all appliance types.
- At the same time, profile phase assignment should stay device-specific.
- Global phases (empty
device_type, meaning "All Devices") are present in per-device lists and can appear duplicated in edit/delete selectors.
Solution:
- Phase Catalog management view aggregates all device types and renders grouped sections by device label.
- Assign Phase flow keeps strict device-type filtering.
- Edit/Delete selectors now use a scoped internal key (
device_type::phase_name) and dedupe by(name, device_type)so global phases are shown once.
Implementation Notes:
- UI selectors in config flow track selected phase name and selected phase scope separately.
- Update/delete operations resolve against the selected scope, preventing accidental edits in the wrong device context.
- Global phases are displayed as "All Devices" and not repeated for each device type.
Problem: Progress stayed stuck at last calculated value when cycle ended; no clear completion signal or unload time tracking.
Solution:
- Progress reaches 100% immediately when cycle completes (clear signal)
- Progress stays at 100% for the Progress Reset Delay (default 30 min,
progress_reset_delay) as the unload window - After that idle window, progress automatically resets to 0% and the state returns to Off
- If a new cycle starts within the window, the reset is cancelled
-
Power-based Off (opt-in, #284): when
power_off_threshold_w > 0(and belowstop_threshold_w), the state returns to Off as soon as power stays below the threshold forpower_off_delayseconds instead of on the timer. In that mode the Progress Reset Delay still clears the progress bar but no longer forces Off, so a finished-but-still-on machine stays in Finished/Clean until it is actually switched off. Evaluated only in terminal states, so a mid-cycle soak is never read as Off; disabled (0) leaves behaviour byte-identical.
Files Modified:
-
custom_components/ha_washdata/manager.py- Complete implementation
State Flow:
RUNNING → COMPLETE
↓
Progress = 100% (cycle finished)
Start 30-min idle timer (progress_reset_delay)
↓
[Scenarios]
├─ New cycle starts within 30min → Cancel reset, progress → 0%
└─ 30min passes with no activity → Progress → 0% (unload complete)
Implementation Details:
| Component | Purpose |
|---|---|
_cycle_completed_time |
Tracks when cycle finished (ISO timestamp) |
_progress_reset_delay |
Configurable idle time (default: 1800s/30min) |
_start_state_expiry_timer() |
Begin countdown after cycle end |
_handle_state_expiry() |
Async callback checking if idle threshold passed |
_stop_state_expiry_timer() |
Cancel reset if new cycle starts |
Entity Updates:
# During cycle (0-100%)
sensor.washer_progress: "45"
# Cycle ends
sensor.washer_progress: "100"
# After the reset-delay idle window (default 30 min)
sensor.washer_progress: "0"Problem: System couldn't learn from users or improve over time; no transparency about why cycles were detected a certain way.
Solution:
- Emit feedback request events for high-confidence matches
- Accept user confirmations or corrections via service call
- Learn from corrections (update profile durations conservatively)
- Track all feedback for history and review
Files Created:
-
custom_components/ha_washdata/learning.py(996 lines) - New LearningManager class
Files Modified:
-
custom_components/ha_washdata/manager.py- Integrated learning -
custom_components/ha_washdata/__init__.py- Service handler -
custom_components/ha_washdata/const.py- Constants
When a cycle completes with high-confidence match:
Event: ha_washdata_feedback_requested
Payload:
cycle_id: "abc123xyz"
detected_profile: "60°C Cotton"
confidence: 0.75
estimated_duration: 60 # minutes
actual_duration: 62 # minutes
is_close_match: true
created_at: "2025-12-17T15:30:00+00:00"Call service to confirm detection was correct:
service: ha_washdata.submit_cycle_feedback
data:
entry_id: "integration_entry_id"
cycle_id: "abc123xyz"
user_confirmed: true
notes: "Perfect detection"Correct if the detected program was wrong:
service: ha_washdata.submit_cycle_feedback
data:
entry_id: "integration_entry_id"
cycle_id: "abc123xyz"
user_confirmed: false
corrected_profile: "40°C Delicate"
corrected_duration: 3300 # seconds
notes: "Was actually a delicate cycle"Updated: the old EWMA nudge (
80% old + 20% new) is no longer used. A correction now re-labels the cycle onto the corrected profile (and fixes that cycle's duration), then the profile's envelope is rebuilt and all statistics (avg_duration/min/max) are recomputed from the profile's labelled cycles
- see
learning.py::_apply_correction_learning. Expected duration is a robust statistic over those cycles:profile_store.filter_duration_outliers()drops outliers with Tukey IQR fences (1.5×IQR), falling back to a MAD-based robust-Z filter (≤3.5) when the IQR collapses.
When a user confirms or corrects a cycle:
- Store the confirmation/correction in feedback history.
- On a correction, re-label the cycle onto the corrected profile and set its duration.
- Rebuild the profile envelope and recompute its duration statistics from all its labelled cycles (outlier-filtered) - no single-correction weighting.
- Future matches use the recomputed profile stats.
This recompute-from-labelled-cycles approach is more accurate than a running average: one bad correction can be fixed by re-labelling, and the stats always reflect the current membership rather than the order corrections arrived in.
Get pending feedback:
manager.learning_manager.get_pending_feedback()
# Returns: {cycle_id: {feedback_data...}}Get feedback history:
manager.learning_manager.get_feedback_history(limit=10)
# Returns: [{feedback_record}, ...] sorted by date descGet learning statistics:
manager.learning_manager.get_learning_stats()
# Returns: {
# "total_feedback": 5,
# "confirmations": 3,
# "corrections": 2,
# "pending": 0
# }When ML models are enabled (enable_ml_models option), manager._compute_cycle_quality_score runs the hybrid_curve_quality model at cycle end and stores ml_quality_score (0–1, P(problem)) on the cycle. learning._maybe_request_feedback then reads this: if the score is ≥ ML_QUALITY_SUSPICIOUS_THRESHOLD (0.65), the auto-label is downgraded to a feedback request even if matcher confidence is high — this catches confident-but-wrong labels caused by ghost cycles or corrupted data.
During active matching (manager._async_do_perform_matching), the live_match_commit model scores P(top-1 is correct). When the score is ≥ ML_MATCH_COMMIT_THRESHOLD (0.85) AND raw confidence ≥ 0.30, the profile is committed immediately — bypassing the persistence counter (default: 3 consecutive matching calls). This cuts time-to-first-match for clear, distinctive cycles. Falls back to persistence if ML is disabled or the scorer raises.
Problem: Time-remaining was matched_profile_duration × (1 − progress), i.e. it assumes every run of a program takes the profile's median duration. Real appliances drift — an aging washer, a heavier load, or an eco variant can run materially longer/shorter — and the naive estimate is systematically wrong for those runs.
Solution: the first regression head in the ML subsystem (all others are logistic classifiers). It predicts the cycle completion fraction (elapsed / actual-total) from duration-invariant shape features, so a run that will take 1.5× the median is recognized as only half-done when the naive elapsed/median says 75%.
-
Model kind:
standardized_linear(ridge).trainer.fit_ridgestandardizes both features and target and solves the ridge normal equations in closed form (NumPy only);predict_matrix_spec/predict_value_specun-standardize via the spec'soutput_center/output_scale. No sigmoid, no threshold. -
No shipped baseline: unlike the three classifier heads there is no embedded
*_model.py.engine.resolve_regressor("remaining_time", store)returns a predictor only once on-device training has promoted one; otherwise(None, None)and live behavior is unchanged. -
Features (
feature_extraction.PROGRESS_FEATURE_COLUMNS):elapsed_over_expected(the naive estimate, kept as the first column so the baseline is trivially recoverable),energy_over_expected,mean_power_over_peak,recent_power_over_peak,tail_slope_norm(a declining tail ⇒ near the end),active_fraction,elapsed_log. -
Training data (
training_task._progress_dataset): each clean completed cycle is cut at several elapsed fractions (0.15…0.90); the target is the prefix's true completion fraction. Every stored trace becomes a handful of supervised examples — no manual labeling. -
Promotion gate (
training_task._train_regression_capability): the trained regressor is promoted only when its held-out MAE beats the naive elapsed/expected estimate (feature column 0) byML_TRAINING_REGRESSION_MARGIN(5%). On synthetic variable-duration cycles the model reaches ~0.003 MAE vs ~0.12 naive. -
Runtime blend (
manager._ml_progress_percent→_update_remaining_only): the predicted fraction is blended into the phase-awarephase_progressatML_PROGRESS_BLEND_WEIGHT(0.5) before the existing EMA smoothing/monotonicity guards, and also into the linear-fallback progress. Gated onenable_ml_models; a bad model can only nudge, never override, the proven phase estimator, and everything downstream (remaining/total back-calculation) stays consistent because we blend progress, not seconds.
Problem: When power drops to zero, the detector holds the cycle open for effective_off_delay = max(off_delay, min_off_gap) before finalizing — up to 8 min for washing machines, 1 h for dishwashers — because a drop to 0 W is indistinguishable from a legitimate soak / drying pause. So a genuinely-stopped cycle (plug pulled, program cancelled) sits "running" for minutes before the integration reacts, even though it correctly labels it Interrupted afterwards.
Solution: a per-device anomaly heuristic (pure statistics, no trained model, like compute_profile_health) that lets the detector recognize a drop as terminal and finalize fast. It learns, from the device's own history, the earliest point at which that appliance has ever legitimately gone quiet — a drop earlier than that is an anomaly.
-
Baseline (
profile_store.earliest_sustained_quiet_offset): the smallest elapsed offset at which any completed cycle first shows a sustained (≥TERMINAL_DROP_MIN_QUIET_SPAN_S, 60 s) near-zero span. Only completed cycles seed it — interrupted / force-stopped / terminal-drop cycles are exactly the anomalies being caught, so including them would poison the baseline. The strict minimum (not a percentile) is deliberately conservative: one cycle that went quiet early only lowers the baseline, making the detector fire less often. ReturnsNone(⇒ keep the slow path) belowTERMINAL_DROP_MIN_CLEAN_CYCLES(3) completed cycles. -
Familiarity / novelty gate (
device_active_peak_range+ theTERMINAL_DROP_PEAK_FAMILIAR_TOL, 0.4, check inis_terminal_drop): an early drop is only trusted as terminal when the cycle's power level is one the device has produced before — its peak within the historical[min, max]peak band widened by ±40%. A very early drop is below the matcher's duration gate (min_duration_ratio0.10), so match confidence is not available that early to confirm the cycle is recognized; power level is the familiarity signal that is available. A cycle drawing power unlike anything in its history is treated as a possible new program and deferred to the proven slow path rather than assumed to be a stop — the guard against a first-ever program that legitimately goes quiet early being mistaken for a pulled plug. -
Provider (
manager._terminal_drop_provider, baselines cached via_terminal_drop_baselinekeyed by cycle count): delegates to the pureprofile_store.is_terminal_drop(...), which requires all of — clearly ON (peak ≥TERMINAL_DROP_MIN_PEAK_RATIO× stop-threshold), familiar (above), and anomalous (trailing cliff began< TERMINAL_DROP_EARLINESS_RATIO× quiet-baseline). Gated onenable_ml_models; returnsFalse(fully inert) when off. -
Runtime (
cycle_detector._is_terminal_dropin theSTATE_ENDINGfallback): when the provider confirms terminal, the cycle finalizes once power has been sub-threshold forTERMINAL_DROP_OFF_DELAY_SECONDS(90 s) — bypassing the energy/defer gates, because the sustained quiet span already proves the appliance is off and the anomaly check has ruled out an early pause — stampingTerminationReason.TERMINAL_DROPand statusinterrupted. Reaction drops from ~8–10 min to ~2 min. - Asymmetry: the exact mirror-image of the ML end-guard. The end-guard can only ever defer a finish (never end early); the terminal-drop detector can only ever shorten the wait (never end a normal cycle early, since a normal end's drop is not earlier than the device's learned quiet baseline).
Problem: Real cycles contain transient artifacts — most commonly a user opening a dishwasher/washer mid-cycle to add an item (power drops to ~0 and resumes), plus sustained out-of-band dips/spikes. These were invisible: nothing flagged them or explained an odd-looking trace.
Solution: ProfileStore.detect_cycle_artifacts(profile_name, points) reuses the envelope-conformance resampling to compare the trace against the matched profile's [lower, upper] band and classify contiguous deviating segments: a pause (near-zero where the profile expects activity and it resumes afterward — excluded at the very end, which is just the cycle finishing), a sustained below-band dip, or above-band spike. Each event is {type, start_s, end_s, detail (plain English), severity} in the trace's own time offsets; the list is capped to the most significant few, chronological. Pure statistics (no ML), never raises.
Frozen onto cycle_data["artifacts"] at cycle end (manager._async_process_cycle_end) and served by ws_get_cycle_power_data (stored, or computed on the fly for older cycles). The panel shades each artifact span on the cycle graph (_drawCycleEditor bands), surfaces the detail in the existing hover readout (_onGraphHover reads wd.artifacts), lists them under the graph, and shows a ⚠ badge in the Cycles list. No new notification — artifacts live on the graph where users inspect. The events also double as candidate labels for a future supervised anomaly model (the "runtime anomaly model" idea), which needs labeled examples this detector begins to accumulate.
Problem: Users had no visible, device-agnostic signal that a running cycle is taking materially longer than usual. Pump-stuck fires an event (pump-only), and the zombie-killer only acts at 300% (hard termination) — nothing surfaced the common "this ran long" case for the UI.
Solution: manager._update_cycle_anomaly sets _overrun_ratio = elapsed / expected each estimate tick and flags _cycle_anomaly = "overrun" once the ratio crosses CYCLE_OVERRUN_ANOMALY_RATIO (1.5). It is deliberately soft: purely a visible signal, never a notification and never a termination (the zombie-killer still owns hard limits). Surfaced three ways, all in existing places: (1) cycle_anomaly/overrun_ratio attributes on the State sensor while running; (2) frozen onto the cycle as cycle_data["anomaly"]/["overrun_ratio"] at end; (3) an ⏱ badge in the panel's Cycles list. Cleared on idle/off and when no profile is matched; never raises.
Problem: The per-profile signals (health, duration/energy trends) were surfaced as scattered badges; users had no consolidated, actionable "what should I do about this profile" view.
Solution: ProfileStore.compute_profile_advisories (pure statistics; reuses compute_profile_health + compute_profile_trends) returns a ranked list of {profile, severity, code, message} recommendations — e.g. poor fit → "review its cycles or re-record", durations trending longer → "if the appliance changed, re-record/rebuild", energy trending up → "worth checking the appliance". A profile already flagged poor suppresses its (redundant) trend advice. Returned by ws_get_profiles as profile_advisories and rendered as a Recommendations banner in the panel's Profiles tab — an existing surface, never a notification. Warnings rank before info; [] on error.
Problem: The per-profile phase configurator (users draw phase ranges on a profile's envelope; get_profile_phase_ranges) fed check_phase_match, but keyed on raw elapsed seconds. When a cycle runs longer/shorter than the profile's nominal timeline the phase readout drifts (e.g. shows "Spin" while still washing). It was effectively a visual configurator whose output didn't track reality.
Solution: manager._current_phase_from_progress makes that one configurator functional by indexing the same phase ranges with the live ML-blended progress fraction instead of raw elapsed: position = progress_fraction × (max phase end), then the existing check_phase_match lookup. phase_description prefers this live phase, falling back to the matcher's matched_phase then the detector state. No second phase system — one definition (the visual ranges), driven by the progress estimator (which already carries the remaining-time regressor blend). Returns None (clean fallback) when not running, no profile is matched, or the profile has no configured ranges, so existing setups are unaffected. This also lays the groundwork for a future on-device phase classifier: the per-profile ranges + labelled cycles are the training labels — no separate per-cycle labeler needed.
Problem: WashData already freezes each completed cycle's energy_kwh/cost from a configured price (CONF_ENERGY_PRICE_STATIC/CONF_ENERGY_PRICE_ENTITY), but users had no in-flight estimate of what the running cycle will use/cost.
Solution: manager._update_projected_energy prefers the on-device total_energy regressor (manager._ml_energy_total) and falls back to accumulated_energy ÷ progress_fraction when it is unavailable/inert. Cost uses the same price resolution used at cycle end (so the running estimate and the final frozen value are consistent). It clears below a _PROJECTION_MIN_PROGRESS (3%) floor, never projects below energy already consumed, and never raises. Surfaced as projected_energy_kwh / projected_cost attributes on WasherProgressSensor (no new entity or translations); keys are omitted when idle or too early in a cycle.
Problem: the energy_so_far ÷ progress_fraction projection assumes energy accumulates linearly with time. It doesn't — appliances with an early heating phase front-load energy (at 40% of the way through time you may already be at ~85% of total energy), so the time-based projection reads high early in the cycle.
Solution: a second standardized_linear on-device regressor (capability total_energy, target = energy-completion fraction energy_so_far / final_energy) built on the same feature vector as the remaining-time model (progress_features / PROGRESS_FEATURE_COLUMNS) — only the training label differs. training_task._energy_dataset synthesizes the labels from cut prefixes of clean cycles. Its held-out MAE is gated against the naive elapsed_over_expected baseline (which is the current time-based projection), so it is promoted only when it beats the existing method. manager._ml_energy_total predicts the fraction and returns energy_so_far / fraction (floored so an under-confident prediction can't blow up), which _update_projected_energy prefers. No shipped baseline → inert until on-device training promotes one; behaviour is unchanged when the ML opt-in is off.
Problem: consumers such as home energy managers want the matched program's forward-looking load shape (e.g. "a ~2 kW heating spike hits in the next 15 min") to make a "ride out a dip vs. throttle another load" decision — something a scalar time-remaining can't express. WashData already learns this shape (the profile envelope) but kept it internal.
Solution: ProfileStore.reference_curve(name, n=REFERENCE_PROFILE_CURVE_POINTS) — a pure, read-only accessor (no detection/matching change) that downsamples the profile envelope's avg ([[offset_s, watts], ...], hundreds–thousands of points) to ≤ n (50) points via np.interp, returning {"points", "duration_s", "cycle_count"}. Never raises (returns None on a missing/degenerate envelope). Surfaced as the reference_profile attribute on WasherProgramSensor — whose extra_state_attributes already returns None for off/detecting.../starting/unknown, so the attribute is present only once a real profile is matched (a consumer distinguishes "not matched yet" from "matched" via the sensor state itself). Offsets are absolute seconds; the full curve is exposed (not a pre-sliced remainder) so it is static per profile and the consumer slices it with the live progress fraction. The attribute is listed in the sensor's _unrecorded_attributes so it stays out of the recorder DB (a live forecast has no historical value and would otherwise re-serialize on every state write). Downsampling keeps it under ~1 KB regardless of cycle length. Locked by tests/test_reference_profile_curve.py.
Problem: Profiles can silently degrade over time as new cycles get assigned that don't actually match the profile's shape, or as the appliance behavior changes.
Solution: ProfileStore.compute_profile_health() computes per-profile health indicators from labeled cycle history — no ML required, pure statistics:
-
duration_cv: coefficient of variation of cycle durations (low = consistent) -
confidence_mean: mean match confidence across labeled cycles -
health_score:0.5 × (1 − duration_cv/0.5) + 0.5 × confidence_mean -
health_status: "healthy" (≥0.65) / "fair" (0.40–0.64) / "poor" (<0.40) / "unknown" (<3 cycles)
Surfaced via ws_get_profiles (added to its response as profile_health) and shown as inline badges (⚠ poor fit, fair fit) on profile cards in the panel's Profiles tab and as a health banner in each profile's Overview modal.
Three more pure-statistics (no ML) heuristics extend the health picture. All live in profile_store.py, never raise (return empty/None on error), and — for the first two — ride along in the ws_get_profiles response next to profile_health.
Profile Trends — compute_profile_trends(min_cycles=12, recent_window=8, slope_threshold_pct=0.08)
- Fits an ordinary-least-squares line to each profile's per-cycle duration (and energy, when the cycles carry it), then normalizes the slope to % of the profile mean per cycle so it is comparable across appliances.
- Classifies each series
up/down/stableagainstslope_threshold_pct; returnsduration_trend,duration_slope_pct,duration_recent_mean_s(+ the energy analogues),cycle_count, andrecent_window. - A rising duration trend is a maintenance signal (e.g. a washer taking progressively longer). Surfaced as a trend badge (↑/↓) on profile cards and a drift banner with a maintenance advisory in the Profiles stats tab. Response key:
profile_trends.
Coverage Gaps — suggest_coverage_gaps(recent_window=30, min_unmatched=5, min_unmatched_rate=0.20, low_confidence_threshold=0.40, duration_bucket_s=900.0)
- Scans the most recent
recent_windowcycles, counting unmatched (noprofile_name) and low-confidence cycles, and buckets the unmatched ones into 15-minute duration bins (only bins with ≥2 members become clusters). - Sets
suggest_createwhen unmatched count ≥min_unmatchedand unmatched rate ≥min_unmatched_rate. Returnsunmatched_count,low_confidence_count,unmatched_rate,suggest_create, andduration_clusters(largest first).{}when below the floor or on error. - Drives a coverage-gap banner (unmatched count/rate + duration-cluster hints + a
data-action="create-profile"button) in the Profiles tab. Response key:coverage_gaps.
Envelope Conformance — compute_envelope_conformance(profile_name, points)
- Resamples a completed cycle's trace onto the matched profile envelope's time grid (scaling by
elapsed/env_duration, clamping to the grid ends so shorter/longer cycles still score), then returns the fraction of samples inside the[lower, upper]band asconformance(outside_frac = 1 − conformance), plussamples/envelope_name.Nonewhen there is no envelope or fewer than 4 points. - Complementary to
MatchResult.confidence: confidence measures shape correlation, conformance measures absolute power level/spread. A cycle can correlate well in shape yet run at the wrong wattage. - Computed at cycle end in
manager._async_process_cycle_endand stored oncycle_data["envelope_conformance"].learning._maybe_request_feedbacktreatsconformance < 0.40as a second auto-label downgrade trigger (independent of the ML quality gate): a high-confidence match whose power level is inconsistent with the profile is downgraded to a feedback request rather than silently auto-labeled.
Problem: Users needed to manually reconfigure all settings when setting up multiple devices or migrating to new instances.
Solution:
- Export all cycles, profiles, feedback history, AND all fine-tuned settings as JSON
- Import via UI (copy/paste, no filesystem needed) or file-based service
- Automatic orphaned profile cleanup during import
- Per-device isolation maintained via entry_id
Files Modified:
-
profile_store.py-export_data(entry_data, entry_options),async_import_data(payload)now handle config - Export/import UI now lives in the panel (Advanced → Diagnostics);
config_flow.pyhas no export/import step -
__init__.py- Services updated to pass entry.data/options to export/import -
strings.json&translations/en.json- New UI labels and descriptions
What's exported:
{
"version": STORAGE_VERSION,
"entry_id": "unique_id",
"exported_at": "ISO timestamp",
"data": {
"profiles": {...},
"past_cycles": [...],
"feedback_history": [...]
},
"entry_data": {
# power_sensor, name (device-specific - NOT imported)
},
"entry_options": {
# ALL fine-tuned settings: min_power, off_delay, learning_confidence, etc.
}
}UI Access:
- Panel → Advanced tab → Diagnostics → Export / Import
- Export to JSON downloads the full backup
- Import from JSON pastes exported data (or an HA diagnostics download)
- All settings and profiles are applied on import
Service Usage:
service: ha_washdata.export_config
data:
device_id: "washer_device_id"
path: "/config/ha_washdata_export.json"
service: ha_washdata.import_config
data:
device_id: "washer_device_id"
path: "/config/ha_washdata_export.json"Problem: Most people run a smart plug for months before they find WashData, then still start from zero: every program has to be recorded by hand, one wash at a time.
Where: Panel → Advanced → Diagnostics → Import power history.
Two ways in, one pipeline:
-
Upload a CSV downloaded from Home Assistant's History panel
(
entity_id,state,last_changed). Also accepts;delimiters, a BOM, and the common header aliases; rows for other entities are ignored, andunavailable/unknownrows are kept as explicit stream breaks. -
Read it from Home Assistant (up to 14 days) directly from the recorder, no file
needed. Home Assistant keeps detailed history for 10 days by default and only hourly
averages after that, which are far too coarse to detect a cycle from — a CSV can reach
further back only if
recorder.purge_keep_dayswas raised.
Why detection cannot simply be replayed over the raw stream. Home Assistant history is change-based: while an appliance sits at a steady 0 W the sensor emits no rows at all. Fed straight to the detector, it never receives the readings that expire a cycle, and the outage handling force-stops instead. Measured on a real export (six months of hourly averages behind ten days of 5-second data): 18 cycles, every one force-stopped, one of them 61 980 minutes long, with two real washes merged into a single blob.
So the stream is pre-segmented first:
- Cut into activity blocks wherever the appliance was demonstrably off — either accumulated quiet time, or time since the last sample that reached the start threshold. The second rule matters for an appliance whose standby draw sits above the stop threshold, which would otherwise never look quiet.
- Gate each block on sample count, cadence and span, each rejection carrying a reason the review screen shows. Gates are relative to the hardware: a plug reporting once a minute is not "too sparse", and a pump cycle under thirty seconds is not "too short".
- Re-insert the quiet a live sensor would have reported inside a gap, without which two cycles ten minutes apart merge into one.
- Replay each block through its own fresh detector as a cancellable background task.
The same export then yields exactly the four real washes (74.4 / 48.3 / 95.5 / 45.8 min).
Review before anything is stored. One row per candidate — date, duration, energy, peak power, shape preview — with a tick box each, plus an account of what was skipped and why, and which detection settings the scan used. Cycles the detector could not end cleanly are shown unticked with the reason rather than hidden. Re-importing an overlapping export is safe: already-imported cycles are recognised and skipped.
Where imported cycles live. In their own category, deliberately neither of the other two:
- they never affect lifetime energy, cycle count, the review queue or on-device learning, and can never be shared to the community store;
- they are not marked as verified reference recordings either — nothing has checked them;
- what they do is shape program matching once you name them.
To name one: open it from the Cycles list (tagged as imported history) and press Label — pick an existing program, or create a new one from that cycle, which builds the program's envelope immediately.
Limits worth knowing: segmentation uses the device's current detection settings, so tune those first if the candidates look wrong; imported cycles cannot be trimmed or split (delete and re-scan instead); and there is a per-device cap on how many are kept.
Naming them in bulk. The Auto-label cycles button in the Cycles tab covers imported cycles as well as real ones, at the same confidence setting: a program is only assigned when the match is confident and unambiguous. On a machine with no programs yet there is nothing to match against, so the order that works is name one or two by hand, then auto-label the rest
- each name you confirm strengthens the curve the next batch is matched against. Labels applied by the matcher on imported cycles are recorded as guesses rather than confirmations, because a named imported cycle immediately starts shaping what later cycles match against.
Choosing what shapes a program. Settings - Matching - Profile Evidence lists the three kinds of stored cycle (ran by this machine / downloaded from the community store / found in imported power history) and lets you untick any of them. An unticked kind stops shaping your programs but is not deleted: those cycles stay in the Cycles list, can still be labelled or removed, and statistics are unaffected either way (they only ever count cycles this machine actually ran). Changing it rebuilds every program straight away. Unticking everything is ignored, since a program with no cycles behind it could never match.
Problem: Deleted cycles left orphaned profile labels; fragmented runs cluttered history.
Solution:
- Nightly cleanup at midnight (configurable via switch)
- Removes profiles referencing deleted cycles
- Merges fragmented cycles (last 24h, max 30min gaps)
- Logs maintenance statistics
- User can toggle on/off via
switch.<name>_auto_maintenance
Files Created:
-
switch.py- New AutoMaintenanceSwitch entity (mdi:broom icon)
Files Modified:
-
profile_store.py:-
cleanup_orphaned_profiles()- Remove profiles with dead cycle references -
async_run_maintenance(lookback_hours, gap_seconds)- Full maintenance run
-
-
manager.py:-
_setup_maintenance_scheduler()- Schedule midnight task -
_remove_maintenance_scheduler- Cancel scheduler - Enhanced
async_shutdown()to clean up scheduler
-
-
const.py- AddedCONF_AUTO_MAINTENANCE,DEFAULT_AUTO_MAINTENANCE=True -
__init__.py- Registered Switch platform
Maintenance Workflow:
Daily at 00:00
↓
ProfileStore.async_run_maintenance()
├─ 1. cleanup_orphaned_profiles()
│ └─ Remove profiles referencing non-existent cycles
├─ 2. merge_cycles(lookback_hours=24, gap_seconds=1800)
│ └─ Merge fragmented runs from past 24h (≤30min gaps)
└─ 3. Save and log stats
Switch Entity:
-
switch.<name>_auto_maintenance(default: ON) - Toggle to enable/disable nightly cleanup
- When toggled, scheduler is re-setup accordingly
- Toggling OFF cancels scheduled cleanup
Problem: Simple ON/OFF logic failed with pauses, soaking, or "Anti-Crease" modes.
Solution:
- Implemented a formal State Machine:
OFF->STARTING->RUNNING<->PAUSED->ENDING->OFF. -
OFF: Monitoring for
min_power. -
STARTING: Debounce phase. Requires
start_duration_thresholdANDstart_energy_threshold(default 0.005 Wh) to confirm. - RUNNING: Main active state.
- PAUSED: Entered if power drops low but not long enough to end. Allows for soaking or door opening.
-
ENDING: Candidates for completion. Must satisfy
off_delayANDend_energy_threshold(default 0.05 Wh in the last window) to finish.
Benefits:
- Eliminates false starts from brief spikes.
- Prevents false endings during long pauses if energy was high recently.
- Handles "Anti-Crease" (periodic tumbles) gracefully via
PAUSED/ENDINGtransitions.
Goal: Improve precision for similar cycles and reduce "stuck" time estimates.
Problem: Cycles with identical duration but different phases (e.g. Eco vs Intensive) were hard to distinguish.
Solution (current): The old correlation "confidence boost" (×1.2 when corrcoef > 0.85) was removed in the matching overhaul; it hurt the leave-one-out net metric. Stage 2 now scores 45% correlation + 55% peak-relative MAE, and near-duplicate same-device programs are separated by the Stage 4 duration/energy agreement term and Stage 5 profile groups instead. See CLAUDE.md → Matching Pipeline Details.
Problem: Time remaining jumped erratically during variable phases (e.g. heating water). Solution:
- System calculates standard deviation (variance) of the matched profile window.
- If variance is high (>50W std dev): Time estimate updates are damped (locked).
- If variance is low: Time estimate updates normally.
-
Switching Logic (Match Persistence): To prevent "flapping" between profiles or between a profile and "detecting...", the system enforces temporal persistence:
- Initial Match: Requires 3 consecutive matching attempts before switching from "detecting..." to a specific profile.
-
Unmatching: Requires 3 consecutive attempts with confidence below the
unmatch_thresholdbefore reverting to "detecting...". - Mid-Cycle Override: Requires 3 consecutive attempts AND a minimum confidence gap of 0.15 (High Confidence) or 0.05 (Positive Trend) to switch between different profiles.
- Divergence Detection: Implemented a "Score-Drop" check to handle cycles that start similar but diverge later. If the current matching confidence falls below a configured ratio (default 40% drop) of the peak score recorded for that cycle, the manager automatically reverts to "Detecting...". This prevents the integration from staying locked to an incorrect long profile when a shorter one ends.
Problem: Dishwashers often have a long silent drying phase followed by a brief, high-power pump-out spike. Smart termination would sometimes cut the cycle off early (during drying), missing the final spike and causing the spike to trigger a new "ghost" cycle.
Solution:
- Conservative Ratio: Dishwashers require 99% of expected duration before Smart Termination is even considered (vs 98% for others).
- End Spike Wait Period: Even if the duration is met, the system scans the "Ending" state for a high-power spike.
- If no spike is found, it waits up to 30 extra minutes (
DISHWASHER_END_SPIKE_WAIT_SECONDS= 1800 s) past the expected duration to catch it. -
Quiet-tail release (short cycles): The pump-out wait is anchored on the profile's expected duration, which drifts upward as longer cycles are learned. A cycle that runs a few minutes shorter than that average, and whose terminal pump-out lands before the drop into
ENDING(so no in-ENDINGend-spike ever arms), would otherwise hang the fullDISHWASHER_END_SPIKE_WAIT_SECONDS(30 min) past expected and even have its label drift to a longer near-duplicate profile. The wait now also releases once the cycle has reached its expected duration and power has since been sustained-quiet forDISHWASHER_END_SPIKE_QUIET_RELEASE_SECONDS(10 min). Gated onduration >= expected, so a long passive-drying phase that precedes a genuinely-late pump-out (quiet from ~50%-99% of expected) keeps waiting and its real pump-out is still caught by the end-spike arm. Takes the sooner of the two anchors, so it only ever shortens the wait. -
Terminal-tail match freeze: once a dishwasher is in
ENDINGwith a profile already matched and power sustained-quiet forDISHWASHER_MATCH_FREEZE_QUIET_SECONDS(5 min), live re-matching is frozen. Re-matching on the ever-growing idle tail inflates the observed duration and drifts the Stage-4 duration-agreement toward the longer of two near-duplicate profiles (e.g. a true "65°" cycle being overtaken by "50°" as the 0 W tail lengthens), which flips the stored label and stalls Smart Termination on the ambiguity gate. The active-phase match is complete by then; a real resume sends a high reading that leavesENDINGand re-arms matching, so the freeze is self-correcting. - Ghost Cycle Suppression: A "Suspicious Window" (20 mins) protects legitimate short cycles. Aggressive ghost cycle termination (10 min timeout) only applies if a cycle starts within 20 mins of the previous one ending.
-
Persistence: This 20-minute window logic persists across Home Assistant restarts by restoring
_last_cycle_end_timefrom the persistentprofile_store, ensuring protection isn't lost after a reboot. - Tail Preservation: The profile store now explicitly preserves trailing silence/spikes for natural completions, preventing the "profile shrinking" feedback loop where frequent early terminations made the learned profile shorter and shorter.
-
Strict Deferral: To prevent termination hangs on mismatched profiles, the "Deferred Finish" logic now requires either a
verified_pause(confirmed by profile envelope alignment) or high match confidence (default > 0.55).
Problem: Power sensors could become "stuck" at non-zero values if a smart plug failed to push the final 0W update. Conversely, long pauses in dishwashers (e.g. drying) could trigger a watchdog kill, prematurely ending a legitimate cycle.
Solution:
-
Profile-Aware Watchdog: The watchdog now checks the
expected_durationfrom the matched profile. If the cycle is currently within its expected runtime (even if silent for > 60 mins), the watchdog automatically extends the timeout. -
Verified Pause Support: For devices like dishwashers with multi-hour silent drying phases, the watchdog detects a
verified_pause(via profile alignment). When active, the timeout is extended to DEFAULT_MAX_DEFERRAL_SECONDS (4 hours) plus a 30-minute buffer, ensuring the cycle is not killed even if it exceeds its original expected duration during the pause. -
Zombie Killer (Hard Limit): To prevent runaway "ghost" cycles, a hard termination limit fires when a matched cycle exceeds 300% of its expected profile duration and has run more than 4 hours (
net elapsed > expected x 3AND> 14400 s). -
Stuck Power Reset: When the watchdog or detector forces a cycle to end (due to timeout or manual stop), the
current_powerstate is explicitly reset to 0.0W, ensuring Home Assistant entities reflect reality even if the hardware sensor fails to report the final drop. -
Low-Power Bypass: Power readings below
min_powernow bypass all debouncing, smoothing, and throttling filters inmanager.py, ensuring the "cycle end" signal is processed with zero latency.
Watchdog Logic Flow:
graph TD
A["Watchdog tick (watchdog_interval)"] --> B{"Cycle active?"}
B -- No --> C[Exit]
B -- Yes --> P{"pump AND elapsed >= stuck_duration?"}
P -- Yes --> PE["fire EVENT_PUMP_STUCK<br/>(visible, no force-end)"]
P -- No --> Z{"matched AND elapsed > expected x3 AND > 4h?"}
Z -- Yes --> K["Force end + reset power to 0W"]
Z -- No --> GH{"detecting AND within suspicious window?"}
GH -- Yes --> K
GH -- No --> LP{"low power?"}
LP -- Yes --> LPT{"stale > effective_timeout?<br/>(device floor, + expected, + deferral if verified pause)"}
LPT -- Yes --> K
LPT -- No --> KA["Inject 0W keepalive (advance detector)"]
LP -- No --> HS{"real-update silence > no_update_timeout?"}
HS -- Yes --> HW{"still high AND within expected + 4h?"}
HW -- Yes --> R["Inject refresh reading"]
HW -- No --> K
HS -- No --> OK[OK]
Main entry point for cycle management.
| Method | Purpose |
|---|---|
async_setup() |
Initialize, load state, setup listeners |
async_shutdown() |
Cleanup, save state |
_async_power_changed(event) |
Handle power sensor updates |
_update_estimates() |
Match profiles, set entities (every 5 min) |
_on_state_change(old, new) |
Handle detector state transitions |
_on_cycle_end(cycle_data) |
Finalize cycle, request feedback |
_start_state_expiry_timer() |
Begin 5-min reset countdown |
_handle_state_expiry() |
Async callback checking if idle threshold passed |
_watchdog_check_stuck_cycle() |
Profile-aware watchdog (Zombie protection) |
_stop_state_expiry_timer() |
Cancel reset if new cycle starts |
_maybe_request_feedback() |
Emit feedback request if confident |
Properties:
manager.learning_manager # LearningManager instance
manager._last_match_confidence # Last profile match score
manager._cycle_completed_time # When cycle finished (ISO)Handles user feedback and profile learning.
| Method | Purpose |
|---|---|
request_cycle_verification(cycle_data, confidence) |
Flag cycle for user verification |
submit_cycle_feedback(cycle_id, user_confirmed, corrected_profile, corrected_duration, notes) |
Accept user input |
_apply_correction_learning(profile_name, corrected_duration) |
Re-label the cycle, then recompute the profile's duration stats from all its labelled cycles (outlier-filtered). The old 80%/20% EWMA nudge was removed. |
get_pending_feedback() |
Return cycles awaiting input |
get_feedback_history(limit=10) |
Return recent feedback |
get_learning_stats() |
Return learning metrics |
Manages cycle storage, compression, and profile matching.
| Method | Purpose |
|---|---|
async_match_profile(power_data, duration) |
Match cycle to profile (confidence 0-1) |
create_profile(name, cycle_id) |
Create new profile from cycle |
async_save_cycle(cycle_data) |
Compress and save cycle |
merge_cycles(hours, gap_threshold) |
Auto-merge fragmented cycles |
Duration Matching:
- Tolerance: ±25% (was ±50%)
- Rejects: duration_ratio < 0.10 or > 1.5
- Accounts for realistic variance
During the expansion of the test suite (Phase 4), several minor issues and areas for improvement were identified:
-
ProfileStore.async_import_data Return Value: The method correctly returns a dict with
entry_dataandentry_optionskeys extracted from the payload. - CycleDetector Minimum Samples for Matching: The matching logic requires at least 12 samples after resampling. Highly irregular data with large gaps (e.g., 250s) can lead to failed matches if the total cycle duration is short, even if the "shape" is recognizable.
- Timezone Sensitivity in decompress_power_data: The isoformat() conversion in decompress_power_data uses datetime.fromtimestamp(ts) which defaults to local time. This can cause mismatches in tests comparing against UTC strings. Using dt_util.utc_from_timestamp or always using aware datetimes is recommended.
- ProfileStore.delete_cycle is Async: Some older test code or assumptions might treat it as sync. It MUST be awaited as it triggers async_rebuild_envelope and async_save.
The integration includes specialized handling for different appliance types to account for their unique power profiles:
- Washing Machine: Standard defaults; handles repeating wash/rinse phases.
- Dryer: More linear power curve; shorter pauses.
- Washer-Dryer Combo: Uses washing-machine defaults extended to cover the optional dry leg.
- Dishwasher: Multi-hour silent drying phases; end pump-out spikes; requires multi-hour watchdog extensions and conservative smart termination.
- Air Fryer: High constant load with thermostat-driven dropouts; explicit user-set timer per cycle.
- Bread Maker: Long programs with low-power proving/rising phases; 2-hour active-timeout to bridge those silences.
- Pump / Sump Pump: Sharp on/off with no warm-down; stuck-alarm watchdog rather than profile matching is the primary feature.
-
Other (Advanced) (
generic): Bucket for predictable appliances that do not fit one of the named classes but still benefit from full profile matching/learning. Ships neutral defaults with no device-type-specific runtime branching; the user tunes thresholds, timeouts, and matching parameters themselves. -
Threshold Device (
other): Truly uncategorised appliances where only threshold-based detection is wanted (no profile matching). Ships intentionally generic defaults the user must tune. This is also the runtime fallback for entries whose device type was hard-removed after deprecation.
Removed in 0.5.0: Electric Vehicle, Coffee Machine, Heat Pump, and Oven. These types fail at least one of WashData's three appliance fit tests (user-selected discrete program, reproducible power signature, clean return to OFF), so matching produced noise rather than signal. They were removed from the new-entry dropdown, and existing entries on those types are automatically migrated to Threshold Device on config-entry upgrade, preserving all tuned options. (Earlier releases had marked them deprecated with a scheduled 0.6.0 removal; the removal was brought forward to 0.5.0.)
There are two distinct systems that share the word "phase" in WashData, and they are not wired to each other:
-
Phase labels (this section) - per-profile, offset-based time ranges (Wash, Rinse, Spin, etc.) that the user draws over a profile's average power curve. These drive the current-phase readout and appear in event data. The live phase is indexed by the ML-blended progress fraction, not raw elapsed time (see Phase-segmented matching & ETA), so overrunning/underrunning cycles still name the phase correctly.
-
Phase-segmented matching & ETA (0.5.1, opt-in) - an unsupervised segmenter (
phase_segmenter.py) that derives per-role priors (heating / wash / spin / idle) from a profile's cycles and, whenenable_phase_matchingis on, feeds a phase-resolved time-remaining estimate intoprogress.py. Documented in its own subsection below.
The user-drawn phase labels do not by themselves affect cycle detection, profile (program) matching, or duration estimation. The opt-in phase-segmented ETA does refine time-remaining, but only for time-remaining - it never changes which program is matched.
Phase Catalog (Default + Custom)
v
Device-Type Filtering
v
Phase Assignment (Offset-Based Ranges)
v
Client-side JS canvas (average power curve + phase spans, rendered in the panel)
1. Phase Catalog
- Default Phases: Predefined phases for each device type (Washing Machine: Pre-Wash, Wash, Rinse, Spin, Soak, Anti-Crease)
- Custom Phases: User-created phases per device type (e.g., "Eco Mode", "TurboWash")
- Device-Type Scoping: Each phase is associated with a device type, ensuring only relevant phases appear in UI dropdowns
2. Phase Assignment
- Time-Based Ranges: Phases are defined as offset-based ranges from cycle start (e.g., "Wash: 5-15 minutes")
- Per-Profile Assignment: Each profile can have its own phase segmentation
- Validation: Overlapping ranges are rejected; ranges must be contiguous with cycle duration
3. Phase Visualization
-
Client-side JS canvas (the panel renders all charts in JavaScript; the old server-side
generate_*_svghelpers were config-flow-era and have been removed). The phase configurator, inside the Profile panel modal, shows:- Average power curve from all cycles in the profile
- Colored phase spans (semi-transparent rectangles)
- Draggable boundaries at phase edges
- Time axis (0/mid/total minutes)
- Legend with phase names and ranges
- Real-Time Updates: the canvas redraws as the user drags phase ranges in the configurator
graph TD
A["WashData panel > Profiles tab"] --> B{Where?}
B -- "Phase Catalog sub-tab" --> D{Action?}
D -- "Create" --> E["Add new custom phase"]
D -- "Edit" --> F["Select phase"]
F --> G["Edit default or custom"]
G --> H["Update phase metadata"]
D -- "Delete" --> I["Remove custom phase"]
E --> J["Phase added to catalog"]
H --> J
I --> K["Phase removed from catalog"]
B -- "Open a profile" --> L["Phase-range editor"]
L --> N["Choose phases from catalog"]
N --> O["Drag boundaries over the average curve"]
O --> P["Live preview on the curve"]
P --> Q["Save profile phases"]
J --> R["Phases available in dropdowns"]
Q --> S["Phases displayed in event data"]
ProfileStore Methods:
list_phase_catalog(device_type: str) → list[dict]
# Returns merged default + custom phases for device
list_custom_phases(device_type: str) → list[dict]
# Returns only user-created phases (and overrides of defaults)
async_create_custom_phase(device_type: str, name: str, description: str)
# Create new phase in catalog
async_update_custom_phase(device_type: str, old_name: str, new_name: str, description: str)
# Edit phase (default or custom). If editing a default phase, creates an override entry.
# Cascades rename to all assigned profiles.
async_delete_custom_phase(device_type: str, name: str)
# Remove custom phase from catalog (removes all assignments)
# Note: Only custom phases can be deleted; defaults are immutable.
get_profile_phase_ranges(profile_name: str) → list[dict]
# Get phase assignments for a profile
async_set_profile_phase_ranges(profile_name: str, ranges: list[dict])
# Update phase assignments (validates no overlap)Phase Catalog (in _data["_custom_phases"]):
{
"name": "Eco Mode",
"description": "Energy-saving cycle",
"device_type": "washing_machine",
"created_at": "2026-03-11T14:30:00+00:00"
}Profile Phase Assignment (in _data["profiles"][profile_name]["phases"]):
{
"name": "Pre-Wash",
"start": 300, // seconds from cycle start
"end": 900, // seconds from cycle start
"description": ""
}-
Scope of the phase labels: the user-drawn phase ranges are NOT used in:
- Cycle start/end detection (uses power thresholds)
- Profile (program) matching (uses power-curve similarity - Stages 1-5)
- Which program is selected They ARE used to name the current phase (indexed by ML-blended progress fraction). Separately, the opt-in phase-segmented ETA (0.5.1) refines time-remaining only. See the Phase-segmented matching & ETA subsection - the older "phases are never used in any analysis logic" claim is no longer accurate.
-
Metadata Only: Phase labels are display labels that:
- Appear in event data when a cycle is running
- Show on the dashboard card (if enabled)
- Help users understand cycle progression
- Aid in manual recording validation
-
Cascade Updates:
- If a phase is renamed in the catalog, all profiles using that phase are updated
- If a phase is deleted from catalog, all assignments are removed
- Profile rename does NOT update phase catalogs
-
Device-Type Filtering:
- Phase dropdowns in assignment dialogs automatically filter to current device type
- Custom phases created for one device type don't appear in other device types
- Default phases are immutable but CAN be edited to create device-specific overrides
- When editing a default phase, an override entry is created in the custom phases list
-
Editing Default Phases:
- Select "Edit Phase" in the catalog manager
- Choose any phase (default or custom) from the dropdown
- Modify the name and/or description
- An override entry is automatically created for default phases
- This allows device-specific customization without affecting the built-in defaults
This is a separate system from the user-drawn phase labels above. It exists to solve a specific accuracy problem: temperature/spin variants of the same program have nearly identical overall shape but very different phase budgets (a 60C wash spends far longer in heating than a 30C wash). A single whole-cycle duration model therefore mis-estimates time-remaining for the variant that was not the median. Phase-segmented ETA gives each cycle role its own time budget.
It is opt-in and time-remaining-only. It never changes which program is matched; the Stage 1-5 program matcher is untouched.
flowchart TD
subgraph Build["Build (envelope rebuild path, cached)"]
CY["Profile's clean cycles"] --> SEG["phase_segmenter.segment_cycle<br/>regimes: idle / active / high (+ spin)"]
SEG --> ROLE["Roles: heating / wash / spin / idle"]
ROLE --> PM["PhaseModel per role<br/>(expected duration + energy priors)"]
PM --> CACHE["envelope['phase_profile']<br/>(derived cache; storage v11 marker only)"]
end
subgraph Live["Live (opt-in, gated)"]
RUN["Running cycle so far"] --> GATE{"enable_phase_matching<br/>AND device in<br/>LIVE_PHASE_DEVICE_TYPES?"}
GATE -- No --> BASE["progress.compute_progress<br/>(phase_remaining_s = None)<br/>= legacy estimator, byte-identical"]
GATE -- Yes --> MATCH["phase_match.phase_eta<br/>completed=0 / current=mean-consumed / future=occ*mean"]
CACHE --> MATCH
MATCH --> BLEND["compute_progress(phase_remaining_s=...)<br/>phase% blended into progress BEFORE EMA/monotonic (byte-identical off)"]
end
Segmenter (phase_segmenter.py). A hysteresis regime classifier splits a trace into
idle / active / high runs (the high threshold is max(floor, frac * P95) of the trace),
merges sub-minimum runs, and detects the terminal spin as the last non-idle run in the
end zone. Roles produced: heating, wash, spin, idle. Per-role priors are held in a
PhaseModel. Models are only built for washing_machine, washer_dryer, and dishwasher.
Phase-profile cache. _compute_phase_profile populates envelope["phase_profile"] during
the normal envelope-rebuild path. It is a derived cache, not migrated data - STORAGE_VERSION
was bumped to 11 with a marker-only migration; the cache self-populates on the next rebuild.
Matcher (phase_match.py). Per-role duration and energy agreement uses a log-ratio score
1/(1 + |ln(observed/expected)|/scale); heating is weighted most heavily (0.50) because it is
the temperature discriminator. The currently in-progress role is scored one-sided (on both
duration and energy) so a larger heating budget cannot rule out a hotter variant mid-cycle.
phase_eta classifies each profile role against the observed-so-far segments and sums the
remaining budget accordingly: a completed role (already observed, not the open one)
contributes 0 even if it ran shorter than its mean; the current open role contributes
max(0, dur_mean − consumed) using the conditional mean (no occurrence discount, since the
role is known present); a future role (not yet seen) contributes the occurrence-weighted
prior. (This role-aware split fixed a completed-role over-count found in review.)
ETA blend (progress.compute_progress, the single source of truth). When a
phase_remaining_s is supplied, it is converted to a completion percent
(phase_pct = elapsed / (elapsed + phase_remaining_s)) and blended into the phase-progress
signal before delegating to _compute_progress_base - so the blend rides the proven,
golden-locked EMA + monotonicity + back-calculation guards (design §8, "one smoothing
implementation") rather than re-deriving a raw, unsmoothed progress that could jitter or
collapse. The crossover leans on the phase budget early (low base progress) and the proven
phase estimate late (f = base_phase_progress/100; blended = (1-f)·phase_pct + f·base).
When phase_remaining_s is None the result is byte-identical to the legacy estimator, so
the feature is inert until enabled. The manager (live) and the Playground SimRunner call
compute_progress identically, so the what-if replay stays faithful.
Candidate scope + safety gates (ProfileStore.phase_remaining). The phase matcher considers
the matched program's group siblings when it is grouped (narrow within the family -
coherent and accurate), otherwise all of the device's cached phase profiles (best-fit;
the Phase-0 gate showed constraining an ungrouped cycle to the sometimes-wrong whole-cycle
match regresses the ETA). Two safety gates bound it: a cold-start floor
(PHASE_PROFILE_MIN_CYCLES = 2, so a single-cycle zero-variance prior can't drive the ETA)
and an ambiguity gate (top-2 phase scores within MATCH_AMBIGUITY_MARGIN fall back to the
classic estimate rather than committing a coin-flip variant).
Gating. Live phase-ETA requires both the per-device enable_phase_matching option flag
AND the device type being in LIVE_PHASE_DEVICE_TYPES = (washing_machine, washer_dryer).
Dishwashers have a segmenter model but are deliberately excluded from live ETA for now.
Panel. A single checkbox, enable_phase_matching, lives in a device-gated "Time Remaining"
settings section, saved through ws_set_options (there is no config-flow entry for it).
Mixed-profile / data-hygiene advisory (Phase 5). compute_profile_advisories inspects the
cached phase profile and flags a profile whose cycles are internally inconsistent (heating
coefficient of variation > 0.45, or heating present in only 25-75% of cycles - a sign two
different programs were labelled as one). It emits {code: "phase_inconsistent", ...} in the
profile_advisories returned by ws_get_profiles. (Maintainer note: as of this branch the
advisory renders inline on the affected profile card in the Profiles tab, using the
msg.advisory_phase_inconsistent_title string.)
WashData has over 180 tunables; many pairs have ordering constraints that the runtime enforces silently (e.g. by ignoring a setting when it violates a guard). Conflict validation makes those constraints visible and fixable before saving.
Fourteen constraints are checked, covering:
| Rule | Constraint | Why it matters |
|---|---|---|
| Hysteresis band | start_threshold_w > stop_threshold_w |
The cycle-detector state machine requires start > stop; equal values collapse the hysteresis band and cause rapid toggling |
| Min power / stop | min_power <= stop_threshold_w |
min_power is a display/filter floor; values above stop silently discard all readings and prevent detection |
| Power-off / stop | power_off_threshold_w < stop_threshold_w |
The runtime disables power-off detection when this threshold is ≥ stop; setting it there is a silent no-op |
| Off delay / min off gap | off_delay <= min_off_gap |
Off Delay exceeding Min Off Gap lets two consecutive cycles merge into one |
| Watchdog / sampling | watchdog_interval >= 2 × sampling_interval |
A watchdog firing in less than one sampling period can kill a healthy cycle mid-reading |
| No-update timeout / watchdog | no_update_active_timeout > watchdog_interval |
The watchdog must fire before the cycle is force-stopped, not after |
| Start duration / sampling | start_duration >= sampling_interval |
A start-duration shorter than the sampling interval means a single spike can open a cycle |
| Confidence ordering | learning_confidence ≤ match_threshold ≤ auto_label_confidence |
All three thresholds form a strict ladder; inverting any pair breaks the auto-label / review / ignore routing |
| Unmatch / match | unmatch_threshold < match_threshold |
An unmatch threshold at or above the match threshold instantly un-matches every commit |
| Anti-wrinkle exit / stop | anti_wrinkle_exit_power < stop_threshold_w |
The exit power marks the true-off state for anti-wrinkle mode; if it is at or above stop, the runtime uses max(exit, stop), making the setting a no-op (washing machine / dryer / washer-dryer only)
|
| Anti-wrinkle max / start | anti_wrinkle_max_power > start_threshold_w |
The max-power cap for anti-wrinkle mode must exceed start threshold or the duration limit is never applied (washing machine / dryer / washer-dryer only) |
| Pump stuck / no-update timeout | pump_stuck_duration < no_update_active_timeout |
The pump-stuck alarm must fire before the watchdog force-stops the cycle (pump / sump-pump only) |
| Duration ratio | profile_match_min_duration_ratio < profile_match_max_duration_ratio |
Inverted min/max eliminates all candidates at the first matching stage |
_SETTING_CONFLICTS (a const array in ha-washdata-panel.js) encodes all 14 rules as objects with three fields:
-
keys— the setting keys involved -
check(vals)→ bool — returnstruewhen the constraint is violated. Device-type-specific rules include a guard: the anti-wrinkle rules check['washing_machine','dryer','washer_dryer'].includes(v.device_type); the pump-stuck rule checksv.device_type === 'pump'.valsalways includesdevice_typevia_readSettingsFormValues(which mergesthis._opts, andthis._optsis populated fromget_optionswhich returns mergedentry.data + entry.options). -
fieldErrors(vals)→ map — for each affected key, returns{msgKey, msgVars, msgFb, fixVal}wherefixValis a safe corrected value
_readSettingsFormValues(sr) merges DOM form values (from the current settings section) over this._opts (last-saved values for off-screen fields), so cross-section conflicts (e.g. min_power in the Basic section vs stop_threshold_w in the Detection section) are caught even when the two fields are not on-screen simultaneously.
_liveValidateSettings(sr) runs every conflict rule on every input / change event and on initial render, adding a red wd-conflict-row below each affected field (with the translated message and a Use X fix button) and an wd-has-conflict outline. It returns the map of active conflicts; _saveSettings() blocks if it is non-empty.
Conflict-suggestion coherence: When _liveValidateSettings emits an error for a field, it checks whether a pending suggestion for that same field would resolve the constraint. It builds a suggMap = {key: +s.suggested} from this._suggestions; for each (key, info) returned by rule.fieldErrors(vals), if suggMap[key] exists and applying that suggestion value passes rule.check({...vals, [key]: sugV}), the error object is tagged {...info, suggFix: sugV}. The rendered row then shows a <span class="wd-conflict-sug-note"> with the translation key conflict.suggestion_resolves ("Stage the pending suggestion (X) below to fix this") instead of the generic Use X fix button. _cascadeConflictFix skips entries tagged with suggFix (they carry no fixVal), so the cascade loop never fights a pending suggestion.
Cascade fix (_cascadeConflictFix): clicking Use X triggers a loop that fixes the clicked field, then re-validates and automatically applies the next downstream fix, repeating until stable. Fields in the current section update their DOM inputs directly; fields in other sections (off-screen) update this._opts in memory and are tracked in this._cascadePending. _saveSettings() prefixes the updates payload with _cascadePending so off-screen cascade values are included in the next save even though they never enter a DOM input. _cascadePending is cleared on successful save, Refresh, or Apply Suggestions. The same cascade also fires when a tuning suggestion's Use button is clicked: after staging the new value in this._opts and re-rendering the settings form, _cascadeConflictFix runs with the suggestion key as initialKey so downstream settings are auto-fixed in the same interaction.
Revert changes button: _saveSettings() snapshots this._opts into this._prevOpts immediately before sending to the server. The "Revert changes" button (disabled until the first save in the session) sends _prevOpts back to the server and restores local state, giving the user one-level post-save undo. Cleared on Refresh or Apply Suggestions.
reconcile_suggestions() enforces the same 14 constraints on the suggestion engine's proposed values via a direction-aware, cascade-creative, fixpoint loop:
-
Direction-aware: each rule knows which setting is the more-fundamental anchor and which is the derivative that yields. When
start_threshold_wis the anchor being suggested lower,stop_threshold_wis cascade-adjusted down — not the other way around. Whenstop_threshold_wis the anchor,start_threshold_wis cascaded up. -
Cascade-create:
adjust(key, new_val, reason)now creates a new suggestion entry ("cascade": True) for keys not originally in the suggestion set, rather than being a no-op. This ensures the full returned map is jointly valid — e.g. suggesting a lower Start Threshold automatically cascade-creates a matching Stop Threshold and Min Power entry. -
in_out()guard: rules only fire when at least one key in the constraint is already in the suggestion map (original or cascade). Live-vs-live conflicts in device options are left to the frontend conflict check, not invented here. - Fixpoint loop (up to 8 iterations, breaks early when stable): a cascade entry created in iteration N may trigger a further rule in iteration N+1. The loop converges in ≤ 2 iterations for typical chains.
-
original_keys: a frozenset of keys the engine originally proposed, used to determine cascade direction within each rule.
_reconcile_stored_suggestions() in SuggestionEngine calls reconcile_suggestions(stored, self._entry_options()) after each apply_suggestions() call, persisting cascade-created entries into the profile store via set_suggestion. This ensures the full coherent set is shown in the panel and applied together via "Apply all". The anti-wrinkle rules (8, 9) and pump-stuck rule (10) are device-type-aware: they compute _dt = current.get(CONF_DEVICE_TYPE) and skip the rule unless _dt matches the applicable types (anti-wrinkle: washing_machine | dryer | washer_dryer; pump-stuck: pump). A None device_type is treated as "eligible" to preserve backward compatibility with existing tests that call reconcile_suggestions without a device_type in current.
_apply_suggestions_and_notify() in learning.py is the single path that writes a new suggestion into the profile store. Two gates run before any set_suggestion call:
Gate 1 — Minimum significant delta: A suggestion is discarded (deleted from the store) if the proposed change is too small to be meaningful. For each suggestion value sv vs the current option value cv:
rel_delta = abs(sv - cv) / max(abs(cv), 1e-3)
The suggestion is dropped when both of the following hold simultaneously — either threshold passing is enough to keep it:
-
rel_delta < MIN_SUGGESTION_REL_DELTA(constant:0.08, i.e. less than 8% relative change), AND -
abs(sv - cv) < _suggestion_min_abs_delta(key)— a per-key absolute floor:
| Key pattern | Absolute minimum | Unit |
|---|---|---|
*_w, *_power
|
0.3 | Watts |
*_interval, *_timeout, *_delay, *_gap, *_duration, *_seconds
|
5.0 | seconds |
*_ratio, *_tolerance, *_confidence, *_threshold
|
0.02 | dimensionless |
*_count, *_window, *_repeat
|
1.0 | count |
| (all others) | 0.05 | — |
This prevents the suggestions panel from surfacing cosmetic noise (e.g. a 1 W shift in a 1000 W threshold) while still catching genuine drift that only appears as an absolute change on already-near-zero values.
Gate 2 — Post-apply cooldown: After the user clicks "Apply suggestions" (ws_apply_suggestions), ws_api.py records the current cycle count via profile_store.set_suggestion_apply_cycle_count(len(past_cycles)). On every subsequent call to _apply_suggestions_and_notify(), the gate checks:
(current_count - last_apply_count) < MIN_SUGGESTION_COOLDOWN_CYCLES (constant: 3)
When fewer than 3 new cycles have completed since the last apply, new suggestions are skipped — neither stored nor deleted, leaving the store unchanged. The first-ever suggestion (when last_apply_count == 0) is exempt so new installs receive suggestions immediately.
The cooldown prevents the engine from immediately re-suggesting changes that were just applied: the first 1–2 cycles after an apply still carry the prior device signature, so waiting for at least 3 cycles gives the detector time to observe the updated thresholds before re-evaluating.
Profile-store support: profile_store.py exposes two thin helpers for the cooldown state:
-
get_suggestion_apply_cycle_count() -> int— returns the stored count, defaulting to0. -
set_suggestion_apply_cycle_count(count: int) -> None— persists the count; called byws_api.pyafterws_apply_suggestions.
The two constants (MIN_SUGGESTION_REL_DELTA, MIN_SUGGESTION_COOLDOWN_CYCLES) live in const.py.
The _field() function in the panel renders the suggestion widget below each settings input. The labels and layout depend on which combination of classic and ML recommendations is available:
| Case | Rendering |
|---|---|
| Both classic and ML exist, within 5% relative |
💡 WashData recommends: X [Use] (one value, one button) |
| Both exist, diverging ≥5% | Two-row split pill: 💡 Observed X [Use] · 🤖 Calibrated Y [Use] plus a per-setting impact line |
| Classic only | 💡 Observed: X [Use] |
| ML only | 🤖 Calibrated: X [Use] |
Label semantics:
-
Observed (
suggestion.observed_label) — what the appliance's power trace statistically shows; the value comes from the stored classic suggestion (this._suggestions) -
Calibrated (
suggestion.calibrated_label) — what would have reduced detection errors on past cycles; the value comes fromthis._mlSettings[key].ml_value(fresh ML run via_build_settings_comparison) -
WashData recommends (
suggestion.both_agree) — used when both engines agree within 5% relative; no distinction shown to the user
5% agreement threshold (relDiff < 0.05): computed as |cN - mN| / max(|cN|, |mN|, ε). Below this threshold the values are statistically indistinguishable given typical sensor noise, so collapsing to one recommendation is correct.
Per-setting impact explanations (suggestion.impact.<key>.higher / .lower): when the two values diverge, a <div class="wd-sug-impact"> line is rendered below the value row. The _t() call uses the key name and the direction of each value relative to the other:
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.
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.
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.
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 <span class="wd-dbadge conf">... N</span> 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 no longer posts unsolicited Home Assistant persistent notifications for its own learning/tuning activity. Three separate paths were removed:
-
Suggestion-ready notification —
_async_send_suggestions_ready_notification()and its call site in_apply_suggestions_and_notify()were removed fromlearning.py. -
Cycle-verification feedback notification —
_async_send_feedback_notification()and its call site inlearning._maybe_request_feedback()were removed. This was previously gated by thesuppress_feedback_notificationsoption, which is now also gone: the constant (CONF_/DEFAULT_SUPPRESS_FEEDBACK_NOTIFICATIONS) was removed fromconst.py, the field was removed from the panel Settings schema, and the key is stripped from existing config entries byasync_migrate_entry(no data lost). Cycles needing verification are surfaced in the panel's Cycles review queue viarequest_cycle_verification. -
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. Themin_powersuggestion is still stored viaprofile_store.set_suggestionand 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.
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.
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.
B1 - HA Energy dashboard entity. A new sensor.<name>_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).
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.
- 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_cyclesgained anoffsetparameter and returnstotal+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 thesettings_changelogstore key (capped atSETTINGS_CHANGELOG_MAX= 50). New WS commandget_settings_changelog; changed fields render a dot with a "Changed from X to Y on {date}" tooltip plus a Settings history table.
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.
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-5sseries(state, model progress/remaining, live confidence, phase, energy, projected energy/cost), a typedeventslog (detected,match_commit/match_changed/match_ambiguous,notify_start/notify_pre_complete/notify_finish/notify_milestone/notify_held,finished),alerts, and anoutcome. Alerts now include end-detection flags:timeout_end(ended only by the low-power off-delay, not smart prediction - warn when also unmatched) andwould_run_indefinitely(TerminationReason.FORCE_STOPPED- never ended on its own), so an auto-detected cycle's end behaviour is visible._pgDrawCanvasrenders 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_matcherreports a persistence-gated committed match (mirrors the manager: a candidate must be non-ambiguous top-1 formatch_persistenceconsecutive 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 (theget_dtw_debugenvelope score) so the two aren't confused. -
Settings panel (shared).
_pgOverrideFields()groups detection triggers / timing / edge-cases / program matching.build_sim_configapplies the detection keys (includingstart_duration_threshold/interrupted_min_seconds, which used to be shown but silently dropped), andapply_match_overridesmaps 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 viaset_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 -
_pgRunHistoryreplays the last N cycles in small chunks (run_playground_historyper 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/afterdiffare 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,_pgLoadSeqsupersede, row highlight, scroll into view) - no page switch. -
Drawer: Optimize -
_pgRunSweep2chunks 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 Tasks (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.)
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 <config>/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.)
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 106 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.
User Docs
Reference
Development
Repository