Browse Source

Fix errant wake issue

Jariullah Safi 4 months ago
parent
commit
8b29802aee
5 changed files with 77 additions and 16 deletions
  1. 24 5
      ARCHITECTURE.md
  2. 4 0
      OpenPuck/OpenPuck.ino
  3. 17 0
      OpenPuck/radio.cpp
  4. 8 0
      OpenPuck/radio.h
  5. 24 11
      OpenPuck/rf_link.cpp

+ 24 - 5
ARCHITECTURE.md

@@ -137,11 +137,16 @@ path forwards the puck's raw `0x45` body untouched so Steam does its own trackpa
 Full details are in `docs/PROTOCOL.md`; the register-level constants and their provenance are commented in
 `radio.cpp` and `rf_link.cpp`. The essentials:
 
-- **PHY/framing** (decoded from real-puck capture): `Ble_2Mbit`, big-endian, whitening off, 5-byte address
-  `"ibex"`, CRC16 `0x11021`/`0xFFFF` (address included). On-air address is the bit-reversed stored bytes.
-- **Bonding/reconnect**: we transmit a *host frame* on the rendezvous address (channel 2) carrying the bonded
-  controller's UUIDs plus the session base/prefix/channel we want it to use. The controller filters by UUID
-  and connects on the clean session channel (default 18).
+- **PHY/framing** (decoded from real-puck capture): `Ble_2Mbit`, big-endian, whitening off, 5-byte address,
+  CRC16 `0x11021`/`0xFFFF` (address included). On-air address is the bit-reversed stored bytes.
+- **Two addresses — discovery vs session**: the *discovery/rendezvous* address is the shared, controller-known
+  `"ibex"` on channel 2; every puck uses it so any controller can find any puck. The *session* address is
+  **unique per device** (`g_sessBase`/`g_sessPrefix`, derived from the FICR DEVICEID in `rfGenSessionAddr()`).
+  This mirrors the real puck and is what keeps two OpenPucks from colliding on-air (see "Multi-puck isolation").
+- **Bonding/reconnect**: we transmit a *host frame* — on the shared `"ibex"` rendezvous (ch2) for a searching
+  controller, and on our unique session address as a keepalive once connected. Both carry the bonded
+  controller's UUIDs plus the **session** base/prefix/channel we want it to adopt. The controller filters by
+  UUID, adopts the advertised session address, and the connected poll then runs there (default channel 18).
 - **Connected poll**: we are the PTX/master. We announce host-awake (`E7 00 00`), then each poll cycle send
   `E3` + a GET-report-`0x45` sub-TLV; the controller replies with a `0xF1` input report containing TLVs
   (type 6 = the HID report `0x45`). Cycling the ESB PID on the poll is what drains the controller's report
@@ -184,6 +189,20 @@ interface set. By default every cold boot returns to Steam mode unless "persist
   appear twice and the second occurrence is currently unreachable (preserved verbatim from the original; worth
   cleaning up if those commands are ever needed).
 
+## Multi-puck isolation
+
+Because the discovery address is shared, two things prevent one puck from disturbing another:
+
+1. **Unique session address.** After bonding, each puck moves the controller onto its own per-device session
+   base/prefix (advertised in the host frame). The connected poll and its RX windows run there, so a puck only
+   ever hears its own controller — not a neighbor's session traffic.
+2. **Reply-type gate.** The link-alive timestamp (`g_connReplyMs`, which drives the "controller reconnected →
+   wake the host" remote-wakeup) is stamped only on genuine controller replies (F-type: `0xF1`/`0xF2`/`0xF3`),
+   never on a puck→controller frame (E-type: `0xE1` host frame, `0xE2/E3/E7`). This is belt-and-suspenders: it
+   means even a neighbor's `0xE1` discovery beacon picked up on the shared rendezvous can't be mistaken for a
+   reconnect. (Before these two fixes, plugging a second puck into another computer would wake a sleeping host
+   running the first puck.)
+
 ## RF reverse-engineering tooling
 
 `rf_diag.cpp` holds everything used to *discover* the protocol — promiscuous capture, config sweeps, bit-exact

+ 4 - 0
OpenPuck/OpenPuck.ino

@@ -22,6 +22,7 @@ using namespace Adafruit_LittleFS_Namespace;
 #include "config.h"
 #include "identity.h"
 #include "bonds.h"
+#include "radio.h"
 #include "controllers.h"
 #include "haptics.h"
 #include "rf_link.h"
@@ -37,6 +38,7 @@ static uint8_t g_usbCfgDesc[512];   // puck composite (4 HID + WebUSB) exceeds t
 
 void setup() {
   genSerial();
+  rfGenSessionAddr();   // per-device unique RF session address (advertised in the host frame; isolates pucks)
   InternalFS.begin();
   loadCfg(); g_xbox = !modeIsPuck(g_usbMode);   // load persisted config + decide USB presentation BEFORE registering interfaces
   g_active = controllerFor(g_usbMode);
@@ -76,6 +78,8 @@ void setup() {
   hapticInit();   // clear relay/active flags + arm the reconnect block & initial stop burst
   static const char* MODE_NAME[]={"STEAM(puck)","XBOX(xinput+mouse)","SWITCH(horipad)","LIZARD(puck kb/mouse)","SWITCH(pro+gyro)","PS5(dualsense)","HIDGYRO(ds4+motion)"};
   Serial.printf("# copycat up: unit=%s board=%s, mode=%s\n", g_unit, g_board, MODE_NAME[g_usbMode<=MODE_MAX?g_usbMode:0]);
+  Serial.printf("# session addr %02X%02X%02X%02X/%02X ch%u (discovery on ibex/ch2)\n",
+                g_sessBase[0],g_sessBase[1],g_sessBase[2],g_sessBase[3],g_sessPrefix,g_sessCh);
   // Hardware watchdog: if loop() ever stops feeding it (a wedged radio busy-wait, a HardFault spin, a blocked
   // CDC write) the WDT resets the nRF52 after ~8s -- re-enumerating USB and re-initialising RF on its own, so a
   // hang no longer needs a physical replug. RUN keeps it counting in sleep; PAUSE freezes it under a debugger.

+ 17 - 0
OpenPuck/radio.cpp

@@ -10,6 +10,23 @@ uint8_t  g_rfBase[4] = {0x69, 0x62, 0x65, 0x78};  // "ibex"
 // {18,2,80}; trackpad data bursts collide on bad channels -> reply-rate crash. Tunable 'C'.
 uint8_t  g_sessCh = 18;
 
+// pre-init to "ibex"/0x10 as a safe default; rfGenSessionAddr() overwrites with the per-device value at boot.
+uint8_t  g_sessBase[4] = {0x69, 0x62, 0x65, 0x78};
+uint8_t  g_sessPrefix = 0x10;
+
+void rfGenSessionAddr(){
+  // Mix the 64-bit FICR DEVICEID into a 4-byte base + a prefix. Deterministic per chip (stable across reboots,
+  // debuggable) and unique across chips (DEVICEID is unique) -> two OpenPucks never collide on the connected
+  // session. Avoid degenerate address bytes (0x00/0xFF) for clean preamble correlation, and never reproduce
+  // the shared discovery base "ibex" (else the isolation is lost).
+  uint32_t h  = NRF_FICR->DEVICEID[0] * 0x9E3779B1u ^ NRF_FICR->DEVICEID[1];
+  uint32_t h2 = NRF_FICR->DEVICEID[1] * 0x85EBCA6Bu ^ NRF_FICR->DEVICEID[0];
+  for(int i=0;i<4;i++){ uint8_t b=(uint8_t)(h>>(i*8)); if(b==0x00||b==0xFF) b^=0x5A; g_sessBase[i]=b; }
+  uint8_t p=(uint8_t)(h2>>16); if(p==0x00||p==0xFF) p=0x5C; g_sessPrefix=p;
+  if(g_sessBase[0]==g_rfBase[0] && g_sessBase[1]==g_rfBase[1] &&
+     g_sessBase[2]==g_rfBase[2] && g_sessBase[3]==g_rfBase[3]) g_sessBase[0]^=0x80;   // collided with "ibex"
+}
+
 uint8_t  rfrx[80], rftx[80];
 uint32_t g_rfRxCount = 0;
 

+ 8 - 0
OpenPuck/radio.h

@@ -24,6 +24,14 @@ extern uint8_t  g_rfCh;              // current TX/RX channel (hopped during bea
 extern uint8_t  g_rfBase[4];         // "ibex"
 extern uint8_t  g_sessCh;            // connected-session channel: a CLEAN data channel off the congested adv ch2
 
+// Per-device SESSION address (base+prefix). Discovery/rendezvous stays on the SHARED "ibex" address (g_rfBase)
+// so any controller can find us; the host frame then advertises THIS unique address and the controller adopts
+// it for the connected session. Two OpenPucks therefore never share an on-air session address -> no crosstalk,
+// no spurious cross-wake. Derived from the FICR DEVICEID (stable per chip, unique across chips).
+extern uint8_t  g_sessBase[4];
+extern uint8_t  g_sessPrefix;
+void rfGenSessionAddr();
+
 extern uint8_t  rfrx[80], rftx[80];  // RADIO DMA buffers
 extern uint32_t g_rfRxCount;
 

+ 24 - 11
OpenPuck/rf_link.cpp

@@ -50,7 +50,11 @@ static unsigned long g_lastStream = 0;
 // HOST FRAME the bonded controller waits for (IBEX FUN_00019000 verify: b[0]=0x12, b[5]=0xE1, b[6..10]=
 // proteus_uuid, b[10..14]=ibex_uuid). Built like PROTEUS FUN_00027e9a. Sent on the shared rendezvous addr;
 // the controller filters by the uuids in the payload, then connects.
-static void rfHostFrameOnce(int slot){
+// Transmit one host frame. `discovery`=true sends it on the SHARED rendezvous address ("ibex"/ch2) where a
+// searching controller looks; =false sends it on this puck's unique SESSION address (the keepalive once the
+// controller has adopted the session). EITHER way the payload advertises the session base/prefix/channel, so
+// the controller always learns the unique address to connect on.
+static void rfHostFrameOnce(int slot, bool discovery){
   if (slot<0||slot>=NSLOT||!g_slot[slot].used) return;
   uint8_t *rec = g_slot[slot].rec;                       // [proteus_uuid 4][ibex_uuid 4][serial 16]
   // CRC-VALIDATED frame (decoded from real puck): ESB-DPL RAM = [LENGTH][S1=PID][payload(18)]. payload:
@@ -64,9 +68,13 @@ static void rfHostFrameOnce(int slot){
   memcpy(rftx+7, rec+4, 4);            // payload[5..9] ibex_uuid
   rftx[11]=g_sessCh;                   // payload[9] session channel: tell the controller to run the session on
                                        // the clean channel (it adopts buf[0xe]); discovery beacon still TXes on ch2
-  memcpy(rftx+15, g_rfBase, 4);        // payload[13..17] session base
-  rftx[19]=g_rfPrefix;                 // payload[17] session prefix
-  rfConfig(g_rfCh); rfSetAddr(g_rfBase,g_rfPrefix);
+  memcpy(rftx+15, g_sessBase, 4);      // payload[13..17] session base  (the per-device UNIQUE address)
+  rftx[19]=g_sessPrefix;               // payload[17] session prefix
+  // TX address: discovery uses the shared "ibex" rendezvous; the session keepalive uses our unique address
+  // (where the controller now listens). The advertised session params (above) are identical either way.
+  const uint8_t* txBase = discovery ? g_rfBase   : g_sessBase;
+  uint8_t        txPfx  = discovery ? g_rfPrefix : g_sessPrefix;
+  rfConfig(g_rfCh); rfSetAddr(txBase, txPfx);
   NRF_RADIO->PACKETPTR=(uint32_t)rftx;
   NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk|RADIO_SHORTS_END_DISABLE_Msk;
   NRF_RADIO->EVENTS_DISABLED=0; NRF_RADIO->TASKS_TXEN=1;
@@ -87,8 +95,8 @@ static void rfHostFrameOnce(int slot){
 void rfHopTo(uint8_t newCh){
   if(g_connSlot<0 || newCh==g_sessCh) return;
   uint8_t cur=g_sessCh, savedRfCh=g_rfCh;
-  g_sessCh=newCh; g_rfCh=cur;                // host frame now advertises newCh but is TXed on cur
-  for(int k=0;k<6;k++){ rfHostFrameOnce(g_connSlot); delayMicroseconds(700); }
+  g_sessCh=newCh; g_rfCh=cur;                // host frame now advertises newCh but is TXed on cur (session addr)
+  for(int k=0;k<6;k++){ rfHostFrameOnce(g_connSlot, false); delayMicroseconds(700); }
   g_rfCh=savedRfCh;                          // poll + session beacon now run on g_sessCh=newCh
 }
 
@@ -97,7 +105,7 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t* payload, uint8_t plen){
   rftx[0]=plen;                          // LENGTH = payload byte count
   rftx[1]=s1;                            // S1 (type-specific)
   memcpy(rftx+2, payload, plen);         // payload[0]=type byte, then data/TLVs
-  rfConfig(ch); rfSetAddr(g_rfBase,g_rfPrefix);
+  rfConfig(ch); rfSetAddr(g_sessBase,g_sessPrefix);   // connected poll runs on this puck's UNIQUE session addr
   NRF_RADIO->PACKETPTR=(uint32_t)rftx;
   NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk|RADIO_SHORTS_END_DISABLE_Msk;
   NRF_RADIO->EVENTS_DISABLED=0; NRF_RADIO->TASKS_TXEN=1;
@@ -112,9 +120,14 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t* payload, uint8_t plen){
     bool crcok=NRF_RADIO->CRCSTATUS&1; rxlen=rfrx[0];
     if(!crcok){ g_stCrc++; g_qosBad++; }                // reply arrived but CRC failed -> RF quality (channel/interference)
     if (crcok && rxlen && rxlen<=64){                   // F1 report ~46B, so allow up to MAXLEN
-      g_connRx++; g_connReplyMs=millis();               // link alive -> loop() suppresses the redundant E1 beacon
-      if(rfrx[2]==0xF1) g_stF1++;
       uint8_t rtype=rfrx[2];                            // reply type byte (proven offset from captures)
+      // Only OUR controller's replies (F-type: 0xF1 input / 0xF2 disconnect / 0xF3 status) mark the link
+      // alive. Every OpenPuck shares the same RF address "ibex" + CRC config, and a puck transmits host-frame
+      // beacons (0xE1) + polls (0xE2/E3/E7) -- all E-type. Without this gate, puck A receives a SECOND puck's
+      // 0xE1 beacon (e.g. one just plugged into another computer), bumps g_connReplyMs, and the "new RF
+      // connection" wake in rfLinkTask() fires -> the second puck spuriously wakes this sleeping host.
+      if (rtype >= 0xF0){ g_connRx++; g_connReplyMs=millis(); }  // link alive -> loop() suppresses the redundant E1 beacon
+      if(rtype==0xF1) g_stF1++;
       if(rtype==0xF2) g_connCooldown=millis();          // controller disconnecting/powering off -> back off 2.5s
       if(rtype==0xF3){                                  // F3 = controller status/version reply (reply to E7 handshake, byte[6]=version)
         g_stF3++; g_connF3v=rfrx[6];
@@ -229,9 +242,9 @@ void rfLinkTask(){
     bool connNow = (g_connSlot>=0 && millis()-g_connReplyMs < 300);
     // session keepalive on the clean channel: every loop while connecting (fast), every 25ms once connected
     // (every-loop beaconing also hammers the session ch and steals reply slots from the poll)
-    if (millis()-g_lastSessBeacon >= (connNow ? 25u : 0u)) { g_lastSessBeacon=millis(); g_rfCh=g_sessCh; for (int s=0;s<NSLOT;s++) rfHostFrameOnce(s); }
+    if (millis()-g_lastSessBeacon >= (connNow ? 25u : 0u)) { g_lastSessBeacon=millis(); g_rfCh=g_sessCh; for (int s=0;s<NSLOT;s++) rfHostFrameOnce(s, false); }
     // discovery beacon on ch2 (where a searching controller looks): every loop when down, occasionally when up
-    if (millis()-g_lastDisc >= (connNow ? 200u : 0u)) { g_lastDisc=millis(); g_rfCh=2; for (int s=0;s<NSLOT;s++) rfHostFrameOnce(s); }
+    if (millis()-g_lastDisc >= (connNow ? 200u : 0u)) { g_lastDisc=millis(); g_rfCh=2; for (int s=0;s<NSLOT;s++) rfHostFrameOnce(s, true); }
   }
   if (g_connOn && millis()-g_connCooldown > 2500) { rfConnStep(); }            // connected-mode: poll controller, read input
   { static bool wasRfConn=false;                                               // remote wakeup on new RF controller connection