|
|
@@ -107,7 +107,11 @@
|
|
|
<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">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 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>
|
|
|
@@ -119,17 +123,6 @@
|
|
|
<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">
|
|
|
- <strong style="flex-basis:100%">Connection tuning <span class="note">(persists across reboot)</span></strong>
|
|
|
- </div>
|
|
|
- <div class="row" style="margin:8px 0 0"><label style="min-width:150px">Block haptics on connect</label>
|
|
|
- <button id="hapBlockOn">off</button>
|
|
|
- <span class="note" style="flex:1">Drops all Steam haptics for a short window right after a controller (re)connects, so its haptic engine settles before the first real haptic.</span>
|
|
|
- </div>
|
|
|
- <div class="row" style="margin:8px 0 0"><label style="min-width:150px">Block duration</label>
|
|
|
- <input type="range" id="hapBlockS" min="0" max="30" step="1"><span class="val" id="hapBlockSV"></span>
|
|
|
- <span class="note" style="flex:1">How long to drop haptics after connect (seconds). Default 10 s.</span>
|
|
|
- </div>
|
|
|
<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>
|
|
|
</div>
|
|
|
@@ -337,9 +330,11 @@ function renderSlotTabs(bondedCount) {
|
|
|
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{
|
|
|
- dev = await navigator.usb.requestDevice({filters:[{vendorId:0x28DE},{vendorId:0x045E},{vendorId:0x057E},{vendorId:0x0F0D},{vendorId:0x054C}]});
|
|
|
+ dev=d;
|
|
|
await dev.open();
|
|
|
if(dev.configuration===null) await dev.selectConfiguration(1);
|
|
|
let found=null;
|
|
|
@@ -351,7 +346,7 @@ async function connect(){
|
|
|
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"); 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;
|
|
|
await dev.claimInterface(ifNum);
|
|
|
await dev.controlTransferOut({requestType:"class", recipient:"interface", request:0x22, value:0x01, index:ifNum});
|
|
|
@@ -360,16 +355,38 @@ async function connect(){
|
|
|
$("#connectBtn").textContent="Reconnect";
|
|
|
$("#panel").classList.remove("hide");
|
|
|
log(`connected — puck ${dev.serialNumber||"?"} (VID ${dev.vendorId.toString(16)} iface ${ifNum})`);
|
|
|
- navigator.usb.addEventListener("disconnect", e=>{ if(e.device===dev){ onGone(); }});
|
|
|
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(){
|
|
|
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");
|
|
|
- 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){
|
|
|
if(!dev) return;
|
|
|
try{ await dev.transferOut(epOut, new Uint8Array(bytes)); }
|
|
|
@@ -379,8 +396,8 @@ async function send(bytes){
|
|
|
// 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 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;
|
|
|
const d=new Uint8Array(r.data.buffer);
|
|
|
let i=0; while(i<d.length && d[i]!==marker) i++;
|
|
|
@@ -470,7 +487,41 @@ function applyBlob(p){
|
|
|
// 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);
|
|
|
- $("#stRfFail").textContent = crc+" · "+norx+" · "+heal; }
|
|
|
+ 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>';
|
|
|
+ }
|
|
|
+ }
|
|
|
$("#stLoop").textContent = loopUs? loopUs+" µs" : "—";
|
|
|
$("#stWorst").textContent = loopUs? (WORST[worstIdx]||worstIdx)+" "+worstUs+"µs" : "—";
|
|
|
$("#stPollPer").textContent = up? (pollUs+" / "+pollIntended+" µs") : "—";
|
|
|
@@ -484,16 +535,17 @@ function applyBlob(p){
|
|
|
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);
|
|
|
- $("#stReset").innerHTML = RR_FAULT.has(code) ? ('<span class="pill dn">'+name+'</span>') : name;
|
|
|
+ // 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;
|
|
|
const el = $("#resetHist");
|
|
|
- if (el) el.textContent = "RESETREAS=0x"+raw.toString(16).padStart(8,"0");
|
|
|
+ if (el) el.textContent = "RESETREAS=0x"+raw.toString(16).padStart(8,"0")
|
|
|
+ + (hangStage!==0xFF ? " hung in: "+(STAGE_NAMES[hangStage]||hangStage) : "");
|
|
|
}
|
|
|
|
|
|
- // Connection tuning: haptic-block enable (p[111]), block seconds (p[112]). (poll RX window p[110] is now
|
|
|
- // fixed in firmware and no longer shown.)
|
|
|
- const hapBlockOn = (p.length>111?p[111]:0);
|
|
|
- $("#hapBlockOn").textContent = hapBlockOn?"on":"off"; $("#hapBlockOn").classList.toggle("active",!!hapBlockOn);
|
|
|
- if (p.length>112) setSlider("hapBlockS", p[112]);
|
|
|
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]);
|
|
|
$("#stBuild").innerHTML = gitHash
|
|
|
@@ -528,7 +580,7 @@ function applyBlob(p){
|
|
|
{ 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 fmtSlider(id,v){ return id==="rumble" ? v+"%" : ""+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;
|
|
|
@@ -553,7 +605,7 @@ function buildBackup(p, bp){
|
|
|
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], hapBlockOn:p[111], hapBlockS:p[112], types } };
|
|
|
+ rumble:p[51], swProRate:p[52], swGyro10:p[53], types } };
|
|
|
}
|
|
|
function downloadBackup(obj){
|
|
|
const ts=new Date().toISOString().slice(0,19).replace(/[:T]/g,"-");
|
|
|
@@ -608,13 +660,12 @@ async function importBackup(file){
|
|
|
// 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(27,c.hapBlockOn?1:0); await sf(28,c.hapBlockS);
|
|
|
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 ("+(11+c.types.length*8)+" fields)");
|
|
|
+ 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++){
|
|
|
@@ -736,9 +787,8 @@ $("#factoryErase").onclick=async()=>{
|
|
|
};
|
|
|
$("#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.)
|
|
|
-$("#hapBlockOn").onclick=()=>{ const on=$("#hapBlockOn").classList.contains("active"); setField(27, on?0:1); };
|
|
|
-for(const id of ["mDiv","mFric","rumble","hapBlockS"]){
|
|
|
- const field={mDiv:1,mFric:2,rumble:22,hapBlockS:28}[id];
|
|
|
+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));
|
|
|
}
|
|
|
@@ -755,6 +805,7 @@ for(const b of document.querySelectorAll(".modebtn")){
|
|
|
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>
|