Metadata-Version: 2.4
Name: strands-radio
Version: 0.1.0
Summary: Crazyradio PA as a general-purpose 2.4GHz radio + pub/sub message bus + Crazyflie CRTP, as a Strands agent tool
Requires-Python: >=3.9
Description-Content-Type: text/markdown
Requires-Dist: cflib>=0.1.20
Requires-Dist: pyusb>=1.2
Requires-Dist: strands-agents
Requires-Dist: strands-mcp-server
Provides-Extra: dev
Requires-Dist: pytest; extra == "dev"

# 📡 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](https://github.com/strands-agents) 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.

```bash
claude mcp add radio -- uvx strands-radio
```

Claude Desktop config:

```json
{
  "mcpServers": {
    "radio": {
      "command": "uvx",
      "args": ["strands-radio"]
    }
  }
}
```

Options:

```bash
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 that `subscribe`/`publish` to the
  same topic string automatically rendezvous on the same RF pipe — no config.
- **publish**: TX framed packets on the topic's pipe. `delivered=True` means 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 (`poll` blocking, `drain` non-blocking).

Frame (≤32-byte nRF24 budget): `[magic 0x5A][type][topic-hash][seq][payload…]`

## Install

```bash
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

```python
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

```python
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:

```python
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:

```python
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_power` and `carrier_on` transmit real RF.

## License
MIT
