فهرست منبع

Merge pull request #18 from safijari/new-controllers

Implement DS4, DS5, Switch Pro (not working?), always lizard, and fix some polling rate issues
Jari 4 ماه پیش
والد
کامیت
5296031cab
3فایلهای تغییر یافته به همراه664 افزوده شده و 163 حذف شده
  1. 610 131
      OpenPuck/OpenPuck.ino
  2. 16 3
      docs/PROTOCOL.md
  3. 38 29
      docs/index.html

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 610 - 131
OpenPuck/OpenPuck.ino


+ 16 - 3
docs/PROTOCOL.md

@@ -286,11 +286,24 @@ does a `detach -> rebuild -> attach` so the host re-reads the descriptor cleanly
 - VID:PID `28DE:1304`
 - Four puck HID interfaces (CDC + WebUSB also present)
 - `0x45` reports are forwarded to the connected slot's HID interface
-- **Seamless lizard**: when Steam's `0x87` heartbeat is alive, `0x45` is forwarded; when it stops
+- **Seamless lizard**: when Steam is driving the device, `0x45` is forwarded; when it isn't
   (Steam closed, 7 s watchdog) the same `0x45` is translated into mouse (`0x40`) + keyboard (`0x41`)
   reports on the **same** puck interface, so the device is a driverless desktop keyboard+mouse with no
   mode switch. This is purely USB-side; the RF poll and relay are unchanged. (There is no standalone
   lizard mode.)
+- **"Steam is driving" signal**: *any* Steam OUTPUT/settings report (`0x80`–`0x89`, e.g. the `0x87`
+  lizard-off heartbeat, LED, or a `0x82` haptic) refreshes the watchdog — not just `0x87`. This makes
+  the puck leave lizard for gamepad on Steam's **first** contact, even if that first packet is a haptic
+  that arrives before the heartbeat.
+- **Haptics are gated on this decision**: haptic reports are **never** relayed to the controller while
+  the puck is presenting lizard. Relaying haptics while Steam isn't reading `0x45` back made Steam loop
+  the same command, leaving the controller buzzing; suppressing them keeps the lizard state clean.
+- **Which OUTPUT reports are relayed**: the haptic/actuator reports `0x80`–`0x86` are forwarded to the
+  **connected slot only** (the slot gate is what stops a haptic aimed at another of the four exposed
+  slots from buzzing the single controller). The 63-byte settings/config reports `0x87`/`0x88`/`0x89`
+  are not haptics and are not pushed on this path (`0x87` lizard-off reaches the controller via the
+  feature `0x01` passthrough). Restricting this to `0x82` alone silently dropped the ping/grip/test
+  haptics, which use other report IDs.
 
 ### 9.2 Xbox mode
 
@@ -302,7 +315,7 @@ does a `detach -> rebuild -> attach` so the host re-reads the descriptor cleanly
 
 - VID:PID `0F0D:0092` (HORI Pokkén Tournament Pro Pad), clean device (no CDC/WebUSB)
 - Single HID interface with the canonical HORIPAD descriptor (interrupt IN + OUT endpoints), accepted by
-  a real Switch console with no handshake; an 8-byte report is streamed at ~125 Hz
+  a real Switch console with no handshake; an 8-byte report is streamed at ~250 Hz
 
 ## 10. WebUSB control channel
 
@@ -348,7 +361,7 @@ Status blob payload:
 
 ## 11. Timing notes
 
-- Default poll interval: `800` microseconds
+- Default poll interval: `4000` microseconds (250 Hz, matches SC2 report rate)
 - Default RX window: `1200` microseconds
 - Discovery beacon continues on channel 2 even after a connected session exists
 - Connected session can be moved to a cleaner channel such as 18 or 52

+ 38 - 29
docs/index.html

@@ -51,18 +51,30 @@
   <div id="panel" class="hide">
     <div class="card">
       <h2>USB mode</h2>
-      <div class="modes">
+      <div class="modes" style="flex-wrap:wrap">
         <button data-mode="0" class="modebtn">Steam (puck)</button>
         <button data-mode="1" class="modebtn">Xbox 360</button>
-        <button data-mode="2" class="modebtn">Switch Pro</button>
+        <button data-mode="2" class="modebtn">Switch (HORIPAD)</button>
+        <button data-mode="3" class="modebtn">Lizard (always)</button>
+        <button data-mode="4" class="modebtn">Switch Pro + gyro</button>
+        <button data-mode="5" class="modebtn">PS5 DualSense</button>
+        <button data-mode="6" class="modebtn">HID gyro (DS4)</button>
       </div>
-      <p class="note">Switching reboots the copycat and re-enumerates USB (~2 s). The RF link reconnects automatically. Desktop keyboard+mouse (lizard) is automatic in Steam mode whenever Steam is closed — no separate mode.</p>
+      <p class="note">Switching reboots the copycat and re-enumerates USB (~2 s). WebUSB works in every mode, including Steam/Lizard with Steam running (Chrome claims the vendor interface; Steam keeps the HID slots). Switch HORIPAD + WebUSB is PC-only.</p>
       <div class="row"><label>Persist last mode</label>
         <button id="persistMode">off</button>
         <span class="note" style="flex:1">Off (default): every restart / fresh reconnect boots into Steam mode. On: remembers the last mode you selected.</span>
       </div>
     </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>
+      <div class="row"><label>B + back4 →</label><select class="chord" data-i="0"></select></div>
+      <div class="row"><label>X + back4 →</label><select class="chord" data-i="1"></select></div>
+      <div class="row"><label>Y + back4 →</label><select class="chord" data-i="2"></select></div>
+    </div>
+
     <div class="card">
       <h2>Link status</h2>
       <div class="statgrid">
@@ -73,12 +85,6 @@
       </div>
     </div>
 
-    <div class="card">
-      <h2>RF poll rate</h2>
-      <div class="row"><label>Poll interval</label><input type="range" id="pollUs" min="1" max="120" step="1"><span class="val" id="pollUsV"></span></div>
-      <p class="note">Microseconds between RF polls (×100). Lower = poll harder = more fresh reports/s (peaks ~60/s near 700&nbsp;µs). Too aggressive can collapse the reply rate during heavy trackpad use — watch the “New reports” stat and trackpad feel as you tune.</p>
-    </div>
-
     <div class="card" id="mouseCard">
       <h2>Trackpad mouse (lizard + Xbox)</h2>
       <div class="row"><label>Sensitivity</label><input type="range" id="mDiv" min="4" max="200" step="1"><span class="val" id="mDivV"></span></div>
@@ -86,11 +92,6 @@
       <p class="note">Right-pad cursor in Steam-mode lizard (Steam closed) and in Xbox mode. Sensitivity is a divisor — lower = faster pointer. Glide = how long the cursor coasts after a flick.</p>
     </div>
 
-    <div class="card" id="steamCard">
-      <h2>Steam-mode trackpad smoothing</h2>
-      <div class="row"><label>Smoothing</label><input type="range" id="padSmooth" min="5" max="100" step="1"><span class="val" id="padSmoothV"></span></div>
-      <p class="note">Interpolation alpha. 100 = off (raw 46 Hz capsense), lower = smoother but slightly laggier.</p>
-    </div>
 
     <div class="card">
       <h2>Button mapping</h2>
@@ -110,32 +111,37 @@
 </div>
 
 <script>
-const MODE_NAMES = ["Steam (puck)","Xbox 360","Switch Pro"];
+const MODE_NAMES = ["Steam (puck)","Xbox 360","Switch (HORIPAD)","Lizard (always)","Switch Pro + gyro","PS5 DualSense","HID gyro (DS4)"];
+const CHORD_FIELD = [17, 18, 19];
 const BTN_CODES = [[0,"— none —"],[1,"A"],[2,"B"],[3,"X"],[4,"Y"],[5,"LB"],[6,"RB"],[7,"L3"],[8,"R3"],[9,"Back"],[10,"Start"],[11,"Guide"]];
 let dev=null, epIn=0, epOut=0, ifNum=0, polling=false;
 const $ = s=>document.querySelector(s);
 function log(m){ const l=$("#log"); l.textContent=(new Date().toLocaleTimeString()+"  "+m+"\n"+l.textContent).slice(0,2000); }
 
-// populate back-paddle dropdowns
+// populate back-paddle + chord dropdowns
 for(const sel of document.querySelectorAll("select.back")){
   for(const [c,n] of BTN_CODES){ const o=document.createElement("option"); o.value=c; o.textContent=n; sel.appendChild(o); }
 }
+for(const sel of document.querySelectorAll("select.chord")){
+  MODE_NAMES.forEach((n,i)=>{ const o=document.createElement("option"); o.value=i; o.textContent=n; sel.appendChild(o); });
+}
 
 async function connect(){
   try{
-    dev = await navigator.usb.requestDevice({filters:[{vendorId:0x28DE},{vendorId:0x045E},{vendorId:0x057E},{vendorId:0x0F0D}]});
+    dev = await navigator.usb.requestDevice({filters:[{vendorId:0x28DE},{vendorId:0x045E},{vendorId:0x057E},{vendorId:0x0F0D},{vendorId:0x054C}]});
     await dev.open();
     if(dev.configuration===null) await dev.selectConfiguration(1);
-    // find the WebUSB vendor interface: class 0xFF with one bulk-IN + one bulk-OUT (XInput is 0xFF but interrupt; CDC data is 0x0A)
+    // WebUSB = vendor class 0xFF with bulk IN + bulk OUT (skip XInput 0xFF/5D/01 which uses interrupt only)
     let found=null;
     for(const itf of dev.configuration.interfaces){
       const a=itf.alternate;
       if(a.interfaceClass!==0xFF) continue;
+      if(a.interfaceSubClass===0x5D) continue;   // Xbox XInput vendor
       let bin=0,bout=0;
       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 interface found on this device"); return; }
+    if(!found){ log("no WebUSB bulk vendor interface — reflash firmware with WebUSB enabled, or another app may be holding the device"); return; }
     ifNum=found.ifNum; epIn=found.epIn; epOut=found.epOut;
     await dev.claimInterface(ifNum);
     // WebUSB "connect" handshake: Adafruit_USBD_WebUSB only enables write() / connected()
@@ -174,27 +180,27 @@ async function readBlob(){
   }catch(e){ if(dev) log("read err: "+e.message); return null; }
 }
 function applyBlob(p){
-  // [ver][mode][mDiv][mFric][padSmooth][abSwap][b0..b3][slot][up][f1lo][f1hi][pollU100][newlo][newhi]
-  const mode=p[1], mDiv=p[2], mFric=p[3], padSmooth=p[4], abSwap=p[5];
+  // [ver][mode][mDiv][mFric][rsvd][abSwap][b0..b3][slot][up][f1lo][f1hi][pollU100(unused)][newlo][newhi]
+  const mode=p[1], mDiv=p[2], mFric=p[3], abSwap=p[5];   // p[4] reserved (ex-padSmooth)
   const back=[p[6],p[7],p[8],p[9]], slot=p[10], up=p[11], f1=(p[12]|(p[13]<<8));
-  const pollU100=p[14], 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);
+  const chord=(p.length>25)?[p[23],p[24],p[25]]:[3,1,2];   // v2 blob: chordBtn after qos/persist
   // mode buttons
   for(const b of document.querySelectorAll(".modebtn")) b.classList.toggle("active", +b.dataset.mode===mode);
-  $("#mouseCard").style.opacity = mode===1?1:0.5;
-  $("#steamCard").style.opacity = mode===0?1:0.5;
+  $("#mouseCard").style.opacity = (mode===1||mode===3)?1:0.5;
   // status
   $("#stLink").innerHTML = up? '<span class="pill up">up</span>' : '<span class="pill dn">down</span>';
   $("#stSlot").textContent = slot===0xFF? "—" : slot;
   $("#stRate").textContent = up? f1+" /s" : "—";
   $("#stNew").textContent = up? newps+" /s" : "—";
   // sliders (only if not being dragged)
-  setSlider("mDiv",mDiv); setSlider("mFric",mFric); setSlider("padSmooth",padSmooth);
-  if(pollU100) setSlider("pollUs",pollU100);
+  setSlider("mDiv",mDiv); setSlider("mFric",mFric);
   $("#abSwap").textContent = abSwap? "on":"off"; $("#abSwap").classList.toggle("active",!!abSwap);
   $("#persistMode").textContent = persistMode? "on":"off"; $("#persistMode").classList.toggle("active",!!persistMode);
   for(const sel of document.querySelectorAll("select.back")){ if(document.activeElement!==sel) sel.value=back[+sel.dataset.i]; }
+  for(const sel of document.querySelectorAll("select.chord")){ if(document.activeElement!==sel) sel.value=chord[+sel.dataset.i]; }
 }
-function fmtSlider(id,v){ return id==="pollUs" ? (v*100)+"µs" : (""+v); }
+function fmtSlider(id,v){ return ""+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;
@@ -215,14 +221,17 @@ async function startPolling(){
 $("#connectBtn").onclick=connect;
 $("#abSwap").onclick=()=>{ const on=$("#abSwap").classList.contains("active"); setField(4, on?0:1); };
 $("#persistMode").onclick=()=>{ const on=$("#persistMode").classList.contains("active"); setField(16, on?0:1); };
-for(const id of ["mDiv","mFric","padSmooth","pollUs"]){
-  const field={mDiv:1,mFric:2,padSmooth:3,pollUs:9}[id];
+for(const id of ["mDiv","mFric"]){
+  const field={mDiv:1,mFric:2}[id];
   $("#"+id).addEventListener("input", ()=>{ $("#"+id+"V").textContent=fmtSlider(id,$("#"+id).value); });
   $("#"+id).addEventListener("change", ()=>setField(field, +$("#"+id).value));
 }
 for(const sel of document.querySelectorAll("select.back")){
   sel.addEventListener("change", ()=>setField(5 + +sel.dataset.i, +sel.value));
 }
+for(const sel of document.querySelectorAll("select.chord")){
+  sel.addEventListener("change", ()=>setField(CHORD_FIELD[+sel.dataset.i], +sel.value));
+}
 for(const b of document.querySelectorAll(".modebtn")){
   b.onclick=()=>{ if(confirm("Switch to "+MODE_NAMES[+b.dataset.mode]+"? The copycat will reboot.")){ send([0x03, +b.dataset.mode]); log("mode switch requested — device will reboot"); } };
 }

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است