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 { addressee, text, subtype })
76    }
77
78    pub fn encode(&self) -> Vec<u8> {
79        let mut out = Vec::new();
80        out.push(b':');
81        out.extend_from_slice(&self.addressee);
82        // Pad addressee to 9 bytes
83        out.extend(std::iter::repeat_n(b' ', 9usize.saturating_sub(self.addressee.len())));
84        out.push(b':');
85
86        match &self.subtype {
87            MessageSubtype::Ack { id } => {
88                out.extend_from_slice(b"ack");
89                out.extend_from_slice(id);
90            }
91            MessageSubtype::Rej { id } => {
92                out.extend_from_slice(b"rej");
93                out.extend_from_slice(id);
94            }
95            MessageSubtype::Directed { id } => {
96                out.extend_from_slice(&self.text);
97                if let Some(id) = id {
98                    out.push(b'{');
99                    out.extend_from_slice(id);
100                }
101            }
102            _ => {
103                // Bulletins, NWS, telemetry metadata, queries: text is the full body
104                out.extend_from_slice(&self.text);
105            }
106        }
107        out
108    }
109}
110
111fn discriminate_subtype(addressee: &[u8], text: &[u8], id: Option<&[u8]>) -> MessageSubtype {
112    // ACK / REJ are identified by the message text prefix
113    if text.starts_with(b"ack") {
114        return MessageSubtype::Ack { id: text[3..].to_vec() };
115    }
116    if text.starts_with(b"rej") {
117        return MessageSubtype::Rej { id: text[3..].to_vec() };
118    }
119
120    // Bulletins: addressee starts with BLN
121    if addressee.starts_with(b"BLN") {
122        return MessageSubtype::Bulletin;
123    }
124
125    // NWS weather bulletins: specific addressee prefixes
126    if addressee.starts_with(b"NWS")
127        || addressee.starts_with(b"SKY")
128        || addressee.starts_with(b"CWA")
129        || addressee.starts_with(b"BOM")
130    {
131        return MessageSubtype::NwsBulletin;
132    }
133
134    // Telemetry metadata (message to a station about its telemetry)
135    if text.starts_with(b"PARM.") {
136        return MessageSubtype::TelemetryParm;
137    }
138    if text.starts_with(b"UNIT.") {
139        return MessageSubtype::TelemetryUnit;
140    }
141    if text.starts_with(b"EQNS.") {
142        return MessageSubtype::TelemetryEqns;
143    }
144    if text.starts_with(b"BITS.") {
145        return MessageSubtype::TelemetryBits;
146    }
147
148    // Directed query
149    if text.starts_with(b"?") {
150        return MessageSubtype::DirectedQuery;
151    }
152
153    MessageSubtype::Directed { id: id.map(|b| b.to_vec()) }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn directed_no_id() {
162        let m = AprsMessage::parse(b":W1AW-9   :Hello world").unwrap();
163        assert_eq!(m.addressee, b"W1AW-9");
164        assert_eq!(m.text, b"Hello world");
165        assert!(matches!(m.subtype, MessageSubtype::Directed { id: None }));
166    }
167
168    #[test]
169    fn directed_with_id() {
170        let m = AprsMessage::parse(b":DESTINATI:Hello World! This msg has a : colon {329A7D5Z4").unwrap();
171        assert_eq!(m.addressee, b"DESTINATI");
172        assert_eq!(m.text, b"Hello World! This msg has a : colon ");
173        assert!(matches!(
174            m.subtype,
175            MessageSubtype::Directed { id: Some(ref id) } if id == b"329A7D5Z4"
176        ));
177    }
178
179    #[test]
180    fn ack() {
181        let m = AprsMessage::parse(b":W1AW-9   :ack001").unwrap();
182        assert!(matches!(m.subtype, MessageSubtype::Ack { ref id } if id == b"001"));
183    }
184
185    #[test]
186    fn rej() {
187        let m = AprsMessage::parse(b":W1AW-9   :rej001").unwrap();
188        assert!(matches!(m.subtype, MessageSubtype::Rej { ref id } if id == b"001"));
189    }
190
191    #[test]
192    fn bulletin() {
193        let m = AprsMessage::parse(b":BLN3     :Net at 21:00z tonight").unwrap();
194        assert!(matches!(m.subtype, MessageSubtype::Bulletin));
195    }
196
197    #[test]
198    fn nws_bulletin() {
199        let m = AprsMessage::parse(b":NWS-WARN :Tornado warning in effect").unwrap();
200        assert!(matches!(m.subtype, MessageSubtype::NwsBulletin));
201    }
202
203    #[test]
204    fn telemetry_parm() {
205        let m = AprsMessage::parse(b":KD9ABC   :PARM.Bat1,Bat2,Temp,Hum,Pres").unwrap();
206        assert!(matches!(m.subtype, MessageSubtype::TelemetryParm));
207    }
208
209    #[test]
210    fn telemetry_bits() {
211        let m = AprsMessage::parse(b":KD9ABC   :BITS.11111111,My Project").unwrap();
212        assert!(matches!(m.subtype, MessageSubtype::TelemetryBits));
213    }
214
215    #[test]
216    fn directed_query() {
217        let m = AprsMessage::parse(b":KD9ABC   :?APRSD").unwrap();
218        assert!(matches!(m.subtype, MessageSubtype::DirectedQuery));
219    }
220
221    #[test]
222    fn too_short() {
223        assert!(AprsMessage::parse(b":W1AW:hi").is_err());
224    }
225
226    #[test]
227    fn encode_round_trip_directed() {
228        let raw = b":DESTINATI:Hello World! This msg has a : colon {329A7D5Z4";
229        let m = AprsMessage::parse(raw).unwrap();
230        assert_eq!(m.encode(), raw);
231    }
232
233    #[test]
234    fn encode_round_trip_bulletin() {
235        let raw = b":BLN3     :Net at 21:00z tonight";
236        let m = AprsMessage::parse(raw).unwrap();
237        assert_eq!(m.encode(), raw);
238    }
239
240    #[test]
241    fn encode_ack() {
242        let raw = b":W1AW-9   :ack001";
243        let m = AprsMessage::parse(raw).unwrap();
244        assert_eq!(m.encode(), raw);
245    }
246}