puck_sniffer.ino 26 KB

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