Show each node's firmware, and what the updater has for it #59
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/update-entity"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
One
updateentity per announced node, on the same device as that node's other entities. Keeping it in the integration rather than discovering it over MQTT is what puts it there; a discovered entity would land on a device of its own and split each node in two.Where each fact comes from:
installed_versionisSYS_ANNOUNCE, off the bus, rendered by the existingfirmware_label. Not the updater'sinstalled_version, which is null until the updater has its own CAN link (CCS-UHA-5).latest_versionis the updater'sGET /api/nodes, keyed by node type. Only cached images are listed, so a version showing here is installable now.SYS_WIFI_STATUSbyte 2, read like any other bus state.POST /api/nodes/<node>/update. An answer withstarted: falseis raised rather than shown as an install that ran, so today'sno_linkreaches the user.Two things worth a reviewer's attention.
The two versions are not comparable. The bus carries a bare
uint16firmware version and the updater names images by the release's CalVer, and nothing maps one to the other — the firmware'sfirmwareVersionis a hand-written0x0001on every node. Soversion_is_neweris overridden to returnTrueand say only that the cache is holding an image. Nothing here can claim a node is up to date, and a node with nothing cached for it reads unknown rather than off. It becomes a real comparison when the updater can ask a node what it is running.CLAUDE.md still lists the WiFi and OTA paths under "not yet implemented on the bus". That is true of the firmware, so progress will not arrive today; the decode is here for when it does. That line wants updating when this branch reaches main, which is not this change.
Also:
CampervanCoordinatortakes an optional config entry now, only so the firmware poll can be tied to one.ReadingStoregained a node listener, because_node_changedwalks endpoint indices and a node that has announced but described nothing has none — an entity belonging to the node would never hear it go.Tested: the updater unreachable, a node it has never heard of, an image cached, nothing cached, an install returning
no_link, a refusal in the updater's own words, progress arriving and ending on the bus, and a node missing three heartbeats. Nothing binds a port — the updater is answered throughaioclient_mock, and the suite is kept off it by default the way it is kept off a real CAN socket.Closes CCS-UHA-7.
Two things to fix, one to tidy. Suite, ruff and mypy are green here.
@ -0,0 +69,4 @@"""started: boolreason: strreasonis parsed and never read —async_installraisesdetailonly. The ticket asks for theno_linktoken to be surfaced, so either put it in the error or drop the field. Same forOtaProgress.node(line 84), which repeats the dict key it is stored under.@ -0,0 +233,4 @@OtaProgress(identifier.source, fields.get("ota_progress")),)else:self._moved(identifier.source, None)Real bug:
in_progressnever clears after a transfer finishes. Only a laterSYS_WIFI_STATUSwith a state other than 4, or the bus dropping, clears_progress— but a node broadcastsSYS_WIFI_STATUSonly while its radio is up, and spec section 11 step 7 ends the update with the node rebooting and switching the radio off. Verified against this branch: after state 4 at 42% the node re-announcing (0x0001 -> 0x0002) leavesin_progress: Trueandupdate_percentage: 42stuck there for good.Clearing a node's progress on
SYS_ANNOUNCEcovers it — a reboot ends the transfer either way — and a node returning from three missed heartbeats gets the same benefit. Nothing tests the bus-down clear either; it only runs incidentally at fixture teardown.@ -0,0 +41,4 @@def follow_the_inventory() -> None:fresh: list[CampervanNodeFirmware] = []for node in coordinator.inventory.nodes:if not node.announced:The entity is built on the announce, so its entity id is made before the node's own
SYS_ENDPOINT_LABELat 0xFF arrives. With a node labelled "Roof rail" the registry ends up withupdate.lighting_firmwarebesidesensor.roof_rail_ceilingon the same device, and registry ids are permanent.platforms.follow_the_inventoryholds an endpoint back for exactly this reason; gate oncoordinator.discovery.settled(or the node's label having had its chance) the same way.All three earlier findings are fixed, and the new tests genuinely fail when the fixes are reverted. One thing the announce fix over-reaches on.
custom_components/campervan/firmware.py,async_handle_frame: the clear fires on anySYS_ANNOUNCE, but the announce is "broadcast once at boot, then on request" (spec section 8.1), so a node answeringQRY_DESCRIPTORSmid-transfer blanks its own progress. Confirmed: feed an in-progressSYS_WIFI_STATUSthen an announce carrying the same firmware and build, andprogress()goes toNone.Discovery._judge_what_arrivedscans whenever any node's burst looks short, so another node booting while node 0x10 writes is enough to trigger it. It recovers on the nextSYS_WIFI_STATUSrather than sticking, so this is a flicker, not the stuck state you started from — but gate the clear on the announce's firmware/build differing from what was held when the transfer started, and it goes away.@ -0,0 +225,4 @@if identifier.source in (identifiers.RESERVED_NODE, identifiers.BROADCAST):returnknown = catalogue.find(identifier.message_class, identifier.message_type)if known is SYS_ANNOUNCE:Clears on any
SYS_ANNOUNCE, including one sent in reply toQRY_DESCRIPTORSwhile the node is still writing (spec section 8.1: "broadcast once at boot, then on request"). Gate on the firmware/build having changed from what was held when the transfer started.One finding, in
OtaStore._announced. Tests, ruff and mypy are clean.@ -0,0 +251,4 @@image = (fields.get("firmware", 0), fields.get("build", 0))if self._image.get(node, image) != image:self._moved(node, None)self._image[node] = imageA rolled-back update leaves the progress stuck forever. Spec section 11: an image that does not commit within 30 s reboots into the previous slot, so the node announces the same firmware and build it had before the transfer.
self._image.get(node, image) != imageis then false, the transfer is never ended, and noSYS_WIFI_STATUScan clear it because the reverted image boots with the radio off. Confirmed: announce(fw=1, build=0x9F2C1A04) -> wifi status state 4 at 42% -> the identical announce leavesprogress(0x10) == OtaProgress(percent=42), and the entity shows installing 42% until the bus drops or HA restarts.One way out that covers both paths: also end the transfer when the node goes absent (three missed heartbeats, via the node listener already added for availability). A node writing to the inactive slot keeps its app running and keeps heartbeating, so absence means it rebooted — success or rollback — while a node answering
QRY_DESCRIPTORSmid-transfer stays live and keeps its progress. I have not verified the firmware keeps heartbeating throughout the write; worth checking against the firmware before taking this route.The rollback fix holds up: neuter
_node_moved's liveness check orasync_node_absent's body andtest_an_update_that_rolled_back_stops_showing_progressis the only test that drops, so both halves are really covered. Nothing here depended on the eagerframesimport —firmware.pyand the new tests reachcatalogue,codecandidentifiersas submodules. Suite, ruff and mypy green.One finding:
README.mdis not touched.updateentity per node, its two version sources, or that pressing install today raisesno_link.Every sibling PR on this branch kept the README current as it went (
a94b838added the API paragraph,4e5f1d3the cache one). Either add a paragraph, or say in the PR body why this one is deferred the way CLAUDE.md's "not yet implemented on the bus" line is.The paragraph checks out against the code, including the rollback and no-link cases. One wording fix, then this is done. Suite, ruff and mypy green.
@ -79,6 +79,22 @@ bus working as designed; a decode failure is not. Beside them is a frame log,off at every start, which writes every frame the Pi hears to the log in`candump` form for as long as it is switched on.Every node that announces itself also gets a firmware entity, on its own device"on its own device beside its endpoints" reads as a second device per node, which is the opposite of what happens and of the one-device-per-node rule. Eight lines above, "gets a device of its own" is used for exactly that separate-device sense, so the two clash. Say "on the same device as its endpoints".
The README wording now matches what
update.pybuilds. Nothing outstanding.