The old IQ noise blanker tracked a fast running RMS and, on a threshold crossing, replaced the sample with the last clean one. That hard sample-and-hold is a step discontinuity: it splatters energy back across the wideband passband, so after the narrow channel filter it often sounded worse than the noise it removed — especially on SSB, CW and digital. It also had no look-ahead (the impulse leading edge leaked through before the fast RMS reacted), blanked only single samples, and used a fixed 1/128 time constant that did not scale with capture rate. Redesign the blanker around accepted wideband-NB practice and add profiles matched to the interference source: - Noise-floor tracker updated only from clean samples and frozen while blanking, so a burst cannot desensitise detection. - Detection on instantaneous power vs threshold² × noise floor. - Look-ahead delay line so the gate closes *before* the impulse reaches the output, removing the leading edge. - Raised-cosine tapered gate (ramp 1→0→1) instead of a hard hold, which minimises blanker splatter. - Windowed blanking that re-arms on every detected sample to cover the full width of a burst. - All timings expressed in real time and converted to samples at the capture rate, so behaviour is consistent across SDR sample rates. Profiles (`NoiseBlankerProfile`, default `spike`): spike, ignition, powerline, broadband — each selects the blank window, look-ahead, taper and floor time constant. `threshold` stays orthogonal as the sensitivity knob. Core/protocol: - New `NoiseBlankerProfile` in trx-core (serde/parse/u8/TS), re-exported at the crate root; `RigFilterState.sdr_nb_profile` for state sync. - `profile` added to `RigCommand`/`ClientCommand::SetSdrNoiseBlanker`, the trait method, and the command mapping. Config: `[rig.sdr.noise_blanker] profile = "spike"` (regenerated trx-rs.toml.example). SDR backend: `NoiseBlanker` rewritten in the channel DSP; profile wired through `SoapySdrConfig`, the runtime setter, and `filter_state()`. Frontend: an "NB profile" selector in the SDR advanced controls (POST /set_sdr_noise_blanker&profile=…), reflecting server state; the profile rides along with the enable/threshold quick toggle so it is preserved. Regenerated generated.ts and app.js. Tests: profile u8/parse round-trips (trx-core); DSP tests for impulse suppression with no leading-edge leak, strong-steady-signal pass-through, and wider-profile-blanks-longer. Docs: User-Manual NB section rewritten for the new algorithm and profiles. Signed-off-by: Stan Grams <sjg@haxx.space>
895 lines
37 KiB
Markdown
895 lines
37 KiB
Markdown
# trx-rs Manual
|
||
|
||
## What trx-rs is
|
||
|
||
`trx-rs` is a modular amateur radio control stack written in Rust. It splits
|
||
hardware access, DSP, transport, and user-facing interfaces into separate
|
||
components so a radio or SDR can be controlled locally while audio, decoding,
|
||
and remote control are exposed elsewhere on the network.
|
||
|
||
In practice, `trx-server` owns the rig or SDR backend and runs the DSP
|
||
pipeline, while `trx-client` connects to it and provides frontends such as the
|
||
web UI, JSON control, and rigctl-compatible access. The workspace also includes
|
||
protocol decoders and plugin-based extension points for adding backends and
|
||
frontends.
|
||
|
||
---
|
||
|
||
## Configuration
|
||
|
||
Both `trx-server` and `trx-client` read TOML. The server takes its settings
|
||
from the `[trx-server]` section and the client from `[trx-client]`, so one
|
||
`trx-rs.toml` can configure both — or each may live in its own file with the
|
||
section header left off.
|
||
|
||
`trx-rs.toml.example` in the repository root is a complete, commented example
|
||
generated from the config definitions themselves. `--print-config` prints the
|
||
same settings without the comments.
|
||
|
||
### File Locations
|
||
|
||
Both binaries use the same lookup order:
|
||
|
||
1. `--config <FILE>`
|
||
2. `./trx-rs.toml`
|
||
3. `~/.config/trx-rs/trx-rs.toml`
|
||
4. `/etc/trx-rs/trx-rs.toml`
|
||
|
||
CLI arguments override config file values.
|
||
|
||
### Checking a Config
|
||
|
||
`--check-config` loads the file, reports every problem it finds — unknown keys,
|
||
invalid values, listeners fighting over a port — and exits without starting
|
||
anything:
|
||
|
||
```bash
|
||
trx-server --check-config --config trx-rs.toml
|
||
trx-client --check-config --config trx-rs.toml
|
||
```
|
||
|
||
Unknown keys are warnings by default, so a config written for a newer version
|
||
still runs on an older binary. `--strict-config` makes them fatal.
|
||
|
||
`trx-configurator --check <FILE>` runs the same checks.
|
||
|
||
### Environment Variables and Secrets
|
||
|
||
Any string in the config may reference an environment variable as `${VAR}`;
|
||
an unset variable is an error rather than an empty value.
|
||
|
||
Credentials can be kept out of the config entirely by pointing at a file
|
||
instead. Every secret has a `*_file` sibling — set one or the other, never
|
||
both:
|
||
|
||
| Inline key | File key | Contents |
|
||
|------------|----------|----------|
|
||
| `[listen.auth].tokens` | `tokens_file` | one token per line |
|
||
| `[[remotes]].auth.token` | `token_file` | the token |
|
||
| `[frontends.http.auth].bootstrap_admin_password` | `bootstrap_admin_password_file` | the initial administrator password |
|
||
| `[frontends.http_json.auth].tokens` | `tokens_file` | one token per line |
|
||
|
||
Blank lines and `#` comments are ignored in the list files. A config that holds
|
||
credentials inline and is readable by group or others is flagged at startup.
|
||
|
||
### Server Options
|
||
|
||
#### `[general]`
|
||
|
||
| Field | Type | Default | Description |
|
||
|-------|------|---------|-------------|
|
||
| `callsign` | string | `"N0CALL"` | Station callsign |
|
||
| `log_level` | string | — | `trace`, `debug`, `info`, `warn`, or `error` |
|
||
| `latitude` | float | — | Station latitude (-90..90) |
|
||
| `longitude` | float | — | Station longitude (-180..180) |
|
||
|
||
`latitude` and `longitude` must be set together or both omitted.
|
||
|
||
#### `[rig]`
|
||
|
||
| Field | Type | Default | Description |
|
||
|-------|------|---------|-------------|
|
||
| `model` | string | — | Backend name (`ft817`, `ft450d`, `soapysdr`) |
|
||
| `initial_freq_hz` | u64 | `144300000` | Startup frequency (must be > 0) |
|
||
| `initial_mode` | string | `"USB"` | Startup mode |
|
||
|
||
#### `[rig.access]`
|
||
|
||
| Field | Type | Description |
|
||
|-------|------|-------------|
|
||
| `type` | string | `serial`, `tcp`, or `sdr` |
|
||
| `port` | string | Serial port path (serial mode) |
|
||
| `baud` | u32 | Serial baud rate (serial mode) |
|
||
| `host` | string | Remote host (tcp mode) |
|
||
| `tcp_port` | u16 | Remote port (tcp mode) |
|
||
| `args` | string | SoapySDR device args (sdr mode, e.g. `"driver=rtlsdr"`) |
|
||
|
||
#### `[behavior]`
|
||
|
||
| Field | Type | Default | Description |
|
||
|-------|------|---------|-------------|
|
||
| `poll_interval_ms` | u64 | `500` | Rig polling interval |
|
||
| `poll_interval_tx_ms` | u64 | `100` | Polling interval during TX |
|
||
| `max_retries` | u32 | `3` | Connection retry limit |
|
||
| `retry_base_delay_ms` | u64 | `100` | Base retry delay |
|
||
|
||
#### `[listen]`
|
||
|
||
| Field | Type | Default | Description |
|
||
|-------|------|---------|-------------|
|
||
| `enabled` | bool | `true` | Enable JSON TCP listener |
|
||
| `listen` | ip | `127.0.0.1` | Bind address |
|
||
| `port` | u16 | `4530` | Bind port |
|
||
|
||
#### `[listen.auth]`
|
||
|
||
| Field | Type | Default | Description |
|
||
|-------|------|---------|-------------|
|
||
| `tokens` | string[] | `[]` | Allowed auth tokens (empty = no auth) |
|
||
| `tokens_file` | string | — | Read tokens from this file, one per line |
|
||
|
||
#### `[audio]`
|
||
|
||
| Field | Type | Default | Description |
|
||
|-------|------|---------|-------------|
|
||
| `enabled` | bool | `true` | Enable audio streaming |
|
||
| `listen` | ip | `127.0.0.1` | Bind address |
|
||
| `port` | u16 | `4531` | Bind port |
|
||
| `rx_enabled` | bool | `true` | Enable RX audio |
|
||
| `tx_enabled` | bool | `true` | Enable TX audio |
|
||
| `device` | string | — | CPAL device name (empty = default) |
|
||
| `sample_rate` | u32 | `48000` | Sample rate (8000–192000) |
|
||
| `channels` | u8 | `1` | Channel count (1 or 2) |
|
||
| `frame_duration_ms` | u16 | `20` | Opus frame duration (3, 5, 10, 20, 40, 60) |
|
||
| `bitrate_bps` | u32 | `24000` | Opus bitrate |
|
||
|
||
When audio is enabled, at least one of `rx_enabled` or `tx_enabled` must be true.
|
||
|
||
#### `[sdr]`
|
||
|
||
| Field | Type | Default | Description |
|
||
|-------|------|---------|-------------|
|
||
| `sample_rate` | u32 | `1920000` | IQ capture rate in Hz |
|
||
| `bandwidth` | u32 | `1500000` | Hardware IF filter bandwidth in Hz |
|
||
| `center_offset_hz` | i64 | `100000` | Offset from dial to avoid DC spur |
|
||
| `spectrum_fft_size` | usize | `1024` | Spectrum FFT bins; power of two, 128–8192 |
|
||
| `spectrum_interval_ms` | u64 | `50` | How often a spectrum frame is pushed to subscribed clients |
|
||
|
||
Spectrum is the largest thing on the client connection. On a slow or
|
||
high-latency link, halving `spectrum_fft_size` halves the bytes per frame (at
|
||
half the frequency resolution) and raising `spectrum_interval_ms` sends fewer of
|
||
them; see [Spectrum over a slow link](#spectrum-over-a-slow-link).
|
||
|
||
#### `[sdr.gain]`
|
||
|
||
| Field | Type | Default | Description |
|
||
|-------|------|---------|-------------|
|
||
| `mode` | string | `"auto"` | `"auto"` (hardware AGC) or `"manual"` |
|
||
| `value` | f64 | `30.0` | Gain in dB (manual mode only) |
|
||
|
||
#### `[sdr.squelch]`
|
||
|
||
| Field | Type | Default | Description |
|
||
|-------|------|---------|-------------|
|
||
| `enabled` | bool | `false` | Enable software squelch |
|
||
| `threshold_db` | f32 | `-65.0` | Open threshold in dBFS (-140..0) |
|
||
| `hysteresis_db` | f32 | `3.0` | Close hysteresis in dB (0..40) |
|
||
| `tail_ms` | u32 | `180` | Tail hold time in ms (0..10000) |
|
||
|
||
#### `[[sdr.channels]]`
|
||
|
||
Defines virtual receiver channels within the wideband IQ stream. The first
|
||
channel is the primary channel (controlled by `set_freq`/`set_mode`).
|
||
|
||
| Field | Type | Default | Description |
|
||
|-------|------|---------|-------------|
|
||
| `id` | string | `""` | Human-readable label |
|
||
| `offset_hz` | i64 | `0` | Frequency offset from dial |
|
||
| `mode` | string | `"auto"` | Demod mode (`auto`, `LSB`, `USB`, `CW`, `AM`, `FM`, `WFM`, etc.) |
|
||
| `audio_bandwidth_hz` | u32 | `3000` | Post-demod audio bandwidth |
|
||
| `fir_taps` | usize | `64` | FIR filter tap count |
|
||
| `cw_center_hz` | u32 | `700` | CW tone centre frequency |
|
||
| `wfm_bandwidth_hz` | u32 | `75000` | WFM pre-demod filter bandwidth |
|
||
| `decoders` | string[] | `[]` | Decoder IDs for this channel (`ft8`, `wspr`, `aprs`, `cw`) |
|
||
| `stream_opus` | bool | `false` | Stream this channel's audio to clients |
|
||
|
||
Notes:
|
||
- Each decoder ID may appear in at most one channel.
|
||
- At most one channel may set `stream_opus = true`.
|
||
- Channel IF constraint: `|center_offset_hz + offset_hz| < sample_rate / 2`.
|
||
|
||
#### `[pskreporter]`
|
||
|
||
| Field | Type | Default | Description |
|
||
|-------|------|---------|-------------|
|
||
| `enabled` | bool | `false` | Enable PSKReporter uplink |
|
||
| `host` | string | `"report.pskreporter.info"` | Server host |
|
||
| `port` | u16 | `4739` | Server port |
|
||
| `receiver_locator` | string | — | Maidenhead grid (derived from lat/lon if omitted) |
|
||
|
||
#### `[aprsfi]`
|
||
|
||
| Field | Type | Default | Description |
|
||
|-------|------|---------|-------------|
|
||
| `enabled` | bool | `false` | Enable APRS-IS IGate |
|
||
| `host` | string | `"rotate.aprs.net"` | Server host |
|
||
| `port` | u16 | `14580` | Server port |
|
||
| `passcode` | i32 | `-1` | APRS-IS passcode (-1 = auto from callsign) |
|
||
|
||
Notes:
|
||
- `[general].callsign` must be non-empty when enabled.
|
||
- Only APRS packets with valid CRC are forwarded.
|
||
- Reconnects with exponential backoff (1 s → 60 s) on TCP errors.
|
||
|
||
#### `[decode_logs]`
|
||
|
||
| Field | Type | Default | Description |
|
||
|-------|------|---------|-------------|
|
||
| `enabled` | bool | `false` | Enable decoder logging |
|
||
| `dir` | string | `"$XDG_DATA_HOME/trx-rs/decoders"` | Log directory |
|
||
| `aprs_file` | string | `"TRXRS-APRS-%YYYY%-%MM%-%DD%.log"` | APRS log filename |
|
||
| `cw_file` | string | `"TRXRS-CW-%YYYY%-%MM%-%DD%.log"` | CW log filename |
|
||
| `ft8_file` | string | `"TRXRS-FT8-%YYYY%-%MM%-%DD%.log"` | FT8 log filename |
|
||
| `wspr_file` | string | `"TRXRS-WSPR-%YYYY%-%MM%-%DD%.log"` | WSPR log filename |
|
||
|
||
Files are appended in JSON Lines format. Supported date tokens: `%YYYY%`,
|
||
`%MM%`, `%DD%` (UTC).
|
||
|
||
#### `[decoders]`
|
||
|
||
| Field | Type | Default | Description |
|
||
|-------|------|---------|-------------|
|
||
| `enabled` | string[] | all decoders | Decoders to run for this rig |
|
||
| `output_dir` | string | `"$XDG_CACHE_HOME/trx-rs"` | Base directory for decoders that write images |
|
||
|
||
Valid decoder names: `aprs`, `aprs_hf`, `ais`, `cw`, `ft2`, `ft4`, `ft8`,
|
||
`lrpt`, `sstv`, `vdes`, `wefax`, `wspr` — the same names `[[sdr.channels]]`
|
||
uses. An unrecognised name is a config error.
|
||
|
||
Every decoder runs by default, which costs real CPU on a small machine. On a
|
||
station that only works digital modes, listing just what you use is worth it:
|
||
|
||
```toml
|
||
[decoders]
|
||
enabled = ["ft8", "ft4", "wspr"]
|
||
```
|
||
|
||
`sstv`, `wefax` and `lrpt` write images into a subdirectory of `output_dir`
|
||
named after the decoder. `ais` and `vdes` additionally require an SDR channel
|
||
configured to feed them.
|
||
|
||
#### Multi-Rig Configuration
|
||
|
||
Use `[[rigs]]` arrays instead of the flat `[rig]` section for multi-rig setups:
|
||
|
||
```toml
|
||
[[rigs]]
|
||
id = "ft817_0"
|
||
name = "HF Transceiver"
|
||
[rigs.rig]
|
||
model = "ft817"
|
||
[rigs.rig.access]
|
||
type = "serial"
|
||
path = "/dev/ttyUSB0"
|
||
baud = 9600
|
||
|
||
[[rigs]]
|
||
id = "sdr_0"
|
||
name = "VHF/UHF SDR"
|
||
[rigs.rig]
|
||
model = "soapysdr"
|
||
[rigs.rig.access]
|
||
type = "sdr"
|
||
args = "driver=rtlsdr"
|
||
```
|
||
|
||
When `[[rigs]]` is present it takes priority over the flat `[rig]` section.
|
||
Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1`.
|
||
|
||
### Client Options
|
||
|
||
#### `[general]`
|
||
|
||
| Field | Type | Default | Description |
|
||
|-------|------|---------|-------------|
|
||
| `callsign` | string | `"N0CALL"` | Station callsign |
|
||
| `log_level` | string | — | `trace`, `debug`, `info`, `warn`, or `error` |
|
||
|
||
#### `[remote]`
|
||
|
||
| Field | Type | Default | Description |
|
||
|-------|------|---------|-------------|
|
||
| `url` | string | — | Server address (e.g. `localhost:4530`) |
|
||
| `poll_interval_ms` | u64 | `750` | State poll interval |
|
||
| `spectrum_interval_ms` | u64 | `50` | Spectrum frame interval; also settable per `[[remotes]]` entry |
|
||
|
||
#### `[remote.auth]`
|
||
|
||
| Field | Type | Default | Description |
|
||
|-------|------|---------|-------------|
|
||
| `token` | string | — | Auth token (must not be empty if set) |
|
||
| `token_file` | string | — | Read the token from this file instead |
|
||
|
||
#### `[[remotes]]`
|
||
|
||
Preferred over the single `[remote]` section: one entry per rig, each mapping a
|
||
short name to a server and an optional server-side rig id.
|
||
|
||
| Field | Type | Default | Description |
|
||
|-------|------|---------|-------------|
|
||
| `name` | string | — | Short name used everywhere in the client |
|
||
| `url` | string | — | Server address (`host:port`) |
|
||
| `rig_id` | string | — | Rig id on a multi-rig server |
|
||
| `auth.token` | string | — | Auth token |
|
||
| `auth.token_file` | string | — | Read the token from this file instead |
|
||
| `poll_interval_ms` | u64 | `750` | State poll interval |
|
||
|
||
The `name` is the key used by `default_rig_name`, `rigctl.rig_ports`,
|
||
`audio.rig_urls`, `audio.rig_ports` and `decode_history_retention_min_by_rig`.
|
||
A name in any of those maps that no remote answers to is a config error.
|
||
|
||
#### `[frontends.http]`
|
||
|
||
| Field | Type | Default | Description |
|
||
|-------|------|---------|-------------|
|
||
| `enabled` | bool | `true` | Enable web UI |
|
||
| `listen` | ip | `127.0.0.1` | Bind address |
|
||
| `port` | u16 | `8080` | Bind port |
|
||
| `default_rig_name` | string | — | Remote selected on startup |
|
||
| `initial_map_zoom` | u8 | `10` | Starting zoom for the APRS map |
|
||
| `show_sdr_gain_control` | bool | `true` | Expose the RF gain control |
|
||
| `bandplan_enabled` | bool | `true` | Show the bandplan strip |
|
||
| `bandplan_region` | string | `"iaru_r1"` | `iaru_r1`, `iaru_r2`, or `iaru_r3` |
|
||
| `decode_history_retention_min` | u64 | `1440` | Decode history retention |
|
||
| `decode_history_retention_min_by_rig` | table | `{}` | Per-remote retention override |
|
||
| `spectrum_coverage_margin_hz` | u32 | `50000` | Centre-retune guard margin |
|
||
| `spectrum_usable_span_ratio` | f32 | `0.92` | Usable fraction of the sampled span |
|
||
|
||
#### `[frontends.http.auth]`
|
||
|
||
| Field | Type | Default | Description |
|
||
|-------|------|---------|-------------|
|
||
| `enabled` | bool | `false` | Enable the user/password ACL |
|
||
| `users_file` | string | `"trx-http-users.json"` | Persistent managed user database |
|
||
| `bootstrap_admin_username` | string | — | First administrator, used only if the database is absent |
|
||
| `bootstrap_admin_password` | string | — | First administrator password |
|
||
| `bootstrap_admin_password_file` | string | — | Read the bootstrap password from this file instead |
|
||
| `bootstrap_read_enabled` | bool | `true` | Create the default Guest account when the database is absent |
|
||
| `bootstrap_read_username` | string | `"guest"` | Initial Guest username |
|
||
| `bootstrap_read_password` | string | `"guest"` | Initial Guest password |
|
||
| `session_ttl_min` | u64 | `480` | Session lifetime |
|
||
| `cookie_secure` | bool | `false` | Set Secure on the session cookie (needs HTTPS) |
|
||
| `cookie_same_site` | string | `"Lax"` | `Strict`, `Lax`, or `None` |
|
||
|
||
When enabling ACL for the first time, configure both bootstrap fields. After
|
||
the database exists, remove the bootstrap credentials from configuration.
|
||
|
||
#### `[frontends.rigctl]`
|
||
|
||
| Field | Type | Default | Description |
|
||
|-------|------|---------|-------------|
|
||
| `enabled` | bool | `false` | Enable Hamlib rigctl |
|
||
| `listen` | ip | `127.0.0.1` | Bind address |
|
||
| `rig_ports` | table | `{}` | Remote name → local port; one listener each |
|
||
|
||
One listener is started per `rig_ports` entry, each routing to its rig, so
|
||
`rig_ports` must name at least one remote when the frontend is enabled. The
|
||
older single `port` key and `--rigctl-port` are ignored.
|
||
|
||
#### `[frontends.http_json]`
|
||
|
||
| Field | Type | Default | Description |
|
||
|-------|------|---------|-------------|
|
||
| `enabled` | bool | `true` | Enable JSON-over-TCP |
|
||
| `listen` | ip | `127.0.0.1` | Bind address |
|
||
| `port` | u16 | `0` | Bind port (0 = ephemeral) |
|
||
| `auth.tokens` | string[] | `[]` | Allowed auth tokens |
|
||
| `auth.tokens_file` | string | — | Read tokens from this file, one per line |
|
||
|
||
#### `[frontends.audio]`
|
||
|
||
| Field | Type | Default | Description |
|
||
|-------|------|---------|-------------|
|
||
| `enabled` | bool | `true` | Enable audio client |
|
||
| `server_url` | string | — | Audio endpoint for every remote |
|
||
| `rig_urls` | table | `{}` | Remote name → audio URL (wins over `server_url`) |
|
||
| `server_port` | u16 | `4531` | Fallback port when no URL is configured |
|
||
| `rig_ports` | table | `{}` | Remote name → port; superseded by `rig_urls` |
|
||
| `bridge.enabled` | bool | `false` | Enable local CPAL audio bridge |
|
||
| `bridge.rx_output_device` | string | — | Local playback device |
|
||
| `bridge.tx_input_device` | string | — | Local capture device |
|
||
| `bridge.rx_gain` | float | `1.0` | RX playback gain |
|
||
| `bridge.tx_gain` | float | `1.0` | TX capture gain |
|
||
|
||
The bridge is intended for WSJT-X integration via virtual audio devices (ALSA
|
||
loopback on Linux, BlackHole on macOS).
|
||
|
||
### Spectrum over a slow link
|
||
|
||
Spectrum dominates the server↔client connection: everything else is a few
|
||
hundred bytes, a frame is a few kilobytes. Three things govern what it costs.
|
||
|
||
**Frames are pushed, not polled.** The client subscribes and the server sends
|
||
frames at `[sdr].spectrum_interval_ms`. Polling cost a round trip per frame, so
|
||
the rate was capped at 1/RTT — on a 200 ms link you could not exceed 5 frames a
|
||
second however often the client asked. Clients fall back to polling
|
||
automatically against a server too old to stream.
|
||
|
||
**Bins travel as whole dBFS.** They are base64-encoded `i8` on the wire, about
|
||
an eighth of the JSON array of floats they used to be, at the resolution the
|
||
display draws anyway.
|
||
|
||
**Both ends have a rate, and the slower one wins.** The server pushes no faster
|
||
than `[sdr].spectrum_interval_ms`; the client asks for no more than
|
||
`[[remotes]].spectrum_interval_ms`.
|
||
|
||
For a link that struggles, start here:
|
||
|
||
```toml
|
||
[trx-server.sdr]
|
||
spectrum_fft_size = 512 # half the bins, half the bytes
|
||
spectrum_interval_ms = 200 # 5 frames/s instead of 20
|
||
|
||
[[trx-client.remotes]]
|
||
name = "remote-site"
|
||
url = "radio.example.com:4530"
|
||
spectrum_interval_ms = 200
|
||
```
|
||
|
||
That is roughly 0.7 KB per frame at 5 frames/s — about 3.5 KB/s, against
|
||
roughly 200 KB/s for 1024 float bins at 20 frames/s.
|
||
|
||
### CLI Override Summary
|
||
|
||
**trx-server:**
|
||
`--config`, `--print-config`, `--check-config`, `--strict-config`, `--rig`,
|
||
`--access`, `--callsign`, `--listen`, `--port`. SDR options are file-only.
|
||
|
||
**trx-client:**
|
||
`--config`, `--print-config`, `--check-config`, `--strict-config`, `--url`,
|
||
`--token`, `--poll-interval`, `--rig-id`, `--frontend`, `--http-listen`,
|
||
`--http-port`, `--rigctl-listen`, `--http-json-listen`, `--http-json-port`,
|
||
`--callsign`.
|
||
|
||
`--listen` on the server overrides the bind address of both the control
|
||
listener and every rig's audio listener.
|
||
|
||
---
|
||
|
||
## Multiple Rigs in the Web UI
|
||
|
||
A client connected to several rigs decodes all of them at once, whichever one
|
||
is on screen. The rig picker in the header decides which rig the UI is about,
|
||
and each page answers that differently:
|
||
|
||
| Page | Shows |
|
||
|------|-------|
|
||
| Radio | The selected rig: its spectrum, its audio, and the mini decode views over the waterfall. |
|
||
| Digital Modes | The selected rig: every decoder panel, its counts and its status line. |
|
||
| Map | The whole station — every rig's positions, with the map's own rig filter to narrow it. HF APRS is a source of its own there, filtered apart from VHF APRS. |
|
||
| Statistics | The whole station, including the per-rig comparison. |
|
||
|
||
Switching rigs repaints the radio and digital modes pages for the rig now
|
||
selected. Nothing is lost by switching: the traffic other rigs heard is still
|
||
held, and switching back brings it up again. The one exception is the CW pane,
|
||
which is a single running stream of copied text rather than a list of frames,
|
||
so it starts empty on the rig you switch to.
|
||
|
||
Each browser tab keeps its own selection, so two tabs can watch two rigs.
|
||
|
||
---
|
||
|
||
## Logbook
|
||
|
||
The **Logbook** tab keeps the station's contacts and speaks ADIF, so a log can
|
||
be uploaded to LoTW, eQSL, Club Log or QRZ, or moved to another logger.
|
||
|
||
An entry opens pre-filled with six fields and no more: the frequency, mode and
|
||
name of the rig you are on, the time from the server's clock, and — when the
|
||
entry was started from a decode or a map station — that station's callsign and
|
||
locator. Signal reports, name, comment and the rest are yours to fill in; a
|
||
report in particular is never guessed, because an FT8 SNR is not what was sent.
|
||
|
||
Your own callsign and locator are not per-contact fields. They are station
|
||
identity: the callsign comes from `[trx-client.general].callsign`, the locator
|
||
from the rig's position, and both are shown once at the top of the panel. The
|
||
operator defaults to the station callsign and can be changed for the session,
|
||
which is what a multi-operator station needs.
|
||
|
||
**Times are the server's**, in UTC — the server is the machine at the radio. If
|
||
the browser's clock disagrees by more than a second the entry says so rather
|
||
than logging a time you did not expect.
|
||
|
||
**A decode is not a contact.** The decoders are receive-only, so a **Log**
|
||
button on an FT8 or APRS row opens an entry with what was heard in it and logs
|
||
nothing by itself. Digital QSOs made in WSJT-X come in through Import ADIF, the
|
||
way every other logger takes them.
|
||
|
||
| Action | What it does |
|
||
|--------|--------------|
|
||
| Log contact | Writes the entry and opens a fresh one |
|
||
| Export ADIF | Downloads the log — filtered, if a filter is set |
|
||
| Import ADIF | Reads a file, skipping contacts already held and reporting what could not be read |
|
||
| Worked before | Shows, as you type a callsign, which bands and modes it has been worked on |
|
||
|
||
Two contacts are treated as the same when the callsign, band and mode match and
|
||
the times are within two minutes: two loggers rarely stamp a QSO to the same
|
||
minute, one recording when it started and the other when it was typed.
|
||
|
||
The log is a JSON Lines file, appended one contact at a time so a crash costs at
|
||
most the contact being written. It lives in your data directory by default;
|
||
`[trx-client.logbook].path` moves it, for a station that keeps its log on a
|
||
backed-up volume.
|
||
|
||
### Contests
|
||
|
||
The **Contest exchange** block on the entry holds the contest's name and the
|
||
serials — sent and received. Both stay between contacts, because they belong to
|
||
the session rather than to the contact just logged, and the serial sent counts
|
||
on by itself so it is not retyped forty times an hour. An exchange that is a
|
||
zone, a section or a name rather than a number is kept as written.
|
||
|
||
**Contest entry (Cabrillo)** exports the entry sponsors accept. Only the
|
||
contacts of the contest named there are included. The header — operator
|
||
category, power, claimed score — cannot be worked out from a log, so it is
|
||
yours to fill in; the defaults are single operator, low power, all bands, mixed
|
||
mode.
|
||
|
||
### Confirmations
|
||
|
||
The **QSL** column shows a tick when the other station has confirmed, and the
|
||
**Confirm** button on a row records a card that has arrived. A confirmation
|
||
counts from wherever it came: a paper card, LoTW or eQSL. An award wants one of
|
||
them, not all three, so the log does not ask for all three.
|
||
|
||
**Bands worked** counts, per band, the contacts made, the distinct stations
|
||
worked, and how many of those contacts are confirmed.
|
||
|
||
### Ham radio layout
|
||
|
||
The **Ham radio** operator layout opens on the logbook with the transceiver
|
||
controls around it — the arrangement for working the bands, where logging the
|
||
contact is the task and the radio is the instrument. It is offered only where
|
||
the selected rig can transmit; a receiver has no contacts to log.
|
||
|
||
---
|
||
|
||
## Tune Links
|
||
|
||
Every page of the web UI carries what the radio is doing in its address, so the
|
||
URL in the address bar is always a link someone else can open:
|
||
|
||
```
|
||
http://receiver.example:8080/?rig=sdr&f=14074000&mode=USB&bw=3000
|
||
```
|
||
|
||
| Parameter | Meaning |
|
||
|-----------|---------|
|
||
| `f` | Frequency. Hz by default; `7074k` and `14.074M` also work. |
|
||
| `mode` | Demodulation mode, e.g. `USB`, `CW`, `WFM`. |
|
||
| `bw` | Filter bandwidth in Hz. Ignored by rigs without filter control. |
|
||
| `rig` | Rig to select first, by id, on a multi-rig client. |
|
||
|
||
Opening such a link selects the rig, sets the mode, tunes, and applies the
|
||
bandwidth, in that order — a mode change carries its own default bandwidth, so
|
||
an explicit `bw` is applied last. Anything the rig cannot do (an unknown mode,
|
||
a frequency outside its range) is reported and the rest of the link still
|
||
applies. All four parameters are optional.
|
||
|
||
The link button in the top bar copies the current link to the clipboard. The
|
||
address bar itself is updated as you tune, using `replaceState`, so sweeping
|
||
the dial does not fill the browser's history.
|
||
|
||
Applying a link changes the radio, so it needs the `Control` role; a `Read`
|
||
session opens the page and says the link was not applied. Links describe the
|
||
rig's own dial — while a tab is listening to a virtual channel the address is
|
||
left as it was, rather than publishing a frequency the rig is not on.
|
||
|
||
---
|
||
|
||
## Authentication
|
||
|
||
The HTTP frontend supports an optional user/password ACL:
|
||
|
||
- **Guest** — read-only station access with no Account or Users controls; Guest cannot be combined with another role
|
||
- **Read** — monitoring, audio, decode streams, and bookmark reads
|
||
- **Control** — tuning, mode, power, and receive-side radio controls
|
||
- **Transmit** — PTT, transmitted audio, and TX power-limit controls
|
||
- **Write** — logbook access and bookmark changes
|
||
- **Administrator** — user management and all other permissions
|
||
|
||
### Configuration
|
||
|
||
```toml
|
||
[frontends.http.auth]
|
||
enabled = false
|
||
users_file = "trx-http-users.json"
|
||
bootstrap_admin_username = "admin"
|
||
bootstrap_admin_password = "change-this-password"
|
||
bootstrap_read_enabled = true
|
||
bootstrap_read_username = "guest"
|
||
bootstrap_read_password = "guest"
|
||
session_ttl_min = 480
|
||
cookie_secure = false # true if served via HTTPS
|
||
cookie_same_site = "Lax" # Strict|Lax|None
|
||
```
|
||
|
||
When `enabled = false` (the default), all auth is bypassed and the UI behaves
|
||
as before. When enabling it for the first time, bootstrap credentials create
|
||
the initial administrator (with every non-Guest role), the default `guest`/`guest` Guest
|
||
account, and the Argon2id-hashed user database. Change or disable the guest
|
||
credentials in configuration before first startup on an exposed deployment.
|
||
|
||
### Behaviour
|
||
|
||
- On login, the server issues an `HttpOnly` session cookie.
|
||
- Sessions are in-memory; a server restart invalidates all sessions.
|
||
- Rate limiting is applied per IP to mitigate brute-force attempts.
|
||
- User records persist in `users_file`; passwords are stored as salted Argon2id hashes.
|
||
- Non-Guest roles are independent; for example, an account may have Read and Write without Control.
|
||
- Guest accounts have no account-control panels and cannot call account-control endpoints.
|
||
- Every non-Guest signed-in user can change their own password in Settings > Account. This signs out all of their sessions.
|
||
- Administrators can add, enable/disable, or remove users and change roles/passwords in Settings > Users.
|
||
- At least one enabled administrator must always remain and cannot be disabled, removed, or demoted.
|
||
- Disabling/removing an account or changing its password/roles revokes all of its sessions.
|
||
- Existing account files migrate automatically: legacy accounts are enabled by default and legacy `user`/`admin` roles become Read/all roles.
|
||
|
||
### Routes
|
||
|
||
| Endpoint | Method | Description |
|
||
|----------|--------|-------------|
|
||
| `/auth/login` | POST | Submit `{ "username": "...", "password": "..." }` |
|
||
| `/auth/logout` | POST | Clear session |
|
||
| `/auth/session` | GET | Check current session/roles |
|
||
| `/auth/account/password` | PATCH | Change a non-Guest user's password after verifying the current password |
|
||
| `/auth/users` | GET/POST | List or add users (admin only) |
|
||
| `/auth/users/{username}` | PATCH/DELETE | Change enabled state/password/roles or remove user (administrator only) |
|
||
|
||
Read routes accept Guest or require Read. Tuning and receive-side radio mutations
|
||
require Control. PTT, transmitted audio, and TX limit changes require Transmit.
|
||
Logbook access and bookmark mutations require Write. Administrator grants every permission.
|
||
|
||
### Frontend Flow
|
||
|
||
1. On load, the UI calls `/auth/session`.
|
||
2. If unauthenticated, a login screen is shown.
|
||
3. On successful login, the normal UI loads.
|
||
4. The interface enables controls according to the account's roles.
|
||
5. If a session expires mid-use, streams stop and the login screen returns.
|
||
|
||
### Transport Security
|
||
|
||
There is no built-in TLS. For remote access, place trx-rs behind a
|
||
TLS-terminating reverse proxy (nginx, Caddy) and set `cookie_secure = true`.
|
||
|
||
---
|
||
|
||
## Background Decoding Scheduler
|
||
|
||
The scheduler automatically retunes the rig to pre-configured bookmarks when no
|
||
users are connected to the HTTP frontend. It runs as a background task inside
|
||
`trx-frontend-http`, polling every 30 seconds.
|
||
|
||
### Modes
|
||
|
||
#### Disabled (default)
|
||
|
||
Scheduler is inactive. The rig is not touched automatically.
|
||
|
||
#### Grayline
|
||
|
||
Retunes around the solar terminator (day/night boundary).
|
||
|
||
The user provides:
|
||
- Station latitude and longitude (decimal degrees)
|
||
- Optional transition window width (minutes, default 20)
|
||
- Bookmark IDs for four periods:
|
||
- **Dawn** — window around sunrise (`sunrise ± window_min/2`)
|
||
- **Day** — after dawn until dusk
|
||
- **Dusk** — window around sunset (`sunset ± window_min/2`)
|
||
- **Night** — after dusk until next dawn
|
||
|
||
Period precedence (most specific wins): Dawn > Dusk > Day > Night.
|
||
|
||
If no bookmark is assigned to a period, the rig is not retuned for that period.
|
||
|
||
Sunrise/sunset is computed inline using the NOAA simplified algorithm. Polar
|
||
regions (midnight sun / polar night) fall back to Day/Night accordingly.
|
||
|
||
#### TimeSpan
|
||
|
||
Retunes according to a list of user-defined time windows (UTC).
|
||
|
||
Each entry specifies:
|
||
- `start_hhmm` — start of window (e.g. 600 = 06:00 UTC)
|
||
- `end_hhmm` — end of window (e.g. 700 = 07:00 UTC)
|
||
- `bookmark_id` — bookmark to apply
|
||
- `label` — optional human-readable description
|
||
|
||
Windows that span midnight (`end_hhmm < start_hhmm`) are supported. When
|
||
multiple entries overlap, the first match (by list order) wins.
|
||
|
||
### Storage
|
||
|
||
Configuration is stored in PickleDB at `~/.config/trx-rs/scheduler.db`.
|
||
|
||
Keys: `sch:{rig_id}` → JSON `SchedulerConfig`.
|
||
|
||
### HTTP API
|
||
|
||
All read endpoints are accessible at the **Rx** role level. Write endpoints
|
||
require the **Control** role.
|
||
|
||
| Method | Path | Description |
|
||
|--------|------|-------------|
|
||
| GET | `/scheduler/{rig_id}` | Get scheduler config for a rig |
|
||
| PUT | `/scheduler/{rig_id}` | Save scheduler config (Control only) |
|
||
| DELETE | `/scheduler/{rig_id}` | Reset config to Disabled (Control only) |
|
||
| GET | `/scheduler/{rig_id}/status` | Get last-applied bookmark and next event |
|
||
|
||
### Activation Logic
|
||
|
||
Every 30 seconds the scheduler task checks:
|
||
1. No SSE clients connected
|
||
2. Active rig has a non-Disabled scheduler config
|
||
3. Current UTC time matches a scheduled window or grayline period
|
||
4. If the matching bookmark differs from last applied, send `SetFreq` + `SetMode`
|
||
|
||
The scheduler does not revert changes when users reconnect.
|
||
|
||
### Web UI
|
||
|
||
A dedicated tab with a clock icon provides:
|
||
- Rig selector (read-only, shows active rig)
|
||
- Mode picker: Disabled / Grayline / TimeSpan
|
||
- Grayline section: lat/lon inputs, transition window slider, four bookmark selectors
|
||
- TimeSpan section: table of entries with start/end times, bookmark, label
|
||
- Status card: last applied bookmark name and timestamp
|
||
- Save button (Control role only)
|
||
|
||
---
|
||
|
||
## SDR Noise Blanker
|
||
|
||
The noise blanker suppresses impulse noise (clicks, pops, ignition interference)
|
||
on raw IQ samples before any mixing or filtering takes place — the only point in
|
||
the chain where an impulse is still short in time, since the narrow channel
|
||
filter downstream smears it into un-removable ringing.
|
||
|
||
It combines four elements:
|
||
|
||
- A **noise-floor tracker** — an exponential estimate of the background level,
|
||
updated only from clean samples (and frozen during a blank) so a burst cannot
|
||
drag the reference up and blind the detector.
|
||
- **Detection** — a sample is flagged when its power exceeds
|
||
**threshold² × noise-floor**.
|
||
- **Look-ahead** — the stream is delayed a few microseconds so the gate can
|
||
begin closing *before* the impulse reaches the output, catching its leading
|
||
edge instead of letting it leak through.
|
||
- A **tapered gate** — instead of a hard sample-and-hold (which splatters energy
|
||
back across the passband and is what made the old blanker sound worse on
|
||
SSB/CW/data), the gain ramps smoothly down and back up, so blanking costs only
|
||
a short, quiet notch.
|
||
|
||
The blank window, look-ahead, taper, and floor time constant are chosen by a
|
||
**profile** matched to the interference source. The `threshold` control is
|
||
orthogonal — it sets detection sensitivity within the chosen profile. All
|
||
profile timings are specified in real time and converted to samples at the
|
||
capture rate, so the blanker behaves consistently across SDR sample rates.
|
||
|
||
### Configuration (server-side)
|
||
|
||
The noise blanker is configured per rig. In a multi-rig setup each
|
||
`[[rigs]]` entry has its own `[rigs.sdr.noise_blanker]` section:
|
||
|
||
```toml
|
||
[[rigs]]
|
||
id = "hf"
|
||
|
||
[rigs.rig]
|
||
type = "sdr"
|
||
|
||
[rigs.sdr.noise_blanker]
|
||
enabled = true
|
||
threshold = 10.0 # 1 – 100; lower = more aggressive blanking
|
||
profile = "spike" # spike | ignition | powerline | broadband
|
||
```
|
||
|
||
For the legacy single-rig (flat) config the path is `[sdr.noise_blanker]`:
|
||
|
||
```toml
|
||
[sdr.noise_blanker]
|
||
enabled = true
|
||
threshold = 10.0
|
||
profile = "spike"
|
||
```
|
||
|
||
| Field | Type | Default | Range | Description |
|
||
|-------------|--------|-----------|---------|-------------|
|
||
| `enabled` | bool | false | — | Turn the noise blanker on or off. |
|
||
| `threshold` | float | 10.0 | 1 – 100 | Multiplier applied to the tracked noise floor. A sample whose magnitude exceeds this multiple is blanked. Lower values blank more aggressively; higher values only catch strong impulses. |
|
||
| `profile` | string | `"spike"` | see below | Tuning profile matched to the interference source. |
|
||
|
||
The noise blanker is off by default.
|
||
|
||
### Profiles
|
||
|
||
Each profile sets the blank-window width, look-ahead, gate taper, and
|
||
noise-floor time constant. Pick the one that matches what you are hearing, then
|
||
fine-tune with the threshold.
|
||
|
||
| Profile | Blank window | Best for |
|
||
|--------------|--------------|----------|
|
||
| `spike` | Narrowest | Sharp, sparse impulses — ignition sparks, static crashes, keyed relays. The safe default: minimal impact on the wanted signal, good for SSB/CW/digital. |
|
||
| `ignition` | Medium | Automotive ignition, electric fences, PWM/LED drivers — clusters of medium-width pulses at a high repetition rate. |
|
||
| `powerline` | Wide | Power-line and arcing noise — buzzy bursts locked to the 100/120 Hz mains cycle. Uses a slower floor tracker to ride out the burst. |
|
||
| `broadband` | Widest | Dense, continuous impulse noise where suppression matters more than fidelity. Most aggressive gating; expect some softening of the wanted signal. |
|
||
|
||
### Choosing a threshold
|
||
|
||
The threshold controls how aggressively the blanker suppresses impulses.
|
||
A value of **N** means: blank any sample whose magnitude exceeds **N times**
|
||
the running average signal level.
|
||
|
||
| Threshold | Behavior | Use case |
|
||
|-----------|----------|----------|
|
||
| 3 – 5 | Very aggressive — blanks frequently | Dense impulse noise (motors, power lines, LED drivers nearby) |
|
||
| 8 – 12 | Moderate — catches clear spikes without touching normal signals | Typical HF conditions with occasional ignition or switching noise |
|
||
| 15 – 25 | Conservative — only blanks strong impulses well above the noise floor | Light interference, or when you want minimal artifacts on weak signals |
|
||
| 30 – 100 | Very light — rarely triggers | Faint, infrequent clicks; mostly a safety net |
|
||
|
||
**Start at 10** (the default) and adjust while listening:
|
||
|
||
- If impulse noise is still audible, lower the threshold.
|
||
- If weak signals sound choppy or distorted, raise it — the blanker may be
|
||
mistaking signal peaks for noise.
|
||
- On bands with steady atmospheric noise (e.g. 160 m / 80 m), a threshold of
|
||
**5 – 8** usually works well.
|
||
- On quieter VHF/UHF bands where the noise floor is low, values of **15 – 25**
|
||
avoid false triggers from strong signals.
|
||
|
||
### Web UI
|
||
|
||
When the server reports noise-blanker support, these controls appear in the
|
||
**SDR Settings** row of the web interface:
|
||
|
||
- **Noise Blanker** checkbox — enables or disables the blanker in real time.
|
||
The **N** keyboard shortcut toggles it too.
|
||
- **NB Threshold** number input (1–100) with a **Set** button — adjusts the
|
||
detection sensitivity. Press Enter or click Set to apply.
|
||
- **NB profile** selector — chooses the profile (Spike / Ignition / Powerline /
|
||
Broadband). Changing it applies immediately.
|
||
|
||
The controls stay hidden until the server sends filter state containing NB
|
||
fields, so they only appear when connected to an SDR backend.
|
||
|
||
### HTTP API
|
||
|
||
```
|
||
POST /set_sdr_noise_blanker?enabled=true&threshold=10&profile=spike
|
||
```
|
||
|
||
| Parameter | Type | Required | Description |
|
||
|-------------|--------|----------|-------------|
|
||
| `enabled` | bool | yes | `true` or `false` |
|
||
| `threshold` | float | yes | Value between 1 and 100 |
|
||
| `profile` | string | no | `spike` (default), `ignition`, `powerline`, or `broadband` |
|
||
|
||
### How it works
|
||
|
||
The blanker runs on every IQ block *before* the mixer stage in the DSP pipeline,
|
||
one sample at a time:
|
||
|
||
1. Emit the sample from the look-ahead delay line and ingest the fresh one.
|
||
2. Compute the fresh sample's power (`re² + im²`) and compare it against
|
||
`threshold² × noise_floor`.
|
||
3. If it exceeds the threshold, hold the gate closed for the profile's blank
|
||
window; the fresh sample reaches the output a few samples later, by which
|
||
time the gate has fully ramped to zero — so the leading edge is removed.
|
||
4. Otherwise, update the noise-floor estimate (skipped while blanking) and let
|
||
the gate ramp back open.
|
||
|
||
Because the blanker operates on raw IQ before frequency translation, it removes
|
||
impulse noise across the entire captured bandwidth regardless of the tuned
|
||
channel offset.
|