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}