vfo-sync

Bidirectional VFO synchronization for IC-7300 and SDR++
git clone https://git.bradleygannon.com/vfo-sync.git
Log | Files | Refs

civ.rs (19650B)


      1 //! CI-V framing, BCD encoding, and frame parsing for the IC-7300.
      2 
      3 use log::trace;
      4 
      5 /// Frequency in Hz.
      6 pub type Freq = u32;
      7 
      8 /// Two frequencies within this many Hz are considered equal.
      9 pub const TOL_HZ: i64 = 5;
     10 
     11 /// CI-V frame preamble / terminator.
     12 const PREAMBLE: [u8; 2] = [0xfe, 0xfe];
     13 const TERMINATOR: u8 = 0xfd;
     14 
     15 /// IC-7300 radio address.
     16 const RADIO_ADDR: u8 = 0x94;
     17 /// Controller (this program) address.
     18 const CONTROLLER_ADDR: u8 = 0xE1;
     19 
     20 /// "Sets the current operating frequency" (set-only, no echo).
     21 const CMD_SET_FREQ: u8 = 0x05;
     22 /// "Sets and outputs the current operating frequency" — what the radio
     23 /// broadcasts when CI-V Transceive is enabled.
     24 const CMD_BROADCAST_FREQ: u8 = 0x00;
     25 /// "Reads the current operating frequency" — side-effect-free, used to probe
     26 /// the radio during baud-rate auto-detection.
     27 const CMD_READ_FREQ: u8 = 0x03;
     28 
     29 /// "Sets and outputs the current operating mode" — what the radio broadcasts
     30 /// on a mode change when CI-V Transceive is enabled.
     31 const CMD_BROADCAST_MODE: u8 = 0x01;
     32 /// "Reads the current operating mode".
     33 const CMD_READ_MODE: u8 = 0x04;
     34 /// "Sets the current operating mode" (set-only, no echo).
     35 const CMD_SET_MODE: u8 = 0x06;
     36 /// "Misc memory/bank/rig control" — carrier for the filter-width subcommand.
     37 const CMD_CTL_MEM: u8 = 0x1a;
     38 /// `1A 03` subcommand: current passband (IF) filter width.
     39 const SUB_FILT_WDTH: u8 = 0x03;
     40 
     41 fn to_bcd(n: u8) -> u8 {
     42     ((n / 10) * 16) + (n % 10)
     43 }
     44 
     45 fn from_bcd(n: u8) -> u8 {
     46     ((n / 16) * 10) + (n % 16)
     47 }
     48 
     49 /// Encode a `0x05` set-frequency command.
     50 ///
     51 /// Frequency data is 10 BCD digits (5 bytes), least-significant pair first:
     52 /// 1 Hz, 10 Hz, 100 Hz, 1 kHz, 10 kHz, 100 kHz, 1 MHz, 10 MHz, then the
     53 /// 100 MHz and 1 GHz digits which are fixed at 0 for the IC-7300.
     54 pub fn encode_set_freq(freq: Freq) -> Vec<u8> {
     55     let ones_tens_hz = (freq % 100) as u8;
     56     let hundreds_hz_khz = ((freq / 100) % 100) as u8;
     57     let tens_hundreds_khz = ((freq / 10_000) % 100) as u8;
     58     let ones_tens_mhz = ((freq / 1_000_000) % 100) as u8;
     59     let frame = vec![
     60         PREAMBLE[0],
     61         PREAMBLE[1],
     62         RADIO_ADDR,
     63         CONTROLLER_ADDR,
     64         CMD_SET_FREQ,
     65         to_bcd(ones_tens_hz),
     66         to_bcd(hundreds_hz_khz),
     67         to_bcd(tens_hundreds_khz),
     68         to_bcd(ones_tens_mhz),
     69         0x00, // 100 MHz + 1 GHz digits (fixed 0)
     70         TERMINATOR,
     71     ];
     72     trace!("encoded set_freq {} Hz -> {:02x?}", freq, frame);
     73     frame
     74 }
     75 
     76 /// Enable CI-V Transceive (`1A 05 00 89 01`) so the radio pushes `0x00`
     77 /// broadcasts on knob/mode changes.
     78 pub fn encode_transceive_enable() -> Vec<u8> {
     79     vec![
     80         PREAMBLE[0],
     81         PREAMBLE[1],
     82         RADIO_ADDR,
     83         CONTROLLER_ADDR,
     84         0x1a,
     85         0x05,
     86         0x00,
     87         0x89,
     88         0x01,
     89         TERMINATOR,
     90     ]
     91 }
     92 
     93 /// "Reads the current operating frequency" (`0x03`, no data). The radio
     94 /// replies with a `0x03` frequency frame; used to probe the serial baud rate.
     95 pub fn encode_read_freq() -> Vec<u8> {
     96     vec![
     97         PREAMBLE[0],
     98         PREAMBLE[1],
     99         RADIO_ADDR,
    100         CONTROLLER_ADDR,
    101         CMD_READ_FREQ,
    102         TERMINATOR,
    103     ]
    104 }
    105 
    106 /// IC-7300 operating-mode data byte values (for the `0x01`/`0x04`/`0x06`
    107 /// commands). See "Operating mode" in the CI-V reference guide.
    108 pub mod icmode {
    109     pub const LSB: u8 = 0x00;
    110     pub const USB: u8 = 0x01;
    111     pub const AM: u8 = 0x02;
    112     pub const CW: u8 = 0x03;
    113     pub const RTTY: u8 = 0x04;
    114     pub const FM: u8 = 0x05;
    115     pub const CW_R: u8 = 0x07;
    116     pub const RTTY_R: u8 = 0x08;
    117 }
    118 
    119 /// SDR++ radio demodulator IDs. These mirror the `RADIO_IFACE_MODE_*` / `DemodID`
    120 /// enum in SDR++'s `radio` decoder module (`decoder_modules/radio/src`).
    121 /// A few constants are unused here but kept for completeness/documentation.
    122 #[allow(dead_code)]
    123 pub mod sdrmode {
    124     pub const NFM: i32 = 0;
    125     pub const WFM: i32 = 1;
    126     pub const AM: i32 = 2;
    127     pub const DSB: i32 = 3;
    128     pub const USB: i32 = 4;
    129     pub const CW: i32 = 5;
    130     pub const LSB: i32 = 6;
    131     pub const RAW: i32 = 7;
    132 }
    133 
    134 /// "Sets the current operating mode" (`0x06`, set-only, no echo).
    135 pub fn encode_set_mode(mode: u8) -> Vec<u8> {
    136     vec![
    137         PREAMBLE[0],
    138         PREAMBLE[1],
    139         RADIO_ADDR,
    140         CONTROLLER_ADDR,
    141         CMD_SET_MODE,
    142         mode,
    143         TERMINATOR,
    144     ]
    145 }
    146 
    147 /// "Sets the current passband filter width" (`1A 03 <index>`) to one of the
    148 /// IC-7300's discrete filter steps.
    149 pub fn encode_set_filter(index: u8) -> Vec<u8> {
    150     vec![
    151         PREAMBLE[0],
    152         PREAMBLE[1],
    153         RADIO_ADDR,
    154         CONTROLLER_ADDR,
    155         CMD_CTL_MEM,
    156         SUB_FILT_WDTH,
    157         to_bcd(index),
    158         TERMINATOR,
    159     ]
    160 }
    161 
    162 /// "Reads the current passband filter width" (`1A 03`, no data). The radio
    163 /// replies with a `1A 03 <index>` frame.
    164 pub fn encode_read_filter() -> Vec<u8> {
    165     vec![
    166         PREAMBLE[0],
    167         PREAMBLE[1],
    168         RADIO_ADDR,
    169         CONTROLLER_ADDR,
    170         CMD_CTL_MEM,
    171         SUB_FILT_WDTH,
    172         TERMINATOR,
    173     ]
    174 }
    175 
    176 /// Translate an IC-7300 operating-mode byte to an SDR++ demodulator ID.
    177 ///
    178 /// Best-effort for modes with no exact SDR++ equivalent: `RTTY` → `USB`,
    179 /// `CW-R` → `CW`, `RTTY-R` → `LSB`, `FM` → `NFM`.
    180 pub fn icmode_to_sdrmode(mode: u8) -> Option<i32> {
    181     use icmode as r;
    182     use sdrmode as s;
    183     Some(match mode {
    184         r::LSB => s::LSB,
    185         r::USB => s::USB,
    186         r::AM => s::AM,
    187         r::CW => s::CW,
    188         r::RTTY => s::USB,
    189         r::FM => s::NFM,
    190         r::CW_R => s::CW,
    191         r::RTTY_R => s::LSB,
    192         _ => return None,
    193     })
    194 }
    195 
    196 /// Translate an SDR++ demodulator ID to an IC-7300 operating-mode byte.
    197 ///
    198 /// `WFM`/`DSB`/`RAW` have no IC-7300 equivalent and return `None`.
    199 pub fn sdrmode_to_icmode(mode: i32) -> Option<u8> {
    200     use icmode as r;
    201     use sdrmode as s;
    202     Some(match mode {
    203         s::LSB => r::LSB,
    204         s::USB => r::USB,
    205         s::AM => r::AM,
    206         s::CW => r::CW,
    207         s::NFM => r::FM,
    208         _ => return None,
    209     })
    210 }
    211 
    212 /// Whether bandwidth sync applies to a given SDR++ mode. SSB/CW/AM have a
    213 /// `1A 03` filter width; FM/NFM uses fixed FIL1/2/3 presets instead.
    214 pub fn bandwidth_applicable(sdr_mode: i32) -> bool {
    215     matches!(
    216         sdr_mode,
    217         sdrmode::AM | sdrmode::USB | sdrmode::LSB | sdrmode::CW
    218     )
    219 }
    220 
    221 /// SSB/CW/RTTY filter width in Hz for a filter index (0..=40).
    222 /// Indices 0..=9 are 50..500 Hz (50 Hz steps); 10..=40 are 600..3600 Hz
    223 /// (100 Hz steps). See "IF filter width settings" in the CI-V reference guide.
    224 fn ssb_filter_hz(index: u8) -> Option<u32> {
    225     match index {
    226         0..=9 => Some(50 + u32::from(index) * 50),
    227         10..=40 => Some(600 + u32::from(index - 10) * 100),
    228         _ => None,
    229     }
    230 }
    231 
    232 /// AM filter width in Hz for a filter index (0..=49): 200 Hz..10 kHz in 200 Hz
    233 /// steps.
    234 fn am_filter_hz(index: u8) -> Option<u32> {
    235     if index <= 49 {
    236         Some(200 + u32::from(index) * 200)
    237     } else {
    238         None
    239     }
    240 }
    241 
    242 /// Map a filter index to Hz for the given mode class.
    243 pub fn filter_index_to_hz(is_am: bool, index: u8) -> Option<u32> {
    244     if is_am {
    245         am_filter_hz(index)
    246     } else {
    247         ssb_filter_hz(index)
    248     }
    249 }
    250 
    251 /// Map a bandwidth in Hz to the nearest IC-7300 filter index for the given mode
    252 /// class.
    253 pub fn hz_to_filter_index(is_am: bool, hz: u32) -> Option<u8> {
    254     let max = if is_am { 49u8 } else { 40u8 };
    255     let mut best: Option<u8> = None;
    256     let mut best_diff = u32::MAX;
    257     for i in 0..=max {
    258         let h = filter_index_to_hz(is_am, i)?;
    259         let d = h.abs_diff(hz);
    260         if d < best_diff {
    261             best_diff = d;
    262             best = Some(i);
    263         }
    264     }
    265     best
    266 }
    267 
    268 #[derive(Debug, PartialEq, Eq)]
    269 pub enum CivMsg {
    270     Freq(Freq),
    271     /// Operating mode byte (IC-7300 `icmode::*`).
    272     Mode(u8),
    273     /// Filter-width index (0..=49, BCD-decoded).
    274     Filter(u8),
    275 }
    276 
    277 #[derive(Debug, PartialEq, Eq)]
    278 pub enum CivMsgError {
    279     InputTooShort,
    280     InvalidStartOrEnd,
    281     WrongAddress,
    282     WrongCommand,
    283 }
    284 
    285 impl TryFrom<&[u8]> for CivMsg {
    286     type Error = CivMsgError;
    287 
    288     fn try_from(frame: &[u8]) -> Result<Self, Self::Error> {
    289         if frame.len() < 5 {
    290             return Err(CivMsgError::InputTooShort);
    291         }
    292         if frame[..2] != PREAMBLE || frame.last() != Some(&TERMINATOR) {
    293             return Err(CivMsgError::InvalidStartOrEnd);
    294         }
    295         // Accept any frame *from* the radio (`to`/`from` = `* 94`). The
    296         // destination (`frame[2]`) varies: `00` broadcast, `E0` the default
    297         // controller address, or `E1` us. Echoes of our own commands have
    298         // `to`/`from` = `94 E1` and are rejected here.
    299         if frame[3] != RADIO_ADDR {
    300             return Err(CivMsgError::WrongAddress);
    301         }
    302         // `0x00` (set-and-output / transceive broadcast) and `0x03` (read
    303         // reply) both carry the same 5-byte BCD frequency payload.
    304         if (frame[4] == CMD_BROADCAST_FREQ || frame[4] == CMD_READ_FREQ) && frame.len() == 11 {
    305             let freq = from_bcd(frame[5]) as Freq
    306                 + from_bcd(frame[6]) as Freq * 100
    307                 + from_bcd(frame[7]) as Freq * 10_000
    308                 + from_bcd(frame[8]) as Freq * 1_000_000;
    309             return Ok(CivMsg::Freq(freq));
    310         }
    311         // `0x01` (transceive broadcast) and `0x04` (read reply) carry the
    312         // operating mode as the first data byte; an optional FIL1/2/3 filter
    313         // byte follows and is ignored.
    314         if (frame[4] == CMD_BROADCAST_MODE || frame[4] == CMD_READ_MODE) && frame.len() >= 7 {
    315             return Ok(CivMsg::Mode(frame[5]));
    316         }
    317         // `1A 03 <index>` — current passband filter width (read reply), a
    318         // single BCD-encoded filter index.
    319         if frame[4] == CMD_CTL_MEM && frame[5] == SUB_FILT_WDTH && frame.len() >= 8 {
    320             return Ok(CivMsg::Filter(from_bcd(frame[6])));
    321         }
    322         Err(CivMsgError::WrongCommand)
    323     }
    324 }
    325 
    326 /// Streaming CI-V frame splitter. Accumulates bytes across reads and yields
    327 /// complete frames, tolerating frames that span reads and stray/garbage bytes.
    328 pub struct Framer {
    329     buf: Vec<u8>,
    330 }
    331 
    332 impl Framer {
    333     pub fn new() -> Self {
    334         Framer { buf: Vec::new() }
    335     }
    336 
    337     pub fn push(&mut self, data: &[u8]) -> Vec<Vec<u8>> {
    338         self.buf.extend_from_slice(data);
    339 
    340         let mut frames = Vec::new();
    341         while let Some((start, end)) = next_frame(&self.buf) {
    342             frames.push(self.buf[start..end].to_vec());
    343             self.buf.drain(..end);
    344         }
    345 
    346         // Drop leading garbage when no frame start is present, keeping a
    347         // possible trailing 0xfe that may be half of a split preamble.
    348         if self.buf.len() >= 2 && !self.buf.windows(2).any(|w| w == PREAMBLE) {
    349             let keep = usize::from(self.buf.last() == Some(&PREAMBLE[0]));
    350             let drop = self.buf.len() - keep;
    351             if drop > 0 {
    352                 self.buf.drain(..drop);
    353             }
    354         }
    355 
    356         // Resync if a frame never terminates.
    357         if self.buf.len() > 4096 {
    358             self.buf.clear();
    359         }
    360 
    361         frames
    362     }
    363 }
    364 
    365 fn next_frame(buf: &[u8]) -> Option<(usize, usize)> {
    366     let start = buf.windows(2).position(|w| w == PREAMBLE)?;
    367     let body = &buf[start + 2..];
    368     let end_rel = body.iter().position(|&b| b == TERMINATOR)?;
    369     Some((start, start + 2 + end_rel + 1))
    370 }
    371 
    372 impl Default for Framer {
    373     fn default() -> Self {
    374         Self::new()
    375     }
    376 }
    377 
    378 #[cfg(test)]
    379 mod tests {
    380     use super::*;
    381 
    382     #[test]
    383     fn bcd_roundtrip() {
    384         for n in 0u8..=99 {
    385             assert_eq!(from_bcd(to_bcd(n)), n);
    386         }
    387     }
    388 
    389     #[test]
    390     fn set_freq_uses_command_05() {
    391         let frame = encode_set_freq(7_100_000);
    392         assert_eq!(frame.len(), 11);
    393         assert_eq!(frame[..5], [0xfe, 0xfe, 0x94, 0xe1, 0x05]);
    394         assert_eq!(frame[10], 0xfd);
    395     }
    396 
    397     #[test]
    398     fn read_freq_uses_command_03() {
    399         let frame = encode_read_freq();
    400         assert_eq!(frame, [0xfe, 0xfe, 0x94, 0xe1, 0x03, 0xfd]);
    401     }
    402 
    403     #[test]
    404     fn encode_set_freq_matches_reference() {
    405         for freq in [0, 1, 99, 7_100_000, 14_250_500, 99_999_999] {
    406             let frame = encode_set_freq(freq);
    407             // Decode our own 0x05 frame by re-reading the BCD digits.
    408             let decoded = from_bcd(frame[5]) as u32
    409                 + from_bcd(frame[6]) as u32 * 100
    410                 + from_bcd(frame[7]) as u32 * 10_000
    411                 + from_bcd(frame[8]) as u32 * 1_000_000;
    412             assert_eq!(decoded, freq, "freq {}", freq);
    413         }
    414     }
    415 
    416     #[test]
    417     fn parse_broadcast_freq() {
    418         // 7.1 MHz broadcast from the radio: FE FE E1 94 00 <bcd> 00 FD
    419         let frame = [
    420             0xfe, 0xfe, 0xe1, 0x94, 0x00, 0x00, 0x00, 0x10, 0x07, 0x00, 0xfd,
    421         ];
    422         assert_eq!(CivMsg::try_from(&frame[..]), Ok(CivMsg::Freq(7_100_000)));
    423     }
    424 
    425     #[test]
    426     fn parse_read_freq_reply() {
    427         // 7.1 MHz reply to a 0x03 read: FE FE E1 94 03 <bcd> 00 FD
    428         let frame = [
    429             0xfe, 0xfe, 0xe1, 0x94, 0x03, 0x00, 0x00, 0x10, 0x07, 0x00, 0xfd,
    430         ];
    431         assert_eq!(CivMsg::try_from(&frame[..]), Ok(CivMsg::Freq(7_100_000)));
    432     }
    433 
    434     #[test]
    435     fn parse_broadcast_to_default_controller() {
    436         // Broadcast directed at 0xE0 (default controller): FE FE E0 94 00 ...
    437         let frame = [
    438             0xfe, 0xfe, 0xe0, 0x94, 0x00, 0x00, 0x00, 0x10, 0x07, 0x00, 0xfd,
    439         ];
    440         assert_eq!(CivMsg::try_from(&frame[..]), Ok(CivMsg::Freq(7_100_000)));
    441     }
    442 
    443     #[test]
    444     fn rejects_echo_of_our_command() {
    445         // Our own 0x05 set-freq command echoed back: FE FE 94 E1 05 ...
    446         let frame = [
    447             0xfe, 0xfe, 0x94, 0xe1, 0x05, 0x00, 0x00, 0x10, 0x07, 0x00, 0xfd,
    448         ];
    449         assert_eq!(CivMsg::try_from(&frame[..]), Err(CivMsgError::WrongAddress));
    450     }
    451 
    452     #[test]
    453     fn parse_rejects_wrong_address_and_command() {
    454         let freq_frame = [
    455             0xfe, 0xfe, 0xe1, 0x94, 0x00, 0x00, 0x00, 0x10, 0x07, 0x00, 0xfd,
    456         ];
    457         let wrong_addr = [
    458             0xfe, 0xfe, 0xe1, 0x55, 0x00, 0x00, 0x00, 0x10, 0x07, 0x00, 0xfd,
    459         ];
    460         let wrong_cmd = [
    461             0xfe, 0xfe, 0xe1, 0x94, 0x09, 0x00, 0x00, 0x10, 0x07, 0x00, 0xfd,
    462         ];
    463         let short = [0xfe, 0xfe, 0xe1, 0x94];
    464         let bad_end = [
    465             0xfe, 0xfe, 0xe1, 0x94, 0x00, 0x00, 0x00, 0x10, 0x07, 0x00, 0x00,
    466         ];
    467         assert_eq!(
    468             CivMsg::try_from(&freq_frame[..]),
    469             Ok(CivMsg::Freq(7_100_000))
    470         );
    471         assert_eq!(
    472             CivMsg::try_from(&wrong_addr[..]),
    473             Err(CivMsgError::WrongAddress)
    474         );
    475         assert_eq!(
    476             CivMsg::try_from(&wrong_cmd[..]),
    477             Err(CivMsgError::WrongCommand)
    478         );
    479         assert_eq!(
    480             CivMsg::try_from(&short[..]),
    481             Err(CivMsgError::InputTooShort)
    482         );
    483         assert_eq!(
    484             CivMsg::try_from(&bad_end[..]),
    485             Err(CivMsgError::InvalidStartOrEnd)
    486         );
    487     }
    488 
    489     #[test]
    490     fn framer_splits_across_reads() {
    491         let frame = [
    492             0xfe, 0xfe, 0xe1, 0x94, 0x00, 0x00, 0x00, 0x10, 0x07, 0x00, 0xfd,
    493         ];
    494         let mut f = Framer::new();
    495         assert!(f.push(&frame[..3]).is_empty());
    496         let out = f.push(&frame[3..]);
    497         assert_eq!(out, vec![frame.to_vec()]);
    498     }
    499 
    500     #[test]
    501     fn framer_drops_garbage_and_yields_frames() {
    502         let frame = [
    503             0xfe, 0xfe, 0xe1, 0x94, 0x00, 0x00, 0x00, 0x10, 0x07, 0x00, 0xfd,
    504         ];
    505         let mut data = vec![0xaa, 0xbb];
    506         data.extend_from_slice(&frame);
    507         data.extend_from_slice(&frame);
    508         let out = Framer::new().push(&data);
    509         assert_eq!(out, vec![frame.to_vec(), frame.to_vec()]);
    510     }
    511 
    512     #[test]
    513     fn framer_handles_lone_preamble_byte() {
    514         let frame = [
    515             0xfe, 0xfe, 0xe1, 0x94, 0x00, 0x00, 0x00, 0x10, 0x07, 0x00, 0xfd,
    516         ];
    517         let mut f = Framer::new();
    518         assert!(f.push(&[0xfe]).is_empty());
    519         let mut rest = vec![0xfe];
    520         rest.extend_from_slice(&frame[2..]);
    521         let out = f.push(&rest);
    522         assert_eq!(out, vec![frame.to_vec()]);
    523     }
    524 
    525     #[test]
    526     fn encode_set_mode_uses_command_06() {
    527         let frame = encode_set_mode(icmode::USB);
    528         assert_eq!(frame, [0xfe, 0xfe, 0x94, 0xe1, 0x06, icmode::USB, 0xfd]);
    529     }
    530 
    531     #[test]
    532     fn encode_set_filter_uses_1a_03() {
    533         // index 28 -> BCD 0x28
    534         let frame = encode_set_filter(28);
    535         assert_eq!(frame, [0xfe, 0xfe, 0x94, 0xe1, 0x1a, 0x03, 0x28, 0xfd]);
    536     }
    537 
    538     #[test]
    539     fn parse_mode_broadcast() {
    540         // Radio transceive mode broadcast: FE FE 00 94 01 <mode> FD
    541         let frame = [0xfe, 0xfe, 0x00, 0x94, 0x01, icmode::USB, 0xfd];
    542         assert_eq!(CivMsg::try_from(&frame[..]), Ok(CivMsg::Mode(icmode::USB)));
    543     }
    544 
    545     #[test]
    546     fn parse_mode_read_reply() {
    547         // Reply to 0x04 read: FE FE E1 94 04 <mode> <filter> FD
    548         let frame = [0xfe, 0xfe, 0xe1, 0x94, 0x04, icmode::LSB, 0x01, 0xfd];
    549         assert_eq!(CivMsg::try_from(&frame[..]), Ok(CivMsg::Mode(icmode::LSB)));
    550     }
    551 
    552     #[test]
    553     fn parse_filter_read_reply() {
    554         // Reply to 1A 03 read: FE FE E1 94 1A 03 <bcd index> FD
    555         let frame = [0xfe, 0xfe, 0xe1, 0x94, 0x1a, 0x03, 0x28, 0xfd];
    556         assert_eq!(CivMsg::try_from(&frame[..]), Ok(CivMsg::Filter(28)));
    557     }
    558 
    559     #[test]
    560     fn mode_mapping_exact_and_best_effort() {
    561         assert_eq!(icmode_to_sdrmode(icmode::LSB), Some(sdrmode::LSB));
    562         assert_eq!(icmode_to_sdrmode(icmode::USB), Some(sdrmode::USB));
    563         assert_eq!(icmode_to_sdrmode(icmode::AM), Some(sdrmode::AM));
    564         assert_eq!(icmode_to_sdrmode(icmode::CW), Some(sdrmode::CW));
    565         // best effort
    566         assert_eq!(icmode_to_sdrmode(icmode::RTTY), Some(sdrmode::USB));
    567         assert_eq!(icmode_to_sdrmode(icmode::CW_R), Some(sdrmode::CW));
    568         assert_eq!(icmode_to_sdrmode(icmode::RTTY_R), Some(sdrmode::LSB));
    569         assert_eq!(icmode_to_sdrmode(icmode::FM), Some(sdrmode::NFM));
    570 
    571         assert_eq!(sdrmode_to_icmode(sdrmode::LSB), Some(icmode::LSB));
    572         assert_eq!(sdrmode_to_icmode(sdrmode::USB), Some(icmode::USB));
    573         assert_eq!(sdrmode_to_icmode(sdrmode::AM), Some(icmode::AM));
    574         assert_eq!(sdrmode_to_icmode(sdrmode::CW), Some(icmode::CW));
    575         assert_eq!(sdrmode_to_icmode(sdrmode::NFM), Some(icmode::FM));
    576         assert_eq!(sdrmode_to_icmode(sdrmode::WFM), None);
    577         assert_eq!(sdrmode_to_icmode(sdrmode::DSB), None);
    578         assert_eq!(sdrmode_to_icmode(sdrmode::RAW), None);
    579     }
    580 
    581     #[test]
    582     fn filter_width_mapping() {
    583         // SSB/CW table: index 0 = 50 Hz, 9 = 500 Hz, 10 = 600 Hz, 40 = 3600 Hz.
    584         assert_eq!(filter_index_to_hz(false, 0), Some(50));
    585         assert_eq!(filter_index_to_hz(false, 9), Some(500));
    586         assert_eq!(filter_index_to_hz(false, 10), Some(600));
    587         assert_eq!(filter_index_to_hz(false, 40), Some(3600));
    588         assert_eq!(filter_index_to_hz(false, 41), None);
    589         // AM table: 0 = 200 Hz, 49 = 10000 Hz.
    590         assert_eq!(filter_index_to_hz(true, 0), Some(200));
    591         assert_eq!(filter_index_to_hz(true, 49), Some(10_000));
    592         assert_eq!(filter_index_to_hz(true, 50), None);
    593 
    594         // Nearest-index round trips.
    595         assert_eq!(hz_to_filter_index(false, 3600), Some(40));
    596         assert_eq!(hz_to_filter_index(false, 250), Some(4)); // 250 Hz
    597         assert_eq!(hz_to_filter_index(true, 1000), Some(4)); // 1000 Hz
    598                                                              // Slightly off-step snaps to the nearest step (600 vs 700 -> 700).
    599         assert_eq!(hz_to_filter_index(false, 670), Some(11));
    600     }
    601 }