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:
- Identity: WACN, System ID, NAC, RFSS ID, Site ID
- Channel: control channel frequency, full IDEN_UP band plan (FDMA + TDMA)
- Topology: neighbor sites with frequencies (resolved through the band plan)
- Signal quality: RSSI (mean + peak, dBFS), BER, decode rate
- RR enrichment (with
--rr): system name, site description, frequency offset vs database, neighbor diff
Outputs 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.jsonSingle-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 --helpThe 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:
- Python 3.10+
- GNU Radio 3.10+
gr-osmosdrwith the driver matching your SDR (RTL-SDR / Airspy / HackRF)gr-op25_repeaterfrom boatbod/op25devbranch (ormasteroncedevis 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-pybind11Then 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 ldconfigPure 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 abovenoise_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 broadcasts; resolve neighbor 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--gainfor 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.jsonDECISIONS.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.
