From aab970749c996981414019481dc964ee288ad1af Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 16:02:20 +0000 Subject: [PATCH 1/4] Add streamlined panel-programming tools (g6-flash CLI + WebUSB flasher) Make flashing G6 panels (RP2350) easy for both new/blank boards and re-flashing existing ones, single panels or bench batches, without a PlatformIO build environment. - docs/development/g6_07-panel-programming.md: design + spec for the approach (CI-published per-rev UF2 releases, the CLI, the web flasher, and rev-mismatch safety). Added to the dev-docs index. - tools/panel-programming/g6_flash.py: picotool-based CLI. Reboots running panels into BOOTSEL and flashes blank/BOOTSEL boards (the gap deploy*.sh can't cover), mandatory --rev guard, parallel batch, sha256-checked UF2 download, post-flash USB product-string verification. - tools/panel-programming/release.yml: CI matrix over pico_v021/pico_v031 publishing g6-panel-.uf2 + manifest.json as GitHub Release assets. - tools/panel-programming/flasher/: zero-install WebUSB PICOBOOT flasher (Chromium/Edge) for nontechnical users. Staged in the parent repo because the firmware and webDisplayTools submodules are separate repos out of this session's push scope; the staging README documents each file's final home. Not yet validated against hardware. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UcDc2Scj2wZMBBXfaBc1HX --- docs/development/README.md | 1 + docs/development/g6_07-panel-programming.md | 167 ++++++++ tools/panel-programming/README.md | 69 ++++ tools/panel-programming/flasher/flasher.js | 275 +++++++++++++ tools/panel-programming/flasher/index.html | 83 ++++ tools/panel-programming/g6_flash.py | 408 ++++++++++++++++++++ tools/panel-programming/release.yml | 95 +++++ 7 files changed, 1098 insertions(+) create mode 100644 docs/development/g6_07-panel-programming.md create mode 100644 tools/panel-programming/README.md create mode 100644 tools/panel-programming/flasher/flasher.js create mode 100644 tools/panel-programming/flasher/index.html create mode 100644 tools/panel-programming/g6_flash.py create mode 100644 tools/panel-programming/release.yml diff --git a/docs/development/README.md b/docs/development/README.md index aff4bee..093ece6 100644 --- a/docs/development/README.md +++ b/docs/development/README.md @@ -21,6 +21,7 @@ Implementation-readiness is the design goal: a firmware author or future Claude | [`g6_04-pattern-file-format.md`](g6_04-pattern-file-format.md) | Pattern Format / Panel Map (merged) | **v2 canonical** | [`Generation 6/maDisplayTools/g6/g6_save_pattern.m`](../../Generation%206/maDisplayTools/g6/g6_save_pattern.m); JS round-trip via [`g6_encoding_reference.json`](../../Generation%206/maDisplayTools/g6/g6_encoding_reference.json) | | [`g6_05-host-software.md`](g6_05-host-software.md) | Host PC Matlab SW | **Stub (firmware-contract framing)** | Authoritative host docs: `Generation 6/maDisplayTools/docs/*.md`. | | [`g6_06-arena-firmware-interface.md`](g6_06-arena-firmware-interface.md) | G6 arena design (v1/v2) | **Thin firmware-interface reference** | Production arena: [`reiserlab/LED-Display_G6_Hardware_Arena`](https://github.com/reiserlab/LED-Display_G6_Hardware_Arena) v1.1.7. | +| [`g6_07-panel-programming.md`](g6_07-panel-programming.md) | — (panel firmware flashing: `g6-flash` CLI + WebUSB flasher) | **Specified** | Reference implementation staged at [`tools/panel-programming/`](../../tools/panel-programming/); firmware: [`reiserlab/LED-Display_G6_Firmware_Panel`](https://github.com/reiserlab/LED-Display_G6_Firmware_Panel) (`panel/platformio.ini`, `panel/tools/deploy*.sh`). Unvalidated against hardware end-to-end. | | [`g6_arena_configs.h`](g6_arena_configs.h) | — (controller-side panel map, keyed by Arena ID) | **Codegen-emitted** | Generated from [`Generation 6/maDisplayTools/`](../../Generation%206/maDisplayTools/) `configs/arena_registry/index.yaml` + `configs/arenas/G6_*.yaml` + `configs/arena_hardware/.yaml` via [`tools/gen_arena_configs.py`](../../Generation%206/maDisplayTools/tools/gen_arena_configs.py). Includes G6_2x10 and G6_2x8of10 (sharing `arena_10-10_v1p1r7` hardware profile); G6_3x12of18 skipped (no 18-col hardware). Unvalidated against firmware end-to-end. | Status values: `Specified` (concrete enough to implement against), `Draft` (mostly there, gaps marked), `Teaser` (sketched), `Stub` (placeholder). diff --git a/docs/development/g6_07-panel-programming.md b/docs/development/g6_07-panel-programming.md new file mode 100644 index 0000000..c37d4f9 --- /dev/null +++ b/docs/development/g6_07-panel-programming.md @@ -0,0 +1,167 @@ +# G6 — Panel Programming (flashing tools) + +Source: G6 panel programming streamlining work; grounds against [`reiserlab/LED-Display_G6_Firmware_Panel`](https://github.com/reiserlab/LED-Display_G6_Firmware_Panel) (`panel/platformio.ini`, `panel/tools/deploy*.sh`, `pixi.toml`). +Status: **Specified** — design + reference implementation staged at [`tools/panel-programming/`](../../tools/panel-programming/); pending relocation into the firmware/web submodule repos and end-to-end hardware validation. + +This file specifies how G6 panels get their firmware: a `g6-flash` command-line tool +and a zero-install WebUSB browser flasher, both fed by CI-published, prebuilt UF2 +artifacts. It covers programming **new (blank)** panels and **re-flashing** existing +ones, single panels and bench batches (~10–20 per powered hub). In-arena ISP-over-SPI +(panel protocol opcodes `0xE0–0xE4`, controller `0x41`, see [`g6_01-panel-protocol.md`](g6_01-panel-protocol.md)) +is a separate, future path and out of scope here. + +--- + +## What makes this easy (and the one hard part) + +- **RP2350 / RP2354** MCU with a native **BOOTSEL** USB bootloader (UF2 + PICOBOOT). + No external programmer (SWD/J-Link) is needed. +- **Panels are stateless.** No per-panel ID or address is burned in — every panel of a + given hardware revision gets the **identical** binary. Panel addressing is the arena + controller's job at runtime (`(spi_bus, cs_gpio)` per [`g6_06-arena-firmware-interface.md`](g6_06-arena-firmware-interface.md)). + So there is **no provisioning step**. +- **The one footgun: hardware revision.** The two revs need different binaries — + `pico_v021` → **v0.2.1**, `pico_v031` → **v0.3.1** (`panel/platformio.ini`) — and the + rev **cannot be detected over USB on a blank board** (the USB product string only + reflects the *currently running* firmware). Rev selection is therefore unavoidable + human input, but it is **once per batch**, not per panel, and every flash is + **verified afterwards** by reading back the panel's USB product string. + +USB identities (from `panel/platformio.ini`): VID `0x2E8A`; running firmware PID +`0x0009` (USB-serial, product `G6 Panel v0.2` / `G6 Panel v0.3`); BOOTSEL PID `0x000f` +(mass-storage + PICOBOOT). + +### Why not the existing `deploy*.sh` + +`panel/tools/deploy.sh` / `deploy_all.sh` drive `pio … -t upload` and match panels only +in **USB-serial mode** — so they **cannot flash a blank/BOOTSEL board** (the common case +for a freshly assembled panel), require a full PlatformIO build env, and `deploy_all.sh` +is sequential. The tools below use **`picotool`**, which can reboot a running panel into +BOOTSEL itself (`reboot -f -u`) *and* flash a board already in BOOTSEL — one code path for +both new and old panels — and consume prebuilt UF2s so no build env is needed. The legacy +scripts remain for the firmware-developer `pio` flow. + +--- + +## A. Firmware release artifacts (shared foundation) + +CI builds and publishes per-rev UF2s so neither tool needs PlatformIO. + +- Workflow: `.github/workflows/release.yml` in the firmware repo (reference copy at + [`tools/panel-programming/release.yml`](../../tools/panel-programming/release.yml)). + Triggers on tag `panel-fw-v*` (and manual dispatch). +- Matrix over the **production envs only** (`pico_v021`, `pico_v031` — never the + `_bcmtest` / `_spidiag` bench builds, which carry a `*** DO NOT DEPLOY ***` banner). + Build via `pio run -d panel -e `; the build already runs + `tools/gen_predef_patterns.py` and enforces the 2 MiB cap, so a broken/oversize image + fails in CI. +- Release assets: `g6-panel-v0.2.1.uf2`, `g6-panel-v0.3.1.uf2`, and a **`manifest.json`**: + + ```json + { "version": "panel-fw-v1.0.0", + "artifacts": [ + { "rev": "v0.2.1", "env": "pico_v021", "file": "g6-panel-v0.2.1.uf2", + "sha256": "…", "usb_product": "G6 Panel v0.2" }, + { "rev": "v0.3.1", "env": "pico_v031", "file": "g6-panel-v0.3.1.uf2", + "sha256": "…", "usb_product": "G6 Panel v0.3" } ] } + ``` + + `usb_product` drives post-flash verification; `sha256` is checked on download. + +## B. `g6-flash` CLI (bench) + +Reference: [`tools/panel-programming/g6_flash.py`](../../tools/panel-programming/g6_flash.py); +final home `panel/tools/g6_flash.py`. Python 3, stdlib-only; needs `picotool` on PATH +(add to `pixi.toml [dependencies]`, conda-forge has it). Linux (enumerates via sysfs, +like the by-id scripts). + +Per panel: if it is running firmware, `picotool reboot -f -u --bus B --address A` → +wait for BOOTSEL; then `picotool load [-x] --bus B --address A `; then (when executed) +wait for re-enumeration as PID `0x0009` and confirm the product string matches `--rev`. + +- **Identity = sysfs USB port path** (e.g. `3-1.4`), which is stable across the + BOOTSEL↔app re-enumeration; bus/address (busnum/devnum) target picotool. +- `--rev {v0.2.1,v0.3.1}` is **mandatory**. A running board whose current product string + disagrees with `--rev` triggers a loud abort (override with `--force`). +- Batch: enumerate all VID-`0x2E8A` boards, flash in parallel (`--jobs`, default 4), + end-of-run summary. **Realistic ceiling ~10–20 panels per externally-powered hub** — + the limit is post-flash LED-matrix inrush + USB re-enumeration, not flashing. `--no-exec` + loads without running, so trays can be power-cycled in small groups. +- Firmware source: default = latest GitHub Release (manifest + sha256-checked UF2, cached + under `~/.cache/g6-flash/`); `--fw-version ` pins a release; `--uf2 ` flashes + a local build. + +```sh +g6_flash.py --rev v0.3.1 # re-flash all connected v0.3.1 panels, latest fw +g6_flash.py --rev v0.2.1 --port 3-1.4 # one board on a specific USB port +g6_flash.py --rev v0.3.1 --uf2 panel/.pio/build/pico_v031/firmware.uf2 # local build +g6_flash.py --list # show connected panels +``` + +Suggested `pixi.toml` tasks (replacing the hardcoded-serial demo tasks): +`flash21 = "python panel/tools/g6_flash.py --rev v0.2.1"`, +`flash31 = "python panel/tools/g6_flash.py --rev v0.3.1"`. + +## C. WebUSB browser flasher (nontechnical users) + +Reference: [`tools/panel-programming/flasher/`](../../tools/panel-programming/flasher/); +final home `flasher/` in [`reiserlab/webDisplayTools`](https://github.com/reiserlab/webDisplayTools), +served over GitHub Pages (WebUSB requires a secure context). **Chromium/Edge only.** + +A standalone static page (plain HTML + ES module, no build step), separate from the +existing Web *Serial* arena console — bootloader flashing needs Web **USB** (the RP2350 +PICOBOOT vendor interface), not Web Serial, and browsers cannot use the UF2 mass-storage +drag-drop. The page parses the UF2 client-side and streams its 256-byte blocks over +PICOBOOT (`EXCLUSIVE_ACCESS → EXIT_XIP → FLASH_ERASE → WRITE → REBOOT2`). Modeled on +[`piersfinlayson/picoflash`](https://github.com/piersfinlayson/picoflash) (MIT, proven +RP2350 over WebUSB). + +Operator flow: pick rev (no default; inline help on reading the PCB rev) → put panel in +BOOTSEL and click **Connect & flash** → progress → verify (re-reads product string, or +asks for a visual confirm if a re-grant gesture is needed). Firmware UF2s are fetched from +the latest release via the same `manifest.json`, so the page auto-tracks new firmware. + +## D. Rev-mismatch safety (both tools) + +- Mandatory, deliberate rev selection; neither tool guesses. +- Both read back the post-flash USB product string and prefix-match it against the + manifest's `usb_product` (`G6 Panel v0.2` / `v0.3`, same casing as `deploy.sh`), so a + wrong-rev flash is caught immediately on the bench rather than in an arena. +- PCB-identification guidance is shown in both UIs. + +--- + +## Current state + +Design and reference implementations are complete and staged in the parent repo at +[`tools/panel-programming/`](../../tools/panel-programming/) (the firmware and webDisplayTools +submodules are separate repos). A maintainer relocates them to their final homes — +`panel/tools/g6_flash.py`, `.github/workflows/release.yml`, webDisplayTools `flasher/` — +and adds the `picotool` dependency + `flash*` tasks to the firmware `pixi.toml`. No +firmware release has been cut yet, so the tools' default download path is untested until +the first `panel-fw-v*` tag. + +## Open Questions / TBDs + +- **PCB silkscreen rev marking** — confirm the exact location/text of the v0.2.1 / v0.3.1 + marking from `Generation 6/Panels` and add it (plus a photo) to the web help panel. +- **picoflash integration** — decide whether to import picoflash's PICOBOOT module + directly or keep the thin reimplementation in `flasher/flasher.js`; validate the + framing against real RP2350 hardware and the picoflash reference. +- **End-to-end hardware validation** — verify against the two bench panels + (`319A5199EE357F77` v0.2.1, `A5D4B82BA2B9FB51` v0.3.1): a re-flash of a running board + (cross-checked vs `pixi run deploy31a`), and a blank/BOOTSEL board (the new capability), + plus the rev-guard abort path. +- **Optional** — a firmware serial command to reboot-to-BOOTSEL would let the web tool + auto-enter BOOTSEL via a Web Serial pre-step, removing the manual hold-BOOTSEL step. + +## Cross-references + +- [`g6_01-panel-protocol.md`](g6_01-panel-protocol.md) — panel protocol; ISP `0xE0–0xE4` + (the separate in-arena programming path, out of scope here). +- [`g6_06-arena-firmware-interface.md`](g6_06-arena-firmware-interface.md) — panel + addressing is controller-side `(spi_bus, cs_gpio)`; panels carry no ID. +- [`g6_05-host-software.md`](g6_05-host-software.md) — host transports; webDisplayTools + Web Serial console (distinct from this WebUSB flasher). +- Firmware build/flash: `Generation 6/Panel-Firmware/README.md`, `panel/platformio.ini`, + `panel/tools/deploy.sh`, `pixi.toml`. diff --git a/tools/panel-programming/README.md b/tools/panel-programming/README.md new file mode 100644 index 0000000..8db3637 --- /dev/null +++ b/tools/panel-programming/README.md @@ -0,0 +1,69 @@ +# Panel programming tools (staging) + +Streamlined flashing for G6 LED-display panels (RP2350 / RP2354): a `g6-flash` +command-line tool and a zero-install WebUSB browser flasher, fed by CI-published +prebuilt UF2 artifacts. Programs **new (blank)** panels and **re-flashes** old ones, +single or in bench batches. + +Full design + rationale: [`docs/development/g6_07-panel-programming.md`](../../docs/development/g6_07-panel-programming.md). + +## ⚠ This is a staging directory + +These files belong in **two separate submodule repos**, not the parent +`Modular-LED-Display`. They are staged here because this session could only push to +the parent repo. A maintainer with access should relocate them as below, then this +directory can be deleted (leaving only the `g6_07` spec doc as the reference). + +| Staged file | Final home | +|---|---| +| `g6_flash.py` | `panel/tools/g6_flash.py` in [`reiserlab/LED-Display_G6_Firmware_Panel`](https://github.com/reiserlab/LED-Display_G6_Firmware_Panel) | +| `release.yml` | `.github/workflows/release.yml` in the same firmware repo | +| `flasher/index.html`, `flasher/flasher.js` | `flasher/` in [`reiserlab/webDisplayTools`](https://github.com/reiserlab/webDisplayTools) (served via GitHub Pages) | + +### Firmware repo `pixi.toml` additions + +Add the `picotool` dependency and convenience tasks (replacing the hardcoded-serial +demo tasks `deploy21a` / `deploy31a`): + +```toml +[dependencies] +picotool = "*" # conda-forge + +[tasks] +flash21 = "python panel/tools/g6_flash.py --rev v0.2.1" +flash31 = "python panel/tools/g6_flash.py --rev v0.3.1" +``` + +The legacy `deploy.sh` / `deploy_all.sh` stay for the firmware-developer `pio` flow. + +## Quick reference + +**CLI** (Linux; needs `picotool` on PATH): + +```sh +python g6_flash.py --rev v0.3.1 # flash all connected v0.3.1 panels (latest fw) +python g6_flash.py --rev v0.2.1 --port 3-1.4 # one board on a specific USB port +python g6_flash.py --rev v0.3.1 --uf2 # flash a local UF2 build +python g6_flash.py --list # list connected panels +python g6_flash.py --help # all options (incl. --no-exec, --jobs, --force) +``` + +Handles both new and old panels: it uses `picotool` to reboot a running panel into +BOOTSEL automatically, and flashes blank/BOOTSEL boards directly. `--rev` is mandatory +(the two hardware revs need different binaries and can't be told apart on a blank +board); every flash is verified afterwards via the panel's USB product string. + +Batch ceiling is ~10–20 panels per **externally-powered** USB hub — the limit is +post-flash LED inrush + re-enumeration, not flashing. Use `--no-exec` for larger trays +and power-cycle in small groups. + +**Web flasher**: open `flasher/index.html` over https (GitHub Pages) in **Chrome/Edge**, +pick the rev, put the panel in BOOTSEL (hold BOOTSEL while plugging in USB), click flash. + +## Status / TODO + +Reference implementation; **not yet validated against hardware**, and the default +download path needs the first `panel-fw-v*` firmware release to exist. See the +"Open Questions / TBDs" in [`g6_07-panel-programming.md`](../../docs/development/g6_07-panel-programming.md) +(PCB silkscreen marking, picoflash integration, bench validation against the two known +panels). diff --git a/tools/panel-programming/flasher/flasher.js b/tools/panel-programming/flasher/flasher.js new file mode 100644 index 0000000..346ab40 --- /dev/null +++ b/tools/panel-programming/flasher/flasher.js @@ -0,0 +1,275 @@ +// G6 Panel WebUSB flasher — drives the RP2350 PICOBOOT interface to write flash. +// +// FINAL HOME: flasher/ in reiserlab/webDisplayTools (deploy via GitHub Pages — WebUSB +// needs a secure context, which Pages https provides). Chromium/Edge only. +// +// Why WebUSB (not Web Serial): the existing webDisplayTools console uses Web *Serial* +// to talk to the arena controller, but flashing a panel's bootloader needs the RP2350 +// PICOBOOT vendor interface, which is Web *USB*. Browsers also cannot use the UF2 +// mass-storage drag-drop path, so we parse the UF2 client-side and stream its blocks +// over PICOBOOT (EXCLUSIVE_ACCESS -> EXIT_XIP -> FLASH_ERASE -> WRITE -> REBOOT2). +// +// The PICOBOOT framing here follows the RP2350 datasheet / picoboot interface. It is +// modeled on piersfinlayson/picoflash (MIT) — validate against real hardware and that +// reference before production use. + +const RP_VID = 0x2e8a; +const FW_REPO = "reiserlab/LED-Display_G6_Firmware_Panel"; +const FLASH_XIP_BASE = 0x10000000; +const SECTOR = 4096; // RP2350 flash erase granularity +const WRITE_CHUNK = 256; // PICOBOOT WRITE granularity (UF2 payloads are 256B) + +// rev -> expected USB product string prefix (set in panel/platformio.ini). +const REV_PRODUCT = { "v0.2.1": "G6 Panel v0.2", "v0.3.1": "G6 Panel v0.3" }; + +// --- PICOBOOT command framing --------------------------------------------------- +const PICOBOOT_MAGIC = 0x431fd10b; +const CMD = { + EXCLUSIVE_ACCESS: 0x01, + FLASH_ERASE: 0x03, + WRITE: 0x05, + EXIT_XIP: 0x06, + REBOOT2: 0x0a, // RP2350 reboot +}; +let token = 1; + +const $ = (id) => document.getElementById(id); +const logEl = $("log"); +function log(msg, cls) { + const line = document.createElement("div"); + if (cls) line.className = cls; + line.textContent = msg; + logEl.appendChild(line); + logEl.scrollTop = logEl.scrollHeight; +} +function setStatus(msg, cls) { const s = $("status"); s.textContent = msg; s.className = cls || ""; } + +// --- UF2 parsing ---------------------------------------------------------------- +// Returns sorted [{addr, data:Uint8Array(256)}], one per 512-byte UF2 block. +function parseUF2(buf) { + const dv = new DataView(buf); + const blocks = []; + for (let off = 0; off + 512 <= buf.byteLength; off += 512) { + if (dv.getUint32(off, true) !== 0x0a324655) continue; // magicStart0 "UF2\n" + if (dv.getUint32(off + 4, true) !== 0x9e5d5157) continue; // magicStart1 + if (dv.getUint32(off + 508, true) !== 0x0ab16f30) continue; // magicEnd + const flags = dv.getUint32(off + 8, true); + if (flags & 0x00000001) continue; // "not main flash" block + const addr = dv.getUint32(off + 12, true); + const size = dv.getUint32(off + 16, true); + const data = new Uint8Array(buf, off + 32, Math.min(size, 256)); + blocks.push({ addr, data: data.slice() }); + } + blocks.sort((a, b) => a.addr - b.addr); + if (!blocks.length) throw new Error("no flashable UF2 blocks found"); + return blocks; +} + +// --- PICOBOOT device ------------------------------------------------------------ +class Picoboot { + constructor(device) { + this.dev = device; + this.epOut = null; + this.epIn = null; + this.iface = null; + } + + async open() { + await this.dev.open(); + if (this.dev.configuration === null) await this.dev.selectConfiguration(1); + // The PICOBOOT interface is the vendor-specific one (class 0xFF) with two bulk EPs. + for (const iface of this.dev.configuration.interfaces) { + const alt = iface.alternate; + if (alt.interfaceClass !== 0xff) continue; + const out = alt.endpoints.find((e) => e.direction === "out" && e.type === "bulk"); + const inp = alt.endpoints.find((e) => e.direction === "in" && e.type === "bulk"); + if (out && inp) { + this.iface = iface.interfaceNumber; + this.epOut = out.endpointNumber; + this.epIn = inp.endpointNumber; + break; + } + } + if (this.iface === null) throw new Error("no PICOBOOT interface — is the panel in BOOTSEL mode?"); + await this.dev.claimInterface(this.iface); + } + + async close() { + try { await this.dev.releaseInterface(this.iface); } catch {} + try { await this.dev.close(); } catch {} + } + + // Build a 32-byte PICOBOOT command packet. + _packet(cmdId, transferLen, args) { + const b = new ArrayBuffer(32); + const dv = new DataView(b); + dv.setUint32(0, PICOBOOT_MAGIC, true); + dv.setUint32(4, token++, true); + dv.setUint8(8, cmdId); + dv.setUint8(9, args ? args.byteLength : 0); + dv.setUint16(10, 0, true); + dv.setUint32(12, transferLen, true); + if (args) new Uint8Array(b, 16).set(new Uint8Array(args)); + return b; + } + + // Command with no data phase: send packet, then read a ZLP ack on IN. + async _cmd(cmdId, args) { + await this.dev.transferOut(this.epOut, this._packet(cmdId, 0, args)); + await this.dev.transferIn(this.epIn, 1); // status / ZLP ack + } + + // Command with an OUT data phase: send packet, send data, read ZLP ack on IN. + async _cmdWrite(cmdId, args, data) { + await this.dev.transferOut(this.epOut, this._packet(cmdId, data.byteLength, args)); + await this.dev.transferOut(this.epOut, data); + await this.dev.transferIn(this.epIn, 1); + } + + _args(...u32) { + const b = new ArrayBuffer(16); + const dv = new DataView(b); + u32.forEach((v, i) => dv.setUint32(i * 4, v >>> 0, true)); + return b; + } + + exclusiveAccess() { return this._cmd(CMD.EXCLUSIVE_ACCESS, this._args(1)); } // 1 = EXCLUSIVE + exitXip() { return this._cmd(CMD.EXIT_XIP); } + flashErase(addr, size) { return this._cmd(CMD.FLASH_ERASE, this._args(addr, size)); } + write(addr, data) { return this._cmdWrite(CMD.WRITE, this._args(addr, data.byteLength), data); } + reboot() { return this._cmd(CMD.REBOOT2, this._args(0, 100, 0, 0)); } // normal reboot, 100ms delay +} + +async function flashBlocks(pb, blocks, onProgress) { + await pb.exclusiveAccess(); + await pb.exitXip(); + + // Erase the sector-aligned span covering every write. + const minAddr = blocks[0].addr & ~(SECTOR - 1); + const lastEnd = blocks[blocks.length - 1].addr + blocks[blocks.length - 1].data.byteLength; + const maxAddr = (lastEnd + SECTOR - 1) & ~(SECTOR - 1); + log(`Erasing 0x${minAddr.toString(16)}…0x${maxAddr.toString(16)} (${(maxAddr - minAddr) / 1024} KiB)`); + for (let a = minAddr; a < maxAddr; a += SECTOR) await pb.flashErase(a, SECTOR); + + // Write each 256-byte block. + for (let i = 0; i < blocks.length; i++) { + const { addr, data } = blocks[i]; + let chunk = data; + if (chunk.byteLength < WRITE_CHUNK) { + const padded = new Uint8Array(WRITE_CHUNK).fill(0xff); + padded.set(chunk); + chunk = padded; + } + await pb.write(addr, chunk); + onProgress((i + 1) / blocks.length); + } + log("Rebooting panel into firmware…"); + try { await pb.reboot(); } catch { /* device drops as it reboots — expected */ } +} + +// --- Firmware resolution (latest GitHub Release) -------------------------------- +let firmware = { version: null, byRev: {} }; // rev -> uf2 download url + +async function resolveFirmware() { + const rel = await (await fetch(`https://api.github.com/repos/${FW_REPO}/releases/latest`)).json(); + const assets = Object.fromEntries((rel.assets || []).map((a) => [a.name, a.browser_download_url])); + const manifest = await (await fetch(assets["manifest.json"])).json(); + firmware.version = manifest.version || rel.tag_name; + for (const a of manifest.artifacts || []) { + if (assets[a.file]) firmware.byRev[a.rev] = assets[a.file]; + } + $("fw-version").textContent = firmware.version; + log(`Firmware release: ${firmware.version} (revs: ${Object.keys(firmware.byRev).join(", ")})`); +} + +// --- Verify after flash --------------------------------------------------------- +// After REBOOT2 the panel re-enumerates as the firmware USB-serial device. We can't +// silently re-grab it (WebUSB needs a user gesture per device), so confirm via the +// product string of any already-granted device, else ask the operator to confirm. +async function verifyRev(rev) { + const want = REV_PRODUCT[rev]; + const granted = await navigator.usb.getDevices(); + const match = granted.find((d) => d.vendorId === RP_VID && (d.productName || "").startsWith(want)); + if (match) { + setStatus(`Verified: ${match.productName} ✓`, "status-ok"); + log(`Verified panel reports "${match.productName}".`, "status-ok"); + } else { + setStatus(`Flashed ${rev}. Confirm the panel boots its normal pattern.`, "status-ok"); + log("Flash complete. (Auto-verify needs a re-grant; visually confirm the panel runs.)"); + } +} + +// --- UI wiring ------------------------------------------------------------------ +let chosenRev = null; + +function setupRevButtons() { + for (const btn of document.querySelectorAll("#rev-group button")) { + btn.addEventListener("click", () => { + chosenRev = btn.dataset.rev; + for (const b of document.querySelectorAll("#rev-group button")) + b.setAttribute("aria-pressed", String(b === btn)); + $("flash-btn").disabled = !(chosenRev && firmware.byRev[chosenRev]); + if (chosenRev && !firmware.byRev[chosenRev]) + setStatus(`Latest firmware has no ${chosenRev} build.`, "status-err"); + else setStatus(""); + }); + } +} + +async function onFlashClick() { + if (!chosenRev) return; + const url = firmware.byRev[chosenRev]; + if (!url) { setStatus(`No firmware for ${chosenRev}.`, "status-err"); return; } + + let device, pb; + try { + $("flash-btn").disabled = true; + setStatus("Requesting panel…"); + device = await navigator.usb.requestDevice({ filters: [{ vendorId: RP_VID }] }); + + if (device.productId !== 0x000f) { + setStatus("That panel is not in BOOTSEL mode.", "status-err"); + log(`Picked device "${device.productName || "?"}" (pid 0x${device.productId.toString(16)}). ` + + "Unplug it, hold BOOTSEL while plugging back in, then retry.", "status-err"); + $("flash-btn").disabled = false; + return; + } + + log(`Downloading firmware ${chosenRev}…`); + const uf2 = await (await fetch(url)).arrayBuffer(); + const blocks = parseUF2(uf2); + log(`UF2: ${blocks.length} blocks (${(blocks.length * 256 / 1024).toFixed(0)} KiB).`); + + pb = new Picoboot(device); + await pb.open(); + + const prog = $("progress"); + prog.hidden = false; prog.value = 0; + setStatus(`Flashing ${chosenRev}…`); + await flashBlocks(pb, blocks, (f) => { prog.value = Math.round(f * 100); }); + + await verifyRev(chosenRev); + } catch (err) { + setStatus(`Failed: ${err.message}`, "status-err"); + log(`ERROR: ${err.message}`, "status-err"); + $("flash-btn").disabled = false; + } finally { + if (pb) await pb.close(); + } +} + +function main() { + if (!("usb" in navigator)) { + $("unsupported").style.display = "block"; + $("app").style.display = "none"; + return; + } + setupRevButtons(); + $("flash-btn").addEventListener("click", onFlashClick); + resolveFirmware().catch((e) => { + $("fw-version").textContent = "unavailable"; + log(`Could not resolve latest firmware: ${e.message}`, "status-err"); + }); +} + +main(); diff --git a/tools/panel-programming/flasher/index.html b/tools/panel-programming/flasher/index.html new file mode 100644 index 0000000..b4b53f9 --- /dev/null +++ b/tools/panel-programming/flasher/index.html @@ -0,0 +1,83 @@ + + + + + +G6 Panel Flasher + + + +
+

G6 Panel Flasher

+

Program a Generation-6 LED display panel over USB — no install, right in your browser.

+ +
+ This browser can’t talk to USB devices. The flasher needs + WebUSB, which is only in Chrome or Edge (desktop). Please reopen this page there. +
+ +
+
+

1Pick the panel revision

+
+ + +
+

Flashing the wrong revision produces a non-working panel + (it must then be re-flashed with the correct one). If unsure, check the board.

+
+ How do I tell which revision I have? +

Read the revision printed on the PCB silkscreen / label (v0.2.1 + vs v0.3.1). A panel already running firmware also reports its + revision — this tool verifies it for you after flashing. +

+
+
+ +
+

2Connect & flash

+

Put the panel in BOOTSEL mode (hold the BOOTSEL button while + plugging in the USB cable), then:

+ +

Firmware: resolving latest…

+ +

+
+ +
+

Activity log

+
+
+
+
+ + + diff --git a/tools/panel-programming/g6_flash.py b/tools/panel-programming/g6_flash.py new file mode 100644 index 0000000..acfdbf6 --- /dev/null +++ b/tools/panel-programming/g6_flash.py @@ -0,0 +1,408 @@ +#!/usr/bin/env python3 +"""g6-flash — streamlined flashing for G6 LED-display panels (RP2350 / RP2354). + +One tool to program new (blank) panels and re-flash old ones, a single panel or a +bench full of them, without a PlatformIO build environment. Built on `picotool`. + +Why this exists +--------------- +The legacy bench scripts (`deploy.sh` / `deploy_all.sh`) drive `pio ... -t upload` +and can only see panels that are *already running* firmware (USB-serial mode), so +they cannot touch a brand-new/blank board and `deploy_all.sh` is sequential. +`picotool` closes both gaps: it can reboot a running panel into BOOTSEL itself +(`reboot -f -u`) and flash a board that is already in BOOTSEL — so the same code +path handles new and old panels — and we flash many in parallel. + +What it does NOT do +------------------- +Panels are stateless: every panel of a given hardware revision gets the IDENTICAL +binary (no per-panel ID/address is burned in — addressing is the arena +controller's job at runtime). So there is no provisioning step. The ONE thing the +operator must get right is the hardware revision, because the two revs need +different binaries and the rev CANNOT be detected over USB on a blank board. +`--rev` is therefore mandatory, and every flash is verified afterwards by reading +back the panel's USB product string. + +Platform: Linux (enumerates via sysfs, like the existing by-id scripts). Requires +`picotool` on PATH (e.g. `pixi run`, conda-forge `picotool`, or a system install). + +Usage +----- + # Re-flash every connected v0.3.1 panel from the latest published firmware: + g6_flash.py --rev v0.3.1 + + # Flash one specific board (physical USB port) with a locally built UF2: + g6_flash.py --rev v0.2.1 --uf2 panel/.pio/build/pico_v021/firmware.uf2 --port 3-1.4 + + # See what would happen without touching anything: + g6_flash.py --rev v0.3.1 --dry-run + +See `tools/panel-programming/README.md` and `docs/development/g6_07-panel-programming.md`. +""" + +from __future__ import annotations + +import argparse +import concurrent.futures +import hashlib +import json +import os +import shutil +import subprocess +import sys +import time +import urllib.request +from dataclasses import dataclass, field +from pathlib import Path + +# --- Constants tied to the firmware (keep in sync with panel/platformio.ini) ---- + +RP_VID = "2e8a" # Raspberry Pi USB vendor id (board_build...usb_vid = 0x2E8A) +PID_APP = "0009" # running panel firmware: USB-serial device +PID_BOOTSEL = "000f" # RP2350 bootrom: USB mass-storage / PICOBOOT + +# rev -> (PlatformIO env, USB product string prefix). Product strings are +# "G6 Panel v0.2" / "G6 Panel v0.3" (major.minor only) — match by prefix, the +# same way deploy.sh maps env -> product. +REVS = { + "v0.2.1": {"env": "pico_v021", "usb_product": "G6 Panel v0.2"}, + "v0.3.1": {"env": "pico_v031", "usb_product": "G6 Panel v0.3"}, +} + +# Default firmware source (GitHub Releases of the panel firmware repo). +FW_REPO = "reiserlab/LED-Display_G6_Firmware_Panel" +CACHE_DIR = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "g6-flash" + +REENUMERATE_TIMEOUT_S = 25.0 +REENUMERATE_POLL_S = 0.25 + + +# --- Device enumeration (Linux sysfs) ------------------------------------------- + + +@dataclass +class Panel: + """A connected RP2350 board, keyed by its stable physical USB port path. + + The port path (sysfs dir name, e.g. "3-1.4") survives the BOOTSEL<->app + re-enumeration, so we use it as the device identity. bus/address (USB + busnum/devnum) are what picotool targets and change on re-enumeration. + """ + + port: str # sysfs port path, e.g. "3-1.4" — stable across re-enumeration + bus: int # USB bus number (picotool --bus) + address: int # USB device address (picotool --address) + pid: str # "0009" (app) or "000f" (bootsel) + serial: str | None = None + product: str | None = None + + @property + def in_bootsel(self) -> bool: + return self.pid == PID_BOOTSEL + + @property + def label(self) -> str: + what = self.product or ("BOOTSEL" if self.in_bootsel else "?") + return f"port {self.port} (bus {self.bus} addr {self.address}, {what})" + + +def _read(path: Path) -> str | None: + try: + return path.read_text().strip() + except OSError: + return None + + +def enumerate_panels() -> list[Panel]: + """Find all connected RP2350 boards (running firmware OR in BOOTSEL).""" + root = Path("/sys/bus/usb/devices") + if not root.is_dir(): + sys.exit("g6-flash: /sys/bus/usb/devices not found — this tool is Linux-only.") + + panels: list[Panel] = [] + for dev in sorted(root.iterdir()): + if _read(dev / "idVendor") != RP_VID: + continue + pid = _read(dev / "idProduct") + if pid not in (PID_APP, PID_BOOTSEL): + continue + busnum = _read(dev / "busnum") + devnum = _read(dev / "devnum") + if not (busnum and devnum): + continue + panels.append( + Panel( + port=dev.name, + bus=int(busnum), + address=int(devnum), + pid=pid, + serial=_read(dev / "serial"), + product=_read(dev / "product"), + ) + ) + return panels + + +def wait_for_port(port: str, want_pid: str | None, timeout: float) -> Panel | None: + """Poll sysfs until the panel on `port` reappears (optionally in want_pid).""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + for p in enumerate_panels(): + if p.port == port and (want_pid is None or p.pid == want_pid): + return p + time.sleep(REENUMERATE_POLL_S) + return None + + +# --- Firmware artifact resolution ----------------------------------------------- + + +def _http_json(url: str) -> dict: + req = urllib.request.Request(url, headers={"Accept": "application/vnd.github+json"}) + token = os.environ.get("GITHUB_TOKEN") + if token: + req.add_header("Authorization", f"Bearer {token}") + with urllib.request.urlopen(req, timeout=30) as r: + return json.load(r) + + +def _download(url: str, dest: Path) -> None: + dest.parent.mkdir(parents=True, exist_ok=True) + req = urllib.request.Request(url, headers={"Accept": "application/octet-stream"}) + with urllib.request.urlopen(req, timeout=120) as r, open(dest, "wb") as f: + shutil.copyfileobj(r, f) + + +def _sha256(path: Path) -> str: + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1 << 16), b""): + h.update(chunk) + return h.hexdigest() + + +def resolve_uf2(rev: str, fw_version: str | None, local_uf2: str | None) -> Path: + """Return a path to the UF2 to flash for `rev` — local file or cached release. + + Release layout (produced by release.yml): each release carries per-rev UF2s + named g6-panel-.uf2 plus a manifest.json describing rev/env/sha256/ + usb_product. We pick the artifact for `rev`, download into the per-version + cache, and verify its sha256 against the manifest. + """ + if local_uf2: + p = Path(local_uf2) + if not p.is_file(): + sys.exit(f"g6-flash: --uf2 not found: {p}") + return p + + base = f"https://api.github.com/repos/{FW_REPO}/releases" + rel = _http_json(f"{base}/tags/{fw_version}" if fw_version else f"{base}/latest") + tag = rel["tag_name"] + assets = {a["name"]: a["browser_download_url"] for a in rel.get("assets", [])} + + if "manifest.json" not in assets: + sys.exit(f"g6-flash: release {tag} has no manifest.json asset.") + cache = CACHE_DIR / tag + manifest_path = cache / "manifest.json" + if not manifest_path.is_file(): + _download(assets["manifest.json"], manifest_path) + manifest = json.loads(manifest_path.read_text()) + + entry = next((a for a in manifest.get("artifacts", []) if a.get("rev") == rev), None) + if not entry: + sys.exit(f"g6-flash: release {tag} manifest has no artifact for rev {rev}.") + + fname = entry["file"] + if fname not in assets: + sys.exit(f"g6-flash: release {tag} is missing UF2 asset '{fname}'.") + uf2 = cache / fname + if not uf2.is_file(): + print(f"g6-flash: downloading {fname} from firmware release {tag} …") + _download(assets[fname], uf2) + + expect = entry.get("sha256") + if expect: + got = _sha256(uf2) + if got != expect: + uf2.unlink(missing_ok=True) + sys.exit(f"g6-flash: sha256 mismatch for {fname}\n expected {expect}\n got {got}") + print(f"g6-flash: firmware {tag} rev {rev} -> {uf2}") + return uf2 + + +# --- picotool wrappers ---------------------------------------------------------- + + +def _picotool(*args: str) -> subprocess.CompletedProcess: + return subprocess.run( + ["picotool", *args], capture_output=True, text=True, check=False + ) + + +def reboot_to_bootsel(panel: Panel) -> None: + """Force a running panel into BOOTSEL (no button press).""" + cp = _picotool("reboot", "-f", "-u", "--bus", str(panel.bus), "--address", str(panel.address)) + # picotool returns nonzero if the device already vanished into BOOTSEL — tolerate it. + if cp.returncode != 0 and "not in BOOTSEL" not in (cp.stderr + cp.stdout): + # Best-effort: re-enumeration check below is the real gate. + pass + + +def load_uf2(panel: Panel, uf2: Path, execute: bool) -> subprocess.CompletedProcess: + args = ["load", "--bus", str(panel.bus), "--address", str(panel.address)] + if execute: + args.append("-x") + args.append(str(uf2)) + return _picotool(*args) + + +# --- Per-panel flash flow ------------------------------------------------------- + + +@dataclass +class Result: + panel: Panel + ok: bool + detail: str + verified_product: str | None = None + + +def flash_one(panel: Panel, rev: str, uf2: Path, execute: bool, dry_run: bool) -> Result: + want_product = REVS[rev]["usb_product"] + + if dry_run: + action = "reboot->BOOTSEL then load" if not panel.in_bootsel else "load" + return Result(panel, True, f"DRY-RUN: would {action} {uf2.name}") + + # 1) Get the board into BOOTSEL. + target = panel + if not panel.in_bootsel: + reboot_to_bootsel(panel) + target = wait_for_port(panel.port, PID_BOOTSEL, REENUMERATE_TIMEOUT_S) + if target is None: + return Result(panel, False, "timed out waiting for BOOTSEL after reboot") + + # 2) Flash. + cp = load_uf2(target, uf2, execute) + if cp.returncode != 0: + return Result(panel, False, f"picotool load failed: {(cp.stderr or cp.stdout).strip()[:200]}") + + if not execute: + return Result(panel, True, "flashed (not executed; power-cycle to run + verify)") + + # 3) Verify: panel re-enumerates as app-mode with the expected product string. + booted = wait_for_port(panel.port, PID_APP, REENUMERATE_TIMEOUT_S) + if booted is None: + return Result(panel, False, "flashed but panel did not re-enumerate as firmware") + if not (booted.product or "").startswith(want_product): + return Result( + panel, False, + f"WRONG REV? flashed {rev} but panel reports '{booted.product}' (expected '{want_product}*')", + booted.product, + ) + return Result(panel, True, "flashed + verified", booted.product) + + +# --- CLI ------------------------------------------------------------------------ + + +def select_targets(panels: list[Panel], rev: str, port: str | None, force: bool) -> list[Panel]: + if port: + chosen = [p for p in panels if p.port == port] + if not chosen: + sys.exit(f"g6-flash: no RP2350 board on port {port}. Connected: " + + (", ".join(p.port for p in panels) or "none")) + return chosen + + if not panels: + sys.exit("g6-flash: no RP2350 panels found (none in firmware or BOOTSEL mode).") + + # Loud warning if a *running* board reports a different rev than --rev. We + # cannot block (a blank/BOOTSEL board reports nothing, and a genuine cross-rev + # re-flash is legitimate), but a silent wrong-rev batch is the headline footgun. + want_product = REVS[rev]["usb_product"] + mismatched = [p for p in panels if p.product and not p.product.startswith(want_product)] + if mismatched and not force: + print(f"\n ⚠ {len(mismatched)} connected panel(s) currently report a DIFFERENT rev than --rev {rev}:") + for p in mismatched: + print(f" {p.label}") + print(f" Expected product prefix '{want_product}'. If these really are {rev}") + print(f" hardware (e.g. blank/mis-flashed), re-run with --force. Otherwise fix --rev.\n") + sys.exit("g6-flash: aborting on rev mismatch (use --force to override).") + return panels + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser( + prog="g6-flash", + description="Flash G6 LED-display panels (RP2350) — new or existing, one or many.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="Realistic batch ceiling: ~10–20 panels per EXTERNALLY-POWERED hub. The\n" + "limit is post-flash LED-matrix inrush + USB re-enumeration, not flashing.\n" + "For larger trays use --no-exec, then power-cycle in small groups.", + ) + ap.add_argument("--rev", required=True, choices=sorted(REVS), + help="panel hardware revision (MANDATORY — selects the binary; " + "cannot be auto-detected on a blank board)") + ap.add_argument("--uf2", metavar="PATH", + help="flash a local UF2 instead of a published release (for firmware devs)") + ap.add_argument("--fw-version", metavar="TAG", + help="firmware release tag to flash (default: latest)") + ap.add_argument("--port", metavar="PORT", + help="flash only the board on this sysfs USB port (e.g. 3-1.4); " + "default: all connected panels") + ap.add_argument("--jobs", type=int, default=4, metavar="N", + help="max panels flashed in parallel (default: 4)") + ap.add_argument("--no-exec", action="store_true", + help="load firmware but do NOT execute it (reduces simultaneous " + "power-on inrush; power-cycle later to run + verify)") + ap.add_argument("--force", action="store_true", + help="suppress the running-panel rev-mismatch guard") + ap.add_argument("--dry-run", action="store_true", + help="show what would be flashed without touching any board") + ap.add_argument("--list", action="store_true", + help="list connected panels and exit") + args = ap.parse_args(argv) + + if not args.dry_run and not shutil.which("picotool"): + sys.exit("g6-flash: 'picotool' not found on PATH. Install it (conda-forge " + "'picotool', or `pixi run`) and retry.") + + panels = enumerate_panels() + + if args.list: + if not panels: + print("No RP2350 panels connected.") + for p in panels: + print(f" {p.label} serial={p.serial}") + return 0 + + targets = select_targets(panels, args.rev, args.port, args.force) + uf2 = resolve_uf2(args.rev, args.fw_version, args.uf2) + execute = not args.no_exec + + print(f"\ng6-flash: flashing {len(targets)} panel(s) as {args.rev} " + f"({'parallel x%d' % args.jobs if len(targets) > 1 else 'single'})\n") + + results: list[Result] = [] + with concurrent.futures.ThreadPoolExecutor(max_workers=max(1, args.jobs)) as pool: + futs = {pool.submit(flash_one, p, args.rev, uf2, execute, args.dry_run): p for p in targets} + for fut in concurrent.futures.as_completed(futs): + r = fut.result() + mark = "✓" if r.ok else "✗" + print(f" {mark} {r.panel.label}: {r.detail}") + results.append(r) + + ok = sum(1 for r in results if r.ok) + failed = len(results) - ok + print(f"\ng6-flash: {ok}/{len(results)} panel(s) OK" + + (f", {failed} FAILED" if failed else "")) + if failed: + for r in results: + if not r.ok: + print(f" FAILED {r.panel.label}: {r.detail}") + return 1 if failed else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/panel-programming/release.yml b/tools/panel-programming/release.yml new file mode 100644 index 0000000..945c313 --- /dev/null +++ b/tools/panel-programming/release.yml @@ -0,0 +1,95 @@ +# Panel-firmware release: build per-rev UF2s and publish them (plus a manifest) +# as GitHub Release assets, so g6-flash and the WebUSB flasher never need a +# PlatformIO toolchain. +# +# FINAL HOME: .github/workflows/release.yml in reiserlab/LED-Display_G6_Firmware_Panel +# (paths below are relative to that repo root, where `panel/platformio.ini` lives). +# +# Tag a release to trigger: git tag panel-fw-v1.0.0 && git push origin panel-fw-v1.0.0 +name: release + +on: + push: + tags: ["panel-fw-v*"] + workflow_dispatch: {} + +permissions: + contents: write # create the GitHub Release + upload assets + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + include: + # Keep env names in sync with panel/platformio.ini. Production envs + # ONLY — _bcmtest / _spidiag are bench-only and must not be released. + - env: pico_v021 + rev: v0.2.1 + usb_product: "G6 Panel v0.2" + - env: pico_v031 + rev: v0.3.1 + usb_product: "G6 Panel v0.3" + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + # PlatformIO drives the build; the panel build also runs + # tools/gen_predef_patterns.py (extra_scripts) and enforces the 2 MiB cap, + # so an oversize or broken image fails here rather than at flash time. + - name: Install PlatformIO + run: pip install "platformio>=6.1.19,<7" + + - name: Build ${{ matrix.env }} + run: pio run -d panel -e ${{ matrix.env }} + + - name: Stage UF2 as g6-panel-${{ matrix.rev }}.uf2 + run: | + mkdir -p dist + cp "panel/.pio/build/${{ matrix.env }}/firmware.uf2" \ + "dist/g6-panel-${{ matrix.rev }}.uf2" + # Per-leg metadata; the release job stitches these into manifest.json. + sha=$(sha256sum "dist/g6-panel-${{ matrix.rev }}.uf2" | cut -d' ' -f1) + cat > "dist/artifact-${{ matrix.rev }}.json" < Date: Fri, 26 Jun 2026 16:10:17 +0000 Subject: [PATCH 2/4] Add panel-programming handoff doc for local finishing/testing Step-by-step continuation guide (release cut, relocation into the submodule repos, web + CLI testing against the two known panels) plus the macOS/sysfs caveat for g6_flash.py. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UcDc2Scj2wZMBBXfaBc1HX --- tools/panel-programming/HANDOFF.md | 102 +++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 tools/panel-programming/HANDOFF.md diff --git a/tools/panel-programming/HANDOFF.md b/tools/panel-programming/HANDOFF.md new file mode 100644 index 0000000..705f23e --- /dev/null +++ b/tools/panel-programming/HANDOFF.md @@ -0,0 +1,102 @@ +# Panel-programming handoff — finishing & testing + +Continuation guide for finishing the streamlined panel-programming work +(PR [reiserlab/Modular-LED-Display#60](https://github.com/reiserlab/Modular-LED-Display/pull/60)) +in a local session. Full design: [`docs/development/g6_07-panel-programming.md`](../../docs/development/g6_07-panel-programming.md). + +## What exists now + +Staged in this directory (`tools/panel-programming/`) on branch +`claude/panels-programming-streamline-2h5f8c`: + +| File | Final home | Status | +|---|---|---| +| `g6_flash.py` | `panel/tools/g6_flash.py` (firmware repo) | compiles + `--help` OK; **not hardware-tested**; **Linux-only** (see caveat) | +| `release.yml` | `.github/workflows/release.yml` (firmware repo) | valid YAML; never run | +| `flasher/index.html`, `flasher/flasher.js` | `flasher/` (webDisplayTools repo) | `node --check` OK; **not hardware-tested**; fetches firmware from Release only | +| `g6_07-panel-programming.md` | stays in `docs/development/` | reference spec | + +Firmware repo: `reiserlab/LED-Display_G6_Firmware_Panel` (`Generation 6/Panel-Firmware`). +Web repo: `reiserlab/webDisplayTools` (`Generation 6/webDisplayTools`). + +## ⚠ macOS caveat + +`g6_flash.py` enumerates panels via Linux **sysfs** (`/sys/bus/usb/devices`), which +does **not** exist on macOS. +- ✅ WebUSB flasher → cross-platform (test in Chrome/Edge on the Mac). +- ✅ `picotool` itself → works on macOS (`brew install picotool`); manual single-panel + flashing works. +- ❌ `g6_flash.py` batch CLI → run it on the **Linux bench machine** (where `deploy*.sh` + already run), or ask Claude to refactor enumeration to use `picotool info` (cross-platform). + +## Steps + +### 1. Cut the first firmware release (unblocks both tools' default download path) +In `reiserlab/LED-Display_G6_Firmware_Panel`: +```sh +mkdir -p .github/workflows +cp /tools/panel-programming/release.yml .github/workflows/release.yml +git add .github/workflows/release.yml && git commit -m "Add firmware release workflow" +git push +git tag panel-fw-v0.0.1 && git push origin panel-fw-v0.0.1 +``` +Confirm the Action publishes `g6-panel-v0.2.1.uf2`, `g6-panel-v0.3.1.uf2`, and +`manifest.json` to a GitHub Release. If the build can't find Python for +`tools/gen_predef_patterns.py`, the `setup-python` step in the workflow covers it. + +### 2. Relocate the tools into their real repos +- `g6_flash.py` → firmware `panel/tools/g6_flash.py`. +- Add to firmware `pixi.toml`: + ```toml + [dependencies] + picotool = "*" # conda-forge + + [tasks] + flash21 = "python panel/tools/g6_flash.py --rev v0.2.1" + flash31 = "python panel/tools/g6_flash.py --rev v0.3.1" + ``` +- `flasher/` → webDisplayTools `flasher/`; enable GitHub Pages for that repo. +- Once relocated, delete `tools/panel-programming/` here (keep the `g6_07` spec doc). + +### 3. Test the WebUSB flasher (Mac, Chrome) — most user-facing +Needs step 1 done first (it fetches the UF2 from the latest Release). Then: open the +Pages URL → pick rev → hold the panel's **BOOTSEL** button while plugging in USB → +**Connect & flash** → confirm the green "Verified: G6 Panel vX.Y ✓" line. +- If you want to test *before* cutting a release, ask Claude to add a "load local .uf2" + file picker to `flasher.js` (small change). + +### 4. Test the CLI (Linux bench) against the two known panels +Panels: `319A5199EE357F77` (v0.2.1), `A5D4B82BA2B9FB51` (v0.3.1). +```sh +# needs picotool on PATH (pixi run, or conda-forge picotool) +python panel/tools/g6_flash.py --list # see connected panels +python panel/tools/g6_flash.py --rev v0.3.1 # re-flash running panel(s), latest fw +python panel/tools/g6_flash.py --rev v0.2.1 --uf2 panel/.pio/build/pico_v021/firmware.uf2 +``` +Validate: +1. **Re-flash a running panel** — cross-check against `pixi run deploy31a`. +2. **Blank/BOOTSEL panel (new capability)** — hold BOOTSEL while plugging in so it + enumerates as PICOBOOT, then `--rev` flashes it with no `pio` and no serial. +3. **Rev guard** — wrong `--rev` on a running board → loud abort (override `--force`). +4. **Post-flash verify** — tool confirms the panel re-enumerates with the expected + `G6 Panel vX.Y` product string. +5. Optional visual: flash a `*_bcmtest` env UF2 (30-s BCM self-test), then re-flash + production (bcmtest has no SPI ingest — do not leave it on a deployed panel). + +## Open items (also in g6_07 § Open Questions) +- Confirm the exact PCB silkscreen rev marking (from `Generation 6/Panels`) and add it + (plus a photo) to the web flasher's help panel. +- Decide: import `piersfinlayson/picoflash`'s PICOBOOT module vs. keep the thin + reimplementation in `flasher.js`; validate framing against real RP2350 hardware. +- Optional: a firmware serial "reboot to BOOTSEL" command so the web tool can auto-enter + BOOTSEL via a Web Serial pre-step (removes the manual hold-BOOTSEL step). + +## Paste-in prompt for the local session +> Continue the G6 panel-programming work from PR reiserlab/Modular-LED-Display#60. +> Read `tools/panel-programming/HANDOFF.md` and `docs/development/g6_07-panel-programming.md`. +> Do: (1) add `release.yml` to the firmware repo's `.github/workflows/` and cut a +> `panel-fw-v0.0.1` release; (2) relocate `g6_flash.py` and `flasher/` into the firmware +> and webDisplayTools repos with the `pixi.toml` additions; (3) help me test the WebUSB +> flasher in Chrome and the CLI against panels `319A5199EE357F77`/`A5D4B82BA2B9FB51`. +> I'm on macOS — `g6_flash.py` currently needs Linux (sysfs); offer to make it +> cross-platform if useful. From 67c845cc521bda4933bde99a8f4629334e9f76a9 Mon Sep 17 00:00:00 2001 From: Frank Loesche Date: Fri, 3 Jul 2026 15:55:25 -0400 Subject: [PATCH 3/4] Delete superseded panel-programming staging dir; bump submodules - Delete tools/panel-programming/ (staged g6_flash.py, release.yml, flasher/, README.md, HANDOFF.md). This was always meant as temporary staging pending relocation; that relocation is done and has evolved well past this snapshot directly in the Panel-Firmware and webDisplayTools submodules (dynamic release/diag catalog discovery, .bin ISP packaging, monitor.py, --serial targeting, and a much more mature WebUSB flasher all live there now, none of it mirrored back here). - Update docs/development/g6_07-panel-programming.md and docs/development/README.md to point at the real firmware-repo/ webDisplayTools locations instead of the deleted staging copies. - pixi.toml: add win-64 to supported platforms. --- docs/development/README.md | 2 +- docs/development/g6_07-panel-programming.md | 119 +++--- pixi.toml | 2 +- tools/panel-programming/HANDOFF.md | 102 ----- tools/panel-programming/README.md | 69 ---- tools/panel-programming/flasher/flasher.js | 275 ------------- tools/panel-programming/flasher/index.html | 83 ---- tools/panel-programming/g6_flash.py | 408 -------------------- tools/panel-programming/release.yml | 95 ----- 9 files changed, 75 insertions(+), 1080 deletions(-) delete mode 100644 tools/panel-programming/HANDOFF.md delete mode 100644 tools/panel-programming/README.md delete mode 100644 tools/panel-programming/flasher/flasher.js delete mode 100644 tools/panel-programming/flasher/index.html delete mode 100644 tools/panel-programming/g6_flash.py delete mode 100644 tools/panel-programming/release.yml diff --git a/docs/development/README.md b/docs/development/README.md index 093ece6..b03c3b3 100644 --- a/docs/development/README.md +++ b/docs/development/README.md @@ -21,7 +21,7 @@ Implementation-readiness is the design goal: a firmware author or future Claude | [`g6_04-pattern-file-format.md`](g6_04-pattern-file-format.md) | Pattern Format / Panel Map (merged) | **v2 canonical** | [`Generation 6/maDisplayTools/g6/g6_save_pattern.m`](../../Generation%206/maDisplayTools/g6/g6_save_pattern.m); JS round-trip via [`g6_encoding_reference.json`](../../Generation%206/maDisplayTools/g6/g6_encoding_reference.json) | | [`g6_05-host-software.md`](g6_05-host-software.md) | Host PC Matlab SW | **Stub (firmware-contract framing)** | Authoritative host docs: `Generation 6/maDisplayTools/docs/*.md`. | | [`g6_06-arena-firmware-interface.md`](g6_06-arena-firmware-interface.md) | G6 arena design (v1/v2) | **Thin firmware-interface reference** | Production arena: [`reiserlab/LED-Display_G6_Hardware_Arena`](https://github.com/reiserlab/LED-Display_G6_Hardware_Arena) v1.1.7. | -| [`g6_07-panel-programming.md`](g6_07-panel-programming.md) | — (panel firmware flashing: `g6-flash` CLI + WebUSB flasher) | **Specified** | Reference implementation staged at [`tools/panel-programming/`](../../tools/panel-programming/); firmware: [`reiserlab/LED-Display_G6_Firmware_Panel`](https://github.com/reiserlab/LED-Display_G6_Firmware_Panel) (`panel/platformio.ini`, `panel/tools/deploy*.sh`). Unvalidated against hardware end-to-end. | +| [`g6_07-panel-programming.md`](g6_07-panel-programming.md) | — (panel firmware flashing: `g6-flash` CLI + WebUSB flasher) | **Specified** | Implemented in [`reiserlab/LED-Display_G6_Firmware_Panel`](https://github.com/reiserlab/LED-Display_G6_Firmware_Panel) (`panel/tools/g6_flash.py`, `panel/tools/build_release.py`, `pixi.toml`) and webDisplayTools `flasher/`. Unvalidated against hardware end-to-end. | | [`g6_arena_configs.h`](g6_arena_configs.h) | — (controller-side panel map, keyed by Arena ID) | **Codegen-emitted** | Generated from [`Generation 6/maDisplayTools/`](../../Generation%206/maDisplayTools/) `configs/arena_registry/index.yaml` + `configs/arenas/G6_*.yaml` + `configs/arena_hardware/.yaml` via [`tools/gen_arena_configs.py`](../../Generation%206/maDisplayTools/tools/gen_arena_configs.py). Includes G6_2x10 and G6_2x8of10 (sharing `arena_10-10_v1p1r7` hardware profile); G6_3x12of18 skipped (no 18-col hardware). Unvalidated against firmware end-to-end. | Status values: `Specified` (concrete enough to implement against), `Draft` (mostly there, gaps marked), `Teaser` (sketched), `Stub` (placeholder). diff --git a/docs/development/g6_07-panel-programming.md b/docs/development/g6_07-panel-programming.md index c37d4f9..fb27c99 100644 --- a/docs/development/g6_07-panel-programming.md +++ b/docs/development/g6_07-panel-programming.md @@ -1,7 +1,10 @@ # G6 — Panel Programming (flashing tools) -Source: G6 panel programming streamlining work; grounds against [`reiserlab/LED-Display_G6_Firmware_Panel`](https://github.com/reiserlab/LED-Display_G6_Firmware_Panel) (`panel/platformio.ini`, `panel/tools/deploy*.sh`, `pixi.toml`). -Status: **Specified** — design + reference implementation staged at [`tools/panel-programming/`](../../tools/panel-programming/); pending relocation into the firmware/web submodule repos and end-to-end hardware validation. +Source: G6 panel programming streamlining work; grounds against [`reiserlab/LED-Display_G6_Firmware_Panel`](https://github.com/reiserlab/LED-Display_G6_Firmware_Panel) (`panel/platformio.ini`, `panel/tools/g6_flash.py`, `pixi.toml`). +Status: **Specified** — `g6-flash` CLI and the release/diag build pipeline live in +`panel/tools/` (firmware repo); the WebUSB flasher lives in webDisplayTools' `flasher/`. +End-to-end hardware validation and cutting the first `panel-fw-v*` release are still open +(see Open Questions / TBDs). This file specifies how G6 panels get their firmware: a `g6-flash` command-line tool and a zero-install WebUSB browser flasher, both fed by CI-published, prebuilt UF2 @@ -31,15 +34,17 @@ USB identities (from `panel/platformio.ini`): VID `0x2E8A`; running firmware PID `0x0009` (USB-serial, product `G6 Panel v0.2` / `G6 Panel v0.3`); BOOTSEL PID `0x000f` (mass-storage + PICOBOOT). -### Why not the existing `deploy*.sh` +### Why not the retired `deploy*.sh` -`panel/tools/deploy.sh` / `deploy_all.sh` drive `pio … -t upload` and match panels only -in **USB-serial mode** — so they **cannot flash a blank/BOOTSEL board** (the common case -for a freshly assembled panel), require a full PlatformIO build env, and `deploy_all.sh` -is sequential. The tools below use **`picotool`**, which can reboot a running panel into -BOOTSEL itself (`reboot -f -u`) *and* flash a board already in BOOTSEL — one code path for -both new and old panels — and consume prebuilt UF2s so no build env is needed. The legacy -scripts remain for the firmware-developer `pio` flow. +`panel/tools/deploy.sh` / `deploy_all.sh` used to drive `pio … -t upload` and matched +panels only in **USB-serial mode** — so they **could not flash a blank/BOOTSEL board** +(the common case for a freshly assembled panel), required a full PlatformIO build env, +and `deploy_all.sh` flashed sequentially. The tools below use **`picotool`**, which can +reboot a running panel into BOOTSEL itself (`reboot -f -u`) *and* flash a board already +in BOOTSEL — one code path for both new and old panels — and consume prebuilt UF2s so no +build env is needed. Both scripts have since been retired: their functionality (build a +local env, flash one panel by USB serial or all panels of a rev) now lives in `g6-flash` +itself, driven by the `deploy*`/`deploy*a`/`deploy*a-diag` pixi tasks (`pixi.toml`). --- @@ -47,33 +52,54 @@ scripts remain for the firmware-developer `pio` flow. CI builds and publishes per-rev UF2s so neither tool needs PlatformIO. -- Workflow: `.github/workflows/release.yml` in the firmware repo (reference copy at - [`tools/panel-programming/release.yml`](../../tools/panel-programming/release.yml)). - Triggers on tag `panel-fw-v*` (and manual dispatch). -- Matrix over the **production envs only** (`pico_v021`, `pico_v031` — never the - `_bcmtest` / `_spidiag` bench builds, which carry a `*** DO NOT DEPLOY ***` banner). - Build via `pio run -d panel -e `; the build already runs - `tools/gen_predef_patterns.py` and enforces the 2 MiB cap, so a broken/oversize image - fails in CI. -- Release assets: `g6-panel-v0.2.1.uf2`, `g6-panel-v0.3.1.uf2`, and a **`manifest.json`**: +- Workflow: `.github/workflows/release.yml` in the firmware repo. Triggers on tag + `panel-fw-v*` (and manual dispatch). +- The release catalog isn't a hardcoded list — `panel/tools/build_release.py` + discovers it from `panel/platformio.ini` itself: an env belongs to the + **release** group if it `extends = common` (a deployable hardware-rev + build — currently `pico_v021`, `pico_v031`); an env that instead `extends` + another `pico_v*` env (`_bcmtest`, `_spidiag`, or any future variant) + belongs to the separate **diag** group (`pixi run diag`) — + bench/diagnostic builds, never published by CI, which only ever runs + `pixi run release`. Adding a new hardware rev or variant to + `platformio.ini` with the right `extends` is the only thing needed for it + to show up in the right group automatically. CI installs pixi + (`prefix-dev/setup-pixi`, `locked: true`) instead of a separate + `pip install platformio`, so it resolves the exact same PlatformIO (and + Python) versions as running `pixi run release` on a bench machine, and a + developer can preview the whole release payload locally before ever + pushing a tag. The build already runs `tools/gen_predef_patterns.py` and + enforces the 2 MiB cap, so a broken/oversize image fails before it's ever + staged. +- Release assets: `g6-panel-v0.2.1.uf2`, `g6-panel-v0.3.1.uf2`, their ISP-footer + `.bin` counterparts (`pixi run release` always builds both formats — one build + pipeline, not two), and a **`manifest.json`**: ```json { "version": "panel-fw-v1.0.0", "artifacts": [ - { "rev": "v0.2.1", "env": "pico_v021", "file": "g6-panel-v0.2.1.uf2", - "sha256": "…", "usb_product": "G6 Panel v0.2" }, - { "rev": "v0.3.1", "env": "pico_v031", "file": "g6-panel-v0.3.1.uf2", - "sha256": "…", "usb_product": "G6 Panel v0.3" } ] } + { "rev": "v0.2.1", "env": "pico_v021", "variant": "production", + "label": "v0.2.1 — Production", "usb_product": "G6 Panel v0.2", "default": false, + "uf2": { "file": "g6-panel-v0.2.1.uf2", "sha256": "…" }, + "bin": { "file": "g6-panel-v0.2.1.bin", "sha256": "…" } }, + { "rev": "v0.3.1", "env": "pico_v031", "variant": "production", + "label": "v0.3.1 — Production", "usb_product": "G6 Panel v0.3", "default": true, + "uf2": { "file": "g6-panel-v0.3.1.uf2", "sha256": "…" }, + "bin": { "file": "g6-panel-v0.3.1.bin", "sha256": "…" } } ] } ``` - `usb_product` drives post-flash verification; `sha256` is checked on download. + `usb_product` drives post-flash verification; `sha256` is checked on download. `bin` is the + ISP-footer image for the arena controller's over-SPI push — that path is out of scope for this + doc (see [`g6_03-controller.md`](g6_03-controller.md) § Panel firmware update (ISP)) — + `g6-flash`/the WebUSB flasher only ever look at `uf2`. ## B. `g6-flash` CLI (bench) -Reference: [`tools/panel-programming/g6_flash.py`](../../tools/panel-programming/g6_flash.py); -final home `panel/tools/g6_flash.py`. Python 3, stdlib-only; needs `picotool` on PATH -(add to `pixi.toml [dependencies]`, conda-forge has it). Linux (enumerates via sysfs, -like the by-id scripts). +Location: `panel/tools/g6_flash.py` (firmware repo). Python 3, stdlib-only; needs `picotool`, which is +**not** a conda-forge package so it isn't a pinned `pixi.toml` dependency — the tool +prefers the copy PlatformIO already vendors under `~/.platformio/packages/tool-picotool*/` +(present for anyone who's run `pixi run release`/`diag`, which build via `pio` +under the hood), falling back to PATH. Linux (enumerates via sysfs, like the by-id scripts). Per panel: if it is running firmware, `picotool reboot -f -u --bus B --address A` → wait for BOOTSEL; then `picotool load [-x] --bus B --address A `; then (when executed) @@ -98,15 +124,18 @@ g6_flash.py --rev v0.3.1 --uf2 panel/.pio/build/pico_v031/firmware.uf2 # local g6_flash.py --list # show connected panels ``` -Suggested `pixi.toml` tasks (replacing the hardcoded-serial demo tasks): -`flash21 = "python panel/tools/g6_flash.py --rev v0.2.1"`, -`flash31 = "python panel/tools/g6_flash.py --rev v0.3.1"`. +The firmware repo's `pixi.toml` wraps this into `flash21-github-release`/`flash31-github-release` +(flash the latest published release, no local build) and `flash21`/`flash31` (build +the full release catalog first via `depends-on = ["release"]`, then flash the resulting +`dist/g6-panel-.uf2`) — see `pixi.toml`'s own comments for the exact task definitions. ## C. WebUSB browser flasher (nontechnical users) -Reference: [`tools/panel-programming/flasher/`](../../tools/panel-programming/flasher/); -final home `flasher/` in [`reiserlab/webDisplayTools`](https://github.com/reiserlab/webDisplayTools), -served over GitHub Pages (WebUSB requires a secure context). **Chromium/Edge only.** +Location: `flasher/` in [`reiserlab/webDisplayTools`](https://github.com/reiserlab/webDisplayTools), +served over GitHub Pages (WebUSB requires a secure context). **Chromium/Edge only.** This +section describes the core design (WebUSB/PICOBOOT mechanics); the live tool's UI/catalog +features (build dropdown grouping, local dev builds, etc.) have continued to evolve past it — +see `flasher/flasher.js` itself for current behavior. A standalone static page (plain HTML + ES module, no build step), separate from the existing Web *Serial* arena console — bootloader flashing needs Web **USB** (the RP2350 @@ -125,21 +154,19 @@ the latest release via the same `manifest.json`, so the page auto-tracks new fir - Mandatory, deliberate rev selection; neither tool guesses. - Both read back the post-flash USB product string and prefix-match it against the - manifest's `usb_product` (`G6 Panel v0.2` / `v0.3`, same casing as `deploy.sh`), so a - wrong-rev flash is caught immediately on the bench rather than in an arena. + manifest's `usb_product` (`G6 Panel v0.2` / `v0.3`), so a wrong-rev flash is caught + immediately on the bench rather than in an arena. - PCB-identification guidance is shown in both UIs. --- ## Current state -Design and reference implementations are complete and staged in the parent repo at -[`tools/panel-programming/`](../../tools/panel-programming/) (the firmware and webDisplayTools -submodules are separate repos). A maintainer relocates them to their final homes — -`panel/tools/g6_flash.py`, `.github/workflows/release.yml`, webDisplayTools `flasher/` — -and adds the `picotool` dependency + `flash*` tasks to the firmware `pixi.toml`. No -firmware release has been cut yet, so the tools' default download path is untested until -the first `panel-fw-v*` tag. +`g6-flash` (`panel/tools/g6_flash.py`), the release/diag build pipeline +(`panel/tools/build_release.py`, `.github/workflows/release.yml`, `pixi.toml`), and the +WebUSB flasher (webDisplayTools `flasher/`) are implemented in their final homes (`picotool` +is not pinned as a `pixi.toml` dependency — see §B). No firmware release has been cut yet, +so the tools' default download path is untested until the first `panel-fw-v*` tag. ## Open Questions / TBDs @@ -150,8 +177,8 @@ the first `panel-fw-v*` tag. framing against real RP2350 hardware and the picoflash reference. - **End-to-end hardware validation** — verify against the two bench panels (`319A5199EE357F77` v0.2.1, `A5D4B82BA2B9FB51` v0.3.1): a re-flash of a running board - (cross-checked vs `pixi run deploy31a`), and a blank/BOOTSEL board (the new capability), - plus the rev-guard abort path. + (cross-checked vs a direct `g6_flash.py --serial ` call), and a blank/BOOTSEL + board (the new capability), plus the rev-guard abort path. - **Optional** — a firmware serial command to reboot-to-BOOTSEL would let the web tool auto-enter BOOTSEL via a Web Serial pre-step, removing the manual hold-BOOTSEL step. @@ -164,4 +191,4 @@ the first `panel-fw-v*` tag. - [`g6_05-host-software.md`](g6_05-host-software.md) — host transports; webDisplayTools Web Serial console (distinct from this WebUSB flasher). - Firmware build/flash: `Generation 6/Panel-Firmware/README.md`, `panel/platformio.ini`, - `panel/tools/deploy.sh`, `pixi.toml`. + `panel/tools/g6_flash.py`, `pixi.toml`. diff --git a/pixi.toml b/pixi.toml index 0a04616..b9d20d1 100644 --- a/pixi.toml +++ b/pixi.toml @@ -3,7 +3,7 @@ name = "modular-led-display" version = "0.1.0" description = "Modular LED Display Documentation" channels = ["conda-forge"] -platforms = ["linux-64", "osx-64", "osx-arm64"] +platforms = ["linux-64", "osx-64", "osx-arm64", "win-64"] [dependencies] python = ">=3.8" diff --git a/tools/panel-programming/HANDOFF.md b/tools/panel-programming/HANDOFF.md deleted file mode 100644 index 705f23e..0000000 --- a/tools/panel-programming/HANDOFF.md +++ /dev/null @@ -1,102 +0,0 @@ -# Panel-programming handoff — finishing & testing - -Continuation guide for finishing the streamlined panel-programming work -(PR [reiserlab/Modular-LED-Display#60](https://github.com/reiserlab/Modular-LED-Display/pull/60)) -in a local session. Full design: [`docs/development/g6_07-panel-programming.md`](../../docs/development/g6_07-panel-programming.md). - -## What exists now - -Staged in this directory (`tools/panel-programming/`) on branch -`claude/panels-programming-streamline-2h5f8c`: - -| File | Final home | Status | -|---|---|---| -| `g6_flash.py` | `panel/tools/g6_flash.py` (firmware repo) | compiles + `--help` OK; **not hardware-tested**; **Linux-only** (see caveat) | -| `release.yml` | `.github/workflows/release.yml` (firmware repo) | valid YAML; never run | -| `flasher/index.html`, `flasher/flasher.js` | `flasher/` (webDisplayTools repo) | `node --check` OK; **not hardware-tested**; fetches firmware from Release only | -| `g6_07-panel-programming.md` | stays in `docs/development/` | reference spec | - -Firmware repo: `reiserlab/LED-Display_G6_Firmware_Panel` (`Generation 6/Panel-Firmware`). -Web repo: `reiserlab/webDisplayTools` (`Generation 6/webDisplayTools`). - -## ⚠ macOS caveat - -`g6_flash.py` enumerates panels via Linux **sysfs** (`/sys/bus/usb/devices`), which -does **not** exist on macOS. -- ✅ WebUSB flasher → cross-platform (test in Chrome/Edge on the Mac). -- ✅ `picotool` itself → works on macOS (`brew install picotool`); manual single-panel - flashing works. -- ❌ `g6_flash.py` batch CLI → run it on the **Linux bench machine** (where `deploy*.sh` - already run), or ask Claude to refactor enumeration to use `picotool info` (cross-platform). - -## Steps - -### 1. Cut the first firmware release (unblocks both tools' default download path) -In `reiserlab/LED-Display_G6_Firmware_Panel`: -```sh -mkdir -p .github/workflows -cp /tools/panel-programming/release.yml .github/workflows/release.yml -git add .github/workflows/release.yml && git commit -m "Add firmware release workflow" -git push -git tag panel-fw-v0.0.1 && git push origin panel-fw-v0.0.1 -``` -Confirm the Action publishes `g6-panel-v0.2.1.uf2`, `g6-panel-v0.3.1.uf2`, and -`manifest.json` to a GitHub Release. If the build can't find Python for -`tools/gen_predef_patterns.py`, the `setup-python` step in the workflow covers it. - -### 2. Relocate the tools into their real repos -- `g6_flash.py` → firmware `panel/tools/g6_flash.py`. -- Add to firmware `pixi.toml`: - ```toml - [dependencies] - picotool = "*" # conda-forge - - [tasks] - flash21 = "python panel/tools/g6_flash.py --rev v0.2.1" - flash31 = "python panel/tools/g6_flash.py --rev v0.3.1" - ``` -- `flasher/` → webDisplayTools `flasher/`; enable GitHub Pages for that repo. -- Once relocated, delete `tools/panel-programming/` here (keep the `g6_07` spec doc). - -### 3. Test the WebUSB flasher (Mac, Chrome) — most user-facing -Needs step 1 done first (it fetches the UF2 from the latest Release). Then: open the -Pages URL → pick rev → hold the panel's **BOOTSEL** button while plugging in USB → -**Connect & flash** → confirm the green "Verified: G6 Panel vX.Y ✓" line. -- If you want to test *before* cutting a release, ask Claude to add a "load local .uf2" - file picker to `flasher.js` (small change). - -### 4. Test the CLI (Linux bench) against the two known panels -Panels: `319A5199EE357F77` (v0.2.1), `A5D4B82BA2B9FB51` (v0.3.1). -```sh -# needs picotool on PATH (pixi run, or conda-forge picotool) -python panel/tools/g6_flash.py --list # see connected panels -python panel/tools/g6_flash.py --rev v0.3.1 # re-flash running panel(s), latest fw -python panel/tools/g6_flash.py --rev v0.2.1 --uf2 panel/.pio/build/pico_v021/firmware.uf2 -``` -Validate: -1. **Re-flash a running panel** — cross-check against `pixi run deploy31a`. -2. **Blank/BOOTSEL panel (new capability)** — hold BOOTSEL while plugging in so it - enumerates as PICOBOOT, then `--rev` flashes it with no `pio` and no serial. -3. **Rev guard** — wrong `--rev` on a running board → loud abort (override `--force`). -4. **Post-flash verify** — tool confirms the panel re-enumerates with the expected - `G6 Panel vX.Y` product string. -5. Optional visual: flash a `*_bcmtest` env UF2 (30-s BCM self-test), then re-flash - production (bcmtest has no SPI ingest — do not leave it on a deployed panel). - -## Open items (also in g6_07 § Open Questions) -- Confirm the exact PCB silkscreen rev marking (from `Generation 6/Panels`) and add it - (plus a photo) to the web flasher's help panel. -- Decide: import `piersfinlayson/picoflash`'s PICOBOOT module vs. keep the thin - reimplementation in `flasher.js`; validate framing against real RP2350 hardware. -- Optional: a firmware serial "reboot to BOOTSEL" command so the web tool can auto-enter - BOOTSEL via a Web Serial pre-step (removes the manual hold-BOOTSEL step). - -## Paste-in prompt for the local session -> Continue the G6 panel-programming work from PR reiserlab/Modular-LED-Display#60. -> Read `tools/panel-programming/HANDOFF.md` and `docs/development/g6_07-panel-programming.md`. -> Do: (1) add `release.yml` to the firmware repo's `.github/workflows/` and cut a -> `panel-fw-v0.0.1` release; (2) relocate `g6_flash.py` and `flasher/` into the firmware -> and webDisplayTools repos with the `pixi.toml` additions; (3) help me test the WebUSB -> flasher in Chrome and the CLI against panels `319A5199EE357F77`/`A5D4B82BA2B9FB51`. -> I'm on macOS — `g6_flash.py` currently needs Linux (sysfs); offer to make it -> cross-platform if useful. diff --git a/tools/panel-programming/README.md b/tools/panel-programming/README.md deleted file mode 100644 index 8db3637..0000000 --- a/tools/panel-programming/README.md +++ /dev/null @@ -1,69 +0,0 @@ -# Panel programming tools (staging) - -Streamlined flashing for G6 LED-display panels (RP2350 / RP2354): a `g6-flash` -command-line tool and a zero-install WebUSB browser flasher, fed by CI-published -prebuilt UF2 artifacts. Programs **new (blank)** panels and **re-flashes** old ones, -single or in bench batches. - -Full design + rationale: [`docs/development/g6_07-panel-programming.md`](../../docs/development/g6_07-panel-programming.md). - -## ⚠ This is a staging directory - -These files belong in **two separate submodule repos**, not the parent -`Modular-LED-Display`. They are staged here because this session could only push to -the parent repo. A maintainer with access should relocate them as below, then this -directory can be deleted (leaving only the `g6_07` spec doc as the reference). - -| Staged file | Final home | -|---|---| -| `g6_flash.py` | `panel/tools/g6_flash.py` in [`reiserlab/LED-Display_G6_Firmware_Panel`](https://github.com/reiserlab/LED-Display_G6_Firmware_Panel) | -| `release.yml` | `.github/workflows/release.yml` in the same firmware repo | -| `flasher/index.html`, `flasher/flasher.js` | `flasher/` in [`reiserlab/webDisplayTools`](https://github.com/reiserlab/webDisplayTools) (served via GitHub Pages) | - -### Firmware repo `pixi.toml` additions - -Add the `picotool` dependency and convenience tasks (replacing the hardcoded-serial -demo tasks `deploy21a` / `deploy31a`): - -```toml -[dependencies] -picotool = "*" # conda-forge - -[tasks] -flash21 = "python panel/tools/g6_flash.py --rev v0.2.1" -flash31 = "python panel/tools/g6_flash.py --rev v0.3.1" -``` - -The legacy `deploy.sh` / `deploy_all.sh` stay for the firmware-developer `pio` flow. - -## Quick reference - -**CLI** (Linux; needs `picotool` on PATH): - -```sh -python g6_flash.py --rev v0.3.1 # flash all connected v0.3.1 panels (latest fw) -python g6_flash.py --rev v0.2.1 --port 3-1.4 # one board on a specific USB port -python g6_flash.py --rev v0.3.1 --uf2 # flash a local UF2 build -python g6_flash.py --list # list connected panels -python g6_flash.py --help # all options (incl. --no-exec, --jobs, --force) -``` - -Handles both new and old panels: it uses `picotool` to reboot a running panel into -BOOTSEL automatically, and flashes blank/BOOTSEL boards directly. `--rev` is mandatory -(the two hardware revs need different binaries and can't be told apart on a blank -board); every flash is verified afterwards via the panel's USB product string. - -Batch ceiling is ~10–20 panels per **externally-powered** USB hub — the limit is -post-flash LED inrush + re-enumeration, not flashing. Use `--no-exec` for larger trays -and power-cycle in small groups. - -**Web flasher**: open `flasher/index.html` over https (GitHub Pages) in **Chrome/Edge**, -pick the rev, put the panel in BOOTSEL (hold BOOTSEL while plugging in USB), click flash. - -## Status / TODO - -Reference implementation; **not yet validated against hardware**, and the default -download path needs the first `panel-fw-v*` firmware release to exist. See the -"Open Questions / TBDs" in [`g6_07-panel-programming.md`](../../docs/development/g6_07-panel-programming.md) -(PCB silkscreen marking, picoflash integration, bench validation against the two known -panels). diff --git a/tools/panel-programming/flasher/flasher.js b/tools/panel-programming/flasher/flasher.js deleted file mode 100644 index 346ab40..0000000 --- a/tools/panel-programming/flasher/flasher.js +++ /dev/null @@ -1,275 +0,0 @@ -// G6 Panel WebUSB flasher — drives the RP2350 PICOBOOT interface to write flash. -// -// FINAL HOME: flasher/ in reiserlab/webDisplayTools (deploy via GitHub Pages — WebUSB -// needs a secure context, which Pages https provides). Chromium/Edge only. -// -// Why WebUSB (not Web Serial): the existing webDisplayTools console uses Web *Serial* -// to talk to the arena controller, but flashing a panel's bootloader needs the RP2350 -// PICOBOOT vendor interface, which is Web *USB*. Browsers also cannot use the UF2 -// mass-storage drag-drop path, so we parse the UF2 client-side and stream its blocks -// over PICOBOOT (EXCLUSIVE_ACCESS -> EXIT_XIP -> FLASH_ERASE -> WRITE -> REBOOT2). -// -// The PICOBOOT framing here follows the RP2350 datasheet / picoboot interface. It is -// modeled on piersfinlayson/picoflash (MIT) — validate against real hardware and that -// reference before production use. - -const RP_VID = 0x2e8a; -const FW_REPO = "reiserlab/LED-Display_G6_Firmware_Panel"; -const FLASH_XIP_BASE = 0x10000000; -const SECTOR = 4096; // RP2350 flash erase granularity -const WRITE_CHUNK = 256; // PICOBOOT WRITE granularity (UF2 payloads are 256B) - -// rev -> expected USB product string prefix (set in panel/platformio.ini). -const REV_PRODUCT = { "v0.2.1": "G6 Panel v0.2", "v0.3.1": "G6 Panel v0.3" }; - -// --- PICOBOOT command framing --------------------------------------------------- -const PICOBOOT_MAGIC = 0x431fd10b; -const CMD = { - EXCLUSIVE_ACCESS: 0x01, - FLASH_ERASE: 0x03, - WRITE: 0x05, - EXIT_XIP: 0x06, - REBOOT2: 0x0a, // RP2350 reboot -}; -let token = 1; - -const $ = (id) => document.getElementById(id); -const logEl = $("log"); -function log(msg, cls) { - const line = document.createElement("div"); - if (cls) line.className = cls; - line.textContent = msg; - logEl.appendChild(line); - logEl.scrollTop = logEl.scrollHeight; -} -function setStatus(msg, cls) { const s = $("status"); s.textContent = msg; s.className = cls || ""; } - -// --- UF2 parsing ---------------------------------------------------------------- -// Returns sorted [{addr, data:Uint8Array(256)}], one per 512-byte UF2 block. -function parseUF2(buf) { - const dv = new DataView(buf); - const blocks = []; - for (let off = 0; off + 512 <= buf.byteLength; off += 512) { - if (dv.getUint32(off, true) !== 0x0a324655) continue; // magicStart0 "UF2\n" - if (dv.getUint32(off + 4, true) !== 0x9e5d5157) continue; // magicStart1 - if (dv.getUint32(off + 508, true) !== 0x0ab16f30) continue; // magicEnd - const flags = dv.getUint32(off + 8, true); - if (flags & 0x00000001) continue; // "not main flash" block - const addr = dv.getUint32(off + 12, true); - const size = dv.getUint32(off + 16, true); - const data = new Uint8Array(buf, off + 32, Math.min(size, 256)); - blocks.push({ addr, data: data.slice() }); - } - blocks.sort((a, b) => a.addr - b.addr); - if (!blocks.length) throw new Error("no flashable UF2 blocks found"); - return blocks; -} - -// --- PICOBOOT device ------------------------------------------------------------ -class Picoboot { - constructor(device) { - this.dev = device; - this.epOut = null; - this.epIn = null; - this.iface = null; - } - - async open() { - await this.dev.open(); - if (this.dev.configuration === null) await this.dev.selectConfiguration(1); - // The PICOBOOT interface is the vendor-specific one (class 0xFF) with two bulk EPs. - for (const iface of this.dev.configuration.interfaces) { - const alt = iface.alternate; - if (alt.interfaceClass !== 0xff) continue; - const out = alt.endpoints.find((e) => e.direction === "out" && e.type === "bulk"); - const inp = alt.endpoints.find((e) => e.direction === "in" && e.type === "bulk"); - if (out && inp) { - this.iface = iface.interfaceNumber; - this.epOut = out.endpointNumber; - this.epIn = inp.endpointNumber; - break; - } - } - if (this.iface === null) throw new Error("no PICOBOOT interface — is the panel in BOOTSEL mode?"); - await this.dev.claimInterface(this.iface); - } - - async close() { - try { await this.dev.releaseInterface(this.iface); } catch {} - try { await this.dev.close(); } catch {} - } - - // Build a 32-byte PICOBOOT command packet. - _packet(cmdId, transferLen, args) { - const b = new ArrayBuffer(32); - const dv = new DataView(b); - dv.setUint32(0, PICOBOOT_MAGIC, true); - dv.setUint32(4, token++, true); - dv.setUint8(8, cmdId); - dv.setUint8(9, args ? args.byteLength : 0); - dv.setUint16(10, 0, true); - dv.setUint32(12, transferLen, true); - if (args) new Uint8Array(b, 16).set(new Uint8Array(args)); - return b; - } - - // Command with no data phase: send packet, then read a ZLP ack on IN. - async _cmd(cmdId, args) { - await this.dev.transferOut(this.epOut, this._packet(cmdId, 0, args)); - await this.dev.transferIn(this.epIn, 1); // status / ZLP ack - } - - // Command with an OUT data phase: send packet, send data, read ZLP ack on IN. - async _cmdWrite(cmdId, args, data) { - await this.dev.transferOut(this.epOut, this._packet(cmdId, data.byteLength, args)); - await this.dev.transferOut(this.epOut, data); - await this.dev.transferIn(this.epIn, 1); - } - - _args(...u32) { - const b = new ArrayBuffer(16); - const dv = new DataView(b); - u32.forEach((v, i) => dv.setUint32(i * 4, v >>> 0, true)); - return b; - } - - exclusiveAccess() { return this._cmd(CMD.EXCLUSIVE_ACCESS, this._args(1)); } // 1 = EXCLUSIVE - exitXip() { return this._cmd(CMD.EXIT_XIP); } - flashErase(addr, size) { return this._cmd(CMD.FLASH_ERASE, this._args(addr, size)); } - write(addr, data) { return this._cmdWrite(CMD.WRITE, this._args(addr, data.byteLength), data); } - reboot() { return this._cmd(CMD.REBOOT2, this._args(0, 100, 0, 0)); } // normal reboot, 100ms delay -} - -async function flashBlocks(pb, blocks, onProgress) { - await pb.exclusiveAccess(); - await pb.exitXip(); - - // Erase the sector-aligned span covering every write. - const minAddr = blocks[0].addr & ~(SECTOR - 1); - const lastEnd = blocks[blocks.length - 1].addr + blocks[blocks.length - 1].data.byteLength; - const maxAddr = (lastEnd + SECTOR - 1) & ~(SECTOR - 1); - log(`Erasing 0x${minAddr.toString(16)}…0x${maxAddr.toString(16)} (${(maxAddr - minAddr) / 1024} KiB)`); - for (let a = minAddr; a < maxAddr; a += SECTOR) await pb.flashErase(a, SECTOR); - - // Write each 256-byte block. - for (let i = 0; i < blocks.length; i++) { - const { addr, data } = blocks[i]; - let chunk = data; - if (chunk.byteLength < WRITE_CHUNK) { - const padded = new Uint8Array(WRITE_CHUNK).fill(0xff); - padded.set(chunk); - chunk = padded; - } - await pb.write(addr, chunk); - onProgress((i + 1) / blocks.length); - } - log("Rebooting panel into firmware…"); - try { await pb.reboot(); } catch { /* device drops as it reboots — expected */ } -} - -// --- Firmware resolution (latest GitHub Release) -------------------------------- -let firmware = { version: null, byRev: {} }; // rev -> uf2 download url - -async function resolveFirmware() { - const rel = await (await fetch(`https://api.github.com/repos/${FW_REPO}/releases/latest`)).json(); - const assets = Object.fromEntries((rel.assets || []).map((a) => [a.name, a.browser_download_url])); - const manifest = await (await fetch(assets["manifest.json"])).json(); - firmware.version = manifest.version || rel.tag_name; - for (const a of manifest.artifacts || []) { - if (assets[a.file]) firmware.byRev[a.rev] = assets[a.file]; - } - $("fw-version").textContent = firmware.version; - log(`Firmware release: ${firmware.version} (revs: ${Object.keys(firmware.byRev).join(", ")})`); -} - -// --- Verify after flash --------------------------------------------------------- -// After REBOOT2 the panel re-enumerates as the firmware USB-serial device. We can't -// silently re-grab it (WebUSB needs a user gesture per device), so confirm via the -// product string of any already-granted device, else ask the operator to confirm. -async function verifyRev(rev) { - const want = REV_PRODUCT[rev]; - const granted = await navigator.usb.getDevices(); - const match = granted.find((d) => d.vendorId === RP_VID && (d.productName || "").startsWith(want)); - if (match) { - setStatus(`Verified: ${match.productName} ✓`, "status-ok"); - log(`Verified panel reports "${match.productName}".`, "status-ok"); - } else { - setStatus(`Flashed ${rev}. Confirm the panel boots its normal pattern.`, "status-ok"); - log("Flash complete. (Auto-verify needs a re-grant; visually confirm the panel runs.)"); - } -} - -// --- UI wiring ------------------------------------------------------------------ -let chosenRev = null; - -function setupRevButtons() { - for (const btn of document.querySelectorAll("#rev-group button")) { - btn.addEventListener("click", () => { - chosenRev = btn.dataset.rev; - for (const b of document.querySelectorAll("#rev-group button")) - b.setAttribute("aria-pressed", String(b === btn)); - $("flash-btn").disabled = !(chosenRev && firmware.byRev[chosenRev]); - if (chosenRev && !firmware.byRev[chosenRev]) - setStatus(`Latest firmware has no ${chosenRev} build.`, "status-err"); - else setStatus(""); - }); - } -} - -async function onFlashClick() { - if (!chosenRev) return; - const url = firmware.byRev[chosenRev]; - if (!url) { setStatus(`No firmware for ${chosenRev}.`, "status-err"); return; } - - let device, pb; - try { - $("flash-btn").disabled = true; - setStatus("Requesting panel…"); - device = await navigator.usb.requestDevice({ filters: [{ vendorId: RP_VID }] }); - - if (device.productId !== 0x000f) { - setStatus("That panel is not in BOOTSEL mode.", "status-err"); - log(`Picked device "${device.productName || "?"}" (pid 0x${device.productId.toString(16)}). ` + - "Unplug it, hold BOOTSEL while plugging back in, then retry.", "status-err"); - $("flash-btn").disabled = false; - return; - } - - log(`Downloading firmware ${chosenRev}…`); - const uf2 = await (await fetch(url)).arrayBuffer(); - const blocks = parseUF2(uf2); - log(`UF2: ${blocks.length} blocks (${(blocks.length * 256 / 1024).toFixed(0)} KiB).`); - - pb = new Picoboot(device); - await pb.open(); - - const prog = $("progress"); - prog.hidden = false; prog.value = 0; - setStatus(`Flashing ${chosenRev}…`); - await flashBlocks(pb, blocks, (f) => { prog.value = Math.round(f * 100); }); - - await verifyRev(chosenRev); - } catch (err) { - setStatus(`Failed: ${err.message}`, "status-err"); - log(`ERROR: ${err.message}`, "status-err"); - $("flash-btn").disabled = false; - } finally { - if (pb) await pb.close(); - } -} - -function main() { - if (!("usb" in navigator)) { - $("unsupported").style.display = "block"; - $("app").style.display = "none"; - return; - } - setupRevButtons(); - $("flash-btn").addEventListener("click", onFlashClick); - resolveFirmware().catch((e) => { - $("fw-version").textContent = "unavailable"; - log(`Could not resolve latest firmware: ${e.message}`, "status-err"); - }); -} - -main(); diff --git a/tools/panel-programming/flasher/index.html b/tools/panel-programming/flasher/index.html deleted file mode 100644 index b4b53f9..0000000 --- a/tools/panel-programming/flasher/index.html +++ /dev/null @@ -1,83 +0,0 @@ - - - - - -G6 Panel Flasher - - - -
-

G6 Panel Flasher

-

Program a Generation-6 LED display panel over USB — no install, right in your browser.

- -
- This browser can’t talk to USB devices. The flasher needs - WebUSB, which is only in Chrome or Edge (desktop). Please reopen this page there. -
- -
-
-

1Pick the panel revision

-
- - -
-

Flashing the wrong revision produces a non-working panel - (it must then be re-flashed with the correct one). If unsure, check the board.

-
- How do I tell which revision I have? -

Read the revision printed on the PCB silkscreen / label (v0.2.1 - vs v0.3.1). A panel already running firmware also reports its - revision — this tool verifies it for you after flashing. -

-
-
- -
-

2Connect & flash

-

Put the panel in BOOTSEL mode (hold the BOOTSEL button while - plugging in the USB cable), then:

- -

Firmware: resolving latest…

- -

-
- -
-

Activity log

-
-
-
-
- - - diff --git a/tools/panel-programming/g6_flash.py b/tools/panel-programming/g6_flash.py deleted file mode 100644 index acfdbf6..0000000 --- a/tools/panel-programming/g6_flash.py +++ /dev/null @@ -1,408 +0,0 @@ -#!/usr/bin/env python3 -"""g6-flash — streamlined flashing for G6 LED-display panels (RP2350 / RP2354). - -One tool to program new (blank) panels and re-flash old ones, a single panel or a -bench full of them, without a PlatformIO build environment. Built on `picotool`. - -Why this exists ---------------- -The legacy bench scripts (`deploy.sh` / `deploy_all.sh`) drive `pio ... -t upload` -and can only see panels that are *already running* firmware (USB-serial mode), so -they cannot touch a brand-new/blank board and `deploy_all.sh` is sequential. -`picotool` closes both gaps: it can reboot a running panel into BOOTSEL itself -(`reboot -f -u`) and flash a board that is already in BOOTSEL — so the same code -path handles new and old panels — and we flash many in parallel. - -What it does NOT do -------------------- -Panels are stateless: every panel of a given hardware revision gets the IDENTICAL -binary (no per-panel ID/address is burned in — addressing is the arena -controller's job at runtime). So there is no provisioning step. The ONE thing the -operator must get right is the hardware revision, because the two revs need -different binaries and the rev CANNOT be detected over USB on a blank board. -`--rev` is therefore mandatory, and every flash is verified afterwards by reading -back the panel's USB product string. - -Platform: Linux (enumerates via sysfs, like the existing by-id scripts). Requires -`picotool` on PATH (e.g. `pixi run`, conda-forge `picotool`, or a system install). - -Usage ------ - # Re-flash every connected v0.3.1 panel from the latest published firmware: - g6_flash.py --rev v0.3.1 - - # Flash one specific board (physical USB port) with a locally built UF2: - g6_flash.py --rev v0.2.1 --uf2 panel/.pio/build/pico_v021/firmware.uf2 --port 3-1.4 - - # See what would happen without touching anything: - g6_flash.py --rev v0.3.1 --dry-run - -See `tools/panel-programming/README.md` and `docs/development/g6_07-panel-programming.md`. -""" - -from __future__ import annotations - -import argparse -import concurrent.futures -import hashlib -import json -import os -import shutil -import subprocess -import sys -import time -import urllib.request -from dataclasses import dataclass, field -from pathlib import Path - -# --- Constants tied to the firmware (keep in sync with panel/platformio.ini) ---- - -RP_VID = "2e8a" # Raspberry Pi USB vendor id (board_build...usb_vid = 0x2E8A) -PID_APP = "0009" # running panel firmware: USB-serial device -PID_BOOTSEL = "000f" # RP2350 bootrom: USB mass-storage / PICOBOOT - -# rev -> (PlatformIO env, USB product string prefix). Product strings are -# "G6 Panel v0.2" / "G6 Panel v0.3" (major.minor only) — match by prefix, the -# same way deploy.sh maps env -> product. -REVS = { - "v0.2.1": {"env": "pico_v021", "usb_product": "G6 Panel v0.2"}, - "v0.3.1": {"env": "pico_v031", "usb_product": "G6 Panel v0.3"}, -} - -# Default firmware source (GitHub Releases of the panel firmware repo). -FW_REPO = "reiserlab/LED-Display_G6_Firmware_Panel" -CACHE_DIR = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "g6-flash" - -REENUMERATE_TIMEOUT_S = 25.0 -REENUMERATE_POLL_S = 0.25 - - -# --- Device enumeration (Linux sysfs) ------------------------------------------- - - -@dataclass -class Panel: - """A connected RP2350 board, keyed by its stable physical USB port path. - - The port path (sysfs dir name, e.g. "3-1.4") survives the BOOTSEL<->app - re-enumeration, so we use it as the device identity. bus/address (USB - busnum/devnum) are what picotool targets and change on re-enumeration. - """ - - port: str # sysfs port path, e.g. "3-1.4" — stable across re-enumeration - bus: int # USB bus number (picotool --bus) - address: int # USB device address (picotool --address) - pid: str # "0009" (app) or "000f" (bootsel) - serial: str | None = None - product: str | None = None - - @property - def in_bootsel(self) -> bool: - return self.pid == PID_BOOTSEL - - @property - def label(self) -> str: - what = self.product or ("BOOTSEL" if self.in_bootsel else "?") - return f"port {self.port} (bus {self.bus} addr {self.address}, {what})" - - -def _read(path: Path) -> str | None: - try: - return path.read_text().strip() - except OSError: - return None - - -def enumerate_panels() -> list[Panel]: - """Find all connected RP2350 boards (running firmware OR in BOOTSEL).""" - root = Path("/sys/bus/usb/devices") - if not root.is_dir(): - sys.exit("g6-flash: /sys/bus/usb/devices not found — this tool is Linux-only.") - - panels: list[Panel] = [] - for dev in sorted(root.iterdir()): - if _read(dev / "idVendor") != RP_VID: - continue - pid = _read(dev / "idProduct") - if pid not in (PID_APP, PID_BOOTSEL): - continue - busnum = _read(dev / "busnum") - devnum = _read(dev / "devnum") - if not (busnum and devnum): - continue - panels.append( - Panel( - port=dev.name, - bus=int(busnum), - address=int(devnum), - pid=pid, - serial=_read(dev / "serial"), - product=_read(dev / "product"), - ) - ) - return panels - - -def wait_for_port(port: str, want_pid: str | None, timeout: float) -> Panel | None: - """Poll sysfs until the panel on `port` reappears (optionally in want_pid).""" - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - for p in enumerate_panels(): - if p.port == port and (want_pid is None or p.pid == want_pid): - return p - time.sleep(REENUMERATE_POLL_S) - return None - - -# --- Firmware artifact resolution ----------------------------------------------- - - -def _http_json(url: str) -> dict: - req = urllib.request.Request(url, headers={"Accept": "application/vnd.github+json"}) - token = os.environ.get("GITHUB_TOKEN") - if token: - req.add_header("Authorization", f"Bearer {token}") - with urllib.request.urlopen(req, timeout=30) as r: - return json.load(r) - - -def _download(url: str, dest: Path) -> None: - dest.parent.mkdir(parents=True, exist_ok=True) - req = urllib.request.Request(url, headers={"Accept": "application/octet-stream"}) - with urllib.request.urlopen(req, timeout=120) as r, open(dest, "wb") as f: - shutil.copyfileobj(r, f) - - -def _sha256(path: Path) -> str: - h = hashlib.sha256() - with open(path, "rb") as f: - for chunk in iter(lambda: f.read(1 << 16), b""): - h.update(chunk) - return h.hexdigest() - - -def resolve_uf2(rev: str, fw_version: str | None, local_uf2: str | None) -> Path: - """Return a path to the UF2 to flash for `rev` — local file or cached release. - - Release layout (produced by release.yml): each release carries per-rev UF2s - named g6-panel-.uf2 plus a manifest.json describing rev/env/sha256/ - usb_product. We pick the artifact for `rev`, download into the per-version - cache, and verify its sha256 against the manifest. - """ - if local_uf2: - p = Path(local_uf2) - if not p.is_file(): - sys.exit(f"g6-flash: --uf2 not found: {p}") - return p - - base = f"https://api.github.com/repos/{FW_REPO}/releases" - rel = _http_json(f"{base}/tags/{fw_version}" if fw_version else f"{base}/latest") - tag = rel["tag_name"] - assets = {a["name"]: a["browser_download_url"] for a in rel.get("assets", [])} - - if "manifest.json" not in assets: - sys.exit(f"g6-flash: release {tag} has no manifest.json asset.") - cache = CACHE_DIR / tag - manifest_path = cache / "manifest.json" - if not manifest_path.is_file(): - _download(assets["manifest.json"], manifest_path) - manifest = json.loads(manifest_path.read_text()) - - entry = next((a for a in manifest.get("artifacts", []) if a.get("rev") == rev), None) - if not entry: - sys.exit(f"g6-flash: release {tag} manifest has no artifact for rev {rev}.") - - fname = entry["file"] - if fname not in assets: - sys.exit(f"g6-flash: release {tag} is missing UF2 asset '{fname}'.") - uf2 = cache / fname - if not uf2.is_file(): - print(f"g6-flash: downloading {fname} from firmware release {tag} …") - _download(assets[fname], uf2) - - expect = entry.get("sha256") - if expect: - got = _sha256(uf2) - if got != expect: - uf2.unlink(missing_ok=True) - sys.exit(f"g6-flash: sha256 mismatch for {fname}\n expected {expect}\n got {got}") - print(f"g6-flash: firmware {tag} rev {rev} -> {uf2}") - return uf2 - - -# --- picotool wrappers ---------------------------------------------------------- - - -def _picotool(*args: str) -> subprocess.CompletedProcess: - return subprocess.run( - ["picotool", *args], capture_output=True, text=True, check=False - ) - - -def reboot_to_bootsel(panel: Panel) -> None: - """Force a running panel into BOOTSEL (no button press).""" - cp = _picotool("reboot", "-f", "-u", "--bus", str(panel.bus), "--address", str(panel.address)) - # picotool returns nonzero if the device already vanished into BOOTSEL — tolerate it. - if cp.returncode != 0 and "not in BOOTSEL" not in (cp.stderr + cp.stdout): - # Best-effort: re-enumeration check below is the real gate. - pass - - -def load_uf2(panel: Panel, uf2: Path, execute: bool) -> subprocess.CompletedProcess: - args = ["load", "--bus", str(panel.bus), "--address", str(panel.address)] - if execute: - args.append("-x") - args.append(str(uf2)) - return _picotool(*args) - - -# --- Per-panel flash flow ------------------------------------------------------- - - -@dataclass -class Result: - panel: Panel - ok: bool - detail: str - verified_product: str | None = None - - -def flash_one(panel: Panel, rev: str, uf2: Path, execute: bool, dry_run: bool) -> Result: - want_product = REVS[rev]["usb_product"] - - if dry_run: - action = "reboot->BOOTSEL then load" if not panel.in_bootsel else "load" - return Result(panel, True, f"DRY-RUN: would {action} {uf2.name}") - - # 1) Get the board into BOOTSEL. - target = panel - if not panel.in_bootsel: - reboot_to_bootsel(panel) - target = wait_for_port(panel.port, PID_BOOTSEL, REENUMERATE_TIMEOUT_S) - if target is None: - return Result(panel, False, "timed out waiting for BOOTSEL after reboot") - - # 2) Flash. - cp = load_uf2(target, uf2, execute) - if cp.returncode != 0: - return Result(panel, False, f"picotool load failed: {(cp.stderr or cp.stdout).strip()[:200]}") - - if not execute: - return Result(panel, True, "flashed (not executed; power-cycle to run + verify)") - - # 3) Verify: panel re-enumerates as app-mode with the expected product string. - booted = wait_for_port(panel.port, PID_APP, REENUMERATE_TIMEOUT_S) - if booted is None: - return Result(panel, False, "flashed but panel did not re-enumerate as firmware") - if not (booted.product or "").startswith(want_product): - return Result( - panel, False, - f"WRONG REV? flashed {rev} but panel reports '{booted.product}' (expected '{want_product}*')", - booted.product, - ) - return Result(panel, True, "flashed + verified", booted.product) - - -# --- CLI ------------------------------------------------------------------------ - - -def select_targets(panels: list[Panel], rev: str, port: str | None, force: bool) -> list[Panel]: - if port: - chosen = [p for p in panels if p.port == port] - if not chosen: - sys.exit(f"g6-flash: no RP2350 board on port {port}. Connected: " - + (", ".join(p.port for p in panels) or "none")) - return chosen - - if not panels: - sys.exit("g6-flash: no RP2350 panels found (none in firmware or BOOTSEL mode).") - - # Loud warning if a *running* board reports a different rev than --rev. We - # cannot block (a blank/BOOTSEL board reports nothing, and a genuine cross-rev - # re-flash is legitimate), but a silent wrong-rev batch is the headline footgun. - want_product = REVS[rev]["usb_product"] - mismatched = [p for p in panels if p.product and not p.product.startswith(want_product)] - if mismatched and not force: - print(f"\n ⚠ {len(mismatched)} connected panel(s) currently report a DIFFERENT rev than --rev {rev}:") - for p in mismatched: - print(f" {p.label}") - print(f" Expected product prefix '{want_product}'. If these really are {rev}") - print(f" hardware (e.g. blank/mis-flashed), re-run with --force. Otherwise fix --rev.\n") - sys.exit("g6-flash: aborting on rev mismatch (use --force to override).") - return panels - - -def main(argv: list[str] | None = None) -> int: - ap = argparse.ArgumentParser( - prog="g6-flash", - description="Flash G6 LED-display panels (RP2350) — new or existing, one or many.", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog="Realistic batch ceiling: ~10–20 panels per EXTERNALLY-POWERED hub. The\n" - "limit is post-flash LED-matrix inrush + USB re-enumeration, not flashing.\n" - "For larger trays use --no-exec, then power-cycle in small groups.", - ) - ap.add_argument("--rev", required=True, choices=sorted(REVS), - help="panel hardware revision (MANDATORY — selects the binary; " - "cannot be auto-detected on a blank board)") - ap.add_argument("--uf2", metavar="PATH", - help="flash a local UF2 instead of a published release (for firmware devs)") - ap.add_argument("--fw-version", metavar="TAG", - help="firmware release tag to flash (default: latest)") - ap.add_argument("--port", metavar="PORT", - help="flash only the board on this sysfs USB port (e.g. 3-1.4); " - "default: all connected panels") - ap.add_argument("--jobs", type=int, default=4, metavar="N", - help="max panels flashed in parallel (default: 4)") - ap.add_argument("--no-exec", action="store_true", - help="load firmware but do NOT execute it (reduces simultaneous " - "power-on inrush; power-cycle later to run + verify)") - ap.add_argument("--force", action="store_true", - help="suppress the running-panel rev-mismatch guard") - ap.add_argument("--dry-run", action="store_true", - help="show what would be flashed without touching any board") - ap.add_argument("--list", action="store_true", - help="list connected panels and exit") - args = ap.parse_args(argv) - - if not args.dry_run and not shutil.which("picotool"): - sys.exit("g6-flash: 'picotool' not found on PATH. Install it (conda-forge " - "'picotool', or `pixi run`) and retry.") - - panels = enumerate_panels() - - if args.list: - if not panels: - print("No RP2350 panels connected.") - for p in panels: - print(f" {p.label} serial={p.serial}") - return 0 - - targets = select_targets(panels, args.rev, args.port, args.force) - uf2 = resolve_uf2(args.rev, args.fw_version, args.uf2) - execute = not args.no_exec - - print(f"\ng6-flash: flashing {len(targets)} panel(s) as {args.rev} " - f"({'parallel x%d' % args.jobs if len(targets) > 1 else 'single'})\n") - - results: list[Result] = [] - with concurrent.futures.ThreadPoolExecutor(max_workers=max(1, args.jobs)) as pool: - futs = {pool.submit(flash_one, p, args.rev, uf2, execute, args.dry_run): p for p in targets} - for fut in concurrent.futures.as_completed(futs): - r = fut.result() - mark = "✓" if r.ok else "✗" - print(f" {mark} {r.panel.label}: {r.detail}") - results.append(r) - - ok = sum(1 for r in results if r.ok) - failed = len(results) - ok - print(f"\ng6-flash: {ok}/{len(results)} panel(s) OK" - + (f", {failed} FAILED" if failed else "")) - if failed: - for r in results: - if not r.ok: - print(f" FAILED {r.panel.label}: {r.detail}") - return 1 if failed else 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/panel-programming/release.yml b/tools/panel-programming/release.yml deleted file mode 100644 index 945c313..0000000 --- a/tools/panel-programming/release.yml +++ /dev/null @@ -1,95 +0,0 @@ -# Panel-firmware release: build per-rev UF2s and publish them (plus a manifest) -# as GitHub Release assets, so g6-flash and the WebUSB flasher never need a -# PlatformIO toolchain. -# -# FINAL HOME: .github/workflows/release.yml in reiserlab/LED-Display_G6_Firmware_Panel -# (paths below are relative to that repo root, where `panel/platformio.ini` lives). -# -# Tag a release to trigger: git tag panel-fw-v1.0.0 && git push origin panel-fw-v1.0.0 -name: release - -on: - push: - tags: ["panel-fw-v*"] - workflow_dispatch: {} - -permissions: - contents: write # create the GitHub Release + upload assets - -jobs: - build: - runs-on: ubuntu-latest - strategy: - matrix: - include: - # Keep env names in sync with panel/platformio.ini. Production envs - # ONLY — _bcmtest / _spidiag are bench-only and must not be released. - - env: pico_v021 - rev: v0.2.1 - usb_product: "G6 Panel v0.2" - - env: pico_v031 - rev: v0.3.1 - usb_product: "G6 Panel v0.3" - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - # PlatformIO drives the build; the panel build also runs - # tools/gen_predef_patterns.py (extra_scripts) and enforces the 2 MiB cap, - # so an oversize or broken image fails here rather than at flash time. - - name: Install PlatformIO - run: pip install "platformio>=6.1.19,<7" - - - name: Build ${{ matrix.env }} - run: pio run -d panel -e ${{ matrix.env }} - - - name: Stage UF2 as g6-panel-${{ matrix.rev }}.uf2 - run: | - mkdir -p dist - cp "panel/.pio/build/${{ matrix.env }}/firmware.uf2" \ - "dist/g6-panel-${{ matrix.rev }}.uf2" - # Per-leg metadata; the release job stitches these into manifest.json. - sha=$(sha256sum "dist/g6-panel-${{ matrix.rev }}.uf2" | cut -d' ' -f1) - cat > "dist/artifact-${{ matrix.rev }}.json" < Date: Fri, 3 Jul 2026 16:33:07 -0400 Subject: [PATCH 4/4] fix small inconsistency --- docs/development/g6_07-panel-programming.md | 79 ++++++++++++++------- 1 file changed, 52 insertions(+), 27 deletions(-) diff --git a/docs/development/g6_07-panel-programming.md b/docs/development/g6_07-panel-programming.md index fb27c99..f8573aa 100644 --- a/docs/development/g6_07-panel-programming.md +++ b/docs/development/g6_07-panel-programming.md @@ -34,17 +34,20 @@ USB identities (from `panel/platformio.ini`): VID `0x2E8A`; running firmware PID `0x0009` (USB-serial, product `G6 Panel v0.2` / `G6 Panel v0.3`); BOOTSEL PID `0x000f` (mass-storage + PICOBOOT). -### Why not the retired `deploy*.sh` +### Why not the retired `deploy*.sh` / `monitor.sh` `panel/tools/deploy.sh` / `deploy_all.sh` used to drive `pio … -t upload` and matched -panels only in **USB-serial mode** — so they **could not flash a blank/BOOTSEL board** -(the common case for a freshly assembled panel), required a full PlatformIO build env, -and `deploy_all.sh` flashed sequentially. The tools below use **`picotool`**, which can -reboot a running panel into BOOTSEL itself (`reboot -f -u`) *and* flash a board already -in BOOTSEL — one code path for both new and old panels — and consume prebuilt UF2s so no -build env is needed. Both scripts have since been retired: their functionality (build a -local env, flash one panel by USB serial or all panels of a rev) now lives in `g6-flash` -itself, driven by the `deploy*`/`deploy*a`/`deploy*a-diag` pixi tasks (`pixi.toml`). +panels only in **USB-serial mode** on Linux — so they **could not flash a blank/BOOTSEL +board** (the common case for a freshly assembled panel), required a full PlatformIO +build env, and `deploy_all.sh` flashed sequentially. On Linux, `g6-flash` uses +**`picotool`** instead, which can reboot a running panel into BOOTSEL itself +(`reboot -f -u`) *and* flash a board already in BOOTSEL — one code path for both new and +old panels — and consumes prebuilt UF2s so no build env is needed. `deploy.sh` had grown +a macOS branch too (1200-baud BOOTSEL touch + UF2 copy to `/Volumes/RP2350`, since +picotool's libusb backend can't reliably claim a CDC interface macOS's own kernel driver +already owns); `g6-flash` ports that mechanism verbatim as its macOS path (see §B). Both +`deploy*.sh` and `monitor.sh` (superseded by `panel/tools/monitor.py`, cross-platform via +`pyserial`) have since been retired. --- @@ -95,24 +98,39 @@ CI builds and publishes per-rev UF2s so neither tool needs PlatformIO. ## B. `g6-flash` CLI (bench) -Location: `panel/tools/g6_flash.py` (firmware repo). Python 3, stdlib-only; needs `picotool`, which is -**not** a conda-forge package so it isn't a pinned `pixi.toml` dependency — the tool -prefers the copy PlatformIO already vendors under `~/.platformio/packages/tool-picotool*/` -(present for anyone who's run `pixi run release`/`diag`, which build via `pio` -under the hood), falling back to PATH. Linux (enumerates via sysfs, like the by-id scripts). - -Per panel: if it is running firmware, `picotool reboot -f -u --bus B --address A` → -wait for BOOTSEL; then `picotool load [-x] --bus B --address A `; then (when executed) -wait for re-enumeration as PID `0x0009` and confirm the product string matches `--rev`. - -- **Identity = sysfs USB port path** (e.g. `3-1.4`), which is stable across the - BOOTSEL↔app re-enumeration; bus/address (busnum/devnum) target picotool. +Location: `panel/tools/g6_flash.py` (firmware repo). Python 3, stdlib-only except for +`pyserial` on macOS (already pulled in transitively by `platformio`). Linux and macOS +are supported with two different device models; Windows is not. + +**Linux** — enumerates via sysfs (like the retired by-id scripts) and flashes with +`picotool`, which is **not** a conda-forge package so it isn't a pinned `pixi.toml` +dependency — the tool prefers the copy PlatformIO already vendors under +`~/.platformio/packages/tool-picotool*/` (present for anyone who's run `pixi run +release`/`diag`, which build via `pio` under the hood), falling back to PATH. Per panel: +if it is running firmware, `picotool reboot -f -u --bus B --address A` → wait for +BOOTSEL; then `picotool load [-x] --bus B --address A `; then (when executed) wait +for re-enumeration as PID `0x0009` and confirm the product string matches `--rev`. +**Identity = sysfs USB port path** (e.g. `3-1.4`), which is stable across the BOOTSEL↔app +re-enumeration; bus/address (busnum/devnum) target picotool. Batch: enumerate all +VID-`0x2E8A` boards, flash in parallel (`--jobs`, default 4), end-of-run summary. +**Realistic ceiling ~10–20 panels per externally-powered hub** — the limit is post-flash +LED-matrix inrush + USB re-enumeration, not flashing. `--no-exec` loads without running, +so trays can be power-cycled in small groups. + +**macOS** — no picotool: its libusb backend can't reliably claim a CDC interface macOS's +own kernel driver already owns. Instead, a 1200-baud DTR touch (the earlephilhower/Arduino +convention for a buttonless BOOTSEL reset) followed by a plain UF2 copy to the +`/Volumes/RP2350` mass-storage mount — the mechanism the retired `deploy.sh` validated. +That mount can't disambiguate multiple simultaneous boards, so macOS flashes **exactly +one panel per invocation** — no "flash every connected panel of a rev", no parallel +batch, no `--no-exec`. Boards running firmware are enumerated via `pyserial` (matching +VID/PID/serial, same as `monitor.py`); post-flash verification keys off the USB serial +known *before* the touch (device paths aren't stable across the re-enumeration), so a +blank/already-BOOTSEL board (no prior serial) flashes but can't be verified this way. + +Both platforms share: - `--rev {v0.2.1,v0.3.1}` is **mandatory**. A running board whose current product string disagrees with `--rev` triggers a loud abort (override with `--force`). -- Batch: enumerate all VID-`0x2E8A` boards, flash in parallel (`--jobs`, default 4), - end-of-run summary. **Realistic ceiling ~10–20 panels per externally-powered hub** — - the limit is post-flash LED-matrix inrush + USB re-enumeration, not flashing. `--no-exec` - loads without running, so trays can be power-cycled in small groups. - Firmware source: default = latest GitHub Release (manifest + sha256-checked UF2, cached under `~/.cache/g6-flash/`); `--fw-version ` pins a release; `--uf2 ` flashes a local build. @@ -165,8 +183,11 @@ the latest release via the same `manifest.json`, so the page auto-tracks new fir `g6-flash` (`panel/tools/g6_flash.py`), the release/diag build pipeline (`panel/tools/build_release.py`, `.github/workflows/release.yml`, `pixi.toml`), and the WebUSB flasher (webDisplayTools `flasher/`) are implemented in their final homes (`picotool` -is not pinned as a `pixi.toml` dependency — see §B). No firmware release has been cut yet, -so the tools' default download path is untested until the first `panel-fw-v*` tag. +is not pinned as a `pixi.toml` dependency — see §B). macOS flashing (1200-baud touch + UF2 +copy) ports the retired `deploy.sh`'s mechanism, itself hardware-validated, but is +unverified in `g6-flash`'s own code path beyond a mocked-pyserial logic test (see Open +Questions). No firmware release has been cut yet, so the tools' default download path is +untested until the first `panel-fw-v*` tag. ## Open Questions / TBDs @@ -179,6 +200,10 @@ so the tools' default download path is untested until the first `panel-fw-v*` ta (`319A5199EE357F77` v0.2.1, `A5D4B82BA2B9FB51` v0.3.1): a re-flash of a running board (cross-checked vs a direct `g6_flash.py --serial ` call), and a blank/BOOTSEL board (the new capability), plus the rev-guard abort path. +- **macOS validation** — `g6-flash`'s macOS path (1200-baud touch + UF2 copy) has not + been run against real hardware; only checked with a mocked-pyserial logic test. Verify + the touch/mount/copy sequence and post-flash serial-based verification on an actual Mac + + bench panel before relying on it. - **Optional** — a firmware serial command to reboot-to-BOOTSEL would let the web tool auto-enter BOOTSEL via a Web Serial pre-step, removing the manual hold-BOOTSEL step.