[refactor](workspace): complete remaining architecture phases

Bundle all pending repository updates, including plugin context de-globalization, runtime hardening, config validation, boundary tests, and supporting docs/scripts.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
This commit is contained in:
2026-02-12 22:27:36 +01:00
parent 144afbae8e
commit 4b34a39745
27 changed files with 684 additions and 210 deletions
+1 -1
View File
@@ -9,5 +9,5 @@ pub mod util;
pub use config::{ConfigError, ConfigFile};
pub use logging::init_logging;
pub use plugins::load_plugins;
pub use plugins::{load_backend_plugins, load_frontend_plugins};
pub use util::normalize_name;
+38 -11
View File
@@ -4,12 +4,14 @@
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::ptr::NonNull;
use libloading::{Library, Symbol};
use tracing::{info, warn};
const PLUGIN_ENV: &str = "TRX_PLUGIN_DIRS";
const PLUGIN_ENTRYPOINT: &str = "trx_register";
const BACKEND_ENTRYPOINT: &str = "trx_register_backend";
const FRONTEND_ENTRYPOINT: &str = "trx_register_frontend";
#[cfg(windows)]
const PATH_SEPARATOR: char = ';';
@@ -23,7 +25,18 @@ const PLUGIN_EXTENSIONS: &[&str] = &["dylib"];
#[cfg(all(unix, not(target_os = "macos")))]
const PLUGIN_EXTENSIONS: &[&str] = &["so"];
pub fn load_plugins() -> Vec<Library> {
pub fn load_backend_plugins(context: NonNull<std::ffi::c_void>) -> Vec<Library> {
load_plugins_for_entrypoint(BACKEND_ENTRYPOINT, context)
}
pub fn load_frontend_plugins(context: NonNull<std::ffi::c_void>) -> Vec<Library> {
load_plugins_for_entrypoint(FRONTEND_ENTRYPOINT, context)
}
fn load_plugins_for_entrypoint(
entrypoint: &str,
context: NonNull<std::ffi::c_void>,
) -> Vec<Library> {
let mut libraries = Vec::new();
let search_paths = plugin_search_paths();
@@ -34,7 +47,7 @@ pub fn load_plugins() -> Vec<Library> {
info!("Plugin search paths: {:?}", search_paths);
for path in search_paths {
if let Err(err) = load_plugins_from_dir(&path, &mut libraries) {
if let Err(err) = load_plugins_from_dir(&path, entrypoint, context, &mut libraries) {
warn!("Plugin scan failed for {:?}: {}", path, err);
}
}
@@ -42,7 +55,12 @@ pub fn load_plugins() -> Vec<Library> {
libraries
}
fn load_plugins_from_dir(path: &Path, libraries: &mut Vec<Library>) -> std::io::Result<()> {
fn load_plugins_from_dir(
path: &Path,
entrypoint: &str,
context: NonNull<std::ffi::c_void>,
libraries: &mut Vec<Library>,
) -> std::io::Result<()> {
if !path.exists() {
return Ok(());
}
@@ -60,7 +78,7 @@ fn load_plugins_from_dir(path: &Path, libraries: &mut Vec<Library>) -> std::io::
unsafe {
match Library::new(&path) {
Ok(lib) => {
if let Err(err) = register_library(&lib, &path) {
if let Err(err) = register_library(&lib, &path, entrypoint, context) {
warn!("Plugin {:?} failed to register: {}", path, err);
continue;
}
@@ -77,11 +95,16 @@ fn load_plugins_from_dir(path: &Path, libraries: &mut Vec<Library>) -> std::io::
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();
unsafe fn register_library(
lib: &Library,
path: &Path,
entrypoint: &str,
context: NonNull<std::ffi::c_void>,
) -> Result<(), String> {
let entry: Symbol<unsafe extern "C" fn(*mut std::ffi::c_void)> = lib
.get(entrypoint.as_bytes())
.map_err(|e| format!("missing entrypoint {}: {}", entrypoint, e))?;
entry(context.as_ptr());
info!("Registered plugin {:?}", path);
Ok(())
}
@@ -110,6 +133,10 @@ fn plugin_search_paths() -> Vec<PathBuf> {
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)))
.map(|ext| {
PLUGIN_EXTENSIONS
.iter()
.any(|e| ext.eq_ignore_ascii_case(e))
})
.unwrap_or(false)
}
+5 -6
View File
@@ -8,6 +8,7 @@ mod remote_client;
use std::net::{IpAddr, SocketAddr};
use std::path::PathBuf;
use std::ptr::NonNull;
use std::time::Duration;
use bytes::Bytes;
@@ -17,16 +18,14 @@ use tokio::sync::{broadcast, mpsc, watch};
use tokio::task::JoinHandle;
use tracing::{error, info};
use trx_app::{init_logging, load_plugins, normalize_name};
use trx_app::{init_logging, load_frontend_plugins, normalize_name};
use trx_core::audio::AudioStreamInfo;
use trx_core::decode::DecodedMessage;
use trx_core::rig::request::RigRequest;
use trx_core::rig::state::RigState;
use trx_core::DynResult;
use trx_frontend::{
snapshot_bootstrap_context, FrontendRegistrationContext, FrontendRuntimeContext,
};
use trx_frontend::{FrontendRegistrationContext, FrontendRuntimeContext};
use trx_frontend_http::register_frontend_on as register_http_frontend;
use trx_frontend_http_json::register_frontend_on as register_http_json_frontend;
use trx_frontend_rigctl::register_frontend_on as register_rigctl_frontend;
@@ -142,8 +141,8 @@ async fn async_init() -> DynResult<AppState> {
init_logging(cfg.general.log_level.as_deref());
let _plugin_libs = load_plugins();
frontend_reg_ctx.extend_from(&snapshot_bootstrap_context());
let frontend_ctx_ptr = NonNull::from(&mut frontend_reg_ctx).cast();
let _plugin_libs = load_frontend_plugins(frontend_ctx_ptr);
if let Some(ref path) = config_path {
info!("Loaded configuration from {}", path.display());
+139 -1
View File
@@ -309,7 +309,18 @@ fn parse_port(port_str: &str) -> Result<u16, String> {
#[cfg(test)]
mod tests {
use super::{parse_remote_url, RemoteEndpoint};
use super::{parse_remote_url, RemoteClientConfig, RemoteEndpoint};
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpListener;
use tokio::sync::{mpsc, watch};
use trx_core::radio::freq::{Band, Freq};
use trx_core::rig::state::RigSnapshot;
use trx_core::rig::{RigAccessMethod, RigCapabilities, RigInfo, RigStatus, RigTxStatus};
use trx_core::{RigMode, RigState};
use trx_protocol::ClientResponse;
#[test]
fn parse_host_default_port() {
@@ -352,4 +363,131 @@ mod tests {
let err = parse_remote_url("::1:7000").expect_err("must fail");
assert!(err.contains("must be bracketed"));
}
fn sample_snapshot() -> RigSnapshot {
RigSnapshot {
info: RigInfo {
manufacturer: "Test".to_string(),
model: "Dummy".to_string(),
revision: "1".to_string(),
capabilities: RigCapabilities {
supported_bands: vec![Band {
low_hz: 7_000_000,
high_hz: 7_200_000,
tx_allowed: true,
}],
supported_modes: vec![RigMode::USB],
num_vfos: 1,
lock: false,
lockable: true,
attenuator: false,
preamp: false,
rit: false,
rpt: false,
split: false,
},
access: RigAccessMethod::Tcp {
addr: "127.0.0.1:1234".to_string(),
},
},
status: RigStatus {
freq: Freq { hz: 7_100_000 },
mode: RigMode::USB,
tx_en: false,
vfo: None,
tx: Some(RigTxStatus {
power: None,
limit: None,
swr: None,
alc: None,
}),
rx: None,
lock: Some(false),
},
band: None,
enabled: Some(true),
initialized: true,
server_callsign: Some("N0CALL".to_string()),
server_version: Some("test".to_string()),
server_latitude: None,
server_longitude: None,
aprs_decode_enabled: false,
cw_decode_enabled: false,
ft8_decode_enabled: false,
cw_auto: true,
cw_wpm: 15,
cw_tone_hz: 700,
}
}
#[tokio::test]
#[ignore = "requires TCP bind permissions"]
async fn reconnects_and_updates_state_after_drop() {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let addr = listener.local_addr().expect("local addr");
let response = serde_json::to_string(&ClientResponse {
success: true,
state: Some(sample_snapshot()),
error: None,
})
.expect("serialize response")
+ "\n";
let server = tokio::spawn(async move {
let (first, _) = listener.accept().await.expect("accept first");
let (first_reader, _) = first.into_split();
let mut first_reader = BufReader::new(first_reader);
let mut buf = String::new();
let _ = first_reader.read_line(&mut buf).await.expect("read first");
let (second, _) = listener.accept().await.expect("accept second");
let (second_reader, mut second_writer) = second.into_split();
let mut second_reader = BufReader::new(second_reader);
buf.clear();
let _ = second_reader
.read_line(&mut buf)
.await
.expect("read second");
second_writer
.write_all(response.as_bytes())
.await
.expect("write response");
second_writer.flush().await.expect("flush");
});
let (_req_tx, req_rx) = mpsc::channel(8);
let (state_tx, mut state_rx) = watch::channel(RigState::new_uninitialized());
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let client = tokio::spawn(super::run_remote_client(
RemoteClientConfig {
addr: addr.to_string(),
token: None,
poll_interval: Duration::from_millis(100),
},
req_rx,
state_tx,
shutdown_rx,
));
tokio::time::timeout(Duration::from_secs(5), async {
loop {
if state_rx.borrow().initialized {
break;
}
state_rx.changed().await.expect("state channel");
}
})
.await
.expect("state update timeout");
assert_eq!(state_rx.borrow().status.freq.hz, 7_100_000);
let _ = shutdown_tx.send(true);
tokio::time::timeout(Duration::from_secs(2), async {
let _ = client.await;
})
.await
.expect("client shutdown timeout");
let _ = server.await;
}
}
+2 -51
View File
@@ -2,10 +2,10 @@
//
// SPDX-License-Identifier: BSD-2-Clause
use std::collections::{HashMap, VecDeque, HashSet};
use std::collections::{HashMap, HashSet, VecDeque};
use std::net::SocketAddr;
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, Mutex, OnceLock};
use std::sync::{Arc, Mutex};
use std::time::Instant;
use bytes::Bytes;
@@ -151,52 +151,3 @@ fn normalize_name(name: &str) -> String {
.filter(|c| c.is_ascii_alphanumeric())
.collect()
}
/// Phase 3D: Plugin compatibility adapter - delegates to bootstrap context.
fn bootstrap_context() -> &'static Arc<Mutex<FrontendRegistrationContext>> {
static BOOTSTRAP_CONTEXT: OnceLock<Arc<Mutex<FrontendRegistrationContext>>> = OnceLock::new();
BOOTSTRAP_CONTEXT.get_or_init(|| Arc::new(Mutex::new(FrontendRegistrationContext::new())))
}
/// Snapshot current plugin/bootstrap registrations into an owned context.
pub fn snapshot_bootstrap_context() -> FrontendRegistrationContext {
let ctx = bootstrap_context()
.lock()
.expect("frontend context mutex poisoned");
ctx.clone()
}
/// Register a frontend spawner under a stable name (e.g. "http").
/// Plugin compatibility: delegates to bootstrap context.
pub fn register_frontend(name: &str, spawner: FrontendSpawnFn) {
let mut ctx = bootstrap_context().lock().expect("frontend context mutex poisoned");
ctx.register_frontend(name, spawner);
}
/// Check whether a frontend name is registered.
/// Plugin compatibility: reads from bootstrap context.
pub fn is_frontend_registered(name: &str) -> bool {
let ctx = bootstrap_context().lock().expect("frontend context mutex poisoned");
ctx.is_frontend_registered(name)
}
/// List registered frontend names.
/// Plugin compatibility: reads from bootstrap context.
pub fn registered_frontends() -> Vec<String> {
let ctx = bootstrap_context().lock().expect("frontend context mutex poisoned");
ctx.registered_frontends()
}
/// Spawn a registered frontend by name with runtime context.
/// Plugin compatibility: reads from bootstrap context.
pub fn spawn_frontend(
name: &str,
state_rx: watch::Receiver<RigState>,
rig_tx: mpsc::Sender<RigRequest>,
callsign: Option<String>,
listen_addr: SocketAddr,
context: Arc<FrontendRuntimeContext>,
) -> DynResult<JoinHandle<()>> {
let ctx = bootstrap_context().lock().expect("frontend context mutex poisoned");
ctx.spawn_frontend(name, state_rx, rig_tx, callsign, listen_addr, context)
}
@@ -8,8 +8,3 @@ pub fn register_frontend_on(context: &mut trx_frontend::FrontendRegistrationCont
use trx_frontend::FrontendSpawner;
context.register_frontend("http-json", server::HttpJsonFrontend::spawn_frontend);
}
pub fn register_frontend() {
use trx_frontend::FrontendSpawner;
trx_frontend::register_frontend("http-json", server::HttpJsonFrontend::spawn_frontend);
}
@@ -266,3 +266,148 @@ fn authorize(token: &Option<String>, context: &FrontendRuntimeContext) -> Result
let validator = SimpleTokenValidator::new(context.auth_tokens.clone());
validator.validate(token)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
use std::net::Ipv4Addr;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use trx_core::radio::freq::{Band, Freq};
use trx_core::rig::state::RigSnapshot;
use trx_core::rig::{RigAccessMethod, RigCapabilities, RigInfo, RigStatus, RigTxStatus};
use trx_core::RigMode;
fn loopback_addr() -> SocketAddr {
let listener = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind");
let addr = listener.local_addr().expect("local_addr");
drop(listener);
addr
}
fn sample_snapshot() -> RigSnapshot {
RigSnapshot {
info: RigInfo {
manufacturer: "Test".to_string(),
model: "Dummy".to_string(),
revision: "1".to_string(),
capabilities: RigCapabilities {
supported_bands: vec![Band {
low_hz: 14_000_000,
high_hz: 14_350_000,
tx_allowed: true,
}],
supported_modes: vec![RigMode::USB],
num_vfos: 1,
lock: false,
lockable: true,
attenuator: false,
preamp: false,
rit: false,
rpt: false,
split: false,
},
access: RigAccessMethod::Tcp {
addr: "127.0.0.1:1234".to_string(),
},
},
status: RigStatus {
freq: Freq { hz: 14_074_000 },
mode: RigMode::USB,
tx_en: false,
vfo: None,
tx: Some(RigTxStatus {
power: None,
limit: None,
swr: None,
alc: None,
}),
rx: None,
lock: Some(false),
},
band: None,
enabled: Some(true),
initialized: true,
server_callsign: Some("N0CALL".to_string()),
server_version: Some("test".to_string()),
server_latitude: None,
server_longitude: None,
aprs_decode_enabled: false,
cw_decode_enabled: false,
ft8_decode_enabled: false,
cw_auto: true,
cw_wpm: 15,
cw_tone_hz: 700,
}
}
#[tokio::test]
#[ignore = "requires TCP bind permissions"]
async fn rejects_missing_token() {
let addr = loopback_addr();
let (rig_tx, _rig_rx) = mpsc::channel::<RigRequest>(8);
let mut runtime = FrontendRuntimeContext::new();
runtime.auth_tokens = HashSet::from(["secret".to_string()]);
let ctx = Arc::new(runtime);
let handle = tokio::spawn(serve(addr, rig_tx, ctx));
let stream = TcpStream::connect(addr).await.expect("connect");
let (reader, mut writer) = stream.into_split();
let mut reader = BufReader::new(reader);
writer
.write_all(br#"{"cmd":"get_state"}"#)
.await
.expect("write");
writer.write_all(b"\n").await.expect("newline");
writer.flush().await.expect("flush");
let mut line = String::new();
reader.read_line(&mut line).await.expect("read");
let resp: ClientResponse = serde_json::from_str(line.trim_end()).expect("response json");
assert!(!resp.success);
assert_eq!(resp.error.as_deref(), Some("missing authorization token"));
handle.abort();
let _ = handle.await;
}
#[tokio::test]
#[ignore = "requires TCP bind permissions"]
async fn forwards_command_and_returns_snapshot() {
let addr = loopback_addr();
let (rig_tx, mut rig_rx) = mpsc::channel::<RigRequest>(8);
let ctx = Arc::new(FrontendRuntimeContext::new());
let rig_worker = tokio::spawn(async move {
if let Some(req) = rig_rx.recv().await {
let _ = req.respond_to.send(Ok(sample_snapshot()));
}
});
let handle = tokio::spawn(serve(addr, rig_tx, ctx));
let stream = TcpStream::connect(addr).await.expect("connect");
let (reader, mut writer) = stream.into_split();
let mut reader = BufReader::new(reader);
writer
.write_all(br#"{"cmd":"get_state"}"#)
.await
.expect("write");
writer.write_all(b"\n").await.expect("newline");
writer.flush().await.expect("flush");
let mut line = String::new();
reader.read_line(&mut line).await.expect("read");
let resp: ClientResponse = serde_json::from_str(line.trim_end()).expect("response json");
assert!(resp.success);
assert_eq!(resp.state.expect("snapshot").status.freq.hz, 14_074_000);
let _ = rig_worker.await;
handle.abort();
let _ = handle.await;
}
}
@@ -13,11 +13,11 @@ use tokio::sync::{broadcast, mpsc, oneshot, watch};
use tokio::time::{self, Duration};
use tokio_stream::wrappers::{IntervalStream, WatchStream};
use trx_frontend::FrontendRuntimeContext;
use trx_protocol::{ClientResponse, parse_mode};
use trx_core::radio::freq::Freq;
use trx_core::rig::{RigAccessMethod, RigCapabilities, RigInfo};
use trx_core::{RigCommand, RigRequest, RigSnapshot, RigState};
use trx_frontend::FrontendRuntimeContext;
use trx_protocol::{parse_mode, ClientResponse};
use crate::server::status;
@@ -450,28 +450,40 @@ async fn style_css() -> impl Responder {
#[get("/app.js")]
async fn app_js() -> impl Responder {
HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "application/javascript; charset=utf-8"))
.insert_header((
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
))
.body(status::APP_JS)
}
#[get("/aprs.js")]
async fn aprs_js() -> impl Responder {
HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "application/javascript; charset=utf-8"))
.insert_header((
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
))
.body(status::APRS_JS)
}
#[get("/ft8.js")]
async fn ft8_js() -> impl Responder {
HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "application/javascript; charset=utf-8"))
.insert_header((
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
))
.body(status::FT8_JS)
}
#[get("/cw.js")]
async fn cw_js() -> impl Responder {
HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "application/javascript; charset=utf-8"))
.insert_header((
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
))
.body(status::CW_JS)
}
@@ -10,11 +10,11 @@
//! - Browser sends binary messages: raw Opus packets (TX)
use std::collections::VecDeque;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::time::{Duration, Instant};
use actix_web::{Error, HttpRequest, HttpResponse, get, web};
use actix_web::{get, web, Error, HttpRequest, HttpResponse};
use actix_ws::Message;
use bytes::Bytes;
use tokio::sync::broadcast;
@@ -62,7 +62,10 @@ fn record_aprs(context: &FrontendRuntimeContext, pkt: AprsPacket) {
}
fn record_cw(context: &FrontendRuntimeContext, event: CwEvent) {
let mut history = context.cw_history.lock().expect("cw history mutex poisoned");
let mut history = context
.cw_history
.lock()
.expect("cw history mutex poisoned");
history.push_back((Instant::now(), event));
prune_cw_history(&mut history);
}
@@ -86,7 +89,10 @@ pub fn snapshot_aprs_history(context: &FrontendRuntimeContext) -> Vec<AprsPacket
}
pub fn snapshot_cw_history(context: &FrontendRuntimeContext) -> Vec<CwEvent> {
let mut history = context.cw_history.lock().expect("cw history mutex poisoned");
let mut history = context
.cw_history
.lock()
.expect("cw history mutex poisoned");
prune_cw_history(&mut history);
history.iter().map(|(_, evt)| evt.clone()).collect()
}
@@ -109,7 +115,10 @@ pub fn clear_aprs_history(context: &FrontendRuntimeContext) {
}
pub fn clear_cw_history(context: &FrontendRuntimeContext) {
let mut history = context.cw_history.lock().expect("cw history mutex poisoned");
let mut history = context
.cw_history
.lock()
.expect("cw history mutex poisoned");
history.clear();
}
@@ -128,7 +137,10 @@ pub fn subscribe_decode(
}
pub fn start_decode_history_collector(context: Arc<FrontendRuntimeContext>) {
if context.decode_collector_started.swap(true, Ordering::AcqRel) {
if context
.decode_collector_started
.swap(true, Ordering::AcqRel)
{
return;
}
@@ -8,8 +8,3 @@ pub fn register_frontend_on(context: &mut trx_frontend::FrontendRegistrationCont
use trx_frontend::FrontendSpawner;
context.register_frontend("http", server::HttpFrontend::spawn_frontend);
}
pub fn register_frontend() {
use trx_frontend::FrontendSpawner;
trx_frontend::register_frontend("http", server::HttpFrontend::spawn_frontend);
}
@@ -22,7 +22,7 @@ use tracing::{error, info};
use trx_core::RigRequest;
use trx_core::RigState;
use trx_frontend::{FrontendSpawner, FrontendRuntimeContext};
use trx_frontend::{FrontendRuntimeContext, FrontendSpawner};
/// HTTP frontend implementation.
pub struct HttpFrontend;
@@ -8,8 +8,3 @@ pub fn register_frontend_on(context: &mut trx_frontend::FrontendRegistrationCont
use trx_frontend::FrontendSpawner;
context.register_frontend("rigctl", server::RigctlFrontend::spawn_frontend);
}
pub fn register_frontend() {
use trx_frontend::FrontendSpawner;
trx_frontend::register_frontend("rigctl", server::RigctlFrontend::spawn_frontend);
}
+4 -2
View File
@@ -62,7 +62,7 @@ pub enum RigMode {
impl Default for RigStatus {
fn default() -> Self {
Self {
freq: Freq { hz: 144_300_000 }, // 2m calling frequency
freq: Freq { hz: 144_300_000 }, // 2m calling frequency
mode: RigMode::USB,
tx_en: false,
vfo: None,
@@ -136,7 +136,9 @@ impl RigState {
state.server_version = version;
state.server_latitude = latitude;
state.server_longitude = longitude;
state.status.freq = Freq { hz: initial_freq_hz };
state.status.freq = Freq {
hz: initial_freq_hz,
};
state.status.mode = initial_mode;
state
}
+16 -8
View File
@@ -43,7 +43,11 @@ extern "C" {
fn ft8_decoder_reset(dec: *mut c_void);
fn ft8_decoder_process(dec: *mut c_void, frame: *const c_float);
fn ft8_decoder_is_ready(dec: *const c_void) -> c_int;
fn ft8_decoder_decode(dec: *mut c_void, out: *mut Ft8DecodeResultRaw, max_results: c_int) -> c_int;
fn ft8_decoder_decode(
dec: *mut c_void,
out: *mut Ft8DecodeResultRaw,
max_results: c_int,
) -> c_int;
}
pub struct Ft8Decoder {
@@ -108,13 +112,17 @@ impl Ft8Decoder {
if ft8_decoder_is_ready(self.inner.as_ptr()) == 0 {
return Vec::new();
}
let mut raw = vec![Ft8DecodeResultRaw {
text: [0; FTX_MAX_MESSAGE_LENGTH],
snr_db: 0.0,
dt_s: 0.0,
freq_hz: 0.0,
}; max_results];
let count = ft8_decoder_decode(self.inner.as_ptr(), raw.as_mut_ptr(), max_results as c_int);
let mut raw = vec![
Ft8DecodeResultRaw {
text: [0; FTX_MAX_MESSAGE_LENGTH],
snr_db: 0.0,
dt_s: 0.0,
freq_hz: 0.0,
};
max_results
];
let count =
ft8_decoder_decode(self.inner.as_ptr(), raw.as_mut_ptr(), max_results as c_int);
let count = count.max(0) as usize;
let mut out = Vec::with_capacity(count);
for item in raw.into_iter().take(count) {
+1 -3
View File
@@ -187,9 +187,7 @@ mod tests {
assert!(validator.validate(&Some("token1".to_string())).is_ok());
assert!(validator.validate(&Some("token2".to_string())).is_ok());
assert!(validator
.validate(&Some("token3".to_string()))
.is_err());
assert!(validator.validate(&Some("token3".to_string())).is_err());
}
#[test]
+1 -1
View File
@@ -6,8 +6,8 @@
use serde_json;
use trx_core::rig::state::RigMode;
use crate::types::{ClientCommand, ClientEnvelope};
use trx_core::rig::state::RigMode;
/// Parse a mode string into a RigMode.
///
+6 -2
View File
@@ -27,7 +27,9 @@ pub fn client_command_to_rig(cmd: ClientCommand) -> RigCommand {
ClientCommand::Unlock => RigCommand::Unlock,
ClientCommand::GetTxLimit => RigCommand::GetTxLimit,
ClientCommand::SetTxLimit { limit } => RigCommand::SetTxLimit(limit),
ClientCommand::SetAprsDecodeEnabled { enabled } => RigCommand::SetAprsDecodeEnabled(enabled),
ClientCommand::SetAprsDecodeEnabled { enabled } => {
RigCommand::SetAprsDecodeEnabled(enabled)
}
ClientCommand::SetCwDecodeEnabled { enabled } => RigCommand::SetCwDecodeEnabled(enabled),
ClientCommand::SetCwAuto { enabled } => RigCommand::SetCwAuto(enabled),
ClientCommand::SetCwWpm { wpm } => RigCommand::SetCwWpm(wpm),
@@ -58,7 +60,9 @@ pub fn rig_command_to_client(cmd: RigCommand) -> ClientCommand {
RigCommand::Unlock => ClientCommand::Unlock,
RigCommand::GetTxLimit => ClientCommand::GetTxLimit,
RigCommand::SetTxLimit(limit) => ClientCommand::SetTxLimit { limit },
RigCommand::SetAprsDecodeEnabled(enabled) => ClientCommand::SetAprsDecodeEnabled { enabled },
RigCommand::SetAprsDecodeEnabled(enabled) => {
ClientCommand::SetAprsDecodeEnabled { enabled }
}
RigCommand::SetCwDecodeEnabled(enabled) => ClientCommand::SetCwDecodeEnabled { enabled },
RigCommand::SetCwAuto(enabled) => ClientCommand::SetCwAuto { enabled },
RigCommand::SetCwWpm(wpm) => ClientCommand::SetCwWpm { wpm },
+1 -2
View File
@@ -200,8 +200,7 @@ impl Demodulator {
self.corr_idx = (idx + 1) % self.corr_len;
// Compare mark vs space energy
let mark_energy =
self.mark_i_sum * self.mark_i_sum + self.mark_q_sum * self.mark_q_sum;
let mark_energy = self.mark_i_sum * self.mark_i_sum + self.mark_q_sum * self.mark_q_sum;
let space_energy =
self.space_i_sum * self.space_i_sum + self.space_q_sum * self.space_q_sum;
let bit: u8 = if mark_energy > space_energy { 1 } else { 0 };
+6 -14
View File
@@ -153,8 +153,7 @@ impl CwDecoder {
let mut tone_scan_bins = Vec::new();
let mut f = TONE_SCAN_LOW;
while f <= TONE_SCAN_HIGH {
let bk =
(f as f32 * window_size as f32 / sample_rate as f32).round();
let bk = (f as f32 * window_size as f32 / sample_rate as f32).round();
let b_omega = (2.0 * std::f32::consts::PI * bk) / window_size as f32;
tone_scan_bins.push(ToneScanBin {
freq: f,
@@ -202,8 +201,7 @@ impl CwDecoder {
fn recompute_goertzel(&mut self, new_freq: u32) {
self.tone_freq = new_freq;
let k = (new_freq as f32 * self.window_size as f32 / self.sample_rate as f32)
.round();
let k = (new_freq as f32 * self.window_size as f32 / self.sample_rate as f32).round();
let omega = (2.0 * std::f32::consts::PI * k) / self.window_size as f32;
self.coeff = 2.0 * omega.cos();
}
@@ -256,9 +254,7 @@ impl CwDecoder {
return;
}
if self.tone_stable_bin >= 0
&& (best_idx - self.tone_stable_bin).unsigned_abs() <= 1
{
if self.tone_stable_bin >= 0 && (best_idx - self.tone_stable_bin).unsigned_abs() <= 1 {
self.tone_stable_count += 1;
} else {
self.tone_stable_bin = best_idx;
@@ -267,9 +263,7 @@ impl CwDecoder {
if self.tone_stable_count >= TONE_STABLE_NEEDED {
let detected_freq = self.tone_scan_bins[self.tone_stable_bin as usize].freq;
if (detected_freq as i32 - self.tone_freq as i32).unsigned_abs()
> TONE_SCAN_STEP
{
if (detected_freq as i32 - self.tone_freq as i32).unsigned_abs() > TONE_SCAN_STEP {
self.recompute_goertzel(detected_freq);
}
}
@@ -337,8 +331,7 @@ impl CwDecoder {
if off_duration > u * 5.0 {
// Word gap
if !self.current_symbol.is_empty() {
let ch = morse_lookup(&self.current_symbol)
.unwrap_or('?');
let ch = morse_lookup(&self.current_symbol).unwrap_or('?');
self.emit_text(&ch.to_string());
self.current_symbol.clear();
}
@@ -346,8 +339,7 @@ impl CwDecoder {
} else if off_duration > u * 2.0 {
// Character gap
if !self.current_symbol.is_empty() {
let ch = morse_lookup(&self.current_symbol)
.unwrap_or('?');
let ch = morse_lookup(&self.current_symbol).unwrap_or('?');
self.emit_text(&ch.to_string());
self.current_symbol.clear();
}
+126
View File
@@ -316,3 +316,129 @@ async fn handle_client(
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
use std::net::{Ipv4Addr, SocketAddr};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpStream;
use trx_core::radio::freq::Band;
use trx_core::rig::{RigAccessMethod, RigCapabilities, RigInfo};
fn loopback_addr() -> SocketAddr {
let listener = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind");
let addr = listener.local_addr().expect("local_addr");
drop(listener);
addr
}
fn sample_state() -> RigState {
let mut state = RigState::new_uninitialized();
state.initialized = true;
state.rig_info = Some(RigInfo {
manufacturer: "Test".to_string(),
model: "Dummy".to_string(),
revision: "1".to_string(),
capabilities: RigCapabilities {
supported_bands: vec![Band {
low_hz: 7_000_000,
high_hz: 7_200_000,
tx_allowed: true,
}],
supported_modes: vec![trx_core::RigMode::USB],
num_vfos: 1,
lock: false,
lockable: true,
attenuator: false,
preamp: false,
rit: false,
rpt: false,
split: false,
},
access: RigAccessMethod::Tcp {
addr: "127.0.0.1:1234".to_string(),
},
});
state
}
#[tokio::test]
#[ignore = "requires TCP bind permissions"]
async fn listener_rejects_missing_token() {
let addr = loopback_addr();
let (rig_tx, _rig_rx) = mpsc::channel::<RigRequest>(8);
let (state_tx, state_rx) = watch::channel(sample_state());
let _state_tx = state_tx;
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let mut auth = HashSet::new();
auth.insert("secret".to_string());
let handle = tokio::spawn(run_listener(addr, rig_tx, auth, state_rx, shutdown_rx));
let stream = TcpStream::connect(addr).await.expect("connect");
let (reader, mut writer) = stream.into_split();
let mut reader = BufReader::new(reader);
writer
.write_all(br#"{"cmd":"get_state"}"#)
.await
.expect("write");
writer.write_all(b"\n").await.expect("newline");
writer.flush().await.expect("flush");
let mut line = String::new();
reader.read_line(&mut line).await.expect("read");
let resp: ClientResponse = serde_json::from_str(line.trim_end()).expect("response json");
assert!(!resp.success);
assert_eq!(resp.error.as_deref(), Some("missing authorization token"));
let _ = shutdown_tx.send(true);
handle.abort();
let _ = handle.await;
}
#[tokio::test]
#[ignore = "requires TCP bind permissions"]
async fn listener_serves_get_state_snapshot() {
let addr = loopback_addr();
let (rig_tx, _rig_rx) = mpsc::channel::<RigRequest>(8);
let (state_tx, state_rx) = watch::channel(sample_state());
let _state_tx = state_tx;
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let handle = tokio::spawn(run_listener(
addr,
rig_tx,
HashSet::new(),
state_rx,
shutdown_rx,
));
let stream = TcpStream::connect(addr).await.expect("connect");
let (reader, mut writer) = stream.into_split();
let mut reader = BufReader::new(reader);
writer
.write_all(br#"{"cmd":"get_state"}"#)
.await
.expect("write");
writer.write_all(b"\n").await.expect("newline");
writer.flush().await.expect("flush");
let mut line = String::new();
reader.read_line(&mut line).await.expect("read");
let resp: ClientResponse = serde_json::from_str(line.trim_end()).expect("response json");
assert!(resp.success);
let snapshot = resp.state.expect("snapshot");
assert_eq!(snapshot.info.model, "Dummy");
assert_eq!(snapshot.status.freq.hz, 144_300_000);
let _ = shutdown_tx.send(true);
handle.abort();
let _ = handle.await;
}
}
+5 -6
View File
@@ -12,6 +12,7 @@ mod rig_task;
use std::collections::HashSet;
use std::net::{IpAddr, SocketAddr};
use std::path::PathBuf;
use std::ptr::NonNull;
use std::time::Duration;
use bytes::Bytes;
@@ -23,10 +24,8 @@ use tracing::{error, info};
use trx_core::audio::AudioStreamInfo;
use trx_app::{init_logging, load_plugins, normalize_name};
use trx_backend::{
register_builtin_backends_on, snapshot_bootstrap_context, RegistrationContext, RigAccess,
};
use trx_app::{init_logging, load_backend_plugins, normalize_name};
use trx_backend::{register_builtin_backends_on, RegistrationContext, RigAccess};
use trx_core::rig::controller::{AdaptivePolling, ExponentialBackoff};
use trx_core::rig::request::RigRequest;
use trx_core::rig::state::RigState;
@@ -247,8 +246,8 @@ async fn main() -> DynResult<()> {
init_logging(cfg.general.log_level.as_deref());
let _plugin_libs = load_plugins();
bootstrap_ctx.extend_from(&snapshot_bootstrap_context());
let bootstrap_ctx_ptr = NonNull::from(&mut bootstrap_ctx).cast();
let _plugin_libs = load_backend_plugins(bootstrap_ctx_ptr);
if let Some(ref path) = config_path {
info!("Loaded configuration from {}", path.display());
+46 -12
View File
@@ -38,15 +38,51 @@ impl DummyRig {
revision: "1.0".to_string(),
capabilities: RigCapabilities {
supported_bands: vec![
Band { low_hz: 1_800_000, high_hz: 2_000_000, tx_allowed: true },
Band { low_hz: 3_500_000, high_hz: 4_000_000, tx_allowed: true },
Band { low_hz: 7_000_000, high_hz: 7_300_000, tx_allowed: true },
Band { low_hz: 14_000_000, high_hz: 14_350_000, tx_allowed: true },
Band { low_hz: 21_000_000, high_hz: 21_450_000, tx_allowed: true },
Band { low_hz: 28_000_000, high_hz: 29_700_000, tx_allowed: true },
Band { low_hz: 50_000_000, high_hz: 54_000_000, tx_allowed: true },
Band { low_hz: 144_000_000, high_hz: 148_000_000, tx_allowed: true },
Band { low_hz: 430_000_000, high_hz: 440_000_000, tx_allowed: true },
Band {
low_hz: 1_800_000,
high_hz: 2_000_000,
tx_allowed: true,
},
Band {
low_hz: 3_500_000,
high_hz: 4_000_000,
tx_allowed: true,
},
Band {
low_hz: 7_000_000,
high_hz: 7_300_000,
tx_allowed: true,
},
Band {
low_hz: 14_000_000,
high_hz: 14_350_000,
tx_allowed: true,
},
Band {
low_hz: 21_000_000,
high_hz: 21_450_000,
tx_allowed: true,
},
Band {
low_hz: 28_000_000,
high_hz: 29_700_000,
tx_allowed: true,
},
Band {
low_hz: 50_000_000,
high_hz: 54_000_000,
tx_allowed: true,
},
Band {
low_hz: 144_000_000,
high_hz: 148_000_000,
tx_allowed: true,
},
Band {
low_hz: 430_000_000,
high_hz: 440_000_000,
tx_allowed: true,
},
],
supported_modes: vec![
RigMode::LSB,
@@ -112,9 +148,7 @@ impl Rig for DummyRig {
impl RigCat for DummyRig {
fn get_status<'a>(&'a mut self) -> RigStatusFuture<'a> {
Box::pin(async move {
Ok((self.freq, self.mode.clone(), Some(self.build_vfo())))
})
Box::pin(async move { Ok((self.freq, self.mode.clone(), Some(self.build_vfo()))) })
}
fn set_freq<'a>(
+2 -54
View File
@@ -3,17 +3,16 @@
// SPDX-License-Identifier: BSD-2-Clause
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use trx_core::rig::RigCat;
use trx_core::DynResult;
mod dummy;
#[cfg(feature = "ft817")]
use trx_backend_ft817::Ft817;
#[cfg(feature = "ft450d")]
use trx_backend_ft450d::Ft450d;
#[cfg(feature = "ft817")]
use trx_backend_ft817::Ft817;
/// Connection details for instantiating a rig backend.
#[derive(Debug, Clone)]
@@ -88,27 +87,6 @@ fn normalize_name(name: &str) -> String {
.collect()
}
/// Phase 3D: Plugin compatibility adapter - delegates to bootstrap context.
fn bootstrap_context() -> &'static Arc<Mutex<RegistrationContext>> {
static BOOTSTRAP_CONTEXT: OnceLock<Arc<Mutex<RegistrationContext>>> = OnceLock::new();
BOOTSTRAP_CONTEXT.get_or_init(|| Arc::new(Mutex::new(RegistrationContext::new())))
}
/// Snapshot current plugin/bootstrap registrations into an owned context.
pub fn snapshot_bootstrap_context() -> RegistrationContext {
let ctx = bootstrap_context()
.lock()
.expect("backend context mutex poisoned");
ctx.clone()
}
/// Register a backend factory under a stable name (e.g. "ft817").
/// Plugin compatibility: delegates to bootstrap context.
pub fn register_backend(name: &str, factory: BackendFactory) {
let mut ctx = bootstrap_context().lock().expect("backend context mutex poisoned");
ctx.register_backend(name, factory);
}
/// Register all built-in backends enabled by features on a context.
pub fn register_builtin_backends_on(context: &mut RegistrationContext) {
context.register_backend("dummy", dummy_factory);
@@ -118,40 +96,10 @@ pub fn register_builtin_backends_on(context: &mut RegistrationContext) {
context.register_backend("ft450d", ft450d_factory);
}
/// Register all built-in backends enabled by features (global, for plugin compatibility).
pub fn register_builtin_backends() {
register_backend("dummy", dummy_factory);
#[cfg(feature = "ft817")]
register_backend("ft817", ft817_factory);
#[cfg(feature = "ft450d")]
register_backend("ft450d", ft450d_factory);
}
fn dummy_factory(_access: RigAccess) -> DynResult<Box<dyn RigCat>> {
Ok(Box::new(dummy::DummyRig::new()))
}
/// Check whether a backend name is registered.
/// Plugin compatibility: reads from bootstrap context.
pub fn is_backend_registered(name: &str) -> bool {
let ctx = bootstrap_context().lock().expect("backend context mutex poisoned");
ctx.is_backend_registered(name)
}
/// List registered backend names.
/// Plugin compatibility: reads from bootstrap context.
pub fn registered_backends() -> Vec<String> {
let ctx = bootstrap_context().lock().expect("backend context mutex poisoned");
ctx.registered_backends()
}
/// Instantiate a rig backend based on the selected name and access method.
/// Plugin compatibility: reads from bootstrap context.
pub fn build_rig(name: &str, access: RigAccess) -> DynResult<Box<dyn RigCat>> {
let ctx = bootstrap_context().lock().expect("backend context mutex poisoned");
ctx.build_rig(name, access)
}
#[cfg(feature = "ft817")]
fn ft817_factory(access: RigAccess) -> DynResult<Box<dyn RigCat>> {
match access {
@@ -325,7 +325,9 @@ impl Ft450d {
async fn read_freq(&mut self) -> DynResult<u64> {
let resp = self.query("FA;").await?;
let data = resp.strip_prefix("FA").ok_or("CAT freq response missing FA")?;
let data = resp
.strip_prefix("FA")
.ok_or("CAT freq response missing FA")?;
let digits: String = data.chars().filter(|c| c.is_ascii_digit()).collect();
let freq: u64 = digits.parse().map_err(|_| "CAT freq parse failed")?;
Ok(freq)
@@ -333,7 +335,9 @@ impl Ft450d {
async fn read_mode(&mut self) -> DynResult<RigMode> {
let resp = self.query("MD0;").await?;
let data = resp.strip_prefix("MD").ok_or("CAT mode response missing MD")?;
let data = resp
.strip_prefix("MD")
.ok_or("CAT mode response missing MD")?;
let code = data.chars().last().ok_or("CAT mode parse failed")?;
Ok(decode_mode(code))
}