| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881 |
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width, initial-scale=1">
- <title>OpenPuck Config</title>
- <style>
- :root{ --bg:#11131a; --card:#1b1f2b; --fg:#e7ecf3; --mut:#8a93a6; --acc:#5bd6a0; --acc2:#5b9bd6; --bad:#d65b6e; }
- *{box-sizing:border-box}
- body{margin:0;font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--fg)}
- h1{font-size:20px;margin:0 0 4px}
- .sub{color:var(--mut);margin:0 0 18px;font-size:13px}
- /* sticky top action bar: connection + device-maintenance actions */
- .topbar{position:sticky;top:0;z-index:20;display:flex;align-items:center;gap:10px;flex-wrap:wrap;
- padding:9px 18px;background:#151925;border-bottom:1px solid #272d3d}
- .topbar .brand{font-weight:700;font-size:15px;letter-spacing:.02em}
- .topbar .brand small{color:var(--mut);font-weight:400;font-size:11px;margin-left:6px}
- .topbar .spacer{flex:1 1 16px}
- .topbar button{padding:7px 12px}
- .wrap{max-width:980px;margin:0 auto;padding:20px}
- /* cards stack in one column; width comes from the wider wrap + grids/rows filling it horizontally */
- .card{background:var(--card);border:1px solid #272d3d;border-radius:12px;padding:16px 18px;margin:0 0 16px}
- .card h2{font-size:13px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);margin:0 0 14px}
- .row{display:flex;align-items:center;gap:14px;margin:12px 0;flex-wrap:wrap}
- .row label{flex:0 0 150px;color:var(--fg)}
- .row .val{flex:0 0 64px;text-align:right;color:var(--acc);font-variant-numeric:tabular-nums}
- input[type=range]{flex:1;min-width:120px;accent-color:var(--acc)}
- select{background:#0e1017;color:var(--fg);border:1px solid #313a4f;border-radius:8px;padding:6px 8px;flex:1;min-width:120px}
- button{background:#222a3a;color:var(--fg);border:1px solid #36405a;border-radius:8px;padding:9px 14px;cursor:pointer;font:inherit}
- button:hover{border-color:var(--acc2)}
- button.primary{background:var(--acc2);border-color:var(--acc2);color:#06121f;font-weight:600}
- button.active{background:var(--acc);border-color:var(--acc);color:#062018;font-weight:600}
- button.danger{background:#3a1420;border-color:#7a2233;color:#ff9bb0;font-weight:600}
- button.danger:hover{border-color:#ff9bb0}
- button:disabled{opacity:.4;cursor:default}
- .pill{display:inline-block;padding:2px 9px;border-radius:99px;font-size:12px;font-weight:600}
- .pill.up{background:#143626;color:var(--acc)} .pill.dn{background:#3a1822;color:var(--bad)}
- /* update-available notice (topbar): :not(.hide) so this ID rule can't out-rank .hide (see #updModal) */
- #updAvail:not(.hide){background:#14263a;color:var(--acc2);cursor:pointer;border:1px solid #2b4a6d}
- #updAvail:hover{text-decoration:underline}
- .statgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(155px,1fr));gap:10px}
- .stat{background:#0e1017;border:1px solid #232a3a;border-radius:8px;padding:10px}
- .stat .k{color:var(--mut);font-size:11px;text-transform:uppercase;letter-spacing:.05em}
- .stat .v{font-size:18px;margin-top:2px}
- .modes{display:flex;gap:10px;flex-wrap:wrap}
- .note{color:var(--mut);font-size:12px;margin-top:10px}
- #log{font:12px ui-monospace,Menlo,monospace;color:var(--mut);white-space:pre-wrap;margin-top:8px;max-height:140px;overflow:auto}
- .hide{display:none}
- /* ---- controller slot tabs ---- */
- .slot-tabs{display:flex;gap:6px;margin:0 0 14px;flex-wrap:wrap}
- .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}
- .slot-tab:hover{border-color:var(--acc2);color:var(--fg)}
- .slot-tab.active{background:#111c2e;border-color:var(--acc2);color:var(--fg)}
- .slot-tab .dot{width:7px;height:7px;border-radius:50%;background:var(--bad);flex-shrink:0;transition:background .3s}
- .slot-tab .dot.up{background:var(--acc);animation:pulse-link 2.2s ease-in-out infinite}
- @keyframes pulse-link{
- 0%,100%{box-shadow:0 0 0 0 rgba(91,214,160,.5)}
- 55%{box-shadow:0 0 0 5px rgba(91,214,160,0)}
- }
- @media(prefers-reduced-motion:reduce){.slot-tab .dot.up{animation:none}}
- .per-slot-grid{margin-bottom:12px}
- /* ---- top-level tabs (Controller / Firmware update) ---- */
- #mainTabs{margin:0 0 16px}
- #mainTabs .slot-tab{font-size:13px;padding:7px 18px}
- /* ---- firmware update tab ---- */
- .dropzone{border:2px dashed #36405a;border-radius:12px;padding:26px 18px;text-align:center;cursor:pointer;
- color:var(--mut);transition:border-color .15s,background .15s;margin-bottom:14px}
- .dropzone:hover,.dropzone.drag{border-color:var(--acc);color:var(--fg);background:#151b28}
- .dropzone b{color:var(--fg)}
- .rel-row{display:flex;align-items:center;gap:14px;padding:10px 2px;border-top:1px solid #232a3a;flex-wrap:wrap}
- .rel-row:first-child{border-top:0}
- .rel-row .ver{font-weight:700;min-width:70px}
- .rel-row .date{color:var(--mut);font-size:12px;min-width:90px}
- .rel-row .grow{flex:1}
- .rel-row label{display:flex;align-items:center;gap:6px;color:var(--mut);font-size:12px;cursor:pointer}
- .rel-row input[type=checkbox]{accent-color:var(--bad)}
- /* firmware-tab gate: when the connected puck's firmware predates panel updates, both update cards go
- inert (pointer-events kills clicks, drops, and the file picker) and the banner explains why */
- .fwupcard.gated{opacity:.45;pointer-events:none;user-select:none}
- /* ---- blocking update modal ---- */
- /* :not(.hide) because this ID selector would otherwise out-rank .hide{display:none} and pin the modal open */
- #updModal:not(.hide){position:fixed;inset:0;z-index:100;background:rgba(8,10,16,.8);display:flex;align-items:center;justify-content:center}
- .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)}
- .updbox h3{margin:0 0 4px;font-size:16px}
- #updStage{color:var(--fg);margin:10px 0 0;font-size:14px}
- .updbar{height:10px;background:#0e1017;border:1px solid #232a3a;border-radius:99px;overflow:hidden;margin:10px 0 6px}
- #updFill{height:100%;width:0;background:var(--acc);transition:width .2s}
- #updFill.pulse{background:repeating-linear-gradient(45deg,var(--acc2),var(--acc2) 12px,#38618c 12px,#38618c 24px);animation:updslide 1s linear infinite}
- #updFill.bad{background:var(--bad)}
- @keyframes updslide{to{background-position:34px 0}}
- @media(prefers-reduced-motion:reduce){#updFill.pulse{animation:none}}
- </style>
- </head>
- <body>
- <div class="topbar">
- <span class="brand">OpenPuck <small>SC2 puck · WebUSB</small></span>
- <span id="connState" class="pill dn">disconnected</span>
- <button id="connectBtn" class="primary">Connect</button>
- <span id="updAvail" class="pill hide" title="A newer OpenPuck release is available — click to open the Firmware update tab">…</span>
- <span class="spacer"></span>
- <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>
- <button id="dfuSerial" title="Reboot into serial DFU (adafruit-nrfutil), then replug">Serial DFU</button>
- <button id="dfuUf2" title="Reboot into the UF2 mass-storage bootloader; drop a .uf2 on the drive">UF2 DFU</button>
- <button id="factoryErase" class="danger" title="Wipe ALL bonds + settings and reboot to defaults. You must re-pair. Cannot be undone.">⚠ Factory erase</button>
- </div>
- <div class="wrap">
- <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>
- <div id="panel" class="hide">
- <div class="slot-tabs" id="mainTabs">
- <span class="slot-tab active" data-tab="tabMain">Controller</span>
- <span class="slot-tab" data-tab="tabUpdate">Firmware update</span>
- </div>
- <div id="tabMain">
- <div class="cards">
- <div class="card">
- <h2>USB mode</h2>
- <div class="modes" style="flex-wrap:wrap">
- <button data-mode="0" class="modebtn">Steam (puck)</button>
- <button data-mode="1" class="modebtn">Xbox 360</button>
- <button data-mode="2" class="modebtn">Switch (HORIPAD)</button>
- <button data-mode="3" class="modebtn">Lizard (always)</button>
- <button data-mode="4" class="modebtn">Switch Pro + gyro</button>
- <button data-mode="5" class="modebtn">PS5 DualSense</button>
- <button data-mode="6" class="modebtn">HID gyro (DS4)</button>
- <button data-mode="9" class="modebtn">PS3 (DualShock 3)</button>
- </div>
- <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>
- <div class="row"><label>Persist last mode</label>
- <button id="persistMode">off</button>
- <span class="note" style="flex:1">Off (default): every restart / fresh reconnect boots into Steam mode. On: remembers the last mode you selected.</span>
- </div>
- </div>
- <div class="card">
- <h2>Back4 + face button chords</h2>
- <p class="note" style="margin-top:0">Hold all four back paddles (L4+R4+L5+R5) plus a face button to switch mode without the WebUI. <strong>A is always Steam.</strong> B / X / Y are configurable below (defaults: B=Lizard, X=Xbox, Y=Switch HORIPAD).</p>
- <div class="row"><label>B + back4 →</label><select class="chord" data-i="0"></select></div>
- <div class="row"><label>X + back4 →</label><select class="chord" data-i="1"></select></div>
- <div class="row"><label>Y + back4 →</label><select class="chord" data-i="2"></select></div>
- </div>
- <div class="card wide">
- <h2>Link status</h2>
- <!-- Per-controller tabs: visible only when 2+ controllers are bonded -->
- <div class="slot-tabs" id="slotTabs" style="display:none"></div>
- <!-- Per-slot: RF link / Battery / Signal / this controller's own link rates (blob v13) -->
- <div class="statgrid per-slot-grid">
- <div class="stat"><div class="k">RF link</div><div class="v" id="stLink">—</div></div>
- <div class="stat"><div class="k">Battery</div><div class="v" id="stBatt">—</div></div>
- <div class="stat"><div class="k">Signal</div><div class="v" id="stRssi">—</div></div>
- <div class="stat"><div class="k">Polls/s</div><div class="v" id="stSlotPolls">—</div></div>
- <div class="stat"><div class="k">Delivered</div><div class="v" id="stSlotF1">—</div></div>
- <div class="stat"><div class="k">Fails/s (crc·noRx·relay)</div><div class="v" id="stSlotFail">—</div></div>
- </div>
- <!-- Global stats (sums over ALL controllers; the per-slot grid above shows the selected controller's own rates) -->
- <div class="statgrid">
- <div class="stat"><div class="k">Delivered (all)</div><div class="v" id="stRate">—</div></div>
- <div class="stat"><div class="k">New reports (all)</div><div class="v" id="stNew">—</div></div>
- <div class="stat"><div class="k">Polls/s (all)</div><div class="v" id="stPolls">—</div></div>
- <div class="stat"><div class="k">Relay/s (all)</div><div class="v" id="stRelay">—</div></div>
- <div class="stat"><div class="k">RF fails/s (crc·noRx·heal)</div><div class="v" id="stRfFail">—</div></div>
- <div class="stat"><div class="k">Clock LF / HF</div><div class="v" id="stClock">—</div></div>
- <div class="stat"><div class="k">µs per ms (ideal 1000)</div><div class="v" id="stUsPerMs">—</div></div>
- <div class="stat"><div class="k">Loop</div><div class="v" id="stLoopState">—</div></div>
- <div class="stat"><div class="k">usbd stack free (words)</div><div class="v" id="stUsbdStack">—</div></div>
- <div class="stat logonly"><div class="k">Loop period</div><div class="v" id="stLoop">—</div></div>
- <div class="stat logonly"><div class="k">Slowest stage</div><div class="v" id="stWorst">—</div></div>
- <div class="stat logonly"><div class="k">Poll period act/want</div><div class="v" id="stPollPer">—</div></div>
- <div class="stat"><div class="k">Build</div><div class="v" id="stBuild">—</div></div>
- <div class="stat"><div class="k">Last reset</div><div class="v" id="stReset">—</div></div>
- </div>
- <p class="note" style="margin-top:6px"><strong>Reset detail:</strong> <span id="resetHist" style="font-family:ui-monospace,Menlo,monospace">—</span><br>
- <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> & <em>pin/replug</em> = normal plug-in.</p>
- <p class="note" style="margin-top:8px"><strong>IMU (raw):</strong> <span id="stImu" style="font-family:ui-monospace,Menlo,monospace">—</span><br>
- 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>
- <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>
- <div class="row debugonly" style="margin:14px 0 0;border-top:1px solid #272d3d;padding-top:14px"><button id="hapClear">Clear stuck buzz</button>
- <span class="note" style="flex:1">If the controller's haptics get stuck/buzzing, this re-inits them (same as pressing Steam).</span>
- </div>
- <div class="row" style="margin:10px 0 0"><button id="ctlrOff">Turn off controller</button>
- <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>
- </div>
- <div class="row debugonly" style="margin:10px 0 0"><button id="landAll87">off</button>
- <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>
- </div>
- <div class="row debugonly" style="margin:10px 0 0;border-top:1px solid #272d3d;padding-top:14px"><button id="stabBtn">Test stability</button>
- <span class="v" id="stabStatus" style="flex:0 0 auto;min-width:120px">—</span>
- <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>
- </div>
- </div>
- <div class="card" id="mouseCard">
- <h2>Trackpad mouse (lizard + Xbox)</h2>
- <div class="row"><label>Sensitivity</label><input type="range" id="mDiv" min="4" max="200" step="1"><span class="val" id="mDivV"></span></div>
- <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>
- <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>
- </div>
- <div class="card">
- <h2>Rumble</h2>
- <div class="row"><label>Strength</label><input type="range" id="rumble" min="0" max="255" step="5"><span class="val" id="rumbleV"></span></div>
- <p class="note">Scales rumble amplitude across all modes (100% = native, 200% = double, 0% = off).</p>
- </div>
- <div class="card">
- <h2>Switch Pro motion</h2>
- <div class="row"><label>Report rate</label><select id="swRate"></select></div>
- <p class="note" style="margin:0 0 6px">Full rate (~250 Hz, default) is lowest-latency. 120 Hz is low-latency and drift-free. 66 Hz is a genuine-cadence compatibility fallback — drop to it only if your console rejects the faster rates.</p>
- <div class="row"><label>Gyro scale</label><select id="swGyro"></select></div>
- <p class="note">Gyro sensitivity multiplier for Switch Pro mode (1.0× = unchanged).</p>
- </div>
- <div class="card wide">
- <h2>Button mapping</h2>
- <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 & Lizard are native puck modes (Steam does its own remapping) and aren't configured here.</p>
- <div id="typeCfgs"></div>
- </div>
- <div class="card" id="lizardCard" style="display:none">
- <h2>Lizard / desktop mapping</h2>
- <p class="note" style="margin-top:0">Keyboard, mouse & 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>
- <div id="lizardList"></div>
- <div class="row" style="margin:12px 0 0;flex-wrap:wrap;gap:8px">
- <button id="lzAdd">+ Add binding</button>
- <button id="lzSave" class="primary">Save to device</button>
- <button id="lzReload">Reload</button>
- <button id="lzReset" style="margin-left:auto">Reset to defaults</button>
- </div>
- <p class="note" id="lzStatus" style="margin-top:8px">—</p>
- </div>
- <div class="card">
- <h2>Backup & clone</h2>
- <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>
- <div class="row" style="margin:0;flex-wrap:wrap">
- <button id="backupExport" class="primary">Export to file…</button>
- <button id="backupImport">Import from file…</button>
- <input type="file" id="backupFile" accept="application/json,.json" style="display:none">
- </div>
- <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>
- </div>
- <div class="card wide debugonly">
- <h2>Hang log</h2>
- <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>
- <div class="row" style="margin:0 0 8px;flex-wrap:wrap">
- <span class="v" id="hangSummary" style="flex:1">no resets logged yet</span>
- <button id="hangCsv">Download CSV</button>
- <button id="hangClear">Clear</button>
- </div>
- <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>
- </div>
- <div class="card wide debugonly">
- <h2>Loop-state trail</h2>
- <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>
- <div class="row" style="margin:0 0 8px;flex-wrap:wrap">
- <span class="v" id="trailSummary" style="flex:1">no events yet</span>
- <button id="trailClear">Clear</button>
- </div>
- <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>
- </div>
- <div class="card wide debugonly">
- <h2>Flight recorder</h2>
- <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>
- <div class="row" style="margin:0 0 8px;flex-wrap:wrap">
- <span class="v" id="flightSummary" style="flex:1">no trail loaded</span>
- <button id="flightLoad">Load last-hang trail</button>
- </div>
- <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>
- </div>
- <div class="card wide logonly">
- <h2>Capture — host → controller commands</h2>
- <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>
- <div class="row" style="margin:0;flex-wrap:wrap">
- <button id="capStart" class="primary">Dump from boot</button>
- <button id="capStop" disabled>Stop</button>
- <button id="capDl">Download .txt</button>
- </div>
- <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>
- <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>
- </div>
- </div><!-- .cards -->
- </div><!-- #tabMain -->
- <div id="tabUpdate" class="hide">
- <div id="updGate" class="card hide" style="border-color:#7a2233">
- <h2 style="color:var(--bad)">Panel updates not supported by this firmware</h2>
- <p class="note" id="updGateMsg" style="margin:0"></p>
- </div>
- <div class="card fwupcard">
- <h2>Update from a local file</h2>
- <input id="uf2File" class="hide" type="file" accept=".uf2,application/octet-stream">
- <div id="uf2Drop" class="dropzone">
- <div><b>Drop a .uf2 file here</b> — or click to browse</div>
- <div class="note" id="uf2Sel" style="margin-top:6px">no file selected</div>
- </div>
- <div class="row" style="margin:0">
- <button id="uf2Flash" class="primary" disabled title="Pick a .uf2 file first">Flash firmware</button>
- <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>
- </div>
- </div>
- <div class="card fwupcard">
- <h2>Update from a release</h2>
- <div class="row" style="margin-top:0">
- <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> · installed: <b id="updInstalled" style="color:var(--acc)">—</b></span>
- <button id="relRefresh">Refresh</button>
- </div>
- <div id="relList" class="note" style="margin:0">loading…</div>
- <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>
- </div>
- </div><!-- #tabUpdate -->
- <div id="log"></div>
- </div>
- </div>
- <!-- blocking modal: covers the whole page while an update is in flight so nothing else can be clicked -->
- <div id="updModal" class="hide">
- <div class="updbox">
- <h3 id="updTitle">Firmware update</h3>
- <div id="updStage">…</div>
- <div class="updbar"><div id="updFill"></div></div>
- <div id="updPct" class="note" style="margin:0"></div>
- <div id="updDetail" class="note"></div>
- <div class="row" style="justify-content:flex-end;margin:14px 0 0"><button id="updClose" class="hide">Close</button></div>
- </div>
- </div>
- <script>
- 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)"];
- const CHORD_FIELD = [17, 18, 19];
- let dev=null, epIn=0, epOut=0, ifNum=0, polling=false, capturing=false, backupBusy=false;
- let lastP=null; // most recent status-blob payload (config source for Export)
- // Stability test: armed = active across reconnects; stabStart = ms of the current run; stabLastRun = last
- // completed run {secs} (ended by a reset). Kept in-page so it survives the device reset (not a page refresh).
- let stabArmed=false, stabStart=0, stabLastRun=null;
- // Firmware update via the UF2 picker: selectedUf2 = {name, image} (image = the app binary extracted from the
- // .uf2 at pick time, so a bad file is rejected before anything is sent). dfuBusy blocks re-entry while an
- // update is in flight.
- let selectedUf2=null, dfuBusy=false;
- // Running hang log: one entry per reset (persists in-page across the device reset; a page refresh clears it).
- // pendingHang is set at disconnect and enriched with reason/PC/stack from the first blob after reconnect.
- let hangLog=[], pendingHang=null;
- function renderHangLog(){
- const out=$("#hangOut"), sum=$("#hangSummary"); if(!out) return;
- if(!hangLog.length){ out.textContent=""; sum.textContent="no resets logged yet"; return; }
- const ups=hangLog.filter(e=>e.uptime!=null).map(e=>e.uptime);
- const avg=ups.length?Math.round(ups.reduce((a,b)=>a+b,0)/ups.length):null;
- sum.textContent = hangLog.length+" reset(s)"+(avg!=null?(" · avg uptime "+fmtDur(avg)):"");
- const pad=(s,n)=>String(s).padEnd(n);
- out.textContent = pad("time",11)+pad("uptime",9)+pad("reason",16)+pad("stage",10)+pad("PC",11)+"usbd\n"
- + 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");
- }
- function fmtDur(s){ s=Math.round(s); return s>=60 ? (Math.floor(s/60)+"m "+(s%60)+"s") : (s+"s"); }
- function updateUf2UI(){
- const sel=$("#uf2Sel"), flash=$("#uf2Flash");
- if(!sel || !flash) return;
- sel.textContent = selectedUf2
- ? selectedUf2.name+" — app image "+Math.round(selectedUf2.image.length/1024)+" KiB, parsed OK"
- +(selectedUf2.updatable ? "" : " — ⚠ no panel-update support in this image (flashing it = drag-and-drop only afterwards)")
- : "no file selected";
- flash.disabled = dfuBusy || !selectedUf2;
- flash.title = selectedUf2
- ? "Send " + selectedUf2.name + " to the puck over WebUSB, verify it, and apply it on reboot"
- : "Pick a .uf2 file first";
- }
- function updateStabUI(){
- const btn=$("#stabBtn"), st=$("#stabStatus");
- if(!btn) return;
- btn.textContent = stabArmed ? "Stop stability test" : "Test stability";
- btn.classList.toggle("active", stabArmed);
- let txt="";
- if(stabArmed && dev && stabStart) txt="up "+fmtDur((Date.now()-stabStart)/1000);
- else if(stabArmed && !dev) txt="reconnecting…";
- if(stabLastRun) txt += (txt?" · ":"")+"last run: "+fmtDur(stabLastRun.secs);
- st.textContent = txt||"—";
- }
- setInterval(updateStabUI, 500);
- const $ = s=>document.querySelector(s);
- // Debug UI gate: developer/diagnostic controls (stability test, clear-stuck-buzz, land-all-0x87) and the
- // forensic cards (Hang log, Loop-state trail, Flight recorder) only show with ?debug=true in the URL.
- // Everything still RUNS regardless (the trail keeps recording to localStorage, stability auto-resume works)
- // -- only the UI is hidden, so adding ?debug=true after an unattended incident still shows the history.
- const DEBUG_UI = new URLSearchParams(location.search).get("debug")==="true";
- if(!DEBUG_UI) for(const el of document.querySelectorAll(".debugonly")) el.style.display="none";
- // Firmware-update tab gate: only visible with ?beta=true in the URL.
- const BETA_UI = new URLSearchParams(location.search).get("beta")==="true";
- if(!BETA_UI){
- $('#mainTabs .slot-tab[data-tab="tabUpdate"]').style.display="none";
- $("#updAvail").style.display="none";
- }
- function log(m){ const l=$("#log"); l.textContent=(new Date().toLocaleTimeString()+" "+m+"\n"+l.textContent).slice(0,2000); }
- // ---- Loop-state trail (persistent) ----
- // Every transition of the Loop pill lands here with a wall-clock timestamp, persisted in localStorage --
- // unlike the pill (live-only) and the in-page logs (a refresh wipes them), this survives page refreshes and
- // device reboots, so a hang that happened while nobody was watching is still on record. Newest first, capped;
- // cleared only by the Clear button.
- const TRAIL_KEY="opk_loop_trail", TRAIL_MAX=400;
- let loopTrail=[]; try{ loopTrail=JSON.parse(localStorage.getItem(TRAIL_KEY))||[]; }catch(e){ loopTrail=[]; }
- function trailAdd(m){
- loopTrail.unshift({t:Date.now(), m});
- if(loopTrail.length>TRAIL_MAX) loopTrail.length=TRAIL_MAX;
- try{ localStorage.setItem(TRAIL_KEY, JSON.stringify(loopTrail)); }catch(e){}
- renderTrail();
- }
- function renderTrail(){
- const out=$("#trailOut"), sum=$("#trailSummary"); if(!out) return;
- sum.textContent = loopTrail.length ? (loopTrail.length+" event(s) — newest first") : "no events yet";
- out.textContent = loopTrail.length
- ? loopTrail.map(e=>new Date(e.t).toLocaleString()+" "+e.m).join("\n") : "—";
- }
- $("#trailClear").onclick=()=>{ loopTrail=[]; try{ localStorage.removeItem(TRAIL_KEY); }catch(e){} renderTrail(); log("loop-state trail cleared"); };
- renderTrail();
- // Per-emulated-type config (must match firmware ET_* order: Xbox=0, Switch=1, DS4=2, DS5=3). Each type lists
- // only the remap targets that exist on that controller. Field id sent to firmware = 40 + et*8 + k
- // (k: 0..3 back paddles, 4 QAM, 5 A/B-swap, 6 trackpad-haptics, 7 LED brightness). Blob v10 reads them back at p[73 + et*8 + ...].
- const DPAD = {12:"D-pad Up",13:"D-pad Down",14:"D-pad Left",15:"D-pad Right"};
- const TYPE_DEFS = [
- {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}},
- {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"}},
- {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"}},
- {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"}},
- ];
- // Which emulated type a USB mode belongs to (mirror of firmware etypeForMode); -1 = puck mode (no type).
- 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;} }
- // ===================== Lizard (desktop) binding map =====================
- // Mirrors firmware lizard_map.h. A binding is {outType, od:[7], trig, hold}. Output types and the
- // per-type payload (od[]) layout must match the constants in lizard_map.h exactly.
- const LZ_MAX = 32;
- const LZO = {NONE:0, KBD:1, MBTN:2, AXIS:3, SCROLL:4, CONSUMER:5};
- const LZ_OUT_LABELS = {0:"(disabled)", 1:"Keyboard key", 2:"Mouse button", 3:"Mouse move", 4:"Scroll wheel", 5:"Media key"};
- // Controller input bits (triton.h TB_* + virtual left-stick deflection bits). Single-bit triggers only.
- const LZ_BTNS = [
- [0x1,"A"],[0x2,"B"],[0x4,"X"],[0x8,"Y"],
- [0x10,"QAM (• • •)"],[0x40,"View"],[0x4000,"Menu"],[0x10000,"Steam"],
- [0x20,"R3 (stick click)"],[0x8000,"L3 (stick click)"],
- [0x80,"R4 (back upper-right)"],[0x100,"R5 (back lower-right)"],
- [0x20000,"L4 (back upper-left)"],[0x40000,"L5 (back lower-left)"],
- [0x200,"RB (bumper)"],[0x80000,"LB (bumper)"],
- [0x800000,"R2 (trigger pull)"],[0x8000000,"L2 (trigger pull)"],
- [0x2000,"D-pad Up"],[0x400,"D-pad Down"],[0x1000,"D-pad Left"],[0x800,"D-pad Right"],
- [0x400000,"Right pad click"],[0x4000000,"Left pad click"],
- [0x200000,"Right pad touch"],[0x2000000,"Left pad touch"],
- [0x10000000,"L-stick → right"],[0x20000000,"L-stick → left"],[0x40000000,"L-stick → down"],[0x80000000,"L-stick → up"],
- ];
- // Keyboard modifier bits (od[0] for KBD output).
- const LZ_MODS = [[0x01,"Ctrl"],[0x02,"Shift"],[0x04,"Alt"],[0x08,"Win/⌘"]];
- // Curated HID keycodes (od[1] for KBD output). Value 0 = no key (modifier-only).
- const LZ_KEYS = [[0,"— none —"]];
- "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("").forEach((c,i)=>LZ_KEYS.push([0x04+i,c]));
- "1234567890".split("").forEach((c,i)=>LZ_KEYS.push([0x1e+i,c]));
- [[0x28,"Enter"],[0x29,"Esc"],[0x2a,"Backspace"],[0x2b,"Tab"],[0x2c,"Space"],
- [0x4f,"Arrow Right"],[0x50,"Arrow Left"],[0x51,"Arrow Down"],[0x52,"Arrow Up"],
- [0x4a,"Home"],[0x4d,"End"],[0x4b,"Page Up"],[0x4e,"Page Down"],[0x49,"Insert"],[0x4c,"Delete"],
- [0x2d,"- _"],[0x2e,"= +"],[0x46,"Print Screen"]].forEach(k=>LZ_KEYS.push(k));
- for(let i=0;i<12;i++) LZ_KEYS.push([0x3a+i,"F"+(i+1)]);
- const LZ_MBTNS = [[1,"Left click"],[2,"Right click"],[4,"Middle click"]];
- const LZ_AXIS_SRC = [[0,"Right trackpad"],[1,"Left stick"],[2,"Gyro"]];
- const LZ_GYRO_ACT = [[0,"Always"],[1,"While right pad touched"],[2,"While left stick deflected"],[3,"While hold-button held"]];
- const LZ_CONSUMER = [[1,"Volume +"],[2,"Volume −"]];
- function lzBtnLabel(mask){ const m=LZ_BTNS.find(b=>b[0]===(mask>>>0)); return m?m[1]:(mask?("0x"+(mask>>>0).toString(16)):""); }
- let lizardBindings = []; // [{outType, od:[7], trig, hold}]
- let lizardBusy = false;
- const BACK_LABELS = ["L4 (back upper-left)","R4 (back upper-right)","L5 (back lower-left)","R5 (back lower-right)"];
- const typeEls = []; // one entry per TYPE_DEFS: {sec, tab, activeDot, back[], qam, abSwap, pad, led, ledV}
- function mkSelect(def, includeNone){
- const sel=document.createElement("select");
- if(includeNone){ const o=document.createElement("option"); o.value=0; o.textContent="— none —"; sel.appendChild(o); }
- else { const o=document.createElement("option"); o.value=0; o.textContent="Default (per-mode)"; sel.appendChild(o); }
- for(const c of Object.keys(def.labels).map(Number).sort((a,b)=>a-b)){
- const o=document.createElement("option"); o.value=c; o.textContent=def.labels[c]; sel.appendChild(o); }
- return sel;
- }
- let curTab=0, tabInited=false;
- function setTab(et){
- curTab=et;
- typeEls.forEach((rec,i)=>{
- rec.sec.style.display = (i===et) ? "" : "none";
- rec.tab.classList.toggle("active", i===et);
- });
- }
- (function buildTypeCfgs(){
- const host=document.getElementById("typeCfgs");
- const bar=document.createElement("div"); bar.style.cssText="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px";
- host.appendChild(bar);
- TYPE_DEFS.forEach((def,et)=>{
- // tab button
- const tab=document.createElement("button"); tab.className="slot-tab"; tab.style.cursor="pointer";
- tab.innerHTML='<span>'+def.name+'</span><span class="active-dot" style="display:none;font-size:11px;color:var(--acc)"> ●</span>';
- tab.onclick=()=>setTab(et); bar.appendChild(tab);
- // panel
- const sec=document.createElement("div"); sec.style.cssText="background:#0e1017;border:1px solid #232a3a;border-radius:8px;padding:12px 14px;display:none";
- const rec={sec,tab,activeDot:tab.querySelector(".active-dot"),back:[],qam:null,abSwap:null,pad:null,led:null,ledV:null};
- // A/B swap + trackpad haptics toggles
- const tog=document.createElement("div"); tog.className="row";
- tog.innerHTML='<label>A/B + X/Y swap</label>';
- const ab=document.createElement("button"); ab.textContent="off"; tog.appendChild(ab);
- const padLbl=document.createElement("label"); padLbl.textContent="Trackpad haptics"; padLbl.style.marginLeft="14px"; tog.appendChild(padLbl);
- const pad=document.createElement("button"); pad.textContent="on"; tog.appendChild(pad);
- sec.appendChild(tog);
- rec.abSwap=ab; rec.pad=pad;
- ab.onclick=()=>{ const on=ab.classList.contains("active"); setField(40+et*8+5, on?0:1); };
- pad.onclick=()=>{ const on=pad.classList.contains("active"); setField(40+et*8+6, on?0:1); };
- // back paddles
- for(let i=0;i<4;i++){
- const row=document.createElement("div"); row.className="row";
- const lab=document.createElement("label"); lab.textContent=BACK_LABELS[i]; row.appendChild(lab);
- const sel=mkSelect(def,true); row.appendChild(sel); sec.appendChild(row);
- sel.addEventListener("change",()=>setField(40+et*8+i, +sel.value));
- rec.back.push(sel);
- }
- // QAM
- { const row=document.createElement("div"); row.className="row";
- const lab=document.createElement("label"); lab.textContent="QAM (3 dots)"; row.appendChild(lab);
- const sel=mkSelect(def,false); row.appendChild(sel); sec.appendChild(row);
- sel.addEventListener("change",()=>setField(40+et*8+4, +sel.value));
- rec.qam=sel; }
- // LED brightness
- { const row=document.createElement("div"); row.className="row";
- const lab=document.createElement("label"); lab.textContent="LED brightness"; row.appendChild(lab);
- const sl=document.createElement("input"); sl.type="range"; sl.min=0; sl.max=100; sl.step=5; row.appendChild(sl);
- const vspan=document.createElement("span"); vspan.className="val"; row.appendChild(vspan);
- function fmtLed(v){ return (+v===0)?"Auto":v+"%"; }
- sl.addEventListener("input",()=>{ vspan.textContent=fmtLed(sl.value); });
- sl.addEventListener("change",()=>setField(40+et*8+7, +sl.value));
- sec.appendChild(row);
- rec.led=sl; rec.ledV=vspan; }
- host.appendChild(sec); typeEls.push(rec);
- });
- setTab(0);
- })();
- for(const sel of document.querySelectorAll("select.chord")){
- MODE_NAMES.forEach((n,i)=>{ if(i===7||i===8) return;
- const o=document.createElement("option"); o.value=i; o.textContent=n; sel.appendChild(o); });
- }
- { const sel=document.getElementById("swRate");
- for(const [lbl,v] of [["Full rate (~250 Hz, default)",2],["120 Hz",1],["66 Hz (compatibility)",0]]){
- const o=document.createElement("option"); o.value=v; o.textContent=lbl; sel.appendChild(o); }
- }
- { const sel=document.getElementById("swGyro");
- for(const [lbl,v] of [["0.5×",5],["1.0×",10],["1.5×",15],["2.0×",20],["2.5×",25],["3.0×",30]]){
- const o=document.createElement("option"); o.value=v; o.textContent=lbl; sel.appendChild(o); }
- }
- // ---- per-slot tab state ----
- let g_activeSlot = 0;
- let g_slotData = []; // [{up, battery, rssi}] indexed by slot 0..3, only set for bonded slots
- function updateSlotDisplay(slotIdx) {
- const d = g_slotData[slotIdx];
- if (!d) { $("#stLink").innerHTML="—"; $("#stBatt").textContent="—"; $("#stRssi").textContent="—";
- $("#stSlotPolls").textContent="—"; $("#stSlotF1").textContent="—"; $("#stSlotFail").textContent="—"; return; }
- $("#stLink").innerHTML = d.up ? '<span class="pill up">up</span>' : '<span class="pill dn">down</span>';
- $("#stBatt").textContent = (d.up && d.battery) ? d.battery+"%" : "—";
- $("#stRssi").textContent = (d.up && d.rssi) ? ("-"+d.rssi+" dBm") : "—";
- // blob v13: this controller's OWN rates (the global grid's numbers are the sum over all controllers)
- if (d.stats) {
- $("#stSlotPolls").textContent = d.stats.polls+" /s";
- $("#stSlotF1").textContent = d.up ? (d.stats.f1+" /s ("+d.stats.newps+" new)") : "—";
- $("#stSlotFail").textContent = d.stats.crc+" · "+d.stats.norx+" · "+d.stats.relay;
- } else {
- $("#stSlotPolls").textContent="—"; $("#stSlotF1").textContent="—"; $("#stSlotFail").textContent="—";
- }
- }
- function renderSlotTabs(bondedCount) {
- const bar = $("#slotTabs");
- if (bondedCount <= 1) { bar.style.display="none"; updateSlotDisplay(g_activeSlot); return; }
- bar.style.display="flex";
- bar.innerHTML="";
- for (let s=0; s<4; s++) {
- const d = g_slotData[s];
- if (!d) continue;
- const btn = document.createElement("button");
- btn.className = "slot-tab" + (s===g_activeSlot ? " active" : "");
- const dot = document.createElement("span");
- dot.className = "dot" + (d.up ? " up" : "");
- btn.appendChild(dot);
- btn.appendChild(document.createTextNode("Controller "+(s+1)));
- btn.onclick = () => { g_activeSlot=s; renderSlotTabs(bondedCount); };
- bar.appendChild(btn);
- }
- updateSlotDisplay(g_activeSlot);
- }
- const USB_FILTERS=[{vendorId:0x28DE},{vendorId:0x045E},{vendorId:0x057E},{vendorId:0x0F0D},{vendorId:0x054C}];
- // Acquire+set up an already-chosen USBDevice (shared by the manual picker and the no-picker auto-reconnect).
- async function openDevice(d){
- try{
- dev=d;
- await dev.open();
- if(dev.configuration===null) await dev.selectConfiguration(1);
- let found=null;
- for(const itf of dev.configuration.interfaces){
- const a=itf.alternate;
- if(a.interfaceClass!==0xFF) continue;
- if(a.interfaceSubClass===0x5D) continue;
- let bin=0,bout=0;
- for(const e of a.endpoints){ if(e.type==="bulk"){ if(e.direction==="in")bin=e.endpointNumber; else bout=e.endpointNumber; } }
- if(bin&&bout){ found={ifNum:itf.interfaceNumber, epIn:bin, epOut:bout}; break; }
- }
- 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; }
- ifNum=found.ifNum; epIn=found.epIn; epOut=found.epOut;
- await dev.claimInterface(ifNum);
- await dev.controlTransferOut({requestType:"class", recipient:"interface", request:0x22, value:0x01, index:ifNum});
- $("#connState").textContent="connected"; $("#connState").className="pill up";
- $("#connState").title = dev.serialNumber ? ("puck serial "+dev.serialNumber) : "";
- $("#connectBtn").textContent="Reconnect";
- $("#panel").classList.remove("hide");
- log(`connected — puck ${dev.serialNumber||"?"} (VID ${dev.vendorId.toString(16)} iface ${ifNum})`);
- // resume a stability test across the reset: re-arm the firmware buzz and start timing the next run
- if(stabArmed){ await send([0x0F,1]); stabStart=Date.now(); log("stability test resumed — timing next run"); }
- updateStabUI();
- await loadLizard(); // populate the lizard-map editor from the device (global disconnect listener is set once, below)
- startPolling();
- loadReleases(false); // background, cached: feeds the update-available notice + pre-warms the update tab
- return true;
- }catch(e){ log("connect failed: "+e.message); dev=null; return false; }
- }
- // Manual connect: shows the Chrome device picker (needed once, to authorize the device).
- async function connect(){
- try{ const d = await navigator.usb.requestDevice({filters:USB_FILTERS}); await openDevice(d); }
- catch(e){ log("connect failed: "+e.message); }
- }
- // No-picker reconnect: reopen a device already authorized this browser. Returns true if it connected.
- async function autoConnect(){
- if(dev) return true;
- try{
- const ds = await navigator.usb.getDevices();
- const d = ds.find(x=>USB_FILTERS.some(f=>f.vendorId===x.vendorId));
- if(d) return await openDevice(d);
- }catch(e){}
- return false;
- }
- function onGone(){
- // if a stability test is running, this disconnect is the reset that ended the run -- record its uptime
- // (kept in-page so it survives the reset + reconnect, shown next to Last reset).
- let up=null;
- if(stabArmed && stabStart){ up=(Date.now()-stabStart)/1000; stabLastRun={secs:up}; log("⏱ stability: stayed up "+fmtDur(up)+", then reset"); stabStart=0; }
- // if the PREVIOUS reset was never classified (no full blob landed between two resets), flush it as
- // "unclassified" instead of silently overwriting it -- every reset must leave a row.
- if(pendingHang){
- hangLog.unshift({ time:new Date(pendingHang.t).toLocaleTimeString(), uptime:pendingHang.uptimeSecs,
- reason:"unclassified", stage:"", pc:"", lr:"", usbd:null });
- renderHangLog();
- trailAdd("note: the previous reset was never classified — no full status blob arrived before this disconnect");
- }
- // queue a hang-log entry; the reason/PC/stack are filled from the first blob after reconnect
- pendingHang = { t: Date.now(), uptimeSecs: up };
- trailAdd("device disconnected"+(up!=null?(" after "+fmtDur(up)+" up"):"")+" — reset or replug; reason logged on reconnect");
- window._flightAuto=false; // re-arm the one-shot flight-trail auto-load for the next reconnect
- window._wedgeEp=false; window._wedgePeak=0; // re-arm the live wedge logger for the next wedge episode
- window._lastBlobTs=0; window._hbLostEp=false; // re-arm the heartbeat watchdog (don't fire on a stale pre-reset timestamp)
- polling=false; dev=null; tabInited=false; // re-default the config tab to the mode on next connect
- checkUpdateNotice(); // no device = no notice
- $("#connState").textContent="reconnecting…"; $("#connState").className="pill dn";
- $("#panel").classList.add("hide");
- log("device disconnected (mode-switch / watchdog reboot) — auto-reconnecting when it returns");
- // The puck re-enumerates after a reboot; retry without the picker. The "connect" event also triggers this,
- // but poll a few times in case the event is missed.
- let tries=0;
- const iv=setInterval(async()=>{ if(dev||tries++>40){ clearInterval(iv); return; } if(await autoConnect()) clearInterval(iv); }, 500);
- }
- // Auto-reconnect when an authorized puck (re)appears — no button, no picker.
- navigator.usb && navigator.usb.addEventListener("connect", e=>{ if(!dev) autoConnect(); });
- navigator.usb && navigator.usb.addEventListener("disconnect", e=>{ if(e.device===dev) onGone(); });
- async function send(bytes){
- if(!dev) return;
- try{ await dev.transferOut(epOut, new Uint8Array(bytes)); }
- catch(e){ log("write err: "+e.message); }
- }
- // Read one framed reply and return its payload (with the 2-byte [marker][len] header stripped). The blob and
- // the bond-export dump share the same framing, distinguished by the marker byte (0xA5 status, 0xA7 bonds).
- async function readFrame(marker, minLen){
- try{
- // largest frame (status blob) spans multiple USB-FS packets; read generously to capture it in one call.
- const r = await dev.transferIn(epIn, 192);
- if(r.status!=="ok"||r.data.byteLength<2) return null;
- const d=new Uint8Array(r.data.buffer);
- // Live wedge reporter (0xA9): emitted from the firmware's SOF callback (usbd task) while loop() is stalled --
- // the ONLY signal that survives a loop wedge on boards that wipe retained RAM across the reset. Scan for it
- // in every read so it surfaces regardless of which frame we were after. Payload: [stage][stallMs u16].
- for(let w=0; w+4<d.length; w++){
- if(d[w]===0xA9 && d[w+1]===3){ onWedge(d[w+2], d[w+3]|(d[w+4]<<8)); break; }
- }
- let i=0; while(i<d.length && d[i]!==marker) i++;
- if(i+2>d.length) return null;
- const len=d[i+1];
- // truncated read (transfer ended mid-frame): applying a half blob silently skips every late field --
- // reset cause, pendingHang classification, stack stats -- so drop the whole frame and retry next poll.
- if(i+2+len>d.length) return null;
- const p=d.slice(i+2, i+2+len);
- return p.length>=(minLen||0) ? p : null;
- }catch(e){ if(dev) log("read err: "+e.message); return null; }
- }
- async function readBlob(){ return readFrame(0xA5, 12); }
- function applyBlob(p){
- lastP=p; // remember for Export (carries every config field the panel shows)
- window._lastBlobTs=Date.now(); // feeds the host-side heartbeat watchdog (hard-wedge detection)
- const mode=p[1], mDiv=p[2], mFric=p[3]; // p[4..11] mirror the active type (per-type block below is authoritative)
- const slot=p[10], up=p[11], f1=(p[12]|(p[13]<<8));
- const newps=(p.length>16?(p[15]|(p[16]<<8)):0), persistMode=(p.length>22?p[22]:0);
- const chord=(p.length>25)?[p[23],p[24],p[25]]:[3,1,4];
- const polls=(p.length>27?(p[26]|(p[27]<<8)):0);
- const loopUs=(p.length>29?(p[28]|(p[29]<<8)):0);
- const worstIdx=(p.length>30?p[30]:0), worstUs=(p.length>32?(p[31]|(p[32]<<8)):0);
- const WORST=["webusb","ctrl.task","serial","rfdiag","rflink","haptic","led"];
- const pollUs=(p.length>34?(p[33]|(p[34]<<8)):0);
- const pollIntended=(p.length>14?p[14]*100:0);
- const logEnabled=(p.length>35?p[35]:0);
- for(const el of document.querySelectorAll(".logonly")) el.style.display = logEnabled ? "" : "none";
- for(const b of document.querySelectorAll(".modebtn")) b.classList.toggle("active", +b.dataset.mode===mode);
- $("#mouseCard").style.opacity = (mode===1||mode===3)?1:0.5;
- // The custom map applies ONLY to pure Lizard (always) mode. Steam-mode seamless lizard keeps
- // the built-in default behavior, so the editor is shown only when the device is in Lizard mode.
- $("#lizardCard").style.display = (mode===3)?"":"none";
- const battery=(p.length>36?p[36]:0);
- const rumbleScale=(p.length>51?p[51]:200);
- const swProRate=(p.length>52?p[52]:2);
- const swGyro10=(p.length>53?p[53]:10);
- const rssi=(p.length>37?p[37]:0);
- // per-slot link status (protocol v8, p.length >= 73)
- const hasSlotsData = p.length >= 73;
- if (hasSlotsData) {
- const bondedN = p[60];
- g_slotData = [];
- for (let s=0; s<4; s++) {
- const base = 61 + s*3;
- if (p.length >= base+3) {
- // only include bonded slots (a slot with up=0 could be bonded but offline)
- // check if the slot appears at all in the bonded map by checking if bondedN covers it;
- // the firmware only sets non-zero battery/rssi for actually-bonded slots
- const slotUp = !!p[base];
- const slotBatt = p[base+1];
- const slotRssi = p[base+2];
- // slot is "bonded" if firmware included it in bondedN coverage
- // we populate slotData for all 4 and renderSlotTabs skips gaps
- g_slotData[s] = {up: slotUp, battery: slotBatt, rssi: slotRssi};
- }
- }
- // keep only bonded slots by cross-referencing bondedN
- // firmware sends bondedSlotCount but not a slot bitmask; use battery>0 || rssi>0 || up as presence hint
- // simpler: just count how many of the 4 slots have any non-zero data OR are marked up
- // Actually the firmware always writes all 4 slots; we filter by the bond system's "used" flag via presence.
- // For clean display: we'll show tabs only for slots that have ever had a reply OR are bonded (battery/rssi set).
- // The firmware sets g_battery[s]=0 and g_linkRssi[s]=0 for unused slots, so 0/0/0 = not bonded.
- // But a bonded offline slot also has 0/0/0 initially. We rely on bondedN for count and show the first bondedN slots
- // that have g_slot[s].used — but we can't easily know which slots are used from the blob.
- // Best heuristic: show all 4 slots if bondedN==4, otherwise use slot indices where battery or rssi != 0 OR up==1,
- // OR fall back to showing slots 0..bondedN-1 if none have data yet (device just booted).
- let bondedSlots = [];
- for (let s=0; s<4; s++) {
- if (g_slotData[s] && (g_slotData[s].up || g_slotData[s].battery || g_slotData[s].rssi)) bondedSlots.push(s);
- }
- // fallback: if no data yet (all offline, just booted), show slots 0..bondedN-1
- if (bondedSlots.length === 0 && bondedN > 0) {
- for (let s=0; s<bondedN; s++) bondedSlots.push(s);
- }
- // remove slotData for non-bonded slots so tabs don't appear for them
- const bondedSet = new Set(bondedSlots);
- for (let s=0; s<4; s++) { if (!bondedSet.has(s)) g_slotData[s] = null; }
- // clamp active slot to a valid bonded slot
- if (!g_slotData[g_activeSlot]) g_activeSlot = bondedSlots[0] || 0;
- // v13: per-slot link stats (fw p[141..176] -> js index 139..174): each controller's own
- // polls/delivered/new/crc/noRx/relay, so the tabs no longer show every controller merged into one number
- if (p.length >= 175) {
- for (let s=0; s<4; s++) {
- if (!g_slotData[s]) continue;
- const b = 139 + s*9;
- g_slotData[s].stats = { polls: p[b]|(p[b+1]<<8), f1: p[b+2]|(p[b+3]<<8),
- newps: p[b+4]|(p[b+5]<<8), crc: p[b+6], norx: p[b+7], relay: p[b+8] };
- }
- }
- renderSlotTabs(bondedN);
- } else {
- // old firmware: single-slot display
- $("#slotTabs").style.display="none";
- $("#stLink").innerHTML = up? '<span class="pill up">up</span>' : '<span class="pill dn">down</span>';
- $("#stBatt").textContent = (up && battery)? battery+"%" : "—";
- $("#stRssi").textContent = (up && rssi)? ("-"+rssi+" dBm") : "—";
- }
- // global stats
- $("#stRate").textContent = up? f1+" /s" : "—";
- $("#stNew").textContent = up? newps+" /s" : "—";
- // Poll TX + RF-fail counts: shown ALWAYS (even when the link reads down) so a wedge is diagnosable --
- // polls>0 here while Delivered is "—" means the puck is still polling but getting no usable replies.
- $("#stPolls").textContent = polls+" /s";
- { const crc=(p.length>113?p[113]:0), norx=(p.length>114?p[114]:0), heal=(p.length>115?p[115]:0);
- const rf=(p.length>126?(p[125]|(p[126]<<8)):0);
- $("#stRfFail").innerHTML = crc+" · "+norx+" · "+heal + (rf?' · <span class="pill dn">ring '+rf+'</span>':"");
- // ring fault = we caught+recovered a relay-ring corruption that would otherwise be an invisible IRQ-off
- // watchdog hang. Log each new one (persists across the device reset + panel reconnect).
- if(rf>(window._ringFault||0)) log("⚠ RING FAULT #"+rf+" recovered — caught a relay-ring desync/corruption (would have hung loop with IRQs off)");
- window._ringFault=rf; }
- // v12: relay-frame rate + clock fingerprint (browser index = firmware index − 2)
- { const relay=(p.length>117?(p[116]|(p[117]<<8)):null);
- $("#stRelay").textContent = relay==null ? "—" : (relay+" /s"); }
- if(p.length>121){
- const LF=["stopped","RC","xtal","synth"], HF=["RC","?","xtal","?"];
- const lf=p[118], hf=p[119], upm=(p[120]|(p[121]<<8));
- const lfBad=(lf!==2), hfBad=(hf!==2), upmBad=(upm && (upm<985||upm>1015));
- const tag=(t,bad)=> bad ? '<span class="pill dn">'+t+'</span>' : t;
- $("#stClock").innerHTML = tag(LF[lf]||lf, lfBad)+" / "+tag(HF[hf]||hf, hfBad);
- $("#stUsPerMs").innerHTML = upm ? tag(""+upm, upmBad) : "—";
- }
- // v12: LIVE loop heartbeat — curStage (firmware p[125]) + ms-since-beat (p[126], 40ms units). Updates even
- // while loop() is wedged (blob is pushed from the SOF interrupt), so a hang shows "STALLED @ <stage>" live.
- if(p.length>124){
- const STAGE=["webusb","ctrl.task","serial","rfdiag","rflink","haptic","led","usbmount","usbtx"];
- const cur=p[123], stallMs=p[124]*40, name=(STAGE[cur]||("stage "+cur));
- if(stallMs>=200){
- $("#stLoopState").innerHTML='<span class="pill dn">STALLED @ '+name+' '+stallMs+'ms</span>';
- if(!window._stallEpisode) trailAdd("STALLED @ "+name+" ("+stallMs+"ms, soft wedge — usbd still alive)");
- // Latch+log the stall so it survives the imminent watchdog reset + panel reconnect (host-side log
- // persists). Log once per episode + keep escalating if it climbs toward the ~8s watchdog.
- if(!window._stallEpisode || stallMs>window._stallPeak+800){
- window._stallEpisode=true; window._stallPeak=stallMs;
- log("⚠ LOOP STALL @ "+name+" ("+stallMs+"ms) — watchdog reset imminent");
- }
- } else {
- if(window._stallEpisode) trailAdd("running again — stall recovered without a reset (peaked "+window._stallPeak+"ms)");
- window._stallEpisode=false; window._stallPeak=0;
- $("#stLoopState").innerHTML='<span class="pill up">running</span>';
- }
- }
- // v12: least-ever free stack on the usbd task (words). Low = overflow risk (multi-controller haptic-relay
- // hang hypothesis). Firmware p[137] -> browser p[135]. Red under 24 words (~96B), amber under 48.
- if(p.length>136){ const usbdW=(p[135]|(p[136]<<8));
- $("#stUsbdStack").innerHTML = usbdW<24 ? ('<span class="pill dn">'+usbdW+'</span>')
- : (usbdW<48 ? ('<span class="pill" style="background:#3a3216;color:#e5c76b">'+usbdW+'</span>') : (""+usbdW));
- }
- $("#stLoop").textContent = loopUs? loopUs+" µs" : "—";
- $("#stWorst").textContent = loopUs? (WORST[worstIdx]||worstIdx)+" "+worstUs+"µs" : "—";
- $("#stPollPer").textContent = up? (pollUs+" / "+pollIntended+" µs") : "—";
- // last-boot reset cause (protocol v11): a single classified code (firmware classifies it from RESETREAS +
- // a GPREGRET2 marker) plus the raw RESETREAS. Codes match RR_* in fault_diag.h. Earlier firmware (<v11)
- // didn't send this -> guard on length and leave the tile blank.
- const RR_NAMES = ["unknown","power-on","pin/replug","watchdog (hang)","CPU lockup",
- "HARDFAULT","reboot","soft reset","wake-from-off"];
- const RR_FAULT = new Set([3,4,5]); // watchdog / lockup / hardfault = a real crash worth reporting (#72)
- if (p.length > 109) {
- const code = p[105];
- const raw = ((p[106]|(p[107]<<8)|(p[108]<<16)|(p[109]<<24))>>>0);
- const name = RR_NAMES[code] || ("code "+code);
- // v12: which loop stage was stuck when the last watchdog/lockup hang fired (0xFF = last boot wasn't a hang)
- const STAGE_NAMES = ["webusb","ctrl.task","serial","rfdiag","rflink","haptic","led","usbmount","usbtx"];
- const hangStage = (p.length>122 ? p[122] : 0xFF);
- let label = name;
- if (hangStage!==0xFF) label += " @ "+(STAGE_NAMES[hangStage]||("stage "+hangStage));
- $("#stReset").innerHTML = RR_FAULT.has(code) ? ('<span class="pill dn">'+label+'</span>') : label;
- // v12: PC/LR captured by the WDT pre-reset ISR (browser idx = firmware−2: hangPC@127, hangLR@131)
- const hpc=(p.length>130?((p[127]|(p[128]<<8)|(p[129]<<16)|(p[130]<<24))>>>0):0);
- const hlr=(p.length>134?((p[131]|(p[132]<<8)|(p[133]<<16)|(p[134]<<24))>>>0):0);
- const hx=v=>"0x"+v.toString(16).padStart(8,"0");
- const el = $("#resetHist");
- if (el) el.textContent = "RESETREAS=0x"+raw.toString(16).padStart(8,"0")
- + (hangStage!==0xFF ? " hung in: "+(STAGE_NAMES[hangStage]||hangStage) : "")
- + (hpc ? " hang PC="+hx(hpc)+" LR="+hx(hlr) : "")
- + (stabLastRun ? " · stability: stayed up "+fmtDur(stabLastRun.secs)+" before this reset" : "");
- 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"); }
- // append the queued reset to the running hang log, now that we have this boot's reason/PC/stack
- if (pendingHang){
- const usbdW2=(p.length>136?(p[135]|(p[136]<<8)):0);
- hangLog.unshift({ time:new Date(pendingHang.t).toLocaleTimeString(), uptime:pendingHang.uptimeSecs,
- reason:name, stage:(hangStage!==0xFF)?(STAGE_NAMES[hangStage]||("stage"+hangStage)):"",
- pc:hpc?hx(hpc):"", lr:hlr?hx(hlr):"", usbd:usbdW2 });
- trailAdd("reconnected — last reset: "+name
- +(hangStage!==0xFF?(" @ "+(STAGE_NAMES[hangStage]||("stage "+hangStage))):"")
- +(hpc?(" PC="+hx(hpc)):"")
- +(pendingHang.uptimeSecs!=null?(" (up "+fmtDur(pendingHang.uptimeSecs)+" before it)"):""));
- try{ localStorage.setItem("opk_trail_rrfp", code+"/"+raw.toString(16)+"/"+hangStage+"/"+hpc.toString(16)); }catch(e){}
- pendingHang=null; renderHangLog();
- }
- // Page wasn't open when it reset (refresh / closed browser): a fault-class boot still lands in the trail,
- // once per distinct reset -- fingerprinted + persisted so every later page load doesn't re-log the same one.
- else if (RR_FAULT.has(code)){
- const fp=code+"/"+raw.toString(16)+"/"+hangStage+"/"+hpc.toString(16);
- if(localStorage.getItem("opk_trail_rrfp")!==fp){
- try{ localStorage.setItem("opk_trail_rrfp", fp); }catch(e){}
- trailAdd("connected — last boot was a "+label+(hpc?(" PC="+hx(hpc)):"")+" (page wasn't open to catch it live)");
- }
- }
- // Auto-load the flight-recorder trail once per connection when the last boot was a real crash (watchdog/
- // lockup/hardfault) -- the trail is the post-mortem of what wedged the board. Deferred to the poll loop so
- // it doesn't nest transferIn inside this refresh.
- if (RR_FAULT.has(code) && !window._flightAuto){ window._flightAuto=true; window._autoFlight=true; }
- }
- const gitDirty=(p.length>38?p[38]:0); let buildId="";
- for(let i=39;i<51 && i<p.length && p[i];i++) buildId+=String.fromCharCode(p[i]);
- $("#stBuild").innerHTML = buildId
- ? (buildId + (gitDirty ? ' <span class="pill dn">dirty</span>' : ' <span class="pill up">clean</span>'))
- : "—";
- $("#updInstalled").textContent = buildId || "—"; // mirrored on the Firmware-update tab
- updateFwGate(); // (re)evaluate the firmware-tab gate on every blob — version is at p[0]
- checkUpdateNotice(); // needs BOTH the installed version (this blob) and relCache (fetched on connect)
- setSlider("mDiv",mDiv); setSlider("mFric",mFric); setSlider("rumble",rumbleScale);
- $("#persistMode").textContent = persistMode? "on":"off"; $("#persistMode").classList.toggle("active",!!persistMode);
- // land-all-0x87 experiment toggle (blob v14, firmware p[177] -> JS p[175])
- { const la=(p.length>175?p[175]:0); $("#landAll87").textContent = la?"on":"off"; $("#landAll87").classList.toggle("active",!!la); }
- // per-type button config (blob v10): 4 types x 8 bytes. readBlob() strips the 2-byte [0xA5][len] header, so
- // the firmware's buffer index 75 lands at JS index 73 here.
- if(p.length>=73+TYPE_DEFS.length*8){
- const activeEt=etypeForMode(mode);
- typeEls.forEach((rec,et)=>{
- const q=73+et*8;
- 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];
- rec.back.forEach((sel,i)=>{ if(document.activeElement!==sel) sel.value=back[i]; });
- if(document.activeElement!==rec.qam) rec.qam.value=qam;
- rec.abSwap.textContent=ab?"on":"off"; rec.abSwap.classList.toggle("active",!!ab);
- rec.pad.textContent=pad?"on":"off"; rec.pad.classList.toggle("active",!!pad);
- if(document.activeElement!==rec.led){ rec.led.value=led; rec.ledV.textContent=(led===0)?"Auto":led+"%"; }
- // dot marks the type matching the current puck mode
- rec.activeDot.style.display = (et===activeEt) ? "" : "none";
- });
- // open the tab for the current mode's type on first load (then leave the user's choice alone)
- if(!tabInited){ tabInited=true; setTab(activeEt>=0?activeEt:0); }
- }
- if(p.length>=60){ const s16=(o)=>{ let v=p[o]|(p[o+1]<<8); return v>32767?v-65536:v; };
- const ax=s16(54),ay=s16(56),az=s16(58);
- const amag=Math.round(Math.sqrt(ax*ax+ay*ay+az*az));
- $("#stImu").textContent = `a=(${ax}, ${ay}, ${az}) |a|=${amag}`;
- }
- { const sel=$("#swRate"); if(document.activeElement!==sel) sel.value=swProRate; }
- { const sel=$("#swGyro"); if(document.activeElement!==sel) sel.value=swGyro10; }
- for(const sel of document.querySelectorAll("select.chord")){ if(document.activeElement!==sel) sel.value=chord[+sel.dataset.i]; }
- }
- function fmtSlider(id,v){ return id==="rumble" ? v+"%" : id==="hapBlockS" ? v+" s" : ""+v; }
- function setSlider(id,v){ const el=$("#"+id); if(document.activeElement!==el){ el.value=v; } $("#"+id+"V").textContent=fmtSlider(id,el.value); }
- let inflight=false;
- async function refresh(){
- if(!dev||inflight||capturing||backupBusy||flightBusy||lizardBusy) return; inflight=true;
- // try/finally: an exception anywhere in here (applyBlob included) must never leave inflight latched true --
- // that would silently kill all future polls and read as a fake "heartbeat lost".
- try{ await send([0x01]); const p=await readBlob(); if(p) applyBlob(p); }
- catch(e){ log("refresh err: "+e.message); }
- finally{ inflight=false; }
- }
- // ---- Backup / clone ----
- // A backup file is { magic, version, bonds[4], config{} }. bonds come from the 0x09 export (0xA7 frame); the
- // config fields come from the status blob the panel already reads. Restoring replays every setting via the
- // normal 0x02 setters, then sends the 0x0D bond-import which writes the pairings, applies the mode and reboots.
- function hexEnc(arr){ return [...arr].map(x=>x.toString(16).padStart(2,"0")).join(""); }
- 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; }
- function buildBackup(p, bp){
- const mask=bp[1], bonds=[];
- for(let s=0;s<4;s++){ const rec=bp.slice(2+s*24, 2+s*24+24);
- bonds.push({slot:s, used:!!((mask>>s)&1), rec:hexEnc(rec)}); }
- const types=[];
- for(let et=0; et<TYPE_DEFS.length; et++){ const q=73+et*8;
- types.push({back:[p[q],p[q+1],p[q+2],p[q+3]], qam:p[q+4], abSwap:p[q+5], pad:p[q+6], led:p[q+7]}); }
- return { magic:"openpuck-backup", version:1, bonds,
- config:{ mode:p[1], mDiv:p[2], mFric:p[3], persistMode:p[22], chord:[p[23],p[24],p[25]],
- rumble:p[51], swProRate:p[52], swGyro10:p[53], types } };
- }
- function downloadBackup(obj){
- const ts=new Date().toISOString().slice(0,19).replace(/[:T]/g,"-");
- const a=document.createElement("a");
- a.href=URL.createObjectURL(new Blob([JSON.stringify(obj,null,2)],{type:"application/json"}));
- a.download="openpuck-backup-"+ts+".json"; a.click();
- }
- // let any in-flight poll read settle before we drive the shared endpoints ourselves
- async function waitIdle(){ for(let i=0;i<60 && inflight;i++) await new Promise(r=>setTimeout(r,5)); }
- async function exportBackup(){
- if(!dev){ log("not connected"); return; }
- backupBusy=true; await waitIdle();
- try{
- // grab a fresh config snapshot, then the bond dump
- await send([0x01]); const cfg=await readBlob(); if(cfg) lastP=cfg;
- if(!lastP){ log("export: no config snapshot yet — wait a second and retry"); return; }
- await send([0x09]);
- let bp=null;
- for(let t=0; t<8 && !bp; t++) bp=await readFrame(0xA7, 2+4*24);
- if(!bp){ log("export: no bond data (firmware too old for 0x09 export — reflash)"); return; }
- const backup=buildBackup(lastP, bp);
- downloadBackup(backup);
- const n=backup.bonds.filter(b=>b.used).length;
- log("exported backup — "+n+" bonded controller slot(s) + all settings");
- } finally { backupBusy=false; }
- }
- async function importBackup(file){
- if(!dev){ log("not connected"); return; }
- let obj; try{ obj=JSON.parse(await file.text()); }
- catch(e){ log("import: not valid JSON"); return; }
- if(obj.magic!=="openpuck-backup" || !obj.bonds || !obj.config){ log("import: unrecognized backup file"); return; }
- const bondedN=obj.bonds.filter(b=>b.used).length;
- 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;
- log("importing onto puck "+(dev.serialNumber||"?"));
- backupBusy=true; await waitIdle();
- try{
- // Capability probe: the connected puck MUST run firmware new enough to answer 0x09 with a 0xA7 frame,
- // otherwise it will silently ignore the 0x0D bond-import below. This is the #1 import failure: the panel
- // is connected to a puck still on old firmware. Catch it loudly instead of "nothing happened".
- await send([0x09]);
- let probe=null; for(let t=0;t<8 && !probe;t++) probe=await readFrame(0xA7, 2+4*24);
- if(!probe){
- log("import ABORTED — the connected puck does not support import (old firmware).");
- alert("This puck's firmware is too old to import a backup.\n\nFlash it with the latest OpenPuck build (the same one that produced the export), reconnect, then import again.");
- return;
- }
- const c=obj.config;
- // Every step is a write-then-read transaction (same shape as the proven 0x02 sliders): the firmware acks
- // each with a status blob, which we read back to keep the OUT pipe flowing and naturally pace the writes.
- const w=async(bytes)=>{ await send(bytes); await readBlob(); };
- const sf=async(f,v)=>{ await w([0x02, f, (v|0)&0xff]); };
- // replay every setting (each persists; none reboot). mode is applied by the 0x0E commit below.
- await sf(1,c.mDiv); await sf(2,c.mFric); await sf(22,c.rumble); await sf(16,c.persistMode?1:0);
- await sf(17,c.chord[0]); await sf(18,c.chord[1]); await sf(19,c.chord[2]);
- await sf(23,c.swProRate); await sf(24,c.swGyro10);
- for(let et=0; et<c.types.length && et<4; et++){ const t=c.types[et];
- for(let k=0;k<4;k++) await sf(40+et*8+k, t.back[k]);
- await sf(40+et*8+4, t.qam); await sf(40+et*8+5, t.abSwap?1:0);
- await sf(40+et*8+6, t.pad?1:0); await sf(40+et*8+7, t.led); }
- log("settings replayed ("+(9+c.types.length*8)+" fields)");
- // write each bond slot with its own small command [0x0D][slot][used][24-byte rec]
- let n=0;
- for(let s=0;s<4;s++){
- const b=obj.bonds.find(x=>x.slot===s) || {used:false, rec:""};
- const rec=hexDec(b.rec), r24=new Array(24).fill(0);
- for(let i=0;i<24 && i<rec.length;i++) r24[i]=rec[i];
- const used=(b.used && rec.length===24)?1:0; if(used) n++;
- await w([0x0D, s, used, ...r24]);
- }
- log("wrote "+n+" bond slot(s) — committing + rebooting");
- // commit: persist bonds, apply mode, reboot (no ack — the device reboots)
- await send([0x0E, (c.mode!==undefined?c.mode:0xFF)&0xff]);
- log("import sent — puck is cloning and rebooting. Reconnect after it returns.");
- } finally { backupBusy=false; }
- }
- async function setField(field,value){
- await send([0x02, field, value&0xff]); const p=await readBlob(); if(p) applyBlob(p);
- }
- function fmtCapEntry(e,maxMs){
- const hex=e.bytes.map(x=>x.toString(16).padStart(2,"0")).join(" ");
- const t = `-${String(maxMs-e.ms).padStart(6," ")}ms `;
- 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"} ════════`; }
- 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)
- 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)
- const who = (e.slot===0xFE) ? "TX→ctlr " : `if${e.slot} `;
- return `${t}${who} cmd=${e.rid.toString(16).padStart(2,"0")} n=${e.nb}: ${hex}`;
- }
- function downloadCap(){
- const t=window._lastCap||$("#capOut").textContent||"";
- const a=document.createElement("a");
- a.href=URL.createObjectURL(new Blob([t],{type:"text/plain"}));
- a.download="puck-capture.txt"; a.click();
- }
- let liveTimer=null, draining=false, capLines=[];
- function renderCap(){
- if(!capLines.length){ $("#capOut").textContent="(nothing captured yet)"; window._lastCap=""; return; }
- const maxMs=capLines[capLines.length-1].ms;
- const t=capLines.map(e=>fmtCapEntry(e,maxMs)).join("\n");
- $("#capOut").textContent=t; window._lastCap=t;
- }
- async function drainTick(){
- if(!dev||draining) return; draining=true;
- let acc=new Uint8Array(0);
- try{
- await send([0x06]);
- let done=false, guard=0;
- while(!done && guard++<400){
- const r=await dev.transferIn(epIn,64);
- if(r.status!=="ok") break;
- const d=new Uint8Array(r.data.buffer);
- const m=new Uint8Array(acc.length+d.length); m.set(acc); m.set(d,acc.length); acc=m;
- let i=0;
- while(i<acc.length){
- if(acc[i]!==0xA6){ i++; continue; }
- if(i+2>acc.length) break;
- const L=acc[i+1]; if(i+2+L>acc.length) break;
- const f=acc.slice(i+2,i+2+L); i+=2+L;
- if(f[0]===0){ done=true; break; }
- if(f[0]===1 && L>=8){
- const ms=((f[1])|(f[2]<<8)|(f[3]<<16)|(f[4]<<24))>>>0;
- capLines.push({ms, slot:f[5], rid:f[6], nb:f[7], bytes:[...f.slice(8,8+f[7])]});
- }
- }
- acc=acc.slice(i);
- }
- }catch(e){ log("drain err: "+e.message); }
- draining=false;
- if(liveTimer){ const el=$("#capOut"); const atBottom=el.scrollTop+el.clientHeight>=el.scrollHeight-4; renderCap(); if(atBottom) el.scrollTop=el.scrollHeight; }
- }
- // ---- Live wedge reporter (0xA9) ----
- // The firmware's SOF callback streams this while loop() is stuck. On boards that wipe retained RAM across the
- // watchdog reset (so the flight recorder / hang stage come back blank), this is the only way to see WHERE it
- // hung -- captured live, over the panel, in the ~8s before the watchdog fires. Log once per wedge episode.
- const WEDGE_STAGE=["webusb","ctrl.task","serial","rfdiag","rflink","haptic","led","usbmount","usbtx"];
- function onWedge(stage, stallMs){
- const name=WEDGE_STAGE[stage]||("stage "+stage);
- if(!window._wedgeEp) trailAdd("WEDGED @ "+name+" ("+stallMs+"ms — live 0xA9 report; watchdog reset imminent)");
- if(!window._wedgeEp || stallMs > (window._wedgePeak||0)+500){
- window._wedgeEp=true; window._wedgePeak=stallMs;
- log("🛑 LOOP WEDGED @ "+name+" (stuck "+stallMs+"ms) — this is where it hangs; watchdog reset imminent");
- const el=$("#flightWedge"); if(el) el.innerHTML=' <span class="pill dn">last wedge: '+name+' ('+stallMs+'ms)</span>';
- }
- }
- // ---- Host-side heartbeat watchdog (hard-wedge detection) ----
- // The STALLED pill and the 0xA9 reporter both rely on the puck still SENDING while loop() is wedged -- which
- // only holds for soft wedges where the usbd task + SOF IRQ stay alive. The LOCKUP/hardfault-class hangs (#72)
- // kill the whole MCU: USB goes silent, transferIn just pends, and the panel would freeze forever showing the
- // last state it rendered ("running"). Only the host can see that class of hang, so detect it here: connected
- // but no status blob for >2.5s (poll cadence is 600ms) => flip the pill and log the episode. The capture /
- // backup / flight paths pause the blob poll legitimately, so they're excluded.
- setInterval(()=>{
- if(!dev || !window._lastBlobTs || capturing || backupBusy || flightBusy) return;
- const age=Date.now()-window._lastBlobTs;
- if(age>2500){
- const secs=Math.round(age/1000);
- $("#stLoopState").innerHTML='<span class="pill dn">NO HEARTBEAT '+secs+'s — hard wedge</span>';
- if(!window._hbLostEp){ window._hbLostEp=true;
- 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");
- trailAdd("HEARTBEAT LOST — no status for "+secs+"s (hard wedge: USB silent, whole MCU likely stopped)");
- const el=$("#flightWedge"); if(el) el.innerHTML=' <span class="pill dn">last wedge: hard (no heartbeat)</span>';
- }
- } else if(window._hbLostEp){ window._hbLostEp=false;
- trailAdd("heartbeat back — status blobs resumed without a reset");
- }
- }, 1000);
- // ---- Flight recorder (0xA8 stream) ----
- // The firmware keeps a .noinit ring of the last high-level events (usbd SET/GET, relays, RF up/down, radio
- // self-heal, USB mount/suspend, bond flash writes, per-250ms heartbeats) that survives a watchdog reset. On
- // 0x10 it streams them: one header (T=2, vitals@wedge + counts), then entries (T=1), then an end (T=0). We
- // pump 0x10 (restart) then 0x10 (continue) until the end frame, accumulating across FIFO-sized batches.
- let flightBusy=false, flightHdr=null, flightEvents=[];
- const FR_EVT=["none","beat","SET","GET","relay","rf-up","rf-DN","HEAL!","mount","SUSPEND","resume","OFF","RINGF","save"];
- function frEvtName(e){ return FR_EVT[e]!==undefined?FR_EVT[e]:("evt"+e); }
- async function loadFlightTrail(){
- if(!dev||flightBusy) return; flightBusy=true;
- $("#flightSummary").textContent="loading…";
- flightHdr=null; flightEvents=[];
- const STAGE_NAMES=["webusb","ctrl.task","serial","rfdiag","rflink","haptic","led","usbmount","usbtx"];
- try{
- let restart=true, done=false, guard=0, acc=new Uint8Array(0);
- while(!done && guard++<600){
- await send([0x10, restart?1:0]); restart=false;
- const r=await dev.transferIn(epIn,192);
- if(r.status!=="ok") break;
- const d=new Uint8Array(r.data.buffer);
- const m=new Uint8Array(acc.length+d.length); m.set(acc); m.set(d,acc.length); acc=m;
- let i=0;
- while(i<acc.length){
- if(acc[i]!==0xA8){ i++; continue; }
- if(i+2>acc.length) break;
- const L=acc[i+1]; if(i+2+L>acc.length) break;
- const f=acc.slice(i+2,i+2+L); i+=2+L;
- const T=f[0];
- if(T===0){ done=true; break; }
- else if(T===2 && L>=27){
- flightHdr={ count:f[1]|(f[2]<<8), total:f[3]|(f[4]<<8),
- loopPerSec:f[5]|(f[6]<<8), stallMs:f[7], stage:f[8],
- usbdStk:f[9]|(f[10]<<8), loopStk:f[11]|(f[12]<<8),
- heap:((f[13]|(f[14]<<8)|(f[15]<<16)|(f[16]<<24))>>>0),
- pollsps:f[17]|(f[18]<<8), relayps:f[19]|(f[20]<<8),
- crc:f[21], norx:f[22], heal:f[23]|(f[24]<<8), ringF:f[25]|(f[26]<<8) };
- } else if(T===1 && L>=9){
- const dt=((f[1]|(f[2]<<8)|(f[3]<<16)|(f[4]<<24))>>>0);
- flightEvents.push({dt, evt:f[5], stage:f[6], arg:f[7]|(f[8]<<8)});
- }
- }
- acc=acc.slice(i);
- }
- }catch(e){ log("flight err: "+e.message); }
- flightBusy=false;
- renderFlight(STAGE_NAMES);
- }
- function renderFlight(STAGE_NAMES){
- const h=flightHdr, out=$("#flightOut"), sum=$("#flightSummary"), wedge=$("#flightWedge");
- const stage=s=>(STAGE_NAMES[s]||("st"+s));
- if(!h || (h.count===0 && flightEvents.length===0)){
- sum.textContent="no trail — last boot wasn't a hang, or the recorder didn't survive this board's reset";
- wedge.textContent=""; out.textContent="—"; return;
- }
- const hx=v=>"0x"+v.toString(16).padStart(4,"0");
- sum.textContent="showing "+flightEvents.length+" of "+h.total+" events before the last hang";
- // usbd stack free trending to 0 is the leading overflow suspect -> flag it inline.
- const usbdFlag=(h.usbdStk>0 && h.usbdStk<16)?" ⚠LOW":"";
- wedge.innerHTML=" <b>@wedge:</b> stuck in "+stage(h.stage)+", stall "+h.stallMs+"ms, loop "+h.loopPerSec+"/s"
- +" · usbdStk "+h.usbdStk+"w"+usbdFlag+" · loopStk "+h.loopStk+"w · heap "+h.heap+"B"
- +" · poll "+h.pollsps+" relay "+h.relayps+" crc "+h.crc+" norx "+h.norx+" heal "+h.heal+" ringF "+h.ringF;
- const pad=(s,n)=>(""+s).padEnd(n);
- out.textContent =
- pad("Δms",8)+pad("event",9)+pad("stage",10)+"arg\n"
- +"".padEnd(30,"─")+"\n"
- + flightEvents.map(e=>pad("-"+e.dt,8)+pad(frEvtName(e.evt),9)+pad(stage(e.stage),10)+hx(e.arg)).join("\n");
- }
- async function startCapture(){
- if(!dev||capturing) return;
- capturing=true; capLines=[];
- $("#capOut").textContent="(dumping ring from boot, then live — press Stop when done)";
- $("#capStart").disabled=true; $("#capStop").disabled=false;
- await send([0x05,1]);
- liveTimer=setInterval(drainTick,100);
- }
- async function stopCapture(){
- if(liveTimer){ clearInterval(liveTimer); liveTimer=null; }
- await drainTick();
- try{ await send([0x05,0]); }catch(e){}
- capturing=false;
- $("#capStart").disabled=false; $("#capStop").disabled=true;
- renderCap(); log(`capture stopped — ${capLines.length} entries`);
- }
- async function startPolling(){
- polling=true; await refresh();
- while(polling && dev){
- await new Promise(r=>setTimeout(r,600)); await refresh();
- if(window._autoFlight){ window._autoFlight=false; await loadFlightTrail(); }
- }
- }
- // ---- lizard map transfers ----
- // The status-blob poll and these map transfers share one bulk pipe, so we pause polling
- // (lizardBusy) and wait out any in-flight refresh before sending a 0x11/0x14/0x15 that
- // expects a 0xAA reply. (Opcodes/frame renumbered from 0x0D../0xA7 on the merge with main,
- // which claimed 0x0D-0x10 for bond-import/stability/flight, 0xA7 for bond export and
- // 0xA8 for the flight recorder -- 0xAA is the free device->host marker.)
- async function lizardExchange(fn){
- lizardBusy=true;
- for(let i=0;i<25 && inflight;i++) await new Promise(r=>setTimeout(r,20));
- try{ return await fn(); }
- catch(e){ log("lizard err: "+e.message); return null; }
- finally{ lizardBusy=false; }
- }
- // Read one [0xAA][count][count*16] frame, accumulating transferIn packets until complete.
- async function readLizard(){
- let acc=new Uint8Array(0);
- for(let guard=0; guard<64; guard++){
- const r=await dev.transferIn(epIn,128);
- if(r.status!=="ok") break;
- const d=new Uint8Array(r.data.buffer);
- const m=new Uint8Array(acc.length+d.length); m.set(acc); m.set(d,acc.length); acc=m;
- let i=0; while(i<acc.length && acc[i]!==0xAA) i++;
- if(i>0) acc=acc.slice(i);
- if(acc.length<2) continue;
- const count=acc[1], total=2+count*16;
- if(acc.length>=total){
- const out=[];
- for(let b=0;b<count;b++){
- const q=2+b*16;
- out.push({
- outType:acc[q],
- od:[...acc.slice(q+1,q+8)],
- trig:((acc[q+8])|(acc[q+9]<<8)|(acc[q+10]<<16)|(acc[q+11]<<24))>>>0,
- hold:((acc[q+12])|(acc[q+13]<<8)|(acc[q+14]<<16)|(acc[q+15]<<24))>>>0,
- });
- }
- return out;
- }
- }
- return null;
- }
- async function loadLizard(){
- await lizardExchange(async()=>{
- await send([0x11]); // dump map
- const b=await readLizard();
- if(b){ lizardBindings=b; renderLizard(); log(`lizard map loaded — ${b.length} bindings`); }
- });
- }
- async function saveLizard(){
- if(!dev) return;
- // A digital binding (keyboard/mouse/media) with neither a trigger nor a hold fires
- // unconditionally in the firmware (the trigMask==0 guard is skipped) -- it would type/click
- // forever. Drop those rows so a half-finished binding can't brick desktop input.
- const digital=b=>b.outType===LZO.KBD||b.outType===LZO.MBTN||b.outType===LZO.CONSUMER;
- const valid=lizardBindings.filter(b=> b.outType!==LZO.NONE && (!digital(b) || (b.trig>>>0)||(b.hold>>>0)) );
- const dropped=lizardBindings.length-valid.length;
- await lizardExchange(async()=>{
- await send([0x13, valid.length&0xff]); // begin edit (set count)
- for(let i=0;i<valid.length;i++){
- const b=valid[i];
- const od=(b.od||[]).slice(0,7); while(od.length<7) od.push(0);
- const t=b.trig>>>0, h=b.hold>>>0;
- await send([0x12, i, b.outType, ...od.map(x=>x&0xff), // set one binding
- t&0xff,(t>>>8)&0xff,(t>>>16)&0xff,(t>>>24)&0xff,
- h&0xff,(h>>>8)&0xff,(h>>>16)&0xff,(h>>>24)&0xff]);
- }
- await send([0x14]); // commit + persist to flash
- const b=await readLizard(); if(b){ lizardBindings=b; renderLizard(); }
- log(`lizard map saved — ${valid.length} bindings`+(dropped?` (skipped ${dropped} incomplete: no trigger/hold)`:""));
- });
- }
- async function resetLizard(){
- if(!dev) return;
- if(!confirm("Reset the lizard map to the built-in defaults? Unsaved edits are lost.")) return;
- await lizardExchange(async()=>{
- await send([0x15]); // reset to defaults
- const b=await readLizard(); if(b){ lizardBindings=b; renderLizard(); }
- log("lizard map reset to defaults");
- });
- }
- // ---- lizard map UI ----
- function mkSel(opts, val, onchange){
- const s=document.createElement("select"); s.style.flex="0 1 auto"; s.style.minWidth="0";
- for(const [v,l] of opts){ const o=document.createElement("option"); o.value=v; o.textContent=l; s.appendChild(o); }
- s.value=val; s.addEventListener("change",()=>onchange(+s.value));
- return s;
- }
- function renderLizard(){
- const host=$("#lizardList"); host.innerHTML="";
- lizardBindings.forEach((b,idx)=>host.appendChild(buildLizardRow(b,idx)));
- $("#lzStatus").textContent = `${lizardBindings.length} / ${LZ_MAX} bindings`;
- $("#lzAdd").disabled = lizardBindings.length>=LZ_MAX;
- }
- function buildLizardRow(b, idx){
- const row=document.createElement("div");
- 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";
- const analog = (b.outType===LZO.AXIS || b.outType===LZO.SCROLL);
- // Trigger (When) + Hold — meaningful only for digital outputs
- if(!analog){
- const when=document.createElement("span"); when.className="note"; when.textContent="When"; when.style.flex="0 0 auto"; row.appendChild(when);
- row.appendChild(mkSel([[0,"— pick input —"],...LZ_BTNS], b.trig, v=>{ b.trig=v>>>0; }));
- const plus=document.createElement("span"); plus.className="note"; plus.textContent="+ hold"; plus.style.flex="0 0 auto"; row.appendChild(plus);
- row.appendChild(mkSel([[0,"(none)"],...LZ_BTNS], b.hold, v=>{ b.hold=v>>>0; }));
- } else {
- const lab=document.createElement("span"); lab.className="note"; lab.textContent="Analog source"; lab.style.flex="0 0 auto"; row.appendChild(lab);
- }
- // Arrow + output type
- const arrow=document.createElement("span"); arrow.textContent="→"; arrow.style.color="var(--mut)"; row.appendChild(arrow);
- row.appendChild(mkSel(Object.entries(LZ_OUT_LABELS).map(([v,l])=>[+v,l]), b.outType, v=>{
- b.outType=v;
- // reset payload to sane defaults for the new type
- if(v===LZO.AXIS){ b.od=[0,0,0,0,0,0,0]; }
- else if(v===LZO.SCROLL){ b.od=[0,0,0,0,0,0,0]; }
- else if(v===LZO.MBTN){ b.od=[1,0,0,0,0,0,0]; }
- else if(v===LZO.CONSUMER){ b.od=[1,0,0,0,0,0,0]; }
- else if(v===LZO.KBD){ b.od=[0,0,0,0,0,0,0]; }
- renderLizard();
- }));
- // Output-specific controls
- if(b.outType===LZO.KBD){
- for(const [bit,name] of LZ_MODS){
- const w=document.createElement("label"); w.style.cssText="flex:0 0 auto;display:flex;align-items:center;gap:3px;color:var(--fg)";
- const cb=document.createElement("input"); cb.type="checkbox"; cb.checked=!!(b.od[0]&bit);
- cb.addEventListener("change",()=>{ b.od[0]=cb.checked?(b.od[0]|bit):(b.od[0]&~bit); });
- w.appendChild(cb); w.appendChild(document.createTextNode(name)); row.appendChild(w);
- }
- 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; }));
- } else if(b.outType===LZO.MBTN){
- row.appendChild(mkSel(LZ_MBTNS, b.od[0]||1, v=>{ b.od[0]=v; }));
- } else if(b.outType===LZO.AXIS){
- row.appendChild(mkSel(LZ_AXIS_SRC, b.od[0]||0, v=>{ b.od[0]=v; renderLizard(); }));
- if((b.od[0]||0)===2){ // gyro: activation selector
- const g=document.createElement("span"); g.className="note"; g.textContent="when"; g.style.flex="0 0 auto"; row.appendChild(g);
- row.appendChild(mkSel(LZ_GYRO_ACT, b.od[1]||0, v=>{ b.od[1]=v; }));
- }
- } else if(b.outType===LZO.SCROLL){
- const s=document.createElement("span"); s.className="note"; s.textContent="Left trackpad → scroll"; row.appendChild(s);
- } else if(b.outType===LZO.CONSUMER){
- row.appendChild(mkSel(LZ_CONSUMER, b.od[0]||1, v=>{ b.od[0]=v; }));
- }
- // delete
- const del=document.createElement("button"); del.textContent="✕"; del.title="Delete binding";
- del.style.cssText="margin-left:auto;flex:0 0 auto;padding:4px 10px";
- del.onclick=()=>{ lizardBindings.splice(idx,1); renderLizard(); };
- row.appendChild(del);
- return row;
- }
- // UI wiring
- $("#connectBtn").onclick=connect;
- // ---- Firmware update: UF2 file -> staged flash over WebUSB, applied on reboot ----
- // Flow: parse the .uf2 into the raw app image, stream it over THIS WebUSB connection (ops 0x20/0x21/0x22,
- // strict ping-pong on the firmware's 0xAB acks) into spare flash high in the puck's app region, let the
- // firmware CRC32-verify what actually landed and commit the one-page "apply on reboot" record, then send a
- // plain reboot (0x23) — the puck copies staged->app from RAM on the way up and re-enumerates on the new
- // firmware, where this panel auto-reconnects. Corruption safety (matches fw_update.h in the firmware):
- // - nothing is armed until the staged bytes verify IN FLASH, so any failure/disconnect/power-cut during
- // the transfer leaves the current firmware completely untouched;
- // - the boot-time apply orders its writes so an interruption leaves the board app-less (UF2 bootloader
- // mass-storage recovery), never a crash-looping half image.
- const UF2_MAGIC0=0x0A324655, UF2_MAGIC1=0x9E5D5157, UF2_MAGIC_END=0x0AB16F30, UF2_FAMILY=0xADA52840;
- const APP_BASE=0x26000, FWUP_MAX_IMG=0x60000; // nRF52840 app base; staging cap (fw_update.h FWUP_MAX_IMG)
- // Extract the contiguous application image from a .uf2 (gaps filled with 0xFF, length padded to a word).
- // Throws on anything that is not an nRF52840 app-region UF2 — the firmware re-checks everything, but a bad
- // file should fail at pick time, not after a reboot.
- function uf2ToImage(buf){
- if(!buf.byteLength || buf.byteLength%512) throw new Error("not a UF2 (size is not a multiple of 512)");
- const dv=new DataView(buf), blocks=[]; let lo=Infinity, hi=0;
- for(let off=0; off<buf.byteLength; off+=512){
- if(dv.getUint32(off,true)!==UF2_MAGIC0 || dv.getUint32(off+4,true)!==UF2_MAGIC1 || dv.getUint32(off+508,true)!==UF2_MAGIC_END)
- throw new Error("bad UF2 block magic at offset "+off);
- const flags=dv.getUint32(off+8,true), addr=dv.getUint32(off+12,true), len=dv.getUint32(off+16,true);
- if(flags&0x00000001) continue; // NOT_MAIN_FLASH
- if((flags&0x00002000) && dv.getUint32(off+28,true)!==UF2_FAMILY)
- throw new Error("UF2 family 0x"+dv.getUint32(off+28,true).toString(16)+" is not nRF52840 (0xada52840)");
- if(len<1 || len>476) throw new Error("bad UF2 payload size "+len);
- blocks.push({addr,len,off}); lo=Math.min(lo,addr); hi=Math.max(hi,addr+len);
- }
- if(!blocks.length) throw new Error("UF2 contains no flash data");
- if(lo!==APP_BASE) throw new Error("image base 0x"+lo.toString(16)+" is not the app region (0x26000) — not an OpenPuck app UF2");
- 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");
- const img=new Uint8Array((hi-lo+3)&~3).fill(0xFF); // firmware stages whole words
- for(const b of blocks) img.set(new Uint8Array(buf,b.off+32,b.len), b.addr-lo);
- return img;
- }
- // CRC32 (IEEE, reflected, init/xorout 0xFFFFFFFF) — must match crc32Step/crc32Flash in fw_update.cpp
- function crc32(u8){
- let c=0xFFFFFFFF;
- for(let i=0;i<u8.length;i++){
- c^=u8[i];
- for(let k=0;k<8;k++) c=(c>>>1)^(0xEDB88320&-(c&1));
- }
- return (~c)>>>0;
- }
- const u32le=v=>[v&0xFF,(v>>>8)&0xFF,(v>>>16)&0xFF,(v>>>24)&0xFF];
- const dfuSleep=ms=>new Promise(r=>setTimeout(r,ms));
- // Firmware built from this branch onward embeds this tag (fw_update.cpp FWUP_TAG). An image WITHOUT it
- // still flashes fine — but the puck it leaves behind can't do panel updates, so we warn before the
- // lock-yourself-out-to-drag-and-drop move (all releases up to 0.9.6 predate panel updates).
- function imagePanelUpdatable(image){
- return new TextDecoder("latin1").decode(image).includes("OPK-FWUP-v1");
- }
- const FWUP_ERR=["ok","command out of sequence","image too big for this puck's free flash","offset resync",
- "staged bytes failed CRC verify","staged image has no valid vector table"];
- // Ack reader ([0xAB][5][status][nextOff u32 LE]). One persistent transferIn at a time: a wait that times out
- // leaves its read PENDING for the next wait — issuing a second concurrent read would silently eat the data
- // the first one eventually resolves with. If one read carries several acks (a retried command double-acks),
- // only the LAST — the firmware's newest state — is returned, so stale acks collapse instead of accumulating.
- let fwupRead=null;
- async function fwupAckWait(timeoutMs){
- const deadline=Date.now()+timeoutMs;
- for(;;){
- const remain=deadline-Date.now();
- if(remain<=0) return null;
- if(!fwupRead) fwupRead=dev.transferIn(epIn, 64);
- const r=await Promise.race([fwupRead, dfuSleep(remain).then(()=>"timeout")]);
- if(r==="timeout") return null;
- fwupRead=null;
- if(r.status!=="ok") continue;
- const d=new Uint8Array(r.data.buffer, r.data.byteOffset, r.data.byteLength);
- let ack=null;
- for(let i=0;i+6<d.length;i++)
- if(d[i]===0xAB && d[i+1]===5)
- ack={status:d[i+2], off:(d[i+3]|(d[i+4]<<8)|(d[i+5]<<16)|(d[i+6]<<24))>>>0};
- if(ack) return ack;
- }
- }
- const fwupSend=cmd=>dev.transferOut(epOut, new Uint8Array(cmd));
- // send a control op (begin/end/abort — all idempotent on the firmware side) and wait; resend on timeout
- async function fwupCtl(cmd, timeoutMs, label){
- for(let t=0;t<3;t++){
- await fwupSend(cmd);
- const a=await fwupAckWait(timeoutMs);
- if(a) return a;
- }
- throw new Error("no response to "+label);
- }
- async function fwupRun(image){
- const kb=Math.round(image.length/1024);
- fwupRead=null; // never reuse a read pending against a previous session/device
- // drain anything stale on the IN pipe (leftover ack from an aborted run, a late status blob)
- while(await fwupAckWait(400)!==null){}
- log("firmware update: staging "+kb+" KiB into the puck's spare flash — input may stutter briefly during page erases");
- let a=await fwupCtl([0x20,...u32le(image.length),...u32le(crc32(image))],4000,"begin");
- if(a.status) throw new Error("begin rejected: "+(FWUP_ERR[a.status]||("code "+a.status)));
- // Chunk loop: send one chunk, then read acks until one shows progress. A TIMEOUT resends (covers a truly
- // lost command); a stale no-progress ack (the surplus twin of a resent chunk) is read past WITHOUT
- // resending, so retries can't snowball. The ack's nextOff is authoritative — the firmware skips duplicate
- // chunks and re-acks, flash words are never written twice — so every path resynchronizes here.
- let off=0, sends=0, lastShown=-1;
- const maxSends=Math.ceil(image.length/128)*2+64;
- while(off<image.length){
- const len=Math.min(128,image.length-off);
- await fwupSend([0x21,...u32le(off),len,...image.subarray(off,off+len)]);
- if(++sends>maxSends) throw new Error("transfer not converging at offset "+off);
- for(let reads=0;reads<8;reads++){
- a=await fwupAckWait(2500);
- if(a===null) break; // timeout: resend this chunk
- if(a.status===3){ off=a.off; break; } // firmware says where it wants us — resend from there
- if(a.status!==0) throw new Error("chunk rejected at offset "+off+": "+(FWUP_ERR[a.status]||("code "+a.status)));
- if(a.off>off){ off=a.off; break; } // progress
- // else: stale ack — keep reading, the real one is behind it
- }
- const pct=Math.floor(off*50/image.length)*2;
- if(pct!==lastShown){ lastShown=pct; modalStage("Sending to the puck", pct); }
- }
- modalStage("Verifying on the puck", null); // firmware CRC32s the staged flash, then commits the record
- a=await fwupCtl([0x22],8000,"verify+commit");
- if(a.status) throw new Error("verify+commit rejected: "+(FWUP_ERR[a.status]||("code "+a.status)));
- log("image staged + CRC-verified on the puck — update armed");
- modalStage("Rebooting", null);
- try{ await fwupSend([0x23]); }catch(e){} // device drops mid-call; that's the point
- log("rebooting to apply: the puck goes dark ~5 s while the new firmware is written, then re-enumerates — the panel reconnects itself");
- }
- // ---- blocking modal: the ONLY interactive surface while an update runs (it covers everything else) ----
- function modalStage(txt,pct){
- const f=$("#updFill"), p=$("#updPct");
- $("#updStage").textContent=txt;
- f.classList.remove("bad");
- if(pct==null){ f.classList.add("pulse"); f.style.width="100%"; p.textContent=""; }
- else{ f.classList.remove("pulse"); f.style.width=pct+"%"; p.textContent=pct+"%"; }
- }
- function modalOpen(title){
- $("#updTitle").textContent=title;
- $("#updDetail").textContent="";
- $("#updClose").classList.add("hide"); // no way out mid-update: closing wouldn't stop the puck anyway
- $("#updModal").classList.remove("hide");
- modalStage("Preparing…",null);
- }
- function modalDone(ok,msg){
- const f=$("#updFill");
- f.classList.remove("pulse");
- f.style.width="100%";
- f.classList.toggle("bad",!ok);
- $("#updStage").textContent = ok ? "Done" : "Update failed";
- $("#updPct").textContent="";
- $("#updDetail").textContent=msg;
- $("#updClose").classList.remove("hide");
- }
- $("#updClose").onclick=()=>$("#updModal").classList.add("hide");
- function blobBuildId(p){
- if(!p) return "";
- let s=""; for(let i=39;i<51 && i<p.length && p[i];i++) s+=String.fromCharCode(p[i]);
- return s;
- }
- // Update-available notice (topbar): shown when the connected puck reports a release-shaped build (x.y.z)
- // older than the newest non-prerelease on GitHub. Dev builds report a git hash, which doesn't parse — no
- // nagging about "updates" for firmware newer than any release. Click = jump to the Firmware update tab.
- function verTriple(s){
- const m=/^(\d+)\.(\d+)\.(\d+)/.exec(s||"");
- return m ? [+m[1],+m[2],+m[3]] : null;
- }
- function checkUpdateNotice(){
- const el=$("#updAvail");
- const inst=verTriple(blobBuildId(lastP));
- const rel=(relCache||[]).find(r=>!r.prerelease && relAsset(r,false));
- const latest=rel ? verTriple(rel.tag_name) : null;
- const newer = !!(dev && inst && latest &&
- (latest[0]>inst[0] || (latest[0]===inst[0] &&
- (latest[1]>inst[1] || (latest[1]===inst[1] && latest[2]>inst[2])))));
- el.classList.toggle("hide", !newer);
- if(newer) el.textContent="⬆ update "+rel.tag_name+" available";
- }
- $("#updAvail").onclick=()=>{ document.querySelector('#mainTabs .slot-tab[data-tab="tabUpdate"]').click(); };
- // Firmware-tab gate: the connected puck must speak status v15+ (the 0x20..0x24 update ops) for ANY of the
- // update tab to be usable. Too old => banner explains + both cards go inert until a capable build is flashed
- // the manual way once.
- function updateFwGate(){
- const ok = !!(lastP && lastP[0]>=15);
- $("#updGate").classList.toggle("hide", ok);
- if(!ok) $("#updGateMsg").textContent =
- "This puck is running "+(blobBuildId(lastP)||"an unknown build")+" (status v"+(lastP?lastP[0]:"?")
- +"), which predates panel updates (needs v15+), so updating from this page is disabled. One manual flash "
- +"gets you back: click “UF2 DFU” in the top bar, then drag a panel-update-capable .uf2 onto the UF2BOOT "
- +"drive it mounts. Every update after that happens right here.";
- for(const c of document.querySelectorAll(".fwupcard")) c.classList.toggle("gated", !ok);
- }
- // after the apply-reboot: resolved true once the panel has reconnected AND received a fresh status blob
- async function waitReconnect(t0,ms){
- const deadline=Date.now()+ms;
- while(Date.now()<deadline){
- if(dev && window._lastBlobTs>t0) return true;
- await dfuSleep(400);
- }
- return false;
- }
- // One update end-to-end: pre-flight checks, confirm, modal up, image acquired (may download), streamed,
- // applied, reconnect observed. getImage runs INSIDE the modal so release downloads show progress there.
- async function runUpdate(title, confirmText, getImage){
- if(dfuBusy) return;
- if(!dev){ log("connect to the puck first — the update travels over this WebUSB connection"); return; }
- if(!lastP || lastP[0]<15){
- 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");
- return;
- }
- if(!confirm(confirmText)) return;
- dfuBusy=true; updateUf2UI(); modalOpen(title);
- polling=false; // the IN pipe belongs to the update acks now
- await dfuSleep(800); // let an in-flight status poll finish its read first
- const t0=Date.now(), oldBuild=blobBuildId(lastP);
- try{
- const image=await getImage();
- if(!imagePanelUpdatable(image) &&
- !confirm("⚠ DOWNGRADE WARNING\n\nThis image predates panel updates (releases up to 0.9.6 do). It will "
- +"flash fine, but the puck it leaves behind CANNOT be updated from this panel — getting off it again "
- +"means UF2 DFU + drag-and-drop.\n\nFlash it anyway?"))
- throw new Error("cancelled — the selected image doesn't support panel updates");
- await fwupRun(image);
- modalStage("Applying on the puck",null);
- $("#updDetail").textContent="the puck goes dark ~5 s while the new firmware is written, then re-enumerates";
- if(await waitReconnect(t0,40000)){
- const nb=blobBuildId(lastP);
- modalDone(true,"Update applied — the puck is back and running build "+(nb||"?")+(oldBuild&&nb!==oldBuild?" (was "+oldBuild+")":""));
- }else{
- modalDone(false,"The update was sent and armed, but the puck didn't reconnect within 40 s. Unplug/replug it. "
- +"If it mounts as a UF2BOOT drive, drag the .uf2 onto the drive to recover — the bootloader is intact.");
- }
- }catch(e){
- log("firmware update FAILED: "+e.message);
- modalDone(false,e.message+" — nothing was applied; the running firmware is untouched.");
- try{ await fwupSend([0x24]); }catch(_e){} // best-effort disarm
- if(dev) startPolling();
- }finally{
- dfuBusy=false; updateUf2UI();
- }
- }
- // ---- local-file source: drag-and-drop or file picker ----
- async function pickUf2(f){
- try{
- const image=uf2ToImage(await f.arrayBuffer());
- selectedUf2={name:f.name, image, updatable:imagePanelUpdatable(image)};
- log("UF2 ready: "+f.name+" — app image "+Math.round(image.length/1024)+" KiB, CRC32 0x"+crc32(image).toString(16).padStart(8,"0"));
- }catch(err){
- selectedUf2=null;
- log("UF2 rejected: "+err.message);
- }
- updateUf2UI();
- }
- $("#uf2File").onchange=async(e)=>{
- const f=e.target.files && e.target.files[0];
- e.target.value=""; // allow re-picking the same filename after a rebuild
- if(f) await pickUf2(f);
- };
- // a file dropped anywhere the dropzone isn't listening (e.g. while gated) must not navigate the page to it
- window.addEventListener("dragover",e=>e.preventDefault());
- window.addEventListener("drop",e=>e.preventDefault());
- {
- const dz=$("#uf2Drop");
- dz.onclick=()=>$("#uf2File").click();
- dz.ondragover=e=>{ e.preventDefault(); dz.classList.add("drag"); };
- dz.ondragleave=()=>dz.classList.remove("drag");
- dz.ondrop=async e=>{
- e.preventDefault(); dz.classList.remove("drag");
- const f=e.dataTransfer.files && e.dataTransfer.files[0];
- if(f) await pickUf2(f);
- };
- }
- $("#uf2Flash").onclick=()=>{
- if(!selectedUf2) return;
- runUpdate("Flashing "+selectedUf2.name,
- "Flash "+selectedUf2.name+" ("+Math.round(selectedUf2.image.length/1024)+" KiB)?\n\n"
- +"The image is sent over WebUSB into spare flash (~15 s), verified on the puck, and applied on an automatic "
- +"reboot (~5 s dark). Nothing is armed until it verifies, so a failed or interrupted transfer leaves the "
- +"current firmware untouched. Worst case (power cut during the apply itself) the puck comes back as the "
- +"UF2BOOT drive for drag-and-drop recovery — it cannot end up half-flashed.",
- async()=>selectedUf2.image);
- };
- // ---- release source: list from the GitHub API, binaries from the CORS-reachable firmware mirror ----
- // api.github.com sends Access-Control-Allow-Origin:* but release-asset downloads redirect to a CDN that does
- // NOT, so the release workflow mirrors every OpenPuck .uf2 onto the repo's orphan `firmware` branch and the
- // panel fetches it from raw.githubusercontent.com (which is CORS-clean). Fallbacks: the API asset endpoint
- // (in case GitHub ever fixes its CDN), then a plain new-tab download + the local-file card.
- const REL_REPO="safijari/openpuck";
- let relCache=null;
- // firmware-mirror manifest: asset name -> {panelUpdate}. Written by the release workflow, which scans each
- // .uf2 for the FWUP_TAG capability string. null = manifest unreachable (then releases just go unbadged; the
- // byte-scan warning at flash time still catches an unsupported image).
- let relManifest=null;
- // tri-state: true = a puck running this release can be panel-updated, false = it cannot (drag-and-drop only
- // to get off it), null = unknown (no manifest)
- function relPanelUpdatable(rel){
- if(!relManifest) return null;
- const known=[relAsset(rel,false),relAsset(rel,true)].filter(a=>a && relManifest[a.name]);
- if(!known.length) return null; // not mirrored (yet) — unknown, don't badge (mirror step lags a fresh release by ~a minute)
- return known.some(a=>relManifest[a.name].panelUpdate);
- }
- async function downloadAsset(asset){
- modalStage("Downloading "+asset.name,0);
- let resp=null;
- try{
- const r=await fetch("https://raw.githubusercontent.com/"+REL_REPO+"/firmware/"+asset.name,{cache:"no-store"});
- if(r.ok) resp=r;
- }catch(e){}
- if(!resp){
- try{
- const r=await fetch(asset.url,{headers:{Accept:"application/octet-stream"}});
- if(r.ok) resp=r;
- }catch(e){}
- }
- if(!resp){
- window.open(asset.browser_download_url,"_blank","noopener");
- throw new Error(asset.name+" isn't on the firmware mirror yet and GitHub's asset CDN blocks in-page downloads. "
- +"It's downloading in a new tab instead — drop the file on the local-file card above.");
- }
- const total=+resp.headers.get("content-length") || asset.size || 0;
- const reader=resp.body.getReader(), parts=[]; let got=0;
- for(;;){
- const {done,value}=await reader.read();
- if(done) break;
- parts.push(value); got+=value.length;
- if(total) modalStage("Downloading "+asset.name, Math.min(99,Math.floor(got*100/total)));
- }
- const buf=new Uint8Array(got); let o=0;
- for(const p of parts){ buf.set(p,o); o+=p.length; }
- return uf2ToImage(buf.buffer);
- }
- function relAsset(rel,factory){
- const suffix=factory ? "factory-reset" : "standard";
- return rel.assets.find(a=>a.name==="OpenPuck-"+rel.tag_name+"-"+suffix+".uf2")
- || rel.assets.find(a=>new RegExp("^OpenPuck-.*-"+suffix+"\\.uf2$").test(a.name)) || null;
- }
- function flashRelease(rel,factory){
- const asset=relAsset(rel,factory);
- if(!asset){ log("release "+rel.tag_name+" has no "+(factory?"factory-reset":"standard")+" .uf2 asset"); return; }
- const kb=Math.round((asset.size||0)/1024);
- runUpdate("Updating to "+rel.tag_name+(factory?" (factory reset)":""),
- "Update the puck to "+rel.tag_name+(factory
- ? " 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."
- : "?")
- +"\n\n"+asset.name+" ("+kb+" KiB) is downloaded, sent over WebUSB, verified on the puck, and applied on an "
- +"automatic reboot. A failed or interrupted transfer leaves the current firmware untouched.",
- ()=>downloadAsset(asset));
- }
- function renderReleases(){
- const list=$("#relList");
- list.textContent="";
- let shown=0;
- for(const rel of relCache){
- const std=relAsset(rel,false), fac=relAsset(rel,true);
- if(!std && !fac) continue;
- shown++;
- const row=document.createElement("div"); row.className="rel-row";
- const ver=document.createElement("span"); ver.className="ver"; ver.textContent=rel.tag_name;
- const date=document.createElement("span"); date.className="date";
- date.textContent=(rel.published_at||"").slice(0,10)+(rel.prerelease?" · pre":"");
- const name=document.createElement("span"); name.className="grow note"; name.style.margin="0";
- name.textContent=rel.name && rel.name!==rel.tag_name ? rel.name : "";
- const lab=document.createElement("label");
- const cb=document.createElement("input"); cb.type="checkbox"; cb.disabled=!fac;
- lab.appendChild(cb); lab.appendChild(document.createTextNode("factory reset"));
- lab.title=fac ? "Flash the -factory-reset build: wipes settings + pairing once on first boot"
- : "this release has no factory-reset build";
- const btn=document.createElement("button"); btn.textContent="Flash "+rel.tag_name;
- btn.onclick=()=>flashRelease(rel,cb.checked);
- row.append(ver,date,name);
- if(relPanelUpdatable(rel)===false){
- const pill=document.createElement("span"); pill.className="pill dn";
- pill.textContent="no panel updates";
- pill.title="This build predates panel-update support: it flashes fine from here, but a puck running it "
- +"can only be updated again via UF2 DFU + drag-and-drop.";
- row.appendChild(pill);
- }
- row.append(lab,btn);
- list.appendChild(row);
- }
- if(!shown) list.textContent="no releases with OpenPuck .uf2 assets found";
- }
- async function loadReleases(force){
- if(relCache && !force) return;
- const list=$("#relList");
- list.textContent="loading…";
- try{
- const [r,mr]=await Promise.all([
- fetch("https://api.github.com/repos/"+REL_REPO+"/releases?per_page=15",
- {headers:{Accept:"application/vnd.github+json"}}),
- fetch("https://raw.githubusercontent.com/"+REL_REPO+"/firmware/manifest.json",{cache:"no-store"})
- .catch(()=>null),
- ]);
- if(!r.ok) throw new Error("GitHub API "+r.status);
- try{ relManifest=(mr && mr.ok) ? await mr.json() : null; }catch(e){ relManifest=null; }
- relCache=(await r.json()).filter(x=>!x.draft);
- renderReleases();
- checkUpdateNotice(); // the connect-time blob usually lands before this fetch does
- }catch(e){
- list.innerHTML='could not load releases ('+e.message.replace(/</g,"<")
- +') — <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';
- relCache=null;
- }
- }
- $("#relRefresh").onclick=()=>loadReleases(true);
- // ---- top-level tabs ----
- for(const t of document.querySelectorAll("#mainTabs .slot-tab")){
- t.onclick=()=>{
- for(const x of document.querySelectorAll("#mainTabs .slot-tab")) x.classList.toggle("active",x===t);
- $("#tabMain").classList.toggle("hide", t.dataset.tab!=="tabMain");
- $("#tabUpdate").classList.toggle("hide", t.dataset.tab!=="tabUpdate");
- if(t.dataset.tab==="tabUpdate") loadReleases(false); // lazy: first visit fetches the list
- };
- }
- $("#backupExport").onclick=exportBackup;
- $("#backupImport").onclick=()=>$("#backupFile").click();
- $("#backupFile").onchange=async(e)=>{ const f=e.target.files[0]; if(f) await importBackup(f); e.target.value=""; };
- $("#capDl").onclick=downloadCap;
- $("#capStart").onclick=startCapture;
- $("#capStop").onclick=stopCapture;
- $("#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(); } };
- $("#lzSave").onclick=saveLizard;
- $("#lzReload").onclick=loadLizard;
- $("#lzReset").onclick=resetLizard;
- $("#stabBtn").onclick=async()=>{
- if(!dev){ log("connect first"); return; }
- stabArmed=!stabArmed;
- 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)"); }
- else { await send([0x0F,0]); stabStart=0; log("stability test stopped"); trailAdd("stability test stopped"); }
- updateStabUI();
- };
- $("#hangClear").onclick=()=>{ hangLog=[]; renderHangLog(); };
- $("#flightLoad").onclick=loadFlightTrail;
- $("#hangCsv").onclick=()=>{
- const rows=[["time","uptime_s","reason","stage","pc","lr","usbd_free_words"]].concat(
- hangLog.map(e=>[e.time, e.uptime!=null?Math.round(e.uptime):"", e.reason, e.stage, e.pc, e.lr, e.usbd]));
- const csv=rows.map(r=>r.map(x=>'"'+String(x).replace(/"/g,'""')+'"').join(",")).join("\n");
- const a=document.createElement("a"); a.href=URL.createObjectURL(new Blob([csv],{type:"text/csv"}));
- a.download="openpuck-hanglog.csv"; a.click();
- };
- $("#hapClear").onclick=async()=>{ if(dev){ await send([0x07]); log("haptic re-init sent (clear stuck buzz)"); } };
- $("#ctlrOff").onclick=async()=>{ if(dev){ await send([0x08]); log("controller power-off attempt sent — watch link status");
- trailAdd("power-off sent to controller (panel button) — stress action, correlate with what follows"); } };
- $("#debugCdc").onclick=async()=>{
- if(!dev){ log("not connected"); return; }
- 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;
- await send([0x02,20,1]); log("debug-CDC reboot sent — device disconnecting; reconnect a serial monitor at 115200 baud");
- };
- $("#dfuSerial").onclick=async()=>{
- if(!dev){ log("not connected"); return; }
- if(!confirm("Reboot into serial DFU?\n\nThe puck will disconnect immediately. Flash with adafruit-nrfutil, then replug.")) return;
- await send([0x0B]); log("serial DFU reboot sent — device disconnecting");
- };
- $("#dfuUf2").onclick=async()=>{
- if(!dev){ log("not connected"); return; }
- 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;
- await send([0x0C]); log("UF2 bootloader reboot sent — device disconnecting");
- };
- $("#factoryErase").onclick=async()=>{
- if(!dev){ log("not connected"); return; }
- 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;
- if(!confirm("Are you absolutely sure?\n\nThere is no recovery. The controller bond and all settings will be gone.")) return;
- const typed=prompt('Final confirmation — type ERASE (all caps) to wipe everything:');
- if(typed!=="ERASE"){ log("factory erase cancelled (confirmation text did not match)"); return; }
- await send([0x0A,0x45,0x52,0x53]);
- log("FACTORY ERASE sent — copycat is reformatting and rebooting to defaults. Re-pair the controller, then reconnect.");
- };
- $("#persistMode").onclick=()=>{ const on=$("#persistMode").classList.contains("active"); setField(16, on?0:1); };
- $("#landAll87").onclick=async()=>{ const on=$("#landAll87").classList.contains("active"); await setField(29, on?0:1); log("land-all-0x87 "+(on?"disabled":"enabled")); };
- // Connection tuning: post-connect haptic block on (field 27) + seconds (field 28). (poll RX window is fixed.)
- for(const id of ["mDiv","mFric","rumble"]){
- const field={mDiv:1,mFric:2,rumble:22}[id];
- $("#"+id).addEventListener("input", ()=>{ $("#"+id+"V").textContent=fmtSlider(id,$("#"+id).value); });
- $("#"+id).addEventListener("change", ()=>setField(field, +$("#"+id).value));
- }
- // per-type back/QAM/A-B-swap/haptics handlers are wired in buildTypeCfgs()
- $("#swRate").addEventListener("change", ()=>setField(23, +$("#swRate").value));
- $("#swGyro").addEventListener("change", ()=>setField(24, +$("#swGyro").value));
- for(const sel of document.querySelectorAll("select.chord")){
- sel.addEventListener("change", ()=>setField(CHORD_FIELD[+sel.dataset.i], +sel.value));
- }
- for(const b of document.querySelectorAll(".modebtn")){
- b.onclick=()=>{ const m=+b.dataset.mode;
- const clean=(m===7||m===8||m===9);
- const msg="Switch to "+MODE_NAMES[m]+"? The copycat will reboot."+(clean?"\n\nNOTE: the game/clean PlayStation modes (and PS3) drop WebUSB + host-wake, so THIS PANEL WILL DISCONNECT and can't reach the device while it's in this mode. To get back, chord on the controller: hold back-paddle 4 + A to return to Steam mode.":"");
- if(confirm(msg)){ send([0x03, m]); log("mode switch requested — device will reboot"); } };
- }
- updateUf2UI();
- updateFwGate(); // start gated: stays inert until the first status blob proves the firmware speaks v15+
- if(!("usb" in navigator)) $("#connectBtn").outerHTML='<b style="color:var(--bad)">WebUSB not supported — use Chrome or Edge.</b>';
- else autoConnect(); // reopen an already-authorized puck on load, no picker (first-ever connect still needs the button)
- </script>
- </body>
- </html>
|