bin: add config loader and remote client mode

This commit is contained in:
2026-01-18 09:24:06 +01:00
parent cbd500edae
commit bceb049e0e
3 changed files with 987 additions and 644 deletions
+450
View File
@@ -0,0 +1,450 @@
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
//
// SPDX-License-Identifier: BSD-2-Clause
//! Configuration file support for trx-bin.
//!
//! Supports loading configuration from TOML files with the following search order:
//! 1. Path specified via `--config` CLI argument
//! 2. `./trx-rs.toml` (current directory)
//! 3. `~/.config/trx-rs/config.toml` (XDG config)
//! 4. `/etc/trx-rs/config.toml` (system-wide)
//!
//! CLI arguments override config file values.
use std::net::IpAddr;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use trx_core::rig::state::RigMode;
/// Top-level configuration structure.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
/// General settings
pub general: GeneralConfig,
/// Rig backend configuration
pub rig: RigConfig,
/// Frontend configurations
pub frontends: FrontendsConfig,
/// Polling and retry behavior
pub behavior: BehaviorConfig,
}
/// General application settings.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct GeneralConfig {
/// Callsign or owner label to display in frontends
pub callsign: Option<String>,
/// Log level (trace, debug, info, warn, error)
pub log_level: Option<String>,
}
/// Rig backend configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct RigConfig {
/// Rig model (e.g., "ft817", "ic7300")
pub model: Option<String>,
/// 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<String>,
/// Serial port path (for serial access)
pub port: Option<String>,
/// Baud rate (for serial access)
pub baud: Option<u32>,
/// Host address (for TCP access)
pub host: Option<String>,
/// TCP port (for TCP access)
pub tcp_port: Option<u16>,
}
impl Default for RigConfig {
fn default() -> Self {
Self {
model: None,
initial_freq_hz: 144_300_000,
initial_mode: RigMode::USB,
access: AccessConfig::default(),
}
}
}
/// 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,
/// Qt/QML frontend settings
pub qt: QtFrontendConfig,
}
/// 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,
}
impl Default for HttpFrontendConfig {
fn default() -> Self {
Self {
enabled: true,
listen: IpAddr::from([127, 0, 0, 1]),
port: 8080,
}
}
}
/// 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,
/// Listen port
pub port: u16,
}
/// 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,
}
/// Qt/QML frontend configuration.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct QtFrontendConfig {
/// Whether Qt frontend is enabled
pub enabled: bool,
/// Remote connection settings
pub remote: QtRemoteConfig,
}
/// Authorization settings for JSON TCP frontend.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct HttpJsonAuthConfig {
/// Accepted bearer tokens.
pub tokens: Vec<String>,
}
/// Remote connection settings for Qt frontend.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct QtRemoteConfig {
/// Enable remote mode (no local rig task).
pub enabled: bool,
/// Remote URL (host:port or tcp://host:port).
pub url: Option<String>,
/// Remote auth settings.
pub auth: QtRemoteAuthConfig,
}
/// Authentication settings for Qt remote mode.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct QtRemoteAuthConfig {
/// Bearer token to send with JSON commands.
pub token: Option<String>,
}
impl Default for HttpJsonFrontendConfig {
fn default() -> Self {
Self {
enabled: true,
listen: IpAddr::from([127, 0, 0, 1]),
port: 0,
auth: HttpJsonAuthConfig::default(),
}
}
}
impl Default for RigctlFrontendConfig {
fn default() -> Self {
Self {
enabled: false,
listen: IpAddr::from([127, 0, 0, 1]),
port: 4532,
}
}
}
/// Behavior configuration for polling and retries.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct BehaviorConfig {
/// Polling interval in milliseconds when idle
pub poll_interval_ms: u64,
/// Polling interval in milliseconds when transmitting
pub poll_interval_tx_ms: u64,
/// Maximum retry attempts for transient errors
pub max_retries: u32,
/// Base delay for exponential backoff in milliseconds
pub retry_base_delay_ms: u64,
}
impl Default for BehaviorConfig {
fn default() -> Self {
Self {
poll_interval_ms: 500,
poll_interval_tx_ms: 100,
max_retries: 3,
retry_base_delay_ms: 100,
}
}
}
impl Config {
/// Load configuration from a specific file path.
pub fn load_from_file(path: &Path) -> Result<Self, ConfigError> {
let contents = std::fs::read_to_string(path)
.map_err(|e| ConfigError::ReadError(path.to_path_buf(), e.to_string()))?;
toml::from_str(&contents)
.map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))
}
/// Load configuration from the default search paths.
/// Returns default config if no config file is found.
pub fn load_from_default_paths() -> Result<(Self, Option<PathBuf>), ConfigError> {
let search_paths = Self::default_search_paths();
for path in search_paths {
if path.exists() {
let config = Self::load_from_file(&path)?;
return Ok((config, Some(path)));
}
}
Ok((Self::default(), None))
}
/// Get the default search paths for config files.
pub fn default_search_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
// Current directory
paths.push(PathBuf::from("trx-rs.toml"));
// XDG config directory
if let Some(config_dir) = dirs::config_dir() {
paths.push(config_dir.join("trx-rs").join("config.toml"));
}
// System-wide config
paths.push(PathBuf::from("/etc/trx-rs/config.toml"));
paths
}
/// Generate an example configuration as a TOML string.
pub fn example_toml() -> String {
let example = Config {
general: GeneralConfig {
callsign: Some("N0CALL".to_string()),
log_level: Some("info".to_string()),
},
rig: RigConfig {
model: Some("ft817".to_string()),
initial_freq_hz: 144_300_000,
initial_mode: RigMode::USB,
access: AccessConfig {
access_type: Some("serial".to_string()),
port: Some("/dev/ttyUSB0".to_string()),
baud: Some(9600),
host: None,
tcp_port: None,
},
},
frontends: FrontendsConfig {
http: HttpFrontendConfig {
enabled: true,
listen: IpAddr::from([127, 0, 0, 1]),
port: 8080,
},
rigctl: RigctlFrontendConfig {
enabled: true,
listen: IpAddr::from([127, 0, 0, 1]),
port: 4532,
},
http_json: HttpJsonFrontendConfig::default(),
qt: QtFrontendConfig::default(),
},
behavior: BehaviorConfig::default(),
};
toml::to_string_pretty(&example).unwrap_or_default()
}
}
/// Errors that can occur when loading configuration.
#[derive(Debug)]
pub enum ConfigError {
/// Failed to read the config file
ReadError(PathBuf, String),
/// Failed to parse the config file
ParseError(PathBuf, String),
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ReadError(path, err) => {
write!(
f,
"failed to read config file '{}': {}",
path.display(),
err
)
}
Self::ParseError(path, err) => {
write!(
f,
"failed to parse config file '{}': {}",
path.display(),
err
)
}
}
}
}
impl std::error::Error for ConfigError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = Config::default();
assert!(config.frontends.http.enabled);
assert!(!config.frontends.rigctl.enabled);
assert_eq!(config.frontends.http.port, 8080);
assert_eq!(config.frontends.rigctl.port, 4532);
assert_eq!(config.rig.initial_freq_hz, 144_300_000);
assert_eq!(config.rig.initial_mode, RigMode::USB);
assert!(config.frontends.http_json.enabled);
assert_eq!(config.frontends.http_json.port, 0);
assert!(!config.frontends.qt.enabled);
assert!(!config.frontends.qt.remote.enabled);
}
#[test]
fn test_parse_minimal_toml() {
let toml_str = r#"
[rig]
model = "ft817"
[rig.access]
type = "serial"
port = "/dev/ttyUSB0"
baud = 9600
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.rig.model, Some("ft817".to_string()));
assert_eq!(config.rig.access.port, Some("/dev/ttyUSB0".to_string()));
assert_eq!(config.rig.access.baud, Some(9600));
}
#[test]
fn test_parse_full_toml() {
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
[frontends.http]
enabled = true
listen = "0.0.0.0"
port = 8080
[frontends.rigctl]
enabled = true
listen = "127.0.0.1"
port = 4532
[frontends.http_json]
enabled = true
listen = "127.0.0.1"
port = 9000
auth.tokens = ["demo-token"]
[frontends.qt]
enabled = true
remote.enabled = true
remote.url = "127.0.0.1:9000"
remote.auth.token = "demo-token"
[behavior]
poll_interval_ms = 1000
poll_interval_tx_ms = 200
max_retries = 5
retry_base_delay_ms = 50
"#;
let config: Config = toml::from_str(toml_str).unwrap();
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!(config.frontends.http.enabled);
assert!(config.frontends.rigctl.enabled);
assert_eq!(config.behavior.poll_interval_ms, 1000);
assert_eq!(config.behavior.max_retries, 5);
}
#[test]
fn test_example_toml_parses() {
let example = Config::example_toml();
let _config: Config = toml::from_str(&example).unwrap();
}
}
+335 -644
View File
File diff suppressed because it is too large Load Diff
+202
View File
@@ -0,0 +1,202 @@
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
//
// SPDX-License-Identifier: BSD-2-Clause
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpStream;
use tokio::sync::{mpsc, watch};
use tokio::time::{self, Instant};
use tracing::{info, warn};
use trx_core::client::{ClientCommand, ClientEnvelope, ClientResponse};
use trx_core::rig::request::RigRequest;
use trx_core::rig::state::RigState;
use trx_core::rig::RigControl;
use trx_core::{RigError, RigResult};
pub struct RemoteClientConfig {
pub addr: String,
pub token: Option<String>,
pub poll_interval: Duration,
}
pub async fn run_remote_client(
config: RemoteClientConfig,
mut rx: mpsc::Receiver<RigRequest>,
state_tx: watch::Sender<RigState>,
) -> RigResult<()> {
let mut reconnect_delay = Duration::from_secs(1);
loop {
info!("Qt remote: connecting to {}", config.addr);
match TcpStream::connect(&config.addr).await {
Ok(stream) => {
if let Err(e) = handle_connection(&config, stream, &mut rx, &state_tx).await {
warn!("Qt remote connection dropped: {}", e);
}
}
Err(e) => {
warn!("Qt remote connect failed: {}", e);
}
}
time::sleep(reconnect_delay).await;
reconnect_delay = (reconnect_delay * 2).min(Duration::from_secs(10));
}
}
async fn handle_connection(
config: &RemoteClientConfig,
stream: TcpStream,
rx: &mut mpsc::Receiver<RigRequest>,
state_tx: &watch::Sender<RigState>,
) -> RigResult<()> {
let (reader, mut writer) = stream.into_split();
let mut reader = BufReader::new(reader);
let mut poll_interval = time::interval(config.poll_interval);
let mut last_poll = Instant::now();
loop {
tokio::select! {
_ = poll_interval.tick() => {
if last_poll.elapsed() < config.poll_interval {
continue;
}
last_poll = Instant::now();
if let Err(e) = send_command(config, &mut writer, &mut reader, ClientCommand::GetState, state_tx).await {
warn!("Qt remote poll failed: {}", e);
}
}
req = rx.recv() => {
let Some(req) = req else {
return Ok(());
};
let cmd = req.cmd;
let result = {
let client_cmd = map_rig_command(cmd);
send_command(config, &mut writer, &mut reader, client_cmd, state_tx).await
};
let _ = req.respond_to.send(result);
}
}
}
}
async fn send_command(
config: &RemoteClientConfig,
writer: &mut tokio::net::tcp::OwnedWriteHalf,
reader: &mut BufReader<tokio::net::tcp::OwnedReadHalf>,
cmd: ClientCommand,
state_tx: &watch::Sender<RigState>,
) -> RigResult<trx_core::RigSnapshot> {
let envelope = ClientEnvelope {
token: config.token.clone(),
cmd,
};
let payload = serde_json::to_string(&envelope)
.map_err(|e| RigError::communication(format!("JSON serialize failed: {e}")))?;
writer
.write_all(format!("{}\n", payload).as_bytes())
.await
.map_err(|e| RigError::communication(format!("write failed: {e}")))?;
writer
.flush()
.await
.map_err(|e| RigError::communication(format!("flush failed: {e}")))?;
let mut line = String::new();
reader
.read_line(&mut line)
.await
.map_err(|e| RigError::communication(format!("read failed: {e}")))?;
let resp: ClientResponse = serde_json::from_str(line.trim_end())
.map_err(|e| RigError::communication(format!("invalid response: {e}")))?;
if resp.success {
if let Some(snapshot) = resp.state {
let _ = state_tx.send(state_from_snapshot(snapshot.clone()));
return Ok(snapshot);
}
return Err(RigError::communication("missing snapshot"));
}
Err(RigError::communication(
resp.error.unwrap_or_else(|| "remote error".into()),
))
}
fn map_rig_command(cmd: trx_core::RigCommand) -> ClientCommand {
match cmd {
trx_core::RigCommand::GetSnapshot => ClientCommand::GetState,
trx_core::RigCommand::SetFreq(freq) => ClientCommand::SetFreq { freq_hz: freq.hz },
trx_core::RigCommand::SetMode(mode) => ClientCommand::SetMode {
mode: mode_label(&mode),
},
trx_core::RigCommand::SetPtt(ptt) => ClientCommand::SetPtt { ptt },
trx_core::RigCommand::PowerOn => ClientCommand::PowerOn,
trx_core::RigCommand::PowerOff => ClientCommand::PowerOff,
trx_core::RigCommand::ToggleVfo => ClientCommand::ToggleVfo,
trx_core::RigCommand::GetTxLimit => ClientCommand::GetTxLimit,
trx_core::RigCommand::SetTxLimit(limit) => ClientCommand::SetTxLimit { limit },
trx_core::RigCommand::Lock => ClientCommand::Lock,
trx_core::RigCommand::Unlock => ClientCommand::Unlock,
}
}
fn mode_label(mode: &trx_core::rig::state::RigMode) -> String {
match mode {
trx_core::rig::state::RigMode::LSB => "LSB".to_string(),
trx_core::rig::state::RigMode::USB => "USB".to_string(),
trx_core::rig::state::RigMode::CW => "CW".to_string(),
trx_core::rig::state::RigMode::CWR => "CWR".to_string(),
trx_core::rig::state::RigMode::AM => "AM".to_string(),
trx_core::rig::state::RigMode::WFM => "WFM".to_string(),
trx_core::rig::state::RigMode::FM => "FM".to_string(),
trx_core::rig::state::RigMode::DIG => "DIG".to_string(),
trx_core::rig::state::RigMode::PKT => "PKT".to_string(),
trx_core::rig::state::RigMode::Other(val) => val.clone(),
}
}
pub fn state_from_snapshot(snapshot: trx_core::RigSnapshot) -> RigState {
let status = snapshot.status;
let lock = status.lock;
RigState {
rig_info: Some(snapshot.info),
status,
initialized: snapshot.initialized,
control: RigControl {
rpt_offset_hz: None,
ctcss_hz: None,
dcs_code: None,
lock,
clar_hz: None,
clar_on: None,
enabled: snapshot.enabled,
},
}
}
pub fn parse_remote_url(url: &str) -> Result<String, String> {
let trimmed = url.trim();
if trimmed.is_empty() {
return Err("remote url is empty".into());
}
let addr = trimmed
.strip_prefix("tcp://")
.or_else(|| trimmed.strip_prefix("http-json://"))
.unwrap_or(trimmed);
if !addr.contains(':') {
return Err("remote url must be host:port".into());
}
Ok(addr.to_string())
}