OBC OpenBikeComputer / docs

Firmware updates

For most of this project's life the only way to change the firmware was a debug probe over SWD — fine on a bench, useless in a tent. A bikepacking computer needs to take an update the way it takes a route: a file on the SD card, or a push from the phone, applied by the device itself. This page is how that works and, more importantly, why it is safe to do to a device you depend on to get home.

The whole mechanism is one application slot plus a tiny bootloader, and a staged update file the device installs into itself. There is no A/B pair — the shipping image is already ~870 KB and growing, and a second full slot doesn't fit the chip's life. Instead the design leans entirely on verifying before erasing and a rollback snapshot, so a single slot is never left in a state that can't boot. The byte-level formats are normative in OBCU_Spec.md — the same tier as the OBCM / OBCR format specs; here we cover the design and the trust model.

The trust model

Everything below hangs off four invariants. They are not aspirations — each is a host test in the shared obc-dfu crate, the same no_std code the app's armer and the bootloader's install engine both run.

  • Verify before erase. The bootloader never touches the app slot until a

CRC-32 has passed over the complete staged image. A truncated download, a bit-flipped card, a half-copied file — all of them are rejected at zero cost: the running firmware is still there, untouched.

  • Armed is idempotent. Arming an update is a durable record; the staged

file on the card is the source of truth. Lose power anywhere mid-install and the state is still Armed, so the entire flash pass simply reruns on the next boot. There is no half-installed state to recover from.

  • One trial boot, then rollback. A freshly-flashed image boots exactly once

on trial. It only becomes permanent when the running app confirms it is healthy (first frame presented, card mounted). An image that crashes or wedges before confirming is rolled back to the snapshot of its predecessor on the next boot — and a hardware watchdog guarantees a wedged trial becomes a next boot: the bootloader starts the dog itself, with the app's own 24 s config, right before jumping into the trial, so the guarantee holds even on a cold power-on where nothing had started a watchdog yet. The same dog is minded on the way in, too — the arm's warm reset carries the app's running watchdog into the bootloader, which adopts and feeds it through the install so a slow SD card can never get an update reset mid-flash.

  • A torn state page is Idle. The one channel between app and bootloader is a

CRC-framed blob in a dedicated RRAM page. Anything that doesn't cleanly decode — a blank page, a half-written line, a caught bit-flip — means "no pending update, run the app", never a garbage install. Safety is the default outcome of corruption, not a case that has to be handled.

Read as a state machine, the update is a short cycle through three states, with the bootloader doing the dangerous work in the middle and every failure edge landing on a bootable image:

The update state machine — one slot, verify before erase Idle running the app the normal state Armed update staged + rollback snapshot obc-boot — install engine verify CRC over raw SD extents → flash app slot → readback Trial new image, one boot unconfirmed = suspect app arms — validate CRC-32, snapshot ROLLBACK.BIN, reboot reboot into obc-boot power loss mid-install ⇒ still Armed — redo flash ok → Trial verify fails — arm cleared, old app intact (zero cost) app confirms healthy → Idle no confirm next boot → reflash ROLLBACK.BIN
The app is the only actor that can arm (validate the staged file, snapshot the running image, reboot) and the only one that can confirm (write Idle once it is healthy). The bootloader does the one irreversible thing — flashing the slot — but only after a full-image CRC passes, and it always leaves a bootable image: a bad stage clears the arm and runs the old app, an unconfirmed trial reflashes the snapshot. A torn state page short-circuits the whole diagram to Idle.

The single stretch where the app slot doesn't hold a complete, verified image is inside the bootloader's flash pass — and that window is covered by invariant 2: the state is still Armed throughout, so a power loss there just reruns the pass from the staged file. Nothing the rider can do turns the device into a paperweight; the worst case is "reinsert the card and power-cycle".

There is one refinement to that worst case. If the card is unreadable while an update is armed — it died in the drawer between arming and rebooting, or the rider swapped in a fresh maps card — the bootloader can't stream the staged file at all. For a rollback, or once the flash pass has already started writing the slot, it keeps retrying forever (never abandon a slot that might be half-written). But an Armed arm whose flash pass hasn't begun has touched nothing — the old app is still whole at its slot base — so after about a minute of triple-blinking the bootloader abandons the arm: it clears the state back to Idle, records that the arm was abandoned, and boots the old firmware. The rider sees a one-time "update abandoned — card unreadable" card and can re-arm once the card is back, instead of staring at a device that is holding perfectly good firmware hostage to a card that never returns.

CRC-32, no signatures — on purpose (v1). Integrity is a CRC-32/IEEE over the whole image, end to end. There is no cryptographic signature: physical access to the card is already root on an open device, so the meaningful gate is the human at the install step, not a key. The OBCU header reserves bytes for a signature scheme if internet-sourced OTA ever lands.

Three ways an update arrives

A staged update is one file, /UPDATE.BIN, in the card's root — an OBCU container (a 64-byte header with the image length, CRC-32, and a git describe version string, then the raw application image). There are three ways it gets there, and exactly one way it gets installed.

  • Card sideload. Copy UPDATE.BIN onto the card from any computer, put the

card back, and choose Settings → System → Firmware → "Install update from card". The device scans and validates the file, shows what it found (the installed version → the staged version, plus a no-undo warning), and installs on a Select press. This is the primitive contract: a file on a card, nothing more. The row is disabled while a ride is recording, because arming reboots the device.

same UPDATE.BIN over the link: it uploads a fwImage object (§7.6 of the BLE spec), the device writes it to the card verbatim, and then the app sends an installFw command (§4.4) to ask the device to install it.

  • USB from the browser. The map builder's device step does the same two

steps over the cable — the object model is the transport's guest, so this is the identical fwImage upload followed by the identical installFw request. It reads the running version from the Device Information service, compares it against the published release, and checks the container before spending the transfer on it: magic, header version, header CRC-32, the image CRC-32 over the bytes that follow, and the slot ceiling. That is the same decode rule the armer applies and it replaces nothing — the device still verifies over what actually landed on the card. What it buys is that "that isn't a firmware update" arrives in a second instead of after an upload. A device running a development build reports a git hash rather than a version, which does not parse, and no update is ever offered for it.

The crucial rule is shared by all three paths and stated plainly in the BLE spec's security posture: installing always confirms on the glass. A peer can stage an image — that is all a bonded link or a plugged-in cable authorises — but it can never arm or reboot the device on its own. installFw merely posts a request; the device runs its own scan and shows a confirm card, and the update proceeds only on a physical Select press by the rider, exactly like the pairing-passkey pattern. There are no silent installs, ever. The running firmware's version a peer displays is read from the standard DIS Firmware Revision characteristic, so after a confirmed update it simply reflects the new image on the next connect.

A press that can't arm is never silent either. Between the confirm press and the reboot the device shows a "Preparing update..." spinner while it snapshots the rollback and writes the boot record — and if that pass can't finish it lands a plain error card on the glass rather than spinning forever. It refuses cleanly when a ride is recording, when a just-finished ride hasn't been saved yet, or when the card is gone, and reports an arm-time failure (the file no longer validating, the rollback snapshot or the boot record failing to write) the same way. In every one of these cases nothing was armed, so the rider dismisses the card and the old firmware keeps running.

The moment the guards pass, the spinner is swapped for a static "Installing update" card — the last frame the app ever paints before the reboot. A memory-in-pixel panel holds its image without being scanned, so that card stays readable on the glass through the entire bootloader install (the bootloader never draws — it only keeps the panel's COM lines alternating, see below). The card is deliberately static: a spinner would freeze mid-sweep at the reset and read as a hang, so the copy names the blinking LED as the "still working" signal and warns to keep power on. The next thing the rider sees is the new image booting.

The reboot's outcome is never silent either. Before rebooting into the bootloader, the armer leaves a small breadcrumb (the staged version + the arm's generation) in the settings page. The bootloader, in turn, records what happened into the Idle it lands on — accepted, rolled back, or rejected before the erase — so the first boot afterwards reads a recorded fact rather than guessing from version strings (which cannot tell a rollback from a reject when the running and staged images share a version). Reconciling the breadcrumb against that record, the app shows a one-time verdict card: "Updated to vX" once the new image's first healthy frame confirms the trial (or when a first install was accepted after an unconfirmed trial), or "UPDATE FAILED" when the armed image is not what's running — either the arm was never consumed (a stale or missing bootloader, which the app then clears so it can't fire by surprise later) or the stage was rejected / rolled back. A plain boot has no breadcrumb and shows nothing.

The RRAM layout

The design fits in the device's non-volatile RRAM as a fixed partition the small bootloader and the big application both agree on. The app is always linked at 0x8000 — there is one build shape, no bootloader-less variant — so dev flashing is "flash obc-boot once, then iterate on the app exactly as before".

RRAM partition — one app slot, a 32 KB bootloader, two small pages obc-boot 32 KB app slot obc-fw-nrf54l, linked at 0x8000 · ~1484 KB BOOT_ STATE 4 KB SETTINGS 4 KB 0x0000 0x8000 0x17B000 0x17C000 the BOOT_STATE page is the only app ↔ bootloader channel — a CRC-framed blob, torn ⇒ Idle SD card root UPDATE .BIN ROLLBACK .BIN
The bootloader lives in its own 32 KB slot below the app and is flashed once; the app never moves it. BOOT_STATE is the single 4 KB handoff page — the armer writes an Armed record there and the bootloader reads it, both through the shared codec, and any unclean read is Idle. The staged UPDATE.BIN and the ROLLBACK.BIN snapshot live on the card, not in RRAM: the app resolves them to raw SD block runs so the FAT-free bootloader can read them with plain SPI block reads.

The bootloader is deliberately tiny and dumb — no FAT, no BLE, no display driver, no async executor, just blocking GPIO + SPI + RRAMC. All the logic that could be wrong (the decode, the boot decision, the install sequencing) lives upstream in obc-dfu and is host-tested with mock IO; the bootloader is a thin driver that maps the engine's outcome onto an LED code. That split is what lets the safety invariants be tested rather than trusted.

The one panel courtesy it performs needs no drawing at all: on every install path it parks the display's scan pins driven-low (so nothing floats into the glass while the app slot is rewritten) and keeps the panel's anti-DC-bias COM wave alternating in software, paced off the CPU cycle counter from the same chokepoints that pet the watchdog. That is what lets the app's pre-painted "Installing update" frame survive on the glass for the whole flash — and it removes a real electrical stress: memory-in-pixel cells must never sit under a DC bias, which is exactly what a frozen COM line would apply for the multi-ten-second install.


Where this lives