[feat](trx-server): add SdrConfig structs and SDR access config validation
Add SdrConfig, SdrGainConfig, and SdrChannelConfig structs to config.rs, extend AccessConfig with an args field for the "sdr" access type, wire SdrConfig into ServerConfig, and implement validate_sdr() with all startup validation rules from SDR.md §11. Marks SDR-03 complete. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -18,7 +18,7 @@ This document specifies the requirements for a SoapySDR-based RX-only backend (`
|
|||||||
|----|--------|------|---------|
|
|----|--------|------|---------|
|
||||||
| SDR-01 | `[x]` | Add `AudioSource` trait to `trx-core`; add `as_audio_source()` default on `RigCat` | `src/trx-core/src/rig/mod.rs` |
|
| SDR-01 | `[x]` | Add `AudioSource` trait to `trx-core`; add `as_audio_source()` default on `RigCat` | `src/trx-core/src/rig/mod.rs` |
|
||||||
| SDR-02 | `[x]` | Add `RigAccess::Sdr { args: String }` variant; register `soapysdr` factory (feature-gated `soapysdr`) | `src/trx-server/trx-backend/src/lib.rs` |
|
| SDR-02 | `[x]` | Add `RigAccess::Sdr { args: String }` variant; register `soapysdr` factory (feature-gated `soapysdr`) | `src/trx-server/trx-backend/src/lib.rs` |
|
||||||
| SDR-03 | `[ ]` | Add `SdrConfig`, `SdrGainConfig`, `SdrChannelConfig` structs; parse `type = "sdr"` in `AccessConfig`; add `sdr: SdrConfig` to `ServerConfig`; add startup validation rules (§11) | `src/trx-server/src/config.rs` |
|
| SDR-03 | `[x]` | Add `SdrConfig`, `SdrGainConfig`, `SdrChannelConfig` structs; parse `type = "sdr"` in `AccessConfig`; add `sdr: SdrConfig` to `ServerConfig`; add startup validation rules (§11) | `src/trx-server/src/config.rs` |
|
||||||
|
|
||||||
### New crate: `trx-backend-soapysdr`
|
### New crate: `trx-backend-soapysdr`
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ pub struct ServerConfig {
|
|||||||
pub aprsfi: AprsFiConfig,
|
pub aprsfi: AprsFiConfig,
|
||||||
/// Decoder file logging configuration
|
/// Decoder file logging configuration
|
||||||
pub decode_logs: DecodeLogsConfig,
|
pub decode_logs: DecodeLogsConfig,
|
||||||
|
/// SDR pipeline configuration (used when [rig.access] type = "sdr").
|
||||||
|
pub sdr: SdrConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// General application settings.
|
/// General application settings.
|
||||||
@@ -96,6 +98,8 @@ pub struct AccessConfig {
|
|||||||
pub host: Option<String>,
|
pub host: Option<String>,
|
||||||
/// TCP port (for TCP access)
|
/// TCP port (for TCP access)
|
||||||
pub tcp_port: Option<u16>,
|
pub tcp_port: Option<u16>,
|
||||||
|
/// SoapySDR device args string (for sdr access), e.g. "driver=rtlsdr".
|
||||||
|
pub args: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for RigConfig {
|
impl Default for RigConfig {
|
||||||
@@ -261,6 +265,97 @@ impl Default for AprsFiConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Top-level SDR configuration (only used when [rig.access] type = "sdr").
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct SdrConfig {
|
||||||
|
/// SoapySDR IQ capture sample rate (Hz). Must be supported by the device.
|
||||||
|
pub sample_rate: u32,
|
||||||
|
/// Hardware IF filter bandwidth (Hz).
|
||||||
|
pub bandwidth: u32,
|
||||||
|
/// SDR tunes this many Hz below the dial frequency to keep signal off DC.
|
||||||
|
pub center_offset_hz: i64,
|
||||||
|
/// Gain configuration.
|
||||||
|
pub gain: SdrGainConfig,
|
||||||
|
/// Virtual receiver channels (at least one required when SDR backend is active).
|
||||||
|
pub channels: Vec<SdrChannelConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SdrConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
sample_rate: 1_920_000,
|
||||||
|
bandwidth: 1_500_000,
|
||||||
|
center_offset_hz: 100_000,
|
||||||
|
gain: SdrGainConfig::default(),
|
||||||
|
channels: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gain control mode for the SDR device.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct SdrGainConfig {
|
||||||
|
/// "auto" (hardware AGC) or "manual" (fixed dB).
|
||||||
|
pub mode: String,
|
||||||
|
/// Gain in dB; effective only when mode = "manual".
|
||||||
|
pub value: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SdrGainConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
mode: "auto".to_string(),
|
||||||
|
value: 30.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One virtual receiver channel within the wideband IQ stream.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct SdrChannelConfig {
|
||||||
|
/// Human-readable identifier used in logs.
|
||||||
|
pub id: String,
|
||||||
|
/// Frequency offset from the dial frequency (Hz). Primary channel should use 0.
|
||||||
|
pub offset_hz: i64,
|
||||||
|
/// Demodulation mode: "auto" (follows RigCat set_mode) or a fixed RigMode string
|
||||||
|
/// (e.g. "USB", "FM").
|
||||||
|
pub mode: String,
|
||||||
|
/// One-sided bandwidth of the post-demod audio BPF (Hz).
|
||||||
|
pub audio_bandwidth_hz: u32,
|
||||||
|
/// FIR filter tap count. Higher = sharper roll-off. Default 64.
|
||||||
|
pub fir_taps: usize,
|
||||||
|
/// CW tone centre frequency in the audio domain (Hz). Default 700.
|
||||||
|
pub cw_center_hz: u32,
|
||||||
|
/// Pre-demod bandwidth for WFM only (Hz). Default 75000.
|
||||||
|
pub wfm_bandwidth_hz: u32,
|
||||||
|
/// Decoder names that receive this channel's PCM frames.
|
||||||
|
/// Valid values: "ft8", "wspr", "aprs", "cw".
|
||||||
|
pub decoders: Vec<String>,
|
||||||
|
/// If true, encode this channel's audio as Opus and stream over TCP.
|
||||||
|
/// At most one channel may set this to true.
|
||||||
|
pub stream_opus: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SdrChannelConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
id: String::new(),
|
||||||
|
offset_hz: 0,
|
||||||
|
mode: "auto".to_string(),
|
||||||
|
audio_bandwidth_hz: 3000,
|
||||||
|
fir_taps: 64,
|
||||||
|
cw_center_hz: 700,
|
||||||
|
wfm_bandwidth_hz: 75_000,
|
||||||
|
decoders: Vec::new(),
|
||||||
|
stream_opus: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl ServerConfig {
|
impl ServerConfig {
|
||||||
pub fn validate(&self) -> Result<(), String> {
|
pub fn validate(&self) -> Result<(), String> {
|
||||||
validate_log_level(self.general.log_level.as_deref())?;
|
validate_log_level(self.general.log_level.as_deref())?;
|
||||||
@@ -364,6 +459,71 @@ impl ServerConfig {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Validate SDR-specific config rules (see SDR.md §11).
|
||||||
|
/// Returns a Vec of error strings; empty means valid.
|
||||||
|
pub fn validate_sdr(&self) -> Vec<String> {
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
|
||||||
|
// Only validate if access type is "sdr"
|
||||||
|
let is_sdr = self.rig.access.access_type.as_deref() == Some("sdr");
|
||||||
|
if !is_sdr {
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
// args must be non-empty
|
||||||
|
if self.rig.access.args.as_deref().map(str::is_empty).unwrap_or(true) {
|
||||||
|
errors.push("[rig.access] args must be non-empty for type = \"sdr\"".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// sample_rate must be non-zero
|
||||||
|
if self.sdr.sample_rate == 0 {
|
||||||
|
errors.push("[sdr] sample_rate must be > 0".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every channel's IF must fit within the captured bandwidth
|
||||||
|
let half_rate = self.sdr.sample_rate as i64 / 2;
|
||||||
|
for ch in &self.sdr.channels {
|
||||||
|
let channel_if = self.sdr.center_offset_hz + ch.offset_hz;
|
||||||
|
if channel_if.abs() >= half_rate {
|
||||||
|
errors.push(format!(
|
||||||
|
"[sdr.channels] id=\"{}\" IF frequency {} Hz exceeds Nyquist limit ±{} Hz",
|
||||||
|
ch.id, channel_if, half_rate
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// At most one channel may have stream_opus = true
|
||||||
|
let opus_count = self.sdr.channels.iter().filter(|c| c.stream_opus).count();
|
||||||
|
if opus_count > 1 {
|
||||||
|
errors.push(format!(
|
||||||
|
"[sdr.channels] at most one channel may have stream_opus = true (found {})",
|
||||||
|
opus_count
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// tx_enabled must be false with SDR backend
|
||||||
|
if self.audio.tx_enabled {
|
||||||
|
errors.push("[audio] tx_enabled must be false when using the soapysdr backend".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decoder names must not appear in more than one channel
|
||||||
|
let mut seen: std::collections::HashMap<String, String> = std::collections::HashMap::new();
|
||||||
|
for ch in &self.sdr.channels {
|
||||||
|
for dec in &ch.decoders {
|
||||||
|
if let Some(prev_id) = seen.get(dec) {
|
||||||
|
errors.push(format!(
|
||||||
|
"[sdr.channels] decoder \"{}\" appears in both \"{}\" and \"{}\"" ,
|
||||||
|
dec, prev_id, ch.id
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
seen.insert(dec.clone(), ch.id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
errors
|
||||||
|
}
|
||||||
|
|
||||||
/// Load configuration from a specific file path.
|
/// Load configuration from a specific file path.
|
||||||
pub fn load_from_file(path: &Path) -> Result<Self, ConfigError> {
|
pub fn load_from_file(path: &Path) -> Result<Self, ConfigError> {
|
||||||
<Self as ConfigFile>::load_from_file(path)
|
<Self as ConfigFile>::load_from_file(path)
|
||||||
@@ -394,6 +554,7 @@ impl ServerConfig {
|
|||||||
baud: Some(9600),
|
baud: Some(9600),
|
||||||
host: None,
|
host: None,
|
||||||
tcp_port: None,
|
tcp_port: None,
|
||||||
|
args: None,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
behavior: BehaviorConfig::default(),
|
behavior: BehaviorConfig::default(),
|
||||||
@@ -402,6 +563,7 @@ impl ServerConfig {
|
|||||||
pskreporter: PskReporterConfig::default(),
|
pskreporter: PskReporterConfig::default(),
|
||||||
aprsfi: AprsFiConfig::default(),
|
aprsfi: AprsFiConfig::default(),
|
||||||
decode_logs: DecodeLogsConfig::default(),
|
decode_logs: DecodeLogsConfig::default(),
|
||||||
|
sdr: SdrConfig::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
toml::to_string_pretty(&example).unwrap_or_default()
|
toml::to_string_pretty(&example).unwrap_or_default()
|
||||||
@@ -479,9 +641,12 @@ fn validate_access(access: &AccessConfig) -> Result<(), String> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"sdr" => {
|
||||||
|
// SDR-specific validation is handled by validate_sdr()
|
||||||
|
}
|
||||||
other => {
|
other => {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"[rig.access].type '{}' is invalid (expected 'serial' or 'tcp')",
|
"[rig.access].type '{}' is invalid (expected 'serial', 'tcp', or 'sdr')",
|
||||||
other
|
other
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user