laundry: a post-Finish running stage is the cycle ending, not a new one

Fixes #358, a regression from #346. That PR's sticky_bypass_fn released
the Finish/100 hold on any concrete non-Finish progress code, ungated on
machine_state, reasoning that a new cycle's own progress can appear
before state catches up. But the reporting DA_WM_TP1_21_COMMON dryer
replays a running stage on the way *out* of a cycle: the issue's history
shows Cooling -> +60s Finish -> +24s 'Drying' -> +4s settled, twice,
identically. The bypass read that tail as a new cycle, dropped the hold,
and republished 'Drying' -- so progress read Drying, Cooling, Finish,
Drying, Idle instead of ending at Finish, Idle.

The tail is not new: rep_fn has always masked progress while state isn't
active, which is why it was invisible before #346. What surfaced it was
sticky_live_fn, a second, ungated view of the same field that the bypass
returned in rep_fn's place -- letting the hold publish a value the entity
otherwise never shows.

Both halves are fixed:

- The bypass (now _new_cycle_running) requires state == 'active'
  alongside the progress code. The arm condition stays ungated -- failing
  to arm loses the Finish entirely (#345), while releasing late costs
  nothing, since the hold expires on its own.
- sticky_live_fn is gone. rep_fn is the only definition of a live value;
  the hold decides only whether to freeze, and the bypass returns rep_fn's
  own result.

Also stop an already-open window from being restarted by a progress that
flaps in and out of Finish, so sticky_seconds is measured from the first
Finish of a cycle and the documented bound actually holds.

A paused new cycle no longer cuts the hold short (it did under the old
ungated bypass). Nothing live is withheld by that: rep_fn shows Idle
while paused with or without a hold, so the only change is a stale Finish
expiring on schedule -- and 'paused' cannot be told apart from this tail.
This commit is contained in:
Marc Billow committed 2026-08-12 13:44:24 +00:00
1 parent b8ef430ed4
commit 2f7170c448
5 files changed
+166 -79

No files matched your search

+33 -14
View File
@@ -3,7 +3,7 @@
from custom_components.localthings.registry.capabilities.operational import (
OPERATIONAL_STATE,
_just_finished,
_live_progress_code,
_new_cycle_running,
)
from custom_components.localthings.registry.entities import NumberDesc
@@ -41,27 +41,46 @@ class TestJustFinished:
assert not _just_finished({"x.com.samsung.da.state": "Run"})
class TestLiveProgressCode:
"""`_live_progress_code` is progress/progress_percentage's
sticky_bypass_fn (issue #345) -- see sensor.py's _apply_sticky."""
class TestNewCycleRunning:
"""`_new_cycle_running` is progress/progress_percentage's
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):
assert _live_progress_code({"x.com.samsung.da.progress": "Wash"})
def test_true_for_a_concrete_non_finish_code_while_active(self):
assert _new_cycle_running(
{"x.com.samsung.da.state": "Run", "x.com.samsung.da.progress": "Wash"}
)
def test_true_regardless_of_state(self):
"""Not gated on machine_state -- a new cycle's own real progress
must win over a held hold even while paused (e.g. adding a sock
mid-hold), not just while actively running."""
assert _live_progress_code(
def test_false_for_a_running_stage_reported_after_state_left_active(self):
"""Issue #358, the whole reason for the `state` gate: the reporting
dryer replays a running stage ('Drying', its first supportedProgress
entry) for a few seconds after Finish while winding down. That is
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"}
)
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):
assert not _live_progress_code({})
assert not _live_progress_code({"x.com.samsung.da.progress": "None"})
assert not _new_cycle_running({})
assert not _new_cycle_running(
{"x.com.samsung.da.state": "Run", "x.com.samsung.da.progress": "None"}
)
class TestProgressPercentage: