sniffer.html 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  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. <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>
  48. <button id="setTgt" disabled>Lock</button>
  49. <label class="note">pin ch <input type="number" id="pinCh" min="1" max="80" placeholder="auto"></label>
  50. <button id="setch" disabled>Set</button>
  51. <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>
  52. <button id="setsess" disabled>Pin</button>
  53. </header>
  54. <div class="stats">
  55. <span>link: <b id="acq">—</b></span>
  56. <span>rx: <b id="rx">0 B</b></span>
  57. <span>session: <b id="sess">—</b></span>
  58. <span>E1 advertises: <b id="adv">—</b></span>
  59. <span>captured: <b id="nrec">0</b></span>
  60. <span>P→C: <b id="npc">0</b></span>
  61. <span>C→P: <b id="ncp">0</b></span>
  62. <span>crc-bad: <b id="nbad">0</b></span>
  63. <span>shown: <b id="nshown">0</b></span>
  64. </div>
  65. <div class="stats" style="border-top:none;padding-top:0">
  66. <label class="note"><input type="checkbox" id="fHideRoutine"> hide input/polls (49-byte F1 + bare E3)</label>
  67. <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>
  68. <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>
  69. <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>
  70. <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>
  71. <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>
  72. </div>
  73. <div id="tbl"><table><thead><tr>
  74. <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>
  75. </tr><tbody id="rows"></tbody></table></div>
  76. <div id="log"></div>
  77. <script>
  78. const $=s=>document.querySelector(s);
  79. let dev=null, epIn=0, epOut=0, ifNum=0, reading=false;
  80. let capturing=false, t0=0, rec=[], nPC=0, nCP=0, nBad=0;
  81. let acqState=-1, sessTxt="—", rxBytes=0, gotData=false;
  82. const MAXROWS=600; // live table cap (full record kept in `rec` for export)
  83. function log(m){ const l=$("#log"); l.textContent=(new Date().toLocaleTimeString()+" "+m+"\n"+l.textContent).slice(0,1500); }
  84. const hex=b=>Array.from(b,x=>x.toString(16).padStart(2,"0")).join(" ");
  85. async function connect(){
  86. try{
  87. dev=await navigator.usb.requestDevice({filters:[{vendorId:0x28DE}]});
  88. await dev.open();
  89. if(dev.configuration===null) await dev.selectConfiguration(1);
  90. let found=null;
  91. for(const itf of dev.configuration.interfaces){
  92. const a=itf.alternate; if(a.interfaceClass!==0xFF || a.interfaceSubClass===0x5D) continue;
  93. let bin=0,bout=0;
  94. for(const e of a.endpoints){ if(e.type==="bulk"){ if(e.direction==="in")bin=e.endpointNumber; else bout=e.endpointNumber; } }
  95. if(bin&&bout){ found={ifNum:itf.interfaceNumber,epIn:bin,epOut:bout}; break; }
  96. }
  97. if(!found){ log("no WebUSB bulk interface — is this the sniffer board (OpenPuck Sniffer)?"); return; }
  98. ifNum=found.ifNum; epIn=found.epIn; epOut=found.epOut;
  99. await dev.claimInterface(ifNum);
  100. await dev.controlTransferOut({requestType:"class",recipient:"interface",request:0x22,value:0x01,index:ifNum});
  101. $("#state").textContent="connected"; $("#state").className="pill up"; $("#connect").textContent="Reconnect";
  102. for(const id of ["cap","clear","dl","dltxt","reacq","survey","setTgt","setch","setsess"]) $("#"+id).disabled=false;
  103. navigator.usb.addEventListener("disconnect",e=>{ if(e.device===dev) onGone(); });
  104. log(`connected — iface ${ifNum} epIn ${epIn} epOut ${epOut} — waiting for stream…`);
  105. rxBytes=0; gotData=false;
  106. 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);
  107. reading=true; readLoop();
  108. }catch(e){ log("connect failed: "+e.message); }
  109. }
  110. 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"); }
  111. async function cmd(...b){ if(dev) try{ await dev.transferOut(epOut,new Uint8Array(b)); }catch(e){ log("write err: "+e.message); } }
  112. let acc=new Uint8Array(0);
  113. function append(buf){ const m=new Uint8Array(acc.length+buf.length); m.set(acc); m.set(buf,acc.length); acc=m; }
  114. function parse(){
  115. let i=0;
  116. while(i+2<=acc.length){
  117. if(acc[i]===0xC1 && acc[i+1]===0xDE){ // status (19 bytes)
  118. if(i+19>acc.length) break;
  119. acqState=acc[i+2];
  120. const ch=acc[i+3], b=acc.slice(i+4,i+8), pfx=acc[i+8];
  121. const advCh=acc[i+12], ab=acc.slice(i+13,i+17), apfx=acc[i+17], lastMatch=acc[i+18];
  122. sessTxt=`ch ${ch} addr ${hex(b)}/${pfx.toString(16).padStart(2,"0")} (last pipe ${lastMatch})`;
  123. $("#acq").textContent=acqState===1?"CAPTURE (session locked)":"ACQUIRE (scanning ibex/ch2)";
  124. $("#acq").style.color=acqState===1?"#3fb950":"#d29922";
  125. $("#sess").textContent=sessTxt;
  126. $("#adv").textContent = advCh ? `ch ${advCh} addr ${hex(ab)}/${apfx.toString(16).padStart(2,"0")}` : "— (no E1 yet)";
  127. i+=19; continue;
  128. }
  129. if(acc[i]===0xC0 && acc[i+1]===0xDE){ // packet
  130. if(i+3>acc.length) break;
  131. const n=acc[i+2]; if(n>98){ i++; continue; } // bad len -> resync (frames up to MAXLEN+2)
  132. if(i+11+n>acc.length) break;
  133. const t=acc[i+3]|(acc[i+4]<<8)|(acc[i+5]<<16)|(acc[i+6]*16777216);
  134. const ch=acc[i+7], crc=acc[i+8]&1, rssi=acc[i+9], match=acc[i+10];
  135. const raw=acc.slice(i+11,i+11+n);
  136. onPacket(t,ch,crc,rssi,match,raw);
  137. i+=11+n; continue;
  138. }
  139. i++; // resync
  140. }
  141. acc=acc.slice(i);
  142. }
  143. let allFrames=[], seqCtr=0, nShown=0; // ring of recent frames so filters can re-render; seq = stable # column
  144. const FRAMEBUF=8000;
  145. function onPacket(t_us,ch,crc,rssi,match,raw){
  146. // raw = [LENGTH, S1, payload...]; opcode = raw[2]; dir from opcode high nibble
  147. const op=raw.length>2?raw[2]:0;
  148. const dir=(op&0xF0)===0xE0?"P→C":(op&0xF0)===0xF0?"C→P":"?";
  149. if(dir==="P→C")nPC++; else if(dir==="C→P")nCP++; if(!crc)nBad++;
  150. const payload=raw.slice(2); // from opcode onward (what we care about)
  151. 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)};
  152. if(capturing) rec.push(ent);
  153. allFrames.push(ent); if(allFrames.length>FRAMEBUF) allFrames.shift();
  154. if(passesFilter(ent)) addRow(ent);
  155. $("#npc").textContent=nPC; $("#ncp").textContent=nCP; $("#nbad").textContent=nBad; $("#nrec").textContent=rec.length;
  156. }
  157. const rows=$("#rows");
  158. function passesFilter(e){
  159. if($("#fHideRoutine").checked){ // drop the 99% spam: input replies + bare polls
  160. if(e.dir==="C→P" && e.op==="f1" && e.len===49) return false;
  161. if(e.dir==="P→C" && e.op==="e3" && e.len<=1) return false;
  162. }
  163. const d=$("#fDir").value; if(d && e.dir!==d) return false;
  164. const op=$("#fOp").value.trim().toLowerCase(); if(op && e.op!==op) return false;
  165. const lf=$("#fLen").value;
  166. if(lf==="ne49" && e.len===49) return false;
  167. if(lf==="gt49" && e.len<=49) return false;
  168. if(lf==="gt1" && e.len<=1) return false;
  169. const hx=$("#filter").value.trim().toLowerCase().replace(/ /g,""); if(hx && !e.payload.replace(/ /g,"").includes(hx)) return false;
  170. return true;
  171. }
  172. function addRow(e){
  173. const tr=document.createElement("tr");
  174. tr.className=(e.dir==="P→C"?"pc":e.dir==="C→P"?"cp":"")+(e.crc?"":" bad");
  175. 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>`;
  176. rows.appendChild(tr);
  177. nShown++;
  178. while(rows.childElementCount>MAXROWS){ rows.removeChild(rows.firstChild); }
  179. $("#nshown").textContent=nShown;
  180. const w=$("#tbl"); if(w.scrollTop+w.clientHeight>=w.scrollHeight-40) w.scrollTop=w.scrollHeight;
  181. }
  182. function applyFilters(){ // re-render visible table from the buffer with current filters
  183. rows.innerHTML=""; nShown=0;
  184. for(const e of allFrames.slice(-MAXROWS)) if(passesFilter(e)) addRow(e);
  185. }
  186. for(const id of ["fHideRoutine","fDir","fOp","fLen","filter"]) $("#"+id).addEventListener("input",applyFilters);
  187. async function readLoop(){
  188. while(reading && dev){
  189. try{
  190. const r=await dev.transferIn(epIn,16384);
  191. if(r.status==="ok" && r.data.byteLength){
  192. const u=new Uint8Array(r.data.buffer);
  193. if(!gotData){ gotData=true; log("✓ receiving — first bytes: "+hex(u.slice(0,24))); }
  194. rxBytes+=u.length; $("#rx").textContent=(rxBytes<1024?rxBytes+" B":(rxBytes/1024).toFixed(1)+" KB");
  195. append(u); parse();
  196. }
  197. else if(r.status==="stall"){ await dev.clearHalt("in",epIn); }
  198. }catch(e){ if(dev) log("read err: "+e.message); break; }
  199. }
  200. }
  201. $("#connect").onclick=connect;
  202. $("#cap").onclick=async()=>{
  203. capturing=!capturing;
  204. $("#cap").classList.toggle("on",capturing);
  205. $("#cap").textContent=capturing?"■ End Cap":"● Start Cap";
  206. if(capturing){ rec=[]; t0=Date.now(); await cmd(0x01); log("CAP STARTED — recording"); }
  207. else{ await cmd(0x02); log(`CAP ENDED — ${rec.length} packets recorded`); }
  208. $("#nrec").textContent=rec.length;
  209. };
  210. $("#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; };
  211. $("#reacq").onclick=()=>{ $("#pinCh").value=""; cmd(0x03); log("re-acquire requested"); };
  212. let surveying=false;
  213. $("#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"); };
  214. $("#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"); };
  215. $("#setch").onclick=()=>{ const c=parseInt($("#pinCh").value); if(c>=1&&c<=80){ cmd(0x04,c); log("pinned channel "+c); } };
  216. $("#setsess").onclick=()=>{ // "b0 b1 b2 b3 pfx ch" -- base/prefix as hex, channel decimal or hex. Read these
  217. 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)
  218. if(v.length!==6){ log("pin session needs 6 values: b0 b1 b2 b3 pfx ch"); return; }
  219. const b=v.slice(0,5).map(x=>parseInt(x,16)); const ch=parseInt(v[5], v[5].toLowerCase().startsWith("0x")?16:10);
  220. if(b.some(isNaN)||isNaN(ch)||ch<1||ch>80){ log("bad pin-session values"); return; }
  221. 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}`);
  222. };
  223. function save(name,text,type){ const a=document.createElement("a"); a.href=URL.createObjectURL(new Blob([text],{type})); a.download=name; a.click(); }
  224. $("#dl").onclick=()=>save("sniff-"+Date.now()+".json", JSON.stringify(rec,null,1), "application/json");
  225. $("#dltxt").onclick=()=>{
  226. 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}`);
  227. save("sniff-"+Date.now()+".txt", lines.join("\n"), "text/plain");
  228. };
  229. if(!navigator.usb) log("This browser has no WebUSB. Use Chrome/Edge over https or localhost.");
  230. </script>
  231. </body>
  232. </html>