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