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:
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 = 0
- 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 = 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.
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):
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.