Merge pull request #356 from mbillow/claude/alert-read-action-guidance-2lwu5a
Fix stuck alarm_code: never merge /alarms/vs/0 onto stale cache
This commit is contained in:
@@ -56,6 +56,26 @@ SUCCESS_FRACTION = 0.8
|
|||||||
PUSH_HEALTH_WINDOW_S = 60.0
|
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:
|
class ObserveManager:
|
||||||
"""Per-device observe-mode state: mode, write-settle guard, and (later)
|
"""Per-device observe-mode state: mode, write-settle guard, and (later)
|
||||||
subscription/staleness tracking. Pure sync logic — safe to call from
|
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
|
comes through, even though nothing about the device's actual
|
||||||
supported options changed.
|
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
|
`apply()` is the sole path StateCache mutations flow through in
|
||||||
this component (poll, sweep, and OBSERVE notify all funnel here),
|
this component (poll, sweep, and OBSERVE notify all funnel here),
|
||||||
so `_cache_lock` serializes the read-then-write across those
|
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)
|
self.log.debug("dropping %s update for %s (settling)", source, href)
|
||||||
return False
|
return False
|
||||||
with self._cache_lock:
|
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)
|
changed = self.cache.apply_rep(href, merged, source=source)
|
||||||
# Outside the cache lock -- the hook takes locks of its own and
|
# Outside the cache lock -- the hook takes locks of its own and
|
||||||
# never reads the cache back. `source` is passed along rather than
|
# never reads the cache back. `source` is passed along rather than
|
||||||
|
|||||||
@@ -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"]
|
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():
|
def test_apply_drops_update_during_settle_window():
|
||||||
mgr = _manager()
|
mgr = _manager()
|
||||||
mgr.cache.apply_rep("/oven/vs/0", {"a": 1}, source="seed")
|
mgr.cache.apply_rep("/oven/vs/0", {"a": 1}, source="seed")
|
||||||
|
|||||||
Reference in New Issue
Block a user