RFC-0019: Built-in Firmware Flashing #174
Closed
kn4oqw-clint
announced in
RFCs
Replies: 1 comment
|
This has already been implemented. |
0 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
internal/modem— the board table, the raw-termios serial layer, and the port-ownership rule this reuses verbatim; RFC-0013 (the verifier every firmware artifact passes through); RFC-0004 (why per-chunk progress does not go through the event hub)Summary
Flashing a modem becomes an operation Waypoint performs, not a script an
operator is told to run over SSH. One button, one code path, two hardware
transports: the STM32 ROM bootloader over the GPIO UART with BOOT0/nRST driven
from the Pi's own lines, and the Maple DFU bootloader over USB for the stick-form
boards. The firmware itself comes from a pinned, CI-built, minisign-signed
catalog Waypoint publishes — not from a third party's directory layout — and the
board and oscillator are matched from what detection already read off the wire
(#18) rather than encoded in the name of the script the operator picked.
Everything is implemented in Go against the interfaces this repo already has:
AN3155 over the existing termios layer, GPIO lines over
/dev/gpiochip*, DFU overusbfs. No
stm32flash, nodfu-util, no new packages in the image, and noargv-and-stdout contract with someone else's CLI.
The safety argument is the load-bearing part and it is stated first, because
"flashing" is the operation operators are most afraid of: on the GPIO path an
interrupted flash is recoverable by retry as a property of the silicon, and on
the USB path Waypoint refuses the one write that could brick a board rather
than offering it behind a warning.
Motivation
Pi-Star's flashing surface is a set of scripts whose names encode the answer the
operator is supposed to already know:
That is sbin #14/#18: a per-board, per-TCXO script matrix bolted on over years,
which asks an operator to name their board's reference oscillator — a fact
almost nobody buying a hotspot has ever been told, and which picking wrong does not
fail loudly. It detunes the radio and the node transmits anyway.
And sbin #55 is the other half: those scripts fetch firmware from a third party's
release directory and break when that directory is restructured. The upstream
project owes Waypoint nothing and reorganising its own repo is its right; a host
system that treats another project's file layout as an API has taken a dependency
it cannot maintain, and the operator experiences the breakage as Waypoint is
broken.
Waypoint is positioned to fix both, because it already knows the answers. Detection
(#18) reads board family, firmware version, reference oscillator and radio count
out of the modem's own identity string before anything is configured. The operator
should never be asked to type what the modem already said.
Design
1. The safety model: what can be bricked, and what cannot
The two transports have different failure floors, and the design follows from that
rather than from convenience.
GPIO / ROM bootloader — cannot be bricked. The STM32F103's bootloader is
mask-programmed into system memory at the factory. Holding BOOT0 high across a
reset enters it regardless of what is in flash, including nothing at all. An
interrupted write therefore leaves a board that is corrupt as a modem and reachable
exactly as it was a moment earlier — so retry works, always. This is issue #19's
second acceptance criterion, and it costs nothing to guarantee: it is a property of
the part, not of this code. What the code must do is not squander it, which means
driving BOOT0 and nRST itself rather than asking a human to hold a jumper.
USB / Maple DFU — brickable, in exactly one way. These boards do not expose
BOOT0; their reachability depends on a DFU bootloader living in the first 8 KB of
flash. An interrupted application write is still recoverable, because the
bootloader is untouched and still enumerates. An interrupted bootloader write
is recoverable only over SWD with hardware the operator does not own.
The rule that falls out, and the one place this RFC is deliberately less capable
than the incumbent:
Upstream's fix for the RPi 3B+ USB problem is precisely that write — replacing the
bootloader with the long-reset-pulse variant (
make stlink-bl). Waypoint detectsthe condition and says so, with the SWD procedure, instead of performing a
write whose interruption produces a coaster. A refusal that names the remedy is a
better outcome than a progress bar with a 1-in-N chance of ending the board.
2. Firmware provenance: a catalog Waypoint builds
A new repository,
KN4OQW/MMDVM_HS, shaped likewaypoint-stack:pins.env, bumped only by a PR whose description is achangelog reading of the upstream diff. Nothing floats.
arm-none-eabi-gcc,minisign-signs each
.binwith the RFC-0013 release key, and publishes them to atagged release alongside a signed
firmware.json:{ "version": "v1.6.1-wp1", "variants": [ { "id": "mmdvm_hs_dual_hat-14m7456", "board_ids": ["mmdvm_hs_dual_hat", "zumspot_duplex", "lonestar_dual"], "tcxo_hz": 14745600, "duplex": true, "transport": "gpio", "load_address": "0x08000000", "url": "…/mmdvm_hs_dual_hat_fw.bin", "sha256": "…", "sig_url": "…/.minisig" } ] }The catalog and every artifact are fetched through
internal/verifydlwith signatureverification mandatory — not opt-in as it is for reference data. The distinction
is not fussiness: a poisoned host list misroutes traffic, while a poisoned firmware
image is arbitrary code on a transmitter that a licence holder is legally
responsible for. Verified artifacts are cached on disk, so a re-flash and a flash
on a node that has since gone offline both work without a second fetch.
board_idsis the join back tointernal/modem's board table. When a board isadded there, the catalog entry names it — the mapping lives in data, in one place,
and no filename is ever parsed to recover a fact.
3. Choosing the variant: refuse rather than guess
Detection returns a
modem.Resolutionthat already distinguishes three outcomes —one candidate, several, or none — and flashing consumes that distinction directly:
flash, say nothing.
that share an oscillator and a radio count take the same image). Flash it, and
name what was flashed rather than what was guessed.
TCXOAssumed. Refuse, and show the picker with the candidates.A wrong-oscillator image does not fail; it produces a node transmitting off
frequency, which is worse than an unflashed node and much harder to diagnose.
Duplex is a hard filter, not a preference: a single-ADF7021 board flashed with a
dual image is a modem that reports capabilities it does not have.
4. The GPIO path: AN3155 in Go
internal/flash/stm32.goimplements the ST USART bootloader protocol (AN3155)directly, as pure framing over an
io.ReadWriter, testable against a scripted fakeexactly the way
modem/protocol.gois:0x7F, autobaud, expect ACK0x79(NACK0x1F)GET(0x00) — returns bootloader version and the supported command listGET_ID(0x02) — the device ID, checked against the variant's expected partERASE(0x43) orEXTENDED_ERASE(0x44), whicheverGETadvertisedWRITE_MEMORY(0x31), 256-byte word-aligned chunks, address + XOR, length + data + XORREAD_MEMORY(0x11) readback compared against the imageThree choices worth defending:
GET, never hardcoded. F103medium-density bootloaders offer
0x43; later parts offer only0x44. Hardcodingeither is how this breaks silently on the first board of the fast-follow tier
(Full-size MMDVM repeater-class support (fast-follow tier) #25), and asking the bootloader costs one round trip.
a write, not that flash holds what was sent. Reading it back is what makes
"flashed successfully" a claim rather than a hope, and it is also the cheap
detector for a board whose flash is read-protected.
GO. Driving BOOT0 low and pulsing nRST brings theboard up exactly as it comes up from cold power.
GO(0x21) jumps to theapplication with the bootloader's peripheral state still configured, which is a
different machine from the one the operator will have after their next reboot.
The bootloader speaks 8E1; the modem protocol speaks 8N1. Both live in the same
package, so
openSerialgrows a parity parameter rather than acquiring a secondimplementation. Sync is attempted at 115200 and falls back to 57600 (the speed
upstream's tooling effectively uses), because a failed autobaud and a dead board are
indistinguishable from one attempt.
5. GPIO line control: by label, not by number
internal/flash/gpio.gorequests BOOT0 and nRST through/dev/gpiochip*with thev2 line ioctls, selecting the chip by its label (
pinctrl-bcm2835,pinctrl-bcm2711,pinctrl-rp1). Line 20 and line 21 then mean the same physicalheader pins on every Pi, and there is no base arithmetic anywhere in the path.
The sysfs fallback exists for anything the character device cannot open, and that
is where the issue's base-512 requirement is honoured: the export number is
base + offset, withbaseread from/sys/class/gpio/gpiochipN/baseon the chipwhose label matched. Linux 6.6 moved dynamic GPIO base allocation up to 512 and
broke every script that had
echo 20 > /sys/class/gpio/exportwritten into it —which is the whole reason the character device is the primary path and the fallback
computes rather than assumes.
The lines themselves belong to the board table, defaulting to BCM20 (BOOT0) and
BCM21 (nRST) — the MMDVM_HS hat family's wiring — with a per-board override, because
a pin number is a hardware fact about a board and the flash engine should not be
where hardware facts live. Lines are released before MMDVM-Host is restarted; a
held line is a modem the host cannot reset.
6. The USB path is a separate decision — see RFC-0020
Waypoint's launch tier is the GPIO hats, and they cannot use DFU at all: a hat
has no USB connection to the Pi, and its BOOT0 and nRST are on the header, which
is what makes the ROM bootloader path work and makes an interrupted flash
unbrickable. The USB stick boards are a different population — no BOOT0
exposed to the host, reachable only through the DFU bootloader in their own
flash — and supporting them is a coverage decision, not a missing piece of this
one.
It is also the only brickable path, and validating it needs hardware nobody on
this project owns (a ZUMspot USB or Nano hotSPOT is around $150). Shipping an
unvalidated flash path for the one class of board that can be destroyed is the
wrong way round.
So the design, the corrected reset mechanism and the open questions move to
RFC-0020, which is design-only and gated on somebody
having a board. The firmware CI already builds and signs the USB images, so
adopting it later is host-side work alone.
7. Port ownership, and one exclusion that is not obvious
Arbitration is
modem.Holder, reused unchanged: MMDVM-Host is stopped only withexplicit authorisation, and restarted afterwards no matter how the job ends —
failure, panic, or a browser tab closed mid-flash — on a context that does not
inherit the request's cancellation. A node that ends a flash off the air because
someone navigated away is a worse bug than no flashing at all.
The non-obvious one: a flash and a stack update must exclude each other.
internal/stackupdatehealth-gates an update on MMDVM-Host being up. A flash stopsMMDVM-Host for a minute. Run them concurrently and a perfectly good stack update
observes a dead host, concludes it broke the node, and rolls back — a spurious
revert with no visible cause.
detect.goalready makes this argument for probing;flashing holds the port far longer, so the two operations take the same lock.
Also checked before the port is opened: a
gettyon the GPIO UART. It is a normalstate on a stock Pi and it produces "busy" rather than a useful message unless
someone looks for it.
8. Progress: two streams, deliberately
Per-chunk progress must not go through the event hub. Everything published
there is persisted to SQLite by
events/writer.go;a 128 KB image in 256-byte chunks is ~500 progress ticks, and writing 500 rows to
the SD card to animate a progress bar is the kind of thing this project is
supposed to be better than.
So:
GET /api/flash/events(SSE), fed from an in-memoryper-job broadcaster. Ephemeral by construction; a client that reconnects gets the
current state, not a replay.
flash_started,flash_ok,flash_failedon the hub, so theyland in history, on the dashboard and on the LCD like every other node event, and
so "when did this node's firmware change, and from what to what" is answerable
months later.
After a successful flash the engine re-probes with the detector and stores the new
modem.Identity. The identity string coming back changed is the proof the flashtook — better evidence than the bootloader's own ACK, and it is what the operator
sees.
9. API, CLI, UI
All behind the session wall, mirroring the update endpoints' shape. The UI is a
Hardware panel showing the detected board, the running firmware, what the catalog
offers, and a flash button that is greyed with the server's reason when the
match is ambiguous — the same rule
buses.goestablished: the validity verdict comes from the one validator, never re-derived in
JavaScript.
10. What is deliberately not in v1
only. The hats this RFC serves cannot use that path and do not need it.
entry differs (jumper or DTR rather than a Pi GPIO line), so entry is an
interface with one implementation today and the fast-follow tier adds a second.
avrdude's protocol, a separate engine..bin. The provenance argument in §2 does not surviveit. Recorded as an open question, because the demand is real.
Downgrades are permitted: any catalog version may be flashed, older included.
Firmware carries no schema to migrate, and "the new release regressed on my board"
is a real thing an operator needs to escape from tonight.
The contract (test harness)
Every side effect — serial, GPIO, usbfs, catalog fetch, the service holder, the
clock — is injected, so the engine is tested without a hat on the bench:
sync retried, erase command chosen from what
GETadvertised, chunk alignmentand size, XOR checksums, and a readback mismatch aborting the job.
second run against the same fake (modelling half-written flash) syncs and
completes. This is the acceptance criterion in unit form.
candidates ⇒ refusal naming them;
TCXOAssumed⇒ refusal; duplex mismatch ⇒refusal.
to the port — asserted against the fake, not inferred.
and restarted, including when the job fails and when the caller's context is
cancelled mid-write.
(milestones only), while the job broadcaster sees every chunk.
computes its export number from the chip's
base, asserted against abase-512 fixture and a base-0 one.
Manual, on the reference bench (MMDVM_HS_Dual_Hat on a Pi 3): flash from the UI
with no SSH; confirm the identity string changes; pull power mid-write and retry;
confirm a deliberately wrong-oscillator variant is refused before any write.
Alternatives considered
stm32flashanddfu-util(the incumbent). Rejected. It addstwo packages to the image, makes another project's argv and stderr into
Waypoint's API — the exact class of breakage sbin Cross mode gateways #55 reports — and reduces
progress streaming to scraping a percentage out of a text stream. The protocols
are small and well specified; the C tools' value is portability Waypoint does not
need, because Waypoint targets one board family on one OS.
.binfiles by URL + SHA-256 instead of buildingthem. Rejected as the end state. It is strictly better than scraping, and the
catalog format could express it, but it leaves an unsigned third-party artifact
being fetched by a node that then runs it on a transmitter — the trust boundary
RFC-0013 exists to draw. Building in CI also means the firmware and the host are
versioned together, which is what makes "your board needs ≥ 1.4.8 for this" a
thing Waypoint can enforce rather than mention.
gousb) for the DFU path. Rejected. usbfs is a handful ofioctls,
x/sysis already a dependency, and cgo would end the staticcross-compiled build the release pipeline depends on.
hd44780and the modemserial layer set this precedent already.
is attached to the node, not to the laptop; a browser path would work only for
the USB boards and only on some browsers, and it would put the node's own
arbitration rules outside the node.
are a large share of the installed base, and "use a Windows machine" is the
answer this project exists to stop giving.
Open questions
DFU specifics need bench confirmation. (Partly closed, 2026-07-28.) The
application load address is no longer an inference: the firmware's own
bootloader.ldplaces ROM at0x08002000with 120K, againstnormal.ld's0x08000000with 128K — the 8K difference being the Maple bootloader, which isexactly the region §1 refuses to write. The alt-setting and the
1EAFresetsequence are still transcribed from upstream's tooling rather than a datasheet,
and the reference bench has no USB board, so the DFU path still does not ship
until one is on it.
Note also that the GPIO hats and the USB sticks are disjoint populations: a hat
has no USB connection at all, and a stick does not expose BOOT0 to the host. The
DFU path is not a fallback for the hats — it is the only path for a different
set of boards.
Bootloader recovery. Do we publish an SWD procedure (an ST-Link clone is a
few pounds) as the documented escape hatch for the 3B+ long-reset upgrade and
for a board someone bricked elsewhere — or stay silent and let it be a return?
Operator-supplied firmware. Developers building their own MMDVM_HS want to
flash it. An operator-registered signing key in the store is the honest shape; a
plain "unverified flash" toggle is the easy one. Neither is in v1.
Fork maintenance. MMDVM_HS upstream is quiet. The fork inherits the rebase
duty
pins.envalready documents for MMDVM-Host, and that duty needs a namedowner before the first bump, not after.
Who publishes the catalog. If the firmware repo's release publishes
firmware.json, a firmware release ships without awaypointdrelease andwaypointdpins only the key and the URL. If Waypoint's release assembles it(the
update.jsonprecedent), the two are coupled. This RFC proposes theformer; the coupling argument deserves a hearing.
Migrated from
docs/rfcs/0019-firmware-flashing.md; the drafting history is in the git log.All reactions