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