Files
istrain 559caead36 istrain-public: from-scratch build of a passive RF train detector
Public community release. The complete working system — DSP + decoders (scripts/),
web dashboard + API (dashboard/), container stack (docker/), config templates (config/) —
plus APOCALYPSE-EDITION.md, the full build guide for a human or a coding agent. GPLv3
(PyEOT dependency). Scrubbed of all secrets, internal IPs, hostnames, and location detail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 23:19:16 -04:00

126 lines
5.2 KiB
Python
Executable File

#!/usr/bin/env python3
"""probe-band — console first-light: tune a dongle to a band, snapshot the spectrum, print
what's there. The "can we hear anything?" tool, before we build decoders or dashboard panels.
a RADIO RUNTIME, not the code author. This OPERATES a radio (tunes + samples). Run it yourself; the maintainer builds it.
a generic band probe is engine-level (every radio project
wants "what's on this band, and does my receiver even work"). Only the presets below are profile.
Recommended order — validate the KNOWN-GOOD first, then the target:
1. noaa — 162.400-162.550 MHz · NOAA Weather, always-on. If we see THIS, the receiver +
antenna + chain all work. On 1002 (the voice stick, 18" whip — it's 162 MHz).
2. eot — 457.5-458.5 MHz · EOT/FRED telemetry (457.9375) on 1001 (~6" whip, matched). Sparse
— may be quiet with no train near. Seeing the noise floor cleanly still proves the path.
Usage:
./probe-band.py noaa # known-good sanity check first
./probe-band.py eot
SERIAL=1002 LO=162.4M HI=162.55M GAIN=40 SECONDS=8 ./probe-band.py # fully manual
GAIN env: "auto" or a dB number (try 30-49 for weak signals). Take notes per run.
"""
import os, sys, glob, shutil, subprocess, tempfile, statistics
PRESETS = {
# name : (serial, lo, hi, note)
"noaa": ("1002", "162.400M", "162.550M", "NOAA Weather — always-on KNOWN-GOOD sanity check (voice stick)"),
"eot": ("1001", "457.500M", "458.500M", "EOT/FRED 457.9375 — sparse, may be quiet"),
}
preset = sys.argv[1] if len(sys.argv) > 1 and not sys.argv[1].startswith("-") else None
p = PRESETS.get(preset, (None, None, None, "manual"))
SERIAL = os.environ.get("SERIAL", p[0] or "1001")
LO = os.environ.get("LO", p[1] or "162.400M")
HI = os.environ.get("HI", p[2] or "162.550M")
BIN = os.environ.get("BIN", "3k")
SECONDS = os.environ.get("SECONDS", "8")
GAIN = os.environ.get("GAIN", "auto")
TOPN = int(os.environ.get("TOPN", "12"))
NOTE = p[3]
def die(msg, code=1):
print(f"probe-band: {msg}", file=sys.stderr); sys.exit(code)
def preflight():
if not shutil.which("rtl_power"):
die("rtl_power not found — install the 'rtl-sdr' package.")
for d in glob.glob("/sys/bus/usb/devices/*"):
try:
if open(os.path.join(d, "idVendor")).read().strip() != "0bda": continue
if open(os.path.join(d, "idProduct")).read().strip() != "2838": continue
if open(os.path.join(d, "serial")).read().strip() != SERIAL: continue
except OSError:
continue
for intf in glob.glob(d + ":*"):
link = os.path.join(intf, "driver")
if os.path.islink(link) and os.path.basename(os.readlink(link)) == "dvb_usb_rtl28xxu":
die(f"dongle {SERIAL} is DVB-claimed — free it: "
f"sudo modprobe -r rtl2832_sdr dvb_usb_rtl28xxu")
return
die(f"dongle serial {SERIAL} not present (check the radios panel / `rtl_test`).")
def run_sweep(csv_path):
cmd = ["rtl_power", "-d", SERIAL, "-f", f"{LO}:{HI}:{BIN}", "-i", SECONDS, "-1", csv_path]
if GAIN != "auto":
cmd[3:3] = ["-g", GAIN]
print(f"# probing {LO}-{HI} @ {BIN}, {SECONDS}s, dongle :{SERIAL} (gain={GAIN})", file=sys.stderr)
print(f"# {NOTE}", file=sys.stderr)
try:
subprocess.run(cmd, check=True)
except subprocess.CalledProcessError as e:
die(f"rtl_power exited {e.returncode} — dongle free? gain valid? band in range (24-1766 MHz)?")
def parse_bins(csv_path):
bins = []
with open(csv_path) as f:
for line in f:
parts = [p.strip() for p in line.split(",")]
if len(parts) < 7: continue
try:
lo, step = float(parts[2]), float(parts[4])
vals = [float(v) for v in parts[6:] if v]
except ValueError:
continue
for i, dbm in enumerate(vals):
bins.append(((lo + (i + 0.5) * step) / 1e6, dbm))
return bins
def main():
preflight()
with tempfile.NamedTemporaryFile(suffix=".csv", delete=False) as tf:
csv_path = tf.name
try:
run_sweep(csv_path)
bins = parse_bins(csv_path)
finally:
try: os.unlink(csv_path)
except OSError: pass
if not bins:
die("no spectrum parsed from rtl_power.")
floor = statistics.median(d for _, d in bins)
peak_f, peak_d = max(bins, key=lambda b: b[1])
top = sorted(bins, key=lambda b: b[1], reverse=True)[:TOPN]
span = peak_d - floor
verdict = ("SIGNAL — clear carrier above the floor" if span > 10 else
"maybe — slight rise, tune gain / dwell longer" if span > 5 else
"just noise — nothing above the floor here")
print(f"\nProbe {LO}-{HI} · dongle :{SERIAL} · {NOTE}")
print(f"noise floor ~{floor:.1f} dBm · peak {peak_d:.1f} dBm @ {peak_f:.4f} MHz "
f"(+{span:.1f} dB) · {verdict}\n")
print(f" {'freq (MHz)':>11} {'dBm':>7} {'above floor':>11}")
print(" " + "-" * 38)
for f_mhz, dbm in top:
print(f" {f_mhz:>11.4f} {dbm:>7.1f} {dbm - floor:>+10.1f}")
print("\n next: paste this back and we'll read it + pick the next gain. (write each run down)\n")
if __name__ == "__main__":
main()