Turns an ADALM-Pluto SDR into a low-power pulsed-CW beacon on the 222 MHz amateur band, for testing direction-finding receivers.
It emulates the (discontinued) Communications Specialists RC-SP/MP/HP rocket and R/C model tracking transmitters: 25 ms unmodulated CW pulses at 50 beats/minute, with a periodic morse callsign ID. Built to exercise an R-300A DF receiver, and compatible with the PR-100 as well.
Transmitting requires an amateur radio license with privileges on the band you use. The beacon refuses to key up until you configure a callsign.
Requires macOS with Homebrew, an ADALM-Pluto, and Python 3.
python3 -m venv .venv
./.venv/bin/pip install pyserial pylibiio pyadi-iio numpy scipylibiio must be built from source — there is no Homebrew formula:
git clone --depth 1 -b v0.26 https://github.com/analogdevicesinc/libiio.git
cmake -S libiio -B libiio/build -DCMAKE_INSTALL_PREFIX=/opt/homebrew \
-DOSX_PACKAGE=OFF -DOSX_FRAMEWORK=OFF -DCMAKE_BUILD_TYPE=Release \
-DLIBUSB_LIBRARIES=/opt/homebrew/lib/libusb-1.0.dylib \
-DLIBUSB_INCLUDE_DIR=/opt/homebrew/include/libusb-1.0
cmake --build libiio/build -j8 && cmake --install libiio/buildA stock Pluto will not transmit below 325 MHz. It must be switched to AD9364 mode. SSH does not work on macOS — even current firmware only exposes RNDIS networking, which macOS does not support — so use the USB serial console:
./.venv/bin/python tools/pluto_serial.py \
'fw_setenv attr_name compatible' 'fw_setenv attr_val ad9364' 'reboot'./.venv/bin/python beacon.py set --callsign YOURCALL # required, stored locally
./.venv/bin/python beacon.py run| Command | What it does |
|---|---|
run |
Start the beacon. --gain 0 for full power (~5 mW), --fm-tone 1000 to make it audible on an FM handheld |
sweep |
Step the carrier across a span, announcing each step in morse — finds the crystal offset by ear |
carrier |
Steady carrier, for frequency measurement |
calibrate |
Store a measured frequency error |
show / set |
Inspect or change persisted config |
selftest.py transmits into the Pluto's own receiver and verifies pulse
width, period, and tone offset. calibrate.py measures the crystal error
against NOAA weather radio (needs an antenna on the RX port).
The default is 223.110 MHz, chosen because both receivers cover it: PR-100 channel 00, R-300 channel 311.
The two receivers use different channel numbering and different coverage:
| Coverage | Numbering | |
|---|---|---|
| PR-100 | 222.250 – 223.290, 20 kHz steps (50 ch) | ch 60–99, then 00–09 |
| R-300 | 222.070 – 224.990, 20 kHz steps | ch = (MHz − 220) × 100 |
The PR-100 is the limiting receiver, so any frequency meant to work on both must fall inside its range and on the shared 20 kHz grid. Note that grid is deliberately offset 10 kHz from the coordinated FM repeater-input channels in this segment. Check with your regional frequency coordinator before putting a beacon in the air.
A stock Pluto's crystal is spec'd at ±25 ppm — up to ±5.6 kHz at 223 MHz. The receivers this emulates have 8-pole crystal filters and only ±500 Hz (PR-100A) to ±1 kHz (R-300A) of fine tuning, so an uncalibrated beacon is simply inaudible, which is indistinguishable from a dead receiver. The real CSI hardware uses a 0.5 ppm TCXO on both ends.
To calibrate against your receiver: park it on a channel, run beacon.py sweep,
note the step where the signal peaks, then feed that back with
beacon.py calibrate --measured <channel freq> --nominal <step freq>. The
correction is stored in ppm and applies at any frequency.
See CLAUDE.md for detailed hardware notes and troubleshooting history.
MIT — see LICENSE.
beacon_config.json holds your callsign and your device's calibration, and is
gitignored. Copy beacon_config.example.json to start. The callsign can also
come from $BEACON_CALLSIGN.