Browse Source

Updater working

Jari 3 tháng trước cách đây
mục cha
commit
7ba5dc409d

+ 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 - 4
OpenPuck/OpenPuck.ino

@@ -38,6 +38,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>
 
@@ -99,6 +100,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();
 
@@ -112,10 +117,6 @@ void setup()
 	factoryResetOnce(OPK_GIT_HASH);
 #endif
 	loadCfg();
-	if (g_bootUf2ThisBoot) {
-		delay(40);
-		enterUf2Dfu();
-	}
 	loadBonds();
 	// regenerate per-slot session addresses from each bond UUID (deterministic, stable across reboots)
 	for (int s = 0; s < NSLOT; s++)

+ 4 - 39
OpenPuck/config.cpp

@@ -13,16 +13,11 @@ uint8_t g_chordBtn[3] = {
 }; // back4+B/X/Y -> these modes (A always STEAM); Y defaults to Switch Pro
 bool g_persistMode = false;
 uint8_t g_bootMode = 0xFF;
-uint8_t g_bootUf2 = 0;
-bool g_bootUf2ThisBoot = false;
 
 bool g_debugCdcThisBoot = false;
 
 // persisted one-shot arm, stored in Cfg.rsvd0 (1 = keep CDC for the next boot)
 static uint8_t g_debugCdc = 0;
-// one-shot UF2 arm token, stored in Cfg.rxWin10 (legacy field no longer used). Chosen so typical legacy
-// rxWin10 values (small 10us units) never collide and accidentally arm the updater path.
-static const uint8_t BOOT_UF2_ARM = 0xA5;
 
 int g_mDiv = 64, g_mFric = 94;
 
@@ -81,13 +76,8 @@ const uint32_t g_pollUs = POLL_US_DEFAULT;
 struct Cfg {
 	uint8_t magic, mode, mDiv, mFric, rsvd0, pollU100, persistMode,
 		bootMode, chordBtn[3], rumbleScale;
-	// rxWin10: one-shot UF2 boot arm token (legacy RF tunable slot, window
-	// now fixed).
-	//
-	// lizKeep: id9=0 hold enable (see haptics.h LIZKEEP_MS).
-	//
-	// landAll87: verbatim-0x87-relay experiment toggle
-	// (haptics.h g_landAll87).
+	// rxWin10: legacy RF tunable slot (window now fixed; ignored). lizKeep: the id9=0 hold enable (see
+	// haptics.h LIZKEEP_MS). landAll87: the verbatim-0x87-relay experiment toggle (haptics.h g_landAll87).
 	uint8_t rxWin10, lizKeep, landAll87;
 	TypeCfg type[ET_COUNT]; // per-emulated-type back/qam/abSwap/padHaptics
 }; // rsvd0 = ex-padSmooth, now the one-shot debug-CDC arm
@@ -104,7 +94,7 @@ void saveCfg()
 		  g_bootMode,
 		  { g_chordBtn[0], g_chordBtn[1], g_chordBtn[2] },
 		  g_rumbleScale,
-		  g_bootUf2,
+		  (uint8_t)(g_rxWin / 10),
 		  g_lizKeep,
 		  g_landAll87,
 		  {} };
@@ -137,13 +127,6 @@ void loadCfg()
 				g_debugCdc = 0;
 				consume = true;
 			}
-
-			// one-shot UF2 boot (Cfg.rxWin10): honor once, then clear.
-			g_bootUf2ThisBoot = (c.rxWin10 == BOOT_UF2_ARM);
-			if (g_bootUf2ThisBoot) {
-				g_bootUf2 = 0;
-				consume = true;
-			}
 			// poll rate is fixed; rewrite cfg so the persisted byte matches the new default.
 			if (c.pollU100 != (uint8_t)(POLL_US_DEFAULT / 100))
 				consume = true;
@@ -175,13 +158,7 @@ void loadCfg()
 			// verbatim-0x87-relay experiment toggle (0/1; default off)
 			if (c.landAll87 <= 1)
 				g_landAll87 = c.landAll87;
-
-			// Keep only our explicit arm token; legacy rxWin10 values
-			// were small 10us units, so they do not collide with 0xA5.
-			if (c.rxWin10 == BOOT_UF2_ARM)
-				g_bootUf2 = BOOT_UF2_ARM;
-			else
-				g_bootUf2 = 0;
+			// The poll RX window is now FIXED (g_rxWin is const) -- any persisted rxWin10 is ignored.
 		}
 		f.close();
 	}
@@ -211,18 +188,6 @@ void armDebugCdcNextBoot()
 	saveCfg();
 } // next boot keeps CDC; loadCfg() consumes it after
 
-void armUf2NextBoot()
-{
-	g_bootUf2 = BOOT_UF2_ARM;
-	saveCfg();
-}
-
-void clearUf2NextBoot()
-{
-	g_bootUf2 = 0;
-	saveCfg();
-}
-
 // FULL factory wipe: reformat the internal LittleFS, erasing cfg.bin (modes/tunables/chords) AND bonds.bin
 // (paired-controller record). Caller reboots: next boot finds no files and falls back to clean defaults, and
 // the controller must be re-paired. Irreversible -- gated behind explicit confirmation at every call site.

+ 0 - 6
OpenPuck/config.h

@@ -103,12 +103,6 @@ extern uint8_t g_chordBtn[3]; // back4+B/X/Y -> these modes (A always STEAM)
 extern bool g_persistMode;
 // one-shot: boot into this mode once then clear (!persistMode + explicit switch)
 extern uint8_t g_bootMode;
-// one-shot: reboot into UF2 bootloader once (apply staged update), then clear
-extern uint8_t g_bootUf2;
-// decision for THIS boot: true => enter UF2 bootloader before normal init
-extern bool g_bootUf2ThisBoot;
-void armUf2NextBoot(); // persist the one-shot UF2 arm (caller reboots later)
-void clearUf2NextBoot(); // clear a previously-armed one-shot UF2 entry
 
 // One-shot debug CDC. Puck mode normally DROPS the CDC serial console to free the USB endpoint its wake-mouse
 // interface needs (to wake a sleeping Windows host). Arming this keeps CDC for the NEXT boot only -- dropping

+ 355 - 0
OpenPuck/fw_update.cpp

@@ -0,0 +1,355 @@
+// 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
+
+#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)
+{
+	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)
+}

+ 46 - 0
OpenPuck/fw_update.h

@@ -0,0 +1,46 @@
+// 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);

+ 1 - 9
OpenPuck/serial_console.cpp

@@ -12,7 +12,7 @@
 #include <stdlib.h>
 #include <string.h>
 
-// CDC commands: l=listen, s=stop, cN=channel, p<hex>=prefix, a<8hex>=base addr, b=bonds, UF2NEXT, etc.
+// CDC commands: l=listen, s=stop, cN=channel, p<hex>=prefix, a<8hex>=base addr, b=bonds, etc.
 // Command order matters: else-if chain, FIRST matching letter wins. 'C' and 'H' appear twice; the second
 // occurrence is unreachable.
 void serialConsolePoll()
@@ -53,14 +53,6 @@ void serialConsolePoll()
 					"# feature-cmd capture %s (I45 %s)\n",
 					g_cmdCapture ? "ON" : "off",
 					g_cmdCapture ? "suppressed" : "on");
-			} else if (!strcmp(line, "UF2NEXT")) {
-				armUf2NextBoot();
-				Serial.println(
-					"# UF2 bootloader armed for NEXT reboot (firmware update applies on reboot)");
-			} else if (!strcmp(line, "UF2CLR")) {
-				clearUf2NextBoot();
-				Serial.println(
-					"# UF2 next-boot arm cleared (normal app boot on reboot)");
 			} else if (!strcmp(line, "L87")) {
 				// EXPERIMENT: land all relayed 0x87 config verbatim (real-puck relay) vs the discard-whitelist.
 				// Exact-match string (single letters are all taken; 'l' alone = rfListenStart).

+ 86 - 20
OpenPuck/webusb_config.cpp

@@ -7,6 +7,7 @@
 #include "triton.h" // g_in raw IMU (diagnostic readout)
 #include "build_info.h"
 #include "fault_diag.h"
+#include "fw_update.h"
 #include "usb_tx.h"
 #include <Arduino.h>
 #include <string.h>
@@ -22,6 +23,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]
@@ -39,8 +53,7 @@ static volatile bool g_bondExportRequest = false;
 //                [v13: per-slot link stats, 4x9B from p[141]: {pollsps u16, f1ps u16, newps u16, crc/s u8,
 //                 noRx/s u8, relay/s u8} -- each controller's own rates (the v4 aggregates are their sums)]
 //                [v14: p[177] landAll87 (verbatim-0x87-relay experiment toggle)]
-//                [v15: p[178] uf2NextBoot armed (1=next reboot enters UF2 bootloader)]
-#define WB_PAYLEN 177
+#define WB_PAYLEN 176
 // The blob send is drop-on-full (never blocks loop), so the vendor TX FIFO MUST be able to hold a whole blob
 // -- otherwise tud_vendor_write_available() never reaches the frame size and EVERY frame is dropped (blank
 // panel / stale mappings). The Makefile sets -DCFG_TUD_VENDOR_TX_BUFSIZE=256; guard it here so a build without
@@ -65,9 +78,9 @@ static void webusbSendBlob()
 	p[0] = 0xA5;
 	p[1] = WB_PAYLEN;
 
-	// protocol version (15 = +UF2-next-boot arm state; 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)
+	// 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;
@@ -234,8 +247,6 @@ static void webusbSendBlob()
 	}
 	// v14: verbatim-0x87-relay experiment toggle (panel reflects + toggles it)
 	p[177] = g_landAll87;
-	// v15: staged update arm -- next reboot goes straight to UF2 bootloader.
-	p[178] = g_bootUf2 ? 1 : 0;
 	// CRITICAL: usb_web.write() SPINS (`while (remain && _connected) yield();`) until the IN FIFO drains or the
 	// panel disconnects. If the panel holds the WebUSB interface open but stops reading its IN endpoint -- a
 	// backgrounded tab, or the host briefly not servicing transferIn under load -- the FIFO never empties and
@@ -397,6 +408,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();
@@ -505,9 +530,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 is the
+	// 0x0D bond-slot write (27 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();
@@ -520,12 +546,23 @@ void webusbPoll()
 			if (n == 0)
 				break;
 			uint8_t op = buf[0];
-			if (op < 0x01 || op > 0x10) { // resync: drop one byte
+			if ((op < 0x01 || op > 0x10) &&
+			    (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); 0x05/0x0E/0x0F/0x10 carry
-			// one value byte; 0x02 a field+value; 0x0A a 3-byte magic.
+			// 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.
 			uint8_t need = (op == 0x0D) ? 27 :
 				       (op == 0x02) ? 3 :
 				       (op == 0x03 || op == 0x05 ||
@@ -533,6 +570,9 @@ void webusbPoll()
 					op == 0x10) ?
 						      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
@@ -639,6 +679,40 @@ void webusbPoll()
 				faultDiagArmIntentionalReset();
 				NVIC_SystemReset();
 
+				// 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];
 
@@ -785,14 +859,6 @@ void webusbPoll()
 				case 29:
 					g_landAll87 = v ? 1 : 0;
 					break;
-				// one-shot UF2 updater mode: arm/clear "enter UF2 on NEXT reboot".
-				case 30:
-					if (v)
-						armUf2NextBoot();
-					else
-						clearUf2NextBoot();
-					persist = false;
-					break;
 				}
 				if (persist)
 					saveCfg();

+ 6 - 1
OpenPuck/webusb_config.h

@@ -2,11 +2,16 @@
 // read/set runtime tunables. Binary framed protocol:
 //   host->dev:  0x01                 GET  -> reply status blob
 //               0x02 <field> <value> SET one byte field (see the switch in webusb_config.cpp)
-//                                      field 30 arms/clears "enter UF2 on next reboot" (1/0)
 //               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. If you want to stage it for later, `UF2 on reboot` arms a one-shot flag so the *next* reboot lands in UF2 bootloader automatically.
+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/): connect, `Pick UF2 file`, then `Flash firmware` — the new 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__":

+ 15 - 4
docs/BUILD_AND_DEPLOY.md

@@ -135,10 +135,6 @@ If the board has the **Adafruit nRF52 UF2 bootloader** (common on Pro Micro nRF5
 
 3. **Copy the `.uf2` file** onto the UF2BOOT drive. The board auto-ejects and reboots with the new firmware.
 
-You can also arm a **deferred UF2 update** from the running firmware (no immediate reboot): in the WebUSB panel,
-click **UF2 on reboot** so it shows "armed". The device stores a one-shot flag in config; on the **next reboot**
-it jumps straight to UF2 bootloader before normal app init. Use the same button again to clear the arm.
-
 Alternatively, use the **adafruit-nrfutil DFU** Python tool with the board in DFU mode (bootloader LED pulsing):
 
 ```bash
@@ -153,6 +149,21 @@ 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/): **Pick UF2 file** → **Flash firmware**. 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, where the panel reconnects. See `OpenPuck/fw_update.h` for the design.
+
+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:

+ 162 - 47
docs/index.html

@@ -66,12 +66,11 @@
   <button id="connectBtn" class="primary">Connect</button>
   <span class="spacer"></span>
   <input id="uf2File" class="hide" type="file" accept=".uf2,application/octet-stream">
-  <button id="uf2Pick" title="Choose a local .uf2 firmware file">Pick UF2 file</button>
-  <button id="uf2Flash" disabled title="Copy the selected .uf2 onto the UF2BOOT/NRF52BOOT drive">Write UF2 file</button>
+  <button id="uf2Pick" title="Choose a local .uf2 firmware file to flash">Pick UF2 file</button>
+  <button id="uf2Flash" disabled title="Pick a .uf2 file first">Flash firmware</button>
   <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>
-  <button id="dfuUf2" title="Reboot into the UF2 mass-storage bootloader; then use Write UF2 file">UF2 DFU</button>
-  <button id="uf2Next" title="Arm/clear one-shot updater mode: the next reboot enters UF2 bootloader">UF2 on reboot</button>
+  <button id="dfuUf2" title="Reboot into the UF2 mass-storage bootloader; drop a .uf2 on the drive">UF2 DFU</button>
   <button id="factoryErase" class="danger" title="Wipe ALL bonds + settings and reboot to defaults. You must re-pair. Cannot be undone.">⚠ Factory erase</button>
 </div>
 <div class="wrap">
@@ -253,7 +252,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;
-let selectedUf2File=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;
@@ -268,19 +270,15 @@ 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(){
+function updateUf2UI(txt){
   const pick=$("#uf2Pick"), flash=$("#uf2Flash");
   if(!pick || !flash) return;
-  const supportsDirectoryPicker = typeof window.showDirectoryPicker === "function";
-  flash.disabled = !selectedUf2File || !supportsDirectoryPicker;
-  pick.textContent = selectedUf2File ? ("UF2: " + selectedUf2File.name) : "Pick UF2 file";
-  if(!supportsDirectoryPicker){
-    flash.title = "Your browser does not support writing files from the panel";
-  } else if(!selectedUf2File){
-    flash.title = "Pick a .uf2 file first";
-  } else {
-    flash.title = "Copy " + selectedUf2File.name + " onto the UF2BOOT/NRF52BOOT drive";
-  }
+  pick.textContent = selectedUf2 ? ("UF2: " + selectedUf2.name) : "Pick UF2 file";
+  flash.disabled = dfuBusy || !selectedUf2;
+  flash.textContent = txt || "Flash firmware";
+  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");
@@ -767,8 +765,6 @@ function applyBlob(p){
   $("#persistMode").textContent = persistMode? "on":"off"; $("#persistMode").classList.toggle("active",!!persistMode);
   // land-all-0x87 experiment toggle (blob v14, firmware p[177] -> JS p[175])
   { const la=(p.length>175?p[175]:0); $("#landAll87").textContent = la?"on":"off"; $("#landAll87").classList.toggle("active",!!la); }
-  // one-shot UF2 updater arm (blob v15, firmware p[178] -> JS p[176])
-  { const u=(p.length>176?p[176]:0); $("#uf2Next").textContent = u?"UF2 on reboot: armed":"UF2 on reboot"; $("#uf2Next").classList.toggle("active",!!u); }
   // per-type button config (blob v10): 4 types x 8 bytes. readBlob() strips the 2-byte [0xA5][len] header, so
   // the firmware's buffer index 75 lands at JS index 73 here.
   if(p.length>=73+TYPE_DEFS.length*8){
@@ -1083,37 +1079,162 @@ async function startPolling(){
 
 // 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));
+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; updateUf2UI("Sending "+pct+"%"); }
+  }
+  updateUf2UI("Verifying…"); // firmware CRC32s the staged flash, then commits the apply-on-reboot 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");
+  updateUf2UI("Rebooting…");
+  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");
+}
 $("#uf2Pick").onclick=()=>{ $("#uf2File").click(); };
-$("#uf2File").onchange=(e)=>{
-  selectedUf2File = e.target.files?.[0] || null;
+$("#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) return;
+  try{
+    const image=uf2ToImage(await f.arrayBuffer());
+    selectedUf2={name:f.name, 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();
-  if(selectedUf2File) log("UF2 selected: " + selectedUf2File.name);
 };
 $("#uf2Flash").onclick=async()=>{
-  if(!selectedUf2File){ log("Pick a UF2 file first"); return; }
-  if(typeof window.showDirectoryPicker !== "function"){
-    log("Browser file-write API unavailable. Use UF2 DFU, then drag/drop manually.");
+  if(dfuBusy || !selectedUf2) 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 predates panel updates (needs status v15+) — update once via UF2 DFU + drag-and-drop, then this button works");
     return;
   }
-  const msg =
-    "Copy " + selectedUf2File.name + " to the UF2BOOT/NRF52BOOT drive?\n\n"
-    + "If the puck is not already in UF2 bootloader mode, click UF2 DFU first.";
-  if(!confirm(msg)) return;
+  if(!confirm("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.")) return;
+  dfuBusy=true; updateUf2UI();
+  polling=false;        // the IN pipe belongs to the update acks now
+  await dfuSleep(800);  // let an in-flight status poll finish its read first
   try{
-    const dir = await window.showDirectoryPicker({mode:"readwrite"});
-    const buf = await selectedUf2File.arrayBuffer();
-    const fh = await dir.getFileHandle(selectedUf2File.name, {create:true});
-    const wr = await fh.createWritable();
-    await wr.write(buf);
-    await wr.close();
-    const sizeKiB = Math.round(buf.byteLength / 1024);
-    log("UF2 copied: " + selectedUf2File.name + " (" + sizeKiB
-      + " KiB). Device should reboot automatically within a few seconds.");
+    await fwupRun(selectedUf2.image);
   }catch(e){
-    if(e.name === "AbortError")
-      log("UF2 copy canceled.");
-    else
-      log("UF2 copy failed: " + e.message);
+    log("firmware update FAILED: "+e.message+" — nothing was applied; the running firmware is untouched");
+    try{ await dev.transferOut(epOut,new Uint8Array([0x24])); }catch(_e){} // best-effort disarm
+    if(dev) startPolling();
+  }finally{
+    dfuBusy=false; updateUf2UI();
   }
 };
 $("#backupExport").onclick=exportBackup;
@@ -1156,12 +1277,6 @@ $("#dfuUf2").onclick=async()=>{
   if(!confirm("Reboot into UF2 bootloader?\n\nThe puck will disconnect and mount as a USB drive. Drag the .uf2 file onto it to flash.")) return;
   await send([0x0C]); log("UF2 bootloader reboot sent — device disconnecting");
 };
-$("#uf2Next").onclick=async()=>{
-  if(!dev){ log("not connected"); return; }
-  const on=$("#uf2Next").classList.contains("active");
-  await setField(30, on?0:1);
-  log(on?"UF2-on-reboot cleared":"UF2-on-reboot armed - next reboot enters UF2 bootloader");
-};
 $("#factoryErase").onclick=async()=>{
   if(!dev){ log("not connected"); return; }
   if(!confirm("Factory erase?\n\nThis wipes ALL persistent storage on the copycat:\n  • the paired-controller bond (you'll have to re-pair)\n  • every saved setting (mode, chords, back paddles, sensitivity)\n\nThe copycat reboots to factory defaults. This CANNOT be undone.")) return;