Jariullah Safi hai 4 meses
pai
achega
22e54b5316

+ 11 - 0
OpenPuck/config.cpp

@@ -64,3 +64,14 @@ void saveMode(uint8_t m){
 }
 
 void armDebugCdcNextBoot(){ g_debugCdc = 1; saveCfg(); }   // next boot keeps CDC; loadCfg() consumes it after
+
+// FULL factory wipe: erase ALL persistent storage in one shot -- cfg.bin (modes/tunables/chords), bonds.bin
+// (the paired-controller record), and anything else living in the internal LittleFS. format() reformats the
+// whole filesystem, so there's nothing left to selectively miss. The caller is expected to reboot immediately
+// (NVIC_SystemReset): on the next boot loadCfg()/loadBonds() find no files and fall back to clean defaults,
+// and the controller must be re-paired. Destructive and irreversible -- gated behind explicit confirmation at
+// every call site (serial "ERASE-ALL", WebUSB op 0x0A magic, both with on-screen warnings).
+void factoryErase(){
+  InternalFS.begin();      // ensure mounted before we reformat (no-op if already up)
+  InternalFS.format();     // wipes cfg.bin + bonds.bin + the entire FS
+}

+ 4 - 0
OpenPuck/config.h

@@ -65,6 +65,10 @@ extern uint16_t g_loopWorstUs;
 
 void loadCfg();
 void saveCfg();
+// FULL factory wipe: reformat the internal LittleFS, erasing cfg.bin (modes/tunables/chords) AND bonds.bin
+// (paired controller). Irreversible; the caller reboots so the next boot comes up on clean defaults and the
+// controller must be re-paired. Gated behind explicit confirmation at every call site.
+void factoryErase();
 // Mode switch (chord / WebUI): persist mode if the toggle is on, else arm a one-shot so this reboot lands in
 // the new mode but the next cold boot returns to Steam. Either way saveCfg + caller reboots.
 void saveMode(uint8_t m);

+ 1 - 1
OpenPuck/gamepad_util.cpp

@@ -69,7 +69,7 @@ uint32_t psButtonsFromSteam(uint32_t raw){
 uint8_t psShouldersByte(uint32_t b){
   return ((b&TB_LB)?0x01:0)|((b&TB_RB)?0x02:0)
          |((g_in.lt>SW_TRIG_ON||(b&0x8000000u))?0x04:0)|((g_in.rt>SW_TRIG_ON||(b&0x800000u))?0x08:0)
-         |((b&TB_MENU)?0x10:0)|((b&TB_VIEW)?0x20:0)   // Menu=Options, View=Create/Share
+         |((b&TB_VIEW)?0x10:0)|((b&TB_MENU)?0x20:0)   // View=Create/Share(bit4), Menu=Options(bit5)
          |((b&TB_L3)?0x40:0)|((b&TB_R3)?0x80:0);
 }
 uint8_t psHatNibble(uint32_t b){

+ 9 - 1
OpenPuck/haptics.cpp

@@ -199,7 +199,15 @@ void hapticReinit(){
   // and proven to clear a stuck haptic. Replayed verbatim and in order (no trimming this time: the brightness
   // write 0x87[2d] and the two 7-byte 0x81 frames are part of what Steam sends, and dropping them may be why
   // the trimmed version didn't always clear the connect buzz).
-  static const uint8_t H30[]={0x30,0x00,0x00,0x07,0x07,0x00,0x08,0x07,0x00,0x31,0x02,0x00,0x52,0x03,0x00};
+  // The leading triplet of Steam's captured sequence is [0x30 00 00] -- register 0x30 is SETTING_GYRO_MODE
+  // (Steam Controller register map: LED brightness 0x2D, gyro mode 0x30), and 0x0000 turns the IMU OFF. Steam
+  // writes it then re-enables gyro with its own follow-up; we only replay the haptic-clear subset, so replaying
+  // 0x30=0 just KILLS the gyro -- and this re-init fires on every reconnect (a mode-switch reboot), repeatedly
+  // across the buzz-flood, and after every haptic idle. That is the "gyro randomly stops, typically after
+  // switching to ps5/switch mode" bug. The controller streams gyro by default (it works on a fresh connect
+  // before any re-init), so the fix is to NOT touch reg 0x30 here. The 0x07/0x08/0x31/0x52 writes that remain
+  // are the haptic-engine registers that actually clear the latch.
+  static const uint8_t H30[]={0x07,0x07,0x00,0x08,0x07,0x00,0x31,0x02,0x00,0x52,0x03,0x00};
   static const uint8_t H18[]={0x18,0x00,0x00,0x2e,0x00,0x00,0x34,0xff,0xff,0x35,0xff,0xff,0x34,0xff,0xff};
   static const uint8_t H35[]={0x35,0xff,0xff,0x2e,0x00,0x00};
   static const uint8_t T81A[]={0x00,0x00,0x00,0x00,0x00,0x00,0x00};

+ 32 - 0
OpenPuck/mode_hidgyro.cpp

@@ -8,6 +8,37 @@
 
 HidGyroController g_hidGyroCtl;
 
+// DS4 calibration blob for feature report 0x02 (36 data bytes; TinyUSB prepends the 0x02 report id -> 37 bytes,
+// what hid-sony expects). Same byte layout and kernel math as DS5 (see mode_ps5.cpp DS5_CALIB for the full
+// derivation): both use DS4_GYRO_RES_PER_DEG_S=1024, DS4_ACC_RES_PER_G=8192, speed_2x = speed_plus + speed_minus
+// (locals are `short`), gyro denom = pitch_plus - pitch_minus, accel bias = acc_plus - range/2.
+// CRITICAL: speed_plus AND speed_minus must BOTH be POSITIVE -- the old blob stored speed_minus as -438, so
+// speed_2x = 438 + (-438) = 0 -> sens_numer = 0 -> dead gyro. Scale assumes ±2000 deg/s (16.384 LSB per deg/s)
+// and ±2 g (16384 LSB/g) raw; trim the pitch/yaw/roll +/- (gyro) or accel +/- (accel) if magnitude is off.
+static const uint8_t DS4_CALIB[] = {
+  0x00,0x00, 0x00,0x00, 0x00,0x00,   // gyro pitch/yaw/roll bias = 0
+  0x00,0x08, 0x00,0xF8,              // gyro pitch plus=+2048 / minus=-2048
+  0x00,0x08, 0x00,0xF8,              // gyro yaw   plus=+2048 / minus=-2048
+  0x00,0x08, 0x00,0xF8,              // gyro roll  plus=+2048 / minus=-2048
+  0x7D,0x00, 0x7D,0x00,              // gyro speed+ =+125 / speed- =+125 (BOTH positive -> speed_2x=250)
+  0x00,0x40, 0x00,0xC0,              // accel X plus=+16384 / minus=-16384 (range 32768 -> /2)
+  0x00,0x40, 0x00,0xC0,              // accel Y
+  0x00,0x40, 0x00,0xC0,              // accel Z
+  0x00,0x00,                         // padding to 36
+};
+
+static uint16_t hidGyroGet(uint8_t rid, hid_report_type_t type, uint8_t* buf, uint16_t reqlen) {
+  if (type != HID_REPORT_TYPE_FEATURE || reqlen == 0) return 0;
+  memset(buf, 0, reqlen);
+  if (rid == 0x02) {
+    uint16_t n = (uint16_t)sizeof(DS4_CALIB);
+    if (n > reqlen) n = reqlen;
+    memcpy(buf, DS4_CALIB, n);
+    return n;
+  }
+  return reqlen;
+}
+
 static const uint8_t GYRO_HID_DESC[]={
   0x05,0x01,0x09,0x05,0xA1,0x01,0x85,0x01,0x09,0x30,0x09,0x31,0x09,0x32,0x09,0x35,
   0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x04,0x81,0x02,0x09,0x39,0x15,0x00,0x25,
@@ -60,6 +91,7 @@ void HidGyroController::begin(){
   USBDevice.setProductDescriptor("Wireless Controller");
   g_hidGyro.enableOutEndpoint(true);
   g_hidGyro.setReportDescriptor(GYRO_HID_DESC, sizeof GYRO_HID_DESC);
+  g_hidGyro.setReportCallback(hidGyroGet, nullptr);
   g_hidGyro.setPollInterval(4);
   g_hidGyro.begin();
 }

+ 39 - 0
OpenPuck/mode_ps5.cpp

@@ -8,6 +8,44 @@
 
 Ps5Controller g_ps5Ctl;
 
+// DS5 calibration blob for feature report 0x05 (40 data bytes; TinyUSB prepends the 0x05 report id, so the
+// host receives the 41 bytes hid-playstation expects). hid-playstation/SDL read this to scale the raw IMU.
+// Layout (signed-LE16, kernel buf[1..] == blob[0..]): pitch/yaw/roll bias, pitch+/-, yaw+/-, roll+/-, speed+/-,
+// accel x/y/z +/-. Kernel math (all locals are `short`):
+//   gyro:  out = raw * (speed_2x * 1024) / (|pitch_plus| + |pitch_minus|),  speed_2x = speed_plus + speed_minus
+//   accel: out = (raw - bias) * (2*8192) / (acc_plus - acc_minus),  bias = acc_plus - range/2
+// CRITICAL: speed_plus AND speed_minus are SPEED MAGNITUDES and must BOTH be POSITIVE -- they're summed. The
+// old blob stored speed_minus as -1024 (0xFC00), so speed_2x = 1024 + (-1024) = 0 -> sens_numer = 0 -> EVERY
+// gyro axis multiplied by zero -> dead gyro. Only the per-axis pitch/yaw/roll_minus are negative (they feed an
+// abs() range). Scale assumes the controller's raw IMU is the common 16-bit default ±2000 deg/s (16.384 LSB per
+// deg/s) and ±2 g (16384 LSB/g): gyro ratio = speed_2x*1024/range = 250*1024/4096 = 62.5 = 1024/16.384 (-> out
+// in the kernel's 1024-per-deg/s units); accel ratio = 16384/32768 = 0.5 (16384 LSB/g raw -> 8192 LSB/g out).
+// If the gyro/accel magnitude reads too hot or too cold, the controller's true LSB-per-unit differs -- scale
+// the pitch/yaw/roll +/- (gyro) or the accel +/- (accel) by the same factor. See PROTOCOL.md §8.
+static const uint8_t DS5_CALIB[] = {
+  0x00,0x00, 0x00,0x00, 0x00,0x00,   // gyro pitch/yaw/roll bias = 0
+  0x00,0x08, 0x00,0xF8,              // gyro pitch plus=+2048 / minus=-2048
+  0x00,0x08, 0x00,0xF8,              // gyro yaw   plus=+2048 / minus=-2048
+  0x00,0x08, 0x00,0xF8,              // gyro roll  plus=+2048 / minus=-2048
+  0x7D,0x00, 0x7D,0x00,              // gyro speed+ =+125 / speed- =+125 (BOTH positive -> speed_2x=250)
+  0x00,0x40, 0x00,0xC0,              // accel X plus=+16384 / minus=-16384 (range 32768 -> /2)
+  0x00,0x40, 0x00,0xC0,              // accel Y
+  0x00,0x40, 0x00,0xC0,              // accel Z
+  0x00,0x00, 0x00,0x00, 0x00,0x00,   // padding to 40
+};
+
+static uint16_t ps5Get(uint8_t rid, hid_report_type_t type, uint8_t* buf, uint16_t reqlen) {
+  if (type != HID_REPORT_TYPE_FEATURE || reqlen == 0) return 0;
+  memset(buf, 0, reqlen);
+  if (rid == 0x05) {
+    uint16_t n = (uint16_t)sizeof(DS5_CALIB);
+    if (n > reqlen) n = reqlen;
+    memcpy(buf, DS5_CALIB, n);
+    return n;
+  }
+  return reqlen;
+}
+
 static const uint8_t PS5_HID_DESC[]={
   0x05,0x01,0x09,0x05,0xA1,0x01,0x85,0x01,0x09,0x30,0x09,0x31,0x09,0x32,0x09,0x35,
   0x09,0x33,0x09,0x34,0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x06,0x81,0x02,0x06,
@@ -62,6 +100,7 @@ void Ps5Controller::begin(){
   USBDevice.setProductDescriptor("DualSense Wireless Controller");
   g_ps5.enableOutEndpoint(true);
   g_ps5.setReportDescriptor(PS5_HID_DESC, sizeof PS5_HID_DESC);
+  g_ps5.setReportCallback(ps5Get, nullptr);
   g_ps5.setPollInterval(4);
   g_ps5.begin();
 }

+ 9 - 1
OpenPuck/rf_link.cpp

@@ -190,7 +190,15 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t* payload, uint8_t plen, u
             g_in.lt=trigU8(u16off(rep,4));   g_in.rt=trigU8(u16off(rep,6));   // for the Switch digital-trigger threshold
             g_in.lpx=(int16_t)s16off(rep,16); g_in.lpy=(int16_t)s16off(rep,18);
             g_in.rpx=(int16_t)s16off(rep,22); g_in.rpy=(int16_t)s16off(rep,24);
-            imuFrom45(rep, &g_in.ax,&g_in.ay,&g_in.az,&g_in.gx,&g_in.gy,&g_in.gz);
+            // IMU lives at report bytes 0x22..0x2D (rep[34..45]). Decode it ONLY when a FULL 46-byte report
+            // was actually received -- bounded by `end` (the received length), NOT sizeof(rfrx) (the whole
+            // buffer). The outer gate is tlen>=28 (enough for buttons/sticks/pads, which end at rep[27]), so a
+            // short 0x45 (button-only frame, or one whose IMU tail was lost) still passes it; without this guard
+            // imuFrom45 would read STALE bytes past the received data and clobber g_in's gyro/accel -- the "gyro
+            // randomly stops/glitches" bug, only visible in the stream modes (PS5/Switch) that emit g_in's IMU.
+            // On a short frame we leave the last good IMU in place rather than injecting garbage.
+            if(tlen>=46 && (size_t)(idx+2)+46<=(size_t)end)
+              imuFrom45(rep, &g_in.ax,&g_in.ay,&g_in.az,&g_in.gx,&g_in.gy,&g_in.gz);
             // Mode-switch chord (all 4 back + face): don't leak the face press to the host. g_in.buttons stays
             // intact so the chord detector still fires; per-mode builders mask the same bits while back-4 held.
             if((bb&CHORD_BACK4)==CHORD_BACK4)

+ 6 - 1
OpenPuck/serial_console.cpp

@@ -8,6 +8,7 @@
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 #include <stdlib.h>
+#include <string.h>
 
 // CDC commands: l=listen, s=stop, cN=channel, p<hex>=prefix, a<8hex>=base addr, b=bonds, etc.
 // NOTE: command order matters -- this is an else-if chain, so the FIRST matching letter wins. A couple of
@@ -17,7 +18,11 @@ void serialConsolePoll(){
   while (Serial.available()){
     char c=Serial.read();
     if (c=='\n'||c=='\r'){ line[li]=0;
-      if (line[0]=='l') rfListenStart();
+      // FULL factory wipe -- requires the exact word "ERASE-ALL" (not a single letter) so it can't be fat-
+      // fingered. Reformats the internal FS (cfg.bin + bonds.bin gone), then reboots into clean defaults; the
+      // controller must be re-paired afterwards.
+      if (!strcmp(line,"ERASE-ALL")){ Serial.println("# ERASING ALL persistent storage (config + bonds)..."); factoryErase(); Serial.println("# done -- rebooting into clean defaults; re-pair the controller"); delay(40); NVIC_SystemReset(); }
+      else if (line[0]=='l') rfListenStart();
       else if (line[0]=='B'){ g_rfBeacon=!g_rfBeacon; g_rfListen=false; Serial.printf("# beacon %s ch%u\n",g_rfBeacon?"ON":"off",g_rfCh); }
       else if (line[0]=='L'){ g_plen=strtol(line+1,0,16); Serial.printf("# plen=%02X\n",g_plen); }
       else if (line[0]=='1'){ g_s1incl=!g_s1incl; Serial.printf("# s1incl=%u\n",g_s1incl); }

+ 7 - 2
OpenPuck/webusb_config.cpp

@@ -72,8 +72,8 @@ void webusbPoll(){
     // process complete commands from the front of buf
     for(;;){
       if(n==0) break;
-      uint8_t op=buf[0]; uint8_t need = (op==0x02)?3 : (op==0x03||op==0x05)?2 : (op==0x09)?4 : 1;   // 0x05 carries a value byte; 0x09 carries R,G,B
-      if(op<0x01 || op>0x09){ // resync: drop one byte
+      uint8_t op=buf[0]; uint8_t need = (op==0x02)?3 : (op==0x03||op==0x05)?2 : (op==0x09||op==0x0A)?4 : 1;   // 0x05 carries a value byte; 0x09 carries R,G,B; 0x0A carries a 3-byte magic
+      if(op<0x01 || op>0x0A){ // resync: drop one byte
         memmove(buf,buf+1,--n); continue;
       }
       if(n<need) break;                      // wait for more bytes
@@ -84,6 +84,11 @@ void webusbPoll(){
 #endif
       else if(op==0x07){ hapticArmBuzzFlood(); hapticReinit(); }   // (re)arm the 30s 10Hz buzz-clear flood; fire one now for instant feedback
       else if(op==0x08){ hapticSendShutdown(); }                   // TEST: trigger the controller power-off attempt (same path host-suspend uses)
+      else if(op==0x0A){                                           // FULL factory wipe: erase cfg.bin + bonds.bin, reboot to clean defaults.
+        // Guarded by a 3-byte magic ("ERS") so a stray/corrupt byte can never trigger it; the panel double-
+        // confirms before sending. Irreversible -- the controller must be re-paired afterwards.
+        if(buf[1]==0x45 && buf[2]==0x52 && buf[3]==0x53){ usb_web.flush(); factoryErase(); delay(40); NVIC_SystemReset(); }
+      }
       else if(op==0x09){                                           // set LED color: feature-0x01 cmd 0xC1, 16-byte RGBW payload (from led-set capture)
         uint8_t led[16]={buf[1],buf[2],buf[3],0xff, 0x03,0x09,0x05, 0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0xff};   // [R][G][B][W=ff][03 09 05][ff x9] -- captured frame, RGB substituted
         relayEnqueue(0xC1, led, sizeof led);

+ 15 - 0
docs/index.html

@@ -99,6 +99,10 @@
       <div class="row" style="margin:10px 0 0"><input type="color" id="ledColor" value="#ffffff"><button id="ledSet">Set LED color</button>
         <span class="note" style="flex:1">Sends the controller LED-color command (0xC1 RGBW). Brightness is separate (Steam's 0x87 reg 2d).</span>
       </div>
+      <div class="row" style="margin:18px 0 0;border-top:1px solid #4a2030;padding-top:14px">
+        <button id="factoryErase" style="background:#3a1420;border-color:#7a2233;color:#ff9bb0;font-weight:600">⚠ Factory erase</button>
+        <span class="note" style="flex:1"><strong style="color:#ff9bb0">Danger:</strong> wipes <em>all</em> persistent storage on the copycat — the paired controller bond <em>and</em> every saved setting (mode, chords, back-paddle map, sensitivity). The copycat reboots to factory defaults and you must <strong>re-pair the controller</strong>. This cannot be undone.</span>
+      </div>
     </div>
 
     <div class="card" id="mouseCard">
@@ -336,6 +340,17 @@ let buzzFloodTimer=null;
 $("#hapClear").onclick=async()=>{ if(dev){ await send([0x07]); $("#hapClear").classList.add("active"); clearTimeout(buzzFloodTimer); buzzFloodTimer=setTimeout(()=>$("#hapClear").classList.remove("active"),30000); log("buzz-clear flood armed — 10Hz re-init for 30s (click again to restart)"); } };
 $("#ctlrOff").onclick=async()=>{ if(dev){ await send([0x08]); log("controller power-off attempt sent — watch link status / capture"); } };
 $("#ledSet").onclick=async()=>{ if(dev){ const h=$("#ledColor").value; const r=parseInt(h.substr(1,2),16),g=parseInt(h.substr(3,2),16),b=parseInt(h.substr(5,2),16); await send([0x09,r,g,b]); log(`LED color set ${h} (r${r} g${g} b${b})`); } };
+$("#factoryErase").onclick=async()=>{
+  if(!dev){ log("not connected"); return; }
+  // Triple gate so this is impossible to fire accidentally: warn, warn harder, then require typing ERASE.
+  if(!confirm("Factory erase?\n\nThis wipes ALL persistent storage on the copycat:\n  • the paired-controller bond (you'll have to re-pair)\n  • every saved setting (mode, chords, back paddles, sensitivity)\n\nThe copycat reboots to factory defaults. This CANNOT be undone.")) return;
+  if(!confirm("Are you absolutely sure?\n\nThere is no recovery. The controller bond and all settings will be gone.")) return;
+  const typed=prompt('Final confirmation — type  ERASE  (all caps) to wipe everything:');
+  if(typed!=="ERASE"){ log("factory erase cancelled (confirmation text did not match)"); return; }
+  // op 0x0A + magic "ERS" (0x45,0x52,0x53) -- firmware reformats the FS and reboots; the device drops off USB.
+  await send([0x0A,0x45,0x52,0x53]);
+  log("FACTORY ERASE sent — copycat is reformatting and rebooting to defaults. Re-pair the controller, then reconnect.");
+};
 $("#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"]){