diff --git a/docs/Architecture.md b/docs/Architecture.md new file mode 100644 index 0000000..3f0b503 --- /dev/null +++ b/docs/Architecture.md @@ -0,0 +1,1005 @@ +# 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 + +``` +┌──────────────────────────────────────────────────────────┐ +│ 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`) + +```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 { + initial_delay: Duration, + max_delay: Duration, + multiplier: f64, + current_delay: Duration, +} + +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_with_config` (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 +``` diff --git a/docs/Home.md b/docs/Home.md new file mode 100644 index 0000000..81792e2 --- /dev/null +++ b/docs/Home.md @@ -0,0 +1,14 @@ +# trx-rs + +`trx-rs` is a modular amateur radio control stack written in Rust. It splits +hardware access, DSP, transport, and user-facing interfaces into separate +components so a radio or SDR can be controlled locally while audio, decoding, +and remote control are exposed elsewhere on the network. + +## Documentation + +- [User Manual](User-Manual) — configuration, features, and usage +- [Architecture](Architecture) — system design, crate layout, data flow, and internals +- [Optimization Guidelines](Optimization-Guidelines) — performance guidelines for the real-time DSP pipeline +- [Planned Features](Planned-Features) — planned features and design notes +- [Improvement Areas](Improvement-Areas) — codebase audit: quality, architecture, security, and performance diff --git a/docs/Improvement-Areas.md b/docs/Improvement-Areas.md new file mode 100644 index 0000000..9793222 --- /dev/null +++ b/docs/Improvement-Areas.md @@ -0,0 +1,125 @@ +# Improvement Areas + +A comprehensive audit of the trx-rs codebase covering code quality, architecture, +security, testing, and performance. Each item includes the affected location and +a suggested fix. + +*Last updated: 2026-03-26* + +--- + +## Resolved + +The following items have been fixed across PRs #58, #59, and #60: + +### Quick Wins (all complete) +- ✅ **Session cleanup timer** — 5-minute periodic `cleanup_expired()` task +- ✅ **`DecodeHistory` type alias** — replaces 9 repeated `Arc>>` patterns +- ✅ **`mode_to_string()` allocation** — returns `Cow<'static, str>` (zero-alloc for known modes) +- ✅ **FTx dedup** — `HashSet` for O(1) lookups +- ✅ **Unbounded channels** — `VChanAudioCmd` channels bounded at 256 +- ✅ **JSON serialization** — `#[serde(flatten)]` wrapper replaces string-level splice +- ✅ **`AtomicUsize` counter** — `estimated_total_count()` avoids 9 mutex acquisitions +- ✅ **Cookie security warning** — startup warning when `cookie_secure` is false +- ✅ **Spectrum encoding** — pre-allocated output string replaces `format!` overhead +- ✅ **`pub(crate)` state data** — `ReadyStateData`/`TransmittingStateData` fields restricted with constructors + getters +- ✅ **Lock ordering docs** — module-level documentation in `` establishing `rigs → sessions → audio_cmd` + +### Critical (P0) +- ✅ **Plugin loading validation** — rejects world-writable files on Unix; `TRX_PLUGINS_DISABLED` env var +- ✅ **Audio pipeline mutex panics** — all `.expect()` on history mutexes and `.unwrap()` on audio ring buffers replaced with `.unwrap_or_else(|e| e.into_inner())` poison recovery +- ✅ **vchan lock panics** — ~25 `.unwrap()` on RwLock/Mutex replaced with poison recovery + +### High (P1) +- ✅ **RigCat trait split** — 13 SDR-specific methods extracted into `RigSdr` extension trait; `RigCat` retains core CAT ops + `as_sdr()`/`as_sdr_ref()`; SoapySdrRig implements both; FT-817/FT-450D/DummyRig unchanged +- ✅ **Decoder history contention** — `AtomicUsize` total counter maintained by record/prune/clear + +### Medium (P2) +- ✅ **Silent state machine failures** — debug-level tracing for rejected transitions +- ✅ **User input in logs** — raw JSON truncated to 128 chars +- ✅ **Rate limiting** — per-IP `LoginRateLimiter` (10 attempts/60s) on `/auth/login` +- ✅ **Lock-holding serialization** — clone data out under lock, serialize after release +- ✅ **Overly-public API** — state data fields `pub(crate)` with controlled accessors +- ✅ **Cookie security flag** — startup warning for non-TLS deployments +- ✅ **Lock ordering** — documented in `` module header + +--- + +## Remaining Issues + +### Critical (P0) + +#### Plugin signing and cross-platform validation + +**Location:** `src/trx-app/src/` + +Current protections: file permission checks (Unix), `TRX_PLUGINS_DISABLED` env var, +loaded plugins logged at startup. + +**Still missing:** +- No SHA-256 checksum verification — an attacker who passes the permission check + can still load a tampered library +- No per-plugin permission scoping (all plugins get full context access) +- Windows has no file permission validation + +**Suggestions:** +- SHA-256 checksum manifest (`plugins.toml`) verified before `Library::new` +- Config option to allowlist specific plugin filenames +- On Windows, verify file owner via `GetSecurityInfo` or equivalent + +--- + +### High Priority (P1) + +#### Synchronous locks in async contexts + +**Location:** `src/trx-client/trx-frontend/trx-frontend-http/src/`, +`src/trx-client/trx-frontend/trx-frontend-http/src/` + +`std::sync::RwLock` is used inside async tasks. Current code is safe (no locks held +across await points), but not idiomatic. Migrating to `tokio::sync::RwLock` would +prevent future regressions. + +#### Large functions in audio pipeline + +**Locations:** +- `src/trx-server/src/` — `run_capture()` (~200 lines), + `run_playback()` (~217 lines) + +These contain nested loops, device re-enumeration logic, and stream error handling +that should be extracted into focused helper functions. + +--- + +### Medium Priority (P2) + +#### Configuration duplication + +**Location:** `src/trx-server/src/` (1512 lines), +`src/trx-client/src/` (1181 lines) + +14 config structs each, many mirrored between server and client. Extract shared +definitions (GeneralConfig, RigConfig, defaults) into `trx-app`. + +--- + +### Low Priority (P3) + +#### Missing tests for critical paths + +Serial backends (FT-817, FT-450D), plugin loading/discovery, and the audio +pipeline (Opus encode/decode) have no or minimal test coverage. + +Core crates (`trx-core`, `trx-server`, `trx-client`, `trx-app`) have limited +`[dev-dependencies]` and use only inline `#[test]` functions. Adding test +utilities (mock serial ports, test fixtures) would improve coverage. + +#### Plugin system lacks versioning and lifecycle + +**Location:** `src/trx-app/src/` + +No plugin API version, capability manifest, or unload/reload semantics. Old +plugins break silently on API changes. + +**Fix:** Add a version field to the registration struct and reject incompatible +plugins at load time. \ No newline at end of file diff --git a/docs/Optimization-Guidelines.md b/docs/Optimization-Guidelines.md new file mode 100644 index 0000000..22a4901 --- /dev/null +++ b/docs/Optimization-Guidelines.md @@ -0,0 +1,175 @@ +# DSP Optimization Guidelines + +This document captures lessons learned and best practices for optimizing +the real-time DSP pipelines in trx-rs, particularly the WFM stereo decoder +and audio encoding paths. + +## General Principles + +1. **Measure first.** Profile with real workloads before optimizing. + Synthetic benchmarks miss cache effects, branch prediction patterns, + and real signal statistics. + +2. **Eliminate transcendentals from inner loops.** A single `sin_cos` or + `atan2` per sample at 200 kHz composite rate costs millions of calls + per second. Replace with: + - **Quadrature NCO** for oscillators: maintain `(cos, sin)` state and + rotate by a precomputed `(cos_inc, sin_inc)` each sample. Cost: + 4 muls + 2 adds. Renormalize every ~1024 samples to prevent drift. + - **Double-angle identities** to derive `sin(2θ), cos(2θ)` from + `sin(θ), cos(θ)`: `sin2 = 2·sin·cos`, `cos2 = 2·cos²−1`. + - **I/Q arm extraction** for PLL phase error: if you have + `i = lp(signal * cos)` and `q = lp(signal * -sin)`, then + `sin(err) = q/mag`, `cos(err) = i/mag` — no `atan2` or `sin_cos` + needed for the rotation. + +3. **Batch operations for SIMD.** Separate data-parallel work (e.g. FM + discriminator: conjugate-multiply + atan2) from sequential-state work + (PLL, biquads). Process the parallel part in batches of 8 using AVX2, + then feed scalar results into the sequential pipeline. + +4. **Power-of-2 sizes for circular buffers.** Use `& (N-1)` bitmask + instead of `% N` modulo. Ensure buffer lengths (e.g. `WFM_RESAMP_TAPS`) + are powers of two. + +5. **Circular buffers over shift registers.** Writing one sample at a + ring-buffer position is O(1); `rotate_left(1)` is O(N). For a 32-tap + FIR called 3× per composite sample, this eliminates ~200 byte-moves + per sample. + +6. **Decimate slow-changing metrics.** Stereo detection (pilot coherence, + lock, drive) changes over tens of milliseconds. Running it every 16th + sample instead of every sample saves ~94% of that work with no audible + effect. Accumulate values over the window and process the average. + +## Filter Design + +- **Match filter cutoffs** across parallel paths (sum and diff) to ensure + identical group delay. Mismatched cutoffs cause frequency-dependent + phase errors that directly degrade stereo separation. + +- **4th-order Butterworth** (two cascaded biquads) is generally sufficient + when the polyphase resampler provides additional stopband rejection. + 6th-order adds 50% more biquad evaluations per sample for diminishing + returns. + +- **Q values for Butterworth cascades:** + - 4th-order: Q₁ = 0.5412, Q₂ = 1.3066 + - 6th-order: Q₁ = 0.5176, Q₂ = 0.7071, Q₃ = 1.9319 + +## Polyphase Resampler + +- **Compute cutoff from actual rate ratio:** `cutoff = output_rate / input_rate`. + A fixed cutoff (e.g. 0.94) can be catastrophically wrong — at 200 kHz + composite to 48 kHz audio, it passes everything up to 94 kHz while the + output Nyquist is only 24 kHz. The 38 kHz stereo subcarrier residuals + alias directly into the treble range. + +- **Blackman-Harris window** gives ~92 dB stopband rejection vs ~43 dB + for Hamming, at the same tap count. Use it for the windowed-sinc + coefficients: + ``` + w(n) = 0.35875 − 0.48829·cos(2πn/N) + 0.14128·cos(4πn/N) − 0.01168·cos(6πn/N) + ``` + +- **32 taps** with Blackman-Harris and a proper cutoff gives >60 dB + stopband rejection — more than enough. 64 taps doubles the MAC count + for marginal improvement. + +- **64 polyphase phases** balances fractional sample resolution against + coefficient bank size (64 × 32 × 4 = 8 KB fits comfortably in L1 + cache). 128 phases offer diminishing returns for double the memory. + +## FM Discriminator + +- **Batch with AVX2:** The conjugate-multiply + atan2 pattern is + data-parallel (each output depends only on two adjacent input samples). + Process 8 samples at a time using 256-bit SIMD. + +- **Use a high-precision atan2 polynomial** for AVX2. A 7th-order minimax + polynomial (max error ~2.4e-7 rad) avoids the treble distortion that + cheap 1st-order approximations (e.g. `0.273*(1−|z|)`) introduce on + strong signals. Coefficients: + ``` + c0 = 0.999_999_5 + c1 = −0.333_326_1 + c2 = 0.199_777_1 + c3 = −0.138_776_8 + ``` + +- **Branchless argument reduction** for atan2: swap `|y|` and `|x|` using + masks rather than branches, apply quadrant correction via arithmetic + shift and copysign. + +## WFM Stereo Specifics + +- **Pilot notch before diff demod:** The 19 kHz pilot leaks into the + 38 kHz multiplication and creates intermod products. Notch it from the + composite signal before `x * cos(2θ)`. This notch is separate from the + mono-path pilot notch (which sits after the sum LPF). + +- **IQ hard limiter before FM discriminator:** For WFM, only the phase + carries information. Normalizing IQ magnitude to 1.0 prevents + overdeviation artifacts and clipping. Guard against zero magnitude. + +- **Binary stereo blend:** A smooth blend function (e.g. smoothstep) + sounds good in theory but reduces real-world separation. Use + `blend = 1.0` when pilot is detected, `0.0` otherwise. + +- **STEREO_MATRIX_GAIN = 0.50:** The correct unity factor for + `L = (S+D)/2`, `R = (S−D)/2`. Lower values waste headroom; higher + values clip. + +## Opus Encoding + +- **Complexity 5** (down from default 9-10) saves significant CPU with + minimal quality impact at bitrates ≥128 kbps. The higher complexity + levels run expensive psychoacoustic search algorithms that produce + negligible improvement at high bitrates. + +- **256 kbps** is transparent for stereo FM broadcast audio. Going higher + wastes bandwidth; going below 128 kbps may introduce artifacts on + complex program material. + +- **`Application::Audio`** (not VoIP) — uses the MDCT-based CELT mode + optimized for music and broadband audio rather than speech. + +## AVX2 Guidelines + +- Gate all AVX2 code behind `#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]` + and runtime `is_x86_feature_detected!("avx2")` checks. + +- Mark unsafe SIMD functions with `#[target_feature(enable = "avx2")]` + so the compiler generates AVX2 code for the function body. + +- Provide scalar fallbacks for non-x86 targets and CPUs without AVX2. + +- Add epsilon guards (e.g. `1e-12`) to denominators in SIMD paths where + both numerator and denominator can be zero simultaneously. + +## What NOT to Optimize + +- **Biquad filters** — already minimal (5 muls + 4 adds per sample). + The sequential state dependency prevents SIMD vectorization within a + single stream. + +- **One-pole lowpass filters** — single multiply-accumulate, cannot be + made faster. + +- **DC blockers** — trivial per-sample cost. + +- **Deemphasis** — single biquad, runs at audio rate (not composite rate). + +## Profiling Tips + +- Use `cargo build --release` — debug builds are 10-50x slower and + misleading for DSP profiling. + +- `perf stat` / `Instruments` on the inner loop to check IPC, cache + misses, and branch mispredictions. + +- Compare CPU% with stereo enabled vs disabled to isolate stereo-specific + costs (diff path biquads, pilot PLL, 38 kHz demod, resampler channels). + +- Watch for unexpected `libm` calls in disassembly — the compiler may + not inline `f32::atan2` or `f32::sin_cos` even in release mode. diff --git a/docs/Planned-Features.md b/docs/Planned-Features.md new file mode 100644 index 0000000..4b628f7 --- /dev/null +++ b/docs/Planned-Features.md @@ -0,0 +1,324 @@ +# Planned Features + +## Recorder + +The recorder captures the demodulated audio stream alongside associated metadata (FFT data, decoded signals, rig state) into a structured session on disk, with full playback and seeking support from within the application. + +### Requirements + +| ID | Description | +|----|-------------| +| REQ-REC-001 | When the user starts recording, the system shall record the currently demodulated audio stream. | +| REQ-REC-002 | When recording audio, the system shall store the recording in OPUS format. | +| REQ-REC-003 | While recording audio, the system shall automatically detect whether the recording should be stored in mono or stereo and select the appropriate format. | +| REQ-REC-004 | While recording is active, the system shall simultaneously record FFT data and all currently visible decoded elements, including APRS and FT8. | +| REQ-REC-005 | While recording metadata, the system shall store FFT data and decoded signal data in a structured data file format. | +| REQ-PLAY-001 | Where recorded sessions exist, the system shall allow playback of recordings from within the same application. | +| REQ-PLAY-002 | During playback, the system shall allow the user to seek to any position in the recording. | +| REQ-SYNC-001 | The system shall maintain time synchronization between the audio recording and the associated data file with at least one-second resolution. | +| REQ-REC-006 | While recording is active, the system shall allow the current cursor position to be stored. | + +--- + +### Architecture + +#### New Crate: `trx-recorder` + +A new crate `src/trx-server/trx-recorder/` handles all record and playback logic. It is a library crate consumed by `trx-server`. + +``` +src/trx-server/ + trx-recorder/ + src/ + lib.rs # Public API: RecorderHandle, start_recorder_task() + session.rs # RecordingSession: file management, open/close/finalise + writer.rs # AudioWriter: PCM → Opus encoder + data_file.rs # DataFileWriter: structured JSON Lines data track + index.rs # SeekIndex: time → byte-offset table for audio seeking + playback.rs # PlaybackEngine: file → PCM broadcast for clients + config.rs # RecorderConfig (serde, derives Default) +``` + +#### Integration Points in `trx-server` + +| Source | What is tapped | How | +|--------|---------------|-----| +| `audio.rs` `pcm_tx` | Raw demodulated PCM frames | New `broadcast::Receiver>` subscriber | +| `audio.rs` spectrum broadcast | FFT/spectrum frames per `RigState.spectrum` | New subscriber on the spectrum watch channel | +| `audio.rs` decoded-message broadcast | FT8, WSPR, CW, APRS, FT4, FT2, APRS-HF frames | New `broadcast::Receiver` subscriber | +| `rig_task.rs` state watch | Frequency/mode/PTT changes | `watch::Receiver` clone | +| New `RecorderCommand` enum | Start, Stop, MarkCursor | Injected into the existing command pipeline | + +No existing code paths are modified beyond: +1. Passing a `RecorderHandle` (cheap `Arc` wrapper) into the audio and rig tasks. +2. Adding `RecorderCommand` variants to the command enum (alongside existing `SetFreq`, `SetMode`, etc.). +3. Adding a `[recorder]` section to `ServerConfig`. + +--- + +### Session Layout on Disk + +Each recording is a **session directory** named by UTC start time and opening rig state: + +``` +/ + 20260317T142301Z_14074000_USB/ + audio.opus + data.jsonl # structured event log (see below) + index.bin # seek index: sorted table of (offset_ms u64, audio_byte u64) +``` + +`output_dir` defaults to `~/.local/share/trx-rs/recordings`. + +#### Audio File (REQ-REC-001, REQ-REC-002, REQ-REC-003) + +- **Format**: Opus, using the `opus` crate (already a workspace dependency via `trx-backend-soapysdr`). Seek index (`index.bin`) provides byte → time mapping. +- **Channel count**: determined at session open from `AudioConfig.channels`. If `channels == 1` → mono; if `channels == 2` → stereo. Written into the file header and recorded in the session's first data event. +- **Sample rate**: preserved from `AudioConfig.sample_rate` (default 48 000 Hz). + +#### Data File (REQ-REC-004, REQ-REC-005) + +`data.jsonl` — one JSON object per line, each with a required `offset_ms` field giving the millisecond offset from session start (satisfies REQ-SYNC-001 at ≥1 s resolution): + +```jsonl +{"offset_ms":0,"type":"session_start","freq_hz":14074000,"mode":"USB","channels":1,"sample_rate":48000,"format":"opus"} +{"offset_ms":1000,"type":"rig_state","freq_hz":14074000,"mode":"USB","ptt":false} +{"offset_ms":2000,"type":"fft","bins_db":[-90.1,-88.4,...]} +{"offset_ms":3412,"type":"ft8","snr_db":-12,"dt_s":0.3,"freq_hz":14074350,"message":"CQ W5XYZ EN34"} +{"offset_ms":4100,"type":"aprs","from":"W5XYZ-9","to":"APRS","path":"WIDE1-1","info":"!3351.00N/09722.00W-"} +{"offset_ms":5000,"type":"cursor","label":"interesting QSO"} +{"offset_ms":61000,"type":"session_end"} +``` + +Supported `type` values: + +| Type | Source | Cadence | +|------|--------|---------| +| `session_start` | recorder | once, at open | +| `session_end` | recorder | once, at close | +| `rig_state` | `watch::Receiver` change | on change | +| `fft` | spectrum data from `RigState.spectrum` | ≤1 Hz (configurable, default 1 s) | +| `ft8` / `ft4` / `ft2` / `wspr` | `DecodedMessage` broadcast | on decode event | +| `aprs` / `aprs_hf` | `DecodedMessage` broadcast | on decode event | +| `cw` | `DecodedMessage` broadcast | on decode event | +| `cursor` | `RecorderCommand::MarkCursor { label }` | on user request | + +#### Seek Index (REQ-PLAY-002) + +`index.bin` is a flat binary table of 16-byte records written every `index_interval_ms` (default 1 000 ms): + +``` +[offset_ms: u64 LE][audio_byte_offset: u64 LE] ... +``` + +At playback seek time, binary search on `offset_ms` locates the nearest audio frame boundary, enabling random-access playback without full file scan. + +--- + +### RecorderConfig + +Added to `ServerConfig` under `[recorder]`: + +```toml +[recorder] +enabled = false +output_dir = "~/.local/share/trx-rs/recordings" +opus_bitrate_bps = 32000 +fft_record_interval_ms = 1000 +index_interval_ms = 1000 +max_session_duration_s = 3600 # auto-split at 1 h; 0 = unlimited +``` + +--- + +### Command API + +New variants added to the existing command enum (handled in `rig_task.rs`): + +```rust +StartRecording, +StopRecording, +MarkCursor { label: String }, +``` + +These are exposed via: +- **HTTP frontend**: `POST /api/recorder/start`, `POST /api/recorder/stop`, `POST /api/recorder/cursor` +- **http-json frontend**: same commands as JSON messages + +--- + +### Playback Engine (REQ-PLAY-001, REQ-PLAY-002) + +`PlaybackEngine` opens a session directory and: + +1. Reads `audio.opus` and decodes PCM frames in real time. +2. Publishes decoded PCM frames onto a `broadcast::Sender>` — the **same channel type** as the live `pcm_tx`, so existing decoder tasks and audio-streaming clients receive playback data transparently. +3. Replays `data.jsonl` events on their original `offset_ms` timestamps, injecting them into the `DecodedMessage` broadcast so the HTTP frontend displays historic decodes during playback. +4. For seek: binary-searches `index.bin` to find the audio byte offset, then replays data events from the same point. + +The playback state machine has two modes, switched by a new `RigState.playback` field: + +```rust +pub enum PlaybackState { + Live, + Playing { session: String, offset_ms: u64 }, + Paused { session: String, offset_ms: u64 }, +} +``` + +While `PlaybackState` is not `Live`, the server suppresses live hardware polling and PCM capture to avoid mixing live and playback audio. + +--- + +### Time Synchronisation (REQ-SYNC-001) + +All timestamps use a single `session_epoch: std::time::Instant` captured at `StartRecording`. Every PCM frame, every data event, and every seek-index entry is stamped as `(Instant::now() - session_epoch).as_millis() as u64`. This gives sub-millisecond internal precision; the requirement of ≥1 s resolution is met by orders of magnitude. + +Wall-clock UTC is embedded only in `session_start` (`wall_clock_utc`) and in the session directory name, providing absolute time anchoring without depending on system clock monotonicity for sync. + +--- + +### Implementation Phases + +#### Phase 1 — Audio recording (REQ-REC-001, REQ-REC-002, REQ-REC-003) + +1. Add `trx-recorder` crate skeleton; `RecorderConfig`; `RecorderHandle`. +2. Implement `AudioWriter` with Opus output. +3. Subscribe `AudioWriter` to `pcm_tx` in `audio.rs`; open session on `StartRecording` command. +4. Auto-detect channel count from `AudioConfig.channels`. + +#### Phase 2 — Metadata recording (REQ-REC-004, REQ-REC-005, REQ-SYNC-001) + +1. Implement `DataFileWriter`; define full event schema. +2. Subscribe to `DecodedMessage` broadcast; fan-in all decoder types. +3. Subscribe to state watch; emit `rig_state` events on freq/mode change. +4. Emit `fft` events at configured interval from spectrum data. +5. Write `SeekIndex` in parallel with audio. + +#### Phase 3 — Cursor (REQ-REC-006) + +1. Add `MarkCursor` command + HTTP endpoint. +2. Write `cursor` event to `data.jsonl` with current `offset_ms`. + +#### Phase 4 — Playback (REQ-PLAY-001, REQ-PLAY-002) + +1. Implement `PlaybackEngine`; Opus decode + PCM broadcast. +2. Add `PlaybackState` to `RigState`; suppress live capture during playback. +3. Implement seek via `index.bin` binary search. +4. Replay `data.jsonl` events; feed into `DecodedMessage` broadcast. +5. Expose start/stop/seek endpoints in `trx-frontend-http`. + +--- + +### Dependencies to Add + +| Crate | Use | Already present? | +|-------|-----|-----------------| +| `opus` | Opus encode/decode | Yes (via trx-backend-soapysdr) | +| `serde_json` | data.jsonl serialisation | Yes | +| `tokio::fs` | async file I/O | Yes | + +--- + +### Open Questions + +1. **Playback isolation**: Should playback be exclusive (block all CAT commands) or concurrent? Initial design blocks CAT polling; revisit if users need to change frequency during playback. +2. **Session listing API**: The HTTP frontend needs an endpoint to enumerate sessions (`GET /api/recorder/sessions`). Schema TBD in Phase 4. +3. **Storage limits**: `max_session_duration_s` auto-splits sessions; a `max_total_size_gb` housekeeping option may be needed but is out of scope for initial phases. + +--- + +## Configurator Helper + +An interactive CLI tool that guides users through creating configuration files +for trx-rs. Instead of editing TOML by hand, the user answers prompts and the +tool generates valid, commented configuration files. + +### Overview + +The configurator is a standalone Rust binary (`trx-configurator`) that reuses +the existing config structs from `trx-app`, `trx-server`, and `trx-client`. It +walks the user through a question-driven flow, validates inputs against the same +rules the binaries use at startup, and writes one or more of: + +- `trx-server.toml` — server configuration +- `trx-client.toml` — client configuration +- `trx-rs.toml` — combined server + client configuration + +The user chooses which file(s) to generate. + +### Requirements + +| ID | Description | +|----|-------------| +| REQ-CFG-001 | The tool shall interactively prompt the user for configuration values. | +| REQ-CFG-002 | The tool shall generate `trx-server.toml`, `trx-client.toml`, or `trx-rs.toml` per user selection. | +| REQ-CFG-003 | The tool shall validate all inputs using the same validation logic as the server and client binaries. | +| REQ-CFG-004 | The tool shall write commented TOML with descriptions of each field. | +| REQ-CFG-005 | The tool shall detect connected serial devices and offer them for rig access configuration. | +| REQ-CFG-006 | The tool shall detect available SoapySDR devices and offer them for SDR backend configuration. | +| REQ-CFG-007 | The tool shall support a non-interactive mode that generates a default config file. | +| REQ-CFG-008 | The tool shall not overwrite existing files without confirmation. | + +### Architecture + +#### New Crate: `trx-configurator` + +A new binary crate at `src/trx-configurator/` that depends on `trx-app` for +config types and validation. + +``` +src/trx-configurator/ + src/ + main.rs # CLI entry point, mode selection + prompts.rs # Interactive prompt helpers (with defaults, validation) + detect.rs # Hardware detection (serial ports, SoapySDR devices) + writer.rs # TOML serialisation with inline comments +``` + +#### Flow + +``` +trx-configurator + ├── What would you like to generate? + │ [ ] trx-server.toml + │ [ ] trx-client.toml + │ [ ] trx-rs.toml (combined) + │ + ├── (if server) + │ ├── General: callsign, location + │ ├── Rig: model selection, access (serial/tcp/sdr) + │ │ └── detect serial ports / SoapySDR devices + │ ├── Listen: address, port + │ ├── Audio: sample rate, channels, codec settings + │ ├── SDR: (if soapysdr selected) gain, channels, decoders + │ ├── Uplinks: PSKReporter, APRS-IS + │ └── Decode logs: enable, directory + │ + ├── (if client) + │ ├── Remote: server URL, auth token + │ ├── Frontends: HTTP, rigctl, http-json (enable/disable, ports) + │ └── Audio: bridge settings + │ + └── Write file(s) with confirmation + +``` + +#### Hardware Detection + +- **Serial ports**: enumerate available serial devices using `serialport` crate + (already a transitive dependency). Present as selectable list with device + path and description. +- **SoapySDR devices**: if built with `soapysdr` feature, call + `SoapySDR::enumerate("")` to list available SDR hardware. Present device + driver, label, and serial number. + +#### Dependencies + +| Crate | Use | Already present? | +|-------|-----|-----------------| +| `dialoguer` | Interactive prompts, selection, confirmation | No | +| `toml_edit` | TOML serialisation preserving comments | No | +| `trx-app` | Config types and validation | Yes | +| `serialport` | Serial port enumeration | Yes (transitive) | +| `soapysdr` | SDR device enumeration (optional) | Yes (feature-gated) | diff --git a/docs/User-Manual.md b/docs/User-Manual.md new file mode 100644 index 0000000..ba49884 --- /dev/null +++ b/docs/User-Manual.md @@ -0,0 +1,546 @@ +# trx-rs Manual + +## What trx-rs is + +`trx-rs` is a modular amateur radio control stack written in Rust. It splits +hardware access, DSP, transport, and user-facing interfaces into separate +components so a radio or SDR can be controlled locally while audio, decoding, +and remote control are exposed elsewhere on the network. + +In practice, `trx-server` owns the rig or SDR backend and runs the DSP +pipeline, while `trx-client` connects to it and provides frontends such as the +web UI, JSON control, and rigctl-compatible access. The workspace also includes +protocol decoders and plugin-based extension points for adding backends and +frontends. + +--- + +## Configuration + +Both `trx-server` and `trx-client` use TOML configuration files. Use +`--print-config` to generate a fully commented example. + +### File Locations + +**trx-server** lookup order: +1. `--config ` +2. `./trx-server.toml` +3. `~/.trx-server.toml` +4. `~/.config/trx-rs/server.toml` +5. `/etc/trx-rs/server.toml` + +**trx-client** lookup order: +1. `--config ` +2. `./trx-client.toml` +3. `~/.config/trx-rs/client.toml` +4. `/etc/trx-rs/client.toml` + +CLI arguments override config file values. + +### Environment Variables + +- `TRX_PLUGIN_DIRS`: additional plugin directories (path-separated), used by + both server and client. + +### Server Options + +#### `[general]` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `callsign` | string | `"N0CALL"` | Station callsign | +| `log_level` | string | — | `trace`, `debug`, `info`, `warn`, or `error` | +| `latitude` | float | — | Station latitude (-90..90) | +| `longitude` | float | — | Station longitude (-180..180) | + +`latitude` and `longitude` must be set together or both omitted. + +#### `[rig]` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `model` | string | — | Backend name (`ft817`, `ft450d`, `soapysdr`) | +| `initial_freq_hz` | u64 | `144300000` | Startup frequency (must be > 0) | +| `initial_mode` | string | `"USB"` | Startup mode | + +#### `[rig.access]` + +| Field | Type | Description | +|-------|------|-------------| +| `type` | string | `serial`, `tcp`, or `sdr` | +| `port` | string | Serial port path (serial mode) | +| `baud` | u32 | Serial baud rate (serial mode) | +| `host` | string | Remote host (tcp mode) | +| `tcp_port` | u16 | Remote port (tcp mode) | +| `args` | string | SoapySDR device args (sdr mode, e.g. `"driver=rtlsdr"`) | + +#### `[behavior]` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `poll_interval_ms` | u64 | `500` | Rig polling interval | +| `poll_interval_tx_ms` | u64 | `100` | Polling interval during TX | +| `max_retries` | u32 | `3` | Connection retry limit | +| `retry_base_delay_ms` | u64 | `100` | Base retry delay | + +#### `[listen]` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `enabled` | bool | `true` | Enable JSON TCP listener | +| `listen` | ip | `127.0.0.1` | Bind address | +| `port` | u16 | `4530` | Bind port | + +#### `[listen.auth]` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `tokens` | string[] | `[]` | Allowed auth tokens (empty = no auth) | + +#### `[audio]` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `enabled` | bool | `true` | Enable audio streaming | +| `listen` | ip | `127.0.0.1` | Bind address | +| `port` | u16 | `4531` | Bind port | +| `rx_enabled` | bool | `true` | Enable RX audio | +| `tx_enabled` | bool | `true` | Enable TX audio | +| `device` | string | — | CPAL device name (empty = default) | +| `sample_rate` | u32 | `48000` | Sample rate (8000–192000) | +| `channels` | u8 | `1` | Channel count (1 or 2) | +| `frame_duration_ms` | u16 | `20` | Opus frame duration (3, 5, 10, 20, 40, 60) | +| `bitrate_bps` | u32 | `24000` | Opus bitrate | + +When audio is enabled, at least one of `rx_enabled` or `tx_enabled` must be true. + +#### `[sdr]` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `sample_rate` | u32 | `1920000` | IQ capture rate in Hz | +| `bandwidth` | u32 | `1500000` | Hardware IF filter bandwidth in Hz | +| `center_offset_hz` | i64 | `100000` | Offset from dial to avoid DC spur | + +#### `[sdr.gain]` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `mode` | string | `"auto"` | `"auto"` (hardware AGC) or `"manual"` | +| `value` | f64 | `30.0` | Gain in dB (manual mode only) | + +#### `[sdr.squelch]` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `enabled` | bool | `false` | Enable software squelch | +| `threshold_db` | f32 | `-65.0` | Open threshold in dBFS (-140..0) | +| `hysteresis_db` | f32 | `3.0` | Close hysteresis in dB (0..40) | +| `tail_ms` | u32 | `180` | Tail hold time in ms (0..10000) | + +#### `[[sdr.channels]]` + +Defines virtual receiver channels within the wideband IQ stream. The first +channel is the primary channel (controlled by `set_freq`/`set_mode`). + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `id` | string | `""` | Human-readable label | +| `offset_hz` | i64 | `0` | Frequency offset from dial | +| `mode` | string | `"auto"` | Demod mode (`auto`, `LSB`, `USB`, `CW`, `AM`, `FM`, `WFM`, etc.) | +| `audio_bandwidth_hz` | u32 | `3000` | Post-demod audio bandwidth | +| `fir_taps` | usize | `64` | FIR filter tap count | +| `cw_center_hz` | u32 | `700` | CW tone centre frequency | +| `wfm_bandwidth_hz` | u32 | `75000` | WFM pre-demod filter bandwidth | +| `decoders` | string[] | `[]` | Decoder IDs for this channel (`ft8`, `wspr`, `aprs`, `cw`) | +| `stream_opus` | bool | `false` | Stream this channel's audio to clients | + +Notes: +- Each decoder ID may appear in at most one channel. +- At most one channel may set `stream_opus = true`. +- Channel IF constraint: `|center_offset_hz + offset_hz| < sample_rate / 2`. + +#### `[pskreporter]` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `enabled` | bool | `false` | Enable PSKReporter uplink | +| `host` | string | `"report.pskreporter.info"` | Server host | +| `port` | u16 | `4739` | Server port | +| `receiver_locator` | string | — | Maidenhead grid (derived from lat/lon if omitted) | + +#### `[aprsfi]` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `enabled` | bool | `false` | Enable APRS-IS IGate | +| `host` | string | `"rotate.aprs.net"` | Server host | +| `port` | u16 | `14580` | Server port | +| `passcode` | i32 | `-1` | APRS-IS passcode (-1 = auto from callsign) | + +Notes: +- `[general].callsign` must be non-empty when enabled. +- Only APRS packets with valid CRC are forwarded. +- Reconnects with exponential backoff (1 s → 60 s) on TCP errors. + +#### `[decode_logs]` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `enabled` | bool | `false` | Enable decoder logging | +| `dir` | string | `"$XDG_DATA_HOME/trx-rs/decoders"` | Log directory | +| `aprs_file` | string | `"TRXRS-APRS-%YYYY%-%MM%-%DD%.log"` | APRS log filename | +| `cw_file` | string | `"TRXRS-CW-%YYYY%-%MM%-%DD%.log"` | CW log filename | +| `ft8_file` | string | `"TRXRS-FT8-%YYYY%-%MM%-%DD%.log"` | FT8 log filename | +| `wspr_file` | string | `"TRXRS-WSPR-%YYYY%-%MM%-%DD%.log"` | WSPR log filename | + +Files are appended in JSON Lines format. Supported date tokens: `%YYYY%`, +`%MM%`, `%DD%` (UTC). + +#### Multi-Rig Configuration + +Use `[[rigs]]` arrays instead of the flat `[rig]` section for multi-rig setups: + +```toml +[[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" +``` + +When `[[rigs]]` is present it takes priority over the flat `[rig]` section. +Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1`. + +### Client Options + +#### `[general]` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `callsign` | string | `"N0CALL"` | Station callsign | +| `log_level` | string | — | `trace`, `debug`, `info`, `warn`, or `error` | + +#### `[remote]` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `url` | string | — | Server address (e.g. `localhost:4530`) | +| `poll_interval_ms` | u64 | `750` | State poll interval | + +#### `[remote.auth]` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `token` | string | — | Auth token (must not be empty if set) | + +#### `[frontends.http]` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `enabled` | bool | `true` | Enable web UI | +| `listen` | ip | `127.0.0.1` | Bind address | +| `port` | u16 | `8080` | Bind port | + +#### `[frontends.rigctl]` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `enabled` | bool | `false` | Enable Hamlib rigctl | +| `listen` | ip | `127.0.0.1` | Bind address | +| `port` | u16 | `4532` | Bind port | + +#### `[frontends.http_json]` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `enabled` | bool | `true` | Enable JSON-over-TCP | +| `listen` | ip | `127.0.0.1` | Bind address | +| `port` | u16 | `0` | Bind port (0 = ephemeral) | +| `auth.tokens` | string[] | `[]` | Allowed auth tokens | + +#### `[frontends.audio]` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `enabled` | bool | `true` | Enable audio client | +| `server_port` | u16 | `4531` | Server audio port | +| `bridge.enabled` | bool | `false` | Enable local CPAL audio bridge | +| `bridge.rx_output_device` | string | — | Local playback device | +| `bridge.tx_input_device` | string | — | Local capture device | +| `bridge.rx_gain` | float | `1.0` | RX playback gain | +| `bridge.tx_gain` | float | `1.0` | TX capture gain | + +The bridge is intended for WSJT-X integration via virtual audio devices (ALSA +loopback on Linux, BlackHole on macOS). + +### CLI Override Summary + +**trx-server:** +`--config`, `--print-config`, `--rig`, `--access`, `--callsign`, `--listen`, +`--port`. SDR options are file-only. + +**trx-client:** +`--config`, `--print-config`, `--url`, `--token`, `--poll-interval`, +`--frontend`, `--http-listen`, `--http-port`, `--rigctl-listen`, +`--rigctl-port`, `--http-json-listen`, `--http-json-port`, `--callsign`. + +--- + +## Authentication + +The HTTP frontend supports optional passphrase-based authentication with two +roles: + +- **rx** — read-only access (monitoring, audio, decode streams) +- **control** — full access (frequency, mode, PTT, and all settings) + +### Configuration + +```toml +[frontends.http.auth] +enabled = false +rx_passphrase = "rx-only-passphrase" +control_passphrase = "full-control-passphrase" +tx_access_control_enabled = true +session_ttl_min = 480 +cookie_secure = false # true if served via HTTPS +cookie_same_site = "Lax" # Strict|Lax|None +``` + +When `enabled = false` (the default), all auth is bypassed and the UI behaves +as before. When enabled, at least one passphrase must be set. + +### Behaviour + +- On login, the server issues an `HttpOnly` session cookie. +- Sessions are in-memory; a server restart invalidates all sessions. +- Rate limiting is applied per IP to mitigate brute-force attempts. +- When `tx_access_control_enabled = true`, TX/PTT controls are hidden and + rejected for unauthenticated or `rx`-role users. + +### Routes + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/auth/login` | POST | Submit `{ "passphrase": "..." }` | +| `/auth/logout` | POST | Clear session | +| `/auth/session` | GET | Check current session/role | + +Protected routes require at least `rx` role. Control routes (set frequency, +mode, PTT, etc.) require `control` role. + +### Frontend Flow + +1. On load, the UI calls `/auth/session`. +2. If unauthenticated, a login screen is shown. +3. On successful login, the normal UI loads. +4. `rx` users see a read-only interface; `control` users get full controls. +5. If a session expires mid-use, streams stop and the login screen returns. + +### Transport Security + +There is no built-in TLS. For remote access, place trx-rs behind a +TLS-terminating reverse proxy (nginx, Caddy) and set `cookie_secure = true`. + +--- + +## Background Decoding Scheduler + +The scheduler automatically retunes the rig to pre-configured bookmarks when no +users are connected to the HTTP frontend. It runs as a background task inside +`trx-frontend-http`, polling every 30 seconds. + +### Modes + +#### Disabled (default) + +Scheduler is inactive. The rig is not touched automatically. + +#### Grayline + +Retunes around the solar terminator (day/night boundary). + +The user provides: +- Station latitude and longitude (decimal degrees) +- Optional transition window width (minutes, default 20) +- Bookmark IDs for four periods: + - **Dawn** — window around sunrise (`sunrise ± window_min/2`) + - **Day** — after dawn until dusk + - **Dusk** — window around sunset (`sunset ± window_min/2`) + - **Night** — after dusk until next dawn + +Period precedence (most specific wins): Dawn > Dusk > Day > Night. + +If no bookmark is assigned to a period, the rig is not retuned for that period. + +Sunrise/sunset is computed inline using the NOAA simplified algorithm. Polar +regions (midnight sun / polar night) fall back to Day/Night accordingly. + +#### TimeSpan + +Retunes according to a list of user-defined time windows (UTC). + +Each entry specifies: +- `start_hhmm` — start of window (e.g. 600 = 06:00 UTC) +- `end_hhmm` — end of window (e.g. 700 = 07:00 UTC) +- `bookmark_id` — bookmark to apply +- `label` — optional human-readable description + +Windows that span midnight (`end_hhmm < start_hhmm`) are supported. When +multiple entries overlap, the first match (by list order) wins. + +### Storage + +Configuration is stored in PickleDB at `~/.config/trx-rs/scheduler.db`. + +Keys: `sch:{rig_id}` → JSON `SchedulerConfig`. + +### HTTP API + +All read endpoints are accessible at the **Rx** role level. Write endpoints +require the **Control** role. + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/scheduler/{rig_id}` | Get scheduler config for a rig | +| PUT | `/scheduler/{rig_id}` | Save scheduler config (Control only) | +| DELETE | `/scheduler/{rig_id}` | Reset config to Disabled (Control only) | +| GET | `/scheduler/{rig_id}/status` | Get last-applied bookmark and next event | + +### Activation Logic + +Every 30 seconds the scheduler task checks: +1. No SSE clients connected +2. Active rig has a non-Disabled scheduler config +3. Current UTC time matches a scheduled window or grayline period +4. If the matching bookmark differs from last applied, send `SetFreq` + `SetMode` + +The scheduler does not revert changes when users reconnect. + +### Web UI + +A dedicated tab with a clock icon provides: +- Rig selector (read-only, shows active rig) +- Mode picker: Disabled / Grayline / TimeSpan +- Grayline section: lat/lon inputs, transition window slider, four bookmark selectors +- TimeSpan section: table of entries with start/end times, bookmark, label +- Status card: last applied bookmark name and timestamp +- Save button (Control role only) + +--- + +## SDR Noise Blanker + +The noise blanker suppresses impulse noise (clicks, pops, ignition interference) +on raw IQ samples before any mixing or filtering takes place. It works by +tracking a running RMS level of the signal and replacing any sample whose +magnitude exceeds **threshold x RMS** with the last known clean sample. + +### Configuration (server-side) + +The noise blanker is configured per rig. In a multi-rig setup each +`[[rigs]]` entry has its own `[rigs.sdr.noise_blanker]` section: + +```toml +[[rigs]] +id = "hf" + +[rigs.rig] +type = "sdr" + +[rigs.sdr.noise_blanker] +enabled = true +threshold = 10.0 # 1 – 100; lower = more aggressive blanking +``` + +For the legacy single-rig (flat) config the path is `[sdr.noise_blanker]`: + +```toml +[sdr.noise_blanker] +enabled = true +threshold = 10.0 +``` + +| Field | Type | Default | Range | Description | +|-------------|-------|---------|---------|-------------| +| `enabled` | bool | false | — | Turn the noise blanker on or off. | +| `threshold` | float | 10.0 | 1 – 100 | Multiplier applied to the running RMS. A sample whose magnitude exceeds this multiple is replaced. Lower values blank more aggressively; higher values only catch strong impulses. | + +The noise blanker is off by default. + +### Choosing a threshold + +The threshold controls how aggressively the blanker suppresses impulses. +A value of **N** means: blank any sample whose magnitude exceeds **N times** +the running average signal level. + +| Threshold | Behavior | Use case | +|-----------|----------|----------| +| 3 – 5 | Very aggressive — blanks frequently | Dense impulse noise (motors, power lines, LED drivers nearby) | +| 8 – 12 | Moderate — catches clear spikes without touching normal signals | Typical HF conditions with occasional ignition or switching noise | +| 15 – 25 | Conservative — only blanks strong impulses well above the noise floor | Light interference, or when you want minimal artifacts on weak signals | +| 30 – 100 | Very light — rarely triggers | Faint, infrequent clicks; mostly a safety net | + +**Start at 10** (the default) and adjust while listening: + +- If impulse noise is still audible, lower the threshold. +- If weak signals sound choppy or distorted, raise it — the blanker may be + mistaking signal peaks for noise. +- On bands with steady atmospheric noise (e.g. 160 m / 80 m), a threshold of + **5 – 8** usually works well. +- On quieter VHF/UHF bands where the noise floor is low, values of **15 – 25** + avoid false triggers from strong signals. + +### Web UI + +When the server reports noise-blanker support, two controls appear in the +**SDR Settings** row of the web interface: + +- **Noise Blanker** checkbox — enables or disables the blanker in real time. +- **NB Threshold** number input (1–100) with a **Set** button — adjusts the + detection threshold. Press Enter or click Set to apply. + +Both controls stay hidden until the server sends filter state containing NB +fields, so they only appear when connected to an SDR backend. + +### HTTP API + +``` +POST /set_sdr_noise_blanker?enabled=true&threshold=10 +``` + +| Parameter | Type | Required | Description | +|-------------|--------|----------|-------------| +| `enabled` | bool | yes | `true` or `false` | +| `threshold` | float | yes | Value between 1 and 100 | + +### How it works + +The blanker runs on every IQ block (4096 samples) *before* the mixer stage in +the DSP pipeline: + +1. For each sample, compute magnitude² (`re² + im²`). +2. Compare against `threshold² × mean_sq` (the exponentially-smoothed running + mean of magnitude²). +3. If the sample exceeds the threshold, replace it with the previous clean + sample. +4. Otherwise, update the running mean with smoothing factor α = 1/128 and store + the sample as the last clean value. + +Because the blanker operates on raw IQ before frequency translation, it removes +impulse noise across the entire captured bandwidth regardless of the tuned +channel offset.