|
@@ -7,31 +7,41 @@
|
|
|
<style>
|
|
<style>
|
|
|
:root{ --bg:#11131a; --card:#1b1f2b; --fg:#e7ecf3; --mut:#8a93a6; --acc:#5bd6a0; --acc2:#5b9bd6; --bad:#d65b6e; }
|
|
:root{ --bg:#11131a; --card:#1b1f2b; --fg:#e7ecf3; --mut:#8a93a6; --acc:#5bd6a0; --acc2:#5b9bd6; --bad:#d65b6e; }
|
|
|
*{box-sizing:border-box}
|
|
*{box-sizing:border-box}
|
|
|
- body{margin:0;font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--fg);padding:24px}
|
|
|
|
|
|
|
+ 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}
|
|
h1{font-size:20px;margin:0 0 4px}
|
|
|
- .sub{color:var(--mut);margin:0 0 20px}
|
|
|
|
|
- .wrap{max-width:680px;margin:0 auto}
|
|
|
|
|
- .card{background:var(--card);border:1px solid #272d3d;border-radius:12px;padding:18px 20px;margin-bottom:16px}
|
|
|
|
|
|
|
+ .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}
|
|
.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}
|
|
|
|
|
|
|
+ .row{display:flex;align-items:center;gap:14px;margin:12px 0;flex-wrap:wrap}
|
|
|
.row label{flex:0 0 150px;color:var(--fg)}
|
|
.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}
|
|
.row .val{flex:0 0 64px;text-align:right;color:var(--acc);font-variant-numeric:tabular-nums}
|
|
|
- input[type=range]{flex:1;accent-color:var(--acc)}
|
|
|
|
|
- select{background:#0e1017;color:var(--fg);border:1px solid #313a4f;border-radius:8px;padding:6px 8px;flex:1}
|
|
|
|
|
|
|
+ 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{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:hover{border-color:var(--acc2)}
|
|
|
button.primary{background:var(--acc2);border-color:var(--acc2);color:#06121f;font-weight:600}
|
|
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.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}
|
|
button:disabled{opacity:.4;cursor:default}
|
|
|
.pill{display:inline-block;padding:2px 9px;border-radius:99px;font-size:12px;font-weight:600}
|
|
.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)}
|
|
.pill.up{background:#143626;color:var(--acc)} .pill.dn{background:#3a1822;color:var(--bad)}
|
|
|
- .statgrid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}
|
|
|
|
|
|
|
+ .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{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 .k{color:var(--mut);font-size:11px;text-transform:uppercase;letter-spacing:.05em}
|
|
|
.stat .v{font-size:18px;margin-top:2px}
|
|
.stat .v{font-size:18px;margin-top:2px}
|
|
|
- .modes{display:flex;gap:10px}
|
|
|
|
|
|
|
+ .modes{display:flex;gap:10px;flex-wrap:wrap}
|
|
|
.note{color:var(--mut);font-size:12px;margin-top:10px}
|
|
.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:120px;overflow:auto}
|
|
|
|
|
|
|
+ #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}
|
|
.hide{display:none}
|
|
|
|
|
|
|
|
/* ---- controller slot tabs ---- */
|
|
/* ---- controller slot tabs ---- */
|
|
@@ -50,19 +60,20 @@
|
|
|
</style>
|
|
</style>
|
|
|
</head>
|
|
</head>
|
|
|
<body>
|
|
<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="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">
|
|
<div class="wrap">
|
|
|
- <h1>OpenPuck Config</h1>
|
|
|
|
|
- <p class="sub">nRF52840 Steam Controller 2 puck reimplementation — WebUSB. Chrome/Edge only.</p>
|
|
|
|
|
-
|
|
|
|
|
- <div class="card" id="connectCard">
|
|
|
|
|
- <div class="row" style="margin:0">
|
|
|
|
|
- <button id="connectBtn" class="primary">Connect to copycat</button>
|
|
|
|
|
- <span id="connState" class="pill dn">disconnected</span>
|
|
|
|
|
- </div>
|
|
|
|
|
- <p class="note">If nothing appears in the device picker, the copycat may be busy (quit any app holding it) or needs a replug.</p>
|
|
|
|
|
- </div>
|
|
|
|
|
|
|
+ <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 id="panel" class="hide">
|
|
|
|
|
+ <div class="cards">
|
|
|
<div class="card">
|
|
<div class="card">
|
|
|
<h2>USB mode</h2>
|
|
<h2>USB mode</h2>
|
|
|
<div class="modes" style="flex-wrap:wrap">
|
|
<div class="modes" style="flex-wrap:wrap">
|
|
@@ -89,7 +100,7 @@
|
|
|
<div class="row"><label>Y + back4 →</label><select class="chord" data-i="2"></select></div>
|
|
<div class="row"><label>Y + back4 →</label><select class="chord" data-i="2"></select></div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- <div class="card">
|
|
|
|
|
|
|
+ <div class="card wide">
|
|
|
<h2>Link status</h2>
|
|
<h2>Link status</h2>
|
|
|
|
|
|
|
|
<!-- Per-controller tabs: visible only when 2+ controllers are bonded -->
|
|
<!-- Per-controller tabs: visible only when 2+ controllers are bonded -->
|
|
@@ -106,35 +117,33 @@
|
|
|
<div class="statgrid">
|
|
<div class="statgrid">
|
|
|
<div class="stat"><div class="k">Delivered</div><div class="v" id="stRate">—</div></div>
|
|
<div class="stat"><div class="k">Delivered</div><div class="v" id="stRate">—</div></div>
|
|
|
<div class="stat"><div class="k">New reports</div><div class="v" id="stNew">—</div></div>
|
|
<div class="stat"><div class="k">New reports</div><div class="v" id="stNew">—</div></div>
|
|
|
- <div class="stat logonly"><div class="k">Polls/s</div><div class="v" id="stPolls">—</div></div>
|
|
|
|
|
|
|
+ <div class="stat"><div class="k">Polls/s</div><div class="v" id="stPolls">—</div></div>
|
|
|
|
|
+ <div class="stat"><div class="k">Relay/s</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">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">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 logonly"><div class="k">Poll period act/want</div><div class="v" id="stPollPer">—</div></div>
|
|
|
<div class="stat"><div class="k">Build (git)</div><div class="v" id="stBuild">—</div></div>
|
|
<div class="stat"><div class="k">Build (git)</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 class="stat"><div class="k">Last reset</div><div class="v" id="stReset">—</div></div>
|
|
|
</div>
|
|
</div>
|
|
|
- <p class="note" style="margin-top:6px"><strong>Reset history</strong> (newest first, persists across power-off): <span id="resetHist" style="font-family:ui-monospace,Menlo,monospace">—</span><br>
|
|
|
|
|
- <em>watchdog</em>/<em>LOCKUP</em> = a fault (loop hang / crash); <em>soft reboot</em> = an intentional reset (mode change); <em>power-on</em> = normal plug-in.</p>
|
|
|
|
|
|
|
+ <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>
|
|
<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>
|
|
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>
|
|
<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" style="margin:10px 0 0"><button id="hapClear">Clear stuck buzz</button>
|
|
|
|
|
|
|
+ <div class="row" 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>
|
|
<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>
|
|
|
<div class="row" style="margin:10px 0 0"><button id="ctlrOff">Turn off controller</button>
|
|
<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>
|
|
<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>
|
|
|
- <div class="row" style="margin:10px 0 0;border-top:1px solid #272d3d;padding-top:14px">
|
|
|
|
|
- <button id="dfuSerial">Reboot to Serial DFU</button>
|
|
|
|
|
- <span class="note" style="flex:1">Reboots the puck into serial DFU mode (adafruit-nrfutil). The device drops off USB — flash with <code>adafruit-nrfutil dfu serial …</code> then replug.</span>
|
|
|
|
|
- </div>
|
|
|
|
|
- <div class="row" style="margin:10px 0 0">
|
|
|
|
|
- <button id="dfuUf2">Reboot to UF2 Bootloader</button>
|
|
|
|
|
- <span class="note" style="flex:1">Reboots the puck into the UF2 mass-storage bootloader. It mounts as a USB drive — drag the <code>.uf2</code> file onto it to flash, then it reboots automatically.</span>
|
|
|
|
|
- </div>
|
|
|
|
|
- <div class="row" style="margin:18px 0 0;border-top:1px solid #4a2030;padding-top:14px">
|
|
|
|
|
- <button id="factoryErase" style="background:#3a1420;border-color:#7a2233;color:#ff9bb0;font-weight:600">⚠ Factory erase</button>
|
|
|
|
|
- <span class="note" style="flex:1"><strong style="color:#ff9bb0">Danger:</strong> wipes <em>all</em> persistent storage on the copycat — the paired controller bond <em>and</em> every saved setting (mode, chords, back-paddle map, sensitivity). The copycat reboots to factory defaults and you must <strong>re-pair the controller</strong>. This cannot be undone.</span>
|
|
|
|
|
|
|
+ <div class="row" 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>
|
|
</div>
|
|
|
|
|
|
|
@@ -159,13 +168,35 @@
|
|
|
<p class="note">Gyro sensitivity multiplier for Switch Pro mode (1.0× = unchanged).</p>
|
|
<p class="note">Gyro sensitivity multiplier for Switch Pro mode (1.0× = unchanged).</p>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- <div class="card">
|
|
|
|
|
|
|
+ <div class="card wide">
|
|
|
<h2>Button mapping</h2>
|
|
<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>
|
|
<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 id="typeCfgs"></div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- <div class="card logonly">
|
|
|
|
|
|
|
+ <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">
|
|
|
|
|
+ <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 logonly">
|
|
|
<h2>Capture — host → controller commands</h2>
|
|
<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>
|
|
<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">
|
|
<div class="row" style="margin:0;flex-wrap:wrap">
|
|
@@ -176,6 +207,7 @@
|
|
|
<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>
|
|
<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>
|
|
<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>
|
|
|
|
|
+ </div><!-- .cards -->
|
|
|
|
|
|
|
|
<div id="log"></div>
|
|
<div id="log"></div>
|
|
|
</div>
|
|
</div>
|
|
@@ -184,7 +216,37 @@
|
|
|
<script>
|
|
<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)"];
|
|
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)"];
|
|
|
const CHORD_FIELD = [17, 18, 19];
|
|
const CHORD_FIELD = [17, 18, 19];
|
|
|
-let dev=null, epIn=0, epOut=0, ifNum=0, polling=false, capturing=false;
|
|
|
|
|
|
|
+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);
|
|
const $ = s=>document.querySelector(s);
|
|
|
function log(m){ const l=$("#log"); l.textContent=(new Date().toLocaleTimeString()+" "+m+"\n"+l.textContent).slice(0,2000); }
|
|
function log(m){ const l=$("#log"); l.textContent=(new Date().toLocaleTimeString()+" "+m+"\n"+l.textContent).slice(0,2000); }
|
|
|
|
|
|
|
@@ -313,9 +375,11 @@ function renderSlotTabs(bondedCount) {
|
|
|
updateSlotDisplay(g_activeSlot);
|
|
updateSlotDisplay(g_activeSlot);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-async function connect(){
|
|
|
|
|
|
|
+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{
|
|
try{
|
|
|
- dev = await navigator.usb.requestDevice({filters:[{vendorId:0x28DE},{vendorId:0x045E},{vendorId:0x057E},{vendorId:0x0F0D},{vendorId:0x054C}]});
|
|
|
|
|
|
|
+ dev=d;
|
|
|
await dev.open();
|
|
await dev.open();
|
|
|
if(dev.configuration===null) await dev.selectConfiguration(1);
|
|
if(dev.configuration===null) await dev.selectConfiguration(1);
|
|
|
let found=null;
|
|
let found=null;
|
|
@@ -327,42 +391,78 @@ async function connect(){
|
|
|
for(const e of a.endpoints){ if(e.type==="bulk"){ if(e.direction==="in")bin=e.endpointNumber; else bout=e.endpointNumber; } }
|
|
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(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"); return; }
|
|
|
|
|
|
|
+ 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;
|
|
ifNum=found.ifNum; epIn=found.epIn; epOut=found.epOut;
|
|
|
await dev.claimInterface(ifNum);
|
|
await dev.claimInterface(ifNum);
|
|
|
await dev.controlTransferOut({requestType:"class", recipient:"interface", request:0x22, value:0x01, index:ifNum});
|
|
await dev.controlTransferOut({requestType:"class", recipient:"interface", request:0x22, value:0x01, index:ifNum});
|
|
|
$("#connState").textContent="connected"; $("#connState").className="pill up";
|
|
$("#connState").textContent="connected"; $("#connState").className="pill up";
|
|
|
|
|
+ $("#connState").title = dev.serialNumber ? ("puck serial "+dev.serialNumber) : "";
|
|
|
$("#connectBtn").textContent="Reconnect";
|
|
$("#connectBtn").textContent="Reconnect";
|
|
|
$("#panel").classList.remove("hide");
|
|
$("#panel").classList.remove("hide");
|
|
|
- log(`connected — VID ${dev.vendorId.toString(16)} iface ${ifNum} epIn ${epIn} epOut ${epOut}`);
|
|
|
|
|
- navigator.usb.addEventListener("disconnect", e=>{ if(e.device===dev){ onGone(); }});
|
|
|
|
|
|
|
+ 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();
|
|
startPolling();
|
|
|
- }catch(e){ log("connect failed: "+e.message); }
|
|
|
|
|
|
|
+ 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(){
|
|
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; }
|
|
|
|
|
+ // queue a hang-log entry; the reason/PC/stack are filled from the first blob after reconnect
|
|
|
|
|
+ pendingHang = { t: Date.now(), uptimeSecs: up };
|
|
|
polling=false; dev=null; tabInited=false; // re-default the config tab to the mode on next connect
|
|
polling=false; dev=null; tabInited=false; // re-default the config tab to the mode on next connect
|
|
|
- $("#connState").textContent="disconnected"; $("#connState").className="pill dn";
|
|
|
|
|
|
|
+ $("#connState").textContent="reconnecting…"; $("#connState").className="pill dn";
|
|
|
$("#panel").classList.add("hide");
|
|
$("#panel").classList.add("hide");
|
|
|
- log("device disconnected (likely a mode-switch reboot — reconnect)");
|
|
|
|
|
|
|
+ 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){
|
|
async function send(bytes){
|
|
|
if(!dev) return;
|
|
if(!dev) return;
|
|
|
try{ await dev.transferOut(epOut, new Uint8Array(bytes)); }
|
|
try{ await dev.transferOut(epOut, new Uint8Array(bytes)); }
|
|
|
catch(e){ log("write err: "+e.message); }
|
|
catch(e){ log("write err: "+e.message); }
|
|
|
}
|
|
}
|
|
|
-async function readBlob(){
|
|
|
|
|
|
|
+// 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{
|
|
try{
|
|
|
- // v8 blob is 75 bytes (two USB-FS packets); read 128 to capture both in one call.
|
|
|
|
|
- const r = await dev.transferIn(epIn, 128);
|
|
|
|
|
|
|
+ // 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;
|
|
if(r.status!=="ok"||r.data.byteLength<2) return null;
|
|
|
const d=new Uint8Array(r.data.buffer);
|
|
const d=new Uint8Array(r.data.buffer);
|
|
|
- let i=0; while(i<d.length && d[i]!==0xA5) i++;
|
|
|
|
|
|
|
+ let i=0; while(i<d.length && d[i]!==marker) i++;
|
|
|
if(i+2>d.length) return null;
|
|
if(i+2>d.length) return null;
|
|
|
const len=d[i+1]; const p=d.slice(i+2, i+2+len);
|
|
const len=d[i+1]; const p=d.slice(i+2, i+2+len);
|
|
|
- return p.length>=12 ? p : null;
|
|
|
|
|
|
|
+ return p.length>=(minLen||0) ? p : null;
|
|
|
}catch(e){ if(dev) log("read err: "+e.message); return null; }
|
|
}catch(e){ if(dev) log("read err: "+e.message); return null; }
|
|
|
}
|
|
}
|
|
|
|
|
+async function readBlob(){ return readFrame(0xA5, 12); }
|
|
|
function applyBlob(p){
|
|
function applyBlob(p){
|
|
|
|
|
+ lastP=p; // remember for Export (carries every config field the panel shows)
|
|
|
const mode=p[1], mDiv=p[2], mFric=p[3]; // p[4..11] mirror the active type (per-type block below is authoritative)
|
|
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 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 newps=(p.length>16?(p[15]|(p[16]<<8)):0), persistMode=(p.length>22?p[22]:0);
|
|
@@ -437,39 +537,90 @@ function applyBlob(p){
|
|
|
// global stats
|
|
// global stats
|
|
|
$("#stRate").textContent = up? f1+" /s" : "—";
|
|
$("#stRate").textContent = up? f1+" /s" : "—";
|
|
|
$("#stNew").textContent = up? newps+" /s" : "—";
|
|
$("#stNew").textContent = up? newps+" /s" : "—";
|
|
|
- $("#stPolls").textContent = up? polls+" /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>';
|
|
|
|
|
+ // 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 {
|
|
|
|
|
+ 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" : "—";
|
|
$("#stLoop").textContent = loopUs? loopUs+" µs" : "—";
|
|
|
$("#stWorst").textContent = loopUs? (WORST[worstIdx]||worstIdx)+" "+worstUs+"µs" : "—";
|
|
$("#stWorst").textContent = loopUs? (WORST[worstIdx]||worstIdx)+" "+worstUs+"µs" : "—";
|
|
|
$("#stPollPer").textContent = up? (pollUs+" / "+pollIntended+" µs") : "—";
|
|
$("#stPollPer").textContent = up? (pollUs+" / "+pollIntended+" µs") : "—";
|
|
|
- // reset-cause history (protocol v9): JS p[73]=count, p[74..83]=last 10 causes (newest first).
|
|
|
|
|
- // Persisted in puck flash, so a reboot that happened on a Switch is still readable here after replugging.
|
|
|
|
|
- function resetReasonText(rr){
|
|
|
|
|
- if (rr === 0) return "power-on";
|
|
|
|
|
- const r = [];
|
|
|
|
|
- if (rr & 0x02) r.push("watchdog"); // DOG: loop wedge / hang
|
|
|
|
|
- if (rr & 0x08) r.push("LOCKUP"); // CPU lockup = HardFault escalation (real crash)
|
|
|
|
|
- if (rr & 0x04) r.push("soft reboot"); // SREQ: NVIC_SystemReset (mode change etc.)
|
|
|
|
|
- if (rr & 0x01) r.push("reset pin");
|
|
|
|
|
- return r.length ? r.join("+") : ("0x"+rr.toString(16));
|
|
|
|
|
- }
|
|
|
|
|
- if (p.length > 73) {
|
|
|
|
|
- const n = Math.min(p[73], 10);
|
|
|
|
|
- const hist = [];
|
|
|
|
|
- for (let i=0; i<n; i++) hist.push(p[74+i]);
|
|
|
|
|
- const fault = (rr)=>!!(rr & 0x0A); // watchdog or lockup = a real fault
|
|
|
|
|
- if (n === 0) { $("#stReset").textContent = "—"; }
|
|
|
|
|
- else {
|
|
|
|
|
- const newest = hist[0];
|
|
|
|
|
- const head = fault(newest) ? ('<span class="pill dn">'+resetReasonText(newest)+'</span>') : resetReasonText(newest);
|
|
|
|
|
- $("#stReset").innerHTML = head;
|
|
|
|
|
- // full history list under the tile (newest -> oldest)
|
|
|
|
|
- const items = hist.map((rr,i)=>{
|
|
|
|
|
- const t = resetReasonText(rr);
|
|
|
|
|
- return (i===0?'<strong>':'')+ '#'+(n-i)+' '+(fault(rr)?('<span style="color:#ff6b6b">'+t+'</span>'):t) +(i===0?' (latest)</strong>':'');
|
|
|
|
|
- });
|
|
|
|
|
- const el = $("#resetHist"); if (el) el.innerHTML = items.join(' · ');
|
|
|
|
|
|
|
+ // 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 });
|
|
|
|
|
+ pendingHang=null; renderHangLog();
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
const gitDirty=(p.length>38?p[38]:0); let gitHash="";
|
|
const gitDirty=(p.length>38?p[38]:0); let gitHash="";
|
|
|
for(let i=39;i<51 && i<p.length && p[i];i++) gitHash+=String.fromCharCode(p[i]);
|
|
for(let i=39;i<51 && i<p.length && p[i];i++) gitHash+=String.fromCharCode(p[i]);
|
|
|
$("#stBuild").innerHTML = gitHash
|
|
$("#stBuild").innerHTML = gitHash
|
|
@@ -509,10 +660,102 @@ function setSlider(id,v){ const el=$("#"+id); if(document.activeElement!==el){ e
|
|
|
|
|
|
|
|
let inflight=false;
|
|
let inflight=false;
|
|
|
async function refresh(){
|
|
async function refresh(){
|
|
|
- if(!dev||inflight||capturing) return; inflight=true;
|
|
|
|
|
|
|
+ if(!dev||inflight||capturing||backupBusy) return; inflight=true;
|
|
|
await send([0x01]); const p=await readBlob(); if(p) applyBlob(p);
|
|
await send([0x01]); const p=await readBlob(); if(p) applyBlob(p);
|
|
|
inflight=false;
|
|
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){
|
|
async function setField(field,value){
|
|
|
await send([0x02, field, value&0xff]); const p=await readBlob(); if(p) applyBlob(p);
|
|
await send([0x02, field, value&0xff]); const p=await readBlob(); if(p) applyBlob(p);
|
|
|
}
|
|
}
|
|
@@ -590,9 +833,27 @@ async function startPolling(){
|
|
|
|
|
|
|
|
// UI wiring
|
|
// UI wiring
|
|
|
$("#connectBtn").onclick=connect;
|
|
$("#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;
|
|
$("#capDl").onclick=downloadCap;
|
|
|
$("#capStart").onclick=startCapture;
|
|
$("#capStart").onclick=startCapture;
|
|
|
$("#capStop").onclick=stopCapture;
|
|
$("#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"); }
|
|
|
|
|
+ else { await send([0x0F,0]); stabStart=0; log("stability test stopped"); }
|
|
|
|
|
+ updateStabUI();
|
|
|
|
|
+};
|
|
|
|
|
+$("#hangClear").onclick=()=>{ hangLog=[]; renderHangLog(); };
|
|
|
|
|
+$("#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)"); } };
|
|
$("#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"); } };
|
|
$("#ctlrOff").onclick=async()=>{ if(dev){ await send([0x08]); log("controller power-off attempt sent — watch link status"); } };
|
|
|
$("#dfuSerial").onclick=async()=>{
|
|
$("#dfuSerial").onclick=async()=>{
|
|
@@ -615,6 +876,7 @@ $("#factoryErase").onclick=async()=>{
|
|
|
log("FACTORY ERASE sent — copycat is reformatting and rebooting to defaults. Re-pair the controller, then reconnect.");
|
|
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); };
|
|
$("#persistMode").onclick=()=>{ const on=$("#persistMode").classList.contains("active"); setField(16, on?0:1); };
|
|
|
|
|
+// Connection tuning: post-connect haptic block on (field 27) + seconds (field 28). (poll RX window is fixed.)
|
|
|
for(const id of ["mDiv","mFric","rumble"]){
|
|
for(const id of ["mDiv","mFric","rumble"]){
|
|
|
const field={mDiv:1,mFric:2,rumble:22}[id];
|
|
const field={mDiv:1,mFric:2,rumble:22}[id];
|
|
|
$("#"+id).addEventListener("input", ()=>{ $("#"+id+"V").textContent=fmtSlider(id,$("#"+id).value); });
|
|
$("#"+id).addEventListener("input", ()=>{ $("#"+id+"V").textContent=fmtSlider(id,$("#"+id).value); });
|
|
@@ -633,6 +895,7 @@ for(const b of document.querySelectorAll(".modebtn")){
|
|
|
if(confirm(msg)){ send([0x03, m]); log("mode switch requested — device will reboot"); } };
|
|
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>';
|
|
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>
|
|
</script>
|
|
|
</body>
|
|
</body>
|
|
|
</html>
|
|
</html>
|