v0.5.2
0.5.2 - 2026-07-23
Bug Fixes
-
Back-to-back washes with an anti-crease tail no longer merge into one giant cycle (#296): A washing machine in anti-crease mode (e.g. Miele "Knitterschutz") holds a low baseline plus periodic low-power tumble bursts after the wash finishes, until the door is opened. Because those bursts recur faster than the off-delay, they kept reviving the cycle, so it never finished into the anti-wrinkle state that is designed to absorb the tail. If a second load was started before the door was opened, the whole sequence (wash → tail → wash → tail) merged into a single multi-hour "cycle". The earlier 0.5.1 backstops only caught a flat standby plateau, not this bursty tail. WashData now recognises the anti-crease tail directly: once a confidently-matched, genuinely-hot cycle is past its expected duration and has settled into a low-power tail with no reading above the anti-wrinkle power threshold, it finalizes the wash into the anti-wrinkle state (which then absorbs the tail and splits off the next wash on its first heating burst). It also freezes program re-matching on the growing tail, so the match can't drift to a longer profile and stall the finish. Opt-in via the existing anti-wrinkle setting, gated on being past the expected duration so a genuine mid-wash low-power phase (a washer spends most of its cycle below that threshold) is never cut short. Verified against a real Miele export.
-
Machines that hold a small standby draw after finishing no longer run indefinitely (#296): Some washers and dryers finish a cycle but keep drawing a small, flat "anti-crease" or display standby load that sits just above the stop threshold. Because the power never actually dropped below the stop threshold, the cycle never progressed to the ending phase and would run until the 8-hour safety cap (and anti-wrinkle handling couldn't engage, because it only starts once a cycle has finished). WashData now detects a flat, low, sustained plateau (at most 10% of the cycle's peak power, held for at least 10 minutes, past twice the expected duration) on washers and dryers and finalizes the cycle as a normal completion — which also lets anti-wrinkle handling engage afterwards. The plateau must be genuinely flat, so a cycle still doing real work is never cut short, and appliances with legitimate low-power holds (bread makers, pumps, air fryers) are excluded.
-
Cycles with an uncertain program match no longer hang for hours after finishing (#296, #311): The early "Smart Termination" that ends a cycle as soon as its matched program is done is deliberately skipped when the program match is ambiguous (to avoid splitting one wash into two records). Such cycles fell back to power-based end detection, which a low but non-zero standby draw (below the stop threshold, but enough to keep tripping the energy gate) could hold open until the 8-hour safety cap. A new duration-anchored backstop now finalizes a matched cycle that has sat in the ending phase well past its expected duration (twice the expected length) and been continuously quiet for at least 10 minutes. It only ever shortens a stuck wait, never ends a cycle early, and the sustained-quiet requirement means a genuinely longer program (which still has high-power phases) is never cut short.
-
A cycle paused during start-up no longer stays pinned in the "Starting" state forever (
cycle_detector.py): Pausing an appliance while WashData was still confirming the cycle start (the brief "Starting" phase) correctly held that state waiting for a Resume — but if the appliance was then switched off entirely instead of resumed, nothing ever moved the detector back to Idle. It would remain stuck in "Starting" until the integration was reloaded. The detector now watches for sustained truly-off power (below the stop threshold) during a paused start-up and returns to Idle after 5 minutes. A genuine pause, which holds standby power above the stop threshold, is unaffected and still waits indefinitely for a Resume. Also fixed: on appliances with a slower sensor polling rate (readings every 30+ seconds), one below-stop reading with a large preceding interval could be credited as the full "off duration" and immediately trigger the 5-minute cancel threshold. The timeout now measures from the first observed below-stop reading rather than accumulating each interval, so only genuinely observed quiet time counts. -
Setting suggestions no longer shorten the off-delay in a way that splits soak pauses (#311): When reconciling suggested settings, the rule that keeps the minimum off-gap at least as large as the off-delay could resolve the conflict by lowering the off-delay instead of raising the gap. A shorter off-delay makes end-of-cycle detection more aggressive, so a genuine multi-minute soak pause could be mistaken for the end of the cycle and split one wash into two records. The reconciliation now always raises the minimum off-gap and never lowers the off-delay. To prevent the larger gap from delaying end-detection, the washing-machine / washer-dryer Smart-Termination quiet-time requirement is now capped at 10 minutes (the shipped defaults of 4 min / 5 min are unchanged; the cap only bounds unusually large hand-set or suggested gaps that previously inflated it to 15+ minutes and starved end-detection).
-
Splitting a cycle no longer silently deletes it on failure (#323): When a split was attempted on an older cycle whose power data had been stripped by the retention policy (or had an unparseable timestamp), the split operation removed the source cycle from the history list before checking whether it could actually be split. The early-exit path left the cycle permanently deleted, and the next unrelated save committed the loss. Validation now runs before the cycle is removed, so a split that cannot proceed leaves the history intact. Two additional edge cases were also fixed: (1) all segment bounds (not just the source timestamp and trace) are now validated before the source cycle is removed — a malformed segment encountered mid-loop after the pop had already happened would previously leave partial children and no source cycle; (2) a split request with fewer than two valid segments is now rejected before the pop, so an empty or single-segment call can no longer delete the source cycle and create zero or one orphaned children.
-
Splitting a cycle no longer permanently blocks all subsequent edits (#323): If the split operation raised an exception after acquiring the per-entry write lock, the lock was never released -- causing every later trim, merge, split, reprocess, and settings-save to block forever waiting for it. The lock is now always released in a
finallyblock, matching the pattern already used by trim, merge, and all other write-locked operations. -
Trim and merge now update cycle energy after editing (#323): Trimming or merging a cycle rewrites its power trace but previously left
energy_wh(and for trim,max_power) unchanged, so the stored value reflected the pre-edit cycle. The per-cycle energy is now recomputed from the new power data using the sameintegrate_whpath used at detection time. Trim also updatesmax_powerfrom the kept window. Profile average-energy statistics derived from cycle history are therefore correct after an edit. Edge case also fixed: when an aggressive trim leaves exactly one sample,energy_whis now set to 0 andmax_powerreflects that sample's power value; previously both retained their pre-trim values. -
Cycles stuck in the ENDING phase no longer run indefinitely (#296, #311): The detector's 8-hour hard-stop cap applied only to the RUNNING state; a cycle that entered the ENDING phase (waiting for sustained low power before officially finishing) had no equivalent cap. With a constant standby baseline above the stop threshold, the cycle could sit in ENDING for 16+ hours. ENDING now has the same 8-hour absolute cap as RUNNING.
-
Unmatched cycles no longer run indefinitely on devices with no profiles (#296): The manager watchdog that kills zombie cycles only fired when a profile had been matched (
expected > 0). A cycle on a device with no profiles, or one that never matched, had no manager-level time limit and would only be stopped by the detector's 8-hour hard cap (which relies on a quiet sensor). A chatty sensor that stays above the start threshold kept these cycles alive indefinitely. Unmatched cycles are now force-ended by the manager watchdog after 4 hours. -
Background tasks (split, trim, merge, reprocess, ML training, Playground) no longer get stuck showing "Running" after a reload (#323): When Home Assistant cancelled a background task during a reload or shutdown, the
asyncio.CancelledErrorwas aBaseExceptionand bypassed theexcept Exceptionhandler, sotask_registry.finish()was never called. The task stayed in therunningstate indefinitely, making its header pill spin forever even after reload. All nine task runners now explicitly catchCancelledError, mark the taskcancelled, and re-raise so asyncio behaves correctly. -
Tasks and write locks no longer leak across
reload_config_entry(#323):async_unload_entrynow explicitly marks any still-running tasks for the entry as cancelled in the task registry, and releases the per-entry write lock. Previously both could survive across a reload, causing task pills to reappear in the panel and new operations to block forever on a held lock. Also fixed: the cancelled WS-spawned tasks are now awaited (asyncio.gather) before the write lock is removed, so theirfinallyblocks (which release the lock on the old reference) complete before the lock entry is discarded and a new setup can start. -
An in-progress cycle-end task no longer races a freshly-loaded ProfileStore (
manager.py): When_on_cycle_endspawned its post-processing task and areload_config_entrycompleted before the task finished, the task would write the new cycle to the old in-memory ProfileStore. The new store (loaded from the file before the write) would then overwrite the file on its next save, silently discarding the cycle. The task handle is now stored and cancelled inasync_shutdown; the task is not created at all if shutdown has already begun. -
Translation updates from community contributors now take effect immediately (
frontend.py): The panel and per-language translation files were cache-busted using only theha-washdata-panel.jsmodification time. A translation-only update (e.g. a GitLocalize merge) didn't change the JS file, so browsers kept serving stale cached translations even after a HA restart. The cache-buster now uses the newer of the JS file mtime and thetranslations/panel/directory mtime, so a translation-only release correctly invalidates the cache. -
Playground what-if simulation now uses the correct default config (
ws_api.py): The Playground fallback config (used when the live detector config isn't yet available) hardcodedmin_power=5.0andrunning_dead_zone=0, while the canonical defaults inconst.pyare2.0Wand3srespectively. Therunning_dead_zonedivergence was reachable because the config migration never set a default for it. The fallback now readsDEFAULT_MIN_POWERandDEFAULT_RUNNING_DEAD_ZONEso Playground simulations are faithful to the live detector. -
Start notification no longer re-evaluates on every 5-minute match tick (
manager.py): When only the HA event bus was used for start notification (no notify service, no action),_notified_startwas never set toTruebecause the notification service path never ran. The matching fallback in_async_do_perform_matchingtherefore re-entered the start-notification block on every 5-minute match tick for the entire cycle's lifetime._notified_startnow tracks only whether the start push has been sent (event delivery is tracked separately by_start_event_fired): an event-only config marks the start handled as soon as the event fires so the re-check is skipped, while a config that has both events and a push service keeps_notified_startclear until the push actually dispatches, so it still receives its start push. -
Phase label no longer drops to the last-defined phase during a gap between user-drawn ranges: When a cycle's elapsed time fell in a gap between two consecutive phase ranges in the profile (for example, between a Heating phase ending at 20 min and a Rinse phase starting at 25 min), the fallback previously returned the absolute last phase name in the list rather than the phase that had most recently applied. It now returns the phase whose start was last passed, which is consistent with "still in the previous phase" semantics. Also fixed: phase ranges with a missing or zero
endvalue are now skipped (instead of silently matching everything at time 0), and aNonestart value no longer raises aTypeErrorduring the sort. An additional robustness fix: a non-dict entry in a profile's saved phase list (possible from legacy or corrupted storage) would raiseAttributeErrorduring the sort and crash live phase matching for the cycle. Such entries are now silently filtered out before sorting. -
Detector state is fully reset between cycles (
cycle_detector.py): After a cycle completed,reset()cleared the matched profile name but left_expected_duration,_last_match_confidence,_match_ambiguous, and_match_prefix_ambiguouscarrying over from the previous cycle. A stale non-zero_expected_durationcould mislead the dishwasher end-spike gate (which uses it to decide whether to arm), potentially blocking the fast-close path for the second cycle. All four fields are now explicitly zeroed inreset(). -
Idle sensors no longer flood the power-reading path (
manager.py): When an appliance is switched off, smart plugs typically publish a 0W reading on every polling interval (often every 1-5 seconds). Because 0W is belowmin_power, the sampling-interval throttle was bypassed unconditionally, causing the detector and all downstream estimators to be called up to 60+ times per minute for a device that was clearly idle. The bypass now only applies when a cycle is actively in progress (RUNNING, ENDING, or PAUSED state) or when the previous reading was abovemin_power(a genuine power-drop event). Devices at idle are throttled by the normal sampling interval. -
ML quality/conformance gate now accepts numpy scalar scores (
learning.py): The auto-label downgrade logic that catches suspicious cycles usedisinstance(x, float)to guard the threshold comparison. In NumPy 2.0+numpy.float64is no longer a subclass of Pythonfloat, so ML model scores (which are numpy scalars) silently bypassed the check and suspicious cycles were never downgraded to feedback requests. The gate now usesfloat()with exception handling so numpy scalars, Python floats, and non-numeric sentinel values are all handled correctly. -
Playground Test-on-history and Parameter Sweep rebuild match snapshots once per run, not once per chunk (
playground.py,ws_api.py): The chunked Playground history runner split work into 2-cycle chunks to keep the event loop responsive, but each chunk independently re-derived the full set of profile match snapshots (decompressing sample cycles and applying group collapsing). For a 10×10 parameter sweep grid, this rebuilt the identical snapshot set 100 times. Snapshots are now built once before the loop and passed through, cutting snapshot-rebuild overhead from O(chunks × profiles) to O(profiles). -
DTW refinement no longer resamples the current-cycle trace once per candidate (
analysis.py): In ensemble DTW mode the current trace was resampled to the DTW grid length twice per candidate (once for the L1 component and once for the derivative component); in other modes it was resampled once per candidate. Since the current trace does not change within a single match pass, it is now resampled once before the candidate loop and the pre-resampled array is passed to each candidate's scoring call. Matching results are byte-identical. -
ENDING energy-gate no longer scans the entire growing trace per reading (
cycle_detector.py): When the off-delay elapsed and the energy gate was active, the gate built its recent-window list by filtering the full_power_readingshistory with a time comparison on every entry. For a long cycle (2800+ readings on a 4-hour dishwasher cycle), this was O(n) on every below-threshold reading while in ENDING. The filter is now a tail-walk that breaks as soon as it steps outside the gate window, making it O(window-size) regardless of cycle length. -
ML cycle quality scoring no longer has a data race on the past-cycles list (
manager.py):_compute_cycle_quality_scoreruns in an executor thread and iterated the livepast_cycleslist, which the event loop can append to concurrently. This is now a snapshotted copy, eliminating the race. -
Task results from one device can no longer be evicted by another device's task activity (
task_registry.py): The finished-task retention cap (_MAX_FINISHED=30) was applied globally across all devices. In a multi-device install, a device running frequent ML training could evict another device's Playground result before the panel read it. The cap is now applied per device entry. -
find_best_alignmentis now safe against empty or single-point inputs (analysis.py):np.correlatefollowed bynp.argmaxcrashes on a zero-length array. While the snapshot builder guarantees at least 2 points today, a defensive early-return is now in place so future callers are safe. -
Navigating away from and back to the WashData panel no longer loses live cycle push updates (#323,
panel.js): HA caches custom panel elements between navigation events. When the element disconnected from the DOM, all WebSocket event subscriptions (cycle-started/ended push events and the background-task registry push) were torn down but never restored when the element reconnected. Returning to the panel left it relying only on the 30-second fallback poll: live cycle state transitions no longer updated immediately, task progress pills stopped updating, "Cancelling…" pills could never clear, and the modal Escape/Tab keyboard handler was dead. All subscriptions are now re-established inconnectedCallback()when returning to an already-initialized panel; the keyboard handler is also re-attached. -
Task cancel pills no longer get permanently stuck in "Cancelling…" (#323,
panel.js): After requesting a cancel, the task ID was held in a_cancellingTasksset and removed only when a task-registry push event confirmed the task had stopped. If the task finished while the WebSocket subscription was down (during a reconnect), the confirmation never arrived and the pill stayed "Cancelling…" disabled forever. The pill renderer now prunes stale IDs from_cancellingTasksbefore rendering — any ID whose task is no longer in the running list is silently cleared. -
Playground Simulate timeline no longer ships thousands of points over WebSocket (
playground.py): A 4-hour dishwasher cycle at the 5-second Simulate cadence produces ~2,800 per-step timeline points, each a 12-key dict. These were stored uncompressed in the task result (up to 30 retained per device) and serialized over WebSocket on demand. The timeline is now capped at 600 points at finalize time via uniform thinning, which preserves the overall shape for the panel's chart without a measurable fidelity cost. -
On-device ML regression training promotion gate fixed (
ml/training_task.py): The gate that decides whether a newly-trained regression model beats the naive baseline usedfloat(metrics.get("mae") or 1.0). Python'soroperator treats0.0(MAE of a perfect regressor) as falsy and replaces it with1.0, so a perfect MAE would be seen as the worst possible score and promotion would be rejected. Changed to an explicitNonecheck. -
Chart hover no longer causes per-frame jank on long cycles (
panel.js): Every pointer-move event over a chart triggered a full synchronous canvas redraw, and on every redraw the canvas's width and height attributes were reassigned unconditionally — which clears the canvas and forces a GPU-side backing-store reallocation even when the size hasn't changed. On the spaghetti chart with 100+ cycles and 10k+ data points this caused visible main-thread stalls. Two fixes: (1) hover redraws are now rAF-coalesced — only one redraw per animation frame regardless of how many pointer-move events fire, and (2) the canvas backing-store is only reallocated when the computed size actually changes (clearRectclears it instead). -
Panel strings now go through the translation system (
panel.js): Five user-visible strings were hardcoded in English and bypassed the_t()translation function: the "Remove"aria-labelon entity-list pill remove buttons, the "Toggle Home Assistant sidebar"aria-labelon the hamburger button, the "Drag to resize"titleon the log-drawer resize handle, and the "Remove this many seconds from the start/end" field hints in the recording trim modal. All five are now translated. Addedlbl.drag_to_resize,msg.head_trim_hint, andmsg.tail_trim_hinttotranslations/panel/en.json -
Trim, split, and merge tasks now respond correctly to cancel requests (
ws_api.py): When a user requested cancellation of a running trim, split, or merge task,cancel_taskreturned{"cancelled": true}and set the cancel flag, but the task runner never checked the flag and the operation completed anyway — leaving the user with a visible "cancelled" confirmation but a result that still landed. All three runners now checktask.cancel_requestedimmediately after acquiring the write lock, and abort cleanly if a cancel was requested while the task was waiting in the queue. -
Notification action
Scriptobject is now compiled once and cached (manager.py): Every notification delivery call (cycle-start, progress update, cycle-end, quiet-hours flush) reconstructed the HAScriptobject from the raw action sequence JSON, which re-validates, re-parses, and re-compiles the action list on each call. The compiled script is now cached on first use and invalidated only when the action sequence changes (on options reload), eliminating the redundant per-call construction for the common steady-state case. -
Suggestion engine no longer runs 4 full cycle-history scans on every cycle end (
learning.py): The model-suggestion and detection-suggestion passes (which each scan up to 200 cycle power traces) were triggered unconditionally on every cycle end — including unlabeled, noise, and interrupted cycles where nothing in the training data changed. These passes are now gated on the labeled-cycle count: they run only when at least one new labeled+completed cycle has appeared since the last run. The single-cyclerun_simulationpass is also now skipped for noise, unlabeled, or interrupted cycles. -
Removed vestigial
abrupt_drop_watts/abrupt_drop_ratiosettings (panel.js,translations/panel/): The detector code for abrupt-drop detection was removed in an earlier release, but the two settings fields remained visible in the detection settings panel (where they do nothing) and their translation strings remained in all 35 language files. Both fields are now removed from the settings schema, diagram key map, diagram SVG renderer, and diagnostics table in the panel, and their translation keys (setting.abrupt_drop_ratio,setting.abrupt_drop_watts,pg_desc.abrupt_drop_watts) have been removed from every panel translation file. -
Dead sync
match_profilemethod removed (profile_store.py): A 56-line synchronous matching method that predated the full Stage 1-5 pipeline was still present inprofile_store.py. It omitted Stage-5 profile groups, golden cycle selection, and the envelope-based snapshot pipeline — diverging fromasync_match_profile— and had no callers. Removed to eliminate the maintenance risk of it being re-wired and producing subtly wrong results. -
On-device ML models with stale feature columns now fall back to baseline (
ml/engine.py): If a future release changes any model'sFEATURE_COLUMNS, a previously-promoted on-device spec stored inml_model_versionswould score on a mismatched feature schema, silently overriding the correct baseline.resolve_scorerandresolve_regressornow compare the spec's storedfeature_columnsagainst the current model's expected columns at load time; a mismatch logs a one-time warning and falls back to the baseline (or inert, for regressors with no baseline). -
Cycle retention now runs before the split result is saved (
profile_store.py): When a cycle was split into segments, the retention enforcement was scheduled as a fire-and-forget async task rather than being awaited. This meant the newly-created segments might be saved without first checking whether they pushed the history past the retention limit; the trim would only apply on the next unrelated save.apply_split_interactivenow usesasync_add_cycle(which awaits retention) instead of the syncadd_cycle, so the retained cycle list is always consistent at save time. -
Cycle-ID collisions no longer corrupt history (
profile_store.py): Cycle IDs are derived from a 12-character SHA-256 prefix ofstart_time + duration. Two cycles with identical raw (pre-normalization) start times and durations — most likely during bulk reference-cycle imports — would hash to the same ID, causing the wrong cycle to be returned or deleted by any ID-based lookup._add_cycle_datanow checks for ID conflicts in the destination list and appends an incrementing suffix to the hash input until the generated ID is unique. -
Playground "Simulate" is ~6x faster for long cycles (
playground.py): The series-point throttle constant (_SIM_SERIES_THROTTLE_S) was 5.0 seconds — matching the typical 5-second sensor reading cadence, so the throttle dropped nothing and every reading triggered a full O(trace-length) estimator pass. A 233-minute dishwasher cycle at 5s cadence therefore ran all ~2,800 estimator passes in serial, each doing more work than the last. Raising the throttle to 30.0 seconds keeps the series well underMAX_SERIES_PER_CYCLE(600) for any realistic cycle, reducing estimator calls approximately 6x and making the per-chunk executor work proportionally shorter. -
"Process history" can now be cancelled between steps (
ws_api.py): The five-step reprocess task (async_reprocess_all_data→ golden backfill → suggestions → ML training → health recompute) only checkedcancel_requestedbefore the ML training step; the first three steps ran regardless of a pending cancel. Cancel checkpoints (with partial results) are now inserted before the first step and at each step boundary, so a cancel request issued while matching cycles completes or while suggestions run is honoured at the next safe exit point. -
Profile store now applies per-device duration-ratio default at startup (
manager.py): TheProfileStoreconstructor was given the globalDEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO(0.10) as the fallback, rather than the per-device override inDEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO_BY_DEVICE. This diverged from theCycleDetectorConfigconstruction which correctly uses the per-device dict. Currently inert (dishwasher's entry equals the flat default), but would silently produce the wrong Stage-1 gate after the first power cycle if a device type with a genuinely different default were added. The initialization now uses the same per-device-with-fallback pattern as the detector config.