From 617255cd32c1a1ba4384ae394ba352e158d8f37b Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Sat, 28 Feb 2026 23:30:08 +0100 Subject: [PATCH] [feat](trx-rs): expose live sdr gain control Co-authored-by: OpenAI Codex Signed-off-by: Stan Grams --- .../trx-frontend-http/assets/web/app.js | 26 ++++++++++++++ .../trx-frontend-http/assets/web/index.html | 4 +++ .../trx-frontend-http/assets/web/style.css | 3 ++ .../trx-frontend/trx-frontend-http/src/api.rs | 14 ++++++++ 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-core/src/rig/state.rs | 2 ++ src/trx-protocol/src/codec.rs | 3 ++ src/trx-protocol/src/mapping.rs | 2 ++ src/trx-protocol/src/types.rs | 1 + src/trx-server/src/rig_task.rs | 8 +++++ .../trx-backend-soapysdr/src/dsp.rs | 23 +++++++++++++ .../trx-backend-soapysdr/src/lib.rs | 34 +++++++++++++++++++ .../src/real_iq_source.rs | 6 ++++ 15 files changed, 138 insertions(+) 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 31f059a..4269b59 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 @@ -1257,6 +1257,13 @@ function render(update) { if (update.filter && typeof update.filter.bandwidth_hz === "number") { currentBandwidthHz = update.filter.bandwidth_hz; syncBandwidthInput(currentBandwidthHz); + if ( + sdrGainEl + && typeof update.filter.sdr_gain_db === "number" + && document.activeElement !== sdrGainEl + ) { + sdrGainEl.value = String(Math.round(update.filter.sdr_gain_db)); + } if (wfmDeemphasisEl && typeof update.filter.wfm_deemphasis_us === "number") { wfmDeemphasisEl.value = String(update.filter.wfm_deemphasis_us); } @@ -2567,6 +2574,8 @@ const audioRow = document.getElementById("audio-row"); const wfmControlsCol = document.getElementById("wfm-controls-col"); const wfmDeemphasisEl = document.getElementById("wfm-deemphasis"); const wfmAudioModeEl = document.getElementById("wfm-audio-mode"); +const sdrGainEl = document.getElementById("sdr-gain-db"); +const sdrGainSetBtn = document.getElementById("sdr-gain-set"); const wfmStFlagEl = document.getElementById("wfm-st-flag"); // Hide audio row if audio is not configured on the server @@ -2611,6 +2620,23 @@ if (wfmDeemphasisEl) { postPath(`/set_wfm_deemphasis?us=${encodeURIComponent(wfmDeemphasisEl.value)}`).catch(() => {}); }); } +function submitSdrGain() { + if (!sdrGainEl) return; + const parsed = Number.parseFloat(sdrGainEl.value); + if (!Number.isFinite(parsed) || parsed < 0) return; + postPath(`/set_sdr_gain?db=${encodeURIComponent(parsed)}`).catch(() => {}); +} +if (sdrGainSetBtn) { + sdrGainSetBtn.addEventListener("click", submitSdrGain); +} +if (sdrGainEl) { + sdrGainEl.addEventListener("keydown", (ev) => { + if (ev.key === "Enter") { + ev.preventDefault(); + submitSdrGain(); + } + }); +} function updateWfmControls() { if (!wfmControlsCol) return; const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase(); 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 c58f93a..08fe3d9 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 @@ -165,6 +165,10 @@ + + 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 957618d..b8f3bf7 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 @@ -186,6 +186,9 @@ input.status-input, select.status-input { width: 100%; padding: 0.45rem 0.5rem; width: auto; font-size: 0.9rem; } +.wfm-control input.status-input { + width: 5rem; +} .wfm-control button.status-input { padding: 0.45rem 0.5rem; height: auto; 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 67dc8f1..28e7c14 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 @@ -473,6 +473,19 @@ pub async fn set_fir_taps( send_command(&rig_tx, RigCommand::SetFirTaps(query.taps)).await } +#[derive(serde::Deserialize)] +pub struct SdrGainQuery { + pub db: f64, +} + +#[post("/set_sdr_gain")] +pub async fn set_sdr_gain( + query: web::Query, + rig_tx: web::Data>, +) -> Result { + send_command(&rig_tx, RigCommand::SetSdrGain(query.db)).await +} + #[derive(serde::Deserialize)] pub struct WfmDeemphasisQuery { pub us: u32, @@ -710,6 +723,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) { .service(set_tx_limit) .service(set_bandwidth) .service(set_fir_taps) + .service(set_sdr_gain) .service(set_wfm_deemphasis) .service(set_wfm_stereo) .service(toggle_aprs_decode) diff --git a/src/trx-core/src/rig/command.rs b/src/trx-core/src/rig/command.rs index bc04636..3284a84 100644 --- a/src/trx-core/src/rig/command.rs +++ b/src/trx-core/src/rig/command.rs @@ -33,6 +33,7 @@ pub enum RigCommand { ResetWsprDecoder, SetBandwidth(u32), SetFirTaps(u32), + SetSdrGain(f64), SetWfmDeemphasis(u32), SetWfmStereo(bool), GetSpectrum, diff --git a/src/trx-core/src/rig/controller/handlers.rs b/src/trx-core/src/rig/controller/handlers.rs index 8d5e42b..c505f7f 100644 --- a/src/trx-core/src/rig/controller/handlers.rs +++ b/src/trx-core/src/rig/controller/handlers.rs @@ -516,6 +516,7 @@ pub fn command_from_rig_command(cmd: RigCommand) -> Box { | RigCommand::ResetWsprDecoder | RigCommand::SetBandwidth(_) | RigCommand::SetFirTaps(_) + | RigCommand::SetSdrGain(_) | RigCommand::SetWfmDeemphasis(_) | RigCommand::SetWfmStereo(_) | RigCommand::GetSpectrum => Box::new(GetSnapshotCommand), diff --git a/src/trx-core/src/rig/mod.rs b/src/trx-core/src/rig/mod.rs index 7eb675f..ffe7004 100644 --- a/src/trx-core/src/rig/mod.rs +++ b/src/trx-core/src/rig/mod.rs @@ -165,6 +165,16 @@ pub trait RigCat: Rig + Send { ))) } + fn set_sdr_gain<'a>( + &'a mut self, + _gain_db: f64, + ) -> Pin> + Send + 'a>> { + Box::pin(std::future::ready(Err( + Box::new(response::RigError::not_supported("set_sdr_gain")) + as Box, + ))) + } + fn set_wfm_stereo<'a>( &'a mut self, _enabled: bool, diff --git a/src/trx-core/src/rig/state.rs b/src/trx-core/src/rig/state.rs index cc6fa46..dd8f59d 100644 --- a/src/trx-core/src/rig/state.rs +++ b/src/trx-core/src/rig/state.rs @@ -269,6 +269,8 @@ pub struct RigFilterState { pub bandwidth_hz: u32, pub fir_taps: u32, pub cw_center_hz: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sdr_gain_db: Option, #[serde(default = "default_wfm_deemphasis_us")] pub wfm_deemphasis_us: u32, #[serde(default = "default_wfm_stereo")] diff --git a/src/trx-protocol/src/codec.rs b/src/trx-protocol/src/codec.rs index c2214e3..e5e7227 100644 --- a/src/trx-protocol/src/codec.rs +++ b/src/trx-protocol/src/codec.rs @@ -297,6 +297,7 @@ mod tests { bandwidth_hz: 3000, fir_taps: 64, cw_center_hz: 700, + sdr_gain_db: Some(12.0), wfm_deemphasis_us: 75, wfm_stereo: true, wfm_stereo_detected: false, @@ -335,6 +336,7 @@ mod tests { bandwidth_hz: 12000, fir_taps: 128, cw_center_hz: 700, + sdr_gain_db: Some(18.0), wfm_deemphasis_us: 50, wfm_stereo: true, wfm_stereo_detected: true, @@ -346,6 +348,7 @@ mod tests { let f = decoded.filter.expect("filter should round-trip"); assert_eq!(f.bandwidth_hz, 12000); assert_eq!(f.fir_taps, 128); + assert_eq!(f.sdr_gain_db, Some(18.0)); assert_eq!(f.wfm_deemphasis_us, 50); assert!(f.wfm_stereo_detected); } diff --git a/src/trx-protocol/src/mapping.rs b/src/trx-protocol/src/mapping.rs index 5ae4530..3ccac76 100644 --- a/src/trx-protocol/src/mapping.rs +++ b/src/trx-protocol/src/mapping.rs @@ -48,6 +48,7 @@ pub fn client_command_to_rig(cmd: ClientCommand) -> RigCommand { ClientCommand::ResetWsprDecoder => RigCommand::ResetWsprDecoder, ClientCommand::SetBandwidth { bandwidth_hz } => RigCommand::SetBandwidth(bandwidth_hz), ClientCommand::SetFirTaps { taps } => RigCommand::SetFirTaps(taps), + ClientCommand::SetSdrGain { gain_db } => RigCommand::SetSdrGain(gain_db), ClientCommand::SetWfmDeemphasis { deemphasis_us } => { RigCommand::SetWfmDeemphasis(deemphasis_us) } @@ -93,6 +94,7 @@ pub fn rig_command_to_client(cmd: RigCommand) -> ClientCommand { RigCommand::ResetWsprDecoder => ClientCommand::ResetWsprDecoder, RigCommand::SetBandwidth(bandwidth_hz) => ClientCommand::SetBandwidth { bandwidth_hz }, RigCommand::SetFirTaps(taps) => ClientCommand::SetFirTaps { taps }, + RigCommand::SetSdrGain(gain_db) => ClientCommand::SetSdrGain { gain_db }, RigCommand::SetWfmDeemphasis(deemphasis_us) => { ClientCommand::SetWfmDeemphasis { deemphasis_us } } diff --git a/src/trx-protocol/src/types.rs b/src/trx-protocol/src/types.rs index f118b56..b4804e0 100644 --- a/src/trx-protocol/src/types.rs +++ b/src/trx-protocol/src/types.rs @@ -38,6 +38,7 @@ pub enum ClientCommand { ResetWsprDecoder, SetBandwidth { bandwidth_hz: u32 }, SetFirTaps { taps: u32 }, + SetSdrGain { gain_db: f64 }, SetWfmDeemphasis { deemphasis_us: u32 }, SetWfmStereo { enabled: bool }, GetSpectrum, diff --git a/src/trx-server/src/rig_task.rs b/src/trx-server/src/rig_task.rs index 16da8fd..2edab59 100644 --- a/src/trx-server/src/rig_task.rs +++ b/src/trx-server/src/rig_task.rs @@ -452,6 +452,14 @@ async fn process_command( let _ = ctx.state_tx.send(ctx.state.clone()); return snapshot_from(ctx.state); } + RigCommand::SetSdrGain(gain_db) => { + if let Err(e) = ctx.rig.set_sdr_gain(gain_db).await { + return Err(RigError::communication(format!("set_sdr_gain: {e}"))); + } + ctx.state.filter = ctx.rig.filter_state(); + let _ = ctx.state_tx.send(ctx.state.clone()); + return snapshot_from(ctx.state); + } RigCommand::SetWfmDeemphasis(deemphasis_us) => { if let Err(e) = ctx.rig.set_wfm_deemphasis(deemphasis_us).await { return Err(RigError::communication(format!("set_wfm_deemphasis: {e}"))); diff --git a/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp.rs b/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp.rs index a8102ed..0077531 100644 --- a/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp.rs +++ b/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp.rs @@ -45,6 +45,12 @@ pub trait IqSource: Send + 'static { Ok(()) } + /// Apply a new hardware receive gain in dB. Default implementation is a + /// no-op for non-hardware sources. + fn set_gain(&mut self, _gain_db: f64) -> Result<(), String> { + Ok(()) + } + /// Gives a source-specific implementation a chance to recover from a /// read error (for example, by rearming a hardware stream after overflow). /// Returns `true` when an active recovery action was attempted. @@ -740,6 +746,9 @@ pub struct SdrPipeline { /// Write `Some(hz)` here to retune the hardware center frequency. /// The IQ read loop picks it up on the next iteration. pub retune_cmd: Arc>>, + /// Write `Some(gain_db)` here to adjust the hardware RX gain. + /// The IQ read loop picks it up on the next iteration. + pub gain_cmd: Arc>>, } impl SdrPipeline { @@ -785,6 +794,8 @@ impl SdrPipeline { let thread_spectrum_buf = spectrum_buf.clone(); let retune_cmd: Arc>> = Arc::new(std::sync::Mutex::new(None)); let thread_retune_cmd = retune_cmd.clone(); + let gain_cmd: Arc>> = Arc::new(std::sync::Mutex::new(None)); + let thread_gain_cmd = gain_cmd.clone(); std::thread::Builder::new() .name("sdr-iq-read".to_string()) @@ -796,6 +807,7 @@ impl SdrPipeline { iq_tx, thread_spectrum_buf, thread_retune_cmd, + thread_gain_cmd, ); }) .expect("failed to spawn sdr-iq-read thread"); @@ -806,6 +818,7 @@ impl SdrPipeline { spectrum_buf, sdr_sample_rate, retune_cmd, + gain_cmd, } } } @@ -829,6 +842,7 @@ fn iq_read_loop( iq_tx: broadcast::Sender>>, spectrum_buf: Arc>>>, retune_cmd: Arc>>, + gain_cmd: Arc>>, ) { let mut block = vec![Complex::new(0.0_f32, 0.0_f32); IQ_BLOCK_SIZE]; let block_duration_ms = if sdr_sample_rate > 0 { @@ -859,6 +873,15 @@ fn iq_read_loop( } } } + if let Ok(mut cmd) = gain_cmd.try_lock() { + if let Some(gain_db) = cmd.take() { + if let Err(e) = source.set_gain(gain_db) { + tracing::warn!("SDR gain change to {:.1} dB failed: {}", gain_db, e); + } else { + tracing::info!("SDR gain updated to {:.1} dB", gain_db); + } + } + } let n = match source.read_into(&mut block) { Ok(n) => { 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 9981df9..e4ab201 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 @@ -41,6 +41,10 @@ pub struct SoapySdrRig { wfm_deemphasis_us: u32, /// Whether WFM stereo decode is enabled. wfm_stereo: bool, + /// Requested hardware gain setting in dB. + gain_db: f64, + /// Optional hard ceiling for the applied hardware gain in dB. + max_gain_db: Option, } impl SoapySdrRig { @@ -200,6 +204,8 @@ impl SoapySdrRig { retune_cmd, wfm_deemphasis_us, wfm_stereo: true, + gain_db, + max_gain_db, }) } @@ -353,6 +359,29 @@ impl RigCat for SoapySdrRig { }) } + fn set_sdr_gain<'a>( + &'a mut self, + gain_db: f64, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + if !gain_db.is_finite() { + return Err("gain must be finite".into()); + } + if gain_db < 0.0 { + return Err("gain must be >= 0".into()); + } + self.gain_db = gain_db; + let effective_gain_db = self + .max_gain_db + .map(|max_gain| gain_db.min(max_gain)) + .unwrap_or(gain_db); + if let Ok(mut cmd) = self.pipeline.gain_cmd.lock() { + *cmd = Some(effective_gain_db); + } + Ok(()) + }) + } + fn get_signal_strength<'a>( &'a mut self, ) -> Pin> + Send + 'a>> { @@ -503,6 +532,11 @@ impl RigCat for SoapySdrRig { bandwidth_hz: self.bandwidth_hz, fir_taps: self.fir_taps, cw_center_hz: 700, + sdr_gain_db: Some( + self.max_gain_db + .map(|max_gain| self.gain_db.min(max_gain)) + .unwrap_or(self.gain_db), + ), wfm_deemphasis_us: self.wfm_deemphasis_us, wfm_stereo: self.wfm_stereo, wfm_stereo_detected, diff --git a/src/trx-server/trx-backend/trx-backend-soapysdr/src/real_iq_source.rs b/src/trx-server/trx-backend/trx-backend-soapysdr/src/real_iq_source.rs index 6fabd20..3fc123d 100644 --- a/src/trx-server/trx-backend/trx-backend-soapysdr/src/real_iq_source.rs +++ b/src/trx-server/trx-backend/trx-backend-soapysdr/src/real_iq_source.rs @@ -173,6 +173,12 @@ impl IqSource for RealIqSource { .map_err(|e| format!("Failed to retune SDR center frequency: {}", e)) } + fn set_gain(&mut self, gain_db: f64) -> Result<(), String> { + self.device + .set_gain(soapysdr::Direction::Rx, 0, gain_db) + .map_err(|e| format!("Failed to set SDR gain: {}", e)) + } + fn handle_read_error(&mut self, err: &str) -> Result { let err_lc = err.to_ascii_lowercase(); let is_overrun = err_lc.contains("overflow") || err_lc.contains("overrun");