1use ax25_codec::{Ax25Address, Ax25Packet, build_ax25};
9use kiss_tnc::{KissFrame, encode_kiss_frame};
10
11use crate::error::AprsError;
12use crate::message::MAX_APRS_MESSAGE_TEXT_LEN;
13use crate::mic_e::{MiceMessage, mice_message_bits};
14use crate::packet::AprsTimestamp;
15use crate::weather::AprsWeather;
16
17const APRS_TOCALL: &str = "APK005";
23
24const fn ax25_ui_frame(
27 source: Ax25Address,
28 destination: Ax25Address,
29 path: Vec<Ax25Address>,
30 info: Vec<u8>,
31) -> Ax25Packet {
32 Ax25Packet {
33 source,
34 destination,
35 digipeaters: path,
36 control: 0x03,
37 protocol: 0xF0,
38 info,
39 }
40}
41
42fn ax25_to_kiss_wire(packet: &Ax25Packet) -> Vec<u8> {
45 let ax25_bytes = build_ax25(packet);
46 encode_kiss_frame(&KissFrame::data(ax25_bytes))
47}
48
49fn format_aprs_latitude(lat: f64) -> String {
55 let lat = if lat.is_finite() {
56 lat.clamp(-90.0, 90.0)
57 } else {
58 0.0
59 };
60 let hemisphere = if lat >= 0.0 { 'N' } else { 'S' };
61 let lat_abs = lat.abs();
62 #[expect(
63 clippy::cast_possible_truncation,
64 clippy::cast_sign_loss,
65 reason = "lat_abs is clamped to 0..=90 so the cast to u32 is safe"
66 )]
67 let degrees = lat_abs as u32;
68 let minutes = (lat_abs - f64::from(degrees)) * 60.0;
69 format!("{degrees:02}{minutes:05.2}{hemisphere}")
70}
71
72fn format_aprs_longitude(lon: f64) -> String {
76 let lon = if lon.is_finite() {
77 lon.clamp(-180.0, 180.0)
78 } else {
79 0.0
80 };
81 let hemisphere = if lon >= 0.0 { 'E' } else { 'W' };
82 let lon_abs = lon.abs();
83 #[expect(
84 clippy::cast_possible_truncation,
85 clippy::cast_sign_loss,
86 reason = "lon_abs is clamped to 0..=180 so the cast to u32 is safe"
87 )]
88 let degrees = lon_abs as u32;
89 let minutes = (lon_abs - f64::from(degrees)) * 60.0;
90 format!("{degrees:03}{minutes:05.2}{hemisphere}")
91}
92
93fn encode_base91_4(mut value: u32) -> [u8; 4] {
99 let mut out = [0u8; 4];
100 for slot in out.iter_mut().rev() {
101 let digit = (value % 91) as u8;
103 *slot = digit + 33;
104 value /= 91;
105 }
106 out
107}
108
109#[must_use]
134pub fn build_aprs_position_report(
135 source: &Ax25Address,
136 latitude: f64,
137 longitude: f64,
138 symbol_table: char,
139 symbol_code: char,
140 comment: &str,
141 path: &[Ax25Address],
142) -> Vec<u8> {
143 ax25_to_kiss_wire(&build_aprs_position_report_packet(
144 source,
145 latitude,
146 longitude,
147 symbol_table,
148 symbol_code,
149 comment,
150 path,
151 ))
152}
153
154#[must_use]
158pub fn build_aprs_position_report_packet(
159 source: &Ax25Address,
160 latitude: f64,
161 longitude: f64,
162 symbol_table: char,
163 symbol_code: char,
164 comment: &str,
165 path: &[Ax25Address],
166) -> Ax25Packet {
167 let lat_str = format_aprs_latitude(latitude);
168 let lon_str = format_aprs_longitude(longitude);
169 let info = format!("!{lat_str}{symbol_table}{lon_str}{symbol_code}{comment}");
170 ax25_ui_frame(
171 source.clone(),
172 Ax25Address::new(APRS_TOCALL, 0),
173 path.to_vec(),
174 info.into_bytes(),
175 )
176}
177
178#[must_use]
202pub fn build_aprs_message(
203 source: &Ax25Address,
204 addressee: &str,
205 text: &str,
206 message_id: Option<&str>,
207 path: &[Ax25Address],
208) -> Vec<u8> {
209 ax25_to_kiss_wire(&build_aprs_message_packet(
210 source, addressee, text, message_id, path,
211 ))
212}
213
214#[must_use]
216pub fn build_aprs_message_packet(
217 source: &Ax25Address,
218 addressee: &str,
219 text: &str,
220 message_id: Option<&str>,
221 path: &[Ax25Address],
222) -> Ax25Packet {
223 let padded_addressee = format!("{addressee:<9}");
225 let padded_addressee = padded_addressee.get(..9).unwrap_or(&padded_addressee);
226
227 let text = if text.len() > MAX_APRS_MESSAGE_TEXT_LEN {
229 let mut end = MAX_APRS_MESSAGE_TEXT_LEN;
230 while end > 0 && !text.is_char_boundary(end) {
231 end -= 1;
232 }
233 text.get(..end).unwrap_or(text)
234 } else {
235 text
236 };
237
238 let info = message_id.map_or_else(
239 || format!(":{padded_addressee}:{text}"),
240 |id| format!(":{padded_addressee}:{text}{{{id}"),
241 );
242
243 ax25_ui_frame(
244 source.clone(),
245 Ax25Address::new(APRS_TOCALL, 0),
246 path.to_vec(),
247 info.into_bytes(),
248 )
249}
250
251pub fn build_aprs_message_checked(
258 source: &Ax25Address,
259 addressee: &str,
260 text: &str,
261 message_id: Option<&str>,
262 path: &[Ax25Address],
263) -> Result<Vec<u8>, AprsError> {
264 if text.len() > MAX_APRS_MESSAGE_TEXT_LEN {
265 return Err(AprsError::MessageTooLong(text.len()));
266 }
267 Ok(build_aprs_message(
268 source, addressee, text, message_id, path,
269 ))
270}
271
272#[must_use]
298#[expect(
299 clippy::too_many_arguments,
300 reason = "APRS object wire fields are fundamentally positional"
301)]
302pub fn build_aprs_object(
303 source: &Ax25Address,
304 name: &str,
305 live: bool,
306 latitude: f64,
307 longitude: f64,
308 symbol_table: char,
309 symbol_code: char,
310 comment: &str,
311 path: &[Ax25Address],
312) -> Vec<u8> {
313 build_aprs_object_with_timestamp(
316 source,
317 name,
318 live,
319 AprsTimestamp::DhmZulu {
320 day: 0,
321 hour: 0,
322 minute: 0,
323 },
324 latitude,
325 longitude,
326 symbol_table,
327 symbol_code,
328 comment,
329 path,
330 )
331}
332
333#[must_use]
339#[expect(
340 clippy::too_many_arguments,
341 reason = "APRS object wire fields are fundamentally positional"
342)]
343pub fn build_aprs_object_with_timestamp(
344 source: &Ax25Address,
345 name: &str,
346 live: bool,
347 timestamp: AprsTimestamp,
348 latitude: f64,
349 longitude: f64,
350 symbol_table: char,
351 symbol_code: char,
352 comment: &str,
353 path: &[Ax25Address],
354) -> Vec<u8> {
355 ax25_to_kiss_wire(&build_aprs_object_with_timestamp_packet(
356 source,
357 name,
358 live,
359 timestamp,
360 latitude,
361 longitude,
362 symbol_table,
363 symbol_code,
364 comment,
365 path,
366 ))
367}
368
369#[must_use]
372#[expect(
373 clippy::too_many_arguments,
374 reason = "APRS object wire fields are fundamentally positional"
375)]
376pub fn build_aprs_object_with_timestamp_packet(
377 source: &Ax25Address,
378 name: &str,
379 live: bool,
380 timestamp: AprsTimestamp,
381 latitude: f64,
382 longitude: f64,
383 symbol_table: char,
384 symbol_code: char,
385 comment: &str,
386 path: &[Ax25Address],
387) -> Ax25Packet {
388 let padded_name = format!("{name:<9}");
389 let padded_name = padded_name.get(..9).unwrap_or(&padded_name);
390 let live_char = if live { '*' } else { '_' };
391 let lat_str = format_aprs_latitude(latitude);
392 let lon_str = format_aprs_longitude(longitude);
393 let ts = timestamp.to_wire_string();
394
395 let info = format!(
396 ";{padded_name}{live_char}{ts}{lat_str}{symbol_table}{lon_str}{symbol_code}{comment}"
397 );
398
399 ax25_ui_frame(
400 source.clone(),
401 Ax25Address::new(APRS_TOCALL, 0),
402 path.to_vec(),
403 info.into_bytes(),
404 )
405}
406
407#[must_use]
433#[expect(
434 clippy::too_many_arguments,
435 reason = "APRS item wire fields are fundamentally positional"
436)]
437pub fn build_aprs_item(
438 source: &Ax25Address,
439 name: &str,
440 live: bool,
441 lat: f64,
442 lon: f64,
443 symbol_table: char,
444 symbol_code: char,
445 comment: &str,
446 path: &[Ax25Address],
447) -> Vec<u8> {
448 ax25_to_kiss_wire(&build_aprs_item_packet(
449 source,
450 name,
451 live,
452 lat,
453 lon,
454 symbol_table,
455 symbol_code,
456 comment,
457 path,
458 ))
459}
460
461#[must_use]
463#[expect(
464 clippy::too_many_arguments,
465 reason = "APRS item wire fields are fundamentally positional"
466)]
467pub fn build_aprs_item_packet(
468 source: &Ax25Address,
469 name: &str,
470 live: bool,
471 lat: f64,
472 lon: f64,
473 symbol_table: char,
474 symbol_code: char,
475 comment: &str,
476 path: &[Ax25Address],
477) -> Ax25Packet {
478 let live_char = if live { '!' } else { '_' };
479 let lat_str = format_aprs_latitude(lat);
480 let lon_str = format_aprs_longitude(lon);
481 let info = format!("){name}{live_char}{lat_str}{symbol_table}{lon_str}{symbol_code}{comment}");
482 ax25_ui_frame(
483 source.clone(),
484 Ax25Address::new(APRS_TOCALL, 0),
485 path.to_vec(),
486 info.into_bytes(),
487 )
488}
489
490#[must_use]
510pub fn build_aprs_weather(
511 source: &Ax25Address,
512 weather: &AprsWeather,
513 path: &[Ax25Address],
514) -> Vec<u8> {
515 ax25_to_kiss_wire(&build_aprs_weather_packet(source, weather, path))
516}
517
518#[must_use]
526pub fn build_aprs_position_weather(
527 source: &Ax25Address,
528 latitude: f64,
529 longitude: f64,
530 symbol_table: char,
531 weather: &AprsWeather,
532 path: &[Ax25Address],
533) -> Vec<u8> {
534 ax25_to_kiss_wire(&build_aprs_position_weather_packet(
535 source,
536 latitude,
537 longitude,
538 symbol_table,
539 weather,
540 path,
541 ))
542}
543
544#[must_use]
547pub fn build_aprs_position_weather_packet(
548 source: &Ax25Address,
549 latitude: f64,
550 longitude: f64,
551 symbol_table: char,
552 weather: &AprsWeather,
553 path: &[Ax25Address],
554) -> Ax25Packet {
555 use std::fmt::Write as _;
556
557 let lat_str = format_aprs_latitude(latitude);
558 let lon_str = format_aprs_longitude(longitude);
559 let wind_dir = weather
563 .wind_direction
564 .map_or_else(|| "...".to_owned(), |d| format!("{d:03}"));
565 let wind_spd = weather
566 .wind_speed
567 .map_or_else(|| "...".to_owned(), |s| format!("{s:03}"));
568
569 let mut info = format!("!{lat_str}{symbol_table}{lon_str}_{wind_dir}/{wind_spd}");
570 if let Some(gust) = weather.wind_gust {
571 let _ = write!(info, "g{gust:03}");
572 }
573 if let Some(temp) = weather.temperature {
574 let _ = write!(info, "t{temp:03}");
575 }
576 if let Some(rain) = weather.rain_1h {
577 let _ = write!(info, "r{rain:03}");
578 }
579 if let Some(rain) = weather.rain_24h {
580 let _ = write!(info, "p{rain:03}");
581 }
582 if let Some(rain) = weather.rain_since_midnight {
583 let _ = write!(info, "P{rain:03}");
584 }
585 if let Some(hum) = weather.humidity {
586 let hum_val = if hum == 100 { 0 } else { hum };
587 let _ = write!(info, "h{hum_val:02}");
588 }
589 if let Some(pres) = weather.pressure {
590 let _ = write!(info, "b{pres:05}");
591 }
592
593 ax25_ui_frame(
594 source.clone(),
595 Ax25Address::new(APRS_TOCALL, 0),
596 path.to_vec(),
597 info.into_bytes(),
598 )
599}
600
601#[must_use]
603pub fn build_aprs_weather_packet(
604 source: &Ax25Address,
605 weather: &AprsWeather,
606 path: &[Ax25Address],
607) -> Ax25Packet {
608 use std::fmt::Write as _;
609
610 let mut info = String::from("_00000000");
611
612 if let Some(dir) = weather.wind_direction {
613 let _ = write!(info, "c{dir:03}");
614 }
615 if let Some(spd) = weather.wind_speed {
616 let _ = write!(info, "s{spd:03}");
617 }
618 if let Some(gust) = weather.wind_gust {
619 let _ = write!(info, "g{gust:03}");
620 }
621 if let Some(temp) = weather.temperature {
622 let _ = write!(info, "t{temp:03}");
623 }
624 if let Some(rain) = weather.rain_1h {
625 let _ = write!(info, "r{rain:03}");
626 }
627 if let Some(rain) = weather.rain_24h {
628 let _ = write!(info, "p{rain:03}");
629 }
630 if let Some(rain) = weather.rain_since_midnight {
631 let _ = write!(info, "P{rain:03}");
632 }
633 if let Some(hum) = weather.humidity {
634 let hum_val = if hum == 100 { 0 } else { hum };
635 let _ = write!(info, "h{hum_val:02}");
636 }
637 if let Some(pres) = weather.pressure {
638 let _ = write!(info, "b{pres:05}");
639 }
640
641 ax25_ui_frame(
642 source.clone(),
643 Ax25Address::new(APRS_TOCALL, 0),
644 path.to_vec(),
645 info.into_bytes(),
646 )
647}
648
649#[must_use]
677pub fn build_aprs_position_compressed(
678 source: &Ax25Address,
679 latitude: f64,
680 longitude: f64,
681 symbol_table: char,
682 symbol_code: char,
683 comment: &str,
684 path: &[Ax25Address],
685) -> Vec<u8> {
686 ax25_to_kiss_wire(&build_aprs_position_compressed_packet(
687 source,
688 latitude,
689 longitude,
690 symbol_table,
691 symbol_code,
692 comment,
693 path,
694 ))
695}
696
697#[must_use]
700pub fn build_aprs_position_compressed_packet(
701 source: &Ax25Address,
702 latitude: f64,
703 longitude: f64,
704 symbol_table: char,
705 symbol_code: char,
706 comment: &str,
707 path: &[Ax25Address],
708) -> Ax25Packet {
709 #[expect(
710 clippy::cast_possible_truncation,
711 clippy::cast_sign_loss,
712 reason = "APRS compressed position encoding scales f64 decimal degrees into u32-ranged integers per APRS101 §9"
713 )]
714 let lat_val = (380_926.0 * (90.0 - latitude)) as u32;
715 #[expect(
716 clippy::cast_possible_truncation,
717 clippy::cast_sign_loss,
718 reason = "APRS compressed position encoding scales f64 decimal degrees into u32-ranged integers per APRS101 §9"
719 )]
720 let lon_val = (190_463.0 * (longitude + 180.0)) as u32;
721 let lat_encoded = encode_base91_4(lat_val);
722 let lon_encoded = encode_base91_4(lon_val);
723
724 let mut info = Vec::with_capacity(1 + 13 + comment.len());
725 info.push(b'!');
726 info.push(symbol_table as u8);
727 info.extend_from_slice(&lat_encoded);
728 info.extend_from_slice(&lon_encoded);
729 info.push(symbol_code as u8);
730 info.push(b' '); info.push(b' ');
732 info.push(b' '); info.extend_from_slice(comment.as_bytes());
734
735 ax25_ui_frame(
736 source.clone(),
737 Ax25Address::new(APRS_TOCALL, 0),
738 path.to_vec(),
739 info,
740 )
741}
742
743#[must_use]
760pub fn build_aprs_status(source: &Ax25Address, text: &str, path: &[Ax25Address]) -> Vec<u8> {
761 ax25_to_kiss_wire(&build_aprs_status_packet(source, text, path))
762}
763
764#[must_use]
766pub fn build_aprs_status_packet(
767 source: &Ax25Address,
768 text: &str,
769 path: &[Ax25Address],
770) -> Ax25Packet {
771 let mut info = Vec::with_capacity(1 + text.len() + 1);
772 info.push(b'>');
773 info.extend_from_slice(text.as_bytes());
774 info.push(b'\r');
775 ax25_ui_frame(
776 source.clone(),
777 Ax25Address::new(APRS_TOCALL, 0),
778 path.to_vec(),
779 info,
780 )
781}
782
783#[must_use]
812#[expect(
813 clippy::too_many_arguments,
814 reason = "Mic-E wire fields are fundamentally positional"
815)]
816pub fn build_aprs_mice(
817 source: &Ax25Address,
818 latitude: f64,
819 longitude: f64,
820 speed_knots: u16,
821 course_deg: u16,
822 symbol_table: char,
823 symbol_code: char,
824 comment: &str,
825 path: &[Ax25Address],
826) -> Vec<u8> {
827 build_aprs_mice_with_message(
829 source,
830 latitude,
831 longitude,
832 speed_knots,
833 course_deg,
834 MiceMessage::OffDuty,
835 symbol_table,
836 symbol_code,
837 comment,
838 path,
839 )
840}
841
842#[must_use]
850#[expect(
851 clippy::too_many_arguments,
852 reason = "Mic-E wire fields are fundamentally positional"
853)]
854pub fn build_aprs_mice_with_message(
855 source: &Ax25Address,
856 latitude: f64,
857 longitude: f64,
858 speed_knots: u16,
859 course_deg: u16,
860 message: MiceMessage,
861 symbol_table: char,
862 symbol_code: char,
863 comment: &str,
864 path: &[Ax25Address],
865) -> Vec<u8> {
866 ax25_to_kiss_wire(&build_aprs_mice_with_message_packet(
867 source,
868 latitude,
869 longitude,
870 speed_knots,
871 course_deg,
872 message,
873 symbol_table,
874 symbol_code,
875 comment,
876 path,
877 ))
878}
879
880#[must_use]
883#[expect(
884 clippy::too_many_arguments,
885 clippy::too_many_lines,
886 reason = "Mic-E wire fields are fundamentally positional; packing all steps in one function keeps the APRS101 §10 cross-reference readable"
887)]
888pub fn build_aprs_mice_with_message_packet(
889 source: &Ax25Address,
890 latitude: f64,
891 longitude: f64,
892 speed_knots: u16,
893 course_deg: u16,
894 message: MiceMessage,
895 symbol_table: char,
896 symbol_code: char,
897 comment: &str,
898 path: &[Ax25Address],
899) -> Ax25Packet {
900 let latitude = latitude.clamp(-90.0, 90.0);
902 let longitude = longitude.clamp(-180.0, 180.0);
903 let north = latitude >= 0.0;
904 let west = longitude < 0.0;
905 let lat_abs = latitude.abs();
906 let lon_abs = longitude.abs();
907
908 #[expect(
911 clippy::cast_possible_truncation,
912 clippy::cast_sign_loss,
913 reason = "lat_abs is clamped to 0..=90"
914 )]
915 let lat_deg = lat_abs as u32;
916 let lat_min_f = (lat_abs - f64::from(lat_deg)) * 60.0;
917 #[expect(
918 clippy::cast_possible_truncation,
919 clippy::cast_sign_loss,
920 reason = "lat_min_f is in 0..60"
921 )]
922 let lat_min = lat_min_f as u32;
923 let lat_hundredths_f = ((lat_min_f - f64::from(lat_min)) * 100.0).round();
924 #[expect(
925 clippy::cast_possible_truncation,
926 clippy::cast_sign_loss,
927 reason = "lat_hundredths_f rounds to an integer in 0..=100"
928 )]
929 let lat_hundredths = (lat_hundredths_f as u32).min(99);
930
931 let d0 = (lat_deg / 10).min(9) as u8;
934 let d1 = (lat_deg % 10) as u8;
935 let d2 = (lat_min / 10).min(9) as u8;
936 let d3 = (lat_min % 10) as u8;
937 let d4 = (lat_hundredths / 10) as u8;
938 let d5 = (lat_hundredths % 10) as u8;
939
940 let (msg_a, msg_b, msg_c) = mice_message_bits(message);
943
944 let lon_offset = lon_abs >= 100.0;
947 let dest_chars: [u8; 6] = [
948 if msg_a { b'P' + d0 } else { b'0' + d0 },
949 if msg_b { b'P' + d1 } else { b'0' + d1 },
950 if msg_c { b'P' + d2 } else { b'0' + d2 },
951 if north { b'P' + d3 } else { b'0' + d3 },
952 if lon_offset { b'P' + d4 } else { b'0' + d4 },
953 if west { b'P' + d5 } else { b'0' + d5 },
954 ];
955 let Ok(dest_callsign) = std::str::from_utf8(&dest_chars) else {
958 unreachable!("Mic-E destination chars are ASCII by construction")
959 };
960
961 #[expect(
969 clippy::cast_possible_truncation,
970 clippy::cast_sign_loss,
971 reason = "lon_abs is clamped to 0..=180 so fits u16"
972 )]
973 let lon_deg_raw = lon_abs as u16;
974 #[expect(
975 clippy::cast_possible_truncation,
976 reason = "lon_deg_raw subtraction always yields a value that fits u8 for valid APRS longitudes"
977 )]
978 let d = if lon_offset {
979 if lon_deg_raw >= 110 {
980 (lon_deg_raw - 100) as u8
981 } else {
982 (lon_deg_raw - 20) as u8
983 }
984 } else {
985 lon_deg_raw as u8
986 };
987
988 #[expect(
989 clippy::cast_possible_truncation,
990 clippy::cast_sign_loss,
991 reason = "lon_abs is clamped to 0..=180 so the u32 cast fits"
992 )]
993 let lon_min_f = (lon_abs - f64::from(lon_abs as u32)) * 60.0;
994 #[expect(
995 clippy::cast_possible_truncation,
996 clippy::cast_sign_loss,
997 reason = "lon_min_f is in 0..60"
998 )]
999 let lon_min_int = lon_min_f as u8;
1000 #[expect(
1001 clippy::cast_possible_truncation,
1002 clippy::cast_sign_loss,
1003 reason = "rounded value is 0..=100"
1004 )]
1005 let lon_hundredths = ((lon_min_f - f64::from(lon_min_int)) * 100.0).round() as u8;
1006
1007 let m = if lon_min_int < 10 {
1009 lon_min_int + 60
1010 } else {
1011 lon_min_int
1012 };
1013
1014 #[expect(
1019 clippy::cast_possible_truncation,
1020 reason = "speed_knots is u16, speed_knots / 10 fits u8 for typical APRS speeds"
1021 )]
1022 let sp = (speed_knots / 10) as u8;
1023 #[expect(
1024 clippy::cast_possible_truncation,
1025 reason = "combined value stays in u8 range for valid APRS inputs"
1026 )]
1027 let dc = ((speed_knots % 10) * 10 + course_deg / 100) as u8;
1028 let se = (course_deg % 100) as u8;
1030
1031 let mut info = Vec::with_capacity(9 + comment.len());
1033 info.push(0x60); info.push(d + 28);
1035 info.push(m + 28);
1036 info.push(lon_hundredths + 28);
1037 info.push(sp + 28);
1038 info.push(dc + 28);
1039 info.push(se + 28);
1040 info.push(symbol_code as u8);
1041 info.push(symbol_table as u8);
1042 info.extend_from_slice(comment.as_bytes());
1043
1044 ax25_ui_frame(
1045 source.clone(),
1046 Ax25Address::new(dest_callsign, 0),
1047 path.to_vec(),
1048 info,
1049 )
1050}
1051
1052#[must_use]
1062pub fn build_query_response_position(
1063 source: &Ax25Address,
1064 lat: f64,
1065 lon: f64,
1066 symbol_table: char,
1067 symbol_code: char,
1068 comment: &str,
1069 path: &[Ax25Address],
1070) -> Vec<u8> {
1071 build_aprs_position_report(source, lat, lon, symbol_table, symbol_code, comment, path)
1073}
1074
1075#[cfg(test)]
1080mod tests {
1081 use super::*;
1082 use ax25_codec::parse_ax25;
1083 use kiss_tnc::{CMD_DATA, decode_kiss_frame};
1084
1085 use crate::item::{parse_aprs_item, parse_aprs_object};
1086 use crate::message::parse_aprs_message;
1087 use crate::mic_e::parse_mice_position;
1088 use crate::packet::{AprsData, parse_aprs_data};
1089 use crate::position::parse_aprs_position;
1090 use crate::weather::parse_aprs_weather_positionless;
1091
1092 type TestResult = Result<(), Box<dyn std::error::Error>>;
1093
1094 fn test_source() -> Ax25Address {
1095 Ax25Address::new("N0CALL", 7)
1096 }
1097
1098 fn default_digipeater_path() -> Vec<Ax25Address> {
1100 vec![Ax25Address::new("WIDE1", 1), Ax25Address::new("WIDE2", 1)]
1101 }
1102
1103 #[test]
1106 fn format_latitude_north() {
1107 let s = format_aprs_latitude(49.058_333);
1108 assert_eq!(s.len(), 8, "latitude wire field is 8 bytes");
1110 assert!(s.ends_with('N'), "north hemisphere should suffix 'N'");
1111 assert!(s.starts_with("49"), "49-degree prefix preserved");
1112 }
1113
1114 #[test]
1115 fn format_latitude_south() {
1116 let s = format_aprs_latitude(-33.856);
1117 assert!(s.ends_with('S'), "south hemisphere should suffix 'S'");
1118 assert!(s.starts_with("33"), "33-degree prefix preserved");
1119 }
1120
1121 #[test]
1122 fn format_longitude_east() {
1123 let s = format_aprs_longitude(151.209);
1124 assert_eq!(s.len(), 9, "longitude wire field is 9 bytes");
1125 assert!(s.ends_with('E'), "east hemisphere should suffix 'E'");
1126 assert!(s.starts_with("151"), "151-degree prefix preserved");
1127 }
1128
1129 #[test]
1130 fn format_longitude_west() {
1131 let s = format_aprs_longitude(-72.029_166);
1132 assert!(s.ends_with('W'), "west hemisphere should suffix 'W'");
1133 assert!(s.starts_with("072"), "zero-padded 72-degree prefix");
1134 }
1135
1136 #[test]
1139 fn build_position_report_roundtrip() -> TestResult {
1140 let source = test_source();
1141 let wire = build_aprs_position_report(
1142 &source,
1143 49.058_333,
1144 -72.029_166,
1145 '/',
1146 '-',
1147 "Test",
1148 &default_digipeater_path(),
1149 );
1150
1151 let kiss = decode_kiss_frame(&wire)?;
1153 assert_eq!(kiss.command, CMD_DATA, "KISS command should be data");
1154
1155 let packet = parse_ax25(&kiss.data)?;
1157 assert_eq!(packet.source.callsign, "N0CALL");
1158 assert_eq!(packet.source.ssid, 7);
1159 assert_eq!(packet.destination.callsign, "APK005");
1160 assert_eq!(packet.destination.ssid, 0);
1161 assert_eq!(packet.digipeaters.len(), 2);
1162 let digi0 = packet.digipeaters.first().ok_or("digipeater 0 missing")?;
1163 let digi1 = packet.digipeaters.get(1).ok_or("digipeater 1 missing")?;
1164 assert_eq!(digi0.callsign, "WIDE1");
1165 assert_eq!(digi0.ssid, 1);
1166 assert_eq!(digi1.callsign, "WIDE2");
1167 assert_eq!(digi1.ssid, 1);
1168 assert_eq!(packet.control, 0x03);
1169 assert_eq!(packet.protocol, 0xF0);
1170
1171 let pos = parse_aprs_position(&packet.info)?;
1173 assert!((pos.latitude - 49.058_333).abs() < 0.01);
1174 assert!((pos.longitude - (-72.029_166)).abs() < 0.01);
1175 assert_eq!(pos.symbol_table, '/');
1176 assert_eq!(pos.symbol_code, '-');
1177 assert!(pos.comment.contains("Test"), "comment preserved");
1178 Ok(())
1179 }
1180
1181 #[test]
1184 fn build_aprs_object_with_real_timestamp() -> TestResult {
1185 let source = test_source();
1186 let wire = build_aprs_object_with_timestamp(
1187 &source,
1188 "EVENT",
1189 true,
1190 AprsTimestamp::DhmZulu {
1191 day: 15,
1192 hour: 14,
1193 minute: 30,
1194 },
1195 35.0,
1196 -97.0,
1197 '/',
1198 '-',
1199 "real",
1200 &default_digipeater_path(),
1201 );
1202 let kiss = decode_kiss_frame(&wire)?;
1203 let packet = parse_ax25(&kiss.data)?;
1204 let obj = parse_aprs_object(&packet.info)?;
1205 assert_eq!(obj.timestamp, "151430z");
1206 Ok(())
1207 }
1208
1209 #[test]
1210 fn build_object_roundtrip() -> TestResult {
1211 let source = test_source();
1212 let wire = build_aprs_object(
1213 &source,
1214 "TORNADO",
1215 true,
1216 49.058_333,
1217 -72.029_166,
1218 '/',
1219 '-',
1220 "Wrn",
1221 &default_digipeater_path(),
1222 );
1223
1224 let kiss = decode_kiss_frame(&wire)?;
1225 let packet = parse_ax25(&kiss.data)?;
1226 assert_eq!(packet.destination.callsign, "APK005");
1227
1228 let obj = parse_aprs_object(&packet.info)?;
1229 assert_eq!(obj.name, "TORNADO");
1230 assert!(obj.live, "object is alive");
1231 assert!((obj.position.latitude - 49.058_333).abs() < 0.01);
1232 assert!((obj.position.longitude - (-72.029_166)).abs() < 0.01);
1233 assert_eq!(obj.position.symbol_table, '/');
1234 assert_eq!(obj.position.symbol_code, '-');
1235 assert!(obj.position.comment.contains("Wrn"), "comment preserved");
1236 Ok(())
1237 }
1238
1239 #[test]
1240 fn build_object_killed() -> TestResult {
1241 let source = test_source();
1242 let wire = build_aprs_object(
1243 &source,
1244 "EVENT",
1245 false,
1246 35.0,
1247 -97.0,
1248 '/',
1249 'E',
1250 "Done",
1251 &default_digipeater_path(),
1252 );
1253
1254 let kiss = decode_kiss_frame(&wire)?;
1255 let packet = parse_ax25(&kiss.data)?;
1256 let obj = parse_aprs_object(&packet.info)?;
1257 assert_eq!(obj.name, "EVENT");
1258 assert!(!obj.live, "killed object should not be live");
1259 Ok(())
1260 }
1261
1262 #[test]
1265 fn build_message_roundtrip() -> TestResult {
1266 let source = test_source();
1267 let wire = build_aprs_message(
1268 &source,
1269 "KQ4NIT",
1270 "Hello 73!",
1271 Some("42"),
1272 &default_digipeater_path(),
1273 );
1274
1275 let kiss = decode_kiss_frame(&wire)?;
1276 let packet = parse_ax25(&kiss.data)?;
1277 assert_eq!(packet.destination.callsign, "APK005");
1278
1279 let msg = parse_aprs_message(&packet.info)?;
1280 assert_eq!(msg.addressee, "KQ4NIT");
1281 assert_eq!(msg.text, "Hello 73!");
1282 assert_eq!(msg.message_id, Some("42".to_string()));
1283 Ok(())
1284 }
1285
1286 #[test]
1287 fn build_message_no_id() -> TestResult {
1288 let source = test_source();
1289 let wire = build_aprs_message(
1290 &source,
1291 "W1AW",
1292 "Test msg",
1293 None,
1294 &default_digipeater_path(),
1295 );
1296
1297 let kiss = decode_kiss_frame(&wire)?;
1298 let packet = parse_ax25(&kiss.data)?;
1299 let msg = parse_aprs_message(&packet.info)?;
1300 assert_eq!(msg.addressee, "W1AW");
1301 assert_eq!(msg.text, "Test msg");
1302 assert_eq!(msg.message_id, None);
1303 Ok(())
1304 }
1305
1306 #[test]
1307 fn build_message_pads_short_addressee() -> TestResult {
1308 let source = test_source();
1309 let wire = build_aprs_message(&source, "AB", "Hi", None, &default_digipeater_path());
1310
1311 let kiss = decode_kiss_frame(&wire)?;
1312 let packet = parse_ax25(&kiss.data)?;
1313 let info_str = String::from_utf8_lossy(&packet.info);
1315 let addressee_field = info_str.get(1..10).ok_or("addressee field missing")?;
1317 assert_eq!(addressee_field, "AB ");
1318 Ok(())
1319 }
1320
1321 #[test]
1322 fn build_aprs_message_truncates_long_text() -> TestResult {
1323 let source = test_source();
1324 let text = "X".repeat(80);
1325 let wire = build_aprs_message(&source, "N0CALL", &text, None, &default_digipeater_path());
1326
1327 let kiss = decode_kiss_frame(&wire)?;
1328 let packet = parse_ax25(&kiss.data)?;
1329 let msg = parse_aprs_message(&packet.info)?;
1330 assert_eq!(
1331 msg.text.len(),
1332 MAX_APRS_MESSAGE_TEXT_LEN,
1333 "long text should be truncated to the 67-byte spec limit",
1334 );
1335 Ok(())
1336 }
1337
1338 #[test]
1339 fn build_aprs_message_checked_rejects_long_text() {
1340 let source = test_source();
1341 let text = "Y".repeat(80);
1342 let result =
1343 build_aprs_message_checked(&source, "N0CALL", &text, None, &default_digipeater_path());
1344 assert!(
1345 matches!(result, Err(AprsError::MessageTooLong(80))),
1346 "long text should be rejected: {result:?}",
1347 );
1348 }
1349
1350 #[test]
1353 fn build_item_live_roundtrip() -> TestResult {
1354 let source = test_source();
1355 let wire = build_aprs_item(
1356 &source,
1357 "MARKER",
1358 true,
1359 49.058_333,
1360 -72.029_166,
1361 '/',
1362 '-',
1363 "Test item",
1364 &default_digipeater_path(),
1365 );
1366
1367 let kiss = decode_kiss_frame(&wire)?;
1368 let packet = parse_ax25(&kiss.data)?;
1369 assert_eq!(packet.destination.callsign, "APK005");
1370
1371 let item = parse_aprs_item(&packet.info)?;
1372 assert_eq!(item.name, "MARKER");
1373 assert!(item.live, "item is alive");
1374 assert!((item.position.latitude - 49.058_333).abs() < 0.01);
1375 assert!((item.position.longitude - (-72.029_166)).abs() < 0.01);
1376 assert_eq!(item.position.symbol_table, '/');
1377 assert_eq!(item.position.symbol_code, '-');
1378 assert!(
1379 item.position.comment.contains("Test item"),
1380 "comment preserved",
1381 );
1382 Ok(())
1383 }
1384
1385 #[test]
1386 fn build_item_killed() -> TestResult {
1387 let source = test_source();
1388 let wire = build_aprs_item(
1389 &source,
1390 "GONE",
1391 false,
1392 35.0,
1393 -97.0,
1394 '/',
1395 'E',
1396 "Removed",
1397 &default_digipeater_path(),
1398 );
1399
1400 let kiss = decode_kiss_frame(&wire)?;
1401 let packet = parse_ax25(&kiss.data)?;
1402 let item = parse_aprs_item(&packet.info)?;
1403 assert_eq!(item.name, "GONE");
1404 assert!(!item.live, "killed item should not be live");
1405 Ok(())
1406 }
1407
1408 #[test]
1411 fn build_weather_full_roundtrip() -> TestResult {
1412 let source = test_source();
1413 let wx = AprsWeather {
1414 wind_direction: Some(180),
1415 wind_speed: Some(10),
1416 wind_gust: Some(25),
1417 temperature: Some(72),
1418 rain_1h: Some(5),
1419 rain_24h: Some(50),
1420 rain_since_midnight: Some(100),
1421 humidity: Some(55),
1422 pressure: Some(10132),
1423 };
1424
1425 let wire = build_aprs_weather(&source, &wx, &default_digipeater_path());
1426 let kiss = decode_kiss_frame(&wire)?;
1427 let packet = parse_ax25(&kiss.data)?;
1428 assert_eq!(packet.destination.callsign, "APK005");
1429
1430 let parsed = parse_aprs_weather_positionless(&packet.info)?;
1432 assert_eq!(parsed.wind_direction, Some(180));
1433 assert_eq!(parsed.wind_speed, Some(10));
1434 assert_eq!(parsed.wind_gust, Some(25));
1435 assert_eq!(parsed.temperature, Some(72));
1436 assert_eq!(parsed.rain_1h, Some(5));
1437 assert_eq!(parsed.rain_24h, Some(50));
1438 assert_eq!(parsed.rain_since_midnight, Some(100));
1439 assert_eq!(parsed.humidity, Some(55));
1440 assert_eq!(parsed.pressure, Some(10132));
1441 Ok(())
1442 }
1443
1444 #[test]
1445 fn build_weather_partial_fields() -> TestResult {
1446 let source = test_source();
1447 let wx = AprsWeather {
1448 wind_direction: None,
1449 wind_speed: None,
1450 wind_gust: None,
1451 temperature: Some(32),
1452 rain_1h: None,
1453 rain_24h: None,
1454 rain_since_midnight: None,
1455 humidity: None,
1456 pressure: Some(10200),
1457 };
1458
1459 let wire = build_aprs_weather(&source, &wx, &default_digipeater_path());
1460 let kiss = decode_kiss_frame(&wire)?;
1461 let packet = parse_ax25(&kiss.data)?;
1462
1463 let parsed = parse_aprs_weather_positionless(&packet.info)?;
1464 assert_eq!(parsed.temperature, Some(32));
1465 assert_eq!(parsed.pressure, Some(10200));
1466 assert_eq!(parsed.wind_direction, None);
1467 assert_eq!(parsed.humidity, None);
1468 Ok(())
1469 }
1470
1471 #[test]
1472 fn build_aprs_position_weather_roundtrip() -> TestResult {
1473 let wx = AprsWeather {
1474 wind_direction: Some(90),
1475 wind_speed: Some(10),
1476 wind_gust: Some(15),
1477 temperature: Some(72),
1478 rain_1h: None,
1479 rain_24h: None,
1480 rain_since_midnight: Some(20),
1481 humidity: Some(55),
1482 pressure: Some(10135),
1483 };
1484 let wire = build_aprs_position_weather(
1485 &test_source(),
1486 35.25,
1487 -97.75,
1488 '/',
1489 &wx,
1490 &default_digipeater_path(),
1491 );
1492 let kiss = decode_kiss_frame(&wire)?;
1493 let packet = parse_ax25(&kiss.data)?;
1494 let pos = parse_aprs_position(&packet.info)?;
1495 assert_eq!(pos.symbol_code, '_');
1496 let weather = pos.weather.ok_or("embedded weather missing")?;
1497 assert_eq!(weather.wind_direction, Some(90));
1498 assert_eq!(weather.wind_speed, Some(10));
1499 assert_eq!(weather.wind_gust, Some(15));
1500 assert_eq!(weather.temperature, Some(72));
1501 assert_eq!(weather.humidity, Some(55));
1502 assert_eq!(weather.pressure, Some(10135));
1503 Ok(())
1504 }
1505
1506 #[test]
1507 fn build_weather_humidity_100_encodes_as_00() -> TestResult {
1508 let source = test_source();
1509 let wx = AprsWeather {
1510 wind_direction: None,
1511 wind_speed: None,
1512 wind_gust: None,
1513 temperature: None,
1514 rain_1h: None,
1515 rain_24h: None,
1516 rain_since_midnight: None,
1517 humidity: Some(100),
1518 pressure: None,
1519 };
1520
1521 let wire = build_aprs_weather(&source, &wx, &default_digipeater_path());
1522 let kiss = decode_kiss_frame(&wire)?;
1523 let packet = parse_ax25(&kiss.data)?;
1524
1525 let parsed = parse_aprs_weather_positionless(&packet.info)?;
1526 assert_eq!(parsed.humidity, Some(100));
1528 Ok(())
1529 }
1530
1531 #[test]
1534 fn build_compressed_position_round_trip() -> TestResult {
1535 let source = test_source();
1536 let wire = build_aprs_position_compressed(
1537 &source,
1538 35.3,
1539 -84.233,
1540 '/',
1541 '>',
1542 "test",
1543 &default_digipeater_path(),
1544 );
1545 let kiss = decode_kiss_frame(&wire)?;
1546 let packet = parse_ax25(&kiss.data)?;
1547 assert_eq!(packet.destination.callsign, "APK005");
1548 assert_eq!(packet.control, 0x03);
1549 assert_eq!(packet.protocol, 0xF0);
1550
1551 let data = parse_aprs_data(&packet.info)?;
1553 let AprsData::Position(pos) = data else {
1554 return Err(format!("expected Position, got {data:?}").into());
1555 };
1556 assert!((pos.latitude - 35.3).abs() < 0.01, "lat: {}", pos.latitude);
1558 assert!(
1559 (pos.longitude - (-84.233)).abs() < 0.01,
1560 "lon: {}",
1561 pos.longitude,
1562 );
1563 assert_eq!(pos.symbol_table, '/');
1564 assert_eq!(pos.symbol_code, '>');
1565 assert!(pos.comment.contains("test"), "comment preserved");
1566 Ok(())
1567 }
1568
1569 #[test]
1570 fn build_compressed_position_equator_prime_meridian() -> TestResult {
1571 let source = test_source();
1572 let wire = build_aprs_position_compressed(
1573 &source,
1574 0.0,
1575 0.0,
1576 '/',
1577 '-',
1578 "",
1579 &default_digipeater_path(),
1580 );
1581 let kiss = decode_kiss_frame(&wire)?;
1582 let packet = parse_ax25(&kiss.data)?;
1583
1584 let data = parse_aprs_data(&packet.info)?;
1585 let AprsData::Position(pos) = data else {
1586 return Err(format!("expected Position, got {data:?}").into());
1587 };
1588 assert!(pos.latitude.abs() < 0.01, "lat: {}", pos.latitude);
1589 assert!(pos.longitude.abs() < 0.01, "lon: {}", pos.longitude);
1590 Ok(())
1591 }
1592
1593 #[test]
1594 fn build_compressed_position_southern_hemisphere() -> TestResult {
1595 let source = test_source();
1596 let wire = build_aprs_position_compressed(
1597 &source,
1598 -33.86,
1599 151.21,
1600 '/',
1601 '>',
1602 "sydney",
1603 &default_digipeater_path(),
1604 );
1605 let kiss = decode_kiss_frame(&wire)?;
1606 let packet = parse_ax25(&kiss.data)?;
1607
1608 let data = parse_aprs_data(&packet.info)?;
1609 let AprsData::Position(pos) = data else {
1610 return Err(format!("expected Position, got {data:?}").into());
1611 };
1612 assert!(
1613 (pos.latitude - (-33.86)).abs() < 0.01,
1614 "lat: {}",
1615 pos.latitude,
1616 );
1617 assert!(
1618 (pos.longitude - 151.21).abs() < 0.01,
1619 "lon: {}",
1620 pos.longitude,
1621 );
1622 Ok(())
1623 }
1624
1625 #[test]
1626 fn base91_encoding_known_value() {
1627 let encoded = encode_base91_4(0);
1629 assert_eq!(encoded, [b'!', b'!', b'!', b'!']);
1630 }
1631
1632 #[test]
1635 fn build_status_round_trip() -> TestResult {
1636 let source = test_source();
1637 let wire = build_aprs_status(&source, "On the air in FM18", &default_digipeater_path());
1638 let kiss = decode_kiss_frame(&wire)?;
1639 let packet = parse_ax25(&kiss.data)?;
1640 assert_eq!(packet.destination.callsign, "APK005");
1641
1642 let data = parse_aprs_data(&packet.info)?;
1643 let AprsData::Status(status) = data else {
1644 return Err(format!("expected Status, got {data:?}").into());
1645 };
1646 assert_eq!(status.text, "On the air in FM18");
1647 Ok(())
1648 }
1649
1650 #[test]
1651 fn build_status_empty_text() -> TestResult {
1652 let source = test_source();
1653 let wire = build_aprs_status(&source, "", &default_digipeater_path());
1654 let kiss = decode_kiss_frame(&wire)?;
1655 let packet = parse_ax25(&kiss.data)?;
1656
1657 let data = parse_aprs_data(&packet.info)?;
1658 let AprsData::Status(status) = data else {
1659 return Err(format!("expected Status, got {data:?}").into());
1660 };
1661 assert_eq!(status.text, "");
1662 Ok(())
1663 }
1664
1665 #[test]
1666 fn build_status_info_field_format() -> TestResult {
1667 let source = test_source();
1668 let wire = build_aprs_status(&source, "Hello", &default_digipeater_path());
1669 let kiss = decode_kiss_frame(&wire)?;
1670 let packet = parse_ax25(&kiss.data)?;
1671
1672 assert_eq!(packet.info.first().copied(), Some(b'>'));
1674 assert_eq!(packet.info.get(1..6), Some(b"Hello".as_slice()));
1675 assert_eq!(packet.info.get(6).copied(), Some(b'\r'));
1676 Ok(())
1677 }
1678
1679 #[test]
1682 fn build_mice_roundtrip_oklahoma() -> TestResult {
1683 let source = test_source();
1685 let wire = build_aprs_mice(
1686 &source,
1687 35.258,
1688 -97.755,
1689 121,
1690 212,
1691 '/',
1692 '>',
1693 "test",
1694 &default_digipeater_path(),
1695 );
1696
1697 let kiss = decode_kiss_frame(&wire)?;
1698 let packet = parse_ax25(&kiss.data)?;
1699
1700 let pos = parse_mice_position(&packet.destination.callsign, &packet.info)?;
1702 assert!((pos.latitude - 35.258).abs() < 0.02, "lat={}", pos.latitude);
1703 assert!(
1704 (pos.longitude - (-97.755)).abs() < 0.02,
1705 "lon={}",
1706 pos.longitude,
1707 );
1708 assert_eq!(pos.symbol_table, '/');
1709 assert_eq!(pos.symbol_code, '>');
1710 assert!(pos.comment.contains("test"), "comment preserved");
1711 Ok(())
1712 }
1713
1714 #[test]
1715 fn build_mice_roundtrip_north_east() -> TestResult {
1716 let source = test_source();
1718 let wire = build_aprs_mice(
1719 &source,
1720 51.5,
1721 -0.1,
1722 0,
1723 0,
1724 '/',
1725 '-',
1726 "",
1727 &default_digipeater_path(),
1728 );
1729
1730 let kiss = decode_kiss_frame(&wire)?;
1731 let packet = parse_ax25(&kiss.data)?;
1732 let pos = parse_mice_position(&packet.destination.callsign, &packet.info)?;
1733 assert!((pos.latitude - 51.5).abs() < 0.02, "lat={}", pos.latitude);
1734 assert!(
1735 (pos.longitude - (-0.1)).abs() < 0.02,
1736 "lon={}",
1737 pos.longitude,
1738 );
1739 Ok(())
1740 }
1741
1742 #[test]
1743 fn build_mice_roundtrip_southern_hemisphere() -> TestResult {
1744 let source = test_source();
1746 let wire = build_aprs_mice(
1747 &source,
1748 -33.86,
1749 151.21,
1750 50,
1751 180,
1752 '/',
1753 '>',
1754 "sydney",
1755 &default_digipeater_path(),
1756 );
1757
1758 let kiss = decode_kiss_frame(&wire)?;
1759 let packet = parse_ax25(&kiss.data)?;
1760 let pos = parse_mice_position(&packet.destination.callsign, &packet.info)?;
1761 assert!(
1762 (pos.latitude - (-33.86)).abs() < 0.02,
1763 "lat={}",
1764 pos.latitude,
1765 );
1766 assert!(
1767 (pos.longitude - 151.21).abs() < 0.02,
1768 "lon={}",
1769 pos.longitude,
1770 );
1771 Ok(())
1772 }
1773
1774 #[test]
1775 fn build_mice_speed_course_roundtrip() -> TestResult {
1776 let source = test_source();
1777 let wire = build_aprs_mice(
1778 &source,
1779 35.0,
1780 -97.0,
1781 55,
1782 270,
1783 '/',
1784 '>',
1785 "",
1786 &default_digipeater_path(),
1787 );
1788
1789 let kiss = decode_kiss_frame(&wire)?;
1790 let packet = parse_ax25(&kiss.data)?;
1791 let pos = parse_mice_position(&packet.destination.callsign, &packet.info)?;
1792 assert_eq!(pos.speed_knots, Some(55));
1793 assert_eq!(pos.course_degrees, Some(270));
1794 Ok(())
1795 }
1796
1797 #[test]
1798 fn build_mice_zero_speed_course() -> TestResult {
1799 let source = test_source();
1800 let wire = build_aprs_mice(
1801 &source,
1802 40.0,
1803 -74.0,
1804 0,
1805 0,
1806 '/',
1807 '>',
1808 "",
1809 &default_digipeater_path(),
1810 );
1811
1812 let kiss = decode_kiss_frame(&wire)?;
1813 let packet = parse_ax25(&kiss.data)?;
1814 let pos = parse_mice_position(&packet.destination.callsign, &packet.info)?;
1815 assert_eq!(pos.speed_knots, Some(0));
1816 assert_eq!(pos.course_degrees, None);
1818 Ok(())
1819 }
1820
1821 #[test]
1822 fn build_mice_high_longitude() -> TestResult {
1823 let source = test_source();
1825 let wire = build_aprs_mice(
1826 &source,
1827 35.0,
1828 140.0,
1829 10,
1830 90,
1831 '/',
1832 '>',
1833 "",
1834 &default_digipeater_path(),
1835 );
1836
1837 let kiss = decode_kiss_frame(&wire)?;
1838 let packet = parse_ax25(&kiss.data)?;
1839 let pos = parse_mice_position(&packet.destination.callsign, &packet.info)?;
1840 assert!((pos.latitude - 35.0).abs() < 0.02, "lat={}", pos.latitude);
1841 assert!(
1842 (pos.longitude - 140.0).abs() < 0.02,
1843 "lon={}",
1844 pos.longitude,
1845 );
1846 Ok(())
1847 }
1848
1849 #[test]
1850 fn build_mice_with_message_roundtrip() -> TestResult {
1851 let cases = [
1853 MiceMessage::OffDuty,
1854 MiceMessage::EnRoute,
1855 MiceMessage::InService,
1856 MiceMessage::Returning,
1857 MiceMessage::Committed,
1858 MiceMessage::Special,
1859 MiceMessage::Priority,
1860 MiceMessage::Emergency,
1861 ];
1862 for msg in cases {
1863 let source = test_source();
1864 let wire = build_aprs_mice_with_message(
1865 &source,
1866 35.25,
1867 -97.75,
1868 10,
1869 90,
1870 msg,
1871 '/',
1872 '>',
1873 "",
1874 &default_digipeater_path(),
1875 );
1876 let kiss = decode_kiss_frame(&wire)?;
1877 let packet = parse_ax25(&kiss.data)?;
1878 let pos = parse_mice_position(&packet.destination.callsign, &packet.info)?;
1879 assert_eq!(pos.mice_message, Some(msg), "round trip for {msg:?}");
1880 }
1881 Ok(())
1882 }
1883
1884 #[test]
1885 fn build_mice_lon_100_109() -> TestResult {
1886 let source = test_source();
1888 let wire = build_aprs_mice(
1889 &source,
1890 35.0,
1891 -105.5,
1892 0,
1893 0,
1894 '/',
1895 '>',
1896 "",
1897 &default_digipeater_path(),
1898 );
1899
1900 let kiss = decode_kiss_frame(&wire)?;
1901 let packet = parse_ax25(&kiss.data)?;
1902 let pos = parse_mice_position(&packet.destination.callsign, &packet.info)?;
1903 assert!(
1904 (pos.longitude - (-105.5)).abs() < 0.02,
1905 "lon={}",
1906 pos.longitude,
1907 );
1908 Ok(())
1909 }
1910
1911 #[test]
1914 fn build_query_response_roundtrip() -> TestResult {
1915 let source = test_source();
1916 let wire = build_query_response_position(
1917 &source,
1918 35.258,
1919 -97.755,
1920 '/',
1921 '>',
1922 "QRY resp",
1923 &default_digipeater_path(),
1924 );
1925 let kiss = decode_kiss_frame(&wire)?;
1926 let packet = parse_ax25(&kiss.data)?;
1927 let data = parse_aprs_data(&packet.info)?;
1928 let AprsData::Position(pos) = data else {
1929 return Err(format!("expected Position, got {data:?}").into());
1930 };
1931 assert!((pos.latitude - 35.258).abs() < 0.01);
1932 assert!((pos.longitude - (-97.755)).abs() < 0.01);
1933 assert!(pos.comment.contains("QRY resp"), "comment preserved");
1934 Ok(())
1935 }
1936}