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)
0x60sets bits 6 and 5 — the two AX.25 reserved bits, transmitted as1/1here — and leaves bit 7 (the command/responseCbit) clear.- bits 4–1 carry the 4-bit SSID (
0–15). - bit 0 is the extension/last bit:
1marks 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):
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.the bit-stuffed frame — each byte emitted LSB-first; a running count of consecutive
1s is kept, and a0is inserted after every fifth1: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 = 08 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 = MARKand 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— a0toggles the tone, a1holds 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
phaseaccumulator 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 timestamp —
position_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 formattedDDMM.mmN/Sand longitudeDDDMM.mmE/Win 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 message —
message_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{Nto 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.
5.2 Bit-clock recovery (brute-force phase search)
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 assertsax25_deframerecovers the exact body andframe_to_tnc2yieldsM0SUP>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 aWIDE1-1,WIDE2-1path, a status message, and a text message with a line number) at 48 kHz and asserts each exactSRC>DEST,path:infostring comes back throughaprs.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 throughAprsDecoderin 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_tnc2 → aprslib 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.pyare 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 toaprslibdownstream (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-1by default) and, on receive, reports the path verbatim; it does not act as a digipeater, does not set/inspect the "has-been-repeated"Hbit in digipeater SSID bytes, and does not decrement WIDEn-N hops. - UI frames only. Only connectionless UI frames (control
0x03, PID0xF0) 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
Cbit is always0and the two reserved SSID bits are always1on 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
- 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.
- 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.
- ISO/IEC 13239 — HDLC (and ITU-T X.25 LAPB) — flag
0x7E, transparency by zero-bit insertion, the frame-check sequence. - Bell System Technical Reference: Bell 202 — the 1200/2200 Hz, 1200-baud AFSK tone plan adopted for VHF packet.
- ITU-T V.41 / CRC-16-CCITT (polynomial
0x1021) and its reflected "X.25" variant — the FCS computed in_crc16_x25. multimon-ng(T. Sailer, E. Oenal et al.) anddirewolf(J. Langner WB2OSZ) — the reference AFSK1200/AX.25 decoders this implementation is a clean-room replacement for and interoperates with.