From 3d9003feb2488cb27bd3fcb6a7e68ba9bb5922a0 Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Thu, 5 Mar 2026 22:15:45 +0100 Subject: [PATCH] [feat](trx-server): add per-rig enable switch in multi-rig config Add [[rigs]].enable (default true) and skip disabled rigs during\nresolution/uniqueness checks.\n\nCo-authored-by: OpenAI Codex Signed-off-by: Stan Grams --- src/trx-server/src/config.rs | 126 ++++++++++++++++++++++++++++++++- src/trx-server/src/rig_task.rs | 5 +- 2 files changed, 128 insertions(+), 3 deletions(-) diff --git a/src/trx-server/src/config.rs b/src/trx-server/src/config.rs index 4421515..fc771f1 100644 --- a/src/trx-server/src/config.rs +++ b/src/trx-server/src/config.rs @@ -27,9 +27,12 @@ use trx_core::rig::state::RigMode; /// `[behavior]` / `[decode_logs]` fields are still supported via /// `ServerConfig::resolved_rigs()` which synthesises a single-element list /// with `id = "default"` when `rigs` is empty. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct RigInstanceConfig { + /// Whether this rig instance should be started. + /// Defaults to true so existing configs remain unchanged. + pub enable: bool, /// Stable rig identifier used in protocol routing. pub id: String, /// Display name for the rig (e.g., "HF Transceiver", "VHF/UHF SDR"). @@ -51,6 +54,23 @@ pub struct RigInstanceConfig { pub decode_logs: DecodeLogsConfig, } +impl Default for RigInstanceConfig { + fn default() -> Self { + Self { + enable: true, + id: String::new(), + name: None, + rig: RigConfig::default(), + behavior: BehaviorConfig::default(), + audio: AudioConfig::default(), + sdr: SdrConfig::default(), + pskreporter: PskReporterConfig::default(), + aprsfi: AprsFiConfig::default(), + decode_logs: DecodeLogsConfig::default(), + } + } +} + impl RigInstanceConfig { /// Get the display name for this rig. /// Returns the configured name if set, otherwise the id. @@ -502,7 +522,12 @@ impl ServerConfig { if !self.rigs.is_empty() { let mut seen_ids: std::collections::HashSet = std::collections::HashSet::new(); let mut seen_ports: std::collections::HashSet = std::collections::HashSet::new(); + let mut enabled_count = 0usize; for rig in &self.rigs { + if !rig.enable { + continue; + } + enabled_count += 1; // Check for explicit duplicate IDs (empty IDs are auto-generated later). if !rig.id.trim().is_empty() && !seen_ids.insert(rig.id.clone()) { return Err(format!("[[rigs]] duplicate rig id: \"{}\"", rig.id)); @@ -528,6 +553,12 @@ impl ServerConfig { } } } + if enabled_count == 0 { + return Err( + "[[rigs]] has no enabled entries; set at least one [[rigs]].enable = true" + .to_string(), + ); + } } if self.decode_logs.enabled { @@ -631,7 +662,7 @@ impl ServerConfig { /// Return the effective list of rig instances to spawn. /// - /// When `[[rigs]]` entries are present they are returned as-is. + /// When `[[rigs]]` entries are present, only enabled entries are returned. /// Otherwise the legacy flat `[rig]` / `[audio]` / … fields are synthesised /// into a single `RigInstanceConfig` with `id = "default"`. pub fn resolved_rigs(&self) -> Vec { @@ -641,6 +672,7 @@ impl ServerConfig { .rigs .iter() .enumerate() + .filter(|(_, rig)| rig.enable) .map(|(idx, rig)| { let id = if rig.id.trim().is_empty() { // Generate ID from model name with counter. @@ -655,6 +687,7 @@ impl ServerConfig { .collect(); } vec![RigInstanceConfig { + enable: true, id: "default".to_string(), name: None, rig: self.rig.clone(), @@ -1205,6 +1238,38 @@ port = 4532 assert_eq!(rigs[1].audio.port, 4532); } + #[test] + fn test_resolved_rigs_skips_disabled_entries() { + let toml_str = r#" +[[rigs]] +id = "disabled" +enable = false +[rigs.rig] +model = "ft817" +[rigs.rig.access] +type = "serial" +port = "/dev/ttyUSB0" +baud = 9600 +[rigs.audio] +port = 4531 + +[[rigs]] +id = "enabled" +[rigs.rig] +model = "ft450d" +[rigs.rig.access] +type = "serial" +port = "/dev/ttyUSB1" +baud = 9600 +[rigs.audio] +port = 4532 +"#; + let cfg: ServerConfig = toml::from_str(toml_str).unwrap(); + let rigs = cfg.resolved_rigs(); + assert_eq!(rigs.len(), 1); + assert_eq!(rigs[0].id, "enabled"); + } + #[test] fn test_validate_rejects_duplicate_rig_ids() { let toml_str = r#" @@ -1273,6 +1338,63 @@ port = 4531 ); } + #[test] + fn test_validate_allows_disabled_duplicate_rig_ids() { + let toml_str = r#" +[[rigs]] +id = "rig1" +[rigs.rig] +model = "ft817" +[rigs.rig.access] +type = "serial" +port = "/dev/ttyUSB0" +baud = 9600 +[rigs.audio] +port = 4531 + +[[rigs]] +id = "rig1" +enable = false +[rigs.rig] +model = "ft450d" +[rigs.rig.access] +type = "serial" +port = "/dev/ttyUSB1" +baud = 9600 +[rigs.audio] +port = 4532 +"#; + let cfg: ServerConfig = toml::from_str(toml_str).unwrap(); + assert!( + cfg.validate().is_ok(), + "expected Ok because disabled rigs are excluded from uniqueness checks" + ); + } + + #[test] + fn test_validate_rejects_when_all_multi_rigs_disabled() { + let toml_str = r#" +[[rigs]] +id = "rig1" +enable = false +[rigs.rig] +model = "ft817" +[rigs.rig.access] +type = "serial" +port = "/dev/ttyUSB0" +baud = 9600 +[rigs.audio] +port = 4531 +"#; + let cfg: ServerConfig = toml::from_str(toml_str).unwrap(); + let result = cfg.validate(); + assert!(result.is_err()); + assert!( + result.unwrap_err().contains("no enabled entries"), + "expected error about enabled rig entries" + ); + } + #[test] fn test_validate_accepts_multi_rig_unique_ids_and_ports() { let toml_str = r#" diff --git a/src/trx-server/src/rig_task.rs b/src/trx-server/src/rig_task.rs index b3d847d..dd6d822 100644 --- a/src/trx-server/src/rig_task.rs +++ b/src/trx-server/src/rig_task.rs @@ -171,7 +171,10 @@ pub async fn run_rig_task( Ok(()) => { state.control.enabled = Some(true); if let Err(e) = refresh_after_power_on(&mut rig, &mut state, retry).await { - warn!("Initial PowerOn refresh failed after retries (continuing): {}", e); + warn!( + "Initial PowerOn refresh failed after retries (continuing): {}", + e + ); } else { initial_status_read = true; }