Skip to main content

aprs_decode/types/
extensions.rs

1use crate::error::AprsError;
2use crate::util::parse_bytes;
3
4/// Antenna directivity.
5#[derive(Debug, Clone, PartialEq, Eq)]
6#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
7pub enum Directivity {
8    Omni,
9    /// Direction in degrees (multiples of 45°).
10    Degrees(u16),
11}
12
13impl Directivity {
14    fn from_digit(d: u8) -> Option<Self> {
15        if d == 0 {
16            return Some(Directivity::Omni);
17        }
18        if d < 9 {
19            return Some(Directivity::Degrees(d as u16 * 45));
20        }
21        None
22    }
23
24    fn as_digit(&self) -> u8 {
25        match self {
26            Directivity::Omni => 0,
27            Directivity::Degrees(deg) => ((deg % 360) / 45) as u8,
28        }
29    }
30}
31
32/// A data extension field that follows the position in the comment field.
33///
34/// Extensions occupy exactly 7 bytes and are encoded in a fixed format that
35/// depends on the extension type. Unknown or malformed extensions are discarded
36/// (the caller treats the entire comment field as the comment in that case).
37#[derive(Debug, Clone, PartialEq)]
38#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
39pub enum Extension {
40    /// Course (degrees) and speed (knots). Format: `DDD/SSS`.
41    DirectionSpeed {
42        direction_degrees: u16,
43        speed_knots: u16,
44    },
45    /// Power-Height-Gain-Directivity. Format: `PHGphgd`.
46    Phg {
47        power_watts: u32,
48        antenna_height_feet: u32,
49        antenna_gain_db: u8,
50        directivity: Directivity,
51    },
52    /// Pre-calculated radio range. Format: `RNGrrrr`.
53    Rng { range_miles: u16 },
54    /// DF Strength-Height-Gain-Directivity. Format: `DFSshgd`.
55    Dfs {
56        s_points: u8,
57        antenna_height_feet: u32,
58        antenna_gain_db: u8,
59        directivity: Directivity,
60    },
61}
62
63impl Extension {
64    /// Attempt to parse the first 7 bytes of `data` as an extension field.
65    /// Returns `None` if unrecognized or malformed.
66    pub fn parse(data: &[u8]) -> Option<Self> {
67        if data.len() < 7 {
68            return None;
69        }
70        let b = &data[..7];
71
72        // Course/Speed: `DDD/SSS` — three digits, slash, three digits
73        if b[3] == b'/'
74            && b[..3].iter().all(|c| c.is_ascii_digit())
75            && b[4..7].iter().all(|c| c.is_ascii_digit())
76        {
77            let dir: u16 = parse_bytes(&b[0..3])?;
78            let spd: u16 = parse_bytes(&b[4..7])?;
79            // direction 000 means unknown/not applicable (valid)
80            return Some(Extension::DirectionSpeed {
81                direction_degrees: dir,
82                speed_knots: spd,
83            });
84        }
85
86        // PHG: `PHGphgd`
87        if b.starts_with(b"PHG")
88            && b[3].is_ascii_digit()
89            && b[4].is_ascii_digit()
90            && b[5].is_ascii_digit()
91            && b[6].is_ascii_digit()
92        {
93            let p = b[3] - b'0';
94            let h = b[4]; // encoded as ASCII
95            let g = b[5] - b'0';
96            let d = b[6] - b'0';
97            let power_watts = (p as u32) * (p as u32);
98            let antenna_height_feet = 10 * (1u32 << (h.saturating_sub(48) as u32));
99            let directivity = Directivity::from_digit(d)?;
100            return Some(Extension::Phg {
101                power_watts,
102                antenna_height_feet,
103                antenna_gain_db: g,
104                directivity,
105            });
106        }
107
108        // RNG: `RNGrrrr`
109        if b.starts_with(b"RNG") && b[3..7].iter().all(|c| c.is_ascii_digit()) {
110            let range: u16 = parse_bytes(&b[3..7])?;
111            return Some(Extension::Rng { range_miles: range });
112        }
113
114        // DFS: `DFSshgd`
115        if b.starts_with(b"DFS")
116            && b[3].is_ascii_digit()
117            && b[4].is_ascii_digit()
118            && b[5].is_ascii_digit()
119            && b[6].is_ascii_digit()
120        {
121            let s = b[3] - b'0';
122            let h = b[4];
123            let g = b[5] - b'0';
124            let d = b[6] - b'0';
125            let antenna_height_feet = 10 * (1u32 << (h.saturating_sub(48) as u32));
126            let directivity = Directivity::from_digit(d)?;
127            return Some(Extension::Dfs {
128                s_points: s,
129                antenna_gain_db: g,
130                antenna_height_feet,
131                directivity,
132            });
133        }
134
135        None
136    }
137
138    /// Encode the extension field as exactly 7 bytes into `out`.
139    pub fn encode(&self, out: &mut Vec<u8>) {
140        match self {
141            Extension::DirectionSpeed {
142                direction_degrees,
143                speed_knots,
144            } => {
145                out.extend_from_slice(
146                    format!("{:03}/{:03}", direction_degrees, speed_knots).as_bytes(),
147                );
148            }
149            Extension::Phg {
150                power_watts,
151                antenna_height_feet,
152                antenna_gain_db,
153                directivity,
154            } => {
155                let p = (*power_watts as f64).sqrt() as u8;
156                let h_log = if *antenna_height_feet >= 10 {
157                    ((*antenna_height_feet / 10) as f64).log2() as u8 + 48
158                } else {
159                    48
160                };
161                out.extend_from_slice(b"PHG");
162                out.push(p + b'0');
163                out.push(h_log);
164                out.push(antenna_gain_db + b'0');
165                out.push(directivity.as_digit() + b'0');
166            }
167            Extension::Rng { range_miles } => {
168                out.extend_from_slice(format!("RNG{:04}", range_miles).as_bytes());
169            }
170            Extension::Dfs {
171                s_points,
172                antenna_height_feet,
173                antenna_gain_db,
174                directivity,
175            } => {
176                let h_log = if *antenna_height_feet >= 10 {
177                    ((*antenna_height_feet / 10) as f64).log2() as u8 + 48
178                } else {
179                    48
180                };
181                out.extend_from_slice(b"DFS");
182                out.push(s_points + b'0');
183                out.push(h_log);
184                out.push(antenna_gain_db + b'0');
185                out.push(directivity.as_digit() + b'0');
186            }
187        }
188    }
189
190    /// Returns an error if parsing fails where one was expected.
191    pub fn require(data: &[u8]) -> Result<Self, AprsError> {
192        Self::parse(data).ok_or(AprsError::UnsupportedPositionFormat)
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn direction_speed() {
202        let ext = Extension::parse(b"322/103").unwrap();
203        assert!(matches!(
204            ext,
205            Extension::DirectionSpeed {
206                direction_degrees: 322,
207                speed_knots: 103
208            }
209        ));
210    }
211
212    #[test]
213    fn direction_speed_encode_round_trip() {
214        let ext = Extension::DirectionSpeed {
215            direction_degrees: 322,
216            speed_knots: 103,
217        };
218        let mut out = Vec::new();
219        ext.encode(&mut out);
220        assert_eq!(out, b"322/103");
221        assert_eq!(Extension::parse(&out).unwrap(), ext);
222    }
223
224    #[test]
225    fn rng_parse() {
226        let ext = Extension::parse(b"RNG0050").unwrap();
227        assert!(matches!(ext, Extension::Rng { range_miles: 50 }));
228    }
229
230    #[test]
231    fn too_short_returns_none() {
232        assert!(Extension::parse(b"12/1").is_none());
233    }
234
235    #[test]
236    fn phg_valid() {
237        let ext = Extension::parse(b"PHG5132").unwrap();
238        assert!(matches!(ext, Extension::Phg { .. }));
239    }
240
241    #[test]
242    fn phg_nondigit_height_returns_none() {
243        // `z` in the height position previously caused a shift-left overflow panic.
244        // It must now be rejected cleanly.
245        assert!(Extension::parse(b"PHG0z00").is_none());
246    }
247
248    #[test]
249    fn dfs_nondigit_height_returns_none() {
250        assert!(Extension::parse(b"DFS0z00").is_none());
251    }
252
253    #[test]
254    fn phg_max_digit_height_no_panic() {
255        // Height digit 9 is the largest valid value (10 * 2^9 = 5120 ft).
256        let ext = Extension::parse(b"PHG0900").unwrap();
257        assert!(matches!(
258            ext,
259            Extension::Phg {
260                antenna_height_feet: 5120,
261                ..
262            }
263        ));
264    }
265}