# trx-rs Architecture
## Table of Contents
1. [Project Purpose](#project-purpose)
2. [Technology Stack](#technology-stack)
3. [High-Level Architecture](#high-level-architecture)
4. [Crate Layout](#crate-layout)
5. [Core Library (trx-core)](#core-library-trx-core)
6. [Protocol Layer (trx-protocol)](#protocol-layer-trx-protocol)
7. [Server (trx-server)](#server-trx-server)
8. [Backend Abstraction (trx-backend)](#backend-abstraction-trx-backend)
9. [Client (trx-client)](#client-trx-client)
10. [Frontend System (trx-frontend)](#frontend-system-trx-frontend)
11. [Signal Decoders](#signal-decoders)
12. [DSP & Spectrum Pipeline](#dsp--spectrum-pipeline)
13. [Plugin System](#plugin-system)
14. [Configuration](#configuration)
15. [Concurrency Model](#concurrency-model)
16. [Authentication & Security](#authentication--security)
17. [Data Flow Diagrams](#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
```mermaid
graph TD
subgraph server["trx-server"]
HW["Radio Hardware"] <-->|"CAT protocol
serial / TCP"| Backend["Rig Backend
(ft817 / ft450d / sdr)"]
Backend --> RigTask["rig_task.rs
(state machine)"]
RigTask --> Listener["listener.rs
(JSON TCP :4530)"]
RigTask --> Audio["audio.rs
(Opus :4531)"]
Audio --> Decoders["Decoders
(APRS, CW, FT8, WSPR, RDS)"]
Decoders --> Uplinks["PSKReporter / APRS-IS"]
end
subgraph client["trx-client"]
Remote["remote_client.rs
(polls state, routes commands)"]
Remote <-->|"mpsc / watch channels"| HTTP["trx-frontend-http
(Web UI :8080)"]
Remote <-->|"mpsc / watch channels"| Rigctl["trx-frontend-rigctl
(rigctl :4532)"]
Remote <-->|"mpsc / watch channels"| JSON["trx-frontend-http-json
(JSON/TCP)"]
end
Listener <-->|"JSON TCP :4530"| Remote
Audio -->|"Opus TCP :4531"| Remote
HTTP & Rigctl & JSON <--> Users["End Users
(Browser / Hamlib / Custom tools)"]
```
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`)
```rust
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:
```rust
pub struct RigState {
pub rig_info: Option,
pub status: RigStatus,
pub initialized: bool,
pub control: RigControl,
pub server_callsign: Option,
pub spectrum: Option, // FFT frame from SDR
pub filter: Option, // 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,
pub tx: Option, // power, SWR, ALC
pub rx: Option, // signal strength
pub lock: Option,
}
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:
```rust
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
```
```rust
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`:
```rust
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> + 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` and calls them on state changes.
```rust
pub trait RigListener: Send + Sync {
fn on_frequency_change(&self, old: Option, 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:
```rust
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`)
```rust
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 = Result;
pub type DynResult = Result>;
```
---
## 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`)
```rust
// Client → Server
pub struct ClientEnvelope {
pub token: Option, // Auth token
pub rig_id: Option, // 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,
pub state: Option, // Updated rig state
pub rigs: Option>, // Response to GetRigs
pub error: Option,
}
pub struct RigEntry {
pub rig_id: String,
pub display_name: Option,
pub state: RigSnapshot,
pub audio_port: Option,
}
```
### 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`)
```rust
pub trait TokenValidator: Send + Sync {
fn validate(&self, token: &str) -> bool;
}
pub struct SimpleTokenValidator { tokens: HashSet }
pub struct NoAuthValidator; // Always returns true (debug/local use)
```
---
## Server (trx-server)
**Path:** `src/trx-server/src/`
### Startup Sequence
1. Parse CLI / TOML config (`config.rs`)
2. Register backends via `RegistrationContext` (built-ins + plugins)
3. For each configured rig:
- Build or pre-configure the rig backend
- Spawn `run_rig_task()` as a Tokio task
4. Spawn `run_listener()` (JSON TCP on `:4530`)
5. Spawn audio streaming server (`:4531`)
6. Wait for shutdown signal
### Multi-Rig Routing
Rigs are stored in `Arc>`. Each `RigHandle` contains:
- `mpsc::Sender` — send commands to the rig task
- `watch::Receiver` — 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 `RigStateMachine` through state transitions
- Polls rig status at `AdaptivePolling` intervals (faster during TX)
- Handles incoming `RigCommand`s from `mpsc::Receiver`
- Broadcasts `RigState` snapshots via `watch::Sender`
### JSON TCP Listener (`listener.rs`)
Accepts connections on port 4530. Per connection:
1. Read newline-delimited JSON (`ClientEnvelope`)
2. Validate token
3. Route to rig by `rig_id`
4. Convert `ClientCommand → RigCommand` and send to rig task
5. Await result and return `ClientResponse`
### Audio Server (`audio.rs`)
Separate TCP listener on port 4531. Per connection:
1. Send `AudioStreamInfo` header
2. Send buffered decoder history (APRS, CW, FT8, WSPR, RDS frames)
3. Stream Opus-encoded RX audio frames as they arrive
4. Interleave decoder messages (`0x03`–`0x06` frame 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`)
```rust
pub enum RigAccess {
Serial { path: String, baud: u32 },
Tcp { addr: String },
Sdr { args: String },
}
type BackendFactory = fn(RigAccess) -> DynResult>;
pub struct RegistrationContext {
factories: HashMap,
}
impl RegistrationContext {
pub fn register_backend(&mut self, name: &str, factory: BackendFactory);
pub fn build_rig(&self, name: &str, access: RigAccess) -> DynResult>;
}
```
Built-in registrations (via `register_builtin_backends_on`):
- `"ft817"` → `Ft817::new`
- `"ft450d"` → `Ft450d::new`
- `"soapysdr"` → `SoapySdrRig::new_from_config(SoapySdrConfig { ... })` (if `soapysdr` feature enabled)
### RigCat Trait (from trx-core)
All backends implement `RigCat`:
```rust
pub trait RigCat: Rig {
async fn get_status(&mut self) -> RigResult;
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:
```rust
pub struct SoapySdrRig {
freq: Freq,
mode: RigMode,
pipeline: dsp::SdrPipeline, // Multi-channel DSP
bandwidth_hz: u32,
fir_taps: u32,
spectrum_buf: Arc>>>,
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
1. Parse CLI / TOML config
2. Register frontends via `FrontendRegistrationContext` (built-ins + plugins)
3. Spawn `run_remote_client()` — connects to server, drives `watch::Sender`
4. Spawn enabled frontends (HTTP, rigctl, http-json)
5. Wait for shutdown
### Remote Client (`remote_client.rs`)
Maintains the server TCP connection:
```rust
pub struct RemoteClientConfig {
pub addr: String,
pub token: Option,
pub selected_rig_id: Arc>>,
pub known_rigs: Arc>>,
pub poll_interval: Duration,
pub spectrum: Arc>,
}
```
Workflow:
1. Connect to `addr` (host:4530)
2. Poll `GetState` at configured interval (default 750 ms)
3. Poll `GetSpectrum` at ~40 ms (25 fps) if backend supports it
4. Forward commands from frontends (`mpsc::Receiver`) to server
5. Broadcast received `RigState` to all frontends via `watch::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`)
```rust
pub trait FrontendSpawner {
fn spawn_frontend(
state_rx: watch::Receiver,
rig_tx: mpsc::Sender,
callsign: Option,
listen_addr: SocketAddr,
context: Arc,
) -> JoinHandle<()>;
}
pub struct FrontendRuntimeContext {
pub rigctl_clients: AtomicUsize,
pub rigctl_addr: Option,
pub http_clients: AtomicUsize,
pub known_rigs: Arc>>,
pub selected_rig_id: Arc>>,
pub spectrum: Arc>,
}
```
### 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)
↓
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:
```rust
pub struct SpectrumData {
pub magnitudes: Vec, // 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)
1. `./plugins/`
2. `~/.config/trx-rs/plugins/`
3. Directories in `TRX_PLUGIN_DIRS` environment 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:**
1. `--config ` CLI argument
2. `./trx-server.toml` / `./trx-client.toml`
3. `~/.config/trx-rs/trx-server.toml`
4. `/etc/trx-rs/trx-server.toml`
### Server Config Structure
```toml
[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
```toml
[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 `token` in every `ClientEnvelope`
- Server validates via `TokenValidator` trait
- `SimpleTokenValidator` — `HashSet` loaded from config
- `NoAuthValidator` — 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
remote_client.rs receives via watch::Receiver
→ broadcasts to frontends via watch::Sender
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>
remote_client.rs polls GetSpectrum every 40 ms
→ stores SpectrumData in SharedSpectrum (Arc>)
HTTP frontend reads SharedSpectrum
→ renders waterfall in browser via WebSocket or polling
```
### Audio Flow
```
SoapySDR IQ → DSP pipeline → PCM (Vec)
→ broadcast::Sender>
↙ (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 `GetSpectrum` requests 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` for all 11 decoder types.
- **Configurable timeouts**: `command_exec_timeout` (default 10s) and `poll_refresh_timeout` (default 8s) are configurable via `RigTaskConfig` and the TOML `[timeouts]` section.
- **Crash recovery**: Rig tasks are monitored; on crash, an `Error` state 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**: `AtomicUsize` with CAS loop avoids acquiring 11 mutex locks in `snapshot_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()` and `find_output_device()` extract the repeated device lookup logic from `run_capture()`/`run_playback()`.
- **CRC filtering**: APRS records filtered by `crc_ok` before storage.
### Remote Client Dual-Connection Model
`remote_client.rs` maintains two independent TCP connections to the server:
1. **Main connection** (port 4530): State polling, command forwarding, rig discovery.
2. **Spectrum connection** (dedicated): Polls `GetSpectrum` at 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:
```rust
// 1. Stateful decoder struct with sample buffer
pub struct XxxDecoder { sample_buf: Vec, ... }
// 2. Block/sample processing
pub fn process_block(&mut self, samples: &[f32]) { ... }
// 3. Result extraction
pub fn decode_if_ready(&mut self) -> Vec { ... }
```
| 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()` and `set_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).