OpenPuck.ino 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  1. // OpenPuck.ino -- Steam Controller 2 ("Triton") puck reimplementation for an nRF52840.
  2. //
  3. // This firmware 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, Switch, PS5, or DS4
  5. // personalities. Build with -DCFG_TUD_HID=4 (the Adafruit nRF port defaults to 2).
  6. //
  7. // This file is just the entry point: setup() builds the USB presentation for the persisted mode and arms the
  8. // hardware watchdog; loop() pumps each subsystem. Everything substantive lives in the modules below -- see
  9. // ARCHITECTURE.md for the full map. The short version:
  10. // config / identity / bonds -- persisted settings, device serial, the four controller bond slots
  11. // radio / rf_link / rf_diag -- bare-metal nRF52 RADIO, the operational puck protocol, and RE tooling
  12. // triton / gamepad_util -- the decoded controller input (g_in) + shared host-report build helpers
  13. // controllers + mode_* -- one IController per USB personality (puck/lizard, xbox, switch, ps5, ds4)
  14. // haptics / webusb_config / serial_console -- haptic relay, the browser config channel, the CDC debug CLI
  15. //
  16. // Protocol reference: docs/PROTOCOL.md. Bond record = [8 uuid][16 serial].
  17. #include <Adafruit_TinyUSB.h>
  18. #include <Adafruit_LittleFS.h>
  19. #include <InternalFileSystem.h>
  20. using namespace Adafruit_LittleFS_Namespace;
  21. #include "config.h"
  22. // OPK_GIT_HASH: tags the one-time factory-reset build
  23. #include "build_info.h"
  24. #include "identity.h"
  25. #include "bonds.h"
  26. #include "radio.h"
  27. #include "controllers.h"
  28. #include "haptics.h"
  29. #include "rf_link.h"
  30. #include "rf_diag.h"
  31. #include "webusb_config.h"
  32. #include "serial_console.h"
  33. #include "wake_hid.h"
  34. #include "status_led.h"
  35. #if CFG_TUD_HID < 4
  36. #error "build with -DCFG_TUD_HID=4 (extra_flags): up to 4 HID interfaces per mode"
  37. #endif
  38. // puck composite (4 HID + WebUSB) exceeds the default 256 B config buffer
  39. static uint8_t g_usbCfgDesc[512];
  40. void setup()
  41. {
  42. genSerial();
  43. ledInit();
  44. // per-device default RF session address (per-slot). The real generation is below, AFTER loadBonds, so
  45. // the address can be derived from the bond UUID for each slot. The pre-load call here is just to
  46. // populate the defaults so a slot with no bond doesn't accidentally share the discovery address.
  47. for (int s = 0; s < NSLOT; s++)
  48. rfGenSessionAddr(s);
  49. InternalFS.begin();
  50. #if OPK_FACTORY_RESET
  51. // Recovery build (-DOPK_FACTORY_RESET=1): wipe ALL persistent storage ONCE on the first boot after flashing,
  52. // then persist normally (tracked by a git-hash tag so it does NOT wipe every boot). See config.h / config.cpp.
  53. factoryResetOnce(OPK_GIT_HASH);
  54. #endif
  55. loadCfg();
  56. loadBonds();
  57. // Now that bonds are loaded, regenerate per-slot session addresses from each bond's UUID. This is
  58. // deterministic (a slot's address is stable across reboots) and avoids persisting the address in
  59. // bonds.bin -- no schema bump needed.
  60. for (int s = 0; s < NSLOT; s++)
  61. if (g_slot[s].used)
  62. rfGenSessionAddr(s);
  63. // decide USB presentation BEFORE registering interfaces
  64. g_xbox = !modeIsPuck(g_usbMode);
  65. g_active = controllerFor(g_usbMode);
  66. // ---- USB descriptor rebuild ----
  67. // Puck mode drops the CDC serial console (clearConfiguration) to free a USB endpoint for the wake-mouse
  68. // interface that wakes a sleeping Windows host. The one-shot debug arm (g_debugCdcThisBoot, set via WebUSB
  69. // panel / CDC 'D', auto-reverting after one boot) instead KEEPS the boot CDC composite and skips the wake
  70. // mouse (no endpoint room for both). Clean modes always rebuild bare.
  71. const bool puckMode = g_active->isPuck();
  72. const bool keepCdc = puckMode && g_debugCdcThisBoot;
  73. // The "game" PlayStation modes enumerate as a CLEAN single HID gamepad so games that classify off the raw HID
  74. // device (Fortnite/UE GameInput, Windows.Gaming.Input) recognise them as PlayStation. A real Sony pad is just
  75. // the gamepad HID (+ a USB-audio interface); our extra wake-mouse HID and WebUSB vendor interface make those
  76. // classifiers refuse the PS glyph path even with the correct VID/PID (SDL/Steam tolerate the extras). So
  77. // clean-PS modes skip BOTH the wake mouse and WebUSB -- no config panel / host-wake; chord back to Steam
  78. // (back-paddle 4 + A) to reach the panel. Normal MODE_PS5 / MODE_HIDGYRO keep wake + panel.
  79. const bool psClean = modeIsCleanPS(g_usbMode);
  80. USBDevice.detach();
  81. delay(30);
  82. if (keepCdc) {
  83. USBDevice.setConfigurationBuffer(g_usbCfgDesc,
  84. sizeof g_usbCfgDesc);
  85. } else {
  86. USBDevice.clearConfiguration();
  87. // headroom over the default 256 B cap
  88. USBDevice.setConfigurationBuffer(g_usbCfgDesc,
  89. sizeof g_usbCfgDesc);
  90. }
  91. // Distinct USB serial PER MODE (must be set AFTER clearConfiguration, which nulls it). Hosts cache USB
  92. // identity by VID:PID:serial; reusing one serial under a changing VID:PID can make a host refuse the new
  93. // identity. Steam keeps the exact unit serial (its pairing identity); the others get a 1-char suffix.
  94. static const char MODE_SUFFIX[] = {
  95. 'X', 'N', 'L', 'P', 'S', 'G', 'Q', 'D'
  96. }; // modes 1..8 (Q=PS5 game, D=DS4 game)
  97. if (puckMode) {
  98. USBDevice.setSerialDescriptor(g_unit);
  99. } else {
  100. snprintf(g_usbSerial, sizeof g_usbSerial, "%s%c", g_unit,
  101. MODE_SUFFIX[g_usbMode - 1]);
  102. USBDevice.setSerialDescriptor(g_usbSerial);
  103. }
  104. // SDL3's Proteus/Triton HIDAPI driver only binds slot HIDs on USB interfaces 2..5. Register WebUSB (IF 0)
  105. // and the wake mouse (IF 1) before the four puck slots so hid[0..3] land on IF 2..5 like the real puck.
  106. if (puckMode)
  107. usb_web.begin();
  108. if (puckMode && !keepCdc)
  109. wakeHidBegin();
  110. g_active->begin();
  111. // Boot-mouse wake interface for clean (non-puck) modes, and for puck on the one-shot debug boot (CDC on,
  112. // no endpoint room for wake mouse on a normal puck boot -- wake is registered above instead). Skipped for PS
  113. // modes so the device stays a single clean HID gamepad (see psClean above).
  114. if (!puckMode && !keepCdc && !psClean)
  115. wakeHidBegin();
  116. // WebUSB config panel -- every mode EXCEPT the PlayStation modes. Puck: registered above (IF 0) before wake +
  117. // slots; other clean modes after controller. PS modes omit it to present a genuine single-HID PS controller.
  118. if (!puckMode && !psClean)
  119. usb_web.begin();
  120. // bmAttributes: required(0x80) | remote_wakeup(0x20). Remote Wakeup lets us signal wake-from-sleep.
  121. USBDevice.setConfigurationAttribute(0x80 | 0x20);
  122. // re-attach with the final descriptor (host re-reads it fresh -> deterministic enumeration)
  123. USBDevice.attach();
  124. Serial.begin(115200);
  125. for (int i = 0; i < 300 && !USBDevice.mounted(); i++)
  126. delay(10); // wait up to 3s for USB mount, but NEVER hang
  127. if (USBDevice.suspended()) {
  128. USBDevice.remoteWakeup();
  129. ledWakePulse();
  130. } // wake host if bus was sleeping when we (re-)attached
  131. // loadBonds() was called earlier (after the bond-UUID-derived session addresses were generated);
  132. // this is a no-op now but kept for forward-compat (a future migration to lazy-loading).
  133. hapticInit();
  134. static const char *MODE_NAME[] = {
  135. "STEAM(puck)", "XBOX(xinput+mouse)",
  136. "SWITCH(horipad)", "LIZARD(puck kb/mouse)",
  137. "SWITCH(pro+gyro)", "PS5(dualsense)",
  138. "HIDGYRO(ds4+motion)", "PS5(dualsense,game/clean)",
  139. "DS4(ds4,game/clean)"
  140. };
  141. Serial.printf("# copycat up: unit=%s board=%s, mode=%s\n", g_unit,
  142. g_board,
  143. MODE_NAME[g_usbMode <= MODE_MAX ? g_usbMode : 0]);
  144. if (puckMode)
  145. Serial.printf(
  146. "# puck USB: %s\n",
  147. keepCdc ?
  148. "DEBUG boot (CDC console on, wake mouse off; reverts next boot)" :
  149. "normal (CDC off, wake mouse on)");
  150. Serial.printf(
  151. "# session ch%u (discovery on ibex/ch2); per-slot session addrs:\n",
  152. g_sessCh);
  153. for (int s = 0; s < NSLOT; s++) {
  154. if (g_slot[s].used) {
  155. Serial.printf(
  156. "# slot %d: %02X%02X%02X%02X/%02X (uuid %02X%02X%02X%02X %02X%02X%02X%02X)\n",
  157. s, g_sessBase[s][0], g_sessBase[s][1],
  158. g_sessBase[s][2], g_sessBase[s][3],
  159. g_sessPrefix[s], g_slot[s].rec[0], g_slot[s].rec[1],
  160. g_slot[s].rec[2], g_slot[s].rec[3],
  161. g_slot[s].rec[4], g_slot[s].rec[5],
  162. g_slot[s].rec[6], g_slot[s].rec[7]);
  163. }
  164. }
  165. // Hardware watchdog: if loop() ever stops feeding it (wedged radio busy-wait, HardFault spin, blocked CDC
  166. // write) the WDT resets the nRF52 after ~8s, re-enumerating USB + re-initialising RF, so a hang no longer
  167. // needs a physical replug. RUN keeps it counting in sleep; PAUSE freezes it under a debugger.
  168. NRF_WDT->CONFIG = (WDT_CONFIG_HALT_Pause << WDT_CONFIG_HALT_Pos) |
  169. (WDT_CONFIG_SLEEP_Run << WDT_CONFIG_SLEEP_Pos);
  170. NRF_WDT->CRV = 8UL * 32768UL - 1; // timeout in 32.768 kHz ticks (~8 s)
  171. NRF_WDT->RREN = WDT_RREN_RR0_Msk; // arm reload register 0
  172. NRF_WDT->TASKS_START = 1;
  173. }
  174. // loop-timing diagnostics: poll rate is capped by loop ITERATION time (pacing wants 4000us but the poll only
  175. // fires as often as the loop comes around). Per-section us accumulates and each second the avg loop period +
  176. // slowest section are published for the WebUSB panel.
  177. // avg loop iteration time last second (1e6/iterations)
  178. uint16_t g_loopPeriodUs = 0;
  179. // index of the slowest section: 0=webusb 1=ctrl.task 2=serial 3=rfdiag 4=rflink 5=haptic 6=led
  180. uint8_t g_loopWorst = 0;
  181. uint16_t g_loopWorstUs = 0; // that section's avg us per iteration
  182. void loop()
  183. {
  184. // feed the watchdog; if we ever stop, the ~8s WDT auto-resets us
  185. NRF_WDT->RR[0] = WDT_RR_RR_Reload;
  186. if (g_dirty) {
  187. g_dirty = false;
  188. saveBonds();
  189. }
  190. #if OPK_LOG
  191. // Diagnostic build: time each loop section so the panel can show the slowest one + the loop period.
  192. static uint32_t acc[7] = { 0 };
  193. static uint32_t loops = 0;
  194. static unsigned long secMs = 0;
  195. uint32_t t;
  196. t = micros();
  197. webusbPoll();
  198. acc[0] += (uint32_t)(micros() - t);
  199. t = micros();
  200. if (g_active)
  201. g_active->task();
  202. acc[1] += (uint32_t)(micros() - t);
  203. t = micros();
  204. serialConsolePoll();
  205. acc[2] += (uint32_t)(micros() - t);
  206. t = micros();
  207. rfDiagTask();
  208. acc[3] += (uint32_t)(micros() - t);
  209. t = micros();
  210. rfLinkTask();
  211. acc[4] += (uint32_t)(micros() - t);
  212. t = micros();
  213. hapticTask();
  214. acc[5] += (uint32_t)(micros() - t);
  215. t = micros();
  216. ledTask();
  217. acc[6] += (uint32_t)(micros() - t);
  218. loops++;
  219. if (millis() - secMs >= 1000) {
  220. g_loopPeriodUs = loops ? (uint16_t)(1000000UL / loops) : 0;
  221. uint8_t wi = 0;
  222. uint32_t wv = 0;
  223. for (int i = 0; i < 7; i++) {
  224. if (acc[i] > wv) {
  225. wv = acc[i];
  226. wi = i;
  227. }
  228. acc[i] = 0;
  229. }
  230. g_loopWorst = wi;
  231. g_loopWorstUs = loops ? (uint16_t)(wv / loops) : 0;
  232. loops = 0;
  233. secMs = millis();
  234. }
  235. #else
  236. webusbPoll();
  237. if (g_active)
  238. g_active->task();
  239. serialConsolePoll();
  240. rfDiagTask();
  241. rfLinkTask();
  242. hapticTask();
  243. ledTask();
  244. #endif
  245. }