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::*;