Skip to content

v0.5.4

Choose a tag to compare

@3dg1luk43 3dg1luk43 released this 17 Aug 21:26
· 49 commits to main since this release
32fd596

0.5.4 - 2026-08-17

Features

  • The Playground gets a settings control panel: load live settings, save/load presets, publish single values (playground.py, profile_store.py, ws_api.py, ws_schema.py, const.py, panel, playwright-tests/): The Playground's detection settings block now opens with a Settings source panel. Every field starts from the device's live integration settings - read back from the running detector and matcher, so device-type defaults (a dishwasher's off-gap, a dryer's thresholds) are the real values the integration uses rather than a generic schema default, and a field that was never explicitly configured is no longer blank. A status line says either "Matches live settings" or "N changed vs live settings", and Load live settings re-reads them and discards every sandbox edit. Setups can now be saved as named presets per device and loaded back later, so you can keep "winter", "quiet nights" or "before/after tuning" side by side and switch between them without retyping (presets are stored with the device, survive restarts, are visible from any browser, and never touch live configuration; up to 30 per device). Finally, each edited setting that is a real integration option gets its own ↑ publish button that saves just that one value to the device, alongside the existing bulk "Publish N to integration" - the Stage 2-4 matcher scoring knobs (correlation weight, DTW blend, duration/energy weights and scales) deliberately have no publish button because they are sandbox-only experiment constants with no configuration option behind them. Previously the bulk save could write those sandbox-only keys into the device's options as dead entries, and it also cleared unrelated sandbox edits after saving; both are fixed.

  • Playground can now load the auto-tuner's current suggestions as a starting point (ws_api.py, ws_schema.py, panel): The Playground's Settings source control panel now shows a Load suggested (N) button when the auto-tuner has recommendations that differ from the current Playground values, and a Load Calibrated (ML) (N) button when ML-calibrated suggestions are also available. Clicking either stages those values as Playground overrides without touching the live integration — the same sandbox-only staging as a manual edit — so you can immediately run a simulation to see what effect the suggestion would have before deciding whether to publish it. Unlike loading a preset (which replaces all values), suggestion loading is additive: it only overwrites the keys covered by suggestions and leaves any other staged edits untouched. Both suggestion sets are computed on the server when the Playground tab opens, so the button labels already show the correct count with no extra round-trip on click.

  • The bundled dashboard card is rebuilt as an adaptive, zero-config card (www/ha-washdata-card.js, translations/panel/*.json, playwright-tests/): The old card was a fixed 56 px tile whose default configuration read the program and progress from sensor attributes that no longer exist on the state sensor (program/cycle_progress), so out of the box it showed only an icon, title and sub-state - program, percentage and time each needed a hand-wired entity. It is now a single adaptive ha-washdata-card with a layout option: tile (default, backward compatible) - the compact tile, now with a state-coloured progress bar and an adaptive detail line; detail - a richer card with a large time-remaining readout, a live phase chip, projected energy and cost, current power, an optional live power sparkline, and an amber "running long" overrun accent (visible-only, never a notification); and glance - several appliances as one at-a-glance list of state-dot rows. Point the card at any one WashData entity (or the discovered device) and it auto-wires every sibling - program, time remaining, progress, power, phase, and the pause/resume/end-cycle/record buttons - from the device registry by translation key, so no manual entity wiring is needed (the old per-entity overrides still work). State colours now come from the integration's single STATE_COLORS source, so Paused, Clean, Finished and Interrupted are visually distinct, and an opt-in action-button row (pause, resume, end cycle, start/stop recording, program picker, open panel) wires straight to the existing button and select entities. The card's private, machine-translated string table has been removed: its chrome now flows through the same panel-translation pipeline as the rest of the UI (localised into all 35 languages), and appliance/program/phase values localise through Home Assistant directly. The detail layout also reserves the correct height in the new sections dashboard view, so its action buttons and sparkline are no longer clipped off the bottom. Existing type: custom:ha-washdata-card dashboards are unchanged (they default to tile). New Playwright coverage exercises all three layouts, auto-discovery, the progress bar, state-gated buttons and the editor schema.

  • The dishwasher end-of-cycle drain wait is now configurable (#379) (const.py, cycle_detector.py, manager.py, playground.py, panel): Many dishwashers finish with a long near-silent passive-drying phase followed by a short final drain. WashData releases the end-of-cycle drain wait once the cycle has passed its expected duration and then been quiet for a fixed window (previously a hard-coded 600 s). On a machine whose silent stretch is longer than that, the wait effectively collapsed to "release at expected duration", so as the true cycle length drifted up (colder winter inlet water = longer heating) the final drain landed just past expected and was missed - and because the recorded duration then fed back into the rolling average, the learned duration could no longer track the drift. A new dishwasher-only Passive-Dry Quiet Release setting (Settings -> Dishwasher, default unchanged at 600 s) lets you widen that window so the drain is caught and the profile can follow the drift. It only ever shortens the wait relative to the internal 30-minute end-spike cap, never extends it. Requires the detector to run the dishwasher path, fixed in #378. Thanks to @andrei-marinache for the analysis and closed-loop simulation.

  • Repeat the "unload the machine" reminder until you open the door (#374) (manager.py, const.py, panel): The post-cycle unload reminder was a single nag. A new opt-in "Repeat Until Door Opens" toggle (Triggers & Door -> Unload Reminder) re-sends it every "Unload Nag Delay" minutes until you open the door, tap a "Stop reminding" button on the notification itself, or the safety limit of 48 repeats is reached (approximately two days at the default one-hour interval). The reminder is delivered as an actionable, sticky notification on Home Assistant companion-app (mobile) targets so it can be dismissed with one tap; opening the door still clears it as before, and on non-mobile targets the door-open or the 48-repeat limit are the stop conditions. Default off, so existing setups keep the single reminder. Like the reminder itself, it requires a Door Sensor Entity (the Clean state that drives the reminder only exists when a door sensor is configured).

  • Adjustable panel font size (panel, store_account.py): The panel now has a font-size slider in My Preferences (the header gear menu, under Display). If the text feels too small - or too large - drag it to scale the entire panel up or down; it previews live as you drag and is remembered for your Home Assistant account on that device. Because the panel's layout is relative, everything scales together cleanly. The value is clamped to a safe range so it can never break rendering.

  • Setup and settings dialogs now link to the panel (strings.json): The initial setup, reconfigure, and options dialogs now include a direct link to the WashData panel (/ha-washdata), so it is obvious where to go to view cycles, tune detection, and manage profiles after adding a device (it also lives in the sidebar).

  • Door-sensor support for dishwashers that auto-open at the end (#342) (manager.py, const.py, panel): Many modern dishwashers pop the door open at the end of the cycle to dry (AirDry and similar). WashData treated that door-open as a mid-cycle pause and stranded the cycle in "paused by user" until you forced it to end. A new "Door Opens Automatically At End" option (with a "Door-Open End Dwell", default 60 s) changes this: on such a device a door-open on a running cycle arms a short dwell instead of the sticky pause, and if the door stays open past the dwell WashData finishes the cycle (completed) - while a brief open to add a dish closes before the dwell and is ignored. Opt-in and needs a Door Sensor Entity; devices without the option keep the previous pause-on-open behaviour.

  • Smarter auto-tune on anti-crease devices, plus a per-setting mute (#343) (suggestion_engine.py, profile_store.py, learning.py, ws_api.py, panel): The auto-tuner kept proposing stop/start thresholds just above the anti-crease tumble-pulse baseline - values that break end-detection - and it re-proposed them every cycle even after you dismissed them. Five algorithmic gaps are now fixed and a mute UI is added: (1) Stop/start thresholds are derived from the main cycle only - the post-cycle tumble-pulse tail is stripped before computing the minimum-active-power statistic (for both the single-cycle and multi-cycle paths). (2) The min_power noise-gate suggestion is now also stripped so it cannot be dragged below the real operating draw by the anti-crease baseline. (3) The off_delay suggestion is now safe: the pause-based path strips anti-crease inter-burst quiet periods (up to 180-240 s on Miele/Bosch) before counting genuine intra-cycle pauses; the cadence-fallback path (p95 × 5) is suppressed entirely when anti-crease is enabled because the result often exceeds the burst interval and resets the end timer on every tumble burst. (4) The per-cycle end_energy suggestion is removed from the single-cycle path where it was hardcoded to 0.05 Wh - a value that falls below the anti-crease baseline energy accumulated over the off_delay window, making the end gate impossible to satisfy; the multi-cycle batch path derives a cycle-proportional floor from real false-end events and is unaffected. (5) Each suggestion can now be muted with a mute button on every suggestion card; muted settings are never re-proposed until you reset them, with a "N muted - Reset muted" banner in Settings.

  • Every notification is now written to the log (manager.py): WashData now records a log line each time it notifies you, so you can audit exactly what was sent and where. Discrete notifications (cycle start, finished, milestone, unload reminder, pause, and the rest) log at INFO as Notification sent (<event>), naming just the event. The delivery target (each notify.* service, actions, or the persistent_notification fallback) and the rendered message body are logged separately at DEBUG as Notification sent (<event>) via <target>: <message>, so the target list (which can contain a person's mobile-app entity id) and message text stay out of the INFO logs users paste into bug reports. High-frequency live-progress countdown updates - which are repeated in-place edits of one persistent card rather than distinct alerts - also log at DEBUG so they don't drown out the meaningful lines; enable debug logging for custom_components.ha_washdata to see the target/message detail and live ticks. Notifications held back by quiet hours or the "notify only when home" presence gate log a DEBUG "deferred" line when queued and then the normal "sent" line when the hold clears and they are actually delivered.

Bug Fixes

  • Calibrated (ML) suggestions now surface as "tuning suggestions available" when you open Settings (www/ha-washdata-panel.js, playwright-tests/): A device whose only recommendation was a Calibrated (ML) value - shown as the "Calibrated" pill beside a field, for example an off-delay calibrated from the machine's own cycles - lit the light-bulb dot on the Settings tab but then, once you opened Settings, showed no "N tuning suggestions available" banner and no dot on the section that held the suggestion. Only the classic (Observed) engine's suggestions counted toward those two indicators. The Settings banner and section-navigation dots now count Calibrated recommendations too, matching the tab bulb and the existing "Show only" filter, so the in-page surfacing is consistent. The banner's Apply all and Dismiss actions still appear only when there is a classic suggestion to act on (Calibrated values are applied individually with their own Use button); the shared key computation is factored into one helper so the four call sites can no longer drift apart.

  • Auto-tune sizes the Min Off Gap from what your cycles actually need, not from how fast you reload (suggestion_engine.py, devtools/min_off_gap_eval.py, translations/panel/*.json): Min Off Gap is the quiet time that has to pass before WashData treats power coming back as a new cycle rather than a continuation. It is squeezed from two sides: it must be long enough to sit through the longest quiet stretch inside a programme (a soak phase, or a dishwasher's passive drying before its final drain), and short enough that a load you start soon after the last one is still recorded separately. The recommendation was being computed from the second of those - it landed at 80% of the shortest gap you had ever left between two loads - which put it right against the point where two loads merge into one record, without any evidence that much bridging was needed. On one real washing machine that works out at about 29 minutes against a measured need of 21 and a real turnaround of 3, so back-to-back washes would be recorded as a single cycle. It now measures the quiet stretches your programmes actually contain and sizes the value from the longest of them plus a minute, using your shortest turnaround only as a ceiling. If your machine needs to bridge more than your turnaround allows, no value can do both and no recommendation is made, leaving your current setting untouched. Every recorded cycle across the reference data set was replayed through the real detector at the proposed values to confirm no cycle gets split and no two loads get merged. For a dishwasher with a long drying phase this typically replaces the blanket 1 hour default with roughly 36 minutes measured from the machine itself.

  • Auto-tune no longer proposes an Off Delay and Watchdog Interval that stall end detection (suggestion_engine.py, translations/panel/*.json): Two of the recommended values were sized far above what the measured data supported, and on a dishwasher the result was cycles that stayed "running" long after the machine had actually finished. (1) Off Delay is derived from the longest genuine pause inside your cycles, but the measured value was then clamped up to a fixed per-appliance minimum (30 minutes for dishwashers) that exists only as a starting guess for machines with no history yet. A dishwasher whose real pauses topped out around 4 minutes was still told to use 30. That mattered because Off Delay also sets how far back WashData looks when checking whether the appliance has gone quiet: over a 30-minute window, the small standby ticks a dishwasher emits minutes after it finishes were counted as activity and kept the cycle open. Once enough real pauses have been measured, the measurement now wins (floored at the generic 3 minutes); appliances with no measured pauses yet keep the conservative per-appliance value, and cycles are still held together by Min Off Gap exactly as before. (2) Watchdog Interval was suggested at three times the typical gap between sensor updates. The watchdog is only how often WashData wakes up to check on a running cycle - it never decides on its own that a cycle has stopped - so the multiplier bought no safety and only delayed the checks: a plug reporting every 30 seconds was told to look every 3 minutes. It is now suggested just above the observed update gap (about 1 minute for a 30-second plug), matching the documented default.

  • Greek, Japanese and Korean panel translations repaired; the Playground tab is named consistently in four more languages (translations/panel/*.json): A sweep of the short UI labels in the Greek, Japanese and Korean panel files found a large amount of old machine-translation residue where the translator had picked the wrong sense of an ambiguous English word, and 185 strings are corrected. Some were actively misleading: "Save" read as rescue (ko) or out/off (el), "Dismiss" as fire an employee (el, ko), "Idle" as lazy (ko) or unemployed (el), "Envelope" (the statistical band around a program) as a mail envelope (el, ko), "Recording" as audio recording (ja, ko), "Cleanup" as spring-cleaning the house (ja, ko), "Live Power" as live strength (el, ja), "spin" as rotation rather than the spin/dry phase (ja, ko), and "Export"/"Trim"/"Edit"/"Clear" taken in trade, grooming, publishing and "obvious" senses respectively. Unit markers lost in translation are also restored (a trailing "(s)" that had become a plural marker instead of seconds, and "min" read as minimum instead of minutes), along with a number of buttons left as dictionary infinitives, and Japanese now uses one consistent word for a program profile instead of two. Separately, the Playground tab was named with two different words within the same screen in Swedish, Icelandic, Estonian and Finnish (the tab label said one thing, the heading another, and two of them read as a children's play park); each language now uses a single term conveying "test area" throughout.

  • Grouped program variants that differ by temperature or spin are told apart more reliably (#334) (profile_store.py): When you group near-duplicate profiles (for example "Cotton 40" and "Cotton 60", or a 1000 vs 1400 rpm variant), WashData identifies the program from the group and then picks which member you actually ran. That member pick compared the cycle's whole-cycle mean power and peak against each member - but on real machines the peak is just the heating element's roughly constant draw, so it is nearly identical across every temperature and spin variant, and mean power is diluted because a hotter wash also runs longer. The member pick now compares integrated energy (the area under the power curve, which WashData already records), which separates temperatures cleanly because a hotter wash spends far more energy heating water. Measured on 196 real washing-machine cycles from the community catalogue, this lifts leave-one-out variant-pick accuracy from 63% to 73%, with the program-level match results left byte-for-byte identical. Washing machines and washer-dryers now also use this energy-based comparison at the main program-matching stage itself, so temperature and spin variants are told apart better even when you have not grouped them (measured on trusted community cycles: about +3 percentage points of matching accuracy with fewer false matches). Dishwashers and dryers deliberately keep the previous behaviour, where programs are already well separated by their length.

  • The matcher callback no longer feeds a placeholder result into the detector on every tick (manager.py, cycle_detector.py): The detector's match callback contract is "return None to mean I have offloaded the real match to an async task and will call update_match later." The manager's wrapper documented that it "Returns: None" but actually returned the placeholder tuple (None, 0.0, 0.0, None). Because a non-empty tuple is truthy, the detector fed that placeholder straight into update_match on every match tick - even mid-cycle with a profile confidently matched - which (a) logged a spurious update_match: invalid raw_expected_duration 0.0 (<= 0) debug line every time, and (b) momentarily reset the detector's _last_match_confidence to 0.0 in the gap before the real async result arrived (a value Smart Termination reads). The wrapper now returns None in the async-offload and empty-reading cases, so only genuine synchronous results (the manual-program override) reach update_match. The stray debug line stops and match confidence stays stable between updates.

  • Non-washing-machine appliances now run their own detection logic (#378) (manager.py): CycleDetectorConfig carries a device_type that the detector branches on in ~16 places (dishwasher end-spike wait, keep_tail, the passive-drying deferral, the 1800 s floor, and the rest of the issue-#43 machinery), but the manager built the detector without passing it and never set it on reload - so the field kept its washing_machine default and every dishwasher, dryer, washer-dryer, bread maker and pump silently ran the washing-machine detection path. The manager's own device_type was correct (its ghost-cycle suppressor, phase naming and per-device defaults all worked), and the detector unit tests construct the config directly with the right type, which is why the wiring gap went unnoticed. On a dishwasher the practical effect was that the cycle closed ~2 minutes early at the generic end ratio and the profile average ratcheted downward each cycle. The configured device type is now passed into the detector at construction and kept in sync on every options reload. Thanks to @andrei-marinache for the detailed diagnosis.

  • A single duplicated sample offset no longer discards a whole cycle from the envelope (#377) (analysis.py): The envelope builder rejected any cycle whose sample offsets were not strictly increasing. Stored offsets are rounded to 0.1 s, so two readings less than 0.1 s apart collapse onto the same offset - and one such duplicate pair anywhere in a two-hour trace silently dropped the entire cycle. On a fast-reporting plug every cycle could carry one, leaving the profile with no envelope at all (the caller deletes the envelope when no curve survives), while matching quietly limped along on the raw sample cycle so nothing looked broken. Exact-duplicate offsets are now collapsed (dropping the duplicate sample, not the trace) with a debug log; a genuinely out-of-order offset, which sorted storage never produces, is still rejected as before. Thanks to @andrei-marinache for the report.

  • The end-energy gate can no longer silently override the stop-power gate (#376) (www/ha-washdata-panel.js, suggestion_engine.py, cycle_detector.py): Ending a cycle passes two gates that decide the same thing in different units - stop_threshold_w (instantaneous power) and end_energy_threshold (energy over an off_delay-long window). Because the energy gate implies a wattage (end_energy_threshold * 3600 / off_delay), whenever that implied value sits below stop_threshold_w the energy gate forbids what the power gate allows and the cycle can only close through a fallback path (smart termination, watchdog force-end) - surfacing only as an unexplained late completion, with the stock defaults already in this state for washers, dryers and especially dishwashers (implied 0.1 W vs a 2 W stop). The settings panel now flags this as a conflict (a new cross-parameter rule alongside the existing ones) with a one-tap fix, and the auto-tuner's reconcile_suggestions cascade raises end_energy_threshold to the implied floor when a coupled suggestion would otherwise leave the pair inconsistent. The two values stay independent - only the silent contradiction is now made visible. (Also corrected a stray code comment that described the 0.05 Wh default as "50 Wh".) Thanks to @kdjkdjkdj for the analysis.

  • A finished dishwasher no longer sits "running" for hours before force-stopping (#375) (manager.py): On a dishwasher whose learned program envelope has a long near-zero "drying" tail baked in (from earlier force-stopped cycles), a genuinely-finished cycle could hang in the ending phase for hours and then close as force_stopped with a badly inflated duration. Once the appliance went truly silent at the real end, the envelope alignment kept re-confirming the low-power tail and set an "auto-detected pause" lock; the early "Smart Termination" release needs the trace to map past 95% of the envelope, which was unreachable because the appliance went quiet before that learned tail ends. The lock then froze on, and every ending-phase finalize backstop (all of which deliberately respect a genuine pause) was defeated, so the cycle stayed open until the watchdog's multi-hour silence limit force-ended it (observed: ~6.7 h for a ~2 h cycle). The auto-detected pause is now released as soon as the cycle has reached its expected duration and has been continuously below the stop threshold for at least 10 minutes - the same sustained-quiet signal the ending logic already trusts - so the normal end path finalizes the cycle promptly. A real user-initiated pause is authoritative and is never released this way, and a genuinely longer program (which still has high-power phases that reset the quiet timer) is never cut short.

  • Muted auto-tune suggestions are now truly silenced across restarts (learning.py): Muting a suggestion with the 🔕 button removed it from the in-memory pending list but the backing store was only saved as part of the normal suggestion-apply flow. When every pending suggestion was muted, the apply flow was skipped entirely, so no save was triggered and the muted suggestions re-appeared from disk on the next HA restart. The pruning path now explicitly schedules a store save when it removes all suggestions, so mutes persist correctly.

  • Import payload settings are no longer silently dropped or fatal when the device has reference cycles (profile_store.py): async_import_data_selective parsed the import metadata (entry_options and other top-level fields) from the payload before looping over the device's existing reference cycles to build a dedup set, and the loop variable was named meta - the same name as the outer import metadata. After the loop meta held the cycle-level meta of the last reference cycle (which has no entry_options key), so meta.get("entry_options") returned None and the selected settings were silently discarded. When the last reference cycle had a non-dict meta field the call raised AttributeError at exactly the point after the import data had already been saved to disk, so the caller saw an error banner on a successful import. The loop variable is renamed so the outer payload metadata is never overwritten.

  • Exporting the device catalog no longer races with concurrent store mutations (ws_api.py): The get_export_inventory handler ran profile_store.get_export_inventory in a thread-pool executor, which iterated over profile_store._data (profiles, cycles, groups) from a worker thread while the event loop could be concurrently mutating the same dicts - producing RuntimeError: dictionary changed size during iteration or silently skipping/double-counting items. Because the scan is pure Python (no I/O, no GIL release), the executor wrapper provided no benefit and only introduced the race. The call now runs directly on the event loop.

  • Expanding a cycle group in the export/import wizard no longer accidentally selects all cycles in the group (panel): The expand-to-reveal-individual-cycles button () was nested inside the group's <label> element. Browser click-propagation rules cause a click on an interactive descendant of a label to also activate the label's associated checkbox, so clicking simultaneously toggled every cycle in the group. The button is moved outside the label, which now wraps only the checkbox and the group name span, so clicking the expand arrow is side-effect free.

  • Stale door-end dwell timer no longer fires during a subsequent cycle (#342 edge case) (manager.py): When an auto-open dishwasher cycle ended while a door-open dwell was already armed (the appliance opened the door during the running phase, as intended), the dwell cancel hook was not called from _on_cycle_end. If a new cycle started within the dwell window (typically 60 s), the stale dwell would fire and call user_stop() on the new cycle. The dwell is now cancelled as part of cycle-end cleanup, matching the pattern used for the watchdog and other active timers.

  • Splitting or merging cycles now always stores UTC timestamps (profile_store.py, extending #369): The #369 fix normalized cycle timestamps to UTC on write in the detector, but the split, merge, and store-cycle-import paths created new cycle records using the timezone of the source datetime (HA's local tz), so those cycles could still carry non-UTC offsets. All three paths now wrap the computed datetime with dt_util.as_utc() before calling .isoformat().

  • Power readings are processed on the event loop again, restoring matching and notifications (#363, #329 regression) (manager.py): The #363/#329 state_reported refactor extracted the power-sensor subscription into a new helper and, in the process, moved the @callback decorator off the power handler (doubling it onto the helper instead). Without @callback, Home Assistant infers the handler as an executor job and runs it in a worker thread, so every reading triggered a burst of "calls ... from a thread other than the event loop" errors and silently broke the loop-only work the handler drives - live profile matching, notifications, cycle-start/-end events, and the periodic active-cycle state save (each surfaced as a "coroutine was never awaited" warning). On Home Assistant 2026.8 these thread-safety violations became hard errors, so a busy device flooded the log on every sensor poll. The handler is @callback again (run on the loop, with the heavy NumPy matching still offloaded to an executor task), and a regression test asserts it stays that way.

  • Merging or splitting cycles no longer leaves a phantom "needs review" count (#362) (profile_store.py, ws_api.py, panel): After merging two mis-split cycles (or splitting one), the consumed cycles were removed but their pending review-feedback entries were left behind, so the attention badge kept showing "N to review" while the review list - which matches feedback against real cycles - was empty, with no way to clear it. Merge and split now prune the feedback for the cycles they remove, and the badge counts only feedback whose cycle still exists (which also self-heals any entries already stuck from before this fix). Separately, the German "cycles to review" label rendered its plural incorrectly ("Zykluss") and now reads naturally. Note: the merge action itself was never removed - it is the multi-select action in the Cycles tab (select two or more cycles, then Merge).

  • Relabeling a review cycle now clears its red dot even without pending feedback (#331 residual) (learning.py): The main #331 fix (relabeling resolves the detection-feedback request) shipped in 0.5.3, but a cycle that was in the review queue only because of a low-confidence quality label or a force_stopped/interrupted status had no pending feedback, so relabeling it left the red "needs review" dot - the user had to open the quality "Save evaluation" flow to clear it. A manual (re)label of such a cycle now also stamps it as reviewed (leaving the quality label itself intact), so the dot clears the same way. Normal cycles are untouched.

  • The "needs review" count and list no longer disagree (#355) (panel): A cycle could carry both an unresolved detection-feedback request and a completed ML quality review at the same time. The header counted it (it still has pending feedback) but the Cycles review list hid it, because the list treated any reviewed cycle as done - so the badge said "N to review" while the filtered list was empty, with no way to clear it except a manual JSON edit. Unresolved pending feedback now always keeps a cycle in the review list (and shows the feedback badge) regardless of a separate quality review, so the count and the list agree and the cycle can be resolved normally.

  • The tile card now respects an entity's Display Precision (#354) (ha-washdata-card.js): When the card's time entity pointed at a sensor carrying more decimals than wanted (or a sensor whose native unit is seconds), the card printed the raw state and always appended "min" - for example "16.2833333333 min". The card now formats the value through Home Assistant's own entity formatter, which applies the per-entity Display Precision set in the entity settings and the entity's real unit, so it shows a clean "16 min" (or the correct unit). Falls back to the previous behaviour on older Home Assistant versions that lack the formatter.

  • Live progress notifications can be sticky and open a chosen screen on tap (#347) (manager.py, const.py, panel): The Android companion app supports a persistent (sticky) notification and a tap target (clickAction), but the live-progress notification never set either, so tapping it dismissed the ongoing thread and always opened the app landing page. Two new opt-in Notification settings expose them: "Keep Live Notification On Tap" (sends sticky) and "Live Notification Tap Target" (sends clickAction, e.g. /lovelace/laundry). These are options on the existing live notification, not a new notification type, and both default off/blank so existing setups are completely unchanged. The keys are forwarded only to mobile_app_* targets.

  • The power_profile attribute interval is now configurable (#367) (sensor.py, const.py, panel): The per-profile power_profile sensor attribute - a flat per-slot average-watts array that external planners such as EMHASS and tibber_prices read to pick the cheapest window to run an appliance - was fixed to a 15-minute bucket, which blurred short sharp power spikes. A new "Power Profile Interval" setting (Timing & Watchdog, default 15 min) lets you choose the bucket size; it is passed straight through to the existing resampler and only affects the exposed attribute, never detection or matching. Existing setups keep the 15-minute default.

  • Smart Termination now leaves a diagnostic trail (#346) (cycle_detector.py, manager.py): When a cycle finished late it was impossible to tell from the logs whether Smart Termination's fast end-path was blocked (ambiguous match, low confidence, or expected duration not yet reached) or whether the envelope-based pause release simply had not reached its threshold - both were silent. Two DEBUG lines now record these decisions: the detector logs why the fast end-path did not fire (throttled to reason changes so it never spams the log), and the manager logs how far the trace has mapped toward the pause-release threshold. Both are debug-level only, with no change to detection behaviour.

  • Long low-power tails no longer make a cycle wait out the safety cap (#350) (profile_store.py): The envelope-alignment check that decides whether a low-power stretch is an expected pause fed only the power values of the current trace to its matcher, discarding each reading's timestamp. Because the profile envelope lives on a fixed time grid, the mapped position then advanced one grid step per reading regardless of how much real time each reading covered, so a sparse standby tail (for example a plug that reports every few minutes) crept forward far slower than the clock and the "near the end, release the pause" check could take hours to trip, leaving the cycle to be force-ended by the safety cap. The current trace is now resampled onto the envelope's own time step before alignment (the same linear interpolation used when the envelope is built), so the mapped position tracks elapsed time. This also prevents the opposite error, where a densely sampled early part of a cycle mapped too far ahead. Works together with #348.

  • Smart Termination could hang a finished cycle until the safety cap (#348) (manager.py, profile_store.py): When a cycle sat in a verified low-power pause near its end, WashData decided whether to release the pause and finalize by comparing how far the trace had mapped into the profile against the profile's average duration (mapped_time / avg_duration > 0.95). But the mapped position is capped at the envelope's own time-grid span, while the average duration is a separately computed outlier-trimmed mean, so the highest ratio actually reachable was span / avg_duration. On any profile whose average ran more than a few percent longer than its envelope span (which force-ended cycles make progressively worse, since their standby overhang inflates the mean), the 0.95 threshold became arithmetically impossible to reach and the cycle hung until the multi-hour deferral cap force-ended it. The release now divides the mapped position by the envelope's own span, so the fraction is a true 0..1 value that reaches the threshold at the actual end of the cycle.

  • The panel no longer traps you when the sidebar is set to always-hide (#359) (panel): On a wide tablet with the companion app's "Always hide sidebar" option, opening WashData replaced the whole Home Assistant header - including the hamburger that reopens the sidebar - while the panel's own sidebar-toggle button only appeared on narrow (below 870px) screens, so there was no way back to the rest of Home Assistant without restarting the app. The panel's hamburger now also appears whenever the sidebar is set to always-hidden, at any width, so the sidebar can always be reopened.

  • Trimming a cycle can no longer destroy it (#366) (profile_store.py, panel): Setting the End time in the Trim tab's clock ("Uhrzeit") mode could collapse a cycle's duration and energy to zero, with no way to recover the original data - and each further trim then operated on the already-emptied cycle. Two problems combined: the backend overwrote the cycle's power trace in place with no lower bound, so a window that kept a single sample (or an inverted end-before-start window) produced a 0-second cycle; and the panel's clock-to-offset conversion treated an empty or unparseable time field as offset 0, dragging an endpoint to a degenerate position (made worse by the mixed timestamp offsets fixed in #369). The trim backend now refuses any trim whose kept window has fewer than two samples or a non-positive duration, leaving the stored cycle completely untouched and reporting a failure instead of destroying data. In the panel, an empty or unparseable time field is now treated as "no change" rather than collapsing the window.

  • Trim now lands on the sample you aimed at, and an early clock entry can't collapse a cycle (#373) (profile_store.py, panel): A follow-up to #366 closing three remaining gaps on the same trim path. (1) Whole-second inputs missed fractional samples. Trim boundaries were rounded to whole seconds, but sample offsets are usually fractional (a nominal 10 s cadence drifts, so an offset like 3132.3 is the norm), and the kept-window filter is inclusive - so a whole-second End landed just below the sample you clicked and quietly cut one sample short (about one in four possible trim targets was affected). Both the stored trace and the on-screen handles now snap to real sample offsets: the store snaps against the full-resolution trace immediately before the irreversible write, and the panel snaps the drag handles and number fields on release/commit so the shaded window matches what will be kept. (2) A clock time before the cycle's start no longer wraps to the end. In clock ("Uhrzeit") mode, entering a Start earlier than the cycle's actual start produced a negative offset that the past-midnight correction pushed forward a full day and clamped to the far end, collapsing the window to about one second; the +24 h correction is now applied only when the shifted time still lands inside the cycle, so an early Start simply means "keep everything from the start" (a genuine past-midnight time still shifts correctly). (3) A destructive trim now asks first. Because a trim cannot be undone, applying one that would keep less than half the cycle shows a confirmation. The backend guard from #366 (refuse any window that keeps fewer than two samples) still stands, so an accidental collapse is reported as a failure instead of silently destroying data.

  • Stored cycle timestamps are now consistently UTC (#369) (cycle_detector.py): Cycle start and end times were saved in whatever timezone the source datetime carried - readings taken from the power sensor produced HA-local offsets (e.g. +02:00) while trim and split operations already wrote UTC (+00:00), so the cycle history accumulated a mix of offsets, and a cycle straddling a restart could even carry different offsets for its own start and end. Every stored instant was still correct (nothing ever displayed the wrong moment), but the inconsistency is a hazard for cross-device transfer and the community store. Cycle timestamps are now normalized to canonical UTC on write, so start and end always carry a +00:00 offset, with the actual instant preserved. Existing history is left untouched - older cycles keep their original offset and continue to parse and display correctly.

  • Anti-wrinkle no longer stays stuck for hours when the power sensor goes silent (#339) (manager.py): On a dryer or washer with anti-wrinkle mode and a publish-on-change plug (Shelly, many Zigbee devices), the meter sends one final 0 W reading after the last tumble pulse and then stops reporting entirely. WashData's anti-wrinkle idle-timeout and 2-hour safety cap only advance while power readings arrive, and the stuck-cycle watchdog is switched off for the whole anti-wrinkle tail, so with no further events the device was pinned in the anti-wrinkle state for hours - until the next cycle started. The 60-second state-expiry timer, which keeps running through the tail, now injects a synthetic 0 W reading once the real sensor has been silent for longer than the off delay, so the detector's own idle/safety-cap logic ends the mode on time. A genuine tumble pulse still resets the idle timer through the normal path, and the synthetic reading is never counted as a real sensor update, so a plug that is still reporting continues to drive the mode itself. Thanks to @sharkyy for contribution.

  • Power sensors that re-report the same value are no longer missed (#363, #329) (manager.py): WashData only subscribed to Home Assistant state_changed events, which fire only when a sensor's value actually changes. Many smart plugs periodically re-publish the same reading - Tasmota's TelePeriod, or a Zigbee device's maximum reporting interval - and those unchanged re-reports fire state_reported instead, so they were invisible to WashData. The consequence: while a finished appliance sat at a flat standby draw no readings arrived, so the detector's end-of-cycle timer never advanced. A cycle then finished late - by up to the plug's full reporting interval (the ~15 min Zigbee lag in #329) - or a dishwasher idling at ~1 W never completed at all (#363). WashData now also subscribes to state_reported for the configured power sensor and routes it through the same handler. Home Assistant fires exactly one of the two events per write (changed vs unchanged), so there is no double counting, and the reading still passes the existing sampling-interval throttle and low-power bypass, so a chatty plug cannot flood the detector. The diagnostic trace now timestamps each reading by the sensor's last_reported time (which advances on every re-report) rather than last_updated (which does not), so the recorded curve no longer flat-lines with a stale timestamp during a standby tail.

  • Two more blocking startup calls removed (#328, #335): 0.5.3 offloaded the frontend.py directory scan to the executor, but a second blocking read remained: ws_api.py parsed manifest.json at module level, and because that module is imported lazily inside async_setup_entry the file read ran on the event loop every first restart. The version is now fetched once from HA's already-loaded integration manifest (cached, no file I/O) during setup and stored in hass.data, so ws_get_constants reads it from there without ever touching the filesystem.

  • Notifications to notify.* entity targets no longer crash with "extra keys not allowed" (manager.py): When a notify target was configured as a notify.* entity (e.g. notify.mobile_app_phone), WashData routed the call through HA's newer notify.send_message entity service, which only accepts message, title, and entity_id. Mobile-app enrichment keys (tag, channel, priority, iOS Live Activity fields, icon, progress) were still bundled into a data sub-dict and appended to that call, causing a voluptuous schema rejection (extra keys not allowed @ data['data']) and a "Task exception was never retrieved" error in the HA log -- meaning the notification was silently dropped. A notify.* entity only ever registers the notify.send_message entity service, so entity targets are now always routed there and the unsupported enrichment keys are dropped (logged at debug) instead of poisoning the call; legacy notify.mobile_app_* services, which use NOTIFY_SERVICE_SCHEMA and accept data: dict, still receive every enrichment key. Previously the routing fell back to a legacy notify.<object_id> service whenever enrichment was present -- but that service does not exist for entity targets, so simply setting a notification icon made every notification fail with ServiceNotFound.

  • A telemetry dropout no longer counts as observed quiet time (cycle_detector.py, manager.py): The end-of-cycle logic that waits for a machine to fall silent tracked "how long have we been below the stop threshold" by simply adding up the time between readings. If the smart plug dropped off the network for half an hour, that entire dark period was banked as quiet time the integration never actually observed -- so a dishwasher could satisfy its passive-dry quiet-release (and a frozen auto-detected pause could be released) purely because the sensor stopped reporting. A parallel gap-free tally now resets whenever the gap between two readings exceeds the device's own outage threshold (ten times its typical reporting interval, clamped to 1-60 minutes), and the quiet-release gates read that instead. The main end-of-cycle timer is unchanged, so soak-bridging behaviour is identical.

  • Trimming a cycle no longer keeps data outside the window you selected (profile_store.py): Trim boundaries are snapped to real sample offsets so a whole-second entry cannot land just short of the fractional sample you aimed at. That snap picked the nearest sample in either direction, so on a coarsely-sampled trace (a dishwasher reporting once a minute, say) a boundary could move outward by up to half a reporting interval -- keeping samples you had asked to remove while the recorded trim range claimed the wider window. Boundaries now snap inward, tolerating only the one-second rounding of the input itself, and a window that ends up containing fewer than two samples is rejected outright rather than silently expanded.

  • The auto-tuner's end-energy floor is no longer lost to rounding (suggestion_engine.py): When a suggested stop threshold and off-delay imply an end-energy floor that is not representable at the two decimal places suggestions are stored with (2 W over 60 s implies 0.0333 Wh), the value was rounded to the nearest -- 0.03 Wh, just below the floor. The consistency pass then found the value already set, made no further change, and returned a suggestion set that still contradicted itself in exactly the way the #376 rule exists to prevent. The floor now rounds up.

  • Anti-wrinkle can no longer stay stuck after a Home Assistant restart (#339 edge case) (manager.py): The keepalive that rescues a silent anti-wrinkle tail needs to know when the power sensor last reported, and that marker is only ever set by a live reading. Restarting Home Assistant while a machine sat in the anti-wrinkle tail with an already-silent plug therefore left it unset, so the keepalive could never fire and the mode stayed pinned until the next cycle -- the exact situation #339 was meant to fix. The restore path now anchors it to the saved state's timestamp.

  • Auto-tune suggestion passes read their settings safely (suggestion_engine.py, learning.py, ws_api.py): The suggestion generators run on a background thread but read the device's configuration directly, which is only safe to touch from Home Assistant's event loop, and the read merged two separate config mappings that could be swapped out mid-read. Configuration is now captured on the event loop immediately before each background pass, so every generator in a pass sees one consistent snapshot.

  • A mid-length sensor dropout is no longer credited as quiet time (cycle_detector.py): The gap-free quiet tally added above decides whether a reading gap is a real outage by comparing it against ten times the sensor's typical reporting interval. That interval was being updated with the current gap before the comparison, so a dropout inflated the very threshold meant to catch it - a two-minute dropout on a ten-second cadence lifted the threshold to about 155 s and slipped through as observed quiet. Very long outages were still caught, so the practical effect was limited to gaps roughly 10-25x the normal cadence. The threshold is now taken from the cadence as it stood before the gap. This also fixes a related blind spot: in the first few readings after a reset the threshold was derived from the current gap itself, which no gap can ever exceed, so no outage was detectable at all in that window.

  • A restored cycle no longer inherits quiet time it never observed (cycle_detector.py): When Home Assistant restarts mid-cycle, the saved state is restored. Snapshots written before this release carry only the older quiet-time counter, which can itself include outage intervals, and that value was being used to seed the new gap-free counter - so a dishwasher could release its end-of-cycle drain wait immediately after a restart without ever observing the configured quiet period. The gap-free counter now starts from zero on restore, which is also the honest value in general: the restart gap is itself unobserved time. Snapshots written by this release and later carry the counter through a restart unchanged.

  • Playground simulations now honour the dishwasher drain-wait setting (ws_api.py): The Playground built its simulated detector without passing the per-device Passive-Dry Quiet Release value, so a replay always used the shipped 600 s default unless you explicitly overrode it in the Playground itself. Simulations of a machine with a tuned value therefore did not match its live behaviour.

  • "Clear all data" now also clears saved Playground presets (profile_store.py): Playground presets survived a full data wipe, leaving watt- and second-scale values from the removed configuration behind.

  • Dismissing a notification no longer shows a stray "clear_notification" message (manager.py): WashData dismisses its own notification cards (the live-progress card, the clean-laundry reminder, the pause reminder) by re-sending them with a special marker body that the companion app understands as "remove the card with this tag". When notify entity targets were switched to Home Assistant's notify.send_message service earlier in this release, that service turned out to be unable to carry the tag - so instead of dismissing anything, the marker was delivered to the user as a visible notification whose text read clear_notification. Entity targets now skip the dismiss send entirely (there is no way to dismiss such a card), and the marker can never fall through to a persistent-notification card either. Regular notifications to entity targets are unaffected.

  • A Playground preset with a very long name can be deleted again (profile_store.py): Saving a preset trimmed its name to the 60-character limit, but deleting one looked the name up untrimmed. A preset saved under a longer name was therefore stored under a shortened key that the delete never matched, so it could not be removed and permanently occupied one of the 30 preset slots. Both paths now derive the storage key the same way, and a name whose trim lands on a space no longer keeps a trailing space in the key.

  • The repeating unload reminder now stops after a safety limit (#374) (manager.py, const.py): The opt-in "Repeat Until Door Opens" reminder had no upper bound. Its "Stop reminding" button only exists on Home Assistant companion-app targets, so anyone whose only notification target is a different platform had the door sensor as their sole way to end it - and if that sensor never reported the door opening (offline, or nobody home for a while), the reminder re-fired forever and held the appliance in the Clean state indefinitely, which also suppressed power-based Off detection for as long as it lasted. The reminder now stops after 48 repeats, roughly two days at the default one-hour spacing and far beyond any useful reminder window, after which the appliance returns to Off normally. Opening the door or tapping "Stop reminding" still ends it immediately as before, and the counter resets for each new cycle.

  • The integration version is populated even when HA's manifest loader fails (__init__.py): ws_get_constants reads the integration version from hass.data; if the async_get_integration call that populates it during setup fails, the field was left absent and the panel received an empty version string. The exception path now falls back to reading manifest.json off the event loop via the executor and only stores the result when the read succeeds, so a transient loader failure no longer leaves the version empty. Normal startup is unchanged; if both reads fail (practically impossible for a packaged integration), the version is still reported as empty.

  • pg_match_defaults is now declared in the WS response contract (ws_schema.py, www/ws-types.d.ts): The get_constants handler added pg_match_defaults to its response payload but the field was missing from GetConstantsResponse in both the Python TypedDict and the TypeScript interface, so the contract validator flagged it as an undeclared field and typed consumers had no definition to rely on. Both schemas now include the field.

  • The issue-validator log-evidence guidance states the 50-character minimum explicitly (.github/workflows/issue_validator.yml): The validator rejected log sections of 50 characters or fewer, but the comment posted to the reporter only said "a few lines of error text are enough" - a reporter who pasted a single short error string would be rejected without understanding why. The guidance now says "more than 50 characters" so the requirement is explicit.