Native APRS / AX.25 packet-radio decode core for xng-mode-aprs. APRS (the
Automatic Packet Reporting System) on VHF — 144.39 MHz in North America,
144.800 MHz in Europe — is Bell 202 AFSK: a 1200 Hz "mark" tone and a
2200 Hz "space" tone keyed at 1200 baud, frequency-modulated onto the RF
carrier (narrowband FM). Packets are framed as AX.25 v2.2
Unnumbered-Information (UI) frames whose information field carries an APRS
Protocol Reference 1.0.1 payload. Clean-room: no decoder was copied or
ported — only protocol facts, the published specifications, and their worked
examples were used, each cited (see PROVENANCE.md). The decode / framing /
payload layers (AX.25 address rule, X.25 FCS, APRS payload formats) are
anchored to external spec-cited byte/bit vectors and published worked
examples, never an encode→decode self-loopback. The demod is validated
only synthetically (modulate→AWGN→demod); no real off-air IQ exists. On top
of the decode core sits a channelized IQ front end (DDC + FM discriminator
- AFSK1200 correlator) that turns a wideband capture into the link-layer octets the decode core consumes.
Status: the receive stack is implemented bottom-up — demod → hdlc →
ax25 → aprs (+ mice) — with AprsChannelDecoder as the channelized IQ
entry point and to_message normalizing a decoded frame into the bus form
MessageBody::Aprs { kind, details }. The payload layer now covers the bulk of
real on-air traffic: position (uncompressed + Base-91 compressed, with Chapter 7
data extensions and the Chapter 9 cs/T sub-field), Mic-E (Chapter 10 — the
single most common encoding), weather, message, bulletin/announcement/group,
status (incl. Maidenhead grid), object, item, general query, and telemetry. The
DECODE/FRAMING/PAYLOAD tests are spec-anchored; the demod is exercised by a
self-generated modulate→AWGN→demod path and documented as synthetic.
wideband capture IQ
→ Ddc mix by freq_offset_hz, decimate to CHANNEL_RATE (38400 S/s)
→ demod::AfskDemod FM discriminator + Bell 202 dual-tone correlator
+ transition-resync bit clock → 1 NRZI line symbol/bit
→ hdlc::HdlcDeframer NRZI differential decode + bit de-stuffing + 0x7E flag framing
deframed AX.25 octet sequence (address…control PID info FCS)
→ ax25::parse_frame address subfields (callsign<<1 + SSID), control 0x03,
PID 0xF0, X.25 FCS check
→ decode_frame Mic-E? → mice::parse(dest_callsign, info) [Ch.10, cross-field]
else → aprs::parse(info) dispatch on the DTI
→ aprs::AprsPayload { kind, fields (serde_json) }
→ to_message → xng_types::Message bus form
Two entry points:
AprsChannelDecoder::new(input_rate, freq_offset_hz) -> Result<Self, String>— channelized IQ entry (mirrors the NAVTEXNavtexChannelDecoder/ AIS contract). It owns an internalxng_dsp::Ddcthat mixes the capture byfreq_offset_hzand decimates toCHANNEL_RATE, then runs the AFSK/HDLC/AX.25/APRS pipeline.process(iq) -> Vec<AprsFrame>returns the packets recovered from that buffer. Wheninput_rate == CHANNEL_RATEandfreq_offset_hz == 0the DDC is skipped (IQ is already channelized). By default only frames whose FCS validated are emitted;set_require_fcs(false)also surfaces CRC-failed candidates (withax25.fcs_ok == false).level_dbfs()reports smoothed channel power.decode_frame(raw: &[u8]) -> Option<AprsFrame>— the deframed-octet decode core: parse the AX.25 UI frame, then parse the APRS payload (only whenpid == 0xF0; any other UI PID parses the frame but keeps the info field as arawpayload). Mic-E dispatch lives here, not inaprs::parse: when the info field's first byte is a Mic-E data-type id (`,', 0x1c, 0x1d) it callsmice::parse(&ax25.dest.callsign, &ax25.info)— because Mic-E packs the latitude into the AX.25 destination address (Chapter 10), so the decoder needs both fields — and tags the payloadAprsKind::MicE; a failed Mic-E parse falls back to the info-onlyaprs::parse. ReturnsNoneif it is not a parseable UI frame.
AprsFrame bundles the decoded ax25: Ax25Frame, the parsed
payload: AprsPayload, and raw: Vec<u8> (the deframed link-layer octets,
address…control PID info FCS).
to_message(frame, frequency_hz, level_dbfs, source) -> Message normalizes an
AprsFrame into the bus Message: mode = Mode::Aprs, body
MessageBody::Aprs { kind, details } where kind is the APRS data class
(position / weather / message / status / object / item /
telemetry / mic-e / bulletin / query / raw) and details is a JSON
object merging the AX.25 addressing (source,
dest, via[], each in TNC-2 CALL-SSID display form) with the decoded APRS
fields (lat, lon, symbol_table, symbol_code, comment, …).
decode.crc_ok = ax25.fcs_ok, RSSI from the channel level, and the deframed
link-layer octets travel as raw.
CHANNEL_RATE = 38_400.0 S/s — an integer multiple of 1200 Bd (32
samples/bit) that comfortably resolves the 2200 Hz space tone (Nyquist) and
the FM swing. CHANNEL_PASSBAND_HZ = 7_000.0 (one-sided): a 2.2 kHz top AFSK
tone under narrowband FM (≈±3 kHz deviation, ≈±5 kHz Carson bandwidth) fits
well inside this, and it rejects the adjacent 25 kHz VHF channels.
The AFSK1200 demodulator, structured after the other modes' streaming demods but for a Bell 202 AFSK signal carried in narrowband FM:
- FM discriminator —
arg(x · conj(x_prev))per sample recovers the instantaneous audio (the AFSK tone) as a real signal; it also smooths channel power forlevel_dbfs()(LEVEL_ALPHA = 0.002). - Non-coherent dual-tone correlator — two quadrature (sin/cos) reference
tables, one tuned to the 1200 Hz mark and one to the 2200 Hz space, are summed
over a sliding one-bit window (
win = round(samples_per_bit), min 4). The per-bit decision is|mark| − |space|(positive ⇒ mark/1200 Hz). This is polarity-unambiguous (no DC-bias tracking needed) and degrades gracefully under noise — more robust at low SNR than two raw Goertzel bins and needs no per-tone gain matching. - Transition-resync bit clock — a phase accumulator (
timing, 0..1 of a bit) advances1/samples_per_bitper sample; the symbol is sampled at its wrap. On each detected sign change of the tone decision the clock is hard-reset toTRANSITION_RESET_PHASE = 0.6(just past 0.5, because the trailing matched window has a ≈half-bit group delay, placing the next sampling wrap at the symbol center). HDLC's NRZI + bit-stuffing guarantee a transition at least every six bits, so this drains accumulated clock error at every transition and never slips, even over the longest AX.25 frame. Tolerates ±1% baud error and any start phase. - The hard
|mark| > |space|decisions are the NRZI line symbols, fed straight intohdlc::HdlcDeframer::push_symbol(NRZI decode + de-stuffing live there). A mark (1200 Hz) is NRZI line symbol 1.
AX.25 v2.2 §3.6–§3.8 / ISO 3309 HDLC, operating bit-by-bit on the NRZI symbol stream:
- NRZI differential decode (§3.6): a
1data bit = no change of the line symbol, a0data bit = a change.push_symboldifferentially decodes the incoming line symbols back to data bits (the first symbol has no predecessor and is treated as a1).push_data_bitfeeds an already-decoded bit (tests that build a bit stream from spec octets). - Flag framing (§3.8): frames are delimited by the flag octet
FLAG = 0x7E(01111110). Flag detection is done on the bit level via a running 1-count — the flag is the only place six consecutive 1s appear. - Bit de-stuffing (§3.7): a
0following exactly five 1s is a stuffed bit and is dropped; a0following exactly six 1s is a flag boundary; seven+ consecutive 1s is an abort/idle (drops any partial frame). - Recovered data bits are assembled LSB-first into octets. A completed frame is emitted only when it has at least 3 octets (so stray flag pairs do not emit empties); the AX.25 parser + FCS reject the rest.
Encode helpers (frame_bits, nrzi_encode) exist for the modulator/tests
only and are the inverse of the deframer.
parse_frame operates on an already-deframed, bit-unstuffed octet sequence
(address…control PID info FCS) per AX.25 v2.2:
- Address field (§3.12 / §3.12.2): each callsign subfield is 7 octets — 6
callsign chars as ASCII shifted left one bit (
C << 1), space-padded, then an SSID octet0x60 | (ssid << 1) | extwith the C/H bit in bit 7. The HDLC address-extension bit (LSB) of every address octet is 0 except the last octet of the whole address field, whose LSB is 1; the parser walks subfields until that bit is set (dest + source + up to 8 digipeaters, max 10 subfields).Addressrecordscallsign,ssid(0..15), andh_or_c(the C/has-been- repeated bit).Address::display()renders TNC-2 form:CALLorCALL-SSID, with a trailing*when the has-been-repeated H-bit is set. - Control field (§3.13): a UI frame uses control
0x03(modulo-8). The parser accepts the P/F bit set too (maskcontrol & 0xEF == 0x03, i.e.0x03or0x13); a non-UI control rejects the frame. - PID field (§3.14):
0xF0= no layer-3 protocol (what APRS uses). - Frame Check Sequence (§3.9): the FCS is the 16-bit ISO 3309 / CCITT
(X.25 / HDLC) CRC — poly 0x1021, reflected, init 0xFFFF, complemented,
transmitted low-order byte first — checked via
xng_dsp::checksum::hdlc_frame_ok(CRC-16/X-25, reused; no new CRC implementation). A bad FCS setsfcs_ok = falsebut does not by itself reject the frame; the caller (AprsChannelDecoder,require_fcs) decides whether to keep CRC-failed frames.
Ax25Frame carries dest, source, via[], control, pid, info, and
fcs_ok. is_aprs_ui is true for control 0x03 (UI) + PID 0xF0.
parse(info) dispatches on the first info byte — the APRS 1.0.1 data-type
identifier (Chapter 5, p.17) — and always returns an AprsPayload { kind, fields }; anything unrecognized falls through to AprsKind::Raw with the raw
text preserved. Mic-E (`, ', 0x1c, 0x1d) is not dispatched here —
it carries its latitude in the AX.25 destination address, so it is decoded one
level up in decode_frame (see below).
| DTI | Handler | AprsKind |
|---|---|---|
! = |
parse_position (no timestamp) |
Position |
/ @ |
parse_position (7-char timestamp follows the DTI) |
Position |
_ |
parse_weather_positionless |
Weather |
: |
parse_message (or bulletin/announcement when addressee = BLN…) |
Message / Bulletin |
> |
parse_status (free text, or Maidenhead grid locator) |
Status |
; |
parse_object |
Object |
) |
parse_item |
Item |
? |
parse_query |
Query |
T |
parse_telemetry |
Telemetry |
` ' 0x1c 0x1d |
(Mic-E — dispatched in decode_frame, mice::parse) |
MicE |
| other | raw |
Raw |
The full AprsKind enum is Position / Weather / Message / Status /
Object / Item / Telemetry / MicE / Bulletin / Query / Raw; its
as_str() produces the kind strings position / weather / message /
status / object / item / telemetry / mic-e / bulletin / query /
raw that ride on MessageBody::Aprs { kind, .. }.
- Position (Chapter 6 / 9). Uncompressed:
DDMM.mmHlat (8 chars), symbol-table id,DDDMM.mmHlon (9 chars), symbol code, then an optional 7-byte Data Extension (Chapter 7) and a comment; lat/lon decode to decimal degrees (S/W negative). Compressed (Base-91, Chapter 9): symbol-table id, 4-byte lat group, 4-byte lon group, symbol code, 2csbytes, 1 compression-type (T) byte;lat = 90 − N/380926,lon = −180 + N/190463where N is the 4-digit Base-91 value (each char − 33, base 91). The form is disambiguated by the first char: a digit ⇒ uncompressed (lat DD), otherwise ⇒ compressed (the symbol-table id is never a digit). Emitslat,lon,symbol_table,symbol_code,comment,compressed, the decoded extension/sub-field fields (below), and optionaltimestamp. - Position Data Extensions (Chapter 7,
parse_data_extension). The fixed-width 7-byte field that may follow the uncompressed symbol code, decoded and stripped from the comment when present. Four forms are recognized by their leading literal / shape:CSE/SPD=nnn/nnn(p.27): three course digits,/, three speed digits →course_deg,speed_knots.PHGphgd(p.28,decode_phg): literalPHG+ 4 codes →phg_power_w= p²,phg_height_ft= 10·2^h,phg_gain_db= g,phg_directivity_deg= d·45 (or"omni"when d = 0).DFSshgd(p.30,decode_dfs): literalDFS+ 4 codes →dfs_strength_s,dfs_height_ft= 10·2^h,dfs_gain_db,dfs_directivity_deg(or"omni").RNGrrrr(p.29): literalRNG+ 4-digit miles →radio_range_miles.
- Compressed cs/T sub-field (Chapter 9,
decode_compressed_cs, p.38-40). The twocsbytes plus the compression-typeTbyte (all Base-91) select one of three sub-fields by the firstcsbytec: a space ⇒ no data;c == '{'⇒ pre-calculated radio range2·1.08^smiles (radio_range_miles); when theTbyte's NMEA-source bits (4,3) =10b(GGA) ⇒ altitude1.002^(c·91+s)feet (altitude_ft); otherwise ⇒ coursec·4deg + speed1.08^s − 1knots (course_deg,speed_knots). TheTbyte's bit-5 GPS-fix flag becomesgps_fix_current. - Weather (positionless, Chapter 12):
_+ 8-char MDHM timestamp + field set;parse_weather_fieldswalks thec/s/g/t/r/p/P/h/bidentifiers (wind dir/speed/gust, temp °F signed, rain 1h/24h/since-midnight, humidity %, barometric pressure 1/10 hPa) by their documented field widths. (h00is normalized to 100% humidity.) - Mic-E (Chapter 10,
mice::parse; dispatched indecode_frame). The most common on-air format, split across two AX.25 fields. The destination address (6 plain-ASCII chars, the AX.25 layer having already reversed the<<1shift) carries the six latitude digits, the 3 message bits A/B/C (one per char 1-3), the N/S indicator (char 4), the longitude offset +0/+100 (char 5), and the W/E indicator (char 6) per the p.44 per-character table; the info field carries the longitude (3 bytes d/m/h + 28, p.48-49), speed + course (3 bytes SP/DC/SE + 28, p.52), and the symbol code + symbol-table id (p.46). The message bits resolve to amessage_type/message_class(StandardM0..M6, CustomC0..C6,Emergency, or mixed-Std/CustomUnknown) via the p.45 table. Trailing bytes are decoded as Mic-E telemetry (leading`/'/0x1d →has_telemetry) or as status text (which may carry a Maidenhead locator + altitude). Position ambiguity — latitude digits sent as spaces (dest charsK/L/Z) — is counted intoposition_ambiguity. An info field shorter than the mandatory 9 bytes (p.47) or a non-Mic-E destination character returnsNoneand the frame falls back to the info-only dispatch. Emitslat,lon,speed_knots,course_deg,symbol_code,symbol_table,message_type,message_class,mic_e,north,west,long_offset_100, optionalposition_ambiguity,status,has_telemetry. - Message (Chapter 14):
:ADDRESSEE:message{nnn— 9-char space-padded addressee, then the message text and an optional{message number. Emitsaddressee,message, optionalmessage_number. - Bulletin / announcement / group (Chapter 14, p.73-74). A
:message whose 9-char addressee is the literalBLN+ an identifier char is split out toAprsKind::Bulletin(bulletins are not acknowledged, so they carry no message number). A digit identifier ⇒ generalbulletin; a letter ⇒announcement; any trailing addressee chars after the identifier are the bulletin group name. Emitsaddressee,bulletin_id,bulletin_kind,text, optionalgroup. - Status (Chapter 16):
>then free-text status →status. A status whose body is a 4- or 6-char Maidenhead grid locator (AAnn/AAnngg) immediately followed by a symbol-table id + symbol code (p.81-82) is decoded tomaidenhead(upper-cased),symbol_table,symbol_code, and optional trailingstatustext; a plausibility check on the locator shape + symbol-table id guards against false-detecting plain free text. - Object (Chapter 11):
;NAME *DDHHMMz<position>— 9-char name, state (*live /_killed), 7-char timestamp, then a position parsed by the same uncompressed/compressed logic (dispatch_position_body) and merged in. Emitsname,live,timestamp, + position fields. - Item (Chapter 11, p.59,
parse_item):)NAME!<position>— a)DTI, a variable-length 3-9 char item name, then!(live) or_(killed), then a position (no timestamp) decoded by the shareddispatch_position_body. Emitsname,live, + position fields. - General Query (Chapter 15, p.78,
parse_query):?QUERYTYPE?optionally followed by a target footprintlat,long,radiusin floating-point degrees. Emitsquery_typeand, when a footprint parses,lat/lon/radius_miles(or the rawfootprintstring if it does not parse as three fields). - Telemetry (Chapter 13):
- Data
T#sss,a1,a2,a3,a4,a5,bbbbbbbb— a sequence number, five analog values, and up to 8 digital bits. Emitssequence,analog[], optionaldigital[](kindtelemetry). - Definition messages — addressed
:messages to the telemetry station's callsign with a 5-byte keyword prefix that names/scales the channels, decoded into distinct kinds:PARM.(channel names →telemetry-parm),UNIT.(units/labels →telemetry-unit),EQNS.(per-analoga·v²+b·v+ccoefficients →telemetry-eqns),BITS.(8 digital bit-senses + project title →telemetry-bits). Verified against the spec's N0QBF-11 worked examples incl. the published EQNS conversion (rawv=199,(0,5.2,0)→1034.8).
- Data
APRS is a single-channel protocol whose channel changes by region. The scan
plan (src/commands/scan.rs) lists the whole 2-meter cluster, which fits
one 2.4 MHz capture window so a single tuner can watch all of it at once:
| Region | MHz | Region | MHz | |
|---|---|---|---|---|
| NA / SA (primary) | 144.390 | EU / RU | 144.800 | |
| New Zealand | 144.575 | NA event/overflow | 144.990 | |
| China / Taiwan | 144.640 | Australia | 145.175 | |
| Japan | 144.660 | ISS / satellite digipeat | 145.825 |
70cm (446.100 MHz) and HF APRS (300-baud packet on 10.1476 / 14.1030 / 29.250 MHz) use a different band and/or modulation and are not in the 1200-baud VHF plan (HF needs a 300-baud path — a follow-up).
145.825 MHz is the international ISS / satellite digipeat channel. A frame
heard there (or via a known satellite digipeater callsign) arrived through a
spacecraft, so to_message tags details.reception = "space":
- Satellite from the path (
satellite_digipeater, crate-local): the AX.25viacallsigns are matched against the well-known space digipeaters —RS0ISS/ARISS/NA1SS→ ISS (ARISS),PSAT/PSAT2→ PSAT, etc. — and setdetails.satellite. This is the reliable primary identification (the spacecraft names itself in the path). - TLE / overhead correlation (
xng::satmap, station runtime): when an APRS station session carries areceiver-pos, the station fetches the Celestrak amateur TLE group at startup (satmap::init_aprs) andsatmap::enrich_aprsaddsdetails.satellites_overhead(the amateur satellites above the receiver's horizon at the reception time, name + elevation°, highest first) plussatellite_likelywhen the path didn't name one.SatMap::overhead(lat, lon, unix)reuses the SGP4 + TEME→ECEF machinery the Iridium satmap uses, computing observer-relative elevation (user pos + TLE→ which bird was in view). No-op withoutreceiver-pos.
Two distinct verification regimes (see PROVENANCE.md):
1. Framing / payload — spec ground truth (the real oracles). Every framing/payload test is anchored to an external reference — a spec-stated rule or a published worked example — never an encode→decode loopback.
| Layer | Fact / vector | Oracle | How verified |
|---|---|---|---|
| HDLC bit-stuffing | a 0 stuffed after five consecutive 1 data bits | AX.25 v2.2 §3.7 | bit_stuffing_inserts_zero_after_five_ones builds the stuffed bit stream for octet 0x7F (LSB-first 1,1,1,1,1,1,1,0) and asserts a 0 was inserted after the fifth 1; deframer_destuffs_and_recovers_octets / nrzi_round_trips_through_deframer confirm the deframer inverts the stuffing + NRZI rules |
| AX.25 address octets | callsign chars = ASCII<<1, space-padded; SSID octet 0x60 | (ssid<<1) | ext, last-octet LSB = 1 |
AX.25 v2.2 §3.12 / §3.12.2 | address_octets_match_spec_shift_rule asserts "APRS" → 82 A0 A4 A6 40 40 60 and the final-octet extension bit (computed by the spec rule, not the crate's encoder); parse_address_from_handbuilt_spec_octets recovers N0CALL-5 from hand-built §3.12 octets |
| Full UI frame + X.25 FCS | dest+source+digi address field, control 0x03, PID 0xF0, info, FCS low byte first |
AX.25 v2.2 §3.12–3.14, §3.9 | parse_full_ui_frame_from_spec_octets hand-builds the frame from spec octets and asserts the parser recovers all fields and validates the FCS; corrupt_info_breaks_fcs flips an info bit and asserts the FCS fails |
| APRS payload formats | uncompressed/compressed position, message, status, object, telemetry, weather field table | APRS Protocol Reference 1.0.1 published worked examples | the *_spec_example tests feed each chapter's worked example (e.g. uncompressed !4903.50N/07201.75W- → 49.0583°N / −72.0292°W p.32; compressed /5L!!<*e7> → 49.5°N / −72.75°W p.38–39; message :WU2Z :Testing{003 p.71; object ;LEADER *092345z… p.58; telemetry T#005,… p.68; weather _…c220s004… p.63) and assert the decoded fields |
| Position Data Extensions (Ch.7) | CSE/SPD, PHG, DFS, RNG 7-byte extensions | APRS 1.0.1 Ch.7 worked examples | uncompressed_course_speed_extension_p27 (088/036 → 88° / 36 kt, stripped from comment), phg_extension_p28 (PHG5132 → 25 W / 20 ft / 3 dB / 90°), dfs_extension_p30 (DFS2360 → S2 / 80 ft / 6 dB / omni), rng_extension_p29 (RNG0050 → 50 mi) |
| Compressed cs/T sub-field (Ch.9) | course/speed, radio range, altitude, compression-type byte, no-data space case | APRS 1.0.1 Ch.9 p.38-40 | compressed_course_speed_p39 (7P → 88° / 36.2 kt), compressed_radio_range_p39 ({? → ≈20 mi), compressed_altitude_p40 (GGA T byte → ≈10004 ft), compressed_space_no_extension_p38 (space ⇒ no fields) |
| Mic-E (Ch.10) | destination-address latitude + message code + N/S/E/W + offset; info-field lon/speed/course/symbol | APRS 1.0.1 Ch.10 worked examples (p.44-53) | dest_worked_example_p44 (S32U6T → 33.4273°N, M3 Returning), message_type_examples_p46 (Std M3, Emergency), info_field_worked_example_p53 / parse_full_mic_e_p53 (`(_fn"Oj/ → 112.129°W, 20 kt, 251°, jeep /j), speed_course_example_p52 (86 kt / 194°, both SP+28 schemes), position_ambiguity_p54 (2 masked digits), short_info_rejected (< 9 bytes ⇒ None); mic_e_decodes_through_full_ax25_frame (lib.rs) routes the p.53 example through a full AX.25 UI frame |
| Item (Ch.11) | )NAME!<pos> live/killed item, uncompressed + compressed |
APRS 1.0.1 Ch.11 p.59 | item_spec_example_p59 ()AID #2!…WA → Aid Station /A), item_killed_p59 (_ ⇒ live=false), item_compressed_p59 ()MOBIL!\… compressed Gas Station) |
| Bulletin / announcement / group (Ch.14) | BLN addressee split into bulletin (digit) vs announcement (letter) vs group |
APRS 1.0.1 Ch.14 p.73-74 | bulletin_spec_example_p73 (BLN3 → bulletin), announcement_spec_example_p73 (BLNQ → announcement), group_bulletin_spec_example_p74 (BLN4WX → group "WX"), normal_message_not_bulletin (regression guard) |
| General query + footprint (Ch.15) | ?QUERYTYPE? and lat,long,radius footprint |
APRS 1.0.1 Ch.15 p.78 | general_query_spec_examples_p78 (?APRS? / ?WX? / ?IGATE?), query_with_footprint_p78 (?APRS? 34.02,-117.15,0200 → 200 mi footprint) |
| Maidenhead-grid status (Ch.16) | 4/6-char locator + symbol after > |
APRS 1.0.1 Ch.16 p.81-82 | maidenhead_status_p82 (>IO91SX/- + status text), maidenhead_status_4char_p82 (>IO91/G), plain_status_not_maidenhead (free text not misdetected) |
2. Demod — SYNTHETIC modulate→AWGN→demod only (no real off-air IQ). There
is no recorded off-air APRS IQ paired with ground-truth packets. The demod
is validated entirely by modulate.rs building the on-air Bell 202 AFSK-over-FM
waveform (1200 Bd, 1200/2200 Hz tones, NRZI line coding, narrowband FM) for a
KNOWN spec-derived frame, optionally adding complex AWGN at a controlled
SNR, and requiring the real AprsChannelDecoder to recover the frame
(FCS-valid, correct callsigns and APRS fields). The modulator is not an
external reference; these are explicitly not real-RF results.
decodes_clean_synth_iq/to_message_emits_aprs_body_from_synth_iq— clean modulate→demod end-to-end through the full chain, asserting the AX.25 addressing, position fields, and theMessageBody::Aprs { kind, details }bus mapping.decodes_through_ddc_with_carrier_offset— exercises thexng_dsp::Ddcat a 4× capture rate and a 12 kHz carrier offset (the discriminator absorbs the residual offset).frame_recovery_under_awgn_synth/frame_recovery_curve_vs_snr_synth— modulate → add complex AWGN → demod over many independent noise seeds, measuring frame-recovery rate; asserts a high recovery rate at strong SNR (≥0.9 at 18 dB; essentially perfect ≥24 dB). PROVENANCE notes an observed (synthetic) cliff between ~10 and 6 dB SNR — a realistic AFSK1200 threshold. Reported as synthetic.tolerates_baud_drift_synth— recovers the frame at ±1% TX/RX baud mismatch (the transition-resync clock drains accumulated error at every HDLC transition).frame_recovery_high_snr_is_perfect_synth— at 35 dB SNR every trial yields an FCS-valid frame (no decoder-internal flakiness).
- No real off-air validation. The demod's only validation is the self-generated modulate→AWGN→demod path; no recorded off-air APRS IQ capture with ground-truth packets exists in this crate. Reported recovery-vs-SNR figures are synthetic AWGN results, not real-RF sensitivity. (Contrast with NAVTEX/UAT, which have a real off-air fixture.)
- No APRS-specific carrier search. The front end relies on the DDC
freq_offset_hzplus the FM discriminator; there is no automatic channel/carrier acquisition. The caller must point the DDC at the channel. - Single FCS error detection only. The X.25 FCS detects corruption but
the crate does no FEC / error correction; a frame that fails the FCS is
dropped (unless
set_require_fcs(false)is used to surface candidates). - APRS payload coverage (now broad, still not exhaustive). The payload
parser now covers position (uncompressed + Base-91 compressed, with Chapter 7
course/speed, PHG, DFS and RNG data extensions and the Chapter 9 compressed
cs/T course/speed/range/altitude sub-field), Mic-E (Chapter 10), weather,
message, bulletin/announcement/group, status (incl. Maidenhead grid
locator), object, item (
)), general query (?) + footprint, and telemetry (T# data values + PARM/UNIT/EQNS/BITS definition messages). Still not specially parsed (fall through toAprsKind::Raw): raw GPS/NMEA ($), third-party traffic (}), station capabilities (<), user-defined / experimental formats, and reply-acks / message ack-reject semantics. Weather decode still extracts only the named numeric fields from the documented table; non-tabulated extensions are not parsed. - Mic-E trailing field not fully decoded. The mandatory Mic-E fields
(lat/lon/speed/course/symbol/message-type) are decoded, but the optional
trailing field is only classified (telemetry vs status) and kept as raw
statustext — the Mic-E telemetry channels (2/5 hex or 5 binary) and an embedded Maidenhead-locator + altitude in the status text are not parsed out. Mixed Standard/Custom message bits reportmessage_class = "unknown"per the p.45 rule. - Object/message edge cases. Object and item positions reuse the shared
position parser (so both uncompressed and compressed forms are handled now),
but message acks/rejects are not distinguished from message text beyond the
optional
{message number. The Maidenhead-status detector uses a shape/symbol-table plausibility check; an unusual free-text status that happens to matchAAnn+ a symbol-table-like byte could in principle be mis-detected.
- NRZI is decoded in
hdlc.rs, notdemod.rs: the demod emits raw line symbols (mark = 1) and the deframer does the differential decode. A1data bit is no change; a0is a change. - Octets are assembled LSB-first (both the HDLC deframer and the AX.25
callsign
C << 1shift); do not assume MSB-first. - AX.25 callsign chars are ASCII shifted left one bit; the SSID octet's LSB is the HDLC extension bit (set only on the final address subfield), bit 7 is the C/H bit, bits 4..1 are the 0..15 SSID.
- UI control matching masks the P/F bit (
control & 0xEF == 0x03), so both0x03and0x13are accepted. - A non-
0xF0PID still parses as an AX.25 UI frame but the info field is kept as arawpayload (the APRS dispatch only runs for PID0xF0). - A bad FCS does not drop the frame at parse time —
fcs_okis recorded andAprsChannelDecoderfilters onrequire_fcs(default true). Set it false to surface CRC-failed candidates. - The bit clock hard-resets to
TRANSITION_RESET_PHASE = 0.6(not 0.5) on each transition to compensate for the trailing correlator window's ≈half-bit group delay; this is what places sampling at the symbol center. CHANNEL_RATE(38400 S/s) must stay an integer multiple of 1200 Bd (32 samples/bit) and ≥ both2·CHANNEL_PASSBAND_HZ(Nyquist) and4·SPACE_HZ(space-tone resolution); thechannel_rate_is_integer_bit_multipletest pins this.- Position compressed-vs-uncompressed is disambiguated purely by the first char after the (optional timestamp +) DTI: a digit ⇒ uncompressed, anything else ⇒ compressed. The symbol-table id is never a digit.
- Mic-E spans two AX.25 fields and is therefore dispatched in
decode_frame, NOT inaprs::parse— the latitude, message code, and N/S/E/W + longitude-offset indicators live in the destination address, the longitude/speed/course/symbol in the info field.mice::parsetakes the destination callsign already un-shifted to plain ASCII (the AX.25 layer reverses the<<1); it matches the raw ASCII dest chars against the p.44 table. A non-Mic-E dest char or an info field < 9 bytes returnsNoneand the frame falls back to the info-only dispatch. - Mic-E speed/course use two valid SP+28 encodings (p.50 note); both decode to the same value. Speed ≥ 800 and course ≥ 400 wrap (subtract 800 / 400) per the p.52 final adjustments.
- A
:message is a bulletin/announcement (and getsAprsKind::Bulletin,kind = "bulletin") only when the 9-char addressee starts with the literalBLN; the bulletin-vs-announcement split is by whether the 4th char is a digit (bulletin) or a letter (announcement), and any chars after it are the group name. Ordinary messages keepkind = "message".
- AX.25 Link Access Protocol for Amateur Packet Radio, Version 2.2
(TAPR / ARRL, July 1998) — address-field encoding (§3.12 / §3.12.2), HDLC
flags / bit-stuffing / NRZI (§3.6–§3.8), control field (§3.13), PID (§3.14),
X.25 FCS (§3.9). Facts only; no code copied. Cited inline in
src/ax25.rsandsrc/hdlc.rs. - APRS Protocol Reference, Protocol Version 1.0.1 (Bob Bruninga et al.,
2000) — data-type-identifier dispatch (Ch. 5, p.17), uncompressed/Base-91
compressed position (Ch. 6 / 9), position Data Extensions — course/speed, PHG,
DFS, RNG (Ch. 7, p.27-30), the compressed cs/T course/speed/range/altitude
sub-field (Ch. 9, p.38-40), Mic-E (Ch. 10, p.42-56), object (Ch. 11, p.58)
and item (Ch. 11, p.59), weather (Ch. 12, p.62-63), telemetry (Ch. 13,
p.68), message and bulletin/announcement/group (Ch. 14, p.71-74),
general query + footprint (Ch. 15, p.78), status and Maidenhead-grid
status (Ch. 16, p.80-82). Each payload test uses the spec's published worked
example. Cited inline in
src/aprs.rsandsrc/mice.rs. - ISO 3309 / CCITT HDLC — the bit-stuffing, flag, and X.25 FCS definitions underlying AX.25's link layer.
crates/xng-mode-aprs/PROVENANCE.md— sourcing policy, per-table oracle notes, and the explicit statement that the demod is validated synthetically (no off-air IQ).crates/xng-dsp/src/ddc.rs(Ddc) andcrates/xng-dsp/src/checksum.rs(hdlc_fcs/hdlc_frame_ok, CRC-16/X-25) — reused DSP primitives.