Merge pull request #359 from mbillow/claude/pr-346-regression-debug-a3x3pj

laundry: a post-Finish running stage is the cycle ending, not a new one
This commit is contained in:
Marc Billow
2026-08-12 10:43:47 -04:00
committed by GitHub
5 changed files with 211 additions and 85 deletions
@@ -62,17 +62,21 @@ def _just_finished(rep):
return rep.get("x.com.samsung.da.progress") == "Finish" return rep.get("x.com.samsung.da.progress") == "Finish"
def _live_progress_code(rep): def _new_cycle_running(rep):
"""progress/progress_percentage's sticky_bypass_fn: a concrete, """progress/progress_percentage's sticky_bypass_fn: drop the #345 hold
non-Finish progress code being reported right now -- e.g. a new early once a new cycle is genuinely running.
cycle's own real 'Wash'/'Spin' -- must win over a still-open hold from
the previous cycle immediately. Not keyed on `state` (unlike Gated on `state == 'active'`, unlike _just_finished's arm condition
_is_active): _just_finished's whole premise is that `state` can't be above: issue #358's dryer resets `progress` to its course's first
trusted to still say 'active' while a fresh, real progress value is stage ('Drying') in the same moment `state` goes idle, ~4s before
already there, and the same applies to recognizing when it's moved on settling to 'None' -- confirmed by the reporter's machine_state
to a new one -- including while paused, e.g. adding a sock mid-hold.""" history, which flips to idle on the exact second progress reads
'Drying', in both captured cycles. A bypass keyed on the progress
code alone read that as a new cycle and republished it. Releasing
late costs nothing -- an unreleased hold still expires on its own --
so this side takes the stronger signal."""
v = rep.get("x.com.samsung.da.progress") v = rep.get("x.com.samsung.da.progress")
return v is not None and v not in ("None", "Finish") return _state_is_active(rep) and v is not None and v not in ("None", "Finish")
def _remaining_seconds(raw): def _remaining_seconds(raw):
@@ -190,14 +194,10 @@ OPERATIONAL_STATE = Capability(
# sticky_* (issue #345): once progress reads 'Finish', keep # sticky_* (issue #345): once progress reads 'Finish', keep
# showing Finish/100 for a grace window even after machine_state # showing Finish/100 for a grace window even after machine_state
# reverts, rather than falling to Idle/0 the instant it does -- # reverts, rather than falling to Idle/0 the instant it does --
# see sensor.py's _apply_sticky. rep_fn below is otherwise # see sensor.py's _apply_sticky. rep_fn below is unchanged and
# unchanged; the hold is entirely a read-side, per-entity concern, # stays the only definition of a live value -- the hold decides
# deliberately not gated on machine_state (_just_finished's # only *whether* to freeze. A second, ungated one here is what
# docstring explains why). sticky_live_fn reads the raw field the # let issue #358's post-Finish tail reach the entity.
# 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( SensorDesc(
key="progress", key="progress",
icon="mdi:progress-wrench", icon="mdi:progress-wrench",
@@ -208,8 +208,7 @@ OPERATIONAL_STATE = Capability(
), ),
sticky_fn=_just_finished, sticky_fn=_just_finished,
sticky_value_fn=lambda rep: "Finish", sticky_value_fn=lambda rep: "Finish",
sticky_live_fn=lambda rep: _progress(rep.get("x.com.samsung.da.progress")), sticky_bypass_fn=_new_cycle_running,
sticky_bypass_fn=_live_progress_code,
), ),
SensorDesc( SensorDesc(
key="progress_percentage", key="progress_percentage",
@@ -222,8 +221,7 @@ OPERATIONAL_STATE = Capability(
), ),
sticky_fn=_just_finished, sticky_fn=_just_finished,
sticky_value_fn=lambda rep: 100, sticky_value_fn=lambda rep: 100,
sticky_live_fn=lambda rep: _int(rep.get("x.com.samsung.da.progressPercentage")) or 0, sticky_bypass_fn=_new_cycle_running,
sticky_bypass_fn=_live_progress_code,
), ),
# Only show finish time while actively running -- firmware leaves a # Only show finish time while actively running -- firmware leaves a
# stale remainingTime after a cycle ends, frozen at '00:01:00'. # stale remainingTime after a cycle ends, frozen at '00:01:00'.
@@ -65,15 +65,15 @@ class SensorDesc(SamsungEntityDescription):
# device-side revisions -- not a general-purpose flag. # device-side revisions -- not a general-purpose flag.
hysteresis: bool = False hysteresis: bool = False
# Opt-in, entity-instance-only hold -- see sensor.py's _apply_sticky # Opt-in, entity-instance-only hold -- see sensor.py's _apply_sticky
# for the full contract (arm/value/live/bypass semantics, # for the full contract (arm/value/bypass semantics, one window per
# edge-triggering, why this never touches the coordinator cache). # bypass, why this never touches the coordinator cache).
# sticky_fn arms it; sticky_value_fn picks what to freeze at that # 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 # moment (defaults to rep_fn's own result); sticky_bypass_fn drops the
# sticky_live_fn's result through and drops the hold; sticky_seconds # hold and lets rep_fn's own live result through; sticky_seconds
# bounds how long it can hold. # bounds how long it can hold. There is deliberately no hook for
# computing a live value differently from rep_fn -- see issue #358.
sticky_fn: Callable[[dict], bool] | None = None sticky_fn: Callable[[dict], bool] | None = None
sticky_value_fn: Callable[[dict], Any] | 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_bypass_fn: Callable[[dict], bool] | None = None
sticky_seconds: float = 300.0 sticky_seconds: float = 300.0
+32 -35
View File
@@ -54,7 +54,7 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity):
self._hysteresis_value = None self._hysteresis_value = None
self._sticky_value = None self._sticky_value = None
self._sticky_until: float | None = None self._sticky_until: float | None = None
self._sticky_armed = False self._sticky_spent = False
@property @property
def native_unit_of_measurement(self): def native_unit_of_measurement(self):
@@ -82,52 +82,49 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity):
cache, so write_fn, diagnostics, and the observe-mode sweep cache, so write_fn, diagnostics, and the observe-mode sweep
comparison keep seeing real device data throughout. comparison keep seeing real device data throughout.
Reads `sticky_fn` and friends against this href's own live rep, `sticky_fn`/`sticky_bypass_fn` read this href's live rep rather
not the already-computed `raw`, so they can be independent of than the already-computed `raw`, so they can key on fields rep_fn
whatever rep_fn itself gates on -- notably `sticky_live_fn`, which has collapsed away -- but they never compute a value. `raw` and
is *not* `raw`: `raw` is rep_fn's own (possibly differently gated) the frozen `sticky_value` are the only things returned here, so a
result, e.g. progress's rep_fn shows "Idle" while paused, but a held entity and a free-running one agree on what "live" means; a
real progress value reported while paused (adding a sock mid- hook that broke that rule caused issue #358.
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 At most one window per `sticky_bypass_fn` cycle: arming marks the
a fresh False->True transition of `sticky_fn`, and -- this is the hold spent, and only the bypass clears that. So a `sticky_fn` that
part level-triggering alone misses -- expiry is still checked on keeps matching (firmware leaving the field stuck -- the quirk
every call even while `sticky_fn` keeps matching. Without the `_completion_minutes` works around) can't extend the window, and
latter, a firmware that leaves the underlying field stuck matching one flapping in and out can't restart it either, before or after
forever (the same class of quirk `_completion_minutes` already expiry. Expiry alone doesn't re-open the door: without something
works around) would show the frozen value forever too, defeating the calibre of "a new cycle is actually running" in between, a
"bounded, not indefinite". second Finish is the same Finish, and re-arming on it would strobe
the entity between held and live once per window -- exactly the
repeated announcements #345 and #358 are about.
`sticky_bypass_fn`, when it matches, always passes `sticky_bypass_fn` drops the hold and returns `raw`, for when "not
`sticky_live_fn`'s result through and drops any hold -- for a sticky right now" is ambiguous between "went idle, honor the hold"
condition where "not sticky right now" is ambiguous between "went and "genuinely moved on to new data". It is both the early release
idle, honor the hold" and "genuinely live, different data" (e.g. a and the only re-arm, so it should demand positive evidence of that
new cycle's own real progress), which "consult the hold whenever move; when unsure, letting the window run out is the cheaper
sticky_fn is False" alone can't tell apart. mistake.
""" """
assert desc.sticky_fn is not None # native_value only calls this when set assert desc.sticky_fn is not None # native_value only calls this when set
rep = self.coordinator.resource(self._bound.href) rep = self.coordinator.resource(self._bound.href)
now = time.monotonic() now = time.monotonic()
matches = desc.sticky_fn(rep)
if matches: if desc.sticky_fn(rep):
if not self._sticky_armed: if not self._sticky_spent:
self._sticky_value = ( self._sticky_value = (
desc.sticky_value_fn(rep) if desc.sticky_value_fn is not None else raw desc.sticky_value_fn(rep) if desc.sticky_value_fn is not None else raw
) )
self._sticky_until = now + desc.sticky_seconds self._sticky_until = now + desc.sticky_seconds
self._sticky_armed = True self._sticky_spent = True
else: elif desc.sticky_bypass_fn is not None and desc.sticky_bypass_fn(rep):
self._sticky_armed = False self._sticky_until = None
if desc.sticky_bypass_fn is not None and desc.sticky_bypass_fn(rep): self._sticky_spent = False
self._sticky_until = None return raw
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: holding = self._sticky_until is not None and now < self._sticky_until
return self._sticky_value return self._sticky_value if holding else raw
return raw
def _apply_hysteresis(self, raw): def _apply_hysteresis(self, raw):
"""Hold the last value this entity actually reported until a new one """Hold the last value this entity actually reported until a new one
+33 -14
View File
@@ -3,7 +3,7 @@
from custom_components.localthings.registry.capabilities.operational import ( from custom_components.localthings.registry.capabilities.operational import (
OPERATIONAL_STATE, OPERATIONAL_STATE,
_just_finished, _just_finished,
_live_progress_code, _new_cycle_running,
) )
from custom_components.localthings.registry.entities import NumberDesc from custom_components.localthings.registry.entities import NumberDesc
@@ -41,27 +41,46 @@ class TestJustFinished:
assert not _just_finished({"x.com.samsung.da.state": "Run"}) assert not _just_finished({"x.com.samsung.da.state": "Run"})
class TestLiveProgressCode: class TestNewCycleRunning:
"""`_live_progress_code` is progress/progress_percentage's """`_new_cycle_running` is progress/progress_percentage's
sticky_bypass_fn (issue #345) -- see sensor.py's _apply_sticky.""" sticky_bypass_fn -- the early-release condition for the #345 hold.
See sensor.py's _apply_sticky."""
def test_true_for_a_concrete_non_finish_code(self): def test_true_for_a_concrete_non_finish_code_while_active(self):
assert _live_progress_code({"x.com.samsung.da.progress": "Wash"}) assert _new_cycle_running(
{"x.com.samsung.da.state": "Run", "x.com.samsung.da.progress": "Wash"}
)
def test_true_regardless_of_state(self): def test_false_for_a_running_stage_reported_after_state_left_active(self):
"""Not gated on machine_state -- a new cycle's own real progress """Issue #358, the whole reason for the `state` gate: the reporting
must win over a held hold even while paused (e.g. adding a sock dryer replays a running stage ('Drying', its first supportedProgress
mid-hold), not just while actively running.""" entry) for a few seconds after Finish while winding down. That is
assert _live_progress_code( the finished cycle's tail, not a new cycle -- releasing the hold on
it is what produced 'Drying, Cooling, Finish, Drying, Idle'."""
assert not _new_cycle_running(
{"x.com.samsung.da.state": "Ready", "x.com.samsung.da.progress": "Drying"}
)
def test_false_while_paused(self):
"""Paused is not evidence a new cycle is running, and the tail
above can't be told apart from it. rep_fn shows 'Idle' whenever
state isn't active anyway, so there is no live value being
withheld here -- only a hold that expires on its own instead of
being released early."""
assert not _new_cycle_running(
{"x.com.samsung.da.state": "Pause", "x.com.samsung.da.progress": "Wash"} {"x.com.samsung.da.state": "Pause", "x.com.samsung.da.progress": "Wash"}
) )
def test_false_for_finish(self): def test_false_for_finish(self):
assert not _live_progress_code({"x.com.samsung.da.progress": "Finish"}) assert not _new_cycle_running(
{"x.com.samsung.da.state": "Run", "x.com.samsung.da.progress": "Finish"}
)
def test_false_when_absent_or_none(self): def test_false_when_absent_or_none(self):
assert not _live_progress_code({}) assert not _new_cycle_running({})
assert not _live_progress_code({"x.com.samsung.da.progress": "None"}) assert not _new_cycle_running(
{"x.com.samsung.da.state": "Run", "x.com.samsung.da.progress": "None"}
)
class TestProgressPercentage: class TestProgressPercentage:
+120 -8
View File
@@ -165,22 +165,134 @@ def test_a_new_cycle_starting_overrides_the_hold():
assert sensor.native_value == "Wash" assert sensor.native_value == "Wash"
def test_a_paused_new_cycle_also_overrides_the_hold(): def test_a_running_stage_after_finish_does_not_break_the_hold():
"""Not just an actively-running new cycle: adding a sock and pausing """Issue #358: the reporting dryer resets `progress` to its course's
mid-cycle must also show the real, current progress rather than a first stage in the same moment `state` goes idle -- observed twice,
stale hold from the previous cycle -- machine_state isn't 'active' identically, as Cooling -> +60s Finish -> +24s 'Drying' -> +4s
while paused, so a bypass keyed on that alone would miss this.""" settled, with the reporter's machine_state history flipping to idle on
sensor, coordinator = _sensor(_PROGRESS_DESC) the exact second progress reads 'Drying'. The hold must survive that,
so the cycle still reads Drying, Cooling, Finish, Idle rather than the
reported Drying, Cooling, Finish, Drying, Idle."""
desc = replace(_PROGRESS_DESC, sticky_seconds=0.2)
sensor, coordinator = _sensor(desc)
_replace(coordinator, state="Run", progress="Drying", progressPercentage="40")
assert sensor.native_value == "Drying"
_replace(coordinator, state="Run", progress="Cooling", progressPercentage="95")
assert sensor.native_value == "Cooling"
_replace(coordinator, state="Run", progress="Finish", progressPercentage="100")
assert sensor.native_value == "Finish"
# The tail: a running stage again, state already idle.
_replace(coordinator, state="Ready", progress="Drying", progressPercentage="100")
assert sensor.native_value == "Finish"
# ...then the device settles, still inside the window.
_replace(coordinator, state="Ready", progress="None")
assert sensor.native_value == "Finish"
time.sleep(0.25)
assert sensor.native_value == "Idle"
def test_progress_percentage_survives_the_same_tail():
"""#358's tail hits progress_percentage through the identical bypass;
it must stay pinned at 100 rather than being released back to a raw
mid-cycle figure."""
desc = replace(_PROGRESS_PERCENTAGE_DESC, sticky_seconds=0.2)
sensor, coordinator = _sensor(desc)
_replace(coordinator, state="Run", progress="Finish", progressPercentage="100")
assert sensor.native_value == 100
_replace(coordinator, state="Ready", progress="Drying", progressPercentage="40")
assert sensor.native_value == 100
time.sleep(0.25)
assert sensor.native_value == 0
def test_a_paused_new_cycle_is_left_to_the_window_rather_than_released():
"""'Paused' isn't positive evidence of a new cycle, and #358's tail is
indistinguishable from it. Nothing live is withheld by waiting --
rep_fn shows 'Idle' while paused with or without a hold -- so the
stale Finish just expires on schedule instead of being cut short."""
desc = replace(_PROGRESS_DESC, sticky_seconds=0.05)
sensor, coordinator = _sensor(desc)
_replace(coordinator, state="Run", progress="Finish", progressPercentage="100") _replace(coordinator, state="Run", progress="Finish", progressPercentage="100")
assert sensor.native_value == "Finish" assert sensor.native_value == "Finish"
_replace(coordinator, state="Ready")
assert sensor.native_value == "Finish" # still held
_replace(coordinator, state="Pause", progress="Wash") _replace(coordinator, state="Pause", progress="Wash")
assert sensor.native_value == "Finish" # held out, not released
time.sleep(0.1)
assert sensor.native_value == "Idle" # what a paused appliance always shows
_replace(coordinator, state="Run", progress="Wash")
assert sensor.native_value == "Wash" assert sensor.native_value == "Wash"
def test_a_flapping_finish_cannot_ratchet_an_open_window_forward():
"""Edge-triggering stops a *stuck* Finish from extending the hold; a
progress that flaps out of and back into Finish must not restart it
either, or the bound stops being a bound."""
desc = replace(_PROGRESS_DESC, sticky_seconds=0.3)
sensor, coordinator = _sensor(desc)
_replace(coordinator, state="Ready", progress="Finish")
assert sensor.native_value == "Finish"
for _ in range(3):
time.sleep(0.05)
_replace(coordinator, state="Ready", progress="None")
assert sensor.native_value == "Finish"
_replace(coordinator, state="Ready", progress="Finish")
assert sensor.native_value == "Finish"
# 0.15s of flapping so far -- the window still ends 0.3s after the
# first Finish, not 0.3s after the most recent re-entry.
time.sleep(0.2)
_replace(coordinator, state="Ready", progress="Finish")
assert sensor.native_value == "Idle"
def test_a_finish_after_the_window_closes_does_not_re_arm_it():
"""Expiry doesn't re-open the door: with no new cycle in between, a
second Finish is the same Finish. Re-arming on it would strobe the
entity Finish -> Idle -> Finish once per window, re-firing exactly the
announcements #345 is about -- so it takes a bypass (a cycle actually
running) to make the hold available again.
Distinct from the flap test above: there the window is still open, and
the last read before expiry leaves the sticky condition *matching*.
Here it has already closed, and the flap ends on a non-matching read,
which is the state a spent-on-arm-only guard would let re-arm."""
desc = replace(_PROGRESS_DESC, sticky_seconds=0.05)
sensor, coordinator = _sensor(desc)
_replace(coordinator, state="Ready", progress="Finish")
assert sensor.native_value == "Finish"
time.sleep(0.1)
assert sensor.native_value == "Idle"
_replace(coordinator, state="Ready", progress="None")
assert sensor.native_value == "Idle"
_replace(coordinator, state="Ready", progress="Finish")
assert sensor.native_value == "Idle"
# A real cycle in between is what makes it available again.
_replace(coordinator, state="Run", progress="Drying")
assert sensor.native_value == "Drying"
_replace(coordinator, state="Run", progress="Finish")
assert sensor.native_value == "Finish"
_replace(coordinator, state="Ready", progress="None")
assert sensor.native_value == "Finish"
def test_hold_expires_after_sticky_seconds(): def test_hold_expires_after_sticky_seconds():
# A fresh desc (frozen dataclass -- replace(), not mutation, so the # A fresh desc (frozen dataclass -- replace(), not mutation, so the
# module-level _PROGRESS_DESC other tests share stays untouched) with # module-level _PROGRESS_DESC other tests share stays untouched) with