aprs/
mic_e.rs

1//! Mic-E compressed position encoding (APRS 1.0.1 ch. 10).
2//!
3//! Mic-E is a compact encoding used by Kenwood HTs (including the TH-D75)
4//! that splits the position across two fields: latitude is encoded in the
5//! 6-character AX.25 destination address, and longitude + speed/course
6//! are in the info field body.
7
8use crate::error::AprsError;
9use crate::packet::{AprsData, PositionAmbiguity, parse_aprs_extensions};
10use crate::position::AprsPosition;
11use crate::weather::extract_position_weather;
12
13/// Mic-E standard message code.
14///
15/// Per APRS 1.0.1 §10.1, the three message bits (A/B/C) encoded by the
16/// "custom" status of destination chars 0-2 select one of 8 standard
17/// messages, or the eighth code indicates that a custom status is carried
18/// in the comment.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20pub enum MiceMessage {
21    /// M0 — "Off Duty" (111 standard, 000 custom).
22    OffDuty,
23    /// M1 — "En Route" (110, 001).
24    EnRoute,
25    /// M2 — "In Service" (101, 010).
26    InService,
27    /// M3 — "Returning" (100, 011).
28    Returning,
29    /// M4 — "Committed" (011, 100).
30    Committed,
31    /// M5 — "Special" (010, 101).
32    Special,
33    /// M6 — "Priority" (001, 110).
34    Priority,
35    /// Emergency — (000, 111) always means emergency.
36    Emergency,
37}
38
39/// Parse a Mic-E encoded APRS position (APRS101.PDF Chapter 10).
40///
41/// Mic-E is a compact encoding used by Kenwood HTs (including the TH-D75)
42/// that splits the position across two fields:
43/// - **Latitude** is encoded in the 6-character AX.25 destination address
44/// - **Longitude** and speed/course are in the info field body
45///
46/// Data type identifiers: `` ` `` (0x60, current Mic-E) or `'` (0x27, old Mic-E).
47/// The TH-D75 uses current Mic-E (`` ` ``).
48///
49/// # Parameters
50///
51/// - `destination`: The AX.25 destination callsign (e.g., "T4SP0R")
52/// - `info`: The full AX.25 information field (including the type byte)
53///
54/// # Errors
55///
56/// Returns [`AprsError`] if the Mic-E encoding is invalid.
57pub fn parse_mice_position(destination: &str, info: &[u8]) -> Result<AprsPosition, AprsError> {
58    let header = info.get(..9).ok_or(AprsError::InvalidFormat)?;
59    let dest = destination.as_bytes();
60    let dest_head = dest.get(..6).ok_or(AprsError::InvalidFormat)?;
61
62    let data_type = *header.first().ok_or(AprsError::InvalidFormat)?;
63    if data_type != b'`' && data_type != b'\'' && data_type != 0x1C && data_type != 0x1D {
64        return Err(AprsError::InvalidFormat);
65    }
66
67    // Validate Mic-E longitude bytes are in valid range (28-127 per APRS101).
68    let lon_bytes = header.get(1..4).ok_or(AprsError::InvalidFormat)?;
69    for &b in lon_bytes {
70        if b < 28 {
71            return Err(AprsError::InvalidCoordinates);
72        }
73    }
74
75    // --- Latitude from destination address ---
76    // Each of the 6 destination chars encodes a latitude digit plus
77    // N/S and longitude offset flags. Chars 0-9 and A-L map to digits.
78    let mut lat_digits = [0u8; 6];
79    let mut north = true;
80    let mut lon_offset = 0i16;
81
82    for (i, &ch) in dest_head.iter().enumerate() {
83        let (digit, is_custom) = mice_dest_digit(ch)?;
84        if let Some(slot) = lat_digits.get_mut(i) {
85            *slot = digit;
86        }
87
88        // Chars 0-3: if custom (A-L), set message bits (we don't use them for position)
89        // Char 3: N/S flag — custom = North
90        if i == 3 {
91            north = is_custom;
92        }
93        // Char 4: longitude offset — custom = +100 degrees
94        if i == 4 && is_custom {
95            lon_offset = 100;
96        }
97        // Char 5: W/E flag — custom = West (negate longitude)
98    }
99
100    let d0 = f64::from(*lat_digits.first().ok_or(AprsError::InvalidCoordinates)?);
101    let d1 = f64::from(*lat_digits.get(1).ok_or(AprsError::InvalidCoordinates)?);
102    let d2 = f64::from(*lat_digits.get(2).ok_or(AprsError::InvalidCoordinates)?);
103    let d3 = f64::from(*lat_digits.get(3).ok_or(AprsError::InvalidCoordinates)?);
104    let d4 = f64::from(*lat_digits.get(4).ok_or(AprsError::InvalidCoordinates)?);
105    let d5 = f64::from(*lat_digits.get(5).ok_or(AprsError::InvalidCoordinates)?);
106    let lat_deg = d0.mul_add(10.0, d1);
107    let lat_min = d2.mul_add(10.0, d3) + d4 / 10.0 + d5 / 100.0;
108    let mut latitude = lat_deg + lat_min / 60.0;
109    if !north {
110        latitude = -latitude;
111    }
112
113    // --- Longitude from info field ---
114    // info[1] = degrees (d+28), info[2] = minutes (m+28), info[3] = hundredths (h+28)
115    let d_byte = *lon_bytes.first().ok_or(AprsError::InvalidCoordinates)?;
116    let m_byte = *lon_bytes.get(1).ok_or(AprsError::InvalidCoordinates)?;
117    let h_byte = *lon_bytes.get(2).ok_or(AprsError::InvalidCoordinates)?;
118    let d = i16::from(d_byte) - 28;
119    let m = i16::from(m_byte) - 28;
120    let h = i16::from(h_byte) - 28;
121
122    let mut lon_deg = d + lon_offset;
123    if (180..=189).contains(&lon_deg) {
124        lon_deg -= 80;
125    } else if (190..=199).contains(&lon_deg) {
126        lon_deg -= 190;
127    }
128
129    let lon_min = if m >= 60 { m - 60 } else { m };
130    let longitude_abs = f64::from(lon_deg) + (f64::from(lon_min) + f64::from(h) / 100.0) / 60.0;
131
132    // Char 5 of destination: custom = West
133    let dest5 = *dest_head.get(5).ok_or(AprsError::InvalidCoordinates)?;
134    let west = mice_dest_is_custom(dest5);
135    let longitude = if west { -longitude_abs } else { longitude_abs };
136
137    // --- Speed and course from info[4..7] (per APRS101 Chapter 10) ---
138    // SP+28 = info[4], DC+28 = info[5], SE+28 = info[6]
139    // Speed = (SP - 28) * 10 + (DC - 28) / 10  (integer division)
140    // Course = ((DC - 28) mod 10) * 100 + (SE - 28)
141    let (speed_knots, course_degrees) = match (header.get(4), header.get(5), header.get(6)) {
142        (Some(&speed_raw), Some(&course_hi), Some(&course_lo)) => {
143            let sp = u16::from(speed_raw).saturating_sub(28);
144            let dc = u16::from(course_hi).saturating_sub(28);
145            let se = u16::from(course_lo).saturating_sub(28);
146            let speed = sp * 10 + dc / 10;
147            let course_raw = (dc % 10) * 100 + se;
148            let speed_opt = if speed < 800 { Some(speed) } else { None };
149            let course_opt = if course_raw > 0 && course_raw <= 360 {
150                Some(course_raw)
151            } else {
152                None
153            };
154            (speed_opt, course_opt)
155        }
156        _ => (None, None),
157    };
158
159    // Symbol: info[7] = symbol code, info[8] = symbol table
160    let symbol_code = header.get(7).map_or('/', |&b| b as char);
161    let symbol_table = header.get(8).map_or('/', |&b| b as char);
162
163    let comment = info.get(9..).map_or_else(String::new, |rest| {
164        String::from_utf8_lossy(rest).into_owned()
165    });
166
167    // Decode the Mic-E standard message code from destination chars 0-2
168    // (APRS 1.0.1 §10.1 Table 10). `None` if any char is in the custom range.
169    let c0 = *dest_head.first().ok_or(AprsError::InvalidCoordinates)?;
170    let c1 = *dest_head.get(1).ok_or(AprsError::InvalidCoordinates)?;
171    let c2 = *dest_head.get(2).ok_or(AprsError::InvalidCoordinates)?;
172    let mice_message = mice_decode_message([c0, c1, c2]);
173
174    // Look for optional altitude in the comment (`<ccc>}` base-91, metres
175    // offset from -10000) per APRS 1.0.1 §10.1.1.
176    let mice_altitude_m = mice_decode_altitude(&comment);
177
178    let weather = extract_position_weather(symbol_code, &comment);
179    let extensions = parse_aprs_extensions(&comment);
180    Ok(AprsPosition {
181        latitude,
182        longitude,
183        symbol_table,
184        symbol_code,
185        speed_knots,
186        course_degrees,
187        comment,
188        weather,
189        extensions,
190        mice_message,
191        mice_altitude_m,
192        // Mic-E positions are not subject to §8.1.6 ambiguity masking.
193        ambiguity: PositionAmbiguity::None,
194    })
195}
196
197/// Extract a digit (0-9) from a Mic-E destination character.
198///
199/// Returns `(digit, is_custom)` where `is_custom` is true for A-K/L
200/// (used for N/S, lon offset, and W/E flags).
201const fn mice_dest_digit(ch: u8) -> Result<(u8, bool), AprsError> {
202    match ch {
203        b'0'..=b'9' => Ok((ch - b'0', false)),
204        b'A'..=b'J' => Ok((ch - b'A', true)), // A=0, B=1, ..., J=9
205        b'K' | b'L' | b'Z' => Ok((0, true)),  // K, L, Z map to space (0)
206        b'P'..=b'Y' => Ok((ch - b'P', true)), // P=0, Q=1, ..., Y=9
207        _ => Err(AprsError::InvalidCoordinates),
208    }
209}
210
211/// Check if a Mic-E destination character is an uppercase letter.
212///
213/// Used by chars 3-5 for N/S, +100 lon offset, and W/E flag decoding.
214const fn mice_dest_is_custom(ch: u8) -> bool {
215    matches!(ch, b'A'..=b'L' | b'P'..=b'Z')
216}
217
218/// Mic-E message-bit classification for destination chars 0-2.
219///
220/// Per APRS 1.0.1 §10.1, each of the first three destination characters
221/// contributes one bit (A, B, or C) to a 3-bit message code via three
222/// categories:
223///
224/// - `Std0` — character is `0`-`9` or `L`, contributes bit `0`
225/// - `Std1` — character is `P`-`Y` or `Z`, contributes bit `1`
226/// - `Custom` — character is `A`-`K`, marks the entire message as custom
227#[derive(Debug, Clone, Copy, PartialEq, Eq)]
228enum MiceMsgClass {
229    Std0,
230    Std1,
231    Custom,
232}
233
234const fn mice_msg_class(ch: u8) -> Option<MiceMsgClass> {
235    match ch {
236        b'0'..=b'9' | b'L' => Some(MiceMsgClass::Std0),
237        b'P'..=b'Y' | b'Z' => Some(MiceMsgClass::Std1),
238        b'A'..=b'K' => Some(MiceMsgClass::Custom),
239        _ => None,
240    }
241}
242
243/// Decode the 3-bit Mic-E message code from destination chars 0-2.
244///
245/// Returns `None` if any of the three chars is in the Custom range
246/// (`A`-`K`); those encode user-defined messages the library does not
247/// currently interpret. Returns `Some(MiceMessage)` for the 8 standard
248/// codes (APRS 1.0.1 Table 10).
249fn mice_decode_message(chars: [u8; 3]) -> Option<MiceMessage> {
250    let c0 = mice_msg_class(*chars.first()?)?;
251    let c1 = mice_msg_class(*chars.get(1)?)?;
252    let c2 = mice_msg_class(*chars.get(2)?)?;
253    if matches!(
254        (c0, c1, c2),
255        (MiceMsgClass::Custom, _, _) | (_, MiceMsgClass::Custom, _) | (_, _, MiceMsgClass::Custom)
256    ) {
257        return None;
258    }
259    let bit = |c| u8::from(matches!(c, MiceMsgClass::Std1));
260    let idx = (bit(c0) << 2) | (bit(c1) << 1) | bit(c2);
261    Some(match idx {
262        0b111 => MiceMessage::OffDuty,
263        0b110 => MiceMessage::EnRoute,
264        0b101 => MiceMessage::InService,
265        0b100 => MiceMessage::Returning,
266        0b011 => MiceMessage::Committed,
267        0b010 => MiceMessage::Special,
268        0b001 => MiceMessage::Priority,
269        _ => MiceMessage::Emergency, // 0b000
270    })
271}
272
273/// Map a Mic-E standard message code to its 3-bit `(A, B, C)` encoding.
274///
275/// Per APRS 1.0.1 §10.1 Table 10. Returns `(bit_a, bit_b, bit_c)` where
276/// `true` means "standard-1" (uppercase P-Y in the destination char).
277///
278/// Used by the Mic-E TX builder (`build_aprs_mice`) which lands in PR 3
279/// Task 5 together with the rest of the APRS builders.
280#[must_use]
281pub const fn mice_message_bits(msg: MiceMessage) -> (bool, bool, bool) {
282    match msg {
283        MiceMessage::OffDuty => (true, true, true),      // 111
284        MiceMessage::EnRoute => (true, true, false),     // 110
285        MiceMessage::InService => (true, false, true),   // 101
286        MiceMessage::Returning => (true, false, false),  // 100
287        MiceMessage::Committed => (false, true, true),   // 011
288        MiceMessage::Special => (false, true, false),    // 010
289        MiceMessage::Priority => (false, false, true),   // 001
290        MiceMessage::Emergency => (false, false, false), // 000
291    }
292}
293
294/// Decode Mic-E altitude from the comment field.
295///
296/// Per APRS 1.0.1 §10.1.1, altitude is optionally encoded as three
297/// base-91 characters (33-126, value = byte - 33) followed by a literal
298/// `}`. The decoded value is metres, offset from -10000 (so the wire
299/// value 10000 = sea level).
300///
301/// Searches the comment for the first occurrence of the `ccc}` pattern
302/// where each `c` is a valid base-91 printable character.
303fn mice_decode_altitude(comment: &str) -> Option<i32> {
304    let bytes = comment.as_bytes();
305    if bytes.len() < 4 {
306        return None;
307    }
308    for i in 0..=bytes.len() - 4 {
309        let window = bytes.get(i..i + 4)?;
310        if window.get(3) != Some(&b'}') {
311            continue;
312        }
313        let b0 = *window.first()?;
314        let b1 = *window.get(1)?;
315        let b2 = *window.get(2)?;
316        if !(33..=126).contains(&b0) || !(33..=126).contains(&b1) || !(33..=126).contains(&b2) {
317            continue;
318        }
319        let val = i32::from(b0 - 33) * 91 * 91 + i32::from(b1 - 33) * 91 + i32::from(b2 - 33);
320        return Some(val - 10_000);
321    }
322    None
323}
324
325/// Parse any APRS data frame, including Mic-E types that require the
326/// AX.25 destination address.
327///
328/// This is the recommended entry point when the full AX.25 packet is
329/// available. For Mic-E data type identifiers (`` ` ``, `'`, `0x1C`,
330/// `0x1D`), the destination callsign is used to decode the latitude
331/// via [`parse_mice_position`]. All other types delegate to
332/// [`crate::packet::parse_aprs_data`].
333///
334/// # Errors
335///
336/// Returns [`AprsError`] if the format is unrecognized or data is invalid.
337pub fn parse_aprs_data_full(info: &[u8], destination: &str) -> Result<AprsData, AprsError> {
338    let first = *info.first().ok_or(AprsError::InvalidFormat)?;
339
340    match first {
341        // Mic-E current/old data types
342        b'`' | b'\'' | 0x1C | 0x1D => {
343            parse_mice_position(destination, info).map(AprsData::Position)
344        }
345        _ => crate::packet::parse_aprs_data(info),
346    }
347}
348
349// ---------------------------------------------------------------------------
350// Tests
351// ---------------------------------------------------------------------------
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    type TestResult = Result<(), Box<dyn std::error::Error>>;
358
359    // ---- Mic-E position tests ----
360
361    #[test]
362    fn parse_mice_basic() -> TestResult {
363        // Destination "SUQU5P" → digits 3,5,1,5,5,0 — Off Duty
364        let dest = "SUQU5P";
365        let info: &[u8] = &[
366            0x60, // Mic-E current data type
367            125,  // longitude degrees + 28 = 97+28
368            73,   // longitude minutes + 28 = 45+28
369            58,   // longitude hundredths + 28 = 30+28
370            40,   // speed/course byte 1
371            40,   // speed/course byte 2
372            40,   // speed/course byte 3
373            b'>', // symbol code
374            b'/', // symbol table
375        ];
376
377        let pos = parse_mice_position(dest, info)?;
378        assert!((pos.latitude - 35.258).abs() < 0.01, "lat={}", pos.latitude);
379        assert!(
380            (pos.longitude - (-97.755)).abs() < 0.01,
381            "lon={}",
382            pos.longitude
383        );
384        assert_eq!(pos.symbol_code, '>');
385        assert_eq!(pos.symbol_table, '/');
386        assert_eq!(pos.speed_knots, Some(121));
387        assert_eq!(pos.course_degrees, Some(212));
388        Ok(())
389    }
390
391    #[test]
392    fn parse_mice_invalid_type() {
393        assert!(
394            parse_mice_position("SUQU5P", b"!test data").is_err(),
395            "non-mic-e type"
396        );
397    }
398
399    #[test]
400    fn parse_mice_too_short() {
401        assert!(
402            parse_mice_position("SHORT", &[0x60, 1, 2]).is_err(),
403            "too-short rejected",
404        );
405    }
406
407    #[test]
408    fn parse_mice_speed_ge_800_rejected() -> TestResult {
409        // SP = 108-28 = 80, DC = 28-28 = 0, SE = 28-28 = 0
410        // speed = 80*10 + 0/10 = 800 → should be rejected (>= 800)
411        let dest = "SUQU5P";
412        let info: &[u8] = &[0x60, 125, 73, 58, 108, 28, 28, b'>', b'/'];
413        let pos = parse_mice_position(dest, info)?;
414        assert_eq!(pos.speed_knots, None);
415        Ok(())
416    }
417
418    #[test]
419    fn mice_decode_message_off_duty() {
420        assert_eq!(mice_decode_message(*b"PPP"), Some(MiceMessage::OffDuty));
421    }
422
423    #[test]
424    fn mice_decode_message_emergency() {
425        assert_eq!(mice_decode_message(*b"000"), Some(MiceMessage::Emergency));
426    }
427
428    #[test]
429    fn mice_decode_message_in_service() {
430        assert_eq!(mice_decode_message(*b"P0P"), Some(MiceMessage::InService));
431    }
432
433    #[test]
434    fn mice_decode_message_custom_returns_none() {
435        assert_eq!(mice_decode_message(*b"APP"), None);
436        assert_eq!(mice_decode_message(*b"PKP"), None);
437    }
438
439    #[test]
440    fn mice_decode_altitude_sea_level() -> TestResult {
441        // Sea level = 0 m → wire value 10000. Base-91 of 10000 = ('"', '3', 'r').
442        let altitude = mice_decode_altitude("\"3r}").ok_or("missing altitude")?;
443        assert_eq!(altitude, 0);
444        Ok(())
445    }
446
447    #[test]
448    fn mice_decode_altitude_absent() {
449        assert_eq!(mice_decode_altitude("no altitude here"), None);
450        assert_eq!(mice_decode_altitude(""), None);
451        assert_eq!(mice_decode_altitude("abc"), None);
452    }
453
454    #[test]
455    fn parse_mice_populates_message_and_altitude() -> TestResult {
456        let mut info = vec![0x60u8, 125, 73, 58, 40, 40, 40, b'>', b'/'];
457        info.extend_from_slice(b"\"3r}");
458        let pos = parse_mice_position("SUQU5P", &info)?;
459        assert_eq!(pos.mice_message, Some(MiceMessage::OffDuty));
460        assert_eq!(pos.mice_altitude_m, Some(0));
461        Ok(())
462    }
463
464    #[test]
465    fn parse_mice_course_zero_is_none() -> TestResult {
466        // SP = 28-28 = 0, DC = 28-28 = 0, SE = 28-28 = 0
467        // course 0 = not known → None
468        let dest = "SUQU5P";
469        let info: &[u8] = &[0x60, 125, 73, 58, 28, 28, 28, b'>', b'/'];
470        let pos = parse_mice_position(dest, info)?;
471        assert_eq!(pos.speed_knots, Some(0));
472        assert_eq!(pos.course_degrees, None);
473        Ok(())
474    }
475
476    // ---- Mic-E byte range validation tests ----
477
478    #[test]
479    fn mice_rejects_low_longitude_bytes() {
480        // info[1] = 27 (below valid Mic-E range of 28)
481        let dest = "SUQU5P";
482        let info: &[u8] = &[0x60, 27, 73, 58, 40, 40, 40, b'>', b'/'];
483        assert_eq!(
484            parse_mice_position(dest, info),
485            Err(AprsError::InvalidCoordinates)
486        );
487    }
488
489    #[test]
490    fn mice_rejects_zero_longitude_byte() {
491        let dest = "SUQU5P";
492        let info: &[u8] = &[0x60, 125, 0, 58, 40, 40, 40, b'>', b'/'];
493        assert_eq!(
494            parse_mice_position(dest, info),
495            Err(AprsError::InvalidCoordinates)
496        );
497    }
498
499    #[test]
500    fn mice_accepts_minimum_valid_byte() {
501        let dest = "SUQU5P";
502        let info: &[u8] = &[0x60, 28, 28, 28, 40, 40, 40, b'>', b'/'];
503        assert!(parse_mice_position(dest, info).is_ok(), "min bytes ok");
504    }
505
506    // ---- parse_aprs_data_full tests ----
507
508    #[test]
509    fn full_dispatch_mice_current() -> TestResult {
510        let dest = "SUQU5P";
511        let info: &[u8] = &[0x60, 125, 73, 58, 40, 40, 40, b'>', b'/'];
512        let result = parse_aprs_data_full(info, dest)?;
513        assert!(matches!(result, AprsData::Position(_)));
514        Ok(())
515    }
516
517    #[test]
518    fn full_dispatch_mice_old() -> TestResult {
519        let dest = "SUQU5P";
520        let info: &[u8] = &[b'\'', 125, 73, 58, 40, 40, 40, b'>', b'/'];
521        let result = parse_aprs_data_full(info, dest)?;
522        assert!(matches!(result, AprsData::Position(_)));
523        Ok(())
524    }
525
526    #[test]
527    fn full_dispatch_mice_0x1c() -> TestResult {
528        let dest = "SUQU5P";
529        let info: &[u8] = &[0x1C, 125, 73, 58, 40, 40, 40, b'>', b'/'];
530        let result = parse_aprs_data_full(info, dest)?;
531        assert!(matches!(result, AprsData::Position(_)));
532        Ok(())
533    }
534
535    #[test]
536    fn full_dispatch_mice_0x1d() -> TestResult {
537        let dest = "SUQU5P";
538        let info: &[u8] = &[0x1D, 125, 73, 58, 40, 40, 40, b'>', b'/'];
539        let result = parse_aprs_data_full(info, dest)?;
540        assert!(matches!(result, AprsData::Position(_)));
541        Ok(())
542    }
543
544    #[test]
545    fn full_dispatch_non_mice_delegates() -> TestResult {
546        let info = b"!4903.50N/07201.75W-Test";
547        let result = parse_aprs_data_full(info, "APRS")?;
548        assert!(matches!(result, AprsData::Position(_)));
549        Ok(())
550    }
551
552    #[test]
553    fn full_dispatch_empty_info() {
554        assert!(parse_aprs_data_full(b"", "APRS").is_err(), "empty rejected");
555    }
556}