[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 -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))
}