1use std::collections::VecDeque;
68use std::time::{Duration, Instant};
69
70use aprs::{
71 AprsData, AprsMessage, AprsMessenger, AprsPosition, AprsWeather, DigiAction, DigipeaterConfig,
72 SmartBeaconing, SmartBeaconingConfig, StationEntry, StationList, build_aprs_object,
73 build_aprs_position_compressed, build_aprs_position_report, build_aprs_status,
74 build_query_response_position, classify_ack_rej, parse_aprs_data_full,
75};
76use ax25_codec::{Ax25Address, Ax25Packet, build_ax25, parse_ax25};
77use kiss_tnc::{CMD_DATA, KissFrame, encode_kiss_frame};
78
79use crate::aprs::ax25_to_kiss_wire;
80use crate::error::Error;
81use crate::radio::Radio;
82use crate::radio::kiss_session::KissSession;
83use crate::transport::Transport;
84use crate::types::TncBaud;
85
86const EVENT_POLL_TIMEOUT: Duration = Duration::from_millis(500);
91
92#[derive(Debug, Clone)]
100#[non_exhaustive]
101pub struct AprsClientConfig {
102 pub callsign: String,
104 pub ssid: u8,
106 pub symbol_table: char,
108 pub symbol_code: char,
110 pub baud: TncBaud,
112 pub beacon_comment: String,
114 pub smart_beaconing: SmartBeaconingConfig,
116 pub digipeater: Option<DigipeaterConfig>,
119 pub max_stations: usize,
121 pub station_timeout_secs: u64,
123 pub auto_ack: bool,
126 pub digipeater_path: Vec<Ax25Address>,
132 pub auto_query_response: bool,
140 pub auto_query_position: Option<(f64, f64)>,
146}
147
148impl AprsClientConfig {
149 #[must_use]
157 pub fn new(callsign: &str, ssid: u8) -> Self {
158 Self {
159 callsign: callsign.to_owned(),
160 ssid,
161 symbol_table: '/',
162 symbol_code: '>',
163 baud: TncBaud::Bps1200,
164 beacon_comment: String::new(),
165 smart_beaconing: SmartBeaconingConfig::default(),
166 digipeater: None,
167 max_stations: 500,
168 station_timeout_secs: 3600,
169 auto_ack: true,
170 digipeater_path: crate::aprs::default_digipeater_path(),
171 auto_query_response: true,
172 auto_query_position: None,
173 }
174 }
175
176 fn my_address(&self) -> Ax25Address {
178 Ax25Address::new(&self.callsign, self.ssid)
179 }
180
181 #[must_use]
195 pub fn builder(callsign: &str, ssid: u8) -> AprsClientConfigBuilder {
196 AprsClientConfigBuilder::new(callsign, ssid)
197 }
198}
199
200#[derive(Debug, Clone)]
205pub struct AprsClientConfigBuilder {
206 callsign: String,
207 ssid: u8,
208 symbol_table: char,
209 symbol_code: char,
210 baud: TncBaud,
211 beacon_comment: String,
212 smart_beaconing: SmartBeaconingConfig,
213 digipeater: Option<DigipeaterConfig>,
214 max_stations: usize,
215 station_timeout_secs: u64,
216 auto_ack: bool,
217 digipeater_path: Vec<Ax25Address>,
218 auto_query_response: bool,
219 auto_query_position: Option<(f64, f64)>,
220}
221
222impl AprsClientConfigBuilder {
223 #[must_use]
225 pub fn new(callsign: &str, ssid: u8) -> Self {
226 Self {
227 callsign: callsign.to_owned(),
228 ssid,
229 symbol_table: '/',
230 symbol_code: '>',
231 baud: TncBaud::Bps1200,
232 beacon_comment: String::new(),
233 smart_beaconing: SmartBeaconingConfig::default(),
234 digipeater: None,
235 max_stations: 500,
236 station_timeout_secs: 3600,
237 auto_ack: true,
238 digipeater_path: crate::aprs::default_digipeater_path(),
239 auto_query_response: true,
240 auto_query_position: None,
241 }
242 }
243
244 #[must_use]
246 pub const fn symbol(mut self, table: char, code: char) -> Self {
247 self.symbol_table = table;
248 self.symbol_code = code;
249 self
250 }
251
252 #[must_use]
254 pub const fn baud(mut self, baud: TncBaud) -> Self {
255 self.baud = baud;
256 self
257 }
258
259 #[must_use]
261 pub fn beacon_comment(mut self, s: &str) -> Self {
262 s.clone_into(&mut self.beacon_comment);
263 self
264 }
265
266 #[must_use]
268 pub const fn smart_beaconing(mut self, sb: SmartBeaconingConfig) -> Self {
269 self.smart_beaconing = sb;
270 self
271 }
272
273 #[must_use]
275 pub fn digipeater(mut self, cfg: DigipeaterConfig) -> Self {
276 self.digipeater = Some(cfg);
277 self
278 }
279
280 #[must_use]
282 pub const fn max_stations(mut self, n: usize) -> Self {
283 self.max_stations = n;
284 self
285 }
286
287 #[must_use]
289 pub const fn station_timeout_secs(mut self, s: u64) -> Self {
290 self.station_timeout_secs = s;
291 self
292 }
293
294 #[must_use]
296 pub const fn auto_ack(mut self, on: bool) -> Self {
297 self.auto_ack = on;
298 self
299 }
300
301 #[must_use]
303 pub fn digipeater_path(mut self, path: Vec<Ax25Address>) -> Self {
304 self.digipeater_path = path;
305 self
306 }
307
308 #[must_use]
310 pub const fn auto_query_response(mut self, on: bool) -> Self {
311 self.auto_query_response = on;
312 self
313 }
314
315 #[must_use]
317 pub const fn auto_query_position(mut self, lat: f64, lon: f64) -> Self {
318 self.auto_query_position = Some((lat, lon));
319 self
320 }
321
322 pub fn build(self) -> Result<AprsClientConfig, crate::error::ValidationError> {
330 let _ = Ax25Address::try_new(&self.callsign, self.ssid).map_err(|_| {
335 crate::error::ValidationError::AprsWireOutOfRange {
336 field: "callsign",
337 detail: "callsign must be 1-6 A-Z/0-9 and SSID 0-15",
338 }
339 })?;
340 let _ = aprs::SymbolTable::from_byte(self.symbol_table as u8).map_err(|_| {
345 crate::error::ValidationError::AprsWireOutOfRange {
346 field: "APRS symbol table",
347 detail: "must be '/', '\\\\', 0-9, or A-Z",
348 }
349 })?;
350 let code_byte = self.symbol_code as u8;
352 if !(0x21..=0x7E).contains(&code_byte) {
353 return Err(crate::error::ValidationError::AprsWireOutOfRange {
354 field: "APRS symbol code",
355 detail: "must be printable ASCII (0x21-0x7E)",
356 });
357 }
358
359 Ok(AprsClientConfig {
360 callsign: self.callsign,
361 ssid: self.ssid,
362 symbol_table: self.symbol_table,
363 symbol_code: self.symbol_code,
364 baud: self.baud,
365 beacon_comment: self.beacon_comment,
366 smart_beaconing: self.smart_beaconing,
367 digipeater: self.digipeater,
368 max_stations: self.max_stations,
369 station_timeout_secs: self.station_timeout_secs,
370 auto_ack: self.auto_ack,
371 digipeater_path: self.digipeater_path,
372 auto_query_response: self.auto_query_response,
373 auto_query_position: self.auto_query_position,
374 })
375 }
376}
377
378#[derive(Debug, Clone)]
388pub enum AprsEvent {
389 StationHeard(StationEntry),
392 MessageReceived(AprsMessage),
394 MessageDelivered(String),
396 MessageRejected(String),
398 MessageExpired(String),
400 PositionReceived {
402 source: String,
404 position: AprsPosition,
406 },
407 WeatherReceived {
409 source: String,
411 weather: AprsWeather,
413 },
414 PacketDigipeated {
416 source: String,
418 },
419 QueryResponded {
421 to: String,
423 },
424 RawPacket(Ax25Packet),
426}
427
428pub struct AprsClient<T: Transport> {
441 session: KissSession<T>,
442 config: AprsClientConfig,
443 messenger: AprsMessenger,
444 stations: StationList,
445 beaconing: SmartBeaconing,
446 pending_events: VecDeque<AprsEvent>,
452}
453
454impl<T: Transport> std::fmt::Debug for AprsClient<T> {
455 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
456 f.debug_struct("AprsClient")
457 .field("config", &self.config)
458 .field("stations_count", &self.stations.len())
459 .field("pending_messages", &self.messenger.pending_count())
460 .finish_non_exhaustive()
461 }
462}
463
464impl<T: Transport> AprsClient<T> {
465 pub async fn start(
476 radio: Radio<T>,
477 config: AprsClientConfig,
478 ) -> Result<Self, (Radio<T>, Error)> {
479 let mut session = match radio.enter_kiss(config.baud).await {
480 Ok(s) => s,
481 Err((radio, e)) => return Err((radio, e)),
482 };
483 session.set_receive_timeout(EVENT_POLL_TIMEOUT);
484
485 let my_addr = config.my_address();
486 let messenger = AprsMessenger::new(my_addr, config.digipeater_path.clone());
487 let stations = StationList::new(
488 config.max_stations,
489 Duration::from_secs(config.station_timeout_secs),
490 );
491 let beaconing = SmartBeaconing::new(config.smart_beaconing.clone());
492
493 Ok(Self {
494 session,
495 config,
496 messenger,
497 stations,
498 beaconing,
499 pending_events: VecDeque::new(),
500 })
501 }
502
503 pub async fn stop(self) -> Result<Radio<T>, Error> {
509 self.session.exit().await
510 }
511
512 pub async fn next_event(&mut self) -> Result<Option<AprsEvent>, Error> {
530 if let Some(ev) = self.pending_events.pop_front() {
532 return Ok(Some(ev));
533 }
534
535 let now = Instant::now();
541
542 self.process_retries(now).await?;
544 if let Some(ev) = self.pending_events.pop_front() {
545 return Ok(Some(ev));
546 }
547
548 let Some(packet) = self.recv_one_frame().await? else {
550 return Ok(None);
551 };
552
553 if let Some(ev) = self.process_digipeater(&packet, now).await? {
555 return Ok(Some(ev));
556 }
557
558 self.handle_packet(packet, now).await
560 }
561
562 async fn process_retries(&mut self, now: Instant) -> Result<(), Error> {
566 if let Some(frame) = self.messenger.next_frame_to_send(now) {
567 self.session.send_wire(&frame).await?;
568 }
569 for id in self.messenger.cleanup_expired(now) {
570 self.pending_events.push_back(AprsEvent::MessageExpired(id));
571 }
572 Ok(())
573 }
574
575 async fn recv_one_frame(&mut self) -> Result<Option<Ax25Packet>, Error> {
580 let frame = match self.session.receive_frame().await {
581 Ok(f) => f,
582 Err(Error::Timeout(_)) => return Ok(None),
583 Err(Error::Transport(crate::error::TransportError::Read(io_err)))
584 if matches!(
585 io_err.kind(),
586 std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
587 ) =>
588 {
589 return Ok(None);
590 }
591 Err(e) => return Err(e),
592 };
593 if frame.command != CMD_DATA {
594 return Ok(None);
595 }
596 Ok(parse_ax25(&frame.data).ok())
597 }
598
599 async fn process_digipeater(
603 &mut self,
604 packet: &Ax25Packet,
605 now: Instant,
606 ) -> Result<Option<AprsEvent>, Error> {
607 if let Some(digi_config) = self.config.digipeater.as_mut()
608 && let DigiAction::Relay { modified_packet } = digi_config.process(packet, now)
609 {
610 let wire = ax25_to_kiss_wire(&modified_packet);
611 self.session.send_wire(&wire).await?;
612 return Ok(Some(AprsEvent::PacketDigipeated {
613 source: packet.source.callsign.as_str().to_owned(),
614 }));
615 }
616 Ok(None)
617 }
618
619 async fn handle_packet(
622 &mut self,
623 packet: Ax25Packet,
624 now: Instant,
625 ) -> Result<Option<AprsEvent>, Error> {
626 let Ok(aprs_data) = parse_aprs_data_full(&packet.info, &packet.destination.callsign) else {
627 return Ok(Some(AprsEvent::RawPacket(packet)));
628 };
629
630 let path: Vec<String> = packet.digipeaters.iter().map(ToString::to_string).collect();
631 self.stations
632 .update(&packet.source.callsign, &aprs_data, &path, now);
633
634 if let AprsData::Message(ref msg) = aprs_data {
635 if !self
636 .messenger
637 .is_new_incoming(&packet.source.callsign, msg, now)
638 {
639 return Ok(None);
640 }
641 return self.handle_incoming_message(msg, &packet.source).await;
642 }
643
644 self.dispatch_event(packet, aprs_data)
645 }
646
647 fn dispatch_event(
650 &mut self,
651 packet: Ax25Packet,
652 aprs_data: AprsData,
653 ) -> Result<Option<AprsEvent>, Error> {
654 match aprs_data {
655 AprsData::Position(pos) => {
656 let source: String = packet.source.callsign.as_str().to_owned();
657 if let Some(wx) = pos.weather.clone() {
658 if let Some(entry) = self.stations.get(&source).cloned() {
659 self.pending_events
660 .push_back(AprsEvent::StationHeard(entry));
661 }
662 return Ok(Some(AprsEvent::WeatherReceived {
663 source,
664 weather: wx,
665 }));
666 }
667 self.stations.get(&source).cloned().map_or(
668 Ok(Some(AprsEvent::PositionReceived {
669 source,
670 position: pos,
671 })),
672 |entry| Ok(Some(AprsEvent::StationHeard(entry))),
673 )
674 }
675 AprsData::Weather(wx) => Ok(Some(AprsEvent::WeatherReceived {
676 source: packet.source.callsign.as_str().to_owned(),
677 weather: wx,
678 })),
679 AprsData::Status(_)
680 | AprsData::Object(_)
681 | AprsData::Item(_)
682 | AprsData::ThirdParty { .. }
683 | AprsData::Grid(_)
684 | AprsData::RawGps(_)
685 | AprsData::StationCapabilities(_)
686 | AprsData::AgreloDfJr(_)
687 | AprsData::UserDefined { .. }
688 | AprsData::InvalidOrTest(_) => self
689 .stations
690 .get(&packet.source.callsign)
691 .cloned()
692 .map_or(Ok(Some(AprsEvent::RawPacket(packet))), |entry| {
693 Ok(Some(AprsEvent::StationHeard(entry)))
694 }),
695 AprsData::Message(_) => unreachable!("messages handled above"),
696 AprsData::Telemetry(_) | AprsData::Query(_) => self
697 .stations
698 .get(&packet.source.callsign)
699 .cloned()
700 .map_or(Ok(Some(AprsEvent::RawPacket(packet))), |entry| {
701 Ok(Some(AprsEvent::StationHeard(entry)))
702 }),
703 }
704 }
705
706 pub async fn send_message(&mut self, addressee: &str, text: &str) -> Result<String, Error> {
715 let now = Instant::now();
716 let message_id = self.messenger.send_message(addressee, text, now);
717
718 if let Some(frame) = self.messenger.next_frame_to_send(now) {
720 self.session.send_wire(&frame).await?;
721 }
722
723 Ok(message_id)
724 }
725
726 pub async fn beacon_position(
735 &mut self,
736 lat: f64,
737 lon: f64,
738 comment: &str,
739 ) -> Result<(), Error> {
740 let source = self.config.my_address();
741 let wire = build_aprs_position_report(
742 &source,
743 lat,
744 lon,
745 self.config.symbol_table,
746 self.config.symbol_code,
747 comment,
748 &self.config.digipeater_path,
749 );
750 self.session.send_wire(&wire).await?;
751 self.beaconing.beacon_sent(Instant::now());
752 Ok(())
753 }
754
755 pub async fn beacon_position_compressed(
764 &mut self,
765 lat: f64,
766 lon: f64,
767 comment: &str,
768 ) -> Result<(), Error> {
769 let source = self.config.my_address();
770 let wire = build_aprs_position_compressed(
771 &source,
772 lat,
773 lon,
774 self.config.symbol_table,
775 self.config.symbol_code,
776 comment,
777 &self.config.digipeater_path,
778 );
779 self.session.send_wire(&wire).await?;
780 self.beaconing.beacon_sent(Instant::now());
781 Ok(())
782 }
783
784 pub async fn send_status(&mut self, text: &str) -> Result<(), Error> {
790 let source = self.config.my_address();
791 let wire = build_aprs_status(&source, text, &self.config.digipeater_path);
792 self.session.send_wire(&wire).await?;
793 Ok(())
794 }
795
796 pub const fn set_query_response_position(&mut self, lat: f64, lon: f64) {
801 self.config.auto_query_position = Some((lat, lon));
802 }
803
804 pub async fn send_object(
810 &mut self,
811 name: &str,
812 live: bool,
813 lat: f64,
814 lon: f64,
815 comment: &str,
816 ) -> Result<(), Error> {
817 let source = self.config.my_address();
818 let wire = build_aprs_object(
819 &source,
820 name,
821 live,
822 lat,
823 lon,
824 self.config.symbol_table,
825 self.config.symbol_code,
826 comment,
827 &self.config.digipeater_path,
828 );
829 self.session.send_wire(&wire).await?;
830 Ok(())
831 }
832
833 pub async fn update_motion(
844 &mut self,
845 speed_kmh: f64,
846 course_deg: f64,
847 lat: f64,
848 lon: f64,
849 ) -> Result<bool, Error> {
850 let now = Instant::now();
851 if self.beaconing.should_beacon(speed_kmh, course_deg, now) {
852 let comment = &self.config.beacon_comment.clone();
853 self.beacon_position(lat, lon, comment).await?;
854 self.beaconing.beacon_sent_with(speed_kmh, course_deg, now);
855 Ok(true)
856 } else {
857 Ok(false)
858 }
859 }
860
861 #[must_use]
863 pub const fn stations(&self) -> &StationList {
864 &self.stations
865 }
866
867 #[must_use]
869 pub const fn messenger(&self) -> &AprsMessenger {
870 &self.messenger
871 }
872
873 #[must_use]
875 pub const fn config(&self) -> &AprsClientConfig {
876 &self.config
877 }
878
879 #[must_use]
891 pub fn format_for_is(&self, packet: &Ax25Packet) -> String {
892 let mut path_parts: Vec<String> =
893 packet.digipeaters.iter().map(ToString::to_string).collect();
894 path_parts.push("qAR".to_owned());
895 path_parts.push(format!("{}", self.config.my_address()));
896 let path_str = path_parts.join(",");
897 let data = String::from_utf8_lossy(&packet.info);
898 format!(
899 "{}>{},{path_str}:{data}\r\n",
900 packet.source, packet.destination,
901 )
902 }
903
904 pub async fn gate_from_is(&mut self, is_packet: &str) -> Result<bool, Error> {
917 if !self.should_gate_to_rf(is_packet) {
918 return Ok(false);
919 }
920
921 let Some((header, data)) = is_packet.split_once(':') else {
923 return Ok(false);
924 };
925
926 let third_party_payload = format!("}}{header}:{data}");
928 let source = self.config.my_address();
929 let dest = Ax25Address::new("APRS", 0);
930 let packet = Ax25Packet {
931 source,
932 destination: dest,
933 digipeaters: vec![Ax25Address::new("TCPIP", 0)],
934 control: 0x03,
935 protocol: 0xF0,
936 info: third_party_payload.into_bytes(),
937 };
938 let ax25_bytes = build_ax25(&packet);
939 let wire = encode_kiss_frame(&KissFrame {
940 port: 0,
941 command: CMD_DATA,
942 data: ax25_bytes,
943 });
944 self.session.send_wire(&wire).await?;
945 Ok(true)
946 }
947
948 #[must_use]
955 pub fn should_gate_to_is(packet: &Ax25Packet) -> bool {
956 let src_upper = packet.source.callsign.to_uppercase();
958 if src_upper == "TCPIP" || src_upper == "TCPXX" {
959 return false;
960 }
961
962 if packet.info.first() == Some(&b'}') {
965 return false;
966 }
967
968 for digi in &packet.digipeaters {
970 let upper = digi.callsign.to_uppercase();
971 if upper == "NOGATE" || upper == "RFONLY" {
972 return false;
973 }
974 }
975
976 true
977 }
978
979 #[must_use]
986 pub fn should_gate_to_rf(&self, is_line: &str) -> bool {
987 let Some(line) = aprs_is::AprsIsLine::parse(is_line) else {
988 return false;
989 };
990
991 if line.has_no_gate_marker() {
993 return false;
994 }
995
996 if !line.data.starts_with(':') {
998 return false;
999 }
1000
1001 if line.data.len() < 11 || line.data.as_bytes().get(10) != Some(&b':') {
1003 return false;
1004 }
1005 let addressee = line.data[1..10].trim();
1006
1007 self.stations.get(addressee).is_some()
1009 }
1010
1011 async fn handle_incoming_message(
1017 &mut self,
1018 msg: &AprsMessage,
1019 from: &Ax25Address,
1020 ) -> Result<Option<AprsEvent>, Error> {
1021 let my_call = self.config.callsign.to_uppercase();
1022
1023 if msg.addressee.to_uppercase() != my_call {
1025 let entry = self.stations.get(&from.callsign).cloned();
1027 return Ok(entry.map(AprsEvent::StationHeard));
1028 }
1029
1030 if let Some((is_ack, id)) = classify_ack_rej(&msg.text) {
1032 let id_owned = id.to_owned();
1033 if self.messenger.process_incoming(msg) {
1034 return Ok(Some(if is_ack {
1035 AprsEvent::MessageDelivered(id_owned)
1036 } else {
1037 AprsEvent::MessageRejected(id_owned)
1038 }));
1039 }
1040 return Ok(None);
1042 }
1043
1044 if self.config.auto_ack
1046 && let Some(ref id) = msg.message_id
1047 {
1048 let ack_frame = self.messenger.build_ack(&from.callsign, id);
1049 self.session.send_wire(&ack_frame).await?;
1050 }
1051
1052 if self.config.auto_query_response
1059 && msg.text.trim() == "?APRSP"
1060 && let Some((lat, lon)) = self.config.auto_query_position
1061 {
1062 tracing::info!(from = %from.callsign, "responding to ?APRSP query");
1063 let source = self.config.my_address();
1064 let wire = build_query_response_position(
1065 &source,
1066 lat,
1067 lon,
1068 self.config.symbol_table,
1069 self.config.symbol_code,
1070 &self.config.beacon_comment,
1071 &self.config.digipeater_path,
1072 );
1073 self.session.send_wire(&wire).await?;
1074 return Ok(Some(AprsEvent::QueryResponded {
1075 to: from.callsign.as_str().to_owned(),
1076 }));
1077 }
1078
1079 Ok(Some(AprsEvent::MessageReceived(msg.clone())))
1080 }
1081}
1082
1083#[cfg(test)]
1088mod tests {
1089 use super::*;
1090 use aprs::{build_aprs_message as build_msg, build_aprs_position_report as build_pos};
1091 use kiss_tnc::FEND;
1092
1093 use crate::aprs::default_digipeater_path;
1094 use crate::transport::MockTransport;
1095 use crate::types::TncBaud;
1096
1097 async fn mock_radio(baud: TncBaud) -> Radio<MockTransport> {
1099 let tn_cmd = format!("TN 2,{}\r", u8::from(baud));
1100 let tn_resp = format!("TN 2,{}\r", u8::from(baud));
1101 let mut mock = MockTransport::new();
1102 mock.expect(tn_cmd.as_bytes(), tn_resp.as_bytes());
1103 Radio::connect(mock).await.unwrap()
1104 }
1105
1106 fn test_config() -> AprsClientConfig {
1107 AprsClientConfig::new("N0CALL", 7)
1108 }
1109
1110 fn test_address() -> Ax25Address {
1111 Ax25Address::new("N0CALL", 7)
1112 }
1113
1114 #[tokio::test]
1115 async fn start_enters_kiss_mode() {
1116 let radio = mock_radio(TncBaud::Bps1200).await;
1117 let config = test_config();
1118 let client = AprsClient::start(radio, config).await.unwrap();
1119 assert_eq!(client.config().callsign, "N0CALL");
1120 assert_eq!(client.config().ssid, 7);
1121 assert_eq!(client.stations().len(), 0);
1122 assert_eq!(client.messenger().pending_count(), 0);
1123 }
1124
1125 #[tokio::test]
1126 async fn stop_exits_kiss_mode() {
1127 let radio = mock_radio(TncBaud::Bps1200).await;
1128 let config = test_config();
1129 let mut client = AprsClient::start(radio, config).await.unwrap();
1130
1131 client.session.transport.expect(&[FEND, 0xFF, FEND], &[]);
1133
1134 let _radio = client.stop().await.unwrap();
1135 }
1136
1137 #[tokio::test]
1138 async fn send_message_queues_and_transmits() {
1139 let radio = mock_radio(TncBaud::Bps1200).await;
1140 let config = test_config();
1141 let mut client = AprsClient::start(radio, config).await.unwrap();
1142
1143 let expected_wire = build_msg(
1146 &test_address(),
1147 "W1AW",
1148 "Hello",
1149 Some("1"),
1150 &default_digipeater_path(),
1151 );
1152 client.session.transport.expect(&expected_wire, &[]);
1153
1154 let id = client.send_message("W1AW", "Hello").await.unwrap();
1155 assert_eq!(id, "1");
1156 assert_eq!(client.messenger().pending_count(), 1);
1157 }
1158
1159 #[tokio::test]
1160 async fn beacon_position_transmits() {
1161 let radio = mock_radio(TncBaud::Bps1200).await;
1162 let config = test_config();
1163 let mut client = AprsClient::start(radio, config).await.unwrap();
1164
1165 let expected = build_pos(
1166 &test_address(),
1167 35.25,
1168 -97.75,
1169 '/',
1170 '>',
1171 "mobile",
1172 &default_digipeater_path(),
1173 );
1174 client.session.transport.expect(&expected, &[]);
1175
1176 client
1177 .beacon_position(35.25, -97.75, "mobile")
1178 .await
1179 .unwrap();
1180 }
1181
1182 #[tokio::test]
1183 async fn beacon_position_compressed_transmits() {
1184 let radio = mock_radio(TncBaud::Bps1200).await;
1185 let config = test_config();
1186 let mut client = AprsClient::start(radio, config).await.unwrap();
1187
1188 let expected = build_aprs_position_compressed(
1189 &test_address(),
1190 35.25,
1191 -97.75,
1192 '/',
1193 '>',
1194 "compressed",
1195 &default_digipeater_path(),
1196 );
1197 client.session.transport.expect(&expected, &[]);
1198
1199 client
1200 .beacon_position_compressed(35.25, -97.75, "compressed")
1201 .await
1202 .unwrap();
1203 }
1204
1205 #[tokio::test]
1206 async fn send_status_transmits() {
1207 let radio = mock_radio(TncBaud::Bps1200).await;
1208 let config = test_config();
1209 let mut client = AprsClient::start(radio, config).await.unwrap();
1210
1211 let expected = build_aprs_status(&test_address(), "On the air", &default_digipeater_path());
1212 client.session.transport.expect(&expected, &[]);
1213
1214 client.send_status("On the air").await.unwrap();
1215 }
1216
1217 #[tokio::test]
1218 async fn send_object_transmits() {
1219 let radio = mock_radio(TncBaud::Bps1200).await;
1220 let config = test_config();
1221 let mut client = AprsClient::start(radio, config).await.unwrap();
1222
1223 let expected = build_aprs_object(
1224 &test_address(),
1225 "Marathon",
1226 true,
1227 35.0,
1228 -97.0,
1229 '/',
1230 '>',
1231 "5K run",
1232 &default_digipeater_path(),
1233 );
1234 client.session.transport.expect(&expected, &[]);
1235
1236 client
1237 .send_object("Marathon", true, 35.0, -97.0, "5K run")
1238 .await
1239 .unwrap();
1240 }
1241
1242 #[test]
1243 fn config_builder_valid() {
1244 let cfg = AprsClientConfig::builder("N0CALL", 9)
1245 .symbol('/', '>')
1246 .beacon_comment("test")
1247 .auto_ack(false)
1248 .max_stations(100)
1249 .build()
1250 .unwrap();
1251 assert_eq!(cfg.callsign, "N0CALL");
1252 assert_eq!(cfg.ssid, 9);
1253 assert_eq!(cfg.symbol_table, '/');
1254 assert_eq!(cfg.symbol_code, '>');
1255 assert_eq!(cfg.beacon_comment, "test");
1256 assert!(!cfg.auto_ack);
1257 assert_eq!(cfg.max_stations, 100);
1258 }
1259
1260 #[test]
1261 fn config_builder_rejects_bad_callsign() {
1262 assert!(AprsClientConfig::builder("", 0).build().is_err());
1263 assert!(AprsClientConfig::builder("TOOLONG", 0).build().is_err());
1264 }
1265
1266 #[test]
1267 fn config_builder_rejects_bad_ssid() {
1268 assert!(AprsClientConfig::builder("N0CALL", 16).build().is_err());
1269 }
1270
1271 #[test]
1272 fn config_builder_rejects_bad_symbol_table() {
1273 assert!(
1274 AprsClientConfig::builder("N0CALL", 0)
1275 .symbol('!', '>')
1276 .build()
1277 .is_err()
1278 );
1279 }
1280
1281 #[test]
1282 fn config_defaults() {
1283 let config = AprsClientConfig::new("W1AW", 0);
1284 assert_eq!(config.callsign, "W1AW");
1285 assert_eq!(config.ssid, 0);
1286 assert_eq!(config.symbol_table, '/');
1287 assert_eq!(config.symbol_code, '>');
1288 assert!(config.auto_ack);
1289 assert!(config.digipeater.is_none());
1290 assert_eq!(config.max_stations, 500);
1291 assert_eq!(config.station_timeout_secs, 3600);
1292 }
1293
1294 #[test]
1295 fn config_my_address() {
1296 let config = AprsClientConfig::new("KQ4NIT", 9);
1297 let addr = config.my_address();
1298 assert_eq!(addr.callsign, "KQ4NIT");
1299 assert_eq!(addr.ssid, 9);
1300 }
1301
1302 #[test]
1303 fn aprs_event_debug_formatting() {
1304 let event = AprsEvent::MessageDelivered("42".to_owned());
1305 let debug = format!("{event:?}");
1306 assert!(debug.contains("MessageDelivered"));
1307 assert!(debug.contains("42"));
1308 }
1309
1310 #[test]
1311 fn aprs_client_debug_formatting() {
1312 let config = test_config();
1315 let debug = format!("{config:?}");
1316 assert!(debug.contains("N0CALL"));
1317 }
1318
1319 fn make_test_packet(source: &str, dest: &str, digis: &[&str], info: &[u8]) -> Ax25Packet {
1324 let parse_digi = |s: &str| -> Ax25Address {
1326 if let Some((call, ssid)) = s.split_once('-') {
1327 let ssid: u8 = ssid.parse().unwrap_or(0);
1328 Ax25Address::new(call, ssid)
1329 } else {
1330 Ax25Address::new(s, 0)
1331 }
1332 };
1333 Ax25Packet {
1334 source: Ax25Address::new(source, 0),
1335 destination: Ax25Address::new(dest, 0),
1336 digipeaters: digis.iter().map(|d| parse_digi(d)).collect(),
1337 control: 0x03,
1338 protocol: 0xF0,
1339 info: info.to_vec(),
1340 }
1341 }
1342
1343 #[tokio::test]
1344 async fn format_for_is_basic() {
1345 let radio = mock_radio(TncBaud::Bps1200).await;
1346 let config = test_config();
1347 let client = AprsClient::start(radio, config).await.unwrap();
1348
1349 let packet = make_test_packet("W1AW", "APK005", &["WIDE1-1"], b"!4903.50N/07201.75W-");
1350 let is_line = client.format_for_is(&packet);
1351
1352 assert!(is_line.starts_with("W1AW>APK005,WIDE1-1,qAR,N0CALL-7:"));
1353 assert!(is_line.ends_with("\r\n"));
1354 assert!(is_line.contains("!4903.50N/07201.75W-"));
1355 }
1356
1357 #[tokio::test]
1358 async fn format_for_is_no_digipeaters() {
1359 let radio = mock_radio(TncBaud::Bps1200).await;
1360 let config = test_config();
1361 let client = AprsClient::start(radio, config).await.unwrap();
1362
1363 let packet = make_test_packet("W1AW", "APK005", &[], b"!4903.50N/07201.75W-");
1364 let is_line = client.format_for_is(&packet);
1365
1366 assert!(is_line.starts_with("W1AW>APK005,qAR,N0CALL-7:"));
1367 }
1368
1369 #[test]
1370 fn should_gate_to_is_normal_packet() {
1371 let packet = make_test_packet("W1AW", "APK005", &["WIDE1-1"], b"!4903.50N/07201.75W-");
1372 assert!(AprsClient::<MockTransport>::should_gate_to_is(&packet));
1373 }
1374
1375 #[test]
1376 fn should_gate_to_is_blocks_tcpip_source() {
1377 let packet = make_test_packet("TCPIP", "APK005", &[], b"!4903.50N/07201.75W-");
1378 assert!(!AprsClient::<MockTransport>::should_gate_to_is(&packet));
1379 }
1380
1381 #[test]
1382 fn should_gate_to_is_blocks_tcpxx_source() {
1383 let packet = make_test_packet("TCPXX", "APK005", &[], b"!4903.50N/07201.75W-");
1384 assert!(!AprsClient::<MockTransport>::should_gate_to_is(&packet));
1385 }
1386
1387 #[test]
1388 fn should_gate_to_is_blocks_third_party() {
1389 let packet = make_test_packet("W1AW", "APK005", &[], b"}W2AW>APK005:!4903.50N/07201.75W-");
1390 assert!(!AprsClient::<MockTransport>::should_gate_to_is(&packet));
1391 }
1392
1393 #[test]
1394 fn should_gate_to_is_blocks_nogate_in_path() {
1395 let packet = make_test_packet("W1AW", "APK005", &["NOGATE"], b"!4903.50N/07201.75W-");
1396 assert!(!AprsClient::<MockTransport>::should_gate_to_is(&packet));
1397 }
1398
1399 #[test]
1400 fn should_gate_to_is_blocks_rfonly_in_path() {
1401 let packet = make_test_packet("W1AW", "APK005", &["RFONLY"], b"!4903.50N/07201.75W-");
1402 assert!(!AprsClient::<MockTransport>::should_gate_to_is(&packet));
1403 }
1404
1405 #[tokio::test]
1406 async fn should_gate_to_rf_rejects_position_reports() {
1407 let radio = mock_radio(TncBaud::Bps1200).await;
1408 let config = test_config();
1409 let client = AprsClient::start(radio, config).await.unwrap();
1410
1411 let line = "W1AW>APK005,TCPIP:!4903.50N/07201.75W-Test\r\n";
1413 assert!(!client.should_gate_to_rf(line));
1414 }
1415
1416 #[tokio::test]
1417 async fn should_gate_to_rf_rejects_nogate_in_path() {
1418 let radio = mock_radio(TncBaud::Bps1200).await;
1419 let config = test_config();
1420 let client = AprsClient::start(radio, config).await.unwrap();
1421
1422 let line = "W1AW>APK005,NOGATE::N0CALL :Hello{123\r\n";
1423 assert!(!client.should_gate_to_rf(line));
1424 }
1425
1426 #[tokio::test]
1427 async fn should_gate_to_rf_requires_heard_station() {
1428 let radio = mock_radio(TncBaud::Bps1200).await;
1429 let config = test_config();
1430 let client = AprsClient::start(radio, config).await.unwrap();
1431
1432 let line = "W1AW>APK005,TCPIP::UNKNOWN :Hello{123\r\n";
1434 assert!(!client.should_gate_to_rf(line));
1435 }
1436
1437 #[tokio::test]
1438 async fn should_gate_to_rf_accepts_message_to_heard_station() {
1439 let radio = mock_radio(TncBaud::Bps1200).await;
1440 let config = test_config();
1441 let mut client = AprsClient::start(radio, config).await.unwrap();
1442
1443 client.stations.update(
1445 "KQ4NIT",
1446 &AprsData::Status(aprs::AprsStatus {
1447 text: "on air".to_owned(),
1448 }),
1449 &[],
1450 Instant::now(),
1451 );
1452
1453 let line = "W1AW>APK005,qAC,SRV::KQ4NIT :Hello{123\r\n";
1457 assert!(client.should_gate_to_rf(line));
1458 }
1459
1460 #[tokio::test]
1461 async fn should_gate_to_rf_rejects_tcpip_marker() {
1462 let radio = mock_radio(TncBaud::Bps1200).await;
1463 let config = test_config();
1464 let mut client = AprsClient::start(radio, config).await.unwrap();
1465 client.stations.update(
1468 "KQ4NIT",
1469 &AprsData::Status(aprs::AprsStatus {
1470 text: "on air".to_owned(),
1471 }),
1472 &[],
1473 Instant::now(),
1474 );
1475 let line = "W1AW>APK005,TCPIP::KQ4NIT :Hello{123\r\n";
1476 assert!(!client.should_gate_to_rf(line));
1477 }
1478
1479 #[tokio::test]
1480 async fn gate_from_is_wraps_in_third_party_header() {
1481 let radio = mock_radio(TncBaud::Bps1200).await;
1482 let config = test_config();
1483 let mut client = AprsClient::start(radio, config).await.unwrap();
1484
1485 client.stations.update(
1487 "KQ4NIT",
1488 &AprsData::Status(aprs::AprsStatus {
1489 text: "on air".to_owned(),
1490 }),
1491 &[],
1492 Instant::now(),
1493 );
1494
1495 client.session.transport.expect_any_write();
1499
1500 let result = client
1501 .gate_from_is("W1AW>APK005,qAC,SRV::KQ4NIT :Hello{123")
1502 .await
1503 .unwrap();
1504 assert!(result);
1505 }
1506
1507 #[tokio::test]
1508 async fn gate_from_is_filters_position_report() {
1509 let radio = mock_radio(TncBaud::Bps1200).await;
1510 let config = test_config();
1511 let mut client = AprsClient::start(radio, config).await.unwrap();
1512
1513 let result = client
1515 .gate_from_is("W1AW>APK005,TCPIP:!4903.50N/07201.75W-Test")
1516 .await
1517 .unwrap();
1518 assert!(!result);
1519 }
1520
1521 fn build_kiss_data_frame(source: &str, ssid: u8, info: &[u8]) -> Vec<u8> {
1527 let packet = Ax25Packet {
1528 source: Ax25Address::new(source, ssid),
1529 destination: Ax25Address::new("APK005", 0),
1530 digipeaters: vec![],
1531 control: 0x03,
1532 protocol: 0xF0,
1533 info: info.to_vec(),
1534 };
1535 let ax25_bytes = build_ax25(&packet);
1536 encode_kiss_frame(&KissFrame {
1537 port: 0,
1538 command: CMD_DATA,
1539 data: ax25_bytes,
1540 })
1541 }
1542
1543 #[tokio::test]
1544 async fn next_event_position_received() {
1545 let radio = mock_radio(TncBaud::Bps1200).await;
1546 let config = test_config();
1547 let mut client = AprsClient::start(radio, config).await.unwrap();
1548
1549 let info = b"!3515.00N/09745.00W>mobile";
1551 let wire = build_kiss_data_frame("W1AW", 0, info);
1552 client.session.transport.queue_read(&wire);
1553
1554 let event = client.next_event().await.unwrap();
1555 assert!(event.is_some());
1556 match event.unwrap() {
1557 AprsEvent::StationHeard(entry) => {
1558 assert_eq!(entry.callsign, "W1AW");
1559 }
1560 AprsEvent::PositionReceived { source, .. } => {
1561 assert_eq!(source, "W1AW");
1562 }
1563 other => panic!("expected StationHeard or PositionReceived, got {other:?}"),
1564 }
1565 }
1566
1567 #[tokio::test]
1568 async fn next_event_weather_received() {
1569 let radio = mock_radio(TncBaud::Bps1200).await;
1570 let config = test_config();
1571 let mut client = AprsClient::start(radio, config).await.unwrap();
1572
1573 let info = b"!3515.00N/09745.00W_090/010g015t072";
1575 let wire = build_kiss_data_frame("WX1STA", 0, info);
1576 client.session.transport.queue_read(&wire);
1577
1578 let event = client.next_event().await.unwrap().expect("event");
1579 let AprsEvent::WeatherReceived { source, weather } = event else {
1580 panic!("expected WeatherReceived, got {event:?}");
1581 };
1582 assert_eq!(source, "WX1STA");
1583 assert_eq!(weather.wind_direction, Some(90));
1584 assert_eq!(weather.wind_speed, Some(10));
1585 assert_eq!(weather.wind_gust, Some(15));
1586 assert_eq!(weather.temperature, Some(72));
1587 }
1588
1589 #[tokio::test]
1590 async fn next_event_message_received() {
1591 let radio = mock_radio(TncBaud::Bps1200).await;
1592 let mut config = test_config();
1593 config.auto_ack = false; let mut client = AprsClient::start(radio, config).await.unwrap();
1595
1596 let info = b":N0CALL :Hello from W1AW{42";
1598 let wire = build_kiss_data_frame("W1AW", 0, info);
1599 client.session.transport.queue_read(&wire);
1600
1601 let event = client.next_event().await.unwrap();
1602 assert!(event.is_some());
1603 match event.unwrap() {
1604 AprsEvent::MessageReceived(msg) => {
1605 assert_eq!(msg.addressee, "N0CALL");
1606 assert!(msg.text.contains("Hello from W1AW"));
1607 }
1608 other => panic!("expected MessageReceived, got {other:?}"),
1609 }
1610 }
1611
1612 #[tokio::test]
1613 async fn next_event_message_delivered() {
1614 let radio = mock_radio(TncBaud::Bps1200).await;
1615 let config = test_config();
1616 let mut client = AprsClient::start(radio, config).await.unwrap();
1617
1618 let expected_wire = build_msg(
1620 &test_address(),
1621 "W1AW",
1622 "Test",
1623 Some("1"),
1624 &default_digipeater_path(),
1625 );
1626 client.session.transport.expect(&expected_wire, &[]);
1627 let _id = client.send_message("W1AW", "Test").await.unwrap();
1628
1629 let info = b":N0CALL :ack1";
1631 let wire = build_kiss_data_frame("W1AW", 0, info);
1632 client.session.transport.queue_read(&wire);
1633
1634 let event = client.next_event().await.unwrap();
1635 assert!(event.is_some());
1636 match event.unwrap() {
1637 AprsEvent::MessageDelivered(id) => {
1638 assert_eq!(id, "1");
1639 }
1640 other => panic!("expected MessageDelivered, got {other:?}"),
1641 }
1642 }
1643
1644 #[tokio::test]
1645 async fn next_event_message_rejected() {
1646 let radio = mock_radio(TncBaud::Bps1200).await;
1647 let config = test_config();
1648 let mut client = AprsClient::start(radio, config).await.unwrap();
1649
1650 let expected_wire = build_msg(
1652 &test_address(),
1653 "W1AW",
1654 "Test",
1655 Some("1"),
1656 &default_digipeater_path(),
1657 );
1658 client.session.transport.expect(&expected_wire, &[]);
1659 let _id = client.send_message("W1AW", "Test").await.unwrap();
1660
1661 let info = b":N0CALL :rej1";
1663 let wire = build_kiss_data_frame("W1AW", 0, info);
1664 client.session.transport.queue_read(&wire);
1665
1666 let event = client.next_event().await.unwrap();
1667 assert!(event.is_some());
1668 match event.unwrap() {
1669 AprsEvent::MessageRejected(id) => {
1670 assert_eq!(id, "1");
1671 }
1672 other => panic!("expected MessageRejected, got {other:?}"),
1673 }
1674 }
1675
1676 #[tokio::test]
1677 async fn next_event_raw_packet_for_unknown_data() {
1678 let radio = mock_radio(TncBaud::Bps1200).await;
1679 let config = test_config();
1680 let mut client = AprsClient::start(radio, config).await.unwrap();
1681
1682 let info = b"XUNKNOWN_DATA_TYPE";
1684 let wire = build_kiss_data_frame("W1AW", 0, info);
1685 client.session.transport.queue_read(&wire);
1686
1687 let event = client.next_event().await.unwrap();
1688 assert!(event.is_some());
1689 match event.unwrap() {
1690 AprsEvent::RawPacket(pkt) => {
1691 assert_eq!(pkt.source.callsign, "W1AW");
1692 }
1693 other => panic!("expected RawPacket, got {other:?}"),
1694 }
1695 }
1696
1697 #[tokio::test]
1698 async fn next_event_returns_none_when_idle() {
1699 let radio = mock_radio(TncBaud::Bps1200).await;
1706 let config = test_config();
1707 let mut client = AprsClient::start(radio, config).await.unwrap();
1708 let event = client.next_event().await.unwrap();
1709 assert!(event.is_none(), "expected Ok(None) on idle, got {event:?}");
1710 }
1711
1712 #[tokio::test]
1717 async fn update_motion_first_call_triggers_beacon() {
1718 let radio = mock_radio(TncBaud::Bps1200).await;
1719 let config = test_config();
1720 let mut client = AprsClient::start(radio, config).await.unwrap();
1721
1722 let expected = build_pos(
1724 &test_address(),
1725 35.25,
1726 -97.75,
1727 '/',
1728 '>',
1729 "",
1730 &default_digipeater_path(),
1731 );
1732 client.session.transport.expect(&expected, &[]);
1733
1734 let beaconed = client
1735 .update_motion(50.0, 90.0, 35.25, -97.75)
1736 .await
1737 .unwrap();
1738 assert!(beaconed);
1739 }
1740
1741 #[tokio::test]
1742 async fn update_motion_second_call_no_beacon() {
1743 let radio = mock_radio(TncBaud::Bps1200).await;
1744 let config = test_config();
1745 let mut client = AprsClient::start(radio, config).await.unwrap();
1746
1747 let expected = build_pos(
1749 &test_address(),
1750 35.25,
1751 -97.75,
1752 '/',
1753 '>',
1754 "",
1755 &default_digipeater_path(),
1756 );
1757 client.session.transport.expect(&expected, &[]);
1758 let _ = client
1759 .update_motion(50.0, 90.0, 35.25, -97.75)
1760 .await
1761 .unwrap();
1762
1763 let beaconed = client
1765 .update_motion(50.0, 90.0, 35.25, -97.75)
1766 .await
1767 .unwrap();
1768 assert!(!beaconed);
1769 }
1770}