From da58a004fe80edc7a97975addef6402418aa985c Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Thu, 6 Aug 2026 20:44:42 +0200 Subject: [PATCH] [refactor](trx-config): extract client/server config into a shared crate The setup wizard, the server and the client each carried their own idea of what a valid config looks like: trx-configurator validated with hand-written toml_edit key lists while the binaries validated with serde plus their own validate(). Nothing kept the three in sync. Move ServerConfig, ClientConfig, the section loader, the shared validators and the endpoint-URL parsing into a new trx-config crate that all three depend on, so there is one definition of the config to drift from. The binaries keep a thin crate::config re-export so their internal paths are unchanged. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7 Signed-off-by: Stan Grams --- Cargo.lock | 21 +- Cargo.toml | 1 + src/trx-app/Cargo.toml | 4 - src/trx-app/src/lib.rs | 8 +- src/trx-client/Cargo.toml | 1 + src/trx-client/src/config.rs | 1219 +------------ src/trx-client/src/remote_client.rs | 102 +- src/trx-config/Cargo.toml | 19 + src/trx-config/src/client.rs | 1221 +++++++++++++ .../src/config.rs => trx-config/src/file.rs} | 0 src/trx-config/src/lib.rs | 22 + src/trx-config/src/server.rs | 1525 +++++++++++++++++ .../src/shared.rs} | 0 src/trx-config/src/url.rs | 173 ++ src/trx-configurator/Cargo.toml | 1 + src/trx-server/Cargo.toml | 1 + src/trx-server/src/config.rs | 1523 +--------------- 17 files changed, 2999 insertions(+), 2842 deletions(-) create mode 100644 src/trx-config/Cargo.toml create mode 100644 src/trx-config/src/client.rs rename src/{trx-app/src/config.rs => trx-config/src/file.rs} (100%) create mode 100644 src/trx-config/src/lib.rs create mode 100644 src/trx-config/src/server.rs rename src/{trx-app/src/shared_config.rs => trx-config/src/shared.rs} (100%) create mode 100644 src/trx-config/src/url.rs diff --git a/Cargo.lock b/Cargo.lock index 1d7d099b..17174b46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3031,10 +3031,6 @@ dependencies = [ name = "trx-app" version = "0.1.0" dependencies = [ - "dirs", - "serde", - "thiserror 2.0.18", - "toml", "tracing", "tracing-subscriber", ] @@ -3115,6 +3111,7 @@ dependencies = [ "toml", "tracing", "trx-app", + "trx-config", "trx-core", "trx-frontend", "trx-frontend-http", @@ -3124,6 +3121,20 @@ dependencies = [ "uuid", ] +[[package]] +name = "trx-config" +version = "0.1.0" +dependencies = [ + "dirs", + "serde", + "thiserror 2.0.18", + "toml", + "tracing", + "trx-core", + "trx-decode-log", + "trx-reporting", +] + [[package]] name = "trx-configurator" version = "0.1.0" @@ -3133,6 +3144,7 @@ dependencies = [ "tempfile", "tokio-serial", "toml_edit 0.22.27", + "trx-config", ] [[package]] @@ -3294,6 +3306,7 @@ dependencies = [ "trx-app", "trx-aprs", "trx-backend", + "trx-config", "trx-core", "trx-cw", "trx-decode-log", diff --git a/Cargo.toml b/Cargo.toml index 22e62b6c..18246b51 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ members = [ "src/trx-core", "src/trx-protocol", "src/trx-app", + "src/trx-config", "src/trx-reporting", "src/trx-server", "src/trx-server/trx-backend", diff --git a/src/trx-app/Cargo.toml b/src/trx-app/Cargo.toml index 917dde4d..7f68abe6 100644 --- a/src/trx-app/Cargo.toml +++ b/src/trx-app/Cargo.toml @@ -9,9 +9,5 @@ edition = "2021" license = "GPL-2.0-or-later" [dependencies] -serde = { workspace = true } -toml = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } -dirs = "6" -thiserror = "2" diff --git a/src/trx-app/src/lib.rs b/src/trx-app/src/lib.rs index fcf9fc96..6193513a 100644 --- a/src/trx-app/src/lib.rs +++ b/src/trx-app/src/lib.rs @@ -2,12 +2,12 @@ // // SPDX-License-Identifier: GPL-2.0-or-later -pub mod config; +//! Shared application helpers. +//! +//! Configuration types and their loader live in the `trx-config` crate. + pub mod logging; -pub mod shared_config; pub mod util; -pub use config::{ConfigError, ConfigFile}; pub use logging::init_logging; -pub use shared_config::{validate_log_level, validate_tokens}; pub use util::normalize_name; diff --git a/src/trx-client/Cargo.toml b/src/trx-client/Cargo.toml index 2cf591c2..b6b831f8 100644 --- a/src/trx-client/Cargo.toml +++ b/src/trx-client/Cargo.toml @@ -21,6 +21,7 @@ uuid = { workspace = true } cpal = "0.15" opus = "0.3" trx-app = { path = "../trx-app" } +trx-config = { path = "../trx-config" } trx-core = { path = "../trx-core" } trx-protocol = { path = "../trx-protocol" } trx-frontend = { path = "trx-frontend" } diff --git a/src/trx-client/src/config.rs b/src/trx-client/src/config.rs index 9e7daf10..4ee139fa 100644 --- a/src/trx-client/src/config.rs +++ b/src/trx-client/src/config.rs @@ -2,1219 +2,10 @@ // // SPDX-License-Identifier: GPL-2.0-or-later -//! Configuration file support for trx-client. +//! Client configuration types. //! -//! Config is loaded from the `[trx-client]` section of `trx-rs.toml`. -//! Default search order: -//! 1. Path specified via `--config` CLI argument -//! 2. `./trx-rs.toml` -//! 3. `~/.config/trx-rs/trx-rs.toml` -//! 4. `/etc/trx-rs/trx-rs.toml` +//! The definitions live in the shared `trx-config` crate so that +//! `trx-configurator` checks a config with exactly the same code the client +//! loads it with. This module re-exports them under the binary's own path. -use std::collections::HashMap; -use std::net::IpAddr; -use std::path::{Path, PathBuf}; -use std::time::Duration; - -use serde::{Deserialize, Serialize}; -use trx_app::{validate_log_level, validate_tokens, ConfigError, ConfigFile}; - -/// Top-level client configuration structure. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(default)] -pub struct ClientConfig { - /// General settings - pub general: GeneralConfig, - /// Legacy single remote connection settings. - /// Kept for backward compatibility; prefer `[[remotes]]`. - pub remote: RemoteConfig, - /// Named remote connections (one per short-name / rig). - /// Each entry maps a user-chosen short name to a (server, rig_id) pair. - pub remotes: Vec, - /// Frontend configurations - pub frontends: FrontendsConfig, -} - -/// General application settings. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct GeneralConfig { - /// Callsign or owner label to display in frontends - pub callsign: Option, - /// Optional website URL to use as the web UI header title link. - pub website_url: Option, - /// Optional website name to use as the web UI header title label. - pub website_name: Option, - /// Optional base URL used to link AIS vessel names as ``. - pub ais_vessel_url_base: Option, - /// Log level (trace, debug, info, warn, error) - pub log_level: Option, -} - -impl Default for GeneralConfig { - fn default() -> Self { - Self { - callsign: Some("N0CALL".to_string()), - website_url: None, - website_name: None, - ais_vessel_url_base: Some("https://www.vesselfinder.com/?mmsi=".to_string()), - log_level: None, - } - } -} - -/// Remote connection configuration. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct RemoteConfig { - /// Remote URL (host:port or tcp://host:port). - pub url: Option, - /// Optional target rig ID on the remote multi-rig server. - pub rig_id: Option, - /// Remote auth settings. - pub auth: RemoteAuthConfig, - /// Poll interval in milliseconds. - pub poll_interval_ms: u64, -} - -impl Default for RemoteConfig { - fn default() -> Self { - Self { - url: None, - rig_id: None, - auth: RemoteAuthConfig::default(), - poll_interval_ms: 750, - } - } -} - -/// 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, -} - -/// A named remote connection entry. -/// -/// Each entry maps a user-chosen **short name** to a `(server, rig_id)` pair. -/// The short name is used as the identifier throughout frontends (HTTP rig -/// picker, rigctl ports, audio routing, etc.) instead of the server-scoped -/// `rig_id`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RemoteEntry { - /// Short name used to identify this remote in frontends and config. - pub name: String, - /// Remote server URL (`host:port` or `tcp://host:port`). - pub url: String, - /// Optional target rig ID on a multi-rig server. - /// When omitted, the server's default (or only) rig is used. - pub rig_id: Option, - /// Authentication settings. - #[serde(default)] - pub auth: RemoteAuthConfig, - /// Poll interval in milliseconds. Defaults to 750. - #[serde(default = "default_poll_interval_ms")] - pub poll_interval_ms: u64, -} - -fn default_poll_interval_ms() -> u64 { - 750 -} - -/// Frontend configurations. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(default)] -pub struct FrontendsConfig { - /// HTTP frontend settings - pub http: HttpFrontendConfig, - /// rigctl frontend settings - pub rigctl: RigctlFrontendConfig, - /// JSON TCP frontend settings - pub http_json: HttpJsonFrontendConfig, - /// Audio streaming settings - pub audio: AudioClientConfig, -} - -/// Audio streaming client configuration. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct AudioClientConfig { - /// Whether audio streaming is enabled - pub enabled: bool, - /// Optional exact audio TCP URL override applied to all remotes. - /// When set, this takes precedence over `server_port`. - pub server_url: Option, - /// Optional per-rig audio URL overrides keyed by remote short name. - /// These take precedence over `server_url`, server-advertised ports, and - /// the legacy `rig_ports` map. - pub rig_urls: HashMap, - /// Legacy audio TCP port fallback on the remote server when no URL override - /// is configured and the server does not advertise a per-rig audio port. - pub server_port: u16, - /// Legacy per-rig audio port overrides for multi-rig servers. - /// Prefer `rig_urls` when the audio endpoint differs by host as well. - pub rig_ports: HashMap, - /// Local audio bridge (virtual device integration) - pub bridge: AudioBridgeConfig, -} - -impl Default for AudioClientConfig { - fn default() -> Self { - Self { - enabled: true, - server_url: None, - rig_urls: HashMap::new(), - server_port: 4531, - rig_ports: HashMap::new(), - bridge: AudioBridgeConfig::default(), - } - } -} - -/// Local audio bridge configuration. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct AudioBridgeConfig { - /// Enable local cpal bridge between remote stream and local audio devices. - pub enabled: bool, - /// Local output device for remote RX playback. - pub rx_output_device: Option, - /// Local input device for TX uplink capture. - pub tx_input_device: Option, - /// Opus bitrate in bits per second for TX uplink capture. - pub bitrate_bps: u32, - /// RX playback gain multiplier. - pub rx_gain: f32, - /// TX capture gain multiplier. - pub tx_gain: f32, -} - -impl Default for AudioBridgeConfig { - fn default() -> Self { - Self { - enabled: false, - rx_output_device: None, - tx_input_device: None, - bitrate_bps: 192000, - rx_gain: 1.0, - tx_gain: 1.0, - } - } -} - -/// Cookie SameSite attribute options. -#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)] -#[serde(rename_all = "PascalCase")] -pub enum CookieSameSite { - /// Strict: cookie only sent in same-site context - Strict, - /// Lax: cookie sent with top-level navigation (default) - #[default] - Lax, - /// None: cookie sent in all contexts (requires Secure=true) - None, -} - -impl AsRef for CookieSameSite { - fn as_ref(&self) -> &str { - match self { - Self::Strict => "Strict", - Self::Lax => "Lax", - Self::None => "None", - } - } -} - -/// HTTP frontend authentication configuration. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct HttpAuthConfig { - /// Enable HTTP frontend authentication - pub enabled: bool, - /// Passphrase for read-only access (rx role) - pub rx_passphrase: Option, - /// Passphrase for full control access (control role) - pub control_passphrase: Option, - /// Enforce TX/PTT access control (hide from unauthenticated/rx users) - pub tx_access_control_enabled: bool, - /// Session time-to-live in minutes - pub session_ttl_min: u64, - /// Set Secure flag on session cookie (required for HTTPS) - pub cookie_secure: bool, - /// SameSite attribute for session cookie - pub cookie_same_site: CookieSameSite, -} - -impl Default for HttpAuthConfig { - fn default() -> Self { - Self { - enabled: false, - rx_passphrase: None, - control_passphrase: None, - tx_access_control_enabled: true, - session_ttl_min: 480, - cookie_secure: false, - cookie_same_site: CookieSameSite::Lax, - } - } -} - -impl HttpAuthConfig { - /// Convert session TTL from minutes to Duration. - pub fn session_ttl(&self) -> Duration { - Duration::from_secs(self.session_ttl_min * 60) - } -} - -/// HTTP frontend configuration. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct HttpFrontendConfig { - /// Whether HTTP frontend is enabled - pub enabled: bool, - /// Listen address - pub listen: IpAddr, - /// Listen port - pub port: u16, - /// Default rig selected in the web UI on startup. - #[serde(alias = "default_rig_id")] - pub default_rig_name: Option, - /// Initial zoom level for the APRS map when receiver coordinates are known. - pub initial_map_zoom: u8, - /// Spectrum center-retune guard margin on each side of the selected bandwidth. - pub spectrum_coverage_margin_hz: u32, - /// Fraction of the sampled spectrum span treated as usable for center-retune logic. - pub spectrum_usable_span_ratio: f32, - /// Whether to expose the RF Gain control in the web UI. - pub show_sdr_gain_control: bool, - /// Whether the bandplan strip is shown by default in the spectrum display. - pub bandplan_enabled: bool, - /// Default bandplan region: "iaru_r1", "iaru_r2", or "iaru_r3". - pub bandplan_region: String, - /// Default decode history retention in minutes for the active rig. - pub decode_history_retention_min: u64, - /// Optional per-rig decode history retention overrides in minutes. - pub decode_history_retention_min_by_rig: HashMap, - /// Authentication settings - pub auth: HttpAuthConfig, -} - -impl Default for HttpFrontendConfig { - fn default() -> Self { - Self { - enabled: true, - listen: IpAddr::from([127, 0, 0, 1]), - port: 8080, - default_rig_name: None, - initial_map_zoom: 10, - spectrum_coverage_margin_hz: 50_000, - spectrum_usable_span_ratio: 0.92, - show_sdr_gain_control: true, - bandplan_enabled: true, - bandplan_region: "iaru_r1".to_string(), - decode_history_retention_min: 24 * 60, - decode_history_retention_min_by_rig: HashMap::new(), - auth: HttpAuthConfig::default(), - } - } -} - -/// rigctl frontend configuration. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct RigctlFrontendConfig { - /// Whether rigctl frontend is enabled - pub enabled: bool, - /// Listen address - pub listen: IpAddr, - /// Legacy shared-listener port. Ignored; per-rig ports must be configured. - pub port: u16, - /// Per-rig rigctl listener ports. - /// Maps rig ID -> local rigctl port. One rigctl listener is spawned per - /// entry, each routing commands to its assigned rig. - pub rig_ports: HashMap, -} - -impl Default for RigctlFrontendConfig { - fn default() -> Self { - Self { - enabled: false, - listen: IpAddr::from([127, 0, 0, 1]), - port: 4532, - rig_ports: HashMap::new(), - } - } -} - -/// JSON TCP frontend configuration. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct HttpJsonFrontendConfig { - /// Whether JSON TCP frontend is enabled - pub enabled: bool, - /// Listen address - pub listen: IpAddr, - /// Listen port (0 = ephemeral) - pub port: u16, - /// Authorization settings - pub auth: HttpJsonAuthConfig, -} - -impl Default for HttpJsonFrontendConfig { - fn default() -> Self { - Self { - enabled: true, - listen: IpAddr::from([127, 0, 0, 1]), - port: 0, - auth: HttpJsonAuthConfig::default(), - } - } -} - -/// Authorization settings for JSON TCP frontend. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(default)] -pub struct HttpJsonAuthConfig { - /// Accepted bearer tokens. - pub tokens: Vec, -} - -impl ClientConfig { - /// Return the effective list of remote entries. - /// - /// If `[[remotes]]` is non-empty, return it directly. Otherwise, - /// synthesize a single entry from the legacy `[remote]` section (if it - /// has a `url`). Returns an empty `Vec` when neither is configured - /// (caller should check CLI overrides). - pub fn resolved_remotes(&self) -> Vec { - if !self.remotes.is_empty() { - return self.remotes.clone(); - } - // Legacy fallback - if let Some(url) = &self.remote.url { - let name = self - .remote - .rig_id - .clone() - .unwrap_or_else(|| "default".to_string()); - vec![RemoteEntry { - name, - url: url.clone(), - rig_id: self.remote.rig_id.clone(), - auth: self.remote.auth.clone(), - poll_interval_ms: self.remote.poll_interval_ms, - }] - } else { - Vec::new() - } - } - - pub fn validate(&self) -> Result<(), String> { - validate_log_level(self.general.log_level.as_deref())?; - - // Validate [[remotes]] entries - { - let mut seen_names = std::collections::HashSet::new(); - for (i, entry) in self.remotes.iter().enumerate() { - if entry.name.trim().is_empty() { - return Err(format!("[[remotes]][{}].name must not be empty", i)); - } - if !seen_names.insert(&entry.name) { - return Err(format!("[[remotes]] duplicate name \"{}\"", entry.name)); - } - if entry.url.trim().is_empty() { - return Err(format!( - "[[remotes]][{}].url must not be empty (name \"{}\")", - i, entry.name - )); - } - if let Some(rig_id) = &entry.rig_id { - if rig_id.trim().is_empty() { - return Err(format!( - "[[remotes]][{}].rig_id must not be empty when set (name \"{}\")", - i, entry.name - )); - } - } - if let Some(token) = &entry.auth.token { - if token.trim().is_empty() { - return Err(format!( - "[[remotes]][{}].auth.token must not be empty when set (name \"{}\")", - i, entry.name - )); - } - } - if entry.poll_interval_ms == 0 { - return Err(format!( - "[[remotes]][{}].poll_interval_ms must be > 0 (name \"{}\")", - i, entry.name - )); - } - } - } - - // Validate legacy [remote] (kept for backward compat) - if self.remote.poll_interval_ms == 0 { - return Err("[remote].poll_interval_ms must be > 0".to_string()); - } - if let Some(url) = &self.remote.url { - if url.trim().is_empty() { - return Err("[remote].url must not be empty when set".to_string()); - } - } - if let Some(rig_id) = &self.remote.rig_id { - if rig_id.trim().is_empty() { - return Err("[remote].rig_id must not be empty when set".to_string()); - } - } - if let Some(token) = &self.remote.auth.token { - if token.trim().is_empty() { - return Err("[remote.auth].token must not be empty when set".to_string()); - } - } - if let Some(url) = &self.general.website_url { - if url.trim().is_empty() { - return Err("[general].website_url must not be empty when set".to_string()); - } - } - if let Some(name) = &self.general.website_name { - if name.trim().is_empty() { - return Err("[general].website_name must not be empty when set".to_string()); - } - } - if let Some(url) = &self.general.ais_vessel_url_base { - if url.trim().is_empty() { - return Err("[general].ais_vessel_url_base must not be empty when set".to_string()); - } - } - - if self.frontends.http.enabled && self.frontends.http.port == 0 { - return Err("[frontends.http].port must be > 0 when enabled".to_string()); - } - if let Some(rig_id) = &self.frontends.http.default_rig_name { - if rig_id.trim().is_empty() { - return Err( - "[frontends.http].default_rig_name must not be empty when set".to_string(), - ); - } - } - if self.frontends.http.initial_map_zoom == 0 { - return Err("[frontends.http].initial_map_zoom must be > 0".to_string()); - } - if self.frontends.http.spectrum_coverage_margin_hz == 0 { - return Err("[frontends.http].spectrum_coverage_margin_hz must be > 0".to_string()); - } - if !(self.frontends.http.spectrum_usable_span_ratio > 0.0 - && self.frontends.http.spectrum_usable_span_ratio <= 1.0) - { - return Err( - "[frontends.http].spectrum_usable_span_ratio must be > 0.0 and <= 1.0".to_string(), - ); - } - match self.frontends.http.bandplan_region.as_str() { - "iaru_r1" | "iaru_r2" | "iaru_r3" => {} - other => { - return Err(format!( - "[frontends.http].bandplan_region must be \"iaru_r1\", \"iaru_r2\", or \"iaru_r3\", got \"{}\"", - other - )); - } - } - if self.frontends.http.decode_history_retention_min == 0 { - return Err("[frontends.http].decode_history_retention_min must be > 0".to_string()); - } - for (rig_id, minutes) in &self.frontends.http.decode_history_retention_min_by_rig { - if rig_id.trim().is_empty() { - return Err( - "[frontends.http].decode_history_retention_min_by_rig keys must not be empty" - .to_string(), - ); - } - if *minutes == 0 { - return Err(format!( - "[frontends.http].decode_history_retention_min_by_rig[\"{}\"] must be > 0", - rig_id - )); - } - } - if self.frontends.rigctl.enabled && self.frontends.rigctl.rig_ports.is_empty() { - return Err( - "[frontends.rigctl].rig_ports must contain at least one rig when enabled" - .to_string(), - ); - } - for (rig_id, port) in &self.frontends.rigctl.rig_ports { - if rig_id.trim().is_empty() { - return Err("[frontends.rigctl].rig_ports keys must not be empty".to_string()); - } - if *port == 0 { - return Err(format!( - "[frontends.rigctl].rig_ports[\"{}\"] must be > 0", - rig_id - )); - } - } - if let Some(url) = &self.frontends.audio.server_url { - crate::remote_client::parse_audio_url(url) - .map_err(|e| format!("[frontends.audio].server_url {e}"))?; - } - if self.frontends.audio.enabled - && self.frontends.audio.server_url.is_none() - && self.frontends.audio.server_port == 0 - { - return Err("[frontends.audio].server_port must be > 0 when enabled".to_string()); - } - for (rig_id, url) in &self.frontends.audio.rig_urls { - if rig_id.trim().is_empty() { - return Err("[frontends.audio].rig_urls keys must not be empty".to_string()); - } - crate::remote_client::parse_audio_url(url) - .map_err(|e| format!("[frontends.audio].rig_urls[\"{rig_id}\"] {e}"))?; - } - for (rig_id, port) in &self.frontends.audio.rig_ports { - if rig_id.trim().is_empty() { - return Err("[frontends.audio].rig_ports keys must not be empty".to_string()); - } - if *port == 0 { - return Err(format!( - "[frontends.audio].rig_ports[\"{}\"] must be > 0", - rig_id - )); - } - } - if !self.frontends.audio.bridge.rx_gain.is_finite() - || self.frontends.audio.bridge.rx_gain < 0.0 - { - return Err("[frontends.audio.bridge].rx_gain must be finite and >= 0".to_string()); - } - if !self.frontends.audio.bridge.tx_gain.is_finite() - || self.frontends.audio.bridge.tx_gain < 0.0 - { - return Err("[frontends.audio.bridge].tx_gain must be finite and >= 0".to_string()); - } - if self.frontends.audio.bridge.bitrate_bps == 0 { - return Err("[frontends.audio.bridge].bitrate_bps must be > 0".to_string()); - } - validate_tokens( - "[frontends.http_json.auth].tokens", - &self.frontends.http_json.auth.tokens, - )?; - - validate_http_auth(&self.frontends.http.auth)?; - - Ok(()) - } - - /// Load configuration from a specific file path. - pub fn load_from_file(path: &Path) -> Result { - ::load_from_file(path) - } - - /// 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> { - ::load_from_default_paths() - } - - /// Generate an example configuration wrapped under the `[trx-client]` - /// section header, suitable for use in a combined `trx-rs.toml` file. - pub fn example_combined_toml() -> String { - #[derive(serde::Serialize)] - struct Wrapper { - #[serde(rename = "trx-client")] - inner: ClientConfig, - } - let example = ClientConfig { - general: GeneralConfig { - callsign: Some("N0CALL".to_string()), - website_url: Some("https://haxx.space".to_string()), - website_name: Some("haxx.space".to_string()), - ais_vessel_url_base: Some("https://www.vesselfinder.com/?mmsi=".to_string()), - log_level: Some("info".to_string()), - }, - remote: RemoteConfig::default(), - remotes: vec![ - RemoteEntry { - name: "home-hf".to_string(), - url: "192.168.1.100:4530".to_string(), - rig_id: Some("hf".to_string()), - auth: RemoteAuthConfig { - token: Some("my-token".to_string()), - }, - poll_interval_ms: 750, - }, - RemoteEntry { - name: "home-vhf".to_string(), - url: "192.168.1.100:4530".to_string(), - rig_id: Some("vhf".to_string()), - auth: RemoteAuthConfig { - token: Some("my-token".to_string()), - }, - poll_interval_ms: 750, - }, - ], - frontends: FrontendsConfig { - http: HttpFrontendConfig { - enabled: true, - listen: IpAddr::from([127, 0, 0, 1]), - port: 8080, - default_rig_name: Some("home-hf".to_string()), - initial_map_zoom: 10, - spectrum_coverage_margin_hz: 50_000, - spectrum_usable_span_ratio: 0.92, - show_sdr_gain_control: true, - bandplan_enabled: true, - bandplan_region: "iaru_r1".to_string(), - decode_history_retention_min: 24 * 60, - decode_history_retention_min_by_rig: HashMap::new(), - auth: HttpAuthConfig { - enabled: false, - rx_passphrase: Some("rx-passphrase-example".to_string()), - control_passphrase: Some("control-passphrase-example".to_string()), - tx_access_control_enabled: true, - session_ttl_min: 480, - cookie_secure: false, - cookie_same_site: CookieSameSite::Lax, - }, - }, - rigctl: RigctlFrontendConfig { - enabled: false, - listen: IpAddr::from([127, 0, 0, 1]), - port: 4532, - rig_ports: HashMap::new(), - }, - http_json: HttpJsonFrontendConfig::default(), - audio: AudioClientConfig::default(), - }, - }; - toml::to_string_pretty(&Wrapper { inner: example }).unwrap_or_default() - } -} - -fn validate_http_auth(auth: &HttpAuthConfig) -> Result<(), String> { - if !auth.enabled { - return Ok(()); - } - - // If enabled, require at least one passphrase - if auth.rx_passphrase.is_none() && auth.control_passphrase.is_none() { - return Err( - "[frontends.http.auth] enabled=true requires at least one passphrase \ - (rx_passphrase and/or control_passphrase)" - .to_string(), - ); - } - - // Validate passphrases are not empty strings - if let Some(rx) = &auth.rx_passphrase { - if rx.trim().is_empty() { - return Err("[frontends.http.auth].rx_passphrase must not be empty if set".to_string()); - } - } - if let Some(ctrl) = &auth.control_passphrase { - if ctrl.trim().is_empty() { - return Err( - "[frontends.http.auth].control_passphrase must not be empty if set".to_string(), - ); - } - } - - // Session TTL must be > 0 - if auth.session_ttl_min == 0 { - return Err("[frontends.http.auth].session_ttl_min must be > 0".to_string()); - } - - Ok(()) -} - -impl ConfigFile for ClientConfig { - fn section_key() -> &'static str { - "trx-client" - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_default_config() { - 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.http.initial_map_zoom, 10); - assert_eq!(config.frontends.http.spectrum_coverage_margin_hz, 50_000); - assert_eq!(config.frontends.http.spectrum_usable_span_ratio, 0.92); - assert!(config.frontends.http.bandplan_enabled); - assert_eq!(config.frontends.http.bandplan_region, "iaru_r1"); - assert_eq!(config.frontends.http.decode_history_retention_min, 1440); - assert!(config - .frontends - .http - .decode_history_retention_min_by_rig - .is_empty()); - assert_eq!(config.frontends.rigctl.port, 4532); - assert!(config.frontends.http_json.enabled); - assert_eq!(config.frontends.http_json.port, 0); - assert!(config.remote.url.is_none()); - assert!(config.general.website_url.is_none()); - assert!(config.general.website_name.is_none()); - assert_eq!( - config.general.ais_vessel_url_base, - Some("https://www.vesselfinder.com/?mmsi=".to_string()) - ); - assert_eq!(config.remote.poll_interval_ms, 750); - assert!(config.frontends.audio.enabled); - assert!(config.frontends.audio.server_url.is_none()); - assert!(config.frontends.audio.rig_urls.is_empty()); - assert_eq!(config.frontends.audio.server_port, 4531); - assert!(config.frontends.audio.rig_ports.is_empty()); - assert!(!config.frontends.audio.bridge.enabled); - assert_eq!(config.frontends.audio.bridge.rx_gain, 1.0); - assert_eq!(config.frontends.audio.bridge.tx_gain, 1.0); - } - - #[test] - fn test_parse_client_toml() { - let toml_str = r#" -[general] -callsign = "W1AW" -website_url = "https://example.com" -website_name = "Example" -ais_vessel_url_base = "https://example.com/vessel/" - -[remote] -url = "192.168.1.100:9000" -rig_id = "hf" -auth.token = "my-token" -poll_interval_ms = 500 - -[frontends.http] -enabled = true -listen = "127.0.0.1" -port = 8080 -initial_map_zoom = 12 -spectrum_coverage_margin_hz = 40000 -spectrum_usable_span_ratio = 0.9 -decode_history_retention_min = 720 - -[frontends.http.decode_history_retention_min_by_rig] -vhf = 180 -uhf = 60 - -"#; - - let config: ClientConfig = toml::from_str(toml_str).unwrap(); - assert_eq!(config.general.callsign, Some("W1AW".to_string())); - assert_eq!( - config.general.website_url, - Some("https://example.com".to_string()) - ); - assert_eq!(config.general.website_name, Some("Example".to_string())); - assert_eq!( - config.general.ais_vessel_url_base, - Some("https://example.com/vessel/".to_string()) - ); - assert_eq!(config.remote.url, Some("192.168.1.100:9000".to_string())); - assert_eq!(config.remote.rig_id, Some("hf".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_eq!(config.frontends.http.initial_map_zoom, 12); - assert_eq!(config.frontends.http.spectrum_coverage_margin_hz, 40_000); - assert_eq!(config.frontends.http.spectrum_usable_span_ratio, 0.9); - // bandplan fields not set in TOML → defaults - assert!(config.frontends.http.bandplan_enabled); - assert_eq!(config.frontends.http.bandplan_region, "iaru_r1"); - assert_eq!(config.frontends.http.decode_history_retention_min, 720); - assert_eq!( - config - .frontends - .http - .decode_history_retention_min_by_rig - .get("vhf"), - Some(&180) - ); - assert_eq!( - config - .frontends - .http - .decode_history_retention_min_by_rig - .get("uhf"), - Some(&60) - ); - } - - #[test] - fn test_parse_client_toml_with_audio_urls() { - let toml_str = r#" -[frontends.audio] -enabled = true -server_url = "tcp://audio.example.com" - -[frontends.audio.rig_urls] -home-hf = "audio://10.0.0.5:4600" -"#; - - let config: ClientConfig = toml::from_str(toml_str).unwrap(); - assert_eq!( - config.frontends.audio.server_url, - Some("tcp://audio.example.com".to_string()) - ); - assert_eq!( - config.frontends.audio.rig_urls.get("home-hf"), - Some(&"audio://10.0.0.5:4600".to_string()) - ); - } - - #[test] - fn test_example_combined_toml_parses() { - let example = ClientConfig::example_combined_toml(); - let table: toml::Table = toml::from_str(&example).unwrap(); - let section = toml::to_string(table.get("trx-client").unwrap()).unwrap(); - let _config: ClientConfig = toml::from_str(§ion).unwrap(); - } - - #[test] - fn test_validate_rejects_zero_poll_interval() { - let mut config = ClientConfig::default(); - config.remote.poll_interval_ms = 0; - assert!(config.validate().is_err()); - } - - #[test] - fn test_validate_rejects_empty_remote_rig_id() { - let mut config = ClientConfig::default(); - config.remote.rig_id = Some(" ".to_string()); - assert!(config.validate().is_err()); - } - - #[test] - fn test_validate_rejects_empty_http_json_token() { - let mut config = ClientConfig::default(); - config.frontends.http_json.auth.tokens = vec!["".to_string()]; - assert!(config.validate().is_err()); - } - - #[test] - fn test_validate_rejects_zero_audio_rig_port() { - let mut config = ClientConfig::default(); - config - .frontends - .audio - .rig_ports - .insert("ft817".to_string(), 0); - assert!(config.validate().is_err()); - } - - #[test] - fn test_validate_rejects_invalid_audio_url() { - let mut config = ClientConfig::default(); - config.frontends.audio.server_url = Some("tcp://:4531".to_string()); - assert!(config.validate().is_err()); - } - - #[test] - fn test_validate_accepts_audio_url_without_server_port() { - let mut config = ClientConfig::default(); - config.frontends.audio.server_url = Some("audio.example.com".to_string()); - config.frontends.audio.server_port = 0; - assert!(config.validate().is_ok()); - } - - #[test] - fn test_validate_rejects_http_auth_enabled_without_passphrases() { - let mut config = ClientConfig::default(); - config.frontends.http.auth.enabled = true; - assert!(config.validate().is_err()); - } - - #[test] - fn test_validate_accepts_http_auth_with_rx_passphrase() { - let mut config = ClientConfig::default(); - config.frontends.http.auth.enabled = true; - config.frontends.http.auth.rx_passphrase = Some("rx-secret".to_string()); - assert!(config.validate().is_ok()); - } - - #[test] - fn test_validate_accepts_http_auth_with_control_passphrase() { - let mut config = ClientConfig::default(); - config.frontends.http.auth.enabled = true; - config.frontends.http.auth.control_passphrase = Some("control-secret".to_string()); - assert!(config.validate().is_ok()); - } - - #[test] - fn test_validate_accepts_http_auth_with_both_passphrases() { - let mut config = ClientConfig::default(); - config.frontends.http.auth.enabled = true; - config.frontends.http.auth.rx_passphrase = Some("rx-secret".to_string()); - config.frontends.http.auth.control_passphrase = Some("control-secret".to_string()); - assert!(config.validate().is_ok()); - } - - #[test] - fn test_validate_rejects_empty_rx_passphrase() { - let mut config = ClientConfig::default(); - config.frontends.http.auth.enabled = true; - config.frontends.http.auth.rx_passphrase = Some("".to_string()); - assert!(config.validate().is_err()); - } - - #[test] - fn test_validate_rejects_zero_session_ttl() { - let mut config = ClientConfig::default(); - config.frontends.http.auth.enabled = true; - config.frontends.http.auth.rx_passphrase = Some("rx-secret".to_string()); - config.frontends.http.auth.session_ttl_min = 0; - assert!(config.validate().is_err()); - } - - #[test] - fn test_validate_auth_disabled_ignores_passphrases() { - let mut config = ClientConfig::default(); - config.frontends.http.auth.enabled = false; - config.frontends.http.auth.rx_passphrase = Some("".to_string()); - assert!(config.validate().is_ok()); - } - - #[test] - fn test_http_auth_config_default() { - let auth = HttpAuthConfig::default(); - assert!(!auth.enabled); - assert!(auth.rx_passphrase.is_none()); - assert!(auth.control_passphrase.is_none()); - assert!(auth.tx_access_control_enabled); - assert_eq!(auth.session_ttl_min, 480); - assert!(!auth.cookie_secure); - assert!(matches!(auth.cookie_same_site, CookieSameSite::Lax)); - } - - #[test] - fn test_http_auth_session_ttl_conversion() { - let auth = HttpAuthConfig { - session_ttl_min: 60, - ..Default::default() - }; - assert_eq!(auth.session_ttl().as_secs(), 3600); - } - - #[test] - fn test_parse_remotes_toml() { - let toml_str = r#" -[[remotes]] -name = "home-hf" -url = "192.168.1.10:4530" -rig_id = "hf" -poll_interval_ms = 500 - -[remotes.auth] -token = "secret" - -[[remotes]] -name = "remote" -url = "remote.example.com:4530" -"#; - - let config: ClientConfig = toml::from_str(toml_str).unwrap(); - assert_eq!(config.remotes.len(), 2); - assert_eq!(config.remotes[0].name, "home-hf"); - assert_eq!(config.remotes[0].url, "192.168.1.10:4530"); - assert_eq!(config.remotes[0].rig_id, Some("hf".to_string())); - assert_eq!(config.remotes[0].auth.token, Some("secret".to_string())); - assert_eq!(config.remotes[0].poll_interval_ms, 500); - assert_eq!(config.remotes[1].name, "remote"); - assert_eq!(config.remotes[1].url, "remote.example.com:4530"); - assert!(config.remotes[1].rig_id.is_none()); - assert!(config.remotes[1].auth.token.is_none()); - assert_eq!(config.remotes[1].poll_interval_ms, 750); // default - assert!(config.validate().is_ok()); - } - - #[test] - fn test_resolved_remotes_from_remotes() { - let config = ClientConfig { - remotes: vec![RemoteEntry { - name: "hf".to_string(), - url: "host:4530".to_string(), - rig_id: None, - auth: RemoteAuthConfig::default(), - poll_interval_ms: 750, - }], - ..Default::default() - }; - let resolved = config.resolved_remotes(); - assert_eq!(resolved.len(), 1); - assert_eq!(resolved[0].name, "hf"); - } - - #[test] - fn test_resolved_remotes_legacy_fallback() { - let config = ClientConfig { - remote: RemoteConfig { - url: Some("host:4530".to_string()), - rig_id: Some("hf".to_string()), - auth: RemoteAuthConfig { - token: Some("tok".to_string()), - }, - poll_interval_ms: 750, - }, - ..Default::default() - }; - let resolved = config.resolved_remotes(); - assert_eq!(resolved.len(), 1); - assert_eq!(resolved[0].name, "hf"); - assert_eq!(resolved[0].url, "host:4530"); - assert_eq!(resolved[0].rig_id, Some("hf".to_string())); - assert_eq!(resolved[0].auth.token, Some("tok".to_string())); - } - - #[test] - fn test_resolved_remotes_legacy_default_name() { - let config = ClientConfig { - remote: RemoteConfig { - url: Some("host:4530".to_string()), - rig_id: None, - ..Default::default() - }, - ..Default::default() - }; - let resolved = config.resolved_remotes(); - assert_eq!(resolved[0].name, "default"); - } - - #[test] - fn test_resolved_remotes_prefers_remotes_over_legacy() { - let config = ClientConfig { - remote: RemoteConfig { - url: Some("old:4530".to_string()), - ..Default::default() - }, - remotes: vec![RemoteEntry { - name: "new".to_string(), - url: "new:4530".to_string(), - rig_id: None, - auth: RemoteAuthConfig::default(), - poll_interval_ms: 750, - }], - ..Default::default() - }; - let resolved = config.resolved_remotes(); - assert_eq!(resolved.len(), 1); - assert_eq!(resolved[0].name, "new"); - } - - #[test] - fn test_validate_rejects_duplicate_remote_names() { - let config = ClientConfig { - remotes: vec![ - RemoteEntry { - name: "dup".to_string(), - url: "a:4530".to_string(), - rig_id: None, - auth: RemoteAuthConfig::default(), - poll_interval_ms: 750, - }, - RemoteEntry { - name: "dup".to_string(), - url: "b:4530".to_string(), - rig_id: None, - auth: RemoteAuthConfig::default(), - poll_interval_ms: 750, - }, - ], - ..Default::default() - }; - assert!(config.validate().unwrap_err().contains("duplicate name")); - } - - #[test] - fn test_validate_rejects_empty_remote_name() { - let config = ClientConfig { - remotes: vec![RemoteEntry { - name: "".to_string(), - url: "a:4530".to_string(), - rig_id: None, - auth: RemoteAuthConfig::default(), - poll_interval_ms: 750, - }], - ..Default::default() - }; - assert!(config - .validate() - .unwrap_err() - .contains("name must not be empty")); - } - - #[test] - fn test_validate_rejects_empty_remote_url() { - let config = ClientConfig { - remotes: vec![RemoteEntry { - name: "hf".to_string(), - url: " ".to_string(), - rig_id: None, - auth: RemoteAuthConfig::default(), - poll_interval_ms: 750, - }], - ..Default::default() - }; - assert!(config - .validate() - .unwrap_err() - .contains("url must not be empty")); - } - - #[test] - fn test_validate_rejects_zero_remote_poll_interval() { - let config = ClientConfig { - remotes: vec![RemoteEntry { - name: "hf".to_string(), - url: "a:4530".to_string(), - rig_id: None, - auth: RemoteAuthConfig::default(), - poll_interval_ms: 0, - }], - ..Default::default() - }; - assert!(config - .validate() - .unwrap_err() - .contains("poll_interval_ms must be > 0")); - } - - #[test] - fn test_validate_rejects_invalid_bandplan_region() { - let mut config = ClientConfig::default(); - config.frontends.http.bandplan_region = "invalid".to_string(); - assert!(config.validate().is_err()); - } - - #[test] - fn test_validate_accepts_all_bandplan_regions() { - for region in &["iaru_r1", "iaru_r2", "iaru_r3"] { - let mut config = ClientConfig::default(); - config.frontends.http.bandplan_region = region.to_string(); - assert!( - config.validate().is_ok(), - "region {} should be valid", - region - ); - } - } - - #[test] - fn test_parse_bandplan_config_from_toml() { - let toml_str = r#" -[frontends.http] -bandplan_enabled = false -bandplan_region = "iaru_r2" -"#; - let config: ClientConfig = toml::from_str(toml_str).unwrap(); - assert!(!config.frontends.http.bandplan_enabled); - assert_eq!(config.frontends.http.bandplan_region, "iaru_r2"); - } -} +pub use trx_config::client::*; diff --git a/src/trx-client/src/remote_client.rs b/src/trx-client/src/remote_client.rs index 5e235f72..2f2d741d 100644 --- a/src/trx-client/src/remote_client.rs +++ b/src/trx-client/src/remote_client.rs @@ -22,30 +22,16 @@ use trx_protocol::rig_command_to_client; use trx_protocol::types::RigEntry; use trx_protocol::{ClientCommand, ClientEnvelope, ClientResponse, MeterUpdate}; -const DEFAULT_REMOTE_PORT: u16 = 4530; -const DEFAULT_AUDIO_PORT: u16 = 4531; +// Endpoint parsing lives in `trx-config` so config validation and the +// connection code agree on what a URL means. +pub use trx_config::url::{parse_audio_url, parse_remote_url, RemoteEndpoint}; + const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); const IO_TIMEOUT: Duration = Duration::from_secs(15); const SPECTRUM_IO_TIMEOUT: Duration = Duration::from_secs(3); const MAX_JSON_LINE_BYTES: usize = 256 * 1024; const MAX_CONSECUTIVE_POLL_FAILURES: u32 = 3; -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RemoteEndpoint { - pub host: String, - pub port: u16, -} - -impl RemoteEndpoint { - pub fn connect_addr(&self) -> String { - if self.host.contains(':') && !self.host.starts_with('[') { - format!("[{}]:{}", self.host, self.port) - } else { - format!("{}:{}", self.host, self.port) - } - } -} - // Keep remote spectrum reasonably responsive without returning to the old // timeout churn caused by a much tighter request cadence. const SPECTRUM_POLL_INTERVAL: Duration = Duration::from_millis(50); @@ -1195,86 +1181,6 @@ async fn read_limited_line( } } -pub fn parse_remote_url(url: &str) -> Result { - parse_endpoint_url(url, DEFAULT_REMOTE_PORT, "remote") -} - -pub fn parse_audio_url(url: &str) -> Result { - parse_endpoint_url(url, DEFAULT_AUDIO_PORT, "audio") -} - -fn parse_endpoint_url(url: &str, default_port: u16, kind: &str) -> Result { - let trimmed = url.trim(); - if trimmed.is_empty() { - return Err(format!("{kind} url is empty")); - } - - let addr = trimmed - .strip_prefix("tcp://") - .or_else(|| trimmed.strip_prefix("http-json://")) - .or_else(|| trimmed.strip_prefix("audio://")) - .unwrap_or(trimmed); - - parse_host_port(addr, default_port, kind) -} - -fn parse_host_port(input: &str, default_port: u16, kind: &str) -> Result { - if let Some(rest) = input.strip_prefix('[') { - let closing = rest - .find(']') - .ok_or_else(|| format!("invalid {kind} url: missing closing ']' for IPv6 host"))?; - let host = &rest[..closing]; - let remainder = &rest[closing + 1..]; - if host.is_empty() { - return Err(format!("invalid {kind} url: host is empty")); - } - let port = if remainder.is_empty() { - default_port - } else if let Some(port_str) = remainder.strip_prefix(':') { - parse_port(port_str, kind)? - } else { - return Err(format!("invalid {kind} url: expected ':' after ']'")); - }; - return Ok(RemoteEndpoint { - host: host.to_string(), - port, - }); - } - - if input.contains(':') { - if input.matches(':').count() > 1 { - return Err(format!( - "invalid {kind} url: IPv6 host must be bracketed like [::1]:4532" - )); - } - let (host, port_str) = input - .rsplit_once(':') - .ok_or_else(|| format!("invalid {kind} url: expected host:port"))?; - if host.is_empty() { - return Err(format!("invalid {kind} url: host is empty")); - } - return Ok(RemoteEndpoint { - host: host.to_string(), - port: parse_port(port_str, kind)?, - }); - } - - Ok(RemoteEndpoint { - host: input.to_string(), - port: default_port, - }) -} - -fn parse_port(port_str: &str, kind: &str) -> Result { - let port: u16 = port_str - .parse() - .map_err(|_| format!("invalid {kind} port: '{port_str}'"))?; - if port == 0 { - return Err(format!("invalid {kind} port: 0")); - } - Ok(port) -} - #[cfg(test)] mod tests { #[allow(unused_imports)] diff --git a/src/trx-config/Cargo.toml b/src/trx-config/Cargo.toml new file mode 100644 index 00000000..6e23f93c --- /dev/null +++ b/src/trx-config/Cargo.toml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: 2026 Stan Grams +# +# SPDX-License-Identifier: GPL-2.0-or-later + +[package] +name = "trx-config" +version.workspace = true +edition = "2021" +license = "GPL-2.0-or-later" + +[dependencies] +serde = { workspace = true, features = ["derive"] } +toml = { workspace = true } +tracing = { workspace = true } +dirs = "6" +thiserror = "2" +trx-core = { path = "../trx-core" } +trx-decode-log = { path = "../decoders/trx-decode-log" } +trx-reporting = { path = "../trx-reporting" } diff --git a/src/trx-config/src/client.rs b/src/trx-config/src/client.rs new file mode 100644 index 00000000..6722119d --- /dev/null +++ b/src/trx-config/src/client.rs @@ -0,0 +1,1221 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +//! Configuration file support for trx-client. +//! +//! Config is loaded from the `[trx-client]` section of `trx-rs.toml`. +//! Default search order: +//! 1. Path specified via `--config` CLI argument +//! 2. `./trx-rs.toml` +//! 3. `~/.config/trx-rs/trx-rs.toml` +//! 4. `/etc/trx-rs/trx-rs.toml` + +use std::collections::HashMap; +use std::net::IpAddr; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use crate::file::{ConfigError, ConfigFile}; +use crate::shared::{validate_log_level, validate_tokens}; + +/// Top-level client configuration structure. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct ClientConfig { + /// General settings + pub general: GeneralConfig, + /// Legacy single remote connection settings. + /// Kept for backward compatibility; prefer `[[remotes]]`. + pub remote: RemoteConfig, + /// Named remote connections (one per short-name / rig). + /// Each entry maps a user-chosen short name to a (server, rig_id) pair. + pub remotes: Vec, + /// Frontend configurations + pub frontends: FrontendsConfig, +} + +/// General application settings. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct GeneralConfig { + /// Callsign or owner label to display in frontends + pub callsign: Option, + /// Optional website URL to use as the web UI header title link. + pub website_url: Option, + /// Optional website name to use as the web UI header title label. + pub website_name: Option, + /// Optional base URL used to link AIS vessel names as ``. + pub ais_vessel_url_base: Option, + /// Log level (trace, debug, info, warn, error) + pub log_level: Option, +} + +impl Default for GeneralConfig { + fn default() -> Self { + Self { + callsign: Some("N0CALL".to_string()), + website_url: None, + website_name: None, + ais_vessel_url_base: Some("https://www.vesselfinder.com/?mmsi=".to_string()), + log_level: None, + } + } +} + +/// Remote connection configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct RemoteConfig { + /// Remote URL (host:port or tcp://host:port). + pub url: Option, + /// Optional target rig ID on the remote multi-rig server. + pub rig_id: Option, + /// Remote auth settings. + pub auth: RemoteAuthConfig, + /// Poll interval in milliseconds. + pub poll_interval_ms: u64, +} + +impl Default for RemoteConfig { + fn default() -> Self { + Self { + url: None, + rig_id: None, + auth: RemoteAuthConfig::default(), + poll_interval_ms: 750, + } + } +} + +/// 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, +} + +/// A named remote connection entry. +/// +/// Each entry maps a user-chosen **short name** to a `(server, rig_id)` pair. +/// The short name is used as the identifier throughout frontends (HTTP rig +/// picker, rigctl ports, audio routing, etc.) instead of the server-scoped +/// `rig_id`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RemoteEntry { + /// Short name used to identify this remote in frontends and config. + pub name: String, + /// Remote server URL (`host:port` or `tcp://host:port`). + pub url: String, + /// Optional target rig ID on a multi-rig server. + /// When omitted, the server's default (or only) rig is used. + pub rig_id: Option, + /// Authentication settings. + #[serde(default)] + pub auth: RemoteAuthConfig, + /// Poll interval in milliseconds. Defaults to 750. + #[serde(default = "default_poll_interval_ms")] + pub poll_interval_ms: u64, +} + +fn default_poll_interval_ms() -> u64 { + 750 +} + +/// Frontend configurations. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct FrontendsConfig { + /// HTTP frontend settings + pub http: HttpFrontendConfig, + /// rigctl frontend settings + pub rigctl: RigctlFrontendConfig, + /// JSON TCP frontend settings + pub http_json: HttpJsonFrontendConfig, + /// Audio streaming settings + pub audio: AudioClientConfig, +} + +/// Audio streaming client configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct AudioClientConfig { + /// Whether audio streaming is enabled + pub enabled: bool, + /// Optional exact audio TCP URL override applied to all remotes. + /// When set, this takes precedence over `server_port`. + pub server_url: Option, + /// Optional per-rig audio URL overrides keyed by remote short name. + /// These take precedence over `server_url`, server-advertised ports, and + /// the legacy `rig_ports` map. + pub rig_urls: HashMap, + /// Legacy audio TCP port fallback on the remote server when no URL override + /// is configured and the server does not advertise a per-rig audio port. + pub server_port: u16, + /// Legacy per-rig audio port overrides for multi-rig servers. + /// Prefer `rig_urls` when the audio endpoint differs by host as well. + pub rig_ports: HashMap, + /// Local audio bridge (virtual device integration) + pub bridge: AudioBridgeConfig, +} + +impl Default for AudioClientConfig { + fn default() -> Self { + Self { + enabled: true, + server_url: None, + rig_urls: HashMap::new(), + server_port: 4531, + rig_ports: HashMap::new(), + bridge: AudioBridgeConfig::default(), + } + } +} + +/// Local audio bridge configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct AudioBridgeConfig { + /// Enable local cpal bridge between remote stream and local audio devices. + pub enabled: bool, + /// Local output device for remote RX playback. + pub rx_output_device: Option, + /// Local input device for TX uplink capture. + pub tx_input_device: Option, + /// Opus bitrate in bits per second for TX uplink capture. + pub bitrate_bps: u32, + /// RX playback gain multiplier. + pub rx_gain: f32, + /// TX capture gain multiplier. + pub tx_gain: f32, +} + +impl Default for AudioBridgeConfig { + fn default() -> Self { + Self { + enabled: false, + rx_output_device: None, + tx_input_device: None, + bitrate_bps: 192000, + rx_gain: 1.0, + tx_gain: 1.0, + } + } +} + +/// Cookie SameSite attribute options. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)] +#[serde(rename_all = "PascalCase")] +pub enum CookieSameSite { + /// Strict: cookie only sent in same-site context + Strict, + /// Lax: cookie sent with top-level navigation (default) + #[default] + Lax, + /// None: cookie sent in all contexts (requires Secure=true) + None, +} + +impl AsRef for CookieSameSite { + fn as_ref(&self) -> &str { + match self { + Self::Strict => "Strict", + Self::Lax => "Lax", + Self::None => "None", + } + } +} + +/// HTTP frontend authentication configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct HttpAuthConfig { + /// Enable HTTP frontend authentication + pub enabled: bool, + /// Passphrase for read-only access (rx role) + pub rx_passphrase: Option, + /// Passphrase for full control access (control role) + pub control_passphrase: Option, + /// Enforce TX/PTT access control (hide from unauthenticated/rx users) + pub tx_access_control_enabled: bool, + /// Session time-to-live in minutes + pub session_ttl_min: u64, + /// Set Secure flag on session cookie (required for HTTPS) + pub cookie_secure: bool, + /// SameSite attribute for session cookie + pub cookie_same_site: CookieSameSite, +} + +impl Default for HttpAuthConfig { + fn default() -> Self { + Self { + enabled: false, + rx_passphrase: None, + control_passphrase: None, + tx_access_control_enabled: true, + session_ttl_min: 480, + cookie_secure: false, + cookie_same_site: CookieSameSite::Lax, + } + } +} + +impl HttpAuthConfig { + /// Convert session TTL from minutes to Duration. + pub fn session_ttl(&self) -> Duration { + Duration::from_secs(self.session_ttl_min * 60) + } +} + +/// HTTP frontend configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct HttpFrontendConfig { + /// Whether HTTP frontend is enabled + pub enabled: bool, + /// Listen address + pub listen: IpAddr, + /// Listen port + pub port: u16, + /// Default rig selected in the web UI on startup. + #[serde(alias = "default_rig_id")] + pub default_rig_name: Option, + /// Initial zoom level for the APRS map when receiver coordinates are known. + pub initial_map_zoom: u8, + /// Spectrum center-retune guard margin on each side of the selected bandwidth. + pub spectrum_coverage_margin_hz: u32, + /// Fraction of the sampled spectrum span treated as usable for center-retune logic. + pub spectrum_usable_span_ratio: f32, + /// Whether to expose the RF Gain control in the web UI. + pub show_sdr_gain_control: bool, + /// Whether the bandplan strip is shown by default in the spectrum display. + pub bandplan_enabled: bool, + /// Default bandplan region: "iaru_r1", "iaru_r2", or "iaru_r3". + pub bandplan_region: String, + /// Default decode history retention in minutes for the active rig. + pub decode_history_retention_min: u64, + /// Optional per-rig decode history retention overrides in minutes. + pub decode_history_retention_min_by_rig: HashMap, + /// Authentication settings + pub auth: HttpAuthConfig, +} + +impl Default for HttpFrontendConfig { + fn default() -> Self { + Self { + enabled: true, + listen: IpAddr::from([127, 0, 0, 1]), + port: 8080, + default_rig_name: None, + initial_map_zoom: 10, + spectrum_coverage_margin_hz: 50_000, + spectrum_usable_span_ratio: 0.92, + show_sdr_gain_control: true, + bandplan_enabled: true, + bandplan_region: "iaru_r1".to_string(), + decode_history_retention_min: 24 * 60, + decode_history_retention_min_by_rig: HashMap::new(), + auth: HttpAuthConfig::default(), + } + } +} + +/// rigctl frontend configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct RigctlFrontendConfig { + /// Whether rigctl frontend is enabled + pub enabled: bool, + /// Listen address + pub listen: IpAddr, + /// Legacy shared-listener port. Ignored; per-rig ports must be configured. + pub port: u16, + /// Per-rig rigctl listener ports. + /// Maps rig ID -> local rigctl port. One rigctl listener is spawned per + /// entry, each routing commands to its assigned rig. + pub rig_ports: HashMap, +} + +impl Default for RigctlFrontendConfig { + fn default() -> Self { + Self { + enabled: false, + listen: IpAddr::from([127, 0, 0, 1]), + port: 4532, + rig_ports: HashMap::new(), + } + } +} + +/// JSON TCP frontend configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct HttpJsonFrontendConfig { + /// Whether JSON TCP frontend is enabled + pub enabled: bool, + /// Listen address + pub listen: IpAddr, + /// Listen port (0 = ephemeral) + pub port: u16, + /// Authorization settings + pub auth: HttpJsonAuthConfig, +} + +impl Default for HttpJsonFrontendConfig { + fn default() -> Self { + Self { + enabled: true, + listen: IpAddr::from([127, 0, 0, 1]), + port: 0, + auth: HttpJsonAuthConfig::default(), + } + } +} + +/// Authorization settings for JSON TCP frontend. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct HttpJsonAuthConfig { + /// Accepted bearer tokens. + pub tokens: Vec, +} + +impl ClientConfig { + /// Return the effective list of remote entries. + /// + /// If `[[remotes]]` is non-empty, return it directly. Otherwise, + /// synthesize a single entry from the legacy `[remote]` section (if it + /// has a `url`). Returns an empty `Vec` when neither is configured + /// (caller should check CLI overrides). + pub fn resolved_remotes(&self) -> Vec { + if !self.remotes.is_empty() { + return self.remotes.clone(); + } + // Legacy fallback + if let Some(url) = &self.remote.url { + let name = self + .remote + .rig_id + .clone() + .unwrap_or_else(|| "default".to_string()); + vec![RemoteEntry { + name, + url: url.clone(), + rig_id: self.remote.rig_id.clone(), + auth: self.remote.auth.clone(), + poll_interval_ms: self.remote.poll_interval_ms, + }] + } else { + Vec::new() + } + } + + pub fn validate(&self) -> Result<(), String> { + validate_log_level(self.general.log_level.as_deref())?; + + // Validate [[remotes]] entries + { + let mut seen_names = std::collections::HashSet::new(); + for (i, entry) in self.remotes.iter().enumerate() { + if entry.name.trim().is_empty() { + return Err(format!("[[remotes]][{}].name must not be empty", i)); + } + if !seen_names.insert(&entry.name) { + return Err(format!("[[remotes]] duplicate name \"{}\"", entry.name)); + } + if entry.url.trim().is_empty() { + return Err(format!( + "[[remotes]][{}].url must not be empty (name \"{}\")", + i, entry.name + )); + } + if let Some(rig_id) = &entry.rig_id { + if rig_id.trim().is_empty() { + return Err(format!( + "[[remotes]][{}].rig_id must not be empty when set (name \"{}\")", + i, entry.name + )); + } + } + if let Some(token) = &entry.auth.token { + if token.trim().is_empty() { + return Err(format!( + "[[remotes]][{}].auth.token must not be empty when set (name \"{}\")", + i, entry.name + )); + } + } + if entry.poll_interval_ms == 0 { + return Err(format!( + "[[remotes]][{}].poll_interval_ms must be > 0 (name \"{}\")", + i, entry.name + )); + } + } + } + + // Validate legacy [remote] (kept for backward compat) + if self.remote.poll_interval_ms == 0 { + return Err("[remote].poll_interval_ms must be > 0".to_string()); + } + if let Some(url) = &self.remote.url { + if url.trim().is_empty() { + return Err("[remote].url must not be empty when set".to_string()); + } + } + if let Some(rig_id) = &self.remote.rig_id { + if rig_id.trim().is_empty() { + return Err("[remote].rig_id must not be empty when set".to_string()); + } + } + if let Some(token) = &self.remote.auth.token { + if token.trim().is_empty() { + return Err("[remote.auth].token must not be empty when set".to_string()); + } + } + if let Some(url) = &self.general.website_url { + if url.trim().is_empty() { + return Err("[general].website_url must not be empty when set".to_string()); + } + } + if let Some(name) = &self.general.website_name { + if name.trim().is_empty() { + return Err("[general].website_name must not be empty when set".to_string()); + } + } + if let Some(url) = &self.general.ais_vessel_url_base { + if url.trim().is_empty() { + return Err("[general].ais_vessel_url_base must not be empty when set".to_string()); + } + } + + if self.frontends.http.enabled && self.frontends.http.port == 0 { + return Err("[frontends.http].port must be > 0 when enabled".to_string()); + } + if let Some(rig_id) = &self.frontends.http.default_rig_name { + if rig_id.trim().is_empty() { + return Err( + "[frontends.http].default_rig_name must not be empty when set".to_string(), + ); + } + } + if self.frontends.http.initial_map_zoom == 0 { + return Err("[frontends.http].initial_map_zoom must be > 0".to_string()); + } + if self.frontends.http.spectrum_coverage_margin_hz == 0 { + return Err("[frontends.http].spectrum_coverage_margin_hz must be > 0".to_string()); + } + if !(self.frontends.http.spectrum_usable_span_ratio > 0.0 + && self.frontends.http.spectrum_usable_span_ratio <= 1.0) + { + return Err( + "[frontends.http].spectrum_usable_span_ratio must be > 0.0 and <= 1.0".to_string(), + ); + } + match self.frontends.http.bandplan_region.as_str() { + "iaru_r1" | "iaru_r2" | "iaru_r3" => {} + other => { + return Err(format!( + "[frontends.http].bandplan_region must be \"iaru_r1\", \"iaru_r2\", or \"iaru_r3\", got \"{}\"", + other + )); + } + } + if self.frontends.http.decode_history_retention_min == 0 { + return Err("[frontends.http].decode_history_retention_min must be > 0".to_string()); + } + for (rig_id, minutes) in &self.frontends.http.decode_history_retention_min_by_rig { + if rig_id.trim().is_empty() { + return Err( + "[frontends.http].decode_history_retention_min_by_rig keys must not be empty" + .to_string(), + ); + } + if *minutes == 0 { + return Err(format!( + "[frontends.http].decode_history_retention_min_by_rig[\"{}\"] must be > 0", + rig_id + )); + } + } + if self.frontends.rigctl.enabled && self.frontends.rigctl.rig_ports.is_empty() { + return Err( + "[frontends.rigctl].rig_ports must contain at least one rig when enabled" + .to_string(), + ); + } + for (rig_id, port) in &self.frontends.rigctl.rig_ports { + if rig_id.trim().is_empty() { + return Err("[frontends.rigctl].rig_ports keys must not be empty".to_string()); + } + if *port == 0 { + return Err(format!( + "[frontends.rigctl].rig_ports[\"{}\"] must be > 0", + rig_id + )); + } + } + if let Some(url) = &self.frontends.audio.server_url { + crate::url::parse_audio_url(url) + .map_err(|e| format!("[frontends.audio].server_url {e}"))?; + } + if self.frontends.audio.enabled + && self.frontends.audio.server_url.is_none() + && self.frontends.audio.server_port == 0 + { + return Err("[frontends.audio].server_port must be > 0 when enabled".to_string()); + } + for (rig_id, url) in &self.frontends.audio.rig_urls { + if rig_id.trim().is_empty() { + return Err("[frontends.audio].rig_urls keys must not be empty".to_string()); + } + crate::url::parse_audio_url(url) + .map_err(|e| format!("[frontends.audio].rig_urls[\"{rig_id}\"] {e}"))?; + } + for (rig_id, port) in &self.frontends.audio.rig_ports { + if rig_id.trim().is_empty() { + return Err("[frontends.audio].rig_ports keys must not be empty".to_string()); + } + if *port == 0 { + return Err(format!( + "[frontends.audio].rig_ports[\"{}\"] must be > 0", + rig_id + )); + } + } + if !self.frontends.audio.bridge.rx_gain.is_finite() + || self.frontends.audio.bridge.rx_gain < 0.0 + { + return Err("[frontends.audio.bridge].rx_gain must be finite and >= 0".to_string()); + } + if !self.frontends.audio.bridge.tx_gain.is_finite() + || self.frontends.audio.bridge.tx_gain < 0.0 + { + return Err("[frontends.audio.bridge].tx_gain must be finite and >= 0".to_string()); + } + if self.frontends.audio.bridge.bitrate_bps == 0 { + return Err("[frontends.audio.bridge].bitrate_bps must be > 0".to_string()); + } + validate_tokens( + "[frontends.http_json.auth].tokens", + &self.frontends.http_json.auth.tokens, + )?; + + validate_http_auth(&self.frontends.http.auth)?; + + Ok(()) + } + + /// Load configuration from a specific file path. + pub fn load_from_file(path: &Path) -> Result { + ::load_from_file(path) + } + + /// 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> { + ::load_from_default_paths() + } + + /// Generate an example configuration wrapped under the `[trx-client]` + /// section header, suitable for use in a combined `trx-rs.toml` file. + pub fn example_combined_toml() -> String { + #[derive(serde::Serialize)] + struct Wrapper { + #[serde(rename = "trx-client")] + inner: ClientConfig, + } + let example = ClientConfig { + general: GeneralConfig { + callsign: Some("N0CALL".to_string()), + website_url: Some("https://haxx.space".to_string()), + website_name: Some("haxx.space".to_string()), + ais_vessel_url_base: Some("https://www.vesselfinder.com/?mmsi=".to_string()), + log_level: Some("info".to_string()), + }, + remote: RemoteConfig::default(), + remotes: vec![ + RemoteEntry { + name: "home-hf".to_string(), + url: "192.168.1.100:4530".to_string(), + rig_id: Some("hf".to_string()), + auth: RemoteAuthConfig { + token: Some("my-token".to_string()), + }, + poll_interval_ms: 750, + }, + RemoteEntry { + name: "home-vhf".to_string(), + url: "192.168.1.100:4530".to_string(), + rig_id: Some("vhf".to_string()), + auth: RemoteAuthConfig { + token: Some("my-token".to_string()), + }, + poll_interval_ms: 750, + }, + ], + frontends: FrontendsConfig { + http: HttpFrontendConfig { + enabled: true, + listen: IpAddr::from([127, 0, 0, 1]), + port: 8080, + default_rig_name: Some("home-hf".to_string()), + initial_map_zoom: 10, + spectrum_coverage_margin_hz: 50_000, + spectrum_usable_span_ratio: 0.92, + show_sdr_gain_control: true, + bandplan_enabled: true, + bandplan_region: "iaru_r1".to_string(), + decode_history_retention_min: 24 * 60, + decode_history_retention_min_by_rig: HashMap::new(), + auth: HttpAuthConfig { + enabled: false, + rx_passphrase: Some("rx-passphrase-example".to_string()), + control_passphrase: Some("control-passphrase-example".to_string()), + tx_access_control_enabled: true, + session_ttl_min: 480, + cookie_secure: false, + cookie_same_site: CookieSameSite::Lax, + }, + }, + rigctl: RigctlFrontendConfig { + enabled: false, + listen: IpAddr::from([127, 0, 0, 1]), + port: 4532, + rig_ports: HashMap::new(), + }, + http_json: HttpJsonFrontendConfig::default(), + audio: AudioClientConfig::default(), + }, + }; + toml::to_string_pretty(&Wrapper { inner: example }).unwrap_or_default() + } +} + +fn validate_http_auth(auth: &HttpAuthConfig) -> Result<(), String> { + if !auth.enabled { + return Ok(()); + } + + // If enabled, require at least one passphrase + if auth.rx_passphrase.is_none() && auth.control_passphrase.is_none() { + return Err( + "[frontends.http.auth] enabled=true requires at least one passphrase \ + (rx_passphrase and/or control_passphrase)" + .to_string(), + ); + } + + // Validate passphrases are not empty strings + if let Some(rx) = &auth.rx_passphrase { + if rx.trim().is_empty() { + return Err("[frontends.http.auth].rx_passphrase must not be empty if set".to_string()); + } + } + if let Some(ctrl) = &auth.control_passphrase { + if ctrl.trim().is_empty() { + return Err( + "[frontends.http.auth].control_passphrase must not be empty if set".to_string(), + ); + } + } + + // Session TTL must be > 0 + if auth.session_ttl_min == 0 { + return Err("[frontends.http.auth].session_ttl_min must be > 0".to_string()); + } + + Ok(()) +} + +impl ConfigFile for ClientConfig { + fn section_key() -> &'static str { + "trx-client" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_config() { + 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.http.initial_map_zoom, 10); + assert_eq!(config.frontends.http.spectrum_coverage_margin_hz, 50_000); + assert_eq!(config.frontends.http.spectrum_usable_span_ratio, 0.92); + assert!(config.frontends.http.bandplan_enabled); + assert_eq!(config.frontends.http.bandplan_region, "iaru_r1"); + assert_eq!(config.frontends.http.decode_history_retention_min, 1440); + assert!(config + .frontends + .http + .decode_history_retention_min_by_rig + .is_empty()); + assert_eq!(config.frontends.rigctl.port, 4532); + assert!(config.frontends.http_json.enabled); + assert_eq!(config.frontends.http_json.port, 0); + assert!(config.remote.url.is_none()); + assert!(config.general.website_url.is_none()); + assert!(config.general.website_name.is_none()); + assert_eq!( + config.general.ais_vessel_url_base, + Some("https://www.vesselfinder.com/?mmsi=".to_string()) + ); + assert_eq!(config.remote.poll_interval_ms, 750); + assert!(config.frontends.audio.enabled); + assert!(config.frontends.audio.server_url.is_none()); + assert!(config.frontends.audio.rig_urls.is_empty()); + assert_eq!(config.frontends.audio.server_port, 4531); + assert!(config.frontends.audio.rig_ports.is_empty()); + assert!(!config.frontends.audio.bridge.enabled); + assert_eq!(config.frontends.audio.bridge.rx_gain, 1.0); + assert_eq!(config.frontends.audio.bridge.tx_gain, 1.0); + } + + #[test] + fn test_parse_client_toml() { + let toml_str = r#" +[general] +callsign = "W1AW" +website_url = "https://example.com" +website_name = "Example" +ais_vessel_url_base = "https://example.com/vessel/" + +[remote] +url = "192.168.1.100:9000" +rig_id = "hf" +auth.token = "my-token" +poll_interval_ms = 500 + +[frontends.http] +enabled = true +listen = "127.0.0.1" +port = 8080 +initial_map_zoom = 12 +spectrum_coverage_margin_hz = 40000 +spectrum_usable_span_ratio = 0.9 +decode_history_retention_min = 720 + +[frontends.http.decode_history_retention_min_by_rig] +vhf = 180 +uhf = 60 + +"#; + + let config: ClientConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(config.general.callsign, Some("W1AW".to_string())); + assert_eq!( + config.general.website_url, + Some("https://example.com".to_string()) + ); + assert_eq!(config.general.website_name, Some("Example".to_string())); + assert_eq!( + config.general.ais_vessel_url_base, + Some("https://example.com/vessel/".to_string()) + ); + assert_eq!(config.remote.url, Some("192.168.1.100:9000".to_string())); + assert_eq!(config.remote.rig_id, Some("hf".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_eq!(config.frontends.http.initial_map_zoom, 12); + assert_eq!(config.frontends.http.spectrum_coverage_margin_hz, 40_000); + assert_eq!(config.frontends.http.spectrum_usable_span_ratio, 0.9); + // bandplan fields not set in TOML → defaults + assert!(config.frontends.http.bandplan_enabled); + assert_eq!(config.frontends.http.bandplan_region, "iaru_r1"); + assert_eq!(config.frontends.http.decode_history_retention_min, 720); + assert_eq!( + config + .frontends + .http + .decode_history_retention_min_by_rig + .get("vhf"), + Some(&180) + ); + assert_eq!( + config + .frontends + .http + .decode_history_retention_min_by_rig + .get("uhf"), + Some(&60) + ); + } + + #[test] + fn test_parse_client_toml_with_audio_urls() { + let toml_str = r#" +[frontends.audio] +enabled = true +server_url = "tcp://audio.example.com" + +[frontends.audio.rig_urls] +home-hf = "audio://10.0.0.5:4600" +"#; + + let config: ClientConfig = toml::from_str(toml_str).unwrap(); + assert_eq!( + config.frontends.audio.server_url, + Some("tcp://audio.example.com".to_string()) + ); + assert_eq!( + config.frontends.audio.rig_urls.get("home-hf"), + Some(&"audio://10.0.0.5:4600".to_string()) + ); + } + + #[test] + fn test_example_combined_toml_parses() { + let example = ClientConfig::example_combined_toml(); + let table: toml::Table = toml::from_str(&example).unwrap(); + let section = toml::to_string(table.get("trx-client").unwrap()).unwrap(); + let _config: ClientConfig = toml::from_str(§ion).unwrap(); + } + + #[test] + fn test_validate_rejects_zero_poll_interval() { + let mut config = ClientConfig::default(); + config.remote.poll_interval_ms = 0; + assert!(config.validate().is_err()); + } + + #[test] + fn test_validate_rejects_empty_remote_rig_id() { + let mut config = ClientConfig::default(); + config.remote.rig_id = Some(" ".to_string()); + assert!(config.validate().is_err()); + } + + #[test] + fn test_validate_rejects_empty_http_json_token() { + let mut config = ClientConfig::default(); + config.frontends.http_json.auth.tokens = vec!["".to_string()]; + assert!(config.validate().is_err()); + } + + #[test] + fn test_validate_rejects_zero_audio_rig_port() { + let mut config = ClientConfig::default(); + config + .frontends + .audio + .rig_ports + .insert("ft817".to_string(), 0); + assert!(config.validate().is_err()); + } + + #[test] + fn test_validate_rejects_invalid_audio_url() { + let mut config = ClientConfig::default(); + config.frontends.audio.server_url = Some("tcp://:4531".to_string()); + assert!(config.validate().is_err()); + } + + #[test] + fn test_validate_accepts_audio_url_without_server_port() { + let mut config = ClientConfig::default(); + config.frontends.audio.server_url = Some("audio.example.com".to_string()); + config.frontends.audio.server_port = 0; + assert!(config.validate().is_ok()); + } + + #[test] + fn test_validate_rejects_http_auth_enabled_without_passphrases() { + let mut config = ClientConfig::default(); + config.frontends.http.auth.enabled = true; + assert!(config.validate().is_err()); + } + + #[test] + fn test_validate_accepts_http_auth_with_rx_passphrase() { + let mut config = ClientConfig::default(); + config.frontends.http.auth.enabled = true; + config.frontends.http.auth.rx_passphrase = Some("rx-secret".to_string()); + assert!(config.validate().is_ok()); + } + + #[test] + fn test_validate_accepts_http_auth_with_control_passphrase() { + let mut config = ClientConfig::default(); + config.frontends.http.auth.enabled = true; + config.frontends.http.auth.control_passphrase = Some("control-secret".to_string()); + assert!(config.validate().is_ok()); + } + + #[test] + fn test_validate_accepts_http_auth_with_both_passphrases() { + let mut config = ClientConfig::default(); + config.frontends.http.auth.enabled = true; + config.frontends.http.auth.rx_passphrase = Some("rx-secret".to_string()); + config.frontends.http.auth.control_passphrase = Some("control-secret".to_string()); + assert!(config.validate().is_ok()); + } + + #[test] + fn test_validate_rejects_empty_rx_passphrase() { + let mut config = ClientConfig::default(); + config.frontends.http.auth.enabled = true; + config.frontends.http.auth.rx_passphrase = Some("".to_string()); + assert!(config.validate().is_err()); + } + + #[test] + fn test_validate_rejects_zero_session_ttl() { + let mut config = ClientConfig::default(); + config.frontends.http.auth.enabled = true; + config.frontends.http.auth.rx_passphrase = Some("rx-secret".to_string()); + config.frontends.http.auth.session_ttl_min = 0; + assert!(config.validate().is_err()); + } + + #[test] + fn test_validate_auth_disabled_ignores_passphrases() { + let mut config = ClientConfig::default(); + config.frontends.http.auth.enabled = false; + config.frontends.http.auth.rx_passphrase = Some("".to_string()); + assert!(config.validate().is_ok()); + } + + #[test] + fn test_http_auth_config_default() { + let auth = HttpAuthConfig::default(); + assert!(!auth.enabled); + assert!(auth.rx_passphrase.is_none()); + assert!(auth.control_passphrase.is_none()); + assert!(auth.tx_access_control_enabled); + assert_eq!(auth.session_ttl_min, 480); + assert!(!auth.cookie_secure); + assert!(matches!(auth.cookie_same_site, CookieSameSite::Lax)); + } + + #[test] + fn test_http_auth_session_ttl_conversion() { + let auth = HttpAuthConfig { + session_ttl_min: 60, + ..Default::default() + }; + assert_eq!(auth.session_ttl().as_secs(), 3600); + } + + #[test] + fn test_parse_remotes_toml() { + let toml_str = r#" +[[remotes]] +name = "home-hf" +url = "192.168.1.10:4530" +rig_id = "hf" +poll_interval_ms = 500 + +[remotes.auth] +token = "secret" + +[[remotes]] +name = "remote" +url = "remote.example.com:4530" +"#; + + let config: ClientConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(config.remotes.len(), 2); + assert_eq!(config.remotes[0].name, "home-hf"); + assert_eq!(config.remotes[0].url, "192.168.1.10:4530"); + assert_eq!(config.remotes[0].rig_id, Some("hf".to_string())); + assert_eq!(config.remotes[0].auth.token, Some("secret".to_string())); + assert_eq!(config.remotes[0].poll_interval_ms, 500); + assert_eq!(config.remotes[1].name, "remote"); + assert_eq!(config.remotes[1].url, "remote.example.com:4530"); + assert!(config.remotes[1].rig_id.is_none()); + assert!(config.remotes[1].auth.token.is_none()); + assert_eq!(config.remotes[1].poll_interval_ms, 750); // default + assert!(config.validate().is_ok()); + } + + #[test] + fn test_resolved_remotes_from_remotes() { + let config = ClientConfig { + remotes: vec![RemoteEntry { + name: "hf".to_string(), + url: "host:4530".to_string(), + rig_id: None, + auth: RemoteAuthConfig::default(), + poll_interval_ms: 750, + }], + ..Default::default() + }; + let resolved = config.resolved_remotes(); + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].name, "hf"); + } + + #[test] + fn test_resolved_remotes_legacy_fallback() { + let config = ClientConfig { + remote: RemoteConfig { + url: Some("host:4530".to_string()), + rig_id: Some("hf".to_string()), + auth: RemoteAuthConfig { + token: Some("tok".to_string()), + }, + poll_interval_ms: 750, + }, + ..Default::default() + }; + let resolved = config.resolved_remotes(); + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].name, "hf"); + assert_eq!(resolved[0].url, "host:4530"); + assert_eq!(resolved[0].rig_id, Some("hf".to_string())); + assert_eq!(resolved[0].auth.token, Some("tok".to_string())); + } + + #[test] + fn test_resolved_remotes_legacy_default_name() { + let config = ClientConfig { + remote: RemoteConfig { + url: Some("host:4530".to_string()), + rig_id: None, + ..Default::default() + }, + ..Default::default() + }; + let resolved = config.resolved_remotes(); + assert_eq!(resolved[0].name, "default"); + } + + #[test] + fn test_resolved_remotes_prefers_remotes_over_legacy() { + let config = ClientConfig { + remote: RemoteConfig { + url: Some("old:4530".to_string()), + ..Default::default() + }, + remotes: vec![RemoteEntry { + name: "new".to_string(), + url: "new:4530".to_string(), + rig_id: None, + auth: RemoteAuthConfig::default(), + poll_interval_ms: 750, + }], + ..Default::default() + }; + let resolved = config.resolved_remotes(); + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].name, "new"); + } + + #[test] + fn test_validate_rejects_duplicate_remote_names() { + let config = ClientConfig { + remotes: vec![ + RemoteEntry { + name: "dup".to_string(), + url: "a:4530".to_string(), + rig_id: None, + auth: RemoteAuthConfig::default(), + poll_interval_ms: 750, + }, + RemoteEntry { + name: "dup".to_string(), + url: "b:4530".to_string(), + rig_id: None, + auth: RemoteAuthConfig::default(), + poll_interval_ms: 750, + }, + ], + ..Default::default() + }; + assert!(config.validate().unwrap_err().contains("duplicate name")); + } + + #[test] + fn test_validate_rejects_empty_remote_name() { + let config = ClientConfig { + remotes: vec![RemoteEntry { + name: "".to_string(), + url: "a:4530".to_string(), + rig_id: None, + auth: RemoteAuthConfig::default(), + poll_interval_ms: 750, + }], + ..Default::default() + }; + assert!(config + .validate() + .unwrap_err() + .contains("name must not be empty")); + } + + #[test] + fn test_validate_rejects_empty_remote_url() { + let config = ClientConfig { + remotes: vec![RemoteEntry { + name: "hf".to_string(), + url: " ".to_string(), + rig_id: None, + auth: RemoteAuthConfig::default(), + poll_interval_ms: 750, + }], + ..Default::default() + }; + assert!(config + .validate() + .unwrap_err() + .contains("url must not be empty")); + } + + #[test] + fn test_validate_rejects_zero_remote_poll_interval() { + let config = ClientConfig { + remotes: vec![RemoteEntry { + name: "hf".to_string(), + url: "a:4530".to_string(), + rig_id: None, + auth: RemoteAuthConfig::default(), + poll_interval_ms: 0, + }], + ..Default::default() + }; + assert!(config + .validate() + .unwrap_err() + .contains("poll_interval_ms must be > 0")); + } + + #[test] + fn test_validate_rejects_invalid_bandplan_region() { + let mut config = ClientConfig::default(); + config.frontends.http.bandplan_region = "invalid".to_string(); + assert!(config.validate().is_err()); + } + + #[test] + fn test_validate_accepts_all_bandplan_regions() { + for region in &["iaru_r1", "iaru_r2", "iaru_r3"] { + let mut config = ClientConfig::default(); + config.frontends.http.bandplan_region = region.to_string(); + assert!( + config.validate().is_ok(), + "region {} should be valid", + region + ); + } + } + + #[test] + fn test_parse_bandplan_config_from_toml() { + let toml_str = r#" +[frontends.http] +bandplan_enabled = false +bandplan_region = "iaru_r2" +"#; + let config: ClientConfig = toml::from_str(toml_str).unwrap(); + assert!(!config.frontends.http.bandplan_enabled); + assert_eq!(config.frontends.http.bandplan_region, "iaru_r2"); + } +} diff --git a/src/trx-app/src/config.rs b/src/trx-config/src/file.rs similarity index 100% rename from src/trx-app/src/config.rs rename to src/trx-config/src/file.rs diff --git a/src/trx-config/src/lib.rs b/src/trx-config/src/lib.rs new file mode 100644 index 00000000..5bb554b2 --- /dev/null +++ b/src/trx-config/src/lib.rs @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +//! Configuration types shared by `trx-server`, `trx-client` and +//! `trx-configurator`. +//! +//! Keeping the structs, the loader and the validators in one crate means the +//! setup wizard checks a config with exactly the same code the binaries load +//! it with, so the two can never drift apart. + +pub mod client; +pub mod file; +pub mod server; +pub mod shared; +pub mod url; + +pub use client::ClientConfig; +pub use file::{ConfigError, ConfigFile}; +pub use server::ServerConfig; +pub use shared::{validate_log_level, validate_tokens}; +pub use url::{parse_audio_url, parse_remote_url, RemoteEndpoint}; diff --git a/src/trx-config/src/server.rs b/src/trx-config/src/server.rs new file mode 100644 index 00000000..d504e0a5 --- /dev/null +++ b/src/trx-config/src/server.rs @@ -0,0 +1,1525 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +//! Configuration file support for trx-server. +//! +//! Config is loaded from the `[trx-server]` section of `trx-rs.toml`. +//! Default search order: +//! 1. Path specified via `--config` CLI argument +//! 2. `./trx-rs.toml` +//! 3. `~/.config/trx-rs/trx-rs.toml` +//! 4. `/etc/trx-rs/trx-rs.toml` + +use std::net::IpAddr; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use crate::file::{ConfigError, ConfigFile}; +use crate::shared::{validate_log_level, validate_tokens}; +pub use trx_decode_log::DecodeLogsConfig; + +use trx_core::rig::state::RigMode; + +/// Per-rig instance configuration for multi-rig setups. +/// +/// Each entry in `[[rigs]]` becomes one of these. The flat top-level +/// `[rig]` / `[audio]` / `[sdr]` / `[pskreporter]` / `[aprsfi]` / +/// `[behavior]` / `[decode_logs]` fields are still supported via +/// `ServerConfig::resolved_rigs()` which synthesises a single-element list +/// with `id = "default"` when `rigs` is empty. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct RigInstanceConfig { + /// Whether this rig instance should be started. + /// Defaults to true so existing configs remain unchanged. + pub enable: bool, + /// Stable rig identifier used in protocol routing. + pub id: String, + /// Display name for the rig (e.g., "HF Transceiver", "VHF/UHF SDR"). + /// If not specified, defaults to the rig id. + pub name: Option, + /// Rig backend configuration. + pub rig: RigConfig, + /// Polling and retry behavior. + pub behavior: BehaviorConfig, + /// Audio streaming configuration for this rig. + pub audio: AudioConfig, + /// SDR pipeline configuration (only used when [rigs.rig.access] type = "sdr"). + pub sdr: SdrConfig, + /// PSK Reporter uplink for this rig. + pub pskreporter: PskReporterConfig, + /// APRS-IS IGate uplink for this rig. + pub aprsfi: AprsFiConfig, + /// Decoder file logging for this rig. + pub decode_logs: DecodeLogsConfig, +} + +impl Default for RigInstanceConfig { + fn default() -> Self { + Self { + enable: true, + id: String::new(), + name: None, + rig: RigConfig::default(), + behavior: BehaviorConfig::default(), + audio: AudioConfig::default(), + sdr: SdrConfig::default(), + pskreporter: PskReporterConfig::default(), + aprsfi: AprsFiConfig::default(), + decode_logs: DecodeLogsConfig::default(), + } + } +} + +impl RigInstanceConfig { + /// Get the display name for this rig. + /// Returns the configured name if set, otherwise the id. + pub fn display_name(&self) -> &str { + self.name.as_deref().unwrap_or(&self.id) + } +} + +/// Top-level server configuration structure. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct ServerConfig { + /// General settings + pub general: GeneralConfig, + /// Rig backend configuration (legacy flat; use [[rigs]] for multi-rig) + pub rig: RigConfig, + /// Polling and retry behavior (legacy flat) + pub behavior: BehaviorConfig, + /// TCP listener configuration + pub listen: ListenConfig, + /// Audio streaming configuration (legacy flat) + pub audio: AudioConfig, + /// PSK Reporter uplink configuration (legacy flat) + pub pskreporter: PskReporterConfig, + /// APRS-IS IGate uplink configuration (legacy flat) + pub aprsfi: AprsFiConfig, + /// Decoder file logging configuration (legacy flat) + pub decode_logs: DecodeLogsConfig, + /// SDR pipeline configuration (legacy flat; used when [rig.access] type = "sdr"). + pub sdr: SdrConfig, + /// Timeout and buffer-size tuning knobs. + pub timeouts: TimeoutsConfig, + /// Multi-rig instance list. When non-empty, takes priority over the flat fields. + #[serde(rename = "rigs", default)] + pub rigs: Vec, +} + +/// General application settings. +#[derive(Debug, Clone, 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, + /// Receiver latitude (decimal degrees, WGS84) + pub latitude: Option, + /// Receiver longitude (decimal degrees, WGS84) + pub longitude: Option, +} + +impl Default for GeneralConfig { + fn default() -> Self { + Self { + callsign: Some("N0CALL".to_string()), + log_level: None, + latitude: None, + longitude: None, + } + } +} + +/// Rig backend configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct RigConfig { + /// Rig model (e.g., "ft817", "ft450d", "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, + /// SoapySDR device args string (for sdr access), e.g. "driver=rtlsdr". + pub args: 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, + /// Whether to prime both VFOs on startup by toggling and reading each. + /// Defaults to true for rigs with dual VFOs (e.g. FT-817). + pub vfo_prime: bool, +} + +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, + vfo_prime: true, + } + } +} + +/// Timeout and buffer-size tuning knobs. +/// +/// All durations are in milliseconds. The defaults match the previously +/// hard-coded values, so existing deployments are unaffected. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct TimeoutsConfig { + /// Maximum time (ms) to wait for a single rig command to complete. + pub command_exec_timeout_ms: u64, + /// Maximum time (ms) for a CAT poll refresh cycle. + pub poll_refresh_timeout_ms: u64, + /// Maximum time (ms) for low-level listener I/O operations (read/write/flush). + pub io_timeout_ms: u64, + /// Maximum time (ms) to wait for a rig command response in the listener. + pub request_timeout_ms: u64, + /// Capacity of the per-rig command channel (number of queued requests). + pub rig_task_channel_buffer: usize, +} + +impl Default for TimeoutsConfig { + fn default() -> Self { + Self { + command_exec_timeout_ms: 10_000, + poll_refresh_timeout_ms: 8_000, + io_timeout_ms: 10_000, + request_timeout_ms: 12_000, + rig_task_channel_buffer: 32, + } + } +} + +/// TCP listener configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct ListenConfig { + /// Whether the listener is enabled + pub enabled: bool, + /// IP address to listen on + pub listen: IpAddr, + /// TCP port to listen on + pub port: u16, + /// Authentication configuration + pub auth: AuthConfig, +} + +impl Default for ListenConfig { + fn default() -> Self { + Self { + enabled: true, + listen: IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), + port: 4530, + auth: AuthConfig::default(), + } + } +} + +/// Authentication configuration for the TCP listener. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct AuthConfig { + /// Valid authentication tokens (empty = no auth required) + pub tokens: Vec, +} + +/// Audio streaming configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct AudioConfig { + /// Whether audio streaming is enabled + pub enabled: bool, + /// IP address to listen on for audio connections + pub listen: IpAddr, + /// TCP port for audio connections + pub port: u16, + /// Whether RX audio capture is enabled + pub rx_enabled: bool, + /// Whether TX audio playback is enabled + pub tx_enabled: bool, + /// Audio input device name (None = system default) + pub device: Option, + /// Sample rate in Hz + pub sample_rate: u32, + /// Number of audio channels + pub channels: u8, + /// Opus frame duration in milliseconds + pub frame_duration_ms: u16, + /// Opus bitrate in bits per second + pub bitrate_bps: u32, +} + +impl Default for AudioConfig { + fn default() -> Self { + Self { + enabled: true, + listen: IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), + port: 4531, + rx_enabled: true, + tx_enabled: true, + device: None, + sample_rate: 48000, + channels: 2, + frame_duration_ms: 20, + bitrate_bps: 256000, + } + } +} + +pub use trx_reporting::{AprsFiConfig, PskReporterConfig}; + +/// Top-level SDR configuration (only used when [rig.access] type = "sdr"). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct SdrConfig { + /// SoapySDR IQ capture sample rate (Hz). Must be supported by the device. + pub sample_rate: u32, + /// Hardware IF filter bandwidth (Hz). + pub bandwidth: u32, + /// WFM deemphasis time constant in microseconds (50 or 75). + pub wfm_deemphasis_us: u32, + /// SDR tunes this many Hz below the dial frequency to keep signal off DC. + pub center_offset_hz: i64, + /// Gain configuration. + pub gain: SdrGainConfig, + /// Virtual software squelch applied to demodulated audio except WFM. + pub squelch: SdrSquelchConfig, + /// Noise blanker for impulse noise suppression on IQ samples. + pub noise_blanker: SdrNoiseBlankerConfig, + /// Virtual receiver channels (at least one required when SDR backend is active). + pub channels: Vec, + /// Maximum number of simultaneous virtual channels (including the primary). + /// Default: 4. + #[serde(default = "default_max_virtual_channels")] + pub max_virtual_channels: usize, +} + +fn default_max_virtual_channels() -> usize { + 4 +} + +impl Default for SdrConfig { + fn default() -> Self { + Self { + sample_rate: 1_920_000, + bandwidth: 1_500_000, + wfm_deemphasis_us: 50, + center_offset_hz: 100_000, + gain: SdrGainConfig::default(), + squelch: SdrSquelchConfig::default(), + noise_blanker: SdrNoiseBlankerConfig::default(), + channels: Vec::new(), + max_virtual_channels: default_max_virtual_channels(), + } + } +} + +/// Virtual squelch settings for SoapySDR demodulated audio (except WFM mode). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct SdrSquelchConfig { + /// Enables software squelch for demodulated audio except WFM. + pub enabled: bool, + /// Open threshold in dBFS (typical range: -120..0). + pub threshold_db: f32, + /// Hysteresis in dB used when closing the squelch. + pub hysteresis_db: f32, + /// Tail hold time after dropping below threshold. + pub tail_ms: u32, +} + +impl Default for SdrSquelchConfig { + fn default() -> Self { + Self { + enabled: false, + threshold_db: -65.0, + hysteresis_db: 3.0, + tail_ms: 180, + } + } +} + +/// Noise blanker settings for impulse noise suppression on IQ samples. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct SdrNoiseBlankerConfig { + /// Enables the noise blanker. + pub enabled: bool, + /// Threshold multiplier for impulse detection (typical range: 1..100). + /// A sample whose magnitude exceeds threshold × running RMS is blanked. + pub threshold: f64, +} + +impl Default for SdrNoiseBlankerConfig { + fn default() -> Self { + Self { + enabled: false, + threshold: 10.0, + } + } +} + +/// Gain control mode for the SDR device. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct SdrGainConfig { + /// "auto" (hardware AGC) or "manual" (fixed dB). + pub mode: String, + /// Gain in dB; effective only when mode = "manual". + pub value: f64, + /// Optional hard ceiling for the applied hardware gain in dB. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_value: Option, +} + +impl Default for SdrGainConfig { + fn default() -> Self { + Self { + mode: "auto".to_string(), + value: 30.0, + max_value: None, + } + } +} + +/// One virtual receiver channel within the wideband IQ stream. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct SdrChannelConfig { + /// Human-readable identifier used in logs. + pub id: String, + /// Frequency offset from the dial frequency (Hz). Primary channel should use 0. + pub offset_hz: i64, + /// Demodulation mode: "auto" (follows RigCat set_mode) or a fixed RigMode string + /// (e.g. "USB", "FM"). + pub mode: String, + /// One-sided bandwidth of the post-demod audio BPF (Hz). + pub audio_bandwidth_hz: u32, + /// CW tone centre frequency in the audio domain (Hz). Default 700. + pub cw_center_hz: u32, + /// Pre-demod bandwidth for WFM only (Hz). Default 75000. + pub wfm_bandwidth_hz: u32, + /// Decoder names that receive this channel's PCM frames. + /// Valid values: "ft8", "wspr", "aprs", "cw". + pub decoders: Vec, + /// If true, encode this channel's audio as Opus and stream over TCP. + /// At most one channel may set this to true. + pub stream_opus: bool, +} + +impl Default for SdrChannelConfig { + fn default() -> Self { + Self { + id: String::new(), + offset_hz: 0, + mode: "auto".to_string(), + audio_bandwidth_hz: 3000, + cw_center_hz: 700, + wfm_bandwidth_hz: 75_000, + decoders: Vec::new(), + stream_opus: false, + } + } +} + +impl ServerConfig { + pub fn validate(&self) -> Result<(), String> { + validate_log_level(self.general.log_level.as_deref())?; + validate_coordinates(self.general.latitude, self.general.longitude)?; + + if self.rig.initial_freq_hz == 0 { + return Err("[rig].initial_freq_hz must be > 0".to_string()); + } + + validate_access(&self.rig.access)?; + + if self.behavior.poll_interval_ms == 0 { + return Err("[behavior].poll_interval_ms must be > 0".to_string()); + } + if self.behavior.poll_interval_tx_ms == 0 { + return Err("[behavior].poll_interval_tx_ms must be > 0".to_string()); + } + if self.behavior.max_retries == 0 { + return Err("[behavior].max_retries must be > 0".to_string()); + } + if self.behavior.retry_base_delay_ms == 0 { + return Err("[behavior].retry_base_delay_ms must be > 0".to_string()); + } + + validate_tokens("[listen.auth].tokens", &self.listen.auth.tokens)?; + if self.listen.enabled && self.listen.port == 0 { + return Err("[listen].port must be > 0 when listener is enabled".to_string()); + } + + if self.audio.enabled { + if self.audio.port == 0 { + return Err("[audio].port must be > 0 when audio is enabled".to_string()); + } + if !self.audio.rx_enabled && !self.audio.tx_enabled { + return Err( + "[audio] enabled but both rx_enabled and tx_enabled are false".to_string(), + ); + } + if self.audio.sample_rate < 8_000 || self.audio.sample_rate > 192_000 { + return Err("[audio].sample_rate must be in range 8000..=192000".to_string()); + } + if !(1..=2).contains(&self.audio.channels) { + return Err("[audio].channels must be 1 or 2".to_string()); + } + match self.audio.frame_duration_ms { + 3 | 5 | 10 | 20 | 40 | 60 => {} + _ => { + return Err( + "[audio].frame_duration_ms must be one of: 3, 5, 10, 20, 40, 60" + .to_string(), + ) + } + } + if self.audio.bitrate_bps == 0 { + return Err("[audio].bitrate_bps must be > 0".to_string()); + } + } + + if self.pskreporter.enabled { + if self.pskreporter.host.trim().is_empty() { + return Err("[pskreporter].host must not be empty".to_string()); + } + if self.pskreporter.port == 0 { + return Err("[pskreporter].port must be > 0".to_string()); + } + if self.pskreporter.receiver_locator.is_none() + && (self.general.latitude.is_none() || self.general.longitude.is_none()) + { + return Err( + "[pskreporter] enabled requires either [pskreporter].receiver_locator \ + or [general].latitude and [general].longitude" + .to_string(), + ); + } + } + + if self.aprsfi.enabled { + if self.aprsfi.host.trim().is_empty() { + return Err("[aprsfi].host must not be empty".to_string()); + } + if self.aprsfi.port == 0 { + return Err("[aprsfi].port must be > 0".to_string()); + } + } + + if let Some(max_gain) = self.sdr.gain.max_value { + if !max_gain.is_finite() { + return Err("[sdr.gain].max_value must be finite".to_string()); + } + if max_gain < 0.0 { + return Err("[sdr.gain].max_value must be >= 0".to_string()); + } + } + validate_sdr_squelch_config("[sdr.squelch]", &self.sdr.squelch)?; + validate_sdr_nb_config("[sdr.noise_blanker]", &self.sdr.noise_blanker)?; + + // Multi-rig uniqueness checks. + if !self.rigs.is_empty() { + let mut seen_ids: std::collections::HashSet = std::collections::HashSet::new(); + let mut seen_ports: std::collections::HashSet = std::collections::HashSet::new(); + let mut enabled_count = 0usize; + for rig in &self.rigs { + if !rig.enable { + continue; + } + enabled_count += 1; + // Check for explicit duplicate IDs (empty IDs are auto-generated later). + if !rig.id.trim().is_empty() && !seen_ids.insert(rig.id.clone()) { + return Err(format!("[[rigs]] duplicate rig id: \"{}\"", rig.id)); + } + if rig.audio.enabled && !seen_ports.insert(rig.audio.port) { + return Err(format!( + "[[rigs]] duplicate audio port {} (rig id: \"{}\")", + rig.audio.port, rig.id + )); + } + if let Some(max_gain) = rig.sdr.gain.max_value { + if !max_gain.is_finite() { + return Err(format!( + "[[rigs]] [sdr.gain].max_value must be finite (rig id: \"{}\")", + rig.id + )); + } + if max_gain < 0.0 { + return Err(format!( + "[[rigs]] [sdr.gain].max_value must be >= 0 (rig id: \"{}\")", + rig.id + )); + } + } + validate_sdr_squelch_config( + &format!("[[rigs]] [sdr.squelch] (rig id: \"{}\")", rig.id), + &rig.sdr.squelch, + )?; + validate_sdr_nb_config( + &format!("[[rigs]] [sdr.noise_blanker] (rig id: \"{}\")", rig.id), + &rig.sdr.noise_blanker, + )?; + } + if enabled_count == 0 { + return Err( + "[[rigs]] has no enabled entries; set at least one [[rigs]].enable = true" + .to_string(), + ); + } + } + + if self.decode_logs.enabled { + if self.decode_logs.dir.trim().is_empty() { + return Err("[decode_logs].dir must not be empty when enabled".to_string()); + } + if self.decode_logs.aprs_file.trim().is_empty() + || self.decode_logs.cw_file.trim().is_empty() + || self.decode_logs.ft8_file.trim().is_empty() + || self.decode_logs.wspr_file.trim().is_empty() + { + return Err("[decode_logs] file names must not be empty when enabled".to_string()); + } + } + + Ok(()) + } + + /// Validate SDR-specific config rules (see SDR.md §11). + /// Returns a Vec of error strings; empty means valid. + pub fn validate_sdr(&self) -> Vec { + let mut errors = Vec::new(); + + // Only validate if access type is "sdr" + let is_sdr = self.rig.access.access_type.as_deref() == Some("sdr"); + if !is_sdr { + return errors; + } + + // args must be non-empty + if self + .rig + .access + .args + .as_deref() + .map(str::is_empty) + .unwrap_or(true) + { + errors.push("[rig.access] args must be non-empty for type = \"sdr\"".into()); + } + + // sample_rate must be non-zero + if self.sdr.sample_rate == 0 { + errors.push("[sdr] sample_rate must be > 0".into()); + } + + // Every channel's IF must fit within the captured bandwidth + let half_rate = self.sdr.sample_rate as i64 / 2; + for ch in &self.sdr.channels { + let channel_if = self.sdr.center_offset_hz + ch.offset_hz; + if channel_if.abs() >= half_rate { + errors.push(format!( + "[sdr.channels] id=\"{}\" IF frequency {} Hz exceeds Nyquist limit ±{} Hz", + ch.id, channel_if, half_rate + )); + } + } + + // At most one channel may have stream_opus = true + let opus_count = self.sdr.channels.iter().filter(|c| c.stream_opus).count(); + if opus_count > 1 { + errors.push(format!( + "[sdr.channels] at most one channel may have stream_opus = true (found {})", + opus_count + )); + } + + // tx_enabled must be false with SDR backend + if self.audio.tx_enabled { + errors.push("[audio] tx_enabled must be false when using the soapysdr backend".into()); + } + + // Decoder names must not appear in more than one channel + let mut seen: std::collections::HashMap = std::collections::HashMap::new(); + for ch in &self.sdr.channels { + for dec in &ch.decoders { + if let Some(prev_id) = seen.get(dec) { + errors.push(format!( + "[sdr.channels] decoder \"{}\" appears in both \"{}\" and \"{}\"", + dec, prev_id, ch.id + )); + } else { + seen.insert(dec.clone(), ch.id.clone()); + } + } + } + + errors + } + + /// Load configuration from a specific file path. + pub fn load_from_file(path: &Path) -> Result { + ::load_from_file(path) + } + + /// 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> { + ::load_from_default_paths() + } + + /// Return the effective list of rig instances to spawn. + /// + /// When `[[rigs]]` entries are present, only enabled entries are returned. + /// Otherwise the legacy flat `[rig]` / `[audio]` / … fields are synthesised + /// into a single `RigInstanceConfig` with `id = "default"`. + pub fn resolved_rigs(&self) -> Vec { + if !self.rigs.is_empty() { + // Auto-generate IDs for rigs that don't have explicit ones. + return self + .rigs + .iter() + .enumerate() + .filter(|(_, rig)| rig.enable) + .map(|(idx, rig)| { + let id = if rig.id.trim().is_empty() { + // Generate ID from model name with counter. + let model = rig.rig.model.as_deref().unwrap_or("unknown").to_lowercase(); + format!("{}_{}", model, idx) + } else { + rig.id.clone() + }; + + RigInstanceConfig { id, ..rig.clone() } + }) + .collect(); + } + vec![RigInstanceConfig { + enable: true, + id: "default".to_string(), + name: None, + rig: self.rig.clone(), + behavior: self.behavior.clone(), + audio: self.audio.clone(), + sdr: self.sdr.clone(), + pskreporter: self.pskreporter.clone(), + aprsfi: self.aprsfi.clone(), + decode_logs: self.decode_logs.clone(), + }] + } + + /// Generate an example configuration wrapped under the `[trx-server]` + /// section header, suitable for use in a combined `trx-rs.toml` file. + pub fn example_combined_toml() -> String { + #[derive(serde::Serialize)] + struct Wrapper { + #[serde(rename = "trx-server")] + inner: ServerConfig, + } + let example = ServerConfig { + general: GeneralConfig { + callsign: Some("N0CALL".to_string()), + log_level: Some("info".to_string()), + latitude: Some(52.2297), + longitude: Some(21.0122), + }, + 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, + args: None, + }, + }, + behavior: BehaviorConfig::default(), + listen: ListenConfig::default(), + audio: AudioConfig::default(), + pskreporter: PskReporterConfig::default(), + aprsfi: AprsFiConfig::default(), + decode_logs: DecodeLogsConfig::default(), + sdr: SdrConfig::default(), + timeouts: TimeoutsConfig::default(), + rigs: Vec::new(), + }; + toml::to_string_pretty(&Wrapper { inner: example }).unwrap_or_default() + } +} + +fn validate_coordinates(latitude: Option, longitude: Option) -> Result<(), String> { + match (latitude, longitude) { + (Some(lat), Some(lon)) => { + if !(-90.0..=90.0).contains(&lat) { + return Err("[general].latitude must be in range -90..=90".to_string()); + } + if !(-180.0..=180.0).contains(&lon) { + return Err("[general].longitude must be in range -180..=180".to_string()); + } + Ok(()) + } + (None, None) => Ok(()), + _ => Err( + "[general].latitude and [general].longitude must be set together or both omitted" + .to_string(), + ), + } +} + +fn validate_access(access: &AccessConfig) -> Result<(), String> { + let serial_fields_set = access.port.is_some() || access.baud.is_some(); + let tcp_fields_set = access.host.is_some() || access.tcp_port.is_some(); + + if access.access_type.is_none() && !serial_fields_set && !tcp_fields_set { + return Ok(()); + } + + match access.access_type.as_deref().unwrap_or("serial") { + "serial" => { + if access.port.as_deref().unwrap_or("").trim().is_empty() { + return Err( + "[rig.access].port must be set for serial access ([rig.access].type='serial')" + .to_string(), + ); + } + if access.baud.unwrap_or(0) == 0 { + return Err( + "[rig.access].baud must be > 0 for serial access ([rig.access].type='serial')" + .to_string(), + ); + } + } + "tcp" => { + if access.host.as_deref().unwrap_or("").trim().is_empty() { + return Err( + "[rig.access].host must be set for tcp access ([rig.access].type='tcp')" + .to_string(), + ); + } + if access.tcp_port.unwrap_or(0) == 0 { + return Err( + "[rig.access].tcp_port must be > 0 for tcp access ([rig.access].type='tcp')" + .to_string(), + ); + } + } + "sdr" => { + // SDR-specific validation is handled by validate_sdr() + } + other => { + return Err(format!( + "[rig.access].type '{}' is invalid (expected 'serial', 'tcp', or 'sdr')", + other + )) + } + } + Ok(()) +} + +fn validate_sdr_squelch_config(path: &str, squelch: &SdrSquelchConfig) -> Result<(), String> { + if !squelch.threshold_db.is_finite() { + return Err(format!("{path}.threshold_db must be finite")); + } + if !(-140.0..=0.0).contains(&squelch.threshold_db) { + return Err(format!("{path}.threshold_db must be in range -140..=0")); + } + if !squelch.hysteresis_db.is_finite() { + return Err(format!("{path}.hysteresis_db must be finite")); + } + if !(0.0..=40.0).contains(&squelch.hysteresis_db) { + return Err(format!("{path}.hysteresis_db must be in range 0..=40")); + } + if squelch.tail_ms > 10_000 { + return Err(format!("{path}.tail_ms must be <= 10000")); + } + Ok(()) +} + +fn validate_sdr_nb_config(path: &str, nb: &SdrNoiseBlankerConfig) -> Result<(), String> { + if !nb.threshold.is_finite() { + return Err(format!("{path}.threshold must be finite")); + } + if !(1.0..=100.0).contains(&nb.threshold) { + return Err(format!("{path}.threshold must be in range 1..=100")); + } + Ok(()) +} + +impl ConfigFile for ServerConfig { + fn section_key() -> &'static str { + "trx-server" + } +} + +#[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); + assert!(config.listen.enabled); + assert_eq!(config.listen.port, 4530); + assert!(config.listen.auth.tokens.is_empty()); + assert!(config.audio.enabled); + assert_eq!(config.audio.port, 4531); + assert_eq!(config.audio.sample_rate, 48000); + assert!(!config.pskreporter.enabled); + assert_eq!(config.pskreporter.port, 4739); + assert!(!config.aprsfi.enabled); + assert_eq!(config.aprsfi.host, "rotate.aprs.net"); + assert_eq!(config.aprsfi.port, 14580); + assert_eq!(config.aprsfi.passcode, -1); + assert!(!config.decode_logs.enabled); + assert!(std::path::Path::new(&config.decode_logs.dir) + .ends_with(std::path::Path::new("decoders"))); + } + + #[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 + +[listen] +enabled = true +listen = "0.0.0.0" +port = 5000 + +[listen.auth] +tokens = ["secret123"] +"#; + + 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); + assert!(config.listen.enabled); + assert_eq!( + config.listen.listen, + std::net::IpAddr::V4(std::net::Ipv4Addr::new(0, 0, 0, 0)) + ); + assert_eq!(config.listen.port, 5000); + assert_eq!(config.listen.auth.tokens, vec!["secret123".to_string()]); + } + + #[test] + fn test_example_combined_toml_parses() { + let example = ServerConfig::example_combined_toml(); + let table: toml::Table = toml::from_str(&example).unwrap(); + let section = toml::to_string(table.get("trx-server").unwrap()).unwrap(); + let _config: ServerConfig = toml::from_str(§ion).unwrap(); + } + + #[test] + fn test_validate_rejects_invalid_coordinates() { + let mut config = ServerConfig::default(); + config.general.latitude = Some(120.0); + config.general.longitude = Some(10.0); + assert!(config.validate().is_err()); + } + + #[test] + fn test_validate_rejects_invalid_audio_frame_duration() { + let mut config = ServerConfig::default(); + config.rig.access.port = Some("/dev/ttyUSB0".to_string()); + config.rig.access.baud = Some(9600); + config.audio.frame_duration_ms = 7; + assert!(config.validate().is_err()); + } + + #[test] + fn test_validate_pskreporter_requires_locator_source() { + let mut config = ServerConfig::default(); + config.rig.access.port = Some("/dev/ttyUSB0".to_string()); + config.rig.access.baud = Some(9600); + config.pskreporter.enabled = true; + config.pskreporter.receiver_locator = None; + config.general.latitude = None; + config.general.longitude = None; + assert!(config.validate().is_err()); + + config.general.latitude = Some(52.0); + config.general.longitude = Some(21.0); + assert!(config.validate().is_ok()); + } + + // --- SDR-11: validate_sdr() unit tests --- + + fn sdr_config_with_access(args: &str) -> ServerConfig { + let mut cfg = ServerConfig::default(); + cfg.rig.access.access_type = Some("sdr".to_string()); + cfg.rig.access.args = Some(args.to_string()); + cfg.audio.tx_enabled = false; + cfg.sdr.sample_rate = 1_920_000; + cfg.sdr.center_offset_hz = 200_000; + cfg + } + + fn add_channel( + cfg: &mut ServerConfig, + id: &str, + offset_hz: i64, + stream_opus: bool, + decoders: Vec, + ) { + cfg.sdr.channels.push(SdrChannelConfig { + id: id.to_string(), + offset_hz, + stream_opus, + decoders, + ..SdrChannelConfig::default() + }); + } + + #[test] + fn test_sdr_validate_ok_minimal() { + let mut cfg = sdr_config_with_access("driver=rtlsdr"); + add_channel(&mut cfg, "primary", 0, false, vec![]); + let errors = cfg.validate_sdr(); + assert!(errors.is_empty(), "expected no errors, got: {:?}", errors); + } + + #[test] + fn test_sdr_validate_non_sdr_skips() { + let cfg = ServerConfig::default(); + let errors = cfg.validate_sdr(); + assert!( + errors.is_empty(), + "expected no errors for non-sdr config, got: {:?}", + errors + ); + } + + #[test] + fn test_sdr_validate_empty_args() { + let cfg = sdr_config_with_access(""); + let errors = cfg.validate_sdr(); + assert_eq!( + errors.len(), + 1, + "expected exactly 1 error, got: {:?}", + errors + ); + assert!( + errors[0].contains("args"), + "expected error to mention 'args', got: {}", + errors[0] + ); + } + + #[test] + fn test_sdr_validate_missing_args() { + let mut cfg = sdr_config_with_access("placeholder"); + cfg.rig.access.args = None; + let errors = cfg.validate_sdr(); + assert_eq!( + errors.len(), + 1, + "expected exactly 1 error, got: {:?}", + errors + ); + assert!( + errors[0].contains("args"), + "expected error to mention 'args', got: {}", + errors[0] + ); + } + + #[test] + fn test_sdr_validate_zero_sample_rate() { + let mut cfg = sdr_config_with_access("driver=rtlsdr"); + cfg.sdr.sample_rate = 0; + let errors = cfg.validate_sdr(); + assert!( + errors.iter().any(|e| e.contains("sample_rate")), + "expected error mentioning 'sample_rate', got: {:?}", + errors + ); + } + + #[test] + fn test_sdr_validate_channel_if_out_of_range() { + // sample_rate=1_000_000 => Nyquist=500_000 + // center_offset_hz=0, offset_hz=600_000 => IF=600_000 > 500_000 + let mut cfg = sdr_config_with_access("driver=rtlsdr"); + cfg.sdr.sample_rate = 1_000_000; + cfg.sdr.center_offset_hz = 0; + add_channel(&mut cfg, "ch_high", 600_000, false, vec![]); + let errors = cfg.validate_sdr(); + assert!( + errors + .iter() + .any(|e| e.contains("ch_high") && (e.contains("Nyquist") || e.contains("exceeds"))), + "expected error mentioning channel id and Nyquist/exceeds, got: {:?}", + errors + ); + } + + #[test] + fn test_sdr_validate_channel_if_negative_out_of_range() { + // sample_rate=1_000_000 => Nyquist=500_000 + // center_offset_hz=0, offset_hz=-600_000 => IF=-600_000, abs=600_000 > 500_000 + let mut cfg = sdr_config_with_access("driver=rtlsdr"); + cfg.sdr.sample_rate = 1_000_000; + cfg.sdr.center_offset_hz = 0; + add_channel(&mut cfg, "ch_low", -600_000, false, vec![]); + let errors = cfg.validate_sdr(); + assert!( + errors + .iter() + .any(|e| e.contains("ch_low") && (e.contains("Nyquist") || e.contains("exceeds"))), + "expected error mentioning channel id and Nyquist/exceeds, got: {:?}", + errors + ); + } + + #[test] + fn test_sdr_validate_channel_if_exactly_nyquist_is_invalid() { + // sample_rate=1_000_000 => Nyquist=500_000 + // IF=500_000 is NOT strictly less than 500_000 => invalid + let mut cfg = sdr_config_with_access("driver=rtlsdr"); + cfg.sdr.sample_rate = 1_000_000; + cfg.sdr.center_offset_hz = 0; + add_channel(&mut cfg, "ch_nyquist", 500_000, false, vec![]); + let errors = cfg.validate_sdr(); + assert!( + errors + .iter() + .any(|e| e.contains("ch_nyquist") + && (e.contains("Nyquist") || e.contains("exceeds"))), + "expected error for IF exactly at Nyquist, got: {:?}", + errors + ); + } + + #[test] + fn test_sdr_validate_dual_stream_opus() { + let mut cfg = sdr_config_with_access("driver=rtlsdr"); + add_channel(&mut cfg, "ch1", 0, true, vec![]); + add_channel(&mut cfg, "ch2", 10_000, true, vec![]); + let errors = cfg.validate_sdr(); + assert!( + errors.iter().any(|e| e.contains("stream_opus")), + "expected error mentioning 'stream_opus', got: {:?}", + errors + ); + } + + #[test] + fn test_sdr_validate_tx_enabled_with_sdr() { + let mut cfg = sdr_config_with_access("driver=rtlsdr"); + cfg.audio.tx_enabled = true; + let errors = cfg.validate_sdr(); + assert!( + errors.iter().any(|e| e.contains("tx_enabled")), + "expected error mentioning 'tx_enabled', got: {:?}", + errors + ); + } + + #[test] + fn test_sdr_validate_duplicate_decoder() { + let mut cfg = sdr_config_with_access("driver=rtlsdr"); + add_channel(&mut cfg, "ch1", 0, false, vec!["ft8".to_string()]); + add_channel(&mut cfg, "ch2", 10_000, false, vec!["ft8".to_string()]); + let errors = cfg.validate_sdr(); + assert!( + errors + .iter() + .any(|e| e.contains("ft8") || e.contains("decoder")), + "expected error mentioning 'ft8' or 'decoder', got: {:?}", + errors + ); + } + + #[test] + fn test_sdr_validate_multiple_errors() { + let mut cfg = sdr_config_with_access("placeholder"); + cfg.rig.access.args = None; + cfg.sdr.sample_rate = 0; + cfg.audio.tx_enabled = true; + let errors = cfg.validate_sdr(); + assert_eq!( + errors.len(), + 3, + "expected exactly 3 errors, got: {:?}", + errors + ); + } + + #[test] + fn test_validate_rejects_invalid_sdr_squelch_threshold() { + let mut cfg = ServerConfig::default(); + cfg.rig.access.port = Some("/dev/ttyUSB0".to_string()); + cfg.rig.access.baud = Some(9600); + cfg.sdr.squelch.threshold_db = 10.0; + let err = cfg + .validate() + .expect_err("expected squelch threshold validation error"); + assert!( + err.contains("squelch") && err.contains("threshold_db"), + "unexpected validation error: {err}" + ); + } + + #[test] + fn test_validate_rejects_invalid_sdr_squelch_hysteresis() { + let mut cfg = ServerConfig::default(); + cfg.rig.access.port = Some("/dev/ttyUSB0".to_string()); + cfg.rig.access.baud = Some(9600); + cfg.sdr.squelch.hysteresis_db = 99.0; + let err = cfg + .validate() + .expect_err("expected squelch hysteresis validation error"); + assert!( + err.contains("squelch") && err.contains("hysteresis_db"), + "unexpected validation error: {err}" + ); + } + + // --- MR-08: multi-rig config tests --- + + #[test] + fn test_resolved_rigs_legacy_flat_fields() { + let mut cfg = ServerConfig::default(); + cfg.rig.model = Some("ft817".to_string()); + cfg.rig.access.access_type = Some("serial".to_string()); + cfg.rig.access.port = Some("/dev/ttyUSB0".to_string()); + cfg.rig.access.baud = Some(9600); + + let rigs = cfg.resolved_rigs(); + assert_eq!(rigs.len(), 1); + assert_eq!(rigs[0].id, "default"); + assert_eq!(rigs[0].rig.model, Some("ft817".to_string())); + } + + #[test] + fn test_resolved_rigs_multi_rig_toml() { + let toml_str = r#" +[general] +callsign = "W1AW" + +[[rigs]] +id = "hf" + +[rigs.rig] +model = "ft450d" +initial_freq_hz = 14074000 + +[rigs.rig.access] +type = "serial" +port = "/dev/ttyUSB0" +baud = 9600 + +[rigs.audio] +port = 4531 + +[[rigs]] +id = "sdr" + +[rigs.rig] +model = "soapysdr" + +[rigs.rig.access] +type = "sdr" +args = "driver=rtlsdr" + +[rigs.audio] +port = 4532 +"#; + let cfg: ServerConfig = toml::from_str(toml_str).unwrap(); + let rigs = cfg.resolved_rigs(); + assert_eq!(rigs.len(), 2); + assert_eq!(rigs[0].id, "hf"); + assert_eq!(rigs[0].rig.model, Some("ft450d".to_string())); + assert_eq!(rigs[0].audio.port, 4531); + assert_eq!(rigs[1].id, "sdr"); + assert_eq!(rigs[1].rig.model, Some("soapysdr".to_string())); + assert_eq!(rigs[1].audio.port, 4532); + } + + #[test] + fn test_resolved_rigs_skips_disabled_entries() { + let toml_str = r#" +[[rigs]] +id = "disabled" +enable = false +[rigs.rig] +model = "ft817" +[rigs.rig.access] +type = "serial" +port = "/dev/ttyUSB0" +baud = 9600 +[rigs.audio] +port = 4531 + +[[rigs]] +id = "enabled" +[rigs.rig] +model = "ft450d" +[rigs.rig.access] +type = "serial" +port = "/dev/ttyUSB1" +baud = 9600 +[rigs.audio] +port = 4532 +"#; + let cfg: ServerConfig = toml::from_str(toml_str).unwrap(); + let rigs = cfg.resolved_rigs(); + assert_eq!(rigs.len(), 1); + assert_eq!(rigs[0].id, "enabled"); + } + + #[test] + fn test_validate_rejects_duplicate_rig_ids() { + let toml_str = r#" +[[rigs]] +id = "rig1" +[rigs.rig] +model = "ft817" +[rigs.rig.access] +type = "serial" +port = "/dev/ttyUSB0" +baud = 9600 +[rigs.audio] +port = 4531 + +[[rigs]] +id = "rig1" +[rigs.rig] +model = "ft450d" +[rigs.rig.access] +type = "serial" +port = "/dev/ttyUSB1" +baud = 9600 +[rigs.audio] +port = 4532 +"#; + let cfg: ServerConfig = toml::from_str(toml_str).unwrap(); + let result = cfg.validate(); + assert!(result.is_err()); + assert!( + result.unwrap_err().contains("duplicate rig id"), + "expected error about duplicate rig id" + ); + } + + #[test] + fn test_validate_rejects_duplicate_audio_ports() { + let toml_str = r#" +[[rigs]] +id = "rig1" +[rigs.rig] +model = "ft817" +[rigs.rig.access] +type = "serial" +port = "/dev/ttyUSB0" +baud = 9600 +[rigs.audio] +port = 4531 + +[[rigs]] +id = "rig2" +[rigs.rig] +model = "ft450d" +[rigs.rig.access] +type = "serial" +port = "/dev/ttyUSB1" +baud = 9600 +[rigs.audio] +port = 4531 +"#; + let cfg: ServerConfig = toml::from_str(toml_str).unwrap(); + let result = cfg.validate(); + assert!(result.is_err()); + assert!( + result.unwrap_err().contains("duplicate audio port"), + "expected error about duplicate audio port" + ); + } + + #[test] + fn test_validate_allows_disabled_duplicate_rig_ids() { + let toml_str = r#" +[[rigs]] +id = "rig1" +[rigs.rig] +model = "ft817" +[rigs.rig.access] +type = "serial" +port = "/dev/ttyUSB0" +baud = 9600 +[rigs.audio] +port = 4531 + +[[rigs]] +id = "rig1" +enable = false +[rigs.rig] +model = "ft450d" +[rigs.rig.access] +type = "serial" +port = "/dev/ttyUSB1" +baud = 9600 +[rigs.audio] +port = 4532 +"#; + let cfg: ServerConfig = toml::from_str(toml_str).unwrap(); + assert!( + cfg.validate().is_ok(), + "expected Ok because disabled rigs are excluded from uniqueness checks" + ); + } + + #[test] + fn test_validate_rejects_when_all_multi_rigs_disabled() { + let toml_str = r#" +[[rigs]] +id = "rig1" +enable = false +[rigs.rig] +model = "ft817" +[rigs.rig.access] +type = "serial" +port = "/dev/ttyUSB0" +baud = 9600 +[rigs.audio] +port = 4531 +"#; + let cfg: ServerConfig = toml::from_str(toml_str).unwrap(); + let result = cfg.validate(); + assert!(result.is_err()); + assert!( + result.unwrap_err().contains("no enabled entries"), + "expected error about enabled rig entries" + ); + } + + #[test] + fn test_validate_accepts_multi_rig_unique_ids_and_ports() { + let toml_str = r#" +[[rigs]] +id = "hf" +[rigs.rig] +model = "ft450d" +[rigs.rig.access] +type = "serial" +port = "/dev/ttyUSB0" +baud = 9600 +[rigs.audio] +port = 4531 + +[[rigs]] +id = "sdr" +[rigs.rig] +model = "soapysdr" +[rigs.rig.access] +type = "sdr" +args = "driver=rtlsdr" +[rigs.audio] +port = 4532 +"#; + let cfg: ServerConfig = toml::from_str(toml_str).unwrap(); + // validate() uses the flat [rig] field for rig-level checks; multi-rig + // validation focuses on ID/port uniqueness. The flat [rig] is default + // (no model), so the access check is skipped when both fields are absent. + assert!( + cfg.validate().is_ok(), + "expected Ok for valid multi-rig config" + ); + } +} diff --git a/src/trx-app/src/shared_config.rs b/src/trx-config/src/shared.rs similarity index 100% rename from src/trx-app/src/shared_config.rs rename to src/trx-config/src/shared.rs diff --git a/src/trx-config/src/url.rs b/src/trx-config/src/url.rs new file mode 100644 index 00000000..4c3ba821 --- /dev/null +++ b/src/trx-config/src/url.rs @@ -0,0 +1,173 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +//! Parsing for the `host:port` endpoint URLs used by the client's `[[remotes]]` +//! and `[frontends.audio]` settings. +//! +//! These live next to the config structs because validation needs them; the +//! client re-exports them for its connection code. + +/// Default port for the server's JSON control listener. +pub const DEFAULT_REMOTE_PORT: u16 = 4530; +/// Default port for the server's Opus audio listener. +pub const DEFAULT_AUDIO_PORT: u16 = 4531; + +/// A resolved `host:port` pair. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RemoteEndpoint { + pub host: String, + pub port: u16, +} + +impl RemoteEndpoint { + /// Format as a connect string, bracketing bare IPv6 hosts. + pub fn connect_addr(&self) -> String { + if self.host.contains(':') && !self.host.starts_with('[') { + format!("[{}]:{}", self.host, self.port) + } else { + format!("{}:{}", self.host, self.port) + } + } +} + +/// Parse a remote control URL, defaulting to port 4530. +pub fn parse_remote_url(url: &str) -> Result { + parse_endpoint_url(url, DEFAULT_REMOTE_PORT, "remote") +} + +/// Parse an audio stream URL, defaulting to port 4531. +pub fn parse_audio_url(url: &str) -> Result { + parse_endpoint_url(url, DEFAULT_AUDIO_PORT, "audio") +} + +fn parse_endpoint_url(url: &str, default_port: u16, kind: &str) -> Result { + let trimmed = url.trim(); + if trimmed.is_empty() { + return Err(format!("{kind} url is empty")); + } + + let addr = trimmed + .strip_prefix("tcp://") + .or_else(|| trimmed.strip_prefix("http-json://")) + .or_else(|| trimmed.strip_prefix("audio://")) + .unwrap_or(trimmed); + + parse_host_port(addr, default_port, kind) +} + +fn parse_host_port(input: &str, default_port: u16, kind: &str) -> Result { + if let Some(rest) = input.strip_prefix('[') { + let closing = rest + .find(']') + .ok_or_else(|| format!("invalid {kind} url: missing closing ']' for IPv6 host"))?; + let host = &rest[..closing]; + let remainder = &rest[closing + 1..]; + if host.is_empty() { + return Err(format!("invalid {kind} url: host is empty")); + } + let port = if remainder.is_empty() { + default_port + } else if let Some(port_str) = remainder.strip_prefix(':') { + parse_port(port_str, kind)? + } else { + return Err(format!("invalid {kind} url: expected ':' after ']'")); + }; + return Ok(RemoteEndpoint { + host: host.to_string(), + port, + }); + } + + if input.contains(':') { + if input.matches(':').count() > 1 { + return Err(format!( + "invalid {kind} url: IPv6 host must be bracketed like [::1]:4532" + )); + } + let (host, port_str) = input + .rsplit_once(':') + .ok_or_else(|| format!("invalid {kind} url: expected host:port"))?; + if host.is_empty() { + return Err(format!("invalid {kind} url: host is empty")); + } + return Ok(RemoteEndpoint { + host: host.to_string(), + port: parse_port(port_str, kind)?, + }); + } + + Ok(RemoteEndpoint { + host: input.to_string(), + port: default_port, + }) +} + +fn parse_port(port_str: &str, kind: &str) -> Result { + let port: u16 = port_str + .parse() + .map_err(|_| format!("invalid {kind} port: '{port_str}'"))?; + if port == 0 { + return Err(format!("invalid {kind} port: 0")); + } + Ok(port) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_remote_url_defaults_port() { + let ep = parse_remote_url("example.com").unwrap(); + assert_eq!(ep.host, "example.com"); + assert_eq!(ep.port, DEFAULT_REMOTE_PORT); + } + + #[test] + fn test_parse_audio_url_defaults_port() { + let ep = parse_audio_url("example.com").unwrap(); + assert_eq!(ep.port, DEFAULT_AUDIO_PORT); + } + + #[test] + fn test_parse_strips_schemes() { + for url in &[ + "tcp://host:9000", + "http-json://host:9000", + "audio://host:9000", + ] { + let ep = parse_remote_url(url).unwrap(); + assert_eq!(ep.host, "host"); + assert_eq!(ep.port, 9000); + } + } + + #[test] + fn test_parse_bracketed_ipv6() { + let ep = parse_remote_url("[::1]:4532").unwrap(); + assert_eq!(ep.host, "::1"); + assert_eq!(ep.port, 4532); + assert_eq!(ep.connect_addr(), "[::1]:4532"); + } + + #[test] + fn test_parse_rejects_unbracketed_ipv6() { + assert!(parse_remote_url("::1:4532").is_err()); + } + + #[test] + fn test_parse_rejects_empty_host() { + assert!(parse_remote_url(":4530").is_err()); + } + + #[test] + fn test_parse_rejects_zero_port() { + assert!(parse_remote_url("host:0").is_err()); + } + + #[test] + fn test_parse_rejects_empty_url() { + assert!(parse_remote_url(" ").is_err()); + } +} diff --git a/src/trx-configurator/Cargo.toml b/src/trx-configurator/Cargo.toml index f8250cd5..1db800a0 100644 --- a/src/trx-configurator/Cargo.toml +++ b/src/trx-configurator/Cargo.toml @@ -16,6 +16,7 @@ clap = { workspace = true, features = ["derive"] } dialoguer = "0.11" tokio-serial = { workspace = true } toml_edit = "0.22" +trx-config = { path = "../trx-config" } [dev-dependencies] tempfile = "3" diff --git a/src/trx-server/Cargo.toml b/src/trx-server/Cargo.toml index b5a83373..90740a89 100644 --- a/src/trx-server/Cargo.toml +++ b/src/trx-server/Cargo.toml @@ -32,6 +32,7 @@ cpal = "0.15" num-complex = "0.4" opus = "0.3" trx-app = { path = "../trx-app" } +trx-config = { path = "../trx-config" } trx-backend = { path = "trx-backend", features = ["soapysdr"] } trx-ais = { path = "../decoders/trx-ais" } trx-vdes = { path = "../decoders/trx-vdes" } diff --git a/src/trx-server/src/config.rs b/src/trx-server/src/config.rs index 99453a8e..924d810c 100644 --- a/src/trx-server/src/config.rs +++ b/src/trx-server/src/config.rs @@ -2,1523 +2,10 @@ // // SPDX-License-Identifier: GPL-2.0-or-later -//! Configuration file support for trx-server. +//! Server configuration types. //! -//! Config is loaded from the `[trx-server]` section of `trx-rs.toml`. -//! Default search order: -//! 1. Path specified via `--config` CLI argument -//! 2. `./trx-rs.toml` -//! 3. `~/.config/trx-rs/trx-rs.toml` -//! 4. `/etc/trx-rs/trx-rs.toml` +//! The definitions live in the shared `trx-config` crate so that +//! `trx-configurator` checks a config with exactly the same code the server +//! loads it with. This module re-exports them under the binary's own path. -use std::net::IpAddr; -use std::path::{Path, PathBuf}; - -use serde::{Deserialize, Serialize}; -use trx_app::{validate_log_level, validate_tokens, ConfigError, ConfigFile}; -pub use trx_decode_log::DecodeLogsConfig; - -use trx_core::rig::state::RigMode; - -/// Per-rig instance configuration for multi-rig setups. -/// -/// Each entry in `[[rigs]]` becomes one of these. The flat top-level -/// `[rig]` / `[audio]` / `[sdr]` / `[pskreporter]` / `[aprsfi]` / -/// `[behavior]` / `[decode_logs]` fields are still supported via -/// `ServerConfig::resolved_rigs()` which synthesises a single-element list -/// with `id = "default"` when `rigs` is empty. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct RigInstanceConfig { - /// Whether this rig instance should be started. - /// Defaults to true so existing configs remain unchanged. - pub enable: bool, - /// Stable rig identifier used in protocol routing. - pub id: String, - /// Display name for the rig (e.g., "HF Transceiver", "VHF/UHF SDR"). - /// If not specified, defaults to the rig id. - pub name: Option, - /// Rig backend configuration. - pub rig: RigConfig, - /// Polling and retry behavior. - pub behavior: BehaviorConfig, - /// Audio streaming configuration for this rig. - pub audio: AudioConfig, - /// SDR pipeline configuration (only used when [rigs.rig.access] type = "sdr"). - pub sdr: SdrConfig, - /// PSK Reporter uplink for this rig. - pub pskreporter: PskReporterConfig, - /// APRS-IS IGate uplink for this rig. - pub aprsfi: AprsFiConfig, - /// Decoder file logging for this rig. - pub decode_logs: DecodeLogsConfig, -} - -impl Default for RigInstanceConfig { - fn default() -> Self { - Self { - enable: true, - id: String::new(), - name: None, - rig: RigConfig::default(), - behavior: BehaviorConfig::default(), - audio: AudioConfig::default(), - sdr: SdrConfig::default(), - pskreporter: PskReporterConfig::default(), - aprsfi: AprsFiConfig::default(), - decode_logs: DecodeLogsConfig::default(), - } - } -} - -impl RigInstanceConfig { - /// Get the display name for this rig. - /// Returns the configured name if set, otherwise the id. - pub fn display_name(&self) -> &str { - self.name.as_deref().unwrap_or(&self.id) - } -} - -/// Top-level server configuration structure. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(default)] -pub struct ServerConfig { - /// General settings - pub general: GeneralConfig, - /// Rig backend configuration (legacy flat; use [[rigs]] for multi-rig) - pub rig: RigConfig, - /// Polling and retry behavior (legacy flat) - pub behavior: BehaviorConfig, - /// TCP listener configuration - pub listen: ListenConfig, - /// Audio streaming configuration (legacy flat) - pub audio: AudioConfig, - /// PSK Reporter uplink configuration (legacy flat) - pub pskreporter: PskReporterConfig, - /// APRS-IS IGate uplink configuration (legacy flat) - pub aprsfi: AprsFiConfig, - /// Decoder file logging configuration (legacy flat) - pub decode_logs: DecodeLogsConfig, - /// SDR pipeline configuration (legacy flat; used when [rig.access] type = "sdr"). - pub sdr: SdrConfig, - /// Timeout and buffer-size tuning knobs. - pub timeouts: TimeoutsConfig, - /// Multi-rig instance list. When non-empty, takes priority over the flat fields. - #[serde(rename = "rigs", default)] - pub rigs: Vec, -} - -/// General application settings. -#[derive(Debug, Clone, 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, - /// Receiver latitude (decimal degrees, WGS84) - pub latitude: Option, - /// Receiver longitude (decimal degrees, WGS84) - pub longitude: Option, -} - -impl Default for GeneralConfig { - fn default() -> Self { - Self { - callsign: Some("N0CALL".to_string()), - log_level: None, - latitude: None, - longitude: None, - } - } -} - -/// Rig backend configuration. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct RigConfig { - /// Rig model (e.g., "ft817", "ft450d", "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, - /// SoapySDR device args string (for sdr access), e.g. "driver=rtlsdr". - pub args: 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, - /// Whether to prime both VFOs on startup by toggling and reading each. - /// Defaults to true for rigs with dual VFOs (e.g. FT-817). - pub vfo_prime: bool, -} - -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, - vfo_prime: true, - } - } -} - -/// Timeout and buffer-size tuning knobs. -/// -/// All durations are in milliseconds. The defaults match the previously -/// hard-coded values, so existing deployments are unaffected. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct TimeoutsConfig { - /// Maximum time (ms) to wait for a single rig command to complete. - pub command_exec_timeout_ms: u64, - /// Maximum time (ms) for a CAT poll refresh cycle. - pub poll_refresh_timeout_ms: u64, - /// Maximum time (ms) for low-level listener I/O operations (read/write/flush). - pub io_timeout_ms: u64, - /// Maximum time (ms) to wait for a rig command response in the listener. - pub request_timeout_ms: u64, - /// Capacity of the per-rig command channel (number of queued requests). - pub rig_task_channel_buffer: usize, -} - -impl Default for TimeoutsConfig { - fn default() -> Self { - Self { - command_exec_timeout_ms: 10_000, - poll_refresh_timeout_ms: 8_000, - io_timeout_ms: 10_000, - request_timeout_ms: 12_000, - rig_task_channel_buffer: 32, - } - } -} - -/// TCP listener configuration. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct ListenConfig { - /// Whether the listener is enabled - pub enabled: bool, - /// IP address to listen on - pub listen: IpAddr, - /// TCP port to listen on - pub port: u16, - /// Authentication configuration - pub auth: AuthConfig, -} - -impl Default for ListenConfig { - fn default() -> Self { - Self { - enabled: true, - listen: IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), - port: 4530, - auth: AuthConfig::default(), - } - } -} - -/// Authentication configuration for the TCP listener. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(default)] -pub struct AuthConfig { - /// Valid authentication tokens (empty = no auth required) - pub tokens: Vec, -} - -/// Audio streaming configuration. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct AudioConfig { - /// Whether audio streaming is enabled - pub enabled: bool, - /// IP address to listen on for audio connections - pub listen: IpAddr, - /// TCP port for audio connections - pub port: u16, - /// Whether RX audio capture is enabled - pub rx_enabled: bool, - /// Whether TX audio playback is enabled - pub tx_enabled: bool, - /// Audio input device name (None = system default) - pub device: Option, - /// Sample rate in Hz - pub sample_rate: u32, - /// Number of audio channels - pub channels: u8, - /// Opus frame duration in milliseconds - pub frame_duration_ms: u16, - /// Opus bitrate in bits per second - pub bitrate_bps: u32, -} - -impl Default for AudioConfig { - fn default() -> Self { - Self { - enabled: true, - listen: IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), - port: 4531, - rx_enabled: true, - tx_enabled: true, - device: None, - sample_rate: 48000, - channels: 2, - frame_duration_ms: 20, - bitrate_bps: 256000, - } - } -} - -pub use trx_reporting::{AprsFiConfig, PskReporterConfig}; - -/// Top-level SDR configuration (only used when [rig.access] type = "sdr"). -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct SdrConfig { - /// SoapySDR IQ capture sample rate (Hz). Must be supported by the device. - pub sample_rate: u32, - /// Hardware IF filter bandwidth (Hz). - pub bandwidth: u32, - /// WFM deemphasis time constant in microseconds (50 or 75). - pub wfm_deemphasis_us: u32, - /// SDR tunes this many Hz below the dial frequency to keep signal off DC. - pub center_offset_hz: i64, - /// Gain configuration. - pub gain: SdrGainConfig, - /// Virtual software squelch applied to demodulated audio except WFM. - pub squelch: SdrSquelchConfig, - /// Noise blanker for impulse noise suppression on IQ samples. - pub noise_blanker: SdrNoiseBlankerConfig, - /// Virtual receiver channels (at least one required when SDR backend is active). - pub channels: Vec, - /// Maximum number of simultaneous virtual channels (including the primary). - /// Default: 4. - #[serde(default = "default_max_virtual_channels")] - pub max_virtual_channels: usize, -} - -fn default_max_virtual_channels() -> usize { - 4 -} - -impl Default for SdrConfig { - fn default() -> Self { - Self { - sample_rate: 1_920_000, - bandwidth: 1_500_000, - wfm_deemphasis_us: 50, - center_offset_hz: 100_000, - gain: SdrGainConfig::default(), - squelch: SdrSquelchConfig::default(), - noise_blanker: SdrNoiseBlankerConfig::default(), - channels: Vec::new(), - max_virtual_channels: default_max_virtual_channels(), - } - } -} - -/// Virtual squelch settings for SoapySDR demodulated audio (except WFM mode). -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct SdrSquelchConfig { - /// Enables software squelch for demodulated audio except WFM. - pub enabled: bool, - /// Open threshold in dBFS (typical range: -120..0). - pub threshold_db: f32, - /// Hysteresis in dB used when closing the squelch. - pub hysteresis_db: f32, - /// Tail hold time after dropping below threshold. - pub tail_ms: u32, -} - -impl Default for SdrSquelchConfig { - fn default() -> Self { - Self { - enabled: false, - threshold_db: -65.0, - hysteresis_db: 3.0, - tail_ms: 180, - } - } -} - -/// Noise blanker settings for impulse noise suppression on IQ samples. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct SdrNoiseBlankerConfig { - /// Enables the noise blanker. - pub enabled: bool, - /// Threshold multiplier for impulse detection (typical range: 1..100). - /// A sample whose magnitude exceeds threshold × running RMS is blanked. - pub threshold: f64, -} - -impl Default for SdrNoiseBlankerConfig { - fn default() -> Self { - Self { - enabled: false, - threshold: 10.0, - } - } -} - -/// Gain control mode for the SDR device. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct SdrGainConfig { - /// "auto" (hardware AGC) or "manual" (fixed dB). - pub mode: String, - /// Gain in dB; effective only when mode = "manual". - pub value: f64, - /// Optional hard ceiling for the applied hardware gain in dB. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub max_value: Option, -} - -impl Default for SdrGainConfig { - fn default() -> Self { - Self { - mode: "auto".to_string(), - value: 30.0, - max_value: None, - } - } -} - -/// One virtual receiver channel within the wideband IQ stream. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default)] -pub struct SdrChannelConfig { - /// Human-readable identifier used in logs. - pub id: String, - /// Frequency offset from the dial frequency (Hz). Primary channel should use 0. - pub offset_hz: i64, - /// Demodulation mode: "auto" (follows RigCat set_mode) or a fixed RigMode string - /// (e.g. "USB", "FM"). - pub mode: String, - /// One-sided bandwidth of the post-demod audio BPF (Hz). - pub audio_bandwidth_hz: u32, - /// CW tone centre frequency in the audio domain (Hz). Default 700. - pub cw_center_hz: u32, - /// Pre-demod bandwidth for WFM only (Hz). Default 75000. - pub wfm_bandwidth_hz: u32, - /// Decoder names that receive this channel's PCM frames. - /// Valid values: "ft8", "wspr", "aprs", "cw". - pub decoders: Vec, - /// If true, encode this channel's audio as Opus and stream over TCP. - /// At most one channel may set this to true. - pub stream_opus: bool, -} - -impl Default for SdrChannelConfig { - fn default() -> Self { - Self { - id: String::new(), - offset_hz: 0, - mode: "auto".to_string(), - audio_bandwidth_hz: 3000, - cw_center_hz: 700, - wfm_bandwidth_hz: 75_000, - decoders: Vec::new(), - stream_opus: false, - } - } -} - -impl ServerConfig { - pub fn validate(&self) -> Result<(), String> { - validate_log_level(self.general.log_level.as_deref())?; - validate_coordinates(self.general.latitude, self.general.longitude)?; - - if self.rig.initial_freq_hz == 0 { - return Err("[rig].initial_freq_hz must be > 0".to_string()); - } - - validate_access(&self.rig.access)?; - - if self.behavior.poll_interval_ms == 0 { - return Err("[behavior].poll_interval_ms must be > 0".to_string()); - } - if self.behavior.poll_interval_tx_ms == 0 { - return Err("[behavior].poll_interval_tx_ms must be > 0".to_string()); - } - if self.behavior.max_retries == 0 { - return Err("[behavior].max_retries must be > 0".to_string()); - } - if self.behavior.retry_base_delay_ms == 0 { - return Err("[behavior].retry_base_delay_ms must be > 0".to_string()); - } - - validate_tokens("[listen.auth].tokens", &self.listen.auth.tokens)?; - if self.listen.enabled && self.listen.port == 0 { - return Err("[listen].port must be > 0 when listener is enabled".to_string()); - } - - if self.audio.enabled { - if self.audio.port == 0 { - return Err("[audio].port must be > 0 when audio is enabled".to_string()); - } - if !self.audio.rx_enabled && !self.audio.tx_enabled { - return Err( - "[audio] enabled but both rx_enabled and tx_enabled are false".to_string(), - ); - } - if self.audio.sample_rate < 8_000 || self.audio.sample_rate > 192_000 { - return Err("[audio].sample_rate must be in range 8000..=192000".to_string()); - } - if !(1..=2).contains(&self.audio.channels) { - return Err("[audio].channels must be 1 or 2".to_string()); - } - match self.audio.frame_duration_ms { - 3 | 5 | 10 | 20 | 40 | 60 => {} - _ => { - return Err( - "[audio].frame_duration_ms must be one of: 3, 5, 10, 20, 40, 60" - .to_string(), - ) - } - } - if self.audio.bitrate_bps == 0 { - return Err("[audio].bitrate_bps must be > 0".to_string()); - } - } - - if self.pskreporter.enabled { - if self.pskreporter.host.trim().is_empty() { - return Err("[pskreporter].host must not be empty".to_string()); - } - if self.pskreporter.port == 0 { - return Err("[pskreporter].port must be > 0".to_string()); - } - if self.pskreporter.receiver_locator.is_none() - && (self.general.latitude.is_none() || self.general.longitude.is_none()) - { - return Err( - "[pskreporter] enabled requires either [pskreporter].receiver_locator \ - or [general].latitude and [general].longitude" - .to_string(), - ); - } - } - - if self.aprsfi.enabled { - if self.aprsfi.host.trim().is_empty() { - return Err("[aprsfi].host must not be empty".to_string()); - } - if self.aprsfi.port == 0 { - return Err("[aprsfi].port must be > 0".to_string()); - } - } - - if let Some(max_gain) = self.sdr.gain.max_value { - if !max_gain.is_finite() { - return Err("[sdr.gain].max_value must be finite".to_string()); - } - if max_gain < 0.0 { - return Err("[sdr.gain].max_value must be >= 0".to_string()); - } - } - validate_sdr_squelch_config("[sdr.squelch]", &self.sdr.squelch)?; - validate_sdr_nb_config("[sdr.noise_blanker]", &self.sdr.noise_blanker)?; - - // Multi-rig uniqueness checks. - if !self.rigs.is_empty() { - let mut seen_ids: std::collections::HashSet = std::collections::HashSet::new(); - let mut seen_ports: std::collections::HashSet = std::collections::HashSet::new(); - let mut enabled_count = 0usize; - for rig in &self.rigs { - if !rig.enable { - continue; - } - enabled_count += 1; - // Check for explicit duplicate IDs (empty IDs are auto-generated later). - if !rig.id.trim().is_empty() && !seen_ids.insert(rig.id.clone()) { - return Err(format!("[[rigs]] duplicate rig id: \"{}\"", rig.id)); - } - if rig.audio.enabled && !seen_ports.insert(rig.audio.port) { - return Err(format!( - "[[rigs]] duplicate audio port {} (rig id: \"{}\")", - rig.audio.port, rig.id - )); - } - if let Some(max_gain) = rig.sdr.gain.max_value { - if !max_gain.is_finite() { - return Err(format!( - "[[rigs]] [sdr.gain].max_value must be finite (rig id: \"{}\")", - rig.id - )); - } - if max_gain < 0.0 { - return Err(format!( - "[[rigs]] [sdr.gain].max_value must be >= 0 (rig id: \"{}\")", - rig.id - )); - } - } - validate_sdr_squelch_config( - &format!("[[rigs]] [sdr.squelch] (rig id: \"{}\")", rig.id), - &rig.sdr.squelch, - )?; - validate_sdr_nb_config( - &format!("[[rigs]] [sdr.noise_blanker] (rig id: \"{}\")", rig.id), - &rig.sdr.noise_blanker, - )?; - } - if enabled_count == 0 { - return Err( - "[[rigs]] has no enabled entries; set at least one [[rigs]].enable = true" - .to_string(), - ); - } - } - - if self.decode_logs.enabled { - if self.decode_logs.dir.trim().is_empty() { - return Err("[decode_logs].dir must not be empty when enabled".to_string()); - } - if self.decode_logs.aprs_file.trim().is_empty() - || self.decode_logs.cw_file.trim().is_empty() - || self.decode_logs.ft8_file.trim().is_empty() - || self.decode_logs.wspr_file.trim().is_empty() - { - return Err("[decode_logs] file names must not be empty when enabled".to_string()); - } - } - - Ok(()) - } - - /// Validate SDR-specific config rules (see SDR.md §11). - /// Returns a Vec of error strings; empty means valid. - pub fn validate_sdr(&self) -> Vec { - let mut errors = Vec::new(); - - // Only validate if access type is "sdr" - let is_sdr = self.rig.access.access_type.as_deref() == Some("sdr"); - if !is_sdr { - return errors; - } - - // args must be non-empty - if self - .rig - .access - .args - .as_deref() - .map(str::is_empty) - .unwrap_or(true) - { - errors.push("[rig.access] args must be non-empty for type = \"sdr\"".into()); - } - - // sample_rate must be non-zero - if self.sdr.sample_rate == 0 { - errors.push("[sdr] sample_rate must be > 0".into()); - } - - // Every channel's IF must fit within the captured bandwidth - let half_rate = self.sdr.sample_rate as i64 / 2; - for ch in &self.sdr.channels { - let channel_if = self.sdr.center_offset_hz + ch.offset_hz; - if channel_if.abs() >= half_rate { - errors.push(format!( - "[sdr.channels] id=\"{}\" IF frequency {} Hz exceeds Nyquist limit ±{} Hz", - ch.id, channel_if, half_rate - )); - } - } - - // At most one channel may have stream_opus = true - let opus_count = self.sdr.channels.iter().filter(|c| c.stream_opus).count(); - if opus_count > 1 { - errors.push(format!( - "[sdr.channels] at most one channel may have stream_opus = true (found {})", - opus_count - )); - } - - // tx_enabled must be false with SDR backend - if self.audio.tx_enabled { - errors.push("[audio] tx_enabled must be false when using the soapysdr backend".into()); - } - - // Decoder names must not appear in more than one channel - let mut seen: std::collections::HashMap = std::collections::HashMap::new(); - for ch in &self.sdr.channels { - for dec in &ch.decoders { - if let Some(prev_id) = seen.get(dec) { - errors.push(format!( - "[sdr.channels] decoder \"{}\" appears in both \"{}\" and \"{}\"", - dec, prev_id, ch.id - )); - } else { - seen.insert(dec.clone(), ch.id.clone()); - } - } - } - - errors - } - - /// Load configuration from a specific file path. - pub fn load_from_file(path: &Path) -> Result { - ::load_from_file(path) - } - - /// 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> { - ::load_from_default_paths() - } - - /// Return the effective list of rig instances to spawn. - /// - /// When `[[rigs]]` entries are present, only enabled entries are returned. - /// Otherwise the legacy flat `[rig]` / `[audio]` / … fields are synthesised - /// into a single `RigInstanceConfig` with `id = "default"`. - pub fn resolved_rigs(&self) -> Vec { - if !self.rigs.is_empty() { - // Auto-generate IDs for rigs that don't have explicit ones. - return self - .rigs - .iter() - .enumerate() - .filter(|(_, rig)| rig.enable) - .map(|(idx, rig)| { - let id = if rig.id.trim().is_empty() { - // Generate ID from model name with counter. - let model = rig.rig.model.as_deref().unwrap_or("unknown").to_lowercase(); - format!("{}_{}", model, idx) - } else { - rig.id.clone() - }; - - RigInstanceConfig { id, ..rig.clone() } - }) - .collect(); - } - vec![RigInstanceConfig { - enable: true, - id: "default".to_string(), - name: None, - rig: self.rig.clone(), - behavior: self.behavior.clone(), - audio: self.audio.clone(), - sdr: self.sdr.clone(), - pskreporter: self.pskreporter.clone(), - aprsfi: self.aprsfi.clone(), - decode_logs: self.decode_logs.clone(), - }] - } - - /// Generate an example configuration wrapped under the `[trx-server]` - /// section header, suitable for use in a combined `trx-rs.toml` file. - pub fn example_combined_toml() -> String { - #[derive(serde::Serialize)] - struct Wrapper { - #[serde(rename = "trx-server")] - inner: ServerConfig, - } - let example = ServerConfig { - general: GeneralConfig { - callsign: Some("N0CALL".to_string()), - log_level: Some("info".to_string()), - latitude: Some(52.2297), - longitude: Some(21.0122), - }, - 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, - args: None, - }, - }, - behavior: BehaviorConfig::default(), - listen: ListenConfig::default(), - audio: AudioConfig::default(), - pskreporter: PskReporterConfig::default(), - aprsfi: AprsFiConfig::default(), - decode_logs: DecodeLogsConfig::default(), - sdr: SdrConfig::default(), - timeouts: TimeoutsConfig::default(), - rigs: Vec::new(), - }; - toml::to_string_pretty(&Wrapper { inner: example }).unwrap_or_default() - } -} - -fn validate_coordinates(latitude: Option, longitude: Option) -> Result<(), String> { - match (latitude, longitude) { - (Some(lat), Some(lon)) => { - if !(-90.0..=90.0).contains(&lat) { - return Err("[general].latitude must be in range -90..=90".to_string()); - } - if !(-180.0..=180.0).contains(&lon) { - return Err("[general].longitude must be in range -180..=180".to_string()); - } - Ok(()) - } - (None, None) => Ok(()), - _ => Err( - "[general].latitude and [general].longitude must be set together or both omitted" - .to_string(), - ), - } -} - -fn validate_access(access: &AccessConfig) -> Result<(), String> { - let serial_fields_set = access.port.is_some() || access.baud.is_some(); - let tcp_fields_set = access.host.is_some() || access.tcp_port.is_some(); - - if access.access_type.is_none() && !serial_fields_set && !tcp_fields_set { - return Ok(()); - } - - match access.access_type.as_deref().unwrap_or("serial") { - "serial" => { - if access.port.as_deref().unwrap_or("").trim().is_empty() { - return Err( - "[rig.access].port must be set for serial access ([rig.access].type='serial')" - .to_string(), - ); - } - if access.baud.unwrap_or(0) == 0 { - return Err( - "[rig.access].baud must be > 0 for serial access ([rig.access].type='serial')" - .to_string(), - ); - } - } - "tcp" => { - if access.host.as_deref().unwrap_or("").trim().is_empty() { - return Err( - "[rig.access].host must be set for tcp access ([rig.access].type='tcp')" - .to_string(), - ); - } - if access.tcp_port.unwrap_or(0) == 0 { - return Err( - "[rig.access].tcp_port must be > 0 for tcp access ([rig.access].type='tcp')" - .to_string(), - ); - } - } - "sdr" => { - // SDR-specific validation is handled by validate_sdr() - } - other => { - return Err(format!( - "[rig.access].type '{}' is invalid (expected 'serial', 'tcp', or 'sdr')", - other - )) - } - } - Ok(()) -} - -fn validate_sdr_squelch_config(path: &str, squelch: &SdrSquelchConfig) -> Result<(), String> { - if !squelch.threshold_db.is_finite() { - return Err(format!("{path}.threshold_db must be finite")); - } - if !(-140.0..=0.0).contains(&squelch.threshold_db) { - return Err(format!("{path}.threshold_db must be in range -140..=0")); - } - if !squelch.hysteresis_db.is_finite() { - return Err(format!("{path}.hysteresis_db must be finite")); - } - if !(0.0..=40.0).contains(&squelch.hysteresis_db) { - return Err(format!("{path}.hysteresis_db must be in range 0..=40")); - } - if squelch.tail_ms > 10_000 { - return Err(format!("{path}.tail_ms must be <= 10000")); - } - Ok(()) -} - -fn validate_sdr_nb_config(path: &str, nb: &SdrNoiseBlankerConfig) -> Result<(), String> { - if !nb.threshold.is_finite() { - return Err(format!("{path}.threshold must be finite")); - } - if !(1.0..=100.0).contains(&nb.threshold) { - return Err(format!("{path}.threshold must be in range 1..=100")); - } - Ok(()) -} - -impl ConfigFile for ServerConfig { - fn section_key() -> &'static str { - "trx-server" - } -} - -#[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); - assert!(config.listen.enabled); - assert_eq!(config.listen.port, 4530); - assert!(config.listen.auth.tokens.is_empty()); - assert!(config.audio.enabled); - assert_eq!(config.audio.port, 4531); - assert_eq!(config.audio.sample_rate, 48000); - assert!(!config.pskreporter.enabled); - assert_eq!(config.pskreporter.port, 4739); - assert!(!config.aprsfi.enabled); - assert_eq!(config.aprsfi.host, "rotate.aprs.net"); - assert_eq!(config.aprsfi.port, 14580); - assert_eq!(config.aprsfi.passcode, -1); - assert!(!config.decode_logs.enabled); - assert!(std::path::Path::new(&config.decode_logs.dir) - .ends_with(std::path::Path::new("decoders"))); - } - - #[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 - -[listen] -enabled = true -listen = "0.0.0.0" -port = 5000 - -[listen.auth] -tokens = ["secret123"] -"#; - - 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); - assert!(config.listen.enabled); - assert_eq!( - config.listen.listen, - std::net::IpAddr::V4(std::net::Ipv4Addr::new(0, 0, 0, 0)) - ); - assert_eq!(config.listen.port, 5000); - assert_eq!(config.listen.auth.tokens, vec!["secret123".to_string()]); - } - - #[test] - fn test_example_combined_toml_parses() { - let example = ServerConfig::example_combined_toml(); - let table: toml::Table = toml::from_str(&example).unwrap(); - let section = toml::to_string(table.get("trx-server").unwrap()).unwrap(); - let _config: ServerConfig = toml::from_str(§ion).unwrap(); - } - - #[test] - fn test_validate_rejects_invalid_coordinates() { - let mut config = ServerConfig::default(); - config.general.latitude = Some(120.0); - config.general.longitude = Some(10.0); - assert!(config.validate().is_err()); - } - - #[test] - fn test_validate_rejects_invalid_audio_frame_duration() { - let mut config = ServerConfig::default(); - config.rig.access.port = Some("/dev/ttyUSB0".to_string()); - config.rig.access.baud = Some(9600); - config.audio.frame_duration_ms = 7; - assert!(config.validate().is_err()); - } - - #[test] - fn test_validate_pskreporter_requires_locator_source() { - let mut config = ServerConfig::default(); - config.rig.access.port = Some("/dev/ttyUSB0".to_string()); - config.rig.access.baud = Some(9600); - config.pskreporter.enabled = true; - config.pskreporter.receiver_locator = None; - config.general.latitude = None; - config.general.longitude = None; - assert!(config.validate().is_err()); - - config.general.latitude = Some(52.0); - config.general.longitude = Some(21.0); - assert!(config.validate().is_ok()); - } - - // --- SDR-11: validate_sdr() unit tests --- - - fn sdr_config_with_access(args: &str) -> ServerConfig { - let mut cfg = ServerConfig::default(); - cfg.rig.access.access_type = Some("sdr".to_string()); - cfg.rig.access.args = Some(args.to_string()); - cfg.audio.tx_enabled = false; - cfg.sdr.sample_rate = 1_920_000; - cfg.sdr.center_offset_hz = 200_000; - cfg - } - - fn add_channel( - cfg: &mut ServerConfig, - id: &str, - offset_hz: i64, - stream_opus: bool, - decoders: Vec, - ) { - cfg.sdr.channels.push(SdrChannelConfig { - id: id.to_string(), - offset_hz, - stream_opus, - decoders, - ..SdrChannelConfig::default() - }); - } - - #[test] - fn test_sdr_validate_ok_minimal() { - let mut cfg = sdr_config_with_access("driver=rtlsdr"); - add_channel(&mut cfg, "primary", 0, false, vec![]); - let errors = cfg.validate_sdr(); - assert!(errors.is_empty(), "expected no errors, got: {:?}", errors); - } - - #[test] - fn test_sdr_validate_non_sdr_skips() { - let cfg = ServerConfig::default(); - let errors = cfg.validate_sdr(); - assert!( - errors.is_empty(), - "expected no errors for non-sdr config, got: {:?}", - errors - ); - } - - #[test] - fn test_sdr_validate_empty_args() { - let cfg = sdr_config_with_access(""); - let errors = cfg.validate_sdr(); - assert_eq!( - errors.len(), - 1, - "expected exactly 1 error, got: {:?}", - errors - ); - assert!( - errors[0].contains("args"), - "expected error to mention 'args', got: {}", - errors[0] - ); - } - - #[test] - fn test_sdr_validate_missing_args() { - let mut cfg = sdr_config_with_access("placeholder"); - cfg.rig.access.args = None; - let errors = cfg.validate_sdr(); - assert_eq!( - errors.len(), - 1, - "expected exactly 1 error, got: {:?}", - errors - ); - assert!( - errors[0].contains("args"), - "expected error to mention 'args', got: {}", - errors[0] - ); - } - - #[test] - fn test_sdr_validate_zero_sample_rate() { - let mut cfg = sdr_config_with_access("driver=rtlsdr"); - cfg.sdr.sample_rate = 0; - let errors = cfg.validate_sdr(); - assert!( - errors.iter().any(|e| e.contains("sample_rate")), - "expected error mentioning 'sample_rate', got: {:?}", - errors - ); - } - - #[test] - fn test_sdr_validate_channel_if_out_of_range() { - // sample_rate=1_000_000 => Nyquist=500_000 - // center_offset_hz=0, offset_hz=600_000 => IF=600_000 > 500_000 - let mut cfg = sdr_config_with_access("driver=rtlsdr"); - cfg.sdr.sample_rate = 1_000_000; - cfg.sdr.center_offset_hz = 0; - add_channel(&mut cfg, "ch_high", 600_000, false, vec![]); - let errors = cfg.validate_sdr(); - assert!( - errors - .iter() - .any(|e| e.contains("ch_high") && (e.contains("Nyquist") || e.contains("exceeds"))), - "expected error mentioning channel id and Nyquist/exceeds, got: {:?}", - errors - ); - } - - #[test] - fn test_sdr_validate_channel_if_negative_out_of_range() { - // sample_rate=1_000_000 => Nyquist=500_000 - // center_offset_hz=0, offset_hz=-600_000 => IF=-600_000, abs=600_000 > 500_000 - let mut cfg = sdr_config_with_access("driver=rtlsdr"); - cfg.sdr.sample_rate = 1_000_000; - cfg.sdr.center_offset_hz = 0; - add_channel(&mut cfg, "ch_low", -600_000, false, vec![]); - let errors = cfg.validate_sdr(); - assert!( - errors - .iter() - .any(|e| e.contains("ch_low") && (e.contains("Nyquist") || e.contains("exceeds"))), - "expected error mentioning channel id and Nyquist/exceeds, got: {:?}", - errors - ); - } - - #[test] - fn test_sdr_validate_channel_if_exactly_nyquist_is_invalid() { - // sample_rate=1_000_000 => Nyquist=500_000 - // IF=500_000 is NOT strictly less than 500_000 => invalid - let mut cfg = sdr_config_with_access("driver=rtlsdr"); - cfg.sdr.sample_rate = 1_000_000; - cfg.sdr.center_offset_hz = 0; - add_channel(&mut cfg, "ch_nyquist", 500_000, false, vec![]); - let errors = cfg.validate_sdr(); - assert!( - errors - .iter() - .any(|e| e.contains("ch_nyquist") - && (e.contains("Nyquist") || e.contains("exceeds"))), - "expected error for IF exactly at Nyquist, got: {:?}", - errors - ); - } - - #[test] - fn test_sdr_validate_dual_stream_opus() { - let mut cfg = sdr_config_with_access("driver=rtlsdr"); - add_channel(&mut cfg, "ch1", 0, true, vec![]); - add_channel(&mut cfg, "ch2", 10_000, true, vec![]); - let errors = cfg.validate_sdr(); - assert!( - errors.iter().any(|e| e.contains("stream_opus")), - "expected error mentioning 'stream_opus', got: {:?}", - errors - ); - } - - #[test] - fn test_sdr_validate_tx_enabled_with_sdr() { - let mut cfg = sdr_config_with_access("driver=rtlsdr"); - cfg.audio.tx_enabled = true; - let errors = cfg.validate_sdr(); - assert!( - errors.iter().any(|e| e.contains("tx_enabled")), - "expected error mentioning 'tx_enabled', got: {:?}", - errors - ); - } - - #[test] - fn test_sdr_validate_duplicate_decoder() { - let mut cfg = sdr_config_with_access("driver=rtlsdr"); - add_channel(&mut cfg, "ch1", 0, false, vec!["ft8".to_string()]); - add_channel(&mut cfg, "ch2", 10_000, false, vec!["ft8".to_string()]); - let errors = cfg.validate_sdr(); - assert!( - errors - .iter() - .any(|e| e.contains("ft8") || e.contains("decoder")), - "expected error mentioning 'ft8' or 'decoder', got: {:?}", - errors - ); - } - - #[test] - fn test_sdr_validate_multiple_errors() { - let mut cfg = sdr_config_with_access("placeholder"); - cfg.rig.access.args = None; - cfg.sdr.sample_rate = 0; - cfg.audio.tx_enabled = true; - let errors = cfg.validate_sdr(); - assert_eq!( - errors.len(), - 3, - "expected exactly 3 errors, got: {:?}", - errors - ); - } - - #[test] - fn test_validate_rejects_invalid_sdr_squelch_threshold() { - let mut cfg = ServerConfig::default(); - cfg.rig.access.port = Some("/dev/ttyUSB0".to_string()); - cfg.rig.access.baud = Some(9600); - cfg.sdr.squelch.threshold_db = 10.0; - let err = cfg - .validate() - .expect_err("expected squelch threshold validation error"); - assert!( - err.contains("squelch") && err.contains("threshold_db"), - "unexpected validation error: {err}" - ); - } - - #[test] - fn test_validate_rejects_invalid_sdr_squelch_hysteresis() { - let mut cfg = ServerConfig::default(); - cfg.rig.access.port = Some("/dev/ttyUSB0".to_string()); - cfg.rig.access.baud = Some(9600); - cfg.sdr.squelch.hysteresis_db = 99.0; - let err = cfg - .validate() - .expect_err("expected squelch hysteresis validation error"); - assert!( - err.contains("squelch") && err.contains("hysteresis_db"), - "unexpected validation error: {err}" - ); - } - - // --- MR-08: multi-rig config tests --- - - #[test] - fn test_resolved_rigs_legacy_flat_fields() { - let mut cfg = ServerConfig::default(); - cfg.rig.model = Some("ft817".to_string()); - cfg.rig.access.access_type = Some("serial".to_string()); - cfg.rig.access.port = Some("/dev/ttyUSB0".to_string()); - cfg.rig.access.baud = Some(9600); - - let rigs = cfg.resolved_rigs(); - assert_eq!(rigs.len(), 1); - assert_eq!(rigs[0].id, "default"); - assert_eq!(rigs[0].rig.model, Some("ft817".to_string())); - } - - #[test] - fn test_resolved_rigs_multi_rig_toml() { - let toml_str = r#" -[general] -callsign = "W1AW" - -[[rigs]] -id = "hf" - -[rigs.rig] -model = "ft450d" -initial_freq_hz = 14074000 - -[rigs.rig.access] -type = "serial" -port = "/dev/ttyUSB0" -baud = 9600 - -[rigs.audio] -port = 4531 - -[[rigs]] -id = "sdr" - -[rigs.rig] -model = "soapysdr" - -[rigs.rig.access] -type = "sdr" -args = "driver=rtlsdr" - -[rigs.audio] -port = 4532 -"#; - let cfg: ServerConfig = toml::from_str(toml_str).unwrap(); - let rigs = cfg.resolved_rigs(); - assert_eq!(rigs.len(), 2); - assert_eq!(rigs[0].id, "hf"); - assert_eq!(rigs[0].rig.model, Some("ft450d".to_string())); - assert_eq!(rigs[0].audio.port, 4531); - assert_eq!(rigs[1].id, "sdr"); - assert_eq!(rigs[1].rig.model, Some("soapysdr".to_string())); - assert_eq!(rigs[1].audio.port, 4532); - } - - #[test] - fn test_resolved_rigs_skips_disabled_entries() { - let toml_str = r#" -[[rigs]] -id = "disabled" -enable = false -[rigs.rig] -model = "ft817" -[rigs.rig.access] -type = "serial" -port = "/dev/ttyUSB0" -baud = 9600 -[rigs.audio] -port = 4531 - -[[rigs]] -id = "enabled" -[rigs.rig] -model = "ft450d" -[rigs.rig.access] -type = "serial" -port = "/dev/ttyUSB1" -baud = 9600 -[rigs.audio] -port = 4532 -"#; - let cfg: ServerConfig = toml::from_str(toml_str).unwrap(); - let rigs = cfg.resolved_rigs(); - assert_eq!(rigs.len(), 1); - assert_eq!(rigs[0].id, "enabled"); - } - - #[test] - fn test_validate_rejects_duplicate_rig_ids() { - let toml_str = r#" -[[rigs]] -id = "rig1" -[rigs.rig] -model = "ft817" -[rigs.rig.access] -type = "serial" -port = "/dev/ttyUSB0" -baud = 9600 -[rigs.audio] -port = 4531 - -[[rigs]] -id = "rig1" -[rigs.rig] -model = "ft450d" -[rigs.rig.access] -type = "serial" -port = "/dev/ttyUSB1" -baud = 9600 -[rigs.audio] -port = 4532 -"#; - let cfg: ServerConfig = toml::from_str(toml_str).unwrap(); - let result = cfg.validate(); - assert!(result.is_err()); - assert!( - result.unwrap_err().contains("duplicate rig id"), - "expected error about duplicate rig id" - ); - } - - #[test] - fn test_validate_rejects_duplicate_audio_ports() { - let toml_str = r#" -[[rigs]] -id = "rig1" -[rigs.rig] -model = "ft817" -[rigs.rig.access] -type = "serial" -port = "/dev/ttyUSB0" -baud = 9600 -[rigs.audio] -port = 4531 - -[[rigs]] -id = "rig2" -[rigs.rig] -model = "ft450d" -[rigs.rig.access] -type = "serial" -port = "/dev/ttyUSB1" -baud = 9600 -[rigs.audio] -port = 4531 -"#; - let cfg: ServerConfig = toml::from_str(toml_str).unwrap(); - let result = cfg.validate(); - assert!(result.is_err()); - assert!( - result.unwrap_err().contains("duplicate audio port"), - "expected error about duplicate audio port" - ); - } - - #[test] - fn test_validate_allows_disabled_duplicate_rig_ids() { - let toml_str = r#" -[[rigs]] -id = "rig1" -[rigs.rig] -model = "ft817" -[rigs.rig.access] -type = "serial" -port = "/dev/ttyUSB0" -baud = 9600 -[rigs.audio] -port = 4531 - -[[rigs]] -id = "rig1" -enable = false -[rigs.rig] -model = "ft450d" -[rigs.rig.access] -type = "serial" -port = "/dev/ttyUSB1" -baud = 9600 -[rigs.audio] -port = 4532 -"#; - let cfg: ServerConfig = toml::from_str(toml_str).unwrap(); - assert!( - cfg.validate().is_ok(), - "expected Ok because disabled rigs are excluded from uniqueness checks" - ); - } - - #[test] - fn test_validate_rejects_when_all_multi_rigs_disabled() { - let toml_str = r#" -[[rigs]] -id = "rig1" -enable = false -[rigs.rig] -model = "ft817" -[rigs.rig.access] -type = "serial" -port = "/dev/ttyUSB0" -baud = 9600 -[rigs.audio] -port = 4531 -"#; - let cfg: ServerConfig = toml::from_str(toml_str).unwrap(); - let result = cfg.validate(); - assert!(result.is_err()); - assert!( - result.unwrap_err().contains("no enabled entries"), - "expected error about enabled rig entries" - ); - } - - #[test] - fn test_validate_accepts_multi_rig_unique_ids_and_ports() { - let toml_str = r#" -[[rigs]] -id = "hf" -[rigs.rig] -model = "ft450d" -[rigs.rig.access] -type = "serial" -port = "/dev/ttyUSB0" -baud = 9600 -[rigs.audio] -port = 4531 - -[[rigs]] -id = "sdr" -[rigs.rig] -model = "soapysdr" -[rigs.rig.access] -type = "sdr" -args = "driver=rtlsdr" -[rigs.audio] -port = 4532 -"#; - let cfg: ServerConfig = toml::from_str(toml_str).unwrap(); - // validate() uses the flat [rig] field for rig-level checks; multi-rig - // validation focuses on ID/port uniqueness. The flat [rig] is default - // (no model), so the access check is skipped when both fields are absent. - assert!( - cfg.validate().is_ok(), - "expected Ok for valid multi-rig config" - ); - } -} +pub use trx_config::server::*;