[chore](trx-rs): add local copy of docs

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