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:
istrain
2026-07-24 23:19:16 -04:00
commit 559caead36
41 changed files with 6740 additions and 0 deletions
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env python3
# scan-hop.py — time-division scanner on dongle 1001. Alternates 452.9375 (head-end / BOT) and 457.9375
# (EOT), ~28s each (+~1.5s settle between), forever. 452 and 457 are 5 MHz apart — too wide for one tuner
# to grab at once — so we hop. Each 200k dwell ALSO spans its mid-train DPU neighbors (±12.5 kHz: 452.925/
# .950 and 457.925/.950) — they're inside the capture, so a DPU burst trips this dwell too (counted as
# bot/eot presence; distinguishing DPU needs IQ sub-channel ID — see eot/README "DPU decode plan"). Each band's bursts go to its OWN log (band-tagged) so the working EOT path is
# untouched and nothing cross-contaminates: 452 → bot.jsonl (presence only, no decoder yet), 457 → eot.jsonl
# + eot_clips (as before). Presence detection via vumon; no decode here.
#
# This REPLACES the fixed-457 EOT watch while it runs (1001 can only be one freq at a time). Accepts the
# coverage tradeoff: ~46% dwell per band. Operate it as a service (istrain-scan-hop) — see README.
#
# IQ MODE (opt-in, HOP_IQ=1): instead of rtl_fm|vumon (one blurred FM channel), each dwell runs
# rtl_sdr | iq_channelize.py, which splits the dwell into center (bot/eot) + the two ±12.5 kHz DPU
# sub-channels (mid) and writes bot/eot/midtrain logs itself. Same 2-position hop; the FM path below
# stays the default fallback. Validate the DSP with `iq_channelize.py --selftest` before flipping.
import os, sys, signal, subprocess, time
HOME = os.path.expanduser("~")
IST = os.path.join(HOME, "istrain")
VUMON = os.path.join(os.path.dirname(os.path.abspath(__file__)), "vumon.py")
IQSCRIPT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "iq_channelize.py")
IQ_MODE = os.environ.get("HOP_IQ", "") not in ("", "0", "no", "false")
IQ_FS = os.environ.get("IQ_FS", "240000") # rtl_sdr sample rate for IQ mode
LEVEL = os.path.join(IST, "level.json") # live meter: whichever band is active writes here
DWELL = int(os.environ.get("HOP_DWELL", "28")) # seconds on each band
SETTLE = float(os.environ.get("HOP_SETTLE", "1.5")) # let the tuner/USB release before the next grab
GAIN = os.environ.get("HOP_GAIN", "40")
THRESH = os.environ.get("HOP_THRESH", "-25")
MARGIN = os.environ.get("HOP_MARGIN", "6") # adaptive: fire dB above the rolling noise floor (beats fixed thresh in the noise)
# (freq, jsonl, clips_dir|None, label). BOT is presence-only for now; EOT keeps clips for the (paused) reaper.
BANDS = [
("452.9375M", os.path.join(IST, "bot.jsonl"), os.path.join(IST, "bot_clips"), "bot"),
("457.9375M", os.path.join(IST, "eot.jsonl"), os.path.join(IST, "eot_clips"), "eot"),
]
def _hz(freq):
"""'452.9375M' -> '452937500' for rtl_sdr -f (which wants plain Hz)."""
return str(int(float(freq.rstrip("Mm")) * 1e6)) if freq.lower().endswith("m") else freq
def segment(freq, jsonl, clips, label):
if IQ_MODE:
dwell = {"bot": "452", "eot": "457"}[label] # iq_channelize writes bot/eot + midtrain itself
cmd = ("rtl_sdr -d 1001 -f %s -s %s -g %s - | python3 %s --dwell %s") % (
_hz(freq), IQ_FS, GAIN, IQSCRIPT, dwell)
sys.stderr.write("[scan-hop] IQ %s for %ss → %s + mid\n" % (freq, DWELL, label))
sys.stderr.flush()
p = subprocess.Popen(cmd, shell=True, preexec_fn=os.setsid)
try:
time.sleep(DWELL)
finally:
try: os.killpg(os.getpgid(p.pid), signal.SIGTERM)
except Exception: pass
try: p.wait(timeout=5)
except Exception:
try: os.killpg(os.getpgid(p.pid), signal.SIGKILL)
except Exception: pass
time.sleep(SETTLE)
return
clip_arg = ("--clips %s --prefix %s_" % (clips, label)) if clips else ""
cmd = ("rtl_fm -d 1001 -f %s -M fm -s 200k -r 48k -g %s -l 0 - | "
"python3 %s --thresh %s --margin %s --jsonl %s --level %s --label %s %s") % (
freq, GAIN, VUMON, THRESH, MARGIN, jsonl, LEVEL, label, clip_arg)
sys.stderr.write("[scan-hop] %s for %ss → %s\n" % (freq, DWELL, os.path.basename(jsonl)))
sys.stderr.flush()
p = subprocess.Popen(cmd, shell=True, preexec_fn=os.setsid) # own process group so we can kill the whole pipe
try:
time.sleep(DWELL)
finally:
try: os.killpg(os.getpgid(p.pid), signal.SIGTERM)
except Exception: pass
try: p.wait(timeout=5)
except Exception:
try: os.killpg(os.getpgid(p.pid), signal.SIGKILL)
except Exception: pass
time.sleep(SETTLE) # device release before the next rtl_fm grabs 1001
def main():
for _, _, clips, _ in BANDS:
if clips:
os.makedirs(clips, exist_ok=True)
sys.stderr.write("[scan-hop] 452/457 time-division on 1001 — %ss each, %ss settle\n" % (DWELL, SETTLE))
sys.stderr.flush()
while True:
for freq, jsonl, clips, label in BANDS:
segment(freq, jsonl, clips, label)
if __name__ == "__main__":
main()