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>
istrain — EOT decode & reap
Stage A captures EOT-band bursts (457.9375 MHz, dongle 1001) with vumon --clips; Stage B decodes them here.
What's here
eot_scan.py— the decoder, TWO passes (2026-07-05): (1) fast/exact — whole file, fixed tone pairs, exact sync (proven againstPyEOT/demo3eot.wav, 3 valid packets); (2) adaptive/fuzzy escalation for off-frequency emitters (OBSERVATIONS O-11) — burst located by FM-quieting, tone pair built on the burst's spectral centroid, sync matched with ≤2 bit errors (BCH still gates). Packets carrydq: exact|fuzzy; the card layer corroborates fuzzy singletons before trusting them.scan_file(path)returns majority-vote-ordered packets ([]= not EOT). numpy required.../reaper.py— the scheduled janitor, now generic/multi-band (lives inscripts/, not here). EOT is wired as a band; BOT/mid are ready slots. See below.PyEOT/— vendored parser + BCH + demo recording by Eric Reuter, GPL (seePyEOT/LICENSE). Source: https://github.com/ereuter/PyEOT. We use its packet parser and the reference wav.
Decode a file by hand
python3 eot_scan.py PyEOT/demo3eot.wav # self-test → 3 packets (unit/pressure/motion…)
python3 eot_scan.py ~/istrain/eot_clips/eot_*.s16 # our captured bursts
The reaper (scheduled cleanup) — ../reaper.py
Generic janitor, split out of EOT (2026-06-23) so it can serve BOT/mid later. Per wired band it decodes each
finished clip (EOT → ~/istrain/eot_clips/):
- real → move the recording to
<band>_clips/confirmed/, enrich its detection in the band's log - noise → delete the clip and remove its detection hit from the log (panel only shows real trains)
- oversized (>32 MB) → quarantine to
<band>_clips/oversized/, never load (OOM guard)
Only scans clips older than 60s (never one mid-capture). --dry-run previews; --band NAME runs one band.
BOT/mid are commented placeholders in reaper.py (no decoder yet → nothing to reap). The EOT decoder
(eot_scan.py) stays separate, so it can be paused/iterated independently of the cleanup.
Deploy the schedule (systemd --user, every 15 min)
cp ../../systemd/istrain-eot-reap.{service,timer} ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now istrain-eot-reap.timer
systemctl --user list-timers istrain-eot-reap.timer # confirm it's scheduled
DPU decode plan (2026-06-24) — the plan-for-a-plan
Mid-train DPU rides UHF 452/457 (NOT ~900 — see radio/research/railroad-eot-atcs.md 06-24 correction),
1200-baud FFSK, same modulation family as EOT. Channel = lead loco road-number last digit:
452.925 (3/7) · 452.950 (2/6) · 457.925 (1/5/9) · 457.950 (0/4/8). So most of the chain is already built.
We can't write this yet — we have no DPU sample. This is the staged plan to execute once a live burst is in hand (the rooftop antenna is the gate, same as EOT). Stages 1–2 are shared with EOT; only stage 3 is new:
- Isolate the sub-channel (the one genuinely-new RF step). ✅ BUILT 2026-06-24 —
../iq_channelize.py: readsrtl_sdrCU8 IQ, NCO-mixes + 151-tap FIR-LPFs + decimates each sub-channel (center + ±12.5 kHz), presence = in-channel power over an adaptive floor, emits a discriminated s16 clip per burst. Routing + isolation proven on synthetic IQ (--selftest); FM-demod audio can't do this (±12.5 kHz becomes a DC bias, not a separable tone). Opt-in viaHOP_IQ=1on scan-hop (FM path stays the fallback). This same channelizer is what lets detection label a burst DPU-vs-HOT — distinct-ID and decode are one job. Unvalidated against a real DPU burst until the antenna's up — same gate as live EOT. - FFSK demod → bits. REUSE
eot_scan.py's tone discriminator + 1200-baud clock recovery unchanged — DPU is the same Bell-202-style FFSK as EOT. - DPU frame parse (the new code). DPU packets are a different frame format than EOT (control vs telemetry — why railfans run SoftDPU separate from SoftEOT). Need to source the DPU packet layout (SoftDPU / open references) and write a parser + its error-check. PyEOT's BCH is EOT-specific; verify whether DPU uses the same FEC before reusing it.
Validation: no DPU reference recording ships with PyEOT (its demo3eot.wav is EOT only). First real
captured DPU burst becomes our reference; until then, stage 3 is unverifiable — don't trust it blind.
For the mission, none of this is required — detection already counts DPU via the 452/457 legs
(../correlate.py). Decode is the "what is the lead unit telling the mid-consist" luxury, not the commute signal.
Status (2026-06-23)
Decoder proven on the reference. No real EOT received yet on the desk 6" whip — a range limit, not a
code one; EOT waits on the rooftop DPD EOTD vertical (tuned 452+457, the chosen feed for 1001 — ../../ANTENNA-UPGRADE.md). Reaper split to the generic
../reaper.py (2026-06-23) and is currently PAUSED (timer stopped) to preserve a capture flurry for
study — resume with systemctl --user start istrain-eot-reap.timer. (Promote-candidate: eot_scan.py is
generic enough for the radio engine; living here in istrain until it earns the move.)