index.html 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625
  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. /* ---- controller slot tabs ---- */
  37. .slot-tabs{display:flex;gap:6px;margin:0 0 14px;flex-wrap:wrap}
  38. .slot-tab{display:flex;align-items:center;gap:7px;padding:5px 14px;border-radius:8px;font-size:12px;font-weight:600;background:#0e1017;border:1px solid #232a3a;color:var(--mut);cursor:pointer;transition:border-color .15s,color .15s,background .15s}
  39. .slot-tab:hover{border-color:var(--acc2);color:var(--fg)}
  40. .slot-tab.active{background:#111c2e;border-color:var(--acc2);color:var(--fg)}
  41. .slot-tab .dot{width:7px;height:7px;border-radius:50%;background:var(--bad);flex-shrink:0;transition:background .3s}
  42. .slot-tab .dot.up{background:var(--acc);animation:pulse-link 2.2s ease-in-out infinite}
  43. @keyframes pulse-link{
  44. 0%,100%{box-shadow:0 0 0 0 rgba(91,214,160,.5)}
  45. 55%{box-shadow:0 0 0 5px rgba(91,214,160,0)}
  46. }
  47. @media(prefers-reduced-motion:reduce){.slot-tab .dot.up{animation:none}}
  48. .per-slot-grid{margin-bottom:12px}
  49. </style>
  50. </head>
  51. <body>
  52. <div class="wrap">
  53. <h1>OpenPuck Config</h1>
  54. <p class="sub">nRF52840 Steam Controller 2 puck reimplementation — WebUSB. Chrome/Edge only.</p>
  55. <div class="card" id="connectCard">
  56. <div class="row" style="margin:0">
  57. <button id="connectBtn" class="primary">Connect to copycat</button>
  58. <span id="connState" class="pill dn">disconnected</span>
  59. </div>
  60. <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>
  61. </div>
  62. <div id="panel" class="hide">
  63. <div class="card">
  64. <h2>USB mode</h2>
  65. <div class="modes" style="flex-wrap:wrap">
  66. <button data-mode="0" class="modebtn">Steam (puck)</button>
  67. <button data-mode="1" class="modebtn">Xbox 360</button>
  68. <button data-mode="2" class="modebtn">Switch (HORIPAD)</button>
  69. <button data-mode="3" class="modebtn">Lizard (always)</button>
  70. <button data-mode="4" class="modebtn">Switch Pro + gyro</button>
  71. <button data-mode="5" class="modebtn">PS5 DualSense</button>
  72. <button data-mode="6" class="modebtn">HID gyro (DS4)</button>
  73. </div>
  74. <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>
  75. <div class="row"><label>Persist last mode</label>
  76. <button id="persistMode">off</button>
  77. <span class="note" style="flex:1">Off (default): every restart / fresh reconnect boots into Steam mode. On: remembers the last mode you selected.</span>
  78. </div>
  79. </div>
  80. <div class="card">
  81. <h2>Back4 + face button chords</h2>
  82. <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>
  83. <div class="row"><label>B + back4 →</label><select class="chord" data-i="0"></select></div>
  84. <div class="row"><label>X + back4 →</label><select class="chord" data-i="1"></select></div>
  85. <div class="row"><label>Y + back4 →</label><select class="chord" data-i="2"></select></div>
  86. </div>
  87. <div class="card">
  88. <h2>Link status</h2>
  89. <!-- Per-controller tabs: visible only when 2+ controllers are bonded -->
  90. <div class="slot-tabs" id="slotTabs" style="display:none"></div>
  91. <!-- Per-slot: RF link / Battery / Signal -->
  92. <div class="statgrid per-slot-grid">
  93. <div class="stat"><div class="k">RF link</div><div class="v" id="stLink">—</div></div>
  94. <div class="stat"><div class="k">Battery</div><div class="v" id="stBatt">—</div></div>
  95. <div class="stat"><div class="k">Signal</div><div class="v" id="stRssi">—</div></div>
  96. </div>
  97. <!-- Global stats -->
  98. <div class="statgrid">
  99. <div class="stat"><div class="k">Delivered</div><div class="v" id="stRate">—</div></div>
  100. <div class="stat"><div class="k">New reports</div><div class="v" id="stNew">—</div></div>
  101. <div class="stat logonly"><div class="k">Polls/s</div><div class="v" id="stPolls">—</div></div>
  102. <div class="stat logonly"><div class="k">Loop period</div><div class="v" id="stLoop">—</div></div>
  103. <div class="stat logonly"><div class="k">Slowest stage</div><div class="v" id="stWorst">—</div></div>
  104. <div class="stat logonly"><div class="k">Poll period act/want</div><div class="v" id="stPollPer">—</div></div>
  105. <div class="stat"><div class="k">Build (git)</div><div class="v" id="stBuild">—</div></div>
  106. <div class="stat"><div class="k">Last reset</div><div class="v" id="stReset">—</div></div>
  107. </div>
  108. <p class="note" style="margin-top:6px"><strong>Reset history</strong> (newest first, persists across power-off): <span id="resetHist" style="font-family:ui-monospace,Menlo,monospace">—</span><br>
  109. <em>watchdog</em>/<em>LOCKUP</em> = a fault (loop hang / crash); <em>soft reboot</em> = an intentional reset (mode change); <em>power-on</em> = normal plug-in.</p>
  110. <p class="note" style="margin-top:8px"><strong>IMU (raw):</strong> <span id="stImu" style="font-family:ui-monospace,Menlo,monospace">—</span><br>
  111. Raw SC2 accel (before scaling). This controller is a ±2 g sensor (~16384 = 1 g); the Switch report divides it by 4 to present the genuine ±8 g (4096 = 1 g) scale so the console's gravity-correction engages and the gyro stops drifting.</p>
  112. <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>
  113. <div class="row" style="margin:10px 0 0"><button id="hapClear">Clear stuck buzz</button>
  114. <span class="note" style="flex:1">If the controller's haptics get stuck/buzzing, this re-inits them (same as pressing Steam).</span>
  115. </div>
  116. <div class="row" style="margin:10px 0 0"><button id="ctlrOff">Turn off controller</button>
  117. <span class="note" style="flex:1">Fires the controller power-off (0x9F "off!", x3) — the same path Steam's "turn off controller" and host-suspend use. Watch the link status to see it disconnect.</span>
  118. </div>
  119. <div class="row" style="margin:10px 0 0;border-top:1px solid #272d3d;padding-top:14px">
  120. <button id="dfuSerial">Reboot to Serial DFU</button>
  121. <span class="note" style="flex:1">Reboots the puck into serial DFU mode (adafruit-nrfutil). The device drops off USB — flash with <code>adafruit-nrfutil dfu serial …</code> then replug.</span>
  122. </div>
  123. <div class="row" style="margin:10px 0 0">
  124. <button id="dfuUf2">Reboot to UF2 Bootloader</button>
  125. <span class="note" style="flex:1">Reboots the puck into the UF2 mass-storage bootloader. It mounts as a USB drive — drag the <code>.uf2</code> file onto it to flash, then it reboots automatically.</span>
  126. </div>
  127. <div class="row" style="margin:18px 0 0;border-top:1px solid #4a2030;padding-top:14px">
  128. <button id="factoryErase" style="background:#3a1420;border-color:#7a2233;color:#ff9bb0;font-weight:600">⚠ Factory erase</button>
  129. <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>
  130. </div>
  131. </div>
  132. <div class="card" id="mouseCard">
  133. <h2>Trackpad mouse (lizard + Xbox)</h2>
  134. <div class="row"><label>Sensitivity</label><input type="range" id="mDiv" min="4" max="200" step="1"><span class="val" id="mDivV"></span></div>
  135. <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>
  136. <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>
  137. </div>
  138. <div class="card">
  139. <h2>Rumble</h2>
  140. <div class="row"><label>Strength</label><input type="range" id="rumble" min="0" max="255" step="5"><span class="val" id="rumbleV"></span></div>
  141. <p class="note">Scales rumble amplitude across all modes (100% = native, 200% = double, 0% = off).</p>
  142. </div>
  143. <div class="card">
  144. <h2>Switch Pro motion</h2>
  145. <div class="row"><label>Report rate</label><select id="swRate"></select></div>
  146. <p class="note" style="margin:0 0 6px">Full rate (~250 Hz, default) is lowest-latency. 120 Hz is low-latency and drift-free. 66 Hz is a genuine-cadence compatibility fallback — drop to it only if your console rejects the faster rates.</p>
  147. <div class="row"><label>Gyro scale</label><select id="swGyro"></select></div>
  148. <p class="note">Gyro sensitivity multiplier for Switch Pro mode (1.0× = unchanged).</p>
  149. </div>
  150. <div class="card">
  151. <h2>Button mapping</h2>
  152. <p class="note" style="margin-top:0">Separate mappings per emulated controller type — each only offers targets that exist on that controller. The type matching the current mode is highlighted. Steam &amp; Lizard are native puck modes (Steam does its own remapping) and aren't configured here.</p>
  153. <div id="typeCfgs"></div>
  154. </div>
  155. <div class="card logonly">
  156. <h2>Capture — host → controller commands</h2>
  157. <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>
  158. <div class="row" style="margin:0;flex-wrap:wrap">
  159. <button id="capStart" class="primary">Dump from boot</button>
  160. <button id="capStop" disabled>Stop</button>
  161. <button id="capDl">Download .txt</button>
  162. </div>
  163. <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>
  164. <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>
  165. </div>
  166. <div id="log"></div>
  167. </div>
  168. </div>
  169. <script>
  170. const MODE_NAMES = ["Steam (puck)","Xbox 360","Switch (HORIPAD)","Lizard (always)","Switch Pro + gyro","PS5 DualSense","HID gyro (DS4)","PS5 (game/clean)","DS4 (game/clean)"];
  171. const CHORD_FIELD = [17, 18, 19];
  172. let dev=null, epIn=0, epOut=0, ifNum=0, polling=false, capturing=false;
  173. const $ = s=>document.querySelector(s);
  174. function log(m){ const l=$("#log"); l.textContent=(new Date().toLocaleTimeString()+" "+m+"\n"+l.textContent).slice(0,2000); }
  175. // Per-emulated-type config (must match firmware ET_* order: Xbox=0, Switch=1, DS4=2, DS5=3). Each type lists
  176. // only the remap targets that exist on that controller. Field id sent to firmware = 40 + et*8 + k
  177. // (k: 0..3 back paddles, 4 QAM, 5 A/B-swap, 6 trackpad-haptics). Blob v9 reads them back at p[75 + et*7 + ...].
  178. const DPAD = {12:"D-pad Up",13:"D-pad Down",14:"D-pad Left",15:"D-pad Right"};
  179. const TYPE_DEFS = [
  180. {key:"XBOX", name:"Xbox", labels:{1:"A",2:"B",3:"X",4:"Y",5:"LB",6:"RB",7:"L3",8:"R3",9:"Back",10:"Start",11:"Guide",19:"LT",20:"RT",...DPAD}},
  181. {key:"SWITCH", name:"Switch", labels:{1:"A",2:"B",3:"X",4:"Y",5:"L",6:"R",7:"L-Stick",8:"R-Stick",9:"Minus",10:"Plus",11:"Home",19:"ZL",20:"ZR",...DPAD,18:"Capture / Screenshot"}},
  182. {key:"DS4", name:"DS4", labels:{1:"Cross",2:"Circle",3:"Square",4:"Triangle",5:"L1",6:"R1",7:"L3",8:"R3",9:"Create",10:"Options",11:"PS",19:"L2",20:"R2",...DPAD,16:"Touchpad Click"}},
  183. {key:"DS5", name:"DS5", labels:{1:"Cross",2:"Circle",3:"Square",4:"Triangle",5:"L1",6:"R1",7:"L3",8:"R3",9:"Create",10:"Options",11:"PS",19:"L2",20:"R2",...DPAD,16:"Touchpad Click",17:"Mute"}},
  184. ];
  185. // Which emulated type a USB mode belongs to (mirror of firmware etypeForMode); -1 = puck mode (no type).
  186. function etypeForMode(m){ switch(m){case 1:return 0; case 2:case 4:return 1; case 6:case 8:return 2; case 5:case 7:return 3; default:return -1;} }
  187. const BACK_LABELS = ["L4 (back upper-left)","R4 (back upper-right)","L5 (back lower-left)","R5 (back lower-right)"];
  188. const typeEls = []; // per-type {sec, abSwap, pad, back[4], qam}
  189. function mkSelect(def, includeNone){
  190. const sel=document.createElement("select");
  191. if(includeNone){ const o=document.createElement("option"); o.value=0; o.textContent="— none —"; sel.appendChild(o); }
  192. else { const o=document.createElement("option"); o.value=0; o.textContent="Default (per-mode)"; sel.appendChild(o); }
  193. for(const c of Object.keys(def.labels).map(Number).sort((a,b)=>a-b)){
  194. const o=document.createElement("option"); o.value=c; o.textContent=def.labels[c]; sel.appendChild(o); }
  195. return sel;
  196. }
  197. let curTab=0, tabInited=false;
  198. function setTab(et){
  199. curTab=et;
  200. typeEls.forEach((rec,i)=>{
  201. rec.sec.style.display = (i===et) ? "" : "none";
  202. rec.tab.classList.toggle("active", i===et);
  203. });
  204. }
  205. (function buildTypeCfgs(){
  206. const host=document.getElementById("typeCfgs");
  207. const bar=document.createElement("div"); bar.style.cssText="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px";
  208. host.appendChild(bar);
  209. TYPE_DEFS.forEach((def,et)=>{
  210. // tab button
  211. const tab=document.createElement("button"); tab.className="slot-tab"; tab.style.cursor="pointer";
  212. tab.innerHTML='<span>'+def.name+'</span><span class="active-dot" style="display:none;font-size:11px;color:var(--acc)"> ●</span>';
  213. tab.onclick=()=>setTab(et); bar.appendChild(tab);
  214. // panel
  215. const sec=document.createElement("div"); sec.style.cssText="background:#0e1017;border:1px solid #232a3a;border-radius:8px;padding:12px 14px;display:none";
  216. const rec={sec,tab,activeDot:tab.querySelector(".active-dot"),back:[],qam:null,abSwap:null,pad:null};
  217. // A/B swap + trackpad haptics toggles
  218. const tog=document.createElement("div"); tog.className="row";
  219. tog.innerHTML='<label>A/B + X/Y swap</label>';
  220. const ab=document.createElement("button"); ab.textContent="off"; tog.appendChild(ab);
  221. const padLbl=document.createElement("label"); padLbl.textContent="Trackpad haptics"; padLbl.style.marginLeft="14px"; tog.appendChild(padLbl);
  222. const pad=document.createElement("button"); pad.textContent="on"; tog.appendChild(pad);
  223. sec.appendChild(tog);
  224. rec.abSwap=ab; rec.pad=pad;
  225. ab.onclick=()=>{ const on=ab.classList.contains("active"); setField(40+et*8+5, on?0:1); };
  226. pad.onclick=()=>{ const on=pad.classList.contains("active"); setField(40+et*8+6, on?0:1); };
  227. // back paddles
  228. for(let i=0;i<4;i++){
  229. const row=document.createElement("div"); row.className="row";
  230. const lab=document.createElement("label"); lab.textContent=BACK_LABELS[i]; row.appendChild(lab);
  231. const sel=mkSelect(def,true); row.appendChild(sel); sec.appendChild(row);
  232. sel.addEventListener("change",()=>setField(40+et*8+i, +sel.value));
  233. rec.back.push(sel);
  234. }
  235. // QAM
  236. { const row=document.createElement("div"); row.className="row";
  237. const lab=document.createElement("label"); lab.textContent="QAM (3 dots)"; row.appendChild(lab);
  238. const sel=mkSelect(def,false); row.appendChild(sel); sec.appendChild(row);
  239. sel.addEventListener("change",()=>setField(40+et*8+4, +sel.value));
  240. rec.qam=sel; }
  241. host.appendChild(sec); typeEls.push(rec);
  242. });
  243. setTab(0);
  244. })();
  245. for(const sel of document.querySelectorAll("select.chord")){
  246. MODE_NAMES.forEach((n,i)=>{ if(i===7||i===8) return;
  247. const o=document.createElement("option"); o.value=i; o.textContent=n; sel.appendChild(o); });
  248. }
  249. { const sel=document.getElementById("swRate");
  250. for(const [lbl,v] of [["Full rate (~250 Hz, default)",2],["120 Hz",1],["66 Hz (compatibility)",0]]){
  251. const o=document.createElement("option"); o.value=v; o.textContent=lbl; sel.appendChild(o); }
  252. }
  253. { const sel=document.getElementById("swGyro");
  254. for(const [lbl,v] of [["0.5×",5],["1.0×",10],["1.5×",15],["2.0×",20],["2.5×",25],["3.0×",30]]){
  255. const o=document.createElement("option"); o.value=v; o.textContent=lbl; sel.appendChild(o); }
  256. }
  257. // ---- per-slot tab state ----
  258. let g_activeSlot = 0;
  259. let g_slotData = []; // [{up, battery, rssi}] indexed by slot 0..3, only set for bonded slots
  260. function updateSlotDisplay(slotIdx) {
  261. const d = g_slotData[slotIdx];
  262. if (!d) { $("#stLink").innerHTML="—"; $("#stBatt").textContent="—"; $("#stRssi").textContent="—"; return; }
  263. $("#stLink").innerHTML = d.up ? '<span class="pill up">up</span>' : '<span class="pill dn">down</span>';
  264. $("#stBatt").textContent = (d.up && d.battery) ? d.battery+"%" : "—";
  265. $("#stRssi").textContent = (d.up && d.rssi) ? ("-"+d.rssi+" dBm") : "—";
  266. }
  267. function renderSlotTabs(bondedCount) {
  268. const bar = $("#slotTabs");
  269. if (bondedCount <= 1) { bar.style.display="none"; updateSlotDisplay(g_activeSlot); return; }
  270. bar.style.display="flex";
  271. bar.innerHTML="";
  272. for (let s=0; s<4; s++) {
  273. const d = g_slotData[s];
  274. if (!d) continue;
  275. const btn = document.createElement("button");
  276. btn.className = "slot-tab" + (s===g_activeSlot ? " active" : "");
  277. const dot = document.createElement("span");
  278. dot.className = "dot" + (d.up ? " up" : "");
  279. btn.appendChild(dot);
  280. btn.appendChild(document.createTextNode("Controller "+(s+1)));
  281. btn.onclick = () => { g_activeSlot=s; renderSlotTabs(bondedCount); };
  282. bar.appendChild(btn);
  283. }
  284. updateSlotDisplay(g_activeSlot);
  285. }
  286. async function connect(){
  287. try{
  288. dev = await navigator.usb.requestDevice({filters:[{vendorId:0x28DE},{vendorId:0x045E},{vendorId:0x057E},{vendorId:0x0F0D},{vendorId:0x054C}]});
  289. await dev.open();
  290. if(dev.configuration===null) await dev.selectConfiguration(1);
  291. let found=null;
  292. for(const itf of dev.configuration.interfaces){
  293. const a=itf.alternate;
  294. if(a.interfaceClass!==0xFF) continue;
  295. if(a.interfaceSubClass===0x5D) continue;
  296. let bin=0,bout=0;
  297. for(const e of a.endpoints){ if(e.type==="bulk"){ if(e.direction==="in")bin=e.endpointNumber; else bout=e.endpointNumber; } }
  298. if(bin&&bout){ found={ifNum:itf.interfaceNumber, epIn:bin, epOut:bout}; break; }
  299. }
  300. if(!found){ log("no WebUSB bulk vendor interface — reflash firmware with WebUSB enabled, or another app may be holding the device"); return; }
  301. ifNum=found.ifNum; epIn=found.epIn; epOut=found.epOut;
  302. await dev.claimInterface(ifNum);
  303. await dev.controlTransferOut({requestType:"class", recipient:"interface", request:0x22, value:0x01, index:ifNum});
  304. $("#connState").textContent="connected"; $("#connState").className="pill up";
  305. $("#connectBtn").textContent="Reconnect";
  306. $("#panel").classList.remove("hide");
  307. log(`connected — VID ${dev.vendorId.toString(16)} iface ${ifNum} epIn ${epIn} epOut ${epOut}`);
  308. navigator.usb.addEventListener("disconnect", e=>{ if(e.device===dev){ onGone(); }});
  309. startPolling();
  310. }catch(e){ log("connect failed: "+e.message); }
  311. }
  312. function onGone(){
  313. polling=false; dev=null; tabInited=false; // re-default the config tab to the mode on next connect
  314. $("#connState").textContent="disconnected"; $("#connState").className="pill dn";
  315. $("#panel").classList.add("hide");
  316. log("device disconnected (likely a mode-switch reboot — reconnect)");
  317. }
  318. async function send(bytes){
  319. if(!dev) return;
  320. try{ await dev.transferOut(epOut, new Uint8Array(bytes)); }
  321. catch(e){ log("write err: "+e.message); }
  322. }
  323. async function readBlob(){
  324. try{
  325. // v8 blob is 75 bytes (two USB-FS packets); read 128 to capture both in one call.
  326. const r = await dev.transferIn(epIn, 128);
  327. if(r.status!=="ok"||r.data.byteLength<2) return null;
  328. const d=new Uint8Array(r.data.buffer);
  329. let i=0; while(i<d.length && d[i]!==0xA5) i++;
  330. if(i+2>d.length) return null;
  331. const len=d[i+1]; const p=d.slice(i+2, i+2+len);
  332. return p.length>=12 ? p : null;
  333. }catch(e){ if(dev) log("read err: "+e.message); return null; }
  334. }
  335. function applyBlob(p){
  336. const mode=p[1], mDiv=p[2], mFric=p[3]; // p[4..11] mirror the active type (per-type block below is authoritative)
  337. const slot=p[10], up=p[11], f1=(p[12]|(p[13]<<8));
  338. const newps=(p.length>16?(p[15]|(p[16]<<8)):0), persistMode=(p.length>22?p[22]:0);
  339. const chord=(p.length>25)?[p[23],p[24],p[25]]:[3,1,4];
  340. const polls=(p.length>27?(p[26]|(p[27]<<8)):0);
  341. const loopUs=(p.length>29?(p[28]|(p[29]<<8)):0);
  342. const worstIdx=(p.length>30?p[30]:0), worstUs=(p.length>32?(p[31]|(p[32]<<8)):0);
  343. const WORST=["webusb","ctrl.task","serial","rfdiag","rflink","haptic","led"];
  344. const pollUs=(p.length>34?(p[33]|(p[34]<<8)):0);
  345. const pollIntended=(p.length>14?p[14]*100:0);
  346. const logEnabled=(p.length>35?p[35]:0);
  347. for(const el of document.querySelectorAll(".logonly")) el.style.display = logEnabled ? "" : "none";
  348. for(const b of document.querySelectorAll(".modebtn")) b.classList.toggle("active", +b.dataset.mode===mode);
  349. $("#mouseCard").style.opacity = (mode===1||mode===3)?1:0.5;
  350. const battery=(p.length>36?p[36]:0);
  351. const rumbleScale=(p.length>51?p[51]:200);
  352. const swProRate=(p.length>52?p[52]:2);
  353. const swGyro10=(p.length>53?p[53]:10);
  354. const rssi=(p.length>37?p[37]:0);
  355. // per-slot link status (protocol v8, p.length >= 73)
  356. const hasSlotsData = p.length >= 73;
  357. if (hasSlotsData) {
  358. const bondedN = p[60];
  359. g_slotData = [];
  360. for (let s=0; s<4; s++) {
  361. const base = 61 + s*3;
  362. if (p.length >= base+3) {
  363. // only include bonded slots (a slot with up=0 could be bonded but offline)
  364. // check if the slot appears at all in the bonded map by checking if bondedN covers it;
  365. // the firmware only sets non-zero battery/rssi for actually-bonded slots
  366. const slotUp = !!p[base];
  367. const slotBatt = p[base+1];
  368. const slotRssi = p[base+2];
  369. // slot is "bonded" if firmware included it in bondedN coverage
  370. // we populate slotData for all 4 and renderSlotTabs skips gaps
  371. g_slotData[s] = {up: slotUp, battery: slotBatt, rssi: slotRssi};
  372. }
  373. }
  374. // keep only bonded slots by cross-referencing bondedN
  375. // firmware sends bondedSlotCount but not a slot bitmask; use battery>0 || rssi>0 || up as presence hint
  376. // simpler: just count how many of the 4 slots have any non-zero data OR are marked up
  377. // Actually the firmware always writes all 4 slots; we filter by the bond system's "used" flag via presence.
  378. // For clean display: we'll show tabs only for slots that have ever had a reply OR are bonded (battery/rssi set).
  379. // The firmware sets g_battery[s]=0 and g_linkRssi[s]=0 for unused slots, so 0/0/0 = not bonded.
  380. // But a bonded offline slot also has 0/0/0 initially. We rely on bondedN for count and show the first bondedN slots
  381. // that have g_slot[s].used — but we can't easily know which slots are used from the blob.
  382. // Best heuristic: show all 4 slots if bondedN==4, otherwise use slot indices where battery or rssi != 0 OR up==1,
  383. // OR fall back to showing slots 0..bondedN-1 if none have data yet (device just booted).
  384. let bondedSlots = [];
  385. for (let s=0; s<4; s++) {
  386. if (g_slotData[s] && (g_slotData[s].up || g_slotData[s].battery || g_slotData[s].rssi)) bondedSlots.push(s);
  387. }
  388. // fallback: if no data yet (all offline, just booted), show slots 0..bondedN-1
  389. if (bondedSlots.length === 0 && bondedN > 0) {
  390. for (let s=0; s<bondedN; s++) bondedSlots.push(s);
  391. }
  392. // remove slotData for non-bonded slots so tabs don't appear for them
  393. const bondedSet = new Set(bondedSlots);
  394. for (let s=0; s<4; s++) { if (!bondedSet.has(s)) g_slotData[s] = null; }
  395. // clamp active slot to a valid bonded slot
  396. if (!g_slotData[g_activeSlot]) g_activeSlot = bondedSlots[0] || 0;
  397. renderSlotTabs(bondedN);
  398. } else {
  399. // old firmware: single-slot display
  400. $("#slotTabs").style.display="none";
  401. $("#stLink").innerHTML = up? '<span class="pill up">up</span>' : '<span class="pill dn">down</span>';
  402. $("#stBatt").textContent = (up && battery)? battery+"%" : "—";
  403. $("#stRssi").textContent = (up && rssi)? ("-"+rssi+" dBm") : "—";
  404. }
  405. // global stats
  406. $("#stRate").textContent = up? f1+" /s" : "—";
  407. $("#stNew").textContent = up? newps+" /s" : "—";
  408. $("#stPolls").textContent = up? polls+" /s" : "—";
  409. $("#stLoop").textContent = loopUs? loopUs+" µs" : "—";
  410. $("#stWorst").textContent = loopUs? (WORST[worstIdx]||worstIdx)+" "+worstUs+"µs" : "—";
  411. $("#stPollPer").textContent = up? (pollUs+" / "+pollIntended+" µs") : "—";
  412. // reset-cause history (protocol v9): JS p[73]=count, p[74..83]=last 10 causes (newest first).
  413. // Persisted in puck flash, so a reboot that happened on a Switch is still readable here after replugging.
  414. function resetReasonText(rr){
  415. if (rr === 0) return "power-on";
  416. const r = [];
  417. if (rr & 0x02) r.push("watchdog"); // DOG: loop wedge / hang
  418. if (rr & 0x08) r.push("LOCKUP"); // CPU lockup = HardFault escalation (real crash)
  419. if (rr & 0x04) r.push("soft reboot"); // SREQ: NVIC_SystemReset (mode change etc.)
  420. if (rr & 0x01) r.push("reset pin");
  421. return r.length ? r.join("+") : ("0x"+rr.toString(16));
  422. }
  423. if (p.length > 73) {
  424. const n = Math.min(p[73], 10);
  425. const hist = [];
  426. for (let i=0; i<n; i++) hist.push(p[74+i]);
  427. const fault = (rr)=>!!(rr & 0x0A); // watchdog or lockup = a real fault
  428. if (n === 0) { $("#stReset").textContent = "—"; }
  429. else {
  430. const newest = hist[0];
  431. const head = fault(newest) ? ('<span class="pill dn">'+resetReasonText(newest)+'</span>') : resetReasonText(newest);
  432. $("#stReset").innerHTML = head;
  433. // full history list under the tile (newest -> oldest)
  434. const items = hist.map((rr,i)=>{
  435. const t = resetReasonText(rr);
  436. return (i===0?'<strong>':'')+ '#'+(n-i)+' '+(fault(rr)?('<span style="color:#ff6b6b">'+t+'</span>'):t) +(i===0?' (latest)</strong>':'');
  437. });
  438. const el = $("#resetHist"); if (el) el.innerHTML = items.join(' &nbsp;·&nbsp; ');
  439. }
  440. }
  441. const gitDirty=(p.length>38?p[38]:0); let gitHash="";
  442. for(let i=39;i<51 && i<p.length && p[i];i++) gitHash+=String.fromCharCode(p[i]);
  443. $("#stBuild").innerHTML = gitHash
  444. ? (gitHash + (gitDirty ? ' <span class="pill dn">dirty</span>' : ' <span class="pill up">clean</span>'))
  445. : "—";
  446. setSlider("mDiv",mDiv); setSlider("mFric",mFric); setSlider("rumble",rumbleScale);
  447. $("#persistMode").textContent = persistMode? "on":"off"; $("#persistMode").classList.toggle("active",!!persistMode);
  448. // per-type button config (blob v9): 4 types x 7 bytes. readBlob() strips the 2-byte [0xA5][len] header, so
  449. // the firmware's payload byte 75 lands at JS index 73 here.
  450. if(p.length>=73+TYPE_DEFS.length*7){
  451. const activeEt=etypeForMode(mode);
  452. typeEls.forEach((rec,et)=>{
  453. const q=73+et*7;
  454. const back=[p[q],p[q+1],p[q+2],p[q+3]], qam=p[q+4], ab=p[q+5], pad=p[q+6];
  455. rec.back.forEach((sel,i)=>{ if(document.activeElement!==sel) sel.value=back[i]; });
  456. if(document.activeElement!==rec.qam) rec.qam.value=qam;
  457. rec.abSwap.textContent=ab?"on":"off"; rec.abSwap.classList.toggle("active",!!ab);
  458. rec.pad.textContent=pad?"on":"off"; rec.pad.classList.toggle("active",!!pad);
  459. // dot marks the type matching the current puck mode
  460. rec.activeDot.style.display = (et===activeEt) ? "" : "none";
  461. });
  462. // open the tab for the current mode's type on first load (then leave the user's choice alone)
  463. if(!tabInited){ tabInited=true; setTab(activeEt>=0?activeEt:0); }
  464. }
  465. if(p.length>=60){ const s16=(o)=>{ let v=p[o]|(p[o+1]<<8); return v>32767?v-65536:v; };
  466. const ax=s16(54),ay=s16(56),az=s16(58);
  467. const amag=Math.round(Math.sqrt(ax*ax+ay*ay+az*az));
  468. $("#stImu").textContent = `a=(${ax}, ${ay}, ${az}) |a|=${amag}`;
  469. }
  470. { const sel=$("#swRate"); if(document.activeElement!==sel) sel.value=swProRate; }
  471. { const sel=$("#swGyro"); if(document.activeElement!==sel) sel.value=swGyro10; }
  472. for(const sel of document.querySelectorAll("select.chord")){ if(document.activeElement!==sel) sel.value=chord[+sel.dataset.i]; }
  473. }
  474. function fmtSlider(id,v){ return id==="rumble" ? v+"%" : ""+v; }
  475. function setSlider(id,v){ const el=$("#"+id); if(document.activeElement!==el){ el.value=v; } $("#"+id+"V").textContent=fmtSlider(id,el.value); }
  476. let inflight=false;
  477. async function refresh(){
  478. if(!dev||inflight||capturing) return; inflight=true;
  479. await send([0x01]); const p=await readBlob(); if(p) applyBlob(p);
  480. inflight=false;
  481. }
  482. async function setField(field,value){
  483. await send([0x02, field, value&0xff]); const p=await readBlob(); if(p) applyBlob(p);
  484. }
  485. function fmtCapEntry(e,maxMs){
  486. const hex=e.bytes.map(x=>x.toString(16).padStart(2,"0")).join(" ");
  487. const t = `-${String(maxMs-e.ms).padStart(6," ")}ms `;
  488. 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"} ════════`; }
  489. const who = (e.slot===0xFE) ? "TX→ctlr " : `if${e.slot} `;
  490. return `${t}${who} cmd=${e.rid.toString(16).padStart(2,"0")} n=${e.nb}: ${hex}`;
  491. }
  492. function downloadCap(){
  493. const t=window._lastCap||$("#capOut").textContent||"";
  494. const a=document.createElement("a");
  495. a.href=URL.createObjectURL(new Blob([t],{type:"text/plain"}));
  496. a.download="puck-capture.txt"; a.click();
  497. }
  498. let liveTimer=null, draining=false, capLines=[];
  499. function renderCap(){
  500. if(!capLines.length){ $("#capOut").textContent="(nothing captured yet)"; window._lastCap=""; return; }
  501. const maxMs=capLines[capLines.length-1].ms;
  502. const t=capLines.map(e=>fmtCapEntry(e,maxMs)).join("\n");
  503. $("#capOut").textContent=t; window._lastCap=t;
  504. }
  505. async function drainTick(){
  506. if(!dev||draining) return; draining=true;
  507. let acc=new Uint8Array(0);
  508. try{
  509. await send([0x06]);
  510. let done=false, guard=0;
  511. while(!done && guard++<400){
  512. const r=await dev.transferIn(epIn,64);
  513. if(r.status!=="ok") break;
  514. const d=new Uint8Array(r.data.buffer);
  515. const m=new Uint8Array(acc.length+d.length); m.set(acc); m.set(d,acc.length); acc=m;
  516. let i=0;
  517. while(i<acc.length){
  518. if(acc[i]!==0xA6){ i++; continue; }
  519. if(i+2>acc.length) break;
  520. const L=acc[i+1]; if(i+2+L>acc.length) break;
  521. const f=acc.slice(i+2,i+2+L); i+=2+L;
  522. if(f[0]===0){ done=true; break; }
  523. if(f[0]===1 && L>=8){
  524. const ms=((f[1])|(f[2]<<8)|(f[3]<<16)|(f[4]<<24))>>>0;
  525. capLines.push({ms, slot:f[5], rid:f[6], nb:f[7], bytes:[...f.slice(8,8+f[7])]});
  526. }
  527. }
  528. acc=acc.slice(i);
  529. }
  530. }catch(e){ log("drain err: "+e.message); }
  531. draining=false;
  532. if(liveTimer){ const el=$("#capOut"); const atBottom=el.scrollTop+el.clientHeight>=el.scrollHeight-4; renderCap(); if(atBottom) el.scrollTop=el.scrollHeight; }
  533. }
  534. async function startCapture(){
  535. if(!dev||capturing) return;
  536. capturing=true; capLines=[];
  537. $("#capOut").textContent="(dumping ring from boot, then live — press Stop when done)";
  538. $("#capStart").disabled=true; $("#capStop").disabled=false;
  539. await send([0x05,1]);
  540. liveTimer=setInterval(drainTick,100);
  541. }
  542. async function stopCapture(){
  543. if(liveTimer){ clearInterval(liveTimer); liveTimer=null; }
  544. await drainTick();
  545. try{ await send([0x05,0]); }catch(e){}
  546. capturing=false;
  547. $("#capStart").disabled=false; $("#capStop").disabled=true;
  548. renderCap(); log(`capture stopped — ${capLines.length} entries`);
  549. }
  550. async function startPolling(){
  551. polling=true; await refresh();
  552. while(polling && dev){ await new Promise(r=>setTimeout(r,600)); await refresh(); }
  553. }
  554. // UI wiring
  555. $("#connectBtn").onclick=connect;
  556. $("#capDl").onclick=downloadCap;
  557. $("#capStart").onclick=startCapture;
  558. $("#capStop").onclick=stopCapture;
  559. $("#hapClear").onclick=async()=>{ if(dev){ await send([0x07]); log("haptic re-init sent (clear stuck buzz)"); } };
  560. $("#ctlrOff").onclick=async()=>{ if(dev){ await send([0x08]); log("controller power-off attempt sent — watch link status"); } };
  561. $("#dfuSerial").onclick=async()=>{
  562. if(!dev){ log("not connected"); return; }
  563. if(!confirm("Reboot into serial DFU?\n\nThe puck will disconnect immediately. Flash with adafruit-nrfutil, then replug.")) return;
  564. await send([0x0B]); log("serial DFU reboot sent — device disconnecting");
  565. };
  566. $("#dfuUf2").onclick=async()=>{
  567. if(!dev){ log("not connected"); return; }
  568. if(!confirm("Reboot into UF2 bootloader?\n\nThe puck will disconnect and mount as a USB drive. Drag the .uf2 file onto it to flash.")) return;
  569. await send([0x0C]); log("UF2 bootloader reboot sent — device disconnecting");
  570. };
  571. $("#factoryErase").onclick=async()=>{
  572. if(!dev){ log("not connected"); return; }
  573. 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;
  574. if(!confirm("Are you absolutely sure?\n\nThere is no recovery. The controller bond and all settings will be gone.")) return;
  575. const typed=prompt('Final confirmation — type ERASE (all caps) to wipe everything:');
  576. if(typed!=="ERASE"){ log("factory erase cancelled (confirmation text did not match)"); return; }
  577. await send([0x0A,0x45,0x52,0x53]);
  578. log("FACTORY ERASE sent — copycat is reformatting and rebooting to defaults. Re-pair the controller, then reconnect.");
  579. };
  580. $("#persistMode").onclick=()=>{ const on=$("#persistMode").classList.contains("active"); setField(16, on?0:1); };
  581. for(const id of ["mDiv","mFric","rumble"]){
  582. const field={mDiv:1,mFric:2,rumble:22}[id];
  583. $("#"+id).addEventListener("input", ()=>{ $("#"+id+"V").textContent=fmtSlider(id,$("#"+id).value); });
  584. $("#"+id).addEventListener("change", ()=>setField(field, +$("#"+id).value));
  585. }
  586. // per-type back/QAM/A-B-swap/haptics handlers are wired in buildTypeCfgs()
  587. $("#swRate").addEventListener("change", ()=>setField(23, +$("#swRate").value));
  588. $("#swGyro").addEventListener("change", ()=>setField(24, +$("#swGyro").value));
  589. for(const sel of document.querySelectorAll("select.chord")){
  590. sel.addEventListener("change", ()=>setField(CHORD_FIELD[+sel.dataset.i], +sel.value));
  591. }
  592. for(const b of document.querySelectorAll(".modebtn")){
  593. b.onclick=()=>{ const m=+b.dataset.mode;
  594. const clean=(m===7||m===8);
  595. const msg="Switch to "+MODE_NAMES[m]+"? The copycat will reboot."+(clean?"\n\nNOTE: the game/clean PlayStation modes drop WebUSB + host-wake, so THIS PANEL WILL DISCONNECT and can't reach the device while it's in this mode. To get back, chord on the controller: hold back-paddle 4 + A to return to Steam mode.":"");
  596. if(confirm(msg)){ send([0x03, m]); log("mode switch requested — device will reboot"); } };
  597. }
  598. if(!("usb" in navigator)) $("#connectBtn").outerHTML='<b style="color:var(--bad)">WebUSB not supported — use Chrome or Edge.</b>';
  599. </script>
  600. </body>
  601. </html>