Merge pull request #346 from mbillow/issue-345-progress-finished-hold
washer/dryer: hold progress/progress_percentage at Finish/100 for 5 min
This commit is contained in:
@@ -35,12 +35,44 @@ def _int(v):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _state_is_active(rep):
|
||||||
|
return _SAMSUNG_STATE_TO_OCF.get(rep.get("x.com.samsung.da.state")) == "active"
|
||||||
|
|
||||||
|
|
||||||
def _is_active(rep):
|
def _is_active(rep):
|
||||||
"""Check if appliance is actively running and cycle is not finished."""
|
"""Check if appliance is actively running and cycle is not finished."""
|
||||||
return (
|
return _state_is_active(rep) and rep.get("x.com.samsung.da.progress") != "Finish"
|
||||||
_SAMSUNG_STATE_TO_OCF.get(rep.get("x.com.samsung.da.state")) == "active"
|
|
||||||
and rep.get("x.com.samsung.da.progress") != "Finish"
|
|
||||||
)
|
def _just_finished(rep):
|
||||||
|
"""progress/progress_percentage's sticky_fn (issue #345, see sensor.py's
|
||||||
|
_apply_sticky): arms their grace window the moment `progress` reads
|
||||||
|
'Finish'.
|
||||||
|
|
||||||
|
Deliberately not also requiring `state == 'active'` in the same rep,
|
||||||
|
unlike _is_active above: #345 reports a washer whose `state` can
|
||||||
|
already read idle by the time `progress` is observed at 'Finish' --
|
||||||
|
the same-family dryer's firmware apparently keeps `state` at 'active'
|
||||||
|
longer, per the report -- so requiring both together risked the arm
|
||||||
|
condition never actually firing on the one device this fixes.
|
||||||
|
_apply_sticky's edge-triggering (only a fresh False->True transition
|
||||||
|
(re)arms) is what keeps a `progress` stuck at 'Finish' indefinitely
|
||||||
|
(the same class of quirk `_completion_minutes` below already works
|
||||||
|
around) from holding this open forever instead."""
|
||||||
|
return rep.get("x.com.samsung.da.progress") == "Finish"
|
||||||
|
|
||||||
|
|
||||||
|
def _live_progress_code(rep):
|
||||||
|
"""progress/progress_percentage's sticky_bypass_fn: a concrete,
|
||||||
|
non-Finish progress code being reported right now -- e.g. a new
|
||||||
|
cycle's own real 'Wash'/'Spin' -- must win over a still-open hold from
|
||||||
|
the previous cycle immediately. Not keyed on `state` (unlike
|
||||||
|
_is_active): _just_finished's whole premise is that `state` can't be
|
||||||
|
trusted to still say 'active' while a fresh, real progress value is
|
||||||
|
already there, and the same applies to recognizing when it's moved on
|
||||||
|
to a new one -- including while paused, e.g. adding a sock mid-hold."""
|
||||||
|
v = rep.get("x.com.samsung.da.progress")
|
||||||
|
return v is not None and v not in ("None", "Finish")
|
||||||
|
|
||||||
|
|
||||||
def _remaining_seconds(raw):
|
def _remaining_seconds(raw):
|
||||||
@@ -153,19 +185,31 @@ OPERATIONAL_STATE = Capability(
|
|||||||
BinarySensorDesc(
|
BinarySensorDesc(
|
||||||
key="cycle_active",
|
key="cycle_active",
|
||||||
device_class="running",
|
device_class="running",
|
||||||
rep_fn=lambda rep: (
|
rep_fn=_is_active,
|
||||||
_SAMSUNG_STATE_TO_OCF.get(rep.get("x.com.samsung.da.state")) == "active"
|
|
||||||
and rep.get("x.com.samsung.da.progress") != "Finish"
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
# sticky_* (issue #345): once progress reads 'Finish', keep
|
||||||
|
# showing Finish/100 for a grace window even after machine_state
|
||||||
|
# reverts, rather than falling to Idle/0 the instant it does --
|
||||||
|
# see sensor.py's _apply_sticky. rep_fn below is otherwise
|
||||||
|
# unchanged; the hold is entirely a read-side, per-entity concern,
|
||||||
|
# deliberately not gated on machine_state (_just_finished's
|
||||||
|
# docstring explains why). sticky_live_fn reads the raw field the
|
||||||
|
# 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",
|
||||||
rep_fn=lambda rep: (
|
rep_fn=lambda rep: (
|
||||||
"Idle"
|
"Idle"
|
||||||
if _SAMSUNG_STATE_TO_OCF.get(rep.get("x.com.samsung.da.state")) != "active"
|
if not _state_is_active(rep)
|
||||||
else _progress(rep.get("x.com.samsung.da.progress"))
|
else _progress(rep.get("x.com.samsung.da.progress"))
|
||||||
),
|
),
|
||||||
|
sticky_fn=_just_finished,
|
||||||
|
sticky_value_fn=lambda rep: "Finish",
|
||||||
|
sticky_live_fn=lambda rep: _progress(rep.get("x.com.samsung.da.progress")),
|
||||||
|
sticky_bypass_fn=_live_progress_code,
|
||||||
),
|
),
|
||||||
SensorDesc(
|
SensorDesc(
|
||||||
key="progress_percentage",
|
key="progress_percentage",
|
||||||
@@ -173,9 +217,13 @@ OPERATIONAL_STATE = Capability(
|
|||||||
state_class="measurement",
|
state_class="measurement",
|
||||||
rep_fn=lambda rep: (
|
rep_fn=lambda rep: (
|
||||||
0
|
0
|
||||||
if _SAMSUNG_STATE_TO_OCF.get(rep.get("x.com.samsung.da.state")) != "active"
|
if not _state_is_active(rep)
|
||||||
else _int(rep.get("x.com.samsung.da.progressPercentage"))
|
else _int(rep.get("x.com.samsung.da.progressPercentage"))
|
||||||
),
|
),
|
||||||
|
sticky_fn=_just_finished,
|
||||||
|
sticky_value_fn=lambda rep: 100,
|
||||||
|
sticky_live_fn=lambda rep: _int(rep.get("x.com.samsung.da.progressPercentage")) or 0,
|
||||||
|
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'.
|
||||||
|
|||||||
@@ -64,6 +64,18 @@ class SensorDesc(SamsungEntityDescription):
|
|||||||
# (see sensor.py). Only for values expected to jitter between
|
# (see sensor.py). Only for values expected to jitter between
|
||||||
# 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
|
||||||
|
# for the full contract (arm/value/live/bypass semantics,
|
||||||
|
# edge-triggering, 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 forces
|
||||||
|
# sticky_live_fn's result through and drops the hold; sticky_seconds
|
||||||
|
# bounds how long it can hold.
|
||||||
|
sticky_fn: Callable[[dict], bool] | 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_seconds: float = 300.0
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, kw_only=True)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
@@ -51,6 +52,9 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity):
|
|||||||
if desc.options:
|
if desc.options:
|
||||||
self._attr_options = list(desc.options)
|
self._attr_options = list(desc.options)
|
||||||
self._hysteresis_value = None
|
self._hysteresis_value = None
|
||||||
|
self._sticky_value = None
|
||||||
|
self._sticky_until: float | None = None
|
||||||
|
self._sticky_armed = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_unit_of_measurement(self):
|
def native_unit_of_measurement(self):
|
||||||
@@ -63,10 +67,68 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity):
|
|||||||
def native_value(self):
|
def native_value(self):
|
||||||
raw = (self.coordinator.data or {}).get(self._state_key)
|
raw = (self.coordinator.data or {}).get(self._state_key)
|
||||||
desc = cast(SensorDesc, self._bound.desc)
|
desc = cast(SensorDesc, self._bound.desc)
|
||||||
|
if desc.sticky_fn is not None:
|
||||||
|
raw = self._apply_sticky(raw, desc)
|
||||||
if not desc.hysteresis:
|
if not desc.hysteresis:
|
||||||
return raw
|
return raw
|
||||||
return self._apply_hysteresis(raw)
|
return self._apply_hysteresis(raw)
|
||||||
|
|
||||||
|
def _apply_sticky(self, raw, desc: SensorDesc):
|
||||||
|
"""Freeze this entity at a value for up to `desc.sticky_seconds`
|
||||||
|
after `desc.sticky_fn` next stops matching this href's live rep
|
||||||
|
(issue #345 -- see operational.py's `_just_finished` for the
|
||||||
|
motivating case). Entity-instance state only, exactly like
|
||||||
|
`_hysteresis_value` above -- never written back to the coordinator
|
||||||
|
cache, so write_fn, diagnostics, and the observe-mode sweep
|
||||||
|
comparison keep seeing real device data throughout.
|
||||||
|
|
||||||
|
Reads `sticky_fn` and friends against this href's own live rep,
|
||||||
|
not the already-computed `raw`, so they can be independent of
|
||||||
|
whatever rep_fn itself gates on -- notably `sticky_live_fn`, which
|
||||||
|
is *not* `raw`: `raw` is rep_fn's own (possibly differently gated)
|
||||||
|
result, e.g. progress's rep_fn shows "Idle" while paused, but a
|
||||||
|
real progress value reported while paused (adding a sock mid-
|
||||||
|
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
|
||||||
|
a fresh False->True transition of `sticky_fn`, and -- this is the
|
||||||
|
part level-triggering alone misses -- expiry is still checked on
|
||||||
|
every call even while `sticky_fn` keeps matching. Without the
|
||||||
|
latter, a firmware that leaves the underlying field stuck matching
|
||||||
|
forever (the same class of quirk `_completion_minutes` already
|
||||||
|
works around) would show the frozen value forever too, defeating
|
||||||
|
"bounded, not indefinite".
|
||||||
|
|
||||||
|
`sticky_bypass_fn`, when it matches, always passes
|
||||||
|
`sticky_live_fn`'s result through and drops any hold -- for a
|
||||||
|
condition where "not sticky right now" is ambiguous between "went
|
||||||
|
idle, honor the hold" and "genuinely live, different data" (e.g. a
|
||||||
|
new cycle's own real progress), which "consult the hold whenever
|
||||||
|
sticky_fn is False" alone can't tell apart.
|
||||||
|
"""
|
||||||
|
assert desc.sticky_fn is not None # native_value only calls this when set
|
||||||
|
rep = self.coordinator.resource(self._bound.href)
|
||||||
|
now = time.monotonic()
|
||||||
|
matches = desc.sticky_fn(rep)
|
||||||
|
|
||||||
|
if matches:
|
||||||
|
if not self._sticky_armed:
|
||||||
|
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
|
||||||
|
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 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:
|
||||||
|
return self._sticky_value
|
||||||
|
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
|
||||||
differs by at least the configured threshold, regardless of how long
|
differs by at least the configured threshold, regardless of how long
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
"""Unit tests for operational state capabilities."""
|
"""Unit tests for operational state capabilities."""
|
||||||
|
|
||||||
from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE
|
from custom_components.localthings.registry.capabilities.operational import (
|
||||||
|
OPERATIONAL_STATE,
|
||||||
|
_just_finished,
|
||||||
|
_live_progress_code,
|
||||||
|
)
|
||||||
from custom_components.localthings.registry.entities import NumberDesc
|
from custom_components.localthings.registry.entities import NumberDesc
|
||||||
|
|
||||||
|
|
||||||
@@ -11,6 +15,55 @@ def test_machine_state_maps_samsung_to_ocf():
|
|||||||
assert ms.value_fn("Ready") == "idle"
|
assert ms.value_fn("Ready") == "idle"
|
||||||
|
|
||||||
|
|
||||||
|
class TestJustFinished:
|
||||||
|
"""`_just_finished` is progress/progress_percentage's sticky_fn arm
|
||||||
|
condition (issue #345) -- see sensor.py's _apply_sticky."""
|
||||||
|
|
||||||
|
def test_true_when_progress_is_finish_while_active(self):
|
||||||
|
assert _just_finished(
|
||||||
|
{"x.com.samsung.da.state": "Run", "x.com.samsung.da.progress": "Finish"}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_true_even_once_state_has_already_left_active(self):
|
||||||
|
"""The exact issue #345 scenario this exists for: a washer's
|
||||||
|
`state` can already read idle by the time `progress` is observed
|
||||||
|
at 'Finish' -- unlike _is_active, this must still arm."""
|
||||||
|
assert _just_finished(
|
||||||
|
{"x.com.samsung.da.state": "Ready", "x.com.samsung.da.progress": "Finish"}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_false_while_active_but_not_yet_finished(self):
|
||||||
|
assert not _just_finished(
|
||||||
|
{"x.com.samsung.da.state": "Run", "x.com.samsung.da.progress": "Spin"}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_false_when_progress_absent(self):
|
||||||
|
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."""
|
||||||
|
|
||||||
|
def test_true_for_a_concrete_non_finish_code(self):
|
||||||
|
assert _live_progress_code({"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(
|
||||||
|
{"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"})
|
||||||
|
|
||||||
|
def test_false_when_absent_or_none(self):
|
||||||
|
assert not _live_progress_code({})
|
||||||
|
assert not _live_progress_code({"x.com.samsung.da.progress": "None"})
|
||||||
|
|
||||||
|
|
||||||
class TestProgressPercentage:
|
class TestProgressPercentage:
|
||||||
"""issue #9: device firmware leaves progressPercentage stale (e.g. '1')
|
"""issue #9: device firmware leaves progressPercentage stale (e.g. '1')
|
||||||
after a cycle ends instead of resetting it, so it must be gated on
|
after a cycle ends instead of resetting it, so it must be gated on
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
"""Unit tests for LocalThingsSensor's sticky gate (issue #345).
|
||||||
|
|
||||||
|
Modeled on test_sensor_hysteresis.py: a _FakeCoordinator with just enough
|
||||||
|
surface for LocalThingsEntity/LocalThingsSensor, driven directly rather
|
||||||
|
than through a full coordinator/HA setup. Unlike that file, `data` here
|
||||||
|
is derived from the real `flatten()` over the same `resources` dict
|
||||||
|
`resource()` serves -- a single source of truth, so a test can't hide a
|
||||||
|
production desync bug by hand-computing the two independently.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from dataclasses import replace
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
from custom_components.localthings.coordinator import LocalThingsCoordinator
|
||||||
|
from custom_components.localthings.registry.adapter import flatten
|
||||||
|
from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE
|
||||||
|
from custom_components.localthings.registry.discovery import BoundEntity
|
||||||
|
from custom_components.localthings.sensor import LocalThingsSensor
|
||||||
|
|
||||||
|
_PROGRESS_DESC = next(e for e in OPERATIONAL_STATE.entities if e.key == "progress")
|
||||||
|
_PROGRESS_PERCENTAGE_DESC = next(
|
||||||
|
e for e in OPERATIONAL_STATE.entities if e.key == "progress_percentage"
|
||||||
|
)
|
||||||
|
_MACHINE_STATE_DESC = next(e for e in OPERATIONAL_STATE.entities if e.key == "machine_state")
|
||||||
|
|
||||||
|
_HREF = "/operational/state/vs/0"
|
||||||
|
_ALL_BOUND = [
|
||||||
|
BoundEntity(href=_HREF, capability=OPERATIONAL_STATE, desc=desc)
|
||||||
|
for desc in OPERATIONAL_STATE.entities
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeConfigEntry:
|
||||||
|
def __init__(self):
|
||||||
|
self.options: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeCoordinator:
|
||||||
|
"""Just enough surface for LocalThingsEntity/LocalThingsSensor.
|
||||||
|
|
||||||
|
`resources` is the one source of truth (standing in for the real
|
||||||
|
coordinator's live cache); `data` and `resource()` are both derived
|
||||||
|
from it exactly as the real coordinator derives `.data` from
|
||||||
|
`flatten(self.bound, self._cache.snapshot())` and `.resource()` from
|
||||||
|
the same snapshot -- so a test can't drift them apart in a way
|
||||||
|
production couldn't.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.device_serial = "TEST-SERIAL"
|
||||||
|
self.config_entry = _FakeConfigEntry()
|
||||||
|
self.resources: dict[str, dict] = {}
|
||||||
|
|
||||||
|
def resource(self, href: str) -> dict:
|
||||||
|
return self.resources.get(href) or {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def data(self) -> dict:
|
||||||
|
return flatten(_ALL_BOUND, self.resources)
|
||||||
|
|
||||||
|
|
||||||
|
def _sensor(desc):
|
||||||
|
coordinator = _FakeCoordinator()
|
||||||
|
bound = BoundEntity(href=_HREF, capability=OPERATIONAL_STATE, desc=desc)
|
||||||
|
sensor = LocalThingsSensor(cast(LocalThingsCoordinator, coordinator), bound)
|
||||||
|
return sensor, coordinator
|
||||||
|
|
||||||
|
|
||||||
|
def _apply(coordinator, **fields):
|
||||||
|
"""Merge `fields` onto the href's existing rep, the same shallow
|
||||||
|
{**cached, **rep} merge ObserveManager.apply() does in production
|
||||||
|
(issue #27) -- so a field this call doesn't mention (e.g. a stale
|
||||||
|
`progress` the device didn't repeat) stays exactly as a real partial
|
||||||
|
update would leave it, rather than being silently wiped."""
|
||||||
|
href_fields = {f"x.com.samsung.da.{k}": v for k, v in fields.items()}
|
||||||
|
coordinator.resources[_HREF] = {**coordinator.resources.get(_HREF, {}), **href_fields}
|
||||||
|
|
||||||
|
|
||||||
|
def _replace(coordinator, **fields):
|
||||||
|
"""A full rep replacement -- for a device reporting fresh from
|
||||||
|
scratch (e.g. a full poll GET), unlike _apply's partial-update merge."""
|
||||||
|
coordinator.resources[_HREF] = {f"x.com.samsung.da.{k}": v for k, v in fields.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def test_holds_finish_after_state_leaves_active():
|
||||||
|
"""The #345 regression this guards: progress used to fall straight to
|
||||||
|
'Idle' the instant machine_state left active, even right after
|
||||||
|
reporting Finish."""
|
||||||
|
sensor, coordinator = _sensor(_PROGRESS_DESC)
|
||||||
|
|
||||||
|
_replace(coordinator, state="Run", progress="Finish", progressPercentage="100")
|
||||||
|
assert sensor.native_value == "Finish"
|
||||||
|
|
||||||
|
_replace(coordinator, state="Ready") # device has moved on
|
||||||
|
assert sensor.native_value == "Finish"
|
||||||
|
|
||||||
|
|
||||||
|
def test_holds_finish_even_when_state_already_idle_at_first_observation():
|
||||||
|
"""issue #345's actual reported failure mode: state can already read
|
||||||
|
idle by the time `progress: Finish` is ever observed (the report's
|
||||||
|
washer skips a Run+Finish moment the same-family dryer still shows) --
|
||||||
|
the hold must still arm from that single rep."""
|
||||||
|
sensor, coordinator = _sensor(_PROGRESS_DESC)
|
||||||
|
|
||||||
|
_replace(coordinator, state="Ready", progress="Finish", progressPercentage="100")
|
||||||
|
assert sensor.native_value == "Finish"
|
||||||
|
|
||||||
|
# Still held on a later poll, even once the device stops repeating it.
|
||||||
|
_replace(coordinator, state="Ready")
|
||||||
|
assert sensor.native_value == "Finish"
|
||||||
|
|
||||||
|
|
||||||
|
def test_progress_percentage_holds_100_regardless_of_the_raw_field_at_finish():
|
||||||
|
"""The hold pins 100 explicitly (sticky_value_fn), not whatever the
|
||||||
|
raw field happened to hold at the finish moment -- a device that
|
||||||
|
never populates progressPercentage at all, or (issue #9's failure
|
||||||
|
mode) leaves a stale non-100 value there, must still show 100 while
|
||||||
|
held, not None/unknown or the stale figure."""
|
||||||
|
sensor, coordinator = _sensor(_PROGRESS_PERCENTAGE_DESC)
|
||||||
|
|
||||||
|
_replace(coordinator, state="Run", progress="Finish") # no progressPercentage at all
|
||||||
|
assert sensor.native_value == 100
|
||||||
|
|
||||||
|
_replace(coordinator, state="Ready")
|
||||||
|
assert sensor.native_value == 100
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_data_flows_through_unheld_while_active():
|
||||||
|
sensor, coordinator = _sensor(_PROGRESS_DESC)
|
||||||
|
|
||||||
|
_replace(coordinator, state="Run", progress="Spin")
|
||||||
|
assert sensor.native_value == "Spin"
|
||||||
|
|
||||||
|
_replace(coordinator, state="Run", progress="Rinse")
|
||||||
|
assert sensor.native_value == "Rinse"
|
||||||
|
|
||||||
|
|
||||||
|
def test_never_finished_stays_idle():
|
||||||
|
"""A device that never actually reached Finish (e.g. Stop pressed
|
||||||
|
mid-cycle) must not be held at some prior mid-cycle value."""
|
||||||
|
sensor, coordinator = _sensor(_PROGRESS_DESC)
|
||||||
|
|
||||||
|
_replace(coordinator, state="Run", progress="Spin")
|
||||||
|
assert sensor.native_value == "Spin"
|
||||||
|
|
||||||
|
_replace(coordinator, state="Ready")
|
||||||
|
assert sensor.native_value == "Idle"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_new_cycle_starting_overrides_the_hold():
|
||||||
|
"""Starting a second cycle must show its own real progress
|
||||||
|
immediately, not the held Finish from the previous one."""
|
||||||
|
sensor, coordinator = _sensor(_PROGRESS_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="Run", progress="Wash")
|
||||||
|
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)
|
||||||
|
|
||||||
|
_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 == "Wash"
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
# a short real window -- consistent with this codebase's own
|
||||||
|
# settle-guard tests (test_observe.py's mark_write_pending(...,
|
||||||
|
# settle_s=0.05)) -- rather than a zero window, which exercises "held
|
||||||
|
# disabled" rather than "an armed hold actually expires".
|
||||||
|
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 within the window
|
||||||
|
|
||||||
|
time.sleep(0.1)
|
||||||
|
assert sensor.native_value == "Idle"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_progress_stuck_at_finish_does_not_hold_open_the_window_forever():
|
||||||
|
"""Edge-triggering: sticky_fn keeps matching on every poll if the
|
||||||
|
device's own `progress` field never resets on its own (the same class
|
||||||
|
of quirk _completion_minutes' remainingTime-freeze workaround already
|
||||||
|
documents) -- the hold must still expire on schedule from its first
|
||||||
|
sighting, not have its deadline pushed out by every subsequent poll
|
||||||
|
that still (correctly, from the device's perspective) reports Finish
|
||||||
|
while machine_state has already gone idle."""
|
||||||
|
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.03)
|
||||||
|
# Device still (incorrectly) reports Finish on every subsequent poll --
|
||||||
|
# must not restart the window.
|
||||||
|
_replace(coordinator, state="Ready", progress="Finish")
|
||||||
|
assert sensor.native_value == "Finish"
|
||||||
|
|
||||||
|
time.sleep(0.03) # 0.06s total since the first sighting -- past 0.05s
|
||||||
|
_replace(coordinator, state="Ready", progress="Finish")
|
||||||
|
assert sensor.native_value == "Idle"
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_sticky_sensor_is_unaffected():
|
||||||
|
"""machine_state has no sticky_fn -- reads straight through, unchanged."""
|
||||||
|
sensor, coordinator = _sensor(_MACHINE_STATE_DESC)
|
||||||
|
_replace(coordinator, state="Run")
|
||||||
|
assert sensor.native_value == "active"
|
||||||
|
_replace(coordinator, state="Ready")
|
||||||
|
assert sensor.native_value == "idle"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cycle_active_and_machine_state_are_never_held():
|
||||||
|
"""The whole point of scoping the hold to progress/progress_percentage
|
||||||
|
only: cycle_active (Running) and machine_state must keep reflecting
|
||||||
|
the device's real-time state throughout, never claiming the appliance
|
||||||
|
is still running once it isn't."""
|
||||||
|
progress_sensor, coordinator = _sensor(_PROGRESS_DESC)
|
||||||
|
machine_state_bound = BoundEntity(
|
||||||
|
href=_HREF, capability=OPERATIONAL_STATE, desc=_MACHINE_STATE_DESC
|
||||||
|
)
|
||||||
|
machine_state_sensor = LocalThingsSensor(
|
||||||
|
cast(LocalThingsCoordinator, coordinator), machine_state_bound
|
||||||
|
)
|
||||||
|
|
||||||
|
_replace(coordinator, state="Run", progress="Finish", progressPercentage="100")
|
||||||
|
assert progress_sensor.native_value == "Finish"
|
||||||
|
assert machine_state_sensor.native_value == "active"
|
||||||
|
|
||||||
|
_replace(coordinator, state="Ready")
|
||||||
|
assert progress_sensor.native_value == "Finish" # held
|
||||||
|
assert machine_state_sensor.native_value == "idle" # real-time, unaffected
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_partial_update_that_omits_progress_does_not_erase_the_hold():
|
||||||
|
"""A device's partial poll/notify that doesn't repeat `progress` at
|
||||||
|
all (issue #27's documented shape) merges onto the cache rather than
|
||||||
|
replacing it -- confirms the hold reads the merged rep, still showing
|
||||||
|
'Finish' from the earlier full rep, not a wiped/absent field."""
|
||||||
|
sensor, coordinator = _sensor(_PROGRESS_DESC)
|
||||||
|
|
||||||
|
_replace(coordinator, state="Run", progress="Finish", progressPercentage="100")
|
||||||
|
assert sensor.native_value == "Finish"
|
||||||
|
|
||||||
|
_apply(coordinator, state="Ready") # partial merge, doesn't restate progress
|
||||||
|
assert coordinator.resources[_HREF]["x.com.samsung.da.progress"] == "Finish"
|
||||||
|
assert sensor.native_value == "Finish"
|
||||||
Reference in New Issue
Block a user