|
@@ -81,6 +81,17 @@
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
|
|
+ <div class="card">
|
|
|
|
|
+ <h2>Backup & clone</h2>
|
|
|
|
|
+ <p class="note" style="margin-top:0">Save this puck's controller pairings <em>and</em> every setting to a file, then restore them onto another puck. The other puck becomes a clone — any controller paired to this one connects to it with <strong>no re-pairing</strong>. (Also a plain backup: re-import after a factory erase to get everything back.)</p>
|
|
|
|
|
+ <div class="row" style="margin:0;flex-wrap:wrap">
|
|
|
|
|
+ <button id="backupExport" class="primary">Export to file…</button>
|
|
|
|
|
+ <button id="backupImport">Import from file…</button>
|
|
|
|
|
+ <input type="file" id="backupFile" accept="application/json,.json" style="display:none">
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <p class="note">Import <strong>overwrites</strong> all pairings and settings on the connected puck, then reboots it (~2 s — reconnect after). Tip: pair every controller to one puck, export once, then import onto each spare.</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
<div class="card">
|
|
<div class="card">
|
|
|
<h2>Back4 + face button chords</h2>
|
|
<h2>Back4 + face button chords</h2>
|
|
|
<p class="note" style="margin-top:0">Hold all four back paddles (L4+R4+L5+R5) plus a face button to switch mode without the WebUI. <strong>A is always Steam.</strong> B / X / Y are configurable below (defaults: B=Lizard, X=Xbox, Y=Switch HORIPAD).</p>
|
|
<p class="note" style="margin-top:0">Hold all four back paddles (L4+R4+L5+R5) plus a face button to switch mode without the WebUI. <strong>A is always Steam.</strong> B / X / Y are configurable below (defaults: B=Lizard, X=Xbox, Y=Switch HORIPAD).</p>
|
|
@@ -196,7 +207,8 @@
|
|
|
<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)
|
|
|
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); }
|
|
|
|
|
|
|
@@ -344,9 +356,10 @@ async function connect(){
|
|
|
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}`);
|
|
|
|
|
|
|
+ log(`connected — puck ${dev.serialNumber||"?"} (VID ${dev.vendorId.toString(16)} iface ${ifNum})`);
|
|
|
navigator.usb.addEventListener("disconnect", e=>{ if(e.device===dev){ onGone(); }});
|
|
navigator.usb.addEventListener("disconnect", e=>{ if(e.device===dev){ onGone(); }});
|
|
|
startPolling();
|
|
startPolling();
|
|
|
}catch(e){ log("connect failed: "+e.message); }
|
|
}catch(e){ log("connect failed: "+e.message); }
|
|
@@ -362,19 +375,23 @@ async function send(bytes){
|
|
|
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.
|
|
|
|
|
|
|
+ // largest frame (status blob) spans two USB-FS packets; read 128 to capture both in one call.
|
|
|
const r = await dev.transferIn(epIn, 128);
|
|
const r = await dev.transferIn(epIn, 128);
|
|
|
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);
|
|
@@ -516,10 +533,103 @@ 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], hapBlockOn:p[111], hapBlockS:p[112], 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(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)");
|
|
|
|
|
+ // 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);
|
|
|
}
|
|
}
|
|
@@ -597,6 +707,9 @@ 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;
|