// SPDX-FileCopyrightText: 2025 Stanislaw Grams // // SPDX-License-Identifier: BSD-2-Clause use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; use std::time::Duration; use clap::{Parser, ValueEnum}; use tokio::signal; use tokio::sync::{mpsc, watch}; use tracing::info; mod config; mod error; mod plugins; mod remote_client; mod rig_task; use trx_backend::{ is_backend_registered, register_builtin_backends, registered_backends, RigAccess, }; use trx_core::radio::freq::Freq; use trx_core::rig::controller::{AdaptivePolling, ExponentialBackoff}; use trx_core::rig::request::RigRequest; use trx_core::rig::state::RigState; use trx_core::rig::{RigControl, RigRxStatus, RigStatus, RigTxStatus}; use trx_core::DynResult; use trx_frontend::{is_frontend_registered, registered_frontends}; use trx_frontend_http::register_frontend as register_http_frontend; use trx_frontend_http_json::{register_frontend as register_http_json_frontend, set_auth_tokens}; use trx_frontend_rigctl::register_frontend as register_rigctl_frontend; #[cfg(feature = "qt-frontend")] use trx_frontend_qt::register_frontend as register_qt_frontend; const PKG_DESCRIPTION: &str = concat!(env!("CARGO_PKG_NAME"), " - ", env!("CARGO_PKG_DESCRIPTION")); const PKG_LONG_ABOUT: &str = concat!( env!("CARGO_PKG_DESCRIPTION"), "\nHomepage: ", env!("CARGO_PKG_HOMEPAGE") ); const RIG_TASK_CHANNEL_BUFFER: usize = 32; const QT_FRONTEND_LISTEN_ADDR: ([u8; 4], u16) = ([127, 0, 0, 1], 0); const RETRY_MAX_DELAY_SECS: u64 = 2; #[derive(Debug, Parser)] #[command( author = env!("CARGO_PKG_AUTHORS"), version = env!("CARGO_PKG_VERSION"), about = PKG_DESCRIPTION, long_about = PKG_LONG_ABOUT )] struct Cli { /// Path to configuration file (default: search trx-rs.toml, ~/.config/trx-rs/config.toml, /etc/trx-rs/config.toml) #[arg(long = "config", short = 'C', value_name = "FILE")] config: Option, /// Print example configuration and exit #[arg(long = "print-config")] print_config: bool, /// Rig backend to use (e.g. ft817) #[arg(short = 'r', long = "rig")] rig: Option, /// Access method to reach the rig CAT interface #[arg(short = 'a', long = "access", value_enum)] access: Option, /// Frontend(s) to expose for control/status (e.g. http,rigctl) #[arg(short = 'f', long = "frontend", value_delimiter = ',', num_args = 1..)] frontends: Option>, /// HTTP frontend listen address #[arg(long = "http-listen")] http_listen: Option, /// HTTP frontend listen port #[arg(long = "http-port")] http_port: Option, /// rigctl frontend listen address #[arg(long = "rigctl-listen")] rigctl_listen: Option, /// rigctl frontend listen port #[arg(long = "rigctl-port")] rigctl_port: Option, /// JSON TCP frontend listen address #[arg(long = "http-json-listen")] http_json_listen: Option, /// JSON TCP frontend listen port #[arg(long = "http-json-port")] http_json_port: Option, /// Rig CAT address: /// when access is serial: ; /// when access is TCP: : #[arg(value_name = "RIG_ADDR")] rig_addr: Option, /// Optional callsign/owner label to show in the frontend #[arg(short = 'c', long = "callsign")] callsign: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] enum AccessKind { Serial, Tcp, } fn normalize_name(name: &str) -> String { name.to_ascii_lowercase() .chars() .filter(|c| c.is_ascii_alphanumeric()) .collect() } /// Parse a serial rig address of the form " ". fn parse_serial_addr(addr: &str) -> DynResult<(String, u32)> { let mut parts = addr.split_whitespace(); let path = parts .next() .ok_or("Serial rig address must be ' '")?; let baud_str = parts .next() .ok_or("Serial rig address must be ' '")?; if parts.next().is_some() { return Err("Serial rig address must be ' ' (got extra data)".into()); } let baud: u32 = baud_str .parse() .map_err(|e| format!("Invalid baud '{}': {}", baud_str, e))?; Ok((path.to_string(), baud)) } /// Resolved configuration after merging config file and CLI arguments. struct ResolvedConfig { rig: String, access: RigAccess, frontends: Vec, http_listen: IpAddr, http_port: u16, rigctl_listen: IpAddr, rigctl_port: u16, http_json_listen: IpAddr, http_json_port: u16, callsign: Option, } impl ResolvedConfig { /// Build resolved config from CLI args and config file. fn from_cli_and_config( cli: &Cli, cfg: &config::Config, qt_remote_enabled: bool, ) -> DynResult { // Resolve rig model: CLI > config > error let rig_str = cli.rig.clone().or_else(|| cfg.rig.model.clone()); let rig = match rig_str.as_deref() { Some(name) => normalize_name(name), None if qt_remote_enabled => "remote".to_string(), None => { return Err( "Rig model not specified. Use --rig or set [rig].model in config.".into(), ) } }; if !qt_remote_enabled && !is_backend_registered(&rig) { return Err(format!( "Unknown rig model: {} (available: {})", rig, registered_backends().join(", ") ) .into()); } let access = if qt_remote_enabled { RigAccess::Tcp { addr: "remote".to_string(), } } else { // Resolve access method: CLI > config > default to serial let access_type = cli .access .as_ref() .map(|a| match a { AccessKind::Serial => "serial", AccessKind::Tcp => "tcp", }) .or(cfg.rig.access.access_type.as_deref()); match access_type { Some("serial") | None => { // Try CLI rig_addr first, then config let (path, baud) = if let Some(ref addr) = cli.rig_addr { parse_serial_addr(addr)? } else if let (Some(port), Some(baud)) = (&cfg.rig.access.port, cfg.rig.access.baud) { (port.clone(), baud) } else { return Err("Serial access requires port and baud. Use ' ' argument or set [rig.access].port and .baud in config.".into()); }; RigAccess::Serial { path, baud } } Some("tcp") => { let addr = if let Some(ref addr) = cli.rig_addr { addr.clone() } else if let (Some(host), Some(port)) = (&cfg.rig.access.host, cfg.rig.access.tcp_port) { format!("{}:{}", host, port) } else { return Err("TCP access requires host:port. Use argument or set [rig.access].host and .tcp_port in config.".into()); }; RigAccess::Tcp { addr } } Some(other) => return Err(format!("Unknown access type: {}", other).into()), } }; // Resolve frontends: CLI > config > default let frontends = if let Some(ref fes) = cli.frontends { fes.iter().map(|f| normalize_name(f)).collect() } else { let mut fes = Vec::new(); if cfg.frontends.http.enabled { fes.push("http".to_string()); } if cfg.frontends.rigctl.enabled { fes.push("rigctl".to_string()); } if cfg.frontends.http_json.enabled { fes.push("httpjson".to_string()); } if cfg.frontends.qt.enabled { fes.push("qt".to_string()); } if fes.is_empty() { fes.push("http".to_string()); // Default } fes }; for name in &frontends { if !is_frontend_registered(name) { return Err(format!( "Unknown frontend: {} (available: {})", name, registered_frontends().join(", ") ) .into()); } } // Resolve HTTP settings: CLI > config let http_listen = cli.http_listen.unwrap_or(cfg.frontends.http.listen); let http_port = cli.http_port.unwrap_or(cfg.frontends.http.port); // Resolve rigctl settings: CLI > config let rigctl_listen = cli.rigctl_listen.unwrap_or(cfg.frontends.rigctl.listen); let rigctl_port = cli.rigctl_port.unwrap_or(cfg.frontends.rigctl.port); // Resolve JSON TCP settings: CLI > config let http_json_listen = cli .http_json_listen .unwrap_or(cfg.frontends.http_json.listen); let http_json_port = cli.http_json_port.unwrap_or(cfg.frontends.http_json.port); // Resolve callsign: CLI > config let callsign = cli .callsign .clone() .or_else(|| cfg.general.callsign.clone()); Ok(Self { rig, access, frontends, http_listen, http_port, rigctl_listen, rigctl_port, http_json_listen, http_json_port, callsign, }) } } #[tokio::main] async fn main() -> DynResult<()> { init_tracing(); register_builtin_backends(); let _plugin_libs = plugins::load_plugins(); register_http_frontend(); register_http_json_frontend(); #[cfg(feature = "qt-frontend")] register_qt_frontend(); register_rigctl_frontend(); let cli = Cli::parse(); // Handle --print-config if cli.print_config { println!("{}", config::Config::example_toml()); return Ok(()); } // Load configuration file let (cfg, config_path) = if let Some(ref path) = cli.config { let cfg = config::Config::load_from_file(path)?; (cfg, Some(path.clone())) } else { config::Config::load_from_default_paths()? }; if let Some(ref path) = config_path { info!("Loaded configuration from {}", path.display()); } set_auth_tokens(cfg.frontends.http_json.auth.tokens.clone()); let qt_remote_enabled = cfg.frontends.qt.enabled && cfg.frontends.qt.remote.enabled; if qt_remote_enabled && cfg .frontends .qt .remote .url .as_deref() .unwrap_or("") .is_empty() { return Err("Qt remote mode enabled but frontends.qt.remote.url is missing".into()); } // Merge CLI and config let resolved = ResolvedConfig::from_cli_and_config(&cli, &cfg, qt_remote_enabled)?; // Log startup info if qt_remote_enabled { info!("Starting trxd in Qt remote client mode"); } else { match &resolved.access { RigAccess::Serial { path, baud } => { info!( "Starting trxd (rig: {}, access: serial {} @ {} baud)", resolved.rig, path, baud ); } RigAccess::Tcp { addr } => { info!( "Starting trxd (rig: {}, access: tcp {})", resolved.rig, addr ); } } } // Channel used to communicate with the rig task. let (tx, rx) = mpsc::channel::(RIG_TASK_CHANNEL_BUFFER); let initial_state = RigState { rig_info: None, status: RigStatus { freq: Freq { hz: cfg.rig.initial_freq_hz, }, mode: cfg.rig.initial_mode.clone(), tx_en: false, vfo: None, tx: Some(RigTxStatus { power: None, limit: None, swr: None, alc: None, }), rx: Some(RigRxStatus { sig: None }), lock: Some(false), }, initialized: false, control: RigControl { rpt_offset_hz: None, ctcss_hz: None, dcs_code: None, lock: Some(false), clar_hz: None, clar_on: None, enabled: Some(false), }, }; let (state_tx, state_rx) = watch::channel(initial_state.clone()); if qt_remote_enabled { let remote_addr = remote_client::parse_remote_url( cfg.frontends.qt.remote.url.as_deref().unwrap_or_default(), ) .map_err(|e| format!("Invalid Qt remote URL: {}", e))?; let remote_cfg = remote_client::RemoteClientConfig { addr: remote_addr, token: cfg.frontends.qt.remote.auth.token.clone(), poll_interval: Duration::from_millis(750), }; let _remote_handle = tokio::spawn(remote_client::run_remote_client(remote_cfg, rx, state_tx)); } else { // Spawn the rig task (controller-based implementation). let rig_task_config = rig_task::RigTaskConfig { rig_model: resolved.rig, access: resolved.access, polling: AdaptivePolling::new( Duration::from_millis(cfg.behavior.poll_interval_ms), Duration::from_millis(cfg.behavior.poll_interval_tx_ms), ), retry: ExponentialBackoff::new( cfg.behavior.max_retries.max(1), Duration::from_millis(cfg.behavior.retry_base_delay_ms), Duration::from_secs(RETRY_MAX_DELAY_SECS), ), initial_freq_hz: cfg.rig.initial_freq_hz, initial_mode: cfg.rig.initial_mode.clone(), }; let _rig_handle = tokio::spawn(rig_task::run_rig_task(rig_task_config, rx, state_tx)); } // Start frontends. for frontend in &resolved.frontends { let frontend_state_rx = state_rx.clone(); let addr = match frontend.as_str() { "http" => SocketAddr::from((resolved.http_listen, resolved.http_port)), "rigctl" => SocketAddr::from((resolved.rigctl_listen, resolved.rigctl_port)), "httpjson" => SocketAddr::from((resolved.http_json_listen, resolved.http_json_port)), "qt" => SocketAddr::from(QT_FRONTEND_LISTEN_ADDR), other => { return Err(format!("Frontend missing listen configuration: {}", other).into()); } }; trx_frontend::spawn_frontend( frontend, frontend_state_rx, tx.clone(), resolved.callsign.clone(), addr, )?; } signal::ctrl_c().await?; info!("Ctrl+C received, shutting down"); Ok(()) } /// Initialize logging/tracing. fn init_tracing() { // Uses default formatting and RUST_LOG if available. tracing_subscriber::fmt().with_target(false).init(); }