Compare commits
1
Commits
main
..
d2a20fd260
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2a20fd260 |
@@ -27,7 +27,7 @@ env:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: self-hosted
|
||||
runs-on: ubuntu-latest
|
||||
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: self-hosted
|
||||
runs-on: ubuntu-latest
|
||||
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: self-hosted
|
||||
runs-on: ubuntu-latest
|
||||
container: git.haxx.space/sjg/trx-rs/sdk:latest
|
||||
defaults:
|
||||
run:
|
||||
@@ -83,7 +83,7 @@ jobs:
|
||||
run: npm run verify-generated
|
||||
|
||||
reuse:
|
||||
runs-on: self-hosted
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: fsfe/reuse-action@v5
|
||||
|
||||
Generated
+5
-93
@@ -316,18 +316,6 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures 0.2.17",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
@@ -357,12 +345,6 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bindgen"
|
||||
version = "0.66.1"
|
||||
@@ -413,24 +395,6 @@ version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.12.0"
|
||||
@@ -561,7 +525,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
"cpufeatures",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
@@ -757,15 +721,6 @@ dependencies = [
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.0"
|
||||
@@ -784,16 +739,6 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.2.1"
|
||||
@@ -854,26 +799,15 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer 0.10.4",
|
||||
"crypto-common 0.1.7",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.0",
|
||||
"block-buffer",
|
||||
"const-oid",
|
||||
"crypto-common 0.2.1",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1085,16 +1019,6 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
@@ -1966,17 +1890,6 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "peeking_take_while"
|
||||
version = "0.1.2"
|
||||
@@ -2579,8 +2492,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
"digest 0.11.2",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3302,7 +3215,6 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"actix-web",
|
||||
"actix-ws",
|
||||
"argon2",
|
||||
"base64",
|
||||
"brotli 7.0.0",
|
||||
"bytes",
|
||||
|
||||
@@ -116,44 +116,6 @@ 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
|
||||
|
||||
@@ -925,14 +925,9 @@ main
|
||||
|
||||
### HTTP Frontend Auth
|
||||
|
||||
- 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]`
|
||||
- Optional token or HTTP Basic Auth middleware
|
||||
- Configured in `[frontends.http.auth]`
|
||||
- Rate limiting supported
|
||||
|
||||
### Transport Security
|
||||
|
||||
@@ -1048,7 +1043,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/read accounts, `session_ttl_secs`, `tokens` |
|
||||
| `HttpAuthConfig` | HTTP auth settings | `enabled`, `rx_passphrase`, `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` |
|
||||
|
||||
@@ -1,287 +0,0 @@
|
||||
# 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.
|
||||
+14
-28
@@ -453,31 +453,25 @@ put in the exchange there is nothing for it to write.
|
||||
| `RigState` | `FREQ`, `BAND`, `MODE`/`SUBMODE`, and the rig id a QSO was made on | watch channel already in the frontend context |
|
||||
| Client config `general.callsign` | `STATION_CALLSIGN`, and the default `OPERATOR` | already surfaced as `owner_callsign` in frontend meta |
|
||||
| The QSO's own rig, and its position | `MY_GRIDSQUARE` | per-rig latitude and longitude already carried in the rig list |
|
||||
| Server clock | `QSO_DATE`, `TIME_ON` in UTC | new `GET /api/logbook/now`, which also feeds the browser-clock check |
|
||||
| Server clock | `QSO_DATE`, `TIME_ON` in UTC | new `GET /logbook/now`, which also feeds the browser-clock check |
|
||||
| Decoder panels and map | a pre-filled entry: callsign, grid, and the report to offer | existing decode history; no new plumbing |
|
||||
| `bandForHz` | `BAND` from a frequency | exists in `map-core.ts`; move to a shared module |
|
||||
|
||||
#### HTTP API
|
||||
|
||||
Under `/api/logbook`, as the recorder's endpoints are, because `/logbook` itself is the page:
|
||||
the `/bookmarks` API and the bookmarks page already share a path, and whichever is registered
|
||||
first wins.
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `GET` | `/api/logbook` | Query: filters, paging |
|
||||
| `POST` | `/api/logbook` | Add a QSO |
|
||||
| `PUT` | `/api/logbook/{id}` | Edit |
|
||||
| `DELETE` | `/api/logbook/{id}` | Delete |
|
||||
| `GET` | `/api/logbook/export.adi` | ADIF export, honouring the current filter |
|
||||
| `GET` | `/api/logbook/export.cbr` | Cabrillo export of a contest selection |
|
||||
| `POST` | `/api/logbook/import` | Import, answering with counts: added, duplicate, rejected |
|
||||
| `GET` | `/api/logbook/worked/{call}` | Worked-before: bands and modes |
|
||||
| `GET` | `/api/logbook/statistics` | Contacts, stations and confirmations, per band |
|
||||
| `GET` | `/api/logbook/now` | The server's UTC clock, for checking the browser's |
|
||||
| `GET` | `/api/logbook/prefill` | The six fields an entry opens with |
|
||||
| `GET` | `/logbook` | Query: filters, paging |
|
||||
| `POST` | `/logbook` | Add a QSO |
|
||||
| `PUT` | `/logbook/{id}` | Edit |
|
||||
| `DELETE` | `/logbook/{id}` | Delete |
|
||||
| `GET` | `/logbook/export.adi` | ADIF export, honouring the current filter |
|
||||
| `GET` | `/logbook/export.cbr` | Cabrillo export of a contest selection |
|
||||
| `POST` | `/logbook/import` | Import, answering with counts: added, duplicate, rejected |
|
||||
| `GET` | `/logbook/worked/{call}` | Worked-before: bands and modes |
|
||||
| `GET` | `/logbook/now` | The server's UTC clock, for stamping entries and checking the browser's |
|
||||
|
||||
Writes require the admin role, as the rig endpoints do.
|
||||
Writes require the control role, as the rig endpoints do.
|
||||
|
||||
### Frontend
|
||||
|
||||
@@ -511,22 +505,14 @@ task and the rig is the instrument.
|
||||
|
||||
### Phases
|
||||
|
||||
All five are implemented.
|
||||
|
||||
| Phase | Lands |
|
||||
|-------|-------|
|
||||
| 1 | `trx-logbook`: `Qso`, the ADI reader and writer, round-trip tests against files from other loggers |
|
||||
| 2 | Store, dedupe, and the HTTP API behind the admin role |
|
||||
| 2 | Store, dedupe, and the HTTP API behind the control role |
|
||||
| 3 | Logbook tab: entry, table, filters, import, export |
|
||||
| 4 | Ham layout, pre-filled entry from a decode row or the map, worked-before |
|
||||
| 5 | Contest exchange fields and Cabrillo export; QSL and LoTW/eQSL fields; per-band worked/confirmed statistics |
|
||||
|
||||
Two decisions phase 5 settled. The Cabrillo header cannot be derived from a log — how many
|
||||
operators, how much power, what the score is claimed to be — so it comes from the operator,
|
||||
with `SINGLE-OP`, `LOW`, `ALL` and `MIXED` behind it. And a confirmation counts from whichever
|
||||
bureau answered: an award wants one card or one electronic match, not one of each, so a log
|
||||
that counted them separately would tell the operator they were short of what they have.
|
||||
|
||||
### Decisions
|
||||
|
||||
**One station log, not one per rig.** Awards and uploads are per station callsign — DXCC, WAS
|
||||
@@ -541,8 +527,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 inferred from the session's identity: an account username need not be an operator
|
||||
callsign, and operational accounts may be shared.
|
||||
It cannot be taken from the session's identity: the auth roles are `control` and `rx`, with no
|
||||
notion of who is logged in.
|
||||
|
||||
**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
|
||||
|
||||
+12
-19
@@ -121,14 +121,13 @@ The spectrum panel uses `<canvas>` elements (WebGL renderer optional) and offers
|
||||
When auth is enabled, an **auth gate** blocks the UI with:
|
||||
|
||||
- Title: "Access Required"
|
||||
- Subtitle: "Sign in to continue"
|
||||
- Username and password inputs + Login button (green accent, full-width)
|
||||
- Subtitle: "Enter passphrase to continue"
|
||||
- Password input + Login button (green accent, full-width)
|
||||
- Optional "Continue as Guest" button (shown when RX passphrase is not set)
|
||||
- Error message area (red `#ff6b6b`)
|
||||
- Role badge display
|
||||
|
||||
**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.
|
||||
Two roles: **Rx** (read-only) and **Control** (full access including TX/PTT).
|
||||
|
||||
Session cookie: `trx_http_sid`, HttpOnly, configurable Secure and SameSite attributes.
|
||||
|
||||
@@ -337,31 +336,25 @@ Logo and favicon are embedded at compile time via `include_bytes!`. The logo ima
|
||||
|
||||
### 7.1 Route Access Classification
|
||||
|
||||
Routes are classified into access tiers:
|
||||
Routes are classified into three tiers:
|
||||
|
||||
| Tier | Examples | Requirement |
|
||||
|---|---|---|
|
||||
| **Public** | `/`, `/index.html`, `/map`, login/session endpoints, static assets | None |
|
||||
| **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 |
|
||||
| **Public** | `/`, `/index.html`, `/map`, `/auth/*`, static assets | None |
|
||||
| **Read** | `/status`, `/events`, `/audio`, `/decode`, `/spectrum`, `/bookmarks` | Rx or Control role |
|
||||
| **Control** | `/set_freq`, `/set_mode`, `/set_ptt`, `/toggle_power`, all other POST | Control role only |
|
||||
|
||||
### 7.2 Session Management
|
||||
|
||||
- Sessions are 128-bit random hex tokens stored in HttpOnly cookies
|
||||
- Configurable TTL (default from TOML config)
|
||||
- Expired sessions auto-pruned on access
|
||||
- Passwords are verified against salted Argon2id hashes
|
||||
- Constant-time passphrase comparison to mitigate timing attacks
|
||||
|
||||
### 7.3 User Management
|
||||
### 7.3 TX Access Control
|
||||
|
||||
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.
|
||||
An additional `tx_access_control_enabled` flag can restrict transmit-related actions even
|
||||
for Control-role users, providing an extra safety layer.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+45
-108
@@ -66,7 +66,8 @@ both:
|
||||
|------------|----------|----------|
|
||||
| `[listen.auth].tokens` | `tokens_file` | one token per line |
|
||||
| `[[remotes]].auth.token` | `token_file` | the token |
|
||||
| `[frontends.http.auth].bootstrap_admin_password` | `bootstrap_admin_password_file` | the initial administrator password |
|
||||
| `[frontends.http.auth].rx_passphrase` | `rx_passphrase_file` | the passphrase |
|
||||
| `[frontends.http.auth].control_passphrase` | `control_passphrase_file` | the passphrase |
|
||||
| `[frontends.http_json.auth].tokens` | `tokens_file` | one token per line |
|
||||
|
||||
Blank lines and `#` comments are ignored in the list files. A config that holds
|
||||
@@ -349,20 +350,17 @@ A name in any of those maps that no remote answers to is a config error.
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `enabled` | bool | `false` | Enable the user/password ACL |
|
||||
| `users_file` | string | `"trx-http-users.json"` | Persistent managed user database |
|
||||
| `bootstrap_admin_username` | string | — | First administrator, used only if the database is absent |
|
||||
| `bootstrap_admin_password` | string | — | First administrator password |
|
||||
| `bootstrap_admin_password_file` | string | — | Read the bootstrap password from this file instead |
|
||||
| `bootstrap_read_enabled` | bool | `true` | Create the default Guest account when the database is absent |
|
||||
| `bootstrap_read_username` | string | `"guest"` | Initial Guest username |
|
||||
| `bootstrap_read_password` | string | `"guest"` | Initial Guest password |
|
||||
| `enabled` | bool | `false` | Require a passphrase |
|
||||
| `rx_passphrase` | string | — | Passphrase granting receive-only access |
|
||||
| `rx_passphrase_file` | string | — | Read it from this file instead |
|
||||
| `control_passphrase` | string | — | Passphrase granting full control |
|
||||
| `control_passphrase_file` | string | — | Read it from this file instead |
|
||||
| `tx_access_control_enabled` | bool | `true` | Hide TX from unauthenticated users |
|
||||
| `session_ttl_min` | u64 | `480` | Session lifetime |
|
||||
| `cookie_secure` | bool | `false` | Set Secure on the session cookie (needs HTTPS) |
|
||||
| `cookie_same_site` | string | `"Lax"` | `Strict`, `Lax`, or `None` |
|
||||
|
||||
When enabling ACL for the first time, configure both bootstrap fields. After
|
||||
the database exists, remove the bootstrap credentials from configuration.
|
||||
With `enabled = true`, at least one passphrase must be set.
|
||||
|
||||
#### `[frontends.rigctl]`
|
||||
|
||||
@@ -580,7 +578,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 `Control` role; a `Read`
|
||||
Applying a link changes the radio, so it needs the `control` role; an `rx`
|
||||
session opens the page and says the link was not applied. Links describe the
|
||||
rig's own dial — while a tab is listening to a virtual channel the address is
|
||||
left as it was, rather than publishing a frequency the rig is not on.
|
||||
@@ -589,72 +587,53 @@ left as it was, rather than publishing a frequency the rig is not on.
|
||||
|
||||
## Authentication
|
||||
|
||||
The HTTP frontend supports an optional user/password ACL:
|
||||
The HTTP frontend supports optional passphrase-based authentication with two
|
||||
roles:
|
||||
|
||||
- **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
|
||||
- **rx** — read-only access (monitoring, audio, decode streams)
|
||||
- **control** — full access (frequency, mode, PTT, and all settings)
|
||||
|
||||
### Configuration
|
||||
|
||||
```toml
|
||||
[frontends.http.auth]
|
||||
enabled = false
|
||||
users_file = "trx-http-users.json"
|
||||
bootstrap_admin_username = "admin"
|
||||
bootstrap_admin_password = "change-this-password"
|
||||
bootstrap_read_enabled = true
|
||||
bootstrap_read_username = "guest"
|
||||
bootstrap_read_password = "guest"
|
||||
rx_passphrase = "rx-only-passphrase"
|
||||
control_passphrase = "full-control-passphrase"
|
||||
tx_access_control_enabled = true
|
||||
session_ttl_min = 480
|
||||
cookie_secure = false # true if served via HTTPS
|
||||
cookie_same_site = "Lax" # Strict|Lax|None
|
||||
```
|
||||
|
||||
When `enabled = false` (the default), all auth is bypassed and the UI behaves
|
||||
as before. When enabling it for the first time, bootstrap credentials create
|
||||
the initial administrator (with every non-Guest role), the default `guest`/`guest` Guest
|
||||
account, and the Argon2id-hashed user database. Change or disable the guest
|
||||
credentials in configuration before first startup on an exposed deployment.
|
||||
as before. When enabled, at least one passphrase must be set.
|
||||
|
||||
### Behaviour
|
||||
|
||||
- On login, the server issues an `HttpOnly` session cookie.
|
||||
- Sessions are in-memory; a server restart invalidates all sessions.
|
||||
- Rate limiting is applied per IP to mitigate brute-force attempts.
|
||||
- User records persist in `users_file`; passwords are stored as salted Argon2id hashes.
|
||||
- Non-Guest roles are independent; for example, an account may have Read and Write without Control.
|
||||
- Guest accounts have no account-control panels and cannot call account-control endpoints.
|
||||
- Every non-Guest signed-in user can change their own password in Settings > Account. This signs out all of their sessions.
|
||||
- Administrators can add, enable/disable, or remove users and change roles/passwords in Settings > Users.
|
||||
- At least one enabled administrator must always remain and cannot be disabled, removed, or demoted.
|
||||
- Disabling/removing an account or changing its password/roles revokes all of its sessions.
|
||||
- Existing account files migrate automatically: legacy accounts are enabled by default and legacy `user`/`admin` roles become Read/all roles.
|
||||
- When `tx_access_control_enabled = true`, TX/PTT controls are hidden and
|
||||
rejected for unauthenticated or `rx`-role users.
|
||||
|
||||
### Routes
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/auth/login` | POST | Submit `{ "username": "...", "password": "..." }` |
|
||||
| `/auth/login` | POST | Submit `{ "passphrase": "..." }` |
|
||||
| `/auth/logout` | POST | Clear session |
|
||||
| `/auth/session` | GET | Check current session/roles |
|
||||
| `/auth/account/password` | PATCH | Change a non-Guest user's password after verifying the current password |
|
||||
| `/auth/users` | GET/POST | List or add users (admin only) |
|
||||
| `/auth/users/{username}` | PATCH/DELETE | Change enabled state/password/roles or remove user (administrator only) |
|
||||
| `/auth/session` | GET | Check current session/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.
|
||||
Protected routes require at least `rx` role. Control routes (set frequency,
|
||||
mode, PTT, etc.) require `control` role.
|
||||
|
||||
### Frontend Flow
|
||||
|
||||
1. On load, the UI calls `/auth/session`.
|
||||
2. If unauthenticated, a login screen is shown.
|
||||
3. On successful login, the normal UI loads.
|
||||
4. The interface enables controls according to the account's roles.
|
||||
4. `rx` users see a read-only interface; `control` users get full controls.
|
||||
5. If a session expires mid-use, streams stop and the login screen returns.
|
||||
|
||||
### Transport Security
|
||||
@@ -752,30 +731,9 @@ 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 — 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.
|
||||
on raw IQ samples before any mixing or filtering takes place. It works by
|
||||
tracking a running RMS level of the signal and replacing any sample whose
|
||||
magnitude exceeds **threshold x RMS** with the last known clean sample.
|
||||
|
||||
### Configuration (server-side)
|
||||
|
||||
@@ -792,7 +750,6 @@ 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]`:
|
||||
@@ -801,30 +758,15 @@ 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 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. |
|
||||
| Field | Type | Default | Range | Description |
|
||||
|-------------|-------|---------|---------|-------------|
|
||||
| `enabled` | bool | false | — | Turn the noise blanker on or off. |
|
||||
| `threshold` | float | 10.0 | 1 – 100 | Multiplier applied to the running RMS. A sample whose magnitude exceeds this multiple is replaced. Lower values blank more aggressively; higher values only catch strong impulses. |
|
||||
|
||||
The noise blanker is off by default.
|
||||
|
||||
### 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.
|
||||
@@ -850,44 +792,39 @@ the running average signal level.
|
||||
|
||||
### Web UI
|
||||
|
||||
When the server reports noise-blanker support, these controls appear in the
|
||||
When the server reports noise-blanker support, two controls appear in the
|
||||
**SDR Settings** row of the web interface:
|
||||
|
||||
- **Noise Blanker** checkbox — enables or disables the blanker in real time.
|
||||
The **N** keyboard shortcut toggles it too.
|
||||
- **NB Threshold** number input (1–100) with a **Set** button — adjusts the
|
||||
detection sensitivity. Press Enter or click Set to apply.
|
||||
- **NB profile** selector — chooses the profile (Spike / Ignition / Powerline /
|
||||
Broadband). Changing it applies immediately.
|
||||
detection threshold. Press Enter or click Set to apply.
|
||||
|
||||
The controls stay hidden until the server sends filter state containing NB
|
||||
Both controls stay hidden until the server sends filter state containing NB
|
||||
fields, so they only appear when connected to an SDR backend.
|
||||
|
||||
### HTTP API
|
||||
|
||||
```
|
||||
POST /set_sdr_noise_blanker?enabled=true&threshold=10&profile=spike
|
||||
POST /set_sdr_noise_blanker?enabled=true&threshold=10
|
||||
```
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-------------|--------|----------|-------------|
|
||||
| `enabled` | bool | yes | `true` or `false` |
|
||||
| `threshold` | float | yes | Value between 1 and 100 |
|
||||
| `profile` | string | no | `spike` (default), `ignition`, `powerline`, or `broadband` |
|
||||
|
||||
### How it works
|
||||
|
||||
The blanker runs on every IQ block *before* the mixer stage in the DSP pipeline,
|
||||
one sample at a time:
|
||||
The blanker runs on every IQ block (4096 samples) *before* the mixer stage in
|
||||
the DSP pipeline:
|
||||
|
||||
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.
|
||||
1. For each sample, compute magnitude² (`re² + im²`).
|
||||
2. Compare against `threshold² × mean_sq` (the exponentially-smoothed running
|
||||
mean of magnitude²).
|
||||
3. If the sample exceeds the threshold, replace it with the previous clean
|
||||
sample.
|
||||
4. Otherwise, update the running mean with smoothing factor α = 1/128 and store
|
||||
the sample as the last clean value.
|
||||
|
||||
Because the blanker operates on raw IQ before frequency translation, it removes
|
||||
impulse noise across the entire captured bandwidth regardless of the tuned
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
# 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
|
||||
@@ -1,38 +0,0 @@
|
||||
# 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
|
||||
@@ -1,156 +0,0 @@
|
||||
#!/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."
|
||||
@@ -1,86 +0,0 @@
|
||||
#!/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."
|
||||
@@ -45,20 +45,8 @@ impl WefaxConfig {
|
||||
60.0 / lpm as f32
|
||||
}
|
||||
|
||||
/// Samples per line at the internal sample rate (rounded to an integer;
|
||||
/// use [`Self::samples_per_line_f64`] for drift-free line slicing).
|
||||
/// Samples per line at the internal sample rate.
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,24 +38,10 @@ 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 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;
|
||||
/// 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;
|
||||
|
||||
/// Maximum number of scan-line-equivalent sample windows to wait for phasing
|
||||
/// lock before falling through to Receiving. Typical WEFAX phasing lasts
|
||||
@@ -121,12 +107,6 @@ 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).
|
||||
@@ -155,7 +135,6 @@ 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(),
|
||||
}
|
||||
@@ -225,13 +204,7 @@ 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.
|
||||
//
|
||||
// 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 {
|
||||
if self.state == State::Idle {
|
||||
self.signal_detect_buf.extend_from_slice(&luminance);
|
||||
let window_size = INTERNAL_RATE as usize / 2;
|
||||
while self.signal_detect_buf.len() >= window_size {
|
||||
@@ -367,14 +340,6 @@ 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 =
|
||||
@@ -437,7 +402,6 @@ impl WefaxDecoder {
|
||||
self.signal_detect_buf.clear();
|
||||
self.low_corr_lines = 0;
|
||||
self.phasing_samples = 0;
|
||||
self.auto_start_used = false;
|
||||
events
|
||||
}
|
||||
|
||||
@@ -470,7 +434,6 @@ 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()
|
||||
@@ -500,7 +463,6 @@ 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)
|
||||
}
|
||||
@@ -634,47 +596,4 @@ 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ use crate::config::WefaxConfig;
|
||||
|
||||
/// Line slicer for WEFAX image assembly.
|
||||
pub struct LineSlicer {
|
||||
/// Exact (fractional) samples per line at the internal sample rate.
|
||||
samples_per_line: f64,
|
||||
/// Samples per line at the internal sample rate.
|
||||
samples_per_line: usize,
|
||||
/// Pixels per line (IOC × π).
|
||||
pixels_per_line: usize,
|
||||
/// Phase offset in samples from the phasing detector.
|
||||
@@ -22,37 +22,22 @@ 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_f64(lpm, sample_rate);
|
||||
let samples_per_line = WefaxConfig::samples_per_line(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 as usize * 2),
|
||||
buffer: Vec::with_capacity(samples_per_line * 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
|
||||
@@ -71,18 +56,12 @@ 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;
|
||||
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;
|
||||
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;
|
||||
}
|
||||
if offset > 0 {
|
||||
self.buffer.drain(..offset);
|
||||
@@ -98,7 +77,6 @@ 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
|
||||
@@ -151,29 +129,6 @@ 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;
|
||||
|
||||
@@ -77,12 +77,8 @@ 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 = 2.0;
|
||||
let min_sustain_s = 1.0; // fldigi uses 2 consecutive half-second windows
|
||||
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;
|
||||
|
||||
|
||||
@@ -252,17 +252,11 @@ async fn async_init() -> DynResult<AppState> {
|
||||
|
||||
// Set HTTP frontend authentication config
|
||||
frontend_runtime.http_auth.enabled = cfg.frontends.http.auth.enabled;
|
||||
frontend_runtime.http_auth.users_file = cfg.frontends.http.auth.users_file.clone();
|
||||
frontend_runtime.http_auth.bootstrap_admin_username =
|
||||
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.rx_passphrase = cfg.frontends.http.auth.rx_passphrase.clone();
|
||||
frontend_runtime.http_auth.control_passphrase =
|
||||
cfg.frontends.http.auth.control_passphrase.clone();
|
||||
frontend_runtime.http_auth.tx_access_control_enabled =
|
||||
cfg.frontends.http.auth.tx_access_control_enabled;
|
||||
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 {
|
||||
|
||||
@@ -257,12 +257,9 @@ impl Default for DecodeHistoryContext {
|
||||
/// HTTP authentication configuration.
|
||||
pub struct HttpAuthConfig {
|
||||
pub enabled: bool,
|
||||
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 rx_passphrase: Option<String>,
|
||||
pub control_passphrase: Option<String>,
|
||||
pub tx_access_control_enabled: bool,
|
||||
pub session_ttl_secs: u64,
|
||||
pub cookie_secure: bool,
|
||||
pub cookie_same_site: String,
|
||||
@@ -274,12 +271,9 @@ impl Default for HttpAuthConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
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()),
|
||||
rx_passphrase: None,
|
||||
control_passphrase: None,
|
||||
tx_access_control_enabled: true,
|
||||
session_ttl_secs: 480 * 60,
|
||||
cookie_secure: false,
|
||||
cookie_same_site: "Lax".to_string(),
|
||||
|
||||
@@ -28,7 +28,6 @@ flate2 = { workspace = true }
|
||||
brotli = "7"
|
||||
rand = "0.8"
|
||||
hex = "0.4"
|
||||
argon2 = "0.5"
|
||||
pickledb = "0.5"
|
||||
dirs = "6"
|
||||
uuid = { workspace = true }
|
||||
|
||||
@@ -1,20 +1,3 @@
|
||||
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";
|
||||
@@ -1331,6 +1314,60 @@ 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 !== "rx" && session.role !== "control") {
|
||||
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.auth_disabled !== void 0) decoded.auth_disabled = session.auth_disabled;
|
||||
return decoded;
|
||||
}
|
||||
var authDisabledSession = {
|
||||
authenticated: true,
|
||||
role: "control",
|
||||
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(passphrase) {
|
||||
const response = await fetch("/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ passphrase })
|
||||
});
|
||||
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 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);
|
||||
@@ -1825,67 +1862,33 @@ 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 authRoles = [];
|
||||
var authUsername = null;
|
||||
var authRole = 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();
|
||||
}
|
||||
async function authLogin(username, password) {
|
||||
return login(username, password);
|
||||
async function authLogin(passphrase) {
|
||||
return login(passphrase);
|
||||
}
|
||||
async function authLogout() {
|
||||
try {
|
||||
await logout();
|
||||
setAuthRoles([]);
|
||||
authUsername = null;
|
||||
authRole = null;
|
||||
disconnect();
|
||||
setDecodeHistoryOverlayVisible(false);
|
||||
requiredElement("content").style.display = "none";
|
||||
requiredElement("loading").style.display = "none";
|
||||
requiredElement("auth-password").value = "";
|
||||
requiredElement("auth-passphrase").value = "";
|
||||
updateAuthUI();
|
||||
showAuthGate();
|
||||
const authStatus = await checkAuthStatus();
|
||||
const allowGuest = authStatus.role === "rx";
|
||||
showAuthGate(allowGuest);
|
||||
} catch (e) {
|
||||
console.error("Logout failed:", e);
|
||||
showAuthError("Logout failed");
|
||||
}
|
||||
}
|
||||
function showAuthGate() {
|
||||
function showAuthGate(allowGuest = false) {
|
||||
if (!authEnabled) return;
|
||||
setDecodeHistoryOverlayVisible(false);
|
||||
requiredElement("loading").style.display = "none";
|
||||
@@ -1902,6 +1905,10 @@ function showAuthGate() {
|
||||
document.querySelectorAll(".tab-panel").forEach((panel) => {
|
||||
panel.style.display = "none";
|
||||
});
|
||||
const guestBtn2 = document.getElementById("auth-guest-btn");
|
||||
if (guestBtn2) {
|
||||
guestBtn2.style.display = allowGuest ? "block" : "none";
|
||||
}
|
||||
document.querySelectorAll(".tab-bar .tab").forEach((btn) => {
|
||||
btn.classList.toggle("active", btn.dataset.tab === "main");
|
||||
});
|
||||
@@ -1937,30 +1944,20 @@ 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 (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 (authRole) {
|
||||
if (badge) badge.style.display = "block";
|
||||
if (badgeRole) badgeRole.textContent = `${authUsername || "local"} — ${authRoles.map((role) => AUTH_ROLE_LABELS[role]).join(", ")}`;
|
||||
if (badgeRole) badgeRole.textContent = authRole === "control" ? "Control (full access)" : "RX (read-only)";
|
||||
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";
|
||||
@@ -1970,34 +1967,31 @@ function updateAuthUI() {
|
||||
syncTopBarAccess();
|
||||
}
|
||||
function applyAuthRestrictions() {
|
||||
if (authRoles.length === 0) return;
|
||||
if (!hasAuthRole2("transmit")) {
|
||||
if (!authRole) return;
|
||||
if (authRole === "rx") {
|
||||
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;
|
||||
@@ -2034,6 +2028,7 @@ function applyAuthRestrictions() {
|
||||
btn.disabled = true;
|
||||
}
|
||||
});
|
||||
if (txLimitRow2) txLimitRow2.style.opacity = "0.5";
|
||||
}
|
||||
}
|
||||
function applyCapabilities(caps) {
|
||||
@@ -2263,21 +2258,20 @@ window.applyDecodeHistoryRetention = function() {
|
||||
}
|
||||
};
|
||||
function syncTopBarAccess() {
|
||||
const loggedOut = authEnabled && authRoles.length === 0;
|
||||
const loggedOut = authEnabled && !authRole;
|
||||
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";
|
||||
const lacksLogbookAccess = authEnabled && btn.dataset.tab === "logbook" && !hasAuthRole2("write");
|
||||
btn.style.display = (!loggedOut || isMain) && !lacksLogbookAccess ? "" : "none";
|
||||
btn.style.display = !loggedOut || isMain ? "" : "none";
|
||||
btn.disabled = false;
|
||||
});
|
||||
if (rigSwitch) {
|
||||
rigSwitch.style.display = loggedOut ? "none" : "";
|
||||
}
|
||||
if (headerRigSwitchSelect) {
|
||||
headerRigSwitchSelect.disabled = loggedOut || !hasAuthRole2("control") || lastRigIds.length === 0;
|
||||
headerRigSwitchSelect.disabled = loggedOut || authRole === "rx" || lastRigIds.length === 0;
|
||||
}
|
||||
}
|
||||
var overviewDrawPending = false;
|
||||
@@ -2913,7 +2907,7 @@ function applyRigList(activeRigId, rigIds, displayNames) {
|
||||
}
|
||||
const nextKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || "");
|
||||
const rigListChanged = prevKey !== nextKey;
|
||||
const disableSwitch = lastRigIds.length === 0 || !hasAuthRole2("control");
|
||||
const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx";
|
||||
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
|
||||
updateRigSubtitle(lastActiveRigId);
|
||||
window.trxUi?.setActiveRig(lastActiveRigId);
|
||||
@@ -3729,19 +3723,10 @@ 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 === "DIG") return sdrDigSidebandSupported ? digSidebandDirection() : 1;
|
||||
if (modeUpper === "USB" || modeUpper === "CW") return 1;
|
||||
if (modeUpper === "USB" || modeUpper === "CW" || modeUpper === "DIG") return 1;
|
||||
return 0;
|
||||
}
|
||||
function displaySpanForBandwidthSpec(spec, mode = modeEl ? modeEl.value : "") {
|
||||
@@ -4260,16 +4245,9 @@ function formatSignal(sUnits) {
|
||||
return overDb === 0 ? `${sigUnit("S")}9` : `${sigUnit("S")}9+${overDb}${sigUnit("dB")}`;
|
||||
}
|
||||
function setDisabled(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;
|
||||
[freqEl, centerFreqEl, modeEl, pttBtn, powerBtn, txLimitInput, txLimitBtn, lockBtn].forEach((el) => {
|
||||
if (el) el.disabled = disabled;
|
||||
});
|
||||
[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;
|
||||
@@ -4532,7 +4510,7 @@ function scheduleTuneLinkSync() {
|
||||
async function applyTuneLink(link) {
|
||||
const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null;
|
||||
if (!wanted) return;
|
||||
if (!hasAuthRole2("control")) {
|
||||
if (authRole === "rx") {
|
||||
showHint("Read-only session — link not applied", 2500);
|
||||
return;
|
||||
}
|
||||
@@ -4766,25 +4744,6 @@ 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") {
|
||||
@@ -5269,7 +5228,7 @@ async function postPath(path, options = {}) {
|
||||
}
|
||||
const resp = await fetch(path, { method: "POST" });
|
||||
if (authEnabled && resp.status === 401) {
|
||||
setAuthRoles([]);
|
||||
authRole = null;
|
||||
if (es) es.close();
|
||||
showAuthGate();
|
||||
throw new Error("Authentication required");
|
||||
@@ -5294,7 +5253,7 @@ async function switchRigFromSelect(selectEl) {
|
||||
showHint("No rig selected", 1500);
|
||||
return;
|
||||
}
|
||||
if (!hasAuthRole2("control")) {
|
||||
if (authRole === "rx") {
|
||||
showHint("Control role required", 1500);
|
||||
return;
|
||||
}
|
||||
@@ -5908,13 +5867,8 @@ function navigateToTab(name, options = {}) {
|
||||
window.trxUi?.closeMobileOverlays?.();
|
||||
const leavingSatellites = _activeTab === "satellites" && name !== "satellites";
|
||||
const { updateHistory = true, replaceHistory = false } = options;
|
||||
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);
|
||||
if (authEnabled && !authRole && name !== "main") {
|
||||
showAuthGate(false);
|
||||
return;
|
||||
}
|
||||
const btn = document.querySelector(`.tab-bar .tab[data-tab="${name}"]`);
|
||||
@@ -6027,11 +5981,11 @@ window.addEventListener("resize", () => {
|
||||
scheduleSpectrumLayout();
|
||||
});
|
||||
async function initializeApp() {
|
||||
showAuthGate();
|
||||
showAuthGate(false);
|
||||
const authStatus = await checkAuthStatus();
|
||||
authEnabled = !authStatus.auth_disabled;
|
||||
if (!authEnabled) {
|
||||
setAuthRoles(AUTH_ADMIN_ROLES);
|
||||
authRole = "control";
|
||||
hideAuthGate();
|
||||
updateAuthUI();
|
||||
connect();
|
||||
@@ -6042,8 +5996,7 @@ async function initializeApp() {
|
||||
return;
|
||||
}
|
||||
if (authStatus.authenticated) {
|
||||
setAuthRoles(authStatus.roles);
|
||||
authUsername = authStatus.username ?? null;
|
||||
authRole = authStatus.role ?? null;
|
||||
hideAuthGate();
|
||||
updateAuthUI();
|
||||
applyAuthRestrictions();
|
||||
@@ -6053,191 +6006,32 @@ async function initializeApp() {
|
||||
resizeHeaderSignalCanvas();
|
||||
startHeaderSignalSampling();
|
||||
} else {
|
||||
showAuthGate();
|
||||
const allowGuest = authStatus.role === "rx";
|
||||
showAuthGate(allowGuest);
|
||||
}
|
||||
}
|
||||
var settingsUiReady = false;
|
||||
function initSettingsUI() {
|
||||
settingsUiReady = true;
|
||||
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRoles);
|
||||
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRole);
|
||||
window.trx.modules.scheduler?.wireEvents();
|
||||
if (window.trx.modules.backgroundDecode) {
|
||||
window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRoles);
|
||||
window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRole);
|
||||
window.trx.modules.backgroundDecode.wireEvents();
|
||||
}
|
||||
void refreshUserManagement();
|
||||
}
|
||||
async function refreshUserManagement() {
|
||||
const section = document.getElementById("user-management");
|
||||
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";
|
||||
row.style.cssText = "display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;margin:.4rem 0";
|
||||
const name = document.createElement("strong");
|
||||
name.textContent = user.username;
|
||||
name.style.minWidth = "10rem";
|
||||
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 (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 = {
|
||||
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 || 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, enabledLabel, roles, password, save, remove);
|
||||
return row;
|
||||
}));
|
||||
} catch (error) {
|
||||
showUserManagementError(error);
|
||||
}
|
||||
}
|
||||
function showUserManagementError(error) {
|
||||
const element = document.getElementById("user-management-error");
|
||||
if (!element) return;
|
||||
element.textContent = error instanceof Error ? error.message : String(error);
|
||||
element.style.display = "block";
|
||||
}
|
||||
async function runUserOperation(operation) {
|
||||
try {
|
||||
await operation();
|
||||
const error = document.getElementById("user-management-error");
|
||||
if (error) error.style.display = "none";
|
||||
await refreshUserManagement();
|
||||
} catch (reason) {
|
||||
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 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, roles.filter((input) => input.checked).map((input) => input.value), 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;
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
const usernameEl = requiredElement("auth-username");
|
||||
const passwordEl = requiredElement("auth-password");
|
||||
const passphraseEl = requiredElement("auth-passphrase");
|
||||
const passphrase = passphraseEl.value;
|
||||
const btn = requiredElement("auth-form").querySelector("button[type=submit]");
|
||||
if (!btn) return;
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Logging in...";
|
||||
try {
|
||||
const result = await authLogin(usernameEl.value, passwordEl.value);
|
||||
setAuthRoles(result.roles);
|
||||
authUsername = result.username ?? usernameEl.value;
|
||||
passwordEl.value = "";
|
||||
const result = await authLogin(passphrase);
|
||||
authRole = result.role ?? null;
|
||||
passphraseEl.value = "";
|
||||
hideAuthGate();
|
||||
updateAuthUI();
|
||||
applyAuthRestrictions();
|
||||
@@ -6247,22 +6041,37 @@ requiredElement("auth-form").addEventListener("submit", async (e) => {
|
||||
resizeHeaderSignalCanvas();
|
||||
startHeaderSignalSampling();
|
||||
} catch (err) {
|
||||
showAuthError("Invalid username or password");
|
||||
showAuthError("Invalid passphrase");
|
||||
console.error("Login error:", err);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Login";
|
||||
}
|
||||
});
|
||||
var guestBtn = document.getElementById("auth-guest-btn");
|
||||
if (guestBtn) {
|
||||
guestBtn.addEventListener("click", () => {
|
||||
authRole = "rx";
|
||||
requiredElement("auth-passphrase").value = "";
|
||||
hideAuthGate();
|
||||
updateAuthUI();
|
||||
applyAuthRestrictions();
|
||||
connect();
|
||||
connectDecode();
|
||||
initSettingsUI();
|
||||
resizeHeaderSignalCanvas();
|
||||
startHeaderSignalSampling();
|
||||
});
|
||||
}
|
||||
var headerAuthBtn = document.getElementById("header-auth-btn");
|
||||
if (headerAuthBtn) {
|
||||
headerAuthBtn.addEventListener("click", async () => {
|
||||
if (authRoles.length > 0) {
|
||||
if (authRole) {
|
||||
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();
|
||||
}
|
||||
} else {
|
||||
showAuthGate();
|
||||
showAuthGate(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -6300,8 +6109,8 @@ Object.defineProperties(trxState, {
|
||||
authEnabled: { get() {
|
||||
return authEnabled;
|
||||
} },
|
||||
authRoles: { get() {
|
||||
return authRoles;
|
||||
authRole: { get() {
|
||||
return authRole;
|
||||
} },
|
||||
decoderRegistry: { get() {
|
||||
return decoderRegistry;
|
||||
@@ -6651,15 +6460,7 @@ 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(() => {
|
||||
@@ -7029,7 +6830,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)}&profile=${encodeURIComponent(sdrNbProfile)}`
|
||||
`/set_sdr_noise_blanker?enabled=${enabled ? "true" : "false"}&threshold=${encodeURIComponent(threshold)}`
|
||||
).catch(() => {
|
||||
});
|
||||
}
|
||||
@@ -7038,16 +6839,6 @@ 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);
|
||||
@@ -7065,28 +6856,11 @@ 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;
|
||||
@@ -7408,10 +7182,6 @@ function startTxAudio() {
|
||||
void stopTxAudio();
|
||||
return;
|
||||
}
|
||||
if (authEnabled && !hasAuthRole2("transmit")) {
|
||||
audioStatus.textContent = "Transmit role required";
|
||||
return;
|
||||
}
|
||||
if (!hasWebCodecs) {
|
||||
audioStatus.textContent = "Audio requires Chrome/Edge";
|
||||
return;
|
||||
|
||||
+4
-7
@@ -1,6 +1,3 @@
|
||||
import {
|
||||
hasAuthRole
|
||||
} from "./chunk-PISLBJGN.js";
|
||||
import {
|
||||
hostState
|
||||
} from "./chunk-KL66PICH.js";
|
||||
@@ -16,7 +13,7 @@ var bgdWindow = window;
|
||||
return d.id;
|
||||
});
|
||||
}
|
||||
let backgroundDecodeRoles = [];
|
||||
let backgroundDecodeRole = null;
|
||||
let currentRigId = null;
|
||||
let currentConfig = null;
|
||||
let bookmarkList = [];
|
||||
@@ -24,8 +21,8 @@ var bgdWindow = window;
|
||||
let bgdDirty = false;
|
||||
let statusByBookmark = /* @__PURE__ */ new Map();
|
||||
let lastStatus = null;
|
||||
function initBackgroundDecode(rigId, roles) {
|
||||
backgroundDecodeRoles = roles;
|
||||
function initBackgroundDecode(rigId, role) {
|
||||
backgroundDecodeRole = role;
|
||||
currentRigId = rigId || hostState.lastActiveRigId || null;
|
||||
if (currentRigId) loadBackgroundDecode();
|
||||
startStatusPolling();
|
||||
@@ -361,7 +358,7 @@ var bgdWindow = window;
|
||||
btn.title = bgdDirty ? "Apply these bookmarks to the background decoder" : "No changes to save";
|
||||
}
|
||||
function isControlRole() {
|
||||
return hasAuthRole(backgroundDecodeRoles, "control") || hostState.authEnabled === false;
|
||||
return backgroundDecodeRole === "control" || hostState.authEnabled === false;
|
||||
}
|
||||
function showToast(msg, isError) {
|
||||
const el = document.getElementById("background-decode-toast");
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import {
|
||||
hasAuthRole
|
||||
} from "./chunk-PISLBJGN.js";
|
||||
import {
|
||||
hostCore,
|
||||
hostState
|
||||
@@ -45,7 +42,7 @@ function bmEsc(str) {
|
||||
return d.innerHTML;
|
||||
}
|
||||
function bmCanControl() {
|
||||
return !hostState.authEnabled || hasAuthRole(hostState.authRoles, "write");
|
||||
return !hostState.authEnabled || hostState.authRole === "control";
|
||||
}
|
||||
function bmSyncAccess() {
|
||||
const canCtrl = bmCanControl();
|
||||
@@ -214,12 +211,6 @@ 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;
|
||||
@@ -233,9 +224,7 @@ 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();
|
||||
@@ -271,7 +260,6 @@ 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) {
|
||||
@@ -288,8 +276,7 @@ async function bmSave(e) {
|
||||
locator: locator || null,
|
||||
category,
|
||||
comment,
|
||||
decoders,
|
||||
dig_sideband
|
||||
decoders
|
||||
};
|
||||
try {
|
||||
let resp;
|
||||
@@ -354,12 +341,6 @@ 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));
|
||||
@@ -612,7 +593,6 @@ 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) {
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
// 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,6 +1,3 @@
|
||||
import {
|
||||
hasAuthRole
|
||||
} from "./chunk-PISLBJGN.js";
|
||||
import {
|
||||
hostCore,
|
||||
hostState
|
||||
@@ -50,9 +47,6 @@ 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);
|
||||
@@ -306,11 +300,6 @@ 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";
|
||||
@@ -431,19 +420,14 @@ importFile?.addEventListener("change", () => {
|
||||
if (file) void importAdif(file);
|
||||
importFile.value = "";
|
||||
});
|
||||
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";
|
||||
}
|
||||
bridge.logContact = (seed) => {
|
||||
bridge.navigateToTab?.("logbook");
|
||||
void openEntry(seed).then(() => callInput?.focus());
|
||||
};
|
||||
renderStation();
|
||||
if (cabrilloCallsign && !cabrilloCallsign.value) {
|
||||
cabrilloCallsign.value = stationCallEl?.textContent?.trim() ?? "";
|
||||
}
|
||||
syncCabrilloLink();
|
||||
if (canWriteLogbook()) void openEntry();
|
||||
void openEntry();
|
||||
void refreshLog();
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import {
|
||||
hasAuthRole
|
||||
} from "./chunk-PISLBJGN.js";
|
||||
import {
|
||||
hostState
|
||||
} from "./chunk-KL66PICH.js";
|
||||
@@ -18,7 +15,7 @@ function schedulerOptionalEl(id) {
|
||||
}
|
||||
(function() {
|
||||
"use strict";
|
||||
let schedulerRoles = [];
|
||||
let schedulerRole = null;
|
||||
let currentRigId = null;
|
||||
let currentConfig = null;
|
||||
let currentSchedulerStatus = null;
|
||||
@@ -28,8 +25,8 @@ function schedulerOptionalEl(id) {
|
||||
let schedulerStepPending = false;
|
||||
let schEntryEditIdx = null;
|
||||
let schedulerDirty = false;
|
||||
function initScheduler(rigId, roles) {
|
||||
schedulerRoles = roles;
|
||||
function initScheduler(rigId, role) {
|
||||
schedulerRole = role;
|
||||
currentRigId = rigId || null;
|
||||
if (currentRigId) loadScheduler();
|
||||
startStatusPolling();
|
||||
@@ -275,7 +272,7 @@ function schedulerOptionalEl(id) {
|
||||
const nextBtn = schedulerEl("scheduler-next-btn");
|
||||
if (!prevBtn || !nextBtn) return;
|
||||
const state = schedulerInterleaveState(currentConfig);
|
||||
const enabled = hasAuthRole(schedulerRoles, "control") && !!currentRigId && !schedulerStepPending && state.activeEntries.length > 1;
|
||||
const enabled = schedulerRole === "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";
|
||||
@@ -357,7 +354,7 @@ function schedulerOptionalEl(id) {
|
||||
const panel = schedulerEl("scheduler-panel");
|
||||
if (!panel) return;
|
||||
const mode = currentConfig && currentConfig.mode || "disabled";
|
||||
const isControl = hasAuthRole(schedulerRoles, "control");
|
||||
const isControl = schedulerRole === "control";
|
||||
setSelected("scheduler-mode-select", mode);
|
||||
const satEnabled = currentConfig && currentConfig.satellites && currentConfig.satellites.enabled;
|
||||
const controlRow = document.querySelector(".scheduler-control-row");
|
||||
@@ -1223,8 +1220,8 @@ function schedulerOptionalEl(id) {
|
||||
markDirty: markSchedulerDirty
|
||||
};
|
||||
schedulerWindow.trx.modules.scheduler = schedulerService;
|
||||
if (!hostState.authEnabled || hostState.authRoles.length > 0) {
|
||||
initScheduler(hostState.lastActiveRigId, hostState.authRoles);
|
||||
if (hostState.authRole != null) {
|
||||
initScheduler(hostState.lastActiveRigId, hostState.authRole);
|
||||
wireSchedulerEvents();
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -123,13 +123,13 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<div id="auth-gate" class="auth-gate" style="display:none;">
|
||||
<div class="auth-gate-head">
|
||||
<div class="auth-gate-title">Access Required</div>
|
||||
<div class="auth-gate-sub">Sign in to continue</div>
|
||||
<div class="auth-gate-sub">Enter passphrase to continue</div>
|
||||
</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" maxlength="1024" required />
|
||||
<input type="password" id="auth-passphrase" class="auth-input" placeholder="Passphrase" autocomplete="off" />
|
||||
<button type="submit" class="auth-submit">Login</button>
|
||||
</form>
|
||||
<button id="auth-guest-btn" type="button" class="auth-guest" style="display: none;">Continue as Guest</button>
|
||||
<div id="auth-error" class="auth-error" style="display: none;"></div>
|
||||
<div id="auth-role" class="auth-role" style="display: none;"></div>
|
||||
</div>
|
||||
@@ -385,23 +385,6 @@ 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 ≥ 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">
|
||||
@@ -738,14 +721,6 @@ 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>
|
||||
@@ -1486,8 +1461,6 @@ 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">
|
||||
@@ -1777,34 +1750,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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" 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tab-about" class="tab-panel" style="display:none;">
|
||||
<h2 class="section-heading">About</h2>
|
||||
|
||||
@@ -196,7 +196,8 @@ body {
|
||||
font-size: var(--fs-base);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.auth-submit {
|
||||
.auth-submit,
|
||||
.auth-guest {
|
||||
width: 100%;
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-radius: var(--radius-md);
|
||||
@@ -212,14 +213,16 @@ body {
|
||||
font-weight: 700;
|
||||
}
|
||||
.auth-submit:hover:not(:disabled) { background: var(--accent-green-hover); }
|
||||
.auth-guest {
|
||||
background: var(--btn-bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border-light);
|
||||
font-weight: 600;
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
.auth-guest:hover:not(:disabled) { background: var(--btn-hover-bg); }
|
||||
.auth-error { color: var(--accent-red); font-size: var(--fs-sm); margin-top: var(--space-4); }
|
||||
.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; }
|
||||
#tab-main .label > span {
|
||||
|
||||
@@ -12,13 +12,9 @@ use trx_core::rig::{
|
||||
RigAccessMethod, RigCapabilities, RigInfo, RigRxStatus, RigStatus, RigTxStatus, RigVfo,
|
||||
RigVfoEntry,
|
||||
};
|
||||
use trx_core::{
|
||||
DecoderConfig, DigSidebandPolicy, NoiseBlankerProfile, RdsData, RigFilterState, RigMode,
|
||||
RigSnapshot, WfmDenoiseLevel,
|
||||
};
|
||||
use trx_core::{DecoderConfig, 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};
|
||||
|
||||
@@ -52,8 +48,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
export!(RigStatus);
|
||||
export!(DecoderConfig);
|
||||
export!(WfmDenoiseLevel);
|
||||
export!(DigSidebandPolicy);
|
||||
export!(NoiseBlankerProfile);
|
||||
export!(RigFilterState);
|
||||
export!(RdsData);
|
||||
export!(SpectrumData);
|
||||
@@ -62,7 +56,6 @@ 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 && node tests/account-management.mjs && node tests/transmit-role.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",
|
||||
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -2,51 +2,11 @@
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
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 type AuthRole = "rx" | "control";
|
||||
|
||||
export interface AuthSession {
|
||||
authenticated: boolean;
|
||||
roles: AuthRole[];
|
||||
username?: string;
|
||||
role?: AuthRole;
|
||||
auth_disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -58,24 +18,21 @@ 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 !== "rx" && session.role !== "control") {
|
||||
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,
|
||||
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;
|
||||
}
|
||||
const decoded: AuthSession = { authenticated: session.authenticated };
|
||||
if (session.role !== undefined) decoded.role = session.role;
|
||||
if (session.auth_disabled !== undefined) decoded.auth_disabled = session.auth_disabled;
|
||||
return decoded;
|
||||
}
|
||||
|
||||
const authDisabledSession: AuthSession = {
|
||||
authenticated: true,
|
||||
roles: [...AUTH_ADMIN_ROLES],
|
||||
role: "control",
|
||||
auth_disabled: true,
|
||||
};
|
||||
|
||||
@@ -83,19 +40,19 @@ 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, roles: [] };
|
||||
if (!response.ok) return { authenticated: false };
|
||||
return decodeAuthSession(await response.json());
|
||||
} catch (error: unknown) {
|
||||
console.error("Auth check failed:", error);
|
||||
return { authenticated: false, roles: [] };
|
||||
return { authenticated: false };
|
||||
}
|
||||
}
|
||||
|
||||
export async function login(username: string, password: string): Promise<AuthSession> {
|
||||
export async function login(passphrase: string): Promise<AuthSession> {
|
||||
const response = await fetch("/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
body: JSON.stringify({ passphrase }),
|
||||
});
|
||||
if (response.status === 404) return authDisabledSession;
|
||||
if (!response.ok) {
|
||||
@@ -105,52 +62,6 @@ export async function login(username: string, password: string): Promise<AuthSes
|
||||
return decodeAuthSession(await response.json());
|
||||
}
|
||||
|
||||
export interface ManagedUser { username: string; roles: AuthRole[]; enabled: boolean }
|
||||
|
||||
async function userRequest(path: string, init?: RequestInit): Promise<Response> {
|
||||
const response = await fetch(path, init);
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({})) as { error?: string };
|
||||
throw new Error(payload.error || `User operation failed (${response.status})`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function listUsers(): Promise<ManagedUser[]> {
|
||||
const value: unknown = await userRequest("/auth/users").then(response => response.json());
|
||||
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"
|
||||
&& 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[]).map(user => ({ ...user, roles: normalizeAuthRoles(user.roles) }));
|
||||
}
|
||||
|
||||
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; 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" });
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
const response = await fetch("/auth/logout", { method: "POST" });
|
||||
if (response.status !== 404 && !response.ok) throw new Error("Logout failed");
|
||||
|
||||
@@ -55,21 +55,7 @@ export type DecoderConfig = { aprs_decode_enabled: boolean, hf_aprs_decode_enabl
|
||||
|
||||
export type WfmDenoiseLevel = "off" | "auto" | "low" | "medium" | "high";
|
||||
|
||||
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,
|
||||
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,
|
||||
/**
|
||||
* Co-Channel Interference level (0–100 scale).
|
||||
*/
|
||||
@@ -137,8 +123,6 @@ 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 = {
|
||||
|
||||
@@ -21,17 +21,6 @@ import {
|
||||
fetchAuthSession,
|
||||
login,
|
||||
logout,
|
||||
listUsers,
|
||||
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,
|
||||
@@ -200,8 +189,8 @@ interface TrxModules {
|
||||
reverseGeocodeLocation(lat: number, lon: number, grid: string): void;
|
||||
bandForHz(frequencyHz: number): unknown;
|
||||
};
|
||||
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 };
|
||||
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 };
|
||||
bookmarks?: {
|
||||
readonly overlayList: readonly Bookmark[];
|
||||
readonly overlayRevision: number;
|
||||
@@ -237,7 +226,7 @@ interface TrxState {
|
||||
readonly initialMapZoom: number;
|
||||
readonly decodeHistoryRetentionMin: number;
|
||||
readonly authEnabled: boolean;
|
||||
readonly authRoles: readonly AuthRole[];
|
||||
readonly authRole: AuthRole | null;
|
||||
readonly decoderRegistry: typeof decoderRegistry;
|
||||
readonly sseSessionId: string | null;
|
||||
readonly primaryRds: RdsData | null;
|
||||
@@ -417,76 +406,40 @@ declare global {
|
||||
void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
|
||||
|
||||
// --- Authentication ---
|
||||
let authRoles: AuthRole[] = [];
|
||||
let authUsername: string | null = null;
|
||||
let authRole: AuthRole | 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();
|
||||
}
|
||||
|
||||
async function authLogin(username: string, password: string) {
|
||||
return login(username, password);
|
||||
async function authLogin(passphrase: string) {
|
||||
return login(passphrase);
|
||||
}
|
||||
|
||||
async function authLogout() {
|
||||
try {
|
||||
await logout();
|
||||
setAuthRoles([]);
|
||||
authUsername = null;
|
||||
authRole = null;
|
||||
// Disconnect and show auth gate without page reload
|
||||
disconnect();
|
||||
setDecodeHistoryOverlayVisible(false);
|
||||
requiredElement("content").style.display = "none";
|
||||
requiredElement("loading").style.display = "none";
|
||||
requiredElement<HTMLInputElement>("auth-password").value = "";
|
||||
requiredElement<HTMLInputElement>("auth-passphrase").value = "";
|
||||
updateAuthUI();
|
||||
|
||||
showAuthGate();
|
||||
// Check if guest mode is available after logout
|
||||
const authStatus = await checkAuthStatus();
|
||||
const allowGuest = authStatus.role === "rx";
|
||||
showAuthGate(allowGuest);
|
||||
} catch (e) {
|
||||
console.error("Logout failed:", e);
|
||||
showAuthError("Logout failed");
|
||||
}
|
||||
}
|
||||
|
||||
function showAuthGate() {
|
||||
function showAuthGate(allowGuest = false) {
|
||||
if (!authEnabled) return;
|
||||
setDecodeHistoryOverlayVisible(false);
|
||||
requiredElement("loading").style.display = "none";
|
||||
@@ -506,6 +459,12 @@ function showAuthGate() {
|
||||
panel.style.display = "none";
|
||||
});
|
||||
|
||||
// Show guest button if guest mode is available
|
||||
const guestBtn = document.getElementById("auth-guest-btn");
|
||||
if (guestBtn) {
|
||||
guestBtn.style.display = allowGuest ? "block" : "none";
|
||||
}
|
||||
|
||||
document.querySelectorAll<HTMLElement>(".tab-bar .tab").forEach((btn) => {
|
||||
btn.classList.toggle("active", btn.dataset.tab === "main");
|
||||
});
|
||||
@@ -547,32 +506,22 @@ 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 (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 (authRole) {
|
||||
if (badge) badge.style.display = "block";
|
||||
if (badgeRole) badgeRole.textContent = `${authUsername || "local"} — ${authRoles.map(role => AUTH_ROLE_LABELS[role]).join(", ")}`;
|
||||
if (badgeRole) badgeRole.textContent = authRole === "control" ? "Control (full access)" : "RX (read-only)";
|
||||
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";
|
||||
@@ -583,40 +532,37 @@ function updateAuthUI() {
|
||||
}
|
||||
|
||||
function applyAuthRestrictions() {
|
||||
if (authRoles.length === 0) return;
|
||||
if (!authRole) return;
|
||||
|
||||
if (!hasAuthRole("transmit")) {
|
||||
// Disable TX/PTT/frequency/mode/VFO controls for rx role
|
||||
if (authRole === "rx") {
|
||||
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);
|
||||
@@ -660,6 +606,9 @@ function applyAuthRestrictions() {
|
||||
btn.disabled = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Hide TX-specific UI but keep controls visible (disabled)
|
||||
if (txLimitRow) txLimitRow.style.opacity = "0.5";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -918,15 +867,14 @@ window.applyDecodeHistoryRetention = function() {
|
||||
};
|
||||
|
||||
function syncTopBarAccess() {
|
||||
const loggedOut = authEnabled && authRoles.length === 0;
|
||||
const loggedOut = authEnabled && !authRole;
|
||||
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";
|
||||
const lacksLogbookAccess = authEnabled && btn.dataset.tab === "logbook" && !hasAuthRole("write");
|
||||
btn.style.display = (!loggedOut || isMain) && !lacksLogbookAccess ? "" : "none";
|
||||
btn.style.display = !loggedOut || isMain ? "" : "none";
|
||||
btn.disabled = false;
|
||||
});
|
||||
|
||||
@@ -935,7 +883,7 @@ function syncTopBarAccess() {
|
||||
}
|
||||
|
||||
if (headerRigSwitchSelect) {
|
||||
headerRigSwitchSelect.disabled = loggedOut || !hasAuthRole("control") || lastRigIds.length === 0;
|
||||
headerRigSwitchSelect.disabled = loggedOut || authRole === "rx" || lastRigIds.length === 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1516,7 +1464,7 @@ function applyRigList(activeRigId: string | null, rigIds: string[], displayNames
|
||||
}
|
||||
const nextKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || "");
|
||||
const rigListChanged = prevKey !== nextKey;
|
||||
const disableSwitch = lastRigIds.length === 0 || !hasAuthRole("control");
|
||||
const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx";
|
||||
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
|
||||
updateRigSubtitle(lastActiveRigId);
|
||||
window.trxUi?.setActiveRig(lastActiveRigId);
|
||||
@@ -2450,26 +2398,10 @@ 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;
|
||||
// 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;
|
||||
if (modeUpper === "USB" || modeUpper === "CW" || modeUpper === "DIG") return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -3097,16 +3029,9 @@ function formatSignal(sUnits: number) {
|
||||
}
|
||||
|
||||
function setDisabled(disabled: boolean) {
|
||||
const controlDisabled = disabled || (authEnabled && !hasAuthRole("control"));
|
||||
const transmitDisabled = disabled || (authEnabled && !hasAuthRole("transmit"));
|
||||
[freqEl, centerFreqEl, modeEl, powerBtn, lockBtn].forEach((el) => {
|
||||
if (el) el.disabled = controlDisabled;
|
||||
[freqEl, centerFreqEl, modeEl, pttBtn, powerBtn, txLimitInput, txLimitBtn, lockBtn].forEach((el) => {
|
||||
if (el) el.disabled = disabled;
|
||||
});
|
||||
[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();
|
||||
}
|
||||
|
||||
@@ -3427,7 +3352,7 @@ function scheduleTuneLinkSync() {
|
||||
async function applyTuneLink(link: TuneLink) {
|
||||
const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null;
|
||||
if (!wanted) return;
|
||||
if (!hasAuthRole("control")) {
|
||||
if (authRole === "rx") {
|
||||
showHint("Read-only session — link not applied", 2500);
|
||||
return;
|
||||
}
|
||||
@@ -3696,26 +3621,6 @@ 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") {
|
||||
@@ -4257,7 +4162,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
|
||||
setAuthRoles([]);
|
||||
authRole = null;
|
||||
if (es) es.close();
|
||||
showAuthGate();
|
||||
throw new Error("Authentication required");
|
||||
@@ -4286,7 +4191,7 @@ async function switchRigFromSelect(selectEl: HTMLSelectElement) {
|
||||
showHint("No rig selected", 1500);
|
||||
return;
|
||||
}
|
||||
if (!hasAuthRole("control")) {
|
||||
if (authRole === "rx") {
|
||||
showHint("Control role required", 1500);
|
||||
return;
|
||||
}
|
||||
@@ -4969,13 +4874,8 @@ 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 && authRoles.length === 0 && name !== "main") {
|
||||
showAuthGate();
|
||||
return;
|
||||
}
|
||||
if (authEnabled && name === "logbook" && !hasAuthRole("write")) {
|
||||
showHint("Write role required for logbook access", 2500);
|
||||
navigateToTab("main", options);
|
||||
if (authEnabled && !authRole && name !== "main") {
|
||||
showAuthGate(false);
|
||||
return;
|
||||
}
|
||||
const btn = document.querySelector<HTMLElement>(`.tab-bar .tab[data-tab="${name}"]`);
|
||||
@@ -5108,12 +5008,12 @@ window.addEventListener("resize", () => { scheduleSpectrumLayout(); });
|
||||
|
||||
// --- Auth startup sequence ---
|
||||
async function initializeApp() {
|
||||
showAuthGate();
|
||||
showAuthGate(false);
|
||||
const authStatus = await checkAuthStatus();
|
||||
authEnabled = !authStatus.auth_disabled;
|
||||
|
||||
if (!authEnabled) {
|
||||
setAuthRoles(AUTH_ADMIN_ROLES);
|
||||
authRole = "control";
|
||||
hideAuthGate();
|
||||
updateAuthUI();
|
||||
connect();
|
||||
@@ -5126,8 +5026,7 @@ async function initializeApp() {
|
||||
|
||||
if (authStatus.authenticated) {
|
||||
// User has valid session
|
||||
setAuthRoles(authStatus.roles);
|
||||
authUsername = authStatus.username ?? null;
|
||||
authRole = authStatus.role ?? null;
|
||||
hideAuthGate();
|
||||
updateAuthUI();
|
||||
applyAuthRestrictions();
|
||||
@@ -5137,7 +5036,10 @@ async function initializeApp() {
|
||||
resizeHeaderSignalCanvas();
|
||||
startHeaderSignalSampling();
|
||||
} else {
|
||||
showAuthGate();
|
||||
// No valid session - show auth gate
|
||||
// Guest button is shown if guest mode is available (role granted without auth)
|
||||
const allowGuest = authStatus.role === "rx";
|
||||
showAuthGate(allowGuest);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5147,186 +5049,28 @@ let settingsUiReady = false;
|
||||
|
||||
function initSettingsUI() {
|
||||
settingsUiReady = true;
|
||||
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRoles);
|
||||
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRole);
|
||||
window.trx.modules.scheduler?.wireEvents();
|
||||
if (window.trx.modules.backgroundDecode) {
|
||||
window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRoles);
|
||||
window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRole);
|
||||
window.trx.modules.backgroundDecode.wireEvents();
|
||||
}
|
||||
void refreshUserManagement();
|
||||
}
|
||||
|
||||
async function refreshUserManagement() {
|
||||
const section = document.getElementById("user-management");
|
||||
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";
|
||||
row.style.cssText = "display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;margin:.4rem 0";
|
||||
const name = document.createElement("strong");
|
||||
name.textContent = user.username;
|
||||
name.style.minWidth = "10rem";
|
||||
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 (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: { 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 || 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, enabledLabel, roles, password, save, remove);
|
||||
return row;
|
||||
}));
|
||||
} catch (error) {
|
||||
showUserManagementError(error);
|
||||
}
|
||||
}
|
||||
|
||||
function showUserManagementError(error: unknown) {
|
||||
const element = document.getElementById("user-management-error");
|
||||
if (!element) return;
|
||||
element.textContent = error instanceof Error ? error.message : String(error);
|
||||
element.style.display = "block";
|
||||
}
|
||||
|
||||
async function runUserOperation(operation: () => Promise<void>) {
|
||||
try {
|
||||
await operation();
|
||||
const error = document.getElementById("user-management-error");
|
||||
if (error) error.style.display = "none";
|
||||
await refreshUserManagement();
|
||||
} catch (reason) {
|
||||
showUserManagementError(reason);
|
||||
}
|
||||
}
|
||||
|
||||
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 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, 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();
|
||||
const usernameEl = requiredElement<HTMLInputElement>("auth-username");
|
||||
const passwordEl = requiredElement<HTMLInputElement>("auth-password");
|
||||
const passphraseEl = requiredElement<HTMLInputElement>("auth-passphrase");
|
||||
const passphrase = passphraseEl.value;
|
||||
const btn = requiredElement<HTMLFormElement>("auth-form").querySelector<HTMLButtonElement>("button[type=submit]");
|
||||
if (!btn) return;
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Logging in...";
|
||||
|
||||
try {
|
||||
const result = await authLogin(usernameEl.value, passwordEl.value);
|
||||
setAuthRoles(result.roles);
|
||||
authUsername = result.username ?? usernameEl.value;
|
||||
passwordEl.value = "";
|
||||
const result = await authLogin(passphrase);
|
||||
authRole = result.role ?? null;
|
||||
passphraseEl.value = "";
|
||||
hideAuthGate();
|
||||
updateAuthUI();
|
||||
applyAuthRestrictions();
|
||||
@@ -5336,7 +5080,7 @@ requiredElement<HTMLFormElement>("auth-form").addEventListener("submit", async (
|
||||
resizeHeaderSignalCanvas();
|
||||
startHeaderSignalSampling();
|
||||
} catch (err) {
|
||||
showAuthError("Invalid username or password");
|
||||
showAuthError("Invalid passphrase");
|
||||
console.error("Login error:", err);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
@@ -5344,18 +5088,35 @@ requiredElement<HTMLFormElement>("auth-form").addEventListener("submit", async (
|
||||
}
|
||||
});
|
||||
|
||||
// Setup guest button
|
||||
const guestBtn = document.getElementById("auth-guest-btn") as HTMLButtonElement | null;
|
||||
if (guestBtn) {
|
||||
guestBtn.addEventListener("click", () => {
|
||||
authRole = "rx";
|
||||
requiredElement<HTMLInputElement>("auth-passphrase").value = "";
|
||||
hideAuthGate();
|
||||
updateAuthUI();
|
||||
applyAuthRestrictions();
|
||||
connect();
|
||||
connectDecode();
|
||||
initSettingsUI();
|
||||
resizeHeaderSignalCanvas();
|
||||
startHeaderSignalSampling();
|
||||
});
|
||||
}
|
||||
|
||||
// Setup header auth button (Login/Logout)
|
||||
const headerAuthBtn = document.getElementById("header-auth-btn") as HTMLButtonElement | null;
|
||||
if (headerAuthBtn) {
|
||||
headerAuthBtn.addEventListener("click", async () => {
|
||||
if (authRoles.length > 0) {
|
||||
if (authRole) {
|
||||
// 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();
|
||||
}
|
||||
} else {
|
||||
// Not logged in - show auth gate
|
||||
showAuthGate();
|
||||
showAuthGate(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -5377,7 +5138,7 @@ Object.defineProperties(trxState, {
|
||||
initialMapZoom: { get() { return initialMapZoom; } },
|
||||
decodeHistoryRetentionMin: { get() { return decodeHistoryRetentionMin; } },
|
||||
authEnabled: { get() { return authEnabled; } },
|
||||
authRoles: { get() { return authRoles; } },
|
||||
authRole: { get() { return authRole; } },
|
||||
decoderRegistry: { get() { return decoderRegistry; } },
|
||||
sseSessionId: { get() { return sseSessionId; } },
|
||||
primaryRds: { get() { return primaryRds; } },
|
||||
@@ -5646,20 +5407,7 @@ 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) => {
|
||||
@@ -6086,7 +5834,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)}&profile=${encodeURIComponent(sdrNbProfile)}`,
|
||||
`/set_sdr_noise_blanker?enabled=${enabled ? "true" : "false"}&threshold=${encodeURIComponent(threshold)}`,
|
||||
).catch(() => {});
|
||||
}
|
||||
if (sdrNbEnabledEl) {
|
||||
@@ -6094,21 +5842,6 @@ 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);
|
||||
@@ -6126,21 +5859,6 @@ 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";
|
||||
@@ -6148,11 +5866,6 @@ 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
|
||||
@@ -6470,10 +6183,6 @@ 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;
|
||||
|
||||
+5
-6
@@ -3,7 +3,6 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
import { hostState } from "./host.js";
|
||||
import { hasAuthRole, type AuthRole } from "../api/auth.js";
|
||||
|
||||
export {};
|
||||
|
||||
@@ -45,7 +44,7 @@ interface BackgroundBridge {
|
||||
trx?: { modules?: { backgroundDecode?: BackgroundDecodeService } };
|
||||
}
|
||||
interface BackgroundDecodeService {
|
||||
initialize(rigId: string | null, roles: readonly AuthRole[]): void;
|
||||
initialize(rigId: string | null, role: string | null): void;
|
||||
wireEvents(): void;
|
||||
setRig(rigId: string | null): void;
|
||||
}
|
||||
@@ -61,7 +60,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
||||
.map(function (d) { return d.id; });
|
||||
}
|
||||
|
||||
let backgroundDecodeRoles: readonly AuthRole[] = [];
|
||||
let backgroundDecodeRole: string | null = null;
|
||||
let currentRigId: string | null = null;
|
||||
let currentConfig: BackgroundDecodeConfig | null = null;
|
||||
let bookmarkList: Bookmark[] = [];
|
||||
@@ -71,8 +70,8 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
||||
let statusByBookmark = new Map<string, BackgroundStatusEntry>();
|
||||
let lastStatus: BackgroundDecodeStatus | null = null;
|
||||
|
||||
function initBackgroundDecode(rigId: string | null, roles: readonly AuthRole[]): void {
|
||||
backgroundDecodeRoles = roles;
|
||||
function initBackgroundDecode(rigId: string | null, role: string | null): void {
|
||||
backgroundDecodeRole = role;
|
||||
// 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
|
||||
@@ -469,7 +468,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
||||
}
|
||||
|
||||
function isControlRole(): boolean {
|
||||
return hasAuthRole(backgroundDecodeRoles, "control") || hostState.authEnabled === false;
|
||||
return backgroundDecodeRole === "control" || hostState.authEnabled === false;
|
||||
}
|
||||
|
||||
function showToast(msg: string, isError: boolean): void {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
import { hostCore, hostState } from "./host.js";
|
||||
import { hasAuthRole } from "../api/auth.js";
|
||||
|
||||
export {};
|
||||
|
||||
@@ -19,8 +18,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -103,8 +100,7 @@ function bmEsc(str: unknown): string {
|
||||
}
|
||||
|
||||
function bmCanControl() {
|
||||
return !hostState.authEnabled
|
||||
|| hasAuthRole(hostState.authRoles, "write");
|
||||
return !hostState.authEnabled || hostState.authRole === "control";
|
||||
}
|
||||
|
||||
// Show/hide the Add Bookmark / Select All buttons based on the current auth role.
|
||||
@@ -323,14 +319,6 @@ 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;
|
||||
@@ -348,9 +336,7 @@ 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";
|
||||
@@ -395,9 +381,6 @@ 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 = "";
|
||||
@@ -418,7 +401,6 @@ async function bmSave(e: Event): Promise<void> {
|
||||
category,
|
||||
comment,
|
||||
decoders,
|
||||
dig_sideband,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -493,16 +475,6 @@ 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));
|
||||
@@ -796,9 +768,6 @@ 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,8 +11,6 @@
|
||||
// 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;
|
||||
@@ -27,7 +25,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 authRoles: readonly AuthRole[];
|
||||
readonly authRole: string | null;
|
||||
readonly lastActiveRigId: string | null;
|
||||
readonly lastRigIds: string[];
|
||||
readonly lastRigDisplayNames: Record<string, string>;
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
// keeping if the times in it are the radio's.
|
||||
|
||||
import { hostCore, hostState } from "./host.js";
|
||||
import { hasAuthRole } from "../api/auth.js";
|
||||
|
||||
export {};
|
||||
|
||||
@@ -121,11 +120,6 @@ 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);
|
||||
@@ -425,11 +419,6 @@ 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");
|
||||
@@ -558,20 +547,15 @@ importFile?.addEventListener("change", () => {
|
||||
});
|
||||
|
||||
/** Start an entry from a decode, and show the operator where it went. */
|
||||
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";
|
||||
}
|
||||
bridge.logContact = (seed) => {
|
||||
bridge.navigateToTab?.("logbook");
|
||||
void openEntry(seed).then(() => callInput?.focus());
|
||||
};
|
||||
|
||||
renderStation();
|
||||
if (cabrilloCallsign && !cabrilloCallsign.value) {
|
||||
cabrilloCallsign.value = stationCallEl?.textContent?.trim() ?? "";
|
||||
}
|
||||
syncCabrilloLink();
|
||||
if (canWriteLogbook()) void openEntry();
|
||||
void openEntry();
|
||||
void refreshLog();
|
||||
|
||||
+1
-2
@@ -3,7 +3,6 @@
|
||||
// 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";
|
||||
|
||||
@@ -61,7 +60,7 @@ export interface SchedulerStatus {
|
||||
}
|
||||
|
||||
export interface SchedulerService {
|
||||
initialize(rigId: string | null, roles: readonly AuthRole[]): void;
|
||||
initialize(rigId: string | null, role: string | null): void;
|
||||
destroy(): void;
|
||||
setRig(rigId: string | null): void;
|
||||
wireEvents(): void;
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
import { hostState } from "./host.js";
|
||||
import { hasAuthRole, type AuthRole } from "../api/auth.js";
|
||||
|
||||
import type {
|
||||
ScheduleEntry,
|
||||
@@ -44,7 +43,7 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
|
||||
// -------------------------------------------------------------------------
|
||||
// State
|
||||
// -------------------------------------------------------------------------
|
||||
let schedulerRoles: readonly AuthRole[] = [];
|
||||
let schedulerRole: string | null = null;
|
||||
let currentRigId: string | null = null;
|
||||
let currentConfig: SchedulerConfig | null = null;
|
||||
let currentSchedulerStatus: SchedulerStatus | null = null;
|
||||
@@ -59,8 +58,8 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
|
||||
// -------------------------------------------------------------------------
|
||||
// Init
|
||||
// -------------------------------------------------------------------------
|
||||
function initScheduler(rigId: string | null, roles: readonly AuthRole[]): void {
|
||||
schedulerRoles = roles;
|
||||
function initScheduler(rigId: string | null, role: string | null): void {
|
||||
schedulerRole = role;
|
||||
currentRigId = rigId || null;
|
||||
if (currentRigId) loadScheduler();
|
||||
startStatusPolling();
|
||||
@@ -357,7 +356,7 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
|
||||
if (!prevBtn || !nextBtn) return;
|
||||
const state = schedulerInterleaveState(currentConfig);
|
||||
const enabled =
|
||||
hasAuthRole(schedulerRoles, "control") &&
|
||||
schedulerRole === "control" &&
|
||||
!!currentRigId &&
|
||||
!schedulerStepPending &&
|
||||
state.activeEntries.length > 1;
|
||||
@@ -467,7 +466,7 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
|
||||
if (!panel) return;
|
||||
|
||||
const mode = (currentConfig && currentConfig.mode) || "disabled";
|
||||
const isControl = hasAuthRole(schedulerRoles, "control");
|
||||
const isControl = schedulerRole === "control";
|
||||
|
||||
// Mode selector
|
||||
setSelected("scheduler-mode-select", mode);
|
||||
@@ -1575,8 +1574,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.authEnabled || hostState.authRoles.length > 0) {
|
||||
initScheduler(hostState.lastActiveRigId, hostState.authRoles);
|
||||
if (hostState.authRole != null) {
|
||||
initScheduler(hostState.lastActiveRigId, hostState.authRole);
|
||||
wireSchedulerEvents();
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
// 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();
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
// 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",
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -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", ["administrator"]);
|
||||
window.trx.modules.backgroundDecode.initialize("rig/a", "control");
|
||||
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,
|
||||
authRoles: ["read", "control", "transmit", "write", "administrator"],
|
||||
authRole: "control",
|
||||
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, authRoles: ["read"] });
|
||||
const { window } = hostFixture({ authEnabled: true, authRole: "rx" });
|
||||
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.authRoles = ["read", "write"];
|
||||
window.trx.state.authRole = "control";
|
||||
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,
|
||||
authRoles: ["read", "control", "transmit", "write", "administrator"],
|
||||
authRole: "control",
|
||||
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: { authEnabled: true, authRoles: [] } }), trxUi: { confirm: async () => true } };
|
||||
const window = { ...createHost({ state: { authRole: null } }), 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: { authRoles: ["administrator"], lastActiveRigId: "sdr" } }),
|
||||
...createHost({ state: { authRole: "control", lastActiveRigId: "sdr" } }),
|
||||
trxUi: { confirm: async () => true },
|
||||
};
|
||||
const context = vm.createContext({
|
||||
|
||||
@@ -9,7 +9,6 @@ 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");
|
||||
@@ -31,30 +30,6 @@ 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"),
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
// 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,8 +145,6 @@ 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,
|
||||
@@ -234,8 +232,7 @@ export async function startWebFixture({
|
||||
};
|
||||
|
||||
const jsonRoutes = new Map([
|
||||
["/auth/session", authSession],
|
||||
["/auth/users", users],
|
||||
["/auth/session", { authenticated: true, role: "control", auth_disabled: true }],
|
||||
["/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_write};
|
||||
use super::{no_cache_response, request_accepts_html, require_control};
|
||||
use crate::server::status;
|
||||
|
||||
// ============================================================================
|
||||
@@ -37,7 +37,6 @@ 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.
|
||||
@@ -89,17 +88,6 @@ 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
|
||||
// ============================================================================
|
||||
@@ -177,7 +165,7 @@ pub async fn create_bookmark(
|
||||
body: web::Json<BookmarkInput>,
|
||||
auth_state: web::Data<crate::server::auth::AuthState>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
require_write(&req, &auth_state)?;
|
||||
require_control(&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(
|
||||
@@ -194,7 +182,6 @@ 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))
|
||||
@@ -214,7 +201,7 @@ pub async fn update_bookmark(
|
||||
body: web::Json<BookmarkInput>,
|
||||
auth_state: web::Data<crate::server::auth::AuthState>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
require_write(&req, &auth_state)?;
|
||||
require_control(&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)) {
|
||||
@@ -232,7 +219,6 @@ 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))
|
||||
@@ -249,7 +235,7 @@ pub async fn delete_bookmark(
|
||||
query: web::Query<BookmarkScopeQuery>,
|
||||
auth_state: web::Data<crate::server::auth::AuthState>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
require_write(&req, &auth_state)?;
|
||||
require_control(&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) {
|
||||
@@ -267,7 +253,7 @@ pub async fn batch_delete_bookmarks(
|
||||
query: web::Query<BookmarkScopeQuery>,
|
||||
auth_state: web::Data<crate::server::auth::AuthState>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
require_write(&req, &auth_state)?;
|
||||
require_control(&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 {
|
||||
@@ -286,7 +272,7 @@ pub async fn batch_move_bookmarks(
|
||||
query: web::Query<BookmarkScopeQuery>,
|
||||
auth_state: web::Data<crate::server::auth::AuthState>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
require_write(&req, &auth_state)?;
|
||||
require_control(&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_write};
|
||||
use super::{active_rig_id_from_context, require_control};
|
||||
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_write(&req, auth_state.get_ref())?;
|
||||
require_control(&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_write(&req, auth_state.get_ref())?;
|
||||
require_control(&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_write(&req, auth_state.get_ref())?;
|
||||
require_control(&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_write(&req, auth_state.get_ref())?;
|
||||
require_control(&req, auth_state.get_ref())?;
|
||||
match book(&logbook).import_adi(&body) {
|
||||
Ok(outcome) => Ok(HttpResponse::Ok().json(outcome)),
|
||||
Err(err) => Ok(HttpResponse::InternalServerError()
|
||||
|
||||
@@ -391,20 +391,16 @@ fn gz_cache_entry(src: &[u8], name: &str) -> GzCacheEntry {
|
||||
GzCacheEntry { gz, br, etag }
|
||||
}
|
||||
|
||||
fn require_write(
|
||||
fn require_control(
|
||||
req: &HttpRequest,
|
||||
auth_state: &crate::server::auth::AuthState,
|
||||
) -> Result<(), actix_web::Error> {
|
||||
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"))
|
||||
if !auth_state.config.enabled {
|
||||
return Ok(());
|
||||
}
|
||||
match crate::server::auth::get_session_role(req, auth_state) {
|
||||
Some(crate::server::auth::AuthRole::Control) => Ok(()),
|
||||
_ => Err(actix_web::error::ErrorForbidden("control role required")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -595,7 +591,6 @@ 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)
|
||||
@@ -714,11 +709,6 @@ 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)
|
||||
.service(crate::server::auth::delete_user)
|
||||
// Logbook
|
||||
.service(logbook::list_qsos)
|
||||
.service(logbook::add_qso)
|
||||
@@ -959,36 +949,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Auth off: every session may write without an account.
|
||||
/// Auth off: every session may write, as a station with no passphrase set.
|
||||
fn auth_state_disabled() -> crate::server::auth::AuthState {
|
||||
crate::server::auth::AuthState::new(crate::server::auth::AuthConfig::new(
|
||||
false,
|
||||
std::path::PathBuf::from("unused-users.json"),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
std::time::Duration::from_secs(3600),
|
||||
false,
|
||||
crate::server::auth::SameSite::Lax,
|
||||
))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Auth on with no session presented, which is what a listener is.
|
||||
fn auth_state_locked() -> crate::server::auth::AuthState {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
crate::server::auth::AuthState::new(crate::server::auth::AuthConfig::new(
|
||||
true,
|
||||
directory.path().join("users.json"),
|
||||
Some(crate::server::auth::BootstrapAccount::new(
|
||||
"admin".to_string(),
|
||||
"password123".to_string(),
|
||||
)),
|
||||
None,
|
||||
Some("listen".to_string()),
|
||||
Some("control".to_string()),
|
||||
false,
|
||||
std::time::Duration::from_secs(3600),
|
||||
false,
|
||||
crate::server::auth::SameSite::Lax,
|
||||
))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// A contact written over HTTP comes back out of the log, and out of an
|
||||
@@ -1180,16 +1164,10 @@ 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))
|
||||
.app_data(web::Data::new(auth_state_locked()))
|
||||
.service(logbook::add_qso)
|
||||
.service(logbook::list_qsos),
|
||||
)
|
||||
@@ -1199,10 +1177,6 @@ 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",
|
||||
}))
|
||||
@@ -1218,47 +1192,12 @@ 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::{DigSidebandPolicy, NoiseBlankerProfile, RigCommand, RigRequest, RigState};
|
||||
use trx_core::{RigCommand, RigRequest, RigState};
|
||||
use trx_frontend::{FrontendRuntimeContext, RemoteRigEntry};
|
||||
use trx_protocol::parse_mode;
|
||||
|
||||
@@ -279,10 +279,6 @@ 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>,
|
||||
}
|
||||
|
||||
@@ -297,30 +293,12 @@ 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,7 +737,6 @@ 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"));
|
||||
@@ -747,7 +746,6 @@ 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
|
||||
@@ -882,16 +880,6 @@ 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,10 +21,6 @@ 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,21 +592,6 @@ 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,31 +252,11 @@ 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(),
|
||||
bootstrap_admin,
|
||||
bootstrap_read,
|
||||
context.http_auth.rx_passphrase.clone(),
|
||||
context.http_auth.control_passphrase.clone(),
|
||||
context.http_auth.tx_access_control_enabled,
|
||||
Duration::from_secs(context.http_auth.session_ttl_secs),
|
||||
context.http_auth.cookie_secure,
|
||||
same_site,
|
||||
@@ -293,9 +273,7 @@ fn build_server(
|
||||
}
|
||||
|
||||
let context_data = web::Data::new(context);
|
||||
let auth_state = web::Data::new(
|
||||
AuthState::new(auth_config.clone()).map_err(actix_web::error::ErrorInternalServerError)?,
|
||||
);
|
||||
let auth_state = web::Data::new(AuthState::new(auth_config.clone()));
|
||||
|
||||
// Spawn session cleanup task if auth is enabled
|
||||
if auth_config.enabled {
|
||||
|
||||
+88
-121
@@ -268,21 +268,18 @@ impl AsRef<str> for CookieSameSite {
|
||||
pub struct HttpAuthConfig {
|
||||
/// Enable HTTP frontend authentication
|
||||
pub enabled: bool,
|
||||
/// JSON file containing the managed user database.
|
||||
pub users_file: String,
|
||||
/// Username used to create the first administrator when the database is absent.
|
||||
pub bootstrap_admin_username: Option<String>,
|
||||
/// Password used to create the first administrator when the database is absent.
|
||||
pub bootstrap_admin_password: Option<String>,
|
||||
/// Read the bootstrap administrator password from this file instead.
|
||||
/// Passphrase for read-only access (rx role)
|
||||
pub rx_passphrase: Option<String>,
|
||||
/// Read the rx passphrase 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>,
|
||||
pub rx_passphrase_file: Option<String>,
|
||||
/// Passphrase for full control access (control role)
|
||||
pub control_passphrase: Option<String>,
|
||||
/// Read the control passphrase from this file instead.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub control_passphrase_file: Option<String>,
|
||||
/// Enforce TX/PTT access control (hide from unauthenticated/rx users)
|
||||
pub tx_access_control_enabled: bool,
|
||||
/// Session time-to-live in minutes
|
||||
pub session_ttl_min: u64,
|
||||
/// Set Secure flag on session cookie (required for HTTPS)
|
||||
@@ -295,13 +292,11 @@ impl Default for HttpAuthConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
users_file: "trx-http-users.json".to_string(),
|
||||
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()),
|
||||
rx_passphrase: None,
|
||||
rx_passphrase_file: None,
|
||||
control_passphrase: None,
|
||||
control_passphrase_file: None,
|
||||
tx_access_control_enabled: true,
|
||||
session_ttl_min: 480,
|
||||
cookie_secure: false,
|
||||
cookie_same_site: CookieSameSite::Lax,
|
||||
@@ -807,9 +802,14 @@ impl ClientConfig {
|
||||
)?;
|
||||
}
|
||||
resolve_secret(
|
||||
&mut self.frontends.http.auth.bootstrap_admin_password,
|
||||
&self.frontends.http.auth.bootstrap_admin_password_file,
|
||||
"[frontends.http.auth].bootstrap_admin_password",
|
||||
&mut self.frontends.http.auth.rx_passphrase,
|
||||
&self.frontends.http.auth.rx_passphrase_file,
|
||||
"[frontends.http.auth].rx_passphrase",
|
||||
)?;
|
||||
resolve_secret(
|
||||
&mut self.frontends.http.auth.control_passphrase,
|
||||
&self.frontends.http.auth.control_passphrase_file,
|
||||
"[frontends.http.auth].control_passphrase",
|
||||
)?;
|
||||
resolve_secret_list(
|
||||
&mut self.frontends.http_json.auth.tokens,
|
||||
@@ -819,7 +819,7 @@ impl ClientConfig {
|
||||
|
||||
if let Some(path) = config_path {
|
||||
if self.has_inline_secrets() {
|
||||
crate::secrets::warn_if_group_readable(path, "tokens/passwords");
|
||||
crate::secrets::warn_if_group_readable(path, "tokens/passphrases");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -832,22 +832,10 @@ impl ClientConfig {
|
||||
.remotes
|
||||
.iter()
|
||||
.any(|r| r.auth.token_file.is_none() && r.auth.token.is_some())
|
||||
|| self
|
||||
.frontends
|
||||
.http
|
||||
.auth
|
||||
.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.auth.rx_passphrase_file.is_none()
|
||||
&& self.frontends.http.auth.rx_passphrase.is_some()
|
||||
|| self.frontends.http.auth.control_passphrase_file.is_none()
|
||||
&& self.frontends.http.auth.control_passphrase.is_some()
|
||||
|| self.frontends.http_json.auth.tokens_file.is_none()
|
||||
&& !self.frontends.http_json.auth.tokens.is_empty()
|
||||
}
|
||||
@@ -917,13 +905,11 @@ impl ClientConfig {
|
||||
decode_history_retention_min_by_rig: HashMap::new(),
|
||||
auth: HttpAuthConfig {
|
||||
enabled: false,
|
||||
users_file: "trx-http-users.json".to_string(),
|
||||
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()),
|
||||
rx_passphrase: Some("rx-passphrase-example".to_string()),
|
||||
rx_passphrase_file: None,
|
||||
control_passphrase: Some("control-passphrase-example".to_string()),
|
||||
control_passphrase_file: None,
|
||||
tx_access_control_enabled: true,
|
||||
session_ttl_min: 480,
|
||||
cookie_secure: false,
|
||||
cookie_same_site: CookieSameSite::Lax,
|
||||
@@ -962,34 +948,27 @@ fn validate_http_auth(auth: &HttpAuthConfig) -> Result<(), String> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if auth.users_file.trim().is_empty() {
|
||||
return Err("[frontends.http.auth].users_file must not be empty".to_string());
|
||||
}
|
||||
if auth.bootstrap_admin_username.is_some() != auth.bootstrap_admin_password.is_some() {
|
||||
return Err("[frontends.http.auth] bootstrap_admin_username and bootstrap_admin_password must be set together".to_string());
|
||||
}
|
||||
if auth
|
||||
.bootstrap_admin_username
|
||||
.as_deref()
|
||||
.is_some_and(|v| v.trim().is_empty())
|
||||
|| auth
|
||||
.bootstrap_admin_password
|
||||
.as_deref()
|
||||
.is_some_and(|v| v.is_empty())
|
||||
{
|
||||
// If enabled, require at least one passphrase
|
||||
if auth.rx_passphrase.is_none() && auth.control_passphrase.is_none() {
|
||||
return Err(
|
||||
"[frontends.http.auth] bootstrap administrator credentials must not be empty"
|
||||
"[frontends.http.auth] enabled=true requires at least one passphrase \
|
||||
(rx_passphrase and/or control_passphrase)"
|
||||
.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());
|
||||
|
||||
// Validate passphrases are not empty strings
|
||||
if let Some(rx) = &auth.rx_passphrase {
|
||||
if rx.trim().is_empty() {
|
||||
return Err("[frontends.http.auth].rx_passphrase must not be empty if set".to_string());
|
||||
}
|
||||
}
|
||||
if let Some(ctrl) = &auth.control_passphrase {
|
||||
if ctrl.trim().is_empty() {
|
||||
return Err(
|
||||
"[frontends.http.auth].control_passphrase must not be empty if set".to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Session TTL must be > 0
|
||||
@@ -1259,34 +1238,42 @@ home-hf = "audio://10.0.0.5:4600"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_accepts_http_auth_with_user_database() {
|
||||
fn test_validate_rejects_http_auth_enabled_without_passphrases() {
|
||||
let mut config = ClientConfig::default();
|
||||
config.frontends.http.auth.enabled = true;
|
||||
assert!(config.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_accepts_bootstrap_admin_pair() {
|
||||
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());
|
||||
assert!(config.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_rejects_incomplete_bootstrap_admin_pair() {
|
||||
let mut config = ClientConfig::default();
|
||||
config.frontends.http.auth.enabled = true;
|
||||
config.frontends.http.auth.bootstrap_admin_username = Some("admin".to_string());
|
||||
assert!(config.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_rejects_empty_users_file() {
|
||||
fn test_validate_accepts_http_auth_with_rx_passphrase() {
|
||||
let mut config = ClientConfig::default();
|
||||
config.frontends.http.auth.enabled = true;
|
||||
config.frontends.http.auth.users_file.clear();
|
||||
config.frontends.http.auth.rx_passphrase = Some("rx-secret".to_string());
|
||||
assert!(config.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_accepts_http_auth_with_control_passphrase() {
|
||||
let mut config = ClientConfig::default();
|
||||
config.frontends.http.auth.enabled = true;
|
||||
config.frontends.http.auth.control_passphrase = Some("control-secret".to_string());
|
||||
assert!(config.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_accepts_http_auth_with_both_passphrases() {
|
||||
let mut config = ClientConfig::default();
|
||||
config.frontends.http.auth.enabled = true;
|
||||
config.frontends.http.auth.rx_passphrase = Some("rx-secret".to_string());
|
||||
config.frontends.http.auth.control_passphrase = Some("control-secret".to_string());
|
||||
assert!(config.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_rejects_empty_rx_passphrase() {
|
||||
let mut config = ClientConfig::default();
|
||||
config.frontends.http.auth.enabled = true;
|
||||
config.frontends.http.auth.rx_passphrase = Some("".to_string());
|
||||
assert!(config.validate().is_err());
|
||||
}
|
||||
|
||||
@@ -1294,27 +1281,16 @@ home-hf = "audio://10.0.0.5:4600"
|
||||
fn test_validate_rejects_zero_session_ttl() {
|
||||
let mut config = ClientConfig::default();
|
||||
config.frontends.http.auth.enabled = true;
|
||||
config.frontends.http.auth.rx_passphrase = Some("rx-secret".to_string());
|
||||
config.frontends.http.auth.session_ttl_min = 0;
|
||||
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() {
|
||||
fn test_validate_auth_disabled_ignores_passphrases() {
|
||||
let mut config = ClientConfig::default();
|
||||
config.frontends.http.auth.enabled = false;
|
||||
config.frontends.http.auth.users_file.clear();
|
||||
config.frontends.http.auth.rx_passphrase = Some("".to_string());
|
||||
assert!(config.validate().is_ok());
|
||||
}
|
||||
|
||||
@@ -1322,12 +1298,9 @@ home-hf = "audio://10.0.0.5:4600"
|
||||
fn test_http_auth_config_default() {
|
||||
let auth = HttpAuthConfig::default();
|
||||
assert!(!auth.enabled);
|
||||
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!(auth.rx_passphrase.is_none());
|
||||
assert!(auth.control_passphrase.is_none());
|
||||
assert!(auth.tx_access_control_enabled);
|
||||
assert_eq!(auth.session_ttl_min, 480);
|
||||
assert!(!auth.cookie_secure);
|
||||
assert!(matches!(auth.cookie_same_site, CookieSameSite::Lax));
|
||||
@@ -1629,21 +1602,15 @@ spectrum_interval_ms = 0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bootstrap_password_file_fills_password() {
|
||||
fn test_passphrase_file_fills_passphrase() {
|
||||
let f = secret_file("hunter2\n");
|
||||
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_file =
|
||||
config.frontends.http.auth.control_passphrase_file =
|
||||
Some(f.path().to_str().unwrap().to_string());
|
||||
config.resolve_secrets(None).unwrap();
|
||||
assert_eq!(
|
||||
config
|
||||
.frontends
|
||||
.http
|
||||
.auth
|
||||
.bootstrap_admin_password
|
||||
.as_deref(),
|
||||
config.frontends.http.auth.control_passphrase.as_deref(),
|
||||
Some("hunter2")
|
||||
);
|
||||
assert!(config.validate().is_ok());
|
||||
|
||||
@@ -111,8 +111,8 @@ const SECTION_COMMENTS: &[(&str, &str)] = &[
|
||||
),
|
||||
(
|
||||
"trx-client.frontends.http.auth",
|
||||
"Optional user/password ACL for the web UI. Administrators manage\n\
|
||||
accounts stored in users_file.",
|
||||
"Passphrase login for the web UI. rx_passphrase_file and\n\
|
||||
control_passphrase_file keep the secrets out of this file.",
|
||||
),
|
||||
(
|
||||
"trx-client.frontends.rigctl",
|
||||
|
||||
@@ -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::{DigSidebandPolicy, NoiseBlankerProfile, RigMode};
|
||||
use trx_core::rig::state::RigMode;
|
||||
|
||||
/// Every decoder the server knows how to run, by config name.
|
||||
///
|
||||
@@ -393,10 +393,6 @@ 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).
|
||||
@@ -437,7 +433,6 @@ 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(),
|
||||
@@ -478,14 +473,8 @@ 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 × the tracked noise floor is
|
||||
/// blanked. Lower values blank more aggressively.
|
||||
/// A sample whose magnitude exceeds threshold × running RMS is blanked.
|
||||
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 {
|
||||
@@ -493,7 +482,6 @@ impl Default for SdrNoiseBlankerConfig {
|
||||
Self {
|
||||
enabled: false,
|
||||
threshold: 10.0,
|
||||
profile: NoiseBlankerProfile::Spike,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, DigSidebandPolicy, NoiseBlankerProfile, RdsData,
|
||||
RigFilterState, RigMode, RigSnapshot, RigState, WfmDenoiseLevel,
|
||||
DecoderConfig, DecoderResetSeqs, RdsData, RigFilterState, RigMode, RigSnapshot, RigState,
|
||||
WfmDenoiseLevel,
|
||||
};
|
||||
pub use rig::AudioSource;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
use crate::radio::freq::Freq;
|
||||
use crate::rig::state::{DigSidebandPolicy, NoiseBlankerProfile, WfmDenoiseLevel};
|
||||
use crate::rig::state::WfmDenoiseLevel;
|
||||
use crate::RigMode;
|
||||
|
||||
/// Internal command handled by the rig task.
|
||||
@@ -48,17 +48,8 @@ pub enum RigCommand {
|
||||
SetSdrGain(f64),
|
||||
SetSdrLnaGain(f64),
|
||||
SetSdrAgc(bool),
|
||||
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),
|
||||
SetSdrSquelch { enabled: bool, threshold_db: f64 },
|
||||
SetSdrNoiseBlanker { enabled: bool, threshold: f64 },
|
||||
SetWfmDeemphasis(u32),
|
||||
SetWfmStereo(bool),
|
||||
SetWfmDenoise(WfmDenoiseLevel),
|
||||
|
||||
@@ -470,7 +470,6 @@ 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(_)
|
||||
|
||||
@@ -253,7 +253,6 @@ 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"))
|
||||
@@ -261,17 +260,6 @@ 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,
|
||||
|
||||
@@ -338,14 +338,6 @@ 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")]
|
||||
@@ -378,155 +370,6 @@ 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
|
||||
}
|
||||
@@ -663,111 +506,3 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,8 +343,6 @@ 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,
|
||||
@@ -393,8 +391,6 @@ 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,
|
||||
|
||||
@@ -146,7 +146,6 @@ 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,
|
||||
@@ -157,7 +156,7 @@ define_command_mapping! {
|
||||
// ── Multi-field struct passthrough ───────────────────────────────
|
||||
multi:
|
||||
SetSdrSquelch { enabled, threshold_db } <=> SetSdrSquelch,
|
||||
SetSdrNoiseBlanker { enabled, threshold, profile } <=> SetSdrNoiseBlanker;
|
||||
SetSdrNoiseBlanker { enabled, threshold } <=> SetSdrNoiseBlanker;
|
||||
|
||||
// ── Freq conversions (u64 <=> Freq) ──────────────────────────────
|
||||
freq:
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use trx_core::rig::state::RigSnapshot;
|
||||
use trx_core::{DigSidebandPolicy, NoiseBlankerProfile, WfmDenoiseLevel};
|
||||
use trx_core::WfmDenoiseLevel;
|
||||
|
||||
/// Command received from network clients (JSON).
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
@@ -105,10 +105,6 @@ pub enum ClientCommand {
|
||||
SetSdrNoiseBlanker {
|
||||
enabled: bool,
|
||||
threshold: f64,
|
||||
profile: NoiseBlankerProfile,
|
||||
},
|
||||
SetSdrDigSideband {
|
||||
policy: DigSidebandPolicy,
|
||||
},
|
||||
SetWfmDeemphasis {
|
||||
deemphasis_us: u32,
|
||||
|
||||
@@ -360,8 +360,6 @@ 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,
|
||||
})?;
|
||||
|
||||
@@ -922,10 +920,11 @@ 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(
|
||||
cli.listen.unwrap_or(rig.audio.listen),
|
||||
audio_ip,
|
||||
rig.audio.port,
|
||||
format!("rig \"{}\" [audio]", rig.id),
|
||||
));
|
||||
@@ -1275,9 +1274,9 @@ async fn main() -> DynResult<()> {
|
||||
}
|
||||
}));
|
||||
|
||||
// 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;
|
||||
// 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));
|
||||
#[cfg(feature = "soapysdr")]
|
||||
let audio_vchan_manager = sdr_vchan_manager.clone();
|
||||
#[cfg(not(feature = "soapysdr"))]
|
||||
@@ -1416,40 +1415,6 @@ 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);
|
||||
|
||||
@@ -760,13 +760,9 @@ async fn process_command(
|
||||
let _ = ctx.state_tx.send(ctx.state.clone());
|
||||
return snapshot_from(ctx.state);
|
||||
}
|
||||
RigCommand::SetSdrNoiseBlanker {
|
||||
enabled,
|
||||
threshold,
|
||||
profile,
|
||||
} => {
|
||||
RigCommand::SetSdrNoiseBlanker { enabled, threshold } => {
|
||||
if let Some(sdr) = ctx.rig.as_sdr() {
|
||||
if let Err(e) = sdr.set_sdr_noise_blanker(enabled, threshold, profile).await {
|
||||
if let Err(e) = sdr.set_sdr_noise_blanker(enabled, threshold).await {
|
||||
return Err(RigError::communication(format!(
|
||||
"set_sdr_noise_blanker: {e}"
|
||||
)));
|
||||
@@ -778,20 +774,6 @@ 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::{NoiseBlankerProfile, RdsData, RigMode, WfmDenoiseLevel};
|
||||
use trx_core::rig::state::{RdsData, RigMode, WfmDenoiseLevel};
|
||||
|
||||
use crate::demod::{DcBlocker, Demodulator, SamDemod, SoftAgc, WfmStereoDecoder};
|
||||
|
||||
@@ -14,174 +14,38 @@ use super::{BlockFirFilterPair, IQ_BLOCK_SIZE};
|
||||
// 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.
|
||||
/// IQ-domain impulse noise blanker.
|
||||
///
|
||||
/// 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:
|
||||
/// 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).
|
||||
///
|
||||
/// 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.
|
||||
/// 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.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NoiseBlanker {
|
||||
enabled: bool,
|
||||
profile: NoiseBlankerProfile,
|
||||
sample_rate: f32,
|
||||
/// Detection sensitivity multiplier over the tracked noise floor (>= 1).
|
||||
threshold: 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,
|
||||
/// Exponentially-smoothed mean-square estimate.
|
||||
mean_sq: f32,
|
||||
/// Last clean sample (used for interpolation fill).
|
||||
last_clean: Complex<f32>,
|
||||
}
|
||||
|
||||
const NB_ALPHA: f32 = 1.0 / 128.0;
|
||||
|
||||
impl NoiseBlanker {
|
||||
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);
|
||||
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),
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -189,14 +53,7 @@ impl NoiseBlanker {
|
||||
self.threshold = threshold.max(1.0);
|
||||
}
|
||||
|
||||
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.
|
||||
/// Process a block of IQ samples in-place, blanking impulse spikes.
|
||||
pub fn process(&mut self, block: &mut [Complex<f32>]) {
|
||||
if !self.enabled || block.is_empty() {
|
||||
return;
|
||||
@@ -205,59 +62,17 @@ impl NoiseBlanker {
|
||||
let thresh_sq = self.threshold * self.threshold;
|
||||
|
||||
for sample in block.iter_mut() {
|
||||
let x = *sample;
|
||||
let mag_sq = x.re * x.re + x.im * x.im;
|
||||
let s = *sample;
|
||||
let mag_sq = s.re * s.re + s.im * s.im;
|
||||
|
||||
// Look-ahead: emit the delayed sample, ingest the fresh one.
|
||||
let y = if self.lookahead == 0 {
|
||||
x
|
||||
if mag_sq > thresh_sq * self.mean_sq {
|
||||
// Impulse detected — replace with last clean sample.
|
||||
*sample = self.last_clean;
|
||||
} else {
|
||||
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;
|
||||
// Clean sample — update RMS tracker.
|
||||
self.mean_sq += NB_ALPHA * (mag_sq - self.mean_sq);
|
||||
self.last_clean = s;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -266,7 +81,6 @@ impl NoiseBlanker {
|
||||
pub struct NoiseBlankerConfig {
|
||||
pub enabled: bool,
|
||||
pub threshold: f32,
|
||||
pub profile: NoiseBlankerProfile,
|
||||
}
|
||||
|
||||
impl Default for NoiseBlankerConfig {
|
||||
@@ -274,7 +88,6 @@ impl Default for NoiseBlankerConfig {
|
||||
Self {
|
||||
enabled: false,
|
||||
threshold: 10.0,
|
||||
profile: NoiseBlankerProfile::Spike,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -743,7 +556,7 @@ impl ChannelDsp {
|
||||
processing_enabled: true,
|
||||
force_mono_pcm,
|
||||
squelch: VirtualSquelch::new(squelch_cfg),
|
||||
noise_blanker: NoiseBlanker::new(nb_cfg, sdr_sample_rate),
|
||||
noise_blanker: NoiseBlanker::new(nb_cfg.enabled, nb_cfg.threshold),
|
||||
last_signal_db: -120.0,
|
||||
carrier_iq_power: 0.0,
|
||||
carrier_attack_alpha: Self::smeter_alphas(channel_sample_rate).0,
|
||||
@@ -764,15 +577,9 @@ impl ChannelDsp {
|
||||
self.squelch.set_threshold_db(threshold_db);
|
||||
}
|
||||
|
||||
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);
|
||||
pub fn set_noise_blanker(&mut self, enabled: bool, threshold: f32) {
|
||||
self.noise_blanker.set_enabled(enabled);
|
||||
self.noise_blanker.set_threshold(threshold);
|
||||
}
|
||||
|
||||
pub fn set_mode(&mut self, mode: &RigMode) {
|
||||
@@ -1239,89 +1046,30 @@ 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 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);
|
||||
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();
|
||||
nb.process(&mut block);
|
||||
|
||||
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];
|
||||
// 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();
|
||||
assert!(
|
||||
(tail.re - 0.5).abs() < 1e-3 && (tail.im - 0.5).abs() < 1e-3,
|
||||
"steady signal was gated: {tail:?}"
|
||||
mag < 1.0,
|
||||
"expected impulse to be blanked, got magnitude {}",
|
||||
mag
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noise_blanker_disabled_passes_through() {
|
||||
let mut nb = NoiseBlanker::new(nb_cfg(false, 5.0, NoiseBlankerProfile::Spike), 1_000_000);
|
||||
let mut nb = NoiseBlanker::new(false, 5.0);
|
||||
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,10 +13,7 @@ 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::{
|
||||
effective_demod_mode, DigSidebandPolicy, NoiseBlankerProfile, RigFilterState, SpectrumData,
|
||||
VchanRdsEntry, WfmDenoiseLevel,
|
||||
};
|
||||
use trx_core::rig::state::{RigFilterState, SpectrumData, VchanRdsEntry, WfmDenoiseLevel};
|
||||
use trx_core::rig::{
|
||||
AudioSource, Rig, RigAccessMethod, RigCapabilities, RigCat, RigInfo, RigSdr, RigStatusFuture,
|
||||
};
|
||||
@@ -76,10 +73,6 @@ 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,
|
||||
@@ -111,8 +104,6 @@ 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,
|
||||
}
|
||||
}
|
||||
@@ -163,17 +154,10 @@ 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 {
|
||||
@@ -216,8 +200,6 @@ 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={:?})",
|
||||
@@ -316,7 +298,6 @@ impl SoapySdrRig {
|
||||
dsp::NoiseBlankerConfig {
|
||||
enabled: nb_enabled,
|
||||
threshold: nb_threshold as f32,
|
||||
profile: nb_profile,
|
||||
},
|
||||
&all_channels,
|
||||
spectrum_fft_size,
|
||||
@@ -383,16 +364,8 @@ 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,
|
||||
@@ -417,20 +390,9 @@ 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)
|
||||
}
|
||||
@@ -486,8 +448,6 @@ impl SoapySdrRig {
|
||||
max_virtual_channels,
|
||||
nb_enabled,
|
||||
nb_threshold,
|
||||
nb_profile: NoiseBlankerProfile::default(),
|
||||
dig_sideband: DigSidebandPolicy::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -659,21 +619,6 @@ 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(())
|
||||
})
|
||||
@@ -687,18 +632,12 @@ 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(&effective);
|
||||
dsp.set_mode(&mode);
|
||||
dsp.set_filter(self.bandwidth_hz);
|
||||
}
|
||||
}
|
||||
@@ -994,7 +933,6 @@ 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() {
|
||||
@@ -1005,38 +943,13 @@ 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, 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;
|
||||
.set_noise_blanker(enabled, threshold as f32);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -1136,8 +1049,6 @@ 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, AtomicU8, Ordering};
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use num_complex::Complex;
|
||||
use tokio::sync::broadcast;
|
||||
use trx_core::rig::state::{effective_demod_mode, DigSidebandPolicy, RigMode, VchanRdsEntry};
|
||||
use trx_core::rig::state::{RigMode, VchanRdsEntry};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dsp::SdrPipeline;
|
||||
@@ -59,12 +59,7 @@ 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).
|
||||
@@ -96,9 +91,6 @@ 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 {
|
||||
@@ -108,12 +100,7 @@ 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,
|
||||
dig_sideband: DigSidebandPolicy,
|
||||
) -> Self {
|
||||
pub fn new(pipeline: Arc<SdrPipeline>, fixed_slot_count: usize, max_total: usize) -> 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
|
||||
@@ -131,7 +118,6 @@ 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,
|
||||
@@ -148,7 +134,6 @@ impl SdrVirtualChannelManager {
|
||||
max_total: max_total.max(1),
|
||||
channels: RwLock::new(vec![primary]),
|
||||
destroyed_tx,
|
||||
dig_sideband: AtomicU8::new(dig_sideband.to_u8()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,36 +141,6 @@ 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
|
||||
}
|
||||
@@ -217,13 +172,10 @@ 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 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 (pcm_tx, iq_tx) = self
|
||||
.pipeline
|
||||
.add_virtual_channel(if_hz as f64, mode, bandwidth_hz);
|
||||
|
||||
let pipeline_slot = self
|
||||
.pipeline
|
||||
@@ -238,7 +190,6 @@ impl SdrVirtualChannelManager {
|
||||
id,
|
||||
freq_hz,
|
||||
mode: mode.clone(),
|
||||
applied_mode,
|
||||
pcm_tx,
|
||||
iq_tx,
|
||||
pipeline_slot,
|
||||
@@ -363,18 +314,9 @@ 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) {
|
||||
let mut dsp = dsp_arc.lock().unwrap();
|
||||
dsp.set_channel_if_hz(if_hz as f64);
|
||||
if mode_changed {
|
||||
dsp.set_mode(&effective);
|
||||
}
|
||||
dsp_arc.lock().unwrap().set_channel_if_hz(if_hz as f64);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -387,11 +329,9 @@ 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(&effective);
|
||||
dsp_arc.lock().unwrap().set_mode(mode);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -485,7 +425,6 @@ 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> {
|
||||
@@ -507,7 +446,7 @@ mod tests {
|
||||
#[test]
|
||||
fn add_and_list() {
|
||||
let p = make_pipeline();
|
||||
let mgr = SdrVirtualChannelManager::new(p, 1, 4, DigSidebandPolicy::Auto);
|
||||
let mgr = SdrVirtualChannelManager::new(p, 1, 4);
|
||||
// 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
|
||||
@@ -523,7 +462,7 @@ mod tests {
|
||||
#[test]
|
||||
fn remove_virtual_channel() {
|
||||
let p = make_pipeline();
|
||||
let mgr = SdrVirtualChannelManager::new(p, 1, 4, DigSidebandPolicy::Auto);
|
||||
let mgr = SdrVirtualChannelManager::new(p, 1, 4);
|
||||
mgr.update_center_hz(14_100_000);
|
||||
let (id, _) = mgr.add_channel(14_074_000, &RigMode::USB).unwrap();
|
||||
mgr.remove_channel(id).unwrap();
|
||||
@@ -533,7 +472,7 @@ mod tests {
|
||||
#[test]
|
||||
fn cannot_remove_primary() {
|
||||
let p = make_pipeline();
|
||||
let mgr = SdrVirtualChannelManager::new(p, 1, 4, DigSidebandPolicy::Auto);
|
||||
let mgr = SdrVirtualChannelManager::new(p, 1, 4);
|
||||
let primary_id = mgr.channels()[0].id;
|
||||
let err = mgr.remove_channel(primary_id).unwrap_err();
|
||||
assert!(matches!(err, VChanError::Permanent));
|
||||
@@ -542,7 +481,7 @@ mod tests {
|
||||
#[test]
|
||||
fn cap_enforced() {
|
||||
let p = make_pipeline();
|
||||
let mgr = SdrVirtualChannelManager::new(p, 1, 2, DigSidebandPolicy::Auto); // primary + 1 virtual max
|
||||
let mgr = SdrVirtualChannelManager::new(p, 1, 2); // 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();
|
||||
@@ -552,7 +491,7 @@ mod tests {
|
||||
#[test]
|
||||
fn out_of_bandwidth() {
|
||||
let p = make_pipeline();
|
||||
let mgr = SdrVirtualChannelManager::new(p, 1, 4, DigSidebandPolicy::Auto);
|
||||
let mgr = SdrVirtualChannelManager::new(p, 1, 4);
|
||||
// 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 { .. }));
|
||||
@@ -561,7 +500,7 @@ mod tests {
|
||||
#[test]
|
||||
fn hidden_background_channels_are_outside_visible_cap() {
|
||||
let p = make_pipeline();
|
||||
let mgr = SdrVirtualChannelManager::new(p, 1, 2, DigSidebandPolicy::Auto); // primary + 1 visible max
|
||||
let mgr = SdrVirtualChannelManager::new(p, 1, 2); // primary + 1 visible max
|
||||
mgr.update_center_hz(14_100_000);
|
||||
|
||||
mgr.add_channel(14_074_000, &RigMode::USB).unwrap();
|
||||
@@ -574,46 +513,10 @@ 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, DigSidebandPolicy::Auto);
|
||||
let mgr = SdrVirtualChannelManager::new(p, 1, 4);
|
||||
mgr.update_center_hz(14_100_000);
|
||||
let mut destroyed_rx = mgr.subscribe_destroyed();
|
||||
|
||||
|
||||
+5
-10
@@ -118,7 +118,6 @@ 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
|
||||
@@ -140,7 +139,6 @@ 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]
|
||||
@@ -205,16 +203,13 @@ decode_history_retention_min = 1440
|
||||
|
||||
[trx-client.frontends.http.decode_history_retention_min_by_rig]
|
||||
|
||||
# Optional user/password ACL for the web UI. Administrators manage
|
||||
# accounts stored in users_file.
|
||||
# Passphrase login for the web UI. rx_passphrase_file and
|
||||
# control_passphrase_file keep the secrets out of this file.
|
||||
[trx-client.frontends.http.auth]
|
||||
enabled = false
|
||||
users_file = "trx-http-users.json"
|
||||
bootstrap_admin_username = "admin"
|
||||
bootstrap_admin_password = "change-this-password"
|
||||
bootstrap_read_enabled = true
|
||||
bootstrap_read_username = "guest"
|
||||
bootstrap_read_password = "guest"
|
||||
rx_passphrase = "rx-passphrase-example"
|
||||
control_passphrase = "control-passphrase-example"
|
||||
tx_access_control_enabled = true
|
||||
session_ttl_min = 480
|
||||
cookie_secure = false
|
||||
cookie_same_site = "Lax"
|
||||
|
||||
Reference in New Issue
Block a user