Repository navigation
feat(firmware): CAN bootloader — safety features #364
Description
Goal
The bootloader protects itself, knows its node's identity even with a broken app, and can rescue nodes whose firmware misbehaves.
Depends on #363 (bootloader core).
Features
- Self write-protection — on first boot the bootloader checks its 4 KB block's WRP bit; if unprotected, it reads all option bytes, rewrites them with only WRP changed (read protection stays off; USER + Data0/Data1 kept), and resets. A power cut in that few-ms window is recoverable over ST-Link (
stm32f1x unlock+ reflash) — the bring-up guide documents it. Identity copy— moved to feat(firmware): CAN bootloader — core #363 (2026-10-25): the core bootloader needs NODE_ID with a broken app for its fault-injection done-when. feat(firmware): CAN bootloader — core #363 builds the option-byte read-modify-write routine (Data0 = NODE_ID, Data1 = NodeKind, synced from the valid app's identity block) and the "unconfigured" announcement (Data0 =0xFF); WRP below reuses that routine.- NODE_ID rule (enforced here, not in the host) — an upload never changes NODE_ID: commit refused if the image's
nodeId≠ Data0, or itsnodeKind≠ Data1. - First image on an unconfigured board (over CAN) — for a base board shipped with the bootloader only, whose owner has no ST-Link. An unconfigured bootloader (Data0 =
0xFF, no valid app) accepts its first image whatever itsnodeIdand adopts that ID (and kind) into Data0/Data1; from then on the NODE_ID rule applies. The image brings its own ID — no assign step, no random ID.- Addressing: a shared unconfigured pair from the free range —
0x7C0command (bridge → unconfigured) /0x7C1announce (unconfigured → bridge). The announce carries the chip UID (0x1FFFF7E8) and is sent at a randomized ~1 s interval; CONNECT names the UID, so only that board answers.- As built (audit 2026-10-27): the interval is a fixed 1 s (
BootProtocol.h:113ANNOUNCE_INTERVAL_MS = 1000), not randomized. Not recorded at merge (feat(firmware): bootloader self-protection, NODE_ID rule, crash-loop counter, catch at boot (#364) #377). Acceptable as built, since the client's "one unconfigured board at a time" rule (SkyHawkClient#177) covers the collision case. The bootloader is frozen (feat(firmware): Gateway updates its own bridge over USART2 #367), so this stays.
- As built (audit 2026-10-27): the interval is a fixed 1 s (
- One unconfigured board on the bus at a time — the client says so before the upload. Two announcing at once collide rarely, and only lose an announce.
0x7C0/0x7C1are already reserved by feat(firmware): CAN bootloader — core #363 (BootProtocol.hUNCONFIGURED_CMD_ID/UNCONFIGURED_RSP_ID,02-can-protocol.md); feat(firmware): CAN bootloader — core #363's bootloader already announces unconfigured on0x7C1(a0 01 ff ff 03 <KB>) — this adds the commands (filter on0x7C0, UID match).- Client: "From file…" to an unconfigured board (SkyHawkClient#177).
- Released boards don't need this — they get the combined bootloader + app image at first flash (feat(firmware): CAN bootloader — core #363).
- Addressing: a shared unconfigured pair from the free range —
- Set-node-ID command — separate and explicit, only inside a bootloader session: invalidates the app (erases its first page) and writes Data0 in one option-byte write, so the boot-time sync can't revert it; the upload then proceeds normally. Host side (client): offered only if supported, the target ID is free on the bus, and the kind matches — otherwise the upload is rejected.
- Crash-loop counter — backup register, incremented before each jump to the app, cleared by the app after READY (N5). ≥ 3 → stay in the bootloader, announce "app unstable". Survives resets, not power loss.
- Catch at boot — after every reset, listen ~200 ms on
BOOT_CMD_nfor a "stay" command before starting the app. Rescues an app that ignores enter-bootloader (client: "power-cycle the cockpit" while the Gateway sends "stay"). - Idle timeout — entered on request but no session follows → back to a valid app.
Done when
(audit 2026-10-27) Recorded in #377: the unconfigured first image, crash-loop "unstable" after 3 boots, and catch-at-boot. WRP was shown as FLASH_WRPR = fffffffe plus a refused ST-Link erase of page 0, not as a CAN-side write. That's a valid substitute, since a CAN upload can't address the bootloader block at all (every address is relative to APP_ADDR). Repurpose over ST-Link wasn't re-run after #377 rewrote that path (combined WRP + Data0/Data1 write), so it's carried to #290 § Carried bench checks.
Each feature is bench-tested: an unconfigured board takes its first image over CAN and comes back as that image's node; WRP blocks a CAN-side write to the bootloader block; repurpose over ST-Link re-syncs Data0; a deliberately crashing app ends "unstable" after 3 boots; catch-at-boot rescues an app with CAN disabled.
Client side
OpenSkyHawk/SkyHawkClient#177 (From file… and change-node-ID-first), OpenSkyHawk/SkyHawkClient#175 (stuck / app-unstable states) — design: https://claude.ai/artifact/R8DXWWC4uyAby54tBQLo8v (Cockpit board).
From #363 (bench, 2026-10-25)
- Option-byte reload: after the bootloader's read-modify-write +
NVIC_SystemReset(), the stored option bytes (0x1FFFF800) held the new Data0/Data1, butFLASH_OBRstill showed the old values. The bootloader reads the stored halfwords, so the NODE_ID copy is unaffected — but WRP / RDP enforcement comes from the loaded values. Check whether a system reset reloads them on the F103 or a power-on reset is needed before relying on self-protection; the WRP check must read whichever the hardware enforces. - Reading the dump: a stored option halfword is
complement << 8 | value—00ffis USER =0xFF, not0x00. - OpenOCD
stm32f1x unlock 0mass-erases and resets RDP but keeps Data0/Data1; for a truly blank bench chip also runstm32f1x options_write 0 SWWDG NORSTSTOP NORSTSTNDBY USEROPT 0xFFFF. Right afterunlock, with the debugger attached, the CPU couldn't run from flash until the option bytes reloaded. - Tests vs WRP: per-library
Tests/upload at0x08000000and overwrite the bootloader today (documented); with WRP they will fail to flash — decide here (move Tests behind the bootloader too, or document lifting WRP). - Size budget: feat(firmware): CAN bootloader — core #363 bootloader is 2,876 B → ~1.2 KB for this ticket.
Plan (signed off 2026-10-25)
Builds on #363 (merged, PR #376). One PR.
- Crash-loop counter + the app-side clear (moved here from feat(firmware): node and bridge support for CAN updates #365). Backup register DR2, incremented before every jump; ≥ 3 → stay,
BootState::APP_UNSTABLE; cleared on a committed image. The app clears it once healthy — PanelGroup / PDU after READY, PanelBridge at the end of its boot — through one STM32Board call usingBootProtocol.h's register. Harmless on a board without a bootloader. Without the clear, every board would stop after 3 resets. - Tests vs WRP: Tests stay at
0x08000000; document a one-line unprotect step (OpenOCD / STM32CubeProgrammer) and-t bootstrapafterwards to re-protect. - Unconfigured addressing: every announce grows to 8 bytes with a 16-bit hash of the chip UID (bytes 6–7) — also lets the bootloader side spot duplicate NODE_IDs. CONNECT on
0x7C0names it; only the matching board binds a session. One unconfigured board on the bus at a time (client rule). - Idle timeout 30 s: entered on request or caught (app still valid) with no command for 30 s → back to the app. Never after BEGIN.
As written above: self-WRP (checks the stored option bytes, so a stale OBR can't loop; one combined option write with the Data0/Data1 copy; bench first checks whether a system reset reloads OBR), NODE_ID rule at END (new WRONG_NODE / WRONG_KIND), SET_NODE_ID in a session (erase app page 0 + Data0 in one option write, reset; not for the bridge, IDs 1–63), first image on an unconfigured board adopts its ID, catch-at-boot 200 ms (CONNECT on BOOT_CMD_n, BootState::CAUGHT). Spec + docs + the bring-up WRP recovery recipe in the same PR.
Activity
feat(firmware): bootloader self-protection, NODE_ID rule, crash-loop …