Fixes #345. progress and progress_percentage were gated on machine_state alone, falling straight to "Idle"/0 the instant state left 'active' -- but a washer can flip state away from 'active' within the same poll interval progress reaches 'Finish' (more reliably than the same-family dryer, per the report), so an automation watching for a real 'Finish' value could go a whole cycle without ever observing one. Implemented read-side, per-entity (sensor.py's new _apply_sticky), generalizing the existing _hysteresis_value/_apply_hysteresis pattern finish_time already uses, rather than writing a synthetic override back into the coordinator's cache. That cache is this integration's record of what the device actually said, and is read by several unrelated consumers -- write_fn, validate_fn, diagnostics, the observe-mode sweep comparison against /device/0, _completion_minutes' own stale-remainingTime workaround -- all of which would otherwise see fabricated state. A first pass gated the hold's arm condition on state=='active' AND progress=='Finish' occurring in the same rep, mirroring _is_active. A second review caught that this could make the whole fix a no-op on the one device #345 reports it for: if state has already reset by the time progress is ever observed at 'Finish' -- exactly what #345 describes -- the arm condition never fires. _just_finished now arms on progress== 'Finish' alone. That reopens the staleness risk the state check existed to guard against (a progress field stuck at 'Finish' forever would then arm forever too), so _apply_sticky is edge-triggered: only a fresh False->True transition (re)starts the window, and expiry is still checked on every call even while the condition keeps matching -- a stuck value still won't hold past sticky_seconds. Dropping the state requirement also exposed a second gap: the bypass that lets a new cycle's own real progress override a stale hold was keyed on machine_state=='active', so it missed a new cycle immediately paused (e.g. adding a sock) -- machine_state isn't 'active' while paused. It's keyed on a live, non-Finish progress code instead (_live_progress_code), independent of state, same reasoning as _just_finished. And since the bypass needs the *real* live value, not whatever rep_fn's own (differently gated) result says, SensorDesc grew sticky_live_fn alongside sticky_value_fn: rep_fn's progress gate still shows "Idle" while paused, but the real progress value read ungated must win over the hold regardless. registry/entities.py: SensorDesc gains sticky_fn/sticky_value_fn/ sticky_live_fn/sticky_bypass_fn/sticky_seconds -- see sensor.py's _apply_sticky docstring for the full contract. registry/capabilities/operational.py: progress/progress_percentage's rep_fn is unchanged; they gain the sticky_* wiring above. machine_state and the Running binary sensor are untouched -- still gated on real-time state (cycle_active now shares _is_active's rep_fn directly rather than a duplicate inline copy), so they never claim the appliance is still running once it isn't.
213 lines
8.4 KiB
Python
213 lines
8.4 KiB
Python
"""Unit tests for operational state capabilities."""
|
|
|
|
from custom_components.localthings.registry.capabilities.operational import (
|
|
OPERATIONAL_STATE,
|
|
_just_finished,
|
|
_live_progress_code,
|
|
)
|
|
from custom_components.localthings.registry.entities import NumberDesc
|
|
|
|
|
|
def test_machine_state_maps_samsung_to_ocf():
|
|
ms = next(e for e in OPERATIONAL_STATE.entities if e.key == "machine_state")
|
|
assert ms.value_fn("Run") == "active"
|
|
assert ms.value_fn("Pause") == "pause"
|
|
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:
|
|
"""issue #9: device firmware leaves progressPercentage stale (e.g. '1')
|
|
after a cycle ends instead of resetting it, so it must be gated on
|
|
active state the same way `progress`/`cycle_active`/`finish_time` are."""
|
|
|
|
def test_zeroed_when_not_active(self):
|
|
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == "progress_percentage")
|
|
assert desc.rep_fn is not None
|
|
rep = {"x.com.samsung.da.state": "Ready", "x.com.samsung.da.progressPercentage": "1"}
|
|
assert desc.rep_fn(rep) == 0
|
|
|
|
def test_passes_through_when_active(self):
|
|
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == "progress_percentage")
|
|
assert desc.rep_fn is not None
|
|
rep = {"x.com.samsung.da.state": "Run", "x.com.samsung.da.progressPercentage": "42"}
|
|
assert desc.rep_fn(rep) == 42
|
|
|
|
|
|
class TestCompletionMinutes:
|
|
"""Unit tests for completion_minutes entity."""
|
|
|
|
def test_completion_minutes_parsing(self):
|
|
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == "completion_minutes")
|
|
assert desc.rep_fn is not None
|
|
|
|
# 1 hour 25 mins 30 secs -> 85 mins + 1 sec ceiling = 86 mins
|
|
rep = {"x.com.samsung.da.remainingTime": "01:25:30"}
|
|
assert desc.rep_fn(rep) == 86
|
|
|
|
# Exact minutes: 1 hour 30 mins 00 secs -> 90 mins
|
|
rep_exact = {"x.com.samsung.da.remainingTime": "01:30:00"}
|
|
assert desc.rep_fn(rep_exact) == 90
|
|
|
|
def test_completion_minutes_fallback_key(self):
|
|
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == "completion_minutes")
|
|
assert desc.rep_fn is not None
|
|
rep = {"remainingTime": "00:45:00"}
|
|
assert desc.rep_fn(rep) == 45
|
|
|
|
def test_completion_minutes_stale_finish_gated(self):
|
|
"""Firmware freezes remainingTime at '00:01:00' when progress reaches 'Finish'.
|
|
Should return 0 to prevent stuck values."""
|
|
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == "completion_minutes")
|
|
assert desc.rep_fn is not None
|
|
rep = {
|
|
"x.com.samsung.da.progress": "Finish",
|
|
"x.com.samsung.da.remainingTime": "00:01:00",
|
|
}
|
|
assert desc.rep_fn(rep) == 0
|
|
|
|
def test_completion_minutes_missing_or_invalid(self):
|
|
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == "completion_minutes")
|
|
assert desc.rep_fn is not None
|
|
|
|
rep = {}
|
|
assert desc.rep_fn(rep) is None
|
|
|
|
|
|
class TestFinishTime:
|
|
"""issue: remainingTime only has minute resolution, but datetime.now()
|
|
always carries fresh seconds/microseconds -- an unrounded finish_time
|
|
changed on nearly every poll even when remainingTime hadn't, flooding
|
|
the recorder history/logbook with values that looked identical once
|
|
the UI rounded them down to the minute for display."""
|
|
|
|
def test_seconds_and_microseconds_are_zeroed(self):
|
|
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == "finish_time")
|
|
assert desc.rep_fn is not None
|
|
rep = {
|
|
"x.com.samsung.da.state": "Run",
|
|
"x.com.samsung.da.remainingTime": "00:29:00",
|
|
}
|
|
result = desc.rep_fn(rep)
|
|
assert result.second == 0
|
|
assert result.microsecond == 0
|
|
|
|
def test_stable_across_polls_within_same_minute(self):
|
|
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == "finish_time")
|
|
assert desc.rep_fn is not None
|
|
rep = {
|
|
"x.com.samsung.da.state": "Run",
|
|
"x.com.samsung.da.remainingTime": "00:29:00",
|
|
}
|
|
first = desc.rep_fn(rep)
|
|
second = desc.rep_fn(rep)
|
|
assert first == second
|
|
|
|
|
|
class TestDelayFieldFallback:
|
|
def test_reads_delay_end_time_when_delay_start_time_absent(self):
|
|
from custom_components.localthings.registry.capabilities.operational import (
|
|
OPERATIONAL_STATE,
|
|
)
|
|
|
|
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == "delay_start_hours")
|
|
assert desc.rep_fn is not None
|
|
rep = {"x.com.samsung.da.delayEndTime": "02:30:00"}
|
|
assert desc.rep_fn(rep) == 2.5
|
|
|
|
def test_prefers_delay_start_time_when_both_present(self):
|
|
from custom_components.localthings.registry.capabilities.operational import (
|
|
OPERATIONAL_STATE,
|
|
)
|
|
|
|
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == "delay_start_hours")
|
|
assert desc.rep_fn is not None
|
|
rep = {
|
|
"x.com.samsung.da.delayStartTime": "01:00:00",
|
|
"x.com.samsung.da.delayEndTime": "02:00:00",
|
|
}
|
|
assert desc.rep_fn(rep) == 1.0
|
|
|
|
def test_write_targets_delay_end_time_when_that_is_what_device_reports(self):
|
|
from custom_components.localthings.registry.capabilities.operational import (
|
|
OPERATIONAL_STATE,
|
|
)
|
|
|
|
desc = next(
|
|
e
|
|
for e in OPERATIONAL_STATE.entities
|
|
if e.key == "delay_start_hours" and isinstance(e, NumberDesc)
|
|
)
|
|
assert desc.write_fn is not None
|
|
rep = {"x.com.samsung.da.delayEndTime": "00:00:00"}
|
|
result = desc.write_fn(1.5, rep)
|
|
assert result is not None
|
|
path, body = result
|
|
assert path == ["operational", "state", "vs", "0"]
|
|
assert body == {"x.com.samsung.da.delayEndTime": "01:30:00"}
|
|
|
|
def test_write_targets_delay_start_time_by_default(self):
|
|
from custom_components.localthings.registry.capabilities.operational import (
|
|
OPERATIONAL_STATE,
|
|
)
|
|
|
|
desc = next(
|
|
e
|
|
for e in OPERATIONAL_STATE.entities
|
|
if e.key == "delay_start_hours" and isinstance(e, NumberDesc)
|
|
)
|
|
assert desc.write_fn is not None
|
|
rep = {"x.com.samsung.da.delayStartTime": "00:00:00"}
|
|
result = desc.write_fn(1.5, rep)
|
|
assert result is not None
|
|
_path, body = result
|
|
assert body == {"x.com.samsung.da.delayStartTime": "01:30:00"}
|