Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b85e20108 | ||
|
|
e5cd212a34 | ||
|
|
11c71a62e8 | ||
|
|
6d73ac8694 | ||
|
|
1cfe126313 | ||
|
|
8d1ecb4f2a | ||
|
|
0c1231794a | ||
|
|
67b28ed10f | ||
|
|
f12f67b2b3 | ||
|
|
2f7170c448 | ||
|
|
b8ef430ed4 | ||
|
|
cd3a47f9a4 |
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"pyOpenSSL>=23.0",
|
||||
"smartthings-local>=0.1.2"
|
||||
],
|
||||
"version": "0.21.0"
|
||||
"version": "0.21.2"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user