Capture and decode End-of-Train (EOT) and Head-of-Train (HOT) telemetry from passing freight trains with an RTL-SDR — a single self-contained Python CLI.
EOT devices (the "FRED" on the last car) transmit status — unit address, brake line pressure, motion, marker light, battery — to the locomotive on the AAR S-9152 wireless link. traintap tunes the radio, demodulates the signal natively from I/Q, validates every packet against its built-in BCH check code, and logs the decoded telemetry to your console and a CSV.
A companion web dashboard (FastAPI + Chart.js) shows live status — a red/green "train near" light, trains/hour, signal strength for antenna tuning, source (EOT/HOT/DPU) and decode-quality breakdowns, and a recent-trains table.
(Click the image for the full-length dashboard.)
You need: an RTL-SDR, a UHF antenna (a 70 cm ham whip is plenty — see Antenna & frequencies), and Docker. Plug the SDR in, then:
git clone https://github.com/trickv/traintap
cd traintap
docker compose upThat builds and starts the decoder (scanning EOT, decoding DPU too) and the dashboard at http://localhost:8000. When a freight train passes, decoded packets appear in the logs and on the dashboard within seconds.
Just want a quick console check, no dashboard?
docker run --rm --device=/dev/bus/usb ghcr.io/trickv/traintap --mode eotNo SDR handy but want to see it run? This decodes a synthetic packet end-to-end, no hardware:
docker run --rm ghcr.io/trickv/traintap --selftestDongle not found? Make sure it's plugged in; if the devices: mapping doesn't
work on your system, set privileged: true on the traintap service in
docker-compose.yml. (The DVB TV driver is auto-detached at runtime, so you
normally don't need to blacklist anything.) Only one program can use the SDR at
a time. A one-time [R82XX] PLL not locked! line at startup is librtlsdr
noise, not an error — if the tuner was found, it's decoding. Silence in the
logs between trains is normal; the dashboard's status light doubles as the
liveness check (it goes red "decoder offline" if the decoder dies).
EOT/HOT/DPU are North American AAR S-9152 signals in the UHF 70 cm band:
| Link | Frequency | Notes |
|---|---|---|
| EOT (rear → loco) | 457.9375 MHz | frequent telemetry — the main signal |
| HOT (loco → rear) | 452.9375 MHz | infrequent commands |
| DPU (distributed power) | 457.9250 MHz | mid-train locos; decoded alongside EOT |
A 70 cm / UHF antenna is good enough — a 70 cm ham whip, a UHF scanner antenna, or the RTL-SDR dipole kit with each element set to ~16 cm (a quarter wave at 458 MHz). Mount it vertically (these signals are vertically polarized), with as clear a path toward the tracks as you can manage. UHF falls off fast indoors and especially in basements, so a window or outdoor placement helps a lot. You don't need to be trackside — within a few hundred meters of a passing train is plenty.
RTL-SDR I/Q ─► NCO mix (offset tune) ─► decimate to 48 kHz + channel filter
─► FM demod ─► 1200/1800 Hz AFSK matched filters ─► DPLL @ 1200 baud
─► sync-word search ─► 45-bit data block + 18-bit BCH check ─► fields
No rtl_fm, sox, or patched minimodem — the whole chain is numpy/scipy in
one process. Only BCH-valid packets are reported, so output is trustworthy.
| Frequency | Direction | Traffic | |
|---|---|---|---|
| EOT | 457.9375 MHz | rear → loco | frequent telemetry (the main prize) |
| HOT | 452.9375 MHz | loco → rear | infrequent commands (arm, comm-test, emergency) |
| DPU | 457.9250 MHz | distributed power | mid-train loco telemetry (decoded alongside EOT) |
Because DPU is only 12.5 kHz from EOT, it falls inside the same capture — traintap
demodulates it from the same I/Q at a second offset (no retuning), so you get DPU
for free whenever listening for EOT. (Disable with --no-dpu.) DPU shares EOT's
AAR framing/BCH; its non-address field semantics are provisional pending a clean
live capture.
Packets heard within --pass-gap seconds of each other are the same train going
by. --passes-csv logs one row per pass — start/end, and the EOT unit ID(s)
alongside any DPU (and HOT) units heard together — so a mid-train distributed-
power unit is tied to the EOT that identifies the train. The console prints a
== PASS hh:mm:ss-hh:mm:ss EOT 69686x4 DPU 12345x2 line as each pass closes.
The dashboard estimates each train's speed from the Doppler shift of its EOT
carrier as it passes (logged per burst as freq_offset_hz in signal.csv). It
works out of the box with no setup (a swing estimate from the frequency
excursion). Optionally set TRACK_DISTANCE_M (perpendicular meters from your
antenna to the track — measure once off a map) to enable a more accurate
geometric fit; it's purely optional. Caveat: EOT bursts are ~60 s apart, so speed
is reliable mainly for slower/longer passes and shows — when there are too few
samples to trust.
When logging with --csv, the daemon touches a heartbeat file next to the CSV
about once a minute (override with --heartbeat FILE, disable with
--heartbeat ""). The dashboard checks its mtime and flips the status card to a
pulsing red decoder offline when the heartbeat is >3 minutes stale (or
missing) — so a dead daemon (power blip, crash, unplugged dongle host) can't
silently masquerade as "no trains". To run the decoder persistently across
reboots, see deploy/traintap.service (systemd) or docker-compose.yml.
Modulation is 1200-baud FFSK in an ~8 kHz NFM channel (mark 1200 Hz, space 1800 Hz after FM demod).
EOT and HOT are 5.0 MHz apart, which exceeds a single RTL-SDR's usable bandwidth (~2.4 MHz) — so one dongle cannot watch both at once (true with any software, including KA9Q-radio). traintap time-shares: it dwells mostly on EOT and dips to HOT periodically. It favors EOT while a train is active and lingers on HOT briefly when a HOT packet decodes. Catching all HOT requires a second receiver (a 2nd RTL-SDR on 452.9375, or a wide SDR + KA9Q-radio).
--mode hotwatch is an alternative strategy that treats HOT as an approach
warning: idle, it spends most of its time (--hot-fraction, default 70%)
listening for HOT (the head end), and the moment a HOT packet decodes — meaning a
train's front is arriving — it locks onto EOT/DPU 100% for --focus-minutes
(default 10) to catch the body and the rear (FRED) as the train finishes passing.
Best for lines with two-way/armed trains that actually transmit HOT.
Prefer Docker (above) for the least fuss. To run it natively instead:
System dependency (the native RTL-SDR library — not pip):
sudo apt install rtl-sdr librtlsdr-dev
# free the dongle from the DVB kernel driver:
echo 'blacklist dvb_usb_rtl28xxu' | sudo tee /etc/modprobe.d/blacklist-rtl.conf
# then replug the dongle (or: sudo modprobe -r dvb_usb_rtl28xxu)The rtl-sdr package usually installs these at /etc/udev/rules.d/rtl-sdr.rules.
If missing (or you built from source), add them:
sudo tee /etc/udev/rules.d/20-rtlsdr.rules >/dev/null <<'EOF'
# RTL-SDR (Realtek RTL2832U) — grant access to the plugdev group
SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2832", GROUP="plugdev", MODE="0666", SYMLINK+="rtl_sdr"
SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2838", GROUP="plugdev", MODE="0666", SYMLINK+="rtl_sdr"
EOF
sudo udevadm control --reload-rules && sudo udevadm trigger
# then unplug and replug the dongleFind your dongle's idVendor:idProduct with lsusb (look for "Realtek") if it
differs. Confirm membership in plugdev (groups | grep plugdev; add with
sudo usermod -aG plugdev $USER then re-login). After replugging, rtl_test -t
should run as a normal user with no "usb_claim_interface error -6".
Python package:
python3 -m venv .venv && . .venv/bin/activate
pip install -e . # installs numpy, scipy, pyrtlsdr + the `traintap` commandVerify the radio: rtl_test -t, then traintap --list-devices.
# Default: park on EOT (457.9375) and decode DPU from the same capture.
# This is the simplest, most productive mode and never misses the frequent EOT.
traintap --csv trains.csv
# Also time-share to HOT (452.9375) to hunt for head-end packets (see HOT note):
traintap --mode scan --csv trains.csv
# Tuning aids:
traintap --gain 40 --ppm 12 # fixed gain (dB) and freq correction
# No hardware needed — prove the DSP+decoder work:
traintap --selftestNote on HOT: the default is EOT/DPU only, on purpose. The rear EOT chirps telemetry constantly; the head-end HOT transmitter only speaks when a train is two-way-armed (arming, comm-test, emergency). On the author's line, HOT appears not to be present at all — many trains run one-way and/or use Distributed Power instead of two-way EOT — so chasing HOT just costs EOT coverage. Your mileage may vary: other lines/railroads do transmit HOT. If you want to hunt for it, use
--mode scan(time-shares HOT) or--mode hotwatch(watches HOT as a train-approach trigger), and watch for clean decodes on 452.9375 — a strong adjacent signal on 452.9250 is a different, non-railroad emitter, not HOT.
Console output (one line per packet, deduped):
14:02:11 EOT 457.9375 unit 45678 92 psig batt OK/94% [MOVING light]
traintap --record pass.npz --record-seconds 20 # save 20 s of I/Q on EOT
traintap --replay pass.npz --csv pass.csv # decode it offlineRecord real I/Q while a train passes, then replay it as a regression fixture.
| Flag | Meaning |
|---|---|
--mode {scan,eot,hot,hotwatch} |
channel strategy (default eot = EOT+DPU; see HOT note) |
--hot-fraction |
hotwatch: idle share of time on HOT (default 0.7) |
--focus-minutes |
hotwatch: lock onto EOT/DPU for N min after a HOT hit (default 10) |
--eot-dwell / --hot-dwell |
seconds per dwell while scanning |
--gain |
auto or gain in dB |
--ppm |
crystal frequency correction |
--offset |
tuning offset below channel to dodge the DC spike (default 250 kHz) |
--csv FILE |
append decoded packets (CSV, header on create) |
--bch-correct N |
BCH-correct up to N bit errors/frame (0 = off; default 2) |
--passes-csv FILE |
log one row per train pass (EOT ID ↔ DPU/HOT units) |
--pass-gap S |
silence (s) that ends a train pass (default 90) |
--dedupe S |
suppress repeat console lines per unit for S seconds (0 = off) |
--stats-interval S |
live summary cadence (0 = off) |
--keep-invalid |
also show BCH-failed packets (debugging) |
- EOT decoding is complete and BCH-validated, with syndrome-based error
correction (up to 3 bit errors;
--bch-correct, default 2). Corrected packets are flagged with their correction count in the console (~Nb) and CSV. To keep correction from accepting noise, a corrected packet is only emitted once corroborated — the same unit also seen clean, or repeated within a pass; isolated corrected frames are dropped. Uncorrected (exact-BCH) packets are trusted immediately. - HOT shares the front end and BCH check; its command-message field semantics
are provisional pending real captures (
--recordto help characterize). - DPU (457.9250) is decoded from the same EOT capture; field semantics beyond the unit address are provisional pending a clean live DPU capture to confirm.
- Future: simultaneous EOT+HOT via a second receiver.
traintap stands on the shoulders of prior EOT/HOT reverse-engineering and decoding work:
- PyEOT (Eric Reuter, 2018, GPL) — the
original Python EOT decoder. traintap's BCH check algorithm and EOT data-block
field layout (
traintap/frame.py) are ported from it; this is the reason traintap is GPL-licensed. - EOTDecode (Russ Innes, 2023) —
a PyEOT-derived decoder; the specific
helpers.py/eot_decoder.pyconstants and offsets traintap reuses were cross-checked against this fork. - SoftEOT — a Windows EOT/HOT decoder; the community reference implementation for these signals.
- SDRangel — includes an EOT demod/decoder.
- Signal Identification Wiki — EOTD and rtl-sdr.com — frequency, modulation, and protocol references.
What traintap adds over the above: an in-process native-I/Q front end (replacing
the rtl_fm | sox | minimodem chain), BCH error correction, time-share EOT/HOT
scanning with DPU decoded from the same capture, EOT↔DPU train-pass correlation,
and a live web dashboard. Receive-only by design.
Bundled third-party code: Chart.js (MIT) in
dashboard/static/vendor/.
GPL-3.0-or-later — see LICENSE. traintap is a derivative of the GPL-licensed PyEOT, so the project carries the GPL forward. The bundled Chart.js (MIT) and the BSD/MIT-licensed dependencies (numpy, scipy, uvicorn, FastAPI) are GPL-compatible.
