1use std::path::PathBuf;
2use std::time::{Instant, SystemTime};
3
4use kenwood_thd75::memory::MemoryImage;
5use kenwood_thd75::types::{
6 AfGainLevel, BatteryLevel, BeaconMode, Frequency, Mode, PowerLevel, SMeterReading,
7 SquelchLevel, VoxDelay, VoxGain,
8};
9
10fn cache_path() -> PathBuf {
17 let base = cache_dir().unwrap_or_else(|| PathBuf::from("."));
18 base.join("thd75-tui").join("mcp.bin")
19}
20
21fn cache_dir() -> Option<PathBuf> {
23 #[cfg(target_os = "macos")]
24 {
25 std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Caches"))
26 }
27 #[cfg(target_os = "windows")]
28 {
29 std::env::var_os("LOCALAPPDATA").map(PathBuf::from)
30 }
31 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
32 {
33 std::env::var_os("XDG_CACHE_HOME")
34 .map(PathBuf::from)
35 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))
36 }
37}
38
39pub(crate) fn save_cache(data: &[u8]) {
44 let path = cache_path();
45 if let Some(parent) = path.parent()
46 && let Err(e) = std::fs::create_dir_all(parent)
47 {
48 tracing::error!(path = %parent.display(), "failed to create cache dir: {e}");
49 return;
50 }
51 if let Err(e) = std::fs::write(&path, data) {
52 tracing::error!(path = %path.display(), "failed to write MCP cache: {e}");
53 }
54}
55
56pub(crate) fn load_cache() -> Option<(MemoryImage, std::time::Duration)> {
58 let path = cache_path();
59 let data = std::fs::read(&path).ok()?;
60 let age = std::fs::metadata(&path)
61 .ok()
62 .and_then(|m| m.modified().ok())
63 .and_then(|t| SystemTime::now().duration_since(t).ok())
64 .unwrap_or_default();
65 let image = MemoryImage::from_raw(data).ok()?;
66 Some((image, age))
67}
68
69pub(crate) const SETTINGS_COUNT: usize = 92;
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub(crate) enum SettingRow {
79 SquelchA,
82 SquelchB,
84 StepSizeA,
86 StepSizeB,
88 FineStep,
90 FilterWidthSsb,
92 FilterWidthCw,
94 FilterWidthAm,
96 FmNarrow,
98 SsbHighCut,
100 CwHighCut,
102 AmHighCut,
104 AutoFilter,
106
107 ScanResume,
110 DigitalScanResume,
112 ScanRestartTime,
114 ScanRestartCarrier,
116
117 TimeoutTimer,
120 TxInhibit,
122 BeatShift,
124
125 VoxEnabled,
128 VoxGain,
130 VoxDelay,
132 VoxTxOnBusy,
134
135 CwBreakIn,
138 CwDelayTime,
140 CwPitch,
142
143 DtmfSpeed,
146 DtmfPauseTime,
148 DtmfTxHold,
150
151 RepeaterAutoOffset,
154 RepeaterCallKey,
156
157 MicSensitivity,
160 PfKey1,
162 PfKey2,
164
165 Lock,
168 KeyLockType,
170 LockKeyA,
172 LockKeyB,
174 LockKeyC,
176 LockPtt,
178 AprsLock,
180
181 DualDisplaySize,
184 DisplayArea,
186 InfoLine,
188 BacklightControl,
190 BacklightTimer,
192 DisplayHoldTime,
194 DisplayMethod,
196 PowerOnDisplay,
198 DualBand,
200
201 EmrVolumeLevel,
204 AutoMuteReturnTime,
206 Announce,
208 KeyBeep,
210 BeepVolume,
212 VoiceLanguage,
214 VoiceVolume,
216 VoiceSpeed,
218 VolumeLock,
220
221 SpeedDistanceUnit,
224 AltitudeRainUnit,
226 TemperatureUnit,
228
229 Bluetooth,
232 BtAutoConnect,
234
235 GpsBtInterface,
238 PcOutputMode,
240 AprsUsbMode,
242 UsbAudioOutput,
244 InternetLink,
246
247 Language,
250 PowerOnMessageFlag,
252
253 BatterySaver,
256 AutoPowerOff,
258
259 PowerA,
262 PowerB,
264 AttenuatorA,
266 AttenuatorB,
268 ModeA,
270 ModeB,
272 ActiveBand,
274 VfoMemModeA,
276 VfoMemModeB,
278 FmRadio,
280 TncBaud,
282 BeaconType,
284 GpsEnabled,
286 GpsPcOutput,
288 AutoInfo,
290 CallsignSlot,
292 DstarSlot,
294 ScanResumeCat,
296}
297
298impl SettingRow {
299 pub(crate) const ALL: [Self; SETTINGS_COUNT] = [
301 Self::SquelchA,
303 Self::SquelchB,
304 Self::StepSizeA,
305 Self::StepSizeB,
306 Self::FineStep,
307 Self::FilterWidthSsb,
308 Self::FilterWidthCw,
309 Self::FilterWidthAm,
310 Self::FmNarrow,
311 Self::SsbHighCut,
312 Self::CwHighCut,
313 Self::AmHighCut,
314 Self::AutoFilter,
315 Self::ScanResume,
317 Self::DigitalScanResume,
318 Self::ScanRestartTime,
319 Self::ScanRestartCarrier,
320 Self::TimeoutTimer,
322 Self::TxInhibit,
323 Self::BeatShift,
324 Self::VoxEnabled,
326 Self::VoxGain,
327 Self::VoxDelay,
328 Self::VoxTxOnBusy,
329 Self::CwBreakIn,
331 Self::CwDelayTime,
332 Self::CwPitch,
333 Self::DtmfSpeed,
335 Self::DtmfPauseTime,
336 Self::DtmfTxHold,
337 Self::RepeaterAutoOffset,
339 Self::RepeaterCallKey,
340 Self::MicSensitivity,
342 Self::PfKey1,
343 Self::PfKey2,
344 Self::Lock,
346 Self::KeyLockType,
347 Self::LockKeyA,
348 Self::LockKeyB,
349 Self::LockKeyC,
350 Self::LockPtt,
351 Self::AprsLock,
352 Self::DualDisplaySize,
354 Self::DisplayArea,
355 Self::InfoLine,
356 Self::BacklightControl,
357 Self::BacklightTimer,
358 Self::DisplayHoldTime,
359 Self::DisplayMethod,
360 Self::PowerOnDisplay,
361 Self::DualBand,
362 Self::EmrVolumeLevel,
364 Self::AutoMuteReturnTime,
365 Self::Announce,
366 Self::KeyBeep,
367 Self::BeepVolume,
368 Self::VoiceLanguage,
369 Self::VoiceVolume,
370 Self::VoiceSpeed,
371 Self::VolumeLock,
372 Self::SpeedDistanceUnit,
374 Self::AltitudeRainUnit,
375 Self::TemperatureUnit,
376 Self::Bluetooth,
378 Self::BtAutoConnect,
379 Self::GpsBtInterface,
381 Self::PcOutputMode,
382 Self::AprsUsbMode,
383 Self::UsbAudioOutput,
384 Self::InternetLink,
385 Self::Language,
387 Self::PowerOnMessageFlag,
388 Self::BatterySaver,
390 Self::AutoPowerOff,
391 Self::PowerA,
393 Self::PowerB,
394 Self::AttenuatorA,
395 Self::AttenuatorB,
396 Self::ModeA,
397 Self::ModeB,
398 Self::ActiveBand,
399 Self::VfoMemModeA,
400 Self::VfoMemModeB,
401 Self::FmRadio,
402 Self::TncBaud,
403 Self::BeaconType,
404 Self::GpsEnabled,
405 Self::GpsPcOutput,
406 Self::AutoInfo,
407 Self::CallsignSlot,
408 Self::DstarSlot,
409 Self::ScanResumeCat,
410 ];
411
412 pub(crate) const fn label(self) -> &'static str {
414 match self {
415 Self::SquelchA => "Squelch A",
416 Self::SquelchB => "Squelch B",
417 Self::StepSizeA => "Step Size A",
418 Self::StepSizeB => "Step Size B",
419 Self::FineStep => "Fine Step",
420 Self::FilterWidthSsb => "Filter Width SSB",
421 Self::FilterWidthCw => "Filter Width CW",
422 Self::FilterWidthAm => "Filter Width AM",
423 Self::FmNarrow => "FM Narrow",
424 Self::SsbHighCut => "SSB High Cut",
425 Self::CwHighCut => "CW High Cut",
426 Self::AmHighCut => "AM High Cut",
427 Self::AutoFilter => "Auto Filter",
428 Self::ScanResume => "Scan Resume",
429 Self::DigitalScanResume => "Digital Scan Resume",
430 Self::ScanRestartTime => "Scan Restart Time",
431 Self::ScanRestartCarrier => "Scan Restart Carrier",
432 Self::TimeoutTimer => "Timeout Timer",
433 Self::TxInhibit => "TX Inhibit",
434 Self::BeatShift => "Beat Shift",
435 Self::VoxEnabled => "VOX",
436 Self::VoxGain => "VOX Gain",
437 Self::VoxDelay => "VOX Delay",
438 Self::VoxTxOnBusy => "VOX TX on Busy",
439 Self::CwBreakIn => "CW Break-In",
440 Self::CwDelayTime => "CW Delay Time",
441 Self::CwPitch => "CW Pitch",
442 Self::DtmfSpeed => "DTMF Speed",
443 Self::DtmfPauseTime => "DTMF Pause Time",
444 Self::DtmfTxHold => "DTMF TX Hold",
445 Self::RepeaterAutoOffset => "Repeater Auto Offset",
446 Self::RepeaterCallKey => "Call Key Function",
447 Self::MicSensitivity => "Mic Sensitivity",
448 Self::PfKey1 => "PF Key 1",
449 Self::PfKey2 => "PF Key 2",
450 Self::Lock => "Lock",
451 Self::KeyLockType => "Key Lock Type",
452 Self::LockKeyA => "Lock Key A",
453 Self::LockKeyB => "Lock Key B",
454 Self::LockKeyC => "Lock Key C",
455 Self::LockPtt => "Lock PTT",
456 Self::AprsLock => "APRS Lock",
457 Self::DualDisplaySize => "Dual Display Size",
458 Self::DisplayArea => "Display Area",
459 Self::InfoLine => "Info Line",
460 Self::BacklightControl => "Backlight Control",
461 Self::BacklightTimer => "Backlight Timer",
462 Self::DisplayHoldTime => "Display Hold Time",
463 Self::DisplayMethod => "Display Method",
464 Self::PowerOnDisplay => "Power-On Display",
465 Self::DualBand => "Dual Band",
466 Self::EmrVolumeLevel => "EMR Volume Level",
467 Self::AutoMuteReturnTime => "Auto Mute Return",
468 Self::Announce => "Announce",
469 Self::KeyBeep => "Key Beep",
470 Self::BeepVolume => "Beep Volume",
471 Self::VoiceLanguage => "Voice Language",
472 Self::VoiceVolume => "Voice Volume",
473 Self::VoiceSpeed => "Voice Speed",
474 Self::VolumeLock => "Volume Lock",
475 Self::SpeedDistanceUnit => "Speed/Distance Unit",
476 Self::AltitudeRainUnit => "Altitude/Rain Unit",
477 Self::TemperatureUnit => "Temperature Unit",
478 Self::Bluetooth => "Bluetooth",
479 Self::BtAutoConnect => "BT Auto Connect",
480 Self::GpsBtInterface => "GPS/BT Interface",
481 Self::PcOutputMode => "PC Output Mode",
482 Self::AprsUsbMode => "APRS USB Mode",
483 Self::UsbAudioOutput => "USB Audio Output",
484 Self::InternetLink => "Internet Link",
485 Self::Language => "Language",
486 Self::PowerOnMessageFlag => "Power-On Msg Flag",
487 Self::BatterySaver => "Battery Saver",
488 Self::AutoPowerOff => "Auto Power Off",
489 Self::PowerA => "Power A",
490 Self::PowerB => "Power B",
491 Self::AttenuatorA => "Attenuator A",
492 Self::AttenuatorB => "Attenuator B",
493 Self::ModeA => "Mode A",
494 Self::ModeB => "Mode B",
495 Self::ActiveBand => "Active Band",
496 Self::VfoMemModeA => "VFO/Mem A",
497 Self::VfoMemModeB => "VFO/Mem B",
498 Self::FmRadio => "FM Radio",
499 Self::TncBaud => "TNC Baud",
500 Self::BeaconType => "Beacon Type",
501 Self::GpsEnabled => "GPS Enabled",
502 Self::GpsPcOutput => "GPS PC Output",
503 Self::AutoInfo => "Auto Info",
504 Self::CallsignSlot => "Callsign Slot",
505 Self::DstarSlot => "D-STAR Slot",
506 Self::ScanResumeCat => "Scan Resume (CAT)",
507 }
508 }
509
510 pub(crate) const fn section_header(self) -> Option<&'static str> {
512 match self {
513 Self::SquelchA => Some("── RX ──"),
514 Self::ScanResume => Some("── Scan ──"),
515 Self::TimeoutTimer => Some("── TX ──"),
516 Self::VoxEnabled => Some("── VOX ──"),
517 Self::CwBreakIn => Some("── CW ──"),
518 Self::DtmfSpeed => Some("── DTMF ──"),
519 Self::RepeaterAutoOffset => Some("── Repeater ──"),
520 Self::MicSensitivity => Some("── Auxiliary ──"),
521 Self::Lock => Some("── Lock ──"),
522 Self::DualDisplaySize => Some("── Display ──"),
523 Self::EmrVolumeLevel => Some("── Audio ──"),
524 Self::SpeedDistanceUnit => Some("── Units ──"),
525 Self::Bluetooth => Some("── Bluetooth ──"),
526 Self::GpsBtInterface => Some("── Interface ──"),
527 Self::Language => Some("── System ──"),
528 Self::BatterySaver => Some("── Battery ──"),
529 Self::PowerA => Some("── Radio Controls ──"),
530 _ => None,
531 }
532 }
533
534 pub(crate) const fn is_numeric(self) -> bool {
536 matches!(
537 self,
538 Self::SquelchA
539 | Self::SquelchB
540 | Self::StepSizeA
541 | Self::StepSizeB
542 | Self::ScanResumeCat
543 | Self::FmNarrow
544 | Self::SsbHighCut
545 | Self::CwHighCut
546 | Self::AmHighCut
547 | Self::AutoFilter
548 | Self::ScanResume
549 | Self::DigitalScanResume
550 | Self::ScanRestartTime
551 | Self::ScanRestartCarrier
552 | Self::TimeoutTimer
553 | Self::VoxGain
554 | Self::VoxDelay
555 | Self::CwDelayTime
556 | Self::CwPitch
557 | Self::DtmfSpeed
558 | Self::DtmfPauseTime
559 | Self::RepeaterCallKey
560 | Self::MicSensitivity
561 | Self::PfKey1
562 | Self::PfKey2
563 | Self::KeyLockType
564 | Self::DualDisplaySize
565 | Self::DisplayArea
566 | Self::InfoLine
567 | Self::BacklightControl
568 | Self::BacklightTimer
569 | Self::DisplayHoldTime
570 | Self::DisplayMethod
571 | Self::PowerOnDisplay
572 | Self::EmrVolumeLevel
573 | Self::AutoMuteReturnTime
574 | Self::BeepVolume
575 | Self::VoiceLanguage
576 | Self::VoiceVolume
577 | Self::VoiceSpeed
578 | Self::SpeedDistanceUnit
579 | Self::AltitudeRainUnit
580 | Self::TemperatureUnit
581 | Self::GpsBtInterface
582 | Self::PcOutputMode
583 | Self::AprsUsbMode
584 | Self::AutoPowerOff
585 | Self::PowerA
586 | Self::PowerB
587 | Self::ModeA
588 | Self::ModeB
589 | Self::ActiveBand
590 | Self::VfoMemModeA
591 | Self::VfoMemModeB
592 | Self::TncBaud
593 | Self::BeaconType
594 | Self::CallsignSlot
595 | Self::DstarSlot
596 )
597 }
598
599 pub(crate) const fn is_cat(self) -> bool {
601 matches!(
602 self,
603 Self::SquelchA
604 | Self::SquelchB
605 | Self::StepSizeA
606 | Self::StepSizeB
607 | Self::FineStep
608 | Self::FilterWidthSsb
609 | Self::FilterWidthCw
610 | Self::FilterWidthAm
611 | Self::VoxEnabled
612 | Self::VoxGain
613 | Self::VoxDelay
614 | Self::Lock
615 | Self::DualBand
616 | Self::Bluetooth
617 | Self::PowerA
618 | Self::PowerB
619 | Self::AttenuatorA
620 | Self::AttenuatorB
621 | Self::ModeA
622 | Self::ModeB
623 | Self::ActiveBand
624 | Self::VfoMemModeA
625 | Self::VfoMemModeB
626 | Self::FmRadio
627 | Self::TncBaud
628 | Self::BeaconType
629 | Self::GpsEnabled
630 | Self::GpsPcOutput
631 | Self::AutoInfo
632 | Self::CallsignSlot
633 | Self::DstarSlot
634 | Self::ScanResumeCat
635 )
636 }
637}
638
639pub(crate) fn cat_settings() -> Vec<SettingRow> {
641 SettingRow::ALL
642 .iter()
643 .copied()
644 .filter(|r| r.is_cat())
645 .collect()
646}
647
648pub(crate) fn mcp_settings() -> Vec<SettingRow> {
650 SettingRow::ALL
651 .iter()
652 .copied()
653 .filter(|r| !r.is_cat())
654 .collect()
655}
656
657const fn on_off(b: bool) -> &'static str {
658 if b { "On" } else { "Off" }
659}
660
661#[derive(Debug, Clone, Copy, PartialEq, Eq)]
663pub(crate) enum Pane {
664 BandA,
665 BandB,
666 Main,
667 Detail,
668}
669
670impl Pane {
671 pub(crate) const fn next(self) -> Self {
672 match self {
673 Self::BandA => Self::BandB,
674 Self::BandB => Self::Main,
675 Self::Main => Self::Detail,
676 Self::Detail => Self::BandA,
677 }
678 }
679
680 pub(crate) const fn prev(self) -> Self {
681 match self {
682 Self::BandA => Self::Detail,
683 Self::BandB => Self::BandA,
684 Self::Main => Self::BandB,
685 Self::Detail => Self::Main,
686 }
687 }
688}
689
690#[derive(Debug, Clone, Copy, PartialEq, Eq)]
692pub(crate) enum MainView {
693 Channels,
694 SettingsCat,
696 SettingsMcp,
698 Aprs,
699 DStar,
700 Gps,
701 Mcp,
702 FmRadio,
704}
705
706#[derive(Debug, Clone, Copy, PartialEq, Eq)]
708pub(crate) enum ChannelEditField {
709 Frequency,
710 Name,
711 Mode,
712 ToneMode,
713 ToneFreq,
714 Duplex,
715 Offset,
716}
717
718impl ChannelEditField {
719 pub(crate) const fn next(self) -> Self {
720 match self {
721 Self::Frequency => Self::Name,
722 Self::Name => Self::Mode,
723 Self::Mode => Self::ToneMode,
724 Self::ToneMode => Self::ToneFreq,
725 Self::ToneFreq => Self::Duplex,
726 Self::Duplex => Self::Offset,
727 Self::Offset => Self::Frequency,
728 }
729 }
730
731 pub(crate) const fn label(self) -> &'static str {
732 match self {
733 Self::Frequency => "Frequency",
734 Self::Name => "Name",
735 Self::Mode => "Mode",
736 Self::ToneMode => "Tone Mode",
737 Self::ToneFreq => "Tone Freq",
738 Self::Duplex => "Duplex",
739 Self::Offset => "Offset",
740 }
741 }
742}
743
744#[derive(Debug, Clone, PartialEq, Eq)]
746pub(crate) enum InputMode {
747 Normal,
748 Search(String),
750 FreqInput(String),
752}
753
754#[derive(Debug, Clone)]
756pub(crate) struct BandState {
757 pub frequency: Frequency,
758 pub mode: Mode,
759 pub s_meter: SMeterReading,
761 pub squelch: SquelchLevel,
763 pub power_level: PowerLevel,
764 pub busy: bool,
766 pub attenuator: bool,
767 pub step_size: Option<kenwood_thd75::types::StepSize>,
768}
769
770impl Default for BandState {
771 fn default() -> Self {
772 Self {
773 frequency: Frequency::new(145_000_000),
774 mode: Mode::Fm,
775 s_meter: SMeterReading::new(0).unwrap(),
776 squelch: SquelchLevel::new(0).unwrap(),
777 power_level: PowerLevel::High,
778 busy: false,
779 attenuator: false,
780 step_size: None,
781 }
782 }
783}
784
785#[derive(Debug, Clone)]
787#[allow(clippy::struct_excessive_bools)]
788pub(crate) struct RadioState {
789 pub band_a: BandState,
790 pub band_b: BandState,
791 pub battery_level: BatteryLevel,
792 pub beep: bool,
793 pub lock: bool,
794 pub dual_band: bool,
795 pub bluetooth: bool,
796 pub vox: bool,
797 pub vox_gain: VoxGain,
798 pub vox_delay: VoxDelay,
799 pub af_gain: AfGainLevel,
800 pub firmware_version: String,
801 pub radio_type: String,
802 pub gps_enabled: bool,
803 pub gps_pc_output: bool,
804 pub gps_sentences: Option<(bool, bool, bool, bool, bool, bool)>,
806 pub gps_mode: Option<kenwood_thd75::types::GpsRadioMode>,
808 pub beacon_type: BeaconMode,
809 pub fine_step: Option<kenwood_thd75::types::FineStep>,
810 pub filter_width_ssb: Option<kenwood_thd75::types::FilterWidthIndex>,
811 pub filter_width_cw: Option<kenwood_thd75::types::FilterWidthIndex>,
812 pub filter_width_am: Option<kenwood_thd75::types::FilterWidthIndex>,
813 pub scan_resume_cat: Option<kenwood_thd75::types::ScanResumeMethod>,
815 pub dstar_urcall: String,
817 pub dstar_urcall_suffix: String,
819 pub dstar_rpt1: String,
821 pub dstar_rpt1_suffix: String,
823 pub dstar_rpt2: String,
825 pub dstar_rpt2_suffix: String,
827 pub dstar_gateway_mode: Option<kenwood_thd75::types::DvGatewayMode>,
829 pub dstar_slot: Option<kenwood_thd75::types::DstarSlot>,
831 pub dstar_callsign_slot: Option<kenwood_thd75::types::CallsignSlot>,
833}
834
835impl Default for RadioState {
836 fn default() -> Self {
837 Self {
838 band_a: BandState::default(),
839 band_b: BandState::default(),
840 battery_level: BatteryLevel::Empty,
841 beep: false,
842 lock: false,
843 dual_band: false,
844 bluetooth: false,
845 vox: false,
846 vox_gain: VoxGain::new(0).unwrap(),
847 vox_delay: VoxDelay::new(0).unwrap(),
848 af_gain: AfGainLevel::new(0),
849 firmware_version: String::new(),
850 radio_type: String::new(),
851 gps_enabled: false,
852 gps_pc_output: false,
853 gps_sentences: None,
854 gps_mode: None,
855 beacon_type: BeaconMode::Off,
856 fine_step: None,
857 filter_width_ssb: None,
858 filter_width_cw: None,
859 filter_width_am: None,
860 scan_resume_cat: None,
861 dstar_urcall: String::new(),
862 dstar_urcall_suffix: String::new(),
863 dstar_rpt1: String::new(),
864 dstar_rpt1_suffix: String::new(),
865 dstar_rpt2: String::new(),
866 dstar_rpt2_suffix: String::new(),
867 dstar_gateway_mode: None,
868 dstar_slot: None,
869 dstar_callsign_slot: None,
870 }
871 }
872}
873
874#[derive(Debug, Clone, Copy, PartialEq, Eq)]
876pub(crate) enum DStarMode {
877 Inactive,
879 Active,
881}
882
883#[derive(Debug, Clone, Copy, PartialEq, Eq)]
885pub(crate) enum AprsMode {
886 Inactive,
888 Active,
890}
891
892#[derive(Debug, Clone)]
894pub(crate) struct AprsMessageStatus {
895 pub addressee: String,
897 pub text: String,
899 pub message_id: String,
901 #[allow(dead_code)]
903 pub sent_at: Instant,
904 pub state: AprsMessageState,
906}
907
908#[derive(Debug, Clone, Copy, PartialEq, Eq)]
910pub(crate) enum AprsMessageState {
911 Pending,
913 Delivered,
915 Rejected,
917 Expired,
919}
920
921#[derive(Debug, Clone)]
927pub(crate) struct AprsStationCache {
928 pub callsign: String,
929 pub latitude: Option<f64>,
930 pub longitude: Option<f64>,
931 pub speed_knots: Option<u16>,
932 pub course_degrees: Option<u16>,
933 pub symbol_table: Option<char>,
934 pub symbol_code: Option<char>,
935 pub comment: Option<String>,
936 pub packet_count: u32,
937 pub last_path: Vec<String>,
938 pub last_heard: Instant,
939}
940
941#[derive(Debug)]
943pub(crate) enum McpState {
944 Idle,
945 Reading { page: u16, total: u16 },
946 Loaded { image: MemoryImage, modified: bool },
947 Writing { page: u16, total: u16 },
948 Reconnecting,
949}
950
951#[derive(Debug)]
953pub(crate) enum Message {
954 Key(crossterm::event::KeyEvent),
955 RadioUpdate(RadioState),
956 RadioError(String),
957 Disconnected,
958 Reconnected,
959 McpProgress {
960 page: u16,
961 total: u16,
962 },
963 McpReadComplete(Vec<u8>),
964 McpWriteComplete,
965 McpByteWritten {
968 offset: u16,
969 value: u8,
970 },
971 McpError(String),
972 AprsStarted,
974 AprsStopped,
976 AprsEvent(kenwood_thd75::AprsEvent),
978 AprsMessageSent {
980 addressee: String,
981 text: String,
982 message_id: String,
983 },
984 AprsError(String),
986 DStarStarted,
988 DStarStopped,
990 DStarEvent(kenwood_thd75::DStarEvent),
992 DStarError(String),
994 Quit,
995}
996
997#[allow(clippy::struct_excessive_bools)]
999pub(crate) struct App {
1000 pub connected: bool,
1001 pub port_path: String,
1002 pub state: RadioState,
1003 pub focus: Pane,
1004 pub main_view: MainView,
1005 pub input_mode: InputMode,
1006 pub mcp: McpState,
1007 pub should_quit: bool,
1008 pub quit_pending: bool,
1009 pub status_message: Option<String>,
1010 pub show_help: bool,
1011 pub channel_list_index: usize,
1012 pub settings_cat_index: usize,
1014 pub settings_mcp_index: usize,
1016 pub search_filter: String,
1018 pub target_band: kenwood_thd75::types::Band,
1020 pub cmd_tx: Option<tokio::sync::mpsc::UnboundedSender<crate::event::RadioCommand>>,
1022 pub aprs_mode: AprsMode,
1024 pub aprs_stations: Vec<AprsStationCache>,
1026 pub aprs_messages: Vec<AprsMessageStatus>,
1028 pub aprs_station_index: usize,
1030 pub aprs_compose: Option<String>,
1032 pub dstar_mode: DStarMode,
1034 pub dstar_last_heard: Vec<kenwood_thd75::LastHeardEntry>,
1036 pub dstar_last_heard_index: usize,
1038 pub dstar_text_message: Option<String>,
1040 pub dstar_rx_header: Option<dstar_gateway_core::DStarHeader>,
1042 pub dstar_rx_active: bool,
1044 pub dstar_urcall_input: Option<String>,
1046 pub dstar_reflector_input: Option<String>,
1048 pub channel_edit_mode: bool,
1050 pub channel_edit_field: ChannelEditField,
1052 pub channel_edit_buffer: String,
1054 pub fm_radio_on: bool,
1056}
1057
1058impl App {
1059 pub(crate) fn filtered_channels(&self) -> Vec<u16> {
1061 if let McpState::Loaded { ref image, .. } = self.mcp {
1062 let channels = image.channels();
1063 let filter = self.search_filter.to_uppercase();
1064 (0u16..1200)
1065 .filter(|&i| {
1066 if !channels.is_used(i) {
1067 return false;
1068 }
1069 if filter.is_empty() {
1070 return true;
1071 }
1072 if let Some(entry) = channels.get(i) {
1074 entry.name.to_uppercase().contains(&filter)
1075 || i.to_string().contains(&filter)
1076 } else {
1077 false
1078 }
1079 })
1080 .collect()
1081 } else {
1082 Vec::new()
1083 }
1084 }
1085
1086 fn used_channel_count(&self) -> usize {
1087 self.filtered_channels().len()
1088 }
1089
1090 pub(crate) fn new(port_path: String) -> Self {
1092 let (mcp, status_message) = match load_cache() {
1093 Some((image, age)) => {
1094 let mins = age.as_secs() / 60;
1095 let msg = if mins < 60 {
1096 format!("Loaded cached MCP data ({mins}m ago)")
1097 } else if mins < 1440 {
1098 format!("Loaded cached MCP data ({}h ago)", mins / 60)
1099 } else {
1100 format!("Loaded cached MCP data ({}d ago)", mins / 1440)
1101 };
1102 (
1103 McpState::Loaded {
1104 image,
1105 modified: false,
1106 },
1107 Some(msg),
1108 )
1109 }
1110 None => (McpState::Idle, None),
1111 };
1112
1113 Self {
1114 connected: false,
1115 port_path,
1116 state: RadioState::default(),
1117 focus: Pane::BandA,
1118 main_view: MainView::Channels,
1119 input_mode: InputMode::Normal,
1120 mcp,
1121 should_quit: false,
1122 quit_pending: false,
1123 status_message,
1124 show_help: false,
1125 channel_list_index: 0,
1126 settings_cat_index: 0,
1127 settings_mcp_index: 0,
1128 search_filter: String::new(),
1129 target_band: kenwood_thd75::types::Band::A,
1130 cmd_tx: None,
1131 aprs_mode: AprsMode::Inactive,
1132 aprs_stations: Vec::new(),
1133 aprs_messages: Vec::new(),
1134 aprs_station_index: 0,
1135 aprs_compose: None,
1136 dstar_mode: DStarMode::Inactive,
1137 dstar_last_heard: Vec::new(),
1138 dstar_last_heard_index: 0,
1139 dstar_text_message: None,
1140 dstar_rx_header: None,
1141 dstar_rx_active: false,
1142 dstar_urcall_input: None,
1143 dstar_reflector_input: None,
1144 channel_edit_mode: false,
1145 channel_edit_field: ChannelEditField::Frequency,
1146 channel_edit_buffer: String::new(),
1147 fm_radio_on: false,
1148 }
1149 }
1150
1151 pub(crate) fn update(&mut self, msg: Message) -> bool {
1153 match msg {
1154 Message::Quit => {
1155 self.should_quit = true;
1156 true
1157 }
1158 Message::Key(key) => self.handle_key(key),
1159 Message::RadioUpdate(mut state) => {
1160 if state.firmware_version.is_empty() {
1162 state.firmware_version = std::mem::take(&mut self.state.firmware_version);
1163 }
1164 if state.radio_type.is_empty() {
1165 state.radio_type = std::mem::take(&mut self.state.radio_type);
1166 }
1167 if state.scan_resume_cat.is_none() {
1169 state.scan_resume_cat = self.state.scan_resume_cat;
1170 }
1171 if state.dstar_urcall.is_empty() {
1173 state.dstar_urcall = std::mem::take(&mut self.state.dstar_urcall);
1174 state.dstar_urcall_suffix = std::mem::take(&mut self.state.dstar_urcall_suffix);
1175 }
1176 if state.dstar_rpt1.is_empty() {
1177 state.dstar_rpt1 = std::mem::take(&mut self.state.dstar_rpt1);
1178 state.dstar_rpt1_suffix = std::mem::take(&mut self.state.dstar_rpt1_suffix);
1179 }
1180 if state.dstar_rpt2.is_empty() {
1181 state.dstar_rpt2 = std::mem::take(&mut self.state.dstar_rpt2);
1182 state.dstar_rpt2_suffix = std::mem::take(&mut self.state.dstar_rpt2_suffix);
1183 }
1184 if state.dstar_gateway_mode.is_none() {
1185 state.dstar_gateway_mode = self.state.dstar_gateway_mode;
1186 }
1187 if state.dstar_slot.is_none() {
1188 state.dstar_slot = self.state.dstar_slot;
1189 }
1190 if state.dstar_callsign_slot.is_none() {
1191 state.dstar_callsign_slot = self.state.dstar_callsign_slot;
1192 }
1193 self.state = state;
1194 self.connected = true;
1195 true
1196 }
1197 Message::RadioError(err) => {
1198 self.status_message = Some(err);
1199 true
1200 }
1201 Message::Disconnected => {
1202 self.connected = false;
1203 self.status_message = Some("Disconnected — reconnecting...".into());
1204 true
1205 }
1206 Message::Reconnected => {
1207 self.connected = true;
1208 self.status_message = Some("Reconnected".into());
1209 true
1210 }
1211 Message::McpProgress { page, total } => {
1212 self.mcp = if matches!(self.mcp, McpState::Writing { .. }) {
1213 McpState::Writing { page, total }
1214 } else {
1215 McpState::Reading { page, total }
1216 };
1217 true
1218 }
1219 Message::McpReadComplete(data) => {
1220 save_cache(&data);
1221 match MemoryImage::from_raw(data) {
1222 Ok(image) => {
1223 self.mcp = McpState::Loaded {
1224 image,
1225 modified: false,
1226 };
1227 self.status_message = Some("MCP read complete — cached to disk".into());
1228 }
1229 Err(e) => {
1230 self.mcp = McpState::Idle;
1231 self.status_message = Some(format!("MCP parse error: {e}"));
1232 }
1233 }
1234 true
1235 }
1236 Message::McpWriteComplete => {
1237 self.mcp = McpState::Reconnecting;
1238 self.status_message = Some("MCP write complete — reconnecting...".into());
1239 true
1240 }
1241 Message::McpByteWritten { offset, value } => {
1242 if let McpState::Loaded { ref mut image, .. } = self.mcp {
1246 image.as_raw_mut()[offset as usize] = value;
1247 save_cache(image.as_raw());
1248 }
1249 true
1250 }
1251 Message::McpError(err) => {
1252 if !matches!(self.mcp, McpState::Loaded { .. }) {
1255 self.mcp = McpState::Idle;
1256 }
1257 self.status_message = Some(format!("MCP error: {err}"));
1258 true
1259 }
1260 Message::AprsStarted => {
1261 self.aprs_mode = AprsMode::Active;
1262 self.status_message = Some("APRS mode active".into());
1263 true
1264 }
1265 Message::AprsStopped => {
1266 self.aprs_mode = AprsMode::Inactive;
1267 self.status_message = Some("APRS mode stopped — CAT polling resumed".into());
1268 true
1269 }
1270 Message::AprsEvent(event) => {
1271 self.handle_aprs_event(event);
1272 true
1273 }
1274 Message::AprsMessageSent {
1275 addressee,
1276 text,
1277 message_id,
1278 } => {
1279 self.aprs_messages.push(AprsMessageStatus {
1280 addressee,
1281 text,
1282 message_id,
1283 sent_at: Instant::now(),
1284 state: AprsMessageState::Pending,
1285 });
1286 true
1287 }
1288 Message::AprsError(err) => {
1289 self.status_message = Some(format!("APRS: {err}"));
1290 true
1291 }
1292 Message::DStarStarted => {
1293 self.dstar_mode = DStarMode::Active;
1294 self.status_message = Some("D-STAR gateway mode active".into());
1295 true
1296 }
1297 Message::DStarStopped => {
1298 self.dstar_mode = DStarMode::Inactive;
1299 self.dstar_rx_active = false;
1300 self.dstar_rx_header = None;
1301 self.status_message =
1302 Some("D-STAR gateway mode stopped — CAT polling resumed".into());
1303 true
1304 }
1305 Message::DStarEvent(event) => {
1306 self.handle_dstar_event(event);
1307 true
1308 }
1309 Message::DStarError(err) => {
1310 self.status_message = Some(format!("D-STAR: {err}"));
1311 true
1312 }
1313 }
1314 }
1315
1316 #[allow(clippy::cognitive_complexity)]
1317 fn handle_key(&mut self, key: crossterm::event::KeyEvent) -> bool {
1318 use crossterm::event::{KeyCode, KeyModifiers};
1319
1320 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
1322 self.should_quit = true;
1323 return true;
1324 }
1325
1326 if let InputMode::Search(ref mut buf) = self.input_mode {
1328 match key.code {
1329 KeyCode::Esc => {
1330 self.search_filter.clear();
1331 self.input_mode = InputMode::Normal;
1332 self.channel_list_index = 0;
1333 }
1334 KeyCode::Enter => {
1335 self.input_mode = InputMode::Normal;
1336 }
1337 KeyCode::Backspace => {
1338 let _ = buf.pop();
1339 self.search_filter = buf.clone();
1340 self.channel_list_index = 0;
1341 }
1342 KeyCode::Char(c) => {
1343 buf.push(c);
1344 self.search_filter = buf.clone();
1345 self.channel_list_index = 0;
1346 }
1347 _ => {}
1348 }
1349 return true;
1350 }
1351
1352 if let Some(ref mut buf) = self.aprs_compose {
1354 match key.code {
1355 KeyCode::Esc => {
1356 self.aprs_compose = None;
1357 }
1358 KeyCode::Enter => {
1359 let text = buf.clone();
1360 self.aprs_compose = None;
1361 if !text.is_empty()
1362 && let Some(station) = self.aprs_stations.get(self.aprs_station_index)
1363 && let Some(ref tx) = self.cmd_tx
1364 {
1365 let addressee = station.callsign.clone();
1366 let _ = tx.send(crate::event::RadioCommand::SendAprsMessage {
1367 addressee: addressee.clone(),
1368 text: text.clone(),
1369 });
1370 self.status_message = Some(format!("Sending to {addressee}: {text}"));
1371 }
1372 }
1373 KeyCode::Backspace => {
1374 let _ = buf.pop();
1375 }
1376 KeyCode::Char(c) => {
1377 buf.push(c);
1378 }
1379 _ => {}
1380 }
1381 return true;
1382 }
1383
1384 if let Some(ref mut buf) = self.dstar_urcall_input {
1386 match key.code {
1387 KeyCode::Esc => {
1388 self.dstar_urcall_input = None;
1389 }
1390 KeyCode::Enter => {
1391 let input = buf.clone();
1392 self.dstar_urcall_input = None;
1393 if !input.is_empty()
1394 && let Some(ref tx) = self.cmd_tx
1395 {
1396 let _ = tx.send(crate::event::RadioCommand::SetUrcall {
1397 callsign: input.clone(),
1398 suffix: String::new(),
1399 });
1400 self.status_message = Some(format!("URCALL set to {input}"));
1401 }
1402 }
1403 KeyCode::Backspace => {
1404 let _ = buf.pop();
1405 }
1406 KeyCode::Char(c) => {
1407 if buf.len() < 8 {
1408 buf.push(c.to_ascii_uppercase());
1409 }
1410 }
1411 _ => {}
1412 }
1413 return true;
1414 }
1415
1416 if let Some(ref mut buf) = self.dstar_reflector_input {
1418 match key.code {
1419 KeyCode::Esc => {
1420 self.dstar_reflector_input = None;
1421 }
1422 KeyCode::Enter => {
1423 let input = buf.clone();
1424 self.dstar_reflector_input = None;
1425 let parts: Vec<&str> = input.split_whitespace().collect();
1427 let (name, module) = if parts.len() >= 2 {
1428 (parts[0].to_string(), parts[1].chars().next().unwrap_or('A'))
1429 } else if input.len() > 1 {
1430 let module = input.chars().last().unwrap_or('A');
1431 let name = &input[..input.len() - 1];
1432 (name.trim().to_string(), module)
1433 } else {
1434 self.status_message = Some("Invalid reflector (e.g. REF030 C)".into());
1435 return true;
1436 };
1437 if let Some(ref tx) = self.cmd_tx {
1438 let _ = tx.send(crate::event::RadioCommand::ConnectReflector {
1439 name: name.clone(),
1440 module,
1441 });
1442 self.status_message =
1443 Some(format!("Connecting to {name} module {module}..."));
1444 }
1445 }
1446 KeyCode::Backspace => {
1447 let _ = buf.pop();
1448 }
1449 KeyCode::Char(c) => {
1450 if buf.len() < 12 {
1451 buf.push(c.to_ascii_uppercase());
1452 }
1453 }
1454 _ => {}
1455 }
1456 return true;
1457 }
1458
1459 if self.channel_edit_mode {
1461 match key.code {
1462 KeyCode::Esc => {
1463 self.channel_edit_mode = false;
1464 self.channel_edit_buffer.clear();
1465 self.status_message = Some("Edit cancelled".into());
1466 }
1467 KeyCode::Tab => {
1468 self.channel_edit_field = self.channel_edit_field.next();
1469 self.channel_edit_buffer.clear();
1470 self.status_message = Some(format!(
1471 "Editing: {} (type value, Enter to apply)",
1472 self.channel_edit_field.label()
1473 ));
1474 }
1475 KeyCode::Backspace => {
1476 let _ = self.channel_edit_buffer.pop();
1477 }
1478 KeyCode::Char(c) => {
1479 self.channel_edit_buffer.push(c);
1480 }
1481 KeyCode::Enter => {
1482 let field = self.channel_edit_field;
1483 let buf = self.channel_edit_buffer.clone();
1484 self.apply_channel_edit(field, &buf);
1485 self.channel_edit_buffer.clear();
1486 }
1487 _ => {}
1488 }
1489 return true;
1490 }
1491
1492 if let InputMode::FreqInput(ref mut buf) = self.input_mode {
1494 match key.code {
1495 KeyCode::Esc => {
1496 self.input_mode = InputMode::Normal;
1497 }
1498 KeyCode::Enter => {
1499 if let Ok(mhz) = buf.parse::<f64>() {
1501 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1502 let hz = (mhz * 1_000_000.0) as u32;
1503 if let Some(ref tx) = self.cmd_tx {
1504 let _ = tx.send(crate::event::RadioCommand::TuneFreq {
1505 band: self.target_band,
1506 freq: hz,
1507 });
1508 }
1509 let band_label = if self.target_band == kenwood_thd75::types::Band::B {
1510 "B"
1511 } else {
1512 "A"
1513 };
1514 self.status_message =
1515 Some(format!("Tuning Band {band_label} to {mhz:.6} MHz..."));
1516 } else {
1517 self.status_message = Some(format!("Invalid frequency: {buf}"));
1518 }
1519 self.input_mode = InputMode::Normal;
1520 }
1521 KeyCode::Backspace => {
1522 let _ = buf.pop();
1523 }
1524 KeyCode::Char(c) if c.is_ascii_digit() || c == '.' => {
1525 buf.push(c);
1526 }
1527 _ => {}
1528 }
1529 return true;
1530 }
1531
1532 if key.code != KeyCode::Char('q') {
1536 self.quit_pending = false;
1537 }
1538
1539 match key.code {
1540 KeyCode::Char('q') => {
1541 if self.quit_pending {
1542 self.should_quit = true;
1543 } else if let McpState::Loaded { modified: true, .. } = &self.mcp {
1544 self.quit_pending = true;
1545 self.status_message =
1546 Some("Unsaved MCP changes! Press q again to confirm quit.".into());
1547 } else {
1548 self.should_quit = true;
1549 }
1550 true
1551 }
1552 KeyCode::Char('?') => {
1553 self.show_help = !self.show_help;
1554 true
1555 }
1556 KeyCode::Tab => {
1557 self.focus = self.focus.next();
1558 if self.focus == Pane::BandA {
1559 self.target_band = kenwood_thd75::types::Band::A;
1560 }
1561 if self.focus == Pane::BandB {
1562 self.target_band = kenwood_thd75::types::Band::B;
1563 }
1564 true
1565 }
1566 KeyCode::BackTab => {
1567 self.focus = self.focus.prev();
1568 if self.focus == Pane::BandA {
1569 self.target_band = kenwood_thd75::types::Band::A;
1570 }
1571 if self.focus == Pane::BandB {
1572 self.target_band = kenwood_thd75::types::Band::B;
1573 }
1574 true
1575 }
1576 KeyCode::Char('1') => {
1577 self.focus = Pane::BandA;
1578 self.target_band = kenwood_thd75::types::Band::A;
1579 true
1580 }
1581 KeyCode::Char('2') => {
1582 self.focus = Pane::BandB;
1583 self.target_band = kenwood_thd75::types::Band::B;
1584 true
1585 }
1586 KeyCode::Char('3') => {
1587 self.focus = Pane::Main;
1588 true
1589 }
1590 KeyCode::Char('4') => {
1591 self.focus = Pane::Detail;
1592 true
1593 }
1594 KeyCode::Char('c') => {
1595 self.main_view = MainView::Channels;
1596 self.focus = Pane::Main;
1597 true
1598 }
1599 KeyCode::Char('s') => {
1600 self.main_view = MainView::SettingsCat;
1601 self.focus = Pane::Main;
1602 true
1603 }
1604 KeyCode::Char('S') => {
1605 self.main_view = MainView::SettingsMcp;
1606 self.focus = Pane::Main;
1607 true
1608 }
1609 KeyCode::Char('e')
1611 if self.main_view == MainView::Channels
1612 && matches!(self.focus, Pane::Main | Pane::Detail)
1613 && matches!(self.mcp, McpState::Loaded { .. }) =>
1614 {
1615 let used = self.filtered_channels();
1616 if used.get(self.channel_list_index).is_some() {
1617 self.channel_edit_mode = true;
1618 self.channel_edit_field = ChannelEditField::Frequency;
1619 self.channel_edit_buffer.clear();
1620 self.status_message =
1621 Some("Edit mode: Tab=next field, Enter=apply, Esc=cancel".into());
1622 }
1623 true
1624 }
1625 KeyCode::Char('F') => {
1627 self.main_view = MainView::FmRadio;
1628 self.focus = Pane::Main;
1629 true
1630 }
1631 KeyCode::Char('f')
1633 if self.main_view == MainView::FmRadio && self.focus == Pane::Main =>
1634 {
1635 self.toggle_fm_radio();
1636 true
1637 }
1638 KeyCode::Char('a') => {
1639 if self.main_view == MainView::Aprs && self.focus == Pane::Main {
1640 self.toggle_aprs_mode();
1642 } else {
1643 self.main_view = MainView::Aprs;
1644 self.focus = Pane::Main;
1645 }
1646 true
1647 }
1648 KeyCode::Char('d') => {
1649 if self.main_view == MainView::DStar && self.focus == Pane::Main {
1650 self.toggle_dstar_mode();
1652 } else {
1653 self.main_view = MainView::DStar;
1654 self.focus = Pane::Main;
1655 }
1656 true
1657 }
1658 KeyCode::Char('p') if self.main_view == MainView::Gps && self.focus == Pane::Main => {
1659 self.toggle_gps_pc_output();
1660 true
1661 }
1662 KeyCode::Char('m') => {
1663 self.main_view = MainView::Mcp;
1664 self.focus = Pane::Main;
1665 true
1666 }
1667 KeyCode::Char('/')
1669 if self.focus == Pane::Main && self.main_view == MainView::Channels =>
1670 {
1671 self.input_mode = InputMode::Search(self.search_filter.clone());
1672 true
1673 }
1674 KeyCode::Char('f') if matches!(self.focus, Pane::BandA | Pane::BandB) => {
1676 self.input_mode = InputMode::FreqInput(String::new());
1677 true
1678 }
1679 KeyCode::Char('g') if self.focus == Pane::Main => {
1681 if self.main_view == MainView::Gps {
1682 self.toggle_gps();
1684 } else if self.main_view == MainView::Channels {
1685 self.channel_list_index = 0;
1687 } else {
1688 self.main_view = MainView::Gps;
1690 }
1691 true
1692 }
1693 KeyCode::Char('g') => {
1694 self.main_view = MainView::Gps;
1696 self.focus = Pane::Main;
1697 true
1698 }
1699 KeyCode::Char('G') if self.focus == Pane::Main => {
1700 self.channel_list_index = self.used_channel_count().saturating_sub(1);
1701 true
1702 }
1703 KeyCode::Char('j') | KeyCode::Down => {
1704 match self.focus {
1705 Pane::Main => match self.main_view {
1706 MainView::Channels => {
1707 let max = self.used_channel_count().saturating_sub(1);
1708 self.channel_list_index =
1709 self.channel_list_index.saturating_add(1).min(max);
1710 }
1711 MainView::SettingsCat => {
1712 let max = cat_settings().len().saturating_sub(1);
1713 self.settings_cat_index =
1714 self.settings_cat_index.saturating_add(1).min(max);
1715 }
1716 MainView::SettingsMcp => {
1717 let max = mcp_settings().len().saturating_sub(1);
1718 self.settings_mcp_index =
1719 self.settings_mcp_index.saturating_add(1).min(max);
1720 }
1721 MainView::Aprs => {
1722 let max = self.aprs_stations.len().saturating_sub(1);
1723 self.aprs_station_index =
1724 self.aprs_station_index.saturating_add(1).min(max);
1725 }
1726 MainView::DStar => {
1727 let max = self.dstar_last_heard.len().saturating_sub(1);
1728 self.dstar_last_heard_index =
1729 self.dstar_last_heard_index.saturating_add(1).min(max);
1730 }
1731 MainView::Gps | MainView::Mcp | MainView::FmRadio => {}
1732 },
1733 Pane::BandA => {
1734 if let Some(ref tx) = self.cmd_tx {
1735 let _ = tx.send(crate::event::RadioCommand::FreqDown(
1736 kenwood_thd75::types::Band::A,
1737 ));
1738 }
1739 }
1740 Pane::BandB => {
1741 if let Some(ref tx) = self.cmd_tx {
1742 let _ = tx.send(crate::event::RadioCommand::FreqDown(
1743 kenwood_thd75::types::Band::B,
1744 ));
1745 }
1746 }
1747 Pane::Detail => {}
1748 }
1749 true
1750 }
1751 KeyCode::Char('k') | KeyCode::Up => {
1752 match self.focus {
1753 Pane::Main => match self.main_view {
1754 MainView::Channels => {
1755 self.channel_list_index = self.channel_list_index.saturating_sub(1);
1756 }
1757 MainView::SettingsCat => {
1758 self.settings_cat_index = self.settings_cat_index.saturating_sub(1);
1759 }
1760 MainView::SettingsMcp => {
1761 self.settings_mcp_index = self.settings_mcp_index.saturating_sub(1);
1762 }
1763 MainView::Aprs => {
1764 self.aprs_station_index = self.aprs_station_index.saturating_sub(1);
1765 }
1766 MainView::DStar => {
1767 self.dstar_last_heard_index =
1768 self.dstar_last_heard_index.saturating_sub(1);
1769 }
1770 MainView::Gps | MainView::Mcp | MainView::FmRadio => {}
1771 },
1772 Pane::BandA => {
1773 if let Some(ref tx) = self.cmd_tx {
1774 let _ = tx.send(crate::event::RadioCommand::FreqUp(
1775 kenwood_thd75::types::Band::A,
1776 ));
1777 }
1778 }
1779 Pane::BandB => {
1780 if let Some(ref tx) = self.cmd_tx {
1781 let _ = tx.send(crate::event::RadioCommand::FreqUp(
1782 kenwood_thd75::types::Band::B,
1783 ));
1784 }
1785 }
1786 Pane::Detail => {}
1787 }
1788 true
1789 }
1790 KeyCode::Enter if self.focus == Pane::Main && self.main_view == MainView::Channels => {
1791 let used = self.filtered_channels();
1792 if let Some(&ch_num) = used.get(self.channel_list_index)
1793 && let Some(ref tx) = self.cmd_tx
1794 {
1795 let band_label = if self.target_band == kenwood_thd75::types::Band::B {
1796 "B"
1797 } else {
1798 "A"
1799 };
1800 let _ = tx.send(crate::event::RadioCommand::TuneChannel {
1801 band: self.target_band,
1802 channel: ch_num,
1803 });
1804 self.status_message =
1805 Some(format!("Tuning Band {band_label} to channel {ch_num}..."));
1806 }
1807 true
1808 }
1809 KeyCode::Enter
1811 if self.focus == Pane::Main
1812 && matches!(
1813 self.main_view,
1814 MainView::SettingsCat | MainView::SettingsMcp
1815 ) =>
1816 {
1817 self.toggle_setting();
1818 true
1819 }
1820 KeyCode::Char('+' | '=')
1821 if self.focus == Pane::Main
1822 && matches!(
1823 self.main_view,
1824 MainView::SettingsCat | MainView::SettingsMcp
1825 ) =>
1826 {
1827 self.adjust_setting(1);
1828 true
1829 }
1830 KeyCode::Char('-')
1831 if self.focus == Pane::Main
1832 && matches!(
1833 self.main_view,
1834 MainView::SettingsCat | MainView::SettingsMcp
1835 ) =>
1836 {
1837 self.adjust_setting(-1);
1838 true
1839 }
1840 KeyCode::Char('p') if matches!(self.focus, Pane::BandA | Pane::BandB) => {
1841 let band = if self.focus == Pane::BandA {
1842 kenwood_thd75::types::Band::A
1843 } else {
1844 kenwood_thd75::types::Band::B
1845 };
1846 let current = if self.focus == Pane::BandA {
1847 &self.state.band_a.power_level
1848 } else {
1849 &self.state.band_b.power_level
1850 };
1851 let next = match current {
1852 PowerLevel::High => PowerLevel::Medium,
1853 PowerLevel::Medium => PowerLevel::Low,
1854 PowerLevel::Low => PowerLevel::ExtraLow,
1855 PowerLevel::ExtraLow => PowerLevel::High,
1856 };
1857 if let Some(ref tx) = self.cmd_tx {
1858 let _ = tx.send(crate::event::RadioCommand::SetPower { band, level: next });
1859 }
1860 true
1861 }
1862 KeyCode::Char('t') if matches!(self.focus, Pane::BandA | Pane::BandB) => {
1864 let (band, cur) = if self.focus == Pane::BandA {
1865 (kenwood_thd75::types::Band::A, self.state.band_a.attenuator)
1866 } else {
1867 (kenwood_thd75::types::Band::B, self.state.band_b.attenuator)
1868 };
1869 if let Some(ref tx) = self.cmd_tx {
1870 let _ = tx.send(crate::event::RadioCommand::SetAttenuator {
1871 band,
1872 enabled: !cur,
1873 });
1874 self.status_message = Some(format!("Attenuator → {}", on_off(!cur)));
1875 }
1876 true
1877 }
1878 KeyCode::Char('[') if matches!(self.focus, Pane::BandA | Pane::BandB) => {
1880 let (band, cur) = if self.focus == Pane::BandA {
1881 (
1882 kenwood_thd75::types::Band::A,
1883 self.state.band_a.squelch.as_u8(),
1884 )
1885 } else {
1886 (
1887 kenwood_thd75::types::Band::B,
1888 self.state.band_b.squelch.as_u8(),
1889 )
1890 };
1891 let next = cur.saturating_sub(1);
1892 if let (Some(tx), Ok(level)) = (&self.cmd_tx, SquelchLevel::new(next)) {
1893 let _ = tx.send(crate::event::RadioCommand::SetSquelch { band, level });
1894 self.status_message = Some(format!("Squelch → {next}"));
1895 }
1896 true
1897 }
1898 KeyCode::Char(']') if matches!(self.focus, Pane::BandA | Pane::BandB) => {
1899 let (band, cur) = if self.focus == Pane::BandA {
1900 (
1901 kenwood_thd75::types::Band::A,
1902 self.state.band_a.squelch.as_u8(),
1903 )
1904 } else {
1905 (
1906 kenwood_thd75::types::Band::B,
1907 self.state.band_b.squelch.as_u8(),
1908 )
1909 };
1910 let next = cur.saturating_add(1).min(6);
1911 if let (Some(tx), Ok(level)) = (&self.cmd_tx, SquelchLevel::new(next)) {
1912 let _ = tx.send(crate::event::RadioCommand::SetSquelch { band, level });
1913 self.status_message = Some(format!("Squelch → {next}"));
1914 }
1915 true
1916 }
1917 KeyCode::Esc => {
1918 if self.show_help {
1919 self.show_help = false;
1920 return true;
1921 }
1922 if !self.search_filter.is_empty() {
1924 self.search_filter.clear();
1925 self.channel_list_index = 0;
1926 return true;
1927 }
1928 false
1929 }
1930 KeyCode::Char('M')
1932 if self.main_view == MainView::Aprs
1933 && self.focus == Pane::Main
1934 && self.aprs_mode == AprsMode::Active
1935 && !self.aprs_stations.is_empty() =>
1936 {
1937 self.aprs_compose = Some(String::new());
1938 true
1939 }
1940 KeyCode::Char('b')
1942 if self.main_view == MainView::Aprs
1943 && self.focus == Pane::Main
1944 && self.aprs_mode == AprsMode::Active =>
1945 {
1946 if let Some(ref tx) = self.cmd_tx {
1947 let _ = tx.send(crate::event::RadioCommand::BeaconPosition {
1949 lat: 0.0,
1950 lon: 0.0,
1951 comment: String::new(),
1952 });
1953 self.status_message = Some("Beacon sent".into());
1954 }
1955 true
1956 }
1957 KeyCode::Char('r') if self.main_view == MainView::Mcp => {
1958 if matches!(self.mcp, McpState::Idle | McpState::Loaded { .. }) {
1959 self.mcp = McpState::Reading {
1960 page: 0,
1961 total: kenwood_thd75::protocol::programming::TOTAL_PAGES,
1962 };
1963 self.status_message = Some("Starting MCP read...".into());
1964 if let Some(ref tx) = self.cmd_tx {
1965 let _ = tx.send(crate::event::RadioCommand::ReadMemory);
1966 }
1967 }
1968 true
1969 }
1970 KeyCode::Char('w') if self.main_view == MainView::Mcp => {
1971 if let McpState::Loaded { ref image, .. } = self.mcp {
1972 let data = image.as_raw().to_vec();
1973 self.mcp = McpState::Writing {
1974 page: 0,
1975 total: kenwood_thd75::protocol::programming::TOTAL_PAGES,
1976 };
1977 self.status_message = Some("Starting MCP write...".into());
1978 if let Some(ref tx) = self.cmd_tx {
1979 let _ = tx.send(crate::event::RadioCommand::WriteMemory(data));
1980 }
1981 }
1982 true
1983 }
1984 KeyCode::Char('C')
1986 if self.main_view == MainView::DStar
1987 && self.focus == Pane::Main
1988 && self.dstar_mode == DStarMode::Inactive =>
1989 {
1990 if let Some(ref tx) = self.cmd_tx {
1991 let _ = tx.send(crate::event::RadioCommand::SetCQ);
1992 self.status_message = Some("URCALL set to CQCQCQ".into());
1993 }
1994 true
1995 }
1996 KeyCode::Char('u')
1998 if self.main_view == MainView::DStar
1999 && self.focus == Pane::Main
2000 && self.dstar_mode == DStarMode::Inactive =>
2001 {
2002 self.dstar_urcall_input = Some(String::new());
2003 true
2004 }
2005 KeyCode::Char('r')
2007 if self.main_view == MainView::DStar
2008 && self.focus == Pane::Main
2009 && self.dstar_mode == DStarMode::Inactive =>
2010 {
2011 self.dstar_reflector_input = Some(String::new());
2012 true
2013 }
2014 KeyCode::Char('U')
2016 if self.main_view == MainView::DStar
2017 && self.focus == Pane::Main
2018 && self.dstar_mode == DStarMode::Inactive =>
2019 {
2020 if let Some(ref tx) = self.cmd_tx {
2021 let _ = tx.send(crate::event::RadioCommand::DisconnectReflector);
2022 self.status_message = Some("Unlinking reflector...".into());
2023 }
2024 true
2025 }
2026 _ => false,
2027 }
2028 }
2029
2030 fn toggle_setting(&mut self) {
2032 let (rows, idx) = if self.main_view == MainView::SettingsCat {
2033 (cat_settings(), self.settings_cat_index)
2034 } else {
2035 (mcp_settings(), self.settings_mcp_index)
2036 };
2037 let row = match rows.get(idx) {
2038 Some(r) => *r,
2039 None => return,
2040 };
2041
2042 if let Some(ref tx) = self.cmd_tx.clone() {
2044 match row {
2045 SettingRow::Lock => {
2046 let next = !self.state.lock;
2047 let _ = tx.send(crate::event::RadioCommand::SetLock(next));
2048 self.status_message = Some(format!("Lock → {}", on_off(next)));
2049 return;
2050 }
2051 SettingRow::DualBand => {
2052 let next = !self.state.dual_band;
2053 let _ = tx.send(crate::event::RadioCommand::SetDualBand(next));
2054 self.status_message = Some(format!("Dual band → {}", on_off(next)));
2055 return;
2056 }
2057 SettingRow::Bluetooth => {
2058 let next = !self.state.bluetooth;
2059 let _ = tx.send(crate::event::RadioCommand::SetBluetooth(next));
2060 self.status_message = Some(format!("Bluetooth → {}", on_off(next)));
2061 return;
2062 }
2063 SettingRow::VoxEnabled => {
2064 let next = !self.state.vox;
2065 let _ = tx.send(crate::event::RadioCommand::SetVox(next));
2066 self.status_message = Some(format!("VOX → {}", on_off(next)));
2067 return;
2068 }
2069 SettingRow::AttenuatorA => {
2070 let next = !self.state.band_a.attenuator;
2071 let _ = tx.send(crate::event::RadioCommand::SetAttenuator {
2072 band: kenwood_thd75::types::Band::A,
2073 enabled: next,
2074 });
2075 self.status_message = Some(format!("Atten A → {}", on_off(next)));
2076 return;
2077 }
2078 SettingRow::AttenuatorB => {
2079 let next = !self.state.band_b.attenuator;
2080 let _ = tx.send(crate::event::RadioCommand::SetAttenuator {
2081 band: kenwood_thd75::types::Band::B,
2082 enabled: next,
2083 });
2084 self.status_message = Some(format!("Atten B → {}", on_off(next)));
2085 return;
2086 }
2087 SettingRow::FmRadio => {
2088 let _ = tx.send(crate::event::RadioCommand::SetFmRadio(true));
2089 self.status_message =
2090 Some("FM Radio: enabled (read-back not available)".into());
2091 return;
2092 }
2093 SettingRow::GpsEnabled => {
2094 let next = !self.state.gps_enabled;
2095 let _ = tx.send(crate::event::RadioCommand::SetGpsConfig(next, false));
2096 self.status_message = Some(format!("GPS → {}", on_off(next)));
2097 return;
2098 }
2099 SettingRow::GpsPcOutput => {
2100 self.status_message =
2101 Some("GPS PC Output: use SetGpsSentences — not yet wired".into());
2102 return;
2103 }
2104 SettingRow::AutoInfo => {
2105 self.status_message = Some("Auto Info: not yet wired".into());
2106 return;
2107 }
2108 _ => {}
2109 }
2110 }
2111
2112 if row.is_numeric() {
2114 self.status_message = Some(format!("{}: use +/- to adjust", row.label()));
2115 return;
2116 }
2117
2118 let Some(tx) = self.cmd_tx.clone() else {
2120 return;
2121 };
2122
2123 let McpState::Loaded { ref mut image, .. } = self.mcp else {
2124 self.status_message = Some(format!("{}: load MCP data first (m → r)", row.label()));
2125 return;
2126 };
2127
2128 macro_rules! toggle_bool {
2129 ($getter:ident, $setter:ident, $label:expr) => {{
2130 let new_val = !image.settings().$getter();
2131 if let Some((offset, value)) = image.modify_setting(|w| w.$setter(new_val)) {
2132 let _ = tx.send(crate::event::RadioCommand::McpWriteByte { offset, value });
2133 self.status_message =
2134 Some(format!("{} → {} — applying...", $label, on_off(new_val)));
2135 }
2136 }};
2137 }
2138
2139 match row {
2140 SettingRow::TxInhibit => toggle_bool!(tx_inhibit, set_tx_inhibit, "TX Inhibit"),
2141 SettingRow::BeatShift => toggle_bool!(beat_shift, set_beat_shift, "Beat Shift"),
2142 SettingRow::VoxTxOnBusy => {
2143 toggle_bool!(vox_tx_on_busy, set_vox_tx_on_busy, "VOX TX Busy");
2144 }
2145 SettingRow::CwBreakIn => toggle_bool!(cw_break_in, set_cw_break_in, "CW Break-In"),
2146 SettingRow::DtmfTxHold => {
2147 toggle_bool!(dtmf_tx_hold, set_dtmf_tx_hold, "DTMF TX Hold");
2148 }
2149 SettingRow::RepeaterAutoOffset => {
2150 toggle_bool!(
2151 repeater_auto_offset,
2152 set_repeater_auto_offset,
2153 "Rpt Auto Offset"
2154 );
2155 }
2156 SettingRow::LockKeyA => toggle_bool!(lock_key_a, set_lock_key_a, "Lock Key A"),
2157 SettingRow::LockKeyB => toggle_bool!(lock_key_b, set_lock_key_b, "Lock Key B"),
2158 SettingRow::LockKeyC => toggle_bool!(lock_key_c, set_lock_key_c, "Lock Key C"),
2159 SettingRow::LockPtt => toggle_bool!(lock_key_ptt, set_lock_key_ptt, "Lock PTT"),
2160 SettingRow::AprsLock => toggle_bool!(aprs_lock, set_aprs_lock, "APRS Lock"),
2161 SettingRow::Announce => toggle_bool!(announce, set_announce, "Announce"),
2162 SettingRow::KeyBeep => toggle_bool!(key_beep, set_key_beep, "Key Beep"),
2163 SettingRow::VolumeLock => toggle_bool!(volume_lock, set_volume_lock, "Vol Lock"),
2164 SettingRow::BtAutoConnect => {
2165 toggle_bool!(bt_auto_connect, set_bt_auto_connect, "BT Auto Connect");
2166 }
2167 SettingRow::UsbAudioOutput => {
2168 toggle_bool!(usb_audio_output, set_usb_audio_output, "USB Audio Out");
2169 }
2170 SettingRow::InternetLink => {
2171 toggle_bool!(internet_link, set_internet_link, "Internet Link");
2172 }
2173 SettingRow::PowerOnMessageFlag => {
2174 toggle_bool!(
2175 power_on_message_flag,
2176 set_power_on_message_flag,
2177 "PowerOn Msg"
2178 );
2179 }
2180 SettingRow::BatterySaver => {
2181 toggle_bool!(battery_saver, set_battery_saver, "Battery Saver");
2182 }
2183 _ => {
2184 self.status_message = Some(format!("{}: use +/- to adjust", row.label()));
2185 }
2186 }
2187 }
2188
2189 #[allow(clippy::too_many_lines, clippy::cognitive_complexity)]
2191 fn adjust_setting(&mut self, delta: i8) {
2192 let (rows, idx) = if self.main_view == MainView::SettingsCat {
2193 (cat_settings(), self.settings_cat_index)
2194 } else {
2195 (mcp_settings(), self.settings_mcp_index)
2196 };
2197 let row = match rows.get(idx) {
2198 Some(r) => *r,
2199 None => return,
2200 };
2201
2202 if let Some(ref tx) = self.cmd_tx.clone() {
2204 match row {
2205 SettingRow::SquelchA => {
2206 let cur = self.state.band_a.squelch.as_u8();
2207 let next = if delta > 0 {
2208 cur.saturating_add(1).min(6)
2209 } else {
2210 cur.saturating_sub(1)
2211 };
2212 if let Ok(level) = SquelchLevel::new(next) {
2213 let _ = tx.send(crate::event::RadioCommand::SetSquelch {
2214 band: kenwood_thd75::types::Band::A,
2215 level,
2216 });
2217 }
2218 self.status_message = Some(format!("Squelch A → {next}"));
2219 return;
2220 }
2221 SettingRow::SquelchB => {
2222 let cur = self.state.band_b.squelch.as_u8();
2223 let next = if delta > 0 {
2224 cur.saturating_add(1).min(6)
2225 } else {
2226 cur.saturating_sub(1)
2227 };
2228 if let Ok(level) = SquelchLevel::new(next) {
2229 let _ = tx.send(crate::event::RadioCommand::SetSquelch {
2230 band: kenwood_thd75::types::Band::B,
2231 level,
2232 });
2233 }
2234 self.status_message = Some(format!("Squelch B → {next}"));
2235 return;
2236 }
2237 SettingRow::VoxGain => {
2238 let cur = self.state.vox_gain.as_u8();
2239 let next = if delta > 0 {
2240 cur.saturating_add(1).min(9)
2241 } else {
2242 cur.saturating_sub(1)
2243 };
2244 if let Ok(gain) = VoxGain::new(next) {
2245 let _ = tx.send(crate::event::RadioCommand::SetVoxGain(gain));
2246 }
2247 self.status_message = Some(format!("VOX Gain → {next}"));
2248 return;
2249 }
2250 SettingRow::VoxDelay => {
2251 let cur = self.state.vox_delay.as_u8();
2252 let next = if delta > 0 {
2253 cur.saturating_add(1).min(30)
2254 } else {
2255 cur.saturating_sub(1)
2256 };
2257 if let Ok(delay) = VoxDelay::new(next) {
2258 let _ = tx.send(crate::event::RadioCommand::SetVoxDelay(delay));
2259 }
2260 self.status_message = Some(format!("VOX Delay → {next}"));
2261 return;
2262 }
2263 SettingRow::StepSizeA => {
2264 use kenwood_thd75::types::StepSize;
2265 let steps = [
2266 StepSize::Hz5000,
2267 StepSize::Hz6250,
2268 StepSize::Hz8330,
2269 StepSize::Hz9000,
2270 StepSize::Hz10000,
2271 StepSize::Hz12500,
2272 StepSize::Hz15000,
2273 StepSize::Hz20000,
2274 StepSize::Hz25000,
2275 StepSize::Hz30000,
2276 StepSize::Hz50000,
2277 StepSize::Hz100000,
2278 ];
2279 let cur_idx = self
2280 .state
2281 .band_a
2282 .step_size
2283 .and_then(|s| steps.iter().position(|&x| x == s))
2284 .unwrap_or(0);
2285 let next_idx = if delta > 0 {
2286 (cur_idx + 1).min(steps.len() - 1)
2287 } else {
2288 cur_idx.saturating_sub(1)
2289 };
2290 let next = steps[next_idx];
2291 let _ = tx.send(crate::event::RadioCommand::SetStepSize {
2292 band: kenwood_thd75::types::Band::A,
2293 step: next,
2294 });
2295 self.status_message = Some(format!("Step A → {next}"));
2296 return;
2297 }
2298 SettingRow::StepSizeB => {
2299 use kenwood_thd75::types::StepSize;
2300 let steps = [
2301 StepSize::Hz5000,
2302 StepSize::Hz6250,
2303 StepSize::Hz8330,
2304 StepSize::Hz9000,
2305 StepSize::Hz10000,
2306 StepSize::Hz12500,
2307 StepSize::Hz15000,
2308 StepSize::Hz20000,
2309 StepSize::Hz25000,
2310 StepSize::Hz30000,
2311 StepSize::Hz50000,
2312 StepSize::Hz100000,
2313 ];
2314 let cur_idx = self
2315 .state
2316 .band_b
2317 .step_size
2318 .and_then(|s| steps.iter().position(|&x| x == s))
2319 .unwrap_or(0);
2320 let next_idx = if delta > 0 {
2321 (cur_idx + 1).min(steps.len() - 1)
2322 } else {
2323 cur_idx.saturating_sub(1)
2324 };
2325 let next = steps[next_idx];
2326 let _ = tx.send(crate::event::RadioCommand::SetStepSize {
2327 band: kenwood_thd75::types::Band::B,
2328 step: next,
2329 });
2330 self.status_message = Some(format!("Step B → {next}"));
2331 return;
2332 }
2333 SettingRow::FineStep => {
2334 self.status_message = Some("Fine Step: read-only".into());
2335 return;
2336 }
2337 SettingRow::FilterWidthSsb
2338 | SettingRow::FilterWidthCw
2339 | SettingRow::FilterWidthAm => {
2340 self.status_message = Some("Filter Width: read-only".into());
2341 return;
2342 }
2343 SettingRow::ScanResumeCat => {
2344 use kenwood_thd75::types::ScanResumeMethod;
2345 let methods = [
2346 ScanResumeMethod::TimeOperated,
2347 ScanResumeMethod::CarrierOperated,
2348 ScanResumeMethod::Seek,
2349 ];
2350 let cur_idx = self
2351 .state
2352 .scan_resume_cat
2353 .and_then(|m| methods.iter().position(|&x| x == m))
2354 .unwrap_or(0);
2355 let next_idx = if delta > 0 {
2356 (cur_idx + 1) % methods.len()
2357 } else {
2358 (cur_idx + methods.len() - 1) % methods.len()
2359 };
2360 let next = methods[next_idx];
2361 let _ = tx.send(crate::event::RadioCommand::SetScanResumeCat(next));
2362 self.state.scan_resume_cat = Some(next);
2363 let label = match next {
2364 ScanResumeMethod::TimeOperated => "Time",
2365 ScanResumeMethod::CarrierOperated => "Carrier",
2366 ScanResumeMethod::Seek => "Seek",
2367 };
2368 self.status_message = Some(format!("Scan Resume → {label}"));
2369 return;
2370 }
2371 SettingRow::PowerA => {
2372 let next = match self.state.band_a.power_level {
2373 PowerLevel::High => PowerLevel::Medium,
2374 PowerLevel::Medium => PowerLevel::Low,
2375 PowerLevel::Low => PowerLevel::ExtraLow,
2376 PowerLevel::ExtraLow => PowerLevel::High,
2377 };
2378 let _ = tx.send(crate::event::RadioCommand::SetPower {
2379 band: kenwood_thd75::types::Band::A,
2380 level: next,
2381 });
2382 self.status_message = Some(format!("Power A → {next}"));
2383 return;
2384 }
2385 SettingRow::PowerB => {
2386 let next = match self.state.band_b.power_level {
2387 PowerLevel::High => PowerLevel::Medium,
2388 PowerLevel::Medium => PowerLevel::Low,
2389 PowerLevel::Low => PowerLevel::ExtraLow,
2390 PowerLevel::ExtraLow => PowerLevel::High,
2391 };
2392 let _ = tx.send(crate::event::RadioCommand::SetPower {
2393 band: kenwood_thd75::types::Band::B,
2394 level: next,
2395 });
2396 self.status_message = Some(format!("Power B → {next}"));
2397 return;
2398 }
2399 SettingRow::ModeA => {
2400 use kenwood_thd75::types::Mode;
2401 let next = match self.state.band_a.mode {
2402 Mode::Fm => Mode::Nfm,
2403 Mode::Nfm => Mode::Am,
2404 Mode::Am => Mode::Lsb,
2405 Mode::Lsb => Mode::Usb,
2406 Mode::Usb => Mode::Cw,
2407 Mode::Cw => Mode::Dv,
2408 Mode::Dv => Mode::Dr,
2409 Mode::Dr => Mode::Wfm,
2410 Mode::Wfm => Mode::CwReverse,
2411 Mode::CwReverse => Mode::Fm,
2412 };
2413 let _ = tx.send(crate::event::RadioCommand::SetMode {
2414 band: kenwood_thd75::types::Band::A,
2415 mode: next,
2416 });
2417 self.status_message = Some(format!("Mode A → {next}"));
2418 return;
2419 }
2420 SettingRow::ModeB => {
2421 use kenwood_thd75::types::Mode;
2422 let next = match self.state.band_b.mode {
2423 Mode::Fm => Mode::Nfm,
2424 Mode::Nfm => Mode::Am,
2425 Mode::Am => Mode::Lsb,
2426 Mode::Lsb => Mode::Usb,
2427 Mode::Usb => Mode::Cw,
2428 Mode::Cw => Mode::Dv,
2429 Mode::Dv => Mode::Dr,
2430 Mode::Dr => Mode::Wfm,
2431 Mode::Wfm => Mode::CwReverse,
2432 Mode::CwReverse => Mode::Fm,
2433 };
2434 let _ = tx.send(crate::event::RadioCommand::SetMode {
2435 band: kenwood_thd75::types::Band::B,
2436 mode: next,
2437 });
2438 self.status_message = Some(format!("Mode B → {next}"));
2439 return;
2440 }
2441 SettingRow::ActiveBand => {
2442 self.status_message =
2443 Some("Active Band: use BC command — not yet wired".into());
2444 return;
2445 }
2446 SettingRow::VfoMemModeA => {
2447 self.status_message = Some("VFO/Mem A: use VM command — not yet wired".into());
2448 return;
2449 }
2450 SettingRow::VfoMemModeB => {
2451 self.status_message = Some("VFO/Mem B: use VM command — not yet wired".into());
2452 return;
2453 }
2454 SettingRow::TncBaud => {
2455 let baud = if delta > 0 {
2456 kenwood_thd75::types::TncBaud::Bps9600
2457 } else {
2458 kenwood_thd75::types::TncBaud::Bps1200
2459 };
2460 let _ = tx.send(crate::event::RadioCommand::SetTncBaud(baud));
2461 self.status_message = Some(format!("TNC Baud → {baud}"));
2462 return;
2463 }
2464 SettingRow::BeaconType => {
2465 let cur = u8::from(self.state.beacon_type);
2466 let next = if delta > 0 {
2467 cur.saturating_add(1).min(4)
2468 } else {
2469 cur.saturating_sub(1)
2470 };
2471 if let Ok(mode) = BeaconMode::try_from(next) {
2472 let _ = tx.send(crate::event::RadioCommand::SetBeaconType(mode));
2473 self.status_message = Some(format!("Beacon Type → {mode}"));
2474 }
2475 return;
2476 }
2477 SettingRow::CallsignSlot => {
2478 self.status_message =
2479 Some("Callsign Slot: not yet polled — cannot adjust".into());
2480 return;
2481 }
2482 SettingRow::DstarSlot => {
2483 self.status_message =
2484 Some("D-STAR Slot: not yet polled — cannot adjust".into());
2485 return;
2486 }
2487 _ => {}
2488 }
2489 }
2490
2491 let Some(tx) = self.cmd_tx.clone() else {
2493 return;
2494 };
2495
2496 let McpState::Loaded { ref mut image, .. } = self.mcp else {
2497 self.status_message = Some(format!("{}: load MCP data first (m → r)", row.label()));
2498 return;
2499 };
2500
2501 macro_rules! adjust_numeric {
2504 ($getter:ident, $setter:ident, $label:expr, $image:expr, $delta:expr, $tx:expr) => {{
2505 let new_val = $image.settings().$getter().saturating_add_signed($delta);
2506 if let Some((offset, value)) = $image.modify_setting(|w| w.$setter(new_val)) {
2507 let _ = $tx.send(crate::event::RadioCommand::McpWriteByte { offset, value });
2508 self.status_message =
2509 Some(format!("{} → {} — applying...", $label, new_val));
2510 }
2511 }};
2512 }
2513
2514 match row {
2515 SettingRow::FmNarrow => {
2516 adjust_numeric!(fm_narrow, set_fm_narrow, "FM Narrow", image, delta, tx);
2517 }
2518 SettingRow::SsbHighCut => {
2519 adjust_numeric!(
2520 ssb_high_cut,
2521 set_ssb_high_cut,
2522 "SSB High Cut",
2523 image,
2524 delta,
2525 tx
2526 );
2527 }
2528 SettingRow::CwHighCut => {
2529 adjust_numeric!(
2530 cw_high_cut,
2531 set_cw_high_cut,
2532 "CW High Cut",
2533 image,
2534 delta,
2535 tx
2536 );
2537 }
2538 SettingRow::AmHighCut => {
2539 adjust_numeric!(
2540 am_high_cut,
2541 set_am_high_cut,
2542 "AM High Cut",
2543 image,
2544 delta,
2545 tx
2546 );
2547 }
2548 SettingRow::AutoFilter => {
2549 adjust_numeric!(
2550 auto_filter,
2551 set_auto_filter,
2552 "Auto Filter",
2553 image,
2554 delta,
2555 tx
2556 );
2557 }
2558 SettingRow::ScanResume => {
2559 adjust_numeric!(
2560 scan_resume,
2561 set_scan_resume,
2562 "Scan Resume",
2563 image,
2564 delta,
2565 tx
2566 );
2567 }
2568 SettingRow::DigitalScanResume => {
2569 adjust_numeric!(
2570 digital_scan_resume,
2571 set_digital_scan_resume,
2572 "Dig Scan Resume",
2573 image,
2574 delta,
2575 tx
2576 );
2577 }
2578 SettingRow::ScanRestartTime => {
2579 adjust_numeric!(
2580 scan_restart_time,
2581 set_scan_restart_time,
2582 "Scan Restart Time",
2583 image,
2584 delta,
2585 tx
2586 );
2587 }
2588 SettingRow::ScanRestartCarrier => {
2589 adjust_numeric!(
2590 scan_restart_carrier,
2591 set_scan_restart_carrier,
2592 "Scan Restart Carrier",
2593 image,
2594 delta,
2595 tx
2596 );
2597 }
2598 SettingRow::TimeoutTimer => {
2599 adjust_numeric!(
2600 timeout_timer,
2601 set_timeout_timer,
2602 "Timeout Timer",
2603 image,
2604 delta,
2605 tx
2606 );
2607 }
2608 SettingRow::CwDelayTime => {
2609 adjust_numeric!(
2610 cw_delay_time,
2611 set_cw_delay_time,
2612 "CW Delay",
2613 image,
2614 delta,
2615 tx
2616 );
2617 }
2618 SettingRow::CwPitch => {
2619 adjust_numeric!(cw_pitch, set_cw_pitch, "CW Pitch", image, delta, tx);
2620 }
2621 SettingRow::DtmfSpeed => {
2622 adjust_numeric!(dtmf_speed, set_dtmf_speed, "DTMF Speed", image, delta, tx);
2623 }
2624 SettingRow::DtmfPauseTime => {
2625 adjust_numeric!(
2626 dtmf_pause_time,
2627 set_dtmf_pause_time,
2628 "DTMF Pause",
2629 image,
2630 delta,
2631 tx
2632 );
2633 }
2634 SettingRow::RepeaterCallKey => {
2635 adjust_numeric!(
2636 repeater_call_key,
2637 set_repeater_call_key,
2638 "Call Key",
2639 image,
2640 delta,
2641 tx
2642 );
2643 }
2644 SettingRow::MicSensitivity => {
2645 adjust_numeric!(
2646 mic_sensitivity,
2647 set_mic_sensitivity,
2648 "Mic Sens",
2649 image,
2650 delta,
2651 tx
2652 );
2653 }
2654 SettingRow::PfKey1 => {
2655 adjust_numeric!(pf_key1, set_pf_key1, "PF Key 1", image, delta, tx);
2656 }
2657 SettingRow::PfKey2 => {
2658 adjust_numeric!(pf_key2, set_pf_key2, "PF Key 2", image, delta, tx);
2659 }
2660 SettingRow::KeyLockType => {
2661 let new_val = image
2662 .settings()
2663 .key_lock_type_raw()
2664 .saturating_add_signed(delta)
2665 .min(2);
2666 if let Some((offset, value)) =
2667 image.modify_setting(|w| w.set_key_lock_type_raw(new_val))
2668 {
2669 let _ = tx.send(crate::event::RadioCommand::McpWriteByte { offset, value });
2670 self.status_message = Some(format!("Lock Type → {new_val} — applying..."));
2671 }
2672 }
2673 SettingRow::DualDisplaySize => {
2674 adjust_numeric!(
2675 dual_display_size,
2676 set_dual_display_size,
2677 "Dual Display",
2678 image,
2679 delta,
2680 tx
2681 );
2682 }
2683 SettingRow::DisplayArea => {
2684 adjust_numeric!(
2685 display_area,
2686 set_display_area,
2687 "Display Area",
2688 image,
2689 delta,
2690 tx
2691 );
2692 }
2693 SettingRow::InfoLine => {
2694 adjust_numeric!(info_line, set_info_line, "Info Line", image, delta, tx);
2695 }
2696 SettingRow::BacklightControl => {
2697 adjust_numeric!(
2698 backlight_control,
2699 set_backlight_control,
2700 "Backlight Ctrl",
2701 image,
2702 delta,
2703 tx
2704 );
2705 }
2706 SettingRow::BacklightTimer => {
2707 adjust_numeric!(
2708 backlight_timer,
2709 set_backlight_timer,
2710 "Backlight Timer",
2711 image,
2712 delta,
2713 tx
2714 );
2715 }
2716 SettingRow::DisplayHoldTime => {
2717 adjust_numeric!(
2718 display_hold_time,
2719 set_display_hold_time,
2720 "Display Hold",
2721 image,
2722 delta,
2723 tx
2724 );
2725 }
2726 SettingRow::DisplayMethod => {
2727 adjust_numeric!(
2728 display_method,
2729 set_display_method,
2730 "Display Method",
2731 image,
2732 delta,
2733 tx
2734 );
2735 }
2736 SettingRow::PowerOnDisplay => {
2737 adjust_numeric!(
2738 power_on_display,
2739 set_power_on_display,
2740 "PowerOn Display",
2741 image,
2742 delta,
2743 tx
2744 );
2745 }
2746 SettingRow::EmrVolumeLevel => {
2747 adjust_numeric!(
2748 emr_volume_level,
2749 set_emr_volume_level,
2750 "EMR Vol",
2751 image,
2752 delta,
2753 tx
2754 );
2755 }
2756 SettingRow::AutoMuteReturnTime => {
2757 adjust_numeric!(
2758 auto_mute_return_time,
2759 set_auto_mute_return_time,
2760 "Auto Mute",
2761 image,
2762 delta,
2763 tx
2764 );
2765 }
2766 SettingRow::BeepVolume => {
2767 let cur = image.settings().beep_volume();
2768 let new_val = if delta > 0 {
2769 cur.saturating_add(1).min(7)
2770 } else {
2771 cur.saturating_sub(1).max(1)
2772 };
2773 if let Some((offset, value)) = image.modify_setting(|w| w.set_beep_volume(new_val))
2774 {
2775 let _ = tx.send(crate::event::RadioCommand::McpWriteByte { offset, value });
2776 self.status_message = Some(format!("Beep Vol → {new_val} — applying..."));
2777 }
2778 }
2779 SettingRow::VoiceLanguage => {
2780 adjust_numeric!(
2781 voice_language,
2782 set_voice_language,
2783 "Voice Lang",
2784 image,
2785 delta,
2786 tx
2787 );
2788 }
2789 SettingRow::VoiceVolume => {
2790 adjust_numeric!(
2791 voice_volume,
2792 set_voice_volume,
2793 "Voice Vol",
2794 image,
2795 delta,
2796 tx
2797 );
2798 }
2799 SettingRow::VoiceSpeed => {
2800 adjust_numeric!(
2801 voice_speed,
2802 set_voice_speed,
2803 "Voice Speed",
2804 image,
2805 delta,
2806 tx
2807 );
2808 }
2809 SettingRow::SpeedDistanceUnit => {
2810 let new_val = if delta > 0 {
2811 image
2812 .settings()
2813 .speed_distance_unit_raw()
2814 .saturating_add(1)
2815 .min(2)
2816 } else {
2817 image.settings().speed_distance_unit_raw().saturating_sub(1)
2818 };
2819 if let Some((offset, value)) =
2820 image.modify_setting(|w| w.set_speed_distance_unit_raw(new_val))
2821 {
2822 let _ = tx.send(crate::event::RadioCommand::McpWriteByte { offset, value });
2823 self.status_message = Some(format!(
2824 "Speed Unit → {} — applying...",
2825 ["mph", "km/h", "knots"]
2826 .get(new_val as usize)
2827 .unwrap_or(&"?")
2828 ));
2829 }
2830 }
2831 SettingRow::AltitudeRainUnit => {
2832 let new_val = if delta > 0 {
2833 image
2834 .settings()
2835 .altitude_rain_unit_raw()
2836 .saturating_add(1)
2837 .min(1)
2838 } else {
2839 image.settings().altitude_rain_unit_raw().saturating_sub(1)
2840 };
2841 if let Some((offset, value)) =
2842 image.modify_setting(|w| w.set_altitude_rain_unit_raw(new_val))
2843 {
2844 let _ = tx.send(crate::event::RadioCommand::McpWriteByte { offset, value });
2845 self.status_message = Some(format!(
2846 "Alt Unit → {} — applying...",
2847 if new_val == 0 { "ft/in" } else { "m/mm" }
2848 ));
2849 }
2850 }
2851 SettingRow::TemperatureUnit => {
2852 let new_val = if delta > 0 {
2853 image
2854 .settings()
2855 .temperature_unit_raw()
2856 .saturating_add(1)
2857 .min(1)
2858 } else {
2859 image.settings().temperature_unit_raw().saturating_sub(1)
2860 };
2861 if let Some((offset, value)) =
2862 image.modify_setting(|w| w.set_temperature_unit_raw(new_val))
2863 {
2864 let _ = tx.send(crate::event::RadioCommand::McpWriteByte { offset, value });
2865 self.status_message = Some(format!(
2866 "Temp Unit → {} — applying...",
2867 if new_val == 0 { "°F" } else { "°C" }
2868 ));
2869 }
2870 }
2871 SettingRow::GpsBtInterface => {
2872 adjust_numeric!(
2873 gps_bt_interface,
2874 set_gps_bt_interface,
2875 "GPS/BT",
2876 image,
2877 delta,
2878 tx
2879 );
2880 }
2881 SettingRow::PcOutputMode => {
2882 adjust_numeric!(
2883 pc_output_mode,
2884 set_pc_output_mode,
2885 "PC Output",
2886 image,
2887 delta,
2888 tx
2889 );
2890 }
2891 SettingRow::AprsUsbMode => {
2892 adjust_numeric!(
2893 aprs_usb_mode,
2894 set_aprs_usb_mode,
2895 "APRS USB",
2896 image,
2897 delta,
2898 tx
2899 );
2900 }
2901 SettingRow::AutoPowerOff => {
2902 let new_val = if delta > 0 {
2903 image
2904 .settings()
2905 .auto_power_off_raw()
2906 .saturating_add(1)
2907 .min(4)
2908 } else {
2909 image.settings().auto_power_off_raw().saturating_sub(1)
2910 };
2911 if let Some((offset, value)) =
2912 image.modify_setting(|w| w.set_auto_power_off_raw(new_val))
2913 {
2914 let _ = tx.send(crate::event::RadioCommand::McpWriteByte { offset, value });
2915 self.status_message = Some(format!(
2916 "Auto PwrOff → {} — applying...",
2917 ["Off", "30m", "60m", "90m", "120m"]
2918 .get(new_val as usize)
2919 .unwrap_or(&"?")
2920 ));
2921 }
2922 }
2923 _ => {
2924 self.status_message = Some(format!("{}: not adjustable", row.label()));
2925 }
2926 }
2927 }
2928
2929 fn handle_aprs_event(&mut self, event: kenwood_thd75::AprsEvent) {
2931 use kenwood_thd75::AprsEvent;
2932 match event {
2933 AprsEvent::StationHeard(entry) => {
2934 self.update_station_cache(&entry);
2935 }
2936 AprsEvent::PositionReceived { source, position } => {
2937 let idx = self.aprs_stations.iter().position(|s| s.callsign == source);
2939 if let Some(idx) = idx {
2940 let cached = &mut self.aprs_stations[idx];
2941 cached.latitude = Some(position.latitude);
2942 cached.longitude = Some(position.longitude);
2943 cached.speed_knots = position.speed_knots;
2944 cached.course_degrees = position.course_degrees;
2945 cached.symbol_table = Some(position.symbol_table);
2946 cached.symbol_code = Some(position.symbol_code);
2947 if !position.comment.is_empty() {
2948 cached.comment = Some(position.comment);
2949 }
2950 cached.last_heard = Instant::now();
2951 cached.packet_count = cached.packet_count.saturating_add(1);
2952 } else {
2953 self.aprs_stations.push(AprsStationCache {
2954 callsign: source,
2955 latitude: Some(position.latitude),
2956 longitude: Some(position.longitude),
2957 speed_knots: position.speed_knots,
2958 course_degrees: position.course_degrees,
2959 symbol_table: Some(position.symbol_table),
2960 symbol_code: Some(position.symbol_code),
2961 comment: if position.comment.is_empty() {
2962 None
2963 } else {
2964 Some(position.comment)
2965 },
2966 packet_count: 1,
2967 last_path: Vec::new(),
2968 last_heard: Instant::now(),
2969 });
2970 }
2971 self.sort_aprs_stations();
2972 }
2973 AprsEvent::MessageReceived(msg) => {
2974 self.status_message =
2975 Some(format!("APRS msg from {}: {}", msg.addressee, msg.text));
2976 }
2977 AprsEvent::MessageDelivered(id) => {
2978 if let Some(m) = self.aprs_messages.iter_mut().find(|m| m.message_id == id) {
2979 m.state = AprsMessageState::Delivered;
2980 }
2981 self.status_message = Some(format!("Message {id} delivered"));
2982 }
2983 AprsEvent::MessageRejected(id) => {
2984 if let Some(m) = self.aprs_messages.iter_mut().find(|m| m.message_id == id) {
2985 m.state = AprsMessageState::Rejected;
2986 }
2987 self.status_message = Some(format!("Message {id} rejected"));
2988 }
2989 AprsEvent::MessageExpired(id) => {
2990 if let Some(m) = self.aprs_messages.iter_mut().find(|m| m.message_id == id) {
2991 m.state = AprsMessageState::Expired;
2992 }
2993 self.status_message = Some(format!("Message {id} expired"));
2994 }
2995 AprsEvent::WeatherReceived { source, .. } => {
2996 self.status_message = Some(format!("WX from {source}"));
2997 }
2998 AprsEvent::PacketDigipeated { source } => {
2999 self.status_message = Some(format!("Digipeated packet from {source}"));
3000 }
3001 AprsEvent::QueryResponded { to } => {
3002 self.status_message = Some(format!("Responded to query from {to}"));
3003 }
3004 AprsEvent::RawPacket(_) => {
3005 }
3007 }
3008 }
3009
3010 fn update_station_cache(&mut self, entry: &kenwood_thd75::StationEntry) {
3012 let cached = AprsStationCache {
3013 callsign: entry.callsign.clone(),
3014 latitude: entry.position.as_ref().map(|p| p.latitude),
3015 longitude: entry.position.as_ref().map(|p| p.longitude),
3016 speed_knots: entry.position.as_ref().and_then(|p| p.speed_knots),
3017 course_degrees: entry.position.as_ref().and_then(|p| p.course_degrees),
3018 symbol_table: entry.position.as_ref().map(|p| p.symbol_table),
3019 symbol_code: entry.position.as_ref().map(|p| p.symbol_code),
3020 comment: entry
3021 .position
3022 .as_ref()
3023 .filter(|p| !p.comment.is_empty())
3024 .map(|p| p.comment.clone()),
3025 packet_count: entry.packet_count,
3026 last_path: entry.last_path.clone(),
3027 last_heard: entry.last_heard,
3028 };
3029
3030 if let Some(idx) = self
3031 .aprs_stations
3032 .iter()
3033 .position(|s| s.callsign == cached.callsign)
3034 {
3035 self.aprs_stations[idx] = cached;
3036 } else {
3037 self.aprs_stations.push(cached);
3038 }
3039 self.sort_aprs_stations();
3040 }
3041
3042 fn sort_aprs_stations(&mut self) {
3044 self.aprs_stations
3045 .sort_by(|a, b| b.last_heard.cmp(&a.last_heard));
3046 }
3047
3048 fn handle_dstar_event(&mut self, event: kenwood_thd75::DStarEvent) {
3050 use kenwood_thd75::DStarEvent;
3051 match event {
3052 DStarEvent::VoiceStart(header) => {
3053 self.dstar_rx_active = true;
3054 self.dstar_rx_header = Some(header);
3055 self.dstar_text_message = None;
3056 }
3057 DStarEvent::VoiceData(_frame) => {
3058 }
3060 DStarEvent::VoiceEnd => {
3061 self.dstar_rx_active = false;
3062 }
3063 DStarEvent::VoiceLost => {
3064 self.dstar_rx_active = false;
3065 self.status_message = Some("D-STAR: voice lost (no clean EOT)".into());
3066 }
3067 DStarEvent::TextMessage(text) => {
3068 self.dstar_text_message = Some(text);
3069 }
3070 DStarEvent::StationHeard(entry) => {
3071 if let Some(idx) = self
3073 .dstar_last_heard
3074 .iter()
3075 .position(|e| e.callsign == entry.callsign)
3076 {
3077 let _ = self.dstar_last_heard.remove(idx);
3078 }
3079 self.dstar_last_heard.insert(0, entry);
3080 self.dstar_last_heard.truncate(100);
3082 }
3083 DStarEvent::UrCallCommand(action) => {
3084 self.status_message = Some(format!("D-STAR: URCALL command detected: {action:?}"));
3085 }
3086 DStarEvent::StatusUpdate(_status) => {
3087 }
3089 }
3090 }
3091
3092 fn toggle_gps(&mut self) {
3093 let next = !self.state.gps_enabled;
3094 if let Some(ref tx) = self.cmd_tx {
3095 let _ = tx.send(crate::event::RadioCommand::SetGpsConfig(
3096 next,
3097 self.state.gps_pc_output,
3098 ));
3099 self.status_message =
3100 Some(format!("GPS {}", if next { "enabled" } else { "disabled" }));
3101 }
3102 }
3103
3104 fn toggle_gps_pc_output(&mut self) {
3105 let next = !self.state.gps_pc_output;
3106 if let Some(ref tx) = self.cmd_tx {
3107 let _ = tx.send(crate::event::RadioCommand::SetGpsConfig(
3108 self.state.gps_enabled,
3109 next,
3110 ));
3111 self.status_message = Some(format!(
3112 "GPS PC Output {}",
3113 if next { "enabled" } else { "disabled" }
3114 ));
3115 }
3116 }
3117
3118 fn toggle_dstar_mode(&mut self) {
3119 match self.dstar_mode {
3120 DStarMode::Inactive => {
3121 let callsign = if let McpState::Loaded { ref image, .. } = self.mcp {
3123 let cs = image.dstar().my_callsign();
3124 if cs.is_empty() {
3125 "N0CALL".to_string()
3126 } else {
3127 cs
3128 }
3129 } else {
3130 "N0CALL".to_string()
3131 };
3132
3133 let config = kenwood_thd75::DStarGatewayConfig::new(&callsign);
3134 if let Some(ref tx) = self.cmd_tx {
3135 let _ = tx.send(crate::event::RadioCommand::EnterDStar { config });
3136 self.status_message = Some("Entering D-STAR gateway mode...".into());
3137 }
3138 }
3139 DStarMode::Active => {
3140 if let Some(ref tx) = self.cmd_tx {
3141 let _ = tx.send(crate::event::RadioCommand::ExitDStar);
3142 self.status_message = Some("Exiting D-STAR gateway mode...".into());
3143 }
3144 }
3145 }
3146 }
3147
3148 fn toggle_fm_radio(&mut self) {
3149 let next = !self.fm_radio_on;
3150 if let Some(ref tx) = self.cmd_tx {
3151 let _ = tx.send(crate::event::RadioCommand::SetFmRadio(next));
3152 self.fm_radio_on = next;
3153 self.status_message = Some(format!(
3154 "FM Radio {}",
3155 if next { "enabled" } else { "disabled" }
3156 ));
3157 }
3158 }
3159
3160 fn apply_channel_edit(&mut self, field: ChannelEditField, buf: &str) {
3165 if buf.is_empty() {
3166 self.status_message = Some("No value entered".into());
3167 return;
3168 }
3169
3170 let used = self.filtered_channels();
3171 let Some(&ch_num) = used.get(self.channel_list_index) else {
3172 self.status_message = Some("No channel selected".into());
3173 return;
3174 };
3175
3176 match field {
3177 ChannelEditField::Frequency => {
3178 if let Ok(mhz) = buf.parse::<f64>() {
3180 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
3181 let hz = (mhz * 1_000_000.0) as u32;
3182 if let Some(ref tx) = self.cmd_tx {
3183 let _ = tx.send(crate::event::RadioCommand::TuneFreq {
3184 band: self.target_band,
3185 freq: hz,
3186 });
3187 self.status_message = Some(format!("Ch {ch_num}: tuning to {mhz:.6} MHz"));
3188 }
3189 } else {
3190 self.status_message = Some(format!("Invalid frequency: {buf}"));
3191 }
3192 }
3193 ChannelEditField::Name => {
3194 self.status_message = Some(format!(
3196 "Ch {ch_num}: name editing requires MCP write — use MCP panel (m)"
3197 ));
3198 }
3199 ChannelEditField::Mode => {
3200 if let Some(ref tx) = self.cmd_tx {
3202 use kenwood_thd75::types::Mode;
3203 let mode = match buf.to_uppercase().as_str() {
3204 "FM" => Some(Mode::Fm),
3205 "NFM" => Some(Mode::Nfm),
3206 "AM" => Some(Mode::Am),
3207 "DV" => Some(Mode::Dv),
3208 "LSB" => Some(Mode::Lsb),
3209 "USB" => Some(Mode::Usb),
3210 "CW" => Some(Mode::Cw),
3211 "DR" => Some(Mode::Dr),
3212 "WFM" => Some(Mode::Wfm),
3213 _ => None,
3214 };
3215 if let Some(mode) = mode {
3216 let _ = tx.send(crate::event::RadioCommand::SetMode {
3217 band: self.target_band,
3218 mode,
3219 });
3220 self.status_message = Some(format!("Ch {ch_num}: mode set to {mode}"));
3221 } else {
3222 self.status_message = Some(format!(
3223 "Unknown mode '{buf}' (try FM/NFM/AM/DV/LSB/USB/CW/DR/WFM)"
3224 ));
3225 }
3226 }
3227 }
3228 ChannelEditField::ToneMode
3229 | ChannelEditField::ToneFreq
3230 | ChannelEditField::Duplex
3231 | ChannelEditField::Offset => {
3232 self.status_message = Some(format!(
3236 "Ch {ch_num}: {} editing not yet implemented — requires ME write",
3237 field.label()
3238 ));
3239 }
3240 }
3241 }
3242
3243 fn toggle_aprs_mode(&mut self) {
3244 match self.aprs_mode {
3245 AprsMode::Inactive => {
3246 let (callsign, ssid) = if let McpState::Loaded { ref image, .. } = self.mcp {
3248 let cs = image.aprs().my_callsign();
3249 if cs.is_empty() {
3250 ("N0CALL".to_string(), 7u8)
3251 } else {
3252 if let Some((call, ssid_str)) = cs.split_once('-') {
3254 let ssid = ssid_str.parse::<u8>().unwrap_or(7);
3255 (call.to_string(), ssid)
3256 } else {
3257 (cs, 7)
3258 }
3259 }
3260 } else {
3261 ("N0CALL".to_string(), 7)
3262 };
3263
3264 let config = Box::new(kenwood_thd75::AprsClientConfig::new(&callsign, ssid));
3265 if let Some(ref tx) = self.cmd_tx {
3266 let _ = tx.send(crate::event::RadioCommand::EnterAprs { config });
3267 self.status_message = Some("Entering APRS mode...".into());
3268 }
3269 }
3270 AprsMode::Active => {
3271 if let Some(ref tx) = self.cmd_tx {
3272 let _ = tx.send(crate::event::RadioCommand::ExitAprs);
3273 self.status_message = Some("Exiting APRS mode...".into());
3274 }
3275 }
3276 }
3277 }
3278}