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}