Patrick Lidstone
Self-hosted

APRS: native AFSK1200 + AX.25 — Bell-202 1200-baud packet radio

A self-contained, dependency-free APRS packet modem: AX.25 v2.2 UI frames with an X.25 (CRC-16-CCITT) FCS, HDLC bit-stuffing and NRZI, carried as Bell-202 1200-baud audio FSK (1200 Hz mark / 2200 Hz space) on an FM channel — modulated and demodulated in ~290 lines of pure NumPy/Python, with no external TNC and no multimon-ng.

Rafe project · app/radio/aprs.py (RX), app/radio/aprstx.py (TX) · reproduction spec (benchmark: ../rvqvoice.md)


Abstract

APRS (Automatic Packet Reporting System) is the ubiquitous VHF telemetry/position mode of amateur radio: short bursts of AX.25 packet data — position beacons, text messages, weather, telemetry — shared on a common frequency (144.800 MHz in Region 1, 144.390 MHz in North America) and re-broadcast by digipeaters. The physical layer is Bell 202: 1200-baud audio frequency-shift keying with a 1200 Hz mark and 2200 Hz space tone, fed into (and recovered from) the microphone/discriminator audio of an ordinary FM transceiver. The link layer is AX.25, the amateur adaptation of X.25/HDLC, and APRS specifically uses unnumbered-information (UI) frames so packets are connectionless broadcasts.

Rafe implements the whole stack natively — the transmit path in app/radio/aprstx.py and the receive path in app/radio/aprs.py — as a clean-room replacement for multimon-ng -a AFSK1200, which was the last external binary the project still shelled out to for a digimode (aprs.py). The transmitter builds an AX.25 UI frame (destination + source + digipeater path, control 0x03, PID 0xF0, information field, 16-bit FCS), wraps it in HDLC flags, bit-stuffs it, NRZI-encodes it, and renders phase-continuous 1200/2200 Hz AFSK. The receiver runs a pair of non-coherent tone correlators to produce a soft mark-minus-space symbol signal, recovers bit timing by brute-forcing eight sub-symbol phases, NRZI-decodes, hunts for the 0x7E flag, de-stuffs, verifies the X.25 FCS, and decodes the AX.25 address and information fields into a TNC2 monitor string (SRC>DEST,path:info). APRS semantics on top of the TNC2 string (latitude/longitude, symbol, comment) are parsed downstream by aprslib. The implementation is validated by an in-repo test suite that round-trips real packets through the modulator and demodulator at 48 kHz and 22.05 kHz, survives 18 dB-SNR additive white Gaussian noise, and exercises the streaming decoder.

This document is written so that a DSP-literate reader can reimplement the mode byte-for-byte without reading the source. Every constant is quoted with its source file, the X.25 FCS and the 7-bit-shifted callsign encoding are tabulated, and a complete worked frame (hex, addresses, bit counts) is given.


1. Background

1.1 APRS over AX.25 UI frames

APRS is not itself a link protocol — it is a set of information-field conventions riding on AX.25, the amateur packet-radio data-link layer (itself an adaptation of ITU-T X.25 LAPB / ISO HDLC). Ordinary AX.25 is a connection-oriented protocol with numbered I-frames, acknowledgements and windows; APRS throws all of that away and uses only the connectionless Unnumbered Information (UI) frame, so every packet is a self-contained one-shot broadcast that any station can hear and any digipeater can repeat. A UI frame carries:

  • an address field — a destination callsign, a source callsign, and up to (conventionally) eight digipeater callsigns, each 7 bytes;
  • a control byte 0x03 (the UI frame identifier, poll/final bit clear);
  • a protocol-identifier (PID) byte 0xF0 (meaning "no layer-3 protocol" — the information field is raw application data);
  • the information field — the APRS payload, an ASCII string whose first character(s) are a data-type identifier (!, =, /, @ for positions, : for messages, > for status, ; for objects, and so on);
  • a frame-check sequence (FCS) — a 16-bit CRC.

The destination callsign in APRS is not a real station; by convention it is a tool/version token (e.g. APRS, APZ001, APRSXX) that identifies the sending software, or is overloaded to carry Mic-E-compressed position/telemetry. Rafe always transmits to the generic APRS destination (manager.py).

1.2 Bell 202 AFSK

The 1200-baud amateur packet standard borrows the Bell 202 telephone modem tone plan: a binary FSK signal with a mark tone at 1200 Hz and a space tone at 2200 Hz, keyed at 1200 baud — i.e. one bit per tone period-ish, with the tones deliberately chosen to fit inside a 3-kHz voice channel. The AFSK audio is fed to an FM transmitter's microphone input, so on the air it is FM-modulated 1200/2200 Hz sub-audio; the receiver takes the FM discriminator's recovered audio and demodulates the tones. Because it rides inside an FM voice channel, Bell-202 APRS is transceiver-agnostic — any FM rig with data/mic audio access works, which is why it became the universal VHF packet mode.

Two consequences matter for the modem. First, the tones are not an integer number of cycles per bit (1200 Hz × 1/1200 s = exactly 1 cycle for mark, but 2200 Hz × 1/1200 s = 1.833 cycles for space), so a phase-continuous modulator is required to avoid spectral splatter at bit boundaries. Second, FM transmission inverts nothing but does add its own DC/phase ambiguity, which is precisely why the data is NRZI-encoded (§1.3) so the receiver never needs an absolute tone-polarity reference.

1.3 NRZI

The bit stream is NRZI (non-return-to-zero, inverted) encoded before modulation. In the HDLC/AX.25 convention used here, a data 0 causes a tone transition (mark↔︎space) and a data 1 holds the current tone. The receiver recovers data by looking only at whether the tone changed between successive bit periods — a change is a 0, no change is a 1 — so it never needs to know which absolute tone is "mark". This makes the link immune to tone/audio inversion (a swapped pair of audio leads, or an inverting discriminator) and lets the bit-clock recovery lock onto transitions. NRZI also interacts with bit-stuffing (§1.4): because a long run of 1s produces no transitions, stuffing a 0 after five 1s guarantees the receiver sees a transition at least every six bit periods, keeping the clock alive.

1.4 HDLC framing

AX.25 uses HDLC framing. Every frame is delimited by the flag byte 0x7E (bit pattern 01111110). Between flags, the frame content is bit-stuffed: whenever the transmitter has sent five consecutive 1 bits, it inserts a 0. This guarantees the six-consecutive-1 pattern of the flag can only occur at a real flag boundary and never inside data — so the flag is an unambiguous frame delimiter. The receiver reverses the process: after five 1s it deletes the following 0; if instead it sees a sixth 1, that is a flag (or an abort) and the frame ends. All frame bytes (addresses, control, PID, info, FCS) are serialised least-significant-bit first; the flag 0x7E is a bit-reversal palindrome, so it is order-independent.


2. Signal structure — the exact numbers

quantity value source
Mark tone 1200.0 Hz aprstx.py, aprs.py
Space tone 2200.0 Hz aprstx.py, aprs.py
Baud rate 1200.0 sym/s aprstx.py, aprs.py
Bits / symbol 1 (binary FSK)
Line code NRZI: 0→transition, 1→hold aprstx.py, aprs.py
HDLC flag 0x7E = (0,1,1,1,1,1,1,0) aprs.py, aprstx.py
Bit-stuff rule insert 0 after five consecutive 1s aprstx.py
Byte bit-order LSB-first aprstx.py, aprs.py
Control byte (UI) 0x03 aprstx.py
PID (no layer-3) 0xF0 aprstx.py
FCS CRC-16/X-25 (poly 0x1021 reflected 0x8408, init 0xFFFF, xorout 0xFFFF) aprstx.py
FCS wire order low byte first aprstx.py
TX leading mark ~100 ms = int(0.1 * sr) samples aprstx.py
TX leading flags 32 flags (256 bits) aprstx.py
TX trailing flags 8 flags (64 bits) aprstx.py
Samples / symbol sr / 1200 (40.0 @ 48 kHz, 18.375 @ 22.05 kHz) aprstx.py
RX oversampling 8 sub-symbol timing phases aprs.py
TX amplitude 0.5 × full-scale s16 (0.5·sin·32767) aprstx.py
Default sample rate 48 000 Hz (RX/TX); 22 050 Hz tested aprs.py, manager.py

Audio format throughout is signed 16-bit little-endian mono PCM (<i2).

The complete on-air burst produced by the modulator is therefore:

[ ~100 ms mark tone ] [ 32× 0x7E flags ] [ bit-stuffed AX.25 frame ] [ 8× 0x7E flags ]
        AGC/PLL          bit-clock sync          payload+FCS              flush
        settle

3. The AX.25 UI frame

Byte layout of the frame the modulator serialises (before HDLC flag-wrap and stuffing), built by ax25_ui_frame (aprstx.py):

+----------------+----------------+----- ... -----+---------+-----+------------- ... -+--------+
| DEST addr (7)  | SRC addr (7)   | DIGI addr (7) …| control | PID | information field | FCS (2)|
| callsign+SSID  | callsign+SSID  | 0..8 digis     |  0x03   | 0xF0| ASCII, variable   | CRC-16 |
+----------------+----------------+----- ... -----+---------+-----+------------- ... -+--------+

Note the destination address is transmitted first, then the source, then the digipeater path — this is the AX.25 order (not the human-readable SRC>DEST order, which the TNC2 renderer reverses on receive). Each address is 7 bytes: 6 for the callsign, 1 for the SSID/flags byte. The extension bit (bit 0) of each SSID byte is 0 on every address except the last, where it is 1 — this is how the receiver knows where the address field ends without a length count.

3.1 Callsign / SSID encoding (7-bit left-shift)

Each callsign is upper-cased and space-padded to 6 characters; each character byte is the ASCII code left-shifted by one bit, and the SSID byte packs the SSID value and control/extension bits (_enc_addr, aprstx.py):

base = (base.upper() + "      ")[:6]                       # pad to 6 chars
out  = bytes(((ord(c) << 1) & 0xFF) for c in base)         # each char <<1
ssid_byte = 0x60 | ((ssid & 0x0F) << 1) | (1 if last else 0)

The left-shift frees bit 0 of every callsign byte to serve as the HDLC address- extension bit (always 0 inside the 6 callsign bytes). The SSID byte breakdown:

 bit:  7   6   5   4   3   2   1   0
       C   R   R  [ S   S   S   S ]  E
       0   1   1  └── SSID 0..15 ──┘  extension (1 on last address)
  • 0x60 sets bits 6 and 5 — the two AX.25 reserved bits, transmitted as 1/1 here — and leaves bit 7 (the command/response C bit) clear.
  • bits 4–1 carry the 4-bit SSID (015).
  • bit 0 is the extension/last bit: 1 marks the final address in the field.

Decoding reverses this (_dec_addr, aprs.py):

call = "".join(chr((b7[i] >> 1) & 0x7F) for i in range(6)).strip()   # >>1, mask 7 bits
ssid = (b7[6] >> 1) & 0x0F
return (call + (f"-{ssid}" if ssid else ""), b7[6] & 1)              # last = bit 0

An SSID of 0 is rendered with no suffix (M0SUP); a non-zero SSID appends -N (M0SUP-7).

3.2 Worked example (real output of the modulator)

Input: source="M0SUP-7", dest="APRS", path="WIDE1-1,WIDE2-1", info="!5231.00N/00007.00W-Rafe". The 56-byte frame (last 2 bytes = FCS):

82 a0 a4 a6 40 40 60   9a 60 a6 aa a0 40 6e   ae 92 88 8a 62 40 62   ae 92 88 8a 64 40 63
└──── "APRS" dst ────┘ └── "M0SUP-7" src ───┘ └── "WIDE1-1" digi ──┘ └── "WIDE2-1" digi ─┘
03 f0   21 35 32 33 31 2e 30 30 4e 2f 30 30 30 30 37 2e 30 30 57 2d 52 61 66 65   79 9c
ctl pid └────────────── info: "!5231.00N/00007.00W-Rafe" (ASCII) ─────────────┘ └FCS┘

Verification of the address bytes:

callsign 6 char bytes (ord<<1) SSID byte binary of SSID byte
APRS (dest, last=0) 82 a0 a4 a6 40 40 60 0110 0000 — SSID 0, ext 0
M0SUP-7 (src, last=0) 9a 60 a6 aa a0 40 6e 0110 1110 — SSID 7, ext 0
WIDE1-1 (last=0) ae 92 88 8a 62 40 62 0110 0010 — SSID 1, ext 0
WIDE2-1 (last=1) ae 92 88 8a 64 40 63 0110 0011 — SSID 1, ext 1

('A'=0x41→0x82, 'M'=0x4D→0x9A, '0'=0x30→0x60, space=0x20→0x40.) The FCS over the 54-byte body is 0x9C79, appended low-byte-first as 79 9c (§4.1). This frame decodes back through the demodulator to the exact TNC2 string M0SUP-7>APRS,WIDE1-1,WIDE2-1:!5231.00N/00007.00W-Rafe.


4. Encode / transmit — step by step

aprstx.py is the transmitter. modulate(source, dest, path, info, sr=48000) (aprstx.py) is the entry point; it returns s16le mono AFSK audio for one UI frame.

4.1 Build the frame and its FCS

ax25_ui_frame (aprstx.py) assembles the address field (dest, then source with last set iff there are no digis, then each digi with last on the final one), appends control 0x03 and PID 0xF0, appends the ASCII-encoded info field, computes the FCS over everything so far, and appends it low-byte first:

frame  = _enc_addr(dest, False)
frame += _enc_addr(source, len(digis) == 0)
for i, d in enumerate(digis):
    frame += _enc_addr(d, i == len(digis) - 1)
frame += bytes([0x03, 0xF0])
frame += info.encode("ascii", "replace")
fcs = _crc16_x25(frame)
frame += bytes([fcs & 0xFF, (fcs >> 8) & 0xFF])   # low byte first

The FCS is the CRC-16/X-25 (_crc16_x25, aprstx.py):

def _crc16_x25(data: bytes) -> int:
    crc = 0xFFFF
    for b in data:
        crc ^= b
        for _ in range(8):
            crc = (crc >> 1) ^ 0x8408 if (crc & 1) else (crc >> 1)
    return crc ^ 0xFFFF

This is the reflected CRC-CCITT: init 0xFFFF, process bytes LSB-first by right-shifting and XORing the reflected polynomial 0x8408 (which is the bit-reversal of the CCITT polynomial 0x1021) whenever the shifted-out bit is 1, and finally complement (xorout = 0xFFFF). Because the AX.25 bit order on the wire is LSB-first, the reflected/right-shifting form matches the transmission order directly, and the low-byte-first append places the CRC on the wire in the order the receiver naturally reconstructs it.

4.2 HDLC-encode: flags + bit-stuff

_frame_to_bits(frame, flags_lead=32, flags_tail=8) (aprstx.py) emits, as a list of 0/1 (pre-NRZI):

  1. 32 leading flags — 32 copies of [0,1,1,1,1,1,1,0] (0x7E), 256 bits, for the receiver's bit-clock to lock before the payload.

  2. the bit-stuffed frame — each byte emitted LSB-first; a running count of consecutive 1s is kept, and a 0 is inserted after every fifth 1:

    for byte in frame:
        for i in range(8):            # LSB first
            b = (byte >> i) & 1
            bits.append(b)
            if b == 1:
                ones += 1
                if ones == 5:
                    bits.append(0)    # stuff a 0 after five 1s
                    ones = 0
            else:
                ones = 0
  3. 8 trailing flags — 64 bits to flush the last data bits through the receiver's demodulator and mark the frame end.

The flags themselves are not bit-stuffed (they are the only place six consecutive 1s legitimately appear).

4.3 NRZI + phase-continuous AFSK

modulate walks the bit list, applying NRZI and rendering tones (aprstx.py):

  • Start with tone = MARK and emit ~100 ms of leading mark (lead = int(0.1*sr) samples) so the receiver's AGC/PLL settles before the flags.

  • For each bit, NRZI: if bit == 0: tone = SPACE if tone == MARK else MARK — a 0 toggles the tone, a 1 holds it.

  • Advance a fractional sample accumulator so non-integer samples/symbol (sps = sr/BAUD, e.g. 18.375 @ 22.05 kHz) average out exactly:

    acc += sps
    n = int(acc); acc -= n
    for _ in range(n):
        phase += 2 * math.pi * tone / sr
        out += struct.pack("<h", int(0.5 * math.sin(phase) * 32767))
  • The phase accumulator is never reset across bits — the sine is phase-continuous through every tone change, so the spectrum stays clean.

  • Output amplitude is half full-scale (0.5 × 32767).

For the worked example above at 48 kHz: 769 total bits (256 lead-flag + 448 frame

  • 1 stuffed + 64 tail-flag) × 40 samples/symbol + 4800 lead-mark samples = 35 560 samples = 0.741 s of audio (71 120 bytes).

4.4 APRS information-field builders

aprstx.py also renders two common APRS payloads (the info field passed to ax25_ui_frame):

  • Uncompressed position, no timestampposition_info(lat, lon, comment, symbol_table="/", symbol="-") (aprstx.py) builds !<lat><table><lon><symbol><comment>. The data-type identifier is ! (position without timestamp, no APRS messaging). Latitude is formatted DDMM.mmN/S and longitude DDDMM.mmE/W in APRS degrees-and-decimal-minutes (_lat_aprs/_lon_aprs, aprstx.py); the comment is truncated to 43 characters. The default symbol table / and symbol - render a house icon.
  • Text messagemessage_info(from_call, to_call, text, msg_no=None) (aprstx.py) builds :<addressee9>:<text>[{msg_no]. The data-type identifier is :; the addressee is upper-cased and space-padded to exactly 9 characters; the text is filtered to printable ASCII excluding | and ~ (reserved by the APRS spec) and truncated to 67 characters; an optional line-number is appended as {N to request an ack.

The app wires these up in manager._aprs_tx (manager.py): a beacon uses position_info and forces an -9 SSID on the source if none is given; a message uses message_info; the path defaults to WIDE1-1,WIDE2-1; and the rendered PCM is streamed to the rig with PTT keyed.


5. Decode / receive — step by step

aprs.py is the receiver. decode(pcm, sr, os=8) (aprs.py) turns an audio buffer into a list of de-duplicated TNC2 strings; AprsDecoder (§5.6) wraps it for streaming.

5.1 AFSK tone discrimination (soft symbol)

demod_soft(pcm, sr) (aprs.py) produces a soft per-sample symbol signal that is >0 when the mark tone dominates and <0 when space dominates. It correlates the audio against each tone with a sliding-window non-coherent detector (_tone_mag, aprs.py):

def _tone_mag(x, f, sr, sps):
    t = np.arange(x.size)
    ph = 2 * np.pi * f * t / sr
    box = np.ones(sps)
    i = np.convolve(x * np.cos(ph), box, mode="same")   # in-phase, boxcar-summed
    q = np.convolve(x * np.sin(ph), box, mode="same")   # quadrature
    return np.hypot(i, q)                                # tone magnitude

This is a one-bin sliding DFT: multiply by the complex reference tone e^{-j2πft/sr} (as separate cos/sin correlators), integrate over one symbol (box of length sps = max(2, round(sr/BAUD)), aprs.py), and take the magnitude — a non-coherent (envelope) tone-energy estimate insensitive to phase.

soft = _tone_mag(x, MARK, sr, sps) - _tone_mag(x, SPACE, sr, sps)   # >0 => mark

The difference mark_mag − space_mag is the soft symbol. Working with the difference (rather than a hard slice) preserves confidence information and centres the decision threshold at zero regardless of overall gain.

Rafe does not use a PLL; it tries every sub-symbol timing offset and keeps whichever yields FCS-valid frames (decode, aprs.py). The soft signal is interpolated to os = 8 samples per symbol, and then each of the 8 integer phases is decoded independently:

nbits = int(soft.size * BAUD / sr)
r = np.interp(np.arange(nbits * os) * (sr / BAUD / os),
              np.arange(soft.size), soft)               # resample to 8×/symbol
for phase in range(os):
    levels = (r[phase::os] > 0).astype(int)             # one sample/symbol at this phase
    for frame in ax25_deframe(_nrzi_decode(levels)):
        ...

levels is the hard mark(1)/space(0) decision, one sample per symbol at a fixed sub-symbol offset. Some of the 8 phases sample near a symbol boundary and fail; at least one samples near the symbol centre and succeeds, and its FCS-valid frames are kept. Duplicate TNC2 strings across phases are removed with a seen set.

A guard at the top (soft.size < int(sr/BAUD)*24, aprs.py) skips buffers shorter than ~24 symbols.

5.3 NRZI decode

_nrzi_decode(levels) (aprs.py) converts the hard tone sequence back to data bits — tone held → 1, tone changed → 0:

out, prev = [], levels[0] if len(levels) else 0
for lv in levels[1:]:
    out.append(1 if lv == prev else 0)   # held => 1, changed => 0
    prev = lv

Because only transitions carry information, an inverted audio path (mark and space swapped) decodes identically — the whole point of NRZI.

5.4 Flag sync + de-stuff

ax25_deframe(bits) (aprs.py) finds frames and undoes bit-stuffing. It scans the whole bit stream for every occurrence of the 8-bit flag pattern, then treats the bits between each adjacent pair of flags as a candidate frame:

flags = [i for i in range(n - 7) if tuple(bits[i:i + 8]) == _FLAG]
for a, b in zip(flags, flags[1:]):
    seg = bits[a + 8:b]

De-stuffing walks the segment: after five consecutive 1s, a following 0 is dropped (it was stuffed); a sixth 1 means a flag/abort and the candidate is rejected (aprs.py):

for bit in seg:
    if ones == 5:
        ones = 0
        if bit == 0:
            continue          # drop the stuffed 0
        bad = True; break     # six 1s => abort/flag
    de.append(bit)
    ones = ones + 1 if bit == 1 else 0

5.5 Repack bytes + FCS check

The de-stuffed bits are packed LSB-first into bytes; a candidate shorter than 17 bytes is discarded as too small for a UI frame; the last two bytes are the FCS and are checked against a fresh CRC over the body (aprs.py):

nby = len(de) // 8
if bad or nby < 17:                         # min AX.25 UI frame
    continue
frame = bytes(sum(de[k * 8 + j] << j for j in range(8))   # LSB-first
              for k in range(nby))
body, fcs = frame[:-2], frame[-2:]
if aprstx._crc16_x25(body) == (fcs[0] | (fcs[1] << 8)):   # FCS low byte first
    frames.append(body)

The FCS is reassembled little-endian (fcs[0] | fcs[1]<<8), matching the transmitter's low-byte-first append (§4.1). The receiver reuses the transmitter's _crc16_x25 verbatim (aprs.py), so RX and TX can never disagree on the CRC convention. Only FCS-valid bodies (FCS stripped) propagate — this single check rejects essentially all noise-induced false frames.

5.6 Address + info parse → TNC2

frame_to_tnc2(frame) (aprs.py) walks the address field 7 bytes at a time until the extension bit marks the last address, then reads control+PID and the info field:

while i + 7 <= len(frame):
    call, last = _dec_addr(frame[i:i + 7]); addrs.append(call); i += 7
    if last: break
...
dest, src, digis = addrs[0], addrs[1], addrs[2:]
info = frame[i + 2:].decode("ascii", "replace")   # skip control + PID
tnc2 = f"{src}>{dest}"
if digis: tnc2 += "," + ",".join(digis)
return tnc2 + ":" + info

Note the reordering: on the wire the destination is first, but the TNC2 monitor format is SRC>DEST,digi1,digi2:info, so the source is printed first. The two bytes after the address field (control + PID) are skipped; everything after is the information field, decoded as ASCII with unmappable bytes replaced. The result is exactly the string a hardware TNC or direwolf would print in monitor mode.

5.7 Streaming wrapper

AprsDecoder(on_frame, sr=48000) (aprs.py) accumulates s16le audio and runs decode roughly every 0.5 s of new audio (len(self._buf) - self._last_len < self.sr, where sr bytes ≈ 0.5 s of samples), firing on_frame(tnc2) for each newly-seen packet. It bounds memory to ~3 s of audio (cap = self.sr * 6 bytes, aprs.py) and caps the de-duplication set at 4000→2000 entries. In the app it is instantiated when APRS_BACKEND == "native" (the default) and fed the 48 kHz RX audio directly (digimodes.py); each decoded TNC2 string is handed to _emit_aprs_tnc2, which parses positions with aprslib (digimodes.py).


6. Constants & tables

6.1 Tone / timing constants

Quoted verbatim (aprstx.py, aprs.py):

MARK  = 1200.0
SPACE = 2200.0
BAUD  = 1200.0
_FLAG = (0, 1, 1, 1, 1, 1, 1, 0)   # 0x7E

6.2 The X.25 FCS (CRC-16-CCITT, reflected)

parameter value
Name CRC-16/X-25 (a.k.a. CRC-CCITT reflected, "X.25 FCS")
Width 16 bits
Polynomial (normal) 0x1021 (x¹⁶+x¹²+x⁵+1)
Polynomial (reflected, as coded) 0x8408 (aprstx.py)
Init 0xFFFF (aprstx.py)
Reflect in / out yes (LSB-first, right-shifting form)
Final XOR (xorout) 0xFFFF (aprstx.py)
Wire byte order low byte first (aprstx.py)
Check value FCS over the address+control+PID+info body; a valid frame's CRC of the whole frame incl. FCS is the residue constant of the code

Worked FCS: for the body of the §3.2 example the FCS is 0x9C79, transmitted as bytes 79 9C.

6.3 Control / PID bytes

field value meaning
Control 0x03 Unnumbered Information (UI), P/F bit = 0
PID 0xF0 No layer-3 protocol (raw APRS payload)

Set at aprstx.py; skipped on decode at aprs.py.

6.4 Callsign / SSID encoding

element encode decode
callsign char (ord(c) << 1) & 0xFF, upper-cased, space-padded to 6 chr((b >> 1) & 0x7F), stripped
SSID byte 0x60 | ((ssid & 0x0F) << 1) | last ssid = (b >> 1) & 0x0F; last = b & 1
reserved bits 0x60 sets bits 6,5 = 1,1; C bit (7) = 0
extension/last bit 0 = 1 on final address only terminates the address walk

Source: _enc_addr aprstx.py, _dec_addr aprs.py.

6.5 APRS info-field forms emitted

builder data-type id template limits
position_info ! !DDMM.mmN{tbl}DDDMM.mmW{sym}{comment} comment ≤ 43 chars; default sym /+- (house)
message_info : :{addressee:9}:{text}[{msgno] addressee padded to 9; text ≤ 67, no |/~

Source: aprstx.py.


7. Interoperability & validation

7.1 In-repo test suite

test_aprs.py drives the modulator into the demodulator and checks the recovered TNC2 strings. The five tests:

  • test_ax25_deframe_and_tnc2 — builds a UI frame, HDLC-encodes it, and asserts ax25_deframe recovers the exact body and frame_to_tnc2 yields M0SUP>APRS:!hi. Confirms the framing/FCS/address path end-to-end without audio.
  • test_fcs_rejects_corruption — flips one bit inside the bit stream and asserts the corrupted frame is not returned — i.e. the FCS actually rejects single-bit errors.
  • test_afsk_roundtrip_48k — modulates three packets (a position beacon with a WIDE1-1,WIDE2-1 path, a status message, and a text message with a line number) at 48 kHz and asserts each exact SRC>DEST,path:info string comes back through aprs.decode.
  • test_afsk_survives_awgn_and_22k — modulates at 22.05 kHz (non-integer 18.375 samples/symbol), adds AWGN at 18 dB SNR (np.mean(x**2)/10**(18/10)), and asserts the packet still decodes — exercising both the fractional-timing modulator and the demod's noise tolerance.
  • test_streaming_decoder — feeds silence/packet/silence through AprsDecoder in 8000-byte chunks and asserts the callback fires with the expected TNC2 string, validating the buffering/decimation and de-dup logic.

7.2 Interoperability by construction

The implementation is a clean-room replacement for multimon-ng -a AFSK1200 (aprs.py) and the transmitter is documented as "received/decoded by the existing multimon-ng AFSK1200 path" (aprstx.py). Because every wire-level convention is the standard Bell-202/AX.25 one — 1200/2200 Hz tones at 1200 baud, NRZI, HDLC 0x7E flags with five-ones bit-stuffing, LSB-first bytes, the X.25 FCS, and the 7-bit-shifted address encoding — frames are byte-for-byte identical to those a hardware TNC, direwolf, or multimon-ng produce, so the mode interoperates with the entire APRS ecosystem over the air. The app keeps APRS_BACKEND as a switch (native by default, multimon as a fallback, digimodes.py); both paths funnel through the same _emit_aprs_tnc2aprslib parser (digimodes.py), so the two backends present identical TNC2 output, and the native decoder's format deliberately matches the string reconstructed from multimon-ng's AFSK1200: fm SRC to DST via PATH UI two-line monitor output (digimodes.py).


8. Limitations

  • No APRS-payload parsing in the native modem. aprs.py/aprstx.py are a transport: they produce and consume the raw TNC2 string (SRC>DEST,path:info) and the AX.25 frame around it. Interpreting the information field — decoding !/=@ positions to lat/lon, : messages, ; objects, > status, Mic-E, compressed positions, telemetry, weather — is left to aprslib downstream (digimodes.py). The encoder only emits two info forms: uncompressed position-without-timestamp (!) and text messages (:) — no timestamped/compressed positions, objects, telemetry, or Mic-E.
  • No digipeater/repeat logic. The modem transmits a static path (WIDE1-1,WIDE2-1 by default) and, on receive, reports the path verbatim; it does not act as a digipeater, does not set/inspect the "has-been-repeated" H bit in digipeater SSID bytes, and does not decrement WIDEn-N hops.
  • UI frames only. Only connectionless UI frames (control 0x03, PID 0xF0) are built and the receiver's minimum-length guard assumes a UI frame; numbered AX.25 connected-mode frames are neither generated nor interpreted (this is exactly the APRS subset, so it is a scope choice, not a defect).
  • Brute-force timing, no tracking PLL. Bit-clock recovery tries 8 fixed sub-symbol phases per decode window rather than a closed-loop PLL. This is simple and robust for short APRS bursts but does not track clock drift within a very long frame, and it re-decodes the whole window 8 times.
  • Non-coherent boxcar detector. The tone discriminator is a single-bin sliding DFT with a rectangular (boxcar) integration window — no matched filter, no emphasis/de-emphasis compensation, no adaptive threshold. It is deliberately minimal; a correlator or matched-filter/PLL demod (as in direwolf) would gain a dB or two on weak/multipath signals.
  • Fixed C/reserved bits. The command/response C bit is always 0 and the two reserved SSID bits are always 1 on transmit; the decoder ignores them. Fine for APRS UI broadcast, but it does not distinguish command vs response frames.
  • ASCII info field. The information field is encoded/decoded as ASCII with "replace" for unmappable bytes; binary payloads (some telemetry/Mic-E) survive as raw bytes into the TNC2 string but are not specially handled here.

None of these are architectural; the transport is complete and standard, and the richer APRS semantics live one layer up in aprslib.


9. Implementation & reproduction

file role
app/radio/aprstx.py TX: AX.25 UI framing, X.25 FCS, HDLC bit-stuff, NRZI, phase-continuous AFSK; APRS position/message info builders
app/radio/aprs.py RX: AFSK soft demod, brute-force bit timing, NRZI decode, flag sync + de-stuff, FCS check, address/info parse → TNC2; AprsDecoder streaming wrapper
app/radio/digimodes.py app integration: APRS_BACKEND switch, audio feed, aprslib position parsing, TNC2 emit
app/radio/manager.py aprs_tx — beacon/message TX orchestration (PTT, path defaults)
test_aprs.py test suite (deframe, FCS rejection, 48 k/22.05 k round-trip + AWGN, streaming)

Minimal round-trip:

from app.radio import aprstx, aprs
pcm = aprstx.modulate("M0SUP-7", "APRS", "WIDE1-1,WIDE2-1",
                      aprstx.position_info(52.5167, -0.1167, "Rafe native"),
                      sr=48000)                     # -> s16le AFSK1200 audio
print(aprs.decode(pcm, 48000))
# ['M0SUP-7>APRS,WIDE1-1,WIDE2-1:!5231.00N/00007.00W-Rafe native']

Everything at runtime is NumPy and the Python standard library; there is no trained table, no downloaded model, and no external binary — the mode is fully in-repo.


10. References

  1. AX.25 Link Access Protocol for Amateur Packet Radio, v2.2 (TAPR/ARRL, 1998) — the UI-frame format, address field, control/PID bytes, HDLC bit-stuffing and the X.25 FCS.
  2. APRS Protocol Reference, v1.0.1 (Bob Bruninga WB4APR et al., 2000) — the information-field data-type identifiers, uncompressed position and message formats, symbol tables.
  3. ISO/IEC 13239 — HDLC (and ITU-T X.25 LAPB) — flag 0x7E, transparency by zero-bit insertion, the frame-check sequence.
  4. Bell System Technical Reference: Bell 202 — the 1200/2200 Hz, 1200-baud AFSK tone plan adopted for VHF packet.
  5. ITU-T V.41 / CRC-16-CCITT (polynomial 0x1021) and its reflected "X.25" variant — the FCS computed in _crc16_x25.
  6. multimon-ng (T. Sailer, E. Oenal et al.) and direwolf (J. Langner WB2OSZ) — the reference AFSK1200/AX.25 decoders this implementation is a clean-room replacement for and interoperates with.