kenwood_thd75/aprs/
mod.rs

1//! TH-D75-specific APRS integration.
2//!
3//! Generic packet-radio protocols live in their own workspace crates:
4//! - [`kiss_tnc`] — KISS TNC wire framing.
5//! - [`ax25_codec`] — AX.25 frame codec.
6//! - [`aprs`] — APRS parser, digipeater, `SmartBeaconing`, messaging, station list.
7//! - [`aprs_is`] — APRS-IS TCP client.
8//!
9//! This module contains only the D75-specific glue: [`client::AprsClient`]
10//! owning a [`Radio`](crate::Radio) and [`KissSession`](crate::KissSession);
11//! [`mcp_bridge`] for MCP-memory ↔ runtime `SmartBeaconingConfig` conversion;
12//! and D75-specific helpers like [`ax25_ui_frame`], [`ax25_to_kiss_wire`],
13//! [`parse_digipeater_path`], and [`default_digipeater_path`].
14//!
15//! # TH-D75 KISS TNC specifications (per Operating Tips §2.7.2, User Manual Chapter 15)
16//!
17//! - TX buffer: 4 KB, RX buffer: 4 KB.
18//! - Speeds: 1200 bps (AFSK) and 9600 bps (GMSK).
19//! - The built-in TNC does NOT support Command mode or Converse mode;
20//!   it enters KISS mode directly.
21//! - The data band frequency defaults to Band A; changeable via Menu No. 506.
22//! - USB or Bluetooth interface is selectable via Menu No. 983.
23//! - To exit KISS mode: send KISS command `C0,FF,C0` (192,255,192).
24//!   To re-enter KISS mode from PC: send CAT command `TN 2,0` (Band A)
25//!   or `TN 2,1` (Band B).
26//!
27//! # References
28//!
29//! - KISS protocol: <http://www.ka9q.net/papers/kiss.html>
30//! - AX.25 v2.2: <http://www.ax25.net/AX25.2.2-Jul%2098-2.pdf>
31//! - APRS spec: <http://www.aprs.org/doc/APRS101.PDF>
32//! - TH-D75 User Manual, Chapter 15: Built-In KISS TNC
33
34pub mod client;
35pub mod mcp_bridge;
36
37use aprs::AprsError;
38use ax25_codec::{Ax25Address, Ax25Packet, build_ax25};
39use kiss_tnc::{KissFrame, encode_kiss_frame};
40
41// ---------------------------------------------------------------------------
42// Construction helpers for the foreign `Ax25Packet` type
43// ---------------------------------------------------------------------------
44
45/// Build a minimal APRS UI frame with the given source, destination, path,
46/// and info field. Control = 0x03, PID = 0xF0.
47///
48/// This is the free-function form of the former `Ax25Packet::ui_frame`
49/// inherent constructor; since [`Ax25Packet`] is a foreign type from the
50/// [`ax25_codec`] crate, inherent impls on it must live there.
51#[must_use]
52pub const fn ax25_ui_frame(
53    source: Ax25Address,
54    destination: Ax25Address,
55    path: Vec<Ax25Address>,
56    info: Vec<u8>,
57) -> Ax25Packet {
58    Ax25Packet {
59        source,
60        destination,
61        digipeaters: path,
62        control: 0x03,
63        protocol: 0xF0,
64        info,
65    }
66}
67
68/// Encode an [`Ax25Packet`] as a KISS-framed data frame ready for the
69/// wire. Equivalent to wrapping [`build_ax25`] in [`encode_kiss_frame`]
70/// with `port = 0` and `command = Data`.
71///
72/// This is the free-function form of the former `Ax25Packet::encode_kiss`
73/// inherent method; see [`ax25_ui_frame`] for why.
74#[must_use]
75pub fn ax25_to_kiss_wire(packet: &Ax25Packet) -> Vec<u8> {
76    let ax25_bytes = build_ax25(packet);
77    encode_kiss_frame(&KissFrame::data(ax25_bytes))
78}
79
80// ---------------------------------------------------------------------------
81// APRS digipeater-path helpers
82// ---------------------------------------------------------------------------
83
84/// Default APRS digipeater path: WIDE1-1, WIDE2-1.
85const DEFAULT_DIGIPEATERS: &[(&str, u8)] = &[("WIDE1", 1), ("WIDE2", 1)];
86
87/// Parse a digipeater path string like `"WIDE1-1,WIDE2-2"` into addresses.
88///
89/// Accepts comma-separated entries, each of the form `CALLSIGN[-SSID]`.
90/// Whitespace around entries is trimmed. An empty string returns an empty
91/// path (direct transmission with no digipeating).
92///
93/// # Errors
94///
95/// Returns [`AprsError::InvalidPath`] if any entry has an SSID that is
96/// not a valid 0-15 integer, or if the callsign is empty or longer than
97/// 6 characters.
98///
99/// # Examples
100///
101/// ```
102/// use kenwood_thd75::aprs::parse_digipeater_path;
103/// let path = parse_digipeater_path("WIDE1-1,WIDE2-2").unwrap();
104/// assert_eq!(path.len(), 2);
105/// assert_eq!(path[0].callsign, "WIDE1");
106/// assert_eq!(path[0].ssid, 1);
107/// ```
108pub fn parse_digipeater_path(s: &str) -> Result<Vec<Ax25Address>, AprsError> {
109    let trimmed = s.trim();
110    if trimmed.is_empty() {
111        return Ok(Vec::new());
112    }
113    let mut result = Vec::new();
114    for entry in trimmed.split(',') {
115        let entry = entry.trim();
116        if entry.is_empty() {
117            return Err(AprsError::InvalidPath(s.to_owned()));
118        }
119        let (callsign, ssid) = if let Some((call, ssid_str)) = entry.split_once('-') {
120            let ssid: u8 = ssid_str
121                .parse()
122                .map_err(|_| AprsError::InvalidPath(s.to_owned()))?;
123            if ssid > 15 {
124                return Err(AprsError::InvalidPath(s.to_owned()));
125            }
126            (call, ssid)
127        } else {
128            (entry, 0)
129        };
130        if callsign.is_empty() || callsign.len() > 6 {
131            return Err(AprsError::InvalidPath(s.to_owned()));
132        }
133        result.push(Ax25Address::new(callsign, ssid));
134    }
135    Ok(result)
136}
137
138/// Build the default digipeater path as [`Ax25Address`] entries
139/// (`WIDE1-1,WIDE2-1`).
140#[must_use]
141pub fn default_digipeater_path() -> Vec<Ax25Address> {
142    DEFAULT_DIGIPEATERS
143        .iter()
144        .map(|(call, ssid)| Ax25Address::new(call, *ssid))
145        .collect()
146}
147
148// ---------------------------------------------------------------------------
149// Tests
150// ---------------------------------------------------------------------------
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use kiss_tnc::FEND;
156
157    #[test]
158    fn parse_digipeater_path_empty_is_ok() {
159        assert_eq!(
160            parse_digipeater_path("").unwrap(),
161            Vec::<Ax25Address>::new()
162        );
163        assert_eq!(
164            parse_digipeater_path("   ").unwrap(),
165            Vec::<Ax25Address>::new()
166        );
167    }
168
169    #[test]
170    fn parse_digipeater_path_single() {
171        let path = parse_digipeater_path("WIDE1-1").unwrap();
172        assert_eq!(path.len(), 1);
173        assert_eq!(path[0].callsign, "WIDE1");
174        assert_eq!(path[0].ssid, 1);
175    }
176
177    #[test]
178    fn parse_digipeater_path_multiple() {
179        let path = parse_digipeater_path("WIDE1-1,WIDE2-2").unwrap();
180        assert_eq!(path.len(), 2);
181        assert_eq!(path[0].callsign, "WIDE1");
182        assert_eq!(path[1].callsign, "WIDE2");
183        assert_eq!(path[1].ssid, 2);
184    }
185
186    #[test]
187    fn parse_digipeater_path_no_ssid() {
188        let path = parse_digipeater_path("WIDE1").unwrap();
189        assert_eq!(path.len(), 1);
190        assert_eq!(path[0].ssid, 0);
191    }
192
193    #[test]
194    fn parse_digipeater_path_rejects_bad_ssid() {
195        assert!(parse_digipeater_path("WIDE1-99").is_err());
196        assert!(parse_digipeater_path("WIDE1-abc").is_err());
197    }
198
199    #[test]
200    fn parse_digipeater_path_rejects_long_callsign() {
201        assert!(parse_digipeater_path("TOOLONG-1").is_err());
202    }
203
204    #[test]
205    fn default_path_is_wide1_wide2() {
206        let path = default_digipeater_path();
207        assert_eq!(path.len(), 2);
208        assert_eq!(path[0].callsign, "WIDE1");
209        assert_eq!(path[0].ssid, 1);
210        assert_eq!(path[1].callsign, "WIDE2");
211        assert_eq!(path[1].ssid, 1);
212    }
213
214    #[test]
215    fn ax25_ui_frame_sets_control_and_pid() {
216        let packet = ax25_ui_frame(
217            Ax25Address::new("N0CALL", 7),
218            Ax25Address::new("APRS", 0),
219            vec![],
220            b"!test".to_vec(),
221        );
222        assert_eq!(packet.control, 0x03);
223        assert_eq!(packet.protocol, 0xF0);
224        assert_eq!(packet.source.callsign, "N0CALL");
225        assert_eq!(packet.destination.callsign, "APRS");
226        assert_eq!(&packet.info, b"!test");
227    }
228
229    #[test]
230    fn ax25_to_kiss_wire_produces_valid_kiss_frame() {
231        let packet = ax25_ui_frame(
232            Ax25Address::new("N0CALL", 7),
233            Ax25Address::new("APRS", 0),
234            vec![],
235            b"!test".to_vec(),
236        );
237        let wire = ax25_to_kiss_wire(&packet);
238        // KISS frame starts and ends with FEND (0xC0).
239        assert_eq!(wire.first(), Some(&FEND));
240        assert_eq!(wire.last(), Some(&FEND));
241    }
242}