Fix four review findings in the raw write/read services

- services.py: normalize an href before handing it to Subdevice.to_actual.
  That transform is textual and rewrites only a trailing '0' segment, so
  '/mode/vs/0/' passed through it untouched and normalized downstream to
  the master's '/mode/vs/0' -- landing the write on the wrong oven cavity
  while still answering 2.04, with nothing in the response to give it
  away. Same order now on the read path.
- services.py: key `verified` off those same normalized canonicals. It was
  built from un-normalized to_actual output against the coordinator's
  normalized hrefs, so a non-canonical input missed the lookup and handed
  back actual hrefs where the documented contract promises canonical ones.
- coordinator.py: report `held: None` when the verify re-read itself
  didn't come back. A non-2.05 yields an empty rep, against which every
  payload comparison is False, so a 4.04 or dropped read was reported as
  `held: false` -- indistinguishable from the board reverting the write,
  which is the one distinction verify_after exists to draw.
- coordinator.py: on a mid-sequence failure, say how many writes landed
  and which, and still kick the refresh. Raising bare threw that away, and
  the appliance is left holding a partial sequence.

Also documents why `settle` waits inside the session lock while
verify_after's wait deliberately doesn't: a poll landing between two
writes is exactly what the sequence exists to rule out, and the caps
bound the worst case at 10 x 30s.
This commit is contained in:
Marc Billow committed 2026-08-07 22:58:37 +00:00
1 parent cec3dd4a68
commit a6d818dfc0
4 files changed
+223 -36

No files matched your search

+77 -26
View File
@@ -114,6 +114,22 @@ def _href_to_path_segs(href: str) -> list[str]:
return [s for s in str(href).strip("/").split("/") if s]
def normalize_href(href: str) -> str:
"""A user-typed href in the one canonical spelling the rest of the debug
path assumes: exactly one leading slash, no trailing slash, no empty
segments.
Public because services.py has to normalize *before* handing an href to
`Subdevice.to_actual` (issue #300). That transform is purely textual --
an indexed subdevice rewrites only a trailing '0' segment -- so
'/mode/vs/0/' slips through it unchanged and then normalizes here to
'/mode/vs/0', silently landing the write on the master's resource
instead of the subdevice's while still reporting 2.04. Normalizing on
the way in makes the two agree.
"""
return "/" + "/".join(_href_to_path_segs(href))
def _coap_code_str(code: int) -> str:
"""Raw CoAP response code -> its 'C.DD' rendering (e.g. 0x44 -> '2.04'),
the class/detail split RFC 7252 §12.1.2 defines. `raw_code` is kept
@@ -1280,30 +1296,55 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
results: list[dict[str, Any]] = []
last_payload_by_href: dict[str, dict] = {}
async with self._session_lock:
for i, (path_segs, href, payload, settle) in enumerate(parsed):
before = self.resource(href)
code, after = await self.hass.async_add_executor_job(
self._raw_write_blocking, path_segs, payload, href
)
last_payload_by_href[href] = payload
results.append(
{
"href": href,
"code": _coap_code_str(code),
"raw_code": code,
"accepted": _coap_accepted(code),
"before": before,
"after": after,
"changed": all(after.get(k) == v for k, v in payload.items()),
}
)
# settle is "how long to wait before the next write" -- the
# last item has no next write, so it gets no wait here;
# verify_after (below) is the equivalent wait after the
# sequence as a whole.
if settle and i < len(parsed) - 1:
await asyncio.sleep(settle)
try:
async with self._session_lock:
for i, (path_segs, href, payload, settle) in enumerate(parsed):
before = self.resource(href)
code, after = await self.hass.async_add_executor_job(
self._raw_write_blocking, path_segs, payload, href
)
last_payload_by_href[href] = payload
results.append(
{
"href": href,
"code": _coap_code_str(code),
"raw_code": code,
"accepted": _coap_accepted(code),
"before": before,
"after": after,
"changed": all(after.get(k) == v for k, v in payload.items()),
}
)
# settle is "how long to wait before the next write" -- the
# last item has no next write, so it gets no wait here;
# verify_after (below) is the equivalent wait after the
# sequence as a whole.
#
# Held across this wait, unlike verify_after's below: a
# poll landing between two writes is exactly what this
# sequence exists to rule out, since it blurs which write
# the appliance was reacting to. Bounded by the caps above
# -- 10 writes x 30s is the worst a caller can ask for.
if settle and i < len(parsed) - 1:
await asyncio.sleep(settle)
except Exception as err:
# A session drop partway leaves the appliance holding whatever
# already landed. Raising bare would throw that away, and knowing
# which writes got through is the difference between a usable
# probe result and having to start the sequence over blind.
done = ", ".join(r["href"] for r in results) or "none"
self._log.warning(
"raw write sequence failed after %d of %d writes (completed: %s): %s",
len(results),
len(parsed),
done,
err,
)
await self.async_request_refresh()
raise HomeAssistantError(
f"Raw write sequence failed after {len(results)} of {len(parsed)} writes "
f"(completed: {done}). The appliance may be holding a partial sequence."
) from err
response: dict[str, Any] = {"results": results}
if verify_after > 0:
@@ -1319,12 +1360,22 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
vcode, vrep = await self.hass.async_add_executor_job(
self._raw_read_blocking, _href_to_path_segs(href), href
)
# `held` is None, not False, when the re-read itself
# didn't come back with a representation to compare.
# _raw_read_blocking answers a non-2.05 with an empty
# rep, against which every payload comparison is False --
# so a 4.04 or a dropped read would otherwise be reported
# as "the board reverted your write", which is the exact
# distinction verify_after exists to draw.
read_ok = _coap_accepted(vcode) and bool(vrep)
verified[href] = {
"code": _coap_code_str(vcode),
"raw_code": vcode,
"rep": vrep,
"held": all(
vrep.get(k) == v for k, v in last_payload_by_href[href].items()
"held": (
all(vrep.get(k) == v for k, v in last_payload_by_href[href].items())
if read_ok
else None
),
}
response["verified"] = verified
+19 -8
View File
@@ -23,7 +23,7 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import device_registry as dr
from .const import DOMAIN, SERVICE_READ_RESOURCE, SERVICE_WRITE_RESOURCE
from .coordinator import LocalThingsCoordinator
from .coordinator import LocalThingsCoordinator, normalize_href
from .registry.subdevices import MAIN, Subdevice
ATTR_HREF = "href"
@@ -119,13 +119,19 @@ async def _async_write_resource(hass: HomeAssistant, call: ServiceCall) -> Servi
# (issue #177): the coordinator's raw-write primitive has no notion of
# subdevices, only the hrefs it's told to hit -- identity transform for
# MAIN, so a device targeting MAIN behaves exactly as it always has.
#
# Normalized first: to_actual is a textual transform, so an un-normalized
# '/mode/vs/0/' passes through it untouched and only gets tidied up
# downstream, quietly hitting the master's resource instead of the
# subdevice's (see coordinator.normalize_href).
canonicals = [normalize_href(w[ATTR_HREF]) for w in writes_in]
raw_writes = [
{
"href": subdevice.to_actual(w[ATTR_HREF]),
"href": subdevice.to_actual(canonical),
"payload": w.get(ATTR_PAYLOAD),
"settle": w.get(ATTR_SETTLE, 0.0),
}
for w in writes_in
for canonical, w in zip(canonicals, writes_in, strict=True)
]
sequence = await coordinator.async_raw_write_sequence(
raw_writes, verify_after=call.data.get(ATTR_VERIFY_AFTER, 0.0)
@@ -133,7 +139,7 @@ async def _async_write_resource(hass: HomeAssistant, call: ServiceCall) -> Servi
results = [
{
"href": original[ATTR_HREF],
"href": canonical,
"actual_href": result["href"],
"code": result["code"],
"raw_code": result["raw_code"],
@@ -142,11 +148,14 @@ async def _async_write_resource(hass: HomeAssistant, call: ServiceCall) -> Servi
"after": result["after"],
"changed": result["changed"],
}
for original, result in zip(writes_in, sequence["results"], strict=True)
for canonical, result in zip(canonicals, sequence["results"], strict=True)
]
response: dict[str, Any] = {"device_id": device_id, "results": results}
if "verified" in sequence:
canonical_by_actual = {subdevice.to_actual(w[ATTR_HREF]): w[ATTR_HREF] for w in writes_in}
# Keyed off the same normalized canonicals the sequence was built
# from, so the lookup can't miss and hand back an actual href where
# the documented contract promises a canonical one.
canonical_by_actual = {subdevice.to_actual(c): c for c in canonicals}
response["verified"] = {
canonical_by_actual.get(actual_href, actual_href): verified
for actual_href, verified in sequence["verified"].items()
@@ -165,10 +174,12 @@ async def _async_read_resource(hass: HomeAssistant, call: ServiceCall) -> Servic
snapshot: dict[str, Any] = {"resources": coordinator.canonical_resources(subdevice)}
return cast(ServiceResponse, snapshot)
actual_href = subdevice.to_actual(href)
# 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)
read_result: dict[str, Any] = {
"href": href,
"href": canonical,
"actual_href": actual_href,
"code": f"{code >> 5}.{code & 0x1F:02d}",
"raw_code": code,