Files
roro9stack/site/content/dev/build/how-an-update-works/index.md
T
twislaandClaude Sonnet 5.5 3b4100dc6a
CI / build (pull_request) Successful in 8m38s
Site / build (pull_request) Successful in 9s
Site: the developer docs (phase 4), with the Debug Builds and the Debug Console first
/dev/ has Debug Builds and the Debug Console (builds and the token, the
console and its protocol, files and screenshots, driving the UI, crashes and
Safe Mode, the command reference), Build, test and release (including how an
update works), the architecture decisions and the milestone plans.

Generated from the repository by site/tools/gen_dev_docs.py: the ADRs, the
milestones, the README's sections, and the command reference, read from the
firmware's own `help` text. The pages are committed (Zola cannot read outside
its folder); the Site workflow checks they are current, and now also runs
when src/main.cpp changes. M0, M1 and CONTEXT.md are not published.
README: the gnss commands that the table lacked.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 21:25:33 +02:00

5.3 KiB

+++ title = "How an update works" description = "The signed Update File, the four ways to get one onto a device, Probation and Rollback, and the check that sits in front of all of them." weight = 3 [extra] tag = "Updates" diagrams = true +++

A Firmware Update installs one signed file, an Update File (.ota). Every way of delivering it ends at the same gate, and a new firmware must prove itself before it is kept. The decisions are ADR 0003 (the signature), ADR 0005 (when the new firmware crashes) and ADR 0008 (who signs releases).

The Update File

{{ diagram(src="update-file.svg", min_width=580, caption="A 160-byte header, then the image. Bytes 0 to 79 are signed.") }}

  • A 160-byte header: the magic RORO-OTA, the format, the header size, the image size, the image's SHA-256 and the version (bytes 0 to 79, the signed part), then the signature's length, the signature and reserved bytes.
  • The signature is ECDSA P-256 over the SHA-256 of bytes 0 to 79, checked by the firmware against a public key compiled into it (keys/ota-public.pem, committed), before anything is written.
  • Then the image, hashed while it is written; at the end the hash must equal the one in the header.

Make, check and push one:

scripts/ota_keygen.sh                                  # once: creates the key pair. The private key goes to ~/.config/roro9stack/ota-key.pem (never committed); the public key to keys/ and the firmware source
scripts/make_ota.py firmware.bin v0.12.0 out.ota       # wraps and signs an image (the key: $RORO_OTA_KEY or the default path)
scripts/ota_verify.py out.ota                          # checks a file the way a device does, on the PC
scripts/ota_push.py out.ota 10.39.39.12                # pushes it to the Update Service, TCP 3232
scripts/flash.sh --ota 10.39.39.12                     # builds, signs and pushes in one go (add --debug for a Debug Build)

ota_push.py prints the device's answer (OK …), or says the device refused the update and hung up, with the reason on the device's screen: the header is checked first, so a refused file stops mid-transfer.

Four ways in, one gate

{{ diagram(src="ways-in.svg", min_width=580, caption="Every path ends at the same Update Service and the same signature check.") }}

  1. Push over Wi-Fi to TCP 3232: scripts/flash.sh --ota. The device always listens while Wi-Fi is connected.
  2. From the SD card: a .ota in /updates, installed from Settings → Firmware, from the Storage App, or with the install <path> command. Put it there by hand, with scripts/rdbg.py put over Wi-Fi, or with scripts/sd_put.sh over USB.
  3. From the project's releases on Gitea, which the device downloads itself over TLS (Settings → Firmware, or update check / update install <tag>): see Flash and update.

All three feed the same parser: the header and signature are checked first, the image is written to the other app slot while it is hashed, and the slot becomes the next boot only if the hash matches. Anything wrong ends in a Toast and nothing changes. A downgrade is allowed, and shows "older than the installed version".

The device then restarts (waiting up to 60 seconds if you are typing) into Probation.

Probation and Rollback

{{ diagram(src="probation.svg", min_width=620, caption="The life of an update: install, restart, Probation, confirmed. A crash or a restart before the last step sends the device back to the previous firmware.") }}

A new image is not trusted at first:

  1. Install: the image goes to the other slot and the OTA data marks it new.
  2. Restart: the bootloader turns new into pending verify and boots it.
  3. Probation: the new firmware must boot, draw its UI, start its Services, run 30 seconds without a crash, and reconnect Wi-Fi within 3 minutes if one is configured.
  4. Confirmed: it marks itself valid and a Toast says Updated.

If it crashes or restarts first, the bootloader marks the image aborted and boots the previous firmware again, which says the update failed. Meanwhile that previous firmware stayed in its slot: it is the way back.

Two lines of defence. The bootloader's rollback is the first. The firmware counts its own boots on Probation, very first thing in setup(), and reverts itself on the second unconfirmed start, as a second line. And Arduino-ESP32 normally marks an image valid before setup() runs, which once hid the bootloader's rollback entirely; the firmware overrides verifyRollbackLater() so an image stays pending until Probation confirms it.

Who signs releases

A tag v* is built, signed and published by Gitea Actions, with the signing key held as a repository secret as well as on the maintainer's machine (ADR 0008 says what that costs and what limits it). The release step checks the signed file against the public key in the sources it built, so a wrong secret stops the release instead of publishing a file no device accepts.

If the private key is ever lost, the next update has to go over USB, carrying a new public key. Someone with USB access can always flash anything: only Wi-Fi and SD card updates are guarded, by design (ADR 0003).