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}