kenwood_thd75/transport/
serial.rs

1//! Serial port transport for USB CDC ACM and Bluetooth SPP connections.
2//!
3//! The MAIN MPU (IC2005, OMAP-L138) communicates with the PC via USB
4//! Type-C (J1) using a Mentor Graphics MUSB USB 2.0 OTG controller
5//! with `MatrixQuest` CDC ACM stack. VID `0x2166` (JVCKENWOOD), PID
6//! `0x9023`. The USB interface presents as CDC ACM (class 0x02,
7//! subclass 0x02, protocol 0x01 V.25ter) with 3 endpoints: interrupt
8//! IN, bulk OUT, bulk IN. USB D+/D- run at Full Speed (12 Mbps).
9//!
10//! USB uses 115200 baud (CDC ACM ignores line coding — per the Kenwood
11//! Operating Tips §5.13, "configuring the baud rate is unnecessary,
12//! selecting randomly will suffice" since it's a virtual COM port).
13//! USB also provides audio output (48 kHz, 16-bit, monaural — per §5.13.2).
14//!
15//! Bluetooth SPP runs through BT/GPS IC2044 → level-shift IC2046 →
16//! MAIN MPU UART2. Requires 9600 baud with RTS/CTS hardware flow
17//! control. The D75 supports Bluetooth 3.0 Class 2 with HSP + SPP
18//! profiles only (no BLE, no HFP). Per §5.12, "configuration of the
19//! baud rate is not necessary" for BT serial either, but we set 9600
20//! explicitly for compatibility.
21//!
22//! The same VID/PID (2166:9023) is used in both normal operation and
23//! firmware update mode (PTT+1 at power-on), though update mode uses
24//! the bootloader's simpler USB implementation rather than `MatrixQuest`.
25//!
26//! [`open`](SerialTransport::open) auto-detects BT ports and applies the
27//! correct settings.
28
29use tokio::io::{AsyncReadExt, AsyncWriteExt};
30use tokio_serial::{FlowControl, SerialPort, SerialStream};
31
32use crate::error::TransportError;
33
34use super::Transport;
35
36/// Baud rate for Bluetooth SPP connections.
37const BT_BAUD: u32 = 9600;
38
39/// Serial port transport for USB CDC ACM and Bluetooth SPP connections.
40///
41/// Port naming by platform:
42/// - Linux: `/dev/ttyACM*` (USB), `/dev/rfcomm*` (BT)
43/// - macOS: `/dev/cu.usbmodem*` (USB), `/dev/cu.TH-D75` (BT)
44/// - Windows: `COM*` for both
45#[derive(Debug)]
46pub struct SerialTransport {
47    port: SerialStream,
48}
49
50impl SerialTransport {
51    /// Default baud rate for USB CDC ACM.
52    pub const DEFAULT_BAUD: u32 = 115_200;
53
54    /// USB Vendor ID (VID) for JVCKENWOOD Corporation.
55    pub const USB_VID: u16 = 0x2166;
56
57    /// USB Product ID (PID) for the TH-D75 transceiver.
58    pub const USB_PID: u16 = 0x9023;
59
60    /// Returns `true` if the port path looks like a Bluetooth SPP device.
61    #[must_use]
62    pub fn is_bluetooth_port(path: &str) -> bool {
63        let lower = path.to_lowercase();
64        lower.contains("th-d75")
65            || lower.contains("rfcomm")
66            || (lower.contains("bluetooth") && !lower.contains("incoming"))
67    }
68
69    /// Open a serial port by path.
70    ///
71    /// Bluetooth SPP ports are auto-detected by name and configured with
72    /// 9600 baud and RTS/CTS flow control. USB ports use the provided
73    /// baud rate with no flow control.
74    ///
75    /// # Errors
76    ///
77    /// Returns [`TransportError::Open`] if the port cannot be opened.
78    pub fn open(path: &str, baud: u32) -> Result<Self, TransportError> {
79        let is_bt = Self::is_bluetooth_port(path);
80        let actual_baud = if is_bt { BT_BAUD } else { baud };
81        let flow = if is_bt {
82            FlowControl::Hardware
83        } else {
84            FlowControl::None
85        };
86
87        tracing::info!(
88            path = %path,
89            baud = actual_baud,
90            bluetooth = is_bt,
91            flow_control = ?flow,
92            "opening serial port"
93        );
94
95        let builder = tokio_serial::new(path, actual_baud).flow_control(flow);
96        let port = SerialStream::open(&builder).map_err(|e| TransportError::Open {
97            path: path.to_owned(),
98            source: e.into(),
99        })?;
100        tracing::info!(path = %path, "serial port opened successfully");
101        Ok(Self { port })
102    }
103
104    /// Discover TH-D75 radios connected via USB.
105    ///
106    /// Filters available serial ports by VID:PID `2166:9023`.
107    ///
108    /// # Errors
109    ///
110    /// Returns [`TransportError::Open`] if port enumeration fails.
111    pub fn discover_usb() -> Result<Vec<tokio_serial::SerialPortInfo>, TransportError> {
112        tracing::debug!(
113            vid = %format_args!("0x{:04X}", Self::USB_VID),
114            pid = %format_args!("0x{:04X}", Self::USB_PID),
115            "scanning for TH-D75 USB devices"
116        );
117        let ports = tokio_serial::available_ports().map_err(|e| TransportError::Open {
118            path: "<enumeration>".to_owned(),
119            source: e.into(),
120        })?;
121
122        let matching: Vec<_> = ports
123            .into_iter()
124            .filter(|p| {
125                matches!(
126                    &p.port_type,
127                    tokio_serial::SerialPortType::UsbPort(info)
128                        if info.vid == Self::USB_VID && info.pid == Self::USB_PID
129                )
130            })
131            .collect();
132
133        tracing::info!(count = matching.len(), "discovered TH-D75 USB devices");
134        Ok(matching)
135    }
136
137    /// Discover TH-D75 radios available via Bluetooth SPP.
138    ///
139    /// Looks for serial ports matching known BT naming patterns.
140    ///
141    /// # Errors
142    ///
143    /// Returns [`TransportError::Open`] if port enumeration fails.
144    pub fn discover_bluetooth() -> Result<Vec<tokio_serial::SerialPortInfo>, TransportError> {
145        tracing::debug!("scanning for TH-D75 Bluetooth SPP devices");
146        let ports = tokio_serial::available_ports().map_err(|e| TransportError::Open {
147            path: "<enumeration>".to_owned(),
148            source: e.into(),
149        })?;
150
151        let matching: Vec<_> = ports
152            .into_iter()
153            .filter(|p| Self::is_bluetooth_port(&p.port_name))
154            .collect();
155
156        tracing::info!(
157            count = matching.len(),
158            "discovered TH-D75 Bluetooth devices"
159        );
160        Ok(matching)
161    }
162}
163
164impl Transport for SerialTransport {
165    fn set_baud_rate(&mut self, baud: u32) -> Result<(), TransportError> {
166        tracing::info!(baud, "changing serial baud rate");
167        self.port
168            .set_baud_rate(baud)
169            .map_err(|e| TransportError::Open {
170                path: String::new(),
171                source: std::io::Error::other(e.to_string()),
172            })
173    }
174
175    async fn write(&mut self, data: &[u8]) -> Result<(), TransportError> {
176        tracing::debug!(bytes = data.len(), "writing to transport");
177        tracing::trace!(raw = ?data, "raw bytes sent");
178        self.port
179            .write_all(data)
180            .await
181            .map_err(TransportError::Write)?;
182        self.port.flush().await.map_err(TransportError::Write)?;
183        Ok(())
184    }
185
186    async fn read(&mut self, buf: &mut [u8]) -> Result<usize, TransportError> {
187        let n = self.port.read(buf).await.map_err(TransportError::Read)?;
188        tracing::debug!(bytes = n, "read from transport");
189        tracing::trace!(raw = ?&buf[..n], "raw bytes received");
190        Ok(n)
191    }
192
193    async fn close(&mut self) -> Result<(), TransportError> {
194        tracing::info!("closing serial transport");
195        self.port
196            .shutdown()
197            .await
198            .map_err(TransportError::Disconnected)?;
199        Ok(())
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn constants_match_re_data() {
209        assert_eq!(SerialTransport::USB_VID, 0x2166);
210        assert_eq!(SerialTransport::USB_PID, 0x9023);
211        assert_eq!(SerialTransport::DEFAULT_BAUD, 115_200);
212    }
213
214    #[test]
215    fn bluetooth_port_detection() {
216        assert!(SerialTransport::is_bluetooth_port("/dev/cu.TH-D75"));
217        assert!(SerialTransport::is_bluetooth_port("/dev/tty.TH-D75"));
218        assert!(SerialTransport::is_bluetooth_port("/dev/rfcomm0"));
219        assert!(!SerialTransport::is_bluetooth_port("/dev/cu.usbmodem1101"));
220        assert!(!SerialTransport::is_bluetooth_port(
221            "/dev/cu.Bluetooth-Incoming-Port"
222        ));
223        assert!(!SerialTransport::is_bluetooth_port("COM3"));
224    }
225}