📡 strands-radio
Turn a Crazyradio PA (Nordic nRF24 / Enhanced ShockBurst, 2.4 GHz) USB dongle into a general-purpose radio and a lightweight pub/sub message bus — exposed as a single Strands agent tool.
Built and tested on a Thor device with Crazyradio PA firmware 5.4,
cflib 0.1.32, pyusb 1.3.1.
🔌 Use as an MCP server
Use strands-radio from Claude Code, Claude Desktop, Cursor, Kiro, or any MCP client — the strands_radio tool (spectrum sweeps, link quality, CRTP) becomes an MCP tool.
claude mcp add radio -- uvx strands-radio
Claude Desktop config:
{
"mcpServers": {
"radio": {
"command": "uvx",
"args": ["strands-radio"]
}
}
}
Options:
strands-radio --http --port 8000 # HTTP mode, multi-client
What it does
Three layers, one tool (strands_radio):
| Layer | Actions | Purpose |
|---|---|---|
| RAW | info, set_power, scan_spectrum, send_raw, carrier_on/off |
Direct RF control: channels 0–125, rates 250K/1M/2M, power −18…0 dBm, spectrum scan, continuous-carrier RF test |
| PUB/SUB | publish, subscribe, unsubscribe, poll, drain, listen |
Send/receive messages over the air. Topic name → deterministic (channel, address) pipe |
| CRTP | scan_drones, drone_info, drone_setpoint |
Talk to Crazyflie drones via cflib's stack |
| PRX | prx_start/send/poll/stop, listen_start/poll/stop |
True receive-side: Crazyflie-as-PRX (appchannel) or reassembling dongle listener |
| SPECTRUM | capabilities, probe_sweep, quality_sweep |
Honest 2.4GHz sensing: nRF24 device discovery + per-channel link quality (NO passive RSSI — see capabilities) |
How pub/sub works over nRF24
The Crazyradio is a PTX (primary transmitter): it transmits and receives data back through the nRF24 auto-ACK payload. strands-radio uses this:
- Topic → pipe:
sha256(topic)deterministically yields a channel (2–99) and a 5-byte pipe address. Any two nodes thatsubscribe/publishto the same topic string automatically rendezvous on the same RF pipe — no config. - publish: TX framed packets on the topic's pipe.
delivered=Truemeans a peer ACKed. Auto-chunks payloads > 28 bytes. - subscribe: a background poller round-robins over subscribed topics,
sending PINGs and draining any ACK-payload data peers piggyback. Messages land
in a per-topic inbox (
pollblocking,drainnon-blocking).
Frame (≤32-byte nRF24 budget): [magic 0x5A][type][topic-hash][seq][payload…]
Install
pip install -e . # needs cflib, pyusb (auto-installed)
Linux: add a udev rule so non-root can access the dongle:
# /etc/udev/rules.d/99-crazyradio.rules
SUBSYSTEM=="usb", ATTRS{idVendor}=="1915", ATTRS{idProduct}=="7777", MODE="0664", GROUP="plugdev"
sudo udevadm control --reload-rules && sudo udevadm trigger
Use as an agent tool
from strands import Agent
from strands_radio import strands_radio
agent = Agent(tools=[strands_radio])
agent("publish 'motors armed' to topic swarm/cmd")
agent("listen on topic telemetry for 5 seconds")
agent("scan for crazyflie drones")
Or with DevDuck: manage_tools(action='add', tools='/path/to/tools/radio_tool.py')
Direct calls
from strands_radio import strands_radio as R
R(action="info")
R(action="set_power", power="0") # max TX power
R(action="scan_spectrum", start=0, stop=125) # find active channels
R(action="publish", topic="telemetry", message="v=3.7")
R(action="subscribe", topic="telemetry")
R(action="poll", topic="telemetry", timeout=2)
R(action="send_raw", channel=80, address="E7E7E7E7E7", hexdata="ff00aa")
R(action="carrier_on", channel=42) # RF test tone
R(action="scan_drones")
R(action="drone_info", uri="radio://0/80/2M/E7E7E7E7E7")
Two-node loopback test
Run python tests/loopback.py pub on one radio node and
python tests/loopback.py sub on another (each with its own Crazyradio) —
they'll exchange messages on topic strands.loopback with zero configuration.
PRX / Listener mode (true receive-side)
Hardware truth: stock Crazyradio firmware is PTX-only — there is no vendor command to make a bare dongle a primary receiver. So two bare dongles cannot hear each other directly. strands-radio provides two genuine listener paths:
1. Crazyflie as PRX (real bidirectional) — recommended
A powered Crazyflie is a receiver. Its appchannel carries arbitrary app
data both ways with true async delivery:
R(action="prx_start", uri="radio://0/80/2M/E7E7E7E7E7") # open appchannel listener
R(action="prx_send", uri="radio://0/80/2M/E7E7E7E7E7", message="hello drone")
R(action="prx_poll", uri="radio://0/80/2M/E7E7E7E7E7", timeout=2) # read RX
R(action="prx_stop", uri="radio://0/80/2M/E7E7E7E7E7")
Requires firmware on the CF that uses the app-channel API (Bitcraze
app_channel example / your own app layer).
2. Dongle-side reassembling listener
For dongle↔dongle (peer piggybacks data on nRF24 ACK payloads). Adds fragment
reassembly (T_DATA_CONT… + final T_DATA) so multi-packet messages arrive
whole, plus a dedicated streaming thread:
R(action="listen_start", topic="telemetry") # background reassembler
R(action="listen_poll", topic="telemetry", timeout=2) # complete messages only
R(action="listen_stop", topic="telemetry")
Demos: python tests/prx_demo.py cf <uri> or python tests/prx_demo.py listen <topic>.
Spectrum / sensing — what's GENUINELY possible
A bare Crazyradio (nRF24L01+, fw 5.4) is not an SDR. It has no passive
RSSI readout and cannot demodulate WiFi/BLE — so it cannot draw a real
spectrum waterfall. Run R(action="capabilities") for the honest boundary.
What it CAN do (all implemented, all real):
| Action | What it measures | Detects |
|---|---|---|
probe_sweep |
TX a probe on every channel×rate, record ACK + RPD bit (~370 probes/s) | nRF24/ESB devices that auto-ack on an address: Crazyflies, other Crazyradios in PRX, nRF24 HID dongles |
quality_sweep |
ACK ratio + mean retransmits per channel to a KNOWN peer | Channel congestion/interference — the same signal Crazyflie firmware uses to pick a clean channel |
carrier_on/off |
Emit a continuous carrier | TX/RF test, jamming-style tone (use responsibly) |
The RPD (powerDet) bit is the nRF24 "received power > −64 dBm" flag — 1 bit,
and only valid when an ACK comes back. quality_sweep renders an ASCII heatmap:
ch 80 2480MHz |████████████████████████████████████████| 100% retry~0.1
ch 60 2460MHz |████████████████████████················| 60% retry~2.1
ch 11 2411MHz |████████································| 20% retry~4.5
So — what can we genuinely achieve with this radio?
- Mesh messaging between Thor nodes / drones (pub/sub, this tool)
- Device discovery — find any nRF24/ESB responder in range across 125 channels
- Automatic channel selection — pick the cleanest 2.4 GHz channel via quality_sweep
- Full Crazyflie control — telemetry, params, commander, app-channel (PRX)
- Link-quality monitoring for a live drone link (retry/ACK trends)
- RF test tones via continuous carrier
For a true passive spectrum analyzer / packet sniffer you'd pair this with an
RTL-SDR, HackRF, or an nRF52840 in promiscuous mode — documented in
capabilities() rather than faked here.
Notes / limits
- One dongle = one owner at a time. The CRTP layer auto-releases the raw bus
before cflib grabs the USB device (avoids
Resource busy). - True async RX is bounded by the PTX/ACK-payload model; for high-throughput bidirectional links, pair with a second dongle or a Crazyflie acting as PRX.
- Respect local RF regulations;
set_powerandcarrier_ontransmit real RF.
License
MIT
Metadata
Release files for strands-radio 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| strands_radio-0.1.0.tar.gz | 23.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| strands_radio-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 47.9 kB
Release files / strands_radio-0.1.0.tar.gz
| Download URL | strands_radio-0.1.0.tar.gz |
|---|---|
| Size | 23.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
67faa464e3020aaadb218a1435596b4ec3fbc07081eac555a6a5e41a4818971d
|
|
BLAKE2b-256 checksum How to use checksums |
9f2b20bcf3b646dba867db3af4e4acb250b8de33f1e7b2e0070908abe678d92b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.12.11
|
Release files / strands_radio-0.1.0-py3-none-any.whl
| Download URL | strands_radio-0.1.0-py3-none-any.whl |
|---|---|
| Size | 24.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
24065664fd4d385a4c980abedbcef0e3ae6e331e3c99ddbac0cfdded2891a987
|
|
BLAKE2b-256 checksum How to use checksums |
2eb5757e7d223546a54058905305ff94acd0e4f46118e5cd0c2f80911ce51bda
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.12.11
|