Files
trx-rs/docs/User-Manual.md
T
sjgandClaude Opus 5 b78c4a4dd4
CI / lint (pull_request) Successful in 2m22s
CI / test (pull_request) Successful in 8m36s
CI / frontend (push) Successful in 3m38s
CI / reuse (push) Successful in 6s
CI / frontend (pull_request) Successful in 4m27s
CI / reuse (pull_request) Successful in 6s
CI / lint (push) Successful in 2m21s
CI / test (push) Successful in 7m48s
[feat](trx-rs): make spectrum affordable over a slow link
Spectrum dominates the server↔client connection, and all three things that
govern its cost were working against a poor link.

**It was polled, one round trip per frame.** The client asked for a frame every
50 ms on a dedicated connection and waited for the reply, so the frame rate was
capped at 1/RTT — on a 200 ms link, five frames a second no matter what was
configured.  Add SubscribeSpectrum alongside the existing SubscribeMeter: the
server pushes frames from a per-rig broadcast that rig_task fills only while
somebody is subscribed.  A server too old to know the command answers with an
error and leaves the connection usable, so the client falls back to polling on
the same connection without reconnecting.

**Bins were JSON floats.** 1024 bins spelled out as decimal text is around
10 KB a frame, ~200 KB/s at full rate — while the very next hop, client to
browser, already sends the same information as base64 i8 in about 1.4 KB.  Bins
now travel base64-encoded whole dBFS, the resolution the display draws at
anyway.  Decoding still accepts the old array form.

**Nothing was tunable.** [sdr].spectrum_fft_size and [sdr].spectrum_interval_ms
replace the compile-time FFT size and cadence; [[remotes]].spectrum_interval_ms
lets the client ask for less.  512 bins at 5 frames/s is roughly 3.5 KB/s
against roughly 200 KB/s before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-07 00:15:50 +02:00

28 KiB
Raw Blame History

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:

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].rx_passphrase rx_passphrase_file the passphrase
[frontends.http.auth].control_passphrase control_passphrase_file the passphrase
[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 (8000192000)
channels u8 1 Channel count (1 or 2)
frame_duration_ms u16 20 Opus frame duration (3, 5, 10, 20, 40, 60)
bitrate_bps u32 24000 Opus bitrate

When audio is enabled, at least one of rx_enabled or tx_enabled must be true.

[sdr]

Field Type Default Description
sample_rate u32 1920000 IQ capture rate in Hz
bandwidth u32 1500000 Hardware IF filter bandwidth in Hz
center_offset_hz i64 100000 Offset from dial to avoid DC spur
spectrum_fft_size usize 1024 Spectrum FFT bins; power of two, 1288192
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.

[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:

[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:

[[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 Require a passphrase
rx_passphrase string Passphrase granting receive-only access
rx_passphrase_file string Read it from this file instead
control_passphrase string Passphrase granting full control
control_passphrase_file string Read it from this file instead
tx_access_control_enabled bool true Hide TX from unauthenticated users
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

With enabled = true, at least one passphrase must be set.

[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 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:

[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.


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; an rx 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 optional passphrase-based authentication with two roles:

  • rx — read-only access (monitoring, audio, decode streams)
  • control — full access (frequency, mode, PTT, and all settings)

Configuration

[frontends.http.auth]
enabled = false
rx_passphrase = "rx-only-passphrase"
control_passphrase = "full-control-passphrase"
tx_access_control_enabled = true
session_ttl_min = 480
cookie_secure = false      # true if served via HTTPS
cookie_same_site = "Lax"   # Strict|Lax|None

When enabled = false (the default), all auth is bypassed and the UI behaves as before. When enabled, at least one passphrase must be set.

Behaviour

  • On login, the server issues an HttpOnly session cookie.
  • Sessions are in-memory; a server restart invalidates all sessions.
  • Rate limiting is applied per IP to mitigate brute-force attempts.
  • When tx_access_control_enabled = true, TX/PTT controls are hidden and rejected for unauthenticated or rx-role users.

Routes

Endpoint Method Description
/auth/login POST Submit { "passphrase": "..." }
/auth/logout POST Clear session
/auth/session GET Check current session/role

Protected routes require at least rx role. Control routes (set frequency, mode, PTT, etc.) require control role.

Frontend Flow

  1. On load, the UI calls /auth/session.
  2. If unauthenticated, a login screen is shown.
  3. On successful login, the normal UI loads.
  4. rx users see a read-only interface; control users get full controls.
  5. If a session expires mid-use, streams stop and the login screen returns.

Transport Security

There is no built-in TLS. For remote access, place trx-rs behind a TLS-terminating reverse proxy (nginx, Caddy) and set cookie_secure = true.


Background Decoding Scheduler

The scheduler automatically retunes the rig to pre-configured bookmarks when no users are connected to the HTTP frontend. It runs as a background task inside trx-frontend-http, polling every 30 seconds.

Modes

Disabled (default)

Scheduler is inactive. The rig is not touched automatically.

Grayline

Retunes around the solar terminator (day/night boundary).

The user provides:

  • Station latitude and longitude (decimal degrees)
  • Optional transition window width (minutes, default 20)
  • Bookmark IDs for four periods:
    • Dawn — window around sunrise (sunrise ± window_min/2)
    • Day — after dawn until dusk
    • Dusk — window around sunset (sunset ± window_min/2)
    • Night — after dusk until next dawn

Period precedence (most specific wins): Dawn > Dusk > Day > Night.

If no bookmark is assigned to a period, the rig is not retuned for that period.

Sunrise/sunset is computed inline using the NOAA simplified algorithm. Polar regions (midnight sun / polar night) fall back to Day/Night accordingly.

TimeSpan

Retunes according to a list of user-defined time windows (UTC).

Each entry specifies:

  • start_hhmm — start of window (e.g. 600 = 06:00 UTC)
  • end_hhmm — end of window (e.g. 700 = 07:00 UTC)
  • bookmark_id — bookmark to apply
  • label — optional human-readable description

Windows that span midnight (end_hhmm < start_hhmm) are supported. When multiple entries overlap, the first match (by list order) wins.

Storage

Configuration is stored in PickleDB at ~/.config/trx-rs/scheduler.db.

Keys: sch:{rig_id} → JSON SchedulerConfig.

HTTP API

All read endpoints are accessible at the Rx role level. Write endpoints require the Control role.

Method Path Description
GET /scheduler/{rig_id} Get scheduler config for a rig
PUT /scheduler/{rig_id} Save scheduler config (Control only)
DELETE /scheduler/{rig_id} Reset config to Disabled (Control only)
GET /scheduler/{rig_id}/status Get last-applied bookmark and next event

Activation Logic

Every 30 seconds the scheduler task checks:

  1. No SSE clients connected
  2. Active rig has a non-Disabled scheduler config
  3. Current UTC time matches a scheduled window or grayline period
  4. If the matching bookmark differs from last applied, send SetFreq + SetMode

The scheduler does not revert changes when users reconnect.

Web UI

A dedicated tab with a clock icon provides:

  • Rig selector (read-only, shows active rig)
  • Mode picker: Disabled / Grayline / TimeSpan
  • Grayline section: lat/lon inputs, transition window slider, four bookmark selectors
  • TimeSpan section: table of entries with start/end times, bookmark, label
  • Status card: last applied bookmark name and timestamp
  • Save button (Control role only)

SDR Noise Blanker

The noise blanker suppresses impulse noise (clicks, pops, ignition interference) on raw IQ samples before any mixing or filtering takes place. It works by tracking a running RMS level of the signal and replacing any sample whose magnitude exceeds threshold x RMS with the last known clean sample.

Configuration (server-side)

The noise blanker is configured per rig. In a multi-rig setup each [[rigs]] entry has its own [rigs.sdr.noise_blanker] section:

[[rigs]]
id = "hf"

[rigs.rig]
type = "sdr"

[rigs.sdr.noise_blanker]
enabled = true
threshold = 10.0     # 1  100; lower = more aggressive blanking

For the legacy single-rig (flat) config the path is [sdr.noise_blanker]:

[sdr.noise_blanker]
enabled = true
threshold = 10.0
Field Type Default Range Description
enabled bool false Turn the noise blanker on or off.
threshold float 10.0 1 100 Multiplier applied to the running RMS. A sample whose magnitude exceeds this multiple is replaced. Lower values blank more aggressively; higher values only catch strong impulses.

The noise blanker is off by default.

Choosing a threshold

The threshold controls how aggressively the blanker suppresses impulses. A value of N means: blank any sample whose magnitude exceeds N times the running average signal level.

Threshold Behavior Use case
3 5 Very aggressive — blanks frequently Dense impulse noise (motors, power lines, LED drivers nearby)
8 12 Moderate — catches clear spikes without touching normal signals Typical HF conditions with occasional ignition or switching noise
15 25 Conservative — only blanks strong impulses well above the noise floor Light interference, or when you want minimal artifacts on weak signals
30 100 Very light — rarely triggers Faint, infrequent clicks; mostly a safety net

Start at 10 (the default) and adjust while listening:

  • If impulse noise is still audible, lower the threshold.
  • If weak signals sound choppy or distorted, raise it — the blanker may be mistaking signal peaks for noise.
  • On bands with steady atmospheric noise (e.g. 160 m / 80 m), a threshold of 5 8 usually works well.
  • On quieter VHF/UHF bands where the noise floor is low, values of 15 25 avoid false triggers from strong signals.

Web UI

When the server reports noise-blanker support, two controls appear in the SDR Settings row of the web interface:

  • Noise Blanker checkbox — enables or disables the blanker in real time.
  • NB Threshold number input (1100) with a Set button — adjusts the detection threshold. Press Enter or click Set to apply.

Both controls stay hidden until the server sends filter state containing NB fields, so they only appear when connected to an SDR backend.

HTTP API

POST /set_sdr_noise_blanker?enabled=true&threshold=10
Parameter Type Required Description
enabled bool yes true or false
threshold float yes Value between 1 and 100

How it works

The blanker runs on every IQ block (4096 samples) before the mixer stage in the DSP pipeline:

  1. For each sample, compute magnitude² (re² + im²).
  2. Compare against threshold² × mean_sq (the exponentially-smoothed running mean of magnitude²).
  3. If the sample exceeds the threshold, replace it with the previous clean sample.
  4. Otherwise, update the running mean with smoothing factor α = 1/128 and store the sample as the last clean value.

Because the blanker operates on raw IQ before frequency translation, it removes impulse noise across the entire captured bandwidth regardless of the tuned channel offset.