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>
This commit is contained in:
Executable
+125
@@ -0,0 +1,125 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user