Drive the OTA conversation over CAN from the updater #58
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/ota-can-session"
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?
Runs one update session with one node, over CAN: raise the hotspot, send its
SSID and key as a
BULKtransfer of content type 2, wait forBULK_ENDresult0, send
SYS_WIFI_CONTROLaction 3 with the address and the file server port,then watch
SYS_WIFI_STATUSandSYS_ANNOUNCE. Every path out hands the host'snetwork back, including the timeouts. Spec section 11. CCS-UHA-5.
UpdateSession(bus, radio).run(node_type)is the entry point; nothing is wiredinto the control API, which is CCS-UHA-6's to do.
NODE_FILE_SERVER_PORTisadded to
config.pywith the same wording as the file server branch, so expecta trivial conflict there.
Three things worth a reviewer's attention.
The updater gets its own SocketCAN transport rather than the integration's.
transport.pyis built around Home Assistant's loop and executor and thiscontainer has neither Home Assistant nor python-can. Raw CAN sockets are in the
standard library, as
tools/frame_benchalready shows. The codec is shared andthe plumbing is not, which is the line that matters: drift lives in the codec.
The shared package would not import in the container.
protocol/__init__.pyimported
frames, which imports python-can, so the updater died on its firstimport of the node registry.
framesis now reached directly by the things thatwant it, and a test mounts the package the way compose does to keep it that way.
The credentials are padded to whole
BULK_DATAframes. The specificationdoes not say what a final short frame means, so there is never one; the padding
is zero bytes past the key's own terminator. The committed bench example needs
none, and the session's frames are checked against it byte for byte.
Tested against a fake bus at the send and receive seam and a socket pair for the
socket itself: the happy path, a node that never answers the transfer, one that
refuses it, one that never comes back, a failure part way, and another node
answering. The
vcan0test is skipped here for want of a virtual bus.updater/session.py:316— anySYS_ANNOUNCEfrom the node ends the watch asUPDATED, including one the node sends while it is still downloading. The integration broadcastsQRY_DESCRIPTORSwhenever a burst looks short or the bus comes back (discovery.py_judge_what_arrived,async_bus_availability_changed), and the firmware answers it unconditionally —node.cpproutesQryDescriptorsstraight toscheduleDescriptors, with no check on whether an OTA is running. So a rescan mid-image makes the session report success with the old firmware version and then tear the hotspot down under the node. Take the node's current firmware and build as arguments torun()and ignore an announce that matches them, or require aSYS_WIFI_STATUStransition out ofUPDATINGfirst.updater/session.py:143-152— the premise that "the specification does not say what a final short frame means" is already settled the other way in the firmware. The in-flightfeat/wifi-radio-controlbranch decodesBULK_DATAby DLC (codecs.hbulkChunkLength) and publishes abulk_data_short_final_chunkvector. Padding does still interoperate —bulk_receiver.cppCRCstotalLengthbytes andsession_credentials.cppstops at each NUL — so this is not a wire bug, but it should be resolved in spec section 8.9 rather than each side guessing. Related and worth checking before the vectors refresh:BULK_DATA.payloadincatalogue.pyhas nocounted=, so it silently drops the payload of a short frame, andtests/test_vectors.pywill fail all three new BULK_DATA cases on the missinglengthfield.updater/session.py:188-193— theraise_hotspotfailure path returns without callingrestore(), relying onRadio.raise_hotspotrestoring internally. TheHotspotsprotocol does not promise that, and anything other thanRadioErrorout of it (cancellation, a future implementation'sOSError) leaves the hotspot up with no route home. Put the raise inside the sametry/finallyas the rest;restore()is documented as safe to call having raised nothing.updater/session.py:156— the docstring says one node at a time but nothing enforces it, and CCS-UHA-6 is being wired against this now. Two concurrentrun()calls give the secondraise_hotspotthe samecon-namewith a fresh passphrase, killing the first node's credentials, and the firstrestore()to finish takes the survivor's hotspot down. Anasyncio.Lockheld acrossrun()would make the docstring true.Taken 1, 3 and 4.
SYS_WIFI_STATUSstate 4. Missing every progress report costs a timeout instead, which is the safer way round.try/finallyas the rest, so the restore runs whatever the radio did.Outcome.BUSYrather than raising a second hotspot.Not taking 2, deliberately. Nothing on this branch decodes
BULK_DATA— the session only sends it — and the padding interoperates either way, as you found.catalogue.pymatches spec 8.9 as it reads today, and the shared vectors failing when the firmware's short-chunk change lands is exactly the mechanism that is meant to force the codec change here. Addingcounted=now would pre-empt vectors that do not exist yet. The spec amendment belongs in the firmware repository, not this pull request.All three fixes do what they claim, and the tests for the first two bite when the code is reverted. Three things left: the
BUSYflag can stick permanently on a cancellation path, nothing tests that it is ever released, and theflashinggate leans on firmware behaviour that does not exist yet.@ -0,0 +504,4 @@assert radio.restored == 1async def test_a_second_session_is_refused_while_one_is_running() -> None:Nothing covers the release of the flag: delete
self._running = Falsefromrun()and the whole suite still passes, so a session that refuses every node after the first would ship green. This test cancelsfirstand never awaits it or runs again afterwards. Let the first session finish (or await the cancellation), then assert a secondrun()gets past the guard and raises a hotspot.@ -0,0 +213,4 @@)return SessionResult(node_type, Outcome.FAILED, detail=str(failure))finally:await self._hand_the_network_back()_runningis cleared after an await, so a cancellation delivered whilerestore()is in flight escapes thefinallyand leaves the flag set for the life of the process — every laterrun()then returnsBUSYwith nothing running. Reproduced: cancel the task, let the restore start, cancel again (a shutdown that cancels a second time, or an outerasyncio.timeouton top of a cancel) and_runningstaysTrue,restoredstays 0.Nest it so the flag cannot outlive the session:
@ -0,0 +354,4 @@continueprogress = received.values.get("ota_progress", progress)state = received.values.get("state")flashing = flashing or state == WifiState.UPDATINGThe gate assumes the node broadcasts
SYS_WIFI_STATUSstate 4 at least once before it reboots. In the firmwareWifiUpdateris still abstract (lib/node-runtime/src/wifi_manager.h:24) andreport()only goes out fromWifiManager::loop, so if the real implementation blocks the main loop while it downloads — the usualhttpUpdateshape — no state 4 frame is ever sent, the announce after the reboot is discarded, and a successful update reportsLOSTten minutes later with the hotspot standing the whole time. Worth pinning the firmware side down to a non-blocking write before this merges.Round two: 1 and 2 taken in
56f23f4. The flag now clears in its ownfinallyoutside the restore, and the new test cancels a session while the restore is waiting, cancels it again, and then runs another — it fails without the fix.On 3, the state 4 frames are not an assumption about the firmware: spec section 11 step 5 says the image is written "with progress reported in
SYS_WIFI_STATUSbyte 2", so a download that reports nothing is not honouring section 11. The gate is written to fail that way round on purpose — a missed report costs a timeout and a wrong announce would claim success on the old image and pull the hotspot down mid-write. Worth raising against the firmware's WiFi work so the download loop stays non-blocking; nothing to change here.56f23f4is right: the outerfinallykeeps_runningtrue across the restore, so the guard still holds while the network goes back, and it clears on a cancellation inside it. Mutating the line topassfails the new test; the test is deterministic, not ordering-luck. The realRadioalso survives a half-done restore —_suspendedis only cleared once the uplink is back, and the nextraise_hotspotsees no wireless uplink in the way, so it does not overwrite what is still owed.Two things left.
updater/config.py:14-16— the branch will not merge.NODE_FILE_SERVER_PORTwas added to this file onfeat/firmware-updatesby #56, with the same value and the same comment text, andgit merge-tree feat/firmware-updates feat/ota-can-sessionconflicts on theCONTROL_API_*block next to it. The branch is behind #56 and #57. Merge the base in and take both sides; resolved that way the merged tree is green (910 passed, mypy and ruff clean), so it is only the merge that is blocked.tests/test_updater_session.py:557—is not Outcome.BUSYpasses on any outcome that is notBUSY, so a third session failing for some unrelated reason would still be green.is Outcome.IGNOREDis what this bus actually does and pins it.Base merged in (
03861ca) — #56 had already addedNODE_FILE_SERVER_PORTword for word, so this branch's copy of it falls away andconfig.pynow matches the base exactly. The assertion is tightened tois Outcome.IGNORED; it rode in on the merge commit rather than its own, which is untidy but it is the one line.910 passed, ruff and mypy clean, pipeline green on the merged head. Leaving the merge to you.