[refactor](trx-rs): resolve all P1/P2 improvement areas

P1 (High Priority):
- Fix LIFO command batching in rig_task.rs (batch.pop→batch.remove(0))
- Add ±25% jitter to ExponentialBackoff to prevent thundering herd
- Add 10,000-entry capacity bounds to decoder history queues
- Add rig task crash detection with Error state broadcast
- Decompose FrontendRuntimeContext 50-field god-struct into 9 sub-structs
  (AudioContext, DecodeHistoryContext, HttpAuthConfig, HttpUiConfig,
   RigRoutingContext, OwnerInfo, VChanContext, SpectrumContext, PerRigAudioContext)
- Migrate std::sync::RwLock to tokio::sync::RwLock in background_decode.rs
- Extract find_input_device/find_output_device helpers from audio pipeline

P2 (Medium Priority):
- Introduce SoapySdrConfig builder struct (replaces 20+ positional params)
- Add define_command_mappings! macro for ClientCommand↔RigCommand mapping
- Replace silent lock poison recovery with lock_or_recover() warning logger
- Make timeouts configurable via RigTaskConfig/ListenerConfig and TOML
- Extract shared config types to trx-app/src/shared_config.rs

Documentation updated in CLAUDE.md, Architecture.md, Improvement-Areas.md.

https://claude.ai/code/session_01P9G7QCWfiYbPVJ7cgiXznf
Signed-off-by: Claude <noreply@anthropic.com>
This commit is contained in:
Claude
2026-03-28 23:26:55 +00:00
committed by Stan Grams
parent 0a60684e28
commit 16426548de
22 changed files with 1245 additions and 916 deletions
+144 -100
View File
@@ -54,6 +54,10 @@ const CW_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
const FT8_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
const WSPR_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
const LRPT_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
/// Maximum entries per decoder history queue. Prevents unbounded memory growth
/// on busy channels (e.g. AIS near a port). Oldest entries are evicted when
/// the limit is reached, independent of the time-based pruning.
const MAX_HISTORY_ENTRIES: usize = 10_000;
/// Silence timeout before auto-finalising an LRPT pass (30 s without new MCUs).
const LRPT_PASS_SILENCE_TIMEOUT: Duration = Duration::from_secs(30);
const FT8_SAMPLE_RATE: u32 = 12_000;
@@ -143,7 +147,7 @@ impl StreamErrorLogger {
fn log(&self, err: &str) {
let now = Instant::now();
let kind = classify_stream_error(err);
let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
let mut state = lock_or_recover(&self.state, self.label);
// First occurrence or changed error class: log as error once.
if state.last_kind != Some(kind) {
@@ -218,6 +222,24 @@ pub struct DecoderHistories {
total_count: AtomicUsize,
}
/// Acquire a mutex, recovering from poisoning with a warning log.
fn lock_or_recover<T>(mutex: &Mutex<T>, label: &str) -> std::sync::MutexGuard<'_, T> {
mutex.unwrap_or_else(|e| {
tracing::warn!(
"Mutex for {} was poisoned (prior panic); recovering with potentially inconsistent data",
label
);
e.into_inner()
})
}
/// Enforce capacity limit on a history deque by evicting oldest entries.
fn enforce_capacity<T>(deque: &mut VecDeque<T>, max: usize) {
while deque.len() > max {
deque.pop_front();
}
}
impl DecoderHistories {
pub fn new() -> Arc<Self> {
Arc::new(Self {
@@ -279,15 +301,16 @@ impl DecoderHistories {
if msg.ts_ms.is_none() {
msg.ts_ms = Some(current_timestamp_ms());
}
let mut h = self.ais.lock().unwrap_or_else(|e| e.into_inner());
let mut h = lock_or_recover(&self.ais, "ais_history");
let before = h.len();
h.push_back((Instant::now(), msg));
Self::prune_ais(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_ais_history(&self) -> Vec<AisMessage> {
let mut h = self.ais.lock().unwrap_or_else(|e| e.into_inner());
let mut h = lock_or_recover(&self.ais, "ais_history");
let before = h.len();
Self::prune_ais(&mut h);
self.adjust_total_count(before, h.len());
@@ -311,15 +334,16 @@ impl DecoderHistories {
if msg.ts_ms.is_none() {
msg.ts_ms = Some(current_timestamp_ms());
}
let mut h = self.vdes.lock().unwrap_or_else(|e| e.into_inner());
let mut h = lock_or_recover(&self.vdes, "vdes_history");
let before = h.len();
h.push_back((Instant::now(), msg));
Self::prune_vdes(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_vdes_history(&self) -> Vec<VdesMessage> {
let mut h = self.vdes.lock().unwrap_or_else(|e| e.into_inner());
let mut h = lock_or_recover(&self.vdes, "vdes_history");
let before = h.len();
Self::prune_vdes(&mut h);
self.adjust_total_count(before, h.len());
@@ -346,15 +370,16 @@ impl DecoderHistories {
if pkt.ts_ms.is_none() {
pkt.ts_ms = Some(current_timestamp_ms());
}
let mut h = self.aprs.lock().unwrap_or_else(|e| e.into_inner());
let mut h = lock_or_recover(&self.aprs, "aprs_history");
let before = h.len();
h.push_back((Instant::now(), pkt));
Self::prune_aprs(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_aprs_history(&self) -> Vec<AprsPacket> {
let mut h = self.aprs.lock().unwrap_or_else(|e| e.into_inner());
let mut h = lock_or_recover(&self.aprs, "aprs_history");
let before = h.len();
Self::prune_aprs(&mut h);
self.adjust_total_count(before, h.len());
@@ -362,7 +387,7 @@ impl DecoderHistories {
}
pub fn clear_aprs_history(&self) {
let mut h = self.aprs.lock().unwrap_or_else(|e| e.into_inner());
let mut h = lock_or_recover(&self.aprs, "aprs_history");
let before = h.len();
h.clear();
self.adjust_total_count(before, 0);
@@ -388,15 +413,16 @@ impl DecoderHistories {
if pkt.ts_ms.is_none() {
pkt.ts_ms = Some(current_timestamp_ms());
}
let mut h = self.hf_aprs.lock().unwrap_or_else(|e| e.into_inner());
let mut h = lock_or_recover(&self.hf_aprs, "hf_aprs_history");
let before = h.len();
h.push_back((Instant::now(), pkt));
Self::prune_hf_aprs(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_hf_aprs_history(&self) -> Vec<AprsPacket> {
let mut h = self.hf_aprs.lock().unwrap_or_else(|e| e.into_inner());
let mut h = lock_or_recover(&self.hf_aprs, "hf_aprs_history");
let before = h.len();
Self::prune_hf_aprs(&mut h);
self.adjust_total_count(before, h.len());
@@ -404,7 +430,7 @@ impl DecoderHistories {
}
pub fn clear_hf_aprs_history(&self) {
let mut h = self.hf_aprs.lock().unwrap_or_else(|e| e.into_inner());
let mut h = lock_or_recover(&self.hf_aprs, "hf_aprs_history");
let before = h.len();
h.clear();
self.adjust_total_count(before, 0);
@@ -424,15 +450,16 @@ impl DecoderHistories {
}
pub fn record_cw_event(&self, evt: CwEvent) {
let mut h = self.cw.lock().unwrap_or_else(|e| e.into_inner());
let mut h = lock_or_recover(&self.cw, "cw_history");
let before = h.len();
h.push_back((Instant::now(), evt));
Self::prune_cw(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_cw_history(&self) -> Vec<CwEvent> {
let mut h = self.cw.lock().unwrap_or_else(|e| e.into_inner());
let mut h = lock_or_recover(&self.cw, "cw_history");
let before = h.len();
Self::prune_cw(&mut h);
self.adjust_total_count(before, h.len());
@@ -440,7 +467,7 @@ impl DecoderHistories {
}
pub fn clear_cw_history(&self) {
let mut h = self.cw.lock().unwrap_or_else(|e| e.into_inner());
let mut h = lock_or_recover(&self.cw, "cw_history");
let before = h.len();
h.clear();
self.adjust_total_count(before, 0);
@@ -460,15 +487,16 @@ impl DecoderHistories {
}
pub fn record_ft8_message(&self, msg: Ft8Message) {
let mut h = self.ft8.lock().unwrap_or_else(|e| e.into_inner());
let mut h = lock_or_recover(&self.ft8, "ft8_history");
let before = h.len();
h.push_back((Instant::now(), msg));
Self::prune_ft8(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_ft8_history(&self) -> Vec<Ft8Message> {
let mut h = self.ft8.lock().unwrap_or_else(|e| e.into_inner());
let mut h = lock_or_recover(&self.ft8, "ft8_history");
let before = h.len();
Self::prune_ft8(&mut h);
self.adjust_total_count(before, h.len());
@@ -476,7 +504,7 @@ impl DecoderHistories {
}
pub fn clear_ft8_history(&self) {
let mut h = self.ft8.lock().unwrap_or_else(|e| e.into_inner());
let mut h = lock_or_recover(&self.ft8, "ft8_history");
let before = h.len();
h.clear();
self.adjust_total_count(before, 0);
@@ -496,15 +524,16 @@ impl DecoderHistories {
}
pub fn record_ft4_message(&self, msg: Ft8Message) {
let mut h = self.ft4.lock().unwrap_or_else(|e| e.into_inner());
let mut h = lock_or_recover(&self.ft4, "ft4_history");
let before = h.len();
h.push_back((Instant::now(), msg));
Self::prune_ft4(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_ft4_history(&self) -> Vec<Ft8Message> {
let mut h = self.ft4.lock().unwrap_or_else(|e| e.into_inner());
let mut h = lock_or_recover(&self.ft4, "ft4_history");
let before = h.len();
Self::prune_ft4(&mut h);
self.adjust_total_count(before, h.len());
@@ -512,7 +541,7 @@ impl DecoderHistories {
}
pub fn clear_ft4_history(&self) {
let mut h = self.ft4.lock().unwrap_or_else(|e| e.into_inner());
let mut h = lock_or_recover(&self.ft4, "ft4_history");
let before = h.len();
h.clear();
self.adjust_total_count(before, 0);
@@ -532,15 +561,16 @@ impl DecoderHistories {
}
pub fn record_ft2_message(&self, msg: Ft8Message) {
let mut h = self.ft2.lock().unwrap_or_else(|e| e.into_inner());
let mut h = lock_or_recover(&self.ft2, "ft2_history");
let before = h.len();
h.push_back((Instant::now(), msg));
Self::prune_ft2(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_ft2_history(&self) -> Vec<Ft8Message> {
let mut h = self.ft2.lock().unwrap_or_else(|e| e.into_inner());
let mut h = lock_or_recover(&self.ft2, "ft2_history");
let before = h.len();
Self::prune_ft2(&mut h);
self.adjust_total_count(before, h.len());
@@ -548,7 +578,7 @@ impl DecoderHistories {
}
pub fn clear_ft2_history(&self) {
let mut h = self.ft2.lock().unwrap_or_else(|e| e.into_inner());
let mut h = lock_or_recover(&self.ft2, "ft2_history");
let before = h.len();
h.clear();
self.adjust_total_count(before, 0);
@@ -568,15 +598,16 @@ impl DecoderHistories {
}
pub fn record_wspr_message(&self, msg: WsprMessage) {
let mut h = self.wspr.lock().unwrap_or_else(|e| e.into_inner());
let mut h = lock_or_recover(&self.wspr, "wspr_history");
let before = h.len();
h.push_back((Instant::now(), msg));
Self::prune_wspr(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_wspr_history(&self) -> Vec<WsprMessage> {
let mut h = self.wspr.lock().unwrap_or_else(|e| e.into_inner());
let mut h = lock_or_recover(&self.wspr, "wspr_history");
let before = h.len();
Self::prune_wspr(&mut h);
self.adjust_total_count(before, h.len());
@@ -584,7 +615,7 @@ impl DecoderHistories {
}
pub fn clear_wspr_history(&self) {
let mut h = self.wspr.lock().unwrap_or_else(|e| e.into_inner());
let mut h = lock_or_recover(&self.wspr, "wspr_history");
let before = h.len();
h.clear();
self.adjust_total_count(before, 0);
@@ -607,15 +638,16 @@ impl DecoderHistories {
if img.ts_ms.is_none() {
img.ts_ms = Some(current_timestamp_ms());
}
let mut h = self.lrpt.lock().unwrap_or_else(|e| e.into_inner());
let mut h = lock_or_recover(&self.lrpt, "lrpt_history");
let before = h.len();
h.push_back((Instant::now(), img));
Self::prune_lrpt(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_lrpt_history(&self) -> Vec<LrptImage> {
let mut h = self.lrpt.lock().unwrap_or_else(|e| e.into_inner());
let mut h = lock_or_recover(&self.lrpt, "lrpt_history");
let before = h.len();
Self::prune_lrpt(&mut h);
self.adjust_total_count(before, h.len());
@@ -623,7 +655,7 @@ impl DecoderHistories {
}
pub fn clear_lrpt_history(&self) {
let mut h = self.lrpt.lock().unwrap_or_else(|e| e.into_inner());
let mut h = lock_or_recover(&self.lrpt, "lrpt_history");
let before = h.len();
h.clear();
self.adjust_total_count(before, 0);
@@ -672,6 +704,74 @@ pub fn spawn_audio_capture(
})
}
/// Map a channel count to an `opus::Channels` value.
fn opus_channels(channels: u16) -> Result<opus::Channels, Box<dyn std::error::Error>> {
match channels {
1 => Ok(opus::Channels::Mono),
2 => Ok(opus::Channels::Stereo),
_ => Err(format!("unsupported channel count: {}", channels).into()),
}
}
/// Look up an audio device by name (or fall back to the default device).
///
/// When `is_input` is true the function searches input devices and falls back
/// to the default input device; otherwise it uses output devices. Returns
/// `None` when the device cannot be found (the caller should retry after a
/// delay).
fn find_device(
host: &cpal::Host,
device_name: &Option<String>,
is_input: bool,
) -> Option<cpal::Device> {
use cpal::traits::{DeviceTrait, HostTrait};
let direction = if is_input { "capture" } else { "playback" };
if let Some(ref name) = device_name {
let devices_result = if is_input {
host.input_devices()
} else {
host.output_devices()
};
match devices_result {
Ok(mut devs) => {
match devs.find(|d| d.name().map(|n| n == *name).unwrap_or(false)) {
Some(d) => Some(d),
None => {
warn!("Audio {}: device '{}' not found, retrying", direction, name);
None
}
}
}
Err(e) => {
warn!(
"Audio {}: failed to enumerate devices, retrying: {}",
direction, e
);
None
}
}
} else {
let default = if is_input {
host.default_input_device()
} else {
host.default_output_device()
};
match default {
Some(d) => Some(d),
None => {
warn!(
"Audio {}: no default {} device, retrying",
direction,
if is_input { "input" } else { "output" }
);
None
}
}
}
}
#[allow(clippy::too_many_arguments)]
fn run_capture(
sample_rate: u32,
@@ -683,7 +783,7 @@ fn run_capture(
pcm_tx: Option<broadcast::Sender<Vec<f32>>>,
shutdown_rx: watch::Receiver<bool>,
) -> Result<(), Box<dyn std::error::Error>> {
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::traits::{DeviceTrait, StreamTrait};
use std::sync::mpsc::{RecvTimeoutError, TryRecvError as StdTryRecvError};
let config = cpal::StreamConfig {
@@ -695,13 +795,9 @@ fn run_capture(
let frame_samples =
(sample_rate as usize * frame_duration_ms as usize / 1000) * channels as usize;
let opus_channels = match channels {
1 => opus::Channels::Mono,
2 => opus::Channels::Stereo,
_ => return Err(format!("unsupported channel count: {}", channels).into()),
};
let opus_ch = opus_channels(channels)?;
let mut encoder = opus::Encoder::new(sample_rate, opus_channels, opus::Application::Audio)?;
let mut encoder = opus::Encoder::new(sample_rate, opus_ch, opus::Application::Audio)?;
encoder.set_bitrate(opus::Bitrate::Bits(bitrate_bps as i32))?;
encoder.set_complexity(5)?;
@@ -725,35 +821,11 @@ fn run_capture(
// Re-enumerate the device on every recovery cycle: after POLLERR the
// existing device handle can be stale (especially for USB audio).
let host = cpal::default_host();
let device = if let Some(ref name) = device_name {
match host.input_devices() {
Ok(mut devs) => {
match devs.find(|d| d.name().map(|n| n == *name).unwrap_or(false)) {
Some(d) => d,
None => {
warn!("Audio capture: device '{}' not found, retrying", name);
std::thread::sleep(AUDIO_STREAM_RECOVERY_DELAY);
continue;
}
}
}
Err(e) => {
warn!(
"Audio capture: failed to enumerate devices, retrying: {}",
e
);
std::thread::sleep(AUDIO_STREAM_RECOVERY_DELAY);
continue;
}
}
} else {
match host.default_input_device() {
Some(d) => d,
None => {
warn!("Audio capture: no default input device, retrying");
std::thread::sleep(AUDIO_STREAM_RECOVERY_DELAY);
continue;
}
let device = match find_device(&host, &device_name, true) {
Some(d) => d,
None => {
std::thread::sleep(AUDIO_STREAM_RECOVERY_DELAY);
continue;
}
};
info!(
@@ -922,13 +994,9 @@ fn run_playback(
let frame_samples =
(sample_rate as usize * frame_duration_ms as usize / 1000) * channels as usize;
let opus_channels = match channels {
1 => opus::Channels::Mono,
2 => opus::Channels::Stereo,
_ => return Err(format!("unsupported channel count: {}", channels).into()),
};
let opus_ch = opus_channels(channels)?;
let mut decoder = opus::Decoder::new(sample_rate, opus_channels)?;
let mut decoder = opus::Decoder::new(sample_rate, opus_ch)?;
let ring = std::sync::Arc::new(std::sync::Mutex::new(
std::collections::VecDeque::<f32>::with_capacity(frame_samples * 8),
@@ -952,35 +1020,11 @@ fn run_playback(
// Re-enumerate the device on every recovery cycle: after POLLERR the
// existing device handle can be stale (especially for USB audio).
let host = cpal::default_host();
let device = if let Some(ref name) = device_name {
match host.output_devices() {
Ok(mut devs) => {
match devs.find(|d| d.name().map(|n| n == *name).unwrap_or(false)) {
Some(d) => d,
None => {
warn!("Audio playback: device '{}' not found, retrying", name);
std::thread::sleep(AUDIO_STREAM_RECOVERY_DELAY);
continue;
}
}
}
Err(e) => {
warn!(
"Audio playback: failed to enumerate devices, retrying: {}",
e
);
std::thread::sleep(AUDIO_STREAM_RECOVERY_DELAY);
continue;
}
}
} else {
match host.default_output_device() {
Some(d) => d,
None => {
warn!("Audio playback: no default output device, retrying");
std::thread::sleep(AUDIO_STREAM_RECOVERY_DELAY);
continue;
}
let device = match find_device(&host, &device_name, false) {
Some(d) => d,
None => {
std::thread::sleep(AUDIO_STREAM_RECOVERY_DELAY);
continue;
}
};
info!(
+34 -23
View File
@@ -15,7 +15,7 @@ use std::net::IpAddr;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use trx_app::{ConfigError, ConfigFile};
use trx_app::{validate_log_level, validate_tokens, ConfigError, ConfigFile};
pub use trx_decode_log::DecodeLogsConfig;
use trx_core::rig::state::RigMode;
@@ -101,6 +101,8 @@ pub struct ServerConfig {
pub decode_logs: DecodeLogsConfig,
/// SDR pipeline configuration (legacy flat; used when [rig.access] type = "sdr").
pub sdr: SdrConfig,
/// Timeout and buffer-size tuning knobs.
pub timeouts: TimeoutsConfig,
/// Multi-rig instance list. When non-empty, takes priority over the flat fields.
#[serde(rename = "rigs", default)]
pub rigs: Vec<RigInstanceConfig>,
@@ -204,6 +206,37 @@ impl Default for BehaviorConfig {
}
}
/// Timeout and buffer-size tuning knobs.
///
/// All durations are in milliseconds. The defaults match the previously
/// hard-coded values, so existing deployments are unaffected.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct TimeoutsConfig {
/// Maximum time (ms) to wait for a single rig command to complete.
pub command_exec_timeout_ms: u64,
/// Maximum time (ms) for a CAT poll refresh cycle.
pub poll_refresh_timeout_ms: u64,
/// Maximum time (ms) for low-level listener I/O operations (read/write/flush).
pub io_timeout_ms: u64,
/// Maximum time (ms) to wait for a rig command response in the listener.
pub request_timeout_ms: u64,
/// Capacity of the per-rig command channel (number of queued requests).
pub rig_task_channel_buffer: usize,
}
impl Default for TimeoutsConfig {
fn default() -> Self {
Self {
command_exec_timeout_ms: 10_000,
poll_refresh_timeout_ms: 8_000,
io_timeout_ms: 10_000,
request_timeout_ms: 12_000,
rig_task_channel_buffer: 32,
}
}
}
/// TCP listener configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
@@ -763,21 +796,6 @@ impl ServerConfig {
}
}
fn validate_log_level(level: Option<&str>) -> Result<(), String> {
if let Some(level) = level {
match level {
"trace" | "debug" | "info" | "warn" | "error" => {}
_ => {
return Err(format!(
"[general].log_level '{}' is invalid (expected one of: trace, debug, info, warn, error)",
level
))
}
}
}
Ok(())
}
fn validate_coordinates(latitude: Option<f64>, longitude: Option<f64>) -> Result<(), String> {
match (latitude, longitude) {
(Some(lat), Some(lon)) => {
@@ -876,13 +894,6 @@ fn validate_sdr_nb_config(path: &str, nb: &SdrNoiseBlankerConfig) -> Result<(),
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"));
}
Ok(())
}
impl ConfigFile for ServerConfig {
fn section_key() -> &'static str {
"trx-server"
+44 -20
View File
@@ -32,9 +32,29 @@ use trx_protocol::ClientResponse;
use crate::rig_handle::RigHandle;
const IO_TIMEOUT: Duration = Duration::from_secs(10);
const REQUEST_TIMEOUT: Duration = Duration::from_secs(12);
/// Fallback I/O timeout used when no config value is provided.
const DEFAULT_IO_TIMEOUT: Duration = Duration::from_secs(10);
/// Fallback request timeout used when no config value is provided.
const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(12);
const MAX_JSON_LINE_BYTES: usize = 256 * 1024;
/// Configurable timeout values for the listener, threaded from `[timeouts]`.
#[derive(Debug, Clone, Copy)]
pub struct ListenerTimeouts {
/// Maximum time for low-level I/O operations (read/write/flush).
pub io_timeout: Duration,
/// Maximum time to wait for a rig command response.
pub request_timeout: Duration,
}
impl Default for ListenerTimeouts {
fn default() -> Self {
Self {
io_timeout: DEFAULT_IO_TIMEOUT,
request_timeout: DEFAULT_REQUEST_TIMEOUT,
}
}
}
/// How long to cache satellite pass predictions before recomputing.
/// SGP4 propagation for 200+ satellites is CPU-intensive; caching avoids
/// redundant recomputation when multiple clients request passes concurrently.
@@ -56,6 +76,7 @@ pub async fn run_listener(
default_rig_id: String,
auth_tokens: HashSet<String>,
station_coords: Option<(f64, f64)>,
timeouts: ListenerTimeouts,
mut shutdown_rx: watch::Receiver<bool>,
) -> std::io::Result<()> {
let listener = TcpListener::bind(addr).await?;
@@ -75,8 +96,9 @@ pub async fn run_listener(
let client_shutdown_rx = shutdown_rx.clone();
let coords = station_coords;
let cache = Arc::clone(&sat_pass_cache);
let client_timeouts = timeouts;
tokio::spawn(async move {
if let Err(e) = handle_client(socket, peer, rigs, default_rig_id, validator, coords, cache, client_shutdown_rx).await {
if let Err(e) = handle_client(socket, peer, rigs, default_rig_id, validator, coords, cache, client_timeouts, client_shutdown_rx).await {
error!("Client {} error: {:?}", peer, e);
}
});
@@ -151,14 +173,15 @@ async fn read_limited_line<R: AsyncBufRead + Unpin>(
async fn send_response(
writer: &mut tokio::net::tcp::OwnedWriteHalf,
response: &ClientResponse,
io_timeout: Duration,
) -> std::io::Result<()> {
let resp_line = serde_json::to_string(response).map_err(std::io::Error::other)? + "\n";
time::timeout(IO_TIMEOUT, writer.write_all(resp_line.as_bytes()))
time::timeout(io_timeout, writer.write_all(resp_line.as_bytes()))
.await
.map_err(|_| {
std::io::Error::new(std::io::ErrorKind::TimedOut, "response write timeout")
})??;
time::timeout(IO_TIMEOUT, writer.flush())
time::timeout(io_timeout, writer.flush())
.await
.map_err(|_| {
std::io::Error::new(std::io::ErrorKind::TimedOut, "response flush timeout")
@@ -174,6 +197,7 @@ async fn handle_client(
validator: Arc<SimpleTokenValidator>,
station_coords: Option<(f64, f64)>,
sat_pass_cache: Arc<Mutex<Option<SatPassCache>>>,
timeouts: ListenerTimeouts,
mut shutdown_rx: watch::Receiver<bool>,
) -> std::io::Result<()> {
let (reader, mut writer) = socket.into_split();
@@ -181,7 +205,7 @@ async fn handle_client(
loop {
let line = tokio::select! {
read = time::timeout(IO_TIMEOUT, read_limited_line(&mut reader, MAX_JSON_LINE_BYTES)) => {
read = time::timeout(timeouts.io_timeout, read_limited_line(&mut reader, MAX_JSON_LINE_BYTES)) => {
match read {
Ok(Ok(line)) => line,
Ok(Err(e)) => return Err(e),
@@ -232,7 +256,7 @@ async fn handle_client(
sat_passes: None,
error: Some(format!("Invalid JSON: {}", e)),
};
send_response(&mut writer, &resp).await?;
send_response(&mut writer, &resp, timeouts.io_timeout).await?;
continue;
}
};
@@ -246,7 +270,7 @@ async fn handle_client(
sat_passes: None,
error: Some(err),
};
send_response(&mut writer, &resp).await?;
send_response(&mut writer, &resp, timeouts.io_timeout).await?;
continue;
}
@@ -279,7 +303,7 @@ async fn handle_client(
sat_passes: None,
error: None,
};
send_response(&mut writer, &resp).await?;
send_response(&mut writer, &resp, timeouts.io_timeout).await?;
continue;
}
@@ -341,7 +365,7 @@ async fn handle_client(
sat_passes: Some(result),
error: None,
};
send_response(&mut writer, &resp).await?;
send_response(&mut writer, &resp, timeouts.io_timeout).await?;
continue;
}
@@ -358,7 +382,7 @@ async fn handle_client(
sat_passes: None,
error: Some(format!("Unknown rig_id: {}", target_rig_id)),
};
send_response(&mut writer, &resp).await?;
send_response(&mut writer, &resp, timeouts.io_timeout).await?;
continue;
}
};
@@ -377,7 +401,7 @@ async fn handle_client(
sat_passes: None,
error: None,
};
send_response(&mut writer, &resp).await?;
send_response(&mut writer, &resp, timeouts.io_timeout).await?;
continue;
}
}
@@ -389,7 +413,7 @@ async fn handle_client(
rig_id_override: None,
};
match time::timeout(IO_TIMEOUT, handle.rig_tx.send(req)).await {
match time::timeout(timeouts.io_timeout, handle.rig_tx.send(req)).await {
Ok(Ok(())) => {}
Ok(Err(e)) => {
error!(
@@ -404,7 +428,7 @@ async fn handle_client(
sat_passes: None,
error: Some("Internal error: rig task not available".into()),
};
send_response(&mut writer, &resp).await?;
send_response(&mut writer, &resp, timeouts.io_timeout).await?;
continue;
}
Err(_) => {
@@ -416,13 +440,13 @@ async fn handle_client(
sat_passes: None,
error: Some("Internal error: request queue timeout".into()),
};
send_response(&mut writer, &resp).await?;
send_response(&mut writer, &resp, timeouts.io_timeout).await?;
continue;
}
}
match tokio::select! {
result = time::timeout(REQUEST_TIMEOUT, resp_rx) => {
result = time::timeout(timeouts.request_timeout, resp_rx) => {
match result {
Ok(inner) => inner,
Err(_) => {
@@ -434,7 +458,7 @@ async fn handle_client(
sat_passes: None,
error: Some("Request timed out waiting for rig response".into()),
};
send_response(&mut writer, &resp).await?;
send_response(&mut writer, &resp, timeouts.io_timeout).await?;
continue;
}
}
@@ -459,7 +483,7 @@ async fn handle_client(
sat_passes: None,
error: None,
};
send_response(&mut writer, &resp).await?;
send_response(&mut writer, &resp, timeouts.io_timeout).await?;
}
Ok(Err(err)) => {
let resp = ClientResponse {
@@ -470,7 +494,7 @@ async fn handle_client(
sat_passes: None,
error: Some(err.message),
};
send_response(&mut writer, &resp).await?;
send_response(&mut writer, &resp, timeouts.io_timeout).await?;
}
Err(e) => {
error!("Rig response oneshot recv error: {:?}", e);
@@ -482,7 +506,7 @@ async fn handle_client(
sat_passes: None,
error: Some("Internal error waiting for rig response".into()),
};
send_response(&mut writer, &resp).await?;
send_response(&mut writer, &resp, timeouts.io_timeout).await?;
}
}
}
+60 -34
View File
@@ -39,7 +39,6 @@ use rig_handle::RigHandle;
use trx_decode_log::DecoderLoggers;
const PKG_DESCRIPTION: &str = concat!(env!("CARGO_PKG_NAME"), " - rig server daemon");
const RIG_TASK_CHANNEL_BUFFER: usize = 32;
const RETRY_MAX_DELAY_SECS: u64 = 2;
#[derive(Debug, Parser)]
@@ -322,32 +321,36 @@ fn build_sdr_rig_from_instance(rig_cfg: &RigInstanceConfig) -> SdrRigBuildResult
));
}
let ais_channel_base_idx = channels.len();
let vdes_channel_idx = channels
.iter()
.position(|(_, mode, _)| matches!(mode, trx_core::rig::state::RigMode::VDES))
.unwrap_or(0);
let sdr_rig = trx_backend::SoapySdrRig::new_with_config(
args,
&channels,
&rig_cfg.sdr.gain.mode,
rig_cfg.sdr.gain.value,
rig_cfg.sdr.gain.max_value,
rig_cfg.audio.sample_rate,
rig_cfg.audio.channels as usize,
rig_cfg.audio.frame_duration_ms,
rig_cfg.sdr.wfm_deemphasis_us,
Freq {
let sdr_rig = trx_backend::SoapySdrRig::new_from_config(trx_backend::SoapySdrConfig {
args: args.to_string(),
channels,
gain_mode: rig_cfg.sdr.gain.mode.clone(),
gain_db: rig_cfg.sdr.gain.value,
max_gain_db: rig_cfg.sdr.gain.max_value,
audio_sample_rate: rig_cfg.audio.sample_rate,
audio_channels: rig_cfg.audio.channels as usize,
frame_duration_ms: rig_cfg.audio.frame_duration_ms,
wfm_deemphasis_us: rig_cfg.sdr.wfm_deemphasis_us,
initial_freq: Freq {
hz: rig_cfg.rig.initial_freq_hz,
},
rig_cfg.rig.initial_mode.clone(),
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,
rig_cfg.sdr.max_virtual_channels,
rig_cfg.sdr.noise_blanker.enabled,
rig_cfg.sdr.noise_blanker.threshold,
)?;
initial_mode: rig_cfg.rig.initial_mode.clone(),
sdr_sample_rate: rig_cfg.sdr.sample_rate,
bandwidth_hz: rig_cfg.sdr.bandwidth,
center_offset_hz: rig_cfg.sdr.center_offset_hz,
squelch_enabled: rig_cfg.sdr.squelch.enabled,
squelch_threshold_db: rig_cfg.sdr.squelch.threshold_db,
squelch_hysteresis_db: rig_cfg.sdr.squelch.hysteresis_db,
squelch_tail_ms: rig_cfg.sdr.squelch.tail_ms,
max_virtual_channels: rig_cfg.sdr.max_virtual_channels,
nb_enabled: rig_cfg.sdr.noise_blanker.enabled,
nb_threshold: rig_cfg.sdr.noise_blanker.threshold,
})?;
let pcm_rx = sdr_rig.subscribe_pcm();
let ais_pcm = (
@@ -357,10 +360,6 @@ fn build_sdr_rig_from_instance(rig_cfg: &RigInstanceConfig) -> SdrRigBuildResult
// Subscribe to the first channel configured as VDES or MARINE so that the
// IQ tap in ChannelDsp actually fires. Fall back to channel 0 when no
// explicit VDES channel has been configured.
let vdes_channel_idx = channels
.iter()
.position(|(_, mode, _)| matches!(mode, trx_core::rig::state::RigMode::VDES))
.unwrap_or(0);
let vdes_iq = sdr_rig.subscribe_iq_channel(vdes_channel_idx);
// Extract the virtual channel manager before the rig is consumed by Box.
let vchan_manager: trx_core::vchan::SharedVChanManager = sdr_rig.channel_manager();
@@ -384,6 +383,7 @@ fn build_rig_task_config(
longitude: Option<f64>,
registry: Arc<RegistrationContext>,
histories: Arc<DecoderHistories>,
timeouts: &config::TimeoutsConfig,
) -> rig_task::RigTaskConfig {
let pskreporter_status = if rig_cfg.pskreporter.enabled {
let has_locator = rig_cfg.pskreporter.receiver_locator.is_some()
@@ -448,6 +448,8 @@ fn build_rig_task_config(
histories,
vfo_prime: rig_cfg.behavior.vfo_prime,
prebuilt_rig: None,
command_exec_timeout: Duration::from_millis(timeouts.command_exec_timeout_ms),
poll_refresh_timeout: Duration::from_millis(timeouts.poll_refresh_timeout_ms),
}
}
@@ -1028,7 +1030,7 @@ async fn main() -> DynResult<()> {
}
rig_histories_for_flush.push((rig_cfg.id.clone(), histories.clone()));
let (rig_tx, rig_rx) = mpsc::channel::<RigRequest>(RIG_TASK_CHANNEL_BUFFER);
let (rig_tx, rig_rx) = mpsc::channel::<RigRequest>(cfg.timeouts.rig_task_channel_buffer);
let mut initial_state = RigState::new_with_metadata(
callsign.clone(),
Some(env!("CARGO_PKG_VERSION").to_string()),
@@ -1065,6 +1067,7 @@ async fn main() -> DynResult<()> {
longitude,
Arc::clone(&registry),
histories.clone(),
&cfg.timeouts,
);
if let Some(prebuilt) = sdr_prebuilt_rig {
task_config.prebuilt_rig = Some(prebuilt);
@@ -1074,13 +1077,31 @@ async fn main() -> DynResult<()> {
AdaptivePolling::new(Duration::from_millis(100), Duration::from_millis(100));
}
// Spawn rig task.
// Spawn rig task with crash detection.
// If the task panics or returns an error, emit RigMachineState::Error
// on the watch channel so connected clients see the failure instead of
// silently losing the rig.
let rig_shutdown_rx = shutdown_rx.clone();
let rig_id_supervisor = rig_cfg.id.clone();
task_handles.push(tokio::spawn(async move {
if let Err(e) =
rig_task::run_rig_task(task_config, rig_rx, state_tx, rig_shutdown_rx).await
{
error!("Rig task error: {:?}", e);
let result =
rig_task::run_rig_task(task_config, rig_rx, state_tx.clone(), rig_shutdown_rx)
.await;
match result {
Ok(()) => {
info!("[{}] Rig task exited cleanly", rig_id_supervisor);
}
Err(e) => {
error!(
"[{}] Rig task crashed: {:?}; signalling error state to clients",
rig_id_supervisor, e
);
let mut err_state = state_tx.borrow().clone();
err_state.machine_state = "Error".to_string();
err_state.error_message =
Some(format!("Rig task terminated unexpectedly: {}", e));
let _ = state_tx.send(err_state);
}
}
}));
@@ -1143,6 +1164,10 @@ async fn main() -> DynResult<()> {
.collect();
let rigs_arc = Arc::new(rig_handles);
let listener_shutdown_rx = shutdown_rx.clone();
let listener_timeouts = listener::ListenerTimeouts {
io_timeout: Duration::from_millis(cfg.timeouts.io_timeout_ms),
request_timeout: Duration::from_millis(cfg.timeouts.request_timeout_ms),
};
task_handles.push(tokio::spawn(async move {
let station_coords = latitude.zip(longitude);
if let Err(e) = listener::run_listener(
@@ -1151,6 +1176,7 @@ async fn main() -> DynResult<()> {
default_rig_id,
auth_tokens,
station_coords,
listener_timeouts,
listener_shutdown_rx,
)
.await
+19 -6
View File
@@ -27,8 +27,10 @@ use trx_core::{DynResult, RigError, RigResult};
use crate::audio::DecoderHistories;
use crate::error::is_invalid_bcd_error;
const POLL_REFRESH_TIMEOUT: Duration = Duration::from_secs(8);
const COMMAND_EXEC_TIMEOUT: Duration = Duration::from_secs(10);
/// Fallback poll refresh timeout used when no config value is provided.
const DEFAULT_POLL_REFRESH_TIMEOUT: Duration = Duration::from_secs(8);
/// Fallback command execution timeout used when no config value is provided.
const DEFAULT_COMMAND_EXEC_TIMEOUT: Duration = Duration::from_secs(10);
/// Configuration for the rig task.
pub struct RigTaskConfig {
pub registry: Arc<RegistrationContext>,
@@ -57,6 +59,10 @@ pub struct RigTaskConfig {
/// `SoapySdrRig` (built with channel config) without duplicating the
/// pipeline construction.
pub prebuilt_rig: Option<Box<dyn RigCat>>,
/// Maximum time to wait for a single rig command to complete.
pub command_exec_timeout: Duration,
/// Maximum time for a CAT poll refresh cycle.
pub poll_refresh_timeout: Duration,
}
impl Default for RigTaskConfig {
@@ -85,6 +91,8 @@ impl Default for RigTaskConfig {
histories: DecoderHistories::new(),
vfo_prime: true,
prebuilt_rig: None,
command_exec_timeout: DEFAULT_COMMAND_EXEC_TIMEOUT,
poll_refresh_timeout: DEFAULT_POLL_REFRESH_TIMEOUT,
}
}
}
@@ -143,6 +151,10 @@ pub async fn run_rig_task(
state.pskreporter_status = config.pskreporter_status.clone();
state.aprs_is_status = config.aprs_is_status.clone();
// Timeout configuration
let command_exec_timeout = config.command_exec_timeout;
let poll_refresh_timeout = config.poll_refresh_timeout;
// Polling configuration
let polling = &config.polling;
let retry = &config.retry;
@@ -284,7 +296,7 @@ pub async fn run_rig_task(
// Poll rig state
let old_state = state.clone();
match time::timeout(
POLL_REFRESH_TIMEOUT,
poll_refresh_timeout,
refresh_state_with_retry(&mut rig, &mut state, retry),
)
.await
@@ -315,7 +327,7 @@ pub async fn run_rig_task(
Err(_) => {
error!(
"CAT polling timed out after {:?}",
POLL_REFRESH_TIMEOUT
poll_refresh_timeout
);
}
}
@@ -329,8 +341,9 @@ pub async fn run_rig_task(
batch.push(next);
}
// Process each request
while let Some(RigRequest { cmd, respond_to, .. }) = batch.pop() {
// Process each request in FIFO order (drain from front)
while !batch.is_empty() {
let RigRequest { cmd, respond_to, .. } = batch.remove(0);
if matches!(cmd, RigCommand::GetSpectrum) {
let mut responders = vec![respond_to];
let mut idx = 0;
+1 -1
View File
@@ -17,7 +17,7 @@ use trx_backend_ft450d::Ft450d;
#[cfg(feature = "ft817")]
use trx_backend_ft817::Ft817;
#[cfg(feature = "soapysdr")]
pub use trx_backend_soapysdr::SoapySdrRig;
pub use trx_backend_soapysdr::{SoapySdrConfig, SoapySdrRig};
/// Connection details for instantiating a rig backend.
#[derive(Debug, Clone)]
@@ -23,6 +23,86 @@ const AIS_CHANNEL_SPACING_HZ: i64 = 50_000;
pub use vchan_impl::SdrVirtualChannelManager;
/// Configuration struct for constructing a [`SoapySdrRig`].
///
/// Replaces the 20+ parameter `new_with_config()` constructor with a more
/// readable and maintainable builder. All fields have sensible defaults via
/// the `Default` implementation.
#[derive(Debug, Clone)]
pub struct SoapySdrConfig {
/// SoapySDR device args string (e.g. `"driver=rtlsdr"`).
pub args: String,
/// Per-channel tuples of `(channel_if_hz, initial_mode, audio_bandwidth_hz)`.
pub channels: Vec<(f64, RigMode, u32)>,
/// `"auto"` or `"manual"`.
pub gain_mode: String,
/// Gain in dB; used when `gain_mode == "manual"`.
pub gain_db: f64,
/// Optional hard ceiling for the applied hardware gain in dB.
pub max_gain_db: Option<f64>,
/// Output PCM rate (Hz).
pub audio_sample_rate: u32,
/// Number of audio channels.
pub audio_channels: usize,
/// Output frame length (ms).
pub frame_duration_ms: u16,
/// WFM deemphasis time constant in microseconds.
pub wfm_deemphasis_us: u32,
/// Initial dial frequency.
pub initial_freq: Freq,
/// Initial demodulation mode.
pub initial_mode: RigMode,
/// IQ capture rate (Hz).
pub sdr_sample_rate: u32,
/// Hardware IF filter bandwidth to apply to the device.
pub bandwidth_hz: u32,
/// The hardware is tuned this many Hz *below* the dial frequency so the
/// desired signal lands off-DC. The DSP mixer shifts it back.
pub center_offset_hz: i64,
/// Enable software squelch for all modes except WFM.
pub squelch_enabled: bool,
/// Squelch open threshold in dBFS.
pub squelch_threshold_db: f32,
/// Close hysteresis in dB.
pub squelch_hysteresis_db: f32,
/// Tail hold time in milliseconds.
pub squelch_tail_ms: u32,
/// Maximum number of dynamic virtual channels.
pub max_virtual_channels: usize,
/// Whether the noise blanker is enabled on the primary channel.
pub nb_enabled: bool,
/// Noise blanker impulse threshold multiplier.
pub nb_threshold: f64,
}
impl Default for SoapySdrConfig {
fn default() -> Self {
Self {
args: String::new(),
channels: Vec::new(),
gain_mode: "auto".to_string(),
gain_db: 30.0,
max_gain_db: None,
audio_sample_rate: 48_000,
audio_channels: 1,
frame_duration_ms: 20,
wfm_deemphasis_us: 50,
initial_freq: Freq { hz: 144_300_000 },
initial_mode: RigMode::USB,
sdr_sample_rate: 1_920_000,
bandwidth_hz: 1_500_000,
center_offset_hz: 0,
squelch_enabled: false,
squelch_threshold_db: -65.0,
squelch_hysteresis_db: 3.0,
squelch_tail_ms: 180,
max_virtual_channels: 4,
nb_enabled: false,
nb_threshold: 10.0,
}
}
}
/// RX-only backend for any SoapySDR-compatible device.
pub struct SoapySdrRig {
info: RigInfo,
@@ -88,55 +168,32 @@ impl SoapySdrRig {
}
}
/// Full constructor. All channel configuration is passed as plain
/// parameters so this crate does not need to depend on `trx-server`
/// (which is a binary, not a library crate).
/// Construct from a [`SoapySdrConfig`] struct.
///
/// # Parameters
/// - `args`: SoapySDR device args string (e.g. `"driver=rtlsdr"`).
/// Opens a real hardware device via SoapySDR.
/// - `channels`: per-channel tuples of
/// `(channel_if_hz, initial_mode, audio_bandwidth_hz)`.
/// - `gain_mode`: `"auto"` or `"manual"`.
/// - `gain_db`: gain in dB; used when `gain_mode == "manual"`.
/// - `max_gain_db`: optional hard ceiling for the applied hardware gain.
/// - `audio_sample_rate`: output PCM rate (Hz).
/// - `frame_duration_ms`: output frame length (ms).
/// - `initial_freq`: initial dial frequency reported by `get_status`.
/// - `initial_mode`: initial demodulation mode.
/// - `sdr_sample_rate`: IQ capture rate (Hz).
/// - `bandwidth_hz`: hardware IF filter bandwidth to apply to the device.
/// - `center_offset_hz`: the hardware is tuned this many Hz *below* the
/// dial frequency so the desired signal lands off-DC. The DSP mixer
/// shifts it back. Pass 0 to tune exactly to the dial frequency.
/// - `squelch_enabled`: enable software squelch for all modes except WFM.
/// - `squelch_threshold_db`: squelch open threshold in dBFS.
/// - `squelch_hysteresis_db`: close hysteresis in dB.
/// - `squelch_tail_ms`: tail hold time in milliseconds.
#[allow(clippy::too_many_arguments)]
pub fn new_with_config(
args: &str,
channels: &[(f64, RigMode, u32)],
gain_mode: &str,
gain_db: f64,
max_gain_db: Option<f64>,
audio_sample_rate: u32,
audio_channels: usize,
frame_duration_ms: u16,
wfm_deemphasis_us: u32,
initial_freq: Freq,
initial_mode: RigMode,
sdr_sample_rate: u32,
bandwidth_hz: u32,
center_offset_hz: i64,
squelch_enabled: bool,
squelch_threshold_db: f32,
squelch_hysteresis_db: f32,
squelch_tail_ms: u32,
max_virtual_channels: usize,
nb_enabled: bool,
nb_threshold: f64,
) -> DynResult<Self> {
/// This is the preferred constructor. See [`SoapySdrConfig`] for field
/// documentation and defaults.
pub fn new_from_config(config: SoapySdrConfig) -> DynResult<Self> {
let args = &config.args;
let channels = &config.channels;
let gain_mode = &config.gain_mode;
let gain_db = config.gain_db;
let max_gain_db = config.max_gain_db;
let audio_sample_rate = config.audio_sample_rate;
let audio_channels = config.audio_channels;
let frame_duration_ms = config.frame_duration_ms;
let wfm_deemphasis_us = config.wfm_deemphasis_us;
let initial_freq = config.initial_freq;
let initial_mode = config.initial_mode;
let sdr_sample_rate = config.sdr_sample_rate;
let bandwidth_hz = config.bandwidth_hz;
let center_offset_hz = config.center_offset_hz;
let squelch_enabled = config.squelch_enabled;
let squelch_threshold_db = config.squelch_threshold_db;
let squelch_hysteresis_db = config.squelch_hysteresis_db;
let squelch_tail_ms = config.squelch_tail_ms;
let max_virtual_channels = config.max_virtual_channels;
let nb_enabled = config.nb_enabled;
let nb_threshold = config.nb_threshold;
tracing::info!(
"initialising SoapySDR backend (args={:?}, gain_mode={:?}, gain_db={}, max_gain_db={:?})",
args,
@@ -332,33 +389,67 @@ impl SoapySdrRig {
Ok(rig)
}
/// Legacy constructor kept for backward compatibility.
///
/// Prefer [`Self::new_from_config`] with a [`SoapySdrConfig`] struct for
/// better readability.
#[allow(clippy::too_many_arguments)]
pub fn new_with_config(
args: &str,
channels: &[(f64, RigMode, u32)],
gain_mode: &str,
gain_db: f64,
max_gain_db: Option<f64>,
audio_sample_rate: u32,
audio_channels: usize,
frame_duration_ms: u16,
wfm_deemphasis_us: u32,
initial_freq: Freq,
initial_mode: RigMode,
sdr_sample_rate: u32,
bandwidth_hz: u32,
center_offset_hz: i64,
squelch_enabled: bool,
squelch_threshold_db: f32,
squelch_hysteresis_db: f32,
squelch_tail_ms: u32,
max_virtual_channels: usize,
nb_enabled: bool,
nb_threshold: f64,
) -> DynResult<Self> {
Self::new_from_config(SoapySdrConfig {
args: args.to_string(),
channels: channels.to_vec(),
gain_mode: gain_mode.to_string(),
gain_db,
max_gain_db,
audio_sample_rate,
audio_channels,
frame_duration_ms,
wfm_deemphasis_us,
initial_freq,
initial_mode,
sdr_sample_rate,
bandwidth_hz,
center_offset_hz,
squelch_enabled,
squelch_threshold_db,
squelch_hysteresis_db,
squelch_tail_ms,
max_virtual_channels,
nb_enabled,
nb_threshold,
})
}
/// Simple constructor for backward compatibility with the factory function.
/// Creates a pipeline with no channels — the DSP loop runs but produces no
/// PCM frames.
pub fn new(args: &str) -> DynResult<Self> {
Self::new_with_config(
args,
&[], // no channels — pipeline does nothing; filter defaults applied in new_with_config
"auto",
30.0,
None,
48_000,
1,
20,
50,
Freq { hz: 144_300_000 },
RigMode::USB,
1_920_000,
1_500_000, // bandwidth_hz
0, // center_offset_hz
false, // squelch_enabled
-65.0, // squelch_threshold_db
3.0, // squelch_hysteresis_db
180, // squelch_tail_ms
4, // max_virtual_channels
false, // nb_enabled
10.0, // nb_threshold
)
Self::new_from_config(SoapySdrConfig {
args: args.to_string(),
..SoapySdrConfig::default()
})
}
/// Return the virtual channel manager for this SDR rig.