Parcourir la source

update sniffer and readme

Jariullah Safi il y a 4 mois
Parent
commit
5030bed493
3 fichiers modifiés avec 427 ajouts et 138 suppressions
  1. 48 14
      docs/sniffer.html
  2. 28 27
      puck_sniffer/README.md
  3. 351 97
      puck_sniffer/puck_sniffer.ino

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