Selaa lähdekoodia

Stability fix with a refactor of how processing is done (#94)

* Possibly better

* Increase TUD_TASK_QUEUE_SZ=64 to protect against disconnects

* refactor

* Make one device being disconnected less catastrophic

* down to 2 seconds

* Easier make

* flash cmd

* refactor working

* refactor stable but polling rate still low

* hopefully final stability fix

* webusb working without disconnect

* export/import configs

* move ui

* stability: relay-ring hang fix, phantom-slot poll fix, hang diagnostics

Investigation + fixes for watchdog hangs and poll-rate anomalies on
nice!nano clone boards (AITRIP, Teyleten Robot) vs the known-good board.

Fixes:
- Relay ring drains (rfConnFlushRelay, hapticCancelPendingOn) run with IRQs
  disabled; an out-of-range/desynced g_rqHead made them spin forever ->
  invisible "watchdog (hang)" (millis/SOF frozen, only the HW WDT recovers).
  Cap both loops at RELAY_QLEN+1, recover (head=tail=0), count g_ringFault.
- Phantom bonded slots (carried in from cloned backups) were polled every
  cycle forever -> 2x poll rate + noRx flood + reply-window theft. Now a
  never-replied slot throttles to the cold cadence once another controller
  is connected; full-rate only when nothing is connected (fast first link).
- Polls/s counted every radio TX (poll + relay); split into true poll cycles
  (Polls/s, ~250 for one controller) and a separate Relay/s.
- Post-connect haptic block forced off and removed (was field 27/28 + UI).

Diagnostics (protocol v12):
- Clock fingerprint: LFCLK/HFCLK source + measured us-per-ms (clone triage).
- Hang localization: GPREGRET2 loop-stage breadcrumb (survives reset where
  the bootloader preserves it) + a live SOF-driven stall monitor that reports
  the stuck stage even while loop() is wedged (works when GPREGRET2 doesn't).
- Panel: new Relay/s, Clock, us/ms, Loop, ring-fault tiles + stall logging.

Panel UX:
- Auto-reconnect via navigator.usb.getDevices() (no device picker after the
  first authorization); reconnects on load, on the connect event, and on
  disconnect retry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* stability: hang localization + usbd-stack diagnosis + uptime test harness

Instrumentation to pin down the interrupts-off watchdog hangs seen across
all boards (worse on clones), which correlate with the haptic/relay path
and regressed at multi-controller support.

Diagnostics (protocol still v12, blob extended):
- WDT pre-reset PC capture: top-priority WDT TIMEOUT ISR reads the stacked
  PC/LR of the wedged code into .noinit RAM that survives the reset (the
  clone bootloader wipes GPREGRET2). Reported in the blob + reset detail;
  map with addr2line. No pc reported => the hang hard-masked interrupts.
- Live loop-stall monitor: loop heartbeat + current stage; the SOF-driven
  blob force-sends when loop() stops beating, so a wedge shows "STALLED @
  <stage>" live even though loop is dead. Panel latches/logs it.
- Per-task stack headroom via uxTaskGetSystemState: the usbd task is a fixed
  800 B and runs handleSet->relayEnqueue; "usbd stack free (words)" trending
  to 0 under haptic load confirms the leading root-cause hypothesis
  (usbd-task stack overflow).
- Clock fingerprint (LFCLK/HFCLK source + us-per-ms) to rule the clocks
  in/out per board.

Stability test harness:
- WebUSB cmd 0x0F toggles a keepalive that buzzes all controllers every 10s
  (so an unattended run isn't ended by idle-sleep). Panel "Test stability"
  button times uptime; on reset it records the run length (in-page, survives
  the reset+reconnect) and shows it next to the last reset reason, then
  auto-resumes after the puck reconnects.

Panel: auto-reconnect without the device picker (getDevices + connect event
+ disconnect retry) so reset-heavy testing needs no clicks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* panel: running hang log (per-reset table) + refute usbd-stack theory

Add a Hang log card to the WebUSB panel: one row per reset (time, uptime
before it, reason, stuck stage, captured PC, usbd stack free), accumulating
across resets in-page (survives the device reset; cleared by a page refresh).
Summary shows reset count + average uptime; CSV export + clear. Pairs with
the Test stability button for unattended runs.

Measured "usbd stack free" stays ~118 words (of 200), so the usbd-task stack
overflow hypothesis is refuted -- the hang is elsewhere. The log accumulates
reason/stage/PC across the frequent resets to expose the pattern.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* stability test: signal host-awake (E7) instead of relying on buzz

Puck->controller haptics don't keep a controller awake -- its auto-off is
driven by its own user-input inactivity (we already poll every 4ms without
preventing sleep). The E7 poll carries a host-awake/suspended flag
(0xE7 00 00 = awake per the RE); OpenPuck defaults to bare-E3 and never
sends it. The stability test (cmd 0x0F) now enables the E7 host-awake
announce (g_e7announce, g_e7b=0) while active, so the controller is told the
host is awake. The 10s buzz stays -- it exercises the haptic-relay path that
correlates with the hang.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* panel: top action bar + wider single-column layout

Add a sticky top bar with the connection button/state and the
device-maintenance actions (Serial DFU, UF2 DFU, Factory erase), moved out
of the Link-status card (same IDs, JS wiring unchanged). Widen the wrap
680->980px and make the Link-status stat grid auto-fill so tiles spread
across the width instead of a fixed 3 columns. Cards stay in one vertical
column (no side-by-side panels); horizontal space is used within cards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Jari 3 kuukautta sitten
vanhempi
commit
6d4880c826

+ 1 - 1
.github/workflows/build.yml

@@ -44,7 +44,7 @@ jobs:
             --clean \
             --build-path build/cache/openpuck \
             --output-dir build/openpuck \
-            --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=4" \
+            --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=4 -DCFG_TUD_TASK_QUEUE_SZ=64 -DCFG_TUD_VENDOR_TX_BUFSIZE=256" \
             OpenPuck
 
       - name: Generate UF2 (OpenPuck)

+ 2 - 2
.github/workflows/release.yml

@@ -45,7 +45,7 @@ jobs:
             --clean \
             --build-path build/cache/openpuck \
             --output-dir build/openpuck \
-            --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=4" \
+            --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=4 -DCFG_TUD_TASK_QUEUE_SZ=64 -DCFG_TUD_VENDOR_TX_BUFSIZE=256" \
             OpenPuck
 
       - name: Generate UF2 (standard)
@@ -73,7 +73,7 @@ jobs:
             --clean \
             --build-path build/cache/openpuck-recovery \
             --output-dir build/openpuck-recovery \
-            --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=4 -DOPK_FACTORY_RESET=1" \
+            --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=4 -DCFG_TUD_TASK_QUEUE_SZ=64 -DCFG_TUD_VENDOR_TX_BUFSIZE=256 -DOPK_FACTORY_RESET=1" \
             OpenPuck
 
       - name: Generate UF2 (recovery)

+ 487 - 0
CODE_MAP.md

@@ -0,0 +1,487 @@
+# OpenPuck firmware — architectural map
+
+nRF52840 / Adafruit Arduino core / TinyUSB. The device impersonates a Valve Steam
+Controller 2 "puck" over USB and talks to SC2 controllers over a bare-metal nRF52
+RADIO (ESB-style, **no SoftDevice**) protocol. Built with `-DCFG_TUD_HID=4`.
+
+> This map describes what the **code actually does**. Source comments in this repo
+> are known to be misleading and were ignored.
+
+---
+
+## 0. Execution contexts (read this first)
+
+Three contexts matter for every piece of state below:
+
+| Context | Priority / stack | What runs here |
+|---|---|---|
+| **loop task** | Arduino FreeRTOS "loop" task, low priority, ~4 KB stack | Everything called from `loop()`: `webusbPoll`, `g_active->task()`, `serialConsolePoll`, `rfDiagTask`, `rfLinkTask`, `hapticTask`, `ledTask`, `usbMountTask`. Also all of the RF radio code (it is driven synchronously from `rfLinkTask`/`rfDiagTask`). Also `setup()`. |
+| **usbd task** | TinyUSB "usbd" task, **high priority, ~800-byte stack** | All TinyUSB HID `set_report`/`get_report` callbacks, the custom XInput class-driver xfer callbacks, and (in the Adafruit core) HID `sendReport` queuing context. This is where host→device control transfers are decoded. **Stack is tiny — a `Serial.printf` here can overflow it (the suspected LOCKUP cause), which is why all per-report logging is `#if OPK_LOG` gated.** |
+| **ISR** | Hardware exception | `HardFault_Handler` (fault_diag.cpp). The RADIO is **polled, not interrupt-driven** — there is no radio ISR. |
+
+Key cross-task rule the code relies on: producers that touch the relay rings and the
+capture ring run under `__disable_irq()`/PRIMASK; the per-slot HID reply rings are
+single-producer (usbd) / single-consumer (loop) with `volatile` head/tail and no lock.
+
+---
+
+## 1. Top level — `OpenPuck.ino`
+
+**Entry point + main loop.** No input/output logic of its own.
+
+### `setup()` (loop task, boot)
+- `genSerial()`, `ledInit()`, seed per-slot RF session addresses, `InternalFS.begin()`,
+  optional one-time `factoryResetOnce()`, `loadCfg()`, `loadBonds()`, regenerate session
+  addresses for used slots.
+- Decides USB presentation: `g_xbox = !modeIsPuck`, `g_active = controllerFor(g_usbMode)`.
+- Builds the USB descriptor. Two paths:
+  - **Dynamic-mount modes** (emulated controllers): `wakeHidBegin()` (HID instance 0,
+    unless clean-PS), `g_active->beginPool()`, optional `usb_web.begin()`,
+    `usbMountEnable()`, `usbMountRebuildMap()`, `usbReenumerate(g_usbMountCount)`.
+  - **Static modes** (puck/lizard, or any non-dynamic): conditional CDC keep
+    (`keepCdc`), per-mode serial suffix, ordered interface registration
+    (`usb_web` IF0 → wake mouse IF1 → 4 puck slots IF2..5 for puck), then `attach()`.
+- Busy-waits up to **3 s** for USB mount: `for (i<300 && !mounted()) delay(10)`.
+- `hapticInit()`, `faultDiagBoot()`, prints diagnostics.
+- **Arms the hardware watchdog**: `NRF_WDT` CRV = `8*32768-1` (~8 s), RUN-in-sleep.
+  If `loop()` ever stops feeding it, the chip resets.
+
+### `usbReenumerate(uint8_t k)` (loop task)
+Tears down and rebuilds the USB config descriptor for `k` connected slots **with no MCU
+reboot**: `detach()`, `delay(20)`, `clearConfiguration()`, restore identity/serial,
+re-add wake mouse + `g_active->mountSlots(k)` + WebUSB in locked instance order,
+`attach()`. Called at boot and by `usbMountTask`.
+
+### `loop()` (loop task)
+1. Feeds the watchdog (`NRF_WDT->RR[0]`).
+2. If `g_dirty`, `saveBonds()` (flash write in loop context).
+3. Calls, in order: `webusbPoll`, `g_active->task`, `serialConsolePoll`, `rfDiagTask`,
+   `rfLinkTask`, `hapticTask`, `ledTask`, `usbMountTask`.
+   `#if OPK_LOG` wraps each in `micros()` timing for the WebUSB panel.
+
+### State owned
+- `g_usbCfgDesc[512]` — config-descriptor build buffer (puck composite + WebUSB exceeds 256 B).
+- `MODE_SUFFIX[8]`, `s_dynWantWebusb`, `s_dynWantWakeMouse`.
+- `g_loopPeriodUs`, `g_loopWorst`, `g_loopWorstUs` — loop-timing diagnostics (read by webusb).
+
+---
+
+## 2. Configuration & identity
+
+### `config.cpp` / `config.h` — persisted settings (loop task)
+- **Owns**: `g_usbMode`, `g_xbox`, `g_chordBtn[3]`, `g_persistMode`, `g_bootMode`,
+  `g_debugCdcThisBoot`, `g_mDiv`, `g_mFric`, `g_type[ET_COUNT]` (per-emulated-type
+  button config), `g_etype`, live mirrors `g_abSwap`/`g_back[4]`/`g_qamMap`/
+  `g_padHaptics`/`g_ledBright`, `g_rumbleScale`, `g_swProRate`, `g_swGyroScale10`,
+  and the constant `g_pollUs = 4000` (250 Hz, **fixed**).
+- `struct Cfg` is serialized to `/cfg.bin` (LittleFS), magic `0xCD`. `rxWin10`,
+  `hapticBlockOn`, `hapticBlockS` and the per-type table travel with it; `rsvd0` is the
+  one-shot debug-CDC arm.
+- `loadCfg()` resolves the boot mode policy: one-shot `bootMode` wins once then clears;
+  else `persistMode ? last mode : STEAM(0)`. Calls `applyActiveType()`.
+- `saveCfg`, `saveMode`, `armDebugCdcNextBoot`, `factoryErase` (LittleFS `format()`),
+  `factoryResetOnce` (git-hash-tagged one-time wipe). All flash I/O — **loop context only**.
+- `applyActiveType()` copies `g_type[g_etype]` into the hot-path live mirrors.
+
+### `identity.cpp` / `identity.h` (loop task, boot)
+- `genSerial()` derives `g_unit[16]` ("FXB...") and `g_board[16]` ("MXB...") from
+  `NRF_FICR->DEVICEID`. `g_usbSerial[18]` holds the per-mode suffixed serial.
+- `ATTR83[25]` — the constant 0x83-attribute blob (advertises product 0x1304).
+
+### `build_info.h` / `git_version.h` — compile-time tags only.
+
+---
+
+## 3. Bonds & shared link state — `bonds.cpp` / `bonds.h`
+
+- `NSLOT = 4`. `struct Slot { uint8_t rec[24]; bool used; uint8_t resp[63]; uint16_t resp_len; }`.
+  `rec` = `[8 uuid][16 serial]`. `resp`/`resp_len` is the staging buffer for that
+  interface's pending feature-report reply.
+- **`g_slot[NSLOT]`** — bond records + per-interface reply staging. Written by the puck
+  feature command channel (usbd task, `handleSet` 0xA2) and by `loadBonds` (loop). Read
+  by RF code, webusb, serial console.
+- **`g_connReplyMs[NSLOT]`** — `millis()` of the last F-type reply per slot (**the live
+  "controller is alive" timestamp**). Written by the RF decode (loop task,
+  `rfConnTx`). Read everywhere: puck_hid connection presentation, haptic link-up gate,
+  usb_mount connectivity watcher, webusb, serial console.
+- **`g_dirty`** (`volatile`) — set by `handleSet` (usbd) when a bond is written; consumed
+  by `loop()` → `saveBonds()` (defers flash off the usbd task).
+- `g_pairing` — set by `handleSet` 0xAD.
+- `loadBonds`/`saveBonds` — `/bonds.bin` LittleFS, loop context.
+
+---
+
+## 4. RF stack (bare-metal RADIO)
+
+### `radio.cpp` / `radio.h` — hardware layer (loop task)
+- Programs `NRF_RADIO` directly (PHY=Ble_2Mbit, ENDIAN=Big, no whitening, CRC16
+  poly 0x11021/init 0xFFFF, ESB-DPL framing). No SoftDevice.
+- **DMA buffers `rfrx[100]`, `rftx[100]`** (declared >= MAXLEN+2 = 66+). Shared by
+  `rf_link.cpp` AND `rf_diag.cpp` with **no guarding** (see §13).
+- `g_rfBase[4]="ibex"`, `g_rfPrefix=0x10`, `g_rfCh=2` — shared discovery rendezvous.
+- `g_sessCh=18` — connected-session channel (global across slots).
+- **`g_sessBase[NSLOT][4]`, `g_sessPrefix[NSLOT]`** — per-bond unique session address,
+  derived in `rfGenSessionAddr(slot)` from bond UUID + FICR DEVICEID (deterministic,
+  stable across reboots; degenerate bytes scrubbed; never equals "ibex").
+- Tunables (`g_crcinit`, `g_whiteiv`, `g_mode`, `g_pcnf0/1`, `g_crcpoly`, `g_crccnf`,
+  `g_pid`, `g_balen`, `g_prefixRaw`, `g_statlen`) — mutated by the CDC console and by
+  rf_diag's `applyCfg`/sweeps. `rfConfig()` reads them on every (re)program.
+- `RWAIT_DISABLED()` macro: **bounded** spin-wait for `EVENTS_DISABLED`, **3000 µs**
+  timeout, then bails (never hangs the loop forever).
+- `rfSetAddr` bit-reverses the base/prefix into BASE0/PREFIX0.
+
+### `rf_link.cpp` / `rf_link.h` — operational puck protocol (loop task)
+The "dongle" role. All driven synchronously from `rfLinkTask()` in `loop()`.
+
+- **`rfLinkTask()`**: host-frame beacons (session keepalive E1 on `g_sessCh`, discovery
+  E1 on ch2), `rfConnStep()` poll loop, USB remote-wakeup on a fresh RF connection, QoS
+  channel hop, per-second stat aggregation. Gated off during a 2.5 s post-disconnect
+  cooldown (`g_connCooldown`).
+- **`rfConnStep()`**: cycle gate fires once per `g_pollUs` (250 Hz); on each fire it
+  polls **every warm slot back-to-back** (so N controllers all run at full rate). Cold
+  slots (silent > 5 s) retry only every 2 s. Per slot it flushes one relay entry and
+  sends a bare E3 poll (real-puck behavior; legacy E7/GET path is toggle-able).
+- **`rfConnTx(ch, s1, payload, plen, rxWinUs)`**: the core TX-then-RX primitive. TXes
+  one frame on the current slot's session address, then busy-waits for a reply
+  (`while(!EVENTS_END && micros()-t0 < win)`, `win` = `g_rxWin` default **2000 µs**, or
+  a tiny override like **80 µs** for no-ACK relays). On a CRC-good F-type reply it:
+  samples RSSI, updates `g_connReplyMs`/`g_linkRssi`, calls `hapticOnReconnect` on a
+  gap, and **decodes the 0x45 input report into `g_in[g_curSlot]`** (buttons, sticks,
+  triggers, trackpads, IMU). Also handles: Steam-button short-press remote wakeup,
+  Steam+Y 2 s power-off chord, back-4 mode-switch chord (`saveMode` + `NVIC_SystemReset`),
+  and status reports 0x43 (battery → `g_battery`/`g_batteryState`) / 0x44 → dispatched
+  via `g_active->onAuxReport`.
+- **`g_curSlot`** — the slot the poll loop is currently driving. Set by `rfConnStep`
+  before each TX; read by the decode, the relay flush, haptics, stats, webusb.
+- **Shared output state** (all `volatile`, written here, read by puck_hid/webusb):
+  `g_linkRssi[NSLOT]`, `g_battery[NSLOT]`, `g_batteryState[NSLOT]`.
+- Per-second readouts: `g_f1ps`, `g_newps`, `g_pollsps`, `g_pollPeriodUs`.
+- Many runtime toggles (`g_pollGet`, `g_e7announce`, `g_e1keepalive`, `g_rxWin`,
+  `g_e3mode`, `g_qos`, …) set from the console/webusb.
+- **Buffers**: uses the shared `rftx`/`rfrx` (radio.h). The 0x45 TLV walk bounds every
+  read against the received length and `sizeof(rfrx)` (explicit guards against runaway
+  `idx` and reads past the buffer — the documented stack-smash/LOCKUP class).
+
+### `triton.cpp` / `triton.h` — decoded controller input
+- **`g_in[NSLOT]`** (`struct PuckInput`): the decoded SC2 input per slot — buttons,
+  `lx/ly/rx/ry`, `lt/rt`, `lpx/lpy/rpx/rpy`, accel `ax/ay/az`, gyro `gx/gy/gz`.
+  **Written by `rf_link` (loop task) once per fresh 0x45; read by every USB
+  personality's `task()` (loop) and by webusb.** No lock (single writer, loop-context
+  readers; the usbd-task readers in mode_switch_pro read it too — benign).
+- TB_* button masks, inline decoders `s16off`/`u16off`/`trigU8`/`btnsOf`, `imuFrom45`.
+
+### `rf_diag.cpp` / `rf_diag.h` — RE / calibration tooling (loop task)
+Channel sniffers, raw capture/replay, beacon/respond, config sweeps — all driven from
+`rfDiagTask()` and CDC-console start hooks. **Reprograms the same `NRF_RADIO` and shares
+`rfrx`/`rftx` with rf_link with zero mutual exclusion** (relies on the design assumption
+that diag modes are off during normal operation). Buffers: `g_replay[48]`,
+`g_hostTx[48]` (len 0x22=34), `pl[48]` in `rfBeaconOnce` (copy clamped to 48 into
+`rftx`). Spin-waits: `RWAIT_DISABLED()` (3000 µs) ×1–3 per call; a 500 µs RX window in
+`rfBeaconOnce`. `rfReplayOnce` runs every loop with no rate gate while armed.
+Notable: `rfCapPoll` copies 48 bytes into `g_replay` but sets `g_replayLen=32`.
+
+---
+
+## 5. Controller abstraction — `controllers.cpp` / `controllers.h`
+
+- `IController` interface: `begin`, `onReport45`, `onAuxReport`, `task`, `wakeEvent`,
+  `isPuck`, and the dynamic-mount hooks `dynamicMount`/`maxSlots`/`usbIdentity`/
+  `beginPool`/`mountSlots`.
+- **`g_active`** — the one controller for this boot.
+- `controllerFor(mode)` maps `g_usbMode` → singleton (Steam & Lizard share `g_steamPuck`;
+  PS5/PS5_GAME share `g_ps5Ctl`; HIDGYRO/DS4_GAME share `g_hidGyroCtl`).
+- **Dispatch model**: PUSH modes (puck/lizard, Xbox) build+send their host report inside
+  `onReport45` (loop task, called from rf_link). STREAM modes (Switch Hori/Pro, PS5,
+  HIDGYRO) ignore `onReport45` and emit from `task()` reading `g_in[]`.
+
+---
+
+## 6. Puck personality — `puck_hid.cpp` / `puck_hid.h`  (MODE_STEAM + MODE_LIZARD)
+
+The 28DE:1304 puck identity with four HID slot interfaces (interface N = bond slot N).
+
+- **`PUCK_HID_DESC[]`** / **`PUCK_LIZARD_HID_DESC[]`** — the cloned puck report
+  descriptors (mouse 0x40 + keyboard 0x41 + vendor FF00 with 63-byte feature reports).
+- **`hid[NSLOT]`** — the four `Adafruit_USBD_HID` slot objects.
+
+### usbd-task code (HID callbacks, 800-byte stack)
+- **`handleSet(slot, rid, type, b, n)`** (via `setcb0..3`): host→device reports.
+  - OUTPUT reports 0x80–0x89: stamp `g_steamAliveMs` (Steam-alive heartbeat),
+    `hapLogAdd`, and for 0x80–0x86 relay to the controller via **`relayEnqueue`**
+    (gated by `hapticRelaySlotOk`, `lizardActive`, post-resume mute, `haptic82Blocked`).
+  - FEATURE report 0x01 = raw passthrough → `relayEnqueue(cmd,…)`; 0x9F → `hapticSendShutdown`.
+  - FEATURE command channel: 0x83 (attrs), 0xAE (strings), 0xB4 (connection state),
+    0xAD (pairing), 0xA2 (write/clear `g_slot[slot]` → sets `g_dirty`), 0xA3 (read slot).
+    Each writes the reply into `S.resp[63]` / `S.resp_len`.
+- **`handleGet(slot, rid, type, buf, reqlen)`** (via `getcb0..3`): returns the staged
+  `S.resp` (≤63 bytes), stamps `g_steamAliveMs` in Steam mode, `hapLogAdd(0xFC,…)`.
+
+### loop-task code
+- **`onReport45(slot, rep, fresh, bodyTlen)`** (from rf_link): suspended/post-resume
+  gating, then either `rfLizard` (lizard, slot 0 only) or `hid[slot].sendReport(0x45,
+  rep+1, blen)` (`blen` clamped ≤ 45).
+- **`onAuxReport(slot, rid, data, n)`**: forwards controller 0x43/0x44 verbatim to
+  `hid[slot]` (how Steam reads battery).
+- **`task()`**: post-resume edge stamp; per-slot connection presentation —
+  edge-triggered `0x79` (1-byte conn state), periodic `0x7B` (12-byte status with RSSI
+  patched from `g_linkRssi`), and a synthesized 14-byte `0x43` battery report from
+  `g_battery`/`g_batteryState` every 2 s (for SDL's full-length requirement). Also
+  `wakeNudgeTask()`.
+- **`wakeEvent()`** / `wakeNudgeTask()`: queue + deliver a net-zero mouse jiggle to wake
+  the host (prefers the boot wake-mouse; falls back to per-slot gamepad jiggle).
+  `g_nudgeStep[NSLOT]`, `g_nudgeMs[NSLOT]`.
+
+### Shared state owned here
+- `g_steamAliveMs` (last Steam OUTPUT/heartbeat; written in usbd & loop) and the derived
+  `lizardActive()` — **the single source of truth gating both the USB input path and the
+  haptic relay**. `g_resumeMs` (post-resume mute). `g_fwdNewOnly`. `g_autoLizard`.
+- **Cross-task**: `S.resp`/`S.resp_len` written in `handleSet` (usbd) and read in
+  `handleGet` (usbd) — same task. `g_slot[slot].rec`/`used` + `g_dirty` written in usbd,
+  flushed in loop. `g_steamAliveMs`/`g_resumeMs` written in both contexts, no lock.
+
+---
+
+## 7. Lizard desktop driver — `mode_lizard.cpp` / `mode_lizard.h`  (loop task)
+
+- **`rfLizard(r, mdev, kdev, mrid, krid)`** — not an IController; called from
+  `puck_hid::onReport45` (loop task). Turns a raw 0x45 into keyboard+mouse:
+  - Right pad → mouse motion (velocity/friction/sub-pixel glide; dx/dy clamped ±127).
+    `mdev->sendReport(mrid, &m, 5)` (`hid_mouse_report_t` = 5 bytes).
+  - Left pad → wheel (dw clamped ±15). Buttons from pad-click / trigger thresholds.
+  - Keyboard: modifiers + up to 6 keycodes; `krep[8]` (boot-keyboard layout),
+    `kdev->sendReport(krid, krep, 8)`.
+  - MODE_LIZARD-only consumer-control: `mdev->sendReport(0x03, &cc, 1)`.
+- All state is loop-local statics; no USB callbacks; no shared cross-task state.
+
+---
+
+## 8. XInput personality — `mode_xinput.cpp` / `mode_xinput.h`  (`g_xboxCtl`)
+
+Dynamic-mount, PUSH-style. Custom TinyUSB **vendor class driver** (0xFF/0x5D/0x01) plus a
+boot mouse for the right pad.
+
+- **usbd-task code (custom class driver `g_xiDriver`, returned via
+  `usbd_app_driver_get_cb`)**: `xi_init`, `xi_reset`, `xi_open` (claims interface →
+  `g_usbToBond[u]`, arms OUT endpoint with a 32-byte xfer), `xi_ctrl`, **`xi_xfer`**.
+  `xi_xfer` decodes the host rumble OUT report (`outBuf[0]==0x00 && [1]==0x08`) and
+  **calls `hapticSteamRumble(lo, hi, slot)` directly from the usbd task**, then re-arms
+  the 32-byte OUT xfer. No HID feature/get/set callbacks in this mode.
+- **loop-task code**: `onReport45` → `rfXboxGamepad(slot, rep)` (+ `rfXboxMouse` for slot
+  0); `xinputSend` fills `S.inBuf` (**20 bytes** filled of the 32-byte buffer) and
+  `usbd_edpt_xfer(0, epIn, r, 20)`; `task()` is the stuck-rumble watchdog (2.5 s →
+  `hapticSteamRumble(0,0,s)`).
+- **Buffers**: `XiSlot.inBuf[32]`, `XiSlot.outBuf[32]`; XInput report = 20 bytes;
+  descriptor = `XINPUT_DESC_LEN` 40 bytes; mouse report = 5 bytes.
+- **Cross-task**: `g_xiSlot[NSLOT]` — `inUse`/`rumbleLow`/`rumbleHigh`/`rumbleMs`
+  `volatile`, but `itf`/`epIn`/`epOut` **not** volatile. usbd writes endpoint/rumble
+  fields, loop reads them and touches the IN endpoint. No lock. `hapticSteamRumble`
+  reached from **both** usbd (`xi_xfer`) and loop (`task`) — safety relies on the relay
+  ring's PRIMASK guard.
+
+---
+
+## 9. Switch personalities
+
+### `mode_switch_hori.cpp` / `mode_switch_hori.h`  (`g_switchHori`)
+HORIPAD (0F0D:0092). Dynamic-mount, STREAM-style, plain 8-byte report, **no report
+callbacks registered → no usbd-task user code at all**.
+- `task()` (loop): rate-gated, `switchBuildHoripad(slot, p[8])` from `g_in[]`,
+  `sendReport(0, p, 8)` (8 bytes, no report id). All build state loop-local.
+
+### `mode_switch_pro.cpp` / `mode_switch_pro.h`  (`g_switchPro`)
+Switch Pro (057E:2009). Dynamic-mount, STREAM-style + a full handshake/subcommand state
+machine. **This mode registers an OUT/set-report callback** (`setReportCallback(NULL,
+JC_SETCB[s])`).
+
+- **usbd-task code (800-byte stack, the deepest USB-callback path in the firmware)**:
+  `jcSet0..3` → `jcSetCommon` → `jcRumble` (decodes amplitudes, dedupes, calls
+  **`hapticSteamRumble(lo,hi,bond)` from usbd**) and `jcSubcmd` (builds a 63-byte 0x21
+  reply: BT pairing, device info, SPI read via `spiRead`, SPI write via `jcSpiWrite`,
+  report-mode set). `jcSubcmd`'s local `p[63]` plus `jcInputPrefix`/`spiRead` is the
+  heaviest stack user on the 800-byte stack. `jcEnq` (usbd) is the **ring producer**.
+  `jcSpiWrite` is **RAM-only** (writes `g_userCal[slot][]`, sets a dirty flag) —
+  deliberately no flash I/O in the callback.
+- **loop-task code**: `beginPool` (LittleFS loads, descriptor setup), `task()` —
+  deferred flash save (`saveUserCal` when dirty + 250 ms), drains **one** queued reply
+  per slot via `sendReport(rid, data, 63)`, else rate-gated `switchProBuild(slot, p[63])`
+  + `sendReport(0x30, p, 63)`.
+- **Buffers**: `JC_REPLEN = 63` (all input/reply reports are 63 payload bytes);
+  reply ring `g_jcQ[NSLOT][8]` of `{rid, data[63]}`; `g_userCal[NSLOT][0x100]`;
+  `g_spiStickCal[18]`; `g_jcMac[NSLOT][6]`; config files `b[3]`.
+- **Cross-task shared data**:
+  - Reply ring `g_jcQ` / `g_jcQh` / `g_jcQt` — SPSC (usbd produces, loop consumes);
+    head/tail `volatile`, ring body **not** volatile.
+  - `g_swProReportMode[]` — written usbd (`jcSubcmd`) + loop (`mountSlots`), read loop;
+    **not volatile**.
+  - `g_userCal[][]` + `g_userCalDirty`/`g_userCalDirtyMs` — `g_userCal` written/read in
+    usbd, read in loop (`saveUserCal`); only the dirty flag/ts are `volatile` → a
+    loop-side save can race a usbd-side write (no lock).
+  - `g_jcTimer[]` — incremented in both contexts, not atomic (benign).
+  - `g_in[bond]` — read from both `jcInputPrefix` (usbd) and `switchProBuild` (loop).
+
+---
+
+## 10. PlayStation personalities
+
+### `mode_ps5.cpp` / `mode_ps5.h`  (`g_ps5Ctl`, MODE_PS5 + MODE_PS5_GAME)
+DualSense (054C:0CE6). Dynamic-mount, STREAM-style. **Registers get+set report
+callbacks** (`setReportCallback(PS5_GETCB[s], PS5_SETCB[s])`).
+- **usbd task**: `ps5GetCommon` (GET_FEATURE: 0x03→47 B, 0x05 motion-calib via
+  `psNeutralCalib` writing through buf[33]→returns 40, 0x09 MAC 6 B→19, 0x20 fw→63 B;
+  each `memset(buf,0,reqlen)` first); `ps5SetCommon` (OUTPUT 0x02 rumble →
+  `hapticSteamRumble(p[3]*257, p[2]*257, bond)` from usbd, reads `g_usbToBond[slot]`).
+- **loop task**: `task()` rate-gated → `ps5Build(u, bond, p[63])` from `g_in[bond]` (per-slot
+  `seq[]`), `sendReport(0x01, p, 63)`. Touch packed at `out+32` (8 bytes).
+- **Buffers**: input report `p[63]`; `g_ps5Mac[NSLOT][6]`.
+
+### `mode_hidgyro.cpp` / `mode_hidgyro.h`  (`g_hidGyroCtl`, MODE_HIDGYRO + MODE_DS4_GAME)
+DS4-layout (054C:05C4) + gyro. Same structure as PS5.
+- **usbd task**: `hidGyroGetCommon` (GET_FEATURE: 0x02 calib via `psNeutralCalib`→36,
+  0x12 MAC 6→15, 0x81 MAC 6→6, 0xA3 fw→48); `hidGyroSetCommon` (OUTPUT 0x05 rumble →
+  `hapticSteamRumble(p[4]*257, p[3]*257, bond)` from usbd).
+- **loop task**: `task()` → `hidGyroBuild(u, bond, p[63])` from `g_in[bond]`,
+  `sendReport(0x01, p, 63)`; touch via `touchPackPads(out+34,…)` (8 bytes).
+- **Buffers**: input report `p[63]`; `g_ds4Mac[NSLOT][6]`.
+
+**Both PS modes**: `g_usbToBond[]` read in usbd without a lock (bounds-checked); the
+calib helper `psNeutralCalib` writes 34 bytes (buf[6..33]) while callers return 36/40
+(remainder zeroed by the prior memset).
+
+### `gamepad_util.cpp` / `gamepad_util.h` — shared report-builders (called from loop task)
+`swStick`, `psNeutralCalib` (writes through `buf[33]`), trackpad→touch mappers
+(`touchPackPads` writes 8 bytes = two 4-byte points), `psButtonsFromSteam` (back-paddle/
+QAM/chord remap), `psShouldersByte`, `psHatNibble`, `psFaceNibble`, `tritonFromCode`.
+Reads `g_qamMap`/`g_abSwap`/`g_back[]`. Pure transforms, no buffers beyond callers'.
+
+---
+
+## 11. Haptics & host→controller relay — `haptics.cpp` / `haptics.h`
+
+**The central cross-task data structure: the relay rings.**
+
+- **`g_rq[NSLOT][RELAY_QLEN]`** (`RELAY_QLEN = 32`), `struct RelayMsg { uint8_t rid, len;
+  uint8_t data[RELAY_MAXP=60]; }`, with **`volatile g_rqHead[NSLOT]` / `g_rqTail[NSLOT]`**.
+  One ring per bond slot. **Multi-producer** (usbd-task HID callbacks: puck_hid
+  `handleSet`, the mode rumble callbacks; plus loop-context console/test injectors),
+  **single-consumer** per slot (`rfConnFlushRelay` on that slot's poll turn, loop task).
+- **`relayEnqueue(rid, payload, plen, slot)`**: producers serialize under
+  `__get_PRIMASK()` / `__disable_irq()` / `__set_PRIMASK`. `slot==0xFF` broadcasts to all
+  rings. Full ring → evict the **oldest** (keeps the most recent meaningful frame).
+- **`rfConnFlushRelay(ch, s1)`** (loop, from `rfConnStep`): drains **one** entry from
+  `g_curSlot`'s ring, frames it as an E3 sub-TLV (type-01 form for 0x9F/LED-0x2D,
+  legacy type-05 otherwise), `hapLogAdd(0xFE,…)`, TXes via `rfConnTx(…, 80)` (no-ACK).
+- **`rfConnQueueHapticRelay()`** (loop): injects test/stop haptics only on an idle cycle.
+
+### Other haptic state (mostly loop task, some `volatile` flags)
+- `g_testHaptic`, `g_hapticStop` (`volatile`), `g_hapticBlockOn`, `g_hapticBlockMs`,
+  `g_hapticBlockUntil[NSLOT]`, `g_relayOp`, `g_relaySub`.
+- `hapticSendShutdown()` — bursts 0x9F "off!" (`{6f 66 66 21}`) ×3 broadcast.
+- `hapticSteamRumble(low, high, slot)` — builds a 9-byte 0x80 report (×`g_rumbleScale`),
+  `relayEnqueue(0x80, p, 9, slot)`; **called from usbd (mode rumble callbacks) and loop**.
+  Per-slot stuck-rumble tracking `g_rumble80On/Ms[NSLOT]`.
+- `haptic82Blocked`/`hapticLinkUp`/`hapticRelaySlotOk` — link-up + block gates (read
+  `g_connReplyMs`, `g_slot`).
+- `hapticOnReconnect(slot)` — arms the per-slot block + schedules `g_reinitLeft` re-init
+  shots; `hapticCancelPendingOn` voids queued ON entries under PRIMASK.
+- `hapticReinit(slot)` — enqueues the captured 0x81/0x87 settings re-init sequence.
+- **`hapticTask()`** (loop): per-slot link-edge detect → `hapticOnReconnect`; fires
+  scheduled re-init; **autonomous controller power-off on a persisted USB suspend**
+  (`SUSPEND_OFF_MS = 4000` ms, VBUS-gated) → `hapticSendShutdown`; stuck-rumble watchdog
+  (2.5 s); idle-clear re-init.
+- **`hapInit()`** resets all rings/flags at boot.
+
+### Diagnostic capture ring (`#if OPK_LOG` only)
+- `g_hapLog[HAPLOG_N=4096]` of `{ms, slot, rid, n, b[16]}` (~80 KB). `hapLogAdd` written
+  from **both usbd and loop**, guarded by PRIMASK; `g_hapHead` is `uint16_t`. Drained to
+  WebUSB via `hapLogResetDrain`/`hapLogPull` (loop) and `'H'` console dump. Compiled out
+  entirely in production (`OPK_LOG=0`), reclaiming the RAM.
+
+---
+
+## 12. USB infrastructure
+
+### `usb_mount.cpp` / `usb_mount.h` — dynamic re-enumeration watcher (loop task)
+- **`g_usbMountCount`**, **`g_usbToBond[NSLOT]`** (usbSlot→bond, -1 unused),
+  **`g_bondToUsb[NSLOT]`** (inverse). `g_usbToBond` is the routing map the usbd-task
+  rumble callbacks read to map a USB output back to a bond slot.
+- **No TinyUSB callbacks here** despite the name — it's a poll-driven state machine.
+- `usbMountTask()` (loop): `connectedMask()` (slots with a reply < `CONN_UP_MS=1200` ms),
+  debounce `MOUNT_DEBOUNCE_MS=2000` ms, and on a steady change `commitOrder()` (rewrites
+  both maps + count) then **`usbReenumerate(g_usbMountCount)`** (detach/rebuild/attach,
+  no reboot). Order-preserving so existing instances keep their index.
+- No `delay()` here (the detach delay lives in `usbReenumerate`). Maps rewritten
+  non-atomically just before re-attach; consumers tolerate the brief window.
+
+### `wake_hid.cpp` / `wake_hid.h` — boot-mouse wake source
+- `WAKE_HID_DESC` = boot mouse, `g_wakeHid`, `g_wakeHidPresent`.
+- `wakeHidBegin` (setup), `wakeHidAddInterface` (re-enumerate build), `wakeHidPresent`,
+  `wakeHidReady`, `wakeHidMove(dx,dy)` → `g_wakeHid.mouseReport(0,0,dx,dy,0,0)`
+  (buttons always 0). Poll interval 10 ms. No buffers/delays/loops; only shared state is
+  the one flag set once at begin.
+
+### `webusb_config.cpp` / `webusb_config.h` — browser config channel (loop task)
+- `Adafruit_USBD_WebUSB usb_web`. **No vendor callbacks** — fully **polled from
+  `webusbPoll()` in `loop()`** (the library buffers the vendor RX). So nothing here runs
+  on the usbd 800-byte stack.
+- `webusbSendBlob()` builds a **115-byte** status blob (`p[2+WB_PAYLEN(113)]`, header
+  `A5 113 11`) reading a large set of diagnostic globals (`g_curSlot`, `g_connReplyMs`,
+  `g_battery`, `g_linkRssi`, `g_in[0]` IMU, `g_slot`, `g_type`, loop/poll stats, …) —
+  unsynchronized reads of RF-owned state, but cosmetic (torn reads at most).
+- `webusbPoll()` command parser (`static buf[16]`): `0x01` send blob; `0x02 field value`
+  mutates config tunables (writes `g_type`, `g_mDiv/Fric`, `g_rxWin` (clamped 600..3000),
+  `g_hapticBlockMs` (≤60 s), etc., then `applyActiveType`/`saveCfg`/`swProSaveCfg`);
+  `0x03 mode`→`saveMode`+reset; `0x07`→`hapticReinit`; `0x08`→`hapticSendShutdown`;
+  `0x0A` ("ERS")→`factoryErase`+reset; `0x0B/0x0C`→DFU; capture drain (`OPK_LOG`).
+  Each reset/DFU path does `delay(40)` then `NVIC_SystemReset()` (harmless — reboots).
+
+### `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
+  radio tunables, rf_link toggles, rf_diag flags, config (`E/F/W/K/Q` → `saveCfg`), and
+  inject relays (`J`/`t`). No blocking reads; `delay()` only before resets.
+- Note: `C` and `H` each appear twice in the chain — the **second occurrence of each is
+  dead code** (unreachable).
+
+---
+
+## 13. Status & fault
+
+### `status_led.cpp` / `status_led.h` (loop task)
+Drives two GPIO pins (LED_BUILTIN + pin 24) together. `ledWakePulse()` lights them and
+stamps `g_pulseMs`; `ledTask()` clears after `PULSE_MS=500`. No buffers/delays/radio.
+
+### `fault_diag.cpp` / `fault_diag.h`
+- **`HardFault_Handler()` (ISR / exception context)** — a **strong override** of the
+  weak default: sets `GPREGRET2 = 0xFA` then `NVIC_SystemReset()`. The only ISR/handler
+  in the codebase.
+- `faultDiagArmIntentionalReset()` sets `GPREGRET2 = 0xB1` before a deliberate reset.
+- `faultDiagBoot()` (loop) reads `RESETREAS` + `GPREGRET2`, classifies the last boot
+  (PIN / WATCHDOG / LOCKUP / HARDFAULT / REBOOT / SOFT / WAKE / POWERON / UNKNOWN),
+  clears `GPREGRET2`. Accessors `faultDiagReason`/`…ResetReas`/`…ReasonStr` feed WebUSB.
+
+---
+
+## 14. Cross-task shared state — consolidated
+
+State touched by **both** the loop task and the usbd task (the synchronization-critical set):
+
+| State | Writer(s) | Reader(s) | Synchronization |
+|---|---|---|---|
+| **`g_rq[NSLOT][32]` relay rings** + `g_rqHead/Tail` (haptics) | usbd HID callbacks (`handleSet`, mode rumble cbs) + loop (console/test/`hapticTask`) | loop `rfConnFlushRelay` | **Producers under PRIMASK**; `volatile` head/tail; ring body not volatile; SPSC-drain per slot |
+| **`g_slot[NSLOT].rec/used/resp`** (bonds) | usbd `handleSet` (0xA2 / reply staging) + loop (`loadBonds`) | loop RF/webusb/console, usbd `handleGet` | `resp` is same-task (usbd); `g_dirty` (`volatile`) defers the flash write to loop |
+| **`g_dirty`** | usbd `handleSet` | loop `loop()` → `saveBonds` | `volatile` flag |
+| **`g_in[NSLOT]`** (decoded input) | loop `rf_link` decode | loop mode `task()`/`onReport45`; **usbd `jcInputPrefix` (switch pro)**; webusb | none (single writer; readers tolerate staleness) |
+| **`g_connReplyMs[NSLOT]`** | loop `rf_link` | loop puck_hid/usb_mount/haptics/webusb; usbd haptic gates | none (plain `unsigned long`) |
+| **`g_battery/g_batteryState/g_linkRssi[NSLOT]`** | loop `rf_link` | loop puck_hid `task`/webusb | declared `volatile` |
+| **`g_usbToBond[NSLOT]`** (usb_mount) | loop `usbMountTask`/`commitOrder` | **usbd rumble callbacks** (ps5/hidgyro/xinput) + loop modes | none; bounds-checked; non-atomic rewrite just before re-attach |
+| **`g_jcQ`/`g_jcQh`/`g_jcQt`** (switch pro reply ring) | usbd `jcEnq` | loop `task()` | `volatile` head/tail, SPSC; body not volatile |
+| **`g_userCal[NSLOT][0x100]`** + dirty flags (switch pro) | usbd `jcSpiWrite` | usbd `spiRead`, loop `saveUserCal` | only dirty flag/ts `volatile`; `g_userCal` itself unguarded (loop save can race usbd write) |
+| **`g_xiSlot[NSLOT]`** (xinput) | usbd class driver (eps, rumble) | loop `xinputSend`/`task` | `inUse`/`rumble*` `volatile`; `itf`/`epIn`/`epOut` not volatile; no lock |
+| **`g_swProReportMode[NSLOT]`** | usbd `jcSubcmd` + loop `mountSlots` | loop `task` | **not volatile** |
+| **`g_steamAliveMs` / `g_resumeMs`** (puck_hid) | usbd `handleSet`/`handleGet` + loop `task` | both | none |
+| **`g_hapLog[4096]`** (`OPK_LOG`) | usbd + loop `hapLogAdd` | loop drain | PRIMASK in `hapLogAdd` |
+| **`rfrx[100]`/`rftx[100]` + `NRF_RADIO`** | loop `rf_link` AND loop `rf_diag` | same | **no guard** — relies on diag-modes-off assumption (both are loop task, so no preemption between them, but no arbitration of radio state) |
+
+**`hapticSteamRumble()` is the one app function legitimately entered from the usbd task**
+(via the ps5/hidgyro/xinput/switch-pro rumble callbacks) as well as the loop task; its
+safety rests entirely on `relayEnqueue`'s PRIMASK guard.

+ 58 - 1
Makefile

@@ -16,7 +16,64 @@ FORMAT_FILES := $(shell find OpenPuck ReversePuckFirmware puck_sniffer pairtui \
 	\( -name '*.c' -o -name '*.cpp' -o -name '*.h' -o -name '*.hpp' -o -name '*.ino' \) \
 	-not -name 'git_version.h')
 
-.PHONY: format format-check check
+# --- firmware build ---------------------------------------------------------
+# The OpenPuck firmware needs two TinyUSB config values that differ from the Adafruit nRF52 core defaults:
+#   CFG_TUD_HID=4            -- four HID interfaces (Steam mode exposes four puck slots); core default is 2.
+#   CFG_TUD_TASK_QUEUE_SZ=64 -- deeper usbd event queue; the default 16 deadlocks the loop task under comms
+#                               load -> watchdog reset (see OpenPuck.ino + docs/BUILD_AND_DEPLOY.md).
+#   CFG_TUD_VENDOR_TX_BUFSIZE=256 -- the WebUSB status blob is ~118 B; the core default 64 can't hold it whole,
+#                               so the panel send (which drops rather than blocks when the FIFO is full) could
+#                               never fit a frame -> blank panel. Sized to hold a full blob with headroom.
+# They're baked in here so a normal build is just `make build` -- no need to remember the flags. Override any
+# on the command line, e.g.   make build CFG_TUD_HID=6 CFG_TUD_TASK_QUEUE_SZ=128
+# or add your own defines:     make build EXTRA_FLAGS="-DOPK_LOG=1"
+FQBN ?= adafruit:nrf52:feather52840
+CFG_TUD_HID ?= 4
+# Deep so a loop()-context sendReport never blocks on a full usbd event queue (the watchdog path). Sends run
+# from loop() now (usbTxPump) for stable RF timing, so this depth is what keeps that block from happening.
+CFG_TUD_TASK_QUEUE_SZ ?= 512
+# Must hold a whole WebUSB status blob (~118 B) so the drop-on-full panel send can fit one; core default 64 is
+# too small and would silently drop every frame (blank panel / stale mappings).
+CFG_TUD_VENDOR_TX_BUFSIZE ?= 256
+EXTRA_FLAGS ?=
+# {build.flags.usb} is expanded by arduino-cli (VID/PID/strings); pass it through verbatim.
+USB_EXTRA_FLAGS = -DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=$(CFG_TUD_HID) -DCFG_TUD_TASK_QUEUE_SZ=$(CFG_TUD_TASK_QUEUE_SZ) -DCFG_TUD_VENDOR_TX_BUFSIZE=$(CFG_TUD_VENDOR_TX_BUFSIZE) $(EXTRA_FLAGS)
+
+# `flash`/`deploy` take the serial port as a REQUIRED POSITIONAL arg: `make flash /dev/cu.usbmodem1101`.
+# (No auto-detect -- uploading to a guessed serial port risks writing to the wrong device. List with
+# `arduino-cli board list`.) FLASH_PORT = whatever goal isn't one of our real targets; the catch-all rule at
+# the bottom swallows it so make doesn't try to build the port path as a target.
+FLASH_PORT := $(filter-out format format-check check build build-recovery flash deploy,$(MAKECMDGOALS))
+UPLOAD = arduino-cli upload -b $(FQBN) -p "$(FLASH_PORT)" OpenPuck
+
+.PHONY: format format-check check build build-recovery flash deploy
+
+## Compile the firmware with the required USB flags baked in. Override CFG_TUD_HID / CFG_TUD_TASK_QUEUE_SZ /
+## EXTRA_FLAGS / FQBN as make variables if needed.
+build:
+	arduino-cli compile -b $(FQBN) --build-property "build.extra_flags=$(USB_EXTRA_FLAGS)" OpenPuck
+
+## One-time factory-reset recovery image (wipes persistent storage once on first boot). See §6 of the build doc.
+build-recovery:
+	$(MAKE) build EXTRA_FLAGS="$(EXTRA_FLAGS) -DOPK_FACTORY_RESET=1"
+
+## Upload the most recent build. Usage: make flash <port>   e.g.  make flash /dev/cu.usbmodem1101
+## The board may need DFU/bootloader mode first (double-tap RST): puck (Steam/Lizard) mode drops the CDC port
+## so arduino-cli can't auto-reset it -- see docs/BUILD_AND_DEPLOY.md.
+flash:
+	@[ -n "$(FLASH_PORT)" ] || { echo "usage: make flash <port>   e.g. make flash /dev/cu.usbmodem1101   (list ports: arduino-cli board list)"; exit 1; }
+	$(UPLOAD)
+
+## Build then upload in one step. Usage: make deploy <port>   (same build overrides as `build`).
+deploy:
+	@[ -n "$(FLASH_PORT)" ] || { echo "usage: make deploy <port>   e.g. make deploy /dev/cu.usbmodem1101"; exit 1; }
+	$(MAKE) build
+	$(UPLOAD)
+
+# Swallow the positional <port> arg so make doesn't error trying to build it as a target. Scoped to things
+# that look like a serial port (so a real typo like `make buld` still errors instead of silently no-op'ing).
+/dev/% COM% tty% cu.%:
+	@:
 
 ## Reformat all C/C++ sources in place using the Linux kernel style.
 format:

+ 57 - 2
OpenPuck/OpenPuck.ino

@@ -2,7 +2,7 @@
 //
 // This firmware impersonates the Valve puck over USB, maintains puck-style bond slots, speaks the
 // reverse-engineered RF protocol to the controller, and re-enumerates into Steam, Xbox, Switch, PS5, or DS4
-// personalities. Build with -DCFG_TUD_HID=4 (the Adafruit nRF port defaults to 2).
+// personalities. Build with `make build` (bakes in the required CFG_TUD_HID=4 + CFG_TUD_TASK_QUEUE_SZ=64).
 //
 // This file is just the entry point: setup() builds the USB presentation for the persisted mode and arms the
 // hardware watchdog; loop() pumps each subsystem. Everything substantive lives in the modules below -- see
@@ -36,10 +36,26 @@ using namespace Adafruit_LittleFS_Namespace;
 #include "status_led.h"
 #include "usb_mount.h"
 #include "identity.h"
+#include "fault_diag.h"
+#include "usb_tx.h"
 #include <stdio.h>
 
 #if CFG_TUD_HID < 4
-#error "build with -DCFG_TUD_HID=4 (extra_flags): up to 4 HID interfaces per mode"
+#error "CFG_TUD_HID must be >= 4 (up to 4 HID interfaces per mode). Build with `make build` (bakes it in), or pass -DCFG_TUD_HID=4 in build.extra_flags."
+#endif
+
+// The TinyUSB device-stack event queue (_usbd_q) defaults to 16 entries. tud_task() runs in the dedicated
+// high-priority "usbd" FreeRTOS task, but this firmware emits HID reports from the LOW-priority loop() task
+// (every mode: 0x45 forwards, per-slot 0x79/0x7B/0x43 status, streamed gamepad reports). A cross-task
+// sendReport whose EasyDMA is momentarily busy takes TinyUSB's usbd_defer_func() path, which does a BLOCKING
+// osal_queue_send (xQueueSendToBack WAIT_FOREVER) onto _usbd_q. Under a comms burst (haptics/trackpad floods,
+// the connect-time re-init storm, 4 slots at once) the 16-deep queue saturates; the loop task then blocks
+// posting to it and stops feeding the ~8 s watchdog -> the MCU resets and the controller "disconnects". A
+// deeper queue absorbs realistic bursts so the loop never blocks. Must be set on the build command (it is a
+// TinyUSB-internal #ifndef default, so a sketch header cannot reach usbd.c) -- enforce it here so a build that
+// omits the flag fails loudly instead of shipping the deadlock.
+#if !defined(CFG_TUD_TASK_QUEUE_SZ) || CFG_TUD_TASK_QUEUE_SZ < 32
+#error "CFG_TUD_TASK_QUEUE_SZ must be >= 32: the default 16-deep usbd event queue deadlocks the loop task under comms load -> watchdog reset. Build with `make build` (bakes in 64), or pass -DCFG_TUD_TASK_QUEUE_SZ=64 in build.extra_flags."
 #endif
 
 // puck composite (4 HID + WebUSB) exceeds the default 256 B config buffer
@@ -198,6 +214,10 @@ void setup()
 		USBDevice.remoteWakeup();
 		ledWakePulse();
 	} // wake host if bus was sleeping when we (re-)attached
+	// Route all device->host HID sends through the usbd task (SOF drain) so loop() never calls tud_* directly
+	// -> the cross-task blocking-defer that deadlocked the loop under comms load can no longer happen.
+	usbTxBegin();
+	webusbInit(); // also drain the WebUSB status blob from the usbd task (its flush() can block loop() too)
 	hapticInit();
 	static const char *MODE_NAME[] = {
 		"STEAM(puck)",	       "XBOX(xinput+mouse)",
@@ -209,6 +229,12 @@ void setup()
 	Serial.printf("# copycat up: unit=%s board=%s, mode=%s\n", g_unit,
 		      g_board,
 		      MODE_NAME[g_usbMode <= MODE_MAX ? g_usbMode : 0]);
+	// 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",
@@ -239,6 +265,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
@@ -255,6 +283,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();
@@ -266,28 +299,38 @@ 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) {
 		g_loopPeriodUs = loops ? (uint16_t)(1000000UL / loops) : 0;
@@ -306,14 +349,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
 }

+ 13 - 2
OpenPuck/config.cpp

@@ -1,4 +1,6 @@
 #include "config.h"
+#include "rf_link.h" // g_rxWin (poll RX window persisted here)
+#include "haptics.h" // g_hapticBlockOn, g_hapticBlockMs
 #include <Adafruit_LittleFS.h>
 #include <InternalFileSystem.h>
 #include <string.h>
@@ -68,11 +70,14 @@ uint8_t g_rumbleScale = 200;
 const uint32_t g_pollUs = POLL_US_DEFAULT;
 
 #define CFG_FILE "/cfg.bin"
-// TypeCfg size changed; old flash format is incompatible -> clean defaults on first boot after upgrade
-#define CFG_MAGIC 0xCB
+// Struct layout/semantics changed (RF tunables); bump so old flash format is discarded -> clean defaults once.
+#define CFG_MAGIC 0xCD
 struct Cfg {
 	uint8_t magic, mode, mDiv, mFric, rsvd0, pollU100, persistMode,
 		bootMode, chordBtn[3], rumbleScale;
+	// Connection tunables: the connected-poll RX window in 10us units (g_rxWin/10), the post-connect haptic
+	// block enable, and its duration in seconds. 0 in rxWin10 => use the boot default.
+	uint8_t rxWin10, hapticBlockOn, hapticBlockS;
 	TypeCfg type[ET_COUNT]; // per-emulated-type back/qam/abSwap/padHaptics
 }; // rsvd0 = ex-padSmooth, now the one-shot debug-CDC arm
 
@@ -88,6 +93,9 @@ void saveCfg()
 		  g_bootMode,
 		  { g_chordBtn[0], g_chordBtn[1], g_chordBtn[2] },
 		  g_rumbleScale,
+		  (uint8_t)(g_rxWin / 10),
+		  g_hapticBlockOn,
+		  (uint8_t)(g_hapticBlockMs / 1000),
 		  {} };
 	for (int i = 0; i < ET_COUNT; i++)
 		c.type[i] = g_type[i];
@@ -142,6 +150,9 @@ 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. The post-connect haptic block is permanently disabled and no longer
+			// configurable, so any persisted hapticBlockOn/hapticBlockS is ignored too.
 		}
 		f.close();
 	}

+ 305 - 0
OpenPuck/fault_diag.cpp

@@ -0,0 +1,305 @@
+#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.
+// Arbitrary non-zero tags -- any value distinct from each other and from the power-on default (0) works.
+#define G2_INTENT 0xB1u
+#define G2_FAULT 0xFAu
+
+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",
+	"HARDFAULT", "reboot",	 "soft reset", "wake-from-off",
+};
+
+// HardFault override. The Adafruit core's default handler (cores/.../debug.cpp) already does NVIC_SystemReset();
+// we replace it to first stamp the fault marker so the NEXT boot classifies this SREQ as RR_HARDFAULT rather
+// than an intentional reboot. The core's strong symbol is only linked to satisfy the vector table, so our own
+// strong definition takes its place. Keep this MINIMAL: we are in fault context on a possibly-corrupt stack --
+// no Serial, no allocations, just stamp and reset.
+extern "C" void HardFault_Handler(void)
+{
+	NRF_POWER->GPREGRET2 = G2_FAULT;
+	NVIC_SystemReset();
+	while (1) {
+	}
+}
+
+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 =
+		readResetReason(); // latched + cleared by the core's init()
+	uint8_t g2 = (uint8_t)NRF_POWER->GPREGRET2;
+	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.
+	if (rr & POWER_RESETREAS_RESETPIN_Msk)
+		reason = RR_PIN;
+	else if (rr & POWER_RESETREAS_DOG_Msk)
+		reason = RR_WATCHDOG;
+	else if (rr & POWER_RESETREAS_LOCKUP_Msk)
+		reason = RR_LOCKUP;
+	else if (rr & POWER_RESETREAS_SREQ_Msk)
+		reason = (g2 == G2_FAULT)  ? RR_HARDFAULT :
+			 (g2 == G2_INTENT) ? RR_REBOOT :
+					     RR_SOFT;
+	else if (rr & POWER_RESETREAS_OFF_Msk)
+		reason = RR_WAKE;
+	else if (rr == 0)
+		reason = RR_POWERON; // all bits clear == power-on / brownout
+	else
+		reason = RR_UNKNOWN;
+	g_reason = reason;
+
+	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()
+{
+	return g_reason;
+}
+uint32_t faultDiagResetReas()
+{
+	return g_resetReas;
+}
+const char *faultDiagReasonStr()
+{
+	return REASON_STR[g_reason < RR_COUNT ? g_reason : 0];
+}

+ 89 - 0
OpenPuck/fault_diag.h

@@ -0,0 +1,89 @@
+// fault_diag.h -- "why did we reboot?" diagnostic for issue #72 (intermittent disconnects).
+//
+// Field reports conflate THREE different events that all look identical from the host (USB re-enumerates, the
+// WebUSB panel shows "RF Link down"):
+//   1. a genuine RF link drop -- NO reboot, the link self-recovers in seconds (handled in rf_link, not here);
+//   2. a loop() hang -- the ~8s hardware watchdog fires and resets the MCU (red LED);
+//   3. an MCU fault -- a HardFault triggers an immediate reset (the Adafruit core's default handler resets).
+// Without recording which one happened, (2) and (3) are indistinguishable in the field, so the thread keeps
+// guessing. This module classifies the cause of each boot and surfaces it (boot banner + WebUSB panel).
+//
+// How: the nRF52 RESETREAS register already distinguishes watchdog (DOG) / software (SREQ) / pin (RESETPIN) /
+// lockup resets; the Adafruit core latches it at boot (readResetReason()). A SREQ alone can't tell "we rebooted
+// on purpose" (mode-switch chord, config reboot, DFU) from "a HardFault reset us" -- so we stamp the GPREGRET2
+// retention register (retained across soft/watchdog/pin reset, cleared only on power-on; GPREGRET itself is
+// reserved by the bootloader for the DFU magic): faultDiagArmIntentionalReset() before every deliberate
+// NVIC_SystemReset, and the HardFault handler stamps a distinct fault marker.
+#pragma once
+#include <stdint.h>
+
+// Boot-cause classification (kept in sync with REASON_STR[] in fault_diag.cpp).
+enum {
+	RR_UNKNOWN = 0,
+	RR_POWERON, // cold power-on / brownout
+	RR_PIN, // reset pin / cable replug
+	RR_WATCHDOG, // WDT fired -> loop() stopped feeding it (a hang)
+	RR_LOCKUP, // CPU lockup
+	RR_HARDFAULT, // HardFault -> our handler reset us
+	RR_REBOOT, // intentional NVIC_SystemReset (mode switch / config / DFU)
+	RR_SOFT, // software reset, unattributed
+	RR_WAKE, // wake from System OFF
+	RR_COUNT,
+};
+
+// Classify + log the cause of THIS boot. Call once, early in setup() (after Serial.begin so the line lands).
+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)

+ 77 - 12
OpenPuck/haptics.cpp

@@ -14,6 +14,10 @@ uint8_t g_relayOp = 0xE3; // E3 poll
 uint8_t g_relaySub = 0x05;
 volatile uint8_t g_testHaptic = 0;
 volatile uint8_t g_hapticStop = 0;
+// Post-connect haptic block (see haptics.h): enabled + duration, both persisted and panel-adjustable.
+// Off by default -- opt in from the WebUSB panel if a controller's haptics come up degraded after connect.
+uint8_t g_hapticBlockOn = 0;
+uint16_t g_hapticBlockMs = HAPTIC_BLOCK_MS_DEFAULT;
 // Per-slot reconnect block. 0 = idle; non-zero = drop haptics aimed at this slot until millis() catches up.
 unsigned long g_hapticBlockUntil[NSLOT] = { 0 };
 
@@ -71,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()
 {
@@ -140,8 +150,10 @@ void hapLogAdd(uint8_t slot, uint8_t rid, const uint8_t *b, uint16_t n)
 	e.n = (uint8_t)(n > 255 ? 255 : n);
 	for (int i = 0; i < 16; i++)
 		e.b[i] = (i < (int)n) ? b[i] : 0;
-	g_hapHead = (uint8_t)((g_hapHead + 1) %
-			      (sizeof g_hapLog / sizeof g_hapLog[0]));
+	// uint16_t, NOT uint8_t: HAPLOG_N is 4096, so a uint8_t cast truncated the head to 0..255 and only the
+	// first 256 ring slots were ever used (capture lost ~94% of its depth).
+	g_hapHead = (uint16_t)((g_hapHead + 1) %
+			       (sizeof g_hapLog / sizeof g_hapLog[0]));
 	__set_PRIMASK(pm);
 }
 void hapticDumpLog()
@@ -231,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;
@@ -315,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())
@@ -332,14 +373,38 @@ 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
+	// high-priority usbd task and, when the ring is full, evicts the oldest by advancing g_rqTail itself --
+	// the same variable this consumer reads/advances. Reading the entry while a producer could overwrite it
+	// is a torn read (and dual-writing g_rqTail desyncs head/tail), so copy the entry out and consume the
+	// slot atomically here, then do the (slow) RF TX on the copy with interrupts enabled.
+	RelayMsg msg;
+	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
 		if (m.rid) {
+			msg = m; // copy out before the producer can reuse the slot
+			have = true;
+			break;
+		}
+	}
+	__set_PRIMASK(pm);
+	if (have) {
+		RelayMsg &m = msg;
+		{
 			uint8_t rl = m.len;
 			if (rl > RELAY_MAXP)
 				rl = RELAY_MAXP;
@@ -371,15 +436,14 @@ void rfConnFlushRelay(uint8_t ch, uint8_t s1)
 				plen = (uint8_t)(4 + rl);
 			}
 			hapLogAdd(0xFE, m.rid, m.data, rl);
-			// release slot before TX
-			g_rqTail[cur] = rqNext(g_rqTail[cur]);
+			// slot was already consumed under the critical section above.
 			// s1 carries a PID distinct from the GET poll (caller cycles it) so the controller's ESB
 			// dedup never treats the GET as a retransmit of this relay. 80us RX: relay is NO-ACK.
-			rfConnTx(ch, s1, p, plen, 80);
-			return; // one relay per poll cycle
+			rfConnTx(ch, s1, p, plen,
+				 80); // one relay per poll cycle
 		}
-		g_rqTail[cur] = rqNext(g_rqTail[cur]);
 	}
+	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
@@ -448,8 +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
-		g_hapticBlockUntil[s] = millis() + HAPTIC_RECONNECT_BLOCK_MS;
+		// 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
@@ -459,7 +523,8 @@ void hapticOnReconnect(int slot)
 {
 	if (slot < 0 || slot >= NSLOT)
 		return;
-	g_hapticBlockUntil[slot] = millis() + HAPTIC_RECONNECT_BLOCK_MS;
+	// 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;

+ 25 - 7
OpenPuck/haptics.h

@@ -20,10 +20,11 @@
 
 // after this much host silence, consider the current 0x82 haptic stream inactive
 #define HAPTIC_QUIET_MS 300u
-// Block ALL haptic relays for this long after a (re)connect: a just-powered-on controller's haptic engine
-// isn't ready, and feeding it haptics in that window leaves it in a degraded/latched state (slow/stuck/missing
-// haptics until a re-init). 3s gives it time to initialize.
-#define HAPTIC_RECONNECT_BLOCK_MS 3000u
+// Post-(re)connect haptic block default. While armed, ALL Steam haptic relays to that slot are dropped: a
+// just-powered-on controller's haptic engine isn't ready, and feeding it haptics in that window leaves it in a
+// degraded/latched state (slow/stuck/missing haptics until a re-init). Runtime-adjustable (g_hapticBlockMs) and
+// toggleable (g_hapticBlockOn) from the WebUSB panel; this is the boot default.
+#define HAPTIC_BLOCK_MS_DEFAULT 10000u
 // 0x82-zero relays per stop event (sent at poll cadence -- not loop rate)
 #define HAPTIC_STOP_BURST 4u
 // max relayed payload bytes per entry: RF frame = [E3][len][05][rid][payload] and MAXLEN=64 -> 60
@@ -46,6 +47,13 @@
 bool relayEnqueue(uint8_t rid, const uint8_t *payload, uint8_t plen,
 		  uint8_t slot = 0xFF);
 
+// Post-connect haptic block (persisted, panel-controlled): when g_hapticBlockOn, Steam haptics are dropped for
+// g_hapticBlockMs after a (re)connect so the controller's haptic engine settles before the first real haptic.
+extern uint8_t
+	g_hapticBlockOn; // 1 = block enabled, 0 = relay haptics immediately on connect (default)
+extern uint16_t
+	g_hapticBlockMs; // block duration in ms (default HAPTIC_BLOCK_MS_DEFAULT)
+
 // anything still queued (xinput uses it to pace rumble re-queues)
 bool relayPending();
 extern uint8_t g_relayOp; // relay frame opcode (E3 poll)
@@ -53,7 +61,7 @@ extern uint8_t g_relaySub; // relay sub-TLV type byte = SET
 extern volatile uint8_t g_testHaptic; // 't<n>' injects n test haptics
 // pending haptic-STOP frames to relay (kill a latched whine)
 extern volatile uint8_t g_hapticStop;
-// Per-slot block: arm after a (re)connect, drop haptics aimed at the slot for HAPTIC_RECONNECT_BLOCK_MS.
+// Per-slot block: arm after a (re)connect, drop haptics aimed at the slot for g_hapticBlockMs (when g_hapticBlockOn).
 extern unsigned long g_hapticBlockUntil[NSLOT];
 
 // relay the controller power-off (0x9F "off!"), burst x3 (Steam 0x9F / host-suspend / test button)
@@ -98,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();
@@ -112,7 +130,7 @@ void hapticTask();
 // harmless on healthy controllers, so it's worth re-initializing every slot the firmware knows about.
 void hapticReinit(uint8_t slot = 0xFF);
 // Called from rf_link the instant a controller (re)connect is detected (an F-reply after a gap): blocks haptic
-// relays for HAPTIC_RECONNECT_BLOCK_MS and schedules a re-init just after, to keep the freshly-booted
+// relays for g_hapticBlockMs and schedules a re-init just after, to keep the freshly-booted
 // controller out of the degraded/latched haptic state. Reliable -- independent of hapticTask's link heuristic.
 // Per-slot: only the slot that just reconnected is blocked, the others keep relaying.
 void hapticOnReconnect(int slot);

+ 2 - 1
OpenPuck/mode_hidgyro.cpp

@@ -5,6 +5,7 @@
 #include "haptics.h"
 #include "bonds.h"
 #include "usb_mount.h"
+#include "usb_tx.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 #include <string.h>
@@ -236,6 +237,6 @@ void HidGyroController::task()
 		g_gyroLastMs[u] = millis();
 		uint8_t p[63];
 		hidGyroBuild(u, (uint8_t)bond, p);
-		g_hidGyro[u].sendReport(0x01, p, sizeof p);
+		usbTxHid(&g_hidGyro[u], 0x01, p, sizeof p);
 	}
 }

+ 6 - 5
OpenPuck/mode_lizard.cpp

@@ -1,6 +1,7 @@
 #include "mode_lizard.h"
 #include "triton.h"
 #include "config.h"
+#include "usb_tx.h"
 
 void rfLizard(const uint8_t *r, Adafruit_USBD_HID *mdev,
 	      Adafruit_USBD_HID *kdev, uint8_t mrid, uint8_t krid)
@@ -84,7 +85,7 @@ void rfLizard(const uint8_t *r, Adafruit_USBD_HID *mdev,
 		m.wheel = (int8_t)dw;
 		m.pan = 0;
 		if (mdev->ready())
-			mdev->sendReport(mrid, &m, sizeof m);
+			usbTxHid(mdev, mrid, &m, sizeof m);
 	}
 	// --- keyboard: modifiers + up to 6 keycodes ---
 	uint8_t mod = 0, kc[6] = { 0, 0, 0, 0, 0, 0 }, nk = 0;
@@ -120,17 +121,17 @@ void rfLizard(const uint8_t *r, Adafruit_USBD_HID *mdev,
 		if (nL5 && !prevL5) {
 			uint8_t cc = 0x02;
 			if (mdev->ready())
-				mdev->sendReport(0x03, &cc, 1);
+				usbTxHid(mdev, 0x03, &cc, 1);
 		}
 		if (nR5 && !prevR5) {
 			uint8_t cc = 0x01;
 			if (mdev->ready())
-				mdev->sendReport(0x03, &cc, 1);
+				usbTxHid(mdev, 0x03, &cc, 1);
 		}
 		if ((!nL5 && prevL5) || (!nR5 && prevR5)) {
 			uint8_t cc = 0x00;
 			if (mdev->ready())
-				mdev->sendReport(0x03, &cc, 1);
+				usbTxHid(mdev, 0x03, &cc, 1);
 		}
 		prevL5 = nL5;
 		prevR5 = nR5;
@@ -169,6 +170,6 @@ void rfLizard(const uint8_t *r, Adafruit_USBD_HID *mdev,
 		uint8_t krep[8] = { mod,   0,	  kc[0], kc[1],
 				    kc[2], kc[3], kc[4], kc[5] };
 		if (kdev->ready())
-			kdev->sendReport(krid, krep, 8);
+			usbTxHid(kdev, krid, krep, 8);
 	}
 }

+ 2 - 1
OpenPuck/mode_ps5.cpp

@@ -5,6 +5,7 @@
 #include "haptics.h"
 #include "bonds.h"
 #include "usb_mount.h"
+#include "usb_tx.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 #include <string.h>
@@ -247,6 +248,6 @@ void Ps5Controller::task()
 		g_ps5LastMs[u] = millis();
 		uint8_t p[63];
 		ps5Build(u, (uint8_t)bond, p);
-		g_ps5[u].sendReport(0x01, p, sizeof p);
+		usbTxHid(&g_ps5[u], 0x01, p, sizeof p);
 	}
 }

+ 3 - 2
OpenPuck/mode_switch_hori.cpp

@@ -4,6 +4,7 @@
 #include "config.h"
 #include "bonds.h"
 #include "usb_mount.h"
+#include "usb_tx.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 
@@ -223,7 +224,7 @@ void SwitchHoriController::task()
 		g_swLastMs[u] = millis();
 		uint8_t p[8];
 		switchBuildHoripad((uint8_t)bond, p);
-		g_switch[u].sendReport(0, p,
-				       sizeof p); // report-id-less descriptor
+		usbTxHid(&g_switch[u], 0, p,
+			 sizeof p); // report-id-less descriptor
 	}
 }

+ 4 - 3
OpenPuck/mode_switch_pro.cpp

@@ -5,6 +5,7 @@
 #include "haptics.h"
 #include "bonds.h"
 #include "usb_mount.h"
+#include "usb_tx.h"
 #include <Adafruit_TinyUSB.h>
 #include <Adafruit_LittleFS.h>
 #include <InternalFileSystem.h>
@@ -760,8 +761,8 @@ void SwitchProController::task()
 		// bursty host-init doesn't starve the streamed 0x30
 		if (g_jcQh[s] != g_jcQt[s]) {
 			JcRep *r = &g_jcQ[s][g_jcQh[s]];
-			if (g_swPro[s].sendReport(r->rid, r->data, JC_REPLEN))
-				g_jcQh[s] = (uint8_t)((g_jcQh[s] + 1) % JCQ_N);
+			usbTxHid(&g_swPro[s], r->rid, r->data, JC_REPLEN);
+			g_jcQh[s] = (uint8_t)((g_jcQh[s] + 1) % JCQ_N);
 			// one report per slot per call; the rest next loop
 			continue;
 		}
@@ -781,6 +782,6 @@ void SwitchProController::task()
 		g_swProLastMs[s] = millis();
 		uint8_t p[63];
 		switchProBuild((uint8_t)s, p);
-		g_swPro[s].sendReport(0x30, p, sizeof p);
+		usbTxHid(&g_swPro[s], 0x30, p, sizeof p);
 	}
 }

+ 33 - 4
OpenPuck/mode_xinput.cpp

@@ -16,6 +16,7 @@
 #include "haptics.h"
 #include "bonds.h"
 #include "usb_mount.h"
+#include "usb_tx.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 #include <string.h>
@@ -62,6 +63,9 @@ struct XiSlot {
 	volatile unsigned long
 		rumbleMs; // millis of last OUT packet (stuck-rumble watchdog)
 	volatile bool inUse;
+	// inBuf holds a built-but-not-yet-sent 20B XInput report; the actual usbd_edpt_xfer is issued from the
+	// usbd task (usbTxDrainHook), never from loop(), so the cross-task blocking-defer can't stall the loop.
+	volatile bool txPending;
 };
 static XiSlot g_xiSlot[NSLOT];
 // Dynamic mount: xi_open is called once per XInput interface in descriptor order; this counts them so the
@@ -232,6 +236,8 @@ static void xinputSend(uint8_t slot, uint16_t buttons, uint8_t lt, uint8_t rt,
 	if (slot >= NSLOT || !g_xiSlot[slot].inUse)
 		return;
 	XiSlot &S = g_xiSlot[slot];
+	// Only refill inBuf when the IN endpoint is idle: while a transfer is in flight (busy) the DMA is still
+	// reading inBuf, so leave it untouched (this also coalesces -- newest state wins, like a real pad).
 	if (!tud_mounted() || S.epIn == 0 || usbd_edpt_busy(0, S.epIn))
 		return;
 	uint8_t *r = S.inBuf;
@@ -250,9 +256,30 @@ static void xinputSend(uint8_t slot, uint16_t buttons, uint8_t lt, uint8_t rt,
 	r[12] = ry & 0xFF;
 	r[13] = ry >> 8;
 	memset(r + 14, 0, 6);
-	if (usbd_edpt_claim(0, S.epIn)) {
-		if (!usbd_edpt_xfer(0, S.epIn, r, 20))
-			usbd_edpt_release(0, S.epIn);
+	// Hand off to the usbd task: the xfer is issued from xiSofDrain() (SOF), not here, so loop() never
+	// calls into the dcd transfer path (which can block on the device event queue under load).
+	S.txPending = true;
+}
+
+// usbd-task drain (registered with usbTxRegisterDrain, called from tud_sof_cb). Issues the deferred XInput IN
+// transfers. Runs at higher priority than loop(), so the claim->xfer is atomic w.r.t. xinputSend() refilling
+// inBuf -- no torn DMA.
+static void xiSofDrain(void)
+{
+	if (!tud_mounted())
+		return;
+	for (int s = 0; s < NSLOT; s++) {
+		XiSlot &S = g_xiSlot[s];
+		if (!S.inUse || !S.txPending || S.epIn == 0)
+			continue;
+		if (usbd_edpt_busy(0, S.epIn))
+			continue;
+		if (usbd_edpt_claim(0, S.epIn)) {
+			if (usbd_edpt_xfer(0, S.epIn, S.inBuf, 20))
+				S.txPending = false;
+			else
+				usbd_edpt_release(0, S.epIn);
+		}
 	}
 }
 
@@ -430,7 +457,7 @@ static void rfXboxMouse(const uint8_t *r)
 		m.wheel = 0;
 		m.pan = 0;
 		if (g_mouse.ready())
-			g_mouse.sendReport(0, &m, sizeof m);
+			usbTxHid(&g_mouse, 0, &m, sizeof m);
 	}
 }
 
@@ -463,6 +490,8 @@ void XboxController::beginPool()
 	g_mouse.begin();
 	for (int s = 0; s < NSLOT; s++)
 		g_xinput[s].setStringDescriptor("Controller");
+	// Drain the deferred XInput IN transfers from the usbd task every SOF (only registered in this mode).
+	usbTxRegisterDrain(xiSofDrain);
 }
 void XboxController::mountSlots(uint8_t k)
 {

+ 30 - 9
OpenPuck/puck_hid.cpp

@@ -7,6 +7,7 @@
 #include "triton.h"
 #include "mode_lizard.h"
 #include "wake_hid.h"
+#include "usb_tx.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 #include <string.h>
@@ -168,6 +169,12 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 			}
 		}
 
+#if OPK_LOG
+		// Per-report Serial echo: gated to the diagnostic build only. handleSet runs on the TinyUSB device
+		// task, whose FreeRTOS stack is just 800 bytes; a Serial.printf there costs 200-500B and, stacked
+		// under TinyUSB's control-transfer frames + a preempting USB ISR, can blow the stack -> the exact
+		// SP-corruption-during-exception-entry that shows up as RR_LOCKUP. Production keeps this path
+		// printf-free; the same data is already in the OPK_LOG capture ring (hapLogAdd) for the WebUSB panel.
 		if (Serial.availableForWrite() > 80) {
 			Serial.printf("# OUT if%d rid=%02X n=%u:", slot, rid,
 				      n);
@@ -175,6 +182,7 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 				Serial.printf(" %02X", b[i]);
 			Serial.println();
 		}
+#endif
 		return;
 	}
 	if (type != HID_REPORT_TYPE_FEATURE || n < 1)
@@ -214,10 +222,12 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 			// multi-register 0x87 settings blocks (LED brightness) and calibration writes exceed the old
 			// 18B cap, and the chopped frames were why those settings never landed on the controller.
 			uint8_t rl = (len <= pln) ? len : (uint8_t)pln;
+#if OPK_LOG
 			if (len > RELAY_MAXP && Serial.availableForWrite() > 60)
 				Serial.printf(
 					"# RELAY TRUNC cmd=%02X len=%u>%u\n",
 					cmd, len, (unsigned)RELAY_MAXP);
+#endif
 			relayEnqueue(cmd, pl, rl, (uint8_t)slot);
 
 			// track from RELAYED frames only (see the OUTPUT path)
@@ -225,6 +235,9 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 				haptic82HostReport(pl, len);
 		}
 	}
+#if OPK_LOG
+	// Diagnostic-build only -- see the OUTPUT-path note: no Serial.printf on the 800B usbd-task stack in
+	// production (LOCKUP mitigation). The feature SET is already captured to the OPK_LOG ring above.
 	if (Serial.availableForWrite() > 80) {
 		Serial.printf("# SET if%d rid=%02X cmd=%02X len=%u:", slot, rid,
 			      cmd, len);
@@ -232,6 +245,7 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 			Serial.printf(" %02X", b[i]);
 		Serial.println();
 	}
+#endif
 	memset(S.resp, 0, sizeof S.resp);
 	S.resp_len = 0;
 	switch (cmd) {
@@ -270,7 +284,9 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 		break;
 	case 0xAD:
 		g_pairing = (pln > 0 && pl[0] != 0);
+#if OPK_LOG
 		Serial.printf("# pairing %s\n", g_pairing ? "ON" : "off");
+#endif
 		S.resp[0] = 0xAD;
 		S.resp[1] = 0;
 		S.resp_len = 63;
@@ -285,8 +301,10 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 				S.used = true;
 			}
 			g_dirty = true;
+#if OPK_LOG
 			Serial.printf("# slot %d %s\n", slot,
 				      recEmpty(pl) ? "cleared" : "bonded");
+#endif
 		}
 		S.resp[0] = 0xA2;
 		S.resp[1] = 0;
@@ -337,6 +355,9 @@ static uint16_t handleGet(int slot, uint8_t rid, hid_report_type_t type,
 			lastMs = millis();
 			// ring marker 0xFC = "host feature GET" (panel renders it as "GET rid=.."); payload = what we returned
 			hapLogAdd(0xFC, rid, S.resp, n);
+#if OPK_LOG
+			// Diagnostic build only -- no Serial.printf on the 800B usbd-task stack in production (LOCKUP
+			// mitigation). The GET is captured to the OPK_LOG ring above for the WebUSB panel.
 			if (Serial.availableForWrite() > 80)
 				Serial.printf(
 					"# GET if%d rid=%02X reqlen=%u -> %02X %02X %02X (batt=%u%%)\n",
@@ -345,6 +366,7 @@ static uint16_t handleGet(int slot, uint8_t rid, hid_report_type_t type,
 					(slot >= 0 && slot < NSLOT) ?
 						g_battery[slot] :
 						0);
+#endif
 		}
 	}
 	return n;
@@ -440,9 +462,8 @@ void SteamPuckController::onReport45(int slot, const uint8_t *rep, bool fresh,
 		// only FRESH reports: the real puck dedupes, so stale repeats make Steam's velocity/smoothing
 		// stair-step. g_fwdNewOnly toggles for A/B.
 		if ((fresh || !g_fwdNewOnly) && hid[slot].ready())
-			hid[slot].sendReport(
-				0x45, rep + 1,
-				blen); // Steam/SDL Triton: input report 0x45
+			usbTxHid(&hid[slot], 0x45, rep + 1,
+				 blen); // Steam/SDL Triton: input report 0x45
 	}
 }
 
@@ -466,7 +487,7 @@ void SteamPuckController::onAuxReport(int slot, uint8_t rid,
 		// capture the pushed status report (0x43 battery / 0x44) device->host for the WebUSB panel: this is
 		// the channel Steam actually reads battery from; marker 0xFB = "->host push".
 		hapLogAdd(0xFB, rid, data, n);
-		hid[slot].sendReport(rid, data, n);
+		usbTxHid(&hid[slot], rid, data, n);
 	}
 }
 
@@ -544,8 +565,8 @@ static void wakeNudgeTask()
 		m.y = 0;
 		m.wheel = 0;
 		m.pan = 0;
-		hid[s].sendReport(0x40, &m,
-				  sizeof m); // jiggle right, then back
+		usbTxHid(&hid[s], 0x40, &m,
+			 sizeof m); // jiggle right, then back
 		g_nudgeStep[s] = (g_nudgeStep[s] >= 2) ?
 					 0 :
 					 (uint8_t)(g_nudgeStep[s] + 1);
@@ -595,7 +616,7 @@ void SteamPuckController::task()
 				connEdgeMs[s] = millis();
 			uint8_t st = conn ? 0x02 : 0x01;
 			hapLogAdd(0xFB, 0x79, &st, 1); // ->host push (capture)
-			hid[s].sendReport(0x79, &st, 1);
+			usbTxHid(&hid[s], 0x79, &st, 1);
 			usbConn[s] = conn;
 			last79[s] = millis();
 		} else if (conn && millis() - last7B[s] >= 2000) {
@@ -619,7 +640,7 @@ void SteamPuckController::task()
 				s7b[8] = (uint8_t)(0u - (uint8_t)mag);
 			}
 			hapLogAdd(0xFB, 0x7B, s7b, 12); // ->host push (capture)
-			hid[s].sendReport(0x7B, s7b, 12);
+			usbTxHid(&hid[s], 0x7B, s7b, 12);
 			last7B[s] = millis();
 		}
 		// Synthesized 0x43 = ID_TRITON_BATTERY_STATUS for SDL/Steam's gamepad driver. The verbatim forward of
@@ -637,7 +658,7 @@ void SteamPuckController::task()
 			b43[0] = st; // ucChargeState
 			b43[1] = g_battery[s]; // ucBatteryLevel (percent)
 			hapLogAdd(0xFB, 0x43, b43, 14); // ->host push (capture)
-			hid[s].sendReport(0x43, b43, sizeof b43);
+			usbTxHid(&hid[s], 0x43, b43, sizeof b43);
 			last43[s] = millis();
 		}
 	}

+ 4 - 1
OpenPuck/rf_diag.cpp

@@ -297,7 +297,10 @@ void rfBeaconOnce()
 	// RAM: [LENGTH][ (S1 if S1INCL) ][payload]
 	rftx[0] = 0;
 	rftx[1] = g_plen;
-	memcpy(rftx + 2, pl, g_plen);
+	// g_plen comes unclamped from the console 'L' command (0..255); the source is pl[48] and the dest is
+	// rftx[100] written at +2, so copy at most 48 bytes -- otherwise we over-read the stack and overrun rftx.
+	uint8_t cpLen = g_plen > sizeof pl ? (uint8_t)sizeof pl : g_plen;
+	memcpy(rftx + 2, pl, cpLen);
 	NRF_RADIO->PACKETPTR = (uint32_t)rftx;
 	NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk |
 			    RADIO_SHORTS_END_DISABLE_Msk;

+ 93 - 10
OpenPuck/rf_link.cpp

@@ -6,6 +6,7 @@
 #include "haptics.h"
 #include "controllers.h"
 #include "status_led.h"
+#include "fault_diag.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 #include <string.h>
@@ -40,8 +41,13 @@ bool g_e7announce =
 bool g_e1keepalive = true;
 
 bool g_connVerbose = false;
-// poll RX-window (us); shorter=more polls/s but may miss DELAYED replies. Tunable 'r'.
-uint32_t g_rxWin = 1200;
+// poll RX-window (us): the poll BUSY-WAITS up to this long for the controller's reply, so it is the dominant
+// per-poll loop cost and directly sets the poll rate -- one slot needs (g_rxWin + overhead) < g_pollUs(4000)
+// to sustain 250 Hz. 1200 is the proven 250 Hz value; it was briefly raised to 2000 (issue-72, delayed-reply
+// tolerance) which dropped the rate to ~220. FIXED + not configurable (like g_pollUs): there is no good
+// reason to raise it in the field, and doing so silently halves the poll rate. Any persisted/old value is
+// ignored.
+const uint32_t g_rxWin = 1200;
 unsigned long g_connCooldown = 0;
 
 uint8_t g_connSt = 0; // 0=announce awake, 1=poll loop
@@ -61,6 +67,15 @@ uint16_t g_f1ps = 0;
 uint16_t g_newps = 0;
 // polls/s (GET+relay TXs) last second -- distinguishes loop-starvation from reply-loss
 uint16_t g_pollsps = 0;
+// last second's CRC-fail and no-reply-in-window counts (wedge diagnosis)
+uint16_t g_crcps = 0, g_norxps = 0;
+uint16_t g_rfStallRecover = 0;
+
+// RF-stall self-heal thresholds. A genuine worst-case reply gap during normal play is ~1.5s (the
+// hapticOnReconnect re-init window); past RF_STALL_MS with us still actively polling, the whole link is wedged,
+// not merely blipping. RF_RECOVER_MS rate-limits the recovery so it can't thrash while a stalled link re-syncs.
+#define RF_STALL_MS 2500u
+#define RF_RECOVER_MS 2000u
 // measured avg us between GET-poll fires (vs intended g_pollUs)
 uint16_t g_pollPeriodUs = 0;
 static uint32_t g_pollDtSum = 0;
@@ -86,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).
@@ -243,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)
@@ -325,6 +344,13 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 				}
 			}
 			bool isF1 = (rtype == 0xF1);
+			// Every rfConnTx caller (rfConnStep) sets g_curSlot to a valid 0..NSLOT-1 before a poll, so it
+			// IS in range here today. But this block does ~50 unguarded g_curSlot array writes (g_in[],
+			// g_lastSeq[], and several static per-slot arrays) -- if any future/edge caller ever left
+			// g_curSlot at -1, those become out-of-bounds writes that corrupt RAM (the stack-smash / LOCKUP
+			// class we're chasing). Guard once at the top so the decode is robust by construction.
+			if (isF1 && (g_curSlot < 0 || g_curSlot >= NSLOT))
+				isF1 = false;
 			if (isF1) {
 				g_connF1++;
 				// walk ALL type6 TLVs (= HID report 0x45); taking only [0] halves the rate. idx is INT,
@@ -595,6 +621,7 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 						    !USBDevice.suspended()) {
 							saveMode(want);
 							delay(40);
+							faultDiagArmIntentionalReset();
 							NVIC_SystemReset();
 						}
 					} else {
@@ -725,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]++;
@@ -745,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);
 	}
@@ -797,6 +834,48 @@ void rfLinkTask()
 	if (g_connOn && millis() - g_connCooldown > 2500) {
 		rfConnStep();
 	} // connected-mode: poll controller, read input
+
+	// ---- RF stall self-heal -------------------------------------------------------------------------------
+	// Field wedge: the controllers stay powered and show a SOLID (connected) light, the puck keeps issuing E3
+	// polls + E1 beacons (radio TX is provably alive -- a power-cycled controller still re-adopts the session
+	// and goes solid against this puck), yet NOT ONE slot decodes a reply for seconds. The link reads down, no
+	// input reaches the host, and it never recovers on its own -- only a puck REPLUG fixes it. rfConfig() already
+	// re-disables (TASKS_DISABLE) the radio before every poll, so whatever this is survives a TASKS_DISABLE; the
+	// only thing that clears it is a full peripheral reset -- exactly what the replug does. So when the WHOLE
+	// link has been silent past RF_STALL_MS while we're still actively polling a slot that WAS connected, do the
+	// replug's job for the radio: power-cycle NRF_RADIO (POWER=0/1 clears every RADIO register; the next
+	// rfConnTx/rfHostFrameOnce re-applies all config via rfConfig()) and drop the connected-mode latches so
+	// polling/beaconing resume and the handshake re-runs from a clean slate. Gated on EVERY used+previously-
+	// connected slot being stalled, so one controller walking off (others still live) never trips it; the
+	// cooldown gate keeps it from firing while a controller is intentionally powering off; rate-limited so it
+	// cannot thrash. g_rfStallRecover is surfaced to the panel so the wedge -- and its recovery -- is observable.
+	if (g_connOn && g_curSlot >= 0 && millis() - g_connCooldown > 2500) {
+		static unsigned long lastRecoverMs = 0;
+		unsigned long nowMs2 = millis();
+		bool anyEverConnected = false, allStalled = true;
+		for (int s = 0; s < NSLOT; s++) {
+			if (!(g_slot[s].used && g_connReplyMs[s] != 0))
+				continue;
+			anyEverConnected = true;
+			if ((uint32_t)(nowMs2 - g_connReplyMs[s]) < RF_STALL_MS)
+				allStalled = false;
+		}
+		if (anyEverConnected && allStalled &&
+		    (uint32_t)(nowMs2 - lastRecoverMs) > RF_RECOVER_MS) {
+			lastRecoverMs = nowMs2;
+			g_rfStallRecover++;
+			NRF_RADIO->POWER = 0;
+			NRF_RADIO->POWER = 1;
+			g_connCooldown = 0;
+			g_connSt = 0;
+			g_connStep = 0;
+			if (Serial.availableForWrite() > 60)
+				Serial.printf(
+					"# RF STALL (#%u) -> radio power-cycle + reconnect\n",
+					g_rfStallRecover);
+		}
+	}
+
 	{
 		// remote wakeup on new RF controller connection (any slot)
 		static bool wasRfConn = false;
@@ -833,6 +912,9 @@ 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 =
 			g_pollDtCnt ? (uint16_t)(g_pollDtSum / g_pollDtCnt) : 0;
 		g_pollDtSum = 0;
@@ -846,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;

+ 14 - 2
OpenPuck/rf_link.h

@@ -43,7 +43,9 @@ extern bool
 
 // full multi-line debug dump vs compact "I45 <hex>" stream
 extern bool g_connVerbose;
-extern uint32_t g_rxWin; // poll RX-window (us): caps poll rate (~1e6/rxWin)
+// poll RX-window (us): caps poll rate (~1e6/rxWin). FIXED, not configurable (like g_pollUs) -- raising it
+// drops the poll rate, so it's pinned at the 250 Hz value rather than exposed as a footgun knob.
+extern const uint32_t g_rxWin;
 
 // set on 0xF2 disconnect; pauses beacon+poll so a powering-off controller can sleep
 extern unsigned long g_connCooldown;
@@ -76,8 +78,18 @@ 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;
+// Count of RF-stall self-heals (radio power-cycle + reconnect) performed since boot. Non-zero means the
+// firmware caught and recovered from a full-link wedge that previously needed a manual puck replug.
+extern uint16_t g_rfStallRecover;
 
 // MEASURED avg us between GET-poll fires (compare to the intended g_pollUs=4000)
 extern uint16_t g_pollPeriodUs;

+ 5 - 4
OpenPuck/serial_console.cpp

@@ -5,6 +5,7 @@
 #include "haptics.h"
 #include "bonds.h"
 #include "config.h"
+#include "fault_diag.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 #include <stdlib.h>
@@ -30,6 +31,7 @@ void serialConsolePoll()
 				Serial.println(
 					"# done -- rebooting into clean defaults; re-pair the controller");
 				delay(40);
+				faultDiagArmIntentionalReset();
 				NVIC_SystemReset();
 			} else if (line[0] == 'l')
 				rfListenStart();
@@ -104,6 +106,7 @@ void serialConsolePoll()
 						delay(20);
 						saveMode(m);
 						delay(40);
+						faultDiagArmIntentionalReset();
 						NVIC_SystemReset();
 					}
 				}
@@ -368,11 +371,9 @@ void serialConsolePoll()
 						g_slot[s].rec[7]);
 				}
 			} else if (line[0] == 'r') {
-				g_rxWin = strtoul(line + 1, 0, 10);
-				if (g_rxWin < 150)
-					g_rxWin = 150;
+				// poll RX-window is FIXED (not configurable) -- report only.
 				Serial.printf(
-					"# poll RX-window=%lu us (poll rate caps ~%lu/s)\n",
+					"# poll RX-window=%lu us (FIXED; poll rate caps ~%lu/s)\n",
 					(unsigned long)g_rxWin,
 					(unsigned long)(1000000 / g_rxWin));
 			} else if (line[0] == 'e') {

+ 26 - 2
OpenPuck/usb_mount.cpp

@@ -15,6 +15,12 @@ static uint8_t g_maxSlots = NSLOT;
 // The connected set must hold steady this long before we re-enumerate -- absorbs RF blips and the staggered
 // way multiple controllers connect at boot (one re-enumeration once the set settles, not one per controller).
 #define MOUNT_DEBOUNCE_MS 2000u
+// Lazy removal: a disconnected slot's interface is only dropped (and the device re-enumerated down) once NO
+// controller has sent input for this long -- i.e. during genuine downtime. Re-enumeration tears down/rebuilds
+// USB from the loop task and is risky to do mid-use, so we never shrink while anything is being played; we
+// just let dead slots linger as idle interfaces and clean them up when the rig goes quiet. Additions are NOT
+// gated by this -- a newly-connected controller grows the set right away.
+#define IDLE_CLEANUP_MS 60000u
 
 static uint8_t connectedMask()
 {
@@ -108,14 +114,32 @@ void usbMountTask()
 	static uint8_t lastMask = 0xFF; // force first compare
 	static unsigned long stableSince = 0;
 	unsigned long now = millis();
-	uint8_t want = connectedMask();
+	// ADD IMMEDIATELY, REMOVE LAZILY. Re-enumeration (usbReenumerate) tears down + rebuilds the whole USB
+	// config from the loop task; doing that while the high-priority usbd task is mid-tud_task races its
+	// internal state -> HardFault/reboot. So we only ever SHRINK the interface set during genuine downtime:
+	//   - active (some controller sent input within IDLE_CLEANUP_MS): target = connected UNION already-mounted
+	//     -> the set only grows. A controller that drops (e.g. an errant ReversePuck disconnect) keeps its
+	//     now-idle interface and reconnecting reuses it with NO re-enumeration. task() gates input per slot,
+	//     so an idle interface just streams nothing.
+	//   - idle (no controller input for a full minute): target = exactly the connected set -> any lingering
+	//     dead slot is dropped and we re-enumerate down, safely, because nobody is playing.
+	// New controllers grow the set right away (after the usual debounce) regardless of idle state.
+	unsigned long lastAny = 0;
+	for (int s = 0; s < NSLOT; s++)
+		if (g_slot[s].used && g_connReplyMs[s] > lastAny)
+			lastAny = g_connReplyMs[s];
+	bool idle = (lastAny == 0) || (now - lastAny) >= IDLE_CLEANUP_MS;
+	uint8_t want = idle ? connectedMask() :
+			      (uint8_t)(connectedMask() | mountedMask());
 	if (want != lastMask) { // set is moving -> restart the stability timer
 		lastMask = want;
 		stableSince = now;
 		return;
 	}
 	// `want` has been steady since stableSince. Re-enumerate once it has held long enough AND the resulting
-	// (order-preserving, capped) mount SET differs from what is currently mounted.
+	// (order-preserving, capped) mount SET differs from what is currently mounted. With the union above,
+	// while active that can only happen when a NEW controller needs an interface (grow); a shrink only ever
+	// comes from the idle branch dropping a long-dead slot.
 	if ((now - stableSince) < MOUNT_DEBOUNCE_MS)
 		return;
 	int8_t tmp[NSLOT];

+ 135 - 0
OpenPuck/usb_tx.cpp

@@ -0,0 +1,135 @@
+#include "usb_tx.h"
+#include <Adafruit_TinyUSB.h>
+#include <Arduino.h>
+#include <string.h>
+
+// One outbound ring per active HID destination. Only one USB mode is live at a time, and a mode presents at
+// most CFG_TUD_HID HID interfaces plus the wake mouse, so a handful of channels covers every case. Channels are
+// allocated lazily on first send to a given Adafruit_USBD_HID* and never freed (the object set is fixed for the
+// session). The producer side runs under PRIMASK (loop enqueue); the consumer (usbTxDrain) runs from the
+// usbtx task AND tud_hid_report_complete_cb, serialized by a guard flag inside usbTxDrain.
+#define TX_CHAN_MAX 8
+#define TX_RING_N \
+	4 // depth per destination; 250 Hz produced, ~1 kHz SOF drain -> stays shallow
+#define TX_DATA_MAX \
+	64 // CFG_TUD_HID_EP_BUFSIZE -- the largest report any mode sends (63 B + id)
+
+struct TxItem {
+	uint8_t rid;
+	uint8_t len;
+	uint8_t data[TX_DATA_MAX];
+};
+struct TxChan {
+	Adafruit_USBD_HID *hid; // nullptr = free slot
+	TxItem ring[TX_RING_N];
+	volatile uint8_t head,
+		tail; // head=next write, tail=next read; empty when equal
+};
+static TxChan g_chan[TX_CHAN_MAX];
+
+static inline uint8_t txNext(uint8_t i)
+{
+	return (uint8_t)((i + 1) % TX_RING_N);
+}
+
+void usbTxHid(Adafruit_USBD_HID *hid, uint8_t rid, const void *data,
+	      uint16_t len)
+{
+	if (!hid)
+		return;
+	if (len > TX_DATA_MAX)
+		len = TX_DATA_MAX;
+	uint32_t pm = __get_PRIMASK();
+	__disable_irq();
+	// find this destination's channel, or claim a free one
+	TxChan *c = nullptr;
+	for (int i = 0; i < TX_CHAN_MAX; i++) {
+		if (g_chan[i].hid == hid) {
+			c = &g_chan[i];
+			break;
+		}
+	}
+	if (!c) {
+		for (int i = 0; i < TX_CHAN_MAX; i++) {
+			if (!g_chan[i].hid) {
+				c = &g_chan[i];
+				c->hid = hid;
+				c->head = c->tail = 0;
+				break;
+			}
+		}
+	}
+	if (c) {
+		uint8_t h = c->head, nx = txNext(h);
+		// full -> drop the oldest (advance tail), never the newest
+		if (nx == c->tail)
+			c->tail = txNext(c->tail);
+		c->ring[h].rid = rid;
+		c->ring[h].len = (uint8_t)len;
+		if (len)
+			memcpy(c->ring[h].data, data, len);
+		c->head = nx;
+	}
+	__set_PRIMASK(pm);
+}
+
+// Send at most one ready report per destination per call. After a send the endpoint stays busy until the host
+// polls it, so the next report for that destination goes out from tud_hid_report_complete_cb (the instant the
+// host reads it) -- that chaining is what paces each stream to the host's poll rate. The usbtx task calls this
+// too, to kick a stream that's idle (no completion pending) and to keep non-busy destinations moving. A busy
+// destination is simply skipped; it does not hold up the others.
+void usbTxDrain(void)
+{
+	// Re-entrancy guard (defensive): usbTxPump only calls this from loop(), but sendReport below can yield,
+	// so guard against a nested loop()-context call ever racing a channel's tail. The loser just skips.
+	static volatile unsigned char draining;
+	if (__atomic_test_and_set(&draining, __ATOMIC_ACQUIRE))
+		return;
+	for (int i = 0; i < TX_CHAN_MAX; i++) {
+		TxChan *c = &g_chan[i];
+		if (!c->hid)
+			continue;
+		uint8_t t = c->tail;
+		if (t == c->head)
+			continue; // empty
+		if (!c->hid->ready())
+			continue; // endpoint busy / not mounted -> retry on completion/next tick
+		TxItem &it = c->ring[t];
+		// sendReport copies into the endpoint buffer, so releasing the slot right after is safe.
+		if (c->hid->sendReport(it.rid, it.data, it.len))
+			c->tail = txNext(t);
+	}
+	__atomic_clear(&draining, __ATOMIC_RELEASE);
+}
+
+// Per-slot drain callbacks for non-HID senders (XInput endpoint, WebUSB blob). Registered at setup(), invoked
+// from usbTxPump() (loop context). Fixed, tiny capacity; registration is setup-only (single-threaded).
+#define TX_DRAIN_MAX 4
+static usbTxDrainFn g_drainFns[TX_DRAIN_MAX];
+static uint8_t g_nDrain;
+
+void usbTxRegisterDrain(usbTxDrainFn fn)
+{
+	if (fn && g_nDrain < TX_DRAIN_MAX)
+		g_drainFns[g_nDrain++] = fn;
+}
+
+// Drain everything from loop() -- NOT from a SOF callback, a dedicated task, or report_complete. All three of
+// those were tried and all fight the RF poll, which is a microsecond-precise busy-wait running in loop(): SOF
+// got silently disabled by bus resets; the high-priority usbd task (report_complete) preempted the RF RX
+// windows -> missed replies + usbd self-blocking on a full event queue -> controllers dropped; a separate
+// low-priority task got starved under USB load -> latency. Draining in loop() is what stability-fix did and is
+// the only context that does NOT jitter the RF timing: the send and the poll run sequentially in one task, so
+// no send ever interrupts an RX window. The cost -- loop() can block if sendReport hits a full usbd event
+// queue -- is handled by a deep CFG_TUD_TASK_QUEUE_SZ (so it never fills) rather than by moving sends off loop.
+// Call once per loop() iteration.
+void usbTxPump(void)
+{
+	usbTxDrain();
+	for (uint8_t i = 0; i < g_nDrain; i++)
+		g_drainFns[i]();
+}
+void usbTxBegin(void)
+{
+	// Nothing to arm: usbTxPump() is driven directly from loop(). Kept for call-site stability.
+}

+ 45 - 0
OpenPuck/usb_tx.h

@@ -0,0 +1,45 @@
+// usb_tx.h -- marshal device->host HID reports onto the TinyUSB "usbd" task.
+//
+// THE PROBLEM this fixes (see ARCHITECTURE / the usbd-queue-deadlock notes): TinyUSB's tud_task() runs in a
+// dedicated high-priority "usbd" FreeRTOS task, but this firmware historically called hid.sendReport() (i.e.
+// tud_hid_*_report) straight from the LOW-priority loop() task. When the nRF52 USBD's single shared EasyDMA is
+// momentarily busy, that cross-task send takes TinyUSB's usbd_defer_func() path, which does a BLOCKING
+// osal_queue_send on the device event queue. Under a comms burst the queue saturates, the loop task blocks
+// there, stops feeding the ~8 s watchdog, and the MCU resets (the controller "disconnects").
+//
+// THE FIX: loop-context code never calls tud_* directly. It enqueues the report here (non-blocking, ISR/PRIMASK
+// safe), and the actual sendReport() happens from tud_sof_cb() -- which TinyUSB dispatches ON THE usbd TASK
+// once per USB frame (~1 ms). Every tud_* call is therefore back in the single context TinyUSB expects, so the
+// blocking-defer path can never stall the loop. Reports are keyed by their Adafruit_USBD_HID* (NOT the HID
+// instance index, which is mode-dependent and fragile -- see the "HID instance order vs dynamic mount" note).
+#pragma once
+#include <stdint.h>
+
+class Adafruit_USBD_HID;
+
+// Queue one HID report for `hid` to be sent from the usbd task. Safe from any context (loop or usbd). The
+// payload is copied, so the caller's buffer can be reused immediately (tud_hid_*_report copies into the
+// endpoint buffer too, so this preserves the original fire-and-forget semantics). Never blocks: if that
+// destination's ring is full the oldest queued report is dropped (same policy as the haptic relay ring) --
+// for a 250 Hz gamepad stream the newest state is what matters. `len` is clamped to the 64 B report ceiling.
+void usbTxHid(Adafruit_USBD_HID *hid, uint8_t rid, const void *data,
+	      uint16_t len);
+
+// Kept for call-site stability; no longer does anything (the drain runs from loop() via usbTxPump). Call once.
+void usbTxBegin(void);
+
+// Drain all pending sends (HID rings + registered non-HID drains). MUST be called from loop() once per
+// iteration -- sends run in loop() context, sequentially with the RF poll, so they never interrupt its
+// timing-critical RX windows (which is what off-loop drain contexts did, dropping controllers).
+void usbTxPump(void);
+
+// Drain pending HID reports (one ready report per destination per call). Internal -- called by usbTxPump().
+void usbTxDrain(void);
+
+// Register a callback to run every USB frame ON THE usbd TASK (from tud_sof_cb, right after usbTxDrain()).
+// This is how senders that DON'T go through Adafruit_USBD_HID get their transmits off the loop task too:
+//   - mode_xinput flushes its raw custom-class IN endpoints;
+//   - webusb_config sends the status blob (its usb_web.flush() can otherwise block the loop on a full queue).
+// Call once at setup() (before usbTxBegin()). Safe no-op if usb isn't up yet. Capacity is small + fixed.
+typedef void (*usbTxDrainFn)(void);
+void usbTxRegisterDrain(usbTxDrainFn fn);

+ 11 - 2
OpenPuck/wake_hid.cpp

@@ -1,4 +1,5 @@
 #include "wake_hid.h"
+#include "usb_tx.h"
 #include <Adafruit_TinyUSB.h>
 
 // Boot MOUSE descriptor -- proven to enumerate and wake Windows. The host enumerates a "HID-compliant mouse"
@@ -34,8 +35,16 @@ bool wakeHidMove(int8_t dx, int8_t dy)
 {
 	if (!wakeHidReady())
 		return false;
-	// boot mouse descriptor has no report ID -> report_id 0; buttons=0 so we move but never click
-	return g_wakeHid.mouseReport(0, 0, dx, dy, 0, 0);
+	// boot mouse descriptor has no report ID -> report_id 0; buttons=0 so we move but never click. Queued for
+	// the usbd task (usbTxHid) rather than sent inline, like every other report -- loop() issues no tud_* call.
+	hid_mouse_report_t m;
+	m.buttons = 0;
+	m.x = dx;
+	m.y = dy;
+	m.wheel = 0;
+	m.pan = 0;
+	usbTxHid(&g_wakeHid, 0, &m, sizeof m);
+	return true;
 }
 
 void wakeHidAddInterface()

+ 229 - 21
OpenPuck/webusb_config.cpp

@@ -6,11 +6,23 @@
 #include "puck_hid.h"
 #include "triton.h" // g_in raw IMU (diagnostic readout)
 #include "build_info.h"
+#include "fault_diag.h"
+#include "usb_tx.h"
 #include <Arduino.h>
 #include <string.h>
 
 Adafruit_USBD_WebUSB usb_web;
 
+// Panel-blob send is deferred to the usbd task: webusbPoll() (loop) just sets this, and webusbSofDrain()
+// (registered with usb_tx, runs from tud_sof_cb on the usbd task) does the actual write+flush. usb_web.flush()
+// 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]
 //                [qos][persistMode][chordBtn B][chordBtn X][chordBtn Y][pollsps_lo][pollsps_hi]
@@ -19,9 +31,19 @@ Adafruit_USBD_WebUSB usb_web;
 //                [gitDirty][gitHash 12B ASCII, NUL-padded][rumbleScale][swPro120][swGyroScale10][raw accel ax ay az 3x s16 LE]
 //                [bondedCount][slot0_up][slot0_batt][slot0_rssi]...[slot3_up][slot3_batt][slot3_rssi]
 //                [v10: per-type cfg, 4x8B: ET_XBOX/SWITCH/DS4/DS5 each {back0..3, qam, abSwap, padHaptics, ledBright}]
-// p[6]/p[7]/p[8..11] mirror the ACTIVE type (legacy display). v10 extends to 105 bytes (107 total incl header);
+//                [v11: resetReason(RR_* code)][resetReas raw u32 LE][rxWin/10 us][hapticBlockOn][hapticBlock s]
+// 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.
-#define WB_PAYLEN 105
+//                [diag: crc/s][noRx/s][rfStallRecover count]
+//                [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
+// that flag (or a future blob that outgrows the FIFO) fails loudly instead of shipping a dead panel.
+static_assert(CFG_TUD_VENDOR_TX_BUFSIZE >= 2 + WB_PAYLEN,
+	      "CFG_TUD_VENDOR_TX_BUFSIZE too small to hold a WebUSB blob in one "
+	      "write -- build via `make build` (sets 256), or raise the flag.");
 static void webusbSendBlob()
 {
 	if (!usb_web.connected())
@@ -35,8 +57,8 @@ static void webusbSendBlob()
 	p[0] = 0xA5;
 	p[1] = WB_PAYLEN;
 
-	// protocol version (10 = +ledBright per type; 9 = +per-type cfg; 8 = +per-slot link status; 7 = +raw accel; 6 = +swPro120/gyroScale)
-	p[2] = 10;
+	// 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;
@@ -127,8 +149,129 @@ static void webusbSendBlob()
 		q[6] = g_type[et].padHaptics;
 		q[7] = g_type[et].ledBright;
 	}
-	usb_web.write(p, sizeof p);
-	usb_web.flush();
+	// last-boot reset cause (protocol v11): why we (re)booted -- watchdog hang vs HardFault vs intentional
+	// reboot vs power-on (issue #72). p[107] = RR_* code; p[108..111] = raw RESETREAS for the curious.
+	p[107] = faultDiagReason();
+	uint32_t rr = faultDiagResetReas();
+	p[108] = (uint8_t)rr;
+	p[109] = (uint8_t)(rr >> 8);
+	p[110] = (uint8_t)(rr >> 16);
+	p[111] = (uint8_t)(rr >> 24);
+	// Connection tunables (protocol v11): poll RX window in 10us units, post-connect haptic block enable,
+	// and block duration in seconds.
+	p[112] = (uint8_t)(g_rxWin / 10);
+	p[113] = g_hapticBlockOn;
+	p[114] = (uint8_t)(g_hapticBlockMs / 1000);
+	// RF wedge diagnostics (always populated, even when the link reads down): CRC-fail/s and no-reply/s,
+	// capped at 255. With Polls/s this distinguishes poll-loop-stopped vs RX-dead vs corrupt-replies.
+	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
+	// that spin hangs loop() until the ~8s watchdog resets us (RESETREAS=watchdog/hang). This is the ONE
+	// cross-task tud_ call the marshalling refactor left able to block loop() (HID goes through the drop-oldest
+	// ring; this didn't). So DROP the blob whenever the FIFO can't take it whole -- a status panel missing an
+	// occasional frame is invisible, and loop() can never stall here again. (Matches the closing-the-panel
+	// "fix": that just flips _connected=false to break the same spin.)
+	if (tud_vendor_write_available() >= sizeof p) {
+		usb_web.write(p, sizeof p);
+		usb_web.flush();
+	}
+}
+
+// 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).
+void webusbInit(void)
+{
+	usbTxRegisterDrain(webusbSofDrain);
 }
 #if OPK_LOG
 // Stream the capture ring (haptics / relayed host commands) to the panel as 0xA6 frames. Frame formats:
@@ -171,14 +314,21 @@ static void webusbDrainCapture()
 	uint32_t ms = 0;
 	uint8_t slot = 0, rid = 0, nb = 0, b[16];
 	uint16_t budget = 128;
-	while (budget-- && hapLogPull(&ms, &slot, &rid, &nb, b))
+	// Same anti-hang rule as the blob: webusbCapFrame's usb_web.write() spins until the FIFO drains, so stop
+	// pulling the moment the FIFO can't hold a max-size frame (2+9+16=27 B) -- the panel resumes the drain on
+	// its next 0x06 poll. Never let a backed-up panel hang loop() here.
+	while (budget-- && tud_vendor_write_available() >= 27 &&
+	       hapLogPull(&ms, &slot, &rid, &nb, b))
 		webusbCapFrame(ms, slot, rid, nb, b);
-	webusbCapEnd();
+	if (tud_vendor_write_available() >= 3)
+		webusbCapEnd();
 }
 #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();
@@ -191,20 +341,37 @@ 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) {
-				webusbSendBlob();
+				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
 
@@ -235,6 +402,7 @@ void webusbPoll()
 					usb_web.flush();
 					factoryErase();
 					delay(40);
+					faultDiagArmIntentionalReset();
 					NVIC_SystemReset();
 				}
 
@@ -242,14 +410,48 @@ void webusbPoll()
 			} else if (op == 0x0B) {
 				usb_web.flush();
 				delay(40);
+				faultDiagArmIntentionalReset();
 				enterSerialDfu();
 
 				// reboot into UF2 bootloader (USB mass storage)
 			} else if (op == 0x0C) {
 				usb_web.flush();
 				delay(40);
+				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];
 
@@ -279,7 +481,7 @@ void webusbPoll()
 							applyActiveType();
 					}
 					saveCfg();
-					webusbSendBlob();
+					g_blobRequest = true;
 					memmove(buf, buf + need, n - need);
 					n -= need;
 					continue;
@@ -358,6 +560,7 @@ void webusbPoll()
 					armDebugCdcNextBoot();
 					usb_web.flush();
 					delay(40);
+					faultDiagArmIntentionalReset();
 					NVIC_SystemReset();
 					break;
 
@@ -386,17 +589,22 @@ void webusbPoll()
 					swProSaveCfg();
 					persist = false;
 					break; // Switch Pro gyro scale x10
+
+				// (field 25, poll RX window, removed -- g_rxWin is now FIXED/not configurable)
+				// (fields 27/28, post-connect haptic block, removed -- permanently disabled)
 				}
 				if (persist)
 					saveCfg();
-				webusbSendBlob();
+				g_blobRequest = true;
 			} else if (op == 0x03) {
 				uint8_t m = buf[1];
 				if (modeValid(m) && !USBDevice.suspended()) {
-					webusbSendBlob();
-					usb_web.flush();
+					// best-effort status to the panel before the reboot; the SOF drain
+					// sends it during the delay(40) below (no blocking flush on loop()).
+					g_blobRequest = true;
 					saveMode(m);
 					delay(40);
+					faultDiagArmIntentionalReset();
 					NVIC_SystemReset();
 				}
 			}

+ 1 - 0
OpenPuck/webusb_config.h

@@ -12,4 +12,5 @@
 
 extern Adafruit_USBD_WebUSB usb_web;
 
+void webusbInit(); // register the SOF-driven blob sender; call once from setup() after usbTxBegin()
 void webusbPoll(); // drain host commands + reply; call every loop()

+ 3 - 1
README.md

@@ -77,11 +77,13 @@ This is a tool to emulate a Steam Controller 2 with almost all of its inputs (ex
 # Future work
 - Find a way to make Xinput mode and mouse work together on all platforms
 - Design the charging portion (and make it short proof)
+- Make ReversePuck into a system that you can plug into most controllers and allow them to talk to OpenPuck
+- A BLE version
 
 # Contributions
 The firmware is split into small, single-responsibility modules under `OpenPuck/` (one file per emulated controller, plus the RF, config, and host-interface layers). Start with [ARCHITECTURE.md](./ARCHITECTURE.md) for the map of how it all fits together and how to add a new USB personality.
 
-I have tested this software fairly extensively but I have limited resources. I only have one controller so I've been unable to do full end-to-end multi-controller hardware testing — the per-slot session address, per-slot USB fanout, and per-slot haptic demux are in place and the design is sound, but the `U` CDC command exists specifically so anyone with a second controller can verify the per-slot state at a glance. Please submit issues with any issues you find. PRs also welcome of course.
+I have tested this software fairly extensively but I have limited resources. Please submit issues with any issues you find. PRs also welcome of course.
 
 # Acknowledgements
 - Valve for putting out the amazing controller

+ 120 - 27
ReversePuck/ui.py

@@ -1,12 +1,22 @@
 """ui.py -- fullscreen touchscreen UI for the Steam Deck forwarder.
 
 Big tappable tiles, one per bonded puck. A tile is tappable when its puck is LIVE (the RF link is up).
-Tapping a live tile detaches the Deck controls and forwards to that puck; tapping anywhere while
-forwarding stops and returns control to the Deck. Sized for the Deck's 1280x800 touchscreen but scales.
+Tapping a live tile detaches the Deck controls and forwards to that puck (a single tap -- connecting is
+cheap and reversible). The two DESTRUCTIVE actions -- stopping/disconnecting a forward, and removing a
+bond (unpair) -- instead require a HOLD (HOLD_SECS): an errant screen tap mid-game used to drop the forward
+(which on the puck side tears down a USB device and could reboot the MCU), so a brief touch must no longer
+trigger it. A progress fill shows the hold; lifting before it completes cancels. Sized for the Deck's
+1280x800 touchscreen but scales.
+
+Status icons (dots, the remove ✕) are drawn with primitives, not font glyphs -- the Deck's available fonts
+don't carry ● ○ ✕ etc., so rendering them as text just showed blank/boxes.
 """
 import time
 import pygame
 
+# Destructive actions (stop forwarding, remove bond) require holding this long; a tap no longer triggers them.
+HOLD_SECS = 2.0
+
 BG = (16, 18, 24)
 FG = (230, 232, 238)
 DIM = (120, 126, 140)
@@ -24,6 +34,23 @@ def _font(sz, bold=False):
     return f
 
 
+def _draw_dot(screen, center, r, color, filled=True):
+    """Status dot drawn with primitives -- font glyphs (● ○) don't render on the Deck's fallback font."""
+    if filled:
+        pygame.draw.circle(screen, color, center, r)
+    else:
+        pygame.draw.circle(screen, color, center, r, max(2, r // 3))
+
+
+def _draw_cross(screen, rect, color, width):
+    """Remove-bond ✕ drawn as two diagonals (the font glyph doesn't render)."""
+    pad = max(6, rect.width // 4)
+    pygame.draw.line(screen, color, (rect.x + pad, rect.y + pad),
+                     (rect.right - pad, rect.bottom - pad), width)
+    pygame.draw.line(screen, color, (rect.right - pad, rect.y + pad),
+                     (rect.x + pad, rect.bottom - pad), width)
+
+
 def run(app):
     pygame.init()
     pygame.display.set_caption("ReversePuck · Steam Deck")
@@ -37,21 +64,37 @@ def run(app):
 
     tile_rects = []  # (rect, slot, tappable)
     del_rects = []   # (rect, slot) -- the per-tile "remove bond" ✕ target
+    # Active hold-to-confirm gesture, or None. dict(kind="stop"|"unpair", slot, start). Read by draw() for
+    # the progress fill; set in handle_down()/cleared in handle_up() and on completion.
+    hold = None
+
+    def hold_progress():
+        """0.0..1.0 of the current hold, or None if no hold is active."""
+        if hold is None:
+            return None
+        return max(0.0, min(1.0, (time.monotonic() - hold["start"]) / HOLD_SECS))
 
     def draw():
         nonlocal tile_rects, del_rects
         screen.fill(BG)
         s = app.status
         connected = app.dongle_connected
-        # compact status line (no big title banner): dongle presence first, then RF link
+        # compact status line (no big title banner): dongle presence first, then RF link. The leading status
+        # dot is drawn (font glyphs don't render); filled = linked, ring = no-dongle / searching.
         if not connected:
-            badge, col = "⨯ no dongle", WARN
+            badge, col = "no dongle", WARN
         elif s["link_up"]:
-            badge, col = "● linked  ch%d" % s["sess_ch"], LIVE
+            badge, col = "linked  ch%d" % s["sess_ch"], LIVE
         else:
-            badge, col = "○ searching…", DIM
+            badge, col = "searching...", DIM
         t = f_med.render(badge, True, col)
-        screen.blit(t, (W - t.get_width() - 40, 24))
+        bx = W - t.get_width() - 40
+        screen.blit(t, (bx, 24))
+        _draw_dot(screen, (bx - 18, 24 + t.get_height() // 2), 8, col,
+                  filled=connected and s["link_up"])
+        # legend (top-left): how the gestures work
+        screen.blit(f_small.render("Tap to connect, hold to disconnect or remove", True, DIM),
+                    (40, 30))
 
         # tiles -- live pucks float to the top, then by slot index
         tile_rects = []
@@ -64,7 +107,7 @@ def run(app):
         if not connected:
             msg = f_big.render("Plug in the ReversePuck dongle", True, WARN)
             screen.blit(msg, (W // 2 - msg.get_width() // 2, H // 2 - 40))
-            sub = f_small.render("Waiting for the nRF (Valve 28DE:1302) on USB… it'll appear here automatically.",
+            sub = f_small.render("Waiting for the nRF (Valve 28DE:1302) on USB... it'll appear here automatically.",
                                  True, DIM)
             screen.blit(sub, (W // 2 - sub.get_width() // 2, H // 2 + 20))
         elif not bonds:
@@ -82,17 +125,16 @@ def run(app):
             color = TILE_SEL if sel else (TILE_LIVE if b["alive"] else TILE)
             pygame.draw.rect(screen, color, rect, border_radius=18)
             pygame.draw.rect(screen, (ACCENT if (tappable or sel) else DIM), rect, width=2, border_radius=18)
-            # glyph + serial
-            glyph = "●" if b["alive"] else "○"
+            # status dot (drawn -- font ● ○ don't render) + serial
             gcol = LIVE if b["alive"] else DIM
-            screen.blit(f_big.render(glyph, True, gcol), (rect.x + 30, rect.y + th // 2 - 22))
+            _draw_dot(screen, (rect.x + 46, rect.y + th // 2), 13, gcol, filled=b["alive"])
             screen.blit(f_big.render(b["serial"] or "puck %d" % slot, True, FG),
                         (rect.x + 90, rect.y + 20))
-            state = ("FORWARDING — tap to stop" if sel
-                     else "available — tap to forward" if tappable
+            state = ("FORWARDING - hold anywhere to stop" if sel
+                     else "available - tap to forward" if tappable
                      else "live (forwarding elsewhere)" if b["alive"]
-                     else "paired · offline")
-            screen.blit(f_small.render("slot %d · %s" % (slot, state), True, DIM),
+                     else "paired - offline")
+            screen.blit(f_small.render("slot %d - %s" % (slot, state), True, DIM),
                         (rect.x + 90, rect.y + th - 38))
             tile_rects.append((rect, slot, tappable or sel))
             # remove-bond ✕ on the far right (only when idle -- can't un-bond while forwarding)
@@ -100,10 +142,16 @@ def run(app):
                 dsz = min(th - 24, 64)
                 drect = pygame.Rect(rect.right - dsz - 18, rect.y + (th - dsz) // 2, dsz, dsz)
                 pygame.draw.rect(screen, TILE, drect, border_radius=12)
+                # while this ✕ is being held, fill it from the bottom up as a hold-to-unpair progress meter
+                held = (hold is not None and hold["kind"] == "unpair"
+                        and hold["slot"] == slot)
+                hp = hold_progress()
+                if held and hp:
+                    fh = int(dsz * hp)
+                    fill = pygame.Rect(drect.x, drect.bottom - fh, dsz, fh)
+                    pygame.draw.rect(screen, STOP, fill, border_radius=12)
                 pygame.draw.rect(screen, STOP, drect, width=2, border_radius=12)
-                x = f_big.render("✕", True, STOP)
-                screen.blit(x, (drect.centerx - x.get_width() // 2,
-                                drect.centery - x.get_height() // 2))
+                _draw_cross(screen, drect, FG if held else STOP, 4)
                 del_rects.append((drect, slot))
 
         # firmware log panel (the in-app serial monitor) — fills the area below the tiles
@@ -128,26 +176,67 @@ def run(app):
         if app.forwarding:
             bar = pygame.Rect(0, H - int(H * 0.1), W, int(H * 0.1))
             pygame.draw.rect(screen, STOP, bar)
-            msg = f_big.render("FORWARDING — Deck controls detached · tap anywhere to stop", True, (255, 255, 255))
+            hp = hold_progress()
+            if hold is not None and hold["kind"] == "stop" and hp:
+                # fill the banner left->right as the stop hold progresses; lighten so the fill reads as a meter
+                pygame.draw.rect(screen, (255, 150, 150),
+                                 pygame.Rect(bar.x, bar.y, int(bar.width * hp), bar.height))
+                left = max(0.0, HOLD_SECS * (1.0 - hp))
+                msg = f_big.render("KEEP HOLDING TO STOP - %.0fs" % (left + 0.99),
+                                   True, (255, 255, 255))
+            else:
+                msg = f_big.render("FORWARDING - Deck controls detached - hold to stop",
+                                   True, (255, 255, 255))
             screen.blit(msg, (W // 2 - msg.get_width() // 2, bar.y + bar.height // 2 - 22))
         pygame.display.flip()
 
-    def handle_tap(pos):
-        # Taps only ENQUEUE intents; the IO thread runs the actual grab()/send() so they never race the
-        # forwarding thread's serial/libusb access.
+    def handle_down(pos):
+        # Press handler. CONNECT (toggle a live tile) fires immediately -- it's cheap and reversible. The
+        # destructive actions (STOP a forward, REMOVE a bond) instead ARM a hold; they only fire from
+        # check_hold() once HOLD_SECS elapses, so an errant tap can't trigger them. Actions still only
+        # ENQUEUE intents so they never race the IO thread's serial/libusb access.
+        nonlocal hold
+        if hold is not None:
+            return  # a hold is already in progress; ignore re-entrant/duplicate down events
         if app.forwarding:
-            app.request_stop()
+            # hold anywhere on the screen to stop forwarding
+            hold = {"kind": "stop", "slot": None, "start": time.monotonic()}
             return
-        # the remove-bond ✕ takes priority over the tile body it sits on
+        # the remove-bond ✕ takes priority over the tile body it sits on -- hold it to unpair
         for drect, slot in del_rects:
             if drect.collidepoint(pos):
-                app.request_remove(slot)
+                hold = {"kind": "unpair", "slot": slot, "start": time.monotonic()}
                 return
+        # live tile: connecting/forwarding stays a single tap
         for rect, slot, tappable in tile_rects:
             if rect.collidepoint(pos) and tappable:
                 app.request_toggle(slot)
                 return
 
+    def handle_up():
+        # Lifting (or a stray up) before HOLD_SECS cancels the pending destructive action.
+        nonlocal hold
+        hold = None
+
+    def check_hold():
+        # Fire the held action once it has been sustained long enough, then disarm. Also cancel a hold whose
+        # target went away (the forward auto-released, or forwarding started so a bond can't be removed).
+        nonlocal hold
+        if hold is None:
+            return
+        if hold["kind"] == "stop" and not app.forwarding:
+            hold = None
+            return
+        if hold["kind"] == "unpair" and app.forwarding:
+            hold = None
+            return
+        if time.monotonic() - hold["start"] >= HOLD_SECS:
+            if hold["kind"] == "stop":
+                app.request_stop()
+            elif hold["kind"] == "unpair":
+                app.request_remove(hold["slot"])
+            hold = None
+
     running = True
     while running:
         for ev in pygame.event.get():
@@ -156,9 +245,13 @@ def run(app):
             elif ev.type == pygame.KEYDOWN and ev.key in (pygame.K_ESCAPE, pygame.K_q):
                 running = False
             elif ev.type == pygame.MOUSEBUTTONDOWN:
-                handle_tap(ev.pos)
+                handle_down(ev.pos)
             elif ev.type == pygame.FINGERDOWN:
-                handle_tap((int(ev.x * W), int(ev.y * H)))
+                handle_down((int(ev.x * W), int(ev.y * H)))
+            elif ev.type in (pygame.MOUSEBUTTONUP, pygame.FINGERUP):
+                handle_up()
+
+        check_hold()  # promote a sustained hold into the actual stop/unpair intent
 
         # Render only -- serial I/O + input forwarding run on app._io_loop (a separate thread) so the
         # vsync'd flip() below can't throttle forwarding to 60Hz. A bad draw must never kill the app.

+ 35 - 9
docs/BUILD_AND_DEPLOY.md

@@ -49,24 +49,48 @@ arduino-cli core install adafruit:nrf52 --additional-urls https://adafruit.githu
 From the repository root:
 
 ```bash
-arduino-cli compile -b adafruit:nrf52:feather52840 --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=4" OpenPuck
+make build
 ```
 
-This sketch requires `CFG_TUD_HID=4` because Steam mode exposes four HID interfaces.
+That's the whole command — the USB flags the firmware needs are baked in, so you don't pass them yourself:
 
-**Build provenance.** `gen_version.sh` writes `OpenPuck/git_version.h` with the current commit's short hash and a dirty flag (1 if the working tree has any tracked change or untracked file). The firmware bakes these in and reports them over WebUSB, so the panel's **Build (git)** field shows exactly which commit is flashed and whether it was a clean checkout — handy for confirming what's on a board. The header is git-ignored; if you skip the script the build still succeeds and the panel shows `unknown`. You can also inject the values directly instead of using the script, e.g. append ``-DOPK_GIT_HASH=\"$(git rev-parse --short=8 HEAD)\" -DOPK_GIT_DIRTY=0`` to `build.extra_flags`.
+- `CFG_TUD_HID=4` — Steam mode exposes four HID interfaces (the Adafruit nRF core defaults to 2).
+- `CFG_TUD_TASK_QUEUE_SZ=64` — a deeper TinyUSB device event queue; the default of 16 can deadlock the firmware's loop under heavy USB traffic and trip the watchdog.
+- `CFG_TUD_VENDOR_TX_BUFSIZE=256` — the WebUSB status blob (~118 B) must fit the vendor TX FIFO in one write; the default 64 is too small and the panel (which drops frames rather than block the loop) would send nothing — a blank dashboard.
 
-## 5. Upload the firmware
+**Overriding the defaults** (only if you need to) — pass them as `make` variables:
 
-### macOS / Linux
+```bash
+make build CFG_TUD_HID=6 CFG_TUD_TASK_QUEUE_SZ=128   # different interface count / queue depth
+make build EXTRA_FLAGS="-DOPK_LOG=1"                  # add your own defines
+make build FQBN=adafruit:nrf52:somethingelse          # a different nRF52840 board
+```
 
-Find the board port:
+**Calling `arduino-cli` directly** instead of `make`? Then you must supply the flags yourself — the build
+`#error`s without them (so a forgotten flag fails loudly instead of shipping a broken/deadlock-prone image):
 
 ```bash
-arduino-cli board list
+arduino-cli compile -b adafruit:nrf52:feather52840 --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=4 -DCFG_TUD_TASK_QUEUE_SZ=64 -DCFG_TUD_VENDOR_TX_BUFSIZE=256" OpenPuck
 ```
 
-Upload:
+## 5. Upload the firmware
+
+The quickest path is `make`. The serial port is a **required argument** (find it with `arduino-cli board list`):
+
+```bash
+make flash /dev/cu.usbmodem1101    # upload the most recent build to that port
+make deploy /dev/cu.usbmodem1101   # build + flash in one step (same build overrides as `make build`)
+```
+
+Use the port for your OS: macOS `/dev/cu.usbmodem*`, Linux `/dev/ttyACM0`, Windows `COM5`.
+
+> **DFU note:** in puck (Steam/Lizard) mode the firmware drops the CDC serial port to free a USB endpoint, so `arduino-cli` can't auto-reset the board into its bootloader. If the upload can't connect, put the board in DFU mode first by **double-tapping RST**, then `make flash <bootloader-port>` (re-check `arduino-cli board list` — the port can change in DFU mode). The drag-and-drop UF2 path in §5b also works.
+
+### Manual upload (without `make`)
+
+Find the board port with `arduino-cli board list`, then:
+
+### macOS / Linux
 
 ```bash
 arduino-cli upload \
@@ -133,9 +157,11 @@ Re-flashing firmware does **not** erase the board's internal LittleFS. The paire
 
   ```bash
   ./gen_version.sh   # recommended: gives the build a distinct git hash (see below)
-  arduino-cli compile -b adafruit:nrf52:feather52840 --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=4 -DOPK_FACTORY_RESET=1" OpenPuck
+  make build-recovery
   ```
 
+  (`make build-recovery` is just `make build` plus `-DOPK_FACTORY_RESET=1`; the usual USB flags are still baked in.)
+
   It is **not** a wipe-every-boot image: after the one-time reset it stamps a tag file with the build's git hash, so subsequent boots skip the wipe and persist normally. Flashing this same image again won't re-wipe (the tag matches); flashing a **different** build (different git hash) re-triggers the one-time reset. For an on-demand wipe at any time, use the WebUSB button or serial `ERASE-ALL` below. Re-pair the controller after a reset.
 - **WebUSB panel (any mode):** open the panel (§8), and in the maintenance card click **⚠ Factory erase**. Confirm the two warning dialogs and type `ERASE` when prompted. The board reformats its filesystem and reboots to factory defaults. This works in every USB mode.
 - **Serial console (CDC):** connect to the board's serial port at 115200 baud and send the line `ERASE-ALL` (exact, all caps). Same effect: reformat + reboot.

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

+ 344 - 81
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">
@@ -89,7 +100,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 -->
@@ -106,35 +117,33 @@
       <div class="statgrid">
         <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 logonly"><div class="k">Polls/s</div><div class="v" id="stPolls">—</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>
         <div class="stat"><div class="k">Build (git)</div><div class="v" id="stBuild">—</div></div>
         <div class="stat"><div class="k">Last reset</div><div class="v" id="stReset">—</div></div>
       </div>
-      <p class="note" style="margin-top:6px"><strong>Reset history</strong> (newest first, persists across power-off): <span id="resetHist" style="font-family:ui-monospace,Menlo,monospace">—</span><br>
-        <em>watchdog</em>/<em>LOCKUP</em> = a fault (loop hang / crash); <em>soft reboot</em> = an intentional reset (mode change); <em>power-on</em> = normal plug-in.</p>
+      <p class="note" style="margin-top:6px"><strong>Reset detail:</strong> <span id="resetHist" style="font-family:ui-monospace,Menlo,monospace">—</span><br>
+        <em>watchdog (hang)</em>/<em>CPU lockup</em>/<em>HARDFAULT</em> = a fault worth reporting (issue #72); <em>reboot</em> = an intentional reset (mode change / config); <em>power-on</em> &amp; <em>pin/replug</em> = normal plug-in.</p>
       <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:10px 0 0"><button id="hapClear">Clear stuck buzz</button>
+      <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>
 
@@ -159,13 +168,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">
@@ -176,6 +207,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>
@@ -184,7 +216,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)"];
 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); }
 
@@ -313,9 +375,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;
@@ -327,42 +391,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);
@@ -437,39 +537,90 @@ function applyBlob(p){
   // global stats
   $("#stRate").textContent = up? f1+" /s" : "—";
   $("#stNew").textContent = up? newps+" /s" : "—";
-  $("#stPolls").textContent = up? polls+" /s" : "—";
+  // Poll TX + RF-fail counts: shown ALWAYS (even when the link reads down) so a wedge is diagnosable --
+  // 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);
+    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") : "—";
-  // reset-cause history (protocol v9): JS p[73]=count, p[74..83]=last 10 causes (newest first).
-  // Persisted in puck flash, so a reboot that happened on a Switch is still readable here after replugging.
-  function resetReasonText(rr){
-    if (rr === 0) return "power-on";
-    const r = [];
-    if (rr & 0x02) r.push("watchdog");      // DOG: loop wedge / hang
-    if (rr & 0x08) r.push("LOCKUP");        // CPU lockup = HardFault escalation (real crash)
-    if (rr & 0x04) r.push("soft reboot");   // SREQ: NVIC_SystemReset (mode change etc.)
-    if (rr & 0x01) r.push("reset pin");
-    return r.length ? r.join("+") : ("0x"+rr.toString(16));
-  }
-  if (p.length > 73) {
-    const n = Math.min(p[73], 10);
-    const hist = [];
-    for (let i=0; i<n; i++) hist.push(p[74+i]);
-    const fault = (rr)=>!!(rr & 0x0A); // watchdog or lockup = a real fault
-    if (n === 0) { $("#stReset").textContent = "—"; }
-    else {
-      const newest = hist[0];
-      const head = fault(newest) ? ('<span class="pill dn">'+resetReasonText(newest)+'</span>') : resetReasonText(newest);
-      $("#stReset").innerHTML = head;
-      // full history list under the tile (newest -> oldest)
-      const items = hist.map((rr,i)=>{
-        const t = resetReasonText(rr);
-        return (i===0?'<strong>':'')+ '#'+(n-i)+' '+(fault(rr)?('<span style="color:#ff6b6b">'+t+'</span>'):t) +(i===0?' (latest)</strong>':'');
-      });
-      const el = $("#resetHist"); if (el) el.innerHTML = items.join(' &nbsp;·&nbsp; ');
+  // last-boot reset cause (protocol v11): a single classified code (firmware classifies it from RESETREAS +
+  // a GPREGRET2 marker) plus the raw RESETREAS. Codes match RR_* in fault_diag.h. Earlier firmware (<v11)
+  // didn't send this -> guard on length and leave the tile blank.
+  const RR_NAMES = ["unknown","power-on","pin/replug","watchdog (hang)","CPU lockup",
+                    "HARDFAULT","reboot","soft reset","wake-from-off"];
+  const RR_FAULT = new Set([3,4,5]); // watchdog / lockup / hardfault = a real crash worth reporting (#72)
+  if (p.length > 109) {
+    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);
+    // 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")
+      + (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();
     }
   }
+
   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
@@ -509,10 +660,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);
 }
@@ -590,9 +833,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()=>{
@@ -615,6 +876,7 @@ $("#factoryErase").onclick=async()=>{
   log("FACTORY ERASE sent — copycat is reformatting and rebooting to defaults. Re-pair the controller, then reconnect.");
 };
 $("#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.)
 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); });
@@ -633,6 +895,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>