OpenPuck.ino 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458
  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 `make build` (bakes in the required CFG_TUD_HID=4 + CFG_TUD_TASK_QUEUE_SZ=64).
  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. #include "board_config.h"
  23. // OPK_GIT_HASH: tags the one-time factory-reset build
  24. #include "build_info.h"
  25. #include "identity.h"
  26. #include "bonds.h"
  27. #include "radio.h"
  28. #include "controllers.h"
  29. #include "haptics.h"
  30. #include "rf_link.h"
  31. #include "puck_hid.h" // puckCmdLogDrain()
  32. #include "rf_diag.h"
  33. #include "webusb_config.h"
  34. #include "lizard_map.h"
  35. #include "serial_console.h"
  36. #include "wake_hid.h"
  37. #include "status_led.h"
  38. #include "pwr_switch.h"
  39. #include "usb_mount.h"
  40. #include "identity.h"
  41. #include "fault_diag.h"
  42. #include "fw_update.h"
  43. #include "usb_tx.h"
  44. #include <stdio.h>
  45. #if CFG_TUD_HID < 4
  46. #error "CFG_TUD_HID must be >= 4 (up to 4 HID interfaces per mode). Build with `make build` (bakes it in), or pass -DCFG_TUD_HID=4 in build.extra_flags."
  47. #endif
  48. // The TinyUSB device-stack event queue (_usbd_q) defaults to 16 entries. tud_task() runs in the dedicated
  49. // high-priority "usbd" FreeRTOS task, but this firmware emits HID reports from the LOW-priority loop() task
  50. // (every mode: 0x45 forwards, per-slot 0x79/0x7B/0x43 status, streamed gamepad reports). A cross-task
  51. // sendReport whose EasyDMA is momentarily busy takes TinyUSB's usbd_defer_func() path, which does a BLOCKING
  52. // osal_queue_send (xQueueSendToBack WAIT_FOREVER) onto _usbd_q. Under a comms burst (haptics/trackpad floods,
  53. // the connect-time re-init storm, 4 slots at once) the 16-deep queue saturates; the loop task then blocks
  54. // posting to it and stops feeding the ~8 s watchdog -> the MCU resets and the controller "disconnects". A
  55. // deeper queue absorbs realistic bursts so the loop never blocks. Must be set on the build command (it is a
  56. // TinyUSB-internal #ifndef default, so a sketch header cannot reach usbd.c) -- enforce it here so a build that
  57. // omits the flag fails loudly instead of shipping the deadlock.
  58. #if !defined(CFG_TUD_TASK_QUEUE_SZ) || CFG_TUD_TASK_QUEUE_SZ < 32
  59. #error "CFG_TUD_TASK_QUEUE_SZ must be >= 32: the default 16-deep usbd event queue deadlocks the loop task under comms load -> watchdog reset. Build with `make build` (bakes in 64), or pass -DCFG_TUD_TASK_QUEUE_SZ=64 in build.extra_flags."
  60. #endif
  61. // puck composite (4 HID + WebUSB) exceeds the default 256 B config buffer
  62. static uint8_t g_usbCfgDesc[512];
  63. // Per-mode USB serial suffix (modes 1..MODE_MAX: X=xbox N=hori L=lizard P=swpro S=ps5 G=hidgyro Q=ps5game
  64. // D=ds4game 3=ps3 O=original-xbox J=dinput I=sinput H=sw2pro).
  65. // Switch 2 Pro overrides the generated suffix serial with the captured "00".
  66. // 'C' is reserved for the CDC mode, see puck_hid.cpp
  67. static const char MODE_SUFFIX[] = { 'X', 'N', 'L', 'P', 'S', 'G', 'Q',
  68. 'D', '3', 'O', 'J', 'I', 'H' };
  69. // Fixed-interface flags captured at boot so usbReenumerate (dynamic mount, no reboot) replays them.
  70. static bool s_dynWantWebusb = false, s_dynWantWakeMouse = false;
  71. // Dynamic re-enumeration (NO MCU reboot): tear down + rebuild the config descriptor presenting `k` connected-
  72. // controller slots, replaying the fixed interfaces in locked HID-instance order (wake mouse = instance 0,
  73. // before the slot pool; WebUSB is vendor-class so order-free), then re-attach. RF/firmware state survives.
  74. // g_usbToBond must already be built for k connected controllers. Called at boot and by usb_mount's watcher.
  75. void usbReenumerate(uint8_t k)
  76. {
  77. USBDevice.detach();
  78. delay(20);
  79. USBDevice.clearConfiguration();
  80. USBDevice.setConfigurationBuffer(g_usbCfgDesc, sizeof g_usbCfgDesc);
  81. g_active->usbIdentity(); // clearConfiguration reset VID/PID/strings -- restore them
  82. // serial carries the mounted count so the host invalidates its cached config descriptor on a change
  83. snprintf(g_usbSerial, sizeof g_usbSerial, "%s%c%u", g_unit,
  84. MODE_SUFFIX[(g_usbMode >= 1 && g_usbMode <= MODE_MAX) ?
  85. g_usbMode - 1 :
  86. 0],
  87. (unsigned)k);
  88. if (g_usbMode == MODE_SW2_PRO) {
  89. USBDevice.setSerialDescriptor("00");
  90. } else {
  91. USBDevice.setSerialDescriptor(g_usbSerial);
  92. }
  93. if (s_dynWantWakeMouse)
  94. wakeHidAddInterface(); // HID instance 0
  95. g_active->mountSlots(
  96. k); // mode's fixed HIDs (if any) + k slot interfaces
  97. if (s_dynWantWebusb)
  98. USBDevice.addInterface(usb_web);
  99. if (g_usbMode == MODE_SW2_PRO) {
  100. // Match the captured Nintendo configuration header exactly.
  101. g_usbCfgDesc[6] = 4; // iConfiguration = "Config_0"
  102. USBDevice.setConfigurationAttribute(0xc0);
  103. USBDevice.setConfigurationMaxPower(500);
  104. } else {
  105. USBDevice.setConfigurationAttribute(0x80 | 0x20);
  106. }
  107. USBDevice.attach();
  108. }
  109. // Persist a new USB personality and reboot into it. The clean DETACH before the reset is the point: a bare
  110. // NVIC_SystemReset drops the D+ pullup only for the ~ms the reset takes -- too briefly for some hosts to
  111. // register a real unplug -- so when switching between DIFFERENT personalities (e.g. Steam <-> Xbox, which are
  112. // distinct VID/PID + class drivers), the OUTGOING device can linger as a ghost until a physical replug, and
  113. // any input held at the instant of the switch stays stuck on it. Detaching first makes the reboot behave like
  114. // that replug: the host tears the old device down (releasing all its input) before the new one enumerates.
  115. // Used by every user-facing mode change (back-4 chord, WebUSB panel, CDC console).
  116. void modeSwitchReboot(uint8_t mode)
  117. {
  118. if (modeValid(mode))
  119. saveMode(
  120. mode); // 0xFF / invalid => keep the current mode (bond-import reboot)
  121. if (USBDevice.mounted())
  122. USBDevice.detach();
  123. delay(60); // let the host see the disconnect before the pullup returns on reset
  124. faultDiagArmIntentionalReset();
  125. NVIC_SystemReset();
  126. }
  127. void setup()
  128. {
  129. // full-board wipe (debug-only "erase everything"): if the panel armed one, this never returns -- it erases
  130. // the app + config/bond + bootloader-settings flash from RAM and resets into the app-less UF2 bootloader.
  131. // Checked before the staged-update apply (both use the meta page; only one can be armed at a time).
  132. // Both are Adafruit-bootloader-specific (UF2 recovery drive + Adafruit-format settings page), so on
  133. // boards without one they're compiled out -- the panel ops that arm them are rejected there too.
  134. #if OPK_HAS_ADAFRUIT_DFU
  135. fwupWipeIfArmed();
  136. // staged firmware update (WebUSB "flash on reboot"): if the panel committed one, this never returns --
  137. // it copies staged->app from RAM and resets into the new firmware. MUST run before anything else touches
  138. // hardware; the board looks dead for the ~5 s the copy takes.
  139. fwupApplyIfArmed();
  140. #endif
  141. genSerial();
  142. ledInit();
  143. #if OPK_PWR_SWITCH
  144. pwrSwitchInit();
  145. #endif
  146. // seed defaults so unbonded slots don't share the discovery address
  147. for (int s = 0; s < NSLOT; s++)
  148. rfGenSessionAddr(s);
  149. InternalFS.begin();
  150. #if OPK_FACTORY_RESET
  151. // Recovery build (-DOPK_FACTORY_RESET=1): wipe ALL persistent storage ONCE on the first boot after flashing,
  152. // then persist normally (tracked by a git-hash tag so it does NOT wipe every boot). See config.h / config.cpp.
  153. factoryResetOnce(OPK_GIT_HASH);
  154. #endif
  155. loadCfg();
  156. loadBonds();
  157. // Lizard (desktop) keyboard/mouse binding table. Custom remapping applies ONLY in pure
  158. // MODE_LIZARD: there we install the user's saved/editable map. In every other mode the
  159. // seamless lizard (Steam mode with Steam closed) uses the built-in DEFAULT map, so edits
  160. // made for pure lizard never change Steam-mode desktop behavior. (Without either call
  161. // g_lizardMap.count stays 0 and lizard produces no keyboard/mouse output at all.)
  162. if (g_usbMode == MODE_LIZARD)
  163. loadLizardMap();
  164. else
  165. defaultLizardMap();
  166. // regenerate per-slot session addresses from each bond UUID (deterministic, stable across reboots)
  167. for (int s = 0; s < NSLOT; s++)
  168. if (g_slot[s].used)
  169. rfGenSessionAddr(s);
  170. // decide USB presentation BEFORE registering interfaces
  171. g_xbox = !modeIsPuck(g_usbMode);
  172. g_active = controllerFor(g_usbMode);
  173. // ---- USB descriptor rebuild ----
  174. // Puck mode drops the CDC serial console (clearConfiguration) to free a USB endpoint for the wake-mouse
  175. // interface that wakes a sleeping Windows host. The one-shot debug arm (g_debugCdcThisBoot, set via WebUSB
  176. // panel / CDC 'D', auto-reverting after one boot) instead KEEPS the boot CDC composite and skips the wake
  177. // mouse (no endpoint room for both). Clean modes always rebuild bare.
  178. const bool puckMode = g_active->isPuck();
  179. const bool keepCdc = puckMode && g_debugCdcThisBoot;
  180. // The "game" PlayStation modes enumerate as a CLEAN single HID gamepad so games that classify off the raw HID
  181. // device (Fortnite/UE GameInput, Windows.Gaming.Input) recognise them as PlayStation. A real Sony pad is just
  182. // the gamepad HID (+ a USB-audio interface); our extra wake-mouse HID and WebUSB vendor interface make those
  183. // classifiers refuse the PS glyph path even with the correct VID/PID (SDL/Steam tolerate the extras). So
  184. // clean-PS modes skip BOTH the wake mouse and WebUSB -- no config panel / host-wake; chord back to Steam
  185. // (back-paddle 4 + A) to reach the panel. Normal MODE_PS5 / MODE_HIDGYRO keep wake + panel.
  186. const bool psClean = modeIsCleanPS(g_usbMode);
  187. const bool consoleClean = modeIsCleanConsole(g_usbMode);
  188. const bool dynamic = g_active->dynamicMount();
  189. if (dynamic) {
  190. // Dynamic mount: present only ACTIVELY-CONNECTED controllers; usbReenumerate re-attaches (no reboot)
  191. // as the set changes. Emulated modes are never puck; clean-PS drops the wake mouse + WebUSB.
  192. s_dynWantWakeMouse = !consoleClean;
  193. s_dynWantWebusb = !consoleClean;
  194. USBDevice.detach();
  195. delay(30);
  196. USBDevice.clearConfiguration();
  197. USBDevice.setConfigurationBuffer(g_usbCfgDesc,
  198. sizeof g_usbCfgDesc);
  199. // Lock TinyUSB HID instance indices ONCE, canonical order: wake mouse (HID 0) then the mode's pool.
  200. if (s_dynWantWakeMouse)
  201. wakeHidBegin();
  202. g_active->beginPool();
  203. if (s_dynWantWebusb)
  204. usb_web.begin();
  205. usbMountEnable(true, g_active->maxSlots());
  206. usbMountRebuildMap(); // initial connected set (usually empty at cold boot)
  207. usbReenumerate(
  208. g_usbMountCount); // build the live descriptor + attach
  209. } else {
  210. USBDevice.detach();
  211. delay(30);
  212. if (keepCdc) {
  213. USBDevice.setConfigurationBuffer(g_usbCfgDesc,
  214. sizeof g_usbCfgDesc);
  215. } else {
  216. USBDevice.clearConfiguration();
  217. // headroom over the default 256 B cap
  218. USBDevice.setConfigurationBuffer(g_usbCfgDesc,
  219. sizeof g_usbCfgDesc);
  220. }
  221. // Distinct USB serial PER MODE (must be set AFTER clearConfiguration, which nulls it). Hosts cache USB
  222. // identity by VID:PID:serial; reusing one serial under a changing VID:PID can make a host refuse the new
  223. // identity. Steam keeps the exact unit serial (its pairing identity); the others get a 1-char suffix.
  224. if (puckMode) {
  225. USBDevice.setSerialDescriptor(g_unit);
  226. } else {
  227. // Bond count in serial so Windows invalidates its cached config descriptor if the count changes.
  228. snprintf(g_usbSerial, sizeof g_usbSerial, "%s%c%d",
  229. g_unit, MODE_SUFFIX[g_usbMode - 1],
  230. bondedSlotCount() > 0 ? bondedSlotCount() : 1);
  231. USBDevice.setSerialDescriptor(g_usbSerial);
  232. }
  233. // SDL3's Proteus/Triton HIDAPI driver only binds slot HIDs on USB interfaces 2..5. Register WebUSB (IF 0)
  234. // and the wake mouse (IF 1) before the four puck slots so hid[0..3] land on IF 2..5 like the real puck.
  235. if (puckMode)
  236. usb_web.begin();
  237. if (puckMode && !keepCdc)
  238. wakeHidBegin();
  239. g_active->begin();
  240. // Boot-mouse wake interface for clean (non-puck) modes, and for puck on the one-shot debug boot (CDC on,
  241. // no endpoint room for wake mouse on a normal puck boot -- wake is registered above instead). Skipped for PS
  242. // modes so the device stays a single clean HID gamepad (see psClean above).
  243. if (!puckMode && !keepCdc && !psClean)
  244. wakeHidBegin();
  245. // WebUSB config panel -- every mode EXCEPT the PlayStation modes. Puck: registered above (IF 0) before wake +
  246. // slots; other clean modes after controller. PS modes omit it to present a genuine single-HID PS controller.
  247. if (!puckMode && !psClean)
  248. usb_web.begin();
  249. // bmAttributes: required(0x80) | remote_wakeup(0x20). Remote Wakeup lets us signal wake-from-sleep.
  250. // The PS3/Sixaxis (the only clean-PS mode on this static path) advertises 0x80 only -- match it so a
  251. // strict console doesn't see a capability the genuine pad never claims.
  252. USBDevice.setConfigurationAttribute(psClean ? 0x80 :
  253. (0x80 | 0x20));
  254. // re-attach with the final descriptor (host re-reads it fresh -> deterministic enumeration)
  255. USBDevice.attach();
  256. }
  257. Serial.begin(115200);
  258. for (int i = 0; i < 300 && !USBDevice.mounted(); i++)
  259. delay(10); // wait up to 3s for USB mount, but NEVER hang
  260. if (USBDevice.suspended()) {
  261. USBDevice.remoteWakeup();
  262. ledWakePulse();
  263. } // wake host if bus was sleeping when we (re-)attached
  264. // Route all device->host HID sends through the usbd task (SOF drain) so loop() never calls tud_* directly
  265. // -> the cross-task blocking-defer that deadlocked the loop under comms load can no longer happen.
  266. usbTxBegin();
  267. webusbInit(); // also drain the WebUSB status blob from the usbd task (its flush() can block loop() too)
  268. hapticInit();
  269. static const char *MODE_NAME[] = {
  270. "STEAM(puck)", "XBOX(xinput+mouse)",
  271. "SWITCH(horipad)", "LIZARD(puck kb/mouse)",
  272. "SWITCH(pro+gyro)", "PS5(dualsense)",
  273. "HIDGYRO(ds4+motion)", "PS5(dualsense,game/clean)",
  274. "DS4(ds4,game/clean)", "PS3(dualshock3/sixaxis)",
  275. "XBOX-OG(controller s)", "DINPUT(joystick+motion)",
  276. "SINPUT(sdl-native)", "SWITCH2(pro/clean)"
  277. };
  278. Serial.printf("# copycat up: unit=%s board=%s, mode=%s\n", g_unit,
  279. g_board,
  280. MODE_NAME[g_usbMode <= MODE_MAX ? g_usbMode : 0]);
  281. // Classify why we (re)booted: distinguishes a watchdog hang from a HardFault from an intentional reboot
  282. // (issue #72 -- those are conflated in the field). Surfaced on the WebUSB panel too.
  283. faultDiagBoot();
  284. // Clock fingerprint: which crystals/oscillators this (possibly clone) board actually came up on. Surfaced
  285. // on the panel so flaky clones can be told apart from the known-good board.
  286. clockDiagBoot();
  287. if (puckMode)
  288. Serial.printf(
  289. "# puck USB: %s\n",
  290. keepCdc ?
  291. "DEBUG boot (CDC console on, wake mouse off; reverts next boot)" :
  292. "normal (CDC off, wake mouse on)");
  293. Serial.printf(
  294. "# session ch%u (discovery on ibex/ch2); per-slot session addrs:\n",
  295. g_sessCh);
  296. for (int s = 0; s < NSLOT; s++) {
  297. if (g_slot[s].used) {
  298. Serial.printf(
  299. "# slot %d: %02X%02X%02X%02X/%02X (uuid %02X%02X%02X%02X %02X%02X%02X%02X)\n",
  300. s, g_sessBase[s][0], g_sessBase[s][1],
  301. g_sessBase[s][2], g_sessBase[s][3],
  302. g_sessPrefix[s], g_slot[s].rec[0],
  303. g_slot[s].rec[1], g_slot[s].rec[2],
  304. g_slot[s].rec[3], g_slot[s].rec[4],
  305. g_slot[s].rec[5], g_slot[s].rec[6],
  306. g_slot[s].rec[7]);
  307. }
  308. }
  309. // Hardware watchdog: if loop() ever stops feeding it (wedged radio busy-wait, HardFault spin, blocked CDC
  310. // write) the WDT resets the nRF52 after ~8s, re-enumerating USB + re-initialising RF, so a hang no longer
  311. // needs a physical replug. RUN keeps it counting in sleep; PAUSE freezes it under a debugger.
  312. NRF_WDT->CONFIG = (WDT_CONFIG_HALT_Pause << WDT_CONFIG_HALT_Pos) |
  313. (WDT_CONFIG_SLEEP_Run << WDT_CONFIG_SLEEP_Pos);
  314. NRF_WDT->CRV = 8UL * 32768UL - 1; // timeout in 32.768 kHz ticks (~8 s)
  315. NRF_WDT->RREN = WDT_RREN_RR0_Msk; // arm reload register 0
  316. NRF_WDT->TASKS_START = 1;
  317. // Capture the stuck PC on the WDT's pre-reset interrupt (software stand-in for SWD on the clone hangs).
  318. faultDiagArmHangCapture();
  319. // Flash black box: at ~6s of loop stall (2s before the WDT reset) dump both tasks' stacked PCs + vitals
  320. // to a reserved flash page -- the post-mortem channel that survives even the boards that wipe .noinit.
  321. faultDiagBlackBoxArm();
  322. }
  323. // loop-timing diagnostics: poll rate is capped by loop ITERATION time (pacing wants 4000us but the poll only
  324. // fires as often as the loop comes around). Per-section us accumulates and each second the avg loop period +
  325. // slowest section are published for the WebUSB panel.
  326. // avg loop iteration time last second (1e6/iterations)
  327. uint16_t g_loopPeriodUs = 0;
  328. // index of the slowest section: 0=webusb 1=ctrl.task 2=serial 3=rfdiag 4=rflink 5=haptic 6=led
  329. uint8_t g_loopWorst = 0;
  330. uint16_t g_loopWorstUs = 0; // that section's avg us per iteration
  331. void loop()
  332. {
  333. // feed the watchdog; if we ever stop, the ~8s WDT auto-resets us
  334. NRF_WDT->RR[0] = WDT_RR_RR_Reload;
  335. faultDiagBeat(); // loop heartbeat -- the SOF blob reports ms-since-beat so a wedge is visible live
  336. faultDiagStackTick(); // per-task stack headroom (self-gated ~1Hz) -- usbd-overflow hypothesis check
  337. faultDiagFlightTick(); // flight recorder: refresh vitals + heartbeat crumb + live CDC vitals line
  338. hapticStabTask(); // stability-test keepalive buzz (no-op unless armed via WebUSB)
  339. // cross-check HFCLK(micros) vs LFCLK(millis) once a second -- cheap, both builds (clone clock diagnostic)
  340. clockDiagTick();
  341. if (g_dirty) {
  342. g_dirty = false;
  343. // A bond flash write erases+programs a page with interrupts effectively stalled for ms -- a known
  344. // watchdog-hang class on slow-flash clones, so leave a crumb bracketing it in the recorder.
  345. faultDiagTrace(FR_SAVE, 0);
  346. saveBonds();
  347. }
  348. #if OPK_LOG
  349. // Diagnostic build: time each loop section so the panel can show the slowest one + the loop period.
  350. static uint32_t acc[7] = { 0 };
  351. static uint32_t loops = 0;
  352. static unsigned long secMs = 0;
  353. uint32_t t;
  354. t = micros();
  355. faultDiagSetStage(0);
  356. webusbPoll();
  357. acc[0] += (uint32_t)(micros() - t);
  358. t = micros();
  359. faultDiagSetStage(1);
  360. if (g_active)
  361. g_active->task();
  362. acc[1] += (uint32_t)(micros() - t);
  363. t = micros();
  364. faultDiagSetStage(2);
  365. serialConsolePoll();
  366. acc[2] += (uint32_t)(micros() - t);
  367. t = micros();
  368. faultDiagSetStage(3);
  369. rfDiagTask();
  370. acc[3] += (uint32_t)(micros() - t);
  371. t = micros();
  372. faultDiagSetStage(4);
  373. rfLinkTask();
  374. acc[4] += (uint32_t)(micros() - t);
  375. t = micros();
  376. faultDiagSetStage(5);
  377. hapticTask();
  378. acc[5] += (uint32_t)(micros() - t);
  379. t = micros();
  380. faultDiagSetStage(6);
  381. ledTask();
  382. acc[6] += (uint32_t)(micros() - t);
  383. #if OPK_PWR_SWITCH
  384. pwrSwitchTask();
  385. #endif
  386. faultDiagSetStage(7);
  387. usbMountTask(); // dynamic mount/unmount of connected controllers (no-op unless enabled)
  388. faultDiagSetStage(8);
  389. usbTxPump(); // drain queued device->host reports HERE, in loop -- never off-loop (jitters the RF poll)
  390. puckCmdLogDrain(); // print captured USB feature commands (diagnostic; no-op unless g_cmdCapture)
  391. loops++;
  392. if (millis() - secMs >= 1000) {
  393. g_loopPeriodUs = loops ? (uint16_t)(1000000UL / loops) : 0;
  394. uint8_t wi = 0;
  395. uint32_t wv = 0;
  396. for (int i = 0; i < 7; i++) {
  397. if (acc[i] > wv) {
  398. wv = acc[i];
  399. wi = i;
  400. }
  401. acc[i] = 0;
  402. }
  403. g_loopWorst = wi;
  404. g_loopWorstUs = loops ? (uint16_t)(wv / loops) : 0;
  405. loops = 0;
  406. secMs = millis();
  407. }
  408. #else
  409. // faultDiagSetStage breadcrumb (GPREGRET2) before each stage: if loop() wedges, the watchdog reset's next
  410. // boot reports which stage was stuck. Cheap (one register write each).
  411. faultDiagSetStage(0);
  412. webusbPoll();
  413. faultDiagSetStage(1);
  414. if (g_active)
  415. g_active->task();
  416. faultDiagSetStage(2);
  417. serialConsolePoll();
  418. faultDiagSetStage(3);
  419. rfDiagTask();
  420. faultDiagSetStage(4);
  421. rfLinkTask();
  422. faultDiagSetStage(5);
  423. hapticTask();
  424. faultDiagSetStage(6);
  425. ledTask();
  426. #if OPK_PWR_SWITCH
  427. pwrSwitchTask();
  428. #endif
  429. faultDiagSetStage(7);
  430. usbMountTask(); // dynamic mount/unmount of connected controllers (no-op unless enabled)
  431. faultDiagSetStage(8);
  432. usbTxPump(); // drain queued device->host reports HERE, in loop -- never off-loop (jitters the RF poll)
  433. puckCmdLogDrain(); // print captured USB feature commands (diagnostic; no-op unless g_cmdCapture)
  434. #endif
  435. }