Files
istrain-public/APOCALYPSE-EDITION.md
T
istrain 559caead36 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>
2026-07-24 23:19:16 -04:00

373 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# istrain — APOCALYPSE EDITION (public build guide)
*The complete from-scratch rebuild. This guide assumes you can install Linux and are comfortable
in a terminal, but it spells out every railroad-, SDR-, and container-specific step — because
those are the parts nobody tells you. If a step feels obvious to you, skip it; it's here for the
person behind you who needs the hint.*
*You are building a **passive** RF receiver. It only listens. No transmit, no license required in
the US to receive, no railroad cooperation needed — the trains announce themselves on public
frequencies, and you just have to be set up to hear them.*
> **Two ways to use this repo:**
> - **As a human:** read top to bottom, run the commands.
> - **As an agent** (point Claude Code / an LLM coding agent at this repo): the whole working tree
> is here — `scripts/` (the DSP + decoders), `dashboard/` (the web UI + API server),
> `docker/` (the container definitions), `config/` (templates). Every script has a header
> comment explaining what it does and how it's operated. The design rules that keep it robust
> are in §11. Start by reading this file, then `dashboard/serve.py` and `scripts/iq_hop.py`.
---
## 0. What you're building, in one minute
A train is a chain of radio transmitters. istrain listens passively to three of them and fuses
the result into one question: **is a train blocking my crossing right now?**
1. **Rail VHF voice (160162 MHz, narrowband FM, unencrypted).** Crews, dispatchers, and trackside
**defect detectors** speaking plain English: *"Norfolk Southern detector, milepost 148.9, no
defects, total axles 460, speed 52, detector out."* A detector hit on your road channel means a
train just passed that milepost — the most direct "train coming" signal there is, in words.
2. **End-of-Train telemetry (457.9375 MHz, 1200-baud FFSK).** The flashing box on the last car
(EOT / FRED / marker) transmits its unit ID, **brake-pipe pressure**, and a **motion flag**
every few seconds. Decode it and the air tells you: passing, stopped, brakes cut, or leaving.
3. **Head-of-Train (452.9375 MHz).** The locomotive's half of the same conversation. Decode the
EOT unit it's addressing and you can **join head to tail** — proof of a whole train, plus
warning from the front.
Bonus ears: **±12.5 kHz off the EOT/HOT centers** (distributed-power / mid-train repeaters live
there) and **NOAA weather radio (162.4162.55 MHz)** as an always-on, known-good test signal.
**The two facts that make this work at ANY North American crossing:**
- **457.9375 / 452.9375 MHz is continent-wide.** Every EOT and HOT in North America uses this
pair. You change nothing when you move.
- **The voice channels are public.** The AAR (Association of American Railroads) channel plan is
published per railroad and per subdivision. Only your voice channel(s) change with location.
The synthesis: any one signal can be too weak to decode, but a real train lights several bands in
the same time window. Co-firing bands = a train, even on a modest antenna.
---
## 1. Hardware — the shopping list
| Item | What we run | Notes |
|---|---|---|
| SDR #1 — voice | **[RTL-SDR Blog V4](https://www.rtl-sdr.com/buy-rtl-sdr-dvb-t-dongles/)** | The V4's front-end filtering genuinely helps at VHF. This is the ears for 160162 MHz. |
| SDR #2 — telemetry | Any RTL-SDR (R820T/R828D-class) | Runs the 452⇄457 hop. Generic sticks drift; the decoder compensates (§8). A TCXO stick is nicer, not required. |
| Antennas | ¼-wave verticals to start | **18.3 in (46.5 cm)** whip for 161 MHz; **6.4 in (16.3 cm)** for 457 MHz. Length is physics — get it right and a desk whip works day one. Upgrade: a tuned railroad-band base antenna (DPD TrainTenna, KB9VBR J-Pole) mounted high and far from your computers. Height beats gain; distance from noisy electronics beats both. |
| USB | A **powered** hub | Two dongles on one host; a powered hub prevents brownout resets. |
| Host | Any always-on 64-bit Linux box that runs Docker | Ours is a TrueNAS SCALE machine. A NUC, a Raspberry Pi 4/5, a Pi-like SBC, or an old desktop all work. 2+ cores, 2+ GB RAM. The whole rig is containers. |
| A second computer | Your laptop | For setup, watching waterfalls, and reading the dashboard. |
**Starting-from-nothing cost floor:** two dongles + whips ≈ US$70100. Everything else is software
in this repo.
---
## 2. Operating system & base software
Any modern 64-bit Linux works. We'll give commands for **Debian/Ubuntu-family** (apt); translate
to your package manager as needed. Three viable host shapes:
- **A regular Linux box (recommended for a first build)** — Debian 12, Ubuntu 22.04+, Linux Mint,
Raspberry Pi OS (64-bit). You control everything; nothing fights you. This guide's default.
- **An appliance NAS OS (what we ended on)** — TrueNAS SCALE, Unraid, etc., running Docker/Dockge.
More convenient long-term, but the OS resists you (see the §4 DVB note — appliance `/etc` can
reset on update). Do your *first* build on a regular box, move to the appliance once it works.
- **Bare metal vs. VM:** bare metal is simplest. A VM works but USB passthrough adds a failure
mode and caps your sample rate — we ran in VirtualBox for a month and it was fine at 1.024 MS/s,
but a Pi or NUC on bare metal is less fuss.
Install the base tools:
```bash
sudo apt update
sudo apt install -y git rtl-sdr librtlsdr-dev build-essential cmake pkg-config \
libusb-1.0-0-dev ffmpeg python3 python3-numpy docker.io docker-compose-plugin
```
- `rtl-sdr` gives you `rtl_test`, `rtl_eeprom`, `rtl_fm`, `rtl_sdr` — the osmocom tools that drive
the dongles.
- `ffmpeg` is the audio plumbing (and the comms band-pass filter in front of transcription).
- `python3` + `numpy` run every DSP and decode script here — **no other Python packages are
required** for the core rig (the scripts are deliberately stdlib + numpy only).
- Docker runs the whole stack. Optionally add **[Dockge](https://github.com/louislam/dockge)** — a
light web UI for compose stacks; it's what we use, but plain `docker compose` is identical.
Clone this repo onto the host:
```bash
git clone <this-repo-url> istrain && cd istrain
```
---
## 3. Know your crossing — 30 minutes of desk research
This is the only location-specific work. Do it once.
1. **Identify the railroad and subdivision** at your crossing. Read the signage at the crossing
itself (the operating railroad's name is on the signal equipment and the blue emergency-notify
sign — that sign also lists a **DOT crossing number** and a phone number). Cross-check on
[OpenRailwayMap](https://www.openrailwaymap.org/).
2. **Find the road (dispatch) channel.** Use two of these three and confirm they agree:
- [RadioReference — US Railroads DB](https://www.radioreference.com/db/aid/7625)
- [Railroad-Frequencies.com](https://www.railroad-frequencies.com/)
- your region's railfan forum / site.
You want the **AAR channel for the subdivision your crossing sits on**. Note its frequency and
AAR channel number. Grab the **neighboring subdivisions' channels too** — pin 46 channels in a
cluster; RTLSDR-Airband demodulates them all at once inside a single ~1 MHz window if they're
within ~500 kHz of each other (most road channels cluster in 160.2161.6 MHz).
3. **Find your defect detectors.** Railfan detector logs and forums list detector mileposts per
subdivision. The detectors within ~10 miles either side of your crossing are your early-warning
tripwires — write down their milepost numbers so the transcripts make sense.
4. **Note your NOAA weather frequency** (162.400162.550, whichever is strongest near you). This is
your receive-chain sanity signal forever — if you can hear NOAA, your antenna→dongle→software
path works.
Write it all down. See `config/istrain.conf.example` — it's our worked example (a Norfolk Southern
subdivision in central Ohio); replace the five channel frequencies with yours.
---
## 4. Free the dongles from the TV driver (the step that bites everyone)
The Linux kernel grabs RTL-SDR dongles as **DVB television tuners** the instant they're plugged in,
and then no SDR software can open them. Blacklist the DVB driver on the **host** (this repo ships
the file at `config/blacklist-rtl-sdr.conf`):
```bash
sudo cp config/blacklist-rtl-sdr.conf /etc/modprobe.d/blacklist-rtl-sdr.conf
sudo modprobe -r dvb_usb_rtl28xxu 2>/dev/null # unload it now
lsmod | grep dvb # should print NOTHING
```
Reboot to make it stick. **The symptom of losing this fight later:** rtl_airband hangs at
"Allocating zero-copy buffers," or `rtl_test` says the device is busy or open failed. It is
*always* the DVB driver reloading — re-run the check above.
> ⚠ **On appliance OSes (TrueNAS/Unraid/etc.), `/etc` can be reset by a system update.** Re-verify
> this blacklist after every OS update. This is a permanent standing chore, not a one-time step.
**Serialize your dongles** so every service can pick the right one by name (index order changes on
reboot; serials don't). With only ONE dongle plugged in at a time:
```bash
rtl_eeprom -s 1002 # plug in dongle A alone, give it serial 1002 (your voice radio)
# unplug, plug in dongle B alone:
rtl_eeprom -s 1001 # dongle B = 1001 (your telemetry radio)
rtl_test # with both in: lists both by serial; confirms they enumerate
```
(We use `1001`/`1002`; any string works — just match it in the configs.)
---
## 5. The stack — what runs
Everything is one Docker Compose stack (`docker/compose.example.yaml` + the Dockerfiles beside it):
```
▣ airband RTLSDR-Airband (rtl-sdr-blog fork, V4-capable) on dongle 1002
→ demodulates your 46 pinned voice channels at once, squelch-gated
→ one mp3 per squelch opening + a line in heard.jsonl
▣ scanhop rtl_tcp + scripts/iq_hop.py on dongle 1001
→ retunes in place: 452.9375 (10 s) ⇄ 457.9375 (10 s), forever
→ scripts/iq_channelize.py splits each dwell into center ±12.5 kHz sub-channels
→ burst → bot.jsonl / eot.jsonl / midtrain.jsonl + a raw .s16 clip
→ level.json written continuously (the live meter + "is capture alive" signal)
▣ eot-decode scripts/eot-decode.py → scripts/eot/eot_scan.py : two-pass FFSK decode of EOT clips
→ unit ID · brake psi · motion flag, enriched back into eot.jsonl
▣ bot-recover scripts/bot-recover.py : head-end frames → addressed-unit → head/tail join
▣ worker scripts/transcribe-worker.py : voice clips >5 KB → ffmpeg comms band-pass
→ a Whisper server (§7) → transcript sidecars; real speech → transcripts.jsonl
▣ trim scripts/trim-captures.py : prunes voice audio >24 h (the jsonl records are kept forever)
▣ web dashboard/serve.py : stdlib Python, zero frameworks — the dashboard + all /api/* endpoints
```
Copy the compose template and edit the two host-specific things — your stack directory path and
your dongle serials (already `1001`/`1002` if you followed §4):
```bash
cp docker/compose.example.yaml docker/compose.yaml
cp config/istrain.conf.example config/istrain.conf # then edit YOUR channels into it (§3)
```
---
## 6. Bring-up, step by step
1. **Selftest the DSP before any radio.** These run the whole signal chain on synthetic data — no
dongle needed. Green here means the math works on your machine; anything failing after this is
radio/USB, not code:
```bash
python3 scripts/iq_hop.py --selftest
python3 scripts/iq_channelize.py --selftest
```
2. **Prove the receive chain with NOAA first.** Before you wait for a train, confirm the whole
antenna→dongle→software path works on a signal that's always there. Point a dongle at your NOAA
frequency:
```bash
rtl_fm -d 0 -f 162.550M -M fm -s 12k -g 40 - | aplay -r 12k -f S16_LE
```
Weather robot voice = your chain works end to end. Silence = fix the antenna/gain/frequency
before going further. (This is the single most useful debugging move; NOAA is your oscilloscope.)
3. **Edit `config/istrain.conf`** — replace the example channels with your voice frequencies from
§3. Keep the shape: one `channels` entry per frequency, `squelch_snr_threshold` starting near
the example's value (tune per §8), mp3 outputs into the captures dir, and your dongle serial in
the device stanza.
4. **Build and start the stack** (from `docker/`):
```bash
docker compose --profile radio --profile radio1001 build # airband image builds first;
# scanhop copies rtl_tcp out of it
docker compose up -d
docker compose ps # all containers Up
```
5. **Verify capture is alive by DATA, not by "is the process running."** Watch `data/level.json` —
it should be rewritten every second or two (that's the hop breathing). Watch `data/captures/`
for mp3s. This data-freshness habit is the whole health model (§11).
6. **Open the dashboard:** `http://<host>:3456`. The live meter should bounce; the correlate
heatmap fills in 15-minute bins as bands fire.
7. **Wait for a train.** The first EOT decode is unmistakable: a unit ID, a brake pressure, and a
motion flag from a machine a mile away, pulled out of static by a US$30 dongle. That's the
moment the project becomes real.
---
## 7. Ears (optional, and the best part): transcription
Any Whisper-family ASR server works. We run **[faster-whisper](https://github.com/SYSTRAN/faster-whisper)
(model `small.en`) with VAD enabled** as its own small container/service, and
`scripts/transcribe-worker.py` posts each filtered voice clip to it (set `WHISPER_URL` /
`MILL_ASR` env to point at your server; the template default is `http://whisper:9000`).
Two rules learned the hard way:
- **Band-pass the audio first.** The worker runs an ffmpeg ~3003400 Hz comms filter before ASR —
Whisper hallucinates confident nonsense on raw squelch noise.
- **VAD on, always.** Without voice-activity detection, an hour of static becomes an hour of
imaginary dispatcher chatter. Only clips with *detected speech* earn a line in
`transcripts.jsonl` — the project's durable memory of everything the railroad said out loud
near your crossing.
---
## 8. Getting the truth out of cheap hardware
- **Generic dongles drift off frequency.** Our EOT decoder (`scripts/eot/eot_scan.py`) is
**two-pass**: an exact fast path, then an adaptive pass with fuzzy sync + tone tracking (±800 Hz)
that recovers the off-frequency, short-burst emitters an exact decoder throws away as noise.
Roughly half our real decodes come from the adaptive pass. If you write your own decoder, plan
for drift from the start.
- **Squelch sits just above YOUR noise floor, not a textbook number.** Watch a quiet hour's
squelch-opening rate. If you're capturing hundreds of empty clips, you're squelching on your own
house's RF hash — we measured **~⅔ of desk-whip squelch openings were never radio at all.** Raise
`squelch_snr_threshold` until the empties stop; lower it if you're missing weak transmissions.
- **Gain does not fix weak.** More gain amplifies the same noise. An A/B at gain 25 vs 40 vs auto on
a buried signal sounds identical. The levers that actually work, in order: **antenna height,
distance from electronics, a tuned antenna, better coax.**
- **Beware standing carriers.** We chased a "mystery mid-train transmitter" for days; it was a
**fixed wayside EOT signal booster** (an FCC-licensed repeater) parked on 457.925 that never
moves. Card it as furniture and subtract its floor from your correlation logic, or it fakes a band
forever.
- **Trust the decoder, not quick statistics.** Every shortcut we tried to characterize undecodable
bursts by spectral/envelope stats failed its own control test (a measure that couldn't tell
modulated from unmodulated on *known-good* clips proves nothing). The decoder is the only
instrument that doesn't lie.
---
## 9. Reading a train (the payoff)
From decoded EOT frames, per train, the **brake-pipe pressure curve is the story silence can't
tell:**
| Air curve | Meaning |
|---|---|
| ~8590 psi + motion flag set | **Passing** through |
| Drops to a held ~5565, motion 0 | **Dwelling** — brakes set, possibly blocking the crossing |
| Bleeds to 0 and holds, head still polling | **Cut & standing** — crew cut the crossing; the road may be OPEN even though a train is "there" |
| Recharges toward ~88, motion 0→1 | **Departing** — the roll-out is your event time |
**Never read a bleed-to-zero as a departure** — that mistake reports a blocked crossing as clear.
This taxonomy came from ~50 ground-truthed passages; yours may differ by railroad and operating
pattern, so watch and record before you trust it.
---
## 10. Security — before you point this at the internet
The dashboard (`dashboard/serve.py`) is safe to run on your LAN as-is. Before exposing it publicly
(a reverse proxy, a Cloudflare tunnel, a port-forward), know these:
- **Run it read-only when public.** Set the `SUPERVISOR_SNAPSHOT` env (any path) to mark the node
as a **presentation node** — that disables clip deletion and refuses control POSTs. Leave it
unset only on a private box you alone reach.
- **Leave `PUSH_KEY` unset unless you actually use the VM→server push path.** Unset = the
`/api/push` and `/api/push-clip` write endpoints are disabled entirely. If you set a key, treat
it as a real secret (env only, never committed) — it can overwrite the data your site displays.
- **The control endpoints are speed-bumped, not authenticated.** They require an `X-Ops` header
(so blind scanners bounce), but that's a bump, not a password. Real control should stay on your
LAN/tailnet, never bare on the public name. Put auth at your proxy if you want remote control.
- **`/api/errors/report` is intentionally public** (browser error beacon) — it's rate-limited and
length-capped server-side; that's the only endpoint that accepts unauthenticated writes, and it
only appends bounded strings to a log.
- **Never expose the raw container port to the internet.** Front it with a proxy that terminates
TLS and, ideally, gates writes.
There are **no credentials, keys, or personal data in this repository.** The frequencies are
public; the code is stdlib. Keep it that way in your fork — put secrets in env, not in files.
---
## 11. The design rules that keep it robust (read if you're modifying it)
These were bought with outages and false readings. Keep them:
- **Every capture entrypoint exits nonzero when its dongle is missing.** The container restart
policy (`restart: unless-stopped`) does the waiting — so a replug or a cold reboot self-heals
with no human. (We lost a morning to a process that exited *zero* on a missing dongle and sat
there looking alive.)
- **No startup ordering between containers.** Each retries until its own dependencies appear —
impossible to deadlock on boot order.
- **Decoders never delete clips.** Capture, decode, and cleanup are separate jobs; a decoder bug
can't eat your evidence.
- **Health is read from data freshness, never from "is the process up."** `level.json`'s timestamp
age, the last capture's age per band, the transcript trickle — a fresh timestamp is the only
health signal a radio can't fake.
- **Keep one append-only, numbered observations log.** Every "mystery signal" we resolved, we
resolved by writing down what we actually *measured* — and the two findings we later retracted
taught more than most that stood. Number the entries. Record the retractions. Future-you (or the
next forker) is who you're writing for.
---
## 12. What's in this repo
```
APOCALYPSE-EDITION.md ← you are here
README.md ← the front door + pointers
LICENSE ← GPLv3
ATTRIBUTION.md ← third-party code (PyEOT) + the shoulders this stands on
scripts/ ← the signal engine + decoders (stdlib + numpy only)
iq_hop.py ← retune-in-place 452⇄457 hop (the mission radio)
iq_channelize.py ← splits a dwell into center + ±12.5 kHz sub-channels
eot-decode.py, eot/ ← two-pass FFSK EOT decoder (eot_scan.py + vendored PyEOT for BCH)
bot-recover.py ← head-end frame recovery + head/tail join
transcribe-worker.py ← voice clip → comms filter → Whisper → transcript
vumon.py ← the live level meter
correlate.py ← the cross-band "why" engine behind the heatmap
trim-captures.py ← the 24 h voice-audio janitor
scan-hop.py, iq_retune.py, envelope.py, probe-band.py ← the hop wrapper + analysis helpers
dashboard/ ← serve.py (the API + static server) + the web UI
docker/ ← compose template + Dockerfiles (web/airband/scanhop/worker)
config/ ← istrain.conf.example (channels), blacklist-rtl-sdr.conf
```
---
*Built at a Norfolk Southern crossing in central Ohio, 2026, on RTL-SDR Blog V4 hardware, by a
human and his AI pair-programmer. The live instance runs at
[istrain.jhestyr.net](https://istrain.jhestyr.net). This repository is the whole thing — read it,
run it, fork it, and if you build one, tell us what you heard.*