t4as

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
Seven Adafruit Feather nRF52832 boards wired to a Raspberry Pi 3 over one SPI bus, on a 3D-printed frame

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.

One slot of the link, 2.7 to 4.3 ms after slot start, to scale 2.8 3.0 3.2 3.4 3.6 3.8 4.0 4.2 ms after slot start A B C link node A link node B auxiliary C A: radio ramp-up after TXEN (40 us) A: data packet on air, 480 us (51-byte PDU at 1 Mbit/s)data 480 µs A: receiving, waiting for the acklisten for ack B: RXEN 200 us before A's TXEN; window closes at A's TXEN + 300 us unless an address arrivedreceive window B: ramp-up, 150 us after the data ends B: ack on air, 264 usack C: ramp-up, TXEN 150 us before A's C: test frame on air, 1.3 ms: covers A's data and B's acktest frame 1.3 ms (only on its assigned channels) A TXEN, by timer compare + PPI
Figure 1. One slot, to scale. A's transmitter is started by a timer compare through PPI, 3.0 ms after the slot start that the Pi computed for it. A C node whose assigned channel is the slot's channel starts 150 µs earlier and stays on air for 1.3 ms, across A's data and B's ack. Faded bars are the 40 µs radio ramp-up.

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

Share of A's transmissions acknowledged, per second, mean of three runs per mode 60 % 70 % 80 % 90 % 100 % 0 5 10 15 20 25 30 seconds after start (10 ms slots) 3 s: 4 channels out 13 s: re-test: all back 15 s: 12, 28 out 17 s: 52, 64 out no test frames, second 0: 98.3 % acknowledged no test frames, second 1: 97.3 % acknowledged no test frames, second 2: 97.7 % acknowledged no test frames, second 3: 96.7 % acknowledged no test frames, second 4: 96.0 % acknowledged no test frames, second 5: 96.3 % acknowledged no test frames, second 6: 97.0 % acknowledged no test frames, second 7: 95.0 % acknowledged no test frames, second 8: 97.7 % acknowledged no test frames, second 9: 97.0 % acknowledged no test frames, second 10: 96.7 % acknowledged no test frames, second 11: 96.3 % acknowledged no test frames, second 12: 95.3 % acknowledged no test frames, second 13: 96.7 % acknowledged no test frames, second 14: 96.7 % acknowledged no test frames, second 15: 96.3 % acknowledged no test frames, second 16: 97.3 % acknowledged no test frames, second 17: 96.7 % acknowledged no test frames, second 18: 97.7 % acknowledged no test frames, second 19: 96.3 % acknowledged no test frames, second 20: 99.0 % acknowledged no test frames, second 21: 97.3 % acknowledged no test frames, second 22: 97.0 % acknowledged no test frames, second 23: 97.7 % acknowledged no test frames, second 24: 97.3 % acknowledged no test frames, second 25: 97.3 % acknowledged no test frames, second 26: 96.3 % acknowledged no test frames, second 27: 97.3 % acknowledged no test frames, second 28: 97.3 % acknowledged no test frames, second 29: 96.3 % acknowledged test frames, no avoidance, second 0: 76.0 % acknowledged test frames, no avoidance, second 1: 78.0 % acknowledged test frames, no avoidance, second 2: 79.0 % acknowledged test frames, no avoidance, second 3: 76.0 % acknowledged test frames, no avoidance, second 4: 75.3 % acknowledged test frames, no avoidance, second 5: 76.0 % acknowledged test frames, no avoidance, second 6: 76.7 % acknowledged test frames, no avoidance, second 7: 77.7 % acknowledged test frames, no avoidance, second 8: 75.7 % acknowledged test frames, no avoidance, second 9: 75.3 % acknowledged test frames, no avoidance, second 10: 75.3 % acknowledged test frames, no avoidance, second 11: 75.3 % acknowledged test frames, no avoidance, second 12: 76.7 % acknowledged test frames, no avoidance, second 13: 77.0 % acknowledged test frames, no avoidance, second 14: 76.3 % acknowledged test frames, no avoidance, second 15: 75.0 % acknowledged test frames, no avoidance, second 16: 77.7 % acknowledged test frames, no avoidance, second 17: 78.7 % acknowledged test frames, no avoidance, second 18: 75.3 % acknowledged test frames, no avoidance, second 19: 76.3 % acknowledged test frames, no avoidance, second 20: 76.7 % acknowledged test frames, no avoidance, second 21: 76.0 % acknowledged test frames, no avoidance, second 22: 77.3 % acknowledged test frames, no avoidance, second 23: 75.7 % acknowledged test frames, no avoidance, second 24: 77.3 % acknowledged test frames, no avoidance, second 25: 76.7 % acknowledged test frames, no avoidance, second 26: 76.0 % acknowledged test frames, no avoidance, second 27: 76.3 % acknowledged test frames, no avoidance, second 28: 78.7 % acknowledged test frames, no avoidance, second 29: 77.7 % acknowledged test frames + avoidance, second 0: 76.0 % acknowledged test frames + avoidance, second 1: 78.3 % acknowledged test frames + avoidance, second 2: 76.7 % acknowledged test frames + avoidance, second 3: 97.7 % acknowledged test frames + avoidance, second 4: 97.3 % acknowledged test frames + avoidance, second 5: 97.3 % acknowledged test frames + avoidance, second 6: 98.3 % acknowledged test frames + avoidance, second 7: 98.0 % acknowledged test frames + avoidance, second 8: 97.3 % acknowledged test frames + avoidance, second 9: 96.7 % acknowledged test frames + avoidance, second 10: 96.7 % acknowledged test frames + avoidance, second 11: 98.0 % acknowledged test frames + avoidance, second 12: 96.7 % acknowledged test frames + avoidance, second 13: 75.7 % acknowledged test frames + avoidance, second 14: 77.7 % acknowledged test frames + avoidance, second 15: 85.0 % acknowledged test frames + avoidance, second 16: 86.7 % acknowledged test frames + avoidance, second 17: 97.3 % acknowledged test frames + avoidance, second 18: 98.0 % acknowledged test frames + avoidance, second 19: 97.7 % acknowledged test frames + avoidance, second 20: 96.7 % acknowledged test frames + avoidance, second 21: 98.3 % acknowledged test frames + avoidance, second 22: 96.7 % acknowledged test frames + avoidance, second 23: 97.7 % acknowledged test frames + avoidance, second 24: 97.3 % acknowledged test frames + avoidance, second 25: 97.3 % acknowledged test frames + avoidance, second 26: 97.3 % acknowledged test frames + avoidance, second 27: 96.7 % acknowledged test frames + avoidance, second 28: 96.3 % acknowledged test frames + avoidance, second 29: 97.0 % acknowledged no test frames test frames, no avoidance test frames + avoidance
Figure 2. Share of A's transmissions acknowledged per second, mean of three runs per mode. With avoidance the curve leaves the disturbed level after 3 s. The dip at 13–16 s is the scheduled re-test: all four channels come back on probation, fail, and are dropped again. The map switches happened at the same slots in all three runs.
Per channel: acknowledged share with test frames and no avoidance; attempts with avoidance (a) test frames, no avoidance: share of attempts acknowledged 0 % 50 % 100 % channel 4: 98.9 % of 444 attempts acknowledged channel 8: 96.4 % of 444 attempts acknowledged channel 12: 0.0 % of 444 attempts acknowledged 0 channel 16: 95.9 % of 444 attempts acknowledged channel 20: 98.7 % of 447 attempts acknowledged channel 24: 95.5 % of 444 attempts acknowledged channel 28: 0.0 % of 441 attempts acknowledged 0 channel 32: 92.3 % of 442 attempts acknowledged channel 36: 94.8 % of 444 attempts acknowledged channel 40: 93.2 % of 444 attempts acknowledged channel 44: 92.3 % of 441 attempts acknowledged channel 48: 99.1 % of 446 attempts acknowledged channel 52: 0.0 % of 441 attempts acknowledged 0 channel 56: 99.3 % of 447 attempts acknowledged channel 60: 99.1 % of 447 attempts acknowledged channel 64: 0.0 % of 444 attempts acknowledged 0 channel 68: 99.8 % of 441 attempts acknowledged channel 72: 98.9 % of 441 attempts acknowledged channel 76: 100.0 % of 444 attempts acknowledged (b) test frames + avoidance: attempts per channel (3 runs, 28 s each) 0 300 600 channel 4: 549 attempts channel 8: 540 attempts channel 12: 51 attempts channel 16: 547 attempts channel 20: 552 attempts channel 24: 549 attempts channel 28: 48 attempts channel 32: 540 attempts channel 36: 543 attempts channel 40: 540 attempts channel 44: 543 attempts channel 48: 549 attempts channel 52: 84 attempts channel 56: 540 attempts channel 60: 543 attempts channel 64: 81 attempts channel 68: 543 attempts channel 72: 540 attempts channel 76: 546 attempts 4 8 12 16 20 24 28 32 36 40 44 48 52 56 60 64 68 72 76 RF channel (MHz above 2400) assigned to C nodesfree
Figure 3. (a) The assigned channels drop to 0 % when the C nodes send; the free channels stay at 92–100 %. (b) With avoidance the traffic moves: about 545 attempts on each free channel against 48–84 on the assigned ones, which are the first 3 s plus the re-test.

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.