#!/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()