[feat](trx-server): wire SDR squelch through server config and rig task

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-03-05 22:43:23 +01:00
parent 3eb5a615b9
commit 8bd779a239
3 changed files with 97 additions and 0 deletions
+82
View File
@@ -341,6 +341,8 @@ pub struct SdrConfig {
pub center_offset_hz: i64, pub center_offset_hz: i64,
/// Gain configuration. /// Gain configuration.
pub gain: SdrGainConfig, pub gain: SdrGainConfig,
/// Virtual software squelch applied to demodulated audio except WFM.
pub squelch: SdrSquelchConfig,
/// Virtual receiver channels (at least one required when SDR backend is active). /// Virtual receiver channels (at least one required when SDR backend is active).
pub channels: Vec<SdrChannelConfig>, pub channels: Vec<SdrChannelConfig>,
} }
@@ -353,11 +355,37 @@ impl Default for SdrConfig {
wfm_deemphasis_us: 50, wfm_deemphasis_us: 50,
center_offset_hz: 100_000, center_offset_hz: 100_000,
gain: SdrGainConfig::default(), gain: SdrGainConfig::default(),
squelch: SdrSquelchConfig::default(),
channels: Vec::new(), channels: Vec::new(),
} }
} }
} }
/// Virtual squelch settings for SoapySDR demodulated audio (except WFM mode).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SdrSquelchConfig {
/// Enables software squelch for demodulated audio except WFM.
pub enabled: bool,
/// Open threshold in dBFS (typical range: -120..0).
pub threshold_db: f32,
/// Hysteresis in dB used when closing the squelch.
pub hysteresis_db: f32,
/// Tail hold time after dropping below threshold.
pub tail_ms: u32,
}
impl Default for SdrSquelchConfig {
fn default() -> Self {
Self {
enabled: false,
threshold_db: -65.0,
hysteresis_db: 3.0,
tail_ms: 180,
}
}
}
/// Gain control mode for the SDR device. /// Gain control mode for the SDR device.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)] #[serde(default)]
@@ -517,6 +545,7 @@ impl ServerConfig {
return Err("[sdr.gain].max_value must be >= 0".to_string()); return Err("[sdr.gain].max_value must be >= 0".to_string());
} }
} }
validate_sdr_squelch_config("[sdr.squelch]", &self.sdr.squelch)?;
// Multi-rig uniqueness checks. // Multi-rig uniqueness checks.
if !self.rigs.is_empty() { if !self.rigs.is_empty() {
@@ -552,6 +581,10 @@ impl ServerConfig {
)); ));
} }
} }
validate_sdr_squelch_config(
&format!("[[rigs]] [sdr.squelch] (rig id: \"{}\")", rig.id),
&rig.sdr.squelch,
)?;
} }
if enabled_count == 0 { if enabled_count == 0 {
return Err( return Err(
@@ -825,6 +858,25 @@ fn validate_access(access: &AccessConfig) -> Result<(), String> {
Ok(()) Ok(())
} }
fn validate_sdr_squelch_config(path: &str, squelch: &SdrSquelchConfig) -> Result<(), String> {
if !squelch.threshold_db.is_finite() {
return Err(format!("{path}.threshold_db must be finite"));
}
if !(-140.0..=0.0).contains(&squelch.threshold_db) {
return Err(format!("{path}.threshold_db must be in range -140..=0"));
}
if !squelch.hysteresis_db.is_finite() {
return Err(format!("{path}.hysteresis_db must be finite"));
}
if !(0.0..=40.0).contains(&squelch.hysteresis_db) {
return Err(format!("{path}.hysteresis_db must be in range 0..=40"));
}
if squelch.tail_ms > 10_000 {
return Err(format!("{path}.tail_ms must be <= 10000"));
}
Ok(())
}
fn validate_tokens(path: &str, tokens: &[String]) -> Result<(), String> { fn validate_tokens(path: &str, tokens: &[String]) -> Result<(), String> {
if tokens.iter().any(|t| t.trim().is_empty()) { if tokens.iter().any(|t| t.trim().is_empty()) {
return Err(format!("{path} must not contain empty tokens")); return Err(format!("{path} must not contain empty tokens"));
@@ -1177,6 +1229,36 @@ tokens = ["secret123"]
); );
} }
#[test]
fn test_validate_rejects_invalid_sdr_squelch_threshold() {
let mut cfg = ServerConfig::default();
cfg.rig.access.port = Some("/dev/ttyUSB0".to_string());
cfg.rig.access.baud = Some(9600);
cfg.sdr.squelch.threshold_db = 10.0;
let err = cfg
.validate()
.expect_err("expected squelch threshold validation error");
assert!(
err.contains("squelch") && err.contains("threshold_db"),
"unexpected validation error: {err}"
);
}
#[test]
fn test_validate_rejects_invalid_sdr_squelch_hysteresis() {
let mut cfg = ServerConfig::default();
cfg.rig.access.port = Some("/dev/ttyUSB0".to_string());
cfg.rig.access.baud = Some(9600);
cfg.sdr.squelch.hysteresis_db = 99.0;
let err = cfg
.validate()
.expect_err("expected squelch hysteresis validation error");
assert!(
err.contains("squelch") && err.contains("hysteresis_db"),
"unexpected validation error: {err}"
);
}
// --- MR-08: multi-rig config tests --- // --- MR-08: multi-rig config tests ---
#[test] #[test]
+4
View File
@@ -343,6 +343,10 @@ fn build_sdr_rig_from_instance(rig_cfg: &RigInstanceConfig) -> SdrRigBuildResult
rig_cfg.sdr.sample_rate, rig_cfg.sdr.sample_rate,
rig_cfg.sdr.bandwidth, rig_cfg.sdr.bandwidth,
rig_cfg.sdr.center_offset_hz, rig_cfg.sdr.center_offset_hz,
rig_cfg.sdr.squelch.enabled,
rig_cfg.sdr.squelch.threshold_db,
rig_cfg.sdr.squelch.hysteresis_db,
rig_cfg.sdr.squelch.tail_ms,
)?; )?;
let pcm_rx = sdr_rig.subscribe_pcm(); let pcm_rx = sdr_rig.subscribe_pcm();
+11
View File
@@ -452,6 +452,17 @@ 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::SetSdrSquelch {
enabled,
threshold_db,
} => {
if let Err(e) = ctx.rig.set_sdr_squelch(enabled, threshold_db).await {
return Err(RigError::communication(format!("set_sdr_squelch: {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) => { RigCommand::SetWfmDeemphasis(deemphasis_us) => {
if let Err(e) = ctx.rig.set_wfm_deemphasis(deemphasis_us).await { if let Err(e) = ctx.rig.set_wfm_deemphasis(deemphasis_us).await {
return Err(RigError::communication(format!("set_wfm_deemphasis: {e}"))); return Err(RigError::communication(format!("set_wfm_deemphasis: {e}")));