Patrick Lidstone
Self-hosted

TETRA downlink decoder: native π/4-DQPSK receive of non-encrypted signalling

A self-contained, receive-only decoder for the clear signalling of a TETRA (ETSI EN 300 392-2) downlink carrier — burst sync, π/4-DQPSK demod, descramble, RCPC/Viterbi channel decode, RM(30,14) and CRC, up through BSCH/AACH/SYSINFO MAC parsing.

Rafe project · app/radio/tetra/ (dsp.py, burst.py, coding.py, mac.py, constants.py) + app/radio/tetra_mode.py · experimental


Abstract

TETRA (Terrestrial Trunked Radio) is the ETSI digital PMR standard behind most of Europe's public-safety, transport and utility fleets. Its downlink is a π/4-DQPSK carrier at 18 000 symbol/s (36 kbit/s gross) in a 25 kHz channel, organised as a rigid TDMA structure of 510-bit timeslots. This module is a native, receive-only decoder for the non-encrypted signalling a TETRA downlink continuously broadcasts: the base station's network identity (MCC/MNC/colour code) and TDMA time from the Broadcast Synchronization Channel (BSCH), the per-slot access assignment from the Access Assignment Channel (AACH), and the cell's carrier/frequency/service parameters — including the air-interface encryption flag — from the SYSINFO broadcast (BNCH). It is built in the same spirit as the project's native DMR/P25/DAB decoders: pure NumPy at runtime, no external binaries in the RX path, every protocol table generated mechanically from the osmo-tetra reference and re-derivable from first principles. The full receive chain is π/4-DQPSK differential demod → burst correlation → descramble → block de-interleave → RCPC depuncture → K=5 Viterbi → CRC-16 → MAC PDU parse; the AACH takes a parallel RM(30,14) block-code path. Encrypted user planes are detected and skipped — the module reads the SYSINFO air-encryption flag and never attempts decryption, and traffic (voice/data) payload is not decoded. Correctness is pinned two ways: an internal loopback (encode → π/4-DQPSK modulate → AWGN → demodulate → decode, recovering the injected cell identity and carrier through the modem), and bit-exact interop against two tiny C oracles compiled from the osmo-tetra source for the scrambler LFSR and the convolutional encoder.

This document is written to be reproduction-grade and self-contained: every constant — the five training sequences as full bit strings, the 510-bit burst field maps, the dibit→phase table, the four convolutional generators, the RCPC puncture tables and their kept-index derivation, the 14-tap scrambling polynomial with a worked colour-code seed, the entire 14×16 RM(30,14) parity matrix, the interleaver constants, the CRC parameters, and the carrier-frequency reconstruction — is printed here in full or given with a complete regeneration algorithm. You could rebuild the decoder from this page alone.


1. Scope and non-goals

In scope. Downlink only. Clear (unencrypted) signalling only. Specifically: BSCH (network identity + TDMA time → the cell scrambling code), AACH (access assignment / traffic-usage marker), BNCH SYSINFO (carrier, frequency band, duplex, location area, subscriber class, air-encryption flag), and first-level MAC PDU typing of SCH/F and SCH/HD signalling blocks.

Out of scope, by design. No uplink, no MS side, no random-access. No traffic (TCH) decoding — ACELP voice and circuit/packet data are not demodulated; the decoder only flags that a slot carries traffic. No decryption of any kind (TEA1–TEA4); the air-encryption flag is surfaced and encrypted planes are skipped. Full MAC/LLC disassembly, fragmentation reassembly, and the higher layers (MM, CMCE, SNDCP, MLE routing) are not implemented — only PDU-type classification. These boundaries keep the module firmly on the "receive-and-report public broadcast parameters" side of the line.


2. Background

2.1 TETRA in one paragraph

TETRA is a 4-slot-per-carrier TDMA system in 25 kHz channels. A base station transmits a continuous downlink; mobiles transmit in assigned uplink slots. The physical layer is π/4-DQPSK at 18 000 symbol/s — 2 bits/symbol, so 36 kbit/s gross — pulse-shaped by a root-raised-cosine filter with roll-off α = 0.35. Time is quantised into 510-bit timeslots (255 symbols each); 4 slots = 1 TDMA frame, 18 frames = 1 multiframe (frame 18 is the control frame), 60 multiframes = 1 hyperframe. At 18 ksym/s that is:

unit duration
timeslot (255 sym / 510 bits) 14.167 ms
frame (4 slots) 56.667 ms
multiframe (18 frames) 1.020 s
hyperframe (60 multiframes) 61.2 s

2.2 Why π/4-DQPSK

π/4-DQPSK carries the dibit in a phase increment rather than an absolute phase. Successive symbols alternate between two QPSK constellations offset by π/4, so the four legal increments are the odd multiples of π/4 (±π/4, ±3π/4). Two consequences matter to a decoder: the signal never passes through the origin (bounded envelope, easier on amplifiers), and — the property this module leans on — it can be demodulated differentially, from the phase step between adjacent symbols, with no absolute carrier-phase recovery.

2.3 What the standard provides, and what osmo-tetra copied

Everything numeric here is a fact of the published standard EN 300 392-2: the training sequences (clause 9.4.4.3), the channel-coding chain (8.2.3), the RCPC puncturing (8.2.3.1.2), the RM(30,14) block code (8.2.3.2), the block interleaving (8.2.4.1), and the scrambling (8.2.5). The osmo-tetra reference (Harald Welte, Sylvain Munaut et al.) transcribes those tables into C; this project's scripts/gen_tetra_constants.py parses them back out mechanically (§7.9). No osmo-tetra source is copied, ported, or linked — only the numeric tables (protocol facts) are lifted, exactly as the WSJT-X-family and M17 modes treat their reference constants.


3. Burst, timeslot and frame structure

(burst.py, constants.py)

The on-air unit is the 510-bit timeslot. Two downlink burst types are decoded:

  • Synchronization continuous downlink burst (SB) — carries the synchronization block SB1 (BSCH), a broadcast block (AACH), and a second block SB2 (BNCH/SYSINFO). Identified by the 38-bit synchronization training sequence at slot offset 214.
  • Normal continuous downlink burst (NDB) — carries two 216-bit blocks plus a broadcast block (AACH) split around the 22-bit normal training sequence at slot offset 244. Two flavours: NORM1 uses normal training sequence 1 (the two blocks form one full SCH/F channel), NORM2 uses normal training sequence 2 (the two blocks are independent SCH/HD half-slots).

3.1 The five training sequences (full bit strings)

These are the on-air correlation words. Left-to-right = transmission order. (constants.py: TRAIN_*; parsed from osmo-tetra phy/tetra_burst.c as n_bits/p_bits/q_bits/x_bits/y_bits.)

name role len bits (MSB = first on air)
TRAIN_NORM1 (n) normal burst, 1 logical channel (NORM1) 22 1101000011101001110100
TRAIN_NORM2 (p) normal burst, 2 logical channels (NORM2) 22 0111101001000011011110
TRAIN_NORM3 (q) burst tail / phase-adjust bits 22 1011011100000110101101
TRAIN_EXT (x) extended training sequence 30 100111010000111010011101000011
TRAIN_SYNC (y) synchronization burst training 38 11000001100111001110100111000001100111

TRAIN_NORM3 is not a mid-burst correlation word: the burst builder splits it to fill the head and tail phase-adjust fields (its bits 10..21 go at the burst start, bits 0..9 at the burst end). TRAIN_EXT is carried for completeness (it is not used in the current downlink RX path).

3.2 Sync burst (SB) field map — 510 bits, 0-indexed

bit range length field contents
0 – 11 12 head / phase adjust TRAIN_NORM3[10:22]
12 – 13 2 (guard/phase)
14 – 93 80 frequency correction all-ones (TX); unused by RX
94 – 213 120 SB1 = BSCH (type-5) scrambled with fixed code 3
214 – 251 38 synchronization training TRAIN_SYNC
252 – 281 30 AACH broadcast block (type-5) RM(30,14), cell-scrambled
282 – 497 216 SB2 = BNCH/SYSINFO (type-5) cell-scrambled
498 – 499 2 (guard)
500 – 509 10 tail TRAIN_NORM3[0:10]

Constants: SB_BLK1_OFFSET,SB_BLK1_BITS = 94,120; SB_BBK_OFFSET,SB_BBK_BITS = 252,30; SB_BLK2_OFFSET,SB_BLK2_BITS = 282,216; SYNC_TRAIN_OFFSET = 214.

3.3 Normal burst (NDB) field map — 510 bits, 0-indexed

bit range length field contents
0 – 11 12 head / phase adjust TRAIN_NORM3[10:22]
12 – 13 2 (guard/phase)
14 – 229 216 block 1 (type-5) cell-scrambled
230 – 243 14 AACH part 1 (BBK1) first 14 of the 30-bit AACH
244 – 265 22 normal training TRAIN_NORM1 (NORM1) or TRAIN_NORM2 (NORM2)
266 – 281 16 AACH part 2 (BBK2) last 16 of the 30-bit AACH
282 – 497 216 block 2 (type-5) cell-scrambled
498 – 499 2 (guard)
500 – 509 10 tail TRAIN_NORM3[0:10]

Constants: NDB_BLK1_OFFSET = 14, NDB_BLK2_OFFSET = 282, NDB_BLK_BITS = 216; NDB_BBK1_OFFSET,NDB_BBK1_BITS = 230,14; NDB_BBK2_OFFSET,NDB_BBK2_BITS = 266,16; NORM_TRAIN_OFFSET = 244. The AACH is reassembled as concat(bits[230:244], bits[266:282]) = 30 bits.

For NORM1 the two 216-bit blocks are concatenated into one 432-bit SCH/F channel; for NORM2 they are decoded independently as two 216-bit SCH/HD (NDB) blocks.

3.4 Burst synchronization and classification

There is no separate preamble search: the decoder operates on an already-demodulated bit stream (§4) and finds slots by correlating the known training sequences at their fixed offsets.

  • find_sync(bits, threshold=0.9) — slides a 510-bit window; declares a slot start where TRAIN_SYNC (38 bits) agrees with bits[frame+214 : frame+252] in ≥ 90 % of positions (Hamming agreement fraction).
  • classify_burst(bits, frame) — at a candidate slot: TRAIN_SYNC match at offset 214 ≥ 0.85 → SYNC; else the better of TRAIN_NORM1/TRAIN_NORM2 at offset 244 ≥ 0.85 → NORM1/NORM2; else None (skip the slot).
  • extract_blocks(bits, frame, kind) — slices the type-5 blocks per the maps above: SYNC → {SB1, AACH, SB2}, NORM1 → {AACH, SCH_F(432)}, NORM2 → {AACH, NDB1(216), NDB2(216)}.

The Receiver (§6.5) then walks the stream slot-by-slot, advancing by exactly BITS_PER_TS = 510 after each burst.


4. Modulation — π/4-DQPSK

(dsp.py)

4.1 Dibit → phase-increment map

Each symbol advances the running phase by one of four increments selected by the dibit (b0, b1). The receiver recovers the dibit from the quantised phase difference between adjacent symbols. Internally the increment is tracked as an integer "symbol" in units of π/4.

dibit (b0,b1) phase increment symbol (×π/4)
(0, 0) +π/4 +1
(0, 1) +3π/4 +3
(1, 0) −π/4 −1
(1, 1) −3π/4 −3

_DIBIT_TO_PHASE = {(0,0):1, (0,1):3, (1,0):-1, (1,1):-3} and its inverse _PHASE_TO_DIBIT. Only the four odd multiples of π/4 are legal — the signature of π/4-DQPSK.

4.2 Transmit (modulate)

  1. Group the on-air bits into dibits; accumulate phase φ[k] = φ[k-1] + inc·π/4 (differential encoding), symbol s[k] = exp(jφ[k]).
  2. Upsample by sps = 8 (zero-stuff) and convolve with the RRC pulse. SAMPLE_RATE = 18 000 × 8 = 144 kHz complex baseband.

4.3 RRC pulse (rrc_taps)

Root-raised-cosine, roll-off ROLLOFF = 0.35, span ±8 symbols → 2·8·8+1 = 129 taps at sps = 8, normalised to unit energy. For t = n/sps:

h(0)          = 1 − α + 4α/π
h(±1/4α)      = (α/√2)·[ (1+2/π)·sin(π/4α) + (1−2/π)·cos(π/4α) ]
h(t) else     = [ sin(πt(1−α)) + 4αt·cos(πt(1+α)) ] / [ πt·(1 − (4αt)²) ]
h ← h / ‖h‖₂

4.4 Receive (demodulate)

  1. Matched filter: convolve IQ with the same RRC taps.
  2. Symbol timing — polyphase energy pick: for each of the sps sub-phases p, compute mean |z[p::sps]|²; take p0 = argmax. Downsample at p0. This is a single, global timing choice (no fractional interpolation, no per-burst tracking).
  3. AGC: divide by RMS.
  4. Differential demod (_diff_demod): d[k] = s[k]·conj(s[k-1]), dφ = angle(d), quantise (_quantize_phase_diff) to the nearest legal {±1, ±3}×π/4, map back to the dibit. The first symbol is consumed as the differential reference and produces no output bit — loopback tests compare from bits[2:].

_quantize_phase_diff rounds dφ/(π/4) to an integer, wraps to (−4, 4], and snaps illegal even results to the nearest odd (a ±2 result becomes ±3 or ±1 according to sign/magnitude). soft_symbols exposes the raw differential phases for diagnostics/timing.

4.5 Done vs deferred

Done: π/4-DQPSK differential encode/decode, RRC pulse shaping and matched filtering, energy-locked polyphase symbol timing, phase quantisation, RMS AGC. Because demod is differential, no carrier-phase recovery is needed and small frequency offsets are tolerated.

Deferred / simplified: the demodulator emits hard bits — there is no true soft-decision hand-off to the Viterbi (the channel decoder re-injects ±1 soft values from the hard bits, §5.4). Timing is one global polyphase index, not tracked per burst; there is no fractional-sample interpolator and no explicit carrier-frequency-offset estimator/corrector; the sync burst's 80-bit frequency correction field is filled on TX but not used for AFC on RX. These are fine for simulation and a clean SDR capture but a fielded receiver would want proper timing/frequency recovery and burst alignment (§9).


5. Channel coding

(coding.py) — EN 300 392-2 clause 8.2. Each logical channel runs a "type" chain. Encode direction (TX/loopback), for a payload of t1 bits:

type-1  payload (t1)                                          -- MAC PDU
  → CRC-16 append (16) + zero tail (4)         → type-2 (t2 = t1+20)
  → K=5 rate-1/4 mother conv encode (×4)       → mother (t2·4)
  → RCPC puncture (rate 2/3)                    → type-3 (t3)
  → block interleave  out[(a·i) mod K]=in[i]    → type-4 (t3)
  → scramble  XOR LFSR(seed)                    → type-5 (on air)

Decode reverses it: descramble → deinterleave → depuncture → Viterbi → CRC. The AACH bypasses the convolutional chain entirely: RM(30,14) block code, no CRC (§5.6).

5.1 Per-channel block-size chain

CHANNELS[name] = (t1, t2, t3, interleave_a, has_crc). Note t2 = t1 + 20 (16 CRC + 4 tail), mother = t2·4, and t3 = mother·3/8 for every convolutionally-coded channel (rate-2/3 puncture, §5.3).

channel t1 t2 mother t3 a CRC role
BSCH 60 80 320 120 11 yes sync block SB1 (fixed scramble 3)
SB2 124 144 576 216 101 yes sync block 2 (BNCH/SYSINFO)
BNCH 124 144 576 216 101 yes broadcast network channel
NDB 124 144 576 216 101 yes normal block (SCH/HD payload)
SCH_HD 124 144 576 216 101 yes signalling half-slot down
SCH_F 268 288 1152 432 103 yes signalling full slot (NORM1)
SCH_HU 92 112 448 168 13 yes signalling half-slot up
AACH 14 30 30 0 no access assignment, RM(30,14)

5.2 The mother convolutional code (K=5, rate 1/4)

Constraint length K = 5 (4 memory bits), rate 1/4 — four output bits per input bit. Generators (CONV_POLYS), each printed as a polynomial in the delay operator D, a binary tap word D⁴D³D²D¹D⁰, and octal:

gen polynomial binary octal
G1 1 + D + D⁴ 10011 023
G2 1 + D² + D³ + D⁴ 11101 035
G3 1 + D + D² + D⁴ 10111 027
G4 1 + D + D³ + D⁴ 11011 033

The encoder (_encoder_step) keeps the four most-recent past inputs d0,d1,d2,d3 (d0 = newest) and emits, for input bit:

g1 = (bit ⊕ d0 ⊕ d3)
g2 = (bit ⊕ d1 ⊕ d2 ⊕ d3)
g3 = (bit ⊕ d0 ⊕ d1 ⊕ d3)
g4 = (bit ⊕ d0 ⊕ d2 ⊕ d3)
output order: (g1, g2, g3, g4)
state = d0 | d1<<1 | d2<<2 | d3<<3   ;   next_state = bit | d0<<1 | d1<<2 | d2<<3

Encoding starts at state 0; the type-2 stream is zero-tailed (its last 4 bits are 0) so the trellis also terminates at state 0. This tap set is exactly the osmo-tetra conv_oracle and is validated bit-exact against it (§8).

5.3 RCPC puncturing — tables and kept-index derivation

The mother code (rate 1/4) is punctured up to rate 2/3 for on-air transmission. The puncture pattern has period 8 mother bits (PUNCT_PERIOD = 8, which is 2 input bits' worth of mother output). Two rates are defined; the RX/TX path uses only rate 2/3 (rate 1/3 is tabulated for completeness but unused here).

PUNCT_P_2_3 = (0, 1, 2, 5)         PUNCT_T_2_3 = 3     PUNCT_PERIOD = 8
PUNCT_P_1_3 = (0, 1, 2, 3, 5, 6, 7) PUNCT_T_1_3 = 6

Puncture (mother → type-3), the exact algorithm — for each 1-based type-3 output position j = 1 … t3:

blk = (j − 1) // t                 # which period we are in
k   = PUNCT_PERIOD·blk + P[ j − t·blk ]   # 1-based mother index kept
type3[j−1] = mother[k−1]

with (P, t) = (PUNCT_P_2_3, 3) for rate 2/3. Because j − t·blk ∈ {1,…,t}, only P[1..t] are indexed (the leading P[0]=0 is never used). Depuncture (RX) inverts it: it lays the t3 received values back at their mother positions k as ±1 soft metrics (1→+1, 0→−1) and leaves all punctured positions at 0.0 (erasures).

Kept-index sets, derived (this is the reproducible core):

  • Rate 2/3 — within each period of 8 mother bits, keep the mother positions {1, 2, 5} (1-based); drop {3, 4, 6, 7, 8}. Over the whole block the kept mother indices are {1,2,5} + 8·blk1,2,5, 9,10,13, 17,18,21, …. Keeping 3 of every 8 mother bits = 3 output per 2 input = rate 2/3; and mother · 3/8 = t3 reproduces every t3 in §5.1.
  • Rate 1/3 — within each period of 8, keep {1, 2, 3, 5, 6, 7}; drop {4, 8}. Kept indices 1,2,3,5,6,7, 9,10,11,13,14,15, …. Keeping 6 of 8 = 6 output per 2 input = rate 1/3.

5.4 Viterbi decoder

viterbi_decode(soft, out_len) runs a 16-state add-compare-select over the trellis built from _encoder_step. Per step it consumes the 4 soft metrics m and, for each state/input, forms the branch metric dot(2·OUT−1, m) (a correlation the decoder maximises; OUT is the branch's (g1..g4)). Path metrics start pm[0]=0, all others −∞; it stores per-step back-pointers (chosen input bit + source state) and, because the encoder zero-tails, traces back deterministically from state 0. Output is truncated to out_len (= t2), from which the CRC check reads t2[:t1+16].

In the RX path the "soft" input is really the ±1/erasure mapping from the hard-decision demodulator (§4.5); the decoder is soft-capable but is fed hard decisions.

5.5 Block interleaving

A single-permutation block interleaver, per channel a constant a (clause 8.2.4.1):

interleave  (type-3 → type-4):  k = 1 + ((a·i) mod K),  out[k−1] = in[i−1]
deinterleave(type-4 → type-3):  k = 1 + ((a·i) mod K),  out[i−1] = in[k−1]

for i = 1 … K, K = block length (= t3). The map is a valid permutation because gcd(a, K) = 1 for every channel:

channel K (=t3) a gcd
BSCH 120 11 1
SB2 / BNCH / NDB / SCH_HD 216 101 1
SCH_F 432 103 1
SCH_HU 168 13 1
AACH 30 0 (no interleave)

5.6 RM(30,14) block code for AACH

The AACH (30 bits on air, 14 data bits) uses a shortened Reed–Muller (30,14) systematic code (clause 8.2.3.2): codeword = [ data(14) | parity(16) ]. The generator is G = [ I₁₄ | P ]; rm3014_encode(data) = XOR of the generator rows selected by the set data bits (_RM_ROWS[i] = e_i ‖ P_row_i).

The complete 14×16 parity matrix P (RM_30_14_PARITY; parsed from osmo-tetra lower_mac/tetra_rm3014.c rm_30_14_gen[14][16]). Row i is the 16-bit parity contributed by data bit i; the identity part is added in code:

data bit  parity row (16 bits, MSB = parity position 0)
   0      1001101101100000
   1      0010110111100000
   2      1111110000100000
   3      1110000000111100
   4      1001100000111010
   5      0101010000110110
   6      0010110000101110
   7      1111111111011111
   8      1000001100111001
   9      0100001010110101
  10      0010000110101101
  11      0001001001110011
  12      0000100101101011
  13      0000010011100111

Decode (rm3014_decode, nearest-codeword): fast path — re-encode the received 14-bit systematic prefix; if it reproduces the 30-bit word, zero errors. Otherwise brute-force the prefix over 0-, 1- and 2-bit flips (14 + 91 trials), re-encode each, and keep the minimum-Hamming-distance codeword. This reliably corrects the small burst errors the AACH sees; it carries no CRC, so the block code is the only protection. (Worked check: a codeword with two flipped bits decodes back to the original data at reported distance 2 — this is the test_rm3014_correction case.)

5.7 Scrambling — LFSR, taps, and colour-code seed

Every channel except the descrambling-agnostic identity is XOR-masked (clause 8.2.5) with a 32-bit Fibonacci LFSR sequence. Scrambling and descrambling are the same XOR.

Tap set (SCRAMB_TAPS, 14 taps, MSB-numbered): 32, 26, 23, 22, 16, 12, 11, 10, 8, 7, 5, 4, 2, 1. Equivalently the generator polynomial

p(x) = 1 + x + x² + x⁴ + x⁵ + x⁷ + x⁸ + x¹⁰ + x¹¹ + x¹² + x¹⁶ + x²² + x²³ + x²⁶ + x³²

LFSR recipe — the taps map to a bit-mask TAP_MASK by 1 << (32 − t) for each tap t; over the 14 taps this is TAP_MASK = 0xDB710641. Each step:

bit  = parity( lfsr & TAP_MASK )        # XOR of all tapped bits, mod 2
lfsr = (lfsr >> 1) | (bit << 31)        # shift right, feed parity into the MSB
emit bit

i.e. the emitted scrambling bit is the feedback bit each step. (This is the osmo-tetra next_lfsr_bit with ST(x,y)=x>>(32−y), validated bit-exact, §8.)

Colour-code seed packing. scramb_init(mcc, mnc, colour):

v    = (colour & 0x3F) | ((mnc & 0x3FFF) << 6) | ((mcc & 0x3FF) << 20)
seed = ((v << 2) | 3) & 0xFFFFFFFF

The final 32-bit seed lays out MSB→LSB as [ mcc:10 | mnc:14 | colour:6 | 11 ] — the <<2 shifts everything up two places and the low 0b11 (=3) is a fixed suffix. Field positions in the seed:

bits field width
31 – 22 MCC 10
21 – 8 MNC 14
7 – 2 colour code 6
1 – 0 constant 11 (=3) 2

Worked example — MCC = 234, MNC = 20, colour = 5 (the value used throughout the tests):

v    = 5 | (20<<6) | (234<<20)
     = 5 | 1280 | 245366784   = 245368069   = 0x0EA00505
seed = (245368069 << 2) | 3   = 981472279   = 0x3A801417
       = 0011101010 00000000010100 000101 11
         └─234────┘ └────20──────┘ └─5──┘ └3┘

BSCH uses the fixed seed SCRAMB_INIT = 3 (= MCC/MNC/colour all zero) because the receiver cannot yet know the cell's identity — decoding BSCH is precisely how it learns MCC/MNC/colour. Once the SYNC PDU is decoded, scramb_init yields the cell seed used for AACH, SB2/BNCH, SCH/F and NDB. Reproducibility check-values: the fixed-3 sequence begins 1011111111110100… and the (234,20,5) cell sequence begins 1111001000100010….

5.8 CRC-16

CRC-16-CCITT in the X.25/HDLC "FCS" convention (_crc16, crc16_fcs, crc16_ok):

parameter value
polynomial CRC16_POLY 0x1021
init CRC16_INIT 0xFFFF
bit order MSB-first (no input/output reflection)
FCS transmitted ones-complement of the register
good-block residue CRC16_OK 0x1D0F

crc16_fcs(payload) returns 16 bits = ~_crc16(payload) (MSB-first). A received block is intact iff _crc16(payload ‖ fcs) == 0x1D0F — the fixed residue of this non-reflected, complemented CRC. _crc16 per bit: crc ^= bit<<15; if the top bit is set, crc = ((crc<<1) ^ 0x1021) & 0xFFFF, else crc = (crc<<1) & 0xFFFF.


6. MAC — PDU parsing and logical channels

(mac.py) — bit fields are big-endian within each PDU (bits_to_uint reads MSB-first). Offsets below are 0-based within the type-1 payload.

6.1 BSCH SYNC PDU (60 bits)

Carries the cell's network identity and TDMA time — the seed for everything else. parse_sync_pdu:

field offset width meaning
colour 4 6 colour code (→ scramble seed)
tn 10 2 timeslot number
fn 12 5 frame number (1–18)
mn 17 6 multiframe number (1–60)
mcc 31 10 mobile country code
mnc 41 14 mobile network code

(Bits 0–3, 23–30 and 55–59 are other SYNC/MLE fields — system code, sharing mode, etc. — not parsed here.) On decode, Receiver immediately computes scramb = scramb_init(mcc, mnc, colour) and records {tn, fn, mn}.

6.2 AACH access-assignment (14 bits)

parse_aach:

field offset width
header 0 2
field1 2 6
field2 8 6

Header string map _ACCESS_HDR = {0:"DLCC/ULCO", 1:"DLF1/ULCA", 2:"DLF1/ULAO", 3:"DLF1/ULF1"}. traffic = (field1 > 3) — a downlink-usage marker above 3 means an assigned traffic channel is active this slot (the decoder flags it but does not decode the traffic).

6.3 BNCH SYSINFO PDU (124 bits)

parse_sysinfo first gates on the MAC-broadcast type: bits[0:2] == 2 (BROADCAST) and bits[2:4] == 0 (SYSINFO), else returns None. Then, with o = 4:

field offset width
main_carrier 4 12
freq_band 16 4
freq_offset 20 2
duplex_spacing 22 3
reverse_operation 25 1
num_of_csch 26 2
ms_txpwr_max_cell 28 3
rxlev_access_min 31 4
access_parameter 35 4
radio_dl_timeout 39 4
cck_valid 43 1
cck_id or hyperframe 44 16

The tail is a 42-bit D-MLE-SYSINFO at offset 124 − 42 = 82:

field offset width
location_area 82 14
subscriber_class 96 16
bs_service_details 112 12

air_encryption = bool(bs_service_details & (1<<10)) (the BS_SERVDET_AIR_ENCR bit) — this is the encrypted-plane detector. carrier_hz is computed (§6.4).

6.4 Carrier-frequency reconstruction

From the SYSINFO frequency fields (carrier_freq_hz, per ETSI tetra_common.c):

carrier_hz = freq_band · 100 000 000
           + main_carrier · 25 000
           + FREQ_OFFSET_HZ[freq_offset]

FREQ_OFFSET_HZ = { 0: 0, 1: +6250, 2: −6250, 3: +12500 }

Worked example (the test vector): band = 4, main_carrier = 1800, freq_offset = 1 → 4·100 000 000 + 1800·25 000 + 6250 = 400 000 000 + 45 000 000 + 6 250 = 445 006 250 Hz (445.00625 MHz).

6.5 Generic MAC typing and the streaming receiver

parse_mac_pdu_type classifies any SCH/F or SB2 block by bits[0:2]: _MAC_PDU = {0:"RESOURCE", 1:"FRAG/END", 2:"BROADCAST", 3:"SUPPL"}, and for BROADCAST reads the subtype bits[2:4]: _BCAST_SUB = {0:"SYSINFO", 1:"ACCESS-DEFINE", 2:"D-NWRK-BROADCAST", 3:"D-NWRK-BROADCAST-EXT"}.

Receiver.process(bits) drives the whole chain and yields event dicts:

  1. find_sync → first slot; loop while a full 510-bit slot remains.
  2. classify_burst; skip None.
  3. SYNC burst → decode BSCH with the fixed seed 3 → parse_sync_pdu → learn mcc/mnc/colour, set scramb, emit {"type":"sync", …, "scramb":…}.
  4. With scramb known: decode AACH (RM path) every burst → emit {"type":"aach", …}. Decode the signalling block(s): SYNC→SB2 as BNCH, NORM1→SCH_F, NORM2→two NDB (SCH/HD). Each is tried as SYSINFO first ({"type":"sysinfo", …, carrier_hz, air_encryption}); otherwise emitted as generic {"type":"mac", lchan, pdu_type_str}.

Until a BSCH is decoded, scramb is None and non-sync blocks are held back — the cell code must be learned first. The app wrapper (tetra_mode.py, TetraReceiver) reads IQ from an SDR source at 144 kHz, demodulates in 1 s chunks with one timeslot of sample carry-over for burst overlap, feeds the bit-level Receiver, and rate-limits repeat events (network identity only on change; SYSINFO and traffic markers always). The IC-705 audio path cannot carry a 25 kHz π/4-DQPSK carrier, so an RTL-SDR/LimeSDR front end is required; TETRA_FREQ defaults to 390 MHz.

6.6 Logical channels summary

channel carried in code path decoded content
BSCH SYNC SB1 (120) conv, fixed scramble 3 MCC/MNC/colour, tn/fn/mn
AACH every burst BBK (30) RM(30,14), cell scramble access header, usage/traffic marker
BNCH (SYSINFO) SYNC SB2 (216) conv, cell scramble carrier, band, LA, encryption flag
SCH/F NORM1 both blocks (432) conv, cell scramble MAC PDU type (SYSINFO or generic)
SCH/HD (NDB) NORM2 each block (216) conv, cell scramble MAC PDU type

7. Constants and tables — the self-contained list

Everything a reimplementation needs, in one place. All values are printed in §3–6; this section adds the physical-layer scalars and the regeneration provenance.

7.1 Physical layer

SYMBOL_RATE = 18000     BITS_PER_SYMBOL = 2
SYM_PER_TS  = 255       BITS_PER_TS     = 510
SLOTS_PER_FRAME = 4     FRAMES_PER_MULTIFRAME = 18
MULTIFRAMES_PER_HYPERFRAME = 60
ROLLOFF (RRC α) = 0.35   sps = 8   →  SAMPLE_RATE = 144000 Hz

7.2 Training sequences — §3.1 · 7.3 Burst offsets — §3.2/3.3

7.4 π/4-DQPSK map — §4.1 · 7.5 Conv generators — §5.2

7.6 RCPC puncture + kept-index — §5.3 · 7.7 Interleaver a — §5.5

7.8 RM(30,14) parity — §5.6 · scrambling/CRC/carrier — §5.7/5.8/6.4

7.9 What gen_tetra_constants.py parses vs hard-writes

The generator clones osmo-tetra to refs/osmo_tetra_src/, then:

Parsed mechanically from the C source (regex c_array, with asserts that pin the values):

constant osmo-tetra symbol file
TRAIN_NORM1 n_bits (len 22) phy/tetra_burst.c
TRAIN_NORM2 p_bits (len 22) phy/tetra_burst.c
TRAIN_NORM3 q_bits (len 22) phy/tetra_burst.c
TRAIN_EXT x_bits (len 30) phy/tetra_burst.c
TRAIN_SYNC y_bits (len 38) phy/tetra_burst.c
PUNCT_P_2_3 P_rate2_3 (asserted == [0,1,2,5]) lower_mac/tetra_conv_enc.c
PUNCT_P_1_3 P_rate1_3 (asserted == [0,1,2,3,5,6,7]) lower_mac/tetra_conv_enc.c
RM_30_14_PARITY rm_30_14_gen[14][16] lower_mac/tetra_rm3014.c

Hard-written as literals by the generator (they are physical-layer geometry or restatements of the standard the parser does not extract): all physical-layer scalars (§7.1); SYNC_TRAIN_OFFSET/NORM_TRAIN_OFFSET and every burst field offset/length (§3.2/3.3); SCRAMB_TAPS and SCRAMB_INIT; CONV_K and CONV_POLYS; PUNCT_T_2_3, PUNCT_PERIOD, PUNCT_T_1_3; the entire CHANNELS block-size table; and CRC16_POLY/CRC16_INIT/CRC16_OK. Note two subtleties: CONV_POLYS is documentation only — the working encoder (coding.py) hard-codes the equivalent tap XORs — and the RM identity part I₁₄ is added in coding.py, so only the 16-bit parity half is parsed. If osmo-tetra is unavailable, every parsed value is also printed in this document, so the module is reconstructable from this page without the reference checkout.


8. Interop and validation

(test_tetra.py, scripts/tetra_oracle/)

Bit-exact C oracles (compiled by build.sh into refs/tetra_oracle/, gitignored; tests skip gracefully if absent):

  • conv_oracle.c — the osmo-tetra K=5 rate-1/4 encoder (g1..g4 taps as in §5.2). test_interop_conv_encoder feeds four random 50-bit inputs and asserts mother_encode == oracle bit-for-bit.
  • scramb_oracle.c — the Fibonacci-LFSR sequence and scramb_get_init. test_interop_scrambler checks the fixed-seed-3 sequence (60 bits) and the (MCC 234, MNC 20, colour 5) cell sequence match, and that this project's scramb_init equals the oracle's scramb_get_init.

Self-tests (always run):

test what it pins
test_conv_viterbi_all_channels encode→puncture→depuncture→Viterbi round-trips to the exact type-2, every channel
test_interleaver_inverse deinterleave(interleave(v)) == v, every channel
test_crc16 FCS round-trip good; single flipped bit detected
test_rm3014_correction RM encode/decode identity; a 2-bit error corrected at reported distance 2
test_dqpsk_loopback modulate→demodulate exact from bits[2:] (1st symbol is the differential reference)
test_block_roundtrip_all_channels encode_blockdecode_block == payload, every channel
test_sync_and_sysinfo_parse PDU build/parse fields; carrier_hz == 445 006 250
test_receiver_bitstream full Receiver over a clean bit stream: sync + sysinfo + aach events; carrier 445.00625 MHz
test_receiver_through_modem_with_noise the whole chain through the π/4-DQPSK modem with AWGN (σ = 0.03): still recovers MCC 234 and the carrier

The last test is the end-to-end demonstration: a sync burst is built (BSCH + AACH + SYSINFO), π/4-DQPSK modulated, corrupted with complex Gaussian noise, demodulated, and decoded — the injected cell identity and 445.00625 MHz carrier come back out through the modem.


9. Limitations

An honest inventory, roughly by impact on a real over-the-air capture:

  • Hard-decision demod. The modem emits hard bits; the Viterbi is fed ±1 values reconstructed from them, so the ~2 dB of soft-decision coding gain is left on the table. A soft demod would help most on a marginal SDR capture.
  • No timing/frequency tracking. Symbol timing is one global polyphase pick; there is no fractional interpolation, no per-burst timing, and no carrier-frequency-offset estimator (the differential demod only tolerates small offsets, and the sync burst's frequency-correction field is not used for AFC). Adequate for simulation and a clean, centred capture; a fielded receiver needs proper acquisition.
  • Bit-level burst sync. find_sync correlates the training sequence on an already-demodulated bit stream (≥ 90 % agreement), and time is only read from tn/fn/mn — there is no multiframe/hyperframe state machine or slot tracking across drop-outs.
  • Rate-2/3 only. The convolutional path always uses the rate-2/3 puncture; the rate-1/3 table is present but unused, so channels that would use it are not handled.
  • Signalling only, shallow MAC. Only BSCH/AACH/BNCH-SYSINFO are fully parsed; SCH/F and SCH/HD get first-level PDU typing, not full MAC/LLC disassembly, addressing, fragmentation reassembly, or the higher layers (MM/CMCE/SNDCP).
  • No traffic, no decryption. TCH voice/data is never demodulated (only flagged via the AACH usage marker), and no air-interface encryption (TEA1–4) is handled — the SYSINFO encryption flag is surfaced and encrypted planes are skipped by design.
  • AACH correction bounded. RM(30,14) decode searches only 0/1/2-bit prefix flips; it is not guaranteed maximum-likelihood beyond 2 errors, and the AACH has no CRC to catch a mis-correction.
  • Downlink only. No uplink, MS, or channel-access modelling.

None of these are architectural: the chain is complete end-to-end for what it claims (clear downlink signalling), and each gap is a bounded addition.


10. References

  1. ETSI EN 300 392-2, TETRA Voice plus Data (V+D); Part 2: Air Interface (AI) — the standard. Relevant clauses: 8.2.3 channel coding (8.2.3.1.1 convolutional code, 8.2.3.1.2 RCPC puncturing, 8.2.3.2 RM(30,14)), 8.2.4.1 block interleaving, 8.2.5 scrambling, 9.4.4.3 training sequences, and the SYNC/SYSINFO PDU layouts.
  2. osmo-tetra (sq5bpf fork), Harald Welte, Sylvain Munaut et al., AGPL-3.0 — the reference implementation from which the numeric tables are mechanically parsed (phy/tetra_burst.c, lower_mac/tetra_conv_enc.c, lower_mac/tetra_rm3014.c, tetra_common.c). No source is copied or linked; see NOTICE.md.
  3. Implementation in this repo: app/radio/tetra/ (dsp.py, burst.py, coding.py, mac.py, constants.py), app/radio/tetra_mode.py, scripts/gen_tetra_constants.py, scripts/tetra_oracle/*.c, test_tetra.py.

Appendix: file map

file role
app/radio/tetra/dsp.py π/4-DQPSK modem (IQ ↔︎ on-air bits), RRC
app/radio/tetra/burst.py training-sequence sync, 510-bit slice, burst builders
app/radio/tetra/coding.py scramble, interleave, RCPC/Viterbi, RM(30,14), CRC-16
app/radio/tetra/mac.py block decode + BSCH/AACH/SYSINFO/MAC parsing, Receiver
app/radio/tetra/constants.py auto-generated protocol tables
app/radio/tetra_mode.py TetraReceiver — SDR IQ → events, app integration
scripts/gen_tetra_constants.py regenerate constants.py from osmo-tetra
scripts/tetra_oracle/conv_oracle.c K=5 encoder oracle (interop)
scripts/tetra_oracle/scramb_oracle.c LFSR scrambler + seed oracle (interop)
scripts/tetra_oracle/build.sh compile the oracles into refs/tetra_oracle/
test_tetra.py coding, modem, block, receiver, and osmo-interop tests