P1 (High Priority): - Fix LIFO command batching in rig_task.rs (batch.pop→batch.remove(0)) - Add ±25% jitter to ExponentialBackoff to prevent thundering herd - Add 10,000-entry capacity bounds to decoder history queues - Add rig task crash detection with Error state broadcast - Decompose FrontendRuntimeContext 50-field god-struct into 9 sub-structs (AudioContext, DecodeHistoryContext, HttpAuthConfig, HttpUiConfig, RigRoutingContext, OwnerInfo, VChanContext, SpectrumContext, PerRigAudioContext) - Migrate std::sync::RwLock to tokio::sync::RwLock in background_decode.rs - Extract find_input_device/find_output_device helpers from audio pipeline P2 (Medium Priority): - Introduce SoapySdrConfig builder struct (replaces 20+ positional params) - Add define_command_mappings! macro for ClientCommand↔RigCommand mapping - Replace silent lock poison recovery with lock_or_recover() warning logger - Make timeouts configurable via RigTaskConfig/ListenerConfig and TOML - Extract shared config types to trx-app/src/shared_config.rs Documentation updated in CLAUDE.md, Architecture.md, Improvement-Areas.md. https://claude.ai/code/session_01P9G7QCWfiYbPVJ7cgiXznf Signed-off-by: Claude <noreply@anthropic.com>
36 KiB
trx-rs Architecture
Table of Contents
- Project Purpose
- Technology Stack
- High-Level Architecture
- Crate Layout
- Core Library (trx-core)
- Protocol Layer (trx-protocol)
- Server (trx-server)
- Backend Abstraction (trx-backend)
- Client (trx-client)
- Frontend System (trx-frontend)
- Signal Decoders
- DSP & Spectrum Pipeline
- Plugin System
- Configuration
- Concurrency Model
- Authentication & Security
- Data Flow Diagrams
Project Purpose
trx-rs is a modular amateur radio transceiver control daemon written in Rust. It separates radio hardware access (server) from user-facing control interfaces (client), enabling:
- Remote control of transceivers over TCP networks
- Multi-rig operation with per-rig isolation and routing
- SDR integration with real-time DSP (demodulation, spectrum, decode)
- Pluggable backends for different radio hardware
- Multiple frontends — web UI, Hamlib-compatible rigctl, JSON-over-TCP
- Signal decoding — APRS, CW, FT8, WSPR, RDS — with live streaming and logging
- Uplinks — PSKReporter, APRS-IS IGate
Target users are amateur radio operators who want networked, automated, or multi-radio control from a single host or across a LAN.
Technology Stack
| Layer | Technology |
|---|---|
| Language | Rust (2021 edition) |
| Async runtime | Tokio |
| Web framework | Actix-web (HTTP frontend) |
| Serialization | Serde / JSON |
| Config format | TOML |
| Audio codec | Opus |
| SDR interface | soapysdr crate (wraps SoapySDR C library) |
| CAT serial | tokio-serial |
| CLI | clap |
| Logging | tracing / tracing-subscriber |
| FTx decode | trx-ftx (pure Rust) |
High-Level Architecture
┌──────────────────────────────────────────────────────────┐
│ trx-server │
│ │
│ Radio Hardware (serial/TCP) │
│ ↕ CAT protocol │
│ Rig Backend ──────── rig_task.rs ─── listener.rs │
│ (ft817/ft450d/sdr) (state machine) (JSON TCP :4530) │
│ │ │
│ audio.rs │
│ (Opus :4531) │
│ │ │
│ Decoders │
│ (APRS, CW, FT8, WSPR, RDS) │
│ PSKReporter / APRS-IS │
└──────────────────────────────────────────────────────────┘
↕ JSON TCP (port 4530)
↕ Opus audio TCP (port 4531)
┌──────────────────────────────────────────────────────────┐
│ trx-client │
│ │
│ remote_client.rs (polls state, routes commands) │
│ ↕ internal mpsc/watch channels │
│ Frontends: │
│ trx-frontend-http (Web UI :8080) │
│ trx-frontend-rigctl (rigctl :4532) │
│ trx-frontend-http-json (JSON/TCP ephemeral) │
└──────────────────────────────────────────────────────────┘
↕ Browser / Hamlib / Custom tools
End Users
The server and client are separate binaries. They communicate over JSON-over-TCP (control) and Opus-encoded TCP (audio). Both binaries can load shared-library plugins at startup.
Crate Layout
trx-rs/ # Workspace root
├── Cargo.toml # Workspace manifest (shared dependencies)
│
└── src/
├── trx-core/ # Core types, traits, state machine
├── trx-protocol/ # Client↔server message types, auth, codec
├── trx-app/ # Shared app helpers (config loading, plugins, logging)
│
├── trx-server/ # Server binary
│ ├── src/
│ │ ├── main.rs
│ │ ├── config.rs
│ │ ├── rig_task.rs # Per-rig polling loop
│ │ ├── listener.rs # JSON TCP server (:4530)
│ │ ├── audio.rs # Opus audio server (:4531)
│ │ ├── pskreporter.rs # PSKReporter uplink
│ │ └── aprsfi.rs # APRS-IS IGate uplink
│ │
│ └── trx-backend/ # Backend abstraction + factory
│ ├── src/lib.rs # RegistrationContext, RigAccess enum
│ ├── trx-backend-ft817/ # Yaesu FT-817 CAT
│ ├── trx-backend-ft450d/ # Yaesu FT-450D CAT
│ └── trx-backend-soapysdr/ # SoapySDR SDR (RX-only)
│ ├── src/
│ │ ├── lib.rs # SoapySdrRig impl
│ │ ├── real_iq_source.rs
│ │ ├── dsp/ # DSP pipeline, FIR, oscillator, AGC
│ │ ├── demod/ # AM, FM, WFM, SSB, CW demodulators
│ │ └── spectrum.rs # FFT spectrum generation
│
├── trx-client/ # Client binary
│ ├── src/
│ │ ├── main.rs
│ │ ├── config.rs
│ │ ├── remote_client.rs # TCP connection to server
│ │ └── audio_client.rs # Audio stream handler
│ │
│ └── trx-frontend/ # Frontend abstraction + registration
│ ├── src/lib.rs # FrontendSpawner trait, FrontendRuntimeContext
│ ├── trx-frontend-http/ # Actix-web: REST + SSE + WebSocket
│ ├── trx-frontend-http-json/ # JSON-over-TCP thin control frontend
│ └── trx-frontend-rigctl/ # Hamlib-compatible rigctl TCP (:4532)
│
└── decoders/
├── trx-aprs/ # APRS packet decoder
├── trx-cw/ # CW / Morse decoder
├── trx-ftx/ # Pure Rust FTx decoder (FT8/FT4/FT2)
├── trx-wspr/ # WSPR beacon decoder
├── trx-rds/ # FM RDS decoder
└── trx-decode-log/ # JSON Lines log rotation for decoded frames
Core Library (trx-core)
Path: src/trx-core/src/
The foundation of the system. All other crates depend on trx-core for shared types and traits.
Key Re-exports (lib.rs)
pub use rig::command::RigCommand;
pub use rig::request::RigRequest;
pub use rig::response::{RigError, RigResult};
pub use rig::state::{RigMode, RigSnapshot, RigState, RigFilterState, SpectrumData};
pub use rig::AudioSource;
pub use decode::DecodedMessage;
pub use audio::AudioStreamInfo;
Rig State (rig/state.rs)
The RigState struct is the canonical snapshot of a rig at any point in time:
pub struct RigState {
pub rig_info: Option<RigInfo>,
pub status: RigStatus,
pub initialized: bool,
pub control: RigControl,
pub server_callsign: Option<String>,
pub spectrum: Option<SpectrumData>, // FFT frame from SDR
pub filter: Option<RigFilterState>, // Runtime DSP parameters
// ... decoder enable flags, CW params, etc.
}
pub struct RigStatus {
pub freq: Freq,
pub mode: RigMode,
pub tx_en: bool,
pub vfo: Option<RigVfo>,
pub tx: Option<RigTxStatus>, // power, SWR, ALC
pub rx: Option<RigRxStatus>, // signal strength
pub lock: Option<bool>,
}
pub enum RigMode {
LSB, USB, CW, CWR, AM, WFM, FM, DIG, PKT, Other(String)
}
Rig Commands (rig/command.rs)
All control actions are represented as enum variants:
pub enum RigCommand {
// Basic control
GetSnapshot, SetFreq(Freq), SetMode(RigMode), SetPtt(bool),
PowerOn, PowerOff, ToggleVfo, Lock, Unlock,
// TX
GetTxLimit, SetTxLimit(u8),
// Decoders
SetAprsDecodeEnabled(bool), SetCwDecodeEnabled(bool),
SetFt8DecodeEnabled(bool), SetWsprDecodeEnabled(bool),
ResetAprsDecoder, ResetCwDecoder, ResetFt8Decoder, ResetWsprDecoder,
// CW keyer
SetCwAuto(bool), SetCwWpm(u32), SetCwToneHz(u32),
// SDR DSP
SetBandwidth(u32), SetFirTaps(u32), SetSdrGain(f64),
SetCenterFreq(Freq), GetSpectrum,
// WFM
SetWfmDeemphasis(u32), SetWfmStereo(bool), SetWfmDenoise(bool),
}
State Machine (rig/controller/machine.rs)
Manages the lifecycle of a rig connection:
Disconnected → Connecting → Initializing → PoweredOff
↘
Ready ⇄ Transmitting
↓
Error
↓ (recoverable)
Connecting
pub enum RigMachineState {
Disconnected,
Connecting,
Initializing,
PoweredOff,
Ready,
Transmitting,
Error(RigStateError),
}
Transitions are triggered by RigEvent (Connected, PoweredOn, PttOn, Error, etc.) and processed by process_event(&mut self, event: RigEvent).
Command Handlers (rig/controller/handlers.rs)
Each command implements RigCommandHandler:
pub trait RigCommandHandler: Debug + Send + Sync {
fn name(&self) -> &'static str;
fn can_execute(&self, ctx: &dyn CommandContext) -> ValidationResult;
fn execute<'a>(
&'a self, executor: &'a mut dyn CommandExecutor
) -> Pin<Box<dyn Future<Output = DynResult<CommandResult>> + Send + 'a>>;
}
pub enum ValidationResult {
Ok,
InvalidState(String), // Wrong machine state
InvalidParams(String), // Bad parameters
Locked, // Rig is locked
}
Event System (rig/controller/events.rs)
Observers subscribe via the RigListener trait. RigEventEmitter maintains a list of Arc<dyn RigListener> and calls them on state changes.
pub trait RigListener: Send + Sync {
fn on_frequency_change(&self, old: Option<Freq>, new: Freq) {}
fn on_mode_change(&self, old: Option<&RigMode>, new: &RigMode) {}
fn on_ptt_change(&self, transmitting: bool) {}
fn on_state_change(&self, old: &RigMachineState, new: &RigMachineState) {}
fn on_meter_update(&self, rx: Option<&RigRxStatus>, tx: Option<&RigTxStatus>) {}
fn on_lock_change(&self, locked: bool) {}
fn on_power_change(&self, powered: bool) {}
}
Operational Policies (rig/controller/policies.rs)
Govern reconnection and polling behaviour:
pub trait RetryPolicy: Send {
fn next_delay(&mut self) -> Duration;
}
pub struct ExponentialBackoff {
max_attempts: u32,
base_delay: Duration,
max_delay: Duration,
// Delays include ±25% randomized jitter to prevent thundering herd
}
pub trait PollingPolicy: Send {
fn next_interval(&mut self) -> Duration;
}
pub struct AdaptivePolling {
idle_interval: Duration,
tx_interval: Duration, // faster polling during TX
}
Audio Wire Format (audio.rs)
[ 1 byte type ][ 4 bytes BE length ][ N bytes payload ]
Types:
0x00 AudioStreamInfo (sample rate, channels, frame duration)
0x01 RX audio frame (Opus-encoded PCM)
0x02 TX audio frame (Opus-encoded PCM)
0x03 APRS decode
0x04 CW decode
0x05 FT8 decode
0x06 WSPR decode
Error Types (rig/response.rs)
pub struct RigError {
pub message: String,
pub kind: RigErrorKind,
}
pub enum RigErrorKind {
Transient, // Retry-able (timeout, busy)
Permanent, // Don't retry (unsupported operation)
}
pub type RigResult<T> = Result<T, RigError>;
pub type DynResult<T> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
Protocol Layer (trx-protocol)
Path: src/trx-protocol/src/
Bridges the internal RigCommand/RigState world to JSON messages exchanged over TCP.
Message Types (types.rs)
// Client → Server
pub struct ClientEnvelope {
pub token: Option<String>, // Auth token
pub rig_id: Option<String>, // Multi-rig routing (None = default rig)
pub cmd: ClientCommand,
}
pub enum ClientCommand {
GetState, GetRigs,
SetFreq { freq_hz: u64 }, SetCenterFreq { freq_hz: u64 },
SetMode { mode: String }, SetPtt { ptt: bool },
PowerOn, PowerOff, ToggleVfo, Lock, Unlock,
GetTxLimit, SetTxLimit { limit: u8 },
SetBandwidth { bandwidth_hz: u32 }, SetFirTaps { taps: u32 },
SetSdrGain { gain_db: f64 },
SetWfmDeemphasis { deemphasis_us: u32 },
SetWfmStereo { enabled: bool }, SetWfmDenoise { enabled: bool },
SetAprsDecodeEnabled { enabled: bool }, /* ... other decoders ... */
GetSpectrum,
// ...
}
// Server → Client
pub struct ClientResponse {
pub success: bool,
pub rig_id: Option<String>,
pub state: Option<RigSnapshot>, // Updated rig state
pub rigs: Option<Vec<RigEntry>>, // Response to GetRigs
pub error: Option<String>,
}
pub struct RigEntry {
pub rig_id: String,
pub display_name: Option<String>,
pub state: RigSnapshot,
pub audio_port: Option<u16>,
}
Type Mapping (mapping.rs)
client_command_to_rig(ClientCommand) → RigCommand and the reverse conversion ensure the protocol types stay decoupled from the core domain model.
Authentication (auth.rs)
pub trait TokenValidator: Send + Sync {
fn validate(&self, token: &str) -> bool;
}
pub struct SimpleTokenValidator { tokens: HashSet<String> }
pub struct NoAuthValidator; // Always returns true (debug/local use)
Server (trx-server)
Path: src/trx-server/src/
Startup Sequence
- Parse CLI / TOML config (
config.rs) - Register backends via
RegistrationContext(built-ins + plugins) - For each configured rig:
- Build or pre-configure the rig backend
- Spawn
run_rig_task()as a Tokio task
- Spawn
run_listener()(JSON TCP on:4530) - Spawn audio streaming server (
:4531) - Wait for shutdown signal
Multi-Rig Routing
Rigs are stored in Arc<HashMap<String, RigHandle>>. Each RigHandle contains:
mpsc::Sender<RigRequest>— send commands to the rig taskwatch::Receiver<RigState>— read latest state
listener.rs routes incoming ClientEnvelope.rig_id to the correct handle. If rig_id is absent, the server's default rig is used.
Auto-generated IDs follow the pattern {model}_{index} (e.g., ft817_0, soapysdr_1) when not explicitly set in config.
Rig Task (rig_task.rs)
Each rig runs an independent async loop:
connect → initialize → poll loop
↓ on error
retry with ExponentialBackoff
↓ on persistent error
Error state → wait for recovery
The task:
- Drives the
RigStateMachinethrough state transitions - Polls rig status at
AdaptivePollingintervals (faster during TX) - Handles incoming
RigCommands frommpsc::Receiver - Broadcasts
RigStatesnapshots viawatch::Sender
JSON TCP Listener (listener.rs)
Accepts connections on port 4530. Per connection:
- Read newline-delimited JSON (
ClientEnvelope) - Validate token
- Route to rig by
rig_id - Convert
ClientCommand → RigCommandand send to rig task - Await result and return
ClientResponse
Audio Server (audio.rs)
Separate TCP listener on port 4531. Per connection:
- Send
AudioStreamInfoheader - Send buffered decoder history (APRS, CW, FT8, WSPR, RDS frames)
- Stream Opus-encoded RX audio frames as they arrive
- Interleave decoder messages (
0x03–0x06frame types)
DecoderHistories maintains ring buffers of recent decoded events so late-connecting clients get context.
Uplinks
| Module | Purpose |
|---|---|
pskreporter.rs |
Posts FT8/WSPR spots to pskreporter.net |
aprsfi.rs |
Forwards APRS packets to APRS-IS network (IGate) |
Both are optional, configured per-rig.
Backend Abstraction (trx-backend)
Path: src/trx-server/trx-backend/
Factory Pattern (src/lib.rs)
pub enum RigAccess {
Serial { path: String, baud: u32 },
Tcp { addr: String },
Sdr { args: String },
}
type BackendFactory = fn(RigAccess) -> DynResult<Box<dyn RigCat>>;
pub struct RegistrationContext {
factories: HashMap<String, BackendFactory>,
}
impl RegistrationContext {
pub fn register_backend(&mut self, name: &str, factory: BackendFactory);
pub fn build_rig(&self, name: &str, access: RigAccess) -> DynResult<Box<dyn RigCat>>;
}
Built-in registrations (via register_builtin_backends_on):
"ft817"→Ft817::new"ft450d"→Ft450d::new"soapysdr"→SoapySdrRig::new_from_config(SoapySdrConfig { ... })(ifsoapysdrfeature enabled)
RigCat Trait (from trx-core)
All backends implement RigCat:
pub trait RigCat: Rig {
async fn get_status(&mut self) -> RigResult<RigStatus>;
async fn set_freq(&mut self, freq: Freq) -> RigResult<()>;
async fn set_mode(&mut self, mode: RigMode) -> RigResult<()>;
async fn set_ptt(&mut self, on: bool) -> RigResult<()>;
async fn power_on(&mut self) -> RigResult<()>;
async fn power_off(&mut self) -> RigResult<()>;
async fn toggle_vfo(&mut self) -> RigResult<()>;
// ... more operations
}
FT-817 Backend (trx-backend-ft817/)
- CAT protocol over serial (9600 baud default)
- BCD-encoded frequency/mode commands
- VFO A/B tracking
- Meter reads: S-meter, TX power, SWR, ALC
- Bands: 160m through 70cm + GHz receive
FT-450D Backend (trx-backend-ft450d/)
- Similar structure to FT-817
- Uses FT-450D-specific CAT command set
SoapySDR Backend (trx-backend-soapysdr/)
RX-only SDR backend with real-time DSP:
pub struct SoapySdrRig {
freq: Freq,
mode: RigMode,
pipeline: dsp::SdrPipeline, // Multi-channel DSP
bandwidth_hz: u32,
fir_taps: u32,
spectrum_buf: Arc<Mutex<Option<Vec<f32>>>>,
center_offset_hz: i64,
wfm_deemphasis_us: u32,
wfm_stereo: bool,
wfm_denoise: bool,
gain_db: f64,
}
Client (trx-client)
Path: src/trx-client/src/
Startup Sequence
- Parse CLI / TOML config
- Register frontends via
FrontendRegistrationContext(built-ins + plugins) - Spawn
run_remote_client()— connects to server, driveswatch::Sender<RigState> - Spawn enabled frontends (HTTP, rigctl, http-json)
- Wait for shutdown
Remote Client (remote_client.rs)
Maintains the server TCP connection:
pub struct RemoteClientConfig {
pub addr: String,
pub token: Option<String>,
pub selected_rig_id: Arc<Mutex<Option<String>>>,
pub known_rigs: Arc<Mutex<Vec<RemoteRigEntry>>>,
pub poll_interval: Duration,
pub spectrum: Arc<Mutex<SharedSpectrum>>,
}
Workflow:
- Connect to
addr(host:4530) - Poll
GetStateat configured interval (default 750 ms) - Poll
GetSpectrumat ~40 ms (25 fps) if backend supports it - Forward commands from frontends (
mpsc::Receiver<RigRequest>) to server - Broadcast received
RigStateto all frontends viawatch::Sender
Multi-rig: selected_rig_id can be changed at runtime to switch which rig the client targets. known_rigs is populated by periodic GetRigs calls.
Audio Client (audio_client.rs)
Connects to the audio port (:4531) and relays:
- Opus-encoded audio frames → local PCM broadcast channel
- Decoder messages → frontend display
Frontend System (trx-frontend)
Path: src/trx-client/trx-frontend/
Abstraction (src/lib.rs)
pub trait FrontendSpawner {
fn spawn_frontend(
state_rx: watch::Receiver<RigState>,
rig_tx: mpsc::Sender<RigRequest>,
callsign: Option<String>,
listen_addr: SocketAddr,
context: Arc<FrontendRuntimeContext>,
) -> JoinHandle<()>;
}
pub struct FrontendRuntimeContext {
pub rigctl_clients: AtomicUsize,
pub rigctl_addr: Option<SocketAddr>,
pub http_clients: AtomicUsize,
pub known_rigs: Arc<Mutex<Vec<RemoteRigEntry>>>,
pub selected_rig_id: Arc<Mutex<Option<String>>>,
pub spectrum: Arc<Mutex<SharedSpectrum>>,
}
HTTP Frontend (trx-frontend-http/)
Built on Actix-web, serves a browser-based control panel.
REST Endpoints:
| Method | Path | Description |
|---|---|---|
| GET | /status |
Current rig state + frontend metadata |
| POST | /cmd/{command} |
Execute a rig command |
| GET | /events |
SSE stream of state changes |
| GET | /audio |
WebSocket audio stream |
| GET | /favicon.png |
Static asset |
Web UI features: frequency display/entry, mode selector, PTT indicator, S-meter/TX-power/SWR meters, decoder toggles, decode history, spectrum waterfall (SDR), rig picker (multi-rig).
Rigctl Frontend (trx-frontend-rigctl/)
Hamlib-compatible plaintext TCP interface on port 4532. Allows WSJT-X, JS8Call, and other Hamlib-aware applications to control the rig without modification.
HTTP-JSON Frontend (trx-frontend-http-json/)
JSON-over-TCP frontend on an ephemeral (or configured) port. Thin wrapper that passes ClientCommand/ClientResponse pairs — useful for scripting or automation tools.
Signal Decoders
Path: src/decoders/
All decoders run as background Tokio tasks inside trx-server. They subscribe to the PCM audio broadcast channel from the active rig and publish decoded messages.
| Crate | Decoder | Notes |
|---|---|---|
trx-aprs |
APRS (AX.25) | Forwards to APRS-IS if enabled |
trx-cw |
CW / Morse | Auto WPM detection |
trx-ftx |
FTx | Pure Rust FT8/FT4/FT2 decoder; posts to PSKReporter |
trx-wspr |
WSPR beacons | Posts to PSKReporter |
trx-rds |
FM RDS | Station name, radiotext, time |
trx-decode-log |
Logging infrastructure | JSON Lines, date-rotated files |
Control commands (e.g., SetAprsDecodeEnabled(bool), ResetCwDecoder) are routed through rig_task.rs to the active decoder tasks.
Decoded events are multiplexed onto the audio stream wire protocol (0x03–0x06 frame types) and also buffered in DecoderHistories for replay to newly connected clients.
DSP & Spectrum Pipeline
Path: src/trx-server/trx-backend/trx-backend-soapysdr/src/
Architecture
IQ Samples (from SoapySDR device)
↓
SdrPipeline (per-channel)
├── Channel 0: Mixer → FIR Filter → Demod → AGC → PCM
├── Channel 1: Mixer → FIR Filter → Demod → AGC → PCM
└── ...
↓
Audio broadcast channel (Vec<f32>)
↓
Decoders / Audio server
Demodulators (demod/)
| Module | Mode |
|---|---|
am.rs |
AM (envelope detection) |
fm.rs |
Narrowband FM |
wfm.rs |
Wideband FM (stereo + deemphasis + denoise) |
ssb.rs |
LSB and USB |
cw.rs |
CW (Morse, beat-frequency oscillator) |
WFM demodulator supports:
- Stereo pilot detection and L+R/L−R matrix decoding
- Configurable de-emphasis time constant (50 us EU / 75 us US)
- Optional noise reduction
Spectrum (spectrum.rs)
Real-time FFT of the mixer output is stored in spectrum_buf and snapshotted on demand:
pub struct SpectrumData {
pub magnitudes: Vec<f32>, // FFT magnitude bins (linear)
pub low_hz: f64,
pub high_hz: f64,
pub center_hz: f64,
}
Clients poll via RigCommand::GetSpectrum → ClientCommand::GetSpectrum. The remote client polls at ~25 fps and caches in SharedSpectrum. The HTTP frontend reads this cache to drive the waterfall display.
Plugin System
Path: src/trx-app/src/plugins.rs
Both trx-server and trx-client support dynamic plugins loaded at startup.
Search Paths (in order)
./plugins/~/.config/trx-rs/plugins/- Directories in
TRX_PLUGIN_DIRSenvironment variable (:on Unix,;on Windows)
Backend Plugins
Export symbol: trx_register_backend(context: *mut RegistrationContext)
Plugins call context.register_backend("my-rig", factory_fn) to add new rig drivers without rebuilding the server binary.
Frontend Plugins
Export symbol: trx_register_frontend(context: *mut FrontendRegistrationContext)
Plugins call context.register_frontend("my-ui", spawner_fn) to add new control interfaces.
An example plugin is provided at examples/trx-plugin-example/ (not a workspace member).
Configuration
Format: TOML. Generated with --print-config flag.
Search order:
--config <path>CLI argument./trx-server.toml/./trx-client.toml~/.config/trx-rs/trx-server.toml/etc/trx-rs/trx-server.toml
Server Config Structure
[general]
callsign = "W5XYZ"
log_level = "info"
latitude = 35.5
longitude = -97.5
[listen]
addr = "127.0.0.1"
port = 4530
audio_port = 4531
[rig] # Legacy single-rig flat config
model = "ft817"
[rig.access]
type = "serial"
path = "/dev/ttyUSB0"
baud = 9600
[behavior]
max_retries = 3
retry_delay_secs = 1
polling_interval_ms = 250
[audio]
sample_rate = 48000
frame_duration_ms = 20
dev = "" # CPAL device name (empty = default)
[sdr] # SoapySDR global params
args = "driver=rtlsdr"
sample_rate = 2000000
bandwidth_hz = 2000000
gain_mode = "manual"
gain_db = 25.0
center_offset_hz = 0
[[sdr.channels]]
if_hz = 0
mode = "USB"
audio_bandwidth_hz = 2800
fir_taps = 64
[pskreporter]
enabled = true
callsign = "W5XYZ"
gridsquare = "EM13AH"
[aprsfi]
enabled = true
callsign = "W5XYZ-11"
[decode_logs]
enabled = true
dir = "~/.trx-rs/decode-logs"
# Multi-rig (takes priority over flat [rig] section)
[[rigs]]
id = "ft817_0"
name = "HF Transceiver"
[rigs.rig]
model = "ft817"
[rigs.rig.access]
type = "serial"
path = "/dev/ttyUSB0"
baud = 9600
[[rigs]]
id = "sdr_0"
name = "VHF/UHF SDR"
[rigs.rig]
model = "soapysdr"
[rigs.rig.access]
type = "sdr"
args = "driver=rtlsdr"
Client Config Structure
[remote]
url = "localhost:4530"
rig_id = "" # Empty = server default rig
poll_interval_ms = 750
[remote.auth]
token = ""
[frontends.http]
enabled = true
listen = "127.0.0.1"
port = 8080
[frontends.rigctl]
enabled = true
listen = "127.0.0.1"
port = 4532
[frontends.http_json]
enabled = false
port = 0
Concurrency Model
The system is built on Tokio and uses channels for all cross-task communication:
| Channel | Type | Purpose |
|---|---|---|
rig_tx / rig_rx |
mpsc |
Frontend → rig task (commands) |
state_tx / state_rx |
watch |
Rig task → frontends (state updates) |
audio_tx / audio_rx |
broadcast |
Rig → decoders / audio server (PCM frames) |
shutdown_tx / shutdown_rx |
watch |
Main → all tasks (graceful shutdown signal) |
Task Tree (server)
main
├── rig_task [per rig] — polls hardware, drives state machine
├── listener — accepts JSON TCP connections
│ └── per-connection task — reads commands, sends responses
├── audio_server — accepts audio TCP connections
│ └── per-connection task — streams Opus frames
├── decoder tasks — APRS, CW, FT8, WSPR, RDS
├── pskreporter — uplink task
└── aprsfi — uplink task
Task Tree (client)
main
├── remote_client — polls server, maintains state_tx
├── audio_client — streams audio from server
├── http_frontend — Actix-web server
├── rigctl_frontend — Hamlib TCP server
└── http_json_frontend — JSON-over-TCP server
Authentication & Security
Token-Based Auth (JSON TCP)
- Clients include
tokenin everyClientEnvelope - Server validates via
TokenValidatortrait SimpleTokenValidator—HashSet<String>loaded from configNoAuthValidator— always passes (debug / local-only mode)
HTTP Frontend Auth
- Optional token or HTTP Basic Auth middleware
- Configured in
[frontends.http.auth] - Rate limiting supported
Transport Security
No built-in TLS. For remote use, tunnel over SSH or place behind a TLS-terminating reverse proxy (nginx, Caddy, etc.).
Data Flow Diagrams
Command Flow (set frequency)
Browser → POST /cmd/set_freq?hz=14225000
↓ trx-frontend-http
RigRequest::Command(RigCommand::SetFreq(14225000))
↓ mpsc channel (rig_tx)
remote_client.rs
↓ TCP
listener.rs (server)
↓ mpsc channel
rig_task.rs → backend.set_freq(14225000)
↓ CAT serial / SoapySDR API
Radio hardware
↑ ACK
rig_task.rs updates RigState → watch::Sender
↑ TCP
remote_client.rs receives ClientResponse
↑ watch::Sender
trx-frontend-http sends SSE event to browser
State Update Flow (polling)
rig_task.rs polls rig_status() every ~250 ms
→ RigState updated → watch::Sender<RigState>
remote_client.rs receives via watch::Receiver
→ broadcasts to frontends via watch::Sender<RigState>
HTTP frontend reads watch::Receiver
→ pushes SSE "state" event to connected browsers
Spectrum Update Flow
SoapySdrRig::run_spectrum_snapshot()
→ FFT of IQ buffer → SpectrumData stored in Arc<Mutex<>>
remote_client.rs polls GetSpectrum every 40 ms
→ stores SpectrumData in SharedSpectrum (Arc<Mutex<>>)
HTTP frontend reads SharedSpectrum
→ renders waterfall in browser via WebSocket or polling
Audio Flow
SoapySDR IQ → DSP pipeline → PCM (Vec<f32>)
→ broadcast::Sender<Vec<f32>>
↙ (decoders subscribe) ↘ (audio server subscribes)
APRS/CW/FT8/WSPR/RDS Opus encode
decode tasks ↓ TCP
↓ audio client (trx-client)
DecoderHistories buffer ↓
↓ broadcast locally
listener connections ↓
stream decoder messages HTTP WebSocket / local speakers
Detailed Component Notes
Rig Task Internals (rig_task.rs — 1,315 lines)
The rig task is the heart of the server. Key implementation details:
- Command batching: Accumulates pending requests before processing sequentially in FIFO order.
- Spectrum deduplication: Concurrent
GetSpectrumrequests are collapsed — one DSP computation broadcasts to all waiting responders. - Adaptive polling: Poll interval adjusts based on TX state (100ms during TX, 500ms idle).
- Grace period: 800ms pause on polling after power-on/off operations to let hardware settle.
- VFO priming: Optional initialization sequence that toggles VFO A/B to populate the state cache.
- Per-rig decoder histories: Each rig maintains independent
Arc<DecoderHistories>for all 11 decoder types. - Configurable timeouts:
command_exec_timeout(default 10s) andpoll_refresh_timeout(default 8s) are configurable viaRigTaskConfigand the TOML[timeouts]section. - Crash recovery: Rig tasks are monitored; on crash, an
Errorstate is broadcast to clients via the watch channel so they see the failure instead of silent timeout.
Audio Pipeline (audio.rs — 3,977 lines)
The audio module handles decoder history storage and stream management:
DecoderHistories: Per-rig mutable store for 11 decoder history queues (AIS, VDES, APRS, HF_APRS, CW, FT8, FT4, FT2, WSPR, WXSAT, LRPT).- Time-based retention: 24h TTL on all history with periodic pruning.
- Capacity bounds: Per-decoder max of 10,000 entries (
MAX_HISTORY_ENTRIES) prevents unbounded memory growth on busy channels. - Atomic total count:
AtomicUsizewith CAS loop avoids acquiring 11 mutex locks insnapshot_all(). - Lock poisoning recovery with logging: Uses
lock_or_recover()helper that logs a warning when recovering from a poisoned mutex. StreamErrorLogger: Suppresses duplicate stream errors with 60s periodic summaries and error classification (alsa_poll_failure, input/output_stream_error).- Device enumeration helpers:
find_input_device()andfind_output_device()extract the repeated device lookup logic fromrun_capture()/run_playback(). - CRC filtering: APRS records filtered by
crc_okbefore storage.
Remote Client Dual-Connection Model
remote_client.rs maintains two independent TCP connections to the server:
- Main connection (port 4530): State polling, command forwarding, rig discovery.
- Spectrum connection (dedicated): Polls
GetSpectrumat 50ms intervals (20 fps) independently to avoid blocking the main connection during command processing.
Constants: CONNECT_TIMEOUT: 5s, IO_TIMEOUT: 15s, SPECTRUM_IO_TIMEOUT: 3s. Exponential backoff with jitter on reconnect.
FrontendRuntimeContext Sub-Structs
The FrontendRuntimeContext struct in trx-frontend/src/lib.rs is decomposed into coherent sub-structs:
| Sub-struct | Purpose | Key fields |
|---|---|---|
AudioContext |
Audio streaming channels | rx, tx, info, decode_rx, clients |
DecodeHistoryContext |
Decode history for all types | ais, vdes, aprs, hf_aprs, cw, ft8, ft4, ft2, wspr |
HttpAuthConfig |
HTTP auth settings | enabled, rx_passphrase, session_ttl_secs, tokens |
HttpUiConfig |
HTTP UI display config | show_sdr_gain_control, initial_map_zoom, spectrum_* |
RigRoutingContext |
Remote rig state & routing | active_rig_id, remote_rigs, rig_states, server_connected |
OwnerInfo |
Station metadata | callsign, website_url, ais_vessel_url_base |
VChanContext |
Virtual channel audio | audio, audio_cmd, destroyed, rig_audio_cmd |
SpectrumContext |
Spectrum data | sender, per_rig |
PerRigAudioContext |
Per-rig audio channels | rx, info |
Decoder Implementation Patterns
All real-time decoders follow a consistent pattern:
// 1. Stateful decoder struct with sample buffer
pub struct XxxDecoder { sample_buf: Vec<f32>, ... }
// 2. Block/sample processing
pub fn process_block(&mut self, samples: &[f32]) { ... }
// 3. Result extraction
pub fn decode_if_ready(&mut self) -> Vec<XxxResult> { ... }
| Decoder | Algorithm | Sample Rate | Key Constants |
|---|---|---|---|
| FT8/FT4/FT2 | Waterfall + LDPC/OSD | Varies | MAX_LDPC_ITERATIONS=20, MAX_CANDIDATES=120 |
| CW | Goertzel tone detection | Varies | 10ms windows, tone range 300–1200 Hz |
| APRS | Bell 202 AFSK (1200/2200 Hz) | 9600 | HDLC framing, NRZI, CRC-16-CCITT |
| AIS | GMSK 9600 baud | 9600 | Narrowband FM input |
| WSPR | Fano decoder | 12000 | 162 symbols, 120s slot, 1.46 Hz spacing |
| RDS | RRC matched filter + Costas PLL | Native | 57 kHz subcarrier, 1187.5 bps, OSD FEC |
| VDES | pi/4-QPSK 76.8 ksps | 100k | Burst detection, partial Turbo FEC |
Backend Reliability Workarounds (FT-817)
The FT-817 CAT backend (trx-backend-ft817/) includes empirical workarounds for hardware quirks:
- Duplicate frame sends:
set_mode()andset_ptt()send CAT frames twice with 80ms delay (radio sometimes drops first frame). - Panel unlock before commands: Clears stale bytes from the serial buffer.
- Power-on dummy frame: CPU wakes before CAT framing locks; dummy frame ensures readiness.
- VFO state inference: Infers VFO A/B by matching frequencies against cached values (fragile when frequencies collide).
- Read timeout: 800ms per CAT read operation (not configurable).