How to Capture and Replay BLE Advertising Packets for Testing

Your DUT fails the BLE scan test once every 40 runs on the CI box. It never fails on your desk. The logs show nothing useful, the advertiser you’re testing against is a real beacon sitting 10 feet away, and you’ve started wondering if the problem is the beacon, the air, or your own code. Odds are you’ll never know, because the RF conditions that triggered the failure will never exactly repeat.
The core problem with BLE testing is that the input isn’t deterministic. Until you make it deterministic.
This guide walks through a working BLE packet replay pipeline: capture real advertising traffic once, store it in a portable format, and rebroadcast it on demand against your device under test. The result is a regression suite that actually regresses, not one that flakes. Scope note: passive capture and rebroadcast against your own hardware, for test purposes.
What You Can and Can’t Replay
Advertising PDUs are fair game. ADV_IND, ADV_NONCONN_IND, ADV_SCAN_IND, and SCAN_RSP are all unencrypted broadcasts by design, so you can record the bytes and transmit them back verbatim.
Connection-oriented traffic is out of scope here. Once a central and peripheral establish a link, channel hopping is pseudo-random per connection, and anything interesting is usually encrypted with a session key you don’t have. Don’t try to replay it. It won’t work, and if it does, you’ve found a bug worth reporting.
One caveat about addresses. Resolvable Private Addresses rotate roughly every 15 minutes. A DUT doing IRK-based resolution will correctly fail to resolve your replayed RPA once the original validity window has passed. For test fixtures, prefer captures from devices using static random or public addresses, or accept that the DUT’s address filter has to be relaxed during replay.
Hardware and Tooling
You have four realistic options:
+-------------------+----------+-----------+----------------+
| Tool | Cost | 3-ch cap | Replay support |
+-------------------+----------+-----------+----------------+
| nRF52840 dongle | $10-20 | Yes (FW) | Via nrfutil |
| Sniffle (TI CC26x)| $20-40 | Yes | Native TX |
| Ubertooth One | $120 | Partial | Limited |
| Ellisys BV1/BV3 | $$$$ | Yes | Pro-grade |
+-------------------+----------+-----------+----------------+For most teams, the sweet spot is an nRF52840 dongle running Nordic’s Sniffer firmware for capture, plus a second board running Sniffle for TX replay. Total outlay is around $40. Nordic’s sniffer drops cleanly into Wireshark, which is what you want for inspection. Sniffle (Sultan Qasim Khan’s project, runs on TI CC1352/CC26x2 LaunchPads) will actually transmit advertising PDUs back out, which Nordic’s stock firmware won’t do.
Ubertooth One still works, but its BLE story has been thin for years and channel 39 capture is spotty. Skip it unless you already own one. Ellisys gear is excellent and priced accordingly; if your org has one, use it, but don’t buy one just for replay.
Capturing Advertising Packets
Flash the nRF Sniffer firmware onto the dongle (Nordic ships a .hex and a flashing script). Then:
- Install the nRF Sniffer extcap plugin into Wireshark’s
extcapdirectory. - Launch Wireshark. The dongle shows up as a capture interface.
- In the Sniffer toolbar, pick your target device from the scan list (or leave it on “all advertising”).
- Apply a display filter:
btle.advertising_header.pdu_type. - Capture for 30 to 60 seconds, then export as
.pcapng.
For headless capture boxes, skip Wireshark:
tshark -i nrfsniffer -w adv_capture.pcapng -a duration:30Verify your capture before you trust it. Check three things: packets present on all three advertising channels (37, 38, 39), consistent AdvA across the frames you care about, and no truncated AD structures (Wireshark will flag malformed ones in red). A capture that only has channel 37 packets will replay fine but won’t exercise your DUT’s channel hopping logic.
Parsing the PCAP
Scapy reads .pcapng directly and has a usable BLE layer. Extract payloads and inter-frame timing:
from scapy.all import rdpcap
from scapy.layers.bluetooth4LE import BTLE_ADV
pkts = rdpcap("adv_capture.pcapng")
prev_t = None
for p in pkts:
if p.haslayer(BTLE_ADV):
delta = (p.time - prev_t) if prev_t else 0
prev_t = p.time
print(f"{delta*1000:.2f}ms {p[BTLE_ADV].AdvA} "
f"{bytes(p.payload).hex()}")The timestamps are the point. Advertising behavior is more than sending a PDU. It’s sending that PDU at a particular cadence with a particular amount of jitter across three channels. If you replay packets back-to-back with a fixed sleep(0.1), you’ve built a fake advertiser, not a replay of a real one. Your scanner-side logic may behave differently under the two.
Save the parsed output (deltas + payload bytes) as a JSON or CSV that the replay stage can consume.
Replaying Captured Packets
Two paths.
Option A: Sniffle TX mode. Sniffle’s Python host tool can push raw PDUs down to the TI board, which transmits on the specified channel with configurable timing. This is the fastest path to a working rig, and it handles CRC computation for you.
Option B: Custom nRF52 firmware. You write a small Zephyr or nRF Connect SDK app that accepts (channel, delay_us, pdu_bytes) tuples over UART and drives the radio peripheral directly. More work, but you control every field, including deliberately malformed ones for fuzzing. The nRF radio peripheral’s TIFS handling and CRC generator are both usable from userspace firmware, and the Hubble device SDK samples for Nordic give you a reasonable starting template for BLE TX on nRF52 targets if you want something closer to production-grade than a bare Zephyr sample.
The rig looks like this:
[pcap file]
|
v
+---------+ UART +----------+ RF +-----+
| replay |------------->| nRF52 TX |--------->| DUT |
| script | | board | adv | |
+---------+ +----------+ +-----+Timing reconstruction is where most rigs fall down. Use the captured deltas, not the nominal adv_interval. Real advertisers drift, skip intervals under load, and jitter by the spec-allowed 0-10ms. If your replay is cleaner than reality, you’re testing a beacon that doesn’t exist.
The channel strip for a nominal advertising event looks like this:
t=0ms t=~0.5ms t=~1ms
|ch37|---|ch38|----|ch39|---> repeat every adv_intervalPreserve the inter-channel spacing from the capture. A DUT scanning with a 10ms scan window will see a very different packet set if you compress this to 100us.
Integrating Into Test Automation
Wrap the replay in a pytest fixture keyed on a pcap filename:
@pytest.fixture
def replay(request):
pcap = request.param
proc = subprocess.Popen(["./replay.py", pcap])
yield
proc.terminate()
@pytest.mark.parametrize("replay", [
"fixtures/ibeacon_nominal.pcapng",
"fixtures/ibeacon_noisy.pcapng",
"fixtures/mfr_data_edge.pcapng",
], indirect=True)
def test_dut_ingests_advertisement(replay, dut):
dut.wait_for_log("BEACON_SEEN", timeout=10)
assert dut.scan_table_count() == 1One pcap per scenario. A “noisy” pcap captured near a microwave is a genuinely useful test fixture. Keep them in version control alongside the test code.
For CI, GitHub Actions self-hosted runners with USB passthrough work fine. Cloud-hosted runners don’t, because there’s no radio. Budget for a small physical test node (a NUC in a shielded enclosure with the sniffer, TX board, and DUT) and expose it over SSH to your CI.
Common Pitfalls
- CRC mismatch. If you modify payload bytes, recompute the 24-bit BLE CRC against the advertising access address
0x8E89BED6. Sniffle does this; hand-rolled firmware often doesn’t. - RPA rotation. A DUT that whitelists by resolved identity will reject replayed RPAs once the original validity window elapses. Capture from static-address devices when possible.
- Missing SCAN_RSP. If your DUT is a scanner doing active scans, it sends
SCAN_REQand expectsSCAN_RSP. A replay that only emitsADV_INDwill look broken to the scanner even though the advertising PDUs are perfect. - Watch out for interval regularity. Some DUT state machines expect variance within a bound, and replays that are too clean trigger heuristic filters meant for fake-beacon detection.
- Regulatory. Keep TX power low (-20 dBm is plenty for a 1m bench test) and use a shielded box if you’re anywhere near a certification lab or a sensitive RF environment.
Next: Fuzz the AD Fields
You now have a loop: capture a real advertising sequence, parse it into timestamped PDUs, replay it through a cheap TX board, and wire the whole thing into pytest. The flaky test from the intro is now a deterministic fixture file.
The natural next step is fuzzing: take a captured PDU, mutate individual AD structure fields, recompute the CRC, and replay. That’s how you find the parser bug hiding behind the last if (len > 31) check nobody looked at.
Hubble Network enables BLE devices to reach satellites directly, so the same advertising packets you’re capturing on the bench can be received from orbit without gateways or extra radios. See how it works →