Skip to main content

aprs_decode/
packet.rs

1use crate::callsign::Callsign;
2use crate::capabilities::AprsCapabilities;
3use crate::digipeater::{Digipeater, parse_via};
4use crate::error::AprsError;
5use crate::grid::AprsGridLocator;
6use crate::item::AprsItem;
7use crate::message::AprsMessage;
8use crate::mic_e::AprsMicE;
9use crate::nmea::AprsNmea;
10use crate::object::AprsObject;
11use crate::position::AprsPosition;
12use crate::query::AprsQuery;
13use crate::status::AprsStatus;
14use crate::telemetry::AprsTelemetry;
15use crate::third_party::AprsThirdParty;
16use crate::user_defined::AprsUserDefined;
17use crate::weather::AprsPositionlessWeather;
18
19/// A fully decoded APRS packet.
20#[derive(Debug, Clone, PartialEq)]
21#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
22pub struct AprsPacket {
23    /// The source station (transmitter).
24    pub from: Callsign,
25    /// The destination callsign (AX.25 destination / APRS path destination).
26    pub to: Callsign,
27    /// The digipeater path.
28    pub via: Vec<Digipeater>,
29    /// The parsed packet content, discriminated by Data Type Indicator.
30    pub data: AprsData,
31}
32
33/// The content of an APRS packet, dispatched by Data Type Indicator (DTI).
34///
35/// The DTI is the first byte of the AX.25 information field.
36#[derive(Debug, Clone, PartialEq)]
37#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
38#[non_exhaustive]
39pub enum AprsData {
40    /// Position report. DTI: `!` `=` `/` `@`
41    Position(AprsPosition),
42    /// Message, bulletin, ACK/REJ, or telemetry metadata. DTI: `:`
43    Message(AprsMessage),
44    /// Status report. DTI: `>`
45    Status(AprsStatus),
46    /// MIC-E compressed position. DTI: `` ` `` `'` `\x1C` `\x1D`
47    MicE(AprsMicE),
48    /// Object report. DTI: `;`
49    Object(AprsObject),
50    /// Item report. DTI: `)`
51    Item(AprsItem),
52    /// Positionless weather report. DTI: `_`
53    Weather(AprsPositionlessWeather),
54    /// Telemetry data packet. DTI: `T` (followed by `#`)
55    Telemetry(AprsTelemetry),
56    /// Station capabilities. DTI: `<`
57    Capabilities(AprsCapabilities),
58    /// General query. DTI: `?`
59    Query(AprsQuery),
60    /// Maidenhead grid locator. DTI: `[`
61    GridLocator(AprsGridLocator),
62    /// Raw NMEA sentence. DTI: `$`
63    Nmea(AprsNmea),
64    /// Third-party forwarded packet. DTI: `}`
65    ThirdParty(AprsThirdParty),
66    /// User-defined / experimental packet. DTI: `{`
67    UserDefined(AprsUserDefined),
68
69    /// Packet type not yet implemented or not recognized.
70    /// Preserves the DTI byte and the raw information field for caller inspection.
71    Unknown { dti: u8, data: Vec<u8> },
72}
73
74impl AprsPacket {
75    /// Decode a textual APRS packet (APRS-IS format).
76    ///
77    /// Expected format: `FROM>TO[,VIA...]:DATA`
78    ///
79    /// The input is `&[u8]` rather than `&str` because APRS information fields may
80    /// contain arbitrary bytes (e.g. MIC-E uses bytes 0x1C and 0x1D).
81    pub fn decode_textual(input: &[u8]) -> Result<Self, AprsError> {
82        if input.is_empty() {
83            return Err(AprsError::EmptyPacket);
84        }
85
86        let colon = input
87            .iter()
88            .position(|&b| b == b':')
89            .ok_or(AprsError::MissingInfoDelimiter)?;
90
91        let header = &input[..colon];
92        let info = &input[colon + 1..];
93
94        let arrow = header
95            .iter()
96            .position(|&b| b == b'>')
97            .ok_or(AprsError::MissingDestinationDelimiter)?;
98
99        let from_bytes = &header[..arrow];
100        let dest_via = &header[arrow + 1..];
101
102        let (to_bytes, via_bytes) = if let Some(comma) = dest_via.iter().position(|&b| b == b',') {
103            (&dest_via[..comma], &dest_via[comma + 1..])
104        } else {
105            (dest_via, &b""[..])
106        };
107
108        let from = Callsign::decode_textual(from_bytes)?;
109        let to = Callsign::decode_textual(to_bytes)?;
110        let via = parse_via(via_bytes)?;
111        let data = dispatch_data(info, &to)?;
112
113        Ok(AprsPacket {
114            from,
115            to,
116            via,
117            data,
118        })
119    }
120
121    /// Decode a raw AX.25 UI frame.
122    ///
123    /// Frame structure:
124    ///   Destination (7 bytes) + Source (7 bytes) + Repeaters (0–8 × 7 bytes)
125    ///   + Control (0x03) + PID (0xF0) + Information field
126    pub fn decode_ax25(input: &[u8]) -> Result<Self, AprsError> {
127        if input.len() < 16 {
128            return Err(AprsError::Ax25FrameTooShort { len: input.len() });
129        }
130
131        let (to, _) = Callsign::decode_ax25(&input[0..7])?;
132        let (from, src_eoa) = Callsign::decode_ax25(&input[7..14])?;
133
134        let mut pos = 14usize;
135        let mut via = Vec::new();
136
137        if !src_eoa {
138            loop {
139                if pos + 7 > input.len() {
140                    return Err(AprsError::Ax25MissingEoa);
141                }
142                let (digi_call, eoa) = Callsign::decode_ax25(&input[pos..pos + 7])?;
143                let heard = input[pos + 6] & 0x80 != 0;
144                via.push(Digipeater::Callsign(digi_call, heard));
145                pos += 7;
146                if eoa {
147                    break;
148                }
149                if pos >= input.len() {
150                    return Err(AprsError::Ax25MissingEoa);
151                }
152            }
153        }
154
155        if pos >= input.len() {
156            return Err(AprsError::TruncatedPacket {
157                expected: pos + 2,
158                got: input.len(),
159            });
160        }
161        if input[pos] != 0x03 {
162            return Err(AprsError::Ax25NotUiFrame { byte: input[pos] });
163        }
164        pos += 1;
165
166        if pos >= input.len() {
167            return Err(AprsError::TruncatedPacket {
168                expected: pos + 1,
169                got: input.len(),
170            });
171        }
172        if input[pos] != 0xF0 {
173            return Err(AprsError::Ax25NotAprsPid { byte: input[pos] });
174        }
175        pos += 1;
176
177        let info = &input[pos..];
178        let data = dispatch_data(info, &to)?;
179
180        Ok(AprsPacket {
181            from,
182            to,
183            via,
184            data,
185        })
186    }
187
188    /// Encode this packet to textual APRS-IS format.
189    pub fn encode_textual(&self) -> Result<Vec<u8>, AprsError> {
190        let mut out = Vec::new();
191        self.from.encode_textual(&mut out);
192        out.push(b'>');
193        self.to.encode_textual(&mut out);
194        for digi in &self.via {
195            out.push(b',');
196            digi.encode_textual(&mut out);
197        }
198        out.push(b':');
199        self.encode_info(&mut out)?;
200        Ok(out)
201    }
202
203    /// Encode this packet to a raw AX.25 UI frame.
204    pub fn encode_ax25(&self) -> Result<Vec<u8>, AprsError> {
205        let mut out = Vec::new();
206        // Destination (EOA=0, more addresses follow)
207        self.to.encode_ax25(&mut out, false);
208        // Source (EOA=1 if no digipeaters, else 0)
209        let src_eoa = self.via.is_empty();
210        self.from.encode_ax25(&mut out, src_eoa);
211        // Digipeaters
212        for (i, digi) in self.via.iter().enumerate() {
213            let is_last = i + 1 == self.via.len();
214            match digi {
215                Digipeater::Callsign(call, heard) => {
216                    call.encode_ax25(&mut out, is_last);
217                    // Restore the "has-been-repeated" H-bit (bit 7 of the SSID byte)
218                    // so a heard digipeater (`*`) round-trips through AX.25.
219                    if *heard && let Some(last) = out.last_mut() {
220                        *last |= 0x80;
221                    }
222                }
223                Digipeater::QConstruct(_, gw) => {
224                    gw.encode_ax25(&mut out, is_last);
225                }
226            }
227        }
228        out.push(0x03); // Control: UI frame
229        out.push(0xF0); // PID: no layer-3 (APRS)
230        self.encode_info(&mut out)?;
231        Ok(out)
232    }
233
234    fn encode_info(&self, out: &mut Vec<u8>) -> Result<(), AprsError> {
235        match &self.data {
236            AprsData::Position(pos) => {
237                out.extend_from_slice(&pos.encode());
238            }
239            AprsData::Message(msg) => {
240                out.extend_from_slice(&msg.encode());
241            }
242            AprsData::Status(s) => {
243                out.extend_from_slice(&s.encode());
244            }
245            AprsData::MicE(m) => {
246                out.extend_from_slice(&m.encode());
247            }
248            AprsData::Object(o) => {
249                out.extend_from_slice(&o.encode());
250            }
251            AprsData::Item(i) => {
252                out.extend_from_slice(&i.encode());
253            }
254            AprsData::Weather(w) => {
255                out.extend_from_slice(&w.encode());
256            }
257            AprsData::Telemetry(t) => {
258                out.extend_from_slice(&t.encode());
259            }
260            AprsData::Capabilities(c) => {
261                out.extend_from_slice(&c.encode());
262            }
263            AprsData::Query(q) => {
264                out.extend_from_slice(&q.encode());
265            }
266            AprsData::GridLocator(g) => {
267                out.extend_from_slice(&g.encode());
268            }
269            AprsData::Nmea(n) => {
270                out.extend_from_slice(&n.encode());
271            }
272            AprsData::ThirdParty(tp) => {
273                out.extend_from_slice(&tp.encode()?);
274            }
275            AprsData::UserDefined(ud) => {
276                out.extend_from_slice(&ud.encode());
277            }
278            AprsData::Unknown { dti: _, data } => {
279                out.extend_from_slice(data);
280            }
281        }
282        Ok(())
283    }
284}
285
286/// Dispatch the information field to the correct packet parser by DTI.
287/// For MIC-E the destination callsign is also needed.
288fn dispatch_data(info: &[u8], to: &Callsign) -> Result<AprsData, AprsError> {
289    let dti = match info.first() {
290        Some(&b) => b,
291        None => {
292            return Ok(AprsData::Unknown {
293                dti: 0,
294                data: Vec::new(),
295            });
296        }
297    };
298
299    match dti {
300        b'!' | b'=' | b'/' | b'@' => AprsPosition::parse(info).map(AprsData::Position),
301        b':' => AprsMessage::parse(info).map(AprsData::Message),
302        b'>' => AprsStatus::parse(info).map(AprsData::Status),
303        b'`' | b'\'' | 0x1C | 0x1D => AprsMicE::parse(info, to).map(AprsData::MicE),
304        b';' => AprsObject::parse(info).map(AprsData::Object),
305        b')' => AprsItem::parse(info).map(AprsData::Item),
306        b'_' => AprsPositionlessWeather::parse(info).map(AprsData::Weather),
307        // `T` is telemetry only when followed by `#`; otherwise preserve as Unknown
308        // rather than failing the whole packet.
309        b'T' if info.get(1) == Some(&b'#') => AprsTelemetry::parse(info).map(AprsData::Telemetry),
310        b'<' => Ok(AprsData::Capabilities(AprsCapabilities::parse(info))),
311        b'?' => Ok(AprsData::Query(AprsQuery::parse(info))),
312        b'[' => AprsGridLocator::parse(info).map(AprsData::GridLocator),
313        b'$' => Ok(AprsData::Nmea(AprsNmea::parse(info))),
314        b'}' => AprsThirdParty::parse(info).map(AprsData::ThirdParty),
315        b'{' => Ok(AprsData::UserDefined(AprsUserDefined::parse(info))),
316        _ => Ok(AprsData::Unknown {
317            dti,
318            data: info.to_vec(),
319        }),
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    const POSITION_PACKET: &[u8] = b"W1AW-9>APRS,WIDE1-1,WIDE2-2:!4903.50N/07201.75W-Test";
328
329    const MSG_PACKET: &[u8] = b"KD9ABC>APDR15,qAR,KD9XYZ::W1AW-9   :Hello world{001";
330
331    #[test]
332    fn decode_position_full() {
333        let pkt = AprsPacket::decode_textual(POSITION_PACKET).unwrap();
334        assert_eq!(pkt.from.to_string(), "W1AW-9");
335        assert_eq!(pkt.to.to_string(), "APRS");
336        assert_eq!(pkt.via.len(), 2);
337        assert!(matches!(pkt.data, AprsData::Position(_)));
338    }
339
340    #[test]
341    fn decode_message_header() {
342        let pkt = AprsPacket::decode_textual(MSG_PACKET).unwrap();
343        assert_eq!(pkt.from.to_string(), "KD9ABC");
344        assert_eq!(pkt.to.to_string(), "APDR15");
345        assert_eq!(pkt.via.len(), 1);
346        assert!(matches!(pkt.data, AprsData::Message(_)));
347    }
348
349    #[test]
350    fn empty_input_error() {
351        assert!(AprsPacket::decode_textual(b"").is_err());
352    }
353
354    #[test]
355    fn missing_arrow_error() {
356        assert!(AprsPacket::decode_textual(b"W1AW:!hello").is_err());
357    }
358
359    #[test]
360    fn missing_colon_error() {
361        assert!(AprsPacket::decode_textual(b"W1AW>APRS,WIDE1").is_err());
362    }
363
364    #[test]
365    fn no_via_path() {
366        let pkt = AprsPacket::decode_textual(b"W1AW>APRS:>Status text").unwrap();
367        assert!(pkt.via.is_empty());
368    }
369
370    #[test]
371    fn unknown_dti_preserved() {
372        let pkt = AprsPacket::decode_textual(b"W1AW>APRS:~custom data").unwrap();
373        #[allow(unreachable_patterns)]
374        match &pkt.data {
375            AprsData::Unknown { dti, data } => {
376                assert_eq!(*dti, b'~');
377                assert_eq!(data.as_slice(), b"~custom data");
378            }
379            _ => panic!("expected Unknown"),
380        }
381    }
382
383    #[test]
384    fn telemetry_data_dispatched() {
385        let pkt = AprsPacket::decode_textual(b"W1AW>APRS:T#005,10,20,30,40,50,10101010").unwrap();
386        assert!(matches!(pkt.data, AprsData::Telemetry(_)));
387    }
388
389    #[test]
390    fn t_without_hash_is_unknown() {
391        // A `T`-prefixed info field that isn't `T#...` must not fail the packet;
392        // it falls back to Unknown.
393        let pkt = AprsPacket::decode_textual(b"W1AW>APRS:Tno hash here").unwrap();
394        match &pkt.data {
395            AprsData::Unknown { dti, .. } => assert_eq!(*dti, b'T'),
396            other => panic!("expected Unknown, got {other:?}"),
397        }
398    }
399
400    #[test]
401    fn encode_textual_round_trip() {
402        let pkt = AprsPacket::decode_textual(POSITION_PACKET).unwrap();
403        let encoded = pkt.encode_textual().unwrap();
404        assert_eq!(encoded, POSITION_PACKET);
405    }
406
407    #[test]
408    fn encode_ax25_round_trip() {
409        let pkt = AprsPacket::decode_textual(POSITION_PACKET).unwrap();
410        let ax25 = pkt.encode_ax25().unwrap();
411        let decoded = AprsPacket::decode_ax25(&ax25).unwrap();
412        assert_eq!(decoded.from.to_string(), "W1AW-9");
413        assert_eq!(decoded.to.to_string(), "APRS");
414    }
415
416    #[test]
417    fn ax25_struct_round_trip_preserves_heard_bit() {
418        // A digipeated packet: WIDE1-1 has been heard (`*`). The H-bit must survive
419        // an AX.25 encode→decode cycle so the structures compare equal.
420        let pkt =
421            AprsPacket::decode_textual(b"W1AW-9>APRS,W0OOD-2*,WIDE1-1:!4903.50N/07201.75W-Test")
422                .unwrap();
423        let ax25 = pkt.encode_ax25().unwrap();
424        let redecoded = AprsPacket::decode_ax25(&ax25).unwrap();
425        assert_eq!(pkt, redecoded);
426        assert!(matches!(redecoded.via[0], Digipeater::Callsign(_, true)));
427        assert!(matches!(redecoded.via[1], Digipeater::Callsign(_, false)));
428    }
429
430    // Real-world packet using all-'@' compressed null-position and space csT bytes.
431    // The station has no GPS lock and uses '@' placeholders with a malformed (all-space)
432    // csT block. The '!' DTI must still dispatch to Position, not Unknown.
433    #[test]
434    fn null_position_at_signs() {
435        let raw = b"N0AMY-3>BEACON,W0OOD-2*,WIDE2*,WIDE1-1:!/@@@@@@@@@@    WWW.TONYTYLER.COM WHERE IS SHAMERFACE SHE IS WITH DOMO";
436        let pkt = AprsPacket::decode_textual(raw).unwrap();
437        assert_eq!(pkt.from.to_string(), "N0AMY-3");
438        assert!(matches!(pkt.data, AprsData::Position(_)));
439        if let AprsData::Position(ref pos) = pkt.data {
440            assert!(!pos.messaging_supported);
441            assert!(pos.timestamp.is_none());
442            assert!(pos.comment.starts_with(b"  WWW.TONYTYLER.COM"));
443        }
444    }
445}