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.
This commit is contained in:
Marc Billow committed 2026-08-09 18:21:26 +00:00
1 parent 16cb01ce5d
commit ef66d1db71
5 files changed
+458 -11

No files matched your search

@@ -35,12 +35,44 @@ def _int(v):
return None
def _state_is_active(rep):
return _SAMSUNG_STATE_TO_OCF.get(rep.get("x.com.samsung.da.state")) == "active"
def _is_active(rep):
"""Check if appliance is actively running and cycle is not finished."""
return (
_SAMSUNG_STATE_TO_OCF.get(rep.get("x.com.samsung.da.state")) == "active"
and rep.get("x.com.samsung.da.progress") != "Finish"
)
return _state_is_active(rep) and rep.get("x.com.samsung.da.progress") != "Finish"
def _just_finished(rep):
"""progress/progress_percentage's sticky_fn (issue #345, see sensor.py's
_apply_sticky): arms their grace window the moment `progress` reads
'Finish'.
Deliberately not also requiring `state == 'active'` in the same rep,
unlike _is_active above: #345 reports a washer whose `state` can
already read idle by the time `progress` is observed at 'Finish' --
the same-family dryer's firmware apparently keeps `state` at 'active'
longer, per the report -- so requiring both together risked the arm
condition never actually firing on the one device this fixes.
_apply_sticky's edge-triggering (only a fresh False->True transition
(re)arms) is what keeps a `progress` stuck at 'Finish' indefinitely
(the same class of quirk `_completion_minutes` below already works
around) from holding this open forever instead."""
return rep.get("x.com.samsung.da.progress") == "Finish"
def _live_progress_code(rep):
"""progress/progress_percentage's sticky_bypass_fn: a concrete,
non-Finish progress code being reported right now -- e.g. a new
cycle's own real 'Wash'/'Spin' -- must win over a still-open hold from
the previous cycle immediately. Not keyed on `state` (unlike
_is_active): _just_finished's whole premise is that `state` can't be
trusted to still say 'active' while a fresh, real progress value is
already there, and the same applies to recognizing when it's moved on
to a new one -- including while paused, e.g. adding a sock mid-hold."""
v = rep.get("x.com.samsung.da.progress")
return v is not None and v not in ("None", "Finish")
def _remaining_seconds(raw):
@@ -153,19 +185,31 @@ OPERATIONAL_STATE = Capability(
BinarySensorDesc(
key="cycle_active",
device_class="running",
rep_fn=lambda rep: (
_SAMSUNG_STATE_TO_OCF.get(rep.get("x.com.samsung.da.state")) == "active"
and rep.get("x.com.samsung.da.progress") != "Finish"
),
rep_fn=_is_active,
),
# sticky_* (issue #345): once progress reads 'Finish', keep
# showing Finish/100 for a grace window even after machine_state
# reverts, rather than falling to Idle/0 the instant it does --
# see sensor.py's _apply_sticky. rep_fn below is otherwise
# unchanged; the hold is entirely a read-side, per-entity concern,
# deliberately not gated on machine_state (_just_finished's
# docstring explains why). sticky_live_fn reads the raw field the
# same ungated way, for sticky_bypass_fn's benefit: a real
# progress value reported while paused (e.g. adding a sock
# mid-cycle) must win over a stale hold even though rep_fn itself
# would show "Idle"/0 there.
SensorDesc(
key="progress",
icon="mdi:progress-wrench",
rep_fn=lambda rep: (
"Idle"
if _SAMSUNG_STATE_TO_OCF.get(rep.get("x.com.samsung.da.state")) != "active"
if not _state_is_active(rep)
else _progress(rep.get("x.com.samsung.da.progress"))
),
sticky_fn=_just_finished,
sticky_value_fn=lambda rep: "Finish",
sticky_live_fn=lambda rep: _progress(rep.get("x.com.samsung.da.progress")),
sticky_bypass_fn=_live_progress_code,
),
SensorDesc(
key="progress_percentage",
@@ -173,9 +217,13 @@ OPERATIONAL_STATE = Capability(
state_class="measurement",
rep_fn=lambda rep: (
0
if _SAMSUNG_STATE_TO_OCF.get(rep.get("x.com.samsung.da.state")) != "active"
if not _state_is_active(rep)
else _int(rep.get("x.com.samsung.da.progressPercentage"))
),
sticky_fn=_just_finished,
sticky_value_fn=lambda rep: 100,
sticky_live_fn=lambda rep: _int(rep.get("x.com.samsung.da.progressPercentage")) or 0,
sticky_bypass_fn=_live_progress_code,
),
# Only show finish time while actively running -- firmware leaves a
# stale remainingTime after a cycle ends, frozen at '00:01:00'.
@@ -64,6 +64,18 @@ class SensorDesc(SamsungEntityDescription):
# (see sensor.py). Only for values expected to jitter between
# device-side revisions -- not a general-purpose flag.
hysteresis: bool = False
# Opt-in, entity-instance-only hold -- see sensor.py's _apply_sticky
# for the full contract (arm/value/live/bypass semantics,
# edge-triggering, why this never touches the coordinator cache).
# sticky_fn arms it; sticky_value_fn picks what to freeze at that
# moment (defaults to rep_fn's own result); sticky_bypass_fn forces
# sticky_live_fn's result through and drops the hold; sticky_seconds
# bounds how long it can hold.
sticky_fn: Callable[[dict], bool] | None = None
sticky_value_fn: Callable[[dict], Any] | None = None
sticky_live_fn: Callable[[dict], Any] | None = None
sticky_bypass_fn: Callable[[dict], bool] | None = None
sticky_seconds: float = 300.0
@dataclass(frozen=True, kw_only=True)
+62
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import time
from datetime import timedelta
from typing import cast
@@ -51,6 +52,9 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity):
if desc.options:
self._attr_options = list(desc.options)
self._hysteresis_value = None
self._sticky_value = None
self._sticky_until: float | None = None
self._sticky_armed = False
@property
def native_unit_of_measurement(self):
@@ -63,10 +67,68 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity):
def native_value(self):
raw = (self.coordinator.data or {}).get(self._state_key)
desc = cast(SensorDesc, self._bound.desc)
if desc.sticky_fn is not None:
raw = self._apply_sticky(raw, desc)
if not desc.hysteresis:
return raw
return self._apply_hysteresis(raw)
def _apply_sticky(self, raw, desc: SensorDesc):
"""Freeze this entity at a value for up to `desc.sticky_seconds`
after `desc.sticky_fn` next stops matching this href's live rep
(issue #345 -- see operational.py's `_just_finished` for the
motivating case). Entity-instance state only, exactly like
`_hysteresis_value` above -- never written back to the coordinator
cache, so write_fn, diagnostics, and the observe-mode sweep
comparison keep seeing real device data throughout.
Reads `sticky_fn` and friends against this href's own live rep,
not the already-computed `raw`, so they can be independent of
whatever rep_fn itself gates on -- notably `sticky_live_fn`, which
is *not* `raw`: `raw` is rep_fn's own (possibly differently gated)
result, e.g. progress's rep_fn shows "Idle" while paused, but a
real progress value reported while paused (adding a sock mid-
cycle) must still win over a stale hold, which means reading it
ungated here rather than through that gate.
Edge-triggered, not level-triggered: the window only (re)starts on
a fresh False->True transition of `sticky_fn`, and -- this is the
part level-triggering alone misses -- expiry is still checked on
every call even while `sticky_fn` keeps matching. Without the
latter, a firmware that leaves the underlying field stuck matching
forever (the same class of quirk `_completion_minutes` already
works around) would show the frozen value forever too, defeating
"bounded, not indefinite".
`sticky_bypass_fn`, when it matches, always passes
`sticky_live_fn`'s result through and drops any hold -- for a
condition where "not sticky right now" is ambiguous between "went
idle, honor the hold" and "genuinely live, different data" (e.g. a
new cycle's own real progress), which "consult the hold whenever
sticky_fn is False" alone can't tell apart.
"""
assert desc.sticky_fn is not None # native_value only calls this when set
rep = self.coordinator.resource(self._bound.href)
now = time.monotonic()
matches = desc.sticky_fn(rep)
if matches:
if not self._sticky_armed:
self._sticky_value = (
desc.sticky_value_fn(rep) if desc.sticky_value_fn is not None else raw
)
self._sticky_until = now + desc.sticky_seconds
self._sticky_armed = True
else:
self._sticky_armed = False
if desc.sticky_bypass_fn is not None and desc.sticky_bypass_fn(rep):
self._sticky_until = None
return desc.sticky_live_fn(rep) if desc.sticky_live_fn is not None else raw
if self._sticky_until is not None and now < self._sticky_until:
return self._sticky_value
return raw
def _apply_hysteresis(self, raw):
"""Hold the last value this entity actually reported until a new one
differs by at least the configured threshold, regardless of how long