ctrl_link.cpp 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280
  1. #include "ctrl_link.h"
  2. #include "radio.h"
  3. #include "ctrl_bonds.h"
  4. #include "report45.h"
  5. #include "deck_input.h"
  6. #include "triton.h"
  7. #include <Adafruit_TinyUSB.h>
  8. #include <Arduino.h>
  9. #include <string.h>
  10. int g_linkSlot = -1;
  11. unsigned long g_linkAliveMs = 0;
  12. // Link is considered up while we've heard a poll within this window (the puck polls ~250 Hz).
  13. #define LINK_ALIVE_MS 1000u
  14. // RX dwell per loop. One transaction (catch a poll, reply) per loop -> ~200 Hz, plenty for input, and
  15. // short enough that USB/CDC servicing isn't starved. Tunable on hardware.
  16. #define RX_WIN_US 4500u
  17. // Time the puck's RX window stays open for our reply; keep the turnaround well under this.
  18. static uint8_t g_seq = 0;
  19. // Last time we caught a frame on the SESSION address (NOT the ch2 discovery beacon). This is the
  20. // truthful "we're actually exchanging with the puck" signal that drives ctrlLinkUp() / the UI
  21. // "available" tile -- hearing the discovery beacon alone does NOT mean the session works.
  22. static unsigned long g_sessRxMs = 0;
  23. // SESSION-CHANNEL HUNT. The E1 host frame advertises a "primary" channel, but that channel mostly
  24. // carries the puck's E1/E2 keepalive -- the ACTIVE session (E3 input poll + our F1 reply) rides a small
  25. // channel set, NOT necessarily the advertised primary (proven by the real-puck capture sniff1.json:
  26. // E1/E2 on ch68, the E3/F1 session on ch48; and by puck_sniffer.ino's session-hunt). So once we adopt a
  27. // session we DWELL across {primary, then these candidates} on the session address until a poll (E2/E3)
  28. // appears, then PARK on that channel. If the parked channel goes dry (QoS hop / puck moved), un-park and
  29. // hunt again. Mirrors puck_sniffer.ino HUNT_CH.
  30. static const uint8_t HUNT_CH[] = { 2, 80, 18, 46, 76, 22, 68, 52, 55, 56, 57 };
  31. static uint8_t g_huntIdx = 0;
  32. static uint8_t g_parkCh = 0; // 0 = hunting; non-zero = parked on this channel
  33. static unsigned long g_huntStepMs = 0;
  34. #define HUNT_DWELL_MS 40u // per-channel dwell while hunting
  35. #define RECONNECT_MS \
  36. 4000u // dry this long -> abandon the session, re-catch E1 on ch2
  37. // ---- build replies into rftx (ESB DPL RAM layout: [LENGTH][S1][payload...]) ----
  38. static void buildF1()
  39. {
  40. // payload = [0xF1][tlen][ttype=6][report45(46)] -> length = 3 + REPORT45_LEN
  41. rftx[0] = (uint8_t)(3 + REPORT45_LEN); // LENGTH (payload byte count)
  42. rftx[1] = (uint8_t)((((g_pid++) & 3) << 1) | 1); // S1: PID<<1 | NO_ACK
  43. rftx[2] = 0xF1;
  44. rftx[3] = REPORT45_LEN; // TLV len
  45. rftx[4] = 6; // TLV type 6 (HID report)
  46. // neutral report (idle controller) when not forwarding or input has gone stale
  47. bool fresh = deckForwarding() && (millis() - deckLastInputMs() < 200u);
  48. if (fresh) {
  49. buildReport45(rftx + 5, g_seq++);
  50. } else {
  51. memset(rftx + 5, 0, REPORT45_LEN);
  52. rftx[5] = 0x45;
  53. rftx[6] = g_seq++;
  54. }
  55. }
  56. static void buildF3()
  57. {
  58. // reply to E7 awake/version probe: rf_link reads the version at rfrx[6] (payload byte 4).
  59. rftx[0] = 5;
  60. rftx[1] = (uint8_t)((((g_pid++) & 3) << 1) | 1);
  61. rftx[2] = 0xF3;
  62. rftx[3] = 0;
  63. rftx[4] = 0;
  64. rftx[5] = 0;
  65. rftx[6] = 1; // protocol/version byte
  66. }
  67. // Transmit whatever is staged in rftx on the currently-configured channel/address, then return to RX
  68. // config. Mirrors rf_link's TX path; radio is left disabled at exit.
  69. static void txStaged()
  70. {
  71. NRF_RADIO->TASKS_DISABLE = 1;
  72. RWAIT_DISABLED();
  73. NRF_RADIO->EVENTS_DISABLED = 0;
  74. NRF_RADIO->PACKETPTR = (uint32_t)rftx;
  75. NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk |
  76. RADIO_SHORTS_END_DISABLE_Msk;
  77. NRF_RADIO->EVENTS_DISABLED = 0;
  78. NRF_RADIO->TASKS_TXEN = 1;
  79. RWAIT_DISABLED();
  80. NRF_RADIO->EVENTS_DISABLED = 0;
  81. NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
  82. }
  83. // One RX->maybe-reply transaction on (base,prefix,ch). Returns the received type byte (rfrx[2]) or 0.
  84. // `reply` selects whether we answer polls (true once connected) or just listen (discovery).
  85. static uint8_t rxOnce(const uint8_t *base, uint8_t prefix, uint8_t ch,
  86. uint16_t winUs, bool reply)
  87. {
  88. rfConfig(ch);
  89. rfSetAddr(base, prefix);
  90. NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
  91. rfrx[0] = 0;
  92. NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk;
  93. NRF_RADIO->EVENTS_END = 0;
  94. NRF_RADIO->TASKS_RXEN = 1;
  95. uint32_t t0 = micros();
  96. while (!NRF_RADIO->EVENTS_END && (uint32_t)(micros() - t0) < winUs) {
  97. }
  98. uint8_t type = 0;
  99. if (NRF_RADIO->EVENTS_END) {
  100. NRF_RADIO->EVENTS_END = 0;
  101. g_rfRxCount++;
  102. bool crcok = NRF_RADIO->CRCSTATUS & 1;
  103. uint8_t rxlen = rfrx[0];
  104. if (crcok && rxlen && rxlen <= 96) {
  105. type = rfrx[2];
  106. if (reply) {
  107. // E1 = host-frame keepalive (no reply expected); follow channel hops.
  108. if (type == 0xE1) {
  109. uint8_t newCh = rfrx[11];
  110. if (newCh && newCh != g_sessCh)
  111. g_sessCh = newCh;
  112. } else if (type == 0xE7) {
  113. buildF3();
  114. txStaged();
  115. } else if (type >= 0xE0 && type <= 0xEF) {
  116. // E3 GET (and any other E-type poll): answer with input.
  117. buildF1();
  118. txStaged();
  119. }
  120. }
  121. return type;
  122. }
  123. }
  124. NRF_RADIO->TASKS_DISABLE = 1;
  125. RWAIT_DISABLED();
  126. NRF_RADIO->EVENTS_DISABLED = 0;
  127. return type;
  128. }
  129. // Listen on the shared "ibex"/ch2 rendezvous for a discovery E1 that matches a stored bond. On a match,
  130. // adopt the advertised session base/prefix/channel and switch to connected.
  131. static void discoveryScan()
  132. {
  133. uint8_t t = rxOnce(g_rfBase, g_rfPrefix, 2, RX_WIN_US, false);
  134. if (t != 0xE1)
  135. return;
  136. const uint8_t *proteus =
  137. rfrx + 3; // payload [proteus_uuid 4][ibex_uuid 4]
  138. const uint8_t *ibex = rfrx + 7;
  139. int slot = ctrlBondMatch(proteus, ibex);
  140. if (slot < 0) {
  141. // We hear the puck's beacon but its UUIDs don't match any stored bond -- the controller
  142. // side of the pairing didn't land, or we're paired to a different puck. Very useful signal.
  143. static unsigned long lastMiss = 0;
  144. if (Serial.availableForWrite() > 40 &&
  145. millis() - lastMiss > 1000) {
  146. lastMiss = millis();
  147. Serial.printf(
  148. "# E1 heard, no bond match: puuid=%02X%02X%02X%02X iuuid=%02X%02X%02X%02X\n",
  149. proteus[0], proteus[1], proteus[2], proteus[3],
  150. ibex[0], ibex[1], ibex[2], ibex[3]);
  151. }
  152. return;
  153. }
  154. // adopt the advertised session address (rfHostFrameOnce layout: ch@[11], base@[15..19], prefix@[19])
  155. uint8_t advCh = rfrx[11];
  156. memcpy(g_sessBase, rfrx + 15, 4);
  157. g_sessPrefix = rfrx[19];
  158. if (advCh)
  159. g_sessCh = advCh;
  160. g_linkSlot = slot;
  161. g_linkAliveMs = millis();
  162. g_parkCh = 0; // start hunting the session channel set from scratch
  163. g_huntIdx = 0;
  164. g_huntStepMs = millis();
  165. deckText("link: adopted session");
  166. if (Serial.availableForWrite() > 40)
  167. Serial.printf(
  168. "# LINK adopted slot=%d base=%02X%02X%02X%02X/%02X ch=%u\n",
  169. slot, g_sessBase[0], g_sessBase[1], g_sessBase[2],
  170. g_sessBase[3], g_sessPrefix, g_sessCh);
  171. }
  172. bool ctrlLinkUp()
  173. {
  174. // "up" = we caught a real SESSION-address frame recently (a poll/keepalive on the adopted
  175. // base+channel), not merely the ch2 discovery beacon -- so the UI tile only goes tappable when
  176. // the puck is genuinely polling us.
  177. return g_linkSlot >= 0 && (millis() - g_sessRxMs) < LINK_ALIVE_MS;
  178. }
  179. void ctrlLinkTask()
  180. {
  181. // nothing to do until at least one puck is bonded
  182. bool anyBond = false;
  183. for (int i = 0; i < NBOND; i++)
  184. if (g_bond[i].used)
  185. anyBond = true;
  186. if (!anyBond) {
  187. static unsigned long lastNb = 0;
  188. if (Serial.availableForWrite() > 40 &&
  189. millis() - lastNb > 2000) {
  190. lastNb = millis();
  191. Serial.println(
  192. "# no bonds stored -- pair first (controller never got 0xEE)");
  193. }
  194. g_linkSlot = -1;
  195. return;
  196. }
  197. // 1 Hz status line for a serial monitor: searching vs connected, channel, RX count.
  198. {
  199. static unsigned long stMs = 0;
  200. static uint32_t lastRx = 0;
  201. if (Serial.availableForWrite() > 50 &&
  202. millis() - stMs >= 1000) {
  203. stMs = millis();
  204. Serial.printf(
  205. "# link slot=%d %s ch=%u park=%u rx=%lu/s\n",
  206. g_linkSlot,
  207. ctrlLinkUp() ? "CONNECTED" : "searching",
  208. g_sessCh, g_parkCh,
  209. (unsigned long)(g_rfRxCount - lastRx));
  210. lastRx = g_rfRxCount;
  211. }
  212. }
  213. // No session adopted yet -> hunt the E1 host frame on the shared ch2/"ibex" rendezvous.
  214. if (g_linkSlot < 0) {
  215. discoveryScan();
  216. return;
  217. }
  218. // Adopted a session, but the puck has gone fully dry for a long time -> abandon it and re-catch the
  219. // E1 (its primary channel may have changed). Only after RECONNECT_MS so a hunt sweep has a chance.
  220. if ((millis() - g_linkAliveMs) >= RECONNECT_MS) {
  221. g_linkSlot = -1;
  222. g_parkCh = 0;
  223. g_huntIdx = 0;
  224. deckText("link: lost, rescanning");
  225. if (Serial.availableForWrite() > 40)
  226. Serial.println("# LINK lost, rescanning ch2");
  227. return;
  228. }
  229. // Pick the channel to listen on: parked channel if we have one, else sweep {primary, HUNT_CH...}.
  230. uint8_t tryCh = g_parkCh ? g_parkCh :
  231. (g_huntIdx == 0 ?
  232. g_sessCh :
  233. HUNT_CH[(g_huntIdx - 1) %
  234. (uint8_t)sizeof HUNT_CH]);
  235. uint8_t t = rxOnce(g_sessBase, g_sessPrefix, tryCh, RX_WIN_US, true);
  236. if (t == 0xE1) {
  237. // keepalive beacon on the session base: refreshes the advertised primary channel (rxOnce already
  238. // copied it into g_sessCh) + proves the puck is present, but it is NOT a poll -> keep hunting.
  239. g_linkAliveMs = millis();
  240. } else if (t >= 0xE0 && t <= 0xEF) {
  241. // a real poll (E2/E3/E7) -> the session lives on THIS channel; PARK and answer it from here.
  242. bool wasUp = (millis() - g_sessRxMs) < LINK_ALIVE_MS;
  243. g_parkCh = tryCh;
  244. g_sessCh = tryCh;
  245. g_linkAliveMs = g_sessRxMs = millis();
  246. if (!wasUp) {
  247. deckText("link: session live");
  248. if (Serial.availableForWrite() > 40)
  249. Serial.printf(
  250. "# SESSION live: type=%02X ch=%u base=%02X%02X%02X%02X/%02X\n",
  251. t, tryCh, g_sessBase[0], g_sessBase[1],
  252. g_sessBase[2], g_sessBase[3],
  253. g_sessPrefix);
  254. }
  255. } else {
  256. // nothing this dwell. parked channel gone dry -> resume hunting (QoS hop / puck moved)
  257. if (g_parkCh && (millis() - g_sessRxMs) >= LINK_ALIVE_MS)
  258. g_parkCh = 0;
  259. if (!g_parkCh && (millis() - g_huntStepMs) >= HUNT_DWELL_MS) {
  260. g_huntStepMs = millis();
  261. g_huntIdx++;
  262. }
  263. }
  264. }