index.html 57 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902
  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)}
  11. h1{font-size:20px;margin:0 0 4px}
  12. .sub{color:var(--mut);margin:0 0 18px;font-size:13px}
  13. /* sticky top action bar: connection + device-maintenance actions */
  14. .topbar{position:sticky;top:0;z-index:20;display:flex;align-items:center;gap:10px;flex-wrap:wrap;
  15. padding:9px 18px;background:#151925;border-bottom:1px solid #272d3d}
  16. .topbar .brand{font-weight:700;font-size:15px;letter-spacing:.02em}
  17. .topbar .brand small{color:var(--mut);font-weight:400;font-size:11px;margin-left:6px}
  18. .topbar .spacer{flex:1 1 16px}
  19. .topbar button{padding:7px 12px}
  20. .wrap{max-width:980px;margin:0 auto;padding:20px}
  21. /* cards stack in one column; width comes from the wider wrap + grids/rows filling it horizontally */
  22. .card{background:var(--card);border:1px solid #272d3d;border-radius:12px;padding:16px 18px;margin:0 0 16px}
  23. .card h2{font-size:13px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);margin:0 0 14px}
  24. .row{display:flex;align-items:center;gap:14px;margin:12px 0;flex-wrap:wrap}
  25. .row label{flex:0 0 150px;color:var(--fg)}
  26. .row .val{flex:0 0 64px;text-align:right;color:var(--acc);font-variant-numeric:tabular-nums}
  27. input[type=range]{flex:1;min-width:120px;accent-color:var(--acc)}
  28. select{background:#0e1017;color:var(--fg);border:1px solid #313a4f;border-radius:8px;padding:6px 8px;flex:1;min-width:120px}
  29. button{background:#222a3a;color:var(--fg);border:1px solid #36405a;border-radius:8px;padding:9px 14px;cursor:pointer;font:inherit}
  30. button:hover{border-color:var(--acc2)}
  31. button.primary{background:var(--acc2);border-color:var(--acc2);color:#06121f;font-weight:600}
  32. button.active{background:var(--acc);border-color:var(--acc);color:#062018;font-weight:600}
  33. button.danger{background:#3a1420;border-color:#7a2233;color:#ff9bb0;font-weight:600}
  34. button.danger:hover{border-color:#ff9bb0}
  35. button:disabled{opacity:.4;cursor:default}
  36. .pill{display:inline-block;padding:2px 9px;border-radius:99px;font-size:12px;font-weight:600}
  37. .pill.up{background:#143626;color:var(--acc)} .pill.dn{background:#3a1822;color:var(--bad)}
  38. .statgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(155px,1fr));gap:10px}
  39. .stat{background:#0e1017;border:1px solid #232a3a;border-radius:8px;padding:10px}
  40. .stat .k{color:var(--mut);font-size:11px;text-transform:uppercase;letter-spacing:.05em}
  41. .stat .v{font-size:18px;margin-top:2px}
  42. .modes{display:flex;gap:10px;flex-wrap:wrap}
  43. .note{color:var(--mut);font-size:12px;margin-top:10px}
  44. #log{font:12px ui-monospace,Menlo,monospace;color:var(--mut);white-space:pre-wrap;margin-top:8px;max-height:140px;overflow:auto}
  45. .hide{display:none}
  46. /* ---- controller slot tabs ---- */
  47. .slot-tabs{display:flex;gap:6px;margin:0 0 14px;flex-wrap:wrap}
  48. .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}
  49. .slot-tab:hover{border-color:var(--acc2);color:var(--fg)}
  50. .slot-tab.active{background:#111c2e;border-color:var(--acc2);color:var(--fg)}
  51. .slot-tab .dot{width:7px;height:7px;border-radius:50%;background:var(--bad);flex-shrink:0;transition:background .3s}
  52. .slot-tab .dot.up{background:var(--acc);animation:pulse-link 2.2s ease-in-out infinite}
  53. @keyframes pulse-link{
  54. 0%,100%{box-shadow:0 0 0 0 rgba(91,214,160,.5)}
  55. 55%{box-shadow:0 0 0 5px rgba(91,214,160,0)}
  56. }
  57. @media(prefers-reduced-motion:reduce){.slot-tab .dot.up{animation:none}}
  58. .per-slot-grid{margin-bottom:12px}
  59. </style>
  60. </head>
  61. <body>
  62. <div class="topbar">
  63. <span class="brand">OpenPuck <small>SC2 puck · WebUSB</small></span>
  64. <span id="connState" class="pill dn">disconnected</span>
  65. <button id="connectBtn" class="primary">Connect</button>
  66. <span class="spacer"></span>
  67. <button id="dfuSerial" title="Reboot into serial DFU (adafruit-nrfutil), then replug">Serial DFU</button>
  68. <button id="dfuUf2" title="Reboot into the UF2 mass-storage bootloader; drop a .uf2 on the drive">UF2 DFU</button>
  69. <button id="factoryErase" class="danger" title="Wipe ALL bonds + settings and reboot to defaults. You must re-pair. Cannot be undone.">⚠ Factory erase</button>
  70. </div>
  71. <div class="wrap">
  72. <p class="sub">First connect uses the Chrome/Edge device picker; reconnects are automatic. If the picker is empty, quit any app holding the device or replug.</p>
  73. <div id="panel" class="hide">
  74. <div class="cards">
  75. <div class="card">
  76. <h2>USB mode</h2>
  77. <div class="modes" style="flex-wrap:wrap">
  78. <button data-mode="0" class="modebtn">Steam (puck)</button>
  79. <button data-mode="1" class="modebtn">Xbox 360</button>
  80. <button data-mode="2" class="modebtn">Switch (HORIPAD)</button>
  81. <button data-mode="3" class="modebtn">Lizard (always)</button>
  82. <button data-mode="4" class="modebtn">Switch Pro + gyro</button>
  83. <button data-mode="5" class="modebtn">PS5 DualSense</button>
  84. <button data-mode="6" class="modebtn">HID gyro (DS4)</button>
  85. <button data-mode="9" class="modebtn">PS3 (DualShock 3)</button>
  86. </div>
  87. <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>
  88. <div class="row"><label>Persist last mode</label>
  89. <button id="persistMode">off</button>
  90. <span class="note" style="flex:1">Off (default): every restart / fresh reconnect boots into Steam mode. On: remembers the last mode you selected.</span>
  91. </div>
  92. </div>
  93. <div class="card">
  94. <h2>Back4 + face button chords</h2>
  95. <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>
  96. <div class="row"><label>B + back4 →</label><select class="chord" data-i="0"></select></div>
  97. <div class="row"><label>X + back4 →</label><select class="chord" data-i="1"></select></div>
  98. <div class="row"><label>Y + back4 →</label><select class="chord" data-i="2"></select></div>
  99. </div>
  100. <div class="card wide">
  101. <h2>Link status</h2>
  102. <!-- Per-controller tabs: visible only when 2+ controllers are bonded -->
  103. <div class="slot-tabs" id="slotTabs" style="display:none"></div>
  104. <!-- Per-slot: RF link / Battery / Signal -->
  105. <div class="statgrid per-slot-grid">
  106. <div class="stat"><div class="k">RF link</div><div class="v" id="stLink">—</div></div>
  107. <div class="stat"><div class="k">Battery</div><div class="v" id="stBatt">—</div></div>
  108. <div class="stat"><div class="k">Signal</div><div class="v" id="stRssi">—</div></div>
  109. </div>
  110. <!-- Global stats -->
  111. <div class="statgrid">
  112. <div class="stat"><div class="k">Delivered</div><div class="v" id="stRate">—</div></div>
  113. <div class="stat"><div class="k">New reports</div><div class="v" id="stNew">—</div></div>
  114. <div class="stat"><div class="k">Polls/s</div><div class="v" id="stPolls">—</div></div>
  115. <div class="stat"><div class="k">Relay/s</div><div class="v" id="stRelay">—</div></div>
  116. <div class="stat"><div class="k">RF fails/s (crc·noRx·heal)</div><div class="v" id="stRfFail">—</div></div>
  117. <div class="stat"><div class="k">Clock LF / HF</div><div class="v" id="stClock">—</div></div>
  118. <div class="stat"><div class="k">µs per ms (ideal 1000)</div><div class="v" id="stUsPerMs">—</div></div>
  119. <div class="stat"><div class="k">Loop</div><div class="v" id="stLoopState">—</div></div>
  120. <div class="stat"><div class="k">usbd stack free (words)</div><div class="v" id="stUsbdStack">—</div></div>
  121. <div class="stat logonly"><div class="k">Loop period</div><div class="v" id="stLoop">—</div></div>
  122. <div class="stat logonly"><div class="k">Slowest stage</div><div class="v" id="stWorst">—</div></div>
  123. <div class="stat logonly"><div class="k">Poll period act/want</div><div class="v" id="stPollPer">—</div></div>
  124. <div class="stat"><div class="k">Build (git)</div><div class="v" id="stBuild">—</div></div>
  125. <div class="stat"><div class="k">Last reset</div><div class="v" id="stReset">—</div></div>
  126. </div>
  127. <p class="note" style="margin-top:6px"><strong>Reset detail:</strong> <span id="resetHist" style="font-family:ui-monospace,Menlo,monospace">—</span><br>
  128. <em>watchdog (hang)</em>/<em>CPU lockup</em>/<em>HARDFAULT</em> = a fault worth reporting (issue #72); <em>reboot</em> = an intentional reset (mode change / config); <em>power-on</em> &amp; <em>pin/replug</em> = normal plug-in.</p>
  129. <p class="note" style="margin-top:8px"><strong>IMU (raw):</strong> <span id="stImu" style="font-family:ui-monospace,Menlo,monospace">—</span><br>
  130. 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>
  131. <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>
  132. <div class="row" style="margin:14px 0 0;border-top:1px solid #272d3d;padding-top:14px"><button id="hapClear">Clear stuck buzz</button>
  133. <span class="note" style="flex:1">If the controller's haptics get stuck/buzzing, this re-inits them (same as pressing Steam).</span>
  134. </div>
  135. <div class="row" style="margin:10px 0 0"><button id="ctlrOff">Turn off controller</button>
  136. <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>
  137. </div>
  138. <div class="row" style="margin:10px 0 0;border-top:1px solid #272d3d;padding-top:14px"><button id="stabBtn">Test stability</button>
  139. <span class="v" id="stabStatus" style="flex:0 0 auto;min-width:120px">—</span>
  140. <span class="note" style="flex:1">Buzzes the controllers every 10 s (keeps them awake) and times how long the puck stays up. On a reset it records the uptime and shows it by <em>Last reset</em>; it auto-resumes after the puck reconnects. The count is kept in this tab (a page refresh clears it).</span>
  141. </div>
  142. </div>
  143. <div class="card" id="mouseCard">
  144. <h2>Trackpad mouse (lizard + Xbox)</h2>
  145. <div class="row"><label>Sensitivity</label><input type="range" id="mDiv" min="4" max="200" step="1"><span class="val" id="mDivV"></span></div>
  146. <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>
  147. <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>
  148. </div>
  149. <div class="card">
  150. <h2>Rumble</h2>
  151. <div class="row"><label>Strength</label><input type="range" id="rumble" min="0" max="255" step="5"><span class="val" id="rumbleV"></span></div>
  152. <p class="note">Scales rumble amplitude across all modes (100% = native, 200% = double, 0% = off).</p>
  153. </div>
  154. <div class="card">
  155. <h2>Switch Pro motion</h2>
  156. <div class="row"><label>Report rate</label><select id="swRate"></select></div>
  157. <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>
  158. <div class="row"><label>Gyro scale</label><select id="swGyro"></select></div>
  159. <p class="note">Gyro sensitivity multiplier for Switch Pro mode (1.0× = unchanged).</p>
  160. </div>
  161. <div class="card wide">
  162. <h2>Button mapping</h2>
  163. <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>
  164. <div id="typeCfgs"></div>
  165. </div>
  166. <div class="card">
  167. <h2>Backup &amp; clone</h2>
  168. <p class="note" style="margin-top:0">Save this puck's controller pairings <em>and</em> every setting to a file, then restore them onto another puck. The other puck becomes a clone — any controller paired to this one connects to it with <strong>no re-pairing</strong>. (Also a plain backup: re-import after a factory erase to get everything back.)</p>
  169. <div class="row" style="margin:0;flex-wrap:wrap">
  170. <button id="backupExport" class="primary">Export to file…</button>
  171. <button id="backupImport">Import from file…</button>
  172. <input type="file" id="backupFile" accept="application/json,.json" style="display:none">
  173. </div>
  174. <p class="note">Import <strong>overwrites</strong> all pairings and settings on the connected puck, then reboots it (~2 s — reconnect after). Tip: pair every controller to one puck, export once, then import onto each spare.</p>
  175. </div>
  176. <div class="card wide">
  177. <h2>Hang log</h2>
  178. <p class="note" style="margin-top:0">One row per reset: uptime before it, reason, the stuck loop stage and captured PC (when the watchdog ISR could run), and usbd stack free. Accumulates across resets in this tab — a page refresh clears it. Pair with <em>Test stability</em> above for unattended runs.</p>
  179. <div class="row" style="margin:0 0 8px;flex-wrap:wrap">
  180. <span class="v" id="hangSummary" style="flex:1">no resets logged yet</span>
  181. <button id="hangCsv">Download CSV</button>
  182. <button id="hangClear">Clear</button>
  183. </div>
  184. <pre id="hangOut" style="font:12px ui-monospace,Menlo,monospace;color:var(--fg);background:#0e1017;border:1px solid #232a3a;border-radius:8px;padding:10px;margin:0;max-height:260px;overflow:auto;white-space:pre"></pre>
  185. </div>
  186. <div class="card wide logonly">
  187. <h2>Capture — host → controller commands</h2>
  188. <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>
  189. <div class="row" style="margin:0;flex-wrap:wrap">
  190. <button id="capStart" class="primary">Dump from boot</button>
  191. <button id="capStop" disabled>Stop</button>
  192. <button id="capDl">Download .txt</button>
  193. </div>
  194. <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>
  195. <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>
  196. </div>
  197. </div><!-- .cards -->
  198. <div id="log"></div>
  199. </div>
  200. </div>
  201. <script>
  202. 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)","PS3 (DualShock 3)"];
  203. const CHORD_FIELD = [17, 18, 19];
  204. let dev=null, epIn=0, epOut=0, ifNum=0, polling=false, capturing=false, backupBusy=false;
  205. let lastP=null; // most recent status-blob payload (config source for Export)
  206. // Stability test: armed = active across reconnects; stabStart = ms of the current run; stabLastRun = last
  207. // completed run {secs} (ended by a reset). Kept in-page so it survives the device reset (not a page refresh).
  208. let stabArmed=false, stabStart=0, stabLastRun=null;
  209. // Running hang log: one entry per reset (persists in-page across the device reset; a page refresh clears it).
  210. // pendingHang is set at disconnect and enriched with reason/PC/stack from the first blob after reconnect.
  211. let hangLog=[], pendingHang=null;
  212. function renderHangLog(){
  213. const out=$("#hangOut"), sum=$("#hangSummary"); if(!out) return;
  214. if(!hangLog.length){ out.textContent=""; sum.textContent="no resets logged yet"; return; }
  215. const ups=hangLog.filter(e=>e.uptime!=null).map(e=>e.uptime);
  216. const avg=ups.length?Math.round(ups.reduce((a,b)=>a+b,0)/ups.length):null;
  217. sum.textContent = hangLog.length+" reset(s)"+(avg!=null?(" · avg uptime "+fmtDur(avg)):"");
  218. const pad=(s,n)=>String(s).padEnd(n);
  219. out.textContent = pad("time",11)+pad("uptime",9)+pad("reason",16)+pad("stage",10)+pad("PC",11)+"usbd\n"
  220. + hangLog.map(e=>pad(e.time,11)+pad(e.uptime!=null?fmtDur(e.uptime):"-",9)+pad(e.reason,16)+pad(e.stage||"-",10)+pad(e.pc||"-",11)+(e.usbd!=null?e.usbd:"-")).join("\n");
  221. }
  222. function fmtDur(s){ s=Math.round(s); return s>=60 ? (Math.floor(s/60)+"m "+(s%60)+"s") : (s+"s"); }
  223. function updateStabUI(){
  224. const btn=$("#stabBtn"), st=$("#stabStatus");
  225. if(!btn) return;
  226. btn.textContent = stabArmed ? "Stop stability test" : "Test stability";
  227. btn.classList.toggle("active", stabArmed);
  228. let txt="";
  229. if(stabArmed && dev && stabStart) txt="up "+fmtDur((Date.now()-stabStart)/1000);
  230. else if(stabArmed && !dev) txt="reconnecting…";
  231. if(stabLastRun) txt += (txt?" · ":"")+"last run: "+fmtDur(stabLastRun.secs);
  232. st.textContent = txt||"—";
  233. }
  234. setInterval(updateStabUI, 500);
  235. const $ = s=>document.querySelector(s);
  236. function log(m){ const l=$("#log"); l.textContent=(new Date().toLocaleTimeString()+" "+m+"\n"+l.textContent).slice(0,2000); }
  237. // Per-emulated-type config (must match firmware ET_* order: Xbox=0, Switch=1, DS4=2, DS5=3). Each type lists
  238. // only the remap targets that exist on that controller. Field id sent to firmware = 40 + et*8 + k
  239. // (k: 0..3 back paddles, 4 QAM, 5 A/B-swap, 6 trackpad-haptics, 7 LED brightness). Blob v10 reads them back at p[73 + et*8 + ...].
  240. const DPAD = {12:"D-pad Up",13:"D-pad Down",14:"D-pad Left",15:"D-pad Right"};
  241. const TYPE_DEFS = [
  242. {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}},
  243. {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"}},
  244. {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"}},
  245. {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"}},
  246. ];
  247. // Which emulated type a USB mode belongs to (mirror of firmware etypeForMode); -1 = puck mode (no type).
  248. function etypeForMode(m){ switch(m){case 1:return 0; case 2:case 4:return 1; case 6:case 8:case 9:return 2; case 5:case 7:return 3; default:return -1;} }
  249. const BACK_LABELS = ["L4 (back upper-left)","R4 (back upper-right)","L5 (back lower-left)","R5 (back lower-right)"];
  250. const typeEls = []; // one entry per TYPE_DEFS: {sec, tab, activeDot, back[], qam, abSwap, pad, led, ledV}
  251. function mkSelect(def, includeNone){
  252. const sel=document.createElement("select");
  253. if(includeNone){ const o=document.createElement("option"); o.value=0; o.textContent="— none —"; sel.appendChild(o); }
  254. else { const o=document.createElement("option"); o.value=0; o.textContent="Default (per-mode)"; sel.appendChild(o); }
  255. for(const c of Object.keys(def.labels).map(Number).sort((a,b)=>a-b)){
  256. const o=document.createElement("option"); o.value=c; o.textContent=def.labels[c]; sel.appendChild(o); }
  257. return sel;
  258. }
  259. let curTab=0, tabInited=false;
  260. function setTab(et){
  261. curTab=et;
  262. typeEls.forEach((rec,i)=>{
  263. rec.sec.style.display = (i===et) ? "" : "none";
  264. rec.tab.classList.toggle("active", i===et);
  265. });
  266. }
  267. (function buildTypeCfgs(){
  268. const host=document.getElementById("typeCfgs");
  269. const bar=document.createElement("div"); bar.style.cssText="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px";
  270. host.appendChild(bar);
  271. TYPE_DEFS.forEach((def,et)=>{
  272. // tab button
  273. const tab=document.createElement("button"); tab.className="slot-tab"; tab.style.cursor="pointer";
  274. tab.innerHTML='<span>'+def.name+'</span><span class="active-dot" style="display:none;font-size:11px;color:var(--acc)"> ●</span>';
  275. tab.onclick=()=>setTab(et); bar.appendChild(tab);
  276. // panel
  277. const sec=document.createElement("div"); sec.style.cssText="background:#0e1017;border:1px solid #232a3a;border-radius:8px;padding:12px 14px;display:none";
  278. const rec={sec,tab,activeDot:tab.querySelector(".active-dot"),back:[],qam:null,abSwap:null,pad:null,led:null,ledV:null};
  279. // A/B swap + trackpad haptics toggles
  280. const tog=document.createElement("div"); tog.className="row";
  281. tog.innerHTML='<label>A/B + X/Y swap</label>';
  282. const ab=document.createElement("button"); ab.textContent="off"; tog.appendChild(ab);
  283. const padLbl=document.createElement("label"); padLbl.textContent="Trackpad haptics"; padLbl.style.marginLeft="14px"; tog.appendChild(padLbl);
  284. const pad=document.createElement("button"); pad.textContent="on"; tog.appendChild(pad);
  285. sec.appendChild(tog);
  286. rec.abSwap=ab; rec.pad=pad;
  287. ab.onclick=()=>{ const on=ab.classList.contains("active"); setField(40+et*8+5, on?0:1); };
  288. pad.onclick=()=>{ const on=pad.classList.contains("active"); setField(40+et*8+6, on?0:1); };
  289. // back paddles
  290. for(let i=0;i<4;i++){
  291. const row=document.createElement("div"); row.className="row";
  292. const lab=document.createElement("label"); lab.textContent=BACK_LABELS[i]; row.appendChild(lab);
  293. const sel=mkSelect(def,true); row.appendChild(sel); sec.appendChild(row);
  294. sel.addEventListener("change",()=>setField(40+et*8+i, +sel.value));
  295. rec.back.push(sel);
  296. }
  297. // QAM
  298. { const row=document.createElement("div"); row.className="row";
  299. const lab=document.createElement("label"); lab.textContent="QAM (3 dots)"; row.appendChild(lab);
  300. const sel=mkSelect(def,false); row.appendChild(sel); sec.appendChild(row);
  301. sel.addEventListener("change",()=>setField(40+et*8+4, +sel.value));
  302. rec.qam=sel; }
  303. // LED brightness
  304. { const row=document.createElement("div"); row.className="row";
  305. const lab=document.createElement("label"); lab.textContent="LED brightness"; row.appendChild(lab);
  306. const sl=document.createElement("input"); sl.type="range"; sl.min=0; sl.max=100; sl.step=5; row.appendChild(sl);
  307. const vspan=document.createElement("span"); vspan.className="val"; row.appendChild(vspan);
  308. function fmtLed(v){ return (+v===0)?"Auto":v+"%"; }
  309. sl.addEventListener("input",()=>{ vspan.textContent=fmtLed(sl.value); });
  310. sl.addEventListener("change",()=>setField(40+et*8+7, +sl.value));
  311. sec.appendChild(row);
  312. rec.led=sl; rec.ledV=vspan; }
  313. host.appendChild(sec); typeEls.push(rec);
  314. });
  315. setTab(0);
  316. })();
  317. for(const sel of document.querySelectorAll("select.chord")){
  318. MODE_NAMES.forEach((n,i)=>{ if(i===7||i===8) return;
  319. const o=document.createElement("option"); o.value=i; o.textContent=n; sel.appendChild(o); });
  320. }
  321. { const sel=document.getElementById("swRate");
  322. for(const [lbl,v] of [["Full rate (~250 Hz, default)",2],["120 Hz",1],["66 Hz (compatibility)",0]]){
  323. const o=document.createElement("option"); o.value=v; o.textContent=lbl; sel.appendChild(o); }
  324. }
  325. { const sel=document.getElementById("swGyro");
  326. for(const [lbl,v] of [["0.5×",5],["1.0×",10],["1.5×",15],["2.0×",20],["2.5×",25],["3.0×",30]]){
  327. const o=document.createElement("option"); o.value=v; o.textContent=lbl; sel.appendChild(o); }
  328. }
  329. // ---- per-slot tab state ----
  330. let g_activeSlot = 0;
  331. let g_slotData = []; // [{up, battery, rssi}] indexed by slot 0..3, only set for bonded slots
  332. function updateSlotDisplay(slotIdx) {
  333. const d = g_slotData[slotIdx];
  334. if (!d) { $("#stLink").innerHTML="—"; $("#stBatt").textContent="—"; $("#stRssi").textContent="—"; return; }
  335. $("#stLink").innerHTML = d.up ? '<span class="pill up">up</span>' : '<span class="pill dn">down</span>';
  336. $("#stBatt").textContent = (d.up && d.battery) ? d.battery+"%" : "—";
  337. $("#stRssi").textContent = (d.up && d.rssi) ? ("-"+d.rssi+" dBm") : "—";
  338. }
  339. function renderSlotTabs(bondedCount) {
  340. const bar = $("#slotTabs");
  341. if (bondedCount <= 1) { bar.style.display="none"; updateSlotDisplay(g_activeSlot); return; }
  342. bar.style.display="flex";
  343. bar.innerHTML="";
  344. for (let s=0; s<4; s++) {
  345. const d = g_slotData[s];
  346. if (!d) continue;
  347. const btn = document.createElement("button");
  348. btn.className = "slot-tab" + (s===g_activeSlot ? " active" : "");
  349. const dot = document.createElement("span");
  350. dot.className = "dot" + (d.up ? " up" : "");
  351. btn.appendChild(dot);
  352. btn.appendChild(document.createTextNode("Controller "+(s+1)));
  353. btn.onclick = () => { g_activeSlot=s; renderSlotTabs(bondedCount); };
  354. bar.appendChild(btn);
  355. }
  356. updateSlotDisplay(g_activeSlot);
  357. }
  358. const USB_FILTERS=[{vendorId:0x28DE},{vendorId:0x045E},{vendorId:0x057E},{vendorId:0x0F0D},{vendorId:0x054C}];
  359. // Acquire+set up an already-chosen USBDevice (shared by the manual picker and the no-picker auto-reconnect).
  360. async function openDevice(d){
  361. try{
  362. dev=d;
  363. await dev.open();
  364. if(dev.configuration===null) await dev.selectConfiguration(1);
  365. let found=null;
  366. for(const itf of dev.configuration.interfaces){
  367. const a=itf.alternate;
  368. if(a.interfaceClass!==0xFF) continue;
  369. if(a.interfaceSubClass===0x5D) continue;
  370. let bin=0,bout=0;
  371. for(const e of a.endpoints){ if(e.type==="bulk"){ if(e.direction==="in")bin=e.endpointNumber; else bout=e.endpointNumber; } }
  372. if(bin&&bout){ found={ifNum:itf.interfaceNumber, epIn:bin, epOut:bout}; break; }
  373. }
  374. if(!found){ log("no WebUSB bulk vendor interface — reflash firmware with WebUSB enabled, or another app may be holding the device"); dev=null; return false; }
  375. ifNum=found.ifNum; epIn=found.epIn; epOut=found.epOut;
  376. await dev.claimInterface(ifNum);
  377. await dev.controlTransferOut({requestType:"class", recipient:"interface", request:0x22, value:0x01, index:ifNum});
  378. $("#connState").textContent="connected"; $("#connState").className="pill up";
  379. $("#connState").title = dev.serialNumber ? ("puck serial "+dev.serialNumber) : "";
  380. $("#connectBtn").textContent="Reconnect";
  381. $("#panel").classList.remove("hide");
  382. log(`connected — puck ${dev.serialNumber||"?"} (VID ${dev.vendorId.toString(16)} iface ${ifNum})`);
  383. // resume a stability test across the reset: re-arm the firmware buzz and start timing the next run
  384. if(stabArmed){ await send([0x0F,1]); stabStart=Date.now(); log("stability test resumed — timing next run"); }
  385. updateStabUI();
  386. startPolling();
  387. return true;
  388. }catch(e){ log("connect failed: "+e.message); dev=null; return false; }
  389. }
  390. // Manual connect: shows the Chrome device picker (needed once, to authorize the device).
  391. async function connect(){
  392. try{ const d = await navigator.usb.requestDevice({filters:USB_FILTERS}); await openDevice(d); }
  393. catch(e){ log("connect failed: "+e.message); }
  394. }
  395. // No-picker reconnect: reopen a device already authorized this browser. Returns true if it connected.
  396. async function autoConnect(){
  397. if(dev) return true;
  398. try{
  399. const ds = await navigator.usb.getDevices();
  400. const d = ds.find(x=>USB_FILTERS.some(f=>f.vendorId===x.vendorId));
  401. if(d) return await openDevice(d);
  402. }catch(e){}
  403. return false;
  404. }
  405. function onGone(){
  406. // if a stability test is running, this disconnect is the reset that ended the run -- record its uptime
  407. // (kept in-page so it survives the reset + reconnect, shown next to Last reset).
  408. let up=null;
  409. if(stabArmed && stabStart){ up=(Date.now()-stabStart)/1000; stabLastRun={secs:up}; log("⏱ stability: stayed up "+fmtDur(up)+", then reset"); stabStart=0; }
  410. // queue a hang-log entry; the reason/PC/stack are filled from the first blob after reconnect
  411. pendingHang = { t: Date.now(), uptimeSecs: up };
  412. polling=false; dev=null; tabInited=false; // re-default the config tab to the mode on next connect
  413. $("#connState").textContent="reconnecting…"; $("#connState").className="pill dn";
  414. $("#panel").classList.add("hide");
  415. log("device disconnected (mode-switch / watchdog reboot) — auto-reconnecting when it returns");
  416. // The puck re-enumerates after a reboot; retry without the picker. The "connect" event also triggers this,
  417. // but poll a few times in case the event is missed.
  418. let tries=0;
  419. const iv=setInterval(async()=>{ if(dev||tries++>40){ clearInterval(iv); return; } if(await autoConnect()) clearInterval(iv); }, 500);
  420. }
  421. // Auto-reconnect when an authorized puck (re)appears — no button, no picker.
  422. navigator.usb && navigator.usb.addEventListener("connect", e=>{ if(!dev) autoConnect(); });
  423. navigator.usb && navigator.usb.addEventListener("disconnect", e=>{ if(e.device===dev) onGone(); });
  424. async function send(bytes){
  425. if(!dev) return;
  426. try{ await dev.transferOut(epOut, new Uint8Array(bytes)); }
  427. catch(e){ log("write err: "+e.message); }
  428. }
  429. // Read one framed reply and return its payload (with the 2-byte [marker][len] header stripped). The blob and
  430. // the bond-export dump share the same framing, distinguished by the marker byte (0xA5 status, 0xA7 bonds).
  431. async function readFrame(marker, minLen){
  432. try{
  433. // largest frame (status blob) spans multiple USB-FS packets; read generously to capture it in one call.
  434. const r = await dev.transferIn(epIn, 192);
  435. if(r.status!=="ok"||r.data.byteLength<2) return null;
  436. const d=new Uint8Array(r.data.buffer);
  437. let i=0; while(i<d.length && d[i]!==marker) i++;
  438. if(i+2>d.length) return null;
  439. const len=d[i+1]; const p=d.slice(i+2, i+2+len);
  440. return p.length>=(minLen||0) ? p : null;
  441. }catch(e){ if(dev) log("read err: "+e.message); return null; }
  442. }
  443. async function readBlob(){ return readFrame(0xA5, 12); }
  444. function applyBlob(p){
  445. lastP=p; // remember for Export (carries every config field the panel shows)
  446. const mode=p[1], mDiv=p[2], mFric=p[3]; // p[4..11] mirror the active type (per-type block below is authoritative)
  447. const slot=p[10], up=p[11], f1=(p[12]|(p[13]<<8));
  448. const newps=(p.length>16?(p[15]|(p[16]<<8)):0), persistMode=(p.length>22?p[22]:0);
  449. const chord=(p.length>25)?[p[23],p[24],p[25]]:[3,1,4];
  450. const polls=(p.length>27?(p[26]|(p[27]<<8)):0);
  451. const loopUs=(p.length>29?(p[28]|(p[29]<<8)):0);
  452. const worstIdx=(p.length>30?p[30]:0), worstUs=(p.length>32?(p[31]|(p[32]<<8)):0);
  453. const WORST=["webusb","ctrl.task","serial","rfdiag","rflink","haptic","led"];
  454. const pollUs=(p.length>34?(p[33]|(p[34]<<8)):0);
  455. const pollIntended=(p.length>14?p[14]*100:0);
  456. const logEnabled=(p.length>35?p[35]:0);
  457. for(const el of document.querySelectorAll(".logonly")) el.style.display = logEnabled ? "" : "none";
  458. for(const b of document.querySelectorAll(".modebtn")) b.classList.toggle("active", +b.dataset.mode===mode);
  459. $("#mouseCard").style.opacity = (mode===1||mode===3)?1:0.5;
  460. const battery=(p.length>36?p[36]:0);
  461. const rumbleScale=(p.length>51?p[51]:200);
  462. const swProRate=(p.length>52?p[52]:2);
  463. const swGyro10=(p.length>53?p[53]:10);
  464. const rssi=(p.length>37?p[37]:0);
  465. // per-slot link status (protocol v8, p.length >= 73)
  466. const hasSlotsData = p.length >= 73;
  467. if (hasSlotsData) {
  468. const bondedN = p[60];
  469. g_slotData = [];
  470. for (let s=0; s<4; s++) {
  471. const base = 61 + s*3;
  472. if (p.length >= base+3) {
  473. // only include bonded slots (a slot with up=0 could be bonded but offline)
  474. // check if the slot appears at all in the bonded map by checking if bondedN covers it;
  475. // the firmware only sets non-zero battery/rssi for actually-bonded slots
  476. const slotUp = !!p[base];
  477. const slotBatt = p[base+1];
  478. const slotRssi = p[base+2];
  479. // slot is "bonded" if firmware included it in bondedN coverage
  480. // we populate slotData for all 4 and renderSlotTabs skips gaps
  481. g_slotData[s] = {up: slotUp, battery: slotBatt, rssi: slotRssi};
  482. }
  483. }
  484. // keep only bonded slots by cross-referencing bondedN
  485. // firmware sends bondedSlotCount but not a slot bitmask; use battery>0 || rssi>0 || up as presence hint
  486. // simpler: just count how many of the 4 slots have any non-zero data OR are marked up
  487. // Actually the firmware always writes all 4 slots; we filter by the bond system's "used" flag via presence.
  488. // For clean display: we'll show tabs only for slots that have ever had a reply OR are bonded (battery/rssi set).
  489. // The firmware sets g_battery[s]=0 and g_linkRssi[s]=0 for unused slots, so 0/0/0 = not bonded.
  490. // But a bonded offline slot also has 0/0/0 initially. We rely on bondedN for count and show the first bondedN slots
  491. // that have g_slot[s].used — but we can't easily know which slots are used from the blob.
  492. // Best heuristic: show all 4 slots if bondedN==4, otherwise use slot indices where battery or rssi != 0 OR up==1,
  493. // OR fall back to showing slots 0..bondedN-1 if none have data yet (device just booted).
  494. let bondedSlots = [];
  495. for (let s=0; s<4; s++) {
  496. if (g_slotData[s] && (g_slotData[s].up || g_slotData[s].battery || g_slotData[s].rssi)) bondedSlots.push(s);
  497. }
  498. // fallback: if no data yet (all offline, just booted), show slots 0..bondedN-1
  499. if (bondedSlots.length === 0 && bondedN > 0) {
  500. for (let s=0; s<bondedN; s++) bondedSlots.push(s);
  501. }
  502. // remove slotData for non-bonded slots so tabs don't appear for them
  503. const bondedSet = new Set(bondedSlots);
  504. for (let s=0; s<4; s++) { if (!bondedSet.has(s)) g_slotData[s] = null; }
  505. // clamp active slot to a valid bonded slot
  506. if (!g_slotData[g_activeSlot]) g_activeSlot = bondedSlots[0] || 0;
  507. renderSlotTabs(bondedN);
  508. } else {
  509. // old firmware: single-slot display
  510. $("#slotTabs").style.display="none";
  511. $("#stLink").innerHTML = up? '<span class="pill up">up</span>' : '<span class="pill dn">down</span>';
  512. $("#stBatt").textContent = (up && battery)? battery+"%" : "—";
  513. $("#stRssi").textContent = (up && rssi)? ("-"+rssi+" dBm") : "—";
  514. }
  515. // global stats
  516. $("#stRate").textContent = up? f1+" /s" : "—";
  517. $("#stNew").textContent = up? newps+" /s" : "—";
  518. // Poll TX + RF-fail counts: shown ALWAYS (even when the link reads down) so a wedge is diagnosable --
  519. // polls>0 here while Delivered is "—" means the puck is still polling but getting no usable replies.
  520. $("#stPolls").textContent = polls+" /s";
  521. { const crc=(p.length>113?p[113]:0), norx=(p.length>114?p[114]:0), heal=(p.length>115?p[115]:0);
  522. const rf=(p.length>126?(p[125]|(p[126]<<8)):0);
  523. $("#stRfFail").innerHTML = crc+" · "+norx+" · "+heal + (rf?' · <span class="pill dn">ring '+rf+'</span>':"");
  524. // ring fault = we caught+recovered a relay-ring corruption that would otherwise be an invisible IRQ-off
  525. // watchdog hang. Log each new one (persists across the device reset + panel reconnect).
  526. if(rf>(window._ringFault||0)) log("⚠ RING FAULT #"+rf+" recovered — caught a relay-ring desync/corruption (would have hung loop with IRQs off)");
  527. window._ringFault=rf; }
  528. // v12: relay-frame rate + clock fingerprint (browser index = firmware index − 2)
  529. { const relay=(p.length>117?(p[116]|(p[117]<<8)):null);
  530. $("#stRelay").textContent = relay==null ? "—" : (relay+" /s"); }
  531. if(p.length>121){
  532. const LF=["stopped","RC","xtal","synth"], HF=["RC","?","xtal","?"];
  533. const lf=p[118], hf=p[119], upm=(p[120]|(p[121]<<8));
  534. const lfBad=(lf!==2), hfBad=(hf!==2), upmBad=(upm && (upm<985||upm>1015));
  535. const tag=(t,bad)=> bad ? '<span class="pill dn">'+t+'</span>' : t;
  536. $("#stClock").innerHTML = tag(LF[lf]||lf, lfBad)+" / "+tag(HF[hf]||hf, hfBad);
  537. $("#stUsPerMs").innerHTML = upm ? tag(""+upm, upmBad) : "—";
  538. }
  539. // v12: LIVE loop heartbeat — curStage (firmware p[125]) + ms-since-beat (p[126], 40ms units). Updates even
  540. // while loop() is wedged (blob is pushed from the SOF interrupt), so a hang shows "STALLED @ <stage>" live.
  541. if(p.length>124){
  542. const STAGE=["webusb","ctrl.task","serial","rfdiag","rflink","haptic","led","usbmount","usbtx"];
  543. const cur=p[123], stallMs=p[124]*40, name=(STAGE[cur]||("stage "+cur));
  544. if(stallMs>=200){
  545. $("#stLoopState").innerHTML='<span class="pill dn">STALLED @ '+name+' '+stallMs+'ms</span>';
  546. // Latch+log the stall so it survives the imminent watchdog reset + panel reconnect (host-side log
  547. // persists). Log once per episode + keep escalating if it climbs toward the ~8s watchdog.
  548. if(!window._stallEpisode || stallMs>window._stallPeak+800){
  549. window._stallEpisode=true; window._stallPeak=stallMs;
  550. log("⚠ LOOP STALL @ "+name+" ("+stallMs+"ms) — watchdog reset imminent");
  551. }
  552. } else {
  553. window._stallEpisode=false; window._stallPeak=0;
  554. $("#stLoopState").innerHTML='<span class="pill up">running</span>';
  555. }
  556. }
  557. // v12: least-ever free stack on the usbd task (words). Low = overflow risk (multi-controller haptic-relay
  558. // hang hypothesis). Firmware p[137] -> browser p[135]. Red under 24 words (~96B), amber under 48.
  559. if(p.length>136){ const usbdW=(p[135]|(p[136]<<8));
  560. $("#stUsbdStack").innerHTML = usbdW<24 ? ('<span class="pill dn">'+usbdW+'</span>')
  561. : (usbdW<48 ? ('<span class="pill" style="background:#3a3216;color:#e5c76b">'+usbdW+'</span>') : (""+usbdW));
  562. }
  563. $("#stLoop").textContent = loopUs? loopUs+" µs" : "—";
  564. $("#stWorst").textContent = loopUs? (WORST[worstIdx]||worstIdx)+" "+worstUs+"µs" : "—";
  565. $("#stPollPer").textContent = up? (pollUs+" / "+pollIntended+" µs") : "—";
  566. // last-boot reset cause (protocol v11): a single classified code (firmware classifies it from RESETREAS +
  567. // a GPREGRET2 marker) plus the raw RESETREAS. Codes match RR_* in fault_diag.h. Earlier firmware (<v11)
  568. // didn't send this -> guard on length and leave the tile blank.
  569. const RR_NAMES = ["unknown","power-on","pin/replug","watchdog (hang)","CPU lockup",
  570. "HARDFAULT","reboot","soft reset","wake-from-off"];
  571. const RR_FAULT = new Set([3,4,5]); // watchdog / lockup / hardfault = a real crash worth reporting (#72)
  572. if (p.length > 109) {
  573. const code = p[105];
  574. const raw = ((p[106]|(p[107]<<8)|(p[108]<<16)|(p[109]<<24))>>>0);
  575. const name = RR_NAMES[code] || ("code "+code);
  576. // v12: which loop stage was stuck when the last watchdog/lockup hang fired (0xFF = last boot wasn't a hang)
  577. const STAGE_NAMES = ["webusb","ctrl.task","serial","rfdiag","rflink","haptic","led","usbmount","usbtx"];
  578. const hangStage = (p.length>122 ? p[122] : 0xFF);
  579. let label = name;
  580. if (hangStage!==0xFF) label += " @ "+(STAGE_NAMES[hangStage]||("stage "+hangStage));
  581. $("#stReset").innerHTML = RR_FAULT.has(code) ? ('<span class="pill dn">'+label+'</span>') : label;
  582. // v12: PC/LR captured by the WDT pre-reset ISR (browser idx = firmware−2: hangPC@127, hangLR@131)
  583. const hpc=(p.length>130?((p[127]|(p[128]<<8)|(p[129]<<16)|(p[130]<<24))>>>0):0);
  584. const hlr=(p.length>134?((p[131]|(p[132]<<8)|(p[133]<<16)|(p[134]<<24))>>>0):0);
  585. const hx=v=>"0x"+v.toString(16).padStart(8,"0");
  586. const el = $("#resetHist");
  587. if (el) el.textContent = "RESETREAS=0x"+raw.toString(16).padStart(8,"0")
  588. + (hangStage!==0xFF ? " hung in: "+(STAGE_NAMES[hangStage]||hangStage) : "")
  589. + (hpc ? " hang PC="+hx(hpc)+" LR="+hx(hlr) : "")
  590. + (stabLastRun ? " · stability: stayed up "+fmtDur(stabLastRun.secs)+" before this reset" : "");
  591. if (hpc && hpc!==window._hangPC){ window._hangPC=hpc; log("⚑ HANG PC="+hx(hpc)+" LR="+hx(hlr)+" — send me this; I'll map it to the stuck function"); }
  592. // append the queued reset to the running hang log, now that we have this boot's reason/PC/stack
  593. if (pendingHang){
  594. const usbdW2=(p.length>136?(p[135]|(p[136]<<8)):0);
  595. hangLog.unshift({ time:new Date(pendingHang.t).toLocaleTimeString(), uptime:pendingHang.uptimeSecs,
  596. reason:name, stage:(hangStage!==0xFF)?(STAGE_NAMES[hangStage]||("stage"+hangStage)):"",
  597. pc:hpc?hx(hpc):"", lr:hlr?hx(hlr):"", usbd:usbdW2 });
  598. pendingHang=null; renderHangLog();
  599. }
  600. }
  601. const gitDirty=(p.length>38?p[38]:0); let gitHash="";
  602. for(let i=39;i<51 && i<p.length && p[i];i++) gitHash+=String.fromCharCode(p[i]);
  603. $("#stBuild").innerHTML = gitHash
  604. ? (gitHash + (gitDirty ? ' <span class="pill dn">dirty</span>' : ' <span class="pill up">clean</span>'))
  605. : "—";
  606. setSlider("mDiv",mDiv); setSlider("mFric",mFric); setSlider("rumble",rumbleScale);
  607. $("#persistMode").textContent = persistMode? "on":"off"; $("#persistMode").classList.toggle("active",!!persistMode);
  608. // per-type button config (blob v10): 4 types x 8 bytes. readBlob() strips the 2-byte [0xA5][len] header, so
  609. // the firmware's buffer index 75 lands at JS index 73 here.
  610. if(p.length>=73+TYPE_DEFS.length*8){
  611. const activeEt=etypeForMode(mode);
  612. typeEls.forEach((rec,et)=>{
  613. const q=73+et*8;
  614. 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], led=p[q+7];
  615. rec.back.forEach((sel,i)=>{ if(document.activeElement!==sel) sel.value=back[i]; });
  616. if(document.activeElement!==rec.qam) rec.qam.value=qam;
  617. rec.abSwap.textContent=ab?"on":"off"; rec.abSwap.classList.toggle("active",!!ab);
  618. rec.pad.textContent=pad?"on":"off"; rec.pad.classList.toggle("active",!!pad);
  619. if(document.activeElement!==rec.led){ rec.led.value=led; rec.ledV.textContent=(led===0)?"Auto":led+"%"; }
  620. // dot marks the type matching the current puck mode
  621. rec.activeDot.style.display = (et===activeEt) ? "" : "none";
  622. });
  623. // open the tab for the current mode's type on first load (then leave the user's choice alone)
  624. if(!tabInited){ tabInited=true; setTab(activeEt>=0?activeEt:0); }
  625. }
  626. if(p.length>=60){ const s16=(o)=>{ let v=p[o]|(p[o+1]<<8); return v>32767?v-65536:v; };
  627. const ax=s16(54),ay=s16(56),az=s16(58);
  628. const amag=Math.round(Math.sqrt(ax*ax+ay*ay+az*az));
  629. $("#stImu").textContent = `a=(${ax}, ${ay}, ${az}) |a|=${amag}`;
  630. }
  631. { const sel=$("#swRate"); if(document.activeElement!==sel) sel.value=swProRate; }
  632. { const sel=$("#swGyro"); if(document.activeElement!==sel) sel.value=swGyro10; }
  633. for(const sel of document.querySelectorAll("select.chord")){ if(document.activeElement!==sel) sel.value=chord[+sel.dataset.i]; }
  634. }
  635. function fmtSlider(id,v){ return id==="rumble" ? v+"%" : id==="hapBlockS" ? v+" s" : ""+v; }
  636. function setSlider(id,v){ const el=$("#"+id); if(document.activeElement!==el){ el.value=v; } $("#"+id+"V").textContent=fmtSlider(id,el.value); }
  637. let inflight=false;
  638. async function refresh(){
  639. if(!dev||inflight||capturing||backupBusy) return; inflight=true;
  640. await send([0x01]); const p=await readBlob(); if(p) applyBlob(p);
  641. inflight=false;
  642. }
  643. // ---- Backup / clone ----
  644. // A backup file is { magic, version, bonds[4], config{} }. bonds come from the 0x09 export (0xA7 frame); the
  645. // config fields come from the status blob the panel already reads. Restoring replays every setting via the
  646. // normal 0x02 setters, then sends the 0x0D bond-import which writes the pairings, applies the mode and reboots.
  647. function hexEnc(arr){ return [...arr].map(x=>x.toString(16).padStart(2,"0")).join(""); }
  648. function hexDec(h){ const a=[]; if(!h) return a; for(let i=0;i+1<h.length;i+=2) a.push(parseInt(h.substr(i,2),16)); return a; }
  649. function buildBackup(p, bp){
  650. const mask=bp[1], bonds=[];
  651. for(let s=0;s<4;s++){ const rec=bp.slice(2+s*24, 2+s*24+24);
  652. bonds.push({slot:s, used:!!((mask>>s)&1), rec:hexEnc(rec)}); }
  653. const types=[];
  654. for(let et=0; et<TYPE_DEFS.length; et++){ const q=73+et*8;
  655. types.push({back:[p[q],p[q+1],p[q+2],p[q+3]], qam:p[q+4], abSwap:p[q+5], pad:p[q+6], led:p[q+7]}); }
  656. return { magic:"openpuck-backup", version:1, bonds,
  657. config:{ mode:p[1], mDiv:p[2], mFric:p[3], persistMode:p[22], chord:[p[23],p[24],p[25]],
  658. rumble:p[51], swProRate:p[52], swGyro10:p[53], types } };
  659. }
  660. function downloadBackup(obj){
  661. const ts=new Date().toISOString().slice(0,19).replace(/[:T]/g,"-");
  662. const a=document.createElement("a");
  663. a.href=URL.createObjectURL(new Blob([JSON.stringify(obj,null,2)],{type:"application/json"}));
  664. a.download="openpuck-backup-"+ts+".json"; a.click();
  665. }
  666. // let any in-flight poll read settle before we drive the shared endpoints ourselves
  667. async function waitIdle(){ for(let i=0;i<60 && inflight;i++) await new Promise(r=>setTimeout(r,5)); }
  668. async function exportBackup(){
  669. if(!dev){ log("not connected"); return; }
  670. backupBusy=true; await waitIdle();
  671. try{
  672. // grab a fresh config snapshot, then the bond dump
  673. await send([0x01]); const cfg=await readBlob(); if(cfg) lastP=cfg;
  674. if(!lastP){ log("export: no config snapshot yet — wait a second and retry"); return; }
  675. await send([0x09]);
  676. let bp=null;
  677. for(let t=0; t<8 && !bp; t++) bp=await readFrame(0xA7, 2+4*24);
  678. if(!bp){ log("export: no bond data (firmware too old for 0x09 export — reflash)"); return; }
  679. const backup=buildBackup(lastP, bp);
  680. downloadBackup(backup);
  681. const n=backup.bonds.filter(b=>b.used).length;
  682. log("exported backup — "+n+" bonded controller slot(s) + all settings");
  683. } finally { backupBusy=false; }
  684. }
  685. async function importBackup(file){
  686. if(!dev){ log("not connected"); return; }
  687. let obj; try{ obj=JSON.parse(await file.text()); }
  688. catch(e){ log("import: not valid JSON"); return; }
  689. if(obj.magic!=="openpuck-backup" || !obj.bonds || !obj.config){ log("import: unrecognized backup file"); return; }
  690. const bondedN=obj.bonds.filter(b=>b.used).length;
  691. if(!confirm("Restore this backup onto the CONNECTED puck (serial "+(dev.serialNumber||"?")+")?\n\nMake sure this is the TARGET puck, not the one you exported from — the panel only talks to the puck you last connected.\n\nThis OVERWRITES its "+bondedN+" controller pairing(s) and ALL settings, then reboots it.\n\nResult: any controller paired to the original puck will connect to this one with no re-pairing.")) return;
  692. log("importing onto puck "+(dev.serialNumber||"?"));
  693. backupBusy=true; await waitIdle();
  694. try{
  695. // Capability probe: the connected puck MUST run firmware new enough to answer 0x09 with a 0xA7 frame,
  696. // otherwise it will silently ignore the 0x0D bond-import below. This is the #1 import failure: the panel
  697. // is connected to a puck still on old firmware. Catch it loudly instead of "nothing happened".
  698. await send([0x09]);
  699. let probe=null; for(let t=0;t<8 && !probe;t++) probe=await readFrame(0xA7, 2+4*24);
  700. if(!probe){
  701. log("import ABORTED — the connected puck does not support import (old firmware).");
  702. alert("This puck's firmware is too old to import a backup.\n\nFlash it with the latest OpenPuck build (the same one that produced the export), reconnect, then import again.");
  703. return;
  704. }
  705. const c=obj.config;
  706. // Every step is a write-then-read transaction (same shape as the proven 0x02 sliders): the firmware acks
  707. // each with a status blob, which we read back to keep the OUT pipe flowing and naturally pace the writes.
  708. const w=async(bytes)=>{ await send(bytes); await readBlob(); };
  709. const sf=async(f,v)=>{ await w([0x02, f, (v|0)&0xff]); };
  710. // replay every setting (each persists; none reboot). mode is applied by the 0x0E commit below.
  711. await sf(1,c.mDiv); await sf(2,c.mFric); await sf(22,c.rumble); await sf(16,c.persistMode?1:0);
  712. await sf(17,c.chord[0]); await sf(18,c.chord[1]); await sf(19,c.chord[2]);
  713. await sf(23,c.swProRate); await sf(24,c.swGyro10);
  714. for(let et=0; et<c.types.length && et<4; et++){ const t=c.types[et];
  715. for(let k=0;k<4;k++) await sf(40+et*8+k, t.back[k]);
  716. await sf(40+et*8+4, t.qam); await sf(40+et*8+5, t.abSwap?1:0);
  717. await sf(40+et*8+6, t.pad?1:0); await sf(40+et*8+7, t.led); }
  718. log("settings replayed ("+(9+c.types.length*8)+" fields)");
  719. // write each bond slot with its own small command [0x0D][slot][used][24-byte rec]
  720. let n=0;
  721. for(let s=0;s<4;s++){
  722. const b=obj.bonds.find(x=>x.slot===s) || {used:false, rec:""};
  723. const rec=hexDec(b.rec), r24=new Array(24).fill(0);
  724. for(let i=0;i<24 && i<rec.length;i++) r24[i]=rec[i];
  725. const used=(b.used && rec.length===24)?1:0; if(used) n++;
  726. await w([0x0D, s, used, ...r24]);
  727. }
  728. log("wrote "+n+" bond slot(s) — committing + rebooting");
  729. // commit: persist bonds, apply mode, reboot (no ack — the device reboots)
  730. await send([0x0E, (c.mode!==undefined?c.mode:0xFF)&0xff]);
  731. log("import sent — puck is cloning and rebooting. Reconnect after it returns.");
  732. } finally { backupBusy=false; }
  733. }
  734. async function setField(field,value){
  735. await send([0x02, field, value&0xff]); const p=await readBlob(); if(p) applyBlob(p);
  736. }
  737. function fmtCapEntry(e,maxMs){
  738. const hex=e.bytes.map(x=>x.toString(16).padStart(2,"0")).join(" ");
  739. const t = `-${String(maxMs-e.ms).padStart(6," ")}ms `;
  740. 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"} ════════`; }
  741. if(e.slot===0xFC){ return `${t}GET←host rid=${e.rid.toString(16).padStart(2,"0")} n=${e.nb}: ${hex}`; } // host feature GET (battery poll lives here)
  742. if(e.slot===0xFB){ return `${t}→host rid=${e.rid.toString(16).padStart(2,"0")} n=${e.nb}: ${hex}`; } // pushed device→host status (0x43 battery / 0x7B / 0x79)
  743. const who = (e.slot===0xFE) ? "TX→ctlr " : `if${e.slot} `;
  744. return `${t}${who} cmd=${e.rid.toString(16).padStart(2,"0")} n=${e.nb}: ${hex}`;
  745. }
  746. function downloadCap(){
  747. const t=window._lastCap||$("#capOut").textContent||"";
  748. const a=document.createElement("a");
  749. a.href=URL.createObjectURL(new Blob([t],{type:"text/plain"}));
  750. a.download="puck-capture.txt"; a.click();
  751. }
  752. let liveTimer=null, draining=false, capLines=[];
  753. function renderCap(){
  754. if(!capLines.length){ $("#capOut").textContent="(nothing captured yet)"; window._lastCap=""; return; }
  755. const maxMs=capLines[capLines.length-1].ms;
  756. const t=capLines.map(e=>fmtCapEntry(e,maxMs)).join("\n");
  757. $("#capOut").textContent=t; window._lastCap=t;
  758. }
  759. async function drainTick(){
  760. if(!dev||draining) return; draining=true;
  761. let acc=new Uint8Array(0);
  762. try{
  763. await send([0x06]);
  764. let done=false, guard=0;
  765. while(!done && guard++<400){
  766. const r=await dev.transferIn(epIn,64);
  767. if(r.status!=="ok") break;
  768. const d=new Uint8Array(r.data.buffer);
  769. const m=new Uint8Array(acc.length+d.length); m.set(acc); m.set(d,acc.length); acc=m;
  770. let i=0;
  771. while(i<acc.length){
  772. if(acc[i]!==0xA6){ i++; continue; }
  773. if(i+2>acc.length) break;
  774. const L=acc[i+1]; if(i+2+L>acc.length) break;
  775. const f=acc.slice(i+2,i+2+L); i+=2+L;
  776. if(f[0]===0){ done=true; break; }
  777. if(f[0]===1 && L>=8){
  778. const ms=((f[1])|(f[2]<<8)|(f[3]<<16)|(f[4]<<24))>>>0;
  779. capLines.push({ms, slot:f[5], rid:f[6], nb:f[7], bytes:[...f.slice(8,8+f[7])]});
  780. }
  781. }
  782. acc=acc.slice(i);
  783. }
  784. }catch(e){ log("drain err: "+e.message); }
  785. draining=false;
  786. if(liveTimer){ const el=$("#capOut"); const atBottom=el.scrollTop+el.clientHeight>=el.scrollHeight-4; renderCap(); if(atBottom) el.scrollTop=el.scrollHeight; }
  787. }
  788. async function startCapture(){
  789. if(!dev||capturing) return;
  790. capturing=true; capLines=[];
  791. $("#capOut").textContent="(dumping ring from boot, then live — press Stop when done)";
  792. $("#capStart").disabled=true; $("#capStop").disabled=false;
  793. await send([0x05,1]);
  794. liveTimer=setInterval(drainTick,100);
  795. }
  796. async function stopCapture(){
  797. if(liveTimer){ clearInterval(liveTimer); liveTimer=null; }
  798. await drainTick();
  799. try{ await send([0x05,0]); }catch(e){}
  800. capturing=false;
  801. $("#capStart").disabled=false; $("#capStop").disabled=true;
  802. renderCap(); log(`capture stopped — ${capLines.length} entries`);
  803. }
  804. async function startPolling(){
  805. polling=true; await refresh();
  806. while(polling && dev){ await new Promise(r=>setTimeout(r,600)); await refresh(); }
  807. }
  808. // UI wiring
  809. $("#connectBtn").onclick=connect;
  810. $("#backupExport").onclick=exportBackup;
  811. $("#backupImport").onclick=()=>$("#backupFile").click();
  812. $("#backupFile").onchange=async(e)=>{ const f=e.target.files[0]; if(f) await importBackup(f); e.target.value=""; };
  813. $("#capDl").onclick=downloadCap;
  814. $("#capStart").onclick=startCapture;
  815. $("#capStop").onclick=stopCapture;
  816. $("#stabBtn").onclick=async()=>{
  817. if(!dev){ log("connect first"); return; }
  818. stabArmed=!stabArmed;
  819. if(stabArmed){ await send([0x0F,1]); stabStart=Date.now(); stabLastRun=null; log("stability test STARTED — buzzing every 10s, timing uptime until reset"); }
  820. else { await send([0x0F,0]); stabStart=0; log("stability test stopped"); }
  821. updateStabUI();
  822. };
  823. $("#hangClear").onclick=()=>{ hangLog=[]; renderHangLog(); };
  824. $("#hangCsv").onclick=()=>{
  825. const rows=[["time","uptime_s","reason","stage","pc","lr","usbd_free_words"]].concat(
  826. hangLog.map(e=>[e.time, e.uptime!=null?Math.round(e.uptime):"", e.reason, e.stage, e.pc, e.lr, e.usbd]));
  827. const csv=rows.map(r=>r.map(x=>'"'+String(x).replace(/"/g,'""')+'"').join(",")).join("\n");
  828. const a=document.createElement("a"); a.href=URL.createObjectURL(new Blob([csv],{type:"text/csv"}));
  829. a.download="openpuck-hanglog.csv"; a.click();
  830. };
  831. $("#hapClear").onclick=async()=>{ if(dev){ await send([0x07]); log("haptic re-init sent (clear stuck buzz)"); } };
  832. $("#ctlrOff").onclick=async()=>{ if(dev){ await send([0x08]); log("controller power-off attempt sent — watch link status"); } };
  833. $("#dfuSerial").onclick=async()=>{
  834. if(!dev){ log("not connected"); return; }
  835. if(!confirm("Reboot into serial DFU?\n\nThe puck will disconnect immediately. Flash with adafruit-nrfutil, then replug.")) return;
  836. await send([0x0B]); log("serial DFU reboot sent — device disconnecting");
  837. };
  838. $("#dfuUf2").onclick=async()=>{
  839. if(!dev){ log("not connected"); return; }
  840. 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;
  841. await send([0x0C]); log("UF2 bootloader reboot sent — device disconnecting");
  842. };
  843. $("#factoryErase").onclick=async()=>{
  844. if(!dev){ log("not connected"); return; }
  845. 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;
  846. if(!confirm("Are you absolutely sure?\n\nThere is no recovery. The controller bond and all settings will be gone.")) return;
  847. const typed=prompt('Final confirmation — type ERASE (all caps) to wipe everything:');
  848. if(typed!=="ERASE"){ log("factory erase cancelled (confirmation text did not match)"); return; }
  849. await send([0x0A,0x45,0x52,0x53]);
  850. log("FACTORY ERASE sent — copycat is reformatting and rebooting to defaults. Re-pair the controller, then reconnect.");
  851. };
  852. $("#persistMode").onclick=()=>{ const on=$("#persistMode").classList.contains("active"); setField(16, on?0:1); };
  853. // Connection tuning: post-connect haptic block on (field 27) + seconds (field 28). (poll RX window is fixed.)
  854. for(const id of ["mDiv","mFric","rumble"]){
  855. const field={mDiv:1,mFric:2,rumble:22}[id];
  856. $("#"+id).addEventListener("input", ()=>{ $("#"+id+"V").textContent=fmtSlider(id,$("#"+id).value); });
  857. $("#"+id).addEventListener("change", ()=>setField(field, +$("#"+id).value));
  858. }
  859. // per-type back/QAM/A-B-swap/haptics handlers are wired in buildTypeCfgs()
  860. $("#swRate").addEventListener("change", ()=>setField(23, +$("#swRate").value));
  861. $("#swGyro").addEventListener("change", ()=>setField(24, +$("#swGyro").value));
  862. for(const sel of document.querySelectorAll("select.chord")){
  863. sel.addEventListener("change", ()=>setField(CHORD_FIELD[+sel.dataset.i], +sel.value));
  864. }
  865. for(const b of document.querySelectorAll(".modebtn")){
  866. b.onclick=()=>{ const m=+b.dataset.mode;
  867. const clean=(m===7||m===8||m===9);
  868. const msg="Switch to "+MODE_NAMES[m]+"? The copycat will reboot."+(clean?"\n\nNOTE: the game/clean PlayStation modes (and PS3) 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.":"");
  869. if(confirm(msg)){ send([0x03, m]); log("mode switch requested — device will reboot"); } };
  870. }
  871. if(!("usb" in navigator)) $("#connectBtn").outerHTML='<b style="color:var(--bad)">WebUSB not supported — use Chrome or Edge.</b>';
  872. else autoConnect(); // reopen an already-authorized puck on load, no picker (first-ever connect still needs the button)
  873. </script>
  874. </body>
  875. </html>