vfo-sync

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

sync.rs (12116B)


      1 //! The background sync thread: reads CI-V frames from the IC-7300 and polls the
      2 //! SDR++ VFO frequency, mirroring changes in both directions with dedup.
      3 
      4 use std::sync::atomic::{AtomicBool, Ordering};
      5 use std::sync::{Arc, Mutex};
      6 use std::time::{Duration, Instant};
      7 
      8 use log::{debug, error, trace};
      9 use serialport::{DataBits, Parity, StopBits};
     10 
     11 use crate::civ::{self, CivMsg, CivMsgError, Framer, Freq, TOL_HZ};
     12 
     13 const DEFAULT_BAUD: u32 = 115_200;
     14 const DETECT_TIMEOUT: Duration = Duration::from_millis(300);
     15 const READ_TIMEOUT: Duration = Duration::from_millis(100);
     16 const POLL_INTERVAL: Duration = Duration::from_millis(100);
     17 /// How often to poll the radio's filter width (radio -> SDR bandwidth).
     18 const FILTER_POLL_INTERVAL: Duration = Duration::from_millis(500);
     19 
     20 /// Candidate rates probed during auto-detection, most common first. Standard
     21 /// Icom CI-V rates (19200/9600/4800) precede the USB-only high rates.
     22 const BAUD_CANDIDATES: [u32; 6] = [19_200, 9_600, 4_800, 115_200, 38_400, 57_600];
     23 
     24 pub fn run(
     25     port: String,
     26     baud: u32,
     27     sync_mode: Arc<AtomicBool>,
     28     stop: Arc<AtomicBool>,
     29     status: Arc<Mutex<String>>,
     30 ) {
     31     set_status(&status, "Connecting...");
     32 
     33     // 0 means "auto": probe the radio with a read-frequency command at each
     34     // candidate rate and use the first that yields a valid CI-V frame.
     35     let baud = if baud == 0 {
     36         detect_baud(&port).unwrap_or(DEFAULT_BAUD)
     37     } else {
     38         baud
     39     };
     40 
     41     let mut serial = match open_port(&port, baud) {
     42         Ok(serial) => serial,
     43         Err(e) => {
     44             set_status(&status, &format!("Serial error: {}", e));
     45             error!("failed to open serial port {} @ {}: {}", port, baud, e);
     46             return;
     47         }
     48     };
     49     set_status(&status, &format!("Connected @ {} baud", baud));
     50 
     51     // Make the radio broadcast frequency changes to us.
     52     let transceive = civ::encode_transceive_enable();
     53     if let Err(e) = serial.write_all(&transceive) {
     54         set_status(&status, &format!("Serial error: {}", e));
     55         error!("failed to enable CI-V transceive: {}", e);
     56         return;
     57     }
     58 
     59     // Adopt the SDR's current state as the baseline.
     60     let mut freq = unsafe { crate::civ_bridge_get_vfo_freq() }.round() as Freq;
     61     debug!("initial frequency baseline: {} Hz", freq);
     62 
     63     // Baseline mode (-1 means the SDR VFO is not a "radio" demodulator) and
     64     // bandwidth for the mode/filter sync.
     65     let mut mode = unsafe { crate::civ_bridge_get_mode() };
     66     let mut bw = unsafe { crate::civ_bridge_get_bandwidth() }.round() as u32;
     67 
     68     // On connect, the SDR++ VFO is authoritative: push its current frequency,
     69     // mode, and filter width to the radio once so the two start in agreement.
     70     let frame = civ::encode_set_freq(freq);
     71     if let Err(e) = serial.write_all(&frame) {
     72         set_status(&status, &format!("Serial error: {}", e));
     73         error!("initial frequency sync failed: {}", e);
     74         return;
     75     }
     76     if sync_mode.load(Ordering::Relaxed) && mode >= 0 {
     77         if let Some(m) = civ::sdrmode_to_icmode(mode) {
     78             if let Err(e) = serial.write_all(&civ::encode_set_mode(m)) {
     79                 set_status(&status, &format!("Serial error: {}", e));
     80                 error!("initial mode sync failed: {}", e);
     81                 return;
     82             }
     83         }
     84         if civ::bandwidth_applicable(mode) {
     85             let is_am = mode == civ::sdrmode::AM;
     86             if let Some(i) = civ::hz_to_filter_index(is_am, bw) {
     87                 if let Err(e) = serial.write_all(&civ::encode_set_filter(i)) {
     88                     set_status(&status, &format!("Serial error: {}", e));
     89                     error!("initial filter sync failed: {}", e);
     90                     return;
     91                 }
     92                 bw = civ::filter_index_to_hz(is_am, i).unwrap_or(bw);
     93             }
     94         }
     95     }
     96     debug!(
     97         "pushed initial state to radio: {} Hz, mode {}, bw {} Hz",
     98         freq, mode, bw
     99     );
    100 
    101     let mut framer = Framer::new();
    102     let mut read_buf = [0u8; 1024];
    103     let mut last_poll = Instant::now();
    104     let mut last_filter_poll = Instant::now();
    105 
    106     while !stop.load(Ordering::Relaxed) {
    107         // Read the live toggle each iteration so the checkbox takes effect
    108         // even while connected.
    109         let sync_mode = sync_mode.load(Ordering::Relaxed);
    110 
    111         // Radio -> SDR: parse incoming CI-V frames.
    112         match serial.read(&mut read_buf) {
    113             Ok(0) => {}
    114             Ok(n) => {
    115                 trace!("read {} bytes: {:02x?}", n, &read_buf[..n]);
    116                 for frame in framer.push(&read_buf[..n]) {
    117                     match CivMsg::try_from(frame.as_slice()) {
    118                         Ok(CivMsg::Freq(f)) => {
    119                             trace!("radio -> SDR: {} Hz", f);
    120                             if freq_diff(f, freq) > TOL_HZ {
    121                                 debug!("tuning SDR to {} Hz", f);
    122                                 unsafe { crate::civ_bridge_tune(f as f64) };
    123                                 freq = f;
    124                             }
    125                         }
    126                         Ok(CivMsg::Mode(m)) if sync_mode => {
    127                             if let Some(s) = civ::icmode_to_sdrmode(m) {
    128                                 if s != mode {
    129                                     debug!("radio -> SDR mode: {}", s);
    130                                     unsafe { crate::civ_bridge_set_mode(s) };
    131                                     mode = s;
    132                                     bw =
    133                                         unsafe { crate::civ_bridge_get_bandwidth() }.round() as u32;
    134                                 }
    135                             }
    136                         }
    137                         Ok(CivMsg::Filter(i)) if sync_mode => {
    138                             let is_am = mode == civ::sdrmode::AM;
    139                             if let Some(hz) = civ::filter_index_to_hz(is_am, i) {
    140                                 if freq_diff(hz, bw) > bw_tolerance(is_am) {
    141                                     debug!("radio -> SDR bandwidth: {} Hz", hz);
    142                                     unsafe { crate::civ_bridge_set_bandwidth(hz as f64) };
    143                                     bw = hz;
    144                                 }
    145                             }
    146                         }
    147                         Ok(_) => {}
    148                         Err(CivMsgError::WrongAddress) => {
    149                             // Echo of our own command (`to`/`from` = `94 E1`),
    150                             // e.g. the `1A 03` read poll; expected and noisy.
    151                         }
    152                         Err(e) => debug!("ignored CI-V frame ({:?}): {:02x?}", e, frame),
    153                     }
    154                 }
    155             }
    156             Err(ref e) if e.kind() == std::io::ErrorKind::TimedOut => {}
    157             Err(e) => {
    158                 set_status(&status, &format!("Serial error: {}", e));
    159                 error!("serial read failed: {}", e);
    160                 break;
    161             }
    162         }
    163 
    164         // SDR -> Radio: poll frequency/mode/bandwidth every 100 ms.
    165         if last_poll.elapsed() >= POLL_INTERVAL {
    166             let sdr = unsafe { crate::civ_bridge_get_vfo_freq() }.round() as Freq;
    167             if freq_diff(sdr, freq) > TOL_HZ {
    168                 debug!("setting radio to {} Hz", sdr);
    169                 let frame = civ::encode_set_freq(sdr);
    170                 if let Err(e) = serial.write_all(&frame) {
    171                     set_status(&status, &format!("Serial error: {}", e));
    172                     error!("serial write failed: {}", e);
    173                     break;
    174                 }
    175                 freq = sdr;
    176             }
    177 
    178             if sync_mode {
    179                 let s = unsafe { crate::civ_bridge_get_mode() };
    180                 if s >= 0 && s != mode {
    181                     if let Some(m) = civ::sdrmode_to_icmode(s) {
    182                         debug!("setting radio mode to {:02x}", m);
    183                         let frame = civ::encode_set_mode(m);
    184                         if let Err(e) = serial.write_all(&frame) {
    185                             set_status(&status, &format!("Serial error: {}", e));
    186                             error!("serial write failed: {}", e);
    187                             break;
    188                         }
    189                     }
    190                     mode = s;
    191                     bw = unsafe { crate::civ_bridge_get_bandwidth() }.round() as u32;
    192                 }
    193 
    194                 if civ::bandwidth_applicable(mode) {
    195                     let sdr_bw = unsafe { crate::civ_bridge_get_bandwidth() }.round() as u32;
    196                     let is_am = mode == civ::sdrmode::AM;
    197                     if freq_diff(sdr_bw, bw) > bw_tolerance(is_am) {
    198                         if let Some(i) = civ::hz_to_filter_index(is_am, sdr_bw) {
    199                             debug!("setting radio filter to index {}", i);
    200                             let frame = civ::encode_set_filter(i);
    201                             if let Err(e) = serial.write_all(&frame) {
    202                                 set_status(&status, &format!("Serial error: {}", e));
    203                                 error!("serial write failed: {}", e);
    204                                 break;
    205                             }
    206                             bw = civ::filter_index_to_hz(is_am, i).unwrap_or(sdr_bw);
    207                         }
    208                     }
    209                 }
    210             }
    211 
    212             last_poll = Instant::now();
    213         }
    214 
    215         // Radio -> SDR bandwidth: poll the radio's filter width (no transceive
    216         // broadcast for width, so read it explicitly).
    217         if sync_mode && last_filter_poll.elapsed() >= FILTER_POLL_INTERVAL {
    218             if civ::bandwidth_applicable(mode) {
    219                 let frame = civ::encode_read_filter();
    220                 if let Err(e) = serial.write_all(&frame) {
    221                     set_status(&status, &format!("Serial error: {}", e));
    222                     error!("serial write failed: {}", e);
    223                     break;
    224                 }
    225             }
    226             last_filter_poll = Instant::now();
    227         }
    228     }
    229 
    230     set_status(&status, "Disconnected");
    231 }
    232 
    233 fn open_port(port: &str, baud: u32) -> Result<Box<dyn serialport::SerialPort>, serialport::Error> {
    234     serialport::new(port, baud)
    235         .data_bits(DataBits::Eight)
    236         .parity(Parity::None)
    237         .stop_bits(StopBits::One)
    238         .timeout(READ_TIMEOUT)
    239         .open()
    240 }
    241 
    242 /// Try each candidate baud rate, sending a side-effect-free `0x03` read
    243 /// command and watching for a parseable CI-V frame in reply.
    244 fn detect_baud(port: &str) -> Option<u32> {
    245     for &baud in &BAUD_CANDIDATES {
    246         let mut serial = match open_port(port, baud) {
    247             Ok(serial) => serial,
    248             Err(_) => continue,
    249         };
    250         if serial.write_all(&civ::encode_read_freq()).is_err() {
    251             continue;
    252         }
    253 
    254         let mut framer = Framer::new();
    255         let mut buf = [0u8; 256];
    256         let deadline = Instant::now() + DETECT_TIMEOUT;
    257         let mut found = false;
    258         while Instant::now() < deadline && !found {
    259             match serial.read(&mut buf) {
    260                 Ok(0) => {}
    261                 Ok(n) => {
    262                     for frame in framer.push(&buf[..n]) {
    263                         if CivMsg::try_from(frame.as_slice()).is_ok() {
    264                             found = true;
    265                         }
    266                     }
    267                 }
    268                 Err(ref e) if e.kind() == std::io::ErrorKind::TimedOut => {}
    269                 Err(_) => break,
    270             }
    271         }
    272         if found {
    273             debug!("auto-detected serial baud: {}", baud);
    274             return Some(baud);
    275         }
    276         debug!("no CI-V reply at {} baud", baud);
    277     }
    278     None
    279 }
    280 
    281 fn freq_diff(a: Freq, b: Freq) -> i64 {
    282     (a as i64 - b as i64).abs()
    283 }
    284 
    285 /// Half-step bandwidth tolerance in Hz. AM steps are 200 Hz, SSB/CW steps are
    286 /// 50/100 Hz, so this is enough to avoid ping-pong while still tracking real
    287 /// changes.
    288 fn bw_tolerance(is_am: bool) -> i64 {
    289     if is_am {
    290         100
    291     } else {
    292         50
    293     }
    294 }
    295 
    296 fn set_status(status: &Arc<Mutex<String>>, text: &str) {
    297     if let Ok(mut s) = status.lock() {
    298         *s = text.to_string();
    299     }
    300 }