Skip to main content

aprs_decode/
object.rs

1use crate::error::AprsError;
2use crate::types::{Extension, Position, Timestamp};
3use crate::util::trim_spaces_end;
4
5/// An APRS Object Report.
6///
7/// DTI: `;`
8///
9/// Objects have a fixed 9-character name, a mandatory timestamp, and a position.
10/// They are used to report the location of things other than the sending station
11/// (storms, marathons, spacecraft, etc.).
12#[derive(Debug, Clone, PartialEq)]
13#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
14pub struct AprsObject {
15    /// Object name, trimmed of trailing spaces (original APRS format pads to 9 chars).
16    pub name: Vec<u8>,
17    /// `true` if the object is live/active; `false` if it has been killed/removed.
18    pub live: bool,
19    /// Mandatory timestamp.
20    pub timestamp: Timestamp,
21    pub position: Position,
22    /// Optional data extension (course/speed, PHG, RNG, DFS) from the comment field.
23    pub extension: Option<Extension>,
24    /// Frequency in MHz extracted from the comment field, if present.
25    pub frequency_mhz: Option<f32>,
26    pub comment: Vec<u8>,
27}
28
29impl AprsObject {
30    /// Decode from the information field (including the leading `;` DTI byte).
31    pub(crate) fn parse(info: &[u8]) -> Result<Self, AprsError> {
32        // Format: ;NNNNNNNNN*DDHHMMzLatSymLonSymComment
33        //          ^ byte 0 = DTI ';'
34        //           123456789  = 9-byte name (bytes 1-9)
35        //                    ^ byte 10 = liveness (* or _)
36        //                     1234567 = 7-byte timestamp (bytes 11-17)
37        //                            ...position + comment
38        if info.len() < 18 {
39            return Err(AprsError::InvalidObject {
40                detail: "packet too short",
41            });
42        }
43
44        let mut name = info[1..10].to_vec();
45        trim_spaces_end(&mut name);
46
47        let live = match info[10] {
48            b'*' => true,
49            b'_' | b' ' => false, // spec says `_`, space is a common variant
50            _ => {
51                return Err(AprsError::InvalidObject {
52                    detail: "invalid liveness byte",
53                });
54            }
55        };
56
57        let timestamp = Timestamp::parse(&info[11..18])?;
58
59        let position_bytes = info.get(18..).ok_or(AprsError::InvalidObject {
60            detail: "truncated after timestamp",
61        })?;
62
63        let (remaining, position) = Position::parse(position_bytes)?;
64        let comment_raw = remaining.unwrap_or_default();
65
66        let (extension, comment) = if position.compressed_cs.is_none() {
67            // For uncompressed positions, try to parse an extension from the comment
68            if let Some(ext) = Extension::parse(comment_raw) {
69                (Some(ext), comment_raw.get(7..).unwrap_or_default().to_vec())
70            } else {
71                (None, comment_raw.to_vec())
72            }
73        } else {
74            (None, comment_raw.to_vec())
75        };
76
77        let frequency_mhz = crate::util::extract_frequency_mhz(&comment);
78        Ok(Self {
79            name,
80            live,
81            timestamp,
82            position,
83            extension,
84            frequency_mhz,
85            comment,
86        })
87    }
88
89    pub fn encode(&self) -> Vec<u8> {
90        let mut out = vec![b';'];
91        out.extend_from_slice(&self.name);
92        out.extend(std::iter::repeat_n(
93            b' ',
94            9usize.saturating_sub(self.name.len()),
95        ));
96        out.push(if self.live { b'*' } else { b'_' });
97        self.timestamp.encode(&mut out);
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    const LIVE_OBJ: &[u8] =
119        b";HFEST-18H*170403z3443.55N\\08635.47Wh146.940MHz T100 Huntsville Hamfest";
120
121    #[test]
122    fn parse_live_object() {
123        let o = AprsObject::parse(LIVE_OBJ).unwrap();
124        assert_eq!(o.name, b"HFEST-18H");
125        assert!(o.live);
126        assert_eq!(o.timestamp, Timestamp::Ddhhmm(17, 4, 3));
127        assert_relative_eq!(
128            o.position.latitude.value(),
129            34.725833333333334,
130            epsilon = 1e-9
131        );
132        assert_relative_eq!(
133            o.position.longitude.value(),
134            -86.59116666666667,
135            epsilon = 1e-9
136        );
137        assert_eq!(o.position.symbol.table, '\\');
138        assert_eq!(o.position.symbol.code, 'h');
139        assert_eq!(o.comment, b"146.940MHz T100 Huntsville Hamfest");
140    }
141
142    #[test]
143    fn parse_dead_object_space_liveness() {
144        // Some encoders use space instead of _ for killed; accept both
145        let o = AprsObject::parse(b";HFEST     170403z3443.55N\\08635.47Wh").unwrap();
146        assert_eq!(o.name, b"HFEST");
147        assert!(!o.live);
148    }
149
150    #[test]
151    fn parse_dead_object_underscore_liveness() {
152        let o = AprsObject::parse(b";HFEST    _170403z3443.55N\\08635.47Wh").unwrap();
153        assert_eq!(o.name, b"HFEST");
154        assert!(!o.live);
155    }
156
157    #[test]
158    fn parse_with_extension() {
159        let o = AprsObject::parse(b";HFEST    _170403z3443.55N\\08635.47WhPHG5132Comment").unwrap();
160        assert!(o.extension.is_some());
161    }
162
163    #[test]
164    fn parse_compressed_object() {
165        let o = AprsObject::parse(b";CAR      _092345z/5L!!<*e7>7P[Moving to the north").unwrap();
166        assert_eq!(o.name, b"CAR");
167        assert!(!o.live);
168        assert_relative_eq!(o.position.latitude.value(), 49.5, epsilon = 0.01);
169        assert_eq!(o.comment, b"Moving to the north");
170    }
171
172    #[test]
173    fn encode_round_trip_live() {
174        // Our encoder uses `_` for dead and `*` for live per spec
175        let o = AprsObject::parse(LIVE_OBJ).unwrap();
176        assert_eq!(o.encode(), LIVE_OBJ);
177    }
178
179    #[test]
180    fn timestamp_validates_strictly() {
181        // Day 0 is invalid
182        assert!(AprsObject::parse(b";HFEST-18H*002345z3443.55N\\08635.47Wh").is_err());
183    }
184}