sniffer.html 13 KB

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