Skip to content

feat: update a board over CAN from a firmware release #175

Open

Description

@mottihoresh

Consolidated 2026-10-27 against firmware 0.3.0 (firmware-v0.3.0).

Firmware refs are on OpenSkyhawk f9fed409, the 0.3.0 release commit. FP = Firmware/ScratchPad/FirmwarePlan/. The release layout (zip, manifest.json schema 2, path rules, SHA-256) is in #192; this ticket only takes images from its cache.

Goal

Update one board on the bus (target 1–63) from a firmware release, from its row in the Firmware sheet or from its Cockpit panel. Today the release images are the PDUs only: in 0.3.0 the entries with a nodeId are nodes 3 / 4 / 5, plus node 0, which is #187's (.github/workflows/firmware-release-assets.yml:106-115). No release has PanelGroup firmware yet, so today a PanelGroup node updates from a file in #177 (OpenSkyHawk/OpenSkyhawk#380). The flow is driven by the manifest's nodeId: a future release that ships a PanelGroup image for a NODE_ID uses the same flow unchanged.

Design

The Cockpit board of the client design (Firmware sheet, node / PDU sheet → Firmware, the update dialog): https://claude.ai/artifact/R8DXWWC4uyAby54tBQLo8v. Markers show this ticket's parts. The design dictates the UI and its interactivity. This ticket names the design's states, and specifies only what the design can't show: firmware facts, logic and tests.

  • Final boards (2026-10-28; line refs are to the artifact's version 1793186986-be4e):
    • node / PDU sheet › Status › Firmware: CockpitMap.dc.html:340-345, :403-408, fwDetail :877-907;
    • Firmware sheet rows: :514-518;
    • the dialog's board path: :551-624, upVals :1018-1220;
    • fail texts: :955-1001;
    • footers: :1199.
  • The DESIGN-ONLY injections (CockpitMap.dc.html:1181-1182): Write error · Retries exhausted · Crash loop · No response · Wrong board · Too big · No bootloader · Wrong version · No confirm · Client cut off · Gateway power-cycled.

Scope

  • Confirm dialog: the shared update dialog from feat: update the Gateway from a firmware release #173 PR 2, confirm step as drawn (upVals, a node's path).

    • Shown as drawn: title and sub (from → to · release), the checks, the notes (PDU calibration, panels pause), the mixed-major warns with their needAck acknowledgement, and the footer and buttons. Update is disabled until every check passes.
    • Logic behind the checks:
      • DCS relay stopped (offers Stop relay). While flashing, the Gateway drops the client's export bytes without a NACK or a counter (FP03:433-435).
      • Gateway connected and able to update boards. UPL_INFO answers: a Gateway older than 0.3.0 has no uploader and lets the request time out (FP03:405-407, :441). Its cockpit side isn't behind (feat: update the Gateway's cockpit side from a firmware release #187's check: the node-0 row = bridgeFw), because the relay to the boards is in the cockpit side's firmware. Order: USB side, then cockpit side, then boards (docs: bootloader rollout, first-setup page and video script OpenSkyhawk#380).
        • As drawn (CockpitMap.dc.html:1052-1054): no uploader → "The Gateway needs firmware 0.3.0 or newer to update boards — update the Gateway first." with Update the Gateway; cockpit side behind → "Update the Gateway's cockpit side first — it's behind its USB side." with Update cockpit side.
      • Board has a bootloader. Its row reports a version and its bootloader ≠ 00. BOOTLOADER 00 also means not reported (NodeStatus.h:95-96): a board that was running before the bridge booted reads 00 until its READY or bootloader announcement arrives, so a row with version 000000 reads not reported first (fix: firmware version handling — supported major 1, honest "not reported" copy #191). In the design a reported 00 is the row's manual update only chip, and Update isn't offered there (fwInfo can). Boards on 0.2.0 or older have no bootloader and need the release's -full.hex by ST-Link once (Firmware/release/README-node.md:7-9). Flashing them anyway costs 10 s and ends NO_BOOTLOADER (feat(firmware): streaming bootloader transfer and the Gateway uploader (#366) OpenSkyhawk#379).
      • Image from the cache: feat: firmware release check and local cache #192 getFile({ kind: 'release', version }, { nodeId, format: 'bin' }), SHA-256 checked there. The entry is found by nodeId, whatever the board is. No image for this NODE_ID (today, every PanelGroup node) → not offered: the row's custom firmware chip.
      • Built for this node ID and kind (the design's "Built for #n · kind — matches this board"). Both come from the image's identity block (read with feat: update the Gateway's cockpit side from a firmware release #187's identity-block parser), since the manifest has no kind. Compare them with the row's NODE_ID and nodeKind (NodeStatus.h:174-180). The Gateway also refuses a wrong kind or ID before erasing.
      • Fits. The client can only check the image against UPL_INFO_ACK.maxImageKB (124, UploadProtocol.h:24). The board's flash size is known only to the Gateway, which reports TOO_BIG_FOR_BOARD before erasing (room = flash − 4 KB; FP07:420). As drawn: "Fits the 124 KB limit; the board checks its own flash before anything is erased" (CockpitMap.dc.html:1059).
    • Facts for the notes: the board is out of action for the whole flash, about 3–5 s. That's 3.2 s measured for a 27.7 KB image and 4.4 s with one retry (FP07:457-459). The 33 KB PDU image hasn't been timed.
      • A board is about 5 s (measured 3–5 s). The rounding rule is feat: update the Gateway from a firmware release #173's. PDU calibration stays on the board: an update erases only the pages the image needs, and the calibration is in the last 1 KB page (BootProtocol.h:47, PduConfig.h:117).
      • As drawn: "Fits the 124 KB limit; the board checks its own flash before anything is erased"; the notes "PDU calibration — stays on the board — a firmware update doesn't touch it." and "Panels pause — the board restarts once — its panels stop for about 5 s."; the foot "33 KB · about 5 s".
  • Stage and flash through the uploader client feat: update the Gateway's cockpit side from a firmware release #187 lands, which drives the Gateway's uploader (firmware feat(firmware): Gateway uploader — staging and CAN flash OpenSkyhawk#366; FP03 § Firmware upload, FP07 § Uploader). What's board-specific here:

    • target 1–63, newNodeId = UPL_NODE_ID_KEEP;
    • the image from feat: firmware release check and local cache #192 getFile (above), staged with UPL_BEGIN {imageLength, imageCrc from the identity block};
    • relay export is paused for the session, and _NODE_STATUS keeps flowing (FP07:433-436).

    The messages, NACKs, KEEPALIVE, UPL_ABORT on close (the image stays staged after a result, so Retry re-sends UPL_FLASH without re-uploading), and the calibration / upload exclusion are feat: update the Gateway's cockpit side from a firmware release #187's uploader client.

  • Live phases: the design's step list for a node (phasesFor): stage → boot → write (with %) → verify → restart → ver. setid is feat: install a specific firmware version or a file — rollback, node-ID checks, history #177's. The UplPhase → step mapping is feat: update the Gateway's cockpit side from a firmware release #187's.

    • block / blocks count the bootloader's 64-byte blocks, not the upload chunks (517 for a 33 KB image). That's the source of Writing · n%.
    • UPL_STATUS comes on each phase change and every 250 ms while writing (Uploader.cpp:183, :479).
    • Confirming waits for the board's row to show the app running, newer than GO: same version → OK, other version → VERSION_MISMATCH, 10 s → TIMEOUT (FP07:426).
    • The result comes from UPL_RESULT: result, target, version, attempts, seconds (UploadProtocol.h:118-126). The done screen reads " reports ".
    • The board's tile shows ↻ while it's updated: the row's updating chip (fwInfo run).
  • Failures: the design's states (tickUp injections, failUp, the fail step's Close / Install another version… / Retry). The logic behind them:

    • Retries: the Gateway retries 2 times by itself (UploadProtocol.h:68). The retry note and its rule are feat: update the Gateway's cockpit side from a firmware release #187's ("Write interrupted — retrying automatically (n of 2)").
    • Failed after the erase: the design's exhaust → Stuck in its bootloader. WRITE_FAILED / VERIFY_FAILED after the retries. Copy (CockpitMap.dc.html:970-973): "writing failed after 2 automatic retries. Its bootloader is intact and waits for an image — nothing is lost. Retry, or install another version."
    • After restarting:
      • The design's crash → App unstable. It shows up as TIMEOUT: an app that crashes before it reports healthy ends APP_UNSTABLE after 3 resets (BootProtocol.h:183-187). Copy (:992): "the new firmware restarted 3 times without starting up properly, so its bootloader stopped starting it. Install another version — the earlier versions saved on this computer are listed in its Settings tab.", or with no release image "…Install a working build with From file… in its Settings tab."
      • VERSION_MISMATCH → Wrong version — "it reports 1.0.0, not 1.0.1. Nothing is retried automatically." Foot: "It runs the version it reports." (:993).
      • TIMEOUT → No confirmation — "It didn't report its new version after the update." Foot: "Check its version on the Firmware sheet." Confirmation is the board's version report after it restarts (user, 2026-10-28). In Update all's queue (feat: Update all — the Gateway first, then the boards you pick #176): "didn't update — check its version".
    • Stuck / unstable → the dialog offers Close · Install another version… · Retry, or Close · Install from a file… · Retry for a board with no release image (CockpitMap.dc.html:1203-1205), and the board shows in Attention until it's fixed. A committed image or a power cycle clears the crash counter (FP12:107-113).
    • Refused, board untouched: NO_RESPONSE, NO_BOOTLOADER, ID_MISMATCH, KIND_MISMATCH, TOO_BIG_FOR_BOARD, all decided before the erase (Uploader.cpp:233-243). The board waits in its bootloader (row bootState 01) and restarts its old app by itself within 30 s (FP12:81-83). A node or kind mismatch at END comes after the erase (Uploader.cpp:281-282), so trust the row.
      • As drawn (CockpitMap.dc.html:987-992), each with the foot "Nothing was written. The board goes back to its old firmware within 30 s.": Refused — wrong board — "the board's bootloader reports a different node ID or kind than the image."; Refused — too big — "the image doesn't fit this board's flash."; No bootloader — "the board didn't enter a bootloader. Flash it once with a programmer; after that it updates from here."
    • NO_RESPONSE: an app that ignores the request is caught only by a CONNECT in the 200 ms after its own reset, inside the Gateway's 10 s attempt (BootProtocol.h:189; Uploader.cpp:28-29). So the dialog shows the instruction first, with no clock running, and Retry starts a new, counted 10 s attempt. The instruction's wording is written after the bench check in Done when; until then the design shows a visible placeholder (decision 2026-10-28): No response — "[wording follows the bench test] the board didn't answer the update request." (CockpitMap.dc.html:988).
    • The client is cut off mid-flash while the Gateway stays powered (the client closed, or the PC asleep while the Gateway keeps power): the design's "Back online — the Gateway carried on by itself while the client was cut off" note.
      • The Gateway finishes on its own. It stops sending UPL_STATUS after 5 s without a client frame and starts again when the client is back.
      • On reconnect, UPL_INFO_ACK state 3 = still flashing, then lastTarget / lastResult / lastFw in bytes 5–9 give the result (UploadProtocol.h:73-87; Uploader.cpp:26, :173).
    • The Gateway is power-cycled mid-flash: the Gateway unplugged (the USB side runs from USB, docs/hardware/base-boards.md:51), or the RP2040-Zero's RESET pressed (it reboots the USB side, where the uploader runs). The uploader stops, and its session and result are lost: they're kept in RAM only (Uploader.cpp:39, :57).
      • On reconnect, UPL_INFO_ACK reports state 0 (idle), lastResult 0 (NONE), lastTarget 0 and lastFw 0.0.0. So there's no result for this board (Uploader.cpp:354-369).
      • The client judges from the board's row. The cockpit side keeps it: it runs from the bus, not from USB.
        • bootState 02 APP_INVALID → stuck in bootloader, Retry. Once BEGIN has erased the app the board stays in its bootloader. There's no idle timeout after a BEGIN, and the state stays 02 even when the cut came after END (Firmware/Bootloader/src/main.cpp:502, :599-600; FP12:81-83). The 0x80 bit lapses 3 s after the traffic stops (FP06:57).
        • Cut before the erase: bootState 01 for up to 30 s, then the app is running at the old version → nothing changed, Retry. Copy (CockpitMap.dc.html:963, :1199): Interrupted — "the Gateway lost power before anything was written." Foot: "Nothing was written. The board goes back to its old firmware within 30 s."
        • Cut after the erase: Stuck in its bootloader — "the Gateway lost power during the update, so the result was lost. The board waits in its bootloader — Retry finishes it." (:962).
        • The board stays present throughout, because its bootloader announces every second (BootProtocol.h:63-64; FP06:40-45).
    • The Gateway PCB's RESET button (SW1, the STM32 / cockpit side) resets only the cockpit side's STM32, the relay. Its NRST goes to the SWD header and SW1 only, and there's no reset line from the USB side (feat(firmware): Gateway updates its own bridge over USART2 OpenSkyhawk#367; PCB/Base/Gateway_Bridge/Gateway_Bridge.kicad_sch SW1 "RESET").
      • Pressed mid-flash, the uploader keeps its session. It sees the stall and retries like a power cut, while the cockpit side restarts.
      • Not benched for a board on the bus: FP07:460 is a cockpit-side reset during the cockpit side's own update, which retried to OK.
      • The RP2040-Zero's BOOT button is its BOOTSEL. It's needed only for a USB side that hangs before USB comes up (feat: update the Gateway from a firmware release #173; Firmware/release/README-gateway.md:31-33). It plays no part here.
    • A power cut mid-update can recover by itself if the board is back within about 20 s. The stall is noticed after about 3.5 s, then come two 10 s retries (Uploader.cpp:32-33, :516-520; worked out from the code). Otherwise the user follows the manual recipe in docs/guides/bring-up.md#recovering-from-a-power-cut-during-an-update.
  • Status chips for the states this adds: the design's fwInfo chips updating (run), stuck in bootloader (stuck), app unstable (unstable) and manual update only (manual). manual update only already shipped in feat: update the Gateway from a firmware release #173 PR 1 (PR feat(cockpit): firmware button, sheet and Overview banner (#173 PR 1) #198, noBootloader() in main/gateway/firmware.ts), for boards and the Gateway's cockpit side; this ticket adds the other three. The other chips are feat: update the Gateway from a firmware release #173's. Logic, from the row's bootState, firmware feat(firmware): node and bridge support for CAN updates OpenSkyhawk#365 (NodeStatus.h:62-67; FP04:83-91):

    • updating: bit 0x80 set.
    • stuck in bootloader: 02 APP_INVALID without 0x80.
    • app unstable: 04 APP_UNSTABLE without 0x80.
    • manual update only: bootloader 00 on a row that reports a version. A row with version 000000 reads not reported first (fix: firmware version handling — supported major 1, honest "not reported" copy #191), since BOOTLOADER 00 also means not reported (NodeStatus.h:95-96). The design orders them the same way (CockpitMap.dc.html:830-831).
    • 01 REQUESTED / 05 CAUGHT are short waits with the app still valid, so they get no chip.
    • A board in its bootloader stays present and never reads as offline (FP06:40-45).
    • What a stuck or unstable row offers:
      • With a release image: Retry on the Firmware sheet row, and Retry the update in the board's Firmware section (CockpitMap.dc.html:1362, :890).
      • With no release image (every PanelGroup board): From file… on the sheet row, or Install a build with From file… in its section. It opens the board's Settings tab: its staged file was dropped (UPL_ABORT) when the dialog closed.
      • Retry exists only inside the dialog that failed for such a board (decision 2026-10-28; CockpitMap.dc.html:853-854, :868-875).
  • Constants (message types, NACK / result codes, CAN-side states) only via npm run sync. The identity-block layout (FirmwareImage.h) and UploadProtocol.h are synced in feat: update the Gateway's cockpit side from a firmware release #187; this ticket reuses them.

    • New to the sync here: BootProtocol.h BootState and NodeStatus.h NODE_BOOT_STATE_* (for the status chips).
  • Tests against a simulated Gateway on the serial seam:

    • success, retry (attempt counter), stuck, unstable;
    • link lost mid-flash: port closed, reconnect to state 3, then lastResult;
    • Gateway power-cycled mid-flash: reconnect to state 0 / lastResult NONE; the row's bootState 02 → stuck, or the old version → nothing changed;
    • each pre-erase refusal;
    • bootloader 00 → manual update only, nothing sent;
    • no-uploader Gateway (UPL_INFO timeout → update the Gateway first);
    • NO_RESPONSE → instruction → Retry with the same staged image;
    • BUSY during calibration;
    • UPL_ABORT after the result.

Design (resolved)

Everything the old gaps list named is drawn (Design, and the copy in Scope):

  • Pre-erase refusals: No response (visible placeholder until the bench test), Refused — wrong board, Refused — too big, No bootloader, each with the foot "Nothing was written. The board goes back to its old firmware within 30 s." (CockpitMap.dc.html:987-992, :1199).
  • After the restart: Wrong version and No confirmation (:993-994; the No confirmation text is the ticket's, decided 2026-10-28).
  • Confirm:
    • "Firmware 1.0.1 · saved on this computer, checked" (:1057);
    • "Fits the 124 KB limit; the board checks its own flash before anything is erased" (:1059);
    • the no-uploader and cockpit-side-behind rows (:1052-1054);
    • "about 5 s" notes and the foot "33 KB · about 5 s".
  • Client cut off vs. Gateway power-cycled: two injections (:1182). Power-cycled after the erase → Stuck in its bootloader (:962); before → Interrupted (:963). Client cut off → Back online. The run footer "Don't unplug the Gateway. Closing the client is fine — the Gateway finishes on its own." stays.
  • Release images: every PanelGroup board reads custom firmware (hasImg :798).

Out of scope

Blocked by

#192 (the image), #173 PR 2 (the shared update dialog), #187 (the uploader client and the identity-block sync). The firmware has shipped in 0.3.0: OpenSkyHawk/OpenSkyhawk#362–#367 are closed.

Blocks

#177 (the board flow it installs through), #176 (the board step).

Build order (approved 2026-10-27): #192 → #173 PR 1 → #173 PR 2 → #187 → #175 → #177 → #176.

Done when

On the bench, a board on an older release is updated from the client end to end and reports the new version. Each failure path above is reachable and recoverable from the UI.

Bench checks this depends on:

Activity

  1. added this to the v1.0.0 milestone on Oct 25, 2026
  2. mottihoresh commented on Oct 27, 2026

    @mottihoresh
    ContributorAuthor

    Design update (2026-10-26, design artifact v178). Where this ticket's parts sit on the node / PDU sheet has changed:

    • Status tab › Firmware: Installed version with its status chip on the same row. A full-width Update to x.y.z (or Retry the update) button shows only while an update is pending: update available, major mismatch, stuck, or app unstable. The section has no Latest release row and no History.
    • No Bootloader row. A board without a bootloader shows a gold box in Status instead: No bootloader — "this board can't be updated from here. Flash it once with a programmer, using the bootstrap image from the release; after that it updates from here." It ends with a link, About the bootloader ↗, that opens https://openskyhawk.github.io/OpenSkyhawk/firmware/bootloader/ in the browser. Switch it to the first-setup guide once docs: bootloader rollout, first-setup page and video script OpenSkyhawk#380 publishes it.
    • A Firmware sheet row's chevron opens the board's Status tab. Its Update button opens the update dialog directly.
    • UI copy has no technical terms (CAN, UART, ST-Link, READY, UF2, SHA-256). Dialog phases read e.g. "Entering its bootloader", "Writing · 45%", "Confirming the version it reports"; failures read "Bus error while writing", "writing failed after 2 automatic retries", "restarted 3 times without starting up properly".

    Markers on the board: CHANGED · #175 — the update sits in Status, only while one is pending, CHANGED · #175 — no Bootloader row; only a board without one says so, NEW · #175 — opens the Bootloader page of the docs in the browser….

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 request

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions