diff --git a/Cargo.lock b/Cargo.lock index 60e47ee..edf6004 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1873,7 +1873,7 @@ dependencies = [ ] [[package]] -name = "trx-bin" +name = "trx-client" version = "0.1.0" dependencies = [ "clap", @@ -1882,11 +1882,9 @@ dependencies = [ "serde", "serde_json", "tokio", - "tokio-serial", "toml", "tracing", "tracing-subscriber", - "trx-backend", "trx-core", "trx-frontend", "trx-frontend-http", @@ -1961,6 +1959,23 @@ dependencies = [ "trx-frontend", ] +[[package]] +name = "trx-server" +version = "0.1.0" +dependencies = [ + "clap", + "dirs", + "libloading", + "serde", + "tokio", + "tokio-serial", + "toml", + "tracing", + "tracing-subscriber", + "trx-backend", + "trx-core", +] + [[package]] name = "typenum" version = "1.19.0" diff --git a/Cargo.toml b/Cargo.toml index 0040c37..d79eb35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,8 @@ [workspace] members = [ - "src/trx-bin", + "src/trx-server", + "src/trx-client", "src/trx-backend", "src/trx-backend/src/trx-backend-ft817", "src/trx-core", diff --git a/src/trx-bin/src/main.rs b/src/trx-bin/src/main.rs deleted file mode 100644 index eb89539..0000000 --- a/src/trx-bin/src/main.rs +++ /dev/null @@ -1,448 +0,0 @@ -// 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(); -} diff --git a/src/trx-bin/Cargo.toml b/src/trx-client/Cargo.toml similarity index 90% rename from src/trx-bin/Cargo.toml rename to src/trx-client/Cargo.toml index 1deaa33..b3207ec 100644 --- a/src/trx-bin/Cargo.toml +++ b/src/trx-client/Cargo.toml @@ -3,13 +3,12 @@ # SPDX-License-Identifier: BSD-2-Clause [package] -name = "trx-bin" +name = "trx-client" version = "0.1.0" edition = "2021" [dependencies] tokio = { workspace = true, features = ["full"] } -tokio-serial = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } toml = { workspace = true } @@ -18,13 +17,12 @@ tracing-subscriber = { workspace = true } clap = { workspace = true, features = ["derive"] } dirs = "6" libloading = "0.8" -trx-backend = { path = "../trx-backend" } trx-core = { path = "../trx-core" } trx-frontend = { path = "../trx-frontend" } trx-frontend-http = { path = "../trx-frontend/src/trx-frontend-http" } trx-frontend-http-json = { path = "../trx-frontend/src/trx-frontend-http-json" } -trx-frontend-qt = { path = "../trx-frontend/src/trx-frontend-qt", optional = true } trx-frontend-rigctl = { path = "../trx-frontend/src/trx-frontend-rigctl" } +trx-frontend-qt = { path = "../trx-frontend/src/trx-frontend-qt", optional = true } [features] default = [] diff --git a/src/trx-bin/src/config.rs b/src/trx-client/src/config.rs similarity index 59% rename from src/trx-bin/src/config.rs rename to src/trx-client/src/config.rs index 7022a24..1f90d67 100644 --- a/src/trx-bin/src/config.rs +++ b/src/trx-client/src/config.rs @@ -2,35 +2,29 @@ // // SPDX-License-Identifier: BSD-2-Clause -//! Configuration file support for trx-bin. +//! Configuration file support for trx-client. //! //! Supports loading configuration from TOML files with the following search order: //! 1. Path specified via `--config` CLI argument -//! 2. `./trx-rs.toml` (current directory) -//! 3. `~/.config/trx-rs/config.toml` (XDG config) -//! 4. `/etc/trx-rs/config.toml` (system-wide) -//! -//! CLI arguments override config file values. +//! 2. `./trx-client.toml` (current directory) +//! 3. `~/.config/trx-rs/client.toml` (XDG config) +//! 4. `/etc/trx-rs/client.toml` (system-wide) use std::net::IpAddr; use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; -use trx_core::rig::state::RigMode; - -/// Top-level configuration structure. +/// Top-level client configuration structure. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default)] -pub struct Config { +pub struct ClientConfig { /// General settings pub general: GeneralConfig, - /// Rig backend configuration - pub rig: RigConfig, + /// Remote connection settings + pub remote: RemoteConfig, /// Frontend configurations pub frontends: FrontendsConfig, - /// Polling and retry behavior - pub behavior: BehaviorConfig, } /// General application settings. @@ -43,49 +37,37 @@ pub struct GeneralConfig { pub log_level: Option, } -/// Rig backend configuration. +/// Remote connection configuration. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] -pub struct RigConfig { - /// Rig model (e.g., "ft817", "ic7300") - pub model: Option, - /// Initial frequency (Hz) for the rig state before first CAT read - pub initial_freq_hz: u64, - /// Initial mode for the rig state before first CAT read - pub initial_mode: RigMode, - /// Access method configuration - pub access: AccessConfig, +pub struct RemoteConfig { + /// Remote URL (host:port or tcp://host:port). + pub url: Option, + /// Remote auth settings. + pub auth: RemoteAuthConfig, + /// Poll interval in milliseconds. + pub poll_interval_ms: u64, } -/// Access method configuration for reaching the rig. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(default)] -pub struct AccessConfig { - /// Access type: "serial" or "tcp" - #[serde(rename = "type")] - pub access_type: Option, - /// Serial port path (for serial access) - pub port: Option, - /// Baud rate (for serial access) - pub baud: Option, - /// Host address (for TCP access) - pub host: Option, - /// TCP port (for TCP access) - pub tcp_port: Option, -} - -impl Default for RigConfig { +impl Default for RemoteConfig { fn default() -> Self { Self { - model: None, - initial_freq_hz: 144_300_000, - initial_mode: RigMode::USB, - access: AccessConfig::default(), + url: None, + auth: RemoteAuthConfig::default(), + poll_interval_ms: 750, } } } -/// Frontend configurations. +/// Authentication settings for remote connection. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct RemoteAuthConfig { + /// Bearer token to send with JSON commands. + pub token: Option, +} + +/// Frontend configurations (client — includes Qt). #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default)] pub struct FrontendsConfig { @@ -133,6 +115,16 @@ pub struct RigctlFrontendConfig { pub port: u16, } +impl Default for RigctlFrontendConfig { + fn default() -> Self { + Self { + enabled: false, + listen: IpAddr::from([127, 0, 0, 1]), + port: 4532, + } + } +} + /// JSON TCP frontend configuration. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] @@ -147,44 +139,6 @@ pub struct HttpJsonFrontendConfig { pub auth: HttpJsonAuthConfig, } -/// Qt/QML frontend configuration. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(default)] -pub struct QtFrontendConfig { - /// Whether Qt frontend is enabled - pub enabled: bool, - /// Remote connection settings - pub remote: QtRemoteConfig, -} - -/// Authorization settings for JSON TCP frontend. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(default)] -pub struct HttpJsonAuthConfig { - /// Accepted bearer tokens. - pub tokens: Vec, -} - -/// Remote connection settings for Qt frontend. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(default)] -pub struct QtRemoteConfig { - /// Enable remote mode (no local rig task). - pub enabled: bool, - /// Remote URL (host:port or tcp://host:port). - pub url: Option, - /// Remote auth settings. - pub auth: QtRemoteAuthConfig, -} - -/// Authentication settings for Qt remote mode. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(default)] -pub struct QtRemoteAuthConfig { - /// Bearer token to send with JSON commands. - pub token: Option, -} - impl Default for HttpJsonFrontendConfig { fn default() -> Self { Self { @@ -196,42 +150,23 @@ impl Default for HttpJsonFrontendConfig { } } -impl Default for RigctlFrontendConfig { - fn default() -> Self { - Self { - enabled: false, - listen: IpAddr::from([127, 0, 0, 1]), - port: 4532, - } - } -} - -/// Behavior configuration for polling and retries. -#[derive(Debug, Clone, Serialize, Deserialize)] +/// Authorization settings for JSON TCP frontend. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default)] -pub struct BehaviorConfig { - /// Polling interval in milliseconds when idle - pub poll_interval_ms: u64, - /// Polling interval in milliseconds when transmitting - pub poll_interval_tx_ms: u64, - /// Maximum retry attempts for transient errors - pub max_retries: u32, - /// Base delay for exponential backoff in milliseconds - pub retry_base_delay_ms: u64, +pub struct HttpJsonAuthConfig { + /// Accepted bearer tokens. + pub tokens: Vec, } -impl Default for BehaviorConfig { - fn default() -> Self { - Self { - poll_interval_ms: 500, - poll_interval_tx_ms: 100, - max_retries: 3, - retry_base_delay_ms: 100, - } - } +/// Qt/QML frontend configuration. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct QtFrontendConfig { + /// Whether Qt frontend is enabled + pub enabled: bool, } -impl Config { +impl ClientConfig { /// Load configuration from a specific file path. pub fn load_from_file(path: &Path) -> Result { let contents = std::fs::read_to_string(path) @@ -261,37 +196,32 @@ impl Config { let mut paths = Vec::new(); // Current directory - paths.push(PathBuf::from("trx-rs.toml")); + paths.push(PathBuf::from("trx-client.toml")); // XDG config directory if let Some(config_dir) = dirs::config_dir() { - paths.push(config_dir.join("trx-rs").join("config.toml")); + paths.push(config_dir.join("trx-rs").join("client.toml")); } // System-wide config - paths.push(PathBuf::from("/etc/trx-rs/config.toml")); + paths.push(PathBuf::from("/etc/trx-rs/client.toml")); paths } /// Generate an example configuration as a TOML string. pub fn example_toml() -> String { - let example = Config { + let example = ClientConfig { general: GeneralConfig { callsign: Some("N0CALL".to_string()), log_level: Some("info".to_string()), }, - rig: RigConfig { - model: Some("ft817".to_string()), - initial_freq_hz: 144_300_000, - initial_mode: RigMode::USB, - access: AccessConfig { - access_type: Some("serial".to_string()), - port: Some("/dev/ttyUSB0".to_string()), - baud: Some(9600), - host: None, - tcp_port: None, + remote: RemoteConfig { + url: Some("192.168.1.100:9000".to_string()), + auth: RemoteAuthConfig { + token: Some("my-token".to_string()), }, + poll_interval_ms: 750, }, frontends: FrontendsConfig { http: HttpFrontendConfig { @@ -300,14 +230,13 @@ impl Config { port: 8080, }, rigctl: RigctlFrontendConfig { - enabled: true, + enabled: false, listen: IpAddr::from([127, 0, 0, 1]), port: 4532, }, http_json: HttpJsonFrontendConfig::default(), - qt: QtFrontendConfig::default(), + qt: QtFrontendConfig { enabled: false }, }, - behavior: BehaviorConfig::default(), }; toml::to_string_pretty(&example).unwrap_or_default() @@ -354,97 +283,50 @@ mod tests { #[test] fn test_default_config() { - let config = Config::default(); + let config = ClientConfig::default(); assert!(config.frontends.http.enabled); assert!(!config.frontends.rigctl.enabled); assert_eq!(config.frontends.http.port, 8080); assert_eq!(config.frontends.rigctl.port, 4532); - assert_eq!(config.rig.initial_freq_hz, 144_300_000); - assert_eq!(config.rig.initial_mode, RigMode::USB); assert!(config.frontends.http_json.enabled); assert_eq!(config.frontends.http_json.port, 0); assert!(!config.frontends.qt.enabled); - assert!(!config.frontends.qt.remote.enabled); + assert!(config.remote.url.is_none()); + assert_eq!(config.remote.poll_interval_ms, 750); } #[test] - fn test_parse_minimal_toml() { - let toml_str = r#" -[rig] -model = "ft817" - -[rig.access] -type = "serial" -port = "/dev/ttyUSB0" -baud = 9600 -"#; - - let config: Config = toml::from_str(toml_str).unwrap(); - assert_eq!(config.rig.model, Some("ft817".to_string())); - assert_eq!(config.rig.access.port, Some("/dev/ttyUSB0".to_string())); - assert_eq!(config.rig.access.baud, Some(9600)); - } - - #[test] - fn test_parse_full_toml() { + fn test_parse_client_toml() { let toml_str = r#" [general] callsign = "W1AW" -log_level = "debug" -[rig] -model = "ft817" -initial_freq_hz = 7100000 -initial_mode = "LSB" - -[rig.access] -type = "serial" -port = "/dev/ttyUSB0" -baud = 9600 +[remote] +url = "192.168.1.100:9000" +auth.token = "my-token" +poll_interval_ms = 500 [frontends.http] enabled = true -listen = "0.0.0.0" +listen = "127.0.0.1" port = 8080 -[frontends.rigctl] -enabled = true -listen = "127.0.0.1" -port = 4532 - -[frontends.http_json] -enabled = true -listen = "127.0.0.1" -port = 9000 -auth.tokens = ["demo-token"] - [frontends.qt] enabled = true -remote.enabled = true -remote.url = "127.0.0.1:9000" -remote.auth.token = "demo-token" - -[behavior] -poll_interval_ms = 1000 -poll_interval_tx_ms = 200 -max_retries = 5 -retry_base_delay_ms = 50 "#; - let config: Config = toml::from_str(toml_str).unwrap(); + let config: ClientConfig = toml::from_str(toml_str).unwrap(); assert_eq!(config.general.callsign, Some("W1AW".to_string())); - assert_eq!(config.general.log_level, Some("debug".to_string())); - assert_eq!(config.rig.initial_freq_hz, 7_100_000); - assert_eq!(config.rig.initial_mode, RigMode::LSB); + assert_eq!(config.remote.url, Some("192.168.1.100:9000".to_string())); + assert_eq!(config.remote.auth.token, Some("my-token".to_string())); + assert_eq!(config.remote.poll_interval_ms, 500); assert!(config.frontends.http.enabled); - assert!(config.frontends.rigctl.enabled); - assert_eq!(config.behavior.poll_interval_ms, 1000); - assert_eq!(config.behavior.max_retries, 5); + assert!(config.frontends.qt.enabled); } #[test] fn test_example_toml_parses() { - let example = Config::example_toml(); - let _config: Config = toml::from_str(&example).unwrap(); + let example = ClientConfig::example_toml(); + let _config: ClientConfig = toml::from_str(&example).unwrap(); } } diff --git a/src/trx-client/src/main.rs b/src/trx-client/src/main.rs new file mode 100644 index 0000000..b37835b --- /dev/null +++ b/src/trx-client/src/main.rs @@ -0,0 +1,259 @@ +// SPDX-FileCopyrightText: 2025 Stanislaw Grams +// +// SPDX-License-Identifier: BSD-2-Clause + +mod config; +mod plugins; +mod remote_client; + +use std::net::{IpAddr, SocketAddr}; +use std::path::PathBuf; +use std::time::Duration; + +use clap::Parser; +use tokio::signal; +use tokio::sync::{mpsc, watch}; +use tracing::info; + +use trx_core::rig::request::RigRequest; +use trx_core::rig::state::RigState; +use trx_core::rig::{RigControl, RigRxStatus, RigStatus, RigTxStatus}; +use trx_core::radio::freq::Freq; +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; + +use config::ClientConfig; +use remote_client::{parse_remote_url, RemoteClientConfig}; + +const PKG_DESCRIPTION: &str = concat!(env!("CARGO_PKG_NAME"), " - remote rig client"); +const RIG_TASK_CHANNEL_BUFFER: usize = 32; +const QT_FRONTEND_LISTEN_ADDR: ([u8; 4], u16) = ([127, 0, 0, 1], 0); + +#[derive(Debug, Parser)] +#[command( + author = env!("CARGO_PKG_AUTHORS"), + version = env!("CARGO_PKG_VERSION"), + about = PKG_DESCRIPTION, +)] +struct Cli { + /// Path to configuration file + #[arg(long = "config", short = 'C', value_name = "FILE")] + config: Option, + /// Print example configuration and exit + #[arg(long = "print-config")] + print_config: bool, + /// Remote server URL (host:port) + #[arg(short = 'u', long = "url")] + url: Option, + /// Authentication token for the remote server + #[arg(long = "token")] + token: Option, + /// Poll interval in milliseconds + #[arg(long = "poll-interval")] + poll_interval_ms: Option, + /// Frontend(s) to expose locally (e.g. http,rigctl,qt) + #[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, + /// Optional callsign/owner label to show in the frontend + #[arg(short = 'c', long = "callsign")] + callsign: Option, +} + +/// Normalize a rig/frontend name to lowercase alphanumeric. +fn normalize_name(name: &str) -> String { + name.to_ascii_lowercase() + .chars() + .filter(|c| c.is_ascii_alphanumeric()) + .collect() +} + +#[tokio::main] +async fn main() -> DynResult<()> { + tracing_subscriber::fmt().with_target(false).init(); + + register_http_frontend(); + register_http_json_frontend(); + register_rigctl_frontend(); + #[cfg(feature = "qt-frontend")] + register_qt_frontend(); + let _plugin_libs = plugins::load_plugins(); + + let cli = Cli::parse(); + + if cli.print_config { + println!("{}", ClientConfig::example_toml()); + return Ok(()); + } + + let (cfg, config_path) = if let Some(ref path) = cli.config { + let cfg = ClientConfig::load_from_file(path)?; + (cfg, Some(path.clone())) + } else { + ClientConfig::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()); + + // Resolve remote URL: CLI > config [remote] section > error + let remote_url = cli + .url + .clone() + .or_else(|| cfg.remote.url.clone()) + .ok_or("Remote URL not specified. Use --url or set [remote].url in config.")?; + + let remote_addr = + parse_remote_url(&remote_url).map_err(|e| format!("Invalid remote URL: {}", e))?; + + let remote_token = cli + .token + .clone() + .or_else(|| cfg.remote.auth.token.clone()); + + let poll_interval_ms = cli + .poll_interval_ms + .unwrap_or(cfg.remote.poll_interval_ms); + + // Resolve frontends: CLI > config > default to http + 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()); + } + fes + }; + for name in &frontends { + if !is_frontend_registered(name) { + return Err(format!( + "Unknown frontend: {} (available: {})", + name, + registered_frontends().join(", ") + ) + .into()); + } + } + + let http_listen = cli.http_listen.unwrap_or(cfg.frontends.http.listen); + let http_port = cli.http_port.unwrap_or(cfg.frontends.http.port); + let rigctl_listen = cli.rigctl_listen.unwrap_or(cfg.frontends.rigctl.listen); + let rigctl_port = cli.rigctl_port.unwrap_or(cfg.frontends.rigctl.port); + 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); + let callsign = cli + .callsign + .clone() + .or_else(|| cfg.general.callsign.clone()); + + info!( + "Starting trx-client (remote: {}, frontends: {})", + remote_addr, + frontends.join(", ") + ); + + let (tx, rx) = mpsc::channel::(RIG_TASK_CHANNEL_BUFFER); + + let initial_state = RigState { + rig_info: None, + status: RigStatus { + freq: Freq { hz: 144_300_000 }, + mode: trx_core::rig::state::RigMode::USB, + 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); + + let remote_cfg = RemoteClientConfig { + addr: remote_addr, + token: remote_token, + poll_interval: Duration::from_millis(poll_interval_ms), + }; + let _remote_handle = + tokio::spawn(remote_client::run_remote_client(remote_cfg, rx, state_tx)); + + // Spawn frontends + for frontend in &frontends { + let frontend_state_rx = state_rx.clone(); + let addr = match frontend.as_str() { + "http" => SocketAddr::from((http_listen, http_port)), + "rigctl" => SocketAddr::from((rigctl_listen, rigctl_port)), + "httpjson" => SocketAddr::from((http_json_listen, 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(), + callsign.clone(), + addr, + )?; + } + + signal::ctrl_c().await?; + info!("Ctrl+C received, shutting down"); + Ok(()) +} diff --git a/src/trx-bin/src/plugins.rs b/src/trx-client/src/plugins.rs similarity index 100% rename from src/trx-bin/src/plugins.rs rename to src/trx-client/src/plugins.rs diff --git a/src/trx-bin/src/remote_client.rs b/src/trx-client/src/remote_client.rs similarity index 96% rename from src/trx-bin/src/remote_client.rs rename to src/trx-client/src/remote_client.rs index 07459bd..aae9a60 100644 --- a/src/trx-bin/src/remote_client.rs +++ b/src/trx-client/src/remote_client.rs @@ -30,15 +30,15 @@ pub async fn run_remote_client( let mut reconnect_delay = Duration::from_secs(1); loop { - info!("Qt remote: connecting to {}", config.addr); + info!("Remote client: connecting to {}", config.addr); match TcpStream::connect(&config.addr).await { Ok(stream) => { if let Err(e) = handle_connection(&config, stream, &mut rx, &state_tx).await { - warn!("Qt remote connection dropped: {}", e); + warn!("Remote connection dropped: {}", e); } } Err(e) => { - warn!("Qt remote connect failed: {}", e); + warn!("Remote connect failed: {}", e); } } @@ -66,7 +66,7 @@ async fn handle_connection( } last_poll = Instant::now(); if let Err(e) = send_command(config, &mut writer, &mut reader, ClientCommand::GetState, state_tx).await { - warn!("Qt remote poll failed: {}", e); + warn!("Remote poll failed: {}", e); } } req = rx.recv() => { diff --git a/src/trx-server/Cargo.toml b/src/trx-server/Cargo.toml new file mode 100644 index 0000000..4865381 --- /dev/null +++ b/src/trx-server/Cargo.toml @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: 2025 Stanislaw Grams +# +# SPDX-License-Identifier: BSD-2-Clause + +[package] +name = "trx-server" +version = "0.1.0" +edition = "2021" + +[dependencies] +tokio = { workspace = true, features = ["full"] } +tokio-serial = { workspace = true } +serde = { workspace = true, features = ["derive"] } +toml = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +clap = { workspace = true, features = ["derive"] } +dirs = "6" +libloading = "0.8" +trx-backend = { path = "../trx-backend" } +trx-core = { path = "../trx-core" } diff --git a/src/trx-server/src/config.rs b/src/trx-server/src/config.rs new file mode 100644 index 0000000..fc14a55 --- /dev/null +++ b/src/trx-server/src/config.rs @@ -0,0 +1,280 @@ +// SPDX-FileCopyrightText: 2025 Stanislaw Grams +// +// SPDX-License-Identifier: BSD-2-Clause + +//! Configuration file support for trx-server. +//! +//! Supports loading configuration from TOML files with the following search order: +//! 1. Path specified via `--config` CLI argument +//! 2. `./trx-server.toml` (current directory) +//! 3. `~/.config/trx-rs/server.toml` (XDG config) +//! 4. `/etc/trx-rs/server.toml` (system-wide) + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use trx_core::rig::state::RigMode; + +/// Top-level server configuration structure. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct ServerConfig { + /// General settings + pub general: GeneralConfig, + /// Rig backend configuration + pub rig: RigConfig, + /// Polling and retry behavior + pub behavior: BehaviorConfig, +} + +/// General application settings. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct GeneralConfig { + /// Callsign or owner label + pub callsign: Option, + /// Log level (trace, debug, info, warn, error) + pub log_level: Option, +} + +/// Rig backend configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct RigConfig { + /// Rig model (e.g., "ft817", "ic7300") + pub model: Option, + /// Initial frequency (Hz) for the rig state before first CAT read + pub initial_freq_hz: u64, + /// Initial mode for the rig state before first CAT read + pub initial_mode: RigMode, + /// Access method configuration + pub access: AccessConfig, +} + +/// Access method configuration for reaching the rig. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct AccessConfig { + /// Access type: "serial" or "tcp" + #[serde(rename = "type")] + pub access_type: Option, + /// Serial port path (for serial access) + pub port: Option, + /// Baud rate (for serial access) + pub baud: Option, + /// Host address (for TCP access) + pub host: Option, + /// TCP port (for TCP access) + pub tcp_port: Option, +} + +impl Default for RigConfig { + fn default() -> Self { + Self { + model: None, + initial_freq_hz: 144_300_000, + initial_mode: RigMode::USB, + access: AccessConfig::default(), + } + } +} + +/// Behavior configuration for polling and retries. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct BehaviorConfig { + /// Polling interval in milliseconds when idle + pub poll_interval_ms: u64, + /// Polling interval in milliseconds when transmitting + pub poll_interval_tx_ms: u64, + /// Maximum retry attempts for transient errors + pub max_retries: u32, + /// Base delay for exponential backoff in milliseconds + pub retry_base_delay_ms: u64, +} + +impl Default for BehaviorConfig { + fn default() -> Self { + Self { + poll_interval_ms: 500, + poll_interval_tx_ms: 100, + max_retries: 3, + retry_base_delay_ms: 100, + } + } +} + +impl ServerConfig { + /// Load configuration from a specific file path. + pub fn load_from_file(path: &Path) -> Result { + let contents = std::fs::read_to_string(path) + .map_err(|e| ConfigError::ReadError(path.to_path_buf(), e.to_string()))?; + + toml::from_str(&contents) + .map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string())) + } + + /// Load configuration from the default search paths. + /// Returns default config if no config file is found. + pub fn load_from_default_paths() -> Result<(Self, Option), ConfigError> { + let search_paths = Self::default_search_paths(); + + for path in search_paths { + if path.exists() { + let config = Self::load_from_file(&path)?; + return Ok((config, Some(path))); + } + } + + Ok((Self::default(), None)) + } + + /// Get the default search paths for config files. + pub fn default_search_paths() -> Vec { + let mut paths = Vec::new(); + + // Current directory + paths.push(PathBuf::from("trx-server.toml")); + + // XDG config directory + if let Some(config_dir) = dirs::config_dir() { + paths.push(config_dir.join("trx-rs").join("server.toml")); + } + + // System-wide config + paths.push(PathBuf::from("/etc/trx-rs/server.toml")); + + paths + } + + /// Generate an example configuration as a TOML string. + pub fn example_toml() -> String { + let example = ServerConfig { + general: GeneralConfig { + callsign: Some("N0CALL".to_string()), + log_level: Some("info".to_string()), + }, + rig: RigConfig { + model: Some("ft817".to_string()), + initial_freq_hz: 144_300_000, + initial_mode: RigMode::USB, + access: AccessConfig { + access_type: Some("serial".to_string()), + port: Some("/dev/ttyUSB0".to_string()), + baud: Some(9600), + host: None, + tcp_port: None, + }, + }, + behavior: BehaviorConfig::default(), + }; + + toml::to_string_pretty(&example).unwrap_or_default() + } +} + +/// Errors that can occur when loading configuration. +#[derive(Debug)] +pub enum ConfigError { + /// Failed to read the config file + ReadError(PathBuf, String), + /// Failed to parse the config file + ParseError(PathBuf, String), +} + +impl std::fmt::Display for ConfigError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::ReadError(path, err) => { + write!( + f, + "failed to read config file '{}': {}", + path.display(), + err + ) + } + Self::ParseError(path, err) => { + write!( + f, + "failed to parse config file '{}': {}", + path.display(), + err + ) + } + } + } +} + +impl std::error::Error for ConfigError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_config() { + let config = ServerConfig::default(); + assert_eq!(config.rig.initial_freq_hz, 144_300_000); + assert_eq!(config.rig.initial_mode, RigMode::USB); + assert_eq!(config.behavior.poll_interval_ms, 500); + assert_eq!(config.behavior.max_retries, 3); + } + + #[test] + fn test_parse_minimal_toml() { + let toml_str = r#" +[rig] +model = "ft817" + +[rig.access] +type = "serial" +port = "/dev/ttyUSB0" +baud = 9600 +"#; + + let config: ServerConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(config.rig.model, Some("ft817".to_string())); + assert_eq!(config.rig.access.port, Some("/dev/ttyUSB0".to_string())); + assert_eq!(config.rig.access.baud, Some(9600)); + } + + #[test] + fn test_parse_full_toml() { + let toml_str = r#" +[general] +callsign = "W1AW" +log_level = "debug" + +[rig] +model = "ft817" +initial_freq_hz = 7100000 +initial_mode = "LSB" + +[rig.access] +type = "serial" +port = "/dev/ttyUSB0" +baud = 9600 + +[behavior] +poll_interval_ms = 1000 +poll_interval_tx_ms = 200 +max_retries = 5 +retry_base_delay_ms = 50 +"#; + + let config: ServerConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(config.general.callsign, Some("W1AW".to_string())); + assert_eq!(config.general.log_level, Some("debug".to_string())); + assert_eq!(config.rig.initial_freq_hz, 7_100_000); + assert_eq!(config.rig.initial_mode, RigMode::LSB); + assert_eq!(config.behavior.poll_interval_ms, 1000); + assert_eq!(config.behavior.max_retries, 5); + } + + #[test] + fn test_example_toml_parses() { + let example = ServerConfig::example_toml(); + let _config: ServerConfig = toml::from_str(&example).unwrap(); + } +} diff --git a/src/trx-bin/src/error.rs b/src/trx-server/src/error.rs similarity index 100% rename from src/trx-bin/src/error.rs rename to src/trx-server/src/error.rs diff --git a/src/trx-server/src/main.rs b/src/trx-server/src/main.rs new file mode 100644 index 0000000..90b5271 --- /dev/null +++ b/src/trx-server/src/main.rs @@ -0,0 +1,282 @@ +// SPDX-FileCopyrightText: 2025 Stanislaw Grams +// +// SPDX-License-Identifier: BSD-2-Clause + +mod config; +mod error; +mod plugins; +mod rig_task; + +use std::path::PathBuf; +use std::time::Duration; + +use clap::{Parser, ValueEnum}; +use tokio::signal; +use tokio::sync::{mpsc, watch}; +use tracing::info; + +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 config::ServerConfig; + +const PKG_DESCRIPTION: &str = concat!(env!("CARGO_PKG_NAME"), " - rig server daemon"); +const RIG_TASK_CHANNEL_BUFFER: usize = 32; +const RETRY_MAX_DELAY_SECS: u64 = 2; + +#[derive(Debug, Parser)] +#[command( + author = env!("CARGO_PKG_AUTHORS"), + version = env!("CARGO_PKG_VERSION"), + about = PKG_DESCRIPTION, +)] +struct Cli { + /// Path to configuration file + #[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, + /// Rig CAT address: + /// when access is serial: ; + /// when access is TCP: : + #[arg(value_name = "RIG_ADDR")] + rig_addr: Option, + /// Optional callsign/owner label + #[arg(short = 'c', long = "callsign")] + callsign: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +enum AccessKind { + Serial, + Tcp, +} + +/// Normalize a rig name to lowercase alphanumeric. +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, + callsign: Option, +} + +fn resolve_config(cli: &Cli, cfg: &ServerConfig) -> DynResult { + 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 => { + return Err( + "Rig model not specified. Use --rig or set [rig].model in config.".into(), + ) + } + }; + if !is_backend_registered(&rig) { + return Err(format!( + "Unknown rig model: {} (available: {})", + rig, + registered_backends().join(", ") + ) + .into()); + } + + let access = { + 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 => { + 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()), + } + }; + + let callsign = cli + .callsign + .clone() + .or_else(|| cfg.general.callsign.clone()); + + Ok(ResolvedConfig { + rig, + access, + callsign, + }) +} + +fn build_initial_state(cfg: &ServerConfig) -> RigState { + 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), + }, + } +} + +fn build_rig_task_config( + resolved: &ResolvedConfig, + cfg: &ServerConfig, +) -> rig_task::RigTaskConfig { + rig_task::RigTaskConfig { + rig_model: resolved.rig.clone(), + access: resolved.access.clone(), + 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(), + } +} + +#[tokio::main] +async fn main() -> DynResult<()> { + tracing_subscriber::fmt().with_target(false).init(); + + register_builtin_backends(); + let _plugin_libs = plugins::load_plugins(); + + let cli = Cli::parse(); + + if cli.print_config { + println!("{}", ServerConfig::example_toml()); + return Ok(()); + } + + let (cfg, config_path) = if let Some(ref path) = cli.config { + let cfg = ServerConfig::load_from_file(path)?; + (cfg, Some(path.clone())) + } else { + ServerConfig::load_from_default_paths()? + }; + + if let Some(ref path) = config_path { + info!("Loaded configuration from {}", path.display()); + } + + let resolved = resolve_config(&cli, &cfg)?; + + match &resolved.access { + RigAccess::Serial { path, baud } => { + info!( + "Starting trx-server (rig: {}, access: serial {} @ {} baud)", + resolved.rig, path, baud + ); + } + RigAccess::Tcp { addr } => { + info!( + "Starting trx-server (rig: {}, access: tcp {})", + resolved.rig, addr + ); + } + } + + if let Some(ref cs) = resolved.callsign { + info!("Callsign: {}", cs); + } + + let (tx, rx) = mpsc::channel::(RIG_TASK_CHANNEL_BUFFER); + let initial_state = build_initial_state(&cfg); + let (state_tx, state_rx) = watch::channel(initial_state); + // Keep receivers alive so channels don't close prematurely + let _state_rx = state_rx; + let _tx = tx; + + let rig_task_config = build_rig_task_config(&resolved, &cfg); + let _rig_handle = tokio::spawn(rig_task::run_rig_task(rig_task_config, rx, state_tx)); + + signal::ctrl_c().await?; + info!("Ctrl+C received, shutting down"); + Ok(()) +} diff --git a/src/trx-server/src/plugins.rs b/src/trx-server/src/plugins.rs new file mode 100644 index 0000000..4456f17 --- /dev/null +++ b/src/trx-server/src/plugins.rs @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: 2025 Stanislaw Grams +// +// SPDX-License-Identifier: BSD-2-Clause + +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; + +use libloading::{Library, Symbol}; +use tracing::{info, warn}; + +const PLUGIN_ENV: &str = "TRX_PLUGIN_DIRS"; +const PLUGIN_ENTRYPOINT: &str = "trx_register"; + +#[cfg(windows)] +const PATH_SEPARATOR: char = ';'; +#[cfg(not(windows))] +const PATH_SEPARATOR: char = ':'; + +#[cfg(windows)] +const PLUGIN_EXTENSIONS: &[&str] = &["dll"]; +#[cfg(target_os = "macos")] +const PLUGIN_EXTENSIONS: &[&str] = &["dylib"]; +#[cfg(all(unix, not(target_os = "macos")))] +const PLUGIN_EXTENSIONS: &[&str] = &["so"]; + +pub fn load_plugins() -> Vec { + let mut libraries = Vec::new(); + let search_paths = plugin_search_paths(); + + if search_paths.is_empty() { + return libraries; + } + + info!("Plugin search paths: {:?}", search_paths); + + for path in search_paths { + if let Err(err) = load_plugins_from_dir(&path, &mut libraries) { + warn!("Plugin scan failed for {:?}: {}", path, err); + } + } + + libraries +} + +fn load_plugins_from_dir(path: &Path, libraries: &mut Vec) -> std::io::Result<()> { + if !path.exists() { + return Ok(()); + } + + for entry in std::fs::read_dir(path)? { + let entry = entry?; + let path = entry.path(); + if !path.is_file() { + continue; + } + if !is_plugin_file(&path) { + continue; + } + + unsafe { + match Library::new(&path) { + Ok(lib) => { + if let Err(err) = register_library(&lib, &path) { + warn!("Plugin {:?} failed to register: {}", path, err); + continue; + } + info!("Loaded plugin {:?}", path); + libraries.push(lib); + } + Err(err) => { + warn!("Failed to load plugin {:?}: {}", path, err); + } + } + } + } + + Ok(()) +} + +unsafe fn register_library(lib: &Library, path: &Path) -> Result<(), String> { + let entry: Symbol = lib + .get(PLUGIN_ENTRYPOINT.as_bytes()) + .map_err(|e| format!("missing entrypoint {}: {}", PLUGIN_ENTRYPOINT, e))?; + entry(); + info!("Registered plugin {:?}", path); + Ok(()) +} + +fn plugin_search_paths() -> Vec { + let mut paths = Vec::new(); + + if let Ok(env_paths) = std::env::var(PLUGIN_ENV) { + for raw in env_paths.split(PATH_SEPARATOR) { + if raw.trim().is_empty() { + continue; + } + paths.push(PathBuf::from(raw)); + } + } + + paths.push(PathBuf::from("plugins")); + + if let Some(config_dir) = dirs::config_dir() { + paths.push(config_dir.join("trx-rs").join("plugins")); + } + + paths +} + +fn is_plugin_file(path: &Path) -> bool { + path.extension() + .and_then(OsStr::to_str) + .map(|ext| PLUGIN_EXTENSIONS.iter().any(|e| ext.eq_ignore_ascii_case(e))) + .unwrap_or(false) +} diff --git a/src/trx-bin/src/rig_task.rs b/src/trx-server/src/rig_task.rs similarity index 100% rename from src/trx-bin/src/rig_task.rs rename to src/trx-server/src/rig_task.rs