Initial commit
Sync docs to Wiki / wiki (push) Has been cancelled

Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
sjg
2026-05-17 23:25:14 +02:00
commit ba48de2d30
237 changed files with 105505 additions and 0 deletions
+21
View File
@@ -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"
+556
View File
@@ -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"));
}
}
+52
View File
@@ -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());
}
}
}
+130
View File
@@ -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);
}
}
+370
View File
@@ -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,
}
}
+455
View File
@@ -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(())
}