kenwood_thd75/radio/dstar.rs
1//! D-STAR (Digital Smart Technologies for Amateur Radio) subsystem methods.
2//!
3//! D-STAR is a digital voice and data protocol developed by JARL (Japan Amateur Radio League).
4//! The TH-D75 supports D-STAR voice (DV mode) and data, including gateway linking for
5//! internet-connected repeater access.
6//!
7//! # Command relationships
8//!
9//! - **DS**: selects the active D-STAR callsign slot (which stored callsign configuration to use)
10//! - **CS**: selects the active callsign slot number (0-10) — similar to DS but for the CS
11//! slot register. The actual callsign text is read via DC.
12//! - **DC**: reads D-STAR callsign data for a given slot (1-6). This command lives in
13//! [`audio.rs`](super) because it was discovered during audio subsystem probing — the DC
14//! mnemonic is overloaded on the D75 compared to the D74.
15//! - **GW**: D-STAR gateway setting for repeater linking
16
17use crate::error::{Error, ProtocolError};
18use crate::protocol::{Command, Response};
19use crate::transport::Transport;
20use crate::types::{CallsignSlot, DstarSlot, DvGatewayMode};
21
22use super::Radio;
23
24/// D-STAR callsign slot 1 (URCALL / destination).
25const SLOT_URCALL: DstarSlot = match DstarSlot::new(1) {
26 Ok(s) => s,
27 Err(_) => unreachable!(),
28};
29
30/// D-STAR callsign slot 2 (RPT1 / access repeater).
31const SLOT_RPT1: DstarSlot = match DstarSlot::new(2) {
32 Ok(s) => s,
33 Err(_) => unreachable!(),
34};
35
36/// D-STAR callsign slot 3 (RPT2 / gateway repeater).
37const SLOT_RPT2: DstarSlot = match DstarSlot::new(3) {
38 Ok(s) => s,
39 Err(_) => unreachable!(),
40};
41
42impl<T: Transport> Radio<T> {
43 /// Get the active D-STAR callsign slot (DS read).
44 ///
45 /// # Errors
46 ///
47 /// Returns an error if the command fails or the response is unexpected.
48 pub async fn get_dstar_slot(&mut self) -> Result<DstarSlot, Error> {
49 tracing::debug!("reading D-STAR callsign slot");
50 let response = self.execute(Command::GetDstarSlot).await?;
51 match response {
52 Response::DstarSlot { slot } => Ok(slot),
53 other => Err(Error::Protocol(ProtocolError::UnexpectedResponse {
54 expected: "DstarSlot".into(),
55 actual: format!("{other:?}").into_bytes(),
56 })),
57 }
58 }
59
60 /// Get the active callsign slot number (CS bare read).
61 ///
62 /// CS returns a slot number (0-10), NOT the callsign text itself.
63 /// The actual callsign text is accessible via the CS callsign slots.
64 ///
65 /// # Errors
66 ///
67 /// Returns an error if the command fails or the response is unexpected.
68 pub async fn get_active_callsign_slot(&mut self) -> Result<CallsignSlot, Error> {
69 tracing::debug!("reading active callsign slot");
70 let response = self.execute(Command::GetActiveCallsignSlot).await?;
71 match response {
72 Response::ActiveCallsignSlot { slot } => Ok(slot),
73 other => Err(Error::Protocol(ProtocolError::UnexpectedResponse {
74 expected: "ActiveCallsignSlot".into(),
75 actual: format!("{other:?}").into_bytes(),
76 })),
77 }
78 }
79
80 /// Set the active callsign slot (CS write).
81 ///
82 /// Selects which callsign slot is active. The callsign text itself
83 /// is read via DC (D-STAR callsign) slots 1-6.
84 ///
85 /// # Errors
86 ///
87 /// Returns an error if the command fails or the response is unexpected.
88 pub async fn set_active_callsign_slot(&mut self, slot: CallsignSlot) -> Result<(), Error> {
89 tracing::info!(?slot, "setting active callsign slot");
90 let response = self
91 .execute(Command::SetActiveCallsignSlot { slot })
92 .await?;
93 match response {
94 Response::ActiveCallsignSlot { .. } => Ok(()),
95 other => Err(Error::Protocol(ProtocolError::UnexpectedResponse {
96 expected: "ActiveCallsignSlot".into(),
97 actual: format!("{other:?}").into_bytes(),
98 })),
99 }
100 }
101
102 /// Set the active D-STAR callsign slot (DS write).
103 ///
104 /// # Errors
105 ///
106 /// Returns an error if the command fails or the response is unexpected.
107 pub async fn set_dstar_slot(&mut self, slot: DstarSlot) -> Result<(), Error> {
108 tracing::info!(?slot, "setting D-STAR callsign slot");
109 let response = self.execute(Command::SetDstarSlot { slot }).await?;
110 match response {
111 Response::DstarSlot { .. } => Ok(()),
112 other => Err(Error::Protocol(ProtocolError::UnexpectedResponse {
113 expected: "DstarSlot".into(),
114 actual: format!("{other:?}").into_bytes(),
115 })),
116 }
117 }
118
119 /// Get the gateway value (GW read).
120 ///
121 /// # Errors
122 ///
123 /// Returns an error if the command fails or the response is unexpected.
124 pub async fn get_gateway(&mut self) -> Result<DvGatewayMode, Error> {
125 tracing::debug!("reading D-STAR gateway");
126 let response = self.execute(Command::GetGateway).await?;
127 match response {
128 Response::Gateway { value } => Ok(value),
129 other => Err(Error::Protocol(ProtocolError::UnexpectedResponse {
130 expected: "Gateway".into(),
131 actual: format!("{other:?}").into_bytes(),
132 })),
133 }
134 }
135
136 /// Set the gateway value (GW write).
137 ///
138 /// # Errors
139 ///
140 /// Returns an error if the command fails or the response is unexpected.
141 pub async fn set_gateway(&mut self, value: DvGatewayMode) -> Result<(), Error> {
142 tracing::info!(?value, "setting D-STAR gateway mode");
143 let response = self.execute(Command::SetGateway { value }).await?;
144 match response {
145 Response::Gateway { .. } => Ok(()),
146 other => Err(Error::Protocol(ProtocolError::UnexpectedResponse {
147 expected: "Gateway".into(),
148 actual: format!("{other:?}").into_bytes(),
149 })),
150 }
151 }
152
153 // -----------------------------------------------------------------------
154 // High-level callsign read/write helpers
155 // -----------------------------------------------------------------------
156
157 /// Read the current URCALL (destination) callsign from slot 1.
158 ///
159 /// In D-STAR, the URCALL field determines the routing behaviour of your
160 /// transmission (per User Manual Chapter 16):
161 ///
162 /// - `"CQCQCQ "` — general CQ call (local or via gateway)
163 /// - A specific callsign — callsign routing through the D-STAR network
164 /// - A reflector command — link/unlink/info/echo operations
165 ///
166 /// Returns `(callsign, suffix)` where both are as stored on the radio
167 /// (8-char callsign, up to 4-char suffix).
168 ///
169 /// # Errors
170 ///
171 /// Returns an error if the command fails or the response is unexpected.
172 pub async fn get_urcall(&mut self) -> Result<(String, String), Error> {
173 self.get_dstar_callsign(SLOT_URCALL).await
174 }
175
176 /// Set the URCALL (destination) callsign in slot 1.
177 ///
178 /// The URCALL field controls D-STAR routing behaviour. Common values:
179 ///
180 /// - CQ call: `set_urcall("CQCQCQ", "")` — general call
181 /// - Callsign routing: `set_urcall("KQ4NIT", "")` — route to a station
182 /// - Reflector link: `set_urcall("REF030", "CL")` — connect module C, link
183 /// - Reflector unlink: `set_urcall(" U", "")` — 7 spaces + U
184 ///
185 /// The callsign is space-padded to 8 characters and the suffix to 4
186 /// characters before writing to the radio.
187 ///
188 /// # Errors
189 ///
190 /// Returns an error if the callsign exceeds 8 characters, the suffix
191 /// exceeds 4 characters, or the command fails.
192 pub async fn set_urcall(&mut self, callsign: &str, suffix: &str) -> Result<(), Error> {
193 let padded_cs = pad_callsign(callsign)?;
194 let padded_sfx = pad_suffix(suffix)?;
195 self.set_dstar_callsign(SLOT_URCALL, &padded_cs, &padded_sfx)
196 .await
197 }
198
199 /// Read the RPT1 (access repeater) callsign from slot 2.
200 ///
201 /// RPT1 is the local repeater that your radio transmits to. In the
202 /// D-STAR routing model, RPT1 receives your signal over RF and either
203 /// plays it locally or forwards it to RPT2 for gateway routing.
204 ///
205 /// Returns `(callsign, suffix)`.
206 ///
207 /// # Errors
208 ///
209 /// Returns an error if the command fails or the response is unexpected.
210 pub async fn get_rpt1(&mut self) -> Result<(String, String), Error> {
211 self.get_dstar_callsign(SLOT_RPT1).await
212 }
213
214 /// Set the RPT1 (access repeater) callsign in slot 2.
215 ///
216 /// RPT1 should be set to the callsign of your local D-STAR repeater's
217 /// RF module (e.g. `"W4BFB C"` for a 2m module). The module letter
218 /// is part of the 8-character callsign field, not the suffix.
219 ///
220 /// The callsign is space-padded to 8 characters and the suffix to 4
221 /// characters before writing.
222 ///
223 /// # Errors
224 ///
225 /// Returns an error if the callsign exceeds 8 characters, the suffix
226 /// exceeds 4 characters, or the command fails.
227 pub async fn set_rpt1(&mut self, callsign: &str, suffix: &str) -> Result<(), Error> {
228 let padded_cs = pad_callsign(callsign)?;
229 let padded_sfx = pad_suffix(suffix)?;
230 self.set_dstar_callsign(SLOT_RPT1, &padded_cs, &padded_sfx)
231 .await
232 }
233
234 /// Read the RPT2 (gateway repeater) callsign from slot 3.
235 ///
236 /// RPT2 is the gateway repeater that forwards your signal to the D-STAR
237 /// network. For gateway-linked calls, RPT2 is typically the repeater's
238 /// gateway callsign (module G). For local-only calls, RPT2 can be left
239 /// blank or set to the same repeater.
240 ///
241 /// Returns `(callsign, suffix)`.
242 ///
243 /// # Errors
244 ///
245 /// Returns an error if the command fails or the response is unexpected.
246 pub async fn get_rpt2(&mut self) -> Result<(String, String), Error> {
247 self.get_dstar_callsign(SLOT_RPT2).await
248 }
249
250 /// Set the RPT2 (gateway repeater) callsign in slot 3.
251 ///
252 /// For gateway-linked operation, set RPT2 to the repeater's gateway
253 /// module (e.g. `"W4BFB G"`). For local-only simplex or repeater use,
254 /// RPT2 can be blank.
255 ///
256 /// The callsign is space-padded to 8 characters and the suffix to 4
257 /// characters before writing.
258 ///
259 /// # Errors
260 ///
261 /// Returns an error if the callsign exceeds 8 characters, the suffix
262 /// exceeds 4 characters, or the command fails.
263 pub async fn set_rpt2(&mut self, callsign: &str, suffix: &str) -> Result<(), Error> {
264 let padded_cs = pad_callsign(callsign)?;
265 let padded_sfx = pad_suffix(suffix)?;
266 self.set_dstar_callsign(SLOT_RPT2, &padded_cs, &padded_sfx)
267 .await
268 }
269
270 // -----------------------------------------------------------------------
271 // Reflector control helpers
272 // -----------------------------------------------------------------------
273
274 /// Connect to a D-STAR reflector.
275 ///
276 /// Sets the URCALL field to the reflector callsign with a link suffix,
277 /// which instructs the gateway to link to the specified reflector module.
278 /// The operator must then key up (transmit briefly) to trigger the link
279 /// command.
280 ///
281 /// # Parameters
282 ///
283 /// - `reflector`: Reflector callsign, e.g. `"REF030"`, `"XLX390"`, `"DCS006"`.
284 /// Padded to 8 characters.
285 /// - `module`: The reflector module letter, e.g. `'C'` for module C.
286 ///
287 /// # Wire encoding
288 ///
289 /// URCALL is set to the reflector callsign (8 chars) and the suffix is
290 /// set to `"{module}L "` (module letter + 'L' for link, space-padded
291 /// to 4 chars). For example, `connect_reflector("REF030", 'C')` sets
292 /// URCALL to `"REF030 "` with suffix `"CL "`.
293 ///
294 /// # Errors
295 ///
296 /// Returns an error if the reflector callsign exceeds 8 characters,
297 /// or the command fails.
298 pub async fn connect_reflector(&mut self, reflector: &str, module: char) -> Result<(), Error> {
299 let suffix = format!("{module}L");
300 self.set_urcall(reflector, &suffix).await
301 }
302
303 /// Disconnect from the current D-STAR reflector.
304 ///
305 /// Sets URCALL to the unlink command (`" U"` — 7 spaces followed
306 /// by 'U') with a blank suffix. The operator must then key up to
307 /// trigger the unlink.
308 ///
309 /// # Errors
310 ///
311 /// Returns an error if the command fails.
312 pub async fn disconnect_reflector(&mut self) -> Result<(), Error> {
313 self.set_urcall(" U", "").await
314 }
315
316 /// Set URCALL to CQCQCQ for a general CQ call.
317 ///
318 /// This configures the radio for a local or gateway CQ call. Whether
319 /// the call goes through the gateway depends on the RPT2 setting:
320 /// if RPT2 is set to the gateway module (G), the call is relayed to
321 /// all linked stations/reflectors.
322 ///
323 /// # Errors
324 ///
325 /// Returns an error if the command fails.
326 pub async fn set_cq(&mut self) -> Result<(), Error> {
327 self.set_urcall("CQCQCQ", "").await
328 }
329
330 /// Set URCALL for callsign routing (individual call).
331 ///
332 /// Routes your transmission to a specific station through the D-STAR
333 /// network. The gateway will look up the destination callsign in the
334 /// D-STAR registration database and forward your audio to the last
335 /// repeater the target station was heard on.
336 ///
337 /// RPT2 must be set to the gateway module for routing to work.
338 ///
339 /// # Errors
340 ///
341 /// Returns an error if the callsign exceeds 8 characters or the
342 /// command fails.
343 pub async fn route_to_callsign(&mut self, callsign: &str) -> Result<(), Error> {
344 self.set_urcall(callsign, "").await
345 }
346
347 // -----------------------------------------------------------------------
348 // D-STAR text messaging
349 // -----------------------------------------------------------------------
350 //
351 // The TH-D75 does not expose a CAT command for sending D-STAR slow-data
352 // text messages. The `MS` command is APRS-only (position source / message
353 // send). D-STAR slow-data messages are embedded in the DV voice stream
354 // and are not accessible through the serial CAT protocol.
355 //
356 // To send D-STAR text, use the radio's front-panel menu or a D-STAR
357 // application (BlueDV, etc.) over Bluetooth/USB data mode.
358}
359
360/// Pad a callsign to exactly 8 characters with trailing spaces.
361///
362/// # Errors
363///
364/// Returns [`ProtocolError::FieldParse`] if the callsign exceeds 8 characters.
365fn pad_callsign(callsign: &str) -> Result<String, Error> {
366 if callsign.len() > 8 {
367 return Err(Error::Protocol(ProtocolError::FieldParse {
368 command: "DC".into(),
369 field: "callsign".into(),
370 detail: format!("callsign {:?} is {} chars, max 8", callsign, callsign.len()),
371 }));
372 }
373 Ok(format!("{callsign:<8}"))
374}
375
376/// Pad a suffix to exactly 4 characters with trailing spaces.
377///
378/// # Errors
379///
380/// Returns [`ProtocolError::FieldParse`] if the suffix exceeds 4 characters.
381fn pad_suffix(suffix: &str) -> Result<String, Error> {
382 if suffix.len() > 4 {
383 return Err(Error::Protocol(ProtocolError::FieldParse {
384 command: "DC".into(),
385 field: "suffix".into(),
386 detail: format!("suffix {:?} is {} chars, max 4", suffix, suffix.len()),
387 }));
388 }
389 Ok(format!("{suffix:<4}"))
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395 use crate::transport::MockTransport;
396
397 // DC wire format: "DC slot,callsign,suffix\r"
398 // Response echoes the same format back.
399
400 #[tokio::test]
401 async fn get_urcall_reads_slot_1() {
402 let mut mock = MockTransport::new();
403 mock.expect(b"DC 1\r", b"DC 1,CQCQCQ , \r");
404
405 let mut radio = Radio::connect(mock).await.unwrap();
406 let (callsign, suffix) = radio.get_urcall().await.unwrap();
407 assert_eq!(callsign, "CQCQCQ ");
408 assert_eq!(suffix, " ");
409 }
410
411 #[tokio::test]
412 async fn set_urcall_pads_and_writes_slot_1() {
413 let mut mock = MockTransport::new();
414 mock.expect(b"DC 1,KQ4NIT , \r", b"DC 1,KQ4NIT , \r");
415
416 let mut radio = Radio::connect(mock).await.unwrap();
417 radio.set_urcall("KQ4NIT", "").await.unwrap();
418 }
419
420 #[tokio::test]
421 async fn get_rpt1_reads_slot_2() {
422 let mut mock = MockTransport::new();
423 mock.expect(b"DC 2\r", b"DC 2,W4BFB C, \r");
424
425 let mut radio = Radio::connect(mock).await.unwrap();
426 let (callsign, _suffix) = radio.get_rpt1().await.unwrap();
427 assert_eq!(callsign, "W4BFB C");
428 }
429
430 #[tokio::test]
431 async fn set_rpt1_pads_and_writes_slot_2() {
432 let mut mock = MockTransport::new();
433 mock.expect(b"DC 2,W4BFB C, \r", b"DC 2,W4BFB C, \r");
434
435 let mut radio = Radio::connect(mock).await.unwrap();
436 radio.set_rpt1("W4BFB C", "").await.unwrap();
437 }
438
439 #[tokio::test]
440 async fn get_rpt2_reads_slot_3() {
441 let mut mock = MockTransport::new();
442 mock.expect(b"DC 3\r", b"DC 3,W4BFB G, \r");
443
444 let mut radio = Radio::connect(mock).await.unwrap();
445 let (callsign, _suffix) = radio.get_rpt2().await.unwrap();
446 assert_eq!(callsign, "W4BFB G");
447 }
448
449 #[tokio::test]
450 async fn set_rpt2_pads_and_writes_slot_3() {
451 let mut mock = MockTransport::new();
452 mock.expect(b"DC 3,W4BFB G, \r", b"DC 3,W4BFB G, \r");
453
454 let mut radio = Radio::connect(mock).await.unwrap();
455 radio.set_rpt2("W4BFB G", "").await.unwrap();
456 }
457
458 #[tokio::test]
459 async fn connect_reflector_sets_urcall_with_link_suffix() {
460 let mut mock = MockTransport::new();
461 // "REF030" padded to 8 = "REF030 ", suffix "CL" padded to 4 = "CL "
462 mock.expect(b"DC 1,REF030 ,CL \r", b"DC 1,REF030 ,CL \r");
463
464 let mut radio = Radio::connect(mock).await.unwrap();
465 radio.connect_reflector("REF030", 'C').await.unwrap();
466 }
467
468 #[tokio::test]
469 async fn disconnect_reflector_sets_unlink_urcall() {
470 let mut mock = MockTransport::new();
471 // " U" is already 8 chars, suffix "" padded to " "
472 mock.expect(b"DC 1, U, \r", b"DC 1, U, \r");
473
474 let mut radio = Radio::connect(mock).await.unwrap();
475 radio.disconnect_reflector().await.unwrap();
476 }
477
478 #[tokio::test]
479 async fn set_cq_sets_cqcqcq() {
480 let mut mock = MockTransport::new();
481 mock.expect(b"DC 1,CQCQCQ , \r", b"DC 1,CQCQCQ , \r");
482
483 let mut radio = Radio::connect(mock).await.unwrap();
484 radio.set_cq().await.unwrap();
485 }
486
487 #[tokio::test]
488 async fn route_to_callsign_sets_urcall() {
489 let mut mock = MockTransport::new();
490 mock.expect(b"DC 1,KQ4NIT , \r", b"DC 1,KQ4NIT , \r");
491
492 let mut radio = Radio::connect(mock).await.unwrap();
493 radio.route_to_callsign("KQ4NIT").await.unwrap();
494 }
495
496 #[test]
497 fn pad_callsign_valid() {
498 assert_eq!(pad_callsign("CQCQCQ").unwrap(), "CQCQCQ ");
499 assert_eq!(pad_callsign("KQ4NIT").unwrap(), "KQ4NIT ");
500 assert_eq!(pad_callsign(" U").unwrap(), " U");
501 assert_eq!(pad_callsign("").unwrap(), " ");
502 }
503
504 #[test]
505 fn pad_callsign_too_long() {
506 assert!(pad_callsign("123456789").is_err());
507 }
508
509 #[test]
510 fn pad_suffix_valid() {
511 assert_eq!(pad_suffix("").unwrap(), " ");
512 assert_eq!(pad_suffix("CL").unwrap(), "CL ");
513 assert_eq!(pad_suffix("D75A").unwrap(), "D75A");
514 }
515
516 #[test]
517 fn pad_suffix_too_long() {
518 assert!(pad_suffix("12345").is_err());
519 }
520}