POCSAG: native CCIR Radiopaging Code No. 1 decoder — 2-FSK at 512/1200/2400 baud
A self-contained, clean-room POCSAG pager decoder (and a matching encoder for self-test): 2-level FSK carried as FM-discriminator NRZ baseband, sliced to hard bits, framed by the 0x7CD215D8 sync codeword into batches of 8 frames × 2 codewords, each a (31,21) BCH-protected + even-parity 32-bit codeword — decoded to address (RIC + function) and numeric-BCD / 7-bit-ASCII message payloads.
Rafe project · app/radio/pocsag.py · reproduction spec (benchmark: ../rvqvoice.md)
Abstract
POCSAG (the CCIR Radiopaging Code No. 1, standardised as ITU-R M.584) is the 1980s pager protocol still carrying VHF/UHF paging traffic worldwide. Rafe decodes it natively — pure Python + NumPy, no external process — in a single ~290-line module, app/radio/pocsag.py, a drop-in replacement for multimon-ng -a POCSAG512 -a POCSAG1200 -a POCSAG2400 (pocsag.py). POCSAG is direct 2-level FSK; on an FM receiver the discriminator audio is the NRZ baseband, so the decoder takes that audio, removes the slow DC droop, resamples to 8 samples per bit, and hard-slices at zero to recover the bit stream. It then hunts a 32-bit rolling window for the frame-synchronisation codeword 0x7CD215D8 (in either polarity), reads the following batch of 16 codewords (8 frames × 2), and decodes each 32-bit codeword: strip the trailing even-parity bit, run a BCH(31,21) syndrome decode correcting up to two bit errors, then split the 21 information bits into a 1-bit flag (0 = address, 1 = message) and 20 data bits. Address codewords give the receiver's capcode (RIC: 18 bits + the 3-bit frame index + 2 function bits); message codewords concatenate their 20-bit fields into a payload read either as reversed-BCD numeric (4 bits/char) or as 7-bit ASCII, LSB-first (alphanumeric). The module ships its own encoder (modulate) that renders conforming NRZ FM audio, and an AWGN self-test recovers pages down to ~20 dB SNR at all three baud rates. The output line is formatted to mirror multimon-ng's exactly (digimodes.py) so the UI and any downstream tooling are unchanged.
The document below is written so that a DSP-literate reader can reimplement the decoder from scratch, bit-for-bit, without reading the source. Every constant is quoted with its source file; the frame-sync and idle codewords, the BCH generator, the numeric charset, and the address/function bit layout are all tabulated.
1. Background
1.1 POCSAG paging
POCSAG — the Post Office Code Standardisation Advisory Group code, adopted by the CCIR as Radiopaging Code No. 1 and published as ITU-R M.584 — is a one-way (transmit-only, base → pager) digital paging protocol. A paging transmitter continuously radiates a synchronous bit stream; each pager holds a factory-programmed identity (a RIC, Radio Identity Code, a.k.a. capcode) and wakes only when it hears a codeword addressed to it. Messages are short: a tone alert (address only, no body), a numeric body (digits and a few symbols), or an alphanumeric body (ASCII text). POCSAG is battery-frugal by design — see the frame-slotting scheme in §1.4 — which is why it out-lived fancier successors for utility, medical and emergency paging.
1.2 Two-level FSK and the NRZ baseband
POCSAG modulation is direct binary FSK: the RF carrier is shifted between two frequencies, nominally ±4.5 kHz deviation, one for a binary 1 and the other for a binary 0, with no pulse shaping — the baseband is plain NRZ (non-return-to-zero: the level is held constant for the whole bit period). On any FM receiver the discriminator output (the demodulated instantaneous frequency) is therefore a two-level NRZ waveform: a positive level for one symbol, a negative level for the other. Rafe exploits exactly this — it never touches RF or does FSK tone discrimination itself; it treats the incoming audio as the discriminator baseband and recovers bits by sign-slicing it (pocsag.py). Polarity (which frequency means 1) is not fixed across radios, so the decoder is made polarity-agnostic by also matching the inverted sync word (§4.3).
Three symbol rates are in use and all three are tried at once: 512, 1200 and 2400 baud (pocsag.py). 512 baud is the original POCSAG rate; 1200 and 2400 are the faster variants (sometimes marketed as "Super-POCSAG").
1.3 Codewords, frames, batches — the container hierarchy
Every unit of POCSAG framing is a 32-bit codeword. There are three kinds:
- Frame-synchronisation codeword (FSC) — the fixed pattern 0x7CD215D8 (pocsag.py) that marks the start of a batch.
- Address codeword — flag bit
0; carries a truncated RIC and 2 function bits. - Message codeword — flag bit
1; carries 20 bits of message payload. - Idle codeword — the fixed pattern 0x7A89C197 (pocsag.py), a specific (valid) address codeword used as filler when a frame slot has nothing to send.
Codewords are grouped into frames (2 codewords each) and frames into batches (8 frames). A batch on the wire is:
[ FSC 0x7CD215D8 ] [ frame0: cw cw ][ frame1: cw cw ] … [ frame7: cw cw ]
1 codeword 2 codewords × 8 frames = 16 codewords
= 32 bits = 512 bits
─────────────────────────────────────────────────────────────────────────
one batch = 1 + 16 = 17 codewords = 544 bits
The module's constant BATCH_CODEWORDS = 16 (pocsag.py) counts the 16 address/message codewords after the sync; the sync is handled separately (§4.3). So "batch" = 1 FSC (32 bits) + 16 codewords (512 bits) = 17 codewords, 544 bits.
1.4 Per-address frame slotting (why the frame index matters)
A pager does not listen to the whole batch — it listens only to one of the 8 frames, chosen by its RIC: frame = RIC mod 8. The base station must place a pager's address codeword in that pager's frame. This is the battery-saving trick: the receiver can sleep through 7/8 of every batch and only power its decoder up for its assigned frame. The consequence for the bit layout is that the low 3 bits of the RIC are not transmitted inside the address codeword — they are implied by the frame position in which the address appears. The 18 bits carried in the codeword are RIC >> 3; the decoder reconstructs the full RIC by OR-ing the frame index back in (§5.4).
1.5 BCH-protected codewords
Paging channels are noisy and one-way (no ARQ), so each codeword is forward-error-protected. The 21 information bits (1 flag + 20 data) are extended to 31 bits by a (31,21) BCH code — a 10-bit cyclic redundancy computed from a degree-10 generator polynomial — giving a code of minimum distance 5 that corrects up to 2 bit errors per codeword. A final even-parity bit over the 31 makes the 32-bit codeword and lifts the distance to 6 (2-error-correct, 3-error-detect). Rafe implements the BCH syndrome decode with a precomputed single-error-syndrome table and an O(31²) two-error search (§5.2).
2. Signal structure (exact)
All values are from app/radio/pocsag.py unless noted.
| Parameter | Value | Source |
|---|---|---|
| Modulation | 2-level FSK, NRZ baseband (FM-discriminator audio) | pocsag.py |
| RF deviation (real signal) | nominally ±4.5 kHz | ITU-R M.584 (not set in code) |
| Symbol rates | 512 / 1200 / 2400 baud | pocsag.py; bauds=(512,1200,2400) — pocsag.py |
| Bit = level | 1 → positive, 0 → negative (TX convention) |
nrz = stream*2−1 — pocsag.py |
| RX polarity | either — matches SYNC and ~SYNC | pocsag.py |
| Preamble | ≥ 576 bits of alternating 0101… (0x55-style reversals) |
PREAMBLE_BITS = 576 — pocsag.py; [0,1]*(576//2) — pocsag.py |
| Frame-sync codeword (FSC) | 0x7CD215D8 | SYNC — pocsag.py |
| Idle codeword | 0x7A89C197 | IDLE — pocsag.py |
| Codeword length | 32 bits = 1 flag + 20 data + 10 BCH + 1 parity | _codeword — pocsag.py |
| Frame | 2 codewords | §1.3 |
| Batch | 1 FSC + 8 frames × 2 = 17 codewords = 544 bits | BATCH_CODEWORDS = 16 (data cws) — pocsag.py |
| Error control | BCH(31,21), 2-bit correcting, + even parity | _BCH_POLY = 0x769 — pocsag.py |
| Numeric charset | 16 chars, 4 bits/char, LSB-first (reversed BCD) | _NUMERIC — pocsag.py |
| Alpha charset | 7-bit ASCII, LSB-first | _alpha_to_bits — pocsag.py |
| RX sample rate | 22050 Hz | decode(..., sr=22050) — pocsag.py; PocsagDecoder(..., sr=22050) — digimodes.py |
| RX oversampling | 8 samples/bit | os = 8 — pocsag.py |
2.1 The bit stream on the wire
preamble batch 0 batch 1
┌──────────────┐┌────────────────────────────┐┌────────────────────── …
│ 0101…0101 ││ FSC │ f0 │ f1 │ … │ f7 ││ FSC │ f0 │ …
│ ≥576 bits ││ 32b │2cw │2cw │ │2cw ││ 32b │
└──────────────┘└────────────────────────────┘└────────────────────── …
▲ └ each frame = 2 codewords (32 bits each) ┘
└ 0x7CD215D8 marks every batch
The receiver locks on the long alternating preamble (a square wave at half the baud rate — the maximum-transition pattern, ideal for clock recovery), then finds the FSC to establish codeword/frame phase. Every batch is preceded by its own FSC, so the decoder re-synchronises each batch and can join mid-stream.
2.2 One 32-bit codeword
bit 31 30 …………………………………………… 11 10 ……………………… 1 0
┌────┬──────────────────────────────┬─────────────────────┬────────┐
│flag│ 20 data bits │ 10 BCH check bits │ parity │
└────┴──────────────────────────────┴─────────────────────┴────────┘
│ (MSB … LSB) even over
│ 0 = address codeword bits 31..1
└▶ 1 = message codeword
flag = bit 31 of the 32-bit word (= bit 30 of the 31-bit BCH word after the parity bit is stripped). The parity bit (bit 0) is even parity over the upper 31 bits — so the whole 32-bit codeword always has even weight (_codeword, pocsag.py; verified: both SYNC and IDLE have 32-bit popcount 16).
Address codeword (flag = 0), the 20 data bits split as:
data20 = [ 18-bit address (RIC>>3) ] [ 2-bit function ]
bits 19..2 bits 1..0
Full RIC = (address18 << 3) | frame_index, where frame_index (0–7) is the frame the codeword sits in (§1.4, §5.4). The 2 function bits select the alert type; Rafe maps function 0 → numeric, any other value → alphanumeric (§5.5).
Message codeword (flag = 1): all 20 data bits are payload, concatenated MSB-first across successive message codewords into one bit vector (§5.6).
3. The transmit / self-test encoder
Although the deliverable is a decoder, the module carries a complete encoder, modulate (pocsag.py), used by the tests and for self-validation. It is the clearest statement of the wire format, so it is documented first.
modulate(pages, sr=22050, baud=1200) takes a list of (ric, text, numeric?) tuples and returns s16le mono PCM — the synthetic NRZ FM-discriminator audio:
- Preamble — start the bit list with
[0,1] * (PREAMBLE_BITS//2)= 576 alternating bits (pocsag.py). - Per page (pocsag.py):
frame = ric & 7— the pager's assigned frame (low 3 RIC bits).addr18 = ric >> 3— the 18 bits actually carried.func = 3 if not numeric else 0— function 0 for numeric, 3 for alpha.- Build the address codeword
addr_cw = _codeword(0, (addr18<<2)|func)(flag 0; pocsag.py). - Turn the text into message bits:
_numeric_to_bitsor_alpha_to_bits(pocsag.py), pack them into 20-bit message codewords with flag 1 and1-padding of the final short field (_pack_message_codewords, pocsag.py). - Fill a 16-slot batch with
IDLEcodewords, then place the address atbatch[2*frame]and the message codewords in the following slots2*frame+1, 2*frame+2, …(single-batch only — it stops at slot 16; pocsag.py). - Emit
_bits_msb(SYNC, 32)then the 16 codewords, each_bits_msb(cw, 32)(MSB-first; pocsag.py).
- Trailing
[0,1]*32(pocsag.py). - NRZ render (pocsag.py): hold each bit for
sps = sr/baudsamples (nearest-sample index map), map bit1 → +1,0 → −1(nrz = stream*2−1), scale to 0.6 full-scale, and pack<i2.
So modulate is the exact inverse of the decoder and is what the round-trip tests exercise (§7).
4. Demodulation — audio to hard bits (step by step)
The demod front-end is inside decode(pcm, sr=22050, baud=1200) (pocsag.py). It is deliberately trivial DSP — POCSAG's NRZ baseband needs only DC removal, resampling and a sign-slice — with the cleverness in the framing search (§4.3) and the BCH decode (§5).
4.1 Float conversion and DC-droop removal
Read the buffer as little-endian int16 → float64 (pocsag.py). Return empty if fewer than 64 samples (pocsag.py).
The FM discriminator adds a slow DC offset and drift that would bias the zero-slice. It is removed by subtracting a moving-average (boxcar) of the signal — a crude high-pass:
win = max(64, int(sr / baud * 32)) # ~32 bit-periods
x = x - np.convolve(x, np.ones(win)/win, mode="same") # pocsag.py
The window length is ~32 bit-periods, chosen deliberately (pocsag.py): a short window droops on long constant runs (a run of five 1s occurs inside the sync word), which would flip sliced bits; 32 bit-periods is long enough that realistic data runs survive while a slow FM DC offset is still cancelled.
4.2 Resampling to 8 samples/bit
The signal is linearly interpolated onto a grid of exactly os = 8 samples per bit (pocsag.py):
nbits = int(x.size * baud / sr)
sample_at = np.arange(nbits*os) * (sr/baud/os)
r = np.interp(sample_at, np.arange(x.size), x)
This decouples the framing search from the true sample rate: whatever sr/baud, downstream code sees 8 uniform sub-samples per bit.
4.3 Bit slicing across all 8 timing phases + sync acquisition
There is no explicit clock-recovery loop. Instead the decoder brute-forces the bit phase: for each of the 8 sub-bit offsets it takes every 8th sample, sign-slices it, and runs the full framing search (pocsag.py):
for phase in range(os):
bits = (r[phase::os] > 0).astype(np.uint8) # hard slice: >0 → 1, else 0
_find_batches(bits, emit)
bits > 0 is the FSK slicer: positive baseband → 1, negative → 0 (matching the TX convention of §2). Trying all 8 phases guarantees one of them samples near the bit centres regardless of the incoming timing offset — cheap, and robust for short buffers.
Sync search (_find_batches, pocsag.py): a 32-bit value is built by shifting in one hard bit at a time (word = ((word<<1)|bit) & 0xFFFFFFFF). Once 32 bits are resident, word is compared against both the frame-sync codeword and its bitwise inversion:
if word == SYNC or word == (~SYNC & 0xFFFFFFFF):
inv = (word != SYNC) # matched the inverted pattern → RX is inverted
seen += _decode_batches_from(bits, i, inv, on_msg)
Matching ~SYNC is what makes the decoder polarity-agnostic: if the radio's discriminator inverts the baseband, every bit (and thus the whole sync word) is flipped, and the inv flag is threaded through so every subsequent codeword is un-inverted by XOR (§5.1). This is an exact 32-bit match — no bit-error tolerance on the sync word itself (the FSC is chosen for good autocorrelation, so a false match is improbable).
5. Codeword decoding (step by step)
Once a sync is found at bit offset pos, _decode_batches_from (pocsag.py) walks consecutive batches.
5.1 Assembling a 32-bit codeword
For codeword c (0–15) in the batch, the 32 bits at pos + c*32 + k are packed MSB-first into an integer, each bit XORed with the polarity flag (pocsag.py):
raw = 0
for k in range(32):
b = int(bits[pos + c*32 + k]) ^ (1 if inv else 0)
raw = (raw << 1) | b
5.2 BCH(31,21) syndrome decode + correction (_correct)
_correct(cw32) (pocsag.py) strips the parity bit and BCH-decodes the 31-bit word:
word31 = cw32 >> 1 # drop the even-parity LSB (parity is NOT checked)
s = _syndrome(word31)
The syndrome is the remainder of dividing the 31-bit word by the generator polynomial _BCH_POLY = 0x769, computed by the same shift-XOR long-division used for encoding (_syndrome, pocsag.py):
def _syndrome(word31):
val = word31
for i in range(30, 9, -1):
if (val >> i) & 1:
val ^= _BCH_POLY << (i - 10)
return val & 0x3FF # 10-bit remainder
A zero syndrome means a valid codeword. A non-zero syndrome triggers correction against a precomputed table of the 31 single-bit-error syndromes (_SYND1 = [_syndrome(1<<i) for i in range(31)], pocsag.py):
- 1-bit error: find
iwith_SYND1[i] == s; flip biti(pocsag.py). - 2-bit error: find
i, jwith_SYND1[i] ^ _SYND1[j] == s(linearity of the syndrome) and flip both (pocsag.py). This is an O(31²) double loop. - Uncorrectable (3+ errors, no match): return
None— the codeword is dropped (pocsag.py).
On success it returns (flag, data20) extracted from the (corrected) 31-bit word (pocsag.py):
return ((word31 >> 30) & 1, (word31 >> 10) & 0xFFFFF)
Note: the even-parity bit is stripped and never checked — correction relies entirely on the BCH syndrome. So the decoder realises the BCH code's 2-error correction but not the extra error-detection the parity bit would add (§8).
The encoder side is the mirror: _bch_parity (pocsag.py) is the identical division applied to info21 << 10 to produce the 10 check bits, and _codeword appends even parity (bin(word31).count("1") & 1, pocsag.py).
5.3 The batch loop and re-sync
Within a batch the loop keeps a current address context (pocsag.py):
cur_ric = None; cur_numeric = False; payload = []
for c in range(BATCH_CODEWORDS): # 16 codewords
dec = _correct(raw)
if dec is None: continue # drop uncorrectable codewords
flag, data20 = dec
if flag == 0: … address …
elif flag == 1 and cur_ric is not None: payload += _bits_msb(data20, 20)
After the 16 codewords, any accumulated payload is emitted (pocsag.py). The decoder then advances pos by 16*32 bits and requires the next 32 bits to be the sync word again (un-inverted per inv); if they are, pos += 32 and it decodes the next batch, otherwise it stops (pocsag.py). This chains contiguous batches while rejecting stray sync matches.
5.4 Address codewords → RIC + function
When a flag-0 codeword arrives (pocsag.py):
if payload and cur_ric is not None: # a previous message just ended
on_msg(_message(cur_ric, cur_numeric, payload)); payload = []
func = data20 & 3
cur_ric = ((data20 >> 2) << 3) | (c // 2) # 18-bit addr, low 3 bits = frame index
cur_numeric = (func == 0)
Key points:
- A new address flushes any message accumulated for the previous address (multiple pages can share one batch).
- The full RIC is reconstructed as
(address18 << 3) | (c // 2). Since a frame is 2 codewords,c // 2is the frame index 0–7 — exactly the low 3 RIC bits that frame-slotting omitted from the codeword (§1.4). (In the encoder the address sits atbatch[2*frame], soc = 2*frameandc//2 = frame.) - The 2 function bits are
data20 & 3;func == 0selects numeric decoding, any other value selects alphanumeric (§5.5).
5.5 Function bits → numeric vs alphanumeric
POCSAG's function bits nominally select one of four alert types, and whether a body is numeric or alphanumeric is strictly a pager-configuration matter, not something the standard encodes. Rafe adopts a simple, self-consistent heuristic: func == 0 ⇒ numeric, otherwise alphanumeric (decoder pocsag.py; encoder emits func = 0 for numeric and func = 3 for alpha, pocsag.py). This round-trips perfectly with its own encoder and matches the common real-world convention; it is a documented simplification versus multimon-ng, which can render both interpretations (§8).
5.6 Message codewords → payload bits
Each flag-1 codeword contributes its 20 data bits, MSB-first, to the running payload list (payload += _bits_msb(data20, 20), pocsag.py). _bits_msb(value, n) (pocsag.py) expands an integer to a bit list, most significant first. Payload is therefore the codewords' 20-bit fields concatenated in order — a single flat bit vector spanning as many message codewords as the page used.
5.7 Payload bits → text (_decode_payload)
_decode_payload(bits, numeric) (pocsag.py) slices the flat bit vector into characters, each read LSB-first:
- Numeric (4 bits/char, pocsag.py):
The 16-entry tablefor i in range(0, len(bits)-3, 4): code = sum(bits[i+k] << k for k in range(4)) # LSB-first ⇒ reversed BCD out.append(_NUMERIC[code])_NUMERIC = "0123456789*U -)("(pocsag.py) maps the 4-bit code to a character (see §6.4). - Alphanumeric (7 bits/char, pocsag.py):
for i in range(0, len(bits)-6, 7): c = sum(bits[i+k] << k for k in range(7)) # 7-bit ASCII, LSB-first if 0x20 <= c < 0x7F or c in (0x0A, 0x0D): # printable + CR/LF only out.append(chr(c))
Finally trailing padding is stripped: numeric text is rstrip("( ") (the pad characters), alpha text is rstrip() (pocsag.py). The result is wrapped as {"ric", "mode": "numeric"|"alpha", "text"} by _message (pocsag.py).
The LSB-first bit order (both the 4-bit numeric codes and the 7-bit ASCII) is the POCSAG standard: each character's bits are transmitted least-significant first.
6. Constants and tables (quick reference, all quoted)
6.1 Module constants (pocsag.py)
SYNC = 0x7CD215D8 # frame-sync codeword (FSC)
IDLE = 0x7A89C197 # idle / filler codeword
_BCH_POLY = 0x769 # BCH(31,21) generator
PREAMBLE_BITS = 576 # ≥576-bit alternating preamble
BATCH_CODEWORDS = 16 # 8 frames × 2 codewords (excl. the FSC)
_NUMERIC = "0123456789*U -)("
6.2 The BCH(31,21) generator polynomial
_BCH_POLY = 0x769 = binary 111 0110 1001 — bits set at positions {10, 9, 8, 6, 5, 3, 0} (verified), i.e.
g(x) = x¹⁰ + x⁹ + x⁸ + x⁶ + x⁵ + x³ + 1
exactly as the in-source comment states (pocsag.py). This is the standard POCSAG BCH(31,21) generator: a degree-10 polynomial over GF(2) giving a cyclic code of length 31, 21 information bits, minimum distance 5 → corrects 2 bit errors. With the appended even-parity bit the effective (32,21) code has distance 6.
6.3 Sync and idle codewords (both are valid, even-parity BCH codewords)
Both special codewords satisfy the BCH check (syndrome 0) and even parity — verified by long division:
| Codeword | Hex | flag | data20 | func | syndrome | 32-bit weight |
|---|---|---|---|---|---|---|
| FSC (frame sync) | 0x7CD215D8 |
0 | 0xF9A42 |
2 | 0 (valid) | 16 (even) |
| IDLE | 0x7A89C197 |
0 | 0xF5138 |
0 | 0 (valid) | 16 (even) |
The FSC is matched before BCH decoding (as a raw 32-bit pattern, §4.3), so its "address" interpretation is never used. IDLE decodes as a flag-0 address with func=0; because idle frames carry no message codewords, no payload accumulates and IDLE emits nothing (the if payload … guards, pocsag.py) — it is harmless filler.
6.4 The numeric character set (_NUMERIC, pocsag.py)
16 entries indexed by the 4-bit code (read LSB-first from the wire):
| Code | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Char | 0 |
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
* |
U |
(space) | - |
) |
( |
This is the conventional POCSAG numeric-pager alphabet (the same 16 glyphs multimon-ng prints): digits 0–9 plus * U ␣ - ) ( for codes 10–15.
6.5 Address / function bit layout (address codeword, flag = 0)
32-bit codeword: [ flag=0 | data20 (20b) | BCH (10b) | parity (1b) ]
data20 (20b): [ address18 (bits 19..2) | function (bits 1..0) ]
full RIC (21b): [ address18 | frame_index (3b, = batch frame 0..7) ]
── carried ── ── implied by frame position ──
- address18 =
data20 >> 2— the transmitted RIC bits (RIC >> 3). - function =
data20 & 3— alert type;0 ⇒ numeric, else alphanumeric. - frame_index = the frame the codeword occupies (
c // 2on RX,RIC & 7on TX) — the low 3 RIC bits, not transmitted (§1.4, §5.4).
6.6 Message payload codecs
| Mode | Bits/char | Order | Table | Source |
|---|---|---|---|---|
| numeric | 4 | LSB-first (reversed BCD) | _NUMERIC (§6.4) |
pocsag.py |
| alphanumeric | 7 | LSB-first, 7-bit ASCII | chr(c), printable + CR/LF |
pocsag.py |
7. Interoperability and validation
7.1 Output identical to multimon-ng
The native decoder is a drop-in for multimon-ng -a POCSAG512 -a POCSAG1200 -a POCSAG2400 (pocsag.py). The app formats each decoded page to mirror multimon-ng's line exactly (_fmt_pocsag, digimodes.py):
POCSAG1200: Address: 1234560 Alpha: CQ DE M0SUP
kind is "Numeric" or "Alpha" from the page's mode, Address is the RIC, and the baud is appended to POCSAG. So the UI and any log scraper see the same text whether the native or the multimon backend is selected — the backend is chosen by the POCSAG_BACKEND environment variable, default "native" (digimodes.py); setting it to "multimon" restores the external pipe (digimodes.py).
Because POCSAG is a published standard (ITU-R M.584) and the module implements the standard framing (0x7CD215D8 sync, (31,21) BCH, the numeric/alpha charsets), a bit stream produced by modulate is a conforming POCSAG signal that multimon-ng decodes, and any conforming POCSAG transmitter (or multimon-ng's own decode of off-air paging) produces the addresses/messages this decoder reads.
7.2 Self-test suite
test_pocsag.py exercises the module end-to-end:
| Test | What it checks |
|---|---|
test_bch_correction (test_pocsag.py) |
_codeword(1, 0xABCDE) round-trips; a 2-bit error (bits 3 & 19) is corrected; a 3-bit error is not mis-corrected. |
test_roundtrip_alpha_and_numeric (test_pocsag.py) |
modulate two pages — an alpha ("CQ DE M0SUP", RIC 1234560) and a numeric ("01234567", RIC 1000008) — then decode; both (ric, text) pairs are recovered. |
test_all_bauds (test_pocsag.py) |
full modulate→decode at 512, 1200, 2400 baud. |
test_survives_awgn (test_pocsag.py) |
a page under +20 dB SNR additive white Gaussian noise still decodes ("NOISY PAGE 42"). |
test_streaming_multibaud (test_pocsag.py) |
the streaming PocsagDecoder (trying 512/1200/2400 at once) recovers a page fed in 4000-byte chunks, tagged with the correct baud. |
The AWGN case is the meaningful one: it proves the BCH correction plus the all-phases sync search recover pages through real channel noise, not just in a clean loopback. Run with python3 -m pytest test_pocsag.py.
7.3 App integration (RX path)
- Mode/rig setup: selecting POCSAG puts the rig in FM with the wide filter (manager.py —
fm = new in ("pocsag","aprs","m17","p25")), because POCSAG is FM/FSK, not SSB. - Audio feed: the 48 kHz RX audio is linearly resampled to 22050 Hz and handed to
PocsagDecoder.feed(_feed_pocsag, digimodes.py). - Streaming decoder (
PocsagDecoder, pocsag.py): buffers audio, re-decodes once ~0.5 s of new audio has arrived, tries all three baud rates, de-duplicates on(ric, text), and bounds both the audio buffer (~3 s) and the dedup set (pocsag.py). It callson_msg({ric, mode, text, baud})per new page. - Quick-tune chips default to UK VHF paging: 153025/153125/153350 kHz FM (digimodes.py).
8. Limitations
Honest gaps in the current implementation:
- Exact sync match, no bit tolerance. The 32-bit FSC must match
SYNCor~SYNCbit-for-bit (pocsag.py). A sync word with even one sliced-bit error is missed and that whole batch is lost. multimon-ng permits a couple of bit errors in the sync correlation; adding a Hamming-distance threshold here would recover more batches in deep noise. - Parity bit unused.
_correctstrips the even-parity bit and never checks it (pocsag.py); only the BCH syndrome is used. The code therefore gets 2-bit correction but forgoes the extra error-detection the (32,21) distance-6 parity would give — a mis-correction that the parity would have flagged can slip through as a wrong-but-plausible codeword. - Numeric/alpha decided by
func == 0. Whether a body is numeric or alphanumeric is a heuristic on the function bits (§5.5), not a property POCSAG actually transmits. A real network that uses function 0 for an alpha pager (or vice-versa) would be decoded in the wrong charset. multimon-ng sidesteps this by offering both renderings. - Single-batch messages in the encoder.
modulatefills exactly one 16-slot batch per page and stops if a long message overruns the batch (pocsag.py, comment "single-batch messages here"). Long real-world messages spanning multiple batches encode truncated. The decoder has no such limit — it concatenates message codewords across as many batches as follow — but the self-test encoder cannot generate them, so multi-batch messages are untested from this module's own TX. - Timing by brute-force phase search, not tracking. All 8 sub-bit phases are tried per buffer (pocsag.py) rather than a tracking clock-recovery loop. This is simple and robust for short buffers but does no timing interpolation — with only 8 phases and a sample-rate/baud ratio that is not an exact multiple, the effective bit centre can sit up to 1/16 bit off. Fine at the tested SNRs; a Gardner or Mueller–Müller TED would tighten it at low SNR.
- No FSK discrimination / AFC. The decoder assumes the input is already FM-discriminator NRZ baseband (§1.2). It does not demodulate FSK from an IF/audio tone pair and has no automatic frequency control; the signal must arrive as clean discriminator audio (which is exactly what the FM-mode rig path provides).
- DC-droop high-pass is a boxcar. The ~32-bit-period moving-average subtraction (pocsag.py) is a blunt high-pass; a pathological very-long constant run (longer than realistic POCSAG framing produces) could still droop across the slicer. The window length is a tuned compromise, documented in-source.
None of these are architectural; each is a bounded addition on a working, standards-conforming base.
9. Implementation and reproduction
| File | Role |
|---|---|
app/radio/pocsag.py |
the whole decoder + self-test encoder: BCH(31,21), framing, charsets, decode, PocsagDecoder, modulate |
test_pocsag.py |
BCH, numeric/alpha round-trip, all bauds, AWGN, streaming (§7.2) |
app/radio/digimodes.py |
app wiring — native/multimon backend select, _fmt_pocsag, 48 k→22050 resample + feed (_feed_pocsag), quick-tune |
app/radio/manager.py |
rig control — FM+wide filter for POCSAG |
Runtime API:
from app.radio import pocsag
# --- decode a whole buffer (one baud) ---
pages = pocsag.decode(pcm_s16le, sr=22050, baud=1200)
# -> [{"ric": 1234560, "mode": "alpha", "text": "CQ DE M0SUP", "baud": 1200}, …]
# --- streaming, all three bauds at once (the app path) ---
dec = pocsag.PocsagDecoder(on_msg=print, sr=22050) # tries 512/1200/2400
dec.feed(pcm_s16le_chunk) # call repeatedly
# --- self-test encoder (synthetic NRZ FM audio) ---
audio = pocsag.modulate([(1234560, "CQ DE M0SUP", False)], sr=22050, baud=1200)
Everything at runtime is NumPy; there is no external process, no data table beyond the constants quoted in §6, and no state carried between buffers except the streaming decoder's audio buffer and dedup set.
10. References
- ITU-R Recommendation M.584 — Codes and formats for radio paging (formalises the CCIR Radiopaging Code No. 1 / POCSAG framing, the frame-sync codeword, the (31,21) BCH code, and the numeric/alphanumeric message formats).
- CCIR Radiopaging Code No. 1 (POCSAG) — the Post Office Code Standardisation Advisory Group specification: 512/1200/2400 baud 2-FSK, 576-bit preamble, batches of 8 frames × 2 codewords, per-address frame slotting.
- R. C. Bose, D. K. Ray-Chaudhuri, and A. Hocquenghem — the BCH cyclic error-correcting codes; the (31,21) 2-error-correcting code with generator
x¹⁰+x⁹+x⁸+x⁶+x⁵+x³+1used per codeword (§6.2). - multimon-ng (Thomas Sailer, Elias Oenal, and contributors) — the reference open-source POCSAG decoder whose
POCSAG512/1200/2400output this module replaces and whose line format it mirrors (§7.1). ../rvqvoice.md— the depth/structure benchmark for this reproduction-spec series;rtty.md— the sibling native-mode spec.