Bladeren bron

Add sniffer for debugging

Jariullah Safi 4 maanden geleden
bovenliggende
commit
a0253cad8e
5 gewijzigde bestanden met toevoegingen van 666 en 0 verwijderingen
  1. 206 0
      docs/sniffer.html
  2. 73 0
      puck_sniffer/README.md
  3. 235 0
      puck_sniffer/puck_sniffer.ino
  4. 93 0
      puck_sniffer/radio.cpp
  5. 59 0
      puck_sniffer/radio.h

+ 206 - 0
docs/sniffer.html

@@ -0,0 +1,206 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>OpenPuck RF Sniffer</title>
+<style>
+  :root{ --bg:#0d1117; --fg:#c9d1d9; --mut:#8b949e; --acc:#2f81f7; --up:#238636; --dn:#8b949e; --warn:#d29922; --card:#161b22; --line:#30363d; }
+  *{ box-sizing:border-box; }
+  body{ margin:0; font:13px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace; background:var(--bg); color:var(--fg); }
+  header{ padding:14px 18px; border-bottom:1px solid var(--line); display:flex; gap:14px; align-items:center; flex-wrap:wrap; }
+  h1{ font-size:15px; margin:0; font-weight:600; }
+  .pill{ padding:2px 9px; border-radius:11px; font-size:11px; }
+  .pill.up{ background:#1f3d2a; color:#3fb950; } .pill.dn{ background:#21262d; color:var(--mut); }
+  button{ background:#21262d; color:var(--fg); border:1px solid var(--line); border-radius:6px; padding:6px 12px; cursor:pointer; font:inherit; }
+  button:hover{ border-color:var(--acc); } button:disabled{ opacity:.4; cursor:default; }
+  button.cap{ background:#3d1f1f; border-color:#6e2c2c; } button.cap.on{ background:#8b1d1d; color:#fff; }
+  input[type=number]{ width:54px; background:#0d1117; color:var(--fg); border:1px solid var(--line); border-radius:5px; padding:4px; font:inherit; }
+  .row{ display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
+  .note{ color:var(--mut); font-size:11px; }
+  .stats{ padding:8px 18px; border-bottom:1px solid var(--line); display:flex; gap:22px; flex-wrap:wrap; }
+  .stats b{ color:#fff; font-weight:600; }
+  #wrap{ padding:0; }
+  table{ width:100%; border-collapse:collapse; }
+  th,td{ text-align:left; padding:3px 8px; border-bottom:1px solid #1c2128; white-space:nowrap; }
+  th{ position:sticky; top:0; background:var(--card); color:var(--mut); font-weight:600; font-size:11px; }
+  td.hex{ white-space:pre; color:#9ad; font-family:inherit; }
+  tr.pc td.dir{ color:#d29922; } tr.cp td.dir{ color:#3fb950; }
+  tr.bad{ opacity:.5; }
+  #tbl{ max-height:calc(100vh - 200px); overflow:auto; }
+  #log{ padding:6px 18px; color:var(--mut); font-size:11px; white-space:pre-wrap; max-height:90px; overflow:auto; border-top:1px solid var(--line); }
+  code{ color:#9ad; }
+</style>
+</head>
+<body>
+<header>
+  <h1>OpenPuck RF Sniffer</h1>
+  <button id="connect">Connect sniffer</button>
+  <span id="state" class="pill dn">disconnected</span>
+  <button id="cap" class="cap" disabled>● Start Cap</button>
+  <button id="clear" disabled>Clear</button>
+  <button id="dl" disabled>Download JSON</button>
+  <button id="dltxt" disabled>Download text</button>
+  <span style="flex:1"></span>
+  <button id="reacq" disabled>Re-acquire</button>
+  <button id="survey" disabled>Survey ch2</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>
+  <button id="setch" disabled>Set</button>
+  <label class="note">pin session <input type="text" id="pinSess" placeholder="b0 b1 b2 b3 pfx ch (hex)" style="width:170px;background:#0d1117;color:var(--fg);border:1px solid var(--line);border-radius:5px;padding:4px;font:inherit"></label>
+  <button id="setsess" disabled>Pin</button>
+</header>
+<div class="stats">
+  <span>link: <b id="acq">—</b></span>
+  <span>rx: <b id="rx">0 B</b></span>
+  <span>session: <b id="sess">—</b></span>
+  <span>E1 advertises: <b id="adv">—</b></span>
+  <span>captured: <b id="nrec">0</b></span>
+  <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 class="note">filter <input type="text" id="filter" placeholder="hex e.g. 9f or e3" style="width:90px;background:#0d1117;color:var(--fg);border:1px solid var(--line);border-radius:5px;padding:3px;font:inherit"></span>
+</div>
+<div id="tbl"><table><thead><tr>
+  <th>#</th><th>t (ms)</th><th>dir</th><th>ch</th><th>pipe</th><th>op</th><th>crc</th><th>rssi</th><th>len</th><th>payload (from opcode)</th>
+</tr><tbody id="rows"></tbody></table></div>
+<div id="log"></div>
+
+<script>
+const $=s=>document.querySelector(s);
+let dev=null, epIn=0, epOut=0, ifNum=0, reading=false;
+let capturing=false, t0=0, rec=[], nPC=0, nCP=0, nBad=0;
+let acqState=-1, sessTxt="—", rxBytes=0, gotData=false;
+const MAXROWS=600;            // live table cap (full record kept in `rec` for export)
+function log(m){ const l=$("#log"); l.textContent=(new Date().toLocaleTimeString()+"  "+m+"\n"+l.textContent).slice(0,1500); }
+const hex=b=>Array.from(b,x=>x.toString(16).padStart(2,"0")).join(" ");
+
+async function connect(){
+  try{
+    dev=await navigator.usb.requestDevice({filters:[{vendorId:0x28DE}]});
+    await dev.open();
+    if(dev.configuration===null) await dev.selectConfiguration(1);
+    let found=null;
+    for(const itf of dev.configuration.interfaces){
+      const a=itf.alternate; if(a.interfaceClass!==0xFF || a.interfaceSubClass===0x5D) continue;
+      let bin=0,bout=0;
+      for(const e of a.endpoints){ if(e.type==="bulk"){ if(e.direction==="in")bin=e.endpointNumber; else bout=e.endpointNumber; } }
+      if(bin&&bout){ found={ifNum:itf.interfaceNumber,epIn:bin,epOut:bout}; break; }
+    }
+    if(!found){ log("no WebUSB bulk interface — is this the sniffer board (OpenPuck Sniffer)?"); return; }
+    ifNum=found.ifNum; epIn=found.epIn; epOut=found.epOut;
+    await dev.claimInterface(ifNum);
+    await dev.controlTransferOut({requestType:"class",recipient:"interface",request:0x22,value:0x01,index:ifNum});
+    $("#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;
+    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;
+    setTimeout(()=>{ if(dev && !gotData) log("⚠ connected but NO bytes from sniffer in 2s — firmware not streaming (re-flash the updated sniffer build) or wrong interface."); }, 2000);
+    reading=true; readLoop();
+  }catch(e){ log("connect failed: "+e.message); }
+}
+function onGone(){ reading=false; dev=null; capturing=false; $("#state").textContent="disconnected"; $("#state").className="pill dn"; $("#cap").classList.remove("on"); $("#cap").textContent="● Start Cap"; log("sniffer disconnected"); }
+async function cmd(...b){ if(dev) try{ await dev.transferOut(epOut,new Uint8Array(b)); }catch(e){ log("write err: "+e.message); } }
+
+let acc=new Uint8Array(0);
+function append(buf){ const m=new Uint8Array(acc.length+buf.length); m.set(acc); m.set(buf,acc.length); acc=m; }
+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;
+      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];
+      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").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;
+    }
+    if(acc[i]===0xC0 && acc[i+1]===0xDE){            // packet
+      if(i+3>acc.length) break;
+      const n=acc[i+2]; if(n>66){ i++; continue; }   // bad len -> resync
+      if(i+11+n>acc.length) break;
+      const t=acc[i+3]|(acc[i+4]<<8)|(acc[i+5]<<16)|(acc[i+6]*16777216);
+      const ch=acc[i+7], crc=acc[i+8]&1, rssi=acc[i+9], match=acc[i+10];
+      const raw=acc.slice(i+11,i+11+n);
+      onPacket(t,ch,crc,rssi,match,raw);
+      i+=11+n; continue;
+    }
+    i++;                                             // resync
+  }
+  acc=acc.slice(i);
+}
+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={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)};
+  if(capturing) rec.push(ent);
+  addRow(ent);
+  $("#npc").textContent=nPC; $("#ncp").textContent=nCP; $("#nbad").textContent=nBad; $("#nrec").textContent=rec.length;
+}
+const rows=$("#rows"); let rowi=0; const flt=()=>$("#filter").value.trim().toLowerCase();
+function addRow(e){
+  const f=flt(); if(f && !e.payload.replace(/ /g,"").includes(f.replace(/ /g,""))) return;
+  const tr=document.createElement("tr");
+  tr.className=(e.dir==="P→C"?"pc":e.dir==="C→P"?"cp":"")+(e.crc?"":" bad");
+  tr.innerHTML=`<td>${++rowi}</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>`;
+  rows.appendChild(tr);
+  while(rows.childElementCount>MAXROWS) rows.removeChild(rows.firstChild);
+  const w=$("#tbl"); if(w.scrollTop+w.clientHeight>=w.scrollHeight-40) w.scrollTop=w.scrollHeight;
+}
+async function readLoop(){
+  while(reading && dev){
+    try{
+      const r=await dev.transferIn(epIn,16384);
+      if(r.status==="ok" && r.data.byteLength){
+        const u=new Uint8Array(r.data.buffer);
+        if(!gotData){ gotData=true; log("✓ receiving — first bytes: "+hex(u.slice(0,24))); }
+        rxBytes+=u.length; $("#rx").textContent=(rxBytes<1024?rxBytes+" B":(rxBytes/1024).toFixed(1)+" KB");
+        append(u); parse();
+      }
+      else if(r.status==="stall"){ await dev.clearHalt("in",epIn); }
+    }catch(e){ if(dev) log("read err: "+e.message); break; }
+  }
+}
+
+$("#connect").onclick=connect;
+$("#cap").onclick=async()=>{
+  capturing=!capturing;
+  $("#cap").classList.toggle("on",capturing);
+  $("#cap").textContent=capturing?"■ End Cap":"● Start Cap";
+  if(capturing){ rec=[]; t0=Date.now(); await cmd(0x01); log("CAP STARTED — recording"); }
+  else{ await cmd(0x02); log(`CAP ENDED — ${rec.length} packets recorded`); }
+  $("#nrec").textContent=rec.length;
+};
+$("#clear").onclick=()=>{ rec=[]; rows.innerHTML=""; rowi=0; nPC=nCP=nBad=0; $("#nrec").textContent=0; $("#npc").textContent=0; $("#ncp").textContent=0; $("#nbad").textContent=0; };
+$("#reacq").onclick=()=>{ $("#pinCh").value=""; cmd(0x03); log("re-acquire requested"); };
+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"); };
+$("#setch").onclick=()=>{ const c=parseInt($("#pinCh").value); if(c>=1&&c<=80){ cmd(0x04,c); log("pinned channel "+c); } };
+$("#setsess").onclick=()=>{   // "b0 b1 b2 b3 pfx ch" -- base/prefix as hex, channel decimal or hex. Read these
+  const v=$("#pinSess").value.trim().split(/[\s,]+/).filter(x=>x.length);   // off a captured E1 (bytes 13..17 of its payload = base, byte 17 = prefix, byte 9 = ch)
+  if(v.length!==6){ log("pin session needs 6 values: b0 b1 b2 b3 pfx ch"); return; }
+  const b=v.slice(0,5).map(x=>parseInt(x,16)); const ch=parseInt(v[5], v[5].toLowerCase().startsWith("0x")?16:10);
+  if(b.some(isNaN)||isNaN(ch)||ch<1||ch>80){ log("bad pin-session values"); return; }
+  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");
+$("#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}`);
+  save("sniff-"+Date.now()+".txt", lines.join("\n"), "text/plain");
+};
+$("#filter").oninput=()=>{};   // filter applies to new rows; Clear to re-filter the view
+if(!navigator.usb) log("This browser has no WebUSB. Use Chrome/Edge over https or localhost.");
+</script>
+</body>
+</html>

+ 73 - 0
puck_sniffer/README.md

@@ -0,0 +1,73 @@
+# OpenPuck RF Sniffer
+
+A **passive** 2.4 GHz sniffer for the link between a real Valve SC2 puck and its controller. It never
+transmits, so it cannot disturb the pairing — it only listens, and streams every frame to a browser app in
+real time with **Start Cap / End Cap**.
+
+Use it to capture the actual puck↔controller traffic for any function (shutdown, LED, battery, …), isolate
+the relevant frames, and export them.
+
+## What you need
+- A **second** nRF52840 board (e.g. the same Pro Micro / Feather you flash OpenPuck onto) — *not* the puck.
+- The real Valve puck + controller, paired and working.
+- Chrome or Edge (WebUSB).
+
+## 1. Flash the sniffer firmware
+```
+arduino-cli compile -b adafruit:nrf52:feather52840 \
+  --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb}" \
+  --upload -p <PORT> puck_sniffer
+```
+(Same manual-DFU/double-tap-reset dance as OpenPuck if the port won't auto-reset.) It enumerates as
+`28DE:534E "OpenPuck Sniffer"`.
+
+## 2. Open the app
+WebUSB needs a secure context, so **not** a `file://` path. Either:
+- **Hosted:** https://safijari.github.io/OpenPuck/sniffer.html (once `docs/` is pushed to gh-pages), or
+- **Local:** `cd docs && python3 -m http.server` → open http://localhost:8000/sniffer.html
+
+Click **Connect sniffer**, pick *OpenPuck Sniffer*.
+
+## 3. Capture
+The link is Nordic **Gazell**: the on-air address is a **System Address random per session** (not derived from
+the bond), and the active `E3`-poll / `F1`-reply exchange rides a small channel set `{primary, 2, 80}` derived
+from that address. The puck's ~5 Hz `E1`/`E2` **keepalive** trickles in even when the controller isn't being
+actively polled — so seeing only `E2` and **`C→P: 0`** means the controller isn't in an active session *yet*.
+
+Recipe (mirrors `~/nrf-sniffer/capture_puck_session.py`):
+1. Status shows **ACQUIRE** → it catches the puck's `E1` on ch2 and locks the session address.
+2. It then **hunts the channel set** for the controller's `0xF_` replies. **Make the session active**: with the
+   controller connected, **do heavy trackpad / move sticks** so the puck polls fast. Watch **`C→P`** climb —
+   that means it parked on the live channel. (Bonded reconnect uses a *fresh* address → if it won't lock,
+   **power-cycle the controller** during ACQUIRE so the new `E1` is heard.)
+3. With `C→P` climbing, **● Start Cap**, trigger the action in Steam (turn off / LED / let battery move),
+   **■ End Cap**, **Download**. Send me the file.
+
+Direction: `P→C` (opcode `0xE_`, puck→controller — LED/shutoff commands ride here) vs `C→P` (`0xF_`,
+controller→puck — battery/telemetry rides here). The `filter` box matches payload hex (e.g. `9f`).
+
+> Bond info (which controller/slot, its serial + uuids) is read over USB, not the air — `scmd`/`pairtui.py`
+> in `~/nrf-sniffer`. Useful for labelling a capture; it does **not** give the on-air address (random per session).
+
+## How it works
+After RX the radio buffer is `[LENGTH][S1][payload…]`; `payload[0]` is the opcode. The puck advertises its
+per-session base/prefix/channel inside the `E1` host frame on the shared `ibex`/ch2 rendezvous, so the sniffer
+reads those, retunes to the session, and receives both directions (same ESB base — and it now listens on **all
+8 ESB pipes** of that base, so a controller reply on a different prefix is still caught; the **pipe** column /
+status `last pipe` shows which `RXMATCH` each frame hit). A QoS channel-hop is followed via the `E1` keepalive.
+When the session goes silent it **sweeps clean candidate channels keeping the learned address** (rather than
+abandoning to ch2) and only falls back to a full discovery scan after two dry sweeps. Radio parameters are
+OpenPuck's CRC-validated config (`radio.cpp`).
+
+### If you only see `E2`/`E1` on ch2 and never the controller's `0xF_` replies
+That means it never camps on the session. Read a real `E1` row in the table (a `P→C`, op `e1` frame): its
+payload bytes are `e1 [proteus:4] [ibex:4] [CH] 00 00 00 [BASE:4] [PFX]` — i.e. **byte 9 = session channel,
+bytes 13–16 = session base, byte 17 = prefix** (the "E1 advertises" stat shows the sniffer's own read of these).
+Cross-check that against where traffic actually is, then type the six values into **pin session**
+(`b0 b1 b2 b3 pfx ch`, address bytes hex) and click **Pin** to force the sniffer onto that exact session — this
+bypasses auto-acquire entirely, which is the reliable path if the real puck's `E1` offsets differ from ours.
+
+## 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

+ 235 - 0
puck_sniffer/puck_sniffer.ino

@@ -0,0 +1,235 @@
+// puck_sniffer.ino -- PASSIVE 2.4GHz sniffer for the Valve SC2 puck <-> controller link.
+//
+// Flash this onto a SECOND nRF52840 board (NOT the one emulating a puck). It never transmits, so it cannot
+// disturb the real puck<->controller pairing -- it only listens. It reuses OpenPuck's CRC-validated radio
+// config (radio.cpp): PHY Ble_2Mbit, ENDIAN=Big, no whitening, CRC16 0x11021/0xFFFF, 5-byte ESB address.
+//
+// HOW IT FINDS THE SESSION
+//   The puck and controller talk on a per-puck SESSION address/channel. The puck advertises those in its E1
+//   host-frame beacon, which it sends on the SHARED discovery rendezvous ("ibex" / prefix 0x10 / channel 2).
+//   So the sniffer: (1) ACQUIRE -- listen on ibex/ch2, catch an E1, read session base/prefix/channel from it;
+//   (2) CAPTURE -- retune to that session address/channel and receive every frame both ways. E1 keepalives on
+//   the session address re-advertise the channel, so a QoS channel-hop is followed; a stall re-acquires.
+//
+// Frame in RAM after RX (PCNF0 S0=0/LF=8/S1=3): rfrx[0]=LENGTH(payload bytes), rfrx[1]=S1, rfrx[2..]=payload.
+//   payload[0] (rfrx[2]) is the opcode: 0xE? = puck->controller, 0xF? = controller->puck.
+//
+// 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)
+// 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)
+//
+// 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 "radio.h"
+
+Adafruit_USBD_WebUSB usb_web;
+
+// ---- 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[66]; };
+#define RINGN 512
+static Cap      g_ring[RINGN];
+static volatile uint16_t g_head = 0, g_tail = 0;
+static inline uint16_t rnext(uint16_t i){ return (uint16_t)((i + 1) % RINGN); }
+
+static void rxArmInit();
+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;
+static uint8_t  g_sBase[4] = {0x69,0x62,0x65,0x78};
+static uint8_t  g_sPrefix = 0x10;
+static uint8_t  g_forceCh = 0;            // !=0 -> user pinned a channel (skip auto-acquire retune)
+static bool     g_pinSession = false;     // user pinned a FULL session (cmd 05): never auto-retune addr/ch
+static bool     g_survey = false;         // cmd 06: stay on ibex/ch2 and stream EVERY E1 beacon, never auto-lock
+                                          // (so you can see how many pucks/slots are advertising + their bases)
+// cmd 07: lock ONLY the E1 whose ibex_uuid (E1 payload bytes, = rfrx[7..10], LE) matches -- the puck beacons one
+// 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 = 0;          // RXMATCH pipe of the most recent frame (diagnostic)
+
+// 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
+// fastest way to tell if auto-acquire is mis-reading the real puck's E1.
+static uint8_t  g_advCh = 0, g_advBase[4] = {0,0,0,0}, g_advPfx = 0;
+
+// CAPTURE-mode stall sweep: instead of jumping straight back to discovery (which abandons the session and
+// 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;
+
+// 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 +
+// 0xF* controller reply) rides a ~3-channel set {primary,2,80} derived from the per-session address. So HUNT on
+// "no 0xF* reply" instead: dwell briefly on each of {primary,2,80,+clean candidates} until the controller's
+// 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;
+#define HUNT_DWELL_MS 350u
+#define HUNT_NOFRX_MS 1000u
+
+static void tuneDiscovery(){ g_state=0; g_curCh=2; rfConfig(2); rfSetAddr(PAIR_BASE, 0x10); rxArmInit(); }
+// Session RX listens across all 8 pipes on the learned base (rfSetAddrMulti) so a same-base/other-prefix
+// controller reply is still caught; RXMATCH tells us which pipe each frame used.
+static void tuneSession(){ g_state=1; rfConfig(g_curCh); rfSetAddrMulti(g_sBase, g_sPrefix); rxArmInit(); }
+
+// 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.
+static void rxArmInit(){
+  NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
+  NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk | RADIO_SHORTS_END_START_Msk
+                    | RADIO_SHORTS_ADDRESS_RSSISTART_Msk | RADIO_SHORTS_DISABLED_RSSISTOP_Msk;
+  NRF_RADIO->EVENTS_END = 0; NRF_RADIO->EVENTS_ADDRESS = 0;
+  NRF_RADIO->TASKS_RXEN = 1;
+}
+
+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
+  Cap &c = g_ring[h];
+  c.t = t; c.ch = ch; c.flags = crcok ? 1 : 0; c.rssi = rssi; c.match = match; c.n = n;
+  memcpy(c.b, b, n);
+  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(){
+  for (int guard = 0; guard < 8 && NRF_RADIO->EVENTS_END; guard++){
+    NRF_RADIO->EVENTS_END = 0;
+    bool crcok = NRF_RADIO->CRCSTATUS & 1;
+    uint8_t rssi = (uint8_t)(NRF_RADIO->RSSISAMPLE & 0x7F);
+    uint8_t match = (uint8_t)(NRF_RADIO->RXMATCH & 0x7);   // which logical address (pipe) the frame hit
+    g_lastMatch = match;
+    uint8_t len = rfrx[0];
+    uint16_t n = (uint16_t)(2 + len); if (n > 66) n = 66;   // LENGTH + S1 + payload, clamped
+    ringPush(micros(), g_curCh, crcok, rssi, match, rfrx, (uint8_t)n);
+    g_lastRx = millis();
+    if (g_state == 1) g_sweepDry = 0;                        // any frame on the session resets the dry-sweep count
+    if (crcok && len >= 1 && (rfrx[2] & 0xF0) == 0xF0) g_lastFrx = millis();   // controller reply = ACTIVE session here -> stop hunting, park
+    // 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) continue;                // pinned / survey -> never auto-retune (just record adv)
+      // 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;
+        }
+      }
+    }
+  }
+}
+
+// ---- WebUSB stream out ----
+static void emitPacket(const Cap &c){
+  uint8_t f[11 + 66];
+  f[0]=0xC0; f[1]=0xDE; f[2]=c.n;
+  f[3]=(uint8_t)c.t; f[4]=(uint8_t)(c.t>>8); f[5]=(uint8_t)(c.t>>16); f[6]=(uint8_t)(c.t>>24);
+  f[7]=c.ch; f[8]=c.flags; f[9]=c.rssi; f[10]=c.match;
+  memcpy(f+11, c.b, c.n);
+  usb_web.write(f, (uint32_t)(11 + c.n));
+}
+static void emitStatus(){
+  static unsigned long last=0; if (millis()-last < 250) return; 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};
+  usb_web.write(f, sizeof f); usb_web.flush();
+}
+static void drainToHost(){
+  if (!usb_web.connected()) return;
+  uint16_t budget = 64;   // bound per-call so RX stays responsive; the ring covers bursts
+  while (budget-- && g_tail != g_head){
+    if (g_streaming) emitPacket(g_ring[g_tail]);
+    g_tail = rnext(g_tail);
+  }
+  usb_web.flush();
+  emitStatus();
+}
+static void handleCmd(){
+  while (usb_web.available()){
+    int c = usb_web.read(); if (c < 0) break;
+    if (c == 0x01){ g_streaming = true; g_head = g_tail = 0; }   // START (flush stale ring)
+    else if (c == 0x02){ g_streaming = false; }                 // STOP
+    else if (c == 0x03){ g_forceCh = 0; g_pinSession = false; g_survey = false; tuneDiscovery(); }   // re-acquire from ibex/ch2
+    else if (c == 0x06){ g_survey = !g_survey; g_forceCh = 0; g_pinSession = false; tuneDiscovery(); }   // survey: list all E1 beacons on ch2, no lock
+    else if (c == 0x07){                                       // target ibex_uuid: lock only the matching slot's E1
+      int u0=usb_web.read(),u1=usb_web.read(),u2=usb_web.read(),u3=usb_web.read();
+      if (u0>=0&&u1>=0&&u2>=0&&u3>=0){
+        g_targetIbex[0]=u0; g_targetIbex[1]=u1; g_targetIbex[2]=u2; g_targetIbex[3]=u3;
+        g_haveTarget=true; g_survey=false; g_forceCh=0; g_pinSession=false; tuneDiscovery();
+      }
+    }
+    else if (c == 0x04){ int ch = usb_web.read(); if (ch>=1 && ch<=80){ g_forceCh=(uint8_t)ch; g_curCh=(uint8_t)ch; tuneSession(); } }   // pin channel, keep addr
+    else if (c == 0x05){                                        // pin a FULL session: b0 b1 b2 b3 pfx ch
+      int b0=usb_web.read(),b1=usb_web.read(),b2=usb_web.read(),b3=usb_web.read(),pf=usb_web.read(),ch=usb_web.read();
+      if (b0>=0&&b1>=0&&b2>=0&&b3>=0&&pf>=0&&ch>=1&&ch<=80){
+        g_sBase[0]=b0; g_sBase[1]=b1; g_sBase[2]=b2; g_sBase[3]=b3; g_sPrefix=(uint8_t)pf;
+        g_curCh=(uint8_t)ch; g_forceCh=(uint8_t)ch; g_pinSession=true; tuneSession();
+      }
+    }
+  }
+}
+
+void setup(){
+  // Standard Adafruit WebUSB setup (keep the default CDC composite; just add the WebUSB vendor interface). No
+  // landing page -> no Chrome "open <url>" notification. No detach/clearConfiguration: that's OpenPuck-specific
+  // endpoint juggling and can leave the WebUSB stream un-enabled.
+  USBDevice.setID(0x28DE, 0x534E);                 // 'SN' -- distinct PID so the host doesn't confuse it with a puck
+  USBDevice.setManufacturerDescriptor("Valve Software");
+  USBDevice.setProductDescriptor("OpenPuck Sniffer");
+  usb_web.begin();
+  Serial.begin(115200);
+  for (int i=0;i<200 && !USBDevice.mounted(); i++) delay(10);
+
+  rfGenSessionAddr();        // harmless (we never TX); leaves the radio tunables at their validated defaults
+  tuneDiscovery();
+  g_lastRx = millis();
+}
+
+void loop(){
+  rxPump();
+  handleCmd();
+  rxPump();                  // pump again around the (fast, buffered) USB work to catch tight poll->reply pairs
+  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
+  // 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)];
+    g_huntIdx++;
+    if (g_huntIdx > 1 + 2 * (uint8_t)sizeof HUNT_CH){ g_huntIdx = 0; tuneDiscovery(); g_lastFrx = millis(); g_huntMs = millis(); return; }
+    g_curCh = ch; rfConfig(g_curCh); rfSetAddrMulti(g_sBase, g_sPrefix); rxArmInit();
+  }
+}

+ 93 - 0
puck_sniffer/radio.cpp

@@ -0,0 +1,93 @@
+#include "radio.h"
+
+// Pairing rendezvous bytes from IBEX rodata. The 0x91A2A793 key that precedes the "ibex" string is a
+// DIFFERENT key, NOT the discovery address (confirmed via PTR_s_ibexesb_common=0x56f99 -> "ibex...").
+const uint8_t PAIR_BASE[4] = {0x69, 0x62, 0x65, 0x78};  // "ibex"
+uint8_t  g_rfPrefix = 0x10;
+uint8_t  g_rfCh = 2;
+uint8_t  g_rfBase[4] = {0x69, 0x62, 0x65, 0x78};  // "ibex"
+// ch18 measured clean (RSSI 95/90, PER 0) on the REAL PUCK's own channel scan and is in its active hop set
+// {18,2,80}; trackpad data bursts collide on bad channels -> reply-rate crash. Tunable 'C'.
+uint8_t  g_sessCh = 18;
+
+// pre-init to "ibex"/0x10 as a safe default; rfGenSessionAddr() overwrites with the per-device value at boot.
+uint8_t  g_sessBase[4] = {0x69, 0x62, 0x65, 0x78};
+uint8_t  g_sessPrefix = 0x10;
+
+void rfGenSessionAddr(){
+  // Mix the 64-bit FICR DEVICEID into a 4-byte base + a prefix. Deterministic per chip (stable across reboots,
+  // debuggable) and unique across chips (DEVICEID is unique) -> two OpenPucks never collide on the connected
+  // session. Avoid degenerate address bytes (0x00/0xFF) for clean preamble correlation, and never reproduce
+  // the shared discovery base "ibex" (else the isolation is lost).
+  uint32_t h  = NRF_FICR->DEVICEID[0] * 0x9E3779B1u ^ NRF_FICR->DEVICEID[1];
+  uint32_t h2 = NRF_FICR->DEVICEID[1] * 0x85EBCA6Bu ^ NRF_FICR->DEVICEID[0];
+  for(int i=0;i<4;i++){ uint8_t b=(uint8_t)(h>>(i*8)); if(b==0x00||b==0xFF) b^=0x5A; g_sessBase[i]=b; }
+  uint8_t p=(uint8_t)(h2>>16); if(p==0x00||p==0xFF) p=0x5C; g_sessPrefix=p;
+  if(g_sessBase[0]==g_rfBase[0] && g_sessBase[1]==g_rfBase[1] &&
+     g_sessBase[2]==g_rfBase[2] && g_sessBase[3]==g_rfBase[3]) g_sessBase[0]^=0x80;   // collided with "ibex"
+}
+
+uint8_t  rfrx[80], rftx[80];
+uint32_t g_rfRxCount = 0;
+
+// HYBRID puck-link config (firmware-derived, definitive). DECODED from real-puck capture: puck TX = Ble_2Mbit,
+// ENDIAN=Big (MSB-first), NO whitening, addr "ibex", CRC16 0x11021/0xFFFF; frame = FUN_00027e9a (static length).
+uint32_t g_crcinit = 0xFFFF;
+uint8_t  g_whiteiv = 37;
+uint8_t  g_mode    = RADIO_MODE_MODE_Ble_2Mbit;   // <-- was the bug (1Mbit). Real puck = 2Mbit.
+uint32_t g_pcnf0   = 0x00030008UL;                // S0LEN0, LFLEN8, S1LEN3 (ESB DPL) - CRC-VALIDATED
+uint8_t  g_statlen = 0x20;
+uint32_t g_pcnf1   = 0x01040040;                  // ENDIAN=Big, BALEN4, MAXLEN64 - CRC-VALIDATED
+uint32_t g_crcpoly = 0x11021UL;
+uint16_t g_crccnf  = 0x2;                          // CRC16, address included
+uint8_t  g_pid     = 0;
+uint8_t  g_balen   = 4;                            // ESB 5-byte addr
+bool     g_prefixRaw = false;
+
+uint8_t rfBitrev8(uint8_t x){ uint8_t y=0; for(int i=0;i<8;i++){y=(y<<1)|(x&1);x>>=1;} return y; }
+
+void rfSetAddr(const uint8_t b4[4], uint8_t prefix){
+  uint8_t b[4]; for(int i=0;i<4;i++) b[i]=rfBitrev8(b4[i]);
+  NRF_RADIO->BASE0   = ((uint32_t)b[0]<<24)|((uint32_t)b[1]<<16)|((uint32_t)b[2]<<8)|b[3];
+  NRF_RADIO->PREFIX0 = g_prefixRaw ? prefix : rfBitrev8(prefix);
+  NRF_RADIO->TXADDRESS = 0; NRF_RADIO->RXADDRESSES = 1u<<0;
+}
+
+// Listen on ONE base across ALL 8 ESB logical addresses (pipes): the real prefix on pipe 0, plus a spread of
+// neighbour prefixes on pipes 1..7 (BASE1 mirrors BASE0). Catches a controller whose reply stream rides the
+// same base on a DIFFERENT prefix -- a real possibility per the RE notes' "separate stream" model -- without
+// us having to know that prefix in advance. RXMATCH (reported per packet) then tells us which pipe it used.
+// Extra pipes only ADD matches; they never drop the pipe-0 (puck) frames. base/prefix are stored (pre-transform)
+// bytes; the on-air values get the same bitrev8 transform as rfSetAddr (g_prefixRaw=false).
+void rfSetAddrMulti(const uint8_t b4[4], uint8_t prefix){
+  uint8_t b[4]; for(int i=0;i<4;i++) b[i]=rfBitrev8(b4[i]);
+  uint32_t base = ((uint32_t)b[0]<<24)|((uint32_t)b[1]<<16)|((uint32_t)b[2]<<8)|b[3];
+  NRF_RADIO->BASE0 = base; NRF_RADIO->BASE1 = base;          // pipe 0 uses BASE0; pipes 1..7 use BASE1
+  // 8 candidate prefixes: the learned one first, then a spread. De-dup isn't needed (a repeat just matches twice).
+  uint8_t cand[8] = { prefix, (uint8_t)(prefix^0x01), (uint8_t)(prefix+1), (uint8_t)(prefix-1),
+                      0x10, (uint8_t)(prefix^0x80), (uint8_t)(prefix+0x10), (uint8_t)(prefix-0x10) };
+  uint8_t ap[8]; for(int i=0;i<8;i++) ap[i] = g_prefixRaw ? cand[i] : rfBitrev8(cand[i]);
+  NRF_RADIO->PREFIX0 = (uint32_t)ap[0] | ((uint32_t)ap[1]<<8) | ((uint32_t)ap[2]<<16) | ((uint32_t)ap[3]<<24);
+  NRF_RADIO->PREFIX1 = (uint32_t)ap[4] | ((uint32_t)ap[5]<<8) | ((uint32_t)ap[6]<<16) | ((uint32_t)ap[7]<<24);
+  NRF_RADIO->TXADDRESS = 0; NRF_RADIO->RXADDRESSES = 0xFF;   // enable all 8 pipes
+}
+
+void rfConfig(uint8_t ch){
+  NRF_RADIO->TASKS_DISABLE = 1; RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED = 0;
+  NRF_RADIO->MODE = ((uint32_t)g_mode << RADIO_MODE_MODE_Pos);   // Ble_2Mbit (tunable 'M')
+  NRF_RADIO->FREQUENCY = ch;
+  NRF_RADIO->TXPOWER   = (RADIO_TXPOWER_TXPOWER_0dBm << RADIO_TXPOWER_TXPOWER_Pos);
+#if defined(RADIO_MODECNF0_RU_Fast)
+  NRF_RADIO->MODECNF0 = (RADIO_MODECNF0_RU_Fast<<RADIO_MODECNF0_RU_Pos);
+#endif
+  NRF_RADIO->PCNF0 = g_pcnf0;       // static length (tunable '0'); g_pcnf1=0 -> build from g_statlen/g_balen
+  NRF_RADIO->PCNF1 = g_pcnf1 ? g_pcnf1 :
+        ((1u<<RADIO_PCNF1_ENDIAN_Pos)              // ENDIAN=Big (puck transmits MSB-first)
+        |((uint32_t)g_balen<<RADIO_PCNF1_BALEN_Pos)
+        |((uint32_t)g_statlen<<RADIO_PCNF1_STATLEN_Pos)
+        |((uint32_t)g_statlen<<RADIO_PCNF1_MAXLEN_Pos));   // WHITEEN=0
+  NRF_RADIO->CRCCNF = g_crccnf;     // CRC16, address included
+  NRF_RADIO->CRCPOLY = g_crcpoly; NRF_RADIO->CRCINIT = g_crcinit;
+  NRF_RADIO->DATAWHITEIV = g_whiteiv;
+  NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
+}

+ 59 - 0
puck_sniffer/radio.h

@@ -0,0 +1,59 @@
+// radio.h -- bare-metal nRF52 RADIO (ESB-style) hardware layer.
+//
+// We drive the RADIO peripheral directly, with NO SoftDevice (BLE is never started), coexisting with TinyUSB.
+// This file owns the register-level config: PHY/CRC/whitening/address parameters (all live-tunable from the
+// CDC console for hardware iteration) plus rfConfig()/rfSetAddr() which program the radio, and the rfrx/rftx
+// DMA buffers. The puck *protocol* built on top lives in rf_link.cpp; the RE/calibration tooling in rf_diag.cpp.
+//
+// Register-confirmed config (decoded from real-puck capture): PHY=Ble_2Mbit, ENDIAN=Big (MSB-first), whitening
+// off, addr "ibex", CRC16 poly 0x11021 init 0xFFFF (address included). On-air address = bitrev8 of stored bytes.
+#pragma once
+#include <stdint.h>
+#include <Arduino.h>
+
+// Bounded radio-disable wait: NEVER spin forever. A wedged RADIO peripheral would otherwise hang the whole
+// main loop (and USB stops being serviced -> device "dies" until replug). On timeout we bail and continue;
+// the next rfConfig re-inits the radio.
+#define RWAIT_DISABLED() do{ uint32_t _w=micros(); while(!NRF_RADIO->EVENTS_DISABLED && (uint32_t)(micros()-_w)<3000){} }while(0)
+
+// Discovery/reconnect address (IBEX FUN_00019000, firmware-derived, definitive):
+//   base = "ibex" (69 62 65 78), prefix = 0x10, channel = 2.
+extern const uint8_t PAIR_BASE[4];   // "ibex"
+extern uint8_t  g_rfPrefix;          // discovery prefix (rodata byte @0x56f98)
+extern uint8_t  g_rfCh;              // current TX/RX channel (hopped during beacon/poll)
+extern uint8_t  g_rfBase[4];         // "ibex"
+extern uint8_t  g_sessCh;            // connected-session channel: a CLEAN data channel off the congested adv ch2
+
+// Per-device SESSION address (base+prefix). Discovery/rendezvous stays on the SHARED "ibex" address (g_rfBase)
+// so any controller can find us; the host frame then advertises THIS unique address and the controller adopts
+// it for the connected session. Two OpenPucks therefore never share an on-air session address -> no crosstalk,
+// no spurious cross-wake. Derived from the FICR DEVICEID (stable per chip, unique across chips).
+extern uint8_t  g_sessBase[4];
+extern uint8_t  g_sessPrefix;
+void rfGenSessionAddr();
+
+extern uint8_t  rfrx[80], rftx[80];  // RADIO DMA buffers
+extern uint32_t g_rfRxCount;
+
+// ---- tunable radio parameters (CDC console M/0/2/i/w/I/P/N/T/A) ----
+extern uint32_t g_crcinit;
+extern uint8_t  g_whiteiv;
+extern uint8_t  g_mode;     // RADIO_MODE_MODE_* (real puck = Ble_2Mbit)
+extern uint32_t g_pcnf0;    // S0LEN0, LFLEN8, S1LEN3 (ESB DPL) -- CRC-VALIDATED
+extern uint8_t  g_statlen;  // static-mode payload length
+extern uint32_t g_pcnf1;    // ENDIAN=Big, BALEN4, MAXLEN64 -- CRC-VALIDATED
+extern uint32_t g_crcpoly;
+extern uint16_t g_crccnf;   // CRC16, address included
+extern uint8_t  g_pid;
+extern uint8_t  g_balen;    // ESB 5-byte addr
+extern bool     g_prefixRaw;// false = bitrev8 prefix (ESB addr_conv); true = raw prefix byte
+
+uint8_t rfBitrev8(uint8_t x);
+// Access address (IBEX FUN_00037530): BASE0 = bitrev8 each base byte packed big-endian; PREFIX0 = raw or
+// bitrev8 prefix per g_prefixRaw. "ibex" 69 62 65 78 -> BASE0=0x9646A61E, PREFIX0=0x10.
+void rfSetAddr(const uint8_t b4[4], uint8_t prefix);
+// Listen on one base across all 8 ESB pipes (learned prefix + a neighbour spread) so a same-base/other-prefix
+// controller stream is still caught; RXMATCH then reveals which pipe. See radio.cpp for the candidate set.
+void rfSetAddrMulti(const uint8_t b4[4], uint8_t prefix);
+// Program MODE/FREQUENCY/PCNF/CRC/whitening from the tunables above onto channel ch (radio left disabled).
+void rfConfig(uint8_t ch);