Commit Graph
1 Commits
Author SHA1 Message Date
Marc Billow ef66d1db71 washer/dryer: hold progress/progress_percentage at Finish/100 for 5 min
Fixes #345. progress and progress_percentage were gated on machine_state
alone, falling straight to "Idle"/0 the instant state left 'active' --
but a washer can flip state away from 'active' within the same poll
interval progress reaches 'Finish' (more reliably than the same-family
dryer, per the report), so an automation watching for a real 'Finish'
value could go a whole cycle without ever observing one.

Implemented read-side, per-entity (sensor.py's new _apply_sticky),
generalizing the existing _hysteresis_value/_apply_hysteresis pattern
finish_time already uses, rather than writing a synthetic override back
into the coordinator's cache. That cache is this integration's record of
what the device actually said, and is read by several unrelated
consumers -- write_fn, validate_fn, diagnostics, the observe-mode sweep
comparison against /device/0, _completion_minutes' own stale-remainingTime
workaround -- all of which would otherwise see fabricated state.

A first pass gated the hold's arm condition on state=='active' AND
progress=='Finish' occurring in the same rep, mirroring _is_active. A
second review caught that this could make the whole fix a no-op on the
one device #345 reports it for: if state has already reset by the time
progress is ever observed at 'Finish' -- exactly what #345 describes --
the arm condition never fires. _just_finished now arms on progress==
'Finish' alone. That reopens the staleness risk the state check existed
to guard against (a progress field stuck at 'Finish' forever would then
arm forever too), so _apply_sticky is edge-triggered: only a fresh
False->True transition (re)starts the window, and expiry is still
checked on every call even while the condition keeps matching -- a
stuck value still won't hold past sticky_seconds.

Dropping the state requirement also exposed a second gap: the bypass
that lets a new cycle's own real progress override a stale hold was
keyed on machine_state=='active', so it missed a new cycle immediately
paused (e.g. adding a sock) -- machine_state isn't 'active' while
paused. It's keyed on a live, non-Finish progress code instead
(_live_progress_code), independent of state, same reasoning as
_just_finished. And since the bypass needs the *real* live value, not
whatever rep_fn's own (differently gated) result says, SensorDesc grew
sticky_live_fn alongside sticky_value_fn: rep_fn's progress gate still
shows "Idle" while paused, but the real progress value read ungated
must win over the hold regardless.

registry/entities.py: SensorDesc gains sticky_fn/sticky_value_fn/
sticky_live_fn/sticky_bypass_fn/sticky_seconds -- see sensor.py's
_apply_sticky docstring for the full contract.

registry/capabilities/operational.py: progress/progress_percentage's
rep_fn is unchanged; they gain the sticky_* wiring above. machine_state
and the Running binary sensor are untouched -- still gated on real-time
state (cycle_active now shares _is_active's rep_fn directly rather than
a duplicate inline copy), so they never claim the appliance is still
running once it isn't.
2026-08-09 18:21:26 +00:00