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}