Native rail End-of-Train / Head-of-Train telemetry decode core for
xng-mode-eot. North-American freight rail uses a short two-way
telemetry link between the locomotive (HOT, Head-of-Train, at the front)
and the rear-car device (EOT, End-of-Train): the EOT periodically reports
brake-pipe pressure, motion, marker-light and battery status to the head
end, and the head end can command the EOT (e.g. emergency brake). On air
it is narrowband 1200-baud binary FSK with Manchester line coding
(two opposite chips per data bit), on two channels:
- EOT → HOT (rear-to-front telemetry): 457.9375 MHz
- HOT → EOT (front-to-rear command): 452.9375 MHz
Direction (telemetry vs command) is not a wire field — it is chosen
by the receive frequency, and surfaces as the kind of the emitted
message ("eot" vs "hot").
Reverse-engineered: there is no public formal AAR standard for this
link. The bit layout, field semantics, and the BCH check are anchored to
the field map shared byte-for-byte by two independent public decoders
(ereuter/PyEOT and russinnes/EOTDecode) plus the on-air RF facts from
SIGIDWIKI; each is cited inline below and in the source. The decode /
framing layer (74-bit packet → fields + BCH) is verified against a
hand-built spec packet laid out exactly per that documented field map.
The demod (IQ → chips) is validated only by a synthetic
modulate → AWGN → demod test — no off-air IQ is available, so no
real-RF claim is made.
Status: WIRED, SYNTHETIC-ONLY. Runtime mode Mode::Eot, body
MessageBody::Eot { kind, details }, and an EotChannelDecoder that owns
an xng_dsp::Ddc. The framing core is spec-anchored; the IQ front end is
exercised only by self-generated waveforms (no recorded capture exists).
wideband capture IQ
→ Ddc mix by freq_offset_hz, decimate to CHANNEL_RATE (24 000 S/s)
→ demod::FskDemod freq discriminator + DC tracker + 2400-chip timing → 1 chip/symbol
→ demod::manchester_decode pair chips [1,0]→1 / [0,1]→0 (both pairing phases tried)
logical bit stream
→ scan_bits hunt 101010 + 11-bit frame sync, slice 74-bit packets
→ frame::parse_packet frame-sync check, LSB-first field map, BCH verify
→ frame::EotFrame (serde JSON) → to_message → xng_types::Message bus form
Two entry points:
EotChannelDecoder::new(input_rate, freq_offset_hz)— channelized IQ entry (mirrors the NAVTEXNavtexChannelDecodercontract).process(iq)feeds the DDC + demod, accumulates the channel's Manchester chip history (EOT bursts are short, so chips are buffered and re-scanned), and emits anEotDecodedFrameper recovered packet. It tries both Manchester pairing phases (0 and 1) and lets the frame-sync hunt reject the wrong one. Dedups by raw packet-bit identity so a growing buffer does not re-emit the same packet. Wheninput_rate == CHANNEL_RATEand offset is 0 the DDC is skipped (IQ is already channelized).scan_bits(bits)— the verified bit-stream → frames core (inlib.rs): scans a logical bit stream for the hunt pattern and decodes every aligned 74-bit packet that follows. Returns the parsedEotFrames.
to_message(frame, frequency_hz, level_dbfs, is_hot, source) normalizes
an EotDecodedFrame into the bus Message: mode = Mode::Eot, body
MessageBody::Eot { kind, details } where kind is "hot" when
is_hot else "eot" (caller picks it from the receive frequency),
details is the EotFrame JSON, decode.crc_ok = frame.bch_ok, RSSI
from the channel level, and the 74 packed packet bits travel as raw
(one byte per bit).
params carries the on-air constants (informational, SIGIDWIKI EOTD):
BAUD = 1200.0, FREQ_EOT_TO_HOT = 457_937_500,
FREQ_HOT_TO_EOT = 452_937_500. CHANNEL_RATE = 24_000.0 S/s
(10 samples per Manchester chip at the 2400-chip rate) and
CHANNEL_PASSBAND_HZ = 4_000.0 (one-sided; passes the ±FSK tones plus
the Manchester chip-rate sidebands and a realistic tuning offset while
staying inside the ~8 kHz EOT channel).
The 1200-baud Manchester-FSK demodulator, structured after the same
narrow-shift FSK chain the NAVTEX core uses but for a Manchester-coded
2-FSK signal. Input is complex channel IQ at CHANNEL_RATE, already mixed
to baseband by the crate's Ddc.
- per-sample frequency discriminator
arg(x · conj(x_prev)); - a slow DC tracker (
FREQ_ALPHA = 0.0005) that absorbs residual carrier/tuning offset so only the FSK swing remains; - per-chip integrate-and-dump at the Manchester chip rate
(
CHIP_RATE = 2 · BAUD = 2400) with zero-crossing timing recovery (TIMING_GAIN = 0.10); Manchester coding makes chip transitions dense, so the loop gets plenty of edges; - mark (positive discriminator) / space slicing → one chip decision per chip period (1 = mark / positive freq, 0 = space / negative).
FskDemod::new() asserts ≥ 4 samples/chip for timing (24000/2400 = 10).
level_dbfs() reports smoothed channel power (LEVEL_ALPHA = 0.002).
manchester_decode(chips, phase) pairs the chip stream into logical bits
starting at chip offset phase (0 or 1): [1,0] → 1, [0,1] → 0. An
ambiguous pair ([0,0] or [1,1], e.g. from noise) decodes to the
first chip's value as a soft fallback so the sync hunt can still slide
over it. The channel decoder runs both phases because the demod has no
absolute chip-pair alignment; only the frame-sync hunt distinguishes the
correct pairing.
After Manchester decode the logical stream is hunted for a 17-bit run
10101011100010010 (cited PyEOT/EOTDecode search):
BIT_SYNC_TAIL = 101010— the 6-bit tail of the alternating bit-sync (clock) preamble;FRAME_SYNC = 11100010010— the 11-bit frame sync word that opens every packet.
The cited decoders take buffer[6:] after the match, so the frame sync
becomes packet[0:11]. scan_bits reproduces this: a match at index i
means the packet starts at i + BIT_SYNC_TAIL.len(); it slices the
following PACKET_BITS = 74 bits, calls parse_packet, and on success
skips past the packet so it does not re-match inside it.
Both cited decoders model a 74-bit packet after Manchester decode + bit sync:
packet[ 0:11] frame sync word = 11100010010 (11 bits)
packet[11:56] data block (45 bits, BCH-protected)
packet[56:74] BCH check word (18 bits, ciphered)
DATA_START = 11, DATA_END = 56, PACKET_BITS = 74. parse_packet
returns None if the slice is short or the frame sync word is wrong.
Bit indices into the 74-bit packet, sliced exactly as the cited decoders
slice them. Multi-bit fields are stored LSB-first on the wire: the
decoders reverse each slice before int(..., 2), which equals reading the
original slice LSB-first — reproduced here by field_rev.
| Bits | Field | Notes |
|---|---|---|
[11:13] |
chaining | 2 bits, surfaced raw as chaining ((p[11]<<1)|p[12]); meaning undocumented publicly |
[13:15] |
battery condition | 2 bits, reversed; 11 OK, 10 Low, 01 Very Low, 00 Not Monitored |
[15:18] |
message type | 3 bits; 0b111 ⇒ status/arm message |
[18:35] |
unit address | 17 bits, reversed → integer (the device's programmed ID) |
[35:42] |
brake-pipe pressure | 7 bits, reversed → psig integer |
[42:49] |
battery charge | 7 bits, reversed → raw/127·100 percent (rounded) |
[49] |
spare | 1 bit |
[50] |
valve circuit / disconnect | 1 bit |
[51] |
conf / arm indicator | 1 bit; with message type 0b111: 0 Arming, 1 Armed |
[52] |
turbine (charger running) | 1 bit |
[53] |
motion | 1 bit; 1 = EOT detects train motion |
[54] |
marker-light battery | 1 bit |
[55] |
marker-light status | 1 bit (on/off) |
EotFrame carries every field above plus battery_condition_text,
bch_ok, and (only for message type 0b111) arm_status
("Arming"/"Armed"). arm_status is None and skipped in JSON for
every other message type.
The 2 chaining bits ([11:13]) fall inside the BCH-protected data
block, but neither cited decoder names their meaning, so they are
surfaced raw and the gap is noted (see Known limitations).
The frame uses a systematic binary BCH(63,45) code narrowed in the field decoders to an 18-bit check word: modulo-2 polynomial division of the 45-bit data block by a 19-bit generator, with the remainder XOR-ed against a fixed 18-bit "cipher" key. Both decoders agree byte-for-byte:
GENERATOR = 1111001101000001111(19 bits → degree-18 generator);CIPHER = 101011011101110000(18 bits);CHECK_BITS = 18.
Verify path (ciphered_check / verify), reproducing the decoders'
arithmetic exactly:
- reverse
packet[11:56]to LSB-first; checkbits= mod-2 long-division remainder byGENERATOR(appendlen(gen)-1zeros, XOR the generator in whenever the leading bit is 1, keep the trailing 18 bits — standard CRC-style systematic remainder, mirroringhelpers.checkbits/helpers.mod2div);- XOR the remainder with
CIPHER; - a frame is valid when that equals
packet[56:74](setsbch_ok).
This is error detection only — the data block is not corrected.
to_message produces:
mode = Mode::Eot;body = MessageBody::Eot { kind, details },kind∈ {"eot","hot"} fromis_hot,details=EotFrameJSON;decode.crc_ok = frame.bch_ok(fec_corrected/errorsareNone— no correction is performed);signal.rssi_dbfrom the channellevel_dbfs;raw= the 74 packet bits, one byte per bit, rebuilt bypacket_bits_of(which re-slices the parsed fields LSB-first and recomputes the ciphered check sorawround-trips through BCH verify).
The framing/decode layer is anchored to the documented field map shared by the two independent public decoders; the demod is validated only synthetically. There is no off-air IQ and no real-RF result.
| Layer | Fact / table | Oracle | How verified |
|---|---|---|---|
| Packet layout + field map | frame sync 11100010010, [0:11]/[11:56]/[56:74] split, every data-block slice, battery-condition table, arm-status, LSB-first field convention |
ereuter/PyEOT eot_decoder.py and russinnes/EOTDecode eot_decoder.py (byte-for-byte identical) |
decodes_spec_field_map_and_bch hand-builds the exact 74 bits per the documented layout and asserts the decoder recovers every field; battery_condition_table_matches_cited_decoders; arm_status_for_status_message_type |
| BCH check | GENERATOR, CIPHER, 18-bit ciphered remainder, the reverse→mod2div→XOR verify path |
PyEOT/EOTDecode helpers.checkbits/mod2div; classic CRC worked examples |
generator_and_cipher_match_cited_decoders re-derives both bit strings; checkbits_matches_reference_mod2div anchors the GF(2) division against the cited mod2div and the Wikipedia "Computation of CRC" example (11010011101100 ÷ 1011 → 100); decodes_spec_field_map_and_bch asserts the spec packet's documented check verifies; bch_detects_corrupted_data_bit flips one data bit and requires bch_ok to drop |
| Hunt / sync | the 10101011100010010 search run, packet offset after the 6-bit bit-sync tail |
PyEOT/EOTDecode (buffer[6:] after match) |
hunt_pattern_is_cited_search_run; scan_finds_packet_after_bit_sync_preamble prepends an alternating clock run and requires exactly one decoded packet |
| RF facts | 1200-baud FSK, EOT→HOT 457.9375 MHz, HOT→EOT 452.9375 MHz | SIGIDWIKI "End of Train Device (EOTD)" | params_are_documented_values; channel_rate_is_integer_chip_multiple |
Spec-cited framing ground truth. build_spec_packet assembles a
plausible EOT→HOT report (unit 0x1A2B3, 75 psig, moving, marker light on,
turbine charging, battery OK) per the documented field map, computes the
documented ciphered BCH check, and asserts the crate's independent
field extraction and BCH verify pass together — spec-cited ground truth,
not a self-modulator round-trip.
Synthetic modulate → AWGN → demod (the only demod proof). The
*_synth_iq tests in tests/end_to_end.rs build the on-air
Manchester-FSK waveform (modulate.rs) for a known spec-built packet and
run it through the real EotChannelDecoder (DDC + discriminator +
chip timing + Manchester pairing + sync hunt + the verified framing core):
channel_decoder_recovers_clean_synth_iq— clean IQ atCHANNEL_RATE, offset 0; recovers a BCH-clean frame, fields exact.channel_decoder_recovers_with_carrier_offset_via_ddc_synth_iq— a 96 kS/s capture with a +12 kHz carrier offset; the DDC mixes it down and the DC tracker absorbs the residual tuning error.synthetic_awgn_ber_frame_recovery— modulate → complex AWGN at a controlled SNR → demod, over 12 LCG-seeded trials. At the chosen per-componentsigma = 0.18against amplitude0.8(≈ 10 dB SNR) it requires ≥ 75 % of trials to recover a BCH-clean, field-exact frame. Because the BCH check is strict, a single residual bit error fails a trial. These SNR/threshold numbers are synthetic-test parameters, not measured off-air performance.to_message_emits_eot_body_from_synth_iq— confirms the bus mapping:Mode::Eot,MessageBody::Eot { kind: "eot", details },crc_okset, RSSI present,rawpresent, and thedetailsJSON fields.
The modulator is not an external reference (modulate.rs): it only
proves the demod inverts this modulation. The FSK SHIFT_HZ = 1800 is an
explicit modulator choice for the synthetic test (a conventional
~1.5 modulation index near the 1200-baud rate that sits inside the ~8 kHz
channel), not a claimed spec value; the on-air shift is not documented
by the cited sources.
- No off-air validation. No real-RF result. No recorded EOT/HOT IQ is available; the entire demod chain is proven only on self-generated synthetic waveforms. Every demod tolerance (SNR, offset, timing) is untested against a real capture.
- Modulation parameters are not fully pinned. The FSK shift is a
modulator choice (
SHIFT_HZ = 1800); the cited sources give the baud rate and channels but not the deviation, soCHANNEL_PASSBAND_HZandSHIFT_HZare educated, not spec-confirmed. - Reverse-engineered, no formal standard. There is no public AAR S-9152 document; field semantics follow the two cited open decoders. If they share a mistake, this crate inherits it.
- Chaining bits unpinned.
packet[11:13]are BCH-protected but unnamed by either decoder; they are surfaced raw with no interpretation. - Detection, not correction. The ciphered BCH check only detects
errors in the 45-bit data block; there is no correction and no time
diversity, so any corrupted packet is simply dropped (no
fec_corrected). - No carrier/channel search. Acquisition relies on the caller's DDC
freq_offset_hzplus the demod's slow DC tracker; there is no automatic EOT carrier acquisition. - Direction is out-of-band.
eotvshotis decided by the caller from the receive frequency (is_hot), not by any wire field; a capture tuned to the wrong channel would still parse but be mislabeled. - HOT command semantics not modeled. Only the (telemetry-style) field map is decoded; HOT→EOT command packets are parsed with the same 74-bit layout, and command-specific field meanings are not separately documented.
- No position output. EOT carries no fix, so a decoded frame has no map location; it surfaces as a telemetry/message record only.
- The packet hunt runs on the Manchester-decoded logical bit stream, not on chips; the demod emits chips and the channel decoder pairs them.
- Manchester pairing has two phases — the channel decoder tries both (0 and 1) and lets the frame-sync hunt reject the wrong one. Do not assume a fixed chip-pair alignment.
- The hunt key is 17 bits (
101010+ the 11-bit sync); the packet begins at the match index + 6 (the frame sync becomespacket[0:11]). - Multi-bit fields are LSB-first on the wire (
field_revreverses the slice); single status bits are read directly. - The BCH path reverses the data block, divides by
GENERATOR, then XORsCIPHER— omitting the cipher XOR makes every frame fail verify. bch_okis detection only;crc_ok/fec_correctedreflect that (no correction is ever applied).CHANNEL_RATE = 24000is exactly 10 samples/chip at the 2400-chip rate;FskDemod::newasserts ≥ 4 samples/chip.- Frames are deduped by raw packet-bit identity;
packet_bits_ofrecomputes the ciphered check when rebuildingraw, sorawround-trips through BCH verify.
- ereuter/PyEOT (
eot_decoder.py,helpers.py, https://github.com/ereuter/PyEOT) — the reverse-engineered 74-bit field map, battery-condition / arm-status semantics, the hunt run, and the ciphered BCH(63,45) generator/cipher and verify path (facts only). - russinnes/EOTDecode (
eot_decoder.py,helpers.py, https://github.com/russinnes/EOTDecode) — the second independent decoder; agrees byte-for-byte with PyEOT on the field map and BCH constants. - SIGIDWIKI "End of Train Device (EOTD)" — on-air RF facts: 1200-baud FSK, EOT→HOT 457.9375 MHz, HOT→EOT 452.9375 MHz.
- Wikipedia "Computation of CRC" — the GF(2) long-division worked
example used to anchor
checkbitsindependently of the EOT key. docs/notes/NAVTEX.md— the sibling narrow-shift FSK channel-decoder template this crate's IQ front end and*ChannelDecodercontract follow.