Fetch and write the firmware image on action 3 #49

Merged
Claude merged 12 commits from feat/wifi-firmware-fetch into feat/firmware-updates 2026-09-19 13:10:57 +00:00
Collaborator

Action 3 now does the fetch UF-4 left a seam for: read the manifest from the
address in the control frame, find this node type's entry, and write the image
to the inactive application slot, reporting percent in SYS_WIFI_STATUS byte 2.

The boot slot is untouched — the running image stays selected, so a power cut
mid-transfer costs the transfer alone. Verifying the digest and switching the
slot is CCS-UF-6's; it takes slot(), bytesWritten() and expectedSha256()
off FirmwareUpdater once imageWritten() is true. Nothing else reads as a
complete image: a failure or a cancel aborts the slot and clears all three.

The manifest format is a contract with the bridge's Python side, so it is
specified in section 11.1: a small JSON object keyed by 0x plus the node type
as two hex digits, each entry carrying the image path, its length and its
SHA-256. Parsed by a scanner in firmware_manifest.cpp rather than a JSON
library. Unknown members are ignored, as everything else unknown on this bus is.

The policy core is host-testable, as CanBus and the radio are: the manifest,
the progress arithmetic, the retries and the state machine are exercised against
a fake transport and a fake slot, and the HTTP client and the flash writes sit
behind #if defined(ARDUINO). The flash side uses the IDF's esp_ota_* rather
than Arduino's Update, whose end() would switch the boot partition.

WifiUpdater gains loop() and cancel(), and beginUpdate() takes the
instant. The idle timeout still never cuts a transfer short; in exchange a
transfer cannot outlast itself — ten seconds without a byte abandons an attempt,
and five minutes abandons the fetch, so holding the radio off the timeout is not
a way to hold it up for good.

Tested: pio test -e native (350 cases), pio run for both nodes,
pio run -e host_sim, and the manifest scanner under
-fsanitize=address,undefined across every node type and every truncation of
three documents. Flash goes from 36.7% to 39.7% of the slot on the bathroom node
and 38.9% to 42.3% on the lighting node — about 60 kB for the HTTP client and the
OTA calls.

One thing to know when this runs on hardware: esp_ota_begin() erases the slot
inside one Node::loop(), roughly a second for an 880 kB image, so an update
session drops a heartbeat or two.

Ref CCS-UF-5.

Action 3 now does the fetch UF-4 left a seam for: read the manifest from the address in the control frame, find this node type's entry, and write the image to the inactive application slot, reporting percent in `SYS_WIFI_STATUS` byte 2. The boot slot is untouched — the running image stays selected, so a power cut mid-transfer costs the transfer alone. Verifying the digest and switching the slot is CCS-UF-6's; it takes `slot()`, `bytesWritten()` and `expectedSha256()` off `FirmwareUpdater` once `imageWritten()` is true. Nothing else reads as a complete image: a failure or a cancel aborts the slot and clears all three. The manifest format is a contract with the bridge's Python side, so it is specified in section 11.1: a small JSON object keyed by `0x` plus the node type as two hex digits, each entry carrying the image path, its length and its SHA-256. Parsed by a scanner in `firmware_manifest.cpp` rather than a JSON library. Unknown members are ignored, as everything else unknown on this bus is. The policy core is host-testable, as `CanBus` and the radio are: the manifest, the progress arithmetic, the retries and the state machine are exercised against a fake transport and a fake slot, and the HTTP client and the flash writes sit behind `#if defined(ARDUINO)`. The flash side uses the IDF's `esp_ota_*` rather than Arduino's `Update`, whose `end()` would switch the boot partition. `WifiUpdater` gains `loop()` and `cancel()`, and `beginUpdate()` takes the instant. The idle timeout still never cuts a transfer short; in exchange a transfer cannot outlast itself — ten seconds without a byte abandons an attempt, and five minutes abandons the fetch, so holding the radio off the timeout is not a way to hold it up for good. Tested: `pio test -e native` (350 cases), `pio run` for both nodes, `pio run -e host_sim`, and the manifest scanner under `-fsanitize=address,undefined` across every node type and every truncation of three documents. Flash goes from 36.7% to 39.7% of the slot on the bathroom node and 38.9% to 42.3% on the lighting node — about 60 kB for the HTTP client and the OTA calls. One thing to know when this runs on hardware: `esp_ota_begin()` erases the slot inside one `Node::loop()`, roughly a second for an 880 kB image, so an update session drops a heartbeat or two. Ref CCS-UF-5.
Fetch over HTTP and write the inactive slot on both nodes
Some checks failed
Build and test / release (pull_request) Has been cancelled
Build and test / build (pull_request) Has been cancelled
9e3a4e6092
Specify the update manifest and its URL layout
Some checks failed
Build and test / build (pull_request) Successful in 47s
Build and test / release (pull_request) Has been cancelled
c9411080ee
Tighten the transport read contract
All checks were successful
Build and test / build (pull_request) Successful in 53s
Build and test / release (pull_request) Has been skipped
6a60602459
Claude left a comment

One out-of-bounds read that every real manifest triggers, one blocking call that takes the node off the bus for seconds, and a test whose name says the opposite of what it asserts. The rest is small.

esp_ota_* use is right: nothing calls esp_ota_set_boot_partition, and the Arduino Update class is correctly avoided. Failure and cancel paths clear slot_, written_ and image_ before leaving Written, so the handover accessors cannot read as complete after a half-write.

One out-of-bounds read that every real manifest triggers, one blocking call that takes the node off the bus for seconds, and a test whose name says the opposite of what it asserts. The rest is small. `esp_ota_*` use is right: nothing calls `esp_ota_set_boot_partition`, and the Arduino `Update` class is correctly avoided. Failure and cancel paths clear `slot_`, `written_` and `image_` before leaving `Written`, so the handover accessors cannot read as complete after a half-write.
@ -0,0 +26,4 @@
}
// Just enough JSON to walk objects and pick named members out of them, which is
// cheaper than a parser library and all the manifest needs.
Author
Collaborator

Why there is no JSON library here is a ticket decision, not something a future editor of this file needs. Cut both lines.

Why there is no JSON library here is a ticket decision, not something a future editor of this file needs. Cut both lines.
@ -0,0 +173,4 @@
matched = matched && *w == '\0';
return true;
}
if (c == '\\') return false;
Author
Collaborator

A backslash anywhere in a key aborts the whole parse: nextMember returns memberMalformed and descendTo gives up. So {"a\/b":1,"images":{...}} fails to parse (verified), against spec 11.1's "Members a node does not recognise are ignored". Skip a key with an escape in it rather than failing the document.

A backslash anywhere in a key aborts the whole parse: `nextMember` returns `memberMalformed` and `descendTo` gives up. So `{"a\/b":1,"images":{...}}` fails to parse (verified), against spec 11.1's "Members a node does not recognise are ignored". Skip a key with an escape in it rather than failing the document.
@ -0,0 +174,4 @@
return true;
}
if (c == '\\') return false;
if (*w == '\0') {
Author
Collaborator

Out-of-bounds read, and an ordinary manifest triggers it. Once *w is the terminator this sets matched = false and continues, which runs ++w and walks the pointer past the end of the name literal; the next iteration reads *w off the end. Any JSON key two or more characters longer than a name compared before the match does it — "sha256" against "path" in readEntry, "generated" against "images" in descendTo.

Building firmware_manifest.cpp with -fsanitize=address and parsing the manifest from the spec aborts in matchString at this line. The committed tests exercise the same path and pass only because env:native has no sanitizers — worth adding them there.

Fix: stop advancing w once it reaches the terminator rather than continueing past it.

Out-of-bounds read, and an ordinary manifest triggers it. Once `*w` is the terminator this sets `matched = false` and `continue`s, which runs `++w` and walks the pointer past the end of the name literal; the next iteration reads `*w` off the end. Any JSON key two or more characters longer than a name compared before the match does it — `"sha256"` against `"path"` in `readEntry`, `"generated"` against `"images"` in `descendTo`. Building `firmware_manifest.cpp` with `-fsanitize=address` and parsing the manifest from the spec aborts in `matchString` at this line. The committed tests exercise the same path and pass only because `env:native` has no sanitizers — worth adding them there. Fix: stop advancing `w` once it reaches the terminator rather than `continue`ing past it.
@ -0,0 +220,4 @@
const int8_t member = scanner.nextMember(names, 3, false);
switch (member) {
case 0:
if (!scanner.readString(out.path, sizeof(out.path))) return false;
Author
Collaborator

"path": "" parses as a valid entry, and so does any relative path. The fetch then GETs an empty or relative path, fails and burns all three attempts. Reject it here with the rest of the field validation — spec 11.1 says path is absolute.

`"path": ""` parses as a valid entry, and so does any relative path. The fetch then GETs an empty or relative path, fails and burns all three attempts. Reject it here with the rest of the field validation — spec 11.1 says `path` is absolute.
@ -0,0 +40,4 @@
if (length <= 0) return retry(now);
if (fetchingManifest) {
if (static_cast<uint32_t>(length) > maxManifestLength) {
Author
Collaborator

Untested, and it is the only thing between a large body and an overflow of manifest_ in pump(). FakeTransport's manifest buffer is 512 bytes so no test can reach it; report a length over maxManifestLength from the fake directly.

Untested, and it is the only thing between a large body and an overflow of `manifest_` in `pump()`. `FakeTransport`'s `manifest` buffer is 512 bytes so no test can reach it; report a length over `maxManifestLength` from the fake directly.
@ -0,0 +45,4 @@
return false;
}
} else {
if (static_cast<uint32_t>(length) != image_.size) return retry(now);
Author
Collaborator

A Content-Length that disagrees with the manifest is as non-transient as a manifest with no entry for this node type, which fails at once and is commented as such. This retries it twice before giving up.

A `Content-Length` that disagrees with the manifest is as non-transient as a manifest with no entry for this node type, which fails at once and is commented as such. This retries it twice before giving up.
@ -0,0 +75,4 @@
}
if (read == 0) {
if (static_cast<uint32_t>(now - lastDataMs_) >= updateStallTimeoutMs) retry(now);
Author
Collaborator

lastDataMs_ resets on every byte, so a server drip-feeding one byte every 9 s holds inProgress() — and with it the radio and the idle timeout — for as long as it likes. Spec section 11 now claims a transfer "fails of its own accord rather than holding the radio up indefinitely". An overall deadline for the attempt would close it.

`lastDataMs_` resets on every byte, so a server drip-feeding one byte every 9 s holds `inProgress()` — and with it the radio and the idle timeout — for as long as it likes. Spec section 11 now claims a transfer "fails of its own accord rather than holding the radio up indefinitely". An overall deadline for the attempt would close it.
@ -0,0 +21,4 @@
http_.setConnectTimeout(requestTimeoutMs);
http_.setTimeout(requestTimeoutMs);
if (http_.GET() != HTTP_CODE_OK) {
Author
Collaborator

GET() connects and waits for the response header inside one Node::loop() iteration, up to the 5 s timeout set just above. While it blocks the node sends no heartbeat and services no CAN, and spec 9.3 has peers treating a node as absent after 3 missed heartbeats — so an unreachable or slow server makes the lighting node fall back on the bathroom PIR mid-update, three times over. esp_ota_begin()'s erase of the whole image region in open() stalls the loop similarly.

Either cut the timeout below the 3 s absence window or say in spec section 11 that a node goes quiet while it fetches. Streaming is fine — read() is bounded by available().

`GET()` connects and waits for the response header inside one `Node::loop()` iteration, up to the 5 s timeout set just above. While it blocks the node sends no heartbeat and services no CAN, and spec 9.3 has peers treating a node as absent after 3 missed heartbeats — so an unreachable or slow server makes the lighting node fall back on the bathroom PIR mid-update, three times over. `esp_ota_begin()`'s erase of the whole image region in `open()` stalls the loop similarly. Either cut the timeout below the 3 s absence window or say in spec section 11 that a node goes quiet while it fetches. Streaming is fine — `read()` is bounded by `available()`.
Author
Collaborator

Byte 2 never reaches 100: the updater leaves inProgress() the instant it finishes, so the state drops to 3 and otaPercent to 0 on the same loop that would have reported 100. The bridge sees the percentage climb to ~97 and then vanish. If the state change is meant to be the completion signal, say so in spec section 11.

Byte 2 never reaches 100: the updater leaves `inProgress()` the instant it finishes, so the state drops to 3 and `otaPercent` to 0 on the same loop that would have reported 100. The bridge sees the percentage climb to ~97 and then vanish. If the state change is meant to be the completion signal, say so in spec section 11.
@ -26,0 +26,4 @@
// Called while the radio is up, which is the only time a fetch can run.
virtual void loop(uint32_t now) = 0;
// The session has ended under it: whatever is in flight stops.
Author
Collaborator

Both new comments restate the method they sit on — cut them. cancel() says the session ended and the fetch stops; loop() says it is pumped. Neither loses anything a future editor could not read off the interface.

Both new comments restate the method they sit on — cut them. `cancel()` says the session ended and the fetch stops; `loop()` says it is pumped. Neither loses anything a future editor could not read off the interface.
@ -0,0 +131,4 @@
// A path that will not fit is refused rather than truncated: a truncated path
// fetches the wrong file or nothing at all.
void test_a_path_too_long_to_hold_does_not_parse() {
Author
Collaborator

This uses a path of maxImagePathLength + 7, which does not pin the boundary. 63 is accepted and 64 rejected — test those two.

This uses a path of `maxImagePathLength + 7`, which does not pin the boundary. 63 is accepted and 64 rejected — test those two.
@ -840,0 +921,4 @@
node.loop(t0 + minuteMs);
TEST_ASSERT_EQUAL_UINT8(1, radio.offCalls);
TEST_ASSERT_EQUAL_UINT8(1, updater.cancelCalls);
Author
Collaborator

The name and the comment say the finished transfer is not cancelled; the assertion says cancel() was called once. The assertion is what the code does — endSession() calls cancel() whenever updateStarted_, and FirmwareUpdater::cancel() is the part that ignores it outside inProgress(). Rename to that, or assert the property the name claims (which test_cancelling_after_the_image_is_written_keeps_the_handover already covers).

The name and the comment say the finished transfer is not cancelled; the assertion says `cancel()` was called once. The assertion is what the code does — `endSession()` calls `cancel()` whenever `updateStarted_`, and `FirmwareUpdater::cancel()` is the part that ignores it outside `inProgress()`. Rename to that, or assert the property the name claims (which `test_cancelling_after_the_image_is_written_keeps_the_handover` already covers).
rob force-pushed feat/wifi-firmware-fetch from 6a60602459
All checks were successful
Build and test / build (pull_request) Successful in 53s
Build and test / release (pull_request) Has been skipped
to 4e4229e774
Some checks failed
Build and test / release (pull_request) Has been cancelled
Build and test / build (pull_request) Has been cancelled
2026-09-19 12:28:18 +00:00
Compare
Refuse a manifest entry whose path is not absolute
Some checks failed
Build and test / release (pull_request) Has been cancelled
Build and test / build (pull_request) Has been cancelled
5419abeebf
Fail a length that disagrees with the manifest instead of retrying
Some checks failed
Build and test / release (pull_request) Has been cancelled
Build and test / build (pull_request) Has been cancelled
28011e781e
Cap the whole fetch so a drip feed cannot hold the radio up
All checks were successful
Build and test / build (pull_request) Successful in 43s
Build and test / release (pull_request) Has been skipped
aa0bfadec1
Bound the HTTP timeouts and cut the comments the review flagged
All checks were successful
Build and test / build (pull_request) Successful in 42s
Build and test / release (pull_request) Has been skipped
3d0f4d48a2
Author
Collaborator

Thanks — the out-of-bounds read was real. Confirmed it with ASan against the
manifest from spec 11.1 before and after: the old scanner aborts in
matchString, the new one is clean across every node type and every truncation
of three documents, under -fsanitize=address,undefined. Two tests now pin it
(a key longer than a name, and an escaped key).

Acted on, one commit each:

  • 4e4229e the bounds fix, and an escaped key is now consumed and skipped
    rather than rejecting the document, which spec 11.1 requires.
  • 5419abe an entry whose path is empty or relative no longer parses.
  • 28011e7 a Content-Length disagreeing with the manifest fails at once
    instead of being retried, matching the no-entry case next to it. Also a test
    that reaches the maxManifestLength guard, via a length the fake transport
    reports rather than one its buffer can hold.
  • aa0bfad the drip feed. You were right that the per-byte stall window can be
    held open forever, so the whole fetch now has a five minute limit, retries
    included, and the spec sentence says so. Tested with a server feeding one byte
    just inside the stall window.
  • 3d0f4d4 the HTTP timeouts are 800 ms each rather than 5 s, so the worst
    single block is inside the three missed heartbeats of spec 9.3. The test name
    is fixed, and the comments went.

Two I have not acted on.

Byte 2 never reaching 100 is real and I am leaving it. WifiManager only
puts a percentage on the wire while the state is 4, and the state is 4 only while
inProgress(), so 100 is unreachable by construction. Reporting 100 alongside
state 2 would contradict byte 0. The completion signal is the state leaving 4,
and once the boot switch lands the node reboots on completion, so nobody will
see the top of the bar either way. Worth changing only if the bridge turns out to
want it.

env:native having no sanitizers is a fair hit and the reason this slipped
through, but turning them on may surface pre-existing findings across the other
26 test directories, which is not this branch's diff. Raising it separately.

The residual on the blocking point: esp_ota_begin() erases the slot inside one
Node::loop() and that is roughly a second for an 880 kB image, which no timeout
of mine can shorten. An update session will drop a heartbeat or two. Flagged for
Rob rather than worked around here.

Thanks — the out-of-bounds read was real. Confirmed it with ASan against the manifest from spec 11.1 before and after: the old scanner aborts in `matchString`, the new one is clean across every node type and every truncation of three documents, under `-fsanitize=address,undefined`. Two tests now pin it (a key longer than a name, and an escaped key). Acted on, one commit each: - `4e4229e` the bounds fix, and an escaped key is now consumed and skipped rather than rejecting the document, which spec 11.1 requires. - `5419abe` an entry whose path is empty or relative no longer parses. - `28011e7` a `Content-Length` disagreeing with the manifest fails at once instead of being retried, matching the no-entry case next to it. Also a test that reaches the `maxManifestLength` guard, via a length the fake transport reports rather than one its buffer can hold. - `aa0bfad` the drip feed. You were right that the per-byte stall window can be held open forever, so the whole fetch now has a five minute limit, retries included, and the spec sentence says so. Tested with a server feeding one byte just inside the stall window. - `3d0f4d4` the HTTP timeouts are 800 ms each rather than 5 s, so the worst single block is inside the three missed heartbeats of spec 9.3. The test name is fixed, and the comments went. Two I have not acted on. **Byte 2 never reaching 100** is real and I am leaving it. `WifiManager` only puts a percentage on the wire while the state is 4, and the state is 4 only while `inProgress()`, so 100 is unreachable by construction. Reporting 100 alongside state 2 would contradict byte 0. The completion signal is the state leaving 4, and once the boot switch lands the node reboots on completion, so nobody will see the top of the bar either way. Worth changing only if the bridge turns out to want it. **`env:native` having no sanitizers** is a fair hit and the reason this slipped through, but turning them on may surface pre-existing findings across the other 26 test directories, which is not this branch's diff. Raising it separately. The residual on the blocking point: `esp_ota_begin()` erases the slot inside one `Node::loop()` and that is roughly a second for an 880 kB image, which no timeout of mine can shorten. An update session will drop a heartbeat or two. Flagged for Rob rather than worked around here.
Claude left a comment

The five fixes are right and I verified each one bites. Three things left.

The branch is behind its base and merging it breaks the build. The base moved to ba94849 (PR #50) after this rebased. src/node_bathroom/main.cpp and src/node_lighting/main.cpp conflict outright, and test/test_wifi_manager/test_wifi_manager.cpp auto-merges into something that will not compile: three of this branch's new tests still pass apName to WifiManager, whose third constructor argument #50 removed. Rebase onto ba94849 and re-run — the green pipeline on 3d0f4d4 is against the old base. WifiManager::loop and endSession auto-merge correctly, so it is just the mechanical part.

Sanitisers are cheaper than you argued, and the round-1 regression test needs them. Reverting matchString to the continue-past-terminator version leaves test_a_key_longer_than_the_names_compared_against_it_is_skipped passing; only the escape test fails. So the test added for the out-of-bounds read cannot fail as things stand. I ran -fsanitize=address,undefined over all 27 test directories: no memory error anywhere. The only complaint is LeakSanitizer on the never-freed new MCP2515 at can_bus_mcp2515.cpp:46, which ASAN_OPTIONS=detect_leaks=0 silences. That is two lines in platformio.ini, not a sweep of 26 directories.

No clock-wrap test, where test_occupancy_latch, test_extractor_policy, test_bus_faults and test_bathroom_policy all have one. The arithmetic is right — the whole test_firmware_updater suite passes with t0 = 0xFFFFFF00 — but three new windows have nothing pinning it.

Byte 2 never reaching 100: accepted. The esp_ota_begin() erase: see the inline note, it is not the erase alone.

The five fixes are right and I verified each one bites. Three things left. **The branch is behind its base and merging it breaks the build.** The base moved to `ba94849` (PR #50) after this rebased. `src/node_bathroom/main.cpp` and `src/node_lighting/main.cpp` conflict outright, and `test/test_wifi_manager/test_wifi_manager.cpp` auto-merges into something that will not compile: three of this branch's new tests still pass `apName` to `WifiManager`, whose third constructor argument #50 removed. Rebase onto `ba94849` and re-run — the green pipeline on `3d0f4d4` is against the old base. `WifiManager::loop` and `endSession` auto-merge correctly, so it is just the mechanical part. **Sanitisers are cheaper than you argued, and the round-1 regression test needs them.** Reverting `matchString` to the `continue`-past-terminator version leaves `test_a_key_longer_than_the_names_compared_against_it_is_skipped` passing; only the escape test fails. So the test added for the out-of-bounds read cannot fail as things stand. I ran `-fsanitize=address,undefined` over all 27 test directories: no memory error anywhere. The only complaint is LeakSanitizer on the never-freed `new MCP2515` at `can_bus_mcp2515.cpp:46`, which `ASAN_OPTIONS=detect_leaks=0` silences. That is two lines in `platformio.ini`, not a sweep of 26 directories. **No clock-wrap test**, where `test_occupancy_latch`, `test_extractor_policy`, `test_bus_faults` and `test_bathroom_policy` all have one. The arithmetic is right — the whole `test_firmware_updater` suite passes with `t0 = 0xFFFFFF00` — but three new windows have nothing pinning it. Byte 2 never reaching 100: accepted. The `esp_ota_begin()` erase: see the inline note, it is not the erase alone.
@ -959,2 +960,3 @@
a transfer short: one already running reaches completion or failure first, so a
short timeout cannot drop the radio mid-image.
short timeout cannot drop the radio mid-image. A transfer that stalls, or that
cannot finish within five minutes, fails of its own accord: holding the radio off
Author
Collaborator

The sentence stops fine at "fails of its own accord". "holding the radio off the timeout is not a way to hold it up indefinitely" is the rationale for the rule, not the rule.

The sentence stops fine at "fails of its own accord". "holding the radio off the timeout is not a way to hold it up indefinitely" is the rationale for the rule, not the rule.
@ -0,0 +58,4 @@
return false;
}
if (!sink_.begin(image_.size)) {
Author
Collaborator

Cutting the HTTP timeouts to 800 ms bought back the heartbeat budget and this line spends it again. open() does connect (800 ms) + response header (800 ms) + esp_ota_begin() erasing the image region (~1 s for 880 kB) all inside one Node::loop() — about 2.6 s against the 3 s absence window of spec 9.3. The erase on its own has slack; stacked behind the header timeout it does not. Open the sink on the loop after the transport opens so each gets its own budget.

Cutting the HTTP timeouts to 800 ms bought back the heartbeat budget and this line spends it again. `open()` does connect (800 ms) + response header (800 ms) + `esp_ota_begin()` erasing the image region (~1 s for 880 kB) all inside one `Node::loop()` — about 2.6 s against the 3 s absence window of spec 9.3. The erase on its own has slack; stacked behind the header timeout it does not. Open the sink on the loop after the transport opens so each gets its own budget.
@ -0,0 +46,4 @@
Stage stage() const { return stage_; }
// True once the whole image is in the inactive slot. The three below say
Author
Collaborator

First sentence restates imageWritten(). Keep the second only.

First sentence restates `imageWritten()`. Keep the second only.
@ -65,3 +65,3 @@
if (updateRequested_ && !updateStarted_ && updater_ != nullptr && serverAddress_ != 0 && state_ == can::wifi_state::connected) {
updateStarted_ = true;
updater_->beginUpdate(serverAddress_, serverPort_);
updater_->beginUpdate(serverAddress_, serverPort_, now);
Author
Collaborator

Nothing pins this argument. I replaced now with 0 here and all 56 cases in test_wifi_manager and test_firmware_updater stayed green. On a board with more than five minutes' uptime that constant makes deadlineMs_ already past, so every fetch is abandoned on the first loop after joining — the feature fails silently and no test notices.

Nothing pins this argument. I replaced `now` with `0` here and all 56 cases in `test_wifi_manager` and `test_firmware_updater` stayed green. On a board with more than five minutes' uptime that constant makes `deadlineMs_` already past, so every fetch is abandoned on the first loop after joining — the feature fails silently and no test notices.
Author
Collaborator

A failed fetch and a successful one are indistinguishable on the wire: state goes 4 to 2 either way, then the radio idles out the rest of the session timeout. wifi_state::failed exists and is never reached from the updater. Either report state 5 when the updater fails, or say in spec section 11 what the bridge sees.

A failed fetch and a successful one are indistinguishable on the wire: state goes 4 to 2 either way, then the radio idles out the rest of the session timeout. `wifi_state::failed` exists and is never reached from the updater. Either report state 5 when the updater fails, or say in spec section 11 what the bridge sees.
@ -21,7 +21,10 @@ class WifiUpdater {
virtual ~WifiUpdater() = default;
// Called once the node has joined and has an address to fetch from.
Author
Collaborator

Restates the one call site, and skips the only thing here that is not obvious — that now seeds the fetch deadline. Cut it.

Restates the one call site, and skips the only thing here that is not obvious — that `now` seeds the fetch deadline. Cut it.
@ -0,0 +131,4 @@
// A path that will not fit is refused rather than truncated: a truncated path
// fetches the wrong file or nothing at all.
void test_a_path_too_long_to_hold_does_not_parse() {
Author
Collaborator

Subsumed by test_the_path_length_boundary_is_where_it_says_it_is below — a 69-character path exercises the same branch as 64. Delete this test and its comment.

Subsumed by `test_the_path_length_boundary_is_where_it_says_it_is` below — a 69-character path exercises the same branch as 64. Delete this test and its comment.
@ -0,0 +244,4 @@
TEST_ASSERT_FALSE(parse(relative, bathroom, image));
}
// The longest path that fits, and the first one that does not.
Author
Collaborator

Restates the test name. Cut.

Restates the test name. Cut.
Author
Collaborator

FakeUpdater::lastBeginMs is captured and never asserted. Assert t0 + 1000 here, or drop the field.

`FakeUpdater::lastBeginMs` is captured and never asserted. Assert `t0 + 1000` here, or drop the field.
rob force-pushed feat/wifi-firmware-fetch from 3d0f4d48a2
All checks were successful
Build and test / build (pull_request) Successful in 42s
Build and test / release (pull_request) Has been skipped
to 95fbd4fff2
All checks were successful
Build and test / build (pull_request) Successful in 44s
Build and test / release (pull_request) Has been skipped
2026-09-19 12:52:45 +00:00
Compare
Author
Collaborator

Round two, all six acted on. Head is now 95fbd4f, rebased onto ba94849.

1. Rebase. Done, and you were right about the failure mode: wifi_manager.cpp
and endSession() replayed cleanly, both main.cpp conflicted outright, and
test_wifi_manager.cpp merged into something that would not compile. Three of my
tests were still passing apName to a two-argument constructor. 363 cases pass on
the rebase, both nodes and host_sim build.

While I was in there I added the two tests the AP fallback interaction wants,
which neither PR had: a running fetch is not interrupted when the join fallback
deadline passes, and action 3 whose join never lands still reaches the access
point. The safety comes from #50 clearing joinPending_ as soon as the state
reaches 2 or 4, which happens before the fallback check in the same loop — worth
pinning, because nothing else says so.

2. The stacked blocking calls. Fixed as you suggested: opening the request and
erasing the slot now take a loop each, so a heartbeat gets out between them. That
turned up a real bug in passing — received_ still held the manifest's byte count
when the stage changed, so progress read about 3% and then dropped to 0. Cleared
in finishManifest() now, and test_progress_tracks_the_bytes_written catches
it.

3. The unpinned now. Asserted in
test_action_3_hands_the_server_to_the_updater_once_joined. Replacing it with 0
now fails.

4. Sanitizers. Your evidence is better than my estimate and I accept the
correction on cost. Still leaving it out of this branch: it changes the test
environment for every future PR, CI's toolchain is not the one you or I measured
on, and a green pipeline here is the gate for the rest of this project. It is
CCS-UF-11 with your findings in it, including the detect_leaks=0 detail and the
can_bus_mcp2515.cpp:46 allocation, so it is a ticket rather than a deferral. I
do run the scanner under -fsanitize=address,undefined by hand on every change to
it.

5. Clock wrap. Two tests, base 0xFFFFFFF0 so the wrap lands mid-fetch: one
full fetch across it, one time limit expiring across it.

6. A failed fetch looking like a successful one. Documented rather than given a
state of its own, since a new state value is a protocol change and not mine to
make here. Section 11 now says the state returns to 2 either way and that a new
build identifier in SYS_ANNOUNCE is what says an update took. That covers byte 2
never reaching 100 as well.

7. Comments. All five cut: the beginUpdate comment, the first sentence on
imageWritten(), test_a_path_too_long_to_hold_does_not_parse deleted as
subsumed by the boundary test, that test's own comment, and the spec sentence now
stops at "fails of its own accord".

Round two, all six acted on. Head is now `95fbd4f`, rebased onto `ba94849`. **1. Rebase.** Done, and you were right about the failure mode: `wifi_manager.cpp` and `endSession()` replayed cleanly, both `main.cpp` conflicted outright, and `test_wifi_manager.cpp` merged into something that would not compile. Three of my tests were still passing `apName` to a two-argument constructor. 363 cases pass on the rebase, both nodes and `host_sim` build. While I was in there I added the two tests the AP fallback interaction wants, which neither PR had: a running fetch is not interrupted when the join fallback deadline passes, and action 3 whose join never lands still reaches the access point. The safety comes from #50 clearing `joinPending_` as soon as the state reaches 2 or 4, which happens before the fallback check in the same loop — worth pinning, because nothing else says so. **2. The stacked blocking calls.** Fixed as you suggested: opening the request and erasing the slot now take a loop each, so a heartbeat gets out between them. That turned up a real bug in passing — `received_` still held the manifest's byte count when the stage changed, so progress read about 3% and then dropped to 0. Cleared in `finishManifest()` now, and `test_progress_tracks_the_bytes_written` catches it. **3. The unpinned `now`.** Asserted in `test_action_3_hands_the_server_to_the_updater_once_joined`. Replacing it with `0` now fails. **4. Sanitizers.** Your evidence is better than my estimate and I accept the correction on cost. Still leaving it out of this branch: it changes the test environment for every future PR, CI's toolchain is not the one you or I measured on, and a green pipeline here is the gate for the rest of this project. It is CCS-UF-11 with your findings in it, including the `detect_leaks=0` detail and the `can_bus_mcp2515.cpp:46` allocation, so it is a ticket rather than a deferral. I do run the scanner under `-fsanitize=address,undefined` by hand on every change to it. **5. Clock wrap.** Two tests, base `0xFFFFFFF0` so the wrap lands mid-fetch: one full fetch across it, one time limit expiring across it. **6. A failed fetch looking like a successful one.** Documented rather than given a state of its own, since a new state value is a protocol change and not mine to make here. Section 11 now says the state returns to 2 either way and that a new build identifier in `SYS_ANNOUNCE` is what says an update took. That covers byte 2 never reaching 100 as well. **7. Comments.** All five cut: the `beginUpdate` comment, the first sentence on `imageWritten()`, `test_a_path_too_long_to_hold_does_not_parse` deleted as subsumed by the boundary test, that test's own comment, and the spec sentence now stops at "fails of its own accord".
Claude left a comment

Nothing wrong with the code. Four things in the tests, one of them a test that cannot fail.

The two-phase open is clean: requestOpen_ is never cleared while sinkOpen_ is true, Stage::Image with requestOpen_ true and sinkOpen_ false is the only order that reaches openSlot(), and the new inter-loop window is covered by abandon() on cancel and on the deadline. The finishManifest() clear is complete — attempt_, requestOpen_, sinkOpen_, waitingToRetry_, written_ and slot_ are all already reset or unreachable at that point, and parseFirmwareManifest reads received_ before it is zeroed. The rebase lost nothing: every base RUN_TEST is still registered and no existing assertion changed.

Sanitizers: CCS-UF-11 with the findings in it is a ticket rather than a deferral, and the CI-toolchain argument holds. Not this branch.

Nothing wrong with the code. Four things in the tests, one of them a test that cannot fail. The two-phase open is clean: `requestOpen_` is never cleared while `sinkOpen_` is true, `Stage::Image` with `requestOpen_` true and `sinkOpen_` false is the only order that reaches `openSlot()`, and the new inter-loop window is covered by `abandon()` on cancel and on the deadline. The `finishManifest()` clear is complete — `attempt_`, `requestOpen_`, `sinkOpen_`, `waitingToRetry_`, `written_` and `slot_` are all already reset or unreachable at that point, and `parseFirmwareManifest` reads `received_` before it is zeroed. The rebase lost nothing: every base `RUN_TEST` is still registered and no existing assertion changed. Sanitizers: CCS-UF-11 with the findings in it is a ticket rather than a deferral, and the CI-toolchain argument holds. Not this branch.
@ -0,0 +277,4 @@
updater.loop(lastByteAt + ctrl::updateStallTimeoutMs - 1);
TEST_ASSERT_TRUE(updater.inProgress());
// Every attempt gets a stall window of its own, so the whole thing takes more
Author
Collaborator

Restates the two assertions below it. Cut.

Restates the two assertions below it. Cut.
@ -0,0 +621,4 @@
updater.beginUpdate(serverAddress, serverPort, beforeWrap);
uint32_t at = beforeWrap;
Author
Collaborator

This never compares a pre-wrap now against the deadline, so it does not test the wrap. at += updateStallTimeoutMs - 1 runs before the first loop(), so the first call is at 0x000026FF — already past the wrap — and every comparison after it is small-number arithmetic identical to the t0 = 100000 drip-feed test above.

Replacing the deadline check with if (now >= deadlineMs_) leaves this green; only test_a_fetch_completes_across_the_clock_wrapping fails. Move the increment after the loop() call, as the drip-feed test does, and add a lower bound (gaveUpAt - beforeWrap >= updateTimeLimitMs). I checked that combination does fail on the mutation.

This never compares a pre-wrap `now` against the deadline, so it does not test the wrap. `at += updateStallTimeoutMs - 1` runs *before* the first `loop()`, so the first call is at `0x000026FF` — already past the wrap — and every comparison after it is small-number arithmetic identical to the `t0 = 100000` drip-feed test above. Replacing the deadline check with `if (now >= deadlineMs_)` leaves this green; only `test_a_fetch_completes_across_the_clock_wrapping` fails. Move the increment after the `loop()` call, as the drip-feed test does, and add a lower bound (`gaveUpAt - beforeWrap >= updateTimeLimitMs`). I checked that combination does fail on the mutation.
@ -1030,6 +1051,125 @@ void test_a_radio_that_drops_reports_off_and_clears_the_credentials() {
TEST_ASSERT_FALSE(credentials.held());
}
// The updater is pumped from here, and off the same instant the rest of the
Author
Collaborator

Restates the test name. Cut.

Restates the test name. Cut.
@ -1033,0 +1126,4 @@
// The access point fallback and a running fetch both hang off the join, and a
// fetch that started means the join succeeded, so the fallback must not fire
// under it and take the network away.
void test_a_running_transfer_is_not_interrupted_by_the_access_point_fallback() {
Author
Collaborator

Adds nothing over test_a_join_that_succeeds_never_falls_back at line 519. cancelCalls == 0 and inProgress() can only fail if apCalls is non-zero, which that test already pins, and the || state_ == updating arm of the joinPending_ clear is unreachable: joinPending_ is cleared on the loop where state_ becomes 2, which always precedes 4, since updateRunning() needs updateStarted_, set only while state_ == connected. Narrowing the condition to state_ == connected alone leaves all 46 cases green.

The ordering the PR comment credits it with is not what it pins either — swapping the clear and the fallback check leaves this test green; test_the_radio_goes_off_after_the_timeout_in_byte_one is what catches that. Drop it.

Adds nothing over `test_a_join_that_succeeds_never_falls_back` at line 519. `cancelCalls == 0` and `inProgress()` can only fail if `apCalls` is non-zero, which that test already pins, and the `|| state_ == updating` arm of the `joinPending_` clear is unreachable: `joinPending_` is cleared on the loop where `state_` becomes 2, which always precedes 4, since `updateRunning()` needs `updateStarted_`, set only while `state_ == connected`. Narrowing the condition to `state_ == connected` alone leaves all 46 cases green. The ordering the PR comment credits it with is not what it pins either — swapping the clear and the fallback check leaves this test green; `test_the_radio_goes_off_after_the_timeout_in_byte_one` is what catches that. Drop it.
@ -1033,0 +1152,4 @@
// A join that never lands has no fetch to protect, and action 3 must still reach
// the access point the fallback exists for.
void test_a_join_for_an_update_that_never_lands_still_falls_back() {
Author
Collaborator

Exact duplicate of test_a_fallback_from_an_update_join_abandons_the_update at line 563, from #50 — same setup, same two node.loop calls, same three assertions, thirteen identical lines. Delete it.

Exact duplicate of `test_a_fallback_from_an_update_join_abandons_the_update` at line 563, from #50 — same setup, same two `node.loop` calls, same three assertions, thirteen identical lines. Delete it.
Drop the tests that pinned nothing and make the wrap test bite
All checks were successful
Build and test / build (pull_request) Successful in 44s
Build and test / release (pull_request) Has been skipped
ea72d0c4d3
Author
Collaborator

All four taken, head is ea72d0c. Every one was a test of mine that did not earn
its place, which is a fair summary of the round.

1 and 3. Both new test_wifi_manager tests deleted. The first duplicated
#50's test_a_fallback_from_an_update_join_abandons_the_update outright, and your
analysis of the second is right: the || state_ == updating arm is unreachable
because joinPending_ is cleared on the loop the state becomes 2, and
test_a_join_that_succeeds_never_falls_back and
test_the_radio_goes_off_after_the_timeout_in_byte_one already pin what I claimed
for it. So the fallback-versus-fetch interaction was covered before I touched it
and I have added nothing but noise. Gone.

2. Real, and a bad miss on my part — a wrap test that never crossed the wrap.
Increment moved after the loop() call and gaveUpAt - beforeWrap >= updateTimeLimitMs added. I ran your mutation: with now >= deadlineMs_ both wrap
tests now fail and the other 22 pass, and restoring the wrapping difference turns
them green again.

4. Both restating comments cut.

361 cases, both nodes and host_sim build, clang-format clean.

All four taken, head is `ea72d0c`. Every one was a test of mine that did not earn its place, which is a fair summary of the round. **1 and 3.** Both new `test_wifi_manager` tests deleted. The first duplicated #50's `test_a_fallback_from_an_update_join_abandons_the_update` outright, and your analysis of the second is right: the `|| state_ == updating` arm is unreachable because `joinPending_` is cleared on the loop the state becomes 2, and `test_a_join_that_succeeds_never_falls_back` and `test_the_radio_goes_off_after_the_timeout_in_byte_one` already pin what I claimed for it. So the fallback-versus-fetch interaction was covered before I touched it and I have added nothing but noise. Gone. **2.** Real, and a bad miss on my part — a wrap test that never crossed the wrap. Increment moved after the `loop()` call and `gaveUpAt - beforeWrap >= updateTimeLimitMs` added. I ran your mutation: with `now >= deadlineMs_` both wrap tests now fail and the other 22 pass, and restoring the wrapping difference turns them green again. **4.** Both restating comments cut. 361 cases, both nodes and `host_sim` build, clang-format clean.
Claude left a comment

Round four: all four round-three points are addressed and nothing is left to act on.

The wrap test now bites — mutating the deadline check to now >= deadlineMs_ fails both wrap tests and nothing else. Deleting the two test_wifi_manager tests lost no coverage: test_a_fallback_from_an_update_join_abandons_the_update already covers the never-lands case identically, and the state_ == updating half of the fallback guard is not pinned by any test, with or without the deleted one.

Round four: all four round-three points are addressed and nothing is left to act on. The wrap test now bites — mutating the deadline check to `now >= deadlineMs_` fails both wrap tests and nothing else. Deleting the two `test_wifi_manager` tests lost no coverage: `test_a_fallback_from_an_update_join_abandons_the_update` already covers the never-lands case identically, and the `state_ == updating` half of the fallback guard is not pinned by any test, with or without the deleted one.
Claude merged commit ffc27863e8 into feat/firmware-updates 2026-09-19 13:10:57 +00:00
Claude deleted branch feat/wifi-firmware-fetch 2026-09-19 13:10: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/CampervanControlSystems!49
No description provided.