OpenPuck.ino 132 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006
  1. // OpenPuck.ino -- Steam Controller 2 ("Triton") puck reimplementation for an nRF52840.
  2. //
  3. // This sketch impersonates the Valve puck over USB, maintains puck-style bond slots, speaks the
  4. // reverse-engineered RF protocol to the controller, and re-enumerates into Steam, Xbox, or Switch
  5. // personalities. The real puck exposes four separate HID control interfaces; interface N owns bond
  6. // slot N, so this sketch does the same. Build with -DCFG_TUD_HID=4 (the Adafruit nRF port defaults to 2).
  7. //
  8. // USB presentations (7 modes): Steam puck (28DE:1304) with auto-lizard when Steam closes; Lizard = always
  9. // kb/mouse on the puck; Xbox = XInput+mouse; Switch HORIPAD (0F0D:0092); Switch Pro (057E:2009)+gyro;
  10. // PS5 DualSense (054C:0CE6)+gyro+split trackpad; HID gyro = DS4-layout (054C:05C4) for motion-aware games.
  11. // Every mode exposes a WebUSB config channel (USB 2.1 + BOS). Clean controller modes omit CDC Serial.
  12. //
  13. // Protocol reference: docs/PROTOCOL.md. Bond record = [8 uuid][16 serial].
  14. #include <Adafruit_TinyUSB.h>
  15. #include <Adafruit_LittleFS.h>
  16. #include <InternalFileSystem.h>
  17. // custom class-driver API (usbd_class_driver_t, usbd_edpt_*) for the XInput interface
  18. extern "C" {
  19. #include "device/usbd_pvt.h"
  20. }
  21. // Bounded radio-disable wait: NEVER spin forever. A wedged RADIO peripheral would otherwise hang the whole
  22. // main loop (and USB stops being serviced -> device "dies" until replug). On timeout we bail and continue;
  23. // the next rfConfig re-inits the radio.
  24. #define RWAIT_DISABLED() do{ uint32_t _w=micros(); while(!NRF_RADIO->EVENTS_DISABLED && (uint32_t)(micros()-_w)<3000){} }while(0)
  25. using namespace Adafruit_LittleFS_Namespace;
  26. #if CFG_TUD_HID < 4
  27. #error "build with -DCFG_TUD_HID=4 (extra_flags): up to 4 HID interfaces per mode"
  28. #endif
  29. // ---- cloned puck HID report descriptor (verbatim): mouse(0x40)+keyboard(0x41)+vendor(FF00) with
  30. // the 63-byte FEATURE command reports on report id 1/2. Each of the 4 interfaces uses this. ----
  31. static const uint8_t PUCK_HID_DESC[] = {
  32. 0x05,0x01,0x09,0x02,0xA1,0x01,0x85,0x40,0x09,0x01,0xA1,0x00,0x05,0x09,0x19,0x01,
  33. 0x29,0x02,0x15,0x00,0x25,0x01,0x75,0x01,0x95,0x02,0x81,0x02,0x75,0x06,0x95,0x01,
  34. 0x81,0x01,0x05,0x01,0x09,0x30,0x09,0x31,0x15,0x81,0x25,0x7F,0x75,0x08,0x95,0x02,
  35. 0x81,0x06,0x95,0x01,0x09,0x38,0x81,0x06,0x05,0x0C,0x0A,0x38,0x02,0x95,0x01,0x81,
  36. 0x06,0xC0,0xC0,0x05,0x01,0x09,0x06,0xA1,0x01,0x85,0x41,0x05,0x07,0x19,0xE0,0x29,
  37. 0xE7,0x15,0x00,0x25,0x01,0x75,0x01,0x95,0x08,0x81,0x02,0x81,0x01,0x19,0x00,0x29,
  38. 0x65,0x15,0x00,0x25,0x65,0x75,0x08,0x95,0x06,0x81,0x00,0xC0,0x06,0x00,0xFF,0x09,
  39. 0x01,0xA1,0x01,0x85,0x42,0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x35,0x09,0x42,
  40. 0x81,0x02,0x85,0x44,0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x05,0x09,0x44,0x81,
  41. 0x02,0x85,0x79,0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x01,0x09,0x79,0x81,0x02,
  42. 0x85,0x43,0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x0E,0x09,0x43,0x81,0x02,0x85,
  43. 0x7B,0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x0C,0x09,0x7B,0x81,0x02,0x85,0x45,
  44. 0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x2D,0x09,0x45,0x81,0x02,0x85,0x80,0x15,
  45. 0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x09,0x09,0x80,0x91,0x02,0x85,0x81,0x15,0x00,
  46. 0x26,0xFF,0x00,0x75,0x08,0x95,0x07,0x09,0x81,0x91,0x02,0x85,0x82,0x15,0x00,0x26,
  47. 0xFF,0x00,0x75,0x08,0x95,0x03,0x09,0x82,0x91,0x02,0x85,0x83,0x15,0x00,0x26,0xFF,
  48. 0x00,0x75,0x08,0x95,0x09,0x09,0x83,0x91,0x02,0x85,0x84,0x15,0x00,0x26,0xFF,0x00,
  49. 0x75,0x08,0x95,0x08,0x09,0x84,0x91,0x02,0x85,0x85,0x15,0x00,0x26,0xFF,0x00,0x75,
  50. 0x08,0x95,0x03,0x09,0x85,0x91,0x02,0x85,0x86,0x15,0x00,0x26,0xFF,0x00,0x75,0x08,
  51. 0x95,0x03,0x09,0x86,0x91,0x02,0x85,0x87,0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,
  52. 0x3F,0x09,0x87,0x91,0x02,0x85,0x89,0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x3F,
  53. 0x09,0x89,0x91,0x02,0x85,0x88,0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x3F,0x09,
  54. 0x88,0x91,0x02,0x85,0x01,0x95,0x3F,0x09,0x01,0xB1,0x02,0x85,0x02,0x95,0x3F,0x09,
  55. 0x01,0xB1,0x02,0xC0
  56. };
  57. #define NSLOT 4
  58. static uint8_t g_usbCfgDesc[512]; // puck composite (4 HID + WebUSB) exceeds the default 256 B config buffer
  59. Adafruit_USBD_HID hid[NSLOT];
  60. // XBOX MODE = a MUTUALLY-EXCLUSIVE USB presentation: the device enumerates as EITHER the 4 puck slot
  61. // interfaces (Steam mode) OR an XInput controller + a mouse (Xbox mode) — never both. The mode is persisted to
  62. // flash and a button combo (back4+X -> xbox, back4+A -> steam) saves it and reboots, so each boot enumerates
  63. // the right interface set. RF (beacon/poll/bonds) is identical in both modes; only the USB side differs.
  64. static const uint8_t MOUSE_HID_DESC[] = { TUD_HID_REPORT_DESC_MOUSE() };
  65. Adafruit_USBD_HID g_mouse; // Xbox-mode mouse interface (right trackpad). Steam-mode lizard reuses the puck's own 0x40 mouse report instead.
  66. // USB presentation modes (g_usbMode). RF poll/relay is identical across all; only USB enumeration + report mapping differs.
  67. #define MODE_STEAM 0 // Valve puck; auto-lizard when Steam closed
  68. #define MODE_XBOX 1 // XInput + right-pad mouse
  69. #define MODE_SW_HORI 2 // HORIPAD (Switch console whitelist)
  70. #define MODE_LIZARD 3 // Puck HID; always keyboard+mouse (ignores Steam heartbeat)
  71. #define MODE_SW_PRO 4 // Nintendo Switch Pro Controller (057E:2009) + gyro
  72. #define MODE_PS5 5 // Sony DualSense (054C:0CE6) + gyro + split trackpad
  73. #define MODE_HIDGYRO 6 // DS4-layout generic HID gamepad + gyro (Fortnite-friendly)
  74. #define MODE_MAX 6
  75. static uint8_t g_usbMode = 0; // loaded from flash at boot
  76. static bool g_xbox = false; // true for all non-puck presentations (not STEAM/LIZARD)
  77. static uint8_t g_chordBtn[3] = { MODE_LIZARD, MODE_XBOX, MODE_SW_HORI }; // back4+B/X/Y -> these modes (A always STEAM)
  78. static inline bool modeIsPuck(uint8_t m){ return m==MODE_STEAM || m==MODE_LIZARD; }
  79. static inline bool modeValid(uint8_t m){ return m<=MODE_MAX; }
  80. // Mode persistence policy: by DEFAULT every fresh power-on/reconnect lands in STEAM mode (0). An explicit
  81. // mode switch still works for the session via a ONE-SHOT bootMode (honored once, then cleared, so the next
  82. // cold boot reverts to Steam). The WebUI "persist last mode" toggle (g_persistMode) instead remembers the
  83. // last selected mode across reboots.
  84. static bool g_persistMode = false; // false (default) = always boot Steam; true = boot into last mode
  85. static uint8_t g_bootMode = 0xFF; // one-shot: boot into this mode once then clear (used when !persistMode + explicit switch)
  86. volatile uint8_t g_rumble = 0; // legacy XInput rumble strength from the host's OUT packet (0=off); drives the haptic relay when the XInput descriptor is used
  87. volatile unsigned long g_rumbleMs = 0; // millis of last rumble OUT packet (declared here — used by xi_xfer above the watchdog block); stuck-rumble watchdog
  88. // persisted, runtime-tunable config (Cfg struct + load/saveCfg below; CDC M/F/D/W/B + WebUSB set these):
  89. static int g_mDiv = 64, g_mFric = 94; // xbox mouse sensitivity divisor / friction%
  90. static uint8_t g_abSwap = 0; // 1 = swap A/B and X/Y (Nintendo face-button layout)
  91. static uint8_t g_back[4] = {5,6,7,8}; // back paddles L4,R4,L5,R5 -> button codes (see codeToXB; 5=LB 6=RB 7=L3 8=R3)
  92. #define POLL_US_DEFAULT 4000u // 250 Hz — matches SC2 input report rate (1000000/250 = 4000 us)
  93. #define USB_STREAM_MS 4u // host-side HID stream cadence for translated modes (~250 Hz)
  94. static const uint32_t g_pollUs = POLL_US_DEFAULT; // RF poll cadence (us). FIXED — not configurable. Any rate persisted by an older build is ignored and overwritten with the default on boot (see loadCfg).
  95. // Faster than the controller can refresh wastes airtime; slower adds latency.
  96. static uint32_t g_rxWin = 1200; // poll RX-window (us): max wait for the reply. This (not pollUs) caps poll rate
  97. // (~1e6/rxWin). Shorter=more polls/s but may miss the controller's DELAYED replies
  98. // during trackpad. Tunable 'r<us>'. The reply normally arrives fast; 1200 is the worst-case wait.
  99. static uint8_t g_fwdNewOnly = 1; // Steam mode: forward report 0x45 only when seq advanced (dedupe like the real puck).
  100. // Sending stale repeats makes Steam's trackpad smoothing stair-step. Toggle to A/B.
  101. // QoS adaptive channel hopping: track link badness (crcfail+noRx) on the current channel; when it degrades,
  102. // hop to the next clean candidate (controller follows via rfHopTo). Conservative hysteresis to avoid blip-storms.
  103. static uint8_t g_qos = 0; // 0=off (static g_sessCh), 1=auto-hop on degradation
  104. static const uint8_t g_hopCand[] = {18,46,76,22,68}; // clean, spread channels (from the puck's RSSI/PER scan)
  105. static uint8_t g_hopIdx = 0;
  106. static volatile uint16_t g_qosBad = 0; // crcfail+noRx accumulated since last QoS check
  107. static unsigned long g_qosCheckMs = 0, g_qosLastHopMs = 0;
  108. // WebUSB config channel — a dedicated vendor interface present in every mode so the browser panel
  109. // (copycat_config.html) can read/set the tunables above. Protocol + poll in webusbPoll() below.
  110. // NOTE: no setLandingPage() — that would make Chrome pop a "device detected, open <url>" notification
  111. // every plug-in. The interface works without it; the user just opens copycat_config.html themselves.
  112. Adafruit_USBD_WebUSB usb_web;
  113. // ===================== XInput (Xbox 360 wired) device — Xbox-mode gamepad =====================
  114. // Real Xbox 360 pads are NOT HID: vendor interface class 0xFF/sub 0x5D/proto 0x01 + 20-byte XInput report.
  115. // Custom TinyUSB class driver (registered via usbd_app_driver_get_cb) + an Adafruit_USBD_Interface subclass
  116. // that emits the interface+0x21-blob+2 endpoints. Steam/SDL bind it as "Xbox 360 Controller" by VID/PID +
  117. // the FF/5D/01 interface. (Verified against Adafruit nRF52 1.7.0 / TinyUSB 0.18.) XInput button bits:
  118. enum { XB_DUP=0x0001,XB_DDOWN=0x0002,XB_DLEFT=0x0004,XB_DRIGHT=0x0008, XB_START=0x0010,XB_BACK=0x0020,
  119. XB_L3=0x0040,XB_R3=0x0080, XB_LB=0x0100,XB_RB=0x0200,XB_GUIDE=0x0400, XB_A=0x1000,XB_B=0x2000,XB_X=0x4000,XB_Y=0x8000 };
  120. #define XINPUT_DESC_LEN (9 + 17 + 7 + 7) // interface(9)+vendor0x21(17)+IN ep(7)+OUT ep(7) = 40
  121. static uint8_t g_xiItf=0xFF, g_xiEpIn=0, g_xiEpOut=0;
  122. static uint8_t g_xiInBuf[32], g_xiOutBuf[32];
  123. static void xi_init(void){}
  124. static bool xi_deinit(void){return true;}
  125. static void xi_reset(uint8_t rhport){(void)rhport; g_xiEpIn=g_xiEpOut=0;}
  126. static uint16_t xi_open(uint8_t rhport, tusb_desc_interface_t const* itf, uint16_t max_len){
  127. if(!(itf->bInterfaceClass==0xFF && itf->bInterfaceSubClass==0x5D && itf->bInterfaceProtocol==0x01)) return 0;
  128. g_xiItf=itf->bInterfaceNumber;
  129. uint8_t const* p=(uint8_t const*)itf; uint8_t const* end=p+max_len; uint16_t used=itf->bLength; p+=itf->bLength;
  130. uint8_t opened=0;
  131. while(p<end && opened<itf->bNumEndpoints){
  132. uint8_t blen=p[0], btype=p[1];
  133. if(btype==TUSB_DESC_ENDPOINT){ tusb_desc_endpoint_t const* ep=(tusb_desc_endpoint_t const*)p;
  134. usbd_edpt_open(rhport,ep);
  135. if(tu_edpt_dir(ep->bEndpointAddress)==TUSB_DIR_IN) g_xiEpIn=ep->bEndpointAddress; else g_xiEpOut=ep->bEndpointAddress;
  136. opened++; }
  137. used+=blen; p+=blen;
  138. }
  139. if(g_xiEpOut) usbd_edpt_xfer(rhport,g_xiEpOut,g_xiOutBuf,sizeof g_xiOutBuf); // arm OUT (rumble/LED)
  140. return used;
  141. }
  142. static bool xi_ctrl(uint8_t rhport,uint8_t stage,tusb_control_request_t const* req){(void)rhport;(void)req; return stage!=CONTROL_STAGE_SETUP;}
  143. static bool xi_xfer(uint8_t rhport,uint8_t ep,xfer_result_t res,uint32_t n){(void)res;
  144. if(ep==g_xiEpOut){
  145. // XInput rumble packet: [00][08][00][bigMotor][smallMotor][00][00][00]; LED pkt is [01][03][led]
  146. if(n>=5 && g_xiOutBuf[0]==0x00 && g_xiOutBuf[1]==0x08){
  147. uint8_t big=g_xiOutBuf[3], sml=g_xiOutBuf[4]; g_rumble = big>sml?big:sml; g_rumbleMs = millis(); // stamp for the stuck-rumble watchdog
  148. }
  149. usbd_edpt_xfer(rhport,g_xiEpOut,g_xiOutBuf,sizeof g_xiOutBuf); // re-arm OUT
  150. }
  151. return true; }
  152. static const usbd_class_driver_t g_xiDriver = {
  153. #if CFG_TUSB_DEBUG>=2
  154. .name="XINPUT",
  155. #endif
  156. .init=xi_init, .deinit=xi_deinit, .reset=xi_reset, .open=xi_open,
  157. .control_xfer_cb=xi_ctrl, .xfer_cb=xi_xfer, .sof=NULL };
  158. extern "C" const usbd_class_driver_t* usbd_app_driver_get_cb(uint8_t* count){ *count=1; return &g_xiDriver; }
  159. class Adafruit_USBD_XInput : public Adafruit_USBD_Interface {
  160. public:
  161. uint16_t getInterfaceDescriptor(uint8_t, uint8_t* buf, uint16_t bufsize) override {
  162. if(!buf) return XINPUT_DESC_LEN; if(bufsize<XINPUT_DESC_LEN) return 0;
  163. uint8_t itfnum=TinyUSBDevice.allocInterface(1);
  164. uint8_t epin=TinyUSBDevice.allocEndpoint(TUSB_DIR_IN), epout=TinyUSBDevice.allocEndpoint(TUSB_DIR_OUT);
  165. const uint8_t t[XINPUT_DESC_LEN]={
  166. 9,TUSB_DESC_INTERFACE,itfnum,0x00,0x02,0xFF,0x5D,0x01,_strid,
  167. 0x11,0x21,0x00,0x01,0x01,0x25,epin,0x14,0x00,0x00,0x00,0x00,0x13,epout,0x08,0x00,0x00,
  168. 7,TUSB_DESC_ENDPOINT,epin, TUSB_XFER_INTERRUPT,U16_TO_U8S_LE(0x20),1, // bInterval 1ms (1000Hz) so the RF rate is the only limit
  169. 7,TUSB_DESC_ENDPOINT,epout,TUSB_XFER_INTERRUPT,U16_TO_U8S_LE(0x20),8 };
  170. memcpy(buf,t,XINPUT_DESC_LEN); return XINPUT_DESC_LEN;
  171. }
  172. bool begin(){ return TinyUSBDevice.addInterface(*this); }
  173. };
  174. Adafruit_USBD_XInput g_xinput;
  175. static void xinputSend(uint16_t buttons,uint8_t lt,uint8_t rt,int16_t lx,int16_t ly,int16_t rx,int16_t ry){
  176. if(!tud_mounted() || g_xiEpIn==0 || usbd_edpt_busy(0,g_xiEpIn)) return;
  177. uint8_t* r=g_xiInBuf;
  178. r[0]=0x00; r[1]=0x14; r[2]=buttons&0xFF; r[3]=buttons>>8; r[4]=lt; r[5]=rt;
  179. r[6]=lx&0xFF; r[7]=lx>>8; r[8]=ly&0xFF; r[9]=ly>>8; r[10]=rx&0xFF; r[11]=rx>>8; r[12]=ry&0xFF; r[13]=ry>>8;
  180. memset(r+14,0,6);
  181. if(usbd_edpt_claim(0,g_xiEpIn)){ if(!usbd_edpt_xfer(0,g_xiEpIn,r,20)) usbd_edpt_release(0,g_xiEpIn); }
  182. }
  183. // ---- identity: a UNIQUE serial from the FICR DEVICEID (never clashes with a real puck) ----
  184. static char g_unit[16]; // "FXB99602xxxxx"
  185. static char g_board[16]; // "MXB99602xxxxx"
  186. static char g_usbSerial[18]; // per-mode USB serial (Steam uses g_unit; others get a suffix — see setup)
  187. static const uint8_t ATTR83[] = { // 0x83 attributes (product 0x1304 = puck)
  188. 0x01,0x04,0x13,0x00,0x00,0x02,0x00,0x00,0x00,0x00,0x0A,0xF2,0xF9,0xD2,0x68,
  189. 0x04,0x53,0xD0,0x18,0x6A,0x09,0x47,0x00,0x00,0x00 };
  190. // ---- per-slot state (record = [8 uuid][16 serial]) + per-interface response staging ----
  191. struct Slot { uint8_t rec[24]; bool used; uint8_t resp[63]; uint16_t resp_len; };
  192. static Slot g_slot[NSLOT];
  193. // connection tracking (defined here so the USB feature handler can report B4 connection state)
  194. static int g_connSlot = -1; // bonded slot being polled (== the USB-connected slot)
  195. static unsigned long g_connReplyMs = 0; // millis of last RF reply (link-alive timestamp)
  196. // host->controller relay: Steam writes feature report id 0x01 (raw passthrough) carrying a controller
  197. // feature report [report-id][len][payload...] (e.g. 87 03 09 00 00 = settings: lizard-mode off). The real
  198. // dongle forwards it to the controller as a SET sub-TLV in the E3 poll; the copycat must do the same.
  199. static volatile bool g_relayPend = false;
  200. static uint8_t g_relayBuf[24];
  201. static volatile uint8_t g_relayN = 0;
  202. static uint8_t g_relayOp = 0xE3; // relay frame opcode (E3 poll)
  203. static uint8_t g_relaySub = 0x05; // relay sub-TLV type byte = SET (confirmed on hardware: E3 + [len][05][rid][payload] makes the controller act on it — haptics buzz + lizard-off lands). GET is 01.
  204. static volatile uint8_t g_testHaptic = 0; // 't<n>' injects n test haptics (output 0x82 [01 01 F7]) for the buzz hunt
  205. // Haptic watchdog — stops the "stuck high-freq buzz" when the host's stop command is lost over RF.
  206. // Xbox rumble is a persistent LEVEL with no keepalive: a dropped stop packet leaves g_rumble set and the
  207. // relay re-buzzes at 40Hz forever (until a host event like the Steam button clears it). Steam glide haptics
  208. // can likewise latch on the controller if Steam's stop relay is lost. We auto-release on host silence.
  209. static unsigned long g_haptic82Ms = 0; // millis of last 0x82 haptic OUTPUT relayed (Steam mode)
  210. static bool g_haptic82On = false; // a non-zero 0x82 haptic is currently active (awaiting host stop)
  211. static volatile uint8_t g_hapticStop = 0; // pending haptic-STOP frames to relay: the controller's haptic LATCHES until told to stop, so when the host's stop is lost over RF (or goes silent, or we reconnect) we actively send 0x82-zero a few times to kill the whine
  212. // ---- diagnostic capture: a ring of the last OUTPUT reports Steam sends (rid/slot/bytes/ms), dumped with 'H'.
  213. // Reproduce the whine, press Steam to stop it, then 'H' to see the ON stream + the exact OFF frame Steam sends.
  214. struct HapLog { uint32_t ms; uint8_t slot, rid, n, b[12]; };
  215. static HapLog g_hapLog[28];
  216. static uint8_t g_hapHead = 0;
  217. static void hapLogAdd(uint8_t slot, uint8_t rid, const uint8_t* b, uint16_t n){
  218. HapLog &e = g_hapLog[g_hapHead]; e.ms = millis(); e.slot = slot; e.rid = rid; e.n = (uint8_t)(n>255?255:n);
  219. for(int i=0;i<12;i++) e.b[i] = (i<(int)n) ? b[i] : 0;
  220. g_hapHead = (uint8_t)((g_hapHead+1) % (sizeof g_hapLog / sizeof g_hapLog[0]));
  221. }
  222. static unsigned long g_hapticBlockUntil = 0; // drop Steam haptics briefly during reconnect settle
  223. #define RUMBLE_STUCK_MS 2500u // Xbox: release a held rumble if no OUT packet refreshes it for this long (covers a lost stop without cutting normal short rumbles)
  224. #define HAPTIC_QUIET_MS 300u // Steam: after this much host silence, consider the current 0x82 haptic stream inactive
  225. #define HAPTIC_RECONNECT_BLOCK_MS 1500u // drop stale host 0x82 after RF reconnect (Steam floods on attach)
  226. #define HAPTIC_STOP_BURST 4u // 0x82-zero relays per event (sent at poll cadence — not loop rate)
  227. // Seamless lizard (Steam mode): exactly like the real puck — when Steam is running it forwards the gamepad
  228. // report 0x45; when Steam is closed it drives mouse(0x40)+keyboard(0x41) on the SAME puck interface. This is
  229. // a PURELY USB-SIDE decision — it changes nothing about the RF poll or the host->controller relay, so the
  230. // controller is polled identically whether Steam is up or not and can't be pushed into a non-puck state.
  231. // Steam, while running, re-sends settings report 0x87 (lizard-off) every ~3s as a heartbeat (captured on HW).
  232. static unsigned long g_steamAliveMs = 0; // millis of last 0x87 settings write from Steam (its heartbeat); 0 at boot => lizard until Steam appears
  233. #define LIZARD_WD_MS 7000u // fall back to lizard this long after the 0x87 heartbeat stops (>2x the 3s cadence, so one missed beat won't trip it mid-session)
  234. static bool g_autoLizard = true; // master switch; false => Steam mode always forwards 0x45 (the prior behavior). Opening Steam also disables lizard instantly (its 0x87 heartbeat sets steamAlive).
  235. #define BOND_FILE "/bonds.bin"
  236. static volatile bool g_dirty = false;
  237. static bool g_pairing = false;
  238. static bool recEmpty(const uint8_t *r) { for (int i = 0; i < 24; i++) if (r[i]) return false; return true; }
  239. static void genSerial() {
  240. uint32_t id = NRF_FICR->DEVICEID[0] ^ NRF_FICR->DEVICEID[1];
  241. snprintf(g_unit, sizeof g_unit, "FXB99602%05lX", (unsigned long)(id & 0xFFFFF));
  242. snprintf(g_board, sizeof g_board, "MXB99602%05lX", (unsigned long)(id & 0xFFFFF));
  243. }
  244. static void saveBonds() {
  245. InternalFS.remove(BOND_FILE);
  246. File f(InternalFS);
  247. if (f.open(BOND_FILE, FILE_O_WRITE)) {
  248. for (int i = 0; i < NSLOT; i++) { uint8_t u = g_slot[i].used ? 1 : 0; f.write(&u, 1); f.write(g_slot[i].rec, 24); }
  249. f.close();
  250. }
  251. }
  252. #define CFG_FILE "/cfg.bin"
  253. #define CFG_MAGIC 0xC7 // bumped (chordBtn[3]): old cfg ignored -> clean defaults on first boot
  254. struct Cfg { uint8_t magic, mode, mDiv, mFric, rsvd0, abSwap, back[4], pollU100, persistMode, bootMode, chordBtn[3]; }; // rsvd0 = ex-padSmooth (kept for layout)
  255. static void saveCfg(){
  256. Cfg c={CFG_MAGIC,g_usbMode,(uint8_t)g_mDiv,(uint8_t)g_mFric,0,g_abSwap,
  257. {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,
  258. {g_chordBtn[0],g_chordBtn[1],g_chordBtn[2]}};
  259. InternalFS.remove(CFG_FILE); File f(InternalFS);
  260. if(f.open(CFG_FILE,FILE_O_WRITE)){ f.write((uint8_t*)&c,sizeof c); f.close(); }
  261. }
  262. static void loadCfg(){
  263. Cfg c; File f(InternalFS); bool consume=false;
  264. if(f.open(CFG_FILE,FILE_O_READ)){
  265. if(f.read((uint8_t*)&c,sizeof c)==(int)sizeof c && c.magic==CFG_MAGIC){
  266. g_mDiv=c.mDiv?c.mDiv:64; g_mFric=c.mFric;
  267. g_abSwap=c.abSwap; for(int i=0;i<4;i++) g_back[i]=c.back[i];
  268. g_persistMode = c.persistMode?true:false;
  269. // poll rate is fixed (g_pollUs const = POLL_US_DEFAULT). A stale rate from an older build is never
  270. // applied; rewrite cfg so the persisted byte also matches the new default.
  271. if(c.pollU100 != (uint8_t)(POLL_US_DEFAULT/100)) consume=true;
  272. // boot-mode policy: a one-shot bootMode (set by an explicit switch when !persist) wins once and is then
  273. // cleared; otherwise persist->last mode, else->Steam. (poll rate stays POLL_US_DEFAULT — never restored from cfg.)
  274. if(c.bootMode!=0xFF){ g_usbMode=modeValid(c.bootMode)?c.bootMode:0; consume=true; }
  275. else g_usbMode = g_persistMode ? (modeValid(c.mode)?c.mode:0) : 0;
  276. static const uint8_t CHORD_DEF[3]={MODE_LIZARD,MODE_XBOX,MODE_SW_HORI};
  277. for(int i=0;i<3;i++) g_chordBtn[i]=modeValid(c.chordBtn[i])?c.chordBtn[i]:CHORD_DEF[i];
  278. }
  279. f.close();
  280. }
  281. if(consume){ g_bootMode=0xFF; saveCfg(); } // clear the one-shot so the NEXT cold boot reverts to the default/persist policy
  282. }
  283. // Mode switch (chord / WebUI): persist mode if the toggle is on, else arm a one-shot so this reboot lands in
  284. // the new mode but the next cold boot returns to Steam. Either way saveCfg + caller reboots.
  285. static void saveMode(uint8_t m){
  286. if(g_persistMode){ g_usbMode=m; g_bootMode=0xFF; }
  287. else { g_bootMode=m; }
  288. saveCfg();
  289. }
  290. static void loadBonds() {
  291. File f(InternalFS);
  292. if (f.open(BOND_FILE, FILE_O_READ))
  293. for (int i = 0; i < NSLOT; i++) { uint8_t u = 0; if (f.read(&u, 1) == 1) { g_slot[i].used = u; f.read(g_slot[i].rec, 24); } }
  294. f.close();
  295. }
  296. static bool hapticLinkUp(){
  297. return g_connSlot>=0 && (millis()-g_connReplyMs) < 300;
  298. }
  299. static bool haptic82Blocked(){
  300. return !hapticLinkUp() || (g_hapticBlockUntil && (int32_t)(millis()-g_hapticBlockUntil) < 0);
  301. }
  302. static bool hapticRelaySlotOk(int slot){
  303. return g_connSlot>=0 && slot==g_connSlot;
  304. }
  305. static bool haptic82PayloadOn(const uint8_t* p, uint16_t n){
  306. if(n<3) return false;
  307. for(uint16_t i=2;i<n;i++) if(p[i]) return true; // observed form is [01 01 gain], but treat any trailing non-zero as active
  308. return false;
  309. }
  310. static void hapticCancelPendingOn(){
  311. if(g_relayPend && g_relayBuf[0]==0x82 && haptic82PayloadOn(g_relayBuf+2, g_relayBuf[1]))
  312. g_relayPend = false;
  313. }
  314. static void haptic82HostReport(const uint8_t* p, uint16_t n){
  315. if(n<3) return;
  316. g_haptic82Ms = millis();
  317. bool nowOn = haptic82PayloadOn(p,n);
  318. if(g_haptic82On && !nowOn) g_hapticStop = HAPTIC_STOP_BURST;
  319. g_haptic82On = nowOn;
  320. }
  321. // Queue a pending host haptic / test-haptic / stop relay (runs inside the poll cadence — never at raw loop rate).
  322. static void rfConnQueueHapticRelay(){
  323. if(g_relayPend) return;
  324. if(g_testHaptic){
  325. g_relayBuf[0]=0x82; g_relayBuf[1]=3; g_relayBuf[2]=0x01; g_relayBuf[3]=0x01; g_relayBuf[4]=0xF7; g_relayPend=true; g_testHaptic--;
  326. } else if(g_hapticStop && !g_xbox){
  327. g_relayBuf[0]=0x82; g_relayBuf[1]=3; g_relayBuf[2]=0x01; g_relayBuf[3]=0x01; g_relayBuf[4]=0x00; g_relayPend=true; g_hapticStop--;
  328. }
  329. }
  330. static void rfConnFlushRelay(uint8_t ch){
  331. if(!g_relayPend) return;
  332. uint8_t rid=g_relayBuf[0], rl=g_relayBuf[1]; if(rl>18)rl=18;
  333. uint8_t p[24]; p[0]=g_relayOp; p[1]=(uint8_t)(1+rl); p[2]=g_relaySub; p[3]=rid;
  334. for(uint8_t i=0;i<rl;i++) p[4+i]=g_relayBuf[2+i];
  335. rfConnTx(ch,0x07,p,(uint8_t)(4+rl));
  336. g_relayPend=false;
  337. }
  338. // ---- command channel; `slot` is the interface index (interface N == bond slot N) ----
  339. static void handleSet(int slot, uint8_t rid, hid_report_type_t type, uint8_t const *b, uint16_t n) {
  340. if (type == HID_REPORT_TYPE_OUTPUT) { // Steam OUTPUT reports 0x80-0x89. ONLY the haptic (0x82) is relayed to
  341. // the controller, and ONLY when it arrives on the CONNECTED slot's interface. We have one controller but
  342. // expose 4 puck slots; forwarding the other 0x80-0x89 reports (LED/config), or a haptic Steam aimed at a
  343. // different slot, made the controller buzz at random. The real puck (4 independent slots) never does that.
  344. if (rid >= 0x80 && rid <= 0x89) hapLogAdd((uint8_t)slot, rid, b, n); // capture ALL OUTPUT reports (even un-relayed) for the 'H' dump
  345. if (rid == 0x82 && n >= 1 && hapticRelaySlotOk(slot)) { // wrap as a SET sub-TLV like the report-01 path
  346. if (!haptic82Blocked()) {
  347. uint8_t m = n > (uint16_t)(sizeof g_relayBuf - 2) ? (sizeof g_relayBuf - 2) : n;
  348. g_relayBuf[0] = rid; g_relayBuf[1] = m; memcpy(g_relayBuf + 2, b, m);
  349. g_relayN = m + 2; g_relayPend = true;
  350. }
  351. haptic82HostReport(b, n); // track on/off for the stuck-haptic watchdog
  352. }
  353. if (Serial.availableForWrite() > 80) { // log so we can see what Steam actually sends (e.g. glide haptics)
  354. Serial.printf("# OUT if%d rid=%02X n=%u:", slot, rid, n);
  355. for (uint16_t i = 0; i < n && i < 14; i++) Serial.printf(" %02X", b[i]);
  356. Serial.println();
  357. }
  358. return;
  359. }
  360. if (type != HID_REPORT_TYPE_FEATURE || n < 1) return;
  361. Slot &S = g_slot[slot];
  362. uint8_t cmd = b[0], len = (n > 1) ? b[1] : 0;
  363. const uint8_t *pl = b + 2; uint16_t pln = (n >= 2) ? n - 2 : 0;
  364. if (cmd == 0x87) g_steamAliveMs = millis(); // Steam's settings/lizard-off heartbeat (~every 3s) -> keep forwarding gamepad, suppress auto-lizard
  365. if (rid == 1 && n >= 2) { // report 0x01 = raw passthrough -> queue for RF relay to the controller
  366. if (cmd >= 0x80 && cmd <= 0x89) hapLogAdd((uint8_t)slot, cmd, b, n); // capture feature-passthrough haptics/LED too (rid shown = cmd; bytes start [cmd][len]...)
  367. bool haptic82 = (cmd == 0x82 && len <= pln);
  368. bool relayOk = hapticRelaySlotOk(slot);
  369. if (relayOk && (!haptic82 || !haptic82Blocked())) {
  370. uint16_t m = n > sizeof g_relayBuf ? sizeof g_relayBuf : n;
  371. memcpy(g_relayBuf, b, m); g_relayN = m; g_relayPend = true;
  372. }
  373. if (haptic82) haptic82HostReport(pl, len); // Steam can send haptics through feature passthrough too
  374. }
  375. if (Serial.availableForWrite() > 80) { // log host feature writes (non-blocking)
  376. Serial.printf("# SET if%d rid=%02X cmd=%02X len=%u:", slot, rid, cmd, len);
  377. for (uint16_t i = 0; i < n && i < 14; i++) Serial.printf(" %02X", b[i]);
  378. Serial.println();
  379. }
  380. memset(S.resp, 0, sizeof S.resp); S.resp_len = 0;
  381. switch (cmd) {
  382. case 0x83:
  383. S.resp[0] = 0x83; S.resp[1] = sizeof ATTR83; memcpy(S.resp + 2, ATTR83, sizeof ATTR83); S.resp_len = 63; break;
  384. case 0xAE: {
  385. uint8_t idx = pln > 0 ? pl[0] : 1; const char *s = (idx == 0) ? g_board : (idx == 1) ? g_unit : "NA";
  386. S.resp[0] = 0xAE; S.resp[1] = 0x14; S.resp[2] = idx; memset(S.resp + 3, 0, 60); memcpy(S.resp + 3, s, strlen(s)); S.resp_len = 63; break; }
  387. case 0xB4: // connection/version state per slot: value 0x02 = controller connected, 0x01 = not
  388. S.resp[0] = 0xB4; S.resp[1] = 0x01;
  389. S.resp[2] = (slot == g_connSlot && !g_xbox && (millis() - g_connReplyMs < 500)) ? 0x02 : 0x01;
  390. S.resp_len = 63; break;
  391. case 0xAD:
  392. g_pairing = (pln > 0 && pl[0] != 0); Serial.printf("# pairing %s\n", g_pairing ? "ON" : "off");
  393. S.resp[0] = 0xAD; S.resp[1] = 0; S.resp_len = 63; break;
  394. case 0xA2: // write/clear THIS interface's slot
  395. if (len >= 24 && pln >= 24) {
  396. if (recEmpty(pl)) { S.used = false; memset(S.rec, 0, 24); }
  397. else { memcpy(S.rec, pl, 24); S.used = true; }
  398. g_dirty = true; Serial.printf("# slot %d %s\n", slot, recEmpty(pl) ? "cleared" : "bonded");
  399. }
  400. S.resp[0] = 0xA2; S.resp[1] = 0; S.resp_len = 63; break;
  401. case 0xA3: // read THIS interface's slot
  402. S.resp[0] = 0xA3; S.resp[1] = 0x18; memset(S.resp + 2, 0, 24);
  403. if (S.used) memcpy(S.resp + 2, S.rec, 24); S.resp_len = 63; break;
  404. default:
  405. S.resp[0] = cmd; S.resp[1] = len; if (pln) memcpy(S.resp + 2, pl, pln > 60 ? 60 : pln); S.resp_len = 63; break;
  406. }
  407. }
  408. static uint16_t handleGet(int slot, uint8_t rid, hid_report_type_t type, uint8_t *buf, uint16_t reqlen) {
  409. if (type != HID_REPORT_TYPE_FEATURE) return 0;
  410. Slot &S = g_slot[slot];
  411. uint16_t n = S.resp_len ? S.resp_len : 63; if (n > reqlen) n = reqlen;
  412. memcpy(buf, S.resp, n); return n;
  413. }
  414. // one callback pair per interface (the Adafruit core routes by interface to the matching object)
  415. #define SLOTCB(N) \
  416. static void setcb##N(uint8_t r, hid_report_type_t t, uint8_t const *b, uint16_t n) { handleSet(N, r, t, b, n); } \
  417. static uint16_t getcb##N(uint8_t r, hid_report_type_t t, uint8_t *bf, uint16_t rl) { return handleGet(N, r, t, bf, rl); }
  418. SLOTCB(0) SLOTCB(1) SLOTCB(2) SLOTCB(3)
  419. typedef uint16_t (*getcb_t)(uint8_t, hid_report_type_t, uint8_t *, uint16_t);
  420. typedef void (*setcb_t)(uint8_t, hid_report_type_t, uint8_t const *, uint16_t);
  421. static getcb_t GETCB[NSLOT] = { getcb0, getcb1, getcb2, getcb3 };
  422. static setcb_t SETCB[NSLOT] = { setcb0, setcb1, setcb2, setcb3 };
  423. // ===================== PHASE 2: ESB radio (firmware-derived) =====================
  424. // Register-confirmed ESB config (RESOLVED.md): 2Mbit, 5-byte addr (BALEN=4), dynamic length
  425. // (LFLEN=8,S1LEN=3), 16-bit preamble, big-endian, whitening off, CRC16 poly 0x11021 init 0xFFFF
  426. // (address included). On-air = bitrev8(stored); RADIO BASE = byteswap32(bitrev8(stored)).
  427. // Pairing rendezvous: ch2 (2402MHz), base 91 A2 A7 93 (FUN_0000b8b8 / PTR_DAT_0000ba30).
  428. // Dongle protocol (protocol/RF_CONNECTED.md): beacon [01][seq][..][E2@5]; handshake 0xF0/0xE0/0xF4.
  429. // Bare-metal RADIO (no SoftDevice — never start BLE); coexists with TinyUSB. Stage A: listen+log.
  430. // Discovery/reconnect address (IBEX FUN_00019000, firmware-derived, definitive):
  431. // base = "ibex" (69 62 65 78), prefix = 0x10, channel = 2.
  432. // (The 0x91A2A793 bytes in IBEX rodata precede the key string but are a *different* key,
  433. // NOT the discovery address — confirmed via PTR_s_ibexesb_common=0x56f99 -> "ibex...".)
  434. static const uint8_t PAIR_BASE[4] = {0x69, 0x62, 0x65, 0x78}; // "ibex"
  435. static uint8_t g_rfPrefix = 0x10; // discovery prefix (rodata byte @0x56f98)
  436. static uint8_t g_rfCh = 2;
  437. static uint8_t g_sessCh = 18; // connected-session channel: a CLEAN data channel, off the congested BLE-adv
  438. // ch2 (=adv37). ch18 measured clean (RSSI 95/90, PER 0) on the REAL PUCK's own
  439. // channel scan and is in its active hop set {18,2,80}; ch52 (old) was mediocre.
  440. // Trackpad data bursts collide on bad channels -> reply-rate crash. Tunable 'C'.
  441. static bool g_rfListen = false;
  442. static uint8_t g_rfBase[4] = {0x69, 0x62, 0x65, 0x78}; // "ibex"
  443. static uint8_t rfrx[80], rftx[80];
  444. static uint32_t g_rfRxCount = 0;
  445. static uint8_t rfBitrev8(uint8_t x){ uint8_t y=0; for(int i=0;i<8;i++){y=(y<<1)|(x&1);x>>=1;} return y; }
  446. // HYBRID puck-link config (firmware-derived, definitive):
  447. // PHY = Ble_1Mbit (IBEX FUN_0000f108 MODE=3 is the only RADIO MODE write; ESB inherits it)
  448. // PCNF0 = 0x00100108 (S0LEN=1 [buf[0]=0x12 IS the S0 byte], LFLEN=8, S1INCL=1, 8-bit preamble)
  449. // PCNF1 = 0x02030040 (WHITEEN ON, BALEN=3 [4-byte addr], little-endian, MAXLEN=64)
  450. // CRC = CRC16 poly 0x11021, init 0xFFFF, CRCCNF=2 (address INCLUDED) -- ESB override
  451. // (FUN_00037000); NOT the Bluetooth radio's CRC24 0x65b/CRCCNF=0x103.
  452. // whiten = ON, DATAWHITEIV=37 (RADIO FREQUENCY=2 = 2402MHz = BLE adv channel 37)
  453. // addr = ESB addr_conv (FUN_00027a78 bitrev8 each byte): "ibex" -> BASE0=0x9646A61E.
  454. // All tunable via CDC (M/0/2/i/w + I=crcinit) for hardware iteration.
  455. // DECODED from real-puck capture (definitive): puck TX = Ble_2Mbit, ENDIAN=Big (MSB-first), NO whitening,
  456. // addr "ibex", CRC16 0x11021/0xFFFF. Frame = FUN_00027e9a (static length). My Little-endian capture saw
  457. // it bit-reversed; decode (shift3+bitrev8) recovered 0xE1 + proteus_uuid_LE + ibex_uuid_LE for both bonds.
  458. static uint32_t g_crcinit = 0xFFFF;
  459. static uint8_t g_whiteiv = 37;
  460. static uint8_t g_mode = RADIO_MODE_MODE_Ble_2Mbit; // <-- was the bug (1Mbit). Real puck = 2Mbit.
  461. static uint32_t g_pcnf0 = 0x00030008UL; // S0LEN0, LFLEN8, S1LEN3 (ESB DPL) - CRC-VALIDATED
  462. static uint8_t g_statlen = 0x20; // (static-mode payload length; tunable 'T')
  463. // PCNF1: ENDIAN=Big (puck transmits MSB-first), WHITEEN=0, BALEN3, MAXLEN64.
  464. static uint32_t g_pcnf1 = 0x01040040; // ENDIAN=Big, BALEN4, MAXLEN64 - CRC-VALIDATED
  465. static uint32_t g_crcpoly = 0x11021UL;
  466. static uint16_t g_crccnf = 0x2; // CRC16, address included
  467. static uint8_t g_pid = 0;
  468. // Connected-mode poll (Phase 2b): puck (PTX) sends 0xE7 protocol-version then 0xE3 input polls on the
  469. // session addr/ch; controller responds in the ACK/reply with its input report (byte5==0xF1 + TLV,
  470. // parsed by IBEX FUN_0000ba38). Tunable for on-hardware iteration (j=type, Q=len, k=enable).
  471. static bool g_connOn = true; // auto-start poll on boot (so a USB replug resumes acting as a puck)
  472. static uint8_t g_connType = 0xE7; // start with protocol-version handshake, then 0xE3
  473. static uint8_t g_e7b = 0; // E7 payload B-byte: 0=current(slow/awake), 1=test protocol-version-1 (ODR agent: B!=0 sets controller 'v1 fast' latch -> ~100Hz?). 'V<n>' to toggle. RISK: original RE called [00][01] "suspended" — revert to 0 if input stops.
  474. static uint8_t g_connLen = 0x08;
  475. static uint32_t g_stPoll=0, g_stF1=0, g_stF3=0; static unsigned long g_stMs=0; // per-second poll/F1/F3 stat counters
  476. static uint8_t g_connF3v=0xFF; // last protocol version the controller reported in an F3 reply (0xFF=none seen)
  477. static uint16_t g_f1ps=0; // last completed second's F1 rate (for the WebUSB status readout)
  478. static uint8_t g_lastSeq=0; static uint32_t g_stNew=0; static uint16_t g_newps=0; // genuine new-report rate (report 0x45 seq byte changes)
  479. static uint32_t g_stCrc=0, g_stNoRx=0; // diagnostics: replies w/ bad CRC, and polls with no reply at all (timing vs RF-quality)
  480. static uint32_t g_chF1[3]={0,0,0}; // diagnostic: F1 replies per poll channel {sessCh,2,80}
  481. static uint8_t g_chIdx=0, g_noRep=0; // (legacy hop counters; controller doesn't hop)
  482. static uint32_t g_lastPollUs=0; // (g_pollUs declared in the config block near the top; fixed, not tunable)
  483. static unsigned long g_lastSessBeacon=0; // session keepalive beacon timer
  484. static uint32_t g_connRx = 0;
  485. static uint8_t g_balen = 4; // ESB 5-byte addr
  486. // Access address (IBEX FUN_00037530 + func_0x00049e5a): BASE0 = bitrev8 each base byte, packed
  487. // big-endian (= byteswap(bitrev8(uint32))); PREFIX0 = raw prefix (func_0x0003756c copies it). BALEN=3
  488. // uses BASE0[31:8] + PREFIX0. For "ibex" 69 62 65 78 -> BASE0=0x9646A61E, PREFIX0=0x10.
  489. static bool g_prefixRaw = false; // false = bitrev8 prefix (ESB addr_conv); true = raw prefix byte
  490. static void rfSetAddr(const uint8_t b4[4], uint8_t prefix){
  491. uint8_t b[4]; for(int i=0;i<4;i++) b[i]=rfBitrev8(b4[i]);
  492. NRF_RADIO->BASE0 = ((uint32_t)b[0]<<24)|((uint32_t)b[1]<<16)|((uint32_t)b[2]<<8)|b[3];
  493. NRF_RADIO->PREFIX0 = g_prefixRaw ? prefix : rfBitrev8(prefix);
  494. NRF_RADIO->TXADDRESS = 0; NRF_RADIO->RXADDRESSES = 1u<<0;
  495. }
  496. // Promiscuous raw capture (calibration only): preamble-match on 0x55 bytes, fixed-length grab, no CRC.
  497. // Catches any 2Mbit packet on a channel so we can read the controller's reconnect addr/prefix/framing.
  498. #define RAWCAP 48
  499. static bool g_rfRaw = false;
  500. static void rfRawStart(uint8_t ch){
  501. NRF_RADIO->TASKS_DISABLE=1; RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
  502. NRF_RADIO->MODE=(RADIO_MODE_MODE_Nrf_2Mbit<<RADIO_MODE_MODE_Pos);
  503. #if defined(RADIO_MODECNF0_RU_Fast)
  504. NRF_RADIO->MODECNF0=(RADIO_MODECNF0_RU_Fast<<RADIO_MODECNF0_RU_Pos);
  505. #endif
  506. NRF_RADIO->FREQUENCY=ch; NRF_RADIO->TXPOWER=(RADIO_TXPOWER_TXPOWER_0dBm<<RADIO_TXPOWER_TXPOWER_Pos);
  507. NRF_RADIO->CRCCNF=0; NRF_RADIO->PCNF0=0;
  508. NRF_RADIO->PCNF1=(RADIO_PCNF1_WHITEEN_Disabled<<RADIO_PCNF1_WHITEEN_Pos)
  509. |(RADIO_PCNF1_ENDIAN_Big<<RADIO_PCNF1_ENDIAN_Pos)
  510. |(2u<<RADIO_PCNF1_BALEN_Pos)|(RAWCAP<<RADIO_PCNF1_STATLEN_Pos)|(RAWCAP<<RADIO_PCNF1_MAXLEN_Pos);
  511. NRF_RADIO->BASE0=0x55555555; NRF_RADIO->PREFIX0=0x00000055;
  512. NRF_RADIO->TXADDRESS=0; NRF_RADIO->RXADDRESSES=1; NRF_RADIO->PACKETPTR=(uint32_t)rfrx;
  513. NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk|RADIO_SHORTS_END_START_Msk;
  514. NRF_RADIO->EVENTS_END=0; NRF_RADIO->TASKS_RXEN=1;
  515. }
  516. // CAPTURE: listen for the CONTROLLER's transmission (test the inverted-direction hypothesis).
  517. // RX on "ibex"/BLE_1Mbit with CRC DISABLED (any address-matched packet logged, whitened or not;
  518. // the address is never whitened so a match still fires). Sweeps channels to find where it TXes.
  519. static bool g_rfCap = false;
  520. // CRC-VALIDATING capture (2Mbit, addr "ibex", CRC16 0x11021/0xFFFF). A CRC PASS pins PHY+addr+CRC+whiten.
  521. // Data looked bit-reversed (not whitened), so sweep whiten-OFF combos first: BALEN{3,4} x CRCCNF{incl,skip}
  522. // x ENDIAN{little,big}; then whiten-ON IV sweep as fallback. Cycle index g_capV.
  523. struct CapCfg{ uint8_t balen; uint16_t crccnf; uint8_t endianBig; uint8_t whiten; uint8_t iv; const char*tag; };
  524. static const CapCfg CAPC[] = {
  525. {3,0x2,0,0,0,"BAL3 incl LE whOFF"}, {4,0x2,0,0,0,"BAL4 incl LE whOFF"},
  526. {3,0x2,1,0,0,"BAL3 incl BE whOFF"}, {4,0x2,1,0,0,"BAL4 incl BE whOFF"},
  527. {3,0x102,0,0,0,"BAL3 skip LE whOFF"}, {4,0x102,0,0,0,"BAL4 skip LE whOFF"},
  528. {3,0x102,1,0,0,"BAL3 skip BE whOFF"}, {4,0x102,1,0,0,"BAL4 skip BE whOFF"},
  529. {3,0x2,0,1,2,"BAL3 incl LE whIV2"}, {3,0x2,0,1,37,"BAL3 incl LE whIV37"},
  530. {4,0x2,0,1,2,"BAL4 incl LE whIV2"}, {4,0x2,0,1,37,"BAL4 incl LE whIV37"},
  531. };
  532. static uint8_t g_capV=0; static uint32_t g_capPass=0;
  533. // CRC-VALIDATING de-whiten sweep: BLE-radio framing PCNF0=0x100108 (S0LEN1/LFLEN8) + WHITEEN + CRC16
  534. // 0x11021. Sweep DATAWHITEIV (0..127) x BALEN{3,4}. A CRC PASS de-whitens & de-frames -> clean payload + IV.
  535. // FRAMING SWEEP: ENDIAN=Big, addr "ibex", CRC16 0x11021/0xFFFF, sweep PCNF0 x CRCCNF. A CRC PASS on the
  536. // real puck's frame pins the exact framing (LFLEN/S1LEN/S0LEN) -> then copycat TX uses it.
  537. static const uint32_t CAPP0[]={0x00030006,0x00030008,0x00100108,0x00000008,0x00000006,0x00130006,0x00130008,0x00100008};
  538. // CRC configs: {CRCCNF, CRCPOLY, CRCINIT}
  539. static const uint32_t CAPCRC[][3]={{0x2,0x11021,0xFFFF},{0x102,0x11021,0xFFFF},{0x1,0x107,0xFF},{0x101,0x107,0xFF}};
  540. #define NCRC (sizeof CAPCRC/sizeof CAPCRC[0])
  541. // FRAMING SWEEP v2: ENDIAN=Big, S1LEN=3 (ESB PID+noack -> the 3-bit shift), STATIC length swept,
  542. // CRC16 0x11021 / CRC8 0x107. g_capV -> (pcnf0_idx, statlen, crc_idx).
  543. // Firmware-correct: ESB DPL (S0L0, LFLEN6or8, S1LEN3), BALEN4 (5-byte addr), CRC8/16 addr-included.
  544. // Sweep PCNF0{LFLEN6,8} x CRC{8,16} x BALEN{4,3} x ENDIAN{Big,Lit} = 16 combos.
  545. static const uint32_t CAPP0B[]={0x00030006,0x00030008}; // S1LEN3 + LFLEN6 / LFLEN8 (dynamic length)
  546. static const uint32_t CAPCRCB[][3]={{0x1,0x107,0xFF},{0x2,0x11021,0xFFFF}};
  547. #define NCRCB 2
  548. static void rfCapStart(uint8_t ch){
  549. uint8_t ci=g_capV&1;
  550. uint8_t balen=((g_capV>>1)&1)?3:4; // BALEN 4 first
  551. uint8_t endbig=((g_capV>>2)&1)^1; // ENDIAN Big first
  552. uint32_t p0=CAPP0B[(g_capV>>3)&1];
  553. const uint32_t* cr=CAPCRCB[ci];
  554. NRF_RADIO->TASKS_DISABLE=1; RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
  555. NRF_RADIO->MODE=(RADIO_MODE_MODE_Ble_2Mbit<<RADIO_MODE_MODE_Pos);
  556. #if defined(RADIO_MODECNF0_RU_Fast)
  557. NRF_RADIO->MODECNF0=(RADIO_MODECNF0_RU_Fast<<RADIO_MODECNF0_RU_Pos);
  558. #endif
  559. NRF_RADIO->FREQUENCY=ch;
  560. NRF_RADIO->PCNF0=p0;
  561. NRF_RADIO->PCNF1=((uint32_t)endbig<<RADIO_PCNF1_ENDIAN_Pos)|((uint32_t)balen<<RADIO_PCNF1_BALEN_Pos)
  562. |(60u<<RADIO_PCNF1_MAXLEN_Pos);
  563. NRF_RADIO->CRCCNF=cr[0]; NRF_RADIO->CRCPOLY=cr[1]; NRF_RADIO->CRCINIT=cr[2];
  564. uint8_t b[4]; for(int i=0;i<4;i++) b[i]=rfBitrev8(g_rfBase[i]);
  565. NRF_RADIO->BASE0=((uint32_t)b[0]<<24)|((uint32_t)b[1]<<16)|((uint32_t)b[2]<<8)|b[3];
  566. NRF_RADIO->PREFIX0=rfBitrev8(g_rfPrefix); NRF_RADIO->TXADDRESS=0; NRF_RADIO->RXADDRESSES=1;
  567. NRF_RADIO->PACKETPTR=(uint32_t)rfrx; memset(rfrx,0,8);
  568. NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk|RADIO_SHORTS_END_START_Msk;
  569. NRF_RADIO->EVENTS_END=0; NRF_RADIO->TASKS_RXEN=1;
  570. }
  571. // REPLAY: capture one real-puck frame (raw, ENDIAN=Little) then re-transmit it VERBATIM (same bits,
  572. // addr, ch) to impersonate the puck bit-for-bit -- sidesteps unknown framing/CRC. Controller must be
  573. // bonded to the REAL puck (replayed frame carries real-puck uuids + its valid CRC).
  574. static uint8_t g_replay[48]; static uint8_t g_replayLen=0;
  575. static bool g_rfCapOne=false, g_rfReplay=false;
  576. static void rfCapPoll(){
  577. if(!g_rfCap) return;
  578. if(NRF_RADIO->EVENTS_END){ NRF_RADIO->EVENTS_END=0;
  579. g_capPass++;
  580. if(g_rfCapOne){ memcpy(g_replay,rfrx,sizeof g_replay); g_replayLen=32; g_rfCapOne=false; g_rfCap=false;
  581. Serial.printf("# captured replay frame (%uB): ",g_replayLen);
  582. for(int i=0;i<g_replayLen;i++)Serial.printf("%02X",g_replay[i]); Serial.println(); return; }
  583. if(NRF_RADIO->CRCSTATUS&1){
  584. uint8_t ci=g_capV&1; uint8_t balen=((g_capV>>1)&1)?3:4; uint8_t eb=((g_capV>>2)&1)^1;
  585. uint32_t p0=CAPP0B[(g_capV>>3)&1];
  586. Serial.printf("@@@ CRCOK PCNF0=%lX BALEN=%u ENDIAN=%s POLY=%lX: ",(unsigned long)p0,balen,
  587. eb?"Big":"Lit",(unsigned long)CAPCRCB[ci][1]);
  588. for(int i=0;i<28;i++) Serial.printf("%02X",rfrx[i]); Serial.println();
  589. }
  590. }
  591. }
  592. static void rfReplayOnce(){
  593. if(!g_replayLen) return;
  594. NRF_RADIO->TASKS_DISABLE=1; RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
  595. NRF_RADIO->MODE=(RADIO_MODE_MODE_Ble_2Mbit<<RADIO_MODE_MODE_Pos);
  596. #if defined(RADIO_MODECNF0_RU_Fast)
  597. NRF_RADIO->MODECNF0=(RADIO_MODECNF0_RU_Fast<<RADIO_MODECNF0_RU_Pos);
  598. #endif
  599. NRF_RADIO->FREQUENCY=2; // ch2
  600. NRF_RADIO->PCNF0=0; // static, ENDIAN=Little -> reproduce on-air bits
  601. NRF_RADIO->PCNF1=(3u<<RADIO_PCNF1_BALEN_Pos)|((uint32_t)g_replayLen<<RADIO_PCNF1_STATLEN_Pos)|((uint32_t)g_replayLen<<RADIO_PCNF1_MAXLEN_Pos);
  602. NRF_RADIO->CRCCNF=0; // CRC already inside the captured bytes
  603. uint8_t b[4]; for(int i=0;i<4;i++) b[i]=rfBitrev8(g_rfBase[i]);
  604. NRF_RADIO->BASE0=((uint32_t)b[0]<<24)|((uint32_t)b[1]<<16)|((uint32_t)b[2]<<8)|b[3];
  605. NRF_RADIO->PREFIX0=rfBitrev8(g_rfPrefix); NRF_RADIO->TXADDRESS=0;
  606. NRF_RADIO->PACKETPTR=(uint32_t)g_replay;
  607. NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk|RADIO_SHORTS_END_DISABLE_Msk;
  608. NRF_RADIO->EVENTS_DISABLED=0; NRF_RADIO->TASKS_TXEN=1;
  609. RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
  610. NRF_RADIO->TASKS_DISABLE=1; RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
  611. }
  612. static void rfConfig(uint8_t ch){
  613. NRF_RADIO->TASKS_DISABLE = 1; RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED = 0;
  614. NRF_RADIO->MODE = ((uint32_t)g_mode << RADIO_MODE_MODE_Pos); // Ble_1Mbit (tunable 'M')
  615. NRF_RADIO->FREQUENCY = ch;
  616. NRF_RADIO->TXPOWER = (RADIO_TXPOWER_TXPOWER_0dBm << RADIO_TXPOWER_TXPOWER_Pos);
  617. #if defined(RADIO_MODECNF0_RU_Fast)
  618. NRF_RADIO->MODECNF0 = (RADIO_MODECNF0_RU_Fast<<RADIO_MODECNF0_RU_Pos);
  619. #endif
  620. NRF_RADIO->PCNF0 = g_pcnf0; // static length (tunable '0'); g_pcnf1=0 -> build from g_statlen/g_balen
  621. NRF_RADIO->PCNF1 = g_pcnf1 ? g_pcnf1 :
  622. ((1u<<RADIO_PCNF1_ENDIAN_Pos) // ENDIAN=Big (puck transmits MSB-first)
  623. |((uint32_t)g_balen<<RADIO_PCNF1_BALEN_Pos)
  624. |((uint32_t)g_statlen<<RADIO_PCNF1_STATLEN_Pos)
  625. |((uint32_t)g_statlen<<RADIO_PCNF1_MAXLEN_Pos)); // WHITEEN=0
  626. NRF_RADIO->CRCCNF = g_crccnf; // CRC16, address included
  627. NRF_RADIO->CRCPOLY = g_crcpoly; NRF_RADIO->CRCINIT = g_crcinit;
  628. NRF_RADIO->DATAWHITEIV = g_whiteiv;
  629. NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
  630. }
  631. static void rfListenStart(){
  632. rfConfig(g_rfCh); rfSetAddr(g_rfBase, g_rfPrefix);
  633. NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
  634. NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk | RADIO_SHORTS_END_START_Msk;
  635. NRF_RADIO->EVENTS_END = 0; NRF_RADIO->TASKS_RXEN = 1;
  636. g_rfListen = true;
  637. Serial.printf("# RF listen ch%u base %02X%02X%02X%02X prefix %02X\n",
  638. g_rfCh, g_rfBase[0],g_rfBase[1],g_rfBase[2],g_rfBase[3], g_rfPrefix);
  639. }
  640. static void rfPoll(){
  641. if (g_rfRaw){
  642. if (NRF_RADIO->EVENTS_END){
  643. NRF_RADIO->EVENTS_END = 0;
  644. // filter noise: require some non-0x55/0xAA/0x00 structure
  645. int nz=0; for(int i=0;i<RAWCAP;i++){uint8_t b=rfrx[i]; if(b&&b!=0x55&&b!=0xAA&&b!=0xFF)nz++;}
  646. if (nz>=6){
  647. g_rfRxCount++;
  648. Serial.printf("RAW#%lu ch%u: ", (unsigned long)g_rfRxCount, g_rfCh);
  649. for (int i=0;i<RAWCAP;i++) Serial.printf("%02X", rfrx[i]);
  650. Serial.println();
  651. }
  652. }
  653. return;
  654. }
  655. if (!g_rfListen) return;
  656. if (NRF_RADIO->EVENTS_END){
  657. NRF_RADIO->EVENTS_END = 0;
  658. bool crcok = NRF_RADIO->CRCSTATUS & 1;
  659. uint8_t len = rfrx[0];
  660. if (len && len < 64){
  661. g_rfRxCount++;
  662. Serial.printf("RF#%lu ch%u crc%d len%u: ", (unsigned long)g_rfRxCount, g_rfCh, crcok, len);
  663. for (uint8_t i=0;i<=len && i<40;i++) Serial.printf("%02X ", rfrx[i]);
  664. Serial.println();
  665. }
  666. }
  667. }
  668. // Dongle is PTX: TX a beacon [01][seq][..][E2@5] then RX the controller's response (0xF0...).
  669. // RAM layout / payload length are byte-level unknowns to pin live (S1INCL, plen tweakable).
  670. static bool g_rfBeacon = false;
  671. static uint8_t g_seq = 0, g_plen = 0x18, g_s1incl = 0;
  672. static unsigned long g_lastBeacon = 0;
  673. static unsigned long g_lastDisc = 0; // last discovery beacon (ch2) time
  674. static bool g_rfSweep = false;
  675. static bool g_rfHost = true; // auto-start host beacon on boot (resumes puck role after a USB replug)
  676. static unsigned long g_lastHop = 0;
  677. // AUTO-SWEEP: cycle candidate radio configs while beaconing, so ONE controller search window
  678. // (a few seconds) covers the whole space. S0LEN=1 / CRC16 0x11021 / addr "ibex" held fixed
  679. // (firmware-confirmed); we sweep the residual unknowns: PHY (1M/2M), whitening (off/IV), CRC-addr.
  680. // Hold MODE=Ble_1Mbit, PCNF0=0x100108 (S0LEN=1), CRC16 0x11021 cnf2; sweep the UNTESTED dims:
  681. // BALEN (3 vs 4 = 4- vs 5-byte addr), whitening (off / iv37), prefix (bitrev vs raw), + one 2M.
  682. struct RfCfg { uint8_t mode; uint32_t pcnf0, pcnf1; uint8_t whiteiv; uint16_t crccnf; bool prefRaw; const char* tag; };
  683. static const RfCfg SWEEP[] = {
  684. {RADIO_MODE_MODE_Ble_1Mbit, 0x00100108, 0x02040040, 37, 0x2, false, "1M BALEN4 whiv37 pfxRev"},
  685. {RADIO_MODE_MODE_Ble_1Mbit, 0x00100108, 0x00040040, 0, 0x2, false, "1M BALEN4 whOFF pfxRev"},
  686. {RADIO_MODE_MODE_Ble_1Mbit, 0x00100108, 0x02040040, 37, 0x2, true, "1M BALEN4 whiv37 pfxRAW"},
  687. {RADIO_MODE_MODE_Ble_1Mbit, 0x00100108, 0x00040040, 0, 0x2, true, "1M BALEN4 whOFF pfxRAW"},
  688. {RADIO_MODE_MODE_Ble_1Mbit, 0x00100108, 0x02030040, 37, 0x2, false, "1M BALEN3 whiv37 pfxRev"},
  689. {RADIO_MODE_MODE_Ble_1Mbit, 0x00100108, 0x00030040, 0, 0x2, true, "1M BALEN3 whOFF pfxRAW"},
  690. {RADIO_MODE_MODE_Ble_2Mbit, 0x00100108, 0x02040040, 37, 0x2, false, "2M BALEN4 whiv37 pfxRev"},
  691. {RADIO_MODE_MODE_Ble_2Mbit, 0x00100108, 0x00040040, 0, 0x2, true, "2M BALEN4 whOFF pfxRAW"},
  692. };
  693. static bool g_rfAuto = false; static uint8_t g_cfgIdx = 0; static unsigned long g_lastCfg = 0;
  694. static void applyCfg(uint8_t i){
  695. const RfCfg &c = SWEEP[i];
  696. g_mode=c.mode; g_pcnf0=c.pcnf0; g_pcnf1=c.pcnf1; g_whiteiv=c.whiteiv; g_crccnf=c.crccnf; g_prefixRaw=c.prefRaw;
  697. Serial.printf("# cfg[%u] %s\n", i, c.tag);
  698. }
  699. // HOST FRAME the bonded controller waits for (IBEX FUN_00019000 verify: b[0]=0x12,b[5]=0xE1,
  700. // b[6..10]=proteus_uuid, b[10..14]=ibex_uuid). Built like PROTEUS FUN_00027e9a. Sent on the shared
  701. // rendezvous addr; the controller filters by the uuids in the payload, then connects.
  702. static void rfHostFrameOnce(int slot){
  703. if (slot<0||slot>=NSLOT||!g_slot[slot].used) return;
  704. uint8_t *rec = g_slot[slot].rec; // [proteus_uuid 4][ibex_uuid 4][serial 16]
  705. // CRC-VALIDATED frame (decoded from real puck): ESB-DPL RAM = [LENGTH][S1=PID][payload(18)].
  706. // payload: [0]=0xE1, [1..5]=proteus_uuid LE, [5..9]=ibex_uuid LE, [9]=session channel,
  707. // [10..13]=0, [13..17]=session base, [17]=session prefix. Radio auto-appends CRC16 0x11021.
  708. memset(rftx,0,sizeof rftx);
  709. rftx[0]=0x12; // LENGTH = 18 (controller's buf[0]==0x12 check validates this)
  710. rftx[1]=(uint8_t)((g_pid++&3)<<1); // S1 = PID<<1 | noack0 (matches real puck 00/02/04/06)
  711. rftx[2]=0xE1; // payload[0] marker
  712. memcpy(rftx+3, rec+0, 4); // payload[1..5] proteus_uuid (LE, as bonded)
  713. memcpy(rftx+7, rec+4, 4); // payload[5..9] ibex_uuid
  714. rftx[11]=g_sessCh; // payload[9] session channel: tell the controller to run the session on
  715. // the clean channel (it adopts buf[0xe]); discovery beacon still TXes on ch2
  716. memcpy(rftx+15, g_rfBase, 4); // payload[13..17] session base
  717. rftx[19]=g_rfPrefix; // payload[17] session prefix
  718. rfConfig(g_rfCh); rfSetAddr(g_rfBase,g_rfPrefix);
  719. NRF_RADIO->PACKETPTR=(uint32_t)rftx;
  720. NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk|RADIO_SHORTS_END_DISABLE_Msk;
  721. NRF_RADIO->EVENTS_DISABLED=0; NRF_RADIO->TASKS_TXEN=1;
  722. RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
  723. NRF_RADIO->PACKETPTR=(uint32_t)rfrx; rfrx[0]=0;
  724. NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk;
  725. NRF_RADIO->EVENTS_END=0; NRF_RADIO->TASKS_RXEN=1;
  726. uint32_t t0=micros(); while(!NRF_RADIO->EVENTS_END && (micros()-t0)<800){}
  727. if (NRF_RADIO->EVENTS_END){ NRF_RADIO->EVENTS_END=0; // ANY reception = controller answered our frame
  728. g_rfRxCount++; bool crcok=NRF_RADIO->CRCSTATUS&1; uint8_t len=rfrx[0];
  729. if (Serial.availableForWrite()>90){ // non-blocking: don't stall the loop on CDC backpressure
  730. Serial.printf("*** RESP#%lu cfg[%u] ch%u crc%d rxmatch%lu len%u: ",(unsigned long)g_rfRxCount,
  731. g_cfgIdx,g_rfCh,crcok,(unsigned long)NRF_RADIO->RXMATCH,len);
  732. for(uint8_t i=0;i<(len<40?len+2:40);i++)Serial.printf("%02X ",rfrx[i]); Serial.println(); } }
  733. NRF_RADIO->TASKS_DISABLE=1; RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
  734. }
  735. // Mid-session channel hop: advertise newCh in the host frame, transmitted on the CURRENT session channel
  736. // (where the controller is listening), a few times so it reliably adopts it; then move our poll to newCh.
  737. // This is the foundational test for channel-hopping/QoS — does the controller follow a live channel change?
  738. static void rfHopTo(uint8_t newCh){
  739. if(g_connSlot<0 || newCh==g_sessCh) return;
  740. uint8_t cur=g_sessCh, savedRfCh=g_rfCh;
  741. g_sessCh=newCh; g_rfCh=cur; // host frame now advertises newCh but is TXed on cur
  742. for(int k=0;k<6;k++){ rfHostFrameOnce(g_connSlot); delayMicroseconds(700); }
  743. g_rfCh=savedRfCh; // poll + session beacon now run on g_sessCh=newCh
  744. }
  745. // CONNECTED MODE (Phase 2b): copycat IS the PTX/master. It already CHOSE the session base/prefix/channel
  746. // (advertised in the host frame the controller connected on), so no discovery/re-sync is needed. After
  747. // connect: send E7 (proto-version 00 01), send E4 (channel map {ch,2,80}), then loop E3 input-poll (+E2
  748. // keepalive periodically) hopping the map; RX the controller's reply each cycle (0xF1 = full input report
  749. // w/ TLVs, 0xF3 = short status). Captured PUCK->controller bytes [LEN][S1][payload], payload[0]=type:
  750. // E7 00 01 (S1=01) | E4 <ch> 02 50 (S1=05) | E2 (S1=07) | E3 (S1=07). See FUN_0000ba38 / FUN_0001b960.
  751. // One TX->RX cycle: TX the connected packet, SHORTS straight into a short RX window to catch the reply.
  752. // ---- XBOX-mode output: map report 0x45 -> standard gamepad (g_pad) + right-trackpad -> mouse (report 0x40) ----
  753. // report 0x45 layout: [0]=0x45 [1]=seq [2..5]=buttons u32; analog offsets are from buttons-low-byte (rep[2]).
  754. static int s16off(const uint8_t* r,int off){ int v=r[2+off]|(r[2+off+1]<<8); return (v&0x8000)?v-0x10000:v; }
  755. static int u16off(const uint8_t* r,int off){ return r[2+off]|(r[2+off+1]<<8); }
  756. // Controller trigger analog: the u16 in report 0x45 tops out near half-scale (~0x8000) at a full pull, so a
  757. // straight >>8 reads only ~0x80 (XInput sees a half-pressed trigger). Scale x2 (>>7) and saturate so a full
  758. // pull maps to the full 0xFF. Shared by Xbox (analog) and Switch (digital threshold).
  759. static inline uint8_t trigU8(int u16v){ int v=u16v>>7; return (uint8_t)(v>255?255:v); }
  760. static uint32_t btnsOf(const uint8_t* r){ return (uint32_t)r[2]|((uint32_t)r[3]<<8)|((uint32_t)r[4]<<16)|((uint32_t)r[5]<<24); }
  761. // Triton button masks
  762. #define TB_A 0x1u
  763. #define TB_B 0x2u
  764. #define TB_X 0x4u
  765. #define TB_Y 0x8u
  766. #define TB_R3 0x20u
  767. #define TB_VIEW 0x40u
  768. #define TB_R4 0x80u
  769. #define TB_R5 0x100u
  770. #define TB_RB 0x200u
  771. #define TB_DDN 0x400u
  772. #define TB_DRT 0x800u
  773. #define TB_DLF 0x1000u
  774. #define TB_DUP 0x2000u
  775. #define TB_MENU 0x4000u
  776. #define TB_L3 0x8000u
  777. #define TB_STEAM 0x10000u
  778. #define TB_L4 0x20000u
  779. #define TB_L5 0x40000u
  780. #define TB_LB 0x80000u
  781. #define TB_RPADT 0x200000u
  782. #define TB_RPADC 0x400000u
  783. #define TB_LPADT 0x2000000u
  784. #define TB_LPADC 0x4000000u
  785. // button code (g_back[], g_abSwap targets) -> legacy XInput bit. 0=none 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back 10=Start 11=Guide
  786. static uint16_t codeToXB(uint8_t c){
  787. switch(c){ case 1:return XB_A; case 2:return XB_B; case 3:return XB_X; case 4:return XB_Y;
  788. case 5:return XB_LB; case 6:return XB_RB; case 7:return XB_L3; case 8:return XB_R3;
  789. case 9:return XB_BACK; case 10:return XB_START; case 11:return XB_GUIDE; default:return 0; }
  790. }
  791. static void rfXboxGamepad(const uint8_t* r){
  792. uint32_t b=btnsOf(r);
  793. uint16_t btn=0; // map Triton buttons -> XInput button word
  794. if(b&TB_DUP)btn|=XB_DUP; if(b&TB_DDN)btn|=XB_DDOWN; if(b&TB_DLF)btn|=XB_DLEFT; if(b&TB_DRT)btn|=XB_DRIGHT;
  795. if(b&TB_VIEW)btn|=XB_START; if(b&TB_MENU)btn|=XB_BACK; if(b&TB_STEAM)btn|=XB_GUIDE;
  796. if(b&TB_LB)btn|=XB_LB; if(b&TB_RB)btn|=XB_RB;
  797. if(b&TB_L3)btn|=XB_L3; if(b&TB_R3)btn|=XB_R3;
  798. // face buttons, with optional A/B + X/Y swap (Nintendo layout)
  799. uint16_t fA=g_abSwap?XB_B:XB_A, fB=g_abSwap?XB_A:XB_B, fX=g_abSwap?XB_Y:XB_X, fY=g_abSwap?XB_X:XB_Y;
  800. if(b&TB_A)btn|=fA; if(b&TB_B)btn|=fB; if(b&TB_X)btn|=fX; if(b&TB_Y)btn|=fY;
  801. // back paddles -> configurable mapping (default L4->LB, R4->RB, L5->L3, R5->R3)
  802. if(b&TB_L4)btn|=codeToXB(g_back[0]); if(b&TB_R4)btn|=codeToXB(g_back[1]);
  803. if(b&TB_L5)btn|=codeToXB(g_back[2]); if(b&TB_R5)btn|=codeToXB(g_back[3]);
  804. uint8_t lt=trigU8(u16off(r,4)), rt=trigU8(u16off(r,6)); // triggers u16 (half-scale) -> full-range u8
  805. xinputSend(btn, lt, rt, (int16_t)s16off(r,8), (int16_t)s16off(r,10), // L stick X/Y
  806. (int16_t)s16off(r,12), (int16_t)s16off(r,14)); // R stick X/Y
  807. }
  808. // Right pad -> mouse, riding ALONGSIDE the XInput gamepad on a second HID-mouse interface. Same glide model
  809. // as Lizard's right pad (velocity injected from pad deltas, friction decay, sub-pixel carry). This is purely
  810. // a USB-side translation of the received 0x45 report — it touches NOTHING about the RF poll or relay, so the
  811. // controller behaves exactly as it does for the bare gamepad. RPad click = left button, LPad click = right.
  812. // Xbox drops CDC/WebUSB, so the only active USB interfaces are XInput (MI_00) + boot mouse (MI_01).
  813. static void rfXboxMouse(const uint8_t* r){
  814. uint32_t b=btnsOf(r);
  815. static int prx=0,pry=0; static bool prt=false; static float vx=0,vy=0,rmx=0,rmy=0; static uint8_t pmb=0;
  816. bool rtouch=b&TB_RPADT; int rx=s16off(r,22), ry=s16off(r,24);
  817. if(rtouch){ if(prt){ vx+=(rx-prx); vy+=(ry-pry); } prx=rx; pry=ry; } prt=rtouch;
  818. float mxf=vx/(float)(g_mDiv*10)+rmx, myf=-(vy/(float)(g_mDiv*10))+rmy; // Y inverted for screen coords
  819. int dx=(int)mxf, dy=(int)myf; rmx=mxf-dx; rmy=myf-dy; // sub-pixel carry
  820. if(dx>127)dx=127; if(dx<-127)dx=-127; if(dy>127)dy=127; if(dy<-127)dy=-127;
  821. float f=g_mFric/100.0f; vx*=f; vy*=f; if(vx>-1&&vx<1)vx=0; if(vy>-1&&vy<1)vy=0; // friction = glide/decay
  822. uint8_t mb=((b&TB_RPADC)?1:0)|((b&TB_LPADC)?2:0); // RPad click = left, LPad click = right
  823. if(dx||dy||mb!=pmb){ pmb=mb;
  824. hid_mouse_report_t m; m.buttons=mb; m.x=(int8_t)dx; m.y=(int8_t)dy; m.wheel=0; m.pan=0;
  825. if(g_mouse.ready()) g_mouse.sendReport(0,&m,sizeof m);
  826. }
  827. }
  828. // ===================== Seamless LIZARD (Steam mode, when Steam is closed) — desktop keyboard + mouse =====
  829. // The controller's native "lizard" desktop behavior, reproduced on the copycat side so the device is a
  830. // driverless keyboard+mouse when Steam isn't running. Driven from the Steam path (report 0x45 -> mouse 0x40 +
  831. // keyboard 0x41 on the puck interface) and parameterized by the target HID device + report IDs. Canonical
  832. // Valve Steam Controller firmware map (SC1, the documented reference; SC2 presumed-similar): right pad ->
  833. // mouse (trackball glide), left pad -> scroll wheel + middle-click on press, R-trigger / R-pad-click -> left
  834. // mouse, L-trigger -> right mouse; A=Enter B=Esc X=PgUp Y=PgDn, D-pad & left-stick -> arrow keys, LB=LeftCtrl
  835. // RB=LeftAlt, Back/View=Tab, Start/Menu=Esc, Steam=none. The mouse reuses the SAME velocity+friction+sub-pixel
  836. // GLIDE model as Xbox mode (g_mDiv = sensitivity divisor, g_mFric = friction% — both live-tunable).
  837. static void rfLizard(const uint8_t* r, Adafruit_USBD_HID* mdev, Adafruit_USBD_HID* kdev, uint8_t mrid, uint8_t krid){
  838. uint32_t b=btnsOf(r);
  839. // --- right pad -> mouse motion with glide (mirrors rfXboxMouse) ---
  840. static int prx=0,pry=0; static bool prt=false; static float vx=0,vy=0,rmx=0,rmy=0;
  841. bool rtouch=b&TB_RPADT; int rx=s16off(r,22), ry=s16off(r,24);
  842. if(rtouch){ if(prt){ vx+=(rx-prx); vy+=(ry-pry); } prx=rx; pry=ry; } prt=rtouch;
  843. float mxf=vx/(float)(g_mDiv*10)+rmx, myf=-(vy/(float)(g_mDiv*10))+rmy; // Y inverted; *10 = desktop-cursor sensitivity (g_mDiv 64 -> eff 640). Lower g_mDiv via WebUI slider = faster.
  844. int dx=(int)mxf, dy=(int)myf; rmx=mxf-dx; rmy=myf-dy; // sub-pixel carry
  845. if(dx>127)dx=127; if(dx<-127)dx=-127; if(dy>127)dy=127; if(dy<-127)dy=-127;
  846. float f=g_mFric/100.0f; vx*=f; vy*=f; if(vx>-1&&vx<1)vx=0; if(vy>-1&&vy<1)vy=0; // friction = glide/decay
  847. // --- left pad -> vertical scroll wheel (no momentum; only while touching, coarse) ---
  848. static int ply=0; static bool plt=false; static float sacc=0;
  849. bool ltouch=b&TB_LPADT; int ly=s16off(r,18);
  850. if(ltouch){ if(plt){ sacc += (ly-ply)/(float)(g_mDiv*24); } ply=ly; } else sacc=0; plt=ltouch;
  851. int dw=(int)sacc; sacc-=dw; if(dw>15)dw=15; if(dw<-15)dw=-15; // finger up = wheel up (positive)
  852. // --- mouse buttons: left=R-pad-click|R-trigger, right=L-trigger, middle=L-pad-click ---
  853. // trigU8 (full-range scale + saturate) so a full pull reaches ~0xFF; a raw >>8 tops out ~0x80 and never
  854. // crosses the threshold, leaving the trigger clicks dead.
  855. uint8_t rtrig=trigU8(u16off(r,6)), ltrig=trigU8(u16off(r,4));
  856. uint8_t mbtn=0;
  857. if((b&TB_RPADC)||rtrig>180) mbtn|=1; // right trigger -> left (primary) click
  858. if(ltrig>180) mbtn|=2; // left trigger -> right click
  859. if(b&TB_LPADC) mbtn|=4;
  860. static uint8_t pmbtn=0;
  861. if(dx||dy||dw||mbtn!=pmbtn){ pmbtn=mbtn;
  862. hid_mouse_report_t m; m.buttons=mbtn; m.x=(int8_t)dx; m.y=(int8_t)dy; m.wheel=(int8_t)dw; m.pan=0;
  863. if(mdev->ready()) mdev->sendReport(mrid,&m,sizeof m);
  864. }
  865. // --- keyboard: modifiers + up to 6 keycodes ---
  866. uint8_t mod=0, kc[6]={0,0,0,0,0,0}, nk=0;
  867. if(b&TB_LB) mod|=KEYBOARD_MODIFIER_LEFTCTRL;
  868. if(b&TB_RB) mod|=KEYBOARD_MODIFIER_LEFTALT;
  869. #define LZK(cond,code) do{ if((cond)&&nk<6) kc[nk++]=(code); }while(0)
  870. LZK(b&TB_A, HID_KEY_ENTER);
  871. LZK(b&TB_B, HID_KEY_ESCAPE);
  872. LZK(b&TB_X, HID_KEY_PAGE_UP);
  873. LZK(b&TB_Y, HID_KEY_PAGE_DOWN);
  874. LZK(b&TB_VIEW, HID_KEY_TAB);
  875. LZK(b&TB_MENU, HID_KEY_ESCAPE);
  876. int sx=s16off(r,8), sy=s16off(r,10); // left stick (XInput sign: +Y = up); deflect ~37% acts as a d-pad
  877. LZK((b&TB_DUP)||sy> 12000, HID_KEY_ARROW_UP);
  878. LZK((b&TB_DDN)||sy<-12000, HID_KEY_ARROW_DOWN);
  879. LZK((b&TB_DLF)||sx<-12000, HID_KEY_ARROW_LEFT);
  880. LZK((b&TB_DRT)||sx> 12000, HID_KEY_ARROW_RIGHT);
  881. #undef LZK
  882. static uint8_t pmod=0, pkc[6]={0,0,0,0,0,0};
  883. bool chg=(mod!=pmod); for(int i=0;i<6;i++) if(kc[i]!=pkc[i]) chg=true;
  884. if(chg){ pmod=mod; for(int i=0;i<6;i++) pkc[i]=kc[i];
  885. uint8_t krep[8]={mod,0,kc[0],kc[1],kc[2],kc[3],kc[4],kc[5]};
  886. if(kdev->ready()) kdev->sendReport(krid,krep,8);
  887. }
  888. }
  889. // ===================== SWITCH mode (mode 2) — HORIPAD / Pokkén Tournament Pro Pad =====================
  890. // Targets a REAL Switch console: a plain HID gamepad (HORI 0F0D:0092) the console accepts on plug-in with
  891. // NO handshake / SPI calibration / encryption — unlike the genuine Pro Controller (057E:2009), whose USB
  892. // init the console handles fussily. This is the descriptor every DIY Switch controller copies (the LUFA
  893. // "Splatoon printer" descriptor). Also recognized by SDL/Steam on PC. 8-byte input report streamed at
  894. // ~250Hz stream: [btn_lo][btn_hi][hat][LX][LY][RX][RY][vendor], sticks uint8 center 0x80.
  895. static const uint8_t SWITCH_HID_DESC[]={
  896. 0x05,0x01, 0x09,0x05, 0xA1,0x01, // Usage Page (Generic Desktop), Usage (Game Pad), Collection (App)
  897. 0x15,0x00, 0x25,0x01, 0x35,0x00, 0x45,0x01, 0x75,0x01, 0x95,0x10, // 16 x 1-bit buttons
  898. 0x05,0x09, 0x19,0x01, 0x29,0x10, 0x81,0x02,
  899. 0x05,0x01, 0x25,0x07, 0x46,0x3B,0x01, 0x75,0x04, 0x95,0x01, 0x65,0x14, 0x09,0x39, 0x81,0x42, // hat switch (4 bits)
  900. 0x65,0x00, 0x95,0x01, 0x81,0x01, // 4-bit padding to a byte
  901. 0x26,0xFF,0x00, 0x46,0xFF,0x00, 0x09,0x30, 0x09,0x31, 0x09,0x32, 0x09,0x35, 0x75,0x08, 0x95,0x04, 0x81,0x02, // X,Y,Z,Rz
  902. 0x06,0x00,0xFF, 0x09,0x20, 0x95,0x01, 0x81,0x02, // vendor byte (input)
  903. 0x0A,0x21,0x26, 0x95,0x08, 0x91,0x02, // vendor (output, 8 bytes; unused)
  904. 0xC0
  905. };
  906. static uint32_t g_swBtns=0; static int16_t g_swLX=0,g_swLY=0,g_swRX=0,g_swRY=0; // latest decoded RF frame
  907. static uint8_t g_swLT=0,g_swRT=0; // latest scaled (0..255) L/R trigger pull, for the Switch digital-trigger threshold
  908. // Switch ZL/ZR are digital (on/off): fire once the analog pull crosses this fraction of full travel, so the
  909. // trigger activates far sooner than the controller's full-press click bit (~16% of 0xFF).
  910. #define SW_TRIG_ON 40
  911. static unsigned long g_swLastMs=0;
  912. Adafruit_USBD_HID g_switch; // HORIPAD gamepad HID interface (mode 2)
  913. // HORIPAD/Switch button bits: Y=1 B=2 A=4 X=8 L=10 R=20 ZL=40 ZR=80 Minus=100 Plus=200 LClick=400 RClick=800 Home=1000 Capture=2000
  914. static inline uint8_t swStick(int16_t v,bool invert){ // int16 (center 0) -> uint8 (center 0x80)
  915. int32_t a = 0x80 + (invert ? -((int32_t)v>>8) : ((int32_t)v>>8));
  916. if(a<0)a=0; if(a>255)a=255; return (uint8_t)a;
  917. }
  918. // back-paddle code (g_back[]) -> Switch button bit. 0=none 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back(Minus) 10=Start(Plus) 11=Guide(Home)
  919. static uint16_t codeToSwitch(uint8_t c, uint16_t fA,uint16_t fB,uint16_t fX,uint16_t fY){
  920. switch(c){ case 1:return fA; case 2:return fB; case 3:return fX; case 4:return fY;
  921. case 5:return 0x10; case 6:return 0x20; case 7:return 0x400; case 8:return 0x800;
  922. case 9:return 0x100; case 10:return 0x200; case 11:return 0x1000; default:return 0; }
  923. }
  924. static void switchBuildHoripad(uint8_t out[8]){
  925. uint32_t b=g_swBtns; uint16_t btn=0;
  926. // Mode-switch chord (all 4 back + A/X/Y): don't pass the face press to the console while the back-4 are held.
  927. if((b&(TB_R4|TB_L4|TB_R5|TB_L5))==(TB_R4|TB_L4|TB_R5|TB_L5)) b &= ~(uint32_t)(TB_A|TB_X|TB_Y);
  928. // face buttons with optional A/B + X/Y swap (Nintendo physical-vs-label layout)
  929. uint16_t fY = g_abSwap?0x08:0x01, fB = g_abSwap?0x04:0x02, fA = g_abSwap?0x02:0x04, fX = g_abSwap?0x01:0x08;
  930. if(b&TB_Y)btn|=fY; if(b&TB_B)btn|=fB; if(b&TB_A)btn|=fA; if(b&TB_X)btn|=fX;
  931. if(b&TB_LB)btn|=0x10; if(b&TB_RB)btn|=0x20; // L, R
  932. // ZL/ZR digital: trip on the analog threshold (activates early) OR the full-press click bit
  933. if((g_swLT>=SW_TRIG_ON)||(b&0x8000000u))btn|=0x40; if((g_swRT>=SW_TRIG_ON)||(b&0x800000u))btn|=0x80;
  934. if(b&TB_MENU)btn|=0x100; if(b&TB_VIEW)btn|=0x200; // Minus, Plus
  935. if(b&TB_L3)btn|=0x400; if(b&TB_R3)btn|=0x800; // LClick, RClick
  936. if(b&TB_STEAM)btn|=0x1000; // Home
  937. // back paddles -> configurable mapping (same g_back[] as Xbox: default L4->LB R4->RB L5->L3 R5->R3)
  938. if(b&TB_L4)btn|=codeToSwitch(g_back[0],fA,fB,fX,fY); if(b&TB_R4)btn|=codeToSwitch(g_back[1],fA,fB,fX,fY);
  939. if(b&TB_L5)btn|=codeToSwitch(g_back[2],fA,fB,fX,fY); if(b&TB_R5)btn|=codeToSwitch(g_back[3],fA,fB,fX,fY);
  940. bool u=b&TB_DUP,d=b&TB_DDN,l=b&TB_DLF,r=b&TB_DRT; // hat: 0=N..7=NW, 8=neutral
  941. uint8_t hat=8;
  942. if(u&&r)hat=1; else if(r&&d)hat=3; else if(d&&l)hat=5; else if(l&&u)hat=7;
  943. else if(u)hat=0; else if(r)hat=2; else if(d)hat=4; else if(l)hat=6;
  944. out[0]=btn&0xFF; out[1]=btn>>8; out[2]=hat;
  945. out[3]=swStick(g_swLX,false); out[4]=swStick(g_swLY,true); // HID Y is down-positive -> invert
  946. out[5]=swStick(g_swRX,false); out[6]=swStick(g_swRY,true);
  947. out[7]=0;
  948. }
  949. static void swStream(){ // call from loop(); stream the 8-byte HORIPAD report at ~250Hz (no handshake needed)
  950. if(g_usbMode!=MODE_SW_HORI || !g_switch.ready()) return;
  951. if(millis()-g_swLastMs < USB_STREAM_MS) return; g_swLastMs=millis();
  952. uint8_t p[8]; switchBuildHoripad(p);
  953. g_switch.sendReport(0, p, sizeof p); // report-id-less descriptor
  954. }
  955. // ---- report 0x45 IMU offsets (PROTOCOL.md §8): accel @0x22, gyro @0x28 from report start ----
  956. static void imuFrom45(const uint8_t* r, int16_t* ax, int16_t* ay, int16_t* az, int16_t* gx, int16_t* gy, int16_t* gz){
  957. *ax=s16off(r,32); *ay=s16off(r,34); *az=s16off(r,36);
  958. *gx=s16off(r,38); *gy=s16off(r,40); *gz=s16off(r,42);
  959. }
  960. // Map Steam trackpad s16 coords into a 0..max axis (centered touch -> mid-range).
  961. static uint16_t padNormU16(int16_t v, uint16_t maxv){
  962. int32_t t=((int32_t)v+32768); if(t<0)t=0; if(t>65535)t=65535;
  963. return (uint16_t)((t*(int32_t)maxv)/65535);
  964. }
  965. #define TOUCH_PAD_W 1920u
  966. static uint16_t touchHalfX(int16_t v, bool rightHalf){
  967. uint16_t halfMax = TOUCH_PAD_W/2 - 1;
  968. uint16_t x = padNormU16(v, halfMax);
  969. return rightHalf ? (uint16_t)(TOUCH_PAD_W/2 + x) : x;
  970. }
  971. static uint16_t touchYInv(int16_t v, uint16_t height){
  972. uint16_t maxy = height - 1;
  973. return (uint16_t)(maxy - padNormU16(v, maxy));
  974. }
  975. static void touchPackPoint(uint8_t* base, int finger, bool touch, uint16_t x, uint16_t y){
  976. uint8_t* f=base+finger*4;
  977. if(!touch){ f[0]=0x80; f[1]=0; f[2]=0; f[3]=0; return; }
  978. f[0]=(uint8_t)(finger&0x7F);
  979. f[1]=(uint8_t)(x&0xFF);
  980. f[2]=(uint8_t)(((x>>8)&0x0F)|((y&0x0F)<<4));
  981. f[3]=(uint8_t)((y>>4)&0xFF);
  982. }
  983. // DualSense / DS4: many hosts only honor contact slot 0 — put a lone pad in slot 0, both pads in 0+1.
  984. static void touchPackPads(uint8_t* pts, bool lTouch, bool rTouch,
  985. uint16_t lx, uint16_t ly, uint16_t rx, uint16_t ry){
  986. touchPackPoint(pts, 0, false, 0, 0);
  987. touchPackPoint(pts, 1, false, 0, 0);
  988. if(lTouch && rTouch){
  989. touchPackPoint(pts, 0, true, lx, ly);
  990. touchPackPoint(pts, 1, true, rx, ry);
  991. } else if(lTouch){
  992. touchPackPoint(pts, 0, true, lx, ly);
  993. } else if(rTouch){
  994. touchPackPoint(pts, 0, true, rx, ry);
  995. }
  996. }
  997. static void steamPadsToTouch(uint32_t b, uint16_t touchH, int16_t lpx, int16_t lpy, int16_t rpx, int16_t rpy,
  998. uint16_t* lx, uint16_t* ly, uint16_t* rx, uint16_t* ry){
  999. bool lt=(b&TB_LPADT)||(b&TB_LPADC), rt=(b&TB_RPADT)||(b&TB_RPADC);
  1000. *lx=touchHalfX(lpx, false); *ly=touchYInv(lpy, touchH);
  1001. *rx=touchHalfX(rpx, true); *ry=touchYInv(rpy, touchH);
  1002. if(lt && !(b&TB_LPADT)){ *lx=TOUCH_PAD_W/4; *ly=touchH/2; }
  1003. if(rt && !(b&TB_RPADT)){ *rx=TOUCH_PAD_W/4*3; *ry=touchH/2; }
  1004. }
  1005. // Map g_back[] paddle code -> Steam button flags (same codes as codeToXB / codeToSwitch).
  1006. static void psOrBackCode(uint32_t* b, uint8_t c){
  1007. switch(c){ case 1:*b|=TB_A; break; case 2:*b|=TB_B; break; case 3:*b|=TB_X; break; case 4:*b|=TB_Y; break;
  1008. case 5:*b|=TB_LB; break; case 6:*b|=TB_RB; break; case 7:*b|=TB_L3; break; case 8:*b|=TB_R3; break;
  1009. case 9:*b|=TB_VIEW; break; case 10:*b|=TB_MENU; break; case 11:*b|=TB_STEAM; break; default: break; }
  1010. }
  1011. static uint32_t psButtonsFromSteam(uint32_t raw){
  1012. uint32_t b=raw;
  1013. if((b&(TB_R4|TB_L4|TB_R5|TB_L5))==(TB_R4|TB_L4|TB_R5|TB_L5)) b&=~(uint32_t)(TB_A|TB_B|TB_X|TB_Y);
  1014. if(b&TB_L4) psOrBackCode(&b, g_back[0]); if(b&TB_R4) psOrBackCode(&b, g_back[1]);
  1015. if(b&TB_L5) psOrBackCode(&b, g_back[2]); if(b&TB_R5) psOrBackCode(&b, g_back[3]);
  1016. return b;
  1017. }
  1018. // DualSense / DS4 buttons[1]: L1..R3, Create(Share), Options(Start)
  1019. static uint8_t psShouldersByte(uint32_t b){
  1020. return ((b&TB_LB)?0x01:0)|((b&TB_RB)?0x02:0)
  1021. |((g_swLT>SW_TRIG_ON||(b&0x8000000u))?0x04:0)|((g_swRT>SW_TRIG_ON||(b&0x800000u))?0x08:0)
  1022. |((b&TB_MENU)?0x10:0)|((b&TB_VIEW)?0x20:0) // Menu=Options, View=Create/Share
  1023. |((b&TB_L3)?0x40:0)|((b&TB_R3)?0x80:0);
  1024. }
  1025. // ===================== SWITCH PRO mode (4) — 057E:2009, report 0x30 + IMU (hid-nintendo layout) =====================
  1026. // Linux hid-nintendo hardcodes joycon_input_report for USB 0x30 (not the bogus descriptor fields).
  1027. // Payload: timer, bat_con, btn[3], sticks[3+3], vib, imu×3 — sendReport(0x30,…) prepends the report ID.
  1028. static const uint8_t SWPRO_HID_DESC[]={
  1029. 0x05,0x01,0x15,0x00,0x09,0x04,0xA1,0x01,0x85,0x30,0x05,0x01,0x05,0x09,0x19,0x01,
  1030. 0x29,0x0A,0x15,0x00,0x25,0x01,0x75,0x01,0x95,0x0A,0x55,0x00,0x65,0x00,0x81,0x02,
  1031. 0x05,0x09,0x19,0x0B,0x29,0x0E,0x15,0x00,0x25,0x01,0x75,0x01,0x95,0x04,0x81,0x02,
  1032. 0x75,0x01,0x95,0x02,0x81,0x03,0x0B,0x01,0x00,0x01,0x00,0xA1,0x00,0x0B,0x30,0x00,
  1033. 0x01,0x00,0x0B,0x31,0x00,0x01,0x00,0x0B,0x32,0x00,0x01,0x00,0x0B,0x35,0x00,0x01,
  1034. 0x00,0x15,0x00,0x27,0xFF,0xFF,0x00,0x00,0x75,0x10,0x95,0x04,0x81,0x02,0xC0,0x0B,
  1035. 0x39,0x00,0x01,0x00,0x15,0x00,0x25,0x07,0x35,0x00,0x46,0x3B,0x01,0x65,0x14,0x75,
  1036. 0x04,0x95,0x01,0x81,0x42,0x05,0x09,0x19,0x0F,0x29,0x12,0x15,0x00,0x25,0x01,0x75,
  1037. 0x01,0x95,0x04,0x81,0x02,0x75,0x08,0x95,0x34,0x81,0x03,0x06,0x00,0xFF,0x85,0x21,
  1038. 0x09,0x01,0x75,0x08,0x95,0x3F,0x81,0x03,0x85,0x81,0x09,0x02,0x75,0x08,0x95,0x3F,
  1039. 0x81,0x03,0x85,0x01,0x09,0x03,0x75,0x08,0x95,0x3F,0x91,0x83,0x85,0x10,0x09,0x04,
  1040. 0x75,0x08,0x95,0x3F,0x91,0x83,0x85,0x80,0x09,0x05,0x75,0x08,0x95,0x3F,0x91,0x83,
  1041. 0x85,0x82,0x09,0x06,0x75,0x08,0x95,0x3F,0x91,0x83,0xC0
  1042. };
  1043. #define JC_BTN_Y (1u<<0)
  1044. #define JC_BTN_X (1u<<1)
  1045. #define JC_BTN_B (1u<<2)
  1046. #define JC_BTN_A (1u<<3)
  1047. #define JC_BTN_R (1u<<6)
  1048. #define JC_BTN_ZR (1u<<7)
  1049. #define JC_BTN_MINUS (1u<<8)
  1050. #define JC_BTN_PLUS (1u<<9)
  1051. #define JC_BTN_RSTICK (1u<<10)
  1052. #define JC_BTN_LSTICK (1u<<11)
  1053. #define JC_BTN_HOME (1u<<12)
  1054. #define JC_BTN_DOWN (1u<<16)
  1055. #define JC_BTN_UP (1u<<17)
  1056. #define JC_BTN_RIGHT (1u<<18)
  1057. #define JC_BTN_LEFT (1u<<19)
  1058. #define JC_BTN_L (1u<<22)
  1059. #define JC_BTN_ZL (1u<<23)
  1060. static uint32_t codeToJc(uint8_t c, uint32_t fA,uint32_t fB,uint32_t fX,uint32_t fY){
  1061. switch(c){ case 1:return fA; case 2:return fB; case 3:return fX; case 4:return fY;
  1062. case 5:return JC_BTN_L; case 6:return JC_BTN_R; case 7:return JC_BTN_LSTICK; case 8:return JC_BTN_RSTICK;
  1063. case 9:return JC_BTN_MINUS; case 10:return JC_BTN_PLUS; case 11:return JC_BTN_HOME; default:return 0; }
  1064. }
  1065. static int16_t g_swProAX=0,g_swProAY=0,g_swProAZ=0,g_swProGX=0,g_swProGY=0,g_swProGZ=0;
  1066. static unsigned long g_swProLastMs=0;
  1067. Adafruit_USBD_HID g_swPro;
  1068. static uint8_t g_swProReportMode=0; // 0 until the host's subcommand 0x03 selects 0x30 -> THEN we stream input
  1069. static const uint8_t JC_MAC[6]={0x7C,0xBB,0x8A,0x00,0x00,0x01}; // synthetic but stable controller MAC
  1070. static int jcStick12(int16_t v, bool inv){ // steam int16 (center 0) -> 12-bit (center 0x800), clamped
  1071. int a = 2048 + (inv ? -((int)v>>4) : ((int)v>>4));
  1072. return a<0?0:(a>4095?4095:a);
  1073. }
  1074. static void jcPackStick(uint8_t s[3], int16_t x, int16_t y){ // pack two 12-bit axes into 3 bytes (both normal; Switch Y polarity is opposite DS4)
  1075. int X=jcStick12(x,false), Y=jcStick12(y,false);
  1076. s[0]=(uint8_t)(X&0xFF);
  1077. s[1]=(uint8_t)(((Y&0x0F)<<4)|((X>>8)&0x0F));
  1078. s[2]=(uint8_t)((Y>>4)&0xFF);
  1079. }
  1080. // Standard input-report prefix [0..11] (timer, battery/conn, 3 button bytes, both packed sticks, vibrator),
  1081. // shared by the streamed 0x30 report and the 0x21 subcommand-reply reports the host reads during init.
  1082. static uint8_t g_jcTimer=0;
  1083. static void jcInputPrefix(uint8_t* out){
  1084. uint32_t b=g_swBtns;
  1085. if((b&(TB_R4|TB_L4|TB_R5|TB_L5))==(TB_R4|TB_L4|TB_R5|TB_L5)) b&=~(uint32_t)(TB_A|TB_B|TB_X|TB_Y);
  1086. uint32_t fA=g_abSwap?JC_BTN_B:JC_BTN_A, fB=g_abSwap?JC_BTN_A:JC_BTN_B;
  1087. uint32_t fX=g_abSwap?JC_BTN_Y:JC_BTN_X, fY=g_abSwap?JC_BTN_X:JC_BTN_Y;
  1088. uint32_t jc=0;
  1089. if(b&TB_Y)jc|=fY; if(b&TB_B)jc|=fB; if(b&TB_A)jc|=fA; if(b&TB_X)jc|=fX;
  1090. if(b&TB_LB)jc|=JC_BTN_L; if(b&TB_RB)jc|=JC_BTN_R;
  1091. if((g_swLT>=SW_TRIG_ON)||(b&0x8000000u))jc|=JC_BTN_ZL; if((g_swRT>=SW_TRIG_ON)||(b&0x800000u))jc|=JC_BTN_ZR;
  1092. if(b&TB_VIEW)jc|=JC_BTN_PLUS; if(b&TB_MENU)jc|=JC_BTN_MINUS;
  1093. if(b&TB_L3)jc|=JC_BTN_LSTICK; if(b&TB_R3)jc|=JC_BTN_RSTICK;
  1094. if(b&TB_STEAM)jc|=JC_BTN_HOME;
  1095. if(b&TB_DDN)jc|=JC_BTN_DOWN; if(b&TB_DUP)jc|=JC_BTN_UP;
  1096. if(b&TB_DRT)jc|=JC_BTN_RIGHT; if(b&TB_DLF)jc|=JC_BTN_LEFT;
  1097. 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);
  1098. 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);
  1099. out[0]=g_jcTimer++;
  1100. out[1]=0x81; // full battery + USB powered (hid-nintendo bat_con)
  1101. out[2]=(uint8_t)(jc); out[3]=(uint8_t)(jc>>8); out[4]=(uint8_t)(jc>>16);
  1102. jcPackStick(out+5, g_swLX, g_swLY);
  1103. jcPackStick(out+8, g_swRX, g_swRY);
  1104. out[11]=0;
  1105. }
  1106. static void switchProBuild(uint8_t out[63]){
  1107. memset(out,0,63);
  1108. jcInputPrefix(out);
  1109. for(int k=0;k<3;k++){
  1110. int o=12+k*12;
  1111. out[o+0]=g_swProAX&0xFF; out[o+1]=g_swProAX>>8;
  1112. out[o+2]=g_swProAY&0xFF; out[o+3]=g_swProAY>>8;
  1113. out[o+4]=g_swProAZ&0xFF; out[o+5]=g_swProAZ>>8;
  1114. out[o+6]=g_swProGX&0xFF; out[o+7]=g_swProGX>>8;
  1115. out[o+8]=g_swProGY&0xFF; out[o+9]=g_swProGY>>8;
  1116. out[o+10]=g_swProGZ&0xFF; out[o+11]=g_swProGZ>>8;
  1117. }
  1118. }
  1119. // --- Canonical factory SPI dumps the host reads for calibration. Neutral IMU + centered sticks so a fresh
  1120. // "device" calibrates sane; user-cal regions (0x80xx) read 0xFF so the host falls back to these factory blocks.
  1121. static const uint8_t SPI_IMU_CAL[24]={0x00,0x00,0x00,0x00,0x00,0x00, 0x00,0x40,0x00,0x40,0x00,0x40,
  1122. 0x00,0x00,0x00,0x00,0x00,0x00, 0x3B,0x34,0x3B,0x34,0x3B,0x34};
  1123. static const uint8_t SPI_PARAMS1[24]={0x50,0xFD,0x00,0x00,0xC6,0x0F, 0x0F,0x30,0x61,0x96,0x30,0xF3,
  1124. 0xD4,0x14,0x54,0x41,0x15,0x54, 0xC7,0x79,0x9C,0x33,0x36,0x63};
  1125. static const uint8_t SPI_PARAMS2[18]={0x0F,0x30,0x61,0x96,0x30,0xF3, 0xD4,0x14,0x54,0x41,0x15,0x54,
  1126. 0xC7,0x79,0x9C,0x33,0x36,0x63};
  1127. static const uint8_t SPI_COLOR[13]={0x32,0x32,0x32, 0xE6,0xE6,0xE6, 0x32,0x32,0x32, 0x32,0x32,0x32, 0xFF};
  1128. static uint8_t g_spiStickCal[18]; // built at boot: left[9]+right[9] factory stick calibration (packed 12-bit)
  1129. static void jcPack12(uint8_t* o9, const uint16_t v[6]){ // pack 6 12-bit values into 9 bytes (Switch stick-cal format)
  1130. o9[0]=v[0]&0xFF; o9[1]=((v[1]&0x0F)<<4)|((v[0]>>8)&0x0F); o9[2]=(v[1]>>4)&0xFF;
  1131. o9[3]=v[2]&0xFF; o9[4]=((v[3]&0x0F)<<4)|((v[2]>>8)&0x0F); o9[5]=(v[3]>>4)&0xFF;
  1132. o9[6]=v[4]&0xFF; o9[7]=((v[5]&0x0F)<<4)|((v[4]>>8)&0x0F); o9[8]=(v[5]>>4)&0xFF;
  1133. }
  1134. static void jcBuildStickCal(){
  1135. const uint16_t C=2048, R=1800; // center, +/- range per axis
  1136. uint16_t L[6]={R,R, C,C, R,R}; // left order: max(x,y), center(x,y), min(x,y)
  1137. uint16_t Rr[6]={C,C, R,R, R,R}; // right order: center(x,y), min(x,y), max(x,y)
  1138. jcPack12(&g_spiStickCal[0], L); jcPack12(&g_spiStickCal[9], Rr);
  1139. }
  1140. static void spiRead(uint32_t addr, uint8_t len, uint8_t* dst){
  1141. for(uint8_t i=0;i<len;i++){
  1142. uint32_t a=addr+i; uint8_t v=0xFF;
  1143. if (a>=0x6020 && a<0x6020+24) v=SPI_IMU_CAL[a-0x6020];
  1144. else if(a>=0x603D && a<0x603D+18) v=g_spiStickCal[a-0x603D];
  1145. else if(a>=0x6050 && a<0x6050+13) v=SPI_COLOR[a-0x6050];
  1146. else if(a>=0x6080 && a<0x6080+24) v=SPI_PARAMS1[a-0x6080];
  1147. else if(a>=0x6098 && a<0x6098+18) v=SPI_PARAMS2[a-0x6098];
  1148. dst[i]=v; // unmapped (incl. user-cal 0x80xx) -> 0xFF = "blank flash" -> host uses factory blocks above
  1149. }
  1150. }
  1151. // Reply FIFO: the host's handshake/subcommand reports arrive in the USB ISR (jcSet); we enqueue the canonical
  1152. // 0x81/0x21 reply and drain it from loop() (swProTask) where sendReport is safe. SPSC ring, volatile indices.
  1153. // Every reply is sent as a full 63-byte report (64 with the id). macOS IOHIDManager silently DROPS any input
  1154. // report shorter than the descriptor-declared length (0x3F), so short 0x81/0x21 replies never reached the host.
  1155. #define JC_REPLEN 63
  1156. struct JcRep { uint8_t rid; uint8_t data[JC_REPLEN]; };
  1157. #define JCQ_N 8
  1158. static volatile uint8_t g_jcQh=0, g_jcQt=0;
  1159. static JcRep g_jcQ[JCQ_N];
  1160. static void jcEnq(uint8_t rid, const uint8_t* d, uint8_t len){
  1161. uint8_t nt=(uint8_t)((g_jcQt+1)%JCQ_N);
  1162. if(nt==g_jcQh) return; // full -> drop; the host re-requests on timeout
  1163. if(len>JC_REPLEN) len=JC_REPLEN;
  1164. g_jcQ[g_jcQt].rid=rid;
  1165. memset(g_jcQ[g_jcQt].data,0,JC_REPLEN); memcpy(g_jcQ[g_jcQt].data,d,len); // zero-pad to full report length
  1166. g_jcQt=nt;
  1167. }
  1168. // Build the 0x21 reply (standard input prefix + ACK + echoed subcommand id + reply data) for a report-0x01 subcommand.
  1169. static void jcSubcmd(uint8_t sub, const uint8_t* args, uint16_t alen){
  1170. uint8_t p[JC_REPLEN]; memset(p,0,sizeof p);
  1171. jcInputPrefix(p);
  1172. p[13]=sub;
  1173. switch(sub){
  1174. case 0x02: // request device info
  1175. p[12]=0x82;
  1176. p[14]=0x04; p[15]=0x21; // firmware version (4.33)
  1177. p[16]=0x03; // controller type: Pro Controller
  1178. p[17]=0x02; // fixed
  1179. memcpy(&p[18], JC_MAC, 6);
  1180. p[24]=0x01; // colors stored in SPI (0x6050)
  1181. p[25]=0x02; // fixed
  1182. break;
  1183. case 0x10: { // SPI flash read -> echo [addr][len] then the data
  1184. if(alen<5){ p[12]=0x80; break; }
  1185. uint32_t a=(uint32_t)args[0]|((uint32_t)args[1]<<8)|((uint32_t)args[2]<<16)|((uint32_t)args[3]<<24);
  1186. uint8_t rl=args[4]; if(rl>0x1D) rl=0x1D;
  1187. p[12]=0x90;
  1188. p[14]=args[0]; p[15]=args[1]; p[16]=args[2]; p[17]=args[3]; p[18]=rl;
  1189. spiRead(a, rl, &p[19]);
  1190. break; }
  1191. case 0x03: // set input report mode (0x30 = standard full) -> begin streaming
  1192. if(alen>=1 && args[0]==0x30) g_swProReportMode=0x30;
  1193. p[12]=0x80; break;
  1194. case 0x04: p[12]=0x83; break; // trigger buttons elapsed time
  1195. case 0x21: p[12]=0xA0; break; // set NFC/IR config
  1196. default: p[12]=0x80; break; // 0x06/0x08/0x30/0x38/0x40/0x41/0x48/... generic positive ACK
  1197. }
  1198. jcEnq(0x21, p, JC_REPLEN);
  1199. }
  1200. // USB-ISR callback for the Pro Controller's OUT endpoint. Data arrives via the interrupt OUT endpoint, where
  1201. // this TinyUSB core passes report_id=0 and leaves the real report id in buffer[0] (it does NOT strip it). Also
  1202. // accept the control SET_REPORT shape (id already in `rid`) for safety. report 0x80 = USB handshake; 0x01 = subcommand.
  1203. static void jcSet(uint8_t rid, hid_report_type_t type, uint8_t const* b, uint16_t n){
  1204. if(type!=HID_REPORT_TYPE_OUTPUT || n<1) return;
  1205. uint8_t id; const uint8_t* p; uint16_t pn;
  1206. if(rid==0){ id=b[0]; p=b+1; pn=(uint16_t)(n-1); } // EP-OUT: id is the first payload byte
  1207. else { id=rid; p=b; pn=n; } // control SET_REPORT: id already split out
  1208. if(id==0x80){
  1209. if(pn<1) return;
  1210. if(p[0]==0x01){ uint8_t d[9]={0x01,0x00,0x03,JC_MAC[0],JC_MAC[1],JC_MAC[2],JC_MAC[3],JC_MAC[4],JC_MAC[5]}; jcEnq(0x81,d,9); } // device type + MAC
  1211. else if(p[0]==0x02){ uint8_t d[1]={0x02}; jcEnq(0x81,d,1); } // handshake
  1212. else if(p[0]==0x03){ uint8_t d[1]={0x03}; jcEnq(0x81,d,1); } // set baudrate
  1213. // 0x04 force-USB / 0x05 enable-timeout / 0x06 reset: no reply expected
  1214. return;
  1215. }
  1216. if(id==0x01){ // [timer][rumble x8][subcmd][args...]
  1217. if(pn<10) return;
  1218. jcSubcmd(p[9], p+10, (pn>10)?(uint16_t)(pn-10):0);
  1219. }
  1220. // report 0x10 (rumble only) / 0x82: ignored
  1221. }
  1222. static void swProTask(){
  1223. if(g_usbMode!=MODE_SW_PRO || !g_swPro.ready()) return;
  1224. if(g_jcQh!=g_jcQt){ // drain handshake/subcommand replies first (ordered)
  1225. JcRep* r=&g_jcQ[g_jcQh];
  1226. if(g_swPro.sendReport(r->rid, r->data, JC_REPLEN)) g_jcQh=(uint8_t)((g_jcQh+1)%JCQ_N);
  1227. return; // one report per call; the rest next loop
  1228. }
  1229. if(g_swProReportMode!=0x30) return; // not until the host has finished init + selected 0x30
  1230. if(millis()-g_swProLastMs<USB_STREAM_MS) return; g_swProLastMs=millis();
  1231. uint8_t p[63]; switchProBuild(p);
  1232. g_swPro.sendReport(0x30, p, sizeof p);
  1233. }
  1234. // ===================== PS5 DualSense mode (5) — 054C:0CE6 + gyro + split trackpad =====================
  1235. static const uint8_t PS5_HID_DESC[]={
  1236. 0x05,0x01,0x09,0x05,0xA1,0x01,0x85,0x01,0x09,0x30,0x09,0x31,0x09,0x32,0x09,0x35,
  1237. 0x09,0x33,0x09,0x34,0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x06,0x81,0x02,0x06,
  1238. 0x00,0xFF,0x09,0x20,0x95,0x01,0x81,0x02,0x05,0x01,0x09,0x39,0x15,0x00,0x25,0x07,
  1239. 0x35,0x00,0x46,0x3B,0x01,0x65,0x14,0x75,0x04,0x95,0x01,0x81,0x42,0x65,0x00,0x05,
  1240. 0x09,0x19,0x01,0x29,0x0F,0x15,0x00,0x25,0x01,0x75,0x01,0x95,0x0F,0x81,0x02,0x06,
  1241. 0x00,0xFF,0x09,0x21,0x95,0x0D,0x81,0x02,0x06,0x00,0xFF,0x09,0x22,0x15,0x00,0x26,
  1242. 0xFF,0x00,0x75,0x08,0x95,0x34,0x81,0x02,0x85,0x02,0x09,0x23,0x95,0x2F,0x91,0x02,
  1243. 0x85,0x05,0x09,0x33,0x95,0x28,0xB1,0x02,0x85,0x08,0x09,0x34,0x95,0x2F,0xB1,0x02,
  1244. 0x85,0x09,0x09,0x24,0x95,0x13,0xB1,0x02,0x85,0x0A,0x09,0x25,0x95,0x1A,0xB1,0x02,
  1245. 0x85,0x20,0x09,0x26,0x95,0x3F,0xB1,0x02,0x85,0x21,0x09,0x27,0x95,0x04,0xB1,0x02,
  1246. 0x85,0x22,0x09,0x40,0x95,0x3F,0xB1,0x02,0x85,0x80,0x09,0x28,0x95,0x3F,0xB1,0x02,
  1247. 0x85,0x81,0x09,0x29,0x95,0x3F,0xB1,0x02,0x85,0x82,0x09,0x2A,0x95,0x09,0xB1,0x02,
  1248. 0x85,0x83,0x09,0x2B,0x95,0x3F,0xB1,0x02,0x85,0x84,0x09,0x2C,0x95,0x3F,0xB1,0x02,
  1249. 0x85,0x85,0x09,0x2D,0x95,0x02,0xB1,0x02,0x85,0xA0,0x09,0x2E,0x95,0x01,0xB1,0x02,
  1250. 0x85,0xE0,0x09,0x2F,0x95,0x3F,0xB1,0x02,0x85,0xF0,0x09,0x30,0x95,0x3F,0xB1,0x02,
  1251. 0x85,0xF1,0x09,0x31,0x95,0x3F,0xB1,0x02,0x85,0xF2,0x09,0x32,0x95,0x0F,0xB1,0x02,
  1252. 0x85,0xF4,0x09,0x35,0x95,0x3F,0xB1,0x02,0x85,0xF5,0x09,0x36,0x95,0x03,0xB1,0x02,0xC0
  1253. };
  1254. #define PS5_TOUCH_H 1080
  1255. #define DS4_TOUCH_H 942
  1256. #define PS5_STATUS_USB 0x1A // charging + level 10 (~100%)
  1257. #define DS4_STATUS_USB 0x1B // cable + level 11 (full)
  1258. static int16_t g_ps5AX=0,g_ps5AY=0,g_ps5AZ=0,g_ps5GX=0,g_ps5GY=0,g_ps5GZ=0;
  1259. static int16_t g_ps5LPX=0,g_ps5LPY=0,g_ps5RPX=0,g_ps5RPY=0;
  1260. static unsigned long g_ps5LastMs=0;
  1261. Adafruit_USBD_HID g_ps5;
  1262. static uint8_t psHatNibble(uint32_t b){
  1263. bool u=b&TB_DUP,d=b&TB_DDN,l=b&TB_DLF,r=b&TB_DRT;
  1264. if(u&&r)return 1; if(r&&d)return 3; if(d&&l)return 5; if(l&&u)return 7;
  1265. if(u)return 0; if(r)return 2; if(d)return 4; if(l)return 6; return 8;
  1266. }
  1267. static uint8_t psFaceNibble(uint32_t b){
  1268. uint8_t f=0;
  1269. if(g_abSwap){
  1270. if(b&TB_A)f|=0x40; if(b&TB_B)f|=0x20; if(b&TB_X)f|=0x80; if(b&TB_Y)f|=0x10;
  1271. } else {
  1272. if(b&TB_A)f|=0x20; if(b&TB_B)f|=0x40; if(b&TB_X)f|=0x10; if(b&TB_Y)f|=0x80;
  1273. }
  1274. return f;
  1275. }
  1276. static void ps5Build(uint8_t out[63]){
  1277. uint32_t b=psButtonsFromSteam(g_swBtns);
  1278. bool lTouch=(b&TB_LPADT)||(b&TB_LPADC), rTouch=(b&TB_RPADT)||(b&TB_RPADC);
  1279. memset(out,0,63);
  1280. out[0]=swStick(g_swLX,false); out[1]=swStick(g_swLY,true);
  1281. out[2]=swStick(g_swRX,false); out[3]=swStick(g_swRY,true);
  1282. out[4]=g_swLT; out[5]=g_swRT;
  1283. static uint8_t seq=0; out[6]=seq++;
  1284. out[7]=psHatNibble(b)|psFaceNibble(b);
  1285. out[8]=psShouldersByte(b);
  1286. out[9]=((b&TB_STEAM)?0x01:0)|((b&TB_LPADC||b&TB_RPADC)?0x02:0);
  1287. out[15]=g_ps5GX&0xFF; out[16]=g_ps5GX>>8;
  1288. out[17]=g_ps5GZ&0xFF; out[18]=g_ps5GZ>>8;
  1289. out[19]=g_ps5GY&0xFF; out[20]=g_ps5GY>>8;
  1290. out[21]=g_ps5AX&0xFF; out[22]=g_ps5AX>>8;
  1291. out[23]=g_ps5AY&0xFF; out[24]=g_ps5AY>>8;
  1292. out[25]=g_ps5AZ&0xFF; out[26]=g_ps5AZ>>8;
  1293. uint16_t lx, ly, rx, ry;
  1294. steamPadsToTouch(b, PS5_TOUCH_H, g_ps5LPX, g_ps5LPY, g_ps5RPX, g_ps5RPY, &lx, &ly, &rx, &ry);
  1295. touchPackPads(out+32, lTouch, rTouch, lx, ly, rx, ry);
  1296. out[52]=PS5_STATUS_USB;
  1297. }
  1298. static void ps5Stream(){
  1299. if(g_usbMode!=MODE_PS5 || !g_ps5.ready()) return;
  1300. if(millis()-g_ps5LastMs<USB_STREAM_MS) return; g_ps5LastMs=millis();
  1301. uint8_t p[63]; ps5Build(p);
  1302. g_ps5.sendReport(0x01, p, sizeof p);
  1303. }
  1304. // ===================== Generic HID gyro mode (6) — DS4 USB report layout + motion =====================
  1305. // Standard DS4 054C:05C4 descriptor (input report 0x01, 64 B). Gyro/accel at bytes 13-24 (GIMX layout).
  1306. static const uint8_t GYRO_HID_DESC[]={
  1307. 0x05,0x01,0x09,0x05,0xA1,0x01,0x85,0x01,0x09,0x30,0x09,0x31,0x09,0x32,0x09,0x35,
  1308. 0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x04,0x81,0x02,0x09,0x39,0x15,0x00,0x25,
  1309. 0x07,0x35,0x00,0x46,0x3B,0x01,0x65,0x14,0x75,0x04,0x95,0x01,0x81,0x42,0x65,0x00,
  1310. 0x05,0x09,0x19,0x01,0x29,0x0E,0x15,0x00,0x25,0x01,0x75,0x01,0x95,0x0E,0x81,0x02,
  1311. 0x06,0x00,0xFF,0x09,0x20,0x75,0x06,0x95,0x01,0x15,0x00,0x25,0x7F,0x81,0x02,0x05,
  1312. 0x01,0x09,0x33,0x09,0x34,0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x02,0x81,0x02,
  1313. 0x06,0x00,0xFF,0x09,0x21,0x95,0x36,0x81,0x02,0x85,0x05,0x09,0x22,0x95,0x1F,0x91,
  1314. 0x02,0x85,0x04,0x09,0x23,0x95,0x24,0xB1,0x02,0x85,0x02,0x09,0x24,0x95,0x24,0xB1,
  1315. 0x02,0xC0
  1316. };
  1317. static int16_t g_gyroAX=0,g_gyroAY=0,g_gyroAZ=0,g_gyroGX=0,g_gyroGY=0,g_gyroGZ=0;
  1318. static unsigned long g_gyroLastMs=0;
  1319. Adafruit_USBD_HID g_hidGyro;
  1320. static void hidGyroBuild(uint8_t out[63]){
  1321. uint32_t b=psButtonsFromSteam(g_swBtns);
  1322. bool lTouch=(b&TB_LPADT)||(b&TB_LPADC), rTouch=(b&TB_RPADT)||(b&TB_RPADC);
  1323. memset(out,0,63);
  1324. out[0]=swStick(g_swLX,false); out[1]=swStick(g_swLY,true);
  1325. out[2]=swStick(g_swRX,false); out[3]=swStick(g_swRY,true);
  1326. out[4]=psHatNibble(b)|psFaceNibble(b);
  1327. out[5]=psShouldersByte(b);
  1328. static uint8_t ctr=0; out[6]=((ctr++&0x0F)<<4)|((b&TB_LPADC||b&TB_RPADC)?0x02:0)|((b&TB_STEAM)?0x01:0);
  1329. out[7]=g_swLT; out[8]=g_swRT;
  1330. out[12]=g_gyroGX&0xFF; out[13]=g_gyroGX>>8;
  1331. out[14]=g_gyroGY&0xFF; out[15]=g_gyroGY>>8;
  1332. out[16]=g_gyroGZ&0xFF; out[17]=g_gyroGZ>>8;
  1333. out[18]=g_gyroAX&0xFF; out[19]=g_gyroAX>>8;
  1334. out[20]=g_gyroAY&0xFF; out[21]=g_gyroAY>>8;
  1335. out[22]=g_gyroAZ&0xFF; out[23]=g_gyroAZ>>8;
  1336. out[29]=DS4_STATUS_USB;
  1337. if(lTouch || rTouch){
  1338. uint16_t lx, ly, rx, ry;
  1339. steamPadsToTouch(b, DS4_TOUCH_H, g_ps5LPX, g_ps5LPY, g_ps5RPX, g_ps5RPY, &lx, &ly, &rx, &ry);
  1340. static uint8_t tstamp=0;
  1341. out[32]=1; out[33]=tstamp++;
  1342. touchPackPads(out+34, lTouch, rTouch, lx, ly, rx, ry);
  1343. } else {
  1344. out[32]=0;
  1345. touchPackPads(out+34, false, false, 0, 0, 0, 0); // contact 0x80 — memset(0) reads as touch @0,0
  1346. }
  1347. }
  1348. static void hidGyroStream(){
  1349. if(g_usbMode!=MODE_HIDGYRO || !g_hidGyro.ready()) return;
  1350. if(millis()-g_gyroLastMs<USB_STREAM_MS) return; g_gyroLastMs=millis();
  1351. uint8_t p[63]; hidGyroBuild(p);
  1352. g_hidGyro.sendReport(0x01, p, sizeof p);
  1353. }
  1354. // RECIPE (IBEX disasm @0x18d80): controller streams 0xF1 input report ONLY when (a) connstate==3 [host
  1355. // frame E1 establishes it], (b) we mark host-awake via 0xE7 payload [00][00] (vs [00][01]=suspended,
  1356. // handler @0x193aa), and (c) EACH 0xE3 poll carries a GET-report-0x45 sub-TLV [len=02][subtype=01][id=45]
  1357. // [param] (parser @0x1948c; F1 builder @0x20ca4 arms a one-shot latch @0x2000d36e cleared after each F1 ->
  1358. // no free-run stream, 1 F1 per GET). F1 reply = type 0xF1 @rfrx[2] then TLV type4(analog/buttons)+
  1359. // type6(per-module gyro/accel/sticks).
  1360. static uint8_t g_connSt = 0; // 0=announce awake, 1=poll loop
  1361. static uint8_t g_connStep = 0; // repeat counter within a state
  1362. static uint16_t g_connPoll = 0; // poll counter (re-assert awake every 64th)
  1363. static uint32_t g_connF1 = 0; // count of 0xF1 input reports seen
  1364. static uint8_t g_getParam = 0x00; // GET report 0x45 param byte (try 0x00, fall back 0x2D) - 'q' cmd
  1365. static uint8_t g_e3mode = 1, g_e3pid = 0; // E3-poll PID/S1 mode: 0=fixed 0x07, 1=cycle PID(noack1), 2=cycle PID(noack0). DEFAULT 1: cycling the ESB PID drains the controller's report queue (~400 new/s vs ~60 with a fixed PID) — THE rate fix. 'e<n>' to A/B.
  1366. static bool g_connVerbose = false; // true=full multi-line debug dump; false=compact "I45 <hex>" stream
  1367. static unsigned long g_lastStream = 0; // throttle for compact UI stream
  1368. static unsigned long g_connCooldown = 0; // set on 0xF2 disconnect; pauses beacon+poll so a powering-off controller can sleep
  1369. // (g_connReplyMs, g_connSlot declared near g_slot so the USB feature handler can read them)
  1370. // TX one connected packet [LEN][S1][payload] on channel ch, then RX the reply into rfrx. Decodes 0xF1.
  1371. static uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t* payload, uint8_t plen){
  1372. memset(rftx,0,sizeof rftx);
  1373. rftx[0]=plen; // LENGTH = payload byte count
  1374. rftx[1]=s1; // S1 (type-specific)
  1375. memcpy(rftx+2, payload, plen); // payload[0]=type byte, then data/TLVs
  1376. rfConfig(ch); rfSetAddr(g_rfBase,g_rfPrefix);
  1377. NRF_RADIO->PACKETPTR=(uint32_t)rftx;
  1378. NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk|RADIO_SHORTS_END_DISABLE_Msk;
  1379. NRF_RADIO->EVENTS_DISABLED=0; NRF_RADIO->TASKS_TXEN=1;
  1380. RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
  1381. NRF_RADIO->PACKETPTR=(uint32_t)rfrx; rfrx[0]=0;
  1382. NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk; // RXEN->READY->START; catch the reply
  1383. NRF_RADIO->EVENTS_END=0; NRF_RADIO->TASKS_RXEN=1;
  1384. g_stPoll++;
  1385. uint32_t t0=micros(); while(!NRF_RADIO->EVENTS_END && (micros()-t0)<g_rxWin){} // RX window (tunable 'r'): caps poll rate ~1e6/rxWin
  1386. uint8_t rxlen=0;
  1387. if (NRF_RADIO->EVENTS_END){ NRF_RADIO->EVENTS_END=0;
  1388. bool crcok=NRF_RADIO->CRCSTATUS&1; rxlen=rfrx[0];
  1389. if(!crcok){ g_stCrc++; g_qosBad++; } // reply arrived but CRC failed -> RF quality (channel/interference)
  1390. if (crcok && rxlen && rxlen<=64){ // F1 report ~46B, so allow up to MAXLEN
  1391. g_connRx++; g_connReplyMs=millis(); // link alive -> loop() suppresses the redundant E1 beacon
  1392. if(rfrx[2]==0xF1) g_stF1++;
  1393. uint8_t rtype=rfrx[2]; // reply type byte (proven offset from captures)
  1394. if(rtype==0xF2) g_connCooldown=millis(); // controller disconnecting/powering off -> back off 2.5s
  1395. if(rtype==0xF3){ // F3 = controller status/version reply (ODR agent: reply to E7 version handshake, byte[6]=version)
  1396. g_stF3++; g_connF3v=rfrx[6];
  1397. if(g_connVerbose && Serial.availableForWrite()>40){ Serial.print(" F3 "); for(uint8_t i=0;i<(rxlen+2<32?rxlen+2:32);i++)Serial.printf("%02X",rfrx[i]); Serial.println(); }
  1398. }
  1399. bool isF1=(rtype==0xF1);
  1400. if(isF1){
  1401. g_connF1++;
  1402. int idx=3, end=rxlen+2; // walk ALL type6 TLVs (= HID report 0x45). idx is INT, not
  1403. const uint8_t* lastRep=nullptr; uint8_t lastTlen=0; // uint8_t: a tlen of 0xFE would make idx+=tlen+2 wrap
  1404. while(idx+1<end){ // mod-256 back to itself -> infinite loop -> USB hang/"crash".
  1405. uint8_t tlen=rfrx[idx], ttype=rfrx[idx+1]; // pace with the real puck — taking only [0] halved our rate.
  1406. if(tlen==0) break;
  1407. // Only a FULL 0x45 report that fits entirely in rfrx: a short or late/garbled TLV must not let the
  1408. // decode read past the RF buffer (corrupts rftx/RAM -> eventual crash).
  1409. if(ttype==6 && tlen>=28 && (size_t)(idx+2)+tlen<=sizeof(rfrx) && rfrx[idx+2]==0x45){
  1410. const uint8_t* rep=&rfrx[idx+2]; // report 0x45: [0x45][seq][buttons u32]...
  1411. bool fresh=(rep[1]!=g_lastSeq); if(fresh){ g_stNew++; g_lastSeq=rep[1]; } // genuine new report vs stale poll-repeat
  1412. uint32_t bb=btnsOf(rep);
  1413. // USB remote wakeup on Steam button short press (down + up within 1 s).
  1414. // A long press likely means the user is powering off the controller, so we ignore it.
  1415. { static bool steamWasDown=false; static unsigned long steamDownMs=0;
  1416. if(fresh){
  1417. bool steamNow=(bb & TB_STEAM)!=0;
  1418. if(steamNow && !steamWasDown) steamDownMs=millis(); // rising edge: record press time
  1419. if(!steamNow && steamWasDown && millis()-steamDownMs<1000u && USBDevice.suspended()) USBDevice.remoteWakeup(); // falling edge within 1 s -> short press -> wake
  1420. steamWasDown=steamNow;
  1421. }
  1422. }
  1423. // cache the latest decoded frame for the Switch streamer + Xbox/Steam paths
  1424. g_swBtns=bb; g_swLX=(int16_t)s16off(rep,8); g_swLY=(int16_t)s16off(rep,10);
  1425. g_swRX=(int16_t)s16off(rep,12); g_swRY=(int16_t)s16off(rep,14);
  1426. g_swLT=trigU8(u16off(rep,4)); g_swRT=trigU8(u16off(rep,6)); // for the Switch digital-trigger threshold
  1427. g_ps5LPX=(int16_t)s16off(rep,16); g_ps5LPY=(int16_t)s16off(rep,18);
  1428. g_ps5RPX=(int16_t)s16off(rep,22); g_ps5RPY=(int16_t)s16off(rep,24);
  1429. imuFrom45(rep, &g_swProAX,&g_swProAY,&g_swProAZ,&g_swProGX,&g_swProGY,&g_swProGZ);
  1430. g_ps5AX=g_swProAX; g_ps5AY=g_swProAY; g_ps5AZ=g_swProAZ;
  1431. g_ps5GX=g_swProGX; g_ps5GY=g_swProGY; g_ps5GZ=g_swProGZ;
  1432. g_gyroAX=g_swProAX; g_gyroAY=g_swProAY; g_gyroAZ=g_swProAZ;
  1433. g_gyroGX=g_swProGX; g_gyroGY=g_swProGY; g_gyroGZ=g_swProGZ;
  1434. // Mode-switch chord (all 4 back + face): don't leak the face press to the host. g_swBtns stays intact
  1435. // so the chord detector still fires; per-mode builders mask the same bits while back-4 are held.
  1436. const uint32_t CHORD_BACK4 = TB_R4|TB_L4|TB_R5|TB_L5;
  1437. if((bb&CHORD_BACK4)==CHORD_BACK4)
  1438. ((uint8_t*)rep)[2] &= ~(uint8_t)(TB_A|TB_B|TB_X|TB_Y);
  1439. if(g_usbMode==MODE_SW_HORI || g_usbMode==MODE_SW_PRO || g_usbMode==MODE_PS5 || g_usbMode==MODE_HIDGYRO){
  1440. // streamed from loop(); nothing to send here
  1441. } else if(g_usbMode==MODE_XBOX){ // XBOX: standard gamepad + right-pad mouse (2nd interface)
  1442. rfXboxGamepad(rep); rfXboxMouse(rep);
  1443. } else if(g_connSlot>=0 && g_connSlot<NSLOT){ // STEAM / LIZARD (puck interface)
  1444. bool steamAlive = g_steamAliveMs && (millis()-g_steamAliveMs < LIZARD_WD_MS);
  1445. if(g_usbMode==MODE_LIZARD || (g_autoLizard && !steamAlive)){
  1446. rfLizard(rep, &hid[g_connSlot], &hid[g_connSlot], 0x40, 0x41);
  1447. } else {
  1448. uint8_t blen=tlen-1; if(blen>45)blen=45; // body after the 0x45 id byte
  1449. // forward the puck's raw pad coords untouched — Steam does its own interpolation/smoothing.
  1450. // forward only FRESH reports (the real puck dedupes -> Steam gets a clean unique stream; sending
  1451. // stale repeats makes Steam's velocity/smoothing stair-step). g_fwdNewOnly toggles for A/B.
  1452. if((fresh || !g_fwdNewOnly) && g_slot[g_connSlot].used && hid[g_connSlot].ready())
  1453. hid[g_connSlot].sendReport(0x45, rep+1, blen); // Steam/SDL: input report -> "connected"
  1454. }
  1455. }
  1456. lastRep=rep; lastTlen=tlen;
  1457. }
  1458. idx+=tlen+2;
  1459. }
  1460. // mode-switch chord (back4 + face): A=always Steam; B/X/Y=configurable (g_chordBtn[]). Debounced.
  1461. { static uint8_t chWant=0xFF, chCnt=0;
  1462. const uint32_t BACK4=TB_R4|TB_L4|TB_R5|TB_L5; uint8_t want=0xFF;
  1463. if((g_swBtns&BACK4)==BACK4){
  1464. if(g_swBtns&TB_A) want=MODE_STEAM;
  1465. else if(g_swBtns&TB_B) want=g_chordBtn[0];
  1466. else if(g_swBtns&TB_X) want=g_chordBtn[1];
  1467. else if(g_swBtns&TB_Y) want=g_chordBtn[2];
  1468. }
  1469. if(want!=0xFF && want==chWant){ if(++chCnt>=12 && want!=g_usbMode && modeValid(want) && !USBDevice.suspended()){ saveMode(want); delay(40); NVIC_SystemReset(); } }
  1470. else { chWant=want; chCnt=(want!=0xFF)?1:0; }
  1471. }
  1472. // compact stream for rf_controller_ui.py — NON-BLOCKING: skip if CDC TX is backed up (a blocking
  1473. // Serial.print stalls the RF+USB loop -> jaggy input). One line/frame using the last record.
  1474. if(lastRep && !g_connVerbose && Serial.availableForWrite()>110 && millis()-g_lastStream>=4){
  1475. g_lastStream=millis(); Serial.print("I45 ");
  1476. for(uint8_t i=0;i<lastTlen;i++)Serial.printf("%02X",lastRep[i]); Serial.println();
  1477. }
  1478. }
  1479. if(g_connVerbose){
  1480. Serial.printf("%s CRX#%lu txtype%02X ch%u len%u: ",isF1?"<<<F1":(rtype==0xF3?" F3":" rx"),
  1481. (unsigned long)g_connRx,payload[0],ch,rxlen);
  1482. for(uint8_t i=0;i<(rxlen+2<=66?rxlen+2:66);i++)Serial.printf("%02X",rfrx[i]); Serial.println();
  1483. }
  1484. } else rxlen=0;
  1485. } else { g_stNoRx++; g_qosBad++; } // RX window expired with no packet at all -> turnaround/timing or controller silent
  1486. NRF_RADIO->TASKS_DISABLE=1; RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
  1487. return rxlen;
  1488. }
  1489. // Drive the connected-mode sequence one step per call (from loop() while g_connOn). Camps on g_rfCh (the
  1490. // host-frame channel the controller connected on); the E1 beacon (cmd 'H') runs in parallel as keepalive.
  1491. static void rfConnStep(){
  1492. g_connSlot=-1; for(int s=0;s<NSLOT;s++) if(g_slot[s].used){g_connSlot=s;break;}
  1493. if(g_connSlot<0) return; // need a bonded slot (session established by host frame)
  1494. uint8_t ch=g_sessCh;
  1495. if(g_connSt==0){ // announce HOST AWAKE: E7 00 00, a few times
  1496. uint8_t p[3]={0xE7,0x00,g_e7b}; rfConnTx(ch,0x01,p,3);
  1497. if(++g_connStep>=4){ g_connSt=1; g_connStep=0; Serial.println("# CONN: awake announced -> polling GET report 0x45"); }
  1498. } else { // poll loop: E3 + GET report 0x45 every poll; re-assert awake periodically
  1499. if((uint32_t)(micros()-g_lastPollUs) < g_pollUs) return; // CONTROLLED CADENCE: poll ~every g_pollUs,
  1500. g_lastPollUs=micros(); // not as-fast-as-possible (over-polling starves replies)
  1501. if((g_connPoll & 0x1F)==0){ uint8_t pa[3]={0xE7,0x00,g_e7b}; rfConnTx(ch,0x01,pa,3); } // re-assert awake/version
  1502. rfConnQueueHapticRelay();
  1503. rfConnFlushRelay(ch);
  1504. {
  1505. uint8_t p[5]={0xE3,0x02,0x01,0x45,g_getParam}; // E3 + TLV [len=02][subtype=01 GET][id=0x45][param]
  1506. uint8_t s1 = (g_e3mode==1) ? (uint8_t)((((g_e3pid++)&3)<<1)|1) // cycle PID (S1 1,3,5,7), NO_ACK=1
  1507. : (g_e3mode==2) ? (uint8_t)(((g_e3pid++)&3)<<1) // cycle PID (S1 0,2,4,6), NO_ACK=0
  1508. : 0x07; // fixed (current default; matches captured puck poll)
  1509. uint8_t rx=rfConnTx(ch,s1,p,5);
  1510. if(rx) g_chF1[0]++;
  1511. }
  1512. g_connPoll++;
  1513. }
  1514. }
  1515. // SESSION SNIFFER (Phase 2b capture): phase 0 RX the real puck's host frame on "ibex"/ch2 (CRC16
  1516. // config) to learn the random session base/prefix/channel; phase 1 RX that session address on the
  1517. // hop channels {hostch,2,80} to capture the live connected exchange (0xE3 poll + 0xF1 input reply).
  1518. static bool g_sniff=false; static uint8_t g_sniffPh=0; static uint32_t g_sniffN=0;
  1519. static uint8_t g_sbase[4]={0,0,0,0}, g_sprefix=0; static uint8_t g_schan[3]={78,2,80}; static uint8_t g_schi=0;
  1520. static unsigned long g_sniffHop=0;
  1521. static uint8_t g_sniffPark=0; // phase-1: 0=park on learned primary g_schan[0]; else park on this literal channel ('g<n>')
  1522. static void rfSniffStart(){
  1523. uint8_t ch = (g_sniffPh==0)?2:(g_sniffPark?g_sniffPark:g_schan[0]); // PARK (no hop): primary carries the traffic
  1524. const uint8_t* base = (g_sniffPh==0)?g_rfBase:g_sbase;
  1525. uint8_t pfx = (g_sniffPh==0)?g_rfPrefix:g_sprefix;
  1526. rfConfig(ch); // Ble_2Mbit, PCNF0=0x30008, ENDIAN big, BALEN4, CRC16 0x11021
  1527. uint8_t b[4]; for(int i=0;i<4;i++) b[i]=rfBitrev8(base[i]);
  1528. NRF_RADIO->BASE0=((uint32_t)b[0]<<24)|((uint32_t)b[1]<<16)|((uint32_t)b[2]<<8)|b[3];
  1529. NRF_RADIO->PREFIX0=rfBitrev8(pfx); NRF_RADIO->TXADDRESS=0; NRF_RADIO->RXADDRESSES=1;
  1530. NRF_RADIO->PACKETPTR=(uint32_t)rfrx; memset(rfrx,0,4);
  1531. NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk|RADIO_SHORTS_END_START_Msk;
  1532. NRF_RADIO->EVENTS_END=0; NRF_RADIO->TASKS_RXEN=1;
  1533. }
  1534. static void rfSniffPoll(){
  1535. if(!g_sniff) return;
  1536. if(NRF_RADIO->EVENTS_END){ NRF_RADIO->EVENTS_END=0;
  1537. bool crcok=NRF_RADIO->CRCSTATUS&1; uint8_t len=rfrx[0];
  1538. if(crcok && len && len<40){
  1539. g_sniffN++;
  1540. if(g_sniffPh==0 && rfrx[2]==0xE1){ // host frame -> learn session params + DUMP FULL (app-data may ride here)
  1541. memcpy(g_sbase,rfrx+15,4); g_sprefix=rfrx[19]; g_schan[0]=rfrx[11];
  1542. Serial.printf("# HOSTFRAME ch=%u sbase=%02X%02X%02X%02X prefix=%02X len%u FULL: ",
  1543. rfrx[11],g_sbase[0],g_sbase[1],g_sbase[2],g_sbase[3],g_sprefix,len);
  1544. { uint8_t dn=(len+2<64)?len+2:64; for(uint8_t i=0;i<dn;i++)Serial.printf("%02X",rfrx[i]); } Serial.println();
  1545. g_sniffPh=1; g_schi=0; rfSniffStart();
  1546. } else if(g_sniffPh==1){
  1547. uint8_t ty=rfrx[2]; // 0xF1=input, 0x12/0xE1/0xE4=puck host-frame w/ app-data
  1548. const char* tag = (ty==0xF1)?"<<<INPUT":(ty==0x12||ty==0xE1||ty==0xE4)?">>>HOSTFR":" pkt";
  1549. Serial.printf("%s SNIFF#%lu ch%u len%u: ",tag,(unsigned long)g_sniffN,g_schan[g_schi],len);
  1550. uint8_t dn=(len+2<64)?len+2:64; // FULL frame (app-data TLVs sit near the end)
  1551. for(uint8_t i=0;i<dn;i++)Serial.printf("%02X",rfrx[i]); Serial.println();
  1552. }
  1553. }
  1554. }
  1555. }
  1556. // SCAN-THEN-RESPOND (dongle role): listen on ch2/91A2A793 for the controller's advertisement,
  1557. // and the instant one arrives, TX the host frame (its ack). Matches IBEX 'scan-before-initiating'.
  1558. static uint8_t g_hostTx[48]; static int g_hostLen=0; static bool g_rfRespond=false; static uint32_t g_advCount=0;
  1559. static void buildHostTx(){
  1560. int slot=-1; for(int i=0;i<NSLOT;i++) if(g_slot[i].used){slot=i;break;}
  1561. memset(g_hostTx,0,sizeof g_hostTx); g_hostLen=0; if(slot<0) return;
  1562. // S0LEN=1 RAM layout: [0]=S0=0x12, [1]=LENGTH, raw RAM offsets validated by controller.
  1563. memset(g_hostTx,0,sizeof g_hostTx);
  1564. g_hostTx[0]=0x12; g_hostTx[1]=0x20; g_hostTx[3]=1; g_hostTx[5]=0xE1;
  1565. memcpy(g_hostTx+6,g_slot[slot].rec,4); memcpy(g_hostTx+10,g_slot[slot].rec+4,4);
  1566. g_hostTx[0xe]=2; memcpy(g_hostTx+0x12,g_rfBase,4); g_hostTx[0x16]=g_rfPrefix;
  1567. g_hostLen=0x22;
  1568. }
  1569. static void rfRespondStart(){
  1570. buildHostTx();
  1571. rfConfig(2); rfSetAddr(g_rfBase,g_rfPrefix); // RX ch2 / 91A2A793
  1572. NRF_RADIO->PACKETPTR=(uint32_t)rfrx;
  1573. NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk|RADIO_SHORTS_END_START_Msk;
  1574. NRF_RADIO->EVENTS_END=0; NRF_RADIO->TASKS_RXEN=1; g_rfRespond=true;
  1575. Serial.printf("# RESPOND mode: RX ch2/91A2A793, reply host frame (len%d)\n", g_hostLen);
  1576. }
  1577. static void rfRespondPoll(){
  1578. if(!g_rfRespond) return;
  1579. if(NRF_RADIO->EVENTS_END){
  1580. NRF_RADIO->EVENTS_END=0; uint8_t len=rfrx[0];
  1581. if(len && len<64){
  1582. g_advCount++;
  1583. // reply: TX the host frame immediately (ack)
  1584. NRF_RADIO->TASKS_DISABLE=1; RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
  1585. NRF_RADIO->PACKETPTR=(uint32_t)g_hostTx;
  1586. NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk|RADIO_SHORTS_END_DISABLE_Msk;
  1587. NRF_RADIO->EVENTS_DISABLED=0; NRF_RADIO->TASKS_TXEN=1;
  1588. RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
  1589. Serial.printf("ADV#%lu len%u: ",(unsigned long)g_advCount,len);
  1590. for(uint8_t i=0;i<=len&&i<32;i++)Serial.printf("%02X",rfrx[i]); Serial.println();
  1591. // re-arm RX
  1592. NRF_RADIO->PACKETPTR=(uint32_t)rfrx;
  1593. NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk|RADIO_SHORTS_END_START_Msk;
  1594. NRF_RADIO->EVENTS_END=0; NRF_RADIO->TASKS_RXEN=1;
  1595. }
  1596. }
  1597. }
  1598. static void rfBeaconOnce(){
  1599. uint8_t pl[48]; memset(pl,0,sizeof pl);
  1600. pl[0]=0x01; pl[1]=g_seq++; pl[5]=0xE2; // dongle beacon (FUN_00027ed8 format)
  1601. rfConfig(g_rfCh); rfSetAddr(g_rfBase,g_rfPrefix);
  1602. if (g_s1incl) NRF_RADIO->PCNF0 |= (1u<<RADIO_PCNF0_S1INCL_Pos);
  1603. // RAM: [LENGTH][ (S1 if S1INCL) ][payload]
  1604. rftx[0]=0; rftx[1]=g_plen; memcpy(rftx+2,pl,g_plen);
  1605. NRF_RADIO->PACKETPTR=(uint32_t)rftx;
  1606. NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk|RADIO_SHORTS_END_DISABLE_Msk;
  1607. NRF_RADIO->EVENTS_DISABLED=0; NRF_RADIO->TASKS_TXEN=1;
  1608. RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
  1609. // RX window for the controller's response
  1610. NRF_RADIO->PACKETPTR=(uint32_t)rfrx; rfrx[0]=0;
  1611. NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk;
  1612. NRF_RADIO->EVENTS_END=0; NRF_RADIO->TASKS_RXEN=1;
  1613. uint32_t t0=micros(); while(!NRF_RADIO->EVENTS_END && (micros()-t0)<500){}
  1614. if (NRF_RADIO->EVENTS_END){ NRF_RADIO->EVENTS_END=0;
  1615. bool crcok=NRF_RADIO->CRCSTATUS&1; uint8_t len=rfrx[0];
  1616. if (len && len<64){ g_rfRxCount++;
  1617. Serial.printf("RESP#%lu crc%d len%u: ",(unsigned long)g_rfRxCount,crcok,len);
  1618. for(uint8_t i=0;i<=len&&i<40;i++)Serial.printf("%02X ",rfrx[i]); Serial.println();
  1619. }
  1620. }
  1621. NRF_RADIO->TASKS_DISABLE=1; RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
  1622. }
  1623. // ===================== WebUSB config channel =====================
  1624. // Browser panel (copycat_config.html) <-> firmware, binary framed.
  1625. // host->dev: 0x01 GET -> reply status blob
  1626. // 0x02 <field> <value> SET one byte field (1=mDiv 2=mFric 3=(removed, ex-padSmooth) 4=abSwap 5..8=back[0..3] 9=(removed, poll rate fixed)
  1627. // 10=e7b 11=relayOp 12=relaySub 13=testHaptic 14=fwdNewOnly 15=qos 16=persistMode
  1628. // 17..19=chordBtn B/X/Y mode assignments)
  1629. // 0x03 <mode> switch USB mode (0..6): persist + reboot
  1630. // dev->host: 0xA5 <len> <payload> payload = [ver=1][mode][mDiv][mFric][rsvd=0][abSwap]
  1631. // [back0..3][connSlot(0xFF=none)][linkUp][f1ps_lo][f1ps_hi][pollU100][newps_lo][newps_hi][e7b][relayOp][relaySub]
  1632. #define WB_PAYLEN 26
  1633. static void webusbSendBlob(){
  1634. if(!usb_web.connected()) return;
  1635. bool up = (g_connSlot>=0 && (millis()-g_connReplyMs) < 300);
  1636. uint8_t p[2+WB_PAYLEN];
  1637. p[0]=0xA5; p[1]=WB_PAYLEN;
  1638. p[2]=2; // protocol version (2 = chordBtn[3] in blob)
  1639. p[3]=g_usbMode; p[4]=(uint8_t)g_mDiv; p[5]=(uint8_t)g_mFric; p[6]=0 /*rsvd: ex-padSmooth*/; p[7]=g_abSwap;
  1640. p[8]=g_back[0]; p[9]=g_back[1]; p[10]=g_back[2]; p[11]=g_back[3];
  1641. p[12]=(g_connSlot>=0)?(uint8_t)g_connSlot:0xFF;
  1642. p[13]=up?1:0;
  1643. p[14]=(uint8_t)g_f1ps; p[15]=(uint8_t)(g_f1ps>>8);
  1644. p[16]=(uint8_t)(g_pollUs/100);
  1645. p[17]=(uint8_t)g_newps; p[18]=(uint8_t)(g_newps>>8);
  1646. p[19]=g_e7b;
  1647. p[20]=g_relayOp; p[21]=g_relaySub; p[22]=g_fwdNewOnly; p[23]=g_qos; p[24]=g_persistMode?1:0;
  1648. p[25]=g_chordBtn[0]; p[26]=g_chordBtn[1]; p[27]=g_chordBtn[2];
  1649. usb_web.write(p,sizeof p); usb_web.flush();
  1650. }
  1651. static void webusbPoll(){
  1652. static uint8_t buf[16]; static uint8_t n=0;
  1653. while(usb_web.available()){
  1654. int c=usb_web.read(); if(c<0) break;
  1655. if(n<sizeof buf) buf[n++]=(uint8_t)c;
  1656. // process complete commands from the front of buf
  1657. for(;;){
  1658. if(n==0) break;
  1659. uint8_t op=buf[0]; uint8_t need = (op==0x02)?3 : (op==0x03)?2 : (op==0x01)?1 : 1;
  1660. if(op!=0x01 && op!=0x02 && op!=0x03){ // resync: drop one byte
  1661. memmove(buf,buf+1,--n); continue;
  1662. }
  1663. if(n<need) break; // wait for more bytes
  1664. if(op==0x01){ webusbSendBlob(); }
  1665. else if(op==0x02){
  1666. uint8_t f=buf[1], v=buf[2];
  1667. bool persist=true; // every settable field persists (poll rate is no longer settable)
  1668. switch(f){
  1669. case 1: g_mDiv = v<4?4:v; break;
  1670. case 2: g_mFric = v>99?99:v; break;
  1671. // case 3 (padSmooth) removed — Steam-mode pad coords are forwarded raw; Steam does its own smoothing.
  1672. case 4: g_abSwap = v?1:0; break;
  1673. case 5: case 6: case 7: case 8: g_back[f-5]=v; break;
  1674. // case 9 (pollU100) removed — poll rate is fixed at POLL_US_DEFAULT and no longer configurable.
  1675. case 10: g_e7b = v?1:0; break; // E7 protocol-version B-byte (experimental v1 fast)
  1676. case 11: g_relayOp = v; break; // haptic-relay opcode (buzz hunt)
  1677. case 12: g_relaySub = v; break; // haptic-relay sub-type (buzz hunt)
  1678. case 13: g_testHaptic = v?v:40; break; // inject v test haptics (0->40)
  1679. case 14: g_fwdNewOnly = v?1:0; break; // Steam: forward only fresh reports (dedupe)
  1680. case 15: g_qos = v?1:0; g_hopIdx=0; g_qosBad=0; g_qosCheckMs=millis(); break; // QoS adaptive channel hopping
  1681. case 16: g_persistMode = v?true:false; break; // persist last mode across reboots (else always boot Steam)
  1682. case 17: case 18: case 19: if(modeValid(v)) g_chordBtn[f-17]=v; break; // back4+B/X/Y mode assignments
  1683. }
  1684. if(persist) saveCfg();
  1685. webusbSendBlob();
  1686. } else if(op==0x03){
  1687. uint8_t m=buf[1]; if(modeValid(m) && !USBDevice.suspended()){ webusbSendBlob(); usb_web.flush(); saveMode(m); delay(40); NVIC_SystemReset(); }
  1688. }
  1689. memmove(buf,buf+need,n-need); n-=need;
  1690. }
  1691. }
  1692. }
  1693. // CDC commands: l=listen, s=stop, cN=channel, p<hex>=prefix, a<8hex>=base addr, b=bonds
  1694. static void rfSerialPoll(){
  1695. static char line[24]; static uint8_t li=0;
  1696. while (Serial.available()){
  1697. char c=Serial.read();
  1698. if (c=='\n'||c=='\r'){ line[li]=0;
  1699. if (line[0]=='l') rfListenStart();
  1700. 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); }
  1701. else if (line[0]=='L'){ g_plen=strtol(line+1,0,16); Serial.printf("# plen=%02X\n",g_plen); }
  1702. else if (line[0]=='1'){ g_s1incl=!g_s1incl; Serial.printf("# s1incl=%u\n",g_s1incl); }
  1703. else if (line[0]=='R'){ g_rfCh=atoi(line+1); g_rfRaw=true; g_rfListen=false; g_rfBeacon=false; rfRawStart(g_rfCh); Serial.printf("# raw cap ch%u\n",g_rfCh); }
  1704. else if (line[0]=='Z'){ g_rfRaw=true; g_rfSweep=true; g_rfListen=false; g_rfBeacon=false; g_rfCh=2; rfRawStart(g_rfCh); Serial.println("# raw SWEEP"); }
  1705. else if (line[0]=='H'){ g_rfHost=!g_rfHost; g_rfBeacon=false; g_rfListen=false; g_rfRaw=false; g_rfSweep=false; g_rfCh=2;
  1706. Serial.printf("# HOSTFRAME beacon %s base %02X%02X%02X%02X (slot0 uuids %02X%02X%02X%02X/%02X%02X%02X%02X)\n",
  1707. g_rfHost?"ON":"off",g_rfBase[0],g_rfBase[1],g_rfBase[2],g_rfBase[3],
  1708. g_slot[0].rec[0],g_slot[0].rec[1],g_slot[0].rec[2],g_slot[0].rec[3],
  1709. g_slot[0].rec[4],g_slot[0].rec[5],g_slot[0].rec[6],g_slot[0].rec[7]); }
  1710. else if (line[0]=='C'){ g_rfBeacon=g_rfListen=g_rfRaw=g_rfSweep=g_rfHost=false; rfRespondStart(); }
  1711. else if (line[0]=='s'){ g_rfListen=false; g_rfBeacon=false; g_rfRaw=false; g_rfSweep=false; g_rfHost=false; g_rfRespond=false; NRF_RADIO->TASKS_DISABLE=1; Serial.println("# RF off"); }
  1712. else if (line[0]=='x'){ uint8_t m=strtoul(line+1,0,10); if(modeValid(m)){
  1713. if(USBDevice.suspended()){ Serial.println("# mode change blocked: host suspended"); }
  1714. else { Serial.printf("# switch mode %u (reboot)\n",m); delay(20); saveMode(m); delay(40); NVIC_SystemReset(); }
  1715. } } // switch USB mode: 0=steam 1=xbox 2=hori 3=lizard 4=swpro 5=ps5 6=hidgyro
  1716. else if (line[0]=='c'){ g_rfCh=atoi(line+1); Serial.printf("# ch=%u\n",g_rfCh); if(g_rfListen) rfListenStart(); }
  1717. else if (line[0]=='p'){ g_rfPrefix=strtol(line+1,0,16); Serial.printf("# prefix=%02X\n",g_rfPrefix); if(g_rfListen) rfListenStart(); }
  1718. else if (line[0]=='i'){ g_crcinit=strtoul(line+1,0,16); Serial.printf("# crcinit=%06lX\n",(unsigned long)g_crcinit); }
  1719. else if (line[0]=='w'){ g_whiteiv=atoi(line+1); Serial.printf("# whiteiv=%u\n",g_whiteiv); }
  1720. else if (line[0]=='0'){ g_pcnf0=strtoul(line+1,0,16); Serial.printf("# pcnf0=%08lX\n",(unsigned long)g_pcnf0); }
  1721. else if (line[0]=='2'){ g_pcnf1=strtoul(line+1,0,16); Serial.printf("# pcnf1=%08lX\n",(unsigned long)g_pcnf1); }
  1722. else if (line[0]=='M'){ g_mode=strtoul(line+1,0,16); Serial.printf("# mode=%u (3=Ble1M,4=Ble2M,0/2=Nrf)\n",g_mode); }
  1723. else if (line[0]=='I'){ g_crcinit=strtoul(line+1,0,16); Serial.printf("# crcinit=%08lX\n",(unsigned long)g_crcinit); }
  1724. else if (line[0]=='P'){ g_crcpoly=strtoul(line+1,0,16); Serial.printf("# crcpoly=%lX\n",(unsigned long)g_crcpoly); }
  1725. else if (line[0]=='N'){ g_crccnf=strtoul(line+1,0,16); Serial.printf("# crccnf=%X\n",g_crccnf); }
  1726. else if (line[0]=='Y'){ g_rfCap=true; g_rfCapOne=true; g_rfReplay=g_rfHost=g_rfAuto=false; g_rfCh=2; rfCapStart(2); Serial.println("# capture ONE frame for replay..."); }
  1727. else if (line[0]=='y'){ g_rfReplay=!g_rfReplay; g_rfCap=g_rfHost=g_rfAuto=false; Serial.printf("# REPLAY %s (%uB)\n",g_rfReplay?"ON":"off",g_replayLen); }
  1728. else if (line[0]=='T'){ g_statlen=strtoul(line+1,0,16); g_pcnf1=0; Serial.printf("# statlen=%02X\n",g_statlen); }
  1729. else if (line[0]=='k'){ g_connOn=!g_connOn; if(g_connOn){ g_connSt=0; g_connStep=0; g_connPoll=0; g_connF1=0; } Serial.printf("# CONN mode %s: E7-awake[00 00] -> E3+GET-report-0x45 poll on ch%u (param=%02X). F1 seen=%lu\n",g_connOn?"ON":"off",g_rfCh,g_getParam,(unsigned long)g_connF1); }
  1730. else if (line[0]=='q'){ g_getParam = g_getParam ? 0x00 : 0x2D; Serial.printf("# GET-0x45 param=%02X\n",g_getParam); }
  1731. else if (line[0]=='v'){ g_connVerbose=!g_connVerbose; Serial.printf("# conn verbose %s (F1 seen=%lu)\n",g_connVerbose?"ON":"off",(unsigned long)g_connF1); }
  1732. else if (line[0]=='C'){ g_sessCh=strtoul(line+1,0,10); Serial.printf("# session channel=%u (re-pair/reconnect to apply)\n",g_sessCh); }
  1733. else if (line[0]=='E'){ g_mDiv=strtoul(line+1,0,10); if(g_mDiv<4)g_mDiv=4; saveCfg(); Serial.printf("# xbox-mouse sensitivity divisor=%d (lower=faster)\n",g_mDiv); }
  1734. else if (line[0]=='F'){ g_mFric=strtoul(line+1,0,10); if(g_mFric>99)g_mFric=99; saveCfg(); Serial.printf("# xbox-mouse friction=%d%% (higher=more glide/momentum)\n",g_mFric); }
  1735. else if (line[0]=='W'){ g_abSwap=!g_abSwap; saveCfg(); Serial.printf("# A/B + X/Y swap %s (Nintendo layout)\n",g_abSwap?"ON":"off"); }
  1736. else if (line[0]=='K'){ int i=line[1]-'0'; uint8_t code=strtoul(line+2,0,10); if(i>=0&&i<4){ g_back[i]=code; saveCfg(); Serial.printf("# back[%d] (%s) -> code %u [0=none 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back 10=Start 11=Guide]\n",i,(const char*[]){"L4","R4","L5","R5"}[i],code); } else Serial.println("# usage: K<0-3> <code> (0=L4 1=R4 2=L5 3=R5)"); }
  1737. else if (line[0]=='J'){ char* sp=0; uint8_t id=strtoul(line+1,&sp,0); uint16_t val=sp?strtoul(sp,0,0):0; // inject SET-SETTINGS to controller: report 0x87 [id][val u16 LE]
  1738. g_relayBuf[0]=0x87; g_relayBuf[1]=3; g_relayBuf[2]=id; g_relayBuf[3]=val&0xFF; g_relayBuf[4]=val>>8; g_relayN=5; g_relayPend=true;
  1739. Serial.printf("# queued SET-SETTINGS id=0x%02X val=%u (relay 0x87) — watch new=/s\n",id,val); }
  1740. else if (line[0]=='G'){ g_sniff=!g_sniff; g_sniffPh=0; g_rfHost=g_connOn=g_rfCap=g_rfAuto=g_rfReplay=false; if(g_sniff) rfSniffStart(); Serial.printf("# SESSION SNIFF %s (phase0: catch host frame on ibex/ch2; phase1 PARK ch=%s)\n",g_sniff?"ON":"off",g_sniffPark?"override":"primary"); }
  1741. else if (line[0]=='g'){ g_sniffPark=strtoul(line+1,0,10); if(g_sniffPh==1) rfSniffStart(); Serial.printf("# sniff park channel=%u (0=learned primary)\n",g_sniffPark); }
  1742. else if (line[0]=='V'){ g_e7b=strtoul(line+1,0,10); Serial.printf("# E7 protocol-version B-byte=%u (0=awake/slow, 1=try v1-fast; watch new=/s; revert if input stops)\n",g_e7b); }
  1743. else if (line[0]=='O'){ g_relayOp=strtoul(line+1,0,16); Serial.printf("# relay opcode=%02X\n",g_relayOp); }
  1744. else if (line[0]=='o'){ g_relaySub=strtoul(line+1,0,16); Serial.printf("# relay sub-type=%02X\n",g_relaySub); }
  1745. else if (line[0]=='h'){ uint8_t nc=strtoul(line+1,0,10); Serial.printf("# HOP %u->%u (advertise on current ch, then poll new). Watch F1=/s\n",g_sessCh,nc); rfHopTo(nc); }
  1746. else if (line[0]=='z'){ g_qos=!g_qos; g_hopIdx=0; g_qosBad=0; g_qosCheckMs=millis(); Serial.printf("# QoS adaptive channel hopping %s (candidates 18,46,76,22,68)\n",g_qos?"ON":"off"); }
  1747. else if (line[0]=='r'){ g_rxWin=strtoul(line+1,0,10); if(g_rxWin<150)g_rxWin=150; Serial.printf("# poll RX-window=%lu us (poll rate caps ~%lu/s)\n",(unsigned long)g_rxWin,(unsigned long)(1000000/g_rxWin)); }
  1748. else if (line[0]=='e'){ g_e3mode=strtoul(line+1,0,10); Serial.printf("# E3 poll PID mode=%u (0=fixed07, 1=cyclePID+noack1, 2=cyclePID+noack0) - watch new=/s\n",g_e3mode); }
  1749. else if (line[0]=='t'){ uint8_t n=line[1]?strtoul(line+1,0,10):40; // inject n test haptics (output 0x82 [01 01 F7]) over the relay
  1750. g_testHaptic=n; Serial.printf("# test-haptic burst x%u queued (relay 0x82 01 01 F7 via op=%02X sub=%02X)\n",n,g_relayOp,g_relaySub); }
  1751. else if (line[0]=='H'){ // dump the captured OUTPUT-report history (oldest->newest) for the haptic-whine hunt
  1752. const uint8_t N=sizeof g_hapLog/sizeof g_hapLog[0]; uint32_t now=millis();
  1753. Serial.printf("# --- haptic/OUTPUT history (now=%lu, connSlot=%d) ---\n",(unsigned long)now,g_connSlot);
  1754. for(uint8_t i=0;i<N;i++){ HapLog &e=g_hapLog[(g_hapHead+i)%N]; if(!e.ms && !e.rid) continue;
  1755. Serial.printf("# -%lums if%u rid=%02X n=%u:",(unsigned long)(now-e.ms),e.slot,e.rid,e.n);
  1756. for(uint8_t j=0;j<12 && j<e.n;j++) Serial.printf(" %02X",e.b[j]); Serial.println(); }
  1757. Serial.println("# --- end ---"); }
  1758. else if (line[0]=='j'){ g_connType=strtoul(line+1,0,16); Serial.printf("# connType=%02X\n",g_connType); }
  1759. else if (line[0]=='Q'){ g_connLen=strtoul(line+1,0,16); Serial.printf("# connLen=%02X\n",g_connLen); }
  1760. else if (line[0]=='A'){ g_balen=strtoul(line+1,0,16); g_pcnf1=0; Serial.printf("# balen=%u\n",g_balen); }
  1761. else if (line[0]=='a'){ uint32_t v=strtoul(line+1,0,16); g_rfBase[0]=v>>24;g_rfBase[1]=v>>16;g_rfBase[2]=v>>8;g_rfBase[3]=v; Serial.printf("# base=%08lX\n",(unsigned long)v); if(g_rfListen) rfListenStart(); }
  1762. else if (line[0]=='b'){ for(int i=0;i<NSLOT;i++) if(g_slot[i].used){Serial.printf("slot%d ",i); for(int j=0;j<24;j++)Serial.printf("%02X",g_slot[i].rec[j]); Serial.println();} }
  1763. else if (line[0]=='X'){ g_rfCap=!g_rfCap; g_rfHost=g_rfAuto=g_rfBeacon=g_rfListen=g_rfRaw=g_rfSweep=g_rfRespond=false; g_rfCh=2; g_capV=0; if(g_rfCap) rfCapStart(g_rfCh); Serial.printf("# CAPTURE(listen) %s ch%u on \"ibex\", cycling 1M/2M x BALEN3/4 (use cN to set ch)\n", g_rfCap?"ON":"off",g_rfCh); }
  1764. else if (line[0]=='S'){ g_rfAuto=!g_rfAuto; g_rfHost=g_rfAuto; g_rfBeacon=g_rfListen=g_rfRaw=g_rfSweep=false; g_rfCh=2; g_cfgIdx=0; if(g_rfAuto) applyCfg(0); Serial.printf("# AUTOSWEEP %s (%u cfgs x300ms)\n", g_rfAuto?"ON":"off",(unsigned)(sizeof SWEEP/sizeof SWEEP[0])); }
  1765. li=0;
  1766. } else if (li<sizeof line-1) line[li++]=c;
  1767. }
  1768. }
  1769. void setup() {
  1770. genSerial();
  1771. InternalFS.begin();
  1772. loadCfg(); g_xbox = !modeIsPuck(g_usbMode); // load persisted config + decide USB presentation BEFORE registering interfaces
  1773. // ---- USB descriptor rebuild ----
  1774. // Puck (Steam/Lizard): KEEP the boot CDC composite (do NOT clearConfiguration) and append 4 HID + WebUSB — this
  1775. // is the layout that worked with Steam running + Chrome WebUSB at the same time. Clearing puck to "save space"
  1776. // broke that coexistence. Clean controller modes still clear the CDC composite and rebuild bare.
  1777. const bool puckMode = modeIsPuck(g_usbMode);
  1778. USBDevice.detach(); delay(30);
  1779. if (puckMode) {
  1780. USBDevice.setConfigurationBuffer(g_usbCfgDesc, sizeof g_usbCfgDesc); // headroom over the default 256 B cap
  1781. } else {
  1782. USBDevice.clearConfiguration();
  1783. USBDevice.setConfigurationBuffer(g_usbCfgDesc, sizeof g_usbCfgDesc);
  1784. }
  1785. // Distinct USB serial PER MODE (must be set AFTER clearConfiguration, which nulls it). Hosts cache USB
  1786. // identity by VID:PID:serial; reusing one serial under a changing VID:PID can make a host refuse the new
  1787. // identity. Steam keeps the exact unit serial (its pairing identity); the others get a 1-char suffix.
  1788. static const char MODE_SUFFIX[] = {'X','N','L','P','S','G'}; // modes 1..6
  1789. if (modeIsPuck(g_usbMode)) { USBDevice.setSerialDescriptor(g_unit); }
  1790. else { snprintf(g_usbSerial, sizeof g_usbSerial, "%s%c", g_unit, MODE_SUFFIX[g_usbMode-1]); USBDevice.setSerialDescriptor(g_usbSerial); }
  1791. if (g_usbMode == MODE_XBOX) { // XBOX presentation: XInput vendor interface + HID boot mouse
  1792. USBDevice.setID(0x045E, 0x028E); // device-level 045E:028E match -> Windows xusb / SDL / Linux xpad all bind it
  1793. USBDevice.setDeviceVersion(0x0114);
  1794. USBDevice.setManufacturerDescriptor("Microsoft");
  1795. USBDevice.setProductDescriptor("Controller");
  1796. g_xinput.setStringDescriptor("Controller"); g_xinput.begin(); // XInput vendor interface (FF/5D/01) -> MI_00
  1797. g_mouse.setStringDescriptor("OpenPuck Mouse");
  1798. g_mouse.setBootProtocol(HID_ITF_PROTOCOL_MOUSE);
  1799. g_mouse.setReportDescriptor(MOUSE_HID_DESC, sizeof MOUSE_HID_DESC); g_mouse.setPollInterval(1); g_mouse.begin();
  1800. } else if (g_usbMode == MODE_SW_HORI) { // SWITCH HORIPAD (console whitelist; composite w/ WebUSB won't pair on-console)
  1801. USBDevice.setID(0x0F0D, 0x0092);
  1802. USBDevice.setDeviceVersion(0x0200);
  1803. USBDevice.setManufacturerDescriptor("HORI CO.,LTD.");
  1804. USBDevice.setProductDescriptor("POKKEN CONTROLLER");
  1805. g_switch.enableOutEndpoint(true);
  1806. g_switch.setReportDescriptor(SWITCH_HID_DESC, sizeof SWITCH_HID_DESC);
  1807. g_switch.setPollInterval(8);
  1808. g_switch.begin();
  1809. } else if (g_usbMode == MODE_SW_PRO) { // Switch Pro Controller + gyro (057E:2009, report 0x30)
  1810. USBDevice.setID(0x057E, 0x2009);
  1811. USBDevice.setDeviceVersion(0x0210);
  1812. USBDevice.setManufacturerDescriptor("Nintendo Co., Ltd.");
  1813. USBDevice.setProductDescriptor("Pro Controller");
  1814. jcBuildStickCal();
  1815. g_swPro.enableOutEndpoint(true);
  1816. g_swPro.setReportCallback(NULL, jcSet); // answer the Nintendo USB handshake + subcommands (else Steam never binds it)
  1817. g_swPro.setReportDescriptor(SWPRO_HID_DESC, sizeof SWPRO_HID_DESC);
  1818. g_swPro.setPollInterval(4);
  1819. g_swPro.begin();
  1820. } else if (g_usbMode == MODE_PS5) { // DualSense + gyro + trackpad
  1821. USBDevice.setID(0x054C, 0x0CE6);
  1822. USBDevice.setDeviceVersion(0x0100);
  1823. USBDevice.setManufacturerDescriptor("Sony Interactive Entertainment");
  1824. USBDevice.setProductDescriptor("DualSense Wireless Controller");
  1825. g_ps5.enableOutEndpoint(true);
  1826. g_ps5.setReportDescriptor(PS5_HID_DESC, sizeof PS5_HID_DESC);
  1827. g_ps5.setPollInterval(4);
  1828. g_ps5.begin();
  1829. } else if (g_usbMode == MODE_HIDGYRO) { // DS4-layout generic HID + gyro
  1830. USBDevice.setID(0x054C, 0x05C4);
  1831. USBDevice.setDeviceVersion(0x0100);
  1832. USBDevice.setManufacturerDescriptor("Sony Computer Entertainment");
  1833. USBDevice.setProductDescriptor("Wireless Controller");
  1834. g_hidGyro.enableOutEndpoint(true);
  1835. g_hidGyro.setReportDescriptor(GYRO_HID_DESC, sizeof GYRO_HID_DESC);
  1836. g_hidGyro.setPollInterval(4);
  1837. g_hidGyro.begin();
  1838. } else { // STEAM / LIZARD: 4 puck slot interfaces (+ WebUSB)
  1839. USBDevice.setID(0x28DE, 0x1304);
  1840. // Distinct bcdDevice so Windows keys a FRESH usbflags entry (cache is VID:PID:bcdDevice) and actually
  1841. // runs MS OS 2.0 / WinUSB binding for the WebUSB vendor interface — instead of reusing a stale "no WinUSB"
  1842. // entry tied to the real Steam Controller (28DE:1304), which has no WebUSB interface. Without this, the
  1843. // device enumerates and Steam binds it fine, but Chrome can't open WebUSB on Windows. Every other mode
  1844. // already sets a version (and has its own VID:PID), which is why only puck mode was affected.
  1845. USBDevice.setDeviceVersion(0x0210);
  1846. USBDevice.setManufacturerDescriptor("Valve Software");
  1847. USBDevice.setProductDescriptor("Steam Controller Puck");
  1848. for (int i = 0; i < NSLOT; i++) {
  1849. hid[i].setReportDescriptor(PUCK_HID_DESC, sizeof PUCK_HID_DESC);
  1850. hid[i].setReportCallback(GETCB[i], SETCB[i]);
  1851. hid[i].setPollInterval(1); // 1ms USB poll (was default 10ms = 100/s cap -> choppy)
  1852. hid[i].begin();
  1853. }
  1854. }
  1855. // WebUSB config panel — every mode. Puck: historical CDC+HID+vendor stack (Steam + Chrome can share it).
  1856. // Clean modes: bare gamepad + WebUSB (begin() sets bcdUSB 0x0210 + BOS).
  1857. usb_web.begin();
  1858. // Enable USB Remote Wakeup (bit 5) so the host lets us signal wake-from-sleep. Bit 7 is always required.
  1859. // The host OS enables device wakeup via SET_FEATURE(DEVICE_REMOTE_WAKEUP); we assert the capability here.
  1860. USBDevice.setConfigurationAttribute(0x80 | 0x20); // bmAttributes: required(0x80) | remote_wakeup(0x20)
  1861. USBDevice.attach(); // re-connect with the final descriptor (host re-reads it fresh -> deterministic enumeration)
  1862. Serial.begin(115200);
  1863. for (int i=0; i<300 && !USBDevice.mounted(); i++) delay(10); // wait up to 3s for USB mount, but NEVER hang:
  1864. if (USBDevice.suspended()) USBDevice.remoteWakeup(); // wake host if bus was sleeping when we (re-)attached (e.g. after a mode change)
  1865. loadBonds();
  1866. g_rumble=0; g_relayPend=false; g_haptic82On=false;
  1867. g_hapticBlockUntil = millis() + HAPTIC_RECONNECT_BLOCK_MS; // boot: block stale Steam 0x82 until link stable
  1868. g_hapticStop = HAPTIC_STOP_BURST;
  1869. static const char* MODE_NAME[]={"STEAM(puck)","XBOX(xinput+mouse)","SWITCH(horipad)","LIZARD(puck kb/mouse)","SWITCH(pro+gyro)","PS5(dualsense)","HIDGYRO(ds4+motion)"};
  1870. Serial.printf("# copycat up: unit=%s board=%s, mode=%s\n", g_unit, g_board, MODE_NAME[g_usbMode<=MODE_MAX?g_usbMode:0]);
  1871. // Hardware watchdog: if loop() ever stops feeding it (a wedged radio busy-wait, a HardFault spin, a blocked
  1872. // CDC write) the WDT resets the nRF52 after ~8s — re-enumerating USB and re-initialising RF on its own, so a
  1873. // hang no longer needs a physical replug. RUN keeps it counting in sleep; PAUSE freezes it under a debugger.
  1874. // 8s is far above any legitimate stall (flash writes are tens of ms) and leaves the bootloader room on DFU.
  1875. NRF_WDT->CONFIG = (WDT_CONFIG_HALT_Pause << WDT_CONFIG_HALT_Pos) | (WDT_CONFIG_SLEEP_Run << WDT_CONFIG_SLEEP_Pos);
  1876. NRF_WDT->CRV = 8UL * 32768UL - 1; // timeout in 32.768 kHz ticks (~8 s)
  1877. NRF_WDT->RREN = WDT_RREN_RR0_Msk; // arm reload register 0
  1878. NRF_WDT->TASKS_START = 1;
  1879. }
  1880. void loop() {
  1881. NRF_WDT->RR[0] = WDT_RR_RR_Reload; // feed the watchdog each loop; if we ever stop, the ~8s WDT auto-resets us
  1882. if (g_dirty) { g_dirty = false; saveBonds(); }
  1883. webusbPoll();
  1884. swStream();
  1885. swProTask();
  1886. ps5Stream();
  1887. hidGyroStream();
  1888. rfSerialPoll();
  1889. rfPoll();
  1890. rfRespondPoll();
  1891. rfCapPoll();
  1892. if (g_rfCap && millis() - g_lastHop >= 90) { // sweep DATAWHITEIV (0..127) x BALEN{3,4} on ch2, CRC-validate
  1893. g_lastHop = millis(); g_capV = (uint8_t)(g_capV+1); rfCapStart(g_rfCh);
  1894. }
  1895. if (g_rfBeacon && millis() - g_lastBeacon >= 5) { g_lastBeacon = millis(); rfBeaconOnce(); }
  1896. if (g_rfSweep && millis() - g_lastHop >= 60) { g_lastHop = millis(); g_rfCh += 2; if (g_rfCh > 80) g_rfCh = 2; rfRawStart(g_rfCh); }
  1897. if (g_rfAuto && millis() - g_lastCfg >= 500) { // advance to next candidate config every 500ms
  1898. g_lastCfg = millis(); g_cfgIdx = (g_cfgIdx + 1) % (sizeof SWEEP/sizeof SWEEP[0]); applyCfg(g_cfgIdx);
  1899. }
  1900. if (g_rfReplay) { rfReplayOnce(); } // bit-perfect replay of a captured real-puck frame on ch2
  1901. // Host-frame beacon: sent continuously, INCLUDING while connected. The controller uses the periodic E1
  1902. // (the real puck's per-hop-cycle announce) to stay synced and keep answering polls at full rate;
  1903. // suppressing it drops the reply rate from ~210/s to ~38/s. Paused only during the post-disconnect
  1904. // cooldown so a controller that's powering off isn't immediately re-woken/reconnected.
  1905. if (g_rfHost && millis()-g_connCooldown > 2500) {
  1906. bool connNow = (g_connSlot>=0 && millis()-g_connReplyMs < 300);
  1907. // session keepalive on the clean channel: every loop while connecting (fast), every 25ms once connected
  1908. // (every-loop beaconing also hammers ch52 and steals reply slots from the poll)
  1909. if (millis()-g_lastSessBeacon >= (connNow ? 25u : 0u)) { g_lastSessBeacon=millis(); g_rfCh=g_sessCh; for (int s=0;s<NSLOT;s++) rfHostFrameOnce(s); }
  1910. // discovery beacon on ch2 (where a searching controller looks): every loop when down, occasionally when up
  1911. if (millis()-g_lastDisc >= (connNow ? 200u : 0u)) { g_lastDisc=millis(); g_rfCh=2; for (int s=0;s<NSLOT;s++) rfHostFrameOnce(s); }
  1912. }
  1913. if (g_connOn && millis()-g_connCooldown > 2500) { rfConnStep(); } // connected-mode: poll controller, read input
  1914. { static bool wasRfConn=false; // remote wakeup on new RF controller connection
  1915. bool nowRfConn=(g_connSlot>=0 && millis()-g_connReplyMs<300);
  1916. if(nowRfConn && !wasRfConn && USBDevice.suspended()) USBDevice.remoteWakeup();
  1917. wasRfConn=nowRfConn;
  1918. }
  1919. { static bool wasHapticLinkUp=false;
  1920. bool up=hapticLinkUp();
  1921. if(up && !wasHapticLinkUp){
  1922. g_hapticBlockUntil = millis() + HAPTIC_RECONNECT_BLOCK_MS;
  1923. g_haptic82On = false;
  1924. hapticCancelPendingOn();
  1925. g_hapticStop = HAPTIC_STOP_BURST;
  1926. }
  1927. wasHapticLinkUp=up;
  1928. }
  1929. // Legacy XInput rumble -> relay the haptic (0x82 [01 01 gain]) to the controller while the host commands it,
  1930. // re-queued ~40/s like Steam's glide haptic so it sustains. Not active in the current HID Xbox presentation.
  1931. if (g_usbMode==MODE_XBOX && g_rumble && millis()-g_rumbleMs > RUMBLE_STUCK_MS) g_rumble=0;
  1932. if (g_usbMode==MODE_XBOX && g_rumble && !g_relayPend && g_connSlot>=0) {
  1933. static unsigned long lastRumble=0;
  1934. if (millis()-lastRumble>=25) { lastRumble=millis();
  1935. uint8_t gain = g_rumble<0x30?0x30:g_rumble; // floor so low rumble is still feelable
  1936. g_relayBuf[0]=0x82; g_relayBuf[1]=3; g_relayBuf[2]=0x01; g_relayBuf[3]=0x01; g_relayBuf[4]=gain; g_relayPend=true;
  1937. }
  1938. }
  1939. // Steam-mode haptic guard: do not synthesize haptic packets. The reconnect guard above only drops stale
  1940. // host 0x82 traffic until the link is stable; active-session haptics are relayed verbatim.
  1941. if (!g_xbox && g_haptic82On && millis()-g_haptic82Ms > HAPTIC_QUIET_MS) { g_haptic82On=false; g_hapticStop=HAPTIC_STOP_BURST; } // host went silent mid-haptic (its stop was lost / never sent) -> actively stop the latched buzz
  1942. // QoS: if the current channel is degrading (crcfail+noRx), hop to the next clean candidate (conservative).
  1943. if (g_qos && g_connSlot>=0 && millis()-g_qosCheckMs>=600) {
  1944. uint16_t bad=g_qosBad; g_qosBad=0; g_qosCheckMs=millis();
  1945. if (bad>20 && millis()-g_qosLastHopMs>2000) {
  1946. for(int k=0;k<(int)sizeof g_hopCand;k++){ g_hopIdx=(g_hopIdx+1)%(sizeof g_hopCand); if(g_hopCand[g_hopIdx]!=g_sessCh) break; }
  1947. if(Serial.availableForWrite()>60) Serial.printf("# QoS: ch%u bad=%u -> hop ch%u\n",g_sessCh,bad,g_hopCand[g_hopIdx]);
  1948. rfHopTo(g_hopCand[g_hopIdx]); g_qosLastHopMs=millis();
  1949. }
  1950. }
  1951. // USB connection presentation (like the real dongle): report 0x79 = connection state (01=disc, 02=conn),
  1952. // edge-triggered, + periodic 0x7B status. Live-captured: this is what Steam reads to mark the controller
  1953. // connected. Without it Steam shows disconnected even though 0x45 input is streaming.
  1954. if(!g_xbox){ static bool usbConn=false; static unsigned long last79=0, last7B=0; // puck-only USB presentation
  1955. bool conn = (g_connSlot>=0 && millis()-g_connReplyMs < 300);
  1956. if (g_connSlot>=0 && g_connSlot<NSLOT && hid[g_connSlot].ready()){
  1957. // 0x79 connection state: on edge AND repeated every 750ms while connected (Steam can miss the edge,
  1958. // and the 0x45 stream can starve the endpoint at the transition instant -> resend until it sticks).
  1959. if (conn!=usbConn || (conn && millis()-last79>=750)){
  1960. uint8_t st=conn?0x02:0x01; hid[g_connSlot].sendReport(0x79,&st,1); usbConn=conn; last79=millis();
  1961. } else if (conn && millis()-last7B>=2000){
  1962. static const uint8_t s7b[12]={0xF7,0x01,0x89,0x00,0x00,0x00,0x03,0x00,0xDD,0x00,0x3A,0x02};
  1963. hid[g_connSlot].sendReport(0x7B,s7b,12); last7B=millis();
  1964. }
  1965. } else if(!conn) usbConn=false;
  1966. }
  1967. if (g_connOn && millis()-g_stMs>=1000){ g_f1ps=g_stF1; g_newps=g_stNew; if(Serial.availableForWrite()>70) Serial.printf("# stat polls=%lu/s F1=%lu/s new=%lu/s F3=%lu/s(v%d) e7b=%u crcfail=%lu noRx=%lu slot=%d\n",(unsigned long)g_stPoll,(unsigned long)g_stF1,(unsigned long)g_stNew,(unsigned long)g_stF3,(int8_t)g_connF3v,g_e7b,(unsigned long)g_stCrc,(unsigned long)g_stNoRx,g_connSlot); g_stPoll=0; g_stF1=0; g_stNew=0; g_stF3=0; g_stCrc=0; g_stNoRx=0; g_chF1[0]=g_chF1[1]=g_chF1[2]=0; g_stMs=millis(); }
  1968. rfSniffPoll();
  1969. // phase-1 PARKS (no hop) on the primary/override channel — re-arm RX only if it dropped (handled in rfSniffPoll via SHORTS END_START)
  1970. if (g_rfRespond && millis() - g_lastHop >= 25) { // scan the 3 BLE adv channels w/ matched whitening
  1971. g_lastHop = millis();
  1972. static const uint8_t advf[3]={2,26,80}, advw[3]={37,38,39}; static uint8_t ai=0; ai=(ai+1)%3;
  1973. g_rfCh=advf[ai]; g_whiteiv=advw[ai];
  1974. rfConfig(g_rfCh); rfSetAddr(g_rfBase,g_rfPrefix); NRF_RADIO->PACKETPTR=(uint32_t)rfrx;
  1975. NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk|RADIO_SHORTS_END_START_Msk; NRF_RADIO->EVENTS_END=0; NRF_RADIO->TASKS_RXEN=1;
  1976. }
  1977. }