Sfoglia il codice sorgente

Switch pro mode that can work on Switch 1 and 2, lower latency in all modes (#49)

* implement 2wicc style reporting

* switch pro gyro almost fully verified

* switch gyro should be perfect now, add rumble settings

* shout out to 2wicc

* update defaults and add "clean" ps4 and 5 modes

* Fix for odd gyro bias

* more switch pro gyro fixes

* reduce polling rate to see if that fixes gyro issues

* add optional higher polling mode for switch

* switch pro polling rate options

* possible fix for disconnects

* hide extra modes for now

* updated readme

* more readme updates

* shout out + latency link
Jari 4 mesi fa
parent
commit
535c57a02d

+ 16 - 6
OpenPuck/OpenPuck.ino

@@ -59,6 +59,14 @@ void setup() {
   // for that boot and skips the wake mouse (no endpoint room for both). Clean modes always rebuild bare.
   const bool puckMode = g_active->isPuck();
   const bool keepCdc  = puckMode && g_debugCdcThisBoot;
+  // The "game" PlayStation modes enumerate as a CLEAN single HID gamepad so games that classify off the raw HID
+  // device (Fortnite/UE GameInput, Windows.Gaming.Input) recognise them as PlayStation. A real Sony pad is just
+  // the gamepad HID (+ a USB-audio interface); our extra wake-mouse HID and WebUSB vendor interface make those
+  // classifiers refuse the PS glyph path even with the correct VID/PID (SDL/Steam tolerate the extras, which is
+  // why Steam still sees the normal PS modes). In clean-PS modes we skip BOTH the wake mouse and WebUSB -- the
+  // cost is no config panel / host-wake; chord back to Steam (back-paddle 4 + A) to reach the panel. The normal
+  // MODE_PS5 / MODE_HIDGYRO keep wake + panel (use those when you need host-wake; the *_GAME ones for games).
+  const bool psClean  = modeIsCleanPS(g_usbMode);
   USBDevice.detach(); delay(30);
   if (keepCdc) {
     USBDevice.setConfigurationBuffer(g_usbCfgDesc, sizeof g_usbCfgDesc);   // keep boot CDC composite (debug boot)
@@ -70,7 +78,7 @@ void setup() {
   // Distinct USB serial PER MODE (must be set AFTER clearConfiguration, which nulls it). Hosts cache USB
   // identity by VID:PID:serial; reusing one serial under a changing VID:PID can make a host refuse the new
   // identity. Steam keeps the exact unit serial (its pairing identity); the others get a 1-char suffix.
-  static const char MODE_SUFFIX[] = {'X','N','L','P','S','G'};   // modes 1..6
+  static const char MODE_SUFFIX[] = {'X','N','L','P','S','G','Q','D'};   // modes 1..8 (Q=PS5 game, D=DS4 game)
   if (puckMode) { USBDevice.setSerialDescriptor(g_unit); }
   else { snprintf(g_usbSerial, sizeof g_usbSerial, "%s%c", g_unit, MODE_SUFFIX[g_usbMode-1]); USBDevice.setSerialDescriptor(g_usbSerial); }
 
@@ -82,11 +90,13 @@ void setup() {
   g_active->begin();   // register this mode's USB interface(s) + set VID/PID/strings
 
   // Boot-mouse wake interface for clean (non-puck) modes, and for puck on the one-shot debug boot (CDC on,
-  // no endpoint room for wake mouse on a normal puck boot -- wake is registered above instead).
-  if (!puckMode && !keepCdc) wakeHidBegin();
+  // no endpoint room for wake mouse on a normal puck boot -- wake is registered above instead). Skipped for PS
+  // modes so the device stays a single clean HID gamepad (see psClean above).
+  if (!puckMode && !keepCdc && !psClean) wakeHidBegin();
 
-  // WebUSB config panel -- every mode. Puck: registered above (IF 0) before wake + slots; clean modes after controller.
-  if (!puckMode) usb_web.begin();
+  // WebUSB config panel -- every mode EXCEPT the PlayStation modes. Puck: registered above (IF 0) before wake +
+  // slots; other clean modes after controller. PS modes omit it to present a genuine single-HID PS controller.
+  if (!puckMode && !psClean) usb_web.begin();
   // Enable USB Remote Wakeup (bit 5) so the host lets us signal wake-from-sleep. Bit 7 is always required.
   USBDevice.setConfigurationAttribute(0x80 | 0x20);  // bmAttributes: required(0x80) | remote_wakeup(0x20)
   USBDevice.attach();   // re-connect with the final descriptor (host re-reads it fresh -> deterministic enumeration)
@@ -95,7 +105,7 @@ void setup() {
   if (USBDevice.suspended()){ USBDevice.remoteWakeup(); ledWakePulse(); }   // wake host if bus was sleeping when we (re-)attached
   loadBonds();
   hapticInit();   // clear relay/active flags + arm the reconnect block & initial stop burst
-  static const char* MODE_NAME[]={"STEAM(puck)","XBOX(xinput+mouse)","SWITCH(horipad)","LIZARD(puck kb/mouse)","SWITCH(pro+gyro)","PS5(dualsense)","HIDGYRO(ds4+motion)"};
+  static const char* MODE_NAME[]={"STEAM(puck)","XBOX(xinput+mouse)","SWITCH(horipad)","LIZARD(puck kb/mouse)","SWITCH(pro+gyro)","PS5(dualsense)","HIDGYRO(ds4+motion)","PS5(dualsense,game/clean)","DS4(ds4,game/clean)"};
   Serial.printf("# copycat up: unit=%s board=%s, mode=%s\n", g_unit, g_board, MODE_NAME[g_usbMode<=MODE_MAX?g_usbMode:0]);
   if (puckMode) Serial.printf("# puck USB: %s\n", keepCdc ? "DEBUG boot (CDC console on, wake mouse off; reverts next boot)" : "normal (CDC off, wake mouse on)");
   Serial.printf("# session addr %02X%02X%02X%02X/%02X ch%u (discovery on ibex/ch2)\n",

+ 7 - 5
OpenPuck/config.cpp

@@ -6,7 +6,7 @@ using namespace Adafruit_LittleFS_Namespace;
 
 uint8_t g_usbMode = 0;
 bool    g_xbox = false;
-uint8_t g_chordBtn[3] = { MODE_LIZARD, MODE_XBOX, MODE_SW_HORI };   // back4+B/X/Y -> these modes (A always STEAM)
+uint8_t g_chordBtn[3] = { MODE_LIZARD, MODE_XBOX, MODE_SW_PRO };   // back4+B/X/Y -> these modes (A always STEAM); Y defaults to Switch Pro
 bool    g_persistMode = false;
 uint8_t g_bootMode = 0xFF;
 
@@ -17,19 +17,20 @@ int     g_mDiv = 64, g_mFric = 94;
 uint8_t g_abSwap = 0;
 uint8_t g_back[4] = {5,6,7,8};   // L4->LB R4->RB L5->L3 R5->R3 (0..11 buttons, 12..15 D-pad U/D/L/R)
 uint8_t g_qamMap = 0;            // 0 = default (unmapped, uses hardcoded behavior per mode)
+uint8_t g_rumbleScale = 200;     // rumble strength % (200 = double); adjustable from the WebUSB panel
 
 // poll rate is fixed. Faster than the controller can refresh wastes airtime; slower adds latency. Any rate
 // persisted by an older build is ignored and overwritten with the default on boot (see loadCfg).
 const uint32_t g_pollUs = POLL_US_DEFAULT;
 
 #define CFG_FILE "/cfg.bin"
-#define CFG_MAGIC 0xC8   // bumped (qamMap): old cfg ignored -> clean defaults on first boot
-struct Cfg { uint8_t magic, mode, mDiv, mFric, rsvd0, abSwap, back[4], pollU100, persistMode, bootMode, chordBtn[3], qamMap; };  // rsvd0 = ex-padSmooth, now the one-shot debug-CDC arm
+#define CFG_MAGIC 0xC9   // bumped (rumbleScale): old cfg ignored -> clean defaults on first boot
+struct Cfg { uint8_t magic, mode, mDiv, mFric, rsvd0, abSwap, back[4], pollU100, persistMode, bootMode, chordBtn[3], qamMap, rumbleScale; };  // rsvd0 = ex-padSmooth, now the one-shot debug-CDC arm
 
 void saveCfg(){
   Cfg c={CFG_MAGIC,g_usbMode,(uint8_t)g_mDiv,(uint8_t)g_mFric,g_debugCdc,g_abSwap,
          {g_back[0],g_back[1],g_back[2],g_back[3]},(uint8_t)(g_pollUs/100),(uint8_t)(g_persistMode?1:0),g_bootMode,
-         {g_chordBtn[0],g_chordBtn[1],g_chordBtn[2]},g_qamMap};
+         {g_chordBtn[0],g_chordBtn[1],g_chordBtn[2]},g_qamMap,g_rumbleScale};
   InternalFS.remove(CFG_FILE); File f(InternalFS);
   if(f.open(CFG_FILE,FILE_O_WRITE)){ f.write((uint8_t*)&c,sizeof c); f.close(); }
 }
@@ -51,9 +52,10 @@ void loadCfg(){
       // cleared; otherwise persist->last mode, else->Steam. (poll rate stays POLL_US_DEFAULT -- never restored from cfg.)
       if(c.bootMode!=0xFF){ g_usbMode=modeValid(c.bootMode)?c.bootMode:0; consume=true; }
       else                 g_usbMode = g_persistMode ? (modeValid(c.mode)?c.mode:0) : 0;
-      static const uint8_t CHORD_DEF[3]={MODE_LIZARD,MODE_XBOX,MODE_SW_HORI};
+      static const uint8_t CHORD_DEF[3]={MODE_LIZARD,MODE_XBOX,MODE_SW_PRO};
       for(int i=0;i<3;i++) g_chordBtn[i]=modeValid(c.chordBtn[i])?c.chordBtn[i]:CHORD_DEF[i];
       g_qamMap = c.qamMap;
+      g_rumbleScale = c.rumbleScale;   // 0 is a valid setting (rumble off)
     }
     f.close();
   }

+ 16 - 3
OpenPuck/config.h

@@ -34,9 +34,16 @@
 #define MODE_SW_HORI 2   // HORIPAD (Switch console whitelist)
 #define MODE_LIZARD  3   // Puck HID; always keyboard+mouse (ignores Steam heartbeat)
 #define MODE_SW_PRO  4   // Nintendo Switch Pro Controller (057E:2009) + gyro
-#define MODE_PS5     5   // Sony DualSense (054C:0CE6) + gyro + split trackpad
-#define MODE_HIDGYRO 6   // DS4-layout generic HID gamepad + gyro (Fortnite-friendly)
-#define MODE_MAX     6
+#define MODE_PS5     5   // Sony DualSense (054C:0CE6) + gyro + split trackpad (+ wake mouse + WebUSB panel)
+#define MODE_HIDGYRO 6   // DS4-layout generic HID gamepad + gyro (+ wake mouse + WebUSB panel)
+#define MODE_PS5_GAME 7  // DualSense, CLEAN single-HID (no wake/WebUSB) so PC games classify it as PlayStation (Fortnite)
+#define MODE_DS4_GAME 8  // DS4, CLEAN single-HID (no wake/WebUSB) for game PlayStation classification
+#define MODE_MAX     8
+
+// The two "game" personalities drop the wake-mouse + WebUSB interfaces so the device is a genuine single-HID PS
+// controller (some PC games -- e.g. Fortnite/UE GameInput -- refuse PS classification when extra interfaces are
+// present). Cost: no config panel / host-wake while in these modes; chord back to Steam (back4 + A) for the panel.
+static inline bool modeIsCleanPS(uint8_t m){ return m==MODE_PS5_GAME || m==MODE_DS4_GAME; }
 
 static inline bool modeIsPuck(uint8_t m){ return m==MODE_STEAM || m==MODE_LIZARD; }
 static inline bool modeValid(uint8_t m){ return m<=MODE_MAX; }
@@ -64,6 +71,12 @@ extern int     g_mDiv, g_mFric; // xbox/lizard mouse sensitivity divisor / frict
 extern uint8_t g_abSwap;        // 1 = swap A/B and X/Y (Nintendo face-button layout)
 extern uint8_t g_back[4];       // back paddles L4,R4,L5,R5 -> button codes (0..15 standard, 16=PS Touch Click, 17=PS5 Mute)
 extern uint8_t g_qamMap;        // QAM (3 dots) physical button -> same code space (0 = default/unmapped)
+extern uint8_t g_rumbleScale;   // rumble strength, percent of decoded amplitude (100 = 1x, 200 = 2x default), all modes
+// Switch Pro motion settings. Persisted in their OWN flash file (mode_switch_pro.cpp), NOT in Cfg -- so changing
+// them never resets the rest of the config. Set from the WebUSB panel.
+extern uint8_t g_swProRate;     // Switch Pro report cadence: 0 = 66Hz (15ms, compat), 1 = 120Hz (8ms, DEFAULT), 2 = full (~250Hz)
+extern uint8_t g_swGyroScale10; // Switch Pro gyro sensitivity x10 (10 = 1.0x default; 5/15/20/25/30 = 0.5..3.0x)
+void swProSaveCfg();            // persist g_swProRate + g_swGyroScale10 to their flash file
 
 #define POLL_US_DEFAULT 4000u   // 250 Hz -- matches SC2 input report rate (1000000/250 = 4000 us)
 #define USB_STREAM_MS   4u      // host-side HID stream cadence for translated modes (~250 Hz)

+ 2 - 0
OpenPuck/controllers.cpp

@@ -21,6 +21,8 @@ IController* controllerFor(uint8_t mode){
     case MODE_SW_PRO:  return &g_switchPro;
     case MODE_PS5:     return &g_ps5Ctl;
     case MODE_HIDGYRO: return &g_hidGyroCtl;
+    case MODE_PS5_GAME:return &g_ps5Ctl;     // same DualSense controller; setup() drops wake/WebUSB for clean enum
+    case MODE_DS4_GAME:return &g_hidGyroCtl; // same DS4 controller; setup() drops wake/WebUSB for clean enum
     default:           return &g_steamPuck;
   }
 }

+ 4 - 0
OpenPuck/haptics.cpp

@@ -148,6 +148,10 @@ void haptic82HostReport(const uint8_t* p, uint16_t n){
   g_haptic82On = haptic82PayloadOn(p,n);
 }
 bool hapticSteamRumble(uint16_t lowFreq, uint16_t highFreq){
+  if(g_rumbleScale!=100){            // user rumble-strength scale (percent; 200 = double). Clamp to 16-bit.
+    uint32_t l=(uint32_t)lowFreq*g_rumbleScale/100, h=(uint32_t)highFreq*g_rumbleScale/100;
+    lowFreq=(l>0xFFFF)?0xFFFF:(uint16_t)l; highFreq=(h>0xFFFF)?0xFFFF:(uint16_t)h;
+  }
   bool on = lowFreq || highFreq;
   if(on && haptic82Blocked()) return false;       // same settle gate as native Steam haptics
   if(!on && !hapticLinkUp()) return false;

+ 2 - 2
OpenPuck/mode_hidgyro.cpp

@@ -66,13 +66,13 @@ static void hidGyroBuild(uint8_t out[63]){
 
 void HidGyroController::begin(){
   USBDevice.setID(0x054C, 0x05C4);
-  USBDevice.setDeviceVersion(0x0101);   // bumped from 0x0100 for the added wake-mouse interface (Windows caches config by VID:PID:bcdDevice)
+  USBDevice.setDeviceVersion(0x0104);   // bumped: clean-DS4 is now a separate mode (DS4_GAME); normal HIDGYRO keeps wake+WebUSB. Host re-reads config by VID:PID:serial (per-mode suffix) -- bump invalidates any cached 0x0103.
   USBDevice.setManufacturerDescriptor("Sony Computer Entertainment");
   USBDevice.setProductDescriptor("Wireless Controller");
   g_hidGyro.enableOutEndpoint(true);
   g_hidGyro.setReportCallback(NULL, hidGyroSet);
   g_hidGyro.setReportDescriptor(GYRO_HID_DESC, sizeof GYRO_HID_DESC);
-  g_hidGyro.setPollInterval(4);
+  g_hidGyro.setPollInterval(1);   // 1ms bInterval so the RF rate is the only latency limit (matches Xbox)
   g_hidGyro.begin();
 }
 void HidGyroController::task(){

+ 2 - 2
OpenPuck/mode_ps5.cpp

@@ -97,13 +97,13 @@ static void ps5Build(uint8_t out[63]){
 
 void Ps5Controller::begin(){
   USBDevice.setID(0x054C, 0x0CE6);
-  USBDevice.setDeviceVersion(0x0101);   // bumped from 0x0100 for the added wake-mouse interface (Windows caches config by VID:PID:bcdDevice)
+  USBDevice.setDeviceVersion(0x0104);   // bumped: clean-PS is now a separate mode (PS5_GAME); normal PS5 keeps wake+WebUSB. Host re-reads config by VID:PID:serial (per-mode suffix) -- bump invalidates any cached 0x0103.
   USBDevice.setManufacturerDescriptor("Sony Interactive Entertainment");
   USBDevice.setProductDescriptor("DualSense Wireless Controller");
   g_ps5.enableOutEndpoint(true);
   g_ps5.setReportCallback(ps5Get, ps5Set);
   g_ps5.setReportDescriptor(PS5_HID_DESC, sizeof PS5_HID_DESC);
-  g_ps5.setPollInterval(4);
+  g_ps5.setPollInterval(1);   // 1ms bInterval so the RF rate is the only latency limit (matches Xbox)
   g_ps5.begin();
 }
 void Ps5Controller::task(){

+ 2 - 2
OpenPuck/mode_switch_hori.cpp

@@ -69,12 +69,12 @@ static void switchBuildHoripad(uint8_t out[8]){
 
 void SwitchHoriController::begin(){
   USBDevice.setID(0x0F0D, 0x0092);
-  USBDevice.setDeviceVersion(0x0201);   // bumped from 0x0200 for the added wake-mouse interface (Windows caches config by VID:PID:bcdDevice)
+  USBDevice.setDeviceVersion(0x0202);   // bumped from 0x0201 for 1ms poll interval (Windows caches config by VID:PID:bcdDevice)
   USBDevice.setManufacturerDescriptor("HORI CO.,LTD.");
   USBDevice.setProductDescriptor("POKKEN CONTROLLER");
   g_switch.enableOutEndpoint(true);
   g_switch.setReportDescriptor(SWITCH_HID_DESC, sizeof SWITCH_HID_DESC);
-  g_switch.setPollInterval(8);
+  g_switch.setPollInterval(1);   // 1ms bInterval so the RF rate is the only latency limit (matches Xbox)
   g_switch.begin();
 }
 void SwitchHoriController::task(){   // stream the 8-byte HORIPAD report at ~250Hz (no handshake needed)

+ 160 - 21
OpenPuck/mode_switch_pro.cpp

@@ -4,11 +4,50 @@
 #include "config.h"
 #include "haptics.h"
 #include <Adafruit_TinyUSB.h>
+#include <Adafruit_LittleFS.h>
+#include <InternalFileSystem.h>
 #include <Arduino.h>
 #include <string.h>
+using namespace Adafruit_LittleFS_Namespace;
 
 SwitchProController g_switchPro;
 
+// Switch full-report (0x30) cadence. The console integrates the 3 IMU samples per report assuming ~5 ms/sample
+// (3 samples / 15 ms genuine); too high a rate over-integrates the gyro. With the accel scale fixed (gravity-
+// correction now works) 120 Hz is drift-free and lower-latency, so it is the DEFAULT. 66 Hz is the genuine
+// 2wiCC-matching compat fallback; "full" is the 4 ms PC rate for those who want lowest latency and tolerate it.
+#define SW_PRO_REPORT_MS    15u   // 66 Hz
+#define SW_PRO_REPORT_MS_120 8u   // ~120 Hz
+#define SW_ACCEL_DIV         4    // SC2 accel is +/-2g (16384/g); /4 -> the genuine Pro +/-8g (4096/g) the Switch cal expects
+
+uint8_t g_swProRate = 1;         // 0 = 66Hz, 1 = 120Hz (default), 2 = full (~250Hz / USB_STREAM_MS)
+uint8_t g_swGyroScale10 = 10;    // gyro sensitivity x10 (10 = 1.0x)
+
+// Persist the two Switch Pro motion settings in their own tiny file so they never trigger a global-Cfg reset.
+#define SWPRO_CFG_FILE "/swprocfg.bin"
+void swProSaveCfg(){
+  uint8_t b[3] = { 0x01, g_swProRate, g_swGyroScale10 };   // [ver][rate 0/1/2][gyroScale x10]
+  InternalFS.remove(SWPRO_CFG_FILE); File f(InternalFS);
+  if(f.open(SWPRO_CFG_FILE, FILE_O_WRITE)){ f.write(b,sizeof b); f.close(); }
+}
+static void swProLoadCfg(){
+  File f(InternalFS); uint8_t b[3];
+  if(f.open(SWPRO_CFG_FILE, FILE_O_READ)){
+    if(f.read(b,3)==3 && b[0]==0x01){
+      g_swProRate = (b[1]<=2) ? b[1] : 1;   // 0=66Hz, 1=120Hz, 2=full; bad value -> default 120Hz
+      g_swGyroScale10 = (b[2]>=5 && b[2]<=30) ? b[2] : 10;   // sane bounds (0.5x..3.0x)
+    }
+    f.close();
+  }
+}
+// Scale a gyro axis by g_swGyroScale10/10, clamped to int16 (3x can saturate at high rates -- expected).
+static int16_t gscale(int16_t v){
+  if(g_swGyroScale10==10) return v;
+  int32_t s = (int32_t)v * (int32_t)g_swGyroScale10 / 10;
+  if(s>32767) s=32767; else if(s<-32768) s=-32768;
+  return (int16_t)s;
+}
+
 static const uint8_t SWPRO_HID_DESC[]={
   0x05,0x01,0x15,0x00,0x09,0x04,0xA1,0x01,0x85,0x30,0x05,0x01,0x05,0x09,0x19,0x01,
   0x29,0x0A,0x15,0x00,0x25,0x01,0x75,0x01,0x95,0x0A,0x55,0x00,0x65,0x00,0x81,0x02,
@@ -52,15 +91,22 @@ static Adafruit_USBD_HID g_swPro;
 static uint8_t g_swProReportMode=0;   // 0 until the host's subcommand 0x03 selects 0x30 -> THEN we stream input
 static const uint8_t JC_MAC[6]={0x7C,0xBB,0x8A,0x00,0x00,0x01};   // synthetic but stable controller MAC
 static uint16_t jcRumbleAmp(const uint8_t r[4]){
-  // Nintendo's 4-byte rumble encoding is frequency+amplitude packed through tables. For translation to the
-  // Steam controller, use the amplitude-bearing fields directly and scale them into a 16-bit motor speed.
-  if((r[0]==0x00 && r[1]==0x01 && r[2]==0x40 && r[3]==0x40) || (r[0]==0x00 && r[1]==0x00 && r[2]==0x01 && r[3]==0x40)) return 0;
-  uint16_t amp = ((uint16_t)(r[1] & 0xFE) << 8) | r[3];
-  return amp ? amp : 0x0800;
+  // Nintendo packs a high band (r[0],r[1]) and low band (r[2],r[3]) of frequency+amplitude. We only need a
+  // magnitude for the Steam motor, so pull the two amplitude fields: HF amp = r[1]>>1, LF amp = r[3]&0x3F.
+  // Every canonical idle/neutral frame -- 00 01 40 40, 00 00 01 40, and all-zero -- decodes to 0 this way, so a
+  // steady idle rumble stream maps to "off" instead of latching the motor on. NO forced floor: 0 amplitude -> 0.
+  uint8_t hf = r[1] >> 1;          // high-band amplitude (neutral 0x01 -> 0)
+  uint8_t lf = r[3] & 0x3F;        // low-band amplitude  (neutral 0x40 -> 0)
+  uint8_t a  = hf > lf ? hf : lf;  // 0..0x7F
+  return (uint16_t)a << 9;         // scale to ~16-bit motor speed
 }
 static void jcRumble(const uint8_t* p, uint16_t pn){
   if(pn<9) return;                       // [timer][left rumble x4][right rumble x4]
-  hapticSteamRumble(jcRumbleAmp(p+1), jcRumbleAmp(p+5));
+  uint16_t lo=jcRumbleAmp(p+1), hi=jcRumbleAmp(p+5);
+  static uint16_t lastLo=0, lastHi=0;
+  if(lo==lastLo && hi==lastHi) return;   // only relay on change: the Switch streams rumble every frame, so
+  lastLo=lo; lastHi=hi;                  // re-sending unchanged values would flood the RF relay and loop the motor
+  hapticSteamRumble(lo, hi);
 }
 static int jcStick12(int16_t v, bool inv){   // steam int16 (center 0) -> 12-bit (center 0x800), clamped
   int a = 2048 + (inv ? -((int)v>>4) : ((int)v>>4));
@@ -93,23 +139,46 @@ static void jcInputPrefix(uint8_t* out){
   if(b&TB_L4)jc|=codeToJc(g_back[0],fA,fB,fX,fY); if(b&TB_R4)jc|=codeToJc(g_back[1],fA,fB,fX,fY);
   if(b&TB_L5)jc|=codeToJc(g_back[2],fA,fB,fX,fY); if(b&TB_R5)jc|=codeToJc(g_back[3],fA,fB,fX,fY);
   out[0]=g_jcTimer++;
-  out[1]=0x81;   // full battery + USB powered (hid-nintendo bat_con)
+  out[1]=0x91;   // battery full+charging (hi nibble 0x9), connection_info=1 (lo nibble): a wired/charging Pro Controller.
+                 // A real Switch reads this to show the pad as connected; Steam/hid-nintendo accept it too. (2wiCC parity)
   out[2]=(uint8_t)(jc); out[3]=(uint8_t)(jc>>8); out[4]=(uint8_t)(jc>>16);
+  out[3]|=0x80;  // charging_grip bit (button "common" byte, bit7): genuine Pro Controller always sets it on USB; real
+                 // Switch uses it to recognise a wired controller. Not a button, so hid-nintendo ignores it.
   jcPackStick(out+5, g_in.lx, g_in.ly);
   jcPackStick(out+8, g_in.rx, g_in.ry);
-  out[11]=0;
+  out[11]=0x09;  // rumble_input_report echo: genuine pad emits 0x09..0x0C; some Switch firmware expects this nonzero.
 }
 static void switchProBuild(uint8_t out[63]){
   memset(out,0,63);
   jcInputPrefix(out);
+  // Gyro slot order follows hid-nintendo: raw+6 = ROLL, raw+8 = PITCH, raw+10 = YAW. Steam-validated source
+  // routing (a proper rotation, det +1): SwitchX<-+gy, SwitchY<--gx, SwitchZ<-+gz.
+  //
+  // CRITICAL: the accel MUST use the SAME signed permutation as the gyro. Steam reads the gyro raw and ignores
+  // this, but the Switch console FUSES accel (gravity) with gyro to anchor absolute orientation -- and if the
+  // accel frame has the opposite handedness, the fusion's gravity-correction term points the wrong way and the
+  // attitude estimate latches into a rotated solution (the intermittent ~45deg axis-mixing, per-unit roll bias,
+  // and "only a replug clears it" behaviour). So accel Y must be -ax to match the gyro's -gx pitch axis; with
+  // that, accel is also det +1 and resting gravity lands cleanly on Switch Z (the up axis).
+  // The SC2 accelerometer is +/-2g full-scale (~16384 counts = 1g; measured |a|=16364 at rest). The factory cal we
+  // present (sensitivity 0x4000) tells the Switch it's a genuine +/-8g Pro Controller (4096 counts = 1g), so divide
+  // by SW_ACCEL_DIV=4 to report ~1g of gravity. Without this the console reads gravity as ~4g, REJECTS the accel for
+  // drift correction (it must be linear accel, not gravity), and the gyro's roll error accumulates into the slow
+  // ~45deg lean. Gyro is left at native scale (aiming sensitivity is right); only the accel magnitude was wrong.
+  int16_t aX=(int16_t)(g_in.ay/SW_ACCEL_DIV);            // accel X <- +g_in.ay /4   (matches gyro +gy)
+  int16_t aY=(int16_t)((-(int16_t)g_in.ax)/SW_ACCEL_DIV);// accel Y <- -g_in.ax /4   (matches gyro -gx)
+  int16_t aZ=(int16_t)(g_in.az/SW_ACCEL_DIV);            // accel Z <- +g_in.az /4   (matches gyro +gz)
+  int16_t groll =gscale((int16_t)g_in.gy);            // gyro outputs scaled by the user sensitivity factor
+  int16_t gpitch=gscale((int16_t)(-(int16_t)g_in.gx));
+  int16_t gyaw  =gscale((int16_t)g_in.gz);
   for(int k=0;k<3;k++){
     int o=12+k*12;
-    out[o+0]=g_in.ax&0xFF; out[o+1]=g_in.ax>>8;
-    out[o+2]=g_in.ay&0xFF; out[o+3]=g_in.ay>>8;
-    out[o+4]=g_in.az&0xFF; out[o+5]=g_in.az>>8;
-    out[o+6]=g_in.gx&0xFF; out[o+7]=g_in.gx>>8;
-    out[o+8]=g_in.gy&0xFF; out[o+9]=g_in.gy>>8;
-    out[o+10]=g_in.gz&0xFF; out[o+11]=g_in.gz>>8;
+    out[o+0]=aX&0xFF;       out[o+1]=(aX>>8)&0xFF;      // accel X (Switch)
+    out[o+2]=aY&0xFF;       out[o+3]=(aY>>8)&0xFF;      // accel Y (Switch)
+    out[o+4]=aZ&0xFF;       out[o+5]=(aZ>>8)&0xFF;      // accel Z (Switch)
+    out[o+6]=groll&0xFF;    out[o+7]=(groll>>8)&0xFF;   // gyro ROLL  <- +g_in.gy (scaled)
+    out[o+8]=gpitch&0xFF;   out[o+9]=(gpitch>>8)&0xFF;  // gyro PITCH <- -g_in.gx (scaled)
+    out[o+10]=gyaw&0xFF;    out[o+11]=(gyaw>>8)&0xFF;   // gyro YAW   <- +g_in.gz (scaled)
   }
 }
 // --- Canonical factory SPI dumps the host reads for calibration. Neutral IMU + centered sticks so a fresh
@@ -133,6 +202,49 @@ static void jcBuildStickCal(){
   uint16_t Rr[6]={C,C, R,R, R,R};  // right order: center(x,y), min(x,y), max(x,y)
   jcPack12(&g_spiStickCal[0], L); jcPack12(&g_spiStickCal[9], Rr);
 }
+// Manual-pairing (subcommand 0x01) reply payloads. A real Switch runs this 3-stage BT key exchange over USB so it
+// can register the pad (Steam/hid-nintendo never do, which is why this was previously unneeded). The data is the
+// canonical hid handshake blob; the Switch only validates the shape, not the key contents. Each is the 31-byte
+// reply body that follows the 0x21 input prefix + ack(0x81) + echoed-subcommand(0x01). (adapted from 2wiCC)
+static const uint8_t BT_PAIR_1[31]={   // type 1: echo type + controller BT addr + "Pro Controller" name
+  0x01, JC_MAC[0],JC_MAC[1],JC_MAC[2],JC_MAC[3],JC_MAC[4],JC_MAC[5], 0x00,0x25,0x08,
+  0x50,0x72,0x6F,0x20,0x43,0x6F,0x6E,0x74,0x72,0x6F,0x6C,0x6C,0x65,0x72,   // "Pro Controller"
+  0x00,0x00,0x00,0x00,0x00,0x68};
+static const uint8_t BT_PAIR_2[31]={   // type 2: LTK exchange
+  0x02, 0xE5,0xC8,0xE4,0x92,0x05,0xFF,0xC9,0x8A,0x7D,0xEA,0x15,0xF6,0x19,0xBA,0x82,0x13,
+  0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00};
+static const uint8_t BT_PAIR_3[31]={0x03};   // type 3: save pairing (all zero body)
+// User-calibration SPI mirror (0x8000-0x80FF). A real Pro Controller stores the gyro/accel (and stick) calibration
+// the Switch writes during "Calibrate Motion Controls" here, then reads it back and applies it -- that's how a
+// resting IMU/neutral offset gets cancelled. We previously DISCARDED SPI writes and returned 0xFF (blank), so the
+// Switch's calibration never stuck (it appeared to do nothing and reverted on replug) -- which is why calibrating
+// did not fix the Mario Kart roll/neutral offset. Now we store writes and serve them back, persisted to flash so
+// it survives unplug/replug. 0xFF = blank -> Switch falls back to our factory block at 0x6020 (default until cal'd).
+#define SWCAL_FILE "/swimucal.bin"
+static uint8_t g_userCal[0x100];
+static void loadUserCal(){
+  memset(g_userCal,0xFF,sizeof g_userCal);
+  File f(InternalFS);
+  if(f.open(SWCAL_FILE, FILE_O_READ)){ f.read(g_userCal,sizeof g_userCal); f.close(); }
+}
+static void saveUserCal(){
+  InternalFS.remove(SWCAL_FILE); File f(InternalFS);
+  if(f.open(SWCAL_FILE, FILE_O_WRITE)){ f.write(g_userCal,sizeof g_userCal); f.close(); }
+}
+// jcSpiWrite runs in the USB ISR (via jcSet). NEVER do flash I/O here -- a blocking LittleFS erase/write in the
+// interrupt wedges USB + the RF poll and corrupts state (device drops into a bad state needing a replug). Only
+// update the RAM mirror and flag it dirty; task() (main loop) does the actual save, debounced so a calibration
+// write-burst coalesces into one flash write.
+static volatile bool          g_userCalDirty=false;
+static volatile unsigned long g_userCalDirtyMs=0;
+static void jcSpiWrite(uint32_t addr, uint8_t len, const uint8_t* data, uint16_t avail){
+  bool changed=false;
+  for(uint8_t i=0;i<len && i<avail;i++){
+    uint32_t a=addr+i;
+    if(a>=0x8000 && a<0x8100){ if(g_userCal[a-0x8000]!=data[i]){ g_userCal[a-0x8000]=data[i]; changed=true; } }
+  }
+  if(changed){ g_userCalDirty=true; g_userCalDirtyMs=millis(); }   // defer the flash write to task()
+}
 static void spiRead(uint32_t addr, uint8_t len, uint8_t* dst){
   for(uint8_t i=0;i<len;i++){
     uint32_t a=addr+i; uint8_t v=0xFF;
@@ -141,7 +253,8 @@ static void spiRead(uint32_t addr, uint8_t len, uint8_t* dst){
     else if(a>=0x6050 && a<0x6050+13) v=SPI_COLOR[a-0x6050];
     else if(a>=0x6080 && a<0x6080+24) v=SPI_PARAMS1[a-0x6080];
     else if(a>=0x6098 && a<0x6098+18) v=SPI_PARAMS2[a-0x6098];
-    dst[i]=v;   // unmapped (incl. user-cal 0x80xx) -> 0xFF = "blank flash" -> host uses factory blocks above
+    else if(a>=0x8000 && a<0x8100)    v=g_userCal[a-0x8000];   // persisted user cal (0xFF blank -> factory fallback)
+    dst[i]=v;
   }
 }
 // Reply FIFO: the host's handshake/subcommand reports arrive in the USB ISR (jcSet); we enqueue the canonical
@@ -167,14 +280,20 @@ static void jcSubcmd(uint8_t sub, const uint8_t* args, uint16_t alen){
   jcInputPrefix(p);
   p[13]=sub;
   switch(sub){
+    case 0x01: {                       // manual BT pairing: 3-stage key exchange a real Switch runs over USB
+      const uint8_t* d=BT_PAIR_3; uint8_t t=(alen>=1)?args[0]:3;
+      if(t==1) d=BT_PAIR_1; else if(t==2) d=BT_PAIR_2;
+      p[12]=0x81;                      // pairing ACK
+      memcpy(&p[14], d, 31);
+      break; }
     case 0x02:                         // request device info
       p[12]=0x82;
-      p[14]=0x04; p[15]=0x21;          // firmware version (4.33)
+      p[14]=0x03; p[15]=0x48;          // firmware version 3.72 (genuine Pro Controller value)
       p[16]=0x03;                      // controller type: Pro Controller
       p[17]=0x02;                      // fixed
       memcpy(&p[18], JC_MAC, 6);
       p[24]=0x01;                      // colors stored in SPI (0x6050)
-      p[25]=0x02;                      // fixed
+      p[25]=0x01;                      // fixed
       break;
     case 0x10: {                       // SPI flash read -> echo [addr][len] then the data
       if(alen<5){ p[12]=0x80; break; }
@@ -184,10 +303,19 @@ static void jcSubcmd(uint8_t sub, const uint8_t* args, uint16_t alen){
       p[14]=args[0]; p[15]=args[1]; p[16]=args[2]; p[17]=args[3]; p[18]=rl;
       spiRead(a, rl, &p[19]);
       break; }
+    case 0x11:                         // SPI flash write -> persist user calibration so Switch motion-cal sticks
+      if(alen>=5){
+        uint32_t a=(uint32_t)args[0]|((uint32_t)args[1]<<8)|((uint32_t)args[2]<<16)|((uint32_t)args[3]<<24);
+        jcSpiWrite(a, args[4], &args[5], (alen>5)?(uint16_t)(alen-5):0);
+      }
+      p[12]=0x80; break;               // write ACK
     case 0x03:                         // set input report mode (0x30 = standard full) -> begin streaming
       if(alen>=1 && args[0]==0x30) g_swProReportMode=0x30;
       p[12]=0x80; break;
-    case 0x04: p[12]=0x83; break;      // trigger buttons elapsed time
+    case 0x04:                         // trigger buttons elapsed time -> canned reply (genuine pad returns data)
+      p[12]=0x83;
+      p[14]=0x00; p[15]=0xCC; p[16]=0x00; p[17]=0xEE; p[18]=0x00; p[19]=0xFF;
+      break;
     case 0x21: p[12]=0xA0; break;      // set NFC/IR config
     default:   p[12]=0x80; break;      // 0x06/0x08/0x30/0x38/0x40/0x41/0x48/... generic positive ACK
   }
@@ -221,25 +349,36 @@ static void jcSet(uint8_t rid, hid_report_type_t type, uint8_t const* b, uint16_
 
 void SwitchProController::begin(){
   USBDevice.setID(0x057E, 0x2009);
-  USBDevice.setDeviceVersion(0x0211);   // bumped from 0x0210 for the added wake-mouse interface (Windows caches config by VID:PID:bcdDevice)
+  USBDevice.setDeviceVersion(0x0212);   // bumped from 0x0211 for 1ms poll interval (Windows caches config by VID:PID:bcdDevice)
   USBDevice.setManufacturerDescriptor("Nintendo Co., Ltd.");
   USBDevice.setProductDescriptor("Pro Controller");
   jcBuildStickCal();
+  loadUserCal();   // restore any persisted Switch motion calibration (per-unit IMU bias correction)
+  swProLoadCfg();  // restore report-rate + gyro-scale settings
   g_swPro.enableOutEndpoint(true);
   g_swPro.setReportCallback(NULL, jcSet);   // answer the Nintendo USB handshake + subcommands (else Steam never binds it)
   g_swPro.setReportDescriptor(SWPRO_HID_DESC, sizeof SWPRO_HID_DESC);
-  g_swPro.setPollInterval(4);
+  g_swPro.setPollInterval(1);   // 1ms bInterval so the RF rate is the only latency limit (matches Xbox)
   g_swPro.begin();
 }
 void SwitchProController::task(){
   if(!g_swPro.ready()) return;
+  // Deferred user-cal flash write (queued by the USB ISR; debounced so a calibration write-burst is one save).
+  if(g_userCalDirty && (unsigned long)(millis()-g_userCalDirtyMs) > 250u){ g_userCalDirty=false; saveUserCal(); }
   if(g_jcQh!=g_jcQt){                                   // drain handshake/subcommand replies first (ordered)
     JcRep* r=&g_jcQ[g_jcQh];
     if(g_swPro.sendReport(r->rid, r->data, JC_REPLEN)) g_jcQh=(uint8_t)((g_jcQh+1)%JCQ_N);
     return;                                             // one report per call; the rest next loop
   }
   if(g_swProReportMode!=0x30) return;                   // not until the host has finished init + selected 0x30
-  if(millis()-g_swProLastMs<USB_STREAM_MS) return; g_swProLastMs=millis();
+  // Stream the standard full report at ~66 Hz (15 ms), NOT the 250 Hz USB_STREAM_MS the PC modes use. The Switch
+  // integrates the report's 3 IMU samples by SAMPLE COUNT at a fixed ~5 ms/sample (it ignores the timer byte and
+  // assumes a 3-samples-per-15ms genuine cadence). At 250 Hz x 3 samples = 750 samples/s it credits ~3.75 s of
+  // gyro rotation per real second, so any residual gyro bias accumulates ~3.75x too fast -> the slow orientation
+  // lean that builds over minutes and resets on replug. 15 ms matches the genuine push (and 2wiCC's ~60 Hz /
+  // MissionControl), making integration 1:1. (Buttons/sticks on the Switch are genuine-rate-limited anyway.)
+  uint32_t interval = (g_swProRate==2) ? USB_STREAM_MS : (g_swProRate==1) ? SW_PRO_REPORT_MS_120 : SW_PRO_REPORT_MS;
+  if(millis()-g_swProLastMs<interval) return; g_swProLastMs=millis();
   uint8_t p[63]; switchProBuild(p);
   g_swPro.sendReport(0x30, p, sizeof p);
 }

+ 1 - 1
OpenPuck/serial_console.cpp

@@ -38,7 +38,7 @@ void serialConsolePoll(){
       else if (line[0]=='x'){ uint8_t m=strtoul(line+1,0,10); if(modeValid(m)){
         if(USBDevice.suspended()){ Serial.println("# mode change blocked: host suspended"); }
         else { Serial.printf("# switch mode %u (reboot)\n",m); delay(20); saveMode(m); delay(40); NVIC_SystemReset(); }
-      } }   // switch USB mode: 0=steam 1=xbox 2=hori 3=lizard 4=swpro 5=ps5 6=hidgyro
+      } }   // switch USB mode: 0=steam 1=xbox 2=hori 3=lizard 4=swpro 5=ps5 6=hidgyro 7=ps5-game/clean 8=ds4-game/clean
       else if (line[0]=='c'){ g_rfCh=atoi(line+1); Serial.printf("# ch=%u\n",g_rfCh); if(g_rfListen) rfListenStart(); }
       else if (line[0]=='p'){ g_rfPrefix=strtol(line+1,0,16); Serial.printf("# prefix=%02X\n",g_rfPrefix); if(g_rfListen) rfListenStart(); }
       else if (line[0]=='i'){ g_crcinit=strtoul(line+1,0,16); Serial.printf("# crcinit=%06lX\n",(unsigned long)g_crcinit); }

+ 11 - 3
OpenPuck/webusb_config.cpp

@@ -4,6 +4,7 @@
 #include "rf_link.h"
 #include "haptics.h"
 #include "puck_hid.h"
+#include "triton.h"   // g_in raw IMU (diagnostic readout)
 #include "build_info.h"
 #include <Arduino.h>
 #include <string.h>
@@ -15,14 +16,14 @@ Adafruit_USBD_WebUSB usb_web;
 //                [qos][persistMode][chordBtn B][chordBtn X][chordBtn Y][pollsps_lo][pollsps_hi]
 //                [loopPeriod_lo][loopPeriod_hi][loopWorstIdx][loopWorstUs_lo][loopWorstUs_hi]
 //                [pollPeriod_lo][pollPeriod_hi][logEnabled][battery%][rssi|dBm|]
-//                [gitDirty][gitHash 12B ASCII, NUL-padded]
-#define WB_PAYLEN 51
+//                [gitDirty][gitHash 12B ASCII, NUL-padded][rumbleScale][swPro120][swGyroScale10][raw accel ax ay az 3x s16 LE]
+#define WB_PAYLEN 60   // keep the blob under 64 bytes total (readBlob reads one 64-byte packet)
 static void webusbSendBlob(){
   if(!usb_web.connected()) return;
   bool up = (g_connSlot>=0 && (millis()-g_connReplyMs) < 300);
   uint8_t p[2+WB_PAYLEN];
   p[0]=0xA5; p[1]=WB_PAYLEN;
-  p[2]=4;                          // protocol version (4 = +battery/rssi/git; 2 = chordBtn[3])
+  p[2]=7;                          // protocol version (7 = +raw accel; 6 = +swPro120/gyroScale; 5 = +rumbleScale)
   p[3]=g_usbMode; p[4]=(uint8_t)g_mDiv; p[5]=(uint8_t)g_mFric; p[6]=g_qamMap; p[7]=g_abSwap;
   p[8]=g_back[0]; p[9]=g_back[1]; p[10]=g_back[2]; p[11]=g_back[3];
   p[12]=(g_connSlot>=0)?(uint8_t)g_connSlot:0xFF;
@@ -46,6 +47,10 @@ static void webusbSendBlob(){
   p[40]=OPK_GIT_DIRTY ? 1 : 0;
   memset(&p[41],0,12);                                                 // 12B ASCII git hash, NUL-padded
   { const char* h=OPK_GIT_HASH; for(uint8_t i=0;i<12 && h[i];i++) p[41+i]=(uint8_t)h[i]; }
+  p[53]=g_rumbleScale;                                                 // rumble strength % (protocol v5)
+  p[54]=g_swProRate;                                                   // Switch Pro report rate 0=66/1=120/2=full (protocol v6)
+  p[55]=g_swGyroScale10;                                               // Switch Pro gyro sensitivity x10 (protocol v6)
+  { int16_t a[3]={g_in.ax,g_in.ay,g_in.az}; memcpy(&p[56], a, 6); }    // raw accelerometer for scale diagnostics (protocol v7)
   usb_web.write(p,sizeof p); usb_web.flush();
 }
 #if OPK_LOG
@@ -117,6 +122,9 @@ void webusbPoll(){
           case 17: case 18: case 19: if(modeValid(v)) g_chordBtn[f-17]=v; break;   // back4+B/X/Y mode assignments
           case 20: armDebugCdcNextBoot(); usb_web.flush(); delay(40); NVIC_SystemReset(); break;  // reboot once WITH the CDC serial console (puck mode), then auto-revert
           case 21: g_qamMap = v; break;   // QAM physical button remap code (0=default/unmapped)
+          case 22: g_rumbleScale = v; break;   // rumble strength % (0=off, 100=1x, 200=double)
+          case 23: g_swProRate = (v<=2)?v:1; swProSaveCfg(); persist=false; break;   // Switch Pro report rate (0=66Hz,1=120Hz,2=full)
+          case 24: g_swGyroScale10 = (v>=5&&v<=30)?v:10; swProSaveCfg(); persist=false; break;   // Switch Pro gyro scale x10
         }
         if(persist) saveCfg();
         webusbSendBlob();

+ 6 - 6
README.md

@@ -21,19 +21,19 @@ There are two fundamental problems with the controller:
 
 [![OpenPuck Intro](https://img.youtube.com/vi/gSaqO9oqq9s/0.jpg)](https://www.youtube.com/watch?v=gSaqO9oqq9s)
 
-OpenPuck uses a [Pro Micro NRF52840](https://www.amazon.com/dp/B0GSZ7FD6T) ($8 on Amazon, possibly cheaper elsewhere) which uses a radio similar to the one being used by the controller and the puck. Once the arduino sketch is uploaded it emulates the puck over USB to Steam by default and allows pairing the controller normally (almost, the lizard mode for when Steam is off might not be 1:1).
+OpenPuck uses a [Pro Micro NRF52840](https://www.amazon.com/dp/B0GSZ7FD6T) ($8 on Amazon, possibly cheaper elsewhere) which uses a radio similar to the one being used by the controller and the puck. Once the arduino sketch is uploaded it emulates the puck over USB to Steam by default and allows pairing the controller normally (almost, the lizard mode for when Steam is off might not be 1:1). Latency [has been measured to be within 1ms of the official puck](https://www.reddit.com/r/SteamController/comments/1u754ze/complete_latency_testing_of_openpuck_project/).
 
 At any point you can hold all 4 back buttons and press X to switch over to ***Xbox mode** which maps all canonical inputs to their expected counterparts (plus L4 -> LB, L5 -> L3, etc which are configurable). In this mode the right trackpad acts as a mouse but at present this only works in Android and SteamOS.
 
-Similarly you can hold all 4 back buttons and press Y to switch (teehee) over to a **Switch mode**. This emulates a Pokken controller and is very limited (no gyro, no haptics, etc) but it's functional enough for my kids to play Animal Crossing :D. There's other modes as well:
+Similarly you can hold all 4 back buttons and press Y to switch (teehee) over to a **Switch mode**. This emulates a pro controller full with gyro and haptics. There's other modes as well:
 
 | Button combo (configurable) | Mode | Comment |
 |---|---|---|
 | back-4 + A | Steam | Steam Controller Mode |
 | back-4 + B | Lizard | Lizard mode, even if Steam is open |
 | back-4 + X | Xbox | Xbox 360 Controller |
-| back-4 + Y | HORI | Switch Controller (works on the consoles) |
-| WebUSB panel → mode 4 | Switch Pro + Gyro | PC only |
+| back-4 + Y | Swith Pro | Switch Controller + Gyro + Haptics |
+| WebUSB panel → mode 4 | Hori Pad | Switch mode with no gyro or haptics |
 | WebUSB panel → mode 5 | DualSense + Gyro + Trackpad | PC only |
 | WebUSB panel → mode 6 | DS4/HIDGYRO + Gyro + Trackpad | PC only |
 
@@ -53,10 +53,8 @@ A webusb based configuration UI is available [here](https://safijari.github.io/o
 # Future work
 - Find a way to make Xinput mode and mouse work together on all platforms
 - Ensure multiple controllers can function correctly (if I can ever buy one)
-- Tune haptics in Xinput mode
 - Design the charging portion (and make it short proof)
 - Design a 3D printable housing
-- Allow bluetooth controllers to pair with and connect to the puck
 
 # Contributions
 The firmware is split into small, single-responsibility modules under `OpenPuck/` (one file per emulated controller, plus the RF, config, and host-interface layers). Start with [ARCHITECTURE.md](./ARCHITECTURE.md) for the map of how it all fits together and how to add a new USB personality.
@@ -67,3 +65,5 @@ I have tested this software fairly extensively but I have limited resources. I o
 - Valve for putting out the amazing controller
 - Whoever wrote the drivers for SDL / Linux
 - Alan for not scalping and selling me this controller for $120
+- https://github.com/knflrpn/2wiCC for the Switch Pro controller mode help
+- Massive thanks to [u/Careful_Tune4744](https://www.reddit.com/user/Careful_Tune4744/) for latency testing as well as testing and giving feedback on the Switch Pro mode

+ 54 - 8
docs/index.html

@@ -59,6 +59,9 @@
         <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>
+        <!-- modes 7/8 (PS5/DS4 "game/clean") hidden from the panel for now; still reachable via serial console -->
+        <!-- <button data-mode="7" class="modebtn">PS5 (game/clean)</button> -->
+        <!-- <button data-mode="8" class="modebtn">DS4 (game/clean)</button> -->
       </div>
       <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>
@@ -90,6 +93,8 @@
         <div class="stat logonly"><div class="k">Poll period act/want</div><div class="v" id="stPollPer">—</div></div>
         <div class="stat"><div class="k">Build (git)</div><div class="v" id="stBuild">—</div></div>
       </div>
+      <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:10px 0 0"><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>
@@ -110,6 +115,20 @@
       <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">
+      <h2>Rumble</h2>
+      <div class="row"><label>Strength</label><input type="range" id="rumble" min="0" max="255" step="5"><span class="val" id="rumbleV"></span></div>
+      <p class="note">Scales rumble amplitude across all modes (100% = native, 200% = double, 0% = off).</p>
+    </div>
+
+    <div class="card">
+      <h2>Switch Pro motion</h2>
+      <div class="row"><label>Report rate</label><select id="swRate"></select></div>
+      <p class="note" style="margin:0 0 6px">120 Hz (default) is low-latency and drift-free. 66 Hz is a genuine-cadence compatibility fallback. Full rate (~250 Hz) is lowest-latency but non-genuine — use only if your console tolerates it.</p>
+      <div class="row"><label>Gyro scale</label><select id="swGyro"></select></div>
+      <p class="note">Gyro sensitivity multiplier for Switch Pro mode (1.0× = unchanged).</p>
+    </div>
+
 
     <div class="card">
       <h2>Button mapping</h2>
@@ -142,7 +161,7 @@
 </div>
 
 <script>
-const MODE_NAMES = ["Steam (puck)","Xbox 360","Switch (HORIPAD)","Lizard (always)","Switch Pro + gyro","PS5 DualSense","HID gyro (DS4)"];
+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 BTN_CODES = [[0,"— none —"],[1,"A"],[2,"B"],[3,"X"],[4,"Y"],[5,"LB"],[6,"RB"],[7,"L3"],[8,"R3"],[9,"Back"],[10,"Quick Access Menu"],[11,"Guide"],[12,"D-pad Up"],[13,"D-pad Down"],[14,"D-pad Left"],[15,"D-pad Right"],[16,"PS Touch Click"],[17,"PS5 Mute"]];
 let dev=null, epIn=0, epOut=0, ifNum=0, polling=false, capturing=false;
@@ -158,7 +177,18 @@ for(const sel of document.querySelectorAll("select.back")){
   for(const [c,n] of BTN_CODES){ if(c===0) continue; 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); });
+  MODE_NAMES.forEach((n,i)=>{ if(i===7||i===8) return;   // hide PS5/DS4 "game/clean" modes from the panel for now
+    const o=document.createElement("option"); o.value=i; o.textContent=n; sel.appendChild(o); });
+}
+// Switch Pro report-rate options (0=66Hz, 1=120Hz default, 2=full ~250Hz)
+{ const sel=document.getElementById("swRate");
+  for(const [lbl,v] of [["120 Hz (default)",1],["66 Hz (compatibility)",0],["Full rate (~250 Hz)",2]]){
+    const o=document.createElement("option"); o.value=v; o.textContent=lbl; sel.appendChild(o); }
+}
+// Switch Pro gyro-scale options (value = scale x10, so it fits in one byte)
+{ const sel=document.getElementById("swGyro");
+  for(const [lbl,v] of [["0.5×",5],["1.0×",10],["1.5×",15],["2.0×",20],["2.5×",25],["3.0×",30]]){
+    const o=document.createElement("option"); o.value=v; o.textContent=lbl; sel.appendChild(o); }
 }
 
 async function connect(){
@@ -219,7 +249,7 @@ function applyBlob(p){
   const mode=p[1], mDiv=p[2], mFric=p[3], qamMap=p[4], abSwap=p[5];   // p[4] = QAM remap code (0 = default)
   const back=[p[6],p[7],p[8],p[9]], 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 chord=(p.length>25)?[p[23],p[24],p[25]]:[3,1,2];   // v2 blob: chordBtn after qos/persist
+  const chord=(p.length>25)?[p[23],p[24],p[25]]:[3,1,4];   // v2 blob: chordBtn after qos/persist (Y defaults to Switch Pro=4)
   const polls=(p.length>27?(p[26]|(p[27]<<8)):0);          // poll TX rate
   const loopUs=(p.length>29?(p[28]|(p[29]<<8)):0);         // loop diag
   const worstIdx=(p.length>30?p[30]:0), worstUs=(p.length>32?(p[31]|(p[32]<<8)):0);
@@ -233,6 +263,9 @@ function applyBlob(p){
   $("#mouseCard").style.opacity = (mode===1||mode===3)?1:0.5;
   // status
   const battery=(p.length>36?p[36]:0);                    // controller battery % (report 0x43); 0 = unknown
+  const rumbleScale=(p.length>51?p[51]:200);              // rumble strength % (protocol v5+; older blob -> default)
+  const swProRate=(p.length>52?p[52]:1);                  // Switch Pro report rate 0/1/2 (protocol v6+; default 120Hz)
+  const swGyro10=(p.length>53?p[53]:10);                  // Switch Pro gyro scale x10 (protocol v6+)
   const rssi=(p.length>37?p[37]:0);                       // RAW signal strength |dBm| (0 = no sample)
   $("#stLink").innerHTML = up? '<span class="pill up">up</span>' : '<span class="pill dn">down</span>';
   $("#stBatt").textContent = (up && battery)? battery+"%" : "—";
@@ -252,14 +285,22 @@ function applyBlob(p){
     ? (gitHash + (gitDirty ? ' <span class="pill dn">dirty</span>' : ' <span class="pill up">clean</span>'))
     : "—";
   // sliders (only if not being dragged)
-  setSlider("mDiv",mDiv); setSlider("mFric",mFric);
+  setSlider("mDiv",mDiv); setSlider("mFric",mFric); setSlider("rumble",rumbleScale);
   $("#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]; }
   { const sel=$("#qamMap"); if(document.activeElement!==sel) sel.value=qamMap; }
+  // raw accelerometer diagnostic (protocol v7): 3x s16 LE at p[54..59]
+  if(p.length>=60){ const s16=(o)=>{ let v=p[o]|(p[o+1]<<8); return v>32767?v-65536:v; };
+    const ax=s16(54),ay=s16(56),az=s16(58);
+    const amag=Math.round(Math.sqrt(ax*ax+ay*ay+az*az));
+    $("#stImu").textContent = `a=(${ax}, ${ay}, ${az})  |a|=${amag}`;
+  }
+  { const sel=$("#swRate"); if(document.activeElement!==sel) sel.value=swProRate; }
+  { 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 ""+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;
@@ -362,8 +403,8 @@ $("#factoryErase").onclick=async()=>{
 };
 $("#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"]){
-  const field={mDiv:1,mFric:2}[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));
 }
@@ -371,11 +412,16 @@ for(const sel of document.querySelectorAll("select.back")){
   sel.addEventListener("change", ()=>setField(5 + +sel.dataset.i, +sel.value));
 }
 $("#qamMap").addEventListener("change", ()=>setField(21, +$("#qamMap").value));
+$("#swRate").addEventListener("change", ()=>setField(23, +$("#swRate").value));
+$("#swGyro").addEventListener("change", ()=>setField(24, +$("#swGyro").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"); } };
+  b.onclick=()=>{ const m=+b.dataset.mode;
+    const clean=(m===7||m===8);
+    const msg="Switch to "+MODE_NAMES[m]+"? The copycat will reboot."+(clean?"\n\nNOTE: the game/clean PlayStation modes drop WebUSB + host-wake, so THIS PANEL WILL DISCONNECT and can't reach the device while it's in this mode. To get back, chord on the controller: hold back-paddle 4 + A to return to Steam mode.":"");
+    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>';
 </script>