1use 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#[derive(Debug, Clone, PartialEq)]
16pub struct AprsPosition {
17 pub latitude: f64,
19 pub longitude: f64,
21 pub symbol_table: char,
23 pub symbol_code: char,
25 pub speed_knots: Option<u16>,
27 pub course_degrees: Option<u16>,
29 pub comment: String,
31 pub weather: Option<AprsWeather>,
37 pub extensions: AprsDataExtension,
44 pub mice_message: Option<MiceMessage>,
47 pub mice_altitude_m: Option<i32>,
50 pub ambiguity: PositionAmbiguity,
57}
58
59fn 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 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
88fn 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 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
113fn 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 let mask_order: [usize; 4] = if dot_idx == 4 {
131 [6, 5, 3, 2] } else {
133 [7, 6, 4, 3] };
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 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 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
171pub 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 b'!' | b'=' => info.get(1..).ok_or(AprsError::InvalidFormat)?,
189 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 if first.is_ascii_digit() {
199 parse_uncompressed_body(body)
200 } else {
201 parse_compressed_body(body)
202 }
203}
204
205pub 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 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 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
259pub fn parse_compressed_body(body: &[u8]) -> Result<AprsPosition, AprsError> {
273 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 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 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 ambiguity: PositionAmbiguity::None,
323 })
324}
325
326fn decode_compressed_tail(cs: u8, s: u8, t: u8) -> (Option<i32>, Option<(u16, u16)>) {
331 if cs == b' ' {
333 return (None, None);
334 }
335 let t_val = t.saturating_sub(33);
338 let type_bits = (t_val >> 3) & 0x03;
339 match type_bits {
340 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 if course_deg == 0 && speed_knots == 0 {
354 (None, None)
355 } else {
356 (None, Some((course_deg, speed_knots)))
357 }
358 }
359 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 _ => (None, None),
375 }
376}
377
378pub 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#[cfg(test)]
404mod tests {
405 use super::*;
406
407 type TestResult = Result<(), Box<dyn std::error::Error>>;
408
409 #[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 assert!(
417 (pos.latitude - 49.058_333).abs() < 0.001,
418 "lat={}",
419 pos.latitude
420 );
421 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 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 #[test]
496 fn parse_aprs_compressed_position() -> TestResult {
497 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"); info.extend_from_slice(b"/%Ztl'&XW> sT"); 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}