diff --git a/OPTIMIZATION.md b/OPTIMIZATION.md deleted file mode 100644 index 30146b5..0000000 --- a/OPTIMIZATION.md +++ /dev/null @@ -1,175 +0,0 @@ -# DSP Chain Performance 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/OVERVIEW.md b/OVERVIEW.md deleted file mode 100644 index 1651f0c..0000000 --- a/OVERVIEW.md +++ /dev/null @@ -1,1036 +0,0 @@ -# trx-rs Code Design & Architecture Overview - -## 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) -├── CLAUDE.md # Contributor notes -│ -└── 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, -} -``` - -**Known limitation:** IQ sample streaming (`real_iq_source.rs:149–157`) is not yet implemented — the IQ source currently returns zero buffers. The soapysdr 0.3 crate lacks streaming APIs; direct `soapysdr-sys` FFI or a crate upgrade would be required. - ---- - -## 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 | - -`/status` response includes a `FrontendMeta` block: - -```rust -struct FrontendMeta { - http_clients: usize, - rigctl_clients: usize, - rigctl_addr: Option, - active_rig_id: Option, - rig_ids: Vec, - owner_callsign: Option, - show_sdr_gain_control: bool, -} -``` - -**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). - -**Modules:** - -| File | Responsibility | -|------|---------------| -| `server.rs` | Actix app builder, middleware, CORS | -| `api.rs` | REST handler functions | -| `audio.rs` | WebSocket ↔ PCM audio bridge | -| `auth.rs` | Token or basic-auth middleware | -| `status.rs` | State formatting for JSON responses | - -### 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 µs EU / 75 µs 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 -``` - ---- - -*Generated from source as of commit `56d6d12` (March 2026).* diff --git a/README.md b/README.md index 408b09b..99bd1cf 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,8 @@ See [`examples/trx-plugin-example/README.md`](examples/trx-plugin-example/README ## Documentation -- [`OVERVIEW.md`](OVERVIEW.md): architecture and design overview +- [User Manual](docs/MANUAL.md): configuration, features, and usage +- [Architecture](docs/ARCHITECTURE.md): system design, crate layout, data flow, and internals - [`CONTRIBUTING.md`](CONTRIBUTING.md): contribution and commit rules ## Project Status diff --git a/RECORDER.md b/RECORDER.md deleted file mode 100644 index 3e972f1..0000000 --- a/RECORDER.md +++ /dev/null @@ -1,227 +0,0 @@ -# Recorder Feature Plan - -## Overview - -This document describes the design and implementation plan for the recorder feature in trx-rs. 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. diff --git a/SCHEDULER.md b/SCHEDULER.md deleted file mode 100644 index 503e03a..0000000 --- a/SCHEDULER.md +++ /dev/null @@ -1,119 +0,0 @@ -# Background Decoding Scheduler - -## Overview - -The Background Decoding Scheduler automatically retunes the rig to pre-configured -bookmarks when no users are connected to the HTTP frontend. It runs as a background -tokio task inside `trx-frontend-http`, polling every 30 seconds. - -## Modes - -### Disabled (default) -Scheduler is inactive. 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. `context.sse_clients.load() == 0` — no users 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. Bookmarks serve as -a frequency map — the user can retune manually after connecting. - -## Data model (Rust) - -```rust -pub enum SchedulerMode { Disabled, Grayline, TimeSpan } - -pub struct GraylineConfig { - pub lat: f64, - pub lon: f64, - pub transition_window_min: u32, - pub day_bookmark_id: Option, - pub night_bookmark_id: Option, - pub dawn_bookmark_id: Option, - pub dusk_bookmark_id: Option, -} - -pub struct ScheduleEntry { - pub id: String, - pub start_hhmm: u32, - pub end_hhmm: u32, - pub bookmark_id: String, - pub label: Option, -} - -pub struct SchedulerConfig { - pub rig_id: String, - pub mode: SchedulerMode, - pub grayline: Option, - pub entries: Vec, -} -``` - -## UI (Scheduler tab) - -A dedicated sixth tab with a clock icon. - -- **Rig selector**: shows active rig (read-only). -- **Mode picker**: Disabled / Grayline / TimeSpan radio buttons. -- **Grayline section** (visible when mode = Grayline): - - Lat/lon inputs - - Transition window slider (5–60 min) - - Four bookmark selectors (Dawn / Day / Dusk / Night) -- **TimeSpan section** (visible when mode = TimeSpan): - - Table of entries with Start, End, Bookmark, Label, Remove button - - "Add Entry" row at the bottom -- **Status card**: last applied bookmark name and timestamp. -- Save button (Control only; form is read-only for Rx users). diff --git a/aidocs/AGENTS.md b/aidocs/AGENTS.md deleted file mode 100644 index 22b0595..0000000 --- a/aidocs/AGENTS.md +++ /dev/null @@ -1,43 +0,0 @@ -# Repository Guidelines - -## Project Structure & Module Organization -- Workspace root contains `Cargo.toml`, `README.md`, and contributor docs. -- Core crates live under `src/`: `src/trx-core`, `src/trx-server`, and `src/trx-client`. -- Server backends are under `src/trx-server/trx-backend` (example: `trx-backend-ft817`). -- Client frontends are under `src/trx-client/trx-frontend` (HTTP, JSON, rigctl). -- Examples live in `examples/` and static assets in `assets/`. -- Reference configs are `trx-server.toml.example` and `trx-client.toml.example`. - -## Build, Test, and Development Commands -- `cargo build --release` builds optimized binaries. -- `cargo test` runs the workspace test suite. -- `cargo clippy` runs lint checks. -- Example server run (release build): `./target/release/trx-server -r ft817 "/dev/ttyUSB0 9600"`. - -## Coding Style & Naming Conventions -- Rust standard style: 4-space indentation and rustfmt-compatible formatting. -- Naming: `snake_case` for modules/functions, `CamelCase` for types/traits, `SCREAMING_SNAKE_CASE` for constants. -- Prefer small, crate-focused commits; keep changes localized to the relevant crate. - -## Testing Guidelines -- Tests are run via `cargo test` across the workspace. -- Add tests near the code they cover (module-level unit tests are preferred). -- If you change behavior in a crate, add or update tests in that crate. - -## Commit & Pull Request Guidelines -- Commit title format: `[](): ` (example: `[fix](trx-frontend-http): handle disconnect`). -- Use `(trx-rs)` for repo-wide changes that are not specific to any crate. -- Allowed types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`. -- Use imperative mood, keep lines under 80 chars, and separate body with a blank line. -- Sign commits with `git commit -s` and include `Co-authored-by:` for LLM assistance. -- Write isolated commits for each crate. -- Pull requests should include a clear summary, test status, and note any config or runtime changes. - -## Contribution Workflow -- Fork the repository and create a new branch for your changes. -- Follow the project's coding style and conventions. -- Ensure changes are tested and pass existing tests. - -## Configuration & Plugins -- Configs use TOML. See the example files for required sections and defaults. -- Plugins can be loaded from `./plugins`, `~/.config/trx-rs/plugins`, or `TRX_PLUGIN_DIRS`. diff --git a/aidocs/AUTH.md b/aidocs/AUTH.md deleted file mode 100644 index 132adb0..0000000 --- a/aidocs/AUTH.md +++ /dev/null @@ -1,190 +0,0 @@ -# HTTP Frontend Authentication Draft - -## Goal -Add optional passphrase authentication for `trx-frontend-http` with two roles: -- `rx` passphrase: read-only access -- `control` passphrase: read + control (RX+TX) - -API/control routes stay locked until a user logs in from the web UI. - -This design keeps current behavior when auth is disabled. - -## Scope -- Protect HTTP API endpoints used by the web UI. -- Protect SSE (`/events`, `/decode`) and audio WebSocket (`/audio`). -- Keep static assets and login page accessible so user can authenticate. -- Do not change rigctl/http_json auth behavior in this draft. - -## Security Model -- Two optional passphrases configured locally (`rx`, `control`). -- On successful login, server issues short-lived session cookie. -- Session required for all protected routes, with role attached. -- Brute-force mitigation via simple per-IP rate limiting. -- TX access can be globally hidden/blocked unless `control` role is present. - -This is not multi-user IAM; it is a pragmatic local/ham-shack gate. - -## Config Proposal -Add to `trx-client.toml`: - -```toml -[frontends.http.auth] -enabled = false -# Plaintext passphrases (as requested) -rx_passphrase = "rx-only-passphrase" -control_passphrase = "full-control-passphrase" - -# If true, TX/PTT controls/endpoints are never available without control auth. -tx_access_control_enabled = true - -# Session lifetime in minutes -session_ttl_min = 480 - -# Cookie security -cookie_secure = false # true if served via HTTPS -cookie_same_site = "Lax" # Strict|Lax|None -``` - -Validation rules: -- If `enabled=false`, all auth fields ignored. -- If `enabled=true`, require at least one passphrase (`rx` and/or `control`). -- `rx_passphrase` only: read-only deployment. -- `control_passphrase` only: control-capable deployment. -- both set: mixed deployment with role split. - -Behavior by mode: -- `enabled=false` (default): no authentication, current behavior unchanged. -- `enabled=true`: authentication enforced per role/route rules in this document. - -## Runtime Structures -Add in `src/trx-client/trx-frontend/src/lib.rs` (or HTTP crate-local state): -- `HttpAuthConfig`: - - `enabled: bool` - - `rx_passphrase: Option` - - `control_passphrase: Option` - - `tx_access_control_enabled: bool` - - `session_ttl: Duration` - - `cookie_secure: bool` - - `same_site: SameSite` -- `SessionStore` in-memory map: - - key: random session id (128-bit+) - - value: `{ role, issued_at, expires_at, last_seen, ip_hash? }` - -Role enum: -- `AuthRole::Rx` -- `AuthRole::Control` - -Periodic cleanup task (e.g., every 5 min) removes expired sessions. - -## Route Design -New endpoints: -- `POST /auth/login` - - body: `{ "passphrase": "..." }` - - server checks passphrase against `control` first, then `rx` - - on success: set `HttpOnly` cookie `trx_http_sid`, return `{ role: "rx"|"control" }` - - on failure: 401 generic error -- `POST /auth/logout` - - clears cookie and invalidates server session -- `GET /auth/session` - - returns `{ authenticated: true|false, role?: "rx"|"control" }` - -Protected existing endpoints: -- Control APIs (`control` role required): `/set_freq`, `/set_mode`, `/set_ptt`, `/toggle_power`, `/toggle_vfo`, `/lock`, `/unlock`, `/set_tx_limit`, `/toggle_*_decode`, `/clear_*_decode`, CW tuning endpoints, etc. -- Read APIs (`rx` or `control`): `/status`, `/events`, `/decode`, `/audio` - -TX/PTT hard-gate behavior when `tx_access_control_enabled=true`: -- Do not render TX/PTT controls for unauthenticated or `rx` role. -- Reject TX/PTT and mutating control endpoints unless role is `control`. -- Prefer returning `404` for hidden TX/PTT endpoints to avoid capability leakage - (or `403` if explicit error semantics are preferred). - -Public endpoints: -- `/` (HTML shell) -- static assets (`/style.css`, `/app.js`, plugin js, logo, favicon) -- `/auth/*` - -## Middleware Behavior -Implement Actix middleware/wrap fn in `trx-frontend-http`: -- Resolve session from cookie. -- Validate in store and expiry. -- If missing/invalid: - - API routes: return `401` JSON/text - - SSE/WS routes: return `401` -- If valid: - - enforce route role (`rx` or `control`) - - return `403` when authenticated but role is insufficient - - continue request - - optionally slide expiry (`last_seen + ttl`) with cap. - -Keep middleware route-aware by checking request path against allowlist. - -## Passphrase Handling -- Use exact passphrase comparison against config values (no hash layer in this draft). -- Still use constant-time string comparison helper to reduce timing leakage. -- Keep passphrases out of logs and API responses. - -## Cookie Settings -Session cookie: -- `HttpOnly=true` -- `Secure` configurable (true for TLS) -- `SameSite=Lax` default -- `Path=/` -- Max-Age = session TTL - -## Frontend Flow -In `assets/web/app.js`: -1. On startup call `/auth/session`. -2. If unauthenticated, show blocking screen with logo + `Access denied`. -3. Submit to `/auth/login`. -4. On success initialize normal app flow (`connect()`, decode stream). -5. If role is `rx`, disable/hide all TX/PTT/mutating controls. -6. If role is `control`, enable full UI. -7. If protected call returns 401/403, stop streams and return to login panel. -8. Add logout button in About tab or header. - -UI minimal requirement: -- Default unauthenticated view: logo + `Access denied` + passphrase field + login button. -- Generic error message on failure. -- No passphrase persistence in localStorage. - -## Implementation Steps -1. Extend client config structs + parser defaults. -2. Build auth state (passphrases + session store) in HTTP server startup. -3. Add `/auth/login`, `/auth/logout`, `/auth/session` handlers. -4. Add middleware and protect selected routes. -5. Update frontend JS with login gate and 401 handling. -6. Add docs to `README.md` + `trx-client.toml.example`. -7. Add role matrix tests and frontend role UI handling. - -## Test Plan -Unit tests: -- Config validation combinations. -- Login success/failure. -- Session expiry. -- Middleware path allowlist/protection. -- Role enforcement (`rx` denied on control routes). -- TX visibility policy (`tx_access_control_enabled`) endpoint behavior. - -Integration tests (Actix test server): -- Unauthed call to `/set_freq` -> 401. -- `rx` login -> cookie set -> `/status` accepted, `/set_freq` -> 403. -- `control` login -> `/set_freq` accepted. -- With `tx_access_control_enabled=true`, unauth/`rx` cannot use `/set_ptt`. -- Expired session -> 401. -- `/events` and `/audio` reject unauthenticated clients. - -Manual checks: -- Browser login works. -- WSJT-X/hamlib unaffected (non-http frontends). -- Auth disabled mode behaves exactly as before. - -## Operational Notes -- This is in-memory session state. Restart invalidates sessions. -- For reverse proxy deployments, use TLS and set `cookie_secure=true`. -- If remote exposure is possible, use strong passphrase and firewall. - -## Future Extensions -- Optional API bearer token for automation scripts. -- Optional migration to hashed passphrases if threat model increases. -- Persistent sessions with signed tokens/JWT (if needed). -- Optional TOTP second factor for internet-exposed deployments. diff --git a/aidocs/CONFIGURATION.md b/aidocs/CONFIGURATION.md deleted file mode 100644 index f318117..0000000 --- a/aidocs/CONFIGURATION.md +++ /dev/null @@ -1,231 +0,0 @@ -# Configuration - -This document lists all currently supported configuration options for `trx-server` and `trx-client`. - -## File Locations - -### `trx-server` -Configuration 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` -Configuration lookup order: -1. `--config ` -2. `./trx-client.toml` -3. `~/.config/trx-rs/client.toml` -4. `/etc/trx-rs/client.toml` - -CLI options override file values. - -## Environment Variables - -- `TRX_PLUGIN_DIRS`: additional plugin directories (path-separated), used by both server and client. - -## `trx-server` Options - -### `[general]` -- `callsign` (`string`, default: `"N0CALL"`) -- `log_level` (`string`, optional): one of `trace|debug|info|warn|error` -- `latitude` (`float`, optional): `-90..=90` -- `longitude` (`float`, optional): `-180..=180` - -Notes: -- `latitude` and `longitude` must be set together or both omitted. - -### `[rig]` -- `model` (`string`, required effectively unless provided by CLI `--rig`) -- `initial_freq_hz` (`u64`, default: `144300000`, must be `> 0`) -- `initial_mode` (`string`, default: `"USB"`): one of `LSB|USB|CW|CWR|AM|WFM|FM|DIG|PKT` - -### `[rig.access]` -- `type` (`string`, default behavior: `serial` if omitted): `serial|tcp|sdr` -- Serial mode: - - `port` (`string`) - - `baud` (`u32`) -- TCP mode: - - `host` (`string`) - - `tcp_port` (`u16`) -- SDR mode: - - `args` (`string`, required when `type = "sdr"`): SoapySDR device args string (e.g. `"driver=rtlsdr"` or `"driver=airspy,serial=00000001"`). Passed verbatim to `SoapySDR::Device::new()`. - -Notes: -- For `serial`, both `port` and `baud` are required. -- For `tcp`, both `host` and `tcp_port` are required. -- For `sdr`, `args` must be non-empty. The `port`, `baud`, `host`, and `tcp_port` fields are ignored. - -### `[behavior]` -- `poll_interval_ms` (`u64`, default: `500`, must be `> 0`) -- `poll_interval_tx_ms` (`u64`, default: `100`, must be `> 0`) -- `max_retries` (`u32`, default: `3`, must be `> 0`) -- `retry_base_delay_ms` (`u64`, default: `100`, must be `> 0`) - -### `[listen]` -- `enabled` (`bool`, default: `true`) -- `listen` (`ip`, default: `127.0.0.1`) -- `port` (`u16`, default: `4530`, must be `> 0` when enabled) - -### `[listen.auth]` -- `tokens` (`string[]`, default: `[]`) - -Notes: -- Empty token strings are invalid. -- Empty list means no auth required. - -### `[audio]` -- `enabled` (`bool`, default: `true`) -- `listen` (`ip`, default: `127.0.0.1`) -- `port` (`u16`, default: `4531`, must be `> 0` when enabled) -- `rx_enabled` (`bool`, default: `true`) -- `tx_enabled` (`bool`, default: `true`) -- `device` (`string`, optional) -- `sample_rate` (`u32`, default: `48000`, valid: `8000..=192000`) -- `channels` (`u8`, default: `1`, valid: `1|2`) -- `frame_duration_ms` (`u16`, default: `20`, valid: `3|5|10|20|40|60`) -- `bitrate_bps` (`u32`, default: `24000`, must be `> 0`) - -Notes: -- When `[audio].enabled = true`, at least one of `rx_enabled` or `tx_enabled` must be true. - -### `[pskreporter]` -- `enabled` (`bool`, default: `false`) -- `host` (`string`, default: `"report.pskreporter.info"`, must not be empty when enabled) -- `port` (`u16`, default: `4739`, must be `> 0` when enabled) -- `receiver_locator` (`string`, optional) - -Notes: -- If `receiver_locator` is omitted, server tries deriving it from `[general].latitude`/`longitude`. -- PSK Reporter software ID is hardcoded to: `trx-server v by SP2SJG`. - -### `[aprsfi]` -- `enabled` (`bool`, default: `false`) -- `host` (`string`, default: `"rotate.aprs.net"`, must not be empty when enabled) -- `port` (`u16`, default: `14580`, must be `> 0` when enabled) -- `passcode` (`i32`, default: `-1`) - -Notes: -- When `passcode = -1` (the default), the passcode is auto-computed from `[general].callsign` using the standard APRS-IS hash algorithm. -- `[general].callsign` must be non-empty when `[aprsfi].enabled = true`; otherwise the IGate is silently disabled at startup. -- Only APRS packets with valid CRC are forwarded; packets from other decoders (FT8, WSPR, CW) are ignored. -- The IGate reconnects automatically with exponential backoff (1 s → 2 s → … → 60 s) on TCP errors. -- Requires `[audio].enabled = true` (APRS packets are decoded from audio). - -### `[sdr]` -- `sample_rate` (`u32`, default: `1920000`, must be `> 0`): IQ capture rate in Hz. Must be supported by the device. -- `bandwidth` (`u32`, default: `1500000`): Hardware IF filter bandwidth in Hz. -- `center_offset_hz` (`i64`, default: `100000`): The SDR tunes this many Hz below the dial frequency to keep the signal off the DC spur. Negative values tune above. - -### `[sdr.gain]` -- `mode` (`string`, default: `"auto"`): `"auto"` enables hardware AGC (falls back to `"manual"` with a warning if the device does not support it); `"manual"` uses the fixed `value`. -- `value` (`f64`, default: `30.0`): Gain in dB. Used only when `mode = "manual"`. - -### `[sdr.squelch]` -- `enabled` (`bool`, default: `false`): Enables virtual software squelch for demodulated audio except WFM on the primary SDR channel. -- `threshold_db` (`f32`, default: `-65.0`, valid: `-140..=0`): Open threshold in dBFS. -- `hysteresis_db` (`f32`, default: `3.0`, valid: `0..=40`): Close hysteresis in dB. -- `tail_ms` (`u32`, default: `180`, valid: `0..=10000`): Tail hold time after signal drops below threshold. - -### `[[sdr.channels]]` - -Defines one virtual receiver channel within the wideband IQ stream. At least one channel is required when using the `soapysdr` backend. The **first** channel in the list is the *primary* channel: `set_freq` and `set_mode` from rig control apply to it, and `get_status` reads from it. - -- `id` (`string`, default: `""`): Human-readable label used in logs. -- `offset_hz` (`i64`, default: `0`): Frequency offset from the dial frequency in Hz. Primary channel should be `0`. -- `mode` (`string`, default: `"auto"`): Demodulation mode. `"auto"` follows the RigCat `set_mode` command; or a fixed mode string: `LSB`, `USB`, `CW`, `CWR`, `AM`, `WFM`, `FM`, `DIG`, `PKT`. -- `audio_bandwidth_hz` (`u32`, default: `3000`): One-sided bandwidth of the post-demodulation audio BPF in Hz. -- `fir_taps` (`usize`, default: `64`): FIR filter tap count. Higher values give sharper roll-off at the cost of latency. -- `cw_center_hz` (`u32`, default: `700`): CW tone centre frequency in the audio domain (Hz). -- `wfm_bandwidth_hz` (`u32`, default: `75000`): Pre-demodulation filter bandwidth for WFM only (Hz). -- `decoders` (`string[]`, default: `[]`): Decoder IDs that receive this channel's PCM audio. Valid values: `"ft8"`, `"wspr"`, `"aprs"`, `"cw"`. Each decoder ID may appear in at most one channel. -- `stream_opus` (`bool`, default: `false`): Encode this channel's audio as Opus and stream to clients over the TCP audio port. At most one channel may set this to `true`. - -Notes: -- Requires `libSoapySDR` installed (`brew install soapysdr` on macOS; `libsoapysdr-dev` on Debian/Ubuntu). -- The SDR backend is RX-only. `[audio] tx_enabled` must be `false`. -- Channel IF constraint: `|center_offset_hz + offset_hz| < sample_rate / 2` for every channel; violated channels cause a startup error. -- `[audio] sample_rate` must match the output audio rate of the SDR pipeline (48000 Hz recommended). -- Use `trx-server --print-config` to see all defaults. SDR fields appear only if the binary was built with `--features soapysdr`. - -### `[decode_logs]` -- `enabled` (`bool`, default: `false`) -- `dir` (`string`, default: `"$XDG_DATA_HOME/trx-rs/decoders"`; fallback: `"logs/decoders"`, must not be empty when enabled) -- `aprs_file` (`string`, default: `"TRXRS-APRS-%YYYY%-%MM%-%DD%.log"`, must not be empty when enabled) -- `cw_file` (`string`, default: `"TRXRS-CW-%YYYY%-%MM%-%DD%.log"`, must not be empty when enabled) -- `ft8_file` (`string`, default: `"TRXRS-FT8-%YYYY%-%MM%-%DD%.log"`, must not be empty when enabled) -- `wspr_file` (`string`, default: `"TRXRS-WSPR-%YYYY%-%MM%-%DD%.log"`, must not be empty when enabled) - -Notes: -- Decoder logs are server-side and split by decoder name (APRS/CW/FT8/WSPR). -- Files are appended in JSON Lines format (one JSON object per line). -- Supported filename date tokens: `%YYYY%`, `%MM%`, `%DD%` (UTC date). - -## `trx-client` Options - -### `[general]` -- `callsign` (`string`, default: `"N0CALL"`) -- `log_level` (`string`, optional): one of `trace|debug|info|warn|error` - -### `[remote]` -- `url` (`string`, optional in file but required at runtime unless provided by CLI `--url`) -- `poll_interval_ms` (`u64`, default: `750`, must be `> 0`) - -### `[remote.auth]` -- `token` (`string`, optional) - -Notes: -- If provided, token must not be empty/whitespace. - -### `[frontends.http]` -- `enabled` (`bool`, default: `true`) -- `listen` (`ip`, default: `127.0.0.1`) -- `port` (`u16`, default: `8080`, must be `> 0` when enabled) - -### `[frontends.rigctl]` -- `enabled` (`bool`, default: `false`) -- `listen` (`ip`, default: `127.0.0.1`) -- `port` (`u16`, default: `4532`, must be `> 0` when enabled) - -### `[frontends.http_json]` -- `enabled` (`bool`, default: `true`) -- `listen` (`ip`, default: `127.0.0.1`) -- `port` (`u16`, default: `0`) -- `auth.tokens` (`string[]`, default: `[]`) - -Notes: -- `port = 0` means ephemeral bind (allowed). -- Empty token strings are invalid. - -### `[frontends.audio]` -- `enabled` (`bool`, default: `true`) -- `server_port` (`u16`, default: `4531`, must be `> 0` when enabled) -- `bridge.enabled` (`bool`, default: `false`): enables local `cpal` audio bridge -- `bridge.rx_output_device` (`string`, optional): exact local playback device name -- `bridge.tx_input_device` (`string`, optional): exact local capture device name -- `bridge.rx_gain` (`float`, default: `1.0`, must be finite and `>= 0`) -- `bridge.tx_gain` (`float`, default: `1.0`, must be finite and `>= 0`) - -Notes: -- The bridge is intended for local WSJT-X integration via virtual audio devices. -- Linux: typically use ALSA loopback (`snd-aloop`). -- macOS: install a virtual CoreAudio device (e.g. BlackHole), then set device names above. - -## CLI Override Summary - -### `trx-server` -- `--config`, `--print-config` -- `--rig`, `--access`, positional `RIG_ADDR` -- `--callsign` -- `--listen`, `--port` (JSON listener) -- SDR backend: all SDR options are file-only (`[sdr]` and `[[sdr.channels]]`). - -### `trx-client` -- `--config`, `--print-config` -- `--url`, `--token`, `--poll-interval` -- `--frontend` (comma-separated) -- `--http-listen`, `--http-port` -- `--rigctl-listen`, `--rigctl-port` -- `--http-json-listen`, `--http-json-port` -- `--callsign` diff --git a/aidocs/ENHANCEMENT.md b/aidocs/ENHANCEMENT.md deleted file mode 100644 index 1512f38..0000000 --- a/aidocs/ENHANCEMENT.md +++ /dev/null @@ -1,69 +0,0 @@ -# Top 5 Real Architecture Issues (Post-Refactor) - -## 1) Plugin ABI is still brittle and unversioned -### Files -- `src/trx-app/src/plugins.rs` -- `examples/trx-plugin-example/src/lib.rs` - -### Why this matters -Plugin loading is now explicit (good), but still assumes exact symbol names and raw FFI contracts with no ABI/version handshake. A plugin built against an older/newer ABI can fail at runtime in hard-to-diagnose ways. - -### Fix steps -1. Add an ABI version symbol/handshake (`trx_plugin_abi_version`) and reject incompatible plugins with clear errors. -2. Split plugin capability metadata (backend/frontend/both) from registration symbols to avoid noisy failed-load logs. -3. Provide a tiny shared plugin-API crate for stable entrypoint signatures. - -## 2) Runtime supervision is still ad-hoc (sleep + abort) -### Files -- `src/trx-server/src/main.rs` -- `src/trx-client/src/main.rs` - -### Why this matters -Shutdown is coordinated, but supervision still uses a fixed delay plus manual `abort()` over `Vec>`. This can mask task failures, race shutdown ordering, and make lifecycle behavior harder to reason about. - -### Fix steps -1. Move to `JoinSet` (or a small supervisor type) for task ownership and result handling. -2. Replace fixed sleep with bounded graceful-join timeout logic. -3. Surface task failure reasons consistently in one place. - -## 3) JSON/TCP transport logic is duplicated across modules -### Files -- `src/trx-server/src/listener.rs` -- `src/trx-client/trx-frontend/trx-frontend-http-json/src/server.rs` -- `src/trx-client/src/remote_client.rs` - -### Why this matters -`read_limited_line`, timeout handling, and response write patterns are repeated in multiple places. This increases drift risk and makes protocol hardening changes expensive. - -### Fix steps -1. Extract shared JSON-over-TCP helpers into `trx-protocol` (or a small transport crate/module). -2. Keep one source of truth for max line size, timeout behavior, and framing errors. -3. Cover shared transport with focused tests once instead of per-module copies. - -## 4) Boundary tests are present but mostly ignored in constrained envs -### Files -- `src/trx-server/src/listener.rs` -- `src/trx-client/src/remote_client.rs` -- `src/trx-client/trx-frontend/trx-frontend-http-json/src/server.rs` - -### Why this matters -Important network-path tests exist, but are marked `#[ignore]` in this environment due bind restrictions. Without a clear CI strategy, regressions can still slip through. - -### Fix steps -1. Add CI jobs/environment where bind-based tests run by default. -2. Split pure transport logic from socket bind/accept so more behavior can be tested without real sockets. -3. Keep ignored tests minimal and document how/when they run. - -## 5) Decode/history shared state still relies on global mutexes -### Files -- `src/trx-server/src/audio.rs` -- `src/trx-client/trx-frontend/src/lib.rs` -- `src/trx-client/trx-frontend/trx-frontend-http/src/audio.rs` - -### Why this matters -History/state paths still use shared mutex-backed globals/contexts with `expect` on lock poisoning in hot paths. This is workable but fragile for long-running async services. - -### Fix steps -1. Replace panic-on-poison lock usage with resilient handling. -2. Consider bounded channel or lock-free append/read model for decode history. -3. Define explicit ownership/lifetime for history data instead of implicit shared mutation. diff --git a/aidocs/MULTI.md b/aidocs/MULTI.md deleted file mode 100644 index 04229b1..0000000 --- a/aidocs/MULTI.md +++ /dev/null @@ -1,156 +0,0 @@ -# Multi-Rig Support - -This document specifies the requirements for running N simultaneous rig backends in one `trx-server` process and the protocol/config changes required to support them. - ---- - -## Progress - -> **For AI agents:** This section is the single source of truth for implementation status. -> Each task has a unique ID (e.g. `MR-01`), a status badge, a description, the files it touches, and any blocking dependencies. -> -> Status legend: `[ ]` not started · `[~]` in progress · `[x]` done · `[!]` blocked - -### Foundational (parallel) - -| ID | Status | Task | Files | Needs | -|----|--------|------|-------|-------| -| MR-01 | `[x]` | Add `rig_id: Option` to `ClientEnvelope`; add `rig_id: Option` to `ClientResponse`; add `ClientCommand::GetRigs`; add `GetRigsResponseBody` + `RigEntry`; add sentinel arm in `mapping.rs` | `src/trx-protocol/src/types.rs`, `mapping.rs`, `lib.rs` | — | -| MR-02 | `[x]` | Add `RigInstanceConfig`; add `rigs: Vec` to `ServerConfig`; implement `resolved_rigs()`; extend `validate()` for unique IDs + unique audio ports | `src/trx-server/src/config.rs` | — | -| MR-03 | `[x]` | Remove four `OnceLock` statics from `audio.rs`; add `DecoderHistories { aprs, ft8, wspr }` struct + `new()`; convert history free-fns to take `&DecoderHistories`; update decoder task signatures + `run_audio_listener` | `src/trx-server/src/audio.rs` | — | -| MR-04 | `[x]` | Create `src/trx-server/src/rig_handle.rs` with `RigHandle { rig_id, rig_tx, state_rx }`; declare mod in `main.rs` | `src/trx-server/src/rig_handle.rs`, `main.rs` | — | - -### Sequential - -| ID | Status | Task | Files | Needs | -|----|--------|------|-------|-------| -| MR-05 | `[x]` | Add `rig_id: String` + `histories: Arc` to `RigTaskConfig`; fix `clear_*_history` calls in `process_command` | `src/trx-server/src/rig_task.rs` | MR-03 | -| MR-06 | `[x]` | Rewrite `run_listener` to take `Arc>` + `default_rig_id`; route by `envelope.rig_id`; add `GetRigs` fast path; populate `rig_id` in every `ClientResponse` | `src/trx-server/src/listener.rs` | MR-01, MR-04 | -| MR-07 | `[x]` | Rewrite `main.rs` spawn loop over `resolved_rigs()`; extract `spawn_rig_audio_stack()`; per-rig pskreporter + aprsfi; build `HashMap`; pass to `run_listener` | `src/trx-server/src/main.rs` | MR-02–06 | - -### Tests - -| ID | Status | Task | Files | Needs | -|----|--------|------|-------|-------| -| MR-08 | `[x]` | Config tests: `resolved_rigs()` with multi-rig TOML and legacy TOML; duplicate ID/port rejection | `src/trx-server/src/config.rs` | MR-02 | -| MR-09 | `[x]` | Protocol tests: `ClientEnvelope` absent `rig_id` parses; `rig_id` in responses; `GetRigs` round-trip; existing tests still pass | `src/trx-protocol/src/codec.rs` | MR-01 | - ---- - -## Goals - -- Run N simultaneous rig backends (SDR, transceivers, or any mix) in one server process -- Route control commands to the correct rig via `rig_id` in the JSON protocol -- Backward compatibility: single-rig configs (`[rig]`/`[audio]` at top level) continue to work unchanged -- Per-rig audio streaming on separate TCP ports -- New `GetRigs` command to enumerate all connected rigs and their states - ---- - -## Non-Goals - -- Load-balancing or failover between rigs -- Sharing a single audio port across multiple rigs (each rig keeps its own port) - ---- - -## Architecture - -``` -Single [listen] port (4530) - └─ listener.rs: Arc> - ├─ route by envelope.rig_id (absent → first rig, backward compat) - └─ GetRigs → aggregate all states - -Per-rig: - rig_task ←→ RigHandle (rig_tx + state_rx) - audio capture → pcm_tx → decoder tasks → decode_tx - run_audio_listener (own TCP port per rig) - pskreporter + aprsfi tasks -``` - ---- - -## TOML Format - -### Multi-rig (`[[rigs]]` array) - -```toml -[general] -callsign = "W1AW" - -[listen] -port = 4530 - -[[rigs]] -id = "hf" -[rigs.rig] -model = "ft450d" -initial_freq_hz = 14074000 -[rigs.rig.access] -type = "serial" -port = "/dev/ttyUSB0" -baud = 9600 -[rigs.audio] -port = 4531 - -[[rigs]] -id = "sdr" -[rigs.rig] -model = "soapysdr" -[rigs.rig.access] -type = "sdr" -args = "driver=rtlsdr" -[rigs.audio] -port = 4532 -[rigs.sdr] -sample_rate = 1920000 -``` - -### Legacy (flat `[rig]` + `[audio]`) — continues to work unchanged - -```toml -[rig] -model = "ft817" -[rig.access] -type = "serial" -port = "/dev/ttyUSB0" -baud = 9600 -[audio] -port = 4531 -``` - -Legacy configs are synthesised into a single-element `[[rigs]]` list with `id = "default"` via `resolved_rigs()`. - ---- - -## Protocol Wire Format - -Request (`rig_id` optional; absent = first rig): -```json -{"rig_id": "hf", "cmd": "set_freq", "freq_hz": 14074000} -{"cmd": "get_state"} -``` - -Response (`rig_id` always present): -```json -{"success": true, "rig_id": "hf", "state": {...}} -{"success": false, "rig_id": "default", "error": "Unknown rig_id: xyz"} -``` - -`GetRigs` response: -```json -{"success": true, "rig_id": "server", "rigs": [ - {"rig_id": "hf", "state": {...}}, - {"rig_id": "sdr", "state": {...}} -]} -``` - ---- - -## Validation Rules (startup) - -- When `[[rigs]]` is non-empty: each `id` must be unique (case-sensitive). -- When `[[rigs]]` is non-empty: each `audio.port` must be unique. -- When `[[rigs]]` is empty: legacy flat fields are used with `id = "default"`. -- Mixing `[[rigs]]` and legacy flat `[rig]`/`[audio]` is undefined; `[[rigs]]` takes precedence. diff --git a/aidocs/OVERVIEW.md b/aidocs/OVERVIEW.md deleted file mode 100644 index 6c4407e..0000000 --- a/aidocs/OVERVIEW.md +++ /dev/null @@ -1,326 +0,0 @@ -# trx-rs Project Overview - -## What is trx-rs? - -**trx-rs** is a modular transceiver (radio) control stack written in Rust. It provides a backend service for controlling amateur radio transceivers via CAT (Computer-Aided Transceiver) protocols, with multiple frontend interfaces for access and monitoring. - -### Current Capabilities - -| Feature | Status | -|---------|--------| -| Yaesu FT-817 CAT control | Implemented | -| HTTP/Web UI with SSE | Implemented | -| rigctl-compatible TCP | Implemented | -| VFO A/B switching | Implemented | -| PTT control | Implemented | -| Signal/TX power metering | Implemented | -| Front panel lock | Implemented | -| Multiple rig backends | Extensible (only FT-817) | -| Backend/frontend registry | Implemented | -| TCP CAT transport | Partial (config wiring only) | -| JSON TCP control (line-delimited) | Implemented (configurable frontend) | -| Plugin registry loading | Implemented (shared libraries) | -| Configuration file (TOML) | Implemented | -| Rig state machine | Implemented | -| Command handlers | Implemented | -| Event notifications | Implemented (rig task emits events) | -| Retry/polling policies | Implemented | -| Controller-based rig task | Implemented | - ---- - -## Current Architecture - -``` -┌──────────────────────────────────────────────────────────────────────────┐ -│ trx-server/trx-client │ -│ ┌────────────────────────────────────────────────────────────────────┐ │ -│ │ Application │ │ -│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │ │ -│ │ │ Config │ │ CLI │ │ Rig Task │ │ │ -│ │ │ (TOML file) │ │ (clap) │ │ (main loop) │ │ │ -│ │ └──────────────┘ └──────────────┘ └──────────────────────────┘ │ │ -│ └────────────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ┌───────────────────┴───────────────────┐ │ -│ ▼ ▼ │ -│ ┌─────────────────────┐ ┌─────────────────────┐ │ -│ │ trx-core │ │ Frontend Layer │ │ -│ │ ┌───────────────┐ │ │ ┌───────────────┐ │ │ -│ │ │ controller/ │ │ │ │ HTTP │ │ │ -│ │ │ - machine │ │ │ │ (REST+SSE) │ │ │ -│ │ │ - handlers │ │ │ └───────────────┘ │ │ -│ │ │ - events │ │ │ ┌───────────────┐ │ │ -│ │ │ - policies │ │ │ │ HTTP JSON │ │ │ -│ │ └───────────────┘ │ │ │ (TCP/JSON) │ │ │ -│ └─────────────────────┘ │ └───────────────┘ │ │ -│ │ │ ┌───────────────┐ │ │ -│ │ │ │ rigctl │ │ │ -│ │ │ │ (TCP/hamlib) │ │ │ -│ │ │ └───────────────┘ │ │ -│ │ └─────────────────────┘ │ -│ ▼ │ -│ ┌─────────────────────┐ │ -│ │ trx-backend │ │ -│ │ ┌───────────────┐ │ │ -│ │ │ FT-817 Driver │ │ │ -│ │ └───────────────┘ │ │ -│ └─────────────────────┘ │ -└──────────────────────────────────────────────────────────────────────────┘ -``` - -### Key Components - -| Component | Purpose | -|-----------|---------| -| `trx-core` | Core types, traits (`Rig`, `RigCat`), state definitions, controller components | -| `trx-core/rig/controller` | State machine, command handlers, event system, policies | -| `trx-backend` | Backend factory and abstraction layer | -| `trx-backend-ft817` | FT-817 CAT protocol implementation | -| `trx-frontend` | Frontend trait (`FrontendSpawner`) | -| `trx-frontend-http` | Web UI with REST API and SSE | -| `trx-frontend-http-json` | JSON-over-TCP control frontend | -| `trx-frontend-rigctl` | Hamlib rigctl-compatible TCP interface | -| `trx-server` | Server binary — connects to rig backend, exposes JSON TCP control | -| `trx-client` | Client binary — connects to server, runs frontends (HTTP, rigctl) | - ---- - -## Configuration - -trx-rs supports TOML configuration files with the following search order: - -1. `--config ` (explicit CLI argument) -2. `./trx-server.toml` or `./trx-client.toml` (current directory) -3. `~/.config/trx-rs/config.toml` (XDG user config) -4. `/etc/trx-rs/config.toml` (system-wide) - -CLI arguments override config file values. - -Plugin discovery: -- Uses shared libraries with a `trx_register` entrypoint. -- Searches `./plugins`, `~/.config/trx-rs/plugins`, and any paths in `TRX_PLUGIN_DIRS`. - -### Example Configuration - -```toml -[general] -callsign = "N0CALL" - -[rig] -model = "ft817" -initial_freq_hz = 144300000 -initial_mode = "USB" - -[rig.access] -type = "serial" -port = "/dev/ttyUSB0" -baud = 9600 - -[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 = true -listen = "127.0.0.1" -port = 9000 -auth.tokens = ["demo-token"] - -[behavior] -poll_interval_ms = 500 -poll_interval_tx_ms = 100 -max_retries = 3 -retry_base_delay_ms = 100 -``` - -Use `trx-server --print-config` or `trx-client --print-config` to generate an example configuration. - ---- - -## Rig Controller Components - -Located in `trx-core/src/rig/controller/`: - -### State Machine (`machine.rs`) - -Explicit state machine for rig lifecycle management: - -```rust -pub enum RigMachineState { - Disconnected, - Connecting { started_at: Option }, - Initializing { rig_info: Option }, - PoweredOff { rig_info: RigInfo }, - Ready(ReadyStateData), - Transmitting(TransmittingStateData), - Error { error: RigStateError, previous_state: Box }, -} -``` - -Events trigger state transitions: -- `RigEvent::Connected`, `Initialized`, `PoweredOn`, `PoweredOff` -- `RigEvent::PttOn`, `PttOff` -- `RigEvent::Error(RigStateError)`, `Recovered`, `Disconnected` - -### Command Handlers (`handlers.rs`) - -Trait-based command system with validation: - -```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>>; -} -``` - -Implemented commands: -- `SetFreqCommand`, `SetModeCommand`, `SetPttCommand` -- `PowerOnCommand`, `PowerOffCommand` -- `ToggleVfoCommand`, `LockCommand`, `UnlockCommand` -- `GetTxLimitCommand`, `SetTxLimitCommand`, `GetSnapshotCommand` - -The rig task (`trx-server/src/rig_task.rs`) now syncs the state machine to the live `RigState` -and emits events whenever rig status changes. - -### Event Notifications (`events.rs`) - -Typed event system for rig 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); -} - -pub struct RigEventEmitter { - // Manages listeners and dispatches events -} -``` - -### Policies (`policies.rs`) - -Configurable retry and polling behavior: - -```rust -pub trait RetryPolicy: Send + Sync { - fn should_retry(&self, attempt: u32, error: &RigError) -> bool; - fn delay(&self, attempt: u32) -> Duration; - fn max_attempts(&self) -> u32; -} - -pub trait PollingPolicy: Send + Sync { - fn interval(&self, transmitting: bool) -> Duration; - fn should_poll(&self, transmitting: bool) -> bool; -} -``` - -Implementations: -- `ExponentialBackoff` - Exponential delay with max cap -- `FixedDelay` - Constant delay between retries -- `NoRetry` - Fail immediately -- `AdaptivePolling` - Faster polling during TX -- `FixedPolling` - Constant interval -- `NoPolling` - Disable automatic polling - -### Error Types - -`RigError` now includes error classification: - -```rust -pub struct RigError { - pub message: String, - pub kind: RigErrorKind, // Transient or Permanent -} - -impl RigError { - pub fn timeout() -> Self; // Transient - pub fn communication(msg) -> Self; // Transient - pub fn invalid_state(msg) -> Self; // Permanent - pub fn not_supported(op) -> Self; // Permanent - pub fn is_transient(&self) -> bool; -} -``` - ---- - -## Remaining Improvement Opportunities - -### Integration Work - -1. **Plugin UX improvements** - Add structured plugin metadata (name, version, capabilities) and surface it in CLI help. - -### Testing - -- Add integration tests with mock backends -- Add more backend/frontend unit tests - -### Features - -- Add more rig backends (IC-7300, TS-590, etc.) -- Add TX limit support for FT-817 (or document per-backend constraints in UI) -- Add WebSocket support for bidirectional communication -- Add metrics/telemetry export (Prometheus) -- Add authentication for HTTP frontend - -### Code Quality - -- Add CI/CD pipeline -- Add pre-commit hooks - ---- - -## Implementation Status - -| Component | Status | Tests | -|-----------|--------|-------| -| State Machine | Implemented | 5 tests | -| Command Handlers | Implemented | 3 tests | -| Event Notifications | Implemented | 2 tests | -| Retry/Polling Policies | Implemented | 5 tests | -| Config File Support | Implemented | 4 tests | -| rigctl Frontend | Implemented | - | -| HTTP Frontend | Implemented | - | -| FT-817 Backend | Implemented | - | - -**Total: 19 unit tests passing** - ---- - -## Building and Running - -```bash -# Build -cargo build --release - -# Run server with CLI args -./target/release/trx-server -r ft817 "/dev/ttyUSB0 9600" - -# Run server with config file -./target/release/trx-server --config /path/to/config.toml - -# Run client -./target/release/trx-client --config /path/to/client-config.toml - -# Print example config -./target/release/trx-server --print-config > trx-server.toml - -# Run tests -cargo test - -# Run clippy -cargo clippy -``` diff --git a/aidocs/SDR.md b/aidocs/SDR.md deleted file mode 100644 index baa883c..0000000 --- a/aidocs/SDR.md +++ /dev/null @@ -1,401 +0,0 @@ -# SDR Backend Requirements - -This document specifies the requirements for a SoapySDR-based RX-only backend (`trx-backend-soapysdr`) and the associated IQ-to-audio pipeline changes in `trx-server`. - ---- - -## Progress - -> **For AI agents:** This section is the single source of truth for implementation status. -> Each task has a unique ID (e.g. `SDR-01`), a status badge, a description, the files it touches, and any blocking dependencies. -> Pick any task whose status is `[ ]` and whose `Needs` list is fully `[x]`. Update status to `[~]` while working, `[x]` when merged. Record notes under the task if you hit non-obvious issues. -> -> Status legend: `[ ]` not started · `[~]` in progress · `[x]` done · `[!]` blocked - -### Foundational (must land first) - -| ID | Status | Task | Touches | -|----|--------|------|---------| -| SDR-01 | `[x]` | Add `AudioSource` trait to `trx-core`; add `as_audio_source()` default on `RigCat` | `src/trx-core/src/rig/mod.rs` | -| SDR-02 | `[x]` | Add `RigAccess::Sdr { args: String }` variant; register `soapysdr` factory (feature-gated `soapysdr`) | `src/trx-server/trx-backend/src/lib.rs` | -| SDR-03 | `[x]` | Add `SdrConfig`, `SdrGainConfig`, `SdrChannelConfig` structs; parse `type = "sdr"` in `AccessConfig`; add `sdr: SdrConfig` to `ServerConfig`; add startup validation rules (§11) | `src/trx-server/src/config.rs` | - -### New crate: `trx-backend-soapysdr` - -| ID | Status | Task | Touches | Needs | -|----|--------|------|---------|-------| -| SDR-04 | `[x]` | Create crate scaffold: `Cargo.toml` (deps: `soapysdr`, `num-complex`, `tokio`), empty `lib.rs` | `src/trx-server/trx-backend/trx-backend-soapysdr/` | SDR-01, SDR-02 | -| SDR-05 | `[x]` | Implement `demod.rs`: SSB (USB/LSB), AM envelope, FM quadrature, CW narrow BPF+envelope | `…/src/demod.rs` | SDR-04 | -| SDR-06 | `[x]` | Implement `dsp.rs`: IQ broadcast loop (SoapySDR read thread → `broadcast::Sender>>`); per-channel mixer → FIR LPF → decimator → demod → frame accumulator → `broadcast::Sender>` | `…/src/dsp.rs` | SDR-04, SDR-05 | -| SDR-07 | `[x]` | Implement `SoapySdrRig` in `lib.rs`: `RigCat` (RX methods + `not_supported` stubs for TX), `AudioSource`, gain control (manual/auto with fallback), primary channel freq/mode tracking | `…/src/lib.rs` | SDR-03, SDR-06 | - -### Server integration - -| ID | Status | Task | Touches | Needs | -|----|--------|------|---------|-------| -| SDR-08 | `[x]` | `main.rs`: after building rig, if `as_audio_source()` is `Some` skip cpal, subscribe each decoder and the Opus encoder to the appropriate channel PCM senders; validate `stream_opus` count ≤ 1 | `src/trx-server/src/main.rs` | SDR-03, SDR-07 | -| SDR-09 | `[x]` | Add `trx-backend-soapysdr` to workspace `Cargo.toml`; update `CONFIGURATION.md` with new `[sdr]` / `[[sdr.channels]]` options | `Cargo.toml`, `CONFIGURATION.md` | SDR-04 | - -### Validation & tests - -| ID | Status | Task | Touches | Needs | -|----|--------|------|---------|-------| -| SDR-10 | `[x]` | Unit tests for `demod.rs`: known-input tone through each demodulator, check output frequency correct | `…/src/demod.rs` | SDR-05 | -| SDR-11 | `[x]` | Unit tests for config validation: channel IF out-of-range, dual `stream_opus`, TX enabled with SDR backend, AGC fallback warning | `src/trx-server/src/config.rs` | SDR-03 | - ---- - -## Goals - -- Receive-only backend that uses any SoapySDR-compatible device (RTL-SDR, Airspy, HackRF, SDRplay, etc.) as the rig -- Full IQ pipeline: raw IQ samples → demodulated PCM → existing decoders (FT8, WSPR, APRS, CW) with zero decoder-side changes -- Wideband capture: one SDR IQ stream feeds multiple simultaneous virtual receivers, each independently tuned and demodulated -- Configurable per-channel filters and demodulation modes -- Demodulated audio streamed to clients as Opus over the existing TCP audio channel - ---- - -## Non-Goals - -- Transmit (TX/PTT) of any kind -- Replacing or deprecating the existing cpal-based audio path (it stays for transceiver backends) - ---- - -## 1. Device Abstraction - -### 1.1 `RigAccess` extension - -A new access type `sdr` is added alongside `serial` and `tcp`: - -```toml -[rig.access] -type = "sdr" -args = "driver=rtlsdr" # SoapySDR device args string -``` - -The `args` value is passed verbatim to `SoapySDR::Device::new(args)`. It follows SoapySDR's key=value comma-separated convention (e.g., `driver=airspy`, `driver=rtlsdr,serial=00000001`). - -### 1.2 `AudioSource` trait - -A new trait is added to `trx-core` (`src/trx-core/src/rig/mod.rs`): - -```rust -pub trait AudioSource: Send + Sync { - /// Subscribe to demodulated PCM audio from the primary channel. - fn subscribe_pcm(&self) -> broadcast::Receiver>; -} -``` - -`RigCat` gains a default opt-in method: - -```rust -pub trait RigCat: Rig + Send { - // ... existing methods ... - fn as_audio_source(&self) -> Option<&dyn AudioSource> { None } -} -``` - -`SoapySdrRig` overrides `as_audio_source()` to return `Some(self)`. When the server detects this, it skips spawning the cpal capture thread entirely. - -### 1.3 TX-only `RigCat` methods - -The following methods return `RigError::not_supported(...)` on the SDR backend: - -- `set_ptt()` -- `power_on()` / `power_off()` -- `get_tx_power()` -- `get_tx_limit()` / `set_tx_limit()` -- `toggle_vfo()` (not applicable; channels are defined statically in config) -- `lock()` / `unlock()` - -The following methods are fully supported: - -- `get_status()` → returns primary channel's current `(freq, mode, None)` -- `set_freq()` → re-tunes the SDR center frequency (keeping `center_offset_hz` invariant) and updates all channel mixer offsets -- `set_mode()` → changes the primary channel's demodulator -- `get_signal_strength()` → returns instantaneous RSSI for the primary channel (dBFS mapped to 0–255 S-unit range) - ---- - -## 2. IQ Pipeline Architecture - -### 2.1 Center frequency offset - -SDR hardware has a DC offset spur at exactly 0 Hz in the IQ spectrum. To keep the primary channel off DC, the SDR is tuned to a frequency offset from the desired dial frequency: - -``` -sdr_center_freq = dial_freq - center_offset_hz -``` - -With `center_offset_hz = 200000` and dial freq 14.074 MHz, the SDR tunes to 13.874 MHz. The 14.074 MHz signal appears at +200 kHz in the IQ spectrum and is mixed down to baseband in software. - -`center_offset_hz` is a global SDR parameter (not per-channel). A reasonable default is `100000` (100 kHz). - -### 2.2 Wideband channel model - -One SoapySDR RX stream produces IQ samples at `sdr.sample_rate` (e.g. 1.92 MHz). This stream is shared among all configured channels. Each channel defines an independent virtual receiver: - -``` -SoapySDR RX stream (complex f32, sdr_sample_rate Hz) - │ - ├──► Channel 0 (primary) offset_hz=0, mode=USB, bw=3000 Hz - ├──► Channel 1 (wspr) offset_hz=+21600, mode=USB, bw=3000 Hz - └──► Channel N ... -``` - -A **channel's frequency** in the real spectrum is: - -``` -channel_real_freq = dial_freq + channel.offset_hz -``` - -A **channel's IF frequency** within the IQ stream is: - -``` -channel_if_hz = center_offset_hz + channel.offset_hz -``` - -This is the frequency at which the channel's signal appears in the captured IQ bandwidth, and is what the channel's mixer shifts to baseband. - -**Constraint:** `|channel_if_hz|` must be less than `sdr_sample_rate / 2` for every channel. The server validates this at startup and rejects invalid configs. - -### 2.3 Per-channel DSP chain - -Each channel runs the following stages independently on the shared IQ stream: - -``` -IQ input (complex f32, sdr_sample_rate) - 1. Mixer: multiply by exp(-j·2π·channel_if_hz·n/sdr_sample_rate) - → complex f32 centred at 0 Hz - 2. FIR LPF: cutoff = audio_bandwidth_hz / 2, order configurable - 3. Decimator: sdr_sample_rate / audio_sample_rate (must be integer; resampler used otherwise) - 4. Demodulator (mode-dependent, see §3) - 5. Output: real f32 at audio_sample_rate - 6. Frame accumulator: chunks of frame_duration_ms - 7. broadcast::Sender> → decoders + optional Opus encoder -``` - -Channels run concurrently in separate tasks, all reading from the same raw IQ broadcast channel. - -### 2.4 IQ broadcast channel - -The SoapySDR read loop runs in a dedicated OS thread (matching the existing cpal thread model). It reads IQ sample blocks from the device and publishes them on: - -```rust -broadcast::Sender>> // capacity: configurable, default 64 blocks -``` - -Each channel task subscribes to this sender. Lagged receivers log a warning and continue. - ---- - -## 3. Demodulators - -Demodulator is selected per-channel based on `mode`. Modes map as follows: - -| `RigMode` | Demodulator | -|-----------|-------------| -| `USB` | SSB: mix to IF, take real part (upper sideband) | -| `LSB` | SSB: mix to IF, take real part (lower sideband, negate IF) | -| `AM` | Envelope detector: `sqrt(I² + Q²)`, DC-remove, normalize | -| `FM` | Quadrature: `arg(s[n] · conj(s[n-1]))`, i.e. instantaneous frequency | -| `WFM` | Same as FM, wider pre-demod filter (`wfm_bandwidth_hz`) | -| `CW` | Narrow BPF centred at `cw_center_hz` (audio domain), then envelope | -| `DIG`/`PKT` | Same as USB (pass audio through for downstream digital decoders) | -| `CWR` | Same as CW (reversed sideband, uses same audio envelope) | - -For SSB modes (USB/LSB), after mixing to baseband the channel's `audio_bandwidth_hz` defines the one-sided cutoff of the post-demod LPF. - ---- - -## 4. Gain Control - -Gain is configured globally under `[sdr.gain]`. - -```toml -[sdr.gain] -mode = "auto" # "auto" (AGC via SoapySDR) or "manual" -value = 30.0 # dB; ignored when mode = "auto" -``` - -- **`auto`**: calls `device.set_gain_mode(SOAPY_SDR_RX, 0, true)` to enable hardware AGC if the device supports it. If the device does not support hardware AGC, falls back to `manual` with a warning. -- **`manual`**: calls `device.set_gain(SOAPY_SDR_RX, 0, value)` with the specified total gain in dB. - -Advanced per-element gain is out of scope for this phase (no `lna`/`vga`/`if` sub-keys initially). - -### 4.1 Virtual Squelch - -Software squelch is configured globally under `[sdr.squelch]` and currently applies to the primary channel's demodulated audio path except WFM. - -```toml -[sdr.squelch] -enabled = false -threshold_db = -65.0 # dBFS open threshold -hysteresis_db = 3.0 # dB close hysteresis -tail_ms = 180 # hold time after signal drops -``` - ---- - -## 5. Filter Configuration - -Filters are configured per-channel. The following are settable: - -```toml -[[sdr.channels]] -audio_bandwidth_hz = 3000 # One-sided bandwidth of post-demod BPF (Hz) - # For FM: deviation hint for deemphasis -fir_taps = 64 # FIR filter tap count (default 64); higher = sharper roll-off -cw_center_hz = 700 # CW tone centre in audio domain (default 700 Hz) -wfm_bandwidth_hz = 75000 # Pre-demod bandwidth for WFM only (default 75 kHz) -``` - -`fir_taps` controls the same FIR used in stage 2 of the DSP chain (§2.3). It applies uniformly to both the pre-demod decimation filter and the post-demod audio BPF in this phase. - ---- - -## 6. Channel Configuration and Decoder Binding - -Channels are declared as a TOML array. The first channel in the list is the **primary channel** and is the one exposed via `RigCat` (`set_freq`/`set_mode` affect it; `get_status` reads from it). - -```toml -[[sdr.channels]] -id = "primary" # Identifier, used in logs -offset_hz = 0 # Offset from dial frequency (Hz) -mode = "auto" # "auto" = follows RigCat set_mode; or fixed RigMode string -audio_bandwidth_hz = 3000 -fir_taps = 64 -decoders = ["ft8", "cw"] # Which decoders receive this channel's PCM -stream_opus = true # Encode and stream via TCP audio channel - -[[sdr.channels]] -id = "wspr-14" -offset_hz = 21600 # 14.0956 MHz when dial = 14.074 MHz -mode = "USB" # Fixed mode, ignores RigCat set_mode -audio_bandwidth_hz = 3000 -decoders = ["wspr"] -stream_opus = false - -[[sdr.channels]] -id = "aprs" -offset_hz = -673600 # e.g. 144.390 MHz when dial = 145.0635 MHz -mode = "FM" -audio_bandwidth_hz = 8000 -decoders = ["aprs"] -stream_opus = false -``` - -**`mode = "auto"`** means the channel's demodulator tracks whatever `set_mode()` last set on the backend. Only the primary channel should use `"auto"` in typical use. - -**`decoders`** maps to the decoder task IDs: `"ft8"`, `"wspr"`, `"aprs"`, `"cw"`. Each named decoder subscribes to the PCM broadcast channel of the listed channel(s). A decoder can only be bound to one channel (first binding wins if duplicated). - ---- - -## 7. Opus Streaming - -Channels with `stream_opus = true` have their demodulated PCM Opus-encoded and streamed over the server's existing TCP audio port (default 4531). - -For this phase, only **one channel** may have `stream_opus = true` (validation error otherwise). This channel's Opus stream replaces what cpal would have produced — the TCP audio protocol and client-side handling are unchanged. - -The Opus encoder uses the `[audio]` config for `frame_duration_ms`, `bitrate_bps`, and `sample_rate`. The SDR pipeline must output PCM at the same `sample_rate` as `[audio]`; a mismatch is a startup validation error. - ---- - -## 8. Full Configuration Example - -```toml -[rig] -model = "soapysdr" -initial_freq_hz = 14074000 -initial_mode = "USB" - -[rig.access] -type = "sdr" -args = "driver=rtlsdr" - -[sdr] -sample_rate = 1920000 # IQ capture rate (Hz) — must be supported by device -bandwidth = 1500000 # Hardware IF filter (Hz) -center_offset_hz = 200000 # SDR tunes this many Hz below dial frequency - -[sdr.gain] -mode = "auto" -value = 30.0 # Effective only when mode = "manual" - -[sdr.squelch] -enabled = false -threshold_db = -65.0 -hysteresis_db = 3.0 -tail_ms = 180 - -[[sdr.channels]] -id = "primary" -offset_hz = 0 -mode = "auto" -audio_bandwidth_hz = 3000 -fir_taps = 64 -decoders = ["ft8", "cw"] -stream_opus = true - -[[sdr.channels]] -id = "wspr" -offset_hz = 21600 -mode = "USB" -audio_bandwidth_hz = 3000 -decoders = ["wspr"] -stream_opus = false - -[audio] -enabled = true -listen = "127.0.0.1" -port = 4531 -rx_enabled = true -tx_enabled = false # No TX on SDR backend -sample_rate = 48000 -channels = 1 -frame_duration_ms = 20 -bitrate_bps = 24000 -``` - ---- - -## 9. Code Changes Map - -| File | Change | -|------|--------| -| `Cargo.toml` (workspace) | Add `src/trx-server/trx-backend/trx-backend-soapysdr` member | -| `src/trx-core/src/rig/mod.rs` | Add `AudioSource` trait; add `as_audio_source()` default to `RigCat` | -| `src/trx-server/trx-backend/src/lib.rs` | Add `RigAccess::Sdr { args }` variant; register `soapysdr` factory (feature-gated) | -| `src/trx-server/src/config.rs` | Add `SdrConfig`, `SdrGainConfig`, `SdrChannelConfig`; parse `type = "sdr"` in `AccessConfig`; add `sdr: SdrConfig` to `ServerConfig` | -| `src/trx-server/src/main.rs` | After building rig: if `as_audio_source()` is `Some`, skip cpal, use `AudioSource::subscribe_pcm()` for each decoder and for the Opus encoder; validate at most one `stream_opus = true` channel | -| `src/trx-server/src/audio.rs` | Expose `spawn_audio_capture` and `run_*_decoder` without assuming cpal as the sole source; no functional change needed — decoders already take `broadcast::Receiver>` | -| `src/trx-server/trx-backend/trx-backend-soapysdr/Cargo.toml` | New crate | -| `src/trx-server/trx-backend/trx-backend-soapysdr/src/lib.rs` | `SoapySdrRig`: implements `RigCat` + `AudioSource`; spawns IQ read thread and channel DSP tasks | -| `src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp.rs` | IQ broadcast loop; per-channel mixer, FIR, decimator, demodulator, frame accumulator | -| `src/trx-server/trx-backend/trx-backend-soapysdr/src/demod.rs` | Mode-specific demodulators: SSB, AM envelope, FM quadrature, CW envelope | -| `CONFIGURATION.md` | Document new `[rig.access] type = "sdr"`, `[sdr]`, `[[sdr.channels]]` options | - ---- - -## 10. External Dependencies - -| Crate | Purpose | -|-------|---------| -| `soapysdr` | Rust bindings to `libSoapySDR` (C++) | -| `num-complex` | `Complex` for IQ arithmetic | - -System requirement: `libSoapySDR` installed (e.g. `brew install soapysdr` on macOS, `libsoapysdr-dev` on Debian/Ubuntu). - ---- - -## 11. Validation Rules (startup) - -- `[rig.access] type = "sdr"` requires `args` to be non-empty. -- `[sdr] sample_rate` must be non-zero. -- For every channel: `|center_offset_hz + channel.offset_hz| < sdr_sample_rate / 2`. -- Exactly one channel must have `stream_opus = true` (or none; zero means no TCP audio stream). -- The audio `sample_rate` in `[audio]` must equal the target audio rate in the SDR pipeline (no cross-rate mismatch). -- `[audio] tx_enabled` must be `false` when `model = "soapysdr"`. -- A decoder name may appear in at most one channel's `decoders` list. -- If the device does not support hardware AGC and `gain.mode = "auto"`, warn and fall back to `manual` using `gain.value`. diff --git a/aidocs/SKILLS.md b/aidocs/SKILLS.md deleted file mode 100644 index b047981..0000000 --- a/aidocs/SKILLS.md +++ /dev/null @@ -1,41 +0,0 @@ -# Project Skills - -Custom slash commands (skills) available in this repository. -Invoke with `/skill-name [args]` inside Claude Code. - ---- - -## `/frontend-design` — HTTP frontend work - -**When to use:** Any time you need to add or modify UI in the HTTP web frontend — new control rows, panels, visual polish, capability-gated elements, or JS behaviour wired to REST endpoints. - -**What it loads:** Design system context (palette, layout primitives, patterns), key file paths, and coding conventions so Claude writes code that matches the existing UI without needing to re-read the style guide each time. - -**File:** `.claude/commands/frontend-design.md` - -**Example invocations** - -``` -/frontend-design Add a CW keyer speed row (wpm slider) that POSTs to /set_cw_wpm, shown only when capabilities.tx is true. -/frontend-design Polish the filters panel — align the bandwidth label with the FIR taps label and add a unit suffix to the slider readout. -/frontend-design Add a waterfall canvas below the signal meter that renders frequency vs. time from a new SSE stream. -``` - ---- - -## Adding new skills - -Place a Markdown file in `.claude/commands/.md`. -Use `$ARGUMENTS` as the placeholder for user-supplied text. -Skills in `.claude/commands/` are project-scoped and not committed if `.claude/` is in `.gitignore`. - -To make a skill part of the repo (shared with all contributors), add it to `aidocs/` as documentation and track the command file in version control by removing `.claude/` from `.gitignore` or adding a specific exception. - ---- - -## Global skills (available in all projects) - -| Skill | When to use | -|-------|------------| -| `frontend-design` | Also installed globally; project version takes precedence here | -| `keybindings-help` | Customise Claude Code keyboard shortcuts | diff --git a/aidocs/UI-CAPS.md b/aidocs/UI-CAPS.md deleted file mode 100644 index 9a7cb25..0000000 --- a/aidocs/UI-CAPS.md +++ /dev/null @@ -1,180 +0,0 @@ -# UI Capability Gating - -This document specifies how `trx-client`'s HTTP frontend adapts its controls to the capabilities of the connected rig backend. Devices such as SDR receivers expose filter controls but not TX controls; traditional transceivers are the reverse. - ---- - -## Progress - -> **For AI agents:** This section is the single source of truth for implementation status. -> Each task has a unique ID (e.g. `UC-01`), a status badge, a description, the files it touches, and any blocking dependencies. -> -> Status legend: `[ ]` not started · `[~]` in progress · `[x]` done · `[!]` blocked - -### Foundational (parallel) - -| ID | Status | Task | Files | Needs | -|----|--------|------|-------|-------| -| UC-01 | `[x]` | Extend `RigCapabilities` with `tx`, `tx_limit`, `vfo_switch`, `filter_controls`, `signal_meter` bool flags | `src/trx-core/src/rig/state.rs` | — | -| UC-02 | `[x]` | Update capability declarations in all backends to set new flags | `src/trx-server/trx-backend/trx-backend-ft817/src/lib.rs`, `trx-backend-ft450d/src/lib.rs`, `trx-backend-soapysdr/src/lib.rs` | UC-01 | -| UC-03 | `[x]` | Add `RigFilterState` struct; add `filter: Option` to `RigSnapshot`; populate from SDR rig state | `src/trx-core/src/rig/state.rs`, `src/trx-server/trx-backend/trx-backend-soapysdr/src/lib.rs` | — | -| UC-04 | `[x]` | Add `SetBandwidth`, `SetFirTaps` to `ClientCommand`; add mapping arms; update `rig_task.rs` to dispatch them | `src/trx-protocol/src/types.rs`, `mapping.rs`, `src/trx-server/src/rig_task.rs` | UC-03 | - -### HTTP layer - -| ID | Status | Task | Files | Needs | -|----|--------|------|-------|-------| -| UC-05 | `[x]` | Add `/set_bandwidth` and `/set_fir_taps` HTTP endpoints | `src/trx-client/trx-frontend/trx-frontend-http/src/api.rs` | UC-04 | - -### Frontend - -| ID | Status | Task | Files | Needs | -|----|--------|------|-------|-------| -| UC-06 | `[x]` | Read `state.info.capabilities` on each SSE event; toggle visibility of TX controls, meter rows, VFO button, lock button | `assets/web/app.js` | UC-01, UC-02 | -| UC-07 | `[x]` | Add "Filters" control panel (bandwidth, FIR taps, CW tone Hz); show only when `capabilities.filter_controls` | `assets/web/index.html`, `assets/web/app.js` | UC-05, UC-06 | - -### Tests - -| ID | Status | Task | Files | Needs | -|----|--------|------|-------|-------| -| UC-08 | `[x]` | Unit tests: SDR backend declares `tx=false`, `filter_controls=true`; FT-817/450D declare `tx=true`, `filter_controls=false` | `src/trx-server/trx-backend/trx-backend-soapysdr/src/lib.rs`, `trx-backend-ft817`, `trx-backend-ft450d` | UC-02 | -| UC-09 | `[x]` | Protocol round-trip test: `RigSnapshot` serialises `filter` field when `Some`, omits it when `None` | `src/trx-protocol/src/codec.rs` or `types.rs` | UC-03 | - ---- - -## Goals - -- All UI control groups are **shown/hidden purely from `RigCapabilities`** flags received in the initial `GET /status` and each SSE `status` event — no hard-coding per model name -- SDR backends show filter controls (bandwidth, FIR taps, CW tone); hide TX controls (PTT, power, TX limit, TX meters, TX audio) -- Transceiver backends show TX controls; hide filter controls -- Adding a new backend requires only setting the right capability flags — no frontend changes - ---- - -## Non-Goals - -- Per-channel filter control (multi-channel SDR tuning) — out of scope; only the primary channel is exposed here -- Dynamic capability changes at runtime (capability flags are set once at rig init and treated as static) -- Changing the rigctl or http-json frontends (HTTP frontend only) - ---- - -## Capability Flags - -### New flags added to `RigCapabilities` (UC-01) - -| Flag | Type | Meaning | -|------|------|---------| -| `tx` | `bool` | Backend supports transmit: PTT, power on/off, TX meters, TX audio | -| `tx_limit` | `bool` | Backend supports `get_tx_limit` / `set_tx_limit` | -| `vfo_switch` | `bool` | Backend supports `toggle_vfo` | -| `filter_controls` | `bool` | Backend supports runtime filter adjustment (bandwidth, FIR taps) | -| `signal_meter` | `bool` | Backend returns a meaningful RX signal strength value | - -Existing flags `lock` and `lockable` are unchanged. - -### Backend declarations (UC-02) - -| Backend | `tx` | `tx_limit` | `vfo_switch` | `filter_controls` | `signal_meter` | `lock`/`lockable` | -|---------|------|-----------|--------------|-------------------|----------------|-------------------| -| FT-817 | ✓ | ✓ | ✓ | ✗ | ✓ | ✓ / ✓ | -| FT-450D | ✓ | ✓ | ✓ | ✗ | ✓ | ✓ / ✓ | -| SoapySDR | ✗ | ✗ | ✗ | ✓ | ✓ | ✗ / ✗ | - ---- - -## Filter State - -### `RigFilterState` struct (UC-03) - -Added to `trx-core/src/rig/state.rs`: - -```rust -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RigFilterState { - pub bandwidth_hz: u32, // Audio bandwidth of primary channel - pub fir_taps: u32, // FIR filter tap count - pub cw_center_hz: u32, // CW tone centre frequency (audio domain) -} -``` - -Added to `RigSnapshot`: - -```rust -#[serde(default, skip_serializing_if = "Option::is_none")] -pub filter: Option, -``` - -The SDR backend populates this from the primary channel's live DSP state. All other backends leave it `None`. - ---- - -## New Protocol Commands - -### `ClientCommand` additions (UC-04) - -```rust -SetBandwidth { bandwidth_hz: u32 }, -SetFirTaps { taps: u32 }, -``` - -`SetCwToneHz` already exists and is reused. - -### Mapping (UC-04) - -```rust -ClientCommand::SetBandwidth { bandwidth_hz } => - RigCommand::SetBandwidth(bandwidth_hz), -ClientCommand::SetFirTaps { taps } => - RigCommand::SetFirTaps(taps), -``` - -The SDR backend applies changes to the live DSP chain immediately. Other backends return `RigError::not_supported(...)`. - ---- - -## New HTTP Endpoints (UC-05) - -| Endpoint | Method | Query param | Action | -|----------|--------|-------------|--------| -| `/set_bandwidth` | POST | `hz: u32` | Sets primary channel audio bandwidth | -| `/set_fir_taps` | POST | `taps: u32` | Sets primary channel FIR tap count | - ---- - -## Frontend Visibility Map (UC-06, UC-07) - -| UI element / group | Shown when | -|--------------------|-----------| -| PTT button | `capabilities.tx` | -| Power button | `capabilities.tx` | -| TX meters (power bar, SWR bar) | `capabilities.tx && state.status.tx_en` | -| TX Limit row | `capabilities.tx_limit` | -| TX Audio toggle + volume | `capabilities.tx` | -| VFO selector buttons | `capabilities.vfo_switch` | -| Lock button | `capabilities.lock` | -| Signal meter | `capabilities.signal_meter` | -| Filters panel | `capabilities.filter_controls` | - -Visibility is applied in a single `applyCapabilities(caps)` function called from the SSE `status` handler, using `element.classList.toggle('hidden', !condition)`. - -### Filter panel layout (UC-07) - -``` -┌─ Filters ──────────────────────────────────┐ -│ Bandwidth [──────●──────] 3000 Hz │ -│ FIR taps [32 ▾] (32 / 64 / 128 / 256) │ -│ CW tone [──●───────────] 700 Hz │ -└────────────────────────────────────────────┘ -``` - -Each control dispatches to its REST endpoint on `change`/`input` (debounced 200 ms). The panel is hidden by default (`class="hidden"`) and revealed when `capabilities.filter_controls` is set. - ---- - -## Implementation Notes - -- `applyCapabilities()` must run **before** the first paint (call it synchronously on the initial `/status` response, not only on SSE events) to avoid layout flash of unsupported controls. -- `hidden` CSS class should set `display: none` and `aria-hidden: true`. -- The existing `set_cw_tone` endpoint and CW decoder panel remain in the CW decoder tab — they are decoder settings, not filter settings. The Filters panel bandwidth/taps apply to the DSP chain; CW tone moves to both places or is de-duplicated in a follow-up. -- If a future backend supports TX but not `tx_limit`, only the TX Limit row is hidden; PTT remains. diff --git a/aidocs/WEBGL.md b/aidocs/WEBGL.md deleted file mode 100644 index 760d738..0000000 --- a/aidocs/WEBGL.md +++ /dev/null @@ -1,79 +0,0 @@ -# Canvas2D to WebGL Transition Plan - -## Goal -- Replace all runtime Canvas2D rendering in the frontend with WebGL. -- Remove Canvas2D code paths after feature parity is reached. -- Keep existing interaction behavior (zoom/pan/tune/BW drag/tooltips/overlays) intact. - -## Scope -- `src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js` - - `overview-canvas` - - `spectrum-canvas` - - `signal-overlay-canvas` -- `src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/cw.js` - - `cw-tone-waterfall` -- New shared WebGL utility module: - - `assets/web/webgl-renderer.js` - -## Non-Goals -- No Canvas2D fallback path. -- No feature redesign outside rendering internals. - -## Constraints -- Must preserve existing data flow and event wiring. -- Must keep map/decoder/bookmark integrations unchanged. -- Must remain dependency-free (no external rendering libraries). - -## 2-Phase Migration -1. Phase 1 (Rendering engine insertion) -- Add shared WebGL renderer utility (primitives + textures + color parsing). -- Keep existing business logic and interaction handlers untouched. -- Swap draw targets from 2D contexts to WebGL primitives. - -2. Phase 2 (Canvas2D removal and parity closure) -- Remove `getContext("2d")` usage from app and plugins. -- Remove obsolete 2D-specific cache paths. -- Validate behavior on resize/theme/style/stream reconnect/decoder mode changes. - -## Parallel Workstreams ("Agents") -1. Agent A: Shared WebGL core -- Build `webgl-renderer.js` with: - - HiDPI resize handling - - Solid/gradient rects - - Polyline/segment/fill primitives - - RGBA texture upload + blit - - CSS color parser helpers - -2. Agent B: Main spectrum/overview migration -- Port `drawSpectrum`, `drawHeaderSignalGraph`, `drawSignalOverlay`, and clear paths. -- Replace 2D offscreen waterfall cache with WebGL texture updates. -- Keep frequency axis/bookmark axis DOM behavior unchanged. - -3. Agent C: CW tone picker migration -- Port `drawCwTonePicker` primitives to WebGL. -- Preserve auto/manual tone interactions and mode gating. - -## Acceptance Criteria -- No frontend `getContext("2d")` usage remains. -- All four canvases render using WebGL and respond to resize/DPR changes. -- Spectrum interactions still work: - - wheel zoom - - drag pan - - BW edge drag - - click tune -- Overview strip continues showing waterfall/history. -- CW tone picker remains interactive and reflects current spectrum/tone. - -## Verification Checklist -- Static: - - `rg -n 'getContext\\("2d"\\)' src/trx-client/trx-frontend/trx-frontend-http/assets/web` -- Runtime smoke: - - Open main tab: verify overview + spectrum + overlay. - - Toggle theme/style. - - Resize window and spectrum grip. - - Enable CW decoder and validate tone picker updates/click-to-set. - - Confirm no rendering exceptions in browser console. - -## Rollout Notes -- Initial rollout is WebGL-only. -- If a browser lacks WebGL, canvases remain blank by design until a dedicated fallback policy is defined. diff --git a/docs b/docs index 95e14e1..d053142 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit 95e14e1cc9eba522a019f20658e546afe4eb4b77 +Subproject commit d053142a233eaa0d3309fd3b9207e6cf6c5e7783