Skip to main content

aprs_decode/types/
compressed.rs

1use crate::error::AprsError;
2use crate::types::lonlat::{base91_decode1, base91_encode1};
3
4// --- Compression type byte (T byte) ---
5
6#[derive(Debug, Copy, Clone, PartialEq, Eq)]
7#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
8pub enum GpsFix {
9    Old,
10    Current,
11}
12
13#[derive(Debug, Copy, Clone, PartialEq, Eq)]
14#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
15pub enum NmeaSource {
16    Other,
17    Gll,
18    Gga,
19    Rmc,
20}
21
22#[derive(Debug, Copy, Clone, PartialEq, Eq)]
23#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
24pub enum Origin {
25    Compressed,
26    TncBText,
27    Software,
28    Tbd,
29    Kpc3,
30    Pico,
31    Other,
32    Digipeater,
33}
34
35/// The compression-type byte (T byte) following the csT block in a compressed position.
36#[derive(Debug, Copy, Clone, PartialEq, Eq)]
37#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
38pub struct CompressionType {
39    pub gps_fix: GpsFix,
40    pub nmea_source: NmeaSource,
41    pub origin: Origin,
42}
43
44impl From<u8> for CompressionType {
45    fn from(b: u8) -> Self {
46        let gps_fix = if b & (1 << 5) != 0 {
47            GpsFix::Current
48        } else {
49            GpsFix::Old
50        };
51        let nmea_source = match (b & (1 << 4) != 0, b & (1 << 3) != 0) {
52            (false, false) => NmeaSource::Other,
53            (false, true) => NmeaSource::Gll,
54            (true, false) => NmeaSource::Gga,
55            (true, true) => NmeaSource::Rmc,
56        };
57        let origin = match (b & (1 << 2) != 0, b & (1 << 1) != 0, b & 1 != 0) {
58            (false, false, false) => Origin::Compressed,
59            (false, false, true) => Origin::TncBText,
60            (false, true, false) => Origin::Software,
61            (false, true, true) => Origin::Tbd,
62            (true, false, false) => Origin::Kpc3,
63            (true, false, true) => Origin::Pico,
64            (true, true, false) => Origin::Other,
65            (true, true, true) => Origin::Digipeater,
66        };
67        Self {
68            gps_fix,
69            nmea_source,
70            origin,
71        }
72    }
73}
74
75impl From<CompressionType> for u8 {
76    fn from(t: CompressionType) -> u8 {
77        let b5 = t.gps_fix == GpsFix::Current;
78        let (b4, b3) = match t.nmea_source {
79            NmeaSource::Other => (false, false),
80            NmeaSource::Gll => (false, true),
81            NmeaSource::Gga => (true, false),
82            NmeaSource::Rmc => (true, true),
83        };
84        let (b2, b1, b0) = match t.origin {
85            Origin::Compressed => (false, false, false),
86            Origin::TncBText => (false, false, true),
87            Origin::Software => (false, true, false),
88            Origin::Tbd => (false, true, true),
89            Origin::Kpc3 => (true, false, false),
90            Origin::Pico => (true, false, true),
91            Origin::Other => (true, true, false),
92            Origin::Digipeater => (true, true, true),
93        };
94        (b5 as u8) << 5
95            | (b4 as u8) << 4
96            | (b3 as u8) << 3
97            | (b2 as u8) << 2
98            | (b1 as u8) << 1
99            | (b0 as u8)
100    }
101}
102
103// --- Course/Speed encoded as 2 base-91 bytes ---
104
105#[derive(Debug, Copy, Clone, PartialEq)]
106#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
107pub struct CourseSpeed {
108    pub course_degrees: u16,
109    pub speed_knots: f64,
110}
111
112impl CourseSpeed {
113    pub fn new(course_degrees: u16, speed_knots: f64) -> Self {
114        Self {
115            course_degrees,
116            speed_knots,
117        }
118    }
119
120    pub(crate) fn from_cs(c: u8, s: u8) -> Self {
121        Self {
122            course_degrees: c as u16 * 4,
123            speed_knots: 1.08_f64.powi(s as i32) - 1.0,
124        }
125    }
126
127    pub(crate) fn to_cs(self) -> (u8, u8) {
128        let c = (self.course_degrees / 4) as u8;
129        let s = ((self.speed_knots + 1.0).ln() / 1.08_f64.ln()).round() as u8;
130        (c, s)
131    }
132}
133
134// --- Radio range encoded as 1 base-91 byte ---
135
136#[derive(Debug, Copy, Clone, PartialEq)]
137#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
138pub struct RadioRange {
139    pub range_miles: f64,
140}
141
142impl RadioRange {
143    pub fn new(range_miles: f64) -> Self {
144        Self { range_miles }
145    }
146
147    pub(crate) fn from_s(s: u8) -> Self {
148        Self {
149            range_miles: 2.0 * 1.08_f64.powi(s as i32),
150        }
151    }
152
153    pub(crate) fn to_s(self) -> u8 {
154        ((self.range_miles / 2.0).ln() / 1.08_f64.ln()).round() as u8
155    }
156}
157
158// --- Compressed altitude (2 base-91 bytes via GGA) ---
159
160#[derive(Debug, Copy, Clone, PartialEq)]
161#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
162pub struct CompressedAltitude {
163    pub feet: f64,
164}
165
166impl CompressedAltitude {
167    pub fn new(feet: f64) -> Self {
168        Self { feet }
169    }
170
171    pub fn meters(self) -> f64 {
172        self.feet * 0.3048
173    }
174
175    pub(crate) fn from_cs(c: u8, s: u8) -> Self {
176        Self {
177            feet: 1.002_f64.powi(c as i32 * 91 + s as i32),
178        }
179    }
180
181    pub(crate) fn to_cs(self) -> (u8, u8) {
182        let v = (self.feet.ln() / 1.002_f64.ln()).round() as i32;
183        ((v / 91) as u8, (v % 91) as u8)
184    }
185}
186
187// --- /A=NNNNNN altitude in comment field ---
188
189#[derive(Debug, Copy, Clone, PartialEq)]
190#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
191pub struct Altitude {
192    pub feet: f64,
193}
194
195impl Altitude {
196    pub fn new(feet: f64) -> Self {
197        Self { feet }
198    }
199
200    pub fn meters(self) -> f64 {
201        self.feet * 0.3048
202    }
203}
204
205// --- Compressed csT block ---
206
207/// The csT block in a compressed position, which encodes course/speed, radio range, or altitude.
208#[derive(Debug, Clone, PartialEq)]
209#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
210pub enum CompressedCs {
211    CourseSpeed(CourseSpeed, CompressionType),
212    RadioRange(RadioRange, CompressionType),
213    Altitude(CompressedAltitude, CompressionType),
214    /// Space character: csT is present but carries no data.
215    None(CompressionType),
216}
217
218impl CompressedCs {
219    /// Parse the 3-byte csT block (c byte, s byte, t byte).
220    pub(crate) fn parse(c: u8, s: u8, t_raw: u8) -> Result<Self, AprsError> {
221        // When c is space, or any byte is out of the valid base-91 / T-byte range
222        // (seen in null-position packets that use '@' or space placeholders), fall
223        // back to None rather than rejecting the whole packet.
224        if c == b' ' {
225            let t_val = t_raw.saturating_sub(33);
226            return Ok(CompressedCs::None(CompressionType::from(t_val)));
227        }
228        let c_val = match base91_decode1(c) {
229            Some(v) => v,
230            None => return Ok(CompressedCs::None(CompressionType::from(0))),
231        };
232        let s_val = match base91_decode1(s) {
233            Some(v) => v,
234            None => return Ok(CompressedCs::None(CompressionType::from(0))),
235        };
236        let t = CompressionType::from(t_raw.saturating_sub(33));
237
238        let cs = if t.nmea_source == NmeaSource::Gga {
239            CompressedCs::Altitude(CompressedAltitude::from_cs(c_val, s_val), t)
240        } else {
241            match c_val {
242                0..=89 => CompressedCs::CourseSpeed(CourseSpeed::from_cs(c_val, s_val), t),
243                90 => CompressedCs::RadioRange(RadioRange::from_s(s_val), t),
244                _ => return Err(AprsError::InvalidCompressedByte { byte: c }),
245            }
246        };
247        Ok(cs)
248    }
249
250    pub(crate) fn encode(&self, out: &mut Vec<u8>) {
251        match self {
252            CompressedCs::CourseSpeed(cs, t) => {
253                let (c, s) = cs.to_cs();
254                out.push(base91_encode1(c));
255                out.push(base91_encode1(s));
256                out.push(u8::from(*t) + 33);
257            }
258            CompressedCs::RadioRange(rr, t) => {
259                out.push(b'{');
260                out.push(base91_encode1(rr.to_s()));
261                out.push(u8::from(*t) + 33);
262            }
263            CompressedCs::Altitude(alt, t) => {
264                let (c, s) = alt.to_cs();
265                out.push(base91_encode1(c));
266                out.push(base91_encode1(s));
267                out.push(u8::from(*t) + 33);
268            }
269            CompressedCs::None(t) => {
270                out.push(b' ');
271                // 's' byte and T byte follow; use 'T' placeholder encoding
272                out.push(b's');
273                out.push(u8::from(*t) + 33);
274            }
275        }
276    }
277
278    pub fn compression_type(&self) -> CompressionType {
279        match self {
280            CompressedCs::CourseSpeed(_, t) => *t,
281            CompressedCs::RadioRange(_, t) => *t,
282            CompressedCs::Altitude(_, t) => *t,
283            CompressedCs::None(t) => *t,
284        }
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    #[test]
293    fn compression_type_round_trip() {
294        let t = CompressionType {
295            gps_fix: GpsFix::Current,
296            nmea_source: NmeaSource::Gga,
297            origin: Origin::Software,
298        };
299        let byte = u8::from(t);
300        assert_eq!(CompressionType::from(byte), t);
301    }
302
303    #[test]
304    fn course_speed_round_trip() {
305        for c in 0u8..90 {
306            for s in 0u8..91 {
307                let cs = CourseSpeed::from_cs(c, s);
308                assert_eq!(cs.to_cs(), (c, s));
309            }
310        }
311    }
312
313    #[test]
314    fn radio_range_round_trip() {
315        for s in 0u8..91 {
316            let rr = RadioRange::from_s(s);
317            assert_eq!(rr.to_s(), s);
318        }
319    }
320
321    #[test]
322    fn altitude_round_trip() {
323        for c in 0u8..91 {
324            for s in 0u8..91 {
325                let alt = CompressedAltitude::from_cs(c, s);
326                assert_eq!(alt.to_cs(), (c, s));
327            }
328        }
329    }
330}