sniffer.html 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. <!doctype html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="utf-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1">
  6. <title>OpenPuck RF Sniffer</title>
  7. <style>
  8. :root{ --bg:#0d1117; --fg:#c9d1d9; --mut:#8b949e; --acc:#2f81f7; --up:#238636; --dn:#8b949e; --warn:#d29922; --card:#161b22; --line:#30363d; }
  9. *{ box-sizing:border-box; }
  10. body{ margin:0; font:13px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace; background:var(--bg); color:var(--fg); }
  11. header{ padding:14px 18px; border-bottom:1px solid var(--line); display:flex; gap:14px; align-items:center; flex-wrap:wrap; }
  12. h1{ font-size:15px; margin:0; font-weight:600; }
  13. .pill{ padding:2px 9px; border-radius:11px; font-size:11px; }
  14. .pill.up{ background:#1f3d2a; color:#3fb950; } .pill.dn{ background:#21262d; color:var(--mut); }
  15. button{ background:#21262d; color:var(--fg); border:1px solid var(--line); border-radius:6px; padding:6px 12px; cursor:pointer; font:inherit; }
  16. button:hover{ border-color:var(--acc); } button:disabled{ opacity:.4; cursor:default; }
  17. button.cap{ background:#3d1f1f; border-color:#6e2c2c; } button.cap.on{ background:#8b1d1d; color:#fff; }
  18. input[type=number]{ width:54px; background:#0d1117; color:var(--fg); border:1px solid var(--line); border-radius:5px; padding:4px; font:inherit; }
  19. .row{ display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
  20. .note{ color:var(--mut); font-size:11px; }
  21. .stats{ padding:8px 18px; border-bottom:1px solid var(--line); display:flex; gap:22px; flex-wrap:wrap; }
  22. .stats b{ color:#fff; font-weight:600; }
  23. #wrap{ padding:0; }
  24. table{ width:100%; border-collapse:collapse; }
  25. th,td{ text-align:left; padding:3px 8px; border-bottom:1px solid #1c2128; white-space:nowrap; }
  26. th{ position:sticky; top:0; background:var(--card); color:var(--mut); font-weight:600; font-size:11px; }
  27. td.hex{ white-space:pre; color:#9ad; font-family:inherit; }
  28. tr.pc td.dir{ color:#d29922; } tr.cp td.dir{ color:#3fb950; }
  29. tr.bad{ opacity:.5; }
  30. #tbl{ max-height:calc(100vh - 200px); overflow:auto; }
  31. #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); }
  32. code{ color:#9ad; }
  33. </style>
  34. </head>
  35. <body>
  36. <header>
  37. <h1>OpenPuck RF Sniffer</h1>
  38. <button id="connect">Connect sniffer</button>
  39. <span id="state" class="pill dn">disconnected</span>
  40. <button id="cap" class="cap" disabled>● Start Cap</button>
  41. <button id="clear" disabled>Clear</button>
  42. <button id="dl" disabled>Download JSON</button>
  43. <button id="dltxt" disabled>Download text</button>
  44. <span style="flex:1"></span>
  45. <button id="reacq" disabled>Re-acquire</button>
  46. <button id="survey" disabled>Survey ch2</button>
  47. <button id="forget" disabled>Forget bonds</button>
  48. <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>
  49. <button id="setTgt" disabled>Lock</button>
  50. <label class="note">pin ch <input type="number" id="pinCh" min="1" max="80" placeholder="auto"></label>
  51. <button id="setch" disabled>Set</button>
  52. <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>
  53. <button id="setsess" disabled>Pin</button>
  54. </header>
  55. <div class="stats">
  56. <span>link: <b id="acq">—</b></span>
  57. <span>rx: <b id="rx">0 B</b></span>
  58. <span>session: <b id="sess">—</b></span>
  59. <span>E1 advertises: <b id="adv">—</b></span>
  60. <span>captured: <b id="nrec">0</b></span>
  61. <span>P→C: <b id="npc">0</b></span>
  62. <span>C→P: <b id="ncp">0</b></span>
  63. <span>crc-bad: <b id="nbad">0</b></span>
  64. <span>drops: <b id="ndrop">0</b></span>
  65. <span>bond: <b id="nbond">none yet</b></span>
  66. <span>shown: <b id="nshown">0</b></span>
  67. </div>
  68. <div class="stats" style="border-top:none;padding-top:0">
  69. <label class="note"><input type="checkbox" id="fHideRoutine"> hide input/polls (49-byte F1 + bare E3)</label>
  70. <label class="note">dir <select id="fDir" style="background:#0d1117;color:var(--fg);border:1px solid var(--line);border-radius:5px;padding:3px;font:inherit"><option value="">all</option><option value="P→C">P→C</option><option value="C→P">C→P</option></select></label>
  71. <label class="note">op <input type="text" id="fOp" placeholder="f1" style="width:48px;background:#0d1117;color:var(--fg);border:1px solid var(--line);border-radius:5px;padding:3px;font:inherit"></label>
  72. <label class="note">len <select id="fLen" style="background:#0d1117;color:var(--fg);border:1px solid var(--line);border-radius:5px;padding:3px;font:inherit"><option value="">any</option><option value="ne49">≠ 49 (non-input)</option><option value="gt49">&gt; 49</option><option value="gt1">&gt; 1</option></select></label>
  73. <label class="note">hex <input type="text" id="filter" placeholder="9f" style="width:90px;background:#0d1117;color:var(--fg);border:1px solid var(--line);border-radius:5px;padding:3px;font:inherit"></label>
  74. <span class="note">↑ to find battery: <b>hide input/polls</b> + dir <b>C→P</b>, then read the controller's battery in Steam.</span>
  75. </div>
  76. <div id="tbl"><table><thead><tr>
  77. <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>
  78. </tr><tbody id="rows"></tbody></table></div>
  79. <div id="log"></div>
  80. <script>
  81. const $=s=>document.querySelector(s);
  82. let dev=null, epIn=0, epOut=0, ifNum=0, reading=false;
  83. let capturing=false, t0=0, rec=[], nPC=0, nCP=0, nBad=0;
  84. let acqState=-1, sessTxt="—", rxBytes=0, gotData=false;
  85. const MAXROWS=600; // live table cap (full record kept in `rec` for export)
  86. function log(m){ const l=$("#log"); l.textContent=(new Date().toLocaleTimeString()+" "+m+"\n"+l.textContent).slice(0,1500); }
  87. const hex=b=>Array.from(b,x=>x.toString(16).padStart(2,"0")).join(" ");
  88. async function connect(){
  89. try{
  90. dev=await navigator.usb.requestDevice({filters:[{vendorId:0x28DE}]});
  91. await dev.open();
  92. if(dev.configuration===null) await dev.selectConfiguration(1);
  93. let found=null;
  94. for(const itf of dev.configuration.interfaces){
  95. const a=itf.alternate; if(a.interfaceClass!==0xFF || a.interfaceSubClass===0x5D) continue;
  96. let bin=0,bout=0;
  97. for(const e of a.endpoints){ if(e.type==="bulk"){ if(e.direction==="in")bin=e.endpointNumber; else bout=e.endpointNumber; } }
  98. if(bin&&bout){ found={ifNum:itf.interfaceNumber,epIn:bin,epOut:bout}; break; }
  99. }
  100. if(!found){ log("no WebUSB bulk interface — is this the sniffer board (OpenPuck Sniffer)?"); return; }
  101. ifNum=found.ifNum; epIn=found.epIn; epOut=found.epOut;
  102. await dev.claimInterface(ifNum);
  103. await dev.controlTransferOut({requestType:"class",recipient:"interface",request:0x22,value:0x01,index:ifNum});
  104. $("#state").textContent="connected"; $("#state").className="pill up"; $("#connect").textContent="Reconnect";
  105. for(const id of ["cap","clear","dl","dltxt","reacq","survey","forget","setTgt","setch","setsess"]) $("#"+id).disabled=false;
  106. navigator.usb.addEventListener("disconnect",e=>{ if(e.device===dev) onGone(); });
  107. log(`connected — iface ${ifNum} epIn ${epIn} epOut ${epOut} — waiting for stream…`);
  108. rxBytes=0; gotData=false;
  109. 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);
  110. reading=true; readLoop();
  111. }catch(e){ log("connect failed: "+e.message); }
  112. }
  113. 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"); }
  114. async function cmd(...b){ if(dev) try{ await dev.transferOut(epOut,new Uint8Array(b)); }catch(e){ log("write err: "+e.message); } }
  115. let acc=new Uint8Array(0);
  116. function append(buf){ const m=new Uint8Array(acc.length+buf.length); m.set(acc); m.set(buf,acc.length); acc=m; }
  117. function parse(){
  118. let i=0;
  119. while(i+2<=acc.length){
  120. if(acc[i]===0xC1 && acc[i+1]===0xDE){ // status (22 bytes)
  121. if(i+22>acc.length) break;
  122. acqState=acc[i+2];
  123. const ch=acc[i+3], b=acc.slice(i+4,i+8), pfx=acc[i+8];
  124. const advCh=acc[i+12], ab=acc.slice(i+13,i+17), apfx=acc[i+17], lastMatch=acc[i+18];
  125. const drops=acc[i+19]|(acc[i+20]<<8); // frames lost to a full ring on the device (0 = lossless)
  126. const bondInfo=acc[i+21], camped=!!(bondInfo&0x80), nbonds=(bondInfo>>4)&0x7, bchans=bondInfo&0xF;
  127. sessTxt=`ch ${ch} addr ${hex(b)}/${pfx.toString(16).padStart(2,"0")} (last pipe ${lastMatch})`;
  128. $("#acq").textContent=acqState===1?(camped?"CAPTURE (auto-camped on learned bond)":"CAPTURE (session locked)"):"ACQUIRE (scanning ibex/ch2)";
  129. $("#acq").style.color=acqState===1?"#3fb950":"#d29922";
  130. $("#sess").textContent=sessTxt;
  131. $("#adv").textContent = advCh ? `ch ${advCh} addr ${hex(ab)}/${apfx.toString(16).padStart(2,"0")}` : "— (no E1 yet)";
  132. const de=$("#ndrop"); if(de){ de.textContent=drops; de.style.color = drops? "#f85149" : "#3fb950"; }
  133. const be=$("#nbond"); if(be){ be.textContent = nbonds? `${nbonds} learned${camped?` · camped (${bchans} ch)`:""}` : "none yet"; be.style.color = camped? "#3fb950" : (nbonds?"#d29922":"#8b949e"); }
  134. i+=22; continue;
  135. }
  136. if(acc[i]===0xC0 && acc[i+1]===0xDE){ // packet
  137. if(i+3>acc.length) break;
  138. const n=acc[i+2]; if(n>98){ i++; continue; } // bad len -> resync (frames up to MAXLEN+2)
  139. if(i+11+n>acc.length) break;
  140. const t=acc[i+3]|(acc[i+4]<<8)|(acc[i+5]<<16)|(acc[i+6]*16777216);
  141. const ch=acc[i+7], crc=acc[i+8]&1, rssi=acc[i+9], match=acc[i+10];
  142. const raw=acc.slice(i+11,i+11+n);
  143. onPacket(t,ch,crc,rssi,match,raw);
  144. i+=11+n; continue;
  145. }
  146. i++; // resync
  147. }
  148. acc=acc.slice(i);
  149. }
  150. let allFrames=[], seqCtr=0, nShown=0; // ring of recent frames so filters can re-render; seq = stable # column
  151. let lastShownSeq=0, dirty=false; // throttled-render bookkeeping (see renderTick)
  152. const FRAMEBUF=8000;
  153. // Hot path (runs per frame at the full capture rate, inside the USB read loop): keep it CHEAP so transferIn is
  154. // never blocked -- a blocked read backs up the device FIFO, stalls its drain, and makes the on-device ring drop
  155. // (the `drops` counter). So we store the raw bytes and defer the expensive hex-string formatting (ensureHex) and
  156. // all DOM work to a throttled renderTick that only touches frames actually displayed/exported.
  157. function onPacket(t_us,ch,crc,rssi,match,raw){
  158. // raw = [LENGTH, S1, payload...]; opcode = raw[2]; dir from opcode high nibble
  159. const op=raw.length>2?raw[2]:0;
  160. const dir=(op&0xF0)===0xE0?"P→C":(op&0xF0)===0xF0?"C→P":"?";
  161. if(dir==="P→C")nPC++; else if(dir==="C→P")nCP++; if(!crc)nBad++;
  162. 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};
  163. if(capturing) rec.push(ent);
  164. allFrames.push(ent);
  165. if(allFrames.length>FRAMEBUF*2) allFrames.splice(0,allFrames.length-FRAMEBUF); // bulk trim, amortized O(1) (was per-frame shift = O(n))
  166. dirty=true;
  167. }
  168. // Lazily fill in the hex strings only when a frame is rendered, filtered-by-hex, or exported -- never in the hot path.
  169. function ensureHex(e){ if(e.raw===undefined){ e.raw=hex(e.bytes); e.payload=hex(e.bytes.subarray(2)); } return e; }
  170. // Render the table on a display-rate tick instead of per frame: append only the frames newer than the last shown,
  171. // so DOM work is bounded by the screen refresh, not the 500+/s capture rate.
  172. function renderTick(){
  173. if(dirty){
  174. dirty=false;
  175. let i=allFrames.length-1; const fresh=[];
  176. while(i>=0 && allFrames[i].seq>lastShownSeq){ fresh.push(allFrames[i]); i--; }
  177. for(let k=fresh.length-1;k>=0;k--){ const e=fresh[k]; if(passesFilter(e)) addRow(e); }
  178. if(allFrames.length) lastShownSeq=allFrames[allFrames.length-1].seq;
  179. $("#npc").textContent=nPC; $("#ncp").textContent=nCP; $("#nbad").textContent=nBad; $("#nrec").textContent=rec.length;
  180. }
  181. requestAnimationFrame(renderTick);
  182. }
  183. requestAnimationFrame(renderTick);
  184. const rows=$("#rows");
  185. function passesFilter(e){
  186. if($("#fHideRoutine").checked){ // drop the 99% spam: input replies + bare polls
  187. if(e.dir==="C→P" && e.op==="f1" && e.len===49) return false;
  188. if(e.dir==="P→C" && e.op==="e3" && e.len<=1) return false;
  189. }
  190. const d=$("#fDir").value; if(d && e.dir!==d) return false;
  191. const op=$("#fOp").value.trim().toLowerCase(); if(op && e.op!==op) return false;
  192. const lf=$("#fLen").value;
  193. if(lf==="ne49" && e.len===49) return false;
  194. if(lf==="gt49" && e.len<=49) return false;
  195. if(lf==="gt1" && e.len<=1) return false;
  196. const hx=$("#filter").value.trim().toLowerCase().replace(/ /g,""); if(hx && !ensureHex(e).payload.replace(/ /g,"").includes(hx)) return false;
  197. return true;
  198. }
  199. function addRow(e){
  200. ensureHex(e);
  201. const tr=document.createElement("tr");
  202. tr.className=(e.dir==="P→C"?"pc":e.dir==="C→P"?"cp":"")+(e.crc?"":" bad");
  203. 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>`;
  204. rows.appendChild(tr);
  205. nShown++;
  206. while(rows.childElementCount>MAXROWS){ rows.removeChild(rows.firstChild); }
  207. $("#nshown").textContent=nShown;
  208. const w=$("#tbl"); if(w.scrollTop+w.clientHeight>=w.scrollHeight-40) w.scrollTop=w.scrollHeight;
  209. }
  210. function applyFilters(){ // re-render visible table from the buffer with current filters
  211. rows.innerHTML=""; nShown=0;
  212. for(const e of allFrames.slice(-MAXROWS)) if(passesFilter(e)) addRow(e);
  213. lastShownSeq=allFrames.length?allFrames[allFrames.length-1].seq:0; // keep renderTick appending after a manual re-render
  214. }
  215. for(const id of ["fHideRoutine","fDir","fOp","fLen","filter"]) $("#"+id).addEventListener("input",applyFilters);
  216. async function readLoop(){
  217. while(reading && dev){
  218. try{
  219. const r=await dev.transferIn(epIn,16384);
  220. if(r.status==="ok" && r.data.byteLength){
  221. const u=new Uint8Array(r.data.buffer);
  222. if(!gotData){ gotData=true; log("✓ receiving — first bytes: "+hex(u.slice(0,24))); }
  223. rxBytes+=u.length; $("#rx").textContent=(rxBytes<1024?rxBytes+" B":(rxBytes/1024).toFixed(1)+" KB");
  224. append(u); parse();
  225. }
  226. else if(r.status==="stall"){ await dev.clearHalt("in",epIn); }
  227. }catch(e){ if(dev) log("read err: "+e.message); break; }
  228. }
  229. }
  230. $("#connect").onclick=connect;
  231. $("#cap").onclick=async()=>{
  232. capturing=!capturing;
  233. $("#cap").classList.toggle("on",capturing);
  234. $("#cap").textContent=capturing?"■ End Cap":"● Start Cap";
  235. if(capturing){ rec=[]; t0=Date.now(); await cmd(0x01); log("CAP STARTED — recording"); }
  236. else{ await cmd(0x02); log(`CAP ENDED — ${rec.length} packets recorded`); }
  237. $("#nrec").textContent=rec.length;
  238. };
  239. $("#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; };
  240. $("#reacq").onclick=()=>{ $("#pinCh").value=""; cmd(0x03); log("re-acquire requested"); };
  241. $("#forget").onclick=()=>{ cmd(0x08); log("forgot all learned bonds — will relearn from the next E1"); };
  242. let surveying=false;
  243. $("#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"); };
  244. $("#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"); };
  245. $("#setch").onclick=()=>{ const c=parseInt($("#pinCh").value); if(c>=1&&c<=80){ cmd(0x04,c); log("pinned channel "+c); } };
  246. $("#setsess").onclick=()=>{ // "b0 b1 b2 b3 pfx ch" -- base/prefix as hex, channel decimal or hex. Read these
  247. 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)
  248. if(v.length!==6){ log("pin session needs 6 values: b0 b1 b2 b3 pfx ch"); return; }
  249. const b=v.slice(0,5).map(x=>parseInt(x,16)); const ch=parseInt(v[5], v[5].toLowerCase().startsWith("0x")?16:10);
  250. if(b.some(isNaN)||isNaN(ch)||ch<1||ch>80){ log("bad pin-session values"); return; }
  251. 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}`);
  252. };
  253. function save(name,text,type){ const a=document.createElement("a"); a.href=URL.createObjectURL(new Blob([text],{type})); a.download=name; a.click(); }
  254. $("#dl").onclick=()=>{
  255. // materialize hex + drop the raw byte array so the JSON matches the old shape (seq,ms,ch,dir,op,crc,rssi,len,raw,payload)
  256. const out=rec.map(e=>{ ensureHex(e); const{bytes,...r}=e; return r; });
  257. save("sniff-"+Date.now()+".json", JSON.stringify(out,null,1), "application/json");
  258. };
  259. $("#dltxt").onclick=()=>{
  260. 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}`);
  261. save("sniff-"+Date.now()+".txt", lines.join("\n"), "text/plain");
  262. };
  263. if(!navigator.usb) log("This browser has no WebUSB. Use Chrome/Edge over https or localhost.");
  264. </script>
  265. </body>
  266. </html>