Procházet zdrojové kódy

export/import configs

Jari před 3 měsíci
rodič
revize
45c04874f8
3 změnil soubory, kde provedl 248 přidání a 16 odebrání
  1. 86 8
      OpenPuck/webusb_config.cpp
  2. 42 1
      docs/PROTOCOL.md
  3. 120 7
      docs/index.html

+ 86 - 8
OpenPuck/webusb_config.cpp

@@ -18,6 +18,10 @@ Adafruit_USBD_WebUSB usb_web;
 // goes through the same blocking osd_queue_send path as HID sends, so doing it from loop() could stall the
 // loop and trip the watchdog under load -- with the panel open during heavy use that was a live risk.
 static volatile bool g_blobRequest = false;
+// Bond export ("clone" backup): a 0x09 panel command asks for a one-shot dump of all four bond slots so the
+// browser can save them to a file and restore them onto a second puck. Like the status blob, the actual send
+// is deferred to the usbd task (webusbSofDrain) so usb_web.write()/flush() never block loop().
+static volatile bool g_bondExportRequest = false;
 
 // blob payload = [ver=10][mode][mDiv][mFric][qamMap(active)][abSwap(active)][back0..3(active)][connSlot(0xFF=none)][linkUp]
 //                [f1ps_lo][f1ps_hi][pollU100][newps_lo][newps_hi][e7b][relayOp][relaySub][fwdNewOnly]
@@ -176,6 +180,36 @@ static void webusbSendBlob()
 	}
 }
 
+// Bond export frame (panel "Export config to file"). One self-describing frame carrying all four bond slots so
+// the browser can clone a puck onto another without re-pairing:
+//   [0xA7][len][ver=1][usedMask][slot0 rec 24]...[slot3 rec 24]   (len = 2 + NSLOT*24 = 98)
+// usedMask bit s = slot s bonded. The session RF address is NOT exported: each puck re-derives it from the bond
+// UUID + its own FICR DEVICEID at boot, and the bonded controller relearns it from the E1 host beacon on every
+// reconnect (rf_link.cpp) -- so the 24-byte record is the whole portable identity. 98+2 < transferIn(128) and
+// < CFG_TUD_VENDOR_TX_BUFSIZE (256), so it sends whole in one drop-on-full write like the status blob.
+#define WB_BOND_PAYLEN (2 + NSLOT * 24)
+static void webusbSendBondExport()
+{
+	if (!usb_web.connected())
+		return;
+	uint8_t p[2 + WB_BOND_PAYLEN];
+	p[0] = 0xA7;
+	p[1] = WB_BOND_PAYLEN;
+	p[2] = 1; // format version
+	uint8_t mask = 0;
+	for (int s = 0; s < NSLOT; s++) {
+		if (g_slot[s].used)
+			mask |= (uint8_t)(1u << s);
+		memcpy(&p[4 + s * 24], g_slot[s].rec, 24);
+	}
+	p[3] = mask;
+	// Same anti-hang rule as the status blob: drop the frame if the FIFO can't take it whole, never spin.
+	if (tud_vendor_write_available() >= sizeof p) {
+		usb_web.write(p, sizeof p);
+		usb_web.flush();
+	}
+}
+
 // Runs on the usbd task (registered via usbTxRegisterDrain -> tud_sof_cb). Sends the blob if loop() asked for
 // one. Keeps every usb_web write/flush off the loop task so it can't block on the device event queue.
 static void webusbSofDrain(void)
@@ -184,6 +218,10 @@ static void webusbSofDrain(void)
 		g_blobRequest = false;
 		webusbSendBlob();
 	}
+	if (g_bondExportRequest) {
+		g_bondExportRequest = false;
+		webusbSendBondExport();
+	}
 }
 // Register the SOF drain. Call once from setup() (harmless even if WebUSB isn't enumerated -- webusbSendBlob
 // no-ops while disconnected, and g_blobRequest is only ever set by panel traffic, which needs a connection).
@@ -244,7 +282,9 @@ static void webusbDrainCapture()
 #endif // OPK_LOG
 void webusbPoll()
 {
-	static uint8_t buf[16];
+	// 40 B holds the largest command: 0x0D writes one bond slot = [0x0D][slot][used][24 rec] = 27 B (still
+	// inside one 64-B USB-FS OUT packet, so no command ever spans packets). Every other command is <= 4 B.
+	static uint8_t buf[40];
 	static uint8_t n = 0;
 	while (usb_web.available()) {
 		int c = usb_web.read();
@@ -257,22 +297,28 @@ void webusbPoll()
 			if (n == 0)
 				break;
 			uint8_t op = buf[0];
-
-			// 0x05 carries a value byte; 0x0A carries a 3-byte magic
-			uint8_t need = (op == 0x02)		  ? 3 :
-				       (op == 0x03 || op == 0x05) ? 2 :
-				       (op == 0x0A)		  ? 4 :
-								    1;
-			if (op < 0x01 || op > 0x0C) { // resync: drop one byte
+			if (op < 0x01 || op > 0x0E) { // resync: drop one byte
 				memmove(buf, buf + 1, --n);
 				continue;
 			}
+			// Command length (fixed per opcode). 0x0D = write-one-bond-slot (27 B); 0x05/0x0E carry one
+			// value byte; 0x02 a field+value; 0x0A a 3-byte magic.
+			uint8_t need = (op == 0x0D)		      ? 27 :
+				       (op == 0x02)		      ? 3 :
+				       (op == 0x03 || op == 0x05 ||
+					op == 0x0E)		      ? 2 :
+				       (op == 0x0A)		      ? 4 :
+								        1;
 			if (n < need)
 				break; // wait for more bytes
 			if (op == 0x01) {
 				g_blobRequest =
 					true; // sent from the usbd task (webusbSofDrain)
 			}
+			// 0x09: export all bond slots (clone backup). One 0xA7 frame, sent from the usbd task.
+			else if (op == 0x09) {
+				g_bondExportRequest = true;
+			}
 #if OPK_LOG
 
 			// rewind drain: buf[1]=1 from boot (whole ring), 0 from now
@@ -320,6 +366,38 @@ void webusbPoll()
 				faultDiagArmIntentionalReset();
 				enterUf2Dfu();
 
+				// 0x0D: write ONE bond slot into RAM -- the "clone onto this puck" side of Export/Import.
+				// [0x0D][slot][used][24 rec]. used=0 (or an empty record) clears the slot. The panel sends
+				// one of these per slot, then a 0x0E to persist + reboot. Kept to a single small command
+				// (<=27 B) so it never spans a USB packet, and acked with a status blob so the panel's
+				// read-after-write keeps the OUT pipe flowing (same shape as the proven 0x02 setters).
+			} else if (op == 0x0D) {
+				uint8_t slot = buf[1], used = buf[2];
+				if (slot < NSLOT) {
+					if (used && !recEmpty(buf + 3)) {
+						memcpy(g_slot[slot].rec, buf + 3,
+						       24);
+						g_slot[slot].used = true;
+					} else {
+						memset(g_slot[slot].rec, 0, 24);
+						g_slot[slot].used = false;
+					}
+				}
+				g_blobRequest = true; // ack
+
+				// 0x0E: commit an import -- persist the bond slots written by 0x0D, optionally apply a USB
+				// mode ([0x0E][mode]; 0xFF = leave unchanged), then reboot so the RF session addresses
+				// regenerate from the new bond UUIDs.
+			} else if (op == 0x0E) {
+				uint8_t mode = buf[1];
+				saveBonds();
+				if (modeValid(mode))
+					saveMode(mode);
+				usb_web.flush();
+				delay(40);
+				faultDiagArmIntentionalReset();
+				NVIC_SystemReset();
+
 			} else if (op == 0x02) {
 				uint8_t f = buf[1], v = buf[2];
 

+ 42 - 1
docs/PROTOCOL.md

@@ -370,8 +370,16 @@ Messages:
   - `0x01`: get status blob
   - `0x02 <field> <value>`: set one field
   - `0x03 <mode>`: switch mode and reboot
+  - `0x07`: re-init haptics (clear a stuck buzz)
+  - `0x08`: send controller power-off
+  - `0x09`: export all bond slots (reply: `0xA7` frame) — see §10.1
+  - `0x0A 0x45 0x52 0x53`: factory erase (`"ERS"` magic), then reboot
+  - `0x0B` / `0x0C`: reboot into serial DFU / UF2 bootloader
+  - `0x0D <slot> <used> <24-byte rec>`: write one bond slot into RAM — see §10.1
+  - `0x0E <mode>`: commit imported bonds (+ apply mode, `0xFF` = unchanged), then reboot
 - Device to host:
-  - `0xA5 <len> <payload>`
+  - `0xA5 <len> <payload>`: status blob
+  - `0xA7 <len> <payload>`: bond export (§10.1)
 
 Status blob payload:
 
@@ -401,6 +409,39 @@ Status blob payload:
 [22] persist-mode flag
 ```
 
+### 10.1 Backup / clone (bond export & import)
+
+A puck's whole portable identity is its four 24-byte bond records (`[proteus_uuid 4][ibex_uuid 4][serial 16]`,
+§2). The per-bond on-air *session address* is **not** stored or transferred: each puck re-derives it from the
+bond UUID mixed with its own FICR `DEVICEID`, and the bonded controller relearns it from the `E1` host beacon on
+every reconnect. So copying the bond records onto a second puck is sufficient for any controller paired to the
+first to connect to the second **with no re-pairing** — the two pucks just derive different (non-colliding)
+session addresses for the same controller.
+
+This makes a puck cloneable. The WebUSB panel exposes it as **Export to file** / **Import from file**.
+
+**Export — `0x09` → `0xA7` frame:**
+
+```text
+[0]    format version (1)
+[1]    used mask (bit s = slot s bonded)
+[2..]  slot 0..3 records, 24 bytes each (96 bytes)        (len = 2 + 4*24 = 98)
+```
+
+**Import — `0x0D` (per slot) then `0x0E` (commit):**
+
+```text
+0x0D  <slot 0..3>  <used 0|1>  <24-byte record>      writes one slot into RAM (used=0 clears it)
+0x0E  <mode>                                         persist all slots, apply mode (0xFF=unchanged), reboot
+```
+
+Bonds are written one slot at a time so no command exceeds a single USB-FS packet, and each `0x0D`/`0x02` is
+acked with a status blob so the host's read-after-write keeps the OUT pipe flowing. The panel sends all four
+`0x0D` slot writes plus every config field via `0x02`, then a single `0x0E` to persist and reboot — so the
+reboot regenerates the session addresses from the new UUIDs and the restored puck reproduces both the pairings
+and all settings. Other settings (mouse, rumble, chords, per-type button maps, Switch Pro motion) ride in the
+backup file as plain `0x02` field values, not in the bond commands.
+
 ## 11. Timing notes
 
 - Default poll interval: `4000` microseconds (250 Hz, matches SC2 report rate)

+ 120 - 7
docs/index.html

@@ -81,6 +81,17 @@
       </div>
     </div>
 
+    <div class="card">
+      <h2>Backup &amp; 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">
       <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>
@@ -196,7 +207,8 @@
 <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 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);
 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.controlTransferOut({requestType:"class", recipient:"interface", request:0x22, value:0x01, index:ifNum});
     $("#connState").textContent="connected"; $("#connState").className="pill up";
+    $("#connState").title = dev.serialNumber ? ("puck serial "+dev.serialNumber) : "";
     $("#connectBtn").textContent="Reconnect";
     $("#panel").classList.remove("hide");
-    log(`connected — 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(); }});
     startPolling();
   }catch(e){ log("connect failed: "+e.message); }
@@ -362,19 +375,23 @@ async function send(bytes){
   try{ await dev.transferOut(epOut, new Uint8Array(bytes)); }
   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{
-    // 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);
     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]!==0xA5) i++;
+    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);
-    return p.length>=12 ? p : null;
+    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)
   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);
@@ -516,10 +533,103 @@ function setSlider(id,v){ const el=$("#"+id); if(document.activeElement!==el){ e
 
 let inflight=false;
 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);
   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){
   await send([0x02, field, value&0xff]); const p=await readBlob(); if(p) applyBlob(p);
 }
@@ -597,6 +707,9 @@ async function startPolling(){
 
 // UI wiring
 $("#connectBtn").onclick=connect;
+$("#backupExport").onclick=exportBackup;
+$("#backupImport").onclick=()=>$("#backupFile").click();
+$("#backupFile").onchange=async(e)=>{ const f=e.target.files[0]; if(f) await importBackup(f); e.target.value=""; };
 $("#capDl").onclick=downloadCap;
 $("#capStart").onclick=startCapture;
 $("#capStop").onclick=stopCapture;