BLE & Wireless · 2026
Hopping Around Occupied Channels: Channel Avoidance on Seven nRF52832 Radios
A frequency-hopping link between two nRF52832 radios, and five more that send test frames on chosen channels in exactly the slots the link uses them. The link finds the occupied channels from its own ack statistics and stops using them; first-try delivery goes from 75 % back to 95 %, with no corrupted value in 22,570.
- nRF52832
- nRF5 SDK
- Raspberry Pi 3
- SPI
- PPI
- GPIOTE
- Python
- proprietary 1 Mbit GFSK
- packets through on the first try with avoidance (75.2 % without, 96.9 % with no test frames; 3 runs each)
- 94.6 %
- to drop all four occupied channels, in 3 of 3 runs
- 3.0 s
- clock sync per radio over the chip-select lines (std. dev., 30 pulses, drift removed)
- ≈2 µs
- acknowledged values that differed from what the receiver got, 9 runs
- 0 / 22,570

Result
Two radios run a frequency-hopping link: A sends a counter value with its own CRC-16 in every 10 ms slot, B checks it and acknowledges. Five more radios, the C nodes, are each given a few channels. Whenever the link’s hop lands on one of them, they send a 1.3 ms test frame on top of it, timed to the microsecond. Four of the 19 channels are covered this way, each by two or three nodes.
Without countermeasures those four channels deliver nothing, and a quarter of all first attempts fail. Retries on the next hop still get 99.94 % of the values through, at the cost of a fifth of the throughput. With avoidance on, A scores every channel by its ack rate, drops the four occupied ones and announces the new channel map in its own packets. B and all five C nodes switch at the same slot, 3.0 s after the start, in every run, and no free channel is dropped. First-try delivery goes back to 94.6 %, against 96.9 % with no test frames at all.
| Mode (3 runs × 28 s) | First try | Lost per run | Values sent | C test frames |
|---|---|---|---|---|
| No test frames | 96.88 % | 0 | 2720 | 0 |
| Test frames, no avoidance | 75.23 % | 1.3 | 2152 | 1327 |
| Test frames + avoidance | 94.64 % | 0.3 | 2648 | 192 |
The setup
All seven radios are the nRF52832 boards of my sniffer array, on one Raspberry Pi 3 SPI bus with a chip-select and a ready line each. They keep their SPI bootloader, so every firmware change in this project went over the bus, one radio first, then the rest.
| Radio | Role | Does |
|---|---|---|
| D0 | A | one data packet per slot on the slot’s channel; scores channels, owns the channel map |
| D1 | B | receives, checks radio CRC, value CRC and sequence, then acknowledges |
| D2–D6 | C | a test frame in the slots where the link is on one of its assigned channels |
The C nodes follow the link’s current map, not the original hop sequence. When A stops using a channel, the nodes assigned to it go quiet; when A re-tests the channel, they hit it again. That keeps the test honest: avoidance is only rewarded if the channel is really left alone.
The setup started differently. The first version ran A and B on two nRF54L15 boards and let the array follow them over the air. It worked, but a node that takes the link’s timing from the air does not see its map changes: after the first change it would keep aiming at the old hop sequence. Moving A and B onto the array put every radio behind one host with one clock, and the host can hand each node the live map.
Each node decides on its own, from the map it holds, whether the slot concerns it. From the
node firmware (fhnode.c):
static void prep_c(void)
{
if (!map_has(assigned, cur_ch)) {
next_slot();
return;
}
int32_t pdu = (int32_t)cfg.frame_us / 8 - 9; /* preamble, address, length, CRC */
pdu = pdu < (int32_t)HDR_PDU_LEN + 1 ? (int32_t)HDR_PDU_LEN + 1 : pdu > 255 ? 255 : pdu;
hdr_fill((pkt_hdr_t *)txbuf, (uint8_t)pdu, PKT_AUX, 0, c_frames, t_tx);
...
st = C_ARMED;
arm(t_tx - FH_C_EARLY_US, &NRF_RADIO->TASKS_TXEN, on_c_txen);
}
arm() sets a TIMER3 compare and, through PPI, wires that compare event to the radio’s TXEN
task. The CPU prepares the packet 1 ms ahead; the transmitter starts on the timer, not in an
interrupt.
One clock without a sync wire
The latch was fine; reading it back was not. The command that fetches the latched value brings its own chip-select edge, which overwrites the latch before the firmware answers. So the reply carried the time of the read, wherever the Linux scheduler had put it.
The radio side sits in the SPI slave’s transfer-done handler (raccoon transport_spi.c). A
transfer of zero bytes is a sync pulse, and the frame that was armed for the host is armed
again instead of being counted as sent:
if (p_event->evt_type == NRFX_SPIS_XFER_DONE) {
if (p_event->rx_amount == 0 && p_event->tx_amount == 0) {
/* Sync pulse: nothing was clocked out, so the armed frame (data or idle) is
* still undelivered. Keep the edge, arm the same frame again. */
sync_pulse_capture = SNIFFER_TIMER->CC[SYNC_CC_CS];
sync_pulse_count++;
nrfx_spis_buffers_set(&spis, spi_tx_buf, spis_armed_len,
spi_rx_buf, SPI_RX_BUF_SIZE);
return;
}
The Pi takes one sample per radio every 0.5 s, drops any whose bracket exceeds 15 µs, and fits
a straight line from Pi time to each radio’s timer. The slot grid is defined once, in Pi time,
and every radio receives it in its own clock (pi_link.py):
def push_timebases(t_grid0, n_ref):
for r in radios:
a, b = r.fit()
t_local = a + b * (t_grid0 + n_ref * period)
payload = struct.pack("<III", n_ref & 0xFFFFFFFF, int(round(t_local)) & 0xFFFFFFFF,
int(round(b * period * 65536)))
r.write(TAG_CMD_FH_TIMEBASE, payload, on_frame)
The firmware turns that into slot starts with one multiply and shift
(t0 + (n - slot0) · period_q16 >> 16). The fitted rates are a check in themselves: against the Pi’s clock, each
radio came out at the same offset in all nine runs, within 0.4 ppm, from −6.1 ppm (D5) to
+3.8 ppm (D0).
Finding the occupied channels
A scores each channel over a 2 s window, about ten attempts per channel at 10 ms slots. A
channel with at least six attempts and an ack rate under 50 % is dropped, worst first, keeping
at least eight channels. After 10 s it comes back on probation; failing again doubles its time
out. From quality_eval() in fhnode.c:
int enabled = map_count(out);
for (int k = 0; k < nc && enabled > cfg.min_channels; k++) {
int i = cand[k];
map_set(out, i, false);
enabled--;
q[i].out = true;
if (q[i].probing && q[i].backoff) {
uint32_t b = q[i].backoff * 2;
q[i].backoff = b > cfg.retry_max_slots ? cfg.retry_max_slots : b;
} else {
q[i].backoff = cfg.retry_slots;
}
q[i].probing = false;
q[i].until = slot + q[i].backoff;
}
A new map takes effect 100 slots (1 s) after the decision. Every data packet carries the map in use and the next one with its switch slot, so B needs to catch one of about a hundred packets; the Pi passes the same decision to the C nodes.
The threshold started at 75 %. With A and B at −20 dBm the free channels run at 88–100 %, and over ten attempts a free channel occasionally fell under 75 %: two were dropped by mistake in three runs. The occupied channels fall to 0 %, so 50 % separates the two cleanly, and no free channel was dropped afterwards.
Measurements
Every value carries its own CRC-16 and B checks that it is the next one in the sequence, so a corruption that slipped past the radio CRC, a skipped value or a duplicate would all show. In nine runs, 22,570 values were acknowledged; each was compared with what B logged in the same slot, and none differed. Values lost after four attempts: 4 in three runs without avoidance, 1 in three runs with it, none without test frames.
The logs behind these numbers are complete: no slot is missing a record from A or B in any of the nine runs. That needed one change on the host. SPI is full duplex, and a radio that has a frame waiting clocks it out during any transfer, including the Pi’s command writes, which ignored it; about 1.5 % of A’s records were lost that way. The Pi now pads every command write to a full frame and parses whatever comes back.
| Setting | Value |
|---|---|
| Slot | 10 ms; A transmits 3.0 ms in, B listens from 2.8 ms |
| Data packet | 51-byte PDU (header, channel map block, value, CRC-16), 480 µs on air |
| Test frame | 1.3 ms, TXEN 150 µs before A’s |
| Powers | A and B −20 dBm, C nodes 0 dBm, all radios a few centimetres apart |
| Channels | 19 (4–76 MHz above 2400, step 4); assigned: 12, 28, 52, 64 |
| Avoidance | 2 s window, ≥ 6 attempts, < 50 % acked, ≥ 8 channels kept, 1 s switch lead |
Status
The proof of concept works end to end: precise, map-aware test traffic, detection, a coordinated switch on all seven radios, and data integrity checked on every value. A full comparison, three modes three times each, runs from one command in about seven minutes.
The test frames are all-or-nothing: an assigned channel fails completely. Next are partial and moving occupancy (a fraction of the slots, channel sets that change between epochs), and measuring how fast a channel that has become free returns to the map. The same channel-map logic already runs on two nRF54L15 boards; with the array’s clock, they can take over as A and B and the five nRF52832 radios stay the test traffic. Today only A’s ack statistics decide; B could report what it hears per channel.