kenwood_thd75/radio/mmdvm_session.rs
1//! MMDVM session management for the TH-D75.
2//!
3//! When the radio enters MMDVM mode (via `TN 3,x`), the serial port switches
4//! from ASCII CAT commands to binary MMDVM framing. CAT commands cannot be
5//! used until MMDVM mode is exited. The [`MmdvmSession`] type enforces this
6//! at the type level: creating one consumes the [`Radio`], and exiting
7//! returns it.
8//!
9//! # Design notes
10//!
11//! The session holds an [`mmdvm::AsyncModem`] that owns the transport via a
12//! [`MmdvmTransportAdapter`]. All MMDVM framing, periodic status polling,
13//! TX-queue slot gating, and RX frame dispatch happen inside the
14//! `AsyncModem`'s spawned task — the session itself is just a thin
15//! lifecycle wrapper that also caches the [`Radio`]'s CAT-mode state for
16//! restoration on exit.
17//!
18//! Higher-level D-STAR operation (slow-data decode, last-heard list,
19//! URCALL parsing, echo recording, etc.) lives in
20//! [`crate::mmdvm::DStarGateway`], which owns an [`MmdvmSession`] and
21//! delegates raw frame I/O to it.
22//!
23//! # Example
24//!
25//! ```rust,no_run
26//! # use kenwood_thd75::radio::Radio;
27//! # use kenwood_thd75::transport::SerialTransport;
28//! # use kenwood_thd75::types::TncBaud;
29//! # async fn example() -> Result<(), kenwood_thd75::error::Error> {
30//! let transport = SerialTransport::open("/dev/cu.usbmodem1234", 115_200)?;
31//! let radio = Radio::connect(transport).await?;
32//!
33//! // Enter MMDVM mode (consumes the Radio).
34//! let session = radio.enter_mmdvm(TncBaud::Bps9600).await.map_err(|(_, e)| e)?;
35//!
36//! // ... use session.modem_mut() for raw MMDVM operations, or build a
37//! // DStarGateway on top of it ...
38//!
39//! // Exit MMDVM mode (returns the Radio).
40//! let radio = session.exit().await?;
41//! # Ok(())
42//! # }
43//! ```
44
45use std::time::Duration;
46
47use mmdvm::AsyncModem;
48
49use crate::error::{Error, ProtocolError};
50use crate::protocol::{Codec, Command, Response};
51use crate::transport::{MmdvmTransportAdapter, Transport};
52use crate::types::{TncBaud, TncMode};
53
54use super::Radio;
55
56/// Wait time after the `TN 0,0` exit command before rebuilding the
57/// `Radio`. Matches the pre-refactor delay so the TNC has time to
58/// switch back to CAT mode.
59const EXIT_SWITCH_DELAY: Duration = Duration::from_millis(100);
60
61/// Cached Radio state that persists across an MMDVM session so the
62/// `Radio` can be rebuilt on [`MmdvmSession::exit`].
63struct RadioState {
64 codec: Codec,
65 notifications: tokio::sync::broadcast::Sender<Response>,
66 timeout: Duration,
67 mode_a: Option<super::RadioMode>,
68 mode_b: Option<super::RadioMode>,
69 mcp_speed: super::programming::McpSpeed,
70}
71
72/// An MMDVM session that owns the radio transport via an
73/// [`mmdvm::AsyncModem`].
74///
75/// While this session is active, the transport speaks the MMDVM binary
76/// framing protocol and all I/O is funneled through the spawned
77/// modem-loop task. CAT commands are unavailable until
78/// [`MmdvmSession::exit`] is called.
79///
80/// The session is consumed on entry (via [`Radio::enter_mmdvm`]) and
81/// returned on exit.
82pub struct MmdvmSession<T: Transport + Unpin + 'static> {
83 /// Async MMDVM modem driving the transport.
84 modem: AsyncModem<MmdvmTransportAdapter<T>>,
85 /// Radio state cached for restoration on exit.
86 radio_state: RadioState,
87}
88
89impl<T: Transport + Unpin + 'static> std::fmt::Debug for MmdvmSession<T> {
90 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91 f.debug_struct("MmdvmSession").finish_non_exhaustive()
92 }
93}
94
95impl<T: Transport + Unpin + 'static> Radio<T> {
96 /// Wrap this [`Radio`] as an [`MmdvmSession`] without sending any commands.
97 ///
98 /// Use this when the radio is already in MMDVM mode (e.g. after
99 /// enabling DV Gateway / Reflector Terminal Mode via MCP write to
100 /// offset `0x1CA0`). The transport is assumed to already speak
101 /// MMDVM binary framing.
102 #[must_use]
103 pub fn into_mmdvm_session(self) -> MmdvmSession<T> {
104 tracing::info!("wrapping transport as MMDVM session (radio already in gateway mode)");
105 let adapter = MmdvmTransportAdapter::new(self.transport);
106 let modem = AsyncModem::spawn(adapter);
107 MmdvmSession {
108 modem,
109 radio_state: RadioState {
110 codec: self.codec,
111 notifications: self.notifications,
112 timeout: self.timeout,
113 mode_a: self.mode_a,
114 mode_b: self.mode_b,
115 mcp_speed: self.mcp_speed,
116 },
117 }
118 }
119
120 /// Enter MMDVM mode, consuming this [`Radio`] and returning an [`MmdvmSession`].
121 ///
122 /// Sends the `TN 3,x` CAT command to switch the TNC to MMDVM mode at the
123 /// specified baud rate. After this call, the serial port speaks MMDVM
124 /// binary framing. Use [`MmdvmSession::exit`] to return to CAT mode.
125 ///
126 /// # Errors
127 ///
128 /// On failure, returns the [`Radio`] alongside the error so the caller
129 /// can continue using CAT mode. The radio is NOT consumed on error.
130 pub async fn enter_mmdvm(mut self, baud: TncBaud) -> Result<MmdvmSession<T>, (Self, Error)> {
131 tracing::info!(?baud, "entering MMDVM mode");
132 let response = match self
133 .execute(Command::SetTncMode {
134 mode: TncMode::Mmdvm,
135 setting: baud,
136 })
137 .await
138 {
139 Ok(r) => r,
140 Err(e) => return Err((self, e)),
141 };
142 match response {
143 Response::TncMode { .. } => {}
144 other => {
145 return Err((
146 self,
147 Error::Protocol(ProtocolError::UnexpectedResponse {
148 expected: "TncMode".into(),
149 actual: format!("{other:?}").into_bytes(),
150 }),
151 ));
152 }
153 }
154
155 Ok(self.into_mmdvm_session())
156 }
157}
158
159impl<T: Transport + Unpin + 'static> MmdvmSession<T> {
160 /// Mutable access to the underlying [`mmdvm::AsyncModem`].
161 ///
162 /// Consumers that need low-level MMDVM control (custom status polls,
163 /// mode changes, raw frame send) work with the handle directly.
164 /// Higher-level D-STAR orchestration (headers, voice frames, EOT)
165 /// is wrapped by [`crate::mmdvm::DStarGateway`].
166 pub const fn modem_mut(&mut self) -> &mut AsyncModem<MmdvmTransportAdapter<T>> {
167 &mut self.modem
168 }
169
170 /// Consume the session and return its [`mmdvm::AsyncModem`].
171 ///
172 /// Used by [`crate::mmdvm::DStarGateway`] to keep long-lived ownership
173 /// of the modem while tracking D-STAR-specific state separately.
174 /// Returns the associated Radio restore state alongside the modem
175 /// so the caller can rebuild the [`Radio`] after shutdown.
176 pub(crate) fn into_parts(self) -> (AsyncModem<MmdvmTransportAdapter<T>>, MmdvmRadioRestore<T>) {
177 (
178 self.modem,
179 MmdvmRadioRestore {
180 state: self.radio_state,
181 _phantom: std::marker::PhantomData,
182 },
183 )
184 }
185
186 /// Exit MMDVM mode and return the [`Radio`].
187 ///
188 /// Shuts down the [`mmdvm::AsyncModem`], recovering the transport,
189 /// sends `TN 0,0` on the raw transport to return the radio's TNC to
190 /// normal APRS mode, then rebuilds the `Radio` from saved state.
191 ///
192 /// # Errors
193 ///
194 /// Returns [`Error::Transport`] if the `TN 0,0` write fails, or
195 /// translates [`mmdvm::ShellError`] into [`Error::Transport`] /
196 /// [`Error::Protocol`] as appropriate.
197 pub async fn exit(self) -> Result<Radio<T>, Error> {
198 tracing::info!("exiting MMDVM mode");
199
200 let (modem, restore) = self.into_parts();
201 restore.exit_and_rebuild(modem).await
202 }
203}
204
205/// Radio restore state carried alongside the [`mmdvm::AsyncModem`] during
206/// MMDVM operation. Keeps the `Radio`'s CAT-mode codec, notifications,
207/// timeouts, and VFO/memory cache alive so they can be restored on exit.
208///
209/// This type is crate-internal — it only escapes [`MmdvmSession::into_parts`]
210/// so [`crate::mmdvm::DStarGateway`] can reconstruct the `Radio` after
211/// `AsyncModem::shutdown`.
212pub(crate) struct MmdvmRadioRestore<T: Transport + Unpin + 'static> {
213 state: RadioState,
214 _phantom: std::marker::PhantomData<fn() -> T>,
215}
216
217impl<T: Transport + Unpin + 'static> std::fmt::Debug for MmdvmRadioRestore<T> {
218 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219 f.debug_struct("MmdvmRadioRestore").finish_non_exhaustive()
220 }
221}
222
223impl<T: Transport + Unpin + 'static> MmdvmRadioRestore<T> {
224 /// Shut down the modem, send `TN 0,0`, and rebuild the [`Radio`].
225 pub(crate) async fn exit_and_rebuild(
226 self,
227 modem: AsyncModem<MmdvmTransportAdapter<T>>,
228 ) -> Result<Radio<T>, Error> {
229 // Shutdown returns the MmdvmTransportAdapter holding our T.
230 let adapter = modem.shutdown().await.map_err(shell_err_to_thd75_err)?;
231
232 // Pull the inner T out of the adapter.
233 let mut inner = adapter
234 .into_inner()
235 .await
236 .map_err(|e| Error::Transport(crate::error::TransportError::Disconnected(e)))?;
237
238 // Send TN 0,0 on the raw transport to switch the TNC back to
239 // APRS mode. The adapter is dropped; we speak ASCII CAT on T
240 // directly now.
241 inner.write(b"TN 0,0\r").await.map_err(Error::Transport)?;
242
243 // Small delay to let the TNC switch back to CAT mode.
244 tokio::time::sleep(EXIT_SWITCH_DELAY).await;
245
246 Ok(Radio {
247 transport: inner,
248 codec: self.state.codec,
249 notifications: self.state.notifications,
250 timeout: self.state.timeout,
251 mode_a: self.state.mode_a,
252 mode_b: self.state.mode_b,
253 mcp_speed: self.state.mcp_speed,
254 last_cmd_time: None,
255 })
256 }
257}
258
259/// Translate an [`mmdvm::ShellError`] into a thd75 [`Error`].
260fn shell_err_to_thd75_err(err: mmdvm::ShellError) -> Error {
261 match err {
262 mmdvm::ShellError::SessionClosed => Error::Protocol(ProtocolError::UnexpectedResponse {
263 expected: "MMDVM session active".into(),
264 actual: b"session closed".to_vec(),
265 }),
266 mmdvm::ShellError::Core(e) => Error::Protocol(ProtocolError::FieldParse {
267 command: "MMDVM".to_owned(),
268 field: "frame".to_owned(),
269 detail: format!("{e}"),
270 }),
271 mmdvm::ShellError::Io(e) => Error::Transport(crate::error::TransportError::Disconnected(e)),
272 mmdvm::ShellError::BufferFull { mode } => {
273 Error::Protocol(ProtocolError::UnexpectedResponse {
274 expected: format!("MMDVM {mode:?} buffer ready"),
275 actual: b"buffer full".to_vec(),
276 })
277 }
278 mmdvm::ShellError::Nak { command, reason } => {
279 Error::Protocol(ProtocolError::UnexpectedResponse {
280 expected: format!("MMDVM ACK for 0x{command:02X}"),
281 actual: format!("NAK: {reason:?}").into_bytes(),
282 })
283 }
284 // `mmdvm::ShellError` is `#[non_exhaustive]`. Surface unknown
285 // variants as a generic transport disconnection.
286 _ => Error::Transport(crate::error::TransportError::Disconnected(
287 std::io::Error::other("unknown MMDVM shell error"),
288 )),
289 }
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295 use crate::transport::MockTransport;
296 use crate::types::TncBaud;
297
298 type TestResult = Result<(), Box<dyn std::error::Error>>;
299
300 /// Helper: create a Radio with a mock that expects the TN 3,x command.
301 async fn mock_radio_for_mmdvm(baud: TncBaud) -> Result<Radio<MockTransport>, Error> {
302 let tn_cmd = format!("TN 3,{}\r", u8::from(baud));
303 let tn_resp = format!("TN 3,{}\r", u8::from(baud));
304 let mut mock = MockTransport::new();
305 mock.expect(tn_cmd.as_bytes(), tn_resp.as_bytes());
306 Radio::connect(mock).await
307 }
308
309 #[tokio::test]
310 async fn enter_mmdvm_sends_tn_command() -> TestResult {
311 // `enter_mmdvm` constructs an `MmdvmTransportAdapter`, which
312 // spawns its pump task via `tokio::task::spawn_local`. Run
313 // inside a `LocalSet` so the spawn succeeds.
314 tokio::task::LocalSet::new()
315 .run_until(async {
316 let radio = mock_radio_for_mmdvm(TncBaud::Bps1200).await?;
317 let session = radio
318 .enter_mmdvm(TncBaud::Bps1200)
319 .await
320 .map_err(|(_, e)| e)?;
321 assert!(format!("{session:?}").contains("MmdvmSession"));
322 Ok(())
323 })
324 .await
325 }
326
327 #[tokio::test]
328 async fn enter_mmdvm_9600_baud() -> TestResult {
329 tokio::task::LocalSet::new()
330 .run_until(async {
331 let radio = mock_radio_for_mmdvm(TncBaud::Bps9600).await?;
332 let _session = radio
333 .enter_mmdvm(TncBaud::Bps9600)
334 .await
335 .map_err(|(_, e)| e)?;
336 Ok(())
337 })
338 .await
339 }
340}