puck_sniffer.ino 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662
  1. // puck_sniffer.ino -- PASSIVE 2.4GHz sniffer for the Valve SC2 puck <-> controller link.
  2. //
  3. // Flash this onto a SECOND nRF52840 board (NOT the one emulating a puck). It never transmits, so it cannot
  4. // disturb the real puck<->controller pairing -- it only listens. It reuses OpenPuck's CRC-validated radio
  5. // config (radio.cpp): PHY Ble_2Mbit, ENDIAN=Big, no whitening, CRC16 0x11021/0xFFFF, 5-byte ESB address.
  6. //
  7. // HOW IT FINDS THE SESSION
  8. // The puck and controller talk on a per-puck SESSION address/channel. The puck advertises those in its E1
  9. // host-frame beacon, which it sends on the SHARED discovery rendezvous ("ibex" / prefix 0x10 / channel 2).
  10. // So the sniffer: (1) ACQUIRE -- listen on ibex/ch2, catch an E1, read session base/prefix/channel from it;
  11. // (2) CAPTURE -- retune to that session address/channel and receive every frame both ways. E1 keepalives on
  12. // the session address re-advertise the channel, so a QoS channel-hop is followed; a stall re-acquires.
  13. //
  14. // Frame in RAM after RX (PCNF0 S0=0/LF=8/S1=3): rfrx[0]=LENGTH(payload bytes), rfrx[1]=S1, rfrx[2..]=payload.
  15. // payload[0] (rfrx[2]) is the opcode: 0xE? = puck->controller, 0xF? = controller->puck.
  16. //
  17. // STREAM (over WebUSB bulk IN), little-endian:
  18. // packet: C0 DE [N] [t_us:4] [ch] [flags] [rssi] [match] [N raw bytes] flags bit0=CRC ok; match=RXMATCH pipe
  19. // status: C1 DE [state][curCh][b0 b1 b2 b3][prefix][cap][rxLo rxHi][advCh][ab0 ab1 ab2 ab3][apfx][lastMatch][dropLo dropHi][bond]
  20. // (state 0=acquire 1=capture; adv* = session params last parsed out of an E1 host frame; drop* =
  21. // frames lost to a full ring -- 0 = lossless; bond bit7=camped on a learned bond, bits4-6=#bonds
  22. // stored, bits0-3=#channels learned for the camped bond; 22 bytes)
  23. // COMMANDS (WebUSB bulk OUT):
  24. // 01 start | 02 stop | 03 re-acquire (back to ibex/ch2, un-camp) | 04 <ch> pin channel (keep current address)
  25. // 05 <b0 b1 b2 b3 pfx ch> pin a full session: force this exact base/prefix/channel (manual override)
  26. // 06 survey | 07 <u0 u1 u2 u3> lock ibex_uuid (auto-camps if its bond is already learned) | 08 forget all bonds
  27. //
  28. // AUTO-CAMP (no manual pinning): the bonded address is stable across reconnects (RE ground truth), so once we've
  29. // seen a controller's E1 once we persist its session (base/prefix/channel) to flash keyed by ibex_uuid and learn
  30. // which channels carry its replies. On a later boot / uuid-lock we camp straight back on it and sweep its learned
  31. // channels, catching the controller's next clean reconnect with no ch2 catch and no manual pin.
  32. //
  33. // Build (Adafruit nRF52 core, same flags as OpenPuck):
  34. // arduino-cli compile -b adafruit:nrf52:feather52840 \
  35. // --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb}" puck_sniffer
  36. #include <Adafruit_TinyUSB.h>
  37. #include <Arduino.h>
  38. #include <Adafruit_LittleFS.h>
  39. #include <InternalFileSystem.h>
  40. #include "radio.h"
  41. using namespace Adafruit_LittleFS_Namespace;
  42. Adafruit_USBD_WebUSB usb_web;
  43. // Forward-declared so Arduino's auto-generated prototypes (inserted before the first function below) can name it;
  44. // the full definition follows under "capture ring".
  45. struct Cap;
  46. // ---- learned-bond store (the "no manual pinning" mechanism) -------------------------------------------------
  47. // RE ground truth (~/nrf-sniffer/protocol): a controller's bonded on-air address is DERIVED AT PAIRING and REUSED
  48. // on every reconnect (reconnect = silent ESB resumption, no fresh address, no guaranteed ch2 re-beacon), and the
  49. // session hops a small STABLE channel map. So once we have observed a bond's address + a working channel ONCE, we
  50. // can camp straight back on it for all future reconnects -- no ch2 catch, no manual `pin session`. We persist what
  51. // we learn to flash keyed by the bond's ibex_uuid, so it even survives unplugging the sniffer.
  52. #define BONDFILE "/sniffbonds.bin"
  53. #define BOND_MAGIC 0x53424E32u // "SBN2"
  54. #define NBONDS 4
  55. #define BOND_NCH \
  56. 6 // learned channels per bond (the map is ~4; a couple of spares)
  57. struct Bond {
  58. uint8_t uuid[4]; // ibex_uuid key; all-zero = empty slot
  59. uint8_t base[4]; // session ESB base
  60. uint8_t prefix; // session ESB prefix
  61. uint8_t primaryCh; // channel last advertised in the bond's E1
  62. uint8_t chans[BOND_NCH]; // channels we've actually seen carry 0xF* replies
  63. uint8_t nchans;
  64. };
  65. static Bond g_bonds[NBONDS];
  66. static int g_curBond = -1; // index of the bond we're camped on (-1 = none)
  67. static bool g_bondDirty =
  68. false; // a learn changed the table -> flush to flash in loop()
  69. static unsigned long g_lastBondSave = 0;
  70. static bool uuidSet(const uint8_t u[4])
  71. {
  72. return (u[0] | u[1] | u[2] | u[3]) != 0;
  73. }
  74. static int bondFind(const uint8_t uuid[4])
  75. {
  76. for (int i = 0; i < NBONDS; i++)
  77. if (uuidSet(g_bonds[i].uuid) &&
  78. memcmp(g_bonds[i].uuid, uuid, 4) == 0)
  79. return i;
  80. return -1;
  81. }
  82. static int bondFirst()
  83. {
  84. for (int i = 0; i < NBONDS; i++)
  85. if (uuidSet(g_bonds[i].uuid))
  86. return i;
  87. return -1;
  88. }
  89. // Find or create the slot for a uuid (evicts slot 0 if the small table is full -- fine for a debug tool).
  90. static int bondAlloc(const uint8_t uuid[4])
  91. {
  92. int i = bondFind(uuid);
  93. if (i >= 0)
  94. return i;
  95. for (int k = 0; k < NBONDS; k++)
  96. if (!uuidSet(g_bonds[k].uuid)) {
  97. memset(&g_bonds[k], 0, sizeof(Bond));
  98. memcpy(g_bonds[k].uuid, uuid, 4);
  99. return k;
  100. }
  101. memset(&g_bonds[0], 0, sizeof(Bond));
  102. memcpy(g_bonds[0].uuid, uuid, 4);
  103. return 0;
  104. }
  105. // Record that channel `ch` carried a real (CRC-ok) controller reply for this bond. One known-good channel is
  106. // already enough to re-catch a reconnect (the controller hops the whole map, so it WILL transmit on it); learning
  107. // the few that appear just makes the re-acquire sweep tighter.
  108. static void bondLearnChan(int idx, uint8_t ch)
  109. {
  110. if (idx < 0 || ch < 1 || ch > 80)
  111. return;
  112. Bond &b = g_bonds[idx];
  113. for (int i = 0; i < b.nchans; i++)
  114. if (b.chans[i] == ch)
  115. return;
  116. if (b.nchans < BOND_NCH) {
  117. b.chans[b.nchans++] = ch;
  118. g_bondDirty = true;
  119. }
  120. }
  121. static void bondsLoad()
  122. {
  123. memset(g_bonds, 0, sizeof g_bonds);
  124. File f(InternalFS);
  125. if (f.open(BONDFILE, FILE_O_READ)) {
  126. uint32_t magic = 0;
  127. if (f.read((uint8_t *)&magic, 4) == 4 && magic == BOND_MAGIC)
  128. f.read((uint8_t *)g_bonds, sizeof g_bonds);
  129. f.close();
  130. }
  131. }
  132. // Flash write blocks for tens of ms -> ONLY ever call from loop() (never the RADIO ISR). The ring absorbs the
  133. // frames captured during the write.
  134. static void bondsSave()
  135. {
  136. InternalFS.remove(BONDFILE);
  137. File f(InternalFS);
  138. if (f.open(BONDFILE, FILE_O_WRITE)) {
  139. uint32_t magic = BOND_MAGIC;
  140. f.write((uint8_t *)&magic, 4);
  141. f.write((uint8_t *)g_bonds, sizeof g_bonds);
  142. f.close();
  143. }
  144. g_bondDirty = false;
  145. g_lastBondSave = millis();
  146. }
  147. // ---- capture ring (single producer + consumer, both loop context -> no locking) ----
  148. struct Cap {
  149. uint32_t t;
  150. uint8_t ch, flags, rssi, match, n;
  151. uint8_t b[96];
  152. };
  153. #define RINGN \
  154. 1024 // ~2 s of headroom at 500 fps so a burst rides out a brief host hiccup without dropping
  155. static Cap g_ring[RINGN];
  156. static volatile uint16_t g_head = 0, g_tail = 0;
  157. static inline uint16_t rnext(uint16_t i)
  158. {
  159. return (uint16_t)((i + 1) % RINGN);
  160. }
  161. static void rxArmInit();
  162. static void tuneDiscovery();
  163. static void tuneSession();
  164. static bool g_streaming = false; // app sent START -> emit packet frames
  165. // g_state/g_curCh are written by loop() (retune) and READ by the RADIO END ISR (which stamps each captured frame
  166. // with the current channel/state) -> volatile so the ISR never sees a cached value.
  167. static volatile uint8_t g_state =
  168. 0; // 0=ACQUIRE (ibex/ch2), 1=CAPTURE (session)
  169. static volatile uint8_t g_curCh = 2;
  170. static uint8_t g_sBase[4] = { 0x69, 0x62, 0x65, 0x78 };
  171. static uint8_t g_sPrefix = 0x10;
  172. static uint8_t g_forceCh =
  173. 0; // !=0 -> user pinned a channel (skip auto-acquire retune)
  174. static bool g_pinSession =
  175. false; // user pinned a FULL session (cmd 05): never auto-retune addr/ch
  176. // cmd 06: stay on ibex/ch2 and stream EVERY E1 beacon, never auto-lock
  177. // (so you can see how many pucks/slots are advertising + their bases)
  178. static bool g_survey = false;
  179. // cmd 07: lock ONLY the E1 whose ibex_uuid (E1 payload bytes, = rfrx[7..10], LE) matches -- the puck beacons one
  180. // E1 per bonded slot and we must pick the CONNECTED controller's. Read its uuid from pairtui (e.g. 0xEF7171B4).
  181. static bool g_haveTarget = false;
  182. static uint8_t g_targetIbex[4] = { 0, 0, 0, 0 };
  183. static volatile unsigned long g_lastRx = 0; // for stall -> sweep/re-acquire
  184. static volatile uint8_t g_lastMatch =
  185. 0; // RXMATCH pipe of the most recent frame (diagnostic)
  186. // Frames lost to a full ring (producer = RADIO ISR outran the loop's USB drain). Incremented in the ISR, surfaced
  187. // in the status frame so capture completeness is MEASURED rather than silently assumed. Lossless = this stays 0.
  188. static volatile uint32_t g_drops = 0;
  189. // What the last E1 host frame ADVERTISED (parsed by the assumed offsets). Reported in status EVEN IF we don't
  190. // lock, so the panel can show whether those offsets are sane (channel in range, address non-degenerate) -- the
  191. // fastest way to tell if auto-acquire is mis-reading the real puck's E1.
  192. static uint8_t g_advCh = 0, g_advBase[4] = { 0, 0, 0, 0 }, g_advPfx = 0;
  193. // CAPTURE-mode stall sweep: instead of jumping straight back to discovery (which abandons the session and
  194. // strands us on ch2 seeing only E1/E2), step through clean candidate channels KEEPING the learned address --
  195. // this follows a QoS channel hop. Only after a couple of fully-dry sweeps do we fall back to discovery.
  196. static const uint8_t SWEEP_CH[] = { 18, 46, 76, 22, 68, 2, 80, 52, 55, 56, 57 };
  197. static uint8_t g_sweepIdx = 0;
  198. static volatile uint8_t g_sweepDry =
  199. 0; // reset in the RADIO ISR on any session frame
  200. // Gazell session-hunt: the puck's 5Hz E1/E2 keepalive trickles in on the primary channel even when the
  201. // controller isn't being actively polled, so "no packets" never fires the sweep. The ACTIVE session (E3 poll +
  202. // 0xF* controller reply) rides a ~3-channel set {primary,2,80} derived from the per-session address. So HUNT on
  203. // "no 0xF* reply" instead: dwell briefly on each of {primary,2,80,+clean candidates} until the controller's
  204. // replies appear, then PARK there. g_lastFrx = last controller (0xF*) frame; g_primaryCh = E1-advertised channel.
  205. static const uint8_t HUNT_CH[] = { 2, 80, 18, 46, 76, 22, 68, 52 };
  206. static uint8_t g_primaryCh = 78, g_huntIdx = 0;
  207. // g_lastFrx is set in the RADIO ISR (on a 0xF* reply) and read by the hunt logic in loop() -> volatile.
  208. static volatile unsigned long g_lastFrx = 0;
  209. static unsigned long g_huntMs = 0;
  210. #define HUNT_DWELL_MS 350u
  211. #define HUNT_NOFRX_MS 1000u
  212. static void tuneDiscovery()
  213. {
  214. g_state = 0;
  215. g_curCh = 2;
  216. rfConfig(2);
  217. rfSetAddr(PAIR_BASE, 0x10);
  218. rxArmInit();
  219. }
  220. // Session RX listens across all 8 pipes on the learned base (rfSetAddrMulti) so a same-base/other-prefix
  221. // controller reply is still caught; RXMATCH tells us which pipe each frame used.
  222. static void tuneSession()
  223. {
  224. g_state = 1;
  225. rfConfig(g_curCh);
  226. rfSetAddrMulti(g_sBase, g_sPrefix);
  227. rxArmInit();
  228. }
  229. // Camp directly on a learned bond (no ch2 discovery): adopt its stored address and start listening on its
  230. // best-known channel. Because the bonded address is stable, this is all that's needed to catch the controller's
  231. // next clean reconnect -- the hunt sweep below walks the bond's learned channels until its 0xF* replies appear.
  232. static void campBond(int idx)
  233. {
  234. if (idx < 0)
  235. return;
  236. Bond &b = g_bonds[idx];
  237. g_curBond = idx;
  238. memcpy(g_sBase, b.base, 4);
  239. g_sPrefix = b.prefix;
  240. if (b.primaryCh >= 1 && b.primaryCh <= 80)
  241. g_primaryCh = b.primaryCh;
  242. // prefer a channel we've actually seen replies on, else the advertised primary
  243. g_curCh = (b.nchans && b.chans[0] >= 1 && b.chans[0] <= 80) ?
  244. b.chans[0] :
  245. g_primaryCh;
  246. g_huntIdx = 0;
  247. g_lastFrx = millis();
  248. tuneSession();
  249. }
  250. // Continuous (gapless) RX: READY->START and END->START keep the radio receiving; ADDRESS->RSSISTART samples
  251. // signal strength for each packet. We poll EVENTS_END and copy rfrx before the next packet can overwrite it.
  252. static void rxArmInit()
  253. {
  254. NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
  255. NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk |
  256. RADIO_SHORTS_END_START_Msk |
  257. RADIO_SHORTS_ADDRESS_RSSISTART_Msk |
  258. RADIO_SHORTS_DISABLED_RSSISTOP_Msk;
  259. NRF_RADIO->EVENTS_END = 0;
  260. NRF_RADIO->EVENTS_ADDRESS = 0;
  261. // Capture the completed frame from the RADIO END interrupt (see RADIO_IRQHandler). The NVIC enable/priority is
  262. // set once in setup(); here we (re)arm the END interrupt source after every retune.
  263. NRF_RADIO->INTENSET = RADIO_INTENSET_END_Msk;
  264. NRF_RADIO->TASKS_RXEN = 1;
  265. }
  266. // Producer = the RADIO END ISR (owns g_head); consumer = loop() (owns g_tail). Lock-free single-producer/
  267. // single-consumer ring: on full we drop the NEWEST frame and COUNT it in g_drops -- the ISR must never touch
  268. // g_tail. (The old code dropped the oldest by advancing g_tail from the producer, which raced the consumer and
  269. // also hid the loss.) g_drops is surfaced in the status frame so a dropped frame is measured, not assumed away.
  270. static void ringPush(uint32_t t, uint8_t ch, bool crcok, uint8_t rssi,
  271. uint8_t match, const uint8_t *b, uint8_t n)
  272. {
  273. uint16_t h = g_head, nx = rnext(h);
  274. if (nx == g_tail) {
  275. g_drops++; // ring full -> drop newest, count it
  276. return;
  277. }
  278. Cap &c = g_ring[h];
  279. c.t = t;
  280. c.ch = ch;
  281. c.flags = crcok ? 1 : 0;
  282. c.rssi = rssi;
  283. c.match = match;
  284. c.n = n;
  285. memcpy(c.b, b, n);
  286. g_head = nx;
  287. }
  288. // RADIO END interrupt -- the ONE latency-critical step, and the reason capture is now lossless.
  289. //
  290. // The radio re-arms into the SAME rfrx buffer (END_START short), so the NEXT packet's payload starts overwriting
  291. // rfrx only ~60us after this END (ramp-up + preamble + address). Snapshotting the just-completed frame HERE -- in
  292. // a high-priority ISR that PREEMPTS the USB writes/flushes running in loop() -- guarantees the copy lands well
  293. // inside that 60us window. The previous design polled EVENTS_END from loop(), where a blocking USB flush could
  294. // delay the copy past 60us and let the next frame overwrite rfrx (a tight poll->reply pair, <100us apart, was the
  295. // danger) -- a silent loss. RSSISAMPLE/CRCSTATUS/RXMATCH belong to the frame whose END we just saw.
  296. //
  297. // The ISR ONLY captures. Interpretation (E1 parse / acquire-lock / QoS hop) is left to processFrame() in loop(),
  298. // so this handler stays a few microseconds long and can't itself become the stall it's meant to prevent.
  299. extern "C" void RADIO_IRQHandler(void)
  300. {
  301. while (NRF_RADIO->EVENTS_END) {
  302. NRF_RADIO->EVENTS_END = 0;
  303. bool crcok = NRF_RADIO->CRCSTATUS & 1;
  304. uint8_t rssi = (uint8_t)(NRF_RADIO->RSSISAMPLE & 0x7F);
  305. uint8_t match = (uint8_t)(NRF_RADIO->RXMATCH & 0x7);
  306. g_lastMatch = match;
  307. uint8_t len = rfrx[0];
  308. uint16_t n = (uint16_t)(2 + len);
  309. if (n > 96)
  310. n = 96; // LENGTH + S1 + payload, clamped
  311. ringPush(micros(), g_curCh, crcok, rssi, match, rfrx,
  312. (uint8_t)n);
  313. g_lastRx = millis();
  314. if (g_state == 1)
  315. // any frame on the session resets the dry-sweep count
  316. g_sweepDry = 0;
  317. if (crcok && len >= 1 && (rfrx[2] & 0xF0) == 0xF0)
  318. // controller reply = ACTIVE session here -> stop hunting, park
  319. g_lastFrx = millis();
  320. }
  321. }
  322. // Interpret ONE captured frame (loop context, fed from the ring). Parses the session params advertised in an E1
  323. // host-frame (payload[0]=0xE1) by the ASSUMED offsets and always records them for the status report -- so the
  324. // panel shows what auto-acquire would lock onto even when we choose not to (lets us eyeball whether the real
  325. // puck's offsets match ours). Returns true if it retuned the radio. This is the exact logic that used to run
  326. // inline in rxPump; it just no longer shares a thread with the latency-sensitive copy.
  327. static bool processFrame(const Cap &c)
  328. {
  329. bool crcok = c.flags & 1;
  330. uint8_t len = c.n >= 1 ? c.b[0] : 0;
  331. uint8_t op = c.n >= 3 ? c.b[2] : 0;
  332. // LEARN: a CRC-ok controller reply (0xF*) on the session we're camped on proves THIS channel carries the
  333. // bond's traffic -> remember it, so the next reconnect can re-acquire straight onto it (no ch2 catch).
  334. if (crcok && g_state == 1 && g_curBond >= 0 && (op & 0xF0) == 0xF0)
  335. bondLearnChan(g_curBond, c.ch);
  336. if (!(crcok && len >= 18 && op == 0xE1))
  337. return false;
  338. g_advCh = c.b[11];
  339. g_advBase[0] = c.b[15];
  340. g_advBase[1] = c.b[16];
  341. g_advBase[2] = c.b[17];
  342. g_advBase[3] = c.b[18];
  343. g_advPfx = c.b[19];
  344. if (g_pinSession || g_survey)
  345. // pinned / survey -> never auto-retune (just record adv)
  346. return false;
  347. // Target a specific bonded slot by its ibex_uuid (E1 bytes [7..10]): the puck beacons one E1 per slot, so skip
  348. // any E1 that isn't the CONNECTED controller's -- otherwise we lock an idle slot's keepalive.
  349. if (g_haveTarget && memcmp(c.b + 7, g_targetIbex, 4) != 0)
  350. return false;
  351. // ACQUIRE: lock onto the advertised session. CAPTURE: a different advertised channel = QoS hop -> follow.
  352. if (g_advCh >= 1 && g_advCh <= 80) {
  353. // LEARN the bonded address from this E1 (keyed by ibex_uuid) and persist it -- this is what lets future
  354. // reconnects be caught with no manual pinning.
  355. int bi = bondAlloc(c.b + 7);
  356. Bond &b = g_bonds[bi];
  357. if (memcmp(b.base, g_advBase, 4) != 0 || b.prefix != g_advPfx ||
  358. b.primaryCh != g_advCh) {
  359. memcpy(b.base, g_advBase, 4);
  360. b.prefix = g_advPfx;
  361. b.primaryCh = g_advCh;
  362. g_bondDirty = true;
  363. }
  364. if (g_state == 0) {
  365. g_curBond = bi;
  366. memcpy(g_sBase, g_advBase, 4);
  367. g_sPrefix = g_advPfx;
  368. g_primaryCh = g_advCh;
  369. g_curCh = g_forceCh ? g_forceCh : g_advCh;
  370. g_huntIdx = 0;
  371. g_lastFrx = millis();
  372. tuneSession();
  373. return true;
  374. } else if (!g_forceCh && g_advCh != g_curCh) {
  375. g_curCh = g_advCh;
  376. tuneSession();
  377. return true;
  378. }
  379. }
  380. return false;
  381. }
  382. // ---- WebUSB stream out ----
  383. static void emitPacket(const Cap &c)
  384. {
  385. uint8_t f[11 + 96];
  386. f[0] = 0xC0;
  387. f[1] = 0xDE;
  388. f[2] = c.n;
  389. f[3] = (uint8_t)c.t;
  390. f[4] = (uint8_t)(c.t >> 8);
  391. f[5] = (uint8_t)(c.t >> 16);
  392. f[6] = (uint8_t)(c.t >> 24);
  393. f[7] = c.ch;
  394. f[8] = c.flags;
  395. f[9] = c.rssi;
  396. f[10] = c.match;
  397. memcpy(f + 11, c.b, c.n);
  398. usb_web.write(f, (uint32_t)(11 + c.n));
  399. }
  400. static void emitStatus()
  401. {
  402. static unsigned long last = 0;
  403. if (millis() - last < 250)
  404. return;
  405. last = millis();
  406. static uint16_t rx = 0;
  407. rx++;
  408. uint32_t drops = g_drops; // snapshot the volatile once
  409. // bond byte: bit7 = camped on a learned bond; bits0-3 = #channels learned for it; bits4-6 = #bonds stored
  410. int nbonds = 0;
  411. for (int i = 0; i < NBONDS; i++)
  412. if (uuidSet(g_bonds[i].uuid))
  413. nbonds++;
  414. uint8_t curN = (g_curBond >= 0) ? g_bonds[g_curBond].nchans : 0;
  415. uint8_t bondInfo = (uint8_t)((g_curBond >= 0 ? 0x80 : 0) |
  416. ((nbonds & 0x7) << 4) | (curN & 0xF));
  417. uint8_t f[22] = { 0xC1,
  418. 0xDE,
  419. g_state,
  420. g_curCh,
  421. g_sBase[0],
  422. g_sBase[1],
  423. g_sBase[2],
  424. g_sBase[3],
  425. g_sPrefix,
  426. (uint8_t)(g_streaming ? 1 : 0),
  427. (uint8_t)rx,
  428. (uint8_t)(rx >> 8),
  429. g_advCh,
  430. g_advBase[0],
  431. g_advBase[1],
  432. g_advBase[2],
  433. g_advBase[3],
  434. g_advPfx,
  435. g_lastMatch,
  436. (uint8_t)drops,
  437. (uint8_t)(drops >> 8),
  438. bondInfo };
  439. usb_web.write(f, sizeof f);
  440. usb_web.flush();
  441. }
  442. // Consume the ring (loop context): interpret each captured frame (E1 parse / acquire-lock / QoS hop) and, when
  443. // streaming, emit it to the host. processFrame() runs even when NOT streaming, because acquisition has to happen
  444. // before Start Cap. Capture itself is the RADIO ISR's job now -- this side just drains, so a slow USB flush here
  445. // can no longer cost us a frame (worst case the ring backs up and g_drops ticks, which the panel shows).
  446. static void consumeRing()
  447. {
  448. bool connected = usb_web.connected();
  449. bool emit = connected && g_streaming;
  450. // bound per-call so the loop stays responsive; the 512-deep ring absorbs anything beyond this
  451. uint16_t budget = 128;
  452. while (budget-- && g_tail != g_head) {
  453. Cap c = g_ring
  454. [g_tail]; // copy out before releasing the slot to the producer
  455. g_tail = rnext(g_tail);
  456. bool retuned = processFrame(
  457. c); // may retune the radio (acquire / channel hop)
  458. if (emit)
  459. emitPacket(c);
  460. // On a retune, stop draining this round: the remaining queued frames were captured on the OLD channel, and
  461. // a flooded ch2 often queues several E1s -- re-applying each would thrash the radio. (The old rxPump did the
  462. // same via an immediate `return`.) The rest drain next loop.
  463. if (retuned)
  464. break;
  465. }
  466. if (connected) {
  467. usb_web.flush();
  468. emitStatus();
  469. }
  470. }
  471. static void handleCmd()
  472. {
  473. while (usb_web.available()) {
  474. int c = usb_web.read();
  475. if (c < 0)
  476. break;
  477. if (c == 0x01) {
  478. g_streaming = true;
  479. g_head = g_tail = 0;
  480. } // START (flush stale ring)
  481. else if (c == 0x02) {
  482. g_streaming = false;
  483. } // STOP
  484. else if (c == 0x03) {
  485. g_forceCh = 0;
  486. g_pinSession = false;
  487. g_survey = false;
  488. g_curBond = -1; // un-camp; relearn from a fresh E1
  489. tuneDiscovery();
  490. } // re-acquire from ibex/ch2
  491. else if (c == 0x06) {
  492. g_survey = !g_survey;
  493. g_forceCh = 0;
  494. g_pinSession = false;
  495. tuneDiscovery();
  496. } // survey: list all E1 beacons on ch2, no lock
  497. // target ibex_uuid: lock only the matching slot's E1
  498. else if (c == 0x07) {
  499. int u0 = usb_web.read(), u1 = usb_web.read(),
  500. u2 = usb_web.read(), u3 = usb_web.read();
  501. if (u0 >= 0 && u1 >= 0 && u2 >= 0 && u3 >= 0) {
  502. g_targetIbex[0] = u0;
  503. g_targetIbex[1] = u1;
  504. g_targetIbex[2] = u2;
  505. g_targetIbex[3] = u3;
  506. g_haveTarget = true;
  507. g_survey = false;
  508. g_forceCh = 0;
  509. g_pinSession = false;
  510. // If we've already learned this controller's bond, camp straight on it (no ch2 catch needed -- the
  511. // bonded address is stable, so its next reconnect lands right here). Otherwise discover it on ch2,
  512. // and processFrame() will persist the bond the first time its E1 is seen.
  513. int bi = bondFind(g_targetIbex);
  514. if (bi >= 0)
  515. campBond(bi);
  516. else
  517. tuneDiscovery();
  518. }
  519. } else if (c == 0x08) {
  520. // forget all learned bonds (start clean)
  521. memset(g_bonds, 0, sizeof g_bonds);
  522. g_curBond = -1;
  523. bondsSave();
  524. tuneDiscovery();
  525. } else if (c == 0x04) {
  526. int ch = usb_web.read();
  527. if (ch >= 1 && ch <= 80) {
  528. g_forceCh = (uint8_t)ch;
  529. g_curCh = (uint8_t)ch;
  530. tuneSession();
  531. }
  532. } // pin channel, keep addr
  533. else if (c == 0x05) { // pin a FULL session: b0 b1 b2 b3 pfx ch
  534. int b0 = usb_web.read(), b1 = usb_web.read(),
  535. b2 = usb_web.read(), b3 = usb_web.read(),
  536. pf = usb_web.read(), ch = usb_web.read();
  537. if (b0 >= 0 && b1 >= 0 && b2 >= 0 && b3 >= 0 &&
  538. pf >= 0 && ch >= 1 && ch <= 80) {
  539. g_sBase[0] = b0;
  540. g_sBase[1] = b1;
  541. g_sBase[2] = b2;
  542. g_sBase[3] = b3;
  543. g_sPrefix = (uint8_t)pf;
  544. g_curCh = (uint8_t)ch;
  545. g_forceCh = (uint8_t)ch;
  546. g_pinSession = true;
  547. tuneSession();
  548. }
  549. }
  550. }
  551. }
  552. void setup()
  553. {
  554. // Standard Adafruit WebUSB setup (keep the default CDC composite; just add the WebUSB vendor interface). No
  555. // landing page -> no Chrome "open <url>" notification. No detach/clearConfiguration: that's OpenPuck-specific
  556. // endpoint juggling and can leave the WebUSB stream un-enabled.
  557. // 'SN' -- distinct PID so the host doesn't confuse it with a puck
  558. USBDevice.setID(0x28DE, 0x534E);
  559. USBDevice.setManufacturerDescriptor("Valve Software");
  560. USBDevice.setProductDescriptor("OpenPuck Sniffer");
  561. usb_web.begin();
  562. Serial.begin(115200);
  563. for (int i = 0; i < 200 && !USBDevice.mounted(); i++)
  564. delay(10);
  565. // Capture is interrupt-driven: the RADIO END ISR snapshots each frame the instant it completes, preempting the
  566. // USB work in loop() so the copy always beats the ~60us buffer-overwrite window. Give it a high priority and
  567. // enable it once here; rxArmInit() arms the END source (INTENSET) after every (re)tune. (No SoftDevice runs, so
  568. // RADIO_IRQn is ours.)
  569. NVIC_SetPriority(RADIO_IRQn, 2);
  570. NVIC_ClearPendingIRQ(RADIO_IRQn);
  571. NVIC_EnableIRQ(RADIO_IRQn);
  572. // harmless (we never TX); leaves the radio tunables at their validated defaults
  573. rfGenSessionAddr();
  574. // Load any bonds learned on a previous run. If we already know a controller's bonded session, camp straight on
  575. // it -- so a clean reconnect is captured the moment the controller powers back on, with NO ch2 catch and NO
  576. // manual pin. (With multiple bonds we wait for the host to `lock ibex` the one you want; a single bond is
  577. // unambiguous, so we adopt it immediately.) Otherwise fall back to ch2 discovery to learn one.
  578. InternalFS.begin();
  579. bondsLoad();
  580. int count = 0, b0 = bondFirst();
  581. for (int i = 0; i < NBONDS; i++)
  582. if (uuidSet(g_bonds[i].uuid))
  583. count++;
  584. if (count == 1)
  585. campBond(
  586. b0); // sole known bond -> adopt immediately (unambiguous)
  587. else
  588. tuneDiscovery(); // 0 bonds, or several -> discover / wait for a uuid lock
  589. g_lastRx = millis();
  590. }
  591. void loop()
  592. {
  593. // Capture now happens in RADIO_IRQHandler (interrupt-driven, gapless), so loop() no longer races to poll the
  594. // radio. It just services host commands and drains the capture ring to USB.
  595. handleCmd();
  596. consumeRing();
  597. // Persist newly-learned bond data, debounced (flash write blocks tens of ms; the ring covers it).
  598. if (g_bondDirty && millis() - g_lastBondSave > 3000)
  599. bondsSave();
  600. // Session HUNT (Gazell). A pinned session (cmd 04/05) is never disturbed. Otherwise, while we're NOT seeing the
  601. // controller's 0xF* replies, sweep the session's channels, KEEPING the (stable, bonded) address, dwelling briefly
  602. // on each. The instant a 0xF* arrives (g_lastFrx) we stop and park. The sweep walks the bond's LEARNED channels
  603. // first (fast re-acquire onto a known-good one), then the advertised primary, then a broad candidate list to
  604. // discover the rest of the map. When a bond is known the address is stable, so we never abandon to ch2 -- we just
  605. // keep sweeping until the controller's reconnect appears; only with NO bond at all do we fall back to discovery
  606. // to re-read an E1. Hunting only runs when there are no replies, so an active capture is never disturbed.
  607. if (g_state == 1 && !g_forceCh && !g_pinSession &&
  608. millis() - g_lastFrx > HUNT_NOFRX_MS &&
  609. millis() - g_huntMs > HUNT_DWELL_MS) {
  610. g_huntMs = millis();
  611. uint8_t learned = (g_curBond >= 0) ? g_bonds[g_curBond].nchans :
  612. 0;
  613. uint8_t total = learned + 1 + (uint8_t)sizeof HUNT_CH;
  614. uint8_t slot = g_huntIdx;
  615. uint8_t ch;
  616. if (slot < learned)
  617. ch = g_bonds[g_curBond].chans[slot];
  618. else if (slot == learned)
  619. ch = g_primaryCh;
  620. else
  621. ch = HUNT_CH[(slot - learned - 1) % (sizeof HUNT_CH)];
  622. g_huntIdx++;
  623. if (g_huntIdx >= total) {
  624. g_huntIdx = 0;
  625. if (g_curBond < 0) {
  626. // nothing learned -> re-read an E1 on the ch2 rendezvous
  627. tuneDiscovery();
  628. g_lastFrx = millis();
  629. g_huntMs = millis();
  630. return;
  631. }
  632. // bonded address is stable -> keep sweeping its set, never abandon to ch2
  633. }
  634. g_curCh = ch;
  635. rfConfig(g_curCh);
  636. rfSetAddrMulti(g_sBase, g_sPrefix);
  637. rxArmInit();
  638. }
  639. }