index.html 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383
  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 Config</title>
  7. <style>
  8. :root{ --bg:#11131a; --card:#1b1f2b; --fg:#e7ecf3; --mut:#8a93a6; --acc:#5bd6a0; --acc2:#5b9bd6; --bad:#d65b6e; }
  9. *{box-sizing:border-box}
  10. body{margin:0;font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--fg);padding:24px}
  11. h1{font-size:20px;margin:0 0 4px}
  12. .sub{color:var(--mut);margin:0 0 20px}
  13. .wrap{max-width:680px;margin:0 auto}
  14. .card{background:var(--card);border:1px solid #272d3d;border-radius:12px;padding:18px 20px;margin-bottom:16px}
  15. .card h2{font-size:13px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);margin:0 0 14px}
  16. .row{display:flex;align-items:center;gap:14px;margin:12px 0}
  17. .row label{flex:0 0 150px;color:var(--fg)}
  18. .row .val{flex:0 0 64px;text-align:right;color:var(--acc);font-variant-numeric:tabular-nums}
  19. input[type=range]{flex:1;accent-color:var(--acc)}
  20. select{background:#0e1017;color:var(--fg);border:1px solid #313a4f;border-radius:8px;padding:6px 8px;flex:1}
  21. button{background:#222a3a;color:var(--fg);border:1px solid #36405a;border-radius:8px;padding:9px 14px;cursor:pointer;font:inherit}
  22. button:hover{border-color:var(--acc2)}
  23. button.primary{background:var(--acc2);border-color:var(--acc2);color:#06121f;font-weight:600}
  24. button.active{background:var(--acc);border-color:var(--acc);color:#062018;font-weight:600}
  25. button:disabled{opacity:.4;cursor:default}
  26. .pill{display:inline-block;padding:2px 9px;border-radius:99px;font-size:12px;font-weight:600}
  27. .pill.up{background:#143626;color:var(--acc)} .pill.dn{background:#3a1822;color:var(--bad)}
  28. .statgrid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}
  29. .stat{background:#0e1017;border:1px solid #232a3a;border-radius:8px;padding:10px}
  30. .stat .k{color:var(--mut);font-size:11px;text-transform:uppercase;letter-spacing:.05em}
  31. .stat .v{font-size:18px;margin-top:2px}
  32. .modes{display:flex;gap:10px}
  33. .note{color:var(--mut);font-size:12px;margin-top:10px}
  34. #log{font:12px ui-monospace,Menlo,monospace;color:var(--mut);white-space:pre-wrap;margin-top:8px;max-height:120px;overflow:auto}
  35. .hide{display:none}
  36. </style>
  37. </head>
  38. <body>
  39. <div class="wrap">
  40. <h1>OpenPuck Config</h1>
  41. <p class="sub">nRF52840 Steam Controller 2 puck reimplementation — WebUSB. Chrome/Edge only.</p>
  42. <div class="card" id="connectCard">
  43. <div class="row" style="margin:0">
  44. <button id="connectBtn" class="primary">Connect to copycat</button>
  45. <span id="connState" class="pill dn">disconnected</span>
  46. </div>
  47. <p class="note">If nothing appears in the device picker, the copycat may be busy (quit any app holding it) or needs a replug.</p>
  48. </div>
  49. <div id="panel" class="hide">
  50. <div class="card">
  51. <h2>USB mode</h2>
  52. <div class="modes" style="flex-wrap:wrap">
  53. <button data-mode="0" class="modebtn">Steam (puck)</button>
  54. <button data-mode="1" class="modebtn">Xbox 360</button>
  55. <button data-mode="2" class="modebtn">Switch (HORIPAD)</button>
  56. <button data-mode="3" class="modebtn">Lizard (always)</button>
  57. <button data-mode="4" class="modebtn">Switch Pro + gyro</button>
  58. <button data-mode="5" class="modebtn">PS5 DualSense</button>
  59. <button data-mode="6" class="modebtn">HID gyro (DS4)</button>
  60. </div>
  61. <p class="note">Switching reboots the copycat and re-enumerates USB (~2 s). WebUSB works in every mode, including Steam/Lizard with Steam running (Chrome claims the vendor interface; Steam keeps the HID slots). Switch HORIPAD + WebUSB is PC-only.</p>
  62. <div class="row"><label>Persist last mode</label>
  63. <button id="persistMode">off</button>
  64. <span class="note" style="flex:1">Off (default): every restart / fresh reconnect boots into Steam mode. On: remembers the last mode you selected.</span>
  65. </div>
  66. </div>
  67. <div class="card">
  68. <h2>Back4 + face button chords</h2>
  69. <p class="note" style="margin-top:0">Hold all four back paddles (L4+R4+L5+R5) plus a face button to switch mode without the WebUI. <strong>A is always Steam.</strong> B / X / Y are configurable below (defaults: B=Lizard, X=Xbox, Y=Switch HORIPAD).</p>
  70. <div class="row"><label>B + back4 →</label><select class="chord" data-i="0"></select></div>
  71. <div class="row"><label>X + back4 →</label><select class="chord" data-i="1"></select></div>
  72. <div class="row"><label>Y + back4 →</label><select class="chord" data-i="2"></select></div>
  73. </div>
  74. <div class="card">
  75. <h2>Link status</h2>
  76. <div class="statgrid">
  77. <div class="stat"><div class="k">RF link</div><div class="v" id="stLink">—</div></div>
  78. <div class="stat"><div class="k">Battery</div><div class="v" id="stBatt">—</div></div>
  79. <div class="stat"><div class="k">Signal</div><div class="v" id="stRssi">—</div></div>
  80. <div class="stat"><div class="k">Slot</div><div class="v" id="stSlot">—</div></div>
  81. <div class="stat"><div class="k">Delivered</div><div class="v" id="stRate">—</div></div>
  82. <div class="stat"><div class="k">New reports</div><div class="v" id="stNew">—</div></div>
  83. <div class="stat logonly"><div class="k">Polls/s</div><div class="v" id="stPolls">—</div></div>
  84. <div class="stat logonly"><div class="k">Loop period</div><div class="v" id="stLoop">—</div></div>
  85. <div class="stat logonly"><div class="k">Slowest stage</div><div class="v" id="stWorst">—</div></div>
  86. <div class="stat logonly"><div class="k">Poll period act/want</div><div class="v" id="stPollPer">—</div></div>
  87. <div class="stat"><div class="k">Build (git)</div><div class="v" id="stBuild">—</div></div>
  88. </div>
  89. <p class="note logonly">Polls/s ≈ 250 expected; it's capped by Loop period (≈4000 µs needed). "Slowest stage" is the loop section eating the most time per iteration.</p>
  90. <div class="row" style="margin:10px 0 0"><button id="hapClear">Clear stuck buzz</button>
  91. <span class="note" style="flex:1">Manually (re)arm the buzz-clear flood now: replays the haptic re-init at 10&nbsp;Hz for 30&nbsp;s (same as pressing Steam). Use it if a buzz shows up right now.</span>
  92. </div>
  93. <div class="row" style="margin:10px 0 0"><label>Connect-time flood</label><button id="buzzFlood">off</button>
  94. <span class="note" style="flex:1">Floods the buzz-clear re-init at 10&nbsp;Hz for 30&nbsp;s after every connect — the proven fix for the connect-time stuck buzz. <strong>On by default</strong>; only turn it off if you're testing. Persists across reboots.</span>
  95. </div>
  96. <div class="row" style="margin:10px 0 0"><button id="ctlrOff">Turn off controller (test)</button>
  97. <span class="note" style="flex:1">Fires the controller power-off (0x9F "off!", x3) — the same path host-suspend uses. Watch the capture / link status to see if the controller actually disconnects.</span>
  98. </div>
  99. <div class="row" style="margin:18px 0 0;border-top:1px solid #4a2030;padding-top:14px">
  100. <button id="factoryErase" style="background:#3a1420;border-color:#7a2233;color:#ff9bb0;font-weight:600">⚠ Factory erase</button>
  101. <span class="note" style="flex:1"><strong style="color:#ff9bb0">Danger:</strong> wipes <em>all</em> persistent storage on the copycat — the paired controller bond <em>and</em> every saved setting (mode, chords, back-paddle map, sensitivity). The copycat reboots to factory defaults and you must <strong>re-pair the controller</strong>. This cannot be undone.</span>
  102. </div>
  103. </div>
  104. <div class="card" id="mouseCard">
  105. <h2>Trackpad mouse (lizard + Xbox)</h2>
  106. <div class="row"><label>Sensitivity</label><input type="range" id="mDiv" min="4" max="200" step="1"><span class="val" id="mDivV"></span></div>
  107. <div class="row"><label>Glide / friction</label><input type="range" id="mFric" min="0" max="99" step="1"><span class="val" id="mFricV"></span></div>
  108. <p class="note">Right-pad cursor in Steam-mode lizard (Steam closed) and in Xbox mode. Sensitivity is a divisor — lower = faster pointer. Glide = how long the cursor coasts after a flick.</p>
  109. </div>
  110. <div class="card">
  111. <h2>Button mapping</h2>
  112. <div class="row"><label>A/B + X/Y swap</label>
  113. <button id="abSwap">off</button>
  114. <span class="note" style="flex:1">Nintendo layout (affects Xbox &amp; Switch modes)</span>
  115. </div>
  116. <div class="row"><label>L4 (back upper-left)</label><select class="back" data-i="0"></select></div>
  117. <div class="row"><label>R4 (back upper-right)</label><select class="back" data-i="1"></select></div>
  118. <div class="row"><label>L5 (back lower-left)</label><select class="back" data-i="2"></select></div>
  119. <div class="row"><label>R5 (back lower-right)</label><select class="back" data-i="3"></select></div>
  120. <p class="note">Back-paddle → button, applied in Xbox &amp; Switch modes.</p>
  121. </div>
  122. <div class="card logonly">
  123. <h2>Capture — host → controller commands</h2>
  124. <p class="note" style="margin-top:0">The firmware logs everything from boot into a big RAM ring: Steam's writes (<code>ifN</code>), the frames we transmit to the controller (<code>TX→ctlr</code>), and RF <code>LINK UP/DOWN</code> edges. For the reconnect buzz: trigger it (it appears moments after the puck boots / the controller reconnects), then connect here and click <b>Dump from boot</b> — the trigger is still in the ring. (Only present in a logging build, <code>-DOPK_LOG=1</code>.)</p>
  125. <div class="row" style="margin:0;flex-wrap:wrap">
  126. <button id="capStart" class="primary">Dump from boot</button>
  127. <button id="capStop" disabled>Stop</button>
  128. <button id="capDl">Download .txt</button>
  129. </div>
  130. <p class="note">Dump streams the whole ring (oldest→newest) and then keeps live-updating until you press Stop. <code>cmd</code> is the command/report byte, then the raw bytes.</p>
  131. <pre id="capOut" style="font:12px ui-monospace,Menlo,monospace;color:var(--fg);background:#0e1017;border:1px solid #232a3a;border-radius:8px;padding:10px;margin-top:10px;max-height:260px;overflow:auto;white-space:pre-wrap"></pre>
  132. </div>
  133. <div id="log"></div>
  134. </div>
  135. </div>
  136. <script>
  137. const MODE_NAMES = ["Steam (puck)","Xbox 360","Switch (HORIPAD)","Lizard (always)","Switch Pro + gyro","PS5 DualSense","HID gyro (DS4)"];
  138. const CHORD_FIELD = [17, 18, 19];
  139. const BTN_CODES = [[0,"— none —"],[1,"A"],[2,"B"],[3,"X"],[4,"Y"],[5,"LB"],[6,"RB"],[7,"L3"],[8,"R3"],[9,"Back"],[10,"Start"],[11,"Guide"],[12,"D-pad Up"],[13,"D-pad Down"],[14,"D-pad Left"],[15,"D-pad Right"]];
  140. let dev=null, epIn=0, epOut=0, ifNum=0, polling=false, capturing=false;
  141. const $ = s=>document.querySelector(s);
  142. function log(m){ const l=$("#log"); l.textContent=(new Date().toLocaleTimeString()+" "+m+"\n"+l.textContent).slice(0,2000); }
  143. // populate back-paddle + chord dropdowns
  144. for(const sel of document.querySelectorAll("select.back")){
  145. for(const [c,n] of BTN_CODES){ const o=document.createElement("option"); o.value=c; o.textContent=n; sel.appendChild(o); }
  146. }
  147. for(const sel of document.querySelectorAll("select.chord")){
  148. MODE_NAMES.forEach((n,i)=>{ const o=document.createElement("option"); o.value=i; o.textContent=n; sel.appendChild(o); });
  149. }
  150. async function connect(){
  151. try{
  152. dev = await navigator.usb.requestDevice({filters:[{vendorId:0x28DE},{vendorId:0x045E},{vendorId:0x057E},{vendorId:0x0F0D},{vendorId:0x054C}]});
  153. await dev.open();
  154. if(dev.configuration===null) await dev.selectConfiguration(1);
  155. // WebUSB = vendor class 0xFF with bulk IN + bulk OUT (skip XInput 0xFF/5D/01 which uses interrupt only)
  156. let found=null;
  157. for(const itf of dev.configuration.interfaces){
  158. const a=itf.alternate;
  159. if(a.interfaceClass!==0xFF) continue;
  160. if(a.interfaceSubClass===0x5D) continue; // Xbox XInput vendor
  161. let bin=0,bout=0;
  162. for(const e of a.endpoints){ if(e.type==="bulk"){ if(e.direction==="in")bin=e.endpointNumber; else bout=e.endpointNumber; } }
  163. if(bin&&bout){ found={ifNum:itf.interfaceNumber, epIn:bin, epOut:bout}; break; }
  164. }
  165. if(!found){ log("no WebUSB bulk vendor interface — reflash firmware with WebUSB enabled, or another app may be holding the device"); return; }
  166. ifNum=found.ifNum; epIn=found.epIn; epOut=found.epOut;
  167. await dev.claimInterface(ifNum);
  168. // WebUSB "connect" handshake: Adafruit_USBD_WebUSB only enables write() / connected()
  169. // after the host sends SET_CONTROL_LINE_STATE (bRequest 0x22, value=1) on this interface.
  170. await dev.controlTransferOut({requestType:"class", recipient:"interface", request:0x22, value:0x01, index:ifNum});
  171. $("#connState").textContent="connected"; $("#connState").className="pill up";
  172. $("#connectBtn").textContent="Reconnect";
  173. $("#panel").classList.remove("hide");
  174. log(`connected — VID ${dev.vendorId.toString(16)} iface ${ifNum} epIn ${epIn} epOut ${epOut}`);
  175. dev.onUSBConnectionEvent;
  176. navigator.usb.addEventListener("disconnect", e=>{ if(e.device===dev){ onGone(); }});
  177. startPolling();
  178. }catch(e){ log("connect failed: "+e.message); }
  179. }
  180. function onGone(){
  181. polling=false; dev=null;
  182. $("#connState").textContent="disconnected"; $("#connState").className="pill dn";
  183. $("#panel").classList.add("hide");
  184. log("device disconnected (likely a mode-switch reboot — reconnect)");
  185. }
  186. async function send(bytes){
  187. if(!dev) return;
  188. try{ await dev.transferOut(epOut, new Uint8Array(bytes)); }
  189. catch(e){ log("write err: "+e.message); }
  190. }
  191. async function readBlob(){
  192. try{
  193. const r = await dev.transferIn(epIn, 64);
  194. if(r.status!=="ok"||r.data.byteLength<2) return null;
  195. const d=new Uint8Array(r.data.buffer);
  196. // find 0xA5 frame
  197. let i=0; while(i<d.length && d[i]!==0xA5) i++;
  198. if(i+2>d.length) return null;
  199. const len=d[i+1]; const p=d.slice(i+2, i+2+len);
  200. return p.length>=12 ? p : null;
  201. }catch(e){ if(dev) log("read err: "+e.message); return null; }
  202. }
  203. function applyBlob(p){
  204. // [ver][mode][mDiv][mFric][rsvd][abSwap][b0..b3][slot][up][f1lo][f1hi][pollU100(unused)][newlo][newhi]
  205. const mode=p[1], mDiv=p[2], mFric=p[3], abSwap=p[5]; // p[4] reserved (ex-padSmooth)
  206. const back=[p[6],p[7],p[8],p[9]], slot=p[10], up=p[11], f1=(p[12]|(p[13]<<8));
  207. const newps=(p.length>16?(p[15]|(p[16]<<8)):0), persistMode=(p.length>22?p[22]:0);
  208. const chord=(p.length>25)?[p[23],p[24],p[25]]:[3,1,2]; // v2 blob: chordBtn after qos/persist
  209. const polls=(p.length>27?(p[26]|(p[27]<<8)):0); // poll TX rate
  210. const loopUs=(p.length>29?(p[28]|(p[29]<<8)):0); // loop diag
  211. const worstIdx=(p.length>30?p[30]:0), worstUs=(p.length>32?(p[31]|(p[32]<<8)):0);
  212. const WORST=["webusb","ctrl.task","serial","rfdiag","rflink","haptic","led"];
  213. const pollUs=(p.length>34?(p[33]|(p[34]<<8)):0); // MEASURED poll period
  214. const pollIntended=(p.length>14?p[14]*100:0); // intended poll interval (g_pollUs)
  215. const logEnabled=(p.length>35?p[35]:0); // is this a logging build (-DOPK_LOG=1)?
  216. for(const el of document.querySelectorAll(".logonly")) el.style.display = logEnabled ? "" : "none";
  217. // mode buttons
  218. for(const b of document.querySelectorAll(".modebtn")) b.classList.toggle("active", +b.dataset.mode===mode);
  219. $("#mouseCard").style.opacity = (mode===1||mode===3)?1:0.5;
  220. // status
  221. const battery=(p.length>36?p[36]:0); // controller battery % (report 0x43); 0 = unknown
  222. const rssi=(p.length>37?p[37]:0); // RAW signal strength |dBm| (0 = no sample)
  223. $("#stLink").innerHTML = up? '<span class="pill up">up</span>' : '<span class="pill dn">down</span>';
  224. $("#stBatt").textContent = (up && battery)? battery+"%" : "—";
  225. $("#stRssi").textContent = (up && rssi)? ("-"+rssi+" dBm") : "—";
  226. $("#stSlot").textContent = slot===0xFF? "—" : slot;
  227. $("#stRate").textContent = up? f1+" /s" : "—";
  228. $("#stNew").textContent = up? newps+" /s" : "—";
  229. $("#stPolls").textContent = up? polls+" /s" : "—";
  230. $("#stLoop").textContent = loopUs? loopUs+" µs" : "—";
  231. $("#stWorst").textContent = loopUs? (WORST[worstIdx]||worstIdx)+" "+worstUs+"µs" : "—";
  232. $("#stPollPer").textContent = up? (pollUs+" / "+pollIntended+" µs") : "—";
  233. // build provenance (protocol v4+): dirty flag at p[38], 12-byte NUL-padded git hash at p[39..50]. Independent
  234. // of link state. Older firmware sends a shorter blob -> stays "—".
  235. const gitDirty=(p.length>38?p[38]:0); let gitHash="";
  236. for(let i=39;i<51 && i<p.length && p[i];i++) gitHash+=String.fromCharCode(p[i]);
  237. $("#stBuild").innerHTML = gitHash
  238. ? (gitHash + (gitDirty ? ' <span class="pill dn">dirty</span>' : ' <span class="pill up">clean</span>'))
  239. : "—";
  240. // sliders (only if not being dragged)
  241. setSlider("mDiv",mDiv); setSlider("mFric",mFric);
  242. $("#abSwap").textContent = abSwap? "on":"off"; $("#abSwap").classList.toggle("active",!!abSwap);
  243. $("#persistMode").textContent = persistMode? "on":"off"; $("#persistMode").classList.toggle("active",!!persistMode);
  244. const buzzFlood=(p.length>51?p[51]:0); // connect-time buzz-clear flood toggle (protocol v5+)
  245. $("#buzzFlood").textContent = buzzFlood? "on":"off"; $("#buzzFlood").classList.toggle("active",!!buzzFlood);
  246. for(const sel of document.querySelectorAll("select.back")){ if(document.activeElement!==sel) sel.value=back[+sel.dataset.i]; }
  247. for(const sel of document.querySelectorAll("select.chord")){ if(document.activeElement!==sel) sel.value=chord[+sel.dataset.i]; }
  248. }
  249. function fmtSlider(id,v){ return ""+v; }
  250. function setSlider(id,v){ const el=$("#"+id); if(document.activeElement!==el){ el.value=v; } $("#"+id+"V").textContent=fmtSlider(id,el.value); }
  251. let inflight=false;
  252. async function refresh(){
  253. if(!dev||inflight||capturing) return; inflight=true; // don't let the status poll steal capture frames
  254. await send([0x01]); const p=await readBlob(); if(p) applyBlob(p);
  255. inflight=false;
  256. }
  257. async function setField(field,value){
  258. await send([0x02, field, value&0xff]); const p=await readBlob(); if(p) applyBlob(p);
  259. }
  260. function fmtCapEntry(e,maxMs){
  261. const hex=e.bytes.map(x=>x.toString(16).padStart(2,"0")).join(" ");
  262. const t = `-${String(maxMs-e.ms).padStart(6," ")}ms `; // age vs the newest entry (consistent across drain batches)
  263. if(e.slot===0xFD){ const k=e.bytes[0]; return `${t}════════ ${k===2?"RECONNECT (block+reinit armed)":k===1?"RF LINK UP":"RF LINK DOWN"} ════════`; }
  264. const who = (e.slot===0xFE) ? "TX→ctlr " : `if${e.slot} `; // 0xFE = frame WE sent the controller
  265. return `${t}${who} cmd=${e.rid.toString(16).padStart(2,"0")} n=${e.nb}: ${hex}`;
  266. }
  267. function downloadCap(){
  268. const t=window._lastCap||$("#capOut").textContent||"";
  269. const a=document.createElement("a");
  270. a.href=URL.createObjectURL(new Blob([t],{type:"text/plain"}));
  271. a.download="puck-capture.txt"; a.click();
  272. }
  273. // ---- live Start/Stop capture: poll the firmware's drain (op 0x06) on a timer and accumulate in the browser ----
  274. let liveTimer=null, draining=false, capLines=[]; // capLines = array of {ms,slot,rid,nb,bytes}
  275. function renderCap(){
  276. if(!capLines.length){ $("#capOut").textContent="(nothing captured yet)"; window._lastCap=""; return; }
  277. const maxMs=capLines[capLines.length-1].ms; // entries are chronological -> newest is last
  278. const t=capLines.map(e=>fmtCapEntry(e,maxMs)).join("\n");
  279. $("#capOut").textContent=t; window._lastCap=t;
  280. }
  281. async function drainTick(){
  282. if(!dev||draining) return; draining=true;
  283. let acc=new Uint8Array(0);
  284. try{
  285. await send([0x06]);
  286. let done=false, guard=0;
  287. while(!done && guard++<400){
  288. const r=await dev.transferIn(epIn,64);
  289. if(r.status!=="ok") break;
  290. const d=new Uint8Array(r.data.buffer);
  291. const m=new Uint8Array(acc.length+d.length); m.set(acc); m.set(d,acc.length); acc=m;
  292. let i=0;
  293. while(i<acc.length){
  294. if(acc[i]!==0xA6){ i++; continue; }
  295. if(i+2>acc.length) break;
  296. const L=acc[i+1]; if(i+2+L>acc.length) break;
  297. const f=acc.slice(i+2,i+2+L); i+=2+L;
  298. if(f[0]===0){ done=true; break; }
  299. if(f[0]===1 && L>=8){
  300. const ms=((f[1])|(f[2]<<8)|(f[3]<<16)|(f[4]<<24))>>>0;
  301. capLines.push({ms, slot:f[5], rid:f[6], nb:f[7], bytes:[...f.slice(8,8+f[7])]});
  302. }
  303. }
  304. acc=acc.slice(i);
  305. }
  306. }catch(e){ log("drain err: "+e.message); }
  307. draining=false;
  308. if(liveTimer){ const el=$("#capOut"); const atBottom=el.scrollTop+el.clientHeight>=el.scrollHeight-4; renderCap(); if(atBottom) el.scrollTop=el.scrollHeight; }
  309. }
  310. async function startCapture(){
  311. if(!dev||capturing) return;
  312. capturing=true; capLines=[]; // capturing=true pauses the status poll so it can't steal frames
  313. $("#capOut").textContent="(dumping ring from boot, then live — press Stop when done)";
  314. $("#capStart").disabled=true; $("#capStop").disabled=false;
  315. await send([0x05,1]); // rewind drain to OLDEST -> dumps the whole ring from boot
  316. liveTimer=setInterval(drainTick,100);
  317. }
  318. async function stopCapture(){
  319. if(liveTimer){ clearInterval(liveTimer); liveTimer=null; }
  320. await drainTick(); // final flush
  321. try{ await send([0x05,0]); }catch(e){}
  322. capturing=false;
  323. $("#capStart").disabled=false; $("#capStop").disabled=true;
  324. renderCap(); log(`capture stopped — ${capLines.length} entries`);
  325. }
  326. async function startPolling(){
  327. polling=true; await refresh();
  328. while(polling && dev){ await new Promise(r=>setTimeout(r,600)); await refresh(); }
  329. }
  330. // UI wiring
  331. $("#connectBtn").onclick=connect;
  332. $("#capDl").onclick=downloadCap;
  333. $("#capStart").onclick=startCapture;
  334. $("#capStop").onclick=stopCapture;
  335. let buzzFloodTimer=null;
  336. $("#hapClear").onclick=async()=>{ if(dev){ await send([0x07]); $("#hapClear").classList.add("active"); clearTimeout(buzzFloodTimer); buzzFloodTimer=setTimeout(()=>$("#hapClear").classList.remove("active"),30000); log("buzz-clear flood armed — 10Hz re-init for 30s (click again to restart)"); } };
  337. $("#ctlrOff").onclick=async()=>{ if(dev){ await send([0x08]); log("controller power-off attempt sent — watch link status / capture"); } };
  338. $("#factoryErase").onclick=async()=>{
  339. if(!dev){ log("not connected"); return; }
  340. // Triple gate so this is impossible to fire accidentally: warn, warn harder, then require typing ERASE.
  341. if(!confirm("Factory erase?\n\nThis wipes ALL persistent storage on the copycat:\n • the paired-controller bond (you'll have to re-pair)\n • every saved setting (mode, chords, back paddles, sensitivity)\n\nThe copycat reboots to factory defaults. This CANNOT be undone.")) return;
  342. if(!confirm("Are you absolutely sure?\n\nThere is no recovery. The controller bond and all settings will be gone.")) return;
  343. const typed=prompt('Final confirmation — type ERASE (all caps) to wipe everything:');
  344. if(typed!=="ERASE"){ log("factory erase cancelled (confirmation text did not match)"); return; }
  345. // op 0x0A + magic "ERS" (0x45,0x52,0x53) -- firmware reformats the FS and reboots; the device drops off USB.
  346. await send([0x0A,0x45,0x52,0x53]);
  347. log("FACTORY ERASE sent — copycat is reformatting and rebooting to defaults. Re-pair the controller, then reconnect.");
  348. };
  349. $("#abSwap").onclick=()=>{ const on=$("#abSwap").classList.contains("active"); setField(4, on?0:1); };
  350. $("#persistMode").onclick=()=>{ const on=$("#persistMode").classList.contains("active"); setField(16, on?0:1); };
  351. $("#buzzFlood").onclick=()=>{ const on=$("#buzzFlood").classList.contains("active"); setField(21, on?0:1); };
  352. for(const id of ["mDiv","mFric"]){
  353. const field={mDiv:1,mFric:2}[id];
  354. $("#"+id).addEventListener("input", ()=>{ $("#"+id+"V").textContent=fmtSlider(id,$("#"+id).value); });
  355. $("#"+id).addEventListener("change", ()=>setField(field, +$("#"+id).value));
  356. }
  357. for(const sel of document.querySelectorAll("select.back")){
  358. sel.addEventListener("change", ()=>setField(5 + +sel.dataset.i, +sel.value));
  359. }
  360. for(const sel of document.querySelectorAll("select.chord")){
  361. sel.addEventListener("change", ()=>setField(CHORD_FIELD[+sel.dataset.i], +sel.value));
  362. }
  363. for(const b of document.querySelectorAll(".modebtn")){
  364. b.onclick=()=>{ if(confirm("Switch to "+MODE_NAMES[+b.dataset.mode]+"? The copycat will reboot.")){ send([0x03, +b.dataset.mode]); log("mode switch requested — device will reboot"); } };
  365. }
  366. if(!("usb" in navigator)) $("#connectBtn").outerHTML='<b style="color:var(--bad)">WebUSB not supported — use Chrome or Edge.</b>';
  367. </script>
  368. </body>
  369. </html>