index.html 138 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237
  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; --warn:#e3b341; }
  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. /* ReversePuck dongle: hide every puck-only Controller card, keep just the paired-pucks card (this leaves
  24. the debugonly/lizard inline visibility rules untouched, so switching back to a puck restores them). */
  25. body.dongle #tabMain > .cards > .card:not(#pairedCard){display:none !important}
  26. .card h2{font-size:13px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);margin:0 0 14px}
  27. .row{display:flex;align-items:center;gap:14px;margin:12px 0;flex-wrap:wrap}
  28. .row label{flex:0 0 150px;color:var(--fg)}
  29. .row .val{flex:0 0 64px;text-align:right;color:var(--acc);font-variant-numeric:tabular-nums}
  30. input[type=range]{flex:1;min-width:120px;accent-color:var(--acc)}
  31. select{background:#0e1017;color:var(--fg);border:1px solid #313a4f;border-radius:8px;padding:6px 8px;flex:1;min-width:120px}
  32. button{background:#222a3a;color:var(--fg);border:1px solid #36405a;border-radius:8px;padding:9px 14px;cursor:pointer;font:inherit}
  33. button:hover{border-color:var(--acc2)}
  34. button.primary{background:var(--acc2);border-color:var(--acc2);color:#06121f;font-weight:600}
  35. button.active{background:var(--acc);border-color:var(--acc);color:#062018;font-weight:600}
  36. button.beta{background:#2d2613;border-color:#80651c;color:#ffd875;font-weight:600}
  37. button.beta.active{background:var(--warn);border-color:var(--warn);color:#1d1604}
  38. button.danger{background:#3a1420;border-color:#7a2233;color:#ff9bb0;font-weight:600}
  39. button.danger:hover{border-color:#ff9bb0}
  40. button:disabled{opacity:.4;cursor:default}
  41. .pill{display:inline-block;padding:2px 9px;border-radius:99px;font-size:12px;font-weight:600}
  42. .pill.up{background:#143626;color:var(--acc)} .pill.dn{background:#3a1822;color:var(--bad)}
  43. /* update-available notice (topbar): :not(.hide) so this ID rule can't out-rank .hide (see #updModal) */
  44. #updAvail:not(.hide){background:#14263a;color:var(--acc2);cursor:pointer;border:1px solid #2b4a6d}
  45. #updAvail:hover{text-decoration:underline}
  46. .statgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(155px,1fr));gap:10px}
  47. .stat{background:#0e1017;border:1px solid #232a3a;border-radius:8px;padding:10px}
  48. .stat .k{color:var(--mut);font-size:11px;text-transform:uppercase;letter-spacing:.05em}
  49. .stat .v{font-size:18px;margin-top:2px}
  50. .modes{display:flex;gap:10px;flex-wrap:wrap}
  51. /* back4 chords: face buttons in the left column, D-pad in the right; auto-fit collapses to one column on a
  52. narrow viewport, and also when the D-pad column is hidden on pre-v18 firmware (display:none drops the item) */
  53. .chord-cols{display:grid;grid-template-columns:repeat(auto-fit,minmax(290px,1fr));gap:0 24px}
  54. .chord-cols .row{margin:10px 0}
  55. .chord-cols .row label{flex:0 0 108px}
  56. .colhead{color:var(--mut);font-size:11px;text-transform:uppercase;letter-spacing:.06em;margin:2px 0 0}
  57. .note{color:var(--mut);font-size:12px;margin-top:10px}
  58. #log{font:12px ui-monospace,Menlo,monospace;color:var(--mut);white-space:pre-wrap;margin-top:8px;max-height:140px;overflow:auto}
  59. .hide{display:none}
  60. /* ---- controller slot tabs ---- */
  61. .slot-tabs{display:flex;gap:6px;margin:0 0 14px;flex-wrap:wrap}
  62. .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}
  63. .slot-tab:hover{border-color:var(--acc2);color:var(--fg)}
  64. .slot-tab.active{background:#111c2e;border-color:var(--acc2);color:var(--fg)}
  65. .slot-tab .dot{width:7px;height:7px;border-radius:50%;background:var(--bad);flex-shrink:0;transition:background .3s}
  66. .slot-tab .dot.up{background:var(--acc);animation:pulse-link 2.2s ease-in-out infinite}
  67. @keyframes pulse-link{
  68. 0%,100%{box-shadow:0 0 0 0 rgba(91,214,160,.5)}
  69. 55%{box-shadow:0 0 0 5px rgba(91,214,160,0)}
  70. }
  71. @media(prefers-reduced-motion:reduce){.slot-tab .dot.up{animation:none}}
  72. .per-slot-grid{margin-bottom:12px}
  73. /* ---- top-level tabs (Controller / Firmware update) ---- */
  74. #mainTabs{margin:0 0 16px}
  75. #mainTabs .slot-tab{font-size:13px;padding:7px 18px}
  76. #mainTabs .slot-tab.beta-gated{background:#31270e;border-color:#8a6b1e;color:#ffd875}
  77. #mainTabs .slot-tab.beta-gated:hover{border-color:var(--warn);color:#ffe7a0}
  78. /* ---- firmware update tab ---- */
  79. .dropzone{border:2px dashed #36405a;border-radius:12px;padding:26px 18px;text-align:center;cursor:pointer;
  80. color:var(--mut);transition:border-color .15s,background .15s;margin-bottom:14px}
  81. .dropzone:hover,.dropzone.drag{border-color:var(--acc);color:var(--fg);background:#151b28}
  82. .dropzone b{color:var(--fg)}
  83. .rel-row{display:flex;align-items:center;gap:14px;padding:10px 2px;border-top:1px solid #232a3a;flex-wrap:wrap}
  84. .rel-row:first-child{border-top:0}
  85. .rel-row .ver{font-weight:700;min-width:70px}
  86. .rel-row .date{color:var(--mut);font-size:12px;min-width:90px}
  87. .rel-row .grow{flex:1}
  88. .rel-row label{display:flex;align-items:center;gap:6px;color:var(--mut);font-size:12px;cursor:pointer}
  89. .rel-row input[type=checkbox]{accent-color:var(--bad)}
  90. /* firmware-tab gate: when the connected puck's firmware predates panel updates, both update cards go
  91. inert (pointer-events kills clicks, drops, and the file picker) and the banner explains why */
  92. .fwupcard.gated{opacity:.45;pointer-events:none;user-select:none}
  93. /* ---- blocking update modal ---- */
  94. /* :not(.hide) because this ID selector would otherwise out-rank .hide{display:none} and pin the modal open */
  95. #updModal:not(.hide){position:fixed;inset:0;z-index:100;background:rgba(8,10,16,.8);display:flex;align-items:center;justify-content:center}
  96. .updbox{background:var(--card);border:1px solid #36405a;border-radius:14px;padding:22px 26px;width:min(500px,92vw);box-shadow:0 18px 60px rgba(0,0,0,.5)}
  97. .updbox h3{margin:0 0 4px;font-size:16px}
  98. #updStage{color:var(--fg);margin:10px 0 0;font-size:14px}
  99. .updbar{height:10px;background:#0e1017;border:1px solid #232a3a;border-radius:99px;overflow:hidden;margin:10px 0 6px}
  100. #updFill{height:100%;width:0;background:var(--acc);transition:width .2s}
  101. #updFill.pulse{background:repeating-linear-gradient(45deg,var(--acc2),var(--acc2) 12px,#38618c 12px,#38618c 24px);animation:updslide 1s linear infinite}
  102. #updFill.bad{background:var(--bad)}
  103. @keyframes updslide{to{background-position:34px 0}}
  104. @media(prefers-reduced-motion:reduce){#updFill.pulse{animation:none}}
  105. </style>
  106. </head>
  107. <body>
  108. <div class="topbar">
  109. <span class="brand">OpenPuck <small>SC2 puck · WebUSB</small></span>
  110. <span id="connState" class="pill dn">disconnected</span>
  111. <button id="connectBtn" class="primary">Connect</button>
  112. <span id="updAvail" class="pill hide" title="A newer OpenPuck release is available — click to open the Firmware update tab">…</span>
  113. <span class="spacer"></span>
  114. <button id="betaBtn" class="beta" title="Show beta features warning and enable beta UI">Beta</button>
  115. <button id="debugCdc" title="Reboot ONCE with the CDC serial console enabled (WebUSB goes away for that boot); auto-reverts on the next boot">Debug CDC</button>
  116. <button id="dfuSerial" title="Reboot into serial DFU (adafruit-nrfutil), then replug">Serial DFU</button>
  117. <button id="dfuUf2" title="Reboot into the UF2 mass-storage bootloader; drop a .uf2 on the drive">UF2 DFU</button>
  118. <button id="factoryErase" class="danger" title="Wipe ALL bonds + settings and reboot to defaults. You must re-pair. Cannot be undone.">⚠ Factory erase</button>
  119. <button id="wipeBoard" class="danger debugonly" title="ERASE THE ENTIRE BOARD: firmware + all settings + bonds. The board reboots app-less and mounts as the UF2 drive on every boot until you re-flash OpenPuck. Debug only. Cannot be undone.">☢ Wipe board</button>
  120. </div>
  121. <div class="wrap">
  122. <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>
  123. <div id="panel" class="hide">
  124. <div class="slot-tabs" id="mainTabs">
  125. <span class="slot-tab active" data-tab="tabMain">Controller</span>
  126. <span class="slot-tab" data-tab="tabUpdate">Firmware update</span>
  127. </div>
  128. <div id="tabMain">
  129. <div class="cards">
  130. <div class="card wide" id="pairedCard" style="display:none">
  131. <h2>Paired pucks</h2>
  132. <div class="row" style="gap:10px;align-items:center;margin-bottom:4px">
  133. <span class="pill" id="pairedLink">—</span>
  134. <span class="note" id="pairedFwd" style="flex:1">—</span>
  135. </div>
  136. <div id="pairedList"><p class="note">Reading paired pucks…</p></div>
  137. <p class="note" style="margin-top:8px">This is a <strong>ReversePuck</strong> controller dongle (28DE:1302). It emulates a Steam Controller to whichever puck it's paired with. Pair new pucks with <code>scpair.py</code>; use the <em>Firmware update</em> tab to flash a new dongle build.</p>
  138. </div>
  139. <div class="card">
  140. <h2>Version</h2>
  141. <div class="statgrid">
  142. <div class="stat"><div class="k">Firmware build</div><div class="v" id="stVersionBuild">—</div></div>
  143. <div class="stat"><div class="k">Status protocol</div><div class="v" id="stVersionProto">—</div></div>
  144. <div class="stat"><div class="k">Panel update</div><div class="v" id="stVersionUpdate">—</div></div>
  145. </div>
  146. <p class="note">This section reflects the connected puck. Reconnect after flashing or changing modes to verify what is currently running.</p>
  147. </div>
  148. <div class="card">
  149. <h2>USB mode</h2>
  150. <div class="modes" style="flex-wrap:wrap">
  151. <button data-mode="0" class="modebtn">Steam (puck)</button>
  152. <button data-mode="1" class="modebtn">Xbox 360</button>
  153. <button data-mode="2" class="modebtn">Switch (HORIPAD)</button>
  154. <button data-mode="3" class="modebtn">Lizard (always)</button>
  155. <button data-mode="4" class="modebtn">Switch Pro + gyro</button>
  156. <button data-mode="5" class="modebtn">PS5 DualSense</button>
  157. <button data-mode="6" class="modebtn">HID gyro (DS4)</button>
  158. <button data-mode="9" class="modebtn">PS3 (DualShock 3)</button>
  159. </div>
  160. <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>
  161. <div class="row"><label>Persist last mode</label>
  162. <button id="persistMode">off</button>
  163. <span class="note" style="flex:1">Off (default): every restart / fresh reconnect boots into Steam mode. On: remembers the last mode you selected.</span>
  164. </div>
  165. </div>
  166. <div class="card">
  167. <h2>Back4 chords (face + D-pad)</h2>
  168. <p class="note" style="margin-top:0">Hold all four back paddles (L4+R4+L5+R5) plus a face button or D-pad direction to switch mode without the WebUI. <strong>A is always Steam</strong> — that is the way back out of any mode. B / X / Y and all four D-pad directions are configurable below (defaults: B=Lizard, X=Xbox, Y=Switch Pro).</p>
  169. <div class="chord-cols">
  170. <div>
  171. <div class="colhead">Face buttons</div>
  172. <div class="row"><label>back4 + B</label><select class="chord" data-i="0"></select></div>
  173. <div class="row"><label>back4 + X</label><select class="chord" data-i="1"></select></div>
  174. <div class="row"><label>back4 + Y</label><select class="chord" data-i="2"></select></div>
  175. </div>
  176. <!-- D-pad chords (firmware >= v18). Unlike the face selects these offer the game/clean modes too: those
  177. modes have no WebUSB interface, so a chord is the only way IN, and back4+A is always the way out.
  178. Older firmware has no D-pad chords at all -> the whole column hides (see #dpadChords in applyBlob). -->
  179. <div id="dpadChords" class="hide">
  180. <div class="colhead">D-pad</div>
  181. <div class="row"><label>back4 + Left</label><select class="chordD" data-i="0"></select></div>
  182. <div class="row"><label>back4 + Up</label><select class="chordD" data-i="1"></select></div>
  183. <div class="row"><label>back4 + Right</label><select class="chordD" data-i="2"></select></div>
  184. <div class="row"><label>back4 + Down</label><select class="chordD" data-i="3"></select></div>
  185. </div>
  186. </div>
  187. <p id="dpadOld" class="note hide">This puck's firmware predates the D-pad chords — update it to configure them.</p>
  188. </div>
  189. <div class="card wide">
  190. <h2>Link status</h2>
  191. <!-- Per-controller tabs: visible only when 2+ controllers are bonded -->
  192. <div class="slot-tabs" id="slotTabs" style="display:none"></div>
  193. <!-- Per-slot: RF link / Battery / Signal / this controller's own link rates (blob v13) -->
  194. <div class="statgrid per-slot-grid">
  195. <div class="stat"><div class="k">RF link</div><div class="v" id="stLink">—</div></div>
  196. <div class="stat"><div class="k">Battery</div><div class="v" id="stBatt">—</div></div>
  197. <div class="stat"><div class="k">Signal</div><div class="v" id="stRssi">—</div></div>
  198. <div class="stat"><div class="k">Polls/s</div><div class="v" id="stSlotPolls">—</div></div>
  199. <div class="stat"><div class="k">Delivered</div><div class="v" id="stSlotF1">—</div></div>
  200. <div class="stat"><div class="k">Fails/s (crc·noRx·relay)</div><div class="v" id="stSlotFail">—</div></div>
  201. </div>
  202. <!-- Global stats (sums over ALL controllers; the per-slot grid above shows the selected controller's own rates) -->
  203. <div class="statgrid">
  204. <div class="stat"><div class="k">Delivered (all)</div><div class="v" id="stRate">—</div></div>
  205. <div class="stat"><div class="k">New reports (all)</div><div class="v" id="stNew">—</div></div>
  206. <div class="stat"><div class="k">Polls/s (all)</div><div class="v" id="stPolls">—</div></div>
  207. <div class="stat"><div class="k">Relay/s (all)</div><div class="v" id="stRelay">—</div></div>
  208. <div class="stat"><div class="k">RF fails/s (crc·noRx·heal)</div><div class="v" id="stRfFail">—</div></div>
  209. <div class="stat"><div class="k">Clock LF / HF</div><div class="v" id="stClock">—</div></div>
  210. <div class="stat"><div class="k">µs per ms (ideal 1000)</div><div class="v" id="stUsPerMs">—</div></div>
  211. <div class="stat"><div class="k">Loop</div><div class="v" id="stLoopState">—</div></div>
  212. <div class="stat"><div class="k">usbd stack free (words)</div><div class="v" id="stUsbdStack">—</div></div>
  213. <div class="stat logonly"><div class="k">Loop period</div><div class="v" id="stLoop">—</div></div>
  214. <div class="stat logonly"><div class="k">Slowest stage</div><div class="v" id="stWorst">—</div></div>
  215. <div class="stat logonly"><div class="k">Poll period act/want</div><div class="v" id="stPollPer">—</div></div>
  216. <div class="stat"><div class="k">Build</div><div class="v" id="stBuild">—</div></div>
  217. <div class="stat"><div class="k">Last reset</div><div class="v" id="stReset">—</div></div>
  218. </div>
  219. <p class="note" style="margin-top:6px"><strong>Reset detail:</strong> <span id="resetHist" style="font-family:ui-monospace,Menlo,monospace">—</span><br>
  220. <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>
  221. <p class="note" style="margin-top:8px"><strong>IMU (raw):</strong> <span id="stImu" style="font-family:ui-monospace,Menlo,monospace">—</span><br>
  222. 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>
  223. <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>
  224. <div class="row debugonly" style="margin:14px 0 0;border-top:1px solid #272d3d;padding-top:14px"><button id="hapClear">Clear stuck buzz</button>
  225. <span class="note" style="flex:1">If the controller's haptics get stuck/buzzing, this re-inits them (same as pressing Steam).</span>
  226. </div>
  227. <div class="row" style="margin:10px 0 0"><button id="ctlrOff">Turn off controller</button>
  228. <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>
  229. </div>
  230. <div class="row debugonly" style="margin:10px 0 0"><button id="landAll87">off</button>
  231. <span class="note" style="flex:1"><strong>Land all 0x87 (experiment).</strong> Relay Steam's full haptic/settings config to the controller verbatim (like the real puck) instead of discarding it. Testing whether the connect buzz is caused by the controller's haptic engine being left misconfigured. If the <em>gyro freezes</em> in Steam, turn this back off.</span>
  232. </div>
  233. <div class="row debugonly" style="margin:10px 0 0;border-top:1px solid #272d3d;padding-top:14px"><button id="stabBtn">Test stability</button>
  234. <span class="v" id="stabStatus" style="flex:0 0 auto;min-width:120px">—</span>
  235. <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>
  236. </div>
  237. </div>
  238. <div class="card" id="mouseCard">
  239. <h2>Trackpad mouse (lizard + Xbox)</h2>
  240. <div class="row"><label>Sensitivity</label><input type="range" id="mDiv" min="4" max="200" step="1"><span class="val" id="mDivV"></span></div>
  241. <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>
  242. <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>
  243. </div>
  244. <div class="card">
  245. <h2>Switch Pro motion</h2>
  246. <!-- Gyro mapping (firmware >= v19). Older firmware always uses the corrected mapping and has no field
  247. to set -> the whole block hides (see #swGyroMapBlock in applyBlob). -->
  248. <div id="swGyroMapBlock">
  249. <div class="row"><label>Gyro mapping</label><select id="swGyroMap"></select></div>
  250. <p class="note">Corrected (default) trims the gyro so Switch Pro mode matches a genuine Pro Controller and Steam mode (roll ×0.8, pitch/yaw ×0.9). Legacy sends the raw axes untrimmed — the pre-fix behavior, if you preferred the faster feel or tuned your in-game sensitivity around it.</p>
  251. </div>
  252. <p id="swGyroOld" class="note hide">This puck's firmware predates the gyro-mapping setting — update it to configure it.</p>
  253. </div>
  254. <div class="card wide">
  255. <h2>Button mapping</h2>
  256. <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>
  257. <div id="typeCfgs"></div>
  258. </div>
  259. <div class="card" id="lizardCard" style="display:none">
  260. <h2>Lizard / desktop mapping</h2>
  261. <p class="note" style="margin-top:0">Keyboard, mouse &amp; media bindings for <strong>Lizard (always)</strong> mode — each row maps a controller input to a desktop action. (Steam-mode seamless lizard, when Steam is closed, keeps the built-in default behavior and is not affected by this map.) Edits apply after <strong>Save to device</strong>.</p>
  262. <div id="lizardList"></div>
  263. <div class="row" style="margin:12px 0 0;flex-wrap:wrap;gap:8px">
  264. <button id="lzAdd">+ Add binding</button>
  265. <button id="lzSave" class="primary">Save to device</button>
  266. <button id="lzReload">Reload</button>
  267. <button id="lzReset" style="margin-left:auto">Reset to defaults</button>
  268. </div>
  269. <p class="note" id="lzStatus" style="margin-top:8px">—</p>
  270. </div>
  271. <div class="card">
  272. <h2>Backup &amp; clone</h2>
  273. <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>
  274. <div class="row" style="margin:0;flex-wrap:wrap">
  275. <button id="backupExport" class="primary">Export to file…</button>
  276. <button id="backupImport">Import from file…</button>
  277. <input type="file" id="backupFile" accept="application/json,.json" style="display:none">
  278. </div>
  279. <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>
  280. </div>
  281. <div class="card wide debugonly">
  282. <h2>Hang log</h2>
  283. <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>
  284. <div class="row" style="margin:0 0 8px;flex-wrap:wrap">
  285. <span class="v" id="hangSummary" style="flex:1">no resets logged yet</span>
  286. <button id="hangCsv">Download CSV</button>
  287. <button id="hangClear">Clear</button>
  288. </div>
  289. <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>
  290. </div>
  291. <div class="card wide debugonly">
  292. <h2>Loop-state trail</h2>
  293. <p class="note" style="margin-top:0">Every change of the <em>Loop</em> pill, timestamped — stall episodes, live wedge reports, heartbeat loss (hard wedge, USB silent), recoveries, disconnects and the reset cause on reconnect — so an unattended hang is recorded even if you weren't watching. Saved in this browser (localStorage): it <strong>survives page refreshes</strong> and device reboots, and clears only with the button.</p>
  294. <div class="row" style="margin:0 0 8px;flex-wrap:wrap">
  295. <span class="v" id="trailSummary" style="flex:1">no events yet</span>
  296. <button id="trailClear">Clear</button>
  297. </div>
  298. <pre id="trailOut" 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-wrap">—</pre>
  299. </div>
  300. <div class="card wide debugonly">
  301. <h2>Flight recorder</h2>
  302. <p class="note" style="margin-top:0">The trail of events the firmware recorded in the seconds before its most recent <b>watchdog hang</b> — the ring survives the reset in <code>.noinit</code> RAM (like the hang PC), so it's the post-mortem of what wedged the board. Loads automatically after a hang reconnects; click to reload. Same data the console <code>FR</code> command prints. <span id="flightWedge" style="font-family:ui-monospace,Menlo,monospace"></span></p>
  303. <div class="row" style="margin:0 0 8px;flex-wrap:wrap">
  304. <span class="v" id="flightSummary" style="flex:1">no trail loaded</span>
  305. <button id="flightLoad">Load last-hang trail</button>
  306. </div>
  307. <pre id="flightOut" 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:320px;overflow:auto;white-space:pre">—</pre>
  308. </div>
  309. <div class="card wide logonly">
  310. <h2>Capture — host → controller commands</h2>
  311. <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>
  312. <div class="row" style="margin:0;flex-wrap:wrap">
  313. <button id="capStart" class="primary">Dump from boot</button>
  314. <button id="capStop" disabled>Stop</button>
  315. <button id="capDl">Download .txt</button>
  316. </div>
  317. <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>
  318. <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>
  319. </div>
  320. </div><!-- .cards -->
  321. </div><!-- #tabMain -->
  322. <div id="tabUpdate" class="hide">
  323. <div id="updGate" class="card hide" style="border-color:#7a2233">
  324. <h2 style="color:var(--bad)">Panel updates not supported by this firmware</h2>
  325. <p class="note" id="updGateMsg" style="margin:0"></p>
  326. </div>
  327. <div class="card fwupcard">
  328. <h2>Update from a local file</h2>
  329. <input id="uf2File" class="hide" type="file" accept=".uf2,application/octet-stream">
  330. <div id="uf2Drop" class="dropzone">
  331. <div><b>Drop a .uf2 file here</b> — or click to browse</div>
  332. <div class="note" id="uf2Sel" style="margin-top:6px">no file selected</div>
  333. </div>
  334. <div class="row" style="margin:0">
  335. <button id="uf2Flash" class="primary" disabled title="Pick a .uf2 file first">Flash firmware</button>
  336. <span class="note" style="flex:1;margin:0">The image is streamed over WebUSB into spare flash, verified <em>on the puck</em>, and applied on an automatic reboot. Nothing is armed until it verifies — a failed or interrupted transfer leaves the running firmware untouched, and even a power cut during the apply only leaves the puck in its UF2 bootloader for drag-and-drop recovery.</span>
  337. </div>
  338. </div>
  339. <div class="card fwupcard" id="relCard">
  340. <h2>Update from a release</h2>
  341. <div class="row" style="margin-top:0">
  342. <span class="note" style="flex:1;margin:0">Official builds from <a href="https://github.com/safijari/openpuck/releases" target="_blank" rel="noopener" style="color:var(--acc2)">github.com/safijari/openpuck/releases</a> &nbsp;·&nbsp; installed: <b id="updInstalled" style="color:var(--acc)">—</b></span>
  343. <button id="relRefresh">Refresh</button>
  344. </div>
  345. <div id="relList" class="note" style="margin:0">loading…</div>
  346. <p class="note"><b style="color:var(--bad)">Factory reset</b> flashes the <code>-factory-reset</code> build of that version: on its first boot it wipes ALL settings and the controller pairing (you must re-pair), then behaves like the standard build. Use it to recover from a bad config or stale bond.</p>
  347. </div>
  348. </div><!-- #tabUpdate -->
  349. <div id="log"></div>
  350. </div>
  351. </div>
  352. <!-- blocking modal: covers the whole page while an update is in flight so nothing else can be clicked -->
  353. <div id="updModal" class="hide">
  354. <div class="updbox">
  355. <h3 id="updTitle">Firmware update</h3>
  356. <div id="updStage">…</div>
  357. <div class="updbar"><div id="updFill"></div></div>
  358. <div id="updPct" class="note" style="margin:0"></div>
  359. <div id="updDetail" class="note"></div>
  360. <div class="row" style="justify-content:flex-end;margin:14px 0 0"><button id="updClose" class="hide">Close</button></div>
  361. </div>
  362. </div>
  363. <script>
  364. 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)"];
  365. const CHORD_FIELD = [17, 18, 19]; // back4 + B/X/Y
  366. const CHORD_DPAD_FIELD = [34, 35, 36, 37]; // back4 + D-pad left/up/right/down (firmware protocol >= 18)
  367. const CHORD_DPAD_DEF = [9, 8, 7, 2]; // firmware defaults: PS3, DS4 game, PS5 game, Switch HORIPAD
  368. let dev=null, epIn=0, epOut=0, ifNum=0, polling=false, capturing=false, backupBusy=false;
  369. // A ReversePuck CONTROLLER dongle (28DE:1302) speaks a REDUCED subset of this protocol: no status blob /
  370. // lizard / config, just a paired-pucks list (0xAC) plus the identical firmware-update + DFU ops. When true,
  371. // the panel shows the controller UI (manage paired pucks + flash) and skips every puck-only poll.
  372. let isDongle=false;
  373. let lastP=null; // most recent status-blob payload (config source for Export)
  374. // Stability test: armed = active across reconnects; stabStart = ms of the current run; stabLastRun = last
  375. // completed run {secs} (ended by a reset). Kept in-page so it survives the device reset (not a page refresh).
  376. let stabArmed=false, stabStart=0, stabLastRun=null;
  377. // Firmware update via the UF2 picker: selectedUf2 = {name, image} (image = the app binary extracted from the
  378. // .uf2 at pick time, so a bad file is rejected before anything is sent). dfuBusy blocks re-entry while an
  379. // update is in flight.
  380. let selectedUf2=null, dfuBusy=false;
  381. // Running hang log: one entry per reset (persists in-page across the device reset; a page refresh clears it).
  382. // pendingHang is set at disconnect and enriched with reason/PC/stack from the first blob after reconnect.
  383. let hangLog=[], pendingHang=null;
  384. function renderHangLog(){
  385. const out=$("#hangOut"), sum=$("#hangSummary"); if(!out) return;
  386. if(!hangLog.length){ out.textContent=""; sum.textContent="no resets logged yet"; return; }
  387. const ups=hangLog.filter(e=>e.uptime!=null).map(e=>e.uptime);
  388. const avg=ups.length?Math.round(ups.reduce((a,b)=>a+b,0)/ups.length):null;
  389. sum.textContent = hangLog.length+" reset(s)"+(avg!=null?(" · avg uptime "+fmtDur(avg)):"");
  390. const pad=(s,n)=>String(s).padEnd(n);
  391. out.textContent = pad("time",11)+pad("uptime",9)+pad("reason",16)+pad("stage",10)+pad("PC",11)+"usbd\n"
  392. + 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");
  393. }
  394. function fmtDur(s){ s=Math.round(s); return s>=60 ? (Math.floor(s/60)+"m "+(s%60)+"s") : (s+"s"); }
  395. function updateUf2UI(){
  396. const sel=$("#uf2Sel"), flash=$("#uf2Flash");
  397. if(!sel || !flash) return;
  398. sel.textContent = selectedUf2
  399. ? selectedUf2.name+" — app image "+Math.round(selectedUf2.image.length/1024)+" KiB, parsed OK"
  400. +(selectedUf2.updatable ? "" : " — ⚠ no panel-update support in this image (flashing it = drag-and-drop only afterwards)")
  401. : "no file selected";
  402. flash.disabled = dfuBusy || !selectedUf2;
  403. flash.title = selectedUf2
  404. ? "Send " + selectedUf2.name + " to the puck over WebUSB, verify it, and apply it on reboot"
  405. : "Pick a .uf2 file first";
  406. }
  407. function updateStabUI(){
  408. const btn=$("#stabBtn"), st=$("#stabStatus");
  409. if(!btn) return;
  410. btn.textContent = stabArmed ? "Stop stability test" : "Test stability";
  411. btn.classList.toggle("active", stabArmed);
  412. let txt="";
  413. if(stabArmed && dev && stabStart) txt="up "+fmtDur((Date.now()-stabStart)/1000);
  414. else if(stabArmed && !dev) txt="reconnecting…";
  415. if(stabLastRun) txt += (txt?" · ":"")+"last run: "+fmtDur(stabLastRun.secs);
  416. st.textContent = txt||"—";
  417. }
  418. setInterval(updateStabUI, 500);
  419. const $ = s=>document.querySelector(s);
  420. // Debug UI gate: developer/diagnostic controls (stability test, clear-stuck-buzz, land-all-0x87) and the
  421. // forensic cards (Hang log, Loop-state trail, Flight recorder) only show with ?debug=true in the URL.
  422. // Everything still RUNS regardless (the trail keeps recording to localStorage, stability auto-resume works)
  423. // -- only the UI is hidden, so adding ?debug=true after an unattended incident still shows the history.
  424. const DEBUG_UI = new URLSearchParams(location.search).get("debug")==="true";
  425. if(!DEBUG_UI) for(const el of document.querySelectorAll(".debugonly")) el.style.display="none";
  426. // Firmware-update tab gate: beta enables tested-but-not-yet-default surfaces.
  427. const BETA_UI = new URLSearchParams(location.search).get("beta")==="true";
  428. $("#betaBtn").textContent = BETA_UI ? "Beta on" : "Beta";
  429. $("#betaBtn").classList.toggle("active", BETA_UI);
  430. $("#betaBtn").title = BETA_UI
  431. ? "Show beta-disable confirmation and return to the standard UI"
  432. : "Show beta warning and enable beta UI";
  433. $('#mainTabs .slot-tab[data-tab="tabUpdate"]').classList.toggle("beta-gated", !BETA_UI);
  434. function escHtml(s){
  435. return String(s).replace(/[&<>"']/g, c=>({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"}[c]));
  436. }
  437. function betaPopup({title, body, okText="OK", cancelText="Cancel"}){
  438. return new Promise(resolve=>{
  439. let done=false;
  440. const shade=document.createElement("div");
  441. shade.style.cssText="position:fixed;inset:0;z-index:90;background:rgba(8,10,16,.55);display:flex;align-items:center;justify-content:center;padding:18px";
  442. shade.innerHTML = '<div style="background:var(--card);border:1px solid #80651c;border-radius:12px;padding:20px 22px;width:min(440px,94vw);box-shadow:0 18px 60px rgba(0,0,0,.45)">'
  443. +'<h3 style="margin:0 0 10px;font-size:16px">'+escHtml(title)+'</h3>'
  444. +body.map(p=>'<p class="note" style="margin:8px 0">'+escHtml(p)+'</p>').join("")
  445. +'<div class="row" style="justify-content:flex-end;margin:18px 0 0">'
  446. +'<button data-beta-cancel>'+escHtml(cancelText)+'</button>'
  447. +'<button class="beta active" data-beta-ok>'+escHtml(okText)+'</button>'
  448. +'</div></div>';
  449. const finish=ok=>{
  450. if(done) return;
  451. done=true;
  452. shade.remove();
  453. resolve(!!ok);
  454. };
  455. shade.querySelector("[data-beta-cancel]").onclick=()=>finish(false);
  456. shade.querySelector("[data-beta-ok]").onclick=()=>finish(true);
  457. shade.onclick=e=>{ if(e.target===shade) finish(false); };
  458. shade.onkeydown=e=>{ if(e.key==="Escape") finish(false); };
  459. document.body.appendChild(shade);
  460. shade.tabIndex=-1;
  461. shade.focus();
  462. });
  463. }
  464. $("#betaBtn").onclick=async()=>{
  465. const u=new URL(location.href);
  466. if(BETA_UI){
  467. const ok=await betaPopup({
  468. title:"Disable beta mode?",
  469. body:["Beta-only tabs and controls will be gated again after the page reloads.",
  470. "Let any firmware or config operation already in progress finish before leaving beta mode."],
  471. okText:"Disable beta"
  472. });
  473. if(!ok) return;
  474. u.searchParams.delete("beta");
  475. location.href=u.toString();
  476. return;
  477. }
  478. const ok=await betaPopup({
  479. title:"Enable beta mode?",
  480. body:["Beta features are still being tested.",
  481. "They may not work correctly, can disconnect the panel or puck, and may write invalid or difficult-to-recover configs.",
  482. "Export a backup first if the puck is already paired and configured."],
  483. okText:"Enable beta"
  484. });
  485. if(!ok) return;
  486. u.searchParams.set("beta","true");
  487. location.href=u.toString();
  488. };
  489. function log(m){ const l=$("#log"); l.textContent=(new Date().toLocaleTimeString()+" "+m+"\n"+l.textContent).slice(0,2000); }
  490. // ---- Loop-state trail (persistent) ----
  491. // Every transition of the Loop pill lands here with a wall-clock timestamp, persisted in localStorage --
  492. // unlike the pill (live-only) and the in-page logs (a refresh wipes them), this survives page refreshes and
  493. // device reboots, so a hang that happened while nobody was watching is still on record. Newest first, capped;
  494. // cleared only by the Clear button.
  495. const TRAIL_KEY="opk_loop_trail", TRAIL_MAX=400;
  496. let loopTrail=[]; try{ loopTrail=JSON.parse(localStorage.getItem(TRAIL_KEY))||[]; }catch(e){ loopTrail=[]; }
  497. function trailAdd(m){
  498. loopTrail.unshift({t:Date.now(), m});
  499. if(loopTrail.length>TRAIL_MAX) loopTrail.length=TRAIL_MAX;
  500. try{ localStorage.setItem(TRAIL_KEY, JSON.stringify(loopTrail)); }catch(e){}
  501. renderTrail();
  502. }
  503. function renderTrail(){
  504. const out=$("#trailOut"), sum=$("#trailSummary"); if(!out) return;
  505. sum.textContent = loopTrail.length ? (loopTrail.length+" event(s) — newest first") : "no events yet";
  506. out.textContent = loopTrail.length
  507. ? loopTrail.map(e=>new Date(e.t).toLocaleString()+" "+e.m).join("\n") : "—";
  508. }
  509. $("#trailClear").onclick=()=>{ loopTrail=[]; try{ localStorage.removeItem(TRAIL_KEY); }catch(e){} renderTrail(); log("loop-state trail cleared"); };
  510. renderTrail();
  511. // Per-emulated-type config (must match firmware ET_* order: Xbox=0, Switch=1, DS4=2, DS5=3). Each type lists
  512. // only the remap targets that exist on that controller. Field id sent to firmware = 40 + et*9 + k
  513. // (k: 0..3 back paddles, 4 QAM, 5 A/B-swap, 6 trackpad-haptics, 7 LED brightness, 8 rumble). Blob v17 reads them back at p[73 + et*9 + ...].
  514. const DPAD = {12:"D-pad Up",13:"D-pad Down",14:"D-pad Left",15:"D-pad Right"};
  515. const TYPE_DEFS = [
  516. {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}},
  517. {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"}},
  518. {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"}},
  519. {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"}},
  520. ];
  521. // Which emulated type a USB mode belongs to (mirror of firmware etypeForMode); -1 = puck mode (no type).
  522. 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;} }
  523. // ===================== Lizard (desktop) binding map =====================
  524. // Mirrors firmware lizard_map.h. A binding is {outType, od:[7], trig, hold}. Output types and the
  525. // per-type payload (od[]) layout must match the constants in lizard_map.h exactly.
  526. const LZ_MAX = 32;
  527. const LZO = {NONE:0, KBD:1, MBTN:2, AXIS:3, SCROLL:4, CONSUMER:5};
  528. const LZ_OUT_LABELS = {0:"(disabled)", 1:"Keyboard key", 2:"Mouse button", 3:"Mouse move", 4:"Scroll wheel", 5:"Media key"};
  529. // Controller input bits (triton.h TB_* + virtual left-stick deflection bits). Single-bit triggers only.
  530. const LZ_BTNS = [
  531. [0x1,"A"],[0x2,"B"],[0x4,"X"],[0x8,"Y"],
  532. [0x10,"QAM (• • •)"],[0x40,"View"],[0x4000,"Menu"],[0x10000,"Steam"],
  533. [0x20,"R3 (stick click)"],[0x8000,"L3 (stick click)"],
  534. [0x80,"R4 (back upper-right)"],[0x100,"R5 (back lower-right)"],
  535. [0x20000,"L4 (back upper-left)"],[0x40000,"L5 (back lower-left)"],
  536. [0x200,"RB (bumper)"],[0x80000,"LB (bumper)"],
  537. [0x800000,"R2 (trigger pull)"],[0x8000000,"L2 (trigger pull)"],
  538. [0x2000,"D-pad Up"],[0x400,"D-pad Down"],[0x1000,"D-pad Left"],[0x800,"D-pad Right"],
  539. [0x400000,"Right pad click"],[0x4000000,"Left pad click"],
  540. [0x200000,"Right pad touch"],[0x2000000,"Left pad touch"],
  541. [0x10000000,"L-stick → right"],[0x20000000,"L-stick → left"],[0x40000000,"L-stick → down"],[0x80000000,"L-stick → up"],
  542. ];
  543. // Keyboard modifier bits (od[0] for KBD output).
  544. const LZ_MODS = [[0x01,"Ctrl"],[0x02,"Shift"],[0x04,"Alt"],[0x08,"Win/⌘"]];
  545. // Curated HID keycodes (od[1] for KBD output). Value 0 = no key (modifier-only).
  546. const LZ_KEYS = [[0,"— none —"]];
  547. "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("").forEach((c,i)=>LZ_KEYS.push([0x04+i,c]));
  548. "1234567890".split("").forEach((c,i)=>LZ_KEYS.push([0x1e+i,c]));
  549. [[0x28,"Enter"],[0x29,"Esc"],[0x2a,"Backspace"],[0x2b,"Tab"],[0x2c,"Space"],
  550. [0x4f,"Arrow Right"],[0x50,"Arrow Left"],[0x51,"Arrow Down"],[0x52,"Arrow Up"],
  551. [0x4a,"Home"],[0x4d,"End"],[0x4b,"Page Up"],[0x4e,"Page Down"],[0x49,"Insert"],[0x4c,"Delete"],
  552. [0x2d,"- _"],[0x2e,"= +"],[0x46,"Print Screen"]].forEach(k=>LZ_KEYS.push(k));
  553. for(let i=0;i<12;i++) LZ_KEYS.push([0x3a+i,"F"+(i+1)]);
  554. const LZ_MBTNS = [[1,"Left click"],[2,"Right click"],[4,"Middle click"]];
  555. const LZ_AXIS_SRC = [[0,"Right trackpad"],[1,"Left stick"],[2,"Gyro"]];
  556. const LZ_GYRO_ACT = [[0,"Always"],[1,"While right pad touched"],[2,"While left stick deflected"],[3,"While hold-button held"]];
  557. const LZ_CONSUMER = [[1,"Volume +"],[2,"Volume −"]];
  558. function lzBtnLabel(mask){ const m=LZ_BTNS.find(b=>b[0]===(mask>>>0)); return m?m[1]:(mask?("0x"+(mask>>>0).toString(16)):""); }
  559. let lizardBindings = []; // [{outType, od:[7], trig, hold}]
  560. let lizardBusy = false;
  561. let lizardLoaded = false; // one-shot: the lizard map is fetched lazily, only once the connected puck proves it speaks v16+
  562. const BACK_LABELS = ["L4 (back upper-left)","R4 (back upper-right)","L5 (back lower-left)","R5 (back lower-right)"];
  563. const typeEls = []; // one entry per TYPE_DEFS: {sec, tab, activeDot, back[], qam, abSwap, pad, led, ledV, rumble, padStick[]}
  564. // Trackpad -> joystick mapping (firmware PS_OFF/PS_LEFT/PS_RIGHT). Field id = PAD_STICK_FIELD0 + et*2 + pad
  565. // (pad 0 = left trackpad, 1 = right). While mapped and touched the pad drives that stick; on release the
  566. // stick re-centers, and an untouched mapped pad leaves the physical stick in control.
  567. const PAD_STICK_FIELD0 = 80;
  568. const PAD_STICK_OPTS = [[0,"Off (touchpad)"],[1,"Left stick"],[2,"Right stick"]];
  569. const PAD_STICK_LABELS = ["Left trackpad \u2192 stick","Right trackpad \u2192 stick"];
  570. function mkSelect(def, includeNone){
  571. const sel=document.createElement("select");
  572. if(includeNone){ const o=document.createElement("option"); o.value=0; o.textContent="— none —"; sel.appendChild(o); }
  573. else { const o=document.createElement("option"); o.value=0; o.textContent="Default (per-mode)"; sel.appendChild(o); }
  574. for(const c of Object.keys(def.labels).map(Number).sort((a,b)=>a-b)){
  575. const o=document.createElement("option"); o.value=c; o.textContent=def.labels[c]; sel.appendChild(o); }
  576. return sel;
  577. }
  578. let curTab=0, tabInited=false;
  579. function setTab(et){
  580. curTab=et;
  581. typeEls.forEach((rec,i)=>{
  582. rec.sec.style.display = (i===et) ? "" : "none";
  583. rec.tab.classList.toggle("active", i===et);
  584. });
  585. }
  586. (function buildTypeCfgs(){
  587. const host=document.getElementById("typeCfgs");
  588. const bar=document.createElement("div"); bar.style.cssText="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px";
  589. host.appendChild(bar);
  590. TYPE_DEFS.forEach((def,et)=>{
  591. // tab button
  592. const tab=document.createElement("button"); tab.className="slot-tab"; tab.style.cursor="pointer";
  593. tab.innerHTML='<span>'+def.name+'</span><span class="active-dot" style="display:none;font-size:11px;color:var(--acc)"> ●</span>';
  594. tab.onclick=()=>setTab(et); bar.appendChild(tab);
  595. // panel
  596. const sec=document.createElement("div"); sec.style.cssText="background:#0e1017;border:1px solid #232a3a;border-radius:8px;padding:12px 14px;display:none";
  597. const rec={sec,tab,activeDot:tab.querySelector(".active-dot"),back:[],qam:null,abSwap:null,pad:null,led:null,ledV:null,rumble:null,padStick:[]};
  598. // A/B swap + trackpad haptics + rumble toggles
  599. const tog=document.createElement("div"); tog.className="row";
  600. tog.innerHTML='<label>A/B + X/Y swap</label>';
  601. const ab=document.createElement("button"); ab.textContent="off"; tog.appendChild(ab);
  602. const padLbl=document.createElement("label"); padLbl.textContent="Trackpad haptics"; padLbl.style.marginLeft="14px"; tog.appendChild(padLbl);
  603. const pad=document.createElement("button"); pad.textContent="on"; tog.appendChild(pad);
  604. const rumbleLbl=document.createElement("label"); rumbleLbl.textContent="Rumble"; rumbleLbl.style.marginLeft="14px"; tog.appendChild(rumbleLbl);
  605. const rumble=document.createElement("button"); rumble.textContent="on"; tog.appendChild(rumble);
  606. sec.appendChild(tog);
  607. rec.abSwap=ab; rec.pad=pad; rec.rumble=rumble;
  608. ab.onclick=()=>{ const on=ab.classList.contains("active"); setField(40+et*9+5, on?0:1); };
  609. pad.onclick=()=>{ const on=pad.classList.contains("active"); setField(40+et*9+6, on?0:1); };
  610. rumble.onclick=()=>{ const on=rumble.classList.contains("active"); setField(40+et*9+8, on?0:1); };
  611. // back paddles
  612. for(let i=0;i<4;i++){
  613. const row=document.createElement("div"); row.className="row";
  614. const lab=document.createElement("label"); lab.textContent=BACK_LABELS[i]; row.appendChild(lab);
  615. const sel=mkSelect(def,true); row.appendChild(sel); sec.appendChild(row);
  616. sel.addEventListener("change",()=>setField(40+et*9+i, +sel.value));
  617. rec.back.push(sel);
  618. }
  619. // QAM
  620. { const row=document.createElement("div"); row.className="row";
  621. const lab=document.createElement("label"); lab.textContent="QAM (3 dots)"; row.appendChild(lab);
  622. const sel=mkSelect(def,false); row.appendChild(sel); sec.appendChild(row);
  623. sel.addEventListener("change",()=>setField(40+et*9+4, +sel.value));
  624. rec.qam=sel; }
  625. // LED brightness
  626. { const row=document.createElement("div"); row.className="row";
  627. const lab=document.createElement("label"); lab.textContent="LED brightness"; row.appendChild(lab);
  628. const sl=document.createElement("input"); sl.type="range"; sl.min=0; sl.max=100; sl.step=5; row.appendChild(sl);
  629. const vspan=document.createElement("span"); vspan.className="val"; row.appendChild(vspan);
  630. function fmtLed(v){ return (+v===0)?"Auto":v+"%"; }
  631. sl.addEventListener("input",()=>{ vspan.textContent=fmtLed(sl.value); });
  632. sl.addEventListener("change",()=>setField(40+et*9+7, +sl.value));
  633. sec.appendChild(row);
  634. rec.led=sl; rec.ledV=vspan; }
  635. // trackpad -> joystick mapping (one select per pad)
  636. for(let pad=0; pad<2; pad++){
  637. const row=document.createElement("div"); row.className="row";
  638. const lab=document.createElement("label"); lab.textContent=PAD_STICK_LABELS[pad]; row.appendChild(lab);
  639. const sel=document.createElement("select");
  640. for(const [v,lbl] of PAD_STICK_OPTS){ const o=document.createElement("option"); o.value=v; o.textContent=lbl; sel.appendChild(o); }
  641. row.appendChild(sel); sec.appendChild(row);
  642. sel.addEventListener("change",()=>setField(PAD_STICK_FIELD0+et*2+pad, +sel.value));
  643. rec.padStick.push(sel);
  644. }
  645. host.appendChild(sec); typeEls.push(rec);
  646. });
  647. setTab(0);
  648. })();
  649. for(const sel of document.querySelectorAll("select.chord")){
  650. MODE_NAMES.forEach((n,i)=>{ if(i===7||i===8) return;
  651. const o=document.createElement("option"); o.value=i; o.textContent=n; sel.appendChild(o); });
  652. }
  653. // D-pad chords offer EVERY mode, including the game/clean ones the face selects skip: those modes drop WebUSB,
  654. // so a chord is the only way into them, and back4+A still gets you back to Steam.
  655. for(const sel of document.querySelectorAll("select.chordD")){
  656. MODE_NAMES.forEach((n,i)=>{
  657. const o=document.createElement("option"); o.value=i; o.textContent=n; sel.appendChild(o); });
  658. }
  659. { const sel=document.getElementById("swGyroMap");
  660. for(const [lbl,v] of [["Corrected (default)",0],["Legacy (untrimmed)",1]]){
  661. const o=document.createElement("option"); o.value=v; o.textContent=lbl; sel.appendChild(o); }
  662. }
  663. // ---- per-slot tab state ----
  664. let g_activeSlot = 0;
  665. let g_slotData = []; // [{up, battery, rssi}] indexed by slot 0..3, only set for bonded slots
  666. function updateSlotDisplay(slotIdx) {
  667. const d = g_slotData[slotIdx];
  668. if (!d) { $("#stLink").innerHTML="—"; $("#stBatt").textContent="—"; $("#stRssi").textContent="—";
  669. $("#stSlotPolls").textContent="—"; $("#stSlotF1").textContent="—"; $("#stSlotFail").textContent="—"; return; }
  670. $("#stLink").innerHTML = d.up ? '<span class="pill up">up</span>' : '<span class="pill dn">down</span>';
  671. $("#stBatt").textContent = (d.up && d.battery) ? d.battery+"%" : "—";
  672. $("#stRssi").textContent = (d.up && d.rssi) ? ("-"+d.rssi+" dBm") : "—";
  673. // blob v13: this controller's OWN rates (the global grid's numbers are the sum over all controllers)
  674. if (d.stats) {
  675. $("#stSlotPolls").textContent = d.stats.polls+" /s";
  676. $("#stSlotF1").textContent = d.up ? (d.stats.f1+" /s ("+d.stats.newps+" new)") : "—";
  677. $("#stSlotFail").textContent = d.stats.crc+" · "+d.stats.norx+" · "+d.stats.relay;
  678. } else {
  679. $("#stSlotPolls").textContent="—"; $("#stSlotF1").textContent="—"; $("#stSlotFail").textContent="—";
  680. }
  681. }
  682. function renderSlotTabs(bondedCount) {
  683. const bar = $("#slotTabs");
  684. if (bondedCount <= 1) { bar.style.display="none"; updateSlotDisplay(g_activeSlot); return; }
  685. bar.style.display="flex";
  686. bar.innerHTML="";
  687. for (let s=0; s<4; s++) {
  688. const d = g_slotData[s];
  689. if (!d) continue;
  690. const btn = document.createElement("button");
  691. btn.className = "slot-tab" + (s===g_activeSlot ? " active" : "");
  692. const dot = document.createElement("span");
  693. dot.className = "dot" + (d.up ? " up" : "");
  694. btn.appendChild(dot);
  695. btn.appendChild(document.createTextNode("Controller "+(s+1)));
  696. btn.onclick = () => { g_activeSlot=s; renderSlotTabs(bondedCount); };
  697. bar.appendChild(btn);
  698. }
  699. updateSlotDisplay(g_activeSlot);
  700. }
  701. const USB_FILTERS=[{vendorId:0x28DE},{vendorId:0x045E},{vendorId:0x057E},{vendorId:0x0F0D},{vendorId:0x054C}];
  702. // Acquire+set up an already-chosen USBDevice (shared by the manual picker and the no-picker auto-reconnect).
  703. async function openDevice(d){
  704. try{
  705. dev=d;
  706. await dev.open();
  707. if(dev.configuration===null) await dev.selectConfiguration(1);
  708. let found=null;
  709. for(const itf of dev.configuration.interfaces){
  710. const a=itf.alternate;
  711. if(a.interfaceClass!==0xFF) continue;
  712. if(a.interfaceSubClass===0x5D) continue;
  713. let bin=0,bout=0;
  714. for(const e of a.endpoints){ if(e.type==="bulk"){ if(e.direction==="in")bin=e.endpointNumber; else bout=e.endpointNumber; } }
  715. if(bin&&bout){ found={ifNum:itf.interfaceNumber, epIn:bin, epOut:bout}; break; }
  716. }
  717. 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; }
  718. ifNum=found.ifNum; epIn=found.epIn; epOut=found.epOut;
  719. await dev.claimInterface(ifNum);
  720. await dev.controlTransferOut({requestType:"class", recipient:"interface", request:0x22, value:0x01, index:ifNum});
  721. isDongle = (dev.productId === 0x1302); // ReversePuck controller dongle vs a puck (0x1304 / real)
  722. const kind = isDongle ? "ReversePuck" : "puck";
  723. $("#connState").textContent="connected"; $("#connState").className="pill up";
  724. $("#connState").title = dev.serialNumber ? (kind+" serial "+dev.serialNumber) : "";
  725. $("#connectBtn").textContent="Reconnect";
  726. $("#panel").classList.remove("hide");
  727. applyDeviceProfile(); // show the controller UI + hide puck-only cards (or vice-versa)
  728. log(`connected — ${kind} ${dev.serialNumber||"?"} (VID ${dev.vendorId.toString(16)} PID ${dev.productId.toString(16)} iface ${ifNum})`);
  729. // resume a stability test across the reset: re-arm the firmware buzz and start timing the next run
  730. // (puck-only: the dongle has no stability-test / haptic path)
  731. if(stabArmed && !isDongle){ await send([0x0F,1]); stabStart=Date.now(); log("stability test resumed — timing next run"); }
  732. updateStabUI();
  733. // The lizard-map dump (0x11 -> 0xAA) is loaded LAZILY, gated on the firmware advertising v16+ (see
  734. // applyBlob). Older firmware (every pre-lizard build, including the v15 updater builds) silently drops
  735. // 0x11, and readLizard()'s blocking transferIn would then hang forever -- wedging the status poll the
  736. // firmware-update gate depends on. So we never send 0x11 until a status blob proves the puck speaks v16.
  737. lizardLoaded=false;
  738. startPolling();
  739. // GitHub releases are PUCK firmware; skip the fetch for a dongle (it flashes via the local-file card).
  740. if(!isDongle) loadReleases(false); // background, cached: feeds the update-available notice + pre-warms the update tab
  741. return true;
  742. }catch(e){ log("connect failed: "+e.message); dev=null; return false; }
  743. }
  744. // Manual connect: shows the Chrome device picker (needed once, to authorize the device).
  745. async function connect(){
  746. try{ const d = await navigator.usb.requestDevice({filters:USB_FILTERS}); await openDevice(d); }
  747. catch(e){ log("connect failed: "+e.message); }
  748. }
  749. // No-picker reconnect: reopen a device already authorized this browser. Returns true if it connected.
  750. async function autoConnect(){
  751. if(dev) return true;
  752. try{
  753. const ds = await navigator.usb.getDevices();
  754. const d = ds.find(x=>USB_FILTERS.some(f=>f.vendorId===x.vendorId));
  755. if(d) return await openDevice(d);
  756. }catch(e){}
  757. return false;
  758. }
  759. function onGone(){
  760. // if a stability test is running, this disconnect is the reset that ended the run -- record its uptime
  761. // (kept in-page so it survives the reset + reconnect, shown next to Last reset).
  762. let up=null;
  763. if(stabArmed && stabStart){ up=(Date.now()-stabStart)/1000; stabLastRun={secs:up}; log("⏱ stability: stayed up "+fmtDur(up)+", then reset"); stabStart=0; }
  764. // if the PREVIOUS reset was never classified (no full blob landed between two resets), flush it as
  765. // "unclassified" instead of silently overwriting it -- every reset must leave a row.
  766. if(pendingHang){
  767. hangLog.unshift({ time:new Date(pendingHang.t).toLocaleTimeString(), uptime:pendingHang.uptimeSecs,
  768. reason:"unclassified", stage:"", pc:"", lr:"", usbd:null });
  769. renderHangLog();
  770. trailAdd("note: the previous reset was never classified — no full status blob arrived before this disconnect");
  771. }
  772. // queue a hang-log entry; the reason/PC/stack are filled from the first blob after reconnect
  773. pendingHang = { t: Date.now(), uptimeSecs: up };
  774. trailAdd("device disconnected"+(up!=null?(" after "+fmtDur(up)+" up"):"")+" — reset or replug; reason logged on reconnect");
  775. window._flightAuto=false; // re-arm the one-shot flight-trail auto-load for the next reconnect
  776. window._wedgeEp=false; window._wedgePeak=0; // re-arm the live wedge logger for the next wedge episode
  777. window._lastBlobTs=0; window._hbLostEp=false; // re-arm the heartbeat watchdog (don't fire on a stale pre-reset timestamp)
  778. polling=false; dev=null; tabInited=false; // re-default the config tab to the mode on next connect
  779. checkUpdateNotice(); // no device = no notice
  780. $("#connState").textContent="reconnecting…"; $("#connState").className="pill dn";
  781. $("#panel").classList.add("hide");
  782. log("device disconnected (mode-switch / watchdog reboot) — auto-reconnecting when it returns");
  783. // The puck re-enumerates after a reboot; retry without the picker. The "connect" event also triggers this,
  784. // but poll a few times in case the event is missed.
  785. let tries=0;
  786. const iv=setInterval(async()=>{ if(dev||tries++>40){ clearInterval(iv); return; } if(await autoConnect()) clearInterval(iv); }, 500);
  787. }
  788. // Auto-reconnect when an authorized puck (re)appears — no button, no picker.
  789. navigator.usb && navigator.usb.addEventListener("connect", e=>{ if(!dev) autoConnect(); });
  790. navigator.usb && navigator.usb.addEventListener("disconnect", e=>{ if(e.device===dev) onGone(); });
  791. async function send(bytes){
  792. if(!dev) return;
  793. try{ await dev.transferOut(epOut, new Uint8Array(bytes)); }
  794. catch(e){ log("write err: "+e.message); }
  795. }
  796. // Read one framed reply and return its payload (with the 2-byte [marker][len] header stripped). The blob and
  797. // the bond-export dump share the same framing, distinguished by the marker byte (0xA5 status, 0xA7 bonds).
  798. async function readFrame(marker, minLen, readLen){
  799. try{
  800. // largest frame (status blob) spans multiple USB-FS packets; read generously to capture it in one call.
  801. // readLen is overridable: the dongle's 0xAC paired-pucks list reaches 213 B (8 pucks) and needs 256.
  802. const r = await dev.transferIn(epIn, readLen||256);
  803. if(r.status!=="ok"||r.data.byteLength<2) return null;
  804. const d=new Uint8Array(r.data.buffer);
  805. // Live wedge reporter (0xA9): emitted from the firmware's SOF callback (usbd task) while loop() is stalled --
  806. // the ONLY signal that survives a loop wedge on boards that wipe retained RAM across the reset. Scan for it
  807. // in every read so it surfaces regardless of which frame we were after. Payload: [stage][stallMs u16].
  808. for(let w=0; w+4<d.length; w++){
  809. if(d[w]===0xA9 && d[w+1]===3){ onWedge(d[w+2], d[w+3]|(d[w+4]<<8)); break; }
  810. }
  811. let i=0; while(i<d.length && d[i]!==marker) i++;
  812. if(i+2>d.length) return null;
  813. const len=d[i+1];
  814. // truncated read (transfer ended mid-frame): applying a half blob silently skips every late field --
  815. // reset cause, pendingHang classification, stack stats -- so drop the whole frame and retry next poll.
  816. if(i+2+len>d.length) return null;
  817. const p=d.slice(i+2, i+2+len);
  818. return p.length>=(minLen||0) ? p : null;
  819. }catch(e){ if(dev) log("read err: "+e.message); return null; }
  820. }
  821. async function readBlob(){ return readFrame(0xA5, 12); }
  822. function applyBlob(p){
  823. lastP=p; // remember for Export (carries every config field the panel shows)
  824. window._lastBlobTs=Date.now(); // feeds the host-side heartbeat watchdog (hard-wedge detection)
  825. // Lazy, capability-gated lizard-map load: only after a blob proves the puck speaks v16+ (the op-0x11 dump).
  826. // On older firmware 0x11 is dropped silently and readLizard() would block the endpoint forever, so we must
  827. // NEVER send it blind at connect. One-shot per connection (reset in openDevice).
  828. if(!lizardLoaded && p[0]>=16){ lizardLoaded=true; loadLizard(); }
  829. const mode=p[1], mDiv=p[2], mFric=p[3]; // p[4..11] mirror the active type (per-type block below is authoritative)
  830. const slot=p[10], up=p[11], f1=(p[12]|(p[13]<<8));
  831. const newps=(p.length>16?(p[15]|(p[16]<<8)):0), persistMode=(p.length>22?p[22]:0);
  832. const chord=(p.length>25)?[p[23],p[24],p[25]]:[3,1,4];
  833. // D-pad chords land at the tail of the v18 payload (firmware p[182..185] = payload p[180..183]).
  834. const dpadCap=(p[0]>=18 && p.length>183);
  835. const chordD=dpadCap?[p[180],p[181],p[182],p[183]]:CHORD_DPAD_DEF;
  836. const polls=(p.length>27?(p[26]|(p[27]<<8)):0);
  837. const loopUs=(p.length>29?(p[28]|(p[29]<<8)):0);
  838. const worstIdx=(p.length>30?p[30]:0), worstUs=(p.length>32?(p[31]|(p[32]<<8)):0);
  839. const WORST=["webusb","ctrl.task","serial","rfdiag","rflink","haptic","led"];
  840. const pollUs=(p.length>34?(p[33]|(p[34]<<8)):0);
  841. const pollIntended=(p.length>14?p[14]*100:0);
  842. const logEnabled=(p.length>35?p[35]:0);
  843. for(const el of document.querySelectorAll(".logonly")) el.style.display = logEnabled ? "" : "none";
  844. for(const b of document.querySelectorAll(".modebtn")) b.classList.toggle("active", +b.dataset.mode===mode);
  845. $("#mouseCard").style.opacity = (mode===1||mode===3)?1:0.5;
  846. // The custom map applies ONLY to pure Lizard (always) mode. Steam-mode seamless lizard keeps
  847. // the built-in default behavior, so the editor is shown only when the device is in Lizard mode.
  848. $("#lizardCard").style.display = (mode===3 && p[0]>=16)?"":"none";
  849. const battery=(p.length>36?p[36]:0);
  850. // Switch Pro gyro mapping (protocol v19, firmware p[186] = payload p[184]). Older firmware has no such
  851. // setting at all -> the select hides (see #swGyroMap below). p[51..53] are the reserved bytes that used to
  852. // carry rumble strength / Switch report rate / Switch gyro scale, all removed settings.
  853. const gyroMapCap=(p[0]>=19 && p.length>184);
  854. const swGyroLegacy=gyroMapCap?p[184]:0;
  855. const rssi=(p.length>37?p[37]:0);
  856. // per-slot link status (protocol v8, p.length >= 73)
  857. const hasSlotsData = p.length >= 73;
  858. if (hasSlotsData) {
  859. const bondedN = p[60];
  860. g_slotData = [];
  861. for (let s=0; s<4; s++) {
  862. const base = 61 + s*3;
  863. if (p.length >= base+3) {
  864. // only include bonded slots (a slot with up=0 could be bonded but offline)
  865. // check if the slot appears at all in the bonded map by checking if bondedN covers it;
  866. // the firmware only sets non-zero battery/rssi for actually-bonded slots
  867. const slotUp = !!p[base];
  868. const slotBatt = p[base+1];
  869. const slotRssi = p[base+2];
  870. // slot is "bonded" if firmware included it in bondedN coverage
  871. // we populate slotData for all 4 and renderSlotTabs skips gaps
  872. g_slotData[s] = {up: slotUp, battery: slotBatt, rssi: slotRssi};
  873. }
  874. }
  875. // keep only bonded slots by cross-referencing bondedN
  876. // firmware sends bondedSlotCount but not a slot bitmask; use battery>0 || rssi>0 || up as presence hint
  877. // simpler: just count how many of the 4 slots have any non-zero data OR are marked up
  878. // Actually the firmware always writes all 4 slots; we filter by the bond system's "used" flag via presence.
  879. // For clean display: we'll show tabs only for slots that have ever had a reply OR are bonded (battery/rssi set).
  880. // The firmware sets g_battery[s]=0 and g_linkRssi[s]=0 for unused slots, so 0/0/0 = not bonded.
  881. // But a bonded offline slot also has 0/0/0 initially. We rely on bondedN for count and show the first bondedN slots
  882. // that have g_slot[s].used — but we can't easily know which slots are used from the blob.
  883. // Best heuristic: show all 4 slots if bondedN==4, otherwise use slot indices where battery or rssi != 0 OR up==1,
  884. // OR fall back to showing slots 0..bondedN-1 if none have data yet (device just booted).
  885. let bondedSlots = [];
  886. for (let s=0; s<4; s++) {
  887. if (g_slotData[s] && (g_slotData[s].up || g_slotData[s].battery || g_slotData[s].rssi)) bondedSlots.push(s);
  888. }
  889. // fallback: if no data yet (all offline, just booted), show slots 0..bondedN-1
  890. if (bondedSlots.length === 0 && bondedN > 0) {
  891. for (let s=0; s<bondedN; s++) bondedSlots.push(s);
  892. }
  893. // remove slotData for non-bonded slots so tabs don't appear for them
  894. const bondedSet = new Set(bondedSlots);
  895. for (let s=0; s<4; s++) { if (!bondedSet.has(s)) g_slotData[s] = null; }
  896. // clamp active slot to a valid bonded slot
  897. if (!g_slotData[g_activeSlot]) g_activeSlot = bondedSlots[0] || 0;
  898. // v13: per-slot link stats (fw p[145..180] -> js index 143..178): each controller's own
  899. // polls/delivered/new/crc/noRx/relay, so the tabs no longer show every controller merged into one number
  900. if (p.length >= 179) {
  901. for (let s=0; s<4; s++) {
  902. if (!g_slotData[s]) continue;
  903. const b = 143 + s*9;
  904. g_slotData[s].stats = { polls: p[b]|(p[b+1]<<8), f1: p[b+2]|(p[b+3]<<8),
  905. newps: p[b+4]|(p[b+5]<<8), crc: p[b+6], norx: p[b+7], relay: p[b+8] };
  906. }
  907. }
  908. renderSlotTabs(bondedN);
  909. } else {
  910. // old firmware: single-slot display
  911. $("#slotTabs").style.display="none";
  912. $("#stLink").innerHTML = up? '<span class="pill up">up</span>' : '<span class="pill dn">down</span>';
  913. $("#stBatt").textContent = (up && battery)? battery+"%" : "—";
  914. $("#stRssi").textContent = (up && rssi)? ("-"+rssi+" dBm") : "—";
  915. }
  916. // global stats
  917. $("#stRate").textContent = up? f1+" /s" : "—";
  918. $("#stNew").textContent = up? newps+" /s" : "—";
  919. // Poll TX + RF-fail counts: shown ALWAYS (even when the link reads down) so a wedge is diagnosable --
  920. // polls>0 here while Delivered is "—" means the puck is still polling but getting no usable replies.
  921. $("#stPolls").textContent = polls+" /s";
  922. { const crc=(p.length>117?p[117]:0), norx=(p.length>118?p[118]:0), heal=(p.length>119?p[119]:0);
  923. const rf=(p.length>130?(p[129]|(p[130]<<8)):0);
  924. $("#stRfFail").innerHTML = crc+" · "+norx+" · "+heal + (rf?' · <span class="pill dn">ring '+rf+'</span>':"");
  925. // ring fault = we caught+recovered a relay-ring corruption that would otherwise be an invisible IRQ-off
  926. // watchdog hang. Log each new one (persists across the device reset + panel reconnect).
  927. if(rf>(window._ringFault||0)) log("⚠ RING FAULT #"+rf+" recovered — caught a relay-ring desync/corruption (would have hung loop with IRQs off)");
  928. window._ringFault=rf; }
  929. // v12: relay-frame rate + clock fingerprint (browser index = firmware index − 2)
  930. { const relay=(p.length>121?(p[120]|(p[121]<<8)):null);
  931. $("#stRelay").textContent = relay==null ? "—" : (relay+" /s"); }
  932. if(p.length>125){
  933. const LF=["stopped","RC","xtal","synth"], HF=["RC","?","xtal","?"];
  934. const lf=p[122], hf=p[123], upm=(p[124]|(p[125]<<8));
  935. const lfBad=(lf!==2), hfBad=(hf!==2), upmBad=(upm && (upm<985||upm>1015));
  936. const tag=(t,bad)=> bad ? '<span class="pill dn">'+t+'</span>' : t;
  937. $("#stClock").innerHTML = tag(LF[lf]||lf, lfBad)+" / "+tag(HF[hf]||hf, hfBad);
  938. $("#stUsPerMs").innerHTML = upm ? tag(""+upm, upmBad) : "—";
  939. }
  940. // v12: LIVE loop heartbeat — curStage (firmware p[129]) + ms-since-beat (p[130], 40ms units). Updates even
  941. // while loop() is wedged (blob is pushed from the SOF interrupt), so a hang shows "STALLED @ <stage>" live.
  942. if(p.length>128){
  943. const STAGE=["webusb","ctrl.task","serial","rfdiag","rflink","haptic","led","usbmount","usbtx"];
  944. const cur=p[127], stallMs=p[128]*40, name=(STAGE[cur]||("stage "+cur));
  945. if(stallMs>=200){
  946. $("#stLoopState").innerHTML='<span class="pill dn">STALLED @ '+name+' '+stallMs+'ms</span>';
  947. if(!window._stallEpisode) trailAdd("STALLED @ "+name+" ("+stallMs+"ms, soft wedge — usbd still alive)");
  948. // Latch+log the stall so it survives the imminent watchdog reset + panel reconnect (host-side log
  949. // persists). Log once per episode + keep escalating if it climbs toward the ~8s watchdog.
  950. if(!window._stallEpisode || stallMs>window._stallPeak+800){
  951. window._stallEpisode=true; window._stallPeak=stallMs;
  952. log("⚠ LOOP STALL @ "+name+" ("+stallMs+"ms) — watchdog reset imminent");
  953. }
  954. } else {
  955. if(window._stallEpisode) trailAdd("running again — stall recovered without a reset (peaked "+window._stallPeak+"ms)");
  956. window._stallEpisode=false; window._stallPeak=0;
  957. $("#stLoopState").innerHTML='<span class="pill up">running</span>';
  958. }
  959. }
  960. // v12: least-ever free stack on the usbd task (words). Low = overflow risk (multi-controller haptic-relay
  961. // hang hypothesis). Firmware p[141] -> browser p[139]. Red under 24 words (~96B), amber under 48.
  962. if(p.length>140){ const usbdW=(p[139]|(p[140]<<8));
  963. $("#stUsbdStack").innerHTML = usbdW<24 ? ('<span class="pill dn">'+usbdW+'</span>')
  964. : (usbdW<48 ? ('<span class="pill" style="background:#3a3216;color:#e5c76b">'+usbdW+'</span>') : (""+usbdW));
  965. }
  966. $("#stLoop").textContent = loopUs? loopUs+" µs" : "—";
  967. $("#stWorst").textContent = loopUs? (WORST[worstIdx]||worstIdx)+" "+worstUs+"µs" : "—";
  968. $("#stPollPer").textContent = up? (pollUs+" / "+pollIntended+" µs") : "—";
  969. // last-boot reset cause (protocol v11): a single classified code (firmware classifies it from RESETREAS +
  970. // a GPREGRET2 marker) plus the raw RESETREAS. Codes match RR_* in fault_diag.h. Earlier firmware (<v11)
  971. // didn't send this -> guard on length and leave the tile blank.
  972. const RR_NAMES = ["unknown","power-on","pin/replug","watchdog (hang)","CPU lockup",
  973. "HARDFAULT","reboot","soft reset","wake-from-off"];
  974. const RR_FAULT = new Set([3,4,5]); // watchdog / lockup / hardfault = a real crash worth reporting (#72)
  975. if (p.length > 113) {
  976. const code = p[109];
  977. const raw = ((p[110]|(p[111]<<8)|(p[112]<<16)|(p[113]<<24))>>>0);
  978. const name = RR_NAMES[code] || ("code "+code);
  979. // v12: which loop stage was stuck when the last watchdog/lockup hang fired (0xFF = last boot wasn't a hang)
  980. const STAGE_NAMES = ["webusb","ctrl.task","serial","rfdiag","rflink","haptic","led","usbmount","usbtx"];
  981. const hangStage = (p.length>126 ? p[126] : 0xFF);
  982. let label = name;
  983. if (hangStage!==0xFF) label += " @ "+(STAGE_NAMES[hangStage]||("stage "+hangStage));
  984. $("#stReset").innerHTML = RR_FAULT.has(code) ? ('<span class="pill dn">'+label+'</span>') : label;
  985. // v12: PC/LR captured by the WDT pre-reset ISR (browser idx = firmware−2: hangPC@131, hangLR@135)
  986. const hpc=(p.length>134?((p[131]|(p[132]<<8)|(p[133]<<16)|(p[134]<<24))>>>0):0);
  987. const hlr=(p.length>138?((p[135]|(p[136]<<8)|(p[137]<<16)|(p[138]<<24))>>>0):0);
  988. const hx=v=>"0x"+v.toString(16).padStart(8,"0");
  989. const el = $("#resetHist");
  990. if (el) el.textContent = "RESETREAS=0x"+raw.toString(16).padStart(8,"0")
  991. + (hangStage!==0xFF ? " hung in: "+(STAGE_NAMES[hangStage]||hangStage) : "")
  992. + (hpc ? " hang PC="+hx(hpc)+" LR="+hx(hlr) : "")
  993. + (stabLastRun ? " · stability: stayed up "+fmtDur(stabLastRun.secs)+" before this reset" : "");
  994. 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"); }
  995. // append the queued reset to the running hang log, now that we have this boot's reason/PC/stack
  996. if (pendingHang){
  997. const usbdW2=(p.length>140?(p[139]|(p[140]<<8)):0);
  998. hangLog.unshift({ time:new Date(pendingHang.t).toLocaleTimeString(), uptime:pendingHang.uptimeSecs,
  999. reason:name, stage:(hangStage!==0xFF)?(STAGE_NAMES[hangStage]||("stage"+hangStage)):"",
  1000. pc:hpc?hx(hpc):"", lr:hlr?hx(hlr):"", usbd:usbdW2 });
  1001. trailAdd("reconnected — last reset: "+name
  1002. +(hangStage!==0xFF?(" @ "+(STAGE_NAMES[hangStage]||("stage "+hangStage))):"")
  1003. +(hpc?(" PC="+hx(hpc)):"")
  1004. +(pendingHang.uptimeSecs!=null?(" (up "+fmtDur(pendingHang.uptimeSecs)+" before it)"):""));
  1005. try{ localStorage.setItem("opk_trail_rrfp", code+"/"+raw.toString(16)+"/"+hangStage+"/"+hpc.toString(16)); }catch(e){}
  1006. pendingHang=null; renderHangLog();
  1007. }
  1008. // Page wasn't open when it reset (refresh / closed browser): a fault-class boot still lands in the trail,
  1009. // once per distinct reset -- fingerprinted + persisted so every later page load doesn't re-log the same one.
  1010. else if (RR_FAULT.has(code)){
  1011. const fp=code+"/"+raw.toString(16)+"/"+hangStage+"/"+hpc.toString(16);
  1012. if(localStorage.getItem("opk_trail_rrfp")!==fp){
  1013. try{ localStorage.setItem("opk_trail_rrfp", fp); }catch(e){}
  1014. trailAdd("connected — last boot was a "+label+(hpc?(" PC="+hx(hpc)):"")+" (page wasn't open to catch it live)");
  1015. }
  1016. }
  1017. // Auto-load the flight-recorder trail once per connection when the last boot was a real crash (watchdog/
  1018. // lockup/hardfault) -- the trail is the post-mortem of what wedged the board. Deferred to the poll loop so
  1019. // it doesn't nest transferIn inside this refresh.
  1020. if (RR_FAULT.has(code) && !window._flightAuto){ window._flightAuto=true; window._autoFlight=true; }
  1021. }
  1022. // per-type trackpad->stick mapping (blob v20, firmware p[187..194] -> JS p[185..192])
  1023. const padStickCap=(p[0]>=20 && p.length>192);
  1024. const gitDirty=(p.length>38?p[38]:0); let buildId="";
  1025. for(let i=39;i<51 && i<p.length && p[i];i++) buildId+=String.fromCharCode(p[i]);
  1026. $("#stBuild").innerHTML = buildId
  1027. ? (buildId + (gitDirty ? ' <span class="pill dn">dirty</span>' : ' <span class="pill up">clean</span>'))
  1028. : "—";
  1029. $("#updInstalled").textContent = buildId || "—"; // mirrored on the Firmware-update tab
  1030. updateVersionUI();
  1031. updateFwGate(); // (re)evaluate the firmware-tab gate on every blob — version is at p[0]
  1032. checkUpdateNotice(); // needs BOTH the installed version (this blob) and relCache (fetched on connect)
  1033. setSlider("mDiv",mDiv); setSlider("mFric",mFric);
  1034. $("#persistMode").textContent = persistMode? "on":"off"; $("#persistMode").classList.toggle("active",!!persistMode);
  1035. // land-all-0x87 experiment toggle (blob v14/v17, firmware p[181] -> JS p[179])
  1036. { const la=(p.length>179?p[179]:0); $("#landAll87").textContent = la?"on":"off"; $("#landAll87").classList.toggle("active",!!la); }
  1037. // per-type button config (blob v10/v17): 4 types x 9 bytes. readBlob() strips the 2-byte [0xA5][len] header, so
  1038. // the firmware's buffer index 75 lands at JS index 73 here.
  1039. if(p.length>=73+TYPE_DEFS.length*9){
  1040. const activeEt=etypeForMode(mode);
  1041. typeEls.forEach((rec,et)=>{
  1042. const q=73+et*9;
  1043. 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], rum=p[q+8];
  1044. rec.back.forEach((sel,i)=>{ if(document.activeElement!==sel) sel.value=back[i]; });
  1045. if(document.activeElement!==rec.qam) rec.qam.value=qam;
  1046. rec.abSwap.textContent=ab?"on":"off"; rec.abSwap.classList.toggle("active",!!ab);
  1047. rec.pad.textContent=pad?"on":"off"; rec.pad.classList.toggle("active",!!pad);
  1048. rec.rumble.textContent=rum?"on":"off"; rec.rumble.classList.toggle("active",!!rum);
  1049. if(document.activeElement!==rec.led){ rec.led.value=led; rec.ledV.textContent=(led===0)?"Auto":led+"%"; }
  1050. rec.padStick.forEach((sel,pad)=>{
  1051. // hidden entirely on firmware too old to speak the mapping, so the panel never shows a dead control
  1052. sel.parentNode.classList.toggle("hide", !padStickCap);
  1053. if(padStickCap && document.activeElement!==sel) sel.value=p[185+et*2+pad];
  1054. });
  1055. // dot marks the type matching the current puck mode
  1056. rec.activeDot.style.display = (et===activeEt) ? "" : "none";
  1057. });
  1058. // open the tab for the current mode's type on first load (then leave the user's choice alone)
  1059. if(!tabInited){ tabInited=true; setTab(activeEt>=0?activeEt:0); }
  1060. }
  1061. if(p.length>=60){ const s16=(o)=>{ let v=p[o]|(p[o+1]<<8); return v>32767?v-65536:v; };
  1062. const ax=s16(54),ay=s16(56),az=s16(58);
  1063. const amag=Math.round(Math.sqrt(ax*ax+ay*ay+az*az));
  1064. $("#stImu").textContent = `a=(${ax}, ${ay}, ${az}) |a|=${amag}`;
  1065. }
  1066. { const sel=$("#swGyroMap");
  1067. $("#swGyroMapBlock").classList.toggle("hide", !gyroMapCap);
  1068. $("#swGyroOld").classList.toggle("hide", gyroMapCap);
  1069. if(gyroMapCap && document.activeElement!==sel) sel.value=swGyroLegacy; }
  1070. for(const sel of document.querySelectorAll("select.chord")){ if(document.activeElement!==sel) sel.value=chord[+sel.dataset.i]; }
  1071. $("#dpadChords").classList.toggle("hide", !dpadCap);
  1072. $("#dpadOld").classList.toggle("hide", dpadCap);
  1073. if(dpadCap) for(const sel of document.querySelectorAll("select.chordD")){ if(document.activeElement!==sel) sel.value=chordD[+sel.dataset.i]; }
  1074. }
  1075. function fmtSlider(id,v){ return id==="hapBlockS" ? v+" s" : ""+v; }
  1076. function setSlider(id,v){ const el=$("#"+id); if(document.activeElement!==el){ el.value=v; } $("#"+id+"V").textContent=fmtSlider(id,el.value); }
  1077. let inflight=false;
  1078. async function refresh(){
  1079. if(!dev||inflight||capturing||backupBusy||flightBusy||lizardBusy) return; inflight=true;
  1080. // try/finally: an exception anywhere in here (applyBlob included) must never leave inflight latched true --
  1081. // that would silently kill all future polls and read as a fake "heartbeat lost".
  1082. try{
  1083. if(isDongle){ await send([0x01]); const p=await readFrame(0xAC, 3, 256); if(p) applyDongleStatus(p); }
  1084. else { await send([0x01]); const p=await readBlob(); if(p) applyBlob(p); }
  1085. }
  1086. catch(e){ log("refresh err: "+e.message); }
  1087. finally{ inflight=false; }
  1088. }
  1089. // ---- ReversePuck controller dongle: paired-pucks list (0xAC frame) ----
  1090. // Payload (readFrame strips the [0xAC][len] header): [ver][flags][count] + per bond 26B:
  1091. // [slot][alive][puuid4][iuuid4][serial16]. flags bit0=forwarding a Deck, bit1=an RF link is up.
  1092. function applyDongleStatus(p){
  1093. window._lastBlobTs=Date.now(); // heartbeat + waitReconnect success signal (mirrors applyBlob)
  1094. if(pendingHang){ // a reconnect after a reboot: close out the hang-log row we opened on disconnect
  1095. pendingHang=null;
  1096. }
  1097. const flags=p[1], count=p[2], bonds=[];
  1098. for(let i=0;i<count;i++){
  1099. const q=3+i*26; if(q+26>p.length) break;
  1100. const dec=new TextDecoder("latin1");
  1101. let serial=dec.decode(new Uint8Array(p.slice(q+10,q+26)));
  1102. const z=serial.indexOf("�"); if(z>=0) serial=serial.slice(0,z);
  1103. serial=serial.replace(/[^\x20-\x7e]/g,"").trim(); // strip NUL padding / stray non-printables
  1104. bonds.push({slot:p[q], alive:!!p[q+1],
  1105. puuid:p.slice(q+2,q+6), iuuid:p.slice(q+6,q+10), serial});
  1106. }
  1107. renderPaired(bonds, flags);
  1108. }
  1109. function renderPaired(bonds, flags){
  1110. const list=$("#pairedList"); if(!list) return;
  1111. $("#pairedFwd").textContent = (flags&1) ? "forwarding a Steam Deck" : "idle";
  1112. $("#pairedLink").textContent = (flags&2) ? "RF link up" : "no RF link";
  1113. $("#pairedLink").className = "pill "+((flags&2)?"up":"dn");
  1114. if(!bonds.length){ list.innerHTML='<p class="note">No pucks paired yet. Pair one with <code>scpair.py</code> (see ReversePuck/README.md), then it appears here.</p>'; return; }
  1115. const hex=a=>[...a].map(x=>x.toString(16).padStart(2,"0")).join("");
  1116. list.innerHTML="";
  1117. for(const b of bonds){
  1118. const row=document.createElement("div"); row.className="row"; row.style.cssText="align-items:center;gap:10px;margin:6px 0";
  1119. const badge=b.alive?'<span class="pill up" style="flex:0 0 auto">live</span>':'<span class="pill" style="flex:0 0 auto">offline</span>';
  1120. row.innerHTML=`${badge}<div style="flex:1"><div style="font-weight:600">${b.serial||"(unnamed puck)"}</div>`
  1121. +`<div class="note" style="font-family:ui-monospace,Menlo,monospace">slot ${b.slot} · puuid ${hex(b.puuid)} · iuuid ${hex(b.iuuid)}</div></div>`;
  1122. const rm=document.createElement("button"); rm.textContent="Remove"; rm.className="danger"; rm.style.flex="0 0 auto";
  1123. rm.title="Un-bond this puck from the ReversePuck. You'll need to re-pair to use it again.";
  1124. rm.onclick=async()=>{
  1125. if(!confirm(`Remove paired puck "${b.serial||"slot "+b.slot}"?\n\nThe ReversePuck forgets this pairing; re-pair with scpair.py to use it again.`)) return;
  1126. await send([0x30, b.slot & 0xff]); log("removed paired puck slot "+b.slot);
  1127. // the firmware replies with a fresh 0xAC after the remove; the next poll (or this read) refreshes the list
  1128. };
  1129. row.appendChild(rm); list.appendChild(row);
  1130. }
  1131. }
  1132. // Toggle the UI between the full puck panel and the reduced ReversePuck (controller dongle) panel. Called on
  1133. // every (re)connect so switching a puck<->dongle in one session lands on the right layout.
  1134. function applyDeviceProfile(){
  1135. const d=isDongle;
  1136. // body.dongle drives the CSS that hides the puck-only Controller cards; toggling the class back on a puck
  1137. // reconnect restores them without clobbering the debugonly/lizard inline visibility rules.
  1138. document.body.classList.toggle("dongle", d);
  1139. const paired=$("#pairedCard"); if(paired) paired.style.display = d ? "" : "none";
  1140. // top-bar buttons: factory-erase is a puck-only op (0x0A); the dongle keeps DFU + (debug) wipe-board
  1141. const fe=$("#factoryErase"); if(fe) fe.style.display = d ? "none" : "";
  1142. // the GitHub "Update from a release" list is puck firmware; a dongle flashes via the local-file drop card
  1143. const rc=$("#relCard"); if(rc) rc.style.display = d ? "none" : "";
  1144. const ua=$("#updAvail"); if(ua && d) ua.classList.add("hide");
  1145. // flashing the dongle is a primary use, so reveal the Firmware-update tab for it even without ?beta=true
  1146. // (which otherwise gates the tab for pucks).
  1147. if(d){ const ut=$('#mainTabs .slot-tab[data-tab="tabUpdate"]'); if(ut) ut.style.display=""; }
  1148. updateFwGate(); // the dongle always supports panel updates
  1149. }
  1150. // ---- Backup / clone ----
  1151. // A backup file is { magic, version, bonds[4], config{} }. bonds come from the 0x09 export (0xA7 frame); the
  1152. // config fields come from the status blob the panel already reads. Restoring replays every setting via the
  1153. // normal 0x02 setters, then sends the 0x0D bond-import which writes the pairings, applies the mode and reboots.
  1154. function hexEnc(arr){ return [...arr].map(x=>x.toString(16).padStart(2,"0")).join(""); }
  1155. 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; }
  1156. function buildBackup(p, bp){
  1157. const mask=bp[1], bonds=[];
  1158. for(let s=0;s<4;s++){ const rec=bp.slice(2+s*24, 2+s*24+24);
  1159. bonds.push({slot:s, used:!!((mask>>s)&1), rec:hexEnc(rec)}); }
  1160. const types=[];
  1161. for(let et=0; et<TYPE_DEFS.length; et++){ const q=73+et*9;
  1162. const t={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], rumble:p[q+8]};
  1163. // pad->stick mapping only exists from protocol v20; omit on older pucks so a restore can't replay defaults
  1164. if(p[0]>=21 && p.length>192) t.padStick=[p[185+et*2],p[186+et*2]];
  1165. types.push(t); }
  1166. const cfg={ mode:p[1], mDiv:p[2], mFric:p[3], persistMode:p[22], chord:[p[23],p[24],p[25]], types };
  1167. // D-pad chords only exist from protocol v18, the Switch gyro mapping from v19; omit the keys entirely on
  1168. // older pucks so a restore doesn't replay defaults over a newer puck's real settings. (Backups from before
  1169. // those settings existed carry rumble/swProRate/swGyro10 keys instead — now-removed settings, ignored.)
  1170. if(p[0]>=18 && p.length>183) cfg.chordD=[p[180],p[181],p[182],p[183]];
  1171. if(p[0]>=19 && p.length>184) cfg.swGyroLegacy=p[184];
  1172. // version 2: include the lizard map so it survives a backup/restore cycle.
  1173. // Guard on !lizardBusy to avoid a race with the one-shot lazy load (lizardExchange sets
  1174. // lizardBusy while the initial 0x11 fetch is in flight); if the load is still pending the
  1175. // map snapshot would be empty, which would incorrectly clear bindings on restore.
  1176. if(lizardLoaded && lizardCapable() && !lizardBusy)
  1177. cfg.lizardMap=lizardBindings.map(b=>({outType:b.outType,od:b.od.slice(),trig:b.trig>>>0,hold:b.hold>>>0}));
  1178. return { magic:"openpuck-backup", version:(cfg.lizardMap?2:1), bonds, config:cfg };
  1179. }
  1180. function downloadBackup(obj){
  1181. const ts=new Date().toISOString().slice(0,19).replace(/[:T]/g,"-");
  1182. const a=document.createElement("a");
  1183. a.href=URL.createObjectURL(new Blob([JSON.stringify(obj,null,2)],{type:"application/json"}));
  1184. a.download="openpuck-backup-"+ts+".json"; a.click();
  1185. }
  1186. // let any in-flight poll read settle before we drive the shared endpoints ourselves
  1187. async function waitIdle(){ for(let i=0;i<60 && inflight;i++) await new Promise(r=>setTimeout(r,5)); }
  1188. async function exportBackup(){
  1189. if(!dev){ log("not connected"); return; }
  1190. backupBusy=true; await waitIdle();
  1191. try{
  1192. // grab a fresh config snapshot, then the bond dump
  1193. await send([0x01]); const cfg=await readBlob(); if(cfg) lastP=cfg;
  1194. if(!lastP){ log("export: no config snapshot yet — wait a second and retry"); return; }
  1195. await send([0x09]);
  1196. let bp=null;
  1197. for(let t=0; t<8 && !bp; t++) bp=await readFrame(0xA7, 2+4*24);
  1198. if(!bp){ log("export: no bond data (firmware too old for 0x09 export — reflash)"); return; }
  1199. const backup=buildBackup(lastP, bp);
  1200. downloadBackup(backup);
  1201. const n=backup.bonds.filter(b=>b.used).length;
  1202. log("exported backup — "+n+" bonded controller slot(s) + all settings");
  1203. } finally { backupBusy=false; }
  1204. }
  1205. async function importBackup(file){
  1206. if(!dev){ log("not connected"); return; }
  1207. let obj; try{ obj=JSON.parse(await file.text()); }
  1208. catch(e){ log("import: not valid JSON"); return; }
  1209. if(obj.magic!=="openpuck-backup" || !obj.bonds || !obj.config){ log("import: unrecognized backup file"); return; }
  1210. const bondedN=obj.bonds.filter(b=>b.used).length;
  1211. 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;
  1212. log("importing onto puck "+(dev.serialNumber||"?"));
  1213. backupBusy=true; await waitIdle();
  1214. // Blocking progress modal (same surface the firmware updater uses): covers the page so nothing else can be
  1215. // driven while the import replays every setting onto the puck, then closes on Done / on error.
  1216. modalOpen("Restore backup");
  1217. const c=obj.config;
  1218. // Pre-count every write so the bar tracks real progress: settings (6 base fields, + 4 D-pad chords and the
  1219. // Switch gyro mapping when the backup carries them), per-type config (9 each), the lizard bindings, 4 bond
  1220. // slots, and the final commit.
  1221. const typeN=Array.isArray(c.types)?Math.min(c.types.length,4):0;
  1222. const lizardN=(Array.isArray(c.lizardMap) && lizardCapable())?Math.min(c.lizardMap.length,LZ_MAX):0;
  1223. const baseN=6 + (Array.isArray(c.chordD)?4:0) + (c.swGyroLegacy!==undefined?1:0);
  1224. const padStickN=Array.isArray(c.types)?c.types.slice(0,typeN).filter(t=>Array.isArray(t.padStick)).length*2:0;
  1225. const total=baseN + typeN*9 + padStickN + lizardN + 4 + 1;
  1226. let step=0, stage="";
  1227. const tick=()=>modalStage(stage, Math.min(99,Math.floor(step*100/total)));
  1228. try{
  1229. // Capability probe: the connected puck MUST run firmware new enough to answer 0x09 with a 0xA7 frame,
  1230. // otherwise it will silently ignore the 0x0D bond-import below. This is the #1 import failure: the panel
  1231. // is connected to a puck still on old firmware. Catch it loudly instead of "nothing happened".
  1232. modalStage("Checking puck firmware", null);
  1233. await send([0x09]);
  1234. let probe=null; for(let t=0;t<8 && !probe;t++) probe=await readFrame(0xA7, 2+4*24);
  1235. if(!probe){
  1236. log("import ABORTED — the connected puck does not support import (old firmware).");
  1237. modalDone(false, "This puck's firmware is too old to import a backup. Flash it with the latest "
  1238. +"OpenPuck build (the same one that produced the export), reconnect, then import again.", "Import failed");
  1239. return;
  1240. }
  1241. // Every step is a write-then-read transaction (same shape as the proven 0x02 sliders): the firmware acks
  1242. // each with a status blob, which we read back to keep the OUT pipe flowing and naturally pace the writes.
  1243. const w=async(bytes)=>{ await send(bytes); await readBlob(); step++; tick(); };
  1244. const sf=async(f,v)=>{ await w([0x02, f, (v|0)&0xff]); };
  1245. // replay every setting (each persists; none reboot). mode is applied by the 0x0E commit below.
  1246. stage="Restoring settings"; tick();
  1247. await sf(1,c.mDiv); await sf(2,c.mFric); await sf(16,c.persistMode?1:0);
  1248. await sf(17,c.chord[0]); await sf(18,c.chord[1]); await sf(19,c.chord[2]);
  1249. // absent in backups taken before the D-pad chords existed (or from a pre-v18 puck) -- skip, don't default
  1250. if(Array.isArray(c.chordD)) for(let i=0;i<4;i++) await sf(CHORD_DPAD_FIELD[i], c.chordD[i]);
  1251. // Switch Pro gyro mapping (v19+ backups only). Backups also carry rumble/swProRate/swGyro10 from the
  1252. // removed settings; those fields no longer exist on the puck, so they are not replayed.
  1253. if(c.swGyroLegacy!==undefined) await sf(38, c.swGyroLegacy?1:0);
  1254. // c.types is absent in some very old backups created before per-type paddle/haptic config
  1255. // was added to the panel export; guard so a missing field never crashes the import mid-flight.
  1256. if(typeN){
  1257. for(let et=0; et<typeN; et++){ const t=c.types[et];
  1258. for(let k=0;k<4;k++) await sf(40+et*9+k, t.back[k]);
  1259. await sf(40+et*9+4, t.qam); await sf(40+et*9+5, t.abSwap?1:0);
  1260. await sf(40+et*9+6, t.pad?1:0); await sf(40+et*9+7, t.led);
  1261. await sf(40+et*9+8, t.rumble!==undefined?t.rumble:1);
  1262. if(Array.isArray(t.padStick)){ await sf(PAD_STICK_FIELD0+et*2, t.padStick[0]); await sf(PAD_STICK_FIELD0+et*2+1, t.padStick[1]); } }
  1263. log("settings replayed ("+(baseN+typeN*9)+" fields)");
  1264. } else { log("settings replayed ("+baseN+" fields — no per-type config in this backup)"); }
  1265. // restore the lizard map if the backup includes it (version 2+) and the puck speaks v16+.
  1266. // pre-lizard-map backups simply lack c.lizardMap; skip silently so they still import cleanly.
  1267. if(lizardN){
  1268. stage="Restoring button map"; tick();
  1269. const map=c.lizardMap.slice(0,LZ_MAX);
  1270. await send([0x13, map.length&0xff]); // begin edit (set count)
  1271. for(let i=0;i<map.length;i++){
  1272. const b=map[i], od=(b.od||[]).slice(0,7); while(od.length<7) od.push(0);
  1273. const t=(b.trig||0)>>>0, h=(b.hold||0)>>>0;
  1274. await send([0x12, i, (b.outType||0)&0xff, ...od.map(x=>(x||0)&0xff),
  1275. t&0xff,(t>>>8)&0xff,(t>>>16)&0xff,(t>>>24)&0xff,
  1276. h&0xff,(h>>>8)&0xff,(h>>>16)&0xff,(h>>>24)&0xff]);
  1277. step++; tick();
  1278. }
  1279. await send([0x14]); // commit + persist to flash
  1280. await readLizard(); // drain the 0xAA echo so the pipe is clean for bond writes
  1281. log("lizard map restored — "+map.length+" bindings");
  1282. }
  1283. // write each bond slot with its own small command [0x0D][slot][used][24-byte rec]
  1284. stage="Restoring controller pairings"; tick();
  1285. let n=0;
  1286. for(let s=0;s<4;s++){
  1287. const b=obj.bonds.find(x=>x.slot===s) || {used:false, rec:""};
  1288. const rec=hexDec(b.rec), r24=new Array(24).fill(0);
  1289. for(let i=0;i<24 && i<rec.length;i++) r24[i]=rec[i];
  1290. const used=(b.used && rec.length===24)?1:0; if(used) n++;
  1291. await w([0x0D, s, used, ...r24]);
  1292. }
  1293. log("wrote "+n+" bond slot(s) — committing + rebooting");
  1294. // commit: persist bonds, apply mode, reboot (no ack — the device reboots)
  1295. stage="Committing + rebooting"; step++; tick();
  1296. await send([0x0E, (c.mode!==undefined?c.mode:0xFF)&0xff]);
  1297. log("import sent — puck is cloning and rebooting. Reconnect after it returns.");
  1298. modalDone(true, "Backup restored — the puck is cloning and rebooting. Reconnect after it returns.");
  1299. } catch(e){
  1300. log("import FAILED — "+e.message);
  1301. modalDone(false, e.message+" — the import stopped partway; reconnect and retry.", "Import failed");
  1302. } finally { backupBusy=false; }
  1303. }
  1304. async function setField(field,value){
  1305. await send([0x02, field, value&0xff]); const p=await readBlob(); if(p) applyBlob(p);
  1306. }
  1307. function fmtCapEntry(e,maxMs){
  1308. const hex=e.bytes.map(x=>x.toString(16).padStart(2,"0")).join(" ");
  1309. const t = `-${String(maxMs-e.ms).padStart(6," ")}ms `;
  1310. 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"} ════════`; }
  1311. 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)
  1312. 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)
  1313. const who = (e.slot===0xFE) ? "TX→ctlr " : `if${e.slot} `;
  1314. return `${t}${who} cmd=${e.rid.toString(16).padStart(2,"0")} n=${e.nb}: ${hex}`;
  1315. }
  1316. function downloadCap(){
  1317. const t=window._lastCap||$("#capOut").textContent||"";
  1318. const a=document.createElement("a");
  1319. a.href=URL.createObjectURL(new Blob([t],{type:"text/plain"}));
  1320. a.download="puck-capture.txt"; a.click();
  1321. }
  1322. let liveTimer=null, draining=false, capLines=[];
  1323. function renderCap(){
  1324. if(!capLines.length){ $("#capOut").textContent="(nothing captured yet)"; window._lastCap=""; return; }
  1325. const maxMs=capLines[capLines.length-1].ms;
  1326. const t=capLines.map(e=>fmtCapEntry(e,maxMs)).join("\n");
  1327. $("#capOut").textContent=t; window._lastCap=t;
  1328. }
  1329. async function drainTick(){
  1330. if(!dev||draining) return; draining=true;
  1331. let acc=new Uint8Array(0);
  1332. try{
  1333. await send([0x06]);
  1334. let done=false, guard=0;
  1335. while(!done && guard++<400){
  1336. const r=await dev.transferIn(epIn,64);
  1337. if(r.status!=="ok") break;
  1338. const d=new Uint8Array(r.data.buffer);
  1339. const m=new Uint8Array(acc.length+d.length); m.set(acc); m.set(d,acc.length); acc=m;
  1340. let i=0;
  1341. while(i<acc.length){
  1342. if(acc[i]!==0xA6){ i++; continue; }
  1343. if(i+2>acc.length) break;
  1344. const L=acc[i+1]; if(i+2+L>acc.length) break;
  1345. const f=acc.slice(i+2,i+2+L); i+=2+L;
  1346. if(f[0]===0){ done=true; break; }
  1347. if(f[0]===1 && L>=8){
  1348. const ms=((f[1])|(f[2]<<8)|(f[3]<<16)|(f[4]<<24))>>>0;
  1349. capLines.push({ms, slot:f[5], rid:f[6], nb:f[7], bytes:[...f.slice(8,8+f[7])]});
  1350. }
  1351. }
  1352. acc=acc.slice(i);
  1353. }
  1354. }catch(e){ log("drain err: "+e.message); }
  1355. draining=false;
  1356. if(liveTimer){ const el=$("#capOut"); const atBottom=el.scrollTop+el.clientHeight>=el.scrollHeight-4; renderCap(); if(atBottom) el.scrollTop=el.scrollHeight; }
  1357. }
  1358. // ---- Live wedge reporter (0xA9) ----
  1359. // The firmware's SOF callback streams this while loop() is stuck. On boards that wipe retained RAM across the
  1360. // watchdog reset (so the flight recorder / hang stage come back blank), this is the only way to see WHERE it
  1361. // hung -- captured live, over the panel, in the ~8s before the watchdog fires. Log once per wedge episode.
  1362. const WEDGE_STAGE=["webusb","ctrl.task","serial","rfdiag","rflink","haptic","led","usbmount","usbtx"];
  1363. function onWedge(stage, stallMs){
  1364. const name=WEDGE_STAGE[stage]||("stage "+stage);
  1365. if(!window._wedgeEp) trailAdd("WEDGED @ "+name+" ("+stallMs+"ms — live 0xA9 report; watchdog reset imminent)");
  1366. if(!window._wedgeEp || stallMs > (window._wedgePeak||0)+500){
  1367. window._wedgeEp=true; window._wedgePeak=stallMs;
  1368. log("🛑 LOOP WEDGED @ "+name+" (stuck "+stallMs+"ms) — this is where it hangs; watchdog reset imminent");
  1369. const el=$("#flightWedge"); if(el) el.innerHTML=' &nbsp;<span class="pill dn">last wedge: '+name+' ('+stallMs+'ms)</span>';
  1370. }
  1371. }
  1372. // ---- Host-side heartbeat watchdog (hard-wedge detection) ----
  1373. // The STALLED pill and the 0xA9 reporter both rely on the puck still SENDING while loop() is wedged -- which
  1374. // only holds for soft wedges where the usbd task + SOF IRQ stay alive. The LOCKUP/hardfault-class hangs (#72)
  1375. // kill the whole MCU: USB goes silent, transferIn just pends, and the panel would freeze forever showing the
  1376. // last state it rendered ("running"). Only the host can see that class of hang, so detect it here: connected
  1377. // but no status blob for >2.5s (poll cadence is 600ms) => flip the pill and log the episode. The capture /
  1378. // backup / flight paths pause the blob poll legitimately, so they're excluded.
  1379. setInterval(()=>{
  1380. if(!dev || !window._lastBlobTs || capturing || backupBusy || flightBusy) return;
  1381. const age=Date.now()-window._lastBlobTs;
  1382. if(age>2500){
  1383. const secs=Math.round(age/1000);
  1384. $("#stLoopState").innerHTML='<span class="pill dn">NO HEARTBEAT '+secs+'s — hard wedge</span>';
  1385. if(!window._hbLostEp){ window._hbLostEp=true;
  1386. log("🛑 heartbeat lost — no status blob for "+secs+"s: hard wedge (USB stack dead too, so the live wedge reporter can't run) — check the flight trail after the reset");
  1387. trailAdd("HEARTBEAT LOST — no status for "+secs+"s (hard wedge: USB silent, whole MCU likely stopped)");
  1388. const el=$("#flightWedge"); if(el) el.innerHTML=' &nbsp;<span class="pill dn">last wedge: hard (no heartbeat)</span>';
  1389. }
  1390. } else if(window._hbLostEp){ window._hbLostEp=false;
  1391. trailAdd("heartbeat back — status blobs resumed without a reset");
  1392. }
  1393. }, 1000);
  1394. // ---- Flight recorder (0xA8 stream) ----
  1395. // The firmware keeps a .noinit ring of the last high-level events (usbd SET/GET, relays, RF up/down, radio
  1396. // self-heal, USB mount/suspend, bond flash writes, per-250ms heartbeats) that survives a watchdog reset. On
  1397. // 0x10 it streams them: one header (T=2, vitals@wedge + counts), then entries (T=1), then an end (T=0). We
  1398. // pump 0x10 (restart) then 0x10 (continue) until the end frame, accumulating across FIFO-sized batches.
  1399. let flightBusy=false, flightHdr=null, flightEvents=[];
  1400. const FR_EVT=["none","beat","SET","GET","relay","rf-up","rf-DN","HEAL!","mount","SUSPEND","resume","OFF","RINGF","save"];
  1401. function frEvtName(e){ return FR_EVT[e]!==undefined?FR_EVT[e]:("evt"+e); }
  1402. async function loadFlightTrail(){
  1403. if(!dev||flightBusy) return; flightBusy=true;
  1404. $("#flightSummary").textContent="loading…";
  1405. flightHdr=null; flightEvents=[];
  1406. const STAGE_NAMES=["webusb","ctrl.task","serial","rfdiag","rflink","haptic","led","usbmount","usbtx"];
  1407. try{
  1408. let restart=true, done=false, guard=0, acc=new Uint8Array(0);
  1409. while(!done && guard++<600){
  1410. await send([0x10, restart?1:0]); restart=false;
  1411. const r=await dev.transferIn(epIn,192);
  1412. if(r.status!=="ok") break;
  1413. const d=new Uint8Array(r.data.buffer);
  1414. const m=new Uint8Array(acc.length+d.length); m.set(acc); m.set(d,acc.length); acc=m;
  1415. let i=0;
  1416. while(i<acc.length){
  1417. if(acc[i]!==0xA8){ i++; continue; }
  1418. if(i+2>acc.length) break;
  1419. const L=acc[i+1]; if(i+2+L>acc.length) break;
  1420. const f=acc.slice(i+2,i+2+L); i+=2+L;
  1421. const T=f[0];
  1422. if(T===0){ done=true; break; }
  1423. else if(T===2 && L>=27){
  1424. flightHdr={ count:f[1]|(f[2]<<8), total:f[3]|(f[4]<<8),
  1425. loopPerSec:f[5]|(f[6]<<8), stallMs:f[7], stage:f[8],
  1426. usbdStk:f[9]|(f[10]<<8), loopStk:f[11]|(f[12]<<8),
  1427. heap:((f[13]|(f[14]<<8)|(f[15]<<16)|(f[16]<<24))>>>0),
  1428. pollsps:f[17]|(f[18]<<8), relayps:f[19]|(f[20]<<8),
  1429. crc:f[21], norx:f[22], heal:f[23]|(f[24]<<8), ringF:f[25]|(f[26]<<8) };
  1430. } else if(T===1 && L>=9){
  1431. const dt=((f[1]|(f[2]<<8)|(f[3]<<16)|(f[4]<<24))>>>0);
  1432. flightEvents.push({dt, evt:f[5], stage:f[6], arg:f[7]|(f[8]<<8)});
  1433. }
  1434. }
  1435. acc=acc.slice(i);
  1436. }
  1437. }catch(e){ log("flight err: "+e.message); }
  1438. flightBusy=false;
  1439. renderFlight(STAGE_NAMES);
  1440. }
  1441. function renderFlight(STAGE_NAMES){
  1442. const h=flightHdr, out=$("#flightOut"), sum=$("#flightSummary"), wedge=$("#flightWedge");
  1443. const stage=s=>(STAGE_NAMES[s]||("st"+s));
  1444. if(!h || (h.count===0 && flightEvents.length===0)){
  1445. sum.textContent="no trail — last boot wasn't a hang, or the recorder didn't survive this board's reset";
  1446. wedge.textContent=""; out.textContent="—"; return;
  1447. }
  1448. const hx=v=>"0x"+v.toString(16).padStart(4,"0");
  1449. sum.textContent="showing "+flightEvents.length+" of "+h.total+" events before the last hang";
  1450. // usbd stack free trending to 0 is the leading overflow suspect -> flag it inline.
  1451. const usbdFlag=(h.usbdStk>0 && h.usbdStk<16)?" ⚠LOW":"";
  1452. wedge.innerHTML=" &nbsp;<b>@wedge:</b> stuck in "+stage(h.stage)+", stall "+h.stallMs+"ms, loop "+h.loopPerSec+"/s"
  1453. +" · usbdStk "+h.usbdStk+"w"+usbdFlag+" · loopStk "+h.loopStk+"w · heap "+h.heap+"B"
  1454. +" · poll "+h.pollsps+" relay "+h.relayps+" crc "+h.crc+" norx "+h.norx+" heal "+h.heal+" ringF "+h.ringF;
  1455. const pad=(s,n)=>(""+s).padEnd(n);
  1456. out.textContent =
  1457. pad("Δms",8)+pad("event",9)+pad("stage",10)+"arg\n"
  1458. +"".padEnd(30,"─")+"\n"
  1459. + flightEvents.map(e=>pad("-"+e.dt,8)+pad(frEvtName(e.evt),9)+pad(stage(e.stage),10)+hx(e.arg)).join("\n");
  1460. }
  1461. async function startCapture(){
  1462. if(!dev||capturing) return;
  1463. capturing=true; capLines=[];
  1464. $("#capOut").textContent="(dumping ring from boot, then live — press Stop when done)";
  1465. $("#capStart").disabled=true; $("#capStop").disabled=false;
  1466. await send([0x05,1]);
  1467. liveTimer=setInterval(drainTick,100);
  1468. }
  1469. async function stopCapture(){
  1470. if(liveTimer){ clearInterval(liveTimer); liveTimer=null; }
  1471. await drainTick();
  1472. try{ await send([0x05,0]); }catch(e){}
  1473. capturing=false;
  1474. $("#capStart").disabled=false; $("#capStop").disabled=true;
  1475. renderCap(); log(`capture stopped — ${capLines.length} entries`);
  1476. }
  1477. async function startPolling(){
  1478. polling=true; await refresh();
  1479. while(polling && dev){
  1480. await new Promise(r=>setTimeout(r,600)); await refresh();
  1481. if(window._autoFlight){ window._autoFlight=false; await loadFlightTrail(); }
  1482. }
  1483. }
  1484. // ---- lizard map transfers ----
  1485. // The status-blob poll and these map transfers share one bulk pipe, so we pause polling
  1486. // (lizardBusy) and wait out any in-flight refresh before sending a 0x11/0x14/0x15 that
  1487. // expects a 0xAA reply. (Opcodes/frame renumbered from 0x0D../0xA7 on the merge with main,
  1488. // which claimed 0x0D-0x10 for bond-import/stability/flight, 0xA7 for bond export and
  1489. // 0xA8 for the flight recorder -- 0xAA is the free device->host marker.)
  1490. async function lizardExchange(fn){
  1491. lizardBusy=true;
  1492. for(let i=0;i<25 && inflight;i++) await new Promise(r=>setTimeout(r,20));
  1493. try{ return await fn(); }
  1494. catch(e){ log("lizard err: "+e.message); return null; }
  1495. finally{ lizardBusy=false; }
  1496. }
  1497. // Read one [0xAA][count][count*16] frame, accumulating transferIn packets until complete.
  1498. async function readLizard(){
  1499. let acc=new Uint8Array(0);
  1500. for(let guard=0; guard<64; guard++){
  1501. const r=await dev.transferIn(epIn,128);
  1502. if(r.status!=="ok") break;
  1503. const d=new Uint8Array(r.data.buffer);
  1504. const m=new Uint8Array(acc.length+d.length); m.set(acc); m.set(d,acc.length); acc=m;
  1505. let i=0; while(i<acc.length && acc[i]!==0xAA) i++;
  1506. if(i>0) acc=acc.slice(i);
  1507. if(acc.length<2) continue;
  1508. const count=acc[1], total=2+count*16;
  1509. if(acc.length>=total){
  1510. const out=[];
  1511. for(let b=0;b<count;b++){
  1512. const q=2+b*16;
  1513. out.push({
  1514. outType:acc[q],
  1515. od:[...acc.slice(q+1,q+8)],
  1516. trig:((acc[q+8])|(acc[q+9]<<8)|(acc[q+10]<<16)|(acc[q+11]<<24))>>>0,
  1517. hold:((acc[q+12])|(acc[q+13]<<8)|(acc[q+14]<<16)|(acc[q+15]<<24))>>>0,
  1518. });
  1519. }
  1520. return out;
  1521. }
  1522. }
  1523. return null;
  1524. }
  1525. // Every lizard op sends 0x11..0x15 and then does a BLOCKING readLizard(). If the firmware doesn't implement
  1526. // the op (any pre-v16 build) it drops the byte and never replies, and the read hangs the shared endpoint. So
  1527. // every entry point bails unless a status blob has proven the puck speaks v16+.
  1528. function lizardCapable(){ return !!(lastP && lastP[0]>=16); }
  1529. async function loadLizard(){
  1530. if(!lizardCapable()) return;
  1531. await lizardExchange(async()=>{
  1532. await send([0x11]); // dump map
  1533. const b=await readLizard();
  1534. if(b){ lizardBindings=b; renderLizard(); log(`lizard map loaded — ${b.length} bindings`); }
  1535. });
  1536. }
  1537. async function saveLizard(){
  1538. if(!dev || !lizardCapable()) return;
  1539. // A digital binding (keyboard/mouse/media) with neither a trigger nor a hold fires
  1540. // unconditionally in the firmware (the trigMask==0 guard is skipped) -- it would type/click
  1541. // forever. Drop those rows so a half-finished binding can't brick desktop input.
  1542. const digital=b=>b.outType===LZO.KBD||b.outType===LZO.MBTN||b.outType===LZO.CONSUMER;
  1543. const valid=lizardBindings.filter(b=> b.outType!==LZO.NONE && (!digital(b) || (b.trig>>>0)||(b.hold>>>0)) );
  1544. const dropped=lizardBindings.length-valid.length;
  1545. await lizardExchange(async()=>{
  1546. await send([0x13, valid.length&0xff]); // begin edit (set count)
  1547. for(let i=0;i<valid.length;i++){
  1548. const b=valid[i];
  1549. const od=(b.od||[]).slice(0,7); while(od.length<7) od.push(0);
  1550. const t=b.trig>>>0, h=b.hold>>>0;
  1551. await send([0x12, i, b.outType, ...od.map(x=>x&0xff), // set one binding
  1552. t&0xff,(t>>>8)&0xff,(t>>>16)&0xff,(t>>>24)&0xff,
  1553. h&0xff,(h>>>8)&0xff,(h>>>16)&0xff,(h>>>24)&0xff]);
  1554. }
  1555. await send([0x14]); // commit + persist to flash
  1556. const b=await readLizard(); if(b){ lizardBindings=b; renderLizard(); }
  1557. log(`lizard map saved — ${valid.length} bindings`+(dropped?` (skipped ${dropped} incomplete: no trigger/hold)`:""));
  1558. });
  1559. }
  1560. async function resetLizard(){
  1561. if(!dev || !lizardCapable()) return;
  1562. if(!confirm("Reset the lizard map to the built-in defaults? Unsaved edits are lost.")) return;
  1563. await lizardExchange(async()=>{
  1564. await send([0x15]); // reset to defaults
  1565. const b=await readLizard(); if(b){ lizardBindings=b; renderLizard(); }
  1566. log("lizard map reset to defaults");
  1567. });
  1568. }
  1569. // ---- lizard map UI ----
  1570. function mkSel(opts, val, onchange){
  1571. const s=document.createElement("select"); s.style.flex="0 1 auto"; s.style.minWidth="0";
  1572. for(const [v,l] of opts){ const o=document.createElement("option"); o.value=v; o.textContent=l; s.appendChild(o); }
  1573. s.value=val; s.addEventListener("change",()=>onchange(+s.value));
  1574. return s;
  1575. }
  1576. function renderLizard(){
  1577. const host=$("#lizardList"); host.innerHTML="";
  1578. lizardBindings.forEach((b,idx)=>host.appendChild(buildLizardRow(b,idx)));
  1579. $("#lzStatus").textContent = `${lizardBindings.length} / ${LZ_MAX} bindings`;
  1580. $("#lzAdd").disabled = lizardBindings.length>=LZ_MAX;
  1581. }
  1582. function buildLizardRow(b, idx){
  1583. const row=document.createElement("div");
  1584. row.style.cssText="display:flex;align-items:center;gap:8px;flex-wrap:wrap;background:#0e1017;border:1px solid #232a3a;border-radius:8px;padding:8px 10px;margin:8px 0";
  1585. const analog = (b.outType===LZO.AXIS || b.outType===LZO.SCROLL);
  1586. // Trigger (When) + Hold — meaningful only for digital outputs
  1587. if(!analog){
  1588. const when=document.createElement("span"); when.className="note"; when.textContent="When"; when.style.flex="0 0 auto"; row.appendChild(when);
  1589. row.appendChild(mkSel([[0,"— pick input —"],...LZ_BTNS], b.trig, v=>{ b.trig=v>>>0; }));
  1590. const plus=document.createElement("span"); plus.className="note"; plus.textContent="+ hold"; plus.style.flex="0 0 auto"; row.appendChild(plus);
  1591. row.appendChild(mkSel([[0,"(none)"],...LZ_BTNS], b.hold, v=>{ b.hold=v>>>0; }));
  1592. } else {
  1593. const lab=document.createElement("span"); lab.className="note"; lab.textContent="Analog source"; lab.style.flex="0 0 auto"; row.appendChild(lab);
  1594. }
  1595. // Arrow + output type
  1596. const arrow=document.createElement("span"); arrow.textContent="→"; arrow.style.color="var(--mut)"; row.appendChild(arrow);
  1597. row.appendChild(mkSel(Object.entries(LZ_OUT_LABELS).map(([v,l])=>[+v,l]), b.outType, v=>{
  1598. b.outType=v;
  1599. // reset payload to sane defaults for the new type
  1600. if(v===LZO.AXIS){ b.od=[0,0,0,0,0,0,0]; }
  1601. else if(v===LZO.SCROLL){ b.od=[0,0,0,0,0,0,0]; }
  1602. else if(v===LZO.MBTN){ b.od=[1,0,0,0,0,0,0]; }
  1603. else if(v===LZO.CONSUMER){ b.od=[1,0,0,0,0,0,0]; }
  1604. else if(v===LZO.KBD){ b.od=[0,0,0,0,0,0,0]; }
  1605. renderLizard();
  1606. }));
  1607. // Output-specific controls
  1608. if(b.outType===LZO.KBD){
  1609. for(const [bit,name] of LZ_MODS){
  1610. const w=document.createElement("label"); w.style.cssText="flex:0 0 auto;display:flex;align-items:center;gap:3px;color:var(--fg)";
  1611. const cb=document.createElement("input"); cb.type="checkbox"; cb.checked=!!(b.od[0]&bit);
  1612. cb.addEventListener("change",()=>{ b.od[0]=cb.checked?(b.od[0]|bit):(b.od[0]&~bit); });
  1613. w.appendChild(cb); w.appendChild(document.createTextNode(name)); row.appendChild(w);
  1614. }
  1615. row.appendChild(mkSel(LZ_KEYS, b.od[1]||0, v=>{ b.od[1]=v; for(let k=2;k<7;k++) b.od[k]=0; }));
  1616. } else if(b.outType===LZO.MBTN){
  1617. row.appendChild(mkSel(LZ_MBTNS, b.od[0]||1, v=>{ b.od[0]=v; }));
  1618. } else if(b.outType===LZO.AXIS){
  1619. row.appendChild(mkSel(LZ_AXIS_SRC, b.od[0]||0, v=>{ b.od[0]=v; renderLizard(); }));
  1620. if((b.od[0]||0)===2){ // gyro: activation selector
  1621. const g=document.createElement("span"); g.className="note"; g.textContent="when"; g.style.flex="0 0 auto"; row.appendChild(g);
  1622. row.appendChild(mkSel(LZ_GYRO_ACT, b.od[1]||0, v=>{ b.od[1]=v; }));
  1623. }
  1624. } else if(b.outType===LZO.SCROLL){
  1625. const s=document.createElement("span"); s.className="note"; s.textContent="Left trackpad → scroll"; row.appendChild(s);
  1626. } else if(b.outType===LZO.CONSUMER){
  1627. row.appendChild(mkSel(LZ_CONSUMER, b.od[0]||1, v=>{ b.od[0]=v; }));
  1628. }
  1629. // delete
  1630. const del=document.createElement("button"); del.textContent="✕"; del.title="Delete binding";
  1631. del.style.cssText="margin-left:auto;flex:0 0 auto;padding:4px 10px";
  1632. del.onclick=()=>{ lizardBindings.splice(idx,1); renderLizard(); };
  1633. row.appendChild(del);
  1634. return row;
  1635. }
  1636. // UI wiring
  1637. $("#connectBtn").onclick=connect;
  1638. // ---- Firmware update: UF2 file -> staged flash over WebUSB, applied on reboot ----
  1639. // Flow: parse the .uf2 into the raw app image, stream it over THIS WebUSB connection (ops 0x20/0x21/0x22,
  1640. // strict ping-pong on the firmware's 0xAB acks) into spare flash high in the puck's app region, let the
  1641. // firmware CRC32-verify what actually landed and commit the one-page "apply on reboot" record, then send a
  1642. // plain reboot (0x23) — the puck copies staged->app from RAM on the way up and re-enumerates on the new
  1643. // firmware, where this panel auto-reconnects. Corruption safety (matches fw_update.h in the firmware):
  1644. // - nothing is armed until the staged bytes verify IN FLASH, so any failure/disconnect/power-cut during
  1645. // the transfer leaves the current firmware completely untouched;
  1646. // - the boot-time apply orders its writes so an interruption leaves the board app-less (UF2 bootloader
  1647. // mass-storage recovery), never a crash-looping half image.
  1648. const UF2_MAGIC0=0x0A324655, UF2_MAGIC1=0x9E5D5157, UF2_MAGIC_END=0x0AB16F30, UF2_FAMILY=0xADA52840;
  1649. const APP_BASE=0x26000, FWUP_MAX_IMG=0x60000; // nRF52840 app base; staging cap (fw_update.h FWUP_MAX_IMG)
  1650. // Extract the contiguous application image from a .uf2 (gaps filled with 0xFF, length padded to a word).
  1651. // Throws on anything that is not an nRF52840 app-region UF2 — the firmware re-checks everything, but a bad
  1652. // file should fail at pick time, not after a reboot.
  1653. function uf2ToImage(buf){
  1654. if(!buf.byteLength || buf.byteLength%512) throw new Error("not a UF2 (size is not a multiple of 512)");
  1655. const dv=new DataView(buf), blocks=[]; let lo=Infinity, hi=0;
  1656. for(let off=0; off<buf.byteLength; off+=512){
  1657. if(dv.getUint32(off,true)!==UF2_MAGIC0 || dv.getUint32(off+4,true)!==UF2_MAGIC1 || dv.getUint32(off+508,true)!==UF2_MAGIC_END)
  1658. throw new Error("bad UF2 block magic at offset "+off);
  1659. const flags=dv.getUint32(off+8,true), addr=dv.getUint32(off+12,true), len=dv.getUint32(off+16,true);
  1660. if(flags&0x00000001) continue; // NOT_MAIN_FLASH
  1661. if((flags&0x00002000) && dv.getUint32(off+28,true)!==UF2_FAMILY)
  1662. throw new Error("UF2 family 0x"+dv.getUint32(off+28,true).toString(16)+" is not nRF52840 (0xada52840)");
  1663. if(len<1 || len>476) throw new Error("bad UF2 payload size "+len);
  1664. blocks.push({addr,len,off}); lo=Math.min(lo,addr); hi=Math.max(hi,addr+len);
  1665. }
  1666. if(!blocks.length) throw new Error("UF2 contains no flash data");
  1667. if(lo!==APP_BASE) throw new Error("image base 0x"+lo.toString(16)+" is not the app region (0x26000) — not an OpenPuck app UF2");
  1668. if(hi-lo>FWUP_MAX_IMG) throw new Error("image is "+Math.round((hi-lo)/1024)+" KiB — over the 384 KiB staged-update cap; flash it via UF2 DFU + drag-and-drop");
  1669. const img=new Uint8Array((hi-lo+3)&~3).fill(0xFF); // firmware stages whole words
  1670. for(const b of blocks) img.set(new Uint8Array(buf,b.off+32,b.len), b.addr-lo);
  1671. return img;
  1672. }
  1673. // CRC32 (IEEE, reflected, init/xorout 0xFFFFFFFF) — must match crc32Step/crc32Flash in fw_update.cpp
  1674. function crc32(u8){
  1675. let c=0xFFFFFFFF;
  1676. for(let i=0;i<u8.length;i++){
  1677. c^=u8[i];
  1678. for(let k=0;k<8;k++) c=(c>>>1)^(0xEDB88320&-(c&1));
  1679. }
  1680. return (~c)>>>0;
  1681. }
  1682. const u32le=v=>[v&0xFF,(v>>>8)&0xFF,(v>>>16)&0xFF,(v>>>24)&0xFF];
  1683. const dfuSleep=ms=>new Promise(r=>setTimeout(r,ms));
  1684. // Firmware built from this branch onward embeds this tag (fw_update.cpp FWUP_TAG). An image WITHOUT it
  1685. // still flashes fine — but the puck it leaves behind can't do panel updates, so we warn before the
  1686. // lock-yourself-out-to-drag-and-drop move (all releases up to 0.9.6 predate panel updates).
  1687. function imagePanelUpdatable(image){
  1688. return new TextDecoder("latin1").decode(image).includes("OPK-FWUP-v1");
  1689. }
  1690. const FWUP_ERR=["ok","command out of sequence","image too big for this puck's free flash","offset resync",
  1691. "staged bytes failed CRC verify","staged image has no valid vector table"];
  1692. // Ack reader ([0xAB][5][status][nextOff u32 LE]). One persistent transferIn at a time: a wait that times out
  1693. // leaves its read PENDING for the next wait — issuing a second concurrent read would silently eat the data
  1694. // the first one eventually resolves with. If one read carries several acks (a retried command double-acks),
  1695. // only the LAST — the firmware's newest state — is returned, so stale acks collapse instead of accumulating.
  1696. let fwupRead=null;
  1697. async function fwupAckWait(timeoutMs){
  1698. const deadline=Date.now()+timeoutMs;
  1699. for(;;){
  1700. const remain=deadline-Date.now();
  1701. if(remain<=0) return null;
  1702. if(!fwupRead) fwupRead=dev.transferIn(epIn, 64);
  1703. const r=await Promise.race([fwupRead, dfuSleep(remain).then(()=>"timeout")]);
  1704. if(r==="timeout") return null;
  1705. fwupRead=null;
  1706. if(r.status!=="ok") continue;
  1707. const d=new Uint8Array(r.data.buffer, r.data.byteOffset, r.data.byteLength);
  1708. let ack=null;
  1709. for(let i=0;i+6<d.length;i++)
  1710. if(d[i]===0xAB && d[i+1]===5)
  1711. ack={status:d[i+2], off:(d[i+3]|(d[i+4]<<8)|(d[i+5]<<16)|(d[i+6]<<24))>>>0};
  1712. if(ack) return ack;
  1713. }
  1714. }
  1715. const fwupSend=cmd=>dev.transferOut(epOut, new Uint8Array(cmd));
  1716. // send a control op (begin/end/abort — all idempotent on the firmware side) and wait; resend on timeout
  1717. async function fwupCtl(cmd, timeoutMs, label){
  1718. for(let t=0;t<3;t++){
  1719. await fwupSend(cmd);
  1720. const a=await fwupAckWait(timeoutMs);
  1721. if(a) return a;
  1722. }
  1723. throw new Error("no response to "+label);
  1724. }
  1725. async function fwupRun(image){
  1726. const kb=Math.round(image.length/1024);
  1727. fwupRead=null; // never reuse a read pending against a previous session/device
  1728. // drain anything stale on the IN pipe (leftover ack from an aborted run, a late status blob)
  1729. while(await fwupAckWait(400)!==null){}
  1730. log("firmware update: staging "+kb+" KiB into the puck's spare flash — input may stutter briefly during page erases");
  1731. let a=await fwupCtl([0x20,...u32le(image.length),...u32le(crc32(image))],4000,"begin");
  1732. if(a.status) throw new Error("begin rejected: "+(FWUP_ERR[a.status]||("code "+a.status)));
  1733. // Chunk loop: send one chunk, then read acks until one shows progress. A TIMEOUT resends (covers a truly
  1734. // lost command); a stale no-progress ack (the surplus twin of a resent chunk) is read past WITHOUT
  1735. // resending, so retries can't snowball. The ack's nextOff is authoritative — the firmware skips duplicate
  1736. // chunks and re-acks, flash words are never written twice — so every path resynchronizes here.
  1737. let off=0, sends=0, lastShown=-1;
  1738. const maxSends=Math.ceil(image.length/128)*2+64;
  1739. while(off<image.length){
  1740. const len=Math.min(128,image.length-off);
  1741. await fwupSend([0x21,...u32le(off),len,...image.subarray(off,off+len)]);
  1742. if(++sends>maxSends) throw new Error("transfer not converging at offset "+off);
  1743. for(let reads=0;reads<8;reads++){
  1744. a=await fwupAckWait(2500);
  1745. if(a===null) break; // timeout: resend this chunk
  1746. if(a.status===3){ off=a.off; break; } // firmware says where it wants us — resend from there
  1747. if(a.status!==0) throw new Error("chunk rejected at offset "+off+": "+(FWUP_ERR[a.status]||("code "+a.status)));
  1748. if(a.off>off){ off=a.off; break; } // progress
  1749. // else: stale ack — keep reading, the real one is behind it
  1750. }
  1751. const pct=Math.floor(off*50/image.length)*2;
  1752. if(pct!==lastShown){ lastShown=pct; modalStage("Sending to the puck", pct); }
  1753. }
  1754. modalStage("Verifying on the puck", null); // firmware CRC32s the staged flash, then commits the record
  1755. a=await fwupCtl([0x22],8000,"verify+commit");
  1756. if(a.status) throw new Error("verify+commit rejected: "+(FWUP_ERR[a.status]||("code "+a.status)));
  1757. log("image staged + CRC-verified on the puck — update armed");
  1758. modalStage("Rebooting", null);
  1759. try{ await fwupSend([0x23]); }catch(e){} // device drops mid-call; that's the point
  1760. log("rebooting to apply: the puck goes dark ~5 s while the new firmware is written, then re-enumerates — the panel reconnects itself");
  1761. }
  1762. // ---- blocking modal: the ONLY interactive surface while an update runs (it covers everything else) ----
  1763. function modalStage(txt,pct){
  1764. const f=$("#updFill"), p=$("#updPct");
  1765. $("#updStage").textContent=txt;
  1766. f.classList.remove("bad");
  1767. if(pct==null){ f.classList.add("pulse"); f.style.width="100%"; p.textContent=""; }
  1768. else{ f.classList.remove("pulse"); f.style.width=pct+"%"; p.textContent=pct+"%"; }
  1769. }
  1770. function modalOpen(title){
  1771. $("#updTitle").textContent=title;
  1772. $("#updDetail").textContent="";
  1773. $("#updClose").classList.add("hide"); // no way out mid-update: closing wouldn't stop the puck anyway
  1774. $("#updModal").classList.remove("hide");
  1775. modalStage("Preparing…",null);
  1776. }
  1777. function modalDone(ok,msg,failTitle){
  1778. const f=$("#updFill");
  1779. f.classList.remove("pulse");
  1780. f.style.width="100%";
  1781. f.classList.toggle("bad",!ok);
  1782. $("#updStage").textContent = ok ? "Done" : (failTitle||"Update failed");
  1783. $("#updPct").textContent="";
  1784. $("#updDetail").textContent=msg;
  1785. $("#updClose").classList.remove("hide");
  1786. }
  1787. $("#updClose").onclick=()=>$("#updModal").classList.add("hide");
  1788. function blobBuildId(p){
  1789. if(!p) return "";
  1790. let s=""; for(let i=39;i<51 && i<p.length && p[i];i++) s+=String.fromCharCode(p[i]);
  1791. return s;
  1792. }
  1793. // Update-available notice (topbar): shown when the connected puck reports a release-shaped build (x.y.z)
  1794. // older than the newest non-prerelease on GitHub. Dev builds report a git hash, which doesn't parse — no
  1795. // nagging about "updates" for firmware newer than any release. Click = jump to the Firmware update tab.
  1796. function verTriple(s){
  1797. const m=/^(\d+)\.(\d+)\.(\d+)/.exec(s||"");
  1798. return m ? [+m[1],+m[2],+m[3]] : null;
  1799. }
  1800. function checkUpdateNotice(){
  1801. const el=$("#updAvail");
  1802. if(!BETA_UI){ el.classList.add("hide"); return; }
  1803. const inst=verTriple(blobBuildId(lastP));
  1804. const rel=(relCache||[]).find(r=>!r.prerelease && relAsset(r,false));
  1805. const latest=rel ? verTriple(rel.tag_name) : null;
  1806. const newer = !!(dev && inst && latest &&
  1807. (latest[0]>inst[0] || (latest[0]===inst[0] &&
  1808. (latest[1]>inst[1] || (latest[1]===inst[1] && latest[2]>inst[2])))));
  1809. el.classList.toggle("hide", !newer);
  1810. if(newer) el.textContent="⬆ update "+rel.tag_name+" available";
  1811. }
  1812. $("#updAvail").onclick=()=>{ document.querySelector('#mainTabs .slot-tab[data-tab="tabUpdate"]').click(); };
  1813. // Firmware-tab gate: the connected puck must speak status v15+ (the 0x20..0x24 update ops) for ANY of the
  1814. // update tab to be usable. Too old => banner explains + both cards go inert until a capable build is flashed
  1815. // the manual way once.
  1816. function updateFwGate(){
  1817. const ok = isDongle || !!(lastP && lastP[0]>=15);
  1818. $("#updGate").classList.toggle("hide", ok);
  1819. if(!ok) $("#updGateMsg").textContent =
  1820. "This puck is running "+(blobBuildId(lastP)||"an unknown build")+" (status v"+(lastP?lastP[0]:"?")
  1821. +"), which predates panel updates (needs v15+), so updating from this page is disabled. One manual flash "
  1822. +"gets you back: click “UF2 DFU” in the top bar, then drag a panel-update-capable .uf2 onto the UF2BOOT "
  1823. +"drive it mounts. Every update after that happens right here.";
  1824. for(const c of document.querySelectorAll(".fwupcard")) c.classList.toggle("gated", !ok);
  1825. }
  1826. function updateVersionUI(){
  1827. const build=blobBuildId(lastP), proto=lastP ? lastP[0] : 0;
  1828. const dirty=lastP && lastP.length>38 ? lastP[38] : 0;
  1829. $("#stVersionBuild").innerHTML = build
  1830. ? (build + (dirty ? ' <span class="pill dn">dirty</span>' : ' <span class="pill up">clean</span>'))
  1831. : "—";
  1832. $("#stVersionProto").textContent = proto ? ("v"+proto) : "—";
  1833. $("#stVersionUpdate").textContent = proto >= 15 ? "supported" : (proto ? "manual UF2 only" : "—");
  1834. }
  1835. // after the apply-reboot: resolved once the puck is back.
  1836. // Returns true if the panel auto-reconnected and received a fresh status blob (desktop), "appeared"
  1837. // if the puck is visible in getDevices() but openDevice() couldn't be called without a user gesture
  1838. // (Android Chrome), or false on timeout.
  1839. async function waitReconnect(t0,ms){
  1840. const deadline=Date.now()+ms;
  1841. // seenGone: the puck must disconnect before we can declare it "back". It is often already gone
  1842. // by the time this runs (fwupRun sent the reboot command and returned), so initialise from dev.
  1843. let seenGone=!dev;
  1844. while(Date.now()<deadline){
  1845. if(dev && window._lastBlobTs>t0) return true;
  1846. if(!dev) seenGone=true;
  1847. if(seenGone && !dev){
  1848. try{
  1849. const ds=await navigator.usb.getDevices();
  1850. if(ds.some(x=>USB_FILTERS.some(f=>f.vendorId===x.vendorId))) return "appeared";
  1851. }catch(_e){}
  1852. }
  1853. await dfuSleep(400);
  1854. }
  1855. return false;
  1856. }
  1857. // One update end-to-end: pre-flight checks, confirm, modal up, image acquired (may download), streamed,
  1858. // applied, reconnect observed. getImage runs INSIDE the modal so release downloads show progress there.
  1859. async function runUpdate(title, confirmText, getImage){
  1860. if(dfuBusy) return;
  1861. if(!dev){ log("connect to the device first — the update travels over this WebUSB connection"); return; }
  1862. // The dongle always speaks the update ops; only gate a PUCK on its status-blob version.
  1863. if(!isDongle && (!lastP || lastP[0]<15)){
  1864. log("this puck's firmware ("+(blobBuildId(lastP)||"?")+", status v"+(lastP?lastP[0]:"?")+") predates panel updates (needs v15+) — flash a panel-update-capable build once via UF2 DFU + drag-and-drop, then this works");
  1865. return;
  1866. }
  1867. if(!confirm(confirmText)) return;
  1868. dfuBusy=true; updateUf2UI(); modalOpen(title);
  1869. polling=false; // the IN pipe belongs to the update acks now
  1870. await dfuSleep(800); // let an in-flight status poll finish its read first
  1871. const t0=Date.now(), oldBuild=blobBuildId(lastP);
  1872. try{
  1873. const image=await getImage();
  1874. if(!imagePanelUpdatable(image) &&
  1875. !confirm("⚠ DOWNGRADE WARNING\n\nThis image predates panel updates (releases up to 0.9.6 do). It will "
  1876. +"flash fine, but the puck it leaves behind CANNOT be updated from this panel — getting off it again "
  1877. +"means UF2 DFU + drag-and-drop.\n\nFlash it anyway?"))
  1878. throw new Error("cancelled — the selected image doesn't support panel updates");
  1879. await fwupRun(image);
  1880. modalStage("Applying on the puck",null);
  1881. $("#updDetail").textContent="the puck goes dark ~5 s while the new firmware is written, then re-enumerates";
  1882. const result=await waitReconnect(t0,40000);
  1883. if(result===true){
  1884. const nb=blobBuildId(lastP);
  1885. modalDone(true,"Update applied — the puck is back and running build "+(nb||"?")+(oldBuild&&nb!==oldBuild?" (was "+oldBuild+")":""));
  1886. }else if(result==="appeared"){
  1887. // The puck re-enumerated but the browser couldn't auto-open it (requires a user gesture on
  1888. // Android Chrome). The update landed — the user just needs to tap Connect.
  1889. modalDone(true,"Update applied — the puck is back. Tap Connect to reconnect.");
  1890. }else{
  1891. modalDone(false,"The update was sent and armed, but the puck didn't reconnect within 40 s. Unplug/replug it. "
  1892. +"If it mounts as a UF2BOOT drive, drag the .uf2 onto the drive to recover — the bootloader is intact.");
  1893. }
  1894. }catch(e){
  1895. log("firmware update FAILED: "+e.message);
  1896. modalDone(false,e.message+" — nothing was applied; the running firmware is untouched.");
  1897. try{ await fwupSend([0x24]); }catch(_e){} // best-effort disarm
  1898. if(dev) startPolling();
  1899. }finally{
  1900. dfuBusy=false; updateUf2UI();
  1901. }
  1902. }
  1903. // ---- local-file source: drag-and-drop or file picker ----
  1904. async function pickUf2(f){
  1905. try{
  1906. const image=uf2ToImage(await f.arrayBuffer());
  1907. selectedUf2={name:f.name, image, updatable:imagePanelUpdatable(image)};
  1908. log("UF2 ready: "+f.name+" — app image "+Math.round(image.length/1024)+" KiB, CRC32 0x"+crc32(image).toString(16).padStart(8,"0"));
  1909. }catch(err){
  1910. selectedUf2=null;
  1911. log("UF2 rejected: "+err.message);
  1912. }
  1913. updateUf2UI();
  1914. }
  1915. $("#uf2File").onchange=async(e)=>{
  1916. const f=e.target.files && e.target.files[0];
  1917. e.target.value=""; // allow re-picking the same filename after a rebuild
  1918. if(f) await pickUf2(f);
  1919. };
  1920. // a file dropped anywhere the dropzone isn't listening (e.g. while gated) must not navigate the page to it
  1921. window.addEventListener("dragover",e=>e.preventDefault());
  1922. window.addEventListener("drop",e=>e.preventDefault());
  1923. {
  1924. const dz=$("#uf2Drop");
  1925. dz.onclick=()=>$("#uf2File").click();
  1926. dz.ondragover=e=>{ e.preventDefault(); dz.classList.add("drag"); };
  1927. dz.ondragleave=()=>dz.classList.remove("drag");
  1928. dz.ondrop=async e=>{
  1929. e.preventDefault(); dz.classList.remove("drag");
  1930. const f=e.dataTransfer.files && e.dataTransfer.files[0];
  1931. if(f) await pickUf2(f);
  1932. };
  1933. }
  1934. $("#uf2Flash").onclick=()=>{
  1935. if(!selectedUf2) return;
  1936. runUpdate("Flashing "+selectedUf2.name,
  1937. "Flash "+selectedUf2.name+" ("+Math.round(selectedUf2.image.length/1024)+" KiB)?\n\n"
  1938. +"The image is sent over WebUSB into spare flash (~15 s), verified on the puck, and applied on an automatic "
  1939. +"reboot (~5 s dark). Nothing is armed until it verifies, so a failed or interrupted transfer leaves the "
  1940. +"current firmware untouched. Worst case (power cut during the apply itself) the puck comes back as the "
  1941. +"UF2BOOT drive for drag-and-drop recovery — it cannot end up half-flashed.",
  1942. async()=>selectedUf2.image);
  1943. };
  1944. // ---- release source: list from the GitHub API, binaries from the CORS-reachable firmware mirror ----
  1945. // api.github.com sends Access-Control-Allow-Origin:* but release-asset downloads redirect to a CDN that does
  1946. // NOT, so the release workflow mirrors every OpenPuck .uf2 onto the repo's orphan `firmware` branch and the
  1947. // panel fetches it from raw.githubusercontent.com (which is CORS-clean). Fallbacks: the API asset endpoint
  1948. // (in case GitHub ever fixes its CDN), then a plain new-tab download + the local-file card.
  1949. const REL_REPO="safijari/openpuck";
  1950. let relCache=null;
  1951. // firmware-mirror manifest: asset name -> {panelUpdate}. Written by the release workflow, which scans each
  1952. // .uf2 for the FWUP_TAG capability string. null = manifest unreachable (then releases just go unbadged; the
  1953. // byte-scan warning at flash time still catches an unsupported image).
  1954. let relManifest=null;
  1955. // tri-state: true = a puck running this release can be panel-updated, false = it cannot (drag-and-drop only
  1956. // to get off it), null = unknown (no manifest)
  1957. function relPanelUpdatable(rel){
  1958. if(!relManifest) return null;
  1959. const known=[relAsset(rel,false),relAsset(rel,true)].filter(a=>a && relManifest[a.name]);
  1960. if(!known.length) return null; // not mirrored (yet) — unknown, don't badge (mirror step lags a fresh release by ~a minute)
  1961. return known.some(a=>relManifest[a.name].panelUpdate);
  1962. }
  1963. async function downloadAsset(asset){
  1964. modalStage("Downloading "+asset.name,0);
  1965. let resp=null;
  1966. try{
  1967. const r=await fetch("https://raw.githubusercontent.com/"+REL_REPO+"/firmware/"+asset.name,{cache:"no-store"});
  1968. if(r.ok) resp=r;
  1969. }catch(e){}
  1970. if(!resp){
  1971. try{
  1972. const r=await fetch(asset.url,{headers:{Accept:"application/octet-stream"}});
  1973. if(r.ok) resp=r;
  1974. }catch(e){}
  1975. }
  1976. if(!resp){
  1977. window.open(asset.browser_download_url,"_blank","noopener");
  1978. throw new Error(asset.name+" isn't on the firmware mirror yet and GitHub's asset CDN blocks in-page downloads. "
  1979. +"It's downloading in a new tab instead — drop the file on the local-file card above.");
  1980. }
  1981. const total=+resp.headers.get("content-length") || asset.size || 0;
  1982. const reader=resp.body.getReader(), parts=[]; let got=0;
  1983. for(;;){
  1984. const {done,value}=await reader.read();
  1985. if(done) break;
  1986. parts.push(value); got+=value.length;
  1987. if(total) modalStage("Downloading "+asset.name, Math.min(99,Math.floor(got*100/total)));
  1988. }
  1989. const buf=new Uint8Array(got); let o=0;
  1990. for(const p of parts){ buf.set(p,o); o+=p.length; }
  1991. return uf2ToImage(buf.buffer);
  1992. }
  1993. function relAsset(rel,factory){
  1994. const suffix=factory ? "factory-reset" : "standard";
  1995. return rel.assets.find(a=>a.name==="OpenPuck-"+rel.tag_name+"-"+suffix+".uf2")
  1996. || rel.assets.find(a=>new RegExp("^OpenPuck-.*-"+suffix+"\\.uf2$").test(a.name)) || null;
  1997. }
  1998. function flashRelease(rel,factory){
  1999. const asset=relAsset(rel,factory);
  2000. if(!asset){ log("release "+rel.tag_name+" has no "+(factory?"factory-reset":"standard")+" .uf2 asset"); return; }
  2001. const kb=Math.round((asset.size||0)/1024);
  2002. runUpdate("Updating to "+rel.tag_name+(factory?" (factory reset)":""),
  2003. "Update the puck to "+rel.tag_name+(factory
  2004. ? " using the FACTORY RESET build?\n\nOn its first boot it wipes ALL settings and the controller pairing — you must re-pair the controller afterwards."
  2005. : "?")
  2006. +"\n\n"+asset.name+" ("+kb+" KiB) is downloaded, sent over WebUSB, verified on the puck, and applied on an "
  2007. +"automatic reboot. A failed or interrupted transfer leaves the current firmware untouched.",
  2008. ()=>downloadAsset(asset));
  2009. }
  2010. function renderReleases(){
  2011. const list=$("#relList");
  2012. list.textContent="";
  2013. let shown=0;
  2014. for(const rel of relCache){
  2015. const std=relAsset(rel,false), fac=relAsset(rel,true);
  2016. if(!std && !fac) continue;
  2017. shown++;
  2018. const row=document.createElement("div"); row.className="rel-row";
  2019. const ver=document.createElement("span"); ver.className="ver"; ver.textContent=rel.tag_name;
  2020. const date=document.createElement("span"); date.className="date";
  2021. date.textContent=(rel.published_at||"").slice(0,10)+(rel.prerelease?" · pre":"");
  2022. const name=document.createElement("span"); name.className="grow note"; name.style.margin="0";
  2023. name.textContent=rel.name && rel.name!==rel.tag_name ? rel.name : "";
  2024. const lab=document.createElement("label");
  2025. const cb=document.createElement("input"); cb.type="checkbox"; cb.disabled=!fac;
  2026. lab.appendChild(cb); lab.appendChild(document.createTextNode("factory reset"));
  2027. lab.title=fac ? "Flash the -factory-reset build: wipes settings + pairing once on first boot"
  2028. : "this release has no factory-reset build";
  2029. const btn=document.createElement("button"); btn.textContent="Flash "+rel.tag_name;
  2030. btn.onclick=()=>flashRelease(rel,cb.checked);
  2031. row.append(ver,date,name);
  2032. if(relPanelUpdatable(rel)===false){
  2033. const pill=document.createElement("span"); pill.className="pill dn";
  2034. pill.textContent="no panel updates";
  2035. pill.title="This build predates panel-update support: it flashes fine from here, but a puck running it "
  2036. +"can only be updated again via UF2 DFU + drag-and-drop.";
  2037. row.appendChild(pill);
  2038. }
  2039. row.append(lab,btn);
  2040. list.appendChild(row);
  2041. }
  2042. if(!shown) list.textContent="no releases with OpenPuck .uf2 assets found";
  2043. }
  2044. async function loadReleases(force){
  2045. if(relCache && !force) return;
  2046. const list=$("#relList");
  2047. list.textContent="loading…";
  2048. try{
  2049. const [r,mr]=await Promise.all([
  2050. fetch("https://api.github.com/repos/"+REL_REPO+"/releases?per_page=15",
  2051. {headers:{Accept:"application/vnd.github+json"}}),
  2052. fetch("https://raw.githubusercontent.com/"+REL_REPO+"/firmware/manifest.json",{cache:"no-store"})
  2053. .catch(()=>null),
  2054. ]);
  2055. if(!r.ok) throw new Error("GitHub API "+r.status);
  2056. try{ relManifest=(mr && mr.ok) ? await mr.json() : null; }catch(e){ relManifest=null; }
  2057. relCache=(await r.json()).filter(x=>!x.draft);
  2058. renderReleases();
  2059. checkUpdateNotice(); // the connect-time blob usually lands before this fetch does
  2060. }catch(e){
  2061. list.innerHTML='could not load releases ('+e.message.replace(/</g,"&lt;")
  2062. +') — <a href="https://github.com/'+REL_REPO+'/releases" target="_blank" rel="noopener" style="color:var(--acc2)">open the releases page</a> and use the local-file card';
  2063. relCache=null;
  2064. }
  2065. }
  2066. $("#relRefresh").onclick=()=>loadReleases(true);
  2067. // ---- top-level tabs ----
  2068. for(const t of document.querySelectorAll("#mainTabs .slot-tab")){
  2069. t.onclick=async()=>{
  2070. if(t.dataset.tab==="tabUpdate" && !BETA_UI){
  2071. const ok=await betaPopup({
  2072. title:"Firmware update is beta",
  2073. body:["Firmware update is still being tested and may not work correctly.",
  2074. "A bad image or interrupted recovery path can leave you needing UF2 DFU to recover.",
  2075. "Enable beta mode before using this feature."],
  2076. okText:"Enable beta"
  2077. });
  2078. if(ok){
  2079. const u=new URL(location.href);
  2080. u.searchParams.set("beta","true");
  2081. location.href=u.toString();
  2082. }
  2083. return;
  2084. }
  2085. for(const x of document.querySelectorAll("#mainTabs .slot-tab")) x.classList.toggle("active",x===t);
  2086. $("#tabMain").classList.toggle("hide", t.dataset.tab!=="tabMain");
  2087. $("#tabUpdate").classList.toggle("hide", t.dataset.tab!=="tabUpdate");
  2088. if(t.dataset.tab==="tabUpdate") loadReleases(false); // lazy: first visit fetches the list
  2089. };
  2090. }
  2091. $("#backupExport").onclick=exportBackup;
  2092. $("#backupImport").onclick=()=>$("#backupFile").click();
  2093. $("#backupFile").onchange=async(e)=>{ const f=e.target.files[0]; if(f) await importBackup(f); e.target.value=""; };
  2094. $("#capDl").onclick=downloadCap;
  2095. $("#capStart").onclick=startCapture;
  2096. $("#capStop").onclick=stopCapture;
  2097. $("#lzAdd").onclick=()=>{ if(lizardBindings.length<LZ_MAX){ lizardBindings.push({outType:LZO.KBD,od:[0,0,0,0,0,0,0],trig:0,hold:0}); renderLizard(); } };
  2098. $("#lzSave").onclick=saveLizard;
  2099. $("#lzReload").onclick=loadLizard;
  2100. $("#lzReset").onclick=resetLizard;
  2101. $("#stabBtn").onclick=async()=>{
  2102. if(!dev){ log("connect first"); return; }
  2103. stabArmed=!stabArmed;
  2104. if(stabArmed){ await send([0x0F,1]); stabStart=Date.now(); stabLastRun=null; log("stability test STARTED — buzzing every 10s, timing uptime until reset"); trailAdd("stability test started (buzz every 10s)"); }
  2105. else { await send([0x0F,0]); stabStart=0; log("stability test stopped"); trailAdd("stability test stopped"); }
  2106. updateStabUI();
  2107. };
  2108. $("#hangClear").onclick=()=>{ hangLog=[]; renderHangLog(); };
  2109. $("#flightLoad").onclick=loadFlightTrail;
  2110. $("#hangCsv").onclick=()=>{
  2111. const rows=[["time","uptime_s","reason","stage","pc","lr","usbd_free_words"]].concat(
  2112. hangLog.map(e=>[e.time, e.uptime!=null?Math.round(e.uptime):"", e.reason, e.stage, e.pc, e.lr, e.usbd]));
  2113. const csv=rows.map(r=>r.map(x=>'"'+String(x).replace(/"/g,'""')+'"').join(",")).join("\n");
  2114. const a=document.createElement("a"); a.href=URL.createObjectURL(new Blob([csv],{type:"text/csv"}));
  2115. a.download="openpuck-hanglog.csv"; a.click();
  2116. };
  2117. $("#hapClear").onclick=async()=>{ if(dev){ await send([0x07]); log("haptic re-init sent (clear stuck buzz)"); } };
  2118. $("#ctlrOff").onclick=async()=>{ if(dev){ await send([0x08]); log("controller power-off attempt sent — watch link status");
  2119. trailAdd("power-off sent to controller (panel button) — stress action, correlate with what follows"); } };
  2120. $("#debugCdc").onclick=async()=>{
  2121. if(!dev){ log("not connected"); return; }
  2122. if(!confirm("Reboot with the CDC serial console enabled?\n\nThe puck reboots and comes back with a serial port (115200 baud) instead of WebUSB — this panel will disconnect and NOT reconnect until the next reboot.\n\nConnect a serial monitor to capture logs, then replug (or reboot) to return to normal WebUSB mode. The debug console auto-reverts after one boot.")) return;
  2123. await send([0x02,20,1]); log("debug-CDC reboot sent — device disconnecting; reconnect a serial monitor at 115200 baud");
  2124. };
  2125. $("#dfuSerial").onclick=async()=>{
  2126. if(!dev){ log("not connected"); return; }
  2127. if(!confirm("Reboot into serial DFU?\n\nThe puck will disconnect immediately. Flash with adafruit-nrfutil, then replug.")) return;
  2128. await send([0x0B]); log("serial DFU reboot sent — device disconnecting");
  2129. };
  2130. $("#dfuUf2").onclick=async()=>{
  2131. if(!dev){ log("not connected"); return; }
  2132. 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;
  2133. await send([0x0C]); log("UF2 bootloader reboot sent — device disconnecting");
  2134. };
  2135. $("#factoryErase").onclick=async()=>{
  2136. if(!dev){ log("not connected"); return; }
  2137. 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;
  2138. if(!confirm("Are you absolutely sure?\n\nThere is no recovery. The controller bond and all settings will be gone.")) return;
  2139. const typed=prompt('Final confirmation — type ERASE (all caps) to wipe everything:');
  2140. if(typed!=="ERASE"){ log("factory erase cancelled (confirmation text did not match)"); return; }
  2141. await send([0x0A,0x45,0x52,0x53]);
  2142. log("FACTORY ERASE sent — copycat is reformatting and rebooting to defaults. Re-pair the controller, then reconnect.");
  2143. };
  2144. // Debug-only: full board wipe. Erases the firmware itself (not just settings), leaving the board app-less so
  2145. // it mounts as the UF2 bootloader drive on every boot until OpenPuck is flashed again. Sends 0x25 + "WIPE".
  2146. $("#wipeBoard").onclick=async()=>{
  2147. if(!dev){ log("not connected"); return; }
  2148. if(!confirm("WIPE THE ENTIRE BOARD?\n\nThis is NOT a factory reset. It erases:\n • the OpenPuck FIRMWARE itself\n • every setting (mode, chords, back paddles, sensitivity)\n • the paired-controller bond\n\nThe board reboots with NO firmware and mounts as the UF2 bootloader drive. It will do so on EVERY boot until you flash OpenPuck (a .uf2) back onto it. This panel will disconnect and NOT reconnect until then.\n\nThis CANNOT be undone from software.")) return;
  2149. if(!confirm("Are you absolutely sure?\n\nThe board will be blank. The ONLY way back is to drag a .uf2 firmware file onto the UF2 drive it mounts as.")) return;
  2150. const typed=prompt('Final confirmation — type WIPE (all caps) to erase the whole board:');
  2151. if(typed!=="WIPE"){ log("board wipe cancelled (confirmation text did not match)"); return; }
  2152. await send([0x25,0x57,0x49,0x50,0x45]);
  2153. log("FULL BOARD WIPE sent — the board is erasing firmware + all data (~15–20 s), then reboots as a blank UF2 drive. Flash OpenPuck (.uf2) to restore it.");
  2154. };
  2155. $("#persistMode").onclick=()=>{ const on=$("#persistMode").classList.contains("active"); setField(16, on?0:1); };
  2156. $("#landAll87").onclick=async()=>{ const on=$("#landAll87").classList.contains("active"); await setField(29, on?0:1); log("land-all-0x87 "+(on?"disabled":"enabled")); };
  2157. // Connection tuning: post-connect haptic block on (field 27) + seconds (field 28). (poll RX window is fixed.)
  2158. for(const id of ["mDiv","mFric"]){
  2159. const field={mDiv:1,mFric:2}[id];
  2160. $("#"+id).addEventListener("input", ()=>{ $("#"+id+"V").textContent=fmtSlider(id,$("#"+id).value); });
  2161. $("#"+id).addEventListener("change", ()=>setField(field, +$("#"+id).value));
  2162. }
  2163. // per-type back/QAM/A-B-swap/haptics handlers are wired in buildTypeCfgs()
  2164. $("#swGyroMap").addEventListener("change", ()=>setField(38, +$("#swGyroMap").value));
  2165. for(const sel of document.querySelectorAll("select.chord")){
  2166. sel.addEventListener("change", ()=>setField(CHORD_FIELD[+sel.dataset.i], +sel.value));
  2167. }
  2168. for(const sel of document.querySelectorAll("select.chordD")){
  2169. sel.addEventListener("change", ()=>setField(CHORD_DPAD_FIELD[+sel.dataset.i], +sel.value));
  2170. }
  2171. for(const b of document.querySelectorAll(".modebtn")){
  2172. b.onclick=()=>{ const m=+b.dataset.mode;
  2173. const clean=(m===7||m===8||m===9);
  2174. 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 all four back paddles (L4+R4+L5+R5) + A to return to Steam mode. The back4+D-pad chords are how you get back INTO these modes without the panel — check their assignments in the chords card first.":"");
  2175. if(confirm(msg)){ send([0x03, m]); log("mode switch requested — device will reboot"); } };
  2176. }
  2177. updateUf2UI();
  2178. updateFwGate(); // start gated: stays inert until the first status blob proves the firmware speaks v15+
  2179. if(!("usb" in navigator)) $("#connectBtn").outerHTML='<b style="color:var(--bad)">WebUSB not supported — use Chrome or Edge.</b>';
  2180. else autoConnect(); // reopen an already-authorized puck on load, no picker (first-ever connect still needs the button)
  2181. </script>
  2182. </body>
  2183. </html>