From 81486fa14763c85ba46c6df8502a072e4428297b Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Wed, 25 Mar 2026 21:48:45 +0100 Subject: [PATCH] [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) Signed-off-by: Stan Grams --- Cargo.lock | 116 +++++++++ Cargo.toml | 1 + src/trx-configurator/Cargo.toml | 17 ++ src/trx-configurator/src/detect.rs | 10 + src/trx-configurator/src/main.rs | 106 ++++++++ src/trx-configurator/src/prompts.rs | 366 ++++++++++++++++++++++++++++ src/trx-configurator/src/writer.rs | 318 ++++++++++++++++++++++++ 7 files changed, 934 insertions(+) create mode 100644 src/trx-configurator/Cargo.toml create mode 100644 src/trx-configurator/src/detect.rs create mode 100644 src/trx-configurator/src/main.rs create mode 100644 src/trx-configurator/src/prompts.rs create mode 100644 src/trx-configurator/src/writer.rs diff --git a/Cargo.lock b/Cargo.lock index fc851b4..9893005 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -576,6 +576,19 @@ dependencies = [ "memchr", ] +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -729,6 +742,19 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "dialoguer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +dependencies = [ + "console", + "shell-words", + "tempfile", + "thiserror 1.0.69", + "zeroize", +] + [[package]] name = "digest" version = "0.10.7" @@ -777,6 +803,12 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -792,6 +824,22 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "find-msvc-tools" version = "0.1.5" @@ -1312,6 +1360,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "litemap" version = "0.8.1" @@ -1902,6 +1956,19 @@ dependencies = [ "transpose", ] +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -2037,6 +2104,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" @@ -2152,6 +2225,19 @@ dependencies = [ "syn", ] +[[package]] +name = "tempfile" +version = "3.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -2553,6 +2639,15 @@ dependencies = [ "uuid", ] +[[package]] +name = "trx-configurator" +version = "0.1.0" +dependencies = [ + "clap", + "dialoguer", + "toml_edit 0.22.27", +] + [[package]] name = "trx-core" version = "0.1.0" @@ -2745,6 +2840,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -3073,6 +3174,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" @@ -3450,6 +3560,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.3" diff --git a/Cargo.toml b/Cargo.toml index bbbc940..1d08fec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ members = [ "src/trx-client/trx-frontend/trx-frontend-http", "src/trx-client/trx-frontend/trx-frontend-http-json", "src/trx-client/trx-frontend/trx-frontend-rigctl", + "src/trx-configurator", ] resolver = "2" diff --git a/src/trx-configurator/Cargo.toml b/src/trx-configurator/Cargo.toml new file mode 100644 index 0000000..1024d8e --- /dev/null +++ b/src/trx-configurator/Cargo.toml @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: 2026 Stan Grams +# +# 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" diff --git a/src/trx-configurator/src/detect.rs b/src/trx-configurator/src/detect.rs new file mode 100644 index 0000000..b114acc --- /dev/null +++ b/src/trx-configurator/src/detect.rs @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// 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() +} diff --git a/src/trx-configurator/src/main.rs b/src/trx-configurator/src/main.rs new file mode 100644 index 0000000..36369f8 --- /dev/null +++ b/src/trx-configurator/src/main.rs @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// 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, + + /// Output file path (default: based on config type) + #[arg(short, long)] + output: Option, +} + +#[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); + } +} diff --git a/src/trx-configurator/src/prompts.rs b/src/trx-configurator/src/prompts.rs new file mode 100644 index 0000000..297db72 --- /dev/null +++ b/src/trx-configurator/src/prompts.rs @@ -0,0 +1,366 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// 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, + pub longitude: Option, +} + +pub struct RigSetup { + pub model: String, + pub access_type: String, + pub serial_port: Option, + pub serial_baud: Option, + pub tcp_host: Option, + pub tcp_port: Option, + pub sdr_args: Option, +} + +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, +} + +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 = 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, + } +} diff --git a/src/trx-configurator/src/writer.rs b/src/trx-configurator/src/writer.rs new file mode 100644 index 0000000..0b25e87 --- /dev/null +++ b/src/trx-configurator/src/writer.rs @@ -0,0 +1,318 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// 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(()) +}