kenwood_thd75/radio/
kiss_session.rs

1//! KISS TNC session management for the TH-D75.
2//!
3//! When the radio enters KISS mode (via `TN 2,x`), the serial port switches
4//! from ASCII CAT commands to binary KISS framing. CAT commands cannot be
5//! used until KISS mode is exited. The [`KissSession`] type enforces this
6//! at the type level: creating one consumes the [`Radio`], and exiting
7//! returns it.
8//!
9//! # Example
10//!
11//! ```rust,no_run
12//! # use kenwood_thd75::radio::Radio;
13//! # use kenwood_thd75::transport::SerialTransport;
14//! # use kenwood_thd75::types::TncBaud;
15//! # async fn example() -> Result<(), kenwood_thd75::error::Error> {
16//! let transport = SerialTransport::open("/dev/cu.usbmodem1234", 115_200)?;
17//! let radio = Radio::connect(transport).await?;
18//!
19//! // Enter KISS mode (consumes the Radio).
20//! let mut kiss = radio.enter_kiss(TncBaud::Bps1200).await.map_err(|(_, e)| e)?;
21//!
22//! // Send and receive KISS frames.
23//! use kiss_tnc::{KissFrame, CMD_DATA};
24//! let frame = KissFrame { port: 0, command: CMD_DATA, data: vec![/* AX.25 */ ] };
25//! kiss.send_frame(&frame).await?;
26//!
27//! // Exit KISS mode (returns the Radio).
28//! let radio = kiss.exit().await?;
29//! # Ok(())
30//! # }
31//! ```
32
33use std::time::Duration;
34
35use kiss_tnc::{
36    CMD_DATA, CMD_FULL_DUPLEX, CMD_PERSISTENCE, CMD_RETURN, CMD_SET_HARDWARE, CMD_SLOT_TIME,
37    CMD_TX_DELAY, CMD_TX_TAIL, FEND, KissFrame, decode_kiss_frame, encode_kiss_frame,
38};
39
40use crate::error::{Error, ProtocolError, TransportError};
41use crate::protocol::{Codec, Command, Response};
42use crate::transport::Transport;
43use crate::types::{TncBaud, TncMode};
44
45use super::Radio;
46
47/// Default timeout for KISS receive operations (10 seconds).
48const KISS_RECEIVE_TIMEOUT: Duration = Duration::from_secs(10);
49
50/// A KISS TNC session that owns the radio transport.
51///
52/// While this session is active, the serial port speaks KISS binary framing
53/// instead of ASCII CAT commands. The [`Radio`] is consumed on entry and
54/// returned on [`exit`](Self::exit).
55///
56/// # KISS commands supported by TH-D75
57///
58/// | Command | Code | Range | Default |
59/// |---------|------|-------|---------|
60/// | Data Frame | `0x00` | AX.25 payload | — |
61/// | TX Delay | `0x01` | 0-120 (10 ms units) | Menu 508 |
62/// | Persistence | `0x02` | 0-255 | 128 |
63/// | Slot Time | `0x03` | 0-250 (10 ms units) | 10 |
64/// | TX Tail | `0x04` | 0-255 | 3 |
65/// | Full Duplex | `0x05` | 0=half, nonzero=full | 0 |
66/// | Set Hardware | `0x06` | 0/0x23=1200, 0x05/0x26=9600 | Menu 505 |
67/// | Return | `0xFF` | — | — |
68pub struct KissSession<T: Transport> {
69    /// The underlying transport (serial or Bluetooth).
70    pub(crate) transport: T,
71    /// Codec retained from the Radio for later restoration.
72    codec: Codec,
73    /// Broadcast channel retained from the Radio for later restoration.
74    notifications: tokio::sync::broadcast::Sender<Response>,
75    /// Cached timeout from the Radio.
76    timeout: Duration,
77    /// Cached `mode_a` from the Radio.
78    mode_a: Option<super::RadioMode>,
79    /// Cached `mode_b` from the Radio.
80    mode_b: Option<super::RadioMode>,
81    /// MCP speed from the Radio.
82    mcp_speed: super::programming::McpSpeed,
83    /// Timeout for receive operations.
84    receive_timeout: Duration,
85    /// Internal buffer for accumulating KISS bytes from the transport.
86    read_buf: Vec<u8>,
87}
88
89impl<T: Transport> std::fmt::Debug for KissSession<T> {
90    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91        f.debug_struct("KissSession")
92            .field("receive_timeout", &self.receive_timeout)
93            .field("read_buf_len", &self.read_buf.len())
94            .finish_non_exhaustive()
95    }
96}
97
98impl<T: Transport> Radio<T> {
99    /// Enter KISS mode, consuming this [`Radio`] and returning a [`KissSession`].
100    ///
101    /// Sends the `TN 2,x` CAT command to switch the TNC to KISS mode at the
102    /// specified baud rate. After this call, the serial port speaks KISS
103    /// binary framing. Use [`KissSession::exit`] to return to CAT mode.
104    ///
105    /// # Errors
106    ///
107    /// On failure, returns the [`Radio`] alongside the error so the caller
108    /// can continue using CAT mode. The radio is NOT consumed on error.
109    pub async fn enter_kiss(mut self, baud: TncBaud) -> Result<KissSession<T>, (Self, Error)> {
110        tracing::info!(?baud, "entering KISS mode");
111        let response = match self
112            .execute(Command::SetTncMode {
113                mode: TncMode::Kiss,
114                setting: baud,
115            })
116            .await
117        {
118            Ok(r) => r,
119            Err(e) => return Err((self, e)),
120        };
121        match response {
122            Response::TncMode { .. } => {}
123            other => {
124                return Err((
125                    self,
126                    Error::Protocol(ProtocolError::UnexpectedResponse {
127                        expected: "TncMode".into(),
128                        actual: format!("{other:?}").into_bytes(),
129                    }),
130                ));
131            }
132        }
133
134        Ok(KissSession {
135            transport: self.transport,
136            codec: self.codec,
137            notifications: self.notifications,
138            timeout: self.timeout,
139            mode_a: self.mode_a,
140            mode_b: self.mode_b,
141            mcp_speed: self.mcp_speed,
142            receive_timeout: KISS_RECEIVE_TIMEOUT,
143            read_buf: Vec::with_capacity(512),
144        })
145    }
146}
147
148impl<T: Transport> KissSession<T> {
149    /// Set the timeout for [`receive_frame`](Self::receive_frame) operations.
150    ///
151    /// Defaults to 10 seconds. Set higher for quiet channels.
152    pub const fn set_receive_timeout(&mut self, duration: Duration) {
153        self.receive_timeout = duration;
154    }
155
156    /// Write pre-encoded KISS wire bytes directly to the transport.
157    ///
158    /// Use this when you already have a fully KISS-encoded frame (e.g.,
159    /// from [`build_aprs_message`](::aprs::build_aprs_message) or
160    /// [`AprsMessenger::next_frame_to_send`](::aprs::AprsMessenger::next_frame_to_send)).
161    /// Unlike [`send_frame`](Self::send_frame) and
162    /// [`send_data`](Self::send_data), this does **not** perform any
163    /// additional encoding.
164    ///
165    /// # Errors
166    ///
167    /// Returns [`Error::Transport`] if the write fails.
168    pub async fn send_wire(&mut self, wire: &[u8]) -> Result<(), Error> {
169        tracing::debug!(wire_len = wire.len(), "KISS TX (raw wire)");
170        self.transport.write(wire).await.map_err(Error::Transport)
171    }
172
173    /// Send a KISS frame to the TNC.
174    ///
175    /// The frame is KISS-encoded (with FEND delimiters and byte stuffing)
176    /// before transmission.
177    ///
178    /// # Errors
179    ///
180    /// Returns [`Error::Transport`] if the write fails.
181    pub async fn send_frame(&mut self, frame: &KissFrame) -> Result<(), Error> {
182        let wire = encode_kiss_frame(frame);
183        tracing::debug!(
184            command = frame.command,
185            data_len = frame.data.len(),
186            wire_len = wire.len(),
187            "KISS TX"
188        );
189        self.transport.write(&wire).await.map_err(Error::Transport)
190    }
191
192    /// Receive a KISS frame from the TNC.
193    ///
194    /// Blocks until a complete KISS frame is received or the receive timeout
195    /// expires. Accumulates bytes from the transport and extracts frames
196    /// delimited by FEND bytes.
197    ///
198    /// # Errors
199    ///
200    /// Returns [`Error::Timeout`] if no complete frame arrives within the
201    /// configured receive timeout.
202    /// Returns [`Error::Transport`] if the read fails.
203    pub async fn receive_frame(&mut self) -> Result<KissFrame, Error> {
204        let timeout_dur = self.receive_timeout;
205        tokio::time::timeout(timeout_dur, self.receive_frame_inner())
206            .await
207            .map_err(|_| Error::Timeout(timeout_dur))?
208    }
209
210    /// Inner receive loop that accumulates bytes and extracts KISS frames.
211    async fn receive_frame_inner(&mut self) -> Result<KissFrame, Error> {
212        let mut tmp = [0u8; 1024];
213        loop {
214            // Try to extract a frame from the buffer first.
215            if let Some(frame) = Self::try_extract_frame(&mut self.read_buf) {
216                return Ok(frame);
217            }
218
219            // Read more bytes from the transport.
220            let n = self
221                .transport
222                .read(&mut tmp)
223                .await
224                .map_err(Error::Transport)?;
225            if n == 0 {
226                return Err(Error::Transport(TransportError::Disconnected(
227                    std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "connection closed"),
228                )));
229            }
230            self.read_buf.extend_from_slice(&tmp[..n]);
231        }
232    }
233
234    /// Try to extract a complete KISS frame from the buffer.
235    ///
236    /// A frame starts with FEND and ends with FEND. If found, the frame bytes
237    /// are removed from the buffer and decoded. Leading FENDs (inter-frame
238    /// fill) are consumed.
239    fn try_extract_frame(buf: &mut Vec<u8>) -> Option<KissFrame> {
240        // Skip leading FENDs.
241        while buf.first() == Some(&FEND) && buf.len() > 1 && buf[1] == FEND {
242            let _ = buf.remove(0);
243        }
244
245        // Need at least FEND + type + FEND.
246        if buf.len() < 3 || buf[0] != FEND {
247            return None;
248        }
249
250        // Find the closing FEND after the opening one.
251        let end_pos = buf[1..].iter().position(|&b| b == FEND)?;
252        let frame_end = end_pos + 2; // Include the closing FEND.
253
254        let frame_bytes: Vec<u8> = buf.drain(..frame_end).collect();
255        match decode_kiss_frame(&frame_bytes) {
256            Ok(frame) => {
257                tracing::debug!(
258                    command = frame.command,
259                    data_len = frame.data.len(),
260                    "KISS RX"
261                );
262                Some(frame)
263            }
264            Err(e) => {
265                tracing::warn!(?e, "discarding malformed KISS frame");
266                None
267            }
268        }
269    }
270
271    /// Set the TNC TX delay (KISS command `0x01`).
272    ///
273    /// The value is in units of 10 ms. The TH-D75 supports 0-120
274    /// (0 ms to 1200 ms). The default is configured via Menu No. 508.
275    ///
276    /// # Errors
277    ///
278    /// Returns [`Error::Transport`] if the write fails.
279    pub async fn set_tx_delay(&mut self, tens_of_ms: u8) -> Result<(), Error> {
280        tracing::debug!(tens_of_ms, "setting KISS TX delay");
281        self.send_frame(&KissFrame {
282            port: 0,
283            command: CMD_TX_DELAY,
284            data: vec![tens_of_ms],
285        })
286        .await
287    }
288
289    /// Set the CSMA persistence parameter (KISS command `0x02`).
290    ///
291    /// Range 0-255. The probability of transmitting when the channel is
292    /// clear is `(persistence + 1) / 256`. Default: 128 (50%).
293    ///
294    /// # Errors
295    ///
296    /// Returns [`Error::Transport`] if the write fails.
297    pub async fn set_persistence(&mut self, value: u8) -> Result<(), Error> {
298        tracing::debug!(value, "setting KISS persistence");
299        self.send_frame(&KissFrame {
300            port: 0,
301            command: CMD_PERSISTENCE,
302            data: vec![value],
303        })
304        .await
305    }
306
307    /// Set the CSMA slot time (KISS command `0x03`).
308    ///
309    /// The value is in units of 10 ms. Range 0-250. Default: 10 (100 ms).
310    ///
311    /// # Errors
312    ///
313    /// Returns [`Error::Transport`] if the write fails.
314    pub async fn set_slot_time(&mut self, tens_of_ms: u8) -> Result<(), Error> {
315        tracing::debug!(tens_of_ms, "setting KISS slot time");
316        self.send_frame(&KissFrame {
317            port: 0,
318            command: CMD_SLOT_TIME,
319            data: vec![tens_of_ms],
320        })
321        .await
322    }
323
324    /// Set the TX tail time (KISS command `0x04`).
325    ///
326    /// The value is in units of 10 ms. Range 0-255. Default: 3 (30 ms).
327    ///
328    /// # Errors
329    ///
330    /// Returns [`Error::Transport`] if the write fails.
331    pub async fn set_tx_tail(&mut self, tens_of_ms: u8) -> Result<(), Error> {
332        tracing::debug!(tens_of_ms, "setting KISS TX tail");
333        self.send_frame(&KissFrame {
334            port: 0,
335            command: CMD_TX_TAIL,
336            data: vec![tens_of_ms],
337        })
338        .await
339    }
340
341    /// Set full or half duplex mode (KISS command `0x05`).
342    ///
343    /// `true` = full duplex, `false` = half duplex (default).
344    ///
345    /// # Errors
346    ///
347    /// Returns [`Error::Transport`] if the write fails.
348    pub async fn set_full_duplex(&mut self, full_duplex: bool) -> Result<(), Error> {
349        tracing::debug!(full_duplex, "setting KISS duplex mode");
350        self.send_frame(&KissFrame {
351            port: 0,
352            command: CMD_FULL_DUPLEX,
353            data: vec![u8::from(full_duplex)],
354        })
355        .await
356    }
357
358    /// Switch the TNC data speed via KISS hardware command (`0x06`).
359    ///
360    /// On the TH-D75, `true` = 1200 bps (AFSK), `false` = 9600 bps (GMSK).
361    /// The hardware command values are: 0 or 0x23 for 1200, 0x05 or 0x26
362    /// for 9600.
363    ///
364    /// # Errors
365    ///
366    /// Returns [`Error::Transport`] if the write fails.
367    pub async fn set_hardware_baud(&mut self, baud_1200: bool) -> Result<(), Error> {
368        let value = if baud_1200 { 0x00 } else { 0x05 };
369        tracing::debug!(baud_1200, value, "setting KISS hardware baud");
370        self.send_frame(&KissFrame {
371            port: 0,
372            command: CMD_SET_HARDWARE,
373            data: vec![value],
374        })
375        .await
376    }
377
378    /// Send an AX.25 data frame via KISS.
379    ///
380    /// Wraps the raw AX.25 bytes in a KISS data frame (`CMD_DATA = 0x00`)
381    /// and sends it.
382    ///
383    /// # Errors
384    ///
385    /// Returns [`Error::Transport`] if the write fails.
386    pub async fn send_data(&mut self, ax25_bytes: &[u8]) -> Result<(), Error> {
387        self.send_frame(&KissFrame {
388            port: 0,
389            command: CMD_DATA,
390            data: ax25_bytes.to_vec(),
391        })
392        .await
393    }
394
395    /// Exit KISS mode by sending the `CMD_RETURN` (`0xFF`) frame.
396    ///
397    /// Returns the [`Radio`] so CAT commands can be used again.
398    ///
399    /// # Errors
400    ///
401    /// Returns [`Error::Transport`] if the write fails.
402    pub async fn exit(mut self) -> Result<Radio<T>, Error> {
403        tracing::info!("exiting KISS mode");
404        self.send_frame(&KissFrame {
405            port: 0,
406            command: CMD_RETURN,
407            data: vec![],
408        })
409        .await?;
410
411        // Small delay to let the TNC switch back to CAT mode.
412        tokio::time::sleep(Duration::from_millis(100)).await;
413
414        // Rebuild the Radio from saved state.
415        Ok(Radio {
416            transport: self.transport,
417            codec: self.codec,
418            notifications: self.notifications,
419            timeout: self.timeout,
420            mode_a: self.mode_a,
421            mode_b: self.mode_b,
422            mcp_speed: self.mcp_speed,
423            last_cmd_time: None,
424        })
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431    use crate::transport::MockTransport;
432    use crate::types::TncBaud;
433    use kiss_tnc::{CMD_DATA, FEND};
434
435    /// Helper: create a Radio with a mock that expects the TN 2,0 command.
436    async fn mock_radio_for_kiss(baud: TncBaud) -> Radio<MockTransport> {
437        let tn_cmd = format!("TN 2,{}\r", u8::from(baud));
438        let tn_resp = format!("TN 2,{}\r", u8::from(baud));
439        let mut mock = MockTransport::new();
440        mock.expect(tn_cmd.as_bytes(), tn_resp.as_bytes());
441        Radio::connect(mock).await.unwrap()
442    }
443
444    #[tokio::test]
445    async fn enter_kiss_sends_tn_command() {
446        let radio = mock_radio_for_kiss(TncBaud::Bps1200).await;
447        let session = radio.enter_kiss(TncBaud::Bps1200).await.unwrap();
448        // Session created successfully means the TN command was sent and accepted.
449        assert!(format!("{session:?}").contains("KissSession"));
450    }
451
452    #[tokio::test]
453    async fn enter_kiss_9600_baud() {
454        let radio = mock_radio_for_kiss(TncBaud::Bps9600).await;
455        let _session = radio.enter_kiss(TncBaud::Bps9600).await.unwrap();
456    }
457
458    #[tokio::test]
459    async fn send_frame_writes_kiss_encoded() {
460        let radio = mock_radio_for_kiss(TncBaud::Bps1200).await;
461        let mut session = radio.enter_kiss(TncBaud::Bps1200).await.unwrap();
462
463        // The mock transport has no more exchanges queued, so sending
464        // will fail. We add one to verify encoding.
465        session
466            .transport
467            .expect(&[FEND, 0x00, 0xAA, 0xBB, FEND], &[]);
468
469        let frame = KissFrame {
470            port: 0,
471            command: CMD_DATA,
472            data: vec![0xAA, 0xBB],
473        };
474        session.send_frame(&frame).await.unwrap();
475    }
476
477    #[tokio::test]
478    async fn send_data_wraps_in_kiss() {
479        let radio = mock_radio_for_kiss(TncBaud::Bps1200).await;
480        let mut session = radio.enter_kiss(TncBaud::Bps1200).await.unwrap();
481
482        session
483            .transport
484            .expect(&[FEND, 0x00, 0x01, 0x02, FEND], &[]);
485
486        session.send_data(&[0x01, 0x02]).await.unwrap();
487    }
488
489    #[tokio::test]
490    async fn set_tx_delay_sends_correct_frame() {
491        let radio = mock_radio_for_kiss(TncBaud::Bps1200).await;
492        let mut session = radio.enter_kiss(TncBaud::Bps1200).await.unwrap();
493
494        // TX delay of 50 (500 ms)
495        session.transport.expect(&[FEND, 0x01, 50, FEND], &[]);
496
497        session.set_tx_delay(50).await.unwrap();
498    }
499
500    #[tokio::test]
501    async fn set_persistence_sends_correct_frame() {
502        let radio = mock_radio_for_kiss(TncBaud::Bps1200).await;
503        let mut session = radio.enter_kiss(TncBaud::Bps1200).await.unwrap();
504
505        session.transport.expect(&[FEND, 0x02, 128, FEND], &[]);
506
507        session.set_persistence(128).await.unwrap();
508    }
509
510    #[tokio::test]
511    async fn set_slot_time_sends_correct_frame() {
512        let radio = mock_radio_for_kiss(TncBaud::Bps1200).await;
513        let mut session = radio.enter_kiss(TncBaud::Bps1200).await.unwrap();
514
515        session.transport.expect(&[FEND, 0x03, 10, FEND], &[]);
516
517        session.set_slot_time(10).await.unwrap();
518    }
519
520    #[tokio::test]
521    async fn set_tx_tail_sends_correct_frame() {
522        let radio = mock_radio_for_kiss(TncBaud::Bps1200).await;
523        let mut session = radio.enter_kiss(TncBaud::Bps1200).await.unwrap();
524
525        session.transport.expect(&[FEND, 0x04, 3, FEND], &[]);
526
527        session.set_tx_tail(3).await.unwrap();
528    }
529
530    #[tokio::test]
531    async fn set_full_duplex_sends_correct_frame() {
532        let radio = mock_radio_for_kiss(TncBaud::Bps1200).await;
533        let mut session = radio.enter_kiss(TncBaud::Bps1200).await.unwrap();
534
535        session.transport.expect(&[FEND, 0x05, 1, FEND], &[]);
536
537        session.set_full_duplex(true).await.unwrap();
538    }
539
540    #[tokio::test]
541    async fn set_hardware_baud_1200() {
542        let radio = mock_radio_for_kiss(TncBaud::Bps1200).await;
543        let mut session = radio.enter_kiss(TncBaud::Bps1200).await.unwrap();
544
545        session.transport.expect(&[FEND, 0x06, 0x00, FEND], &[]);
546
547        session.set_hardware_baud(true).await.unwrap();
548    }
549
550    #[tokio::test]
551    async fn set_hardware_baud_9600() {
552        let radio = mock_radio_for_kiss(TncBaud::Bps1200).await;
553        let mut session = radio.enter_kiss(TncBaud::Bps1200).await.unwrap();
554
555        session.transport.expect(&[FEND, 0x06, 0x05, FEND], &[]);
556
557        session.set_hardware_baud(false).await.unwrap();
558    }
559
560    #[tokio::test]
561    async fn exit_sends_return_and_restores_radio() {
562        let radio = mock_radio_for_kiss(TncBaud::Bps1200).await;
563        let mut session = radio.enter_kiss(TncBaud::Bps1200).await.unwrap();
564
565        // CMD_RETURN frame: C0 FF C0
566        session.transport.expect(&[FEND, 0xFF, FEND], &[]);
567
568        let _radio = session.exit().await.unwrap();
569    }
570
571    #[tokio::test]
572    async fn try_extract_frame_complete() {
573        let mut buf = vec![FEND, 0x00, 0xAA, FEND];
574        let frame = KissSession::<MockTransport>::try_extract_frame(&mut buf);
575        assert!(frame.is_some());
576        let frame = frame.unwrap();
577        assert_eq!(frame.command, CMD_DATA);
578        assert_eq!(frame.data, vec![0xAA]);
579        assert!(buf.is_empty());
580    }
581
582    #[tokio::test]
583    async fn try_extract_frame_incomplete() {
584        let mut buf = vec![FEND, 0x00, 0xAA];
585        let frame = KissSession::<MockTransport>::try_extract_frame(&mut buf);
586        assert!(frame.is_none());
587        // Buffer should be unchanged.
588        assert_eq!(buf.len(), 3);
589    }
590
591    #[tokio::test]
592    async fn try_extract_frame_leading_fends() {
593        let mut buf = vec![FEND, FEND, FEND, 0x00, 0xBB, FEND];
594        let frame = KissSession::<MockTransport>::try_extract_frame(&mut buf);
595        assert!(frame.is_some());
596        let frame = frame.unwrap();
597        assert_eq!(frame.command, CMD_DATA);
598        assert_eq!(frame.data, vec![0xBB]);
599    }
600
601    #[tokio::test]
602    async fn set_receive_timeout() {
603        let radio = mock_radio_for_kiss(TncBaud::Bps1200).await;
604        let mut session = radio.enter_kiss(TncBaud::Bps1200).await.unwrap();
605        session.set_receive_timeout(Duration::from_secs(30));
606        assert_eq!(session.receive_timeout, Duration::from_secs(30));
607    }
608}