[docs](trx-rs): consolidate documentation into docs/ wiki
Move scattered documentation into the docs/ submodule (GitHub wiki): - OVERVIEW.md → docs/ARCHITECTURE.md - SCHEDULER.md, aidocs/CONFIGURATION.md, aidocs/AUTH.md → docs/MANUAL.md - OPTIMIZATION.md → docs/OPTIMIZATION_GUIDELINES.md - RECORDER.md → docs/NEXT.md - Remove aidocs/ (content migrated or obsolete) - Update README.md documentation links Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
-175
@@ -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.
|
||||
-1036
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
-227
@@ -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<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.
|
||||
-119
@@ -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<String>,
|
||||
pub night_bookmark_id: Option<String>,
|
||||
pub dawn_bookmark_id: Option<String>,
|
||||
pub dusk_bookmark_id: Option<String>,
|
||||
}
|
||||
|
||||
pub struct ScheduleEntry {
|
||||
pub id: String,
|
||||
pub start_hhmm: u32,
|
||||
pub end_hhmm: u32,
|
||||
pub bookmark_id: String,
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
pub struct SchedulerConfig {
|
||||
pub rig_id: String,
|
||||
pub mode: SchedulerMode,
|
||||
pub grayline: Option<GraylineConfig>,
|
||||
pub entries: Vec<ScheduleEntry>,
|
||||
}
|
||||
```
|
||||
|
||||
## 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).
|
||||
@@ -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: `[<type>](<crate>): <description>` (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`.
|
||||
-190
@@ -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<String>`
|
||||
- `control_passphrase: Option<String>`
|
||||
- `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.
|
||||
@@ -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 <FILE>`
|
||||
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 <FILE>`
|
||||
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<version> 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`
|
||||
@@ -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<JoinHandle<_>>`. 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.
|
||||
-156
@@ -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<String>` to `ClientEnvelope`; add `rig_id: Option<String>` 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<RigInstanceConfig>` 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<DecoderHistories>` 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<HashMap<String, RigHandle>>` + `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<String, RigHandle>`; 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<HashMap<rig_id, RigHandle>>
|
||||
├─ 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.
|
||||
@@ -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 <path>` (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<u64> },
|
||||
Initializing { rig_info: Option<RigInfo> },
|
||||
PoweredOff { rig_info: RigInfo },
|
||||
Ready(ReadyStateData),
|
||||
Transmitting(TransmittingStateData),
|
||||
Error { error: RigStateError, previous_state: Box<RigMachineState> },
|
||||
}
|
||||
```
|
||||
|
||||
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<Box<dyn Future<Output = DynResult<CommandResult>> + 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<Freq>, 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
|
||||
```
|
||||
-401
@@ -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<Vec<Complex<f32>>>`); per-channel mixer → FIR LPF → decimator → demod → frame accumulator → `broadcast::Sender<Vec<f32>>` | `…/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<Vec<f32>>;
|
||||
}
|
||||
```
|
||||
|
||||
`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<Vec<f32>> → 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<Vec<Complex<f32>>> // 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<Vec<f32>>` |
|
||||
| `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<f32>` 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`.
|
||||
@@ -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/<skill-name>.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 |
|
||||
@@ -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<RigFilterState>` 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<RigFilterState>,
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -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.
|
||||
+1
-1
Submodule docs updated: 95e14e1cc9...d053142a23
Reference in New Issue
Block a user