laundry: one hold per cycle, so the sticky bound is actually a bound

Review of the previous commit caught that its docstring promised more
than the code did. Not restarting an *open* window still let a progress
that flapped out of and back into Finish re-arm a full fresh window once
the first had expired, so the value could be held well past
sticky_seconds from the first Finish. The new test passed only because
its final read left the sticky condition matching; ending the flap on a
non-matching read re-armed and would have failed it.

Make the guarantee real instead of weakening the claim: arming marks the
hold spent, and only sticky_bypass_fn -- a cycle actually running --
clears it. Expiry on its own no longer re-opens the door, because with no
cycle in between a second Finish is the same Finish, and re-arming on it
strobes the entity Finish -> Idle -> Finish once per window, re-firing
the announcements #345 and #358 are both about.

That subsumes the old _sticky_armed edge-trigger flag, which existed to
stop a stuck field extending the window; "spent until a new cycle" covers
that case and the flap case together, before or after expiry.

Also give the flap test real headroom -- it fitted 0.09s of sleeps into a
0.1s window and would have failed spuriously on a loaded runner.
This commit is contained in:
Marc Billow committed 2026-08-12 14:18:56 +00:00
1 parent 2f7170c448
commit f12f67b2b3
3 files changed
+67 -32

No files matched your search

@@ -65,8 +65,8 @@ class SensorDesc(SamsungEntityDescription):
# 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/bypass semantics, edge-triggering,
# why this never touches the coordinator cache).
# for the full contract (arm/value/bypass semantics, one window per
# bypass, 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 drops the
# hold and lets rep_fn's own live result through; sticky_seconds
+22 -20
View File
@@ -54,7 +54,7 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity):
self._hysteresis_value = None
self._sticky_value = None
self._sticky_until: float | None = None
self._sticky_armed = False
self._sticky_spent = False
@property
def native_unit_of_measurement(self):
@@ -89,39 +89,41 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity):
held entity and a free-running one agree on what "live" means; a
hook that broke that rule caused issue #358.
Edge-triggered, not level-triggered: the window (re)starts only on
a fresh False->True transition of `sticky_fn`, is never restarted
while already open, and is checked for expiry on every call even
while `sticky_fn` keeps matching -- so neither a field stuck
matching forever (the quirk `_completion_minutes` works around)
nor one flapping in and out can hold the value past
`sticky_seconds` from when it first armed.
At most one window per `sticky_bypass_fn` cycle: arming marks the
hold spent, and only the bypass clears that. So a `sticky_fn` that
keeps matching (firmware leaving the field stuck -- the quirk
`_completion_minutes` works around) can't extend the window, and
one flapping in and out can't restart it either, before or after
expiry. Expiry alone doesn't re-open the door: without something
the calibre of "a new cycle is actually running" in between, a
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` drops the hold and returns `raw`, for when "not
sticky right now" is ambiguous between "went idle, honor the hold"
and "genuinely moved on to new data". Being the early-release
path it should demand positive evidence of the latter; when
unsure, letting the window run out is the cheaper mistake.
and "genuinely moved on to new data". It is both the early release
and the only re-arm, so it should demand positive evidence of that
move; when unsure, letting the window run out is the cheaper
mistake.
"""
assert desc.sticky_fn is not None # native_value only calls this when set
rep = self.coordinator.resource(self._bound.href)
now = time.monotonic()
holding = self._sticky_until is not None and now < self._sticky_until
if desc.sticky_fn(rep):
if not self._sticky_armed and not holding:
if not self._sticky_spent:
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
holding = True
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 raw
self._sticky_spent = True
elif desc.sticky_bypass_fn is not None and desc.sticky_bypass_fn(rep):
self._sticky_until = None
self._sticky_spent = False
return raw
holding = self._sticky_until is not None and now < self._sticky_until
return self._sticky_value if holding else raw
def _apply_hysteresis(self, raw):