registry: add backend/frontend registries and plugin loader
This commit is contained in:
@@ -0,0 +1,18 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "trx-plugin-example"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
trx-backend = { path = "../../src/trx-backend" }
|
||||||
|
trx-core = { path = "../../src/trx-core" }
|
||||||
|
trx-frontend = { path = "../../src/trx-frontend" }
|
||||||
|
tokio = { workspace = true, features = ["full"] }
|
||||||
|
tracing = { workspace = true }
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# trx-plugin-example
|
||||||
|
|
||||||
|
This is a minimal shared-library plugin that registers a backend and frontend.
|
||||||
|
The backend is a stub that returns an error; the frontend is a no-op spawner.
|
||||||
|
|
||||||
|
Build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build -p trx-plugin-example --release
|
||||||
|
```
|
||||||
|
|
||||||
|
Install (example):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p plugins
|
||||||
|
cp target/release/libtrx_plugin_example.* plugins/
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `trx-bin` with `TRX_PLUGIN_DIRS=./plugins` to discover the plugin.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
|
use tokio::sync::{mpsc, watch};
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
use trx_backend::{register_backend, RigAccess};
|
||||||
|
use trx_core::{DynResult, RigRequest, RigState};
|
||||||
|
use trx_frontend::{register_frontend, FrontendSpawner};
|
||||||
|
|
||||||
|
const BACKEND_NAME: &str = "example";
|
||||||
|
const FRONTEND_NAME: &str = "example-frontend";
|
||||||
|
|
||||||
|
/// Entry point called by trx-bin when the plugin is loaded.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn trx_register() {
|
||||||
|
register_backend(BACKEND_NAME, example_backend_factory);
|
||||||
|
register_frontend(FRONTEND_NAME, ExampleFrontend::spawn_frontend);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn example_backend_factory(_access: RigAccess) -> DynResult<Box<dyn trx_core::rig::RigCat>> {
|
||||||
|
Err("example plugin backend not implemented".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExampleFrontend;
|
||||||
|
|
||||||
|
impl FrontendSpawner for ExampleFrontend {
|
||||||
|
fn spawn_frontend(
|
||||||
|
_state_rx: watch::Receiver<RigState>,
|
||||||
|
_rig_tx: mpsc::Sender<RigRequest>,
|
||||||
|
_callsign: Option<String>,
|
||||||
|
listen_addr: SocketAddr,
|
||||||
|
) -> JoinHandle<()> {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
info!("example frontend loaded at {} (no-op)", listen_addr);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,4 +18,3 @@ tokio = { workspace = true, features = ["full"] }
|
|||||||
tokio-serial = { workspace = true }
|
tokio-serial = { workspace = true }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
clap = { workspace = true, features = ["derive"] }
|
|
||||||
|
|||||||
+71
-41
@@ -2,39 +2,15 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: BSD-2-Clause
|
// 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::rig::RigCat;
|
||||||
use trx_core::DynResult;
|
use trx_core::DynResult;
|
||||||
|
|
||||||
#[cfg(feature = "ft817")]
|
#[cfg(feature = "ft817")]
|
||||||
use trx_backend_ft817::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.
|
/// Connection details for instantiating a rig backend.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum RigAccess {
|
pub enum RigAccess {
|
||||||
@@ -42,21 +18,75 @@ pub enum RigAccess {
|
|||||||
Tcp { addr: String },
|
Tcp { addr: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Instantiate a rig backend based on the selected kind and access method.
|
type BackendFactory = fn(RigAccess) -> DynResult<Box<dyn RigCat>>;
|
||||||
pub fn build_rig(kind: RigKind, access: RigAccess) -> DynResult<Box<dyn RigCat>> {
|
|
||||||
match (kind, access) {
|
struct BackendRegistry {
|
||||||
// Yaesu FT-817
|
factories: HashMap<String, BackendFactory>,
|
||||||
#[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())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback for unsupported combinations
|
impl BackendRegistry {
|
||||||
#[allow(unreachable_patterns)]
|
fn new() -> Self {
|
||||||
_ => Err("Selected rig is not enabled/available".into()),
|
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()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -2,10 +2,14 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: BSD-2-Clause
|
// 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::sync::{mpsc, watch};
|
||||||
use tokio::task::JoinHandle;
|
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.
|
/// Trait implemented by concrete frontends to expose a runner entrypoint.
|
||||||
pub trait FrontendSpawner {
|
pub trait FrontendSpawner {
|
||||||
@@ -13,5 +17,76 @@ pub trait FrontendSpawner {
|
|||||||
state_rx: watch::Receiver<RigState>,
|
state_rx: watch::Receiver<RigState>,
|
||||||
rig_tx: mpsc::Sender<RigRequest>,
|
rig_tx: mpsc::Sender<RigRequest>,
|
||||||
callsign: Option<String>,
|
callsign: Option<String>,
|
||||||
|
listen_addr: SocketAddr,
|
||||||
) -> JoinHandle<()>;
|
) -> 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))
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user