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:
2026-02-06 22:44:04 +01:00
parent ee25271275
commit 4e9f1d2be6
14 changed files with 1061 additions and 656 deletions
+29
View File
@@ -0,0 +1,29 @@
# SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
#
# SPDX-License-Identifier: BSD-2-Clause
[package]
name = "trx-client"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { workspace = true, features = ["full"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
toml = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
clap = { workspace = true, features = ["derive"] }
dirs = "6"
libloading = "0.8"
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-rigctl = { path = "../trx-frontend/src/trx-frontend-rigctl" }
trx-frontend-qt = { path = "../trx-frontend/src/trx-frontend-qt", optional = true }
[features]
default = []
qt-frontend = ["trx-frontend-qt/qt"]
+332
View File
@@ -0,0 +1,332 @@
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
//
// SPDX-License-Identifier: BSD-2-Clause
//! 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-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};
/// Top-level client configuration structure.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct ClientConfig {
/// General settings
pub general: GeneralConfig,
/// Remote connection settings
pub remote: RemoteConfig,
/// Frontend configurations
pub frontends: FrontendsConfig,
}
/// 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>,
}
/// Remote connection configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
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,
}
impl Default for RemoteConfig {
fn default() -> Self {
Self {
url: 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<String>,
}
/// Frontend configurations (client — includes Qt).
#[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,
}
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)]
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<String>,
}
/// Qt/QML frontend configuration.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct QtFrontendConfig {
/// Whether Qt frontend is enabled
pub enabled: bool,
}
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)
.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-client.toml"));
// XDG config directory
if let Some(config_dir) = dirs::config_dir() {
paths.push(config_dir.join("trx-rs").join("client.toml"));
}
// System-wide config
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 = ClientConfig {
general: GeneralConfig {
callsign: Some("N0CALL".to_string()),
log_level: Some("info".to_string()),
},
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 {
enabled: true,
listen: IpAddr::from([127, 0, 0, 1]),
port: 8080,
},
rigctl: RigctlFrontendConfig {
enabled: false,
listen: IpAddr::from([127, 0, 0, 1]),
port: 4532,
},
http_json: HttpJsonFrontendConfig::default(),
qt: QtFrontendConfig { enabled: false },
},
};
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 = 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!(config.frontends.http_json.enabled);
assert_eq!(config.frontends.http_json.port, 0);
assert!(!config.frontends.qt.enabled);
assert!(config.remote.url.is_none());
assert_eq!(config.remote.poll_interval_ms, 750);
}
#[test]
fn test_parse_client_toml() {
let toml_str = r#"
[general]
callsign = "W1AW"
[remote]
url = "192.168.1.100:9000"
auth.token = "my-token"
poll_interval_ms = 500
[frontends.http]
enabled = true
listen = "127.0.0.1"
port = 8080
[frontends.qt]
enabled = true
"#;
let config: ClientConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.general.callsign, Some("W1AW".to_string()));
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.qt.enabled);
}
#[test]
fn test_example_toml_parses() {
let example = ClientConfig::example_toml();
let _config: ClientConfig = toml::from_str(&example).unwrap();
}
}
+259
View File
@@ -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(())
}
+115
View File
@@ -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)
}
+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!("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!("Remote connection dropped: {}", e);
}
}
Err(e) => {
warn!("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!("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())
}