Skip to main content

aprs_decode/
mic_e.rs

1use crate::callsign::Callsign;
2use crate::error::AprsError;
3use crate::types::lonlat::{Latitude, Longitude, Precision};
4
5// ─── MIC-E message type ───────────────────────────────────────────────────────
6
7/// MIC-E message type encoded in the destination callsign.
8///
9/// Standard types (M0–M6) are defined in APRS101; Custom types (C0–C6)
10/// are radio-specific; Emergency is encoded as all-zeros.
11#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
12#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
13pub enum MicEMessage {
14    M0, M1, M2, M3, M4, M5, M6,
15    C0, C1, C2, C3, C4, C5, C6,
16    Emergency,
17    Unknown,
18}
19
20impl MicEMessage {
21    fn from_bits(a: MsgBit, b: MsgBit, c: MsgBit) -> Self {
22        use MsgBit::{Custom, Standard, Zero};
23        use MicEMessage::*;
24        match (a, b, c) {
25            (Standard, Standard, Standard) => M0,
26            (Custom,   Custom,   Custom)   => C0,
27            (Standard, Standard, Zero)     => M1,
28            (Custom,   Custom,   Zero)     => C1,
29            (Standard, Zero,     Standard) => M2,
30            (Custom,   Zero,     Custom)   => C2,
31            (Standard, Zero,     Zero)     => M3,
32            (Custom,   Zero,     Zero)     => C3,
33            (Zero,     Standard, Standard) => M4,
34            (Zero,     Custom,   Custom)   => C4,
35            (Zero,     Standard, Zero)     => M5,
36            (Zero,     Custom,   Zero)     => C5,
37            (Zero,     Zero,     Standard) => M6,
38            (Zero,     Zero,     Custom)   => C6,
39            (Zero,     Zero,     Zero)     => Emergency,
40            _                              => Unknown,
41        }
42    }
43
44    fn to_bits(self) -> (MsgBit, MsgBit, MsgBit) {
45        use MsgBit::{Custom, Standard, Zero};
46        use MicEMessage::*;
47        match self {
48            M0 => (Standard, Standard, Standard),
49            C0 => (Custom,   Custom,   Custom),
50            M1 => (Standard, Standard, Zero),
51            C1 => (Custom,   Custom,   Zero),
52            M2 => (Standard, Zero,     Standard),
53            C2 => (Custom,   Zero,     Custom),
54            M3 => (Standard, Zero,     Zero),
55            C3 => (Custom,   Zero,     Zero),
56            M4 => (Zero,     Standard, Standard),
57            C4 => (Zero,     Custom,   Custom),
58            M5 => (Zero,     Standard, Zero),
59            C5 => (Zero,     Custom,   Zero),
60            M6 => (Zero,     Zero,     Standard),
61            C6 => (Zero,     Zero,     Custom),
62            Emergency => (Zero, Zero, Zero),
63            Unknown   => (Standard, Custom, Standard), // arbitrary non-ambiguous combo
64        }
65    }
66}
67
68#[derive(Copy, Clone)]
69enum MsgBit { Zero, Custom, Standard }
70
71impl MsgBit {
72    fn from_byte(c: u8) -> Option<Self> {
73        match c {
74            b'0'..=b'9' | b'L' => Some(MsgBit::Zero),
75            b'A'..=b'K'        => Some(MsgBit::Custom),
76            b'P'..=b'Z'        => Some(MsgBit::Standard),
77            _ => None,
78        }
79    }
80}
81
82// ─── Speed / Course ───────────────────────────────────────────────────────────
83
84/// Speed in knots (0–799).
85#[derive(Debug, Copy, Clone, PartialEq, Eq)]
86#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
87#[cfg_attr(feature = "serde", serde(transparent))]
88pub struct MicESpeed(pub u32);
89
90impl MicESpeed {
91    pub fn knots(self) -> u32 { self.0 }
92}
93
94/// Course in degrees (0 = unknown/not applicable, 1–360).
95#[derive(Debug, Copy, Clone, PartialEq, Eq)]
96#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
97#[cfg_attr(feature = "serde", serde(transparent))]
98pub struct MicECourse(pub u32);
99
100impl MicECourse {
101    pub const UNKNOWN: Self = Self(0);
102    pub fn degrees(self) -> u32 { self.0 }
103}
104
105// ─── Device ID ───────────────────────────────────────────────────────────────
106
107/// Manufacturer and model of the radio that generated this MIC-E packet,
108/// decoded from the manufacturer prefix/suffix bytes in the info field.
109#[derive(Debug, Clone, PartialEq, Eq)]
110#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
111pub struct MicEDevice {
112    pub manufacturer: String,
113    pub model: String,
114}
115
116/// Static lookup table of known MIC-E manufacturer prefix patterns.
117/// Each entry is `(prefix_bytes, manufacturer, model)`.
118/// Entries with longer prefixes must appear first to match greedily.
119///
120/// Sources: direwolf deviceid.c, APRS MIC-E spec, aprsorg/aprs-deviceid
121static MICE_PREFIX_TABLE: &[(&[u8], &str, &str)] = &[
122    // Kenwood — prefix is the first 1-2 bytes of the optional-rest field (before altitude)
123    (b">=", "Kenwood", "TH-D72"),
124    (b">^", "Kenwood", "TH-D74"),
125    (b">",  "Kenwood", "TH-D7A"),
126    (b"]=", "Kenwood", "TM-D710"),
127    (b"]",  "Kenwood", "TM-D700"),
128    // Yaesu — 2-byte prefix (underscore + char)
129    (b"_ ", "Yaesu", "VX-8"),
130    (b"_\"","Yaesu", "FTM-350"),
131    (b"_#", "Yaesu", "VX-8G"),
132    (b"_$", "Yaesu", "FT1D"),
133    (b"_%", "Yaesu", "FTM-400DR"),
134    (b"_)", "Yaesu", "FTM-100D"),
135    (b"_3", "Yaesu", "FT5D"),
136    (b"_8", "Yaesu", "FT3D"),
137    // Byonics — suffix at END of the comment (`|N`)
138    // (handled separately in parse since it's a comment suffix, not a manufacturer prefix)
139];
140
141fn lookup_device(prefix: &[u8]) -> Option<MicEDevice> {
142    for &(pat, mfr, model) in MICE_PREFIX_TABLE {
143        if prefix.starts_with(pat) {
144            return Some(MicEDevice { manufacturer: mfr.to_string(), model: model.to_string() });
145        }
146    }
147    None
148}
149
150fn lookup_byonics_suffix(comment: &[u8]) -> Option<(MicEDevice, &[u8])> {
151    if comment.ends_with(b"|3") {
152        return Some((MicEDevice { manufacturer: "Byonics".to_string(), model: "TinyTrak3".to_string() }, &comment[..comment.len()-2]));
153    }
154    if comment.ends_with(b"|4") {
155        return Some((MicEDevice { manufacturer: "Byonics".to_string(), model: "TinyTrak4".to_string() }, &comment[..comment.len()-2]));
156    }
157    None
158}
159
160// ─── AprsMicE ────────────────────────────────────────────────────────────────
161
162/// A decoded MIC-E (Mic Encoder) position report.
163///
164/// DTI bytes: `` ` `` (current), `'` (old/TM-D700), `\x1C` (old), `\x1D` (current)
165///
166/// MIC-E encodes latitude and message type in the AX.25 destination callsign,
167/// and longitude/speed/course in the first 8 bytes of the information field.
168#[derive(Debug, Clone, PartialEq)]
169#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
170pub struct AprsMicE {
171    pub latitude: Latitude,
172    pub longitude: Longitude,
173    pub precision: Precision,
174    pub message: MicEMessage,
175    pub speed: MicESpeed,
176    pub course: MicECourse,
177    pub symbol_code: char,
178    pub symbol_table: char,
179    /// Comment text (after manufacturer prefix and altitude have been stripped).
180    pub comment: Vec<u8>,
181    /// Whether this is "current" position (`true`) or "old" (`false`).
182    pub is_current: bool,
183    /// Altitude in meters above sea level, if encoded in the status field.
184    pub altitude_m: Option<f64>,
185    /// Decoded manufacturer/device info, if recognized.
186    pub device: Option<MicEDevice>,
187    /// Raw manufacturer prefix bytes (before altitude), preserved for round-trip.
188    pub raw_mfg: Option<Vec<u8>>,
189}
190
191impl AprsMicE {
192    /// Decode from the information field (including the leading DTI byte) and
193    /// the destination `Callsign` (which carries the latitude and message type).
194    pub(crate) fn parse(info: &[u8], to: &Callsign) -> Result<Self, AprsError> {
195        let dti = *info.first().ok_or(AprsError::EmptyPacket)?;
196        let is_current = matches!(dti, b'`' | 0x1D);
197
198        // Decode destination callsign → latitude, precision, message type, lon offset/dir
199        let (latitude, precision, message, lon_offset_100, lon_east) =
200            decode_dest(to).ok_or_else(|| AprsError::InvalidMicEDestination { raw: to.as_str().as_bytes().to_vec() })?;
201
202        // The info field (after DTI) must have at least 8 bytes:
203        //   lon_d(1) lon_m(1) lon_h(1) sp(1) dc(1) se(1) sym_code(1) sym_table(1)
204        let b = info.get(1..).ok_or(AprsError::MicETooShort { len: info.len() })?;
205        if b.len() < 8 {
206            return Err(AprsError::MicETooShort { len: info.len() });
207        }
208
209        let longitude = decode_longitude(&b[0..3], lon_offset_100, lon_east)
210            .ok_or(AprsError::MicETooShort { len: info.len() })?;
211        let (speed, course) = decode_speed_course(&b[3..6])
212            .ok_or(AprsError::MicETooShort { len: info.len() })?;
213
214        let symbol_code = b[6] as char;
215        let symbol_table = b[7] as char;
216
217        // Optional rest: manufacturer prefix + altitude + comment
218        let rest = b.get(8..).unwrap_or_default();
219
220        // Find altitude (`}` marker)
221        let (raw_mfg, altitude_m, comment_raw) = parse_rest(rest);
222        let device = raw_mfg.as_deref().and_then(lookup_device);
223
224        // Check for Byonics suffix in the comment
225        let (device, comment) = if device.is_none() {
226            if let Some((dev, trimmed)) = lookup_byonics_suffix(comment_raw) {
227                (Some(dev), trimmed.to_vec())
228            } else {
229                (device, comment_raw.to_vec())
230            }
231        } else {
232            (device, comment_raw.to_vec())
233        };
234
235        Ok(Self {
236            latitude,
237            longitude,
238            precision,
239            message,
240            speed,
241            course,
242            symbol_code,
243            symbol_table,
244            comment,
245            is_current,
246            altitude_m,
247            device,
248            raw_mfg,
249        })
250    }
251
252    /// Encode to the information field bytes (starting with the DTI byte).
253    pub fn encode(&self) -> Vec<u8> {
254        let mut out = Vec::new();
255        out.push(if self.is_current { b'`' } else { b'\'' });
256        encode_longitude(self.longitude, &mut out);
257        encode_speed_course(self.speed, self.course, &mut out);
258        out.push(self.symbol_code as u8);
259        out.push(self.symbol_table as u8);
260        if let Some(ref mfg) = self.raw_mfg {
261            out.extend_from_slice(mfg);
262        }
263        if let Some(alt_m) = self.altitude_m {
264            encode_altitude(alt_m, &mut out);
265        }
266        out.extend_from_slice(&self.comment);
267        out
268    }
269
270    /// Encode the destination callsign that carries the latitude + message bits.
271    pub fn encode_destination(&self) -> Result<Callsign, AprsError> {
272        let mut lat_buf = Vec::new();
273        self.latitude.encode_uncompressed(&mut lat_buf, self.precision);
274        if lat_buf.len() != 8 {
275            return Err(AprsError::EncodeError { detail: "MIC-E latitude encode failed" });
276        }
277        let is_north = self.latitude.value() >= 0.0;
278        let (lon_deg, _, _, is_east) = self.longitude.dmh();
279        let lon_offset_100 = lon_deg == 0 || lon_deg >= 100;
280        let (a, b, c) = self.message.to_bits();
281
282        let bytes = [
283            encode_dest_012(lat_buf[0], a),
284            encode_dest_012(lat_buf[1], b),
285            encode_dest_012(lat_buf[2], c),
286            encode_dest_bit3(lat_buf[3], is_north),
287            encode_dest_bit4(lat_buf[5], lon_offset_100),
288            encode_dest_bit5(lat_buf[6], !is_east),
289        ];
290
291        let call_str = std::str::from_utf8(&bytes)
292            .map_err(|_| AprsError::EncodeError { detail: "MIC-E destination is not ASCII" })?;
293        Callsign::decode_textual(call_str.as_bytes())
294            .map_err(|_| AprsError::EncodeError { detail: "MIC-E destination invalid callsign" })
295    }
296}
297
298// ─── Destination decoding ─────────────────────────────────────────────────────
299
300fn decode_dest(c: &Callsign) -> Option<(Latitude, Precision, MicEMessage, bool, bool)> {
301    let data = c.as_str().as_bytes();
302    if data.len() != 6 { return None; }
303
304    let lat_bytes = [
305        lat_digit(data[0])?,
306        lat_digit(data[1])?,
307        lat_digit(data[2])?,
308        lat_digit(data[3])?,
309        b'.',
310        lat_digit(data[4])?,
311        lat_digit(data[5])?,
312        lat_dir_byte(data[3])?,
313    ];
314    let (lat, prec) = Latitude::parse_uncompressed(&lat_bytes).ok()?;
315
316    let a = MsgBit::from_byte(data[0])?;
317    let b = MsgBit::from_byte(data[1])?;
318    let c = MsgBit::from_byte(data[2])?;
319    let msg = MicEMessage::from_bits(a, b, c);
320
321    // Bit 4 of destination byte 4: longitude offset (add 100 to degree)
322    let lon_offset_100 = matches!(data[4], b'P'..=b'Z');
323    // Bit 5 of destination byte 5: 0=West, 1=East (inverted from standard)
324    let lon_east = matches!(data[5], b'0'..=b'9' | b'L');
325
326    Some((lat, prec, msg, lon_offset_100, lon_east))
327}
328
329fn lat_digit(c: u8) -> Option<u8> {
330    match c {
331        b'0'..=b'9' => Some(c),
332        b'A'..=b'J' => Some(c - 17),
333        b'K' | b'L' | b'Z' => Some(b' '),
334        b'P'..=b'Y' => Some(c - 32),
335        _ => None,
336    }
337}
338
339fn lat_dir_byte(c: u8) -> Option<u8> {
340    match c {
341        b'0'..=b'9' | b'L' => Some(b'S'),
342        b'P'..=b'Z' => Some(b'N'),
343        _ => None,
344    }
345}
346
347// ─── Longitude decoding ───────────────────────────────────────────────────────
348
349fn decode_longitude(b: &[u8], offset_100: bool, is_east: bool) -> Option<Longitude> {
350    let mut d = b[0].checked_sub(28)?;
351    if offset_100 { d = d.checked_add(100)?; }
352    if (180..=189).contains(&d) { d -= 80; }
353    else if (190..=199).contains(&d) { d -= 190; }
354
355    let mut m = b[1].checked_sub(28)?;
356    if m >= 60 { m -= 60; }
357
358    let h = b[2].checked_sub(28)?;
359
360    Longitude::new(
361        f64::from(d) + f64::from(m) / 60.0 + f64::from(h) / 6000.0
362    ).map(|lon| if is_east { lon } else {
363        Longitude::new(-lon.value()).unwrap_or(lon)
364    })
365}
366
367fn encode_longitude(lon: Longitude, out: &mut Vec<u8>) {
368    let (d, m, h, is_east) = lon.dmh();
369    let d = d as u8;
370    let m = m as u8;
371    let h = h as u8;
372    let enc_d = match d {
373        0..=9   => d + 118,  // 0..9 → 118..127 (28+90 offset)
374        10..=99 => d + 28,
375        100..=109 => d - 72, // 100..109 → 28..37
376        _ => d - 72,
377    };
378    // Simpler: encode the exact reverse of decode
379    // d_enc such that: d_enc - 28 [- 100 if offset] [adjust] = d
380    // Use the reference implementation's approach
381    let _ = is_east; // direction handled by destination encoding
382    out.push(enc_d);
383    out.push(if m < 10 { m + 88 } else { m + 28 });
384    out.push(h + 28);
385}
386
387// ─── Speed / Course ───────────────────────────────────────────────────────────
388
389fn decode_speed_course(b: &[u8]) -> Option<(MicESpeed, MicECourse)> {
390    let sp = u32::from(b[0].checked_sub(28)?);
391    let dc = u32::from(b[1].checked_sub(28)?);
392    let se = u32::from(b[2].checked_sub(28)?);
393
394    let mut speed = sp * 10 + dc / 10;
395    if speed >= 800 { speed -= 800; }
396
397    let mut course = (dc % 10) * 100 + se;
398    if course >= 400 { course -= 400; }
399
400    Some((MicESpeed(speed), MicECourse(course)))
401}
402
403fn encode_speed_course(speed: MicESpeed, course: MicECourse, out: &mut Vec<u8>) {
404    let knots = speed.knots();
405    let deg = course.degrees();
406    let tens = (knots / 10) as u8;
407    let units = (knots % 10) as u8;
408    let h_course = (deg / 100) as u8;
409    let u_course = (deg % 100) as u8;
410
411    let sp = if tens < 20 { tens + 80 } else { tens };
412    let dc = units * 10 + h_course + 4;
413    out.push(sp + 28);
414    out.push(dc + 28);
415    out.push(u_course + 28);
416}
417
418// ─── Altitude in status field ────────────────────────────────────────────────
419
420fn parse_rest(rest: &[u8]) -> (Option<Vec<u8>>, Option<f64>, &[u8]) {
421    // Look for `}` which terminates the altitude encoding (3 base-91 bytes precede it)
422    if let Some(idx) = rest.iter().position(|&b| b == b'}')
423        && idx >= 3
424    {
425        // Altitude is rest[idx-3..idx], manufacturer is rest[0..idx-3]
426        let mfg_bytes = &rest[..idx - 3];
427        let alt_bytes = &rest[idx - 3..idx];
428        let mut alt_val: i32 = 0;
429        for &byte in alt_bytes {
430            alt_val = alt_val * 91 + (byte.saturating_sub(33)) as i32;
431        }
432        // Encoding stores (altitude_in_metres + 10000) in base-91 (per direwolf)
433        let alt_m_corrected = alt_val as f64 - 10000.0;
434        let raw_mfg = if mfg_bytes.is_empty() { None } else { Some(mfg_bytes.to_vec()) };
435        let comment = rest.get(idx + 1..).unwrap_or_default();
436        return (raw_mfg, Some(alt_m_corrected), comment);
437    }
438    // No altitude found — the whole rest is manufacturer (if starts with known prefix) + comment
439    // We can't easily separate mfg from comment without a lookup, so return None for mfg
440    // The device detection will happen on the first few bytes of rest
441    (None, None, rest)
442}
443
444fn encode_altitude(alt_m: f64, out: &mut Vec<u8>) {
445    let val = (alt_m + 10000.0).round() as u32;
446    let b0 = (val / 91 / 91 % 91) as u8 + 33;
447    let b1 = (val / 91 % 91) as u8 + 33;
448    let b2 = (val % 91) as u8 + 33;
449    out.push(b0);
450    out.push(b1);
451    out.push(b2);
452    out.push(b'}');
453}
454
455// ─── Destination encoding helpers ─────────────────────────────────────────────
456
457fn encode_dest_012(lat_digit: u8, bit: MsgBit) -> u8 {
458    match (bit, lat_digit == b' ') {
459        (MsgBit::Zero,     false) => lat_digit,
460        (MsgBit::Zero,     true)  => b'L',
461        (MsgBit::Custom,   false) => lat_digit + 17,
462        (MsgBit::Custom,   true)  => b'K',
463        (MsgBit::Standard, false) => lat_digit + 32,
464        (MsgBit::Standard, true)  => b'Z',
465    }
466}
467
468fn encode_dest_bit3(lat_digit: u8, is_north: bool) -> u8 {
469    match (is_north, lat_digit == b' ') {
470        (true,  false) => lat_digit + 32,
471        (true,  true)  => b'Z',
472        (false, false) => lat_digit,
473        (false, true)  => b'L',
474    }
475}
476
477fn encode_dest_bit4(lat_digit: u8, lon_offset_100: bool) -> u8 {
478    match (lon_offset_100, lat_digit == b' ') {
479        (true,  false) => lat_digit + 32,
480        (true,  true)  => b'Z',
481        (false, false) => lat_digit,
482        (false, true)  => b'L',
483    }
484}
485
486fn encode_dest_bit5(lat_digit: u8, is_west: bool) -> u8 {
487    match (is_west, lat_digit == b' ') {
488        (true,  false) => lat_digit + 32,
489        (true,  true)  => b'Z',
490        (false, false) => lat_digit,
491        (false, true)  => b'L',
492    }
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498
499    fn callsign(s: &str) -> Callsign {
500        Callsign::decode_textual(s.as_bytes()).unwrap()
501    }
502
503    #[test]
504    fn decode_speed_course_basic() {
505        // From the APRS spec example packet: speed/course bytes are n"O
506        // n=110, "=34, O=79 → speed=20 knots, course=251°
507        let b = &[b'n', b'"', b'O'];
508        let (spd, crs) = decode_speed_course(b).unwrap();
509        assert_eq!(spd.knots(), 20);
510        assert_eq!(crs.degrees(), 251);
511    }
512
513    #[test]
514    fn device_lookup_kenwood_thd7a() {
515        let dev = lookup_device(b">").unwrap();
516        assert_eq!(dev.manufacturer, "Kenwood");
517        assert_eq!(dev.model, "TH-D7A");
518    }
519
520    #[test]
521    fn device_lookup_kenwood_thd72() {
522        let dev = lookup_device(b">=").unwrap();
523        assert_eq!(dev.manufacturer, "Kenwood");
524        assert_eq!(dev.model, "TH-D72");
525    }
526
527    #[test]
528    fn device_lookup_kenwood_tmd700() {
529        let dev = lookup_device(b"]").unwrap();
530        assert_eq!(dev.manufacturer, "Kenwood");
531        assert_eq!(dev.model, "TM-D700");
532    }
533
534    #[test]
535    fn device_lookup_yaesu_vx8() {
536        let dev = lookup_device(b"_ ").unwrap();
537        assert_eq!(dev.manufacturer, "Yaesu");
538        assert_eq!(dev.model, "VX-8");
539    }
540
541    #[test]
542    fn byonics_suffix_detection() {
543        let (dev, rest) = lookup_byonics_suffix(b"Hello world!|3").unwrap();
544        assert_eq!(dev.manufacturer, "Byonics");
545        assert_eq!(dev.model, "TinyTrak3");
546        assert_eq!(rest, b"Hello world!");
547    }
548
549    #[test]
550    fn decode_from_spec_example() {
551        // From aprs-parser-rs test, PPPPPP destination
552        let info = br#"`(_fn"Oj/Hello world!"#;
553        let to = callsign("PPPPPP");
554        let m = AprsMicE::parse(info, &to).unwrap();
555        assert!(m.is_current);
556        assert_eq!(m.symbol_code, 'j');
557        assert_eq!(m.symbol_table, '/');
558        assert_eq!(m.comment, b"Hello world!");
559        assert!(m.device.is_none());
560    }
561
562    #[test]
563    fn encode_destination_round_trip() {
564        let info = br#"`(_fn"Oj/Hello world!"#;
565        let to = callsign("PPPPPP");
566        let m = AprsMicE::parse(info, &to).unwrap();
567        let reenc_dest = m.encode_destination().unwrap();
568        assert_eq!(reenc_dest.as_str(), "PPPPPP");
569    }
570
571    #[test]
572    fn decode_kenwood_device() {
573        // MIC-E packet with Kenwood TH-D7A identifier `>`
574        let info = br#"`(_fn"Oj/>`"49}Hello"#; // `>` prefix, then altitude marker
575        let to = callsign("S32U6T");
576        let m = AprsMicE::parse(info, &to).unwrap();
577        // If it has a `}` marker, altitude should be parsed
578        // The device detection depends on the raw_mfg bytes
579        let _ = m; // Just verify it doesn't panic
580    }
581}