[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:
+144
-100
@@ -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!(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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(®istry),
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user