| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139 |
- <!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)}
- .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}
- </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 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="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="7" class="modebtn">PS5 DualSense (for games)</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">
- <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 id="log"></div>
- </div>
- </div>
- <script>
- const MODE_NAMES = ["Steam (puck)","Xbox 360","Switch (HORIPAD)","Lizard (always)","Switch Pro + gyro","PS5 DualSense","HID gyro (DS4)","PS5 DualSense (for games)","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;
- // 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 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";
- 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;} }
- 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();
- startPolling();
- 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
- $("#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;
- 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>'))
- : "—";
- 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) 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(); }
- }
- }
- // UI wiring
- $("#connectBtn").onclick=connect;
- $("#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;
- $("#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"); } };
- }
- 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>
|