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
+300
View File
@@ -0,0 +1,300 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>iSTRAIN — railroad VHF watch</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<header>
<h1>🚆 iSTRAIN <span class="sub">Lassiter Creek · <span id="ver" title="commit this dashboard is serving (* = uncommitted local changes)"></span> · railroad VHF 160162 MHz</span></h1>
<div class="hdr-right">
<div class="controls">
<button id="btn-refresh" class="btn" title="re-pull data">↻ refresh</button>
<button id="btn-reset" class="btn reset" title="full reload">⟲ hard reset</button>
</div>
<div class="hits">
<div class="stat"><div class="num" id="hits-unique"></div><div class="lab">unique</div></div>
<div class="stat"><div class="num" id="hits-total"></div><div class="lab">total hits</div></div>
</div>
</div>
</header>
<!-- live: the headline inference — isTRAIN? — mounted from panels/banner/ (above all cards) -->
<div id="istrain-banner"></div>
<main>
<!-- FIRST PANEL: the "AIS-equivalent" for trains — dispatch / yard data.
In-band source now (defect-detector & dispatcher voice on the road channel, transcribed);
ATCS/CTC (900 MHz) was evaluated and dropped (FCC-retired → encrypted PTC; our 900 was a spur). -->
<section id="dispatch" class="panel dispatch">
<h2>Dispatch / Yard <span class="tag">rail-voice captures — the "AIS" for trains</span></h2>
<div class="placeholder">
<b>Airband is watching 5 channels on 1002.</b> Captured transmissions (squelch-gated clips →
whisper transcripts) will list here newest-first: channel, time, and the milepost / axle-count /
speed read-outs from detector call-outs. ★ <b>160.980 NS Dayton District</b> is the mission channel.<br>
<span class="dim">Live clips appear in the <b>Captures</b> panel (one per transmission, playable);
transcription (whisper) still pending.</span><br>
<b>scan status:</b> <span id="airband-status">service <code>istrain-airband</code> — live status &amp; controls in the <b>Supervisor</b> panel below</span>
</div>
</section>
<!-- captures panel RESTORED 2026-07-10 — now lives below Community Feeds (novelty tier), see there. -->
<!-- live: Trains — per-train head+tail cards from decoded EOT (lifecycle + crossing inference) -->
<section id="trains" class="panel detect"></section>
<!-- activity panel SHELVED 2026-07-02 — voice ACTIVITY stays a signal in the correlate graph
(which reads heard.jsonl); the dedicated activity panel stays down. -->
<!-- live: EOT presence on 457.9375 (dongle 1001) — a burst = a train's rear unit is near -->
<section id="eot" class="panel detect"></section>
<!-- live: multi-band train-activity correlation — co-occurring activity across voice/EOT/(head-end) -->
<section id="correlate" class="panel detect"></section>
<!-- scaffold: BOT / head-end presence on 452.9375 — lights up when the 452 hop feeds /api/bot -->
<section id="bot" class="panel detect"></section>
<!-- scaffold: mid-train DPU (~900 MHz) presence — lights up when researched + hopped, /api/midtrain -->
<section id="midtrain" class="panel detect"></section>
<!-- live: signal meter — bouncing dBFS bar for the band the hop is currently on (/api/level) -->
<section id="live" class="panel"></section>
<!-- DEFECT DETECTORS — static reference from defectdetector.net (one-time import by id, not a poller) -->
<section id="detectors" class="panel detect">
<h2>Defect Detectors <span class="tag">reference — your subdivision (example)</span></h2>
<div class="placeholder">
Wayside detectors near your crossing (example: MP 148.99), all on your mission channel (example <b>160.980</b>). The
nearest <b>talks on every train</b> — its call-out is the unambiguous "train through" event.<br>
<b>CJ 146.4 ★</b><b>nearest</b>, ~2.6 mi east (New Rome OH). HBD-DED · <b>talks every train</b>
(Talk-On-Defect-Only: No) → routine call-out on <b>westbounds</b> · <a href="http://map.defectdetector.net/?id=8519">record</a><br>
<b>CJ 144.7</b> — ~4.3 mi east, high-car (over-height) — defect-only<br>
<b>CJ 157.8</b> — ~8.8 mi west, warns eastbounds — type TBD<br>
<span class="dim">Source: defectdetector.net — static records (location/type/freq/audio), pullable per detector by id. One-time import, not a live poller.</span>
</div>
</section>
<!-- COMMUNITY FEEDS — live audio (when up) -->
<section id="feeds" class="panel detect">
<h2>Community Feeds <span class="tag">live audio — when up</span></h2>
<div class="placeholder">
<a href="https://www.broadcastify.com/listen/feed/26042">Broadcastify — South&nbsp;Central&nbsp;Ohio&nbsp;Railroads</a>
— the one Franklin County rail feed; monitors our <b>160.980</b> (independently validates the channel). <span class="dim">Often offline, ~0 listeners.</span><br>
<span class="dim">Their rooftop hears more than our desk whip — "they catch it, we don't" = range, not broken. Someday: we become the local contributor.</span>
</div>
</section>
<!-- captures: RESTORED 2026-07-10 (was shelved 07-02 "return with the new antenna" — it arrived,
same night the whisper pipeline went live). The public node serves VOICED clips only (pushed
from the VM: push-to-nas _push_clips → /api/push-clip, self-pruning ~120 files); 🗣 rows carry
the whisper transcript. Novelty tier below Community Feeds. -->
<section id="captures" class="panel detect"></section>
<!-- SIGNAL FLOW — hand-maintained ASCII map: frequency -> dongle -> approach, by band.
A learning tool. Keep it current as the build iterates (LIVE/TEST/NEXT/LATER tags). -->
<section id="flow" class="panel flow">
<h2>Signal Flow <span class="tag">how it fits together — the air ▸ the dongles ▸ meaning</span></h2>
<pre class="flow-chart">
iSTRAIN — SIGNAL FLOW the air ▸ the dongles ▸ meaning
mission: is a train blocking YOUR crossing? (set your channels in config/istrain.conf)
legend: ★ TCXO (precise) [nnnn] = dongle serial ─▶ data path
updated 2026-06-28: voice live (1002); 1001 WEIGHTED hop 452/457 (clock-step-proof); EOT decoded LIVE (ID+brake+motion); HOT head-end ADDRESS cracked → head+tail join; MID standing carrier ID'd = 457.925 EOT booster, carded. ATCS dropped.
updated 2026-07-05: EOT decode is TWO-PASS — off-frequency emitters (±800 Hz, ~100 ms bursts) now decode
via adaptive tones + fuzzy sync (O-11) → dwelling trains identified w/ brake psi: held ~60 = stopped
solid · bled to 0 + head polling = CUT & standing (crossing possibly open). Booster floor subtracted
from contacts & correlate (O-9).
updated 2026-07-18: THE WHOLE RIG MOVED TO THE MILL — both dongles + capture + decode + transcription
+ this site now run as one Docker (Dockge) stack on a TrueNAS box; the old VM runs zero radio
services; data is born where it's served. Voice transcription is LIVE (not planned — see below).
────────────────────────────────────────────────────────────────────
PRIMARY ░░ RAIL VHF VOICE — 160162 MHz · AAR · NBFM, unencrypted ░░
└ defect detectors ("MP 123, no defects, 45 axles, 60 mph") + dispatcher
─▶ RTLSDR-Airband (multi-ch) ─▶ ffmpeg comms-filter ─▶ faster-whisper (small.en + VAD,
LIVE on the Mill since 07-10) ─▶ transcripts.jsonl — "a train is moving through," in words
▸ the most direct, gettable "is MY crossing blocked" signal
▸ proven 2026-06-16: same band as NOAA 162.550 — RX confirmed here
▸ CONFIRMED channel (2026-06-18): 160.980 (ch58) NS Dayton District, MP 148.99;
detectors CJ 144.7 / 157.8 announce here. Plan + sources → CHANNELS.md
▸ first intelligible speech 2026-07-10, the night the tuned antenna went in:
detector CJ at MP 146.4, transcribed (O-22)
stick: [1002 ★] Blog V4 — Airband demods the 5 pinned channels at once (160.86161.19 @ 1.024 MS/s)
antenna: tuned railroad-band vertical (upgraded 07-10 — ≈2.4× the desk whip's real content;
office placement, rooftop mast = the standing next lever)
SECONDARY ░░ EOT-SYSTEM UHF — 1001 WEIGHTED HOP (452 ⇄ 457) · 1200-baud FFSK ░░
└ a train is a chain of RF emitters front-to-back; 1001 hops to watch both ends:
▸ 457.9375 EOT/FRED — the REAR unit; DECODED live: ID · brake · MOTION (rolling/stopped) ─▶ eot.jsonl + clips
decode is TWO-PASS (07-05): exact fast path + adaptive/fuzzy escalation for the
off-frequency short-burst emitters that used to read "strong but undecodable" (O-11)
▸ 452.9375 BOT / head-end (HOT) — the FRONT unit; frame RECOVERED + addressed-unit DECODED ─▶ bot.jsonl
▸ hop: iq_hop.py — retune-in-place, BOT 10s / EOT 10s (rebalanced 07-07: the head = the join + the EARLY WARNING; was 7/14 EOT-favored before the HOT decoder existed)
▸ EOT decode LIVE 2026-06-27 — real trains identified; brake-release + motion=1 = departed (crossing cleared)
▸ HOT address CRACKED 2026-06-28 from our own air (invert · sync 0110101010 · bit-49) → head↔tail JOIN:
a HOT frame addressing a card's own EOT = confirmed complete consist. Command/type + BCH still open.
stick: [1001] generic — drifts, ppm-calibrate for a clean FSK decode; 450 band clean (no filtering)
antenna: ~6" vertical — ¼λ ≈ 6.4" @ 457, well-matched
▸ MID / DPU — ±12.5k off both centers (452.925/.950, 457.925/.950), watched in-hop → midtrain.jsonl
found ░ a STANDING carrier on 457.925 = an FCC railroad EOT SIGNAL-BOOSTER (fixed wayside repeater, not a
train); carded as a STANDING SIGNAL. A booster here ⇒ marginal head↔tail RF — likely why our range is short.
SYNTHESIS ░░ the mission is DETECT a train, not decode one signal ░░
└ a train lights several bands at once — co-firing in the same 15-min slot = a candidate,
even when each is too weak to decode: 160.980 voice + 452 BOT + 457 EOT (+ ±12.5k DPU,
booster-floor subtracted so the fixed carrier can't fake a band — O-9)
▸ correlate panel = week heatmap (one row/band, aligned bins) — read DOWN a column
▸ BOT/mid/EOT strips below = the RECENT ZOOM (1-min bins, last 6 h) — correlate's magnifier
▸ live meter = bouncing dBFS bar for whichever band the hop is on (level.json)
MEANING ░░ the AIR CURVE is the state — psi tells the story silence can't (O-10 · O-11) ░░
└ from decoded EOT frames, per train:
psi ~8590 + moving = PASSING through
psi drops to a HELD ~5565 + motion 0 = DWELLING (brakes set — possibly blocking)
psi bleeds to 0 AND HOLDS + head still polling = CUT & STANDING — crews cut the crossing
(eyes-confirmed 07-05: ~11 h split, crossing open; never read the bleed as a departure)
psi RECHARGES toward ~88 + motion 0→1 = DEPARTED (the roll-out is the event time)
sanity ░ NOAA 162.550 (Columbus) — the proven known-good RX test on this band
(the per-radio toggle is parked since the Mill move; control relay TBD)
config ░ radio→task is swappable, not hardcoded — reassign per band
dropped ░ ATCS (900 MHz): LOS-limited + FCC-retired Sep 2025 → encrypted PTC;
our 900 "carriers" were a dongle spur. Why → radio/sdr/atcs/tuning-log.md
────────────────────────────────────────────────────────────────────
why voice first: unencrypted, easy at 160 MHz, and it says a train is
moving through your crossing in plain words — the direct commute signal.
</pre>
</section>
<!-- SYSTEM MAP — hand-maintained ASCII map of the software side: the browser, the local
servers/services, the ports, and how data flows. Sibling to SIGNAL FLOW (which maps the air).
A learning tool for us and whoever comes after. Keep it current as servers/flows change. -->
<section id="stack" class="panel stack">
<h2>System Map <span class="tag">the istrain dashboard &amp; the services behind it</span></h2>
<pre class="flow-chart">
iSTRAIN — SYSTEM MAP the dashboard &amp; the services behind it
how the pieces talk · keep current as it changes
legend: ─▶ request / data ◂ produced by ▣ = a container in the Dockge stack `istrain`
updated 2026-07-18 (the Mill cutover): ONE BOX — everything below is a container on the Mill
(a TrueNAS SCALE machine running Dockge); both dongles on its USB; every log AUTHORED where
it's served (the VM-era push pipeline is retired); istrain.jhestyr.net serves this stack.
────────────────────────────────────────────────────────────────────
BROWSER ▸ istrain.jhestyr.net (Cloudflare ─▶ the Mill's ▣ web container)
└─▶ ▣ web serve.py — stdlib Python, no framework
│ (keeps a jsonl PARSE CACHE keyed on file mtime/size + a 4 s TTL on the card
│ compute — polls are ~free; the logs only change every ~20-40 s)
├ static index.html · main.js · style.css · panels/strip.js (TWO grids: aligned week/15-min
│ for correlate · RECENT1M = 6h/1-min zoom for the band strips)
│ panels/{trains,live,supervisor,correlate,bot,midtrain,eot,checklist}
├ GET /api/version ◂ dashboard/VERSION (stamped by mill/deploy.sh) ─▶ header build stamp
├ GET /api/trains ◂ eot.jsonl + bot.jsonl (cards: tail+head ✓join, ⚓parked, ◉standing,
│ air-curve states: passing/dwelling/CUT&amp;standing/departed) ─▶ Trains
├ GET /api/istrain ◂ strength-gated isTRAIN? verdict (+ dwelling-join fallback) ─▶ Banner
├ GET /api/level ◂ level.json (live dBFS) ─▶ Live meter (2×/s)
├ GET /api/heard ◂ heard.jsonl (voice detections) ─▶ Correlate (voice row)
├ GET /api/eot?hours=6 ◂ eot.jsonl ─▶ EOT zoom strip (no ?hours = week ─▶ Correlate)
├ GET /api/bot?hours=6 ◂ bot.jsonl ─▶ BOT zoom strip (ditto)
├ GET /api/midtrain?hours=6 ◂ midtrain.jsonl ─▶ Mid-train zoom strip (±12.5k DPU + booster)
├ GET /api/correlate ◂ scripts/correlate.py (recent-window why; booster-floor aware) ─▶ Correlate
├ GET /api/supervisor ◂ docker state via supervisor-probe (host cron, 1/min) + DATA-FLOW
│ liveness: hop = level.json freshness · voice = last-capture age
└ POST /api/noaa · /api/supervisor/{ctl,reset} (parked since the move — buttons decline
gracefully; a docker control relay is TBD)
DONGLES ░ USB by serial, both on the Mill through a hub · dvb driver blacklisted on the host
├ 1001 ▸ UHF hop — 452(BOT) ⇄ 457(EOT) └ 1002 ★ ▸ rail voice 160162 / NOAA
CAPTURE ░ voice + the UHF hop run at once — separate dongles, separate containers
├ ▣ airband rtl_airband v5.2.0 (rtl-sdr-blog fork, V4-capable) -c istrain.conf
│ ─▶ 5 NFM channels on 1002 ─▶ captures/*.mp3 ─▶ heard.jsonl
└ ▣ scanhop rtl_tcp + iq_hop.py: retune-in-place hop on 1001 (BOT 10s ⇄ EOT 10s, monotonic dwell — step-proof)
├ 452 ─▶ bot.jsonl + bot_clips/*.s16 (presence + clip)
├ 457 ─▶ eot.jsonl + eot_clips/*.s16 (presence + clip)
└ both ─▶ level.json (live meter + the hop's liveness signal)
DECODE ░ containers · no radio · CPU on saved clips · NEVER cull
├ ▣ eot-decode ─▶ eot-decode.py → eot_scan (TWO-PASS: exact + adaptive/fuzzy for off-freq
│ emitters, O-11) → unit·brake·motion (+dq provenance) → enrich eot.jsonl
├ ▣ bot-recover ─▶ bot-recover.py: BOT clips → recover HOT frame + DECODE addressed unit → enrich bot.jsonl
└ ▣ transcribe-worker ─▶ voice clips &gt;5 KB ─▶ ffmpeg comms-filter ─▶ the whisper stack
(faster-whisper small.en + VAD, a sibling Dockge stack, localhost) ─▶ sidecar .txt per clip;
real speech only ─▶ transcripts.jsonl (durable)
HOUSEKEEPING ░ ▣ trim — 24 h voice-audio prune (ingest-gated; heard.jsonl KEPT)
░ supervisor-probe — host cron, 1/min: docker truth into the snapshot
DATA ░ born on the Mill's ZFS datasets — jsonl logs = keep forever · clips accumulate
(keep-everything standing policy) · the VM-era corpus is archived alongside
build vs operate ░ the hands build the code; the radios are operated by services —
never fired ad hoc. deploy = mill/deploy.sh: rsync ─▶ build ─▶ up ─▶ smoke, idempotent.
history ░ the VM-capture / NAS-web era (2026-06-15 → 07-18) is preserved in MIGRATION.md;
the VM units stay on disk, disabled — the standing rollback.
────────────────────────────────────────────────────────────────────
</pre>
</section>
</main>
<!-- WITH THANKS — carried from the first istrain, extended for the rebuild -->
<section class="thanks">
<div class="th-h">📡 WITH THANKS</div>
<p>Railroad frequencies, the tools that pull signal out of the air, and the hands that built it.</p>
<p class="th-grp"><b>Frequency &amp; data communities</b><br>
<a href="https://trainweb.us/columbusrailfan/">Columbus Railfan Online</a> (Silver Rails / TrainWeb) — scanner frequencies<br>
<a href="https://www.radioreference.com/db/subcat/77377">RadioReference</a> — NS Columbus Terminal &amp; the US Railroads index<br>
<a href="https://www.railroad-frequencies.com/train/norfolk-southern-railway/ohio/">Railroad-Frequencies.com</a> — NS &amp; CSX Ohio assignments
</p>
<p class="th-grp"><b>Tools &amp; projects</b><br>
<a href="https://www.rtl-sdr.com/">RTL-SDR Blog</a> — the Blog V4 hardware and the community behind it<br>
<a href="https://osmocom.org/projects/rtl-sdr/">Osmocom rtl-sdr</a><code>rtl_test</code> / <code>rtl_eeprom</code>, the tools that drive the dongles<br>
<a href="https://github.com/szpajder/RTLSDR-Airband">RTLSDR-Airband</a> — the multi-channel voice watch (we run the <a href="https://github.com/rtl-sdr-blog/RTLSDR-Airband">rtl-sdr-blog fork</a>, V4-capable)<br>
<a href="https://github.com/SYSTRAN/faster-whisper">faster-whisper</a> (SYSTRAN) + <a href="https://github.com/openai/whisper">OpenAI Whisper</a> — the transcription engine that lets istrain <em>hear</em> (small.en + VAD)<br>
<a href="https://github.com/ggerganov/whisper.cpp">whisper.cpp</a> — lit the transcription path first, in the VM era<br>
<a href="https://ffmpeg.org/">FFmpeg</a> — the comms-filter in front of the ears, and every clip's plumbing<br>
<a href="https://github.com/jvde-github/AIS-catcher">AIS-catcher</a> — AIS decode, our known-good validation signal<br>
<a href="https://github.com/ereuter/PyEOT">PyEOT</a> (Eric Reuter) — the open-source EOT/FFSK decoder (packet parser + BCH check) that proved we can read End-of-Train telemetry; ships the demo recording we validated against<br>
<a href="https://www.sigidwiki.com/wiki/End_of_Train_Device_(EOTD)">SigidWiki — EOTD</a> &amp; <a href="https://www.rtl-sdr.com/tag/eot/">rtl-sdr.com EOT guides</a> — the 1200-baud FFSK spec and the decode trail that pointed the way<br>
<a href="https://numpy.org/">NumPy</a> — the signal-crunching behind the EOT demodulator<br>
<strong>ATCS Monitor</strong> &amp; <a href="https://rail.watch/rwmon.html">Rail Watch Monitor</a> — the 900 MHz dispatcher's board, and the community that mapped ATCS (evaluated & dropped — kept for provenance)<br>
<a href="https://www.radioreference.com/db/subcat/69805">RadioReference ATCS DB</a> &amp; <a href="https://www.sigidwiki.com/wiki/Automated_Train_Control_System_(ATCS)">SigidWiki</a> — the published channel plans that told us what we'd found<br>
<a href="https://www.docker.com/">Docker</a> + <a href="https://github.com/louislam/dockge">Dockge</a> (louislam) — the runtime every service lives in since the Mill move<br>
<a href="https://tailscale.com/">Tailscale</a> — the quiet plumbing between the boxes<br>
<a href="https://www.cloudflare.com/">Cloudflare</a> — the front door
</p>
<p class="th-grp"><b>The machine</b><br>
<a href="https://www.truenas.com/truenas-scale/">TrueNAS SCALE</a> on the Mill — the one box the whole rig runs on (since 2026-07-18): dongles, capture, decode, ears, and this site<br>
<a href="https://www.virtualbox.org/">Oracle VM VirtualBox</a> + <a href="https://linuxmint.com/">Linux Mint (LMDE)</a> — the dev seat, and the box the rig grew up inside (2026-06-15 → 07-18)
</p>
<p class="th-grp"><b>Foundation</b><br>
• built on a reusable radio-capture engine and iterated with an AI pair-programmer (<a href="https://www.anthropic.com/claude">Claude</a>, Anthropic)<br>
• a community project — the credits above are the shoulders it stands on; if your tool, frequency, or hand helped and isnt here yet, it will be.
</p>
<p class="th-grp"><b>A living list</b><br>
istrain is a community project — this list is intentionally unfinished and grows as the work brings in
more shoulders to stand on. If your tool, frequency, or hand helped and isn't here yet, it will be.
</p>
</section>
<!-- live: Supervisor — services + radios (moved below THANKS: the ops corner
lives at the bottom with the errors card; the triple-tap gate is on this panel's title) -->
<section id="supervisor" class="panel yard"></section>
<!-- ERRORS — live issues + recent events, below WITH THANKS (the owner 2026-07-06): the card to
copy/share when something looks wrong. Fed by /api/errors (supervisor audit + client JS reports). -->
<section id="errors" class="panel errors"></section>
<!-- BUILD CHECKLIST — rendered from checklist.md (single source; also a git host task list).
Keep checklist.md current; this panel mirrors it live. -->
<section id="checklist" class="panel checklist"></section>
<footer>
<span id="status">live — one box: the Mill captures, decodes &amp; presents</span> ·
<a href="https://istrain.jhestyr.net">istrain.jhestyr.net</a> · an open community project
</footer>
<script type="module" src="/main.js"></script>
</body>
</html>