kenwood_thd75/types/
dstar.rs

1//! D-STAR (Digital Smart Technologies for Amateur Radio) configuration types.
2//!
3//! D-STAR is a digital voice and data protocol for amateur radio developed
4//! by JARL (Japan Amateur Radio League). The TH-D75 supports DV (Digital
5//! Voice) mode with features including reflector linking, callsign routing,
6//! gateway access, and DR (D-STAR Repeater) mode for simplified operation.
7//!
8//! # Callsign registration (per Operating Tips §4.1.1)
9//!
10//! Before using D-STAR gateway/reflector functions, the operator's callsign
11//! must be registered at <https://regist.dstargateway.org>.
12//!
13//! # My Callsign (per Operating Tips §4.1.2)
14//!
15//! A valid MY callsign is required for any DV or DR mode transmission.
16//! Menu No. 610 allows registration of up to 6 callsigns; the active
17//! one is selected for transmission.
18//!
19//! # DR mode (per Operating Tips §4.2)
20//!
21//! DR (Digital Repeater) mode simplifies D-STAR operation by combining
22//! repeater and destination selection into a single interface. The operator
23//! selects an access repeater from the repeater list and a destination
24//! (another repeater, callsign, or reflector), and the radio automatically
25//! configures RPT1, RPT2, and UR callsign fields.
26//!
27//! # Reflector Terminal Mode (per Operating Tips §4.4)
28//!
29//! The TH-D75 supports Reflector Terminal Mode, which connects to D-STAR
30//! reflectors without a physical hotspot. On Android, use `BlueDV` Connect
31//! via Bluetooth; on Windows, use `BlueDV` via Bluetooth or USB.
32//!
33//! # Simultaneous reception
34//!
35//! The TH-D75 can receive D-STAR DV signals on both Band A and Band B
36//! simultaneously.
37//!
38//! # Repeater and Hotspot lists (per Operating Tips §4.3)
39//!
40//! The radio stores up to 1500 repeater list entries and 30 hotspot list
41//! entries. These are managed via the MCP-D75 software or SD card import.
42//!
43//! These types model every D-STAR setting accessible through the TH-D75's
44//! menu system (Chapter 16 of the user manual) and MCP programming memory
45//! (pages 0x02A1+ in the memory map, plus system settings at 0x03F0).
46
47use crate::error::ValidationError;
48
49// ---------------------------------------------------------------------------
50// Top-level D-STAR configuration
51// ---------------------------------------------------------------------------
52
53/// Complete D-STAR configuration for the TH-D75.
54///
55/// Covers all settings from the radio's D-STAR menu tree, including
56/// callsign configuration, repeater routing, digital squelch, auto-reply,
57/// and data options. Derived from the capability gap analysis features 40-62.
58#[allow(clippy::struct_excessive_bools)]
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct DstarConfig {
61    /// MY callsign (up to 8 characters). This is the station's own
62    /// callsign transmitted in every D-STAR frame header.
63    pub my_callsign: DstarCallsign,
64    /// MY callsign extension / suffix (up to 4 characters).
65    /// Used for additional station identification (e.g. "/P" for portable).
66    pub my_suffix: DstarSuffix,
67    /// UR callsign (8 characters). The destination callsign.
68    /// "CQCQCQ" for general CQ calls, a specific callsign for
69    /// callsign routing, or a reflector command.
70    pub ur_call: DstarCallsign,
71    /// RPT1 callsign (8 characters). The access repeater (local).
72    pub rpt1: DstarCallsign,
73    /// RPT2 callsign (8 characters). The gateway/linked repeater.
74    pub rpt2: DstarCallsign,
75    /// DV/DR mode selection.
76    pub dv_mode: DvDrMode,
77    /// Digital squelch configuration.
78    pub digital_squelch: DigitalSquelch,
79    /// Auto-reply configuration for D-STAR messages.
80    pub auto_reply: DstarAutoReply,
81    /// RX AFC (Automatic Frequency Control) for DV mode.
82    /// Compensates for frequency drift on received signals.
83    pub rx_afc: bool,
84    /// Automatically detect FM signals when in DV mode.
85    /// Allows receiving analog FM on a DV-mode channel.
86    pub fm_auto_detect_on_dv: bool,
87    /// Output D-STAR data frames to the serial port.
88    pub data_frame_output: bool,
89    /// Include GPS position information in DV frame headers.
90    pub gps_info_in_frame: bool,
91    /// Standby beep when a DV transmission ends.
92    pub standby_beep: bool,
93    /// Enable break-in call (interrupt an ongoing QSO).
94    pub break_call: bool,
95    /// Voice announcement of received callsigns.
96    pub callsign_announce: bool,
97    /// EMR (Emergency) volume level (0-9, 0 = off).
98    pub emr_volume: EmrVolume,
99    /// Gateway mode for DV operation.
100    pub gateway_mode: GatewayMode,
101    /// Enable fast data mode (high-speed DV data).
102    pub fast_data: bool,
103}
104
105impl Default for DstarConfig {
106    fn default() -> Self {
107        Self {
108            my_callsign: DstarCallsign::default(),
109            my_suffix: DstarSuffix::default(),
110            ur_call: DstarCallsign::cqcqcq(),
111            rpt1: DstarCallsign::default(),
112            rpt2: DstarCallsign::default(),
113            dv_mode: DvDrMode::Dv,
114            digital_squelch: DigitalSquelch::default(),
115            auto_reply: DstarAutoReply::default(),
116            rx_afc: false,
117            fm_auto_detect_on_dv: false,
118            data_frame_output: false,
119            gps_info_in_frame: false,
120            standby_beep: true,
121            break_call: false,
122            callsign_announce: false,
123            emr_volume: EmrVolume::default(),
124            gateway_mode: GatewayMode::Auto,
125            fast_data: false,
126        }
127    }
128}
129
130// ---------------------------------------------------------------------------
131// Callsign types
132// ---------------------------------------------------------------------------
133
134/// D-STAR callsign (up to 8 characters, space-padded).
135///
136/// D-STAR callsigns are always exactly 8 characters in the protocol,
137/// right-padded with spaces. This type stores the trimmed form and
138/// provides padding methods for wire encoding.
139#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
140pub struct DstarCallsign(String);
141
142impl DstarCallsign {
143    /// Maximum length of a D-STAR callsign.
144    pub const MAX_LEN: usize = 8;
145
146    /// Wire-format width (always 8 characters, space-padded).
147    pub const WIRE_LEN: usize = 8;
148
149    /// Creates a new D-STAR callsign.
150    ///
151    /// # Errors
152    ///
153    /// Returns `None` if the callsign exceeds 8 characters.
154    #[must_use]
155    pub fn new(callsign: &str) -> Option<Self> {
156        let trimmed = callsign.trim_end();
157        if trimmed.len() <= Self::MAX_LEN {
158            Some(Self(trimmed.to_owned()))
159        } else {
160            None
161        }
162    }
163
164    /// Creates the broadcast CQ callsign ("CQCQCQ").
165    #[must_use]
166    pub fn cqcqcq() -> Self {
167        Self("CQCQCQ".to_owned())
168    }
169
170    /// Returns the callsign as a trimmed string slice.
171    #[must_use]
172    pub fn as_str(&self) -> &str {
173        &self.0
174    }
175
176    /// Returns the callsign as an 8-byte space-padded ASCII array
177    /// for wire encoding.
178    #[must_use]
179    pub fn to_wire_bytes(&self) -> [u8; 8] {
180        let mut buf = [b' '; 8];
181        let src = self.0.as_bytes();
182        let len = src.len().min(8);
183        buf[..len].copy_from_slice(&src[..len]);
184        buf
185    }
186
187    /// Decodes a D-STAR callsign from an 8-byte space-padded array.
188    #[must_use]
189    pub fn from_wire_bytes(bytes: &[u8; 8]) -> Self {
190        let s = std::str::from_utf8(bytes).unwrap_or("").trim_end();
191        Self(s.to_owned())
192    }
193
194    /// Returns `true` if this is the broadcast CQ callsign.
195    #[must_use]
196    pub fn is_cqcqcq(&self) -> bool {
197        self.0 == "CQCQCQ"
198    }
199
200    /// Returns `true` if the callsign is empty.
201    #[must_use]
202    pub const fn is_empty(&self) -> bool {
203        self.0.is_empty()
204    }
205}
206
207/// D-STAR MY callsign suffix (up to 4 characters).
208///
209/// The suffix is appended to the MY callsign in the D-STAR frame header
210/// as additional identification (e.g. "/P" for portable, "/M" for mobile).
211#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
212pub struct DstarSuffix(String);
213
214impl DstarSuffix {
215    /// Maximum length of a D-STAR callsign suffix.
216    pub const MAX_LEN: usize = 4;
217
218    /// Creates a new D-STAR callsign suffix.
219    ///
220    /// # Errors
221    ///
222    /// Returns `None` if the suffix exceeds 4 characters.
223    #[must_use]
224    pub fn new(suffix: &str) -> Option<Self> {
225        if suffix.len() <= Self::MAX_LEN {
226            Some(Self(suffix.to_owned()))
227        } else {
228            None
229        }
230    }
231
232    /// Returns the suffix as a string slice.
233    #[must_use]
234    pub fn as_str(&self) -> &str {
235        &self.0
236    }
237}
238
239// ---------------------------------------------------------------------------
240// Mode selection
241// ---------------------------------------------------------------------------
242
243/// DV/DR mode selection.
244///
245/// DV mode provides manual repeater configuration; DR mode simplifies
246/// operation with automatic repeater selection from the repeater list.
247///
248/// Per Operating Tips §4.2: DR (Digital Repeater) mode combines repeater
249/// selection and destination selection. The radio configures RPT1, RPT2,
250/// and UR callsign fields automatically based on the user's choices from
251/// the repeater list and destination list.
252#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
253pub enum DvDrMode {
254    /// DV (Digital Voice) mode -- manual repeater configuration.
255    Dv,
256    /// DR (D-STAR Repeater) mode -- automatic repeater selection.
257    Dr,
258}
259
260// ---------------------------------------------------------------------------
261// Digital squelch
262// ---------------------------------------------------------------------------
263
264/// Validated D-STAR digital squelch code (0-99).
265///
266/// The TH-D75 uses a numeric code in the range 0-99 for digital code
267/// squelch on D-STAR. Only frames with a matching code open the audio.
268#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
269pub struct DigitalSquelchCode(u8);
270
271impl DigitalSquelchCode {
272    /// Creates a new digital squelch code.
273    ///
274    /// # Errors
275    ///
276    /// Returns [`ValidationError::DigitalSquelchCodeOutOfRange`] if `code > 99`.
277    pub const fn new(code: u8) -> Result<Self, ValidationError> {
278        if code <= 99 {
279            Ok(Self(code))
280        } else {
281            Err(ValidationError::DigitalSquelchCodeOutOfRange(code))
282        }
283    }
284
285    /// Returns the raw code value (0-99).
286    #[must_use]
287    pub const fn value(self) -> u8 {
288        self.0
289    }
290}
291
292impl std::fmt::Display for DigitalSquelchCode {
293    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
294        write!(f, "{:02}", self.0)
295    }
296}
297
298/// Digital squelch configuration.
299///
300/// Digital squelch opens the audio only when the received D-STAR frame
301/// header matches specific criteria: a digital code (0-99) or a specific
302/// callsign.
303#[derive(Debug, Clone, PartialEq, Eq, Hash)]
304pub struct DigitalSquelch {
305    /// Digital squelch mode.
306    pub squelch_type: DigitalSquelchType,
307    /// Digital code for code squelch mode (0-99).
308    pub code: DigitalSquelchCode,
309    /// Callsign for callsign squelch mode.
310    pub callsign: DstarCallsign,
311}
312
313impl Default for DigitalSquelch {
314    fn default() -> Self {
315        Self {
316            squelch_type: DigitalSquelchType::Off,
317            code: DigitalSquelchCode::default(),
318            callsign: DstarCallsign::default(),
319        }
320    }
321}
322
323/// Digital squelch type.
324#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
325pub enum DigitalSquelchType {
326    /// Digital squelch disabled -- receive all DV signals.
327    Off,
328    /// Code squelch -- open audio only when the digital code matches.
329    CodeSquelch,
330    /// Callsign squelch -- open audio only when the source callsign matches.
331    CallsignSquelch,
332}
333
334impl TryFrom<u8> for DigitalSquelchType {
335    type Error = ValidationError;
336
337    fn try_from(value: u8) -> Result<Self, Self::Error> {
338        match value {
339            0 => Ok(Self::Off),
340            1 => Ok(Self::CodeSquelch),
341            2 => Ok(Self::CallsignSquelch),
342            _ => Err(ValidationError::SettingOutOfRange {
343                name: "digital squelch type",
344                value,
345                detail: "must be 0-2",
346            }),
347        }
348    }
349}
350
351// ---------------------------------------------------------------------------
352// Auto-reply
353// ---------------------------------------------------------------------------
354
355/// D-STAR auto-reply configuration.
356///
357/// When enabled, the radio automatically responds to incoming D-STAR
358/// slow-data messages with a configured text reply.
359#[derive(Debug, Clone, PartialEq, Eq)]
360pub struct DstarAutoReply {
361    /// Auto-reply mode.
362    pub mode: DstarAutoReplyMode,
363    /// Auto-reply message text (up to 20 characters).
364    pub message: DstarMessage,
365}
366
367impl Default for DstarAutoReply {
368    fn default() -> Self {
369        Self {
370            mode: DstarAutoReplyMode::Off,
371            message: DstarMessage::default(),
372        }
373    }
374}
375
376/// D-STAR auto-reply mode.
377#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
378pub enum DstarAutoReplyMode {
379    /// Auto-reply disabled.
380    Off,
381    /// Reply with the configured message text.
382    Reply,
383    /// Reply with the current GPS position.
384    Position,
385    /// Reply with both message text and GPS position.
386    Both,
387}
388
389impl TryFrom<u8> for DstarAutoReplyMode {
390    type Error = ValidationError;
391
392    fn try_from(value: u8) -> Result<Self, Self::Error> {
393        match value {
394            0 => Ok(Self::Off),
395            1 => Ok(Self::Reply),
396            2 => Ok(Self::Position),
397            3 => Ok(Self::Both),
398            _ => Err(ValidationError::SettingOutOfRange {
399                name: "D-STAR auto reply mode",
400                value,
401                detail: "must be 0-3",
402            }),
403        }
404    }
405}
406
407impl TryFrom<u8> for GatewayMode {
408    type Error = ValidationError;
409
410    fn try_from(value: u8) -> Result<Self, Self::Error> {
411        match value {
412            0 => Ok(Self::Auto),
413            1 => Ok(Self::Manual),
414            _ => Err(ValidationError::SettingOutOfRange {
415                name: "gateway mode",
416                value,
417                detail: "must be 0-1",
418            }),
419        }
420    }
421}
422
423/// D-STAR slow-data message text (up to 20 characters).
424#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
425pub struct DstarMessage(String);
426
427impl DstarMessage {
428    /// Maximum length of a D-STAR message.
429    pub const MAX_LEN: usize = 20;
430
431    /// Creates a new D-STAR message.
432    ///
433    /// # Errors
434    ///
435    /// Returns `None` if the text exceeds 20 characters.
436    #[must_use]
437    pub fn new(text: &str) -> Option<Self> {
438        if text.len() <= Self::MAX_LEN {
439            Some(Self(text.to_owned()))
440        } else {
441            None
442        }
443    }
444
445    /// Returns the message as a string slice.
446    #[must_use]
447    pub fn as_str(&self) -> &str {
448        &self.0
449    }
450}
451
452// ---------------------------------------------------------------------------
453// Gateway and EMR
454// ---------------------------------------------------------------------------
455
456/// D-STAR gateway mode.
457///
458/// Controls how the radio selects the gateway repeater for callsign
459/// routing via the D-STAR network.
460#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
461pub enum GatewayMode {
462    /// Automatic gateway selection based on the repeater list.
463    Auto,
464    /// Manual gateway configuration (user sets RPT2 directly).
465    Manual,
466}
467
468/// EMR (Emergency) volume level (0-9).
469///
470/// When EMR mode is activated by the remote station, the radio increases
471/// volume to the configured EMR level. 0 disables EMR volume override.
472#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
473pub struct EmrVolume(u8);
474
475impl EmrVolume {
476    /// Maximum EMR volume level.
477    pub const MAX: u8 = 9;
478
479    /// Creates a new EMR volume level.
480    ///
481    /// # Errors
482    ///
483    /// Returns `None` if the value exceeds 9.
484    #[must_use]
485    pub const fn new(level: u8) -> Option<Self> {
486        if level <= Self::MAX {
487            Some(Self(level))
488        } else {
489            None
490        }
491    }
492
493    /// Returns the EMR volume level.
494    #[must_use]
495    pub const fn level(self) -> u8 {
496        self.0
497    }
498}
499
500// ---------------------------------------------------------------------------
501// Repeater list entry
502// ---------------------------------------------------------------------------
503
504/// D-STAR repeater list entry.
505///
506/// Stored in MCP memory at pages 0x02A1+ as 108-byte records, and
507/// importable/exportable via TSV files on the SD card at
508/// `/KENWOOD/TH-D75/SETTINGS/RPT_LIST/`.
509///
510/// The TH-D75 supports up to 1500 repeater entries.
511#[derive(Debug, Clone, PartialEq)]
512pub struct RepeaterEntry {
513    /// Group name / region (up to 16 characters).
514    pub group_name: String,
515    /// Repeater name / description (up to 16 characters).
516    pub name: String,
517    /// Sub-name / area description (up to 16 characters).
518    pub sub_name: String,
519    /// RPT1 callsign (access repeater, 8-character D-STAR format).
520    pub callsign_rpt1: DstarCallsign,
521    /// RPT2 / gateway callsign (8-character D-STAR format).
522    pub gateway_rpt2: DstarCallsign,
523    /// Operating frequency in Hz.
524    pub frequency: u32,
525    /// Duplex direction.
526    pub duplex: RepeaterDuplex,
527    /// TX offset frequency in Hz.
528    pub offset: u32,
529    /// D-STAR module letter (A = 23 cm, B = 70 cm, C = 2 m).
530    pub module: DstarModule,
531    /// Repeater latitude in decimal degrees (positive = North).
532    pub latitude: f64,
533    /// Repeater longitude in decimal degrees (positive = East).
534    pub longitude: f64,
535    /// UTC offset / time zone string (e.g. "+09:00").
536    pub utc_offset: String,
537    /// Position accuracy indicator.
538    pub position_accuracy: PositionAccuracy,
539    /// Lockout this repeater from DR scan.
540    pub lockout: bool,
541}
542
543/// Repeater duplex direction (from TSV "Dup" column).
544#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
545pub enum RepeaterDuplex {
546    /// Simplex (no shift).
547    Simplex,
548    /// Positive shift.
549    Plus,
550    /// Negative shift.
551    Minus,
552}
553
554/// D-STAR module letter.
555///
556/// Each D-STAR repeater has up to 3 RF modules and 1 gateway module.
557#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
558pub enum DstarModule {
559    /// Module A (1.2 GHz / 23 cm band).
560    A,
561    /// Module B (430 MHz / 70 cm band).
562    B,
563    /// Module C (144 MHz / 2 m band).
564    C,
565    /// Gateway module (internet linking).
566    G,
567}
568
569/// Position accuracy for repeater list entries.
570#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
571pub enum PositionAccuracy {
572    /// Position data is invalid or not available.
573    Invalid,
574    /// Position is approximate (city-level).
575    Approximate,
576    /// Position is exact (surveyed coordinates).
577    Exact,
578}
579
580// ---------------------------------------------------------------------------
581// Hotspot entry
582// ---------------------------------------------------------------------------
583
584/// D-STAR hotspot list entry.
585///
586/// The TH-D75 supports up to 30 hotspot entries for personal D-STAR
587/// access points (e.g. DVAP, `DV4mini`, MMDVM).
588#[derive(Debug, Clone, PartialEq, Eq)]
589pub struct HotspotEntry {
590    /// Hotspot name (up to 16 characters).
591    pub name: String,
592    /// Sub-name / description (up to 16 characters).
593    pub sub_name: String,
594    /// RPT1 callsign (8-character D-STAR format).
595    pub callsign_rpt1: DstarCallsign,
596    /// Gateway / RPT2 callsign (8-character D-STAR format).
597    pub gateway_rpt2: DstarCallsign,
598    /// Operating frequency in Hz.
599    pub frequency: u32,
600    /// Lockout this hotspot from scanning.
601    pub lockout: bool,
602}
603
604// ---------------------------------------------------------------------------
605// Callsign list entry
606// ---------------------------------------------------------------------------
607
608/// D-STAR callsign list entry (URCALL memory).
609///
610/// Stored on the SD card at `/KENWOOD/TH-D75/SETTINGS/CALLSIGN_LIST/`
611/// and in MCP memory as part of the repeater/callsign region.
612/// The TH-D75 supports up to 120 callsign entries.
613#[derive(Debug, Clone, PartialEq, Eq, Hash)]
614pub struct CallsignEntry {
615    /// D-STAR destination callsign (8 characters, space-padded).
616    pub callsign: DstarCallsign,
617}
618
619// ---------------------------------------------------------------------------
620// Reflector operations
621// ---------------------------------------------------------------------------
622
623/// D-STAR reflector operation command.
624///
625/// Reflector operations are performed by setting specific URCALL values.
626/// The TH-D75 provides dedicated menu items for these operations.
627/// Handler at firmware address `0xC005D460`.
628#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
629pub enum ReflectorCommand {
630    /// Link to a reflector module.
631    Link,
632    /// Unlink from the current reflector.
633    Unlink,
634    /// Echo test (transmit and receive back your own audio).
635    Echo,
636    /// Request reflector status information.
637    Info,
638    /// Use the currently linked reflector.
639    Use,
640}
641
642/// Parsed action from a D-STAR URCALL field (8 characters).
643///
644/// The URCALL field in a D-STAR header can contain either a destination
645/// callsign for routing, or a special command for the gateway. This enum
646/// represents all possible interpretations.
647///
648/// # Special URCALL patterns (per DPlus/DCS/DExtra conventions)
649///
650/// - `"CQCQCQ  "` — Broadcast CQ (no routing)
651/// - `"       E"` — Echo test (7 spaces + `E`)
652/// - `"       U"` — Unlink from reflector (7 spaces + `U`)
653/// - `"       I"` — Request info (7 spaces + `I`)
654/// - `"REF001 A"` — Link to reflector REF001, module A
655///   (up to 7 chars reflector name + module letter)
656#[derive(Debug, Clone, PartialEq, Eq)]
657pub enum UrCallAction {
658    /// Broadcast CQ — no special routing.
659    Cq,
660    /// Echo test — record and play back the transmission.
661    Echo,
662    /// Unlink — disconnect from the current reflector.
663    Unlink,
664    /// Request information from the gateway.
665    Info,
666    /// Link to a reflector and module.
667    Link {
668        /// Reflector name (e.g. "REF001", "XRF012", "DCS003").
669        reflector: String,
670        /// Module letter (A-Z).
671        module: char,
672    },
673    /// Route to a specific callsign (not a special command).
674    Callsign(String),
675}
676
677impl UrCallAction {
678    /// Parse an 8-character URCALL field into an action.
679    ///
680    /// The input should be exactly 8 characters (space-padded). If
681    /// shorter, it is right-padded with spaces. If longer, only the
682    /// first 8 characters are used.
683    #[must_use]
684    pub fn parse(ur_call: &str) -> Self {
685        // Pad to 8 characters.
686        let padded = format!("{:<8}", &ur_call[..ur_call.len().min(8)]);
687        let bytes = padded.as_bytes();
688
689        // Check for CQCQCQ.
690        if padded.trim() == "CQCQCQ" {
691            return Self::Cq;
692        }
693
694        // Check single-char commands (7 spaces + command).
695        if bytes[..7] == *b"       " {
696            return match bytes[7] {
697                b'E' => Self::Echo,
698                b'U' => Self::Unlink,
699                b'I' => Self::Info,
700                _ => Self::Callsign(padded.trim().to_owned()),
701            };
702        }
703
704        // Check for reflector link: last char is A-Z module letter,
705        // and the name portion matches known reflector prefixes.
706        let module = bytes[7];
707        if module.is_ascii_uppercase() {
708            let name = padded[..7].trim();
709            if !name.is_empty()
710                && (name.starts_with("REF")
711                    || name.starts_with("XRF")
712                    || name.starts_with("DCS")
713                    || name.starts_with("XLX"))
714            {
715                return Self::Link {
716                    reflector: name.to_owned(),
717                    module: module as char,
718                };
719            }
720        }
721
722        // Default: treat as a destination callsign.
723        Self::Callsign(padded.trim().to_owned())
724    }
725}
726
727// ---------------------------------------------------------------------------
728// Destination / route select
729// ---------------------------------------------------------------------------
730
731/// D-STAR destination selection method.
732///
733/// In DR mode, the radio can select destinations from multiple sources.
734#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
735pub enum DestinationSelect {
736    /// Select from the repeater list.
737    RepeaterList,
738    /// Select from the callsign list.
739    CallsignList,
740    /// Select from TX/RX history.
741    History,
742    /// Direct callsign input.
743    DirectInput,
744}
745
746/// D-STAR route selection for gateway linking.
747#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
748pub enum RouteSelect {
749    /// Automatic route selection via the gateway.
750    Auto,
751    /// Use a specific repeater as the gateway destination.
752    Specified,
753}
754
755// ---------------------------------------------------------------------------
756// QSO log entry (D-STAR specific fields)
757// ---------------------------------------------------------------------------
758
759/// D-STAR QSO log entry.
760///
761/// Extends the generic QSO log with D-STAR-specific fields from the
762/// 24-column TSV format stored on the SD card at
763/// `/KENWOOD/TH-D75/QSO_LOG/`.
764#[derive(Debug, Clone, PartialEq)]
765pub struct DstarQsoEntry {
766    /// TX or RX direction.
767    pub direction: QsoDirection,
768    /// Source callsign (MYCALL).
769    pub caller: DstarCallsign,
770    /// Destination callsign (URCALL).
771    pub called: DstarCallsign,
772    /// RPT1 callsign (link source repeater).
773    pub rpt1: DstarCallsign,
774    /// RPT2 callsign (link destination repeater).
775    pub rpt2: DstarCallsign,
776    /// D-STAR slow-data message content.
777    pub message: String,
778    /// Break-in flag.
779    pub break_in: bool,
780    /// EMR (emergency) flag.
781    pub emr: bool,
782    /// Fast data flag.
783    pub fast_data: bool,
784    /// Remote station latitude (from D-STAR GPS data).
785    pub remote_latitude: Option<f64>,
786    /// Remote station longitude (from D-STAR GPS data).
787    pub remote_longitude: Option<f64>,
788    /// Remote station altitude in meters.
789    pub remote_altitude: Option<f64>,
790    /// Remote station course in degrees.
791    pub remote_course: Option<f64>,
792    /// Remote station speed in km/h.
793    pub remote_speed: Option<f64>,
794}
795
796/// QSO log direction.
797#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
798pub enum QsoDirection {
799    /// Transmitted.
800    Tx,
801    /// Received.
802    Rx,
803}
804
805// ---------------------------------------------------------------------------
806// Tests
807// ---------------------------------------------------------------------------
808
809#[cfg(test)]
810mod tests {
811    use super::*;
812
813    #[test]
814    fn dstar_callsign_valid() {
815        let cs = DstarCallsign::new("N0CALL").unwrap();
816        assert_eq!(cs.as_str(), "N0CALL");
817    }
818
819    #[test]
820    fn dstar_callsign_max_length() {
821        let cs = DstarCallsign::new("JR6YPR A").unwrap();
822        assert_eq!(cs.as_str(), "JR6YPR A");
823    }
824
825    #[test]
826    fn dstar_callsign_too_long() {
827        assert!(DstarCallsign::new("123456789").is_none());
828    }
829
830    #[test]
831    fn dstar_callsign_trims_trailing_spaces() {
832        let cs = DstarCallsign::new("N0CALL  ").unwrap();
833        assert_eq!(cs.as_str(), "N0CALL");
834    }
835
836    #[test]
837    fn dstar_callsign_wire_bytes_padded() {
838        let cs = DstarCallsign::new("N0CALL").unwrap();
839        let bytes = cs.to_wire_bytes();
840        assert_eq!(&bytes, b"N0CALL  ");
841    }
842
843    #[test]
844    fn dstar_callsign_from_wire_bytes() {
845        let bytes = *b"JR6YPR B";
846        let cs = DstarCallsign::from_wire_bytes(&bytes);
847        assert_eq!(cs.as_str(), "JR6YPR B");
848    }
849
850    #[test]
851    fn dstar_callsign_cqcqcq() {
852        let cs = DstarCallsign::cqcqcq();
853        assert!(cs.is_cqcqcq());
854        assert_eq!(cs.as_str(), "CQCQCQ");
855    }
856
857    #[test]
858    fn dstar_suffix_valid() {
859        let s = DstarSuffix::new("/P").unwrap();
860        assert_eq!(s.as_str(), "/P");
861    }
862
863    #[test]
864    fn dstar_suffix_too_long() {
865        assert!(DstarSuffix::new("12345").is_none());
866    }
867
868    #[test]
869    fn emr_volume_valid_range() {
870        for i in 0u8..=9 {
871            assert!(EmrVolume::new(i).is_some());
872        }
873    }
874
875    #[test]
876    fn emr_volume_invalid() {
877        assert!(EmrVolume::new(10).is_none());
878    }
879
880    #[test]
881    fn dstar_message_valid() {
882        let msg = DstarMessage::new("Hello D-STAR").unwrap();
883        assert_eq!(msg.as_str(), "Hello D-STAR");
884    }
885
886    #[test]
887    fn dstar_message_too_long() {
888        let text = "a".repeat(21);
889        assert!(DstarMessage::new(&text).is_none());
890    }
891
892    #[test]
893    fn dstar_config_default() {
894        let cfg = DstarConfig::default();
895        assert!(cfg.ur_call.is_cqcqcq());
896        assert_eq!(cfg.dv_mode, DvDrMode::Dv);
897        assert!(cfg.standby_beep);
898        assert!(!cfg.break_call);
899    }
900
901    #[test]
902    fn digital_squelch_default() {
903        let sq = DigitalSquelch::default();
904        assert_eq!(sq.squelch_type, DigitalSquelchType::Off);
905        assert_eq!(sq.code.value(), 0);
906    }
907
908    // -----------------------------------------------------------------------
909    // UrCallAction tests
910    // -----------------------------------------------------------------------
911
912    #[test]
913    fn urcall_cq() {
914        assert_eq!(UrCallAction::parse("CQCQCQ  "), UrCallAction::Cq);
915        assert_eq!(UrCallAction::parse("CQCQCQ"), UrCallAction::Cq);
916    }
917
918    #[test]
919    fn urcall_echo() {
920        assert_eq!(UrCallAction::parse("       E"), UrCallAction::Echo);
921    }
922
923    #[test]
924    fn urcall_unlink() {
925        assert_eq!(UrCallAction::parse("       U"), UrCallAction::Unlink);
926    }
927
928    #[test]
929    fn urcall_info() {
930        assert_eq!(UrCallAction::parse("       I"), UrCallAction::Info);
931    }
932
933    #[test]
934    fn urcall_link_ref() {
935        let action = UrCallAction::parse("REF001 A");
936        assert_eq!(
937            action,
938            UrCallAction::Link {
939                reflector: "REF001".to_owned(),
940                module: 'A',
941            }
942        );
943    }
944
945    #[test]
946    fn urcall_link_xrf() {
947        let action = UrCallAction::parse("XRF012 C");
948        assert_eq!(
949            action,
950            UrCallAction::Link {
951                reflector: "XRF012".to_owned(),
952                module: 'C',
953            }
954        );
955    }
956
957    #[test]
958    fn urcall_link_dcs() {
959        let action = UrCallAction::parse("DCS003 B");
960        assert_eq!(
961            action,
962            UrCallAction::Link {
963                reflector: "DCS003".to_owned(),
964                module: 'B',
965            }
966        );
967    }
968
969    #[test]
970    fn urcall_link_xlx() {
971        let action = UrCallAction::parse("XLX999 A");
972        assert_eq!(
973            action,
974            UrCallAction::Link {
975                reflector: "XLX999".to_owned(),
976                module: 'A',
977            }
978        );
979    }
980
981    #[test]
982    fn urcall_callsign() {
983        let action = UrCallAction::parse("W1AW    ");
984        assert_eq!(action, UrCallAction::Callsign("W1AW".to_owned()));
985    }
986
987    #[test]
988    fn urcall_unknown_single_char() {
989        // 7 spaces + unknown letter → callsign
990        let action = UrCallAction::parse("       X");
991        assert_eq!(action, UrCallAction::Callsign("X".to_owned()));
992    }
993}