aprs/
item.rs

1//! APRS item reports, object reports, and queries (APRS 1.0.1 ch. 11 & 15).
2
3use crate::error::AprsError;
4use crate::position::{AprsPosition, parse_compressed_body, parse_uncompressed_body};
5
6/// An APRS object report (data type `;`).
7///
8/// Objects represent entities that may not have their own radio —
9/// hurricanes, marathon runners, event locations. They include a
10/// name (9 chars), a live/killed flag, a timestamp, and a position.
11///
12/// Per User Manual Chapter 14: the TH-D75 can transmit Object
13/// information via Menu No. 550 (Object 1-3).
14#[derive(Debug, Clone, PartialEq)]
15pub struct AprsObject {
16    /// Object name (up to 9 characters).
17    pub name: String,
18    /// Whether the object is live (`true`) or killed (`false`).
19    pub live: bool,
20    /// DHM or HMS timestamp from the object report (7 characters).
21    pub timestamp: String,
22    /// Position data.
23    pub position: AprsPosition,
24}
25
26/// An APRS item report (data type `)` ).
27///
28/// Items are similar to objects but simpler — no timestamp. They
29/// represent static entities like event locations or landmarks.
30#[derive(Debug, Clone, PartialEq)]
31pub struct AprsItem {
32    /// Item name (3-9 characters).
33    pub name: String,
34    /// Whether the item is live (`true`) or killed (`false`).
35    pub live: bool,
36    /// Position data.
37    pub position: AprsPosition,
38}
39
40/// Parsed APRS query.
41///
42/// Per APRS 1.0.1 Chapter 15, queries start with `?` and allow stations
43/// to request information from other stations.
44#[derive(Debug, Clone, PartialEq, Eq, Hash)]
45pub enum AprsQuery {
46    /// Position query (`?APRSP` or `?APRS?`).
47    Position,
48    /// Status query (`?APRSS`).
49    Status,
50    /// Message query for a specific callsign (`?APRSM`).
51    Message,
52    /// Direction finding query (`?APRSD`).
53    DirectionFinding,
54    /// Weather query (`?WX`) — request latest weather fields.
55    Weather,
56    /// Telemetry query (`?APRST` or `?APRST?`).
57    Telemetry,
58    /// Ping query (`?PING?` or `?PING`).
59    Ping,
60    /// `IGate` query (`?IGATE?` or `?IGATE`).
61    IGate,
62    /// Stations-heard-on-RF query (`?APRSH`).
63    Heard,
64    /// General query with raw text (everything after the leading `?`,
65    /// not one of the well-known forms).
66    Other(String),
67}
68
69/// Parse an APRS object report (`;name_____*DDHHMMzpos...`).
70///
71/// # Errors
72///
73/// Returns [`AprsError::InvalidFormat`] if the info field is shorter
74/// than 27 bytes, is missing the leading `;`, has an invalid live/killed
75/// flag, or has malformed position data.
76pub fn parse_aprs_object(info: &[u8]) -> Result<AprsObject, AprsError> {
77    if info.first() != Some(&b';') {
78        return Err(AprsError::InvalidFormat);
79    }
80    // ; + 9-char name + * or _ + 7-char timestamp + position (≥8 bytes) = 27 min
81    let name_bytes = info.get(1..10).ok_or(AprsError::InvalidFormat)?;
82    let flag = *info.get(10).ok_or(AprsError::InvalidFormat)?;
83    let live = match flag {
84        b'*' => true,
85        b'_' => false,
86        _ => return Err(AprsError::InvalidFormat),
87    };
88
89    let name = String::from_utf8_lossy(name_bytes).trim().to_string();
90
91    // After the name and live/killed flag, there's a 7-char timestamp
92    // then position data.
93    let pos_body = info.get(11..).ok_or(AprsError::InvalidFormat)?;
94    let ts_bytes = pos_body.get(..7).ok_or(AprsError::InvalidFormat)?;
95    let timestamp = String::from_utf8_lossy(ts_bytes).to_string();
96    let pos_data = pos_body.get(7..).ok_or(AprsError::InvalidFormat)?;
97
98    let first = *pos_data.first().ok_or(AprsError::InvalidFormat)?;
99    let position = if first.is_ascii_digit() {
100        parse_uncompressed_body(pos_data)?
101    } else {
102        parse_compressed_body(pos_data)?
103    };
104
105    Ok(AprsObject {
106        name,
107        live,
108        timestamp,
109        position,
110    })
111}
112
113/// Parse an APRS item report (`)name!pos...` or `)name_pos...`).
114///
115/// Per APRS 1.0.1 Chapter 11, the name is 3-9 characters terminated by
116/// `!` (live) or `_` (killed). The name is restricted to printable
117/// ASCII excluding the terminator characters themselves.
118///
119/// # Errors
120///
121/// Returns [`AprsError::InvalidFormat`] for any violation of the spec:
122/// missing leading `)`, name outside the 3-9 character range, missing
123/// terminator, non-printable ASCII in name, or malformed position data.
124pub fn parse_aprs_item(info: &[u8]) -> Result<AprsItem, AprsError> {
125    if info.first() != Some(&b')') {
126        return Err(AprsError::InvalidFormat);
127    }
128    let body = info.get(1..).ok_or(AprsError::InvalidFormat)?;
129
130    // Scan the first 9 bytes for a terminator. Anything beyond that is
131    // outside the spec-legal range and the frame is malformed.
132    let search_len = std::cmp::min(body.len(), 9);
133    let search = body.get(..search_len).ok_or(AprsError::InvalidFormat)?;
134    let terminator_pos = search
135        .iter()
136        .position(|&b| b == b'!' || b == b'_')
137        .ok_or(AprsError::InvalidFormat)?;
138
139    // Names are 3-9 characters inclusive.
140    if terminator_pos < 3 {
141        return Err(AprsError::InvalidFormat);
142    }
143
144    let term_byte = *body.get(terminator_pos).ok_or(AprsError::InvalidFormat)?;
145    let live = term_byte == b'!';
146    let name_bytes = body.get(..terminator_pos).ok_or(AprsError::InvalidFormat)?;
147    // Reject non-printable ASCII in names.
148    if name_bytes.iter().any(|&b| !(0x20..=0x7E).contains(&b)) {
149        return Err(AprsError::InvalidFormat);
150    }
151    let name = std::str::from_utf8(name_bytes)
152        .map_err(|_| AprsError::InvalidFormat)?
153        .to_owned();
154    let pos_data = body
155        .get(terminator_pos + 1..)
156        .ok_or(AprsError::InvalidFormat)?;
157
158    let first = *pos_data.first().ok_or(AprsError::InvalidFormat)?;
159    let position = if first.is_ascii_digit() {
160        parse_uncompressed_body(pos_data)?
161    } else {
162        parse_compressed_body(pos_data)?
163    };
164
165    Ok(AprsItem {
166        name,
167        live,
168        position,
169    })
170}
171
172/// Parse an APRS query (`?APRSx` or `?text`).
173///
174/// # Errors
175///
176/// Returns [`AprsError::InvalidFormat`] if the info field does not begin
177/// with `?`.
178pub fn parse_aprs_query(info: &[u8]) -> Result<AprsQuery, AprsError> {
179    if info.first() != Some(&b'?') {
180        return Err(AprsError::InvalidFormat);
181    }
182
183    let body_bytes = info.get(1..).unwrap_or(&[]);
184    let body = String::from_utf8_lossy(body_bytes);
185    let text = body.trim_end_matches('\r');
186
187    // Standard APRS queries per APRS 1.0.1 Chapter 15.
188    match text {
189        "APRSP" | "APRS?" => Ok(AprsQuery::Position),
190        "APRSS" => Ok(AprsQuery::Status),
191        "APRSM" => Ok(AprsQuery::Message),
192        "APRSD" => Ok(AprsQuery::DirectionFinding),
193        "APRST" | "APRST?" => Ok(AprsQuery::Telemetry),
194        "APRSH" => Ok(AprsQuery::Heard),
195        "WX" => Ok(AprsQuery::Weather),
196        "PING" | "PING?" => Ok(AprsQuery::Ping),
197        "IGATE" | "IGATE?" => Ok(AprsQuery::IGate),
198        _ => Ok(AprsQuery::Other(text.to_owned())),
199    }
200}
201
202// ---------------------------------------------------------------------------
203// Tests
204// ---------------------------------------------------------------------------
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    type TestResult = Result<(), Box<dyn std::error::Error>>;
211
212    // ---- APRS object tests ----
213
214    #[test]
215    fn parse_object_live() -> TestResult {
216        let info = b";TORNADO  *092345z4903.50N/07201.75W-Tornado warning";
217        let obj = parse_aprs_object(info)?;
218        assert_eq!(obj.name, "TORNADO");
219        assert!(obj.live);
220        assert_eq!(obj.timestamp, "092345z");
221        assert!(
222            (obj.position.latitude - 49.058_333).abs() < 0.001,
223            "lat check"
224        );
225        Ok(())
226    }
227
228    #[test]
229    fn parse_object_killed() -> TestResult {
230        let info = b";MARATHON _092345z4903.50N/07201.75W-Event over";
231        let obj = parse_aprs_object(info)?;
232        assert_eq!(obj.name, "MARATHON");
233        assert!(!obj.live);
234        Ok(())
235    }
236
237    #[test]
238    fn parse_object_rejects_bad_live_flag() {
239        let info = b";TORNADO  X092345z4903.50N/07201.75W-";
240        assert!(parse_aprs_object(info).is_err(), "bad flag must error");
241    }
242
243    // ---- APRS item tests ----
244
245    #[test]
246    fn parse_item_live() -> TestResult {
247        let info = b")AID#2!4903.50N/07201.75W-First aid";
248        let item = parse_aprs_item(info)?;
249        assert_eq!(item.name, "AID#2");
250        assert!(item.live);
251        assert!(
252            (item.position.latitude - 49.058_333).abs() < 0.001,
253            "lat check"
254        );
255        Ok(())
256    }
257
258    #[test]
259    fn parse_item_killed() -> TestResult {
260        let info = b")AID#2_4903.50N/07201.75W-Closed";
261        let item = parse_aprs_item(info)?;
262        assert!(!item.live);
263        Ok(())
264    }
265
266    #[test]
267    fn parse_item_short_name_rejected() {
268        // "AB" is only 2 characters — APRS101 requires 3-9.
269        let info = b")AB!4903.50N/07201.75W-";
270        assert!(
271            matches!(parse_aprs_item(info), Err(AprsError::InvalidFormat)),
272            "short name must be rejected",
273        );
274    }
275
276    // ---- APRS query tests ----
277
278    #[test]
279    fn parse_query_position_aprsp() -> TestResult {
280        assert_eq!(parse_aprs_query(b"?APRSP")?, AprsQuery::Position);
281        Ok(())
282    }
283
284    #[test]
285    fn parse_query_position_aprs_question() -> TestResult {
286        assert_eq!(parse_aprs_query(b"?APRS?")?, AprsQuery::Position);
287        Ok(())
288    }
289
290    #[test]
291    fn parse_query_status() -> TestResult {
292        assert_eq!(parse_aprs_query(b"?APRSS")?, AprsQuery::Status);
293        Ok(())
294    }
295
296    #[test]
297    fn parse_query_message() -> TestResult {
298        assert_eq!(parse_aprs_query(b"?APRSM")?, AprsQuery::Message);
299        Ok(())
300    }
301
302    #[test]
303    fn parse_query_direction_finding() -> TestResult {
304        assert_eq!(parse_aprs_query(b"?APRSD")?, AprsQuery::DirectionFinding);
305        Ok(())
306    }
307
308    #[test]
309    fn parse_query_igate() -> TestResult {
310        assert_eq!(parse_aprs_query(b"?IGATE")?, AprsQuery::IGate);
311        Ok(())
312    }
313
314    #[test]
315    fn parse_query_ping() -> TestResult {
316        assert_eq!(parse_aprs_query(b"?PING?")?, AprsQuery::Ping);
317        Ok(())
318    }
319
320    #[test]
321    fn parse_query_weather() -> TestResult {
322        assert_eq!(parse_aprs_query(b"?WX")?, AprsQuery::Weather);
323        Ok(())
324    }
325
326    #[test]
327    fn parse_query_telemetry() -> TestResult {
328        assert_eq!(parse_aprs_query(b"?APRST")?, AprsQuery::Telemetry);
329        Ok(())
330    }
331
332    #[test]
333    fn parse_query_heard() -> TestResult {
334        assert_eq!(parse_aprs_query(b"?APRSH")?, AprsQuery::Heard);
335        Ok(())
336    }
337
338    #[test]
339    fn parse_query_other() -> TestResult {
340        assert_eq!(
341            parse_aprs_query(b"?FOOBAR")?,
342            AprsQuery::Other("FOOBAR".to_owned())
343        );
344        Ok(())
345    }
346
347    #[test]
348    fn parse_query_with_trailing_cr() -> TestResult {
349        assert_eq!(parse_aprs_query(b"?APRSP\r")?, AprsQuery::Position);
350        Ok(())
351    }
352}