Skip to content

v0.5.2

Choose a tag to compare

@3dg1luk43 3dg1luk43 released this 23 Jul 09:44
· 180 commits to main since this release
08aa94e

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 finally block, 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 same integrate_wh path used at detection time. Trim also updates max_power from 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_wh is now set to 0 and max_power reflects 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.CancelledError was a BaseException and bypassed the except Exception handler, so task_registry.finish() was never called. The task stayed in the running state indefinitely, making its header pill spin forever even after reload. All nine task runners now explicitly catch CancelledError, mark the task cancelled, and re-raise so asyncio behaves correctly.

  • Tasks and write locks no longer leak across reload_config_entry (#323): async_unload_entry now 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 their finally blocks (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_end spawned its post-processing task and a reload_config_entry completed 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 in async_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 the ha-washdata-panel.js modification 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 the translations/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) hardcoded min_power=5.0 and running_dead_zone=0, while the canonical defaults in const.py are 2.0W and 3s respectively. The running_dead_zone divergence was reachable because the config migration never set a default for it. The fallback now reads DEFAULT_MIN_POWER and DEFAULT_RUNNING_DEAD_ZONE so 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_start was never set to True because the notification service path never ran. The matching fallback in _async_do_perform_matching therefore re-entered the start-notification block on every 5-minute match tick for the entire cycle's lifetime. _notified_start now 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_start clear 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 end value are now skipped (instead of silently matching everything at time 0), and a None start value no longer raises a TypeError during the sort. An additional robustness fix: a non-dict entry in a profile's saved phase list (possible from legacy or corrupted storage) would raise AttributeError during 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_ambiguous carrying over from the previous cycle. A stale non-zero _expected_duration could 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 in reset().

  • 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 below min_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 above min_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 used isinstance(x, float) to guard the threshold comparison. In NumPy 2.0+ numpy.float64 is no longer a subclass of Python float, so ML model scores (which are numpy scalars) silently bypassed the check and suspicious cycles were never downgraded to feedback requests. The gate now uses float() 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_readings history 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_score runs in an executor thread and iterated the live past_cycles list, 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_alignment is now safe against empty or single-point inputs (analysis.py): np.correlate followed by np.argmax crashes 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 in connectedCallback() 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 _cancellingTasks set 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 _cancellingTasks before 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 used float(metrics.get("mae") or 1.0). Python's or operator treats 0.0 (MAE of a perfect regressor) as falsy and replaces it with 1.0, so a perfect MAE would be seen as the worst possible score and promotion would be rejected. Changed to an explicit None check.

  • 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 (clearRect clears 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-label on entity-list pill remove buttons, the "Toggle Home Assistant sidebar" aria-label on the hamburger button, the "Drag to resize" title on 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. Added lbl.drag_to_resize, msg.head_trim_hint, and msg.tail_trim_hint to translations/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_task returned {"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 check task.cancel_requested immediately after acquiring the write lock, and abort cleanly if a cancel was requested while the task was waiting in the queue.

  • Notification action Script object is now compiled once and cached (manager.py): Every notification delivery call (cycle-start, progress update, cycle-end, quiet-hours flush) reconstructed the HA Script object 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-cycle run_simulation pass is also now skipped for noise, unlabeled, or interrupted cycles.

  • Removed vestigial abrupt_drop_watts/abrupt_drop_ratio settings (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_profile method removed (profile_store.py): A 56-line synchronous matching method that predated the full Stage 1-5 pipeline was still present in profile_store.py. It omitted Stage-5 profile groups, golden cycle selection, and the envelope-based snapshot pipeline — diverging from async_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's FEATURE_COLUMNS, a previously-promoted on-device spec stored in ml_model_versions would score on a mismatched feature schema, silently overriding the correct baseline. resolve_scorer and resolve_regressor now compare the spec's stored feature_columns against 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_interactive now uses async_add_cycle (which awaits retention) instead of the sync add_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 of start_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_data now 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 under MAX_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 checked cancel_requested before 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): The ProfileStore constructor was given the global DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO (0.10) as the fallback, rather than the per-device override in DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO_BY_DEVICE. This diverged from the CycleDetectorConfig construction 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.