Skip to main content

aprs_decode/
item.rs

1use crate::error::AprsError;
2use crate::types::{Extension, Position};
3
4/// An APRS Item Report.
5///
6/// DTI: `)`
7///
8/// Items are similar to objects but lack a timestamp and have a variable-length
9/// name (3–9 characters). They are intended for inanimate things reported
10/// occasionally on a map (checkpoints, aid posts, etc.).
11#[derive(Debug, Clone, PartialEq)]
12#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
13pub struct AprsItem {
14    /// Item name (3–9 chars; any char except `!` and ` `).
15    pub name: Vec<u8>,
16    /// `true` if the item is live/active (`!`); `false` if killed (` `).
17    pub live: bool,
18    pub position: Position,
19    /// Optional data extension (course/speed, PHG, RNG, DFS).
20    pub extension: Option<Extension>,
21    pub comment: Vec<u8>,
22}
23
24impl AprsItem {
25    /// Decode from the information field (including the leading `)` DTI byte).
26    pub(crate) fn parse(info: &[u8]) -> Result<Self, AprsError> {
27        // Format: )NAME...!latSymlonSymComment   (live)
28        //         )NAME... latSymlonSymComment   (killed)
29        // NAME is 3-9 chars, terminated by `!` (live) or ` ` (killed)
30        if info.len() < 5 {
31            return Err(AprsError::InvalidItem {
32                detail: "packet too short",
33            });
34        }
35
36        // Collect name bytes (starting at index 1, after `)`)
37        let mut name = Vec::with_capacity(9);
38        let mut liveness_idx = None;
39
40        for (i, &b) in info.iter().enumerate().skip(1).take(9) {
41            if b == b'!' || b == b' ' {
42                liveness_idx = Some(i);
43                break;
44            }
45            name.push(b);
46        }
47
48        if name.len() < 3 {
49            return Err(AprsError::InvalidItem {
50                detail: "name too short (< 3 chars)",
51            });
52        }
53
54        let liveness_idx = liveness_idx.ok_or(AprsError::InvalidItem {
55            detail: "liveness byte not found",
56        })?;
57
58        let live = match info[liveness_idx] {
59            b'!' => true,
60            b' ' | b'_' => false,
61            _ => {
62                return Err(AprsError::InvalidItem {
63                    detail: "invalid liveness byte",
64                });
65            }
66        };
67
68        let position_bytes = info.get(liveness_idx + 1..).ok_or(AprsError::InvalidItem {
69            detail: "truncated after liveness",
70        })?;
71
72        let (remaining, position) = Position::parse(position_bytes)?;
73        let comment_raw = remaining.unwrap_or_default();
74
75        let (extension, comment) = if position.compressed_cs.is_none() {
76            if let Some(ext) = Extension::parse(comment_raw) {
77                (Some(ext), comment_raw.get(7..).unwrap_or_default().to_vec())
78            } else {
79                (None, comment_raw.to_vec())
80            }
81        } else {
82            (None, comment_raw.to_vec())
83        };
84
85        Ok(Self {
86            name,
87            live,
88            position,
89            extension,
90            comment,
91        })
92    }
93
94    pub fn encode(&self) -> Vec<u8> {
95        let mut out = vec![b')'];
96        out.extend_from_slice(&self.name);
97        out.push(if self.live { b'!' } else { b' ' });
98
99        if self.extension.is_some() || self.position.compressed_cs.is_none() {
100            self.position.encode_uncompressed(&mut out);
101            if let Some(ref ext) = self.extension {
102                ext.encode(&mut out);
103            }
104        } else {
105            self.position.encode_compressed(&mut out);
106        }
107
108        out.extend_from_slice(&self.comment);
109        out
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use approx::assert_relative_eq;
117
118    #[test]
119    fn parse_live_item() {
120        let item = AprsItem::parse(b")AIDV#2!4903.50N/07201.75WA").unwrap();
121        assert_eq!(item.name, b"AIDV#2");
122        assert!(item.live);
123        assert_relative_eq!(
124            item.position.latitude.value(),
125            49.05833333333333,
126            epsilon = 1e-9
127        );
128        assert_relative_eq!(
129            item.position.longitude.value(),
130            -72.02916666666667,
131            epsilon = 1e-9
132        );
133        assert_eq!(item.position.symbol.table, '/');
134        assert_eq!(item.position.symbol.code, 'A');
135    }
136
137    #[test]
138    fn parse_dead_item() {
139        let item = AprsItem::parse(b")AID 4903.50N/07201.75WA").unwrap();
140        assert_eq!(item.name, b"AID");
141        assert!(!item.live);
142    }
143
144    #[test]
145    fn parse_with_extension() {
146        let item = AprsItem::parse(b")AID 4903.50N/07201.75WAPHG5132").unwrap();
147        assert!(item.extension.is_some());
148        assert!(item.comment.is_empty());
149    }
150
151    #[test]
152    fn parse_compressed_item() {
153        let item = AprsItem::parse(b")MOBIL!\\5L!!<*e79 sT").unwrap();
154        assert_eq!(item.name, b"MOBIL");
155        assert!(item.live);
156        assert_relative_eq!(item.position.latitude.value(), 49.5, epsilon = 0.01);
157    }
158
159    #[test]
160    fn name_too_short() {
161        assert!(AprsItem::parse(b")AB!4903.50N/07201.75WA").is_err());
162    }
163
164    #[test]
165    fn encode_round_trip_live() {
166        let raw = b")AIDV#2!4903.50N/07201.75WA";
167        let item = AprsItem::parse(raw).unwrap();
168        assert_eq!(item.encode(), raw);
169    }
170
171    #[test]
172    fn encode_round_trip_dead() {
173        let raw = b")AID 4903.50N/07201.75WA";
174        let item = AprsItem::parse(raw).unwrap();
175        assert_eq!(item.encode(), raw);
176    }
177
178    #[test]
179    fn encode_round_trip_with_extension() {
180        let raw = b")AID 4903.50N/07201.75WAPHG5132";
181        let item = AprsItem::parse(raw).unwrap();
182        assert_eq!(item.encode(), raw);
183    }
184}