registry: add backend/frontend registries and plugin loader

This commit is contained in:
2026-01-18 09:19:37 +01:00
parent 6ef16f2cf4
commit 1be08b245c
7 changed files with 342 additions and 44 deletions
-1
View File
@@ -18,4 +18,3 @@ tokio = { workspace = true, features = ["full"] }
tokio-serial = { workspace = true }
serde = { workspace = true, features = ["derive"] }
tracing = { workspace = true }
clap = { workspace = true, features = ["derive"] }
+72 -42
View File
@@ -2,39 +2,15 @@
//
// SPDX-License-Identifier: BSD-2-Clause
use clap::ValueEnum;
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use trx_core::rig::RigCat;
use trx_core::DynResult;
#[cfg(feature = "ft817")]
use trx_backend_ft817::Ft817;
/// Supported rig backends selectable at runtime.
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum RigKind {
#[cfg(feature = "ft817")]
#[value(alias = "ft-817")]
Ft817,
}
impl RigKind {
pub fn all() -> &'static [RigKind] {
&[
#[cfg(feature = "ft817")]
RigKind::Ft817,
]
}
}
impl std::fmt::Display for RigKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
#[cfg(feature = "ft817")]
RigKind::Ft817 => write!(f, "ft817"),
}
}
}
/// Connection details for instantiating a rig backend.
#[derive(Debug, Clone)]
pub enum RigAccess {
@@ -42,21 +18,75 @@ pub enum RigAccess {
Tcp { addr: String },
}
/// Instantiate a rig backend based on the selected kind and access method.
pub fn build_rig(kind: RigKind, access: RigAccess) -> DynResult<Box<dyn RigCat>> {
match (kind, access) {
// Yaesu FT-817
#[cfg(feature = "ft817")]
(RigKind::Ft817, RigAccess::Serial { path, baud }) => {
Ok(Box::new(Ft817::new(&path, baud)?))
}
#[cfg(feature = "ft817")]
(RigKind::Ft817, RigAccess::Tcp { .. }) => {
Err("FT-817 only supports serial CAT access".into())
}
type BackendFactory = fn(RigAccess) -> DynResult<Box<dyn RigCat>>;
// Fallback for unsupported combinations
#[allow(unreachable_patterns)]
_ => Err("Selected rig is not enabled/available".into()),
struct BackendRegistry {
factories: HashMap<String, BackendFactory>,
}
impl BackendRegistry {
fn new() -> Self {
Self {
factories: HashMap::new(),
}
}
}
fn registry() -> &'static Mutex<BackendRegistry> {
static REGISTRY: OnceLock<Mutex<BackendRegistry>> = OnceLock::new();
REGISTRY.get_or_init(|| Mutex::new(BackendRegistry::new()))
}
fn normalize_name(name: &str) -> String {
name.to_ascii_lowercase()
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.collect()
}
/// Register a backend factory under a stable name (e.g. "ft817").
pub fn register_backend(name: &str, factory: BackendFactory) {
let key = normalize_name(name);
let mut reg = registry().lock().expect("backend registry mutex poisoned");
reg.factories.insert(key, factory);
}
/// Register all built-in backends enabled by features.
pub fn register_builtin_backends() {
#[cfg(feature = "ft817")]
register_backend("ft817", ft817_factory);
}
/// Check whether a backend name is registered.
pub fn is_backend_registered(name: &str) -> bool {
let key = normalize_name(name);
let reg = registry().lock().expect("backend registry mutex poisoned");
reg.factories.contains_key(&key)
}
/// List registered backend names.
pub fn registered_backends() -> Vec<String> {
let reg = registry().lock().expect("backend registry mutex poisoned");
let mut names: Vec<String> = reg.factories.keys().cloned().collect();
names.sort();
names
}
/// Instantiate a rig backend based on the selected name and access method.
pub fn build_rig(name: &str, access: RigAccess) -> DynResult<Box<dyn RigCat>> {
let key = normalize_name(name);
let reg = registry().lock().expect("backend registry mutex poisoned");
let factory = reg
.factories
.get(&key)
.ok_or_else(|| format!("Unknown rig backend: {}", name))?;
factory(access)
}
#[cfg(feature = "ft817")]
fn ft817_factory(access: RigAccess) -> DynResult<Box<dyn RigCat>> {
match access {
RigAccess::Serial { path, baud } => Ok(Box::new(Ft817::new(&path, baud)?)),
RigAccess::Tcp { .. } => Err("FT-817 only supports serial CAT access".into()),
}
}
+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)
}
+76 -1
View File
@@ -2,10 +2,14 @@
//
// SPDX-License-Identifier: BSD-2-Clause
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::{Mutex, OnceLock};
use tokio::sync::{mpsc, watch};
use tokio::task::JoinHandle;
use trx_core::{RigRequest, RigState};
use trx_core::{DynResult, RigRequest, RigState};
/// Trait implemented by concrete frontends to expose a runner entrypoint.
pub trait FrontendSpawner {
@@ -13,5 +17,76 @@ pub trait FrontendSpawner {
state_rx: watch::Receiver<RigState>,
rig_tx: mpsc::Sender<RigRequest>,
callsign: Option<String>,
listen_addr: SocketAddr,
) -> JoinHandle<()>;
}
type FrontendSpawnFn = fn(
watch::Receiver<RigState>,
mpsc::Sender<RigRequest>,
Option<String>,
SocketAddr,
) -> JoinHandle<()>;
struct FrontendRegistry {
spawners: HashMap<String, FrontendSpawnFn>,
}
impl FrontendRegistry {
fn new() -> Self {
Self {
spawners: HashMap::new(),
}
}
}
fn registry() -> &'static Mutex<FrontendRegistry> {
static REGISTRY: OnceLock<Mutex<FrontendRegistry>> = OnceLock::new();
REGISTRY.get_or_init(|| Mutex::new(FrontendRegistry::new()))
}
fn normalize_name(name: &str) -> String {
name.to_ascii_lowercase()
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.collect()
}
/// Register a frontend spawner under a stable name (e.g. "http").
pub fn register_frontend(name: &str, spawner: FrontendSpawnFn) {
let key = normalize_name(name);
let mut reg = registry().lock().expect("frontend registry mutex poisoned");
reg.spawners.insert(key, spawner);
}
/// Check whether a frontend name is registered.
pub fn is_frontend_registered(name: &str) -> bool {
let key = normalize_name(name);
let reg = registry().lock().expect("frontend registry mutex poisoned");
reg.spawners.contains_key(&key)
}
/// List registered frontend names.
pub fn registered_frontends() -> Vec<String> {
let reg = registry().lock().expect("frontend registry mutex poisoned");
let mut names: Vec<String> = reg.spawners.keys().cloned().collect();
names.sort();
names
}
/// Spawn a registered frontend by name.
pub fn spawn_frontend(
name: &str,
state_rx: watch::Receiver<RigState>,
rig_tx: mpsc::Sender<RigRequest>,
callsign: Option<String>,
listen_addr: SocketAddr,
) -> DynResult<JoinHandle<()>> {
let key = normalize_name(name);
let reg = registry().lock().expect("frontend registry mutex poisoned");
let spawner = reg
.spawners
.get(&key)
.ok_or_else(|| format!("Unknown frontend: {}", name))?;
Ok(spawner(state_rx, rig_tx, callsign, listen_addr))
}