Compare commits

...
20 Commits
Author SHA1 Message Date
sjg 0b1fb005f6 [fix](trx-rs): install binaries under user home
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / reuse (push) Canceled after 0s
Default installation and removal to /home/sjg/.local/bin while preserving explicit prefix and binary-directory overrides. Update the service examples and README to match.

Assisted-By: OpenAI Codex (GPT-5)
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-18 23:08:08 +02:00
sjg be9d5c301b [docs](trx-rs): add safe deployment guide
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / reuse (push) Canceled after 0s
Document deployment with a dedicated service account, restricted device access, authenticated network listeners, systemd user services, reverse proxying, verification, upgrades, and rollback.

Assisted-By: OpenAI Codex (GPT-5)
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-18 22:53:00 +02:00
sjg b73c97dd5b [fix](trx-server): honor per-rig audio bind addresses
Use each rig's configured audio listener unless --listen explicitly overrides all bind addresses. Keep preflight socket validation consistent with runtime behavior.

Assisted-By: OpenAI Codex (GPT-5)
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-18 22:45:33 +02:00
sjg 6276c3feea [fix](trx-rs): allow systemd hardware discovery
Do not restrict socket families in the generic user units because SoapySDR and libusb require netlink sockets to enumerate radio hardware.

Assisted-By: OpenAI Codex (GPT-5)
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-18 22:44:58 +02:00
sjg 110c0e1d49 [fix](trx-rs): correct systemd network ordering
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / reuse (push) Canceled after 0s
Let the client wait for an enabled local server without activating a disabled one, and declare the socket families required by both services for configured listeners and remote connections.

Assisted-By: OpenAI Codex (GPT-5)
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-18 22:35:54 +02:00
sjg 05c337b581 [feat](trx-rs): add build/install script and systemd user services
CI / lint (pull_request) Canceled after 0s
CI / test (pull_request) Canceled after 0s
CI / frontend (pull_request) Canceled after 0s
CI / reuse (pull_request) Canceled after 0s
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / reuse (push) Canceled after 0s
Provide a one-shot installer and matching systemd *user* units so the
server and client can be built, installed system-wide, and run in the
background without hand-rolled steps.

- script/install.sh: builds all three binaries (trx-server, trx-client,
  trx-configurator) in release mode and installs them to /usr/local/bin
  (configurable via --prefix/--bindir/PREFIX, sudo only when the target
  is not writable). Seeds ~/.config/trx-rs/trx-rs.toml from the example
  without ever overwriting existing config, then installs the user units
  with @BINDIR@ substituted to the real path and runs daemon-reload.
  Flags: --no-sdr, --no-build, --no-systemd, --enable-now.
- script/uninstall.sh: stops/disables the units, removes them and the
  binaries; keeps config unless --purge.
- packaging/systemd/{trx-server,trx-client}.service: user units reading
  the combined config at ~/.config/trx-rs/trx-rs.toml. The client softly
  depends on the server (Wants/After). KillSignal=SIGINT matches how the
  binaries shut down cleanly (SIGTERM is not handled).
- README: new "Install (optional, Linux + systemd)" section.

The web UI assets are embedded in the binary, so an install needs only
the binaries plus a config file — no data directory to ship.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-16 23:01:45 +02:00
sjg c10b5faef4 [feat](trx-rs): redesign SDR noise blanker with tuning profiles
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / reuse (push) Canceled after 0s
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>
2026-08-16 21:42:14 +02:00
sjgandClaude Opus 4.8 79adc8d5c6 [chore](trx-rs): run CI on self-hosted runner
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / reuse (push) Canceled after 0s
Switch every CI job from ubuntu-latest to the self-hosted runner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UiK871ht2uPFBHtMbxy3wD
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-16 13:22:44 +02:00
sjgandClaude Opus 4.8 c33e3caedb [style](trx-rs): cargo fmt
CI / frontend (push) Successful in 5m32s
CI / lint (push) Successful in 2m23s
CI / test (push) Successful in 10m20s
CI / reuse (push) Successful in 4s
Apply rustfmt to the wefax and DIG-sideband changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UiK871ht2uPFBHtMbxy3wD
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-16 12:50:12 +02:00
sjgandClaude Opus 4.8 5084c19899 [fix](trx-frontend-http): move passband overlay to resolved DIG sideband
CI / lint (push) Failing after 2s
CI / test (push) Successful in 9m8s
CI / frontend (push) Successful in 5m24s
CI / reuse (push) Successful in 4s
The spectrum passband overlay is drawn one-sided per mode, but DIG was
hardcoded to upper sideband — so switching the DIG sideband (or tuning
DIG/Auto across 10 MHz) left the overlay on the wrong side of the carrier
even though the backend had flipped the demodulator.

Resolve the DIG overlay direction from the current sideband policy and dial
frequency, mirroring the backend: usb → upper, lsb → lower, auto → upper at
or above 10 MHz and lower below. The overlay repaints immediately on a
policy change (optimistically on the selector, and on confirmed filter
state) and tracks frequency as it already did. Non-SDR backends keep the
historical upper-sideband overlay.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UiK871ht2uPFBHtMbxy3wD
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-16 10:24:30 +02:00
sjgandClaude Opus 4.8 df7483fe30 [feat](trx-rs): configurable DIG sideband for SDR (auto/USB/LSB)
CI / frontend (push) Successful in 5m41s
CI / lint (push) Failing after 4s
CI / test (push) Successful in 10m37s
CI / reuse (push) Successful in 5s
DIG has no inherent sideband; on the SDR backend it was always demodulated
as USB. Make it resolve to USB or LSB via a policy that defaults to the
amateur SSB/data convention (USB at/above 10 MHz, LSB below) and can be
overridden globally from the advanced radio controls or per-bookmark.

Design: the logical DIG mode is kept in RigState (display, decoder gating)
while the SDR pipeline is handed a concrete USB/LSB demodulator resolved from
(policy, dial frequency). Resolution happens at the boundary — the rig for
the primary channel and the virtual-channel manager for vchans — so the hot
DSP/demod path is untouched. The resolved sideband is only re-pushed when it
actually changes (e.g. tuning DIG/Auto across 10 MHz), keeping ordinary
tuning glitch-free.

Core/protocol:
- New `DigSidebandPolicy { Auto, Usb, Lsb }` with `resolve(freq)` and an
  `effective_demod_mode()` helper (trx-core), re-exported at the crate root.
- `RigCommand::SetSdrDigSideband`, `RigSdr::set_sdr_dig_sideband`, and a
  `RigFilterState.sdr_dig_sideband` field for state sync; wired through the
  ClientCommand mapping.

Config: `[rig.sdr] dig_sideband = "auto"` (regenerated trx-rs.toml.example).

SDR backend: the vchan manager owns the shared policy (atomic); the rig
applies it to the primary channel and, on `set_sdr_dig_sideband`, re-resolves
all DIG virtual channels.

Frontend: a mode-gated "DIG sideband" selector in the SDR advanced controls
(POST /set_sdr_dig_sideband), reflecting server state; bookmarks gain an
optional `dig_sideband` field (form selector shown only for DIG) that, on
apply, sets the global policy before switching to DIG. The scheduler honours
it for automated bookmark activation too.

Tests: policy resolution / effective-mode / u8+parse round-trips (trx-core);
a vchan integration test asserting a DIG channel resolves to LSB below
10 MHz, flips with the policy, and still lists as DIG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UiK871ht2uPFBHtMbxy3wD
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-16 00:26:45 +02:00
sjgandClaude Opus 4.8 fe3b414fba [fix](trx-wefax): stop fragmenting one transmission into many images
The WEFAX decoder decoded content but chopped a single chart into many
short PNG "chunks". Two heuristics fought each other: in State::Receiving
the carrier-loss watchdog finalized the image after only 30 low-correlation
scan lines (~15 s at 120 LPM), and the Idle variance auto-start then
re-triggered on the still-present carrier ~3 s later, starting a fresh
image. Ordinary HF fading (QSB) of 5-20 s therefore split every chart into
a stream of tiny images.

Reference decoders (fldigi) keep one continuous image per APT cycle up to a
large line cap and only stop on the APT stop tone or genuine signal loss.
Align with that model:

- Raise the end-of-transmission watchdog to 120 low-correlation lines
  (~60 s at 120 LPM) so normal fades ride through within one image.
- Gate the variance-based auto-start to fire at most once per session
  (auto_start_used). After the first image a new one starts only on an APT
  start tone or an explicit reset, so trailing carrier/noise can no longer
  spawn a second image. reset() re-arms it.
- Cap a single image at 3000 lines to bound memory on an open carrier.
- Require a 2 s (was 1 s) APT tone sustain, cutting false Stop detections on
  busy image content that momentarily hits ~450 transitions/s.

Also make line slicing drift-free: 120 LPM at 11025 Hz is 5512.5 samples per
line, and slicing on the rounded integer accumulated a fractional-sample
error every line (slow horizontal slant). Boundaries are now derived from the
exact fractional line length (samples_per_line_f64) so the error never
accumulates.

Adds regression tests for the single-auto-start invariant and zero slicer
drift over 1000 lines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UiK871ht2uPFBHtMbxy3wD
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-15 23:35:09 +02:00
sjg 1829e3d17b [fix](trx-frontend-http): make Guest role composable
CI / lint (pull_request) Successful in 2m25s
CI / test (pull_request) Successful in 9m16s
CI / frontend (pull_request) Successful in 5m22s
CI / reuse (pull_request) Successful in 6s
CI / lint (push) Successful in 2m23s
CI / test (push) Successful in 8m11s
CI / frontend (push) Successful in 4m24s
CI / reuse (push) Successful in 4s
2026-08-13 02:16:59 +02:00
sjg b037225a05 [fix](trx-frontend-http): block Guest password changes
CI / lint (pull_request) Successful in 2m25s
CI / test (pull_request) Successful in 9m5s
CI / frontend (pull_request) Successful in 5m23s
CI / reuse (pull_request) Successful in 5s
CI / lint (push) Successful in 2m24s
CI / test (push) Successful in 9m11s
CI / frontend (push) Successful in 5m22s
CI / reuse (push) Successful in 5s
2026-08-11 22:47:55 +02:00
sjg e978cf8a84 [feat](trx-frontend-http): separate transmit permission
CI / frontend (pull_request) Successful in 5m21s
CI / lint (pull_request) Successful in 2m24s
CI / test (pull_request) Successful in 9m11s
CI / test (push) Successful in 8m16s
CI / frontend (push) Successful in 4m24s
CI / reuse (push) Successful in 5s
CI / reuse (pull_request) Successful in 5s
CI / lint (push) Successful in 2m25s
Assisted-By: Codex (GPT-5)
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-11 18:36:33 +02:00
sjg 44870bc941 [feat](trx-frontend-http): add restricted Guest role
CI / lint (pull_request) Successful in 2m24s
CI / test (pull_request) Successful in 8m16s
CI / frontend (pull_request) Successful in 4m17s
CI / reuse (pull_request) Successful in 5s
Assisted-By: Codex (GPT-5)
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-11 16:04:51 +02:00
sjg 5e9dae02c7 Complete managed account lifecycle
CI / test (push) Successful in 8m12s
CI / frontend (push) Successful in 4m15s
CI / reuse (push) Successful in 5s
CI / lint (pull_request) Successful in 2m24s
CI / test (pull_request) Successful in 9m6s
CI / frontend (pull_request) Successful in 5m17s
CI / reuse (pull_request) Successful in 5s
CI / lint (push) Successful in 2m26s
2026-08-11 07:49:53 +02:00
sjg 34507ffa17 Add composable HTTP access roles
CI / lint (pull_request) Successful in 2m24s
CI / test (pull_request) Successful in 9m24s
CI / frontend (pull_request) Successful in 5m12s
CI / reuse (pull_request) Successful in 6s
CI / lint (push) Successful in 2m24s
CI / test (push) Successful in 8m8s
CI / frontend (push) Successful in 4m15s
CI / reuse (push) Successful in 5s
2026-08-11 01:06:57 +02:00
sjg 36c1e56efa Protect the final administrator 2026-08-11 00:39:25 +02:00
sjg e4cce9a004 Add Users settings tab 2026-08-11 00:37:59 +02:00
70 changed files with 4257 additions and 657 deletions
+4 -4
View File
@@ -27,7 +27,7 @@ env:
jobs:
lint:
runs-on: ubuntu-latest
runs-on: self-hosted
container: git.haxx.space/sjg/trx-rs/sdk:latest
steps:
- uses: actions/checkout@v4
@@ -40,7 +40,7 @@ jobs:
run: sccache --show-stats
test:
runs-on: ubuntu-latest
runs-on: self-hosted
container: git.haxx.space/sjg/trx-rs/sdk:latest
steps:
- uses: actions/checkout@v4
@@ -53,7 +53,7 @@ jobs:
run: sccache --show-stats
frontend:
runs-on: ubuntu-latest
runs-on: self-hosted
container: git.haxx.space/sjg/trx-rs/sdk:latest
defaults:
run:
@@ -83,7 +83,7 @@ jobs:
run: npm run verify-generated
reuse:
runs-on: ubuntu-latest
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- uses: fsfe/reuse-action@v5
+38
View File
@@ -116,6 +116,44 @@ section and the client reads `[trx-client]`.
Open the configured HTTP frontend address in a browser (default `http://localhost:8080`).
### 5. Install (optional, Linux + systemd)
To build, install the binaries system-wide, and set up systemd **user** services
that run the server and client in the background:
```bash
script/install.sh
```
This builds in release mode, installs `trx-server`, `trx-client`, and
`trx-configurator` to `~/.local/bin`, seeds
`~/.config/trx-rs/trx-rs.toml` from the example (existing config is never
overwritten), and installs `~/.config/systemd/user/{trx-server,trx-client}.service`.
```bash
script/install.sh --prefix /opt/trx-rs # choose another installation prefix
script/install.sh --no-sdr # build without SoapySDR support
script/install.sh --enable-now # also enable + start the services now
script/install.sh --help # all options
```
After editing your config, manage the services with:
```bash
systemctl --user enable --now trx-server trx-client # start now + on login
journalctl --user -u trx-server -u trx-client -f # follow logs
loginctl enable-linger "$USER" # keep running after logout
```
Serial (`/dev/ttyUSB*`) and audio access require your user to be in the
`dialout` and `audio` groups. Remove everything with `script/uninstall.sh`
(add `--purge` to also delete the config).
For an unattended or remotely accessible installation, follow the
[safe deployment guide](docs/Deployment.md). It covers a dedicated service
account, device permissions, authentication, firewall and reverse-proxy
boundaries, verification, upgrades, and rollback.
## How It Works
```mermaid
+9 -4
View File
@@ -925,9 +925,14 @@ main
### HTTP Frontend Auth
- Optional token or HTTP Basic Auth middleware
- Configured in `[frontends.http.auth]`
- Rate limiting supported
- Optional Argon2id-backed managed accounts with HttpOnly session cookies
- An exclusive Guest role plus composable Read, Control, Transmit, Write, and Administrator roles, with policy shared by middleware and handlers
- Guest sessions receive read-only station access but no account-control endpoints or panels
- Transmit separately gates PTT, TX audio frames, and TX power-limit changes
- Atomic JSON persistence with migration from the legacy single-role schema
- Account enable/disable, administrator CRUD, self-service password changes, and session revocation on security changes
- A database invariant always preserves at least one enabled administrator
- Per-IP login rate limiting; configured in `[frontends.http.auth]`
### Transport Security
@@ -1043,7 +1048,7 @@ The `FrontendRuntimeContext` struct in `trx-frontend/src/lib.rs` is decomposed i
|-----------|---------|------------|
| `AudioContext` | Audio streaming channels | `rx`, `tx`, `info`, `decode_rx`, `clients` |
| `DecodeHistoryContext` | Decode history for all types | `ais`, `vdes`, `aprs`, `hf_aprs`, `cw`, `ft8`, `ft4`, `ft2`, `wspr` |
| `HttpAuthConfig` | HTTP auth settings | `enabled`, `users_file`, bootstrap admin, `session_ttl_secs`, `tokens` |
| `HttpAuthConfig` | HTTP auth settings | `enabled`, `users_file`, bootstrap admin/read accounts, `session_ttl_secs`, `tokens` |
| `HttpUiConfig` | HTTP UI display config | `show_sdr_gain_control`, `initial_map_zoom`, `spectrum_*` |
| `RigRoutingContext` | Remote rig state & routing | `active_rig_id`, `remote_rigs`, `rig_states`, `server_connected` |
| `OwnerInfo` | Station metadata | `callsign`, `website_url`, `ais_vessel_url_base` |
+287
View File
@@ -0,0 +1,287 @@
# Safe deployment
This guide deploys trx-rs on Linux as a dedicated, unprivileged user with
systemd user services. It keeps radio-device access, configuration, and runtime
data separate from an administrator's account.
The examples use `trx-rs` as the account name and `/opt/trx-rs/bin` for
root-owned executables. Adapt group names and firewall commands to your Linux
distribution.
## 1. Decide what must be reachable
Only expose listeners that another machine actually needs:
| Listener | Typical port | Recommended exposure |
| --- | ---: | --- |
| Server control | TCP 4530 | Loopback or trusted radio LAN only |
| Server audio | TCP 4531 and per-rig ports | Trusted radio LAN only |
| Client web UI | TCP 8080 or a chosen port | Loopback behind HTTPS proxy |
| Client rigctl | Per-rig TCP ports | Loopback or trusted LAN only |
| Client JSON | Configured TCP port | Loopback or trusted LAN only |
`127.0.0.1` accepts connections only from the same host. Use a specific LAN
address when possible, or `0.0.0.0` when the listener must accept connections
on every IPv4 interface. Binding a socket does not configure the host firewall.
Do not expose unauthenticated control, audio, rigctl, or JSON listeners to the
public Internet. Prefer a VPN for links between radio sites. Put the web UI
behind an HTTPS reverse proxy when it is remotely accessible.
## 2. Create the service account
Create a non-login service account with a home directory:
```bash
sudo useradd --create-home --shell /usr/sbin/nologin trx-rs
sudo chmod 0750 /home/trx-rs
```
Add only the hardware groups required on this host. Common group names are
`dialout` for serial devices and `audio` for sound devices:
```bash
sudo usermod -aG dialout,audio trx-rs
```
SDR USB access is distribution- and device-specific. Install the vendor's udev
rules or add a narrowly scoped rule for the device's USB vendor/product IDs.
Avoid making all USB devices world-writable. After reconnecting the device,
verify access as the service account:
```bash
sudo -u trx-rs test -r /dev/ttyUSB0
sudo -u trx-rs test -w /dev/ttyUSB0
sudo -u trx-rs SoapySDRUtil --find
```
Run only the checks relevant to the configured hardware. Group membership and
udev-rule changes normally require reconnecting the device or restarting the
service.
## 3. Build and install immutable binaries
Build from a reviewed revision as a normal development user, not as root:
```bash
git clone https://github.com/stanislawgrams/trx-rs.git
cd trx-rs
git switch --detach <reviewed-tag-or-commit>
cargo build --release -p trx-server -p trx-client -p trx-configurator
```
Install root-owned binaries into a directory the service user cannot modify:
```bash
sudo install -d -o root -g root -m 0755 /opt/trx-rs/bin
sudo install -o root -g root -m 0755 \
target/release/trx-server \
target/release/trx-client \
target/release/trx-configurator \
/opt/trx-rs/bin/
```
If SDR support is not needed, build `trx-server` with
`--no-default-features`. Keep the source revision and Rust toolchain used for
the build in deployment records.
## 4. Install and validate configuration
Create private configuration and state directories, then seed the example:
```bash
sudo install -d -o trx-rs -g trx-rs -m 0700 \
/home/trx-rs/.config/trx-rs \
/home/trx-rs/.config/systemd/user
sudo install -o trx-rs -g trx-rs -m 0600 \
trx-rs.toml.example /home/trx-rs/.config/trx-rs/trx-rs.toml
sudoedit /home/trx-rs/.config/trx-rs/trx-rs.toml
```
At minimum:
- remove unused example rigs and remotes;
- select the correct serial, TCP, or SDR device;
- give every enabled rig a unique ID and audio port;
- use `127.0.0.1` for same-host connections;
- use a LAN address or `0.0.0.0` only for deliberately remote listeners;
- enable authentication before exposing server control or the web UI;
- replace every example password and token;
- keep credential files mode `0600` and owned by `trx-rs`;
- set `cookie_secure = true` when the web UI is served through HTTPS.
For a remote server, control and each per-rig audio listener need an explicit
non-loopback address:
```toml
[trx-server.listen]
enabled = true
listen = "0.0.0.0"
port = 4530
[trx-server.listen.auth]
tokens_file = "/home/trx-rs/.config/trx-rs/server-tokens"
[[trx-server.rigs]]
id = "station-hf"
[trx-server.rigs.audio]
enabled = true
listen = "0.0.0.0"
port = 4531
```
When using several `[[trx-server.rigs]]` entries, configure audio under each
`[trx-server.rigs.audio]` section. Do not rely on the legacy flat
`[trx-server.audio]` section.
For a web UI behind a reverse proxy, keep the backend on loopback and choose an
unused port. The proxy upstream must use the same address and port:
```toml
[trx-client.frontends.http]
enabled = true
listen = "127.0.0.1"
port = 7345
[trx-client.frontends.http.auth]
enabled = true
users_file = "/home/trx-rs/.config/trx-rs/http-users.json"
cookie_secure = true
```
Validate the configuration before starting either daemon:
```bash
sudo -u trx-rs /opt/trx-rs/bin/trx-server \
--check-config --config /home/trx-rs/.config/trx-rs/trx-rs.toml
sudo -u trx-rs /opt/trx-rs/bin/trx-client \
--check-config --config /home/trx-rs/.config/trx-rs/trx-rs.toml
```
Treat unknown-key and deprecated-section warnings as deployment errors. They
often mean a setting is not applied where expected.
## 5. Install the systemd user services
Render the packaged units with the immutable binary directory:
```bash
sed 's|@BINDIR@|/opt/trx-rs/bin|g' packaging/systemd/trx-server.service \
| sudo tee /home/trx-rs/.config/systemd/user/trx-server.service >/dev/null
sed 's|@BINDIR@|/opt/trx-rs/bin|g' packaging/systemd/trx-client.service \
| sudo tee /home/trx-rs/.config/systemd/user/trx-client.service >/dev/null
sudo chown trx-rs:trx-rs \
/home/trx-rs/.config/systemd/user/trx-server.service \
/home/trx-rs/.config/systemd/user/trx-client.service
sudo chmod 0644 \
/home/trx-rs/.config/systemd/user/trx-server.service \
/home/trx-rs/.config/systemd/user/trx-client.service
```
Enable lingering so the user manager runs without an interactive login, then
start it:
```bash
sudo loginctl enable-linger trx-rs
trx_uid=$(id -u trx-rs)
sudo systemctl start "user@${trx_uid}.service"
```
Manage the user services through that user's runtime directory:
```bash
trx_uid=$(id -u trx-rs)
sudo -u trx-rs XDG_RUNTIME_DIR="/run/user/${trx_uid}" \
systemctl --user daemon-reload
sudo -u trx-rs XDG_RUNTIME_DIR="/run/user/${trx_uid}" \
systemctl --user enable --now trx-server.service trx-client.service
```
The client unit has ordering, but not activation, on `trx-server.service`. If
both are enabled locally, the client starts after the server process is
started. If the local server is disabled because all remotes are external, the
client does not start it. The client retries remote connections; systemd cannot
order startup against a service on another host.
## 6. Firewall and reverse proxy
Allow only required ports and sources. For example, permit server control and
audio only from the trusted radio subnet, not from every interface. Exact
commands differ between nftables, firewalld, and ufw.
For the web UI, terminate TLS in a maintained reverse proxy and send traffic to
the loopback backend. Preserve WebSocket upgrade headers if the proxy requires
them. Example nginx location:
```nginx
location / {
proxy_pass http://127.0.0.1:7345;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
```
Test the backend directly before debugging a `502 Bad Gateway` response:
```bash
curl --fail --show-error http://127.0.0.1:7345/
```
A refused connection means nothing is listening at the proxy's configured
address and port. Check for port conflicts with `ss -ltnp` and confirm the
application log reports the same bind address as the proxy upstream.
## 7. Verify the deployment
```bash
trx_uid=$(id -u trx-rs)
sudo -u trx-rs XDG_RUNTIME_DIR="/run/user/${trx_uid}" \
systemctl --user status trx-server.service trx-client.service
sudo journalctl _UID="${trx_uid}" \
-u trx-server.service -u trx-client.service --since today
sudo ss -ltnp
```
Verify all of the following:
- both required services remain active without a restart loop;
- radio hardware opens successfully;
- logs show the intended control, audio, and frontend bind addresses;
- the client connects to every configured control and audio endpoint;
- only intended interfaces expose listeners;
- authentication works and anonymous access is rejected where configured;
- the HTTPS proxy returns the UI and supports live updates;
- the service account cannot write `/opt/trx-rs/bin`.
Do not rely only on `systemctl` reporting `active`: individual frontend tasks
can fail after the main client process starts. Always inspect startup logs and
probe each required endpoint.
## 8. Upgrade and roll back
Build and validate a new revision before replacing the installed executables.
Back up configuration and state first. Stop the services, install all binaries
from the same build, and restart:
```bash
trx_uid=$(id -u trx-rs)
sudo -u trx-rs XDG_RUNTIME_DIR="/run/user/${trx_uid}" \
systemctl --user stop trx-client.service trx-server.service
sudo install -o root -g root -m 0755 \
target/release/trx-server \
target/release/trx-client \
target/release/trx-configurator \
/opt/trx-rs/bin/
sudo -u trx-rs XDG_RUNTIME_DIR="/run/user/${trx_uid}" \
systemctl --user start trx-server.service trx-client.service
```
Keep the previous binaries or package revision available for rollback. If the
new version fails, stop both services, restore the complete previous binary
set, restore configuration only if its format changed, and start the services
again.
+2 -2
View File
@@ -541,8 +541,8 @@ setting, which is also what LoTW's station locations expect.
rotate operators through one station callsign, which is why contest loggers record it per QSO.
It is stored per QSO, defaulted from the configured callsign so a single operator never touches
it, and changed on the station line at the top of the panel where it sticks for the session.
It cannot be taken from the session's identity: the auth roles are `admin` and `user`, with no
notion of who is logged in.
It cannot be inferred from the session's identity: an account username need not be an operator
callsign, and operational accounts may be shared.
**Server clock, and the log says so.** The server is the machine at the radio; the browser may
be on a phone in another timezone with a clock nobody has checked. QSO times are UTC from the
+14 -6
View File
@@ -126,7 +126,9 @@ When auth is enabled, an **auth gate** blocks the UI with:
- Error message area (red `#ff6b6b`)
- Role badge display
Two roles: **User** (read-only) and **Admin** (full access including user management).
**Guest** provides read-only station access and is exclusive. Non-Guest accounts
may combine **Read**, **Control**, **Transmit**, **Write**, and **Administrator** roles.
Administrator implies all permissions.
Session cookie: `trx_http_sid`, HttpOnly, configurable Secure and SameSite attributes.
@@ -335,13 +337,15 @@ Logo and favicon are embedded at compile time via `include_bytes!`. The logo ima
### 7.1 Route Access Classification
Routes are classified into three tiers:
Routes are classified into access tiers:
| Tier | Examples | Requirement |
|---|---|---|
| **Public** | `/`, `/index.html`, `/map`, login/session endpoints, static assets | None |
| **Read** | `/status`, `/events`, `/audio`, `/decode`, `/spectrum`, `/bookmarks` | User or Admin role |
| **Control** | `/set_freq`, `/set_mode`, `/set_ptt`, `/toggle_power`, all other POST | Admin role only |
| **Read** | `/status`, `/events`, `/audio`, `/decode`, `/spectrum`, `/bookmarks` | Guest, Read, Control, Transmit, or Administrator role |
| **Control** | `/set_freq`, `/set_mode`, `/toggle_power`, receive-side radio-control POST routes | Control or Administrator role |
| **Transmit** | `/set_ptt`, `/set_tx_limit`, outbound `/audio` frames | Transmit or Administrator role |
| **Write** | Logbook access and bookmark mutations | Write or Administrator role |
### 7.2 Session Management
@@ -352,8 +356,12 @@ Routes are classified into three tiers:
### 7.3 User Management
Only administrators can list, add, update, or remove accounts. The final
administrator cannot be removed or demoted.
Every authenticated non-Guest account gets a Settings > Account tab for changing
its own password. Guest sees neither Account nor Users and both account-control
APIs deny Guest sessions. Only administrators get Settings > Users, where accounts can be
created, enabled/disabled, assigned multiple roles, given a new password, or
removed. The final enabled administrator cannot be disabled, removed, or
demoted. Account security changes revoke every active session for that account.
---
+93 -34
View File
@@ -354,6 +354,9 @@ A name in any of those maps that no remote answers to is a config error.
| `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` |
@@ -577,7 +580,7 @@ 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 `admin` role; a `user`
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.
@@ -586,11 +589,14 @@ left as it was, rather than publishing a frequency the rig is not on.
## Authentication
The HTTP frontend supports an optional user/password ACL with multiple accounts and two
roles:
The HTTP frontend supports an optional user/password ACL:
- **user** — read-only access (monitoring, audio, decode streams)
- **admin** — full radio control, settings, and user management
- **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
@@ -600,6 +606,9 @@ 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
@@ -607,7 +616,9 @@ 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 and the Argon2id-hashed user database.
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
@@ -615,9 +626,13 @@ the initial administrator and the Argon2id-hashed user database.
- 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.
- `user` sessions cannot call control routes. There is no guest-access mode.
- Administrators can add/remove users and change roles/passwords in Settings.
- Removing an account or changing its password/role revokes its sessions.
- 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
@@ -625,19 +640,21 @@ the initial administrator and the Argon2id-hashed user database.
|----------|--------|-------------|
| `/auth/login` | POST | Submit `{ "username": "...", "password": "..." }` |
| `/auth/logout` | POST | Clear session |
| `/auth/session` | GET | Check current session/role |
| `/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 password/role or remove user (admin only) |
| `/auth/users/{username}` | PATCH/DELETE | Change enabled state/password/roles or remove user (administrator only) |
Protected routes require at least `user` role. Control routes (set frequency,
mode, PTT, etc.) require `admin` role.
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. `user` accounts see a read-only interface; admins get full controls.
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
@@ -735,9 +752,30 @@ A dedicated tab with a clock icon provides:
## 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.
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)
@@ -754,6 +792,7 @@ 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]`:
@@ -762,15 +801,30 @@ For the legacy single-rig (flat) config the path is `[sdr.noise_blanker]`:
[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 running RMS. A sample whose magnitude exceeds this multiple is replaced. Lower values blank more aggressively; higher values only catch strong impulses. |
| 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.
@@ -796,39 +850,44 @@ the running average signal level.
### Web UI
When the server reports noise-blanker support, two controls appear in the
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 (1100) with a **Set** button — adjusts the
detection threshold. Press Enter or click Set to apply.
detection sensitivity. Press Enter or click Set to apply.
- **NB profile** selector — chooses the profile (Spike / Ignition / Powerline /
Broadband). Changing it applies immediately.
Both controls stay hidden until the server sends filter state containing NB
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
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 (4096 samples) *before* the mixer stage in
the DSP pipeline:
The blanker runs on every IQ block *before* the mixer stage in the DSP pipeline,
one sample at a time:
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.
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
+38
View File
@@ -0,0 +1,38 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
#
# systemd *user* unit for the trx-rs radio client and web frontend.
#
# Install with `script/install.sh` (which substitutes @BINDIR@ and copies this
# into ~/.config/systemd/user/), or by hand:
# sed "s|@BINDIR@|$HOME/.local/bin|" trx-client.service \
# > ~/.config/systemd/user/trx-client.service
# systemctl --user daemon-reload
# systemctl --user enable --now trx-client.service
#
# Reads the combined config at ~/.config/trx-rs/trx-rs.toml ([trx-client]
# section) and connects to the server (default 127.0.0.1:4530). The web UI
# defaults to http://127.0.0.1:8080.
[Unit]
Description=trx-rs radio client and web frontend
Documentation=https://github.com/stanislawgrams/trx-rs
# Wait for networking and, when it is enabled, the local server. After= only
# orders units already in the transaction: it deliberately does not start a
# disabled trx-server, since the client may connect to a remote server instead.
Wants=network-online.target
After=network-online.target trx-server.service
[Service]
Type=simple
ExecStart=@BINDIR@/trx-client --config %h/.config/trx-rs/trx-rs.toml
Restart=on-failure
RestartSec=2
# Shuts down cleanly on SIGINT (see trx-server.service for the rationale).
KillSignal=SIGINT
TimeoutStopSec=15
NoNewPrivileges=true
[Install]
WantedBy=default.target
+38
View File
@@ -0,0 +1,38 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
#
# systemd *user* unit for the trx-rs radio server.
#
# Install with `script/install.sh` (which substitutes @BINDIR@ and copies this
# into ~/.config/systemd/user/), or by hand:
# sed "s|@BINDIR@|$HOME/.local/bin|" trx-server.service \
# > ~/.config/systemd/user/trx-server.service
# systemctl --user daemon-reload
# systemctl --user enable --now trx-server.service
#
# Reads the combined config at ~/.config/trx-rs/trx-rs.toml ([trx-server]
# section). Serial (/dev/ttyUSB*) and audio access require your user to be in
# the `dialout` and `audio` groups — a user unit cannot grant them itself.
[Unit]
Description=trx-rs radio server (CAT/SDR backend)
Documentation=https://github.com/stanislawgrams/trx-rs
# The server binds its control/audio TCP listeners and may connect to networked
# rigs and reporting services, so do not start it before networking is ready.
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
ExecStart=@BINDIR@/trx-server --config %h/.config/trx-rs/trx-rs.toml
Restart=on-failure
RestartSec=2
# Both binaries shut down cleanly on SIGINT (the server drains audio and exits
# 0); systemd's default SIGTERM is not handled, so signal SIGINT instead.
KillSignal=SIGINT
TimeoutStopSec=15
NoNewPrivileges=true
[Install]
WantedBy=default.target
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env bash
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
#
# Build trx-rs in release mode and install the binaries system-wide, seed a
# per-user config, and install systemd *user* services to run the server and
# client. Idempotent: existing config is never overwritten.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
BINARIES=(trx-server trx-client trx-configurator)
# --- Defaults (override via flags or environment) ---------------------------
PREFIX="${PREFIX:-$HOME/.local}"
BINDIR="${BINDIR:-}" # derived from PREFIX unless set explicitly
NO_SDR=0
DO_BUILD=1
DO_SYSTEMD=1
ENABLE_NOW=0
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/trx-rs"
CONFIG_FILE="$CONFIG_DIR/trx-rs.toml"
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
usage() {
cat <<EOF
Usage: script/install.sh [options]
Build trx-rs (release) and install it.
Options:
--prefix DIR Install prefix; binaries go to DIR/bin (default: \$HOME/.local)
--bindir DIR Install binaries directly to DIR (overrides --prefix)
--no-sdr Build without SoapySDR support (--no-default-features)
--no-build Skip cargo build; install existing target/release binaries
--no-systemd Do not install the systemd user services
--enable-now Enable and start the user services immediately after install
-h, --help Show this help
Environment:
PREFIX, BINDIR Same as the matching flags.
Installs:
binaries -> ${BINDIR:-$PREFIX/bin} (uses sudo if not writable)
config -> $CONFIG_FILE (from trx-rs.toml.example, only if absent)
services -> $UNIT_DIR/{trx-server,trx-client}.service
EOF
}
# --- Parse args -------------------------------------------------------------
while [ $# -gt 0 ]; do
case "$1" in
--prefix) PREFIX="$2"; shift 2 ;;
--bindir) BINDIR="$2"; shift 2 ;;
--no-sdr) NO_SDR=1; shift ;;
--no-build) DO_BUILD=0; shift ;;
--no-systemd) DO_SYSTEMD=0; shift ;;
--enable-now) ENABLE_NOW=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "error: unknown option '$1'" >&2; usage >&2; exit 2 ;;
esac
done
[ -n "$BINDIR" ] || BINDIR="$PREFIX/bin"
log() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33mwarning:\033[0m %s\n' "$*" >&2; }
# Return 0 (true) if writing into $1 needs elevated privileges.
need_sudo() {
local dir="$1"
while [ ! -e "$dir" ]; do dir="$(dirname "$dir")"; done
[ -w "$dir" ] && return 1 || return 0
}
# --- Build ------------------------------------------------------------------
if [ "$DO_BUILD" -eq 1 ]; then
build_args=(build --release --manifest-path "$PROJECT_ROOT/Cargo.toml")
for b in "${BINARIES[@]}"; do build_args+=(-p "$b"); done
if [ "$NO_SDR" -eq 1 ]; then
# --no-default-features only affects trx-server; harmless elsewhere.
build_args+=(-p trx-server --no-default-features)
log "Building (release, no SDR)…"
else
log "Building (release, with SDR)…"
fi
cargo "${build_args[@]}"
fi
# --- Install binaries -------------------------------------------------------
SUDO=""
if need_sudo "$BINDIR"; then
SUDO="sudo"
log "Installing binaries to $BINDIR (using sudo)…"
else
log "Installing binaries to $BINDIR"
fi
for b in "${BINARIES[@]}"; do
src="$PROJECT_ROOT/target/release/$b"
if [ ! -x "$src" ]; then
echo "error: $src not found (build failed or --no-build with no prior build?)" >&2
exit 1
fi
$SUDO install -Dm755 "$src" "$BINDIR/$b"
log "installed $BINDIR/$b"
done
# --- Seed per-user config ---------------------------------------------------
if [ -e "$CONFIG_FILE" ]; then
log "Config already present, leaving it untouched: $CONFIG_FILE"
else
mkdir -p "$CONFIG_DIR"
install -m600 "$PROJECT_ROOT/trx-rs.toml.example" "$CONFIG_FILE"
log "Seeded config from example: $CONFIG_FILE"
warn "Edit $CONFIG_FILE for your rig before starting the services."
fi
# --- systemd user services --------------------------------------------------
if [ "$DO_SYSTEMD" -eq 1 ]; then
mkdir -p "$UNIT_DIR"
for unit in trx-server trx-client; do
sed "s|@BINDIR@|$BINDIR|g" \
"$PROJECT_ROOT/packaging/systemd/$unit.service" \
> "$UNIT_DIR/$unit.service"
log "installed $UNIT_DIR/$unit.service"
done
if command -v systemctl >/dev/null 2>&1; then
systemctl --user daemon-reload || warn "systemctl --user daemon-reload failed"
if [ "$ENABLE_NOW" -eq 1 ]; then
log "Enabling and starting user services…"
systemctl --user enable --now trx-server.service trx-client.service
warn "Run 'loginctl enable-linger $USER' to keep services running after logout."
else
cat <<EOF
Next steps:
1. Edit your config: \$EDITOR $CONFIG_FILE
2. Start the services: systemctl --user enable --now trx-server trx-client
3. Watch the logs: journalctl --user -u trx-server -u trx-client -f
4. Run after logout: loginctl enable-linger $USER
Web UI (default): http://127.0.0.1:8080
EOF
fi
else
warn "systemctl not found; units copied but not reloaded."
fi
fi
log "Done."
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
#
# Reverse script/install.sh: stop and remove the systemd user services and the
# installed binaries. User config (~/.config/trx-rs) is left in place unless
# --purge is given.
set -euo pipefail
BINARIES=(trx-server trx-client trx-configurator)
PREFIX="${PREFIX:-$HOME/.local}"
BINDIR="${BINDIR:-}"
PURGE=0
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/trx-rs"
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
usage() {
cat <<EOF
Usage: script/uninstall.sh [options]
Options:
--prefix DIR Install prefix binaries were installed under (default: \$HOME/.local)
--bindir DIR Directory binaries were installed to (overrides --prefix)
--purge Also delete the user config directory ($CONFIG_DIR)
-h, --help Show this help
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
--prefix) PREFIX="$2"; shift 2 ;;
--bindir) BINDIR="$2"; shift 2 ;;
--purge) PURGE=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "error: unknown option '$1'" >&2; usage >&2; exit 2 ;;
esac
done
[ -n "$BINDIR" ] || BINDIR="$PREFIX/bin"
log() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
need_sudo() {
local dir="$1"
while [ ! -e "$dir" ]; do dir="$(dirname "$dir")"; done
[ -w "$dir" ] && return 1 || return 0
}
# --- Stop and remove user services -----------------------------------------
if command -v systemctl >/dev/null 2>&1; then
log "Stopping and disabling user services…"
systemctl --user disable --now trx-client.service trx-server.service 2>/dev/null || true
fi
for unit in trx-client trx-server; do
if [ -e "$UNIT_DIR/$unit.service" ]; then
rm -f "$UNIT_DIR/$unit.service"
log "removed $UNIT_DIR/$unit.service"
fi
done
if command -v systemctl >/dev/null 2>&1; then
systemctl --user daemon-reload || true
fi
# --- Remove binaries --------------------------------------------------------
SUDO=""
if need_sudo "$BINDIR"; then SUDO="sudo"; fi
for b in "${BINARIES[@]}"; do
if [ -e "$BINDIR/$b" ]; then
$SUDO rm -f "$BINDIR/$b"
log "removed $BINDIR/$b"
fi
done
# --- Optionally purge config ------------------------------------------------
if [ "$PURGE" -eq 1 ]; then
rm -rf "$CONFIG_DIR"
log "purged $CONFIG_DIR"
else
log "Left config in place: $CONFIG_DIR (use --purge to delete)"
fi
log "Done."
+13 -1
View File
@@ -45,8 +45,20 @@ impl WefaxConfig {
60.0 / lpm as f32
}
/// Samples per line at the internal sample rate.
/// Samples per line at the internal sample rate (rounded to an integer;
/// use [`Self::samples_per_line_f64`] for drift-free line slicing).
pub fn samples_per_line(lpm: u16, sample_rate: u32) -> usize {
(Self::line_duration_s(lpm) * sample_rate as f32).round() as usize
}
/// Exact (fractional) samples per line at the internal sample rate.
///
/// The line period rarely lands on an integer number of samples
/// (e.g. 120 LPM at 11 025 Hz is 5512.5 samples). Slicing on the rounded
/// integer accumulates a fractional-sample error every line, which shows
/// up as a slow horizontal slant over a tall image. Line boundaries are
/// instead computed from this exact value so the error never accumulates.
pub fn samples_per_line_f64(lpm: u16, sample_rate: u32) -> f64 {
60.0 / f64::from(lpm) * f64::from(sample_rate)
}
}
+86 -5
View File
@@ -38,10 +38,24 @@ const SIGNAL_DETECT_WINDOWS: u32 = 6;
/// Real WEFAX content typically shows r > 0.5 between adjacent lines.
const LINE_CORR_NOISE_THRESHOLD: f32 = 0.2;
/// Number of consecutive uncorrelated scan lines that trigger auto-finalize
/// while receiving. At 120 LPM this is 15 s; at 60 LPM it's 30 s. Modelled on
/// fldigi's line-to-line correlation check for automatic stop.
const LINE_CORR_NOISE_LINES: u32 = 30;
/// Number of consecutive uncorrelated scan lines that mark the end of a
/// transmission (carrier truly gone) and trigger auto-finalize. This must be
/// long enough to ride through ordinary HF fading (QSB), which routinely
/// decorrelates adjacent lines for several seconds without the transmission
/// having ended. At 120 LPM this is ~60 s; at 60 LPM ~120 s.
///
/// A short window here is what previously chopped a single chart into many
/// PNG "chunks": a 15 s fade tripped the watchdog, the image was finalized,
/// and the still-present carrier immediately re-started a fresh image. Real
/// WEFAX decoders (fldigi) keep one continuous image per APT cycle up to a
/// large line cap and only stop on the APT stop tone or genuine signal loss.
const LINE_CORR_NOISE_LINES: u32 = 120;
/// Hard cap on lines in a single image. A 120 LPM chart runs ~10 min
/// (~1200 lines); this cap (≈25 min at 120 LPM) only bounds memory if a
/// session is left running on an open carrier. On reaching it the image is
/// finalized and the decoder waits for a fresh APT start.
const MAX_IMAGE_LINES: u32 = 3000;
/// Maximum number of scan-line-equivalent sample windows to wait for phasing
/// lock before falling through to Receiving. Typical WEFAX phasing lasts
@@ -107,6 +121,12 @@ pub struct WefaxDecoder {
/// the decoder falls through to Receiving so a noisy or partial
/// phasing signal doesn't wedge the state machine.
phasing_samples: u64,
/// Whether a reception has already been auto-started from bare signal
/// variance during this session. After the first image, a new one is only
/// started by an APT start tone — this stops the trailing noise / carrier
/// that follows one chart from immediately auto-starting another image
/// (the mechanism that fragmented a transmission into many chunks).
auto_start_used: bool,
/// Current rig dial frequency in Hz (for image filenames).
freq_hz: u64,
/// Current rig mode name (for image filenames).
@@ -135,6 +155,7 @@ impl WefaxDecoder {
signal_detect_buf: Vec::with_capacity(INTERNAL_RATE as usize / 2),
low_corr_lines: 0,
phasing_samples: 0,
auto_start_used: false,
freq_hz: 0,
mode: String::new(),
}
@@ -204,7 +225,13 @@ impl WefaxDecoder {
// Fallback: detect active WEFAX signal by luminance variance.
// Like fldigi's "strong image signal" detection — if we see
// sustained modulated signal, auto-start receiving with defaults.
if self.state == State::Idle {
//
// Only ever taken once per session: it exists to catch a user
// tuning in mid-image with no APT start. After the first image,
// a new reception requires an APT start tone, so the residual
// carrier / noise that trails a finished chart cannot silently
// spawn a second image (which is what produced many chunks).
if self.state == State::Idle && !self.auto_start_used {
self.signal_detect_buf.extend_from_slice(&luminance);
let window_size = INTERNAL_RATE as usize / 2;
while self.signal_detect_buf.len() >= window_size {
@@ -340,6 +367,14 @@ impl WefaxDecoder {
break;
}
// Bound memory on an open carrier: finalize and wait
// for a fresh APT start rather than growing forever.
if count >= MAX_IMAGE_LINES {
debug!(lines = count, "WEFAX: max image lines — finalizing");
carrier_lost = true;
break;
}
// Emit progress event.
if self.config.emit_progress && count % PROGRESS_INTERVAL == 0 {
let line_data =
@@ -402,6 +437,7 @@ impl WefaxDecoder {
self.signal_detect_buf.clear();
self.low_corr_lines = 0;
self.phasing_samples = 0;
self.auto_start_used = false;
events
}
@@ -434,6 +470,7 @@ impl WefaxDecoder {
fn transition_to_start_detected(&mut self, ioc: u16) -> WefaxEvent {
let ioc = self.config.ioc.unwrap_or(ioc);
debug!(ioc, "WEFAX: APT start detected");
self.auto_start_used = true;
self.state = State::StartDetected { ioc };
self.reception_start_ms = Some(
std::time::SystemTime::now()
@@ -463,6 +500,7 @@ impl WefaxDecoder {
self.image = Some(ImageAssembler::new(ppl));
self.tone_detector.reset();
self.low_corr_lines = 0;
self.auto_start_used = true;
self.state = State::Receiving { ioc, lpm };
self.state_event("Receiving", ioc, lpm)
}
@@ -596,4 +634,47 @@ mod tests {
dec.reset();
assert_eq!(dec.state, State::Idle);
}
/// Regression test for the over-chunking bug: once a session has produced
/// an image, the trailing carrier / noise must not silently auto-start a
/// second image. Only an APT start tone (or an explicit reset) may begin a
/// new reception after the first.
#[test]
fn variance_auto_start_only_fires_once_per_session() {
let mut dec = WefaxDecoder::new(11025, WefaxConfig::default());
// A transition rate that matches no APT tone → drives the variance
// based "strong signal" auto-start rather than a tone detection.
let strong = generate_apt_start(100.0, 11025, 4.0);
dec.process_samples(&strong);
assert!(
matches!(dec.state, State::Receiving { .. }),
"strong signal should auto-start one image, got {:?}",
dec.state
);
assert!(dec.auto_start_used);
// Simulate the image ending on carrier loss / stop (finalize → idle)
// WITHOUT an operator reset.
dec.transition_to_idle();
assert_eq!(dec.state, State::Idle);
// The still-present carrier must NOT spawn a second image.
dec.process_samples(&generate_apt_start(100.0, 11025, 4.0));
assert_eq!(
dec.state,
State::Idle,
"trailing signal must not re-auto-start a fresh image"
);
// An explicit reset re-arms mid-image auto-start.
dec.reset();
assert!(!dec.auto_start_used);
dec.process_samples(&generate_apt_start(100.0, 11025, 4.0));
assert!(
matches!(dec.state, State::Receiving { .. }),
"reset should re-arm variance auto-start, got {:?}",
dec.state
);
}
}
+54 -9
View File
@@ -12,8 +12,8 @@ use crate::config::WefaxConfig;
/// Line slicer for WEFAX image assembly.
pub struct LineSlicer {
/// Samples per line at the internal sample rate.
samples_per_line: usize,
/// Exact (fractional) samples per line at the internal sample rate.
samples_per_line: f64,
/// Pixels per line (IOC × π).
pixels_per_line: usize,
/// Phase offset in samples from the phasing detector.
@@ -22,22 +22,37 @@ pub struct LineSlicer {
buffer: Vec<f32>,
/// Whether we have aligned to the phase offset yet.
aligned: bool,
/// Index of the next line to emit. Boundaries are derived from this
/// against the exact fractional line length so rounding never accumulates.
line_index: u64,
}
impl LineSlicer {
pub fn new(lpm: u16, ioc: u16, sample_rate: u32, phase_offset: usize) -> Self {
let samples_per_line = WefaxConfig::samples_per_line(lpm, sample_rate);
let samples_per_line = WefaxConfig::samples_per_line_f64(lpm, sample_rate);
let pixels_per_line = WefaxConfig::pixels_per_line(ioc) as usize;
Self {
samples_per_line,
pixels_per_line,
phase_offset,
buffer: Vec::with_capacity(samples_per_line * 2),
buffer: Vec::with_capacity(samples_per_line as usize * 2),
aligned: false,
line_index: 0,
}
}
/// Number of samples in line `n`, from the exact fractional line length.
///
/// Boundaries are `round(n · spl)`; the per-line length is the difference
/// of successive boundaries, so lengths alternate (e.g. 5513/5512 for
/// 120 LPM at 11 025 Hz) with no cumulative drift.
fn line_len(&self, n: u64) -> usize {
let start = (n as f64 * self.samples_per_line).round() as u64;
let end = ((n + 1) as f64 * self.samples_per_line).round() as u64;
(end - start) as usize
}
/// Feed luminance samples and extract complete image lines.
///
/// Returns a vector of completed lines, each as a `Vec<u8>` of
@@ -56,12 +71,18 @@ impl LineSlicer {
}
// Extract complete lines (single drain at the end to avoid O(n²)).
// Line boundaries follow the exact fractional line length so the
// sample clock stays locked over a tall image (no accumulating slant).
let mut offset = 0;
while offset + self.samples_per_line <= self.buffer.len() {
let line_samples = &self.buffer[offset..offset + self.samples_per_line];
let pixels = self.resample_line(line_samples);
lines.push(pixels);
offset += self.samples_per_line;
loop {
let len = self.line_len(self.line_index);
if offset + len > self.buffer.len() {
break;
}
let line_samples = &self.buffer[offset..offset + len];
lines.push(self.resample_line(line_samples));
offset += len;
self.line_index += 1;
}
if offset > 0 {
self.buffer.drain(..offset);
@@ -77,6 +98,7 @@ impl LineSlicer {
pub fn reset(&mut self) {
self.buffer.clear();
self.aligned = false;
self.line_index = 0;
}
/// Resample a line's worth of luminance samples to the target pixel count
@@ -129,6 +151,29 @@ mod tests {
assert!(lines[0].iter().all(|&p| p == 255));
}
#[test]
fn slicer_no_cumulative_drift() {
// 120 LPM at 11 025 Hz is 5512.5 samples/line — not an integer. Slicing
// on the rounded value (5513) would lose a line every ~11 000 lines and
// slant the image; the fractional boundaries must not accumulate error.
let lpm = 120;
let ioc = 576;
let sr = 11025;
let spl = WefaxConfig::samples_per_line_f64(lpm, sr);
assert_ne!(spl.fract(), 0.0, "test premise: spl is fractional");
let mut slicer = LineSlicer::new(lpm, ioc, sr, 0);
let total = (spl * 1000.0).round() as usize;
let samples = vec![1.0f32; total];
let lines = slicer.process(&samples);
assert_eq!(
lines.len(),
1000,
"exactly 1000 lines should fit in {} samples with no drift",
total
);
}
#[test]
fn slicer_linear_interpolation() {
let lpm = 120;
+5 -1
View File
@@ -77,8 +77,12 @@ pub struct ToneDetector {
impl ToneDetector {
pub fn new(sample_rate: u32) -> Self {
// APT start/stop tones are transmitted for ~5 s (WMO), so requiring a
// 2 s sustain costs no real detection latency while sharply cutting
// false positives from busy image content that momentarily produces a
// 300/450/675-transitions-per-second rate.
let window_size = (sample_rate / 2) as usize; // ~0.5 s window
let min_sustain_s = 1.0; // fldigi uses 2 consecutive half-second windows
let min_sustain_s = 2.0;
let window_duration_s = window_size as f32 / sample_rate as f32;
let min_sustain_windows = (min_sustain_s / window_duration_s).ceil() as u32;
+6
View File
@@ -257,6 +257,12 @@ async fn async_init() -> DynResult<AppState> {
cfg.frontends.http.auth.bootstrap_admin_username.clone();
frontend_runtime.http_auth.bootstrap_admin_password =
cfg.frontends.http.auth.bootstrap_admin_password.clone();
frontend_runtime.http_auth.bootstrap_read_enabled =
cfg.frontends.http.auth.bootstrap_read_enabled;
frontend_runtime.http_auth.bootstrap_read_username =
cfg.frontends.http.auth.bootstrap_read_username.clone();
frontend_runtime.http_auth.bootstrap_read_password =
cfg.frontends.http.auth.bootstrap_read_password.clone();
frontend_runtime.http_auth.session_ttl_secs = cfg.frontends.http.auth.session_ttl().as_secs();
frontend_runtime.http_auth.cookie_secure = cfg.frontends.http.auth.cookie_secure;
frontend_runtime.http_auth.cookie_same_site = match cfg.frontends.http.auth.cookie_same_site {
+6
View File
@@ -260,6 +260,9 @@ pub struct HttpAuthConfig {
pub users_file: String,
pub bootstrap_admin_username: Option<String>,
pub bootstrap_admin_password: Option<String>,
pub bootstrap_read_enabled: bool,
pub bootstrap_read_username: String,
pub bootstrap_read_password: Option<String>,
pub session_ttl_secs: u64,
pub cookie_secure: bool,
pub cookie_same_site: String,
@@ -274,6 +277,9 @@ impl Default for HttpAuthConfig {
users_file: "trx-http-users.json".to_string(),
bootstrap_admin_username: None,
bootstrap_admin_password: None,
bootstrap_read_enabled: true,
bootstrap_read_username: "guest".to_string(),
bootstrap_read_password: Some("guest".to_string()),
session_ttl_secs: 480 * 60,
cookie_secure: false,
cookie_same_site: "Lax".to_string(),
@@ -1,3 +1,20 @@
import {
AUTH_ADMIN_ROLES,
AUTH_ROLES,
AUTH_ROLE_LABELS,
changeOwnPassword,
createUser,
deleteUser,
fetchAuthSession,
hasAccountControls,
hasAuthRole,
listUsers,
login,
logout,
normalizeAuthRoles,
updateUser
} from "./chunk-PISLBJGN.js";
// src/webgl-renderer.ts
(function initTrxWebGl(global) {
"use strict";
@@ -1314,92 +1331,6 @@ async function loadDecoderRegistry(onLoaded) {
bridge.decoderRegistry = decoderRegistry;
bridge.onDecoderRegistryReady = onDecoderRegistryReady;
// src/api/auth.ts
function decodeAuthSession(value) {
if (typeof value !== "object" || value === null) {
throw new TypeError("The authentication response is malformed");
}
const session = value;
if (typeof session.authenticated !== "boolean") {
throw new TypeError("The authentication response has no authenticated flag");
}
if (session.role !== void 0 && session.role !== "user" && session.role !== "admin") {
throw new TypeError("The authentication response has an invalid role");
}
if (session.auth_disabled !== void 0 && typeof session.auth_disabled !== "boolean") {
throw new TypeError("The authentication response has an invalid auth_disabled flag");
}
const decoded = { authenticated: session.authenticated };
if (session.role !== void 0) decoded.role = session.role;
if (session.username !== void 0) {
if (typeof session.username !== "string") throw new TypeError("The authentication response has an invalid username");
decoded.username = session.username;
}
if (session.auth_disabled !== void 0) decoded.auth_disabled = session.auth_disabled;
return decoded;
}
var authDisabledSession = {
authenticated: true,
role: "admin",
auth_disabled: true
};
async function fetchAuthSession() {
try {
const response = await fetch("/auth/session");
if (response.status === 404) return authDisabledSession;
if (!response.ok) return { authenticated: false };
return decodeAuthSession(await response.json());
} catch (error) {
console.error("Auth check failed:", error);
return { authenticated: false };
}
}
async function login(username, password) {
const response = await fetch("/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password })
});
if (response.status === 404) return authDisabledSession;
if (!response.ok) {
const message = await response.text();
throw new Error(message || "Login failed");
}
return decodeAuthSession(await response.json());
}
async function userRequest(path, init) {
const response = await fetch(path, init);
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
throw new Error(payload.error || `User operation failed (${response.status})`);
}
return response;
}
async function listUsers() {
const value = await userRequest("/auth/users").then((response) => response.json());
if (!Array.isArray(value) || !value.every((user) => {
if (typeof user !== "object" || user === null) return false;
const record = user;
return typeof record.username === "string" && (record.role === "user" || record.role === "admin");
})) {
throw new TypeError("The user list response is malformed");
}
return value;
}
async function createUser(username, password, role) {
await userRequest("/auth/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password, role }) });
}
async function updateUser(username, changes) {
await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(changes) });
}
async function deleteUser(username) {
await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "DELETE" });
}
async function logout() {
const response = await fetch("/auth/logout", { method: "POST" });
if (response.status !== 404 && !response.ok) throw new Error("Logout failed");
}
// src/core/format.ts
function formatDuration(milliseconds) {
const seconds = Math.floor(milliseconds / 1e3);
@@ -1894,9 +1825,43 @@ function isVchanRdsEntry(value) {
return isRecord2(value) && typeof value.id === "string" && (value.rds === void 0 || value.rds === null || isRdsData(value.rds)) && (value.signal_db === void 0 || value.signal_db === null || typeof value.signal_db === "number");
}
void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
var authRole = null;
var authRoles = [];
var authUsername = null;
var authEnabled = true;
function setAuthRoles(roles) {
authRoles = normalizeAuthRoles(roles);
}
function hasAuthRole2(role) {
return hasAuthRole(authRoles, role);
}
function buildRoleChoices(selected) {
const element = document.createElement("span");
element.className = "auth-role-choices";
const inputs = AUTH_ROLES.map((value) => {
const label = document.createElement("label");
label.className = "auth-role-choice";
const input = document.createElement("input");
input.type = "checkbox";
input.value = value;
input.checked = selected.includes(value);
label.append(input, ` ${AUTH_ROLE_LABELS[value]}`);
element.append(label);
return { input, value };
});
inputs.forEach(({ input, value }) => {
input.addEventListener("change", () => {
if (!input.checked) return;
if (value === "guest") {
const administrator = inputs.find((choice) => choice.value === "administrator");
if (administrator) administrator.input.checked = false;
} else if (value === "administrator") {
const guest = inputs.find((choice) => choice.value === "guest");
if (guest) guest.input.checked = false;
}
});
});
return { element, inputs };
}
async function checkAuthStatus() {
return fetchAuthSession();
}
@@ -1906,7 +1871,7 @@ async function authLogin(username, password) {
async function authLogout() {
try {
await logout();
authRole = null;
setAuthRoles([]);
authUsername = null;
disconnect();
setDecodeHistoryOverlayVisible(false);
@@ -1972,20 +1937,30 @@ function updateAuthUI() {
const badge = document.getElementById("auth-badge");
const badgeRole = document.getElementById("auth-role-badge");
const headerAuthBtn2 = document.getElementById("header-auth-btn");
const accountTab = document.getElementById("settings-account-tab");
if (!authEnabled) {
if (badge) badge.style.display = "none";
if (headerAuthBtn2) headerAuthBtn2.style.display = "none";
if (accountTab) accountTab.style.display = "none";
syncTopBarAccess();
return;
}
if (authRole) {
if (authRoles.length > 0) {
const canManageAccount = hasAccountControls(authRoles);
if (accountTab) accountTab.style.display = canManageAccount ? "" : "none";
if (!canManageAccount && accountTab?.classList.contains("active")) {
const panel = document.getElementById("subtab-settings-account");
if (panel) panel.style.display = "none";
document.querySelector('[data-subtab="settings-scheduler"]')?.click();
}
if (badge) badge.style.display = "block";
if (badgeRole) badgeRole.textContent = `${authUsername || "local"}${authRole === "admin" ? "Admin" : "User (read-only)"}`;
if (badgeRole) badgeRole.textContent = `${authUsername || "local"}${authRoles.map((role) => AUTH_ROLE_LABELS[role]).join(", ")}`;
if (headerAuthBtn2) {
headerAuthBtn2.textContent = "Logout";
headerAuthBtn2.style.display = "block";
}
} else {
if (accountTab) accountTab.style.display = "none";
if (badge) badge.style.display = "none";
if (headerAuthBtn2) {
headerAuthBtn2.textContent = "Login";
@@ -1995,31 +1970,34 @@ function updateAuthUI() {
syncTopBarAccess();
}
function applyAuthRestrictions() {
if (!authRole) return;
if (authRole === "user") {
if (authRoles.length === 0) return;
if (!hasAuthRole2("transmit")) {
const pttBtn2 = document.getElementById("ptt-btn");
const txLimitInput2 = document.getElementById("tx-limit");
const txLimitBtn2 = document.getElementById("tx-limit-btn");
const txAudioBtn2 = document.getElementById("tx-audio-btn");
const txLimitRow2 = document.getElementById("tx-limit-row");
if (pttBtn2) pttBtn2.disabled = true;
if (txAudioBtn2) txAudioBtn2.disabled = true;
if (txLimitBtn2) txLimitBtn2.disabled = true;
if (txLimitInput2) txLimitInput2.disabled = true;
if (txLimitRow2) txLimitRow2.style.opacity = "0.5";
}
if (!hasAuthRole2("control")) {
const powerBtn2 = document.getElementById("power-btn");
const lockBtn2 = document.getElementById("lock-btn");
const freqInput = document.getElementById("freq");
const centerFreqInput = document.getElementById("center-freq");
const modeSelect = document.getElementById("mode");
const txLimitInput2 = document.getElementById("tx-limit");
const txLimitBtn2 = document.getElementById("tx-limit-btn");
const txAudioBtn2 = document.getElementById("tx-audio-btn");
const txLimitRow2 = document.getElementById("tx-limit-row");
const jogUp = document.getElementById("jog-up");
const jogDown = document.getElementById("jog-down");
const jogButtons = document.querySelectorAll(".jog-step button");
const vfoButtons = document.querySelectorAll("#vfo-picker button");
if (pttBtn2) pttBtn2.disabled = true;
if (powerBtn2) powerBtn2.disabled = true;
if (lockBtn2) lockBtn2.disabled = true;
if (txAudioBtn2) txAudioBtn2.disabled = true;
if (txLimitBtn2) txLimitBtn2.disabled = true;
if (freqInput) freqInput.disabled = true;
if (centerFreqInput) centerFreqInput.disabled = true;
if (modeSelect) modeSelect.disabled = true;
if (txLimitInput2) txLimitInput2.disabled = true;
vfoButtons.forEach((btn) => btn.disabled = true);
const jogWheel2 = document.getElementById("jog-wheel");
if (jogUp) jogUp.disabled = true;
@@ -2056,7 +2034,6 @@ function applyAuthRestrictions() {
btn.disabled = true;
}
});
if (txLimitRow2) txLimitRow2.style.opacity = "0.5";
}
}
function applyCapabilities(caps) {
@@ -2286,20 +2263,21 @@ window.applyDecodeHistoryRetention = function() {
}
};
function syncTopBarAccess() {
const loggedOut = authEnabled && !authRole;
const loggedOut = authEnabled && authRoles.length === 0;
const tabBar = document.getElementById("tab-bar");
const rigSwitch = document.querySelector(".header-rig-switch");
if (tabBar) tabBar.style.display = "";
document.querySelectorAll(".tab-bar .tab").forEach((btn) => {
const isMain = btn.dataset.tab === "main";
btn.style.display = !loggedOut || isMain ? "" : "none";
const lacksLogbookAccess = authEnabled && btn.dataset.tab === "logbook" && !hasAuthRole2("write");
btn.style.display = (!loggedOut || isMain) && !lacksLogbookAccess ? "" : "none";
btn.disabled = false;
});
if (rigSwitch) {
rigSwitch.style.display = loggedOut ? "none" : "";
}
if (headerRigSwitchSelect) {
headerRigSwitchSelect.disabled = loggedOut || authRole === "user" || lastRigIds.length === 0;
headerRigSwitchSelect.disabled = loggedOut || !hasAuthRole2("control") || lastRigIds.length === 0;
}
}
var overviewDrawPending = false;
@@ -2935,7 +2913,7 @@ function applyRigList(activeRigId, rigIds, displayNames) {
}
const nextKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || "");
const rigListChanged = prevKey !== nextKey;
const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "user";
const disableSwitch = lastRigIds.length === 0 || !hasAuthRole2("control");
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
updateRigSubtitle(lastActiveRigId);
window.trxUi?.setActiveRig(lastActiveRigId);
@@ -3751,10 +3729,19 @@ function visibleBandwidthSpecs(freqHz = lastFreqHz, mode = modeEl ? modeEl.value
}
return [{ centerHz: freqHz, widthHz: currentBandwidthHz }];
}
function digSidebandDirection() {
if (sdrDigSidebandPolicy === "usb") return 1;
if (sdrDigSidebandPolicy === "lsb") return -1;
if (typeof lastFreqHz === "number" && isFiniteNumber(lastFreqHz)) {
return lastFreqHz >= DIG_AUTO_SIDEBAND_THRESHOLD_HZ ? 1 : -1;
}
return 1;
}
function sidebandDirectionForMode(mode = modeEl ? modeEl.value : "") {
const modeUpper = String(mode || "").toUpperCase();
if (modeUpper === "LSB" || modeUpper === "CWR") return -1;
if (modeUpper === "USB" || modeUpper === "CW" || modeUpper === "DIG") return 1;
if (modeUpper === "DIG") return sdrDigSidebandSupported ? digSidebandDirection() : 1;
if (modeUpper === "USB" || modeUpper === "CW") return 1;
return 0;
}
function displaySpanForBandwidthSpec(spec, mode = modeEl ? modeEl.value : "") {
@@ -4273,9 +4260,16 @@ function formatSignal(sUnits) {
return overDb === 0 ? `${sigUnit("S")}9` : `${sigUnit("S")}9+${overDb}${sigUnit("dB")}`;
}
function setDisabled(disabled) {
[freqEl, centerFreqEl, modeEl, pttBtn, powerBtn, txLimitInput, txLimitBtn, lockBtn].forEach((el) => {
if (el) el.disabled = disabled;
const controlDisabled = disabled || authEnabled && !hasAuthRole2("control");
const transmitDisabled = disabled || authEnabled && !hasAuthRole2("transmit");
[freqEl, centerFreqEl, modeEl, powerBtn, lockBtn].forEach((el) => {
if (el) el.disabled = controlDisabled;
});
[pttBtn, txLimitInput, txLimitBtn].forEach((el) => {
if (el) el.disabled = transmitDisabled;
});
const transmitAudio = document.getElementById("tx-audio-btn");
if (transmitAudio) transmitAudio.disabled = transmitDisabled || !hasWebCodecs;
syncModePicker();
}
var serverVersion = null;
@@ -4538,7 +4532,7 @@ function scheduleTuneLinkSync() {
async function applyTuneLink(link) {
const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null;
if (!wanted) return;
if (authRole === "user") {
if (!hasAuthRole2("control")) {
showHint("Read-only session — link not applied", 2500);
return;
}
@@ -4772,6 +4766,25 @@ function render(update) {
sdrNbThresholdEl.value = String(Math.round(update.filter.sdr_nb_threshold));
}
}
if (typeof update.filter.sdr_nb_profile === "string") {
sdrNbProfile = update.filter.sdr_nb_profile;
if (sdrNbProfileWrapEl) sdrNbProfileWrapEl.style.display = "";
if (sdrNbProfileEl && document.activeElement !== sdrNbProfileEl) {
sdrNbProfileEl.value = update.filter.sdr_nb_profile;
}
}
}
if (typeof update.filter.sdr_dig_sideband === "string") {
sdrDigSidebandSupported = true;
const prevPolicy = sdrDigSidebandPolicy;
sdrDigSidebandPolicy = update.filter.sdr_dig_sideband;
if (sdrDigSidebandEl && document.activeElement !== sdrDigSidebandEl) {
sdrDigSidebandEl.value = update.filter.sdr_dig_sideband;
}
updateWfmControls();
if (prevPolicy !== sdrDigSidebandPolicy && lastSpectrumData) {
scheduleSpectrumDraw();
}
}
}
if (typeof update.show_sdr_gain_control === "boolean") {
@@ -5256,7 +5269,7 @@ async function postPath(path, options = {}) {
}
const resp = await fetch(path, { method: "POST" });
if (authEnabled && resp.status === 401) {
authRole = null;
setAuthRoles([]);
if (es) es.close();
showAuthGate();
throw new Error("Authentication required");
@@ -5281,8 +5294,8 @@ async function switchRigFromSelect(selectEl) {
showHint("No rig selected", 1500);
return;
}
if (authRole === "user") {
showHint("Admin role required", 1500);
if (!hasAuthRole2("control")) {
showHint("Control role required", 1500);
return;
}
if (!lastRigIds.includes(selectEl.value)) {
@@ -5895,10 +5908,15 @@ function navigateToTab(name, options = {}) {
window.trxUi?.closeMobileOverlays?.();
const leavingSatellites = _activeTab === "satellites" && name !== "satellites";
const { updateHistory = true, replaceHistory = false } = options;
if (authEnabled && !authRole && name !== "main") {
if (authEnabled && authRoles.length === 0 && name !== "main") {
showAuthGate();
return;
}
if (authEnabled && name === "logbook" && !hasAuthRole2("write")) {
showHint("Write role required for logbook access", 2500);
navigateToTab("main", options);
return;
}
const btn = document.querySelector(`.tab-bar .tab[data-tab="${name}"]`);
if (!btn) return;
_activeTab = name;
@@ -6013,7 +6031,7 @@ async function initializeApp() {
const authStatus = await checkAuthStatus();
authEnabled = !authStatus.auth_disabled;
if (!authEnabled) {
authRole = "admin";
setAuthRoles(AUTH_ADMIN_ROLES);
hideAuthGate();
updateAuthUI();
connect();
@@ -6024,7 +6042,7 @@ async function initializeApp() {
return;
}
if (authStatus.authenticated) {
authRole = authStatus.role ?? null;
setAuthRoles(authStatus.roles);
authUsername = authStatus.username ?? null;
hideAuthGate();
updateAuthUI();
@@ -6041,22 +6059,32 @@ async function initializeApp() {
var settingsUiReady = false;
function initSettingsUI() {
settingsUiReady = true;
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRole);
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRoles);
window.trx.modules.scheduler?.wireEvents();
if (window.trx.modules.backgroundDecode) {
window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRole);
window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRoles);
window.trx.modules.backgroundDecode.wireEvents();
}
void refreshUserManagement();
}
async function refreshUserManagement() {
const section = document.getElementById("user-management");
if (!section) return;
section.style.display = authEnabled && authRole === "admin" ? "block" : "none";
if (section.style.display === "none") return;
const tab = document.getElementById("settings-users-tab");
if (!section || !tab) return;
const canManageUsers = authEnabled && hasAuthRole2("administrator");
tab.style.display = canManageUsers ? "" : "none";
if (!canManageUsers) {
const panel = document.getElementById("subtab-settings-users");
if (panel) panel.style.display = "none";
if (tab.classList.contains("active")) {
document.querySelector('[data-subtab="settings-scheduler"]')?.click();
}
return;
}
const list = requiredElement("user-list");
try {
const users = await listUsers();
const enabledAdminCount = users.filter((user) => user.enabled && hasAuthRole(user.roles, "administrator")).length;
list.replaceChildren(...users.map((user) => {
const row = document.createElement("div");
row.className = "sch-row";
@@ -6064,26 +6092,54 @@ async function refreshUserManagement() {
const name = document.createElement("strong");
name.textContent = user.username;
name.style.minWidth = "10rem";
const role = document.createElement("select");
role.className = "auth-input";
for (const value of ["user", "admin"]) {
const option = document.createElement("option");
option.value = value;
option.textContent = value === "admin" ? "Admin" : "User";
option.selected = user.role === value;
role.append(option);
if (!user.enabled) name.textContent += " (disabled)";
const { element: roles, inputs: roleInputs } = buildRoleChoices(user.roles);
const enabledLabel = document.createElement("label");
enabledLabel.className = "auth-role-choice";
const enabled = document.createElement("input");
enabled.type = "checkbox";
enabled.checked = user.enabled;
enabled.disabled = user.username === authUsername;
if (enabled.disabled) enabled.title = "You cannot disable your current account";
enabledLabel.append(enabled, " Enabled");
const isOnlyAdmin = user.enabled && hasAuthRole(user.roles, "administrator") && enabledAdminCount === 1;
const administratorInput = roleInputs.find((item) => item.value === "administrator")?.input;
const guestInput = roleInputs.find((item) => item.value === "guest")?.input;
if (isOnlyAdmin && administratorInput) {
administratorInput.disabled = true;
administratorInput.title = "The final administrator cannot be demoted";
}
if (isOnlyAdmin && guestInput) {
guestInput.disabled = true;
guestInput.title = "The final administrator cannot become a Guest";
}
if (isOnlyAdmin) {
enabled.disabled = true;
enabled.title = "The final enabled administrator cannot be disabled";
}
const password = document.createElement("input");
password.type = "password";
password.placeholder = "New password";
password.placeholder = "New password (8+ characters)";
password.autocomplete = "new-password";
password.className = "auth-input";
password.minLength = 8;
password.maxLength = 1024;
const syncPasswordAccess = () => {
const guestActive = guestInput?.checked === true;
password.disabled = guestActive;
password.title = guestActive ? "Password changes are unavailable while the Guest role is active" : "";
if (guestActive) password.value = "";
};
roleInputs.forEach(({ input }) => input.addEventListener("change", syncPasswordAccess));
syncPasswordAccess();
const save = document.createElement("button");
save.type = "button";
save.textContent = "Save";
save.addEventListener("click", async () => {
const changes = { role: role.value };
const changes = {
roles: roleInputs.filter(({ input }) => input.checked).map(({ value }) => value),
enabled: enabled.checked
};
if (password.value) changes.password = password.value;
await runUserOperation(() => updateUser(user.username, changes));
});
@@ -6091,13 +6147,14 @@ async function refreshUserManagement() {
remove.type = "button";
remove.textContent = "Remove";
remove.className = "danger";
remove.disabled = user.username === authUsername;
remove.disabled = user.username === authUsername || isOnlyAdmin;
if (isOnlyAdmin) remove.title = "The final administrator cannot be removed";
remove.addEventListener("click", async () => {
if (await window.trxUi.confirm({ title: "Remove user?", message: `Remove ${user.username} and revoke their sessions?`, confirmLabel: "Remove", danger: true })) {
await runUserOperation(() => deleteUser(user.username));
}
});
row.append(name, role, password, save, remove);
row.append(name, enabledLabel, roles, password, save, remove);
return row;
}));
} catch (error) {
@@ -6120,16 +6177,52 @@ async function runUserOperation(operation) {
showUserManagementError(reason);
}
}
var createRoleContainer = document.getElementById("user-create-roles");
if (createRoleContainer) {
const { element } = buildRoleChoices(["read"]);
element.id = createRoleContainer.id;
createRoleContainer.replaceWith(element);
}
document.getElementById("user-create-form")?.addEventListener("submit", (event) => {
event.preventDefault();
const username = requiredElement("user-create-username");
const password = requiredElement("user-create-password");
const role = requiredElement("user-create-role");
const enabled = requiredElement("user-create-enabled");
const roles = Array.from(document.querySelectorAll("#user-create-roles input[type=checkbox]"));
void runUserOperation(async () => {
await createUser(username.value, password.value, role.value);
await createUser(username.value, password.value, roles.filter((input) => input.checked).map((input) => input.value), enabled.checked);
username.value = "";
password.value = "";
role.value = "user";
enabled.checked = true;
roles.forEach((input) => {
input.checked = input.value === "read";
});
});
});
document.getElementById("account-password-form")?.addEventListener("submit", (event) => {
event.preventDefault();
const form = event.currentTarget;
const currentPassword = requiredElement("account-current-password");
const newPassword = requiredElement("account-new-password");
const confirmPassword = requiredElement("account-confirm-password");
const error = requiredElement("account-password-error");
const submit = form.querySelector('button[type="submit"]');
if (newPassword.value !== confirmPassword.value) {
error.textContent = "New passwords do not match";
error.style.display = "block";
return;
}
if (submit) submit.disabled = true;
void changeOwnPassword(currentPassword.value, newPassword.value).then(async () => {
form.reset();
error.style.display = "none";
await authLogout();
showHint("Password changed. Sign in again.", 3e3);
}).catch((reason) => {
error.textContent = reason instanceof Error ? reason.message : String(reason);
error.style.display = "block";
}).finally(() => {
if (submit) submit.disabled = false;
});
});
requiredElement("auth-form").addEventListener("submit", async (e) => {
@@ -6142,7 +6235,7 @@ requiredElement("auth-form").addEventListener("submit", async (e) => {
btn.textContent = "Logging in...";
try {
const result = await authLogin(usernameEl.value, passwordEl.value);
authRole = result.role ?? null;
setAuthRoles(result.roles);
authUsername = result.username ?? usernameEl.value;
passwordEl.value = "";
hideAuthGate();
@@ -6164,7 +6257,7 @@ requiredElement("auth-form").addEventListener("submit", async (e) => {
var headerAuthBtn = document.getElementById("header-auth-btn");
if (headerAuthBtn) {
headerAuthBtn.addEventListener("click", async () => {
if (authRole) {
if (authRoles.length > 0) {
if (await window.trxUi.confirm({ title: "Log out?", message: "Audio and control access for this browser session will end.", confirmLabel: "Log out", danger: false })) {
await authLogout();
}
@@ -6207,8 +6300,8 @@ Object.defineProperties(trxState, {
authEnabled: { get() {
return authEnabled;
} },
authRole: { get() {
return authRole;
authRoles: { get() {
return authRoles;
} },
decoderRegistry: { get() {
return decoderRegistry;
@@ -6558,7 +6651,15 @@ var sdrNbEnabledEl = document.getElementById("sdr-nb-enabled");
var sdrNbThresholdControlsEl = document.getElementById("sdr-nb-threshold-controls");
var sdrNbThresholdEl = document.getElementById("sdr-nb-threshold");
var sdrNbThresholdSetBtn = document.getElementById("sdr-nb-threshold-set");
var sdrNbProfileWrapEl = document.getElementById("sdr-nb-profile-wrap");
var sdrNbProfileEl = document.getElementById("sdr-nb-profile");
var sdrNbSupported = false;
var sdrNbProfile = "spike";
var sdrDigSidebandWrapEl = document.getElementById("sdr-dig-sideband-wrap");
var sdrDigSidebandEl = document.getElementById("sdr-dig-sideband");
var sdrDigSidebandSupported = false;
var sdrDigSidebandPolicy = "auto";
var DIG_AUTO_SIDEBAND_THRESHOLD_HZ = 1e7;
fetch("/audio", { method: "GET" }).then((r) => {
if (r.status === 404) audioRow.style.display = "none";
}).catch(() => {
@@ -6928,7 +7029,7 @@ function submitSdrNbState() {
const threshold = sdrNbThresholdEl ? Number.parseFloat(sdrNbThresholdEl.value) : 10;
if (!isFiniteNumber(threshold) || threshold < 1 || threshold > 100) return;
postPath(
`/set_sdr_noise_blanker?enabled=${enabled ? "true" : "false"}&threshold=${encodeURIComponent(threshold)}`
`/set_sdr_noise_blanker?enabled=${enabled ? "true" : "false"}&threshold=${encodeURIComponent(threshold)}&profile=${encodeURIComponent(sdrNbProfile)}`
).catch(() => {
});
}
@@ -6937,6 +7038,16 @@ if (sdrNbEnabledEl) {
submitSdrNbState();
});
}
if (sdrNbProfileEl) {
sdrNbProfileEl.addEventListener("change", () => {
const profile = sdrNbProfileEl.value || "spike";
if (profile !== "spike" && profile !== "ignition" && profile !== "powerline" && profile !== "broadband") {
return;
}
sdrNbProfile = profile;
submitSdrNbState();
});
}
function submitSdrNbThreshold() {
if (!sdrNbThresholdEl) return;
const parsed = Number.parseFloat(sdrNbThresholdEl.value);
@@ -6954,11 +7065,28 @@ if (sdrNbThresholdEl) {
}
});
}
function submitSdrDigSideband() {
if (!sdrDigSidebandSupported || !sdrDigSidebandEl) return;
const policy = sdrDigSidebandEl.value || "auto";
if (policy !== "auto" && policy !== "usb" && policy !== "lsb") return;
sdrDigSidebandPolicy = policy;
if (lastSpectrumData) scheduleSpectrumDraw();
postPath(`/set_sdr_dig_sideband?policy=${encodeURIComponent(policy)}`).catch(() => {
});
}
if (sdrDigSidebandEl) {
sdrDigSidebandEl.addEventListener("change", () => {
submitSdrDigSideband();
});
}
function updateWfmControls() {
const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
if (wfmControlsCol) wfmControlsCol.style.display = mode === "WFM" ? "" : "none";
if (samControlsCol) samControlsCol.style.display = mode === "SAM" ? "" : "none";
if (modeControlsRow) modeControlsRow.style.display = mode === "WFM" || mode === "SAM" ? "" : "none";
if (sdrDigSidebandWrapEl) {
sdrDigSidebandWrapEl.style.display = sdrDigSidebandSupported && mode === "DIG" ? "" : "none";
}
}
if (!hasWebCodecs) {
rxAudioBtn.disabled = true;
@@ -7280,6 +7408,10 @@ function startTxAudio() {
void stopTxAudio();
return;
}
if (authEnabled && !hasAuthRole2("transmit")) {
audioStatus.textContent = "Transmit role required";
return;
}
if (!hasWebCodecs) {
audioStatus.textContent = "Audio requires Chrome/Edge";
return;
@@ -1,3 +1,6 @@
import {
hasAuthRole
} from "./chunk-PISLBJGN.js";
import {
hostState
} from "./chunk-KL66PICH.js";
@@ -13,7 +16,7 @@ var bgdWindow = window;
return d.id;
});
}
let backgroundDecodeRole = null;
let backgroundDecodeRoles = [];
let currentRigId = null;
let currentConfig = null;
let bookmarkList = [];
@@ -21,8 +24,8 @@ var bgdWindow = window;
let bgdDirty = false;
let statusByBookmark = /* @__PURE__ */ new Map();
let lastStatus = null;
function initBackgroundDecode(rigId, role) {
backgroundDecodeRole = role;
function initBackgroundDecode(rigId, roles) {
backgroundDecodeRoles = roles;
currentRigId = rigId || hostState.lastActiveRigId || null;
if (currentRigId) loadBackgroundDecode();
startStatusPolling();
@@ -358,7 +361,7 @@ var bgdWindow = window;
btn.title = bgdDirty ? "Apply these bookmarks to the background decoder" : "No changes to save";
}
function isControlRole() {
return backgroundDecodeRole === "admin" || hostState.authEnabled === false;
return hasAuthRole(backgroundDecodeRoles, "control") || hostState.authEnabled === false;
}
function showToast(msg, isError) {
const el = document.getElementById("background-decode-toast");
@@ -1,3 +1,6 @@
import {
hasAuthRole
} from "./chunk-PISLBJGN.js";
import {
hostCore,
hostState
@@ -42,7 +45,7 @@ function bmEsc(str) {
return d.innerHTML;
}
function bmCanControl() {
return !hostState.authEnabled || hostState.authRole === "admin";
return !hostState.authEnabled || hasAuthRole(hostState.authRoles, "write");
}
function bmSyncAccess() {
const canCtrl = bmCanControl();
@@ -211,6 +214,12 @@ function bmBuildDecoderCheckboxes() {
container.appendChild(label);
});
}
function bmSyncDigSidebandVisibility() {
const label = bmOptionalEl("bm-dig-sideband-label");
if (!label) return;
const mode = (bmEl("bm-mode").value || "").trim().toUpperCase();
label.style.display = mode === "DIG" ? "" : "none";
}
function bmOpenForm(bm) {
const wrap = bmEl("bm-form-wrap");
if (!wrap) return;
@@ -224,7 +233,9 @@ function bmOpenForm(bm) {
bmEl("bm-locator").value = bm ? bm.locator || "" : "";
bmEl("bm-category-input").value = bm ? bm.category || "" : "";
bmEl("bm-comment").value = bm ? bm.comment || "" : "";
bmEl("bm-dig-sideband").value = bm ? bm.dig_sideband || "" : "";
bmWriteDecoders(bm?.decoders ?? []);
bmSyncDigSidebandVisibility();
bmEl("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark";
wrap.style.display = "flex";
bmEl("bm-name").focus();
@@ -260,6 +271,7 @@ async function bmSave(e) {
const category = bmEl("bm-category-input").value.trim();
const comment = bmEl("bm-comment").value.trim();
const decoders = bmReadDecoders();
const dig_sideband = mode.toUpperCase() === "DIG" ? bmEl("bm-dig-sideband").value || null : null;
const formError = bmEl("bm-form-error");
if (formError) formError.textContent = "";
if (!name || !Number.isFinite(freq_hz) || !mode) {
@@ -276,7 +288,8 @@ async function bmSave(e) {
locator: locator || null,
category,
comment,
decoders
decoders,
dig_sideband
};
try {
let resp;
@@ -341,6 +354,12 @@ function bmApply(bm) {
}
const tunePromise = (async () => {
await bridge.trx.modules.vchan?.takeSchedulerControl();
if ((bm.mode || "").toUpperCase() === "DIG" && bm.dig_sideband) {
const p = bm.dig_sideband.toLowerCase();
if (p === "auto" || p === "usb" || p === "lsb") {
await hostCore.postPath("/set_sdr_dig_sideband?policy=" + encodeURIComponent(p));
}
}
const onVirtual = await bridge.trx.modules.vchan?.interceptMode(bm.mode) ?? false;
if (!onVirtual) {
await hostCore.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
@@ -593,6 +612,7 @@ function bmPopulateScopePicker() {
bmEl("bm-form").addEventListener("submit", (event) => {
void bmSave(event);
});
bmEl("bm-mode").addEventListener("input", bmSyncDigSidebandVisibility);
bmEl("bm-form-cancel").addEventListener("click", bmCloseForm);
const formWrap = bmEl("bm-form-wrap");
if (formWrap) {
@@ -0,0 +1,136 @@
// src/api/auth.ts
var AUTH_ROLES = ["guest", "read", "control", "transmit", "write", "administrator"];
var AUTH_ADMIN_ROLES = AUTH_ROLES.filter((role) => role !== "guest");
var AUTH_ROLE_LABELS = {
guest: "Guest",
read: "Read",
control: "Control",
transmit: "Transmit",
write: "Write",
administrator: "Administrator"
};
function isAuthRole(value) {
return typeof value === "string" && AUTH_ROLES.includes(value);
}
function normalizeAuthRoles(roles) {
return AUTH_ROLES.filter((role) => roles.includes(role));
}
function hasAuthRole(roles, required) {
return roles.includes("administrator") || roles.includes(required) || required === "read" && roles.includes("guest") || required === "read" && (roles.includes("control") || roles.includes("transmit"));
}
function hasAccountControls(roles) {
return roles.length > 0 && !roles.includes("guest");
}
function decodeRoles(value, context) {
if (!Array.isArray(value) || !value.every(isAuthRole)) {
throw new TypeError(`${context} has invalid roles`);
}
return normalizeAuthRoles(value);
}
function decodeAuthSession(value) {
if (typeof value !== "object" || value === null) {
throw new TypeError("The authentication response is malformed");
}
const session = value;
if (typeof session.authenticated !== "boolean") {
throw new TypeError("The authentication response has no authenticated flag");
}
if (session.auth_disabled !== void 0 && typeof session.auth_disabled !== "boolean") {
throw new TypeError("The authentication response has an invalid auth_disabled flag");
}
const decoded = {
authenticated: session.authenticated,
roles: decodeRoles(session.roles, "The authentication response")
};
if (session.username !== void 0) {
if (typeof session.username !== "string") throw new TypeError("The authentication response has an invalid username");
decoded.username = session.username;
}
if (session.auth_disabled !== void 0) decoded.auth_disabled = session.auth_disabled;
return decoded;
}
var authDisabledSession = {
authenticated: true,
roles: [...AUTH_ADMIN_ROLES],
auth_disabled: true
};
async function fetchAuthSession() {
try {
const response = await fetch("/auth/session");
if (response.status === 404) return authDisabledSession;
if (!response.ok) return { authenticated: false, roles: [] };
return decodeAuthSession(await response.json());
} catch (error) {
console.error("Auth check failed:", error);
return { authenticated: false, roles: [] };
}
}
async function login(username, password) {
const response = await fetch("/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password })
});
if (response.status === 404) return authDisabledSession;
if (!response.ok) {
const message = await response.text();
throw new Error(message || "Login failed");
}
return decodeAuthSession(await response.json());
}
async function userRequest(path, init) {
const response = await fetch(path, init);
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
throw new Error(payload.error || `User operation failed (${response.status})`);
}
return response;
}
async function listUsers() {
const value = await userRequest("/auth/users").then((response) => response.json());
if (!Array.isArray(value) || !value.every((user) => {
if (typeof user !== "object" || user === null) return false;
const record = user;
return typeof record.username === "string" && typeof record.enabled === "boolean" && Array.isArray(record.roles) && record.roles.every(isAuthRole);
})) {
throw new TypeError("The user list response is malformed");
}
return value.map((user) => ({ ...user, roles: normalizeAuthRoles(user.roles) }));
}
async function createUser(username, password, roles, enabled = true) {
await userRequest("/auth/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password, roles, enabled }) });
}
async function updateUser(username, changes) {
await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(changes) });
}
async function changeOwnPassword(currentPassword, newPassword) {
await userRequest("/auth/account/password", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword })
});
}
async function deleteUser(username) {
await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "DELETE" });
}
async function logout() {
const response = await fetch("/auth/logout", { method: "POST" });
if (response.status !== 404 && !response.ok) throw new Error("Logout failed");
}
export {
AUTH_ROLES,
AUTH_ADMIN_ROLES,
AUTH_ROLE_LABELS,
normalizeAuthRoles,
hasAuthRole,
hasAccountControls,
fetchAuthSession,
login,
listUsers,
createUser,
updateUser,
changeOwnPassword,
deleteUser,
logout
};
@@ -1,3 +1,6 @@
import {
hasAuthRole
} from "./chunk-PISLBJGN.js";
import {
hostCore,
hostState
@@ -47,6 +50,9 @@ var entryRigName = null;
var entryGrid = null;
var qsos = [];
var workedRequest = 0;
function canWriteLogbook() {
return !hostState.authEnabled || hasAuthRole(hostState.authRoles, "write");
}
function notify(message, kind) {
if (bridge.trxUi.notify) bridge.trxUi.notify(message, kind ? { kind } : void 0);
else hostCore.showHint(message, 2e3);
@@ -300,6 +306,11 @@ function renderRows() {
row.appendChild(cell);
}
const actions = document.createElement("td");
if (!canWriteLogbook()) {
row.appendChild(actions);
fragment.appendChild(row);
continue;
}
const confirm = document.createElement("button");
confirm.type = "button";
confirm.className = "log-row-btn";
@@ -420,14 +431,19 @@ importFile?.addEventListener("change", () => {
if (file) void importAdif(file);
importFile.value = "";
});
bridge.logContact = (seed) => {
bridge.navigateToTab?.("logbook");
void openEntry(seed).then(() => callInput?.focus());
};
if (canWriteLogbook()) {
bridge.logContact = (seed) => {
bridge.navigateToTab?.("logbook");
void openEntry(seed).then(() => callInput?.focus());
};
} else {
if (form) form.style.display = "none";
if (importBtn) importBtn.style.display = "none";
}
renderStation();
if (cabrilloCallsign && !cabrilloCallsign.value) {
cabrilloCallsign.value = stationCallEl?.textContent?.trim() ?? "";
}
syncCabrilloLink();
void openEntry();
if (canWriteLogbook()) void openEntry();
void refreshLog();
@@ -1,3 +1,6 @@
import {
hasAuthRole
} from "./chunk-PISLBJGN.js";
import {
hostState
} from "./chunk-KL66PICH.js";
@@ -15,7 +18,7 @@ function schedulerOptionalEl(id) {
}
(function() {
"use strict";
let schedulerRole = null;
let schedulerRoles = [];
let currentRigId = null;
let currentConfig = null;
let currentSchedulerStatus = null;
@@ -25,8 +28,8 @@ function schedulerOptionalEl(id) {
let schedulerStepPending = false;
let schEntryEditIdx = null;
let schedulerDirty = false;
function initScheduler(rigId, role) {
schedulerRole = role;
function initScheduler(rigId, roles) {
schedulerRoles = roles;
currentRigId = rigId || null;
if (currentRigId) loadScheduler();
startStatusPolling();
@@ -272,7 +275,7 @@ function schedulerOptionalEl(id) {
const nextBtn = schedulerEl("scheduler-next-btn");
if (!prevBtn || !nextBtn) return;
const state = schedulerInterleaveState(currentConfig);
const enabled = schedulerRole === "admin" && !!currentRigId && !schedulerStepPending && state.activeEntries.length > 1;
const enabled = hasAuthRole(schedulerRoles, "control") && !!currentRigId && !schedulerStepPending && state.activeEntries.length > 1;
prevBtn.disabled = !enabled;
nextBtn.disabled = !enabled;
const hint = enabled ? "Select a different active scheduler entry" : "Available only when multiple scheduler entries are active";
@@ -354,7 +357,7 @@ function schedulerOptionalEl(id) {
const panel = schedulerEl("scheduler-panel");
if (!panel) return;
const mode = currentConfig && currentConfig.mode || "disabled";
const isControl = schedulerRole === "admin";
const isControl = hasAuthRole(schedulerRoles, "control");
setSelected("scheduler-mode-select", mode);
const satEnabled = currentConfig && currentConfig.satellites && currentConfig.satellites.enabled;
const controlRow = document.querySelector(".scheduler-control-row");
@@ -1220,8 +1223,8 @@ function schedulerOptionalEl(id) {
markDirty: markSchedulerDirty
};
schedulerWindow.trx.modules.scheduler = schedulerService;
if (hostState.authRole != null) {
initScheduler(hostState.lastActiveRigId, hostState.authRole);
if (!hostState.authEnabled || hostState.authRoles.length > 0) {
initScheduler(hostState.lastActiveRigId, hostState.authRoles);
wireSchedulerEvents();
}
})();
@@ -127,7 +127,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div>
<form id="auth-form" class="auth-form">
<input type="text" id="auth-username" class="auth-input" placeholder="Username" autocomplete="username" required />
<input type="password" id="auth-password" class="auth-input" placeholder="Password" autocomplete="current-password" required />
<input type="password" id="auth-password" class="auth-input" placeholder="Password" autocomplete="current-password" maxlength="1024" required />
<button type="submit" class="auth-submit">Login</button>
</form>
<div id="auth-error" class="auth-error" style="display: none;"></div>
@@ -385,6 +385,23 @@ SPDX-License-Identifier: GPL-2.0-or-later
</label>
<button id="sdr-nb-threshold-set" type="button" class="wfm-inline-btn">Set</button>
</div>
<label class="wfm-control" id="sdr-nb-profile-wrap" style="display:none;">
<span class="wfm-control-label" title="Matches the blanker to the interference: Spike = sharp bursts, Ignition = engine/PWM, Powerline = mains buzz, Broadband = dense noise.">NB profile</span>
<select id="sdr-nb-profile" class="status-input">
<option value="spike">Spike</option>
<option value="ignition">Ignition</option>
<option value="powerline">Powerline</option>
<option value="broadband">Broadband</option>
</select>
</label>
<label class="wfm-control" id="sdr-dig-sideband-wrap" style="display:none;">
<span class="wfm-control-label" title="Sideband used to demodulate DIG. Auto = USB &ge; 10 MHz, LSB below.">DIG sideband</span>
<select id="sdr-dig-sideband" class="status-input">
<option value="auto">Auto (by band)</option>
<option value="usb">USB</option>
<option value="lsb">LSB</option>
</select>
</label>
</div>
</div>
<div class="full-row label-below-row" id="vchan-row">
@@ -721,6 +738,14 @@ SPDX-License-Identifier: GPL-2.0-or-later
<label class="bm-label">Bandwidth (Hz)
<input type="number" id="bm-bw" class="status-input" min="0" placeholder="optional" />
</label>
<label class="bm-label" id="bm-dig-sideband-label" style="display:none;">DIG sideband
<select id="bm-dig-sideband" class="status-input">
<option value="">Global setting</option>
<option value="auto">Auto (by band)</option>
<option value="usb">USB</option>
<option value="lsb">LSB</option>
</select>
</label>
<label class="bm-label">Locator
<input type="text" id="bm-locator" class="status-input" maxlength="6" placeholder="e.g. JO93" />
</label>
@@ -1461,6 +1486,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
<button class="sub-tab" data-subtab="settings-background-decode">Background Decode</button>
<button class="sub-tab" data-subtab="settings-bandplan">Bandplan</button>
<button class="sub-tab" data-subtab="settings-history">History</button>
<button id="settings-account-tab" class="sub-tab" data-subtab="settings-account" style="display:none;">Account</button>
<button id="settings-users-tab" class="sub-tab" data-subtab="settings-users" style="display:none;">Users</button>
</div>
<div id="subtab-settings-scheduler" class="sub-tab-panel">
<div id="scheduler-panel" class="sch-panel">
@@ -1750,19 +1777,34 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div>
</div>
</div>
<section id="user-management" style="display:none; margin-top:1rem;">
<h2 class="section-heading">User management</h2>
<div id="subtab-settings-account" class="sub-tab-panel" style="display:none;">
<div class="settings-card">
<h3>Change password</h3>
<form id="account-password-form" class="sch-row" style="flex-wrap:wrap; gap:.5rem;">
<input id="account-current-password" class="auth-input" type="password" placeholder="Current password" autocomplete="current-password" maxlength="1024" required />
<input id="account-new-password" class="auth-input" type="password" placeholder="New password (8+ characters)" autocomplete="new-password" minlength="8" maxlength="1024" required />
<input id="account-confirm-password" class="auth-input" type="password" placeholder="Confirm new password" autocomplete="new-password" minlength="8" maxlength="1024" required />
<button type="submit" class="auth-submit">Change password</button>
</form>
<div id="account-password-error" class="auth-error" role="alert" aria-live="polite" style="display:none;"></div>
<p class="settings-note">Changing your password signs out every session for this account.</p>
</div>
</div>
<div id="subtab-settings-users" class="sub-tab-panel" style="display:none;">
<div id="user-management">
<div class="settings-card">
<form id="user-create-form" class="sch-row" style="flex-wrap:wrap; gap:.5rem;">
<input id="user-create-username" class="auth-input" placeholder="Username" autocomplete="off" required />
<input id="user-create-password" class="auth-input" type="password" placeholder="Password (8+ characters)" autocomplete="new-password" minlength="8" required />
<select id="user-create-role" class="auth-input"><option value="user">User</option><option value="admin">Admin</option></select>
<input id="user-create-password" class="auth-input" type="password" placeholder="Password (8+ characters)" autocomplete="new-password" minlength="8" maxlength="1024" required />
<span id="user-create-roles" class="auth-role-choices"></span>
<label class="auth-role-choice"><input id="user-create-enabled" type="checkbox" checked /> Enabled</label>
<button type="submit" class="auth-submit">Add user</button>
</form>
<div id="user-management-error" class="auth-error" style="display:none;"></div>
<div id="user-list" style="margin-top:.75rem;"></div>
</div>
</section>
</div>
</div>
</div>
<div id="tab-about" class="tab-panel" style="display:none;">
<h2 class="section-heading">About</h2>
@@ -216,6 +216,9 @@ body {
.auth-role { margin-top: var(--space-4); color: var(--text-muted); font-size: var(--fs-sm); }
#user-management .auth-input { width: auto; min-width: 9rem; flex: 1 1 10rem; margin-bottom: 0; }
#user-management .auth-submit { width: auto; }
#user-management .auth-role-choices { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
#user-management .auth-role-choice { display: inline-flex; align-items: center; gap: .2rem; white-space: nowrap; }
.settings-note { color: var(--text-muted); font-size: .85rem; margin: .75rem 0 0; }
#user-management button { padding: 0.55rem 0.75rem; }
.label { color: var(--text-muted); font-size: 0.9rem; margin-bottom: 6px; display: block; }
@@ -12,9 +12,13 @@ use trx_core::rig::{
RigAccessMethod, RigCapabilities, RigInfo, RigRxStatus, RigStatus, RigTxStatus, RigVfo,
RigVfoEntry,
};
use trx_core::{DecoderConfig, RdsData, RigFilterState, RigMode, RigSnapshot, WfmDenoiseLevel};
use trx_core::{
DecoderConfig, DigSidebandPolicy, NoiseBlankerProfile, RdsData, RigFilterState, RigMode,
RigSnapshot, WfmDenoiseLevel,
};
use trx_frontend_http::server::api::rig::{RigListItem, RigListResponse};
use trx_frontend_http::server::api::FrontendMeta;
use trx_frontend_http::server::auth::AuthRole;
use trx_protocol::{DecoderActivation, DecoderDescriptor};
use ts_rs::{Config, TS};
@@ -48,6 +52,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
export!(RigStatus);
export!(DecoderConfig);
export!(WfmDenoiseLevel);
export!(DigSidebandPolicy);
export!(NoiseBlankerProfile);
export!(RigFilterState);
export!(RdsData);
export!(SpectrumData);
@@ -56,6 +62,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
export!(RigListItem);
export!(RigListResponse);
export!(FrontendMeta);
export!(AuthRole);
export!(DecoderActivation);
export!(DecoderDescriptor);
@@ -12,7 +12,7 @@
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
"test": "node --test tests/*.test.mjs",
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs && node tests/mobile-layout.mjs && node tests/satellite-predictions.mjs && node tests/background-decode.mjs && node tests/logbook.mjs",
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs && node tests/mobile-layout.mjs && node tests/satellite-predictions.mjs && node tests/background-decode.mjs && node tests/logbook.mjs && node tests/account-management.mjs && node tests/transmit-role.mjs",
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
},
"devDependencies": {
@@ -2,11 +2,50 @@
//
// SPDX-License-Identifier: GPL-2.0-or-later
export type AuthRole = "user" | "admin";
import type { AuthRole } from "./generated.js";
export type { AuthRole };
export const AUTH_ROLES: readonly AuthRole[] = ["guest", "read", "control", "transmit", "write", "administrator"];
export const AUTH_ADMIN_ROLES: readonly AuthRole[] = AUTH_ROLES.filter(role => role !== "guest");
export const AUTH_ROLE_LABELS: Readonly<Record<AuthRole, string>> = {
guest: "Guest",
read: "Read",
control: "Control",
transmit: "Transmit",
write: "Write",
administrator: "Administrator",
};
export function isAuthRole(value: unknown): value is AuthRole {
return typeof value === "string" && (AUTH_ROLES as readonly string[]).includes(value);
}
export function normalizeAuthRoles(roles: readonly AuthRole[]): AuthRole[] {
return AUTH_ROLES.filter(role => roles.includes(role));
}
export function hasAuthRole(roles: readonly AuthRole[], required: AuthRole): boolean {
return roles.includes("administrator")
|| roles.includes(required)
|| required === "read" && roles.includes("guest")
|| required === "read" && (roles.includes("control") || roles.includes("transmit"));
}
export function hasAccountControls(roles: readonly AuthRole[]): boolean {
return roles.length > 0 && !roles.includes("guest");
}
function decodeRoles(value: unknown, context: string): AuthRole[] {
if (!Array.isArray(value) || !value.every(isAuthRole)) {
throw new TypeError(`${context} has invalid roles`);
}
return normalizeAuthRoles(value);
}
export interface AuthSession {
authenticated: boolean;
role?: AuthRole;
roles: AuthRole[];
username?: string;
auth_disabled?: boolean;
}
@@ -19,14 +58,13 @@ function decodeAuthSession(value: unknown): AuthSession {
if (typeof session.authenticated !== "boolean") {
throw new TypeError("The authentication response has no authenticated flag");
}
if (session.role !== undefined && session.role !== "user" && session.role !== "admin") {
throw new TypeError("The authentication response has an invalid role");
}
if (session.auth_disabled !== undefined && typeof session.auth_disabled !== "boolean") {
throw new TypeError("The authentication response has an invalid auth_disabled flag");
}
const decoded: AuthSession = { authenticated: session.authenticated };
if (session.role !== undefined) decoded.role = session.role;
const decoded: AuthSession = {
authenticated: session.authenticated,
roles: decodeRoles(session.roles, "The authentication response"),
};
if (session.username !== undefined) {
if (typeof session.username !== "string") throw new TypeError("The authentication response has an invalid username");
decoded.username = session.username;
@@ -37,7 +75,7 @@ function decodeAuthSession(value: unknown): AuthSession {
const authDisabledSession: AuthSession = {
authenticated: true,
role: "admin",
roles: [...AUTH_ADMIN_ROLES],
auth_disabled: true,
};
@@ -45,11 +83,11 @@ export async function fetchAuthSession(): Promise<AuthSession> {
try {
const response = await fetch("/auth/session");
if (response.status === 404) return authDisabledSession;
if (!response.ok) return { authenticated: false };
if (!response.ok) return { authenticated: false, roles: [] };
return decodeAuthSession(await response.json());
} catch (error: unknown) {
console.error("Auth check failed:", error);
return { authenticated: false };
return { authenticated: false, roles: [] };
}
}
@@ -67,7 +105,7 @@ export async function login(username: string, password: string): Promise<AuthSes
return decodeAuthSession(await response.json());
}
export interface ManagedUser { username: string; role: AuthRole }
export interface ManagedUser { username: string; roles: AuthRole[]; enabled: boolean }
async function userRequest(path: string, init?: RequestInit): Promise<Response> {
const response = await fetch(path, init);
@@ -83,21 +121,32 @@ export async function listUsers(): Promise<ManagedUser[]> {
if (!Array.isArray(value) || !value.every((user: unknown) => {
if (typeof user !== "object" || user === null) return false;
const record = user as Record<string, unknown>;
return typeof record.username === "string" && (record.role === "user" || record.role === "admin");
return typeof record.username === "string"
&& typeof record.enabled === "boolean"
&& Array.isArray(record.roles)
&& record.roles.every(isAuthRole);
})) {
throw new TypeError("The user list response is malformed");
}
return value as ManagedUser[];
return (value as ManagedUser[]).map(user => ({ ...user, roles: normalizeAuthRoles(user.roles) }));
}
export async function createUser(username: string, password: string, role: AuthRole): Promise<void> {
await userRequest("/auth/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password, role }) });
export async function createUser(username: string, password: string, roles: AuthRole[], enabled = true): Promise<void> {
await userRequest("/auth/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password, roles, enabled }) });
}
export async function updateUser(username: string, changes: { password?: string; role?: AuthRole }): Promise<void> {
export async function updateUser(username: string, changes: { password?: string; roles?: AuthRole[]; enabled?: boolean }): Promise<void> {
await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(changes) });
}
export async function changeOwnPassword(currentPassword: string, newPassword: string): Promise<void> {
await userRequest("/auth/account/password", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
});
}
export async function deleteUser(username: string): Promise<void> {
await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "DELETE" });
}
@@ -55,7 +55,21 @@ export type DecoderConfig = { aprs_decode_enabled: boolean, hf_aprs_decode_enabl
export type WfmDenoiseLevel = "off" | "auto" | "low" | "medium" | "high";
export type RigFilterState = { bandwidth_hz: number, cw_center_hz: number, sdr_gain_db?: number | null, sdr_lna_gain_db?: number | null, sdr_agc_enabled?: boolean | null, sdr_squelch_enabled?: boolean | null, sdr_squelch_threshold_db?: number | null, sdr_nb_enabled?: boolean | null, sdr_nb_threshold?: number | null, wfm_deemphasis_us: number, wfm_stereo: boolean, wfm_stereo_detected: boolean, wfm_denoise: WfmDenoiseLevel,
export type DigSidebandPolicy = "auto" | "usb" | "lsb";
export type NoiseBlankerProfile = "spike" | "ignition" | "powerline" | "broadband";
export type RigFilterState = { bandwidth_hz: number, cw_center_hz: number, sdr_gain_db?: number | null, sdr_lna_gain_db?: number | null, sdr_agc_enabled?: boolean | null, sdr_squelch_enabled?: boolean | null, sdr_squelch_threshold_db?: number | null, sdr_nb_enabled?: boolean | null, sdr_nb_threshold?: number | null,
/**
* Current noise-blanker tuning profile (SDR backends only). Surfaces in the
* UI as the advanced-controls "NB profile" selector.
*/
sdr_nb_profile?: NoiseBlankerProfile | null,
/**
* Current DIG sideband policy (SDR backends only). Surfaces in the UI as
* the advanced-controls "DIG sideband" selector.
*/
sdr_dig_sideband?: DigSidebandPolicy | null, wfm_deemphasis_us: number, wfm_stereo: boolean, wfm_stereo_detected: boolean, wfm_denoise: WfmDenoiseLevel,
/**
* Co-Channel Interference level (0100 scale).
*/
@@ -123,6 +137,8 @@ export type RigListResponse = { active_remote: string | null, rigs: Array<RigLis
export type FrontendMeta = { clients: number, rigctl_clients: number, audio_clients: number, rigctl_addr: string | null, active_remote: string | null, remotes: Array<string>, owner_callsign: string | null, owner_website_url: string | null, owner_website_name: string | null, ais_vessel_url_base: string | null, show_sdr_gain_control: boolean, initial_map_zoom: number, spectrum_coverage_margin_hz: number, spectrum_usable_span_ratio: number, bandplan_enabled: boolean, bandplan_region: string, decode_history_retention_min: bigint, server_connected: boolean, };
export type AuthRole = "guest" | "read" | "control" | "transmit" | "write" | "administrator";
export type DecoderActivation = "mode_bound" | "toggle";
export type DecoderDescriptor = {
@@ -25,6 +25,13 @@ import {
createUser,
updateUser,
deleteUser,
changeOwnPassword,
AUTH_ADMIN_ROLES,
AUTH_ROLES,
AUTH_ROLE_LABELS,
hasAccountControls,
hasAuthRole as rolesInclude,
normalizeAuthRoles,
} from "./api/auth.js";
import {
formatByteSize as recorderFormatSize,
@@ -193,8 +200,8 @@ interface TrxModules {
reverseGeocodeLocation(lat: number, lon: number, grid: string): void;
bandForHz(frequencyHz: number): unknown;
};
scheduler?: { initialize(rigId: string | null, role: AuthRole | null): void; setRig(rigId: string | null): void; wireEvents(): void };
backgroundDecode?: { initialize(rigId: string | null, role: AuthRole | null): void; setRig(rigId: string | null): void; wireEvents(): void };
scheduler?: { initialize(rigId: string | null, roles: readonly AuthRole[]): void; setRig(rigId: string | null): void; wireEvents(): void };
backgroundDecode?: { initialize(rigId: string | null, roles: readonly AuthRole[]): void; setRig(rigId: string | null): void; wireEvents(): void };
bookmarks?: {
readonly overlayList: readonly Bookmark[];
readonly overlayRevision: number;
@@ -230,7 +237,7 @@ interface TrxState {
readonly initialMapZoom: number;
readonly decodeHistoryRetentionMin: number;
readonly authEnabled: boolean;
readonly authRole: AuthRole | null;
readonly authRoles: readonly AuthRole[];
readonly decoderRegistry: typeof decoderRegistry;
readonly sseSessionId: string | null;
readonly primaryRds: RdsData | null;
@@ -410,10 +417,47 @@ declare global {
void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
// --- Authentication ---
let authRole: AuthRole | null = null;
let authRoles: AuthRole[] = [];
let authUsername: string | null = null;
let authEnabled = true;
function setAuthRoles(roles: readonly AuthRole[]) {
authRoles = normalizeAuthRoles(roles);
}
function hasAuthRole(role: AuthRole) {
return rolesInclude(authRoles, role);
}
function buildRoleChoices(selected: readonly AuthRole[]) {
const element = document.createElement("span");
element.className = "auth-role-choices";
const inputs = AUTH_ROLES.map((value) => {
const label = document.createElement("label");
label.className = "auth-role-choice";
const input = document.createElement("input");
input.type = "checkbox";
input.value = value;
input.checked = selected.includes(value);
label.append(input, ` ${AUTH_ROLE_LABELS[value]}`);
element.append(label);
return { input, value };
});
inputs.forEach(({ input, value }) => {
input.addEventListener("change", () => {
if (!input.checked) return;
if (value === "guest") {
const administrator = inputs.find(choice => choice.value === "administrator");
if (administrator) administrator.input.checked = false;
} else if (value === "administrator") {
const guest = inputs.find(choice => choice.value === "guest");
if (guest) guest.input.checked = false;
}
});
});
return { element, inputs };
}
async function checkAuthStatus() {
return fetchAuthSession();
}
@@ -425,7 +469,7 @@ async function authLogin(username: string, password: string) {
async function authLogout() {
try {
await logout();
authRole = null;
setAuthRoles([]);
authUsername = null;
// Disconnect and show auth gate without page reload
disconnect();
@@ -503,22 +547,32 @@ function updateAuthUI() {
const badge = document.getElementById("auth-badge");
const badgeRole = document.getElementById("auth-role-badge");
const headerAuthBtn = document.getElementById("header-auth-btn");
const accountTab = document.getElementById("settings-account-tab");
if (!authEnabled) {
if (badge) badge.style.display = "none";
if (headerAuthBtn) headerAuthBtn.style.display = "none";
if (accountTab) accountTab.style.display = "none";
syncTopBarAccess();
return;
}
if (authRole) {
if (authRoles.length > 0) {
const canManageAccount = hasAccountControls(authRoles);
if (accountTab) accountTab.style.display = canManageAccount ? "" : "none";
if (!canManageAccount && accountTab?.classList.contains("active")) {
const panel = document.getElementById("subtab-settings-account");
if (panel) panel.style.display = "none";
document.querySelector<HTMLButtonElement>('[data-subtab="settings-scheduler"]')?.click();
}
if (badge) badge.style.display = "block";
if (badgeRole) badgeRole.textContent = `${authUsername || "local"}${authRole === "admin" ? "Admin" : "User (read-only)"}`;
if (badgeRole) badgeRole.textContent = `${authUsername || "local"}${authRoles.map(role => AUTH_ROLE_LABELS[role]).join(", ")}`;
if (headerAuthBtn) {
headerAuthBtn.textContent = "Logout";
headerAuthBtn.style.display = "block";
}
} else {
if (accountTab) accountTab.style.display = "none";
if (badge) badge.style.display = "none";
if (headerAuthBtn) {
headerAuthBtn.textContent = "Login";
@@ -529,37 +583,40 @@ function updateAuthUI() {
}
function applyAuthRestrictions() {
if (!authRole) return;
if (authRoles.length === 0) return;
// Disable TX/PTT/frequency/mode/VFO controls for user role
if (authRole === "user") {
if (!hasAuthRole("transmit")) {
const pttBtn = document.getElementById("ptt-btn") as HTMLButtonElement | null;
const txLimitInput = document.getElementById("tx-limit") as HTMLInputElement | null;
const txLimitBtn = document.getElementById("tx-limit-btn") as HTMLButtonElement | null;
const txAudioBtn = document.getElementById("tx-audio-btn") as HTMLButtonElement | null;
const txLimitRow = document.getElementById("tx-limit-row");
if (pttBtn) pttBtn.disabled = true;
if (txAudioBtn) txAudioBtn.disabled = true;
if (txLimitBtn) txLimitBtn.disabled = true;
if (txLimitInput) txLimitInput.disabled = true;
if (txLimitRow) txLimitRow.style.opacity = "0.5";
}
// General tuning and receive-side controls require Control.
if (!hasAuthRole("control")) {
const powerBtn = document.getElementById("power-btn") as HTMLButtonElement | null;
const lockBtn = document.getElementById("lock-btn") as HTMLButtonElement | null;
const freqInput = document.getElementById("freq") as HTMLInputElement | null;
const centerFreqInput = document.getElementById("center-freq") as HTMLInputElement | null;
const modeSelect = document.getElementById("mode") as HTMLSelectElement | null;
const txLimitInput = document.getElementById("tx-limit") as HTMLInputElement | null;
const txLimitBtn = document.getElementById("tx-limit-btn") as HTMLButtonElement | null;
const txAudioBtn = document.getElementById("tx-audio-btn") as HTMLButtonElement | null;
const txLimitRow = document.getElementById("tx-limit-row");
const jogUp = document.getElementById("jog-up") as HTMLButtonElement | null;
const jogDown = document.getElementById("jog-down") as HTMLButtonElement | null;
const jogButtons = document.querySelectorAll<HTMLButtonElement>(".jog-step button");
const vfoButtons = document.querySelectorAll<HTMLButtonElement>("#vfo-picker button");
// Disable TX buttons
if (pttBtn) pttBtn.disabled = true;
if (powerBtn) powerBtn.disabled = true;
if (lockBtn) lockBtn.disabled = true;
if (txAudioBtn) txAudioBtn.disabled = true;
if (txLimitBtn) txLimitBtn.disabled = true;
// Disable frequency/mode inputs
if (freqInput) freqInput.disabled = true;
if (centerFreqInput) centerFreqInput.disabled = true;
if (modeSelect) modeSelect.disabled = true;
if (txLimitInput) txLimitInput.disabled = true;
// Disable VFO selector
vfoButtons.forEach(btn => btn.disabled = true);
@@ -603,9 +660,6 @@ function applyAuthRestrictions() {
btn.disabled = true;
}
});
// Hide TX-specific UI but keep controls visible (disabled)
if (txLimitRow) txLimitRow.style.opacity = "0.5";
}
}
@@ -864,14 +918,15 @@ window.applyDecodeHistoryRetention = function() {
};
function syncTopBarAccess() {
const loggedOut = authEnabled && !authRole;
const loggedOut = authEnabled && authRoles.length === 0;
const tabBar = document.getElementById("tab-bar");
const rigSwitch = document.querySelector<HTMLElement>(".header-rig-switch");
if (tabBar) tabBar.style.display = "";
document.querySelectorAll<HTMLButtonElement>(".tab-bar .tab").forEach((btn) => {
const isMain = btn.dataset.tab === "main";
btn.style.display = !loggedOut || isMain ? "" : "none";
const lacksLogbookAccess = authEnabled && btn.dataset.tab === "logbook" && !hasAuthRole("write");
btn.style.display = (!loggedOut || isMain) && !lacksLogbookAccess ? "" : "none";
btn.disabled = false;
});
@@ -880,7 +935,7 @@ function syncTopBarAccess() {
}
if (headerRigSwitchSelect) {
headerRigSwitchSelect.disabled = loggedOut || authRole === "user" || lastRigIds.length === 0;
headerRigSwitchSelect.disabled = loggedOut || !hasAuthRole("control") || lastRigIds.length === 0;
}
}
@@ -1461,7 +1516,7 @@ function applyRigList(activeRigId: string | null, rigIds: string[], displayNames
}
const nextKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || "");
const rigListChanged = prevKey !== nextKey;
const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "user";
const disableSwitch = lastRigIds.length === 0 || !hasAuthRole("control");
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
updateRigSubtitle(lastActiveRigId);
window.trxUi?.setActiveRig(lastActiveRigId);
@@ -2395,10 +2450,26 @@ function visibleBandwidthSpecs(freqHz: number | null = lastFreqHz, mode = modeEl
return [{ centerHz: freqHz, widthHz: currentBandwidthHz }];
}
// Resolve the DIG passband direction from the current policy and dial
// frequency, mirroring the backend's demodulator resolution so the spectrum
// overlay lands on the same sideband that is actually being demodulated.
function digSidebandDirection() {
if (sdrDigSidebandPolicy === "usb") return 1;
if (sdrDigSidebandPolicy === "lsb") return -1;
// "auto": USB at/above the threshold, LSB below.
if (typeof lastFreqHz === "number" && isFiniteNumber(lastFreqHz)) {
return lastFreqHz >= DIG_AUTO_SIDEBAND_THRESHOLD_HZ ? 1 : -1;
}
return 1;
}
function sidebandDirectionForMode(mode = modeEl ? modeEl.value : "") {
const modeUpper = String(mode || "").toUpperCase();
if (modeUpper === "LSB" || modeUpper === "CWR") return -1;
if (modeUpper === "USB" || modeUpper === "CW" || modeUpper === "DIG") return 1;
// On SDR the DIG sideband is configurable; resolve it. On other backends
// keep the historical upper-sideband overlay.
if (modeUpper === "DIG") return sdrDigSidebandSupported ? digSidebandDirection() : 1;
if (modeUpper === "USB" || modeUpper === "CW") return 1;
return 0;
}
@@ -3026,9 +3097,16 @@ function formatSignal(sUnits: number) {
}
function setDisabled(disabled: boolean) {
[freqEl, centerFreqEl, modeEl, pttBtn, powerBtn, txLimitInput, txLimitBtn, lockBtn].forEach((el) => {
if (el) el.disabled = disabled;
const controlDisabled = disabled || (authEnabled && !hasAuthRole("control"));
const transmitDisabled = disabled || (authEnabled && !hasAuthRole("transmit"));
[freqEl, centerFreqEl, modeEl, powerBtn, lockBtn].forEach((el) => {
if (el) el.disabled = controlDisabled;
});
[pttBtn, txLimitInput, txLimitBtn].forEach((el) => {
if (el) el.disabled = transmitDisabled;
});
const transmitAudio = document.getElementById("tx-audio-btn") as HTMLButtonElement | null;
if (transmitAudio) transmitAudio.disabled = transmitDisabled || !hasWebCodecs;
syncModePicker();
}
@@ -3349,7 +3427,7 @@ function scheduleTuneLinkSync() {
async function applyTuneLink(link: TuneLink) {
const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null;
if (!wanted) return;
if (authRole === "user") {
if (!hasAuthRole("control")) {
showHint("Read-only session — link not applied", 2500);
return;
}
@@ -3618,6 +3696,26 @@ function render(update: AppUpdate) {
sdrNbThresholdEl.value = String(Math.round(update.filter.sdr_nb_threshold));
}
}
if (typeof update.filter.sdr_nb_profile === "string") {
sdrNbProfile = update.filter.sdr_nb_profile;
if (sdrNbProfileWrapEl) sdrNbProfileWrapEl.style.display = "";
if (sdrNbProfileEl && document.activeElement !== sdrNbProfileEl) {
sdrNbProfileEl.value = update.filter.sdr_nb_profile;
}
}
}
if (typeof update.filter.sdr_dig_sideband === "string") {
sdrDigSidebandSupported = true;
const prevPolicy = sdrDigSidebandPolicy;
sdrDigSidebandPolicy = update.filter.sdr_dig_sideband;
if (sdrDigSidebandEl && document.activeElement !== sdrDigSidebandEl) {
sdrDigSidebandEl.value = update.filter.sdr_dig_sideband;
}
updateWfmControls();
// Repaint the passband overlay if the DIG sideband just changed.
if (prevPolicy !== sdrDigSidebandPolicy && lastSpectrumData) {
scheduleSpectrumDraw();
}
}
}
if (typeof update.show_sdr_gain_control === "boolean") {
@@ -4159,7 +4257,7 @@ async function postPath(path: string, options: PostOptions = {}) {
const resp = await fetch(path, { method: "POST" });
if (authEnabled && resp.status === 401) {
// Not authenticated - return to login
authRole = null;
setAuthRoles([]);
if (es) es.close();
showAuthGate();
throw new Error("Authentication required");
@@ -4188,8 +4286,8 @@ async function switchRigFromSelect(selectEl: HTMLSelectElement) {
showHint("No rig selected", 1500);
return;
}
if (authRole === "user") {
showHint("Admin role required", 1500);
if (!hasAuthRole("control")) {
showHint("Control role required", 1500);
return;
}
if (!lastRigIds.includes(selectEl.value)) {
@@ -4871,10 +4969,15 @@ function navigateToTab(name: TabName, options: { updateHistory?: boolean; replac
window.trxUi?.closeMobileOverlays?.();
const leavingSatellites = _activeTab === "satellites" && name !== "satellites";
const { updateHistory = true, replaceHistory = false } = options;
if (authEnabled && !authRole && name !== "main") {
if (authEnabled && authRoles.length === 0 && name !== "main") {
showAuthGate();
return;
}
if (authEnabled && name === "logbook" && !hasAuthRole("write")) {
showHint("Write role required for logbook access", 2500);
navigateToTab("main", options);
return;
}
const btn = document.querySelector<HTMLElement>(`.tab-bar .tab[data-tab="${name}"]`);
if (!btn) return;
_activeTab = name;
@@ -5010,7 +5113,7 @@ async function initializeApp() {
authEnabled = !authStatus.auth_disabled;
if (!authEnabled) {
authRole = "admin";
setAuthRoles(AUTH_ADMIN_ROLES);
hideAuthGate();
updateAuthUI();
connect();
@@ -5023,7 +5126,7 @@ async function initializeApp() {
if (authStatus.authenticated) {
// User has valid session
authRole = authStatus.role ?? null;
setAuthRoles(authStatus.roles);
authUsername = authStatus.username ?? null;
hideAuthGate();
updateAuthUI();
@@ -5044,10 +5147,10 @@ let settingsUiReady = false;
function initSettingsUI() {
settingsUiReady = true;
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRole);
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRoles);
window.trx.modules.scheduler?.wireEvents();
if (window.trx.modules.backgroundDecode) {
window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRole);
window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRoles);
window.trx.modules.backgroundDecode.wireEvents();
}
void refreshUserManagement();
@@ -5055,12 +5158,22 @@ function initSettingsUI() {
async function refreshUserManagement() {
const section = document.getElementById("user-management");
if (!section) return;
section.style.display = authEnabled && authRole === "admin" ? "block" : "none";
if (section.style.display === "none") return;
const tab = document.getElementById("settings-users-tab");
if (!section || !tab) return;
const canManageUsers = authEnabled && hasAuthRole("administrator");
tab.style.display = canManageUsers ? "" : "none";
if (!canManageUsers) {
const panel = document.getElementById("subtab-settings-users");
if (panel) panel.style.display = "none";
if (tab.classList.contains("active")) {
document.querySelector<HTMLButtonElement>('[data-subtab="settings-scheduler"]')?.click();
}
return;
}
const list = requiredElement("user-list");
try {
const users = await listUsers();
const enabledAdminCount = users.filter(user => user.enabled && rolesInclude(user.roles, "administrator")).length;
list.replaceChildren(...users.map((user) => {
const row = document.createElement("div");
row.className = "sch-row";
@@ -5068,27 +5181,61 @@ async function refreshUserManagement() {
const name = document.createElement("strong");
name.textContent = user.username;
name.style.minWidth = "10rem";
const role = document.createElement("select");
role.className = "auth-input";
for (const value of ["user", "admin"] as AuthRole[]) {
const option = document.createElement("option"); option.value = value; option.textContent = value === "admin" ? "Admin" : "User"; option.selected = user.role === value; role.append(option);
if (!user.enabled) name.textContent += " (disabled)";
const { element: roles, inputs: roleInputs } = buildRoleChoices(user.roles);
const enabledLabel = document.createElement("label");
enabledLabel.className = "auth-role-choice";
const enabled = document.createElement("input");
enabled.type = "checkbox";
enabled.checked = user.enabled;
enabled.disabled = user.username === authUsername;
if (enabled.disabled) enabled.title = "You cannot disable your current account";
enabledLabel.append(enabled, " Enabled");
const isOnlyAdmin = user.enabled
&& rolesInclude(user.roles, "administrator")
&& enabledAdminCount === 1;
const administratorInput = roleInputs.find(item => item.value === "administrator")?.input;
const guestInput = roleInputs.find(item => item.value === "guest")?.input;
if (isOnlyAdmin && administratorInput) {
administratorInput.disabled = true;
administratorInput.title = "The final administrator cannot be demoted";
}
if (isOnlyAdmin && guestInput) {
guestInput.disabled = true;
guestInput.title = "The final administrator cannot become a Guest";
}
if (isOnlyAdmin) {
enabled.disabled = true;
enabled.title = "The final enabled administrator cannot be disabled";
}
const password = document.createElement("input");
password.type = "password"; password.placeholder = "New password"; password.autocomplete = "new-password"; password.className = "auth-input"; password.minLength = 8;
password.type = "password"; password.placeholder = "New password (8+ characters)"; password.autocomplete = "new-password"; password.className = "auth-input"; password.minLength = 8; password.maxLength = 1024;
const syncPasswordAccess = () => {
const guestActive = guestInput?.checked === true;
password.disabled = guestActive;
password.title = guestActive ? "Password changes are unavailable while the Guest role is active" : "";
if (guestActive) password.value = "";
};
roleInputs.forEach(({ input }) => input.addEventListener("change", syncPasswordAccess));
syncPasswordAccess();
const save = document.createElement("button"); save.type = "button"; save.textContent = "Save";
save.addEventListener("click", async () => {
const changes: { role?: AuthRole; password?: string } = { role: role.value as AuthRole };
const changes: { roles?: AuthRole[]; password?: string; enabled?: boolean } = {
roles: roleInputs.filter(({ input }) => input.checked).map(({ value }) => value),
enabled: enabled.checked,
};
if (password.value) changes.password = password.value;
await runUserOperation(() => updateUser(user.username, changes));
});
const remove = document.createElement("button"); remove.type = "button"; remove.textContent = "Remove"; remove.className = "danger";
remove.disabled = user.username === authUsername;
remove.disabled = user.username === authUsername || isOnlyAdmin;
if (isOnlyAdmin) remove.title = "The final administrator cannot be removed";
remove.addEventListener("click", async () => {
if (await window.trxUi.confirm({ title: "Remove user?", message: `Remove ${user.username} and revoke their sessions?`, confirmLabel: "Remove", danger: true })) {
await runUserOperation(() => deleteUser(user.username));
}
});
row.append(name, role, password, save, remove);
row.append(name, enabledLabel, roles, password, save, remove);
return row;
}));
} catch (error) {
@@ -5114,17 +5261,57 @@ async function runUserOperation(operation: () => Promise<void>) {
}
}
const createRoleContainer = document.getElementById("user-create-roles");
if (createRoleContainer) {
const { element } = buildRoleChoices(["read"]);
element.id = createRoleContainer.id;
createRoleContainer.replaceWith(element);
}
document.getElementById("user-create-form")?.addEventListener("submit", (event) => {
event.preventDefault();
const username = requiredElement<HTMLInputElement>("user-create-username");
const password = requiredElement<HTMLInputElement>("user-create-password");
const role = requiredElement<HTMLSelectElement>("user-create-role");
const enabled = requiredElement<HTMLInputElement>("user-create-enabled");
const roles = Array.from(document.querySelectorAll<HTMLInputElement>("#user-create-roles input[type=checkbox]"));
void runUserOperation(async () => {
await createUser(username.value, password.value, role.value as AuthRole);
username.value = ""; password.value = ""; role.value = "user";
await createUser(username.value, password.value, roles.filter(input => input.checked).map(input => input.value as AuthRole), enabled.checked);
username.value = ""; password.value = "";
enabled.checked = true;
roles.forEach(input => { input.checked = input.value === "read"; });
});
});
document.getElementById("account-password-form")?.addEventListener("submit", (event) => {
event.preventDefault();
const form = event.currentTarget as HTMLFormElement;
const currentPassword = requiredElement<HTMLInputElement>("account-current-password");
const newPassword = requiredElement<HTMLInputElement>("account-new-password");
const confirmPassword = requiredElement<HTMLInputElement>("account-confirm-password");
const error = requiredElement("account-password-error");
const submit = form.querySelector<HTMLButtonElement>('button[type="submit"]');
if (newPassword.value !== confirmPassword.value) {
error.textContent = "New passwords do not match";
error.style.display = "block";
return;
}
if (submit) submit.disabled = true;
void changeOwnPassword(currentPassword.value, newPassword.value)
.then(async () => {
form.reset();
error.style.display = "none";
await authLogout();
showHint("Password changed. Sign in again.", 3000);
})
.catch((reason: unknown) => {
error.textContent = reason instanceof Error ? reason.message : String(reason);
error.style.display = "block";
})
.finally(() => {
if (submit) submit.disabled = false;
});
});
// Setup auth form
requiredElement<HTMLFormElement>("auth-form").addEventListener("submit", async (e) => {
e.preventDefault();
@@ -5137,7 +5324,7 @@ requiredElement<HTMLFormElement>("auth-form").addEventListener("submit", async (
try {
const result = await authLogin(usernameEl.value, passwordEl.value);
authRole = result.role ?? null;
setAuthRoles(result.roles);
authUsername = result.username ?? usernameEl.value;
passwordEl.value = "";
hideAuthGate();
@@ -5161,7 +5348,7 @@ requiredElement<HTMLFormElement>("auth-form").addEventListener("submit", async (
const headerAuthBtn = document.getElementById("header-auth-btn") as HTMLButtonElement | null;
if (headerAuthBtn) {
headerAuthBtn.addEventListener("click", async () => {
if (authRole) {
if (authRoles.length > 0) {
// Logged in - show logout confirmation
if (await window.trxUi.confirm({ title: "Log out?", message: "Audio and control access for this browser session will end.", confirmLabel: "Log out", danger: false })) {
await authLogout();
@@ -5190,7 +5377,7 @@ Object.defineProperties(trxState, {
initialMapZoom: { get() { return initialMapZoom; } },
decodeHistoryRetentionMin: { get() { return decodeHistoryRetentionMin; } },
authEnabled: { get() { return authEnabled; } },
authRole: { get() { return authRole; } },
authRoles: { get() { return authRoles; } },
decoderRegistry: { get() { return decoderRegistry; } },
sseSessionId: { get() { return sseSessionId; } },
primaryRds: { get() { return primaryRds; } },
@@ -5459,7 +5646,20 @@ const sdrNbEnabledEl = document.getElementById("sdr-nb-enabled") as HTMLInputEle
const sdrNbThresholdControlsEl = document.getElementById("sdr-nb-threshold-controls");
const sdrNbThresholdEl = document.getElementById("sdr-nb-threshold") as HTMLInputElement | null;
const sdrNbThresholdSetBtn = document.getElementById("sdr-nb-threshold-set") as HTMLButtonElement | null;
const sdrNbProfileWrapEl = document.getElementById("sdr-nb-profile-wrap");
const sdrNbProfileEl = document.getElementById("sdr-nb-profile") as HTMLSelectElement | null;
let sdrNbSupported = false;
// Current NB profile, mirrored from server filter state; sent alongside every
// enable/threshold change (including the quick toggle) so it is preserved.
let sdrNbProfile = "spike";
const sdrDigSidebandWrapEl = document.getElementById("sdr-dig-sideband-wrap");
const sdrDigSidebandEl = document.getElementById("sdr-dig-sideband") as HTMLSelectElement | null;
let sdrDigSidebandSupported = false;
// Current DIG sideband policy ("auto" | "usb" | "lsb"), mirrored from server
// filter state. Drives the spectrum passband overlay direction for DIG.
let sdrDigSidebandPolicy = "auto";
// Matches DigSidebandPolicy::AUTO_THRESHOLD_HZ on the backend.
const DIG_AUTO_SIDEBAND_THRESHOLD_HZ = 10_000_000;
// Hide audio row if audio is not configured on the server
fetch("/audio", { method: "GET" }).then((r) => {
@@ -5886,7 +6086,7 @@ function submitSdrNbState() {
const threshold = sdrNbThresholdEl ? Number.parseFloat(sdrNbThresholdEl.value) : 10;
if (!isFiniteNumber(threshold) || threshold < 1 || threshold > 100) return;
postPath(
`/set_sdr_noise_blanker?enabled=${enabled ? "true" : "false"}&threshold=${encodeURIComponent(threshold)}`,
`/set_sdr_noise_blanker?enabled=${enabled ? "true" : "false"}&threshold=${encodeURIComponent(threshold)}&profile=${encodeURIComponent(sdrNbProfile)}`,
).catch(() => {});
}
if (sdrNbEnabledEl) {
@@ -5894,6 +6094,21 @@ if (sdrNbEnabledEl) {
submitSdrNbState();
});
}
if (sdrNbProfileEl) {
sdrNbProfileEl.addEventListener("change", () => {
const profile = sdrNbProfileEl.value || "spike";
if (
profile !== "spike" &&
profile !== "ignition" &&
profile !== "powerline" &&
profile !== "broadband"
) {
return;
}
sdrNbProfile = profile;
submitSdrNbState();
});
}
function submitSdrNbThreshold() {
if (!sdrNbThresholdEl) return;
const parsed = Number.parseFloat(sdrNbThresholdEl.value);
@@ -5911,6 +6126,21 @@ if (sdrNbThresholdEl) {
}
});
}
function submitSdrDigSideband() {
if (!sdrDigSidebandSupported || !sdrDigSidebandEl) return;
const policy = sdrDigSidebandEl.value || "auto";
if (policy !== "auto" && policy !== "usb" && policy !== "lsb") return;
// Optimistically move the passband overlay to the chosen sideband so the
// display responds instantly, before the server round-trip confirms it.
sdrDigSidebandPolicy = policy;
if (lastSpectrumData) scheduleSpectrumDraw();
postPath(`/set_sdr_dig_sideband?policy=${encodeURIComponent(policy)}`).catch(() => {});
}
if (sdrDigSidebandEl) {
sdrDigSidebandEl.addEventListener("change", () => {
submitSdrDigSideband();
});
}
function updateWfmControls() {
const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
if (wfmControlsCol) wfmControlsCol.style.display = mode === "WFM" ? "" : "none";
@@ -5918,6 +6148,11 @@ function updateWfmControls() {
// The row holds only these two, so it goes with them — an empty one would
// still take a track and a gap in the tray, and draw its divider.
if (modeControlsRow) modeControlsRow.style.display = (mode === "WFM" || mode === "SAM") ? "" : "none";
// DIG sideband selector is only meaningful in DIG mode on an SDR backend.
if (sdrDigSidebandWrapEl) {
sdrDigSidebandWrapEl.style.display =
sdrDigSidebandSupported && mode === "DIG" ? "" : "none";
}
}
// Show compatibility warning for non-Chromium browsers
@@ -6235,6 +6470,10 @@ function stopRxAudio() {
function startTxAudio() {
if (txActive) { void stopTxAudio(); return; }
if (authEnabled && !hasAuthRole("transmit")) {
audioStatus.textContent = "Transmit role required";
return;
}
if (!hasWebCodecs) {
audioStatus.textContent = "Audio requires Chrome/Edge";
return;
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import { hostState } from "./host.js";
import { hasAuthRole, type AuthRole } from "../api/auth.js";
export {};
@@ -44,7 +45,7 @@ interface BackgroundBridge {
trx?: { modules?: { backgroundDecode?: BackgroundDecodeService } };
}
interface BackgroundDecodeService {
initialize(rigId: string | null, role: string | null): void;
initialize(rigId: string | null, roles: readonly AuthRole[]): void;
wireEvents(): void;
setRig(rigId: string | null): void;
}
@@ -60,7 +61,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
.map(function (d) { return d.id; });
}
let backgroundDecodeRole: string | null = null;
let backgroundDecodeRoles: readonly AuthRole[] = [];
let currentRigId: string | null = null;
let currentConfig: BackgroundDecodeConfig | null = null;
let bookmarkList: Bookmark[] = [];
@@ -70,8 +71,8 @@ const bgdWindow = window as unknown as BackgroundBridge;
let statusByBookmark = new Map<string, BackgroundStatusEntry>();
let lastStatus: BackgroundDecodeStatus | null = null;
function initBackgroundDecode(rigId: string | null, role: string | null): void {
backgroundDecodeRole = role;
function initBackgroundDecode(rigId: string | null, roles: readonly AuthRole[]): void {
backgroundDecodeRoles = roles;
// The panel used to take whatever rig it was handed at load and wait to be
// told again. Loading before the rig list arrives handed it null, and the
// next telling only came when the operator switched rigs, so the panel sat
@@ -468,7 +469,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
}
function isControlRole(): boolean {
return backgroundDecodeRole === "admin" || hostState.authEnabled === false;
return hasAuthRole(backgroundDecodeRoles, "control") || hostState.authEnabled === false;
}
function showToast(msg: string, isError: boolean): void {
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import { hostCore, hostState } from "./host.js";
import { hasAuthRole } from "../api/auth.js";
export {};
@@ -18,6 +19,8 @@ interface Bookmark {
category?: string | null;
comment?: string | null;
decoders?: string[];
/** DIG sideband override: "auto" | "usb" | "lsb". Empty/absent = global. */
dig_sideband?: string | null;
scope?: string;
}
@@ -100,7 +103,8 @@ function bmEsc(str: unknown): string {
}
function bmCanControl() {
return !hostState.authEnabled || hostState.authRole === "admin";
return !hostState.authEnabled
|| hasAuthRole(hostState.authRoles, "write");
}
// Show/hide the Add Bookmark / Select All buttons based on the current auth role.
@@ -319,6 +323,14 @@ function bmBuildDecoderCheckboxes() {
});
}
/** Show the DIG-sideband override selector only when the mode field is DIG. */
function bmSyncDigSidebandVisibility(): void {
const label = bmOptionalEl("bm-dig-sideband-label");
if (!label) return;
const mode = (bmEl("bm-mode").value || "").trim().toUpperCase();
label.style.display = mode === "DIG" ? "" : "none";
}
function bmOpenForm(bm: Bookmark | null): void {
const wrap = bmEl("bm-form-wrap");
if (!wrap) return;
@@ -336,7 +348,9 @@ function bmOpenForm(bm: Bookmark | null): void {
bmEl("bm-locator").value = bm ? (bm.locator || "") : "";
bmEl("bm-category-input").value = bm ? (bm.category || "") : "";
bmEl("bm-comment").value = bm ? (bm.comment || "") : "";
bmEl("bm-dig-sideband").value = bm ? (bm.dig_sideband || "") : "";
bmWriteDecoders(bm?.decoders ?? []);
bmSyncDigSidebandVisibility();
bmEl("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark";
wrap.style.display = "flex";
@@ -381,6 +395,9 @@ async function bmSave(e: Event): Promise<void> {
const category = bmEl("bm-category-input").value.trim();
const comment = bmEl("bm-comment").value.trim();
const decoders = bmReadDecoders();
// Only meaningful for DIG; stored empty otherwise so it never overrides.
const dig_sideband =
mode.toUpperCase() === "DIG" ? (bmEl("bm-dig-sideband").value || null) : null;
const formError = bmEl("bm-form-error");
if (formError) formError.textContent = "";
@@ -401,6 +418,7 @@ async function bmSave(e: Event): Promise<void> {
category,
comment,
decoders,
dig_sideband,
};
try {
@@ -475,6 +493,16 @@ function bmApply(bm: Bookmark): void {
const tunePromise = (async () => {
await bridge.trx.modules.vchan?.takeSchedulerControl();
// For a DIG bookmark carrying a sideband override, set the global DIG
// policy first so the backend resolves the intended sideband when DIG is
// applied. "auto" is honoured explicitly; an empty override is left alone.
if ((bm.mode || "").toUpperCase() === "DIG" && bm.dig_sideband) {
const p = bm.dig_sideband.toLowerCase();
if (p === "auto" || p === "usb" || p === "lsb") {
await hostCore.postPath("/set_sdr_dig_sideband?policy=" + encodeURIComponent(p));
}
}
const onVirtual = await bridge.trx.modules.vchan?.interceptMode(bm.mode) ?? false;
if (!onVirtual) {
await hostCore.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
@@ -768,6 +796,9 @@ function bmPopulateScopePicker() {
// Form submit
bmEl("bm-form").addEventListener("submit", (event) => { void bmSave(event); });
// Reveal the DIG-sideband override only while the mode field reads DIG.
bmEl("bm-mode").addEventListener("input", bmSyncDigSidebandVisibility);
// Form cancel
bmEl("bm-form-cancel").addEventListener("click", bmCloseForm);
@@ -11,6 +11,8 @@
// feature bundles from re-deriving it — and from drifting back to bare `window`
// properties, which the module graph no longer publishes.
import type { AuthRole } from "../api/auth.js";
export interface HostDecoderDescriptor {
id: string;
label: string;
@@ -25,7 +27,7 @@ export interface HostState {
/** The callsign this station is on the air with, from the client config. */
readonly ownerCallsign: string | null;
readonly authEnabled: boolean;
readonly authRole: string | null;
readonly authRoles: readonly AuthRole[];
readonly lastActiveRigId: string | null;
readonly lastRigIds: string[];
readonly lastRigDisplayNames: Record<string, string>;
@@ -10,6 +10,7 @@
// keeping if the times in it are the radio's.
import { hostCore, hostState } from "./host.js";
import { hasAuthRole } from "../api/auth.js";
export {};
@@ -120,6 +121,11 @@ let entryGrid: string | null = null;
let qsos: Qso[] = [];
let workedRequest = 0;
function canWriteLogbook(): boolean {
return !hostState.authEnabled
|| hasAuthRole(hostState.authRoles, "write");
}
function notify(message: string, kind?: string): void {
if (bridge.trxUi.notify) bridge.trxUi.notify(message, kind ? { kind } : undefined);
else hostCore.showHint(message, 2000);
@@ -419,6 +425,11 @@ function renderRows(): void {
row.appendChild(cell);
}
const actions = document.createElement("td");
if (!canWriteLogbook()) {
row.appendChild(actions);
fragment.appendChild(row);
continue;
}
// Confirming is the commonest edit a log gets, so it is a button rather
// than a form: a card arrives, and the contact counts towards an award.
const confirm = document.createElement("button");
@@ -547,15 +558,20 @@ importFile?.addEventListener("change", () => {
});
/** Start an entry from a decode, and show the operator where it went. */
bridge.logContact = (seed) => {
bridge.navigateToTab?.("logbook");
void openEntry(seed).then(() => callInput?.focus());
};
if (canWriteLogbook()) {
bridge.logContact = (seed) => {
bridge.navigateToTab?.("logbook");
void openEntry(seed).then(() => callInput?.focus());
};
} else {
if (form) form.style.display = "none";
if (importBtn) importBtn.style.display = "none";
}
renderStation();
if (cabrilloCallsign && !cabrilloCallsign.value) {
cabrilloCallsign.value = stationCallEl?.textContent?.trim() ?? "";
}
syncCabrilloLink();
void openEntry();
if (canWriteLogbook()) void openEntry();
void refreshLog();
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import type { SatelliteScheduleConfig, SatelliteSchedulerApi } from "./satellite-types.js";
import type { AuthRole } from "../api/auth.js";
export type SchedulerMode = "disabled" | "grayline" | "time_span";
@@ -60,7 +61,7 @@ export interface SchedulerStatus {
}
export interface SchedulerService {
initialize(rigId: string | null, role: string | null): void;
initialize(rigId: string | null, roles: readonly AuthRole[]): void;
destroy(): void;
setRig(rigId: string | null): void;
wireEvents(): void;
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import { hostState } from "./host.js";
import { hasAuthRole, type AuthRole } from "../api/auth.js";
import type {
ScheduleEntry,
@@ -43,7 +44,7 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
// -------------------------------------------------------------------------
// State
// -------------------------------------------------------------------------
let schedulerRole: string | null = null;
let schedulerRoles: readonly AuthRole[] = [];
let currentRigId: string | null = null;
let currentConfig: SchedulerConfig | null = null;
let currentSchedulerStatus: SchedulerStatus | null = null;
@@ -58,8 +59,8 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
// -------------------------------------------------------------------------
// Init
// -------------------------------------------------------------------------
function initScheduler(rigId: string | null, role: string | null): void {
schedulerRole = role;
function initScheduler(rigId: string | null, roles: readonly AuthRole[]): void {
schedulerRoles = roles;
currentRigId = rigId || null;
if (currentRigId) loadScheduler();
startStatusPolling();
@@ -356,7 +357,7 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
if (!prevBtn || !nextBtn) return;
const state = schedulerInterleaveState(currentConfig);
const enabled =
schedulerRole === "admin" &&
hasAuthRole(schedulerRoles, "control") &&
!!currentRigId &&
!schedulerStepPending &&
state.activeEntries.length > 1;
@@ -466,7 +467,7 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
if (!panel) return;
const mode = (currentConfig && currentConfig.mode) || "disabled";
const isControl = schedulerRole === "admin";
const isControl = hasAuthRole(schedulerRoles, "control");
// Mode selector
setSelected("scheduler-mode-select", mode);
@@ -1574,8 +1575,8 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
// When loaded eagerly, initSettingsUI() in app.js calls initScheduler();
// when loaded lazily (e.g. settings tab click after boot), the app has
// already passed that point, so we must self-initialize here.
if (hostState.authRole != null) {
initScheduler(hostState.lastActiveRigId, hostState.authRole);
if (!hostState.authEnabled || hostState.authRoles.length > 0) {
initScheduler(hostState.lastActiveRigId, hostState.authRoles);
wireSchedulerEvents();
}
})();
@@ -0,0 +1,102 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import { chromium } from "playwright-core";
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
/* global document */
const ALL_ROLES = ["read", "control", "transmit", "write", "administrator"];
const fixture = await startWebFixture({
authSession: {
authenticated: true,
username: "admin",
roles: ALL_ROLES,
auth_disabled: false,
},
users: [
{ username: "admin", roles: ALL_ROLES, enabled: true },
{ username: "guest", roles: ["guest"], enabled: true },
{ username: "listener", roles: ["read"], enabled: false },
],
});
const { browser, page, runtimeErrors } = await startBrowser(chromium);
try {
await page.goto(`${fixture.origin}/settings`, { waitUntil: "domcontentloaded" });
await page.locator("#tab-settings").waitFor({ state: "visible" });
await page.locator("#settings-account-tab").waitFor({ state: "visible" });
await page.locator("#settings-users-tab").waitFor({ state: "visible" });
await page.locator("#settings-account-tab").click();
assert.equal(await page.locator("#account-password-form").isVisible(), true);
assert.equal(await page.locator("#subtab-settings-users").isVisible(), false);
await page.locator("#settings-users-tab").click();
await page.locator("#user-list").getByText("listener (disabled)").waitFor();
assert.equal(await page.locator("#user-create-form").isVisible(), true);
const state = await page.evaluate(() => {
const rows = [...document.querySelectorAll("#user-list > .sch-row")];
const rowFor = (username) => rows.find((row) => row.querySelector("strong")?.textContent.startsWith(username));
const admin = rowFor("admin");
const guest = rowFor("guest");
const listener = rowFor("listener");
const role = (row, value) => row?.querySelector(`input[value="${value}"]`);
return {
createRoles: [...document.querySelectorAll("#user-create-roles input")].map((input) => input.value),
adminEnabledLocked: admin?.querySelector('input[type="checkbox"]')?.disabled,
adminRoleLocked: role(admin, "administrator")?.disabled,
adminGuestLocked: role(admin, "guest")?.disabled,
adminRemoveLocked: admin?.querySelector("button.danger")?.disabled,
guestPasswordLocked: guest?.querySelector('input[type="password"]')?.disabled,
listenerEnabled: listener?.querySelector('input[type="checkbox"]')?.checked,
listenerRead: role(listener, "read")?.checked,
};
});
assert.deepEqual(state.createRoles, ["guest", ...ALL_ROLES]);
assert.equal(state.adminEnabledLocked, true);
assert.equal(state.adminRoleLocked, true);
assert.equal(state.adminGuestLocked, true);
assert.equal(state.adminRemoveLocked, true);
assert.equal(state.guestPasswordLocked, true);
assert.equal(state.listenerEnabled, false);
assert.equal(state.listenerRead, true);
const listenerRow = page.locator("#user-list > .sch-row").filter({ hasText: "listener" });
const listenerPassword = listenerRow.locator('input[type="password"]');
await listenerRow.locator('input[value="guest"]').check();
assert.equal(await listenerPassword.isDisabled(), true);
assert.equal(await listenerRow.locator('input[value="read"]').isChecked(), true);
await listenerRow.locator('input[value="control"]').check();
assert.equal(await listenerRow.locator('input[value="guest"]').isChecked(), true);
assert.equal(await listenerPassword.isDisabled(), true);
await listenerRow.locator('input[value="guest"]').uncheck();
assert.equal(await listenerPassword.isDisabled(), false);
assert.deepEqual(runtimeErrors, []);
} finally {
await browser.close();
await fixture.close();
}
const guestFixture = await startWebFixture({
authSession: {
authenticated: true,
username: "guest",
roles: ["guest"],
auth_disabled: false,
},
});
const guestBrowser = await startBrowser(chromium);
try {
await guestBrowser.page.goto(`${guestFixture.origin}/settings`, { waitUntil: "domcontentloaded" });
await guestBrowser.page.locator("#tab-settings").waitFor({ state: "visible" });
assert.equal(await guestBrowser.page.locator("#settings-account-tab").isVisible(), false);
assert.equal(await guestBrowser.page.locator("#settings-users-tab").isVisible(), false);
assert.deepEqual(guestBrowser.runtimeErrors, []);
} finally {
await guestBrowser.browser.close();
await guestFixture.close();
}
@@ -0,0 +1,61 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import test from "node:test";
import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs";
const source = await bundleEntry(new URL("../src/api/auth.ts", import.meta.url), "AuthApi");
function loadAuth(fetch) {
const context = vm.createContext({ fetch, console });
new vm.Script(source).runInContext(context);
return context.AuthApi;
}
test("role policy centralizes Guest and separates Control from Transmit", () => {
const auth = loadAuth(async () => { throw new Error("unused"); });
assert.deepEqual(Array.from(auth.AUTH_ROLES), ["guest", "read", "control", "transmit", "write", "administrator"]);
assert.equal(auth.hasAuthRole(["guest"], "read"), true);
assert.equal(auth.hasAccountControls(["guest"]), false);
assert.equal(auth.hasAccountControls(["read"]), true);
assert.equal(auth.hasAuthRole(["control"], "read"), true);
assert.equal(auth.hasAuthRole(["transmit"], "read"), true);
assert.equal(auth.hasAuthRole(["control"], "transmit"), false);
assert.equal(auth.hasAuthRole(["control"], "write"), false);
assert.equal(auth.hasAuthRole(["administrator"], "write"), true);
});
test("auth responses normalize roles and require the managed-account lifecycle state", async () => {
const replies = new Map([
["/auth/session", { authenticated: true, roles: ["write", "read", "write"], username: "alice" }],
["/auth/users", [{ username: "alice", roles: ["write", "read"], enabled: false }]],
]);
const auth = loadAuth(async (url) => ({
ok: true,
status: 200,
json: async () => replies.get(String(url)),
}));
assert.deepEqual(Array.from((await auth.fetchAuthSession()).roles), ["read", "write"]);
assert.deepEqual(Array.from((await auth.listUsers())[0].roles), ["read", "write"]);
assert.equal((await auth.listUsers())[0].enabled, false);
});
test("changing a password sends current and replacement credentials", async () => {
let request;
const auth = loadAuth(async (url, init) => {
request = { url, init };
return { ok: true, status: 200, json: async () => ({}) };
});
await auth.changeOwnPassword("old-password", "new-password");
assert.equal(request.url, "/auth/account/password");
assert.equal(request.init.method, "PATCH");
assert.deepEqual(JSON.parse(request.init.body), {
current_password: "old-password",
new_password: "new-password",
});
});
@@ -40,7 +40,7 @@ test("background decode loads configuration for the explicitly selected rig", as
const source = await bundleEntry(new URL("../src/plugins/background-decode.ts", import.meta.url));
new vm.Script(source).runInContext(context);
window.trx.modules.backgroundDecode.initialize("rig/a", "admin");
window.trx.modules.backgroundDecode.initialize("rig/a", ["administrator"]);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.ok(requested.includes("/background-decode/rig%2Fa"));
assert.ok(requested.includes("/bookmarks"));
@@ -32,7 +32,7 @@ function hostFixture(overrides = {}) {
const calls = { postPath: [], setRigFrequency: [], armOptimisticFrequency: [], applyLocalTunedFrequency: [], syncBandwidthInput: [], scheduleSpectrumDraw: 0, syncModePicker: 0 };
const state = {
authEnabled: false,
authRole: "admin",
authRoles: ["read", "control", "transmit", "write", "administrator"],
lastActiveRigId: null,
lastRigIds: [],
lastRigDisplayNames: {},
@@ -157,7 +157,7 @@ test("bookmark controls follow the host authentication state", async () => {
if (!elements.has(id)) elements.set(id, new ElementFixture());
return elements.get(id);
};
const { window } = hostFixture({ authEnabled: true, authRole: "user" });
const { window } = hostFixture({ authEnabled: true, authRoles: ["read"] });
const context = vm.createContext({
window,
document: documentFixture(element),
@@ -171,7 +171,7 @@ test("bookmark controls follow the host authentication state", async () => {
assert.equal(element("bm-add-btn").style.display, "none");
window.trx.state.authRole = "admin";
window.trx.state.authRoles = ["read", "write"];
await window.trx.modules.bookmarks.fetch("");
assert.equal(element("bm-add-btn").style.display, "");
});
@@ -19,7 +19,7 @@ export function createHost({ state = {}, core = {}, modules = {} } = {}) {
serverLat: null,
serverLon: null,
authEnabled: false,
authRole: "admin",
authRoles: ["read", "control", "transmit", "write", "administrator"],
lastActiveRigId: null,
lastRigIds: [],
lastRigDisplayNames: {},
@@ -11,7 +11,7 @@ import { bundleEntry } from "./bundle-entry.mjs";
test("scheduler registers a typed module service without lifecycle globals", async () => {
// No role known yet: the entry registers its service and waits for the
// application to drive initialization.
const window = { ...createHost({ state: { authRole: null } }), trxUi: { confirm: async () => true } };
const window = { ...createHost({ state: { authEnabled: true, authRoles: [] } }), trxUi: { confirm: async () => true } };
const context = vm.createContext({
window,
document: {
@@ -76,7 +76,7 @@ test("scheduler self-initializes for the active rig when a role is already known
return elements.get(id);
};
const window = {
...createHost({ state: { authRole: "admin", lastActiveRigId: "sdr" } }),
...createHost({ state: { authRoles: ["administrator"], lastActiveRigId: "sdr" } }),
trxUi: { confirm: async () => true },
};
const context = vm.createContext({
@@ -9,6 +9,7 @@ import { readFile } from "node:fs/promises";
const indexPath = new URL("../../assets/web/index.html", import.meta.url);
const pluginLoaderPath = new URL("../src/plugin-loader.ts", import.meta.url);
const mapCorePath = new URL("../src/map-core.ts", import.meta.url);
const appPath = new URL("../src/app.ts", import.meta.url);
test("index loads one first-party application bootstrap", async () => {
const html = await readFile(indexPath, "utf8");
@@ -30,6 +31,30 @@ test("startup has no remote script or stylesheet dependencies", async () => {
);
});
test("administrator user management is a dedicated Settings sub-tab", async () => {
const [html, app] = await Promise.all([
readFile(indexPath, "utf8"),
readFile(appPath, "utf8"),
]);
assert.match(html, /data-subtab="settings-users"/);
assert.match(html, /id="subtab-settings-users" class="sub-tab-panel"/);
assert.match(app, /authEnabled && hasAuthRole\("administrator"\)/);
assert.match(app, /settings-users-tab/);
});
test("account lifecycle controls include self-service passwords and enable state", async () => {
const [html, app] = await Promise.all([
readFile(indexPath, "utf8"),
readFile(appPath, "utf8"),
]);
assert.match(html, /data-subtab="settings-account"/);
assert.match(html, /id="account-password-form"/);
assert.match(html, /id="user-create-enabled"/);
assert.match(app, /changeOwnPassword/);
assert.match(app, /hasAccountControls\(authRoles\)/);
assert.match(app, /enabledAdminCount/);
});
test("lazy frontend features use modules and local map symbols", async () => {
const [loader, map] = await Promise.all([
readFile(pluginLoaderPath, "utf8"),
@@ -0,0 +1,53 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import { chromium } from "playwright-core";
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
async function controlState(page) {
return {
frequency: await page.locator("#freq").isDisabled(),
ptt: await page.locator("#ptt-btn").isDisabled(),
txAudio: await page.locator("#tx-audio-btn").isDisabled(),
txLimit: await page.locator("#tx-limit-btn").isDisabled(),
};
}
async function inspectRole(roles) {
const fixture = await startWebFixture({
tx: true,
authSession: {
authenticated: true,
username: "operator",
roles,
auth_disabled: false,
},
});
const session = await startBrowser(chromium);
try {
await session.page.goto(`${fixture.origin}/`, { waitUntil: "domcontentloaded" });
await session.page.locator("#content").waitFor({ state: "visible" });
await session.page.waitForTimeout(1200);
assert.deepEqual(session.runtimeErrors, []);
return await controlState(session.page);
} finally {
await session.browser.close();
await fixture.close();
}
}
assert.deepEqual(await inspectRole(["control"]), {
frequency: false,
ptt: true,
txAudio: true,
txLimit: true,
});
assert.deepEqual(await inspectRole(["transmit"]), {
frequency: true,
ptt: false,
txAudio: false,
txLimit: false,
});
@@ -145,6 +145,8 @@ export async function startWebFixture({
bandplanEnabled = false,
bandplanUnauthorizedFirst = false,
satPasses = null,
authSession = { authenticated: true, roles: ["read", "control", "transmit", "write", "administrator"], auth_disabled: true },
users = [],
} = {}) {
const rigItems = ["rig-a", "rig-b"].map((remote) => ({
remote,
@@ -232,7 +234,8 @@ export async function startWebFixture({
};
const jsonRoutes = new Map([
["/auth/session", { authenticated: true, role: "admin", auth_disabled: true }],
["/auth/session", authSession],
["/auth/users", users],
["/decoders", DECODER_REGISTRY],
["/rigs", rigsResponse],
["/status", status],
@@ -9,7 +9,7 @@ use std::sync::Arc;
use actix_web::Error;
use actix_web::{delete, get, post, put, web, HttpRequest, HttpResponse};
use super::{no_cache_response, request_accepts_html, require_control};
use super::{no_cache_response, request_accepts_html, require_write};
use crate::server::status;
// ============================================================================
@@ -37,6 +37,7 @@ pub struct BookmarkInput {
pub comment: Option<String>,
pub category: Option<String>,
pub decoders: Option<Vec<String>>,
pub dig_sideband: Option<String>,
}
/// A bookmark with its owning scope tag for the list response.
@@ -88,6 +89,17 @@ fn normalize_bookmark_locator(locator: Option<String>) -> Option<String> {
})
}
/// Normalize a DIG sideband override to `auto`/`usb`/`lsb`, or `None` when
/// absent or unrecognized (leaves the current policy untouched on apply).
fn normalize_dig_sideband(value: Option<String>) -> Option<String> {
value.and_then(|v| match v.trim().to_lowercase().as_str() {
"auto" => Some("auto".to_string()),
"usb" => Some("usb".to_string()),
"lsb" => Some("lsb".to_string()),
_ => None,
})
}
// ============================================================================
// Endpoints
// ============================================================================
@@ -165,7 +177,7 @@ pub async fn create_bookmark(
body: web::Json<BookmarkInput>,
auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> {
require_control(&req, &auth_state)?;
require_write(&req, &auth_state)?;
let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
if store.freq_taken(body.freq_hz, None) {
return Err(actix_web::error::ErrorConflict(
@@ -182,6 +194,7 @@ pub async fn create_bookmark(
comment: body.comment.clone().unwrap_or_default(),
category: body.category.clone().unwrap_or_default(),
decoders: body.decoders.clone().unwrap_or_default(),
dig_sideband: normalize_dig_sideband(body.dig_sideband.clone()),
};
if store.insert(&bm) {
Ok(HttpResponse::Created().json(bm))
@@ -201,7 +214,7 @@ pub async fn update_bookmark(
body: web::Json<BookmarkInput>,
auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> {
require_control(&req, &auth_state)?;
require_write(&req, &auth_state)?;
let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
let id = path.into_inner();
if store.freq_taken(body.freq_hz, Some(&id)) {
@@ -219,6 +232,7 @@ pub async fn update_bookmark(
comment: body.comment.clone().unwrap_or_default(),
category: body.category.clone().unwrap_or_default(),
decoders: body.decoders.clone().unwrap_or_default(),
dig_sideband: normalize_dig_sideband(body.dig_sideband.clone()),
};
if store.upsert(&id, &bm) {
Ok(HttpResponse::Ok().json(bm))
@@ -235,7 +249,7 @@ pub async fn delete_bookmark(
query: web::Query<BookmarkScopeQuery>,
auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> {
require_control(&req, &auth_state)?;
require_write(&req, &auth_state)?;
let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
let id = path.into_inner();
if store.remove(&id) {
@@ -253,7 +267,7 @@ pub async fn batch_delete_bookmarks(
query: web::Query<BookmarkScopeQuery>,
auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> {
require_control(&req, &auth_state)?;
require_write(&req, &auth_state)?;
let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
let mut deleted = 0usize;
for id in &body.ids {
@@ -272,7 +286,7 @@ pub async fn batch_move_bookmarks(
query: web::Query<BookmarkScopeQuery>,
auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> {
require_control(&req, &auth_state)?;
require_write(&req, &auth_state)?;
let from_store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
let to_store = resolve_bookmark_store(Some(body.to.as_str()), store_map.get_ref());
let mut moved = 0usize;
@@ -15,7 +15,7 @@ use serde::{Deserialize, Serialize};
use trx_logbook::qso::{adif_mode_for_rig_mode, band_for_hz, mode_for_decoder};
use trx_logbook::{LogQuery, Logbook, Qso};
use super::{active_rig_id_from_context, require_control};
use super::{active_rig_id_from_context, require_write};
use crate::server::auth::AuthState;
/// What a contact looks like on the wire.
@@ -225,7 +225,7 @@ pub async fn add_qso(
logbook: web::Data<Arc<Logbook>>,
auth_state: web::Data<AuthState>,
) -> Result<HttpResponse, actix_web::Error> {
require_control(&req, auth_state.get_ref())?;
require_write(&req, auth_state.get_ref())?;
let qso = match input.into_inner().into_qso(None) {
Ok(qso) => qso,
Err(reason) => {
@@ -248,7 +248,7 @@ pub async fn edit_qso(
logbook: web::Data<Arc<Logbook>>,
auth_state: web::Data<AuthState>,
) -> Result<HttpResponse, actix_web::Error> {
require_control(&req, auth_state.get_ref())?;
require_write(&req, auth_state.get_ref())?;
let id = path.into_inner();
let Some(existing) = book(&logbook).get(&id) else {
return Ok(HttpResponse::NotFound().json(serde_json::json!({ "error": "no such contact" })));
@@ -276,7 +276,7 @@ pub async fn delete_qso(
logbook: web::Data<Arc<Logbook>>,
auth_state: web::Data<AuthState>,
) -> Result<HttpResponse, actix_web::Error> {
require_control(&req, auth_state.get_ref())?;
require_write(&req, auth_state.get_ref())?;
match book(&logbook).delete(&path.into_inner()) {
Ok(true) => Ok(HttpResponse::Ok().json(serde_json::json!({ "deleted": true }))),
Ok(false) => {
@@ -312,7 +312,7 @@ pub async fn import_adi(
logbook: web::Data<Arc<Logbook>>,
auth_state: web::Data<AuthState>,
) -> Result<HttpResponse, actix_web::Error> {
require_control(&req, auth_state.get_ref())?;
require_write(&req, auth_state.get_ref())?;
match book(&logbook).import_adi(&body) {
Ok(outcome) => Ok(HttpResponse::Ok().json(outcome)),
Err(err) => Ok(HttpResponse::InternalServerError()
@@ -391,16 +391,20 @@ fn gz_cache_entry(src: &[u8], name: &str) -> GzCacheEntry {
GzCacheEntry { gz, br, etag }
}
fn require_control(
fn require_write(
req: &HttpRequest,
auth_state: &crate::server::auth::AuthState,
) -> Result<(), actix_web::Error> {
if !auth_state.config.enabled {
return Ok(());
}
match crate::server::auth::get_session_role(req, auth_state) {
Some(crate::server::auth::AuthRole::Admin) => Ok(()),
_ => Err(actix_web::error::ErrorForbidden("admin role required")),
if !auth_state.config.enabled
|| crate::server::auth::session_grants(
req,
auth_state,
crate::server::auth::AuthRole::Write,
)
{
Ok(())
} else {
Err(actix_web::error::ErrorForbidden("write role required"))
}
}
@@ -591,6 +595,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(rig::set_sdr_agc)
.service(rig::set_sdr_squelch)
.service(rig::set_sdr_noise_blanker)
.service(rig::set_sdr_dig_sideband)
.service(rig::set_wfm_deemphasis)
.service(rig::set_wfm_stereo)
.service(rig::set_wfm_denoise)
@@ -709,6 +714,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(crate::server::auth::login)
.service(crate::server::auth::logout)
.service(crate::server::auth::session_status)
.service(crate::server::auth::change_own_password)
.service(crate::server::auth::list_users)
.service(crate::server::auth::create_user)
.service(crate::server::auth::update_user)
@@ -973,8 +979,11 @@ mod tests {
crate::server::auth::AuthState::new(crate::server::auth::AuthConfig::new(
true,
directory.path().join("users.json"),
Some("admin".to_string()),
Some("password123".to_string()),
Some(crate::server::auth::BootstrapAccount::new(
"admin".to_string(),
"password123".to_string(),
)),
None,
std::time::Duration::from_secs(3600),
false,
crate::server::auth::SameSite::Lax,
@@ -1171,10 +1180,16 @@ mod tests {
let logbook = std::sync::Arc::new(
trx_logbook::Logbook::open(&dir.path().join("logbook.jsonl")).expect("open"),
);
let auth_state = auth_state_locked();
let session_id = auth_state.store.create(
"reader".to_string(),
[crate::server::auth::AuthRole::Read].into_iter().collect(),
std::time::Duration::from_secs(3600),
);
let app = actix_test::init_service(
App::new()
.app_data(web::Data::new(logbook))
.app_data(web::Data::new(auth_state_locked()))
.app_data(web::Data::new(auth_state))
.service(logbook::add_qso)
.service(logbook::list_qsos),
)
@@ -1184,6 +1199,10 @@ mod tests {
&app,
actix_test::TestRequest::post()
.uri("/api/logbook")
.cookie(actix_web::cookie::Cookie::new(
"trx_http_sid",
session_id.clone(),
))
.set_json(serde_json::json!({
"call": "SP2SJG", "freq_hz": 14_074_000_u64, "mode": "FT8",
}))
@@ -1199,12 +1218,47 @@ mod tests {
&app,
actix_test::TestRequest::get()
.uri("/api/logbook")
.cookie(actix_web::cookie::Cookie::new("trx_http_sid", session_id))
.to_request(),
)
.await;
assert_eq!(listed["total"], 0);
}
#[actix_web::test]
async fn a_write_session_can_write_to_the_log() {
let dir = tempfile::tempdir().expect("tempdir");
let logbook = std::sync::Arc::new(
trx_logbook::Logbook::open(&dir.path().join("logbook.jsonl")).expect("open"),
);
let auth_state = auth_state_locked();
let session_id = auth_state.store.create(
"writer".to_string(),
[crate::server::auth::AuthRole::Write].into_iter().collect(),
std::time::Duration::from_secs(3600),
);
let app = actix_test::init_service(
App::new()
.app_data(web::Data::new(logbook))
.app_data(web::Data::new(auth_state))
.service(logbook::add_qso),
)
.await;
let response = actix_test::call_service(
&app,
actix_test::TestRequest::post()
.uri("/api/logbook")
.cookie(actix_web::cookie::Cookie::new("trx_http_sid", session_id))
.set_json(serde_json::json!({
"call": "SP2SJG", "freq_hz": 14_074_000_u64, "mode": "FT8",
}))
.to_request(),
)
.await;
assert_eq!(response.status(), actix_web::http::StatusCode::OK);
}
/// A contact needs a callsign; the panel is not the only thing that has to
/// insist on it.
#[actix_web::test]
@@ -14,7 +14,7 @@ use uuid::Uuid;
use trx_core::radio::freq::Freq;
use trx_core::rig::state::WfmDenoiseLevel;
use trx_core::{RigCommand, RigRequest, RigState};
use trx_core::{DigSidebandPolicy, NoiseBlankerProfile, RigCommand, RigRequest, RigState};
use trx_frontend::{FrontendRuntimeContext, RemoteRigEntry};
use trx_protocol::parse_mode;
@@ -279,6 +279,10 @@ pub async fn set_sdr_squelch(
pub struct SdrNoiseBlankerQuery {
pub enabled: bool,
pub threshold: f64,
/// `spike` (default), `ignition`, `powerline`, or `broadband`. Optional so
/// the quick toggle (which only carries enable/threshold) keeps working.
#[serde(default)]
pub profile: NoiseBlankerProfile,
pub remote: Option<String>,
}
@@ -293,12 +297,30 @@ pub async fn set_sdr_noise_blanker(
RigCommand::SetSdrNoiseBlanker {
enabled: q.enabled,
threshold: q.threshold,
profile: q.profile,
},
q.remote,
)
.await
}
#[derive(serde::Deserialize)]
pub struct SdrDigSidebandQuery {
/// `auto`, `usb`, or `lsb`.
pub policy: DigSidebandPolicy,
pub remote: Option<String>,
}
/// Set how the SDR backend resolves DIG mode to a sideband.
#[post("/set_sdr_dig_sideband")]
pub async fn set_sdr_dig_sideband(
query: web::Query<SdrDigSidebandQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let q = query.into_inner();
send_command(&rig_tx, RigCommand::SetSdrDigSideband(q.policy), q.remote).await
}
// ============================================================================
// WFM / SAM settings
// ============================================================================
@@ -737,6 +737,7 @@ pub async fn audio_ws(
body: web::Payload,
query: web::Query<AudioQuery>,
context: web::Data<Arc<FrontendRuntimeContext>>,
auth_state: web::Data<crate::server::auth::AuthState>,
) -> Result<HttpResponse, Error> {
let Some(tx_sender) = context.audio.tx.as_ref().cloned() else {
return Ok(HttpResponse::NotFound().body("audio not enabled"));
@@ -746,6 +747,7 @@ pub async fn audio_ws(
if !req.headers().contains_key("upgrade") {
return Ok(HttpResponse::NoContent().finish());
}
let tx_session_id = crate::server::auth::extract_session_id(&req);
// If a channel_id is specified, subscribe to the per-channel broadcaster.
// The entry is created asynchronously when AUDIO_MSG_VCHAN_ALLOCATED arrives
@@ -880,6 +882,16 @@ pub async fn audio_ws(
msg = msg_stream.recv() => {
match msg {
Some(Ok(Message::Binary(data))) => {
let can_transmit = !auth_state.config.enabled
|| crate::server::auth::session_id_grants(
tx_session_id.as_ref(),
&auth_state,
crate::server::auth::AuthRole::Transmit,
);
if !can_transmit {
warn!("Audio WS: closing after unauthorized TX frame");
break;
}
let _ = tx_sender.send(Bytes::from(data.to_vec())).await;
}
Some(Ok(Message::Close(_))) => break,
File diff suppressed because it is too large Load Diff
@@ -21,6 +21,10 @@ pub struct Bookmark {
pub comment: String,
pub category: String,
pub decoders: Vec<String>,
/// For `DIG` bookmarks, the sideband policy to apply on tune: `auto`,
/// `usb`, or `lsb`. `None` (or non-DIG) leaves the current policy alone.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dig_sideband: Option<String>,
}
pub struct BookmarkStore {
@@ -592,6 +592,21 @@ async fn apply_scheduler_target(
)
.await?;
// Apply any DIG sideband override before switching to DIG so the SDR
// backend resolves the intended sideband for this bookmark.
if let Some(policy) = bookmark
.dig_sideband
.as_deref()
.and_then(trx_core::DigSidebandPolicy::parse)
{
scheduler_send(
rig_tx,
RigCommand::SetSdrDigSideband(policy),
remote.to_string(),
)
.await?;
}
scheduler_send(
rig_tx,
RigCommand::SetMode(trx_protocol::parse_mode(&bookmark.mode)),
@@ -252,11 +252,31 @@ fn build_server(
"None" => SameSite::None,
_ => SameSite::Lax, // default
};
let bootstrap_admin = auth::BootstrapAccount::from_parts(
context.http_auth.bootstrap_admin_username.clone(),
context.http_auth.bootstrap_admin_password.clone(),
);
let bootstrap_read = context
.http_auth
.bootstrap_read_enabled
.then(|| {
context
.http_auth
.bootstrap_read_password
.clone()
.map(|password| {
auth::BootstrapAccount::new(
context.http_auth.bootstrap_read_username.clone(),
password,
)
})
})
.flatten();
let auth_config = AuthConfig::new(
context.http_auth.enabled,
context.http_auth.users_file.clone().into(),
context.http_auth.bootstrap_admin_username.clone(),
context.http_auth.bootstrap_admin_password.clone(),
bootstrap_admin,
bootstrap_read,
Duration::from_secs(context.http_auth.session_ttl_secs),
context.http_auth.cookie_secure,
same_site,
+45
View File
@@ -277,6 +277,12 @@ pub struct HttpAuthConfig {
/// Read the bootstrap administrator password from this file instead.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bootstrap_admin_password_file: Option<String>,
/// Create a Guest account when bootstrapping a new database.
pub bootstrap_read_enabled: bool,
/// Username for the Guest bootstrap account.
pub bootstrap_read_username: String,
/// Password for the Guest bootstrap account.
pub bootstrap_read_password: Option<String>,
/// Session time-to-live in minutes
pub session_ttl_min: u64,
/// Set Secure flag on session cookie (required for HTTPS)
@@ -293,6 +299,9 @@ impl Default for HttpAuthConfig {
bootstrap_admin_username: None,
bootstrap_admin_password: None,
bootstrap_admin_password_file: None,
bootstrap_read_enabled: true,
bootstrap_read_username: "guest".to_string(),
bootstrap_read_password: Some("guest".to_string()),
session_ttl_min: 480,
cookie_secure: false,
cookie_same_site: CookieSameSite::Lax,
@@ -830,6 +839,15 @@ impl ClientConfig {
.bootstrap_admin_password_file
.is_none()
&& self.frontends.http.auth.bootstrap_admin_password.is_some()
|| self.frontends.http.auth.enabled
&& self.frontends.http.auth.bootstrap_read_enabled
&& self
.frontends
.http
.auth
.bootstrap_read_password
.as_deref()
.is_some_and(|password| password != "guest")
|| self.frontends.http_json.auth.tokens_file.is_none()
&& !self.frontends.http_json.auth.tokens.is_empty()
}
@@ -903,6 +921,9 @@ impl ClientConfig {
bootstrap_admin_username: Some("admin".to_string()),
bootstrap_admin_password: Some("change-this-password".to_string()),
bootstrap_admin_password_file: None,
bootstrap_read_enabled: true,
bootstrap_read_username: "guest".to_string(),
bootstrap_read_password: Some("guest".to_string()),
session_ttl_min: 480,
cookie_secure: false,
cookie_same_site: CookieSameSite::Lax,
@@ -961,6 +982,15 @@ fn validate_http_auth(auth: &HttpAuthConfig) -> Result<(), String> {
.to_string(),
);
}
if auth.bootstrap_read_enabled
&& (auth.bootstrap_read_username.trim().is_empty()
|| auth
.bootstrap_read_password
.as_deref()
.is_none_or(str::is_empty))
{
return Err("[frontends.http.auth] enabled bootstrap Guest account requires a non-empty username and password".to_string());
}
// Session TTL must be > 0
if auth.session_ttl_min == 0 {
@@ -1268,6 +1298,18 @@ home-hf = "audio://10.0.0.5:4600"
assert!(config.validate().is_err());
}
#[test]
fn test_validate_allows_disabling_read_bootstrap_credentials() {
let mut config = ClientConfig::default();
config.frontends.http.auth.enabled = true;
config.frontends.http.auth.bootstrap_admin_username = Some("admin".to_string());
config.frontends.http.auth.bootstrap_admin_password = Some("secret-password".to_string());
config.frontends.http.auth.bootstrap_read_enabled = false;
config.frontends.http.auth.bootstrap_read_username.clear();
config.frontends.http.auth.bootstrap_read_password = None;
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_auth_disabled_ignores_user_settings() {
let mut config = ClientConfig::default();
@@ -1283,6 +1325,9 @@ home-hf = "audio://10.0.0.5:4600"
assert_eq!(auth.users_file, "trx-http-users.json");
assert!(auth.bootstrap_admin_username.is_none());
assert!(auth.bootstrap_admin_password.is_none());
assert!(auth.bootstrap_read_enabled);
assert_eq!(auth.bootstrap_read_username, "guest");
assert_eq!(auth.bootstrap_read_password.as_deref(), Some("guest"));
assert_eq!(auth.session_ttl_min, 480);
assert!(!auth.cookie_secure);
assert!(matches!(auth.cookie_same_site, CookieSameSite::Lax));
+14 -2
View File
@@ -19,7 +19,7 @@ use crate::shared::{check_socket_conflicts, validate_log_level, validate_tokens,
use serde::{Deserialize, Serialize};
pub use trx_decode_log::DecodeLogsConfig;
use trx_core::rig::state::RigMode;
use trx_core::rig::state::{DigSidebandPolicy, NoiseBlankerProfile, RigMode};
/// Every decoder the server knows how to run, by config name.
///
@@ -393,6 +393,10 @@ pub struct SdrConfig {
pub squelch: SdrSquelchConfig,
/// Noise blanker for impulse noise suppression on IQ samples.
pub noise_blanker: SdrNoiseBlankerConfig,
/// How DIG mode picks a sideband: `auto` (USB ≥ 10 MHz, LSB below),
/// `usb`, or `lsb`. Overridable at runtime and per-bookmark.
#[serde(default)]
pub dig_sideband: DigSidebandPolicy,
/// Virtual receiver channels (at least one required when SDR backend is active).
pub channels: Vec<SdrChannelConfig>,
/// Maximum number of simultaneous virtual channels (including the primary).
@@ -433,6 +437,7 @@ impl Default for SdrConfig {
gain: SdrGainConfig::default(),
squelch: SdrSquelchConfig::default(),
noise_blanker: SdrNoiseBlankerConfig::default(),
dig_sideband: DigSidebandPolicy::default(),
channels: Vec::new(),
max_virtual_channels: default_max_virtual_channels(),
spectrum_fft_size: default_spectrum_fft_size(),
@@ -473,8 +478,14 @@ pub struct SdrNoiseBlankerConfig {
/// Enables the noise blanker.
pub enabled: bool,
/// Threshold multiplier for impulse detection (typical range: 1..100).
/// A sample whose magnitude exceeds threshold × running RMS is blanked.
/// A sample whose magnitude exceeds threshold × the tracked noise floor is
/// blanked. Lower values blank more aggressively.
pub threshold: f64,
/// Tuning profile matched to the interference source: `spike` (default),
/// `ignition`, `powerline`, or `broadband`. Selects the blank window width,
/// look-ahead, gate taper, and noise-floor time constant.
#[serde(default)]
pub profile: NoiseBlankerProfile,
}
impl Default for SdrNoiseBlankerConfig {
@@ -482,6 +493,7 @@ impl Default for SdrNoiseBlankerConfig {
Self {
enabled: false,
threshold: 10.0,
profile: NoiseBlankerProfile::Spike,
}
}
}
+2 -2
View File
@@ -16,7 +16,7 @@ pub use rig::command::RigCommand;
pub use rig::request::RigRequest;
pub use rig::response::{RigError, RigResult};
pub use rig::state::{
DecoderConfig, DecoderResetSeqs, RdsData, RigFilterState, RigMode, RigSnapshot, RigState,
WfmDenoiseLevel,
DecoderConfig, DecoderResetSeqs, DigSidebandPolicy, NoiseBlankerProfile, RdsData,
RigFilterState, RigMode, RigSnapshot, RigState, WfmDenoiseLevel,
};
pub use rig::AudioSource;
+12 -3
View File
@@ -3,7 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
use crate::radio::freq::Freq;
use crate::rig::state::WfmDenoiseLevel;
use crate::rig::state::{DigSidebandPolicy, NoiseBlankerProfile, WfmDenoiseLevel};
use crate::RigMode;
/// Internal command handled by the rig task.
@@ -48,8 +48,17 @@ pub enum RigCommand {
SetSdrGain(f64),
SetSdrLnaGain(f64),
SetSdrAgc(bool),
SetSdrSquelch { enabled: bool, threshold_db: f64 },
SetSdrNoiseBlanker { enabled: bool, threshold: f64 },
SetSdrSquelch {
enabled: bool,
threshold_db: f64,
},
SetSdrNoiseBlanker {
enabled: bool,
threshold: f64,
profile: NoiseBlankerProfile,
},
/// Set how the SDR backend resolves DIG mode to a sideband (SDR only).
SetSdrDigSideband(DigSidebandPolicy),
SetWfmDeemphasis(u32),
SetWfmStereo(bool),
SetWfmDenoise(WfmDenoiseLevel),
@@ -470,6 +470,7 @@ pub fn command_from_rig_command(cmd: RigCommand) -> Box<dyn RigCommandHandler> {
| RigCommand::SetSdrAgc(_)
| RigCommand::SetSdrSquelch { .. }
| RigCommand::SetSdrNoiseBlanker { .. }
| RigCommand::SetSdrDigSideband(_)
| RigCommand::SetWfmDeemphasis(_)
| RigCommand::SetWfmStereo(_)
| RigCommand::SetWfmDenoise(_)
+12
View File
@@ -253,6 +253,7 @@ pub trait RigSdr: Send {
&'a mut self,
_enabled: bool,
_threshold: f64,
_profile: crate::rig::state::NoiseBlankerProfile,
) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'a>> {
Box::pin(std::future::ready(Err(
Box::new(response::RigError::not_supported("set_sdr_noise_blanker"))
@@ -260,6 +261,17 @@ pub trait RigSdr: Send {
)))
}
/// Set how DIG mode resolves to a sideband (SDR backends only).
fn set_sdr_dig_sideband<'a>(
&'a mut self,
_policy: crate::rig::state::DigSidebandPolicy,
) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'a>> {
Box::pin(std::future::ready(Err(
Box::new(response::RigError::not_supported("set_sdr_dig_sideband"))
as Box<dyn std::error::Error + Send + Sync>,
)))
}
fn set_wfm_stereo<'a>(
&'a mut self,
_enabled: bool,
+265
View File
@@ -338,6 +338,14 @@ pub struct RigFilterState {
pub sdr_nb_enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sdr_nb_threshold: Option<f64>,
/// Current noise-blanker tuning profile (SDR backends only). Surfaces in the
/// UI as the advanced-controls "NB profile" selector.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sdr_nb_profile: Option<NoiseBlankerProfile>,
/// Current DIG sideband policy (SDR backends only). Surfaces in the UI as
/// the advanced-controls "DIG sideband" selector.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sdr_dig_sideband: Option<DigSidebandPolicy>,
#[serde(default = "default_wfm_deemphasis_us")]
pub wfm_deemphasis_us: u32,
#[serde(default = "default_wfm_stereo")]
@@ -370,6 +378,155 @@ pub enum WfmDenoiseLevel {
High,
}
/// How the SDR backend resolves the [`RigMode::DIG`] mode to a concrete
/// sideband when demodulating.
///
/// DIG (data / digital) has no inherent sideband; by ham-radio convention it
/// follows the SSB convention for the band. `Auto` applies that convention
/// (USB at/above 10 MHz, LSB below); `Usb`/`Lsb` force a fixed sideband. The
/// setting is a global SDR control (see advanced radio controls) and may be
/// overridden per-bookmark.
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, TS)]
#[serde(rename_all = "lowercase")]
pub enum DigSidebandPolicy {
/// Pick USB or LSB from the dial frequency (USB ≥ 10 MHz, LSB below).
#[default]
Auto,
/// Always demodulate DIG as upper sideband.
Usb,
/// Always demodulate DIG as lower sideband.
Lsb,
}
impl DigSidebandPolicy {
/// Frequency boundary used by [`DigSidebandPolicy::Auto`]: at or above this
/// dial frequency DIG is upper sideband, below it is lower sideband. This is
/// the standard amateur SSB/data convention.
pub const AUTO_THRESHOLD_HZ: u64 = 10_000_000;
/// Resolve this policy to a concrete sideband mode ([`RigMode::USB`] or
/// [`RigMode::LSB`]) for the given dial frequency.
pub fn resolve(self, freq_hz: u64) -> RigMode {
match self {
DigSidebandPolicy::Usb => RigMode::USB,
DigSidebandPolicy::Lsb => RigMode::LSB,
DigSidebandPolicy::Auto => {
if freq_hz >= Self::AUTO_THRESHOLD_HZ {
RigMode::USB
} else {
RigMode::LSB
}
}
}
}
/// Compact encoding for storage in an atomic (e.g. shared runtime state).
pub fn to_u8(self) -> u8 {
match self {
DigSidebandPolicy::Auto => 0,
DigSidebandPolicy::Usb => 1,
DigSidebandPolicy::Lsb => 2,
}
}
/// Inverse of [`DigSidebandPolicy::to_u8`]; unknown values decode to `Auto`.
pub fn from_u8(v: u8) -> Self {
match v {
1 => DigSidebandPolicy::Usb,
2 => DigSidebandPolicy::Lsb,
_ => DigSidebandPolicy::Auto,
}
}
/// Parse a case-insensitive `auto`/`usb`/`lsb` string; `None` if unknown.
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"auto" => Some(DigSidebandPolicy::Auto),
"usb" => Some(DigSidebandPolicy::Usb),
"lsb" => Some(DigSidebandPolicy::Lsb),
_ => None,
}
}
}
/// Resolve a channel's effective demodulation mode. For [`RigMode::DIG`] this
/// applies the sideband `policy` at the given dial frequency; every other mode
/// is returned unchanged. Callers hand the result to the DSP while keeping the
/// logical `DIG` mode for display and decoder gating.
pub fn effective_demod_mode(logical: &RigMode, policy: DigSidebandPolicy, freq_hz: u64) -> RigMode {
if *logical == RigMode::DIG {
policy.resolve(freq_hz)
} else {
logical.clone()
}
}
/// Tuning profile for the SDR impulse noise blanker.
///
/// The blanker removes short, wideband impulse noise from the IQ stream before
/// down-conversion. Different interference sources have different pulse widths
/// and repetition rates, so a single blank window cannot serve all of them: too
/// narrow and it clips only the tip of a wide power-line burst, too wide and it
/// punches audible holes in the wanted signal on sparse ignition spikes. Each
/// profile selects a matched blank window, look-ahead, gate taper, and
/// noise-floor time constant (the concrete values live in the SDR DSP, since
/// they are converted to samples at the capture rate). The user-facing
/// `threshold` control is orthogonal — it sets detection sensitivity within the
/// chosen profile.
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, TS)]
#[serde(rename_all = "lowercase")]
pub enum NoiseBlankerProfile {
/// Short, sharp, sparse impulses — ignition sparks, static crashes, keyed
/// relays. Narrow blank window and fast recovery for minimal impact on the
/// wanted signal; the safe default for SSB/CW/digital.
#[default]
Spike,
/// Automotive ignition, electric fences, PWM/LED drivers — clusters of
/// medium-width pulses at a high repetition rate. Wider window than `Spike`.
Ignition,
/// Power-line and arcing noise — buzzy bursts locked to the 100/120 Hz mains
/// cycle. Wide blank window with a longer, slower noise-floor tracker.
Powerline,
/// Dense, continuous impulse noise where suppression matters more than
/// fidelity. Widest window and most aggressive gating; expect some softening
/// of the wanted signal.
Broadband,
}
impl NoiseBlankerProfile {
/// Compact encoding for storage in an atomic or terse wire field.
pub fn to_u8(self) -> u8 {
match self {
NoiseBlankerProfile::Spike => 0,
NoiseBlankerProfile::Ignition => 1,
NoiseBlankerProfile::Powerline => 2,
NoiseBlankerProfile::Broadband => 3,
}
}
/// Inverse of [`NoiseBlankerProfile::to_u8`]; unknown values decode to the
/// default `Spike`.
pub fn from_u8(v: u8) -> Self {
match v {
1 => NoiseBlankerProfile::Ignition,
2 => NoiseBlankerProfile::Powerline,
3 => NoiseBlankerProfile::Broadband,
_ => NoiseBlankerProfile::Spike,
}
}
/// Parse a case-insensitive profile name; `None` if unknown.
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"spike" => Some(NoiseBlankerProfile::Spike),
"ignition" => Some(NoiseBlankerProfile::Ignition),
"powerline" => Some(NoiseBlankerProfile::Powerline),
"broadband" => Some(NoiseBlankerProfile::Broadband),
_ => None,
}
}
}
fn default_wfm_deemphasis_us() -> u32 {
75
}
@@ -506,3 +663,111 @@ pub struct RigSnapshot {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vchan_rds: Option<Vec<VchanRdsEntry>>,
}
#[cfg(test)]
mod dig_sideband_tests {
use super::{effective_demod_mode, DigSidebandPolicy, RigMode};
#[test]
fn auto_follows_ssb_band_convention() {
// Below 10 MHz → LSB; at/above 10 MHz → USB.
assert_eq!(DigSidebandPolicy::Auto.resolve(7_074_000), RigMode::LSB);
assert_eq!(DigSidebandPolicy::Auto.resolve(3_580_000), RigMode::LSB);
assert_eq!(DigSidebandPolicy::Auto.resolve(9_999_999), RigMode::LSB);
assert_eq!(DigSidebandPolicy::Auto.resolve(10_000_000), RigMode::USB);
assert_eq!(DigSidebandPolicy::Auto.resolve(14_074_000), RigMode::USB);
}
#[test]
fn forced_policies_ignore_frequency() {
assert_eq!(DigSidebandPolicy::Usb.resolve(3_580_000), RigMode::USB);
assert_eq!(DigSidebandPolicy::Lsb.resolve(14_074_000), RigMode::LSB);
}
#[test]
fn effective_mode_only_rewrites_dig() {
// Non-DIG modes pass through untouched regardless of policy/frequency.
assert_eq!(
effective_demod_mode(&RigMode::USB, DigSidebandPolicy::Lsb, 3_580_000),
RigMode::USB
);
assert_eq!(
effective_demod_mode(&RigMode::FM, DigSidebandPolicy::Usb, 3_580_000),
RigMode::FM
);
// DIG resolves by policy + frequency.
assert_eq!(
effective_demod_mode(&RigMode::DIG, DigSidebandPolicy::Auto, 7_074_000),
RigMode::LSB
);
assert_eq!(
effective_demod_mode(&RigMode::DIG, DigSidebandPolicy::Auto, 14_074_000),
RigMode::USB
);
}
#[test]
fn u8_roundtrips_and_parses() {
for p in [
DigSidebandPolicy::Auto,
DigSidebandPolicy::Usb,
DigSidebandPolicy::Lsb,
] {
assert_eq!(DigSidebandPolicy::from_u8(p.to_u8()), p);
}
assert_eq!(DigSidebandPolicy::from_u8(200), DigSidebandPolicy::Auto);
assert_eq!(
DigSidebandPolicy::parse("USB"),
Some(DigSidebandPolicy::Usb)
);
assert_eq!(
DigSidebandPolicy::parse(" lsb "),
Some(DigSidebandPolicy::Lsb)
);
assert_eq!(DigSidebandPolicy::parse("nonsense"), None);
}
}
#[cfg(test)]
mod noise_blanker_profile_tests {
use super::NoiseBlankerProfile;
#[test]
fn default_is_spike() {
assert_eq!(NoiseBlankerProfile::default(), NoiseBlankerProfile::Spike);
}
#[test]
fn u8_round_trips() {
for p in [
NoiseBlankerProfile::Spike,
NoiseBlankerProfile::Ignition,
NoiseBlankerProfile::Powerline,
NoiseBlankerProfile::Broadband,
] {
assert_eq!(NoiseBlankerProfile::from_u8(p.to_u8()), p);
}
// Unknown encodings fall back to the default.
assert_eq!(
NoiseBlankerProfile::from_u8(200),
NoiseBlankerProfile::Spike
);
}
#[test]
fn parse_is_case_insensitive() {
assert_eq!(
NoiseBlankerProfile::parse("SPIKE"),
Some(NoiseBlankerProfile::Spike)
);
assert_eq!(
NoiseBlankerProfile::parse(" powerline "),
Some(NoiseBlankerProfile::Powerline)
);
assert_eq!(
NoiseBlankerProfile::parse("Broadband"),
Some(NoiseBlankerProfile::Broadband)
);
assert_eq!(NoiseBlankerProfile::parse("nonsense"), None);
}
}
+4
View File
@@ -343,6 +343,8 @@ mod tests {
sdr_squelch_threshold_db: None,
sdr_nb_enabled: None,
sdr_nb_threshold: None,
sdr_nb_profile: None,
sdr_dig_sideband: None,
wfm_deemphasis_us: 75,
wfm_stereo: true,
wfm_stereo_detected: false,
@@ -391,6 +393,8 @@ mod tests {
sdr_squelch_threshold_db: None,
sdr_nb_enabled: None,
sdr_nb_threshold: None,
sdr_nb_profile: None,
sdr_dig_sideband: None,
wfm_deemphasis_us: 50,
wfm_stereo: true,
wfm_stereo_detected: true,
+2 -1
View File
@@ -146,6 +146,7 @@ define_command_mapping! {
SetSdrGain { gain_db } <=> SetSdrGain,
SetSdrLnaGain { gain_db } <=> SetSdrLnaGain,
SetSdrAgc { enabled } <=> SetSdrAgc,
SetSdrDigSideband { policy } <=> SetSdrDigSideband,
SetWfmDeemphasis { deemphasis_us } <=> SetWfmDeemphasis,
SetWfmStereo { enabled } <=> SetWfmStereo,
SetWfmDenoise { level } <=> SetWfmDenoise,
@@ -156,7 +157,7 @@ define_command_mapping! {
// ── Multi-field struct passthrough ───────────────────────────────
multi:
SetSdrSquelch { enabled, threshold_db } <=> SetSdrSquelch,
SetSdrNoiseBlanker { enabled, threshold } <=> SetSdrNoiseBlanker;
SetSdrNoiseBlanker { enabled, threshold, profile } <=> SetSdrNoiseBlanker;
// ── Freq conversions (u64 <=> Freq) ──────────────────────────────
freq:
+5 -1
View File
@@ -7,7 +7,7 @@
use serde::{Deserialize, Serialize};
use trx_core::rig::state::RigSnapshot;
use trx_core::WfmDenoiseLevel;
use trx_core::{DigSidebandPolicy, NoiseBlankerProfile, WfmDenoiseLevel};
/// Command received from network clients (JSON).
#[derive(Debug, Serialize, Deserialize)]
@@ -105,6 +105,10 @@ pub enum ClientCommand {
SetSdrNoiseBlanker {
enabled: bool,
threshold: f64,
profile: NoiseBlankerProfile,
},
SetSdrDigSideband {
policy: DigSidebandPolicy,
},
SetWfmDeemphasis {
deemphasis_us: u32,
+40 -5
View File
@@ -360,6 +360,8 @@ fn build_sdr_rig_from_instance(rig_cfg: &RigInstanceConfig) -> SdrRigBuildResult
max_virtual_channels: rig_cfg.sdr.max_virtual_channels,
nb_enabled: rig_cfg.sdr.noise_blanker.enabled,
nb_threshold: rig_cfg.sdr.noise_blanker.threshold,
nb_profile: rig_cfg.sdr.noise_blanker.profile,
dig_sideband: rig_cfg.sdr.dig_sideband,
spectrum_fft_size: rig_cfg.sdr.spectrum_fft_size,
})?;
@@ -920,11 +922,10 @@ fn bound_sockets(cli: &Cli, cfg: &ServerConfig, rigs: &[RigInstanceConfig]) -> V
"[listen]",
));
}
let audio_ip = cli.listen.unwrap_or(cfg.audio.listen);
for rig in rigs {
if rig.audio.enabled {
sockets.push(BoundSocket::new(
audio_ip,
cli.listen.unwrap_or(rig.audio.listen),
rig.audio.port,
format!("rig \"{}\" [audio]", rig.id),
));
@@ -1274,9 +1275,9 @@ async fn main() -> DynResult<()> {
}
}));
// Spawn audio stack.
// listen_override priority: --listen CLI flag > global [audio].listen > per-rig default.
let audio_listen_override = cli.listen.or(Some(cfg.audio.listen));
// Spawn audio stack. --listen overrides every configured bind address;
// otherwise each rig keeps its own [rigs.audio].listen value.
let audio_listen_override = cli.listen;
#[cfg(feature = "soapysdr")]
let audio_vchan_manager = sdr_vchan_manager.clone();
#[cfg(not(feature = "soapysdr"))]
@@ -1415,6 +1416,40 @@ mod tests {
assert!(parse_serial_addr(" ").is_err());
}
#[test]
fn bound_sockets_preserves_per_rig_audio_addresses() {
let cli = Cli::parse_from(["trx-server"]);
let cfg = ServerConfig::default();
let mut local = RigInstanceConfig::default();
local.id = "local".into();
local.audio.listen = "127.0.0.1".parse().unwrap();
local.audio.port = 4531;
let mut remote = RigInstanceConfig::default();
remote.id = "remote".into();
remote.audio.listen = "0.0.0.0".parse().unwrap();
remote.audio.port = 4532;
let sockets = bound_sockets(&cli, &cfg, &[local, remote]);
let local = sockets.iter().find(|s| s.port == 4531).unwrap();
let remote = sockets.iter().find(|s| s.port == 4532).unwrap();
assert_eq!(local.addr, "127.0.0.1".parse::<IpAddr>().unwrap());
assert_eq!(remote.addr, "0.0.0.0".parse::<IpAddr>().unwrap());
}
#[test]
fn bound_sockets_applies_cli_listen_to_every_audio_listener() {
let cli = Cli::parse_from(["trx-server", "--listen", "192.0.2.10"]);
let cfg = ServerConfig::default();
let mut rig = RigInstanceConfig::default();
rig.id = "remote".into();
rig.audio.listen = "127.0.0.1".parse().unwrap();
rig.audio.port = 4532;
let sockets = bound_sockets(&cli, &cfg, &[rig]);
let audio = sockets.iter().find(|s| s.port == 4532).unwrap();
assert_eq!(audio.addr, "192.0.2.10".parse::<IpAddr>().unwrap());
}
#[test]
fn default_audio_bandwidth_for_mode_table() {
assert_eq!(default_audio_bandwidth_for_mode(&RigMode::USB), 3_000);
+20 -2
View File
@@ -760,9 +760,13 @@ async fn process_command(
let _ = ctx.state_tx.send(ctx.state.clone());
return snapshot_from(ctx.state);
}
RigCommand::SetSdrNoiseBlanker { enabled, threshold } => {
RigCommand::SetSdrNoiseBlanker {
enabled,
threshold,
profile,
} => {
if let Some(sdr) = ctx.rig.as_sdr() {
if let Err(e) = sdr.set_sdr_noise_blanker(enabled, threshold).await {
if let Err(e) = sdr.set_sdr_noise_blanker(enabled, threshold, profile).await {
return Err(RigError::communication(format!(
"set_sdr_noise_blanker: {e}"
)));
@@ -774,6 +778,20 @@ async fn process_command(
let _ = ctx.state_tx.send(ctx.state.clone());
return snapshot_from(ctx.state);
}
RigCommand::SetSdrDigSideband(policy) => {
if let Some(sdr) = ctx.rig.as_sdr() {
if let Err(e) = sdr.set_sdr_dig_sideband(policy).await {
return Err(RigError::communication(format!(
"set_sdr_dig_sideband: {e}"
)));
}
} else {
return Err(RigError::not_supported("set_sdr_dig_sideband"));
}
ctx.state.filter = ctx.rig.as_sdr_ref().and_then(|s| s.filter_state());
let _ = ctx.state_tx.send(ctx.state.clone());
return snapshot_from(ctx.state);
}
RigCommand::SetWfmDeemphasis(deemphasis_us) => {
if let Some(sdr) = ctx.rig.as_sdr() {
if let Err(e) = sdr.set_wfm_deemphasis(deemphasis_us).await {
@@ -4,7 +4,7 @@
use num_complex::Complex;
use tokio::sync::broadcast;
use trx_core::rig::state::{RdsData, RigMode, WfmDenoiseLevel};
use trx_core::rig::state::{NoiseBlankerProfile, RdsData, RigMode, WfmDenoiseLevel};
use crate::demod::{DcBlocker, Demodulator, SamDemod, SoftAgc, WfmStereoDecoder};
@@ -14,38 +14,174 @@ use super::{BlockFirFilterPair, IQ_BLOCK_SIZE};
// Noise blanker
// ---------------------------------------------------------------------------
/// IQ-domain impulse noise blanker.
/// Temporal shape of a [`NoiseBlankerProfile`], in real time so the numbers
/// scale correctly across capture rates (they are converted to samples at
/// runtime). See [`NoiseBlanker`] for how each field is used.
struct NbShape {
/// Delay applied to the stream so the gate can begin closing *before* a
/// detected impulse reaches the output, catching its leading edge.
lookahead_us: f32,
/// Minimum time the gate is held fully closed after each detected sample.
blank_us: f32,
/// Raised-cosine gate ramp length (attack == release).
ramp_us: f32,
/// Time constant of the exponential noise-floor tracker.
ref_tc_ms: f32,
}
impl NbShape {
const fn for_profile(p: NoiseBlankerProfile) -> Self {
match p {
// Short, sparse spikes: tight window, quick recovery, fast floor.
NoiseBlankerProfile::Spike => NbShape {
lookahead_us: 8.0,
blank_us: 24.0,
ramp_us: 5.0,
ref_tc_ms: 4.0,
},
// Ignition/PWM bursts: medium window at a high repetition rate.
NoiseBlankerProfile::Ignition => NbShape {
lookahead_us: 12.0,
blank_us: 80.0,
ramp_us: 8.0,
ref_tc_ms: 4.0,
},
// Power-line buzz: wide window, slow floor to ride out the burst.
NoiseBlankerProfile::Powerline => NbShape {
lookahead_us: 20.0,
blank_us: 200.0,
ramp_us: 12.0,
ref_tc_ms: 15.0,
},
// Dense impulse noise: widest, most aggressive gating.
NoiseBlankerProfile::Broadband => NbShape {
lookahead_us: 24.0,
blank_us: 400.0,
ramp_us: 16.0,
ref_tc_ms: 8.0,
},
}
}
}
/// Floor for the tracked noise-floor estimate, guarding against a zero divisor
/// and cold-start false triggers.
const NB_MIN_REF: f32 = 1e-12;
/// IQ-domain impulse noise blanker with look-ahead and a tapered gate.
///
/// Maintains a running RMS estimate of the IQ magnitude. When a sample's
/// magnitude exceeds `threshold × rms`, it is replaced by linear interpolation
/// between the last clean sample and the next clean sample (lookahead of 1).
/// This runs on the wide, undecimated IQ stream — the only place an impulse is
/// still short in time (narrow filtering downstream smears it into un-removable
/// ringing). The algorithm has four parts:
///
/// The RMS tracker uses exponential smoothing with a time constant of ~128
/// samples at the IQ sample rate, fast enough to track band-noise changes
/// but slow enough not to follow individual impulses.
/// 1. **Noise-floor tracker** — an exponential mean-square estimate updated
/// *only from clean samples* and frozen while blanking, so a burst cannot
/// drag the reference up and desensitise detection. Its time constant comes
/// from the profile.
/// 2. **Detection** — a sample is an impulse when its instantaneous power
/// exceeds `threshold² × noise_floor`. `threshold` is the user sensitivity
/// knob and is orthogonal to the profile.
/// 3. **Look-ahead** — the signal is delayed by `lookahead` samples so the gate
/// can start closing before the impulse reaches the output, removing the
/// leading edge instead of letting it leak through (the old blanker's main
/// failure). Each detection holds the gate shut for a profile-sized window.
/// 4. **Tapered gate** — instead of the old hard sample-and-hold (a step that
/// splattered energy right back across the passband and made the blanker
/// audibly worse on SSB/CW/data), the gain ramps smoothly 1→0→1 over
/// `ramp` samples, so blanking costs only a short, quiet notch.
#[derive(Debug, Clone)]
pub struct NoiseBlanker {
enabled: bool,
profile: NoiseBlankerProfile,
sample_rate: f32,
/// Detection sensitivity multiplier over the tracked noise floor (>= 1).
threshold: f32,
/// Exponentially-smoothed mean-square estimate.
mean_sq: f32,
/// Last clean sample (used for interpolation fill).
last_clean: Complex<f32>,
// Derived from (profile, sample_rate); recomputed when either changes.
lookahead: usize,
blank_samples: usize,
/// Per-sample gate gain increment; `1.0 / ramp_len`.
ramp_step: f32,
/// Noise-floor EMA coefficient.
ref_alpha: f32,
/// Samples to seed the noise floor before detection is trusted.
warmup: u32,
// Streaming state.
/// Look-ahead ring buffer (length `lookahead`; empty when `lookahead == 0`).
delay: Vec<Complex<f32>>,
dpos: usize,
/// Tracked noise-floor mean-square.
ref_sq: f32,
/// Samples processed since (re)configuration, capped at `warmup`.
seen: u32,
/// Remaining forced-blank samples.
hold: usize,
/// Current gate gain in `0.0..=1.0`.
gain: f32,
}
const NB_ALPHA: f32 = 1.0 / 128.0;
impl NoiseBlanker {
pub fn new(enabled: bool, threshold: f32) -> Self {
Self {
enabled,
threshold: threshold.max(1.0),
mean_sq: 1e-10,
last_clean: Complex::new(0.0, 0.0),
pub fn new(cfg: NoiseBlankerConfig, sample_rate: u32) -> Self {
let mut nb = Self {
enabled: cfg.enabled,
profile: cfg.profile,
sample_rate: sample_rate as f32,
threshold: cfg.threshold.max(1.0),
lookahead: 0,
blank_samples: 0,
ramp_step: 1.0,
ref_alpha: 0.0,
warmup: 0,
delay: Vec::new(),
dpos: 0,
ref_sq: NB_MIN_REF,
seen: 0,
hold: 0,
gain: 1.0,
};
nb.recompute();
nb
}
/// Recompute sample-domain parameters from the current profile and sample
/// rate, then clear streaming state so the new geometry starts clean.
fn recompute(&mut self) {
let sr = self.sample_rate.max(1.0);
let shape = NbShape::for_profile(self.profile);
let per_us = sr / 1_000_000.0;
self.lookahead = ((shape.lookahead_us * per_us).round() as usize).max(1);
self.blank_samples = ((shape.blank_us * per_us).round() as usize).max(1);
let ramp_len = (shape.ramp_us * per_us).round().max(1.0);
self.ramp_step = 1.0 / ramp_len;
let ref_tc = (shape.ref_tc_ms * 1e-3 * sr).max(1.0);
self.ref_alpha = 1.0 / ref_tc;
// Warm up over roughly one floor time constant, bounded so a huge rate
// cannot stall detection for long.
self.warmup = ref_tc.min(96_000.0) as u32;
self.delay = vec![Complex::new(0.0, 0.0); self.lookahead];
self.reset_state();
}
/// Clear streaming state without touching the derived parameters.
fn reset_state(&mut self) {
for s in self.delay.iter_mut() {
*s = Complex::new(0.0, 0.0);
}
self.dpos = 0;
self.ref_sq = NB_MIN_REF;
self.seen = 0;
self.hold = 0;
self.gain = 1.0;
}
pub fn set_enabled(&mut self, enabled: bool) {
// Engaging fresh: drop any stale delayed samples and re-seed the floor.
if enabled && !self.enabled {
self.reset_state();
}
self.enabled = enabled;
}
@@ -53,7 +189,14 @@ impl NoiseBlanker {
self.threshold = threshold.max(1.0);
}
/// Process a block of IQ samples in-place, blanking impulse spikes.
pub fn set_profile(&mut self, profile: NoiseBlankerProfile) {
if profile != self.profile {
self.profile = profile;
self.recompute();
}
}
/// Process a block of IQ samples in-place, blanking impulse noise.
pub fn process(&mut self, block: &mut [Complex<f32>]) {
if !self.enabled || block.is_empty() {
return;
@@ -62,17 +205,59 @@ impl NoiseBlanker {
let thresh_sq = self.threshold * self.threshold;
for sample in block.iter_mut() {
let s = *sample;
let mag_sq = s.re * s.re + s.im * s.im;
let x = *sample;
let mag_sq = x.re * x.re + x.im * x.im;
if mag_sq > thresh_sq * self.mean_sq {
// Impulse detected — replace with last clean sample.
*sample = self.last_clean;
// Look-ahead: emit the delayed sample, ingest the fresh one.
let y = if self.lookahead == 0 {
x
} else {
// Clean sample — update RMS tracker.
self.mean_sq += NB_ALPHA * (mag_sq - self.mean_sq);
self.last_clean = s;
let out = self.delay[self.dpos];
self.delay[self.dpos] = x;
self.dpos += 1;
if self.dpos >= self.lookahead {
self.dpos = 0;
}
out
};
// Seed the noise floor before trusting detection.
if self.seen < self.warmup {
self.seen += 1;
self.ref_sq += self.ref_alpha * (mag_sq - self.ref_sq);
if self.ref_sq < NB_MIN_REF {
self.ref_sq = NB_MIN_REF;
}
*sample = y;
continue;
}
// Detect on the fresh sample; it reaches the output `lookahead`
// samples later, by which time the gate has closed.
if mag_sq > thresh_sq * self.ref_sq {
self.hold = self.blank_samples;
} else if self.hold == 0 {
// Update the floor only from clean, passed samples.
self.ref_sq += self.ref_alpha * (mag_sq - self.ref_sq);
if self.ref_sq < NB_MIN_REF {
self.ref_sq = NB_MIN_REF;
}
}
// Drive the gate toward its target and apply it to the output.
let target = if self.hold > 0 {
self.hold -= 1;
0.0
} else {
1.0
};
if self.gain < target {
self.gain = (self.gain + self.ramp_step).min(target);
} else if self.gain > target {
self.gain = (self.gain - self.ramp_step).max(target);
}
*sample = y * self.gain;
}
}
}
@@ -81,6 +266,7 @@ impl NoiseBlanker {
pub struct NoiseBlankerConfig {
pub enabled: bool,
pub threshold: f32,
pub profile: NoiseBlankerProfile,
}
impl Default for NoiseBlankerConfig {
@@ -88,6 +274,7 @@ impl Default for NoiseBlankerConfig {
Self {
enabled: false,
threshold: 10.0,
profile: NoiseBlankerProfile::Spike,
}
}
}
@@ -556,7 +743,7 @@ impl ChannelDsp {
processing_enabled: true,
force_mono_pcm,
squelch: VirtualSquelch::new(squelch_cfg),
noise_blanker: NoiseBlanker::new(nb_cfg.enabled, nb_cfg.threshold),
noise_blanker: NoiseBlanker::new(nb_cfg, sdr_sample_rate),
last_signal_db: -120.0,
carrier_iq_power: 0.0,
carrier_attack_alpha: Self::smeter_alphas(channel_sample_rate).0,
@@ -577,9 +764,15 @@ impl ChannelDsp {
self.squelch.set_threshold_db(threshold_db);
}
pub fn set_noise_blanker(&mut self, enabled: bool, threshold: f32) {
self.noise_blanker.set_enabled(enabled);
pub fn set_noise_blanker(
&mut self,
enabled: bool,
threshold: f32,
profile: NoiseBlankerProfile,
) {
self.noise_blanker.set_profile(profile);
self.noise_blanker.set_threshold(threshold);
self.noise_blanker.set_enabled(enabled);
}
pub fn set_mode(&mut self, mode: &RigMode) {
@@ -1046,30 +1239,89 @@ mod tests {
assert_eq!(dsp.demodulator, Demodulator::Fm);
}
fn nb_cfg(enabled: bool, threshold: f32, profile: NoiseBlankerProfile) -> NoiseBlankerConfig {
NoiseBlankerConfig {
enabled,
threshold,
profile,
}
}
/// Steady low-level signal, long enough to clear the warm-up window.
fn nb_warm(nb: &mut NoiseBlanker) {
let mut warm = vec![Complex::new(0.01, 0.01); 40_000];
nb.process(&mut warm);
}
#[test]
fn noise_blanker_suppresses_impulse() {
let mut nb = NoiseBlanker::new(true, 5.0);
// Feed a steady signal to establish the RMS baseline.
let mut block: Vec<Complex<f32>> = (0..256).map(|_| Complex::new(0.01, 0.01)).collect();
let sr = 1_000_000;
let mut nb = NoiseBlanker::new(nb_cfg(true, 5.0, NoiseBlankerProfile::Spike), sr);
nb_warm(&mut nb);
// One massive spike embedded in steady signal. With look-ahead the
// spike emerges at the output already gated, so *no* output sample may
// approach the raw spike power (200) — proving the leading edge did not
// leak through, which the old hold-based blanker allowed.
let mut block = vec![Complex::new(0.01, 0.01); 512];
block[200] = Complex::new(10.0, 10.0);
nb.process(&mut block);
// Now inject a single massive spike at index 0.
let mut block2: Vec<Complex<f32>> = (0..256).map(|_| Complex::new(0.01, 0.01)).collect();
block2[0] = Complex::new(10.0, 10.0);
nb.process(&mut block2);
// The spike should have been blanked (replaced by last clean sample).
let mag = (block2[0].re * block2[0].re + block2[0].im * block2[0].im).sqrt();
let peak = block
.iter()
.map(|s| s.re * s.re + s.im * s.im)
.fold(0.0f32, f32::max);
assert!(peak < 1.0, "impulse leaked through, peak power {peak}");
}
#[test]
fn noise_blanker_passes_strong_steady_signal() {
// A strong *continuous* tone is not impulse noise: the floor tracks it,
// so the blanker must leave it essentially untouched rather than gating
// a real signal.
let sr = 1_000_000;
let mut nb = NoiseBlanker::new(nb_cfg(true, 5.0, NoiseBlankerProfile::Spike), sr);
let mut warm = vec![Complex::new(0.5, 0.5); 40_000];
nb.process(&mut warm);
let mut block = vec![Complex::new(0.5, 0.5); 512];
nb.process(&mut block);
// Tail of the block is past all transients and should pass at unity.
let tail = block[511];
assert!(
mag < 1.0,
"expected impulse to be blanked, got magnitude {}",
mag
(tail.re - 0.5).abs() < 1e-3 && (tail.im - 0.5).abs() < 1e-3,
"steady signal was gated: {tail:?}"
);
}
#[test]
fn noise_blanker_disabled_passes_through() {
let mut nb = NoiseBlanker::new(false, 5.0);
let mut nb = NoiseBlanker::new(nb_cfg(false, 5.0, NoiseBlankerProfile::Spike), 1_000_000);
let mut block = vec![Complex::new(10.0, 10.0); 4];
nb.process(&mut block);
assert_eq!(block[0], Complex::new(10.0, 10.0));
}
#[test]
fn noise_blanker_wider_profile_blanks_longer() {
// A wider profile must hold the gate closed for more samples than a
// narrow one on the same impulse.
let sr = 1_000_000;
let count_blanked = |profile| {
let mut nb = NoiseBlanker::new(nb_cfg(true, 5.0, profile), sr);
nb_warm(&mut nb);
let mut block = vec![Complex::new(0.01, 0.01); 2048];
block[100] = Complex::new(10.0, 10.0);
nb.process(&mut block);
block
.iter()
.filter(|s| s.re * s.re + s.im * s.im < 1e-6)
.count()
};
assert!(
count_blanked(NoiseBlankerProfile::Broadband)
> count_blanked(NoiseBlankerProfile::Spike),
"broadband profile should blank a wider window than spike"
);
}
}
@@ -13,7 +13,10 @@ use std::sync::atomic::Ordering;
use std::sync::{Arc, Mutex};
use trx_core::radio::freq::{Band, Freq};
use trx_core::rig::response::RigError;
use trx_core::rig::state::{RigFilterState, SpectrumData, VchanRdsEntry, WfmDenoiseLevel};
use trx_core::rig::state::{
effective_demod_mode, DigSidebandPolicy, NoiseBlankerProfile, RigFilterState, SpectrumData,
VchanRdsEntry, WfmDenoiseLevel,
};
use trx_core::rig::{
AudioSource, Rig, RigAccessMethod, RigCapabilities, RigCat, RigInfo, RigSdr, RigStatusFuture,
};
@@ -73,6 +76,10 @@ pub struct SoapySdrConfig {
pub nb_enabled: bool,
/// Noise blanker impulse threshold multiplier.
pub nb_threshold: f64,
/// Noise blanker tuning profile (spike/ignition/powerline/broadband).
pub nb_profile: NoiseBlankerProfile,
/// How DIG mode resolves to a sideband (auto/usb/lsb).
pub dig_sideband: DigSidebandPolicy,
/// FFT bin count for the spectrum display; a power of two.
///
/// Fewer bins cost less DSP and put fewer bytes on the network per frame,
@@ -104,6 +111,8 @@ impl Default for SoapySdrConfig {
max_virtual_channels: 4,
nb_enabled: false,
nb_threshold: 10.0,
nb_profile: NoiseBlankerProfile::Spike,
dig_sideband: DigSidebandPolicy::Auto,
spectrum_fft_size: 1024,
}
}
@@ -154,10 +163,17 @@ pub struct SoapySdrRig {
nb_enabled: bool,
/// Noise blanker impulse threshold multiplier.
nb_threshold: f64,
/// Noise blanker tuning profile on the primary channel.
nb_profile: NoiseBlankerProfile,
/// Hidden AIS decoder channels (A and B) when available.
ais_channel_indices: Option<(usize, usize)>,
/// Virtual channel manager shared with external consumers (e.g. RigHandle).
/// Also owns the shared DIG-sideband policy.
channel_manager: Arc<vchan_impl::SdrVirtualChannelManager>,
/// Concrete demodulation mode last pushed to the primary channel DSP. For
/// DIG this is the resolved USB/LSB; used to avoid rebuilding filters on
/// every tune step when the resolved sideband hasn't actually changed.
applied_primary_mode: RigMode,
}
impl SoapySdrRig {
@@ -200,6 +216,8 @@ impl SoapySdrRig {
let max_virtual_channels = config.max_virtual_channels;
let nb_enabled = config.nb_enabled;
let nb_threshold = config.nb_threshold;
let nb_profile = config.nb_profile;
let dig_sideband = config.dig_sideband;
let spectrum_fft_size = config.spectrum_fft_size;
tracing::info!(
"initialising SoapySDR backend (args={:?}, gain_mode={:?}, gain_db={}, max_gain_db={:?})",
@@ -298,6 +316,7 @@ impl SoapySdrRig {
dsp::NoiseBlankerConfig {
enabled: nb_enabled,
threshold: nb_threshold as f32,
profile: nb_profile,
},
&all_channels,
spectrum_fft_size,
@@ -364,8 +383,16 @@ impl SoapySdrRig {
pipeline.clone(),
fixed_slot_count,
max_virtual_channels,
dig_sideband,
));
// Concrete demod mode the primary channel starts in. For DIG this
// resolves the configured sideband policy against the initial dial
// frequency so the very first image/audio uses the right sideband.
let initial_primary_mode =
effective_demod_mode(&initial_mode, dig_sideband, initial_freq.hz);
let initial_is_dig = initial_mode == RigMode::DIG;
let rig = Self {
info,
freq: initial_freq,
@@ -390,9 +417,20 @@ impl SoapySdrRig {
squelch_threshold_db,
nb_enabled,
nb_threshold,
nb_profile,
ais_channel_indices: Some((primary_channel_count, primary_channel_count + 1)),
channel_manager,
applied_primary_mode: initial_primary_mode.clone(),
};
// If the primary channel starts in DIG, its DSP was created with the
// logical DIG demodulator (upper sideband); push the resolved sideband
// so an Auto/LSB start is honoured immediately.
if initial_is_dig {
let dsps = rig.pipeline.channel_dsps.read().unwrap();
if let Some(dsp_arc) = dsps.get(rig.primary_channel_idx) {
dsp_arc.lock().unwrap().set_mode(&initial_primary_mode);
}
}
rig.apply_ais_channel_activity();
Ok(rig)
}
@@ -448,6 +486,8 @@ impl SoapySdrRig {
max_virtual_channels,
nb_enabled,
nb_threshold,
nb_profile: NoiseBlankerProfile::default(),
dig_sideband: DigSidebandPolicy::default(),
})
}
@@ -619,6 +659,21 @@ impl RigCat for SoapySdrRig {
}
}
}
// In DIG/Auto, tuning across the 10 MHz boundary flips the
// sideband. Rebuild the demodulator only when it actually changes,
// so ordinary tuning stays glitch-free.
if self.mode == RigMode::DIG {
let effective = self.channel_manager.dig_policy().resolve(self.freq.hz);
if effective != self.applied_primary_mode {
let dsps = self.pipeline.channel_dsps.read().unwrap();
if let Some(dsp_arc) = dsps.get(self.primary_channel_idx) {
let mut dsp = dsp_arc.lock().unwrap();
dsp.set_mode(&effective);
dsp.set_filter(self.bandwidth_hz);
}
self.applied_primary_mode = effective;
}
}
self.update_ais_channel_offsets();
Ok(())
})
@@ -632,12 +687,18 @@ impl RigCat for SoapySdrRig {
tracing::debug!("SoapySdrRig: set_mode -> {:?}", mode);
self.mode = mode.clone();
self.bandwidth_hz = Self::default_bandwidth_for_mode(&mode);
// DIG carries no inherent sideband: resolve it to a concrete
// USB/LSB demodulator from the policy + dial frequency. The logical
// DIG mode is kept in `self.mode` (and RigState) for display.
let effective =
effective_demod_mode(&self.mode, self.channel_manager.dig_policy(), self.freq.hz);
self.applied_primary_mode = effective.clone();
// Update the primary channel's demodulator in the live pipeline.
{
let dsps = self.pipeline.channel_dsps.read().unwrap();
if let Some(dsp_arc) = dsps.get(self.primary_channel_idx) {
let mut dsp = dsp_arc.lock().unwrap();
dsp.set_mode(&mode);
dsp.set_mode(&effective);
dsp.set_filter(self.bandwidth_hz);
}
}
@@ -933,6 +994,7 @@ impl RigSdr for SoapySdrRig {
&'a mut self,
enabled: bool,
threshold: f64,
profile: NoiseBlankerProfile,
) -> Pin<Box<dyn std::future::Future<Output = DynResult<()>> + Send + 'a>> {
Box::pin(async move {
if !threshold.is_finite() {
@@ -943,13 +1005,38 @@ impl RigSdr for SoapySdrRig {
}
self.nb_enabled = enabled;
self.nb_threshold = threshold;
self.nb_profile = profile;
{
let dsps = self.pipeline.channel_dsps.read().unwrap();
if let Some(dsp_arc) = dsps.get(self.primary_channel_idx) {
dsp_arc
.lock()
.unwrap()
.set_noise_blanker(enabled, threshold as f32);
.set_noise_blanker(enabled, threshold as f32, profile);
}
}
Ok(())
})
}
fn set_sdr_dig_sideband<'a>(
&'a mut self,
policy: DigSidebandPolicy,
) -> Pin<Box<dyn std::future::Future<Output = DynResult<()>> + Send + 'a>> {
Box::pin(async move {
// Update the shared policy (also re-resolves DIG virtual channels).
self.channel_manager.set_dig_policy(policy);
// Re-apply to the primary channel when it is currently DIG.
if self.mode == RigMode::DIG {
let effective = policy.resolve(self.freq.hz);
if effective != self.applied_primary_mode {
let dsps = self.pipeline.channel_dsps.read().unwrap();
if let Some(dsp_arc) = dsps.get(self.primary_channel_idx) {
let mut dsp = dsp_arc.lock().unwrap();
dsp.set_mode(&effective);
dsp.set_filter(self.bandwidth_hz);
}
self.applied_primary_mode = effective;
}
}
Ok(())
@@ -1049,6 +1136,8 @@ impl RigSdr for SoapySdrRig {
sdr_squelch_threshold_db: Some(self.squelch_threshold_db as f64),
sdr_nb_enabled: Some(self.nb_enabled),
sdr_nb_threshold: Some(self.nb_threshold),
sdr_nb_profile: Some(self.nb_profile),
sdr_dig_sideband: Some(self.channel_manager.dig_policy()),
wfm_deemphasis_us: self.wfm_deemphasis_us,
wfm_stereo: self.wfm_stereo,
wfm_stereo_detected,
@@ -22,12 +22,12 @@
//! updates every `ChannelDsp` in place and pauses out-of-span channels instead
//! of destroying them.
use std::sync::atomic::{AtomicI64, Ordering};
use std::sync::atomic::{AtomicI64, AtomicU8, Ordering};
use std::sync::{Arc, RwLock};
use num_complex::Complex;
use tokio::sync::broadcast;
use trx_core::rig::state::{RigMode, VchanRdsEntry};
use trx_core::rig::state::{effective_demod_mode, DigSidebandPolicy, RigMode, VchanRdsEntry};
use uuid::Uuid;
use crate::dsp::SdrPipeline;
@@ -59,7 +59,12 @@ fn default_bandwidth_hz(mode: &RigMode) -> u32 {
struct ManagedChannel {
id: Uuid,
freq_hz: u64,
/// Logical mode requested by the user (e.g. `DIG`), shown in listings.
mode: RigMode,
/// Concrete demod mode last pushed to the DSP. For `DIG` this is the
/// resolved USB/LSB; tracked so a frequency change only rebuilds the
/// demodulator when the resolved sideband actually flips.
applied_mode: RigMode,
/// `broadcast::Sender` kept alive so new subscribers can join at any time.
pcm_tx: broadcast::Sender<Vec<f32>>,
/// IQ tap sender (kept alive; external consumers may subscribe).
@@ -91,6 +96,9 @@ pub struct SdrVirtualChannelManager {
channels: RwLock<Vec<ManagedChannel>>,
/// Fires whenever a channel is explicitly destroyed.
destroyed_tx: broadcast::Sender<Uuid>,
/// Shared DIG-sideband policy ([`DigSidebandPolicy`] encoded via `to_u8`).
/// Read when resolving any DIG channel's demodulator.
dig_sideband: AtomicU8,
}
impl SdrVirtualChannelManager {
@@ -100,7 +108,12 @@ impl SdrVirtualChannelManager {
/// - `fixed_slot_count`: number of fixed pipeline slots (primary + AIS),
/// i.e. the index of the first slot available for virtual channels.
/// - `max_total`: maximum total channels including primary (e.g. 4).
pub fn new(pipeline: Arc<SdrPipeline>, fixed_slot_count: usize, max_total: usize) -> Self {
pub fn new(
pipeline: Arc<SdrPipeline>,
fixed_slot_count: usize,
max_total: usize,
dig_sideband: DigSidebandPolicy,
) -> Self {
// Seed the channel list with a synthetic primary-channel entry.
// We use the first PCM sender from the pipeline (index 0).
let primary_pcm_tx = pipeline
@@ -118,6 +131,7 @@ impl SdrVirtualChannelManager {
id: Uuid::new_v4(),
freq_hz: 0, // actual freq kept by SoapySdrRig; manager treats ch-0 as opaque
mode: RigMode::USB,
applied_mode: RigMode::USB,
pcm_tx: primary_pcm_tx,
iq_tx: primary_iq_tx,
pipeline_slot: 0,
@@ -134,6 +148,7 @@ impl SdrVirtualChannelManager {
max_total: max_total.max(1),
channels: RwLock::new(vec![primary]),
destroyed_tx,
dig_sideband: AtomicU8::new(dig_sideband.to_u8()),
}
}
@@ -141,6 +156,36 @@ impl SdrVirtualChannelManager {
self.destroyed_tx.clone()
}
/// Current shared DIG-sideband policy.
pub fn dig_policy(&self) -> DigSidebandPolicy {
DigSidebandPolicy::from_u8(self.dig_sideband.load(Ordering::Relaxed))
}
/// Update the shared DIG-sideband policy and re-resolve every DIG virtual
/// channel's demodulator (the primary channel is handled by `SoapySdrRig`).
pub fn set_dig_policy(&self, policy: DigSidebandPolicy) {
self.dig_sideband.store(policy.to_u8(), Ordering::Relaxed);
let mut channels = self.channels.write().unwrap();
let dsps = self.pipeline.channel_dsps.read().unwrap();
for ch in channels
.iter_mut()
.filter(|c| !c.permanent && c.mode == RigMode::DIG)
{
let effective = policy.resolve(ch.freq_hz);
if effective != ch.applied_mode {
ch.applied_mode = effective.clone();
if let Some(dsp_arc) = dsps.get(ch.pipeline_slot) {
dsp_arc.lock().unwrap().set_mode(&effective);
}
}
}
}
/// Resolve a channel's effective demod mode under the current policy.
fn resolve_mode(&self, logical: &RigMode, freq_hz: u64) -> RigMode {
effective_demod_mode(logical, self.dig_policy(), freq_hz)
}
fn half_span_hz(&self) -> i64 {
i64::from(self.pipeline.sdr_sample_rate) / 2
}
@@ -172,10 +217,13 @@ impl SdrVirtualChannelManager {
});
}
// Bandwidth follows the logical mode; the DSP demodulator follows the
// resolved sideband (DIG → USB/LSB by policy + frequency).
let bandwidth_hz = default_bandwidth_hz(mode);
let (pcm_tx, iq_tx) = self
.pipeline
.add_virtual_channel(if_hz as f64, mode, bandwidth_hz);
let applied_mode = self.resolve_mode(mode, freq_hz);
let (pcm_tx, iq_tx) =
self.pipeline
.add_virtual_channel(if_hz as f64, &applied_mode, bandwidth_hz);
let pipeline_slot = self
.pipeline
@@ -190,6 +238,7 @@ impl SdrVirtualChannelManager {
id,
freq_hz,
mode: mode.clone(),
applied_mode,
pcm_tx,
iq_tx,
pipeline_slot,
@@ -314,9 +363,18 @@ impl VirtualChannelManager for SdrVirtualChannelManager {
.ok_or(VChanError::NotFound)?;
ch.freq_hz = freq_hz;
// A DIG/Auto channel flips sideband across the 10 MHz boundary; rebuild
// the demodulator only when the resolved sideband actually changes.
let effective = self.resolve_mode(&ch.mode, freq_hz);
let mode_changed = effective != ch.applied_mode;
ch.applied_mode = effective.clone();
let dsps = self.pipeline.channel_dsps.read().unwrap();
if let Some(dsp_arc) = dsps.get(ch.pipeline_slot) {
dsp_arc.lock().unwrap().set_channel_if_hz(if_hz as f64);
let mut dsp = dsp_arc.lock().unwrap();
dsp.set_channel_if_hz(if_hz as f64);
if mode_changed {
dsp.set_mode(&effective);
}
}
Ok(())
}
@@ -329,9 +387,11 @@ impl VirtualChannelManager for SdrVirtualChannelManager {
.ok_or(VChanError::NotFound)?;
ch.mode = mode.clone();
let effective = self.resolve_mode(mode, ch.freq_hz);
ch.applied_mode = effective.clone();
let dsps = self.pipeline.channel_dsps.read().unwrap();
if let Some(dsp_arc) = dsps.get(ch.pipeline_slot) {
dsp_arc.lock().unwrap().set_mode(mode);
dsp_arc.lock().unwrap().set_mode(&effective);
}
Ok(())
}
@@ -425,6 +485,7 @@ impl VirtualChannelManager for SdrVirtualChannelManager {
#[cfg(test)]
mod tests {
use super::*;
use crate::demod::Demodulator;
use crate::dsp::{MockIqSource, NoiseBlankerConfig, SdrPipeline};
fn make_pipeline() -> Arc<SdrPipeline> {
@@ -446,7 +507,7 @@ mod tests {
#[test]
fn add_and_list() {
let p = make_pipeline();
let mgr = SdrVirtualChannelManager::new(p, 1, 4);
let mgr = SdrVirtualChannelManager::new(p, 1, 4, DigSidebandPolicy::Auto);
// Set center to 14.1 MHz so that 14.074 MHz is within ±960 kHz.
mgr.update_center_hz(14_100_000);
assert_eq!(mgr.channels().len(), 1); // primary only
@@ -462,7 +523,7 @@ mod tests {
#[test]
fn remove_virtual_channel() {
let p = make_pipeline();
let mgr = SdrVirtualChannelManager::new(p, 1, 4);
let mgr = SdrVirtualChannelManager::new(p, 1, 4, DigSidebandPolicy::Auto);
mgr.update_center_hz(14_100_000);
let (id, _) = mgr.add_channel(14_074_000, &RigMode::USB).unwrap();
mgr.remove_channel(id).unwrap();
@@ -472,7 +533,7 @@ mod tests {
#[test]
fn cannot_remove_primary() {
let p = make_pipeline();
let mgr = SdrVirtualChannelManager::new(p, 1, 4);
let mgr = SdrVirtualChannelManager::new(p, 1, 4, DigSidebandPolicy::Auto);
let primary_id = mgr.channels()[0].id;
let err = mgr.remove_channel(primary_id).unwrap_err();
assert!(matches!(err, VChanError::Permanent));
@@ -481,7 +542,7 @@ mod tests {
#[test]
fn cap_enforced() {
let p = make_pipeline();
let mgr = SdrVirtualChannelManager::new(p, 1, 2); // primary + 1 virtual max
let mgr = SdrVirtualChannelManager::new(p, 1, 2, DigSidebandPolicy::Auto); // primary + 1 virtual max
mgr.update_center_hz(14_100_000);
mgr.add_channel(14_074_000, &RigMode::USB).unwrap();
let err = mgr.add_channel(14_075_000, &RigMode::USB).unwrap_err();
@@ -491,7 +552,7 @@ mod tests {
#[test]
fn out_of_bandwidth() {
let p = make_pipeline();
let mgr = SdrVirtualChannelManager::new(p, 1, 4);
let mgr = SdrVirtualChannelManager::new(p, 1, 4, DigSidebandPolicy::Auto);
// center_hz = 0, half_span = 960_000 Hz — 10 MHz is way out
let err = mgr.add_channel(10_000_000, &RigMode::USB).unwrap_err();
assert!(matches!(err, VChanError::OutOfBandwidth { .. }));
@@ -500,7 +561,7 @@ mod tests {
#[test]
fn hidden_background_channels_are_outside_visible_cap() {
let p = make_pipeline();
let mgr = SdrVirtualChannelManager::new(p, 1, 2); // primary + 1 visible max
let mgr = SdrVirtualChannelManager::new(p, 1, 2, DigSidebandPolicy::Auto); // primary + 1 visible max
mgr.update_center_hz(14_100_000);
mgr.add_channel(14_074_000, &RigMode::USB).unwrap();
@@ -513,10 +574,46 @@ mod tests {
assert!(visible.iter().all(|channel| channel.id != hidden_id));
}
#[test]
fn dig_channel_resolves_sideband_and_tracks_policy() {
let p = make_pipeline();
let mgr = SdrVirtualChannelManager::new(p.clone(), 1, 6, DigSidebandPolicy::Auto);
mgr.update_center_hz(7_100_000);
// The virtual channel occupies pipeline slot 1 (slot 0 is the primary).
let demod_at_slot1 = || {
p.channel_dsps.read().unwrap()[1]
.lock()
.unwrap()
.demodulator
.clone()
};
// DIG below 10 MHz under Auto → lower sideband.
let (_id, _rx) = mgr.add_channel(7_074_000, &RigMode::DIG).unwrap();
assert_eq!(demod_at_slot1(), Demodulator::Lsb);
// Forcing USB re-resolves the live channel.
mgr.set_dig_policy(DigSidebandPolicy::Usb);
assert_eq!(demod_at_slot1(), Demodulator::Usb);
// Back to Auto (still below 10 MHz) → lower sideband again.
mgr.set_dig_policy(DigSidebandPolicy::Auto);
assert_eq!(demod_at_slot1(), Demodulator::Lsb);
// The listing keeps the logical DIG mode for display.
let ch = mgr
.channels()
.into_iter()
.find(|c| !c.permanent)
.expect("virtual channel");
assert_eq!(ch.mode, "DIG");
}
#[test]
fn retune_keeps_virtual_channel_allocated() {
let p = make_pipeline();
let mgr = SdrVirtualChannelManager::new(p, 1, 4);
let mgr = SdrVirtualChannelManager::new(p, 1, 4, DigSidebandPolicy::Auto);
mgr.update_center_hz(14_100_000);
let mut destroyed_rx = mgr.subscribe_destroyed();
+5
View File
@@ -118,6 +118,7 @@ sample_rate = 1920000
bandwidth = 1500000
wfm_deemphasis_us = 50
center_offset_hz = 100000
dig_sideband = "auto"
channels = []
max_virtual_channels = 4
spectrum_fft_size = 1024
@@ -139,6 +140,7 @@ tail_ms = 180
[trx-server.sdr.noise_blanker]
enabled = false
threshold = 10.0
profile = "spike"
# Timeout and buffer tuning. The defaults suit most setups.
[trx-server.timeouts]
@@ -210,6 +212,9 @@ 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
cookie_same_site = "Lax"