A follow-up review of the 0.1.6 upgrade found four call sites where a
library exception (new typed one or the old bare ConnectionError/
TimeoutError it replaced) could escape this integration's own
reconnect/logging or a service call's translation layer entirely,
instead of being handled the way equivalent failures already are
elsewhere in this file:
- _attempt_observe_mode's own _connect_session() reconnect (fires only
when the session was closed out from under it concurrently) had no
try/except, and neither did either of its two call sites in
_async_update_data. A failure there escaped uncaught: HA's
DataUpdateCoordinator has its own final safety net so nothing crashed
the config entry, but non-TimeoutError failures logged a full ERROR
traceback instead of this integration's deliberately quiet "poll
failed, reconnecting" voice, and skipped its own reconnect bookkeeping
entirely. Fixed by catching around just the connect call (the only
unguarded raise path in the method -- subscribe_hrefs/
await_observe_notifies already handle their own failures), landing in
the same "give up on push this cycle" state abandon_observe_attempt()
already produces for the subscribe-failed and stale-session branches.
Deliberately does not touch _close_session() (self._session is
already None here -- _connect_session only ever publishes it after a
full success), _reconnect_is_frequent() (that window records the poll
path's own reconnects; feeding it a secondary path's failure would
over-trigger its warning threshold), or _resubscribe_due (that flag
means "a live session nothing has tried yet" -- setting it here would
re-enter the doomed handshake every cycle instead of letting
_last_observe_attempt_ts pace the retry).
- _enumerate_subdevices_blocking's _connect_session() call (first
discovery only) had the same gap. Fixed the same way: log and fall
through on the resources _poll_once already returned this cycle,
rather than losing first discovery over a failed subdevice probe.
- async_raw_read (backing the read_resource service) had no exception
handling at all -- a session/network failure during a live debug read
reached the service caller as a raw, untranslated library exception,
unlike write_resource's equivalent path. Now wrapped the same way
async_send_command/async_raw_write_sequence already are, raising
HomeAssistantError with a new debug_read_failed translation key
(added to all 7 shipped locales).
- async_raw_write_sequence's verify_after tail sat outside the method's
own try/except, so a failed confirmation read discarded the write
results that had already landed by throwing past them. Now caught
per-href inside the verify loop instead: a failed read is treated the
same as a 4.04/empty one (held=None, "couldn't verify" -- not lost or
misreported as a revert), and the rest of the batch still gets
checked.
Design for the first fix (the trickiest -- it's mid-lock, and has to
interact correctly with observe-mode state and the poll path's own
bookkeeping without corrupting either) was worked through with a
dedicated review pass before implementing.
Tests: new coverage for all four (test_coordinator.py's
test_attempt_observe_mode_survives_a_failed_reconnect, a new
test_coordinator_error_handling.py for the subdevice-enumeration case,
and two additions to test_services.py for the read-service and
verify_after cases). Full suite (1521 tests), ruff, and ty all pass.
queue_get accepts dict | list since #335's Collection test started
queueing a batch list, but _get_reps was still typed list[dict] --
ty flagged the list.append() as invalid. No behavior change.
`_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.
Holding _session_lock for a whole write sequence buys certainty about what
the appliance saw and when, but blocks every poll and entity write for the
sequence's full length -- up to 10 x 30s. Which of those matters more
depends on what is being probed, so it is now hold_session_lock on
async_raw_write_sequence and a field on the service, defaulting to the
holding behavior that shipped.
Off, the lock is taken per write and released across the settle waits, so
entities keep updating through a long sequence. Exactly one of the two
context managers is ever the real lock -- asyncio.Lock isn't reentrant.
Tests assert the lock's actual state during the settle wait in both modes,
rather than just that the flag is accepted.
- 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.
The options-flow "Debug write" panel could only ever do one write to one
href per pass -- not enough for the issue #300 wall oven, whose board
discards settings writes while idle and only keeps them once a cycle is
already running. Finding what starts a cycle needs an ordered sequence of
writes across resources, with real settle delays between them, and a way
to check afterward whether anything actually held.
- coordinator.py: async_raw_write_sequence owns a whole ordered sequence
under one _session_lock hold (so a poll can't interleave mid-sequence),
with per-step settle and an optional delayed verify_after re-read done
outside the lock. async_raw_write is now a one-item wrapper over it, so
tests/test_coordinator_raw_write.py keeps passing unmodified. Also adds
async_raw_read, a live GET bypassing the cache -- staleness is exactly
what makes revert-testing unreliable.
- services.py (new): the two HA services. Device-target resolution scans
loaded coordinators' MAIN/subdevice identifiers and requires exactly one
match, so an area/label target can't silently fan a raw write out across
several appliances. Canonical->actual href translation happens here, not
in the coordinator, which stays subdevice-agnostic.
- services.yaml (new): selectors/descriptions for both services, inline
per HA's custom-integration support -- keeps translations/en.json's
mirror test (test_translations.py) green without touching all 6
languages for a services block. New exception keys (write caps, device
target resolution) still went into translations/*.json's existing
exceptions section, mirrored across all 6 languages.
- __init__.py: adds async_setup to register the services once, process-wide.
- config_flow.py: the debug panel's async_step_debug_edit now calls
write_resource instead of coord.async_raw_write directly, so there is
exactly one code path that performs a raw write.
- README.md: new Part 5 documenting both services, with a worked
write_resource example; points the capability-gap section at them.
tests/test_services.py (new): sequencing/ordering, settle timing, changed
vs. held (the reverted case is issue #300's own symptom), exactly-one-
device resolution, subdevice href translation, validation caps, and the
options-flow panel end to end through the service.