v0.5.3
0.5.3 - 2026-07-26
Features
-
Export and import now let you choose exactly what to move (proposed by @sueha) (
profile_store.py,ws_api.py, panel): Export and import used to be all-or-nothing - export dumped the entire store and import replaced the whole thing, overwriting everything on the target device. Both are now guided wizards in Advanced -> Diagnostics with a hierarchical, tri-state selection tree (select all, per-category, or individual profiles/cycles). You pick precisely what travels: profiles (with or without their cycles - a profile exported without cycles still carries its learned shape, so it stays matchable), run cycles, imported reference cycles, detection & matching settings, phases, profile groups, matcher tuning, ML models, feedback/review labels, maintenance log, history logs, and lifetime totals. Mandatory device-type and version markers are always included; the GitHub token and transient in-flight state are never exported.- Import analyzes the file first, over WebSocket, before anything changes. Paste JSON or load a file and WashData immediately shows a manifest of what it contains and what can be imported into this device - per-category counts, per-profile cycle counts, name-clash flags, and a warning when the export is from a different appliance type (in which case device-specific settings and real-history import are disabled, but programs and cycles can still come in as reference data).
- Import merges instead of overwriting. By default imported data is added alongside your existing data and nothing is lost; when a profile name already exists you choose per-clash: import as a copy, keep yours, or overwrite. A Replace mode is also available (each ticked category is wiped and replaced from the file; unticked categories are left untouched).
- You decide where imported cycles land. By default they become reference cycles that only sharpen program matching and never touch your usage/energy/count statistics; opt into "real history" (which feeds stats) when you are genuinely migrating one appliance to a new install. The reference-vs-history isolation is preserved end to end.
- The old whole-store export/import remain available as "Quick export everything" and an "Advanced: replace all from JSON" fallback, and previously exported files (including older versions and Home Assistant diagnostics downloads) import through the new wizard unchanged. Four new WebSocket commands back this:
get_export_inventory,analyze_import,export_config_selective, andimport_config_selective(all full/admin-gated, like the existing export/import).
-
Playground can now simulate what happens when an appliance idles after a cycle (
playground.py,ws_api.py, panel): Previously the Playground's "Simulate" mode appended a silent 0 W tail after the recorded cycle, which never represents how real appliances behave (a display, pump controller, or WiFi module typically holds 1–5 W in standby). There was no way to know whether WashData would correctly stop the cycle at a device's actual standby draw. A new "Test idle termination" toggle in the detection settings column appends a synthetic standby continuation instead. The integration automatically estimates the standby floor from the last 60 seconds of the real recording (using the 7th percentile, which picks the between-burst baseline rather than a contaminated mean), or you can type an explicit idle level in watts to override it. The simulation runs through the real detector - if the standby draw is below the stop threshold, the cycle ends normally and WashData tells you how long it took and which detection path fired; if the draw sits above the stop threshold, the cycle holds open until the 8-hour safety cap and an alert explains why. The synthetic region is shaded on the timeline graph. The feature uses sparse 30-minute steps after a dense 20-minute pre-fill, so even an 8-hour worst-case scenario completes in under a second. -
Anti-wrinkle: the gap allowed between two tumble pulses is now a setting (
anti_wrinkle_idle_timeout, "Max Pulse Gap"): Anti-wrinkle mode ended after a hardcoded 120 s of quiet, which is shorter than the pulse spacing of some dryers. On a Bosch heat-pump dryer the pulses sit 130-660 s apart (18 pulses measured in one recorded cycle), so the mode dropped out right after the first pulse and every later pulse surfaced as an aborted false start (off -> starting -> off, 3-14 times per cycle across three recorded cycles) instead of staying attached to the finished cycle. The tolerance is now a normal setting in the Anti-Wrinkle section, defaulting to 120 s so existing behaviour is unchanged; dryers with wider pulse spacing can raise it. The exit still also honours the dynamic end threshold, so nothing gets shorter than before. -
The Playground now shows anti-wrinkle:
anti_wrinklewas mapped to "Idle" in the simulation state band, so the mode was invisible in the very view used to tune it. It now renders as its own band segment, and all fiveanti_wrinkle_*options (enabled,max_power,max_duration,exit_power,idle_timeout) are editable as Playground overrides and honoured by the simulator, which previously ignored them and always used the stored values.
Performance
-
Simulating a very long cycle no longer pegs the CPU for minutes (#311): 0.5.2 made the Playground "Simulate" run cancellable and off the main request, but the underlying replay of a multi-hour cycle (for example a ~235-minute dishwasher program) still burned several minutes of solid CPU and could saturate a small host. Profiling traced almost all of it to the phase-progress estimator, which slides the current power window across the profile's entire time grid once per progress update: for a very long cycle that grid holds thousands of points, so each update ran thousands of correlations and a whole simulation ran over a million. That per-update search is now vectorized with NumPy instead of a Python loop - about 40x faster on a long cycle (a ~235-minute dishwasher simulation drops from ~130s to ~3s of compute) with byte-for-byte identical results (verified across the full simulated timeline). Because the same estimator runs live every few seconds, this also cuts the per-update CPU cost of the running-cycle progress/time-remaining estimate on low-power hosts.
-
Rebuilding profile envelopes is much faster on long cycles (#311): Every operation that rebuilds a profile's power envelope - reprocessing history, splitting, merging, or trimming a cycle, and the "Rebuild Envelopes" button - aligns cycles with dynamic time warping, and that alignment was a pure-Python double loop over the full-length traces (tens of millions of
min/abscalls for a multi-hour cycle, dominating the whole rebuild). The cost matrix is now filled with a vectorized NumPy anti-diagonal sweep instead, giving up to ~8x faster envelope rebuilds on long dishwasher traces with byte-for-byte identical results (verified across every recorded cycle dataset, plus a 468-case fuzz against the original). These paths already ran in the background, so this is a CPU/throughput win rather than a responsiveness fix, most noticeable on low-power hosts with long cycles. -
Program-matching DTW is ~3× faster (
analysis.py): The bounded DTW used to score every profile candidate during program matching (the live matching pass that runs every 5 minutes and the Playground simulate path) iterated the dynamic-programming band cell-by-cell in Python, each step boxing and unboxing NumPy scalars. The inner loop now operates entirely on Python-native floats (converted via a single.tolist()per row) and writes results back as one slice assignment — eliminating per-element NumPy boxing overhead without changing the algorithm. Results are byte-for-byte identical to the original (verified with a 537-case fuzz covering shapes from 1×1 to 300×300, multiple band widths, and both normal and derivative modes). The anti-diagonal vectorized fill used for full-path envelope DTW is intentionally not reused here because its per-diagonal Python setup cost dominates for the small fixed-size arrays used in matching (n=200 both sides), where it is actually 2× slower. -
Phase-regime run-length encoding is ~17× faster (
phase_segmenter.py): The_runsfunction that converts the per-sample regime array (idle/active/high) into a list of contiguous runs used a nested Python while-loop, visiting every sample individually. For a 2-hour cycle at 5-second resolution (1 440 samples) that is 1 440+ Python loop iterations. The function now usesnp.diff+np.flatnonzero(both C-level) to find run boundaries, then a short list comprehension over only the boundaries (~10–30 for a real cycle). The speedup is ~17× on a realistic cycle trace, ~1.3× on the pathological fully-alternating case, with byte-identical output across 313 fuzz cases. Phase regime segmentation is called on every envelope rebuild and, whenenable_phase_matchingis on, on every live matching trigger. -
The community store no longer re-queries the brand and device catalog on every panel open (
store_client.py): The online-features panel fetches the store's brand list and device search results whenever the Settings or Store tab is (re-)opened, and those reads were sent to the store's Firebase backend every single time with no caching. On the store's free tier (a fixed daily document-read budget shared by every install) that brand-list query alone was by far the largest read source, and repeated panel opens could exhaust the daily budget. The integration now caches these public, slow-changing catalog reads (list_brands,search_devices) in memory for 15 minutes, and theconfig/siteread (used to resolve the community confirm-threshold on every device confirmation) for one hour. Because the store client is a single long-lived instance per device, the cache survives panel reloads, so a burst of panel opens now costs at most one Firestore query per catalog key per window instead of one per open. Contributing a brand or device immediately clears the affected cache entries, so a freshly-added entry still appears right away for the user who added it. Only successful reads are cached, so a transient network failure never pins a stale or empty result.
Bug Fixes
-
Panel registration on multi-device installs no longer causes a blocking startup call or a registration race (#328): On systems with more than one WashData device, every
async_setup_entrycall issued aos.scandir(blocking filesystem call) directly on the event loop and then raced to register the panel sidebar entry, with each device winning the race overwriting the last. Both issues are fixed: the scandir is now offloaded to an executor thread, and a sharedasyncio.Taskkey on thehassobject ensures only the first setup call registers the panel while all subsequent ones await the same in-flight task, so registration happens exactly once regardless of how many devices load simultaneously. -
The live power chart no longer appears frozen at the last active reading during end-of-cycle detection (#329): While a cycle is in the ending phase (waiting out the minimum off-gap soak-bridge before finalizing), the detector's sampled power trace can lag the raw sensor by up to one sampling interval because some readings are throttled. The
get_power_historyWebSocket handler now overlays any rawdiag_bufferreadings that are strictly newer than the last trace point before returning the live chart data, so the power-drop is visible immediately rather than waiting for the next throttle-pass or cycle finalize. Additionally, therunning_dead_zonesetting has been retired: this configuration option existed for several releases but was never wired to any detection logic (tuning it had no effect whatsoever). The field has been removed from the panel, the settings schema, the suggestion engine, and the Playground; a 3.7 -> 3.8 config migration strips it from existing entries. Its documented purpose -- guarding against false stops in the first few minutes of a cycle -- is correctly served by the Start Duration Threshold and the energy gate on the STARTING state. -
Re-labelling a cycle that is awaiting review now clears it from the "must be reviewed" queue (#331): When a cycle finished with an uncertain match it entered a review queue backed by a pending-feedback record holding the (often wrong) auto-detected program. Correcting it the obvious way -- opening the cycle and using the "Label" button to assign the right program -- changed the label but never touched that pending feedback, so the cycle stayed in the queue indefinitely, still tied to the stale original detection, with no visible control to resolve it (only the
submit_cycle_feedbackservice could). A manual (re)label is now treated as the user's answer to "did WashData detect the right program?": labelling a review cycle to the same program records a confirmation, labelling it to a different program (or creating a new one) records a correction, and removing the label records a rejection -- in every case the pending feedback is resolved and the cycle leaves the review queue. This applies to the panel's Label button, bulk relabel, the Review-mode label field (which previously stamped the cycle as reviewed while silently leaving the feedback orphaned), and thelabel_cycleservice. The Confirm / Correct / Ignore controls are also now shown directly in the cycle's Inspect view, not only inside Review mode, so a "needs review" cycle exposes its resolve actions the moment you open it.