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 whereTRAIN_SYNC(38 bits) agrees withbits[frame+214 : frame+252]in ≥ 90 % of positions (Hamming agreement fraction).classify_burst(bits, frame)— at a candidate slot:TRAIN_SYNCmatch at offset 214 ≥ 0.85 →SYNC; else the better ofTRAIN_NORM1/TRAIN_NORM2at offset 244 ≥ 0.85 →NORM1/NORM2; elseNone(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)
- Group the on-air bits into dibits; accumulate phase
φ[k] = φ[k-1] + inc·π/4(differential encoding), symbols[k] = exp(jφ[k]). - Upsample by
sps = 8(zero-stuff) and convolve with the RRC pulse.SAMPLE_RATE = 18 000 × 8 = 144 kHzcomplex 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)
- Matched filter: convolve IQ with the same RRC taps.
- Symbol timing — polyphase energy pick: for each of the
spssub-phasesp, compute mean|z[p::sps]|²; takep0 = argmax. Downsample atp0. This is a single, global timing choice (no fractional interpolation, no per-burst tracking). - AGC: divide by RMS.
- 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 frombits[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·blk→1,2,5, 9,10,13, 17,18,21, …. Keeping 3 of every 8 mother bits = 3 output per 2 input = rate 2/3; andmother · 3/8 = t3reproduces everyt3in §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:
find_sync→ first slot; loop while a full 510-bit slot remains.classify_burst; skipNone.- SYNC burst → decode BSCH with the fixed seed 3 →
parse_sync_pdu→ learnmcc/mnc/colour, setscramb, emit{"type":"sync", …, "scramb":…}. - With
scrambknown: 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..g4taps as in §5.2).test_interop_conv_encoderfeeds four random 50-bit inputs and assertsmother_encode == oraclebit-for-bit.scramb_oracle.c— the Fibonacci-LFSR sequence andscramb_get_init.test_interop_scramblerchecks the fixed-seed-3 sequence (60 bits) and the (MCC 234, MNC 20, colour 5) cell sequence match, and that this project'sscramb_initequals the oracle'sscramb_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_block→decode_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_synccorrelates 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
- 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.
- 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; seeNOTICE.md. - 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 |