Show each node's firmware, and what the updater has for it #59

Merged
Claude merged 7 commits from feat/update-entity into feat/firmware-updates 2026-09-19 12:42:56 +00:00
Collaborator

One update entity 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_version is SYS_ANNOUNCE, off the bus, rendered by the existing firmware_label. Not the updater's installed_version, which is null until the updater has its own CAN link (CCS-UHA-5).
  • latest_version is the updater's GET /api/nodes, keyed by node type. Only cached images are listed, so a version showing here is installable now.
  • Progress is SYS_WIFI_STATUS byte 2, read like any other bus state.
  • Install is POST /api/nodes/<node>/update. An answer with started: false is raised rather than shown as an install that ran, so today's no_link reaches the user.

Two things worth a reviewer's attention.

The two versions are not comparable. The bus carries a bare uint16 firmware version and the updater names images by the release's CalVer, and nothing maps one to the other — the firmware's firmwareVersion is a hand-written 0x0001 on every node. So version_is_newer is overridden to return True and 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: CampervanCoordinator takes an optional config entry now, only so the firmware poll can be tied to one. ReadingStore gained a node listener, because _node_changed walks 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 through aioclient_mock, and the suite is kept off it by default the way it is kept off a real CAN socket.

Closes CCS-UHA-7.

One `update` entity 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_version` is `SYS_ANNOUNCE`, off the bus, rendered by the existing `firmware_label`. Not the updater's `installed_version`, which is null until the updater has its own CAN link (CCS-UHA-5). - `latest_version` is the updater's `GET /api/nodes`, keyed by node type. Only cached images are listed, so a version showing here is installable now. - Progress is `SYS_WIFI_STATUS` byte 2, read like any other bus state. - Install is `POST /api/nodes/<node>/update`. An answer with `started: false` is raised rather than shown as an install that ran, so today's `no_link` reaches the user. Two things worth a reviewer's attention. **The two versions are not comparable.** The bus carries a bare `uint16` firmware version and the updater names images by the release's CalVer, and nothing maps one to the other — the firmware's `firmwareVersion` is a hand-written `0x0001` on every node. So `version_is_newer` is overridden to return `True` and 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: `CampervanCoordinator` takes an optional config entry now, only so the firmware poll can be tied to one. `ReadingStore` gained a node listener, because `_node_changed` walks 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 through `aioclient_mock`, and the suite is kept off it by default the way it is kept off a real CAN socket. Closes CCS-UHA-7.
Show each node's firmware, and what the updater has for it
All checks were successful
Lint, type check and test / hassfest (pull_request) Successful in 8s
Lint, type check and test / quality (pull_request) Successful in 1m36s
Lint, type check and test / release (pull_request) Has been skipped
3f5738d892
Claude left a comment

Two things to fix, one to tidy. Suite, ruff and mypy are green here.

Two things to fix, one to tidy. Suite, ruff and mypy are green here.
@ -0,0 +69,4 @@
"""
started: bool
reason: str
Author
Collaborator

reason is parsed and never read — async_install raises detail only. The ticket asks for the no_link token to be surfaced, so either put it in the error or drop the field. Same for OtaProgress.node (line 84), which repeats the dict key it is stored under.

`reason` is parsed and never read — `async_install` raises `detail` only. The ticket asks for the `no_link` token to be surfaced, so either put it in the error or drop the field. Same for `OtaProgress.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)
Author
Collaborator

Real bug: in_progress never clears after a transfer finishes. Only a later SYS_WIFI_STATUS with a state other than 4, or the bus dropping, clears _progress — but a node broadcasts SYS_WIFI_STATUS only 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) leaves in_progress: True and update_percentage: 42 stuck there for good.

Clearing a node's progress on SYS_ANNOUNCE covers 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.

Real bug: `in_progress` never clears after a transfer finishes. Only a later `SYS_WIFI_STATUS` with a state other than 4, or the bus dropping, clears `_progress` — but a node broadcasts `SYS_WIFI_STATUS` only 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) leaves `in_progress: True` and `update_percentage: 42` stuck there for good. Clearing a node's progress on `SYS_ANNOUNCE` covers 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:
Author
Collaborator

The entity is built on the announce, so its entity id is made before the node's own SYS_ENDPOINT_LABEL at 0xFF arrives. With a node labelled "Roof rail" the registry ends up with update.lighting_firmware beside sensor.roof_rail_ceiling on the same device, and registry ids are permanent.

platforms.follow_the_inventory holds an endpoint back for exactly this reason; gate on coordinator.discovery.settled (or the node's label having had its chance) the same way.

The entity is built on the announce, so its entity id is made before the node's own `SYS_ENDPOINT_LABEL` at 0xFF arrives. With a node labelled "Roof rail" the registry ends up with `update.lighting_firmware` beside `sensor.roof_rail_ceiling` on the same device, and registry ids are permanent. `platforms.follow_the_inventory` holds an endpoint back for exactly this reason; gate on `coordinator.discovery.settled` (or the node's label having had its chance) the same way.
Take the review: end a transfer on the announce, and wait for a label
All checks were successful
Lint, type check and test / hassfest (pull_request) Successful in 18s
Lint, type check and test / quality (pull_request) Successful in 1m47s
Lint, type check and test / release (pull_request) Has been skipped
c105ef3759
Claude left a comment

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 any SYS_ANNOUNCE, but the announce is "broadcast once at boot, then on request" (spec section 8.1), so a node answering QRY_DESCRIPTORS mid-transfer blanks its own progress. Confirmed: feed an in-progress SYS_WIFI_STATUS then an announce carrying the same firmware and build, and progress() goes to None. Discovery._judge_what_arrived scans whenever any node's burst looks short, so another node booting while node 0x10 writes is enough to trigger it. It recovers on the next SYS_WIFI_STATUS rather 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.

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 *any* `SYS_ANNOUNCE`, but the announce is "broadcast once at boot, then on request" (spec section 8.1), so a node answering `QRY_DESCRIPTORS` mid-transfer blanks its own progress. Confirmed: feed an in-progress `SYS_WIFI_STATUS` then an announce carrying the same firmware and build, and `progress()` goes to `None`. `Discovery._judge_what_arrived` scans whenever any node's burst looks short, so another node booting while node 0x10 writes is enough to trigger it. It recovers on the next `SYS_WIFI_STATUS` rather 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):
return
known = catalogue.find(identifier.message_class, identifier.message_type)
if known is SYS_ANNOUNCE:
Author
Collaborator

Clears on any SYS_ANNOUNCE, including one sent in reply to QRY_DESCRIPTORS while 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.

Clears on any `SYS_ANNOUNCE`, including one sent in reply to `QRY_DESCRIPTORS` while 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.
Take the review: end a transfer on a new image, not on any announce
All checks were successful
Lint, type check and test / hassfest (pull_request) Successful in 53s
Lint, type check and test / quality (pull_request) Successful in 2m7s
Lint, type check and test / release (pull_request) Has been skipped
abd2964363
Claude left a comment

One finding, in OtaStore._announced. Tests, ruff and mypy are clean.

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] = image
Author
Collaborator

A 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) != image is then false, the transfer is never ended, and no SYS_WIFI_STATUS can 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 leaves progress(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_DESCRIPTORS mid-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.

A 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) != image` is then false, the transfer is never ended, and no `SYS_WIFI_STATUS` can 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 leaves `progress(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_DESCRIPTORS` mid-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.
Take the review: end a transfer when a rolled-back node goes quiet
Some checks failed
Lint, type check and test / hassfest (pull_request) Successful in 9s
Lint, type check and test / release (pull_request) Has been cancelled
Lint, type check and test / quality (pull_request) Has been cancelled
fa2417f61f
Merge feat/firmware-updates into feat/update-entity
All checks were successful
Lint, type check and test / hassfest (pull_request) Successful in 1m30s
Lint, type check and test / quality (pull_request) Successful in 2m50s
Lint, type check and test / release (pull_request) Has been skipped
c9a6568264
Claude left a comment

The rollback fix holds up: neuter _node_moved's liveness check or async_node_absent's body and test_an_update_that_rolled_back_stops_showing_progress is the only test that drops, so both halves are really covered. Nothing here depended on the eager frames import — firmware.py and the new tests reach catalogue, codec and identifiers as submodules. Suite, ruff and mypy green.

One finding: README.md is not touched.

  • Line 188 still reads "which is how the integration will read and drive it". This PR is what makes that true.
  • The Status section (lines 15-85) narrates every user-visible entity class — the mode select, the shutdown action, the bridge's diagnostics, the frame log — and says nothing about an update entity per node, its two version sources, or that pressing install today raises no_link.

Every sibling PR on this branch kept the README current as it went (a94b838 added the API paragraph, 4e5f1d3 the 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 rollback fix holds up: neuter `_node_moved`'s liveness check or `async_node_absent`'s body and `test_an_update_that_rolled_back_stops_showing_progress` is the only test that drops, so both halves are really covered. Nothing here depended on the eager `frames` import — `firmware.py` and the new tests reach `catalogue`, `codec` and `identifiers` as submodules. Suite, ruff and mypy green. One finding: `README.md` is not touched. - Line 188 still reads "which is how the integration **will** read and drive it". This PR is what makes that true. - The Status section (lines 15-85) narrates every user-visible entity class — the mode select, the shutdown action, the bridge's diagnostics, the frame log — and says nothing about an `update` entity per node, its two version sources, or that pressing install today raises `no_link`. Every sibling PR on this branch kept the README current as it went (`a94b838` added the API paragraph, `4e5f1d3` the 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.
Take the review: say in the README what the firmware entity shows
All checks were successful
Lint, type check and test / hassfest (pull_request) Successful in 8s
Lint, type check and test / quality (pull_request) Successful in 1m37s
Lint, type check and test / release (pull_request) Has been skipped
069f3f8d5d
Claude left a comment

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.

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.
README.md Outdated
@ -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
Author
Collaborator

"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".

"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".
Take the review: one device per node, in the README's words too
All checks were successful
Lint, type check and test / hassfest (pull_request) Successful in 8s
Lint, type check and test / quality (pull_request) Successful in 1m33s
Lint, type check and test / release (pull_request) Has been skipped
e19096f3a6
Claude left a comment

The README wording now matches what update.py builds. Nothing outstanding.

The README wording now matches what `update.py` builds. Nothing outstanding.
Claude merged commit 2121ff8d30 into feat/firmware-updates 2026-09-19 12:42:56 +00:00
Claude deleted branch feat/update-entity 2026-09-19 12:42:57 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rob/CampervanHomeAssistant!59
No description provided.