[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:
@@ -0,0 +1,70 @@
|
|||||||
|
# Top 5 Real Architecture Issues
|
||||||
|
|
||||||
|
## 1) Global plugin compatibility registries still exist
|
||||||
|
### Files
|
||||||
|
- `src/trx-server/trx-backend/src/lib.rs`
|
||||||
|
- `src/trx-client/trx-frontend/src/lib.rs`
|
||||||
|
|
||||||
|
### Why this matters
|
||||||
|
`OnceLock<Mutex<...>>` registry shims still hold mutable global state. This keeps plugin registration behavior implicit and harder to test.
|
||||||
|
|
||||||
|
### Fix steps
|
||||||
|
1. Introduce explicit plugin registration API that takes a mutable context.
|
||||||
|
2. Make plugin loader return registration data instead of relying on global side effects.
|
||||||
|
3. Remove global `register_*`/`snapshot_bootstrap_context` wrappers after migration.
|
||||||
|
|
||||||
|
## 2) No supervised shutdown/lifecycle model
|
||||||
|
### Files
|
||||||
|
- `src/trx-server/src/main.rs`
|
||||||
|
- `src/trx-client/src/main.rs`
|
||||||
|
|
||||||
|
### Why this matters
|
||||||
|
Many tasks are detached via `tokio::spawn` and process shutdown mostly waits on Ctrl+C. Task failures and cancellation order are not centrally managed.
|
||||||
|
|
||||||
|
### Fix steps
|
||||||
|
1. Add shared cancellation token.
|
||||||
|
2. Track tasks in `JoinSet`.
|
||||||
|
3. On shutdown: stop listeners, cancel workers, await joins with timeout, then exit.
|
||||||
|
|
||||||
|
## 3) Protocol/network hardening gaps
|
||||||
|
### Files
|
||||||
|
- `src/trx-client/src/remote_client.rs`
|
||||||
|
- `src/trx-server/src/listener.rs`
|
||||||
|
- `src/trx-client/trx-frontend/trx-frontend-http-json/src/server.rs`
|
||||||
|
|
||||||
|
### Why this matters
|
||||||
|
`parse_remote_url` is ad-hoc and line-based listeners accept unbounded lines. This risks parsing edge cases and memory pressure.
|
||||||
|
|
||||||
|
### Fix steps
|
||||||
|
1. Replace string URL parsing with typed address parsing (support IPv4/IPv6/hostnames explicitly).
|
||||||
|
2. Enforce maximum line/frame size for JSON-over-TCP.
|
||||||
|
3. Add read/write/request timeouts and explicit error messages.
|
||||||
|
|
||||||
|
## 4) Config has parse defaults but weak semantic validation
|
||||||
|
### Files
|
||||||
|
- `src/trx-server/src/config.rs`
|
||||||
|
- `src/trx-client/src/config.rs`
|
||||||
|
|
||||||
|
### Why this matters
|
||||||
|
Config loads successfully even when values are semantically bad (timings, ports, audio params), leading to runtime failures.
|
||||||
|
|
||||||
|
### Fix steps
|
||||||
|
1. Add `validate()` to server/client config models.
|
||||||
|
2. Validate ranges and required field combinations.
|
||||||
|
3. Call `validate()` in startup before spawning tasks; fail fast with clear path-based errors.
|
||||||
|
|
||||||
|
## 5) Integration coverage is still thin at boundaries
|
||||||
|
### Files
|
||||||
|
- `src/trx-server/src/listener.rs`
|
||||||
|
- `src/trx-client/src/remote_client.rs`
|
||||||
|
- `src/trx-client/trx-frontend/trx-frontend-http-json/src/server.rs`
|
||||||
|
- `src/trx-app/src/plugins.rs`
|
||||||
|
|
||||||
|
### Why this matters
|
||||||
|
Most coverage is unit-level. Critical network/plugin/runtime flows can regress without tests.
|
||||||
|
|
||||||
|
### Fix steps
|
||||||
|
1. Add integration tests for JSON TCP auth/command flow.
|
||||||
|
2. Add reconnect tests for remote client.
|
||||||
|
3. Add plugin load/failure isolation tests.
|
||||||
|
4. Add shutdown behavior tests once lifecycle supervision is added.
|
||||||
@@ -8,18 +8,25 @@ use tokio::sync::{mpsc, watch};
|
|||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
use trx_backend::{register_backend, RigAccess};
|
use trx_backend::{RegistrationContext, RigAccess};
|
||||||
use trx_core::{DynResult, RigRequest, RigState};
|
use trx_core::{DynResult, RigRequest, RigState};
|
||||||
use trx_frontend::{register_frontend, FrontendSpawner};
|
use trx_frontend::{FrontendRuntimeContext, FrontendSpawner, FrontendRegistrationContext};
|
||||||
|
|
||||||
const BACKEND_NAME: &str = "example";
|
const BACKEND_NAME: &str = "example";
|
||||||
const FRONTEND_NAME: &str = "example-frontend";
|
const FRONTEND_NAME: &str = "example-frontend";
|
||||||
|
|
||||||
/// Entry point called by trx-server/trx-client when the plugin is loaded.
|
/// Entry point called by trx-server when the plugin is loaded.
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
pub extern "C" fn trx_register() {
|
pub extern "C" fn trx_register_backend(context: *mut std::ffi::c_void) {
|
||||||
register_backend(BACKEND_NAME, example_backend_factory);
|
let context = unsafe { &mut *(context as *mut RegistrationContext) };
|
||||||
register_frontend(FRONTEND_NAME, ExampleFrontend::spawn_frontend);
|
context.register_backend(BACKEND_NAME, example_backend_factory);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entry point called by trx-client when the plugin is loaded.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn trx_register_frontend(context: *mut std::ffi::c_void) {
|
||||||
|
let context = unsafe { &mut *(context as *mut FrontendRegistrationContext) };
|
||||||
|
context.register_frontend(FRONTEND_NAME, ExampleFrontend::spawn_frontend);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn example_backend_factory(_access: RigAccess) -> DynResult<Box<dyn trx_core::rig::RigCat>> {
|
fn example_backend_factory(_access: RigAccess) -> DynResult<Box<dyn trx_core::rig::RigCat>> {
|
||||||
@@ -34,6 +41,7 @@ impl FrontendSpawner for ExampleFrontend {
|
|||||||
_rig_tx: mpsc::Sender<RigRequest>,
|
_rig_tx: mpsc::Sender<RigRequest>,
|
||||||
_callsign: Option<String>,
|
_callsign: Option<String>,
|
||||||
listen_addr: SocketAddr,
|
listen_addr: SocketAddr,
|
||||||
|
_context: std::sync::Arc<FrontendRuntimeContext>,
|
||||||
) -> JoinHandle<()> {
|
) -> JoinHandle<()> {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
info!("example frontend loaded at {} (no-op)", listen_addr);
|
info!("example frontend loaded at {} (no-op)", listen_addr);
|
||||||
|
|||||||
Executable
+13
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Run trx-server with the dummy backend for development and testing.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
exec cargo run --manifest-path "$PROJECT_ROOT/Cargo.toml" \
|
||||||
|
-p trx-server -- \
|
||||||
|
--rig dummy \
|
||||||
|
--access serial \
|
||||||
|
"/dev/null 9600" \
|
||||||
|
"$@"
|
||||||
@@ -9,5 +9,5 @@ pub mod util;
|
|||||||
|
|
||||||
pub use config::{ConfigError, ConfigFile};
|
pub use config::{ConfigError, ConfigFile};
|
||||||
pub use logging::init_logging;
|
pub use logging::init_logging;
|
||||||
pub use plugins::load_plugins;
|
pub use plugins::{load_backend_plugins, load_frontend_plugins};
|
||||||
pub use util::normalize_name;
|
pub use util::normalize_name;
|
||||||
|
|||||||
+38
-11
@@ -4,12 +4,14 @@
|
|||||||
|
|
||||||
use std::ffi::OsStr;
|
use std::ffi::OsStr;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::ptr::NonNull;
|
||||||
|
|
||||||
use libloading::{Library, Symbol};
|
use libloading::{Library, Symbol};
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
const PLUGIN_ENV: &str = "TRX_PLUGIN_DIRS";
|
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)]
|
#[cfg(windows)]
|
||||||
const PATH_SEPARATOR: char = ';';
|
const PATH_SEPARATOR: char = ';';
|
||||||
@@ -23,7 +25,18 @@ const PLUGIN_EXTENSIONS: &[&str] = &["dylib"];
|
|||||||
#[cfg(all(unix, not(target_os = "macos")))]
|
#[cfg(all(unix, not(target_os = "macos")))]
|
||||||
const PLUGIN_EXTENSIONS: &[&str] = &["so"];
|
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 mut libraries = Vec::new();
|
||||||
let search_paths = plugin_search_paths();
|
let search_paths = plugin_search_paths();
|
||||||
|
|
||||||
@@ -34,7 +47,7 @@ pub fn load_plugins() -> Vec<Library> {
|
|||||||
info!("Plugin search paths: {:?}", search_paths);
|
info!("Plugin search paths: {:?}", search_paths);
|
||||||
|
|
||||||
for path in 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);
|
warn!("Plugin scan failed for {:?}: {}", path, err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -42,7 +55,12 @@ pub fn load_plugins() -> Vec<Library> {
|
|||||||
libraries
|
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() {
|
if !path.exists() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -60,7 +78,7 @@ fn load_plugins_from_dir(path: &Path, libraries: &mut Vec<Library>) -> std::io::
|
|||||||
unsafe {
|
unsafe {
|
||||||
match Library::new(&path) {
|
match Library::new(&path) {
|
||||||
Ok(lib) => {
|
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);
|
warn!("Plugin {:?} failed to register: {}", path, err);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -77,11 +95,16 @@ fn load_plugins_from_dir(path: &Path, libraries: &mut Vec<Library>) -> std::io::
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
unsafe fn register_library(lib: &Library, path: &Path) -> Result<(), String> {
|
unsafe fn register_library(
|
||||||
let entry: Symbol<unsafe extern "C" fn()> = lib
|
lib: &Library,
|
||||||
.get(PLUGIN_ENTRYPOINT.as_bytes())
|
path: &Path,
|
||||||
.map_err(|e| format!("missing entrypoint {}: {}", PLUGIN_ENTRYPOINT, e))?;
|
entrypoint: &str,
|
||||||
entry();
|
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);
|
info!("Registered plugin {:?}", path);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -110,6 +133,10 @@ fn plugin_search_paths() -> Vec<PathBuf> {
|
|||||||
fn is_plugin_file(path: &Path) -> bool {
|
fn is_plugin_file(path: &Path) -> bool {
|
||||||
path.extension()
|
path.extension()
|
||||||
.and_then(OsStr::to_str)
|
.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)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ mod remote_client;
|
|||||||
|
|
||||||
use std::net::{IpAddr, SocketAddr};
|
use std::net::{IpAddr, SocketAddr};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::ptr::NonNull;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
@@ -17,16 +18,14 @@ use tokio::sync::{broadcast, mpsc, watch};
|
|||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
use tracing::{error, info};
|
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::audio::AudioStreamInfo;
|
||||||
|
|
||||||
use trx_core::decode::DecodedMessage;
|
use trx_core::decode::DecodedMessage;
|
||||||
use trx_core::rig::request::RigRequest;
|
use trx_core::rig::request::RigRequest;
|
||||||
use trx_core::rig::state::RigState;
|
use trx_core::rig::state::RigState;
|
||||||
use trx_core::DynResult;
|
use trx_core::DynResult;
|
||||||
use trx_frontend::{
|
use trx_frontend::{FrontendRegistrationContext, FrontendRuntimeContext};
|
||||||
snapshot_bootstrap_context, FrontendRegistrationContext, FrontendRuntimeContext,
|
|
||||||
};
|
|
||||||
use trx_frontend_http::register_frontend_on as register_http_frontend;
|
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_http_json::register_frontend_on as register_http_json_frontend;
|
||||||
use trx_frontend_rigctl::register_frontend_on as register_rigctl_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());
|
init_logging(cfg.general.log_level.as_deref());
|
||||||
|
|
||||||
let _plugin_libs = load_plugins();
|
let frontend_ctx_ptr = NonNull::from(&mut frontend_reg_ctx).cast();
|
||||||
frontend_reg_ctx.extend_from(&snapshot_bootstrap_context());
|
let _plugin_libs = load_frontend_plugins(frontend_ctx_ptr);
|
||||||
|
|
||||||
if let Some(ref path) = config_path {
|
if let Some(ref path) = config_path {
|
||||||
info!("Loaded configuration from {}", path.display());
|
info!("Loaded configuration from {}", path.display());
|
||||||
|
|||||||
@@ -309,7 +309,18 @@ fn parse_port(port_str: &str) -> Result<u16, String> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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]
|
#[test]
|
||||||
fn parse_host_default_port() {
|
fn parse_host_default_port() {
|
||||||
@@ -352,4 +363,131 @@ mod tests {
|
|||||||
let err = parse_remote_url("::1:7000").expect_err("must fail");
|
let err = parse_remote_url("::1:7000").expect_err("must fail");
|
||||||
assert!(err.contains("must be bracketed"));
|
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,10 +2,10 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: BSD-2-Clause
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
use std::collections::{HashMap, VecDeque, HashSet};
|
use std::collections::{HashMap, HashSet, VecDeque};
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::sync::atomic::AtomicBool;
|
use std::sync::atomic::AtomicBool;
|
||||||
use std::sync::{Arc, Mutex, OnceLock};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
@@ -151,52 +151,3 @@ fn normalize_name(name: &str) -> String {
|
|||||||
.filter(|c| c.is_ascii_alphanumeric())
|
.filter(|c| c.is_ascii_alphanumeric())
|
||||||
.collect()
|
.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;
|
use trx_frontend::FrontendSpawner;
|
||||||
context.register_frontend("http-json", server::HttpJsonFrontend::spawn_frontend);
|
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());
|
let validator = SimpleTokenValidator::new(context.auth_tokens.clone());
|
||||||
validator.validate(token)
|
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::time::{self, Duration};
|
||||||
use tokio_stream::wrappers::{IntervalStream, WatchStream};
|
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::radio::freq::Freq;
|
||||||
use trx_core::rig::{RigAccessMethod, RigCapabilities, RigInfo};
|
use trx_core::rig::{RigAccessMethod, RigCapabilities, RigInfo};
|
||||||
use trx_core::{RigCommand, RigRequest, RigSnapshot, RigState};
|
use trx_core::{RigCommand, RigRequest, RigSnapshot, RigState};
|
||||||
|
use trx_frontend::FrontendRuntimeContext;
|
||||||
|
use trx_protocol::{parse_mode, ClientResponse};
|
||||||
|
|
||||||
use crate::server::status;
|
use crate::server::status;
|
||||||
|
|
||||||
@@ -450,28 +450,40 @@ async fn style_css() -> impl Responder {
|
|||||||
#[get("/app.js")]
|
#[get("/app.js")]
|
||||||
async fn app_js() -> impl Responder {
|
async fn app_js() -> impl Responder {
|
||||||
HttpResponse::Ok()
|
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)
|
.body(status::APP_JS)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/aprs.js")]
|
#[get("/aprs.js")]
|
||||||
async fn aprs_js() -> impl Responder {
|
async fn aprs_js() -> impl Responder {
|
||||||
HttpResponse::Ok()
|
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)
|
.body(status::APRS_JS)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/ft8.js")]
|
#[get("/ft8.js")]
|
||||||
async fn ft8_js() -> impl Responder {
|
async fn ft8_js() -> impl Responder {
|
||||||
HttpResponse::Ok()
|
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)
|
.body(status::FT8_JS)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/cw.js")]
|
#[get("/cw.js")]
|
||||||
async fn cw_js() -> impl Responder {
|
async fn cw_js() -> impl Responder {
|
||||||
HttpResponse::Ok()
|
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)
|
.body(status::CW_JS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,11 @@
|
|||||||
//! - Browser sends binary messages: raw Opus packets (TX)
|
//! - Browser sends binary messages: raw Opus packets (TX)
|
||||||
|
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::sync::Arc;
|
|
||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
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 actix_ws::Message;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
@@ -62,7 +62,10 @@ fn record_aprs(context: &FrontendRuntimeContext, pkt: AprsPacket) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn record_cw(context: &FrontendRuntimeContext, event: CwEvent) {
|
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));
|
history.push_back((Instant::now(), event));
|
||||||
prune_cw_history(&mut history);
|
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> {
|
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);
|
prune_cw_history(&mut history);
|
||||||
history.iter().map(|(_, evt)| evt.clone()).collect()
|
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) {
|
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();
|
history.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +137,10 @@ pub fn subscribe_decode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn start_decode_history_collector(context: Arc<FrontendRuntimeContext>) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,3 @@ pub fn register_frontend_on(context: &mut trx_frontend::FrontendRegistrationCont
|
|||||||
use trx_frontend::FrontendSpawner;
|
use trx_frontend::FrontendSpawner;
|
||||||
context.register_frontend("http", server::HttpFrontend::spawn_frontend);
|
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::RigRequest;
|
||||||
use trx_core::RigState;
|
use trx_core::RigState;
|
||||||
use trx_frontend::{FrontendSpawner, FrontendRuntimeContext};
|
use trx_frontend::{FrontendRuntimeContext, FrontendSpawner};
|
||||||
|
|
||||||
/// HTTP frontend implementation.
|
/// HTTP frontend implementation.
|
||||||
pub struct HttpFrontend;
|
pub struct HttpFrontend;
|
||||||
|
|||||||
@@ -8,8 +8,3 @@ pub fn register_frontend_on(context: &mut trx_frontend::FrontendRegistrationCont
|
|||||||
use trx_frontend::FrontendSpawner;
|
use trx_frontend::FrontendSpawner;
|
||||||
context.register_frontend("rigctl", server::RigctlFrontend::spawn_frontend);
|
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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ pub enum RigMode {
|
|||||||
impl Default for RigStatus {
|
impl Default for RigStatus {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
freq: Freq { hz: 144_300_000 }, // 2m calling frequency
|
freq: Freq { hz: 144_300_000 }, // 2m calling frequency
|
||||||
mode: RigMode::USB,
|
mode: RigMode::USB,
|
||||||
tx_en: false,
|
tx_en: false,
|
||||||
vfo: None,
|
vfo: None,
|
||||||
@@ -136,7 +136,9 @@ impl RigState {
|
|||||||
state.server_version = version;
|
state.server_version = version;
|
||||||
state.server_latitude = latitude;
|
state.server_latitude = latitude;
|
||||||
state.server_longitude = longitude;
|
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.status.mode = initial_mode;
|
||||||
state
|
state
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-8
@@ -43,7 +43,11 @@ extern "C" {
|
|||||||
fn ft8_decoder_reset(dec: *mut c_void);
|
fn ft8_decoder_reset(dec: *mut c_void);
|
||||||
fn ft8_decoder_process(dec: *mut c_void, frame: *const c_float);
|
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_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 {
|
pub struct Ft8Decoder {
|
||||||
@@ -108,13 +112,17 @@ impl Ft8Decoder {
|
|||||||
if ft8_decoder_is_ready(self.inner.as_ptr()) == 0 {
|
if ft8_decoder_is_ready(self.inner.as_ptr()) == 0 {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
let mut raw = vec![Ft8DecodeResultRaw {
|
let mut raw = vec![
|
||||||
text: [0; FTX_MAX_MESSAGE_LENGTH],
|
Ft8DecodeResultRaw {
|
||||||
snr_db: 0.0,
|
text: [0; FTX_MAX_MESSAGE_LENGTH],
|
||||||
dt_s: 0.0,
|
snr_db: 0.0,
|
||||||
freq_hz: 0.0,
|
dt_s: 0.0,
|
||||||
}; max_results];
|
freq_hz: 0.0,
|
||||||
let count = ft8_decoder_decode(self.inner.as_ptr(), raw.as_mut_ptr(), max_results as c_int);
|
};
|
||||||
|
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 count = count.max(0) as usize;
|
||||||
let mut out = Vec::with_capacity(count);
|
let mut out = Vec::with_capacity(count);
|
||||||
for item in raw.into_iter().take(count) {
|
for item in raw.into_iter().take(count) {
|
||||||
|
|||||||
@@ -187,9 +187,7 @@ mod tests {
|
|||||||
|
|
||||||
assert!(validator.validate(&Some("token1".to_string())).is_ok());
|
assert!(validator.validate(&Some("token1".to_string())).is_ok());
|
||||||
assert!(validator.validate(&Some("token2".to_string())).is_ok());
|
assert!(validator.validate(&Some("token2".to_string())).is_ok());
|
||||||
assert!(validator
|
assert!(validator.validate(&Some("token3".to_string())).is_err());
|
||||||
.validate(&Some("token3".to_string()))
|
|
||||||
.is_err());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
|
|
||||||
use serde_json;
|
use serde_json;
|
||||||
|
|
||||||
use trx_core::rig::state::RigMode;
|
|
||||||
use crate::types::{ClientCommand, ClientEnvelope};
|
use crate::types::{ClientCommand, ClientEnvelope};
|
||||||
|
use trx_core::rig::state::RigMode;
|
||||||
|
|
||||||
/// Parse a mode string into a RigMode.
|
/// Parse a mode string into a RigMode.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ pub fn client_command_to_rig(cmd: ClientCommand) -> RigCommand {
|
|||||||
ClientCommand::Unlock => RigCommand::Unlock,
|
ClientCommand::Unlock => RigCommand::Unlock,
|
||||||
ClientCommand::GetTxLimit => RigCommand::GetTxLimit,
|
ClientCommand::GetTxLimit => RigCommand::GetTxLimit,
|
||||||
ClientCommand::SetTxLimit { limit } => RigCommand::SetTxLimit(limit),
|
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::SetCwDecodeEnabled { enabled } => RigCommand::SetCwDecodeEnabled(enabled),
|
||||||
ClientCommand::SetCwAuto { enabled } => RigCommand::SetCwAuto(enabled),
|
ClientCommand::SetCwAuto { enabled } => RigCommand::SetCwAuto(enabled),
|
||||||
ClientCommand::SetCwWpm { wpm } => RigCommand::SetCwWpm(wpm),
|
ClientCommand::SetCwWpm { wpm } => RigCommand::SetCwWpm(wpm),
|
||||||
@@ -58,7 +60,9 @@ pub fn rig_command_to_client(cmd: RigCommand) -> ClientCommand {
|
|||||||
RigCommand::Unlock => ClientCommand::Unlock,
|
RigCommand::Unlock => ClientCommand::Unlock,
|
||||||
RigCommand::GetTxLimit => ClientCommand::GetTxLimit,
|
RigCommand::GetTxLimit => ClientCommand::GetTxLimit,
|
||||||
RigCommand::SetTxLimit(limit) => ClientCommand::SetTxLimit { limit },
|
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::SetCwDecodeEnabled(enabled) => ClientCommand::SetCwDecodeEnabled { enabled },
|
||||||
RigCommand::SetCwAuto(enabled) => ClientCommand::SetCwAuto { enabled },
|
RigCommand::SetCwAuto(enabled) => ClientCommand::SetCwAuto { enabled },
|
||||||
RigCommand::SetCwWpm(wpm) => ClientCommand::SetCwWpm { wpm },
|
RigCommand::SetCwWpm(wpm) => ClientCommand::SetCwWpm { wpm },
|
||||||
|
|||||||
@@ -200,8 +200,7 @@ impl Demodulator {
|
|||||||
self.corr_idx = (idx + 1) % self.corr_len;
|
self.corr_idx = (idx + 1) % self.corr_len;
|
||||||
|
|
||||||
// Compare mark vs space energy
|
// Compare mark vs space energy
|
||||||
let mark_energy =
|
let mark_energy = self.mark_i_sum * self.mark_i_sum + self.mark_q_sum * self.mark_q_sum;
|
||||||
self.mark_i_sum * self.mark_i_sum + self.mark_q_sum * self.mark_q_sum;
|
|
||||||
let space_energy =
|
let space_energy =
|
||||||
self.space_i_sum * self.space_i_sum + self.space_q_sum * self.space_q_sum;
|
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 };
|
let bit: u8 = if mark_energy > space_energy { 1 } else { 0 };
|
||||||
|
|||||||
@@ -153,8 +153,7 @@ impl CwDecoder {
|
|||||||
let mut tone_scan_bins = Vec::new();
|
let mut tone_scan_bins = Vec::new();
|
||||||
let mut f = TONE_SCAN_LOW;
|
let mut f = TONE_SCAN_LOW;
|
||||||
while f <= TONE_SCAN_HIGH {
|
while f <= TONE_SCAN_HIGH {
|
||||||
let bk =
|
let bk = (f as f32 * window_size as f32 / sample_rate as f32).round();
|
||||||
(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;
|
let b_omega = (2.0 * std::f32::consts::PI * bk) / window_size as f32;
|
||||||
tone_scan_bins.push(ToneScanBin {
|
tone_scan_bins.push(ToneScanBin {
|
||||||
freq: f,
|
freq: f,
|
||||||
@@ -202,8 +201,7 @@ impl CwDecoder {
|
|||||||
|
|
||||||
fn recompute_goertzel(&mut self, new_freq: u32) {
|
fn recompute_goertzel(&mut self, new_freq: u32) {
|
||||||
self.tone_freq = new_freq;
|
self.tone_freq = new_freq;
|
||||||
let k = (new_freq as f32 * self.window_size as f32 / self.sample_rate as f32)
|
let k = (new_freq as f32 * self.window_size as f32 / self.sample_rate as f32).round();
|
||||||
.round();
|
|
||||||
let omega = (2.0 * std::f32::consts::PI * k) / self.window_size as f32;
|
let omega = (2.0 * std::f32::consts::PI * k) / self.window_size as f32;
|
||||||
self.coeff = 2.0 * omega.cos();
|
self.coeff = 2.0 * omega.cos();
|
||||||
}
|
}
|
||||||
@@ -256,9 +254,7 @@ impl CwDecoder {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.tone_stable_bin >= 0
|
if self.tone_stable_bin >= 0 && (best_idx - self.tone_stable_bin).unsigned_abs() <= 1 {
|
||||||
&& (best_idx - self.tone_stable_bin).unsigned_abs() <= 1
|
|
||||||
{
|
|
||||||
self.tone_stable_count += 1;
|
self.tone_stable_count += 1;
|
||||||
} else {
|
} else {
|
||||||
self.tone_stable_bin = best_idx;
|
self.tone_stable_bin = best_idx;
|
||||||
@@ -267,9 +263,7 @@ impl CwDecoder {
|
|||||||
|
|
||||||
if self.tone_stable_count >= TONE_STABLE_NEEDED {
|
if self.tone_stable_count >= TONE_STABLE_NEEDED {
|
||||||
let detected_freq = self.tone_scan_bins[self.tone_stable_bin as usize].freq;
|
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()
|
if (detected_freq as i32 - self.tone_freq as i32).unsigned_abs() > TONE_SCAN_STEP {
|
||||||
> TONE_SCAN_STEP
|
|
||||||
{
|
|
||||||
self.recompute_goertzel(detected_freq);
|
self.recompute_goertzel(detected_freq);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -337,8 +331,7 @@ impl CwDecoder {
|
|||||||
if off_duration > u * 5.0 {
|
if off_duration > u * 5.0 {
|
||||||
// Word gap
|
// Word gap
|
||||||
if !self.current_symbol.is_empty() {
|
if !self.current_symbol.is_empty() {
|
||||||
let ch = morse_lookup(&self.current_symbol)
|
let ch = morse_lookup(&self.current_symbol).unwrap_or('?');
|
||||||
.unwrap_or('?');
|
|
||||||
self.emit_text(&ch.to_string());
|
self.emit_text(&ch.to_string());
|
||||||
self.current_symbol.clear();
|
self.current_symbol.clear();
|
||||||
}
|
}
|
||||||
@@ -346,8 +339,7 @@ impl CwDecoder {
|
|||||||
} else if off_duration > u * 2.0 {
|
} else if off_duration > u * 2.0 {
|
||||||
// Character gap
|
// Character gap
|
||||||
if !self.current_symbol.is_empty() {
|
if !self.current_symbol.is_empty() {
|
||||||
let ch = morse_lookup(&self.current_symbol)
|
let ch = morse_lookup(&self.current_symbol).unwrap_or('?');
|
||||||
.unwrap_or('?');
|
|
||||||
self.emit_text(&ch.to_string());
|
self.emit_text(&ch.to_string());
|
||||||
self.current_symbol.clear();
|
self.current_symbol.clear();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -316,3 +316,129 @@ async fn handle_client(
|
|||||||
|
|
||||||
Ok(())
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ mod rig_task;
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::net::{IpAddr, SocketAddr};
|
use std::net::{IpAddr, SocketAddr};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::ptr::NonNull;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
@@ -23,10 +24,8 @@ use tracing::{error, info};
|
|||||||
|
|
||||||
use trx_core::audio::AudioStreamInfo;
|
use trx_core::audio::AudioStreamInfo;
|
||||||
|
|
||||||
use trx_app::{init_logging, load_plugins, normalize_name};
|
use trx_app::{init_logging, load_backend_plugins, normalize_name};
|
||||||
use trx_backend::{
|
use trx_backend::{register_builtin_backends_on, RegistrationContext, RigAccess};
|
||||||
register_builtin_backends_on, snapshot_bootstrap_context, RegistrationContext, RigAccess,
|
|
||||||
};
|
|
||||||
use trx_core::rig::controller::{AdaptivePolling, ExponentialBackoff};
|
use trx_core::rig::controller::{AdaptivePolling, ExponentialBackoff};
|
||||||
use trx_core::rig::request::RigRequest;
|
use trx_core::rig::request::RigRequest;
|
||||||
use trx_core::rig::state::RigState;
|
use trx_core::rig::state::RigState;
|
||||||
@@ -247,8 +246,8 @@ async fn main() -> DynResult<()> {
|
|||||||
|
|
||||||
init_logging(cfg.general.log_level.as_deref());
|
init_logging(cfg.general.log_level.as_deref());
|
||||||
|
|
||||||
let _plugin_libs = load_plugins();
|
let bootstrap_ctx_ptr = NonNull::from(&mut bootstrap_ctx).cast();
|
||||||
bootstrap_ctx.extend_from(&snapshot_bootstrap_context());
|
let _plugin_libs = load_backend_plugins(bootstrap_ctx_ptr);
|
||||||
|
|
||||||
if let Some(ref path) = config_path {
|
if let Some(ref path) = config_path {
|
||||||
info!("Loaded configuration from {}", path.display());
|
info!("Loaded configuration from {}", path.display());
|
||||||
|
|||||||
@@ -38,15 +38,51 @@ impl DummyRig {
|
|||||||
revision: "1.0".to_string(),
|
revision: "1.0".to_string(),
|
||||||
capabilities: RigCapabilities {
|
capabilities: RigCapabilities {
|
||||||
supported_bands: vec![
|
supported_bands: vec![
|
||||||
Band { low_hz: 1_800_000, high_hz: 2_000_000, tx_allowed: true },
|
Band {
|
||||||
Band { low_hz: 3_500_000, high_hz: 4_000_000, tx_allowed: true },
|
low_hz: 1_800_000,
|
||||||
Band { low_hz: 7_000_000, high_hz: 7_300_000, tx_allowed: true },
|
high_hz: 2_000_000,
|
||||||
Band { low_hz: 14_000_000, high_hz: 14_350_000, tx_allowed: true },
|
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 {
|
||||||
Band { low_hz: 50_000_000, high_hz: 54_000_000, tx_allowed: true },
|
low_hz: 3_500_000,
|
||||||
Band { low_hz: 144_000_000, high_hz: 148_000_000, tx_allowed: true },
|
high_hz: 4_000_000,
|
||||||
Band { low_hz: 430_000_000, high_hz: 440_000_000, tx_allowed: true },
|
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![
|
supported_modes: vec![
|
||||||
RigMode::LSB,
|
RigMode::LSB,
|
||||||
@@ -112,9 +148,7 @@ impl Rig for DummyRig {
|
|||||||
|
|
||||||
impl RigCat for DummyRig {
|
impl RigCat for DummyRig {
|
||||||
fn get_status<'a>(&'a mut self) -> RigStatusFuture<'a> {
|
fn get_status<'a>(&'a mut self) -> RigStatusFuture<'a> {
|
||||||
Box::pin(async move {
|
Box::pin(async move { Ok((self.freq, self.mode.clone(), Some(self.build_vfo()))) })
|
||||||
Ok((self.freq, self.mode.clone(), Some(self.build_vfo())))
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_freq<'a>(
|
fn set_freq<'a>(
|
||||||
|
|||||||
@@ -3,17 +3,16 @@
|
|||||||
// SPDX-License-Identifier: BSD-2-Clause
|
// SPDX-License-Identifier: BSD-2-Clause
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, Mutex, OnceLock};
|
|
||||||
|
|
||||||
use trx_core::rig::RigCat;
|
use trx_core::rig::RigCat;
|
||||||
use trx_core::DynResult;
|
use trx_core::DynResult;
|
||||||
|
|
||||||
mod dummy;
|
mod dummy;
|
||||||
|
|
||||||
#[cfg(feature = "ft817")]
|
|
||||||
use trx_backend_ft817::Ft817;
|
|
||||||
#[cfg(feature = "ft450d")]
|
#[cfg(feature = "ft450d")]
|
||||||
use trx_backend_ft450d::Ft450d;
|
use trx_backend_ft450d::Ft450d;
|
||||||
|
#[cfg(feature = "ft817")]
|
||||||
|
use trx_backend_ft817::Ft817;
|
||||||
|
|
||||||
/// Connection details for instantiating a rig backend.
|
/// Connection details for instantiating a rig backend.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -88,27 +87,6 @@ fn normalize_name(name: &str) -> String {
|
|||||||
.collect()
|
.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.
|
/// Register all built-in backends enabled by features on a context.
|
||||||
pub fn register_builtin_backends_on(context: &mut RegistrationContext) {
|
pub fn register_builtin_backends_on(context: &mut RegistrationContext) {
|
||||||
context.register_backend("dummy", dummy_factory);
|
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);
|
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>> {
|
fn dummy_factory(_access: RigAccess) -> DynResult<Box<dyn RigCat>> {
|
||||||
Ok(Box::new(dummy::DummyRig::new()))
|
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")]
|
#[cfg(feature = "ft817")]
|
||||||
fn ft817_factory(access: RigAccess) -> DynResult<Box<dyn RigCat>> {
|
fn ft817_factory(access: RigAccess) -> DynResult<Box<dyn RigCat>> {
|
||||||
match access {
|
match access {
|
||||||
|
|||||||
@@ -325,7 +325,9 @@ impl Ft450d {
|
|||||||
|
|
||||||
async fn read_freq(&mut self) -> DynResult<u64> {
|
async fn read_freq(&mut self) -> DynResult<u64> {
|
||||||
let resp = self.query("FA;").await?;
|
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 digits: String = data.chars().filter(|c| c.is_ascii_digit()).collect();
|
||||||
let freq: u64 = digits.parse().map_err(|_| "CAT freq parse failed")?;
|
let freq: u64 = digits.parse().map_err(|_| "CAT freq parse failed")?;
|
||||||
Ok(freq)
|
Ok(freq)
|
||||||
@@ -333,7 +335,9 @@ impl Ft450d {
|
|||||||
|
|
||||||
async fn read_mode(&mut self) -> DynResult<RigMode> {
|
async fn read_mode(&mut self) -> DynResult<RigMode> {
|
||||||
let resp = self.query("MD0;").await?;
|
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")?;
|
let code = data.chars().last().ok_or("CAT mode parse failed")?;
|
||||||
Ok(decode_mode(code))
|
Ok(decode_mode(code))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user