ctrl_link.cpp 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607
  1. #include "ctrl_link.h"
  2. #include "radio.h"
  3. #include "ctrl_bonds.h"
  4. #include "ctrl_usb.h"
  5. #include "report45.h"
  6. #include "deck_input.h"
  7. #include "triton.h"
  8. #include <Adafruit_TinyUSB.h>
  9. #include <Arduino.h>
  10. #include <string.h>
  11. int g_linkSlot = -1;
  12. unsigned long g_linkAliveMs = 0;
  13. // Link is considered up while we've heard a poll within this window (the puck polls ~250 Hz).
  14. #define LINK_ALIVE_MS 1000u
  15. // RX dwell per loop. One transaction (catch a poll, reply) per loop -> ~200 Hz, plenty for input, and
  16. // short enough that USB/CDC servicing isn't starved. Tunable on hardware.
  17. #define RX_WIN_US 4500u
  18. // Time the puck's RX window stays open for our reply; keep the turnaround well under this.
  19. static uint8_t g_seq = 0;
  20. // Last time we caught a frame on the SESSION address (NOT the ch2 discovery beacon). This is the
  21. // truthful "we're actually exchanging with the puck" signal that drives ctrlLinkUp() / the UI
  22. // "available" tile -- hearing the discovery beacon alone does NOT mean the session works.
  23. static unsigned long g_sessRxMs = 0;
  24. // SESSION-CHANNEL HUNT. The E1 host frame advertises a "primary" channel, but that channel mostly
  25. // carries the puck's E1/E2 keepalive -- the ACTIVE session (E3 input poll + our F1 reply) rides a small
  26. // channel set, NOT necessarily the advertised primary (proven by the real-puck capture sniff1.json:
  27. // E1/E2 on ch68, the E3/F1 session on ch48; and by puck_sniffer.ino's session-hunt). So once we adopt a
  28. // session we DWELL across {primary, then these candidates} on the session address until a poll (E2/E3)
  29. // appears, then PARK on that channel. If the parked channel goes dry (QoS hop / puck moved), un-park and
  30. // hunt again. Mirrors puck_sniffer.ino HUNT_CH.
  31. static const uint8_t HUNT_CH[] = { 2, 80, 18, 46, 76, 22, 68, 52, 55, 56, 57 };
  32. static uint8_t g_huntIdx = 0;
  33. static uint8_t g_parkCh = 0; // 0 = hunting; non-zero = parked on this channel
  34. static unsigned long g_huntStepMs = 0;
  35. #define HUNT_DWELL_MS 40u // per-channel dwell while hunting
  36. #define RECONNECT_MS \
  37. 4000u // dry this long -> abandon the session, re-catch E1 on ch2
  38. // connected-mode frame-type diagnostics (reset + printed each second in the status line)
  39. static uint16_t g_cntE2, g_cntE3, g_cntE7, g_cntE4, g_cntOut, g_cntOther;
  40. // E4 hops we actually ADOPTED this second (followed the puck's commanded channel)
  41. static uint16_t g_cntE4follow;
  42. // of the E3 polls: how many were bare/0x45 INPUT polls vs feature/identity GETs (0x83/0xAE/0x87). Tells
  43. // us whether the puck is actually polling for input or stuck looping enumeration GETs.
  44. static uint16_t g_cntE3in, g_cntE3get;
  45. // last non-0x45 report id the puck asked for via an E3 GET sub-TLV (0 = none)
  46. static uint8_t g_getReq = 0;
  47. // distinct channels seen in E4 channel-map commands this second (128-bit) -> reveals the hop pattern:
  48. // a small fixed set is followable; a large/changing set is the unsolved fast-AFH sequence.
  49. static uint8_t g_e4set[16];
  50. // 256-bit: frame types we've already dumped a sample of
  51. static uint8_t g_seenDump[32];
  52. // 256-bit: GET report ids we've dumped a poll+response sample for
  53. static uint8_t g_seenReq[32];
  54. // ---- build replies into rftx (ESB DPL RAM layout: [LENGTH][S1][payload...]) ----
  55. static void buildF1()
  56. {
  57. // payload = [0xF1][tlen][ttype=6][report45(46)] -> length = 3 + REPORT45_LEN
  58. rftx[0] = (uint8_t)(3 + REPORT45_LEN); // LENGTH (payload byte count)
  59. rftx[1] = (uint8_t)((((g_pid++) & 3) << 1) | 1); // S1: PID<<1 | NO_ACK
  60. rftx[2] = 0xF1;
  61. rftx[3] = REPORT45_LEN; // TLV len
  62. rftx[4] = 6; // TLV type 6 (HID report)
  63. // neutral report (idle controller) when not forwarding or input has gone stale
  64. bool fresh = deckForwarding() && (millis() - deckLastInputMs() < 200u);
  65. if (fresh) {
  66. buildReport45(rftx + 5, g_seq++);
  67. } else {
  68. memset(rftx + 5, 0, REPORT45_LEN);
  69. rftx[5] = 0x45;
  70. rftx[6] = g_seq++;
  71. }
  72. }
  73. // Build an F1 carrying a feature/command RESPONSE. The CORRECT on-wire form (sniff1.json idx 2932/2946)
  74. // is a TWO-TLV F1: a type-4 feature TLV FOLLOWED BY the type-6 report-0x45 input TLV. BUT that 78-byte
  75. // frame destabilizes OUR link because the real puck puts our session into an aggressive E4 hop storm
  76. // (the captured real-controller session was calm single-channel, no E4) and the long frame overruns the
  77. // tight per-hop window -> e3in collapses 245->60. Until the hop storm is calmed (the real blocker), use
  78. // the SHORTER single type-6 TLV (keeps the link stable; Steam won't accept it for enumeration, but
  79. // nothing enumerates on an unstable link anyway). Flip TWO_TLV_FEATURE to 1 once the link is calm.
  80. #define TWO_TLV_FEATURE 1
  81. static void buildFeatureF1(uint8_t reqid, const uint8_t *param, uint8_t plen)
  82. {
  83. rftx[1] = (uint8_t)((((g_pid++) & 3) << 1) | 1);
  84. rftx[2] = 0xF1;
  85. uint8_t v[64];
  86. uint8_t vlen = ctrlFeatureResp(reqid, param, plen, v);
  87. if (vlen > 60)
  88. vlen = 60;
  89. #if TWO_TLV_FEATURE
  90. rftx[3] = vlen; // TLV1 len
  91. rftx[4] = 0x04; // TLV1 type (feature/bulk)
  92. memcpy(rftx + 5, v, vlen);
  93. uint8_t *t2 =
  94. rftx + 5 + vlen; // TLV2: report-0x45 input (the link heartbeat)
  95. t2[0] = REPORT45_LEN;
  96. t2[1] = 6;
  97. bool fresh = deckForwarding() && (millis() - deckLastInputMs() < 200u);
  98. if (fresh)
  99. buildReport45(t2 + 2, g_seq++);
  100. else {
  101. memset(t2 + 2, 0, REPORT45_LEN);
  102. t2[2] = 0x45;
  103. t2[3] = g_seq++;
  104. }
  105. rftx[0] = (uint8_t)(1 + 2 + vlen + 2 + REPORT45_LEN);
  106. #else
  107. rftx[3] = vlen; // TLV len
  108. rftx[4] = 6; // type 6 (single-TLV, stable baseline)
  109. memcpy(rftx + 5, v, vlen);
  110. rftx[0] = (uint8_t)(3 + vlen);
  111. #endif
  112. }
  113. static void buildF3()
  114. {
  115. // Reply to the puck's E7 awake/version probe with a STATUS report. The real puck's RX parser
  116. // (proteus FUN_0000ba38, F3 branch) reads the connected-state byte at payload[1] (== rftx[3]) and
  117. // only raises its internal "controller connected" signal if that byte is NONZERO -- so leaving it 0
  118. // makes the puck bond us but never ACTIVATE us (no enumeration). Set payload[1]=1 (connected). Also
  119. // set payload[4] (rftx[6]=1) which is where OpenPuck reads its F3 version, so this works both ways.
  120. rftx[0] = 5;
  121. rftx[1] = (uint8_t)((((g_pid++) & 3) << 1) | 1);
  122. rftx[2] = 0xF3;
  123. // payload[1] = connected state (real puck reads param_2[6]) -- MUST be nonzero
  124. rftx[3] = 1;
  125. rftx[4] = 0;
  126. rftx[5] = 0;
  127. rftx[6] = 1; // payload[4] = version (OpenPuck reads rfrx[6])
  128. }
  129. // Transmit whatever is staged in rftx on the currently-configured channel/address, then return to RX
  130. // config. Mirrors rf_link's TX path; radio is left disabled at exit.
  131. static void txStaged()
  132. {
  133. NRF_RADIO->TASKS_DISABLE = 1;
  134. RWAIT_DISABLED();
  135. NRF_RADIO->EVENTS_DISABLED = 0;
  136. NRF_RADIO->PACKETPTR = (uint32_t)rftx;
  137. NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk |
  138. RADIO_SHORTS_END_DISABLE_Msk;
  139. NRF_RADIO->EVENTS_DISABLED = 0;
  140. NRF_RADIO->TASKS_TXEN = 1;
  141. RWAIT_DISABLED();
  142. NRF_RADIO->EVENTS_DISABLED = 0;
  143. NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
  144. }
  145. // CAMPED continuous RX. The radio stays armed in RX across ctrlLinkTask calls (END_START auto-re-arm) so
  146. // it keeps listening during the loop's CDC servicing instead of being torn down each call. This raises
  147. // the puck-poll catch rate: the real puck AFH-hops AWAY from a channel where our replies look unreliable,
  148. // and the gap-misses (radio off between calls) were making the link look bad. We only (re)configure the
  149. // radio when the address/channel changes or after a reply; otherwise we just poll EVENTS_END.
  150. static bool g_rxArmed = false;
  151. static uint8_t g_rxArmedCh = 0;
  152. static const uint8_t *g_rxArmedBase = nullptr;
  153. static uint8_t g_rxArmedPrefix = 0;
  154. static void armRx(const uint8_t *base, uint8_t prefix, uint8_t ch)
  155. {
  156. NRF_RADIO->TASKS_DISABLE = 1;
  157. RWAIT_DISABLED();
  158. NRF_RADIO->EVENTS_DISABLED = 0;
  159. rfConfig(ch);
  160. rfSetAddr(base, prefix);
  161. NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
  162. rfrx[0] = 0;
  163. NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk |
  164. RADIO_SHORTS_END_START_Msk;
  165. NRF_RADIO->EVENTS_END = 0;
  166. NRF_RADIO->TASKS_RXEN = 1;
  167. g_rxArmed = true;
  168. g_rxArmedCh = ch;
  169. g_rxArmedBase = base;
  170. g_rxArmedPrefix = prefix;
  171. }
  172. // One RX->maybe-reply transaction on (base,prefix,ch). Returns the received type byte (rfrx[2]) or 0.
  173. // `reply` selects whether we answer polls (true once connected) or just listen (discovery). The radio is
  174. // kept CAMPED between calls (see armRx); only re-armed on an address/channel change or after a packet.
  175. static uint8_t rxOnce(const uint8_t *base, uint8_t prefix, uint8_t ch,
  176. uint16_t winUs, bool reply)
  177. {
  178. if (reply) {
  179. // CONNECTED: camp the radio (END_START) so it keeps listening between calls. The dispatch reads
  180. // rfrx + replies INSIDE this function (before re-arm), and the connected caller never reads rfrx
  181. // afterward, so clobbering rfrx on re-arm is safe.
  182. if (!g_rxArmed || g_rxArmedCh != ch || g_rxArmedBase != base ||
  183. g_rxArmedPrefix != prefix)
  184. armRx(base, prefix, ch);
  185. } else {
  186. // DISCOVERY: fresh, NON-camped RX. discoveryScan reads rfrx AFTER we return, so it must not be
  187. // clobbered by an END_START auto-re-arm -- plain READY_START (radio idles after the packet).
  188. g_rxArmed = false;
  189. rfConfig(ch);
  190. rfSetAddr(base, prefix);
  191. NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
  192. rfrx[0] = 0;
  193. NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk;
  194. NRF_RADIO->EVENTS_END = 0;
  195. NRF_RADIO->TASKS_RXEN = 1;
  196. }
  197. uint32_t t0 = micros();
  198. while (!NRF_RADIO->EVENTS_END && (uint32_t)(micros() - t0) < winUs) {
  199. }
  200. uint8_t type = 0;
  201. if (NRF_RADIO->EVENTS_END) {
  202. NRF_RADIO->EVENTS_END = 0;
  203. g_rfRxCount++;
  204. bool crcok = NRF_RADIO->CRCSTATUS & 1;
  205. uint8_t rxlen = rfrx[0];
  206. if (crcok && rxlen && rxlen <= 96) {
  207. type = rfrx[2];
  208. if (reply) {
  209. // DIAGNOSTIC: tally what the puck polls us with, and dump a sample of any frame we
  210. // DON'T answer with input -- this is how we find identity/feature queries (the likely
  211. // reason Steam can't enumerate the controller). Surfaced in the 1Hz status line.
  212. if (type == 0xE2)
  213. g_cntE2++;
  214. else if (type == 0xE3) {
  215. g_cntE3++;
  216. // E3 may carry a GET sub-TLV [len][01=GET][reportid]; split input vs feature GET
  217. if (rxlen >= 4 && rfrx[4] == 0x01 &&
  218. rfrx[5] != 0x45) {
  219. g_getReq = rfrx[5];
  220. g_cntE3get++;
  221. } else
  222. g_cntE3in++;
  223. } else if (type == 0xE7)
  224. g_cntE7++;
  225. else if (type == 0xE4)
  226. g_cntE4++;
  227. else if (type >= 0x80 && type <= 0x89)
  228. g_cntOut++;
  229. else if (type != 0xE1)
  230. g_cntOther++;
  231. // dump first sample of any non-input-poll type (E1/E4/E7/output/other)
  232. if (type != 0xE2 && type != 0xE3 &&
  233. !(g_seenDump[type >> 3] &
  234. (1 << (type & 7)))) {
  235. g_seenDump[type >> 3] |=
  236. (1 << (type & 7));
  237. if (Serial.availableForWrite() > 70) {
  238. Serial.printf(
  239. "# RFtype %02X len%u:",
  240. type, rxlen);
  241. uint8_t m = rxlen + 2 < 26 ?
  242. rxlen + 2 :
  243. 26;
  244. for (uint8_t i = 0; i < m; i++)
  245. Serial.printf("%02X",
  246. rfrx[i]);
  247. Serial.println();
  248. }
  249. }
  250. // E1 = host-frame keepalive (no reply expected); follow channel hops.
  251. if (type == 0xE1) {
  252. uint8_t newCh = rfrx[11];
  253. if (newCh && newCh != g_sessCh)
  254. g_sessCh = newCh;
  255. } else if (type == 0xE4) {
  256. // channel-map update [E4][ch][02][50]. The real puck floods this ~80/s on ONE
  257. // channel right after we report connected (F3) and will NOT advance to E3 input
  258. // polls until it is ACKED -- so reply F3 on the channel we received it (turnaround,
  259. // like E7), THEN adopt <ch> for subsequent polls (keeping the session base/prefix;
  260. // the per-timeslot base/prefix hop generator FUN_000491e0 is NOT required to
  261. // interop -- IBEX_REIMPL_SPEC §5). Earlier we replied nothing here -> the puck
  262. // retransmitted E4 forever -> 4s timeout -> reconnect w/ incremented counter (flap).
  263. uint8_t nc = rfrx[3];
  264. if (rxlen >= 2 && nc >= 1 && nc <= 80) {
  265. g_e4set[nc >> 3] |=
  266. (1 << (nc & 7));
  267. g_cntE4follow++;
  268. if (deckForwarding()) {
  269. // ACK the channel map (F3) on the RX channel
  270. buildF3();
  271. txStaged();
  272. if (!(g_seenDump[0xE4 >>
  273. 3] &
  274. (1 << (0xE4 &
  275. 7))) &&
  276. Serial.availableForWrite() >
  277. 90) {
  278. g_seenDump[0xE4 >>
  279. 3] |=
  280. (1
  281. << (0xE4 &
  282. 7));
  283. Serial.printf(
  284. "# E4 ack ch=%u poll[%02X%02X%02X%02X%02X%02X] f3[%02X%02X%02X%02X%02X%02X%02X]\n",
  285. nc,
  286. rfrx[0],
  287. rfrx[1],
  288. rfrx[2],
  289. rfrx[3],
  290. rfrx[4],
  291. rfrx[5],
  292. rftx[0],
  293. rftx[1],
  294. rftx[2],
  295. rftx[3],
  296. rftx[4],
  297. rftx[5],
  298. rftx[6]);
  299. }
  300. }
  301. if (nc != g_sessCh) {
  302. g_sessCh = nc;
  303. g_parkCh = nc;
  304. g_huntStepMs = millis();
  305. }
  306. g_linkAliveMs = g_sessRxMs =
  307. millis();
  308. }
  309. } else if (!deckForwarding()) {
  310. // NOT forwarding -> stay SILENT (no F3/F1). We still hear the puck's polls
  311. // (so the UI shows the puck "available"), but we never answer, so the puck
  312. // never marks us connected and the HOST shows no controller. The controller
  313. // only appears once the user taps to forward (deckForwarding() -> true).
  314. } else if (type == 0xE7) {
  315. buildF3();
  316. txStaged();
  317. } else if (type >= 0xE0 && type <= 0xEF) {
  318. // E3 GET: if it requests a NON-0x45 report (Steam enumeration -> 0x83
  319. // attrs / 0xAE serial / 0x87 settings), answer THAT report; a bare poll
  320. // or a 0x45 request gets the input report.
  321. if (type == 0xE3 && rxlen >= 4 &&
  322. rfrx[4] == 0x05) {
  323. // Relayed Steam OUTPUT (SET): E3 [tlvlen][05][rid][data]. The puck
  324. // forwards Steam's rumble/haptic/LED writes this way (legacy type-05
  325. // form). Hand rumble (0x80) / haptic pulses (0x81/0x82/0x8F) to the Deck so
  326. // it buzzes; relays are NO_ACK so we DON'T reply.
  327. uint8_t rl =
  328. (rfrx[3] >= 1) ?
  329. (uint8_t)(rfrx[3] -
  330. 1) :
  331. 0;
  332. deckForwardHaptic(rfrx[5],
  333. &rfrx[6], rl);
  334. } else if (type == 0xE3 && rxlen >= 4 &&
  335. rfrx[4] == 0x01 &&
  336. (rfrx[5] == 0x83 ||
  337. rfrx[5] == 0xAE)) {
  338. // only 0x83/0xAE get a feature TLV (matches the real controller,
  339. // sniff1.json); 0x87 / bare / 0x45 -> plain input below.
  340. uint8_t plen =
  341. (rxlen >= 5) ?
  342. (uint8_t)(rxlen -
  343. 4) :
  344. 0;
  345. buildFeatureF1(rfrx[5],
  346. &rfrx[6], plen);
  347. // dump the GET poll + our response once per reqid (verify framing)
  348. uint8_t rq = rfrx[5];
  349. if (!(g_seenReq[rq >> 3] &
  350. (1 << (rq & 7))) &&
  351. Serial.availableForWrite() >
  352. 100) {
  353. g_seenReq[rq >> 3] |=
  354. (1 << (rq & 7));
  355. Serial.printf(
  356. "# GET %02X poll[",
  357. rq);
  358. uint8_t m =
  359. rxlen + 2 < 20 ?
  360. rxlen + 2 :
  361. 20;
  362. for (uint8_t i = 0;
  363. i < m; i++)
  364. Serial.printf(
  365. "%02X",
  366. rfrx[i]);
  367. Serial.print("] resp[");
  368. uint8_t r =
  369. rftx[0] + 2 < 30 ?
  370. rftx[0] +
  371. 2 :
  372. 30;
  373. for (uint8_t i = 0;
  374. i < r; i++)
  375. Serial.printf(
  376. "%02X",
  377. rftx[i]);
  378. Serial.println("]");
  379. }
  380. } else {
  381. buildF1();
  382. }
  383. txStaged();
  384. }
  385. }
  386. // connected: re-camp on the channel the session is NOW on. CRITICAL for hop-following:
  387. // an E4 we just processed set g_parkCh to the puck's NEW channel -- re-arm there
  388. // IMMEDIATELY (not on `ch`, the channel we received the E4 on), else we sit a full loop
  389. // iteration behind and fall off the puck's hop sequence (e3in collapses 144->0 in ~1s).
  390. // discovery: freeze rfrx.
  391. if (reply)
  392. armRx(base, prefix, g_parkCh ? g_parkCh : ch);
  393. else {
  394. NRF_RADIO->TASKS_DISABLE = 1;
  395. RWAIT_DISABLED();
  396. NRF_RADIO->EVENTS_DISABLED = 0;
  397. }
  398. return type;
  399. }
  400. // CRC fail. connected: END_START already re-armed -> stay camped. discovery: stop.
  401. if (!reply) {
  402. NRF_RADIO->TASKS_DISABLE = 1;
  403. RWAIT_DISABLED();
  404. NRF_RADIO->EVENTS_DISABLED = 0;
  405. }
  406. return type;
  407. }
  408. // timeout, no packet. connected: stay camped (END_START keeps RX live). discovery: stop.
  409. if (!reply) {
  410. NRF_RADIO->TASKS_DISABLE = 1;
  411. RWAIT_DISABLED();
  412. NRF_RADIO->EVENTS_DISABLED = 0;
  413. }
  414. return type;
  415. }
  416. // Listen on the shared "ibex"/ch2 rendezvous for a discovery E1 that matches a stored bond. On a match,
  417. // adopt the advertised session base/prefix/channel and switch to connected.
  418. static void discoveryScan()
  419. {
  420. uint8_t t = rxOnce(g_rfBase, g_rfPrefix, 2, RX_WIN_US, false);
  421. if (t != 0xE1)
  422. return;
  423. const uint8_t *proteus =
  424. rfrx + 3; // payload [proteus_uuid 4][ibex_uuid 4]
  425. const uint8_t *ibex = rfrx + 7;
  426. int slot = ctrlBondMatch(proteus, ibex);
  427. if (slot < 0) {
  428. // We hear the puck's beacon but its UUIDs don't match any stored bond -- the controller
  429. // side of the pairing didn't land, or we're paired to a different puck. Very useful signal.
  430. static unsigned long lastMiss = 0;
  431. if (Serial.availableForWrite() > 40 &&
  432. millis() - lastMiss > 1000) {
  433. lastMiss = millis();
  434. Serial.printf(
  435. "# E1 heard, no bond match: puuid=%02X%02X%02X%02X iuuid=%02X%02X%02X%02X\n",
  436. proteus[0], proteus[1], proteus[2], proteus[3],
  437. ibex[0], ibex[1], ibex[2], ibex[3]);
  438. }
  439. return;
  440. }
  441. // adopt the advertised session address (rfHostFrameOnce layout: ch@[11], base@[15..19], prefix@[19])
  442. uint8_t advCh = rfrx[11];
  443. memcpy(g_sessBase, rfrx + 15, 4);
  444. g_sessPrefix = rfrx[19];
  445. if (advCh)
  446. g_sessCh = advCh;
  447. g_linkSlot = slot;
  448. g_linkAliveMs = millis();
  449. g_parkCh = 0; // start hunting the session channel set from scratch
  450. g_huntIdx = 0;
  451. g_huntStepMs = millis();
  452. deckText("link: adopted session");
  453. if (Serial.availableForWrite() > 40)
  454. Serial.printf(
  455. "# LINK adopted slot=%d base=%02X%02X%02X%02X/%02X ch=%u\n",
  456. slot, g_sessBase[0], g_sessBase[1], g_sessBase[2],
  457. g_sessBase[3], g_sessPrefix, g_sessCh);
  458. }
  459. bool ctrlLinkUp()
  460. {
  461. // "up" = we caught a real SESSION-address frame recently (a poll/keepalive on the adopted
  462. // base+channel), not merely the ch2 discovery beacon -- so the UI tile only goes tappable when
  463. // the puck is genuinely polling us.
  464. return g_linkSlot >= 0 && (millis() - g_sessRxMs) < LINK_ALIVE_MS;
  465. }
  466. void ctrlLinkTask()
  467. {
  468. // nothing to do until at least one puck is bonded
  469. bool anyBond = false;
  470. for (int i = 0; i < NBOND; i++)
  471. if (g_bond[i].used)
  472. anyBond = true;
  473. if (!anyBond) {
  474. static unsigned long lastNb = 0;
  475. if (Serial.availableForWrite() > 40 &&
  476. millis() - lastNb > 2000) {
  477. lastNb = millis();
  478. Serial.println(
  479. "# no bonds stored -- pair first (controller never got 0xEE)");
  480. }
  481. g_linkSlot = -1;
  482. return;
  483. }
  484. // 1 Hz status line for a serial monitor: searching vs connected, channel, RX count.
  485. {
  486. static unsigned long stMs = 0;
  487. static uint32_t lastRx = 0;
  488. if (Serial.availableForWrite() > 50 &&
  489. millis() - stMs >= 1000) {
  490. stMs = millis();
  491. Serial.printf(
  492. "# link slot=%d %s ch=%u park=%u rx=%lu/s\n",
  493. g_linkSlot,
  494. ctrlLinkUp() ? "CONNECTED" : "searching",
  495. g_sessCh, g_parkCh,
  496. (unsigned long)(g_rfRxCount - lastRx));
  497. // poll-type histogram. e3in = INPUT polls (we send 0x45); e3get = identity/feature GETs.
  498. // If e3in stays ~0 while e3get is high, the puck is stuck enumerating and never asks for
  499. // input -> the controller can't show as active no matter what we send.
  500. Serial.printf(
  501. "# polls e3in=%u e3get=%u e2=%u e7=%u e4=%u(follow=%u) out=%u oth=%u getReq=%02X\n",
  502. g_cntE3in, g_cntE3get, g_cntE2, g_cntE7,
  503. g_cntE4, g_cntE4follow, g_cntOut, g_cntOther,
  504. g_getReq);
  505. // if the puck flooded E4 this second, list the distinct channels it commanded
  506. if (g_cntE4) {
  507. char cb[160];
  508. int n = 0;
  509. for (int c = 0;
  510. c <= 100 && n < (int)sizeof(cb) - 5; c++)
  511. if (g_e4set[c >> 3] & (1 << (c & 7)))
  512. n += snprintf(cb + n,
  513. sizeof(cb) - n,
  514. "%d ", c);
  515. cb[n] = 0;
  516. Serial.printf("# E4 channels: %s\n", cb);
  517. }
  518. memset(g_e4set, 0, sizeof g_e4set);
  519. g_cntE2 = g_cntE3 = g_cntE7 = g_cntE4 = g_cntOut =
  520. g_cntOther = g_cntE3in = g_cntE3get =
  521. g_cntE4follow = 0;
  522. lastRx = g_rfRxCount;
  523. // re-arm the one-shot frame/GET dumps every ~8s so they stay capturable in the app log
  524. static uint8_t reArm = 0;
  525. if (++reArm >= 8) {
  526. reArm = 0;
  527. memset(g_seenReq, 0, sizeof g_seenReq);
  528. memset(g_seenDump, 0, sizeof g_seenDump);
  529. }
  530. }
  531. }
  532. // No session adopted yet -> hunt the E1 host frame on the shared ch2/"ibex" rendezvous.
  533. if (g_linkSlot < 0) {
  534. discoveryScan();
  535. return;
  536. }
  537. // Adopted a session, but the puck has gone fully dry for a long time -> abandon it and re-catch the
  538. // E1 (its primary channel may have changed). Only after RECONNECT_MS so a hunt sweep has a chance.
  539. if ((millis() - g_linkAliveMs) >= RECONNECT_MS) {
  540. g_linkSlot = -1;
  541. g_parkCh = 0;
  542. g_huntIdx = 0;
  543. deckText("link: lost, rescanning");
  544. if (Serial.availableForWrite() > 40)
  545. Serial.println("# LINK lost, rescanning ch2");
  546. return;
  547. }
  548. // Pick the channel to listen on: parked channel if we have one, else sweep {primary, HUNT_CH...}.
  549. uint8_t tryCh = g_parkCh ? g_parkCh :
  550. (g_huntIdx == 0 ?
  551. g_sessCh :
  552. HUNT_CH[(g_huntIdx - 1) %
  553. (uint8_t)sizeof HUNT_CH]);
  554. uint8_t t = rxOnce(g_sessBase, g_sessPrefix, tryCh, RX_WIN_US, true);
  555. if (t == 0xE1) {
  556. // keepalive beacon on the session base: refreshes the advertised primary channel (rxOnce already
  557. // copied it into g_sessCh) + proves the puck is present, but it is NOT a poll -> keep hunting.
  558. g_linkAliveMs = millis();
  559. } else if (t == 0xE4) {
  560. // E4 already adopted ITS commanded channel inside rxOnce (g_parkCh/g_sessCh = nc). Do NOT
  561. // re-park on the receive channel here -- that would undo the hop-follow. Aliveness already set.
  562. } else if (t >= 0xE0 && t <= 0xEF) {
  563. // a real poll (E2/E3/E7) -> the session lives on THIS channel; PARK and answer it from here.
  564. bool wasUp = (millis() - g_sessRxMs) < LINK_ALIVE_MS;
  565. g_parkCh = tryCh;
  566. g_sessCh = tryCh;
  567. g_linkAliveMs = g_sessRxMs = millis();
  568. if (!wasUp) {
  569. deckText("link: session live");
  570. if (Serial.availableForWrite() > 40)
  571. Serial.printf(
  572. "# SESSION live: type=%02X ch=%u base=%02X%02X%02X%02X/%02X\n",
  573. t, tryCh, g_sessBase[0], g_sessBase[1],
  574. g_sessBase[2], g_sessBase[3],
  575. g_sessPrefix);
  576. }
  577. } else {
  578. // nothing this dwell. parked channel gone dry -> resume hunting (QoS hop / puck moved)
  579. if (g_parkCh && (millis() - g_sessRxMs) >= LINK_ALIVE_MS)
  580. g_parkCh = 0;
  581. if (!g_parkCh && (millis() - g_huntStepMs) >= HUNT_DWELL_MS) {
  582. g_huntStepMs = millis();
  583. g_huntIdx++;
  584. }
  585. }
  586. }