diff --git a/src/trx-config/src/server.rs b/src/trx-config/src/server.rs index 55c86401..042718eb 100644 --- a/src/trx-config/src/server.rs +++ b/src/trx-config/src/server.rs @@ -21,6 +21,56 @@ pub use trx_decode_log::DecodeLogsConfig; use trx_core::rig::state::RigMode; +/// Every decoder the server knows how to run, by config name. +/// +/// The same names are used by `[sdr.channels].decoders`, so there is one +/// vocabulary for both. +pub const DECODER_NAMES: &[&str] = &[ + "aprs", "aprs_hf", "ais", "cw", "ft2", "ft4", "ft8", "lrpt", "sstv", "vdes", "wefax", "wspr", +]; + +/// Which decoders run for a rig, and where the ones that write files put them. +/// +/// Decoders used to be started unconditionally: every rig ran ten of them +/// whether or not the operator ever looked at the results, which is real CPU on +/// a Pi. `enabled` defaults to all of them so upgrading changes nothing. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct DecodersConfig { + /// Decoder names to run; see `DECODER_NAMES`. + pub enabled: Vec, + /// Base directory for decoders that write images (sstv, wefax, lrpt). + /// Each decoder gets a subdirectory. Defaults to the user cache directory. + pub output_dir: Option, +} + +impl Default for DecodersConfig { + fn default() -> Self { + Self { + enabled: DECODER_NAMES.iter().map(|s| s.to_string()).collect(), + output_dir: None, + } + } +} + +impl DecodersConfig { + /// Whether `name` should be started for this rig. + pub fn is_enabled(&self, name: &str) -> bool { + self.enabled.iter().any(|n| n == name) + } + + /// Where `decoder` should write its files. + pub fn output_dir_for(&self, decoder: &str) -> std::path::PathBuf { + let base = match &self.output_dir { + Some(dir) => std::path::PathBuf::from(dir), + None => dirs::cache_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".cache")) + .join("trx-rs"), + }; + base.join(decoder) + } +} + /// Per-rig instance configuration for multi-rig setups. /// /// Each entry in `[[rigs]]` becomes one of these. The flat top-level @@ -53,6 +103,8 @@ pub struct RigInstanceConfig { pub aprsfi: AprsFiConfig, /// Decoder file logging for this rig. pub decode_logs: DecodeLogsConfig, + /// Which decoders to run for this rig. + pub decoders: DecodersConfig, } impl Default for RigInstanceConfig { @@ -68,6 +120,7 @@ impl Default for RigInstanceConfig { pskreporter: PskReporterConfig::default(), aprsfi: AprsFiConfig::default(), decode_logs: DecodeLogsConfig::default(), + decoders: DecodersConfig::default(), } } } @@ -100,6 +153,8 @@ pub struct ServerConfig { pub aprsfi: AprsFiConfig, /// Decoder file logging configuration (legacy flat) pub decode_logs: DecodeLogsConfig, + /// Decoder selection (legacy flat) + pub decoders: DecodersConfig, /// SDR pipeline configuration (legacy flat; used when [rig.access] type = "sdr"). pub sdr: SdrConfig, /// Timeout and buffer-size tuning knobs. @@ -642,6 +697,7 @@ impl ServerConfig { pskreporter: self.pskreporter.clone(), aprsfi: self.aprsfi.clone(), decode_logs: self.decode_logs.clone(), + decoders: self.decoders.clone(), }] } @@ -679,6 +735,7 @@ impl ServerConfig { pskreporter: PskReporterConfig::default(), aprsfi: AprsFiConfig::default(), decode_logs: DecodeLogsConfig::default(), + decoders: DecodersConfig::default(), sdr: SdrConfig::default(), timeouts: TimeoutsConfig::default(), rigs: Vec::new(), @@ -821,6 +878,16 @@ fn validate_rig_instance( errors.push(e); } + for name in &rig.decoders.enabled { + if !DECODER_NAMES.contains(&name.as_str()) { + errors.push(format!( + "{prefix}[decoders].enabled contains unknown decoder \"{}\" (valid: {})", + name, + DECODER_NAMES.join(", ") + )); + } + } + if rig.decode_logs.enabled { if rig.decode_logs.dir.trim().is_empty() { errors.push(format!( @@ -1650,6 +1717,73 @@ port = 4531 ); } + // --- Decoder selection --- + + #[test] + fn test_decoders_default_enables_everything() { + let cfg = DecodersConfig::default(); + for name in DECODER_NAMES { + assert!(cfg.is_enabled(name), "{name} should be on by default"); + } + } + + #[test] + fn test_decoders_parsed_from_rig_entry() { + let toml_str = r#" +[[rigs]] +id = "hf" +[rigs.rig] +model = "ft817" +[rigs.rig.access] +type = "serial" +port = "/dev/ttyUSB0" +baud = 9600 +[rigs.audio] +port = 4531 +[rigs.decoders] +enabled = ["ft8", "wspr"] +output_dir = "/var/lib/trx-rs" +"#; + let cfg: ServerConfig = toml::from_str(toml_str).unwrap(); + cfg.validate().expect("config should be valid"); + let rigs = cfg.resolved_rigs(); + assert!(rigs[0].decoders.is_enabled("ft8")); + assert!(!rigs[0].decoders.is_enabled("cw")); + assert_eq!( + rigs[0].decoders.output_dir_for("sstv"), + std::path::PathBuf::from("/var/lib/trx-rs/sstv") + ); + } + + #[test] + fn test_decoders_flat_layout_reaches_resolved_rig() { + let toml_str = r#" +[decoders] +enabled = ["cw"] +"#; + let cfg: ServerConfig = toml::from_str(toml_str).unwrap(); + let rigs = cfg.resolved_rigs(); + assert!(rigs[0].decoders.is_enabled("cw")); + assert!(!rigs[0].decoders.is_enabled("ft8")); + } + + #[test] + fn test_validate_rejects_unknown_decoder_name() { + let toml_str = r#" +[decoders] +enabled = ["ft8", "morse"] +"#; + let cfg: ServerConfig = toml::from_str(toml_str).unwrap(); + let err = cfg.validate().expect_err("expected an unknown decoder error"); + assert!(err.contains("morse"), "unexpected error: {err}"); + } + + #[test] + fn test_decoders_output_dir_defaults_under_cache() { + let cfg = DecodersConfig::default(); + assert!(cfg.output_dir_for("sstv").ends_with("trx-rs/sstv")); + } + // --- Per-rig validation: [[rigs]] entries obey the same rules as the // legacy flat layout, which they previously escaped entirely. --- diff --git a/src/trx-server/src/audio.rs b/src/trx-server/src/audio.rs index 933b062e..e26054d4 100644 --- a/src/trx-server/src/audio.rs +++ b/src/trx-server/src/audio.rs @@ -2291,15 +2291,12 @@ pub async fn run_wefax_decoder( mut state_rx: watch::Receiver, decode_tx: broadcast::Sender, histories: Arc, + wefax_output_dir: std::path::PathBuf, ) { use trx_wefax::{WefaxConfig, WefaxDecoder, WefaxEvent}; info!("WEFAX decoder started ({}Hz, {} ch)", sample_rate, channels); - let wefax_output_dir = dirs::cache_dir() - .unwrap_or_else(|| std::path::PathBuf::from(".cache")) - .join("trx-rs") - .join("wefax"); let config = WefaxConfig { output_dir: Some(wefax_output_dir.to_string_lossy().into_owned()), ..WefaxConfig::default() diff --git a/src/trx-server/src/main.rs b/src/trx-server/src/main.rs index 11c7ac1b..0621689a 100644 --- a/src/trx-server/src/main.rs +++ b/src/trx-server/src/main.rs @@ -660,38 +660,44 @@ fn spawn_rig_audio_stack( } // Spawn APRS decoder task - let aprs_pcm_rx = pcm_tx.subscribe(); - let aprs_state_rx = state_rx.clone(); - let aprs_decode_tx = decode_tx.clone(); - let aprs_sr = rig_cfg.audio.sample_rate; - let aprs_ch = rig_cfg.audio.channels; - let aprs_shutdown_rx = shutdown_rx.clone(); - let aprs_logs = decoder_logs.clone(); - let aprs_histories = histories.clone(); - handles.push(tokio::spawn(async move { - tokio::select! { - _ = audio::run_aprs_decoder(aprs_sr, aprs_ch as u16, aprs_pcm_rx, aprs_state_rx, aprs_decode_tx, aprs_logs, aprs_histories) => {} - _ = wait_for_shutdown(aprs_shutdown_rx) => {} - } - })); + if rig_cfg.decoders.is_enabled("aprs") { + let aprs_pcm_rx = pcm_tx.subscribe(); + let aprs_state_rx = state_rx.clone(); + let aprs_decode_tx = decode_tx.clone(); + let aprs_sr = rig_cfg.audio.sample_rate; + let aprs_ch = rig_cfg.audio.channels; + let aprs_shutdown_rx = shutdown_rx.clone(); + let aprs_logs = decoder_logs.clone(); + let aprs_histories = histories.clone(); + handles.push(tokio::spawn(async move { + tokio::select! { + _ = audio::run_aprs_decoder(aprs_sr, aprs_ch as u16, aprs_pcm_rx, aprs_state_rx, aprs_decode_tx, aprs_logs, aprs_histories) => {} + _ = wait_for_shutdown(aprs_shutdown_rx) => {} + } + })); + } // Spawn HF APRS decoder task - let hf_aprs_pcm_rx = pcm_tx.subscribe(); - let hf_aprs_state_rx = state_rx.clone(); - let hf_aprs_decode_tx = decode_tx.clone(); - let hf_aprs_sr = rig_cfg.audio.sample_rate; - let hf_aprs_ch = rig_cfg.audio.channels; - let hf_aprs_shutdown_rx = shutdown_rx.clone(); - let hf_aprs_logs = decoder_logs.clone(); - let hf_aprs_histories = histories.clone(); - handles.push(tokio::spawn(async move { - tokio::select! { - _ = audio::run_hf_aprs_decoder(hf_aprs_sr, hf_aprs_ch as u16, hf_aprs_pcm_rx, hf_aprs_state_rx, hf_aprs_decode_tx, hf_aprs_logs, hf_aprs_histories) => {} - _ = wait_for_shutdown(hf_aprs_shutdown_rx) => {} - } - })); + if rig_cfg.decoders.is_enabled("aprs_hf") { + let hf_aprs_pcm_rx = pcm_tx.subscribe(); + let hf_aprs_state_rx = state_rx.clone(); + let hf_aprs_decode_tx = decode_tx.clone(); + let hf_aprs_sr = rig_cfg.audio.sample_rate; + let hf_aprs_ch = rig_cfg.audio.channels; + let hf_aprs_shutdown_rx = shutdown_rx.clone(); + let hf_aprs_logs = decoder_logs.clone(); + let hf_aprs_histories = histories.clone(); + handles.push(tokio::spawn(async move { + tokio::select! { + _ = audio::run_hf_aprs_decoder(hf_aprs_sr, hf_aprs_ch as u16, hf_aprs_pcm_rx, hf_aprs_state_rx, hf_aprs_decode_tx, hf_aprs_logs, hf_aprs_histories) => {} + _ = wait_for_shutdown(hf_aprs_shutdown_rx) => {} + } + })); + } - if let Some((ais_a_pcm_rx, ais_b_pcm_rx)) = sdr_ais_pcm_rx { + if let Some((ais_a_pcm_rx, ais_b_pcm_rx)) = + sdr_ais_pcm_rx.filter(|_| rig_cfg.decoders.is_enabled("ais")) + { let ais_state_rx = state_rx.clone(); let ais_decode_tx = decode_tx.clone(); let ais_shutdown_rx = shutdown_rx.clone(); @@ -706,7 +712,7 @@ fn spawn_rig_audio_stack( })); } - if let Some(vdes_iq_rx) = sdr_vdes_iq_rx { + if let Some(vdes_iq_rx) = sdr_vdes_iq_rx.filter(|_| rig_cfg.decoders.is_enabled("vdes")) { let vdes_state_rx = state_rx.clone(); let vdes_decode_tx = decode_tx.clone(); let vdes_shutdown_rx = shutdown_rx.clone(); @@ -728,55 +734,61 @@ fn spawn_rig_audio_stack( } // Spawn CW decoder task - let cw_pcm_rx = pcm_tx.subscribe(); - let cw_state_rx = state_rx.clone(); - let cw_decode_tx = decode_tx.clone(); - let cw_sr = rig_cfg.audio.sample_rate; - let cw_ch = rig_cfg.audio.channels; - let cw_shutdown_rx = shutdown_rx.clone(); - let cw_logs = decoder_logs.clone(); - let cw_histories = histories.clone(); - handles.push(tokio::spawn(async move { - tokio::select! { - _ = audio::run_cw_decoder(cw_sr, cw_ch as u16, cw_pcm_rx, cw_state_rx, cw_decode_tx, cw_logs, cw_histories) => {} - _ = wait_for_shutdown(cw_shutdown_rx) => {} - } - })); + if rig_cfg.decoders.is_enabled("cw") { + let cw_pcm_rx = pcm_tx.subscribe(); + let cw_state_rx = state_rx.clone(); + let cw_decode_tx = decode_tx.clone(); + let cw_sr = rig_cfg.audio.sample_rate; + let cw_ch = rig_cfg.audio.channels; + let cw_shutdown_rx = shutdown_rx.clone(); + let cw_logs = decoder_logs.clone(); + let cw_histories = histories.clone(); + handles.push(tokio::spawn(async move { + tokio::select! { + _ = audio::run_cw_decoder(cw_sr, cw_ch as u16, cw_pcm_rx, cw_state_rx, cw_decode_tx, cw_logs, cw_histories) => {} + _ = wait_for_shutdown(cw_shutdown_rx) => {} + } + })); + } // Spawn FT8 decoder task - let ft8_pcm_rx = pcm_tx.subscribe(); - let ft8_state_rx = state_rx.clone(); - let ft8_decode_tx = decode_tx.clone(); - let ft8_sr = rig_cfg.audio.sample_rate; - let ft8_ch = rig_cfg.audio.channels; - let ft8_shutdown_rx = shutdown_rx.clone(); - let ft8_logs = decoder_logs.clone(); - let ft8_histories = histories.clone(); - handles.push(tokio::spawn(async move { - tokio::select! { - _ = audio::run_ft8_decoder(ft8_sr, ft8_ch as u16, ft8_pcm_rx, ft8_state_rx, ft8_decode_tx, ft8_logs, ft8_histories) => {} - _ = wait_for_shutdown(ft8_shutdown_rx) => {} - } - })); + if rig_cfg.decoders.is_enabled("ft8") { + let ft8_pcm_rx = pcm_tx.subscribe(); + let ft8_state_rx = state_rx.clone(); + let ft8_decode_tx = decode_tx.clone(); + let ft8_sr = rig_cfg.audio.sample_rate; + let ft8_ch = rig_cfg.audio.channels; + let ft8_shutdown_rx = shutdown_rx.clone(); + let ft8_logs = decoder_logs.clone(); + let ft8_histories = histories.clone(); + handles.push(tokio::spawn(async move { + tokio::select! { + _ = audio::run_ft8_decoder(ft8_sr, ft8_ch as u16, ft8_pcm_rx, ft8_state_rx, ft8_decode_tx, ft8_logs, ft8_histories) => {} + _ = wait_for_shutdown(ft8_shutdown_rx) => {} + } + })); + } // Spawn FT4 decoder task - let ft4_pcm_rx = pcm_tx.subscribe(); - let ft4_state_rx = state_rx.clone(); - let ft4_decode_tx = decode_tx.clone(); - let ft4_sr = rig_cfg.audio.sample_rate; - let ft4_ch = rig_cfg.audio.channels; - let ft4_shutdown_rx = shutdown_rx.clone(); - let ft4_histories = histories.clone(); - handles.push(tokio::spawn(async move { - tokio::select! { - _ = audio::run_ft4_decoder(ft4_sr, ft4_ch as u16, ft4_pcm_rx, ft4_state_rx, ft4_decode_tx, ft4_histories) => {} - _ = wait_for_shutdown(ft4_shutdown_rx) => {} - } - })); + if rig_cfg.decoders.is_enabled("ft4") { + let ft4_pcm_rx = pcm_tx.subscribe(); + let ft4_state_rx = state_rx.clone(); + let ft4_decode_tx = decode_tx.clone(); + let ft4_sr = rig_cfg.audio.sample_rate; + let ft4_ch = rig_cfg.audio.channels; + let ft4_shutdown_rx = shutdown_rx.clone(); + let ft4_histories = histories.clone(); + handles.push(tokio::spawn(async move { + tokio::select! { + _ = audio::run_ft4_decoder(ft4_sr, ft4_ch as u16, ft4_pcm_rx, ft4_state_rx, ft4_decode_tx, ft4_histories) => {} + _ = wait_for_shutdown(ft4_shutdown_rx) => {} + } + })); + } // Spawn FT2 decoder task #[cfg(feature = "ft2")] - { + if rig_cfg.decoders.is_enabled("ft2") { let ft2_pcm_rx = pcm_tx.subscribe(); let ft2_state_rx = state_rx.clone(); let ft2_decode_tx = decode_tx.clone(); @@ -793,73 +805,76 @@ fn spawn_rig_audio_stack( } // Spawn WSPR decoder task - let wspr_pcm_rx = pcm_tx.subscribe(); - let wspr_state_rx = state_rx.clone(); - let wspr_decode_tx = decode_tx.clone(); - let wspr_sr = rig_cfg.audio.sample_rate; - let wspr_ch = rig_cfg.audio.channels; - let wspr_shutdown_rx = shutdown_rx.clone(); - let wspr_logs = decoder_logs.clone(); - let wspr_histories = histories.clone(); - handles.push(tokio::spawn(async move { - tokio::select! { - _ = audio::run_wspr_decoder(wspr_sr, wspr_ch as u16, wspr_pcm_rx, wspr_state_rx, wspr_decode_tx, wspr_logs, wspr_histories) => {} - _ = wait_for_shutdown(wspr_shutdown_rx) => {} - } - })); + if rig_cfg.decoders.is_enabled("wspr") { + let wspr_pcm_rx = pcm_tx.subscribe(); + let wspr_state_rx = state_rx.clone(); + let wspr_decode_tx = decode_tx.clone(); + let wspr_sr = rig_cfg.audio.sample_rate; + let wspr_ch = rig_cfg.audio.channels; + let wspr_shutdown_rx = shutdown_rx.clone(); + let wspr_logs = decoder_logs.clone(); + let wspr_histories = histories.clone(); + handles.push(tokio::spawn(async move { + tokio::select! { + _ = audio::run_wspr_decoder(wspr_sr, wspr_ch as u16, wspr_pcm_rx, wspr_state_rx, wspr_decode_tx, wspr_logs, wspr_histories) => {} + _ = wait_for_shutdown(wspr_shutdown_rx) => {} + } + })); + } // Spawn Meteor-M LRPT decoder task - let lrpt_pcm_rx = pcm_tx.subscribe(); - let lrpt_state_rx = state_rx.clone(); - let lrpt_decode_tx = decode_tx.clone(); - let lrpt_sr = rig_cfg.audio.sample_rate; - let lrpt_ch = rig_cfg.audio.channels; - let lrpt_shutdown_rx = shutdown_rx.clone(); - let lrpt_histories = histories.clone(); - let lrpt_output_dir = dirs::cache_dir() - .unwrap_or_else(|| std::path::PathBuf::from(".cache")) - .join("trx-rs") - .join("lrpt"); - handles.push(tokio::spawn(async move { - tokio::select! { - _ = audio::run_lrpt_decoder(lrpt_sr, lrpt_ch as u16, lrpt_pcm_rx, lrpt_state_rx, lrpt_decode_tx, lrpt_histories, lrpt_output_dir) => {} - _ = wait_for_shutdown(lrpt_shutdown_rx) => {} - } - })); + if rig_cfg.decoders.is_enabled("lrpt") { + let lrpt_pcm_rx = pcm_tx.subscribe(); + let lrpt_state_rx = state_rx.clone(); + let lrpt_decode_tx = decode_tx.clone(); + let lrpt_sr = rig_cfg.audio.sample_rate; + let lrpt_ch = rig_cfg.audio.channels; + let lrpt_shutdown_rx = shutdown_rx.clone(); + let lrpt_histories = histories.clone(); + let lrpt_output_dir = rig_cfg.decoders.output_dir_for("lrpt"); + handles.push(tokio::spawn(async move { + tokio::select! { + _ = audio::run_lrpt_decoder(lrpt_sr, lrpt_ch as u16, lrpt_pcm_rx, lrpt_state_rx, lrpt_decode_tx, lrpt_histories, lrpt_output_dir) => {} + _ = wait_for_shutdown(lrpt_shutdown_rx) => {} + } + })); + } // Spawn WEFAX decoder task - let wefax_pcm_rx = pcm_tx.subscribe(); - let wefax_state_rx = state_rx.clone(); - let wefax_decode_tx = decode_tx.clone(); - let wefax_sr = rig_cfg.audio.sample_rate; - let wefax_ch = rig_cfg.audio.channels; - let wefax_shutdown_rx = shutdown_rx.clone(); - let wefax_histories = histories.clone(); - handles.push(tokio::spawn(async move { - tokio::select! { - _ = audio::run_wefax_decoder(wefax_sr, wefax_ch as u16, wefax_pcm_rx, wefax_state_rx, wefax_decode_tx, wefax_histories) => {} - _ = wait_for_shutdown(wefax_shutdown_rx) => {} - } - })); + if rig_cfg.decoders.is_enabled("wefax") { + let wefax_pcm_rx = pcm_tx.subscribe(); + let wefax_state_rx = state_rx.clone(); + let wefax_decode_tx = decode_tx.clone(); + let wefax_sr = rig_cfg.audio.sample_rate; + let wefax_ch = rig_cfg.audio.channels; + let wefax_shutdown_rx = shutdown_rx.clone(); + let wefax_histories = histories.clone(); + let wefax_output_dir = rig_cfg.decoders.output_dir_for("wefax"); + handles.push(tokio::spawn(async move { + tokio::select! { + _ = audio::run_wefax_decoder(wefax_sr, wefax_ch as u16, wefax_pcm_rx, wefax_state_rx, wefax_decode_tx, wefax_histories, wefax_output_dir) => {} + _ = wait_for_shutdown(wefax_shutdown_rx) => {} + } + })); + } // Spawn SSTV decoder task - let sstv_pcm_rx = pcm_tx.subscribe(); - let sstv_state_rx = state_rx.clone(); - let sstv_decode_tx = decode_tx.clone(); - let sstv_sr = rig_cfg.audio.sample_rate; - let sstv_ch = rig_cfg.audio.channels; - let sstv_shutdown_rx = shutdown_rx.clone(); - let sstv_histories = histories.clone(); - let sstv_output_dir = dirs::cache_dir() - .unwrap_or_else(|| std::path::PathBuf::from(".cache")) - .join("trx-rs") - .join("sstv"); - handles.push(tokio::spawn(async move { - tokio::select! { - _ = audio::run_sstv_decoder(sstv_sr, sstv_ch as u16, sstv_pcm_rx, sstv_state_rx, sstv_decode_tx, sstv_histories, sstv_output_dir) => {} - _ = wait_for_shutdown(sstv_shutdown_rx) => {} - } - })); + if rig_cfg.decoders.is_enabled("sstv") { + let sstv_pcm_rx = pcm_tx.subscribe(); + let sstv_state_rx = state_rx.clone(); + let sstv_decode_tx = decode_tx.clone(); + let sstv_sr = rig_cfg.audio.sample_rate; + let sstv_ch = rig_cfg.audio.channels; + let sstv_shutdown_rx = shutdown_rx.clone(); + let sstv_histories = histories.clone(); + let sstv_output_dir = rig_cfg.decoders.output_dir_for("sstv"); + handles.push(tokio::spawn(async move { + tokio::select! { + _ = audio::run_sstv_decoder(sstv_sr, sstv_ch as u16, sstv_pcm_rx, sstv_state_rx, sstv_decode_tx, sstv_histories, sstv_output_dir) => {} + _ = wait_for_shutdown(sstv_shutdown_rx) => {} + } + })); + } } if rig_cfg.audio.tx_enabled {