[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 <codex@openai.com>

Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-03-05 22:15:45 +01:00
parent 3f2892e245
commit 3d9003feb2
2 changed files with 128 additions and 3 deletions
+124 -2
View File
@@ -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<String> = std::collections::HashSet::new();
let mut seen_ports: std::collections::HashSet<u16> = 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<RigInstanceConfig> {
@@ -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#"
+4 -1
View File
@@ -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;
}