1use core::fmt;
10
11use crate::error::AprsError;
12use crate::item::{
13 AprsItem, AprsObject, AprsQuery, parse_aprs_item, parse_aprs_object, parse_aprs_query,
14};
15use crate::message::{AprsMessage, parse_aprs_message};
16use crate::position::{AprsPosition, parse_aprs_position};
17use crate::status::{AprsStatus, parse_aprs_status};
18use crate::telemetry::{AprsTelemetry, parse_aprs_telemetry};
19use crate::weather::{AprsWeather, parse_aprs_weather_positionless};
20
21#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct ParseContext<E> {
35 pub error: E,
37 pub offset: usize,
40 pub field: Option<&'static str>,
42}
43
44impl<E> ParseContext<E> {
45 pub const fn with_error(error: E, offset: usize, field: Option<&'static str>) -> Self {
47 Self {
48 error,
49 offset,
50 field,
51 }
52 }
53}
54
55impl<E: fmt::Display> fmt::Display for ParseContext<E> {
56 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57 if let Some(field) = self.field {
58 write!(
59 f,
60 "{} (at byte {} in field {field})",
61 self.error, self.offset
62 )
63 } else {
64 write!(f, "{} (at byte {})", self.error, self.offset)
65 }
66 }
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
87pub enum PositionAmbiguity {
88 None,
90 OneDigit,
92 TwoDigits,
94 ThreeDigits,
96 FourDigits,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
116pub enum AprsTimestamp {
117 DhmZulu {
119 day: u8,
121 hour: u8,
123 minute: u8,
125 },
126 DhmLocal {
128 day: u8,
130 hour: u8,
132 minute: u8,
134 },
135 Hms {
137 hour: u8,
139 minute: u8,
141 second: u8,
143 },
144 Mdhm {
147 month: u8,
149 day: u8,
151 hour: u8,
153 minute: u8,
155 },
156}
157
158impl AprsTimestamp {
159 #[must_use]
162 pub fn to_wire_string(self) -> String {
163 match self {
164 Self::DhmZulu { day, hour, minute } => {
165 format!("{day:02}{hour:02}{minute:02}z")
166 }
167 Self::DhmLocal { day, hour, minute } => {
168 format!("{day:02}{hour:02}{minute:02}/")
169 }
170 Self::Hms {
171 hour,
172 minute,
173 second,
174 } => {
175 format!("{hour:02}{minute:02}{second:02}h")
176 }
177 Self::Mdhm {
178 month,
179 day,
180 hour,
181 minute,
182 } => {
183 format!("{month:02}{day:02}{hour:02}{minute:02}")
184 }
185 }
186 }
187}
188
189#[derive(Debug, Clone, PartialEq, Eq)]
197pub struct Phg {
198 pub power_watts: u32,
200 pub height_feet: u32,
202 pub gain_db: u8,
204 pub directivity_deg: u16,
206}
207
208#[derive(Debug, Clone, Default, PartialEq)]
214pub struct AprsDataExtension {
215 pub course_speed: Option<(u16, u16)>,
217 pub phg: Option<Phg>,
219 pub altitude_ft: Option<i32>,
221 pub dao: Option<(f64, f64)>,
223}
224
225#[must_use]
238pub fn parse_aprs_extensions(comment: &str) -> AprsDataExtension {
239 let course_speed = parse_cse_spd(comment);
240 let phg = parse_phg(comment);
241 let altitude_ft = parse_altitude(comment);
242 let dao = parse_dao(comment);
243
244 AprsDataExtension {
245 course_speed,
246 phg,
247 altitude_ft,
248 dao,
249 }
250}
251
252fn parse_cse_spd(comment: &str) -> Option<(u16, u16)> {
258 let bytes = comment.as_bytes();
259 let header = bytes.get(..7)?;
260 if header.get(3) != Some(&b'/') {
261 return None;
262 }
263 let dir_bytes = header.get(..3)?;
264 let spd_bytes = header.get(4..7)?;
265 if !dir_bytes.iter().all(u8::is_ascii_digit) || !spd_bytes.iter().all(u8::is_ascii_digit) {
266 return None;
267 }
268 let course: u16 = comment.get(0..3)?.parse().ok()?;
269 let speed: u16 = comment.get(4..7)?.parse().ok()?;
270 if course > 360 {
271 return None;
272 }
273 Some((course, speed))
274}
275
276const PHG_POWER: [u32; 10] = [0, 1, 4, 9, 16, 25, 36, 49, 64, 81];
278const PHG_HEIGHT: [u32; 10] = [10, 20, 40, 80, 160, 320, 640, 1280, 2560, 5120];
280const PHG_DIR: [u16; 10] = [0, 20, 40, 60, 80, 100, 120, 140, 160, 180];
282
283fn parse_phg(comment: &str) -> Option<Phg> {
288 let idx = comment.find("PHG")?;
289 let rest = comment.get(idx + 3..)?;
290 let first_four = rest.get(..4)?.as_bytes();
291 if !first_four.iter().all(u8::is_ascii_digit) {
292 return None;
293 }
294 let p = (*first_four.first()? - b'0') as usize;
295 let h = (*first_four.get(1)? - b'0') as usize;
296 let g = *first_four.get(2)? - b'0';
297 let d = (*first_four.get(3)? - b'0') as usize;
298
299 Some(Phg {
300 power_watts: PHG_POWER.get(p).copied().unwrap_or(0),
301 height_feet: PHG_HEIGHT.get(h).copied().unwrap_or(10),
302 gain_db: g,
303 directivity_deg: PHG_DIR.get(d).copied().unwrap_or(0),
304 })
305}
306
307fn parse_altitude(comment: &str) -> Option<i32> {
312 let idx = comment.find("/A=")?;
313 let rest = comment.get(idx + 3..)?;
314 let val_str = rest.get(..6)?;
315 val_str.parse::<i32>().ok()
316}
317
318fn parse_dao(comment: &str) -> Option<(f64, f64)> {
329 let bytes = comment.as_bytes();
331 for i in 0..bytes.len().saturating_sub(4) {
332 let window = bytes.get(i..i + 5)?;
333 if window.first() != Some(&b'!') || window.get(4) != Some(&b'!') {
334 continue;
335 }
336 let d = *window.get(1)?;
337 let a = *window.get(2)?;
338 let o = *window.get(3)?;
339
340 if a.is_ascii_uppercase() {
341 if d.is_ascii_digit() && o.is_ascii_digit() {
343 let lat_extra = f64::from(d - b'0') / 600.0;
344 let lon_extra = f64::from(o - b'0') / 600.0;
345 return Some((lat_extra, lon_extra));
346 }
347 } else if a.is_ascii_lowercase() {
348 if (33..=123).contains(&d) && (33..=123).contains(&o) {
350 let lat_extra = f64::from(d - 33) / (91.0 * 60.0);
351 let lon_extra = f64::from(o - 33) / (91.0 * 60.0);
352 return Some((lat_extra, lon_extra));
353 }
354 }
355 }
356 None
357}
358
359#[derive(Debug, Clone, PartialEq, Eq, Hash)]
368pub enum MessageKind {
369 Direct,
371 Bulletin {
373 number: u8,
375 },
376 GroupBulletin {
379 group: String,
381 },
382 NwsBulletin,
385 AckRej,
388}
389
390#[derive(Debug, Clone, PartialEq)]
402pub enum TelemetryDefinition {
403 Parameters(TelemetryParameters),
406 Units(TelemetryParameters),
408 Equations([Option<(f64, f64, f64)>; 5]),
411 Bits {
414 bits: String,
417 title: String,
419 },
420}
421
422#[derive(Debug, Clone, PartialEq, Eq, Default)]
424pub struct TelemetryParameters {
425 pub analog: [Option<String>; 5],
427 pub digital: [Option<String>; 8],
429}
430
431impl TelemetryDefinition {
432 #[must_use]
438 pub fn from_text(text: &str) -> Option<Self> {
439 let trimmed = text.trim_end_matches(['\r', '\n']);
440 if let Some(rest) = trimmed.strip_prefix("PARM.") {
441 return Some(Self::Parameters(parse_telemetry_labels(rest)));
442 }
443 if let Some(rest) = trimmed.strip_prefix("UNIT.") {
444 return Some(Self::Units(parse_telemetry_labels(rest)));
445 }
446 if let Some(rest) = trimmed.strip_prefix("EQNS.") {
447 return Some(Self::Equations(parse_telemetry_equations(rest)));
448 }
449 if let Some(rest) = trimmed.strip_prefix("BITS.") {
450 let (bits, title) = rest.split_once(',').unwrap_or((rest, ""));
451 return Some(Self::Bits {
452 bits: bits.to_owned(),
453 title: title.to_owned(),
454 });
455 }
456 None
457 }
458}
459
460fn parse_telemetry_labels(s: &str) -> TelemetryParameters {
462 let mut params = TelemetryParameters::default();
463 for (i, field) in s.split(',').enumerate() {
464 let field = field.trim();
465 if i < 5 {
466 if !field.is_empty()
467 && let Some(slot) = params.analog.get_mut(i)
468 {
469 *slot = Some(field.to_owned());
470 }
471 } else if i < 13 {
472 if !field.is_empty()
473 && let Some(slot) = params.digital.get_mut(i - 5)
474 {
475 *slot = Some(field.to_owned());
476 }
477 } else {
478 break;
479 }
480 }
481 params
482}
483
484fn parse_telemetry_equations(s: &str) -> [Option<(f64, f64, f64)>; 5] {
486 let values: Vec<f64> = s
487 .split(',')
488 .map(str::trim)
489 .map(|v| v.parse::<f64>().unwrap_or(0.0))
490 .collect();
491 let mut out: [Option<(f64, f64, f64)>; 5] = [None, None, None, None, None];
492 for (i, slot) in out.iter_mut().enumerate() {
493 let base = i * 3;
494 if let (Some(&a), Some(&b), Some(&c)) =
495 (values.get(base), values.get(base + 1), values.get(base + 2))
496 {
497 *slot = Some((a, b, c));
498 }
499 }
500 out
501}
502
503#[derive(Debug, Clone, PartialEq)]
513pub enum AprsData {
514 Position(AprsPosition),
516 Message(AprsMessage),
518 Status(AprsStatus),
520 Object(AprsObject),
522 Item(AprsItem),
524 Weather(AprsWeather),
526 Telemetry(AprsTelemetry),
528 Query(AprsQuery),
530 ThirdParty {
535 header: String,
538 payload: Vec<u8>,
540 },
541 Grid(String),
544 RawGps(String),
550 StationCapabilities(Vec<(String, String)>),
556 AgreloDfJr(Vec<u8>),
561 UserDefined {
568 experiment: char,
570 data: Vec<u8>,
572 },
573 InvalidOrTest(Vec<u8>),
578}
579
580#[derive(Debug, Clone, PartialEq)]
584pub struct AprsPacket {
585 pub data: AprsData,
587}
588
589pub fn parse_aprs_data(info: &[u8]) -> Result<AprsData, AprsError> {
621 let first = *info.first().ok_or(AprsError::InvalidFormat)?;
622
623 match first {
624 b'!' | b'=' | b'/' | b'@' => parse_aprs_position(info).map(AprsData::Position),
626 b':' => parse_aprs_message(info).map(AprsData::Message),
628 b'>' => parse_aprs_status(info).map(AprsData::Status),
630 b';' => parse_aprs_object(info).map(AprsData::Object),
632 b')' => parse_aprs_item(info).map(AprsData::Item),
634 b'_' => parse_aprs_weather_positionless(info).map(AprsData::Weather),
636 b'T' => parse_aprs_telemetry(info).map(AprsData::Telemetry),
638 b'?' => parse_aprs_query(info).map(AprsData::Query),
640 b'}' => parse_aprs_third_party(info),
642 b'[' => parse_aprs_grid(info),
644 b'$' => parse_aprs_raw_gps(info),
646 b'<' => parse_aprs_capabilities(info),
648 b'%' => Ok(AprsData::AgreloDfJr(info.get(1..).unwrap_or(&[]).to_vec())),
650 b'{' => parse_aprs_user_defined(info),
652 b',' => Ok(AprsData::InvalidOrTest(
654 info.get(1..).unwrap_or(&[]).to_vec(),
655 )),
656 b'`' | b'\'' | 0x1C | 0x1D => Err(AprsError::MicERequiresDestination),
658 _ => Err(AprsError::InvalidFormat),
660 }
661}
662
663fn parse_aprs_third_party(info: &[u8]) -> Result<AprsData, AprsError> {
670 if info.first() != Some(&b'}') {
671 return Err(AprsError::InvalidFormat);
672 }
673 let body = info.get(1..).ok_or(AprsError::InvalidFormat)?;
674 let Some(colon) = body.iter().position(|&b| b == b':') else {
675 return Err(AprsError::InvalidFormat);
676 };
677 let header_bytes = body.get(..colon).ok_or(AprsError::InvalidFormat)?;
678 let payload = body
679 .get(colon + 1..)
680 .ok_or(AprsError::InvalidFormat)?
681 .to_vec();
682 let header = String::from_utf8_lossy(header_bytes).into_owned();
683 Ok(AprsData::ThirdParty { header, payload })
684}
685
686fn parse_aprs_grid(info: &[u8]) -> Result<AprsData, AprsError> {
690 if info.first() != Some(&b'[') {
691 return Err(AprsError::InvalidFormat);
692 }
693 let tail = info.get(1..).unwrap_or(&[]);
694 let body = String::from_utf8_lossy(tail)
695 .trim_end_matches(['\r', '\n', ' '])
696 .to_owned();
697 if !(4..=6).contains(&body.len()) {
698 return Err(AprsError::InvalidFormat);
699 }
700 let bytes = body.as_bytes();
701 let b0 = *bytes.first().ok_or(AprsError::InvalidFormat)?;
704 let b1 = *bytes.get(1).ok_or(AprsError::InvalidFormat)?;
705 let b2 = *bytes.get(2).ok_or(AprsError::InvalidFormat)?;
706 let b3 = *bytes.get(3).ok_or(AprsError::InvalidFormat)?;
707 if !b0.is_ascii_uppercase()
708 || !b1.is_ascii_uppercase()
709 || !b2.is_ascii_digit()
710 || !b3.is_ascii_digit()
711 || b0 > b'R'
712 || b1 > b'R'
713 {
714 return Err(AprsError::InvalidFormat);
715 }
716 if bytes.len() == 6 {
717 let b4 = *bytes.get(4).ok_or(AprsError::InvalidFormat)?;
718 let b5 = *bytes.get(5).ok_or(AprsError::InvalidFormat)?;
719 if !b4.is_ascii_lowercase() || !b5.is_ascii_lowercase() || b4 > b'x' || b5 > b'x' {
720 return Err(AprsError::InvalidFormat);
721 }
722 }
723 Ok(AprsData::Grid(body))
724}
725
726fn parse_aprs_raw_gps(info: &[u8]) -> Result<AprsData, AprsError> {
732 if info.first() != Some(&b'$') {
733 return Err(AprsError::InvalidFormat);
734 }
735 let tail = info.get(1..).unwrap_or(&[]);
736 let body = std::str::from_utf8(tail)
737 .map_err(|_| AprsError::InvalidFormat)?
738 .trim_end_matches(['\r', '\n'])
739 .to_owned();
740 Ok(AprsData::RawGps(body))
741}
742
743fn parse_aprs_capabilities(info: &[u8]) -> Result<AprsData, AprsError> {
750 if info.first() != Some(&b'<') {
751 return Err(AprsError::InvalidFormat);
752 }
753 let tail = info.get(1..).unwrap_or(&[]);
754 let body = std::str::from_utf8(tail)
755 .map_err(|_| AprsError::InvalidFormat)?
756 .trim_end_matches(['\r', '\n']);
757 let mut tokens: Vec<(String, String)> = Vec::new();
758 for entry in body.split(',') {
759 let entry = entry.trim();
760 if entry.is_empty() {
761 continue;
762 }
763 if let Some((k, v)) = entry.split_once('=') {
764 tokens.push((k.trim().to_owned(), v.trim().to_owned()));
765 } else {
766 tokens.push((entry.to_owned(), String::new()));
767 }
768 }
769 Ok(AprsData::StationCapabilities(tokens))
770}
771
772fn parse_aprs_user_defined(info: &[u8]) -> Result<AprsData, AprsError> {
777 if info.first() != Some(&b'{') {
778 return Err(AprsError::InvalidFormat);
779 }
780 let experiment = *info.get(1).ok_or(AprsError::InvalidFormat)? as char;
781 let data = info.get(2..).unwrap_or(&[]).to_vec();
782 Ok(AprsData::UserDefined { experiment, data })
783}
784
785#[cfg(test)]
790mod tests {
791 use super::*;
792
793 type TestResult = Result<(), Box<dyn std::error::Error>>;
794
795 #[test]
798 fn dispatch_position() {
799 let info = b"!4903.50N/07201.75W-Test";
800 assert!(
801 matches!(parse_aprs_data(info), Ok(AprsData::Position(_))),
802 "expected Position variant",
803 );
804 }
805
806 #[test]
807 fn dispatch_message() {
808 let info = b":N0CALL :Hello{1";
809 assert!(
810 matches!(parse_aprs_data(info), Ok(AprsData::Message(_))),
811 "expected Message variant",
812 );
813 }
814
815 #[test]
816 fn dispatch_status() {
817 let info = b">Status text";
818 assert!(
819 matches!(parse_aprs_data(info), Ok(AprsData::Status(_))),
820 "expected Status variant",
821 );
822 }
823
824 #[test]
825 fn dispatch_object() {
826 let info = b";OBJNAME *092345z4903.50N/07201.75W-";
827 assert!(
828 matches!(parse_aprs_data(info), Ok(AprsData::Object(_))),
829 "expected Object variant",
830 );
831 }
832
833 #[test]
834 fn dispatch_item() {
835 let info = b")ITEM!4903.50N/07201.75W-";
836 assert!(
837 matches!(parse_aprs_data(info), Ok(AprsData::Item(_))),
838 "expected Item variant",
839 );
840 }
841
842 #[test]
843 fn dispatch_weather() {
844 let info = b"_01011234c180s005t072";
845 assert!(
846 matches!(parse_aprs_data(info), Ok(AprsData::Weather(_))),
847 "expected Weather variant",
848 );
849 }
850
851 #[test]
852 fn dispatch_third_party() -> TestResult {
853 let info = b"}W1AW>APK005,TCPIP:!4903.50N/07201.75W-from IS";
854 let result = parse_aprs_data(info)?;
855 assert!(
856 matches!(
857 &result,
858 AprsData::ThirdParty { header, payload }
859 if header == "W1AW>APK005,TCPIP"
860 && payload == b"!4903.50N/07201.75W-from IS"
861 ),
862 "expected ThirdParty, got {result:?}",
863 );
864 Ok(())
865 }
866
867 #[test]
868 fn dispatch_grid_locator() -> TestResult {
869 let info = b"[EM13qc";
870 let result = parse_aprs_data(info)?;
871 assert!(
872 matches!(&result, AprsData::Grid(g) if g == "EM13qc"),
873 "expected Grid, got {result:?}",
874 );
875 Ok(())
876 }
877
878 #[test]
879 fn dispatch_grid_4char() -> TestResult {
880 let info = b"[FM18";
881 let result = parse_aprs_data(info)?;
882 assert!(
883 matches!(&result, AprsData::Grid(g) if g == "FM18"),
884 "expected Grid, got {result:?}",
885 );
886 Ok(())
887 }
888
889 #[test]
890 fn dispatch_grid_invalid_rejected() {
891 assert!(parse_aprs_data(b"[XX12").is_err(), "X > R rejected");
892 assert!(parse_aprs_data(b"[AB").is_err(), "too short rejected");
893 }
894
895 #[test]
896 fn dispatch_raw_gps() -> TestResult {
897 let info = b"$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W";
898 let result = parse_aprs_data(info)?;
899 assert!(
900 matches!(
901 &result,
902 AprsData::RawGps(s) if s.starts_with("GPRMC,") && s.contains("4807.038")
903 ),
904 "expected RawGps, got {result:?}",
905 );
906 Ok(())
907 }
908
909 #[test]
910 fn dispatch_capabilities_parses_tokens() -> TestResult {
911 let info = b"<IGATE,MSG_CNT=10,LOC_CNT=42";
912 let result = parse_aprs_data(info)?;
913 let AprsData::StationCapabilities(tokens) = result else {
914 return Err("expected StationCapabilities".into());
915 };
916 assert_eq!(tokens.len(), 3);
917 assert_eq!(tokens.first(), Some(&("IGATE".to_owned(), String::new())));
918 assert_eq!(
919 tokens.get(1),
920 Some(&("MSG_CNT".to_owned(), "10".to_owned()))
921 );
922 assert_eq!(
923 tokens.get(2),
924 Some(&("LOC_CNT".to_owned(), "42".to_owned()))
925 );
926 Ok(())
927 }
928
929 #[test]
930 fn dispatch_agrelo_df() -> TestResult {
931 let info = b"%\x01\x02\x03\x04";
932 let result = parse_aprs_data(info)?;
933 assert!(
934 matches!(&result, AprsData::AgreloDfJr(bytes) if bytes == &vec![1u8, 2, 3, 4]),
935 "expected AgreloDfJr, got {result:?}",
936 );
937 Ok(())
938 }
939
940 #[test]
941 fn dispatch_user_defined() -> TestResult {
942 let info = b"{Adata payload";
943 let result = parse_aprs_data(info)?;
944 assert!(
945 matches!(
946 &result,
947 AprsData::UserDefined { experiment, data }
948 if *experiment == 'A' && data == b"data payload"
949 ),
950 "expected UserDefined, got {result:?}",
951 );
952 Ok(())
953 }
954
955 #[test]
956 fn dispatch_invalid_or_test() -> TestResult {
957 let info = b",test frame";
958 let result = parse_aprs_data(info)?;
959 assert!(
960 matches!(&result, AprsData::InvalidOrTest(bytes) if bytes == b"test frame"),
961 "expected InvalidOrTest, got {result:?}",
962 );
963 Ok(())
964 }
965
966 #[test]
967 fn dispatch_mice_returns_error() {
968 let info = &[0x60u8, 125, 73, 58, 40, 40, 40, b'>', b'/'];
970 assert!(
971 matches!(
972 parse_aprs_data(info),
973 Err(AprsError::MicERequiresDestination)
974 ),
975 "expected MicERequiresDestination",
976 );
977 }
978
979 #[test]
982 fn parse_context_display_with_field() {
983 let ctx = ParseContext::with_error(AprsError::InvalidFormat, 17, Some("addressee"));
984 let s = format!("{ctx}");
985 assert!(s.contains("byte 17"), "expected byte 17 in {s:?}");
986 assert!(s.contains("addressee"), "expected addressee in {s:?}");
987 }
988
989 #[test]
990 fn parse_context_display_without_field() {
991 let ctx = ParseContext::with_error(AprsError::InvalidCoordinates, 4, None);
992 let s = format!("{ctx}");
993 assert!(s.contains("byte 4"), "expected byte 4 in {s:?}");
994 }
995
996 #[test]
999 fn aprs_timestamp_dhm_zulu_format() {
1000 let ts = AprsTimestamp::DhmZulu {
1001 day: 9,
1002 hour: 23,
1003 minute: 45,
1004 };
1005 assert_eq!(ts.to_wire_string(), "092345z");
1006 }
1007
1008 #[test]
1009 fn aprs_timestamp_hms_format() {
1010 let ts = AprsTimestamp::Hms {
1011 hour: 12,
1012 minute: 0,
1013 second: 1,
1014 };
1015 assert_eq!(ts.to_wire_string(), "120001h");
1016 }
1017
1018 #[test]
1021 fn parse_extensions_cse_spd() {
1022 let ext = parse_aprs_extensions("088/036");
1023 assert_eq!(ext.course_speed, Some((88, 36)));
1024 assert!(ext.phg.is_none());
1025 assert!(ext.altitude_ft.is_none());
1026 assert!(ext.dao.is_none());
1027 }
1028
1029 #[test]
1030 fn parse_extensions_cse_spd_with_comment() {
1031 let ext = parse_aprs_extensions("270/015via Mic-E");
1032 assert_eq!(ext.course_speed, Some((270, 15)));
1033 }
1034
1035 #[test]
1036 fn parse_extensions_cse_spd_invalid_course() {
1037 let ext = parse_aprs_extensions("999/050");
1039 assert!(ext.course_speed.is_none());
1040 }
1041
1042 #[test]
1043 fn parse_extensions_cse_spd_not_at_start() {
1044 let ext = parse_aprs_extensions("xx088/036");
1046 assert!(ext.course_speed.is_none());
1047 }
1048
1049 #[test]
1050 fn parse_extensions_phg() -> TestResult {
1051 let ext = parse_aprs_extensions("PHG5132");
1052 let phg = ext.phg.ok_or("phg missing")?;
1053 assert_eq!(phg.power_watts, 25);
1054 assert_eq!(phg.height_feet, 20);
1055 assert_eq!(phg.gain_db, 3);
1056 assert_eq!(phg.directivity_deg, 40);
1057 Ok(())
1058 }
1059
1060 #[test]
1061 fn parse_extensions_phg_omni() -> TestResult {
1062 let ext = parse_aprs_extensions("PHG2360");
1063 let phg = ext.phg.ok_or("phg missing")?;
1064 assert_eq!(phg.power_watts, 4);
1065 assert_eq!(phg.height_feet, 80);
1066 assert_eq!(phg.gain_db, 6);
1067 assert_eq!(phg.directivity_deg, 0);
1068 Ok(())
1069 }
1070
1071 #[test]
1072 fn parse_extensions_phg_in_comment() -> TestResult {
1073 let ext = parse_aprs_extensions("some text PHG5132 more text");
1074 let phg = ext.phg.ok_or("phg missing")?;
1075 assert_eq!(phg.power_watts, 25);
1076 Ok(())
1077 }
1078
1079 #[test]
1080 fn parse_extensions_altitude() {
1081 let ext = parse_aprs_extensions("some comment /A=001234 more");
1082 assert_eq!(ext.altitude_ft, Some(1234));
1083 }
1084
1085 #[test]
1086 fn parse_extensions_altitude_negative() {
1087 let ext = parse_aprs_extensions("/A=-00100");
1088 assert_eq!(ext.altitude_ft, Some(-100));
1089 }
1090
1091 #[test]
1092 fn parse_extensions_altitude_zeros() {
1093 let ext = parse_aprs_extensions("/A=000000");
1094 assert_eq!(ext.altitude_ft, Some(0));
1095 }
1096
1097 #[test]
1098 fn parse_extensions_dao_human_readable() -> TestResult {
1099 let ext = parse_aprs_extensions("text !5W5! more");
1101 let (lat, lon) = ext.dao.ok_or("dao missing")?;
1102 let expected = 5.0 / 600.0;
1103 assert!((lat - expected).abs() < 1e-9, "lat={lat}");
1104 assert!((lon - expected).abs() < 1e-9, "lon={lon}");
1105 Ok(())
1106 }
1107
1108 #[test]
1109 fn parse_extensions_dao_base91() -> TestResult {
1110 let ext = parse_aprs_extensions("!\"w\"!");
1112 let (lat, lon) = ext.dao.ok_or("dao missing")?;
1113 let expected = 1.0 / (91.0 * 60.0);
1114 assert!((lat - expected).abs() < 1e-9, "lat={lat}");
1115 assert!((lon - expected).abs() < 1e-9, "lon={lon}");
1116 Ok(())
1117 }
1118
1119 #[test]
1120 fn parse_extensions_combined() {
1121 let ext = parse_aprs_extensions("088/036PHG5132/A=001234");
1122 assert_eq!(ext.course_speed, Some((88, 36)));
1123 assert!(ext.phg.is_some());
1124 assert_eq!(ext.altitude_ft, Some(1234));
1125 }
1126
1127 #[test]
1128 fn parse_extensions_empty() {
1129 let ext = parse_aprs_extensions("");
1130 assert!(ext.course_speed.is_none());
1131 assert!(ext.phg.is_none());
1132 assert!(ext.altitude_ft.is_none());
1133 assert!(ext.dao.is_none());
1134 }
1135
1136 #[test]
1139 fn telemetry_definition_parm() -> TestResult {
1140 let def =
1141 TelemetryDefinition::from_text("PARM.Volts,Temp,Humid,Wind,Rain,Door,Light,Heat,,,,,")
1142 .ok_or("missing")?;
1143 let TelemetryDefinition::Parameters(p) = def else {
1144 return Err("expected Parameters".into());
1145 };
1146 assert_eq!(p.analog.first().and_then(Option::as_deref), Some("Volts"));
1147 assert_eq!(p.analog.get(4).and_then(Option::as_deref), Some("Rain"));
1148 assert_eq!(p.digital.first().and_then(Option::as_deref), Some("Door"));
1149 assert_eq!(p.digital.get(2).and_then(Option::as_deref), Some("Heat"));
1150 Ok(())
1151 }
1152
1153 #[test]
1154 fn telemetry_definition_unit() -> TestResult {
1155 let def = TelemetryDefinition::from_text("UNIT.Vdc,C,%,mph,in,open,lit,on,,,,,")
1156 .ok_or("missing")?;
1157 let TelemetryDefinition::Units(p) = def else {
1158 return Err("expected Units".into());
1159 };
1160 assert_eq!(p.analog.get(1).and_then(Option::as_deref), Some("C"));
1161 Ok(())
1162 }
1163
1164 #[test]
1165 fn telemetry_definition_eqns() -> TestResult {
1166 let def = TelemetryDefinition::from_text("EQNS.0,0.1,0,0,0.5,0,0,1,0,0,2,0,0,3,0")
1167 .ok_or("missing")?;
1168 let TelemetryDefinition::Equations(eqs) = def else {
1169 return Err("expected Equations".into());
1170 };
1171 assert_eq!(eqs.first(), Some(&Some((0.0, 0.1, 0.0))));
1172 assert_eq!(eqs.get(1), Some(&Some((0.0, 0.5, 0.0))));
1173 assert_eq!(eqs.get(4), Some(&Some((0.0, 3.0, 0.0))));
1174 Ok(())
1175 }
1176
1177 #[test]
1178 fn telemetry_definition_bits() -> TestResult {
1179 let def = TelemetryDefinition::from_text("BITS.11111111,WX station telemetry")
1180 .ok_or("missing")?;
1181 let TelemetryDefinition::Bits { bits, title } = def else {
1182 return Err("expected Bits".into());
1183 };
1184 assert_eq!(bits, "11111111");
1185 assert_eq!(title, "WX station telemetry");
1186 Ok(())
1187 }
1188
1189 #[test]
1190 fn telemetry_definition_unknown_returns_none() {
1191 assert!(TelemetryDefinition::from_text("hello world").is_none());
1192 }
1193}