kenwood_thd75/radio/
mod.rs

1//! High-level async API for controlling a Kenwood TH-D75 transceiver.
2//!
3//! The [`Radio`] struct provides ergonomic methods for all radio operations,
4//! organized by subsystem: frequency control, channel memory, audio settings,
5//! APRS (Automatic Packet Reporting System), D-STAR (Digital Smart
6//! Technologies for Amateur Radio), GPS, scanning, and system configuration.
7//!
8//! Generic over [`Transport`], allowing use with
9//! USB serial, Bluetooth SPP, or mock transports for testing.
10
11pub mod aprs;
12pub mod audio;
13pub mod dstar;
14#[path = "freq.rs"]
15pub mod freq;
16pub mod gps;
17pub mod kiss_session;
18pub mod memory;
19pub mod mmdvm_session;
20pub mod programming;
21pub mod scan;
22pub mod service;
23pub mod system;
24pub mod tuning;
25
26use std::time::Duration;
27
28use crate::error::{Error, ProtocolError};
29use crate::protocol::{self, Codec, Command, Response, command_name};
30use crate::transport::Transport;
31use crate::types::Band;
32use crate::types::radio_params::VfoMemoryMode;
33
34/// Default timeout for command execution (5 seconds).
35const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);
36
37/// Information returned by [`Radio::identify`].
38#[derive(Debug, Clone)]
39pub struct RadioInfo {
40    /// Radio model identifier (e.g., "TH-D75").
41    pub model: String,
42}
43
44/// VFO/Memory mode state for a band.
45///
46/// Tracked internally by the [`Radio`] struct to detect mode-incompatible
47/// commands before they are sent. Values correspond to the VM command:
48/// 0 = VFO, 1 = Memory, 2 = Call, 3 = WX.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum RadioMode {
51    /// VFO (Variable Frequency Oscillator) mode — direct frequency entry.
52    Vfo,
53    /// Memory mode — operating on a stored channel.
54    Memory,
55    /// Call channel mode.
56    Call,
57    /// Weather channel mode (WX).
58    Wx,
59}
60
61impl RadioMode {
62    /// Converts a [`VfoMemoryMode`] to a `RadioMode`.
63    #[must_use]
64    pub const fn from_vfo_mode(mode: VfoMemoryMode) -> Self {
65        match mode {
66            VfoMemoryMode::Vfo => Self::Vfo,
67            VfoMemoryMode::Memory => Self::Memory,
68            VfoMemoryMode::Call => Self::Call,
69            VfoMemoryMode::Weather => Self::Wx,
70        }
71    }
72
73    /// Returns the [`VfoMemoryMode`] equivalent.
74    #[must_use]
75    pub const fn as_vfo_mode(self) -> VfoMemoryMode {
76        match self {
77            Self::Vfo => VfoMemoryMode::Vfo,
78            Self::Memory => VfoMemoryMode::Memory,
79            Self::Call => VfoMemoryMode::Call,
80            Self::Wx => VfoMemoryMode::Weather,
81        }
82    }
83}
84
85/// High-level async API for controlling a Kenwood TH-D75.
86///
87/// Generic over the transport layer — works with USB serial,
88/// Bluetooth SPP, or mock transport for testing.
89///
90/// The `Radio` struct tracks the VFO/Memory mode of each band when VM
91/// commands are sent through it, enabling mode-compatibility warnings.
92/// Use the safe tuning methods ([`tune_frequency`](Radio::tune_frequency),
93/// [`tune_channel`](Radio::tune_channel)) for automatic mode management.
94pub struct Radio<T: Transport> {
95    pub(crate) transport: T,
96    pub(crate) codec: Codec,
97    pub(crate) notifications: tokio::sync::broadcast::Sender<Response>,
98    pub(crate) timeout: Duration,
99    /// Cached mode for band A. `None` until a VM command is observed.
100    pub(crate) mode_a: Option<RadioMode>,
101    /// Cached mode for band B. `None` until a VM command is observed.
102    pub(crate) mode_b: Option<RadioMode>,
103    /// MCP programming mode transfer speed.
104    pub(crate) mcp_speed: programming::McpSpeed,
105    /// Timestamp of last command sent, for 5ms inter-command spacing.
106    /// ARFC-D75 enforces a minimum 5ms gap between commands to avoid
107    /// overwhelming the radio's command buffer.
108    last_cmd_time: Option<tokio::time::Instant>,
109}
110
111impl<T: Transport> std::fmt::Debug for Radio<T> {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        f.debug_struct("Radio")
114            .field("codec", &self.codec)
115            .field(
116                "notifications",
117                &format_args!("broadcast::Sender({})", self.notifications.receiver_count()),
118            )
119            .field("timeout", &self.timeout)
120            .field("mode_a", &self.mode_a)
121            .field("mode_b", &self.mode_b)
122            .field("mcp_speed", &self.mcp_speed)
123            .field("last_cmd_time", &self.last_cmd_time)
124            .finish_non_exhaustive()
125    }
126}
127
128impl<T: Transport> Radio<T> {
129    /// Create a new `Radio` instance over the given transport.
130    ///
131    /// # Errors
132    ///
133    /// Returns an error if the initial connection setup fails.
134    #[allow(clippy::unused_async)]
135    pub async fn connect(transport: T) -> Result<Self, Error> {
136        tracing::info!("connecting to radio");
137        let (tx, _rx) = tokio::sync::broadcast::channel(64);
138        Ok(Self {
139            transport,
140            codec: Codec::new(),
141            notifications: tx,
142            timeout: DEFAULT_TIMEOUT,
143            mode_a: None,
144            mode_b: None,
145            mcp_speed: programming::McpSpeed::default(),
146            last_cmd_time: None,
147        })
148    }
149
150    /// Connect with a TNC exit preamble for robustness.
151    ///
152    /// If the radio was left in KISS/TNC mode (e.g., by a crashed application),
153    /// normal CAT commands will fail. This method sends the same exit sequence
154    /// that Kenwood's ARFC-D75 software uses before starting CAT communication:
155    ///
156    /// 1. Two empty frames
157    /// 2. 300ms delay
158    /// 3. ETX byte (0x03)
159    /// 4. `\rTC 1\r` (TNC exit command)
160    ///
161    /// After the preamble, the radio should be in normal CAT mode regardless
162    /// of its previous state.
163    ///
164    /// # Errors
165    ///
166    /// Returns an error if the transport connection fails.
167    pub async fn connect_safe(transport: T) -> Result<Self, Error> {
168        tracing::info!("connecting with TNC exit preamble");
169        let mut radio = Self::connect(transport).await?;
170
171        // Send empty frames to wake up any stale connection.
172        let _ = radio.transport.write(b"\r").await;
173        let _ = radio.transport.write(b"\r").await;
174        tokio::time::sleep(Duration::from_millis(300)).await;
175
176        // ETX (exit KISS mode if active).
177        let _ = radio.transport.write(&[0x03]).await;
178        // TC 1 exits KISS TNC mode.
179        let _ = radio.transport.write(b"\rTC 1\r").await;
180        tokio::time::sleep(Duration::from_millis(100)).await;
181        // TN 0,0 exits MMDVM mode (returns to APRS/normal TNC).
182        let _ = radio.transport.write(b"TN 0,0\r").await;
183        tokio::time::sleep(Duration::from_millis(300)).await;
184
185        // Drain any buffered responses from the mode exit commands.
186        let mut drain_buf = [0u8; 4096];
187        let _ = tokio::time::timeout(
188            Duration::from_millis(500),
189            radio.transport.read(&mut drain_buf),
190        )
191        .await;
192
193        Ok(radio)
194    }
195
196    /// Subscribe to auto-info notifications.
197    ///
198    /// When auto-info is enabled (`set_auto_info(true)`), the radio pushes
199    /// unsolicited status updates. These are routed to all subscribers.
200    pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver<Response> {
201        self.notifications.subscribe()
202    }
203
204    /// Verify the radio identity. Sends the ID command and checks the response.
205    ///
206    /// # Errors
207    ///
208    /// Returns [`Error::Protocol`] with [`ProtocolError::UnexpectedResponse`]
209    /// if the radio does not return a `RadioId` response.
210    /// Returns [`Error::Transport`] if communication fails.
211    pub async fn identify(&mut self) -> Result<RadioInfo, Error> {
212        tracing::info!("identifying radio");
213        let response = self.execute(Command::GetRadioId).await?;
214        match response {
215            Response::RadioId { model } => {
216                tracing::info!(model = %model, "radio identified");
217                Ok(RadioInfo { model })
218            }
219            other => Err(Error::Protocol(ProtocolError::UnexpectedResponse {
220                expected: "RadioId".into(),
221                actual: format!("{other:?}").into_bytes(),
222            })),
223        }
224    }
225
226    /// Set the timeout duration for command execution.
227    ///
228    /// Defaults to 5 seconds. Commands that do not receive a response
229    /// within this duration return [`Error::Timeout`].
230    pub const fn set_timeout(&mut self, duration: Duration) {
231        self.timeout = duration;
232    }
233
234    /// Set the MCP transfer speed for programming mode operations.
235    ///
236    /// The default is [`McpSpeed::Safe`] (9600 baud throughout, ~55 s
237    /// for a full dump). Set to [`McpSpeed::Fast`] to switch the serial
238    /// port to 115200 baud after the handshake (~8 s for a full dump),
239    /// matching the fast MCP transfer mode.
240    ///
241    /// See [`McpSpeed`] for platform compatibility caveats.
242    ///
243    /// [`McpSpeed`]: programming::McpSpeed
244    /// [`McpSpeed::Safe`]: programming::McpSpeed::Safe
245    /// [`McpSpeed::Fast`]: programming::McpSpeed::Fast
246    pub const fn set_mcp_speed(&mut self, speed: programming::McpSpeed) {
247        self.mcp_speed = speed;
248    }
249
250    /// Execute a raw command and return the parsed response.
251    ///
252    /// Before sending, this method checks whether the command is compatible
253    /// with the cached band mode. If a mismatch is detected, a
254    /// `tracing::warn` is emitted but the command is **not** blocked --
255    /// advanced users may have valid reasons to send raw commands in any
256    /// state.
257    ///
258    /// After a successful response, mode state is automatically updated
259    /// when VM commands are observed.
260    ///
261    /// # Errors
262    ///
263    /// Returns [`Error::RadioError`] if the radio replies with `?`.
264    /// Returns [`Error::NotAvailable`] if the radio replies with `N`.
265    /// Returns [`Error::Timeout`] if no response arrives within the configured timeout.
266    /// Returns [`Error::Transport`] if the connection is lost or I/O fails.
267    /// Returns [`Error::Protocol`] if the response cannot be parsed.
268    pub async fn execute(&mut self, cmd: Command) -> Result<Response, Error> {
269        let cmd_name = command_name(&cmd);
270        let timeout_dur = self.timeout;
271        tracing::debug!(cmd = %cmd_name, "executing command");
272
273        // 0. Warn if the command is likely to fail in the current mode.
274        if let Some(warning) = self.check_mode_compatibility(&cmd) {
275            tracing::warn!(cmd = %cmd_name, warning, "command may fail in current mode");
276        }
277
278        // 1. Enforce 5ms minimum inter-command spacing (per ARFC-D75 RE).
279        if let Some(last) = self.last_cmd_time {
280            let elapsed = last.elapsed();
281            if elapsed < Duration::from_millis(5) {
282                tokio::time::sleep(Duration::from_millis(5).saturating_sub(elapsed)).await;
283            }
284        }
285
286        // 2. Serialize command to wire format.
287        let wire = protocol::serialize(&cmd);
288
289        // 3. Write to transport.
290        tracing::trace!(cmd = %cmd_name, wire = ?String::from_utf8_lossy(&wire).trim(), "TX");
291        self.transport
292            .write(&wire)
293            .await
294            .map_err(Error::Transport)?;
295        self.last_cmd_time = Some(tokio::time::Instant::now());
296
297        // 4. Read response bytes (loop until codec has a complete frame),
298        //    wrapped in a timeout. With AI mode enabled, unsolicited
299        //    notifications may arrive interleaved with command responses.
300        //    Match the frame's mnemonic to the command we sent; route
301        //    mismatches to the notification broadcast channel.
302        let expected_mnemonic = command_name(&cmd);
303        let result = tokio::time::timeout(timeout_dur, async {
304            let mut buf = [0u8; 1024];
305            loop {
306                let n = self
307                    .transport
308                    .read(&mut buf)
309                    .await
310                    .map_err(Error::Transport)?;
311                if n == 0 {
312                    tracing::error!(cmd = %cmd_name, "transport disconnected during read");
313                    return Err(Error::Transport(
314                        crate::error::TransportError::Disconnected(std::io::Error::new(
315                            std::io::ErrorKind::UnexpectedEof,
316                            "connection closed",
317                        )),
318                    ));
319                }
320                self.codec.feed(&buf[..n]);
321                while let Some(frame) = self.codec.next_frame() {
322                    // Frames are CR-terminated ASCII: "MNEMONIC PAYLOAD\r"
323                    // e.g. "FQ 0,0145520000\r", "BY 1,1\r", "?\r", "N\r".
324                    // Extract the 2-letter mnemonic before the space.
325                    let frame_str = String::from_utf8_lossy(&frame);
326                    let frame_mnemonic = frame_str
327                        .split_once(' ')
328                        .map_or_else(|| frame_str.trim(), |(m, _)| m);
329
330                    tracing::trace!(cmd = %cmd_name, frame = ?frame_str.trim(), "RX");
331
332                    // Error/not-available are always responses to the current command.
333                    if frame_mnemonic == "?" {
334                        return Err(Error::RadioError);
335                    }
336                    if frame_mnemonic == "N" {
337                        return Err(Error::NotAvailable);
338                    }
339
340                    let response = protocol::parse(&frame).map_err(Error::Protocol)?;
341
342                    // If this frame's mnemonic doesn't match what we sent,
343                    // it's an unsolicited AI notification — route it to
344                    // subscribers and keep waiting for our actual response.
345                    if frame_mnemonic != expected_mnemonic {
346                        tracing::debug!(
347                            expected = expected_mnemonic,
348                            got = frame_mnemonic,
349                            "unsolicited AI notification"
350                        );
351                        let _ = self.notifications.send(response);
352                        continue;
353                    }
354
355                    return Ok(response);
356                }
357            }
358        })
359        .await;
360
361        match result {
362            Ok(inner) => {
363                // 4. Track mode changes from successful VM responses.
364                self.track_mode_from_response(&cmd, &inner);
365                inner
366            }
367            Err(_elapsed) => {
368                tracing::error!(cmd = %cmd_name, timeout = ?timeout_dur, "command timed out");
369                Err(Error::Timeout(timeout_dur))
370            }
371        }
372    }
373
374    /// Returns the cached VFO/Memory mode for a band, if known.
375    ///
376    /// Mode is only tracked for Band A and Band B (the two main VFOs).
377    /// Returns `None` for other bands or until the first VM command for
378    /// that band is observed.
379    #[must_use]
380    pub const fn get_cached_mode(&self, band: Band) -> Option<RadioMode> {
381        match band {
382            Band::A => self.mode_a,
383            Band::B => self.mode_b,
384            _ => None,
385        }
386    }
387
388    /// Check if a command is likely to fail in the current cached mode.
389    ///
390    /// Returns a human-readable warning string if a mismatch is detected,
391    /// or `None` if the command is compatible (or the mode is unknown).
392    const fn check_mode_compatibility(&self, cmd: &Command) -> Option<&'static str> {
393        match cmd {
394            Command::SetFrequency { band, .. } | Command::SetFrequencyFull { band, .. } => {
395                match self.get_cached_mode(*band) {
396                    Some(RadioMode::Vfo) | None => None,
397                    Some(_) => {
398                        Some("SetFrequency requires VFO mode \u{2014} use tune_frequency() instead")
399                    }
400                }
401            }
402            Command::RecallMemoryChannel { band, .. } => match self.get_cached_mode(*band) {
403                Some(RadioMode::Memory) | None => None,
404                Some(_) => Some(
405                    "RecallMemoryChannel requires Memory mode \u{2014} use tune_channel() instead",
406                ),
407            },
408            _ => None,
409        }
410    }
411
412    /// Update cached mode state from a command/response pair.
413    fn track_mode_from_response(&mut self, cmd: &Command, response: &Result<Response, Error>) {
414        // Only track on successful VM responses.
415        if let Ok(Response::VfoMemoryMode { band, mode }) = response {
416            self.update_cached_mode(*band, *mode);
417        }
418        // Also track mode when we send a SetVfoMemoryMode command and it succeeds.
419        if let Command::SetVfoMemoryMode { band, mode } = cmd
420            && response.is_ok()
421        {
422            self.update_cached_mode(*band, *mode);
423        }
424    }
425
426    /// Update the cached mode for a band from a [`VfoMemoryMode`] value.
427    fn update_cached_mode(&mut self, band: Band, mode: VfoMemoryMode) {
428        let radio_mode = RadioMode::from_vfo_mode(mode);
429        match band {
430            Band::A => {
431                tracing::debug!(?radio_mode, "updated cached mode for band A");
432                self.mode_a = Some(radio_mode);
433            }
434            Band::B => {
435                tracing::debug!(?radio_mode, "updated cached mode for band B");
436                self.mode_b = Some(radio_mode);
437            }
438            _ => {
439                // Sub-bands don't have independent mode tracking.
440            }
441        }
442    }
443
444    /// Disconnect from the radio, consuming the `Radio` instance.
445    ///
446    /// # Errors
447    ///
448    /// Returns [`Error::Transport`] if closing the connection fails.
449    pub async fn disconnect(mut self) -> Result<(), Error> {
450        tracing::info!("disconnecting from radio");
451        self.transport.close().await.map_err(Error::Transport)
452    }
453
454    /// Write raw bytes to the underlying transport.
455    ///
456    /// Use this for protocol detection (e.g. sending MMDVM frames to
457    /// check if the radio is in gateway mode). No framing or parsing
458    /// is applied.
459    ///
460    /// # Errors
461    ///
462    /// Returns [`Error::Transport`] if the write fails.
463    pub async fn transport_write(&mut self, data: &[u8]) -> Result<(), Error> {
464        self.transport.write(data).await.map_err(Error::Transport)
465    }
466
467    /// Read raw bytes from the underlying transport.
468    ///
469    /// Use this for protocol detection. No framing or parsing is applied.
470    ///
471    /// # Errors
472    ///
473    /// Returns [`Error::Transport`] if the read fails.
474    pub async fn transport_read(&mut self, buf: &mut [u8]) -> Result<usize, Error> {
475        self.transport.read(buf).await.map_err(Error::Transport)
476    }
477
478    /// Close the underlying transport without consuming the `Radio`.
479    ///
480    /// This is used before reconnecting to ensure Bluetooth RFCOMM
481    /// resources are fully released before a new connection is opened.
482    /// The `Radio` is left in a non-functional state — only reassignment
483    /// or drop should follow.
484    ///
485    /// # Errors
486    ///
487    /// Returns [`Error::Transport`] if closing fails.
488    pub async fn close_transport(&mut self) -> Result<(), Error> {
489        tracing::info!("closing transport for reconnect");
490        self.transport.close().await.map_err(Error::Transport)
491    }
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497    use crate::transport::MockTransport;
498    use crate::types::Band;
499    use std::time::Duration;
500
501    #[tokio::test]
502    async fn radio_connect_and_identify() {
503        let mut mock = MockTransport::new();
504        mock.expect(b"ID\r", b"ID TH-D75\r");
505        let mut radio = Radio::connect(mock).await.unwrap();
506        let info = radio.identify().await.unwrap();
507        assert!(info.model.contains("TH-D75"));
508    }
509
510    #[tokio::test]
511    async fn radio_execute_raw_command() {
512        let mut mock = MockTransport::new();
513        mock.expect(b"FV\r", b"FV 1.03.000\r");
514        let mut radio = Radio::connect(mock).await.unwrap();
515        let response = radio.execute(Command::GetFirmwareVersion).await.unwrap();
516        match response {
517            Response::FirmwareVersion { version } => assert_eq!(version, "1.03.000"),
518            other => panic!("expected FirmwareVersion, got {other:?}"),
519        }
520    }
521
522    #[tokio::test]
523    async fn radio_error_response() {
524        let mut mock = MockTransport::new();
525        mock.expect(b"FQ 0\r", b"?\r");
526        let mut radio = Radio::connect(mock).await.unwrap();
527        let result = radio.execute(Command::GetFrequency { band: Band::A }).await;
528        assert!(matches!(result, Err(Error::RadioError)));
529    }
530
531    #[tokio::test]
532    async fn radio_disconnect() {
533        let mock = MockTransport::new();
534        let radio = Radio::connect(mock).await.unwrap();
535        radio.disconnect().await.unwrap();
536    }
537
538    #[tokio::test]
539    async fn subscribe_returns_receiver() {
540        let mock = MockTransport::new();
541        let radio = Radio::connect(mock).await.unwrap();
542        let _rx = radio.subscribe();
543        // Just verify it compiles and doesn't panic
544    }
545
546    #[tokio::test]
547    async fn set_auto_info_sends_command() {
548        let mut mock = MockTransport::new();
549        mock.expect(b"AI 1\r", b"AI 1\r");
550        let mut radio = Radio::connect(mock).await.unwrap();
551        radio.set_auto_info(true).await.unwrap();
552    }
553
554    #[tokio::test]
555    async fn multiple_subscribers_receive_notifications() {
556        let mock = MockTransport::new();
557        let radio = Radio::connect(mock).await.unwrap();
558        let _rx1 = radio.subscribe();
559        let _rx2 = radio.subscribe();
560        // Sending to the broadcast channel should succeed with 2 receivers
561        let sent = radio
562            .notifications
563            .send(Response::AutoInfo { enabled: true });
564        assert!(sent.is_ok());
565        assert_eq!(sent.unwrap(), 2);
566    }
567
568    #[tokio::test]
569    async fn debug_impl_works() {
570        let mock = MockTransport::new();
571        let radio = Radio::connect(mock).await.unwrap();
572        let debug_str = format!("{radio:?}");
573        assert!(debug_str.contains("Radio"));
574    }
575
576    #[tokio::test]
577    async fn radio_not_available_response() {
578        let mut mock = MockTransport::new();
579        mock.expect(b"BE\r", b"N\r");
580        let mut radio = Radio::connect(mock).await.unwrap();
581        let result = radio.execute(Command::GetBeep).await;
582        assert!(matches!(result, Err(Error::NotAvailable)));
583    }
584
585    #[tokio::test]
586    async fn set_timeout_configurable() {
587        let mock = MockTransport::new();
588        let mut radio = Radio::connect(mock).await.unwrap();
589        radio.set_timeout(Duration::from_millis(100));
590        assert_eq!(radio.timeout, Duration::from_millis(100));
591    }
592}