|
|
@@ -65,6 +65,7 @@
|
|
|
<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>
|
|
|
@@ -107,19 +108,22 @@
|
|
|
<!-- 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 -->
|
|
|
+ <!-- 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 -->
|
|
|
+ <!-- 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</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">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">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>
|
|
|
@@ -136,13 +140,16 @@
|
|
|
<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" style="margin:14px 0 0;border-top:1px solid #272d3d;padding-top:14px"><button id="hapClear">Clear stuck buzz</button>
|
|
|
+ <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" style="margin:10px 0 0;border-top:1px solid #272d3d;padding-top:14px"><button id="stabBtn">Test stability</button>
|
|
|
+ <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>
|
|
|
@@ -186,7 +193,7 @@
|
|
|
<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">
|
|
|
+ <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">
|
|
|
@@ -197,6 +204,26 @@
|
|
|
<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>
|
|
|
@@ -249,8 +276,36 @@ function updateStabUI(){
|
|
|
}
|
|
|
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 + ...].
|
|
|
@@ -350,10 +405,19 @@ let g_slotData = []; // [{up, battery, rssi}] indexed by slot 0..3, only set for
|
|
|
|
|
|
function updateSlotDisplay(slotIdx) {
|
|
|
const d = g_slotData[slotIdx];
|
|
|
- if (!d) { $("#stLink").innerHTML="—"; $("#stBatt").textContent="—"; $("#stRssi").textContent="—"; return; }
|
|
|
+ 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) {
|
|
|
@@ -428,8 +492,20 @@ function onGone(){
|
|
|
// (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");
|
|
|
@@ -455,15 +531,26 @@ async function readFrame(marker, minLen){
|
|
|
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]; const p=d.slice(i+2, i+2+len);
|
|
|
+ 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);
|
|
|
@@ -526,6 +613,16 @@ function applyBlob(p){
|
|
|
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
|
|
|
@@ -566,6 +663,7 @@ function applyBlob(p){
|
|
|
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){
|
|
|
@@ -573,6 +671,7 @@ function applyBlob(p){
|
|
|
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>';
|
|
|
}
|
|
|
@@ -618,8 +717,26 @@ function applyBlob(p){
|
|
|
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 gitHash="";
|
|
|
@@ -629,6 +746,8 @@ function applyBlob(p){
|
|
|
: "—";
|
|
|
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){
|
|
|
@@ -661,9 +780,12 @@ function setSlider(id,v){ const el=$("#"+id); if(document.activeElement!==el){ e
|
|
|
|
|
|
let inflight=false;
|
|
|
async function refresh(){
|
|
|
- if(!dev||inflight||capturing||backupBusy) return; inflight=true;
|
|
|
- await send([0x01]); const p=await readBlob(); if(p) applyBlob(p);
|
|
|
- inflight=false;
|
|
|
+ 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 ----
|
|
|
@@ -811,6 +933,109 @@ async function drainTick(){
|
|
|
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=[];
|
|
|
@@ -829,7 +1054,10 @@ async function stopCapture(){
|
|
|
}
|
|
|
async function startPolling(){
|
|
|
polling=true; await refresh();
|
|
|
- while(polling && dev){ await new Promise(r=>setTimeout(r,600)); await refresh(); }
|
|
|
+ while(polling && dev){
|
|
|
+ await new Promise(r=>setTimeout(r,600)); await refresh();
|
|
|
+ if(window._autoFlight){ window._autoFlight=false; await loadFlightTrail(); }
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
// UI wiring
|
|
|
@@ -843,11 +1071,12 @@ $("#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"); }
|
|
|
+ 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]));
|
|
|
@@ -856,7 +1085,13 @@ $("#hangCsv").onclick=()=>{
|
|
|
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"); } };
|
|
|
+$("#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;
|
|
|
@@ -877,6 +1112,7 @@ $("#factoryErase").onclick=async()=>{
|
|
|
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];
|