[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:
@@ -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]
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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}")));
|
||||||
|
|||||||
Reference in New Issue
Block a user