refactor: split into independent trx-server and trx-client binaries
Delete trx-bin (all-in-one) and trx-bin-common (shared lib). Each binary now has its own config, plugins, and helper modules inlined. - trx-server: backend-only daemon with ServerConfig (general, rig, behavior) no frontend dependencies - trx-client: remote client with ClientConfig (general, remote, frontends) includes all frontend support (http, rigctl, http-json, qt) - Dedicated config files: trx-server.toml / trx-client.toml Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,448 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use clap::{Parser, ValueEnum};
|
||||
use tokio::signal;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use tracing::info;
|
||||
|
||||
mod config;
|
||||
mod error;
|
||||
mod plugins;
|
||||
mod remote_client;
|
||||
mod rig_task;
|
||||
|
||||
use trx_backend::{
|
||||
is_backend_registered, register_builtin_backends, registered_backends, RigAccess,
|
||||
};
|
||||
use trx_core::radio::freq::Freq;
|
||||
use trx_core::rig::controller::{AdaptivePolling, ExponentialBackoff};
|
||||
use trx_core::rig::request::RigRequest;
|
||||
use trx_core::rig::state::RigState;
|
||||
use trx_core::rig::{RigControl, RigRxStatus, RigStatus, RigTxStatus};
|
||||
use trx_core::DynResult;
|
||||
use trx_frontend::{is_frontend_registered, registered_frontends};
|
||||
use trx_frontend_http::register_frontend as register_http_frontend;
|
||||
use trx_frontend_http_json::{register_frontend as register_http_json_frontend, set_auth_tokens};
|
||||
use trx_frontend_rigctl::register_frontend as register_rigctl_frontend;
|
||||
|
||||
#[cfg(feature = "qt-frontend")]
|
||||
use trx_frontend_qt::register_frontend as register_qt_frontend;
|
||||
|
||||
const PKG_DESCRIPTION: &str = concat!(env!("CARGO_PKG_NAME"), " - ", env!("CARGO_PKG_DESCRIPTION"));
|
||||
const PKG_LONG_ABOUT: &str = concat!(
|
||||
env!("CARGO_PKG_DESCRIPTION"),
|
||||
"\nHomepage: ",
|
||||
env!("CARGO_PKG_HOMEPAGE")
|
||||
);
|
||||
const RIG_TASK_CHANNEL_BUFFER: usize = 32;
|
||||
const QT_FRONTEND_LISTEN_ADDR: ([u8; 4], u16) = ([127, 0, 0, 1], 0);
|
||||
const RETRY_MAX_DELAY_SECS: u64 = 2;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
author = env!("CARGO_PKG_AUTHORS"),
|
||||
version = env!("CARGO_PKG_VERSION"),
|
||||
about = PKG_DESCRIPTION,
|
||||
long_about = PKG_LONG_ABOUT
|
||||
)]
|
||||
struct Cli {
|
||||
/// Path to configuration file (default: search trx-rs.toml, ~/.config/trx-rs/config.toml, /etc/trx-rs/config.toml)
|
||||
#[arg(long = "config", short = 'C', value_name = "FILE")]
|
||||
config: Option<PathBuf>,
|
||||
/// Print example configuration and exit
|
||||
#[arg(long = "print-config")]
|
||||
print_config: bool,
|
||||
/// Rig backend to use (e.g. ft817)
|
||||
#[arg(short = 'r', long = "rig")]
|
||||
rig: Option<String>,
|
||||
/// Access method to reach the rig CAT interface
|
||||
#[arg(short = 'a', long = "access", value_enum)]
|
||||
access: Option<AccessKind>,
|
||||
/// Frontend(s) to expose for control/status (e.g. http,rigctl)
|
||||
#[arg(short = 'f', long = "frontend", value_delimiter = ',', num_args = 1..)]
|
||||
frontends: Option<Vec<String>>,
|
||||
/// HTTP frontend listen address
|
||||
#[arg(long = "http-listen")]
|
||||
http_listen: Option<IpAddr>,
|
||||
/// HTTP frontend listen port
|
||||
#[arg(long = "http-port")]
|
||||
http_port: Option<u16>,
|
||||
/// rigctl frontend listen address
|
||||
#[arg(long = "rigctl-listen")]
|
||||
rigctl_listen: Option<IpAddr>,
|
||||
/// rigctl frontend listen port
|
||||
#[arg(long = "rigctl-port")]
|
||||
rigctl_port: Option<u16>,
|
||||
/// JSON TCP frontend listen address
|
||||
#[arg(long = "http-json-listen")]
|
||||
http_json_listen: Option<IpAddr>,
|
||||
/// JSON TCP frontend listen port
|
||||
#[arg(long = "http-json-port")]
|
||||
http_json_port: Option<u16>,
|
||||
/// Rig CAT address:
|
||||
/// when access is serial: <path> <baud>;
|
||||
/// when access is TCP: <host>:<port>
|
||||
#[arg(value_name = "RIG_ADDR")]
|
||||
rig_addr: Option<String>,
|
||||
/// Optional callsign/owner label to show in the frontend
|
||||
#[arg(short = 'c', long = "callsign")]
|
||||
callsign: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
||||
enum AccessKind {
|
||||
Serial,
|
||||
Tcp,
|
||||
}
|
||||
|
||||
fn normalize_name(name: &str) -> String {
|
||||
name.to_ascii_lowercase()
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Parse a serial rig address of the form "<path> <baud>".
|
||||
fn parse_serial_addr(addr: &str) -> DynResult<(String, u32)> {
|
||||
let mut parts = addr.split_whitespace();
|
||||
let path = parts
|
||||
.next()
|
||||
.ok_or("Serial rig address must be '<path> <baud>'")?;
|
||||
let baud_str = parts
|
||||
.next()
|
||||
.ok_or("Serial rig address must be '<path> <baud>'")?;
|
||||
if parts.next().is_some() {
|
||||
return Err("Serial rig address must be '<path> <baud>' (got extra data)".into());
|
||||
}
|
||||
let baud: u32 = baud_str
|
||||
.parse()
|
||||
.map_err(|e| format!("Invalid baud '{}': {}", baud_str, e))?;
|
||||
Ok((path.to_string(), baud))
|
||||
}
|
||||
|
||||
/// Resolved configuration after merging config file and CLI arguments.
|
||||
struct ResolvedConfig {
|
||||
rig: String,
|
||||
access: RigAccess,
|
||||
frontends: Vec<String>,
|
||||
http_listen: IpAddr,
|
||||
http_port: u16,
|
||||
rigctl_listen: IpAddr,
|
||||
rigctl_port: u16,
|
||||
http_json_listen: IpAddr,
|
||||
http_json_port: u16,
|
||||
callsign: Option<String>,
|
||||
}
|
||||
|
||||
impl ResolvedConfig {
|
||||
/// Build resolved config from CLI args and config file.
|
||||
fn from_cli_and_config(
|
||||
cli: &Cli,
|
||||
cfg: &config::Config,
|
||||
qt_remote_enabled: bool,
|
||||
) -> DynResult<Self> {
|
||||
// Resolve rig model: CLI > config > error
|
||||
let rig_str = cli.rig.clone().or_else(|| cfg.rig.model.clone());
|
||||
let rig = match rig_str.as_deref() {
|
||||
Some(name) => normalize_name(name),
|
||||
None if qt_remote_enabled => "remote".to_string(),
|
||||
None => {
|
||||
return Err(
|
||||
"Rig model not specified. Use --rig or set [rig].model in config.".into(),
|
||||
)
|
||||
}
|
||||
};
|
||||
if !qt_remote_enabled && !is_backend_registered(&rig) {
|
||||
return Err(format!(
|
||||
"Unknown rig model: {} (available: {})",
|
||||
rig,
|
||||
registered_backends().join(", ")
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let access = if qt_remote_enabled {
|
||||
RigAccess::Tcp {
|
||||
addr: "remote".to_string(),
|
||||
}
|
||||
} else {
|
||||
// Resolve access method: CLI > config > default to serial
|
||||
let access_type = cli
|
||||
.access
|
||||
.as_ref()
|
||||
.map(|a| match a {
|
||||
AccessKind::Serial => "serial",
|
||||
AccessKind::Tcp => "tcp",
|
||||
})
|
||||
.or(cfg.rig.access.access_type.as_deref());
|
||||
|
||||
match access_type {
|
||||
Some("serial") | None => {
|
||||
// Try CLI rig_addr first, then config
|
||||
let (path, baud) = if let Some(ref addr) = cli.rig_addr {
|
||||
parse_serial_addr(addr)?
|
||||
} else if let (Some(port), Some(baud)) =
|
||||
(&cfg.rig.access.port, cfg.rig.access.baud)
|
||||
{
|
||||
(port.clone(), baud)
|
||||
} else {
|
||||
return Err("Serial access requires port and baud. Use '<path> <baud>' argument or set [rig.access].port and .baud in config.".into());
|
||||
};
|
||||
RigAccess::Serial { path, baud }
|
||||
}
|
||||
Some("tcp") => {
|
||||
let addr = if let Some(ref addr) = cli.rig_addr {
|
||||
addr.clone()
|
||||
} else if let (Some(host), Some(port)) =
|
||||
(&cfg.rig.access.host, cfg.rig.access.tcp_port)
|
||||
{
|
||||
format!("{}:{}", host, port)
|
||||
} else {
|
||||
return Err("TCP access requires host:port. Use argument or set [rig.access].host and .tcp_port in config.".into());
|
||||
};
|
||||
RigAccess::Tcp { addr }
|
||||
}
|
||||
Some(other) => return Err(format!("Unknown access type: {}", other).into()),
|
||||
}
|
||||
};
|
||||
|
||||
// Resolve frontends: CLI > config > default
|
||||
let frontends = if let Some(ref fes) = cli.frontends {
|
||||
fes.iter().map(|f| normalize_name(f)).collect()
|
||||
} else {
|
||||
let mut fes = Vec::new();
|
||||
if cfg.frontends.http.enabled {
|
||||
fes.push("http".to_string());
|
||||
}
|
||||
if cfg.frontends.rigctl.enabled {
|
||||
fes.push("rigctl".to_string());
|
||||
}
|
||||
if cfg.frontends.http_json.enabled {
|
||||
fes.push("httpjson".to_string());
|
||||
}
|
||||
if cfg.frontends.qt.enabled {
|
||||
fes.push("qt".to_string());
|
||||
}
|
||||
if fes.is_empty() {
|
||||
fes.push("http".to_string()); // Default
|
||||
}
|
||||
fes
|
||||
};
|
||||
for name in &frontends {
|
||||
if !is_frontend_registered(name) {
|
||||
return Err(format!(
|
||||
"Unknown frontend: {} (available: {})",
|
||||
name,
|
||||
registered_frontends().join(", ")
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve HTTP settings: CLI > config
|
||||
let http_listen = cli.http_listen.unwrap_or(cfg.frontends.http.listen);
|
||||
let http_port = cli.http_port.unwrap_or(cfg.frontends.http.port);
|
||||
|
||||
// Resolve rigctl settings: CLI > config
|
||||
let rigctl_listen = cli.rigctl_listen.unwrap_or(cfg.frontends.rigctl.listen);
|
||||
let rigctl_port = cli.rigctl_port.unwrap_or(cfg.frontends.rigctl.port);
|
||||
|
||||
// Resolve JSON TCP settings: CLI > config
|
||||
let http_json_listen = cli
|
||||
.http_json_listen
|
||||
.unwrap_or(cfg.frontends.http_json.listen);
|
||||
let http_json_port = cli.http_json_port.unwrap_or(cfg.frontends.http_json.port);
|
||||
|
||||
// Resolve callsign: CLI > config
|
||||
let callsign = cli
|
||||
.callsign
|
||||
.clone()
|
||||
.or_else(|| cfg.general.callsign.clone());
|
||||
|
||||
Ok(Self {
|
||||
rig,
|
||||
access,
|
||||
frontends,
|
||||
http_listen,
|
||||
http_port,
|
||||
rigctl_listen,
|
||||
rigctl_port,
|
||||
http_json_listen,
|
||||
http_json_port,
|
||||
callsign,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> DynResult<()> {
|
||||
init_tracing();
|
||||
register_builtin_backends();
|
||||
let _plugin_libs = plugins::load_plugins();
|
||||
register_http_frontend();
|
||||
register_http_json_frontend();
|
||||
#[cfg(feature = "qt-frontend")]
|
||||
register_qt_frontend();
|
||||
register_rigctl_frontend();
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
// Handle --print-config
|
||||
if cli.print_config {
|
||||
println!("{}", config::Config::example_toml());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Load configuration file
|
||||
let (cfg, config_path) = if let Some(ref path) = cli.config {
|
||||
let cfg = config::Config::load_from_file(path)?;
|
||||
(cfg, Some(path.clone()))
|
||||
} else {
|
||||
config::Config::load_from_default_paths()?
|
||||
};
|
||||
|
||||
if let Some(ref path) = config_path {
|
||||
info!("Loaded configuration from {}", path.display());
|
||||
}
|
||||
|
||||
set_auth_tokens(cfg.frontends.http_json.auth.tokens.clone());
|
||||
|
||||
let qt_remote_enabled = cfg.frontends.qt.enabled && cfg.frontends.qt.remote.enabled;
|
||||
if qt_remote_enabled
|
||||
&& cfg
|
||||
.frontends
|
||||
.qt
|
||||
.remote
|
||||
.url
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.is_empty()
|
||||
{
|
||||
return Err("Qt remote mode enabled but frontends.qt.remote.url is missing".into());
|
||||
}
|
||||
|
||||
// Merge CLI and config
|
||||
let resolved = ResolvedConfig::from_cli_and_config(&cli, &cfg, qt_remote_enabled)?;
|
||||
|
||||
// Log startup info
|
||||
if qt_remote_enabled {
|
||||
info!("Starting trxd in Qt remote client mode");
|
||||
} else {
|
||||
match &resolved.access {
|
||||
RigAccess::Serial { path, baud } => {
|
||||
info!(
|
||||
"Starting trxd (rig: {}, access: serial {} @ {} baud)",
|
||||
resolved.rig, path, baud
|
||||
);
|
||||
}
|
||||
RigAccess::Tcp { addr } => {
|
||||
info!(
|
||||
"Starting trxd (rig: {}, access: tcp {})",
|
||||
resolved.rig, addr
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Channel used to communicate with the rig task.
|
||||
let (tx, rx) = mpsc::channel::<RigRequest>(RIG_TASK_CHANNEL_BUFFER);
|
||||
let initial_state = RigState {
|
||||
rig_info: None,
|
||||
status: RigStatus {
|
||||
freq: Freq {
|
||||
hz: cfg.rig.initial_freq_hz,
|
||||
},
|
||||
mode: cfg.rig.initial_mode.clone(),
|
||||
tx_en: false,
|
||||
vfo: None,
|
||||
tx: Some(RigTxStatus {
|
||||
power: None,
|
||||
limit: None,
|
||||
swr: None,
|
||||
alc: None,
|
||||
}),
|
||||
rx: Some(RigRxStatus { sig: None }),
|
||||
lock: Some(false),
|
||||
},
|
||||
initialized: false,
|
||||
control: RigControl {
|
||||
rpt_offset_hz: None,
|
||||
ctcss_hz: None,
|
||||
dcs_code: None,
|
||||
lock: Some(false),
|
||||
clar_hz: None,
|
||||
clar_on: None,
|
||||
enabled: Some(false),
|
||||
},
|
||||
};
|
||||
let (state_tx, state_rx) = watch::channel(initial_state.clone());
|
||||
|
||||
if qt_remote_enabled {
|
||||
let remote_addr = remote_client::parse_remote_url(
|
||||
cfg.frontends.qt.remote.url.as_deref().unwrap_or_default(),
|
||||
)
|
||||
.map_err(|e| format!("Invalid Qt remote URL: {}", e))?;
|
||||
let remote_cfg = remote_client::RemoteClientConfig {
|
||||
addr: remote_addr,
|
||||
token: cfg.frontends.qt.remote.auth.token.clone(),
|
||||
poll_interval: Duration::from_millis(750),
|
||||
};
|
||||
let _remote_handle =
|
||||
tokio::spawn(remote_client::run_remote_client(remote_cfg, rx, state_tx));
|
||||
} else {
|
||||
// Spawn the rig task (controller-based implementation).
|
||||
let rig_task_config = rig_task::RigTaskConfig {
|
||||
rig_model: resolved.rig,
|
||||
access: resolved.access,
|
||||
polling: AdaptivePolling::new(
|
||||
Duration::from_millis(cfg.behavior.poll_interval_ms),
|
||||
Duration::from_millis(cfg.behavior.poll_interval_tx_ms),
|
||||
),
|
||||
retry: ExponentialBackoff::new(
|
||||
cfg.behavior.max_retries.max(1),
|
||||
Duration::from_millis(cfg.behavior.retry_base_delay_ms),
|
||||
Duration::from_secs(RETRY_MAX_DELAY_SECS),
|
||||
),
|
||||
initial_freq_hz: cfg.rig.initial_freq_hz,
|
||||
initial_mode: cfg.rig.initial_mode.clone(),
|
||||
};
|
||||
let _rig_handle = tokio::spawn(rig_task::run_rig_task(rig_task_config, rx, state_tx));
|
||||
}
|
||||
|
||||
// Start frontends.
|
||||
for frontend in &resolved.frontends {
|
||||
let frontend_state_rx = state_rx.clone();
|
||||
let addr = match frontend.as_str() {
|
||||
"http" => SocketAddr::from((resolved.http_listen, resolved.http_port)),
|
||||
"rigctl" => SocketAddr::from((resolved.rigctl_listen, resolved.rigctl_port)),
|
||||
"httpjson" => SocketAddr::from((resolved.http_json_listen, resolved.http_json_port)),
|
||||
"qt" => SocketAddr::from(QT_FRONTEND_LISTEN_ADDR),
|
||||
other => {
|
||||
return Err(format!("Frontend missing listen configuration: {}", other).into());
|
||||
}
|
||||
};
|
||||
trx_frontend::spawn_frontend(
|
||||
frontend,
|
||||
frontend_state_rx,
|
||||
tx.clone(),
|
||||
resolved.callsign.clone(),
|
||||
addr,
|
||||
)?;
|
||||
}
|
||||
|
||||
signal::ctrl_c().await?;
|
||||
info!("Ctrl+C received, shutting down");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Initialize logging/tracing.
|
||||
fn init_tracing() {
|
||||
// Uses default formatting and RUST_LOG if available.
|
||||
tracing_subscriber::fmt().with_target(false).init();
|
||||
}
|
||||
@@ -3,13 +3,12 @@
|
||||
# SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
[package]
|
||||
name = "trx-bin"
|
||||
name = "trx-client"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
tokio-serial = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
@@ -18,13 +17,12 @@ tracing-subscriber = { workspace = true }
|
||||
clap = { workspace = true, features = ["derive"] }
|
||||
dirs = "6"
|
||||
libloading = "0.8"
|
||||
trx-backend = { path = "../trx-backend" }
|
||||
trx-core = { path = "../trx-core" }
|
||||
trx-frontend = { path = "../trx-frontend" }
|
||||
trx-frontend-http = { path = "../trx-frontend/src/trx-frontend-http" }
|
||||
trx-frontend-http-json = { path = "../trx-frontend/src/trx-frontend-http-json" }
|
||||
trx-frontend-qt = { path = "../trx-frontend/src/trx-frontend-qt", optional = true }
|
||||
trx-frontend-rigctl = { path = "../trx-frontend/src/trx-frontend-rigctl" }
|
||||
trx-frontend-qt = { path = "../trx-frontend/src/trx-frontend-qt", optional = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
@@ -2,35 +2,29 @@
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
//! Configuration file support for trx-bin.
|
||||
//! Configuration file support for trx-client.
|
||||
//!
|
||||
//! Supports loading configuration from TOML files with the following search order:
|
||||
//! 1. Path specified via `--config` CLI argument
|
||||
//! 2. `./trx-rs.toml` (current directory)
|
||||
//! 3. `~/.config/trx-rs/config.toml` (XDG config)
|
||||
//! 4. `/etc/trx-rs/config.toml` (system-wide)
|
||||
//!
|
||||
//! CLI arguments override config file values.
|
||||
//! 2. `./trx-client.toml` (current directory)
|
||||
//! 3. `~/.config/trx-rs/client.toml` (XDG config)
|
||||
//! 4. `/etc/trx-rs/client.toml` (system-wide)
|
||||
|
||||
use std::net::IpAddr;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use trx_core::rig::state::RigMode;
|
||||
|
||||
/// Top-level configuration structure.
|
||||
/// Top-level client configuration structure.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
pub struct ClientConfig {
|
||||
/// General settings
|
||||
pub general: GeneralConfig,
|
||||
/// Rig backend configuration
|
||||
pub rig: RigConfig,
|
||||
/// Remote connection settings
|
||||
pub remote: RemoteConfig,
|
||||
/// Frontend configurations
|
||||
pub frontends: FrontendsConfig,
|
||||
/// Polling and retry behavior
|
||||
pub behavior: BehaviorConfig,
|
||||
}
|
||||
|
||||
/// General application settings.
|
||||
@@ -43,49 +37,37 @@ pub struct GeneralConfig {
|
||||
pub log_level: Option<String>,
|
||||
}
|
||||
|
||||
/// Rig backend configuration.
|
||||
/// Remote connection configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct RigConfig {
|
||||
/// Rig model (e.g., "ft817", "ic7300")
|
||||
pub model: Option<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,
|
||||
pub struct RemoteConfig {
|
||||
/// Remote URL (host:port or tcp://host:port).
|
||||
pub url: Option<String>,
|
||||
/// Remote auth settings.
|
||||
pub auth: RemoteAuthConfig,
|
||||
/// Poll interval in milliseconds.
|
||||
pub poll_interval_ms: u64,
|
||||
}
|
||||
|
||||
/// Access method configuration for reaching the rig.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct AccessConfig {
|
||||
/// Access type: "serial" or "tcp"
|
||||
#[serde(rename = "type")]
|
||||
pub access_type: Option<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 {
|
||||
impl Default for RemoteConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
model: None,
|
||||
initial_freq_hz: 144_300_000,
|
||||
initial_mode: RigMode::USB,
|
||||
access: AccessConfig::default(),
|
||||
url: None,
|
||||
auth: RemoteAuthConfig::default(),
|
||||
poll_interval_ms: 750,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Frontend configurations.
|
||||
/// Authentication settings for remote connection.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct RemoteAuthConfig {
|
||||
/// Bearer token to send with JSON commands.
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
/// Frontend configurations (client — includes Qt).
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct FrontendsConfig {
|
||||
@@ -133,6 +115,16 @@ pub struct RigctlFrontendConfig {
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
impl Default for RigctlFrontendConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
listen: IpAddr::from([127, 0, 0, 1]),
|
||||
port: 4532,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON TCP frontend configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
@@ -147,44 +139,6 @@ pub struct HttpJsonFrontendConfig {
|
||||
pub auth: HttpJsonAuthConfig,
|
||||
}
|
||||
|
||||
/// Qt/QML frontend configuration.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct QtFrontendConfig {
|
||||
/// Whether Qt frontend is enabled
|
||||
pub enabled: bool,
|
||||
/// Remote connection settings
|
||||
pub remote: QtRemoteConfig,
|
||||
}
|
||||
|
||||
/// Authorization settings for JSON TCP frontend.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct HttpJsonAuthConfig {
|
||||
/// Accepted bearer tokens.
|
||||
pub tokens: Vec<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 {
|
||||
@@ -196,42 +150,23 @@ impl Default for HttpJsonFrontendConfig {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RigctlFrontendConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
listen: IpAddr::from([127, 0, 0, 1]),
|
||||
port: 4532,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Behavior configuration for polling and retries.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
/// Authorization settings for JSON TCP frontend.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct BehaviorConfig {
|
||||
/// Polling interval in milliseconds when idle
|
||||
pub poll_interval_ms: u64,
|
||||
/// Polling interval in milliseconds when transmitting
|
||||
pub poll_interval_tx_ms: u64,
|
||||
/// Maximum retry attempts for transient errors
|
||||
pub max_retries: u32,
|
||||
/// Base delay for exponential backoff in milliseconds
|
||||
pub retry_base_delay_ms: u64,
|
||||
pub struct HttpJsonAuthConfig {
|
||||
/// Accepted bearer tokens.
|
||||
pub tokens: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for BehaviorConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
poll_interval_ms: 500,
|
||||
poll_interval_tx_ms: 100,
|
||||
max_retries: 3,
|
||||
retry_base_delay_ms: 100,
|
||||
}
|
||||
}
|
||||
/// Qt/QML frontend configuration.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct QtFrontendConfig {
|
||||
/// Whether Qt frontend is enabled
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
impl ClientConfig {
|
||||
/// Load configuration from a specific file path.
|
||||
pub fn load_from_file(path: &Path) -> Result<Self, ConfigError> {
|
||||
let contents = std::fs::read_to_string(path)
|
||||
@@ -261,37 +196,32 @@ impl Config {
|
||||
let mut paths = Vec::new();
|
||||
|
||||
// Current directory
|
||||
paths.push(PathBuf::from("trx-rs.toml"));
|
||||
paths.push(PathBuf::from("trx-client.toml"));
|
||||
|
||||
// XDG config directory
|
||||
if let Some(config_dir) = dirs::config_dir() {
|
||||
paths.push(config_dir.join("trx-rs").join("config.toml"));
|
||||
paths.push(config_dir.join("trx-rs").join("client.toml"));
|
||||
}
|
||||
|
||||
// System-wide config
|
||||
paths.push(PathBuf::from("/etc/trx-rs/config.toml"));
|
||||
paths.push(PathBuf::from("/etc/trx-rs/client.toml"));
|
||||
|
||||
paths
|
||||
}
|
||||
|
||||
/// Generate an example configuration as a TOML string.
|
||||
pub fn example_toml() -> String {
|
||||
let example = Config {
|
||||
let example = ClientConfig {
|
||||
general: GeneralConfig {
|
||||
callsign: Some("N0CALL".to_string()),
|
||||
log_level: Some("info".to_string()),
|
||||
},
|
||||
rig: RigConfig {
|
||||
model: Some("ft817".to_string()),
|
||||
initial_freq_hz: 144_300_000,
|
||||
initial_mode: RigMode::USB,
|
||||
access: AccessConfig {
|
||||
access_type: Some("serial".to_string()),
|
||||
port: Some("/dev/ttyUSB0".to_string()),
|
||||
baud: Some(9600),
|
||||
host: None,
|
||||
tcp_port: None,
|
||||
remote: RemoteConfig {
|
||||
url: Some("192.168.1.100:9000".to_string()),
|
||||
auth: RemoteAuthConfig {
|
||||
token: Some("my-token".to_string()),
|
||||
},
|
||||
poll_interval_ms: 750,
|
||||
},
|
||||
frontends: FrontendsConfig {
|
||||
http: HttpFrontendConfig {
|
||||
@@ -300,14 +230,13 @@ impl Config {
|
||||
port: 8080,
|
||||
},
|
||||
rigctl: RigctlFrontendConfig {
|
||||
enabled: true,
|
||||
enabled: false,
|
||||
listen: IpAddr::from([127, 0, 0, 1]),
|
||||
port: 4532,
|
||||
},
|
||||
http_json: HttpJsonFrontendConfig::default(),
|
||||
qt: QtFrontendConfig::default(),
|
||||
qt: QtFrontendConfig { enabled: false },
|
||||
},
|
||||
behavior: BehaviorConfig::default(),
|
||||
};
|
||||
|
||||
toml::to_string_pretty(&example).unwrap_or_default()
|
||||
@@ -354,97 +283,50 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_default_config() {
|
||||
let config = Config::default();
|
||||
let config = ClientConfig::default();
|
||||
assert!(config.frontends.http.enabled);
|
||||
assert!(!config.frontends.rigctl.enabled);
|
||||
assert_eq!(config.frontends.http.port, 8080);
|
||||
assert_eq!(config.frontends.rigctl.port, 4532);
|
||||
assert_eq!(config.rig.initial_freq_hz, 144_300_000);
|
||||
assert_eq!(config.rig.initial_mode, RigMode::USB);
|
||||
assert!(config.frontends.http_json.enabled);
|
||||
assert_eq!(config.frontends.http_json.port, 0);
|
||||
assert!(!config.frontends.qt.enabled);
|
||||
assert!(!config.frontends.qt.remote.enabled);
|
||||
assert!(config.remote.url.is_none());
|
||||
assert_eq!(config.remote.poll_interval_ms, 750);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_minimal_toml() {
|
||||
let toml_str = r#"
|
||||
[rig]
|
||||
model = "ft817"
|
||||
|
||||
[rig.access]
|
||||
type = "serial"
|
||||
port = "/dev/ttyUSB0"
|
||||
baud = 9600
|
||||
"#;
|
||||
|
||||
let config: Config = toml::from_str(toml_str).unwrap();
|
||||
assert_eq!(config.rig.model, Some("ft817".to_string()));
|
||||
assert_eq!(config.rig.access.port, Some("/dev/ttyUSB0".to_string()));
|
||||
assert_eq!(config.rig.access.baud, Some(9600));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_full_toml() {
|
||||
fn test_parse_client_toml() {
|
||||
let toml_str = r#"
|
||||
[general]
|
||||
callsign = "W1AW"
|
||||
log_level = "debug"
|
||||
|
||||
[rig]
|
||||
model = "ft817"
|
||||
initial_freq_hz = 7100000
|
||||
initial_mode = "LSB"
|
||||
|
||||
[rig.access]
|
||||
type = "serial"
|
||||
port = "/dev/ttyUSB0"
|
||||
baud = 9600
|
||||
[remote]
|
||||
url = "192.168.1.100:9000"
|
||||
auth.token = "my-token"
|
||||
poll_interval_ms = 500
|
||||
|
||||
[frontends.http]
|
||||
enabled = true
|
||||
listen = "0.0.0.0"
|
||||
listen = "127.0.0.1"
|
||||
port = 8080
|
||||
|
||||
[frontends.rigctl]
|
||||
enabled = true
|
||||
listen = "127.0.0.1"
|
||||
port = 4532
|
||||
|
||||
[frontends.http_json]
|
||||
enabled = true
|
||||
listen = "127.0.0.1"
|
||||
port = 9000
|
||||
auth.tokens = ["demo-token"]
|
||||
|
||||
[frontends.qt]
|
||||
enabled = true
|
||||
remote.enabled = true
|
||||
remote.url = "127.0.0.1:9000"
|
||||
remote.auth.token = "demo-token"
|
||||
|
||||
[behavior]
|
||||
poll_interval_ms = 1000
|
||||
poll_interval_tx_ms = 200
|
||||
max_retries = 5
|
||||
retry_base_delay_ms = 50
|
||||
"#;
|
||||
|
||||
let config: Config = toml::from_str(toml_str).unwrap();
|
||||
let config: ClientConfig = toml::from_str(toml_str).unwrap();
|
||||
assert_eq!(config.general.callsign, Some("W1AW".to_string()));
|
||||
assert_eq!(config.general.log_level, Some("debug".to_string()));
|
||||
assert_eq!(config.rig.initial_freq_hz, 7_100_000);
|
||||
assert_eq!(config.rig.initial_mode, RigMode::LSB);
|
||||
assert_eq!(config.remote.url, Some("192.168.1.100:9000".to_string()));
|
||||
assert_eq!(config.remote.auth.token, Some("my-token".to_string()));
|
||||
assert_eq!(config.remote.poll_interval_ms, 500);
|
||||
assert!(config.frontends.http.enabled);
|
||||
assert!(config.frontends.rigctl.enabled);
|
||||
assert_eq!(config.behavior.poll_interval_ms, 1000);
|
||||
assert_eq!(config.behavior.max_retries, 5);
|
||||
assert!(config.frontends.qt.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_example_toml_parses() {
|
||||
let example = Config::example_toml();
|
||||
let _config: Config = toml::from_str(&example).unwrap();
|
||||
let example = ClientConfig::example_toml();
|
||||
let _config: ClientConfig = toml::from_str(&example).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
mod config;
|
||||
mod plugins;
|
||||
mod remote_client;
|
||||
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use clap::Parser;
|
||||
use tokio::signal;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use tracing::info;
|
||||
|
||||
use trx_core::rig::request::RigRequest;
|
||||
use trx_core::rig::state::RigState;
|
||||
use trx_core::rig::{RigControl, RigRxStatus, RigStatus, RigTxStatus};
|
||||
use trx_core::radio::freq::Freq;
|
||||
use trx_core::DynResult;
|
||||
use trx_frontend::{is_frontend_registered, registered_frontends};
|
||||
use trx_frontend_http::register_frontend as register_http_frontend;
|
||||
use trx_frontend_http_json::{register_frontend as register_http_json_frontend, set_auth_tokens};
|
||||
use trx_frontend_rigctl::register_frontend as register_rigctl_frontend;
|
||||
|
||||
#[cfg(feature = "qt-frontend")]
|
||||
use trx_frontend_qt::register_frontend as register_qt_frontend;
|
||||
|
||||
use config::ClientConfig;
|
||||
use remote_client::{parse_remote_url, RemoteClientConfig};
|
||||
|
||||
const PKG_DESCRIPTION: &str = concat!(env!("CARGO_PKG_NAME"), " - remote rig client");
|
||||
const RIG_TASK_CHANNEL_BUFFER: usize = 32;
|
||||
const QT_FRONTEND_LISTEN_ADDR: ([u8; 4], u16) = ([127, 0, 0, 1], 0);
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
author = env!("CARGO_PKG_AUTHORS"),
|
||||
version = env!("CARGO_PKG_VERSION"),
|
||||
about = PKG_DESCRIPTION,
|
||||
)]
|
||||
struct Cli {
|
||||
/// Path to configuration file
|
||||
#[arg(long = "config", short = 'C', value_name = "FILE")]
|
||||
config: Option<PathBuf>,
|
||||
/// Print example configuration and exit
|
||||
#[arg(long = "print-config")]
|
||||
print_config: bool,
|
||||
/// Remote server URL (host:port)
|
||||
#[arg(short = 'u', long = "url")]
|
||||
url: Option<String>,
|
||||
/// Authentication token for the remote server
|
||||
#[arg(long = "token")]
|
||||
token: Option<String>,
|
||||
/// Poll interval in milliseconds
|
||||
#[arg(long = "poll-interval")]
|
||||
poll_interval_ms: Option<u64>,
|
||||
/// Frontend(s) to expose locally (e.g. http,rigctl,qt)
|
||||
#[arg(short = 'f', long = "frontend", value_delimiter = ',', num_args = 1..)]
|
||||
frontends: Option<Vec<String>>,
|
||||
/// HTTP frontend listen address
|
||||
#[arg(long = "http-listen")]
|
||||
http_listen: Option<IpAddr>,
|
||||
/// HTTP frontend listen port
|
||||
#[arg(long = "http-port")]
|
||||
http_port: Option<u16>,
|
||||
/// rigctl frontend listen address
|
||||
#[arg(long = "rigctl-listen")]
|
||||
rigctl_listen: Option<IpAddr>,
|
||||
/// rigctl frontend listen port
|
||||
#[arg(long = "rigctl-port")]
|
||||
rigctl_port: Option<u16>,
|
||||
/// JSON TCP frontend listen address
|
||||
#[arg(long = "http-json-listen")]
|
||||
http_json_listen: Option<IpAddr>,
|
||||
/// JSON TCP frontend listen port
|
||||
#[arg(long = "http-json-port")]
|
||||
http_json_port: Option<u16>,
|
||||
/// Optional callsign/owner label to show in the frontend
|
||||
#[arg(short = 'c', long = "callsign")]
|
||||
callsign: Option<String>,
|
||||
}
|
||||
|
||||
/// Normalize a rig/frontend name to lowercase alphanumeric.
|
||||
fn normalize_name(name: &str) -> String {
|
||||
name.to_ascii_lowercase()
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> DynResult<()> {
|
||||
tracing_subscriber::fmt().with_target(false).init();
|
||||
|
||||
register_http_frontend();
|
||||
register_http_json_frontend();
|
||||
register_rigctl_frontend();
|
||||
#[cfg(feature = "qt-frontend")]
|
||||
register_qt_frontend();
|
||||
let _plugin_libs = plugins::load_plugins();
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
if cli.print_config {
|
||||
println!("{}", ClientConfig::example_toml());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let (cfg, config_path) = if let Some(ref path) = cli.config {
|
||||
let cfg = ClientConfig::load_from_file(path)?;
|
||||
(cfg, Some(path.clone()))
|
||||
} else {
|
||||
ClientConfig::load_from_default_paths()?
|
||||
};
|
||||
|
||||
if let Some(ref path) = config_path {
|
||||
info!("Loaded configuration from {}", path.display());
|
||||
}
|
||||
|
||||
set_auth_tokens(cfg.frontends.http_json.auth.tokens.clone());
|
||||
|
||||
// Resolve remote URL: CLI > config [remote] section > error
|
||||
let remote_url = cli
|
||||
.url
|
||||
.clone()
|
||||
.or_else(|| cfg.remote.url.clone())
|
||||
.ok_or("Remote URL not specified. Use --url or set [remote].url in config.")?;
|
||||
|
||||
let remote_addr =
|
||||
parse_remote_url(&remote_url).map_err(|e| format!("Invalid remote URL: {}", e))?;
|
||||
|
||||
let remote_token = cli
|
||||
.token
|
||||
.clone()
|
||||
.or_else(|| cfg.remote.auth.token.clone());
|
||||
|
||||
let poll_interval_ms = cli
|
||||
.poll_interval_ms
|
||||
.unwrap_or(cfg.remote.poll_interval_ms);
|
||||
|
||||
// Resolve frontends: CLI > config > default to http
|
||||
let frontends = if let Some(ref fes) = cli.frontends {
|
||||
fes.iter().map(|f| normalize_name(f)).collect()
|
||||
} else {
|
||||
let mut fes = Vec::new();
|
||||
if cfg.frontends.http.enabled {
|
||||
fes.push("http".to_string());
|
||||
}
|
||||
if cfg.frontends.rigctl.enabled {
|
||||
fes.push("rigctl".to_string());
|
||||
}
|
||||
if cfg.frontends.http_json.enabled {
|
||||
fes.push("httpjson".to_string());
|
||||
}
|
||||
if cfg.frontends.qt.enabled {
|
||||
fes.push("qt".to_string());
|
||||
}
|
||||
if fes.is_empty() {
|
||||
fes.push("http".to_string());
|
||||
}
|
||||
fes
|
||||
};
|
||||
for name in &frontends {
|
||||
if !is_frontend_registered(name) {
|
||||
return Err(format!(
|
||||
"Unknown frontend: {} (available: {})",
|
||||
name,
|
||||
registered_frontends().join(", ")
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
|
||||
let http_listen = cli.http_listen.unwrap_or(cfg.frontends.http.listen);
|
||||
let http_port = cli.http_port.unwrap_or(cfg.frontends.http.port);
|
||||
let rigctl_listen = cli.rigctl_listen.unwrap_or(cfg.frontends.rigctl.listen);
|
||||
let rigctl_port = cli.rigctl_port.unwrap_or(cfg.frontends.rigctl.port);
|
||||
let http_json_listen = cli
|
||||
.http_json_listen
|
||||
.unwrap_or(cfg.frontends.http_json.listen);
|
||||
let http_json_port = cli.http_json_port.unwrap_or(cfg.frontends.http_json.port);
|
||||
let callsign = cli
|
||||
.callsign
|
||||
.clone()
|
||||
.or_else(|| cfg.general.callsign.clone());
|
||||
|
||||
info!(
|
||||
"Starting trx-client (remote: {}, frontends: {})",
|
||||
remote_addr,
|
||||
frontends.join(", ")
|
||||
);
|
||||
|
||||
let (tx, rx) = mpsc::channel::<RigRequest>(RIG_TASK_CHANNEL_BUFFER);
|
||||
|
||||
let initial_state = RigState {
|
||||
rig_info: None,
|
||||
status: RigStatus {
|
||||
freq: Freq { hz: 144_300_000 },
|
||||
mode: trx_core::rig::state::RigMode::USB,
|
||||
tx_en: false,
|
||||
vfo: None,
|
||||
tx: Some(RigTxStatus {
|
||||
power: None,
|
||||
limit: None,
|
||||
swr: None,
|
||||
alc: None,
|
||||
}),
|
||||
rx: Some(RigRxStatus { sig: None }),
|
||||
lock: Some(false),
|
||||
},
|
||||
initialized: false,
|
||||
control: RigControl {
|
||||
rpt_offset_hz: None,
|
||||
ctcss_hz: None,
|
||||
dcs_code: None,
|
||||
lock: Some(false),
|
||||
clar_hz: None,
|
||||
clar_on: None,
|
||||
enabled: Some(false),
|
||||
},
|
||||
};
|
||||
let (state_tx, state_rx) = watch::channel(initial_state);
|
||||
|
||||
let remote_cfg = RemoteClientConfig {
|
||||
addr: remote_addr,
|
||||
token: remote_token,
|
||||
poll_interval: Duration::from_millis(poll_interval_ms),
|
||||
};
|
||||
let _remote_handle =
|
||||
tokio::spawn(remote_client::run_remote_client(remote_cfg, rx, state_tx));
|
||||
|
||||
// Spawn frontends
|
||||
for frontend in &frontends {
|
||||
let frontend_state_rx = state_rx.clone();
|
||||
let addr = match frontend.as_str() {
|
||||
"http" => SocketAddr::from((http_listen, http_port)),
|
||||
"rigctl" => SocketAddr::from((rigctl_listen, rigctl_port)),
|
||||
"httpjson" => SocketAddr::from((http_json_listen, http_json_port)),
|
||||
"qt" => SocketAddr::from(QT_FRONTEND_LISTEN_ADDR),
|
||||
other => {
|
||||
return Err(format!("Frontend missing listen configuration: {}", other).into());
|
||||
}
|
||||
};
|
||||
trx_frontend::spawn_frontend(
|
||||
frontend,
|
||||
frontend_state_rx,
|
||||
tx.clone(),
|
||||
callsign.clone(),
|
||||
addr,
|
||||
)?;
|
||||
}
|
||||
|
||||
signal::ctrl_c().await?;
|
||||
info!("Ctrl+C received, shutting down");
|
||||
Ok(())
|
||||
}
|
||||
@@ -30,15 +30,15 @@ pub async fn run_remote_client(
|
||||
let mut reconnect_delay = Duration::from_secs(1);
|
||||
|
||||
loop {
|
||||
info!("Qt remote: connecting to {}", config.addr);
|
||||
info!("Remote client: connecting to {}", config.addr);
|
||||
match TcpStream::connect(&config.addr).await {
|
||||
Ok(stream) => {
|
||||
if let Err(e) = handle_connection(&config, stream, &mut rx, &state_tx).await {
|
||||
warn!("Qt remote connection dropped: {}", e);
|
||||
warn!("Remote connection dropped: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Qt remote connect failed: {}", e);
|
||||
warn!("Remote connect failed: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ async fn handle_connection(
|
||||
}
|
||||
last_poll = Instant::now();
|
||||
if let Err(e) = send_command(config, &mut writer, &mut reader, ClientCommand::GetState, state_tx).await {
|
||||
warn!("Qt remote poll failed: {}", e);
|
||||
warn!("Remote poll failed: {}", e);
|
||||
}
|
||||
}
|
||||
req = rx.recv() => {
|
||||
@@ -0,0 +1,21 @@
|
||||
# SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
#
|
||||
# SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
[package]
|
||||
name = "trx-server"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
tokio-serial = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
toml = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
clap = { workspace = true, features = ["derive"] }
|
||||
dirs = "6"
|
||||
libloading = "0.8"
|
||||
trx-backend = { path = "../trx-backend" }
|
||||
trx-core = { path = "../trx-core" }
|
||||
@@ -0,0 +1,280 @@
|
||||
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
//! Configuration file support for trx-server.
|
||||
//!
|
||||
//! Supports loading configuration from TOML files with the following search order:
|
||||
//! 1. Path specified via `--config` CLI argument
|
||||
//! 2. `./trx-server.toml` (current directory)
|
||||
//! 3. `~/.config/trx-rs/server.toml` (XDG config)
|
||||
//! 4. `/etc/trx-rs/server.toml` (system-wide)
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use trx_core::rig::state::RigMode;
|
||||
|
||||
/// Top-level server configuration structure.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ServerConfig {
|
||||
/// General settings
|
||||
pub general: GeneralConfig,
|
||||
/// Rig backend configuration
|
||||
pub rig: RigConfig,
|
||||
/// Polling and retry behavior
|
||||
pub behavior: BehaviorConfig,
|
||||
}
|
||||
|
||||
/// General application settings.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct GeneralConfig {
|
||||
/// Callsign or owner label
|
||||
pub callsign: Option<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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Behavior configuration for polling and retries.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct BehaviorConfig {
|
||||
/// Polling interval in milliseconds when idle
|
||||
pub poll_interval_ms: u64,
|
||||
/// Polling interval in milliseconds when transmitting
|
||||
pub poll_interval_tx_ms: u64,
|
||||
/// Maximum retry attempts for transient errors
|
||||
pub max_retries: u32,
|
||||
/// Base delay for exponential backoff in milliseconds
|
||||
pub retry_base_delay_ms: u64,
|
||||
}
|
||||
|
||||
impl Default for BehaviorConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
poll_interval_ms: 500,
|
||||
poll_interval_tx_ms: 100,
|
||||
max_retries: 3,
|
||||
retry_base_delay_ms: 100,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ServerConfig {
|
||||
/// Load configuration from a specific file path.
|
||||
pub fn load_from_file(path: &Path) -> Result<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-server.toml"));
|
||||
|
||||
// XDG config directory
|
||||
if let Some(config_dir) = dirs::config_dir() {
|
||||
paths.push(config_dir.join("trx-rs").join("server.toml"));
|
||||
}
|
||||
|
||||
// System-wide config
|
||||
paths.push(PathBuf::from("/etc/trx-rs/server.toml"));
|
||||
|
||||
paths
|
||||
}
|
||||
|
||||
/// Generate an example configuration as a TOML string.
|
||||
pub fn example_toml() -> String {
|
||||
let example = ServerConfig {
|
||||
general: GeneralConfig {
|
||||
callsign: Some("N0CALL".to_string()),
|
||||
log_level: Some("info".to_string()),
|
||||
},
|
||||
rig: RigConfig {
|
||||
model: Some("ft817".to_string()),
|
||||
initial_freq_hz: 144_300_000,
|
||||
initial_mode: RigMode::USB,
|
||||
access: AccessConfig {
|
||||
access_type: Some("serial".to_string()),
|
||||
port: Some("/dev/ttyUSB0".to_string()),
|
||||
baud: Some(9600),
|
||||
host: None,
|
||||
tcp_port: None,
|
||||
},
|
||||
},
|
||||
behavior: BehaviorConfig::default(),
|
||||
};
|
||||
|
||||
toml::to_string_pretty(&example).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors that can occur when loading configuration.
|
||||
#[derive(Debug)]
|
||||
pub enum ConfigError {
|
||||
/// Failed to read the config file
|
||||
ReadError(PathBuf, String),
|
||||
/// Failed to parse the config file
|
||||
ParseError(PathBuf, String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ConfigError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::ReadError(path, err) => {
|
||||
write!(
|
||||
f,
|
||||
"failed to read config file '{}': {}",
|
||||
path.display(),
|
||||
err
|
||||
)
|
||||
}
|
||||
Self::ParseError(path, err) => {
|
||||
write!(
|
||||
f,
|
||||
"failed to parse config file '{}': {}",
|
||||
path.display(),
|
||||
err
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ConfigError {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_config() {
|
||||
let config = ServerConfig::default();
|
||||
assert_eq!(config.rig.initial_freq_hz, 144_300_000);
|
||||
assert_eq!(config.rig.initial_mode, RigMode::USB);
|
||||
assert_eq!(config.behavior.poll_interval_ms, 500);
|
||||
assert_eq!(config.behavior.max_retries, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_minimal_toml() {
|
||||
let toml_str = r#"
|
||||
[rig]
|
||||
model = "ft817"
|
||||
|
||||
[rig.access]
|
||||
type = "serial"
|
||||
port = "/dev/ttyUSB0"
|
||||
baud = 9600
|
||||
"#;
|
||||
|
||||
let config: ServerConfig = toml::from_str(toml_str).unwrap();
|
||||
assert_eq!(config.rig.model, Some("ft817".to_string()));
|
||||
assert_eq!(config.rig.access.port, Some("/dev/ttyUSB0".to_string()));
|
||||
assert_eq!(config.rig.access.baud, Some(9600));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_full_toml() {
|
||||
let toml_str = r#"
|
||||
[general]
|
||||
callsign = "W1AW"
|
||||
log_level = "debug"
|
||||
|
||||
[rig]
|
||||
model = "ft817"
|
||||
initial_freq_hz = 7100000
|
||||
initial_mode = "LSB"
|
||||
|
||||
[rig.access]
|
||||
type = "serial"
|
||||
port = "/dev/ttyUSB0"
|
||||
baud = 9600
|
||||
|
||||
[behavior]
|
||||
poll_interval_ms = 1000
|
||||
poll_interval_tx_ms = 200
|
||||
max_retries = 5
|
||||
retry_base_delay_ms = 50
|
||||
"#;
|
||||
|
||||
let config: ServerConfig = toml::from_str(toml_str).unwrap();
|
||||
assert_eq!(config.general.callsign, Some("W1AW".to_string()));
|
||||
assert_eq!(config.general.log_level, Some("debug".to_string()));
|
||||
assert_eq!(config.rig.initial_freq_hz, 7_100_000);
|
||||
assert_eq!(config.rig.initial_mode, RigMode::LSB);
|
||||
assert_eq!(config.behavior.poll_interval_ms, 1000);
|
||||
assert_eq!(config.behavior.max_retries, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_example_toml_parses() {
|
||||
let example = ServerConfig::example_toml();
|
||||
let _config: ServerConfig = toml::from_str(&example).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
mod config;
|
||||
mod error;
|
||||
mod plugins;
|
||||
mod rig_task;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use clap::{Parser, ValueEnum};
|
||||
use tokio::signal;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use tracing::info;
|
||||
|
||||
use trx_backend::{is_backend_registered, register_builtin_backends, registered_backends, RigAccess};
|
||||
use trx_core::radio::freq::Freq;
|
||||
use trx_core::rig::controller::{AdaptivePolling, ExponentialBackoff};
|
||||
use trx_core::rig::request::RigRequest;
|
||||
use trx_core::rig::state::RigState;
|
||||
use trx_core::rig::{RigControl, RigRxStatus, RigStatus, RigTxStatus};
|
||||
use trx_core::DynResult;
|
||||
|
||||
use config::ServerConfig;
|
||||
|
||||
const PKG_DESCRIPTION: &str = concat!(env!("CARGO_PKG_NAME"), " - rig server daemon");
|
||||
const RIG_TASK_CHANNEL_BUFFER: usize = 32;
|
||||
const RETRY_MAX_DELAY_SECS: u64 = 2;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
author = env!("CARGO_PKG_AUTHORS"),
|
||||
version = env!("CARGO_PKG_VERSION"),
|
||||
about = PKG_DESCRIPTION,
|
||||
)]
|
||||
struct Cli {
|
||||
/// Path to configuration file
|
||||
#[arg(long = "config", short = 'C', value_name = "FILE")]
|
||||
config: Option<PathBuf>,
|
||||
/// Print example configuration and exit
|
||||
#[arg(long = "print-config")]
|
||||
print_config: bool,
|
||||
/// Rig backend to use (e.g. ft817)
|
||||
#[arg(short = 'r', long = "rig")]
|
||||
rig: Option<String>,
|
||||
/// Access method to reach the rig CAT interface
|
||||
#[arg(short = 'a', long = "access", value_enum)]
|
||||
access: Option<AccessKind>,
|
||||
/// Rig CAT address:
|
||||
/// when access is serial: <path> <baud>;
|
||||
/// when access is TCP: <host>:<port>
|
||||
#[arg(value_name = "RIG_ADDR")]
|
||||
rig_addr: Option<String>,
|
||||
/// Optional callsign/owner label
|
||||
#[arg(short = 'c', long = "callsign")]
|
||||
callsign: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
||||
enum AccessKind {
|
||||
Serial,
|
||||
Tcp,
|
||||
}
|
||||
|
||||
/// Normalize a rig name to lowercase alphanumeric.
|
||||
fn normalize_name(name: &str) -> String {
|
||||
name.to_ascii_lowercase()
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Parse a serial rig address of the form "<path> <baud>".
|
||||
fn parse_serial_addr(addr: &str) -> DynResult<(String, u32)> {
|
||||
let mut parts = addr.split_whitespace();
|
||||
let path = parts
|
||||
.next()
|
||||
.ok_or("Serial rig address must be '<path> <baud>'")?;
|
||||
let baud_str = parts
|
||||
.next()
|
||||
.ok_or("Serial rig address must be '<path> <baud>'")?;
|
||||
if parts.next().is_some() {
|
||||
return Err("Serial rig address must be '<path> <baud>' (got extra data)".into());
|
||||
}
|
||||
let baud: u32 = baud_str
|
||||
.parse()
|
||||
.map_err(|e| format!("Invalid baud '{}': {}", baud_str, e))?;
|
||||
Ok((path.to_string(), baud))
|
||||
}
|
||||
|
||||
/// Resolved configuration after merging config file and CLI arguments.
|
||||
struct ResolvedConfig {
|
||||
rig: String,
|
||||
access: RigAccess,
|
||||
callsign: Option<String>,
|
||||
}
|
||||
|
||||
fn resolve_config(cli: &Cli, cfg: &ServerConfig) -> DynResult<ResolvedConfig> {
|
||||
let rig_str = cli.rig.clone().or_else(|| cfg.rig.model.clone());
|
||||
let rig = match rig_str.as_deref() {
|
||||
Some(name) => normalize_name(name),
|
||||
None => {
|
||||
return Err(
|
||||
"Rig model not specified. Use --rig or set [rig].model in config.".into(),
|
||||
)
|
||||
}
|
||||
};
|
||||
if !is_backend_registered(&rig) {
|
||||
return Err(format!(
|
||||
"Unknown rig model: {} (available: {})",
|
||||
rig,
|
||||
registered_backends().join(", ")
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let access = {
|
||||
let access_type = cli
|
||||
.access
|
||||
.as_ref()
|
||||
.map(|a| match a {
|
||||
AccessKind::Serial => "serial",
|
||||
AccessKind::Tcp => "tcp",
|
||||
})
|
||||
.or(cfg.rig.access.access_type.as_deref());
|
||||
|
||||
match access_type {
|
||||
Some("serial") | None => {
|
||||
let (path, baud) = if let Some(ref addr) = cli.rig_addr {
|
||||
parse_serial_addr(addr)?
|
||||
} else if let (Some(port), Some(baud)) =
|
||||
(&cfg.rig.access.port, cfg.rig.access.baud)
|
||||
{
|
||||
(port.clone(), baud)
|
||||
} else {
|
||||
return Err("Serial access requires port and baud. Use '<path> <baud>' argument or set [rig.access].port and .baud in config.".into());
|
||||
};
|
||||
RigAccess::Serial { path, baud }
|
||||
}
|
||||
Some("tcp") => {
|
||||
let addr = if let Some(ref addr) = cli.rig_addr {
|
||||
addr.clone()
|
||||
} else if let (Some(host), Some(port)) =
|
||||
(&cfg.rig.access.host, cfg.rig.access.tcp_port)
|
||||
{
|
||||
format!("{}:{}", host, port)
|
||||
} else {
|
||||
return Err("TCP access requires host:port. Use argument or set [rig.access].host and .tcp_port in config.".into());
|
||||
};
|
||||
RigAccess::Tcp { addr }
|
||||
}
|
||||
Some(other) => return Err(format!("Unknown access type: {}", other).into()),
|
||||
}
|
||||
};
|
||||
|
||||
let callsign = cli
|
||||
.callsign
|
||||
.clone()
|
||||
.or_else(|| cfg.general.callsign.clone());
|
||||
|
||||
Ok(ResolvedConfig {
|
||||
rig,
|
||||
access,
|
||||
callsign,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_initial_state(cfg: &ServerConfig) -> RigState {
|
||||
RigState {
|
||||
rig_info: None,
|
||||
status: RigStatus {
|
||||
freq: Freq {
|
||||
hz: cfg.rig.initial_freq_hz,
|
||||
},
|
||||
mode: cfg.rig.initial_mode.clone(),
|
||||
tx_en: false,
|
||||
vfo: None,
|
||||
tx: Some(RigTxStatus {
|
||||
power: None,
|
||||
limit: None,
|
||||
swr: None,
|
||||
alc: None,
|
||||
}),
|
||||
rx: Some(RigRxStatus { sig: None }),
|
||||
lock: Some(false),
|
||||
},
|
||||
initialized: false,
|
||||
control: RigControl {
|
||||
rpt_offset_hz: None,
|
||||
ctcss_hz: None,
|
||||
dcs_code: None,
|
||||
lock: Some(false),
|
||||
clar_hz: None,
|
||||
clar_on: None,
|
||||
enabled: Some(false),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn build_rig_task_config(
|
||||
resolved: &ResolvedConfig,
|
||||
cfg: &ServerConfig,
|
||||
) -> rig_task::RigTaskConfig {
|
||||
rig_task::RigTaskConfig {
|
||||
rig_model: resolved.rig.clone(),
|
||||
access: resolved.access.clone(),
|
||||
polling: AdaptivePolling::new(
|
||||
Duration::from_millis(cfg.behavior.poll_interval_ms),
|
||||
Duration::from_millis(cfg.behavior.poll_interval_tx_ms),
|
||||
),
|
||||
retry: ExponentialBackoff::new(
|
||||
cfg.behavior.max_retries.max(1),
|
||||
Duration::from_millis(cfg.behavior.retry_base_delay_ms),
|
||||
Duration::from_secs(RETRY_MAX_DELAY_SECS),
|
||||
),
|
||||
initial_freq_hz: cfg.rig.initial_freq_hz,
|
||||
initial_mode: cfg.rig.initial_mode.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> DynResult<()> {
|
||||
tracing_subscriber::fmt().with_target(false).init();
|
||||
|
||||
register_builtin_backends();
|
||||
let _plugin_libs = plugins::load_plugins();
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
if cli.print_config {
|
||||
println!("{}", ServerConfig::example_toml());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let (cfg, config_path) = if let Some(ref path) = cli.config {
|
||||
let cfg = ServerConfig::load_from_file(path)?;
|
||||
(cfg, Some(path.clone()))
|
||||
} else {
|
||||
ServerConfig::load_from_default_paths()?
|
||||
};
|
||||
|
||||
if let Some(ref path) = config_path {
|
||||
info!("Loaded configuration from {}", path.display());
|
||||
}
|
||||
|
||||
let resolved = resolve_config(&cli, &cfg)?;
|
||||
|
||||
match &resolved.access {
|
||||
RigAccess::Serial { path, baud } => {
|
||||
info!(
|
||||
"Starting trx-server (rig: {}, access: serial {} @ {} baud)",
|
||||
resolved.rig, path, baud
|
||||
);
|
||||
}
|
||||
RigAccess::Tcp { addr } => {
|
||||
info!(
|
||||
"Starting trx-server (rig: {}, access: tcp {})",
|
||||
resolved.rig, addr
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref cs) = resolved.callsign {
|
||||
info!("Callsign: {}", cs);
|
||||
}
|
||||
|
||||
let (tx, rx) = mpsc::channel::<RigRequest>(RIG_TASK_CHANNEL_BUFFER);
|
||||
let initial_state = build_initial_state(&cfg);
|
||||
let (state_tx, state_rx) = watch::channel(initial_state);
|
||||
// Keep receivers alive so channels don't close prematurely
|
||||
let _state_rx = state_rx;
|
||||
let _tx = tx;
|
||||
|
||||
let rig_task_config = build_rig_task_config(&resolved, &cfg);
|
||||
let _rig_handle = tokio::spawn(rig_task::run_rig_task(rig_task_config, rx, state_tx));
|
||||
|
||||
signal::ctrl_c().await?;
|
||||
info!("Ctrl+C received, shutting down");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
use std::ffi::OsStr;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use libloading::{Library, Symbol};
|
||||
use tracing::{info, warn};
|
||||
|
||||
const PLUGIN_ENV: &str = "TRX_PLUGIN_DIRS";
|
||||
const PLUGIN_ENTRYPOINT: &str = "trx_register";
|
||||
|
||||
#[cfg(windows)]
|
||||
const PATH_SEPARATOR: char = ';';
|
||||
#[cfg(not(windows))]
|
||||
const PATH_SEPARATOR: char = ':';
|
||||
|
||||
#[cfg(windows)]
|
||||
const PLUGIN_EXTENSIONS: &[&str] = &["dll"];
|
||||
#[cfg(target_os = "macos")]
|
||||
const PLUGIN_EXTENSIONS: &[&str] = &["dylib"];
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
const PLUGIN_EXTENSIONS: &[&str] = &["so"];
|
||||
|
||||
pub fn load_plugins() -> Vec<Library> {
|
||||
let mut libraries = Vec::new();
|
||||
let search_paths = plugin_search_paths();
|
||||
|
||||
if search_paths.is_empty() {
|
||||
return libraries;
|
||||
}
|
||||
|
||||
info!("Plugin search paths: {:?}", search_paths);
|
||||
|
||||
for path in search_paths {
|
||||
if let Err(err) = load_plugins_from_dir(&path, &mut libraries) {
|
||||
warn!("Plugin scan failed for {:?}: {}", path, err);
|
||||
}
|
||||
}
|
||||
|
||||
libraries
|
||||
}
|
||||
|
||||
fn load_plugins_from_dir(path: &Path, libraries: &mut Vec<Library>) -> std::io::Result<()> {
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for entry in std::fs::read_dir(path)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
if !is_plugin_file(&path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
unsafe {
|
||||
match Library::new(&path) {
|
||||
Ok(lib) => {
|
||||
if let Err(err) = register_library(&lib, &path) {
|
||||
warn!("Plugin {:?} failed to register: {}", path, err);
|
||||
continue;
|
||||
}
|
||||
info!("Loaded plugin {:?}", path);
|
||||
libraries.push(lib);
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("Failed to load plugin {:?}: {}", path, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
unsafe fn register_library(lib: &Library, path: &Path) -> Result<(), String> {
|
||||
let entry: Symbol<unsafe extern "C" fn()> = lib
|
||||
.get(PLUGIN_ENTRYPOINT.as_bytes())
|
||||
.map_err(|e| format!("missing entrypoint {}: {}", PLUGIN_ENTRYPOINT, e))?;
|
||||
entry();
|
||||
info!("Registered plugin {:?}", path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn plugin_search_paths() -> Vec<PathBuf> {
|
||||
let mut paths = Vec::new();
|
||||
|
||||
if let Ok(env_paths) = std::env::var(PLUGIN_ENV) {
|
||||
for raw in env_paths.split(PATH_SEPARATOR) {
|
||||
if raw.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
paths.push(PathBuf::from(raw));
|
||||
}
|
||||
}
|
||||
|
||||
paths.push(PathBuf::from("plugins"));
|
||||
|
||||
if let Some(config_dir) = dirs::config_dir() {
|
||||
paths.push(config_dir.join("trx-rs").join("plugins"));
|
||||
}
|
||||
|
||||
paths
|
||||
}
|
||||
|
||||
fn is_plugin_file(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(OsStr::to_str)
|
||||
.map(|ext| PLUGIN_EXTENSIONS.iter().any(|e| ext.eq_ignore_ascii_case(e)))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
Reference in New Issue
Block a user