Skip to content

feat(firmware): CAN bootloader — safety features #364

Closed

Description

@mottihoresh

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 its nodeKind ≠ 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 its nodeId and 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.
  • 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_n for 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, but FLASH_OBR still 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 — 00ff is USER = 0xFF, not 0x00.
  • OpenOCD stm32f1x unlock 0 mass-erases and resets RDP but keeps Data0/Data1; for a truly blank bench chip also run stm32f1x options_write 0 SWWDG NORSTSTOP NORSTSTNDBY USEROPT 0xFFFF. Right after unlock, with the debugger attached, the CPU couldn't run from flash until the option bytes reloaded.
  • Tests vs WRP: per-library Tests/ upload at 0x08000000 and 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.

  1. 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 using BootProtocol.h's register. Harmless on a board without a bootloader. Without the clear, every board would stop after 3 resets.
  2. Tests vs WRP: Tests stay at 0x08000000; document a one-line unprotect step (OpenOCD / STM32CubeProgrammer) and -t bootstrap afterwards to re-protect.
  3. 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 0x7C0 names it; only the matching board binds a session. One unconfigured board on the bus at a time (client rule).
  4. 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestfirmwareFirmware implementation workpriority: mediumWanted and scoped; not blocking today

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions