|
@@ -16,29 +16,144 @@
|
|
|
//
|
|
//
|
|
|
// STREAM (over WebUSB bulk IN), little-endian:
|
|
// STREAM (over WebUSB bulk IN), little-endian:
|
|
|
// packet: C0 DE [N] [t_us:4] [ch] [flags] [rssi] [match] [N raw bytes] flags bit0=CRC ok; match=RXMATCH pipe
|
|
// packet: C0 DE [N] [t_us:4] [ch] [flags] [rssi] [match] [N raw bytes] flags bit0=CRC ok; match=RXMATCH pipe
|
|
|
-// status: C1 DE [state][curCh][b0 b1 b2 b3][prefix][cap][rxLo rxHi][advCh][ab0 ab1 ab2 ab3][apfx][lastMatch]
|
|
|
|
|
-// (state 0=acquire 1=capture; adv* = session params last parsed out of an E1 host frame; 19 bytes)
|
|
|
|
|
|
|
+// status: C1 DE [state][curCh][b0 b1 b2 b3][prefix][cap][rxLo rxHi][advCh][ab0 ab1 ab2 ab3][apfx][lastMatch][dropLo dropHi][bond]
|
|
|
|
|
+// (state 0=acquire 1=capture; adv* = session params last parsed out of an E1 host frame; drop* =
|
|
|
|
|
+// frames lost to a full ring -- 0 = lossless; bond bit7=camped on a learned bond, bits4-6=#bonds
|
|
|
|
|
+// stored, bits0-3=#channels learned for the camped bond; 22 bytes)
|
|
|
// COMMANDS (WebUSB bulk OUT):
|
|
// COMMANDS (WebUSB bulk OUT):
|
|
|
-// 01 start | 02 stop | 03 re-acquire (back to ibex/ch2) | 04 <ch> pin channel (keep current address)
|
|
|
|
|
-// 05 <b0 b1 b2 b3 pfx ch> pin a full session: force this exact base/prefix/channel (use after reading a real
|
|
|
|
|
-// E1 by eye -- the surest way to camp on the session when auto-acquire's fixed E1 offsets are wrong)
|
|
|
|
|
|
|
+// 01 start | 02 stop | 03 re-acquire (back to ibex/ch2, un-camp) | 04 <ch> pin channel (keep current address)
|
|
|
|
|
+// 05 <b0 b1 b2 b3 pfx ch> pin a full session: force this exact base/prefix/channel (manual override)
|
|
|
|
|
+// 06 survey | 07 <u0 u1 u2 u3> lock ibex_uuid (auto-camps if its bond is already learned) | 08 forget all bonds
|
|
|
|
|
+//
|
|
|
|
|
+// AUTO-CAMP (no manual pinning): the bonded address is stable across reconnects (RE ground truth), so once we've
|
|
|
|
|
+// seen a controller's E1 once we persist its session (base/prefix/channel) to flash keyed by ibex_uuid and learn
|
|
|
|
|
+// which channels carry its replies. On a later boot / uuid-lock we camp straight back on it and sweep its learned
|
|
|
|
|
+// channels, catching the controller's next clean reconnect with no ch2 catch and no manual pin.
|
|
|
//
|
|
//
|
|
|
// Build (Adafruit nRF52 core, same flags as OpenPuck):
|
|
// Build (Adafruit nRF52 core, same flags as OpenPuck):
|
|
|
// arduino-cli compile -b adafruit:nrf52:feather52840 \
|
|
// arduino-cli compile -b adafruit:nrf52:feather52840 \
|
|
|
// --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb}" puck_sniffer
|
|
// --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb}" puck_sniffer
|
|
|
#include <Adafruit_TinyUSB.h>
|
|
#include <Adafruit_TinyUSB.h>
|
|
|
#include <Arduino.h>
|
|
#include <Arduino.h>
|
|
|
|
|
+#include <Adafruit_LittleFS.h>
|
|
|
|
|
+#include <InternalFileSystem.h>
|
|
|
#include "radio.h"
|
|
#include "radio.h"
|
|
|
|
|
+using namespace Adafruit_LittleFS_Namespace;
|
|
|
|
|
|
|
|
Adafruit_USBD_WebUSB usb_web;
|
|
Adafruit_USBD_WebUSB usb_web;
|
|
|
|
|
|
|
|
|
|
+// Forward-declared so Arduino's auto-generated prototypes (inserted before the first function below) can name it;
|
|
|
|
|
+// the full definition follows under "capture ring".
|
|
|
|
|
+struct Cap;
|
|
|
|
|
+
|
|
|
|
|
+// ---- learned-bond store (the "no manual pinning" mechanism) -------------------------------------------------
|
|
|
|
|
+// RE ground truth (~/nrf-sniffer/protocol): a controller's bonded on-air address is DERIVED AT PAIRING and REUSED
|
|
|
|
|
+// on every reconnect (reconnect = silent ESB resumption, no fresh address, no guaranteed ch2 re-beacon), and the
|
|
|
|
|
+// session hops a small STABLE channel map. So once we have observed a bond's address + a working channel ONCE, we
|
|
|
|
|
+// can camp straight back on it for all future reconnects -- no ch2 catch, no manual `pin session`. We persist what
|
|
|
|
|
+// we learn to flash keyed by the bond's ibex_uuid, so it even survives unplugging the sniffer.
|
|
|
|
|
+#define BONDFILE "/sniffbonds.bin"
|
|
|
|
|
+#define BOND_MAGIC 0x53424E32u // "SBN2"
|
|
|
|
|
+#define NBONDS 4
|
|
|
|
|
+#define BOND_NCH 6 // learned channels per bond (the map is ~4; a couple of spares)
|
|
|
|
|
+struct Bond {
|
|
|
|
|
+ uint8_t uuid[4]; // ibex_uuid key; all-zero = empty slot
|
|
|
|
|
+ uint8_t base[4]; // session ESB base
|
|
|
|
|
+ uint8_t prefix; // session ESB prefix
|
|
|
|
|
+ uint8_t primaryCh; // channel last advertised in the bond's E1
|
|
|
|
|
+ uint8_t chans[BOND_NCH]; // channels we've actually seen carry 0xF* replies
|
|
|
|
|
+ uint8_t nchans;
|
|
|
|
|
+};
|
|
|
|
|
+static Bond g_bonds[NBONDS];
|
|
|
|
|
+static int g_curBond = -1; // index of the bond we're camped on (-1 = none)
|
|
|
|
|
+static bool g_bondDirty = false; // a learn changed the table -> flush to flash in loop()
|
|
|
|
|
+static unsigned long g_lastBondSave = 0;
|
|
|
|
|
+
|
|
|
|
|
+static bool uuidSet(const uint8_t u[4])
|
|
|
|
|
+{
|
|
|
|
|
+ return (u[0] | u[1] | u[2] | u[3]) != 0;
|
|
|
|
|
+}
|
|
|
|
|
+static int bondFind(const uint8_t uuid[4])
|
|
|
|
|
+{
|
|
|
|
|
+ for (int i = 0; i < NBONDS; i++)
|
|
|
|
|
+ if (uuidSet(g_bonds[i].uuid) &&
|
|
|
|
|
+ memcmp(g_bonds[i].uuid, uuid, 4) == 0)
|
|
|
|
|
+ return i;
|
|
|
|
|
+ return -1;
|
|
|
|
|
+}
|
|
|
|
|
+static int bondFirst()
|
|
|
|
|
+{
|
|
|
|
|
+ for (int i = 0; i < NBONDS; i++)
|
|
|
|
|
+ if (uuidSet(g_bonds[i].uuid))
|
|
|
|
|
+ return i;
|
|
|
|
|
+ return -1;
|
|
|
|
|
+}
|
|
|
|
|
+// Find or create the slot for a uuid (evicts slot 0 if the small table is full -- fine for a debug tool).
|
|
|
|
|
+static int bondAlloc(const uint8_t uuid[4])
|
|
|
|
|
+{
|
|
|
|
|
+ int i = bondFind(uuid);
|
|
|
|
|
+ if (i >= 0)
|
|
|
|
|
+ return i;
|
|
|
|
|
+ for (int k = 0; k < NBONDS; k++)
|
|
|
|
|
+ if (!uuidSet(g_bonds[k].uuid)) {
|
|
|
|
|
+ memset(&g_bonds[k], 0, sizeof(Bond));
|
|
|
|
|
+ memcpy(g_bonds[k].uuid, uuid, 4);
|
|
|
|
|
+ return k;
|
|
|
|
|
+ }
|
|
|
|
|
+ memset(&g_bonds[0], 0, sizeof(Bond));
|
|
|
|
|
+ memcpy(g_bonds[0].uuid, uuid, 4);
|
|
|
|
|
+ return 0;
|
|
|
|
|
+}
|
|
|
|
|
+// Record that channel `ch` carried a real (CRC-ok) controller reply for this bond. One known-good channel is
|
|
|
|
|
+// already enough to re-catch a reconnect (the controller hops the whole map, so it WILL transmit on it); learning
|
|
|
|
|
+// the few that appear just makes the re-acquire sweep tighter.
|
|
|
|
|
+static void bondLearnChan(int idx, uint8_t ch)
|
|
|
|
|
+{
|
|
|
|
|
+ if (idx < 0 || ch < 1 || ch > 80)
|
|
|
|
|
+ return;
|
|
|
|
|
+ Bond &b = g_bonds[idx];
|
|
|
|
|
+ for (int i = 0; i < b.nchans; i++)
|
|
|
|
|
+ if (b.chans[i] == ch)
|
|
|
|
|
+ return;
|
|
|
|
|
+ if (b.nchans < BOND_NCH) {
|
|
|
|
|
+ b.chans[b.nchans++] = ch;
|
|
|
|
|
+ g_bondDirty = true;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+static void bondsLoad()
|
|
|
|
|
+{
|
|
|
|
|
+ memset(g_bonds, 0, sizeof g_bonds);
|
|
|
|
|
+ File f(InternalFS);
|
|
|
|
|
+ if (f.open(BONDFILE, FILE_O_READ)) {
|
|
|
|
|
+ uint32_t magic = 0;
|
|
|
|
|
+ if (f.read((uint8_t *)&magic, 4) == 4 && magic == BOND_MAGIC)
|
|
|
|
|
+ f.read((uint8_t *)g_bonds, sizeof g_bonds);
|
|
|
|
|
+ f.close();
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+// Flash write blocks for tens of ms -> ONLY ever call from loop() (never the RADIO ISR). The ring absorbs the
|
|
|
|
|
+// frames captured during the write.
|
|
|
|
|
+static void bondsSave()
|
|
|
|
|
+{
|
|
|
|
|
+ InternalFS.remove(BONDFILE);
|
|
|
|
|
+ File f(InternalFS);
|
|
|
|
|
+ if (f.open(BONDFILE, FILE_O_WRITE)) {
|
|
|
|
|
+ uint32_t magic = BOND_MAGIC;
|
|
|
|
|
+ f.write((uint8_t *)&magic, 4);
|
|
|
|
|
+ f.write((uint8_t *)g_bonds, sizeof g_bonds);
|
|
|
|
|
+ f.close();
|
|
|
|
|
+ }
|
|
|
|
|
+ g_bondDirty = false;
|
|
|
|
|
+ g_lastBondSave = millis();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// ---- capture ring (single producer + consumer, both loop context -> no locking) ----
|
|
// ---- capture ring (single producer + consumer, both loop context -> no locking) ----
|
|
|
struct Cap {
|
|
struct Cap {
|
|
|
uint32_t t;
|
|
uint32_t t;
|
|
|
uint8_t ch, flags, rssi, match, n;
|
|
uint8_t ch, flags, rssi, match, n;
|
|
|
uint8_t b[96];
|
|
uint8_t b[96];
|
|
|
};
|
|
};
|
|
|
-#define RINGN 512
|
|
|
|
|
|
|
+#define RINGN 1024 // ~2 s of headroom at 500 fps so a burst rides out a brief host hiccup without dropping
|
|
|
static Cap g_ring[RINGN];
|
|
static Cap g_ring[RINGN];
|
|
|
static volatile uint16_t g_head = 0, g_tail = 0;
|
|
static volatile uint16_t g_head = 0, g_tail = 0;
|
|
|
static inline uint16_t rnext(uint16_t i)
|
|
static inline uint16_t rnext(uint16_t i)
|
|
@@ -51,8 +166,10 @@ static void tuneDiscovery();
|
|
|
static void tuneSession();
|
|
static void tuneSession();
|
|
|
|
|
|
|
|
static bool g_streaming = false; // app sent START -> emit packet frames
|
|
static bool g_streaming = false; // app sent START -> emit packet frames
|
|
|
-static uint8_t g_state = 0; // 0=ACQUIRE (ibex/ch2), 1=CAPTURE (session)
|
|
|
|
|
-static uint8_t g_curCh = 2;
|
|
|
|
|
|
|
+// g_state/g_curCh are written by loop() (retune) and READ by the RADIO END ISR (which stamps each captured frame
|
|
|
|
|
+// with the current channel/state) -> volatile so the ISR never sees a cached value.
|
|
|
|
|
+static volatile uint8_t g_state = 0; // 0=ACQUIRE (ibex/ch2), 1=CAPTURE (session)
|
|
|
|
|
+static volatile uint8_t g_curCh = 2;
|
|
|
static uint8_t g_sBase[4] = { 0x69, 0x62, 0x65, 0x78 };
|
|
static uint8_t g_sBase[4] = { 0x69, 0x62, 0x65, 0x78 };
|
|
|
static uint8_t g_sPrefix = 0x10;
|
|
static uint8_t g_sPrefix = 0x10;
|
|
|
static uint8_t g_forceCh =
|
|
static uint8_t g_forceCh =
|
|
@@ -67,9 +184,12 @@ static bool g_survey = false;
|
|
|
// E1 per bonded slot and we must pick the CONNECTED controller's. Read its uuid from pairtui (e.g. 0xEF7171B4).
|
|
// E1 per bonded slot and we must pick the CONNECTED controller's. Read its uuid from pairtui (e.g. 0xEF7171B4).
|
|
|
static bool g_haveTarget = false;
|
|
static bool g_haveTarget = false;
|
|
|
static uint8_t g_targetIbex[4] = { 0, 0, 0, 0 };
|
|
static uint8_t g_targetIbex[4] = { 0, 0, 0, 0 };
|
|
|
-static unsigned long g_lastRx = 0; // for stall -> sweep/re-acquire
|
|
|
|
|
-static uint8_t g_lastMatch =
|
|
|
|
|
|
|
+static volatile unsigned long g_lastRx = 0; // for stall -> sweep/re-acquire
|
|
|
|
|
+static volatile uint8_t g_lastMatch =
|
|
|
0; // RXMATCH pipe of the most recent frame (diagnostic)
|
|
0; // RXMATCH pipe of the most recent frame (diagnostic)
|
|
|
|
|
+// Frames lost to a full ring (producer = RADIO ISR outran the loop's USB drain). Incremented in the ISR, surfaced
|
|
|
|
|
+// in the status frame so capture completeness is MEASURED rather than silently assumed. Lossless = this stays 0.
|
|
|
|
|
+static volatile uint32_t g_drops = 0;
|
|
|
|
|
|
|
|
// What the last E1 host frame ADVERTISED (parsed by the assumed offsets). Reported in status EVEN IF we don't
|
|
// What the last E1 host frame ADVERTISED (parsed by the assumed offsets). Reported in status EVEN IF we don't
|
|
|
// lock, so the panel can show whether those offsets are sane (channel in range, address non-degenerate) -- the
|
|
// lock, so the panel can show whether those offsets are sane (channel in range, address non-degenerate) -- the
|
|
@@ -80,7 +200,8 @@ static uint8_t g_advCh = 0, g_advBase[4] = { 0, 0, 0, 0 }, g_advPfx = 0;
|
|
|
// strands us on ch2 seeing only E1/E2), step through clean candidate channels KEEPING the learned address --
|
|
// strands us on ch2 seeing only E1/E2), step through clean candidate channels KEEPING the learned address --
|
|
|
// this follows a QoS channel hop. Only after a couple of fully-dry sweeps do we fall back to discovery.
|
|
// this follows a QoS channel hop. Only after a couple of fully-dry sweeps do we fall back to discovery.
|
|
|
static const uint8_t SWEEP_CH[] = { 18, 46, 76, 22, 68, 2, 80, 52, 55, 56, 57 };
|
|
static const uint8_t SWEEP_CH[] = { 18, 46, 76, 22, 68, 2, 80, 52, 55, 56, 57 };
|
|
|
-static uint8_t g_sweepIdx = 0, g_sweepDry = 0;
|
|
|
|
|
|
|
+static uint8_t g_sweepIdx = 0;
|
|
|
|
|
+static volatile uint8_t g_sweepDry = 0; // reset in the RADIO ISR on any session frame
|
|
|
|
|
|
|
|
// Gazell session-hunt: the puck's 5Hz E1/E2 keepalive trickles in on the primary channel even when the
|
|
// Gazell session-hunt: the puck's 5Hz E1/E2 keepalive trickles in on the primary channel even when the
|
|
|
// controller isn't being actively polled, so "no packets" never fires the sweep. The ACTIVE session (E3 poll +
|
|
// controller isn't being actively polled, so "no packets" never fires the sweep. The ACTIVE session (E3 poll +
|
|
@@ -89,7 +210,9 @@ static uint8_t g_sweepIdx = 0, g_sweepDry = 0;
|
|
|
// replies appear, then PARK there. g_lastFrx = last controller (0xF*) frame; g_primaryCh = E1-advertised channel.
|
|
// replies appear, then PARK there. g_lastFrx = last controller (0xF*) frame; g_primaryCh = E1-advertised channel.
|
|
|
static const uint8_t HUNT_CH[] = { 2, 80, 18, 46, 76, 22, 68, 52 };
|
|
static const uint8_t HUNT_CH[] = { 2, 80, 18, 46, 76, 22, 68, 52 };
|
|
|
static uint8_t g_primaryCh = 78, g_huntIdx = 0;
|
|
static uint8_t g_primaryCh = 78, g_huntIdx = 0;
|
|
|
-static unsigned long g_lastFrx = 0, g_huntMs = 0;
|
|
|
|
|
|
|
+// g_lastFrx is set in the RADIO ISR (on a 0xF* reply) and read by the hunt logic in loop() -> volatile.
|
|
|
|
|
+static volatile unsigned long g_lastFrx = 0;
|
|
|
|
|
+static unsigned long g_huntMs = 0;
|
|
|
#define HUNT_DWELL_MS 350u
|
|
#define HUNT_DWELL_MS 350u
|
|
|
#define HUNT_NOFRX_MS 1000u
|
|
#define HUNT_NOFRX_MS 1000u
|
|
|
|
|
|
|
@@ -110,6 +233,27 @@ static void tuneSession()
|
|
|
rfSetAddrMulti(g_sBase, g_sPrefix);
|
|
rfSetAddrMulti(g_sBase, g_sPrefix);
|
|
|
rxArmInit();
|
|
rxArmInit();
|
|
|
}
|
|
}
|
|
|
|
|
+// Camp directly on a learned bond (no ch2 discovery): adopt its stored address and start listening on its
|
|
|
|
|
+// best-known channel. Because the bonded address is stable, this is all that's needed to catch the controller's
|
|
|
|
|
+// next clean reconnect -- the hunt sweep below walks the bond's learned channels until its 0xF* replies appear.
|
|
|
|
|
+static void campBond(int idx)
|
|
|
|
|
+{
|
|
|
|
|
+ if (idx < 0)
|
|
|
|
|
+ return;
|
|
|
|
|
+ Bond &b = g_bonds[idx];
|
|
|
|
|
+ g_curBond = idx;
|
|
|
|
|
+ memcpy(g_sBase, b.base, 4);
|
|
|
|
|
+ g_sPrefix = b.prefix;
|
|
|
|
|
+ if (b.primaryCh >= 1 && b.primaryCh <= 80)
|
|
|
|
|
+ g_primaryCh = b.primaryCh;
|
|
|
|
|
+ // prefer a channel we've actually seen replies on, else the advertised primary
|
|
|
|
|
+ g_curCh = (b.nchans && b.chans[0] >= 1 && b.chans[0] <= 80) ?
|
|
|
|
|
+ b.chans[0] :
|
|
|
|
|
+ g_primaryCh;
|
|
|
|
|
+ g_huntIdx = 0;
|
|
|
|
|
+ g_lastFrx = millis();
|
|
|
|
|
+ tuneSession();
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
// Continuous (gapless) RX: READY->START and END->START keep the radio receiving; ADDRESS->RSSISTART samples
|
|
// Continuous (gapless) RX: READY->START and END->START keep the radio receiving; ADDRESS->RSSISTART samples
|
|
|
// signal strength for each packet. We poll EVENTS_END and copy rfrx before the next packet can overwrite it.
|
|
// signal strength for each packet. We poll EVENTS_END and copy rfrx before the next packet can overwrite it.
|
|
@@ -122,15 +266,24 @@ static void rxArmInit()
|
|
|
RADIO_SHORTS_DISABLED_RSSISTOP_Msk;
|
|
RADIO_SHORTS_DISABLED_RSSISTOP_Msk;
|
|
|
NRF_RADIO->EVENTS_END = 0;
|
|
NRF_RADIO->EVENTS_END = 0;
|
|
|
NRF_RADIO->EVENTS_ADDRESS = 0;
|
|
NRF_RADIO->EVENTS_ADDRESS = 0;
|
|
|
|
|
+ // Capture the completed frame from the RADIO END interrupt (see RADIO_IRQHandler). The NVIC enable/priority is
|
|
|
|
|
+ // set once in setup(); here we (re)arm the END interrupt source after every retune.
|
|
|
|
|
+ NRF_RADIO->INTENSET = RADIO_INTENSET_END_Msk;
|
|
|
NRF_RADIO->TASKS_RXEN = 1;
|
|
NRF_RADIO->TASKS_RXEN = 1;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// Producer = the RADIO END ISR (owns g_head); consumer = loop() (owns g_tail). Lock-free single-producer/
|
|
|
|
|
+// single-consumer ring: on full we drop the NEWEST frame and COUNT it in g_drops -- the ISR must never touch
|
|
|
|
|
+// g_tail. (The old code dropped the oldest by advancing g_tail from the producer, which raced the consumer and
|
|
|
|
|
+// also hid the loss.) g_drops is surfaced in the status frame so a dropped frame is measured, not assumed away.
|
|
|
static void ringPush(uint32_t t, uint8_t ch, bool crcok, uint8_t rssi,
|
|
static void ringPush(uint32_t t, uint8_t ch, bool crcok, uint8_t rssi,
|
|
|
uint8_t match, const uint8_t *b, uint8_t n)
|
|
uint8_t match, const uint8_t *b, uint8_t n)
|
|
|
{
|
|
{
|
|
|
uint16_t h = g_head, nx = rnext(h);
|
|
uint16_t h = g_head, nx = rnext(h);
|
|
|
- if (nx == g_tail)
|
|
|
|
|
- g_tail = rnext(g_tail); // full -> drop oldest
|
|
|
|
|
|
|
+ if (nx == g_tail) {
|
|
|
|
|
+ g_drops++; // ring full -> drop newest, count it
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
Cap &c = g_ring[h];
|
|
Cap &c = g_ring[h];
|
|
|
c.t = t;
|
|
c.t = t;
|
|
|
c.ch = ch;
|
|
c.ch = ch;
|
|
@@ -142,16 +295,23 @@ static void ringPush(uint32_t t, uint8_t ch, bool crcok, uint8_t rssi,
|
|
|
g_head = nx;
|
|
g_head = nx;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// Drain the radio: copy every completed RX frame into the ring. Called very often from loop() so the tight
|
|
|
|
|
-// poll->reply exchange is caught. RSSISAMPLE/CRCSTATUS belong to the frame whose END we just saw.
|
|
|
|
|
-static void rxPump()
|
|
|
|
|
|
|
+// RADIO END interrupt -- the ONE latency-critical step, and the reason capture is now lossless.
|
|
|
|
|
+//
|
|
|
|
|
+// The radio re-arms into the SAME rfrx buffer (END_START short), so the NEXT packet's payload starts overwriting
|
|
|
|
|
+// rfrx only ~60us after this END (ramp-up + preamble + address). Snapshotting the just-completed frame HERE -- in
|
|
|
|
|
+// a high-priority ISR that PREEMPTS the USB writes/flushes running in loop() -- guarantees the copy lands well
|
|
|
|
|
+// inside that 60us window. The previous design polled EVENTS_END from loop(), where a blocking USB flush could
|
|
|
|
|
+// delay the copy past 60us and let the next frame overwrite rfrx (a tight poll->reply pair, <100us apart, was the
|
|
|
|
|
+// danger) -- a silent loss. RSSISAMPLE/CRCSTATUS/RXMATCH belong to the frame whose END we just saw.
|
|
|
|
|
+//
|
|
|
|
|
+// The ISR ONLY captures. Interpretation (E1 parse / acquire-lock / QoS hop) is left to processFrame() in loop(),
|
|
|
|
|
+// so this handler stays a few microseconds long and can't itself become the stall it's meant to prevent.
|
|
|
|
|
+extern "C" void RADIO_IRQHandler(void)
|
|
|
{
|
|
{
|
|
|
- for (int guard = 0; guard < 8 && NRF_RADIO->EVENTS_END; guard++) {
|
|
|
|
|
|
|
+ while (NRF_RADIO->EVENTS_END) {
|
|
|
NRF_RADIO->EVENTS_END = 0;
|
|
NRF_RADIO->EVENTS_END = 0;
|
|
|
bool crcok = NRF_RADIO->CRCSTATUS & 1;
|
|
bool crcok = NRF_RADIO->CRCSTATUS & 1;
|
|
|
uint8_t rssi = (uint8_t)(NRF_RADIO->RSSISAMPLE & 0x7F);
|
|
uint8_t rssi = (uint8_t)(NRF_RADIO->RSSISAMPLE & 0x7F);
|
|
|
-
|
|
|
|
|
- // which logical address (pipe) the frame hit
|
|
|
|
|
uint8_t match = (uint8_t)(NRF_RADIO->RXMATCH & 0x7);
|
|
uint8_t match = (uint8_t)(NRF_RADIO->RXMATCH & 0x7);
|
|
|
g_lastMatch = match;
|
|
g_lastMatch = match;
|
|
|
uint8_t len = rfrx[0];
|
|
uint8_t len = rfrx[0];
|
|
@@ -167,44 +327,68 @@ static void rxPump()
|
|
|
if (crcok && len >= 1 && (rfrx[2] & 0xF0) == 0xF0)
|
|
if (crcok && len >= 1 && (rfrx[2] & 0xF0) == 0xF0)
|
|
|
// controller reply = ACTIVE session here -> stop hunting, park
|
|
// controller reply = ACTIVE session here -> stop hunting, park
|
|
|
g_lastFrx = millis();
|
|
g_lastFrx = millis();
|
|
|
- // Parse the session params advertised in an E1 host-frame (payload[0]=0xE1) by the ASSUMED offsets, and
|
|
|
|
|
- // always record them for the status report -- so the panel shows what auto-acquire would lock onto even
|
|
|
|
|
- // when we choose not to (lets us eyeball whether the real puck's offsets match ours).
|
|
|
|
|
- if (crcok && len >= 18 && rfrx[2] == 0xE1) {
|
|
|
|
|
- g_advCh = rfrx[11];
|
|
|
|
|
- g_advBase[0] = rfrx[15];
|
|
|
|
|
- g_advBase[1] = rfrx[16];
|
|
|
|
|
- g_advBase[2] = rfrx[17];
|
|
|
|
|
- g_advBase[3] = rfrx[18];
|
|
|
|
|
- g_advPfx = rfrx[19];
|
|
|
|
|
- if (g_pinSession || g_survey)
|
|
|
|
|
- // pinned / survey -> never auto-retune (just record adv)
|
|
|
|
|
- continue;
|
|
|
|
|
- // Target a specific bonded slot by its ibex_uuid (E1 bytes rfrx[7..10]): the puck beacons one E1 per slot,
|
|
|
|
|
- // so skip any E1 that isn't the CONNECTED controller's -- otherwise we lock an idle slot's keepalive.
|
|
|
|
|
- if (g_haveTarget &&
|
|
|
|
|
- memcmp(rfrx + 7, g_targetIbex, 4) != 0)
|
|
|
|
|
- continue;
|
|
|
|
|
- // ACQUIRE: lock onto the advertised session. CAPTURE: a different advertised channel = QoS hop -> follow.
|
|
|
|
|
- if (g_advCh >= 1 && g_advCh <= 80) {
|
|
|
|
|
- if (g_state == 0) {
|
|
|
|
|
- memcpy(g_sBase, g_advBase, 4);
|
|
|
|
|
- g_sPrefix = g_advPfx;
|
|
|
|
|
- g_primaryCh = g_advCh;
|
|
|
|
|
- g_curCh = g_forceCh ? g_forceCh :
|
|
|
|
|
- g_advCh;
|
|
|
|
|
- g_huntIdx = 0;
|
|
|
|
|
- g_lastFrx = millis();
|
|
|
|
|
- tuneSession();
|
|
|
|
|
- return;
|
|
|
|
|
- } else if (!g_forceCh && g_advCh != g_curCh) {
|
|
|
|
|
- g_curCh = g_advCh;
|
|
|
|
|
- tuneSession();
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Interpret ONE captured frame (loop context, fed from the ring). Parses the session params advertised in an E1
|
|
|
|
|
+// host-frame (payload[0]=0xE1) by the ASSUMED offsets and always records them for the status report -- so the
|
|
|
|
|
+// panel shows what auto-acquire would lock onto even when we choose not to (lets us eyeball whether the real
|
|
|
|
|
+// puck's offsets match ours). Returns true if it retuned the radio. This is the exact logic that used to run
|
|
|
|
|
+// inline in rxPump; it just no longer shares a thread with the latency-sensitive copy.
|
|
|
|
|
+static bool processFrame(const Cap &c)
|
|
|
|
|
+{
|
|
|
|
|
+ bool crcok = c.flags & 1;
|
|
|
|
|
+ uint8_t len = c.n >= 1 ? c.b[0] : 0;
|
|
|
|
|
+ uint8_t op = c.n >= 3 ? c.b[2] : 0;
|
|
|
|
|
+ // LEARN: a CRC-ok controller reply (0xF*) on the session we're camped on proves THIS channel carries the
|
|
|
|
|
+ // bond's traffic -> remember it, so the next reconnect can re-acquire straight onto it (no ch2 catch).
|
|
|
|
|
+ if (crcok && g_state == 1 && g_curBond >= 0 && (op & 0xF0) == 0xF0)
|
|
|
|
|
+ bondLearnChan(g_curBond, c.ch);
|
|
|
|
|
+ if (!(crcok && len >= 18 && op == 0xE1))
|
|
|
|
|
+ return false;
|
|
|
|
|
+ g_advCh = c.b[11];
|
|
|
|
|
+ g_advBase[0] = c.b[15];
|
|
|
|
|
+ g_advBase[1] = c.b[16];
|
|
|
|
|
+ g_advBase[2] = c.b[17];
|
|
|
|
|
+ g_advBase[3] = c.b[18];
|
|
|
|
|
+ g_advPfx = c.b[19];
|
|
|
|
|
+ if (g_pinSession || g_survey)
|
|
|
|
|
+ // pinned / survey -> never auto-retune (just record adv)
|
|
|
|
|
+ return false;
|
|
|
|
|
+ // Target a specific bonded slot by its ibex_uuid (E1 bytes [7..10]): the puck beacons one E1 per slot, so skip
|
|
|
|
|
+ // any E1 that isn't the CONNECTED controller's -- otherwise we lock an idle slot's keepalive.
|
|
|
|
|
+ if (g_haveTarget && memcmp(c.b + 7, g_targetIbex, 4) != 0)
|
|
|
|
|
+ return false;
|
|
|
|
|
+ // ACQUIRE: lock onto the advertised session. CAPTURE: a different advertised channel = QoS hop -> follow.
|
|
|
|
|
+ if (g_advCh >= 1 && g_advCh <= 80) {
|
|
|
|
|
+ // LEARN the bonded address from this E1 (keyed by ibex_uuid) and persist it -- this is what lets future
|
|
|
|
|
+ // reconnects be caught with no manual pinning.
|
|
|
|
|
+ int bi = bondAlloc(c.b + 7);
|
|
|
|
|
+ Bond &b = g_bonds[bi];
|
|
|
|
|
+ if (memcmp(b.base, g_advBase, 4) != 0 || b.prefix != g_advPfx ||
|
|
|
|
|
+ b.primaryCh != g_advCh) {
|
|
|
|
|
+ memcpy(b.base, g_advBase, 4);
|
|
|
|
|
+ b.prefix = g_advPfx;
|
|
|
|
|
+ b.primaryCh = g_advCh;
|
|
|
|
|
+ g_bondDirty = true;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (g_state == 0) {
|
|
|
|
|
+ g_curBond = bi;
|
|
|
|
|
+ memcpy(g_sBase, g_advBase, 4);
|
|
|
|
|
+ g_sPrefix = g_advPfx;
|
|
|
|
|
+ g_primaryCh = g_advCh;
|
|
|
|
|
+ g_curCh = g_forceCh ? g_forceCh : g_advCh;
|
|
|
|
|
+ g_huntIdx = 0;
|
|
|
|
|
+ g_lastFrx = millis();
|
|
|
|
|
+ tuneSession();
|
|
|
|
|
+ return true;
|
|
|
|
|
+ } else if (!g_forceCh && g_advCh != g_curCh) {
|
|
|
|
|
+ g_curCh = g_advCh;
|
|
|
|
|
+ tuneSession();
|
|
|
|
|
+ return true;
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
+ return false;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// ---- WebUSB stream out ----
|
|
// ---- WebUSB stream out ----
|
|
@@ -233,33 +417,55 @@ static void emitStatus()
|
|
|
last = millis();
|
|
last = millis();
|
|
|
static uint16_t rx = 0;
|
|
static uint16_t rx = 0;
|
|
|
rx++;
|
|
rx++;
|
|
|
- uint8_t f[19] = { 0xC1, 0xDE,
|
|
|
|
|
- g_state, g_curCh,
|
|
|
|
|
- g_sBase[0], g_sBase[1],
|
|
|
|
|
- g_sBase[2], g_sBase[3],
|
|
|
|
|
- g_sPrefix, (uint8_t)(g_streaming ? 1 : 0),
|
|
|
|
|
- (uint8_t)rx, (uint8_t)(rx >> 8),
|
|
|
|
|
- g_advCh, g_advBase[0],
|
|
|
|
|
- g_advBase[1], g_advBase[2],
|
|
|
|
|
- g_advBase[3], g_advPfx,
|
|
|
|
|
- g_lastMatch };
|
|
|
|
|
|
|
+ uint32_t drops = g_drops; // snapshot the volatile once
|
|
|
|
|
+ // bond byte: bit7 = camped on a learned bond; bits0-3 = #channels learned for it; bits4-6 = #bonds stored
|
|
|
|
|
+ int nbonds = 0;
|
|
|
|
|
+ for (int i = 0; i < NBONDS; i++)
|
|
|
|
|
+ if (uuidSet(g_bonds[i].uuid))
|
|
|
|
|
+ nbonds++;
|
|
|
|
|
+ uint8_t curN = (g_curBond >= 0) ? g_bonds[g_curBond].nchans : 0;
|
|
|
|
|
+ uint8_t bondInfo = (uint8_t)((g_curBond >= 0 ? 0x80 : 0) |
|
|
|
|
|
+ ((nbonds & 0x7) << 4) | (curN & 0xF));
|
|
|
|
|
+ uint8_t f[22] = { 0xC1, 0xDE,
|
|
|
|
|
+ g_state, g_curCh,
|
|
|
|
|
+ g_sBase[0], g_sBase[1],
|
|
|
|
|
+ g_sBase[2], g_sBase[3],
|
|
|
|
|
+ g_sPrefix, (uint8_t)(g_streaming ? 1 : 0),
|
|
|
|
|
+ (uint8_t)rx, (uint8_t)(rx >> 8),
|
|
|
|
|
+ g_advCh, g_advBase[0],
|
|
|
|
|
+ g_advBase[1], g_advBase[2],
|
|
|
|
|
+ g_advBase[3], g_advPfx,
|
|
|
|
|
+ g_lastMatch, (uint8_t)drops,
|
|
|
|
|
+ (uint8_t)(drops >> 8), bondInfo };
|
|
|
usb_web.write(f, sizeof f);
|
|
usb_web.write(f, sizeof f);
|
|
|
usb_web.flush();
|
|
usb_web.flush();
|
|
|
}
|
|
}
|
|
|
-static void drainToHost()
|
|
|
|
|
|
|
+// Consume the ring (loop context): interpret each captured frame (E1 parse / acquire-lock / QoS hop) and, when
|
|
|
|
|
+// streaming, emit it to the host. processFrame() runs even when NOT streaming, because acquisition has to happen
|
|
|
|
|
+// before Start Cap. Capture itself is the RADIO ISR's job now -- this side just drains, so a slow USB flush here
|
|
|
|
|
+// can no longer cost us a frame (worst case the ring backs up and g_drops ticks, which the panel shows).
|
|
|
|
|
+static void consumeRing()
|
|
|
{
|
|
{
|
|
|
- if (!usb_web.connected())
|
|
|
|
|
- return;
|
|
|
|
|
-
|
|
|
|
|
- // bound per-call so RX stays responsive; the ring covers bursts
|
|
|
|
|
- uint16_t budget = 64;
|
|
|
|
|
|
|
+ bool connected = usb_web.connected();
|
|
|
|
|
+ bool emit = connected && g_streaming;
|
|
|
|
|
+ // bound per-call so the loop stays responsive; the 512-deep ring absorbs anything beyond this
|
|
|
|
|
+ uint16_t budget = 128;
|
|
|
while (budget-- && g_tail != g_head) {
|
|
while (budget-- && g_tail != g_head) {
|
|
|
- if (g_streaming)
|
|
|
|
|
- emitPacket(g_ring[g_tail]);
|
|
|
|
|
|
|
+ Cap c = g_ring[g_tail]; // copy out before releasing the slot to the producer
|
|
|
g_tail = rnext(g_tail);
|
|
g_tail = rnext(g_tail);
|
|
|
|
|
+ bool retuned = processFrame(c); // may retune the radio (acquire / channel hop)
|
|
|
|
|
+ if (emit)
|
|
|
|
|
+ emitPacket(c);
|
|
|
|
|
+ // On a retune, stop draining this round: the remaining queued frames were captured on the OLD channel, and
|
|
|
|
|
+ // a flooded ch2 often queues several E1s -- re-applying each would thrash the radio. (The old rxPump did the
|
|
|
|
|
+ // same via an immediate `return`.) The rest drain next loop.
|
|
|
|
|
+ if (retuned)
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (connected) {
|
|
|
|
|
+ usb_web.flush();
|
|
|
|
|
+ emitStatus();
|
|
|
}
|
|
}
|
|
|
- usb_web.flush();
|
|
|
|
|
- emitStatus();
|
|
|
|
|
}
|
|
}
|
|
|
static void handleCmd()
|
|
static void handleCmd()
|
|
|
{
|
|
{
|
|
@@ -278,6 +484,7 @@ static void handleCmd()
|
|
|
g_forceCh = 0;
|
|
g_forceCh = 0;
|
|
|
g_pinSession = false;
|
|
g_pinSession = false;
|
|
|
g_survey = false;
|
|
g_survey = false;
|
|
|
|
|
+ g_curBond = -1; // un-camp; relearn from a fresh E1
|
|
|
tuneDiscovery();
|
|
tuneDiscovery();
|
|
|
} // re-acquire from ibex/ch2
|
|
} // re-acquire from ibex/ch2
|
|
|
else if (c == 0x06) {
|
|
else if (c == 0x06) {
|
|
@@ -299,8 +506,21 @@ static void handleCmd()
|
|
|
g_survey = false;
|
|
g_survey = false;
|
|
|
g_forceCh = 0;
|
|
g_forceCh = 0;
|
|
|
g_pinSession = false;
|
|
g_pinSession = false;
|
|
|
- tuneDiscovery();
|
|
|
|
|
|
|
+ // If we've already learned this controller's bond, camp straight on it (no ch2 catch needed -- the
|
|
|
|
|
+ // bonded address is stable, so its next reconnect lands right here). Otherwise discover it on ch2,
|
|
|
|
|
+ // and processFrame() will persist the bond the first time its E1 is seen.
|
|
|
|
|
+ int bi = bondFind(g_targetIbex);
|
|
|
|
|
+ if (bi >= 0)
|
|
|
|
|
+ campBond(bi);
|
|
|
|
|
+ else
|
|
|
|
|
+ tuneDiscovery();
|
|
|
}
|
|
}
|
|
|
|
|
+ } else if (c == 0x08) {
|
|
|
|
|
+ // forget all learned bonds (start clean)
|
|
|
|
|
+ memset(g_bonds, 0, sizeof g_bonds);
|
|
|
|
|
+ g_curBond = -1;
|
|
|
|
|
+ bondsSave();
|
|
|
|
|
+ tuneDiscovery();
|
|
|
} else if (c == 0x04) {
|
|
} else if (c == 0x04) {
|
|
|
int ch = usb_web.read();
|
|
int ch = usb_web.read();
|
|
|
if (ch >= 1 && ch <= 80) {
|
|
if (ch >= 1 && ch <= 80) {
|
|
@@ -344,41 +564,75 @@ void setup()
|
|
|
for (int i = 0; i < 200 && !USBDevice.mounted(); i++)
|
|
for (int i = 0; i < 200 && !USBDevice.mounted(); i++)
|
|
|
delay(10);
|
|
delay(10);
|
|
|
|
|
|
|
|
|
|
+ // Capture is interrupt-driven: the RADIO END ISR snapshots each frame the instant it completes, preempting the
|
|
|
|
|
+ // USB work in loop() so the copy always beats the ~60us buffer-overwrite window. Give it a high priority and
|
|
|
|
|
+ // enable it once here; rxArmInit() arms the END source (INTENSET) after every (re)tune. (No SoftDevice runs, so
|
|
|
|
|
+ // RADIO_IRQn is ours.)
|
|
|
|
|
+ NVIC_SetPriority(RADIO_IRQn, 2);
|
|
|
|
|
+ NVIC_ClearPendingIRQ(RADIO_IRQn);
|
|
|
|
|
+ NVIC_EnableIRQ(RADIO_IRQn);
|
|
|
|
|
+
|
|
|
// harmless (we never TX); leaves the radio tunables at their validated defaults
|
|
// harmless (we never TX); leaves the radio tunables at their validated defaults
|
|
|
rfGenSessionAddr();
|
|
rfGenSessionAddr();
|
|
|
- tuneDiscovery();
|
|
|
|
|
|
|
+
|
|
|
|
|
+ // Load any bonds learned on a previous run. If we already know a controller's bonded session, camp straight on
|
|
|
|
|
+ // it -- so a clean reconnect is captured the moment the controller powers back on, with NO ch2 catch and NO
|
|
|
|
|
+ // manual pin. (With multiple bonds we wait for the host to `lock ibex` the one you want; a single bond is
|
|
|
|
|
+ // unambiguous, so we adopt it immediately.) Otherwise fall back to ch2 discovery to learn one.
|
|
|
|
|
+ InternalFS.begin();
|
|
|
|
|
+ bondsLoad();
|
|
|
|
|
+ int count = 0, b0 = bondFirst();
|
|
|
|
|
+ for (int i = 0; i < NBONDS; i++)
|
|
|
|
|
+ if (uuidSet(g_bonds[i].uuid))
|
|
|
|
|
+ count++;
|
|
|
|
|
+ if (count == 1)
|
|
|
|
|
+ campBond(b0); // sole known bond -> adopt immediately (unambiguous)
|
|
|
|
|
+ else
|
|
|
|
|
+ tuneDiscovery(); // 0 bonds, or several -> discover / wait for a uuid lock
|
|
|
g_lastRx = millis();
|
|
g_lastRx = millis();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
void loop()
|
|
void loop()
|
|
|
{
|
|
{
|
|
|
- rxPump();
|
|
|
|
|
|
|
+ // Capture now happens in RADIO_IRQHandler (interrupt-driven, gapless), so loop() no longer races to poll the
|
|
|
|
|
+ // radio. It just services host commands and drains the capture ring to USB.
|
|
|
handleCmd();
|
|
handleCmd();
|
|
|
-
|
|
|
|
|
- // pump again around the (fast, buffered) USB work to catch tight poll->reply pairs
|
|
|
|
|
- rxPump();
|
|
|
|
|
- drainToHost();
|
|
|
|
|
- // Session HUNT (Gazell). A pinned session (cmd 04/05) is never disturbed. Otherwise: while we're NOT seeing the
|
|
|
|
|
- // controller's 0xF* replies (the ACTIVE session), the keepalive trickles in on the primary but the real E3/F1
|
|
|
|
|
- // exchange is on another channel of the per-session set -> hunt {primary,2,80,+candidates}, dwelling briefly on
|
|
|
|
|
- // each, KEEPING the learned address. The instant a 0xF* arrives (g_lastFrx) we stop and park there. After two
|
|
|
|
|
- // full dry passes (no replies anywhere -> session address likely changed on a reconnect) fall back to discovery
|
|
|
|
|
|
|
+ consumeRing();
|
|
|
|
|
+ // Persist newly-learned bond data, debounced (flash write blocks tens of ms; the ring covers it).
|
|
|
|
|
+ if (g_bondDirty && millis() - g_lastBondSave > 3000)
|
|
|
|
|
+ bondsSave();
|
|
|
|
|
+ // Session HUNT (Gazell). A pinned session (cmd 04/05) is never disturbed. Otherwise, while we're NOT seeing the
|
|
|
|
|
+ // controller's 0xF* replies, sweep the session's channels, KEEPING the (stable, bonded) address, dwelling briefly
|
|
|
|
|
+ // on each. The instant a 0xF* arrives (g_lastFrx) we stop and park. The sweep walks the bond's LEARNED channels
|
|
|
|
|
+ // first (fast re-acquire onto a known-good one), then the advertised primary, then a broad candidate list to
|
|
|
|
|
+ // discover the rest of the map. When a bond is known the address is stable, so we never abandon to ch2 -- we just
|
|
|
|
|
+ // keep sweeping until the controller's reconnect appears; only with NO bond at all do we fall back to discovery
|
|
|
// to re-read an E1. Hunting only runs when there are no replies, so an active capture is never disturbed.
|
|
// to re-read an E1. Hunting only runs when there are no replies, so an active capture is never disturbed.
|
|
|
if (g_state == 1 && !g_forceCh && !g_pinSession &&
|
|
if (g_state == 1 && !g_forceCh && !g_pinSession &&
|
|
|
millis() - g_lastFrx > HUNT_NOFRX_MS &&
|
|
millis() - g_lastFrx > HUNT_NOFRX_MS &&
|
|
|
millis() - g_huntMs > HUNT_DWELL_MS) {
|
|
millis() - g_huntMs > HUNT_DWELL_MS) {
|
|
|
g_huntMs = millis();
|
|
g_huntMs = millis();
|
|
|
- uint8_t ch =
|
|
|
|
|
- (g_huntIdx == 0) ?
|
|
|
|
|
- g_primaryCh :
|
|
|
|
|
- HUNT_CH[(g_huntIdx - 1) % (sizeof HUNT_CH)];
|
|
|
|
|
|
|
+ uint8_t learned = (g_curBond >= 0) ? g_bonds[g_curBond].nchans : 0;
|
|
|
|
|
+ uint8_t total = learned + 1 + (uint8_t)sizeof HUNT_CH;
|
|
|
|
|
+ uint8_t slot = g_huntIdx;
|
|
|
|
|
+ uint8_t ch;
|
|
|
|
|
+ if (slot < learned)
|
|
|
|
|
+ ch = g_bonds[g_curBond].chans[slot];
|
|
|
|
|
+ else if (slot == learned)
|
|
|
|
|
+ ch = g_primaryCh;
|
|
|
|
|
+ else
|
|
|
|
|
+ ch = HUNT_CH[(slot - learned - 1) % (sizeof HUNT_CH)];
|
|
|
g_huntIdx++;
|
|
g_huntIdx++;
|
|
|
- if (g_huntIdx > 1 + 2 * (uint8_t)sizeof HUNT_CH) {
|
|
|
|
|
|
|
+ if (g_huntIdx >= total) {
|
|
|
g_huntIdx = 0;
|
|
g_huntIdx = 0;
|
|
|
- tuneDiscovery();
|
|
|
|
|
- g_lastFrx = millis();
|
|
|
|
|
- g_huntMs = millis();
|
|
|
|
|
- return;
|
|
|
|
|
|
|
+ if (g_curBond < 0) {
|
|
|
|
|
+ // nothing learned -> re-read an E1 on the ch2 rendezvous
|
|
|
|
|
+ tuneDiscovery();
|
|
|
|
|
+ g_lastFrx = millis();
|
|
|
|
|
+ g_huntMs = millis();
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ // bonded address is stable -> keep sweeping its set, never abandon to ch2
|
|
|
}
|
|
}
|
|
|
g_curCh = ch;
|
|
g_curCh = ch;
|
|
|
rfConfig(g_curCh);
|
|
rfConfig(g_curCh);
|