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 { detail: "packet too short" });
40        }
41
42        let mut name = info[1..10].to_vec();
43        trim_spaces_end(&mut name);
44
45        let live = match info[10] {
46            b'*' => true,
47            b'_' | b' ' => false, // spec says `_`, space is a common variant
48            _ => return Err(AprsError::InvalidObject { detail: "invalid liveness byte" }),
49        };
50
51        let timestamp = Timestamp::parse(&info[11..18])?;
52
53        let position_bytes = info.get(18..)
54            .ok_or(AprsError::InvalidObject { detail: "truncated after timestamp" })?;
55
56        let (remaining, position) = Position::parse(position_bytes)?;
57        let comment_raw = remaining.unwrap_or_default();
58
59        let (extension, comment) = if position.compressed_cs.is_none() {
60            // For uncompressed positions, try to parse an extension from the comment
61            if let Some(ext) = Extension::parse(comment_raw) {
62                (Some(ext), comment_raw.get(7..).unwrap_or_default().to_vec())
63            } else {
64                (None, comment_raw.to_vec())
65            }
66        } else {
67            (None, comment_raw.to_vec())
68        };
69
70        let frequency_mhz = crate::util::extract_frequency_mhz(&comment);
71        Ok(Self { name, live, timestamp, position, extension, frequency_mhz, comment })
72    }
73
74    pub fn encode(&self) -> Vec<u8> {
75        let mut out = vec![b';'];
76        out.extend_from_slice(&self.name);
77        out.extend(std::iter::repeat_n(b' ', 9usize.saturating_sub(self.name.len())));
78        out.push(if self.live { b'*' } else { b'_' });
79        self.timestamp.encode(&mut out);
80
81        if self.extension.is_some() || self.position.compressed_cs.is_none() {
82            self.position.encode_uncompressed(&mut out);
83            if let Some(ref ext) = self.extension {
84                ext.encode(&mut out);
85            }
86        } else {
87            self.position.encode_compressed(&mut out);
88        }
89
90        out.extend_from_slice(&self.comment);
91        out
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use approx::assert_relative_eq;
99
100    const LIVE_OBJ: &[u8] =
101        b";HFEST-18H*170403z3443.55N\\08635.47Wh146.940MHz T100 Huntsville Hamfest";
102
103    #[test]
104    fn parse_live_object() {
105        let o = AprsObject::parse(LIVE_OBJ).unwrap();
106        assert_eq!(o.name, b"HFEST-18H");
107        assert!(o.live);
108        assert_eq!(o.timestamp, Timestamp::Ddhhmm(17, 4, 3));
109        assert_relative_eq!(o.position.latitude.value(), 34.725833333333334, epsilon = 1e-9);
110        assert_relative_eq!(o.position.longitude.value(), -86.59116666666667, epsilon = 1e-9);
111        assert_eq!(o.position.symbol.table, '\\');
112        assert_eq!(o.position.symbol.code, 'h');
113        assert_eq!(o.comment, b"146.940MHz T100 Huntsville Hamfest");
114    }
115
116    #[test]
117    fn parse_dead_object_space_liveness() {
118        // Some encoders use space instead of _ for killed; accept both
119        let o = AprsObject::parse(b";HFEST     170403z3443.55N\\08635.47Wh").unwrap();
120        assert_eq!(o.name, b"HFEST");
121        assert!(!o.live);
122    }
123
124    #[test]
125    fn parse_dead_object_underscore_liveness() {
126        let o = AprsObject::parse(b";HFEST    _170403z3443.55N\\08635.47Wh").unwrap();
127        assert_eq!(o.name, b"HFEST");
128        assert!(!o.live);
129    }
130
131    #[test]
132    fn parse_with_extension() {
133        let o = AprsObject::parse(
134            b";HFEST    _170403z3443.55N\\08635.47WhPHG5132Comment"
135        ).unwrap();
136        assert!(o.extension.is_some());
137    }
138
139    #[test]
140    fn parse_compressed_object() {
141        let o = AprsObject::parse(
142            b";CAR      _092345z/5L!!<*e7>7P[Moving to the north"
143        ).unwrap();
144        assert_eq!(o.name, b"CAR");
145        assert!(!o.live);
146        assert_relative_eq!(o.position.latitude.value(), 49.5, epsilon = 0.01);
147        assert_eq!(o.comment, b"Moving to the north");
148    }
149
150    #[test]
151    fn encode_round_trip_live() {
152        // Our encoder uses `_` for dead and `*` for live per spec
153        let o = AprsObject::parse(LIVE_OBJ).unwrap();
154        assert_eq!(o.encode(), LIVE_OBJ);
155    }
156
157    #[test]
158    fn timestamp_validates_strictly() {
159        // Day 0 is invalid
160        assert!(AprsObject::parse(
161            b";HFEST-18H*002345z3443.55N\\08635.47Wh"
162        ).is_err());
163    }
164}