kenwood_thd75/mmdvm/
gateway.rs

1//! Integrated D-STAR gateway client for the TH-D75.
2//!
3//! Manages the MMDVM session, tracks voice transmissions, decodes
4//! slow data messages, and maintains a last-heard list. This is the
5//! building block for D-STAR reflector clients --- it handles the
6//! radio side of the gateway while the user provides the network side.
7//!
8//! # Architecture
9//!
10//! The TH-D75 in Reflector Terminal Mode acts as an MMDVM modem.
11//! This client manages that modem interface:
12//!
13//! ```text
14//! [Radio] <--MMDVM BT/USB--> [DStarGateway] <--user code--> [Reflector UDP]
15//! ```
16//!
17//! The gateway does NOT implement reflector protocols (DExtra/DCS/DPlus)
18//! --- those are separate concerns. This client provides:
19//! - Voice frame relay (radio to user, user to radio)
20//! - D-STAR header management
21//! - Slow data text message decode/encode
22//! - Last heard tracking
23//! - Connection lifecycle
24//!
25//! # Design
26//!
27//! The [`DStarGateway`] owns an [`mmdvm::AsyncModem`] via an
28//! [`MmdvmSession`]. The [`mmdvm`] crate's async shell handles MMDVM
29//! framing, periodic `GetStatus` polling, and TX-buffer slot gating
30//! in a spawned task; the gateway consumes the [`mmdvm::Event`]
31//! stream, translates it into [`DStarEvent`]s, and forwards TX frames
32//! through the handle's `send_dstar_*` methods.
33//!
34//! Create a gateway with [`DStarGateway::start`], which enters MMDVM
35//! mode and initializes D-STAR, and tear it down with
36//! [`DStarGateway::stop`], which exits MMDVM mode and returns the
37//! [`Radio`] for other use.
38//!
39//! # Example
40//!
41//! ```no_run
42//! use kenwood_thd75::{Radio, DStarGateway, DStarGatewayConfig};
43//! use kenwood_thd75::transport::SerialTransport;
44//!
45//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
46//! let transport = SerialTransport::open("/dev/cu.usbmodem1234", 115_200)?;
47//! let radio = Radio::connect(transport).await?;
48//!
49//! let config = DStarGatewayConfig::new("N0CALL");
50//! let mut gw = DStarGateway::start(radio, config).await.map_err(|(_, e)| e)?;
51//!
52//! while let Some(event) = gw.next_event().await? {
53//!     match event {
54//!         kenwood_thd75::DStarEvent::VoiceStart(header) => {
55//!             println!("TX from {} to {}", header.my_call, header.ur_call);
56//!             // Forward header to reflector...
57//!         }
58//!         kenwood_thd75::DStarEvent::VoiceData(frame) => {
59//!             let _ = frame; // Forward AMBE + slow data to reflector...
60//!         }
61//!         kenwood_thd75::DStarEvent::VoiceEnd => {
62//!             // Send EOT to reflector...
63//!         }
64//!         kenwood_thd75::DStarEvent::TextMessage(text) => {
65//!             println!("Slow data message: {text}");
66//!         }
67//!         kenwood_thd75::DStarEvent::StationHeard(entry) => {
68//!             println!("Heard: {}", entry.callsign);
69//!         }
70//!         _ => {}
71//!     }
72//! }
73//!
74//! let _radio = gw.stop().await?;
75//! # Ok(())
76//! # }
77//! ```
78
79use std::collections::VecDeque;
80use std::time::{Duration, Instant};
81
82use dstar_gateway_core::{DStarHeader, SlowDataTextCollector, VoiceFrame};
83use mmdvm::{AsyncModem, Event};
84use mmdvm_core::{MMDVM_SET_CONFIG, ModemMode, ModemStatus};
85
86use crate::error::Error;
87use crate::radio::Radio;
88use crate::radio::mmdvm_session::{MmdvmRadioRestore, MmdvmSession};
89use crate::transport::{MmdvmTransportAdapter, Transport};
90use crate::types::TncBaud;
91use crate::types::dstar::UrCallAction;
92
93/// Default receive timeout for `next_event` polling (500 ms).
94///
95/// Gives the event loop a short ceiling so callers can drive other
96/// work between polls on a quiet channel.
97const EVENT_POLL_TIMEOUT: Duration = Duration::from_millis(500);
98
99/// Reverses the bit order within a byte (MSB ↔ LSB).
100///
101/// The TH-D75's MMDVM firmware assembles serial bytes LSB-first — bit 0
102/// of each delivered byte is the earliest-in-time bit that came off the
103/// wire. Every other MMDVM-standard D-STAR tooling (mbelib, DSD,
104/// `dstar-gateway-core`) expects the MSB-first convention where bit 7
105/// is earliest. Bit-reversing each D-STAR-voice byte at the TH-D75
106/// gateway boundary translates between the two conventions so
107/// downstream decoders see standards-compliant bytes.
108///
109/// Discovered empirically (bit-reversal experiment on a captured
110/// "chip hello" AMBE frame, 2026-04): applying this transform to
111/// captured `DStarData`
112/// payloads eliminated all spurious tone/erasure frames and dropped
113/// the mean per-frame `b0` jump from 38 to 6.6, consistent with real
114/// speech. The fix applies symmetrically on TX so what we hand to the
115/// radio for air transmission matches what the DVSI expects.
116#[inline]
117const fn bit_reverse(byte: u8) -> u8 {
118    let mut b = byte;
119    b = (b & 0xAA) >> 1 | (b & 0x55) << 1;
120    b = (b & 0xCC) >> 2 | (b & 0x33) << 2;
121    b = (b & 0xF0) >> 4 | (b & 0x0F) << 4;
122    b
123}
124
125/// Default maximum entries in the last-heard list.
126const DEFAULT_MAX_LAST_HEARD: usize = 100;
127
128/// Default initial reconnection delay.
129const DEFAULT_RECONNECT_INITIAL: Duration = Duration::from_secs(1);
130
131/// Default maximum reconnection delay.
132const DEFAULT_RECONNECT_MAX: Duration = Duration::from_secs(30);
133
134/// Timeout waiting for each ACK during the D-STAR init handshake.
135const INIT_ACK_TIMEOUT: Duration = Duration::from_secs(2);
136
137/// Default TX delay for MMDVM `SetConfig` (in 10 ms units).
138const DEFAULT_TX_DELAY: u8 = 10;
139
140/// Default RX audio level for MMDVM `SetConfig`.
141const DEFAULT_RX_LEVEL: u8 = 128;
142
143/// Default TX audio level for MMDVM `SetConfig`.
144const DEFAULT_TX_LEVEL: u8 = 128;
145
146// ---------------------------------------------------------------------------
147// Configuration
148// ---------------------------------------------------------------------------
149
150/// Configuration for a [`DStarGateway`] session.
151///
152/// Created with [`DStarGatewayConfig::new`] which provides sensible
153/// defaults for a D-STAR gateway station. All fields are public for
154/// customisation before passing to [`DStarGateway::start`].
155#[derive(Debug, Clone)]
156pub struct DStarGatewayConfig {
157    /// My callsign (up to 8 characters, space-padded internally).
158    pub callsign: String,
159    /// My suffix (up to 4 characters, space-padded internally).
160    /// Default: four spaces.
161    pub suffix: String,
162    /// TNC baud rate for MMDVM mode. Default: 9600 bps (GMSK, the
163    /// standard D-STAR data rate).
164    pub baud: TncBaud,
165    /// Maximum last-heard entries to keep. Oldest entries are evicted
166    /// when this limit is reached. Default: 100.
167    pub max_last_heard: usize,
168}
169
170impl DStarGatewayConfig {
171    /// Create a new configuration with sensible defaults.
172    ///
173    /// - Suffix: four spaces (no suffix)
174    /// - Baud: 9600 bps (GMSK, standard for D-STAR voice)
175    /// - Max last-heard: 100 entries
176    #[must_use]
177    pub fn new(callsign: &str) -> Self {
178        Self {
179            callsign: callsign.to_owned(),
180            suffix: "    ".to_owned(),
181            baud: TncBaud::Bps9600,
182            max_last_heard: DEFAULT_MAX_LAST_HEARD,
183        }
184    }
185}
186
187// ---------------------------------------------------------------------------
188// Reconnection backoff
189// ---------------------------------------------------------------------------
190
191/// Exponential backoff policy for reflector reconnection.
192///
193/// Provides a state machine that tracks reconnection attempts and
194/// computes the next delay using exponential backoff with a configurable
195/// ceiling.
196///
197/// # Usage
198///
199/// ```
200/// use kenwood_thd75::mmdvm::ReconnectPolicy;
201///
202/// let mut policy = ReconnectPolicy::default();
203/// // After first failure:
204/// let delay = policy.next_delay();
205/// // ... wait `delay`, then retry ...
206/// // On success:
207/// policy.reset();
208/// ```
209#[derive(Debug, Clone)]
210pub struct ReconnectPolicy {
211    /// Initial delay before the first retry.
212    pub initial_delay: Duration,
213    /// Maximum delay between retries.
214    pub max_delay: Duration,
215    /// Current delay (doubles after each failure).
216    current_delay: Duration,
217    /// Number of consecutive failures.
218    attempts: u32,
219}
220
221impl ReconnectPolicy {
222    /// Create a new policy with custom initial and max delays.
223    #[must_use]
224    pub const fn new(initial_delay: Duration, max_delay: Duration) -> Self {
225        Self {
226            initial_delay,
227            max_delay,
228            current_delay: initial_delay,
229            attempts: 0,
230        }
231    }
232
233    /// Get the next delay and advance the backoff state.
234    ///
235    /// The delay doubles with each call, up to `max_delay`.
236    #[must_use]
237    pub fn next_delay(&mut self) -> Duration {
238        let delay = self.current_delay;
239        self.attempts = self.attempts.saturating_add(1);
240        self.current_delay = (self.current_delay * 2).min(self.max_delay);
241        delay
242    }
243
244    /// Reset the backoff state after a successful connection.
245    pub const fn reset(&mut self) {
246        self.current_delay = self.initial_delay;
247        self.attempts = 0;
248    }
249
250    /// Number of consecutive reconnection attempts.
251    #[must_use]
252    pub const fn attempts(&self) -> u32 {
253        self.attempts
254    }
255}
256
257impl Default for ReconnectPolicy {
258    fn default() -> Self {
259        Self::new(DEFAULT_RECONNECT_INITIAL, DEFAULT_RECONNECT_MAX)
260    }
261}
262
263// ---------------------------------------------------------------------------
264// Last heard
265// ---------------------------------------------------------------------------
266
267/// Entry in the last-heard list.
268///
269/// Tracks the most recent transmission heard from each unique callsign.
270/// Updated each time a D-STAR header is received from the radio.
271#[derive(Debug, Clone)]
272pub struct LastHeardEntry {
273    /// Origin callsign (MY field), trimmed of trailing spaces.
274    pub callsign: String,
275    /// Origin suffix (MY suffix field), trimmed of trailing spaces.
276    pub suffix: String,
277    /// Destination callsign (UR field), trimmed of trailing spaces.
278    pub destination: String,
279    /// Repeater 1 callsign, trimmed of trailing spaces.
280    pub repeater1: String,
281    /// Repeater 2 callsign, trimmed of trailing spaces.
282    pub repeater2: String,
283    /// When this station was last heard.
284    pub timestamp: Instant,
285}
286
287// ---------------------------------------------------------------------------
288// Event enum
289// ---------------------------------------------------------------------------
290
291/// An event produced by [`DStarGateway::next_event`].
292///
293/// Each variant represents a distinct category of D-STAR gateway
294/// activity. The gateway translates raw MMDVM responses into these
295/// typed events so callers never need to parse wire data.
296#[derive(Debug)]
297pub enum DStarEvent {
298    /// A voice transmission started (header received from radio).
299    VoiceStart(DStarHeader),
300    /// A voice data frame received from the radio.
301    VoiceData(VoiceFrame),
302    /// Voice transmission ended cleanly (EOT received).
303    VoiceEnd,
304    /// Voice transmission lost (no clean EOT, signal lost).
305    VoiceLost,
306    /// A slow data text message was decoded from the voice stream.
307    TextMessage(String),
308    /// A station was heard (added or updated in the last-heard list).
309    StationHeard(LastHeardEntry),
310    /// A URCALL command was detected in the voice header.
311    ///
312    /// The gateway parsed the UR field and identified a special command
313    /// (echo, unlink, info, link). The caller should handle the command
314    /// (e.g. connect/disconnect reflector, start echo recording).
315    UrCallCommand(UrCallAction),
316    /// Modem status update received.
317    StatusUpdate(ModemStatus),
318}
319
320// ---------------------------------------------------------------------------
321// Gateway struct
322// ---------------------------------------------------------------------------
323
324/// Complete D-STAR gateway client for the TH-D75.
325///
326/// Manages the MMDVM session, tracks voice transmissions, decodes
327/// slow data messages, and maintains a last-heard list. This is the
328/// building block for D-STAR reflector clients --- it handles the
329/// radio side of the gateway while the user provides the network side.
330///
331/// See the [module-level documentation](self) for architecture details
332/// and a full usage example.
333pub struct DStarGateway<T: Transport + Unpin + 'static> {
334    /// The underlying MMDVM async modem.
335    modem: AsyncModem<MmdvmTransportAdapter<T>>,
336    /// Radio-state restore envelope used on [`Self::stop`].
337    restore: MmdvmRadioRestore<T>,
338    /// Gateway configuration.
339    config: DStarGatewayConfig,
340    /// Slow data decoder for the current RX stream.
341    slow_data: SlowDataTextCollector,
342    /// Frame counter for slow data decoding within a transmission.
343    slow_data_frame_index: u8,
344    /// Last-heard station list, newest first.
345    last_heard: Vec<LastHeardEntry>,
346    /// Whether a voice transmission is currently active (RX from radio).
347    rx_active: bool,
348    /// The D-STAR header for the currently active RX transmission.
349    rx_header: Option<DStarHeader>,
350    /// Buffered events to emit on the next `next_event` call.
351    pending_events: VecDeque<DStarEvent>,
352    /// Echo recording buffer (header + voice frames).
353    echo_header: Option<DStarHeader>,
354    /// Echo recorded voice frames.
355    echo_frames: Vec<VoiceFrame>,
356    /// Whether echo recording is active.
357    echo_active: bool,
358    /// Per-event poll timeout (configurable via [`Self::set_event_timeout`]).
359    event_timeout: Duration,
360}
361
362impl<T: Transport + Unpin + 'static> std::fmt::Debug for DStarGateway<T> {
363    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
364        f.debug_struct("DStarGateway")
365            .field("config", &self.config)
366            .field("rx_active", &self.rx_active)
367            .field("last_heard_count", &self.last_heard.len())
368            .finish_non_exhaustive()
369    }
370}
371
372impl<T: Transport + Unpin + 'static> DStarGateway<T> {
373    /// Start the D-STAR gateway.
374    ///
375    /// Enters MMDVM mode on the radio, initializes the modem for D-STAR
376    /// operation, and returns a ready-to-use gateway. Consumes the
377    /// [`Radio`] --- call [`stop`](Self::stop) to exit and reclaim it.
378    ///
379    /// # Errors
380    ///
381    /// On failure, returns the [`Radio`] alongside the error so the
382    /// caller can continue using CAT mode.
383    ///
384    /// # Panics
385    ///
386    /// Panics if MMDVM was entered and D-STAR init failed AND the
387    /// subsequent MMDVM exit also failed. This indicates unrecoverable
388    /// transport state; the caller's only option is to drop any
389    /// remaining handles and reconnect to the radio from scratch.
390    pub async fn start(
391        radio: Radio<T>,
392        config: DStarGatewayConfig,
393    ) -> Result<Self, (Radio<T>, Error)> {
394        let session = match radio.enter_mmdvm(config.baud).await {
395            Ok(s) => s,
396            Err((radio, e)) => return Err((radio, e)),
397        };
398
399        match Self::build_from_session(session, config).await {
400            Ok(gateway) => Ok(gateway),
401            Err((restore, modem, init_err)) => {
402                // Init failed; roll back MMDVM mode to recover the Radio.
403                match restore.exit_and_rebuild(modem).await {
404                    Ok(radio) => Err((radio, init_err)),
405                    Err(exit_err) => {
406                        tracing::error!(
407                            init_err = %init_err,
408                            exit_err = %exit_err,
409                            "MMDVM exit failed after D-STAR init failure; \
410                             radio state is unrecoverable"
411                        );
412                        // We cannot return a valid Radio to the caller.
413                        // The contract of this function requires a Radio
414                        // in the error path; this is an unrecoverable
415                        // state, so we mark it with `unreachable!` to
416                        // satisfy the clippy `panic` lint while still
417                        // failing loudly.
418                        unreachable!(
419                            "MMDVM exit failed after D-STAR init failure; \
420                             transport is in an unrecoverable state: \
421                             init_err={init_err}, exit_err={exit_err}"
422                        );
423                    }
424                }
425            }
426        }
427    }
428
429    /// Start the D-STAR gateway on a radio already in MMDVM mode.
430    ///
431    /// Use this when the radio was put into DV Gateway / Reflector
432    /// Terminal Mode via MCP write (offset `0x1CA0 = 1`). The transport
433    /// already speaks MMDVM binary — no `TN` command is sent.
434    ///
435    /// # Errors
436    ///
437    /// Returns an error if the D-STAR initialization sequence fails.
438    pub async fn start_gateway_mode(
439        radio: Radio<T>,
440        config: DStarGatewayConfig,
441    ) -> Result<Self, Error> {
442        let session = radio.into_mmdvm_session();
443        Self::build_from_session(session, config)
444            .await
445            .map_err(|(_restore, _modem, err)| err)
446    }
447
448    /// Build a gateway from an already-prepared [`MmdvmSession`].
449    ///
450    /// Runs the D-STAR init handshake (`SetConfig` + `SetMode`) and,
451    /// on success, returns the fully-initialised gateway. On failure,
452    /// returns the `(restore, modem, error)` triple so the caller can
453    /// clean up the MMDVM session before surfacing the error.
454    async fn build_from_session(
455        session: MmdvmSession<T>,
456        config: DStarGatewayConfig,
457    ) -> Result<
458        Self,
459        (
460            MmdvmRadioRestore<T>,
461            AsyncModem<MmdvmTransportAdapter<T>>,
462            Error,
463        ),
464    > {
465        let (mut modem, restore) = session.into_parts();
466
467        if let Err(e) = init_dstar(&mut modem).await {
468            return Err((restore, modem, e));
469        }
470
471        Ok(Self {
472            modem,
473            restore,
474            config,
475            slow_data: SlowDataTextCollector::new(),
476            slow_data_frame_index: 0,
477            last_heard: Vec::new(),
478            rx_active: false,
479            rx_header: None,
480            pending_events: VecDeque::new(),
481            echo_header: None,
482            echo_frames: Vec::new(),
483            echo_active: false,
484            event_timeout: EVENT_POLL_TIMEOUT,
485        })
486    }
487
488    /// Stop the gateway, exiting MMDVM mode and returning the [`Radio`].
489    ///
490    /// # Errors
491    ///
492    /// Returns an error if the MMDVM exit command fails.
493    pub async fn stop(self) -> Result<Radio<T>, Error> {
494        self.restore.exit_and_rebuild(self.modem).await
495    }
496
497    /// Process pending I/O and return the next event.
498    ///
499    /// Each call waits up to [`Self::set_event_timeout`] for a new MMDVM
500    /// event from the modem loop, translates it into a [`DStarEvent`],
501    /// and returns. Returns `Ok(None)` when no MMDVM event arrives
502    /// within the timeout.
503    ///
504    /// # Errors
505    ///
506    /// Only returns errors if the underlying transport fails fatally.
507    /// Malformed frames are swallowed by the [`mmdvm`] crate's RX loop
508    /// as debug diagnostics — propagating a decode error would kill
509    /// the whole session on a single malformed byte.
510    pub async fn next_event(&mut self) -> Result<Option<DStarEvent>, Error> {
511        // Drain buffered events first (e.g. UrCallCommand after VoiceStart).
512        if let Some(evt) = self.pending_events.pop_front() {
513            return Ok(Some(evt));
514        }
515
516        // Noise events (Status at 4 Hz, init-handshake Version/Ack/Nak,
517        // Debug frames, etc.) are swallowed by `dispatch_event` and
518        // surface as `Ok(None)`. Callers' typical drain loop is
519        // `while let Ok(Some(e)) = gw.next_event().await { ... }`,
520        // which would BREAK on the first noise event — leaving the
521        // remaining noise in the mmdvm event channel. During an
522        // active D-STAR voice stream the REPL spends most of its
523        // time in the reflector-event branch of `dstar_poll_cycle`,
524        // producing only ~one radio-drain pass per cycle; if that
525        // pass swallows a single Status and then breaks, noise
526        // accumulates faster than it's consumed. The channel fills
527        // at cap 256 after ~128 s of voice, at which point
528        // `ModemLoop::emit_event` blocks forever on `event_tx.send`,
529        // wedging command processing and deadlocking the REPL on
530        // `send_dstar_data`'s oneshot reply.
531        //
532        // Fix: loop internally past noise within the caller's time
533        // budget so `Ok(None)` means "timed out with no meaningful
534        // event" and nothing else.
535        let timeout = self.event_timeout;
536        let deadline = tokio::time::Instant::now() + timeout;
537        loop {
538            let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
539            if remaining.is_zero() {
540                return Ok(None);
541            }
542            let Ok(Some(raw)) = tokio::time::timeout(remaining, self.modem.next_event()).await
543            else {
544                // Either a timeout (outer Err) or the task shut down cleanly
545                // (inner None) — surface no event.
546                return Ok(None);
547            };
548            if let Some(evt) = self.dispatch_event(raw).await? {
549                return Ok(Some(evt));
550            }
551            // `dispatch_event` returned `Ok(None)` — noise event
552            // consumed. Keep pulling from the mmdvm channel within
553            // the same deadline so periodic Status frames don't
554            // short-circuit the caller's drain loop.
555        }
556    }
557
558    /// Dispatch a raw [`mmdvm::Event`] into a [`DStarEvent`].
559    async fn dispatch_event(&mut self, raw: Event) -> Result<Option<DStarEvent>, Error> {
560        match raw {
561            Event::DStarHeaderRx { bytes } => {
562                let header = DStarHeader::decode(&bytes);
563                self.handle_voice_start(header);
564                Ok(Some(DStarEvent::VoiceStart(header)))
565            }
566            Event::DStarDataRx { bytes } => {
567                // The TH-D75 firmware hands us each byte bit-reversed.
568                // See `bit_reverse` for the full rationale.
569                let mut ambe = [0u8; 9];
570                if let Some(src) = bytes.get(..9) {
571                    for (dst, &b) in ambe.iter_mut().zip(src.iter()) {
572                        *dst = bit_reverse(b);
573                    }
574                }
575                let mut slow_data = [0u8; 3];
576                if let Some(src) = bytes.get(9..12) {
577                    for (dst, &b) in slow_data.iter_mut().zip(src.iter()) {
578                        *dst = bit_reverse(b);
579                    }
580                }
581                let frame = VoiceFrame { ambe, slow_data };
582                self.handle_voice_data(frame);
583                Ok(Some(DStarEvent::VoiceData(frame)))
584            }
585            Event::DStarEot => self.on_eot().await,
586            Event::DStarLost => {
587                self.rx_active = false;
588                self.rx_header = None;
589                Ok(Some(DStarEvent::VoiceLost))
590            }
591            Event::TransportClosed => Err(Error::Transport(
592                crate::error::TransportError::Disconnected(std::io::Error::new(
593                    std::io::ErrorKind::UnexpectedEof,
594                    "MMDVM transport closed",
595                )),
596            )),
597            // Everything else is non-fatal noise — status updates,
598            // init-handshake artefacts, debug frames, unhandled
599            // commands, and `#[non_exhaustive]` variants the mmdvm
600            // crate may add in the future.
601            other => {
602                log_noise_event(&other);
603                Ok(None)
604            }
605        }
606    }
607
608    /// Handle a received D-STAR EOT, emitting any queued text message
609    /// and driving echo playback if the record phase was active.
610    async fn on_eot(&mut self) -> Result<Option<DStarEvent>, Error> {
611        let text_event = self.take_text_message();
612        let was_echo = self.echo_active;
613        if was_echo {
614            self.echo_active = false;
615            self.play_echo().await?;
616        }
617        self.rx_active = false;
618        self.rx_header = None;
619        if let Some(text) = text_event {
620            self.pending_events.push_back(DStarEvent::TextMessage(text));
621        }
622        Ok(Some(DStarEvent::VoiceEnd))
623    }
624
625    /// Handle a received D-STAR header (internal).
626    fn handle_voice_start(&mut self, header: DStarHeader) {
627        self.rx_active = true;
628        self.slow_data.reset();
629        self.slow_data_frame_index = 0;
630        self.rx_header = Some(header);
631
632        // Parse URCALL for special commands.
633        let ur_str = std::str::from_utf8(header.ur_call.as_bytes()).unwrap_or("");
634        let action = UrCallAction::parse(ur_str);
635        match &action {
636            UrCallAction::Cq | UrCallAction::Callsign(_) => {}
637            UrCallAction::Echo => {
638                self.echo_active = true;
639                self.echo_header = Some(header);
640                self.echo_frames.clear();
641            }
642            _ => {
643                self.pending_events
644                    .push_back(DStarEvent::UrCallCommand(action));
645            }
646        }
647
648        // Update last-heard list.
649        let entry = LastHeardEntry {
650            callsign: cs_trim(header.my_call),
651            suffix: sfx_trim(header.my_suffix),
652            destination: cs_trim(header.ur_call),
653            repeater1: cs_trim(header.rpt1),
654            repeater2: cs_trim(header.rpt2),
655            timestamp: Instant::now(),
656        };
657        self.update_last_heard(entry);
658    }
659
660    /// Handle a received D-STAR voice frame (internal).
661    fn handle_voice_data(&mut self, frame: VoiceFrame) {
662        // Feed the slow data collector. Non-zero index so the
663        // sync-frame codepath in the collector doesn't fire.
664        let idx = (self.slow_data_frame_index % 20) + 1;
665        self.slow_data.push(frame.slow_data, idx);
666        self.slow_data_frame_index = self.slow_data_frame_index.wrapping_add(1);
667
668        if self.echo_active {
669            self.echo_frames.push(frame);
670        }
671    }
672
673    /// Send a D-STAR voice header to the radio for transmission.
674    ///
675    /// Enqueues the header in the mmdvm TX queue, which is drained
676    /// when the modem reports enough D-STAR FIFO space.
677    ///
678    /// # Errors
679    ///
680    /// Returns [`Error::Transport`] if the modem loop has exited.
681    pub async fn send_header(&mut self, header: &DStarHeader) -> Result<(), Error> {
682        let encoded = header.encode();
683        self.modem
684            .send_dstar_header(encoded)
685            .await
686            .map_err(shell_err_to_thd75_err)
687    }
688
689    /// Send a D-STAR voice data frame to the radio for transmission.
690    ///
691    /// Enqueues the frame in the mmdvm TX queue. Pacing is handled
692    /// inside the mmdvm modem loop — no host-side sleep is introduced
693    /// here.
694    ///
695    /// # Errors
696    ///
697    /// Returns [`Error::Transport`] if the modem loop has exited.
698    pub async fn send_voice(&mut self, frame: &VoiceFrame) -> Result<(), Error> {
699        // Do NOT bit-reverse on TX.
700        //
701        // Originally this path mirrored the RX bit-reversal for
702        // symmetry, but the TH-D75's MMDVM firmware turns out to
703        // handle TX and RX asymmetrically — the RX path delivers
704        // bytes LSB-first (hence `dispatch_event`'s reversal to
705        // restore MSB-first spec convention), but the TX path
706        // accepts bytes in the on-wire MSB-first order directly.
707        // User-confirmed regression: adding TX reversal broke
708        // thd75-repl's D-STAR audio forwarding — the radio
709        // received the D-STAR header (text message popped up on the
710        // LCD) but couldn't decode any voice frames because the
711        // byte layout no longer matched what the DVSI chip expected.
712        // Reverting the TX path to raw passthrough restores
713        // thd75-repl audio forwarding while keeping the RX fix that
714        // made radio→sextant intelligible.
715        let mut data = [0u8; 12];
716        if let Some(dst) = data.get_mut(..9) {
717            dst.copy_from_slice(&frame.ambe);
718        }
719        if let Some(dst) = data.get_mut(9..12) {
720            dst.copy_from_slice(&frame.slow_data);
721        }
722        tracing::trace!(target: "mmdvm::hang_hunt", "gateway.send_voice: awaiting modem.send_dstar_data");
723        let r = self
724            .modem
725            .send_dstar_data(data)
726            .await
727            .map_err(shell_err_to_thd75_err);
728        tracing::trace!(target: "mmdvm::hang_hunt", "gateway.send_voice: modem.send_dstar_data returned");
729        r
730    }
731
732    /// Send a voice frame to the radio without any host-side pacing.
733    ///
734    /// In the current architecture (mmdvm owns pacing via its
735    /// buffer-gated `TxQueue` drain), this method and
736    /// [`Self::send_voice`] are functionally equivalent; both simply
737    /// enqueue the frame and let the modem loop drain when
738    /// `dstar_space` allows. The alias is retained for back-compat
739    /// with callers that historically preferred the unpaced variant.
740    ///
741    /// # Errors
742    ///
743    /// Returns [`Error::Transport`] if the modem loop has exited.
744    pub async fn send_voice_unpaced(&mut self, frame: &VoiceFrame) -> Result<(), Error> {
745        self.send_voice(frame).await
746    }
747
748    /// Send end-of-transmission to the radio.
749    ///
750    /// # Errors
751    ///
752    /// Returns [`Error::Transport`] if the modem loop has exited.
753    pub async fn send_eot(&mut self) -> Result<(), Error> {
754        self.modem
755            .send_dstar_eot()
756            .await
757            .map_err(shell_err_to_thd75_err)
758    }
759
760    /// Send a status header to the radio indicating connection state.
761    ///
762    /// When connected to a reflector, sets RPT1/RPT2 to the reflector
763    /// name + module and UR to CQCQCQ. When disconnected, sets
764    /// RPT1/RPT2 to "DIRECT".
765    ///
766    /// This updates the radio's display to show the current gateway
767    /// state, matching the behavior of `d75link` and `BlueDV`.
768    ///
769    /// # Errors
770    ///
771    /// Returns [`Error::Transport`] if the write fails.
772    pub async fn send_status_header(
773        &mut self,
774        reflector: Option<(&str, char)>,
775    ) -> Result<(), Error> {
776        use dstar_gateway_core::{Callsign, Suffix};
777
778        let rpt_bytes = reflector.map_or(*b"DIRECT  ", |(name, module)| {
779            let mut bytes = [b' '; 8];
780            let name_bytes = name.as_bytes();
781            let n = name_bytes.len().min(7);
782            if let Some(dst) = bytes.get_mut(..n)
783                && let Some(src) = name_bytes.get(..n)
784            {
785                dst.copy_from_slice(src);
786            }
787            if let Some(b) = bytes.get_mut(7) {
788                *b = u8::try_from(u32::from(module)).unwrap_or(b'?');
789            }
790            bytes
791        });
792
793        let mut my_bytes = [b' '; 8];
794        let cs = self.config.callsign.as_bytes();
795        let n = cs.len().min(8);
796        if let Some(dst) = my_bytes.get_mut(..n)
797            && let Some(src) = cs.get(..n)
798        {
799            dst.copy_from_slice(src);
800        }
801
802        let mut suffix_bytes = [b' '; 4];
803        let sfx = self.config.suffix.as_bytes();
804        let s = sfx.len().min(4);
805        if let Some(dst) = suffix_bytes.get_mut(..s)
806            && let Some(src) = sfx.get(..s)
807        {
808            dst.copy_from_slice(src);
809        }
810
811        let header = DStarHeader {
812            flag1: 0x00,
813            flag2: 0x00,
814            flag3: 0x00,
815            rpt2: Callsign::from_wire_bytes(rpt_bytes),
816            rpt1: Callsign::from_wire_bytes(rpt_bytes),
817            ur_call: Callsign::from_wire_bytes(*b"CQCQCQ  "),
818            my_call: Callsign::from_wire_bytes(my_bytes),
819            my_suffix: Suffix::from_wire_bytes(suffix_bytes),
820        };
821
822        self.send_header(&header).await
823    }
824
825    /// Set the receive timeout for `next_event` polling.
826    ///
827    /// Lower values make the event loop more responsive but increase
828    /// CPU usage. Use short timeouts (10-50ms) when actively relaying
829    /// voice from a reflector.
830    pub const fn set_event_timeout(&mut self, timeout: Duration) {
831        self.event_timeout = timeout;
832    }
833
834    /// Current receive timeout for `next_event` polling.
835    ///
836    /// Mirrors [`Self::set_event_timeout`]. Callers that temporarily
837    /// drop the timeout (e.g. during a tight event-drain loop) use
838    /// this to save and restore the prior value.
839    #[must_use]
840    pub const fn event_timeout(&self) -> Duration {
841        self.event_timeout
842    }
843
844    /// Get the last-heard list (newest first).
845    #[must_use]
846    pub fn last_heard(&self) -> &[LastHeardEntry] {
847        &self.last_heard
848    }
849
850    /// Poll the modem status.
851    ///
852    /// Requests an immediate `GetStatus` and returns the next status
853    /// event delivered by the modem loop. The mmdvm modem loop also
854    /// polls status periodically (every 250 ms), so callers rarely
855    /// need this.
856    ///
857    /// # Errors
858    ///
859    /// Returns an error if the status request fails or the modem loop
860    /// exits before delivering a status event.
861    pub async fn poll_status(&mut self) -> Result<ModemStatus, Error> {
862        self.modem
863            .request_status()
864            .await
865            .map_err(shell_err_to_thd75_err)?;
866
867        // Drain until we see a Status event or the channel closes.
868        loop {
869            let evt =
870                match tokio::time::timeout(Duration::from_secs(2), self.modem.next_event()).await {
871                    Ok(Some(e)) => e,
872                    Ok(None) => {
873                        return Err(Error::Transport(
874                            crate::error::TransportError::Disconnected(std::io::Error::new(
875                                std::io::ErrorKind::UnexpectedEof,
876                                "MMDVM modem loop exited before delivering status",
877                            )),
878                        ));
879                    }
880                    Err(_) => {
881                        return Err(Error::Timeout(Duration::from_secs(2)));
882                    }
883                };
884            if let Event::Status(status) = evt {
885                return Ok(status);
886            }
887        }
888    }
889
890    /// Check if a voice transmission is currently active (RX from radio).
891    #[must_use]
892    pub const fn is_receiving(&self) -> bool {
893        self.rx_active
894    }
895
896    /// Get the current RX header, if a voice transmission is active.
897    #[must_use]
898    pub const fn current_header(&self) -> Option<&DStarHeader> {
899        self.rx_header.as_ref()
900    }
901
902    /// Get the current configuration.
903    #[must_use]
904    pub const fn config(&self) -> &DStarGatewayConfig {
905        &self.config
906    }
907
908    // -----------------------------------------------------------------------
909    // Internal helpers
910    // -----------------------------------------------------------------------
911
912    /// Update the last-heard list with a new entry.
913    ///
914    /// If the callsign already exists, the existing entry is replaced.
915    /// If the list exceeds the configured maximum, the oldest entry is
916    /// removed.
917    fn update_last_heard(&mut self, entry: LastHeardEntry) {
918        self.last_heard.retain(|e| e.callsign != entry.callsign);
919        self.last_heard.insert(0, entry);
920        if self.last_heard.len() > self.config.max_last_heard {
921            self.last_heard.truncate(self.config.max_last_heard);
922        }
923    }
924
925    /// Play back recorded echo frames to the radio.
926    ///
927    /// Builds a modified header (`RPT2` = callsign + G, `RPT1` = callsign
928    /// + reflector module) and transmits all recorded frames.
929    async fn play_echo(&mut self) -> Result<(), Error> {
930        use dstar_gateway_core::{Callsign, Suffix};
931
932        let Some(orig_header) = self.echo_header.take() else {
933            return Ok(());
934        };
935        let frames = std::mem::take(&mut self.echo_frames);
936        if frames.is_empty() {
937            return Ok(());
938        }
939
940        let mut rpt2_bytes = [b' '; 8];
941        let cs = self.config.callsign.as_bytes();
942        let n = cs.len().min(7);
943        if let Some(dst) = rpt2_bytes.get_mut(..n)
944            && let Some(src) = cs.get(..n)
945        {
946            dst.copy_from_slice(src);
947        }
948        if let Some(b) = rpt2_bytes.get_mut(7) {
949            *b = b'G';
950        }
951
952        let mut my_bytes = [b' '; 8];
953        let m = cs.len().min(8);
954        if let Some(dst) = my_bytes.get_mut(..m)
955            && let Some(src) = cs.get(..m)
956        {
957            dst.copy_from_slice(src);
958        }
959
960        let mut suffix_bytes = [b' '; 4];
961        let sfx = self.config.suffix.as_bytes();
962        let s = sfx.len().min(4);
963        if let Some(dst) = suffix_bytes.get_mut(..s)
964            && let Some(src) = sfx.get(..s)
965        {
966            dst.copy_from_slice(src);
967        }
968
969        let echo_header = DStarHeader {
970            flag1: orig_header.flag1,
971            flag2: orig_header.flag2,
972            flag3: orig_header.flag3,
973            rpt2: Callsign::from_wire_bytes(rpt2_bytes),
974            rpt1: orig_header.rpt1,
975            ur_call: orig_header.my_call,
976            my_call: Callsign::from_wire_bytes(my_bytes),
977            my_suffix: Suffix::from_wire_bytes(suffix_bytes),
978        };
979
980        self.send_header(&echo_header).await?;
981        for frame in &frames {
982            self.send_voice(frame).await?;
983        }
984        self.send_eot().await?;
985
986        Ok(())
987    }
988
989    /// Take the decoded text message from the slow data decoder, if
990    /// complete.
991    fn take_text_message(&mut self) -> Option<String> {
992        let bytes = self.slow_data.take_message()?;
993        Some(String::from_utf8_lossy(&bytes).into_owned())
994    }
995}
996
997/// Initialise the MMDVM modem for D-STAR: send `SetConfig` with
998/// D-STAR-only flags, then `SetMode(DStar)`.
999///
1000/// Consumes events until the corresponding ACK arrives for each
1001/// command. `Version` and `Status` events delivered by the modem's
1002/// startup handshake are accepted silently.
1003async fn init_dstar<T: Transport + Unpin + 'static>(
1004    modem: &mut AsyncModem<MmdvmTransportAdapter<T>>,
1005) -> Result<(), Error> {
1006    // Send SetConfig: D-STAR-only, default levels.
1007    let config_payload = vec![
1008        0x00, // invert
1009        0x01, // mode flags: D-STAR only
1010        DEFAULT_TX_DELAY,
1011        ModemMode::DStar.as_byte(),
1012        DEFAULT_RX_LEVEL,
1013        DEFAULT_TX_LEVEL,
1014    ];
1015    modem
1016        .send_raw(MMDVM_SET_CONFIG, config_payload)
1017        .await
1018        .map_err(shell_err_to_thd75_err)?;
1019    await_ack(modem, MMDVM_SET_CONFIG).await?;
1020
1021    // Send SetMode.
1022    modem
1023        .set_mode(ModemMode::DStar)
1024        .await
1025        .map_err(shell_err_to_thd75_err)?;
1026    await_ack(modem, mmdvm_core::MMDVM_SET_MODE).await?;
1027
1028    Ok(())
1029}
1030
1031/// Wait for an ACK for the given command byte, dropping Version /
1032/// Status events that arrive in the meantime.
1033async fn await_ack<T: Transport + Unpin + 'static>(
1034    modem: &mut AsyncModem<MmdvmTransportAdapter<T>>,
1035    expected_command: u8,
1036) -> Result<(), Error> {
1037    let deadline = tokio::time::Instant::now() + INIT_ACK_TIMEOUT;
1038    loop {
1039        let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
1040        if remaining.is_zero() {
1041            return Err(Error::Timeout(INIT_ACK_TIMEOUT));
1042        }
1043        let Ok(maybe_evt) = tokio::time::timeout(remaining, modem.next_event()).await else {
1044            return Err(Error::Timeout(INIT_ACK_TIMEOUT));
1045        };
1046        let Some(evt) = maybe_evt else {
1047            return Err(Error::Transport(
1048                crate::error::TransportError::Disconnected(std::io::Error::new(
1049                    std::io::ErrorKind::UnexpectedEof,
1050                    "MMDVM modem loop exited during init",
1051                )),
1052            ));
1053        };
1054        match evt {
1055            Event::Ack { command } if command == expected_command => return Ok(()),
1056            Event::Nak { command, reason } if command == expected_command => {
1057                return Err(Error::Protocol(
1058                    crate::error::ProtocolError::UnexpectedResponse {
1059                        expected: format!("MMDVM ACK for 0x{expected_command:02X}"),
1060                        actual: format!("NAK: {reason:?}").into_bytes(),
1061                    },
1062                ));
1063            }
1064            Event::Version(_) | Event::Status(_) | Event::Ack { .. } | Event::Nak { .. } => {
1065                // Drop stray handshake events.
1066            }
1067            Event::Debug { level, text } => {
1068                tracing::trace!(level, ?text, "MMDVM debug during init");
1069            }
1070            Event::TransportClosed => {
1071                return Err(Error::Transport(
1072                    crate::error::TransportError::Disconnected(std::io::Error::new(
1073                        std::io::ErrorKind::UnexpectedEof,
1074                        "MMDVM transport closed during init",
1075                    )),
1076                ));
1077            }
1078            // Any protocol frames during init are unexpected but non-fatal.
1079            Event::DStarHeaderRx { .. }
1080            | Event::DStarDataRx { .. }
1081            | Event::DStarLost
1082            | Event::DStarEot
1083            | Event::SerialData(_)
1084            | Event::TransparentData(_)
1085            | Event::UnhandledResponse { .. } => {
1086                tracing::debug!("unexpected MMDVM event during init; ignoring");
1087            }
1088            // `mmdvm::Event` is marked `#[non_exhaustive]` — new
1089            // variants are added without a major version bump. Treat
1090            // unknown events as "keep waiting for the ACK".
1091            _ => {
1092                tracing::debug!("unrecognised MMDVM event during init; ignoring");
1093            }
1094        }
1095    }
1096}
1097
1098/// Translate an [`mmdvm::ShellError`] into a thd75 [`Error`].
1099fn shell_err_to_thd75_err(err: mmdvm::ShellError) -> Error {
1100    match err {
1101        mmdvm::ShellError::SessionClosed => {
1102            Error::Transport(crate::error::TransportError::Disconnected(
1103                std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "MMDVM session closed"),
1104            ))
1105        }
1106        mmdvm::ShellError::Core(e) => Error::Protocol(crate::error::ProtocolError::FieldParse {
1107            command: "MMDVM".to_owned(),
1108            field: "frame".to_owned(),
1109            detail: format!("{e}"),
1110        }),
1111        mmdvm::ShellError::Io(e) => Error::Transport(crate::error::TransportError::Disconnected(e)),
1112        mmdvm::ShellError::BufferFull { mode } => {
1113            Error::Protocol(crate::error::ProtocolError::UnexpectedResponse {
1114                expected: format!("MMDVM {mode:?} buffer ready"),
1115                actual: b"buffer full".to_vec(),
1116            })
1117        }
1118        mmdvm::ShellError::Nak { command, reason } => {
1119            Error::Protocol(crate::error::ProtocolError::UnexpectedResponse {
1120                expected: format!("MMDVM ACK for 0x{command:02X}"),
1121                actual: format!("NAK: {reason:?}").into_bytes(),
1122            })
1123        }
1124        // `mmdvm::ShellError` is `#[non_exhaustive]`. Surface unknown
1125        // variants as a generic transport disconnection.
1126        _ => Error::Transport(crate::error::TransportError::Disconnected(
1127            std::io::Error::other("unknown MMDVM shell error"),
1128        )),
1129    }
1130}
1131
1132/// Log a non-fatal MMDVM event (status update, init handshake
1133/// artefact, debug frame, etc.) at the appropriate tracing level so
1134/// consumers that dump trace output can see what's happening.
1135fn log_noise_event(event: &Event) {
1136    match event {
1137        Event::Status(status) => {
1138            // Buffer-slot gating happens inside mmdvm's TxQueue; no
1139            // consumer-side action needed. Log all status fields at
1140            // trace so operators can audit modem state over time —
1141            // particularly the `dstar_space` FIFO depth and the
1142            // overflow / lockout / CD bits that signal trouble.
1143            tracing::trace!(
1144                target: "kenwood_thd75::mmdvm::gateway",
1145                mode = ?status.mode,
1146                flags = format!("0x{:02X}", status.flags.bits()),
1147                tx = status.tx(),
1148                cd = status.cd(),
1149                lockout = status.lockout(),
1150                adc_overflow = status.adc_overflow(),
1151                rx_overflow = status.rx_overflow(),
1152                tx_overflow = status.tx_overflow(),
1153                dac_overflow = status.dac_overflow(),
1154                dstar_space = status.dstar_space,
1155                "MMDVM status"
1156            );
1157        }
1158        Event::Ack { command } => tracing::debug!(
1159            target: "kenwood_thd75::mmdvm::gateway",
1160            command = format!("0x{command:02X}"),
1161            "MMDVM ACK (ignored)"
1162        ),
1163        Event::Nak { command, reason } => tracing::debug!(
1164            target: "kenwood_thd75::mmdvm::gateway",
1165            command = format!("0x{command:02X}"),
1166            ?reason,
1167            "MMDVM NAK (ignored)"
1168        ),
1169        Event::Version(v) => tracing::debug!(
1170            target: "kenwood_thd75::mmdvm::gateway",
1171            protocol = v.protocol,
1172            description = %v.description,
1173            "MMDVM Version (ignored)"
1174        ),
1175        Event::Debug { level, text } => tracing::trace!(
1176            target: "kenwood_thd75::mmdvm::gateway",
1177            level = *level,
1178            text = %text,
1179            "MMDVM debug"
1180        ),
1181        Event::SerialData(data) => tracing::trace!(
1182            target: "kenwood_thd75::mmdvm::gateway",
1183            len = data.len(),
1184            "MMDVM serial data (ignored)"
1185        ),
1186        Event::TransparentData(data) => tracing::trace!(
1187            target: "kenwood_thd75::mmdvm::gateway",
1188            len = data.len(),
1189            "MMDVM transparent data (ignored)"
1190        ),
1191        Event::UnhandledResponse { command, payload } => tracing::debug!(
1192            target: "kenwood_thd75::mmdvm::gateway",
1193            command = format!("0x{command:02X}"),
1194            payload_len = payload.len(),
1195            "MMDVM unhandled response"
1196        ),
1197        // Handled variants should never reach this helper; unknown
1198        // future variants fall through silently.
1199        _ => tracing::trace!(
1200            target: "kenwood_thd75::mmdvm::gateway",
1201            "MMDVM unrecognised event"
1202        ),
1203    }
1204}
1205
1206/// Trim trailing spaces from a `Callsign` and return an owned `String`.
1207fn cs_trim(cs: dstar_gateway_core::Callsign) -> String {
1208    std::str::from_utf8(cs.as_bytes())
1209        .unwrap_or("")
1210        .trim_end()
1211        .to_owned()
1212}
1213
1214/// Trim trailing spaces from a `Suffix` and return an owned `String`.
1215fn sfx_trim(sfx: dstar_gateway_core::Suffix) -> String {
1216    std::str::from_utf8(sfx.as_bytes())
1217        .unwrap_or("")
1218        .trim_end()
1219        .to_owned()
1220}
1221
1222// ---------------------------------------------------------------------------
1223// Tests
1224// ---------------------------------------------------------------------------
1225
1226#[cfg(test)]
1227mod tests {
1228    use super::*;
1229    use crate::types::TncBaud;
1230
1231    fn test_config() -> DStarGatewayConfig {
1232        DStarGatewayConfig::new("N0CALL")
1233    }
1234
1235    // -------------------------------------------------------------------
1236    // Configuration tests
1237    // -------------------------------------------------------------------
1238
1239    #[test]
1240    fn config_defaults() {
1241        let config = DStarGatewayConfig::new("W1AW");
1242        assert_eq!(config.callsign, "W1AW");
1243        assert_eq!(config.suffix, "    ");
1244        assert_eq!(config.baud, TncBaud::Bps9600);
1245        assert_eq!(config.max_last_heard, 100);
1246    }
1247
1248    #[test]
1249    fn config_debug_formatting() {
1250        let config = test_config();
1251        let debug = format!("{config:?}");
1252        assert!(debug.contains("N0CALL"), "debug should mention callsign");
1253    }
1254
1255    // -------------------------------------------------------------------
1256    // Voice frame tests
1257    // -------------------------------------------------------------------
1258
1259    #[test]
1260    fn voice_frame_construction() {
1261        let frame = VoiceFrame {
1262            ambe: [1, 2, 3, 4, 5, 6, 7, 8, 9],
1263            slow_data: [0xA, 0xB, 0xC],
1264        };
1265        assert_eq!(frame.ambe[0], 1);
1266        assert_eq!(frame.slow_data[2], 0xC);
1267    }
1268
1269    #[test]
1270    fn voice_frame_equality() {
1271        let a = VoiceFrame {
1272            ambe: [0; 9],
1273            slow_data: [0; 3],
1274        };
1275        let b = a;
1276        assert_eq!(a, b);
1277    }
1278
1279    // -------------------------------------------------------------------
1280    // Bit-reversal tests (TH-D75 MMDVM byte-order quirk)
1281    // -------------------------------------------------------------------
1282
1283    #[test]
1284    fn bit_reverse_identities() {
1285        // Known bit-reverse cases from the D75 serial-byte convention.
1286        assert_eq!(bit_reverse(0x00), 0x00);
1287        assert_eq!(bit_reverse(0xFF), 0xFF);
1288        assert_eq!(bit_reverse(0x80), 0x01);
1289        assert_eq!(bit_reverse(0x01), 0x80);
1290        assert_eq!(bit_reverse(0xAA), 0x55);
1291        assert_eq!(bit_reverse(0x55), 0xAA);
1292    }
1293
1294    #[test]
1295    fn bit_reverse_is_involution() {
1296        // Applying bit-reversal twice must return the original byte —
1297        // guarantees RX reverse and TX reverse are mirror operations.
1298        for b in 0u8..=255 {
1299            assert_eq!(
1300                bit_reverse(bit_reverse(b)),
1301                b,
1302                "double-reverse should restore {b:#04x}"
1303            );
1304        }
1305    }
1306
1307    // -------------------------------------------------------------------
1308    // Last heard tests
1309    // -------------------------------------------------------------------
1310
1311    #[test]
1312    fn last_heard_entry_debug() {
1313        let entry = LastHeardEntry {
1314            callsign: "W1AW".to_owned(),
1315            suffix: String::new(),
1316            destination: "CQCQCQ".to_owned(),
1317            repeater1: "DIRECT".to_owned(),
1318            repeater2: "DIRECT".to_owned(),
1319            timestamp: Instant::now(),
1320        };
1321        let debug = format!("{entry:?}");
1322        assert!(debug.contains("W1AW"), "debug should mention callsign");
1323    }
1324
1325    // -------------------------------------------------------------------
1326    // Event enum tests
1327    // -------------------------------------------------------------------
1328
1329    #[test]
1330    fn event_debug_formatting() {
1331        let event = DStarEvent::VoiceEnd;
1332        let debug = format!("{event:?}");
1333        assert!(debug.contains("VoiceEnd"), "debug should mention variant");
1334    }
1335
1336    #[test]
1337    fn event_text_message_debug() {
1338        let event = DStarEvent::TextMessage("Hello D-STAR".to_owned());
1339        let debug = format!("{event:?}");
1340        assert!(debug.contains("Hello D-STAR"), "debug should mention text");
1341    }
1342
1343    // -------------------------------------------------------------------
1344    // Reconnect policy tests
1345    // -------------------------------------------------------------------
1346
1347    #[test]
1348    fn reconnect_policy_exponential_backoff() {
1349        let mut policy = ReconnectPolicy::default();
1350        let d1 = policy.next_delay();
1351        let d2 = policy.next_delay();
1352        assert_eq!(d1, DEFAULT_RECONNECT_INITIAL);
1353        assert_eq!(d2, DEFAULT_RECONNECT_INITIAL * 2);
1354    }
1355
1356    #[test]
1357    fn reconnect_policy_caps_at_max() {
1358        let mut policy = ReconnectPolicy::new(Duration::from_secs(1), Duration::from_secs(4));
1359        for _ in 0..10 {
1360            let d = policy.next_delay();
1361            assert!(d <= Duration::from_secs(4), "delay capped at max");
1362        }
1363    }
1364
1365    #[test]
1366    fn reconnect_policy_reset() {
1367        let mut policy = ReconnectPolicy::default();
1368        let _ = policy.next_delay();
1369        let _ = policy.next_delay();
1370        assert!(policy.attempts() > 0);
1371        policy.reset();
1372        assert_eq!(policy.attempts(), 0);
1373    }
1374
1375    // Shell-err translation is unit-testable without a live modem.
1376    #[test]
1377    fn shell_err_session_closed_maps_to_transport_disconnected() {
1378        let err = shell_err_to_thd75_err(mmdvm::ShellError::SessionClosed);
1379        assert!(matches!(err, Error::Transport(_)));
1380    }
1381
1382    #[test]
1383    fn shell_err_io_maps_to_transport_disconnected() {
1384        let err = shell_err_to_thd75_err(mmdvm::ShellError::Io(std::io::Error::from(
1385            std::io::ErrorKind::BrokenPipe,
1386        )));
1387        assert!(matches!(err, Error::Transport(_)));
1388    }
1389}