aprs/
message.rs

1//! APRS message packets and ack/rej classification (APRS 1.0.1 ch. 14).
2
3use crate::error::AprsError;
4use crate::packet::MessageKind;
5
6/// Maximum APRS message text length in bytes (APRS 1.0.1 §14).
7pub const MAX_APRS_MESSAGE_TEXT_LEN: usize = 67;
8
9/// An APRS message (data type `:`) addressed to a specific station or
10/// group.
11///
12/// Format: `:ADDRESSEE:message text{ID` or, with the APRS 1.2 reply-ack
13/// extension, `:ADDRESSEE:message text{MM}AA` where `MM` is this
14/// message's ID and `AA` is an ack for a previously-received message.
15/// - Addressee is exactly 9 characters, space-padded.
16/// - Message text follows the second `:`.
17/// - Optional message ID after `{` (for ack/rej).
18/// - Optional reply-ack after `}` (APRS 1.2).
19///
20/// The TH-D75 displays received messages on-screen and can store
21/// up to 100 messages in the station list.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct AprsMessage {
24    /// Destination callsign (up to 9 chars, trimmed).
25    pub addressee: String,
26    /// Message text content.
27    pub text: String,
28    /// Optional message sequence number (for ack/rej tracking).
29    pub message_id: Option<String>,
30    /// Optional APRS 1.2 reply-ack: when the sender bundles an
31    /// acknowledgement for a previously-received message into a new
32    /// outgoing message. Format on wire is `{MM}AA` where `AA` is the
33    /// acknowledged msgno.
34    pub reply_ack: Option<String>,
35}
36
37impl AprsMessage {
38    /// Classify this message by addressee / text pattern per APRS 1.0.1
39    /// §14 and bulletin conventions.
40    #[must_use]
41    pub fn kind(&self) -> MessageKind {
42        let addr = self.addressee.trim();
43        // Check ack/rej on text first — control frames use a regular
44        // addressee.
45        if classify_ack_rej(&self.text).is_some() {
46            return MessageKind::AckRej;
47        }
48        // NWS bulletins use well-known prefixes.
49        if addr.starts_with("NWS-")
50            || addr.starts_with("SKY-")
51            || addr.starts_with("CWA-")
52            || addr.starts_with("BOM-")
53        {
54            return MessageKind::NwsBulletin;
55        }
56        // Numeric bulletin: BLN0-BLN9 exactly.
57        if let Some(rest) = addr.strip_prefix("BLN") {
58            if rest.len() == 1
59                && let Some(n) = rest.bytes().next()
60                && n.is_ascii_digit()
61            {
62                return MessageKind::Bulletin { number: n - b'0' };
63            }
64            // Group bulletin: BLN<group> where group is 1-5 alnum.
65            if (1..=5).contains(&rest.len()) && rest.bytes().all(|b| b.is_ascii_alphanumeric()) {
66                return MessageKind::GroupBulletin {
67                    group: rest.to_owned(),
68                };
69            }
70        }
71        MessageKind::Direct
72    }
73}
74
75/// Classify a message text as an APRS ack or rej control frame.
76///
77/// Per APRS 1.0.1 §14, ack and rej frames have text of the exact form
78/// `ack<id>` or `rej<id>` where `<id>` is 1-5 alphanumeric characters and
79/// nothing follows. This helper avoids the false-positive that naive
80/// `starts_with("ack")` matching would produce for legitimate messages
81/// that happen to begin with those letters (e.g. "acknowledge receipt").
82///
83/// Returns `Some((is_ack, message_id))` for control frames, `None`
84/// otherwise. `is_ack` is `true` for `ack`, `false` for `rej`.
85#[must_use]
86pub fn classify_ack_rej(text: &str) -> Option<(bool, &str)> {
87    let trimmed = text.trim_end_matches(['\r', '\n', ' ']);
88    let (is_ack, rest) = if let Some(rest) = trimmed.strip_prefix("ack") {
89        (true, rest)
90    } else if let Some(rest) = trimmed.strip_prefix("rej") {
91        (false, rest)
92    } else {
93        return None;
94    };
95    if !(1..=5).contains(&rest.len()) {
96        return None;
97    }
98    if !rest.bytes().all(|b| b.is_ascii_alphanumeric()) {
99        return None;
100    }
101    Some((is_ack, rest))
102}
103
104/// Parse an APRS message (`:ADDRESSEE:text{id`).
105///
106/// # Errors
107///
108/// Returns [`AprsError::InvalidFormat`] for malformed input: missing
109/// leading `:`, absence of the second `:`, or addressee shorter than 9
110/// characters.
111pub fn parse_aprs_message(info: &[u8]) -> Result<AprsMessage, AprsError> {
112    // Minimum: : + 9 char addressee + : + at least 0 text = 11 bytes
113    if info.first() != Some(&b':') {
114        return Err(AprsError::InvalidFormat);
115    }
116
117    // Addressee is exactly 9 characters (space-padded)
118    let addressee_raw = info.get(1..10).ok_or(AprsError::InvalidFormat)?;
119    let addressee = String::from_utf8_lossy(addressee_raw).trim().to_string();
120
121    if info.get(10) != Some(&b':') {
122        return Err(AprsError::InvalidFormat);
123    }
124
125    let body = info.get(11..).unwrap_or(&[]);
126    let body_str = String::from_utf8_lossy(body).into_owned();
127    let trimmed_body = body_str.trim_end_matches(['\r', '\n']);
128
129    // Split on `{` for message ID and APRS 1.2 reply-ack extension.
130    // Three possible trailer forms to recognise (checked from richest):
131    //   1. `text{MM}AA`  — reply-ack: MM is this msg's id, AA is ack
132    //   2. `text{MM`     — plain message id
133    //   3. `text`        — no trailer
134    let (text, message_id, reply_ack) = parse_message_trailer(trimmed_body);
135
136    Ok(AprsMessage {
137        addressee,
138        text,
139        message_id,
140        reply_ack,
141    })
142}
143
144/// Parse the optional `{MM}AA` / `{MM` trailer of an APRS message body.
145///
146/// Returns `(text, message_id, reply_ack)` where the latter two are
147/// `Some` only when the trailer has the exact well-formed shape.
148fn parse_message_trailer(body: &str) -> (String, Option<String>, Option<String>) {
149    let Some(brace_idx) = body.rfind('{') else {
150        return (body.to_owned(), None, None);
151    };
152    let Some(after_brace) = body.get(brace_idx + 1..) else {
153        return (body.to_owned(), None, None);
154    };
155    let Some(prefix) = body.get(..brace_idx) else {
156        return (body.to_owned(), None, None);
157    };
158
159    // APRS 1.2 reply-ack: `MM}AA` where MM is 1-5 alnum and AA is 1-5 alnum.
160    if let Some(close_idx) = after_brace.find('}') {
161        let mm = after_brace.get(..close_idx).unwrap_or("");
162        let aa = after_brace.get(close_idx + 1..).unwrap_or("");
163        if (1..=5).contains(&mm.len())
164            && mm.bytes().all(|b| b.is_ascii_alphanumeric())
165            && (1..=5).contains(&aa.len())
166            && aa.bytes().all(|b| b.is_ascii_alphanumeric())
167        {
168            return (prefix.to_owned(), Some(mm.to_owned()), Some(aa.to_owned()));
169        }
170    }
171
172    // Plain message id: `MM` (1-5 alnum, end of string).
173    if (1..=5).contains(&after_brace.len())
174        && after_brace.bytes().all(|b| b.is_ascii_alphanumeric())
175    {
176        return (prefix.to_owned(), Some(after_brace.to_owned()), None);
177    }
178
179    // Neither pattern matched — treat whole body as plain text.
180    (body.to_owned(), None, None)
181}
182
183// ---------------------------------------------------------------------------
184// Tests
185// ---------------------------------------------------------------------------
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    type TestResult = Result<(), Box<dyn std::error::Error>>;
192
193    // ---- Message parsing ----
194
195    #[test]
196    fn parse_message_basic() -> TestResult {
197        let info = b":N0CALL   :Hello World{123";
198        let msg = parse_aprs_message(info)?;
199        assert_eq!(msg.addressee, "N0CALL");
200        assert_eq!(msg.text, "Hello World");
201        assert_eq!(msg.message_id, Some("123".to_string()));
202        Ok(())
203    }
204
205    #[test]
206    fn parse_message_no_id() -> TestResult {
207        let info = b":KQ4NIT   :Test message";
208        let msg = parse_aprs_message(info)?;
209        assert_eq!(msg.addressee, "KQ4NIT");
210        assert_eq!(msg.text, "Test message");
211        assert_eq!(msg.message_id, None);
212        Ok(())
213    }
214
215    #[test]
216    fn parse_message_ack() -> TestResult {
217        let info = b":N0CALL   :ack123";
218        let msg = parse_aprs_message(info)?;
219        assert_eq!(msg.text, "ack123");
220        Ok(())
221    }
222
223    #[test]
224    fn parse_message_too_short() {
225        assert!(
226            parse_aprs_message(b":SHORT:hi").is_err(),
227            "short input rejected",
228        );
229    }
230
231    #[test]
232    fn parse_message_does_not_misinterpret_brace_in_text() -> TestResult {
233        // Regression: "reply {soon}" should NOT produce message_id="soon}"
234        // because "soon}" is not 1-5 alphanumerics at end of string.
235        let info = b":N0CALL   :reply {soon}";
236        let msg = parse_aprs_message(info)?;
237        assert_eq!(msg.text, "reply {soon}");
238        assert_eq!(msg.message_id, None);
239        Ok(())
240    }
241
242    #[test]
243    fn parse_message_accepts_valid_id_with_text_containing_brace() -> TestResult {
244        let info = b":N0CALL   :json {foo}{42";
245        let msg = parse_aprs_message(info)?;
246        assert_eq!(msg.text, "json {foo}");
247        assert_eq!(msg.message_id, Some("42".to_owned()));
248        Ok(())
249    }
250
251    #[test]
252    fn parse_message_reply_ack() -> TestResult {
253        let info = b":N0CALL   :Hello back{3}7";
254        let msg = parse_aprs_message(info)?;
255        assert_eq!(msg.text, "Hello back");
256        assert_eq!(msg.message_id, Some("3".to_owned()));
257        assert_eq!(msg.reply_ack, Some("7".to_owned()));
258        Ok(())
259    }
260
261    #[test]
262    fn parse_message_plain_id_no_reply_ack() -> TestResult {
263        let info = b":N0CALL   :Hello{3";
264        let msg = parse_aprs_message(info)?;
265        assert_eq!(msg.text, "Hello");
266        assert_eq!(msg.message_id, Some("3".to_owned()));
267        assert_eq!(msg.reply_ack, None);
268        Ok(())
269    }
270
271    // ---- MessageKind classification ----
272
273    #[test]
274    fn message_kind_direct() {
275        let msg = AprsMessage {
276            addressee: "N0CALL".to_owned(),
277            text: "hello".to_owned(),
278            message_id: None,
279            reply_ack: None,
280        };
281        assert_eq!(msg.kind(), MessageKind::Direct);
282    }
283
284    #[test]
285    fn message_kind_numeric_bulletin() {
286        let msg = AprsMessage {
287            addressee: "BLN3".to_owned(),
288            text: "event update".to_owned(),
289            message_id: None,
290            reply_ack: None,
291        };
292        assert_eq!(msg.kind(), MessageKind::Bulletin { number: 3 });
293    }
294
295    #[test]
296    fn message_kind_group_bulletin() {
297        let msg = AprsMessage {
298            addressee: "BLNWX".to_owned(),
299            text: "weather watch".to_owned(),
300            message_id: None,
301            reply_ack: None,
302        };
303        assert_eq!(
304            msg.kind(),
305            MessageKind::GroupBulletin {
306                group: "WX".to_owned()
307            }
308        );
309    }
310
311    #[test]
312    fn message_kind_nws_bulletin() {
313        let msg = AprsMessage {
314            addressee: "NWS-TOR".to_owned(),
315            text: "tornado warning".to_owned(),
316            message_id: None,
317            reply_ack: None,
318        };
319        assert_eq!(msg.kind(), MessageKind::NwsBulletin);
320    }
321
322    #[test]
323    fn message_kind_ack_rej_frame() {
324        let msg = AprsMessage {
325            addressee: "N0CALL".to_owned(),
326            text: "ack42".to_owned(),
327            message_id: None,
328            reply_ack: None,
329        };
330        assert_eq!(msg.kind(), MessageKind::AckRej);
331    }
332
333    // ---- classify_ack_rej ----
334
335    #[test]
336    fn classify_ack_rej_ack_numeric() {
337        assert_eq!(classify_ack_rej("ack42"), Some((true, "42")));
338        assert_eq!(classify_ack_rej("ack1"), Some((true, "1")));
339        assert_eq!(classify_ack_rej("ack12345"), Some((true, "12345")));
340        assert_eq!(classify_ack_rej("ackABC"), Some((true, "ABC")));
341    }
342
343    #[test]
344    fn classify_ack_rej_rej() {
345        assert_eq!(classify_ack_rej("rej42"), Some((false, "42")));
346        assert_eq!(classify_ack_rej("rej1"), Some((false, "1")));
347    }
348
349    #[test]
350    fn classify_ack_rej_rejects_non_control() {
351        assert_eq!(classify_ack_rej("acknowledge receipt"), None);
352        assert_eq!(classify_ack_rej("rejection letter"), None);
353        assert_eq!(classify_ack_rej("ack with space"), None);
354    }
355
356    #[test]
357    fn classify_ack_rej_rejects_overlong_id() {
358        assert_eq!(classify_ack_rej("ack123456"), None);
359    }
360
361    #[test]
362    fn classify_ack_rej_rejects_empty_id() {
363        assert_eq!(classify_ack_rej("ack"), None);
364        assert_eq!(classify_ack_rej("rej"), None);
365    }
366
367    #[test]
368    fn classify_ack_rej_rejects_non_alnum() {
369        assert_eq!(classify_ack_rej("ack-12"), None);
370        assert_eq!(classify_ack_rej("ack 42"), None);
371    }
372
373    #[test]
374    fn classify_ack_rej_strips_trailing_whitespace() {
375        assert_eq!(classify_ack_rej("ack42\r\n"), Some((true, "42")));
376    }
377}