#!/usr/bin/env python3 """istrain dashboard server (LOCAL preview). Serves the static dashboard AND a tiny control endpoint for the per-radio NOAA sanity toggle. Replaces `python3 -m http.server` so a browser button can start/stop live audio per dongle. /api/noaa GET -> {"playing": bool, "serial": , "freq", "channel"} POST ?serial=X -> toggle dongle X on NOAA: if X is playing, stop it; otherwise stop whatever's playing and tune X. One dongle plays at a time. BUILD vs OPERATE (radio-runtime rule): the maintainer builds this server; the USER OPERATES the radio by clicking a NOAA button in the browser. The server spawns rtl_fm ONLY in response to a POST driven by that click. the code author must NOT curl/POST this endpoint to start audio — that's operating. GET is read-only (no radio op) and safe to hit. It's a *temporary hijack* to confirm a radio is responding: while NOAA plays, that dongle is busy; stopping frees it so it can go back to its assigned task. NOAA voice = signal; plain hiss = receiver works but weak/no signal (squelch is off so the test always makes sound). """ import json, os, glob, re, signal, subprocess, atexit, shutil, time, threading, hashlib from urllib.parse import urlparse, parse_qs from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer HERE = os.path.dirname(os.path.abspath(__file__)) PORT = int(os.environ.get("PORT", "8157")) BIND = os.environ.get("BIND", "127.0.0.1") # NOAA sanity-toggle config (env-overridable; later this comes from the swappable roles file). NOAA_FREQ = os.environ.get("NOAA_FREQ", "162.550M") # Columbus KIG76 NOAA_CHANNEL = os.environ.get("NOAA_CHANNEL", "162.550 · Columbus KIG76") NOAA_GAIN = os.environ.get("NOAA_GAIN", "auto") # "auto" or a dB number NOAA_DEFAULT = os.environ.get("NOAA_SERIAL", "1001") # used if POST omits ?serial # Airband captures: list + serve the per-transmission MP3 clips. Reads the dir passively — it does # NOT care how Airband was started (run by hand, or the istrain-airband service). See airband/istrain.conf. CAPTURES_DIR = os.environ.get("CAPTURES_DIR", "/tmp/airband") # Persistent "heard" log — one line per transmission ever detected (channel + time + size). Survives # clip pruning AND reboot, so it's the durable substrate for spotting train patterns. NOT in /tmp. HEARD_LOG = os.path.expanduser(os.environ.get("HEARD_LOG", "~/istrain/heard.jsonl")) EOT_LOG = os.path.expanduser(os.environ.get("EOT_LOG", "~/istrain/eot.jsonl")) BOT_LOG = os.path.expanduser(os.environ.get("BOT_LOG", "~/istrain/bot.jsonl")) # 452 head-end (when the hop runs) MID_LOG = os.path.expanduser(os.environ.get("MID_LOG", "~/istrain/midtrain.jsonl")) # DPU 452/457 ±12.5k (when IQ sub-channel ID feeds it; NOT auto from bot/eot) LEVEL_LOG = os.path.expanduser(os.environ.get("LEVEL_LOG", "~/istrain/level.json")) # vumon's live instantaneous level (meter) # Supervisor snapshot: on the NAS (presentation node) /api/supervisor serves a snapshot PUSHED from the # VM (the NAS has no systemd/dongles). Set SUPERVISOR_SNAPSHOT on the NAS; empty on the VM = serve live. SUPERVISOR_SNAPSHOT = os.path.expanduser(os.environ.get("SUPERVISOR_SNAPSHOT", "")) # Push receiver (NAS): the VM POSTs its data files here over Tailscale (bypasses Cloudflare). PUSH_KEY # gates it — if EMPTY, /api/push is DISABLED (so the VM's own serve.py, which has no key, can't be pushed # to). Each whitelisted name maps to the exact path its read endpoint reads, so a push = "replace that file". PUSH_KEY = os.environ.get("PUSH_KEY", "") SITE_WINDOW_S = int(os.environ.get("SITE_WINDOW_DAYS", "3")) * 86400 # website shows the last N days only; the raw logs keep EVERYTHING (for the deeper offline analysis) # Errors card (2026-07-06): ERRORS_LOG = this node's own events (ctl actions, failures, client JS # reports). ERRORS_PUSHED = the VM's pushed copy (NAS only) — defaults NEXT TO the supervisor snapshot # so the existing compose needs no new env. /api/errors merges both. ERRORS_LOG = os.path.expanduser(os.environ.get("ERRORS_LOG", "~/istrain/errors.jsonl")) ERRORS_PUSHED = os.environ.get("ERRORS_PUSHED", "") or ( os.path.join(os.path.dirname(SUPERVISOR_SNAPSHOT), "errors-vm.jsonl") if SUPERVISOR_SNAPSHOT else "") # Voice transcripts (2026-07-10): real-speech lines from the Mill's whisper (transcribe-worker). TRANSCRIPTS_LOG = os.path.expanduser(os.environ.get("TRANSCRIPTS_LOG", "~/istrain/transcripts.jsonl")) _PUSH_TARGETS = { "eot": EOT_LOG, "bot": BOT_LOG, "midtrain": MID_LOG, "heard": HEARD_LOG, "level": LEVEL_LOG, "supervisor": SUPERVISOR_SNAPSHOT or os.path.expanduser("~/istrain/supervisor.json"), "errors": ERRORS_PUSHED or os.path.expanduser("~/istrain/errors-vm.jsonl"), "transcripts": TRANSCRIPTS_LOG, } # Hit counter: total page loads + unique visitors. Counted on a real page load (GET /), never on the # API polls. Behind Cloudflare the visitor IP is in CF-Connecting-IP; IPs are stored HASHED (dedup + # privacy). Persist to /data on the NAS (set HITS_LOG) so counts survive container restarts. HITS_LOG = os.path.expanduser(os.environ.get("HITS_LOG", "~/istrain/hits.json")) _hits_lock = threading.Lock() _CH_LABELS = { "ns_dayton_road_160980": "NS Dayton Rd · 160.980 ★", "ns_buckeye_yard_160920": "NS Buckeye Yard · 160.920", "ns_col_sandusky_161190": "NS Col+Sandusky · 161.190", "csx_scottslawn_160860": "CSX Scottslawn · 160.860", "ns_col_area_161085": "NS Col area · 161.085", } _proc = None # the running rtl_fm|aplay pipeline (Popen), or None _serial = None # which dongle serial it's tuned to, or None # ── jsonl parse cache ──────────────────────────────────────────────────────────────────────────── # The dashboard polls every 5 s but the logs only change every ~20-40 s (append) or on a decode # enrich (atomic rewrite) — yet every poll was re-parsing ~2 MB / ~40k lines of jsonl (profiled at # ~100 ms per /api/trains call, 2026-07-05). Cache the parsed rows keyed on (mtime_ns, size); any # append or rewrite changes the key. Callers get the SAME list object on a hit — treat it as # READ-ONLY: filter/sort into new lists, never .sort()/.append() the returned one. _jsonl_cache = {} _jsonl_lock = threading.Lock() def _read_jsonl(path): try: st = os.stat(path) except OSError: return [] key = (st.st_mtime_ns, st.st_size) with _jsonl_lock: hit = _jsonl_cache.get(path) if hit and hit[0] == key: return hit[1] rows = [] try: with open(path) as f: for line in f: line = line.strip() if not line: continue try: rows.append(json.loads(line)) except Exception: pass except OSError: pass with _jsonl_lock: _jsonl_cache[path] = (key, rows) return rows def _dongle_state(serial): """(present, dvb_claimed) for a dongle by serial — passive sysfs read, never opens it.""" 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": return True, True return True, False return False, False def _playing(): global _proc, _serial if _proc is not None and _proc.poll() is not None: _proc, _serial = None, None # it exited on its own (dongle busy, killed, etc.) return _proc is not None def _stop(): """Kill the whole pipeline group (rtl_fm AND aplay), freeing the dongle.""" global _proc, _serial if _proc is not None: try: os.killpg(os.getpgid(_proc.pid), signal.SIGTERM) except (ProcessLookupError, PermissionError): pass try: _proc.wait(timeout=3) except subprocess.TimeoutExpired: try: os.killpg(os.getpgid(_proc.pid), signal.SIGKILL) except ProcessLookupError: pass _proc, _serial = None, None def _start(serial): """Tune NOAA on `serial` and play it. Returns None on success or an error string.""" global _proc, _serial if not shutil.which("rtl_fm"): return "rtl_fm not found — install the 'rtl-sdr' package." present, dvb = _dongle_state(serial) if not present: return f"dongle {serial} not present — check the radios panel." if dvb: return f"dongle {serial} is DVB-claimed — free it: sudo modprobe -r rtl2832_sdr dvb_usb_rtl28xxu" # squelch off (-l 0) so the test ALWAYS makes sound: NOAA voice = signal, hiss = receiver works. gain = "" if NOAA_GAIN == "auto" else f"-g {NOAA_GAIN} " cmd = (f"rtl_fm -d {serial} -f {NOAA_FREQ} -M fm -s 200000 -r 48000 -E deemp -l 0 {gain}- " f"| aplay -r 48000 -f S16_LE -t raw -c 1") # own session so we can kill the pair as a group; detached from the request handler thread. _proc = subprocess.Popen(cmd, shell=True, start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) _serial = serial return None def _state_json(error=None): d = {"playing": _playing(), "serial": _serial if _playing() else None, "freq": NOAA_FREQ, "channel": NOAA_CHANNEL} if error: d["error"] = error return d def _toggle(serial): """One-at-a-time: same dongle -> stop; different (or idle) -> stop any, start this one.""" if _playing() and _serial == serial: _stop() return None _stop() return _start(serial) def _capture_label(fname): """Friendly channel label from a recording filename (