[feat](trx-configurator): add interactive configuration generator

New binary crate that generates trx-server.toml, trx-client.toml, or
trx-rs.toml via interactive prompts or --defaults mode. Produces
commented TOML using toml_edit with per-field descriptions.

Supports server config (general, rig, listen, audio, behavior) and
client config (general, remote, frontends). Hardware detection is
stubbed for future iteration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-03-25 21:48:45 +01:00
parent ac751bd82d
commit 81486fa147
7 changed files with 934 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: BSD-2-Clause
[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"
toml_edit = "0.22"
+10
View File
@@ -0,0 +1,10 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: BSD-2-Clause
/// Detect available serial ports on the system.
/// Returns a list of (path, description) pairs.
pub fn detect_serial_ports() -> Vec<(String, String)> {
// TODO: use serialport::available_ports() for real detection
Vec::new()
}
+106
View File
@@ -0,0 +1,106 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: BSD-2-Clause
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>,
}
#[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();
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);
}
}
+366
View File
@@ -0,0 +1,366 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: BSD-2-Clause
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,
}
}
+318
View File
@@ -0,0 +1,318 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: BSD-2-Clause
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(())
}