Explorar el Código

Merge main (#79, #80) into multiple-controllers

Integrates main's real-puck handshake alignment (#80) into the
multi-controller relay structure.

rf_link.cpp conflict resolved by keeping the per-slot polling model
(cycle gate + doPoll lambda over every warm slot, per-slot g_pollPid[k]/
g_relayPid[k]) and weaving in main's runtime toggles:
  - g_e7announce ('n') gates the E7 awake-announce (both initial and the
    every-32-poll re-assert) -- off by default per the sniff1.json capture
  - g_pollGet ('d') selects bare E3 vs legacy E3+GET-report-0x45 TLV
  - g_e1keepalive ('m') gate auto-merged into the restructured beacon loop

Other files auto-merged (rf_link.h externs, serial_console d/n/m commands,
README, sniffer, CI/release workflows, .gitignore). Compile-checked clean
(150 KB flash).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Jariullah Safi hace 4 meses
padre
commit
0aa176f89d

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

@@ -2,7 +2,8 @@ name: Build
 
 on:
   push:
-  pull_request:
+    branches:
+      - main
 
 jobs:
   compile:
@@ -32,9 +33,33 @@ jobs:
           arduino-cli core update-index
           arduino-cli core install adafruit:nrf52
 
+      - name: Generate version header
+        run: ./gen_version.sh
+
       - name: Compile OpenPuck
         run: |
+          mkdir -p build/openpuck build/cache/openpuck
           arduino-cli compile \
             -b adafruit:nrf52:feather52840 \
+            --clean \
+            --build-path build/cache/openpuck \
+            --output-dir build/openpuck \
             --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=4" \
             OpenPuck
+
+      - name: List build outputs
+        run: |
+          find build/openpuck -maxdepth 1 -type f -print | sort
+
+      - name: Upload build artifacts
+        uses: actions/upload-artifact@v4
+        with:
+          name: openpuck-firmware
+          retention-days: 7
+          path: |
+            build/openpuck/*.uf2
+            build/openpuck/*.bin
+            build/openpuck/*.hex
+            build/openpuck/*.elf
+            build/openpuck/*.map
+            build/openpuck/*.zip

+ 2 - 1
.github/workflows/format.yml

@@ -2,7 +2,8 @@ name: Format
 
 on:
   push:
-  pull_request:
+    branches:
+      - main
 
 jobs:
   clang-format:

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

@@ -0,0 +1,105 @@
+name: Release
+
+on:
+  push:
+    branches:
+      - main
+
+permissions:
+  contents: write
+
+jobs:
+  build-release:
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Check out repository
+        uses: actions/checkout@v4
+
+      - name: Set up Arduino CLI
+        uses: arduino/setup-arduino-cli@v2
+
+      - name: Set up Python
+        uses: actions/setup-python@v5
+        with:
+          python-version: '3.x'
+
+      - name: Install adafruit-nrfutil
+        run: pip install adafruit-nrfutil
+
+      - name: Install Adafruit nRF52 core
+        run: |
+          arduino-cli config init --overwrite
+          arduino-cli config set board_manager.additional_urls https://adafruit.github.io/arduino-board-index/package_adafruit_index.json
+          arduino-cli core update-index
+          arduino-cli core install adafruit:nrf52
+
+      - name: Generate version header
+        run: ./gen_version.sh
+
+      - name: Compile OpenPuck
+        run: |
+          mkdir -p build/openpuck build/cache/openpuck
+          arduino-cli compile \
+            -b adafruit:nrf52:feather52840 \
+            --clean \
+            --build-path build/cache/openpuck \
+            --output-dir build/openpuck \
+            --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=4" \
+            OpenPuck
+
+      - name: Compile recovery build
+        run: |
+          mkdir -p build/openpuck-recovery build/cache/openpuck-recovery
+          arduino-cli compile \
+            -b adafruit:nrf52:feather52840 \
+            --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" \
+            OpenPuck
+
+      - name: Collect artifacts
+        run: |
+          mkdir -p release_assets
+          REF="${GITHUB_REF_NAME}"
+          SAFE_REF="$(printf '%s' "$REF" | tr -c 'A-Za-z0-9._-' '-')"
+          SHA_SHORT="$(git rev-parse --short=8 HEAD)"
+          VERSION="${SAFE_REF}-${SHA_SHORT}"
+
+          find build/openpuck build/openpuck-recovery \
+            -maxdepth 1 -type f \
+            \( -name '*.uf2' -o -name '*.zip' \) \
+            -print | while read -r f; do
+            base="$(basename "$f")"
+            dir="$(basename "$(dirname "$f")")"
+            if [ "$dir" = "openpuck-recovery" ]; then
+              flavor="factory-reset"
+            else
+              flavor="standard"
+            fi
+            cp "$f" "release_assets/OpenPuck-${VERSION}-${flavor}-${base}"
+          done
+          test -n "$(find release_assets -maxdepth 1 -type f -print -quit)"
+          ls -la release_assets
+
+      - name: Create GitHub Release
+        uses: softprops/action-gh-release@v2
+        with:
+          tag_name: ${{ format('build-{0}', github.sha) }}
+          target_commitish: ${{ github.sha }}
+          name: ${{ format('Build {0}', github.sha) }}
+          prerelease: true
+          files: release_assets/*
+          body: |
+            OpenPuck Firmware Release
+
+            Build: ${{ github.sha }}
+            Ref: ${{ github.ref_name }}
+
+            ### Files included:
+
+            - `.zip` files for Adafruit DFU tool
+            - `.uf2` files for drag-and-drop flashing, if generated by the board core
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

+ 2 - 0
.gitignore

@@ -3,6 +3,8 @@
 *.log
 *.json
 tmp/
+build/
+release_assets/
 # pairtui helper binary (build from scmd.c: clang -framework IOKit -framework CoreFoundation -o scmd scmd.c)
 pairtui/scmd
 __pycache__/

+ 48 - 10
OpenPuck/rf_link.cpp

@@ -20,6 +20,23 @@ uint8_t g_connLen = 0x08;
 uint8_t g_getParam = 0x00;
 // cycling the ESB PID drains the controller's report queue (~400 new/s vs ~60 with a fixed PID). 'e<n>' selects.
 uint8_t g_e3mode = 1;
+
+// ---- real-puck alignment (sniff1.json: a bonded controller RECONNECTING) ----
+// The live air capture of a real puck<->controller reconnect shows the controller streams 0xF1 input in
+// response to a BARE 0xE3 poll (1-byte payload, just the opcode) with NO 0xE7 awake-announce and NO
+// GET-report-0x45 sub-TLV -- 1857 of the puck's 2003 polls were bare E3, and the very first session frame
+// (a bare E3) was answered by F1 immediately. That contradicts the earlier RE "recipe" (rf_link.h) which
+// assumed E7 + GET-0x45 were required. So these now default to the real-puck behavior; flip them at runtime
+// ('d'/'n' console cmds) to fall back to the legacy GET/E7 path for an A/B comparison on hardware.
+bool g_pollGet = false; // false = bare E3 poll (real puck); true = append GET-report-0x45 TLV (legacy)
+bool g_e7announce = false; // false = no E7 awake-announce (real puck); true = announce host-awake (legacy)
+// Session-channel E1 host-frame keepalive. The real puck sends NO E1 on its session channel (the bonded
+// controller already knows the per-bond address and just resumes). OpenPuck still needs E1 because it runs
+// the SHARED "ibex" address, not the (un-reversed) per-bond address -- E1 is how the controller learns this
+// puck's session base/prefix/channel. So this defaults ON; turn it off ('m') to test the real-puck "no
+// session E1" model once per-bond addressing exists. Discovery on ch2 is separate and always runs.
+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;
@@ -642,15 +659,23 @@ static void rfConnStep()
 	}
 
 	uint8_t ch = g_sessCh;
+	// announce HOST AWAKE: E7 00 00, a few times. The real puck does NOT do this (the controller streams F1 to a
+	// bare E3 with no E7) -- skipped unless g_e7announce ('n') re-enables the legacy handshake.
 	if (g_connSt == 0) {
 		g_curSlot = firstUsed;
-		uint8_t p[3] = { 0xE7, 0x00, g_e7b };
-		rfConnTx(ch, 0x01, p, 3);
-		if (++g_connStep >= 4) {
+		if (g_e7announce) {
+			uint8_t p[3] = { 0xE7, 0x00, g_e7b };
+			rfConnTx(ch, 0x01, p, 3);
+			if (++g_connStep >= 4) {
+				g_connSt = 1;
+				g_connStep = 0;
+				Serial.println(
+					"# CONN: awake announced -> polling");
+			}
+		} else {
+			// real-puck path: straight to the bare-E3 poll loop, no E7
 			g_connSt = 1;
 			g_connStep = 0;
-			Serial.println(
-				"# CONN: awake announced -> polling GET report 0x45");
 		}
 		return;
 	}
@@ -682,7 +707,8 @@ static void rfConnStep()
 	auto doPoll = [&](int k) {
 		g_slotLastAttemptMs[k] = nowMs;
 		g_curSlot = k;
-		if ((g_connPoll & 0x1F) == 0) {
+		// re-assert awake/version every 32 polls (legacy; real puck never sends E7 -- gated by g_e7announce)
+		if (g_e7announce && (g_connPoll & 0x1F) == 0) {
 			uint8_t pa[3] = { 0xE7, 0x00, g_e7b };
 			rfConnTx(ch, 0x01, pa, 3, 600);
 		}
@@ -692,13 +718,22 @@ static void rfConnStep()
 				(uint8_t)((((g_relayPid[k]++) & 3) << 1) | 1);
 			rfConnFlushRelay(ch, rs1);
 		}
-		uint8_t p[5] = { 0xE3, 0x02, 0x01, 0x45, g_getParam };
+		// per-slot PID cycle so each bonded controller's polls stay distinct
 		uint8_t pidv = g_pollPid[k]++;
 		uint8_t s1 = (g_e3mode == 1) ?
 				     (uint8_t)(((pidv & 3) << 1) | 1) :
 			     (g_e3mode == 2) ? (uint8_t)((pidv & 3) << 1) :
 					       0x07;
-		uint8_t rx = rfConnTx(ch, s1, p, 5);
+		uint8_t rx;
+		if (g_pollGet) {
+			// legacy: E3 + TLV [len=02][subtype=01 GET][id=0x45][param]
+			uint8_t p[5] = { 0xE3, 0x02, 0x01, 0x45, g_getParam };
+			rx = rfConnTx(ch, s1, p, 5);
+		} else {
+			// real puck: BARE E3 (just the opcode) -- the controller streams F1 to any E3 ack
+			uint8_t p[1] = { 0xE3 };
+			rx = rfConnTx(ch, s1, p, 1);
+		}
 		if (rx)
 			g_chF1[0]++;
 		g_connPoll++;
@@ -737,8 +772,11 @@ void rfLinkTask()
 				break;
 			}
 		// session keepalive on the clean channel: every loop while connecting (fast), every 25ms once connected
-		// (every-loop beaconing also hammers the session ch and steals reply slots from the poll)
-		if (millis() - g_lastSessBeacon >= (connNow ? 25u : 0u)) {
+		// (every-loop beaconing also hammers the session ch and steals reply slots from the poll). The real puck
+		// sends NO E1 on its session channel; gated by g_e1keepalive ('m') so this can be A/B'd on hardware --
+		// but it stays ON by default because OpenPuck's shared-address model relies on E1 to advertise the session.
+		if (g_e1keepalive &&
+		    millis() - g_lastSessBeacon >= (connNow ? 25u : 0u)) {
 			g_lastSessBeacon = millis();
 			g_rfCh = g_sessCh;
 			for (int s = 0; s < NSLOT; s++)

+ 13 - 3
OpenPuck/rf_link.h

@@ -7,9 +7,14 @@
 // mode-switch chord, USB remote-wakeup on a Steam short-press, adaptive channel hopping (QoS), and the
 // host-frame keepalive beacons. RE/calibration scaffolding lives separately in rf_diag.cpp.
 //
-// RECIPE (IBEX disasm @0x18d80): the controller streams 0xF1 ONLY when (a) connstate==3 [host frame E1
-// establishes it], (b) we mark host-awake via E7 [00][00], and (c) each E3 poll carries a GET-report-0x45
-// sub-TLV. F1 reply = type 0xF1 then TLV type4(analog/buttons)+type6(per-module gyro/accel/sticks).
+// RECIPE: a live air capture of a real puck<->controller RECONNECT (sniff1.json) shows the controller streams
+// 0xF1 input in response to a BARE 0xE3 poll -- with NO 0xE7 awake-announce and NO GET-report-0x45 sub-TLV.
+// So the only thing OpenPuck genuinely needs to (re)establish the stream is connstate==3, which the host frame
+// (0xE1) sets up; once connected, bare E3 polls keep F1 flowing. (The earlier RE hypothesis that E7+GET-0x45
+// were required is contradicted by the capture -- see g_pollGet/g_e7announce.) The real puck also sends no E1
+// on its session channel (its bonded controller resumes on the known per-bond address); OpenPuck still beacons
+// E1 only because it runs the SHARED "ibex" address, not the per-bond one (g_e1keepalive).
+// F1 reply = type 0xF1 then TLV type4(analog/buttons)+type6(per-module gyro/accel/sticks).
 #pragma once
 #include <stdint.h>
 #include "bonds.h" // NSLOT
@@ -28,6 +33,11 @@ extern uint8_t g_getParam; // GET report 0x45 param byte
 // E3-poll PID/S1 mode (0=fixed07, 1=cyclePID+noack1, 2=cyclePID+noack0)
 extern uint8_t g_e3mode;
 
+// Real-puck alignment toggles (default to the captured real-puck behavior; flip on the CDC console to A/B):
+extern bool g_pollGet; // 'd': false=bare E3 poll (real puck), true=append GET-report-0x45 TLV (legacy)
+extern bool g_e7announce; // 'n': false=no E7 awake-announce (real puck), true=announce host-awake (legacy)
+extern bool g_e1keepalive; // 'm': session-channel E1 host-frame keepalive (ON by default; needed for shared addr)
+
 // 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)

+ 23 - 3
OpenPuck/serial_console.cpp

@@ -175,9 +175,13 @@ void serialConsolePoll()
 					g_connF1 = 0;
 				}
 				Serial.printf(
-					"# CONN mode %s: E7-awake[00 00] -> E3+GET-report-0x45 poll on ch%u (param=%02X). F1 seen=%lu\n",
-					g_connOn ? "ON" : "off", g_rfCh,
-					g_getParam, (unsigned long)g_connF1);
+					"# CONN mode %s: %s%s poll on ch%u (param=%02X). F1 seen=%lu\n",
+					g_connOn ? "ON" : "off",
+					g_e7announce ? "E7-awake[00 00] -> " : "",
+					g_pollGet ? "E3+GET-report-0x45" :
+						    "bare E3",
+					g_rfCh, g_getParam,
+					(unsigned long)g_connF1);
 			} else if (line[0] == 'q') {
 				g_getParam = g_getParam ? 0x00 : 0x2D;
 				Serial.printf("# GET-0x45 param=%02X\n",
@@ -357,6 +361,22 @@ void serialConsolePoll()
 				Serial.printf(
 					"# E3 poll PID mode=%u (0=fixed07, 1=cyclePID+noack1, 2=cyclePID+noack0) - watch new=/s\n",
 					g_e3mode);
+			} else if (line[0] == 'd') {
+				g_pollGet = !g_pollGet;
+				Serial.printf(
+					"# poll = %s (real puck sends BARE E3) - watch F1=/s new=/s\n",
+					g_pollGet ? "E3 + GET-report-0x45 TLV (legacy)" :
+						    "bare E3");
+			} else if (line[0] == 'n') {
+				g_e7announce = !g_e7announce;
+				Serial.printf(
+					"# E7 awake-announce %s (real puck never sends E7) - watch F1=/s\n",
+					g_e7announce ? "ON (legacy)" : "off");
+			} else if (line[0] == 'm') {
+				g_e1keepalive = !g_e1keepalive;
+				Serial.printf(
+					"# session-channel E1 keepalive %s (real puck sends none; needed for shared addr) - watch F1=/s\n",
+					g_e1keepalive ? "ON" : "off");
 			} else if (line[0] == 't') {
 				// inject n test haptics (output 0x82 [01 01 F7]) over the relay
 				uint8_t n = line[1] ? strtoul(line + 1, 0, 10) :

+ 13 - 2
README.md

@@ -48,17 +48,28 @@ the biggest one being that you can use inputs when a high privilege application
 Additionally it has some shortcuts that might be useful: Steam + L5/R5 will do volume control, RB is Alt so you can RB + Select to move through windows, LB is Ctrl 
 and Steam + L4 ls Ctrl + Alt + Delete.
 
-# How to use it
+# How to install/use it
 You will need to flash OpenPuck.ino onto a compatible microcontroller. See [build instructions document](./docs/BUILD_AND_DEPLOY.md) for more details.
 
+# Pairing
+Both OpenPuck and the controller need to be hooked up to the same machine with a data capable USB C cable at the same time and Steam must be running. Steam should in most cases automatically pop up a menu to pair the controller. If not, you can go to Settings -> Controllers and press "Add Controller".
+
+If you want to use the second slot for OpenPuck, you'll need to first turn the controller off and then hold LB + A + Steam to turn the controller back on (the chime when the controller comes on in this mode sounds different). Then connect the USB C cable and continue to pair.
+
+Switching slots requires turning the controller off (Steam + Y if steam is running, Steam + Y held for 2 seconds if Steam isn't running or if you're in a different mode, or just hold the Steam button for an eternity until the controller shuts off) and then you hold RB for slot 1 and LB for slot 2 while holding A and Steam to turn the controller back on.
+
 # Configuration
 A webusb based configuration UI is available [here](https://safijari.github.io/openpuck/). It allows Switching the mode manually and changing the back button mapping for other modes among other things. This will likely only work in Chrome and Edge and needs the pro micro to be connected via USB to the same computer for it to function. Note that it might not work in all modes on all machines but should always work in the Steam Controller mode (which you can revert to with back-4 + A).
 
+# 3D Printed Cases
+- [jaki-gh](https://github.com/jaki-gh) has contributed a 3D printable housing with OpenPuck written on it alongside a Steam logo. You can find that [here](https://www.thingiverse.com/thing:7371668).
+- [StonnedModder](https://www.printables.com/model/1760684-openpuck-promicro-nrf52840-case) built a case meant to accomodate a USB C to USB A adapter which you can find [here](https://www.printables.com/model/1760684-openpuck-promicro-nrf52840-case).
+- Another plain case for these pro micros can be found [here](https://www.printables.com/model/1285346-pro-micro-nicenano-nrf52840-dongle-case/collections).
+
 # Future work
 - Find a way to make Xinput mode and mouse work together on all platforms
 - Multi-controller support is now working in Steam/Lizard mode (up to 4 controllers, see the `U` CDC command for a per-slot status dump). Non-puck modes (Xbox, Switch, PS5, DS4) remain single-controller by design — the user can pair one controller and use the appropriate mode.
 - Design the charging portion (and make it short proof)
-- Design a 3D printable housing
 
 # 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.

+ 48 - 14
docs/sniffer.html

@@ -44,6 +44,7 @@
   <span style="flex:1"></span>
   <button id="reacq" disabled>Re-acquire</button>
   <button id="survey" disabled>Survey ch2</button>
+  <button id="forget" disabled>Forget bonds</button>
   <label class="note">lock ibex <input type="text" id="tgtIbex" placeholder="EF7171B4" style="width:84px;background:#0d1117;color:var(--fg);border:1px solid var(--line);border-radius:5px;padding:4px;font:inherit"></label>
   <button id="setTgt" disabled>Lock</button>
   <label class="note">pin ch <input type="number" id="pinCh" min="1" max="80" placeholder="auto"></label>
@@ -60,6 +61,8 @@
   <span>P→C: <b id="npc">0</b></span>
   <span>C→P: <b id="ncp">0</b></span>
   <span>crc-bad: <b id="nbad">0</b></span>
+  <span>drops: <b id="ndrop">0</b></span>
+  <span>bond: <b id="nbond">none yet</b></span>
   <span>shown: <b id="nshown">0</b></span>
 </div>
 <div class="stats" style="border-top:none;padding-top:0">
@@ -101,7 +104,7 @@ async function connect(){
     await dev.claimInterface(ifNum);
     await dev.controlTransferOut({requestType:"class",recipient:"interface",request:0x22,value:0x01,index:ifNum});
     $("#state").textContent="connected"; $("#state").className="pill up"; $("#connect").textContent="Reconnect";
-    for(const id of ["cap","clear","dl","dltxt","reacq","survey","setTgt","setch","setsess"]) $("#"+id).disabled=false;
+    for(const id of ["cap","clear","dl","dltxt","reacq","survey","forget","setTgt","setch","setsess"]) $("#"+id).disabled=false;
     navigator.usb.addEventListener("disconnect",e=>{ if(e.device===dev) onGone(); });
     log(`connected — iface ${ifNum} epIn ${epIn} epOut ${epOut} — waiting for stream…`);
     rxBytes=0; gotData=false;
@@ -117,17 +120,21 @@ function append(buf){ const m=new Uint8Array(acc.length+buf.length); m.set(acc);
 function parse(){
   let i=0;
   while(i+2<=acc.length){
-    if(acc[i]===0xC1 && acc[i+1]===0xDE){            // status (19 bytes)
-      if(i+19>acc.length) break;
+    if(acc[i]===0xC1 && acc[i+1]===0xDE){            // status (22 bytes)
+      if(i+22>acc.length) break;
       acqState=acc[i+2];
       const ch=acc[i+3], b=acc.slice(i+4,i+8), pfx=acc[i+8];
       const advCh=acc[i+12], ab=acc.slice(i+13,i+17), apfx=acc[i+17], lastMatch=acc[i+18];
+      const drops=acc[i+19]|(acc[i+20]<<8);          // frames lost to a full ring on the device (0 = lossless)
+      const bondInfo=acc[i+21], camped=!!(bondInfo&0x80), nbonds=(bondInfo>>4)&0x7, bchans=bondInfo&0xF;
       sessTxt=`ch ${ch}  addr ${hex(b)}/${pfx.toString(16).padStart(2,"0")}  (last pipe ${lastMatch})`;
-      $("#acq").textContent=acqState===1?"CAPTURE (session locked)":"ACQUIRE (scanning ibex/ch2)";
+      $("#acq").textContent=acqState===1?(camped?"CAPTURE (auto-camped on learned bond)":"CAPTURE (session locked)"):"ACQUIRE (scanning ibex/ch2)";
       $("#acq").style.color=acqState===1?"#3fb950":"#d29922";
       $("#sess").textContent=sessTxt;
       $("#adv").textContent = advCh ? `ch ${advCh}  addr ${hex(ab)}/${apfx.toString(16).padStart(2,"0")}` : "— (no E1 yet)";
-      i+=19; continue;
+      const de=$("#ndrop"); if(de){ de.textContent=drops; de.style.color = drops? "#f85149" : "#3fb950"; }
+      const be=$("#nbond"); if(be){ be.textContent = nbonds? `${nbonds} learned${camped?` · camped (${bchans} ch)`:""}` : "none yet"; be.style.color = camped? "#3fb950" : (nbonds?"#d29922":"#8b949e"); }
+      i+=22; continue;
     }
     if(acc[i]===0xC0 && acc[i+1]===0xDE){            // packet
       if(i+3>acc.length) break;
@@ -144,19 +151,39 @@ function parse(){
   acc=acc.slice(i);
 }
 let allFrames=[], seqCtr=0, nShown=0;          // ring of recent frames so filters can re-render; seq = stable # column
+let lastShownSeq=0, dirty=false;               // throttled-render bookkeeping (see renderTick)
 const FRAMEBUF=8000;
+// Hot path (runs per frame at the full capture rate, inside the USB read loop): keep it CHEAP so transferIn is
+// never blocked -- a blocked read backs up the device FIFO, stalls its drain, and makes the on-device ring drop
+// (the `drops` counter). So we store the raw bytes and defer the expensive hex-string formatting (ensureHex) and
+// all DOM work to a throttled renderTick that only touches frames actually displayed/exported.
 function onPacket(t_us,ch,crc,rssi,match,raw){
   // raw = [LENGTH, S1, payload...]; opcode = raw[2]; dir from opcode high nibble
   const op=raw.length>2?raw[2]:0;
   const dir=(op&0xF0)===0xE0?"P→C":(op&0xF0)===0xF0?"C→P":"?";
   if(dir==="P→C")nPC++; else if(dir==="C→P")nCP++; if(!crc)nBad++;
-  const payload=raw.slice(2);                        // from opcode onward (what we care about)
-  const ent={seq:++seqCtr,t_us,ms:(t_us/1000),ch,pipe:match,dir,op:op.toString(16).padStart(2,"0"),crc,rssi:-rssi,len:raw.length>0?raw[0]:0,raw:hex(raw),payload:hex(payload)};
+  const ent={seq:++seqCtr,t_us,ms:(t_us/1000),ch,pipe:match,dir,op:op.toString(16).padStart(2,"0"),crc,rssi:-rssi,len:raw.length>0?raw[0]:0,bytes:raw};
   if(capturing) rec.push(ent);
-  allFrames.push(ent); if(allFrames.length>FRAMEBUF) allFrames.shift();
-  if(passesFilter(ent)) addRow(ent);
-  $("#npc").textContent=nPC; $("#ncp").textContent=nCP; $("#nbad").textContent=nBad; $("#nrec").textContent=rec.length;
+  allFrames.push(ent);
+  if(allFrames.length>FRAMEBUF*2) allFrames.splice(0,allFrames.length-FRAMEBUF); // bulk trim, amortized O(1) (was per-frame shift = O(n))
+  dirty=true;
 }
+// Lazily fill in the hex strings only when a frame is rendered, filtered-by-hex, or exported -- never in the hot path.
+function ensureHex(e){ if(e.raw===undefined){ e.raw=hex(e.bytes); e.payload=hex(e.bytes.subarray(2)); } return e; }
+// Render the table on a display-rate tick instead of per frame: append only the frames newer than the last shown,
+// so DOM work is bounded by the screen refresh, not the 500+/s capture rate.
+function renderTick(){
+  if(dirty){
+    dirty=false;
+    let i=allFrames.length-1; const fresh=[];
+    while(i>=0 && allFrames[i].seq>lastShownSeq){ fresh.push(allFrames[i]); i--; }
+    for(let k=fresh.length-1;k>=0;k--){ const e=fresh[k]; if(passesFilter(e)) addRow(e); }
+    if(allFrames.length) lastShownSeq=allFrames[allFrames.length-1].seq;
+    $("#npc").textContent=nPC; $("#ncp").textContent=nCP; $("#nbad").textContent=nBad; $("#nrec").textContent=rec.length;
+  }
+  requestAnimationFrame(renderTick);
+}
+requestAnimationFrame(renderTick);
 const rows=$("#rows");
 function passesFilter(e){
   if($("#fHideRoutine").checked){                    // drop the 99% spam: input replies + bare polls
@@ -169,10 +196,11 @@ function passesFilter(e){
   if(lf==="ne49" && e.len===49) return false;
   if(lf==="gt49" && e.len<=49) return false;
   if(lf==="gt1"  && e.len<=1)  return false;
-  const hx=$("#filter").value.trim().toLowerCase().replace(/ /g,""); if(hx && !e.payload.replace(/ /g,"").includes(hx)) return false;
+  const hx=$("#filter").value.trim().toLowerCase().replace(/ /g,""); if(hx && !ensureHex(e).payload.replace(/ /g,"").includes(hx)) return false;
   return true;
 }
 function addRow(e){
+  ensureHex(e);
   const tr=document.createElement("tr");
   tr.className=(e.dir==="P→C"?"pc":e.dir==="C→P"?"cp":"")+(e.crc?"":" bad");
   tr.innerHTML=`<td>${e.seq}</td><td>${e.ms.toFixed(1)}</td><td class="dir">${e.dir}</td><td>${e.ch}</td><td>${e.pipe}</td><td>${e.op}</td><td>${e.crc?"ok":"BAD"}</td><td>${e.rssi}</td><td>${e.len}</td><td class="hex">${e.payload}</td>`;
@@ -185,6 +213,7 @@ function addRow(e){
 function applyFilters(){                              // re-render visible table from the buffer with current filters
   rows.innerHTML=""; nShown=0;
   for(const e of allFrames.slice(-MAXROWS)) if(passesFilter(e)) addRow(e);
+  lastShownSeq=allFrames.length?allFrames[allFrames.length-1].seq:0; // keep renderTick appending after a manual re-render
 }
 for(const id of ["fHideRoutine","fDir","fOp","fLen","filter"]) $("#"+id).addEventListener("input",applyFilters);
 async function readLoop(){
@@ -211,8 +240,9 @@ $("#cap").onclick=async()=>{
   else{ await cmd(0x02); log(`CAP ENDED — ${rec.length} packets recorded`); }
   $("#nrec").textContent=rec.length;
 };
-$("#clear").onclick=()=>{ rec=[]; allFrames=[]; seqCtr=0; rows.innerHTML=""; nPC=nCP=nBad=nShown=0; for(const id of ["nrec","npc","ncp","nbad","nshown"]) $("#"+id).textContent=0; };
+$("#clear").onclick=()=>{ rec=[]; allFrames=[]; seqCtr=0; lastShownSeq=0; dirty=false; rows.innerHTML=""; nPC=nCP=nBad=nShown=0; for(const id of ["nrec","npc","ncp","nbad","nshown"]) $("#"+id).textContent=0; };
 $("#reacq").onclick=()=>{ $("#pinCh").value=""; cmd(0x03); log("re-acquire requested"); };
+$("#forget").onclick=()=>{ cmd(0x08); log("forgot all learned bonds — will relearn from the next E1"); };
 let surveying=false;
 $("#survey").onclick=()=>{ surveying=!surveying; $("#survey").classList.toggle("active",surveying); cmd(0x06); log(surveying?"SURVEY on — listing every E1 beacon on ch2 (no lock). Each distinct 'op e1' payload = one puck/slot session.":"survey off"); };
 $("#setTgt").onclick=()=>{ const v=$("#tgtIbex").value.trim().replace(/^0x/i,""); if(/^[0-9a-fA-F]{8}$/.test(v)){ const u=parseInt(v,16); cmd(0x07,u&0xff,(u>>8)&0xff,(u>>16)&0xff,(u>>24)&0xff); log("locking ibex_uuid 0x"+v.toUpperCase()+" — only that slot's E1 (then hunting its channels for F1)"); } else log("enter ibex_uuid as 8 hex digits from pairtui, e.g. EF7171B4"); };
@@ -225,9 +255,13 @@ $("#setsess").onclick=()=>{   // "b0 b1 b2 b3 pfx ch" -- base/prefix as hex, cha
   cmd(0x05,b[0],b[1],b[2],b[3],b[4],ch); log(`pinned session addr ${v.slice(0,4).join(" ")}/${v[4]} ch ${ch}`);
 };
 function save(name,text,type){ const a=document.createElement("a"); a.href=URL.createObjectURL(new Blob([text],{type})); a.download=name; a.click(); }
-$("#dl").onclick=()=>save("sniff-"+Date.now()+".json", JSON.stringify(rec,null,1), "application/json");
+$("#dl").onclick=()=>{
+  // materialize hex + drop the raw byte array so the JSON matches the old shape (seq,ms,ch,dir,op,crc,rssi,len,raw,payload)
+  const out=rec.map(e=>{ ensureHex(e); const{bytes,...r}=e; return r; });
+  save("sniff-"+Date.now()+".json", JSON.stringify(out,null,1), "application/json");
+};
 $("#dltxt").onclick=()=>{
-  const lines=rec.map(e=>`${e.ms.toFixed(1)}ms ch${e.ch} ${e.dir} ${e.crc?"":"BAD "}rssi${e.rssi} op=${e.op} | ${e.payload}`);
+  const lines=rec.map(e=>`${e.ms.toFixed(1)}ms ch${e.ch} ${e.dir} ${e.crc?"":"BAD "}rssi${e.rssi} op=${e.op} | ${ensureHex(e).payload}`);
   save("sniff-"+Date.now()+".txt", lines.join("\n"), "text/plain");
 };
 if(!navigator.usb) log("This browser has no WebUSB. Use Chrome/Edge over https or localhost.");

+ 28 - 27
puck_sniffer/README.md

@@ -28,39 +28,38 @@ WebUSB needs a secure context, so **not** a `file://` path. Either:
 
 Click **Connect sniffer**, pick *OpenPuck Sniffer*.
 
-## 3. Capture — the exact sequence (do it in this order)
+## 3. Capture — the exact sequence
 
-Why order matters: the link is Nordic **Gazell** — the on-air address is random per session, and **a *connected*
-slot never beacons on ch2; only a *reconnecting* one does.** So you can't lock onto an already-connected
-controller — you must catch its **reconnect**. The bond's **ibex_uuid is stable** across reconnects (only the
-address changes), so we anchor the lock on that uuid and force a reconnect.
+The link is Nordic **Gazell**. A controller's **bonded on-air address is fixed at pairing and REUSED on every
+reconnect** (reconnect is a silent ESB resume — no fresh address, no guaranteed ch2 re-beacon), and the session
+hops a small **stable** channel map. So the sniffer **learns each bond once** — it persists the session
+(base/prefix/channel) to flash keyed by the bond's **ibex_uuid** and remembers which channels carry its replies —
+and from then on **auto-camps** straight onto it, catching the next clean reconnect with **no ch2 catch and no
+manual pinning**.
 
 ### Setup (once)
 1. **Unplug the copycat** (the OpenPuck dev board) if it's plugged in — it beacons its own `E1`s and pollutes
    ch2. Leave only: the **real Valve puck**, the **controller**, and the **sniffer board**.
-2. Controller connected to the **real puck** (Steam sees it). With `pairtui` (see `../pairtui/`), note the
-   connected slot's **ibex_uuid** (e.g. `EF7171B4`) — it's the bond id, stable across sessions.
+2. With `pairtui` (see `../pairtui/`), note the connected slot's **ibex_uuid** (e.g. `EF7171B4`).
 
-### Arm the sniffer
+### First run — teach it the bond (one time per controller)
 3. Flash the sniffer, open the app (`docs/sniffer.html` over localhost/https — not `file://`), **Connect** →
    *OpenPuck Sniffer*. Confirm **rx** is climbing.
-4. In **lock ibex**, type the uuid (`EF7171B4`), click **Lock**. Status → **ACQUIRE** (waiting on ch2 for *only*
-   that slot's `E1`; ignores other slots and the copycat).
-5. Click **● Start Cap**.
-
-### Trigger (the step that's easy to miss)
-6. **Power-cycle the controller** — hold its power button until it turns **off**, wait ~2 s, turn it back **on**.
-   On reconnect it re-runs ch2 discovery and broadcasts slot 3's `E1` (matching the uuid) with a **fresh** session
-   address → the sniffer matches the uuid, locks that session, and hunts its channels.
-7. Watch the app: link → **CAPTURE (session locked)** and **`C→P` climbs**. Wiggle sticks / trackpad to keep
-   traffic flowing.
-
-### Capture the action
-8. With `C→P` climbing, do the thing in Steam (change brightness / LED, or turn off).
-9. **■ End Cap → Download.**
-
-**Always: Lock the uuid → Start Cap → *then* power-cycle.** The most common failure is the controller silently
-auto-reconnecting *before* the lock is armed, so its `E1` is never caught.
+4. In **lock ibex**, type the uuid (`EF7171B4`), click **Lock**.
+   - If **bond** shows `… camped`, it already knows this controller → skip to step 6.
+   - Otherwise status is **ACQUIRE** (waiting on ch2 for that slot's `E1`).
+5. Click **● Start Cap**, then **power-cycle the controller** (hold power until off, ~2 s, back on). When its `E1`
+   is caught the sniffer locks the session, **persists the bond**, and the **bond** stat shows `1 learned · camped`.
+
+### Every run after — just capture the reconnect
+6. With the bond known, the link goes **CAPTURE (auto-camped on learned bond)** on its own (on plug-in if it's the
+   only bond, or the moment you **Lock** its uuid). **Power-cycle the controller** and its reconnect is captured
+   from the first packets — `C→P` climbs with no further steps.
+7. Wiggle sticks / trackpad to keep traffic flowing; do the thing in Steam (change brightness / LED, or turn off).
+8. **■ End Cap → Download.**
+
+> **Forget bonds** clears the learned store (relearn from the next `E1`). Use it if a controller was re-paired
+> (its bonded address changed). The learned channel set self-corrects as new reply channels are observed.
 
 Direction: `P→C` (opcode `0xE_`, puck→controller — LED/shutoff/brightness commands ride here) vs `C→P` (`0xF_`,
 controller→puck — input + battery/telemetry).
@@ -112,5 +111,7 @@ bypasses auto-acquire entirely, which is the reliable path if the real puck's `E
 
 ## Stream protocol (WebUSB bulk)
 - packet: `C0 DE [N] [t_us:4 LE] [ch] [flags] [rssi] [match] [N raw bytes]`  (flags bit0 = CRC ok; match = RXMATCH pipe; raw = `[LEN][S1][payload]`)
-- status: `C1 DE [state] [curCh] [base:4] [prefix] [cap] [hb:2] [advCh] [advBase:4] [advPfx] [lastMatch]`  (19 B; adv* = session parsed from the last `E1`)
-- commands (bulk OUT): `01` start · `02` stop · `03` re-acquire · `04 <ch>` pin channel · `05 <b0 b1 b2 b3 pfx ch>` pin full session
+- status: `C1 DE [state] [curCh] [base:4] [prefix] [cap] [hb:2] [advCh] [advBase:4] [advPfx] [lastMatch] [drops:2 LE] [bond]`  (22 B; adv* = session parsed from the last `E1`; `drops` = frames lost to a full on-device ring, `0` = lossless capture; `bond` bit7 = camped on a learned bond, bits4-6 = #bonds stored, bits0-3 = #channels learned for the camped bond)
+- commands (bulk OUT): `01` start · `02` stop · `03` re-acquire (un-camp) · `04 <ch>` pin channel · `05 <b0 b1 b2 b3 pfx ch>` pin full session · `06` survey · `07 <u0 u1 u2 u3>` lock ibex_uuid (auto-camps if the bond is already learned) · `08` forget all learned bonds
+
+Learned bonds persist in flash (`/sniffbonds.bin`, keyed by ibex_uuid: session base/prefix/channel + observed reply channels), so after one successful learn the sniffer auto-camps on the controller's stable bonded address and catches its reconnects with no manual steps.

+ 351 - 97
puck_sniffer/puck_sniffer.ino

@@ -16,29 +16,144 @@
 //
 // STREAM (over WebUSB bulk IN), little-endian:
 //   packet:  C0 DE [N] [t_us:4] [ch] [flags] [rssi] [match] [N raw bytes]   flags bit0=CRC ok; match=RXMATCH pipe
-//   status:  C1 DE [state][curCh][b0 b1 b2 b3][prefix][cap][rxLo rxHi][advCh][ab0 ab1 ab2 ab3][apfx][lastMatch]
-//            (state 0=acquire 1=capture; adv* = session params last parsed out of an E1 host frame; 19 bytes)
+//   status:  C1 DE [state][curCh][b0 b1 b2 b3][prefix][cap][rxLo rxHi][advCh][ab0 ab1 ab2 ab3][apfx][lastMatch][dropLo dropHi][bond]
+//            (state 0=acquire 1=capture; adv* = session params last parsed out of an E1 host frame; drop* =
+//             frames lost to a full ring -- 0 = lossless; bond bit7=camped on a learned bond, bits4-6=#bonds
+//             stored, bits0-3=#channels learned for the camped bond; 22 bytes)
 // COMMANDS (WebUSB bulk OUT):
-//   01 start  | 02 stop  | 03 re-acquire (back to ibex/ch2)  | 04 <ch> pin channel (keep current address)
-//   05 <b0 b1 b2 b3 pfx ch> pin a full session: force this exact base/prefix/channel (use after reading a real
-//      E1 by eye -- the surest way to camp on the session when auto-acquire's fixed E1 offsets are wrong)
+//   01 start | 02 stop | 03 re-acquire (back to ibex/ch2, un-camp) | 04 <ch> pin channel (keep current address)
+//   05 <b0 b1 b2 b3 pfx ch> pin a full session: force this exact base/prefix/channel (manual override)
+//   06 survey | 07 <u0 u1 u2 u3> lock ibex_uuid (auto-camps if its bond is already learned) | 08 forget all bonds
+//
+// AUTO-CAMP (no manual pinning): the bonded address is stable across reconnects (RE ground truth), so once we've
+// seen a controller's E1 once we persist its session (base/prefix/channel) to flash keyed by ibex_uuid and learn
+// which channels carry its replies. On a later boot / uuid-lock we camp straight back on it and sweep its learned
+// channels, catching the controller's next clean reconnect with no ch2 catch and no manual pin.
 //
 // Build (Adafruit nRF52 core, same flags as OpenPuck):
 //   arduino-cli compile -b adafruit:nrf52:feather52840 \
 //     --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb}" puck_sniffer
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
+#include <Adafruit_LittleFS.h>
+#include <InternalFileSystem.h>
 #include "radio.h"
+using namespace Adafruit_LittleFS_Namespace;
 
 Adafruit_USBD_WebUSB usb_web;
 
+// Forward-declared so Arduino's auto-generated prototypes (inserted before the first function below) can name it;
+// the full definition follows under "capture ring".
+struct Cap;
+
+// ---- learned-bond store (the "no manual pinning" mechanism) -------------------------------------------------
+// RE ground truth (~/nrf-sniffer/protocol): a controller's bonded on-air address is DERIVED AT PAIRING and REUSED
+// on every reconnect (reconnect = silent ESB resumption, no fresh address, no guaranteed ch2 re-beacon), and the
+// session hops a small STABLE channel map. So once we have observed a bond's address + a working channel ONCE, we
+// can camp straight back on it for all future reconnects -- no ch2 catch, no manual `pin session`. We persist what
+// we learn to flash keyed by the bond's ibex_uuid, so it even survives unplugging the sniffer.
+#define BONDFILE "/sniffbonds.bin"
+#define BOND_MAGIC 0x53424E32u // "SBN2"
+#define NBONDS 4
+#define BOND_NCH 6 // learned channels per bond (the map is ~4; a couple of spares)
+struct Bond {
+	uint8_t uuid[4]; // ibex_uuid key; all-zero = empty slot
+	uint8_t base[4]; // session ESB base
+	uint8_t prefix; // session ESB prefix
+	uint8_t primaryCh; // channel last advertised in the bond's E1
+	uint8_t chans[BOND_NCH]; // channels we've actually seen carry 0xF* replies
+	uint8_t nchans;
+};
+static Bond g_bonds[NBONDS];
+static int g_curBond = -1; // index of the bond we're camped on (-1 = none)
+static bool g_bondDirty = false; // a learn changed the table -> flush to flash in loop()
+static unsigned long g_lastBondSave = 0;
+
+static bool uuidSet(const uint8_t u[4])
+{
+	return (u[0] | u[1] | u[2] | u[3]) != 0;
+}
+static int bondFind(const uint8_t uuid[4])
+{
+	for (int i = 0; i < NBONDS; i++)
+		if (uuidSet(g_bonds[i].uuid) &&
+		    memcmp(g_bonds[i].uuid, uuid, 4) == 0)
+			return i;
+	return -1;
+}
+static int bondFirst()
+{
+	for (int i = 0; i < NBONDS; i++)
+		if (uuidSet(g_bonds[i].uuid))
+			return i;
+	return -1;
+}
+// Find or create the slot for a uuid (evicts slot 0 if the small table is full -- fine for a debug tool).
+static int bondAlloc(const uint8_t uuid[4])
+{
+	int i = bondFind(uuid);
+	if (i >= 0)
+		return i;
+	for (int k = 0; k < NBONDS; k++)
+		if (!uuidSet(g_bonds[k].uuid)) {
+			memset(&g_bonds[k], 0, sizeof(Bond));
+			memcpy(g_bonds[k].uuid, uuid, 4);
+			return k;
+		}
+	memset(&g_bonds[0], 0, sizeof(Bond));
+	memcpy(g_bonds[0].uuid, uuid, 4);
+	return 0;
+}
+// Record that channel `ch` carried a real (CRC-ok) controller reply for this bond. One known-good channel is
+// already enough to re-catch a reconnect (the controller hops the whole map, so it WILL transmit on it); learning
+// the few that appear just makes the re-acquire sweep tighter.
+static void bondLearnChan(int idx, uint8_t ch)
+{
+	if (idx < 0 || ch < 1 || ch > 80)
+		return;
+	Bond &b = g_bonds[idx];
+	for (int i = 0; i < b.nchans; i++)
+		if (b.chans[i] == ch)
+			return;
+	if (b.nchans < BOND_NCH) {
+		b.chans[b.nchans++] = ch;
+		g_bondDirty = true;
+	}
+}
+static void bondsLoad()
+{
+	memset(g_bonds, 0, sizeof g_bonds);
+	File f(InternalFS);
+	if (f.open(BONDFILE, FILE_O_READ)) {
+		uint32_t magic = 0;
+		if (f.read((uint8_t *)&magic, 4) == 4 && magic == BOND_MAGIC)
+			f.read((uint8_t *)g_bonds, sizeof g_bonds);
+		f.close();
+	}
+}
+// Flash write blocks for tens of ms -> ONLY ever call from loop() (never the RADIO ISR). The ring absorbs the
+// frames captured during the write.
+static void bondsSave()
+{
+	InternalFS.remove(BONDFILE);
+	File f(InternalFS);
+	if (f.open(BONDFILE, FILE_O_WRITE)) {
+		uint32_t magic = BOND_MAGIC;
+		f.write((uint8_t *)&magic, 4);
+		f.write((uint8_t *)g_bonds, sizeof g_bonds);
+		f.close();
+	}
+	g_bondDirty = false;
+	g_lastBondSave = millis();
+}
+
 // ---- capture ring (single producer + consumer, both loop context -> no locking) ----
 struct Cap {
 	uint32_t t;
 	uint8_t ch, flags, rssi, match, n;
 	uint8_t b[96];
 };
-#define RINGN 512
+#define RINGN 1024 // ~2 s of headroom at 500 fps so a burst rides out a brief host hiccup without dropping
 static Cap g_ring[RINGN];
 static volatile uint16_t g_head = 0, g_tail = 0;
 static inline uint16_t rnext(uint16_t i)
@@ -51,8 +166,10 @@ static void tuneDiscovery();
 static void tuneSession();
 
 static bool g_streaming = false; // app sent START -> emit packet frames
-static uint8_t g_state = 0; // 0=ACQUIRE (ibex/ch2), 1=CAPTURE (session)
-static uint8_t g_curCh = 2;
+// g_state/g_curCh are written by loop() (retune) and READ by the RADIO END ISR (which stamps each captured frame
+// with the current channel/state) -> volatile so the ISR never sees a cached value.
+static volatile uint8_t g_state = 0; // 0=ACQUIRE (ibex/ch2), 1=CAPTURE (session)
+static volatile uint8_t g_curCh = 2;
 static uint8_t g_sBase[4] = { 0x69, 0x62, 0x65, 0x78 };
 static uint8_t g_sPrefix = 0x10;
 static uint8_t g_forceCh =
@@ -67,9 +184,12 @@ static bool g_survey = false;
 // E1 per bonded slot and we must pick the CONNECTED controller's. Read its uuid from pairtui (e.g. 0xEF7171B4).
 static bool g_haveTarget = false;
 static uint8_t g_targetIbex[4] = { 0, 0, 0, 0 };
-static unsigned long g_lastRx = 0; // for stall -> sweep/re-acquire
-static uint8_t g_lastMatch =
+static volatile unsigned long g_lastRx = 0; // for stall -> sweep/re-acquire
+static volatile uint8_t g_lastMatch =
 	0; // RXMATCH pipe of the most recent frame (diagnostic)
+// Frames lost to a full ring (producer = RADIO ISR outran the loop's USB drain). Incremented in the ISR, surfaced
+// in the status frame so capture completeness is MEASURED rather than silently assumed. Lossless = this stays 0.
+static volatile uint32_t g_drops = 0;
 
 // What the last E1 host frame ADVERTISED (parsed by the assumed offsets). Reported in status EVEN IF we don't
 // lock, so the panel can show whether those offsets are sane (channel in range, address non-degenerate) -- the
@@ -80,7 +200,8 @@ static uint8_t g_advCh = 0, g_advBase[4] = { 0, 0, 0, 0 }, g_advPfx = 0;
 // strands us on ch2 seeing only E1/E2), step through clean candidate channels KEEPING the learned address --
 // this follows a QoS channel hop. Only after a couple of fully-dry sweeps do we fall back to discovery.
 static const uint8_t SWEEP_CH[] = { 18, 46, 76, 22, 68, 2, 80, 52, 55, 56, 57 };
-static uint8_t g_sweepIdx = 0, g_sweepDry = 0;
+static uint8_t g_sweepIdx = 0;
+static volatile uint8_t g_sweepDry = 0; // reset in the RADIO ISR on any session frame
 
 // Gazell session-hunt: the puck's 5Hz E1/E2 keepalive trickles in on the primary channel even when the
 // controller isn't being actively polled, so "no packets" never fires the sweep. The ACTIVE session (E3 poll +
@@ -89,7 +210,9 @@ static uint8_t g_sweepIdx = 0, g_sweepDry = 0;
 // replies appear, then PARK there. g_lastFrx = last controller (0xF*) frame; g_primaryCh = E1-advertised channel.
 static const uint8_t HUNT_CH[] = { 2, 80, 18, 46, 76, 22, 68, 52 };
 static uint8_t g_primaryCh = 78, g_huntIdx = 0;
-static unsigned long g_lastFrx = 0, g_huntMs = 0;
+// g_lastFrx is set in the RADIO ISR (on a 0xF* reply) and read by the hunt logic in loop() -> volatile.
+static volatile unsigned long g_lastFrx = 0;
+static unsigned long g_huntMs = 0;
 #define HUNT_DWELL_MS 350u
 #define HUNT_NOFRX_MS 1000u
 
@@ -110,6 +233,27 @@ static void tuneSession()
 	rfSetAddrMulti(g_sBase, g_sPrefix);
 	rxArmInit();
 }
+// Camp directly on a learned bond (no ch2 discovery): adopt its stored address and start listening on its
+// best-known channel. Because the bonded address is stable, this is all that's needed to catch the controller's
+// next clean reconnect -- the hunt sweep below walks the bond's learned channels until its 0xF* replies appear.
+static void campBond(int idx)
+{
+	if (idx < 0)
+		return;
+	Bond &b = g_bonds[idx];
+	g_curBond = idx;
+	memcpy(g_sBase, b.base, 4);
+	g_sPrefix = b.prefix;
+	if (b.primaryCh >= 1 && b.primaryCh <= 80)
+		g_primaryCh = b.primaryCh;
+	// prefer a channel we've actually seen replies on, else the advertised primary
+	g_curCh = (b.nchans && b.chans[0] >= 1 && b.chans[0] <= 80) ?
+			  b.chans[0] :
+			  g_primaryCh;
+	g_huntIdx = 0;
+	g_lastFrx = millis();
+	tuneSession();
+}
 
 // Continuous (gapless) RX: READY->START and END->START keep the radio receiving; ADDRESS->RSSISTART samples
 // signal strength for each packet. We poll EVENTS_END and copy rfrx before the next packet can overwrite it.
@@ -122,15 +266,24 @@ static void rxArmInit()
 			    RADIO_SHORTS_DISABLED_RSSISTOP_Msk;
 	NRF_RADIO->EVENTS_END = 0;
 	NRF_RADIO->EVENTS_ADDRESS = 0;
+	// Capture the completed frame from the RADIO END interrupt (see RADIO_IRQHandler). The NVIC enable/priority is
+	// set once in setup(); here we (re)arm the END interrupt source after every retune.
+	NRF_RADIO->INTENSET = RADIO_INTENSET_END_Msk;
 	NRF_RADIO->TASKS_RXEN = 1;
 }
 
+// Producer = the RADIO END ISR (owns g_head); consumer = loop() (owns g_tail). Lock-free single-producer/
+// single-consumer ring: on full we drop the NEWEST frame and COUNT it in g_drops -- the ISR must never touch
+// g_tail. (The old code dropped the oldest by advancing g_tail from the producer, which raced the consumer and
+// also hid the loss.) g_drops is surfaced in the status frame so a dropped frame is measured, not assumed away.
 static void ringPush(uint32_t t, uint8_t ch, bool crcok, uint8_t rssi,
 		     uint8_t match, const uint8_t *b, uint8_t n)
 {
 	uint16_t h = g_head, nx = rnext(h);
-	if (nx == g_tail)
-		g_tail = rnext(g_tail); // full -> drop oldest
+	if (nx == g_tail) {
+		g_drops++; // ring full -> drop newest, count it
+		return;
+	}
 	Cap &c = g_ring[h];
 	c.t = t;
 	c.ch = ch;
@@ -142,16 +295,23 @@ static void ringPush(uint32_t t, uint8_t ch, bool crcok, uint8_t rssi,
 	g_head = nx;
 }
 
-// Drain the radio: copy every completed RX frame into the ring. Called very often from loop() so the tight
-// poll->reply exchange is caught. RSSISAMPLE/CRCSTATUS belong to the frame whose END we just saw.
-static void rxPump()
+// RADIO END interrupt -- the ONE latency-critical step, and the reason capture is now lossless.
+//
+// The radio re-arms into the SAME rfrx buffer (END_START short), so the NEXT packet's payload starts overwriting
+// rfrx only ~60us after this END (ramp-up + preamble + address). Snapshotting the just-completed frame HERE -- in
+// a high-priority ISR that PREEMPTS the USB writes/flushes running in loop() -- guarantees the copy lands well
+// inside that 60us window. The previous design polled EVENTS_END from loop(), where a blocking USB flush could
+// delay the copy past 60us and let the next frame overwrite rfrx (a tight poll->reply pair, <100us apart, was the
+// danger) -- a silent loss. RSSISAMPLE/CRCSTATUS/RXMATCH belong to the frame whose END we just saw.
+//
+// The ISR ONLY captures. Interpretation (E1 parse / acquire-lock / QoS hop) is left to processFrame() in loop(),
+// so this handler stays a few microseconds long and can't itself become the stall it's meant to prevent.
+extern "C" void RADIO_IRQHandler(void)
 {
-	for (int guard = 0; guard < 8 && NRF_RADIO->EVENTS_END; guard++) {
+	while (NRF_RADIO->EVENTS_END) {
 		NRF_RADIO->EVENTS_END = 0;
 		bool crcok = NRF_RADIO->CRCSTATUS & 1;
 		uint8_t rssi = (uint8_t)(NRF_RADIO->RSSISAMPLE & 0x7F);
-
-		// which logical address (pipe) the frame hit
 		uint8_t match = (uint8_t)(NRF_RADIO->RXMATCH & 0x7);
 		g_lastMatch = match;
 		uint8_t len = rfrx[0];
@@ -167,44 +327,68 @@ static void rxPump()
 		if (crcok && len >= 1 && (rfrx[2] & 0xF0) == 0xF0)
 			// controller reply = ACTIVE session here -> stop hunting, park
 			g_lastFrx = millis();
-		// Parse the session params advertised in an E1 host-frame (payload[0]=0xE1) by the ASSUMED offsets, and
-		// always record them for the status report -- so the panel shows what auto-acquire would lock onto even
-		// when we choose not to (lets us eyeball whether the real puck's offsets match ours).
-		if (crcok && len >= 18 && rfrx[2] == 0xE1) {
-			g_advCh = rfrx[11];
-			g_advBase[0] = rfrx[15];
-			g_advBase[1] = rfrx[16];
-			g_advBase[2] = rfrx[17];
-			g_advBase[3] = rfrx[18];
-			g_advPfx = rfrx[19];
-			if (g_pinSession || g_survey)
-				// pinned / survey -> never auto-retune (just record adv)
-				continue;
-			// Target a specific bonded slot by its ibex_uuid (E1 bytes rfrx[7..10]): the puck beacons one E1 per slot,
-			// so skip any E1 that isn't the CONNECTED controller's -- otherwise we lock an idle slot's keepalive.
-			if (g_haveTarget &&
-			    memcmp(rfrx + 7, g_targetIbex, 4) != 0)
-				continue;
-			// ACQUIRE: lock onto the advertised session. CAPTURE: a different advertised channel = QoS hop -> follow.
-			if (g_advCh >= 1 && g_advCh <= 80) {
-				if (g_state == 0) {
-					memcpy(g_sBase, g_advBase, 4);
-					g_sPrefix = g_advPfx;
-					g_primaryCh = g_advCh;
-					g_curCh = g_forceCh ? g_forceCh :
-							      g_advCh;
-					g_huntIdx = 0;
-					g_lastFrx = millis();
-					tuneSession();
-					return;
-				} else if (!g_forceCh && g_advCh != g_curCh) {
-					g_curCh = g_advCh;
-					tuneSession();
-					return;
-				}
-			}
+	}
+}
+
+// Interpret ONE captured frame (loop context, fed from the ring). Parses the session params advertised in an E1
+// host-frame (payload[0]=0xE1) by the ASSUMED offsets and always records them for the status report -- so the
+// panel shows what auto-acquire would lock onto even when we choose not to (lets us eyeball whether the real
+// puck's offsets match ours). Returns true if it retuned the radio. This is the exact logic that used to run
+// inline in rxPump; it just no longer shares a thread with the latency-sensitive copy.
+static bool processFrame(const Cap &c)
+{
+	bool crcok = c.flags & 1;
+	uint8_t len = c.n >= 1 ? c.b[0] : 0;
+	uint8_t op = c.n >= 3 ? c.b[2] : 0;
+	// LEARN: a CRC-ok controller reply (0xF*) on the session we're camped on proves THIS channel carries the
+	// bond's traffic -> remember it, so the next reconnect can re-acquire straight onto it (no ch2 catch).
+	if (crcok && g_state == 1 && g_curBond >= 0 && (op & 0xF0) == 0xF0)
+		bondLearnChan(g_curBond, c.ch);
+	if (!(crcok && len >= 18 && op == 0xE1))
+		return false;
+	g_advCh = c.b[11];
+	g_advBase[0] = c.b[15];
+	g_advBase[1] = c.b[16];
+	g_advBase[2] = c.b[17];
+	g_advBase[3] = c.b[18];
+	g_advPfx = c.b[19];
+	if (g_pinSession || g_survey)
+		// pinned / survey -> never auto-retune (just record adv)
+		return false;
+	// Target a specific bonded slot by its ibex_uuid (E1 bytes [7..10]): the puck beacons one E1 per slot, so skip
+	// any E1 that isn't the CONNECTED controller's -- otherwise we lock an idle slot's keepalive.
+	if (g_haveTarget && memcmp(c.b + 7, g_targetIbex, 4) != 0)
+		return false;
+	// ACQUIRE: lock onto the advertised session. CAPTURE: a different advertised channel = QoS hop -> follow.
+	if (g_advCh >= 1 && g_advCh <= 80) {
+		// LEARN the bonded address from this E1 (keyed by ibex_uuid) and persist it -- this is what lets future
+		// reconnects be caught with no manual pinning.
+		int bi = bondAlloc(c.b + 7);
+		Bond &b = g_bonds[bi];
+		if (memcmp(b.base, g_advBase, 4) != 0 || b.prefix != g_advPfx ||
+		    b.primaryCh != g_advCh) {
+			memcpy(b.base, g_advBase, 4);
+			b.prefix = g_advPfx;
+			b.primaryCh = g_advCh;
+			g_bondDirty = true;
+		}
+		if (g_state == 0) {
+			g_curBond = bi;
+			memcpy(g_sBase, g_advBase, 4);
+			g_sPrefix = g_advPfx;
+			g_primaryCh = g_advCh;
+			g_curCh = g_forceCh ? g_forceCh : g_advCh;
+			g_huntIdx = 0;
+			g_lastFrx = millis();
+			tuneSession();
+			return true;
+		} else if (!g_forceCh && g_advCh != g_curCh) {
+			g_curCh = g_advCh;
+			tuneSession();
+			return true;
 		}
 	}
+	return false;
 }
 
 // ---- WebUSB stream out ----
@@ -233,33 +417,55 @@ static void emitStatus()
 	last = millis();
 	static uint16_t rx = 0;
 	rx++;
-	uint8_t f[19] = { 0xC1,		0xDE,
-			  g_state,	g_curCh,
-			  g_sBase[0],	g_sBase[1],
-			  g_sBase[2],	g_sBase[3],
-			  g_sPrefix,	(uint8_t)(g_streaming ? 1 : 0),
-			  (uint8_t)rx,	(uint8_t)(rx >> 8),
-			  g_advCh,	g_advBase[0],
-			  g_advBase[1], g_advBase[2],
-			  g_advBase[3], g_advPfx,
-			  g_lastMatch };
+	uint32_t drops = g_drops; // snapshot the volatile once
+	// bond byte: bit7 = camped on a learned bond; bits0-3 = #channels learned for it; bits4-6 = #bonds stored
+	int nbonds = 0;
+	for (int i = 0; i < NBONDS; i++)
+		if (uuidSet(g_bonds[i].uuid))
+			nbonds++;
+	uint8_t curN = (g_curBond >= 0) ? g_bonds[g_curBond].nchans : 0;
+	uint8_t bondInfo = (uint8_t)((g_curBond >= 0 ? 0x80 : 0) |
+				     ((nbonds & 0x7) << 4) | (curN & 0xF));
+	uint8_t f[22] = { 0xC1,		  0xDE,
+			  g_state,	  g_curCh,
+			  g_sBase[0],	  g_sBase[1],
+			  g_sBase[2],	  g_sBase[3],
+			  g_sPrefix,	  (uint8_t)(g_streaming ? 1 : 0),
+			  (uint8_t)rx,	  (uint8_t)(rx >> 8),
+			  g_advCh,	  g_advBase[0],
+			  g_advBase[1],	  g_advBase[2],
+			  g_advBase[3],	  g_advPfx,
+			  g_lastMatch,	  (uint8_t)drops,
+			  (uint8_t)(drops >> 8), bondInfo };
 	usb_web.write(f, sizeof f);
 	usb_web.flush();
 }
-static void drainToHost()
+// Consume the ring (loop context): interpret each captured frame (E1 parse / acquire-lock / QoS hop) and, when
+// streaming, emit it to the host. processFrame() runs even when NOT streaming, because acquisition has to happen
+// before Start Cap. Capture itself is the RADIO ISR's job now -- this side just drains, so a slow USB flush here
+// can no longer cost us a frame (worst case the ring backs up and g_drops ticks, which the panel shows).
+static void consumeRing()
 {
-	if (!usb_web.connected())
-		return;
-
-	// bound per-call so RX stays responsive; the ring covers bursts
-	uint16_t budget = 64;
+	bool connected = usb_web.connected();
+	bool emit = connected && g_streaming;
+	// bound per-call so the loop stays responsive; the 512-deep ring absorbs anything beyond this
+	uint16_t budget = 128;
 	while (budget-- && g_tail != g_head) {
-		if (g_streaming)
-			emitPacket(g_ring[g_tail]);
+		Cap c = g_ring[g_tail]; // copy out before releasing the slot to the producer
 		g_tail = rnext(g_tail);
+		bool retuned = processFrame(c); // may retune the radio (acquire / channel hop)
+		if (emit)
+			emitPacket(c);
+		// On a retune, stop draining this round: the remaining queued frames were captured on the OLD channel, and
+		// a flooded ch2 often queues several E1s -- re-applying each would thrash the radio. (The old rxPump did the
+		// same via an immediate `return`.) The rest drain next loop.
+		if (retuned)
+			break;
+	}
+	if (connected) {
+		usb_web.flush();
+		emitStatus();
 	}
-	usb_web.flush();
-	emitStatus();
 }
 static void handleCmd()
 {
@@ -278,6 +484,7 @@ static void handleCmd()
 			g_forceCh = 0;
 			g_pinSession = false;
 			g_survey = false;
+			g_curBond = -1; // un-camp; relearn from a fresh E1
 			tuneDiscovery();
 		} // re-acquire from ibex/ch2
 		else if (c == 0x06) {
@@ -299,8 +506,21 @@ static void handleCmd()
 				g_survey = false;
 				g_forceCh = 0;
 				g_pinSession = false;
-				tuneDiscovery();
+				// If we've already learned this controller's bond, camp straight on it (no ch2 catch needed -- the
+				// bonded address is stable, so its next reconnect lands right here). Otherwise discover it on ch2,
+				// and processFrame() will persist the bond the first time its E1 is seen.
+				int bi = bondFind(g_targetIbex);
+				if (bi >= 0)
+					campBond(bi);
+				else
+					tuneDiscovery();
 			}
+		} else if (c == 0x08) {
+			// forget all learned bonds (start clean)
+			memset(g_bonds, 0, sizeof g_bonds);
+			g_curBond = -1;
+			bondsSave();
+			tuneDiscovery();
 		} else if (c == 0x04) {
 			int ch = usb_web.read();
 			if (ch >= 1 && ch <= 80) {
@@ -344,41 +564,75 @@ void setup()
 	for (int i = 0; i < 200 && !USBDevice.mounted(); i++)
 		delay(10);
 
+	// Capture is interrupt-driven: the RADIO END ISR snapshots each frame the instant it completes, preempting the
+	// USB work in loop() so the copy always beats the ~60us buffer-overwrite window. Give it a high priority and
+	// enable it once here; rxArmInit() arms the END source (INTENSET) after every (re)tune. (No SoftDevice runs, so
+	// RADIO_IRQn is ours.)
+	NVIC_SetPriority(RADIO_IRQn, 2);
+	NVIC_ClearPendingIRQ(RADIO_IRQn);
+	NVIC_EnableIRQ(RADIO_IRQn);
+
 	// harmless (we never TX); leaves the radio tunables at their validated defaults
 	rfGenSessionAddr();
-	tuneDiscovery();
+
+	// Load any bonds learned on a previous run. If we already know a controller's bonded session, camp straight on
+	// it -- so a clean reconnect is captured the moment the controller powers back on, with NO ch2 catch and NO
+	// manual pin. (With multiple bonds we wait for the host to `lock ibex` the one you want; a single bond is
+	// unambiguous, so we adopt it immediately.) Otherwise fall back to ch2 discovery to learn one.
+	InternalFS.begin();
+	bondsLoad();
+	int count = 0, b0 = bondFirst();
+	for (int i = 0; i < NBONDS; i++)
+		if (uuidSet(g_bonds[i].uuid))
+			count++;
+	if (count == 1)
+		campBond(b0); // sole known bond -> adopt immediately (unambiguous)
+	else
+		tuneDiscovery(); // 0 bonds, or several -> discover / wait for a uuid lock
 	g_lastRx = millis();
 }
 
 void loop()
 {
-	rxPump();
+	// Capture now happens in RADIO_IRQHandler (interrupt-driven, gapless), so loop() no longer races to poll the
+	// radio. It just services host commands and drains the capture ring to USB.
 	handleCmd();
-
-	// pump again around the (fast, buffered) USB work to catch tight poll->reply pairs
-	rxPump();
-	drainToHost();
-	// Session HUNT (Gazell). A pinned session (cmd 04/05) is never disturbed. Otherwise: while we're NOT seeing the
-	// controller's 0xF* replies (the ACTIVE session), the keepalive trickles in on the primary but the real E3/F1
-	// exchange is on another channel of the per-session set -> hunt {primary,2,80,+candidates}, dwelling briefly on
-	// each, KEEPING the learned address. The instant a 0xF* arrives (g_lastFrx) we stop and park there. After two
-	// full dry passes (no replies anywhere -> session address likely changed on a reconnect) fall back to discovery
+	consumeRing();
+	// Persist newly-learned bond data, debounced (flash write blocks tens of ms; the ring covers it).
+	if (g_bondDirty && millis() - g_lastBondSave > 3000)
+		bondsSave();
+	// Session HUNT (Gazell). A pinned session (cmd 04/05) is never disturbed. Otherwise, while we're NOT seeing the
+	// controller's 0xF* replies, sweep the session's channels, KEEPING the (stable, bonded) address, dwelling briefly
+	// on each. The instant a 0xF* arrives (g_lastFrx) we stop and park. The sweep walks the bond's LEARNED channels
+	// first (fast re-acquire onto a known-good one), then the advertised primary, then a broad candidate list to
+	// discover the rest of the map. When a bond is known the address is stable, so we never abandon to ch2 -- we just
+	// keep sweeping until the controller's reconnect appears; only with NO bond at all do we fall back to discovery
 	// to re-read an E1. Hunting only runs when there are no replies, so an active capture is never disturbed.
 	if (g_state == 1 && !g_forceCh && !g_pinSession &&
 	    millis() - g_lastFrx > HUNT_NOFRX_MS &&
 	    millis() - g_huntMs > HUNT_DWELL_MS) {
 		g_huntMs = millis();
-		uint8_t ch =
-			(g_huntIdx == 0) ?
-				g_primaryCh :
-				HUNT_CH[(g_huntIdx - 1) % (sizeof HUNT_CH)];
+		uint8_t learned = (g_curBond >= 0) ? g_bonds[g_curBond].nchans : 0;
+		uint8_t total = learned + 1 + (uint8_t)sizeof HUNT_CH;
+		uint8_t slot = g_huntIdx;
+		uint8_t ch;
+		if (slot < learned)
+			ch = g_bonds[g_curBond].chans[slot];
+		else if (slot == learned)
+			ch = g_primaryCh;
+		else
+			ch = HUNT_CH[(slot - learned - 1) % (sizeof HUNT_CH)];
 		g_huntIdx++;
-		if (g_huntIdx > 1 + 2 * (uint8_t)sizeof HUNT_CH) {
+		if (g_huntIdx >= total) {
 			g_huntIdx = 0;
-			tuneDiscovery();
-			g_lastFrx = millis();
-			g_huntMs = millis();
-			return;
+			if (g_curBond < 0) {
+				// nothing learned -> re-read an E1 on the ch2 rendezvous
+				tuneDiscovery();
+				g_lastFrx = millis();
+				g_huntMs = millis();
+				return;
+			}
+			// bonded address is stable -> keep sweeping its set, never abandon to ch2
 		}
 		g_curCh = ch;
 		rfConfig(g_curCh);