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:
+86 -8
View File
@@ -165,22 +165,100 @@ def test_a_new_cycle_starting_overrides_the_hold():
assert sensor.native_value == "Wash"
def test_a_paused_new_cycle_also_overrides_the_hold():
"""Not just an actively-running new cycle: adding a sock and pausing
mid-cycle must also show the real, current progress rather than a
stale hold from the previous cycle -- machine_state isn't 'active'
while paused, so a bypass keyed on that alone would miss this."""
sensor, coordinator = _sensor(_PROGRESS_DESC)
def test_a_running_stage_after_finish_does_not_break_the_hold():
"""Issue #358: the reporting dryer replays a running stage after
Finish -- observed twice, identically, as Cooling -> +60s Finish ->
+24s 'Drying' -> +4s settled, with `state` already idle throughout the
tail. The hold must survive it, 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")
assert sensor.native_value == "Finish"
_replace(coordinator, state="Ready")
assert sensor.native_value == "Finish" # still held
_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"
def test_a_flapping_finish_cannot_ratchet_the_window_forward():
"""Edge-triggering stops a *stuck* Finish from extending the hold, but
a device whose progress flaps out of and back into Finish re-arms --
an already-open window must not restart on that, or the bound stops
being a bound."""
desc = replace(_PROGRESS_DESC, sticky_seconds=0.1)
sensor, coordinator = _sensor(desc)
_replace(coordinator, state="Ready", progress="Finish")
assert sensor.native_value == "Finish"
for _ in range(3):
time.sleep(0.03)
_replace(coordinator, state="Ready", progress="None")
assert sensor.native_value == "Finish"
_replace(coordinator, state="Ready", progress="Finish")
assert sensor.native_value == "Finish"
# 0.09s of flapping so far; past 0.1s from the *first* Finish it ends,
# rather than 0.1s from the most recent re-arm.
time.sleep(0.03)
_replace(coordinator, state="Ready", progress="Finish")
assert sensor.native_value == "Idle"
def test_hold_expires_after_sticky_seconds():
# A fresh desc (frozen dataclass -- replace(), not mutation, so the
# module-level _PROGRESS_DESC other tests share stays untouched) with