diff --git a/src/trx-server/src/config.rs b/src/trx-server/src/config.rs index fc771f1..df33daf 100644 --- a/src/trx-server/src/config.rs +++ b/src/trx-server/src/config.rs @@ -341,6 +341,8 @@ pub struct SdrConfig { pub center_offset_hz: i64, /// Gain configuration. 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). pub channels: Vec, } @@ -353,11 +355,37 @@ impl Default for SdrConfig { wfm_deemphasis_us: 50, center_offset_hz: 100_000, gain: SdrGainConfig::default(), + squelch: SdrSquelchConfig::default(), 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. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] @@ -517,6 +545,7 @@ impl ServerConfig { return Err("[sdr.gain].max_value must be >= 0".to_string()); } } + validate_sdr_squelch_config("[sdr.squelch]", &self.sdr.squelch)?; // Multi-rig uniqueness checks. 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 { return Err( @@ -825,6 +858,25 @@ fn validate_access(access: &AccessConfig) -> Result<(), String> { 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> { if tokens.iter().any(|t| t.trim().is_empty()) { 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 --- #[test] diff --git a/src/trx-server/src/main.rs b/src/trx-server/src/main.rs index 331f63e..b90f5ef 100644 --- a/src/trx-server/src/main.rs +++ b/src/trx-server/src/main.rs @@ -343,6 +343,10 @@ fn build_sdr_rig_from_instance(rig_cfg: &RigInstanceConfig) -> SdrRigBuildResult rig_cfg.sdr.sample_rate, rig_cfg.sdr.bandwidth, 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(); diff --git a/src/trx-server/src/rig_task.rs b/src/trx-server/src/rig_task.rs index dd6d822..debf953 100644 --- a/src/trx-server/src/rig_task.rs +++ b/src/trx-server/src/rig_task.rs @@ -452,6 +452,17 @@ async fn process_command( let _ = ctx.state_tx.send(ctx.state.clone()); 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) => { if let Err(e) = ctx.rig.set_wfm_deemphasis(deemphasis_us).await { return Err(RigError::communication(format!("set_wfm_deemphasis: {e}")));