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
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 |