aprs/
position.rs

1//! APRS position reports (uncompressed and compressed).
2
3use crate::error::AprsError;
4use crate::mic_e::MiceMessage;
5use crate::packet::{AprsDataExtension, PositionAmbiguity, parse_aprs_extensions};
6use crate::weather::{AprsWeather, extract_position_weather};
7
8/// A parsed APRS position report.
9///
10/// Includes optional speed/course fields populated by Mic-E decoding and
11/// optional embedded weather data populated when the station reports with
12/// the weather-station symbol code `_`. Data extensions (course/speed,
13/// PHG, altitude, DAO) found in the comment field are parsed
14/// automatically and exposed via [`Self::extensions`].
15#[derive(Debug, Clone, PartialEq)]
16pub struct AprsPosition {
17    /// Latitude in decimal degrees (positive = North).
18    pub latitude: f64,
19    /// Longitude in decimal degrees (positive = East).
20    pub longitude: f64,
21    /// APRS symbol table identifier character.
22    pub symbol_table: char,
23    /// APRS symbol code character.
24    pub symbol_code: char,
25    /// Speed in knots (from Mic-E or course/speed extension).
26    pub speed_knots: Option<u16>,
27    /// Course in degrees (from Mic-E or course/speed extension).
28    pub course_degrees: Option<u16>,
29    /// Optional comment/extension text after the position.
30    pub comment: String,
31    /// Optional weather data embedded in the position comment.
32    ///
33    /// Populated when the symbol code is `_` (weather station) and the
34    /// comment starts with the `DDD/SSS` wind direction/speed extension,
35    /// followed by the remaining weather fields. See APRS 1.0.1 §12.1.
36    pub weather: Option<AprsWeather>,
37    /// Parsed data extensions (course/speed, PHG, altitude, DAO) found in
38    /// the comment field.
39    ///
40    /// Populated automatically by [`parse_aprs_position`] via
41    /// [`parse_aprs_extensions`]. Fields that aren't present in the
42    /// comment are `None`.
43    pub extensions: AprsDataExtension,
44    /// Mic-E standard message code (only populated by
45    /// [`crate::mic_e::parse_mice_position`]).
46    pub mice_message: Option<MiceMessage>,
47    /// Mic-E altitude in metres, decoded from the comment per APRS 1.0.1
48    /// §10.1.1 (three base-91 chars followed by `}`, offset from -10000).
49    pub mice_altitude_m: Option<i32>,
50    /// Position ambiguity level (APRS 1.0.1 §8.1.6).
51    ///
52    /// Stations can deliberately reduce their precision by replacing
53    /// trailing lat/lon digits with spaces; this field records how many
54    /// digits were masked. Mic-E and compressed positions do not use
55    /// ambiguity and always report [`PositionAmbiguity::None`].
56    pub ambiguity: PositionAmbiguity,
57}
58
59/// Parse APRS latitude from the standard `DDMM.HH[N/S]` format.
60///
61/// Returns `(degrees, ambiguity)` where `degrees` is the decimal-degree
62/// value (positive North) and `ambiguity` counts how many trailing
63/// digits were replaced with spaces per APRS 1.0.1 §8.1.6.
64fn parse_aprs_latitude(s: &[u8]) -> Result<(f64, PositionAmbiguity), AprsError> {
65    let bytes_slice = s.get(..8).ok_or(AprsError::InvalidCoordinates)?;
66    let bytes: [u8; 8] = bytes_slice
67        .try_into()
68        .map_err(|_| AprsError::InvalidCoordinates)?;
69    // Field layout: DD MM . HH H/S   (indices 0..7, hemisphere at 7)
70    let field = bytes.get(..7).ok_or(AprsError::InvalidCoordinates)?;
71    let (digits, ambiguity) = unmask_coord_digits(field, 4)?;
72    let text = std::str::from_utf8(&digits).map_err(|_| AprsError::InvalidCoordinates)?;
73    let deg_str = text.get(..2).ok_or(AprsError::InvalidCoordinates)?;
74    let min_str = text.get(2..7).ok_or(AprsError::InvalidCoordinates)?;
75    let degrees: f64 = deg_str.parse().map_err(|_| AprsError::InvalidCoordinates)?;
76    let minutes: f64 = min_str.parse().map_err(|_| AprsError::InvalidCoordinates)?;
77    let hemisphere = *bytes.get(7).ok_or(AprsError::InvalidCoordinates)?;
78
79    let mut lat = degrees + minutes / 60.0;
80    if hemisphere == b'S' {
81        lat = -lat;
82    } else if hemisphere != b'N' {
83        return Err(AprsError::InvalidCoordinates);
84    }
85    Ok((lat, ambiguity))
86}
87
88/// Parse APRS longitude from the standard `DDDMM.HH[E/W]` format.
89fn parse_aprs_longitude(s: &[u8]) -> Result<(f64, PositionAmbiguity), AprsError> {
90    let bytes_slice = s.get(..9).ok_or(AprsError::InvalidCoordinates)?;
91    let bytes: [u8; 9] = bytes_slice
92        .try_into()
93        .map_err(|_| AprsError::InvalidCoordinates)?;
94    // Field layout: DDD MM . HH E/W  (indices 0..8, hemisphere at 8)
95    let field = bytes.get(..8).ok_or(AprsError::InvalidCoordinates)?;
96    let (digits, ambiguity) = unmask_coord_digits(field, 5)?;
97    let text = std::str::from_utf8(&digits).map_err(|_| AprsError::InvalidCoordinates)?;
98    let deg_str = text.get(..3).ok_or(AprsError::InvalidCoordinates)?;
99    let min_str = text.get(3..8).ok_or(AprsError::InvalidCoordinates)?;
100    let degrees: f64 = deg_str.parse().map_err(|_| AprsError::InvalidCoordinates)?;
101    let minutes: f64 = min_str.parse().map_err(|_| AprsError::InvalidCoordinates)?;
102    let hemisphere = *bytes.get(8).ok_or(AprsError::InvalidCoordinates)?;
103
104    let mut lon = degrees + minutes / 60.0;
105    if hemisphere == b'W' {
106        lon = -lon;
107    } else if hemisphere != b'E' {
108        return Err(AprsError::InvalidCoordinates);
109    }
110    Ok((lon, ambiguity))
111}
112
113/// Replace space-masked digits with `'0'` and return the masked-count
114/// alongside the rebuilt byte sequence. `dot_idx` is the index of the
115/// literal `.` in the field (4 for latitude, 5 for longitude).
116fn unmask_coord_digits(
117    field: &[u8],
118    dot_idx: usize,
119) -> Result<([u8; 8], PositionAmbiguity), AprsError> {
120    if field.len() > 8 {
121        return Err(AprsError::InvalidCoordinates);
122    }
123    let dot_byte = *field.get(dot_idx).ok_or(AprsError::InvalidCoordinates)?;
124    if dot_byte != b'.' {
125        return Err(AprsError::InvalidCoordinates);
126    }
127    // Ambiguity is counted by walking the mask-eligible positions from
128    // rightmost back to the start until we stop seeing spaces.
129    // Mask order (rightmost first): HH tens, HH ones, MM ones, MM tens
130    let mask_order: [usize; 4] = if dot_idx == 4 {
131        [6, 5, 3, 2] // lat: HH(6,5), MM(3,2)
132    } else {
133        [7, 6, 4, 3] // lon: HH(7,6), MM(4,3)
134    };
135    let mut count: u8 = 0;
136    for &pos in &mask_order {
137        if field.get(pos) == Some(&b' ') {
138            count += 1;
139        } else {
140            break;
141        }
142    }
143    // Build output buffer.
144    let mut out = [b'0'; 8];
145    if let Some(dst) = out.get_mut(..field.len()) {
146        dst.copy_from_slice(field);
147    }
148    let masked = mask_order.get(..count as usize).unwrap_or(&[]);
149    for pos in masked {
150        if let Some(slot) = out.get_mut(*pos) {
151            *slot = b'0';
152        }
153    }
154    // Also fail if we see a space at a non-maskable position (outside
155    // the trailing run).
156    for (i, &b) in field.iter().enumerate() {
157        if b == b' ' && !masked.contains(&i) {
158            return Err(AprsError::InvalidCoordinates);
159        }
160    }
161    let ambiguity = match count {
162        0 => PositionAmbiguity::None,
163        1 => PositionAmbiguity::OneDigit,
164        2 => PositionAmbiguity::TwoDigits,
165        3 => PositionAmbiguity::ThreeDigits,
166        _ => PositionAmbiguity::FourDigits,
167    };
168    Ok((out, ambiguity))
169}
170
171/// Parse an APRS position report from an AX.25 information field.
172///
173/// Supports three APRS position formats (per APRS101.PDF chapters 8-9):
174/// - **Uncompressed**: `!`/`=`/`/`/`@` with ASCII lat/lon (`DDMM.HH`)
175/// - **Compressed**: `!`/`=`/`/`/`@` with base-91 encoded lat/lon (13 bytes)
176///
177/// For **Mic-E** positions (`` ` ``/`'`), use
178/// [`crate::mic_e::parse_mice_position`] which also requires the AX.25
179/// destination address.
180///
181/// # Errors
182///
183/// Returns [`AprsError`] if the format is unrecognized or coordinates are invalid.
184pub fn parse_aprs_position(info: &[u8]) -> Result<AprsPosition, AprsError> {
185    let data_type = *info.first().ok_or(AprsError::InvalidFormat)?;
186    let body = match data_type {
187        // Position without timestamp: ! or =
188        b'!' | b'=' => info.get(1..).ok_or(AprsError::InvalidFormat)?,
189        // Position with timestamp: / or @
190        // Timestamp is 7 characters after the type byte
191        b'/' | b'@' => info.get(8..).ok_or(AprsError::InvalidFormat)?,
192        _ => return Err(AprsError::InvalidFormat),
193    };
194
195    let first = *body.first().ok_or(AprsError::InvalidFormat)?;
196    // Detect compressed vs uncompressed: if the first byte is a digit (0-9),
197    // it's uncompressed latitude. Otherwise it's a compressed symbol table char.
198    if first.is_ascii_digit() {
199        parse_uncompressed_body(body)
200    } else {
201        parse_compressed_body(body)
202    }
203}
204
205/// Parse uncompressed APRS position body.
206///
207/// Format: `lat(8) sym_table(1) lon(9) sym_code(1) [comment]` = 19+ bytes.
208///
209/// # Errors
210///
211/// Returns [`AprsError::InvalidFormat`] if the body is shorter than 19
212/// bytes or [`AprsError::InvalidCoordinates`] if the latitude or
213/// longitude fields are malformed.
214pub fn parse_uncompressed_body(body: &[u8]) -> Result<AprsPosition, AprsError> {
215    let lat_slice = body.get(..8).ok_or(AprsError::InvalidFormat)?;
216    let (latitude, lat_ambig) = parse_aprs_latitude(lat_slice)?;
217    let symbol_table = *body.get(8).ok_or(AprsError::InvalidFormat)? as char;
218    let lon_slice = body.get(9..18).ok_or(AprsError::InvalidFormat)?;
219    let (longitude, lon_ambig) = parse_aprs_longitude(lon_slice)?;
220    let symbol_code = *body.get(18).ok_or(AprsError::InvalidFormat)? as char;
221    // A position's ambiguity is the maximum of the two component
222    // ambiguities — whichever field was masked more aggressively wins.
223    let ambiguity = std::cmp::max_by_key(lat_ambig, lon_ambig, |a| match a {
224        PositionAmbiguity::None => 0,
225        PositionAmbiguity::OneDigit => 1,
226        PositionAmbiguity::TwoDigits => 2,
227        PositionAmbiguity::ThreeDigits => 3,
228        PositionAmbiguity::FourDigits => 4,
229    });
230
231    let comment = body.get(19..).map_or_else(String::new, |rest| {
232        String::from_utf8_lossy(rest).into_owned()
233    });
234
235    let weather = extract_position_weather(symbol_code, &comment);
236    let extensions = parse_aprs_extensions(&comment);
237    // If the comment had a CSE/SPD extension, surface it on speed/course
238    // too so callers that only read those fields see the data.
239    let (speed_knots, course_degrees) = match extensions.course_speed {
240        Some((course, speed)) => (Some(speed), Some(course)),
241        None => (None, None),
242    };
243    Ok(AprsPosition {
244        latitude,
245        longitude,
246        symbol_table,
247        symbol_code,
248        speed_knots,
249        course_degrees,
250        comment,
251        weather,
252        extensions,
253        mice_message: None,
254        mice_altitude_m: None,
255        ambiguity,
256    })
257}
258
259/// Parse compressed APRS position body (APRS101.PDF Chapter 9).
260///
261/// Format: `sym_table(1) YYYY(4) XXXX(4) sym_code(1) cs(1) s(1) t(1)` = 13 bytes.
262/// YYYY and XXXX are base-91 encoded (each byte = ASCII 33-124, value = byte - 33).
263///
264/// Latitude:  `90 - (YYYY / 380926.0)` degrees
265/// Longitude: `-180 + (XXXX / 190463.0)` degrees
266///
267/// # Errors
268///
269/// Returns [`AprsError::InvalidFormat`] if the body is shorter than 13
270/// bytes or [`AprsError::InvalidCoordinates`] if the base-91 lat/lon
271/// fields contain bytes outside the `33..=124` range.
272pub fn parse_compressed_body(body: &[u8]) -> Result<AprsPosition, AprsError> {
273    // Require the full 13-byte compressed body upfront.
274    let header = body.get(..13).ok_or(AprsError::InvalidFormat)?;
275    let symbol_table = *header.first().ok_or(AprsError::InvalidFormat)? as char;
276    let lat_bytes = header.get(1..5).ok_or(AprsError::InvalidFormat)?;
277    let lon_bytes = header.get(5..9).ok_or(AprsError::InvalidFormat)?;
278    let lat_val = decode_base91_4(lat_bytes)?;
279    let lon_val = decode_base91_4(lon_bytes)?;
280    let symbol_code = *header.get(9).ok_or(AprsError::InvalidFormat)? as char;
281
282    let latitude = 90.0 - f64::from(lat_val) / 380_926.0;
283    let longitude = -180.0 + f64::from(lon_val) / 190_463.0;
284
285    // Decode the 3-byte cs/s/t tail per APRS 1.0.1 §9.
286    let cs_byte = *header.get(10).ok_or(AprsError::InvalidFormat)?;
287    let s_byte = *header.get(11).ok_or(AprsError::InvalidFormat)?;
288    let t_byte = *header.get(12).ok_or(AprsError::InvalidFormat)?;
289    let (compressed_altitude_ft, compressed_course_speed) =
290        decode_compressed_tail(cs_byte, s_byte, t_byte);
291
292    let comment = body.get(13..).map_or_else(String::new, |rest| {
293        String::from_utf8_lossy(rest).into_owned()
294    });
295
296    let weather = extract_position_weather(symbol_code, &comment);
297    let extensions = parse_aprs_extensions(&comment);
298    // Surface course/speed into the direct fields too.
299    let (speed_knots, course_degrees) =
300        compressed_course_speed.map_or((None, None), |(course, speed)| (Some(speed), Some(course)));
301    let final_extensions = if let Some(alt) = compressed_altitude_ft {
302        AprsDataExtension {
303            altitude_ft: Some(alt),
304            ..extensions
305        }
306    } else {
307        extensions
308    };
309    Ok(AprsPosition {
310        latitude,
311        longitude,
312        symbol_table,
313        symbol_code,
314        speed_knots,
315        course_degrees,
316        comment,
317        weather,
318        extensions: final_extensions,
319        mice_message: None,
320        mice_altitude_m: None,
321        // Compressed positions do not use APRS §8.1.6 ambiguity.
322        ambiguity: PositionAmbiguity::None,
323    })
324}
325
326/// Decode the 3-byte `cs`/`s`/`t` compression tail of a compressed APRS
327/// position report per APRS 1.0.1 §9 Table 10.
328///
329/// Returns `(altitude_ft, course_speed)` where either may be `None`.
330fn decode_compressed_tail(cs: u8, s: u8, t: u8) -> (Option<i32>, Option<(u16, u16)>) {
331    // Space in the `cs` column means "no data."
332    if cs == b' ' {
333        return (None, None);
334    }
335    // The `t` byte minus 33 gives a 6-bit compression type value. Bits
336    // 3-4 (0x18) select the semantic meaning of `cs`/`s`.
337    let t_val = t.saturating_sub(33);
338    let type_bits = (t_val >> 3) & 0x03;
339    match type_bits {
340        // 0b00 / 0b01: course (c) + speed (s). Course is (cs - 33) * 4
341        // degrees. Speed is 1.08^(s - 33) - 1 knots.
342        0 | 1 => {
343            let c = cs.saturating_sub(33);
344            let s_val = s.saturating_sub(33);
345            #[allow(
346                clippy::cast_possible_truncation,
347                clippy::cast_sign_loss,
348                clippy::cast_precision_loss
349            )]
350            let speed_knots = (1.08_f64.powi(i32::from(s_val)) - 1.0).round() as u16;
351            let course_deg = u16::from(c) * 4;
352            // Course 0 == "no data" per spec convention.
353            if course_deg == 0 && speed_knots == 0 {
354                (None, None)
355            } else {
356                (None, Some((course_deg, speed_knots)))
357            }
358        }
359        // 0b10: altitude. cs,s = base-91 two-char altitude, value =
360        // 1.002^((cs-33)*91 + (s-33)) feet.
361        2 => {
362            let c = i32::from(cs.saturating_sub(33));
363            let s_val = i32::from(s.saturating_sub(33));
364            let exponent = c * 91 + s_val;
365            #[allow(
366                clippy::cast_possible_truncation,
367                clippy::cast_sign_loss,
368                clippy::cast_precision_loss
369            )]
370            let alt_ft = 1.002_f64.powi(exponent).round() as i32;
371            (Some(alt_ft), None)
372        }
373        // 0b11 (range): not currently surfaced.
374        _ => (None, None),
375    }
376}
377
378/// Decode a 4-byte base-91 value.
379///
380/// Each byte is in the ASCII range 33-124. The value is:
381/// `b[0]*91^3 + b[1]*91^2 + b[2]*91 + b[3]`
382///
383/// # Errors
384///
385/// Returns [`AprsError::InvalidCoordinates`] if `bytes` is shorter than
386/// 4 bytes or any byte is outside the `33..=124` base-91 range.
387pub fn decode_base91_4(bytes: &[u8]) -> Result<u32, AprsError> {
388    let window = bytes.get(..4).ok_or(AprsError::InvalidCoordinates)?;
389    let mut val: u32 = 0;
390    for &b in window {
391        if !(33..=124).contains(&b) {
392            return Err(AprsError::InvalidCoordinates);
393        }
394        val = val * 91 + u32::from(b - 33);
395    }
396    Ok(val)
397}
398
399// ---------------------------------------------------------------------------
400// Tests
401// ---------------------------------------------------------------------------
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406
407    type TestResult = Result<(), Box<dyn std::error::Error>>;
408
409    // ---- APRS position tests ----
410
411    #[test]
412    fn parse_aprs_position_no_timestamp() -> TestResult {
413        let info = b"!4903.50N/07201.75W-Test comment";
414        let pos = parse_aprs_position(info)?;
415        // 49 degrees 3.50 minutes N = 49.058333...
416        assert!(
417            (pos.latitude - 49.058_333).abs() < 0.001,
418            "lat={}",
419            pos.latitude
420        );
421        // 72 degrees 1.75 minutes W = -72.029166...
422        assert!(
423            (pos.longitude - (-72.029_166)).abs() < 0.001,
424            "lon={}",
425            pos.longitude
426        );
427        assert_eq!(pos.symbol_table, '/');
428        assert_eq!(pos.symbol_code, '-');
429        assert_eq!(pos.comment, "Test comment");
430        Ok(())
431    }
432
433    #[test]
434    fn parse_aprs_position_with_timestamp() -> TestResult {
435        // '@' type with DHM timestamp "092345z"
436        let info = b"@092345z4903.50N/07201.75W-";
437        let pos = parse_aprs_position(info)?;
438        assert!((pos.latitude - 49.058_333).abs() < 0.001, "lat check");
439        assert!((pos.longitude - (-72.029_166)).abs() < 0.001, "lon check");
440        Ok(())
441    }
442
443    #[test]
444    fn parse_aprs_position_south_east() -> TestResult {
445        let info = b"!3356.65S/15113.72E>";
446        let pos = parse_aprs_position(info)?;
447        assert!(
448            pos.latitude < 0.0,
449            "expected South, got lat={}",
450            pos.latitude
451        );
452        assert!(
453            pos.longitude > 0.0,
454            "expected East, got lon={}",
455            pos.longitude
456        );
457        Ok(())
458    }
459
460    #[test]
461    fn parse_aprs_position_messaging_enabled() -> TestResult {
462        let info = b"=4903.50N/07201.75W-";
463        let pos = parse_aprs_position(info)?;
464        assert!((pos.latitude - 49.058_333).abs() < 0.001, "lat check");
465        Ok(())
466    }
467
468    #[test]
469    fn parse_aprs_position_invalid_type() {
470        let info = b"X4903.50N/07201.75W-";
471        assert!(
472            parse_aprs_position(info).is_err(),
473            "expected error for invalid type",
474        );
475    }
476
477    #[test]
478    fn parse_aprs_position_too_short() {
479        assert!(
480            parse_aprs_position(b"!short").is_err(),
481            "expected error for short input",
482        );
483    }
484
485    #[test]
486    fn parse_aprs_position_empty() {
487        assert!(
488            parse_aprs_position(b"").is_err(),
489            "expected error for empty"
490        );
491    }
492
493    // ---- APRS compressed position tests ----
494
495    #[test]
496    fn parse_aprs_compressed_position() -> TestResult {
497        // Use computed example with known values.
498        // lat_val = 3493929 → lat = 90 - 3493929/380926 = 80.828
499        //   bytes: ('%', 'Z', 't', 'l')
500        // lon_val = 4567890 → lon = -180 + 4567890/190463 = -156.018
501        //   bytes: (''', '&', 'X', 'W')
502        let body: &[u8] = b"/%Ztl'&XW> sT";
503        let mut info = vec![b'!'];
504        info.extend_from_slice(body);
505
506        let pos = parse_aprs_position(&info)?;
507        assert!((pos.latitude - 80.828).abs() < 0.01, "lat={}", pos.latitude);
508        assert!(
509            (pos.longitude - (-156.018)).abs() < 0.01,
510            "lon={}",
511            pos.longitude
512        );
513        assert_eq!(pos.symbol_table, '/');
514        assert_eq!(pos.symbol_code, '>');
515        Ok(())
516    }
517
518    #[test]
519    fn parse_aprs_compressed_with_timestamp() -> TestResult {
520        let mut info = Vec::new();
521        info.push(b'@');
522        info.extend_from_slice(b"092345z"); // 7-char timestamp
523        info.extend_from_slice(b"/%Ztl'&XW> sT"); // compressed body
524        let pos = parse_aprs_position(&info)?;
525        assert!((pos.latitude - 80.828).abs() < 0.01, "lat check");
526        Ok(())
527    }
528
529    #[test]
530    fn parse_aprs_compressed_too_short() {
531        let info = b"!/short";
532        assert!(parse_aprs_position(info).is_err(), "too-short compressed");
533    }
534
535    #[test]
536    fn base91_decode_zero() -> TestResult {
537        assert_eq!(decode_base91_4(b"!!!!")?, 0);
538        Ok(())
539    }
540
541    #[test]
542    fn base91_decode_max() -> TestResult {
543        let val = decode_base91_4(b"||||")?;
544        let expected = 91_u32 * 753_571 + 91 * 8281 + 91 * 91 + 91;
545        assert_eq!(val, expected);
546        Ok(())
547    }
548
549    #[test]
550    fn base91_decode_invalid_char() {
551        assert!(
552            decode_base91_4(b" !!!").is_err(),
553            "space is below valid range"
554        );
555    }
556
557    #[test]
558    fn parse_position_with_one_digit_ambiguity() -> TestResult {
559        let info = b"!4903.5 N/07201.75W-";
560        let pos = parse_aprs_position(info)?;
561        assert_eq!(pos.ambiguity, PositionAmbiguity::OneDigit);
562        assert!((pos.latitude - 49.0583).abs() < 0.001, "lat check");
563        Ok(())
564    }
565
566    #[test]
567    fn parse_position_with_two_digit_ambiguity() -> TestResult {
568        let info = b"!4903.  N/07201.75W-";
569        let pos = parse_aprs_position(info)?;
570        assert_eq!(pos.ambiguity, PositionAmbiguity::TwoDigits);
571        Ok(())
572    }
573
574    #[test]
575    fn parse_position_with_four_digit_ambiguity() -> TestResult {
576        let info = b"!49  .  N/072  .  W-";
577        let pos = parse_aprs_position(info)?;
578        assert_eq!(pos.ambiguity, PositionAmbiguity::FourDigits);
579        Ok(())
580    }
581
582    #[test]
583    fn parse_position_full_precision_has_no_ambiguity() -> TestResult {
584        let info = b"!4903.50N/07201.75W-";
585        let pos = parse_aprs_position(info)?;
586        assert_eq!(pos.ambiguity, PositionAmbiguity::None);
587        Ok(())
588    }
589
590    #[test]
591    fn parse_position_populates_extensions_from_comment() -> TestResult {
592        let info = b"!3515.00N/09745.00W>088/036/A=001234hello";
593        let pos = parse_aprs_position(info)?;
594        assert_eq!(pos.extensions.course_speed, Some((88, 36)));
595        assert_eq!(pos.extensions.altitude_ft, Some(1234));
596        assert_eq!(pos.speed_knots, Some(36));
597        assert_eq!(pos.course_degrees, Some(88));
598        Ok(())
599    }
600
601    #[test]
602    fn parse_position_embedded_weather() -> TestResult {
603        let info = b"!3515.00N/09745.00W_090/010g015t072r001P020h55b10135";
604        let pos = parse_aprs_position(info)?;
605        assert_eq!(pos.symbol_code, '_');
606        let wx = pos.weather.ok_or("embedded weather missing")?;
607        assert_eq!(wx.wind_direction, Some(90));
608        assert_eq!(wx.wind_speed, Some(10));
609        assert_eq!(wx.wind_gust, Some(15));
610        assert_eq!(wx.temperature, Some(72));
611        assert_eq!(wx.rain_1h, Some(1));
612        assert_eq!(wx.rain_since_midnight, Some(20));
613        assert_eq!(wx.humidity, Some(55));
614        assert_eq!(wx.pressure, Some(10135));
615        Ok(())
616    }
617
618    #[test]
619    fn parse_position_without_weather_symbol_has_no_weather() -> TestResult {
620        let info = b"!3515.00N/09745.00W>mobile comment";
621        let pos = parse_aprs_position(info)?;
622        assert!(pos.weather.is_none(), "no weather expected");
623        Ok(())
624    }
625
626    #[test]
627    fn parse_position_weather_symbol_bad_format_has_no_weather() -> TestResult {
628        let info = b"!3515.00N/09745.00W_hello";
629        let pos = parse_aprs_position(info)?;
630        assert!(pos.weather.is_none(), "bad format → no weather");
631        Ok(())
632    }
633}