Part 8 of Protocol Decoders. Parts 2–7 built up the modern digital control channel — typed PDUs, strong FEC, an explicit grant message with a talkgroup and a frequency. This episode goes the other direction, to the three legacy families still on the air: EDACS, LTR and MPT-1327. They predate the “one clean digital CC” idea, and each solves the trunking problem with what the 1970s–80s had on hand. Understanding them is also a reminder for the Mercury hunt ahead — an unknown emitter might not be modern at all.
TL;DR: EDACS runs a continuous 9600-baud GFSK control channel of 40-bit Control Channel Words (CCWs), each protected by a shortened BCH(40,28,2). LTR has no central control channel at all — every repeater transmits a 41-bit status word at 300 bps under the voice, and the scanner follows calls by watching all of them. MPT-1327 uses 1200-baud FFSK carrying 64-bit codewords (38 info + 26 BCH check). All three land in the same engine through
events.KindGrant, but the decode front-ends could hardly be more different.
Key takeaways
- EDACS is the closest to modern: a dedicated fast digital CC with a per-word
BCH and a clean
Commandopcode enum. - LTR is distributed trunking — there is no control channel; call-follow means reading every repeater’s sub-audible status word.
- MPT-1327 is FFSK tone signalling: 64-bit codewords, a
GoToChannel(GTC) grant, and BCH left to the caller. - All three converge on the same
trunking.Grantthe engine consumes — the legacy weirdness is quarantined in the decoders, not the engine.
Cheat sheet
| System | Control scheme | Unit | Grant message | Where |
|---|---|---|---|---|
| EDACS | continuous 9600-baud GFSK CC | 40-bit CCW | GroupVoiceGrant |
internal/radio/edacs/ |
| LTR | no central CC — per-repeater | 41-bit status word @ 300 bps | active Status (F-bit) |
internal/radio/ltr/ |
| MPT-1327 | continuous 1200-baud FFSK CC | 64-bit codeword | GoToChannel (GTC) |
internal/radio/mpt1327/ |
In this post
- What “legacy trunking” means — three answers to one problem.
- EDACS — the fast digital CC and the CCW.
- LTR — distributed trunking with no control channel.
- MPT-1327 — FFSK codewords and the GTC grant.
- The common denominator — how all three feed one engine.
Three answers to one problem
Every trunked system answers the same question: when a user keys up, which
frequency does everyone’s radio jump to? Modern digital systems (P25, DMR, NXDN,
TETRA) answer it with a dedicated digital control channel streaming typed,
FEC-protected PDUs — the world Parts 2–7 lived in. The legacy family answers it
three different, older ways, and GopherTrunk keeps each one in its own package
with the same overall shape: a wire parser, an opcode/field layer, a band-plan
resolver, and a control.go state machine that emits cc.locked and
events.KindGrant.
EDACS: the fast digital CC
EDACS (Enhanced Digital Access Communications System, the GE-Marc / Ericsson
lineage) is the most “modern-feeling” of the three. It runs a continuous
9600-baud GFSK control channel over which 40-bit Control Channel Words flow,
each prefaced by a 24-bit sync pattern and protected by a shortened
BCH(40,28,2) code. A CCW packs four fields — {Command, Status, Address, LCN,
Aux} — and the Command nibble is a clean opcode enum:
// internal/radio/edacs/opcodes.go (shape)
const (
CmdIdle Command = 0x0
CmdGroupVoiceGrant Command = 0x1
CmdProVoiceGrant Command = 0x2 // EDACS ProVoice (digital voice)
CmdDataGrant Command = 0x4
CmdSystemID Command = 0x5
CmdAdjacentSite Command = 0x6
// ...
)
A voice grant is a CCW whose command is CmdGroupVoiceGrant (or the digital
CmdProVoiceGrant); AsGroupVoiceGrant lifts the talkgroup out of Address and
resolves the LCN (Logical Channel Number) through the operator’s band plan to a
frequency. The encrypted and emergency flags are the low two bits of the Status
nibble. The BCH decode is an opt-in (SetBCHMode, wired to an
edacs_bch_mode YAML key) because per the canonical open reference it’s the
only on-wire FEC — an earlier package comment claiming an interleaved
Reed-Solomon layer above the BCH was simply wrong, and the code says so. Honesty
about what’s real matters more than a tidy-sounding claim; the same instinct runs
through the whole codebase.
What’s not wired is honest too: EDACS ProVoice / Aegis digital voice uses a
proprietary AMBE-derived vocoder GopherTrunk doesn’t decode, so a CmdProVoiceGrant
gets you the grant metadata but not (yet) the audio.
LTR: trunking with no control channel
LTR (Logic Trunked Radio, E.F. Johnson, 1970s) is the outlier that makes the whole “control channel” abstraction wobble, because it doesn’t have one. Instead, every repeater in the system continuously transmits its own 41-bit status word at 300 bps, riding under the in-band voice. There is no central coordinator; a scanner follows a call by watching every repeater’s status word and tuning to whichever one currently announces the talkgroup of interest.
// internal/radio/ltr/status.go (shape)
type Status struct {
Sync bool
Area uint8 // 5-bit — disambiguates co-channel LTR systems
Group bool // the "F-bit": 1 = active call for GroupID on this repeater
Channel uint8 // 4-bit physical channel (1..20)
Home uint8 // 5-bit home-repeater number for the active group
GroupID uint16 // 8-bit talkgroup (1..250)
Free uint8 // 5-bit free-repeater hint (for handoff)
FCS uint16 // 12-bit frame check
}
func (s Status) IsActive() bool { return s.Group && s.GroupID != 0 }
The IsActive test — F-bit set and a non-zero group — is the entire “is this a
grant?” logic. When it’s true, the per-repeater state machine republishes the
status as events.KindGrant; the first well-formed status from a repeater also
fires a one-shot cc.locked so the hunter can confirm it’s tuned to a real LTR
site. Because noise can occasionally pass a bare sync test, IsWellFormed gates on
the fixed-range fields (channel 1..20, home 1..20) before the state machine trusts
a word — a small but important guard when your “control channel” is a 41-bit
sub-audible word with a 12-bit check.
How that principle shaped the Go code
LTR forces the same abstraction every other protocol uses even though it violates
the abstraction’s premise. The engine only understands “a grant arrived on some
frequency for some talkgroup.” LTR has no grant message — it has a state
(this repeater is active for this group right now). The decoder’s job is to
translate that state into the engine’s event vocabulary, and it does so without
the engine ever learning that LTR is special. That’s the payoff of the
publish-a-neutral-Grant contract from
Part 1:
a protocol with a completely alien topology bolts on as one more control.go, and
the Trunking Engine is none
the wiser.
MPT-1327: FFSK codewords
MPT-1327 (the 1988 UK Code of Practice, still running taxi, transport and utility fleets across Europe, Australia and beyond) sits between the two. It has a proper dedicated control channel like EDACS, but the physical layer is 1200-baud FFSK — audio-frequency-shift keying with 1200/1800 Hz mark and space, the same tone family as classic POCSAG. It carries 64-bit codewords back-to-back: 38 information bits plus a 26-bit BCH(63,38)-derived check folded into the unit.
The interesting decode detail is that the message type lives in the upper 4 bits of the 17-bit Function field, the spec’s “Address Categorisation” subfield:
// internal/radio/mpt1327/opcodes.go (shape)
const (
KindAloha CodewordKind = 0x1 // ALH — control-channel idle
KindAhoy CodewordKind = 0x2 // AHY — paging / inquiry
KindAhoyChan CodewordKind = 0x3 // AHYC — broadcast / system info
KindGoToChan CodewordKind = 0x4 // GTC — voice grant ("go to channel")
)
func (c Codeword) Kind() CodewordKind { return CodewordKind((c.Function >> 13) & 0xF) }
The grant is a GTC (“Go To Channel”): AsGoToChannel reads the assigned
channel number out of the lower 13 bits of Function and the called party from
Prefix + Ident. The state machine locks on the first valid Aloha (the idle
“I am a control channel” beacon) or AHYC broadcast, then republishes GTCs as
events.KindGrant with Protocol="mpt1327". As with LTR, the demodulator (the
1200-baud FFSK front-end) and the BCH(63,38) correction are honest deferrals — the
codeword parser assumes clean, error-corrected bits arrive from upstream.
The common denominator
For all their differences at the wire, the three legacy decoders converge exactly
where the modern ones do: each emits a trunking.Grant on events.KindGrant, and
each control.go follows the shared idiom — lock on the first credible frame, keep
a band-plan Resolver to turn a channel/LCN into Hz, and gate on a
strict-validation flag so a noisy word doesn’t spawn a phantom call. The engine
that records the resulting call has no idea whether the grant came from a P25 TSBK,
a TETRA D-CONNECT, an EDACS CCW, an LTR F-bit, or an MPT-1327 GTC. That is the
entire point of decoupling the decoder from the engine, and it’s why adding a
40-year-old protocol is a decoder-package problem, never an engine problem.
Where this goes next
Part 9 leaves trunking behind entirely for the non-trunked world: conventional (fixed-frequency) decode, wideband Phase 2, DMR LCN mapping, and the symbol-scope diagnostic that lets you see a modulation before you trust a decode. For the standards themselves, the EDACS, LTR and MPT-1327 reference pages go deeper on each air interface.
FAQ
Why does LTR not have a control channel? Because it’s distributed trunking — an intentionally decentralised 1970s design where every repeater advertises its own state. Each transmits a 41-bit status word under the voice at 300 bps, and any radio (or scanner) reconstructs the system’s state by listening to all of them. There is no central coordinator to point a “control channel” at.
What FEC does EDACS use on the control channel?
A shortened BCH(40,28,2) per 40-bit Control Channel Word — and, per the canonical
open reference, that is the only on-wire FEC layer. GopherTrunk decodes it as an
opt-in (edacs_bch_mode); there is no additional Reed-Solomon stage above it,
despite older documentation to the contrary.
What is an MPT-1327 GTC?
“Go To Channel” — the voice grant. It’s an address codeword whose Address
Categorisation subfield is KindGoToChan, carrying the assigned channel number in
the low 13 bits of the Function field. AsGoToChannel decodes it and the state
machine republishes it as a trunking.Grant.
Do these legacy protocols decode voice in GopherTrunk? The trunking follow-along (control-channel decode, grants, retune) works; the voice side varies. Analog FM voice records fine, but EDACS ProVoice / Aegis digital voice uses a proprietary AMBE-derived vocoder that isn’t decoded — an honest deferral noted right in the package docs.
Series navigation
Part 8 of 12 · ← Part 7: TETRA · Next → Part 9: Conventional, Wideband & the Symbol Scope