puck_hid.cpp 51 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052
  1. #include "puck_hid.h"
  2. #include "bonds.h"
  3. #include "config.h"
  4. #include "identity.h"
  5. #include "haptics.h"
  6. #include "fault_diag.h"
  7. #include "rf_link.h"
  8. #include "triton.h"
  9. #include "mode_lizard.h"
  10. #include "wake_hid.h"
  11. #include "usb_tx.h"
  12. #include <Adafruit_TinyUSB.h>
  13. #include <Arduino.h>
  14. #include <string.h>
  15. uint8_t g_fwdNewOnly = 1;
  16. // Content dedup for the Steam input forward: only forward a 0x45/0x42 report when its body EXCLUDING the
  17. // counter byte differs from the last one forwarded on that slot. The controller's counter (rep[1]) FREE-RUNS
  18. // -- it advances on every reply even with static input -- so the seq-based g_fwdNewOnly can't suppress true
  19. // repeats, and a poll rate above the controller's ~250 Hz sample rate forwards identical inputs with only the
  20. // counter bumped (host tester reads >250/s for a <=250 Hz controller). Content dedup is what the real puck
  21. // effectively does. On by default; console "CD" toggles for A/B.
  22. uint8_t g_fwdContentDedup = 1;
  23. SteamPuckController g_steamPuck;
  24. // millis() a power-off (0x9F) was last relayed to each slot; 0 = never. See puckNotePowerOff() / task().
  25. static volatile unsigned long g_powerOffMs[NSLOT] = { 0 };
  26. // Present a powered-off slot as DISCONNECTED to Steam for this long, to ride out the controller's post-off
  27. // F1 tail (measured ~<=1s of dying replies after the "off!" command) without bouncing the connection state.
  28. #define POWEROFF_HOLD_MS 2000u
  29. void puckNotePowerOff(uint8_t slot)
  30. {
  31. if (slot >=
  32. NSLOT) { // 0xFF broadcast ("all off": host suspend, panel/test button)
  33. for (int s = 0; s < NSLOT; s++)
  34. if (g_slot[s].used)
  35. g_powerOffMs[s] = millis();
  36. } else {
  37. g_powerOffMs[slot] = millis();
  38. }
  39. }
  40. // True while `slot` is inside its post-power-off hold window (present it disconnected, ignore dying replies).
  41. static inline bool slotPoweringOff(int slot)
  42. {
  43. return slot >= 0 && slot < NSLOT && g_powerOffMs[slot] &&
  44. (millis() - g_powerOffMs[slot] < POWEROFF_HOLD_MS);
  45. }
  46. // Cloned puck HID report descriptor: mouse(0x40)+keyboard(0x41)+vendor(FF00) with the 63-byte FEATURE
  47. // command reports on report id 1/2. Each of the 4 interfaces uses this.
  48. static const uint8_t PUCK_HID_DESC[] = {
  49. 0x05, 0x01, 0x09, 0x02, 0xA1, 0x01, 0x85, 0x40, 0x09, 0x01, 0xA1, 0x00,
  50. 0x05, 0x09, 0x19, 0x01, 0x29, 0x02, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01,
  51. 0x95, 0x02, 0x81, 0x02, 0x75, 0x06, 0x95, 0x01, 0x81, 0x01, 0x05, 0x01,
  52. 0x09, 0x30, 0x09, 0x31, 0x15, 0x81, 0x25, 0x7F, 0x75, 0x08, 0x95, 0x02,
  53. 0x81, 0x06, 0x95, 0x01, 0x09, 0x38, 0x81, 0x06, 0x05, 0x0C, 0x0A, 0x38,
  54. 0x02, 0x95, 0x01, 0x81, 0x06, 0xC0, 0xC0, 0x05, 0x01, 0x09, 0x06, 0xA1,
  55. 0x01, 0x85, 0x41, 0x05, 0x07, 0x19, 0xE0, 0x29, 0xE7, 0x15, 0x00, 0x25,
  56. 0x01, 0x75, 0x01, 0x95, 0x08, 0x81, 0x02, 0x81, 0x01, 0x19, 0x00, 0x29,
  57. 0x65, 0x15, 0x00, 0x25, 0x65, 0x75, 0x08, 0x95, 0x06, 0x81, 0x00, 0xC0,
  58. 0x06, 0x00, 0xFF, 0x09, 0x01, 0xA1, 0x01, 0x85, 0x42, 0x15, 0x00, 0x26,
  59. 0xFF, 0x00, 0x75, 0x08, 0x95, 0x35, 0x09, 0x42, 0x81, 0x02, 0x85, 0x44,
  60. 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x05, 0x09, 0x44, 0x81,
  61. 0x02, 0x85, 0x79, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x01,
  62. 0x09, 0x79, 0x81, 0x02, 0x85, 0x43, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
  63. 0x08, 0x95, 0x0E, 0x09, 0x43, 0x81, 0x02, 0x85, 0x7B, 0x15, 0x00, 0x26,
  64. 0xFF, 0x00, 0x75, 0x08, 0x95, 0x0C, 0x09, 0x7B, 0x81, 0x02, 0x85, 0x45,
  65. 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x2D, 0x09, 0x45, 0x81,
  66. 0x02, 0x85, 0x80, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x09,
  67. 0x09, 0x80, 0x91, 0x02, 0x85, 0x81, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
  68. 0x08, 0x95, 0x07, 0x09, 0x81, 0x91, 0x02, 0x85, 0x82, 0x15, 0x00, 0x26,
  69. 0xFF, 0x00, 0x75, 0x08, 0x95, 0x03, 0x09, 0x82, 0x91, 0x02, 0x85, 0x83,
  70. 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x09, 0x09, 0x83, 0x91,
  71. 0x02, 0x85, 0x84, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x08,
  72. 0x09, 0x84, 0x91, 0x02, 0x85, 0x85, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
  73. 0x08, 0x95, 0x03, 0x09, 0x85, 0x91, 0x02, 0x85, 0x86, 0x15, 0x00, 0x26,
  74. 0xFF, 0x00, 0x75, 0x08, 0x95, 0x03, 0x09, 0x86, 0x91, 0x02, 0x85, 0x87,
  75. 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F, 0x09, 0x87, 0x91,
  76. 0x02, 0x85, 0x89, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F,
  77. 0x09, 0x89, 0x91, 0x02, 0x85, 0x88, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
  78. 0x08, 0x95, 0x3F, 0x09, 0x88, 0x91, 0x02, 0x85, 0x01, 0x95, 0x3F, 0x09,
  79. 0x01, 0xB1, 0x02, 0x85, 0x02, 0x95, 0x3F, 0x09, 0x01, 0xB1, 0x02, 0xC0
  80. };
  81. // Pure MODE_LIZARD only: same puck/lizard descriptor plus a tiny Consumer Control report for media keys.
  82. // MODE_STEAM must keep PUCK_HID_DESC verbatim so Steam's controller/haptics path sees the normal puck shape.
  83. static const uint8_t PUCK_LIZARD_HID_DESC[] = {
  84. 0x05, 0x01, 0x09, 0x02, 0xA1, 0x01, 0x85, 0x40, 0x09, 0x01, 0xA1, 0x00,
  85. 0x05, 0x09, 0x19, 0x01, 0x29, 0x02, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01,
  86. 0x95, 0x02, 0x81, 0x02, 0x75, 0x06, 0x95, 0x01, 0x81, 0x01, 0x05, 0x01,
  87. 0x09, 0x30, 0x09, 0x31, 0x15, 0x81, 0x25, 0x7F, 0x75, 0x08, 0x95, 0x02,
  88. 0x81, 0x06, 0x95, 0x01, 0x09, 0x38, 0x81, 0x06, 0x05, 0x0C, 0x0A, 0x38,
  89. 0x02, 0x95, 0x01, 0x81, 0x06, 0xC0, 0xC0, 0x05, 0x01, 0x09, 0x06, 0xA1,
  90. 0x01, 0x85, 0x41, 0x05, 0x07, 0x19, 0xE0, 0x29, 0xE7, 0x15, 0x00, 0x25,
  91. 0x01, 0x75, 0x01, 0x95, 0x08, 0x81, 0x02, 0x81, 0x01, 0x19, 0x00, 0x29,
  92. 0x65, 0x15, 0x00, 0x25, 0x65, 0x75, 0x08, 0x95, 0x06, 0x81, 0x00, 0xC0,
  93. 0x06, 0x00, 0xFF, 0x09, 0x01, 0xA1, 0x01, 0x85, 0x42, 0x15, 0x00, 0x26,
  94. 0xFF, 0x00, 0x75, 0x08, 0x95, 0x35, 0x09, 0x42, 0x81, 0x02, 0x85, 0x44,
  95. 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x05, 0x09, 0x44, 0x81,
  96. 0x02, 0x85, 0x79, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x01,
  97. 0x09, 0x79, 0x81, 0x02, 0x85, 0x43, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
  98. 0x08, 0x95, 0x0E, 0x09, 0x43, 0x81, 0x02, 0x85, 0x7B, 0x15, 0x00, 0x26,
  99. 0xFF, 0x00, 0x75, 0x08, 0x95, 0x0C, 0x09, 0x7B, 0x81, 0x02, 0x85, 0x45,
  100. 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x2D, 0x09, 0x45, 0x81,
  101. 0x02, 0x85, 0x80, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x09,
  102. 0x09, 0x80, 0x91, 0x02, 0x85, 0x81, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
  103. 0x08, 0x95, 0x07, 0x09, 0x81, 0x91, 0x02, 0x85, 0x82, 0x15, 0x00, 0x26,
  104. 0xFF, 0x00, 0x75, 0x08, 0x95, 0x03, 0x09, 0x82, 0x91, 0x02, 0x85, 0x83,
  105. 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x09, 0x09, 0x83, 0x91,
  106. 0x02, 0x85, 0x84, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x08,
  107. 0x09, 0x84, 0x91, 0x02, 0x85, 0x85, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
  108. 0x08, 0x95, 0x03, 0x09, 0x85, 0x91, 0x02, 0x85, 0x86, 0x15, 0x00, 0x26,
  109. 0xFF, 0x00, 0x75, 0x08, 0x95, 0x03, 0x09, 0x86, 0x91, 0x02, 0x85, 0x87,
  110. 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F, 0x09, 0x87, 0x91,
  111. 0x02, 0x85, 0x89, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F,
  112. 0x09, 0x89, 0x91, 0x02, 0x85, 0x88, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
  113. 0x08, 0x95, 0x3F, 0x09, 0x88, 0x91, 0x02, 0x85, 0x01, 0x95, 0x3F, 0x09,
  114. 0x01, 0xB1, 0x02, 0x85, 0x02, 0x95, 0x3F, 0x09, 0x01, 0xB1, 0x02, 0xC0,
  115. 0x05, 0x0C, 0x09, 0x01, 0xA1, 0x01, 0x85, 0x03, 0x15, 0x00, 0x25, 0x01,
  116. 0x09, 0xE9, 0x09, 0xEA, 0x75, 0x01, 0x95, 0x02, 0x81, 0x02, 0x75, 0x06,
  117. 0x95, 0x01, 0x81, 0x01, 0xC0
  118. };
  119. static Adafruit_USBD_HID hid[NSLOT];
  120. // Drop Steam's relayed 0x81 CLEAR_DIGITAL_MAPPINGS in Steam mode (console "S81" toggles; on by default). It's
  121. // the confirmed amp-clicker in Steam's per-connect config and OpenPuck doesn't need it (own input translation
  122. // + id9=0 keepalive). Reversible from the console if it regresses.
  123. bool g_drop81 = true;
  124. // Per-slot shadow of the controller's SET_SETTINGS_VALUES array (id-indexed u16, ids 0..0x52). Steam writes
  125. // it with 0x87 and READS IT BACK with 0x89 to verify its config "took"; on the real puck the controller
  126. // answers 0x89 from this array. OpenPuck can't relay-and-return over the NO-ACK RF link, so it maintains the
  127. // shadow here and answers 0x89 from it -- WITHOUT this, Steam's verify never matches and it re-clears (0x81)
  128. // + re-writes (0x87) forever = the connect config storm that buzzes the amp and floods the usbd task.
  129. static uint16_t g_setShadow[NSLOT][0x53];
  130. // ---- feature-command capture (diagnostic) ---------------------------------------------------------------
  131. // Log the USB feature command channel (Steam's SET/GET) to serial to see the connect handshake -- WITHOUT a
  132. // Serial.printf on the fragile 800B usbd task (that path can blow the stack -> LOCKUP; it's why production
  133. // avoids printf here). handleSet/handleGet (usbd task) push a compact record under PRIMASK; puckCmdLogDrain()
  134. // prints them from loop() context. When g_cmdCapture is on, the high-rate I45 input stream is also suppressed
  135. // (rf_link) so the command sequence is readable. Toggle from the console with "FC".
  136. bool g_cmdCapture = true;
  137. struct FCmdRec {
  138. uint8_t dir; // 0 = SET (host->puck write), 1 = GET (host read)
  139. uint8_t iface; // HID interface index = bond slot the command hit
  140. uint8_t rid, cmd, len, b[10];
  141. };
  142. static FCmdRec g_fc[32];
  143. static volatile uint8_t g_fcHead, g_fcTail;
  144. static void fcPush(uint8_t dir, int iface, uint8_t rid, uint8_t cmd,
  145. uint8_t len, const uint8_t *b, uint8_t n)
  146. {
  147. if (!g_cmdCapture)
  148. return;
  149. uint32_t pm = __get_PRIMASK();
  150. __disable_irq();
  151. uint8_t h = g_fcHead, nx = (uint8_t)((h + 1) & 31);
  152. if (nx !=
  153. g_fcTail) { // drop-newest when full (loop drains fast); never block the usbd task
  154. g_fc[h].dir = dir;
  155. g_fc[h].iface = (uint8_t)iface;
  156. g_fc[h].rid = rid;
  157. g_fc[h].cmd = cmd;
  158. g_fc[h].len = len;
  159. for (uint8_t i = 0; i < 10; i++)
  160. g_fc[h].b[i] = (i < n) ? b[i] : 0;
  161. g_fcHead = nx;
  162. }
  163. __set_PRIMASK(pm);
  164. }
  165. void puckCmdLogDrain(void)
  166. {
  167. // boosted: runs at Steam's feature-storm rate and CDC flush enters the same TinyUSB DMA claim window
  168. // as HID sends (the issue-72 livelock; see usb_tx.cpp)
  169. usbTxBoost();
  170. while (g_fcHead != g_fcTail) {
  171. if (Serial.availableForWrite() < 70)
  172. break; // don't stall loop on CDC backpressure; resume next iteration
  173. FCmdRec r = g_fc[g_fcTail];
  174. g_fcTail = (uint8_t)((g_fcTail + 1) & 31);
  175. Serial.printf("# FC %s if=%u rid=%u cmd=%02X len=%u:",
  176. r.dir ? "GET" : "SET", r.iface, r.rid, r.cmd,
  177. r.len);
  178. for (uint8_t i = 0; i < 10; i++)
  179. Serial.printf(" %02X", r.b[i]);
  180. Serial.println();
  181. }
  182. usbTxUnboost();
  183. }
  184. // ===================== seamless LIZARD decision =====================
  185. // Steam, while running, re-sends settings report 0x87 (lizard-off) every ~3s as a heartbeat (captured on HW),
  186. // and ANY OUTPUT report likewise stamps g_steamAliveMs. When the heartbeat stops we fall back to lizard, so the
  187. // controller drives desktop keyboard+mouse whenever Steam isn't running. MODE_LIZARD forces lizard always.
  188. // millis of last Steam OUTPUT/settings write; 0 at boot => lizard until Steam appears
  189. static unsigned long g_steamAliveMs = 0;
  190. // Fall back to lizard this long after Steam's ~3s settings heartbeat stops. Keep >2x the cadence: the haptic
  191. // relay is gated by !lizardActive(), so a shorter window lets a jittered/delayed heartbeat flip lizard
  192. // mid-session while Steam is still running -> a haptic arriving in that window gets gated out (dropped). 7s
  193. // tolerates one missed beat. Do not shorten without a lizard trigger independent of the heartbeat timeout.
  194. #define LIZARD_WD_MS 7000u
  195. static bool g_autoLizard =
  196. true; // master switch; false => Steam mode always forwards 0x45
  197. // Single source of truth, shared by the USB input path AND the haptic relay gate: if we ever relay a 0x82 to
  198. // the controller while presenting lizard (Steam isn't reading 0x45 back), Steam loops the same haptic -> buzz.
  199. static inline bool steamDrivingGamepad()
  200. {
  201. return g_steamAliveMs && (millis() - g_steamAliveMs < LIZARD_WD_MS);
  202. }
  203. static inline bool lizardActive()
  204. {
  205. return modeIsPuck(g_usbMode) &&
  206. (g_usbMode == MODE_LIZARD ||
  207. (g_autoLizard && !steamDrivingGamepad()));
  208. }
  209. // Public accessor for the haptic layer (haptics.cpp gates the lizard-suppression keepalive on this).
  210. bool puckLizardActive()
  211. {
  212. return lizardActive();
  213. }
  214. static inline void hostStampAlive()
  215. {
  216. g_steamAliveMs = millis();
  217. }
  218. // Right after the host resumes from suspend, MUTE input forwarding briefly. Otherwise the controller's input
  219. // in that instant (a trackpad click/trigger, or residual button state from the wake gesture) gets forwarded
  220. // as a real click/keypress into the just-woken desktop. Set when task() sees the suspended->active transition.
  221. static unsigned long g_resumeMs = 0;
  222. #define POST_RESUME_MUTE_MS 1500u
  223. // How long to keep re-asserting the 0x79 DISCONNECT edge to Steam after a controller drops, so a single
  224. // lost report can't strand Steam in "connected" (see task()). Bounded to avoid forever-spamming disc.
  225. #define DISC_RESEND_MS 6000u
  226. // ===================== puck feature command channel =====================
  227. // `slot` is the interface index (interface N == bond slot N).
  228. static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
  229. uint8_t const *b, uint16_t n)
  230. {
  231. // Steam OUTPUT reports 0x80-0x89. The haptic/actuator reports (0x80-0x86) are relayed to the controller,
  232. // and ONLY when they arrive on the CONNECTED slot's interface: we have one controller but expose 4 puck
  233. // slots, and a report aimed at a DIFFERENT slot made the controller buzz at random (the slot gate below
  234. // fixes that). Must NOT clamp to 0x82-only: ping/grip/test haptics ride other report IDs (0x85/0x86). The
  235. // 63-byte settings/config reports 0x87/0x88/0x89 are NOT haptics and reach the controller via the
  236. // feature-0x01 passthrough path instead.
  237. if (type == HID_REPORT_TYPE_OUTPUT) {
  238. if (rid >= 0x80 && rid <= 0x89) {
  239. // capture ALL OUTPUT reports (even un-relayed) for the 'H' dump
  240. hapLogAdd((uint8_t)slot, rid, b, n);
  241. // ANY Steam OUTPUT report (not just the 0x87 heartbeat) means Steam is present and driving ->
  242. // leave lizard for gamepad NOW, so a haptic arriving before the first 0x87 isn't relayed while
  243. // we're still presenting lizard (-> buzz loop).
  244. hostStampAlive();
  245. }
  246. // Post-resume mute also gates haptics: while onReport45 is muted Steam reads NO 0x45 back, which is the
  247. // exact condition under which Steam loops the same haptic command (-> connect/wake buzz loop).
  248. bool muted = g_resumeMs &&
  249. millis() - g_resumeMs < POST_RESUME_MUTE_MS;
  250. // Drop the OUTPUT-report form of 0x81 too (Steam's haptic-reset action, a 7-byte report id 0x81 with
  251. // payloads 00../01..). Same amp-clicker as the feature-0x01 0x81; g_drop81 only covered the feature
  252. // path, so this OUTPUT form was still leaking through and clicking. OpenPuck doesn't need it.
  253. bool dropOut81 =
  254. (g_drop81 && g_usbMode == MODE_STEAM && rid == 0x81);
  255. if (g_hapticRelay && rid >= 0x80 && rid <= 0x86 && !dropOut81 &&
  256. n >= 1 && hapticRelaySlotOk(slot) && !lizardActive() &&
  257. !muted) {
  258. if (!haptic82Blocked(slot)) {
  259. relayEnqueue(rid, b,
  260. (uint8_t)(n > RELAY_MAXP ?
  261. RELAY_MAXP :
  262. n),
  263. (uint8_t)slot);
  264. // Track on/off from what was actually RELAYED (= the controller's believed state): a
  265. // BLOCKED stop must leave "on" set (controller may be latched -> reconnect stop-burst),
  266. // a blocked ON must not set it (nothing reached the controller -> no spurious clicks).
  267. if (rid == 0x82)
  268. haptic82HostReport(b, n);
  269. }
  270. }
  271. #if OPK_LOG
  272. // Per-report Serial echo: gated to the diagnostic build only. handleSet runs on the TinyUSB device
  273. // task, whose FreeRTOS stack is just 800 bytes; a Serial.printf there costs 200-500B and, stacked
  274. // under TinyUSB's control-transfer frames + a preempting USB ISR, can blow the stack -> the exact
  275. // SP-corruption-during-exception-entry that shows up as RR_LOCKUP. Production keeps this path
  276. // printf-free; the same data is already in the OPK_LOG capture ring (hapLogAdd) for the WebUSB panel.
  277. if (Serial.availableForWrite() > 80) {
  278. Serial.printf("# OUT if%d rid=%02X n=%u:", slot, rid,
  279. n);
  280. for (uint16_t i = 0; i < n && i < 14; i++)
  281. Serial.printf(" %02X", b[i]);
  282. Serial.println();
  283. }
  284. #endif
  285. return;
  286. }
  287. if (type != HID_REPORT_TYPE_FEATURE || n < 1)
  288. return;
  289. Slot &S = g_slot[slot];
  290. uint8_t cmd = b[0], len = (n > 1) ? b[1] : 0;
  291. const uint8_t *pl = b + 2;
  292. uint16_t pln = (n >= 2) ? n - 2 : 0;
  293. // Capture EVERY feature SET (the whole cmd channel: 0x83 attr, 0xAE strings, 0xB4 conn, 0xA2/A3 bond, the
  294. // feature-1 relay, AND any host battery query) to the WebUSB ring so the panel's capture view shows it.
  295. hapLogAdd((uint8_t)slot, cmd, b, n);
  296. // ...and to the serial feature-command capture (diagnostic; drained in loop, see puckCmdLogDrain).
  297. fcPush(0, slot, rid, cmd, len, pl, pln > 10 ? 10 : (uint8_t)pln);
  298. // ...and to the flight recorder (survives a watchdog reset). This runs ON the fragile usbd task, so the
  299. // command flood that (theory) overflows it lands in the post-mortem trail rather than being lost with USB.
  300. faultDiagTrace(FR_SET, (uint16_t)((rid << 8) | cmd));
  301. // settings/haptic/LED report (incl. 0x87 lizard-off heartbeat, SDL Triton lizard-disable)
  302. if (cmd >= 0x80 && cmd <= 0x89)
  303. hostStampAlive();
  304. // Controller power-off: Steam's "turn off controller" is feature-0x01 frame 9F 04 6F 66 66 21 ("off!"),
  305. // confirmed from a real puck capture. The feature-0x01 relay below forwards it once; hapticSendShutdown
  306. // bursts it for NO-ACK reliability. Slot-targeted: the command arrived on THIS controller's interface,
  307. // so only this controller powers off (broadcasting killed all connected controllers at once).
  308. if (rid == 1 && cmd == 0x9F)
  309. hapticSendShutdown((uint8_t)slot);
  310. // report 0x01 = raw passthrough -> queue for RF relay to the controller
  311. if (rid == 1 && n >= 2) {
  312. // (feature-1 commands -- haptics, LED, 0x87 settings, 0x9F power-off -- are captured by the
  313. // general feature-SET hapLogAdd above.)
  314. bool haptic82 = (cmd == 0x82 && len <= pln);
  315. bool muted = g_resumeMs &&
  316. millis() - g_resumeMs < POST_RESUME_MUTE_MS;
  317. // Steam-mode: DROP the relayed 0x81 CLEAR_DIGITAL_MAPPINGS (g_drop81, console "S81"). It EXECUTES on
  318. // the controller (rid<0x87, legacy framing) and each one re-arms the haptic amp (non-idempotent, ibex
  319. // func_0x0001bbf0) = the connect click/buzz; Steam sends ~13 in its per-connect config. OpenPuck does
  320. // its OWN input translation and holds mappings cleared via the id9=0 keepalive, so it does NOT need the
  321. // controller's mapping engine -> Steam's 0x81 is pure downside here. The manual "Clear stuck buzz"
  322. // (hapticReinit) sends its own 0x81 via relayEnqueue directly, so that cure path is unaffected.
  323. bool drop =
  324. (g_drop81 && g_usbMode == MODE_STEAM && cmd == 0x81);
  325. // Do NOT relay commands OpenPuck answers LOCALLY (identity/bond/settings READS). Relaying them to the
  326. // controller is pointless (their reply can't come back over the NO-ACK RF link -- we answer from the
  327. // handleSet/handleGet switch), and 0x83 GET_ATTRIBUTES in particular is < 0x87 so it EXECUTES on the
  328. // controller -- captured as the remaining periodic CLICK, fired every time Steam re-polls identity
  329. // (0x83/0xAE on all 4 interfaces). Only actuator/config commands (0x80-0x82/0x84-0x88 haptics+config,
  330. // 0x9F power) should reach the controller.
  331. bool localAnswer = (cmd == 0x83 || cmd == 0x89 || cmd == 0xAE ||
  332. cmd == 0xA2 || cmd == 0xA3 || cmd == 0xAD ||
  333. cmd == 0xB4 || cmd == 0xED || cmd == 0xA4);
  334. // never push haptics while presenting lizard (Steam isn't reading 0x45 -> would buzz-loop).
  335. // HR toggle (g_hapticRelay): when off, suppress the actuator/haptic range (0x80-0x86) -- the
  336. // trackpad texture-feedback stream Steam pushes while dragging -- to isolate its cost on drag
  337. // smoothness. Config (0x87/0x88) and power-off (0x9F) still relay so nothing else regresses.
  338. bool hapticCmd = (cmd >= 0x80 && cmd <= 0x86);
  339. bool relayOk = hapticRelaySlotOk(slot) && !drop &&
  340. !localAnswer &&
  341. !(haptic82 && (lizardActive() || muted)) &&
  342. !(hapticCmd && !g_hapticRelay);
  343. if (relayOk && (!haptic82 || !haptic82Blocked(slot))) {
  344. // Relay the DECLARED length (up to the 60B RF frame ceiling), not a truncation: Steam's
  345. // multi-register 0x87 settings blocks (LED brightness) and calibration writes exceed the old
  346. // 18B cap, and the chopped frames were why those settings never landed on the controller.
  347. uint8_t rl = (len <= pln) ? len : (uint8_t)pln;
  348. #if OPK_LOG
  349. if (len > RELAY_MAXP && Serial.availableForWrite() > 60)
  350. Serial.printf(
  351. "# RELAY TRUNC cmd=%02X len=%u>%u\n",
  352. cmd, len, (unsigned)RELAY_MAXP);
  353. #endif
  354. relayEnqueue(cmd, pl, rl, (uint8_t)slot);
  355. // track from RELAYED frames only (see the OUTPUT path)
  356. if (haptic82)
  357. haptic82HostReport(pl, len);
  358. }
  359. }
  360. #if OPK_LOG
  361. // Diagnostic-build only -- see the OUTPUT-path note: no Serial.printf on the 800B usbd-task stack in
  362. // production (LOCKUP mitigation). The feature SET is already captured to the OPK_LOG ring above.
  363. if (Serial.availableForWrite() > 80) {
  364. Serial.printf("# SET if%d rid=%02X cmd=%02X len=%u:", slot, rid,
  365. cmd, len);
  366. for (uint16_t i = 0; i < n && i < 14; i++)
  367. Serial.printf(" %02X", b[i]);
  368. Serial.println();
  369. }
  370. #endif
  371. memset(S.resp, 0, sizeof S.resp);
  372. S.resp_len = 0;
  373. switch (cmd) {
  374. case 0x83:
  375. S.resp[0] = 0x83;
  376. S.resp[1] = sizeof ATTR83;
  377. memcpy(S.resp + 2, ATTR83, sizeof ATTR83);
  378. // Report-id 1 = an attributes query RELAYED to the bonded CONTROLLER; it must report the CONTROLLER's
  379. // product id (0x1302), NOT the puck/dongle's (0x1304) -- else Steam sees "a controller with a dongle's
  380. // id" (HANDOFF.md) and drops to the LEGACY 0x81 CLEAR_DIGITAL_MAPPINGS init path, whose rapid re-arm
  381. // storm is the connect buzz; with the correct id Steam uses the modern quiet 0x87 id9=0 path. Report-id
  382. // 2 stays the puck (0x1304). ATTR83 stores product as u32 LE at offset 1, so only the low byte flips
  383. // (0x04 -> 0x02); everything else in the blob is identical.
  384. if (rid == 1)
  385. S.resp[2 + 1] =
  386. 0x02; // product 0x1304 -> 0x1302 (controller)
  387. S.resp_len = 63;
  388. break;
  389. case 0xAE: {
  390. uint8_t idx = pln > 0 ? pl[0] : 1;
  391. // Report-id 1 = string attributes of the bonded CONTROLLER, not the puck. Steam matches the connected
  392. // controller to its bond by SERIAL: it reads the controller's serial here (rid 1) and compares to the
  393. // 16-byte serial in the bond record it reads via 0xA3. OpenPuck was returning the PUCK's serial (g_unit,
  394. // "FXB..."), which never matches any bond's controller serial ("FXA...") -> Steam can't associate the
  395. // controller with a puck -> "paired to" list is EMPTY -> Steam treats it as unconfigured and re-runs the
  396. // 0x81/0x87 config storm every connect (the buzz). So on rid 1, answer with the CONTROLLER's serial from
  397. // this interface's bond record (rec[8..24], the same 16-byte serial 0xA3 returns). Captured: Steam
  398. // hammered this read (AE x39 on rid1) exactly because the identity never matched. rid 2 = puck (unchanged).
  399. // idx 0/1/4 = board/unit/alt serial (real controller returns the SAME serial for 0 and 4; the clone
  400. // was returning "NA" for idx 4, which failed Steam's read -> retry).
  401. S.resp[0] = 0xAE;
  402. S.resp[1] = 0x14;
  403. S.resp[2] = idx;
  404. memset(S.resp + 3, 0, 60);
  405. if (rid == 1 && slot >= 0 && slot < NSLOT &&
  406. g_slot[slot].used && (idx == 0 || idx == 1 || idx == 4)) {
  407. // THIS slot's paired-controller serial, straight from its bond record (rec[8..24], the same
  408. // 16-byte serial 0xA3 returns) -- per device, nothing hardcoded. Copied without a stack temp
  409. // (this runs on the fragile 800B usbd task; every byte off the stack helps under a Steam
  410. // re-enumeration burst).
  411. memcpy(S.resp + 3, g_slot[slot].rec + 8, 16);
  412. } else {
  413. // rid 2 = the puck's own board/unit serials (device-derived). Any other idx -> "NA".
  414. const char *s = (rid == 1) ? "NA" :
  415. (idx == 0 || idx == 4) ? g_board :
  416. (idx == 1) ? g_unit :
  417. "NA";
  418. memcpy(S.resp + 3, s, strlen(s));
  419. }
  420. S.resp_len = 63;
  421. break;
  422. }
  423. // connection/version state per slot: value 0x02 = controller connected, 0x01 = not
  424. case 0xB4:
  425. // SDL Triton polls this on init; treat like Steam contact so we forward 0x45
  426. hostStampAlive();
  427. S.resp[0] = 0xB4;
  428. S.resp[1] = 0x01;
  429. // Report disconnected during the post-power-off hold so B4 agrees with the 0x79 disconnect (they used
  430. // different windows -- 500ms here vs the 300ms conn/DOWN edge -- so right after a power-off Steam's B4
  431. // probe answered "still connected" and contradicted the disconnect we just pushed).
  432. S.resp[2] = (slot >= 0 && slot < NSLOT && !g_xbox &&
  433. g_slot[slot].used && !slotPoweringOff(slot) &&
  434. (millis() - g_connReplyMs[slot] < 500)) ?
  435. 0x02 :
  436. 0x01;
  437. S.resp_len = 63;
  438. break;
  439. case 0xAD:
  440. g_pairing = (pln > 0 && pl[0] != 0);
  441. #if OPK_LOG
  442. Serial.printf("# pairing %s\n", g_pairing ? "ON" : "off");
  443. #endif
  444. S.resp[0] = 0xAD;
  445. S.resp[1] = 0;
  446. S.resp_len = 63;
  447. break;
  448. case 0xA2: // write/clear THIS interface's slot
  449. if (len >= 24 && pln >= 24) {
  450. if (recEmpty(pl)) {
  451. S.used = false;
  452. memset(S.rec, 0, 24);
  453. } else {
  454. memcpy(S.rec, pl, 24);
  455. S.used = true;
  456. }
  457. g_dirty = true;
  458. #if OPK_LOG
  459. Serial.printf("# slot %d %s\n", slot,
  460. recEmpty(pl) ? "cleared" : "bonded");
  461. #endif
  462. }
  463. S.resp[0] = 0xA2;
  464. S.resp[1] = 0;
  465. S.resp_len = 63;
  466. break;
  467. case 0xA3: // read THIS interface's slot
  468. S.resp[0] = 0xA3;
  469. S.resp[1] = 0x18;
  470. memset(S.resp + 2, 0, 24);
  471. if (S.used)
  472. memcpy(S.resp + 2, S.rec, 24);
  473. S.resp_len = 63;
  474. break;
  475. case 0x87:
  476. // SET_SETTINGS_VALUES: payload = N x [id][val16 LE]. Shadow each into g_setShadow[slot] so the 0x89
  477. // read-back below matches what Steam wrote -- this is THE fix that stops Steam's endless config-verify
  478. // retry (the 0x81/0x87 storm that buzzes the amp). (Still relayed to the controller by the feature-1
  479. // path above; this just maintains the dongle-side shadow the real controller would return on 0x89.)
  480. // ACK with [0x87][0], not the default payload echo (a clean success reply, like the real dongle).
  481. if (slot >= 0 && slot < NSLOT)
  482. for (uint16_t i = 0; i + 2 < pln; i += 3) {
  483. uint8_t id = pl[i];
  484. if (id < 0x53)
  485. g_setShadow[slot][id] =
  486. (uint16_t)(pl[i + 1] |
  487. (pl[i + 2] << 8));
  488. }
  489. S.resp[0] = 0x87;
  490. S.resp[1] = 0;
  491. S.resp_len = 63;
  492. break;
  493. case 0x89: {
  494. // GET_SETTINGS_VALUES: Steam reads back setting `id` (payload[0]) from the id-indexed array. Answer
  495. // from the shadow 0x87 populated: [0x89][3][id][val16 LE]. (Steam didn't use 0x89 in the captured
  496. // session -- it provisions via 0xED below -- but this is the correct real-dongle behavior; harmless.)
  497. uint8_t id = (pln > 0) ? pl[0] : 0;
  498. uint16_t v = (slot >= 0 && slot < NSLOT && id < 0x53) ?
  499. g_setShadow[slot][id] :
  500. 0;
  501. S.resp[0] = 0x89;
  502. S.resp[1] = 3;
  503. S.resp[2] = id;
  504. S.resp[3] = (uint8_t)v;
  505. S.resp[4] = (uint8_t)(v >> 8);
  506. S.resp_len = 63;
  507. break;
  508. }
  509. case 0xED: {
  510. // GET-SETTING-BY-PATH (keyed). Steam SETs the path string, then GETs the value; the REAL controller
  511. // returns the setting's value, but OpenPuck was ECHOING the path -> Steam sees the controller as
  512. // un-provisioned/un-bonded and re-runs its full LEGACY config every connect (0x81 CLEAR_DIGITAL_MAPPINGS
  513. // = the amp clicks/buzz; captured: 0xED reads of "esb/bond"/"user/wireless_transport" that got echoed).
  514. // Answer the paths Steam checks at connect so it treats the controller as provisioned + bonded:
  515. // esb/bond -> this slot's 24-byte bond record (the controller's record of ITS puck)
  516. // esb/bond_2 -> a 2nd-puck bond; OpenPuck bonds one puck per slot -> absent (empty)
  517. // user/wireless_transport -> 1 byte = the ACTIVE (connected) slot's transport code (see below)
  518. // Anything else -> empty ([0xED][0]) rather than a garbage path echo.
  519. const char *p = (const char *)pl;
  520. uint16_t pl_n = pln;
  521. auto pathIs = [&](const char *k) -> bool {
  522. uint16_t kl = (uint16_t)strlen(k);
  523. return pl_n >= kl && memcmp(p, k, kl) == 0 &&
  524. (pl_n == kl || p[kl] == 0);
  525. };
  526. S.resp[0] = 0xED;
  527. if (slot >= 0 && slot < NSLOT && g_slot[slot].used &&
  528. pathIs("esb/bond")) {
  529. S.resp[1] = 0x18;
  530. memcpy(S.resp + 2, g_slot[slot].rec, 24);
  531. } else if (pathIs("user/wireless_transport")) {
  532. // Steam reads this (a CONTROLLER setting, relayed rid1) to mark which bond slot is ACTIVE in the
  533. // pairing list, mapping active_slot = value XOR 2 (HANDOFF: slot0->0x02, slot1->0x03, slot2->0x00,
  534. // slot3->0x01). It's a single DONGLE-WIDE value = the currently-connected slot, NOT this
  535. // interface's own index (returning the raw index made Steam mark slot^2 active -> the live puck
  536. // showed "Inactive"). Report the connected slot's code so Steam marks the live puck active.
  537. int act = 0;
  538. unsigned long best = 0, now = millis();
  539. for (int s2 = 0; s2 < NSLOT; s2++)
  540. if (g_slot[s2].used && g_connReplyMs[s2] &&
  541. (now - g_connReplyMs[s2]) < 1200u &&
  542. (best == 0 || g_connReplyMs[s2] > best)) {
  543. best = g_connReplyMs[s2];
  544. act = s2;
  545. }
  546. S.resp[1] = 1;
  547. S.resp[2] = (uint8_t)(act ^ 2);
  548. } else {
  549. S.resp[1] =
  550. 0; // absent/empty -- valid "no value" reply, not a path echo
  551. }
  552. S.resp_len = 63;
  553. break;
  554. }
  555. default:
  556. S.resp[0] = cmd;
  557. S.resp[1] = len;
  558. if (pln)
  559. memcpy(S.resp + 2, pl, pln > 60 ? 60 : pln);
  560. S.resp_len = 63;
  561. break;
  562. }
  563. }
  564. static uint16_t handleGet(int slot, uint8_t rid, hid_report_type_t type,
  565. uint8_t *buf, uint16_t reqlen)
  566. {
  567. (void)rid;
  568. if (type != HID_REPORT_TYPE_FEATURE)
  569. return 0;
  570. // Do NOT treat a feature GET as "Steam is driving." A bare read is weak evidence: on Linux the kernel
  571. // hid-steam driver (and any hidapi enumerator) issues GET_FEATURE probes even with Steam's window closed,
  572. // which pinned steamDrivingGamepad() true forever and blocked the Steam-closed lizard fallback (keyboard/
  573. // mouse dead, and the lizard-off writes those readers pair with the probe also killed the autonomous
  574. // touchpad haptics). Steam actually taking over is detected by its OUTPUT/SET WRITES (the 0x87 lizard-off
  575. // heartbeat ~every 3s, and 0x82 haptics), which stamp g_steamAliveMs on the handleSet paths. So drop the
  576. // GET-based stamp entirely; a read alone no longer suppresses lizard.
  577. Slot &S = g_slot[slot];
  578. uint16_t n = S.resp_len ? S.resp_len : 63;
  579. if (n > reqlen)
  580. n = reqlen;
  581. memcpy(buf, S.resp, n);
  582. // Flight recorder (every GET, un-deduped): a read STORM -- e.g. the "AE x39" identity hammering seen before
  583. // the identity fix -- is itself a wedge signal, so we want it filling the post-mortem trail if it happens.
  584. faultDiagTrace(FR_GET, (uint16_t)((rid << 8) | S.resp[0]));
  585. // Battery diagnostic: in gamepad (Steam) mode battery is read host-side via the feature channel, NOT the
  586. // forwarded 0x43 (that path is verbatim-identical to lizard mode, where battery works). Capture what Steam
  587. // GETs so a WebUSB-panel (or CDC) capture in Steam mode shows the report id it polls for battery (then we
  588. // answer it with g_battery in handleSet). De-duped by report id so the high-rate polling doesn't flood the
  589. // ring/console -- a freshly-requested id is logged once, then again only after 1s.
  590. {
  591. // Dedup by (rid, interface, response-cmd) so each interface's GET is captured (not collapsed to one
  592. // line across all 4 slots) -- the per-interface bond/identity responses are exactly what we need to
  593. // see -- while still not flooding on high-rate same-report polling.
  594. static uint8_t lastRid = 0xFF, lastSlot = 0xFF, lastCmd = 0xFF;
  595. static unsigned long lastMs = 0;
  596. if (rid != lastRid || (uint8_t)slot != lastSlot ||
  597. S.resp[0] != lastCmd || millis() - lastMs > 1000) {
  598. lastRid = rid;
  599. lastSlot = (uint8_t)slot;
  600. lastCmd = S.resp[0];
  601. lastMs = millis();
  602. // ring marker 0xFC = "host feature GET" (panel renders it as "GET rid=.."); payload = what we returned
  603. hapLogAdd(0xFC, rid, S.resp, n);
  604. // serial feature-command capture: what Steam READ on this interface + our answer
  605. fcPush(1, slot, rid, S.resp[0], (uint8_t)n, S.resp + 2,
  606. 10);
  607. #if OPK_LOG
  608. // Diagnostic build only -- no Serial.printf on the 800B usbd-task stack in production (LOCKUP
  609. // mitigation). The GET is captured to the OPK_LOG ring above for the WebUSB panel.
  610. if (Serial.availableForWrite() > 80)
  611. Serial.printf(
  612. "# GET if%d rid=%02X reqlen=%u -> %02X %02X %02X (batt=%u%%)\n",
  613. slot, rid, reqlen, S.resp[0], S.resp[1],
  614. S.resp[2],
  615. (slot >= 0 && slot < NSLOT) ?
  616. g_battery[slot] :
  617. 0);
  618. #endif
  619. }
  620. }
  621. return n;
  622. }
  623. // one callback pair per interface (the Adafruit core routes by interface to the matching object)
  624. #define SLOTCB(N) \
  625. static void setcb##N(uint8_t r, hid_report_type_t t, uint8_t const *b, \
  626. uint16_t n) \
  627. { \
  628. handleSet(N, r, t, b, n); \
  629. } \
  630. static uint16_t getcb##N(uint8_t r, hid_report_type_t t, uint8_t *bf, \
  631. uint16_t rl) \
  632. { \
  633. return handleGet(N, r, t, bf, rl); \
  634. }
  635. // clang-format off
  636. SLOTCB(0)
  637. SLOTCB(1)
  638. SLOTCB(2)
  639. SLOTCB(3)
  640. // clang-format on
  641. typedef uint16_t (*getcb_t)(uint8_t, hid_report_type_t, uint8_t *, uint16_t);
  642. typedef void (*setcb_t)(uint8_t, hid_report_type_t, uint8_t const *, uint16_t);
  643. static getcb_t GETCB[NSLOT] = { getcb0, getcb1, getcb2, getcb3 };
  644. static setcb_t SETCB[NSLOT] = { setcb0, setcb1, setcb2, setcb3 };
  645. // ===================== IController =====================
  646. void SteamPuckController::begin()
  647. {
  648. USBDevice.setID(0x28DE, 0x1304);
  649. // Distinct bcdDevice so Windows keys a FRESH usbflags entry (cache is VID:PID:bcdDevice) and actually runs
  650. // MS OS 2.0 / WinUSB binding for the WebUSB vendor interface, instead of reusing a stale "no WinUSB" entry
  651. // tied to the real Steam Controller (28DE:1304, no WebUSB interface). The normal (wake-mouse) and one-shot
  652. // debug (CDC) boots present DIFFERENT interface sets, so they need DIFFERENT bcdDevice values or Windows
  653. // serves one's cached descriptor for the other across a reboot.
  654. USBDevice.setDeviceVersion(
  655. g_debugCdcThisBoot ?
  656. 0x0212 :
  657. (g_usbMode == MODE_LIZARD ? 0x0213 : 0x0211));
  658. USBDevice.setManufacturerDescriptor("Valve Software");
  659. USBDevice.setProductDescriptor("Steam Controller Puck");
  660. const uint8_t *desc = (g_usbMode == MODE_LIZARD) ?
  661. PUCK_LIZARD_HID_DESC :
  662. PUCK_HID_DESC;
  663. const uint16_t descLen = (g_usbMode == MODE_LIZARD) ?
  664. sizeof PUCK_LIZARD_HID_DESC :
  665. sizeof PUCK_HID_DESC;
  666. for (int i = 0; i < NSLOT; i++) {
  667. hid[i].setReportDescriptor(desc, descLen);
  668. hid[i].setReportCallback(GETCB[i], SETCB[i]);
  669. // 1ms USB poll (was default 10ms = 100/s cap -> choppy)
  670. hid[i].setPollInterval(1);
  671. hid[i].begin();
  672. }
  673. }
  674. // Forward the controller's report 0x45 to Steam, or drive lizard kb/mouse when Steam is closed. PURELY a
  675. // USB-side decision -- changes nothing about the RF poll or the host->controller relay. Per-slot: each
  676. // controller's 0x45 goes to its OWN hid[slot], so Steam sees four independent inputs. Lizard, by contrast,
  677. // presents ONE mouse + keyboard (a desktop can only consume one): rfLizard merges every bonded controller's
  678. // input (g_in[] across all used slots, populated by the per-slot decode) onto hid[0], so all connected
  679. // controllers drive the same cursor/keys together -- and it works regardless of which bond slot is live.
  680. void SteamPuckController::onReport45(int slot, const uint8_t *rep, bool fresh,
  681. uint8_t bodyTlen)
  682. {
  683. (void)fresh;
  684. (void)bodyTlen;
  685. // Host asleep -> forward NOTHING. While suspended, every sendReport attempt can translate into a host wake,
  686. // making the PC wake on any controller movement. Waking is an explicit gesture only (Steam-button short
  687. // press / controller connect), handled in rf_link.cpp via the device-level USB resume signal.
  688. if (USBDevice.suspended())
  689. return;
  690. // Just woke? Hold off forwarding so the wake gesture's residual controller input doesn't click/type into
  691. // the freshly-woken desktop.
  692. if (g_resumeMs && millis() - g_resumeMs < POST_RESUME_MUTE_MS)
  693. return;
  694. if (slot < 0 || slot >= NSLOT)
  695. return;
  696. if (!g_slot[slot].used)
  697. return;
  698. // Report id: 0x45 = legacy main input; 0x42 = new-firmware (SC2 beta ~2026-07) main input (53B). Forward
  699. // whichever the controller actually sent, verbatim, under its own id -- both are declared in PUCK_HID_DESC
  700. // and this is exactly what the real puck does. rep[0] is the id byte (rep points at it in the F1 TLV).
  701. const uint8_t rid = rep[0];
  702. if (lizardActive()) {
  703. // All bonded controllers share ONE desktop mouse/keyboard (mounted on hid[0] regardless of
  704. // which RF slots are in use). rfLizard merges g_in[] across every used slot internally, so we
  705. // fire it ONCE per cycle -- gated on the lowest CONNECTED slot's report so it runs at a steady
  706. // cadence (not once per slot, which would multiply the output rate). g_in[] is populated by the
  707. // per-slot decode for BOTH report 0x45 and the beta 0x42 (byte-identical front section), so
  708. // lizard works on new-firmware controllers too without reading rep[] here.
  709. // Gate on the lowest RECENTLY-REPLYING slot, not merely the lowest bonded one: a bonded-but-
  710. // offline low slot (e.g. a stale/phantom bond from a cloned backup) must not starve lizard when
  711. // a higher slot is the live controller (main's #98 fix -- do not regress it). onReport45 only
  712. // runs for a slot that just decoded a report, so the reporting slot is itself connected.
  713. unsigned long now = millis();
  714. int lizSlot = -1;
  715. for (int s = 0; s < NSLOT; s++)
  716. if (g_slot[s].used && g_connReplyMs[s] &&
  717. (now - g_connReplyMs[s]) < 1200u) {
  718. lizSlot = s;
  719. break;
  720. }
  721. if (slot == lizSlot)
  722. rfLizard(&hid[0], &hid[0], 0x40, 0x41);
  723. } else {
  724. // body length after the id byte, clamped to the descriptor's declared size for this report id
  725. // (0x42 = 53B vendor input, 0x45 = 45B input).
  726. uint8_t maxb = (rid == 0x42) ? 53 : 45;
  727. uint8_t blen = bodyTlen - 1;
  728. if (blen > maxb)
  729. blen = maxb;
  730. // forward the puck's raw pad coords untouched (Steam does its own interpolation/smoothing). Forward
  731. // only FRESH reports: the real puck dedupes, so stale repeats make Steam's velocity/smoothing
  732. // stair-step. g_fwdNewOnly toggles for A/B.
  733. //
  734. // Do NOT gate on hid[slot].ready() here: usbTxHid enqueues into a ring buffer (drop-oldest)
  735. // that exists precisely to hold a report while the endpoint is briefly busy -- usbTxDrain
  736. // sends it the instant the host next polls. Gating on ready() at enqueue time DROPPED every
  737. // fresh report that landed while the endpoint was busy (~1 ms after each send), which at
  738. // 300+ captured reports/s silently discarded ~a third of them (measured: RF new/s ~313 but
  739. // host delivered ~180). Always enqueue; the ring paces delivery to the host without loss.
  740. bool send = (fresh || !g_fwdNewOnly);
  741. // Content dedup: the counter byte (rep[1]) free-runs, so drop a report whose body AFTER the
  742. // counter is byte-identical to the last one forwarded on this slot -- a true repeat (same
  743. // physical input, bumped counter), not a new sample. Caps delivery at the controller's real
  744. // distinct-report rate instead of the (higher) poll rate. See g_fwdContentDedup above.
  745. if (send && g_fwdContentDedup && blen >= 2) {
  746. static uint8_t lastBody[NSLOT][53];
  747. static uint8_t lastBodyLen[NSLOT] = { 0 };
  748. uint8_t clen =
  749. (uint8_t)(blen - 1); // bytes after the counter
  750. if (clen > sizeof lastBody[0])
  751. clen = sizeof lastBody[0];
  752. if (lastBodyLen[slot] == clen &&
  753. memcmp(lastBody[slot], rep + 2, clen) == 0)
  754. send = false; // identical input, only the counter moved
  755. else {
  756. lastBodyLen[slot] = clen;
  757. memcpy(lastBody[slot], rep + 2, clen);
  758. }
  759. }
  760. if (send)
  761. usbTxHid(
  762. &hid[slot], rid, rep + 1,
  763. blen); // Steam/SDL Triton: input report 0x45 (old) / 0x42 (new fw)
  764. }
  765. }
  766. // Forward the controller's NON-input status reports (0x43 power/battery, 0x44) to Steam verbatim -- the real
  767. // puck does this and it's how Steam reads battery. Same host-asleep / post-resume gating as 0x45; no lizard
  768. // path (status reports aren't input, so they forward regardless of the lizard decision). Per-slot: each
  769. // controller's status goes to its own hid[slot].
  770. void SteamPuckController::onAuxReport(int slot, uint8_t rid,
  771. const uint8_t *data, uint8_t n)
  772. {
  773. if (USBDevice.suspended())
  774. return;
  775. if (g_resumeMs && millis() - g_resumeMs < POST_RESUME_MUTE_MS)
  776. return;
  777. if (slot < 0 || slot >= NSLOT)
  778. return;
  779. // Forward the controller's status report VERBATIM (the real puck does this; it's how the host reads
  780. // battery). Padding the report to the descriptor-declared length broke battery in both lizard and
  781. // Steam, so it's reverted -- send exactly what the controller sent.
  782. if (g_slot[slot].used && hid[slot].ready()) {
  783. // capture the pushed status report (0x43 battery / 0x44) device->host for the WebUSB panel: this is
  784. // the channel Steam actually reads battery from; marker 0xFB = "->host push".
  785. hapLogAdd(0xFB, rid, data, n);
  786. usbTxHid(&hid[slot], rid, data, n);
  787. }
  788. }
  789. // wake nudge: a bare USB resume signal is NOT enough to wake some hosts (Windows in particular) -- they only
  790. // wake when actual mouse/keyboard input follows. So on a deliberate wake gesture we play a HARMLESS mouse
  791. // JIGGLE (move a few px right, then back -- NET ZERO cursor, NO button): real mouse activity wakes the host
  792. // but clicks/activates nothing (an open Start menu stays open). Queued by wakeEvent() (rf_link, on a Steam
  793. // short press / controller connect while suspended); delivered once the suspended bus has resumed. Per-slot
  794. // so every connected interface gets the jiggle (Windows can credit any of them with the wake).
  795. static uint8_t g_nudgeStep[NSLOT] = { 0 }; // 0=idle; 1=jiggle+, 2=jiggle-
  796. static unsigned long g_nudgeMs[NSLOT] = { 0 };
  797. #define NUDGE_JIGGLE_PX 10
  798. void SteamPuckController::wakeEvent()
  799. {
  800. // arm the jiggle on every connected slot -- any one of them is enough to wake Windows
  801. for (int s = 0; s < NSLOT; s++) {
  802. if (g_slot[s].used) {
  803. g_nudgeStep[s] = 1;
  804. g_nudgeMs[s] = millis();
  805. }
  806. }
  807. }
  808. static void wakeNudgeTask()
  809. {
  810. if (USBDevice.suspended())
  811. return; // wait for resume; reports can't cross a suspended bus
  812. // Expire stale arms (bus never resumed) and see if any slot still wants a wake nudge.
  813. bool armed = false;
  814. for (int s = 0; s < NSLOT; s++) {
  815. if (!g_nudgeStep[s])
  816. continue;
  817. if (millis() - g_nudgeMs[s] > 5000) {
  818. g_nudgeStep[s] = 0;
  819. continue;
  820. }
  821. armed = true;
  822. }
  823. if (!armed)
  824. return;
  825. // Ride the BOOT MOUSE -- the interface Windows armed as the wake source; a gamepad-slot report does not
  826. // wake Modern Standby. A single jiggle on it wakes the host regardless of how many slots are connected.
  827. if (wakeHidPresent()) {
  828. if (!wakeHidReady())
  829. return;
  830. static unsigned long stepMs = 0;
  831. static uint8_t step = 1;
  832. if (millis() - stepMs < 15)
  833. return; // pace the edges
  834. stepMs = millis();
  835. wakeHidMove((step == 1) ? NUDGE_JIGGLE_PX : -NUDGE_JIGGLE_PX,
  836. 0);
  837. if (step >=
  838. 2) { // jiggle (right, then back) delivered -> disarm every slot
  839. step = 1;
  840. for (int s = 0; s < NSLOT; s++)
  841. g_nudgeStep[s] = 0;
  842. } else
  843. step++;
  844. return;
  845. }
  846. // Fallback (debug-CDC boot: no wake mouse): per-slot jiggle on the gamepad slot HIDs.
  847. static unsigned long stepMs[NSLOT] = { 0 };
  848. for (int s = 0; s < NSLOT; s++) {
  849. if (!g_nudgeStep[s])
  850. continue;
  851. if (!hid[s].ready())
  852. continue;
  853. if (millis() - stepMs[s] < 15)
  854. continue; // pace the edges
  855. stepMs[s] = millis();
  856. hid_mouse_report_t m;
  857. m.buttons = 0;
  858. m.x = (g_nudgeStep[s] == 1) ? NUDGE_JIGGLE_PX :
  859. -NUDGE_JIGGLE_PX;
  860. m.y = 0;
  861. m.wheel = 0;
  862. m.pan = 0;
  863. usbTxHid(&hid[s], 0x40, &m,
  864. sizeof m); // jiggle right, then back
  865. g_nudgeStep[s] = (g_nudgeStep[s] >= 2) ?
  866. 0 :
  867. (uint8_t)(g_nudgeStep[s] + 1);
  868. }
  869. }
  870. // USB connection presentation (like the real dongle): report 0x79 = connection state (01=disc, 02=conn),
  871. // edge-triggered, + periodic 0x7B status. Live-captured: this is what Steam reads to mark the controller
  872. // connected. Without it Steam shows disconnected even though 0x45 input is streaming.
  873. void SteamPuckController::task()
  874. {
  875. wakeNudgeTask();
  876. {
  877. static bool wasSusp = false;
  878. // stamp the suspended->active edge for the post-resume mute
  879. bool susp = USBDevice.suspended();
  880. if (wasSusp && !susp)
  881. g_resumeMs = millis();
  882. wasSusp = susp;
  883. }
  884. // no periodic 0x79/0x7B while the host sleeps -- those sends can wake it too
  885. if (USBDevice.suspended())
  886. return;
  887. // Lizard<->Steam handoff: release whatever the OUTGOING path was holding. lizardActive() is a pure
  888. // runtime decision -- Steam opening/closing (its ~3s settings heartbeat starting/stopping) flips it with
  889. // NO USB re-enumeration -- so a key/mouse-button held when Steam TAKES OVER, or a gamepad button held
  890. // when Steam CLOSES, is otherwise never released and sticks on the host until a reconnect or power-cycle
  891. // (the reported "stuck input after switching modes"). The link-drop neutral in rf_link only covers an RF
  892. // outage, not this handoff. Fire a neutral through the path we're leaving on each edge. Placed after the
  893. // suspend early-return: while suspended nothing can be sent, and a flip across a sleep is covered on
  894. // resume (this edge fires once, plus the post-resume input mute). MODE_LIZARD never leaves lizard, so
  895. // this is inert there.
  896. {
  897. static bool wasLizard = lizardActive();
  898. bool nowLizard = lizardActive();
  899. if (nowLizard != wasLizard) {
  900. if (wasLizard) {
  901. // leaving lizard -> release held desktop keyboard/mouse/consumer
  902. rfLizardRelease(&hid[0], &hid[0], 0x40, 0x41);
  903. } else {
  904. // leaving gamepad-forward -> release held 0x45 buttons/sticks/triggers on every slot
  905. static const uint8_t neutral45[45] = { 0 };
  906. for (int s = 0; s < NSLOT; s++)
  907. if (g_slot[s].used && hid[s].ready())
  908. usbTxHid(&hid[s], 0x45,
  909. neutral45,
  910. sizeof neutral45);
  911. }
  912. wasLizard = nowLizard;
  913. }
  914. }
  915. // Per-slot 0x79/0x7B: each connected slot reports its OWN edge and its OWN status. State arrays are
  916. // per-slot so each controller's "connected" edge fires once and is re-sent only until Steam acks THAT
  917. // slot. The real puck's per-slot edge-triggered 0x79 prevents re-triggering Steam's connect-chime loop.
  918. static bool usbConn[NSLOT] = { 0 };
  919. static unsigned long last79[NSLOT] = { 0 }, last7B[NSLOT] = { 0 },
  920. connEdgeMs[NSLOT] = { 0 },
  921. discEdgeMs[NSLOT] = { 0 }, last43[NSLOT] = { 0 },
  922. poHandled[NSLOT] = { 0 };
  923. for (int s = 0; s < NSLOT; s++) {
  924. if (!g_slot[s].used || !hid[s].ready())
  925. continue;
  926. // Power-off just relayed to this slot -> force ONE clean disconnect to Steam now, regardless of the
  927. // prior edge state. The controller's noisy shutdown (it keeps streaming F1 for ~1s after "off!") can
  928. // leave usbConn desynced from what Steam shows, so the ordinary conn!=usbConn edge sometimes never
  929. // fired -> Steam kept the controller in its list (the "doesn't get removed" case). Anchor discEdgeMs
  930. // too so the bounded resend covers a dropped packet.
  931. if (g_powerOffMs[s] && g_powerOffMs[s] != poHandled[s]) {
  932. poHandled[s] = g_powerOffMs[s];
  933. uint8_t st = 0x01;
  934. hapLogAdd(0xFB, 0x79, &st, 1); // ->host push (capture)
  935. usbTxHid(&hid[s], 0x79, &st, 1);
  936. usbConn[s] = false;
  937. discEdgeMs[s] = millis();
  938. last79[s] = millis();
  939. }
  940. // Hold the slot DISCONNECTED through the controller's post-power-off F1 tail (see slotPoweringOff);
  941. // otherwise a stray dying reply bounces conn true -> a phantom 0x79=02 that Steam reads as a reconnect
  942. // and answers by re-running its connect config (the "reappears for a split second").
  943. bool conn = !slotPoweringOff(s) &&
  944. (millis() - g_connReplyMs[s] < 300);
  945. // 0x79 connection state: on edge, then repeated every 750ms ONLY until Steam reacts (its first OUTPUT/
  946. // settings write after the edge -- g_steamAliveMs). The real puck sends 0x79 ONCE, edge-triggered; an
  947. // unconditional forever-resend re-triggers Steam's connect handling (connect chime) every 750ms before
  948. // Steam consumes 0x45 -> a loop of connect-time haptic buzzes. Resending until acked still covers
  949. // "Steam missed the edge".
  950. bool steamAcked = g_steamAliveMs &&
  951. (int32_t)(g_steamAliveMs - connEdgeMs[s]) >=
  952. 0;
  953. // The DISCONNECT edge (0x79=01) also needs resending: it used to be sent exactly ONCE, so a single
  954. // dropped report left Steam believing the controller was still connected indefinitely -- observed as
  955. // "WebUSB panel shows the slot DOWN (g_connReplyMs stale) while Steam still lists it connected, and the
  956. // controller light is solid (it re-adopted the beacon) but no input flows." Resend disc every 750ms but
  957. // BOUNDED to DISC_RESEND_MS so a lost packet converges without the forever-spam the "real puck sends
  958. // 0x79 once" note warns against.
  959. bool discResend = !conn &&
  960. (millis() - discEdgeMs[s] < DISC_RESEND_MS);
  961. if (conn != usbConn[s] ||
  962. (conn && !steamAcked && millis() - last79[s] >= 750) ||
  963. (discResend && millis() - last79[s] >= 750)) {
  964. if (conn && !usbConn[s])
  965. connEdgeMs[s] = millis();
  966. if (!conn &&
  967. usbConn[s]) // connect->disc edge: anchor the resend window
  968. discEdgeMs[s] = millis();
  969. uint8_t st = conn ? 0x02 : 0x01;
  970. hapLogAdd(0xFB, 0x79, &st, 1); // ->host push (capture)
  971. usbTxHid(&hid[s], 0x79, &st, 1);
  972. usbConn[s] = conn;
  973. last79[s] = millis();
  974. } else if (conn && millis() - last7B[s] >= 2000) {
  975. // 0x7B status, live-captured template. Byte 8 is the controller->puck signal strength as signed
  976. // dBm (capture showed 0xDD = -35) -- patch in the smoothed RSSI the radio samples on each
  977. // controller reply (rf_link). 0 = no sample yet -> keep the capture value rather than garbage.
  978. //
  979. // CALIBRATION: our raw RSSISAMPLE reads ~RSSI_DBM_OFFSET dB lower than the real Valve puck at the
  980. // same distance (Pro Micro PCB-trace antenna vs Valve's tuned front-end). The 2Mbit ESB link has
  981. // ~55dB margin, so a -75dBm reading still works across a house, but Steam's bar maps raw dBm to
  982. // "weak" long before that. The offset lines our close-range value up with the puck's captured -35
  983. // so the bar tracks usable range, not antenna gain. Clamp keeps it in a sane window.
  984. uint8_t s7b[12] = { 0xF7, 0x01, 0x89, 0x00, 0x00, 0x00,
  985. 0x03, 0x00, 0xDD, 0x00, 0x3A, 0x02 };
  986. if (g_linkRssi[s]) {
  987. int mag = (int)g_linkRssi[s] - RSSI_DBM_OFFSET;
  988. if (mag < 25)
  989. mag = 25;
  990. else if (mag > 95)
  991. mag = 95;
  992. s7b[8] = (uint8_t)(0u - (uint8_t)mag);
  993. }
  994. hapLogAdd(0xFB, 0x7B, s7b, 12); // ->host push (capture)
  995. usbTxHid(&hid[s], 0x7B, s7b, 12);
  996. last7B[s] = millis();
  997. }
  998. // Synthesized 0x43 = ID_TRITON_BATTERY_STATUS for SDL/Steam's gamepad driver. The verbatim forward of
  999. // the controller's own 0x43 (onAuxReport) is what the LIZARD/kernel path reads, but SDL's Triton driver
  1000. // requires the FULL TritonBatteryStatus_t length (r >= 1 + 14) and lapses to the "wired" glyph without a
  1001. // fresh one -- so we push a clean 14-byte report from g_battery every 2s. Body: [ucChargeState][ucBattery
  1002. // Level][voltages/current/temp = 0]; SDL only reads the first two. Map unknown/reset state -> discharging
  1003. // so it shows ON_BATTERY + % rather than UNKNOWN. Skipped until the controller has reported a level.
  1004. if (conn && g_battery[s] && millis() - last43[s] >= 2000) {
  1005. uint8_t st = g_batteryState[s];
  1006. // EChargeState discharging -> SDL_POWERSTATE_ON_BATTERY
  1007. if (st != 1 && st != 2 && st != 4)
  1008. st = 1;
  1009. uint8_t b43[14] = { 0 };
  1010. b43[0] = st; // ucChargeState
  1011. b43[1] = g_battery[s]; // ucBatteryLevel (percent)
  1012. hapLogAdd(0xFB, 0x43, b43, 14); // ->host push (capture)
  1013. usbTxHid(&hid[s], 0x43, b43, sizeof b43);
  1014. last43[s] = millis();
  1015. }
  1016. }
  1017. // Reset edge state for slots that are no longer used/ready (so a re-bond sees a fresh edge).
  1018. for (int s = 0; s < NSLOT; s++)
  1019. if (!g_slot[s].used || !hid[s].ready())
  1020. usbConn[s] = false;
  1021. }