Skip to main content

aprs_decode/
message.rs

1use crate::error::AprsError;
2use crate::util::trim_spaces_end;
3
4/// The subtype of an APRS message packet, discriminated at parse time.
5#[derive(Debug, Clone, PartialEq, Eq)]
6#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
7pub enum MessageSubtype {
8    /// Directed message to a specific station. `id` is the optional message number.
9    Directed { id: Option<Vec<u8>> },
10    /// Acknowledgement: `id` is the message number being ACK'd.
11    Ack { id: Vec<u8> },
12    /// Rejection: `id` is the message number being REJ'd.
13    Rej { id: Vec<u8> },
14    /// General bulletin (addressee starts with `BLN`).
15    Bulletin,
16    /// National Weather Service or equivalent weather alert bulletin.
17    /// Addressee starts with `NWS`, `SKY`, `CWA`, or `BOM`.
18    NwsBulletin,
19    /// Telemetry parameter names. Text starts with `PARM.`; addressee is the described station.
20    TelemetryParm,
21    /// Telemetry unit/label names. Text starts with `UNIT.`.
22    TelemetryUnit,
23    /// Telemetry equation coefficients. Text starts with `EQNS.`.
24    TelemetryEqns,
25    /// Telemetry bit sense / project name. Text starts with `BITS.`.
26    TelemetryBits,
27    /// Directed station query. Text starts with `?`.
28    DirectedQuery,
29}
30
31/// An APRS message, bulletin, ACK/REJ, or telemetry metadata packet.
32///
33/// DTI: `:`
34///
35/// Wire format: `:AAAAAAAAA:text{id`
36/// where `AAAAAAAAA` is a space-padded 9-character addressee.
37#[derive(Debug, Clone, PartialEq, Eq)]
38#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
39pub struct AprsMessage {
40    /// Addressee, trimmed of trailing spaces.
41    pub addressee: Vec<u8>,
42    /// Message text (without the `{` separator or message number).
43    pub text: Vec<u8>,
44    /// Subtype discriminated from the addressee and text content.
45    pub subtype: MessageSubtype,
46}
47
48impl AprsMessage {
49    /// Decode from the information field (including the leading `:` DTI byte).
50    pub(crate) fn parse(info: &[u8]) -> Result<Self, AprsError> {
51        // info = `:AAAAAAAAA:text{id`
52        // We need at least `:` + 9-char addressee + `:` = 11 bytes
53        if info.len() < 11 {
54            return Err(AprsError::InvalidMessageMissingDelimiter);
55        }
56        // Byte 0 is the DTI `:`, bytes 1-9 are the addressee, byte 10 must be `:`
57        if info[10] != b':' {
58            return Err(AprsError::InvalidMessageMissingDelimiter);
59        }
60        let mut addressee = info[1..10].to_vec();
61        trim_spaces_end(&mut addressee);
62
63        let body = &info[11..]; // everything after the second `:`
64
65        // Split body into text and optional id on `{`
66        let (text_bytes, id_bytes) = if let Some(pos) = body.iter().position(|&b| b == b'{') {
67            (&body[..pos], Some(&body[pos + 1..]))
68        } else {
69            (body, None)
70        };
71
72        let text = text_bytes.to_vec();
73        let subtype = discriminate_subtype(&addressee, &text, id_bytes);
74
75        Ok(Self {
76            addressee,
77            text,
78            subtype,
79        })
80    }
81
82    pub fn encode(&self) -> Vec<u8> {
83        let mut out = Vec::new();
84        out.push(b':');
85        out.extend_from_slice(&self.addressee);
86        // Pad addressee to 9 bytes
87        out.extend(std::iter::repeat_n(
88            b' ',
89            9usize.saturating_sub(self.addressee.len()),
90        ));
91        out.push(b':');
92
93        match &self.subtype {
94            MessageSubtype::Ack { id } => {
95                out.extend_from_slice(b"ack");
96                out.extend_from_slice(id);
97            }
98            MessageSubtype::Rej { id } => {
99                out.extend_from_slice(b"rej");
100                out.extend_from_slice(id);
101            }
102            MessageSubtype::Directed { id } => {
103                out.extend_from_slice(&self.text);
104                if let Some(id) = id {
105                    out.push(b'{');
106                    out.extend_from_slice(id);
107                }
108            }
109            _ => {
110                // Bulletins, NWS, telemetry metadata, queries: text is the full body
111                out.extend_from_slice(&self.text);
112            }
113        }
114        out
115    }
116}
117
118fn discriminate_subtype(addressee: &[u8], text: &[u8], id: Option<&[u8]>) -> MessageSubtype {
119    // ACK / REJ are identified by the message text prefix
120    if text.starts_with(b"ack") {
121        return MessageSubtype::Ack {
122            id: text[3..].to_vec(),
123        };
124    }
125    if text.starts_with(b"rej") {
126        return MessageSubtype::Rej {
127            id: text[3..].to_vec(),
128        };
129    }
130
131    // Bulletins: addressee starts with BLN
132    if addressee.starts_with(b"BLN") {
133        return MessageSubtype::Bulletin;
134    }
135
136    // NWS weather bulletins: specific addressee prefixes
137    if addressee.starts_with(b"NWS")
138        || addressee.starts_with(b"SKY")
139        || addressee.starts_with(b"CWA")
140        || addressee.starts_with(b"BOM")
141    {
142        return MessageSubtype::NwsBulletin;
143    }
144
145    // Telemetry metadata (message to a station about its telemetry)
146    if text.starts_with(b"PARM.") {
147        return MessageSubtype::TelemetryParm;
148    }
149    if text.starts_with(b"UNIT.") {
150        return MessageSubtype::TelemetryUnit;
151    }
152    if text.starts_with(b"EQNS.") {
153        return MessageSubtype::TelemetryEqns;
154    }
155    if text.starts_with(b"BITS.") {
156        return MessageSubtype::TelemetryBits;
157    }
158
159    // Directed query
160    if text.starts_with(b"?") {
161        return MessageSubtype::DirectedQuery;
162    }
163
164    MessageSubtype::Directed {
165        id: id.map(|b| b.to_vec()),
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    #[test]
174    fn directed_no_id() {
175        let m = AprsMessage::parse(b":W1AW-9   :Hello world").unwrap();
176        assert_eq!(m.addressee, b"W1AW-9");
177        assert_eq!(m.text, b"Hello world");
178        assert!(matches!(m.subtype, MessageSubtype::Directed { id: None }));
179    }
180
181    #[test]
182    fn directed_with_id() {
183        let m = AprsMessage::parse(b":DESTINATI:Hello World! This msg has a : colon {329A7D5Z4")
184            .unwrap();
185        assert_eq!(m.addressee, b"DESTINATI");
186        assert_eq!(m.text, b"Hello World! This msg has a : colon ");
187        assert!(matches!(
188            m.subtype,
189            MessageSubtype::Directed { id: Some(ref id) } if id == b"329A7D5Z4"
190        ));
191    }
192
193    #[test]
194    fn ack() {
195        let m = AprsMessage::parse(b":W1AW-9   :ack001").unwrap();
196        assert!(matches!(m.subtype, MessageSubtype::Ack { ref id } if id == b"001"));
197    }
198
199    #[test]
200    fn rej() {
201        let m = AprsMessage::parse(b":W1AW-9   :rej001").unwrap();
202        assert!(matches!(m.subtype, MessageSubtype::Rej { ref id } if id == b"001"));
203    }
204
205    #[test]
206    fn bulletin() {
207        let m = AprsMessage::parse(b":BLN3     :Net at 21:00z tonight").unwrap();
208        assert!(matches!(m.subtype, MessageSubtype::Bulletin));
209    }
210
211    #[test]
212    fn nws_bulletin() {
213        let m = AprsMessage::parse(b":NWS-WARN :Tornado warning in effect").unwrap();
214        assert!(matches!(m.subtype, MessageSubtype::NwsBulletin));
215    }
216
217    #[test]
218    fn telemetry_parm() {
219        let m = AprsMessage::parse(b":KD9ABC   :PARM.Bat1,Bat2,Temp,Hum,Pres").unwrap();
220        assert!(matches!(m.subtype, MessageSubtype::TelemetryParm));
221    }
222
223    #[test]
224    fn telemetry_bits() {
225        let m = AprsMessage::parse(b":KD9ABC   :BITS.11111111,My Project").unwrap();
226        assert!(matches!(m.subtype, MessageSubtype::TelemetryBits));
227    }
228
229    #[test]
230    fn directed_query() {
231        let m = AprsMessage::parse(b":KD9ABC   :?APRSD").unwrap();
232        assert!(matches!(m.subtype, MessageSubtype::DirectedQuery));
233    }
234
235    #[test]
236    fn too_short() {
237        assert!(AprsMessage::parse(b":W1AW:hi").is_err());
238    }
239
240    #[test]
241    fn encode_round_trip_directed() {
242        let raw = b":DESTINATI:Hello World! This msg has a : colon {329A7D5Z4";
243        let m = AprsMessage::parse(raw).unwrap();
244        assert_eq!(m.encode(), raw);
245    }
246
247    #[test]
248    fn encode_round_trip_bulletin() {
249        let raw = b":BLN3     :Net at 21:00z tonight";
250        let m = AprsMessage::parse(raw).unwrap();
251        assert_eq!(m.encode(), raw);
252    }
253
254    #[test]
255    fn encode_ack() {
256        let raw = b":W1AW-9   :ack001";
257        let m = AprsMessage::parse(raw).unwrap();
258        assert_eq!(m.encode(), raw);
259    }
260}