Compare commits

...
12 Commits
Author SHA1 Message Date
Marc Billow 2b85e20108 Bump version to 0.21.2 2026-08-13 02:44:15 +00:00
Marc Billow e5cd212a34 read_resource: a Collection's list body is not an empty resource (#335)
`_raw_read_blocking` decoded the CBOR body and kept it only when it was a
Property map, so a Collection -- which answers the `[devcol rep, {href,
rep}, ...]` batch `parse_device0_batch` reads -- came back as `2.05` with
`rep: {}`. That renders as "the resource exists and has nothing in it",
which is the opposite of what a populated batch means, and `/device/0`
itself would have read the same way.

It cost a real result: issue #335's board answers `/sec/devices` (the
`x.com.samsung.devcol` sibling of `/device/0`, and the one remaining place
a composite appliance could be enumerating its indoor units) with exactly
that empty-looking 2.05, and it was nearly written off as a dead end.

The read path now returns the decoded body alongside `rep`, and the service
response carries it as `body` whenever it isn't the map `rep` already has --
omitted for the ordinary case rather than duplicating every rep in every
response. Records the probe round this came out of: indexed leaves 4.04 on
that board, and the UUID prefix confirmed routable by a positive control, so
Patterns A/B/C are ruled out there on evidence rather than on absence.
2026-08-13 02:43:12 +00:00
Marc Billow 11c71a62e8 docs: where else a composite AC's sibling hrefs could live (#335)
Issue #335's board reports a sibling in subdeviceIdList and then 4.04s on
all 26 seeds enumerate_subdevices tries, which reads like "there is nothing
there". Comparing every captured /oic/res in the corpus says otherwise: only
the ARTIK051_DONGLE_FAC_18K board advertises its operational tree at all.
The other five list the onboarding surface and stop -- the range board hides
a live /device/1 behind a ten-link /oic/res -- so an href's absence from
/oic/res is not evidence, and Pattern A's index scan is dead weight
everywhere except the board it was written against.

What that leaves untried is the bare indexed leaf: every indexed href this
project has ever seen arrived inside a /device/<n> batch, and /device/1
4.04ing is evidence about the Collection, not about /mode/vs/1. Leaves
without their Collection is already confirmed BORA behavior in the other
namespace (issue #205). Records the probe list, the OCF composite-device
clause that suggests /sec/devices, a positive control for whether the UUID
prefix routes at all, and the dead ends worth not re-treading.
2026-08-13 02:43:11 +00:00
Marc Billow 6d73ac8694 Bump version to 0.21.1 2026-08-13 02:31:48 +00:00
Marc Billow 1cfe126313 Merge pull request #360 from mbillow/claude/issue-357-5bsr88
laundry: add Table_00 cycle labels for WF45R6300 washer and DVE45R6300 dryer
2026-08-12 11:28:59 -04:00
Marc Billow 8d1ecb4f2a laundry: add Table_00 cycle labels for WF45R6300 washer and DVE45R6300 dryer
Adds washer_cycle_table_00 and dryer_cycle_table_00 translation catalog
entries, confirmed by the issue #357 reporter selecting each cycle on a
WF45R6300AW/US washer and DVE45R6300W/A3 dryer and reading back the raw
course code. Table_00 is a separate, older course-code family from the
existing Table_02/Table_03 catalogs -- laundry.cycle_select's table_href
scoping already keeps them apart, so this is a translations-only change.

Table_00 was previously used only as an example of an unconfirmed table in
tests; those now use Table_99 for that role, and new tests assert the
confirmed codes translate and that the resolved key routes to the new
table-scoped catalog entries.

Mirrored to all shipped languages (cs/de/es/it/ko/nl) to keep
tests/test_translations.py's key-for-key invariant.
2026-08-12 15:25:54 +00:00
Marc Billow 0c1231794a Merge pull request #359 from mbillow/claude/pr-346-regression-debug-a3x3pj
laundry: a post-Finish running stage is the cycle ending, not a new one
2026-08-12 10:43:47 -04:00
Marc Billow 67b28ed10f laundry: cite the machine_state history confirming #358's tail
The reporter's machine_state history for the same two cycles flips to
idle on the exact second progress reads 'Drying' (12:08:51 and 14:01:26),
which settles what the previous commits had to infer: rep_fn returns
'Idle' whenever state isn't active, so it cannot have produced that
value, and the only remaining path was the ungated sticky_live_fn the
bypass returned in its place. Replaying the sequence against the pre-fix
path reproduces the reported Cooling, Finish, Drying, Idle exactly; the
fix holds Finish through it.

Comments only -- swap the inference for the observation that confirms it.
2026-08-12 14:39:18 +00:00
Marc Billow f12f67b2b3 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.
2026-08-12 14:18:56 +00:00
Marc Billow 2f7170c448 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.
2026-08-12 13:44:24 +00:00
Marc Billow b8ef430ed4 Merge pull request #356 from mbillow/claude/alert-read-action-guidance-2lwu5a
Fix stuck alarm_code: never merge /alarms/vs/0 onto stale cache
2026-08-11 22:43:23 -04:00
Marc Billow cd3a47f9a4 Fix stuck alarm_code: never merge /alarms/vs/0 onto stale cache (#348)
ObserveManager.apply() shallow-merges every incoming rep onto whatever's
already cached for that href (issue #27's fix for /mode/vs/0's partial
notifies). That assumes an absent key always means "unchanged, keep the
old value" -- true for /mode/vs/0's supportedOptions, but backwards for
/alarms/vs/0: entity.py already documents {} as this resource's
canonical no-alarm state, and a live read_resource GET on the reporter's
washer confirmed the board sends exactly that {} when an alarm clears.
Merging it onto the prior rep left the stale ErrorCode_DC entry in the
cache forever, surviving power cycles and only clearing on a full
integration reload (which rebuilds the cache from scratch instead of
merging).

Add _is_alarms_href() to recognize /alarms/vs/<index> across every
subdevice-translated shape (MAIN identity, indexed renumbering, prefixed
UUID -- Subdevice.to_actual never touches the 'alarms/vs' stem) and have
apply() fully replace the cache for that href instead of merging. This
is a global fix: every family with an alarm sensor shares this href
(common.ALARMS, range_hood's own copy), so they were all exposed.
2026-08-12 02:41:02 +00:00
25 changed files with 805 additions and 104 deletions
+1 -1
View File
@@ -159,7 +159,7 @@ data:
href: /mode/vs/0
```
returning `{"href", "actual_href", "code", "raw_code", "rep"}` off a **live GET straight from the device**, not the cache — which can be up to a poll interval stale, exactly the staleness that would make `held` above meaningless. Omit `href` and you get `{"resources": {href: rep, ...}}`, the cached snapshot of everything this integration currently tracks on that device, with no GET at all — useful for seeing what's there before you start writing to it, without hammering the appliance.
returning `{"href", "actual_href", "code", "raw_code", "rep"}` off a **live GET straight from the device**, not the cache — which can be up to a poll interval stale, exactly the staleness that would make `held` above meaningless. A sixth key, `body`, appears only when the response isn't a Property map: a Collection (`/device/0`, and the `x.com.samsung.devcol` siblings some boards expose) answers a CBOR list, which `rep` can't carry, and which would otherwise read as an accepted-but-empty resource. Omit `href` and you get `{"resources": {href: rep, ...}}`, the cached snapshot of everything this integration currently tracks on that device, with no GET at all — useful for seeing what's there before you start writing to it, without hammering the appliance.
The **Debug write** panel under a device's Configure menu (Part 4) is the friendlier single-write path over this same machinery — pick an href, type a payload, see the result — for when you don't need a sequence.
+22 -7
View File
@@ -530,7 +530,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
single missed read is not worth surfacing.
"""
try:
code, rep = await self.async_raw_read(cloudcourse.COURSE_HREF)
code, rep, _body = await self.async_raw_read(cloudcourse.COURSE_HREF)
except Exception:
# One missed probe; the caller is a retry loop.
self._log.debug("cloud-course probe failed", exc_info=True)
@@ -1576,12 +1576,24 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._log.debug("raw write follow-up read failed: %s", e)
return code, new_rep
def _raw_read_blocking(self, path_segs: list[str], href: str) -> tuple[int, dict]:
def _raw_read_blocking(self, path_segs: list[str], href: str) -> tuple[int, dict, Any]:
"""Debug primitive: a live GET, deliberately bypassing the cache
(issue #300) -- the cache can be up to a poll interval stale,
exactly the staleness that makes testing whether a write held or
got silently reverted by the board unreliable. Blocking -- runs in
executor."""
executor.
Returns `(code, rep, body)`. `rep` is the decoded body only when it
is a Property map, since that's the shape the observe cache and
every capability are written against. `body` is whatever CBOR
actually decoded to, and exists because a Collection answers a
*list*, not a map: `/device/0` and its `x.com.samsung.devcol`
siblings return the `[devcol rep, {href, rep}, ...]` batch
`parse_device0_batch` reads. Reporting only `rep` rendered those as
an accepted-but-empty `2.05 {}`, which reads as "the resource is
there and has nothing in it" -- the opposite of what a full batch
means, and how issue #335's `/sec/devices` was nearly written off.
"""
if self._session is None:
self._connect_session()
sess = self._session
@@ -1589,6 +1601,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
raise RuntimeError("no session")
code, payload = sess.get(path_segs, timeout=10.0)
rep: dict = {}
body: Any = None
if code == 0x45 and payload:
try:
body = cbor2.loads(payload)
@@ -1598,11 +1611,13 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if isinstance(body, dict):
self._observe.apply(href, body, source="poll")
rep = body
return code, rep
return code, rep, body
async def async_raw_read(self, href: str) -> tuple[int, dict]:
async def async_raw_read(self, href: str) -> tuple[int, dict, Any]:
"""Debug-only live GET (issue #300, backs the read_resource
service). Same href validation as async_raw_write."""
service). Same href validation as async_raw_write. Three-tuple --
see `_raw_read_blocking` for why the raw body comes back alongside
the Property-map `rep`."""
path_segs = _href_to_path_segs(href)
if not path_segs:
raise ServiceValidationError(
@@ -1721,7 +1736,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
verified: dict[str, Any] = {}
async with self._session_lock:
for href in dict.fromkeys(r["href"] for r in results):
vcode, vrep = await self.hass.async_add_executor_job(
vcode, vrep, _vbody = await self.hass.async_add_executor_job(
self._raw_read_blocking, _href_to_path_segs(href), href
)
# None, not False, when the re-read brought back nothing
+1 -1
View File
@@ -12,5 +12,5 @@
"pyOpenSSL>=23.0",
"smartthings-local>=0.1.2"
],
"version": "0.21.0"
"version": "0.21.2"
}
+35 -1
View File
@@ -56,6 +56,26 @@ SUCCESS_FRACTION = 0.8
PUSH_HEALTH_WINDOW_S = 60.0
def _is_alarms_href(href: str) -> bool:
"""True for /alarms/vs/<index> in any subdevice-translated shape --
the canonical MAIN form (/alarms/vs/0), an indexed subdevice's
renumbered instance (/alarms/vs/<key>), or a prefixed subdevice's
UUID-qualified form (/<uuid>/alarms/vs/0). `Subdevice.to_actual`
(registry/subdevices.py) only ever rewrites the trailing index
segment or prepends a prefix -- it never touches the 'alarms/vs'
stem -- so matching that fixed segment plus a wildcard tail catches
every shape without this module needing to be subdevice-aware.
See `ObserveManager.apply`'s use of this for why the href matters:
unlike most resources, /alarms/vs/0's `x.com.samsung.da.items` array
is a complete snapshot of every currently-active alarm, not a
possibly-partial field update -- so it must never be merged onto a
stale prior rep (issue #348).
"""
head, _, _ = href.rpartition("/")
return head.endswith("/alarms/vs")
class ObserveManager:
"""Per-device observe-mode state: mode, write-settle guard, and (later)
subscription/staleness tracking. Pure sync logic — safe to call from
@@ -122,6 +142,20 @@ class ObserveManager:
comes through, even though nothing about the device's actual
supported options changed.
`_is_alarms_href` is the one exception to that merge (issue #348):
/alarms/vs/0's `items` array is always sent as a complete
snapshot of every currently-active alarm, never a partial delta
-- confirmed by a live `read_resource` GET returning `{}` (no
`items` key at all) the moment a washer's board actually clears
an alarm, which entity.py already documents as this resource's
normal no-alarm shape. Merging that `{}` onto the prior rep the
same way as everywhere else silently kept the stale `items`
entry forever: an absent key merges as "unchanged" everywhere
else, but on this href absent specifically means "cleared".
Every family that exposes an alarm sensor shares this href
(common.ALARMS, range_hood's own copy), so this is a full
replace for all of them, not a washer-specific carve-out.
`apply()` is the sole path StateCache mutations flow through in
this component (poll, sweep, and OBSERVE notify all funnel here),
so `_cache_lock` serializes the read-then-write across those
@@ -149,7 +183,7 @@ class ObserveManager:
self.log.debug("dropping %s update for %s (settling)", source, href)
return False
with self._cache_lock:
merged = {**(self.cache.get(href) or {}), **rep}
merged = dict(rep) if _is_alarms_href(href) else {**(self.cache.get(href) or {}), **rep}
changed = self.cache.apply_rep(href, merged, source=source)
# Outside the cache lock -- the hook takes locks of its own and
# never reads the cache back. `source` is passed along rather than
@@ -46,6 +46,13 @@ DRYER_SETTINGS = Capability(
# (issue #244). /st/dryercourse/vs/0 re-encodes the same selected course
# and is ignored (ignored.py), mirroring /st/washercourse/vs/0 for washers.
#
# dryer_cycle_table_00 is a separate, older course-code family reported by
# a DVE45R6300W/A3 (issue #357), confirmed the same way: the reporter
# selected each cycle on the appliance and read back the resulting raw
# code. It shares no codes with Table_03 above -- 'a5' Bedding here and
# '01' Normal are both table-scoped, so a Table_03 dryer never picks up a
# Table_00 label or vice versa (see laundry.cycle_select's table_href).
#
# Drum Clean+ maintenance tracking (issue #258) reuses washer.py's
# DrumCleanProposal_/WashingTimes_/DrumCleanLog_ tokens on this same
# options[] array -- see laundry.drum_clean_cycles_remaining/
@@ -62,17 +62,21 @@ def _just_finished(rep):
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."""
def _new_cycle_running(rep):
"""progress/progress_percentage's sticky_bypass_fn: drop the #345 hold
early once a new cycle is genuinely running.
Gated on `state == 'active'`, unlike _just_finished's arm condition
above: issue #358's dryer resets `progress` to its course's first
stage ('Drying') in the same moment `state` goes idle, ~4s before
settling to 'None' -- confirmed by the reporter's machine_state
history, which flips to idle on the exact second progress reads
'Drying', in both captured cycles. A bypass keyed on the progress
code alone read that as a new cycle and republished it. Releasing
late costs nothing -- an unreleased hold still expires on its own --
so this side takes the stronger signal."""
v = rep.get("x.com.samsung.da.progress")
return v is not None and v not in ("None", "Finish")
return _state_is_active(rep) and v is not None and v not in ("None", "Finish")
def _remaining_seconds(raw):
@@ -190,14 +194,10 @@ OPERATIONAL_STATE = Capability(
# 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.
# see sensor.py's _apply_sticky. rep_fn below is unchanged and
# stays the only definition of a live value -- the hold decides
# only *whether* to freeze. A second, ungated one here is what
# let issue #358's post-Finish tail reach the entity.
SensorDesc(
key="progress",
icon="mdi:progress-wrench",
@@ -208,8 +208,7 @@ OPERATIONAL_STATE = Capability(
),
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,
sticky_bypass_fn=_new_cycle_running,
),
SensorDesc(
key="progress_percentage",
@@ -222,8 +221,7 @@ OPERATIONAL_STATE = Capability(
),
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,
sticky_bypass_fn=_new_cycle_running,
),
# Only show finish time while actively running -- firmware leaves a
# stale remainingTime after a cycle ends, frozen at '00:01:00'.
@@ -58,6 +58,15 @@ from .laundry import (
# the owner or device metadata falls back to washer_cycle_fallback, which
# surfaces a personal-course name only -- no invented English label for an
# unrecognized standard code (PR #251 review).
#
# washer_cycle_table_00 (issue #357) is a separate, older course-code family
# reported by a WF45R6300AW/US -- confirmed by the reporter selecting each
# cycle on the appliance and reading back the raw code, the same method used
# for Table_02's WF50A8600AV/US codes above. A device reporting Table_00 with
# an unconfirmed code (FlexWash's washer_flexwash_device fixture, for
# instance) still renders that code raw rather than borrowing a Table_02
# label -- the two tables are unrelated code spaces despite a handful of
# overlapping hex values.
# ---------------------------------------------------------------------------
# /washer/vs/0 -- wash temperature, spin speed, rinse cycle count.
@@ -65,15 +65,15 @@ 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/live/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 forces
# sticky_live_fn's result through and drops the hold; sticky_seconds
# bounds how long it can hold.
# 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
# bounds how long it can hold. There is deliberately no hook for
# computing a live value differently from rep_fn -- see issue #358.
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
+32 -35
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):
@@ -82,52 +82,49 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity):
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.
`sticky_fn`/`sticky_bypass_fn` read this href's live rep rather
than the already-computed `raw`, so they can key on fields rep_fn
has collapsed away -- but they never compute a value. `raw` and
the frozen `sticky_value` are the only things returned here, so a
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 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".
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`, 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.
`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". 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()
matches = desc.sticky_fn(rep)
if matches:
if not self._sticky_armed:
if desc.sticky_fn(rep):
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
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
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
if self._sticky_until is not None and now < self._sticky_until:
return self._sticky_value
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):
"""Hold the last value this entity actually reported until a new one
+8 -1
View File
@@ -179,7 +179,7 @@ async def _async_read_resource(hass: HomeAssistant, call: ServiceCall) -> Servic
# Same normalize-before-translate order as the write path above.
canonical = normalize_href(href)
actual_href = subdevice.to_actual(canonical)
code, rep = await coordinator.async_raw_read(actual_href)
code, rep, body = await coordinator.async_raw_read(actual_href)
read_result: dict[str, Any] = {
"href": canonical,
"actual_href": actual_href,
@@ -187,6 +187,13 @@ async def _async_read_resource(hass: HomeAssistant, call: ServiceCall) -> Servic
"raw_code": code,
"rep": rep,
}
# `body` only when it isn't the Property map already in `rep` -- a
# Collection (`/device/0`, `/sec/devices`) answers a CBOR list, which
# `rep` can't carry and which used to vanish into an empty-looking
# 2.05 (issue #335). Omitted for the ordinary map case rather than
# duplicating every rep in every response.
if body is not None and not isinstance(body, dict):
read_result["body"] = body
return cast(ServiceResponse, read_result)
@@ -324,6 +324,22 @@
"4": "Alarm 4"
}
},
"dryer_cycle_table_00": {
"name": "Cyklus",
"state": {
"01": "Normální",
"9c": "Intenzivní",
"a5": "Ložní prádlo",
"9e": "Nežehlivé prádlo",
"9b": "Parní dezinfekce+",
"27": "Osvěžení",
"a0": "Provětrání",
"a4": "Časové sušení",
"a6": "Rychlé sušení",
"a3": "Sportovní oblečení",
"a2": "Jemné prádlo"
}
},
"dryer_cycle_table_03": {
"name": "Cyklus",
"state": {
@@ -604,6 +620,22 @@
"extra_hot": "Extra horká"
}
},
"washer_cycle_table_00": {
"name": "Cyklus",
"state": {
"01": "Normální",
"70": "Intenzivní",
"55": "Bílé prádlo",
"71": "Ložní prádlo",
"72": "Dezinfekce",
"77": "Nežehlivé prádlo",
"57": "Samočištění+",
"73": "Máchání + odstřeďování",
"74": "Sportovní oblečení",
"75": "Jemné prádlo",
"78": "Rychlé praní"
}
},
"washer_cycle_table_02": {
"name": "Cyklus",
"state": {
@@ -324,6 +324,22 @@
"4": "Alarm 4"
}
},
"dryer_cycle_table_00": {
"name": "Programm",
"state": {
"01": "Normal",
"9c": "Intensiv",
"a5": "Bettwäsche",
"9e": "Pflegeleicht",
"9b": "Dampf-Hygiene+",
"27": "Auffrischen",
"a0": "Lüften",
"a4": "Zeittrocknen",
"a6": "Schnelltrocknen",
"a3": "Sportkleidung",
"a2": "Feinwäsche"
}
},
"dryer_cycle_table_03": {
"name": "Programm",
"state": {
@@ -567,6 +583,22 @@
"extra_hot": "Extra heiß"
}
},
"washer_cycle_table_00": {
"name": "Programm",
"state": {
"01": "Normal",
"70": "Intensiv",
"55": "Weißwäsche",
"71": "Bettwäsche",
"72": "Hygienespülung",
"77": "Pflegeleicht",
"57": "Selbstreinigung+",
"73": "Spülen + Schleudern",
"74": "Sportkleidung",
"75": "Feinwäsche",
"78": "Schnellwäsche"
}
},
"washer_cycle_table_02": {
"name": "Programm",
"state": {
@@ -324,6 +324,22 @@
"4": "Alarm 4"
}
},
"dryer_cycle_table_00": {
"name": "Cycle",
"state": {
"01": "Normal",
"9c": "Heavy Duty",
"a5": "Bedding",
"9e": "Perm Press",
"9b": "Steam Sanitize+",
"27": "Refresh",
"a0": "Air Fluff",
"a4": "Time Dry",
"a6": "Quick Dry",
"a3": "Active Wear",
"a2": "Delicates"
}
},
"dryer_cycle_table_03": {
"name": "Cycle",
"state": {
@@ -604,6 +620,22 @@
"extra_hot": "Extra hot"
}
},
"washer_cycle_table_00": {
"name": "Cycle",
"state": {
"01": "Normal",
"70": "Heavy Duty",
"55": "Whites",
"71": "Bedding",
"72": "Sanitize",
"77": "Perm Press",
"57": "Self Clean+",
"73": "Rinse + Spin",
"74": "Active Wear",
"75": "Delicates",
"78": "Quick Wash"
}
},
"washer_cycle_table_02": {
"name": "Cycle",
"state": {
@@ -517,6 +517,22 @@
"4": "Alarma 4"
}
},
"dryer_cycle_table_00": {
"name": "Ciclo",
"state": {
"01": "Normal",
"9c": "Servicio intensivo",
"a5": "Ropa de cama",
"9e": "Planchado fácil",
"9b": "Desinfección por vapor+",
"27": "Renovar",
"a0": "Aireación",
"a4": "Secado por tiempo",
"a6": "Secado rápido",
"a3": "Ropa deportiva",
"a2": "Delicados"
}
},
"dryer_cycle_table_03": {
"name": "Ciclo",
"state": {
@@ -797,6 +813,22 @@
"extra_hot": "Muy caliente"
}
},
"washer_cycle_table_00": {
"name": "Ciclo",
"state": {
"01": "Normal",
"70": "Servicio intensivo",
"55": "Blancos",
"71": "Ropa de cama",
"72": "Desinfección",
"77": "Planchado fácil",
"57": "Autolimpieza+",
"73": "Aclarar + Centrifugar",
"74": "Ropa deportiva",
"75": "Delicados",
"78": "Lavado rápido"
}
},
"washer_cycle_table_02": {
"name": "Ciclo",
"state": {
@@ -324,6 +324,22 @@
"4": "Allarme 4"
}
},
"dryer_cycle_table_00": {
"name": "Ciclo",
"state": {
"01": "Normale",
"9c": "Intenso",
"a5": "Biancheria da letto",
"9e": "Pronto da stirare",
"9b": "Igienizzante a vapore+",
"27": "Rinfresca",
"a0": "Arieggiatura",
"a4": "Asciugatura a tempo",
"a6": "Asciugatura rapida",
"a3": "Abbigliamento sportivo",
"a2": "Delicati"
}
},
"dryer_cycle_table_03": {
"name": "Ciclo",
"state": {
@@ -604,6 +620,22 @@
"extra_hot": "Extra caldo"
}
},
"washer_cycle_table_00": {
"name": "Ciclo",
"state": {
"01": "Normale",
"70": "Intenso",
"55": "Bianchi",
"71": "Biancheria da letto",
"72": "Igienizzante",
"77": "Pronto da stirare",
"57": "Self Clean+",
"73": "Risciacquo+Centrifuga",
"74": "Abbigliamento sportivo",
"75": "Delicati",
"78": "Lavaggio rapido"
}
},
"washer_cycle_table_02": {
"name": "Ciclo",
"state": {
@@ -324,6 +324,22 @@
"4": "알림음 4"
}
},
"dryer_cycle_table_00": {
"name": "코스",
"state": {
"01": "표준건조",
"9c": "강력건조",
"a5": "이불",
"9e": "구김방지",
"9b": "스팀살균+",
"27": "리프레시",
"a0": "송풍",
"a4": "시간건조",
"a6": "쾌속건조",
"a3": "운동복",
"a2": "섬세의류"
}
},
"dryer_cycle_table_03": {
"name": "코스",
"state": {
@@ -604,6 +620,22 @@
"extra_hot": "고온수"
}
},
"washer_cycle_table_00": {
"name": "코스",
"state": {
"01": "표준세탁",
"70": "강력세탁",
"55": "흰옷",
"71": "이불",
"72": "살균",
"77": "구김방지",
"57": "통세척+",
"73": "헹굼+탈수",
"74": "운동복",
"75": "섬세의류",
"78": "쾌속세탁"
}
},
"washer_cycle_table_02": {
"name": "코스",
"state": {
@@ -324,6 +324,22 @@
"4": "Alarm 4"
}
},
"dryer_cycle_table_00": {
"name": "Programma",
"state": {
"01": "Normaal",
"9c": "Intensief",
"a5": "Beddengoed",
"9e": "Strijkvrij",
"9b": "Stoomhygiëne+",
"27": "Opfrissen",
"a0": "Luchtdrogen",
"a4": "Tijdprogramma",
"a6": "Snel drogen",
"a3": "Sportkleding",
"a2": "Fijne was"
}
},
"dryer_cycle_table_03": {
"name": "Programma",
"state": {
@@ -604,6 +620,22 @@
"extra_hot": "Extra heet"
}
},
"washer_cycle_table_00": {
"name": "Programma",
"state": {
"01": "Normaal",
"70": "Intensief",
"55": "Witte was",
"71": "Beddengoed",
"72": "Hygiëne",
"77": "Strijkvrij",
"57": "Self Clean+",
"73": "Spoelen + centrifugeren",
"74": "Sportkleding",
"75": "Fijne was",
"78": "Snelle was"
}
},
"washer_cycle_table_02": {
"name": "Programma",
"state": {
@@ -0,0 +1,184 @@
# Composite AC subdevices: where else a sibling's hrefs could live
Open question behind issue #335 (`ARTIK051_FAC_BORA_19K`, a 2-in-1 floor +
wall AC): the board reports a sibling in `/subdevices/vs/0`'s
`subdeviceIdList`, but every seed `registry/subdevices.enumerate_subdevices`
tries comes back 4.04, so `subdevices` and `subdevices_skipped` are both
empty and the wall unit never becomes an entity.
This file records what that actually rules out (less than it looks like),
why, and which hrefs are worth reading next.
## `/oic/res` does not enumerate the resource tree on modern firmware
This is the finding that reopens the question. Across every fixture that
carries a captured `/oic/res`:
| board | links | `sec:true` | lists `/device/0`? |
| --- | --- | --- | --- |
| `ARTIK051_DONGLE_FAC_18K` | 91 | 78 | yes |
| `TP2X_FAC_BORA_21K` (2-in-1) | 17 | 6 | no |
| `TP2X_FAC_BORA_21K` (#205 flat) | 17 | 6 | no |
| `TP1X_DA_KS_RANGE_0101X` | 10 | 6 | no |
| `AWM-WW-AID-26-ONEBODY` | 15 | 9 | no |
| `ARTIK051_FAC_BORA_19K` (#335) | 18 | 6 | no |
The `ARTIK051_DONGLE_FAC_18K` board — the one Pattern A was built against —
is the outlier, not the model. Everywhere else `/oic/res` lists the
onboarding surface and nothing else: `/oic/d`, `/oic/p`, the security and
EasySetup/WiFiConf/CoapCloudConf/DevConf resources, file transfer, and the
`sec/*` pair. On issue #335's board the six `sec:true` links are exactly
doxm, pstat and the four setup URIs; every other listed link is `sec:false`.
The entire secure operational tree — `/device/0` included, which
demonstrably answers, since the dump comes from it — is absent.
Two consequences, both load-bearing:
1. **Nothing is learned from an href's absence in `/oic/res`.** On the range
board (issue #324) `/oic/res` lists ten onboarding links and no
`/device/0`, yet `/device/1` answers a full indexed dual-cavity sibling.
It was found only by `_SPECULATIVE_DEVICE_INDICES`, never by enumeration
of the links.
2. **Pattern A's `/oic/res` index scan is dead weight on these boards.** It
contributes nothing anywhere except the dongle board, so in practice
indexed siblings are found by the speculative `/device/1`, `/device/2`
probe alone.
## What issue #335 has actually ruled out
All 26 probes in the report returned false. Twenty-three of them are the
issue #205 flat fallback walking the master's own href list under the
sibling's UUID prefix, plus `/<uuid>/device/0`, `/device/1`, `/device/2`
and `/multidevice/vs/0`. Four more were read by hand from the issue thread
(`/<uuid>/information/vs/{1,2}`, `/<uuid>/device/{1,2}`), all 4.04.
So what is ruled out is: the UUID-prefixed namespace (Pattern B/C), and the
indexed **Collection** (`/device/<n>`). What has never been read on this
board — or on any `FAC_BORA` board — is **a bare indexed leaf**:
`/mode/vs/1`, `/temperatures/vs/1`, and friends. Every indexed href ever
probed by this project arrived via a `/device/<n>` batch; none was ever
GETed directly.
That gap matters because the "leaves exist, their Collection does not" shape
is already confirmed on this exact product family, just in the other
namespace: issue #205's `TP2X_FAC_BORA_21K` answers
`/<uuid>/information/vs/0` while `/<uuid>/device/0` comes back empty. A
board that mounts sibling leaves without mounting a sibling Collection is
the documented BORA behavior, so `/device/1`'s 4.04 is evidence about the
Collection and not about `/mode/vs/1`.
## What the OCF spec says about composite devices
The Core/Device specifications model this as a *Composite Device*: one
Platform representing the whole appliance, `/oic/d` carrying the Device
Types of every constituent Device, and — the relevant part — a **Collection
per distinct Device in the composition**, each Collection's `rt` including
the Device Type it represents.
Issue #335's `/oic/d` reports `["oic.wk.d", "oic.d.airconditioner"]`, which
is consistent with a two-indoor-unit composite (both constituents are air
conditioners, so the type appears once) and equally consistent with a single
unit. It does not discriminate.
The Collection half does suggest something untried. `x.com.samsung.devcol`
is Samsung's Collection type, carried by `/device/0` — and on the dongle
board `/oic/res` advertises a second resource with the same
`["x.com.samsung.devcol", "oic.wk.col"]` pair: **`/sec/devices`**. A
collection of devices, sitting alongside `/device/0`, never read by this
project or by any issue thread. If the composite enumeration is exposed
anywhere as a first-class resource, that is the shape it would take.
## Results of the second probe round
The reporter ran these live. Three answers, all informative.
**Indexed leaves do not exist.** `/information/vs/1`, `/power/vs/1`,
`/mode/vs/1` → 4.04. Pattern A is ruled out on this board properly now:
not just the `/device/1` Collection, but the leaf namespace it would have
carried.
**The UUID prefix routes, and is empty of operational resources.** The
control pair settles it:
/c24e25e9-.../file/list/vs/0 → 2.05, two items
/file/list/vs/0 → 2.05, the same two items
(/opt/data/energy.db, /opt/data/hass.db)
So the sibling's prefix is a live, routed namespace — the 23 flat-fallback
4.04s under it are the firmware answering "no such resource", not a dead
prefix swallowing everything. Pattern B/C is ruled out on this board on
positive evidence rather than on absence. That the two listings are
identical is expected either way: one board, one flash, one filesystem.
**`/sec/devices` exists — and this project could not see what's in it.**
It answered `2.05` with `rep: {}`, which reads as "the resource is there and
has nothing in it". It is not. `coordinator._raw_read_blocking` decoded the
CBOR body and then kept it *only if it was a Property map*:
```python
if isinstance(body, dict):
rep = body
```
A Collection answers a **list** — the `[devcol rep, {href, rep}, ...]` batch
`parse_device0_batch` reads. `/device/0` itself would have rendered exactly
the same accepted-but-empty `2.05 {}` through `read_resource`. Fixed: the
read path now returns the decoded body alongside `rep`, and the service
response carries it as `body` whenever it isn't the map already in `rep`.
`/sec/devices` therefore remains the one open lead, and needs one re-read on
a build carrying that fix.
## Still worth reading
**1 — `/sec/devices`, again.** Same `x.com.samsung.devcol` + `oic.wk.col`
pair as `/device/0`, so its body should be a batch naming its members. If a
composite enumeration is exposed anywhere, it is here.
**2 — the file-transfer pair.** `/oic/res` advertises
`/c24e25e9-.../file/transfer/vs/0` alongside the master's, and the prefix is
now known to route. Issue #301 documents the shape: a baseline GET returns
one item, `x.com.samsung.name` plus `x.com.samsung.blob`, no write needed to
see whatever it currently serves. If the prefixed endpoint serves *different
bytes* than the master's, that is the first hard local evidence the wall
unit exists as a data producer, and `/opt/data/energy.db` would be where its
runtime history lives.
/file/transfer/vs/0
/c24e25e9-55dd-ba18-d567-000000000001/file/transfer/vs/0
Mind the blob: #301 measured 2172 B on a `KRAC_18K`, and a raw `bytes` value
in a service response is not guaranteed to survive rendering in Developer
Tools. Ask for `x.com.samsung.name` and whether a blob field appears, not
for the blob pasted into a comment.
## Dead ends, so they aren't re-tried
- `/hass/state/vs/0`, `/hass/command/vs/0` — advertised in `/oic/res` on
every board here, and indexed per subdevice on the dongle board
(`/hass/state/vs/{0,1,2}`), which makes them look like a subdevice-aware
state channel. They are not: 4.04 on every interface on
`ARTIK051_KRAC_18K` (see `ac-filter-reset.md`). Cheap enough to retry once
on #335's newer build, but expect nothing.
- `/multidevice/vs/0` — probed, 4.04. Absent on this board; only the dongle
family exposes it.
- `/actions/vs/0` — GET returns `{}` on baseline and `oic.if.a`; publishes
no schema (`ac-filter-reset.md`).
## Where this lands if `/sec/devices` is empty too
Then the sibling is named in `subdeviceIdList` for the cloud's benefit and
has no local operational surface at all on this firmware — every namespace
it could occupy has now been read directly, and the UUID one was confirmed
routable first, so the negatives mean what they say. That closes issue #335
as a firmware limitation rather than leaving it open against a probe
strategy that was never actually exercised.
Worth keeping in view for the enumeration code either way: both remaining
patterns hinge on a Collection, and this board answers neither `/device/1`
nor a prefixed `/device/0`. An indexed flat-probe fallback — the mirror of
issue #205's prefixed one, gated on a board that claims a sibling but
materialized nothing — would have cost 8 round trips here and returned the
same 4.04s the reporter got by hand. It is worth building only if some
other board turns out to serve indexed leaves without their Collection;
this one does not.
+43
View File
@@ -58,6 +58,49 @@ def test_apply_merges_partial_update_onto_prior_rep():
assert cached["x.com.samsung.da.supportedOptions"] == ["CV_FDR_WINE", "CV_FDR_MEAT"]
def test_apply_fully_replaces_alarms_href_instead_of_merging():
"""Regression test for issue #348: /alarms/vs/0's `items` array is a
complete snapshot of every currently-active alarm, not a partial field
update like /mode/vs/0 (issue #27). A washer's board reports a cleared
alarm by omitting `items` entirely -- a live read_resource GET showed
`{}` -- so merging that onto the prior rep (as every other href does)
left the stale ErrorCode_DC entry in the cache forever. This must
instead behave like a full replace: the empty rep wins outright."""
mgr = _manager()
active = {
"x.com.samsung.da.items": [
{"x.com.samsung.da.code": "ErrorCode_DC", "x.com.samsung.da.state": "Created"}
]
}
mgr.apply("/alarms/vs/0", active, source="poll")
assert mgr.cache.get("/alarms/vs/0") == active
cleared = mgr.apply("/alarms/vs/0", {}, source="poll")
assert cleared is True
assert mgr.cache.get("/alarms/vs/0") == {}
def test_apply_fully_replaces_alarms_href_for_subdevice_shapes():
"""The same full-replace behavior must hold for both hrefs
`Subdevice.to_actual` can produce: an indexed subdevice renumbers only
the trailing '0' (/alarms/vs/1), and a prefixed one prepends a UUID
(/<uuid>/alarms/vs/0) -- neither ever touches the 'alarms/vs' stem
itself (registry/subdevices.py)."""
for href in ("/alarms/vs/1", "/6c2dff6d-ee5c-dad1-6a5e-000000000001/alarms/vs/0"):
mgr = _manager()
mgr.apply(
href,
{"x.com.samsung.da.items": [{"x.com.samsung.da.code": "ErrorCode_UB"}]},
source="poll",
)
cleared = mgr.apply(href, {}, source="poll")
assert cleared is True
assert mgr.cache.get(href) == {}
def test_apply_drops_update_during_settle_window():
mgr = _manager()
mgr.cache.apply_rep("/oven/vs/0", {"a": 1}, source="seed")
+14
View File
@@ -89,6 +89,20 @@ def test_course_bound_to_shared_course_vs_0():
assert desc.rep_fn(rep) == "16"
def test_reported_table_00_course_codes_are_translated():
"""The reporter confirmed these codes on a DVE45R6300W/A3 by selecting
each cycle and reading back the raw course code (issue #357)."""
from custom_components.localthings.catalog import translated_states
desc = next(
e for e in dryer.DRYER_COURSE.entities if e.key == "cycle" and isinstance(e, SelectDesc)
)
table_00 = {"/st/dryercourse/vs/0": {"x.com.samsung.da.st.courseTable": "Table_00"}}
assert desc.translation_key(table_00) == "dryer_cycle_table_00"
confirmed = {"01", "9c", "a5", "9e", "9b", "27", "a0", "a4", "a6", "a3", "a2"}
assert confirmed <= translated_states("select", "dryer_cycle_table_00")
def test_st_dryercourse_is_ignored():
"""/st/dryercourse/vs/0 re-encodes the course exposed via /course/vs/0 and
is globally ignored -- the mirror of /st/washercourse/vs/0."""
+1 -1
View File
@@ -369,7 +369,7 @@ class TestCycleSelectTableGating:
def test_untranslated_table_uses_generic_cycle_key(self):
"""An unknown table does not claim another board's state labels."""
desc = self._desc()
resources = {"/st/washercourse/vs/0": {"x.com.samsung.da.st.courseTable": "Table_00"}}
resources = {"/st/washercourse/vs/0": {"x.com.samsung.da.st.courseTable": "Table_99"}}
assert desc.translation_key(resources) == "cycle"
def test_resolves_to_generic_cycle_when_table_id_is_unknown(self):
+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:
+120 -8
View File
@@ -165,22 +165,134 @@ 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 resets `progress` to its course's
first stage in the same moment `state` goes idle -- observed twice,
identically, as Cooling -> +60s Finish -> +24s 'Drying' -> +4s
settled, with the reporter's machine_state history flipping to idle on
the exact second progress reads 'Drying'. The hold must survive that,
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_an_open_window_forward():
"""Edge-triggering stops a *stuck* Finish from extending the hold; a
progress that flaps out of and back into Finish must not restart it
either, or the bound stops being a bound."""
desc = replace(_PROGRESS_DESC, sticky_seconds=0.3)
sensor, coordinator = _sensor(desc)
_replace(coordinator, state="Ready", progress="Finish")
assert sensor.native_value == "Finish"
for _ in range(3):
time.sleep(0.05)
_replace(coordinator, state="Ready", progress="None")
assert sensor.native_value == "Finish"
_replace(coordinator, state="Ready", progress="Finish")
assert sensor.native_value == "Finish"
# 0.15s of flapping so far -- the window still ends 0.3s after the
# first Finish, not 0.3s after the most recent re-entry.
time.sleep(0.2)
_replace(coordinator, state="Ready", progress="Finish")
assert sensor.native_value == "Idle"
def test_a_finish_after_the_window_closes_does_not_re_arm_it():
"""Expiry doesn't re-open the door: with no new cycle in between, a
second Finish is the same Finish. Re-arming on it would strobe the
entity Finish -> Idle -> Finish once per window, re-firing exactly the
announcements #345 is about -- so it takes a bypass (a cycle actually
running) to make the hold available again.
Distinct from the flap test above: there the window is still open, and
the last read before expiry leaves the sticky condition *matching*.
Here it has already closed, and the flap ends on a non-matching read,
which is the state a spent-on-arm-only guard would let re-arm."""
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.1)
assert sensor.native_value == "Idle"
_replace(coordinator, state="Ready", progress="None")
assert sensor.native_value == "Idle"
_replace(coordinator, state="Ready", progress="Finish")
assert sensor.native_value == "Idle"
# A real cycle in between is what makes it available again.
_replace(coordinator, state="Run", progress="Drying")
assert sensor.native_value == "Drying"
_replace(coordinator, state="Run", progress="Finish")
assert sensor.native_value == "Finish"
_replace(coordinator, state="Ready", progress="None")
assert sensor.native_value == "Finish"
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
+29 -2
View File
@@ -56,11 +56,16 @@ class _FakeSession:
self._post_code = post_code
self._get_reps: dict[str, list[dict]] = {}
def queue_get(self, href: str, rep: dict) -> None:
def queue_get(self, href: str, rep: dict | list) -> None:
"""Queue one more canned rep for `href`'s next GET. Once an href's
queue is down to one entry, that entry keeps answering every
further GET -- a test only needs to queue the values that
actually change across calls."""
actually change across calls.
A list models a Collection's answer (the `[devcol rep, {href, rep},
...]` batch), which is not a Property map and so is a shape the
read path has to carry separately -- see the collection test below.
"""
self._get_reps.setdefault(href.strip("/"), []).append(rep)
def post(self, path_segs, payload, timeout=None):
@@ -576,6 +581,28 @@ async def test_read_resource_with_href_does_live_get(hass, coordinator, device_i
assert response["href"] == "/mode/vs/0"
assert response["actual_href"] == "/mode/vs/0"
assert response["rep"] == {"x.field": "live"}
# No duplicate copy of a Property map that `rep` already carries.
assert "body" not in response
async def test_read_resource_surfaces_a_collections_list_body(hass, coordinator, device_id):
"""A Collection answers a CBOR list, not a Property map, so `rep` can't
hold it (issue #335: `/sec/devices` came back as an accepted-but-empty
2.05, which reads as "exists, nothing in it" -- the opposite of what a
populated batch means)."""
batch = [
{"rt": ["x.com.samsung.devcol", "oic.wk.col"]},
{"href": "/mode/vs/0", "rep": {"x.field": "live"}},
]
fake = _FakeSession()
fake.queue_get("sec/devices", batch)
coordinator._session = fake
response = await _call_read(hass, device_id, href="/sec/devices")
assert response["code"] == "2.05"
assert response["rep"] == {}
assert response["body"] == batch
async def test_read_resource_without_href_returns_cached_snapshot_and_does_not_get(
+16 -5
View File
@@ -99,16 +99,18 @@ class TestWasherCourse:
def test_translation_key(self):
"""Table-scoped (issue: course codes aren't guaranteed consistent
across board generations sharing /course/vs/0 -- FlexWash's older
board reports Table_00, not the Table_02 every washer_cycle_table_02
name was confirmed against) -- see laundry.cycle_select. Only a
verified table gets table-specific state translations."""
across board generations sharing /course/vs/0 -- a device reporting
an unrecognized table id must not borrow another board generation's
labels) -- see laundry.cycle_select. Only a verified table gets
table-specific state translations."""
desc = next(e for e in washer.WASHER_COURSE.entities if e.key == "cycle")
assert callable(desc.translation_key)
table_02 = {"/st/washercourse/vs/0": {"x.com.samsung.da.st.courseTable": "Table_02"}}
assert desc.translation_key(table_02) == "washer_cycle_table_02"
table_00 = {"/st/washercourse/vs/0": {"x.com.samsung.da.st.courseTable": "Table_00"}}
assert desc.translation_key(table_00) == "cycle"
assert desc.translation_key(table_00) == "washer_cycle_table_00"
unrecognized = {"/st/washercourse/vs/0": {"x.com.samsung.da.st.courseTable": "Table_99"}}
assert desc.translation_key(unrecognized) == "cycle"
assert desc.translation_key({}) == "cycle"
def test_reads_raw_course_code_from_options_array(self):
@@ -146,6 +148,15 @@ class TestWasherCourse:
}
assert confirmed <= translated_states("select", "washer_cycle_table_02")
def test_reported_table_00_course_codes_are_translated(self):
"""The reporter confirmed these codes on a WF45R6300AW/US by
selecting each cycle and reading back the raw course code
(issue #357)."""
from custom_components.localthings.catalog import translated_states
confirmed = {"01", "70", "55", "71", "72", "77", "57", "73", "74", "75", "78"}
assert confirmed <= translated_states("select", "washer_cycle_table_00")
def test_missing_course_option_returns_none(self):
desc = next(e for e in washer.WASHER_COURSE.entities if e.key == "cycle")
assert desc.rep_fn is not None