index.html 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256
  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 OpenPuck</button>
  45. <span id="connState" class="pill dn">disconnected</span>
  46. </div>
  47. <p class="note">If nothing appears in the device picker, OpenPuck may be busy, already claimed by another app, or needs a replug. This page also works when published through GitHub Pages because WebUSB only requires a secure context.</p>
  48. </div>
  49. <div id="panel" class="hide">
  50. <div class="card">
  51. <h2>USB mode</h2>
  52. <div class="modes">
  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 Pro</button>
  56. <button data-mode="3" class="modebtn">Lizard (kbd+mouse)</button>
  57. </div>
  58. <p class="note">Switching reboots OpenPuck and re-enumerates USB (~2 s). The RF link reconnects automatically.</p>
  59. <div class="row"><label>Persist last mode</label>
  60. <button id="persistMode">off</button>
  61. <span class="note" style="flex:1">Off (default): every restart / fresh reconnect boots into Steam mode. On: remembers the last mode you selected.</span>
  62. </div>
  63. </div>
  64. <div class="card">
  65. <h2>Link status</h2>
  66. <div class="statgrid">
  67. <div class="stat"><div class="k">RF link</div><div class="v" id="stLink">—</div></div>
  68. <div class="stat"><div class="k">Slot</div><div class="v" id="stSlot">—</div></div>
  69. <div class="stat"><div class="k">Delivered</div><div class="v" id="stRate">—</div></div>
  70. <div class="stat"><div class="k">New reports</div><div class="v" id="stNew">—</div></div>
  71. </div>
  72. </div>
  73. <div class="card">
  74. <h2>RF poll rate</h2>
  75. <div class="row"><label>Poll interval</label><input type="range" id="pollUs" min="1" max="120" step="1"><span class="val" id="pollUsV"></span></div>
  76. <p class="note">Microseconds between RF polls (×100). Lower = poll harder = more fresh reports/s (peaks ~60/s near 700&nbsp;µs). Too aggressive can collapse the reply rate during heavy trackpad use — watch the “New reports” stat and trackpad feel as you tune.</p>
  77. </div>
  78. <div class="card">
  79. <h2>Steam-mode report dedupe</h2>
  80. <div class="row"><label>Forward only new</label>
  81. <button id="fwdNew">on</button>
  82. <span class="note" style="flex:1">Only send report 0x45 to Steam when the data actually changed (like the real puck).</span>
  83. </div>
  84. <p class="note">We were sending ~400 stale-repeat reports/s on top of ~100 real ones — Steam's trackpad smoothing stair-steps on the duplicates. ON = dedupe (puck-like). Toggle OFF to compare the old jittery behavior.</p>
  85. </div>
  86. <div class="card">
  87. <h2>QoS adaptive channel hopping</h2>
  88. <div class="row"><label>Auto-hop</label>
  89. <button id="qos">off</button>
  90. <span class="note" style="flex:1">When the RF link degrades (crcfail/no-reply spikes during trackpad), hop to a clean channel — like the real puck.</span>
  91. </div>
  92. <p class="note">Candidates {18,46,76,22,68}. Conservative (hops at most every ~2s) to avoid blip-storms; each hop has a brief ~0.5–1s re-sync blip. A/B it during heavy trackpad — watch crcfail and the feel.</p>
  93. </div>
  94. <div class="card" id="mouseCard">
  95. <h2>Xbox-mode trackpad mouse</h2>
  96. <div class="row"><label>Sensitivity</label><input type="range" id="mDiv" min="4" max="200" step="1"><span class="val" id="mDivV"></span></div>
  97. <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>
  98. <p class="note">Sensitivity is a divisor — lower = faster pointer. Glide = how long the cursor coasts after a flick.</p>
  99. </div>
  100. <div class="card" id="steamCard">
  101. <h2>Steam-mode trackpad smoothing</h2>
  102. <div class="row"><label>Smoothing</label><input type="range" id="padSmooth" min="5" max="100" step="1"><span class="val" id="padSmoothV"></span></div>
  103. <p class="note">Interpolation alpha. 100 = off (raw 46 Hz capsense), lower = smoother but slightly laggier.</p>
  104. </div>
  105. <div class="card">
  106. <h2>Button mapping</h2>
  107. <div class="row"><label>A/B + X/Y swap</label>
  108. <button id="abSwap">off</button>
  109. <span class="note" style="flex:1">Nintendo layout (affects Xbox &amp; Switch modes)</span>
  110. </div>
  111. <div class="row"><label>L4 (back upper-left)</label><select class="back" data-i="0"></select></div>
  112. <div class="row"><label>R4 (back upper-right)</label><select class="back" data-i="1"></select></div>
  113. <div class="row"><label>L5 (back lower-left)</label><select class="back" data-i="2"></select></div>
  114. <div class="row"><label>R5 (back lower-right)</label><select class="back" data-i="3"></select></div>
  115. <p class="note">Back-paddle → button, applied in Xbox mode.</p>
  116. </div>
  117. <div id="log"></div>
  118. </div>
  119. </div>
  120. <script>
  121. const MODE_NAMES = ["Steam (puck)","Xbox 360","Switch Pro","Lizard (kbd+mouse)"];
  122. 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"]];
  123. let dev=null, epIn=0, epOut=0, ifNum=0, polling=false;
  124. const $ = s=>document.querySelector(s);
  125. function log(m){ const l=$("#log"); l.textContent=(new Date().toLocaleTimeString()+" "+m+"\n"+l.textContent).slice(0,2000); }
  126. // populate back-paddle dropdowns
  127. for(const sel of document.querySelectorAll("select.back")){
  128. for(const [c,n] of BTN_CODES){ const o=document.createElement("option"); o.value=c; o.textContent=n; sel.appendChild(o); }
  129. }
  130. async function connect(){
  131. try{
  132. dev = await navigator.usb.requestDevice({filters:[{vendorId:0x28DE},{vendorId:0x045E},{vendorId:0x057E}]});
  133. await dev.open();
  134. if(dev.configuration===null) await dev.selectConfiguration(1);
  135. // find the WebUSB vendor interface: class 0xFF with one bulk-IN + one bulk-OUT (XInput is 0xFF but interrupt; CDC data is 0x0A)
  136. let found=null;
  137. for(const itf of dev.configuration.interfaces){
  138. const a=itf.alternate;
  139. if(a.interfaceClass!==0xFF) continue;
  140. let bin=0,bout=0;
  141. for(const e of a.endpoints){ if(e.type==="bulk"){ if(e.direction==="in")bin=e.endpointNumber; else bout=e.endpointNumber; } }
  142. if(bin&&bout){ found={ifNum:itf.interfaceNumber, epIn:bin, epOut:bout}; break; }
  143. }
  144. if(!found){ log("no WebUSB interface found on this device"); return; }
  145. ifNum=found.ifNum; epIn=found.epIn; epOut=found.epOut;
  146. await dev.claimInterface(ifNum);
  147. // WebUSB "connect" handshake: Adafruit_USBD_WebUSB only enables write() / connected()
  148. // after the host sends SET_CONTROL_LINE_STATE (bRequest 0x22, value=1) on this interface.
  149. await dev.controlTransferOut({requestType:"class", recipient:"interface", request:0x22, value:0x01, index:ifNum});
  150. $("#connState").textContent="connected"; $("#connState").className="pill up";
  151. $("#connectBtn").textContent="Reconnect";
  152. $("#panel").classList.remove("hide");
  153. log(`connected — VID ${dev.vendorId.toString(16)} iface ${ifNum} epIn ${epIn} epOut ${epOut}`);
  154. dev.onUSBConnectionEvent;
  155. navigator.usb.addEventListener("disconnect", e=>{ if(e.device===dev){ onGone(); }});
  156. startPolling();
  157. }catch(e){ log("connect failed: "+e.message); }
  158. }
  159. function onGone(){
  160. polling=false; dev=null;
  161. $("#connState").textContent="disconnected"; $("#connState").className="pill dn";
  162. $("#panel").classList.add("hide");
  163. log("device disconnected (likely a mode-switch reboot — reconnect)");
  164. }
  165. async function send(bytes){
  166. if(!dev) return;
  167. try{ await dev.transferOut(epOut, new Uint8Array(bytes)); }
  168. catch(e){ log("write err: "+e.message); }
  169. }
  170. async function readBlob(){
  171. try{
  172. const r = await dev.transferIn(epIn, 64);
  173. if(r.status!=="ok"||r.data.byteLength<2) return null;
  174. const d=new Uint8Array(r.data.buffer);
  175. // find 0xA5 frame
  176. let i=0; while(i<d.length && d[i]!==0xA5) i++;
  177. if(i+2>d.length) return null;
  178. const len=d[i+1]; const p=d.slice(i+2, i+2+len);
  179. return p.length>=12 ? p : null;
  180. }catch(e){ if(dev) log("read err: "+e.message); return null; }
  181. }
  182. function applyBlob(p){
  183. // [ver][mode][mDiv][mFric][padSmooth][abSwap][b0..b3][slot][up][f1lo][f1hi][pollU100][newlo][newhi]
  184. const mode=p[1], mDiv=p[2], mFric=p[3], padSmooth=p[4], abSwap=p[5];
  185. const back=[p[6],p[7],p[8],p[9]], slot=p[10], up=p[11], f1=(p[12]|(p[13]<<8));
  186. const pollU100=p[14], newps=(p.length>16?(p[15]|(p[16]<<8)):0), fwdNew=(p.length>20?p[20]:1), qos=(p.length>21?p[21]:0), persistMode=(p.length>22?p[22]:0);
  187. // mode buttons
  188. for(const b of document.querySelectorAll(".modebtn")) b.classList.toggle("active", +b.dataset.mode===mode);
  189. $("#mouseCard").style.opacity = mode===1?1:0.5;
  190. $("#steamCard").style.opacity = mode===0?1:0.5;
  191. // status
  192. $("#stLink").innerHTML = up? '<span class="pill up">up</span>' : '<span class="pill dn">down</span>';
  193. $("#stSlot").textContent = slot===0xFF? "—" : slot;
  194. $("#stRate").textContent = up? f1+" /s" : "—";
  195. $("#stNew").textContent = up? newps+" /s" : "—";
  196. // sliders (only if not being dragged)
  197. setSlider("mDiv",mDiv); setSlider("mFric",mFric); setSlider("padSmooth",padSmooth);
  198. if(pollU100) setSlider("pollUs",pollU100);
  199. $("#abSwap").textContent = abSwap? "on":"off"; $("#abSwap").classList.toggle("active",!!abSwap);
  200. $("#fwdNew").textContent = fwdNew? "on":"off"; $("#fwdNew").classList.toggle("active",!!fwdNew);
  201. $("#qos").textContent = qos? "on":"off"; $("#qos").classList.toggle("active",!!qos);
  202. $("#persistMode").textContent = persistMode? "on":"off"; $("#persistMode").classList.toggle("active",!!persistMode);
  203. for(const sel of document.querySelectorAll("select.back")){ if(document.activeElement!==sel) sel.value=back[+sel.dataset.i]; }
  204. }
  205. function fmtSlider(id,v){ return id==="pollUs" ? (v*100)+"µs" : (""+v); }
  206. function setSlider(id,v){ const el=$("#"+id); if(document.activeElement!==el){ el.value=v; } $("#"+id+"V").textContent=fmtSlider(id,el.value); }
  207. let inflight=false;
  208. async function refresh(){
  209. if(!dev||inflight) return; inflight=true;
  210. await send([0x01]); const p=await readBlob(); if(p) applyBlob(p);
  211. inflight=false;
  212. }
  213. async function setField(field,value){
  214. await send([0x02, field, value&0xff]); const p=await readBlob(); if(p) applyBlob(p);
  215. }
  216. async function startPolling(){
  217. polling=true; await refresh();
  218. while(polling && dev){ await new Promise(r=>setTimeout(r,600)); await refresh(); }
  219. }
  220. // UI wiring
  221. $("#connectBtn").onclick=connect;
  222. $("#abSwap").onclick=()=>{ const on=$("#abSwap").classList.contains("active"); setField(4, on?0:1); };
  223. $("#fwdNew").onclick=()=>{ const on=$("#fwdNew").classList.contains("active"); setField(14, on?0:1); };
  224. $("#qos").onclick=()=>{ const on=$("#qos").classList.contains("active"); setField(15, on?0:1); };
  225. $("#persistMode").onclick=()=>{ const on=$("#persistMode").classList.contains("active"); setField(16, on?0:1); };
  226. for(const id of ["mDiv","mFric","padSmooth","pollUs"]){
  227. const field={mDiv:1,mFric:2,padSmooth:3,pollUs:9}[id];
  228. $("#"+id).addEventListener("input", ()=>{ $("#"+id+"V").textContent=fmtSlider(id,$("#"+id).value); });
  229. $("#"+id).addEventListener("change", ()=>setField(field, +$("#"+id).value));
  230. }
  231. for(const sel of document.querySelectorAll("select.back")){
  232. sel.addEventListener("change", ()=>setField(5 + +sel.dataset.i, +sel.value));
  233. }
  234. for(const b of document.querySelectorAll(".modebtn")){
  235. b.onclick=()=>{ if(confirm("Switch to "+MODE_NAMES[+b.dataset.mode]+"? OpenPuck will reboot.")){ send([0x03, +b.dataset.mode]); log("mode switch requested — device will reboot"); } };
  236. }
  237. if(!("usb" in navigator)) $("#connectBtn").outerHTML='<b style="color:var(--bad)">WebUSB not supported — use Chrome or Edge.</b>';
  238. </script>
  239. </body>
  240. </html>