فهرست منبع

Merge branch 'main' into copilot/add-remap-pure-lizard-mode-shortcuts

Union of the two webusbPoll command-set extensions: this branch's lizard
binding-map editor (0x11-0x15) and main's staged firmware update
(0x20-0x24, PR #123). Kept main's 160 B RX buffer (fwup 0x21 chunk is the
new largest command) and its buf[5] length sanity-gate; opcode resync now
accepts 0x01-0x15 and 0x20-0x24.

Also fixes a device->host frame-marker collision introduced by the
previous reconciliation merge (8849c4a): the lizard map dump had been
renumbered onto 0xA8, which main's flight recorder already owned. Lizard
map now replies on 0xAA (the free marker) in firmware, panel JS and
PROTOCOL.md; the protocol doc also gained the fwup opcodes and the
0xA8/0xA9/0xAB device->host frames.

Compile-checked with make build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Jari 3 ماه پیش
والد
کامیت
3b37d8e1d7

+ 46 - 26
.github/workflows/build.yml

@@ -6,18 +6,18 @@ on:
       - main
     tags:
       - '[0-9]*.[0-9]*.[0-9]*'
-  # PRs get the same gate -- without this the checks only ran after merge.
+  # Build every PR head commit and publish UF2 artifacts for reviewers.
   pull_request:
-  pull_request_review:
-    types:
-      - submitted
+
+concurrency:
+  group: build-${{ github.event.pull_request.number || github.ref }}
+  cancel-in-progress: true
 
 permissions:
   contents: read
 
 jobs:
   compile:
-    if: github.event_name != 'pull_request_review' || github.event.review.state == 'approved'
     runs-on: ubuntu-latest
 
     steps:
@@ -25,7 +25,7 @@ jobs:
         uses: actions/checkout@v4
         with:
           fetch-depth: 0
-          ref: ${{ github.event.pull_request.head.sha || github.event.review.pull_request.head.sha || github.sha }}
+          ref: ${{ github.event.pull_request.head.sha || github.sha }}
 
       - name: Set up Arduino CLI
         uses: arduino/setup-arduino-cli@v2
@@ -50,8 +50,8 @@ jobs:
       - name: Resolve build version
         id: build_version
         env:
-          PR_NUMBER: ${{ github.event.pull_request.number || github.event.review.pull_request.number || '' }}
-          HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.review.pull_request.head.sha || github.sha }}
+          PR_NUMBER: ${{ github.event.pull_request.number || '' }}
+          HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
         run: |
           set -eu
           SEMVER_RE='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
@@ -64,14 +64,21 @@ jobs:
           if [ -z "$BASE_VERSION" ]; then
             BASE_VERSION="0.9.1"
           fi
+          SHORT_SHA="$(git rev-parse --short=8 "${HEAD_SHA}")"
+          PR_NUMBER_OR_ZERO="${PR_NUMBER:-0}"
 
-          if [ "${{ github.event_name }}" = "pull_request" ] || [ "${{ github.event_name }}" = "pull_request_review" ]; then
-            SHORT_SHA="$(git rev-parse --short=8 "${HEAD_SHA}")"
-            VERSION="${BASE_VERSION}-pr${PR_NUMBER}+${SHORT_SHA}"
+          if [ "${{ github.event_name }}" = "pull_request" ]; then
+            VERSION="${BASE_VERSION}-pr${PR_NUMBER_OR_ZERO}+${SHORT_SHA}"
+            ARTIFACT_VERSION="${BASE_VERSION}-pr${PR_NUMBER_OR_ZERO}-${SHORT_SHA}"
           else
             VERSION="${BASE_VERSION}"
+            ARTIFACT_VERSION="${BASE_VERSION}-${SHORT_SHA}"
           fi
 
+          echo "base_version=$BASE_VERSION" >> "$GITHUB_OUTPUT"
+          echo "pr_number=$PR_NUMBER_OR_ZERO" >> "$GITHUB_OUTPUT"
+          echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT"
+          echo "artifact_version=$ARTIFACT_VERSION" >> "$GITHUB_OUTPUT"
           echo "version=$VERSION" >> "$GITHUB_OUTPUT"
 
       - name: Generate version header
@@ -114,21 +121,34 @@ jobs:
         run: |
           find build/openpuck build/reversepuck -maxdepth 1 -type f -print | sort
 
-      - name: Upload build artifacts
+      - name: Upload OpenPuck UF2 artifact
+        uses: actions/upload-artifact@v4
+        with:
+          name: OpenPuck-${{ steps.build_version.outputs.artifact_version }}-standard.uf2
+          retention-days: 7
+          if-no-files-found: error
+          path: build/openpuck/OpenPuck.ino.uf2
+
+      - name: Upload OpenPuck HEX artifact
+        uses: actions/upload-artifact@v4
+        with:
+          name: OpenPuck-${{ steps.build_version.outputs.artifact_version }}-standard.hex
+          retention-days: 7
+          if-no-files-found: error
+          path: build/openpuck/OpenPuck.ino.hex
+
+      - name: Upload ReversePuckFirmware UF2 artifact
+        uses: actions/upload-artifact@v4
+        with:
+          name: ReversePuckFirmware-${{ steps.build_version.outputs.artifact_version }}.uf2
+          retention-days: 7
+          if-no-files-found: error
+          path: build/reversepuck/ReversePuckFirmware.ino.uf2
+
+      - name: Upload ReversePuckFirmware HEX artifact
         uses: actions/upload-artifact@v4
         with:
-          name: openpuck-firmware
+          name: ReversePuckFirmware-${{ steps.build_version.outputs.artifact_version }}.hex
           retention-days: 7
-          path: |
-            build/openpuck/*.uf2
-            build/openpuck/*.bin
-            build/openpuck/*.hex
-            build/openpuck/*.elf
-            build/openpuck/*.map
-            build/openpuck/*.zip
-            build/reversepuck/*.uf2
-            build/reversepuck/*.bin
-            build/reversepuck/*.hex
-            build/reversepuck/*.elf
-            build/reversepuck/*.map
-            build/reversepuck/*.zip
+          if-no-files-found: error
+          path: build/reversepuck/ReversePuckFirmware.ino.hex

+ 51 - 3
.github/workflows/release.yml

@@ -157,7 +157,7 @@ jobs:
 
           find build/openpuck build/openpuck-recovery \
             -maxdepth 1 -type f \
-            \( -name '*.uf2' -o -name '*.zip' \) \
+            \( -name '*.uf2' -o -name '*.hex' \) \
             -print | while read -r f; do
             # Keep only the extension; the arduino base name (OpenPuck.ino.*)
             # is redundant once we prefix with OpenPuck-<version>-<flavor>.
@@ -173,7 +173,7 @@ jobs:
 
           find build/reversepuck \
             -maxdepth 1 -type f \
-            \( -name '*.uf2' -o -name '*.zip' \) \
+            \( -name '*.uf2' -o -name '*.hex' \) \
             -print | while read -r f; do
             ext="${f##*.}"
             cp "$f" "release_assets/ReversePuckFirmware-${VERSION}.${ext}"
@@ -199,7 +199,55 @@ jobs:
             - `OpenPuck-*-standard.*` — puck firmware (standard)
             - `OpenPuck-*-factory-reset.*` — puck firmware (factory reset)
             - `ReversePuckFirmware-*.*` — reverse-puck firmware (controller emulator)
-            - `.zip` files for Adafruit DFU tool
             - `.uf2` files for drag-and-drop flashing (BOOT/UF2 drive)
+            - `.hex` files for direct flashing workflows
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+      - name: Mirror OpenPuck UF2s to the firmware branch
+        # The WebUSB panel's "update from a release" flow downloads these in-browser. GitHub's release-asset
+        # CDN (release-assets.githubusercontent.com) sends no CORS headers, so browsers cannot fetch release
+        # assets directly; raw.githubusercontent.com IS CORS-clean. Mirror the puck .uf2s onto the orphan
+        # `firmware` branch, where the panel fetches raw.githubusercontent.com/<repo>/firmware/<asset-name>.
+        # Isolated clone in a temp dir so the build tree (untracked artifacts, regenerated version header)
+        # can't leak into the mirror commit.
+        env:
+          VERSION: ${{ steps.release_version.outputs.version }}
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        run: |
+          set -eu
+          MIRROR="$(mktemp -d)/mirror"
+          REPO="https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
+          if git ls-remote --exit-code "$REPO" refs/heads/firmware >/dev/null 2>&1; then
+            git clone --depth 1 --branch firmware "$REPO" "$MIRROR"
+          else
+            git init "$MIRROR"
+            git -C "$MIRROR" checkout --orphan firmware
+            git -C "$MIRROR" remote add origin "$REPO"
+            printf '%s\n' \
+              '# firmware mirror' '' \
+              'OpenPuck release `.uf2` files, mirrored from the GitHub release assets because' \
+              'raw.githubusercontent.com serves files WITH CORS headers while the release-asset CDN does' \
+              'not — this is what lets the WebUSB config panel download and flash releases in-browser.' \
+              'Maintained automatically by `.github/workflows/release.yml`; do not edit by hand.' \
+              > "$MIRROR/README.md"
+          fi
+          cp release_assets/OpenPuck-"${VERSION}"-*.uf2 "$MIRROR"/
+          # manifest.json: name -> {panelUpdate}. panelUpdate = the image embeds the FWUP_TAG capability
+          # string (fw_update.cpp), i.e. a puck running it can be updated FROM the panel. The panel badges
+          # releases whose images can't ("no panel updates") before the user flashes one.
+          ( cd "$MIRROR" && python3 - <<'PY'
+          import json, os, glob
+          m = {}
+          if os.path.exists("manifest.json"):
+              m = json.load(open("manifest.json"))
+          for f in sorted(glob.glob("OpenPuck-*.uf2")):
+              m[f] = {"panelUpdate": b"OPK-FWUP-v1" in open(f, "rb").read()}
+          json.dump(m, open("manifest.json", "w"), indent=1, sort_keys=True)
+          PY
+          )
+          git -C "$MIRROR" config user.name "github-actions[bot]"
+          git -C "$MIRROR" config user.email "github-actions[bot]@users.noreply.github.com"
+          git -C "$MIRROR" add -A
+          git -C "$MIRROR" commit -m "mirror OpenPuck ${VERSION} uf2s for the panel updater"
+          git -C "$MIRROR" push origin firmware

+ 15 - 0
CODE_MAP.md

@@ -434,6 +434,21 @@ Reads `g_qamMap`/`g_abSwap`/`g_back[]`. Pure transforms, no buffers beyond calle
   `0x0A` ("ERS")→`factoryErase`+reset; `0x0B/0x0C`→DFU; capture drain (`OPK_LOG`).
   Each reset/DFU path does `delay(40)` then `NVIC_SystemReset()` (harmless — reboots).
 
+### `fw_update.cpp` / `fw_update.h` — staged firmware update (loop task + boot)
+- WebUSB ops `0x20 begin / 0x21 chunk / 0x22 verify+commit / 0x24 abort` stage a new app
+  image into unused flash below the meta page at `0xEC000` (top of the app region);
+  strictly sequential offsets, duplicate chunks re-acked without rewriting (flash words
+  are written once per erase). One page erase max per `loop()` pass (~85 ms CPU stall).
+- `fwupEnd()` CRC32s the **staged flash** (heartbeat-beaten so the wedge reporter stays
+  quiet), sanity-checks the vector table, then commits the one-page meta record — the
+  single arming action. Idempotent on retry (lost-ack safe).
+- `fwupApplyIfArmed()` (first line of `setup()`): re-validates everything, then
+  `ramApply()` — a `.data`-section (RAM-resident) copier, IRQs off, WDT force-started
+  and fed — erases the app (vector page FIRST), writes it (vector page LAST, first word
+  dead-last), verifies word-for-word, rewrites the bootloader settings page to the
+  drag-and-drop state (`BANK_VALID_APP`, crc-check off), erases meta, resets. Any
+  interruption leaves the board app-less → UF2 bootloader, never a half image.
+
 ### `serial_console.cpp` / `serial_console.h` — CDC debug CLI (loop task)
 - `serialConsolePoll()` drains available CDC bytes into `static char line[24]` (bounded),
   dispatches single-letter commands on newline (first match wins). Commands mutate the

+ 5 - 0
OpenPuck/OpenPuck.ino

@@ -39,6 +39,7 @@ using namespace Adafruit_LittleFS_Namespace;
 #include "usb_mount.h"
 #include "identity.h"
 #include "fault_diag.h"
+#include "fw_update.h"
 #include "usb_tx.h"
 #include <stdio.h>
 
@@ -100,6 +101,10 @@ void usbReenumerate(uint8_t k)
 
 void setup()
 {
+	// staged firmware update (WebUSB "flash on reboot"): if the panel committed one, this never returns --
+	// it copies staged->app from RAM and resets into the new firmware. MUST run before anything else touches
+	// hardware; the board looks dead for the ~5 s the copy takes.
+	fwupApplyIfArmed();
 	genSerial();
 	ledInit();
 

+ 369 - 0
OpenPuck/fw_update.cpp

@@ -0,0 +1,369 @@
+// Staged firmware update: WebUSB streams the new app image into unused upper app-region flash; a verified
+// meta-page commit arms it; the next boot applies it from RAM. See fw_update.h for the protocol and the
+// corruption-safety invariant every step here preserves.
+#include "fw_update.h"
+#include "fault_diag.h"
+#include <Arduino.h>
+#include <string.h>
+
+// nRF52840 flash map (Adafruit UF2 bootloader layout): MBR 0x0-0x1000, SoftDevice to 0x26000, app region
+// 0x26000-0xED000, internal LittleFS 0xED000-0xF4000, bootloader 0xF4000+, its settings page at 0xFF000.
+// We manage the TOP of the app region: the meta/commit page at 0xEC000 and the staged image right below it,
+// growing downward. Nothing outside [APP_BASE, 0xED000) is ever written except FWUP_BL_SETTINGS (see apply).
+#define FWUP_APP_BASE 0x26000UL
+#define FWUP_APP_END 0xED000UL
+#define FWUP_META 0xEC000UL
+#define FWUP_BL_SETTINGS 0xFF000UL
+#define FWUP_PAGE 4096UL
+// Image cap. Also what makes the apply copy safe from self-overlap: dst ends at most at 0x26000+0x60000 =
+// 0x86000, while the staged source starts at (0xEC000-size)&~0xFFF >= 0x8C000 -- disjoint by >=24 KiB.
+#define FWUP_MAX_IMG 0x60000UL
+
+// Capability tag, searched for BY THE PANEL inside any .uf2 it is about to flash: an image without this
+// exact string predates panel updates, so flashing it silently locks future updates back to UF2-DFU
+// drag-and-drop -- the panel warns before letting that happen. Bump the suffix only on a breaking protocol
+// change (the panel searches for this exact value). The asm reference below keeps it through --gc-sections.
+static const char FWUP_TAG[] = "OPK-FWUP-v1";
+
+#define FWUP_META_MAGIC 0x32465055UL // "UPF2"
+struct FwupMeta {
+	uint32_t magic, size, crc, staged;
+	uint32_t metaCrc; // CRC32 over the four fields above
+};
+static_assert(sizeof(FwupMeta) == 20, "FwupMeta layout");
+
+// end of the flash the RUNNING image occupies: code/rodata (__etext) plus the .data init image loaded right
+// after it (.data is the last flash-loaded section in the core's linker script). Staging must stay above it.
+extern "C" {
+extern uint32_t __etext[];
+extern uint32_t __data_start__[];
+extern uint32_t __data_end__[];
+}
+static uint32_t flashUsedEnd(void)
+{
+	return (uint32_t)__etext +
+	       ((uint32_t)__data_end__ - (uint32_t)__data_start__);
+}
+
+// ---- CRC32 (IEEE, reflected, init/xorout 0xFFFFFFFF) -- must match the panel's JS implementation ----------
+static uint32_t crc32Step(uint32_t crc, const volatile uint8_t *p, uint32_t n)
+{
+	while (n--) {
+		crc ^= *p++;
+		for (int k = 0; k < 8; k++)
+			crc = (crc >> 1) ^ (0xEDB88320UL & (0UL - (crc & 1)));
+	}
+	return crc;
+}
+// CRC a flash range. Beats the loop-heartbeat between blocks: verifying a full-size image takes ~200 ms of
+// CPU, and without beats the fault_diag live-wedge reporter (>300 ms stall) would push an unsolicited status
+// blob into the IN pipe right when the panel is waiting for the 0x22 ack.
+static uint32_t crc32Flash(uint32_t addr, uint32_t len)
+{
+	uint32_t crc = 0xFFFFFFFFUL;
+	while (len) {
+		uint32_t blk = len > 16384 ? 16384 : len;
+		crc = crc32Step(crc, (const volatile uint8_t *)addr, blk);
+		addr += blk;
+		len -= blk;
+		faultDiagBeat();
+	}
+	return ~crc;
+}
+
+// ---- NVMC helpers (flash-resident; used for staging + meta only, never on the live app region) ------------
+// NVMC ops halt the CPU (page erase ~85 ms) -- callers keep it to at most one erase per loop() pass so the
+// 8 s watchdog and the RF link only see brief hiccups.
+static void nvmcErasePage(uint32_t page)
+{
+	NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Een;
+	while (!NRF_NVMC->READY) {
+	}
+	NRF_NVMC->ERASEPAGE = page;
+	while (!NRF_NVMC->READY) {
+	}
+	NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Ren;
+	while (!NRF_NVMC->READY) {
+	}
+}
+static void nvmcWriteWords(uint32_t addr, const uint32_t *w, uint32_t nWords)
+{
+	NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Wen;
+	while (!NRF_NVMC->READY) {
+	}
+	for (uint32_t i = 0; i < nWords; i++) {
+		((volatile uint32_t *)addr)[i] = w[i];
+		while (!NRF_NVMC->READY) {
+		}
+	}
+	NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Ren;
+	while (!NRF_NVMC->READY) {
+	}
+}
+
+// staged image must start with a plausible Cortex-M vector table: initial SP word-aligned in RAM (256 KiB on
+// the nRF52840), reset vector Thumb within the app region. Last line of defense against arming a non-app blob.
+static bool vectorsPlausible(uint32_t imgAddr, uint32_t size)
+{
+	if (size < 8)
+		return false;
+	uint32_t sp = ((const volatile uint32_t *)imgAddr)[0];
+	uint32_t rv = ((const volatile uint32_t *)imgAddr)[1];
+	return sp >= 0x20000000UL && sp <= 0x20040000UL && (sp & 3) == 0 &&
+	       (rv & 1) && rv >= FWUP_APP_BASE && rv < FWUP_APP_END;
+}
+
+// ---- staging state (transfer is strictly sequential; every ack carries s_off so the panel can resync) -----
+static bool s_active = false;
+static uint32_t s_size, s_crc, s_base, s_off, s_erased;
+
+uint32_t fwupNextOff(void)
+{
+	return s_off;
+}
+
+uint8_t fwupBegin(uint32_t size, uint32_t crc32)
+{
+	s_active = false;
+	// any BEGIN first disarms a previously staged update -- a restarted transfer must never leave a stale
+	// commit behind (the staged bytes it points at are about to be overwritten).
+	nvmcErasePage(FWUP_META);
+	if (size == 0 || size > FWUP_MAX_IMG || (size & 3))
+		return FWUP_ERR_BOUNDS;
+	uint32_t base = (FWUP_META - size) & ~(FWUP_PAGE - 1);
+	// keep a page of slack above the running image so staging can NEVER touch live code/rodata/.data-init
+	if (base < flashUsedEnd() + FWUP_PAGE)
+		return FWUP_ERR_BOUNDS;
+	s_size = size;
+	s_crc = crc32;
+	s_base = base;
+	s_off = 0;
+	s_erased =
+		base; // pages erased lazily, one per chunk at most (85 ms CPU stall each)
+	s_active = true;
+	return FWUP_OK;
+}
+
+uint8_t fwupChunk(uint32_t off, const uint8_t *d, uint8_t len)
+{
+	if (!s_active)
+		return FWUP_ERR_STATE;
+	if (len == 0 || len > 128 || (len & 3) || off + len > s_size)
+		return FWUP_ERR_BOUNDS;
+	if (off != s_off) {
+		// duplicate of already-written data (panel retried a chunk whose ack got lost): don't rewrite
+		// the words (nRF52 flash allows only 2 writes per word between erases) -- just re-ack.
+		if (off < s_off && off + len <= s_off)
+			return FWUP_OK;
+		return FWUP_ERR_OFFSET; // gap/desync: ack carries s_off, panel resends from there
+	}
+	while (s_erased < s_base + off + len) {
+		nvmcErasePage(s_erased);
+		s_erased += FWUP_PAGE;
+	}
+	uint32_t w[32]; // chunk arrives unaligned in the command buffer; NVMC wants word writes
+	memcpy(w, d, len);
+	nvmcWriteWords(s_base + off, w, len / 4);
+	s_off = off + len;
+	return FWUP_OK;
+}
+
+uint8_t fwupEnd(void)
+{
+	if (!s_active) {
+		// duplicate END (the panel retried because our ack got lost): if the commit it is asking about
+		// is exactly the one already on the meta page, re-report success instead of ERR_STATE.
+		const volatile FwupMeta *m =
+			(const volatile FwupMeta *)FWUP_META;
+		if (s_size && m->magic == FWUP_META_MAGIC &&
+		    m->size == s_size && m->crc == s_crc)
+			return FWUP_OK;
+		return FWUP_ERR_STATE;
+	}
+	if (s_off != s_size)
+		return FWUP_ERR_STATE;
+	s_active = false;
+	// verify what actually landed in flash, not what we were sent
+	if (crc32Flash(s_base, s_size) != s_crc)
+		return FWUP_ERR_CRC;
+	if (!vectorsPlausible(s_base, s_size))
+		return FWUP_ERR_VECTOR;
+	// COMMIT: this meta page write is the single arming action of the whole protocol
+	FwupMeta m = { FWUP_META_MAGIC, s_size, s_crc, s_base, 0 };
+	m.metaCrc = ~crc32Step(0xFFFFFFFFUL, (const uint8_t *)&m, 16);
+	nvmcErasePage(FWUP_META);
+	nvmcWriteWords(FWUP_META, (const uint32_t *)&m, 5);
+	return FWUP_OK;
+}
+
+void fwupAbort(void)
+{
+	s_active = false;
+	nvmcErasePage(FWUP_META);
+}
+
+// ---- boot-time apply ---------------------------------------------------------------------------------------
+// Runs entirely from RAM (.data section => copied out of flash by the startup code) with interrupts off: it
+// erases and rewrites the very flash the rest of the firmware executes from, so from the first erase to the
+// reset it must not touch ANY flash-resident code or data -- only registers, its arguments, and the two
+// flash regions it copies between. No calls, no libc, volatile pointers so the compiler can't lift the loops
+// into memcpy/memcmp libcalls.
+//
+// Power-cut safety is in the ordering: all target pages are erased up front (the vector page first), pages
+// 1..N-1 are written forward, then the vector page is written BACKWARD so word 0 -- the app-validity marker
+// the bootloader checks -- is the final word written anywhere in the app. Interrupt the sequence at any
+// point and the board reads as app-less: the UF2 bootloader keeps it, drag-and-drop recovers it.
+#define FWUP_RAMFUNC \
+	__attribute__((used, noinline, section(".data.opk_ramfunc")))
+FWUP_RAMFUNC static void ramApply(uint32_t dst, uint32_t src, uint32_t size,
+				  uint32_t metaPage, uint32_t blSettings)
+{
+	volatile uint32_t *d = (volatile uint32_t *)dst;
+	const volatile uint32_t *s = (const volatile uint32_t *)src;
+	const uint32_t words = size / 4;
+	const uint32_t pg0w = words < FWUP_PAGE / 4 ? words : FWUP_PAGE / 4;
+
+	// 1) erase every target page; dst itself (the vector page) goes first
+	NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Een;
+	while (!NRF_NVMC->READY) {
+	}
+	for (uint32_t a = dst; a < dst + size; a += FWUP_PAGE) {
+		NRF_WDT->RR[0] = WDT_RR_RR_Reload;
+		NRF_NVMC->ERASEPAGE = a;
+		while (!NRF_NVMC->READY) {
+		}
+	}
+	// 2) write pages 1..N-1 forward, then the vector page backward (word 0 dead-last)
+	NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Wen;
+	while (!NRF_NVMC->READY) {
+	}
+	for (uint32_t w = FWUP_PAGE / 4; w < words; w++) {
+		if ((w & 1023) == 0)
+			NRF_WDT->RR[0] = WDT_RR_RR_Reload;
+		d[w] = s[w];
+		while (!NRF_NVMC->READY) {
+		}
+	}
+	for (uint32_t w = pg0w; w-- > 0;) {
+		d[w] = s[w];
+		while (!NRF_NVMC->READY) {
+		}
+	}
+	// 3) verify the copy word-for-word against the staged source
+	NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Ren;
+	while (!NRF_NVMC->READY) {
+	}
+	uint32_t bad = 0;
+	for (uint32_t w = 0; w < words; w++) {
+		if ((w & 4095) == 0)
+			NRF_WDT->RR[0] = WDT_RR_RR_Reload;
+		if (d[w] != s[w]) {
+			bad = 1;
+			break;
+		}
+	}
+	NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Een;
+	while (!NRF_NVMC->READY) {
+	}
+	if (bad) {
+		// flash cell failure: make the app unambiguously invalid (bootloader keeps the board) instead
+		// of resetting into a half-good image. Meta is erased below so this can't loop.
+		NRF_NVMC->ERASEPAGE = dst;
+		while (!NRF_NVMC->READY) {
+		}
+	} else {
+		// 4) bootloader settings: leave the exact "valid app, CRC check disabled" state drag-and-drop
+		// (MSC) flashing leaves. A previous adafruit-nrfutil serial-DFU flash records a real CRC16 here,
+		// and the bootloader re-checks it EVERY boot -- stale, it would fail our new image straight into
+		// DFU mode. Layout = SDK11 bootloader_settings_t; word 0 ([bank_0=BANK_VALID_APP][bank_0_crc=0])
+		// is written last so an interrupted settings write falls back to the erased-settings path.
+		NRF_WDT->RR[0] = WDT_RR_RR_Reload;
+		NRF_NVMC->ERASEPAGE = blSettings;
+		while (!NRF_NVMC->READY) {
+		}
+		NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Wen;
+		while (!NRF_NVMC->READY) {
+		}
+		volatile uint32_t *bs = (volatile uint32_t *)blSettings;
+		bs[6] = 0; // sd_image_start
+		while (!NRF_NVMC->READY) {
+		}
+		bs[5] = 0; // app_image_size (bank_0_size is the authoritative one)
+		while (!NRF_NVMC->READY) {
+		}
+		bs[4] = 0; // bl_image_size
+		while (!NRF_NVMC->READY) {
+		}
+		bs[3] = 0; // sd_image_size
+		while (!NRF_NVMC->READY) {
+		}
+		bs[2] = size; // bank_0_size
+		while (!NRF_NVMC->READY) {
+		}
+		bs[1] = 0x000000FEUL; // bank_1 = BANK_ERASED
+		while (!NRF_NVMC->READY) {
+		}
+		bs[0] = 0x00000001UL; // bank_0 = BANK_VALID_APP, bank_0_crc = 0 ("not used")
+		while (!NRF_NVMC->READY) {
+		}
+		NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Een;
+		while (!NRF_NVMC->READY) {
+		}
+	}
+	// 5) disarm: erase the meta page so the (old or new) firmware boots normally from here on
+	NRF_NVMC->ERASEPAGE = metaPage;
+	while (!NRF_NVMC->READY) {
+	}
+	NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Ren;
+	while (!NRF_NVMC->READY) {
+	}
+	// 6) reset -- raw AIRCR write; NVIC_SystemReset() is inline but keep even its helpers out of the path
+	__asm volatile("dsb 0xF" ::: "memory");
+	*(volatile uint32_t *)0xE000ED0CUL = (0x5FAUL << 16) | (1UL << 2);
+	for (;;) {
+	}
+}
+
+void fwupApplyIfArmed(void)
+{
+	// materialize a reference to the capability tag so the linker's --gc-sections keeps it in the image
+	__asm volatile("" ::"r"(FWUP_TAG));
+	const volatile FwupMeta *m = (const volatile FwupMeta *)FWUP_META;
+	if (m->magic != FWUP_META_MAGIC)
+		return; // erased page (0xFFFFFFFF) = nothing armed: the common path, zero boot cost
+	// snapshot, then validate EVERYTHING before touching any flash; anything off -> disarm and boot normally
+	FwupMeta v = { m->magic, m->size, m->crc, m->staged, m->metaCrc };
+	bool ok = v.metaCrc ==
+		  (uint32_t)~crc32Step(0xFFFFFFFFUL, (const uint8_t *)&v, 16);
+	ok = ok && v.size && v.size <= FWUP_MAX_IMG && (v.size & 3) == 0;
+	ok = ok && v.staged >= FWUP_APP_BASE + FWUP_MAX_IMG &&
+	     v.staged + v.size <= FWUP_META &&
+	     (v.staged & (FWUP_PAGE - 1)) == 0;
+	ok = ok && crc32Flash(v.staged, v.size) ==
+			   v.crc; // staged bytes still intact?
+	ok = ok && vectorsPlausible(v.staged, v.size);
+	if (!ok) {
+		nvmcErasePage(FWUP_META);
+		return;
+	}
+	// Point of no return. Freeze the system: nothing may run from app flash once the copy starts. The WDT
+	// is forced on first (it survives soft reset if the previous boot armed it, but not power-on), so even
+	// a fault inside the copier resets into the safe app-less/bootloader state instead of hanging forever.
+	__disable_irq();
+	if (!NRF_WDT->RUNSTATUS) {
+		NRF_WDT->CONFIG =
+			(WDT_CONFIG_HALT_Pause << WDT_CONFIG_HALT_Pos) |
+			(WDT_CONFIG_SLEEP_Run << WDT_CONFIG_SLEEP_Pos);
+		NRF_WDT->CRV = 8UL * 32768UL - 1; // ~8 s, same as the app's own
+		NRF_WDT->RREN = WDT_RREN_RR0_Msk;
+		NRF_WDT->TASKS_START = 1;
+	}
+	NRF_WDT->RR[0] = WDT_RR_RR_Reload;
+	// call through a volatile pointer: the compiler must materialize the RAM address and may not inline the
+	// copier back into flash-resident code
+	void (*volatile apply)(uint32_t, uint32_t, uint32_t, uint32_t,
+			       uint32_t) = ramApply;
+	apply(FWUP_APP_BASE, v.staged, v.size, FWUP_META, FWUP_BL_SETTINGS);
+	for (;;) {
+	} // unreachable (ramApply resets)
+}

+ 56 - 0
OpenPuck/fw_update.h

@@ -0,0 +1,56 @@
+// Firmware update over WebUSB ("staged UF2"): the panel extracts the raw app image from a .uf2 and streams
+// it over the existing WebUSB vendor channel (ops 0x20..0x24, see webusb_config.h) into UNUSED flash high in
+// the app region. A one-page metadata commit -- written only after the staged bytes CRC32-verify in flash --
+// arms the update; the next reboot applies it before anything else runs, then boots the new firmware.
+//
+// Corruption safety (the invariant every step preserves): at any instant the device has EITHER a valid old
+// app, a valid new app, OR reads as app-less so the resident UF2 bootloader keeps the board (drag-and-drop
+// recovery) -- never a half-written image that crash-loops:
+//   - The transfer stages into flash the running image doesn't use; until fwupEnd() verifies the full CRC32
+//     and commits the meta page, a disconnect/abort/power-cut leaves ZERO trace (no arm, app untouched).
+//   - The boot-time applier re-verifies the staged CRC32 and vector table BEFORE erasing anything.
+//   - The apply itself runs from RAM (flash code can't survive overwriting itself) with a page ordering that
+//     makes power cuts safe: the app's vector-table page is erased FIRST and rewritten LAST (its first word
+//     dead-last), so an interrupted apply leaves 0xFFFFFFFF at the app entry -- the Adafruit bootloader's
+//     validity check then keeps the board in the UF2BOOT mass-storage bootloader instead of jumping to junk.
+//   - The watchdog is force-started before the copy, so even a crash mid-apply resets into that same
+//     safe app-less state rather than hanging forever.
+//   - The applier is idempotent: a reset after the copy but before the meta clear just re-applies the same
+//     verified image.
+// The bootloader, MBR/SoftDevice, and the LittleFS config/bond region are never written (one exception: the
+// bootloader SETTINGS page is rewritten to the same "valid app, no CRC" state drag-and-drop flashing leaves,
+// because a stale CRC recorded by a previous adafruit-nrfutil flash would otherwise fail the new image at
+// the bootloader's boot check).
+#pragma once
+#include <stdbool.h>
+#include <stdint.h>
+
+// ACK status codes, echoed to the panel in the 0xAB frame ([0xAB][5][status][nextOff u32 LE]).
+enum {
+	FWUP_OK = 0,
+	FWUP_ERR_STATE =
+		1, // command out of sequence (no BEGIN, or END before all bytes)
+	FWUP_ERR_BOUNDS =
+		2, // image too big / would collide with the running app or config region
+	FWUP_ERR_OFFSET =
+		3, // chunk offset mismatch (ack carries the expected offset -- resync and resend)
+	FWUP_ERR_CRC = 4, // staged bytes don't match the announced CRC32
+	FWUP_ERR_VECTOR =
+		5, // staged image doesn't start with a plausible Cortex-M vector table
+};
+
+uint8_t fwupBegin(
+	uint32_t size,
+	uint32_t crc32); // 0x20: announce image; disarms any previous staged update
+uint8_t fwupChunk(uint32_t off, const uint8_t *d,
+		  uint8_t len); // 0x21: sequential data (len%4==0, <=128)
+uint8_t
+fwupEnd(void); // 0x22: verify staged CRC32 + vectors, commit the meta page (ARMS the update)
+void fwupAbort(
+	void); // 0x24: disarm + drop any transfer state (staged bytes become inert garbage)
+uint32_t fwupNextOff(
+	void); // next expected chunk offset (returned in every ack for panel resync)
+
+// Apply a committed staged update. Call FIRST in setup(): if a valid meta page is present this never returns
+// (copies staged->app from RAM and resets). Invalid/stale meta is erased and ignored.
+void fwupApplyIfArmed(void);

+ 20 - 18
OpenPuck/haptics.cpp

@@ -604,34 +604,36 @@ void hapticOnReconnect(int slot)
 void hapticTask()
 {
 	// id9 steering (SET_SETTINGS index 9 = digital-mappings / the controller's AUTONOMOUS mapping+haptic
-	// engine). Two regimes, switched by who is actually driving:
-	//  - Steam DRIVING (MODE_STEAM + heartbeat alive): hold id9=0 every LIZKEEP_MS, the way the real puck
-	//    does (sniff1.json) -- keeps the autonomous engine off so Steam owns haptics and the engine can't
-	//    latch the "deep-inside" reconnect buzz. 0x87 is change-guarded in the controller, so the repeat
-	//    is silent (no 0x81-style click).
-	//  - lizard PRESENTING (pure MODE_LIZARD, or MODE_STEAM with Steam closed): land id9=1 ONCE per
-	//    connect/lizard episode. id9 gates the whole autonomous pad layer INCLUDING the trackpad ticks --
-	//    the old always-in-MODE_STEAM id9=0 hold is why Steam-closed lizard lost its ticks, and a stale
-	//    id9=0 from a previous Steam session (mode switch without a controller power cycle) is why pure
-	//    lizard could come up tickless too. Landing 1 explicitly restores the pad layer immediately
-	//    instead of waiting for the controller's revert timer (which also resets settings = audible pop).
-	// Emulated modes (Xbox/Switch/DS) are untouched: lizardActive() is false there and the id9=0 hold
-	// stays scoped to MODE_STEAM, exactly as before.
-	if (g_lizKeep && modeIsPuck(g_usbMode)) {
+	// engine, which is what generates the trackpad tick haptics). We decide per mode whether that autonomous
+	// engine should be ON, then either land id9=1 ONCE per connect episode (engine on) or hold id9=0 every
+	// LIZKEEP_MS (engine off). id9 gates the whole autonomous pad layer INCLUDING the trackpad ticks; 0x87 is
+	// change-guarded in the controller so a repeated same-value write is silent (no 0x81-style click), and
+	// re-landing id9=1 on (re)connect restores the pad layer immediately instead of waiting for the
+	// controller's revert timer (which also resets all settings = audible pop).
+	//
+	// wantAuto = should the controller run its own pad layer (trackpad ticks) for the ACTIVE mode?
+	//  - puck modes (STEAM/LIZARD): ON while we present lizard (pure MODE_LIZARD, or MODE_STEAM with Steam
+	//    closed) so the pad keeps its ticks; OFF when Steam is driving (Steam owns haptics, and holding the
+	//    engine off stops it latching the deep-inside reconnect buzz). = puckLizardActive().
+	//  - emulated modes (Xbox/Switch/DS): follow the per-type trackpad-haptics config g_padHaptics (default
+	//    ON; Switch defaults OFF). This is the MIRROR of the lizard case -- here holding id9=0 is how we turn
+	//    the controller's autonomous trackpad haptics OFF for a type that doesn't want them.
+	if (g_lizKeep) {
 		static unsigned long lastKeep[NSLOT] = { 0 };
 		static bool landedAuto[NSLOT] = { false };
 		static const uint8_t K0[3] = { 0x09, 0x00, 0x00 };
 		static const uint8_t K1[3] = { 0x09, 0x01, 0x00 };
-		bool liz = puckLizardActive();
+		bool wantAuto = modeIsPuck(g_usbMode) ? puckLizardActive() :
+							(g_padHaptics != 0);
 		for (int s = 0; s < NSLOT; s++) {
 			if (!g_slot[s].used || !hapticLinkUp(s)) {
-				// re-land id9=1 on the next (re)connect: a fresh controller defaults to
-				// autonomous, but one carrying our previous session's id9=0 does not
+				// re-land id9 on the next (re)connect: a fresh controller defaults to
+				// autonomous, but one carrying our previous session's id9 does not
 				landedAuto[s] = false;
 				lastKeep[s] = 0;
 				continue;
 			}
-			if (liz) {
+			if (wantAuto) {
 				if (!landedAuto[s]) {
 					landedAuto[s] = true;
 					relayEnqueue(0x87, K1, sizeof K1,

+ 9 - 1
OpenPuck/radio.cpp

@@ -126,8 +126,16 @@ void rfConfig(uint8_t ch)
 	NRF_RADIO->EVENTS_DISABLED = 0;
 	NRF_RADIO->MODE = ((uint32_t)g_mode << RADIO_MODE_MODE_Pos);
 	NRF_RADIO->FREQUENCY = ch;
+	// +8dBm = the nRF52840's max TX power (was 0dBm). The link margin is thin on
+	// the Pro Micro's PCB-trace antenna (RSSI reads ~20dB below a real puck), and
+	// 2.4GHz is crowded, so weak polls the controller can't hear -> no reply ->
+	// the reply gaps that read as intermittent disconnects. This only lifts the
+	// puck->controller side (poll/beacon reach); the controller's own TX (the F1
+	// reply) is fixed -- but the poll MUST land for the controller to answer, so
+	// the extra 8dB of headroom directly cuts missed-poll dropouts. USB-powered,
+	// so the higher current draw is free.
 	NRF_RADIO->TXPOWER =
-		(RADIO_TXPOWER_TXPOWER_0dBm << RADIO_TXPOWER_TXPOWER_Pos);
+		(RADIO_TXPOWER_TXPOWER_Pos8dBm << RADIO_TXPOWER_TXPOWER_Pos);
 #if defined(RADIO_MODECNF0_RU_Fast)
 	NRF_RADIO->MODECNF0 = (RADIO_MODECNF0_RU_Fast << RADIO_MODECNF0_RU_Pos);
 #endif

+ 29 - 0
OpenPuck/rf_link.cpp

@@ -941,6 +941,35 @@ void rfLinkTask()
 		rfConnStep();
 	} // connected-mode: poll controller, read input
 
+	// Release stale input on a per-slot link-drop edge. g_in[s] is refreshed ONLY
+	// by the 0x45 decode on a fresh reply, so once a controller goes silent the
+	// last-decoded input is frozen in place: STREAM modes (Switch Hori/Pro, PS5,
+	// DS4-gyro) keep emitting it every cycle, and PUSH modes (Steam/puck, Xbox)
+	// leave the host holding their last report -- a feathered trigger, or a
+	// "release" frame that was the one lost in the gap, stays asserted for the
+	// whole outage (the stuck / false-trigger symptom, most visible on the analog
+	// triggers). On the up->down edge, zero g_in[s] (neutralizes the stream modes)
+	// and push one synthetic neutral 0x45 through the active push mode's normal
+	// build path (neutralizes Steam/puck -- including held lizard keys/mouse --
+	// and Xbox; a no-op for stream modes, which don't override onReport45). The
+	// next real 0x45 on reconnect refills everything. PS3 already streams its own
+	// neutral when no slot is live, so it needs nothing here.
+	{
+		static bool wasUp[NSLOT] = {};
+		static const uint8_t neutral45[46] = { 0x45 };
+		for (int s = 0; s < NSLOT; s++) {
+			bool up = g_slot[s].used && g_connReplyMs[s] != 0 &&
+				  (millis() - g_connReplyMs[s] < 300u);
+			if (wasUp[s] && !up) {
+				memset(&g_in[s], 0, sizeof g_in[s]);
+				if (g_active)
+					g_active->onReport45(s, neutral45, true,
+							     sizeof neutral45);
+			}
+			wasUp[s] = up;
+		}
+	}
+
 	// ---- RF stall self-heal -------------------------------------------------------------------------------
 	// Field wedge: the controllers stay powered and show a SOLID (connected) light, the puck keeps issuing E3
 	// polls + E1 beacons (radio TX is provably alive -- a power-cycled controller still re-adopts the session

+ 101 - 23
OpenPuck/webusb_config.cpp

@@ -8,6 +8,7 @@
 #include "lizard_map.h"
 #include "build_info.h"
 #include "fault_diag.h"
+#include "fw_update.h"
 #include "usb_tx.h"
 #include <Arduino.h>
 #include <string.h>
@@ -23,6 +24,19 @@ static volatile bool g_blobRequest = false;
 // browser can save them to a file and restore them onto a second puck. Like the status blob, the actual send
 // is deferred to the usbd task (webusbSofDrain) so usb_web.write()/flush() never block loop().
 static volatile bool g_bondExportRequest = false;
+// Firmware-update ack ([0xAB][5][status][nextOff u32 LE]). Like the blob it is written from the usbd task
+// (webusbSofDrain), but unlike the blob it is NEVER dropped -- the panel's transfer flow-control is strict
+// ping-pong on these acks, so an unsent ack just stays pending until the FIFO has room (the panel is
+// actively draining the IN pipe during an update, so that is at most a few SOFs).
+static volatile bool g_fwupAckPend = false;
+static volatile uint8_t g_fwupAckStatus = 0;
+static volatile uint32_t g_fwupAckOff = 0;
+static void fwupAckPost(uint8_t status)
+{
+	g_fwupAckStatus = status;
+	g_fwupAckOff = fwupNextOff();
+	g_fwupAckPend = true;
+}
 
 // blob payload = [ver=10][mode][mDiv][mFric][qamMap(active)][abSwap(active)][back0..3(active)][connSlot(0xFF=none)][linkUp]
 //                [f1ps_lo][f1ps_hi][pollU100][newps_lo][newps_hi][e7b][relayOp][relaySub][fwdNewOnly]
@@ -65,8 +79,10 @@ static void webusbSendBlob()
 	p[0] = 0xA5;
 	p[1] = WB_PAYLEN;
 
-	// protocol version (14 = +landAll87 toggle; 13 = +per-slot link stats; 12 = +relay rate + clock fingerprint; 11 = +reset cause; 10 = +ledBright per type; 9 = +per-type cfg; 8 = +per-slot link status; 7 = +raw accel; 6 = +swPro120/gyroScale)
-	p[2] = 14;
+	// protocol version (15 = +staged firmware-update ops 0x20..0x24, payload unchanged; 14 = +landAll87
+	// toggle; 13 = +per-slot link stats; 12 = +relay rate + clock fingerprint; 11 = +reset cause; 10 =
+	// +ledBright per type; 9 = +per-type cfg; 8 = +per-slot link status; 7 = +raw accel; 6 = +swPro120/gyroScale)
+	p[2] = 15;
 	p[3] = g_usbMode;
 	p[4] = (uint8_t)g_mDiv;
 	p[5] = (uint8_t)g_mFric;
@@ -395,6 +411,20 @@ static void webusbSofDrain(void)
 	// it works even though loop() is dead. drop-on-full in webusbSendBlob keeps it from ever blocking.
 	if (faultDiagStallMs() > 300)
 		g_blobRequest = true;
+	// firmware-update ack first: the panel is blocked on it (ping-pong), and it must never be dropped --
+	// retry every SOF until the FIFO has room.
+	if (g_fwupAckPend && tud_vendor_write_available() >= 7) {
+		uint8_t f[7] = { 0xAB,
+				 5,
+				 g_fwupAckStatus,
+				 (uint8_t)(g_fwupAckOff),
+				 (uint8_t)(g_fwupAckOff >> 8),
+				 (uint8_t)(g_fwupAckOff >> 16),
+				 (uint8_t)(g_fwupAckOff >> 24) };
+		usb_web.write(f, sizeof f);
+		usb_web.flush();
+		g_fwupAckPend = false;
+	}
 	if (g_blobRequest) {
 		g_blobRequest = false;
 		webusbSendBlob();
@@ -449,8 +479,8 @@ void webusbInit(void)
 	tud_sof_cb_enable(true);
 }
 
-// Send the whole lizard binding table as one 0xA7 frame so the panel can edit it:
-//   [0xA7][count][ per binding (16B): outType, outData[0..6], trigMask LE(4), holdMask LE(4) ]
+// Send the whole lizard binding table as one 0xAA frame so the panel can edit it:
+//   [0xAA][count][ per binding (16B): outType, outData[0..6], trigMask LE(4), holdMask LE(4) ]
 // Total = 2 + count*16 (max 2 + 32*16 = 514). The panel reads byte[1]=count and accumulates
 // transferIn packets until it has the full frame.
 static void webusbSendLizard()
@@ -462,9 +492,9 @@ static void webusbSendLizard()
 		count = LZ_MAX_BINDINGS;
 	// static (not stack): 514 B is large for the USB task stack, and webusbPoll is single-threaded.
 	static uint8_t f[2 + LZ_MAX_BINDINGS * 16];
-	// 0xA8, NOT 0xA7: the merge with main added bond-export on 0xA7, so the lizard map moved to the next
-	// free device->host marker (0xA5 status, 0xA6 capture, 0xA7 bond export, 0xA8 lizard map).
-	f[0] = 0xA8;
+	// 0xAA, NOT 0xA7/0xA8: main claimed 0xA7 (bond export) and 0xA8 (flight recorder), plus 0xA9 (wedge
+	// reporter) and 0xAB (fwup ack) -- 0xAA is the free device->host marker for the lizard map.
+	f[0] = 0xAA;
 	f[1] = count;
 	for (uint8_t i = 0; i < count; i++) {
 		const LizardBinding &b = g_lizardMap.bindings[i];
@@ -539,10 +569,10 @@ static void webusbDrainCapture()
 #endif // OPK_LOG
 void webusbPoll()
 {
-	// 40 B holds the largest command: 0x0D writes one bond slot = [0x0D][slot][used][24 rec] = 27 B (still
-	// inside one 64-B USB-FS OUT packet, so no command ever spans packets). The lizard "set binding" command
-	// (0x12) is 18 B; every other command is <= 4 B.
-	static uint8_t buf[40];
+	// 160 B holds the largest command: a firmware-update data chunk 0x21 = [op][off u32][len][<=128 data]
+	// = 134 B (spans USB-FS packets; the byte-wise accumulation below reassembles it). Next-largest are the
+	// 0x0D bond-slot write (27 B) and the 0x12 lizard set-binding (18 B); everything else is <= 9 B.
+	static uint8_t buf[160];
 	static uint8_t n = 0;
 	while (usb_web.available()) {
 		int c = usb_web.read();
@@ -555,22 +585,36 @@ void webusbPoll()
 			if (n == 0)
 				break;
 			uint8_t op = buf[0];
-			if (op < 0x01 || op > 0x15) { // resync: drop one byte
+			if ((op < 0x01 || op > 0x15) &&
+			    (op < 0x20 || op > 0x24)) { // resync: drop one byte
 				memmove(buf, buf + 1, --n);
 				continue;
 			}
+			// 0x21 (fwup data) carries its own length at [5]; sanity-gate it BEFORE trusting it as a
+			// command length so a corrupt byte can only cost a one-byte resync, never a stuck parser.
+			if (op == 0x21 && n >= 6) {
+				uint8_t dl = buf[5];
+				if (dl == 0 || dl > 128 || (dl & 3)) {
+					memmove(buf, buf + 1, --n);
+					continue;
+				}
+			}
 			// Command length (fixed per opcode). 0x0D = write-one-bond-slot (27 B); 0x12 = set-one-lizard-
 			// binding (18 B); 0x05/0x0E/0x0F/0x10/0x13 carry one value byte; 0x02 a field+value; 0x0A a
-			// 3-byte magic. (0x11/0x14/0x15 are bare opcodes -> default 1.)
-			uint8_t need = (op == 0x0D) ? 27 :
-				       (op == 0x12) ? 18 :
-				       (op == 0x02) ? 3 :
-				       (op == 0x03 || op == 0x05 ||
-					op == 0x0E || op == 0x0F ||
-					op == 0x10 || op == 0x13) ?
-						      2 :
-				       (op == 0x0A) ? 4 :
-						      1;
+			// 3-byte magic. Firmware update: 0x20 begin [size u32][crc32 u32], 0x21 data (6 B header +
+			// payload), 0x22/0x23/0x24 bare. (0x11/0x14/0x15 are bare lizard opcodes -> default 1.)
+			uint8_t need =
+				(op == 0x0D) ? 27 :
+				(op == 0x12) ? 18 :
+				(op == 0x02) ? 3 :
+				(op == 0x03 || op == 0x05 || op == 0x0E ||
+				 op == 0x0F || op == 0x10 || op == 0x13) ?
+					       2 :
+				(op == 0x0A) ? 4 :
+				(op == 0x20) ? 9 :
+				(op == 0x21) ? (uint8_t)(6 + (n >= 6 ? buf[5] :
+								       0)) :
+					       1;
 			if (n < need)
 				break; // wait for more bytes
 			if (op == 0x01) {
@@ -680,7 +724,7 @@ void webusbPoll()
 				// Renumbered to 0x11..0x15 on the merge with main: main already owns 0x0D (bond-slot
 				// write), 0x0E (import commit), 0x0F (stability test) and 0x10 (flight-recorder drain),
 				// which the pre-merge branch had collided with. The panel JS uses the same 0x11..0x15.
-				// 0x11: dump the current map as a 0xA7 frame.
+				// 0x11: dump the current map as a 0xAA frame.
 			} else if (op == 0x11) {
 				webusbSendLizard();
 
@@ -722,6 +766,40 @@ void webusbPoll()
 				saveLizardMap();
 				webusbSendLizard();
 
+				// 0x20..0x24: staged firmware update (see fw_update.h). Each op is acked with an 0xAB
+				// frame from the usbd task; the panel ping-pongs on those acks, so at most one command
+				// is ever in flight (also what bounds the flash-stall per loop() pass to one page erase).
+			} else if (op == 0x20) {
+				uint32_t sz = (uint32_t)buf[1] |
+					      ((uint32_t)buf[2] << 8) |
+					      ((uint32_t)buf[3] << 16) |
+					      ((uint32_t)buf[4] << 24);
+				uint32_t crc = (uint32_t)buf[5] |
+					       ((uint32_t)buf[6] << 8) |
+					       ((uint32_t)buf[7] << 16) |
+					       ((uint32_t)buf[8] << 24);
+				fwupAckPost(fwupBegin(sz, crc));
+			} else if (op == 0x21) {
+				uint32_t off = (uint32_t)buf[1] |
+					       ((uint32_t)buf[2] << 8) |
+					       ((uint32_t)buf[3] << 16) |
+					       ((uint32_t)buf[4] << 24);
+				fwupAckPost(fwupChunk(off, buf + 6, buf[5]));
+			} else if (op == 0x22) {
+				fwupAckPost(fwupEnd());
+
+				// 0x23: plain clean reboot. After a committed 0x22 the boot path applies the staged
+				// image; unarmed it is just a normal restart.
+			} else if (op == 0x23) {
+				usb_web.flush();
+				delay(40);
+				faultDiagArmIntentionalReset();
+				NVIC_SystemReset();
+
+			} else if (op == 0x24) {
+				fwupAbort();
+				fwupAckPost(FWUP_OK);
+
 			} else if (op == 0x02) {
 				uint8_t f = buf[1], v = buf[2];
 

+ 6 - 0
OpenPuck/webusb_config.h

@@ -5,7 +5,13 @@
 //               0x03 <mode>          switch USB mode (0..6): persist + reboot
 //               0x0B                 reboot into serial DFU (adafruit-nrfutil)
 //               0x0C                 reboot into UF2 bootloader (USB mass storage)
+//               0x20 <size u32> <crc32 u32>        firmware update: begin staging (disarms any previous)
+//               0x21 <off u32> <len> <data...>     firmware update: sequential chunk (len<=128, %4==0)
+//               0x22                 firmware update: verify staged image + COMMIT (applies on next reboot)
+//               0x23                 clean reboot (after 0x22: boots into the freshly staged firmware)
+//               0x24                 firmware update: abort/disarm
 //   dev->host:  0xA5 <len> <payload>  status blob (mode/tunables/link state/rates)
+//               0xAB 5 <status> <nextOff u32>  firmware-update ack, one per 0x20/0x21/0x22/0x24 (fw_update.h)
 // No setLandingPage() on purpose -- it would pop a Chrome "open <url>?" notification on every plug-in.
 #pragma once
 #include <Adafruit_TinyUSB.h>

+ 1 - 1
README.md

@@ -50,7 +50,7 @@ Additionally it has some shortcuts that might be useful: Steam + L5/R5 will do v
 and Steam + L4 ls Ctrl + Alt + Delete.
 
 # How to install/use it
-You will need to flash OpenPuck.ino onto a compatible microcontroller. The easiest way to do this is to grab a uf2 file from the GitHub releases and drag and drop it onto the folder that the microcontroller mounts when in DFU mode. Fresh microcontrollers should already be in DFU mode and present like a flash drive. If they are not in DFU mode you'll need to short the RST and GND pints twice in quick succession. If you've already flashed openpuck you can go to the [webusb configurator](https://safijari.github.io/openpuck/), connect to the OpenPuck, and then press the `UF2 DFU` button on the top bar.
+You will need to flash OpenPuck.ino onto a compatible microcontroller. The easiest way to do this is to grab a uf2 file from the GitHub releases and drag and drop it onto the folder that the microcontroller mounts when in DFU mode. Fresh microcontrollers should already be in DFU mode and present like a flash drive. If they are not in DFU mode you'll need to short the RST and GND pints twice in quick succession. If you've already flashed openpuck you can update it straight from the [webusb configurator](https://safijari.github.io/openpuck/)'s **Firmware update** tab: pick a version from the built-in releases list (with an optional factory-reset variant), or drag and drop a `.uf2` — the firmware is sent to the puck over the same WebUSB connection, verified on-device, and applied automatically on a reboot. A failed or interrupted transfer leaves the running firmware untouched, and even a power cut during the apply just leaves the puck in its UF2 bootloader (drag-and-drop recovery) — it can't end up half-flashed. The `UF2 DFU` button still reboots into the mass-storage bootloader for manual drag-and-drop.
 
 See [build instructions document](./docs/BUILD_AND_DEPLOY.md) for  details on how to flash the MCU during development.
 

+ 21 - 0
ReversePuck/PUCK_CLONING.md

@@ -0,0 +1,21 @@
+# Puck cloning
+
+The Steam Controller has two different pairing slots that each contain an 8-byte key and the serial number of the paired puck. The Steam Puck has four pairing slots that each contain an 8-byte key and the serial number of the paired controller. 
+
+However, the serial numbers both on the Puck and on the Controller seem to only be used to display them to the user. The only thing that handles the pairing is the 8-byte key. 
+
+Usually, when pairing, Steam generates that key randomly, then writes it to the Puck and to the Controller. 
+
+With the script `scpair.py`, it is possible to read these pairing records both from the controller and from a Puck (Steam Puck or OpenPuck). Then, with the key known, you can pair one single controller slot to multiple Pucks, so they seamlessly switch to whichever Puck they can reach. 
+
+`scpair.py list` lists all pairings on the attached pucks, including the controller serial and the 8-byte pairing key. 
+
+Use `scpair.py write-controller --slot <0|1> --serial <puck-serial> --key <pairing-key>` to write the key to a controller.
+
+Use `scpair.py write-puck --slot <0|1|2|3> --serial <controller-serial> --key <pairing-key>` to write the key to a Puck. 
+
+For example, if you want to pair one controller to multiple different pucks, then just pair the controller to one Puck normally, then run `scpair.py list`. This will dump the pairing record from the controller pair. Note the controller serial and the key. 
+
+Then plug in another puck and run `scpair.py write-puck --serial <controller-serial> --key <pairing-key>`. Repeat that process with as many pucks as you like. 
+
+Now your controller will always choose any of the available pucks to connect to.

+ 109 - 5
ReversePuck/scpair.py

@@ -185,7 +185,7 @@ def read_slots(puck):
     return slots
 
 
-def pair(slot=None):
+def pair(slot=None, pkey=None):
     devs = enumerate_devices()
     pucks = sorted(devs["puck"], key=lambda d: d.node)
     ctrls = sorted(devs["ctrl"], key=lambda d: d.node)
@@ -203,7 +203,11 @@ def pair(slot=None):
     if slot >= len(pucks):
         raise SystemExit("slot %d has no puck control interface" % slot)
 
-    r = os.urandom(8)
+    if pkey is None: 
+        r = os.urandom(8)
+    else: 
+        r = pkey
+
     key = b"esb/bond\x00" if slot == 0 else b"esb/bond_2\x00"
     print("pairing puck %s slot %d <-> controller %s  key=%s" %
           (puck_serial, slot, ctrl_serial, r.hex()))
@@ -218,6 +222,74 @@ def pair(slot=None):
     ctrl.set_feature(1, 0x95, bytes([0x52, 0xAF, 0x27, 0xA4]))
     print("paired. controller rebooting into wireless — move the puck to your host.")
 
+def write_puck_slot(slot=None, ctrl_serial=None, pkey=None):
+    devs = enumerate_devices()
+    pucks = sorted(devs["puck"], key=lambda d: d.node)
+    if not pucks:
+        raise SystemExit("no puck (28DE:1304) control interface found")
+
+    if slot is None:
+        slots = read_slots(pucks)
+        slot = next((s["idx"] for s in slots if not s["used"]), 0)
+    if slot >= len(pucks):
+        raise SystemExit("slot %d has no puck control interface" % slot)
+
+    
+    if ctrl_serial is None and pkey is None: 
+        print("wiping puck slot %d" % (slot))
+        pucks[slot].set_feature(2, 0xA2, b"\x00" * 24)
+    else:
+        if pkey is None: 
+            raise SystemExit("No key provided!")
+        
+        print("writing to puck slot %d: controller %s key=%s" %
+          (slot, ctrl_serial, pkey.hex()))
+        # puck slot: 0xA2 [r1 r2][ctrl serial]
+        pucks[slot].set_feature(2, 0xA2, pkey + _ser16(ctrl_serial))
+
+    print("done")
+
+def write_controller_slot(slot=0, puck_serial=None, pkey=None):
+    devs = enumerate_devices()
+    ctrls = sorted(devs["ctrl"], key=lambda d: d.node)
+    if not ctrls:
+        raise SystemExit("no controller (28DE:1302) control interface found — plug the ReversePuck controller in")
+    
+    if slot not in [0, 1]:
+        raise SystemExit("invalid controller slot %d" % (slot))
+
+
+    ctrl = ctrls[0]
+
+    if pkey is None: 
+        r = os.urandom(8)
+    else: 
+        r = pkey
+
+    key = b"esb/bond\x00" if slot == 0 else b"esb/bond_2\x00"
+
+    if puck_serial is None and pkey is None: 
+        print("Wiping controller slot %d" % (slot))
+        try:
+            ctrl.set_feature(1, 0xEE, key + b"\x00" * 24)
+            ctrl.set_feature(1, 0xEF, key)
+            print("Slot wiped.")
+        except Exception:
+            print("Slot wipe failed.")
+        
+    else: 
+
+        print("Writing controller slot %d: puck serial %s key=%s" %
+          (slot, puck_serial, r.hex()))
+
+        # controller esb/bond: 0xEE [key\0][r1 r2][puck serial] + 0xEF [key\0]
+        ctrl.set_feature(1, 0xEE, key + r + _ser16(puck_serial))
+        ctrl.set_feature(1, 0xEF, key)
+        time.sleep(0.1)
+        # reboot controller into wireless mode (magic 0xA427AF52)
+        ctrl.set_feature(1, 0x95, bytes([0x52, 0xAF, 0x27, 0xA4]))
+        print("paired. controller rebooting into wireless — move the puck to your host.")
+
 
 def unpair(slot):
     devs = enumerate_devices()
@@ -232,10 +304,11 @@ def unpair(slot):
             ctrl.set_feature(1, 0xEE, key + b"\x00" * 24)
             ctrl.set_feature(1, 0xEF, key)
         except Exception:
-            pass
+            print("Slot wipe on controller failed.")
     print("cleared slot %d" % slot)
 
 
+
 def cmd_list():
     devs = enumerate_devices()
     pucks = sorted(devs["puck"], key=lambda d: d.node)
@@ -244,26 +317,57 @@ def cmd_list():
     if pucks:
         print("puck:", pucks[0].read_serial(2))
         for s in read_slots(pucks):
-            print("  slot %d: %s" % (s["idx"], (s["serial"] + " (bonded)") if s["used"] else "empty"))
+            if s["used"]:
+                print("  slot %d: %s (key %s)" % (s["idx"], s["serial"], s["rec"][:8].hex()))
+            else: 
+                print("  slot %d: Empty" % (s["idx"]))
     else:
         print("puck: (none)")
 
 
+def steam_key_check(key):
+    try: 
+        key = bytes.fromhex(key)
+        if len(key) == 8: 
+            return key
+    except: 
+        raise argparse.ArgumentTypeError("Invalid pairing key!")
+
+
 def main():
     ap = argparse.ArgumentParser(description="Linux hidraw steamless pairing")
     sub = ap.add_subparsers(dest="cmd", required=True)
     sub.add_parser("list")
     p = sub.add_parser("pair")
     p.add_argument("--slot", type=int, default=None)
+    p.add_argument("--key", type=steam_key_check, default=None)
     u = sub.add_parser("unpair")
     u.add_argument("--slot", type=int, required=True)
+
+    s = sub.add_parser("write-puck")
+    s.add_argument("--slot", type=int, default=None)
+    s.add_argument("--serial", type=str, default=None)
+    s.add_argument("--key", type=steam_key_check, default=None)
+
+    c = sub.add_parser("write-controller")
+    c.add_argument("--slot", type=int, default=None)
+    c.add_argument("--serial", type=str, default=None)
+    c.add_argument("--key", type=steam_key_check, default=None)
+
+
+    
     args = ap.parse_args()
     if args.cmd == "list":
         cmd_list()
     elif args.cmd == "pair":
-        pair(args.slot)
+        pair(args.slot, args.key)
     elif args.cmd == "unpair":
         unpair(args.slot)
+    elif args.cmd == "write-puck":
+        write_puck_slot(args.slot, args.serial, args.key)
+    elif args.cmd == "write-controller":
+        write_controller_slot(args.slot, args.serial, args.key)
+
 
 
 if __name__ == "__main__":

+ 25 - 0
docs/BUILD_AND_DEPLOY.md

@@ -149,6 +149,31 @@ Replace the port (`/dev/ttyACM0` / `COM5`) with the actual board port.
 
 > **Note:** The `.zip` package is generated automatically by `arduino-cli compile` when the Adafruit nRF52 core is used. If it is missing, ensure `adafruit-nrfutil` is installed and recompile.
 
+### Flashing from the WebUSB panel (no tools, no drag-and-drop)
+
+A board already running OpenPuck (status protocol v15+) can be updated entirely from the
+[WebUSB configurator](https://safijari.github.io/openpuck/)'s **Firmware update** tab: drag-and-drop a
+`.uf2` (or click to browse), or pick a version from the built-in **releases list** — each release offers the
+standard build or, via its checkbox, the `-factory-reset` build (wipes settings + pairing once on first
+boot). A blocking modal shows download/transfer/verify/apply progress and reports the old → new build once
+the puck reconnects. Under the hood the panel extracts the app image from the `.uf2` and streams it over the
+normal WebUSB connection into spare flash high in the app region (~15 s; the running firmware keeps working);
+the firmware CRC32-verifies what landed and commits a one-page "apply on reboot" record; a final automatic
+reboot copies staged→app from RAM (~5 s dark) and comes back up on the new firmware. See
+`OpenPuck/fw_update.h` for the design.
+
+> **Release downloads / the `firmware` branch:** GitHub's release-asset CDN sends no CORS headers, so the
+> browser cannot fetch release assets directly. The release workflow therefore mirrors every OpenPuck `.uf2`
+> onto the orphan **`firmware`** branch, and the panel downloads from
+> `raw.githubusercontent.com/safijari/openpuck/firmware/<asset>` (which is CORS-clean). If a release is
+> missing from the mirror, the panel falls back to opening the asset in a new tab for manual drag-and-drop.
+
+Failure safety: **nothing is armed until the staged image verifies in flash**, so a disconnect, error, or
+power cut during the transfer leaves the current firmware untouched. The apply step erases the app's vector
+page first and rewrites it last (first word dead-last), so even a power cut mid-apply leaves the board
+"app-less" — the resident UF2 bootloader then keeps it as the UF2BOOT drive for drag-and-drop recovery. A
+half-flashed, crash-looping state is not reachable.
+
 ## 6. Factory reset (erase persistent storage)
 
 Re-flashing firmware does **not** erase the board's internal LittleFS. The paired-controller bond (`bonds.bin`) and every saved setting (`cfg.bin`: USB mode, chord assignments, back-paddle map, mouse sensitivity) survive a fresh build and upload. To bring a board up in a truly clean state — a new unit, a hand-me-down with a stale bond, or a corrupted config — wipe the filesystem with one of:

+ 11 - 6
docs/PROTOCOL.md

@@ -377,15 +377,19 @@ Messages:
   - `0x0B` / `0x0C`: reboot into serial DFU / UF2 bootloader
   - `0x0D <slot> <used> <24-byte rec>`: write one bond slot into RAM — see §10.1
   - `0x0E <mode>`: commit imported bonds (+ apply mode, `0xFF` = unchanged), then reboot
-  - `0x11`: get the lizard binding map (device replies `0xA8`)
+  - `0x11`: get the lizard binding map (device replies `0xAA`)
   - `0x13 <count>`: begin a lizard-map edit — set the binding count
   - `0x12 <idx> <16-byte binding>`: set one lizard binding (see below)
-  - `0x14`: commit the edited lizard map to flash (device echoes `0xA8`)
-  - `0x15`: reset the lizard map to built-in defaults (device echoes `0xA8`)
+  - `0x14`: commit the edited lizard map to flash (device echoes `0xAA`)
+  - `0x15`: reset the lizard map to built-in defaults (device echoes `0xAA`)
+  - `0x20`–`0x24`: staged firmware update (begin/data/end/reboot/abort), acked with `0xAB` frames
 - Device to host:
   - `0xA5 <len> <payload>`: status blob
   - `0xA7 <len> <payload>`: bond export (§10.1)
-  - `0xA8 <count> <count×16-byte bindings>`: lizard binding map
+  - `0xA8 ...`: flight-recorder stream
+  - `0xA9 ...`: live wedge report
+  - `0xAA <count> <count×16-byte bindings>`: lizard binding map
+  - `0xAB 5 <status> <nextOff u32 LE>`: firmware-update ack
 
 Lizard binding wire format (16 bytes), matching `LizardBinding` in `lizard_map.h`:
 
@@ -396,8 +400,9 @@ Lizard binding wire format (16 bytes), matching `LizardBinding` in `lizard_map.h
 [12..15] holdMask u32 LE  (button bits: all-of guard)
 ```
 
-> Lizard-map commands and the `0xA8` reply were renumbered from `0x0D–0x11`/`0xA7` on the merge with
-> `main`, which had independently claimed `0x0D–0x10` and `0xA7`.
+> Lizard-map commands and the `0xAA` reply were renumbered from `0x0D–0x11`/`0xA7` on the merges with
+> `main`, which had independently claimed `0x0D–0x10`, `0xA7` (bond export) and `0xA8` (flight
+> recorder).
 
 Status blob payload:
 

+ 528 - 4
docs/index.html

@@ -35,6 +35,9 @@
   button:disabled{opacity:.4;cursor:default}
   .pill{display:inline-block;padding:2px 9px;border-radius:99px;font-size:12px;font-weight:600}
   .pill.up{background:#143626;color:var(--acc)} .pill.dn{background:#3a1822;color:var(--bad)}
+  /* update-available notice (topbar): :not(.hide) so this ID rule can't out-rank .hide (see #updModal) */
+  #updAvail:not(.hide){background:#14263a;color:var(--acc2);cursor:pointer;border:1px solid #2b4a6d}
+  #updAvail:hover{text-decoration:underline}
   .statgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(155px,1fr));gap:10px}
   .stat{background:#0e1017;border:1px solid #232a3a;border-radius:8px;padding:10px}
   .stat .k{color:var(--mut);font-size:11px;text-transform:uppercase;letter-spacing:.05em}
@@ -57,6 +60,39 @@
   }
   @media(prefers-reduced-motion:reduce){.slot-tab .dot.up{animation:none}}
   .per-slot-grid{margin-bottom:12px}
+
+  /* ---- top-level tabs (Controller / Firmware update) ---- */
+  #mainTabs{margin:0 0 16px}
+  #mainTabs .slot-tab{font-size:13px;padding:7px 18px}
+
+  /* ---- firmware update tab ---- */
+  .dropzone{border:2px dashed #36405a;border-radius:12px;padding:26px 18px;text-align:center;cursor:pointer;
+    color:var(--mut);transition:border-color .15s,background .15s;margin-bottom:14px}
+  .dropzone:hover,.dropzone.drag{border-color:var(--acc);color:var(--fg);background:#151b28}
+  .dropzone b{color:var(--fg)}
+  .rel-row{display:flex;align-items:center;gap:14px;padding:10px 2px;border-top:1px solid #232a3a;flex-wrap:wrap}
+  .rel-row:first-child{border-top:0}
+  .rel-row .ver{font-weight:700;min-width:70px}
+  .rel-row .date{color:var(--mut);font-size:12px;min-width:90px}
+  .rel-row .grow{flex:1}
+  .rel-row label{display:flex;align-items:center;gap:6px;color:var(--mut);font-size:12px;cursor:pointer}
+  .rel-row input[type=checkbox]{accent-color:var(--bad)}
+  /* firmware-tab gate: when the connected puck's firmware predates panel updates, both update cards go
+     inert (pointer-events kills clicks, drops, and the file picker) and the banner explains why */
+  .fwupcard.gated{opacity:.45;pointer-events:none;user-select:none}
+
+  /* ---- blocking update modal ---- */
+  /* :not(.hide) because this ID selector would otherwise out-rank .hide{display:none} and pin the modal open */
+  #updModal:not(.hide){position:fixed;inset:0;z-index:100;background:rgba(8,10,16,.8);display:flex;align-items:center;justify-content:center}
+  .updbox{background:var(--card);border:1px solid #36405a;border-radius:14px;padding:22px 26px;width:min(500px,92vw);box-shadow:0 18px 60px rgba(0,0,0,.5)}
+  .updbox h3{margin:0 0 4px;font-size:16px}
+  #updStage{color:var(--fg);margin:10px 0 0;font-size:14px}
+  .updbar{height:10px;background:#0e1017;border:1px solid #232a3a;border-radius:99px;overflow:hidden;margin:10px 0 6px}
+  #updFill{height:100%;width:0;background:var(--acc);transition:width .2s}
+  #updFill.pulse{background:repeating-linear-gradient(45deg,var(--acc2),var(--acc2) 12px,#38618c 12px,#38618c 24px);animation:updslide 1s linear infinite}
+  #updFill.bad{background:var(--bad)}
+  @keyframes updslide{to{background-position:34px 0}}
+  @media(prefers-reduced-motion:reduce){#updFill.pulse{animation:none}}
 </style>
 </head>
 <body>
@@ -64,6 +100,7 @@
   <span class="brand">OpenPuck <small>SC2 puck · WebUSB</small></span>
   <span id="connState" class="pill dn">disconnected</span>
   <button id="connectBtn" class="primary">Connect</button>
+  <span id="updAvail" class="pill hide" title="A newer OpenPuck release is available — click to open the Firmware update tab">…</span>
   <span class="spacer"></span>
   <button id="debugCdc" title="Reboot ONCE with the CDC serial console enabled (WebUSB goes away for that boot); auto-reverts on the next boot">Debug CDC</button>
   <button id="dfuSerial" title="Reboot into serial DFU (adafruit-nrfutil), then replug">Serial DFU</button>
@@ -74,6 +111,12 @@
   <p class="sub">First connect uses the Chrome/Edge device picker; reconnects are automatic. If the picker is empty, quit any app holding the device or replug.</p>
 
   <div id="panel" class="hide">
+    <div class="slot-tabs" id="mainTabs">
+      <span class="slot-tab active" data-tab="tabMain">Controller</span>
+      <span class="slot-tab" data-tab="tabUpdate">Firmware update</span>
+    </div>
+
+    <div id="tabMain">
     <div class="cards">
     <div class="card">
       <h2>USB mode</h2>
@@ -249,11 +292,54 @@
       <pre id="capOut" style="font:12px ui-monospace,Menlo,monospace;color:var(--fg);background:#0e1017;border:1px solid #232a3a;border-radius:8px;padding:10px;margin-top:10px;max-height:260px;overflow:auto;white-space:pre-wrap"></pre>
     </div>
     </div><!-- .cards -->
+    </div><!-- #tabMain -->
+
+    <div id="tabUpdate" class="hide">
+      <div id="updGate" class="card hide" style="border-color:#7a2233">
+        <h2 style="color:var(--bad)">Panel updates not supported by this firmware</h2>
+        <p class="note" id="updGateMsg" style="margin:0"></p>
+      </div>
+
+      <div class="card fwupcard">
+        <h2>Update from a local file</h2>
+        <input id="uf2File" class="hide" type="file" accept=".uf2,application/octet-stream">
+        <div id="uf2Drop" class="dropzone">
+          <div><b>Drop a .uf2 file here</b> — or click to browse</div>
+          <div class="note" id="uf2Sel" style="margin-top:6px">no file selected</div>
+        </div>
+        <div class="row" style="margin:0">
+          <button id="uf2Flash" class="primary" disabled title="Pick a .uf2 file first">Flash firmware</button>
+          <span class="note" style="flex:1;margin:0">The image is streamed over WebUSB into spare flash, verified <em>on the puck</em>, and applied on an automatic reboot. Nothing is armed until it verifies — a failed or interrupted transfer leaves the running firmware untouched, and even a power cut during the apply only leaves the puck in its UF2 bootloader for drag-and-drop recovery.</span>
+        </div>
+      </div>
+
+      <div class="card fwupcard">
+        <h2>Update from a release</h2>
+        <div class="row" style="margin-top:0">
+          <span class="note" style="flex:1;margin:0">Official builds from <a href="https://github.com/safijari/openpuck/releases" target="_blank" rel="noopener" style="color:var(--acc2)">github.com/safijari/openpuck/releases</a> &nbsp;·&nbsp; installed: <b id="updInstalled" style="color:var(--acc)">—</b></span>
+          <button id="relRefresh">Refresh</button>
+        </div>
+        <div id="relList" class="note" style="margin:0">loading…</div>
+        <p class="note"><b style="color:var(--bad)">Factory reset</b> flashes the <code>-factory-reset</code> build of that version: on its first boot it wipes ALL settings and the controller pairing (you must re-pair), then behaves like the standard build. Use it to recover from a bad config or stale bond.</p>
+      </div>
+    </div><!-- #tabUpdate -->
 
     <div id="log"></div>
   </div>
 </div>
 
+<!-- blocking modal: covers the whole page while an update is in flight so nothing else can be clicked -->
+<div id="updModal" class="hide">
+  <div class="updbox">
+    <h3 id="updTitle">Firmware update</h3>
+    <div id="updStage">…</div>
+    <div class="updbar"><div id="updFill"></div></div>
+    <div id="updPct" class="note" style="margin:0"></div>
+    <div id="updDetail" class="note"></div>
+    <div class="row" style="justify-content:flex-end;margin:14px 0 0"><button id="updClose" class="hide">Close</button></div>
+  </div>
+</div>
+
 <script>
 const MODE_NAMES = ["Steam (puck)","Xbox 360","Switch (HORIPAD)","Lizard (always)","Switch Pro + gyro","PS5 DualSense","HID gyro (DS4)","PS5 (game/clean)","DS4 (game/clean)","PS3 (DualShock 3)"];
 const CHORD_FIELD = [17, 18, 19];
@@ -262,6 +348,10 @@ let lastP=null; // most recent status-blob payload (config source for Export)
 // Stability test: armed = active across reconnects; stabStart = ms of the current run; stabLastRun = last
 // completed run {secs} (ended by a reset). Kept in-page so it survives the device reset (not a page refresh).
 let stabArmed=false, stabStart=0, stabLastRun=null;
+// Firmware update via the UF2 picker: selectedUf2 = {name, image} (image = the app binary extracted from the
+// .uf2 at pick time, so a bad file is rejected before anything is sent). dfuBusy blocks re-entry while an
+// update is in flight.
+let selectedUf2=null, dfuBusy=false;
 // Running hang log: one entry per reset (persists in-page across the device reset; a page refresh clears it).
 // pendingHang is set at disconnect and enriched with reason/PC/stack from the first blob after reconnect.
 let hangLog=[], pendingHang=null;
@@ -276,6 +366,18 @@ function renderHangLog(){
     + hangLog.map(e=>pad(e.time,11)+pad(e.uptime!=null?fmtDur(e.uptime):"-",9)+pad(e.reason,16)+pad(e.stage||"-",10)+pad(e.pc||"-",11)+(e.usbd!=null?e.usbd:"-")).join("\n");
 }
 function fmtDur(s){ s=Math.round(s); return s>=60 ? (Math.floor(s/60)+"m "+(s%60)+"s") : (s+"s"); }
+function updateUf2UI(){
+  const sel=$("#uf2Sel"), flash=$("#uf2Flash");
+  if(!sel || !flash) return;
+  sel.textContent = selectedUf2
+    ? selectedUf2.name+" — app image "+Math.round(selectedUf2.image.length/1024)+" KiB, parsed OK"
+      +(selectedUf2.updatable ? "" : " — ⚠ no panel-update support in this image (flashing it = drag-and-drop only afterwards)")
+    : "no file selected";
+  flash.disabled = dfuBusy || !selectedUf2;
+  flash.title = selectedUf2
+    ? "Send " + selectedUf2.name + " to the puck over WebUSB, verify it, and apply it on reboot"
+    : "Pick a .uf2 file first";
+}
 function updateStabUI(){
   const btn=$("#stabBtn"), st=$("#stabStatus");
   if(!btn) return;
@@ -523,6 +625,7 @@ async function openDevice(d){
     updateStabUI();
     await loadLizard(); // populate the lizard-map editor from the device (global disconnect listener is set once, below)
     startPolling();
+    loadReleases(false); // background, cached: feeds the update-available notice + pre-warms the update tab
     return true;
   }catch(e){ log("connect failed: "+e.message); dev=null; return false; }
 }
@@ -561,6 +664,7 @@ function onGone(){
   window._wedgeEp=false; window._wedgePeak=0; // re-arm the live wedge logger for the next wedge episode
   window._lastBlobTs=0; window._hbLostEp=false; // re-arm the heartbeat watchdog (don't fire on a stale pre-reset timestamp)
   polling=false; dev=null; tabInited=false; // re-default the config tab to the mode on next connect
+  checkUpdateNotice(); // no device = no notice
   $("#connState").textContent="reconnecting…"; $("#connState").className="pill dn";
   $("#panel").classList.add("hide");
   log("device disconnected (mode-switch / watchdog reboot) — auto-reconnecting when it returns");
@@ -801,6 +905,9 @@ function applyBlob(p){
   $("#stBuild").innerHTML = buildId
     ? (buildId + (gitDirty ? ' <span class="pill dn">dirty</span>' : ' <span class="pill up">clean</span>'))
     : "—";
+  $("#updInstalled").textContent = buildId || "—"; // mirrored on the Firmware-update tab
+  updateFwGate(); // (re)evaluate the firmware-tab gate on every blob — version is at p[0]
+  checkUpdateNotice(); // needs BOTH the installed version (this blob) and relCache (fetched on connect)
   setSlider("mDiv",mDiv); setSlider("mFric",mFric); setSlider("rumble",rumbleScale);
   $("#persistMode").textContent = persistMode? "on":"off"; $("#persistMode").classList.toggle("active",!!persistMode);
   // land-all-0x87 experiment toggle (blob v14, firmware p[177] -> JS p[175])
@@ -1120,8 +1227,9 @@ async function startPolling(){
 // ---- lizard map transfers ----
 // The status-blob poll and these map transfers share one bulk pipe, so we pause polling
 // (lizardBusy) and wait out any in-flight refresh before sending a 0x11/0x14/0x15 that
-// expects a 0xA8 reply. (Opcodes/frame renumbered from 0x0D../0xA7 on the merge with main,
-// which claimed 0x0D-0x10 for bond-import/stability/flight and 0xA7 for bond export.)
+// expects a 0xAA reply. (Opcodes/frame renumbered from 0x0D../0xA7 on the merge with main,
+// which claimed 0x0D-0x10 for bond-import/stability/flight, 0xA7 for bond export and
+// 0xA8 for the flight recorder -- 0xAA is the free device->host marker.)
 async function lizardExchange(fn){
   lizardBusy=true;
   for(let i=0;i<25 && inflight;i++) await new Promise(r=>setTimeout(r,20));
@@ -1129,7 +1237,7 @@ async function lizardExchange(fn){
   catch(e){ log("lizard err: "+e.message); return null; }
   finally{ lizardBusy=false; }
 }
-// Read one [0xA8][count][count*16] frame, accumulating transferIn packets until complete.
+// Read one [0xAA][count][count*16] frame, accumulating transferIn packets until complete.
 async function readLizard(){
   let acc=new Uint8Array(0);
   for(let guard=0; guard<64; guard++){
@@ -1137,7 +1245,7 @@ async function readLizard(){
     if(r.status!=="ok") break;
     const d=new Uint8Array(r.data.buffer);
     const m=new Uint8Array(acc.length+d.length); m.set(acc); m.set(d,acc.length); acc=m;
-    let i=0; while(i<acc.length && acc[i]!==0xA8) i++;
+    let i=0; while(i<acc.length && acc[i]!==0xAA) i++;
     if(i>0) acc=acc.slice(i);
     if(acc.length<2) continue;
     const count=acc[1], total=2+count*16;
@@ -1271,6 +1379,420 @@ function buildLizardRow(b, idx){
 
 // UI wiring
 $("#connectBtn").onclick=connect;
+// ---- Firmware update: UF2 file -> staged flash over WebUSB, applied on reboot ----
+// Flow: parse the .uf2 into the raw app image, stream it over THIS WebUSB connection (ops 0x20/0x21/0x22,
+// strict ping-pong on the firmware's 0xAB acks) into spare flash high in the puck's app region, let the
+// firmware CRC32-verify what actually landed and commit the one-page "apply on reboot" record, then send a
+// plain reboot (0x23) — the puck copies staged->app from RAM on the way up and re-enumerates on the new
+// firmware, where this panel auto-reconnects. Corruption safety (matches fw_update.h in the firmware):
+//   - nothing is armed until the staged bytes verify IN FLASH, so any failure/disconnect/power-cut during
+//     the transfer leaves the current firmware completely untouched;
+//   - the boot-time apply orders its writes so an interruption leaves the board app-less (UF2 bootloader
+//     mass-storage recovery), never a crash-looping half image.
+const UF2_MAGIC0=0x0A324655, UF2_MAGIC1=0x9E5D5157, UF2_MAGIC_END=0x0AB16F30, UF2_FAMILY=0xADA52840;
+const APP_BASE=0x26000, FWUP_MAX_IMG=0x60000; // nRF52840 app base; staging cap (fw_update.h FWUP_MAX_IMG)
+// Extract the contiguous application image from a .uf2 (gaps filled with 0xFF, length padded to a word).
+// Throws on anything that is not an nRF52840 app-region UF2 — the firmware re-checks everything, but a bad
+// file should fail at pick time, not after a reboot.
+function uf2ToImage(buf){
+  if(!buf.byteLength || buf.byteLength%512) throw new Error("not a UF2 (size is not a multiple of 512)");
+  const dv=new DataView(buf), blocks=[]; let lo=Infinity, hi=0;
+  for(let off=0; off<buf.byteLength; off+=512){
+    if(dv.getUint32(off,true)!==UF2_MAGIC0 || dv.getUint32(off+4,true)!==UF2_MAGIC1 || dv.getUint32(off+508,true)!==UF2_MAGIC_END)
+      throw new Error("bad UF2 block magic at offset "+off);
+    const flags=dv.getUint32(off+8,true), addr=dv.getUint32(off+12,true), len=dv.getUint32(off+16,true);
+    if(flags&0x00000001) continue; // NOT_MAIN_FLASH
+    if((flags&0x00002000) && dv.getUint32(off+28,true)!==UF2_FAMILY)
+      throw new Error("UF2 family 0x"+dv.getUint32(off+28,true).toString(16)+" is not nRF52840 (0xada52840)");
+    if(len<1 || len>476) throw new Error("bad UF2 payload size "+len);
+    blocks.push({addr,len,off}); lo=Math.min(lo,addr); hi=Math.max(hi,addr+len);
+  }
+  if(!blocks.length) throw new Error("UF2 contains no flash data");
+  if(lo!==APP_BASE) throw new Error("image base 0x"+lo.toString(16)+" is not the app region (0x26000) — not an OpenPuck app UF2");
+  if(hi-lo>FWUP_MAX_IMG) throw new Error("image is "+Math.round((hi-lo)/1024)+" KiB — over the 384 KiB staged-update cap; flash it via UF2 DFU + drag-and-drop");
+  const img=new Uint8Array((hi-lo+3)&~3).fill(0xFF); // firmware stages whole words
+  for(const b of blocks) img.set(new Uint8Array(buf,b.off+32,b.len), b.addr-lo);
+  return img;
+}
+// CRC32 (IEEE, reflected, init/xorout 0xFFFFFFFF) — must match crc32Step/crc32Flash in fw_update.cpp
+function crc32(u8){
+  let c=0xFFFFFFFF;
+  for(let i=0;i<u8.length;i++){
+    c^=u8[i];
+    for(let k=0;k<8;k++) c=(c>>>1)^(0xEDB88320&-(c&1));
+  }
+  return (~c)>>>0;
+}
+const u32le=v=>[v&0xFF,(v>>>8)&0xFF,(v>>>16)&0xFF,(v>>>24)&0xFF];
+const dfuSleep=ms=>new Promise(r=>setTimeout(r,ms));
+// Firmware built from this branch onward embeds this tag (fw_update.cpp FWUP_TAG). An image WITHOUT it
+// still flashes fine — but the puck it leaves behind can't do panel updates, so we warn before the
+// lock-yourself-out-to-drag-and-drop move (all releases up to 0.9.6 predate panel updates).
+function imagePanelUpdatable(image){
+  return new TextDecoder("latin1").decode(image).includes("OPK-FWUP-v1");
+}
+const FWUP_ERR=["ok","command out of sequence","image too big for this puck's free flash","offset resync",
+  "staged bytes failed CRC verify","staged image has no valid vector table"];
+// Ack reader ([0xAB][5][status][nextOff u32 LE]). One persistent transferIn at a time: a wait that times out
+// leaves its read PENDING for the next wait — issuing a second concurrent read would silently eat the data
+// the first one eventually resolves with. If one read carries several acks (a retried command double-acks),
+// only the LAST — the firmware's newest state — is returned, so stale acks collapse instead of accumulating.
+let fwupRead=null;
+async function fwupAckWait(timeoutMs){
+  const deadline=Date.now()+timeoutMs;
+  for(;;){
+    const remain=deadline-Date.now();
+    if(remain<=0) return null;
+    if(!fwupRead) fwupRead=dev.transferIn(epIn, 64);
+    const r=await Promise.race([fwupRead, dfuSleep(remain).then(()=>"timeout")]);
+    if(r==="timeout") return null;
+    fwupRead=null;
+    if(r.status!=="ok") continue;
+    const d=new Uint8Array(r.data.buffer, r.data.byteOffset, r.data.byteLength);
+    let ack=null;
+    for(let i=0;i+6<d.length;i++)
+      if(d[i]===0xAB && d[i+1]===5)
+        ack={status:d[i+2], off:(d[i+3]|(d[i+4]<<8)|(d[i+5]<<16)|(d[i+6]<<24))>>>0};
+    if(ack) return ack;
+  }
+}
+const fwupSend=cmd=>dev.transferOut(epOut, new Uint8Array(cmd));
+// send a control op (begin/end/abort — all idempotent on the firmware side) and wait; resend on timeout
+async function fwupCtl(cmd, timeoutMs, label){
+  for(let t=0;t<3;t++){
+    await fwupSend(cmd);
+    const a=await fwupAckWait(timeoutMs);
+    if(a) return a;
+  }
+  throw new Error("no response to "+label);
+}
+async function fwupRun(image){
+  const kb=Math.round(image.length/1024);
+  fwupRead=null; // never reuse a read pending against a previous session/device
+  // drain anything stale on the IN pipe (leftover ack from an aborted run, a late status blob)
+  while(await fwupAckWait(400)!==null){}
+  log("firmware update: staging "+kb+" KiB into the puck's spare flash — input may stutter briefly during page erases");
+  let a=await fwupCtl([0x20,...u32le(image.length),...u32le(crc32(image))],4000,"begin");
+  if(a.status) throw new Error("begin rejected: "+(FWUP_ERR[a.status]||("code "+a.status)));
+  // Chunk loop: send one chunk, then read acks until one shows progress. A TIMEOUT resends (covers a truly
+  // lost command); a stale no-progress ack (the surplus twin of a resent chunk) is read past WITHOUT
+  // resending, so retries can't snowball. The ack's nextOff is authoritative — the firmware skips duplicate
+  // chunks and re-acks, flash words are never written twice — so every path resynchronizes here.
+  let off=0, sends=0, lastShown=-1;
+  const maxSends=Math.ceil(image.length/128)*2+64;
+  while(off<image.length){
+    const len=Math.min(128,image.length-off);
+    await fwupSend([0x21,...u32le(off),len,...image.subarray(off,off+len)]);
+    if(++sends>maxSends) throw new Error("transfer not converging at offset "+off);
+    for(let reads=0;reads<8;reads++){
+      a=await fwupAckWait(2500);
+      if(a===null) break; // timeout: resend this chunk
+      if(a.status===3){ off=a.off; break; } // firmware says where it wants us — resend from there
+      if(a.status!==0) throw new Error("chunk rejected at offset "+off+": "+(FWUP_ERR[a.status]||("code "+a.status)));
+      if(a.off>off){ off=a.off; break; } // progress
+      // else: stale ack — keep reading, the real one is behind it
+    }
+    const pct=Math.floor(off*50/image.length)*2;
+    if(pct!==lastShown){ lastShown=pct; modalStage("Sending to the puck", pct); }
+  }
+  modalStage("Verifying on the puck", null); // firmware CRC32s the staged flash, then commits the record
+  a=await fwupCtl([0x22],8000,"verify+commit");
+  if(a.status) throw new Error("verify+commit rejected: "+(FWUP_ERR[a.status]||("code "+a.status)));
+  log("image staged + CRC-verified on the puck — update armed");
+  modalStage("Rebooting", null);
+  try{ await fwupSend([0x23]); }catch(e){} // device drops mid-call; that's the point
+  log("rebooting to apply: the puck goes dark ~5 s while the new firmware is written, then re-enumerates — the panel reconnects itself");
+}
+// ---- blocking modal: the ONLY interactive surface while an update runs (it covers everything else) ----
+function modalStage(txt,pct){
+  const f=$("#updFill"), p=$("#updPct");
+  $("#updStage").textContent=txt;
+  f.classList.remove("bad");
+  if(pct==null){ f.classList.add("pulse"); f.style.width="100%"; p.textContent=""; }
+  else{ f.classList.remove("pulse"); f.style.width=pct+"%"; p.textContent=pct+"%"; }
+}
+function modalOpen(title){
+  $("#updTitle").textContent=title;
+  $("#updDetail").textContent="";
+  $("#updClose").classList.add("hide"); // no way out mid-update: closing wouldn't stop the puck anyway
+  $("#updModal").classList.remove("hide");
+  modalStage("Preparing…",null);
+}
+function modalDone(ok,msg){
+  const f=$("#updFill");
+  f.classList.remove("pulse");
+  f.style.width="100%";
+  f.classList.toggle("bad",!ok);
+  $("#updStage").textContent = ok ? "Done" : "Update failed";
+  $("#updPct").textContent="";
+  $("#updDetail").textContent=msg;
+  $("#updClose").classList.remove("hide");
+}
+$("#updClose").onclick=()=>$("#updModal").classList.add("hide");
+
+function blobBuildId(p){
+  if(!p) return "";
+  let s=""; for(let i=39;i<51 && i<p.length && p[i];i++) s+=String.fromCharCode(p[i]);
+  return s;
+}
+// Update-available notice (topbar): shown when the connected puck reports a release-shaped build (x.y.z)
+// older than the newest non-prerelease on GitHub. Dev builds report a git hash, which doesn't parse — no
+// nagging about "updates" for firmware newer than any release. Click = jump to the Firmware update tab.
+function verTriple(s){
+  const m=/^(\d+)\.(\d+)\.(\d+)/.exec(s||"");
+  return m ? [+m[1],+m[2],+m[3]] : null;
+}
+function checkUpdateNotice(){
+  const el=$("#updAvail");
+  const inst=verTriple(blobBuildId(lastP));
+  const rel=(relCache||[]).find(r=>!r.prerelease && relAsset(r,false));
+  const latest=rel ? verTriple(rel.tag_name) : null;
+  const newer = !!(dev && inst && latest &&
+    (latest[0]>inst[0] || (latest[0]===inst[0] &&
+     (latest[1]>inst[1] || (latest[1]===inst[1] && latest[2]>inst[2])))));
+  el.classList.toggle("hide", !newer);
+  if(newer) el.textContent="⬆ update "+rel.tag_name+" available";
+}
+$("#updAvail").onclick=()=>{ document.querySelector('#mainTabs .slot-tab[data-tab="tabUpdate"]').click(); };
+
+// Firmware-tab gate: the connected puck must speak status v15+ (the 0x20..0x24 update ops) for ANY of the
+// update tab to be usable. Too old => banner explains + both cards go inert until a capable build is flashed
+// the manual way once.
+function updateFwGate(){
+  const ok = !!(lastP && lastP[0]>=15);
+  $("#updGate").classList.toggle("hide", ok);
+  if(!ok) $("#updGateMsg").textContent =
+    "This puck is running "+(blobBuildId(lastP)||"an unknown build")+" (status v"+(lastP?lastP[0]:"?")
+    +"), which predates panel updates (needs v15+), so updating from this page is disabled. One manual flash "
+    +"gets you back: click “UF2 DFU” in the top bar, then drag a panel-update-capable .uf2 onto the UF2BOOT "
+    +"drive it mounts. Every update after that happens right here.";
+  for(const c of document.querySelectorAll(".fwupcard")) c.classList.toggle("gated", !ok);
+}
+// after the apply-reboot: resolved true once the panel has reconnected AND received a fresh status blob
+async function waitReconnect(t0,ms){
+  const deadline=Date.now()+ms;
+  while(Date.now()<deadline){
+    if(dev && window._lastBlobTs>t0) return true;
+    await dfuSleep(400);
+  }
+  return false;
+}
+// One update end-to-end: pre-flight checks, confirm, modal up, image acquired (may download), streamed,
+// applied, reconnect observed. getImage runs INSIDE the modal so release downloads show progress there.
+async function runUpdate(title, confirmText, getImage){
+  if(dfuBusy) return;
+  if(!dev){ log("connect to the puck first — the update travels over this WebUSB connection"); return; }
+  if(!lastP || lastP[0]<15){
+    log("this puck's firmware ("+(blobBuildId(lastP)||"?")+", status v"+(lastP?lastP[0]:"?")+") predates panel updates (needs v15+) — flash a panel-update-capable build once via UF2 DFU + drag-and-drop, then this works");
+    return;
+  }
+  if(!confirm(confirmText)) return;
+  dfuBusy=true; updateUf2UI(); modalOpen(title);
+  polling=false;        // the IN pipe belongs to the update acks now
+  await dfuSleep(800);  // let an in-flight status poll finish its read first
+  const t0=Date.now(), oldBuild=blobBuildId(lastP);
+  try{
+    const image=await getImage();
+    if(!imagePanelUpdatable(image) &&
+       !confirm("⚠ DOWNGRADE WARNING\n\nThis image predates panel updates (releases up to 0.9.6 do). It will "
+        +"flash fine, but the puck it leaves behind CANNOT be updated from this panel — getting off it again "
+        +"means UF2 DFU + drag-and-drop.\n\nFlash it anyway?"))
+      throw new Error("cancelled — the selected image doesn't support panel updates");
+    await fwupRun(image);
+    modalStage("Applying on the puck",null);
+    $("#updDetail").textContent="the puck goes dark ~5 s while the new firmware is written, then re-enumerates";
+    if(await waitReconnect(t0,40000)){
+      const nb=blobBuildId(lastP);
+      modalDone(true,"Update applied — the puck is back and running build "+(nb||"?")+(oldBuild&&nb!==oldBuild?" (was "+oldBuild+")":""));
+    }else{
+      modalDone(false,"The update was sent and armed, but the puck didn't reconnect within 40 s. Unplug/replug it. "
+        +"If it mounts as a UF2BOOT drive, drag the .uf2 onto the drive to recover — the bootloader is intact.");
+    }
+  }catch(e){
+    log("firmware update FAILED: "+e.message);
+    modalDone(false,e.message+" — nothing was applied; the running firmware is untouched.");
+    try{ await fwupSend([0x24]); }catch(_e){} // best-effort disarm
+    if(dev) startPolling();
+  }finally{
+    dfuBusy=false; updateUf2UI();
+  }
+}
+
+// ---- local-file source: drag-and-drop or file picker ----
+async function pickUf2(f){
+  try{
+    const image=uf2ToImage(await f.arrayBuffer());
+    selectedUf2={name:f.name, image, updatable:imagePanelUpdatable(image)};
+    log("UF2 ready: "+f.name+" — app image "+Math.round(image.length/1024)+" KiB, CRC32 0x"+crc32(image).toString(16).padStart(8,"0"));
+  }catch(err){
+    selectedUf2=null;
+    log("UF2 rejected: "+err.message);
+  }
+  updateUf2UI();
+}
+$("#uf2File").onchange=async(e)=>{
+  const f=e.target.files && e.target.files[0];
+  e.target.value=""; // allow re-picking the same filename after a rebuild
+  if(f) await pickUf2(f);
+};
+// a file dropped anywhere the dropzone isn't listening (e.g. while gated) must not navigate the page to it
+window.addEventListener("dragover",e=>e.preventDefault());
+window.addEventListener("drop",e=>e.preventDefault());
+{
+  const dz=$("#uf2Drop");
+  dz.onclick=()=>$("#uf2File").click();
+  dz.ondragover=e=>{ e.preventDefault(); dz.classList.add("drag"); };
+  dz.ondragleave=()=>dz.classList.remove("drag");
+  dz.ondrop=async e=>{
+    e.preventDefault(); dz.classList.remove("drag");
+    const f=e.dataTransfer.files && e.dataTransfer.files[0];
+    if(f) await pickUf2(f);
+  };
+}
+$("#uf2Flash").onclick=()=>{
+  if(!selectedUf2) return;
+  runUpdate("Flashing "+selectedUf2.name,
+    "Flash "+selectedUf2.name+" ("+Math.round(selectedUf2.image.length/1024)+" KiB)?\n\n"
+    +"The image is sent over WebUSB into spare flash (~15 s), verified on the puck, and applied on an automatic "
+    +"reboot (~5 s dark). Nothing is armed until it verifies, so a failed or interrupted transfer leaves the "
+    +"current firmware untouched. Worst case (power cut during the apply itself) the puck comes back as the "
+    +"UF2BOOT drive for drag-and-drop recovery — it cannot end up half-flashed.",
+    async()=>selectedUf2.image);
+};
+
+// ---- release source: list from the GitHub API, binaries from the CORS-reachable firmware mirror ----
+// api.github.com sends Access-Control-Allow-Origin:* but release-asset downloads redirect to a CDN that does
+// NOT, so the release workflow mirrors every OpenPuck .uf2 onto the repo's orphan `firmware` branch and the
+// panel fetches it from raw.githubusercontent.com (which is CORS-clean). Fallbacks: the API asset endpoint
+// (in case GitHub ever fixes its CDN), then a plain new-tab download + the local-file card.
+const REL_REPO="safijari/openpuck";
+let relCache=null;
+// firmware-mirror manifest: asset name -> {panelUpdate}. Written by the release workflow, which scans each
+// .uf2 for the FWUP_TAG capability string. null = manifest unreachable (then releases just go unbadged; the
+// byte-scan warning at flash time still catches an unsupported image).
+let relManifest=null;
+// tri-state: true = a puck running this release can be panel-updated, false = it cannot (drag-and-drop only
+// to get off it), null = unknown (no manifest)
+function relPanelUpdatable(rel){
+  if(!relManifest) return null;
+  const known=[relAsset(rel,false),relAsset(rel,true)].filter(a=>a && relManifest[a.name]);
+  if(!known.length) return null; // not mirrored (yet) — unknown, don't badge (mirror step lags a fresh release by ~a minute)
+  return known.some(a=>relManifest[a.name].panelUpdate);
+}
+async function downloadAsset(asset){
+  modalStage("Downloading "+asset.name,0);
+  let resp=null;
+  try{
+    const r=await fetch("https://raw.githubusercontent.com/"+REL_REPO+"/firmware/"+asset.name,{cache:"no-store"});
+    if(r.ok) resp=r;
+  }catch(e){}
+  if(!resp){
+    try{
+      const r=await fetch(asset.url,{headers:{Accept:"application/octet-stream"}});
+      if(r.ok) resp=r;
+    }catch(e){}
+  }
+  if(!resp){
+    window.open(asset.browser_download_url,"_blank","noopener");
+    throw new Error(asset.name+" isn't on the firmware mirror yet and GitHub's asset CDN blocks in-page downloads. "
+      +"It's downloading in a new tab instead — drop the file on the local-file card above.");
+  }
+  const total=+resp.headers.get("content-length") || asset.size || 0;
+  const reader=resp.body.getReader(), parts=[]; let got=0;
+  for(;;){
+    const {done,value}=await reader.read();
+    if(done) break;
+    parts.push(value); got+=value.length;
+    if(total) modalStage("Downloading "+asset.name, Math.min(99,Math.floor(got*100/total)));
+  }
+  const buf=new Uint8Array(got); let o=0;
+  for(const p of parts){ buf.set(p,o); o+=p.length; }
+  return uf2ToImage(buf.buffer);
+}
+function relAsset(rel,factory){
+  const suffix=factory ? "factory-reset" : "standard";
+  return rel.assets.find(a=>a.name==="OpenPuck-"+rel.tag_name+"-"+suffix+".uf2")
+      || rel.assets.find(a=>new RegExp("^OpenPuck-.*-"+suffix+"\\.uf2$").test(a.name)) || null;
+}
+function flashRelease(rel,factory){
+  const asset=relAsset(rel,factory);
+  if(!asset){ log("release "+rel.tag_name+" has no "+(factory?"factory-reset":"standard")+" .uf2 asset"); return; }
+  const kb=Math.round((asset.size||0)/1024);
+  runUpdate("Updating to "+rel.tag_name+(factory?" (factory reset)":""),
+    "Update the puck to "+rel.tag_name+(factory
+      ? " using the FACTORY RESET build?\n\nOn its first boot it wipes ALL settings and the controller pairing — you must re-pair the controller afterwards."
+      : "?")
+    +"\n\n"+asset.name+" ("+kb+" KiB) is downloaded, sent over WebUSB, verified on the puck, and applied on an "
+    +"automatic reboot. A failed or interrupted transfer leaves the current firmware untouched.",
+    ()=>downloadAsset(asset));
+}
+function renderReleases(){
+  const list=$("#relList");
+  list.textContent="";
+  let shown=0;
+  for(const rel of relCache){
+    const std=relAsset(rel,false), fac=relAsset(rel,true);
+    if(!std && !fac) continue;
+    shown++;
+    const row=document.createElement("div"); row.className="rel-row";
+    const ver=document.createElement("span"); ver.className="ver"; ver.textContent=rel.tag_name;
+    const date=document.createElement("span"); date.className="date";
+    date.textContent=(rel.published_at||"").slice(0,10)+(rel.prerelease?" · pre":"");
+    const name=document.createElement("span"); name.className="grow note"; name.style.margin="0";
+    name.textContent=rel.name && rel.name!==rel.tag_name ? rel.name : "";
+    const lab=document.createElement("label");
+    const cb=document.createElement("input"); cb.type="checkbox"; cb.disabled=!fac;
+    lab.appendChild(cb); lab.appendChild(document.createTextNode("factory reset"));
+    lab.title=fac ? "Flash the -factory-reset build: wipes settings + pairing once on first boot"
+                  : "this release has no factory-reset build";
+    const btn=document.createElement("button"); btn.textContent="Flash "+rel.tag_name;
+    btn.onclick=()=>flashRelease(rel,cb.checked);
+    row.append(ver,date,name);
+    if(relPanelUpdatable(rel)===false){
+      const pill=document.createElement("span"); pill.className="pill dn";
+      pill.textContent="no panel updates";
+      pill.title="This build predates panel-update support: it flashes fine from here, but a puck running it "
+        +"can only be updated again via UF2 DFU + drag-and-drop.";
+      row.appendChild(pill);
+    }
+    row.append(lab,btn);
+    list.appendChild(row);
+  }
+  if(!shown) list.textContent="no releases with OpenPuck .uf2 assets found";
+}
+async function loadReleases(force){
+  if(relCache && !force) return;
+  const list=$("#relList");
+  list.textContent="loading…";
+  try{
+    const [r,mr]=await Promise.all([
+      fetch("https://api.github.com/repos/"+REL_REPO+"/releases?per_page=15",
+            {headers:{Accept:"application/vnd.github+json"}}),
+      fetch("https://raw.githubusercontent.com/"+REL_REPO+"/firmware/manifest.json",{cache:"no-store"})
+        .catch(()=>null),
+    ]);
+    if(!r.ok) throw new Error("GitHub API "+r.status);
+    try{ relManifest=(mr && mr.ok) ? await mr.json() : null; }catch(e){ relManifest=null; }
+    relCache=(await r.json()).filter(x=>!x.draft);
+    renderReleases();
+    checkUpdateNotice(); // the connect-time blob usually lands before this fetch does
+  }catch(e){
+    list.innerHTML='could not load releases ('+e.message.replace(/</g,"&lt;")
+      +') — <a href="https://github.com/'+REL_REPO+'/releases" target="_blank" rel="noopener" style="color:var(--acc2)">open the releases page</a> and use the local-file card';
+    relCache=null;
+  }
+}
+$("#relRefresh").onclick=()=>loadReleases(true);
+
+// ---- top-level tabs ----
+for(const t of document.querySelectorAll("#mainTabs .slot-tab")){
+  t.onclick=()=>{
+    for(const x of document.querySelectorAll("#mainTabs .slot-tab")) x.classList.toggle("active",x===t);
+    $("#tabMain").classList.toggle("hide", t.dataset.tab!=="tabMain");
+    $("#tabUpdate").classList.toggle("hide", t.dataset.tab!=="tabUpdate");
+    if(t.dataset.tab==="tabUpdate") loadReleases(false); // lazy: first visit fetches the list
+  };
+}
 $("#backupExport").onclick=exportBackup;
 $("#backupImport").onclick=()=>$("#backupFile").click();
 $("#backupFile").onchange=async(e)=>{ const f=e.target.files[0]; if(f) await importBackup(f); e.target.value=""; };
@@ -1344,6 +1866,8 @@ for(const b of document.querySelectorAll(".modebtn")){
     const msg="Switch to "+MODE_NAMES[m]+"? The copycat will reboot."+(clean?"\n\nNOTE: the game/clean PlayStation modes (and PS3) drop WebUSB + host-wake, so THIS PANEL WILL DISCONNECT and can't reach the device while it's in this mode. To get back, chord on the controller: hold back-paddle 4 + A to return to Steam mode.":"");
     if(confirm(msg)){ send([0x03, m]); log("mode switch requested — device will reboot"); } };
 }
+updateUf2UI();
+updateFwGate(); // start gated: stays inert until the first status blob proves the firmware speaks v15+
 if(!("usb" in navigator)) $("#connectBtn").outerHTML='<b style="color:var(--bad)">WebUSB not supported — use Chrome or Edge.</b>';
 else autoConnect(); // reopen an already-authorized puck on load, no picker (first-ever connect still needs the button)
 </script>