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