Forráskód Böngészése

Merge main into ps3

Bring main's PR #94 work (v12 diagnostics: fault_diag/clock fingerprint,
hang-stage/PC capture, relay-ring & phantom-slot fixes, reworked WebUSB
panel, PROTOCOL.md) into the ps3 branch, keeping ps3's DualShock 3 mode.

Conflict resolution:
- config.cpp, fault_diag.{cpp,h}, haptics.cpp, rf_link.{cpp,h},
  webusb_config.cpp: took main's version. These were the stability commits
  ps3 shares with #94, which main squashed then superseded with more work;
  ps3 made no independent edits to them.
- OpenPuck.ino: took main's added clockDiagBoot()/faultDiagSetStage(8);
  kept ps3's MODE_PS3 name (auto-merged).
- docs/index.html: took main's newer panel (extra stat tiles, v12 parsing,
  hang log; dropped the removed haptic-block controls); re-kept ps3's PS3
  mode button, MODE_NAMES entry, etypeForMode case 9, and clean-set/confirm.

Verified: no conflict markers remain; `make build` compiles clean
(159528 B flash / 36840 B RAM).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Jari 3 hónapja
szülő
commit
b19d65c64d

+ 30 - 0
OpenPuck/OpenPuck.ino

@@ -237,6 +237,9 @@ void setup()
 	// Classify why we (re)booted: distinguishes a watchdog hang from a HardFault from an intentional reboot
 	// (issue #72 -- those are conflated in the field). Surfaced on the WebUSB panel too.
 	faultDiagBoot();
+	// Clock fingerprint: which crystals/oscillators this (possibly clone) board actually came up on. Surfaced
+	// on the panel so flaky clones can be told apart from the known-good board.
+	clockDiagBoot();
 	if (puckMode)
 		Serial.printf(
 			"# puck USB: %s\n",
@@ -267,6 +270,8 @@ void setup()
 	NRF_WDT->CRV = 8UL * 32768UL - 1; // timeout in 32.768 kHz ticks (~8 s)
 	NRF_WDT->RREN = WDT_RREN_RR0_Msk; // arm reload register 0
 	NRF_WDT->TASKS_START = 1;
+	// Capture the stuck PC on the WDT's pre-reset interrupt (software stand-in for SWD on the clone hangs).
+	faultDiagArmHangCapture();
 }
 
 // loop-timing diagnostics: poll rate is capped by loop ITERATION time (pacing wants 4000us but the poll only
@@ -283,6 +288,11 @@ void loop()
 {
 	// feed the watchdog; if we ever stop, the ~8s WDT auto-resets us
 	NRF_WDT->RR[0] = WDT_RR_RR_Reload;
+	faultDiagBeat(); // loop heartbeat -- the SOF blob reports ms-since-beat so a wedge is visible live
+	faultDiagStackTick(); // per-task stack headroom (self-gated ~1Hz) -- usbd-overflow hypothesis check
+	hapticStabTask(); // stability-test keepalive buzz (no-op unless armed via WebUSB)
+	// cross-check HFCLK(micros) vs LFCLK(millis) once a second -- cheap, both builds (clone clock diagnostic)
+	clockDiagTick();
 	if (g_dirty) {
 		g_dirty = false;
 		saveBonds();
@@ -294,28 +304,37 @@ void loop()
 	static unsigned long secMs = 0;
 	uint32_t t;
 	t = micros();
+	faultDiagSetStage(0);
 	webusbPoll();
 	acc[0] += (uint32_t)(micros() - t);
 	t = micros();
+	faultDiagSetStage(1);
 	if (g_active)
 		g_active->task();
 	acc[1] += (uint32_t)(micros() - t);
 	t = micros();
+	faultDiagSetStage(2);
 	serialConsolePoll();
 	acc[2] += (uint32_t)(micros() - t);
 	t = micros();
+	faultDiagSetStage(3);
 	rfDiagTask();
 	acc[3] += (uint32_t)(micros() - t);
 	t = micros();
+	faultDiagSetStage(4);
 	rfLinkTask();
 	acc[4] += (uint32_t)(micros() - t);
 	t = micros();
+	faultDiagSetStage(5);
 	hapticTask();
 	acc[5] += (uint32_t)(micros() - t);
 	t = micros();
+	faultDiagSetStage(6);
 	ledTask();
 	acc[6] += (uint32_t)(micros() - t);
+	faultDiagSetStage(7);
 	usbMountTask(); // dynamic mount/unmount of connected controllers (no-op unless enabled)
+	faultDiagSetStage(8);
 	usbTxPump(); // drain queued device->host reports HERE, in loop -- never off-loop (jitters the RF poll)
 	loops++;
 	if (millis() - secMs >= 1000) {
@@ -335,15 +354,26 @@ void loop()
 		secMs = millis();
 	}
 #else
+	// faultDiagSetStage breadcrumb (GPREGRET2) before each stage: if loop() wedges, the watchdog reset's next
+	// boot reports which stage was stuck. Cheap (one register write each).
+	faultDiagSetStage(0);
 	webusbPoll();
+	faultDiagSetStage(1);
 	if (g_active)
 		g_active->task();
+	faultDiagSetStage(2);
 	serialConsolePoll();
+	faultDiagSetStage(3);
 	rfDiagTask();
+	faultDiagSetStage(4);
 	rfLinkTask();
+	faultDiagSetStage(5);
 	hapticTask();
+	faultDiagSetStage(6);
 	ledTask();
+	faultDiagSetStage(7);
 	usbMountTask(); // dynamic mount/unmount of connected controllers (no-op unless enabled)
+	faultDiagSetStage(8);
 	usbTxPump(); // drain queued device->host reports HERE, in loop -- never off-loop (jitters the RF poll)
 #endif
 }

+ 2 - 7
OpenPuck/config.cpp

@@ -151,13 +151,8 @@ void loadCfg()
 			// 0 is a valid setting (rumble off)
 			g_rumbleScale = c.rumbleScale;
 			// Connection tunables: the poll RX window is now FIXED (g_rxWin is const) -- any persisted
-			// rxWin10 is ignored. Restore the post-connect haptic block (enable + duration in seconds).
-			g_hapticBlockOn = c.hapticBlockOn ? 1 : 0;
-			// clamp seconds so s*1000 fits the uint16 ms field (max 60s)
-			g_hapticBlockMs = (uint16_t)((c.hapticBlockS > 60 ?
-							      60 :
-							      c.hapticBlockS) *
-						     1000u);
+			// rxWin10 is ignored. The post-connect haptic block is permanently disabled and no longer
+			// configurable, so any persisted hapticBlockOn/hapticBlockS is ignored too.
 		}
 		f.close();
 	}

+ 224 - 0
OpenPuck/fault_diag.cpp

@@ -1,5 +1,8 @@
 #include "fault_diag.h"
 #include <Arduino.h> // readResetReason(), NRF_POWER, NVIC_SystemReset, POWER_RESETREAS_*_Msk
+#include <FreeRTOS.h>
+#include <task.h> // uxTaskGetSystemState -> per-task stack high-water (overflow hypothesis check)
+#include <string.h>
 
 // GPREGRET2 markers. GPREGRET (id 0) is reserved by the Adafruit bootloader for the DFU magic; GPREGRET2 is
 // free for the application and is retained across soft/watchdog/pin reset, cleared only on power-on/brownout.
@@ -9,6 +12,66 @@
 
 static uint8_t g_reason = RR_UNKNOWN;
 static uint32_t g_resetReas = 0;
+static uint8_t g_hangStage = 0xFF;
+
+// ---- watchdog pre-reset PC capture ("software SWD") --------------------------------------------------------
+// The nRF52 WDT raises a TIMEOUT interrupt ~2 LFCLK cycles BEFORE it resets the chip. We take that interrupt at
+// the highest NVIC priority (0) -- above FreeRTOS/TinyUSB BASEPRI critical sections -- read the stacked PC of
+// whatever code was wedged, and stash it in a .noinit RAM struct that survives the reset. Next boot reports it
+// on the panel; map it with addr2line to name the stuck function. Caveat: this fires only if interrupts aren't
+// hard-masked (PRIMASK/CPSID). If a watchdog reset reports NO pc, the hang was a PRIMASK-off / flash-stall type
+// (a different, narrower class) -- itself a useful signal. .noinit isn't zeroed by the C runtime, so it rides
+// through the watchdog reset (unlike GPREGRET2, which this board's bootloader wipes).
+struct HangFrame {
+	uint32_t pc, lr, magic;
+};
+#define HANGPC_MAGIC 0x48414E47u // "HANG"
+__attribute__((section(".noinit"))) volatile struct HangFrame g_hangFrame;
+static uint32_t g_reportHangPC = 0, g_reportHangLR = 0;
+
+__attribute__((naked)) void WDT_IRQHandler(void)
+{
+	__asm volatile(
+		"tst lr, #4            \n" // EXC_RETURN bit2: 0=frame on MSP, 1=on PSP
+		"ite eq                \n"
+		"mrseq r0, msp         \n"
+		"mrsne r0, psp         \n"
+		"ldr r1, [r0, #24]     \n" // stacked PC  (frame[6])
+		"ldr r3, [r0, #20]     \n" // stacked LR  (frame[5])
+		"ldr r2, =g_hangFrame  \n"
+		"str r1, [r2, #0]      \n"
+		"str r3, [r2, #4]      \n"
+		"ldr r1, =0x48414E47   \n"
+		"str r1, [r2, #8]      \n"
+		"b .                   \n" // hold until the WDT reset lands (microseconds away)
+		".ltorg                \n");
+}
+
+// Enable the WDT TIMEOUT interrupt at top priority. Call once, right after NRF_WDT->TASKS_START in setup().
+void faultDiagArmHangCapture()
+{
+	NRF_WDT->INTENSET = WDT_INTENSET_TIMEOUT_Msk;
+	NVIC_SetPriority(WDT_IRQn, 0); // above FreeRTOS configMAX_SYSCALL priority -> fires through BASEPRI guards
+	NVIC_ClearPendingIRQ(WDT_IRQn);
+	NVIC_EnableIRQ(WDT_IRQn);
+}
+uint32_t faultDiagHangPC()
+{
+	return g_reportHangPC;
+}
+uint32_t faultDiagHangLR()
+{
+	return g_reportHangLR;
+}
+
+// Hang breadcrumb: loop() stage written to GPREGRET2 as 0x80|stage. 0x80..0x9F can never collide with the
+// intentional-reboot (0xB1) / HardFault (0xFA) markers or the power-on default (0), so the same register
+// disambiguates all of them at boot.
+#define G2_STAGE_FLAG 0x80u
+static const char *const STAGE_STR[] = { "webusb", "ctrl.task", "serial",
+					 "rfdiag", "rflink",	"haptic",
+					 "led",	   "usbmount",	"usbtx" };
+#define STAGE_COUNT ((uint8_t)(sizeof STAGE_STR / sizeof STAGE_STR[0]))
 
 static const char *const REASON_STR[RR_COUNT] = {
 	"unknown",   "power-on", "pin/replug", "WATCHDOG (hang)", "CPU lockup",
@@ -33,6 +96,47 @@ void faultDiagArmIntentionalReset()
 	NRF_POWER->GPREGRET2 = G2_INTENT;
 }
 
+// Live stage + loop heartbeat. The post-reset GPREGRET2 breadcrumb only works if the bootloader preserves
+// GPREGRET2 across a watchdog reset -- some clone boards clear it, so it reads blank. These LIVE values don't
+// depend on surviving a reset: the WebUSB blob send runs on the USB SOF interrupt (independent of loop()), so
+// when loop() wedges, the SOF drain can still report the current stage + how long loop() has been stuck,
+// straight to the panel during the ~8s before the watchdog fires.
+static volatile uint8_t g_curStage = 0xFF;
+static volatile uint32_t g_loopBeatMs = 0;
+
+void faultDiagSetStage(uint8_t stage)
+{
+	g_curStage = stage;
+	NRF_POWER->GPREGRET2 = (uint8_t)(G2_STAGE_FLAG | (stage & 0x1Fu));
+}
+
+void faultDiagBeat()
+{
+	g_loopBeatMs = millis();
+}
+uint8_t faultDiagCurStage()
+{
+	return g_curStage;
+}
+uint32_t faultDiagStallMs()
+{
+	// ms since loop() last beat. ~0 when healthy (loop beats ~250x/s); grows while loop() is stuck.
+	return (uint32_t)(millis() - g_loopBeatMs);
+}
+const char *faultDiagStageStr(uint8_t s)
+{
+	return s < STAGE_COUNT ? STAGE_STR[s] : "?";
+}
+
+uint8_t faultDiagHangStage()
+{
+	return g_hangStage;
+}
+const char *faultDiagHangStageStr()
+{
+	return g_hangStage < STAGE_COUNT ? STAGE_STR[g_hangStage] : "n/a";
+}
+
 void faultDiagBoot()
 {
 	uint32_t rr =
@@ -41,6 +145,27 @@ void faultDiagBoot()
 	NRF_POWER->GPREGRET2 = 0; // consume the marker for this boot cycle
 	g_resetReas = rr;
 
+	// If this boot follows a watchdog/lockup reset and GPREGRET2 holds a stage breadcrumb (0x80|stage), recover
+	// which loop stage was stuck. Only meaningful for hangs -- an intentional reboot/HardFault stamps its own
+	// marker over the breadcrumb, and a clean power-on zeroes it.
+	bool isHang = (rr & POWER_RESETREAS_DOG_Msk) ||
+		      (rr & POWER_RESETREAS_LOCKUP_Msk);
+	if (isHang && (g2 & G2_STAGE_FLAG) && g2 < G2_INTENT)
+		g_hangStage = (uint8_t)(g2 & 0x1Fu);
+	else
+		g_hangStage = 0xFF;
+
+	// Recover the PC the WDT pre-reset ISR captured (if it fired -- i.e. the hang wasn't PRIMASK-off). Consume
+	// the magic so a later watchdog reset that DIDN'T capture (PRIMASK-off) can't report a stale address.
+	if (isHang && g_hangFrame.magic == HANGPC_MAGIC) {
+		g_reportHangPC = g_hangFrame.pc;
+		g_reportHangLR = g_hangFrame.lr;
+	} else {
+		g_reportHangPC = 0;
+		g_reportHangLR = 0;
+	}
+	g_hangFrame.magic = 0;
+
 	uint8_t reason;
 	// Precedence: a physical pin reset / watchdog / lockup is unambiguous from RESETREAS; only a SREQ needs the
 	// GPREGRET2 marker to split intentional reboot vs HardFault.
@@ -65,6 +190,105 @@ void faultDiagBoot()
 	Serial.printf(
 		"# reset cause: %s (RESETREAS=0x%08lX gpregret2=0x%02X)\n",
 		REASON_STR[reason], (unsigned long)rr, g2);
+	if (g_hangStage != 0xFF)
+		Serial.printf("# hang stage: %s (%u)\n", faultDiagHangStageStr(),
+			      g_hangStage);
+	if (g_reportHangPC)
+		Serial.printf("# hang PC=0x%08lX LR=0x%08lX\n",
+			      (unsigned long)g_reportHangPC,
+			      (unsigned long)g_reportHangLR);
+}
+
+// ---- clock fingerprint -------------------------------------------------------------------------------------
+static uint8_t g_clkLf = 0, g_clkHf = 0;
+static uint16_t g_clkUsPerMs = 0;
+
+void clockDiagBoot()
+{
+	// LFCLKSTAT: bit16 STATE(running), bits1:0 SRC (0=RC,1=Xtal,2=Synth). HFCLKSTAT: bit16 STATE, bit0 SRC
+	// (0=RC,1=Xtal). The bare-metal radio needs HFXO; if HF shows RC here, RF runs on the 64 MHz internal RC.
+	uint32_t lf = NRF_CLOCK->LFCLKSTAT;
+	uint32_t hf = NRF_CLOCK->HFCLKSTAT;
+	bool lfrun = lf & CLOCK_LFCLKSTAT_STATE_Msk;
+	uint8_t lfsrc = (uint8_t)(lf & CLOCK_LFCLKSTAT_SRC_Msk);
+	g_clkLf = lfrun ? (uint8_t)(lfsrc + 1) : 0; // 1=RC,2=Xtal,3=Synth, 0=stopped
+	bool hfxtal = hf & CLOCK_HFCLKSTAT_SRC_Msk;
+	g_clkHf = hfxtal ? 2 : 0; // 2=crystal, 0=RC
+	Serial.printf("# clock: LFCLK=%s HFCLK=%s\n",
+		      g_clkLf == 2 ? "xtal" :
+		      g_clkLf == 1 ? "RC" :
+		      g_clkLf == 3 ? "synth" :
+				     "stopped",
+		      g_clkHf == 2 ? "xtal" : "RC");
+}
+
+void clockDiagTick()
+{
+	// Cross-check the two time bases: count micros() (HFCLK-derived) elapsed over a ~1s millis() (LFCLK/RTC)
+	// window. usPerMs = micros-delta / millis-delta; 1000 = the clocks agree. A clone whose HFCLK runs fast vs
+	// its LFCLK reads >1000 here -- the exact signature behind "poll rate too high, delivered too low".
+	static uint32_t u0 = 0;
+	static unsigned long m0 = 0;
+	static bool init = false;
+	unsigned long m = millis();
+	if (!init) {
+		u0 = micros();
+		m0 = m;
+		init = true;
+		return;
+	}
+	unsigned long dm = m - m0;
+	if (dm >= 1000) {
+		uint32_t du = (uint32_t)(micros() - u0);
+		g_clkUsPerMs = (uint16_t)(du / dm);
+		u0 = micros();
+		m0 = m;
+	}
+}
+
+uint8_t clockLfSrc()
+{
+	return g_clkLf;
+}
+uint8_t clockHfSrc()
+{
+	return g_clkHf;
+}
+uint16_t clockUsPerMs()
+{
+	return g_clkUsPerMs;
+}
+
+// ---- per-task stack headroom (usbd-overflow hypothesis check) ---------------------------------------------
+// uxTaskGetSystemState (configUSE_TRACE_FACILITY=1) reports each task's stack high-water mark = the LEAST free
+// stack it ever had, in words. The "usbd" task (200 words / 800 B, core-fixed) runs handleSet->relayEnqueue;
+// if this trends toward 0 under haptic load, the stack overflow is confirmed. Repo-scoped, read-only, no core
+// changes -- just observing FreeRTOS.
+static uint16_t g_usbdStackMin = 0xFFFF;
+static uint16_t g_loopStackMin = 0xFFFF;
+void faultDiagStackTick()
+{
+	static unsigned long ms = 0;
+	if ((uint32_t)(millis() - ms) < 1000)
+		return;
+	ms = millis();
+	static TaskStatus_t st[12];
+	UBaseType_t n = uxTaskGetSystemState(st, 12, NULL);
+	for (UBaseType_t i = 0; i < n; i++) {
+		uint16_t hw = (uint16_t)st[i].usStackHighWaterMark;
+		if (!strcmp(st[i].pcTaskName, "usbd") && hw < g_usbdStackMin)
+			g_usbdStackMin = hw;
+		else if (!strcmp(st[i].pcTaskName, "loop") && hw < g_loopStackMin)
+			g_loopStackMin = hw;
+	}
+}
+uint16_t faultDiagUsbdStackFree()
+{
+	return g_usbdStackMin == 0xFFFF ? 0 : g_usbdStackMin;
+}
+uint16_t faultDiagLoopStackFree()
+{
+	return g_loopStackMin == 0xFFFF ? 0 : g_loopStackMin;
 }
 
 uint8_t faultDiagReason()

+ 47 - 0
OpenPuck/fault_diag.h

@@ -36,7 +36,54 @@ void faultDiagBoot();
 // Stamp "the reset about to happen is intentional" -- call immediately before a deliberate NVIC_SystemReset so
 // the next boot classifies it as RR_REBOOT, not RR_HARDFAULT.
 void faultDiagArmIntentionalReset();
+
+// ---- hang-stage breadcrumb -------------------------------------------------------------------------------
+// loop() writes the stage it is ENTERING to GPREGRET2 (encoded 0x80|stage, distinct from the intentional/fault
+// markers and from 0 during normal SREQ). If loop() wedges, the ~8s watchdog fires; GPREGRET2 survives that
+// reset, so the next boot can report WHICH loop stage was stuck -- the missing piece for the clone hangs.
+// Stage indices match the OPK_LOG timing order: 0 webusb,1 ctrl.task,2 serial,3 rfdiag,4 rflink,5 haptic,
+// 6 led,7 usbmount,8 usbtx.
+void faultDiagSetStage(uint8_t stage);
+// The stage loop() was in when the last WATCHDOG/LOCKUP reset fired (0xFF = last boot wasn't a hang, or the
+// breadcrumb didn't survive -- some clone bootloaders clear GPREGRET2). String form for the panel/console.
+uint8_t faultDiagHangStage();
+const char *faultDiagHangStageStr();
+
+// Live hang localization (does NOT need a reset to survive): faultDiagBeat() is called once per loop()
+// iteration; faultDiagStallMs() is ms since that last beat (~0 healthy, grows while loop() is wedged), and
+// faultDiagCurStage() is the stage loop() is currently in. The SOF-driven blob reports these so the panel can
+// show "STALLED in <stage>" live, before the watchdog even fires.
+void faultDiagBeat();
+uint8_t faultDiagCurStage();
+uint32_t faultDiagStallMs();
+const char *faultDiagStageStr(uint8_t s);
+
+// Watchdog pre-reset PC capture ("software SWD"): arm in setup() right after NRF_WDT->TASKS_START. After a
+// watchdog hang, faultDiagHangPC()/LR() return the PC/LR of the stuck code (0 if the hang hard-masked
+// interrupts so the capture ISR couldn't run). Map the PC with addr2line on the build .elf.
+void faultDiagArmHangCapture();
+uint32_t faultDiagHangPC();
+uint32_t faultDiagHangLR();
+
+// Per-task stack headroom (words of stack never used = free). Call faultDiagStackTick() from loop() (self-gated
+// to ~1 Hz). faultDiagUsbdStackFree() trending toward 0 under haptic load confirms the usbd-task overflow.
+void faultDiagStackTick();
+uint16_t faultDiagUsbdStackFree();
+uint16_t faultDiagLoopStackFree();
 // Last-boot classification (RR_*) + the raw RESETREAS, for the WebUSB panel / console.
 uint8_t faultDiagReason();
 uint32_t faultDiagResetReas();
 const char *faultDiagReasonStr();
+
+// ---- clock fingerprint (clone-board stability diagnostic) -------------------------------------------------
+// nice!nano clones vary in their crystals. The bare-metal radio needs HFXO (32 MHz xtal); millis()/RTC and the
+// watchdog run on LFCLK (32.768 kHz xtal or RC). If a clone runs HFCLK on the internal RC, or its LFCLK is
+// off-rate, the two time bases diverge -- which inflates the micros()-gated poll rate, shortens the RX window
+// (delivered drops), and skews the LFCLK-based watchdog. These let the panel show, per board: which source
+// each clock is actually on, and the measured micros()/millis() ratio (ideal 1000 us per ms).
+//   LF code: 0=stopped, 1=RC, 2=crystal, 3=synth.  HF code: 0=RC(running or not), 2=crystal.
+void clockDiagBoot(); // read+log the clock sources once at boot
+void clockDiagTick(); // call from loop(); recomputes usPerMs about once a second
+uint8_t clockLfSrc();
+uint8_t clockHfSrc();
+uint16_t clockUsPerMs(); // measured micros() advanced per millis() tick (ideal 1000; 0 until first sample)

+ 47 - 6
OpenPuck/haptics.cpp

@@ -75,6 +75,12 @@ static inline uint8_t rqNext(uint8_t i)
 {
 	return (uint8_t)((i + 1) % RELAY_QLEN);
 }
+// Counts times a ring-drain loop hit its iteration cap = head/tail were desynced or corrupted (e.g. an
+// out-of-range g_rqHead from a stray write/stack overflow). These loops run with IRQs DISABLED, so an
+// unterminated one spins forever with interrupts off -> millis()/USB/SOF all freeze and only the hardware
+// watchdog recovers (an invisible "watchdog (hang)" -- the live stall monitor can't see it because the SOF
+// IRQ is dead). The cap turns that into a logged, recovered event instead of a hang. Surfaced on the panel.
+volatile uint16_t g_ringFault = 0;
 
 bool relayPending()
 {
@@ -237,7 +243,13 @@ static void hapticCancelPendingOn()
 	uint32_t pm = __get_PRIMASK();
 	__disable_irq();
 	for (int s = 0; s < NSLOT; s++) {
+		uint8_t guard = RELAY_QLEN + 1;
 		for (uint8_t i = g_rqTail[s]; i != g_rqHead[s]; i = rqNext(i)) {
+			if (!guard--) { // desynced/corrupt ring -> recover, don't spin IRQs-off
+				g_rqHead[s] = g_rqTail[s] = 0;
+				g_ringFault++;
+				break;
+			}
 			RelayMsg &m = g_rq[s][i];
 			if (m.rid == 0x82) {
 				bool on = false;
@@ -321,6 +333,29 @@ bool hapticSteamRumble(uint16_t lowFreq, uint16_t highFreq, uint8_t slot)
 // haptics broadcast to all connected slots (slot 0xFF); the stop frame is broadcast too (a stuck latch can
 // affect any controller, and the haptic-engine clear-re-init is settings-only so it's harmless on healthy
 // ones).
+// Stability-test keepalive: while g_stabTest, buzz every connected controller for ~150ms once per 10s so an
+// unattended uptime measurement isn't ended by a controller idle-sleeping. Toggled by WebUSB cmd 0x0F; the
+// panel times uptime-until-reset. Reboots clear g_stabTest (the panel re-arms it on reconnect).
+bool g_stabTest = false;
+void hapticStabTask()
+{
+	if (!g_stabTest)
+		return;
+	static const uint8_t on[3] = { 0x01, 0x01, 0xF7 };
+	static const uint8_t off[3] = { 0x01, 0x01, 0x00 };
+	static unsigned long lastOn = 0, offAt = 0;
+	unsigned long now = millis();
+	if (lastOn == 0 || (uint32_t)(now - lastOn) >= 10000u) {
+		lastOn = now;
+		relayEnqueue(0x82, on, 3, 0xFF); // broadcast buzz-on to all slots
+		offAt = now + 150;
+	}
+	if (offAt && (int32_t)(now - offAt) >= 0) {
+		relayEnqueue(0x82, off, 3, 0xFF);
+		offAt = 0;
+	}
+}
+
 void rfConnQueueHapticRelay()
 {
 	if (relayPending())
@@ -338,7 +373,7 @@ void rfConnQueueHapticRelay()
 // rfConnFlushRelay(ch, s1): drain one entry from the current slot's relay queue and TX it. Each slot's queue
 // is independent, so each controller only sees its own commands. With N connected slots the per-slot relay
 // rate is 1/N of the per-cycle rate; sustained buzz streams are still 1 packet/cycle/slot.
-void rfConnFlushRelay(uint8_t ch, uint8_t s1)
+bool rfConnFlushRelay(uint8_t ch, uint8_t s1)
 {
 	int cur = (g_curSlot >= 0 && g_curSlot < NSLOT) ? g_curSlot : 0;
 	// Snapshot one entry under a short critical section. relayEnqueue() (the producer) runs on the
@@ -350,7 +385,13 @@ void rfConnFlushRelay(uint8_t ch, uint8_t s1)
 	bool have = false;
 	uint32_t pm = __get_PRIMASK();
 	__disable_irq();
+	uint8_t guard = RELAY_QLEN + 1;
 	while (g_rqTail[cur] != g_rqHead[cur]) {
+		if (!guard--) { // desynced/corrupt ring -> recover, don't spin IRQs-off (watchdog-hang class)
+			g_rqHead[cur] = g_rqTail[cur] = 0;
+			g_ringFault++;
+			break;
+		}
 		RelayMsg &m = g_rq[cur][g_rqTail[cur]];
 		g_rqTail[cur] = rqNext(g_rqTail[cur]); // consume the slot
 		// rid 0 = entry voided by hapticCancelPendingOn -> skip
@@ -402,6 +443,7 @@ void rfConnFlushRelay(uint8_t ch, uint8_t s1)
 				 80); // one relay per poll cycle
 		}
 	}
+	return have; // true = a relay frame went out this cycle (steals a reply window from the E3 poll)
 }
 
 // Haptic-subsystem re-init: the captured sequence Steam sends when it (re)takes control (0x81 reset + 0x87
@@ -470,9 +512,8 @@ void hapticInit()
 		g_rqHead[s] = g_rqTail[s] = 0;
 		g_rumble80On[s] = false;
 		g_rumble80Ms[s] = 0;
-		// block stale Steam 0x82 until the link is stable after boot (when the block is enabled)
-		g_hapticBlockUntil[s] =
-			g_hapticBlockOn ? (millis() + g_hapticBlockMs) : 0;
+		// post-connect haptic block is permanently disabled (not armed, not configurable)
+		g_hapticBlockUntil[s] = 0;
 	}
 }
 // Arm the post-(re)connect haptic block + schedule the clearing re-init. Called on the reliable first-reply
@@ -482,8 +523,8 @@ void hapticOnReconnect(int slot)
 {
 	if (slot < 0 || slot >= NSLOT)
 		return;
-	g_hapticBlockUntil[slot] =
-		g_hapticBlockOn ? (millis() + g_hapticBlockMs) : 0;
+	// post-connect haptic block is permanently disabled -- relay haptics immediately on (re)connect
+	g_hapticBlockUntil[slot] = 0;
 	g_haptic82On = false;
 	g_rumble80On[slot] = false;
 	g_rumble80Ms[slot] = 0;

+ 11 - 1
OpenPuck/haptics.h

@@ -106,8 +106,18 @@ bool hapticSteamRumble(uint16_t lowFreq, uint16_t highFreq, uint8_t slot = 0);
 // queue + flush the pending host/test/stop relay inside the poll cadence (called from rf_link).
 // rfConnFlushRelay's s1 must carry a PID distinct from the GET poll that follows it (rf_link cycles the shared
 // PID counter), so the controller doesn't dedup the GET as a retransmit of the relay.
+// Stability test: when g_stabTest (WebUSB cmd 0x0F), buzz all controllers every 10s to keep them awake for an
+// unattended uptime-until-hang measurement. hapticStabTask() is called from loop().
+extern bool g_stabTest;
+void hapticStabTask();
+
 void rfConnQueueHapticRelay();
-void rfConnFlushRelay(uint8_t ch, uint8_t s1);
+// returns true if a relay frame was actually transmitted this call (queue had an entry), so the poll loop can
+// count relay TXs separately from poll cycles.
+bool rfConnFlushRelay(uint8_t ch, uint8_t s1);
+// times a relay-ring drain hit its iteration cap (head/tail desync or corruption) -- non-zero means we caught
+// and recovered from what would otherwise be an IRQ-off watchdog hang. Surfaced on the WebUSB panel.
+extern volatile uint16_t g_ringFault;
 
 // boot reset: clear relay/active flags, arm the reconnect block
 void hapticInit();

+ 24 - 8
OpenPuck/rf_link.cpp

@@ -101,6 +101,11 @@ int g_curSlot = -1;
 static uint8_t g_pollPid[NSLOT] = {};
 static uint8_t g_relayPid[NSLOT] = {};
 static uint32_t g_stPoll = 0, g_stF1 = 0, g_stF3 = 0;
+// g_stPoll counts true poll CYCLES (one E3 GET per warm slot per cycle). g_stRelay counts relay frames TX'd
+// (host/haptic output reports). They were conflated before (every rfConnTx bumped one counter), which made
+// "Polls/s" read ~540 (250 polls + ~290 relays) and hid that each relay steals a reply window from its poll.
+static uint32_t g_stRelay = 0;
+uint16_t g_relayps = 0;
 static unsigned long g_stMs = 0;
 // Per-slot dedupe seq + per-slot new-report counter (the real puck sends 0x45 per controller; merging all
 // slots into a single sequence makes one controller "swallow" the other's frame).
@@ -258,7 +263,6 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 			    RADIO_SHORTS_DISABLED_RSSISTOP_Msk;
 	NRF_RADIO->EVENTS_END = 0;
 	NRF_RADIO->TASKS_RXEN = 1;
-	g_stPoll++;
 	uint32_t t0 = micros();
 	while (!NRF_RADIO->EVENTS_END && (micros() - t0) < win) {
 	} // RX window (tunable 'r'; or relay override)
@@ -748,7 +752,8 @@ static void rfConnStep()
 		{
 			uint8_t rs1 =
 				(uint8_t)((((g_relayPid[k]++) & 3) << 1) | 1);
-			rfConnFlushRelay(ch, rs1);
+			if (rfConnFlushRelay(ch, rs1))
+				g_stRelay++;
 		}
 		// per-slot PID cycle so each bonded controller's polls stay distinct
 		uint8_t pidv = g_pollPid[k]++;
@@ -768,18 +773,27 @@ static void rfConnStep()
 		}
 		if (rx)
 			g_chF1[0]++;
+		g_stPoll++; // one true poll cycle for this slot
 		g_connPoll++;
 	};
 
-	// Poll every warm slot this cycle. "Cold" = was connected, now silent > SLOT_COLD_MS;
-	// those are retried only every SLOT_COLD_RETRY_MS. Never-replied slots count as warm so
-	// a controller that connects at any time gets the full poll rate to establish its link.
+	// Poll a slot every cycle (full rate) only when its controller is actually here. Throttle the rest to
+	// every SLOT_COLD_RETRY_MS:
+	//   - "cold": WAS connected but silent > SLOT_COLD_MS (controller powered off / out of range);
+	//   - "phantom": NEVER replied AND another controller is already connected. This is the cloned-puck case
+	//     (issue: bonds copied from a backup include controllers that aren't present). Polling a phantom slot
+	//     every cycle was doubling/tripling Polls/s, flooding noRx, and stealing reply windows from the live
+	//     controller. A never-replied slot with nothing else connected still polls full-rate, so the FIRST
+	//     controller connects instantly; a phantom only backs off once a real link exists to protect.
+	bool linkUp = anySlotLinkUp();
 	for (int k = 0; k < NSLOT; k++) {
 		if (!g_slot[k].used)
 			continue;
-		bool cold = g_connReplyMs[k] != 0 &&
-			    nowMs - g_connReplyMs[k] > SLOT_COLD_MS;
-		if (cold && nowMs - g_slotLastAttemptMs[k] < SLOT_COLD_RETRY_MS)
+		bool everReplied = g_connReplyMs[k] != 0;
+		bool cold = everReplied && (nowMs - g_connReplyMs[k] > SLOT_COLD_MS);
+		bool phantom = !everReplied && linkUp;
+		if ((cold || phantom) &&
+		    nowMs - g_slotLastAttemptMs[k] < SLOT_COLD_RETRY_MS)
 			continue;
 		doPoll(k);
 	}
@@ -898,6 +912,7 @@ void rfLinkTask()
 		g_f1ps = g_stF1;
 		g_newps = g_stNew;
 		g_pollsps = (uint16_t)g_stPoll;
+		g_relayps = (uint16_t)g_stRelay;
 		g_crcps = (uint16_t)g_stCrc;
 		g_norxps = (uint16_t)g_stNoRx;
 		g_pollPeriodUs =
@@ -913,6 +928,7 @@ void rfLinkTask()
 				(unsigned long)g_stCrc, (unsigned long)g_stNoRx,
 				g_curSlot);
 		g_stPoll = 0;
+		g_stRelay = 0;
 		g_stF1 = 0;
 		g_stNew = 0;
 		g_stF3 = 0;

+ 5 - 1
OpenPuck/rf_link.h

@@ -78,8 +78,12 @@ extern uint16_t g_f1ps; // last completed second's F1 rate
 // genuine new-report rate (report 0x45 seq byte changes)
 extern uint16_t g_newps;
 
-// last second's poll TX count (GET+relay) -- vs F1 tells starvation from reply-loss
+// last second's TRUE poll-cycle count (one E3 GET per warm slot per cycle, ~250 for one controller) -- vs F1
+// tells starvation from reply-loss.
 extern uint16_t g_pollsps;
+// last second's relay-frame TX count (host/haptic output reports forwarded to the controller). Each relay TX
+// precedes a poll and consumes a reply window, so a high relay rate directly depresses the F1/delivered rate.
+extern uint16_t g_relayps;
 // last second's CRC-fail and no-reply counts: diagnose a wedge -- polls>0 + noRx high = RX dead / wrong
 // channel / controller silent; crc high = corrupt replies (interference); polls==0 = poll loop stopped.
 extern uint16_t g_crcps, g_norxps;

+ 144 - 23
OpenPuck/webusb_config.cpp

@@ -18,6 +18,10 @@ Adafruit_USBD_WebUSB usb_web;
 // goes through the same blocking osd_queue_send path as HID sends, so doing it from loop() could stall the
 // loop and trip the watchdog under load -- with the panel open during heavy use that was a live risk.
 static volatile bool g_blobRequest = false;
+// Bond export ("clone" backup): a 0x09 panel command asks for a one-shot dump of all four bond slots so the
+// 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;
 
 // 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]
@@ -31,7 +35,8 @@ static volatile bool g_blobRequest = false;
 // p[6]/p[7]/p[8..11] mirror the ACTIVE type (legacy display). v11 extends to 113 bytes (115 total incl header);
 // browser reads with transferIn(128) to span the two USB-FS packets.
 //                [diag: crc/s][noRx/s][rfStallRecover count]
-#define WB_PAYLEN 116
+//                [v12: relayps(lo,hi)][clkLfSrc][clkHfSrc][usPerMs(lo,hi)][hangStage][curStage][stallMs/40][ringFault(lo,hi)][hangPC u32][hangLR u32][usbdStackFree(lo,hi)][loopStackFree(lo,hi)]
+#define WB_PAYLEN 139
 // 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
@@ -52,8 +57,8 @@ static void webusbSendBlob()
 	p[0] = 0xA5;
 	p[1] = WB_PAYLEN;
 
-	// protocol version (11 = +reset cause; 10 = +ledBright per type; 9 = +per-type cfg; 8 = +per-slot link status; 7 = +raw accel; 6 = +swPro120/gyroScale)
-	p[2] = 11;
+	// protocol version (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] = 12;
 	p[3] = g_usbMode;
 	p[4] = (uint8_t)g_mDiv;
 	p[5] = (uint8_t)g_mFric;
@@ -162,6 +167,44 @@ static void webusbSendBlob()
 	p[115] = (uint8_t)(g_crcps > 255 ? 255 : g_crcps);
 	p[116] = (uint8_t)(g_norxps > 255 ? 255 : g_norxps);
 	p[117] = (uint8_t)(g_rfStallRecover > 255 ? 255 : g_rfStallRecover);
+	// v12: relay-frame TX rate (separated from true poll cycles -- Polls/s now reads ~250, Relay/s exposes the
+	// host output-report flood that steals reply windows), and the clock fingerprint for clone-board triage.
+	p[118] = (uint8_t)g_relayps;
+	p[119] = (uint8_t)(g_relayps >> 8);
+	p[120] = clockLfSrc();
+	p[121] = clockHfSrc();
+	uint16_t upm = clockUsPerMs();
+	p[122] = (uint8_t)upm;
+	p[123] = (uint8_t)(upm >> 8);
+	// last hang's loop stage (0xFF = last boot wasn't a watchdog/lockup hang, or GPREGRET2 didn't survive)
+	p[124] = faultDiagHangStage();
+	// LIVE hang localization: current loop stage + how long loop() has been stuck (40ms units, capped). When
+	// loop() wedges these keep updating because the blob is sent from the SOF interrupt, not loop().
+	p[125] = faultDiagCurStage();
+	uint32_t sms = faultDiagStallMs();
+	p[126] = (uint8_t)(sms / 40 > 255 ? 255 : sms / 40);
+	// relay-ring fault count: non-zero = we caught+recovered a desynced/corrupt ring that would otherwise be an
+	// invisible IRQ-off watchdog hang. A climbing count points at the haptic relay path / memory corruption.
+	p[127] = (uint8_t)g_ringFault;
+	p[128] = (uint8_t)(g_ringFault >> 8);
+	// PC/LR of the stuck code captured by the WDT pre-reset ISR on the last watchdog hang (0 = none captured =
+	// the hang hard-masked interrupts). Map with addr2line on the .elf to name the function.
+	uint32_t hpc = faultDiagHangPC(), hlr = faultDiagHangLR();
+	p[129] = (uint8_t)hpc;
+	p[130] = (uint8_t)(hpc >> 8);
+	p[131] = (uint8_t)(hpc >> 16);
+	p[132] = (uint8_t)(hpc >> 24);
+	p[133] = (uint8_t)hlr;
+	p[134] = (uint8_t)(hlr >> 8);
+	p[135] = (uint8_t)(hlr >> 16);
+	p[136] = (uint8_t)(hlr >> 24);
+	// least-ever free stack (words) on the usbd task (800B total) and loop task -- usbd trending to 0 confirms
+	// the overflow. words, not bytes.
+	uint16_t usbdFree = faultDiagUsbdStackFree(), loopFree = faultDiagLoopStackFree();
+	p[137] = (uint8_t)usbdFree;
+	p[138] = (uint8_t)(usbdFree >> 8);
+	p[139] = (uint8_t)loopFree;
+	p[140] = (uint8_t)(loopFree >> 8);
 	// 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
@@ -176,14 +219,53 @@ static void webusbSendBlob()
 	}
 }
 
+// Bond export frame (panel "Export config to file"). One self-describing frame carrying all four bond slots so
+// the browser can clone a puck onto another without re-pairing:
+//   [0xA7][len][ver=1][usedMask][slot0 rec 24]...[slot3 rec 24]   (len = 2 + NSLOT*24 = 98)
+// usedMask bit s = slot s bonded. The session RF address is NOT exported: each puck re-derives it from the bond
+// UUID + its own FICR DEVICEID at boot, and the bonded controller relearns it from the E1 host beacon on every
+// reconnect (rf_link.cpp) -- so the 24-byte record is the whole portable identity. 98+2 < transferIn(128) and
+// < CFG_TUD_VENDOR_TX_BUFSIZE (256), so it sends whole in one drop-on-full write like the status blob.
+#define WB_BOND_PAYLEN (2 + NSLOT * 24)
+static void webusbSendBondExport()
+{
+	if (!usb_web.connected())
+		return;
+	uint8_t p[2 + WB_BOND_PAYLEN];
+	p[0] = 0xA7;
+	p[1] = WB_BOND_PAYLEN;
+	p[2] = 1; // format version
+	uint8_t mask = 0;
+	for (int s = 0; s < NSLOT; s++) {
+		if (g_slot[s].used)
+			mask |= (uint8_t)(1u << s);
+		memcpy(&p[4 + s * 24], g_slot[s].rec, 24);
+	}
+	p[3] = mask;
+	// Same anti-hang rule as the status blob: drop the frame if the FIFO can't take it whole, never spin.
+	if (tud_vendor_write_available() >= sizeof p) {
+		usb_web.write(p, sizeof p);
+		usb_web.flush();
+	}
+}
+
 // Runs on the usbd task (registered via usbTxRegisterDrain -> tud_sof_cb). Sends the blob if loop() asked for
 // one. Keeps every usb_web write/flush off the loop task so it can't block on the device event queue.
 static void webusbSofDrain(void)
 {
+	// If loop() has stopped beating, it's wedged -- keep pushing the blob (which carries the live stuck stage)
+	// so the panel shows WHERE it hung during the ~8s before the watchdog fires. This runs on the SOF IRQ, so
+	// it works even though loop() is dead. drop-on-full in webusbSendBlob keeps it from ever blocking.
+	if (faultDiagStallMs() > 300)
+		g_blobRequest = true;
 	if (g_blobRequest) {
 		g_blobRequest = false;
 		webusbSendBlob();
 	}
+	if (g_bondExportRequest) {
+		g_bondExportRequest = false;
+		webusbSendBondExport();
+	}
 }
 // Register the SOF drain. Call once from setup() (harmless even if WebUSB isn't enumerated -- webusbSendBlob
 // no-ops while disconnected, and g_blobRequest is only ever set by panel traffic, which needs a connection).
@@ -244,7 +326,9 @@ static void webusbDrainCapture()
 #endif // OPK_LOG
 void webusbPoll()
 {
-	static uint8_t buf[16];
+	// 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];
 	static uint8_t n = 0;
 	while (usb_web.available()) {
 		int c = usb_web.read();
@@ -257,22 +341,38 @@ void webusbPoll()
 			if (n == 0)
 				break;
 			uint8_t op = buf[0];
-
-			// 0x05 carries a value byte; 0x0A carries a 3-byte magic
-			uint8_t need = (op == 0x02)		  ? 3 :
-				       (op == 0x03 || op == 0x05) ? 2 :
-				       (op == 0x0A)		  ? 4 :
-								    1;
-			if (op < 0x01 || op > 0x0C) { // resync: drop one byte
+			if (op < 0x01 || op > 0x0F) { // 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 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)     ? 2 :
+				       (op == 0x0A)		      ? 4 :
+								        1;
 			if (n < need)
 				break; // wait for more bytes
 			if (op == 0x01) {
 				g_blobRequest =
 					true; // sent from the usbd task (webusbSofDrain)
 			}
+			// 0x09: export all bond slots (clone backup). One 0xA7 frame, sent from the usbd task.
+			else if (op == 0x09) {
+				g_bondExportRequest = true;
+			}
+			// 0x0F: stability test on/off. Puck->controller haptics do NOT reset the controller's own
+			// user-input idle auto-off (we already poll it every 4ms without keeping it awake), so instead
+			// signal host-awake: enable the E7 announce (0xE7 00 00 = host-awake vs 00 01 = suspended, per
+			// the RE) so the controller is told the host is active and (hopefully) doesn't power-save. The
+			// 10s buzz stays -- it also exercises the haptic-relay path that correlates with the hang.
+			else if (op == 0x0F) {
+				g_stabTest = (buf[1] != 0);
+				g_e7b = 0; // byte2=0 => host-awake
+				g_e7announce = g_stabTest;
+			}
 #if OPK_LOG
 
 			// rewind drain: buf[1]=1 from boot (whole ring), 0 from now
@@ -320,6 +420,38 @@ void webusbPoll()
 				faultDiagArmIntentionalReset();
 				enterUf2Dfu();
 
+				// 0x0D: write ONE bond slot into RAM -- the "clone onto this puck" side of Export/Import.
+				// [0x0D][slot][used][24 rec]. used=0 (or an empty record) clears the slot. The panel sends
+				// one of these per slot, then a 0x0E to persist + reboot. Kept to a single small command
+				// (<=27 B) so it never spans a USB packet, and acked with a status blob so the panel's
+				// read-after-write keeps the OUT pipe flowing (same shape as the proven 0x02 setters).
+			} else if (op == 0x0D) {
+				uint8_t slot = buf[1], used = buf[2];
+				if (slot < NSLOT) {
+					if (used && !recEmpty(buf + 3)) {
+						memcpy(g_slot[slot].rec, buf + 3,
+						       24);
+						g_slot[slot].used = true;
+					} else {
+						memset(g_slot[slot].rec, 0, 24);
+						g_slot[slot].used = false;
+					}
+				}
+				g_blobRequest = true; // ack
+
+				// 0x0E: commit an import -- persist the bond slots written by 0x0D, optionally apply a USB
+				// mode ([0x0E][mode]; 0xFF = leave unchanged), then reboot so the RF session addresses
+				// regenerate from the new bond UUIDs.
+			} else if (op == 0x0E) {
+				uint8_t mode = buf[1];
+				saveBonds();
+				if (modeValid(mode))
+					saveMode(mode);
+				usb_web.flush();
+				delay(40);
+				faultDiagArmIntentionalReset();
+				NVIC_SystemReset();
+
 			} else if (op == 0x02) {
 				uint8_t f = buf[1], v = buf[2];
 
@@ -459,18 +591,7 @@ void webusbPoll()
 					break; // Switch Pro gyro scale x10
 
 				// (field 25, poll RX window, removed -- g_rxWin is now FIXED/not configurable)
-
-				// post-connect haptic block enable: drop Steam haptics for g_hapticBlockMs after a connect
-				case 27:
-					g_hapticBlockOn = v ? 1 : 0;
-					break;
-
-				// post-connect haptic block duration, in seconds (clamp 0..60)
-				case 28:
-					g_hapticBlockMs =
-						(uint16_t)((v > 60 ? 60 : v) *
-							   1000u);
-					break;
+				// (fields 27/28, post-connect haptic block, removed -- permanently disabled)
 				}
 				if (persist)
 					saveCfg();

+ 3 - 0
README.md

@@ -1,3 +1,6 @@
+> [!WARNING]
+> EXTREMELY IMPORTANT PLEASE READ: We're finding that stability of this software across different versions of copycat nrf pro micros varies wildly. If you're coming into this project fresh then please be warned that you might encounter disconnection issues depending on which board you buy. I'm actively working on testing multiple brands and will post a list of known working well boards soon.
+
 # OpenPuck
 Opensource firmware for NRF52840 Pro Micro that copycats the Steam Controller 2 Puck and adds an Xbox and Switch mode.
 

+ 42 - 1
docs/PROTOCOL.md

@@ -370,8 +370,16 @@ Messages:
   - `0x01`: get status blob
   - `0x02 <field> <value>`: set one field
   - `0x03 <mode>`: switch mode and reboot
+  - `0x07`: re-init haptics (clear a stuck buzz)
+  - `0x08`: send controller power-off
+  - `0x09`: export all bond slots (reply: `0xA7` frame) — see §10.1
+  - `0x0A 0x45 0x52 0x53`: factory erase (`"ERS"` magic), then reboot
+  - `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
 - Device to host:
-  - `0xA5 <len> <payload>`
+  - `0xA5 <len> <payload>`: status blob
+  - `0xA7 <len> <payload>`: bond export (§10.1)
 
 Status blob payload:
 
@@ -401,6 +409,39 @@ Status blob payload:
 [22] persist-mode flag
 ```
 
+### 10.1 Backup / clone (bond export & import)
+
+A puck's whole portable identity is its four 24-byte bond records (`[proteus_uuid 4][ibex_uuid 4][serial 16]`,
+§2). The per-bond on-air *session address* is **not** stored or transferred: each puck re-derives it from the
+bond UUID mixed with its own FICR `DEVICEID`, and the bonded controller relearns it from the `E1` host beacon on
+every reconnect. So copying the bond records onto a second puck is sufficient for any controller paired to the
+first to connect to the second **with no re-pairing** — the two pucks just derive different (non-colliding)
+session addresses for the same controller.
+
+This makes a puck cloneable. The WebUSB panel exposes it as **Export to file** / **Import from file**.
+
+**Export — `0x09` → `0xA7` frame:**
+
+```text
+[0]    format version (1)
+[1]    used mask (bit s = slot s bonded)
+[2..]  slot 0..3 records, 24 bytes each (96 bytes)        (len = 2 + 4*24 = 98)
+```
+
+**Import — `0x0D` (per slot) then `0x0E` (commit):**
+
+```text
+0x0D  <slot 0..3>  <used 0|1>  <24-byte record>      writes one slot into RAM (used=0 clears it)
+0x0E  <mode>                                         persist all slots, apply mode (0xFF=unchanged), reboot
+```
+
+Bonds are written one slot at a time so no command exceeds a single USB-FS packet, and each `0x0D`/`0x02` is
+acked with a status blob so the host's read-after-write keeps the OUT pipe flowing. The panel sends all four
+`0x0D` slot writes plus every config field via `0x02`, then a single `0x0E` to persist and reboot — so the
+reboot regenerates the session addresses from the new UUIDs and the restored puck reproduces both the pairings
+and all settings. Other settings (mouse, rumble, chords, per-type button maps, Switch Pro motion) ride in the
+backup file as plain `0x02` field values, not in the bond commands.
+
 ## 11. Timing notes
 
 - Default poll interval: `4000` microseconds (250 Hz, matches SC2 report rate)

+ 325 - 71
docs/index.html

@@ -7,31 +7,41 @@
 <style>
   :root{ --bg:#11131a; --card:#1b1f2b; --fg:#e7ecf3; --mut:#8a93a6; --acc:#5bd6a0; --acc2:#5b9bd6; --bad:#d65b6e; }
   *{box-sizing:border-box}
-  body{margin:0;font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--fg);padding:24px}
+  body{margin:0;font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--fg)}
   h1{font-size:20px;margin:0 0 4px}
-  .sub{color:var(--mut);margin:0 0 20px}
-  .wrap{max-width:680px;margin:0 auto}
-  .card{background:var(--card);border:1px solid #272d3d;border-radius:12px;padding:18px 20px;margin-bottom:16px}
+  .sub{color:var(--mut);margin:0 0 18px;font-size:13px}
+  /* sticky top action bar: connection + device-maintenance actions */
+  .topbar{position:sticky;top:0;z-index:20;display:flex;align-items:center;gap:10px;flex-wrap:wrap;
+    padding:9px 18px;background:#151925;border-bottom:1px solid #272d3d}
+  .topbar .brand{font-weight:700;font-size:15px;letter-spacing:.02em}
+  .topbar .brand small{color:var(--mut);font-weight:400;font-size:11px;margin-left:6px}
+  .topbar .spacer{flex:1 1 16px}
+  .topbar button{padding:7px 12px}
+  .wrap{max-width:980px;margin:0 auto;padding:20px}
+  /* cards stack in one column; width comes from the wider wrap + grids/rows filling it horizontally */
+  .card{background:var(--card);border:1px solid #272d3d;border-radius:12px;padding:16px 18px;margin:0 0 16px}
   .card h2{font-size:13px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);margin:0 0 14px}
-  .row{display:flex;align-items:center;gap:14px;margin:12px 0}
+  .row{display:flex;align-items:center;gap:14px;margin:12px 0;flex-wrap:wrap}
   .row label{flex:0 0 150px;color:var(--fg)}
   .row .val{flex:0 0 64px;text-align:right;color:var(--acc);font-variant-numeric:tabular-nums}
-  input[type=range]{flex:1;accent-color:var(--acc)}
-  select{background:#0e1017;color:var(--fg);border:1px solid #313a4f;border-radius:8px;padding:6px 8px;flex:1}
+  input[type=range]{flex:1;min-width:120px;accent-color:var(--acc)}
+  select{background:#0e1017;color:var(--fg);border:1px solid #313a4f;border-radius:8px;padding:6px 8px;flex:1;min-width:120px}
   button{background:#222a3a;color:var(--fg);border:1px solid #36405a;border-radius:8px;padding:9px 14px;cursor:pointer;font:inherit}
   button:hover{border-color:var(--acc2)}
   button.primary{background:var(--acc2);border-color:var(--acc2);color:#06121f;font-weight:600}
   button.active{background:var(--acc);border-color:var(--acc);color:#062018;font-weight:600}
+  button.danger{background:#3a1420;border-color:#7a2233;color:#ff9bb0;font-weight:600}
+  button.danger:hover{border-color:#ff9bb0}
   button:disabled{opacity:.4;cursor:default}
   .pill{display:inline-block;padding:2px 9px;border-radius:99px;font-size:12px;font-weight:600}
   .pill.up{background:#143626;color:var(--acc)} .pill.dn{background:#3a1822;color:var(--bad)}
-  .statgrid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}
+  .statgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(155px,1fr));gap:10px}
   .stat{background:#0e1017;border:1px solid #232a3a;border-radius:8px;padding:10px}
   .stat .k{color:var(--mut);font-size:11px;text-transform:uppercase;letter-spacing:.05em}
   .stat .v{font-size:18px;margin-top:2px}
-  .modes{display:flex;gap:10px}
+  .modes{display:flex;gap:10px;flex-wrap:wrap}
   .note{color:var(--mut);font-size:12px;margin-top:10px}
-  #log{font:12px ui-monospace,Menlo,monospace;color:var(--mut);white-space:pre-wrap;margin-top:8px;max-height:120px;overflow:auto}
+  #log{font:12px ui-monospace,Menlo,monospace;color:var(--mut);white-space:pre-wrap;margin-top:8px;max-height:140px;overflow:auto}
   .hide{display:none}
 
   /* ---- controller slot tabs ---- */
@@ -50,19 +60,20 @@
 </style>
 </head>
 <body>
+<div class="topbar">
+  <span class="brand">OpenPuck <small>SC2 puck · WebUSB</small></span>
+  <span id="connState" class="pill dn">disconnected</span>
+  <button id="connectBtn" class="primary">Connect</button>
+  <span class="spacer"></span>
+  <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; 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">
-  <h1>OpenPuck Config</h1>
-  <p class="sub">nRF52840 Steam Controller 2 puck reimplementation — WebUSB. Chrome/Edge only.</p>
-
-  <div class="card" id="connectCard">
-    <div class="row" style="margin:0">
-      <button id="connectBtn" class="primary">Connect to copycat</button>
-      <span id="connState" class="pill dn">disconnected</span>
-    </div>
-    <p class="note">If nothing appears in the device picker, the copycat may be busy (quit any app holding it) or needs a replug.</p>
-  </div>
+  <p class="sub">First connect uses the Chrome/Edge device picker; reconnects are automatic. If the picker is empty, quit any app holding the device or replug.</p>
 
   <div id="panel" class="hide">
+    <div class="cards">
     <div class="card">
       <h2>USB mode</h2>
       <div class="modes" style="flex-wrap:wrap">
@@ -90,7 +101,7 @@
       <div class="row"><label>Y + back4 →</label><select class="chord" data-i="2"></select></div>
     </div>
 
-    <div class="card">
+    <div class="card wide">
       <h2>Link status</h2>
 
       <!-- Per-controller tabs: visible only when 2+ controllers are bonded -->
@@ -108,7 +119,12 @@
         <div class="stat"><div class="k">Delivered</div><div class="v" id="stRate">—</div></div>
         <div class="stat"><div class="k">New reports</div><div class="v" id="stNew">—</div></div>
         <div class="stat"><div class="k">Polls/s</div><div class="v" id="stPolls">—</div></div>
+        <div class="stat"><div class="k">Relay/s</div><div class="v" id="stRelay">—</div></div>
         <div class="stat"><div class="k">RF fails/s (crc·noRx·heal)</div><div class="v" id="stRfFail">—</div></div>
+        <div class="stat"><div class="k">Clock LF / HF</div><div class="v" id="stClock">—</div></div>
+        <div class="stat"><div class="k">µs per ms (ideal 1000)</div><div class="v" id="stUsPerMs">—</div></div>
+        <div class="stat"><div class="k">Loop</div><div class="v" id="stLoopState">—</div></div>
+        <div class="stat"><div class="k">usbd stack free (words)</div><div class="v" id="stUsbdStack">—</div></div>
         <div class="stat logonly"><div class="k">Loop period</div><div class="v" id="stLoop">—</div></div>
         <div class="stat logonly"><div class="k">Slowest stage</div><div class="v" id="stWorst">—</div></div>
         <div class="stat logonly"><div class="k">Poll period act/want</div><div class="v" id="stPollPer">—</div></div>
@@ -120,34 +136,15 @@
       <p class="note" style="margin-top:8px"><strong>IMU (raw):</strong> <span id="stImu" style="font-family:ui-monospace,Menlo,monospace">—</span><br>
         Raw SC2 accel (before scaling). This controller is a ±2 g sensor (~16384 = 1 g); the Switch report divides it by 4 to present the genuine ±8 g (4096 = 1 g) scale so the console's gravity-correction engages and the gyro stops drifting.</p>
       <p class="note logonly">Polls/s ≈ 250 expected; it's capped by Loop period (≈4000 µs needed). "Slowest stage" is the loop section eating the most time per iteration.</p>
-      <div class="row" style="margin:14px 0 0;border-top:1px solid #272d3d;padding-top:14px">
-        <strong style="flex-basis:100%">Connection tuning <span class="note">(persists across reboot)</span></strong>
-      </div>
-      <div class="row" style="margin:8px 0 0"><label style="min-width:150px">Block haptics on connect</label>
-        <button id="hapBlockOn">off</button>
-        <span class="note" style="flex:1">Drops all Steam haptics for a short window right after a controller (re)connects, so its haptic engine settles before the first real haptic.</span>
-      </div>
-      <div class="row" style="margin:8px 0 0"><label style="min-width:150px">Block duration</label>
-        <input type="range" id="hapBlockS" min="0" max="30" step="1"><span class="val" id="hapBlockSV"></span>
-        <span class="note" style="flex:1">How long to drop haptics after connect (seconds). Default 10 s.</span>
-      </div>
       <div class="row" style="margin:14px 0 0;border-top:1px solid #272d3d;padding-top:14px"><button id="hapClear">Clear stuck buzz</button>
         <span class="note" style="flex:1">If the controller's haptics get stuck/buzzing, this re-inits them (same as pressing Steam).</span>
       </div>
       <div class="row" style="margin:10px 0 0"><button id="ctlrOff">Turn off controller</button>
         <span class="note" style="flex:1">Fires the controller power-off (0x9F "off!", x3) — the same path Steam's "turn off controller" and host-suspend use. Watch the link status to see it disconnect.</span>
       </div>
-      <div class="row" style="margin:10px 0 0;border-top:1px solid #272d3d;padding-top:14px">
-        <button id="dfuSerial">Reboot to Serial DFU</button>
-        <span class="note" style="flex:1">Reboots the puck into serial DFU mode (adafruit-nrfutil). The device drops off USB — flash with <code>adafruit-nrfutil dfu serial …</code> then replug.</span>
-      </div>
-      <div class="row" style="margin:10px 0 0">
-        <button id="dfuUf2">Reboot to UF2 Bootloader</button>
-        <span class="note" style="flex:1">Reboots the puck into the UF2 mass-storage bootloader. It mounts as a USB drive — drag the <code>.uf2</code> file onto it to flash, then it reboots automatically.</span>
-      </div>
-      <div class="row" style="margin:18px 0 0;border-top:1px solid #4a2030;padding-top:14px">
-        <button id="factoryErase" style="background:#3a1420;border-color:#7a2233;color:#ff9bb0;font-weight:600">⚠ Factory erase</button>
-        <span class="note" style="flex:1"><strong style="color:#ff9bb0">Danger:</strong> wipes <em>all</em> persistent storage on the copycat — the paired controller bond <em>and</em> every saved setting (mode, chords, back-paddle map, sensitivity). The copycat reboots to factory defaults and you must <strong>re-pair the controller</strong>. This cannot be undone.</span>
+      <div class="row" style="margin:10px 0 0;border-top:1px solid #272d3d;padding-top:14px"><button id="stabBtn">Test stability</button>
+        <span class="v" id="stabStatus" style="flex:0 0 auto;min-width:120px">—</span>
+        <span class="note" style="flex:1">Buzzes the controllers every 10 s (keeps them awake) and times how long the puck stays up. On a reset it records the uptime and shows it by <em>Last reset</em>; it auto-resumes after the puck reconnects. The count is kept in this tab (a page refresh clears it).</span>
       </div>
     </div>
 
@@ -172,13 +169,35 @@
       <p class="note">Gyro sensitivity multiplier for Switch Pro mode (1.0× = unchanged).</p>
     </div>
 
-    <div class="card">
+    <div class="card wide">
       <h2>Button mapping</h2>
       <p class="note" style="margin-top:0">Separate mappings per emulated controller type — each only offers targets that exist on that controller. The type matching the current mode is highlighted. Steam &amp; Lizard are native puck modes (Steam does its own remapping) and aren't configured here.</p>
       <div id="typeCfgs"></div>
     </div>
 
-    <div class="card logonly">
+    <div class="card">
+      <h2>Backup &amp; clone</h2>
+      <p class="note" style="margin-top:0">Save this puck's controller pairings <em>and</em> every setting to a file, then restore them onto another puck. The other puck becomes a clone — any controller paired to this one connects to it with <strong>no re-pairing</strong>. (Also a plain backup: re-import after a factory erase to get everything back.)</p>
+      <div class="row" style="margin:0;flex-wrap:wrap">
+        <button id="backupExport" class="primary">Export to file…</button>
+        <button id="backupImport">Import from file…</button>
+        <input type="file" id="backupFile" accept="application/json,.json" style="display:none">
+      </div>
+      <p class="note">Import <strong>overwrites</strong> all pairings and settings on the connected puck, then reboots it (~2 s — reconnect after). Tip: pair every controller to one puck, export once, then import onto each spare.</p>
+    </div>
+
+    <div class="card wide">
+      <h2>Hang log</h2>
+      <p class="note" style="margin-top:0">One row per reset: uptime before it, reason, the stuck loop stage and captured PC (when the watchdog ISR could run), and usbd stack free. Accumulates across resets in this tab — a page refresh clears it. Pair with <em>Test stability</em> above for unattended runs.</p>
+      <div class="row" style="margin:0 0 8px;flex-wrap:wrap">
+        <span class="v" id="hangSummary" style="flex:1">no resets logged yet</span>
+        <button id="hangCsv">Download CSV</button>
+        <button id="hangClear">Clear</button>
+      </div>
+      <pre id="hangOut" style="font:12px ui-monospace,Menlo,monospace;color:var(--fg);background:#0e1017;border:1px solid #232a3a;border-radius:8px;padding:10px;margin:0;max-height:260px;overflow:auto;white-space:pre"></pre>
+    </div>
+
+    <div class="card wide logonly">
       <h2>Capture — host → controller commands</h2>
       <p class="note" style="margin-top:0">The firmware logs everything from boot into a big RAM ring: Steam's writes (<code>ifN</code>), the frames we transmit to the controller (<code>TX→ctlr</code>), and RF <code>LINK UP/DOWN</code> edges. For the reconnect buzz: trigger it (it appears moments after the puck boots / the controller reconnects), then connect here and click <b>Dump from boot</b> — the trigger is still in the ring. (Only present in a logging build, <code>-DOPK_LOG=1</code>.)</p>
       <div class="row" style="margin:0;flex-wrap:wrap">
@@ -189,6 +208,7 @@
       <p class="note">Dump streams the whole ring (oldest→newest) and then keeps live-updating until you press Stop. <code>cmd</code> is the command/report byte, then the raw bytes.</p>
       <pre id="capOut" style="font:12px ui-monospace,Menlo,monospace;color:var(--fg);background:#0e1017;border:1px solid #232a3a;border-radius:8px;padding:10px;margin-top:10px;max-height:260px;overflow:auto;white-space:pre-wrap"></pre>
     </div>
+    </div><!-- .cards -->
 
     <div id="log"></div>
   </div>
@@ -197,7 +217,37 @@
 <script>
 const MODE_NAMES = ["Steam (puck)","Xbox 360","Switch (HORIPAD)","Lizard (always)","Switch Pro + gyro","PS5 DualSense","HID gyro (DS4)","PS5 (game/clean)","DS4 (game/clean)","PS3 (DualShock 3)"];
 const CHORD_FIELD = [17, 18, 19];
-let dev=null, epIn=0, epOut=0, ifNum=0, polling=false, capturing=false;
+let dev=null, epIn=0, epOut=0, ifNum=0, polling=false, capturing=false, backupBusy=false;
+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;
+// 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;
+function renderHangLog(){
+  const out=$("#hangOut"), sum=$("#hangSummary"); if(!out) return;
+  if(!hangLog.length){ out.textContent=""; sum.textContent="no resets logged yet"; return; }
+  const ups=hangLog.filter(e=>e.uptime!=null).map(e=>e.uptime);
+  const avg=ups.length?Math.round(ups.reduce((a,b)=>a+b,0)/ups.length):null;
+  sum.textContent = hangLog.length+" reset(s)"+(avg!=null?("  ·  avg uptime "+fmtDur(avg)):"");
+  const pad=(s,n)=>String(s).padEnd(n);
+  out.textContent = pad("time",11)+pad("uptime",9)+pad("reason",16)+pad("stage",10)+pad("PC",11)+"usbd\n"
+    + 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 updateStabUI(){
+  const btn=$("#stabBtn"), st=$("#stabStatus");
+  if(!btn) return;
+  btn.textContent = stabArmed ? "Stop stability test" : "Test stability";
+  btn.classList.toggle("active", stabArmed);
+  let txt="";
+  if(stabArmed && dev && stabStart) txt="up "+fmtDur((Date.now()-stabStart)/1000);
+  else if(stabArmed && !dev) txt="reconnecting…";
+  if(stabLastRun) txt += (txt?"  ·  ":"")+"last run: "+fmtDur(stabLastRun.secs);
+  st.textContent = txt||"—";
+}
+setInterval(updateStabUI, 500);
 const $ = s=>document.querySelector(s);
 function log(m){ const l=$("#log"); l.textContent=(new Date().toLocaleTimeString()+"  "+m+"\n"+l.textContent).slice(0,2000); }
 
@@ -326,9 +376,11 @@ function renderSlotTabs(bondedCount) {
   updateSlotDisplay(g_activeSlot);
 }
 
-async function connect(){
+const USB_FILTERS=[{vendorId:0x28DE},{vendorId:0x045E},{vendorId:0x057E},{vendorId:0x0F0D},{vendorId:0x054C}];
+// Acquire+set up an already-chosen USBDevice (shared by the manual picker and the no-picker auto-reconnect).
+async function openDevice(d){
   try{
-    dev = await navigator.usb.requestDevice({filters:[{vendorId:0x28DE},{vendorId:0x045E},{vendorId:0x057E},{vendorId:0x0F0D},{vendorId:0x054C}]});
+    dev=d;
     await dev.open();
     if(dev.configuration===null) await dev.selectConfiguration(1);
     let found=null;
@@ -340,42 +392,78 @@ async function connect(){
       for(const e of a.endpoints){ if(e.type==="bulk"){ if(e.direction==="in")bin=e.endpointNumber; else bout=e.endpointNumber; } }
       if(bin&&bout){ found={ifNum:itf.interfaceNumber, epIn:bin, epOut:bout}; break; }
     }
-    if(!found){ log("no WebUSB bulk vendor interface — reflash firmware with WebUSB enabled, or another app may be holding the device"); return; }
+    if(!found){ log("no WebUSB bulk vendor interface — reflash firmware with WebUSB enabled, or another app may be holding the device"); dev=null; return false; }
     ifNum=found.ifNum; epIn=found.epIn; epOut=found.epOut;
     await dev.claimInterface(ifNum);
     await dev.controlTransferOut({requestType:"class", recipient:"interface", request:0x22, value:0x01, index:ifNum});
     $("#connState").textContent="connected"; $("#connState").className="pill up";
+    $("#connState").title = dev.serialNumber ? ("puck serial "+dev.serialNumber) : "";
     $("#connectBtn").textContent="Reconnect";
     $("#panel").classList.remove("hide");
-    log(`connected — VID ${dev.vendorId.toString(16)} iface ${ifNum} epIn ${epIn} epOut ${epOut}`);
-    navigator.usb.addEventListener("disconnect", e=>{ if(e.device===dev){ onGone(); }});
+    log(`connected — puck ${dev.serialNumber||"?"} (VID ${dev.vendorId.toString(16)} iface ${ifNum})`);
+    // resume a stability test across the reset: re-arm the firmware buzz and start timing the next run
+    if(stabArmed){ await send([0x0F,1]); stabStart=Date.now(); log("stability test resumed — timing next run"); }
+    updateStabUI();
     startPolling();
-  }catch(e){ log("connect failed: "+e.message); }
+    return true;
+  }catch(e){ log("connect failed: "+e.message); dev=null; return false; }
+}
+// Manual connect: shows the Chrome device picker (needed once, to authorize the device).
+async function connect(){
+  try{ const d = await navigator.usb.requestDevice({filters:USB_FILTERS}); await openDevice(d); }
+  catch(e){ log("connect failed: "+e.message); }
+}
+// No-picker reconnect: reopen a device already authorized this browser. Returns true if it connected.
+async function autoConnect(){
+  if(dev) return true;
+  try{
+    const ds = await navigator.usb.getDevices();
+    const d = ds.find(x=>USB_FILTERS.some(f=>f.vendorId===x.vendorId));
+    if(d) return await openDevice(d);
+  }catch(e){}
+  return false;
 }
 function onGone(){
+  // if a stability test is running, this disconnect is the reset that ended the run -- record its uptime
+  // (kept in-page so it survives the reset + reconnect, shown next to Last reset).
+  let up=null;
+  if(stabArmed && stabStart){ up=(Date.now()-stabStart)/1000; stabLastRun={secs:up}; log("⏱ stability: stayed up "+fmtDur(up)+", then reset"); stabStart=0; }
+  // queue a hang-log entry; the reason/PC/stack are filled from the first blob after reconnect
+  pendingHang = { t: Date.now(), uptimeSecs: up };
   polling=false; dev=null; tabInited=false; // re-default the config tab to the mode on next connect
-  $("#connState").textContent="disconnected"; $("#connState").className="pill dn";
+  $("#connState").textContent="reconnecting…"; $("#connState").className="pill dn";
   $("#panel").classList.add("hide");
-  log("device disconnected (likely a mode-switch reboot — reconnect)");
+  log("device disconnected (mode-switch / watchdog reboot) — auto-reconnecting when it returns");
+  // The puck re-enumerates after a reboot; retry without the picker. The "connect" event also triggers this,
+  // but poll a few times in case the event is missed.
+  let tries=0;
+  const iv=setInterval(async()=>{ if(dev||tries++>40){ clearInterval(iv); return; } if(await autoConnect()) clearInterval(iv); }, 500);
 }
+// Auto-reconnect when an authorized puck (re)appears — no button, no picker.
+navigator.usb && navigator.usb.addEventListener("connect", e=>{ if(!dev) autoConnect(); });
+navigator.usb && navigator.usb.addEventListener("disconnect", e=>{ if(e.device===dev) onGone(); });
 async function send(bytes){
   if(!dev) return;
   try{ await dev.transferOut(epOut, new Uint8Array(bytes)); }
   catch(e){ log("write err: "+e.message); }
 }
-async function readBlob(){
+// Read one framed reply and return its payload (with the 2-byte [marker][len] header stripped). The blob and
+// the bond-export dump share the same framing, distinguished by the marker byte (0xA5 status, 0xA7 bonds).
+async function readFrame(marker, minLen){
   try{
-    // v8 blob is 75 bytes (two USB-FS packets); read 128 to capture both in one call.
-    const r = await dev.transferIn(epIn, 128);
+    // largest frame (status blob) spans multiple USB-FS packets; read generously to capture it in one call.
+    const r = await dev.transferIn(epIn, 192);
     if(r.status!=="ok"||r.data.byteLength<2) return null;
     const d=new Uint8Array(r.data.buffer);
-    let i=0; while(i<d.length && d[i]!==0xA5) i++;
+    let i=0; while(i<d.length && d[i]!==marker) i++;
     if(i+2>d.length) return null;
     const len=d[i+1]; const p=d.slice(i+2, i+2+len);
-    return p.length>=12 ? p : null;
+    return p.length>=(minLen||0) ? p : null;
   }catch(e){ if(dev) log("read err: "+e.message); return null; }
 }
+async function readBlob(){ return readFrame(0xA5, 12); }
 function applyBlob(p){
+  lastP=p; // remember for Export (carries every config field the panel shows)
   const mode=p[1], mDiv=p[2], mFric=p[3]; // p[4..11] mirror the active type (per-type block below is authoritative)
   const slot=p[10], up=p[11], f1=(p[12]|(p[13]<<8));
   const newps=(p.length>16?(p[15]|(p[16]<<8)):0), persistMode=(p.length>22?p[22]:0);
@@ -454,7 +542,47 @@ function applyBlob(p){
   // polls>0 here while Delivered is "—" means the puck is still polling but getting no usable replies.
   $("#stPolls").textContent = polls+" /s";
   { const crc=(p.length>113?p[113]:0), norx=(p.length>114?p[114]:0), heal=(p.length>115?p[115]:0);
-    $("#stRfFail").textContent = crc+" · "+norx+" · "+heal; }
+    const rf=(p.length>126?(p[125]|(p[126]<<8)):0);
+    $("#stRfFail").innerHTML = crc+" · "+norx+" · "+heal + (rf?' · <span class="pill dn">ring '+rf+'</span>':"");
+    // ring fault = we caught+recovered a relay-ring corruption that would otherwise be an invisible IRQ-off
+    // watchdog hang. Log each new one (persists across the device reset + panel reconnect).
+    if(rf>(window._ringFault||0)) log("⚠ RING FAULT #"+rf+" recovered — caught a relay-ring desync/corruption (would have hung loop with IRQs off)");
+    window._ringFault=rf; }
+  // v12: relay-frame rate + clock fingerprint (browser index = firmware index − 2)
+  { const relay=(p.length>117?(p[116]|(p[117]<<8)):null);
+    $("#stRelay").textContent = relay==null ? "—" : (relay+" /s"); }
+  if(p.length>121){
+    const LF=["stopped","RC","xtal","synth"], HF=["RC","?","xtal","?"];
+    const lf=p[118], hf=p[119], upm=(p[120]|(p[121]<<8));
+    const lfBad=(lf!==2), hfBad=(hf!==2), upmBad=(upm && (upm<985||upm>1015));
+    const tag=(t,bad)=> bad ? '<span class="pill dn">'+t+'</span>' : t;
+    $("#stClock").innerHTML = tag(LF[lf]||lf, lfBad)+" / "+tag(HF[hf]||hf, hfBad);
+    $("#stUsPerMs").innerHTML = upm ? tag(""+upm, upmBad) : "—";
+  }
+  // v12: LIVE loop heartbeat — curStage (firmware p[125]) + ms-since-beat (p[126], 40ms units). Updates even
+  // while loop() is wedged (blob is pushed from the SOF interrupt), so a hang shows "STALLED @ <stage>" live.
+  if(p.length>124){
+    const STAGE=["webusb","ctrl.task","serial","rfdiag","rflink","haptic","led","usbmount","usbtx"];
+    const cur=p[123], stallMs=p[124]*40, name=(STAGE[cur]||("stage "+cur));
+    if(stallMs>=200){
+      $("#stLoopState").innerHTML='<span class="pill dn">STALLED @ '+name+' '+stallMs+'ms</span>';
+      // Latch+log the stall so it survives the imminent watchdog reset + panel reconnect (host-side log
+      // persists). Log once per episode + keep escalating if it climbs toward the ~8s watchdog.
+      if(!window._stallEpisode || stallMs>window._stallPeak+800){
+        window._stallEpisode=true; window._stallPeak=stallMs;
+        log("⚠ LOOP STALL @ "+name+" ("+stallMs+"ms) — watchdog reset imminent");
+      }
+    } else {
+      window._stallEpisode=false; window._stallPeak=0;
+      $("#stLoopState").innerHTML='<span class="pill up">running</span>';
+    }
+  }
+  // v12: least-ever free stack on the usbd task (words). Low = overflow risk (multi-controller haptic-relay
+  // hang hypothesis). Firmware p[137] -> browser p[135]. Red under 24 words (~96B), amber under 48.
+  if(p.length>136){ const usbdW=(p[135]|(p[136]<<8));
+    $("#stUsbdStack").innerHTML = usbdW<24 ? ('<span class="pill dn">'+usbdW+'</span>')
+      : (usbdW<48 ? ('<span class="pill" style="background:#3a3216;color:#e5c76b">'+usbdW+'</span>') : (""+usbdW));
+  }
   $("#stLoop").textContent = loopUs? loopUs+" µs" : "—";
   $("#stWorst").textContent = loopUs? (WORST[worstIdx]||worstIdx)+" "+worstUs+"µs" : "—";
   $("#stPollPer").textContent = up? (pollUs+" / "+pollIntended+" µs") : "—";
@@ -468,16 +596,32 @@ function applyBlob(p){
     const code = p[105];
     const raw = ((p[106]|(p[107]<<8)|(p[108]<<16)|(p[109]<<24))>>>0);
     const name = RR_NAMES[code] || ("code "+code);
-    $("#stReset").innerHTML = RR_FAULT.has(code) ? ('<span class="pill dn">'+name+'</span>') : name;
+    // v12: which loop stage was stuck when the last watchdog/lockup hang fired (0xFF = last boot wasn't a hang)
+    const STAGE_NAMES = ["webusb","ctrl.task","serial","rfdiag","rflink","haptic","led","usbmount","usbtx"];
+    const hangStage = (p.length>122 ? p[122] : 0xFF);
+    let label = name;
+    if (hangStage!==0xFF) label += " @ "+(STAGE_NAMES[hangStage]||("stage "+hangStage));
+    $("#stReset").innerHTML = RR_FAULT.has(code) ? ('<span class="pill dn">'+label+'</span>') : label;
+    // v12: PC/LR captured by the WDT pre-reset ISR (browser idx = firmware−2: hangPC@127, hangLR@131)
+    const hpc=(p.length>130?((p[127]|(p[128]<<8)|(p[129]<<16)|(p[130]<<24))>>>0):0);
+    const hlr=(p.length>134?((p[131]|(p[132]<<8)|(p[133]<<16)|(p[134]<<24))>>>0):0);
+    const hx=v=>"0x"+v.toString(16).padStart(8,"0");
     const el = $("#resetHist");
-    if (el) el.textContent = "RESETREAS=0x"+raw.toString(16).padStart(8,"0");
+    if (el) el.textContent = "RESETREAS=0x"+raw.toString(16).padStart(8,"0")
+      + (hangStage!==0xFF ? "  hung in: "+(STAGE_NAMES[hangStage]||hangStage) : "")
+      + (hpc ? "  hang PC="+hx(hpc)+" LR="+hx(hlr) : "")
+      + (stabLastRun ? "  ·  stability: stayed up "+fmtDur(stabLastRun.secs)+" before this reset" : "");
+    if (hpc && hpc!==window._hangPC){ window._hangPC=hpc; log("⚑ HANG PC="+hx(hpc)+" LR="+hx(hlr)+" — send me this; I'll map it to the stuck function"); }
+    // append the queued reset to the running hang log, now that we have this boot's reason/PC/stack
+    if (pendingHang){
+      const usbdW2=(p.length>136?(p[135]|(p[136]<<8)):0);
+      hangLog.unshift({ time:new Date(pendingHang.t).toLocaleTimeString(), uptime:pendingHang.uptimeSecs,
+        reason:name, stage:(hangStage!==0xFF)?(STAGE_NAMES[hangStage]||("stage"+hangStage)):"",
+        pc:hpc?hx(hpc):"", lr:hlr?hx(hlr):"", usbd:usbdW2 });
+      pendingHang=null; renderHangLog();
+    }
   }
 
-  // Connection tuning: haptic-block enable (p[111]), block seconds (p[112]). (poll RX window p[110] is now
-  // fixed in firmware and no longer shown.)
-  const hapBlockOn = (p.length>111?p[111]:0);
-  $("#hapBlockOn").textContent = hapBlockOn?"on":"off"; $("#hapBlockOn").classList.toggle("active",!!hapBlockOn);
-  if (p.length>112) setSlider("hapBlockS", p[112]);
   const gitDirty=(p.length>38?p[38]:0); let gitHash="";
   for(let i=39;i<51 && i<p.length && p[i];i++) gitHash+=String.fromCharCode(p[i]);
   $("#stBuild").innerHTML = gitHash
@@ -517,10 +661,102 @@ function setSlider(id,v){ const el=$("#"+id); if(document.activeElement!==el){ e
 
 let inflight=false;
 async function refresh(){
-  if(!dev||inflight||capturing) return; inflight=true;
+  if(!dev||inflight||capturing||backupBusy) return; inflight=true;
   await send([0x01]); const p=await readBlob(); if(p) applyBlob(p);
   inflight=false;
 }
+
+// ---- Backup / clone ----
+// A backup file is { magic, version, bonds[4], config{} }. bonds come from the 0x09 export (0xA7 frame); the
+// config fields come from the status blob the panel already reads. Restoring replays every setting via the
+// normal 0x02 setters, then sends the 0x0D bond-import which writes the pairings, applies the mode and reboots.
+function hexEnc(arr){ return [...arr].map(x=>x.toString(16).padStart(2,"0")).join(""); }
+function hexDec(h){ const a=[]; if(!h) return a; for(let i=0;i+1<h.length;i+=2) a.push(parseInt(h.substr(i,2),16)); return a; }
+function buildBackup(p, bp){
+  const mask=bp[1], bonds=[];
+  for(let s=0;s<4;s++){ const rec=bp.slice(2+s*24, 2+s*24+24);
+    bonds.push({slot:s, used:!!((mask>>s)&1), rec:hexEnc(rec)}); }
+  const types=[];
+  for(let et=0; et<TYPE_DEFS.length; et++){ const q=73+et*8;
+    types.push({back:[p[q],p[q+1],p[q+2],p[q+3]], qam:p[q+4], abSwap:p[q+5], pad:p[q+6], led:p[q+7]}); }
+  return { magic:"openpuck-backup", version:1, bonds,
+    config:{ mode:p[1], mDiv:p[2], mFric:p[3], persistMode:p[22], chord:[p[23],p[24],p[25]],
+             rumble:p[51], swProRate:p[52], swGyro10:p[53], types } };
+}
+function downloadBackup(obj){
+  const ts=new Date().toISOString().slice(0,19).replace(/[:T]/g,"-");
+  const a=document.createElement("a");
+  a.href=URL.createObjectURL(new Blob([JSON.stringify(obj,null,2)],{type:"application/json"}));
+  a.download="openpuck-backup-"+ts+".json"; a.click();
+}
+// let any in-flight poll read settle before we drive the shared endpoints ourselves
+async function waitIdle(){ for(let i=0;i<60 && inflight;i++) await new Promise(r=>setTimeout(r,5)); }
+async function exportBackup(){
+  if(!dev){ log("not connected"); return; }
+  backupBusy=true; await waitIdle();
+  try{
+    // grab a fresh config snapshot, then the bond dump
+    await send([0x01]); const cfg=await readBlob(); if(cfg) lastP=cfg;
+    if(!lastP){ log("export: no config snapshot yet — wait a second and retry"); return; }
+    await send([0x09]);
+    let bp=null;
+    for(let t=0; t<8 && !bp; t++) bp=await readFrame(0xA7, 2+4*24);
+    if(!bp){ log("export: no bond data (firmware too old for 0x09 export — reflash)"); return; }
+    const backup=buildBackup(lastP, bp);
+    downloadBackup(backup);
+    const n=backup.bonds.filter(b=>b.used).length;
+    log("exported backup — "+n+" bonded controller slot(s) + all settings");
+  } finally { backupBusy=false; }
+}
+async function importBackup(file){
+  if(!dev){ log("not connected"); return; }
+  let obj; try{ obj=JSON.parse(await file.text()); }
+  catch(e){ log("import: not valid JSON"); return; }
+  if(obj.magic!=="openpuck-backup" || !obj.bonds || !obj.config){ log("import: unrecognized backup file"); return; }
+  const bondedN=obj.bonds.filter(b=>b.used).length;
+  if(!confirm("Restore this backup onto the CONNECTED puck (serial "+(dev.serialNumber||"?")+")?\n\nMake sure this is the TARGET puck, not the one you exported from — the panel only talks to the puck you last connected.\n\nThis OVERWRITES its "+bondedN+" controller pairing(s) and ALL settings, then reboots it.\n\nResult: any controller paired to the original puck will connect to this one with no re-pairing.")) return;
+  log("importing onto puck "+(dev.serialNumber||"?"));
+  backupBusy=true; await waitIdle();
+  try{
+    // Capability probe: the connected puck MUST run firmware new enough to answer 0x09 with a 0xA7 frame,
+    // otherwise it will silently ignore the 0x0D bond-import below. This is the #1 import failure: the panel
+    // is connected to a puck still on old firmware. Catch it loudly instead of "nothing happened".
+    await send([0x09]);
+    let probe=null; for(let t=0;t<8 && !probe;t++) probe=await readFrame(0xA7, 2+4*24);
+    if(!probe){
+      log("import ABORTED — the connected puck does not support import (old firmware).");
+      alert("This puck's firmware is too old to import a backup.\n\nFlash it with the latest OpenPuck build (the same one that produced the export), reconnect, then import again.");
+      return;
+    }
+    const c=obj.config;
+    // Every step is a write-then-read transaction (same shape as the proven 0x02 sliders): the firmware acks
+    // each with a status blob, which we read back to keep the OUT pipe flowing and naturally pace the writes.
+    const w=async(bytes)=>{ await send(bytes); await readBlob(); };
+    const sf=async(f,v)=>{ await w([0x02, f, (v|0)&0xff]); };
+    // replay every setting (each persists; none reboot). mode is applied by the 0x0E commit below.
+    await sf(1,c.mDiv); await sf(2,c.mFric); await sf(22,c.rumble); await sf(16,c.persistMode?1:0);
+    await sf(17,c.chord[0]); await sf(18,c.chord[1]); await sf(19,c.chord[2]);
+    await sf(23,c.swProRate); await sf(24,c.swGyro10);
+    for(let et=0; et<c.types.length && et<4; et++){ const t=c.types[et];
+      for(let k=0;k<4;k++) await sf(40+et*8+k, t.back[k]);
+      await sf(40+et*8+4, t.qam); await sf(40+et*8+5, t.abSwap?1:0);
+      await sf(40+et*8+6, t.pad?1:0); await sf(40+et*8+7, t.led); }
+    log("settings replayed ("+(9+c.types.length*8)+" fields)");
+    // write each bond slot with its own small command [0x0D][slot][used][24-byte rec]
+    let n=0;
+    for(let s=0;s<4;s++){
+      const b=obj.bonds.find(x=>x.slot===s) || {used:false, rec:""};
+      const rec=hexDec(b.rec), r24=new Array(24).fill(0);
+      for(let i=0;i<24 && i<rec.length;i++) r24[i]=rec[i];
+      const used=(b.used && rec.length===24)?1:0; if(used) n++;
+      await w([0x0D, s, used, ...r24]);
+    }
+    log("wrote "+n+" bond slot(s) — committing + rebooting");
+    // commit: persist bonds, apply mode, reboot (no ack — the device reboots)
+    await send([0x0E, (c.mode!==undefined?c.mode:0xFF)&0xff]);
+    log("import sent — puck is cloning and rebooting. Reconnect after it returns.");
+  } finally { backupBusy=false; }
+}
 async function setField(field,value){
   await send([0x02, field, value&0xff]); const p=await readBlob(); if(p) applyBlob(p);
 }
@@ -598,9 +834,27 @@ async function startPolling(){
 
 // UI wiring
 $("#connectBtn").onclick=connect;
+$("#backupExport").onclick=exportBackup;
+$("#backupImport").onclick=()=>$("#backupFile").click();
+$("#backupFile").onchange=async(e)=>{ const f=e.target.files[0]; if(f) await importBackup(f); e.target.value=""; };
 $("#capDl").onclick=downloadCap;
 $("#capStart").onclick=startCapture;
 $("#capStop").onclick=stopCapture;
+$("#stabBtn").onclick=async()=>{
+  if(!dev){ log("connect first"); return; }
+  stabArmed=!stabArmed;
+  if(stabArmed){ await send([0x0F,1]); stabStart=Date.now(); stabLastRun=null; log("stability test STARTED — buzzing every 10s, timing uptime until reset"); }
+  else { await send([0x0F,0]); stabStart=0; log("stability test stopped"); }
+  updateStabUI();
+};
+$("#hangClear").onclick=()=>{ hangLog=[]; renderHangLog(); };
+$("#hangCsv").onclick=()=>{
+  const rows=[["time","uptime_s","reason","stage","pc","lr","usbd_free_words"]].concat(
+    hangLog.map(e=>[e.time, e.uptime!=null?Math.round(e.uptime):"", e.reason, e.stage, e.pc, e.lr, e.usbd]));
+  const csv=rows.map(r=>r.map(x=>'"'+String(x).replace(/"/g,'""')+'"').join(",")).join("\n");
+  const a=document.createElement("a"); a.href=URL.createObjectURL(new Blob([csv],{type:"text/csv"}));
+  a.download="openpuck-hanglog.csv"; a.click();
+};
 $("#hapClear").onclick=async()=>{ if(dev){ await send([0x07]); log("haptic re-init sent (clear stuck buzz)"); } };
 $("#ctlrOff").onclick=async()=>{ if(dev){ await send([0x08]); log("controller power-off attempt sent — watch link status"); } };
 $("#dfuSerial").onclick=async()=>{
@@ -624,9 +878,8 @@ $("#factoryErase").onclick=async()=>{
 };
 $("#persistMode").onclick=()=>{ const on=$("#persistMode").classList.contains("active"); setField(16, on?0:1); };
 // Connection tuning: post-connect haptic block on (field 27) + seconds (field 28). (poll RX window is fixed.)
-$("#hapBlockOn").onclick=()=>{ const on=$("#hapBlockOn").classList.contains("active"); setField(27, on?0:1); };
-for(const id of ["mDiv","mFric","rumble","hapBlockS"]){
-  const field={mDiv:1,mFric:2,rumble:22,hapBlockS:28}[id];
+for(const id of ["mDiv","mFric","rumble"]){
+  const field={mDiv:1,mFric:2,rumble:22}[id];
   $("#"+id).addEventListener("input", ()=>{ $("#"+id+"V").textContent=fmtSlider(id,$("#"+id).value); });
   $("#"+id).addEventListener("change", ()=>setField(field, +$("#"+id).value));
 }
@@ -643,6 +896,7 @@ for(const b of document.querySelectorAll(".modebtn")){
     if(confirm(msg)){ send([0x03, m]); log("mode switch requested — device will reboot"); } };
 }
 if(!("usb" in navigator)) $("#connectBtn").outerHTML='<b style="color:var(--bad)">WebUSB not supported — use Chrome or Edge.</b>';
+else autoConnect(); // reopen an already-authorized puck on load, no picker (first-ever connect still needs the button)
 </script>
 </body>
 </html>