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 { return Some(Directivity::Omni); }
16        if d < 9 { return Some(Directivity::Degrees(d as u16 * 45)); }
17        None
18    }
19
20    fn as_digit(&self) -> u8 {
21        match self {
22            Directivity::Omni => 0,
23            Directivity::Degrees(deg) => ((deg % 360) / 45) as u8,
24        }
25    }
26}
27
28/// A data extension field that follows the position in the comment field.
29///
30/// Extensions occupy exactly 7 bytes and are encoded in a fixed format that
31/// depends on the extension type. Unknown or malformed extensions are discarded
32/// (the caller treats the entire comment field as the comment in that case).
33#[derive(Debug, Clone, PartialEq)]
34#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
35pub enum Extension {
36    /// Course (degrees) and speed (knots). Format: `DDD/SSS`.
37    DirectionSpeed {
38        direction_degrees: u16,
39        speed_knots: u16,
40    },
41    /// Power-Height-Gain-Directivity. Format: `PHGphgd`.
42    Phg {
43        power_watts: u32,
44        antenna_height_feet: u32,
45        antenna_gain_db: u8,
46        directivity: Directivity,
47    },
48    /// Pre-calculated radio range. Format: `RNGrrrr`.
49    Rng {
50        range_miles: u16,
51    },
52    /// DF Strength-Height-Gain-Directivity. Format: `DFSshgd`.
53    Dfs {
54        s_points: u8,
55        antenna_height_feet: u32,
56        antenna_gain_db: u8,
57        directivity: Directivity,
58    },
59}
60
61impl Extension {
62    /// Attempt to parse the first 7 bytes of `data` as an extension field.
63    /// Returns `None` if unrecognized or malformed.
64    pub fn parse(data: &[u8]) -> Option<Self> {
65        if data.len() < 7 {
66            return None;
67        }
68        let b = &data[..7];
69
70        // Course/Speed: `DDD/SSS` — three digits, slash, three digits
71        if b[3] == b'/' && b[..3].iter().all(|c| c.is_ascii_digit())
72            && b[4..7].iter().all(|c| c.is_ascii_digit())
73        {
74            let dir: u16 = parse_bytes(&b[0..3])?;
75            let spd: u16 = parse_bytes(&b[4..7])?;
76            // direction 000 means unknown/not applicable (valid)
77            return Some(Extension::DirectionSpeed {
78                direction_degrees: dir,
79                speed_knots: spd,
80            });
81        }
82
83        // PHG: `PHGphgd`
84        if b.starts_with(b"PHG") && b[3].is_ascii_digit() && b[5].is_ascii_digit() && b[6].is_ascii_digit() {
85            let p = b[3] - b'0';
86            let h = b[4]; // encoded as ASCII
87            let g = b[5] - b'0';
88            let d = b[6] - b'0';
89            let power_watts = (p as u32) * (p as u32);
90            let antenna_height_feet = 10 * (1u32 << (h.saturating_sub(48) as u32));
91            let directivity = Directivity::from_digit(d)?;
92            return Some(Extension::Phg {
93                power_watts,
94                antenna_height_feet,
95                antenna_gain_db: g,
96                directivity,
97            });
98        }
99
100        // RNG: `RNGrrrr`
101        if b.starts_with(b"RNG") && b[3..7].iter().all(|c| c.is_ascii_digit()) {
102            let range: u16 = parse_bytes(&b[3..7])?;
103            return Some(Extension::Rng { range_miles: range });
104        }
105
106        // DFS: `DFSshgd`
107        if b.starts_with(b"DFS") && b[3].is_ascii_digit() && b[5].is_ascii_digit() && b[6].is_ascii_digit() {
108            let s = b[3] - b'0';
109            let h = b[4];
110            let g = b[5] - b'0';
111            let d = b[6] - b'0';
112            let antenna_height_feet = 10 * (1u32 << (h.saturating_sub(48) as u32));
113            let directivity = Directivity::from_digit(d)?;
114            return Some(Extension::Dfs {
115                s_points: s,
116                antenna_gain_db: g,
117                antenna_height_feet,
118                directivity,
119            });
120        }
121
122        None
123    }
124
125    /// Encode the extension field as exactly 7 bytes into `out`.
126    pub fn encode(&self, out: &mut Vec<u8>) {
127        match self {
128            Extension::DirectionSpeed { direction_degrees, speed_knots } => {
129                out.extend_from_slice(
130                    format!("{:03}/{:03}", direction_degrees, speed_knots).as_bytes()
131                );
132            }
133            Extension::Phg { power_watts, antenna_height_feet, antenna_gain_db, directivity } => {
134                let p = (*power_watts as f64).sqrt() as u8;
135                let h_log = if *antenna_height_feet >= 10 {
136                    ((*antenna_height_feet / 10) as f64).log2() as u8 + 48
137                } else {
138                    48
139                };
140                out.extend_from_slice(b"PHG");
141                out.push(p + b'0');
142                out.push(h_log);
143                out.push(antenna_gain_db + b'0');
144                out.push(directivity.as_digit() + b'0');
145            }
146            Extension::Rng { range_miles } => {
147                out.extend_from_slice(format!("RNG{:04}", range_miles).as_bytes());
148            }
149            Extension::Dfs { s_points, antenna_height_feet, antenna_gain_db, directivity } => {
150                let h_log = if *antenna_height_feet >= 10 {
151                    ((*antenna_height_feet / 10) as f64).log2() as u8 + 48
152                } else {
153                    48
154                };
155                out.extend_from_slice(b"DFS");
156                out.push(s_points + b'0');
157                out.push(h_log);
158                out.push(antenna_gain_db + b'0');
159                out.push(directivity.as_digit() + b'0');
160            }
161        }
162    }
163
164    /// Returns an error if parsing fails where one was expected.
165    pub fn require(data: &[u8]) -> Result<Self, AprsError> {
166        Self::parse(data).ok_or(AprsError::UnsupportedPositionFormat)
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn direction_speed() {
176        let ext = Extension::parse(b"322/103").unwrap();
177        assert!(matches!(ext, Extension::DirectionSpeed { direction_degrees: 322, speed_knots: 103 }));
178    }
179
180    #[test]
181    fn direction_speed_encode_round_trip() {
182        let ext = Extension::DirectionSpeed { direction_degrees: 322, speed_knots: 103 };
183        let mut out = Vec::new();
184        ext.encode(&mut out);
185        assert_eq!(out, b"322/103");
186        assert_eq!(Extension::parse(&out).unwrap(), ext);
187    }
188
189    #[test]
190    fn rng_parse() {
191        let ext = Extension::parse(b"RNG0050").unwrap();
192        assert!(matches!(ext, Extension::Rng { range_miles: 50 }));
193    }
194
195    #[test]
196    fn too_short_returns_none() {
197        assert!(Extension::parse(b"12/1").is_none());
198    }
199}