From 54fb107d3bea8d6518f940dedfab93cea981b4f5 Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Fri, 27 Feb 2026 22:48:12 +0100 Subject: [PATCH] [fix](trx-rs): keep SDR center frequency stable in-band Signed-off-by: Stan Grams Co-authored-by: OpenAI Codex --- .../trx-frontend-http/assets/web/app.js | 52 ++++++++++++++++++- .../trx-frontend-http/assets/web/index.html | 4 ++ .../trx-frontend-http/assets/web/style.css | 3 +- .../trx-frontend/trx-frontend-http/src/api.rs | 9 ++++ .../trx-frontend-http/src/auth.rs | 4 ++ src/trx-core/src/rig/command.rs | 1 + src/trx-core/src/rig/controller/handlers.rs | 1 + src/trx-core/src/rig/mod.rs | 10 ++++ src/trx-protocol/src/mapping.rs | 2 + src/trx-protocol/src/types.rs | 1 + src/trx-server/src/rig_task.rs | 7 +++ .../trx-backend-soapysdr/src/lib.rs | 52 ++++++++++++++----- 12 files changed, 130 insertions(+), 16 deletions(-) diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js index dde995d..4787b74 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js @@ -173,6 +173,7 @@ function applyAuthRestrictions() { const powerBtn = document.getElementById("power-btn"); const lockBtn = document.getElementById("lock-btn"); const freqInput = document.getElementById("freq"); + const centerFreqInput = document.getElementById("center-freq"); const modeSelect = document.getElementById("mode"); const txLimitInput = document.getElementById("tx-limit"); const txLimitBtn = document.getElementById("tx-limit-btn"); @@ -193,6 +194,7 @@ function applyAuthRestrictions() { // Disable frequency/mode inputs if (freqInput) freqInput.disabled = true; + if (centerFreqInput) centerFreqInput.disabled = true; if (modeSelect) modeSelect.disabled = true; if (txLimitInput) txLimitInput.disabled = true; @@ -260,18 +262,22 @@ function applyCapabilities(caps) { // Spectrum panel (SDR-only) const spectrumPanel = document.getElementById("spectrum-panel"); + const centerFreqField = document.getElementById("center-freq-field"); if (spectrumPanel) { if (caps.filter_controls) { spectrumPanel.style.display = ""; + if (centerFreqField) centerFreqField.style.display = ""; startSpectrumPolling(); } else { spectrumPanel.style.display = "none"; + if (centerFreqField) centerFreqField.style.display = "none"; stopSpectrumPolling(); } } } const freqEl = document.getElementById("freq"); +const centerFreqEl = document.getElementById("center-freq"); const wavelengthEl = document.getElementById("wavelength"); const modeEl = document.getElementById("mode"); const bandLabel = document.getElementById("band-label"); @@ -316,6 +322,7 @@ let sigMeasureAccumMs = 0; let sigMeasureWeighted = 0; let sigMeasurePeak = null; let lastFreqHz = null; +let centerFreqDirty = false; let jogStep = loadSetting("jogStep", 1000); let minFreqStepHz = 1; const VFO_COLORS = ["var(--accent-green)", "var(--accent-yellow)"]; @@ -619,6 +626,11 @@ function refreshFreqDisplay() { refreshWavelengthDisplay(lastFreqHz); } +function refreshCenterFreqDisplay() { + if (!centerFreqEl || !lastSpectrumData || centerFreqDirty) return; + centerFreqEl.value = formatFreqForStep(lastSpectrumData.center_hz, jogStep); +} + function parseFreqInput(val, defaultStep) { if (!val) return null; const trimmed = val.trim().toLowerCase(); @@ -701,6 +713,7 @@ function updateJogStepSupport(cap) { }); refreshFreqDisplay(); + refreshCenterFreqDisplay(); } function normalizeMode(modeVal) { @@ -751,7 +764,7 @@ function formatSignal(sUnits) { } function setDisabled(disabled) { - [freqEl, modeEl, pttBtn, powerBtn, txLimitInput, txLimitBtn, lockBtn].forEach((el) => { + [freqEl, centerFreqEl, modeEl, pttBtn, powerBtn, txLimitInput, txLimitBtn, lockBtn].forEach((el) => { if (el) el.disabled = disabled; }); } @@ -1299,6 +1312,32 @@ async function applyFreqFromInput() { } } +async function applyCenterFreqFromInput() { + if (!centerFreqEl) return; + const parsedRaw = parseFreqInput(centerFreqEl.value, jogStep); + const parsed = alignFreqToRigStep(parsedRaw); + if (parsed === null) { + showHint("Central freq missing", 1500); + return; + } + if (!freqAllowed(parsed)) { + showHint("Out of supported bands", 1500); + return; + } + centerFreqDirty = false; + centerFreqEl.disabled = true; + showHint("Setting central frequency…"); + try { + await postPath(`/set_center_freq?hz=${parsed}`); + showHint("Central freq set", 1500); + } catch (err) { + showHint("Set central freq failed", 2000); + console.error(err); + } finally { + centerFreqEl.disabled = false; + } +} + freqEl.addEventListener("keydown", (e) => { freqDirty = true; if (e.key === "Enter") { @@ -1306,6 +1345,15 @@ freqEl.addEventListener("keydown", (e) => { applyFreqFromInput(); } }); +if (centerFreqEl) { + centerFreqEl.addEventListener("keydown", (e) => { + centerFreqDirty = true; + if (e.key === "Enter") { + e.preventDefault(); + applyCenterFreqFromInput(); + } + }); +} freqEl.addEventListener("wheel", (e) => { e.preventDefault(); const direction = e.deltaY < 0 ? 1 : -1; @@ -1394,6 +1442,7 @@ jogStepEl.addEventListener("click", (e) => { btn.classList.add("active"); saveSetting("jogStep", jogStep); refreshFreqDisplay(); + refreshCenterFreqDisplay(); }); // Restore active jog step button from saved setting @@ -2416,6 +2465,7 @@ async function fetchSpectrum() { if (resp.status === 204) { lastSpectrumData = null; clearSpectrumCanvas(); return; } if (!resp.ok) return; lastSpectrumData = await resp.json(); + refreshCenterFreqDisplay(); drawSpectrum(lastSpectrumData); } catch (_) {} } diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html index ed46f22..69b0e62 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html @@ -84,6 +84,10 @@
Frequency
+
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css index 3fe76cf..dee39a7 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css @@ -239,6 +239,7 @@ button:disabled { opacity: 0.6; cursor: not-allowed; } .inline { display: flex; gap: 0.5rem; align-items: center; } .freq-inline { align-items: flex-start; + flex-wrap: wrap; } .freq-field { display: grid; @@ -253,7 +254,7 @@ button:disabled { opacity: 0.6; cursor: not-allowed; } flex: 1 1 auto; min-width: 0; } -.frequency-col #freq { +.frequency-col input.status-input { width: 100%; height: 3.35rem; box-sizing: border-box; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs index 54fabfc..6b1ed71 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs @@ -355,6 +355,14 @@ pub async fn set_freq( send_command(&rig_tx, RigCommand::SetFreq(Freq { hz: query.hz })).await } +#[post("/set_center_freq")] +pub async fn set_center_freq( + query: web::Query, + rig_tx: web::Data>, +) -> Result { + send_command(&rig_tx, RigCommand::SetCenterFreq(Freq { hz: query.hz })).await +} + #[derive(serde::Deserialize)] pub struct ModeQuery { pub mode: String, @@ -633,6 +641,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) { .service(lock_panel) .service(unlock_panel) .service(set_freq) + .service(set_center_freq) .service(set_mode) .service(set_ptt) .service(set_tx_limit) diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/auth.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/auth.rs index 547a0c8..965d781 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/auth.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/auth.rs @@ -591,6 +591,10 @@ mod tests { #[test] fn test_route_access_control_paths() { assert_eq!(RouteAccess::from_path("/set_freq"), RouteAccess::Control); + assert_eq!( + RouteAccess::from_path("/set_center_freq"), + RouteAccess::Control + ); assert_eq!(RouteAccess::from_path("/set_mode"), RouteAccess::Control); } diff --git a/src/trx-core/src/rig/command.rs b/src/trx-core/src/rig/command.rs index 875d431..360e8e4 100644 --- a/src/trx-core/src/rig/command.rs +++ b/src/trx-core/src/rig/command.rs @@ -10,6 +10,7 @@ use crate::RigMode; pub enum RigCommand { GetSnapshot, SetFreq(Freq), + SetCenterFreq(Freq), SetMode(RigMode), SetPtt(bool), PowerOn, diff --git a/src/trx-core/src/rig/controller/handlers.rs b/src/trx-core/src/rig/controller/handlers.rs index 677c64e..7e4bcab 100644 --- a/src/trx-core/src/rig/controller/handlers.rs +++ b/src/trx-core/src/rig/controller/handlers.rs @@ -491,6 +491,7 @@ pub fn command_from_rig_command(cmd: RigCommand) -> Box { match cmd { RigCommand::GetSnapshot => Box::new(GetSnapshotCommand), RigCommand::SetFreq(freq) => Box::new(SetFreqCommand::new(freq)), + RigCommand::SetCenterFreq(_) => Box::new(GetSnapshotCommand), RigCommand::SetMode(mode) => Box::new(SetModeCommand::new(mode)), RigCommand::SetPtt(ptt) => Box::new(SetPttCommand::new(ptt)), RigCommand::PowerOn => Box::new(PowerOnCommand), diff --git a/src/trx-core/src/rig/mod.rs b/src/trx-core/src/rig/mod.rs index 78791af..f15f281 100644 --- a/src/trx-core/src/rig/mod.rs +++ b/src/trx-core/src/rig/mod.rs @@ -88,6 +88,16 @@ pub trait RigCat: Rig + Send { freq: Freq, ) -> Pin> + Send + 'a>>; + fn set_center_freq<'a>( + &'a mut self, + _freq: Freq, + ) -> Pin> + Send + 'a>> { + Box::pin(std::future::ready(Err( + Box::new(response::RigError::not_supported("set_center_freq")) + as Box, + ))) + } + fn set_mode<'a>( &'a mut self, mode: RigMode, diff --git a/src/trx-protocol/src/mapping.rs b/src/trx-protocol/src/mapping.rs index a83288e..f4c219d 100644 --- a/src/trx-protocol/src/mapping.rs +++ b/src/trx-protocol/src/mapping.rs @@ -21,6 +21,7 @@ pub fn client_command_to_rig(cmd: ClientCommand) -> RigCommand { } ClientCommand::GetState => RigCommand::GetSnapshot, ClientCommand::SetFreq { freq_hz } => RigCommand::SetFreq(Freq { hz: freq_hz }), + ClientCommand::SetCenterFreq { freq_hz } => RigCommand::SetCenterFreq(Freq { hz: freq_hz }), ClientCommand::SetMode { mode } => RigCommand::SetMode(parse_mode(&mode)), ClientCommand::SetPtt { ptt } => RigCommand::SetPtt(ptt), ClientCommand::PowerOn => RigCommand::PowerOn, @@ -59,6 +60,7 @@ pub fn rig_command_to_client(cmd: RigCommand) -> ClientCommand { match cmd { RigCommand::GetSnapshot => ClientCommand::GetState, RigCommand::SetFreq(freq) => ClientCommand::SetFreq { freq_hz: freq.hz }, + RigCommand::SetCenterFreq(freq) => ClientCommand::SetCenterFreq { freq_hz: freq.hz }, RigCommand::SetMode(mode) => ClientCommand::SetMode { mode: mode_to_string(&mode), }, diff --git a/src/trx-protocol/src/types.rs b/src/trx-protocol/src/types.rs index ca76e0a..eec9d52 100644 --- a/src/trx-protocol/src/types.rs +++ b/src/trx-protocol/src/types.rs @@ -15,6 +15,7 @@ pub enum ClientCommand { GetState, GetRigs, SetFreq { freq_hz: u64 }, + SetCenterFreq { freq_hz: u64 }, SetMode { mode: String }, SetPtt { ptt: bool }, PowerOn, diff --git a/src/trx-server/src/rig_task.rs b/src/trx-server/src/rig_task.rs index 9def828..540268f 100644 --- a/src/trx-server/src/rig_task.rs +++ b/src/trx-server/src/rig_task.rs @@ -449,6 +449,13 @@ async fn process_command( let _ = ctx.state_tx.send(ctx.state.clone()); return snapshot_from(ctx.state); } + RigCommand::SetCenterFreq(freq) => { + if let Err(e) = ctx.rig.set_center_freq(freq).await { + return Err(RigError::communication(format!("set_center_freq: {e}"))); + } + *ctx.poll_pause_until = Some(Instant::now() + Duration::from_millis(200)); + return snapshot_from(ctx.state); + } RigCommand::GetSpectrum => { // Fetch current spectrum and embed it in a one-shot snapshot. ctx.state.spectrum = ctx.rig.get_spectrum(); diff --git a/src/trx-server/trx-backend/trx-backend-soapysdr/src/lib.rs b/src/trx-server/trx-backend/trx-backend-soapysdr/src/lib.rs index 042012b..41025b7 100644 --- a/src/trx-server/trx-backend/trx-backend-soapysdr/src/lib.rs +++ b/src/trx-server/trx-backend/trx-backend-soapysdr/src/lib.rs @@ -33,6 +33,8 @@ pub struct SoapySdrRig { /// How many Hz below the dial frequency the SDR hardware is actually tuned. /// The DSP mixer compensates for this offset to demodulate the dial frequency. center_offset_hz: i64, + /// Actual hardware center frequency currently tuned on the SDR. + center_hz: i64, /// Used to send hardware retune commands to the IQ read loop. retune_cmd: Arc>>, } @@ -94,14 +96,13 @@ impl SoapySdrRig { let hardware_center_hz = initial_freq.hz as i64 - center_offset_hz; // Create real IQ source from hardware device. - let iq_source: Box = - Box::new(real_iq_source::RealIqSource::new( - args, - hardware_center_hz as f64, - sdr_sample_rate as f64, - bandwidth_hz as f64, - gain_db, - )?); + let iq_source: Box = Box::new(real_iq_source::RealIqSource::new( + args, + hardware_center_hz as f64, + sdr_sample_rate as f64, + bandwidth_hz as f64, + gain_db, + )?); let pipeline = dsp::SdrPipeline::start( iq_source, @@ -172,6 +173,7 @@ impl SoapySdrRig { fir_taps, spectrum_buf, center_offset_hz, + center_hz: hardware_center_hz, retune_cmd, }) } @@ -242,10 +244,34 @@ impl RigCat for SoapySdrRig { Box::pin(async move { tracing::debug!("SoapySdrRig: set_freq -> {} Hz", freq.hz); self.freq = freq; - // Retune the hardware center to keep the dial frequency off-DC. - let hardware_hz = freq.hz as i64 - self.center_offset_hz; + let half_span_hz = i128::from(self.pipeline.sdr_sample_rate) / 2; + let current_center_hz = i128::from(self.center_hz); + let target_hz = i128::from(freq.hz); + let within_current_span = target_hz >= current_center_hz - half_span_hz + && target_hz <= current_center_hz + half_span_hz; + + if !within_current_span { + // Only retune when the requested dial frequency leaves the + // currently captured SDR bandwidth. + let hardware_hz = freq.hz as i64 - self.center_offset_hz; + self.center_hz = hardware_hz; + if let Ok(mut cmd) = self.retune_cmd.lock() { + *cmd = Some(hardware_hz as f64); + } + } + Ok(()) + }) + } + + fn set_center_freq<'a>( + &'a mut self, + freq: Freq, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + tracing::debug!("SoapySdrRig: set_center_freq -> {} Hz", freq.hz); + self.center_hz = freq.hz as i64; if let Ok(mut cmd) = self.retune_cmd.lock() { - *cmd = Some(hardware_hz as f64); + *cmd = Some(self.center_hz as f64); } Ok(()) }) @@ -402,11 +428,9 @@ impl RigCat for SoapySdrRig { fn get_spectrum(&self) -> Option { let bins = self.spectrum_buf.lock().ok()?.clone()?; - // Report the actual hardware center frequency, not the dial frequency. - let center_hz = (self.freq.hz as i64 - self.center_offset_hz) as u64; Some(SpectrumData { bins, - center_hz, + center_hz: self.center_hz.max(0) as u64, sample_rate: self.pipeline.sdr_sample_rate, }) }