1pub 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
34const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);
36
37#[derive(Debug, Clone)]
39pub struct RadioInfo {
40 pub model: String,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum RadioMode {
51 Vfo,
53 Memory,
55 Call,
57 Wx,
59}
60
61impl RadioMode {
62 #[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 #[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
85pub 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 pub(crate) mode_a: Option<RadioMode>,
101 pub(crate) mode_b: Option<RadioMode>,
103 pub(crate) mcp_speed: programming::McpSpeed,
105 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 #[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 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 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 let _ = radio.transport.write(&[0x03]).await;
178 let _ = radio.transport.write(b"\rTC 1\r").await;
180 tokio::time::sleep(Duration::from_millis(100)).await;
181 let _ = radio.transport.write(b"TN 0,0\r").await;
183 tokio::time::sleep(Duration::from_millis(300)).await;
184
185 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 pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver<Response> {
201 self.notifications.subscribe()
202 }
203
204 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 pub const fn set_timeout(&mut self, duration: Duration) {
231 self.timeout = duration;
232 }
233
234 pub const fn set_mcp_speed(&mut self, speed: programming::McpSpeed) {
247 self.mcp_speed = speed;
248 }
249
250 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 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 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 let wire = protocol::serialize(&cmd);
288
289 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 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 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 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 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 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 #[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 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 fn track_mode_from_response(&mut self, cmd: &Command, response: &Result<Response, Error>) {
414 if let Ok(Response::VfoMemoryMode { band, mode }) = response {
416 self.update_cached_mode(*band, *mode);
417 }
418 if let Command::SetVfoMemoryMode { band, mode } = cmd
420 && response.is_ok()
421 {
422 self.update_cached_mode(*band, *mode);
423 }
424 }
425
426 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 }
441 }
442 }
443
444 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 pub async fn transport_write(&mut self, data: &[u8]) -> Result<(), Error> {
464 self.transport.write(data).await.map_err(Error::Transport)
465 }
466
467 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 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 }
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 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}