kenwood_thd75/aprs/
client.rs

1//! Integrated APRS client for the TH-D75.
2//!
3//! Combines KISS session management, position beaconing ([`SmartBeaconing`]),
4//! reliable messaging (ack/retry via [`AprsMessenger`]), station tracking
5//! ([`StationList`]), and optional digipeater forwarding
6//! ([`DigipeaterConfig`]) into a single, easy-to-use async interface.
7//!
8//! # Design
9//!
10//! The [`AprsClient`] owns a [`KissSession`] and therefore the radio
11//! transport. Create it with [`AprsClient::start`], which enters KISS
12//! mode, and tear it down with [`AprsClient::stop`], which exits KISS
13//! mode and returns the [`Radio`] for other use. This is the same
14//! ownership pattern used by [`KissSession`] and
15//! [`MmdvmSession`](crate::radio::mmdvm_session::MmdvmSession).
16//!
17//! The main loop calls [`AprsClient::next_event`] repeatedly. Each call
18//! performs one cycle of I/O: send pending retries and beacons, receive
19//! an incoming packet (with a short timeout), parse it, update the
20//! station list, auto-ack if configured, and return a typed
21//! [`AprsEvent`].
22//!
23//! # Example
24//!
25//! ```no_run
26//! use kenwood_thd75::{Radio, AprsClient, AprsClientConfig};
27//! use kenwood_thd75::transport::SerialTransport;
28//!
29//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
30//! let transport = SerialTransport::open("/dev/cu.usbmodem1234", 115_200)?;
31//! let radio = Radio::connect(transport).await?;
32//!
33//! let config = AprsClientConfig::new("N0CALL", 7);
34//! let mut client = AprsClient::start(radio, config).await.map_err(|(_, e)| e)?;
35//!
36//! // Send a message
37//! client.send_message("KQ4NIT", "Hello!").await?;
38//!
39//! // Beacon position
40//! client.beacon_position(35.25, -97.75, "On the road").await?;
41//!
42//! // Process incoming packets (call in a loop)
43//! while let Some(event) = client.next_event().await? {
44//!     match event {
45//!         kenwood_thd75::AprsEvent::StationHeard(entry) => {
46//!             println!("Heard: {}", entry.callsign);
47//!         }
48//!         kenwood_thd75::AprsEvent::MessageReceived(msg) => {
49//!             println!("Msg: {}", msg.text);
50//!         }
51//!         kenwood_thd75::AprsEvent::MessageDelivered(id) => {
52//!             println!("Delivered: {id}");
53//!         }
54//!         kenwood_thd75::AprsEvent::MessageExpired(id) => {
55//!             println!("Failed: {id}");
56//!         }
57//!         _ => {}
58//!     }
59//! }
60//!
61//! // Clean shutdown — exits KISS mode, returns Radio for other use
62//! let _radio = client.stop().await?;
63//! # Ok(())
64//! # }
65//! ```
66
67use 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
86/// Default receive timeout for `next_event` polling (500 ms).
87///
88/// Short enough to keep the event loop responsive for retries and
89/// beacons, long enough to avoid busy-spinning on a quiet channel.
90const EVENT_POLL_TIMEOUT: Duration = Duration::from_millis(500);
91
92/// Configuration for an [`AprsClient`] session.
93///
94/// Created with [`AprsClientConfig::new`] which provides sensible
95/// defaults for a mobile station. All fields are public for
96/// customisation before passing to [`AprsClient::start`]. Marked
97/// `#[non_exhaustive]` so future optional fields can be added without
98/// breaking the API.
99#[derive(Debug, Clone)]
100#[non_exhaustive]
101pub struct AprsClientConfig {
102    /// Station callsign (e.g., `"N0CALL"`).
103    pub callsign: String,
104    /// SSID (0-15). Common values: 7 = handheld, 9 = mobile, 15 = generic.
105    pub ssid: u8,
106    /// APRS primary symbol table character. Default: `'/'`.
107    pub symbol_table: char,
108    /// APRS symbol code character. Default: `'>'` (car).
109    pub symbol_code: char,
110    /// TNC data speed. Default: 1200 bps (AFSK).
111    pub baud: TncBaud,
112    /// Default comment appended to position beacons.
113    pub beacon_comment: String,
114    /// `SmartBeaconing` algorithm configuration.
115    pub smart_beaconing: SmartBeaconingConfig,
116    /// Optional digipeater configuration. When set, incoming packets
117    /// are evaluated for relay according to the digipeater rules.
118    pub digipeater: Option<DigipeaterConfig>,
119    /// Maximum number of stations to track. Default: 500.
120    pub max_stations: usize,
121    /// Seconds before a station entry expires. Default: 3600 (1 hour).
122    pub station_timeout_secs: u64,
123    /// Automatically acknowledge incoming messages addressed to us.
124    /// Default: `true`.
125    pub auto_ack: bool,
126    /// Digipeater path for outgoing packets.
127    ///
128    /// Default: `WIDE1-1,WIDE2-1` (standard 2-hop path). Use an empty
129    /// vector for direct transmission with no digipeating. Parse from
130    /// a string with [`crate::aprs::parse_digipeater_path`].
131    pub digipeater_path: Vec<Ax25Address>,
132    /// Automatically respond to `?APRSP` position queries addressed to us.
133    ///
134    /// When set and an incoming message contains `?APRSP`, the client
135    /// sends a position beacon in response. Requires
136    /// [`auto_query_position`](Self::auto_query_position) to be set.
137    ///
138    /// Default: `true`.
139    pub auto_query_response: bool,
140    /// Cached position for auto query responses, as `(lat, lon)`.
141    ///
142    /// When `None`, query responses are not sent even if
143    /// `auto_query_response` is `true`. Update via
144    /// [`AprsClient::set_query_response_position`].
145    pub auto_query_position: Option<(f64, f64)>,
146}
147
148impl AprsClientConfig {
149    /// Create a new configuration with sensible defaults for a mobile station.
150    ///
151    /// - Symbol: car (`/>`)
152    /// - Baud: 1200 bps (standard APRS AFSK)
153    /// - `SmartBeaconing`: TH-D75 defaults (Menu 540-547)
154    /// - Max stations: 500, timeout: 1 hour
155    /// - Auto-ack: on
156    #[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    /// Build the [`Ax25Address`] for this station.
177    fn my_address(&self) -> Ax25Address {
178        Ax25Address::new(&self.callsign, self.ssid)
179    }
180
181    /// Start building a configuration with the fluent builder.
182    ///
183    /// Example:
184    ///
185    /// ```no_run
186    /// use kenwood_thd75::AprsClientConfig;
187    /// let config = AprsClientConfig::builder("N0CALL", 9)
188    ///     .symbol('/', '>')
189    ///     .beacon_comment("mobile")
190    ///     .auto_ack(true)
191    ///     .build()
192    ///     .expect("valid callsign and symbol");
193    /// ```
194    #[must_use]
195    pub fn builder(callsign: &str, ssid: u8) -> AprsClientConfigBuilder {
196        AprsClientConfigBuilder::new(callsign, ssid)
197    }
198}
199
200/// Fluent builder for [`AprsClientConfig`].
201///
202/// Validates callsign / SSID / symbol at [`Self::build`] time and
203/// returns a descriptive [`crate::error::ValidationError`] on bad input.
204#[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    /// Create a new builder with sensible defaults for a mobile station.
224    #[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    /// Set both symbol table and code in one call.
245    #[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    /// Override the TNC data speed (default 1200 bps).
253    #[must_use]
254    pub const fn baud(mut self, baud: TncBaud) -> Self {
255        self.baud = baud;
256        self
257    }
258
259    /// Set the default beacon comment.
260    #[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    /// Replace the `SmartBeaconing` config.
267    #[must_use]
268    pub const fn smart_beaconing(mut self, sb: SmartBeaconingConfig) -> Self {
269        self.smart_beaconing = sb;
270        self
271    }
272
273    /// Attach a digipeater configuration.
274    #[must_use]
275    pub fn digipeater(mut self, cfg: DigipeaterConfig) -> Self {
276        self.digipeater = Some(cfg);
277        self
278    }
279
280    /// Maximum number of stations tracked in the station list.
281    #[must_use]
282    pub const fn max_stations(mut self, n: usize) -> Self {
283        self.max_stations = n;
284        self
285    }
286
287    /// Station entry expiry in seconds.
288    #[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    /// Whether to auto-ack incoming messages addressed to us.
295    #[must_use]
296    pub const fn auto_ack(mut self, on: bool) -> Self {
297        self.auto_ack = on;
298        self
299    }
300
301    /// Replace the outgoing digipeater path.
302    #[must_use]
303    pub fn digipeater_path(mut self, path: Vec<Ax25Address>) -> Self {
304        self.digipeater_path = path;
305        self
306    }
307
308    /// Whether to auto-respond to `?APRSP` position queries.
309    #[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    /// Cache a position for auto query responses.
316    #[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    /// Validate the accumulated fields and build the config.
323    ///
324    /// # Errors
325    ///
326    /// Returns [`crate::error::ValidationError::AprsWireOutOfRange`] if the callsign
327    /// fails validation, the SSID is out of range, or the symbol table
328    /// byte is outside the APRS-defined set (`/`, `\\`, 0-9, A-Z).
329    pub fn build(self) -> Result<AprsClientConfig, crate::error::ValidationError> {
330        // Callsign + SSID validation (same rules as Ax25Address::try_new).
331        // `Ax25Address::try_new` now returns `Ax25Error` from the
332        // `ax25-codec` crate; map to this crate's `ValidationError` at the
333        // boundary so callers keep the stable error type.
334        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        // Validate symbol table character. The aprs crate's
341        // `SymbolTable::from_byte` returns `AprsError`; map to
342        // `ValidationError` at the thd75 API boundary so callers keep the
343        // stable error type.
344        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        // Validate symbol code (printable ASCII per APRS 1.0.1).
351        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// ---------------------------------------------------------------------------
379// AprsEvent
380// ---------------------------------------------------------------------------
381
382/// An event produced by [`AprsClient::next_event`].
383///
384/// Each variant represents a distinct category of APRS activity. The
385/// client translates raw KISS/AX.25/APRS packets into these typed
386/// events so callers never need to parse wire data.
387#[derive(Debug, Clone)]
388pub enum AprsEvent {
389    /// A new or updated station was heard. Contains the station's
390    /// current state after applying the received packet.
391    StationHeard(StationEntry),
392    /// An APRS message addressed to us was received.
393    MessageReceived(AprsMessage),
394    /// A previously sent message was acknowledged by the remote station.
395    MessageDelivered(String),
396    /// A previously sent message was rejected by the remote station.
397    MessageRejected(String),
398    /// A previously sent message expired after exhausting all retries.
399    MessageExpired(String),
400    /// A position report was received from another station.
401    PositionReceived {
402        /// Source callsign.
403        source: String,
404        /// Decoded position data.
405        position: AprsPosition,
406    },
407    /// A weather report was received from another station.
408    WeatherReceived {
409        /// Source callsign.
410        source: String,
411        /// Decoded weather data.
412        weather: AprsWeather,
413    },
414    /// A packet was digipeated (relayed) by our station.
415    PacketDigipeated {
416        /// Original source callsign.
417        source: String,
418    },
419    /// An automatic response to a `?APRSP` position query was sent.
420    QueryResponded {
421        /// The callsign that sent the query.
422        to: String,
423    },
424    /// A raw AX.25 packet that does not match any specific event type.
425    RawPacket(Ax25Packet),
426}
427
428// ---------------------------------------------------------------------------
429// AprsClient
430// ---------------------------------------------------------------------------
431
432/// Complete APRS client for the TH-D75.
433///
434/// Combines KISS session management, position beaconing
435/// ([`SmartBeaconing`]), reliable messaging (ack/retry), station
436/// tracking, and optional digipeater forwarding into a single,
437/// easy-to-use async interface.
438///
439/// See the [module-level documentation](self) for a full usage example.
440pub struct AprsClient<T: Transport> {
441    session: KissSession<T>,
442    config: AprsClientConfig,
443    messenger: AprsMessenger,
444    stations: StationList,
445    beaconing: SmartBeaconing,
446    /// Events produced but not yet returned to the caller.
447    ///
448    /// Used when a single call to [`Self::next_event`] generates more than
449    /// one event (e.g. several retry timers expired at once). Drained at
450    /// the top of each `next_event` before any new I/O is performed.
451    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    /// Start the APRS client, entering KISS mode on the radio.
466    ///
467    /// Consumes the [`Radio`] and returns an [`AprsClient`] that owns
468    /// the transport. Call [`stop`](Self::stop) to exit KISS mode and
469    /// reclaim the `Radio`.
470    ///
471    /// # Errors
472    ///
473    /// On failure, returns the [`Radio`] alongside the error so the
474    /// caller can continue using CAT mode.
475    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    /// Stop the APRS client, exiting KISS mode and returning the [`Radio`].
504    ///
505    /// # Errors
506    ///
507    /// Returns an error if the KISS exit command fails.
508    pub async fn stop(self) -> Result<Radio<T>, Error> {
509        self.session.exit().await
510    }
511
512    /// Process pending I/O and return the next event.
513    ///
514    /// Each call performs one cycle:
515    /// 1. Send any pending message retries via the [`AprsMessenger`].
516    /// 2. Expire messages that have exhausted all retries.
517    /// 3. Attempt to receive a KISS frame (short timeout).
518    /// 4. If received: parse AX.25, parse APRS data, update station list.
519    /// 5. If it is a message addressed to us and `auto_ack` is on, send ack.
520    /// 6. If digipeater is configured, check whether we should relay.
521    /// 7. Return the appropriate [`AprsEvent`].
522    ///
523    /// Returns `Ok(None)` when no activity occurs within the poll
524    /// timeout. Callers should loop on this method.
525    ///
526    /// # Errors
527    ///
528    /// Returns an error on transport failures.
529    pub async fn next_event(&mut self) -> Result<Option<AprsEvent>, Error> {
530        // 0. Drain any events produced by a prior call.
531        if let Some(ev) = self.pending_events.pop_front() {
532            return Ok(Some(ev));
533        }
534
535        // Read the wall clock exactly once per iteration and thread it
536        // through every stateful `aprs` call that needs a timestamp.
537        // This keeps the sans-io `aprs` crate pure and guarantees that
538        // all state-machine decisions within a single iteration observe
539        // the same instant.
540        let now = Instant::now();
541
542        // 1. Send pending retries and enqueue expired message events.
543        self.process_retries(now).await?;
544        if let Some(ev) = self.pending_events.pop_front() {
545            return Ok(Some(ev));
546        }
547
548        // 2. Try to receive a KISS data frame.
549        let Some(packet) = self.recv_one_frame().await? else {
550            return Ok(None);
551        };
552
553        // 3. Run digipeater logic before consuming the packet.
554        if let Some(ev) = self.process_digipeater(&packet, now).await? {
555            return Ok(Some(ev));
556        }
557
558        // 4. Parse APRS content and dispatch.
559        self.handle_packet(packet, now).await
560    }
561
562    /// Phase 1: send any retry frames that are due and queue up
563    /// `MessageExpired` events for any messages that exhausted their
564    /// retry budget.
565    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    /// Phase 2: try to receive one KISS frame, decode it as AX.25, and
576    /// return the parsed packet. Returns `Ok(None)` on timeout or
577    /// `WouldBlock` (no data ready), and on non-data frames / parse
578    /// failures. Real transport errors propagate as `Err`.
579    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    /// Phase 3: if the digipeater is configured and would relay this
600    /// packet, emit the relay frame and return a
601    /// [`AprsEvent::PacketDigipeated`] event.
602    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    /// Phase 4: parse the APRS info field, update the station list,
620    /// and dispatch to the appropriate event variant.
621    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    /// Phase 4b: given the parsed APRS data and the source packet, pick
648    /// the right `AprsEvent` variant.
649    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    /// Send an APRS message to a station. Returns the message ID for tracking.
707    ///
708    /// The message is queued with the [`AprsMessenger`] for automatic
709    /// retry until acknowledged, rejected, or expired.
710    ///
711    /// # Errors
712    ///
713    /// Returns an error if the initial transmission fails.
714    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        // Send the first frame immediately.
719        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    /// Beacon current position using uncompressed format.
727    ///
728    /// Builds an APRS position report and transmits it via KISS.
729    /// Updates the `SmartBeaconing` timer.
730    ///
731    /// # Errors
732    ///
733    /// Returns an error if the transmission fails.
734    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    /// Beacon position using compressed format (smaller packet).
756    ///
757    /// Uses base-91 encoding per APRS101 Chapter 9. Produces smaller
758    /// packets than [`beacon_position`](Self::beacon_position).
759    ///
760    /// # Errors
761    ///
762    /// Returns an error if the transmission fails.
763    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    /// Send a status report.
785    ///
786    /// # Errors
787    ///
788    /// Returns an error if the transmission fails.
789    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    /// Set the cached position for auto query responses.
797    ///
798    /// When a station sends `?APRSP` and auto query response is enabled,
799    /// the client replies with a position beacon using this position.
800    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    /// Send an object report.
805    ///
806    /// # Errors
807    ///
808    /// Returns an error if the transmission fails.
809    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    /// Update speed and course for `SmartBeaconing`.
834    ///
835    /// If the `SmartBeaconing` algorithm determines a beacon is due (based
836    /// on speed, course change, and elapsed time), a position report is
837    /// transmitted and this method returns `Ok(true)`. Otherwise returns
838    /// `Ok(false)`.
839    ///
840    /// # Errors
841    ///
842    /// Returns an error if the beacon transmission fails.
843    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    /// Get the station list (read-only reference).
862    #[must_use]
863    pub const fn stations(&self) -> &StationList {
864        &self.stations
865    }
866
867    /// Get the messenger state (pending message count, etc).
868    #[must_use]
869    pub const fn messenger(&self) -> &AprsMessenger {
870        &self.messenger
871    }
872
873    /// Get the current configuration.
874    #[must_use]
875    pub const fn config(&self) -> &AprsClientConfig {
876        &self.config
877    }
878
879    // -----------------------------------------------------------------------
880    // IGate (Internet Gateway) methods
881    // -----------------------------------------------------------------------
882
883    /// Format a received RF packet for transmission to APRS-IS.
884    ///
885    /// Converts the AX.25 packet to APRS-IS text format:
886    /// `SOURCE>DEST,PATH,qAR,MYCALL:data`
887    ///
888    /// The `qAR` construct identifies this as an RF-gated packet per
889    /// the APRS-IS q-construct specification.
890    #[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    /// Parse an APRS-IS packet and transmit it on RF via KISS.
905    ///
906    /// Only transmits if the packet passes the third-party header check
907    /// (avoids RF loops). The packet is wrapped in a third-party header
908    /// `}` before transmission per APRS101 Chapter 17.
909    ///
910    /// Returns `true` if the packet was transmitted, `false` if it was
911    /// filtered out.
912    ///
913    /// # Errors
914    ///
915    /// Returns an error if the KISS transmission fails.
916    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        // Parse source>dest,path:data
922        let Some((header, data)) = is_packet.split_once(':') else {
923            return Ok(false);
924        };
925
926        // Wrap in third-party header: }original_packet
927        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    /// Check if a packet should be gated to APRS-IS.
949    ///
950    /// Applies standard `IGate` rules:
951    /// - Don't gate packets from TCPIP/TCPXX sources
952    /// - Don't gate third-party packets (}prefix)
953    /// - Don't gate packets with NOGATE/RFONLY in path
954    #[must_use]
955    pub fn should_gate_to_is(packet: &Ax25Packet) -> bool {
956        // Don't gate packets originating from the internet.
957        let src_upper = packet.source.callsign.to_uppercase();
958        if src_upper == "TCPIP" || src_upper == "TCPXX" {
959            return false;
960        }
961
962        // Don't gate third-party packets (info starts with '}'). These
963        // have already been gated once and re-gating creates loops.
964        if packet.info.first() == Some(&b'}') {
965            return false;
966        }
967
968        // Don't gate packets with NOGATE or RFONLY in the digipeater path.
969        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    /// Check if an APRS-IS packet should be gated to RF.
980    ///
981    /// Applies standard `IGate` rules:
982    /// - Only gate messages addressed to stations heard on RF recently
983    /// - Don't gate general position reports to RF (would flood)
984    /// - Don't gate packets containing TCPIP/TCPXX/NOGATE/RFONLY in path
985    #[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        // Skip packets marked as RF-only or no-gate (any path element).
992        if line.has_no_gate_marker() {
993            return false;
994        }
995
996        // Only gate APRS messages, not position reports, weather, etc.
997        if !line.data.starts_with(':') {
998            return false;
999        }
1000
1001        // Extract the addressee from the message (9-char padded field).
1002        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        // Only gate if the addressee has been heard on RF recently.
1008        self.stations.get(addressee).is_some()
1009    }
1010
1011    // -----------------------------------------------------------------------
1012    // Internal helpers
1013    // -----------------------------------------------------------------------
1014
1015    /// Handle an incoming APRS message addressed to us.
1016    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        // Check if this message is addressed to us.
1024        if msg.addressee.to_uppercase() != my_call {
1025            // Not for us — treat as a station heard event.
1026            let entry = self.stations.get(&from.callsign).cloned();
1027            return Ok(entry.map(AprsEvent::StationHeard));
1028        }
1029
1030        // Check if it is an ack/rej control frame for a pending message.
1031        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            // Control frame for an unknown message — ignore.
1041            return Ok(None);
1042        }
1043
1044        // Regular message addressed to us — auto-ack if configured.
1045        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        // Handle directed position query (`?APRSP`).
1053        //
1054        // When enabled and a position is cached, respond with a position
1055        // beacon. The beacon goes to CQCQCQ (all stations), not just the
1056        // querying station — this is per APRS spec, which treats the
1057        // query as a request for a fresh beacon from the queried station.
1058        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// ---------------------------------------------------------------------------
1084// Tests
1085// ---------------------------------------------------------------------------
1086
1087#[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    /// Build a mock Radio that expects the TN 2,x command for KISS entry.
1098    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        // Queue the KISS exit frame expectation.
1132        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        // The messenger builds a KISS-encoded wire frame internally.
1144        // send_message calls send_wire which writes it directly.
1145        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        // Cannot construct AprsClient without async, but we can verify
1313        // the config formatting.
1314        let config = test_config();
1315        let debug = format!("{config:?}");
1316        assert!(debug.contains("N0CALL"));
1317    }
1318
1319    // -----------------------------------------------------------------------
1320    // IGate tests
1321    // -----------------------------------------------------------------------
1322
1323    fn make_test_packet(source: &str, dest: &str, digis: &[&str], info: &[u8]) -> Ax25Packet {
1324        // Parse each digi string as "CALL-SSID" or bare "CALL".
1325        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        // Position report (starts with '!') should not be gated to RF.
1412        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        // Message to a station NOT in our station list.
1433        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        // Simulate hearing a station on RF.
1444        client.stations.update(
1445            "KQ4NIT",
1446            &AprsData::Status(aprs::AprsStatus {
1447                text: "on air".to_owned(),
1448            }),
1449            &[],
1450            Instant::now(),
1451        );
1452
1453        // Message addressed to that station should be gated (no TCPIP
1454        // marker in the path since the spec forbids gating TCPIP-tagged
1455        // packets back to RF).
1456        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        // Even with a heard addressee, TCPIP-marked packets must NOT
1466        // be gated back to RF (APRS-IS spec).
1467        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        // Simulate hearing the addressee on RF so gating is allowed.
1486        client.stations.update(
1487            "KQ4NIT",
1488            &AprsData::Status(aprs::AprsStatus {
1489                text: "on air".to_owned(),
1490            }),
1491            &[],
1492            Instant::now(),
1493        );
1494
1495        // Expect the KISS frame output (we just need the mock to accept it).
1496        // The exact bytes depend on the third-party packet encoding.
1497        // We use a broad expectation: the mock will accept any write.
1498        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        // Position report should not be gated to RF.
1514        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    // -----------------------------------------------------------------------
1522    // next_event dispatch tests
1523    // -----------------------------------------------------------------------
1524
1525    /// Build a KISS-encoded data frame from a source callsign and APRS info.
1526    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        // Uncompressed position: !DDMM.MMN/DDDMM.MMW>comment
1550        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        // Position + weather report: !DDMM.MMN/DDDMM.MMW_DIR/SPDgGUSTt072
1574        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; // Disable auto-ack to simplify test
1594        let mut client = AprsClient::start(radio, config).await.unwrap();
1595
1596        // APRS message: :ADDRESSEE:message text{id
1597        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        // First, send a message so we have a pending message with id "1"
1619        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        // Now simulate receiving an ack for that message
1630        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        // Send a message to have pending id "1"
1651        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        // Simulate receiving a rejection
1662        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        // Send some unparseable APRS data (random info bytes)
1683        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        // With no incoming frames the event loop should return Ok(None)
1700        // after the receive timeout, indicating the caller can sleep
1701        // before the next iteration. We don't use tokio::time::pause()
1702        // here because the underlying mock transport returns WouldBlock
1703        // immediately, which the session converts to a Timeout error,
1704        // which next_event maps to Ok(None) without ever sleeping.
1705        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    // -----------------------------------------------------------------------
1713    // update_motion tests
1714    // -----------------------------------------------------------------------
1715
1716    #[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        // SmartBeaconing always triggers on first call.
1723        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        // First call beacons.
1748        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        // Second call immediately after should NOT beacon.
1764        let beaconed = client
1765            .update_motion(50.0, 90.0, 35.25, -97.75)
1766            .await
1767            .unwrap();
1768        assert!(!beaconed);
1769    }
1770}