瀏覽代碼

Merge branch 'main' into copilot/mouse-smoothness-issue

Jari 3 月之前
父節點
當前提交
ad3169f521

+ 48 - 0
.github/workflows/release.yml

@@ -203,3 +203,51 @@ jobs:
             - `.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

+ 15 - 0
OpenPuck/OpenPuck.ino

@@ -32,12 +32,14 @@ using namespace Adafruit_LittleFS_Namespace;
 #include "puck_hid.h" // puckCmdLogDrain()
 #include "rf_diag.h"
 #include "webusb_config.h"
+#include "lizard_map.h"
 #include "serial_console.h"
 #include "wake_hid.h"
 #include "status_led.h"
 #include "usb_mount.h"
 #include "identity.h"
 #include "fault_diag.h"
+#include "fw_update.h"
 #include "usb_tx.h"
 #include <stdio.h>
 
@@ -99,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();
 
@@ -113,6 +119,15 @@ void setup()
 #endif
 	loadCfg();
 	loadBonds();
+	// Lizard (desktop) keyboard/mouse binding table. Custom remapping applies ONLY in pure
+	// MODE_LIZARD: there we install the user's saved/editable map. In every other mode the
+	// seamless lizard (Steam mode with Steam closed) uses the built-in DEFAULT map, so edits
+	// made for pure lizard never change Steam-mode desktop behavior. (Without either call
+	// g_lizardMap.count stays 0 and lizard produces no keyboard/mouse output at all.)
+	if (g_usbMode == MODE_LIZARD)
+		loadLizardMap();
+	else
+		defaultLizardMap();
 	// regenerate per-slot session addresses from each bond UUID (deterministic, stable across reboots)
 	for (int s = 0; s < NSLOT; s++)
 		if (g_slot[s].used)

+ 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);

+ 155 - 0
OpenPuck/lizard_map.cpp

@@ -0,0 +1,155 @@
+#include "lizard_map.h"
+#include <Adafruit_LittleFS.h>
+#include <InternalFileSystem.h>
+#include <string.h>
+#include <class/hid/hid.h>
+using namespace Adafruit_LittleFS_Namespace;
+
+LizardMap g_lizardMap;
+
+#define LZ_FILE "/lizard_map.bin"
+#define LZ_MAGIC 0xB1u
+#define LZ_VERSION 1u
+
+// KB modifier bits (from TinyUSB hid.h)
+#define KM_LCTRL 0x01u
+#define KM_LSHIFT 0x02u
+#define KM_LALT 0x04u
+#define KM_LGUI 0x08u
+
+// Helper: append a binding to g_lizardMap
+static void addBind(uint8_t type, const uint8_t *od7, uint32_t trig,
+		    uint32_t hold)
+{
+	if (g_lizardMap.count >= LZ_MAX_BINDINGS)
+		return;
+	LizardBinding &b = g_lizardMap.bindings[g_lizardMap.count++];
+	b.outType = type;
+	for (int i = 0; i < 7; i++)
+		b.outData[i] = od7[i];
+	b.trigMask = trig;
+	b.holdMask = hold;
+}
+
+static inline void addAxis(uint8_t src, uint8_t gyroAct)
+{
+	uint8_t d[7] = { src, gyroAct, 0, 0, 0, 0, 0 };
+	addBind(LZ_OUT_MOUSE_AXIS, d, 0, 0);
+}
+static inline void addScroll(uint8_t src)
+{
+	uint8_t d[7] = { src, 0, 0, 0, 0, 0, 0 };
+	addBind(LZ_OUT_SCROLL, d, 0, 0);
+}
+static inline void addMouseBtn(uint8_t btn, uint32_t trig, uint32_t hold)
+{
+	uint8_t d[7] = { btn, 0, 0, 0, 0, 0, 0 };
+	addBind(LZ_OUT_MOUSE_BTN, d, trig, hold);
+}
+static inline void addKey(uint8_t mod, uint8_t k0, uint32_t trig, uint32_t hold)
+{
+	uint8_t d[7] = { mod, k0, 0, 0, 0, 0, 0 };
+	addBind(LZ_OUT_KBD_CHORD, d, trig, hold);
+}
+static inline void addKey3(uint8_t mod, uint8_t k0, uint8_t k1, uint8_t k2,
+			   uint32_t trig, uint32_t hold)
+{
+	uint8_t d[7] = { mod, k0, k1, k2, 0, 0, 0 };
+	addBind(LZ_OUT_KBD_CHORD, d, trig, hold);
+}
+static inline void addConsumer(uint8_t bits, uint32_t trig, uint32_t hold)
+{
+	uint8_t d[7] = { bits, 0, 0, 0, 0, 0, 0 };
+	addBind(LZ_OUT_CONSUMER, d, trig, hold);
+}
+
+void defaultLizardMap()
+{
+	g_lizardMap.count = 0;
+
+	// Analog sources (always active; no trigger mask)
+	addAxis(LZ_MSRC_RPAD, LZ_GYRO_ALWAYS); // right pad → mouse
+	addScroll(LZ_MSRC_LPAD); // left pad → scroll
+
+	// Mouse buttons
+	// right pad click → left mouse
+	addMouseBtn(1, 0x400000u /*TB_RPADC*/, 0);
+	// right trigger (digital click) → left mouse
+	addMouseBtn(1, 0x800000u /*TB_R2*/, 0);
+	// left trigger → right mouse
+	addMouseBtn(2, 0x8000000u /*TB_L2*/, 0);
+	// left pad click → middle mouse
+	addMouseBtn(4, 0x4000000u /*TB_LPADC*/, 0);
+
+	// Hold-modifier shortcuts (Steam-button combos: place BEFORE generic X/L4 so they consume first)
+	// Steam+L4 → Ctrl+Alt+Delete
+	addKey3(KM_LCTRL | KM_LALT, HID_KEY_DELETE, 0, 0, 0x20000u /*TB_L4*/,
+		0x10000u /*TB_STEAM*/);
+	// Steam+X → Win+Ctrl+O (on-screen keyboard)
+	addKey3(KM_LGUI | KM_LCTRL, HID_KEY_O, 0, 0, 0x4u /*TB_X*/,
+		0x10000u /*TB_STEAM*/);
+	// Steam+L5 → volume down (consumer bit1=0x02)
+	addConsumer(0x02, 0x40000u /*TB_L5*/, 0x10000u /*TB_STEAM*/);
+	// Steam+R5 → volume up (consumer bit0=0x01)
+	addConsumer(0x01, 0x100u /*TB_R5*/, 0x10000u /*TB_STEAM*/);
+
+	// Keyboard keys
+	addKey(0, HID_KEY_ENTER, 0x1u /*TB_A*/, 0);
+	addKey(0, HID_KEY_ESCAPE, 0x2u /*TB_B*/, 0);
+	addKey(0, HID_KEY_PAGE_UP, 0x4u /*TB_X*/, 0);
+	addKey(0, HID_KEY_PAGE_DOWN, 0x8u /*TB_Y*/, 0);
+	addKey(0, HID_KEY_TAB, 0x40u /*TB_VIEW*/, 0);
+	addKey(0, HID_KEY_ESCAPE, 0x4000u /*TB_MENU*/, 0);
+
+	// D-pad → arrow keys
+	addKey(0, HID_KEY_ARROW_UP, 0x2000u /*TB_DUP*/, 0);
+	addKey(0, HID_KEY_ARROW_DOWN, 0x400u /*TB_DDN*/, 0);
+	addKey(0, HID_KEY_ARROW_LEFT, 0x1000u /*TB_DLF*/, 0);
+	addKey(0, HID_KEY_ARROW_RIGHT, 0x800u /*TB_DRT*/, 0);
+
+	// Left stick → arrow keys (virtual deflection bits)
+	addKey(0, HID_KEY_ARROW_UP, LZ_BTN_LSTICK_UP, 0);
+	addKey(0, HID_KEY_ARROW_DOWN, LZ_BTN_LSTICK_DN, 0);
+	addKey(0, HID_KEY_ARROW_LEFT, LZ_BTN_LSTICK_LF, 0);
+	addKey(0, HID_KEY_ARROW_RIGHT, LZ_BTN_LSTICK_RT, 0);
+
+	// Shoulder buttons → modifier keys only (no keycode)
+	addKey(KM_LCTRL, 0, 0x80000u /*TB_LB*/, 0);
+	addKey(KM_LALT, 0, 0x200u /*TB_RB*/, 0);
+}
+
+void saveLizardMap()
+{
+	InternalFS.remove(LZ_FILE);
+	File f(InternalFS);
+	if (!f.open(LZ_FILE, FILE_O_WRITE))
+		return;
+	uint8_t hdr[3] = { LZ_MAGIC, LZ_VERSION, g_lizardMap.count };
+	f.write(hdr, 3);
+	f.write((uint8_t *)g_lizardMap.bindings,
+		g_lizardMap.count * sizeof(LizardBinding));
+	f.close();
+}
+
+void loadLizardMap()
+{
+	File f(InternalFS);
+	if (f.open(LZ_FILE, FILE_O_READ)) {
+		uint8_t hdr[3] = { 0, 0, 0 };
+		if (f.read(hdr, 3) == 3 && hdr[0] == LZ_MAGIC &&
+		    hdr[1] == LZ_VERSION && hdr[2] <= LZ_MAX_BINDINGS) {
+			uint8_t cnt = hdr[2];
+			g_lizardMap.count = 0;
+			int got = f.read((uint8_t *)g_lizardMap.bindings,
+					 cnt * sizeof(LizardBinding));
+			if (got == (int)(cnt * sizeof(LizardBinding)))
+				g_lizardMap.count = cnt;
+		}
+		f.close();
+	}
+	// If nothing loaded, install + persist defaults
+	if (g_lizardMap.count == 0) {
+		defaultLizardMap();
+		saveLizardMap();
+	}
+}

+ 72 - 0
OpenPuck/lizard_map.h

@@ -0,0 +1,72 @@
+// lizard_map.h -- configurable input→keyboard/mouse/consumer binding table for lizard (desktop)
+// mode.
+//
+// g_lizardMap replaces the hardcoded button→key map in mode_lizard.cpp with a runtime-configurable
+// list of LizardBinding entries. Each binding maps a trigger condition (button bitmask + optional
+// hold-modifier bitmask) to a single output action. rfLizard() walks the table every input frame.
+//
+// Designed for forward compatibility: the output union can gain a GAMEPAD variant to remap emulated-
+// controller buttons without changing the trigger model, storage format, or WebUSB protocol.
+#pragma once
+#include <stdint.h>
+
+// ---- output type (LizardBinding::outType) ----
+// 0 = disabled; skip this entry.
+#define LZ_OUT_NONE 0u
+// Keyboard chord: outData[0]=modifier bits, outData[1..6]=HID keycodes (up to 6, zero=unused).
+#define LZ_OUT_KBD_CHORD 1u
+// Mouse button: outData[0]=button bitmask (bit0=left, bit1=right, bit2=middle).
+#define LZ_OUT_MOUSE_BTN 2u
+// Mouse axis: outData[0]=source (LZ_MSRC_*), outData[1]=gyro activation (LZ_GYRO_*).
+#define LZ_OUT_MOUSE_AXIS 3u
+// Scroll wheel from an analog source: outData[0]=source (LZ_MSRC_*).
+#define LZ_OUT_SCROLL 4u
+// Consumer control (edge-triggered, MODE_LIZARD only): outData[0]=report bitmask (bit0=vol+, bit1=vol-).
+#define LZ_OUT_CONSUMER 5u
+
+// ---- analog source (outData[0] for MOUSE_AXIS and SCROLL) ----
+#define LZ_MSRC_RPAD 0u // right trackpad (MOUSE_AXIS; touch-gated by TB_RPADT)
+#define LZ_MSRC_LSTICK 1u // left analog stick (MOUSE_AXIS)
+#define LZ_MSRC_GYRO \
+	2u // gyroscope (MOUSE_AXIS; activation-gated, see LZ_GYRO_*)
+#define LZ_MSRC_LPAD 0u // left trackpad (SCROLL; touch-gated by TB_LPADT)
+
+// ---- gyro activation condition (outData[1] when src=LZ_MSRC_GYRO) ----
+#define LZ_GYRO_ALWAYS 0u // gyro always drives mouse
+#define LZ_GYRO_RPAD 1u // only when right trackpad is touched
+#define LZ_GYRO_LSTICK 2u // only when left stick is deflected past threshold
+// only when all bits in LizardBinding::holdMask are held
+#define LZ_GYRO_BTN 3u
+
+// ---- virtual stick-deflection trigger bits (usable in trigMask / holdMask) ----
+// The SC2 controller never sets bits 28..31 in its 0x45 report; rfLizard ORs these in after
+// reading the physical button word so bindings can fire on stick deflection.
+#define LZ_BTN_LSTICK_RT 0x10000000u // lx >  12000
+#define LZ_BTN_LSTICK_LF 0x20000000u // lx < -12000
+#define LZ_BTN_LSTICK_DN 0x40000000u // ly < -12000
+#define LZ_BTN_LSTICK_UP 0x80000000u // ly >  12000
+
+// ---- binding (16 bytes, naturally 4-byte aligned) ----
+// trigMask: (buttons & trigMask) != 0 activates the binding.
+// holdMask: ALL bits must also be set at the same time (AND-guard).
+// MOUSE_AXIS and SCROLL bindings ignore trigMask/holdMask; the analog source drives them.
+struct LizardBinding {
+	uint8_t outType; // LZ_OUT_*
+	uint8_t outData[7]; // type-specific payload (see constants above)
+	uint32_t trigMask; // physical/virtual button bits: any-of (OR)
+	uint32_t holdMask; // physical/virtual button bits: all-of (AND guard)
+};
+
+#define LZ_MAX_BINDINGS 32u
+
+struct LizardMap {
+	uint8_t count;
+	LizardBinding bindings[LZ_MAX_BINDINGS];
+};
+
+extern LizardMap g_lizardMap;
+
+// fill g_lizardMap with defaults (does NOT save to flash)
+void defaultLizardMap();
+void loadLizardMap();
+void saveLizardMap();

+ 207 - 158
OpenPuck/mode_lizard.cpp

@@ -1,187 +1,236 @@
 #include "mode_lizard.h"
+#include "lizard_map.h"
 #include "triton.h"
 #include "config.h"
-#include "usb_tx.h"
-#include "bonds.h" // NSLOT
+#include <string.h>
 
-// Per-controller lizard state. rfLizard runs once per input report on the report's bond slot, so the glide
-// velocity / sub-pixel carry / button + keyboard edge trackers must be PER SLOT -- shared statics would let
-// one controller's motion clobber another's (garbage deltas). Zero-init (static storage) matches the old
-// per-function initial values (all 0 / false).
-struct LizardState {
-	int prx, pry; // last right-pad sample (mouse)
-	bool prt; // right pad was touched last frame
-	float vx, vy, rmx, rmy; // mouse velocity + sub-pixel carry
-	int ply; // last left-pad Y (scroll)
-	bool plt; // left pad was touched last frame
-	float sacc; // scroll accumulator
-	uint8_t pmbtn; // last mouse-button mask
-	bool prevL5, prevR5; // MODE_LIZARD media-key edges (L5/R5)
-	uint8_t pmod, pkc[6]; // last keyboard modifier + keycodes (edge-send)
-};
-static LizardState g_lz[NSLOT];
+// Evaluate the binding table for ONE controller's button word, merging its keyboard/mouse-button/
+// consumer outputs into the shared accumulators. Pulled out of rfLizard so every bonded controller can
+// feed the same outputs. doRpadMouse/doLpadScroll are map-level (set if such a binding exists at all).
+static void lizardEvalSlot(uint32_t buttons, uint8_t &outMod,
+			   uint8_t outKeys[6], uint8_t &nKeys, uint8_t &outMBtn,
+			   uint8_t &consumerBits, bool &doRpadMouse,
+			   bool &doLpadScroll)
+{
+	// kbdConsumed (per controller): trig bits claimed by a hold-modifier binding so simpler
+	// single-button bindings sharing those bits are suppressed for THIS controller.
+	uint32_t kbdConsumed = 0;
+	for (uint8_t i = 0; i < g_lizardMap.count; i++) {
+		const LizardBinding &b = g_lizardMap.bindings[i];
+		if (b.outType == LZ_OUT_NONE)
+			continue;
+
+		if (b.outType == LZ_OUT_MOUSE_AXIS) {
+			if (b.outData[0] == LZ_MSRC_RPAD)
+				doRpadMouse = true;
+			// LZ_MSRC_LSTICK and LZ_MSRC_GYRO could be added later
+			continue;
+		}
+		if (b.outType == LZ_OUT_SCROLL) {
+			if (b.outData[0] == LZ_MSRC_LPAD)
+				doLpadScroll = true;
+			continue;
+		}
+
+		// Check hold modifier (AND) then trigger (any-of OR)
+		if (b.holdMask && (buttons & b.holdMask) != b.holdMask)
+			continue;
+		if (b.trigMask && (buttons & b.trigMask) == 0)
+			continue;
+
+		switch (b.outType) {
+		case LZ_OUT_MOUSE_BTN:
+			if (!b.trigMask || (buttons & b.trigMask))
+				outMBtn |= b.outData[0];
+			break;
+
+		case LZ_OUT_KBD_CHORD:
+			// suppress if trig bits already claimed by a hold-modifier binding
+			if (b.holdMask == 0 && b.trigMask != 0 &&
+			    (b.trigMask & kbdConsumed))
+				break;
+			outMod |= b.outData[0];
+			for (int j = 1; j < 7 && b.outData[j]; j++) {
+				if (nKeys < 6)
+					outKeys[nKeys++] = b.outData[j];
+			}
+			// claim trig bits so simpler bindings don't double-fire
+			if (b.holdMask != 0)
+				kbdConsumed |= b.trigMask;
+			break;
 
-void rfLizard(int slot, const uint8_t *r, Adafruit_USBD_HID *mdev,
-	      Adafruit_USBD_HID *kdev, uint8_t mrid, uint8_t krid)
+		case LZ_OUT_CONSUMER:
+			if (g_usbMode == MODE_LIZARD)
+				consumerBits |= b.outData[0];
+			break;
+
+		default:
+			break;
+		}
+	}
+}
+
+// Add the virtual stick-deflection bits so bindings can trigger on left-stick direction.
+static uint32_t lizardButtons(const PuckInput &in)
 {
-	if (slot < 0 || slot >= NSLOT)
-		return;
-	LizardState &ls = g_lz[slot];
-	uint32_t b = btnsOf(r);
-	bool qamHeld = (b & TB_QAM) != 0;
-	// --- right pad -> mouse motion with glide (mirrors mode_xinput's rfXboxMouse) ---
-	bool rtouch = b & TB_RPADT;
-	int rx = s16off(r, 22), ry = s16off(r, 24);
-	if (rtouch) {
-		if (ls.prt) {
-			ls.vx += (rx - ls.prx);
-			ls.vy += (ry - ls.pry);
+	uint32_t buttons = in.buttons;
+	if (in.lx > 12000)
+		buttons |= LZ_BTN_LSTICK_RT;
+	if (in.lx < -12000)
+		buttons |= LZ_BTN_LSTICK_LF;
+	if (in.ly < -12000)
+		buttons |= LZ_BTN_LSTICK_DN;
+	if (in.ly > 12000)
+		buttons |= LZ_BTN_LSTICK_UP;
+	return buttons;
+}
+
+void rfLizard(Adafruit_USBD_HID *mdev, Adafruit_USBD_HID *kdev, uint8_t mrid,
+	      uint8_t krid)
+{
+	// ---- merge binding outputs across every bonded controller ----
+	uint8_t outMod = 0;
+	uint8_t outKeys[6] = { 0, 0, 0, 0, 0, 0 };
+	uint8_t nKeys = 0;
+	uint8_t outMBtn = 0;
+	bool doRpadMouse = false, doLpadScroll = false;
+	uint8_t consumerBits = 0;
+
+	for (int s = 0; s < NSLOT; s++) {
+		if (!g_slot[s].used)
+			continue;
+		lizardEvalSlot(lizardButtons(g_in[s]), outMod, outKeys, nKeys,
+			       outMBtn, consumerBits, doRpadMouse,
+			       doLpadScroll);
+	}
+
+	// ---- right pad -> mouse motion with glide ----
+	// Per-slot velocity/last-position so each controller's pad integrates independently; the resulting
+	// deltas are SUMMED into one cursor (sub-pixel carry rmx/rmy is shared -- one mouse).
+	static int prx[NSLOT] = { 0 }, pry[NSLOT] = { 0 };
+	static bool prt[NSLOT] = { false };
+	static float vx[NSLOT] = { 0 }, vy[NSLOT] = { 0 }, rmx = 0, rmy = 0;
+	int dx = 0, dy = 0;
+	if (doRpadMouse) {
+		float sumx = 0, sumy = 0;
+		float f = g_mFric / 100.0f;
+		for (int s = 0; s < NSLOT; s++) {
+			if (!g_slot[s].used) {
+				vx[s] = vy[s] = 0;
+				prt[s] = false;
+				continue;
+			}
+			const PuckInput &in = g_in[s];
+			bool rtouch = (in.buttons & TB_RPADT) != 0;
+			int rx = in.rpx, ry = in.rpy;
+			if (rtouch && prt[s]) {
+				vx[s] += (rx - prx[s]);
+				vy[s] += (ry - pry[s]);
+			}
+			if (rtouch) {
+				prx[s] = rx;
+				pry[s] = ry;
+			}
+			prt[s] = rtouch;
+			sumx += vx[s];
+			sumy += vy[s];
+			// friction = glide/decay (per slot)
+			vx[s] *= f;
+			vy[s] *= f;
+			if (vx[s] > -1 && vx[s] < 1)
+				vx[s] = 0;
+			if (vy[s] > -1 && vy[s] < 1)
+				vy[s] = 0;
 		}
-		ls.prx = rx;
-		ls.pry = ry;
+		// Y inverted; *10 = desktop cursor sensitivity (g_mDiv 64 -> eff 640)
+		float mxf = sumx / (float)(g_mDiv * 10) + rmx,
+		      myf = -(sumy / (float)(g_mDiv * 10)) + rmy;
+		dx = (int)mxf;
+		dy = (int)myf;
+		rmx = mxf - dx;
+		rmy = myf - dy; // sub-pixel carry
+		if (dx > 127)
+			dx = 127;
+		if (dx < -127)
+			dx = -127;
+		if (dy > 127)
+			dy = 127;
+		if (dy < -127)
+			dy = -127;
 	}
-	ls.prt = rtouch;
 
-	// Y inverted; *10 = desktop-cursor sensitivity (g_mDiv 64 -> eff 640). Lower g_mDiv via WebUI slider = faster.
-	float mxf = ls.vx / (float)(g_mDiv * 10) + ls.rmx,
-	      myf = -(ls.vy / (float)(g_mDiv * 10)) + ls.rmy;
-	int dx = (int)mxf, dy = (int)myf;
-	ls.rmx = mxf - dx;
-	ls.rmy = myf - dy; // sub-pixel carry
-	if (dx > 127)
-		dx = 127;
-	if (dx < -127)
-		dx = -127;
-	if (dy > 127)
-		dy = 127;
-	if (dy < -127)
-		dy = -127;
-	float f = g_mFric / 100.0f;
-	ls.vx *= f;
-	ls.vy *= f;
-	if (ls.vx > -1 && ls.vx < 1)
-		ls.vx = 0;
-	if (ls.vy > -1 && ls.vy < 1)
-		ls.vy = 0; // friction = glide/decay
-	// --- left pad -> vertical scroll wheel (no momentum; only while touching, coarse) ---
-	bool ltouch = b & TB_LPADT;
-	int ly = s16off(r, 18);
-	if (ltouch) {
-		if (ls.plt) {
-			ls.sacc += (ly - ls.ply) / (float)(g_mDiv * 24);
+	// ---- left pad -> scroll wheel ----
+	// Per-slot last-position; accumulator shared (one wheel). Reset only when NO controller is scrolling.
+	static int ply[NSLOT] = { 0 };
+	static bool plt[NSLOT] = { false };
+	static float sacc = 0;
+	int dw = 0;
+	if (doLpadScroll) {
+		bool anyTouch = false;
+		for (int s = 0; s < NSLOT; s++) {
+			if (!g_slot[s].used) {
+				plt[s] = false;
+				continue;
+			}
+			const PuckInput &in = g_in[s];
+			bool ltouch = (in.buttons & TB_LPADT) != 0;
+			int ly = in.lpy;
+			if (ltouch && plt[s])
+				sacc += (ly - ply[s]) / (float)(g_mDiv * 24);
+			if (ltouch) {
+				ply[s] = ly;
+				anyTouch = true;
+			}
+			plt[s] = ltouch;
 		}
-		ls.ply = ly;
-	} else
-		ls.sacc = 0;
-	ls.plt = ltouch;
-	int dw = (int)ls.sacc;
-	ls.sacc -= dw;
-	if (dw > 15)
-		dw = 15;
-	if (dw < -15)
-		dw = -15; // finger up = wheel up (positive)
-	// --- mouse buttons: left=R-pad-click|R-trigger, right=L-trigger, middle=L-pad-click ---
-	// trigU8 scales full-range so a full pull reaches ~0xFF; a raw >>8 tops out ~0x80, never crossing threshold.
-	uint8_t rtrig = trigU8(u16off(r, 6)), ltrig = trigU8(u16off(r, 4));
-	uint8_t mbtn = 0;
-	if ((b & TB_RPADC) || rtrig > 180)
-		mbtn |= 1; // right trigger -> left (primary) click
-	if (ltrig > 180)
-		mbtn |= 2; // left trigger  -> right click
-	if (b & TB_LPADC)
-		mbtn |= 4;
-	if (dx || dy || dw || mbtn != ls.pmbtn) {
-		ls.pmbtn = mbtn;
+		if (!anyTouch)
+			sacc = 0;
+		dw = (int)sacc;
+		sacc -= dw;
+		if (dw > 15)
+			dw = 15;
+		if (dw < -15)
+			dw = -15;
+	}
+
+	// ---- mouse report ----
+	static uint8_t pmbtn = 0;
+	if (dx || dy || dw || outMBtn != pmbtn) {
+		pmbtn = outMBtn;
 		hid_mouse_report_t m;
-		m.buttons = mbtn;
+		m.buttons = outMBtn;
 		m.x = (int8_t)dx;
 		m.y = (int8_t)dy;
 		m.wheel = (int8_t)dw;
 		m.pan = 0;
 		if (mdev->ready())
-			usbTxHid(mdev, mrid, &m, sizeof m);
+			mdev->sendReport(mrid, &m, sizeof m);
 	}
-	// --- keyboard: modifiers + up to 6 keycodes ---
-	uint8_t mod = 0, kc[6] = { 0, 0, 0, 0, 0, 0 }, nk = 0;
-	if (b & TB_LB)
-		mod |= KEYBOARD_MODIFIER_LEFTCTRL;
-	if (b & TB_RB)
-		mod |= KEYBOARD_MODIFIER_LEFTALT;
-#define LZK(cond, code)                    \
-	do {                               \
-		if ((cond) && nk < 6)      \
-			kc[nk++] = (code); \
-	} while (0)
-	LZK(b & TB_A, HID_KEY_ENTER);
-	LZK(b & TB_B, HID_KEY_ESCAPE);
-	LZK(b & TB_X, HID_KEY_PAGE_UP);
-	LZK(b & TB_Y, HID_KEY_PAGE_DOWN);
-	LZK(b & TB_VIEW,
-	    (g_usbMode == MODE_LIZARD) ? HID_KEY_ESCAPE : HID_KEY_TAB);
-	LZK(b & TB_MENU,
-	    (g_usbMode == MODE_LIZARD) ? HID_KEY_TAB : HID_KEY_ESCAPE);
 
-	// left stick (XInput sign: +Y = up); deflect ~37% acts as a d-pad
-	int sx = s16off(r, 8), sy = s16off(r, 10);
-	LZK((b & TB_DUP) || sy > 12000, HID_KEY_ARROW_UP);
-	LZK((b & TB_DDN) || sy < -12000, HID_KEY_ARROW_DOWN);
-	LZK((b & TB_DLF) || sx < -12000, HID_KEY_ARROW_LEFT);
-	LZK((b & TB_DRT) || sx > 12000, HID_KEY_ARROW_RIGHT);
-#undef LZK
+	// ---- consumer control (edge-triggered) ----
+	static uint8_t prevCC = 0;
 	if (g_usbMode == MODE_LIZARD) {
-		bool mh = (b & TB_STEAM) || qamHeld;
-		bool nL5 = mh && (b & TB_L5), nR5 = mh && (b & TB_R5);
-		if (nL5 && !ls.prevL5) {
-			uint8_t cc = 0x02;
+		if (consumerBits != prevCC) {
 			if (mdev->ready())
-				usbTxHid(mdev, 0x03, &cc, 1);
-		}
-		if (nR5 && !ls.prevR5) {
-			uint8_t cc = 0x01;
-			if (mdev->ready())
-				usbTxHid(mdev, 0x03, &cc, 1);
-		}
-		if ((!nL5 && ls.prevL5) || (!nR5 && ls.prevR5)) {
-			uint8_t cc = 0x00;
-			if (mdev->ready())
-				usbTxHid(mdev, 0x03, &cc, 1);
-		}
-		ls.prevL5 = nL5;
-		ls.prevR5 = nR5;
-		if (mh && (b & TB_X)) {
-			mod = KEYBOARD_MODIFIER_LEFTGUI |
-			      KEYBOARD_MODIFIER_LEFTCTRL;
-			kc[0] = HID_KEY_O;
-			kc[1] = 0;
-			kc[2] = 0;
-			kc[3] = 0;
-			kc[4] = 0;
-			kc[5] = 0;
-			nk = 1;
-		}
-		if (mh && (b & TB_L4)) {
-			mod = KEYBOARD_MODIFIER_LEFTCTRL |
-			      KEYBOARD_MODIFIER_LEFTALT;
-			kc[0] = HID_KEY_DELETE;
-			kc[1] = 0;
-			kc[2] = 0;
-			kc[3] = 0;
-			kc[4] = 0;
-			kc[5] = 0;
-			nk = 1;
+				mdev->sendReport(0x03, &consumerBits, 1);
+			prevCC = consumerBits;
 		}
 	}
-	bool chg = (mod != ls.pmod);
+
+	// ---- keyboard report ----
+	static uint8_t pmod = 0, pkc[6] = { 0, 0, 0, 0, 0, 0 };
+	bool chg = (outMod != pmod);
 	for (int i = 0; i < 6; i++)
-		if (kc[i] != ls.pkc[i])
+		if (outKeys[i] != pkc[i])
 			chg = true;
 	if (chg) {
-		ls.pmod = mod;
+		pmod = outMod;
 		for (int i = 0; i < 6; i++)
-			ls.pkc[i] = kc[i];
-		uint8_t krep[8] = { mod,   0,	  kc[0], kc[1],
-				    kc[2], kc[3], kc[4], kc[5] };
+			pkc[i] = outKeys[i];
+		uint8_t krep[8] = { outMod,	0,	    outKeys[0],
+				    outKeys[1], outKeys[2], outKeys[3],
+				    outKeys[4], outKeys[5] };
 		if (kdev->ready())
-			usbTxHid(kdev, krid, krep, 8);
+			kdev->sendReport(krid, krep, 8);
 	}
 }

+ 16 - 12
OpenPuck/mode_lizard.h

@@ -1,18 +1,22 @@
-// mode_lizard.h -- the controller's native "lizard" desktop behavior (driverless keyboard + mouse).
+// mode_lizard.h -- configurable lizard (desktop) keyboard+mouse mode.
 //
-// Not a standalone IController: lizard rides ON the puck interface (puck_hid.cpp calls rfLizard when Steam is
-// closed), driving mouse(0x40)+keyboard(0x41) on the puck HID slot. Canonical Valve SC1 map: right pad ->
-// mouse (trackball glide), left pad -> scroll wheel + middle-click, R-trigger/R-pad-click -> left mouse,
-// L-trigger -> right mouse; A=Enter B=Esc X=PgUp Y=PgDn, d-pad/left-stick -> arrows, LB=LeftCtrl RB=LeftAlt,
-// View=Tab, Menu=Esc. Mouse reuses the Xbox-mode velocity+friction+sub-pixel glide model (g_mDiv / g_mFric).
+// Not a standalone IController: lizard rides ON the puck interface (puck_hid.cpp calls rfLizard
+// when Steam is closed or in MODE_LIZARD), driving mouse(0x40)+keyboard(0x41)+consumer(0x03) on
+// the same puck HID slot. The mapping table g_lizardMap (lizard_map.h) drives all outputs; the
+// default map mirrors the original hardcoded Valve SC1 behavior. Mouse reuses the Xbox-mode
+// velocity+friction+sub-pixel glide model (g_mDiv / g_mFric).
 //
-// EVERY connected controller drives lizard independently: puck_hid.cpp calls rfLizard once per input report on
-// the report's own bond `slot`, routing to that slot's own HID interface. The OS merges multiple HID mice /
-// keyboards onto the one desktop, so all controllers move the cursor / type together. `slot` also indexes the
-// per-controller glide/edge state, so one controller's motion never clobbers another's.
+// EVERY connected controller drives lizard: rfLizard merges g_in[s] across all used bond slots
+// (populated by the per-slot RF decode) onto the ONE shared desktop mouse/keyboard, so any controller
+// on any bond slot contributes and multiple controllers drive the same cursor/keys together. Per-slot
+// glide/edge state keeps one controller's motion from clobbering another's.
 #pragma once
 #include <Adafruit_TinyUSB.h>
 #include <stdint.h>
 
-void rfLizard(int slot, const uint8_t *r, Adafruit_USBD_HID *mdev,
-	      Adafruit_USBD_HID *kdev, uint8_t mrid, uint8_t krid);
+// Drives the desktop mouse/keyboard from the binding table. Merges input from ALL bonded controllers
+// (g_in[s] for every used slot) onto the one shared mouse/keyboard -- a controller on any RF slot
+// contributes, and multiple controllers drive the same cursor/keys together. mdev/kdev may be the same
+// object (puck mode sends both reports to hid[0]).
+void rfLizard(Adafruit_USBD_HID *mdev, Adafruit_USBD_HID *kdev, uint8_t mrid,
+	      uint8_t krid);

+ 24 - 11
OpenPuck/puck_hid.cpp

@@ -656,9 +656,10 @@ void SteamPuckController::begin()
 
 // Forward the controller's report 0x45 to Steam, or drive lizard kb/mouse when Steam is closed. PURELY a
 // USB-side decision -- changes nothing about the RF poll or the host->controller relay. Per-slot: each
-// controller's 0x45 goes to its OWN hid[slot], so Steam sees four independent inputs. Lizard is likewise
-// per-slot now: each connected controller drives keyboard+mouse on its own hid[slot] (the OS merges the
-// several HID mice/keyboards onto the one desktop), so any controller -- on any bond slot -- works.
+// controller's 0x45 goes to its OWN hid[slot], so Steam sees four independent inputs. Lizard, by contrast,
+// presents ONE mouse + keyboard (a desktop can only consume one): rfLizard merges every bonded controller's
+// input (g_in[] across all used slots, populated by the per-slot decode) onto hid[0], so all connected
+// controllers drive the same cursor/keys together -- and it works regardless of which bond slot is live.
 void SteamPuckController::onReport45(int slot, const uint8_t *rep, bool fresh,
 				     uint8_t bodyTlen)
 {
@@ -682,14 +683,26 @@ void SteamPuckController::onReport45(int slot, const uint8_t *rep, bool fresh,
 	// and this is exactly what the real puck does. rep[0] is the id byte (rep points at it in the F1 TLV).
 	const uint8_t rid = rep[0];
 	if (lizardActive()) {
-		// Every connected controller drives keyboard+mouse on ITS OWN slot interface; the OS merges the
-		// multiple HID mice/keyboards onto the one desktop. (Was hardcoded to slot 0, which went silent
-		// whenever the live controller bonded to a non-zero slot -- e.g. slot 0 holding a stale/phantom
-		// bond from a cloned backup.) rfLizard keeps its glide/edge state per-slot so they don't clobber.
-		// rfLizard reads 0x45 field offsets; report 0x42 is VERIFIED byte-identical over [0..45] (see the
-		// rf_link decode note), so both ids drive lizard -- covers MODE_LIZARD and Steam-mode-with-Steam-
-		// closed on new-firmware controllers.
-		rfLizard(slot, rep, &hid[slot], &hid[slot], 0x40, 0x41);
+		// All bonded controllers share ONE desktop mouse/keyboard (mounted on hid[0] regardless of
+		// which RF slots are in use). rfLizard merges g_in[] across every used slot internally, so we
+		// fire it ONCE per cycle -- gated on the lowest CONNECTED slot's report so it runs at a steady
+		// cadence (not once per slot, which would multiply the output rate). g_in[] is populated by the
+		// per-slot decode for BOTH report 0x45 and the beta 0x42 (byte-identical front section), so
+		// lizard works on new-firmware controllers too without reading rep[] here.
+		// Gate on the lowest RECENTLY-REPLYING slot, not merely the lowest bonded one: a bonded-but-
+		// offline low slot (e.g. a stale/phantom bond from a cloned backup) must not starve lizard when
+		// a higher slot is the live controller (main's #98 fix -- do not regress it). onReport45 only
+		// runs for a slot that just decoded a report, so the reporting slot is itself connected.
+		unsigned long now = millis();
+		int lizSlot = -1;
+		for (int s = 0; s < NSLOT; s++)
+			if (g_slot[s].used && g_connReplyMs[s] &&
+			    (now - g_connReplyMs[s]) < 1200u) {
+				lizSlot = s;
+				break;
+			}
+		if (slot == lizSlot)
+			rfLizard(&hid[0], &hid[0], 0x40, 0x41);
 	} else {
 		// body length after the id byte, clamped to the descriptor's declared size for this report id
 		// (0x42 = 53B vendor input, 0x45 = 45B input).

+ 185 - 17
OpenPuck/webusb_config.cpp

@@ -5,8 +5,10 @@
 #include "haptics.h"
 #include "puck_hid.h"
 #include "triton.h" // g_in raw IMU (diagnostic readout)
+#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>
@@ -22,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]
@@ -64,8 +79,12 @@ 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 (16 = +configurable lizard-map ops 0x11..0x15 / 0xAA frame, payload unchanged -- the
+	// panel MUST see >=16 before it dares send 0x11, or a blocking readLizard() would hang forever against a
+	// firmware that silently drops the unknown op; 15 = +staged firmware-update ops 0x20..0x24; 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] = 16;
 	p[3] = g_usbMode;
 	p[4] = (uint8_t)g_mDiv;
 	p[5] = (uint8_t)g_mFric;
@@ -130,7 +149,9 @@ static void webusbSendBlob()
 	// Switch Pro gyro sensitivity x10 (protocol v6)
 	p[55] = g_swGyroScale10;
 	{
-		int16_t a[3] = { g_in[0].ax, g_in[0].ay, g_in[0].az };
+		// Read the ACTIVE slot's accel, not a hardcoded g_in[0]: a controller bonded to a non-zero
+		// slot leaves g_in[0] zeroed, which made this readout (and lizard) look dead.
+		int16_t a[3] = { g_in[cs].ax, g_in[cs].ay, g_in[cs].az };
 		memcpy(&p[56], a, 6);
 	} // raw accelerometer for scale diagnostics (protocol v7)
 
@@ -392,6 +413,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();
@@ -445,6 +480,42 @@ void webusbInit(void)
 	// boards whose bootloader wipes retained RAM. Cheap: the callback no-ops unless loop() is already stalled.
 	tud_sof_cb_enable(true);
 }
+
+// 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()
+{
+	if (!usb_web.connected())
+		return;
+	uint8_t count = g_lizardMap.count;
+	if (count > LZ_MAX_BINDINGS)
+		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];
+	// 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];
+		uint8_t *q = &f[2 + i * 16];
+		q[0] = b.outType;
+		for (int k = 0; k < 7; k++)
+			q[1 + k] = b.outData[k];
+		q[8] = (uint8_t)b.trigMask;
+		q[9] = (uint8_t)(b.trigMask >> 8);
+		q[10] = (uint8_t)(b.trigMask >> 16);
+		q[11] = (uint8_t)(b.trigMask >> 24);
+		q[12] = (uint8_t)b.holdMask;
+		q[13] = (uint8_t)(b.holdMask >> 8);
+		q[14] = (uint8_t)(b.holdMask >> 16);
+		q[15] = (uint8_t)(b.holdMask >> 24);
+	}
+	usb_web.write(f, (uint16_t)(2 + count * 16));
+	usb_web.flush();
+}
 #if OPK_LOG
 // Stream the capture ring (haptics / relayed host commands) to the panel as 0xA6 frames. Frame formats:
 //   entry: [0xA6][L][T=1][age u32 LE][slot][rid][n][bytes n]   (L = 8 + n)
@@ -500,9 +571,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). 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();
@@ -515,20 +587,36 @@ void webusbPoll()
 			if (n == 0)
 				break;
 			uint8_t op = buf[0];
-			if (op < 0x01 || op > 0x10) { // resync: drop one byte
+			if ((op < 0x01 || op > 0x15) &&
+			    (op < 0x20 || op > 0x24)) { // resync: drop one byte
 				memmove(buf, buf + 1, --n);
 				continue;
 			}
-			// Command length (fixed per opcode). 0x0D = write-one-bond-slot (27 B); 0x05/0x0E/0x0F/0x10 carry
-			// one value byte; 0x02 a field+value; 0x0A a 3-byte magic.
-			uint8_t need = (op == 0x0D) ? 27 :
-				       (op == 0x02) ? 3 :
-				       (op == 0x03 || op == 0x05 ||
-					op == 0x0E || op == 0x0F ||
-					op == 0x10) ?
-						      2 :
-				       (op == 0x0A) ? 4 :
-						      1;
+			// 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. 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) {
@@ -634,6 +722,86 @@ void webusbPoll()
 				faultDiagArmIntentionalReset();
 				NVIC_SystemReset();
 
+				// ---- lizard (desktop) binding map editor ----
+				// 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 0xAA frame.
+			} else if (op == 0x11) {
+				webusbSendLizard();
+
+				// 0x13 [count]: BEGIN a map edit -- set the binding count (clamped). The
+				// panel then sends one 0x12 per binding (0..count-1) and a 0x14 to persist.
+			} else if (op == 0x13) {
+				g_lizardMap.count =
+					(buf[1] <= LZ_MAX_BINDINGS) ?
+						buf[1] :
+						LZ_MAX_BINDINGS;
+
+				// 0x12 [idx][outType][od0..6][trig LE4][hold LE4]: set one binding.
+			} else if (op == 0x12) {
+				uint8_t idx = buf[1];
+				if (idx < LZ_MAX_BINDINGS) {
+					LizardBinding &b =
+						g_lizardMap.bindings[idx];
+					b.outType = buf[2];
+					for (int k = 0; k < 7; k++)
+						b.outData[k] = buf[3 + k];
+					b.trigMask = (uint32_t)buf[10] |
+						     ((uint32_t)buf[11] << 8) |
+						     ((uint32_t)buf[12] << 16) |
+						     ((uint32_t)buf[13] << 24);
+					b.holdMask = (uint32_t)buf[14] |
+						     ((uint32_t)buf[15] << 8) |
+						     ((uint32_t)buf[16] << 16) |
+						     ((uint32_t)buf[17] << 24);
+				}
+
+				// 0x14: COMMIT the edited map to flash and echo it back.
+			} else if (op == 0x14) {
+				saveLizardMap();
+				webusbSendLizard();
+
+				// 0x15: reset the map to the built-in defaults, persist, echo back.
+			} else if (op == 0x15) {
+				defaultLizardMap();
+				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>

+ 2 - 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.
 
@@ -70,6 +70,7 @@ You can copy configurations between OpenPucks using the export/import card in th
 
 # 3D Printed Cases
 - [jaki-gh](https://github.com/jaki-gh) has contributed a 3D printable housing with OpenPuck written on it alongside a Steam logo. You can find that [here](https://www.thingiverse.com/thing:7371668).
+- [BOT-Yanni](https://www.reddit.com/user/BOT-Yanni/) designed a slim case with OpenPuck written on it and a cute little glyph of the Steam Controller. You can find that [here](https://www.thingiverse.com/thing:7379316).
 - [StonnedModder](https://www.printables.com/model/1760684-openpuck-promicro-nrf52840-case) built a case meant to accomodate a USB C to USB A adapter which you can find [here](https://www.printables.com/model/1760684-openpuck-promicro-nrf52840-case).
 - Another plain case for these pro micros can be found [here](https://www.printables.com/model/1285346-pro-micro-nicenano-nrf52840-dongle-case/collections).
 

+ 161 - 85
ReversePuck/scpair.py

@@ -57,21 +57,30 @@ FEAT_LEN = 64  # Valve feature reports are exactly 64 bytes
 
 
 def _sysfs_ids(node):
-    """Return (vid, pid) for a /dev/hidrawN node, or (None, None)."""
+    """Return (vid, pid, serial) for a /dev/hidrawN node, or (None, None, None)."""
     name = os.path.basename(node)
     try:
         # /sys/class/hidraw/hidrawN/device/uevent has HID_ID=0003:000028DE:00001304
         ue = open("/sys/class/hidraw/%s/device/uevent" % name).read()
     except Exception:
-        return (None, None)
+        return (None, None, None)
+    
+    vid = None
+    pid = None 
+    serial = None
+
     for line in ue.splitlines():
         if line.startswith("HID_ID="):
             parts = line.split(":")
             try:
-                return (int(parts[1], 16) & 0xFFFF, int(parts[2], 16) & 0xFFFF)
-            except Exception:
-                return (None, None)
-    return (None, None)
+                vid = int(parts[1], 16) & 0xFFFF
+                pid = int(parts[2], 16) & 0xFFFF
+            except:
+                pass
+        if line.startswith("HID_UNIQ="):
+            serial = line.split('=')[1]
+    
+    return (vid, pid, serial)
 
 
 def _first_usage_page(fd):
@@ -99,10 +108,11 @@ def _first_usage_page(fd):
 
 
 class HidDev:
-    def __init__(self, node, vid, pid):
+    def __init__(self, node, vid, pid, serial):
         self.node = node
         self.vid = vid
         self.pid = pid
+        self.serial = serial
         self.fd = os.open(node, os.O_RDWR)
         self.usage_page = _first_usage_page(self.fd)
 
@@ -136,16 +146,25 @@ class HidDev:
         except Exception:
             return ""
 
+def nodesort(nodeobj):
+    """Sorts the node path properly (numerically)."""
+
+    if isinstance(nodeobj, HidDev):
+        return nodesort(nodeobj.node)
+
+    if isinstance(nodeobj, str):
+        return int(nodeobj.replace("/dev/hidraw", ""))
+
 
 def enumerate_devices():
     """Return dict role -> list[HidDev]: {'puck':[...], 'ctrl':[...]} (control interfaces only)."""
     out = {"puck": [], "ctrl": []}
-    for node in sorted(glob.glob("/dev/hidraw*")):
-        vid, pid = _sysfs_ids(node)
+    for node in sorted(glob.glob("/dev/hidraw*"), key=nodesort):
+        vid, pid, serial = _sysfs_ids(node)
         if vid != VALVE_VID:
             continue
         try:
-            dev = HidDev(node, vid, pid)
+            dev = HidDev(node, vid, pid, serial)
         except PermissionError:
             print("permission denied on %s (run with sudo or install the udev rule)" % node, file=sys.stderr)
             continue
@@ -169,89 +188,151 @@ def _ser16(serial):
     return b + b"\x00" * (16 - len(b))
 
 
-def read_slots(puck):
-    """Read the puck's 4 bond slots (each puck control interface == one slot). Returns list of dicts."""
+def read_slots(puck_list):
+    """Read all bond slots from all attached pucks (each puck control interface == one slot)."""
+    pucks = []
     slots = []
-    for i, dev in enumerate(sorted(puck, key=lambda d: d.node)):
+
+    last_dev = None
+    idx = 0
+    for dev in sorted(puck_list, key=nodesort):
+        if last_dev is not None and dev.serial != last_dev.serial:
+            # This is a new puck, append the old puck and all its slots to the list.
+            pucks.append({"serial": last_dev.serial, "slots": slots})
+            slots = []
+            idx = 0
         try:
             dev.set_feature(2, 0xA3)
             r = dev.get_feature(2)  # [02 A3 18 <8 uuid><16 serial>]
             rec = r[3:3 + 24]
             used = any(rec[0:8])
             serial = rec[8:24].split(b"\x00")[0].decode("latin1", "replace") if used else ""
-            slots.append({"idx": i, "used": used, "serial": serial, "rec": rec})
+            slots.append({"idx": idx, "dev": dev, "used": used, "serial": serial, "rec": rec})
         except Exception as ex:
-            slots.append({"idx": i, "used": False, "serial": "", "rec": b"", "err": str(ex)})
-    return slots
+            slots.append({"idx": idx, "dev": dev, "used": False, "serial": "", "rec": b"", "err": str(ex)})
+        
+        last_dev = dev
+        idx = idx + 1
+
+    pucks.append({"serial": dev.serial, "slots": slots})
+    return pucks
+
+
+def get_puck_slot_for_writing(puck_serial=None, slot=None):
+    devs = enumerate_devices()
+    pucks = sorted(devs["puck"], key=nodesort)
+    if not pucks:
+        raise SystemExit("no puck (28DE:1304) control interface found")
+
+    puck_objects = read_slots(pucks)
+    if puck_serial is None and len(puck_objects) > 1:
+        raise SystemExit("Multiple Pucks connected, you need to specify its serial number")
+    
+    puck_object = None
+
+    if puck_serial is not None:
+        for puck in puck_objects:
+            if puck['serial'] == puck_serial:
+                puck_object = puck
+                break
+        
+        if puck_object is None: 
+            raise SystemExit("Can't find a puck with that serial number!")
+        
+    else: 
+        puck_object = puck_objects[0]
+
+
+    slots = puck_object['slots']
+
+    if slot is None:
+        slot_object = next((s for s in slots if not s["used"]), None)
+    else: 
+        slot_object = next((s for s in slots if s["idx"] == slot), None)
+
+    if slot_object is None:
+        raise SystemExit("This Puck has all slots occupied!")
+
+    
+    return (puck_object['serial'], slot_object)
+    
 
 
-def pair(slot=None, pkey=None):
+def pair(puck_serial=None, puck_slot=None, controller_slot=0, puck_name=None, controller_name=None, pkey=None):
     devs = enumerate_devices()
-    pucks = sorted(devs["puck"], key=lambda d: d.node)
-    ctrls = sorted(devs["ctrl"], key=lambda d: d.node)
+    pucks = sorted(devs["puck"], key=nodesort)
+    ctrls = sorted(devs["ctrl"], key=nodesort)
     if not pucks:
         raise SystemExit("no puck (28DE:1304) control interface found")
     if not ctrls:
         raise SystemExit("no controller (28DE:1302) control interface found — plug the ReversePuck controller in")
     ctrl = ctrls[0]
-    puck_serial = pucks[0].read_serial(2) or "FXB000000000"
-    ctrl_serial = ctrl.read_serial(1) or "FXA000000000"
 
-    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 controller_name is None: 
+        ctrl_serial_str = ctrl.read_serial(1) or "FXA000000000"
+    else: 
+        ctrl_serial_str = controller_name[:15]
+
+    puck_serial, slot_obj = get_puck_slot_for_writing(puck_serial, puck_slot)  
+
+    if puck_name is None: 
+        puck_serial_str = puck_serial
+    else: 
+        puck_serial_str = puck_name[:15]
 
     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()))
+    key = b"esb/bond\x00" if controller_slot == 0 else b"esb/bond_2\x00"
+
+    print("Pairing key: %s" % (r.hex()))
+
+    # Puck name gets written to controller (defaults to puck serial).
+    # Controller name gets written to Puck
+
+    print("Puck: %s slot %d controller %s" % (puck_serial, slot_obj['idx'], ctrl_serial_str))
+    print("Controller: %s slot %d puck %s" % (ctrl.read_serial(1), controller_slot, puck_serial_str))
 
     # puck slot: 0xA2 [r1 r2][ctrl serial]
-    pucks[slot].set_feature(2, 0xA2, r + _ser16(ctrl_serial))
+    slot_obj['dev'].set_feature(2, 0xA2, r + _ser16(ctrl_serial_str))
     # 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, 0xEE, key + r + _ser16(puck_serial_str))
     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 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")
+def write_puck_slot(puck_serial=None, puck_slot=None, ctrl_name=None, pkey=None):
 
-    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)
+    puck_serial, slot_obj = get_puck_slot_for_writing(puck_serial, puck_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)
+    if ctrl_name is None and pkey is None: 
+        print("wiping puck %s slot %d" % (puck_serial, slot_obj['idx']))
+        slot_obj['dev'].set_feature(2, 0xA2, b"\x00" * 24)
     else:
+
+        if ctrl_name is None or len(ctrl_name) > 15: 
+            raise SystemExit("Invalid controller name.")
+
         if pkey is None: 
             raise SystemExit("No key provided!")
         
-        print("writing to puck slot %d: controller %s key=%s" %
-          (slot, ctrl_serial, pkey.hex()))
+        print("writing to puck %s slot %d: controller %s key=%s" %
+          (puck_serial, slot_obj['idx'], ctrl_name, pkey.hex()))
         # puck slot: 0xA2 [r1 r2][ctrl serial]
-        pucks[slot].set_feature(2, 0xA2, pkey + _ser16(ctrl_serial))
+
+        print(slot_obj['dev'].node)
+
+        slot_obj['dev'].set_feature(2, 0xA2, pkey + _ser16(ctrl_name))
 
     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)
+    ctrls = sorted(devs["ctrl"], key=nodesort)
     if not ctrls:
         raise SystemExit("no controller (28DE:1302) control interface found — plug the ReversePuck controller in")
     
@@ -291,36 +372,26 @@ def write_controller_slot(slot=0, puck_serial=None, pkey=None):
         print("paired. controller rebooting into wireless — move the puck to your host.")
 
 
-def unpair(slot):
-    devs = enumerate_devices()
-    pucks = sorted(devs["puck"], key=lambda d: d.node)
-    if slot >= len(pucks):
-        raise SystemExit("slot %d has no puck control interface" % slot)
-    pucks[slot].set_feature(2, 0xA2, b"\x00" * 24)  # clear puck slot
-    # also clear the controller bond if present
-    for ctrl in devs["ctrl"]:
-        key = b"esb/bond\x00" if slot == 0 else b"esb/bond_2\x00"
-        try:
-            ctrl.set_feature(1, 0xEE, key + b"\x00" * 24)
-            ctrl.set_feature(1, 0xEF, key)
-        except Exception:
-            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)
-    ctrls = sorted(devs["ctrl"], key=lambda d: d.node)
+    pucks = sorted(devs["puck"], key=nodesort)
+    ctrls = sorted(devs["ctrl"], key=nodesort)
     print("controllers:", [(d.node, "%04X" % d.pid, d.read_serial(1)) for d in ctrls] or "(none)")
+
     if pucks:
-        print("puck:", pucks[0].read_serial(2))
-        for s in read_slots(pucks):
-            if s["used"]:
-                print("  slot %d: %s (key %s)" % (s["idx"], s["serial"], s["rec"][:8].hex()))
-            else: 
-                print("  slot %d: Empty" % (s["idx"]))
+        ps = read_slots(pucks)
+        for puck in ps:
+            slots = puck['slots']
+
+            print(f"Puck: {puck['serial']}")
+
+            for s in slots: 
+                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)")
 
@@ -339,19 +410,26 @@ def main():
     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)
+    #def pair(puck_serial=None, puck_slot=None, controller_slot=0, puck_name=None, controller_name=None, pkey=None):
+    p.add_argument("--puck-serial", type=str, default=None)
+    p.add_argument("--puck-slot", type=int, default=None)
+    p.add_argument("--puck-name", type=str, default=None)
+    p.add_argument("--controller-slot", type=int, default=0)
+    p.add_argument("--controller-name", type=str, 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)
+    #def write_puck_slot(puck_serial=None, puck_slot=None, ctrl_name=None, pkey=None):
+    s.add_argument("--puck-serial", type=str, default=None)
+    s.add_argument("--puck-slot", type=int, default=None)
+    s.add_argument("--controller-name", 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)
+    #def write_controller_slot(slot=0, puck_serial=None, pkey=None):
+    c.add_argument("--controller-slot", type=int, default=None)
+    c.add_argument("--puck-name", type=str, default=None)
     c.add_argument("--key", type=steam_key_check, default=None)
 
 
@@ -360,13 +438,11 @@ def main():
     if args.cmd == "list":
         cmd_list()
     elif args.cmd == "pair":
-        pair(args.slot, args.key)
-    elif args.cmd == "unpair":
-        unpair(args.slot)
+        pair(args.puck_serial, args.puck_slot, args.controller_slot, args.puck_name, args.controller_name, args.key)
     elif args.cmd == "write-puck":
-        write_puck_slot(args.slot, args.serial, args.key)
+        write_puck_slot(args.puck_serial, args.puck_slot, args.controller_name, args.key)
     elif args.cmd == "write-controller":
-        write_controller_slot(args.slot, args.serial, args.key)
+        write_controller_slot(args.controller_slot, args.puck_name, args.key)
 
 
 

+ 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:

+ 26 - 0
docs/PROTOCOL.md

@@ -377,9 +377,35 @@ 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 `0xAA`). **Requires status-blob version ≥ 16** —
+    older firmware drops the unknown op silently and never replies, so a host that issues a blocking read
+    for the `0xAA` frame would hang the shared endpoint. The panel gates all `0x11`–`0x15` sends on the
+    version byte (`0xA5` payload byte 0) reaching 16 first.
+  - `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 `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 ...`: 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`:
+
+```text
+[0]     outType   (0 none, 1 keyboard chord, 2 mouse btn, 3 mouse axis, 4 scroll, 5 consumer)
+[1..7]  outData[0..6]  (type-specific payload)
+[8..11] trigMask  u32 LE  (button bits: any-of)
+[12..15] holdMask u32 LE  (button bits: all-of guard)
+```
+
+> 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:
 

File diff suppressed because it is too large
+ 832 - 12
docs/index.html


Some files were not shown because too many files changed in this diff