kenwood_thd75/types/
fm.rs

1//! FM broadcast radio types.
2//!
3//! The TH-D75 has a built-in wideband FM broadcast receiver covering
4//! 76.0-107.9 MHz. It provides 10 FM memory channels (FM0-FM9) for
5//! storing favourite broadcast stations.
6//!
7//! # Reception methods (per Operating Tips §5.10.7)
8//!
9//! There are two ways to receive FM broadcast:
10//!
11//! 1. **Band B frequency selection**: Tune Band B to the FM broadcast
12//!    band and select WFM mode. This uses the normal Band B receiver.
13//! 2. **FM Radio mode** (Menu No. 700): A dedicated FM radio mode that
14//!    runs concurrently with APRS and D-STAR operations. When a signal
15//!    is received on the amateur bands, FM Radio audio is muted; it
16//!    returns automatically after a configurable timeout (Menu No. 701).
17//!
18//! # Operation (per User Manual Chapter 21)
19//!
20//! - Frequency range: 76.0-108.0 MHz (WFM)
21//! - 10 memory channels (FM0-FM9) with assignable names
22//! - Direct frequency input supported via `[ENT]` and number keys
23//! - `[MODE]` toggles between FM Radio mode (VFO tuning) and FM Radio
24//!   Memory mode (FM0-FM9 recall). Cannot switch to memory mode if no
25//!   stations are registered.
26//! - `[A/B]` starts seek scanning; "\<\<Tuned\>\>" is displayed when a
27//!   station is found
28//! - FM Radio cannot be enabled when Band B is set to LF/MF, HF, 50,
29//!   or FMBC bands, or when Priority Scan, WX Alert, or IF/Detect
30//!   output mode is active.
31//! - When FM Radio mode is on, Menu No. 105, 134, 200, 203, 204, 210,
32//!   and 220 cannot be accessed.
33//!
34//! The FM radio is toggled on/off via the FR CAT command (already
35//! implemented as `get_fm_radio` / `set_fm_radio`). FM memory channels
36//! are managed through the radio's menu system or MCP software, as there
37//! is no CAT command for individual FM memory channel programming.
38//!
39//! When FM radio mode is active, the display shows "WFM" (Wide FM) and
40//! the radio uses the wideband FM demodulator. The LED control setting
41//! has a separate "FM Radio" option for controlling LED behavior during
42//! FM broadcast reception.
43//!
44//! See TH-D75 User Manual, Chapter 21: FM Radio.
45
46use std::fmt;
47
48/// FM broadcast radio frequency range lower bound (76.0 MHz), in Hz.
49pub const FM_RADIO_MIN_HZ: u32 = 76_000_000;
50
51/// FM broadcast radio frequency range upper bound (108.0 MHz), in Hz.
52pub const FM_RADIO_MAX_HZ: u32 = 108_000_000;
53
54/// Number of FM radio memory channels available.
55pub const FM_RADIO_CHANNEL_COUNT: u8 = 10;
56
57/// An FM broadcast radio memory channel (FM0-FM9).
58///
59/// The TH-D75 provides 10 memory channels for storing FM broadcast
60/// station frequencies. These are separate from the 1000 regular
61/// memory channels.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct FmRadioChannel {
64    /// Channel number (0-9, displayed as FM0-FM9).
65    pub number: u8,
66    /// Station frequency in Hz (76,000,000 - 108,000,000).
67    /// The radio tunes in 50/100 kHz steps in the FM broadcast band.
68    pub frequency_hz: u32,
69    /// Station name (up to 8 characters).
70    pub name: String,
71}
72
73impl FmRadioChannel {
74    /// Create a new FM radio channel.
75    ///
76    /// Returns `None` if the channel number or frequency is out of range,
77    /// or if the name exceeds 8 characters.
78    #[must_use]
79    pub fn new(number: u8, frequency_hz: u32, name: String) -> Option<Self> {
80        if number >= FM_RADIO_CHANNEL_COUNT {
81            return None;
82        }
83        if !(FM_RADIO_MIN_HZ..=FM_RADIO_MAX_HZ).contains(&frequency_hz) {
84            return None;
85        }
86        if name.len() > 8 {
87            return None;
88        }
89        Some(Self {
90            number,
91            frequency_hz,
92            name,
93        })
94    }
95
96    /// Returns the frequency in MHz as a floating-point value.
97    #[must_use]
98    #[allow(clippy::cast_precision_loss)]
99    pub fn frequency_mhz(&self) -> f64 {
100        f64::from(self.frequency_hz) / 1_000_000.0
101    }
102}
103
104impl fmt::Display for FmRadioChannel {
105    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106        if self.name.is_empty() {
107            write!(f, "FM{}: {:.1} MHz", self.number, self.frequency_mhz())
108        } else {
109            write!(
110                f,
111                "FM{}: {:.1} MHz ({})",
112                self.number,
113                self.frequency_mhz(),
114                self.name
115            )
116        }
117    }
118}
119
120/// FM radio operating mode.
121///
122/// The TH-D75's FM broadcast receiver can operate in two modes:
123/// direct frequency tuning or memory channel recall.
124///
125/// Per User Manual Chapter 21: the auto-mute return time (Menu No. 701,
126/// 1-10 seconds, default 3) controls how long after an amateur-band
127/// signal ends before the FM radio audio resumes.
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
129pub enum FmRadioMode {
130    /// Direct frequency tuning — tune to any frequency in the
131    /// 76-108 MHz FM broadcast band using the dial or up/down keys.
132    Tuning,
133    /// Memory channel mode — recall one of the 10 FM memory
134    /// channels (FM0-FM9).
135    Memory,
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn fm_channel_valid() {
144        let ch = FmRadioChannel::new(0, 89_100_000, "NPR".to_owned()).unwrap();
145        assert_eq!(ch.number, 0);
146        assert_eq!(ch.frequency_hz, 89_100_000);
147        assert!((ch.frequency_mhz() - 89.1).abs() < 0.001);
148        assert_eq!(ch.name, "NPR");
149    }
150
151    #[test]
152    fn fm_channel_invalid_number() {
153        assert!(FmRadioChannel::new(10, 89_100_000, String::new()).is_none());
154    }
155
156    #[test]
157    fn fm_channel_invalid_frequency_low() {
158        assert!(FmRadioChannel::new(0, 75_000_000, String::new()).is_none());
159    }
160
161    #[test]
162    fn fm_channel_invalid_frequency_high() {
163        assert!(FmRadioChannel::new(0, 109_000_000, String::new()).is_none());
164    }
165
166    #[test]
167    fn fm_channel_name_too_long() {
168        assert!(FmRadioChannel::new(0, 89_100_000, "123456789".to_owned()).is_none());
169    }
170
171    #[test]
172    fn fm_channel_display_with_name() {
173        let ch = FmRadioChannel::new(3, 101_100_000, "KFLY".to_owned()).unwrap();
174        let s = format!("{ch}");
175        assert!(s.contains("FM3"));
176        assert!(s.contains("101.1"));
177        assert!(s.contains("KFLY"));
178    }
179
180    #[test]
181    fn fm_channel_display_without_name() {
182        let ch = FmRadioChannel::new(0, 88_500_000, String::new()).unwrap();
183        let s = format!("{ch}");
184        assert!(s.contains("FM0"));
185        assert!(s.contains("88.5"));
186        assert!(!s.contains('('));
187    }
188
189    #[test]
190    fn fm_channel_boundary_frequencies() {
191        // Lower bound
192        let low = FmRadioChannel::new(0, FM_RADIO_MIN_HZ, String::new());
193        assert!(low.is_some());
194        // Upper bound
195        let high = FmRadioChannel::new(0, FM_RADIO_MAX_HZ, String::new());
196        assert!(high.is_some());
197    }
198
199    #[test]
200    fn fm_radio_mode_debug() {
201        let _ = format!("{:?}", FmRadioMode::Tuning);
202        let _ = format!("{:?}", FmRadioMode::Memory);
203    }
204}