t4as

Test equipment · nRF52832 × 7 · Raspberry Pi · 2026

Raccoon: a Seven-Radio BLE Sniffer on One Clock

Seven nRF52832 radios on one SPI bus to a Raspberry Pi 3, every packet on one timeline. On a real bench it follows a connection from CONNECT_IND through a 2M PHY switch into encryption, decrypts it, reads the sync transfer out of it and then follows the PAwR train event by event. The radios take new firmware over the same bus in 15 seconds, through a 2.8 KB bootloader I wrote for them.

  • nRF52832
  • nRF5 SDK
  • PPI / GPIOTE
  • SPI slave
  • bare-metal bootloader
  • Raspberry Pi
  • Python
  • BLE 5.4 PAwR
  • AES-CCM
  • Wireshark / pcap
connection events captured by each of two radios while a gateway enrolled an nRF54L15 tag
375 / 376
encrypted PDUs of that connection decrypted with a valid MIC, sync transfer included
41 / 41
to put new firmware on all seven radios over the SPI bus (bootloader: 2,780 bytes)
15 s
spread between four radios measuring a 36 ppm crystal shift from the air
0.04 ppm
Raccoon: seven nRF52832 sniffer boards and a Raspberry Pi 3 on a 3D-printed frame

What it does on a real bench

The test: an nRF5340 gateway enrols an nRF54L15 tag into a PAwR network (periodic advertising with responses, the Bluetooth 5.4 mode behind electronic shelf labels). That takes one short connection: pair, encrypt, configure, hand over the timing of the periodic train, disconnect. The array listened with all seven radios, and afterwards I could read every step from the air.

Connected case study: this sniffer also confirmed, from the air, the root cause of a PAwR fault I had traced from the other end, a tag crystal running about 175 ppm fast. That investigation is written up in One Packet, Then Silence.

The same seven radios also run as a frequency-hopping link with five nodes sending test traffic on chosen channels, synchronised from the Pi to about 2 µs: the link finds the occupied channels and hops around them. Written up in Hopping Around Occupied Channels.

StepWhat was captured
Connection requestCONNECT_IND on channel 39, 430 µs after the start of the tag's advertisement, heard by both radios on that channel
Following375 of 376 connection events on each of the two radios, over all 37 data channels (CSA#2), through the switch to the 2M PHY at event 19
Decryptionall 41 encrypted PDUs, each verified by its MIC, with the bond key exported from the gateway
Sync transferthe LL_PERIODIC_SYNC_WR_IND at +3.16 s: access address, CRC init, interval and event counter of the PAwR train, matching the tag's own log
The PAwR train157 of 169 events over five minutes, each on the channel computed from its event counter
Clocksgateway +37 to +43 ppm and tag crystal +171 to +179 ppm, measured from packet timing alone
Timeline of the captured enrolment connection: pairing, encryption start, encrypted ATT traffic, the sync transfer at 3.16 s and the connection update that ends the capture The whole connection: 376 events, 5.17 s 0 s 1 s 2 s 3 s 4 s 5 s sync transfer (PAST), +3.16 s connection update new interval takes effect: following ends enlarged below The first 650 ms, one tick per PDU 0 ms 100 ms 200 ms 300 ms 400 ms 500 ms 600 ms C→P P→C +0.00 ms, event 0, C→P: SMP Pairing Request +13.75 ms, event 1, C→P: LL_FEATURE_REQ +14.05 ms, event 1, P→C: LL_PERIPHERAL_FEATURE_REQ +27.50 ms, event 2, C→P: LL_FEATURE_RSP +27.80 ms, event 2, P→C: SMP Pairing Response +41.25 ms, event 3, C→P: SMP Pairing Public Key +41.69 ms, event 3, P→C: LL_FEATURE_RSP +55.00 ms, event 4, C→P: LL_LENGTH_REQ +68.75 ms, event 5, C→P: L2CAP continuation, 27 bytes +69.19 ms, event 5, P→C: LL_LENGTH_RSP +82.50 ms, event 6, C→P: L2CAP continuation, 15 bytes +96.47 ms, event 7, P→C: LL_PHY_REQ +110.22 ms, event 8, P→C: SMP Pairing Public Key +123.75 ms, event 9, C→P: LL_PHY_UPDATE_IND +124.02 ms, event 9, P→C: SMP Pairing Confirm +151.24 ms, event 11, C→P: SMP Pairing Random +165.22 ms, event 12, P→C: SMP Pairing Random +178.74 ms, event 13, C→P: SMP Pairing DHKey Check +192.72 ms, event 14, P→C: SMP Pairing DHKey Check +219.99 ms, event 16, C→P: LL_ENC_REQ +233.97 ms, event 17, P→C: LL_ENC_RSP +261.41 ms, event 19, P→C: LL_START_ENC_REQ +274.97 ms, event 20, C→P: LL_START_ENC_RSP +288.91 ms, event 21, P→C: LL_START_ENC_RSP +302.46 ms, event 22, C→P: ATT MTU Req +316.41 ms, event 23, P→C: ATT MTU Rsp +329.96 ms, event 24, C→P: ATT Find By Type Req +343.91 ms, event 25, P→C: ATT Find By Type Rsp +357.46 ms, event 26, C→P: ATT Find Info Req +371.41 ms, event 27, P→C: ATT Find Info Rsp +384.96 ms, event 28, C→P: ATT Read By Type Req +398.91 ms, event 29, P→C: ATT Read By Type Rsp +412.46 ms, event 30, C→P: ATT Read By Type Req +426.41 ms, event 31, P→C: ATT Error Rsp +439.96 ms, event 32, C→P: ATT Write Req handle 0x0026 value 0100 +453.90 ms, event 33, P→C: ATT Write Rsp +467.46 ms, event 34, C→P: ATT Find By Type Req +481.40 ms, event 35, P→C: ATT Error Rsp +494.96 ms, event 36, C→P: ATT Find By Type Req +508.90 ms, event 37, P→C: ATT Find By Type Rsp +522.46 ms, event 38, C→P: ATT Find Info Req +536.40 ms, event 39, P→C: ATT Find Info Rsp +549.96 ms, event 40, C→P: ATT Read By Type Req +563.90 ms, event 41, P→C: ATT Read By Type Rsp +577.45 ms, event 42, C→P: ATT Read By Type Req +591.40 ms, event 43, P→C: ATT Error Rsp +604.95 ms, event 44, C→P: ATT Write Req handle 0x0019 value 0400 +618.90 ms, event 45, P→C: ATT Write Rsp LE Secure Connections pairing encryption start ATT: discovery and ESL configuration (encrypted) 2M PHY from event 19 SMP (pairing) LL control ATT
Figure 1. The enrolment connection as one radio recorded it (run 4, radio D2). Ticks above the line are from the gateway, below from the tag. The second radio's record decodes to the same 41 PDUs. Hover a tick in the lower band for the PDU.

Getting there took three firmware versions in one evening. That was only practical because the radios no longer need a debugger to be reprogrammed, which is the other half of this page.


Why build a sniffer

A single-radio BLE sniffer hears one channel at a time. A connection hops across up to 37 data channels, and BLE 5 advertising splits into primary packets, auxiliary packets and periodic trains on different channels. To see what a device actually transmits, you need several radios listening at once, and their packets on one timeline. That second part is the one that usually goes missing.

Raccoon started as bluekitchen/raccoon, an open-source nRF5 sniffer firmware with a USB/UART transport and a Python client that can use three dongles for the three advertising channels. I built a seven-radio instrument on top of it:

  • SPI transport and board target. For Adafruit Feather nRF52832s, with a ready-line handshake and a queued, non-blocking transmit path.
  • Hardware time alignment. PPI latches the chip-select edge into the packet timer, so every board's clock is tied to the host clock without CPU jitter.
  • My own bootloader. 2,780 bytes; it updates a radio in place over the same SPI bus.
  • Connection following. In the radio firmware (CSA#1 / CSA#2, PHY updates), with host tools that decrypt the result.
  • Periodic advertising: a probe that decodes AuxPtr and SyncInfo, and a follower that puts a radio on the right channel for every event of a PAwR train.
Top-down view of the seven Feather nRF52832 boards in the 3D-printed frame with the SPI harness
Seven Feather nRF52832 boards in a 3D-printed two-level frame. The Pi sits on the top shelf.
The rig on the wall: seven Feather boards on the 3D-printed frame, the Pi and the SPI harness. No sound.
Raspberry Pi 3 host with seven nRF52832 sniffers on a shared SPI bus with per-device CS and RDY lines Raspberry Pi 3 SPI1 master · CS via gpiomem · RDY polling capture · decrypt · firmware update shared SCLK / MOSI / MISO, 4 MHz · one CS and one RDY line per board D0ch 37advertising D1ch 38advertising D2ch 39advertising D3ch 37 / datasecond listener D4ch 38 / datasecond listener D5ch 39 / datasecond listener D6ch 37 / datasecond listener each board: TIMER3 at 1 MHz · packet start → PPI → CC[3] · CS edge → GPIOTE → PPI → CC[5] each board: 4 KB bootloader page, then the sniffer firmware, updated in place over this bus
Figure 2. The rig. D0–D2 always cover the three advertising channels, where connections start and extended advertising points onward. D3–D6 double them up, or are sent by the host to wherever the next packet is expected.

An SPI transport that doesn't lie

Seven USB-serial links would mean seven cables and seven independent clocks. One SPI bus with a chip-select per board gives the Pi a single master clock over every transfer. The cost is that an SPI slave cannot speak first, and the nRF52's SPIS peripheral has a window when it cannot answer at all.

The first version worked for a few dozen packets, then every reply came back as 0xFF. That is the SPIS over-read character, what the peripheral clocks out when the host selects it while it doesn't own a buffer. Re-arming SPIS is asynchronous. nrfx_spis_buffers_set() requests the buffer semaphore and returns at once, and the peripheral only accepts a transaction after it is released again. The ready line went high inside that gap.

The fix follows the hardware's sequence. RDY drops the moment a transfer completes. The next frame is armed, and RDY goes high only on BUFFERS_SET_DONE, when the SPIS can really take a transaction. The radio interrupt never waits for the bus. It drops packets into an 8-slot queue (252 bytes each) and moves on. If the queue is full, a packet is dropped rather than the radio stalled, and since firmware 2.2.0 the next packet carries a flag saying so.

The host side got the matching discipline. It reads only when RDY is high, matches each command reply by its tag and skips unrelated async packets, and drains to idle before a reset.

/* firmware/nrf/transport_spi.c (trimmed) */
static void spis_event_handler(nrfx_spis_evt_t const *e, void *ctx)
{
    if (e->evt_type == NRFX_SPIS_BUFFERS_SET_DONE) {
        /* Buffers loaded, TASKS_RELEASE fired: the SPIS
         * can take a CS transaction only from here on. */
        if (spis_armed_with_data)
            nrf_gpio_pin_set(TRANSPORT_RDY_PIN);
        return;
    }
    if (e->evt_type == NRFX_SPIS_XFER_DONE) {
        /* stop the host before anything else */
        nrf_gpio_pin_clear(TRANSPORT_RDY_PIN);
        ...                     /* take command bytes */
        spis_rearm();           /* next queued packet or idle
                                 * frame; RDY waits for the
                                 * BUFFERS_SET_DONE above */
    }
}

A bus fault that was a second program. In October the rig came back from storage unreliable: a different set of radios answered on every run, replies went missing, version strings arrived with flipped bits, and slowing the SPI clock did not help. It looked like wiring. That last symptom was the clue that it was not: a marginal signal improves when the clock slows down. A capture service I had installed on the Pi months earlier was scanning on the same bus in the background, and every tool started by hand was a second master in software. With it stopped, every radio answered 20 of 20 queries at 1 MHz and at 4 MHz. The tools now take a lock and refuse to run next to it.


One clock for seven radios

Each nRF52 timestamps packets with its own timer running at 1 MHz. To merge seven streams, the host has to know each board's clock offset. Measuring it with a request-response round trip gives milliseconds of uncertainty. Hardware can do much better, because the host already produces a sharp edge on every board: chip select.

On each board, a GPIOTE channel watches the CS pin, and a PPI channel connects its falling edge straight to a capture task of the packet timer. The capture happens in hardware, independent of interrupt latency or what the firmware is doing. A second GPIOTE channel can watch the same pin as the SPIS driver without interfering with it. I used channels 6/7 and PPI 10/11, well above what nrfx allocates, and configured them with raw register writes so the driver's bookkeeping never sees them.

On the Pi, the host reads perf_counter() immediately before and after the GPIO write that drops CS, then reads the latched value afterwards. The bracket is the host-side uncertainty of the edge.

sync_all_devices.py, 50 samples / boardResult
CS-edge bracket4.9 – 9.2 µs
Residual per device± 2.5 µs
Boards passing (< 10 µs bracket)7 / 7
/* transport_spi.c: CS edge → timer capture, no CPU */
NRF_GPIOTE->CONFIG[SYNC_PPI_CS_GPIOTE_CH] =
    (GPIOTE_CONFIG_MODE_Event      << GPIOTE_CONFIG_MODE_Pos) |
    ((uint32_t)SPI_SS_PIN          << GPIOTE_CONFIG_PSEL_Pos) |
    (GPIOTE_CONFIG_POLARITY_HiToLo << GPIOTE_CONFIG_POLARITY_Pos);

NRF_PPI->CH[SYNC_PPI_CS_PPI_CH].EEP =
    (uint32_t)&NRF_GPIOTE->EVENTS_IN[SYNC_PPI_CS_GPIOTE_CH];
NRF_PPI->CH[SYNC_PPI_CS_PPI_CH].TEP =
    (uint32_t)&SNIFFER_TIMER->TASKS_CAPTURE[SYNC_CC_CS];
NRF_PPI->CHEN |= (1UL << SYNC_PPI_CS_PPI_CH);
# host_spi/spi_host_driver.py
t_before = time.perf_counter()
gm.clr()            # CS falls → PPI latches the timer
t_after  = time.perf_counter()
latched = get_cs_sync_timestamp()   # read afterwards
bracket_us = (t_after - t_before) * 1e6

Checking the timestamps against each other

The host alignment says nothing about whether the packet timestamps themselves are right. Seven radios allow a direct test: put several on the same channel, so each packet on air is received by all of them, and compare. Two crystals drift apart in a straight line. Whatever is left after removing that line is timestamp error.

I had a reason to run it. Going back through the firmware in October I saw that the chip-select latch from April and the packet-start latch shared one capture register: TIMER0 on the nRF52832 has four, and three were taken. If the host started a read while a packet was on air, that packet would get the time of the read. On paper that is a late timestamp, never an early one, and never late by more than the length of a packet.

TIMER3 has six capture registers. I moved the whole timebase there, gave each source its own register, and ran the test with both firmware versions in the array at once, two radios each, on the same packets. The old firmware showed exactly the predicted signature: about 1.6 % of packets late by 20 to 336 µs, none early. The new one showed none.

Timestamp difference for the same packet on two radios over 120 seconds: within 3 microseconds for every packet when both radios run the new firmware, and with 72 packets up to 330 microseconds late when one radio runs the old firmware New firmware on both radios 3,998 packets, all between -3.2 and +2.0 µs -5 0 +5 Old firmware on one of the two radios 4,510 packets, 72 stamped late by up to 330 µs 0 100 200 300 0 s 20 s 40 s 60 s 80 s 100 s 120 s y: timestamp difference for the same packet on two radios, drift removed, in µs. Note the scales: ±5 above, 0 to 350 below. one packet, timestamps agree (a sample of them is drawn) one packet stamped late by the old firmware
Figure 3. Same channel, same 120 seconds, same packets. Top: two radios on the new firmware agree on every one of 3,998 packets to within about 3 µs (standard deviation 1.0 µs); the slow bend is what a straight-line drift model leaves behind. Bottom, on a scale seventy times coarser: a radio on the previous firmware against one on the new, 72 of 4,510 stamped late. All four old-against-new pairs showed 1.5 to 1.7 %; the crystals of the four radios differed by −5.4 to +2.7 ppm.

New firmware in 15 seconds

Every firmware change used to mean wiring a debug probe to seven boards in turn. A sniffer that is tedious to change does not get changed. So the radios got a bootloader that loads firmware over the SPI bus they already use for packets: no extra wire, no extra pin.

The design is deliberately small: one firmware slot, updated in place, no second copy to fall back to, no cryptography. That is the right size for bench equipment, and it makes one property essential: an interrupted update must never leave a radio that needs the debugger again. Three rules give that.

  • The bootloader never writes its own page. It write-protects that page before starting the firmware.
  • The commit is one 32-bit write. An image is bootable only while its magic word is in flash. The update writes everything else, then computes the CRC of the slot as it will read once the word is there, and only then programs it.
  • Every boot checks the CRC again. A slot that was never finished stays under the bootloader's control.

The image header sits in the four vector-table entries a Cortex-M never reads, so the firmware needs no special linker section: build at the new base address, run one script over the binary. The bootloader decides whether to stay before it configures any peripheral and only ever leaves through a reset, so the firmware always starts from reset state.

The way back in when a new firmware boots but does not listen: hold that radio's chip select low through a reset. It has to stay low for about 20 ms, ten times longer than the longest SPI transaction at 1 MHz, and a valid command has to follow within three seconds.

/* bootloader/boot_core.c: END command (trimmed) */
/* CRC of the slot as it will read once the
 * magic word is in place. */
wr32(magic_bytes, magic);
crc = crc32_update(0xFFFFFFFFu, img, IMG_OFF_MAGIC);
crc = crc32_update(crc, magic_bytes, 4);
crc = ~crc32_update(crc, img + IMG_OFF_SIZE,
                    upd_size - IMG_OFF_SIZE);
if (crc != upd_crc || crc != rd32(img + upd_size)
        || rd32(img + IMG_OFF_SIZE) != upd_size) {
    return BP_ST_IMAGE_CRC;
}

hal_flash_write(APP_BASE + IMG_OFF_MAGIC, &magic, 1);
if (!app_valid()) {
    return BP_ST_FLASH;
}
updating = false;
app_ok = true;
return BP_ST_OK;

I tested it before it touched a chip. All the logic is one C file with no register access; for the tests it is compiled for the PC against a simulated chip with NOR flash rules (programming only clears bits), a register that survives resets and a power switch. Every boot is a fresh process, so RAM really starts from zero, and the Python code that later ran on the Pi drives it through a pipe. One test cuts the power after each of the 2,505 flash operations of an update in turn. In every case the radio either stays in the bootloader and accepts the image again, or runs the complete image.

On the radiosResult
Size2,780 bytes in one 4 KB flash page, 356 bytes of RAM, no SDK drivers, no interrupts
First run on hardwareworked as built: boot, enter and leave, update, recovery strap
Update of one radio (36.5 KB image)1.3 – 1.5 s at 1 MHz, 0.9 s at 4 MHz, written and read back
All seven, one after another, each checked before the next15.0 s
Update stopped at 50 %, with and without a resetradio reports "no valid application" and takes the image again
Used the same eveningfirmware 2.1.0 (timer fix above) and 2.2.0 (address filter below) on all seven radios

Following a connection

A connection announces everything needed to follow it in one packet: CONNECT_IND carries the access address, CRC init, hop increment, channel map and interval, plus the channel-selection algorithm. The radio that hears it leaves the advertising channel and hops along with the connection, re-anchoring its timer on every packet from the central.

The first run against the real gateway followed the connection across all 37 channels and through the 2M PHY switch, and each of the two following radios recorded 56 and 58 % of the events. The pattern of the misses was the useful part. The two radios did not miss the same events. For long stretches they alternated: one got event n, the other n+1. Two receivers with independent RF problems don't take turns. Something they share was rationing them.

They share the SPI bus. The other five radios sat on busy advertising channels and forwarded every packet from every device in range, 75 to 120 per second each. The host could not drain seven queues fast enough, so the following radios' 8-slot queues overflowed and dropped packets that had been received correctly.

The fix belongs in the radio interrupt, before the packet takes a queue slot: when a target address is set, advertising-channel packets that don't involve it are not forwarded. That went to all seven radios over SPI, and the same test ran again a quarter of an hour after the first.

/* firmware/nrf/main.c: radio interrupt (trimmed) */
static bool adv_pdu_involves_filter_address(
        const uint8_t *pdu) {
    uint8_t type = pdu[0] & 0x0f;
    uint8_t len  = pdu[1];

    if (len >= 6 && memcmp(&pdu[2],
            ctx.connection_filter_address, 6) == 0) {
        return true;                /* AdvA */
    }
    /* SCAN_REQ, CONNECT_IND, ADV_DIRECT_IND carry
     * the advertiser as their second address */
    if ((type == PDU_ADV_TYPE_DIRECT_IND
            || type == PDU_ADV_TYPE_SCAN_REQ
            || type == PDU_ADV_TYPE_CONNECT_IND)
            && len >= 12 && memcmp(&pdu[8],
            ctx.connection_filter_address, 6) == 0) {
        return true;
    }
    return false;
}
...
if (ctx.mode == FOLLOW_CONNECT && ctx.filter_forwarding
        && !adv_pdu_involves_filter_address(curBuf)) {
    queue_packet = 0;   /* buffer is reused */
}
Connection events captured by the two following radios: 56 and 58 percent with unfiltered advertising traffic on the shared bus, 375 of 376 on each radio with the address filter All radios forwarding every advertising packet: 73,308 frames on the bus D2 221 of 384 connection events (58 %) D5 214 of 384 connection events (56 %) Radios forwarding only packets that involve the tag: 6,747 frames D2 375 of 376 connection events (100 %) D5 375 of 376 connection events (100 %) x: connection event number, 13.75 ms each. Green: the central's packet of that event was captured by this radio.
Figure 4. The same enrolment, before and after the filter. In the upper pair the two radios take turns for long stretches, which is what pointed at the shared bus. With the filter the bus carried a tenth of the frames and each radio missed one event; the tag's replies were captured in 125 events as well.

Reading what is inside

The pairing itself is in the clear, and so are the two PDUs that start encryption: LL_ENC_REQ and LL_ENC_RSP each carry half of the session key diversifier and half of the nonce's IV. With the long-term key, which this gateway can export for its own bonds, the session key is one AES block away. From there every PDU is AES-CCM with a 4-byte MIC, and the MIC is what makes the result trustworthy: a wrong key, a wrong packet counter or a wrong guess at the direction fails the check and never produces plausible garbage.

I let the MIC do more of the work than planned. An event can hold more than two packets, so "first packet is the central's" is sometimes wrong. The decoder tries the other direction and a few counter values ahead, and accepts only what verifies. Both radios' recordings decode to the same 41 PDUs with no failures.

The last decoded line explains the one event the radios did not follow: the gateway changes the connection interval at event 375, and that instruction is encrypted. The radios hop on the old schedule from there and lose the connection shortly before the gateway closes it.

# tools/decrypt_conn.py, run 4, radio D2 (excerpt)
+   0.00 ms ev   0 ch 11 C→P SMP Pairing Request
+ 123.75 ms ev   9 ch 12 C→P LL_PHY_UPDATE_IND
                         2M both ways, instant 19
+ 178.74 ms ev  13 ch 12 C→P SMP Pairing DHKey Check
+ 219.99 ms ev  16 ch 25 C→P LL_ENC_REQ
+ 233.97 ms ev  17 ch  5 P→C LL_ENC_RSP
+ 261.41 ms ev  19 ch 34 P→C LL_START_ENC_REQ
+ 274.97 ms ev  20 ch  3 C→P LL_START_ENC_RSP
+ 302.46 ms ev  22 ch 19 C→P ATT MTU Req
+ 439.96 ms ev  32 ch 16 C→P ATT Write Req 0x0026
+1499.28 ms ev 109 ch  5 P→C ATT Notification 0x0025
+3162.73 ms ev 230 ch 16 C→P LL_PERIODIC_SYNC_WR_IND
   periodic AA 0xDC8A6272, CRC init 0xC1BCC9,
   interval 1408 × 1.25 ms, event counter 16164,
   SID 8, 2M PHY, 1 subevent every 13.75 ms
+5018.52 ms ev 365 ch 34 C→P LL_CONNECTION_UPDATE_IND
   interval 50 ms, instant 375

# the tag's own console, same moment
PER_ADV_SYNC[0]: synced, Interval 0x0580 (1408),
  PHY 2, sync info sid 8
#SYNC_CB:idx=0,sid=8,phy=2,int=1408,num=1,sub_int=11

From the connection to the PAwR train

A PAwR train is hard to find from outside. One packet every 1.76 seconds, each on a different one of 37 channels, on an access address that this gateway does not announce on the advertising channels. The sync transfer hands a tag everything it needs to find it, and the decoded connection now hands the same to the sniffer.

The channel of each event comes from CSA#2 with the train's access address and the event counter. The transfer gave the counter at one moment, so the counter now is an estimate. The follower parks the seven radios on seven channels, waits until the train lands on two of them, and keeps the counter value that explains both sightings. In the five-minute run the estimate from the transfer was 16634.3 and the counter it settled on was 16634.

From there the host keeps a radio waiting on the right channel for each of the next five events, and re-times its schedule from every packet it sees, so it runs on the gateway's clock and not the Pi's.

Because periodic packets sit on an exact grid, their timestamps also give the transmitter's clock. Against five sniffer radios the gateway's 1.76 s period is short by 37 to 43 ppm, inside the 31–50 ppm class it declares in its SyncInfo.

# host_spi/exp_pawr_follow.py: find the event counter
hits.append((rec['host_time'], rec['channel']))
t1, ch1 = hits[0]
guess = round(estimate(t1))
cands = [c for c in range(guess - 40, guess + 41)
         if channel(c) == ch1
         and all(channel(c + round((t - t1) / period)) == ch
                 for t, ch in hits[1:])]
if len(cands) == 1:
    anchor = (t1, cands[0])

# run 2
  seen on channel 25: 4 candidate counter(s)
  seen on channel 15: 1 candidate counter(s)
[+] event counter 16634
    (estimate from the sync transfer was 16634.3)
PAwR follow: 157 of 169 events captured over five minutes, and the first 36 events showing the predicted channel of each event with the captured packet on it All 169 events of the five minutes 157 captured, 12 missed 16645 16673 16701 16729 16757 16785 16813 x: event counter, 1.76 s per event The first 36 events, with the channel each one was on 14 16645 7 16646 5 16647 18 16648 6 16649 4 16650 16 16651 24 16652 22 16653 33 16654 17 16655 18 16656 22 16657 5 16658 33 16659 10 16660 12 16661 24 16662 5 16663 16 16664 22 16665 9 16666 14 16667 35 16668 12 16669 1 16670 6 16671 12 16672 30 16673 17 16674 6 16675 14 16676 6 16677 7 16678 26 16679 33 16680 packet received on that channel nothing received large: data channel · small: event counter
Figure 5. Top: every event of the five-minute run, green where a radio caught it (157 of 169). Bottom: the first 36 events. The number is the data channel the host computed for that event before it happened and sent a radio to. A green cell means the packet arrived there; a wrong computation would leave the cell empty.

Measuring a crystal from the air

The PAwR investigation ended at a 32 kHz crystal running about 175 ppm fast on a board that declared 50 ppm. That number came from a counter on the chip itself. The sniffer gives an independent reading that needs nothing from the device under test except that it transmits.

A second unit of the same nRF54L15 board ran a beacon with periodic advertising every 20 ms. Four radios each caught around 600 of its packets in eight minutes and fitted the period. Then the same with the crystal's load capacitance changed from 15.5 to 17 pF.

Each radio reads a different absolute value, because each compares the beacon against its own crystal. The difference between the two settings cancels that: 36.15, 36.14, 36.17 and 36.18 ppm on the four radios. The on-chip meter had said 36.2. Its absolute readings sit 4 to 11 ppm above the radios', which is the ordinary tolerance between the board's 32 MHz crystal and theirs.

Radio15.5 pF17 pFDifference
D3+172.60+136.4536.15
D4+173.98+137.8436.14
D5+179.49+143.3236.17
D6+170.77+134.5936.18
On-chip meter+183.3+147.136.2

ppm, transmitter fast. Fit residuals 0.4 – 0.7 µs RMS in the 17 pF run (n = 376 – 409 per radio over 300 s) and 4.2 – 5.2 µs in the 15.5 pF run (n = 590 – 648 over 480 s).

Clock error of the nRF54L15 board measured from the air by four sniffer radios at two crystal load settings, with the on-chip meter readings for comparison +130 +140 +150 +160 +170 +180 +190 on-chip meter, 15.5 pF: +183.3 on-chip meter, 17 pF: +147.1 D3 36.15 ppm D4 36.14 ppm D5 36.17 ppm D6 36.18 ppm x: transmitter clock error as seen by each sniffer radio (ppm). Magenta: 15.5 pF load. Cyan: 17 pF. The on-chip meter measures against the board's own 32 MHz crystal.
Figure 6. Four radios, four absolute readings, one difference. A sniffer with ordinary crystals resolves a change in someone else's crystal to a few hundredths of a ppm, as long as the same radio measures both.

Earlier use

Advertising capture, 90 s

3 radios on 37 / 38 / 39, BLE devices nearbyValue
Packets23,073
Average rate256 pkt/s
Channel split 37 / 38 / 3938 / 32 / 30 %
RSSI median (range)−79 dBm (−99 … −38)
pcap size1.5 MB

PAwR investigation, May 2026

An nRF54L15 access point reported success at every step, but its tags lost sync after one periodic event. I added a probe mode: decode ADV_EXT_IND headers (SID, AuxPtr), send a secondary radio to the channel and PHY each CRC-good AuxPtr points at, and decode SyncInfo. A direct mode then listens on the periodic train's own access address.

Before using it on the real device, I validated it against a signal generator I wrote for an nRF54L15. On the access point it produced 4,642 CRC-good target records in one run, and those captures later confirmed the clock error from the air. The full story is in One Packet, Then Silence.

Raspberry Pi 3 on the top shelf with the Feather boards mounted below
Pi 3 host on the top shelf.
Top-down view of the SPI wiring harness
Shared bus plus per-board CS and RDY.
Wiring bundle routed through the frame cross-members
Harness routed through the frame.

Pin assignment (Pi 3, SPI1)

DeviceRoleCS GPIO (pin)RDY GPIO (pin)
D0adv 37GPIO8 (24)GPIO25 (22)
D1adv 38GPIO7 (26)GPIO24 (18)
D2adv 39GPIO5 (29)GPIO23 (16)
D3dataGPIO6 (31)GPIO22 (15)
D4dataGPIO12 (32)GPIO27 (13)
D5dataGPIO13 (33)GPIO17 (11)
D6dataGPIO16 (36)GPIO4 (7)

Shared: SCLK GPIO21, MOSI GPIO20, MISO GPIO19.


Where it stands, and next

  • PAwR responses. The gateway logged the tag's answers during the captures; radios waiting on the subevent's channel with the response access address from the sync transfer recorded none. That is the next experiment: one of my assumptions about where a response is sent is wrong.
  • Following past a connection update. The radios lose an encrypted connection when its parameters change. The host can already decrypt; feeding the new parameters back to the radios in time is the missing step.
  • More radios per connection. Today only the radios that hear the CONNECT_IND follow. The host-side placement of the other radios on predicted events exists and did not engage in these runs.
  • Sync wire. The firmware latches a shared sync pulse into its own capture register; the wire itself is not on every board yet.
  • nRF52840 nodes for Coded PHY and more queue RAM. The boards are on the bench.
Array of Adafruit Feather nRF52840 boards planned as next-generation sniffer nodes
nRF52840 Feathers for the next generation of nodes.

Numbers on this page are from runs on 1 October 2026 (timestamp comparison, clock captures, connection runs 1 and 4, PAwR run 2) and the April and May validation runs. The firmware is a fork of bluekitchen/raccoon; the bootloader, host tools and test simulator are my own.