MDC1200 / Motorola Signaling

GopherTrunk decodes MDC1200 (“Motorola Data Communications”) — the analog in-band data burst Motorola two-way radios key at the start (and optionally the end) of a transmission. It carries the radio’s unit ID (ANI — automatic number identification) plus emergency, status, call-alert, radio-check and selective-call signaling on otherwise-analog conventional VHF / UHF voice channels. On a system that is just FM voice, MDC1200 is what tells you which radio is talking and surfaces emergency / status events.

Added in response to #438.

Modulation

MDC1200 is a 1200-baud MSK burst on the CCIR tone pair 1200 Hz / 1800 Hz, carried inside the narrowband-FM voice channel — the same modulation class GopherTrunk demodulates for MPT 1327 and FleetSync, so the DSP frontend reuses internal/dsp/demod.FFSK.

The line code is XOR precoding, not plain NRZ: the radio compares each data bit with the previous one and sends one cycle of 1200 Hz when the bit is the same and 1.5 cycles of 1800 Hz when it changed (the reference modem describes the format as “XOR-precoded MSK”). The receiver therefore recovers the data stream as the running XOR of its tone decisions. The bit-sync leader (bytes of 0x55) changes on every bit, so on the air it is one continuous 1800 Hz tone up to the sync word. A tone’s frequency does not depend on the FM discriminator’s sense, so the only ambiguity is the running XOR’s start state, which complements the whole stream; the framer accepts the complemented sync word for that.

Until the fix for #1220 the receiver sliced the tone sequence itself as NRZ data, and no real radio’s sync word could ever match — the decoder’s own tests encoded the same wrong line code. The format is now pinned literal-for-literal against the reference encoder (internal/radio/mdc1200/synth_test.go) and the reference encoder’s audio decodes through the production chain (internal/radio/mdc1200/afsk/testdata).

Pipeline

IQ chunks (Fs Hz, complex64)
  → FM demod (internal/dsp/demod.FM)
  → real resampler to 9600 Hz (1200 baud × 8 oversample)
  → FFSK tone discriminator (mark 1200 Hz / space 1800 Hz)
  → Mueller-Müller symbol-timing recovery (8 sps → 1 sample/symbol)
  → zero-threshold tone decision (1200 Hz or 1800 Hz this bit)
  → XOR-precoding decode (1800 Hz = data bit changed) → data bits
  → 40-bit sync framer (internal/radio/mdc1200/receiver)
  → op/arg/unit-ID parse + CRC-16 check (internal/radio/mdc1200)
  → events.KindMDC1200Message on the bus
  → storage.MDC1200Log → mdc1200_log SQLite table
  → GET /api/v1/mdc1200/messages → /mdc1200 web panel

The frame layout, after the 40-bit sync word 0x07 09 2A 44 6F (most-significant bit first), is 112 payload bits column-interleaved over a 16×7 grid. De-interleaved and packed LSB-first, the header bytes are:

Bytes Meaning
data[0] op (operation code)
data[1] arg (operation argument)
data[2:4] unit ID (big-endian)
data[4:6] CRC-16 of data[0:4] (little-endian on the wire)
data[6:] redundancy (over-the-air FEC; not yet exploited)

The CRC is CRC-16/CCITT with reflected in/out, polynomial 0x1021, initial value 0x0000 and final XOR 0xFFFF. The sync hunt tolerates a few bit errors and accepts the bit-complemented sync word so a flipped FM discriminator (inverted tone sense) still decodes.

Operations

The decoder resolves a human label for the common Motorola CPS opcodes (PTT ID / ANI, emergency, status, radio check, call alert / page, selective call, radio inhibit / enable, remote monitor). The op/arg table is best-effort and intentionally non-exhaustive — many vendor-specific and extended opcodes exist; unrecognised pairs surface the raw op/arg so nothing is silently dropped. The unit ID and CRC are always decoded regardless of the label.

Double packets (extended two-block messages, op 0x35 / 0x55) are framed as two consecutive 112-bit blocks; the second block’s header bytes are attached but its vendor-specific payload interpretation is left to a follow-up.

Configuration

Each entry pins one SDR to a conventional analog voice channel:

mdc1200:
  channels:
    - serial: "vhf-antenna"
      frequency_hz: 154_000_000   # the analog voice channel to monitor
      drop_bad_crc: false         # true to drop CRC-failed bursts

Leave drop_bad_crc false to see CRC-failed bursts on the panel (flagged with crc_ok=false and dimmed); flip it on for noisy channels.

On a conventional scanner channel (no dedicated SDR)

A channel the conventional scanner already monitors can run the decoder on its own IQ instead (issue #1220):

scanner:
  conventional:
    - label: "Kenwood fleet"
      frequency_hz: 462_562_500
      mode: nfm
      decoders: [mdc1200]   # and/or fleetsync

The scanner decimates its IQ to one ~48 kHz channel (behind a ±8 kHz channel filter) before the decoder sees it, so the decoder works the same at any SDR sample rate. It runs on every chunk that opens the channel’s squelch and on the whole dwell; a burst whose sync word has been heard holds the channel until it is framed, so the scanner neither hops away nor ends the call on hangtime mid-burst. The CTCSS/DCS gate does not apply: a burst carries its own sync word and check, so it is logged even when the tone gate stays shut. Bursts are stamped with the scanner’s SDR serial and the channel frequency (the panel’s Channel column). A burst sent while the scanner is on another channel is missed — that is the trade against pinning an SDR.

Verification status and replay

Synthetic and reference-encoder verified; not yet confirmed on air. The first on-air run (#1220, a Kenwood lab, 26 Sep) decoded FleetSync through the same DSP chain and nothing from MDC1200 — the line-code defect above. To verify a real radio, record a keyup with a known unit ID (gophertrunk capture IQ, or an SDR# baseband WAV) and replay it through the production front end:

GT_MDC1200_IQ=<capture> GT_MDC1200_RATE=<Hz> GT_MDC1200_UNIT=<hex unit id> \
  go test ./cmd/gophertrunk -run 'TestMDC1200Replay$' -v

The harness content-sniffs wav/flac, takes GT_MDC1200_FORMAT=f32|cs16|audio for headerless files, probes the rate when GT_MDC1200_RATE is unset, and searches a wideband capture for its carriers when GT_MDC1200_TUNE_HZ is unset. It prints a per-burst timeline and a PASS/FAIL verdict on the unit ID.

What’s surfaced

  • Bus event — events.KindMDC1200Message, payload storage.MDC1200Message.
  • Storage — the mdc1200_log SQLite table (op, arg, unit ID, operation, body, raw hex, crc_ok, and the decoding receiver’s SDR serial
    • channel frequency), indexed by time and unit ID.
  • REST — GET /api/v1/mdc1200/messages?limit=N (default 200, max 5000); 503 when the daemon runs without storage.path.
  • Web — the /mdc1200 panel polls every 5 s, tinting emergency bursts red and dimming CRC failures.

Licensing note

The MDC1200 protocol facts implemented here (sync word, interleave geometry, CRC parameters, opcode semantics) are public protocol details. This is a clean-room Go implementation; no third-party decoder source — including the GPL-licensed reference libraries — is incorporated, keeping the decoder under the project’s Apache-2.0 license.