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:
5 files changed
+166
-79
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user