[feat](trx-backend-soapysdr): add hardware AGC toggle and SDR settings UI row

Add hardware AGC on/off control for SoapySDR backend, wired through the
full stack from RigCommand to the web UI:

- RigCommand::SetSdrAgc(bool) + ClientCommand::SetSdrAgc in protocol
- set_sdr_agc() on RigCat trait (not-supported default)
- SoapySdrRig: agc_enabled field, set_sdr_agc() via pipeline agc_cmd,
  sdr_agc_enabled in filter_state(); removes the "not yet implemented"
  warning — gain_mode="auto" now properly enables hardware AGC via
  SoapySDR set_gain_mode()
- IqSource::set_gain_mode() trait method; RealIqSource implements it
- SdrPipeline: agc_cmd channel, read loop applies it each iteration
- POST /set_sdr_agc endpoint in trx-frontend-http
- New "SDR settings" full-row in index.html with Hardware AGC checkbox
  and RF Gain (moved out of WFM controls); row hidden when
  show_sdr_gain_control is false
- app.js: AGC checkbox handler, disables RF gain input while AGC is on,
  syncs checkbox state from filter.sdr_agc_enabled

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-03-15 09:44:46 +01:00
parent 262e78e72b
commit 3d99cac03b
13 changed files with 122 additions and 16 deletions
@@ -2755,8 +2755,12 @@ function render(update) {
} }
updateSdrSquelchControlVisibility(); updateSdrSquelchControlVisibility();
} }
if (sdrGainControlsEl && typeof update.show_sdr_gain_control === "boolean") { if (typeof update.show_sdr_gain_control === "boolean") {
sdrGainControlsEl.style.display = update.show_sdr_gain_control ? "" : "none"; if (sdrSettingsRowEl) sdrSettingsRowEl.style.display = update.show_sdr_gain_control ? "" : "none";
}
if (update.filter && sdrAgcEl && typeof update.filter.sdr_agc_enabled === "boolean") {
sdrAgcEl.checked = update.filter.sdr_agc_enabled;
updateSdrGainInputState();
} }
if (update.status && update.status.freq && typeof update.status.freq.hz === "number") { if (update.status && update.status.freq && typeof update.status.freq.hz === "number") {
applyLocalTunedFrequency(update.status.freq.hz, true); applyLocalTunedFrequency(update.status.freq.hz, true);
@@ -6712,9 +6716,11 @@ const wfmControlsCol = document.getElementById("wfm-controls-col");
const wfmDeemphasisEl = document.getElementById("wfm-deemphasis"); const wfmDeemphasisEl = document.getElementById("wfm-deemphasis");
const wfmAudioModeEl = document.getElementById("wfm-audio-mode"); const wfmAudioModeEl = document.getElementById("wfm-audio-mode");
const wfmDenoiseEl = document.getElementById("wfm-denoise"); const wfmDenoiseEl = document.getElementById("wfm-denoise");
const sdrSettingsRowEl = document.getElementById("sdr-settings-row");
const sdrGainControlsEl = document.getElementById("sdr-gain-controls"); const sdrGainControlsEl = document.getElementById("sdr-gain-controls");
const sdrGainEl = document.getElementById("sdr-gain-db"); const sdrGainEl = document.getElementById("sdr-gain-db");
const sdrGainSetBtn = document.getElementById("sdr-gain-set"); const sdrGainSetBtn = document.getElementById("sdr-gain-set");
const sdrAgcEl = document.getElementById("sdr-agc-enabled");
const wfmStFlagEl = document.getElementById("wfm-st-flag"); const wfmStFlagEl = document.getElementById("wfm-st-flag");
const sdrSquelchWrapEl = document.getElementById("sdr-squelch-wrap"); const sdrSquelchWrapEl = document.getElementById("sdr-squelch-wrap");
const sdrSquelchEl = document.getElementById("sdr-squelch"); const sdrSquelchEl = document.getElementById("sdr-squelch");
@@ -6889,6 +6895,18 @@ function submitSdrGain() {
if (!Number.isFinite(parsed) || parsed < 0) return; if (!Number.isFinite(parsed) || parsed < 0) return;
postPath(`/set_sdr_gain?db=${encodeURIComponent(parsed)}`).catch(() => {}); postPath(`/set_sdr_gain?db=${encodeURIComponent(parsed)}`).catch(() => {});
} }
function updateSdrGainInputState() {
if (!sdrAgcEl || !sdrGainEl || !sdrGainSetBtn) return;
const agcOn = sdrAgcEl.checked;
sdrGainEl.disabled = agcOn;
sdrGainSetBtn.disabled = agcOn;
}
if (sdrAgcEl) {
sdrAgcEl.addEventListener("change", () => {
postPath(`/set_sdr_agc?enabled=${sdrAgcEl.checked ? "true" : "false"}`).catch(() => {});
updateSdrGainInputState();
});
}
if (sdrGainSetBtn) { if (sdrGainSetBtn) {
sdrGainSetBtn.addEventListener("click", submitSdrGain); sdrGainSetBtn.addEventListener("click", submitSdrGain);
} }
@@ -232,13 +232,6 @@
<option value="high">High</option> <option value="high">High</option>
</select> </select>
</label> </label>
<div class="wfm-gain-group" id="sdr-gain-controls">
<label class="wfm-control">
<span class="wfm-control-label">RF Gain</span>
<input id="sdr-gain-db" class="status-input" type="number" min="0" max="60" step="1" inputmode="decimal">
</label>
<button id="sdr-gain-set" type="button" class="wfm-inline-btn">Set</button>
</div>
<label class="wfm-control wfm-st-flag-wrap" aria-label="Stereo pilot status"> <label class="wfm-control wfm-st-flag-wrap" aria-label="Stereo pilot status">
<span class="wfm-control-label">Pilot</span> <span class="wfm-control-label">Pilot</span>
<span id="wfm-st-flag" class="wfm-st-flag wfm-st-flag-mono">MO</span> <span id="wfm-st-flag" class="wfm-st-flag wfm-st-flag-mono">MO</span>
@@ -259,6 +252,22 @@
<div class="label"><span>VFO</span></div> <div class="label"><span>VFO</span></div>
<div class="vfo-picker" id="vfo-picker"></div> <div class="vfo-picker" id="vfo-picker"></div>
</div> </div>
<div class="full-row label-below-row" id="sdr-settings-row" style="display:none;">
<div class="label"><span>SDR settings</span></div>
<div class="inline" style="gap: 0.6rem; flex-wrap: wrap; align-items: center;">
<label class="vol-label" id="sdr-agc-wrap">
<span style="margin-right:0.3rem;">Hardware AGC</span>
<input type="checkbox" id="sdr-agc-enabled">
</label>
<div class="wfm-gain-group" id="sdr-gain-controls">
<label class="wfm-control">
<span class="wfm-control-label">RF Gain</span>
<input id="sdr-gain-db" class="status-input" type="number" min="0" max="60" step="1" inputmode="decimal">
</label>
<button id="sdr-gain-set" type="button" class="wfm-inline-btn">Set</button>
</div>
</div>
</div>
<div class="full-row label-below-row" id="vchan-row"> <div class="full-row label-below-row" id="vchan-row">
<div class="label"><span>Channels / Scheduler</span></div> <div class="label"><span>Channels / Scheduler</span></div>
<div class="channel-scheduler-controls"> <div class="channel-scheduler-controls">
@@ -873,6 +873,19 @@ pub async fn set_sdr_gain(
send_command(&rig_tx, RigCommand::SetSdrGain(query.db)).await send_command(&rig_tx, RigCommand::SetSdrGain(query.db)).await
} }
#[derive(serde::Deserialize)]
pub struct SdrAgcQuery {
pub enabled: bool,
}
#[post("/set_sdr_agc")]
pub async fn set_sdr_agc(
query: web::Query<SdrAgcQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
send_command(&rig_tx, RigCommand::SetSdrAgc(query.enabled)).await
}
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
pub struct SdrSquelchQuery { pub struct SdrSquelchQuery {
pub enabled: bool, pub enabled: bool,
@@ -1533,6 +1546,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(set_bandwidth) .service(set_bandwidth)
.service(set_fir_taps) .service(set_fir_taps)
.service(set_sdr_gain) .service(set_sdr_gain)
.service(set_sdr_agc)
.service(set_sdr_squelch) .service(set_sdr_squelch)
.service(set_wfm_deemphasis) .service(set_wfm_deemphasis)
.service(set_wfm_stereo) .service(set_wfm_stereo)
+1
View File
@@ -41,6 +41,7 @@ pub enum RigCommand {
SetBandwidth(u32), SetBandwidth(u32),
SetFirTaps(u32), SetFirTaps(u32),
SetSdrGain(f64), SetSdrGain(f64),
SetSdrAgc(bool),
SetSdrSquelch { enabled: bool, threshold_db: f64 }, SetSdrSquelch { enabled: bool, threshold_db: f64 },
SetWfmDeemphasis(u32), SetWfmDeemphasis(u32),
SetWfmStereo(bool), SetWfmStereo(bool),
@@ -523,6 +523,7 @@ pub fn command_from_rig_command(cmd: RigCommand) -> Box<dyn RigCommandHandler> {
| RigCommand::SetBandwidth(_) | RigCommand::SetBandwidth(_)
| RigCommand::SetFirTaps(_) | RigCommand::SetFirTaps(_)
| RigCommand::SetSdrGain(_) | RigCommand::SetSdrGain(_)
| RigCommand::SetSdrAgc(_)
| RigCommand::SetSdrSquelch { .. } | RigCommand::SetSdrSquelch { .. }
| RigCommand::SetWfmDeemphasis(_) | RigCommand::SetWfmDeemphasis(_)
| RigCommand::SetWfmStereo(_) | RigCommand::SetWfmStereo(_)
+10
View File
@@ -190,6 +190,16 @@ pub trait RigCat: Rig + Send {
))) )))
} }
fn set_sdr_agc<'a>(
&'a mut self,
_enabled: bool,
) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'a>> {
Box::pin(std::future::ready(Err(
Box::new(response::RigError::not_supported("set_sdr_agc"))
as Box<dyn std::error::Error + Send + Sync>,
)))
}
fn set_sdr_squelch<'a>( fn set_sdr_squelch<'a>(
&'a mut self, &'a mut self,
_enabled: bool, _enabled: bool,
+2
View File
@@ -313,6 +313,8 @@ pub struct RigFilterState {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub sdr_gain_db: Option<f64>, pub sdr_gain_db: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub sdr_agc_enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sdr_squelch_enabled: Option<bool>, pub sdr_squelch_enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub sdr_squelch_threshold_db: Option<f64>, pub sdr_squelch_threshold_db: Option<f64>,
+2
View File
@@ -57,6 +57,7 @@ pub fn client_command_to_rig(cmd: ClientCommand) -> RigCommand {
ClientCommand::SetBandwidth { bandwidth_hz } => RigCommand::SetBandwidth(bandwidth_hz), ClientCommand::SetBandwidth { bandwidth_hz } => RigCommand::SetBandwidth(bandwidth_hz),
ClientCommand::SetFirTaps { taps } => RigCommand::SetFirTaps(taps), ClientCommand::SetFirTaps { taps } => RigCommand::SetFirTaps(taps),
ClientCommand::SetSdrGain { gain_db } => RigCommand::SetSdrGain(gain_db), ClientCommand::SetSdrGain { gain_db } => RigCommand::SetSdrGain(gain_db),
ClientCommand::SetSdrAgc { enabled } => RigCommand::SetSdrAgc(enabled),
ClientCommand::SetSdrSquelch { ClientCommand::SetSdrSquelch {
enabled, enabled,
threshold_db, threshold_db,
@@ -119,6 +120,7 @@ pub fn rig_command_to_client(cmd: RigCommand) -> ClientCommand {
RigCommand::SetBandwidth(bandwidth_hz) => ClientCommand::SetBandwidth { bandwidth_hz }, RigCommand::SetBandwidth(bandwidth_hz) => ClientCommand::SetBandwidth { bandwidth_hz },
RigCommand::SetFirTaps(taps) => ClientCommand::SetFirTaps { taps }, RigCommand::SetFirTaps(taps) => ClientCommand::SetFirTaps { taps },
RigCommand::SetSdrGain(gain_db) => ClientCommand::SetSdrGain { gain_db }, RigCommand::SetSdrGain(gain_db) => ClientCommand::SetSdrGain { gain_db },
RigCommand::SetSdrAgc(enabled) => ClientCommand::SetSdrAgc { enabled },
RigCommand::SetSdrSquelch { RigCommand::SetSdrSquelch {
enabled, enabled,
threshold_db, threshold_db,
+1
View File
@@ -46,6 +46,7 @@ pub enum ClientCommand {
SetBandwidth { bandwidth_hz: u32 }, SetBandwidth { bandwidth_hz: u32 },
SetFirTaps { taps: u32 }, SetFirTaps { taps: u32 },
SetSdrGain { gain_db: f64 }, SetSdrGain { gain_db: f64 },
SetSdrAgc { enabled: bool },
SetSdrSquelch { enabled: bool, threshold_db: f64 }, SetSdrSquelch { enabled: bool, threshold_db: f64 },
SetWfmDeemphasis { deemphasis_us: u32 }, SetWfmDeemphasis { deemphasis_us: u32 },
SetWfmStereo { enabled: bool }, SetWfmStereo { enabled: bool },
+8
View File
@@ -563,6 +563,14 @@ async fn process_command(
let _ = ctx.state_tx.send(ctx.state.clone()); let _ = ctx.state_tx.send(ctx.state.clone());
return snapshot_from(ctx.state); return snapshot_from(ctx.state);
} }
RigCommand::SetSdrAgc(enabled) => {
if let Err(e) = ctx.rig.set_sdr_agc(enabled).await {
return Err(RigError::communication(format!("set_sdr_agc: {e}")));
}
ctx.state.filter = ctx.rig.filter_state();
let _ = ctx.state_tx.send(ctx.state.clone());
return snapshot_from(ctx.state);
}
RigCommand::SetSdrSquelch { RigCommand::SetSdrSquelch {
enabled, enabled,
threshold_db, threshold_db,
@@ -57,6 +57,12 @@ pub trait IqSource: Send + 'static {
Ok(()) Ok(())
} }
/// Enable or disable hardware automatic gain control. Default
/// implementation is a no-op for sources that do not support AGC.
fn set_gain_mode(&mut self, _automatic: bool) -> Result<(), String> {
Ok(())
}
/// Gives a source-specific implementation a chance to recover from a /// Gives a source-specific implementation a chance to recover from a
/// read error (for example, by rearming a hardware stream after overflow). /// read error (for example, by rearming a hardware stream after overflow).
/// Returns `true` when an active recovery action was attempted. /// Returns `true` when an active recovery action was attempted.
@@ -100,6 +106,9 @@ pub struct SdrPipeline {
/// Write `Some(gain_db)` here to adjust the hardware RX gain. /// Write `Some(gain_db)` here to adjust the hardware RX gain.
/// The IQ read loop picks it up on the next iteration. /// The IQ read loop picks it up on the next iteration.
pub gain_cmd: Arc<std::sync::Mutex<Option<f64>>>, pub gain_cmd: Arc<std::sync::Mutex<Option<f64>>>,
/// Write `Some(enabled)` here to switch hardware AGC on or off.
/// The IQ read loop picks it up on the next iteration.
pub agc_cmd: Arc<std::sync::Mutex<Option<bool>>>,
/// Current hardware center frequency in Hz, kept in sync by `SoapySdrRig`. /// Current hardware center frequency in Hz, kept in sync by `SoapySdrRig`.
/// Read by `SdrVirtualChannelManager` to validate and compute IF offsets. /// Read by `SdrVirtualChannelManager` to validate and compute IF offsets.
pub shared_center_hz: Arc<AtomicI64>, pub shared_center_hz: Arc<AtomicI64>,
@@ -174,6 +183,8 @@ impl SdrPipeline {
let thread_retune_cmd = retune_cmd.clone(); let thread_retune_cmd = retune_cmd.clone();
let gain_cmd: Arc<std::sync::Mutex<Option<f64>>> = Arc::new(std::sync::Mutex::new(None)); let gain_cmd: Arc<std::sync::Mutex<Option<f64>>> = Arc::new(std::sync::Mutex::new(None));
let thread_gain_cmd = gain_cmd.clone(); let thread_gain_cmd = gain_cmd.clone();
let agc_cmd: Arc<std::sync::Mutex<Option<bool>>> = Arc::new(std::sync::Mutex::new(None));
let thread_agc_cmd = agc_cmd.clone();
std::thread::Builder::new() std::thread::Builder::new()
.name("sdr-iq-read".to_string()) .name("sdr-iq-read".to_string())
@@ -186,6 +197,7 @@ impl SdrPipeline {
thread_spectrum_buf, thread_spectrum_buf,
thread_retune_cmd, thread_retune_cmd,
thread_gain_cmd, thread_gain_cmd,
thread_agc_cmd,
); );
}) })
.expect("failed to spawn sdr-iq-read thread"); .expect("failed to spawn sdr-iq-read thread");
@@ -198,6 +210,7 @@ impl SdrPipeline {
sdr_sample_rate, sdr_sample_rate,
retune_cmd, retune_cmd,
gain_cmd, gain_cmd,
agc_cmd,
shared_center_hz: Arc::new(AtomicI64::new(0)), shared_center_hz: Arc::new(AtomicI64::new(0)),
audio_sample_rate, audio_sample_rate,
audio_channels: output_channels, audio_channels: output_channels,
@@ -277,6 +290,7 @@ fn iq_read_loop(
spectrum_buf: Arc<Mutex<Option<Vec<f32>>>>, spectrum_buf: Arc<Mutex<Option<Vec<f32>>>>,
retune_cmd: Arc<std::sync::Mutex<Option<f64>>>, retune_cmd: Arc<std::sync::Mutex<Option<f64>>>,
gain_cmd: Arc<std::sync::Mutex<Option<f64>>>, gain_cmd: Arc<std::sync::Mutex<Option<f64>>>,
agc_cmd: Arc<std::sync::Mutex<Option<bool>>>,
) { ) {
let mut block = vec![Complex::new(0.0_f32, 0.0_f32); IQ_BLOCK_SIZE]; let mut block = vec![Complex::new(0.0_f32, 0.0_f32); IQ_BLOCK_SIZE];
let block_duration_ms = if sdr_sample_rate > 0 { let block_duration_ms = if sdr_sample_rate > 0 {
@@ -309,6 +323,15 @@ fn iq_read_loop(
} }
} }
} }
if let Ok(mut cmd) = agc_cmd.try_lock() {
if let Some(enabled) = cmd.take() {
if let Err(e) = source.set_gain_mode(enabled) {
tracing::warn!("SDR AGC mode change to {} failed: {}", enabled, e);
} else {
tracing::info!("SDR AGC mode set to {}", enabled);
}
}
}
let n = match source.read_into(&mut block) { let n = match source.read_into(&mut block) {
Ok(n) => { Ok(n) => {
@@ -52,6 +52,8 @@ pub struct SoapySdrRig {
gain_db: f64, gain_db: f64,
/// Optional hard ceiling for the applied hardware gain in dB. /// Optional hard ceiling for the applied hardware gain in dB.
max_gain_db: Option<f64>, max_gain_db: Option<f64>,
/// Whether hardware AGC is currently enabled.
agc_enabled: bool,
/// Whether software squelch is enabled on primary channel (except WFM mode). /// Whether software squelch is enabled on primary channel (except WFM mode).
squelch_enabled: bool, squelch_enabled: bool,
/// Software squelch threshold (dBFS) on primary channel. /// Software squelch threshold (dBFS) on primary channel.
@@ -133,13 +135,7 @@ impl SoapySdrRig {
max_gain_db, max_gain_db,
); );
if gain_mode == "auto" { let agc_enabled = gain_mode == "auto";
tracing::warn!(
"SoapySDR hardware AGC is not yet implemented; falling back to configured \
gain of {} dB",
gain_db,
);
}
let effective_gain_db = max_gain_db let effective_gain_db = max_gain_db
.map(|max_gain| gain_db.min(max_gain)) .map(|max_gain| gain_db.min(max_gain))
@@ -289,6 +285,7 @@ impl SoapySdrRig {
wfm_denoise: WfmDenoiseLevel::Auto, wfm_denoise: WfmDenoiseLevel::Auto,
gain_db, gain_db,
max_gain_db, max_gain_db,
agc_enabled,
squelch_enabled, squelch_enabled,
squelch_threshold_db, squelch_threshold_db,
ais_channel_indices: Some((primary_channel_count, primary_channel_count + 1)), ais_channel_indices: Some((primary_channel_count, primary_channel_count + 1)),
@@ -582,6 +579,19 @@ impl RigCat for SoapySdrRig {
}) })
} }
fn set_sdr_agc<'a>(
&'a mut self,
enabled: bool,
) -> Pin<Box<dyn std::future::Future<Output = DynResult<()>> + Send + 'a>> {
Box::pin(async move {
self.agc_enabled = enabled;
if let Ok(mut cmd) = self.pipeline.agc_cmd.lock() {
*cmd = Some(enabled);
}
Ok(())
})
}
fn set_sdr_squelch<'a>( fn set_sdr_squelch<'a>(
&'a mut self, &'a mut self,
enabled: bool, enabled: bool,
@@ -793,6 +803,7 @@ impl RigCat for SoapySdrRig {
.map(|max_gain| self.gain_db.min(max_gain)) .map(|max_gain| self.gain_db.min(max_gain))
.unwrap_or(self.gain_db), .unwrap_or(self.gain_db),
), ),
sdr_agc_enabled: Some(self.agc_enabled),
sdr_squelch_enabled: Some(self.squelch_enabled), sdr_squelch_enabled: Some(self.squelch_enabled),
sdr_squelch_threshold_db: Some(self.squelch_threshold_db as f64), sdr_squelch_threshold_db: Some(self.squelch_threshold_db as f64),
wfm_deemphasis_us: self.wfm_deemphasis_us, wfm_deemphasis_us: self.wfm_deemphasis_us,
@@ -179,6 +179,12 @@ impl IqSource for RealIqSource {
.map_err(|e| format!("Failed to set SDR gain: {}", e)) .map_err(|e| format!("Failed to set SDR gain: {}", e))
} }
fn set_gain_mode(&mut self, automatic: bool) -> Result<(), String> {
self.device
.set_gain_mode(soapysdr::Direction::Rx, 0, automatic)
.map_err(|e| format!("Failed to set SDR gain mode: {}", e))
}
fn handle_read_error(&mut self, err: &str, streak: u32) -> Result<bool, String> { fn handle_read_error(&mut self, err: &str, streak: u32) -> Result<bool, String> {
const OVERFLOW_RESTART_STREAK: u32 = 50; const OVERFLOW_RESTART_STREAK: u32 = 50;
const NON_OVERFLOW_RESTART_STREAK: u32 = 10; const NON_OVERFLOW_RESTART_STREAK: u32 = 10;