Every config struct is #[serde(default)], so a misspelled key was dropped in
silence and the setting kept its default. Writing `prot = 9999` under
[listen] started the server on 4530 without a word.
Collect the ignored key paths with serde_ignored and pair each with the
closest known key at the same level:
WARN unknown config key 'listen.prot' (did you mean 'listen.port'?)
Warnings by default, so a config written for a newer version still runs on an
older binary; --strict-config makes them fatal for CI. Logging now starts
before validation so these warnings are actually visible.
trx-configurator --check drops its hand-maintained key lists and re-implemented
range checks in favour of the real loader and validators, so it no longer
passes configs the binaries reject.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
516 lines
16 KiB
Rust
516 lines
16 KiB
Rust
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
//
|
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
|
|
use std::path::Path;
|
|
|
|
use toml_edit::{value, DocumentMut, Item, Table};
|
|
|
|
use crate::prompts::{
|
|
ClientGeneral, FrontendsSetup, ListenSetup, RemoteSetup, RigSetup, ServerGeneral,
|
|
};
|
|
use crate::ConfigType;
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
|
|
fn commented(table: &mut Table, key: &str, val: Item, comment: &str) {
|
|
table.insert(key, val);
|
|
if let Some(mut kv) = table.key_mut(key) {
|
|
kv.leaf_decor_mut().set_prefix(format!("# {}\n", comment));
|
|
}
|
|
}
|
|
|
|
fn header_comment(table: &mut Table, comment: &str) {
|
|
table.decor_mut().set_prefix(format!("\n# {}\n", comment));
|
|
}
|
|
|
|
// ── Server document builder ─────────────────────────────────────────────
|
|
|
|
fn build_server_tables(general: ServerGeneral, rig: RigSetup, listen: ListenSetup) -> Table {
|
|
let mut root = Table::new();
|
|
|
|
// [general]
|
|
{
|
|
let mut t = Table::new();
|
|
header_comment(&mut t, "General settings");
|
|
commented(
|
|
&mut t,
|
|
"callsign",
|
|
value(&general.callsign),
|
|
"Station callsign",
|
|
);
|
|
commented(
|
|
&mut t,
|
|
"log_level",
|
|
value(&general.log_level),
|
|
"Log level (trace, debug, info, warn, error)",
|
|
);
|
|
if let Some(lat) = general.latitude {
|
|
commented(
|
|
&mut t,
|
|
"latitude",
|
|
value(lat),
|
|
"Station latitude (decimal degrees, WGS84)",
|
|
);
|
|
}
|
|
if let Some(lon) = general.longitude {
|
|
commented(
|
|
&mut t,
|
|
"longitude",
|
|
value(lon),
|
|
"Station longitude (decimal degrees, WGS84)",
|
|
);
|
|
}
|
|
root.insert("general", Item::Table(t));
|
|
}
|
|
|
|
// [rig]
|
|
{
|
|
let mut t = Table::new();
|
|
header_comment(&mut t, "Rig backend configuration");
|
|
commented(
|
|
&mut t,
|
|
"model",
|
|
value(&rig.model),
|
|
"Rig model (ft817, ft450d, soapysdr)",
|
|
);
|
|
commented(
|
|
&mut t,
|
|
"initial_freq_hz",
|
|
value(144_300_000i64),
|
|
"Initial frequency in Hz",
|
|
);
|
|
commented(&mut t, "initial_mode", value("USB"), "Initial mode");
|
|
|
|
// [rig.access]
|
|
let mut access = Table::new();
|
|
header_comment(&mut access, "Rig access method");
|
|
commented(
|
|
&mut access,
|
|
"type",
|
|
value(&rig.access_type),
|
|
"Access type: serial, tcp, or sdr",
|
|
);
|
|
match rig.access_type.as_str() {
|
|
"serial" => {
|
|
if let Some(port) = &rig.serial_port {
|
|
commented(
|
|
&mut access,
|
|
"port",
|
|
value(port.as_str()),
|
|
"Serial port path",
|
|
);
|
|
}
|
|
if let Some(baud) = rig.serial_baud {
|
|
commented(&mut access, "baud", value(baud as i64), "Baud rate");
|
|
}
|
|
}
|
|
"tcp" => {
|
|
if let Some(host) = &rig.tcp_host {
|
|
commented(&mut access, "host", value(host.as_str()), "Remote host");
|
|
}
|
|
if let Some(port) = rig.tcp_port {
|
|
commented(&mut access, "tcp_port", value(port as i64), "Remote port");
|
|
}
|
|
}
|
|
"sdr" => {
|
|
if let Some(args) = &rig.sdr_args {
|
|
commented(
|
|
&mut access,
|
|
"args",
|
|
value(args.as_str()),
|
|
"SoapySDR device args string",
|
|
);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
t.insert("access", Item::Table(access));
|
|
root.insert("rig", Item::Table(t));
|
|
}
|
|
|
|
// [behavior]
|
|
{
|
|
let mut t = Table::new();
|
|
header_comment(&mut t, "Polling and retry behavior");
|
|
commented(
|
|
&mut t,
|
|
"poll_interval_ms",
|
|
value(500i64),
|
|
"Rig polling interval (ms)",
|
|
);
|
|
commented(
|
|
&mut t,
|
|
"poll_interval_tx_ms",
|
|
value(100i64),
|
|
"Polling interval during TX (ms)",
|
|
);
|
|
commented(&mut t, "max_retries", value(3i64), "Maximum retry attempts");
|
|
commented(
|
|
&mut t,
|
|
"retry_base_delay_ms",
|
|
value(100i64),
|
|
"Base retry delay (ms)",
|
|
);
|
|
root.insert("behavior", Item::Table(t));
|
|
}
|
|
|
|
// [listen]
|
|
{
|
|
let mut t = Table::new();
|
|
header_comment(&mut t, "JSON TCP listener for client connections");
|
|
commented(&mut t, "enabled", value(true), "Enable the TCP listener");
|
|
commented(
|
|
&mut t,
|
|
"listen",
|
|
value(&listen.listen),
|
|
"IP address to listen on",
|
|
);
|
|
commented(
|
|
&mut t,
|
|
"port",
|
|
value(listen.port as i64),
|
|
"TCP port for client connections",
|
|
);
|
|
root.insert("listen", Item::Table(t));
|
|
}
|
|
|
|
// [audio]
|
|
{
|
|
let mut t = Table::new();
|
|
header_comment(&mut t, "Audio streaming");
|
|
commented(&mut t, "enabled", value(true), "Enable audio streaming");
|
|
commented(&mut t, "listen", value("127.0.0.1"), "Audio listen address");
|
|
commented(&mut t, "port", value(4531i64), "Audio TCP port");
|
|
commented(&mut t, "sample_rate", value(48000i64), "Sample rate in Hz");
|
|
commented(
|
|
&mut t,
|
|
"channels",
|
|
value(2i64),
|
|
"Channel count (1 = mono, 2 = stereo)",
|
|
);
|
|
commented(
|
|
&mut t,
|
|
"frame_duration_ms",
|
|
value(20i64),
|
|
"Opus frame duration (ms)",
|
|
);
|
|
commented(
|
|
&mut t,
|
|
"bitrate_bps",
|
|
value(256000i64),
|
|
"Opus bitrate (bps)",
|
|
);
|
|
root.insert("audio", Item::Table(t));
|
|
}
|
|
|
|
root
|
|
}
|
|
|
|
pub fn build_server(general: ServerGeneral, rig: RigSetup, listen: ListenSetup) -> DocumentMut {
|
|
let mut doc = DocumentMut::new();
|
|
doc.decor_mut()
|
|
.set_prefix("# trx-server configuration\n# Generated by trx-configurator\n");
|
|
// Emit the sectioned shape (`[trx-server]`) that trx-server writes with
|
|
// --print-config, so a generated file can be dropped into a combined
|
|
// trx-rs.toml unchanged.
|
|
doc.insert(
|
|
"trx-server",
|
|
Item::Table(build_server_tables(general, rig, listen)),
|
|
);
|
|
doc
|
|
}
|
|
|
|
// ── Client document builder ─────────────────────────────────────────────
|
|
|
|
fn build_client_tables(
|
|
general: ClientGeneral,
|
|
remote: RemoteSetup,
|
|
frontends: FrontendsSetup,
|
|
) -> Table {
|
|
let mut root = Table::new();
|
|
|
|
// [general]
|
|
{
|
|
let mut t = Table::new();
|
|
header_comment(&mut t, "General settings");
|
|
commented(
|
|
&mut t,
|
|
"callsign",
|
|
value(&general.callsign),
|
|
"Station callsign",
|
|
);
|
|
commented(
|
|
&mut t,
|
|
"log_level",
|
|
value(&general.log_level),
|
|
"Log level (trace, debug, info, warn, error)",
|
|
);
|
|
root.insert("general", Item::Table(t));
|
|
}
|
|
|
|
// [remote]
|
|
{
|
|
let mut t = Table::new();
|
|
header_comment(&mut t, "Remote server connection");
|
|
commented(
|
|
&mut t,
|
|
"url",
|
|
value(&remote.url),
|
|
"Server address (host:port)",
|
|
);
|
|
commented(
|
|
&mut t,
|
|
"poll_interval_ms",
|
|
value(750i64),
|
|
"State poll interval (ms)",
|
|
);
|
|
|
|
let mut auth = Table::new();
|
|
if let Some(token) = &remote.token {
|
|
commented(&mut auth, "token", value(token.as_str()), "Auth token");
|
|
}
|
|
if !auth.is_empty() {
|
|
t.insert("auth", Item::Table(auth));
|
|
}
|
|
root.insert("remote", Item::Table(t));
|
|
}
|
|
|
|
// [frontends.http]
|
|
{
|
|
let mut frontends_table = Table::new();
|
|
header_comment(&mut frontends_table, "Frontend configuration");
|
|
|
|
let mut http = Table::new();
|
|
commented(
|
|
&mut http,
|
|
"enabled",
|
|
value(frontends.http_enabled),
|
|
"Enable HTTP web frontend",
|
|
);
|
|
commented(&mut http, "listen", value("127.0.0.1"), "Listen address");
|
|
commented(
|
|
&mut http,
|
|
"port",
|
|
value(frontends.http_port as i64),
|
|
"HTTP port",
|
|
);
|
|
frontends_table.insert("http", Item::Table(http));
|
|
|
|
let mut rigctl = Table::new();
|
|
commented(
|
|
&mut rigctl,
|
|
"enabled",
|
|
value(frontends.rigctl_enabled),
|
|
"Enable Hamlib rigctl frontend",
|
|
);
|
|
commented(&mut rigctl, "listen", value("127.0.0.1"), "Listen address");
|
|
commented(
|
|
&mut rigctl,
|
|
"port",
|
|
value(frontends.rigctl_port as i64),
|
|
"rigctl port",
|
|
);
|
|
frontends_table.insert("rigctl", Item::Table(rigctl));
|
|
|
|
let mut http_json = Table::new();
|
|
commented(
|
|
&mut http_json,
|
|
"enabled",
|
|
value(true),
|
|
"Enable JSON-over-TCP frontend",
|
|
);
|
|
commented(
|
|
&mut http_json,
|
|
"listen",
|
|
value("127.0.0.1"),
|
|
"Listen address",
|
|
);
|
|
commented(&mut http_json, "port", value(0i64), "Port (0 = ephemeral)");
|
|
frontends_table.insert("http_json", Item::Table(http_json));
|
|
|
|
let mut audio = Table::new();
|
|
commented(&mut audio, "enabled", value(true), "Enable audio client");
|
|
commented(
|
|
&mut audio,
|
|
"server_port",
|
|
value(4531i64),
|
|
"Server audio port",
|
|
);
|
|
frontends_table.insert("audio", Item::Table(audio));
|
|
|
|
root.insert("frontends", Item::Table(frontends_table));
|
|
}
|
|
|
|
root
|
|
}
|
|
|
|
pub fn build_client(
|
|
general: ClientGeneral,
|
|
remote: RemoteSetup,
|
|
frontends: FrontendsSetup,
|
|
) -> DocumentMut {
|
|
let mut doc = DocumentMut::new();
|
|
doc.decor_mut()
|
|
.set_prefix("# trx-client configuration\n# Generated by trx-configurator\n");
|
|
doc.insert(
|
|
"trx-client",
|
|
Item::Table(build_client_tables(general, remote, frontends)),
|
|
);
|
|
doc
|
|
}
|
|
|
|
// ── Combined document builder ───────────────────────────────────────────
|
|
|
|
pub fn build_combined(
|
|
s_general: ServerGeneral,
|
|
rig: RigSetup,
|
|
listen: ListenSetup,
|
|
c_general: ClientGeneral,
|
|
remote: RemoteSetup,
|
|
frontends: FrontendsSetup,
|
|
) -> DocumentMut {
|
|
let mut doc = DocumentMut::new();
|
|
doc.decor_mut()
|
|
.set_prefix("# trx-rs combined configuration\n# Generated by trx-configurator\n");
|
|
|
|
let server = build_server_tables(s_general, rig, listen);
|
|
let mut server_item = Item::Table(server);
|
|
if let Some(t) = server_item.as_table_mut() {
|
|
header_comment(t, "Server configuration");
|
|
}
|
|
doc.insert("trx-server", server_item);
|
|
|
|
let client = build_client_tables(c_general, remote, frontends);
|
|
let mut client_item = Item::Table(client);
|
|
if let Some(t) = client_item.as_table_mut() {
|
|
header_comment(t, "Client configuration");
|
|
}
|
|
doc.insert("trx-client", client_item);
|
|
|
|
doc
|
|
}
|
|
|
|
// ── Default builder ─────────────────────────────────────────────────────
|
|
|
|
pub fn build_default(config_type: ConfigType) -> DocumentMut {
|
|
let s_general = ServerGeneral {
|
|
callsign: "N0CALL".to_string(),
|
|
log_level: "info".to_string(),
|
|
latitude: None,
|
|
longitude: None,
|
|
};
|
|
let rig = RigSetup {
|
|
model: "ft817".to_string(),
|
|
access_type: "serial".to_string(),
|
|
serial_port: Some("/dev/ttyUSB0".to_string()),
|
|
serial_baud: Some(9600),
|
|
tcp_host: None,
|
|
tcp_port: None,
|
|
sdr_args: None,
|
|
};
|
|
let listen = ListenSetup {
|
|
listen: "127.0.0.1".to_string(),
|
|
port: 4530,
|
|
};
|
|
let c_general = ClientGeneral {
|
|
callsign: "N0CALL".to_string(),
|
|
log_level: "info".to_string(),
|
|
};
|
|
let remote = RemoteSetup {
|
|
url: "localhost:4530".to_string(),
|
|
token: None,
|
|
};
|
|
let frontends = FrontendsSetup {
|
|
http_enabled: true,
|
|
http_port: 8080,
|
|
rigctl_enabled: false,
|
|
rigctl_port: 4532,
|
|
};
|
|
|
|
match config_type {
|
|
ConfigType::Server => build_server(s_general, rig, listen),
|
|
ConfigType::Client => build_client(c_general, remote, frontends),
|
|
ConfigType::Combined => {
|
|
build_combined(s_general, rig, listen, c_general, remote, frontends)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── File writer ─────────────────────────────────────────────────────────
|
|
|
|
pub fn write_file(doc: &DocumentMut, path: &Path) -> Result<(), String> {
|
|
if path.exists() {
|
|
let overwrite = dialoguer::Confirm::new()
|
|
.with_prompt(format!("{} already exists. Overwrite?", path.display()))
|
|
.default(false)
|
|
.interact()
|
|
.map_err(|e| e.to_string())?;
|
|
if !overwrite {
|
|
return Err("Aborted.".to_string());
|
|
}
|
|
}
|
|
|
|
std::fs::write(path, doc.to_string())
|
|
.map_err(|e| format!("Failed to write {}: {}", path.display(), e))?;
|
|
println!("Wrote {}", path.display());
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use trx_config::{ClientConfig, ServerConfig};
|
|
|
|
fn write_temp(doc: &DocumentMut) -> tempfile::NamedTempFile {
|
|
let file = tempfile::Builder::new().suffix(".toml").tempfile().unwrap();
|
|
std::fs::write(file.path(), doc.to_string()).unwrap();
|
|
file
|
|
}
|
|
|
|
/// The wizard used to emit root-level `[general]` / `[rig]` tables while the
|
|
/// loader demanded a `[trx-server]` section, so every generated standalone
|
|
/// config was rejected by the binary it was generated for.
|
|
#[test]
|
|
fn test_generated_server_config_loads_and_validates() {
|
|
let file = write_temp(&build_default(ConfigType::Server));
|
|
let cfg = ServerConfig::load_from_file(file.path())
|
|
.expect("generated config must load")
|
|
.config;
|
|
cfg.validate().expect("generated config must validate");
|
|
assert_eq!(cfg.rig.model.as_deref(), Some("ft817"));
|
|
assert_eq!(cfg.listen.port, 4530);
|
|
}
|
|
|
|
#[test]
|
|
fn test_generated_client_config_loads_and_validates() {
|
|
let file = write_temp(&build_default(ConfigType::Client));
|
|
let cfg = ClientConfig::load_from_file(file.path())
|
|
.expect("generated config must load")
|
|
.config;
|
|
cfg.validate().expect("generated config must validate");
|
|
assert_eq!(cfg.remote.url.as_deref(), Some("localhost:4530"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_generated_combined_config_loads_both_sections() {
|
|
let file = write_temp(&build_default(ConfigType::Combined));
|
|
let server = ServerConfig::load_from_file(file.path())
|
|
.expect("server section must load")
|
|
.config;
|
|
server.validate().expect("server section must validate");
|
|
let client = ClientConfig::load_from_file(file.path())
|
|
.expect("client section must load")
|
|
.config;
|
|
client.validate().expect("client section must validate");
|
|
}
|
|
|
|
#[test]
|
|
fn test_generated_docs_are_sectioned() {
|
|
let doc = build_default(ConfigType::Server);
|
|
assert!(doc.as_table().contains_key("trx-server"));
|
|
let doc = build_default(ConfigType::Client);
|
|
assert!(doc.as_table().contains_key("trx-client"));
|
|
}
|
|
}
|