# 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).