Verify the image, then commit once the bus proves healthy #52
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/verify-image-commit-on-healthy-bus"
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?
Closes the update path: the digest is checked before the boot slot moves, the node
reboots into the new slot with the radio off, and the new image commits itself on
the first CAN transmit that completes or restarts after 30 s so the bootloader can
revert.
Behaviour:
FirmwareUpdaterhashes the image as it writes, compares it to the manifest andonly then calls the sink's new
activate(). A mismatch, a slot that will notclose and a slot that will not switch all go through
Stage::Failed, which isthe one place CCS-UF-12 has to map.
CanBus::transmitCompleted()latches a transmit another node acknowledged. TWAIreads
TWAI_ALERT_TX_SUCCESS, the MCP2515 readsCANINTFTXnIF, and SocketCANreports nothing, since a write to a vcan succeeds with nobody listening.
BootGuardarms only when the bootloader reports the running imagePENDING_VERIFY, so a bootloader withoutCONFIG_BOOTLOADER_APP_ROLLBACK_ENABLEleaves it disarmed rather than restarting into the same image for ever. Both
boards' prebuilt bootloaders on espressif32@7.0.1 do have it.
with the code the node runs. Costs about 2 kB: lighting 42.3% to 42.4% of its
slot.
Tested with
pio test -e native(390 cases, including newtest_sha256,test_boot_commitand the digest and reboot cases),pio runfor both nodes andpio run -e host_sim. No hardware.Spec section 11 corrected: state 2 is where a failed fetch lands, since a
successful one now reboots.
CCS-UF-6.
Digest-before-switch ordering, the 30 s arithmetic and the SHA-256 all check out (the hash matches Python for every length 0-300, sanitisers clean). Seven things below.
@ -163,0 +159,4 @@`DIAG`, CCT and heater paths. `ALARM_STATE` has a codec but no caller;`ALARM_ACK` has neither.The update path is whole: action 3 joins, fetches, writes the slot, verifies theThis paragraph is under "Not yet implemented" but four of its five lines describe what now exists. Only the missing piece belongs here — the
SYS_WIFI_STATUSstate for a failed update. Cut the rest.Also worth checking against CCS-UF-12's scope: "every one of them goes through
Stage::Failed" is true of fetch failures, but a rollback after the 30 s window leaves the old image up with no record that an update was attempted, so there is nothing for that ticket to map there."a node that boots a bad image and then hangs" overclaims what this can do.
BootGuard::loopis driven fromNode::loop, so an image that genuinely hangs never reaches the deadline check and never restarts. What the 30 s window actually catches is an image that runs but cannot get on the bus. Reword, or say the task watchdog is what covers a hang.@ -143,0 +155,4 @@if ((mcp->getInterrupts() & interruptTxSentMask) == 0) return;transmitCompleted_ = true;mcp->clearTXInterrupts();clearTXInterrupts()achieves nothing:transmitCompleted_is latched on the line above, sopoll()returns early from here on and never reads CANINTF again. The flags re-set on the next frame and stay set for the life of the node. Drop the call.@ -56,0 +56,4 @@// esp_ota_end() has to have run first, or the IDF refuses the partition it has// not finished verifying.bool ArduinoFirmwareSink::activate() {if (open_ || partition_ == nullptr) return false;abort()leavespartition_set, so after an aborted, partly written transfer the state is exactly!open_ && partition_ != nullptr— activate()'s whole guard. Nothing in the sink stops it switching the boot slot to a half-written slot; onlyFirmwareUpdater's call ordering does, and this class has no host tests at all.Clear
partition_inabort()and gateactivate()on a successfulend()(a flag set inend()on ESP_OK) so the sink refuses rather than trusting its caller.@ -45,3 +46,3 @@uint8_t progressPercent() const override;Stage stage() const { return stage_; }// The two below say nothing until this is true."The two below" —
stage()now sits between this and the two accessors it means. Move the comment down toslot()or drop it.@ -91,0 +92,4 @@// section 11.if (boot_ != nullptr && updater_ != nullptr && updater_->installed()) {disable(node, now);boot_->restart();The final
SYS_WIFI_STATUSstate 0 is lost on the lighting node.disable()reports off vianode.send(), which on the TWAI backend only pushes onto its ownTxQueue;poll()releases one frame per loop, andesp_restart()here runs first. On MCP2515send()loads TXB0 over SPI so the frame does go out — the external part is unaffected by the reset.So whether the bus sees the session close depends on which backend the node has. Defer the restart to a later loop once the queue has drained rather than calling it inline; one loop is not enough on TWAI if other frames are queued ahead of it.
@ -0,0 +44,4 @@// 56 bytes: the padding no longer fits in the block holding the last byte, which// is where a hash written from the spec usually goes wrong.void test_an_input_that_pushes_the_padding_into_another_block() {No vector has a length of 63 mod 64, which is the one padding case where
0x80exactly fills the block and the zero fill is empty. It is correct today (I checked every length 0-300 against Python), but it is the branch a future edit is most likely to break and nothing here would catch it. Add a 63-byte input.All seven earlier points addressed. One new finding below; the
closed_state machine is sound — no begin/write/end/activate/abort sequence reachesactivate()with an uncleanly closed slot, and none blocks a legitimate one.@ -91,0 +92,4 @@// status frame on the way out, because a backend that queues one would not get// it onto the wire before the reset; the build identifier in the announce after// it is what says the update took. Spec section 11.if (boot_ != nullptr && updater_ != nullptr && updater_->installed()) {Nothing clears the updater's
Installedstage, so this condition stays true for the life of the object. Ifrestart()returns — a hostBootControl, or a fake — the next session is torn down the moment it starts:onFramesetsconnecting, then this block runs in the sameNode::loopand callsradio_.off()andrestart()again.I reproduced it against this commit with a plain action 1 join after the install: restartCalls=2, offCalls=2, state forced back to 0.
Add
updateStarted_to the guard —endSession()clears it inside this block, so it becomes strictly one-shot per session:With that, the same probe gives restartCalls=1 and the later join survives, and all 50 wifi tests still pass. Unreachable on hardware since
esp_restart()does not return, but the guard should not depend on that.Clean — nothing outstanding. Confirmed the new test fails with the
updateStarted_term removed (Expected 1 Was 2), 392 tests pass, both nodes build, clang-format clean.Agreed on keeping
closed_ = falseinbegin():abort()'s clearing is a side effect its name does not advertise, so the explicit reset next topartition_earns the line.