MDC1200 — Motorola Selective Calling
Implementation:
app_utils/mdc1200.py· Tests:tests/test_mdc1200.py· Verification: a dedicated MDC1200 decoder such asmdc-decoder(mainline multimon-ng has no MDC demodulator)
MDC1200 is Motorola's 1200-baud FFSK signalling protocol for two-way LMR (Land Mobile Radio) systems. It carries a small amount of digital data — typically a 16-bit unit ID and an op-code/argument pair — over the same voice audio channel a radio normally uses for speech, so a receiving subscriber radio can identify who is transmitting and what event is being signalled (PTT-ID, emergency alarm, request-to-talk, status update, etc.).
EAS Station™ emits MDC1200 as one of the configurable pre/post-alert signal profiles. The packet sits outside the SAME signalling — it never overlaps the SAME header, attention tone, narration, or EOM — so it is invisible to EAS-aware decoders while being fully decodable by subscriber radios on a shared LMR channel.
1. Use case: feeding EAS into an LMR system
The driving design goal of MDC1200 support in EAS Station™ is seamless forwarding of EAS audio into a two-way radio dispatch system. With MDC1200 signals enabled and the audio output wired into a base-station radio's auxiliary input (with PTT keying handled by an external GPIO / VOX / COR loop), the on-air sequence becomes:
Receiving Motorola (or compatible) subscribers will:
- Display the calling unit ID on screen (typically as 4 hex digits).
- Selectively unmute for that ID if selective-call is enabled, so EAS Station's transmissions only break squelch on radios authorised to hear them.
- Log the call in the radio's call list / dispatch console.
- Trigger user-configured tones / lights / vibrate tied to the unit ID.
- Cleanly close the call on the post-ID, so the radio's display doesn't latch indefinitely.
Typical fleets where this is useful: county EM dispatch repeaters, volunteer fire / EMS / Skywarn / RACES nets, school and utility radio systems, industrial site-wide LMR. Anywhere there is already an MDC1200-capable fleet, EAS Station™ can plug in as just another (clearly identified) talkgroup member.
Out of scope (and intentionally so): EAS Station™ produces audio. PTT keying — the act of putting the radio into transmit — is handled by the radio interface (GPIO, COR, VOX, VOIP gateway, etc.) and is not part of the MDC1200 implementation. Configure your radio interface to key on the same trigger that starts EAS Station™ audio playback.
2. Physical layer
| Parameter | Value | Notes |
|---|---|---|
| Modulation | FFSK (Fast FSK / coherent CPFSK) | Phase-continuous, deviation = ±300 Hz around the 1500 Hz centre |
| Symbol rate | 1200 baud exactly | |
| Mark frequency (logical 1) | 1200 Hz | 1 cycle per bit |
| Space frequency (logical 0) | 1800 Hz | 1.5 cycles per bit |
| Bit order on-air | MSB-first per byte | (Note: this differs from SAME, which is LSB-first) |
| Audio channel | Voice band (300–3000 Hz) | Sits in the same audio path as speech, so any narrowband FM voice channel can carry it |
| Required audio bandwidth | ≈ 2.4 kHz | Easily fits in 12.5 kHz / 25 kHz LMR channels |
Phase continuity at mark-to-space transitions is essential — receivers FFSK
demodulate by integrating the phase, so any glitch corrupts the next several
bits. EAS Station™ re-uses the same eas_fsk.generate_fsk_samples renderer
the SAME modem uses, which carries phase across the symbol boundary.
The renderer is parameterised on the audio sample rate (defaults to the EAS Station™-wide setting, typically 16 kHz or 22.05 kHz). At 16 kHz the on-air durations are:
| Frame | Bytes | Bits | Audio @ 1200 baud | Samples @ 16 kHz |
|---|---|---|---|---|
| Single packet (PTT-ID, Emergency, …) | 26 | 208 | 173.33 ms | ~2773 |
| Double packet (Call Alert, Selective Call) | 40 | 320 | 266.67 ms | ~4267 |
3. Frame format
EAS Station™ emits two MDC1200 frame variants depending on the op-code:
- Single packet — 26 bytes (PTT-ID, Emergency, Request to Talk, Remote Monitor, Custom): one 14-byte interleaved payload bracketed by the standard preamble + sync prefix and a 4-byte trailing post-preamble that gives receivers a clean idle transition after the CRC passes.
- Double packet — 40 bytes (Call Alert, Selective Call, and any
other op-code/arg pair listed in :data:
MDC1200_DOUBLE_PACKET_OPS): a second 14-byte interleaved payload is appended after a 4-byte inter-packet preamble that re-syncs the receiver between blocks. The second payload carries a target unit ID (the radio that should be paged or unmuted) while the first payload still carries the transmitting station's source ID and the op-code.
3.1 Single packet layout
3.2 Double packet layout
After differential ("XOR") modulation is applied across the entire frame buffer (26 bytes for single, 40 bytes for double) in one pass, the on-air bytes are different from the values shown above, but the logical content the receiver recovers is exactly this layout. The 4-byte inter-packet preamble and the trailing post-preamble both decode to a continuous mark-tone segment, giving receivers a re-sync window between blocks (double) or a clean tail-off (single) — the differential modulator passes a steady mark whenever the input bit doesn't change, and four 0x00 bytes contain no transitions.
3.3 Preamble — 3 bytes of 0x00
After differential modulation, an all-zero input produces a steady stream of mark tones (because no transitions occur, and the encoder inverts the "transitioned?" bit so "no change" → 1 → mark). This gives the receiver's PLL ~20 ms to lock to the 1200 Hz tone before the sync word arrives.
3.4 Frame sync — 5 bytes / 40 bits
The canonical Motorola pattern, recognised by every MDC1200 decoder:
| Byte | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| Hex | 0x07 |
0x09 |
0x2A |
0x44 |
0x6F |
Concatenated MSB-first:
0000 0111 0000 1001 0010 1010 0100 0100 0110 1111
After differential modulation, the on-air sync becomes
04 8D BF 66 58 (or its bit-inverse FB 72 40 99 A7 on a polarity-flipped
receiver). EAS Station™ does not need to care about the on-air form because
the encoder applies the same modulation as the receiver expects.
A receiver typically declares lock when at least 32 of the 40 sync bits
match (a Hamming-distance threshold of ≤8) — this is what mdc1200.c style
implementations call sync_bit_ok_threshold.
3.5 Payload — 14 bytes
Built in three steps from a 7-byte information block, in this order:
The 7-byte information block lays out as:
| Offset | Field | Width | Description |
|---|---|---|---|
| 0 | opcode |
8 bits | What event is being signalled |
| 1 | arg |
8 bits | Op-code-specific argument |
| 2 | unit_id (high) |
8 bits | High byte of the 16-bit subscriber ID |
| 3 | unit_id (low) |
8 bits | Low byte of the 16-bit subscriber ID |
| 4 | CRC (low byte) |
8 bits | Low byte of CRC-16 over op,arg,idH,idL |
| 5 | CRC (high byte) |
8 bits | High byte (note: CRC is on-the-wire little-endian) |
| 6 | status |
8 bits | 0x00 for PTT-ID variants, 0x76 for STS / MSG |
3.5.1 CRC-16
| Property | Value |
|---|---|
| Polynomial | x¹⁶ + x¹² + x⁵ + 1 (CRC-CCITT, value 0x1021) |
| Implementation form | Reverse polynomial 0x8408, LSB-first feedback |
| Initial value | 0x0000 |
| Final XOR | 0xFFFF |
| Bit order | LSB-first per input byte |
| Coverage | 4 bytes: op, arg, idH, idL (the status byte is not covered) |
def compute_crc(data):
crc = 0
for byte in data:
crc ^= byte & 0xFF
for _ in range(8):
crc = (crc >> 1) ^ 0x8408 if crc & 1 else crc >> 1
return (crc ^ 0xFFFF) & 0xFFFF
Verified vector:
compute_crc([0x01, 0x80, 0x12, 0x34]) == 0x3E2E
On the wire that becomes 2E 3E (low byte first) at offsets 4–5.
3.5.2 K=7 rate-1/2 convolutional FEC
The 7 information bytes are fed bit-by-bit (LSB-first per byte) into an 8-bit shift register that is initialised to zero and carries state across all 7 bytes (it is not reset per byte — this is the most common stumbling block when re-implementing the codec).
For each input bit, one parity bit is generated as:
parity = sr[6] ⊕ sr[5] ⊕ sr[2] ⊕ sr[0]
where sr[n] is bit n of the shift register after the new input bit
has been shifted in at position 0 and the old bit 7 has fallen off.
Equivalently, the generator polynomial is g(x) = 1 + x² + x⁵ + x⁶
(taps at positions {0, 2, 5, 6}).
The 56 generated parity bits are packed back LSB-first per byte into 7 output bytes appended to the information block, giving the 14-byte FEC block.
Verified vector:
info = [0x01, 0x80, 0x12, 0x34, 0x2E, 0x3E, 0x00]
fec = [0x65, 0x80, 0xA8, 0x62, 0xDD, 0x88, 0x08]
The receiver runs an inverse pass that computes the syndrome over a sliding
4-bit window of (parity_calc ⊕ parity_received) and corrects single-bit
errors when ≥ 3 of the last 4 syndrome bits are set, then XORs the syndrome
with 0xA6 to clear the corrected position. In practice this corrects up
to 3–4 corrupted bits per packet.
3.5.3 Bit interleaver — 16 × 7
The 14-byte (112-bit) FEC block is interleaved with a distance-16 mapping that spreads adjacent FEC bits 16 bits apart on the wire — so a contiguous burst error on-air becomes scattered single-bit errors in the de-interleaved stream, exactly the kind the K=7 FEC can repair.
Source-bit walk order (LSB-first per byte) maps to the output bit array as:
output_bit_index k = 0
for i in 0 .. 13:
for bit_num in 0 .. 7:
output[k] = (payload[i] >> bit_num) & 1
k += 16
if k >= 112:
k -= 111 ← wrap, advances the column by 1
This produces the canonical 16-row × 7-column block:
output[ 0..15] ← payload bits ( 0, 16, 32, 48, 64, 80, 96, …)
output[16..31] ← payload bits ( 1, 17, 33, 49, 65, 81, 97, …)
…
output[96..111] ← payload bits (15, 31, 47, 63, 79, 95, 111)
Output bits are then packed MSB-first per byte into the final 14 interleaved bytes that make up the on-air payload.
3.6 Second info block (double packet only)
When the op-code is one of the documented double-packet variants (Call
Alert 0x63 0x85, Selective Call 0x35 0x80, …) and a target unit ID
is configured, a second 7-byte information block is built from the
target ID, run through the same K=7 FEC + 16×7 interleaver pipeline as
packet 1, and appended to the frame after a 4-byte inter-packet
preamble (32 bits of 0x00 → continuous mark tone for receiver re-sync).
| Offset | Field | Width | Description |
|---|---|---|---|
| 0 | target_id (high) |
8 bits | High byte of the 16-bit target unit ID |
| 1 | target_id (low) |
8 bits | Low byte of the 16-bit target unit ID |
| 2 | reserved | 8 bits | 0x00 |
| 3 | reserved | 8 bits | 0x00 |
| 4 | CRC (low byte) |
8 bits | Low byte of CRC-16 over target_id_h, target_id_l, 0x00, 0x00 |
| 5 | CRC (high byte) |
8 bits | High byte (little-endian on the wire, same as packet 1) |
| 6 | status |
8 bits | 0x00 |
The CRC over the second info block uses the same algorithm as
packet 1 (CRC-CCITT reverse polynomial 0x8408, init 0x0000, final
XOR 0xFFFF), independently computed over the 4-byte (target_h, target_l, 0x00, 0x00) prefix. The status byte is not covered, again
matching packet 1.
When mdc1200_target_unit_id is NULL or 0, the encoder falls back
to a single-packet frame whose unit_id field acts as the target —
some receivers tolerate this shortcut, but real Motorola CPS-programmed
subscribers normally expect the double-packet form for Call Alert and
Selective Call.
3.6.1 Differential ("XOR") modulation
After the preamble, sync, payload(s), and post-/inter-packet preamble(s)
have all been assembled into the final pre-modulation buffer (26 bytes
for single, 40 bytes for double), each bit is replaced by a "did the
input bit change?" flag, MSB-first across the entire buffer, with the
previous-bit register initialised to 0. Each completed output byte is
then inverted (^ 0xFF).
prev = 0
for byte in buf:
out = 0
for bit_num in (7, 6, 5, 4, 3, 2, 1, 0):
new_bit = (byte >> bit_num) & 1
if new_bit != prev:
out |= 1 << bit_num
prev = new_bit
output.append(out ^ 0xFF)
The inversion translates "no change" → mark (logical 1), which matches the
on-air convention every MDC1200 receiver expects. This step also makes the
receiver tolerant of audio polarity inversions: a polarity-flipped receiver
sees the bitwise-inverted sync word FB 72 40 99 A7 and trivially
flips its decoded data to recover the original.
4. Op-code reference
The op-code/arg pair determines what action the receiving radio takes.
EAS Station™ ships these symbolic presets (defined in
MDC1200_OP_PRESETS):
| Preset | op |
arg |
Meaning |
|---|---|---|---|
ptt_id_pre |
0x01 |
0x80 |
PTT-ID at the start of a transmission |
ptt_id_post |
0x00 |
0x80 |
PTT-ID at the end of a transmission |
emergency |
0x40 |
0x80 |
Emergency alarm |
request_to_talk |
0x35 |
0x89 |
Request-to-talk paging |
remote_monitor |
0x11 |
0x80 |
Remote-monitor command |
call_alert |
0x63 |
0x85 |
Call Alert — page a specific target unit (unit ID = target) |
selective_call |
0x35 |
0x80 |
Voice Selective Call — unmute a specific target (unit ID = target) |
| custom | any | any | Operator-supplied raw bytes |
Target-vs-source semantics. For
ptt_id_pre,ptt_id_post,emergency,request_to_talk, andremote_monitor, themdc1200_unit_idsetting is the transmitting station's own ID — receiving radios display "who is talking." Forcall_alertandselective_call, the same field is reinterpreted as the target subscriber's ID — the receiving radio that should be paged or unmuted. Setmdc1200_unit_idto the destination radio's programmed ID when using these two presets. Multiple targets require sending the signal repeatedly (or grouping IDs at the receiver via talkgroup programming).
For a complete-but-not-formally-published list of additional op-codes (status messages, GPS, etc.), Matthew Kaufman's mdc-encode-decode and several open-source LMR firmware projects are good cross-references. Custom raw bytes are accepted for advanced operators who need to interoperate with non-standard fleets.
4.1 Smart pre/post pairing
When both the pre and post signals are set to MDC1200 and the op-code
preset is the default ptt_id_pre, EAS Station™ automatically substitutes
ptt_id_post on the post-alert position. This is what
_resolve_mdc1200_op_for_position() in app_utils/eas.py does — the helper
is intentionally narrow: it only rewrites ptt_id_pre → ptt_id_post
on the post side, and only when position == 'post'. All other presets
(including ptt_id_post already chosen, emergency, request_to_talk,
remote_monitor, or custom) pass through unchanged. This preserves the
behaviour of operators who deliberately want the same op-code on both
sides — for example sandwiching a broadcast in two emergency-alarm packets
to wake every subscriber on a quiet channel.
5. EAS Station™ integration
5.1 Settings (eas_settings table)
| Column | Type | Default | Notes |
|---|---|---|---|
mdc1200_unit_id |
INTEGER NOT NULL |
1 |
1..65535 (zero is reserved). The transmitting station's source ID — what receiving radios display as the calling unit. Stored as a decimal integer; the UI and API accept either decimal (1234) or 0x.. hex (0x04D2) on input. |
mdc1200_op_code |
VARCHAR(32) NOT NULL |
'ptt_id_pre' |
One of the preset keys, or 'custom' |
mdc1200_op_code_raw |
SMALLINT NULL |
NULL |
0..255, used only when preset = 'custom'. Accepts decimal or 0x.. hex on input; rendered as 0xNN in the UI. |
mdc1200_arg_raw |
SMALLINT NULL |
NULL |
0..255, used only when preset = 'custom'. Accepts decimal or 0x.. hex on input; rendered as 0xNN in the UI. |
mdc1200_target_unit_id |
INTEGER NULL |
NULL |
1..65535 target ID for the double-packet ops (Call Alert, Selective Call). When set and the op-code is double-packet-eligible, the encoder appends a second 14-byte info block carrying this ID — making the on-air frame ~267 ms instead of ~173 ms. NULL or 0 forces single-packet emission. |
Hex input policy. All three byte/word fields (
mdc1200_unit_id,mdc1200_op_code_raw,mdc1200_arg_raw) accept full hexadecimal notation including theA–Fdigits. The HTML inputs use the pattern(0[xX][0-9A-Fa-f]{1,N}|\d{1,M})and the server parses with Python'sint(value, 0). This matches Motorola CPS conventions — subscriber unit IDs are typically displayed in 4-digit hex on programming sheets and on subscriber-radio displays — and lets operators paste values directly without conversion. Decimal entry remains supported for fleets that prefer it.
These were added by Alembic migration
20260505_add_mdc1200_to_eas_settings.
The auto-heal _PENDING_MIGRATIONS block in
webapp/admin/maintenance.py mirrors
the same DDL so installs that update DB-first also self-heal.
5.2 Configuration plumbing
load_config() (in app_utils/eas.py) reads the four DB columns into the
config dict, allowing env-var overrides matching the existing pattern:
| Env var | Type | Meaning |
|---|---|---|
EAS_MDC1200_UNIT_ID |
int (decimal or 0x.. hex) |
Override source unit ID |
EAS_MDC1200_OP_CODE |
str | Override preset key |
EAS_MDC1200_OP_CODE_RAW |
int (decimal or 0x.. hex) |
Override raw op byte |
EAS_MDC1200_ARG_RAW |
int (decimal or 0x.. hex) |
Override raw arg byte |
EAS_MDC1200_TARGET_UNIT_ID |
int (decimal or 0x.. hex) |
Override target unit ID for double-packet ops; empty/0 → single packet |
_generate_chime() then routes the config into
generate_mdc1200_samples(opcode, arg, unit_id, sample_rate, amplitude, status=0x00, target_unit_id=None), which produces the phase-continuous
PCM samples. When the op-code is double-packet-eligible (Call Alert,
Selective Call) and target_unit_id is provided and non-zero the
encoder dispatches to encode_double_packet(); otherwise the classic
single-packet encode_packet() path is used.
5.3 Audio assembly
The four call sites of _generate_chime (build_files pre, build_files
post, build_manual_components pre, build_manual_components post) wrap
the op-code in _resolve_mdc1200_op_for_position(op_code, position) so
the smart pairing rule (§4.1) is applied uniformly across CAP-forwarded,
OTA-relayed, and manual broadcasts.
5.4 Admin UI
templates/admin.html exposes the settings under Admin → EAS Broadcast
Settings → Pre/Post-Alert Signaling with the dropdown options gated by
data-eas-tone-show="mdc1200" and mdc1200-custom. The custom
sub-fields accept either decimal (0..255) or 0x.. hex notation;
webapp/admin/maintenance.py parses both via int(value, 0).
6. Verification & debugging
Note: mainline multimon-ng does not include an MDC demodulator (
MDCis not a valid-aargument), so it cannot decode these packets. Use a dedicated MDC1200 decoder instead.
A practical choice is mdc-decoder,
which decodes a WAV file directly and prints the unit IDs / op-codes it finds.
Other options that decode MDC1200 include
mdc-encode-decode (the
reference modem this implementation is checked against),
fsync-mdc1200-decode
(JSON output), DSD+, and SDRTrunk.
# Generate an alert with MDC1200 signals enabled in admin, then point a
# dedicated MDC1200 decoder at the rendered WAV, e.g. with mdc-decoder:
mdc-decoder /var/www/eas-station/static/eas_messages/<your-alert>.wav
# You should see the configured unit ID and op-code reported, e.g.
# op 0x01 / arg 0x80 (PTT-ID Pre) for the pre-alert packet
# op 0x00 / arg 0x80 (PTT-ID Post) for the auto-paired post-alert packet
# op 0x63 / arg 0x85 (Call Alert) / op 0x35 / arg 0x80 (Selective Call)
# with their target ID for the double-packet ops
Inside the codebase, tests/test_mdc1200.py exercises every layer:
| Test | What it pins down |
|---|---|
test_compute_crc_known_vector |
CRC-16 implementation matches the published 01 80 12 34 → 0x3E2E vector |
test_apply_fec_known_vector |
K=7 FEC matches 01 80 12 34 2E 3E 00 → 65 80 A8 62 DD 88 08 |
test_interleaver_is_a_bit_permutation |
Interleaver loses no bits |
test_xor_modulate_zero_buffer_emits_steady_mark |
Preamble assumption (no transitions → all-mark) holds |
test_xor_modulate_round_trip |
Differential decode recovers the original |
test_encode_packet_length_and_prefix_structure |
22-byte frame, decoded preamble + sync match canonical values |
test_generate_mdc1200_samples_is_phase_continuous |
No glitch ≥ 2·A·sin(π·f/sr) between any two adjacent samples |
test_generate_mdc1200_samples_contains_both_carriers |
Goertzel power at 1200 Hz and 1800 Hz both ≥ 10× off-band power |
test_smart_pairing_* |
PTT-ID pre→post substitution, only for the documented case |
Run with pytest tests/test_mdc1200.py -q.
7. Limitations
- Both the single packet (26 bytes, ~173 ms) and double packet
(40 bytes, ~267 ms) variants are implemented. Double-packet ops
beyond Call Alert and Selective Call (e.g. GPS coordinates, extended
status messages with 4 extra bytes in the second info block) are
straightforward to add by extending :data:
MDC1200_DOUBLE_PACKET_OPSand supplying additional fields toencode_double_packet(). - EAS Station™ does not implement an MDC1200 receiver. Decoding incoming MDC packets from monitored radios would require a full FFSK demodulator and the inverse of the encoder pipeline (sync hunt, de-interleave, FEC syndrome, CRC verify). Out of scope for the current signaling feature; could be added as a separate audio-source decoder.
- PTT keying is the responsibility of the radio interface (GPIO, COR, VOX); EAS Station™ only produces audio.
8. References
- Algorithmic specification — public algorithm description in the
mdc1200.csource of theuv-k5-firmware-customproject (GPL-3.0). The algorithm itself (taps {0, 2, 5, 6}, 16×7 interleaver, reverse-poly CRC-16, differential modulation) is a 1980s Motorola specification not subject to copyright; EAS Station™'s implementation is clean-room from that algorithmic description with no source code copied. - Receiver verification — a dedicated MDC1200 decoder such as
mdc-decoderormdc-encode-decode. Mainline multimon-ng has no MDC demodulator and cannot decode these packets. - Frame format cross-reference —
kg-toolsmdc-encode.c, thexastirMDC1200 plugin, and several Motorola Service Manual appendices describing CPS programming for MDC-equipped subscribers (XPR, CDM, GM340, …) all describe the same wire format.
This document is served from docs/reference/protocols/MDC1200.md in the EAS Station™ installation.