Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
[package]
|
||||
name = "trx-configurator"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "trx-configurator"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
clap = { workspace = true, features = ["derive"] }
|
||||
dialoguer = "0.11"
|
||||
tokio-serial = { workspace = true }
|
||||
toml_edit = "0.22"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,556 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::path::Path;
|
||||
|
||||
use toml_edit::DocumentMut;
|
||||
|
||||
/// Known top-level keys for a standalone server config.
|
||||
const SERVER_KEYS: &[&str] = &[
|
||||
"general",
|
||||
"rig",
|
||||
"rigs",
|
||||
"behavior",
|
||||
"listen",
|
||||
"audio",
|
||||
"sdr",
|
||||
"pskreporter",
|
||||
"aprsfi",
|
||||
"decode_logs",
|
||||
];
|
||||
|
||||
/// Known top-level keys for a standalone client config.
|
||||
const CLIENT_KEYS: &[&str] = &["general", "remote", "remotes", "frontends"];
|
||||
|
||||
/// Known top-level keys for a combined trx-rs.toml.
|
||||
const COMBINED_KEYS: &[&str] = &["trx-server", "trx-client"];
|
||||
|
||||
/// Known sub-keys within [general] (server).
|
||||
const SERVER_GENERAL_KEYS: &[&str] = &["callsign", "log_level", "latitude", "longitude"];
|
||||
|
||||
/// Known sub-keys within [general] (client).
|
||||
const CLIENT_GENERAL_KEYS: &[&str] = &[
|
||||
"callsign",
|
||||
"log_level",
|
||||
"website_url",
|
||||
"website_name",
|
||||
"ais_vessel_url_base",
|
||||
];
|
||||
|
||||
/// Known sub-keys within [rig].
|
||||
const RIG_KEYS: &[&str] = &["model", "initial_freq_hz", "initial_mode", "access"];
|
||||
|
||||
/// Known sub-keys within [rig.access].
|
||||
const ACCESS_KEYS: &[&str] = &["type", "port", "baud", "host", "tcp_port", "args"];
|
||||
|
||||
/// Known sub-keys within [listen].
|
||||
const LISTEN_KEYS: &[&str] = &["enabled", "listen", "port", "auth"];
|
||||
|
||||
/// Known sub-keys within [audio] (server).
|
||||
const AUDIO_KEYS: &[&str] = &[
|
||||
"enabled",
|
||||
"listen",
|
||||
"port",
|
||||
"rx_enabled",
|
||||
"tx_enabled",
|
||||
"device",
|
||||
"sample_rate",
|
||||
"channels",
|
||||
"frame_duration_ms",
|
||||
"bitrate_bps",
|
||||
];
|
||||
|
||||
/// Known sub-keys within [behavior].
|
||||
const BEHAVIOR_KEYS: &[&str] = &[
|
||||
"poll_interval_ms",
|
||||
"poll_interval_tx_ms",
|
||||
"max_retries",
|
||||
"retry_base_delay_ms",
|
||||
"vfo_prime",
|
||||
];
|
||||
|
||||
/// Known sub-keys within [remote].
|
||||
const REMOTE_KEYS: &[&str] = &["url", "rig_id", "auth", "poll_interval_ms"];
|
||||
|
||||
/// Known sub-keys within [frontends].
|
||||
const FRONTENDS_KEYS: &[&str] = &["http", "rigctl", "http_json", "audio"];
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum DetectedType {
|
||||
Server,
|
||||
Client,
|
||||
Combined,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DetectedType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Server => write!(f, "server"),
|
||||
Self::Client => write!(f, "client"),
|
||||
Self::Combined => write!(f, "combined"),
|
||||
Self::Unknown => write!(f, "unknown"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn check_file(path: &Path) -> Result<String, String> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
|
||||
|
||||
// Step 1: TOML syntax check
|
||||
let doc: DocumentMut = content
|
||||
.parse()
|
||||
.map_err(|e| format!("{}: TOML syntax error: {}", path.display(), e))?;
|
||||
|
||||
let mut report = String::new();
|
||||
let mut warnings: Vec<String> = Vec::new();
|
||||
let mut errors: Vec<String> = Vec::new();
|
||||
|
||||
let table = doc.as_table();
|
||||
|
||||
// Step 2: Detect config type
|
||||
let detected = detect_type(table);
|
||||
writeln!(report, "{}: valid TOML", path.display()).unwrap();
|
||||
writeln!(report, " Detected type: {}", detected).unwrap();
|
||||
|
||||
// Step 3: Structural validation
|
||||
match detected {
|
||||
DetectedType::Server => {
|
||||
check_unknown_keys(table, SERVER_KEYS, "", &mut warnings);
|
||||
check_server_sections(table, "", &mut warnings, &mut errors);
|
||||
}
|
||||
DetectedType::Client => {
|
||||
check_unknown_keys(table, CLIENT_KEYS, "", &mut warnings);
|
||||
check_client_sections(table, "", &mut warnings, &mut errors);
|
||||
}
|
||||
DetectedType::Combined => {
|
||||
check_unknown_keys(table, COMBINED_KEYS, "", &mut warnings);
|
||||
if let Some(server) = table.get("trx-server").and_then(|v| v.as_table()) {
|
||||
check_unknown_keys(server, SERVER_KEYS, "[trx-server].", &mut warnings);
|
||||
check_server_sections(server, "[trx-server].", &mut warnings, &mut errors);
|
||||
}
|
||||
if let Some(client) = table.get("trx-client").and_then(|v| v.as_table()) {
|
||||
check_unknown_keys(client, CLIENT_KEYS, "[trx-client].", &mut warnings);
|
||||
check_client_sections(client, "[trx-client].", &mut warnings, &mut errors);
|
||||
}
|
||||
}
|
||||
DetectedType::Unknown => {
|
||||
warnings.push("Could not detect config type. Expected server, client, or combined (trx-rs.toml) layout.".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Format report
|
||||
for w in &warnings {
|
||||
writeln!(report, " warning: {}", w).unwrap();
|
||||
}
|
||||
for e in &errors {
|
||||
writeln!(report, " error: {}", e).unwrap();
|
||||
}
|
||||
|
||||
if errors.is_empty() && warnings.is_empty() {
|
||||
writeln!(report, " No issues found.").unwrap();
|
||||
} else {
|
||||
writeln!(
|
||||
report,
|
||||
" {} warning(s), {} error(s)",
|
||||
warnings.len(),
|
||||
errors.len()
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(report)
|
||||
} else {
|
||||
Err(report)
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_type(table: &toml_edit::Table) -> DetectedType {
|
||||
if table.contains_key("trx-server") || table.contains_key("trx-client") {
|
||||
return DetectedType::Combined;
|
||||
}
|
||||
let keys: Vec<&str> = table.iter().map(|(k, _)| k).collect();
|
||||
|
||||
let server_score = keys.iter().filter(|k| SERVER_KEYS.contains(k)).count();
|
||||
let client_score = keys.iter().filter(|k| CLIENT_KEYS.contains(k)).count();
|
||||
|
||||
// Use distinguishing keys to break ties
|
||||
if keys.contains(&"rig") || keys.contains(&"rigs") || keys.contains(&"listen") {
|
||||
return DetectedType::Server;
|
||||
}
|
||||
if keys.contains(&"remote") || keys.contains(&"remotes") || keys.contains(&"frontends") {
|
||||
return DetectedType::Client;
|
||||
}
|
||||
|
||||
if server_score > client_score {
|
||||
DetectedType::Server
|
||||
} else if client_score > server_score {
|
||||
DetectedType::Client
|
||||
} else if server_score > 0 {
|
||||
DetectedType::Server
|
||||
} else {
|
||||
DetectedType::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
fn check_unknown_keys(
|
||||
table: &toml_edit::Table,
|
||||
known: &[&str],
|
||||
prefix: &str,
|
||||
warnings: &mut Vec<String>,
|
||||
) {
|
||||
for (key, _) in table.iter() {
|
||||
if !known.contains(&key) {
|
||||
warnings.push(format!("{}unknown key '{}'", prefix, key));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check_server_sections(
|
||||
table: &toml_edit::Table,
|
||||
prefix: &str,
|
||||
warnings: &mut Vec<String>,
|
||||
errors: &mut Vec<String>,
|
||||
) {
|
||||
if let Some(general) = table.get("general").and_then(|v| v.as_table()) {
|
||||
check_unknown_keys(
|
||||
general,
|
||||
SERVER_GENERAL_KEYS,
|
||||
&format!("{}[general].", prefix),
|
||||
warnings,
|
||||
);
|
||||
validate_log_level(general, &format!("{}[general]", prefix), errors);
|
||||
validate_coordinates(general, &format!("{}[general]", prefix), errors);
|
||||
}
|
||||
|
||||
if let Some(rig) = table.get("rig").and_then(|v| v.as_table()) {
|
||||
check_unknown_keys(rig, RIG_KEYS, &format!("{}[rig].", prefix), warnings);
|
||||
if let Some(access) = rig.get("access").and_then(|v| v.as_table()) {
|
||||
check_unknown_keys(
|
||||
access,
|
||||
ACCESS_KEYS,
|
||||
&format!("{}[rig.access].", prefix),
|
||||
warnings,
|
||||
);
|
||||
validate_access(access, &format!("{}[rig.access]", prefix), errors);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(listen) = table.get("listen").and_then(|v| v.as_table()) {
|
||||
check_unknown_keys(
|
||||
listen,
|
||||
LISTEN_KEYS,
|
||||
&format!("{}[listen].", prefix),
|
||||
warnings,
|
||||
);
|
||||
validate_port(listen, "port", &format!("{}[listen]", prefix), errors);
|
||||
}
|
||||
|
||||
if let Some(audio) = table.get("audio").and_then(|v| v.as_table()) {
|
||||
check_unknown_keys(audio, AUDIO_KEYS, &format!("{}[audio].", prefix), warnings);
|
||||
validate_port(audio, "port", &format!("{}[audio]", prefix), errors);
|
||||
}
|
||||
|
||||
if let Some(behavior) = table.get("behavior").and_then(|v| v.as_table()) {
|
||||
check_unknown_keys(
|
||||
behavior,
|
||||
BEHAVIOR_KEYS,
|
||||
&format!("{}[behavior].", prefix),
|
||||
warnings,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn check_client_sections(
|
||||
table: &toml_edit::Table,
|
||||
prefix: &str,
|
||||
warnings: &mut Vec<String>,
|
||||
errors: &mut Vec<String>,
|
||||
) {
|
||||
if let Some(general) = table.get("general").and_then(|v| v.as_table()) {
|
||||
check_unknown_keys(
|
||||
general,
|
||||
CLIENT_GENERAL_KEYS,
|
||||
&format!("{}[general].", prefix),
|
||||
warnings,
|
||||
);
|
||||
validate_log_level(general, &format!("{}[general]", prefix), errors);
|
||||
}
|
||||
|
||||
if let Some(remote) = table.get("remote").and_then(|v| v.as_table()) {
|
||||
check_unknown_keys(
|
||||
remote,
|
||||
REMOTE_KEYS,
|
||||
&format!("{}[remote].", prefix),
|
||||
warnings,
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(frontends) = table.get("frontends").and_then(|v| v.as_table()) {
|
||||
check_unknown_keys(
|
||||
frontends,
|
||||
FRONTENDS_KEYS,
|
||||
&format!("{}[frontends].", prefix),
|
||||
warnings,
|
||||
);
|
||||
if let Some(http) = frontends.get("http").and_then(|v| v.as_table()) {
|
||||
validate_port(http, "port", &format!("{}[frontends.http]", prefix), errors);
|
||||
}
|
||||
if let Some(rigctl) = frontends.get("rigctl").and_then(|v| v.as_table()) {
|
||||
validate_port(
|
||||
rigctl,
|
||||
"port",
|
||||
&format!("{}[frontends.rigctl]", prefix),
|
||||
errors,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Value validators ────────────────────────────────────────────────────
|
||||
|
||||
fn validate_log_level(table: &toml_edit::Table, context: &str, errors: &mut Vec<String>) {
|
||||
if let Some(level) = table.get("log_level").and_then(|v| v.as_str()) {
|
||||
if !["trace", "debug", "info", "warn", "error"].contains(&level) {
|
||||
errors.push(format!(
|
||||
"{}.log_level '{}' is invalid (expected: trace, debug, info, warn, error)",
|
||||
context, level
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_coordinates(table: &toml_edit::Table, context: &str, errors: &mut Vec<String>) {
|
||||
if let Some(lat) = table
|
||||
.get("latitude")
|
||||
.and_then(|v| v.as_float().or_else(|| v.as_integer().map(|i| i as f64)))
|
||||
{
|
||||
if !(-90.0..=90.0).contains(&lat) {
|
||||
errors.push(format!(
|
||||
"{}.latitude {} is out of range (-90..90)",
|
||||
context, lat
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(lon) = table
|
||||
.get("longitude")
|
||||
.and_then(|v| v.as_float().or_else(|| v.as_integer().map(|i| i as f64)))
|
||||
{
|
||||
if !(-180.0..=180.0).contains(&lon) {
|
||||
errors.push(format!(
|
||||
"{}.longitude {} is out of range (-180..180)",
|
||||
context, lon
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let has_lat = table.contains_key("latitude");
|
||||
let has_lon = table.contains_key("longitude");
|
||||
if has_lat != has_lon {
|
||||
errors.push(format!(
|
||||
"{}: latitude and longitude must be set together or both omitted",
|
||||
context
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_port(table: &toml_edit::Table, key: &str, context: &str, errors: &mut Vec<String>) {
|
||||
if let Some(port) = table.get(key).and_then(|v| v.as_integer()) {
|
||||
if let Some(enabled) = table.get("enabled").and_then(|v| v.as_bool()) {
|
||||
if enabled && port <= 0 {
|
||||
errors.push(format!("{}.{} must be > 0 when enabled", context, key));
|
||||
}
|
||||
}
|
||||
if !(0..=65535).contains(&port) {
|
||||
errors.push(format!(
|
||||
"{}.{} {} is out of range (0..65535)",
|
||||
context, key, port
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_access(table: &toml_edit::Table, context: &str, errors: &mut Vec<String>) {
|
||||
if let Some(access_type) = table.get("type").and_then(|v| v.as_str()) {
|
||||
if !["serial", "tcp", "sdr"].contains(&access_type) {
|
||||
errors.push(format!(
|
||||
"{}.type '{}' is invalid (expected: serial, tcp, sdr)",
|
||||
context, access_type
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
fn check_toml(content: &str) -> Result<String, String> {
|
||||
let mut f = NamedTempFile::new().unwrap();
|
||||
f.write_all(content.as_bytes()).unwrap();
|
||||
check_file(f.path())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_server_config() {
|
||||
let result = check_toml(
|
||||
r#"
|
||||
[general]
|
||||
callsign = "W1AW"
|
||||
log_level = "info"
|
||||
|
||||
[rig]
|
||||
model = "ft817"
|
||||
|
||||
[rig.access]
|
||||
type = "serial"
|
||||
port = "/dev/ttyUSB0"
|
||||
baud = 9600
|
||||
|
||||
[listen]
|
||||
enabled = true
|
||||
port = 4530
|
||||
"#,
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
let report = result.unwrap();
|
||||
assert!(report.contains("Detected type: server"));
|
||||
assert!(report.contains("No issues found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_client_config() {
|
||||
let result = check_toml(
|
||||
r#"
|
||||
[general]
|
||||
callsign = "W1AW"
|
||||
|
||||
[remote]
|
||||
url = "localhost:4530"
|
||||
|
||||
[frontends.http]
|
||||
enabled = true
|
||||
port = 8080
|
||||
"#,
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
let report = result.unwrap();
|
||||
assert!(report.contains("Detected type: client"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_combined_config() {
|
||||
let result = check_toml(
|
||||
r#"
|
||||
[trx-server.general]
|
||||
callsign = "W1AW"
|
||||
|
||||
[trx-client.general]
|
||||
callsign = "W1AW"
|
||||
|
||||
[trx-client.remote]
|
||||
url = "localhost:4530"
|
||||
"#,
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
let report = result.unwrap();
|
||||
assert!(report.contains("Detected type: combined"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_toml_syntax() {
|
||||
let result = check_toml("this is not [valid toml");
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("TOML syntax error"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_key_warning() {
|
||||
let result = check_toml(
|
||||
r#"
|
||||
[general]
|
||||
callsign = "W1AW"
|
||||
|
||||
[rig]
|
||||
model = "ft817"
|
||||
|
||||
[bogus_section]
|
||||
foo = "bar"
|
||||
"#,
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
let report = result.unwrap();
|
||||
assert!(report.contains("unknown key 'bogus_section'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_log_level() {
|
||||
let result = check_toml(
|
||||
r#"
|
||||
[general]
|
||||
log_level = "verbose"
|
||||
|
||||
[rig]
|
||||
model = "ft817"
|
||||
"#,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
let report = result.unwrap_err();
|
||||
assert!(report.contains("log_level 'verbose' is invalid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_latitude_without_longitude() {
|
||||
let result = check_toml(
|
||||
r#"
|
||||
[general]
|
||||
latitude = 45.0
|
||||
|
||||
[rig]
|
||||
model = "ft817"
|
||||
"#,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
let report = result.unwrap_err();
|
||||
assert!(report.contains("latitude and longitude must be set together"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_latitude_out_of_range() {
|
||||
let result = check_toml(
|
||||
r#"
|
||||
[general]
|
||||
latitude = 95.0
|
||||
longitude = 10.0
|
||||
|
||||
[rig]
|
||||
model = "ft817"
|
||||
"#,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
let report = result.unwrap_err();
|
||||
assert!(report.contains("latitude 95 is out of range"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_access_type() {
|
||||
let result = check_toml(
|
||||
r#"
|
||||
[rig]
|
||||
model = "ft817"
|
||||
|
||||
[rig.access]
|
||||
type = "usb"
|
||||
"#,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
let report = result.unwrap_err();
|
||||
assert!(report.contains("type 'usb' is invalid"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
/// Detect available serial ports on the system.
|
||||
/// Returns a list of (path, description) pairs.
|
||||
pub fn detect_serial_ports() -> Vec<(String, String)> {
|
||||
match tokio_serial::available_ports() {
|
||||
Ok(ports) => ports
|
||||
.into_iter()
|
||||
.map(|p| {
|
||||
let desc = match &p.port_type {
|
||||
tokio_serial::SerialPortType::UsbPort(usb) => {
|
||||
let mut parts = Vec::new();
|
||||
if let Some(m) = &usb.manufacturer {
|
||||
parts.push(m.clone());
|
||||
}
|
||||
if let Some(prod) = &usb.product {
|
||||
parts.push(prod.clone());
|
||||
}
|
||||
if parts.is_empty() {
|
||||
format!("USB (VID:{:04X} PID:{:04X})", usb.vid, usb.pid)
|
||||
} else {
|
||||
parts.join(" ")
|
||||
}
|
||||
}
|
||||
tokio_serial::SerialPortType::BluetoothPort => "Bluetooth".to_string(),
|
||||
tokio_serial::SerialPortType::PciPort => "PCI".to_string(),
|
||||
tokio_serial::SerialPortType::Unknown => "Unknown".to_string(),
|
||||
};
|
||||
(p.port_name, desc)
|
||||
})
|
||||
.collect(),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn detect_returns_vec() {
|
||||
// Smoke test: detection must not panic. The port count depends on
|
||||
// hardware (often empty on CI), so we only assert the call succeeds
|
||||
// and the result is iterable.
|
||||
let ports = detect_serial_ports();
|
||||
for (path, _desc) in &ports {
|
||||
assert!(!path.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
mod check;
|
||||
mod detect;
|
||||
mod prompts;
|
||||
mod writer;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "trx-configurator",
|
||||
about = "Interactive configuration generator for trx-rs"
|
||||
)]
|
||||
struct Cli {
|
||||
/// Generate a default config without interactive prompts
|
||||
#[arg(long)]
|
||||
defaults: bool,
|
||||
|
||||
/// Config type to generate (server, client, combined)
|
||||
#[arg(long, value_name = "TYPE")]
|
||||
r#type: Option<String>,
|
||||
|
||||
/// Output file path (default: based on config type)
|
||||
#[arg(short, long)]
|
||||
output: Option<PathBuf>,
|
||||
|
||||
/// Check an existing config file for syntax and structure errors
|
||||
#[arg(long, value_name = "FILE")]
|
||||
check: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ConfigType {
|
||||
Server,
|
||||
Client,
|
||||
Combined,
|
||||
}
|
||||
|
||||
impl ConfigType {
|
||||
fn default_filename(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Server => "trx-server.toml",
|
||||
Self::Client => "trx-client.toml",
|
||||
Self::Combined => "trx-rs.toml",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let cli = Cli::parse();
|
||||
|
||||
if let Some(path) = &cli.check {
|
||||
match check::check_file(path) {
|
||||
Ok(report) => {
|
||||
println!("{}", report);
|
||||
std::process::exit(0);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("{}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let config_type = if let Some(t) = &cli.r#type {
|
||||
match t.as_str() {
|
||||
"server" => ConfigType::Server,
|
||||
"client" => ConfigType::Client,
|
||||
"combined" => ConfigType::Combined,
|
||||
other => {
|
||||
eprintln!(
|
||||
"Unknown config type '{}'. Use: server, client, combined",
|
||||
other
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
} else if cli.defaults {
|
||||
eprintln!("--defaults requires --type (server, client, combined)");
|
||||
std::process::exit(1);
|
||||
} else {
|
||||
prompts::prompt_config_type()
|
||||
};
|
||||
|
||||
let output = cli
|
||||
.output
|
||||
.unwrap_or_else(|| PathBuf::from(config_type.default_filename()));
|
||||
|
||||
let doc = if cli.defaults {
|
||||
writer::build_default(config_type)
|
||||
} else {
|
||||
match config_type {
|
||||
ConfigType::Server => {
|
||||
let general = prompts::prompt_server_general();
|
||||
let rig = prompts::prompt_rig();
|
||||
let listen = prompts::prompt_listen();
|
||||
writer::build_server(general, rig, listen)
|
||||
}
|
||||
ConfigType::Client => {
|
||||
let general = prompts::prompt_client_general();
|
||||
let remote = prompts::prompt_remote();
|
||||
let frontends = prompts::prompt_frontends();
|
||||
writer::build_client(general, remote, frontends)
|
||||
}
|
||||
ConfigType::Combined => {
|
||||
println!("\n--- Server configuration ---\n");
|
||||
let s_general = prompts::prompt_server_general();
|
||||
let rig = prompts::prompt_rig();
|
||||
let listen = prompts::prompt_listen();
|
||||
|
||||
println!("\n--- Client configuration ---\n");
|
||||
let c_general = prompts::prompt_client_general();
|
||||
let remote = prompts::prompt_remote();
|
||||
let frontends = prompts::prompt_frontends();
|
||||
|
||||
writer::build_combined(s_general, rig, listen, c_general, remote, frontends)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = writer::write_file(&doc, &output) {
|
||||
eprintln!("Error: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
use dialoguer::{Confirm, Input, Select};
|
||||
|
||||
use crate::detect;
|
||||
use crate::ConfigType;
|
||||
|
||||
// ── Data types returned by prompts ──────────────────────────────────────
|
||||
|
||||
pub struct ServerGeneral {
|
||||
pub callsign: String,
|
||||
pub log_level: String,
|
||||
pub latitude: Option<f64>,
|
||||
pub longitude: Option<f64>,
|
||||
}
|
||||
|
||||
pub struct RigSetup {
|
||||
pub model: String,
|
||||
pub access_type: String,
|
||||
pub serial_port: Option<String>,
|
||||
pub serial_baud: Option<u32>,
|
||||
pub tcp_host: Option<String>,
|
||||
pub tcp_port: Option<u16>,
|
||||
pub sdr_args: Option<String>,
|
||||
}
|
||||
|
||||
pub struct ListenSetup {
|
||||
pub listen: String,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
pub struct ClientGeneral {
|
||||
pub callsign: String,
|
||||
pub log_level: String,
|
||||
}
|
||||
|
||||
pub struct RemoteSetup {
|
||||
pub url: String,
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
pub struct FrontendsSetup {
|
||||
pub http_enabled: bool,
|
||||
pub http_port: u16,
|
||||
pub rigctl_enabled: bool,
|
||||
pub rigctl_port: u16,
|
||||
}
|
||||
|
||||
// ── Prompt functions ────────────────────────────────────────────────────
|
||||
|
||||
pub fn prompt_config_type() -> ConfigType {
|
||||
let items = &[
|
||||
"Server (trx-server.toml)",
|
||||
"Client (trx-client.toml)",
|
||||
"Combined (trx-rs.toml)",
|
||||
];
|
||||
let sel = Select::new()
|
||||
.with_prompt("What configuration would you like to generate?")
|
||||
.items(items)
|
||||
.default(2)
|
||||
.interact()
|
||||
.unwrap();
|
||||
match sel {
|
||||
0 => ConfigType::Server,
|
||||
1 => ConfigType::Client,
|
||||
_ => ConfigType::Combined,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prompt_server_general() -> ServerGeneral {
|
||||
let callsign: String = Input::new()
|
||||
.with_prompt("Callsign")
|
||||
.default("N0CALL".to_string())
|
||||
.interact_text()
|
||||
.unwrap();
|
||||
|
||||
let log_levels = &["trace", "debug", "info", "warn", "error"];
|
||||
let level_sel = Select::new()
|
||||
.with_prompt("Log level")
|
||||
.items(log_levels)
|
||||
.default(2)
|
||||
.interact()
|
||||
.unwrap();
|
||||
let log_level = log_levels[level_sel].to_string();
|
||||
|
||||
let has_location = Confirm::new()
|
||||
.with_prompt("Set station location (latitude/longitude)?")
|
||||
.default(false)
|
||||
.interact()
|
||||
.unwrap();
|
||||
|
||||
let (latitude, longitude) = if has_location {
|
||||
let lat: f64 = Input::new()
|
||||
.with_prompt("Latitude (decimal degrees, -90..90)")
|
||||
.validate_with(|input: &f64| {
|
||||
if *input >= -90.0 && *input <= 90.0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Must be between -90 and 90")
|
||||
}
|
||||
})
|
||||
.interact_text()
|
||||
.unwrap();
|
||||
let lon: f64 = Input::new()
|
||||
.with_prompt("Longitude (decimal degrees, -180..180)")
|
||||
.validate_with(|input: &f64| {
|
||||
if *input >= -180.0 && *input <= 180.0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Must be between -180 and 180")
|
||||
}
|
||||
})
|
||||
.interact_text()
|
||||
.unwrap();
|
||||
(Some(lat), Some(lon))
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
ServerGeneral {
|
||||
callsign,
|
||||
log_level,
|
||||
latitude,
|
||||
longitude,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prompt_rig() -> RigSetup {
|
||||
let models = &["ft817", "ft450d", "soapysdr"];
|
||||
let model_sel = Select::new()
|
||||
.with_prompt("Rig model")
|
||||
.items(models)
|
||||
.default(0)
|
||||
.interact()
|
||||
.unwrap();
|
||||
let model = models[model_sel].to_string();
|
||||
|
||||
let (access_type, serial_port, serial_baud, tcp_host, tcp_port, sdr_args) = match model.as_str()
|
||||
{
|
||||
"soapysdr" => {
|
||||
let args: String = Input::new()
|
||||
.with_prompt("SoapySDR device args (e.g. driver=rtlsdr)")
|
||||
.default("driver=rtlsdr".to_string())
|
||||
.interact_text()
|
||||
.unwrap();
|
||||
("sdr".to_string(), None, None, None, None, Some(args))
|
||||
}
|
||||
_ => {
|
||||
let access_types = &["serial", "tcp"];
|
||||
let access_sel = Select::new()
|
||||
.with_prompt("Access type")
|
||||
.items(access_types)
|
||||
.default(0)
|
||||
.interact()
|
||||
.unwrap();
|
||||
let access_type = access_types[access_sel].to_string();
|
||||
|
||||
match access_type.as_str() {
|
||||
"serial" => {
|
||||
let port = prompt_serial_port();
|
||||
let default_baud: u32 = match model.as_str() {
|
||||
"ft450d" => 38400,
|
||||
_ => 9600,
|
||||
};
|
||||
let baud: u32 = Input::new()
|
||||
.with_prompt("Baud rate")
|
||||
.default(default_baud)
|
||||
.validate_with(|input: &u32| {
|
||||
if [4800, 9600, 19200, 38400, 57600, 115200].contains(input) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Must be one of: 4800, 9600, 19200, 38400, 57600, 115200")
|
||||
}
|
||||
})
|
||||
.interact_text()
|
||||
.unwrap();
|
||||
(access_type, Some(port), Some(baud), None, None, None)
|
||||
}
|
||||
"tcp" => {
|
||||
let host: String = Input::new()
|
||||
.with_prompt("TCP host")
|
||||
.default("127.0.0.1".to_string())
|
||||
.interact_text()
|
||||
.unwrap();
|
||||
let port: u16 = Input::new()
|
||||
.with_prompt("TCP port")
|
||||
.default(4530u16)
|
||||
.validate_with(|input: &u16| {
|
||||
if *input > 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Port must be > 0")
|
||||
}
|
||||
})
|
||||
.interact_text()
|
||||
.unwrap();
|
||||
(access_type, None, None, Some(host), Some(port), None)
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
RigSetup {
|
||||
model,
|
||||
access_type,
|
||||
serial_port,
|
||||
serial_baud,
|
||||
tcp_host,
|
||||
tcp_port,
|
||||
sdr_args,
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_serial_port() -> String {
|
||||
let ports = detect::detect_serial_ports();
|
||||
if ports.is_empty() {
|
||||
Input::new()
|
||||
.with_prompt("Serial port path")
|
||||
.default("/dev/ttyUSB0".to_string())
|
||||
.interact_text()
|
||||
.unwrap()
|
||||
} else {
|
||||
let items: Vec<String> = ports
|
||||
.iter()
|
||||
.map(|(path, desc)| {
|
||||
if desc.is_empty() {
|
||||
path.clone()
|
||||
} else {
|
||||
format!("{} ({})", path, desc)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let sel = Select::new()
|
||||
.with_prompt("Select serial port")
|
||||
.items(&items)
|
||||
.default(0)
|
||||
.interact()
|
||||
.unwrap();
|
||||
ports[sel].0.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prompt_listen() -> ListenSetup {
|
||||
let listen: String = Input::new()
|
||||
.with_prompt("Listen address")
|
||||
.default("127.0.0.1".to_string())
|
||||
.interact_text()
|
||||
.unwrap();
|
||||
|
||||
let port: u16 = Input::new()
|
||||
.with_prompt("Listen port")
|
||||
.default(4530u16)
|
||||
.validate_with(|input: &u16| {
|
||||
if *input > 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Port must be > 0")
|
||||
}
|
||||
})
|
||||
.interact_text()
|
||||
.unwrap();
|
||||
|
||||
ListenSetup { listen, port }
|
||||
}
|
||||
|
||||
pub fn prompt_client_general() -> ClientGeneral {
|
||||
let callsign: String = Input::new()
|
||||
.with_prompt("Callsign")
|
||||
.default("N0CALL".to_string())
|
||||
.interact_text()
|
||||
.unwrap();
|
||||
|
||||
let log_levels = &["trace", "debug", "info", "warn", "error"];
|
||||
let level_sel = Select::new()
|
||||
.with_prompt("Log level")
|
||||
.items(log_levels)
|
||||
.default(2)
|
||||
.interact()
|
||||
.unwrap();
|
||||
let log_level = log_levels[level_sel].to_string();
|
||||
|
||||
ClientGeneral {
|
||||
callsign,
|
||||
log_level,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prompt_remote() -> RemoteSetup {
|
||||
let url: String = Input::new()
|
||||
.with_prompt("Server URL (host:port)")
|
||||
.default("localhost:4530".to_string())
|
||||
.interact_text()
|
||||
.unwrap();
|
||||
|
||||
let has_token = Confirm::new()
|
||||
.with_prompt("Set auth token?")
|
||||
.default(false)
|
||||
.interact()
|
||||
.unwrap();
|
||||
|
||||
let token = if has_token {
|
||||
let t: String = Input::new()
|
||||
.with_prompt("Auth token")
|
||||
.interact_text()
|
||||
.unwrap();
|
||||
Some(t)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
RemoteSetup { url, token }
|
||||
}
|
||||
|
||||
pub fn prompt_frontends() -> FrontendsSetup {
|
||||
let http_enabled = Confirm::new()
|
||||
.with_prompt("Enable HTTP web frontend?")
|
||||
.default(true)
|
||||
.interact()
|
||||
.unwrap();
|
||||
|
||||
let http_port: u16 = if http_enabled {
|
||||
Input::new()
|
||||
.with_prompt("HTTP port")
|
||||
.default(8080u16)
|
||||
.validate_with(|input: &u16| {
|
||||
if *input > 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Port must be > 0")
|
||||
}
|
||||
})
|
||||
.interact_text()
|
||||
.unwrap()
|
||||
} else {
|
||||
8080
|
||||
};
|
||||
|
||||
let rigctl_enabled = Confirm::new()
|
||||
.with_prompt("Enable rigctl frontend (Hamlib-compatible)?")
|
||||
.default(false)
|
||||
.interact()
|
||||
.unwrap();
|
||||
|
||||
let rigctl_port: u16 = if rigctl_enabled {
|
||||
Input::new()
|
||||
.with_prompt("rigctl port")
|
||||
.default(4532u16)
|
||||
.validate_with(|input: &u16| {
|
||||
if *input > 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Port must be > 0")
|
||||
}
|
||||
})
|
||||
.interact_text()
|
||||
.unwrap()
|
||||
} else {
|
||||
4532
|
||||
};
|
||||
|
||||
FrontendsSetup {
|
||||
http_enabled,
|
||||
http_port,
|
||||
rigctl_enabled,
|
||||
rigctl_port,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
// 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");
|
||||
let tables = build_server_tables(general, rig, listen);
|
||||
for (key, item) in tables.iter() {
|
||||
doc.insert(key, item.clone());
|
||||
}
|
||||
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");
|
||||
let tables = build_client_tables(general, remote, frontends);
|
||||
for (key, item) in tables.iter() {
|
||||
doc.insert(key, item.clone());
|
||||
}
|
||||
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(())
|
||||
}
|
||||
Reference in New Issue
Block a user