vfo-sync

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

commit c691339ca12a19dc604f0322bea9a0704bdc34cc
parent 2d159453f5503dd13bd74a002e967d4ef1ca19b8
Author: Bradley Gannon <bradley@bradleygannon.com>
Date:   Tue,  8 Sep 2026 14:45:28 -0400

Add mode and filter bandwidth syncing

Diffstat:
Mshim/CMakeLists.txt | 1+
Mshim/src/main.cpp | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/civ.rs | 268++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/lib.rs | 18+++++++++++++++++-
Msrc/sync.rs | 126++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
5 files changed, 452 insertions(+), 17 deletions(-)

diff --git a/shim/CMakeLists.txt b/shim/CMakeLists.txt @@ -29,6 +29,7 @@ add_library(${PROJECT_NAME} SHARED ${SRC}) target_include_directories(${PROJECT_NAME} PRIVATE "${SDRPP_DIR}/core/src" "${SDRPP_DIR}/core/src/imgui" + "${SDRPP_DIR}/decoder_modules/radio/src" ${OPENGL_INCLUDE_DIRS} ) diff --git a/shim/src/main.cpp b/shim/src/main.cpp @@ -1,9 +1,11 @@ #include <imgui.h> #include <module.h> +#include <core.h> #include <gui/gui.h> #include <gui/tuner.h> #include <signal_path/signal_path.h> #include <utils/flog.h> +#include <radio_interface.h> #include <cstring> #include <cstdint> @@ -20,19 +22,31 @@ extern "C" { void civ_status(void* ctx, char* out, size_t out_len); void civ_set_serial_port(void* ctx, const char* port); void civ_set_serial_baud(void* ctx, uint32_t baud); + void civ_set_sync_mode(void* ctx, bool sync_mode); } SDRPP_MOD_INFO{ /* Name: */ "civ_sync", - /* Description: */ "Sync an Icom IC-7300 (CI-V) with an SDR++ VFO", + /* Description: */ "Sync an Icom IC-7300 (CI-V) with an SDR++ VFO (freq/mode/filter)", /* Author: */ "vfo-sync", - /* Version: */ 0, 1, 0, + /* Version: */ 0, 2, 0, /* Max instances */ 1 }; // Guard the GUI-side state accessors (waterfall/VFO) called off-thread. static std::mutex guiMtx; +// Returns the selected VFO name if it is bound to the "radio" demodulator, +// or "" otherwise. Caller must hold guiMtx. +static std::string selectedRadioVfo() { + std::string vfo = gui::waterfall.selectedVFO; + if (!core::modComManager.interfaceExists(vfo) || + core::modComManager.getModuleName(vfo) != "radio") { + return ""; + } + return vfo; +} + extern "C" void civ_bridge_tune(double freq) { std::lock_guard<std::mutex> lck(guiMtx); tuner::tune(tuner::TUNER_MODE_NORMAL, gui::waterfall.selectedVFO, freq); @@ -47,6 +61,39 @@ extern "C" double civ_bridge_get_vfo_freq() { return freq; } +extern "C" int civ_bridge_get_mode() { + std::lock_guard<std::mutex> lck(guiMtx); + std::string vfo = selectedRadioVfo(); + if (vfo.empty()) { return -1; } + int mode = -1; + core::modComManager.callInterface(vfo, RADIO_IFACE_CMD_GET_MODE, NULL, &mode); + return mode; +} + +extern "C" void civ_bridge_set_mode(int mode) { + std::lock_guard<std::mutex> lck(guiMtx); + std::string vfo = selectedRadioVfo(); + if (vfo.empty()) { return; } + core::modComManager.callInterface(vfo, RADIO_IFACE_CMD_SET_MODE, &mode, NULL); +} + +extern "C" double civ_bridge_get_bandwidth() { + std::lock_guard<std::mutex> lck(guiMtx); + std::string vfo = selectedRadioVfo(); + if (vfo.empty()) { return 0.0; } + float bw = 0.0f; + core::modComManager.callInterface(vfo, RADIO_IFACE_CMD_GET_BANDWIDTH, NULL, &bw); + return (double)bw; +} + +extern "C" void civ_bridge_set_bandwidth(double hz) { + std::lock_guard<std::mutex> lck(guiMtx); + std::string vfo = selectedRadioVfo(); + if (vfo.empty()) { return; } + float bw = (float)hz; + core::modComManager.callInterface(vfo, RADIO_IFACE_CMD_SET_BANDWIDTH, &bw, NULL); +} + extern "C" void civ_bridge_log(int level, const char* msg) { if (msg == nullptr) { return; } switch (level) { @@ -136,6 +183,10 @@ private: } } + if (ImGui::Checkbox("Sync mode & filter", &_this->syncMode)) { + civ_set_sync_mode(_this->rustCtx, _this->syncMode); + } + if (ImGui::Button(_this->isEnabled() ? "Disconnect" : "Connect")) { if (_this->isEnabled()) { _this->disable(); @@ -152,6 +203,7 @@ private: uint32_t baud = 0; bool customBaudMode = false; int customBaud = 115200; + bool syncMode = true; }; MOD_EXPORT void _INIT_() { diff --git a/src/civ.rs b/src/civ.rs @@ -26,6 +26,18 @@ const CMD_BROADCAST_FREQ: u8 = 0x00; /// the radio during baud-rate auto-detection. const CMD_READ_FREQ: u8 = 0x03; +/// "Sets and outputs the current operating mode" — what the radio broadcasts +/// on a mode change when CI-V Transceive is enabled. +const CMD_BROADCAST_MODE: u8 = 0x01; +/// "Reads the current operating mode". +const CMD_READ_MODE: u8 = 0x04; +/// "Sets the current operating mode" (set-only, no echo). +const CMD_SET_MODE: u8 = 0x06; +/// "Misc memory/bank/rig control" — carrier for the filter-width subcommand. +const CMD_CTL_MEM: u8 = 0x1a; +/// `1A 03` subcommand: current passband (IF) filter width. +const SUB_FILT_WDTH: u8 = 0x03; + fn to_bcd(n: u8) -> u8 { ((n / 10) * 16) + (n % 10) } @@ -91,9 +103,175 @@ pub fn encode_read_freq() -> Vec<u8> { ] } +/// IC-7300 operating-mode data byte values (for the `0x01`/`0x04`/`0x06` +/// commands). See "Operating mode" in the CI-V reference guide. +pub mod icmode { + pub const LSB: u8 = 0x00; + pub const USB: u8 = 0x01; + pub const AM: u8 = 0x02; + pub const CW: u8 = 0x03; + pub const RTTY: u8 = 0x04; + pub const FM: u8 = 0x05; + pub const CW_R: u8 = 0x07; + pub const RTTY_R: u8 = 0x08; +} + +/// SDR++ radio demodulator IDs. These mirror the `RADIO_IFACE_MODE_*` / `DemodID` +/// enum in SDR++'s `radio` decoder module (`decoder_modules/radio/src`). +/// A few constants are unused here but kept for completeness/documentation. +#[allow(dead_code)] +pub mod sdrmode { + pub const NFM: i32 = 0; + pub const WFM: i32 = 1; + pub const AM: i32 = 2; + pub const DSB: i32 = 3; + pub const USB: i32 = 4; + pub const CW: i32 = 5; + pub const LSB: i32 = 6; + pub const RAW: i32 = 7; +} + +/// "Sets the current operating mode" (`0x06`, set-only, no echo). +pub fn encode_set_mode(mode: u8) -> Vec<u8> { + vec![ + PREAMBLE[0], + PREAMBLE[1], + RADIO_ADDR, + CONTROLLER_ADDR, + CMD_SET_MODE, + mode, + TERMINATOR, + ] +} + +/// "Sets the current passband filter width" (`1A 03 <index>`) to one of the +/// IC-7300's discrete filter steps. +pub fn encode_set_filter(index: u8) -> Vec<u8> { + vec![ + PREAMBLE[0], + PREAMBLE[1], + RADIO_ADDR, + CONTROLLER_ADDR, + CMD_CTL_MEM, + SUB_FILT_WDTH, + to_bcd(index), + TERMINATOR, + ] +} + +/// "Reads the current passband filter width" (`1A 03`, no data). The radio +/// replies with a `1A 03 <index>` frame. +pub fn encode_read_filter() -> Vec<u8> { + vec![ + PREAMBLE[0], + PREAMBLE[1], + RADIO_ADDR, + CONTROLLER_ADDR, + CMD_CTL_MEM, + SUB_FILT_WDTH, + TERMINATOR, + ] +} + +/// Translate an IC-7300 operating-mode byte to an SDR++ demodulator ID. +/// +/// Best-effort for modes with no exact SDR++ equivalent: `RTTY` → `USB`, +/// `CW-R` → `CW`, `RTTY-R` → `LSB`, `FM` → `NFM`. +pub fn icmode_to_sdrmode(mode: u8) -> Option<i32> { + use icmode as r; + use sdrmode as s; + Some(match mode { + r::LSB => s::LSB, + r::USB => s::USB, + r::AM => s::AM, + r::CW => s::CW, + r::RTTY => s::USB, + r::FM => s::NFM, + r::CW_R => s::CW, + r::RTTY_R => s::LSB, + _ => return None, + }) +} + +/// Translate an SDR++ demodulator ID to an IC-7300 operating-mode byte. +/// +/// `WFM`/`DSB`/`RAW` have no IC-7300 equivalent and return `None`. +pub fn sdrmode_to_icmode(mode: i32) -> Option<u8> { + use icmode as r; + use sdrmode as s; + Some(match mode { + s::LSB => r::LSB, + s::USB => r::USB, + s::AM => r::AM, + s::CW => r::CW, + s::NFM => r::FM, + _ => return None, + }) +} + +/// Whether bandwidth sync applies to a given SDR++ mode. SSB/CW/AM have a +/// `1A 03` filter width; FM/NFM uses fixed FIL1/2/3 presets instead. +pub fn bandwidth_applicable(sdr_mode: i32) -> bool { + matches!( + sdr_mode, + sdrmode::AM | sdrmode::USB | sdrmode::LSB | sdrmode::CW + ) +} + +/// SSB/CW/RTTY filter width in Hz for a filter index (0..=40). +/// Indices 0..=9 are 50..500 Hz (50 Hz steps); 10..=40 are 600..3600 Hz +/// (100 Hz steps). See "IF filter width settings" in the CI-V reference guide. +fn ssb_filter_hz(index: u8) -> Option<u32> { + match index { + 0..=9 => Some(50 + u32::from(index) * 50), + 10..=40 => Some(600 + u32::from(index - 10) * 100), + _ => None, + } +} + +/// AM filter width in Hz for a filter index (0..=49): 200 Hz..10 kHz in 200 Hz +/// steps. +fn am_filter_hz(index: u8) -> Option<u32> { + if index <= 49 { + Some(200 + u32::from(index) * 200) + } else { + None + } +} + +/// Map a filter index to Hz for the given mode class. +pub fn filter_index_to_hz(is_am: bool, index: u8) -> Option<u32> { + if is_am { + am_filter_hz(index) + } else { + ssb_filter_hz(index) + } +} + +/// Map a bandwidth in Hz to the nearest IC-7300 filter index for the given mode +/// class. +pub fn hz_to_filter_index(is_am: bool, hz: u32) -> Option<u8> { + let max = if is_am { 49u8 } else { 40u8 }; + let mut best: Option<u8> = None; + let mut best_diff = u32::MAX; + for i in 0..=max { + let h = filter_index_to_hz(is_am, i)?; + let d = h.abs_diff(hz); + if d < best_diff { + best_diff = d; + best = Some(i); + } + } + best +} + #[derive(Debug, PartialEq, Eq)] pub enum CivMsg { Freq(Freq), + /// Operating mode byte (IC-7300 `icmode::*`). + Mode(u8), + /// Filter-width index (0..=49, BCD-decoded). + Filter(u8), } #[derive(Debug, PartialEq, Eq)] @@ -130,6 +308,17 @@ impl TryFrom<&[u8]> for CivMsg { + from_bcd(frame[8]) as Freq * 1_000_000; return Ok(CivMsg::Freq(freq)); } + // `0x01` (transceive broadcast) and `0x04` (read reply) carry the + // operating mode as the first data byte; an optional FIL1/2/3 filter + // byte follows and is ignored. + if (frame[4] == CMD_BROADCAST_MODE || frame[4] == CMD_READ_MODE) && frame.len() >= 7 { + return Ok(CivMsg::Mode(frame[5])); + } + // `1A 03 <index>` — current passband filter width (read reply), a + // single BCD-encoded filter index. + if frame[4] == CMD_CTL_MEM && frame[5] == SUB_FILT_WDTH && frame.len() >= 8 { + return Ok(CivMsg::Filter(from_bcd(frame[6]))); + } Err(CivMsgError::WrongCommand) } } @@ -269,7 +458,7 @@ mod tests { 0xfe, 0xfe, 0xe1, 0x55, 0x00, 0x00, 0x00, 0x10, 0x07, 0x00, 0xfd, ]; let wrong_cmd = [ - 0xfe, 0xfe, 0xe1, 0x94, 0x04, 0x00, 0x00, 0x10, 0x07, 0x00, 0xfd, + 0xfe, 0xfe, 0xe1, 0x94, 0x09, 0x00, 0x00, 0x10, 0x07, 0x00, 0xfd, ]; let short = [0xfe, 0xfe, 0xe1, 0x94]; let bad_end = [ @@ -332,4 +521,81 @@ mod tests { let out = f.push(&rest); assert_eq!(out, vec![frame.to_vec()]); } + + #[test] + fn encode_set_mode_uses_command_06() { + let frame = encode_set_mode(icmode::USB); + assert_eq!(frame, [0xfe, 0xfe, 0x94, 0xe1, 0x06, icmode::USB, 0xfd]); + } + + #[test] + fn encode_set_filter_uses_1a_03() { + // index 28 -> BCD 0x28 + let frame = encode_set_filter(28); + assert_eq!(frame, [0xfe, 0xfe, 0x94, 0xe1, 0x1a, 0x03, 0x28, 0xfd]); + } + + #[test] + fn parse_mode_broadcast() { + // Radio transceive mode broadcast: FE FE 00 94 01 <mode> FD + let frame = [0xfe, 0xfe, 0x00, 0x94, 0x01, icmode::USB, 0xfd]; + assert_eq!(CivMsg::try_from(&frame[..]), Ok(CivMsg::Mode(icmode::USB))); + } + + #[test] + fn parse_mode_read_reply() { + // Reply to 0x04 read: FE FE E1 94 04 <mode> <filter> FD + let frame = [0xfe, 0xfe, 0xe1, 0x94, 0x04, icmode::LSB, 0x01, 0xfd]; + assert_eq!(CivMsg::try_from(&frame[..]), Ok(CivMsg::Mode(icmode::LSB))); + } + + #[test] + fn parse_filter_read_reply() { + // Reply to 1A 03 read: FE FE E1 94 1A 03 <bcd index> FD + let frame = [0xfe, 0xfe, 0xe1, 0x94, 0x1a, 0x03, 0x28, 0xfd]; + assert_eq!(CivMsg::try_from(&frame[..]), Ok(CivMsg::Filter(28))); + } + + #[test] + fn mode_mapping_exact_and_best_effort() { + assert_eq!(icmode_to_sdrmode(icmode::LSB), Some(sdrmode::LSB)); + assert_eq!(icmode_to_sdrmode(icmode::USB), Some(sdrmode::USB)); + assert_eq!(icmode_to_sdrmode(icmode::AM), Some(sdrmode::AM)); + assert_eq!(icmode_to_sdrmode(icmode::CW), Some(sdrmode::CW)); + // best effort + assert_eq!(icmode_to_sdrmode(icmode::RTTY), Some(sdrmode::USB)); + assert_eq!(icmode_to_sdrmode(icmode::CW_R), Some(sdrmode::CW)); + assert_eq!(icmode_to_sdrmode(icmode::RTTY_R), Some(sdrmode::LSB)); + assert_eq!(icmode_to_sdrmode(icmode::FM), Some(sdrmode::NFM)); + + assert_eq!(sdrmode_to_icmode(sdrmode::LSB), Some(icmode::LSB)); + assert_eq!(sdrmode_to_icmode(sdrmode::USB), Some(icmode::USB)); + assert_eq!(sdrmode_to_icmode(sdrmode::AM), Some(icmode::AM)); + assert_eq!(sdrmode_to_icmode(sdrmode::CW), Some(icmode::CW)); + assert_eq!(sdrmode_to_icmode(sdrmode::NFM), Some(icmode::FM)); + assert_eq!(sdrmode_to_icmode(sdrmode::WFM), None); + assert_eq!(sdrmode_to_icmode(sdrmode::DSB), None); + assert_eq!(sdrmode_to_icmode(sdrmode::RAW), None); + } + + #[test] + fn filter_width_mapping() { + // SSB/CW table: index 0 = 50 Hz, 9 = 500 Hz, 10 = 600 Hz, 40 = 3600 Hz. + assert_eq!(filter_index_to_hz(false, 0), Some(50)); + assert_eq!(filter_index_to_hz(false, 9), Some(500)); + assert_eq!(filter_index_to_hz(false, 10), Some(600)); + assert_eq!(filter_index_to_hz(false, 40), Some(3600)); + assert_eq!(filter_index_to_hz(false, 41), None); + // AM table: 0 = 200 Hz, 49 = 10000 Hz. + assert_eq!(filter_index_to_hz(true, 0), Some(200)); + assert_eq!(filter_index_to_hz(true, 49), Some(10_000)); + assert_eq!(filter_index_to_hz(true, 50), None); + + // Nearest-index round trips. + assert_eq!(hz_to_filter_index(false, 3600), Some(40)); + assert_eq!(hz_to_filter_index(false, 250), Some(4)); // 250 Hz + assert_eq!(hz_to_filter_index(true, 1000), Some(4)); // 1000 Hz + // Slightly off-step snaps to the nearest step (600 vs 700 -> 700). + assert_eq!(hz_to_filter_index(false, 670), Some(11)); + } } diff --git a/src/lib.rs b/src/lib.rs @@ -16,6 +16,10 @@ extern "C" { fn civ_bridge_tune(freq: f64); fn civ_bridge_get_vfo_freq() -> f64; fn civ_bridge_log(level: i32, msg: *const c_char); + fn civ_bridge_get_mode() -> i32; + fn civ_bridge_set_mode(mode: i32); + fn civ_bridge_get_bandwidth() -> f64; + fn civ_bridge_set_bandwidth(hz: f64); } struct FlogLogger; @@ -54,6 +58,7 @@ pub struct CivCtx { name: String, port: String, baud: u32, + sync_mode: bool, enabled: bool, status: Arc<Mutex<String>>, stop: Arc<AtomicBool>, @@ -74,6 +79,7 @@ pub unsafe extern "C" fn civ_create(name: *const c_char) -> *mut CivCtx { name: name.clone(), port: DEFAULT_PORT.to_string(), baud: 0, + sync_mode: true, enabled: false, status: Arc::new(Mutex::new("Disconnected".to_string())), stop: Arc::new(AtomicBool::new(false)), @@ -116,11 +122,12 @@ pub unsafe extern "C" fn civ_enable(ctx: *mut CivCtx) { ctx.stop.store(false, Ordering::Relaxed); let port = ctx.port.clone(); let baud = ctx.baud; + let sync_mode = ctx.sync_mode; let stop = Arc::clone(&ctx.stop); let status = Arc::clone(&ctx.status); debug!("starting sync thread (instance: {})", ctx.name); ctx.handle = Some(std::thread::spawn(move || { - sync::run(port, baud, stop, status); + sync::run(port, baud, sync_mode, stop, status); })); } @@ -200,3 +207,12 @@ pub unsafe extern "C" fn civ_set_serial_baud(ctx: *mut CivCtx, baud: u32) { ); (*ctx).baud = baud; } + +#[no_mangle] +pub unsafe extern "C" fn civ_set_sync_mode(ctx: *mut CivCtx, sync_mode: bool) { + if ctx.is_null() { + return; + } + debug!("mode/filter sync set to {}", sync_mode); + (*ctx).sync_mode = sync_mode; +} diff --git a/src/sync.rs b/src/sync.rs @@ -8,18 +8,26 @@ use std::time::{Duration, Instant}; use log::{debug, error, trace}; use serialport::{DataBits, Parity, StopBits}; -use crate::civ::{self, CivMsg, Framer, Freq, TOL_HZ}; +use crate::civ::{self, CivMsg, CivMsgError, Framer, Freq, TOL_HZ}; const DEFAULT_BAUD: u32 = 115_200; const DETECT_TIMEOUT: Duration = Duration::from_millis(300); const READ_TIMEOUT: Duration = Duration::from_millis(100); const POLL_INTERVAL: Duration = Duration::from_millis(100); +/// How often to poll the radio's filter width (radio -> SDR bandwidth). +const FILTER_POLL_INTERVAL: Duration = Duration::from_millis(500); /// Candidate rates probed during auto-detection, most common first. Standard /// Icom CI-V rates (19200/9600/4800) precede the USB-only high rates. const BAUD_CANDIDATES: [u32; 6] = [19_200, 9_600, 4_800, 115_200, 38_400, 57_600]; -pub fn run(port: String, baud: u32, stop: Arc<AtomicBool>, status: Arc<Mutex<String>>) { +pub fn run( + port: String, + baud: u32, + sync_mode: bool, + stop: Arc<AtomicBool>, + status: Arc<Mutex<String>>, +) { set_status(&status, "Connecting..."); // 0 means "auto": probe the radio with a read-frequency command at each @@ -50,12 +58,18 @@ pub fn run(port: String, baud: u32, stop: Arc<AtomicBool>, status: Arc<Mutex<Str // Adopt the SDR's current frequency as the baseline so we don't force a // retune in either direction on connect. - let mut current = unsafe { crate::civ_bridge_get_vfo_freq() }.round() as Freq; - debug!("initial frequency baseline: {} Hz", current); + let mut freq = unsafe { crate::civ_bridge_get_vfo_freq() }.round() as Freq; + debug!("initial frequency baseline: {} Hz", freq); + + // Baseline mode (-1 means the SDR VFO is not a "radio" demodulator) and + // bandwidth for the mode/filter sync. + let mut mode = unsafe { crate::civ_bridge_get_mode() }; + let mut bw = unsafe { crate::civ_bridge_get_bandwidth() }.round() as u32; let mut framer = Framer::new(); let mut read_buf = [0u8; 1024]; let mut last_poll = Instant::now(); + let mut last_filter_poll = Instant::now(); while !stop.load(Ordering::Relaxed) { // Radio -> SDR: parse incoming CI-V frames. @@ -65,14 +79,40 @@ pub fn run(port: String, baud: u32, stop: Arc<AtomicBool>, status: Arc<Mutex<Str trace!("read {} bytes: {:02x?}", n, &read_buf[..n]); for frame in framer.push(&read_buf[..n]) { match CivMsg::try_from(frame.as_slice()) { - Ok(CivMsg::Freq(freq)) => { - trace!("radio -> SDR: {} Hz", freq); - if freq_diff(freq, current) > TOL_HZ { - debug!("tuning SDR to {} Hz", freq); - unsafe { crate::civ_bridge_tune(freq as f64) }; - current = freq; + Ok(CivMsg::Freq(f)) => { + trace!("radio -> SDR: {} Hz", f); + if freq_diff(f, freq) > TOL_HZ { + debug!("tuning SDR to {} Hz", f); + unsafe { crate::civ_bridge_tune(f as f64) }; + freq = f; + } + } + Ok(CivMsg::Mode(m)) if sync_mode => { + if let Some(s) = civ::icmode_to_sdrmode(m) { + if s != mode { + debug!("radio -> SDR mode: {}", s); + unsafe { crate::civ_bridge_set_mode(s) }; + mode = s; + bw = + unsafe { crate::civ_bridge_get_bandwidth() }.round() as u32; + } + } + } + Ok(CivMsg::Filter(i)) if sync_mode => { + let is_am = mode == civ::sdrmode::AM; + if let Some(hz) = civ::filter_index_to_hz(is_am, i) { + if freq_diff(hz, bw) > bw_tolerance(is_am) { + debug!("radio -> SDR bandwidth: {} Hz", hz); + unsafe { crate::civ_bridge_set_bandwidth(hz as f64) }; + bw = hz; + } } } + Ok(_) => {} + Err(CivMsgError::WrongAddress) => { + // Echo of our own command (`to`/`from` = `94 E1`), + // e.g. the `1A 03` read poll; expected and noisy. + } Err(e) => debug!("ignored CI-V frame ({:?}): {:02x?}", e, frame), } } @@ -85,10 +125,10 @@ pub fn run(port: String, baud: u32, stop: Arc<AtomicBool>, status: Arc<Mutex<Str } } - // SDR -> Radio: poll the VFO frequency every 100 ms. + // SDR -> Radio: poll frequency/mode/bandwidth every 100 ms. if last_poll.elapsed() >= POLL_INTERVAL { let sdr = unsafe { crate::civ_bridge_get_vfo_freq() }.round() as Freq; - if freq_diff(sdr, current) > TOL_HZ { + if freq_diff(sdr, freq) > TOL_HZ { debug!("setting radio to {} Hz", sdr); let frame = civ::encode_set_freq(sdr); if let Err(e) = serial.write_all(&frame) { @@ -96,10 +136,59 @@ pub fn run(port: String, baud: u32, stop: Arc<AtomicBool>, status: Arc<Mutex<Str error!("serial write failed: {}", e); break; } - current = sdr; + freq = sdr; } + + if sync_mode { + let s = unsafe { crate::civ_bridge_get_mode() }; + if s >= 0 && s != mode { + if let Some(m) = civ::sdrmode_to_icmode(s) { + debug!("setting radio mode to {:02x}", m); + let frame = civ::encode_set_mode(m); + if let Err(e) = serial.write_all(&frame) { + set_status(&status, &format!("Serial error: {}", e)); + error!("serial write failed: {}", e); + break; + } + } + mode = s; + bw = unsafe { crate::civ_bridge_get_bandwidth() }.round() as u32; + } + + if civ::bandwidth_applicable(mode) { + let sdr_bw = unsafe { crate::civ_bridge_get_bandwidth() }.round() as u32; + let is_am = mode == civ::sdrmode::AM; + if freq_diff(sdr_bw, bw) > bw_tolerance(is_am) { + if let Some(i) = civ::hz_to_filter_index(is_am, sdr_bw) { + debug!("setting radio filter to index {}", i); + let frame = civ::encode_set_filter(i); + if let Err(e) = serial.write_all(&frame) { + set_status(&status, &format!("Serial error: {}", e)); + error!("serial write failed: {}", e); + break; + } + bw = civ::filter_index_to_hz(is_am, i).unwrap_or(sdr_bw); + } + } + } + } + last_poll = Instant::now(); } + + // Radio -> SDR bandwidth: poll the radio's filter width (no transceive + // broadcast for width, so read it explicitly). + if sync_mode && last_filter_poll.elapsed() >= FILTER_POLL_INTERVAL { + if civ::bandwidth_applicable(mode) { + let frame = civ::encode_read_filter(); + if let Err(e) = serial.write_all(&frame) { + set_status(&status, &format!("Serial error: {}", e)); + error!("serial write failed: {}", e); + break; + } + } + last_filter_poll = Instant::now(); + } } set_status(&status, "Disconnected"); @@ -157,6 +246,17 @@ fn freq_diff(a: Freq, b: Freq) -> i64 { (a as i64 - b as i64).abs() } +/// Half-step bandwidth tolerance in Hz. AM steps are 200 Hz, SSB/CW steps are +/// 50/100 Hz, so this is enough to avoid ping-pong while still tracking real +/// changes. +fn bw_tolerance(is_am: bool) -> i64 { + if is_am { + 100 + } else { + 50 + } +} + fn set_status(status: &Arc<Mutex<String>>, text: &str) { if let Ok(mut s) = status.lock() { *s = text.to_string();