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}