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}