Scan a frequency range with an SDR (RTL-SDR / Airspy / HackRF), identify P25 control channels, and log per-system metadata. Optionally cross-reference findings against RadioReference and auto-tune SDR gain via BER feedback.
For each control channel found, records:
--rr): system name, site description, frequency offset vs database, neighbor diffOutputs three files per scan: NDJSON survey, plain-text human-readable report, and a Markdown submission report listing data not yet in RadioReference.

The live console table updates per candidate as Phase 2 decodes; the RR Match
column shows the matched RadioReference system + site (with frequency offset)
or flags new sites / NAC mismatches as it goes.
# Scan 800 MHz P25 with an Airspy
./p25-survey --start 851 --stop 869 --sdr airspy --gain 14 \
--output /tmp/survey-800.json
# Scan 700 MHz public-safety downlink with RR enrichment
./p25-survey --start 769 --stop 775 --sdr airspy --gain 14 \
--rr --hide-no-cc --output /tmp/survey-700.json
# Find the best gain for your SDR/location/band
./p25-survey --start 769 --stop 775 --sdr airspy --gain 14 \
--rr --auto-gain --output /tmp/survey-700.json
Single-file executable via shiv:
make dev-deps # one-time: install shiv + pytest + numpy/scipy for testing
make # produces ./p25-survey (~85 KB)
make test # 135 unit tests, no SDR or GNU Radio needed
./p25-survey --help
The executable bundles the package only — numpy, scipy, rich, GNU Radio,
and gr-op25_repeater come from the host's Python (this avoids ABI conflicts
with GNU Radio's compiled C extensions).
On the host running the survey:
gr-osmosdr with the driver matching your SDR (RTL-SDR / Airspy / HackRF)gr-op25_repeater from boatbod/op25 dev
branch (or master once dev is merged forward). The two patches this tool
previously required — the audio-destructor guard and FEC stats — are now
upstream, so a stock build works.Runtime stack (GNU Radio + osmosdr + SDR drivers):
sudo apt-get update
sudo apt-get install -y \
gnuradio gnuradio-dev \
gr-osmosdr libosmosdr-dev \
librtlsdr-dev libairspy-dev libhackrf-dev \
python3-pip python3-pybind11
Then build and install op25:
sudo apt-get install -y libcppunit-dev cmake build-essential pkg-config \
libsndfile1-dev libspdlog-dev
git clone https://github.com/boatbod/op25.git
cd op25 && git checkout dev
mkdir build && cd build && cmake .. && make -j$(nproc) && \
sudo make install && sudo ldconfig
Pure unit tests (make test) don't need any of the above.
Three phases per scan:
Energy scan — sweep the SDR's center frequency in chunks of 0.8 ×
sample_rate, FFT each capture, find spectral peaks above
noise_floor + threshold_db. Snap to the band's tuning grid. Output: a
list of candidate frequencies.
P25 decode — for each candidate, run op25's P25 demodulator + frame
assembler with adaptive dwell. Bail at --confirm-timeout (2 s default)
if no P25 broadcasts arrive. Otherwise dwell up to --max-dwell (12 s)
while collecting NET_STS_BCST, RFSS_STS_BCST, IDEN_UP*, ADJ_STS_BCST, and
SCCB (secondary CC) broadcasts; resolve neighbor and secondary-CC
frequencies through the captured band plan.
Optional gain sweep (--auto-gain) — sweep 5 gain values × 4 s dwell
on each confirmed CC, measure BER per gain, recommend the gain that
minimizes block error rate. Per-band median across CCs becomes the
suggested --gain for re-running the scan.
| Flag | Description |
|---|---|
--start MHz |
Start frequency (e.g., 851.0) |
--stop MHz |
Stop frequency (e.g., 870.0) |
--step kHz |
Tuning step. Defaults from the band table below; override for special cases. |
| Flag | Description |
|---|---|
--sdr {rtlsdr,airspy,hackrf} |
Driver. Autoprobed if omitted. |
--device-args ARGS |
Raw gr-osmosdr device args (e.g. airspy=0,LNA=10,MIX=15,IF=12). Lets you set per-stage gain or other driver-specific options. |
--gain dB |
Sets the SDR's default gain stage (Airspy linearity preset 0–21, RTL-SDR tuner, HackRF IF/VGA). Omit for AGC. Use --list-gains to see the available range. For Airspy, see docs/airspy-gain-tables.md for the full preset → LNA/MIX/IF lookup tables and per-stage control via --device-args. |
--ppm PPM |
Frequency correction in PPM. |
--list-gains |
Probe the SDR and print available gain stages, then exit. |
| Flag | Default | Description |
|---|---|---|
--threshold dB |
8 | Phase 1 energy margin above noise floor. Lower → more candidates, weaker signals; higher → faster, only strong signals. See "Tuning the threshold" below. |
--confirm-timeout SECONDS |
2 | Phase 2 abandon-fast: bail if no P25 broadcasts arrive in this window. |
--max-dwell SECONDS |
12 | Hard cap per candidate. Adaptive logic exits earlier when complete. |
--hide-no-cc |
off | Hide no-cc rows from the live console table (still saved to JSON). |
| Flag | Description |
|---|---|
--rr |
Enable RR cross-reference. Prompts for username + password at startup (credentials never stored). Each decoded CC is matched against the RR database; results inline-annotated in the TXT report; non-matching items written to *-submissions.md; per-band ppm offsets summarized at end. |
| Flag | Description |
|---|---|
--auto-gain |
After Phase 2 finishes, sweep gain values on each confirmed CC and print per-CC + per-band recommendations. After the summary, prompts to re-run the scan with the recommended gain. |
--gain-sweep "4,8,12,16,20" |
Custom sweep grid. Defaults to driver-appropriate values. |
| Flag | Description |
|---|---|
--output PATH |
NDJSON survey file. Default: survey-YYYYMMDD-HHMMSS.json. Truncates the file by default; use --resume to append. |
--resume |
Append to an existing --output; skip frequencies already in the file. |
--phase1-only |
Run only Phase 1 (spectrum survey). Skip P25 decode. |
--verbose |
Verbose logging. |
For --output /tmp/survey.json, you get three files:
| File | Format | Contents |
|---|---|---|
/tmp/survey.json |
NDJSON | One record per characterized frequency (CCs and no-cc candidates). Survives crashes (per-record fsync); resumable. |
/tmp/survey.txt |
Plain text | Human-readable report grouped by control channel, with full band plan, neighbor table, RR annotations, and a Non-CC candidates appendix. |
/tmp/survey-submissions.md |
Markdown | Only with --rr. Lists items not in RadioReference: new systems, new sites, frequency mismatches, neighbor diffs. Designed to paste into a forum thread or RR support ticket. |
NDJSON survey schema is documented in docs/schema.md.
--threshold N is the dB margin above the median noise floor that an
FFT bin must clear to be considered a candidate. The trade-off:
| Value | Behavior | When to use |
|---|---|---|
| 6–10 | Sensitive — catches weak signals, more false positives | Hunting weak/distant CCs; rural VHF; willing to spend extra time on no-cc rows |
| 12–16 | Balanced (default 14 in most examples) | First-pass surveys, most US-urban scenarios |
| 18+ | Picky — only strong signals, fast scan | Quick re-scans; densely populated bands where you only care about local sites |
Phase 2 has its own filter (--confirm-timeout abandons a candidate that
produces no P25 broadcasts), so a low threshold mostly costs scan time, not
final-report quality. Non-P25 noise gets filtered at the decode step
regardless.
Auto-selected from the start frequency. Override with --step <kHz>.
| Band | Range | Default step | Notes |
|---|---|---|---|
| VHF land mobile | 150–174 MHz | 7.5 kHz | Post-narrowband raster |
| UHF land mobile | 380–512 MHz | 12.5 kHz | Post-narrowband |
| 700 MHz PS narrowband downlink | 769–775 MHz | 6.25 kHz | base TX — CCs broadcast here |
| 700 MHz PS narrowband uplink | 799–805 MHz | 6.25 kHz | mobile TX |
| 800 MHz PS rebanded | 851–869 MHz | 12.5 kHz | base TX — CCs broadcast here |
# Texas 800 MHz statewide PS (TXWARN, regional systems, etc.)
./p25-survey --start 851 --stop 869 --sdr airspy --gain 14 \
--threshold 14 --rr --hide-no-cc --output /tmp/survey-800.json
# 700 MHz PS narrowband downlink (DART, NCTCOG, etc. for DFW area)
./p25-survey --start 769 --stop 775 --sdr airspy --gain 14 \
--threshold 14 --rr --hide-no-cc --output /tmp/survey-700.json
# VHF land mobile — be generous with threshold; lots of non-P25 noise
./p25-survey --start 150 --stop 174 --sdr airspy --gain 14 \
--threshold 8 --rr --hide-no-cc --output /tmp/survey-vhf.json
# UHF land mobile (federal, state, narrowbanded business)
./p25-survey --start 380 --stop 512 --sdr airspy --gain 14 \
--threshold 12 --rr --hide-no-cc --output /tmp/survey-uhf.json
# Calibrate gain for an unfamiliar SDR/location:
./p25-survey --start 851 --stop 869 --sdr airspy --gain 14 \
--rr --auto-gain --output /tmp/cal.json
# (At the end, accept the rescan prompt to validate the recommendation.)
Ctrl-C exits gracefully. Records decoded up to that point are already on
disk (per-record fsync). Resume with --resume:
# First attempt got Ctrl-C'd partway through:
./p25-survey --start 851 --stop 869 --sdr airspy --rr \
--output /tmp/survey-800.json
# ... ^C ...
# Pick up where it left off:
./p25-survey --start 851 --stop 869 --sdr airspy --rr \
--resume --output /tmp/survey-800.json
DECISIONS.md — every architectural choice with rationale.PLAN.md — module layout, algorithms, milestones.docs/schema.md — NDJSON survey record schema.docs/airspy-gain-tables.md — Airspy linearity/sensitivity preset → LNA/MIX/IF lookup tables, snapshot from libairspy.GPL-3.0-or-later. Required because gr-op25_repeater is GPLv3.
$ claude mcp add p25-survey \
-- python -m otcore.mcp_server <graph>