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)]
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)]
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 {
72        dti: u8,
73        data: Vec<u8>,
74    },
75}
76
77impl AprsPacket {
78    /// Decode a textual APRS packet (APRS-IS format).
79    ///
80    /// Expected format: `FROM>TO[,VIA...]:DATA`
81    ///
82    /// The input is `&[u8]` rather than `&str` because APRS information fields may
83    /// contain arbitrary bytes (e.g. MIC-E uses bytes 0x1C and 0x1D).
84    pub fn decode_textual(input: &[u8]) -> Result<Self, AprsError> {
85        if input.is_empty() {
86            return Err(AprsError::EmptyPacket);
87        }
88
89        let colon = input.iter().position(|&b| b == b':')
90            .ok_or(AprsError::MissingInfoDelimiter)?;
91
92        let header = &input[..colon];
93        let info = &input[colon + 1..];
94
95        let arrow = header.iter().position(|&b| b == b'>')
96            .ok_or(AprsError::MissingDestinationDelimiter)?;
97
98        let from_bytes = &header[..arrow];
99        let dest_via = &header[arrow + 1..];
100
101        let (to_bytes, via_bytes) = if let Some(comma) = dest_via.iter().position(|&b| b == b',') {
102            (&dest_via[..comma], &dest_via[comma + 1..])
103        } else {
104            (dest_via, &b""[..])
105        };
106
107        let from = Callsign::decode_textual(from_bytes)?;
108        let to = Callsign::decode_textual(to_bytes)?;
109        let via = parse_via(via_bytes)?;
110        let data = dispatch_data(info, &to)?;
111
112        Ok(AprsPacket { from, to, via, data })
113    }
114
115    /// Decode a raw AX.25 UI frame.
116    ///
117    /// Frame structure:
118    ///   Destination (7 bytes) + Source (7 bytes) + Repeaters (0–8 × 7 bytes)
119    ///   + Control (0x03) + PID (0xF0) + Information field
120    pub fn decode_ax25(input: &[u8]) -> Result<Self, AprsError> {
121        if input.len() < 16 {
122            return Err(AprsError::Ax25FrameTooShort { len: input.len() });
123        }
124
125        let (to, _) = Callsign::decode_ax25(&input[0..7])?;
126        let (from, src_eoa) = Callsign::decode_ax25(&input[7..14])?;
127
128        let mut pos = 14usize;
129        let mut via = Vec::new();
130
131        if !src_eoa {
132            loop {
133                if pos + 7 > input.len() {
134                    return Err(AprsError::Ax25MissingEoa);
135                }
136                let (digi_call, eoa) = Callsign::decode_ax25(&input[pos..pos + 7])?;
137                let heard = input[pos + 6] & 0x80 != 0;
138                via.push(Digipeater::Callsign(digi_call, heard));
139                pos += 7;
140                if eoa { break; }
141                if pos >= input.len() {
142                    return Err(AprsError::Ax25MissingEoa);
143                }
144            }
145        }
146
147        if pos >= input.len() {
148            return Err(AprsError::TruncatedPacket { expected: pos + 2, got: input.len() });
149        }
150        if input[pos] != 0x03 {
151            return Err(AprsError::Ax25NotUiFrame { byte: input[pos] });
152        }
153        pos += 1;
154
155        if pos >= input.len() {
156            return Err(AprsError::TruncatedPacket { expected: pos + 1, got: input.len() });
157        }
158        if input[pos] != 0xF0 {
159            return Err(AprsError::Ax25NotAprsPid { byte: input[pos] });
160        }
161        pos += 1;
162
163        let info = &input[pos..];
164        let data = dispatch_data(info, &to)?;
165
166        Ok(AprsPacket { from, to, via, data })
167    }
168
169    /// Encode this packet to textual APRS-IS format.
170    pub fn encode_textual(&self) -> Result<Vec<u8>, AprsError> {
171        let mut out = Vec::new();
172        self.from.encode_textual(&mut out);
173        out.push(b'>');
174        self.to.encode_textual(&mut out);
175        for digi in &self.via {
176            out.push(b',');
177            digi.encode_textual(&mut out);
178        }
179        out.push(b':');
180        self.encode_info(&mut out)?;
181        Ok(out)
182    }
183
184    /// Encode this packet to a raw AX.25 UI frame.
185    pub fn encode_ax25(&self) -> Result<Vec<u8>, AprsError> {
186        let mut out = Vec::new();
187        // Destination (EOA=0, more addresses follow)
188        self.to.encode_ax25(&mut out, false);
189        // Source (EOA=1 if no digipeaters, else 0)
190        let src_eoa = self.via.is_empty();
191        self.from.encode_ax25(&mut out, src_eoa);
192        // Digipeaters
193        for (i, digi) in self.via.iter().enumerate() {
194            let is_last = i + 1 == self.via.len();
195            match digi {
196                Digipeater::Callsign(call, _heard) => {
197                    call.encode_ax25(&mut out, is_last);
198                }
199                Digipeater::QConstruct(_, gw) => {
200                    gw.encode_ax25(&mut out, is_last);
201                }
202            }
203        }
204        out.push(0x03); // Control: UI frame
205        out.push(0xF0); // PID: no layer-3 (APRS)
206        self.encode_info(&mut out)?;
207        Ok(out)
208    }
209
210    fn encode_info(&self, out: &mut Vec<u8>) -> Result<(), AprsError> {
211        match &self.data {
212            AprsData::Position(pos) => {
213                out.extend_from_slice(&pos.encode());
214            }
215            AprsData::Message(msg) => {
216                out.extend_from_slice(&msg.encode());
217            }
218            AprsData::Status(s) => {
219                out.extend_from_slice(&s.encode());
220            }
221            AprsData::MicE(m) => {
222                out.extend_from_slice(&m.encode());
223            }
224            AprsData::Object(o) => {
225                out.extend_from_slice(&o.encode());
226            }
227            AprsData::Item(i) => {
228                out.extend_from_slice(&i.encode());
229            }
230            AprsData::Weather(w) => {
231                out.extend_from_slice(&w.encode());
232            }
233            AprsData::Telemetry(t) => {
234                out.extend_from_slice(&t.encode());
235            }
236            AprsData::Capabilities(c) => {
237                out.extend_from_slice(&c.encode());
238            }
239            AprsData::Query(q) => {
240                out.extend_from_slice(&q.encode());
241            }
242            AprsData::GridLocator(g) => {
243                out.extend_from_slice(&g.encode());
244            }
245            AprsData::Nmea(n) => {
246                out.extend_from_slice(&n.encode());
247            }
248            AprsData::ThirdParty(tp) => {
249                out.extend_from_slice(&tp.encode()?);
250            }
251            AprsData::UserDefined(ud) => {
252                out.extend_from_slice(&ud.encode());
253            }
254            AprsData::Unknown { dti: _, data } => {
255                out.extend_from_slice(data);
256            }
257        }
258        Ok(())
259    }
260}
261
262/// Dispatch the information field to the correct packet parser by DTI.
263/// For MIC-E the destination callsign is also needed.
264fn dispatch_data(info: &[u8], to: &Callsign) -> Result<AprsData, AprsError> {
265    let dti = match info.first() {
266        Some(&b) => b,
267        None => return Ok(AprsData::Unknown { dti: 0, data: Vec::new() }),
268    };
269
270    match dti {
271        b'!' | b'=' | b'/' | b'@' => AprsPosition::parse(info).map(AprsData::Position),
272        b':'                       => AprsMessage::parse(info).map(AprsData::Message),
273        b'>'                       => AprsStatus::parse(info).map(AprsData::Status),
274        b'`' | b'\'' | 0x1C | 0x1D => AprsMicE::parse(info, to).map(AprsData::MicE),
275        b';'                       => AprsObject::parse(info).map(AprsData::Object),
276        b')'                       => AprsItem::parse(info).map(AprsData::Item),
277        b'_'                       => AprsPositionlessWeather::parse(info).map(AprsData::Weather),
278        b'T'                       => AprsTelemetry::parse(info).map(AprsData::Telemetry),
279        b'<'                       => Ok(AprsData::Capabilities(AprsCapabilities::parse(info))),
280        b'?'                       => Ok(AprsData::Query(AprsQuery::parse(info))),
281        b'['                       => AprsGridLocator::parse(info).map(AprsData::GridLocator),
282        b'$'                       => Ok(AprsData::Nmea(AprsNmea::parse(info))),
283        b'}'                       => AprsThirdParty::parse(info).map(AprsData::ThirdParty),
284        b'{'                       => Ok(AprsData::UserDefined(AprsUserDefined::parse(info))),
285        _ => Ok(AprsData::Unknown { dti, data: info.to_vec() }),
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    const POSITION_PACKET: &[u8] =
294        b"W1AW-9>APRS,WIDE1-1,WIDE2-2:!4903.50N/07201.75W-Test";
295
296    const MSG_PACKET: &[u8] =
297        b"KD9ABC>APDR15,qAR,KD9XYZ::W1AW-9   :Hello world{001";
298
299    #[test]
300    fn decode_position_full() {
301        let pkt = AprsPacket::decode_textual(POSITION_PACKET).unwrap();
302        assert_eq!(pkt.from.to_string(), "W1AW-9");
303        assert_eq!(pkt.to.to_string(), "APRS");
304        assert_eq!(pkt.via.len(), 2);
305        assert!(matches!(pkt.data, AprsData::Position(_)));
306    }
307
308    #[test]
309    fn decode_message_header() {
310        let pkt = AprsPacket::decode_textual(MSG_PACKET).unwrap();
311        assert_eq!(pkt.from.to_string(), "KD9ABC");
312        assert_eq!(pkt.to.to_string(), "APDR15");
313        assert_eq!(pkt.via.len(), 1);
314        assert!(matches!(pkt.data, AprsData::Message(_)));
315    }
316
317    #[test]
318    fn empty_input_error() {
319        assert!(AprsPacket::decode_textual(b"").is_err());
320    }
321
322    #[test]
323    fn missing_arrow_error() {
324        assert!(AprsPacket::decode_textual(b"W1AW:!hello").is_err());
325    }
326
327    #[test]
328    fn missing_colon_error() {
329        assert!(AprsPacket::decode_textual(b"W1AW>APRS,WIDE1").is_err());
330    }
331
332    #[test]
333    fn no_via_path() {
334        let pkt = AprsPacket::decode_textual(b"W1AW>APRS:>Status text").unwrap();
335        assert!(pkt.via.is_empty());
336    }
337
338    #[test]
339    fn unknown_dti_preserved() {
340        let pkt = AprsPacket::decode_textual(b"W1AW>APRS:~custom data").unwrap();
341        #[allow(unreachable_patterns)]
342        match &pkt.data {
343            AprsData::Unknown { dti, data } => {
344                assert_eq!(*dti, b'~');
345                assert_eq!(data.as_slice(), b"~custom data");
346            }
347            _ => panic!("expected Unknown"),
348        }
349    }
350
351    #[test]
352    fn encode_textual_round_trip() {
353        let pkt = AprsPacket::decode_textual(POSITION_PACKET).unwrap();
354        let encoded = pkt.encode_textual().unwrap();
355        assert_eq!(encoded, POSITION_PACKET);
356    }
357
358    #[test]
359    fn encode_ax25_round_trip() {
360        let pkt = AprsPacket::decode_textual(POSITION_PACKET).unwrap();
361        let ax25 = pkt.encode_ax25().unwrap();
362        let decoded = AprsPacket::decode_ax25(&ax25).unwrap();
363        assert_eq!(decoded.from.to_string(), "W1AW-9");
364        assert_eq!(decoded.to.to_string(), "APRS");
365    }
366
367    // Real-world packet using all-'@' compressed null-position and space csT bytes.
368    // The station has no GPS lock and uses '@' placeholders with a malformed (all-space)
369    // csT block. The '!' DTI must still dispatch to Position, not Unknown.
370    #[test]
371    fn null_position_at_signs() {
372        let raw = b"N0AMY-3>BEACON,W0OOD-2*,WIDE2*,WIDE1-1:!/@@@@@@@@@@    WWW.TONYTYLER.COM WHERE IS SHAMERFACE SHE IS WITH DOMO";
373        let pkt = AprsPacket::decode_textual(raw).unwrap();
374        assert_eq!(pkt.from.to_string(), "N0AMY-3");
375        assert!(matches!(pkt.data, AprsData::Position(_)));
376        if let AprsData::Position(ref pos) = pkt.data {
377            assert!(!pos.messaging_supported);
378            assert!(pos.timestamp.is_none());
379            assert!(pos.comment.starts_with(b"  WWW.TONYTYLER.COM"));
380        }
381    }
382}