Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b1fb005f6 | ||
|
|
be9d5c301b | ||
|
|
b73c97dd5b | ||
|
|
6276c3feea | ||
|
|
110c0e1d49 | ||
|
|
05c337b581 | ||
|
|
c10b5faef4 | ||
|
|
79adc8d5c6 | ||
|
|
c33e3caedb | ||
|
|
5084c19899 | ||
|
|
df7483fe30 | ||
|
|
fe3b414fba | ||
|
|
1829e3d17b | ||
|
|
b037225a05 | ||
|
|
e978cf8a84 | ||
|
|
44870bc941 | ||
|
|
5e9dae02c7 | ||
|
|
34507ffa17 | ||
|
|
36c1e56efa | ||
|
|
e4cce9a004 | ||
|
|
3c3fc69542 | ||
|
|
d539ff96e5 | ||
|
|
ca5cd65c85 | ||
|
|
a9e1e86fdc | ||
|
|
76e33a91bb | ||
|
|
ee185b98bc | ||
|
|
18b2d0efe6 |
@@ -27,7 +27,7 @@ env:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: self-hosted
|
||||||
container: git.haxx.space/sjg/trx-rs/sdk:latest
|
container: git.haxx.space/sjg/trx-rs/sdk:latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -40,7 +40,7 @@ jobs:
|
|||||||
run: sccache --show-stats
|
run: sccache --show-stats
|
||||||
|
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: self-hosted
|
||||||
container: git.haxx.space/sjg/trx-rs/sdk:latest
|
container: git.haxx.space/sjg/trx-rs/sdk:latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -53,7 +53,7 @@ jobs:
|
|||||||
run: sccache --show-stats
|
run: sccache --show-stats
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
runs-on: ubuntu-latest
|
runs-on: self-hosted
|
||||||
container: git.haxx.space/sjg/trx-rs/sdk:latest
|
container: git.haxx.space/sjg/trx-rs/sdk:latest
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
@@ -83,7 +83,7 @@ jobs:
|
|||||||
run: npm run verify-generated
|
run: npm run verify-generated
|
||||||
|
|
||||||
reuse:
|
reuse:
|
||||||
runs-on: ubuntu-latest
|
runs-on: self-hosted
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: fsfe/reuse-action@v5
|
- uses: fsfe/reuse-action@v5
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ src/
|
|||||||
trx-backend-ft450d/ # Yaesu FT-450D ASCII CAT
|
trx-backend-ft450d/ # Yaesu FT-450D ASCII CAT
|
||||||
trx-backend-soapysdr/ # SoapySDR RX with full DSP pipeline (~5,000+ LOC)
|
trx-backend-soapysdr/ # SoapySDR RX with full DSP pipeline (~5,000+ LOC)
|
||||||
trx-client/ # Client binary: remote connection, frontend spawning (~1,500 LOC)
|
trx-client/ # Client binary: remote connection, frontend spawning (~1,500 LOC)
|
||||||
|
trx-logbook/ # Station log: QSO record, ADIF reader/writer, append-only store
|
||||||
trx-frontend/ # Frontend trait (FrontendSpawner), runtime context
|
trx-frontend/ # Frontend trait (FrontendSpawner), runtime context
|
||||||
trx-frontend-http/ # Web UI: REST API, SSE, WebSocket audio, session auth
|
trx-frontend-http/ # Web UI: REST API, SSE, WebSocket audio, session auth
|
||||||
trx-frontend-http-json/ # JSON-over-TCP control frontend
|
trx-frontend-http-json/ # JSON-over-TCP control frontend
|
||||||
|
|||||||
Generated
+109
-5
@@ -316,6 +316,18 @@ version = "1.0.102"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
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]]
|
[[package]]
|
||||||
name = "atomic-waker"
|
name = "atomic-waker"
|
||||||
version = "1.1.2"
|
version = "1.1.2"
|
||||||
@@ -345,6 +357,12 @@ version = "0.22.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64ct"
|
||||||
|
version = "1.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bindgen"
|
name = "bindgen"
|
||||||
version = "0.66.1"
|
version = "0.66.1"
|
||||||
@@ -395,6 +413,24 @@ version = "2.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
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]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.12.0"
|
version = "0.12.0"
|
||||||
@@ -525,7 +561,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
|
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures 0.3.0",
|
||||||
"rand_core 0.10.1",
|
"rand_core 0.10.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -721,6 +757,15 @@ dependencies = [
|
|||||||
"windows",
|
"windows",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -739,6 +784,16 @@ dependencies = [
|
|||||||
"cfg-if",
|
"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]]
|
[[package]]
|
||||||
name = "crypto-common"
|
name = "crypto-common"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -799,15 +854,26 @@ dependencies = [
|
|||||||
"zeroize",
|
"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]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.11.2"
|
version = "0.11.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c"
|
checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer",
|
"block-buffer 0.12.0",
|
||||||
"const-oid",
|
"const-oid",
|
||||||
"crypto-common",
|
"crypto-common 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1019,6 +1085,16 @@ dependencies = [
|
|||||||
"slab",
|
"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]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -1890,6 +1966,17 @@ dependencies = [
|
|||||||
"windows-link",
|
"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]]
|
[[package]]
|
||||||
name = "peeking_take_while"
|
name = "peeking_take_while"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
@@ -2492,8 +2579,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
|
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures 0.3.0",
|
||||||
"digest",
|
"digest 0.11.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3215,9 +3302,11 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"actix-web",
|
"actix-web",
|
||||||
"actix-ws",
|
"actix-ws",
|
||||||
|
"argon2",
|
||||||
"base64",
|
"base64",
|
||||||
"brotli 7.0.0",
|
"brotli 7.0.0",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"chrono",
|
||||||
"dirs",
|
"dirs",
|
||||||
"flate2",
|
"flate2",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
@@ -3226,11 +3315,13 @@ dependencies = [
|
|||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
"tracing",
|
"tracing",
|
||||||
"trx-core",
|
"trx-core",
|
||||||
"trx-frontend",
|
"trx-frontend",
|
||||||
|
"trx-logbook",
|
||||||
"trx-protocol",
|
"trx-protocol",
|
||||||
"ts-rs",
|
"ts-rs",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -3269,6 +3360,19 @@ dependencies = [
|
|||||||
"rustfft",
|
"rustfft",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "trx-logbook"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
|
"dirs",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
|
"tracing",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "trx-protocol"
|
name = "trx-protocol"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ members = [
|
|||||||
"src/trx-server/trx-backend/trx-backend-ft450d",
|
"src/trx-server/trx-backend/trx-backend-ft450d",
|
||||||
"src/trx-server/trx-backend/trx-backend-soapysdr",
|
"src/trx-server/trx-backend/trx-backend-soapysdr",
|
||||||
"src/trx-client",
|
"src/trx-client",
|
||||||
|
"src/trx-client/trx-logbook",
|
||||||
"src/trx-client/trx-frontend",
|
"src/trx-client/trx-frontend",
|
||||||
"src/trx-client/trx-frontend/trx-frontend-http",
|
"src/trx-client/trx-frontend/trx-frontend-http",
|
||||||
"src/trx-client/trx-frontend/trx-frontend-http-json",
|
"src/trx-client/trx-frontend/trx-frontend-http-json",
|
||||||
|
|||||||
@@ -116,6 +116,44 @@ section and the client reads `[trx-client]`.
|
|||||||
|
|
||||||
Open the configured HTTP frontend address in a browser (default `http://localhost:8080`).
|
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
|
## How It Works
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
|
|||||||
@@ -925,9 +925,14 @@ main
|
|||||||
|
|
||||||
### HTTP Frontend Auth
|
### HTTP Frontend Auth
|
||||||
|
|
||||||
- Optional token or HTTP Basic Auth middleware
|
- Optional Argon2id-backed managed accounts with HttpOnly session cookies
|
||||||
- Configured in `[frontends.http.auth]`
|
- An exclusive Guest role plus composable Read, Control, Transmit, Write, and Administrator roles, with policy shared by middleware and handlers
|
||||||
- Rate limiting supported
|
- Guest sessions receive read-only station access but no account-control endpoints or panels
|
||||||
|
- Transmit separately gates PTT, TX audio frames, and TX power-limit changes
|
||||||
|
- Atomic JSON persistence with migration from the legacy single-role schema
|
||||||
|
- Account enable/disable, administrator CRUD, self-service password changes, and session revocation on security changes
|
||||||
|
- A database invariant always preserves at least one enabled administrator
|
||||||
|
- Per-IP login rate limiting; configured in `[frontends.http.auth]`
|
||||||
|
|
||||||
### Transport Security
|
### Transport Security
|
||||||
|
|
||||||
@@ -1043,7 +1048,7 @@ The `FrontendRuntimeContext` struct in `trx-frontend/src/lib.rs` is decomposed i
|
|||||||
|-----------|---------|------------|
|
|-----------|---------|------------|
|
||||||
| `AudioContext` | Audio streaming channels | `rx`, `tx`, `info`, `decode_rx`, `clients` |
|
| `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` |
|
| `DecodeHistoryContext` | Decode history for all types | `ais`, `vdes`, `aprs`, `hf_aprs`, `cw`, `ft8`, `ft4`, `ft2`, `wspr` |
|
||||||
| `HttpAuthConfig` | HTTP auth settings | `enabled`, `rx_passphrase`, `session_ttl_secs`, `tokens` |
|
| `HttpAuthConfig` | HTTP auth settings | `enabled`, `users_file`, bootstrap admin/read accounts, `session_ttl_secs`, `tokens` |
|
||||||
| `HttpUiConfig` | HTTP UI display config | `show_sdr_gain_control`, `initial_map_zoom`, `spectrum_*` |
|
| `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` |
|
| `RigRoutingContext` | Remote rig state & routing | `active_rig_id`, `remote_rigs`, `rig_states`, `server_connected` |
|
||||||
| `OwnerInfo` | Station metadata | `callsign`, `website_url`, `ais_vessel_url_base` |
|
| `OwnerInfo` | Station metadata | `callsign`, `website_url`, `ais_vessel_url_base` |
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
# Safe deployment
|
||||||
|
|
||||||
|
This guide deploys trx-rs on Linux as a dedicated, unprivileged user with
|
||||||
|
systemd user services. It keeps radio-device access, configuration, and runtime
|
||||||
|
data separate from an administrator's account.
|
||||||
|
|
||||||
|
The examples use `trx-rs` as the account name and `/opt/trx-rs/bin` for
|
||||||
|
root-owned executables. Adapt group names and firewall commands to your Linux
|
||||||
|
distribution.
|
||||||
|
|
||||||
|
## 1. Decide what must be reachable
|
||||||
|
|
||||||
|
Only expose listeners that another machine actually needs:
|
||||||
|
|
||||||
|
| Listener | Typical port | Recommended exposure |
|
||||||
|
| --- | ---: | --- |
|
||||||
|
| Server control | TCP 4530 | Loopback or trusted radio LAN only |
|
||||||
|
| Server audio | TCP 4531 and per-rig ports | Trusted radio LAN only |
|
||||||
|
| Client web UI | TCP 8080 or a chosen port | Loopback behind HTTPS proxy |
|
||||||
|
| Client rigctl | Per-rig TCP ports | Loopback or trusted LAN only |
|
||||||
|
| Client JSON | Configured TCP port | Loopback or trusted LAN only |
|
||||||
|
|
||||||
|
`127.0.0.1` accepts connections only from the same host. Use a specific LAN
|
||||||
|
address when possible, or `0.0.0.0` when the listener must accept connections
|
||||||
|
on every IPv4 interface. Binding a socket does not configure the host firewall.
|
||||||
|
|
||||||
|
Do not expose unauthenticated control, audio, rigctl, or JSON listeners to the
|
||||||
|
public Internet. Prefer a VPN for links between radio sites. Put the web UI
|
||||||
|
behind an HTTPS reverse proxy when it is remotely accessible.
|
||||||
|
|
||||||
|
## 2. Create the service account
|
||||||
|
|
||||||
|
Create a non-login service account with a home directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo useradd --create-home --shell /usr/sbin/nologin trx-rs
|
||||||
|
sudo chmod 0750 /home/trx-rs
|
||||||
|
```
|
||||||
|
|
||||||
|
Add only the hardware groups required on this host. Common group names are
|
||||||
|
`dialout` for serial devices and `audio` for sound devices:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo usermod -aG dialout,audio trx-rs
|
||||||
|
```
|
||||||
|
|
||||||
|
SDR USB access is distribution- and device-specific. Install the vendor's udev
|
||||||
|
rules or add a narrowly scoped rule for the device's USB vendor/product IDs.
|
||||||
|
Avoid making all USB devices world-writable. After reconnecting the device,
|
||||||
|
verify access as the service account:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u trx-rs test -r /dev/ttyUSB0
|
||||||
|
sudo -u trx-rs test -w /dev/ttyUSB0
|
||||||
|
sudo -u trx-rs SoapySDRUtil --find
|
||||||
|
```
|
||||||
|
|
||||||
|
Run only the checks relevant to the configured hardware. Group membership and
|
||||||
|
udev-rule changes normally require reconnecting the device or restarting the
|
||||||
|
service.
|
||||||
|
|
||||||
|
## 3. Build and install immutable binaries
|
||||||
|
|
||||||
|
Build from a reviewed revision as a normal development user, not as root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/stanislawgrams/trx-rs.git
|
||||||
|
cd trx-rs
|
||||||
|
git switch --detach <reviewed-tag-or-commit>
|
||||||
|
cargo build --release -p trx-server -p trx-client -p trx-configurator
|
||||||
|
```
|
||||||
|
|
||||||
|
Install root-owned binaries into a directory the service user cannot modify:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo install -d -o root -g root -m 0755 /opt/trx-rs/bin
|
||||||
|
sudo install -o root -g root -m 0755 \
|
||||||
|
target/release/trx-server \
|
||||||
|
target/release/trx-client \
|
||||||
|
target/release/trx-configurator \
|
||||||
|
/opt/trx-rs/bin/
|
||||||
|
```
|
||||||
|
|
||||||
|
If SDR support is not needed, build `trx-server` with
|
||||||
|
`--no-default-features`. Keep the source revision and Rust toolchain used for
|
||||||
|
the build in deployment records.
|
||||||
|
|
||||||
|
## 4. Install and validate configuration
|
||||||
|
|
||||||
|
Create private configuration and state directories, then seed the example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo install -d -o trx-rs -g trx-rs -m 0700 \
|
||||||
|
/home/trx-rs/.config/trx-rs \
|
||||||
|
/home/trx-rs/.config/systemd/user
|
||||||
|
sudo install -o trx-rs -g trx-rs -m 0600 \
|
||||||
|
trx-rs.toml.example /home/trx-rs/.config/trx-rs/trx-rs.toml
|
||||||
|
sudoedit /home/trx-rs/.config/trx-rs/trx-rs.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
At minimum:
|
||||||
|
|
||||||
|
- remove unused example rigs and remotes;
|
||||||
|
- select the correct serial, TCP, or SDR device;
|
||||||
|
- give every enabled rig a unique ID and audio port;
|
||||||
|
- use `127.0.0.1` for same-host connections;
|
||||||
|
- use a LAN address or `0.0.0.0` only for deliberately remote listeners;
|
||||||
|
- enable authentication before exposing server control or the web UI;
|
||||||
|
- replace every example password and token;
|
||||||
|
- keep credential files mode `0600` and owned by `trx-rs`;
|
||||||
|
- set `cookie_secure = true` when the web UI is served through HTTPS.
|
||||||
|
|
||||||
|
For a remote server, control and each per-rig audio listener need an explicit
|
||||||
|
non-loopback address:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[trx-server.listen]
|
||||||
|
enabled = true
|
||||||
|
listen = "0.0.0.0"
|
||||||
|
port = 4530
|
||||||
|
|
||||||
|
[trx-server.listen.auth]
|
||||||
|
tokens_file = "/home/trx-rs/.config/trx-rs/server-tokens"
|
||||||
|
|
||||||
|
[[trx-server.rigs]]
|
||||||
|
id = "station-hf"
|
||||||
|
|
||||||
|
[trx-server.rigs.audio]
|
||||||
|
enabled = true
|
||||||
|
listen = "0.0.0.0"
|
||||||
|
port = 4531
|
||||||
|
```
|
||||||
|
|
||||||
|
When using several `[[trx-server.rigs]]` entries, configure audio under each
|
||||||
|
`[trx-server.rigs.audio]` section. Do not rely on the legacy flat
|
||||||
|
`[trx-server.audio]` section.
|
||||||
|
|
||||||
|
For a web UI behind a reverse proxy, keep the backend on loopback and choose an
|
||||||
|
unused port. The proxy upstream must use the same address and port:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[trx-client.frontends.http]
|
||||||
|
enabled = true
|
||||||
|
listen = "127.0.0.1"
|
||||||
|
port = 7345
|
||||||
|
|
||||||
|
[trx-client.frontends.http.auth]
|
||||||
|
enabled = true
|
||||||
|
users_file = "/home/trx-rs/.config/trx-rs/http-users.json"
|
||||||
|
cookie_secure = true
|
||||||
|
```
|
||||||
|
|
||||||
|
Validate the configuration before starting either daemon:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u trx-rs /opt/trx-rs/bin/trx-server \
|
||||||
|
--check-config --config /home/trx-rs/.config/trx-rs/trx-rs.toml
|
||||||
|
sudo -u trx-rs /opt/trx-rs/bin/trx-client \
|
||||||
|
--check-config --config /home/trx-rs/.config/trx-rs/trx-rs.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
Treat unknown-key and deprecated-section warnings as deployment errors. They
|
||||||
|
often mean a setting is not applied where expected.
|
||||||
|
|
||||||
|
## 5. Install the systemd user services
|
||||||
|
|
||||||
|
Render the packaged units with the immutable binary directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sed 's|@BINDIR@|/opt/trx-rs/bin|g' packaging/systemd/trx-server.service \
|
||||||
|
| sudo tee /home/trx-rs/.config/systemd/user/trx-server.service >/dev/null
|
||||||
|
sed 's|@BINDIR@|/opt/trx-rs/bin|g' packaging/systemd/trx-client.service \
|
||||||
|
| sudo tee /home/trx-rs/.config/systemd/user/trx-client.service >/dev/null
|
||||||
|
sudo chown trx-rs:trx-rs \
|
||||||
|
/home/trx-rs/.config/systemd/user/trx-server.service \
|
||||||
|
/home/trx-rs/.config/systemd/user/trx-client.service
|
||||||
|
sudo chmod 0644 \
|
||||||
|
/home/trx-rs/.config/systemd/user/trx-server.service \
|
||||||
|
/home/trx-rs/.config/systemd/user/trx-client.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable lingering so the user manager runs without an interactive login, then
|
||||||
|
start it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo loginctl enable-linger trx-rs
|
||||||
|
trx_uid=$(id -u trx-rs)
|
||||||
|
sudo systemctl start "user@${trx_uid}.service"
|
||||||
|
```
|
||||||
|
|
||||||
|
Manage the user services through that user's runtime directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
trx_uid=$(id -u trx-rs)
|
||||||
|
sudo -u trx-rs XDG_RUNTIME_DIR="/run/user/${trx_uid}" \
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
sudo -u trx-rs XDG_RUNTIME_DIR="/run/user/${trx_uid}" \
|
||||||
|
systemctl --user enable --now trx-server.service trx-client.service
|
||||||
|
```
|
||||||
|
|
||||||
|
The client unit has ordering, but not activation, on `trx-server.service`. If
|
||||||
|
both are enabled locally, the client starts after the server process is
|
||||||
|
started. If the local server is disabled because all remotes are external, the
|
||||||
|
client does not start it. The client retries remote connections; systemd cannot
|
||||||
|
order startup against a service on another host.
|
||||||
|
|
||||||
|
## 6. Firewall and reverse proxy
|
||||||
|
|
||||||
|
Allow only required ports and sources. For example, permit server control and
|
||||||
|
audio only from the trusted radio subnet, not from every interface. Exact
|
||||||
|
commands differ between nftables, firewalld, and ufw.
|
||||||
|
|
||||||
|
For the web UI, terminate TLS in a maintained reverse proxy and send traffic to
|
||||||
|
the loopback backend. Preserve WebSocket upgrade headers if the proxy requires
|
||||||
|
them. Example nginx location:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:7345;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Test the backend directly before debugging a `502 Bad Gateway` response:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --fail --show-error http://127.0.0.1:7345/
|
||||||
|
```
|
||||||
|
|
||||||
|
A refused connection means nothing is listening at the proxy's configured
|
||||||
|
address and port. Check for port conflicts with `ss -ltnp` and confirm the
|
||||||
|
application log reports the same bind address as the proxy upstream.
|
||||||
|
|
||||||
|
## 7. Verify the deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
trx_uid=$(id -u trx-rs)
|
||||||
|
sudo -u trx-rs XDG_RUNTIME_DIR="/run/user/${trx_uid}" \
|
||||||
|
systemctl --user status trx-server.service trx-client.service
|
||||||
|
sudo journalctl _UID="${trx_uid}" \
|
||||||
|
-u trx-server.service -u trx-client.service --since today
|
||||||
|
sudo ss -ltnp
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify all of the following:
|
||||||
|
|
||||||
|
- both required services remain active without a restart loop;
|
||||||
|
- radio hardware opens successfully;
|
||||||
|
- logs show the intended control, audio, and frontend bind addresses;
|
||||||
|
- the client connects to every configured control and audio endpoint;
|
||||||
|
- only intended interfaces expose listeners;
|
||||||
|
- authentication works and anonymous access is rejected where configured;
|
||||||
|
- the HTTPS proxy returns the UI and supports live updates;
|
||||||
|
- the service account cannot write `/opt/trx-rs/bin`.
|
||||||
|
|
||||||
|
Do not rely only on `systemctl` reporting `active`: individual frontend tasks
|
||||||
|
can fail after the main client process starts. Always inspect startup logs and
|
||||||
|
probe each required endpoint.
|
||||||
|
|
||||||
|
## 8. Upgrade and roll back
|
||||||
|
|
||||||
|
Build and validate a new revision before replacing the installed executables.
|
||||||
|
Back up configuration and state first. Stop the services, install all binaries
|
||||||
|
from the same build, and restart:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
trx_uid=$(id -u trx-rs)
|
||||||
|
sudo -u trx-rs XDG_RUNTIME_DIR="/run/user/${trx_uid}" \
|
||||||
|
systemctl --user stop trx-client.service trx-server.service
|
||||||
|
sudo install -o root -g root -m 0755 \
|
||||||
|
target/release/trx-server \
|
||||||
|
target/release/trx-client \
|
||||||
|
target/release/trx-configurator \
|
||||||
|
/opt/trx-rs/bin/
|
||||||
|
sudo -u trx-rs XDG_RUNTIME_DIR="/run/user/${trx_uid}" \
|
||||||
|
systemctl --user start trx-server.service trx-client.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep the previous binaries or package revision available for rollback. If the
|
||||||
|
new version fails, stop both services, restore the complete previous binary
|
||||||
|
set, restore configuration only if its format changed, and start the services
|
||||||
|
again.
|
||||||
+28
-14
@@ -453,25 +453,31 @@ 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 |
|
| `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 |
|
| 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 |
|
| 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 /logbook/now`, which also feeds the browser-clock check |
|
| Server clock | `QSO_DATE`, `TIME_ON` in UTC | new `GET /api/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 |
|
| 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 |
|
| `bandForHz` | `BAND` from a frequency | exists in `map-core.ts`; move to a shared module |
|
||||||
|
|
||||||
#### HTTP API
|
#### 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 |
|
| Method | Path | Purpose |
|
||||||
|--------|------|---------|
|
|--------|------|---------|
|
||||||
| `GET` | `/logbook` | Query: filters, paging |
|
| `GET` | `/api/logbook` | Query: filters, paging |
|
||||||
| `POST` | `/logbook` | Add a QSO |
|
| `POST` | `/api/logbook` | Add a QSO |
|
||||||
| `PUT` | `/logbook/{id}` | Edit |
|
| `PUT` | `/api/logbook/{id}` | Edit |
|
||||||
| `DELETE` | `/logbook/{id}` | Delete |
|
| `DELETE` | `/api/logbook/{id}` | Delete |
|
||||||
| `GET` | `/logbook/export.adi` | ADIF export, honouring the current filter |
|
| `GET` | `/api/logbook/export.adi` | ADIF export, honouring the current filter |
|
||||||
| `GET` | `/logbook/export.cbr` | Cabrillo export of a contest selection |
|
| `GET` | `/api/logbook/export.cbr` | Cabrillo export of a contest selection |
|
||||||
| `POST` | `/logbook/import` | Import, answering with counts: added, duplicate, rejected |
|
| `POST` | `/api/logbook/import` | Import, answering with counts: added, duplicate, rejected |
|
||||||
| `GET` | `/logbook/worked/{call}` | Worked-before: bands and modes |
|
| `GET` | `/api/logbook/worked/{call}` | Worked-before: bands and modes |
|
||||||
| `GET` | `/logbook/now` | The server's UTC clock, for stamping entries and checking the browser's |
|
| `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 |
|
||||||
|
|
||||||
Writes require the control role, as the rig endpoints do.
|
Writes require the admin role, as the rig endpoints do.
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
@@ -505,14 +511,22 @@ task and the rig is the instrument.
|
|||||||
|
|
||||||
### Phases
|
### Phases
|
||||||
|
|
||||||
|
All five are implemented.
|
||||||
|
|
||||||
| Phase | Lands |
|
| Phase | Lands |
|
||||||
|-------|-------|
|
|-------|-------|
|
||||||
| 1 | `trx-logbook`: `Qso`, the ADI reader and writer, round-trip tests against files from other loggers |
|
| 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 control role |
|
| 2 | Store, dedupe, and the HTTP API behind the admin role |
|
||||||
| 3 | Logbook tab: entry, table, filters, import, export |
|
| 3 | Logbook tab: entry, table, filters, import, export |
|
||||||
| 4 | Ham layout, pre-filled entry from a decode row or the map, worked-before |
|
| 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 |
|
| 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
|
### Decisions
|
||||||
|
|
||||||
**One station log, not one per rig.** Awards and uploads are per station callsign — DXCC, WAS
|
**One station log, not one per rig.** Awards and uploads are per station callsign — DXCC, WAS
|
||||||
@@ -527,8 +541,8 @@ setting, which is also what LoTW's station locations expect.
|
|||||||
rotate operators through one station callsign, which is why contest loggers record it per QSO.
|
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 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, and changed on the station line at the top of the panel where it sticks for the session.
|
||||||
It cannot be taken from the session's identity: the auth roles are `control` and `rx`, with no
|
It cannot be inferred from the session's identity: an account username need not be an operator
|
||||||
notion of who is logged in.
|
callsign, and operational accounts may be shared.
|
||||||
|
|
||||||
**Server clock, and the log says so.** The server is the machine at the radio; the browser may
|
**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
|
be on a phone in another timezone with a clock nobody has checked. QSO times are UTC from the
|
||||||
|
|||||||
+19
-12
@@ -121,13 +121,14 @@ The spectrum panel uses `<canvas>` elements (WebGL renderer optional) and offers
|
|||||||
When auth is enabled, an **auth gate** blocks the UI with:
|
When auth is enabled, an **auth gate** blocks the UI with:
|
||||||
|
|
||||||
- Title: "Access Required"
|
- Title: "Access Required"
|
||||||
- Subtitle: "Enter passphrase to continue"
|
- Subtitle: "Sign in to continue"
|
||||||
- Password input + Login button (green accent, full-width)
|
- Username and password inputs + Login button (green accent, full-width)
|
||||||
- Optional "Continue as Guest" button (shown when RX passphrase is not set)
|
|
||||||
- Error message area (red `#ff6b6b`)
|
- Error message area (red `#ff6b6b`)
|
||||||
- Role badge display
|
- Role badge display
|
||||||
|
|
||||||
Two roles: **Rx** (read-only) and **Control** (full access including TX/PTT).
|
**Guest** provides read-only station access and is exclusive. Non-Guest accounts
|
||||||
|
may combine **Read**, **Control**, **Transmit**, **Write**, and **Administrator** roles.
|
||||||
|
Administrator implies all permissions.
|
||||||
|
|
||||||
Session cookie: `trx_http_sid`, HttpOnly, configurable Secure and SameSite attributes.
|
Session cookie: `trx_http_sid`, HttpOnly, configurable Secure and SameSite attributes.
|
||||||
|
|
||||||
@@ -336,25 +337,31 @@ Logo and favicon are embedded at compile time via `include_bytes!`. The logo ima
|
|||||||
|
|
||||||
### 7.1 Route Access Classification
|
### 7.1 Route Access Classification
|
||||||
|
|
||||||
Routes are classified into three tiers:
|
Routes are classified into access tiers:
|
||||||
|
|
||||||
| Tier | Examples | Requirement |
|
| Tier | Examples | Requirement |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Public** | `/`, `/index.html`, `/map`, `/auth/*`, static assets | None |
|
| **Public** | `/`, `/index.html`, `/map`, login/session endpoints, static assets | None |
|
||||||
| **Read** | `/status`, `/events`, `/audio`, `/decode`, `/spectrum`, `/bookmarks` | Rx or Control role |
|
| **Read** | `/status`, `/events`, `/audio`, `/decode`, `/spectrum`, `/bookmarks` | Guest, Read, Control, Transmit, or Administrator role |
|
||||||
| **Control** | `/set_freq`, `/set_mode`, `/set_ptt`, `/toggle_power`, all other POST | Control role only |
|
| **Control** | `/set_freq`, `/set_mode`, `/toggle_power`, receive-side radio-control POST routes | Control or Administrator role |
|
||||||
|
| **Transmit** | `/set_ptt`, `/set_tx_limit`, outbound `/audio` frames | Transmit or Administrator role |
|
||||||
|
| **Write** | Logbook access and bookmark mutations | Write or Administrator role |
|
||||||
|
|
||||||
### 7.2 Session Management
|
### 7.2 Session Management
|
||||||
|
|
||||||
- Sessions are 128-bit random hex tokens stored in HttpOnly cookies
|
- Sessions are 128-bit random hex tokens stored in HttpOnly cookies
|
||||||
- Configurable TTL (default from TOML config)
|
- Configurable TTL (default from TOML config)
|
||||||
- Expired sessions auto-pruned on access
|
- Expired sessions auto-pruned on access
|
||||||
- Constant-time passphrase comparison to mitigate timing attacks
|
- Passwords are verified against salted Argon2id hashes
|
||||||
|
|
||||||
### 7.3 TX Access Control
|
### 7.3 User Management
|
||||||
|
|
||||||
An additional `tx_access_control_enabled` flag can restrict transmit-related actions even
|
Every authenticated non-Guest account gets a Settings > Account tab for changing
|
||||||
for Control-role users, providing an extra safety layer.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+181
-43
@@ -66,8 +66,7 @@ both:
|
|||||||
|------------|----------|----------|
|
|------------|----------|----------|
|
||||||
| `[listen.auth].tokens` | `tokens_file` | one token per line |
|
| `[listen.auth].tokens` | `tokens_file` | one token per line |
|
||||||
| `[[remotes]].auth.token` | `token_file` | the token |
|
| `[[remotes]].auth.token` | `token_file` | the token |
|
||||||
| `[frontends.http.auth].rx_passphrase` | `rx_passphrase_file` | the passphrase |
|
| `[frontends.http.auth].bootstrap_admin_password` | `bootstrap_admin_password_file` | the initial administrator password |
|
||||||
| `[frontends.http.auth].control_passphrase` | `control_passphrase_file` | the passphrase |
|
|
||||||
| `[frontends.http_json.auth].tokens` | `tokens_file` | one token per line |
|
| `[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
|
Blank lines and `#` comments are ignored in the list files. A config that holds
|
||||||
@@ -350,17 +349,20 @@ A name in any of those maps that no remote answers to is a config error.
|
|||||||
|
|
||||||
| Field | Type | Default | Description |
|
| Field | Type | Default | Description |
|
||||||
|-------|------|---------|-------------|
|
|-------|------|---------|-------------|
|
||||||
| `enabled` | bool | `false` | Require a passphrase |
|
| `enabled` | bool | `false` | Enable the user/password ACL |
|
||||||
| `rx_passphrase` | string | — | Passphrase granting receive-only access |
|
| `users_file` | string | `"trx-http-users.json"` | Persistent managed user database |
|
||||||
| `rx_passphrase_file` | string | — | Read it from this file instead |
|
| `bootstrap_admin_username` | string | — | First administrator, used only if the database is absent |
|
||||||
| `control_passphrase` | string | — | Passphrase granting full control |
|
| `bootstrap_admin_password` | string | — | First administrator password |
|
||||||
| `control_passphrase_file` | string | — | Read it from this file instead |
|
| `bootstrap_admin_password_file` | string | — | Read the bootstrap password from this file instead |
|
||||||
| `tx_access_control_enabled` | bool | `true` | Hide TX from unauthenticated users |
|
| `bootstrap_read_enabled` | bool | `true` | Create the default Guest account when the database is absent |
|
||||||
|
| `bootstrap_read_username` | string | `"guest"` | Initial Guest username |
|
||||||
|
| `bootstrap_read_password` | string | `"guest"` | Initial Guest password |
|
||||||
| `session_ttl_min` | u64 | `480` | Session lifetime |
|
| `session_ttl_min` | u64 | `480` | Session lifetime |
|
||||||
| `cookie_secure` | bool | `false` | Set Secure on the session cookie (needs HTTPS) |
|
| `cookie_secure` | bool | `false` | Set Secure on the session cookie (needs HTTPS) |
|
||||||
| `cookie_same_site` | string | `"Lax"` | `Strict`, `Lax`, or `None` |
|
| `cookie_same_site` | string | `"Lax"` | `Strict`, `Lax`, or `None` |
|
||||||
|
|
||||||
With `enabled = true`, at least one passphrase must be set.
|
When enabling ACL for the first time, configure both bootstrap fields. After
|
||||||
|
the database exists, remove the bootstrap credentials from configuration.
|
||||||
|
|
||||||
#### `[frontends.rigctl]`
|
#### `[frontends.rigctl]`
|
||||||
|
|
||||||
@@ -477,6 +479,81 @@ Each browser tab keeps its own selection, so two tabs can watch two rigs.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Logbook
|
||||||
|
|
||||||
|
The **Logbook** tab keeps the station's contacts and speaks ADIF, so a log can
|
||||||
|
be uploaded to LoTW, eQSL, Club Log or QRZ, or moved to another logger.
|
||||||
|
|
||||||
|
An entry opens pre-filled with six fields and no more: the frequency, mode and
|
||||||
|
name of the rig you are on, the time from the server's clock, and — when the
|
||||||
|
entry was started from a decode or a map station — that station's callsign and
|
||||||
|
locator. Signal reports, name, comment and the rest are yours to fill in; a
|
||||||
|
report in particular is never guessed, because an FT8 SNR is not what was sent.
|
||||||
|
|
||||||
|
Your own callsign and locator are not per-contact fields. They are station
|
||||||
|
identity: the callsign comes from `[trx-client.general].callsign`, the locator
|
||||||
|
from the rig's position, and both are shown once at the top of the panel. The
|
||||||
|
operator defaults to the station callsign and can be changed for the session,
|
||||||
|
which is what a multi-operator station needs.
|
||||||
|
|
||||||
|
**Times are the server's**, in UTC — the server is the machine at the radio. If
|
||||||
|
the browser's clock disagrees by more than a second the entry says so rather
|
||||||
|
than logging a time you did not expect.
|
||||||
|
|
||||||
|
**A decode is not a contact.** The decoders are receive-only, so a **Log**
|
||||||
|
button on an FT8 or APRS row opens an entry with what was heard in it and logs
|
||||||
|
nothing by itself. Digital QSOs made in WSJT-X come in through Import ADIF, the
|
||||||
|
way every other logger takes them.
|
||||||
|
|
||||||
|
| Action | What it does |
|
||||||
|
|--------|--------------|
|
||||||
|
| Log contact | Writes the entry and opens a fresh one |
|
||||||
|
| Export ADIF | Downloads the log — filtered, if a filter is set |
|
||||||
|
| Import ADIF | Reads a file, skipping contacts already held and reporting what could not be read |
|
||||||
|
| Worked before | Shows, as you type a callsign, which bands and modes it has been worked on |
|
||||||
|
|
||||||
|
Two contacts are treated as the same when the callsign, band and mode match and
|
||||||
|
the times are within two minutes: two loggers rarely stamp a QSO to the same
|
||||||
|
minute, one recording when it started and the other when it was typed.
|
||||||
|
|
||||||
|
The log is a JSON Lines file, appended one contact at a time so a crash costs at
|
||||||
|
most the contact being written. It lives in your data directory by default;
|
||||||
|
`[trx-client.logbook].path` moves it, for a station that keeps its log on a
|
||||||
|
backed-up volume.
|
||||||
|
|
||||||
|
### Contests
|
||||||
|
|
||||||
|
The **Contest exchange** block on the entry holds the contest's name and the
|
||||||
|
serials — sent and received. Both stay between contacts, because they belong to
|
||||||
|
the session rather than to the contact just logged, and the serial sent counts
|
||||||
|
on by itself so it is not retyped forty times an hour. An exchange that is a
|
||||||
|
zone, a section or a name rather than a number is kept as written.
|
||||||
|
|
||||||
|
**Contest entry (Cabrillo)** exports the entry sponsors accept. Only the
|
||||||
|
contacts of the contest named there are included. The header — operator
|
||||||
|
category, power, claimed score — cannot be worked out from a log, so it is
|
||||||
|
yours to fill in; the defaults are single operator, low power, all bands, mixed
|
||||||
|
mode.
|
||||||
|
|
||||||
|
### Confirmations
|
||||||
|
|
||||||
|
The **QSL** column shows a tick when the other station has confirmed, and the
|
||||||
|
**Confirm** button on a row records a card that has arrived. A confirmation
|
||||||
|
counts from wherever it came: a paper card, LoTW or eQSL. An award wants one of
|
||||||
|
them, not all three, so the log does not ask for all three.
|
||||||
|
|
||||||
|
**Bands worked** counts, per band, the contacts made, the distinct stations
|
||||||
|
worked, and how many of those contacts are confirmed.
|
||||||
|
|
||||||
|
### Ham radio layout
|
||||||
|
|
||||||
|
The **Ham radio** operator layout opens on the logbook with the transceiver
|
||||||
|
controls around it — the arrangement for working the bands, where logging the
|
||||||
|
contact is the task and the radio is the instrument. It is offered only where
|
||||||
|
the selected rig can transmit; a receiver has no contacts to log.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Tune Links
|
## Tune Links
|
||||||
|
|
||||||
Every page of the web UI carries what the radio is doing in its address, so the
|
Every page of the web UI carries what the radio is doing in its address, so the
|
||||||
@@ -503,7 +580,7 @@ The link button in the top bar copies the current link to the clipboard. The
|
|||||||
address bar itself is updated as you tune, using `replaceState`, so sweeping
|
address bar itself is updated as you tune, using `replaceState`, so sweeping
|
||||||
the dial does not fill the browser's history.
|
the dial does not fill the browser's history.
|
||||||
|
|
||||||
Applying a link changes the radio, so it needs the `control` role; an `rx`
|
Applying a link changes the radio, so it needs the `Control` role; a `Read`
|
||||||
session opens the page and says the link was not applied. Links describe the
|
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
|
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.
|
left as it was, rather than publishing a frequency the rig is not on.
|
||||||
@@ -512,53 +589,72 @@ left as it was, rather than publishing a frequency the rig is not on.
|
|||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
The HTTP frontend supports optional passphrase-based authentication with two
|
The HTTP frontend supports an optional user/password ACL:
|
||||||
roles:
|
|
||||||
|
|
||||||
- **rx** — read-only access (monitoring, audio, decode streams)
|
- **Guest** — read-only station access with no Account or Users controls; Guest cannot be combined with another role
|
||||||
- **control** — full access (frequency, mode, PTT, and all settings)
|
- **Read** — monitoring, audio, decode streams, and bookmark reads
|
||||||
|
- **Control** — tuning, mode, power, and receive-side radio controls
|
||||||
|
- **Transmit** — PTT, transmitted audio, and TX power-limit controls
|
||||||
|
- **Write** — logbook access and bookmark changes
|
||||||
|
- **Administrator** — user management and all other permissions
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[frontends.http.auth]
|
[frontends.http.auth]
|
||||||
enabled = false
|
enabled = false
|
||||||
rx_passphrase = "rx-only-passphrase"
|
users_file = "trx-http-users.json"
|
||||||
control_passphrase = "full-control-passphrase"
|
bootstrap_admin_username = "admin"
|
||||||
tx_access_control_enabled = true
|
bootstrap_admin_password = "change-this-password"
|
||||||
|
bootstrap_read_enabled = true
|
||||||
|
bootstrap_read_username = "guest"
|
||||||
|
bootstrap_read_password = "guest"
|
||||||
session_ttl_min = 480
|
session_ttl_min = 480
|
||||||
cookie_secure = false # true if served via HTTPS
|
cookie_secure = false # true if served via HTTPS
|
||||||
cookie_same_site = "Lax" # Strict|Lax|None
|
cookie_same_site = "Lax" # Strict|Lax|None
|
||||||
```
|
```
|
||||||
|
|
||||||
When `enabled = false` (the default), all auth is bypassed and the UI behaves
|
When `enabled = false` (the default), all auth is bypassed and the UI behaves
|
||||||
as before. When enabled, at least one passphrase must be set.
|
as before. When enabling it for the first time, bootstrap credentials create
|
||||||
|
the initial administrator (with every non-Guest role), the default `guest`/`guest` Guest
|
||||||
|
account, and the Argon2id-hashed user database. Change or disable the guest
|
||||||
|
credentials in configuration before first startup on an exposed deployment.
|
||||||
|
|
||||||
### Behaviour
|
### Behaviour
|
||||||
|
|
||||||
- On login, the server issues an `HttpOnly` session cookie.
|
- On login, the server issues an `HttpOnly` session cookie.
|
||||||
- Sessions are in-memory; a server restart invalidates all sessions.
|
- Sessions are in-memory; a server restart invalidates all sessions.
|
||||||
- Rate limiting is applied per IP to mitigate brute-force attempts.
|
- Rate limiting is applied per IP to mitigate brute-force attempts.
|
||||||
- When `tx_access_control_enabled = true`, TX/PTT controls are hidden and
|
- User records persist in `users_file`; passwords are stored as salted Argon2id hashes.
|
||||||
rejected for unauthenticated or `rx`-role users.
|
- Non-Guest roles are independent; for example, an account may have Read and Write without Control.
|
||||||
|
- Guest accounts have no account-control panels and cannot call account-control endpoints.
|
||||||
|
- Every non-Guest signed-in user can change their own password in Settings > Account. This signs out all of their sessions.
|
||||||
|
- Administrators can add, enable/disable, or remove users and change roles/passwords in Settings > Users.
|
||||||
|
- At least one enabled administrator must always remain and cannot be disabled, removed, or demoted.
|
||||||
|
- Disabling/removing an account or changing its password/roles revokes all of its sessions.
|
||||||
|
- Existing account files migrate automatically: legacy accounts are enabled by default and legacy `user`/`admin` roles become Read/all roles.
|
||||||
|
|
||||||
### Routes
|
### Routes
|
||||||
|
|
||||||
| Endpoint | Method | Description |
|
| Endpoint | Method | Description |
|
||||||
|----------|--------|-------------|
|
|----------|--------|-------------|
|
||||||
| `/auth/login` | POST | Submit `{ "passphrase": "..." }` |
|
| `/auth/login` | POST | Submit `{ "username": "...", "password": "..." }` |
|
||||||
| `/auth/logout` | POST | Clear session |
|
| `/auth/logout` | POST | Clear session |
|
||||||
| `/auth/session` | GET | Check current session/role |
|
| `/auth/session` | GET | Check current session/roles |
|
||||||
|
| `/auth/account/password` | PATCH | Change a non-Guest user's password after verifying the current password |
|
||||||
|
| `/auth/users` | GET/POST | List or add users (admin only) |
|
||||||
|
| `/auth/users/{username}` | PATCH/DELETE | Change enabled state/password/roles or remove user (administrator only) |
|
||||||
|
|
||||||
Protected routes require at least `rx` role. Control routes (set frequency,
|
Read routes accept Guest or require Read. Tuning and receive-side radio mutations
|
||||||
mode, PTT, etc.) require `control` role.
|
require Control. PTT, transmitted audio, and TX limit changes require Transmit.
|
||||||
|
Logbook access and bookmark mutations require Write. Administrator grants every permission.
|
||||||
|
|
||||||
### Frontend Flow
|
### Frontend Flow
|
||||||
|
|
||||||
1. On load, the UI calls `/auth/session`.
|
1. On load, the UI calls `/auth/session`.
|
||||||
2. If unauthenticated, a login screen is shown.
|
2. If unauthenticated, a login screen is shown.
|
||||||
3. On successful login, the normal UI loads.
|
3. On successful login, the normal UI loads.
|
||||||
4. `rx` users see a read-only interface; `control` users get full controls.
|
4. The interface enables controls according to the account's roles.
|
||||||
5. If a session expires mid-use, streams stop and the login screen returns.
|
5. If a session expires mid-use, streams stop and the login screen returns.
|
||||||
|
|
||||||
### Transport Security
|
### Transport Security
|
||||||
@@ -656,9 +752,30 @@ A dedicated tab with a clock icon provides:
|
|||||||
## SDR Noise Blanker
|
## SDR Noise Blanker
|
||||||
|
|
||||||
The noise blanker suppresses impulse noise (clicks, pops, ignition interference)
|
The noise blanker suppresses impulse noise (clicks, pops, ignition interference)
|
||||||
on raw IQ samples before any mixing or filtering takes place. It works by
|
on raw IQ samples before any mixing or filtering takes place — the only point in
|
||||||
tracking a running RMS level of the signal and replacing any sample whose
|
the chain where an impulse is still short in time, since the narrow channel
|
||||||
magnitude exceeds **threshold x RMS** with the last known clean sample.
|
filter downstream smears it into un-removable ringing.
|
||||||
|
|
||||||
|
It combines four elements:
|
||||||
|
|
||||||
|
- A **noise-floor tracker** — an exponential estimate of the background level,
|
||||||
|
updated only from clean samples (and frozen during a blank) so a burst cannot
|
||||||
|
drag the reference up and blind the detector.
|
||||||
|
- **Detection** — a sample is flagged when its power exceeds
|
||||||
|
**threshold² × noise-floor**.
|
||||||
|
- **Look-ahead** — the stream is delayed a few microseconds so the gate can
|
||||||
|
begin closing *before* the impulse reaches the output, catching its leading
|
||||||
|
edge instead of letting it leak through.
|
||||||
|
- A **tapered gate** — instead of a hard sample-and-hold (which splatters energy
|
||||||
|
back across the passband and is what made the old blanker sound worse on
|
||||||
|
SSB/CW/data), the gain ramps smoothly down and back up, so blanking costs only
|
||||||
|
a short, quiet notch.
|
||||||
|
|
||||||
|
The blank window, look-ahead, taper, and floor time constant are chosen by a
|
||||||
|
**profile** matched to the interference source. The `threshold` control is
|
||||||
|
orthogonal — it sets detection sensitivity within the chosen profile. All
|
||||||
|
profile timings are specified in real time and converted to samples at the
|
||||||
|
capture rate, so the blanker behaves consistently across SDR sample rates.
|
||||||
|
|
||||||
### Configuration (server-side)
|
### Configuration (server-side)
|
||||||
|
|
||||||
@@ -675,6 +792,7 @@ type = "sdr"
|
|||||||
[rigs.sdr.noise_blanker]
|
[rigs.sdr.noise_blanker]
|
||||||
enabled = true
|
enabled = true
|
||||||
threshold = 10.0 # 1 – 100; lower = more aggressive blanking
|
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]`:
|
For the legacy single-rig (flat) config the path is `[sdr.noise_blanker]`:
|
||||||
@@ -683,15 +801,30 @@ For the legacy single-rig (flat) config the path is `[sdr.noise_blanker]`:
|
|||||||
[sdr.noise_blanker]
|
[sdr.noise_blanker]
|
||||||
enabled = true
|
enabled = true
|
||||||
threshold = 10.0
|
threshold = 10.0
|
||||||
|
profile = "spike"
|
||||||
```
|
```
|
||||||
|
|
||||||
| Field | Type | Default | Range | Description |
|
| Field | Type | Default | Range | Description |
|
||||||
|-------------|-------|---------|---------|-------------|
|
|-------------|--------|-----------|---------|-------------|
|
||||||
| `enabled` | bool | false | — | Turn the noise blanker on or off. |
|
| `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. |
|
| `threshold` | float | 10.0 | 1 – 100 | Multiplier applied to the tracked noise floor. A sample whose magnitude exceeds this multiple is blanked. Lower values blank more aggressively; higher values only catch strong impulses. |
|
||||||
|
| `profile` | string | `"spike"` | see below | Tuning profile matched to the interference source. |
|
||||||
|
|
||||||
The noise blanker is off by default.
|
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
|
### Choosing a threshold
|
||||||
|
|
||||||
The threshold controls how aggressively the blanker suppresses impulses.
|
The threshold controls how aggressively the blanker suppresses impulses.
|
||||||
@@ -717,39 +850,44 @@ the running average signal level.
|
|||||||
|
|
||||||
### Web UI
|
### Web UI
|
||||||
|
|
||||||
When the server reports noise-blanker support, two controls appear in the
|
When the server reports noise-blanker support, these controls appear in the
|
||||||
**SDR Settings** row of the web interface:
|
**SDR Settings** row of the web interface:
|
||||||
|
|
||||||
- **Noise Blanker** checkbox — enables or disables the blanker in real time.
|
- **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
|
- **NB Threshold** number input (1–100) with a **Set** button — adjusts the
|
||||||
detection threshold. Press Enter or click Set to apply.
|
detection sensitivity. Press Enter or click Set to apply.
|
||||||
|
- **NB profile** selector — chooses the profile (Spike / Ignition / Powerline /
|
||||||
|
Broadband). Changing it applies immediately.
|
||||||
|
|
||||||
Both controls stay hidden until the server sends filter state containing NB
|
The controls stay hidden until the server sends filter state containing NB
|
||||||
fields, so they only appear when connected to an SDR backend.
|
fields, so they only appear when connected to an SDR backend.
|
||||||
|
|
||||||
### HTTP API
|
### HTTP API
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /set_sdr_noise_blanker?enabled=true&threshold=10
|
POST /set_sdr_noise_blanker?enabled=true&threshold=10&profile=spike
|
||||||
```
|
```
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
| Parameter | Type | Required | Description |
|
||||||
|-------------|--------|----------|-------------|
|
|-------------|--------|----------|-------------|
|
||||||
| `enabled` | bool | yes | `true` or `false` |
|
| `enabled` | bool | yes | `true` or `false` |
|
||||||
| `threshold` | float | yes | Value between 1 and 100 |
|
| `threshold` | float | yes | Value between 1 and 100 |
|
||||||
|
| `profile` | string | no | `spike` (default), `ignition`, `powerline`, or `broadband` |
|
||||||
|
|
||||||
### How it works
|
### How it works
|
||||||
|
|
||||||
The blanker runs on every IQ block (4096 samples) *before* the mixer stage in
|
The blanker runs on every IQ block *before* the mixer stage in the DSP pipeline,
|
||||||
the DSP pipeline:
|
one sample at a time:
|
||||||
|
|
||||||
1. For each sample, compute magnitude² (`re² + im²`).
|
1. Emit the sample from the look-ahead delay line and ingest the fresh one.
|
||||||
2. Compare against `threshold² × mean_sq` (the exponentially-smoothed running
|
2. Compute the fresh sample's power (`re² + im²`) and compare it against
|
||||||
mean of magnitude²).
|
`threshold² × noise_floor`.
|
||||||
3. If the sample exceeds the threshold, replace it with the previous clean
|
3. If it exceeds the threshold, hold the gate closed for the profile's blank
|
||||||
sample.
|
window; the fresh sample reaches the output a few samples later, by which
|
||||||
4. Otherwise, update the running mean with smoothing factor α = 1/128 and store
|
time the gate has fully ramped to zero — so the leading edge is removed.
|
||||||
the sample as the last clean value.
|
4. Otherwise, update the noise-floor estimate (skipped while blanking) and let
|
||||||
|
the gate ramp back open.
|
||||||
|
|
||||||
Because the blanker operates on raw IQ before frequency translation, it removes
|
Because the blanker operates on raw IQ before frequency translation, it removes
|
||||||
impulse noise across the entire captured bandwidth regardless of the tuned
|
impulse noise across the entire captured bandwidth regardless of the tuned
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
#
|
||||||
|
# systemd *user* unit for the trx-rs radio client and web frontend.
|
||||||
|
#
|
||||||
|
# Install with `script/install.sh` (which substitutes @BINDIR@ and copies this
|
||||||
|
# into ~/.config/systemd/user/), or by hand:
|
||||||
|
# sed "s|@BINDIR@|$HOME/.local/bin|" trx-client.service \
|
||||||
|
# > ~/.config/systemd/user/trx-client.service
|
||||||
|
# systemctl --user daemon-reload
|
||||||
|
# systemctl --user enable --now trx-client.service
|
||||||
|
#
|
||||||
|
# Reads the combined config at ~/.config/trx-rs/trx-rs.toml ([trx-client]
|
||||||
|
# section) and connects to the server (default 127.0.0.1:4530). The web UI
|
||||||
|
# defaults to http://127.0.0.1:8080.
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=trx-rs radio client and web frontend
|
||||||
|
Documentation=https://github.com/stanislawgrams/trx-rs
|
||||||
|
# Wait for networking and, when it is enabled, the local server. After= only
|
||||||
|
# orders units already in the transaction: it deliberately does not start a
|
||||||
|
# disabled trx-server, since the client may connect to a remote server instead.
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target trx-server.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=@BINDIR@/trx-client --config %h/.config/trx-rs/trx-rs.toml
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=2
|
||||||
|
# Shuts down cleanly on SIGINT (see trx-server.service for the rationale).
|
||||||
|
KillSignal=SIGINT
|
||||||
|
TimeoutStopSec=15
|
||||||
|
NoNewPrivileges=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
#
|
||||||
|
# systemd *user* unit for the trx-rs radio server.
|
||||||
|
#
|
||||||
|
# Install with `script/install.sh` (which substitutes @BINDIR@ and copies this
|
||||||
|
# into ~/.config/systemd/user/), or by hand:
|
||||||
|
# sed "s|@BINDIR@|$HOME/.local/bin|" trx-server.service \
|
||||||
|
# > ~/.config/systemd/user/trx-server.service
|
||||||
|
# systemctl --user daemon-reload
|
||||||
|
# systemctl --user enable --now trx-server.service
|
||||||
|
#
|
||||||
|
# Reads the combined config at ~/.config/trx-rs/trx-rs.toml ([trx-server]
|
||||||
|
# section). Serial (/dev/ttyUSB*) and audio access require your user to be in
|
||||||
|
# the `dialout` and `audio` groups — a user unit cannot grant them itself.
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=trx-rs radio server (CAT/SDR backend)
|
||||||
|
Documentation=https://github.com/stanislawgrams/trx-rs
|
||||||
|
# The server binds its control/audio TCP listeners and may connect to networked
|
||||||
|
# rigs and reporting services, so do not start it before networking is ready.
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=@BINDIR@/trx-server --config %h/.config/trx-rs/trx-rs.toml
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=2
|
||||||
|
# Both binaries shut down cleanly on SIGINT (the server drains audio and exits
|
||||||
|
# 0); systemd's default SIGTERM is not handled, so signal SIGINT instead.
|
||||||
|
KillSignal=SIGINT
|
||||||
|
TimeoutStopSec=15
|
||||||
|
NoNewPrivileges=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
Executable
+156
@@ -0,0 +1,156 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
#
|
||||||
|
# Build trx-rs in release mode and install the binaries system-wide, seed a
|
||||||
|
# per-user config, and install systemd *user* services to run the server and
|
||||||
|
# client. Idempotent: existing config is never overwritten.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
BINARIES=(trx-server trx-client trx-configurator)
|
||||||
|
|
||||||
|
# --- Defaults (override via flags or environment) ---------------------------
|
||||||
|
PREFIX="${PREFIX:-$HOME/.local}"
|
||||||
|
BINDIR="${BINDIR:-}" # derived from PREFIX unless set explicitly
|
||||||
|
NO_SDR=0
|
||||||
|
DO_BUILD=1
|
||||||
|
DO_SYSTEMD=1
|
||||||
|
ENABLE_NOW=0
|
||||||
|
|
||||||
|
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/trx-rs"
|
||||||
|
CONFIG_FILE="$CONFIG_DIR/trx-rs.toml"
|
||||||
|
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
Usage: script/install.sh [options]
|
||||||
|
|
||||||
|
Build trx-rs (release) and install it.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--prefix DIR Install prefix; binaries go to DIR/bin (default: \$HOME/.local)
|
||||||
|
--bindir DIR Install binaries directly to DIR (overrides --prefix)
|
||||||
|
--no-sdr Build without SoapySDR support (--no-default-features)
|
||||||
|
--no-build Skip cargo build; install existing target/release binaries
|
||||||
|
--no-systemd Do not install the systemd user services
|
||||||
|
--enable-now Enable and start the user services immediately after install
|
||||||
|
-h, --help Show this help
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
PREFIX, BINDIR Same as the matching flags.
|
||||||
|
|
||||||
|
Installs:
|
||||||
|
binaries -> ${BINDIR:-$PREFIX/bin} (uses sudo if not writable)
|
||||||
|
config -> $CONFIG_FILE (from trx-rs.toml.example, only if absent)
|
||||||
|
services -> $UNIT_DIR/{trx-server,trx-client}.service
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Parse args -------------------------------------------------------------
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--prefix) PREFIX="$2"; shift 2 ;;
|
||||||
|
--bindir) BINDIR="$2"; shift 2 ;;
|
||||||
|
--no-sdr) NO_SDR=1; shift ;;
|
||||||
|
--no-build) DO_BUILD=0; shift ;;
|
||||||
|
--no-systemd) DO_SYSTEMD=0; shift ;;
|
||||||
|
--enable-now) ENABLE_NOW=1; shift ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
*) echo "error: unknown option '$1'" >&2; usage >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[ -n "$BINDIR" ] || BINDIR="$PREFIX/bin"
|
||||||
|
|
||||||
|
log() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||||
|
warn() { printf '\033[1;33mwarning:\033[0m %s\n' "$*" >&2; }
|
||||||
|
|
||||||
|
# Return 0 (true) if writing into $1 needs elevated privileges.
|
||||||
|
need_sudo() {
|
||||||
|
local dir="$1"
|
||||||
|
while [ ! -e "$dir" ]; do dir="$(dirname "$dir")"; done
|
||||||
|
[ -w "$dir" ] && return 1 || return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Build ------------------------------------------------------------------
|
||||||
|
if [ "$DO_BUILD" -eq 1 ]; then
|
||||||
|
build_args=(build --release --manifest-path "$PROJECT_ROOT/Cargo.toml")
|
||||||
|
for b in "${BINARIES[@]}"; do build_args+=(-p "$b"); done
|
||||||
|
if [ "$NO_SDR" -eq 1 ]; then
|
||||||
|
# --no-default-features only affects trx-server; harmless elsewhere.
|
||||||
|
build_args+=(-p trx-server --no-default-features)
|
||||||
|
log "Building (release, no SDR)…"
|
||||||
|
else
|
||||||
|
log "Building (release, with SDR)…"
|
||||||
|
fi
|
||||||
|
cargo "${build_args[@]}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Install binaries -------------------------------------------------------
|
||||||
|
SUDO=""
|
||||||
|
if need_sudo "$BINDIR"; then
|
||||||
|
SUDO="sudo"
|
||||||
|
log "Installing binaries to $BINDIR (using sudo)…"
|
||||||
|
else
|
||||||
|
log "Installing binaries to $BINDIR…"
|
||||||
|
fi
|
||||||
|
|
||||||
|
for b in "${BINARIES[@]}"; do
|
||||||
|
src="$PROJECT_ROOT/target/release/$b"
|
||||||
|
if [ ! -x "$src" ]; then
|
||||||
|
echo "error: $src not found (build failed or --no-build with no prior build?)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
$SUDO install -Dm755 "$src" "$BINDIR/$b"
|
||||||
|
log "installed $BINDIR/$b"
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- Seed per-user config ---------------------------------------------------
|
||||||
|
if [ -e "$CONFIG_FILE" ]; then
|
||||||
|
log "Config already present, leaving it untouched: $CONFIG_FILE"
|
||||||
|
else
|
||||||
|
mkdir -p "$CONFIG_DIR"
|
||||||
|
install -m600 "$PROJECT_ROOT/trx-rs.toml.example" "$CONFIG_FILE"
|
||||||
|
log "Seeded config from example: $CONFIG_FILE"
|
||||||
|
warn "Edit $CONFIG_FILE for your rig before starting the services."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- systemd user services --------------------------------------------------
|
||||||
|
if [ "$DO_SYSTEMD" -eq 1 ]; then
|
||||||
|
mkdir -p "$UNIT_DIR"
|
||||||
|
for unit in trx-server trx-client; do
|
||||||
|
sed "s|@BINDIR@|$BINDIR|g" \
|
||||||
|
"$PROJECT_ROOT/packaging/systemd/$unit.service" \
|
||||||
|
> "$UNIT_DIR/$unit.service"
|
||||||
|
log "installed $UNIT_DIR/$unit.service"
|
||||||
|
done
|
||||||
|
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
systemctl --user daemon-reload || warn "systemctl --user daemon-reload failed"
|
||||||
|
if [ "$ENABLE_NOW" -eq 1 ]; then
|
||||||
|
log "Enabling and starting user services…"
|
||||||
|
systemctl --user enable --now trx-server.service trx-client.service
|
||||||
|
warn "Run 'loginctl enable-linger $USER' to keep services running after logout."
|
||||||
|
else
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
Next steps:
|
||||||
|
1. Edit your config: \$EDITOR $CONFIG_FILE
|
||||||
|
2. Start the services: systemctl --user enable --now trx-server trx-client
|
||||||
|
3. Watch the logs: journalctl --user -u trx-server -u trx-client -f
|
||||||
|
4. Run after logout: loginctl enable-linger $USER
|
||||||
|
|
||||||
|
Web UI (default): http://127.0.0.1:8080
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "systemctl not found; units copied but not reloaded."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Done."
|
||||||
Executable
+86
@@ -0,0 +1,86 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
#
|
||||||
|
# Reverse script/install.sh: stop and remove the systemd user services and the
|
||||||
|
# installed binaries. User config (~/.config/trx-rs) is left in place unless
|
||||||
|
# --purge is given.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BINARIES=(trx-server trx-client trx-configurator)
|
||||||
|
|
||||||
|
PREFIX="${PREFIX:-$HOME/.local}"
|
||||||
|
BINDIR="${BINDIR:-}"
|
||||||
|
PURGE=0
|
||||||
|
|
||||||
|
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/trx-rs"
|
||||||
|
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
Usage: script/uninstall.sh [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--prefix DIR Install prefix binaries were installed under (default: \$HOME/.local)
|
||||||
|
--bindir DIR Directory binaries were installed to (overrides --prefix)
|
||||||
|
--purge Also delete the user config directory ($CONFIG_DIR)
|
||||||
|
-h, --help Show this help
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--prefix) PREFIX="$2"; shift 2 ;;
|
||||||
|
--bindir) BINDIR="$2"; shift 2 ;;
|
||||||
|
--purge) PURGE=1; shift ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
*) echo "error: unknown option '$1'" >&2; usage >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[ -n "$BINDIR" ] || BINDIR="$PREFIX/bin"
|
||||||
|
|
||||||
|
log() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||||
|
|
||||||
|
need_sudo() {
|
||||||
|
local dir="$1"
|
||||||
|
while [ ! -e "$dir" ]; do dir="$(dirname "$dir")"; done
|
||||||
|
[ -w "$dir" ] && return 1 || return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Stop and remove user services -----------------------------------------
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
log "Stopping and disabling user services…"
|
||||||
|
systemctl --user disable --now trx-client.service trx-server.service 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
for unit in trx-client trx-server; do
|
||||||
|
if [ -e "$UNIT_DIR/$unit.service" ]; then
|
||||||
|
rm -f "$UNIT_DIR/$unit.service"
|
||||||
|
log "removed $UNIT_DIR/$unit.service"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
systemctl --user daemon-reload || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Remove binaries --------------------------------------------------------
|
||||||
|
SUDO=""
|
||||||
|
if need_sudo "$BINDIR"; then SUDO="sudo"; fi
|
||||||
|
for b in "${BINARIES[@]}"; do
|
||||||
|
if [ -e "$BINDIR/$b" ]; then
|
||||||
|
$SUDO rm -f "$BINDIR/$b"
|
||||||
|
log "removed $BINDIR/$b"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- Optionally purge config ------------------------------------------------
|
||||||
|
if [ "$PURGE" -eq 1 ]; then
|
||||||
|
rm -rf "$CONFIG_DIR"
|
||||||
|
log "purged $CONFIG_DIR"
|
||||||
|
else
|
||||||
|
log "Left config in place: $CONFIG_DIR (use --purge to delete)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Done."
|
||||||
@@ -45,8 +45,20 @@ impl WefaxConfig {
|
|||||||
60.0 / lpm as f32
|
60.0 / lpm as f32
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Samples per line at the internal sample rate.
|
/// Samples per line at the internal sample rate (rounded to an integer;
|
||||||
|
/// use [`Self::samples_per_line_f64`] for drift-free line slicing).
|
||||||
pub fn samples_per_line(lpm: u16, sample_rate: u32) -> usize {
|
pub fn samples_per_line(lpm: u16, sample_rate: u32) -> usize {
|
||||||
(Self::line_duration_s(lpm) * sample_rate as f32).round() as 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,10 +38,24 @@ const SIGNAL_DETECT_WINDOWS: u32 = 6;
|
|||||||
/// Real WEFAX content typically shows r > 0.5 between adjacent lines.
|
/// Real WEFAX content typically shows r > 0.5 between adjacent lines.
|
||||||
const LINE_CORR_NOISE_THRESHOLD: f32 = 0.2;
|
const LINE_CORR_NOISE_THRESHOLD: f32 = 0.2;
|
||||||
|
|
||||||
/// Number of consecutive uncorrelated scan lines that trigger auto-finalize
|
/// Number of consecutive uncorrelated scan lines that mark the end of a
|
||||||
/// while receiving. At 120 LPM this is 15 s; at 60 LPM it's 30 s. Modelled on
|
/// transmission (carrier truly gone) and trigger auto-finalize. This must be
|
||||||
/// fldigi's line-to-line correlation check for automatic stop.
|
/// long enough to ride through ordinary HF fading (QSB), which routinely
|
||||||
const LINE_CORR_NOISE_LINES: u32 = 30;
|
/// decorrelates adjacent lines for several seconds without the transmission
|
||||||
|
/// having ended. At 120 LPM this is ~60 s; at 60 LPM ~120 s.
|
||||||
|
///
|
||||||
|
/// A short window here is what previously chopped a single chart into many
|
||||||
|
/// PNG "chunks": a 15 s fade tripped the watchdog, the image was finalized,
|
||||||
|
/// and the still-present carrier immediately re-started a fresh image. Real
|
||||||
|
/// WEFAX decoders (fldigi) keep one continuous image per APT cycle up to a
|
||||||
|
/// large line cap and only stop on the APT stop tone or genuine signal loss.
|
||||||
|
const LINE_CORR_NOISE_LINES: u32 = 120;
|
||||||
|
|
||||||
|
/// Hard cap on lines in a single image. A 120 LPM chart runs ~10 min
|
||||||
|
/// (~1200 lines); this cap (≈25 min at 120 LPM) only bounds memory if a
|
||||||
|
/// session is left running on an open carrier. On reaching it the image is
|
||||||
|
/// finalized and the decoder waits for a fresh APT start.
|
||||||
|
const MAX_IMAGE_LINES: u32 = 3000;
|
||||||
|
|
||||||
/// Maximum number of scan-line-equivalent sample windows to wait for phasing
|
/// Maximum number of scan-line-equivalent sample windows to wait for phasing
|
||||||
/// lock before falling through to Receiving. Typical WEFAX phasing lasts
|
/// lock before falling through to Receiving. Typical WEFAX phasing lasts
|
||||||
@@ -107,6 +121,12 @@ pub struct WefaxDecoder {
|
|||||||
/// the decoder falls through to Receiving so a noisy or partial
|
/// the decoder falls through to Receiving so a noisy or partial
|
||||||
/// phasing signal doesn't wedge the state machine.
|
/// phasing signal doesn't wedge the state machine.
|
||||||
phasing_samples: u64,
|
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).
|
/// Current rig dial frequency in Hz (for image filenames).
|
||||||
freq_hz: u64,
|
freq_hz: u64,
|
||||||
/// Current rig mode name (for image filenames).
|
/// Current rig mode name (for image filenames).
|
||||||
@@ -135,6 +155,7 @@ impl WefaxDecoder {
|
|||||||
signal_detect_buf: Vec::with_capacity(INTERNAL_RATE as usize / 2),
|
signal_detect_buf: Vec::with_capacity(INTERNAL_RATE as usize / 2),
|
||||||
low_corr_lines: 0,
|
low_corr_lines: 0,
|
||||||
phasing_samples: 0,
|
phasing_samples: 0,
|
||||||
|
auto_start_used: false,
|
||||||
freq_hz: 0,
|
freq_hz: 0,
|
||||||
mode: String::new(),
|
mode: String::new(),
|
||||||
}
|
}
|
||||||
@@ -204,7 +225,13 @@ impl WefaxDecoder {
|
|||||||
// Fallback: detect active WEFAX signal by luminance variance.
|
// Fallback: detect active WEFAX signal by luminance variance.
|
||||||
// Like fldigi's "strong image signal" detection — if we see
|
// Like fldigi's "strong image signal" detection — if we see
|
||||||
// sustained modulated signal, auto-start receiving with defaults.
|
// sustained modulated signal, auto-start receiving with defaults.
|
||||||
if self.state == State::Idle {
|
//
|
||||||
|
// Only ever taken once per session: it exists to catch a user
|
||||||
|
// tuning in mid-image with no APT start. After the first image,
|
||||||
|
// a new reception requires an APT start tone, so the residual
|
||||||
|
// carrier / noise that trails a finished chart cannot silently
|
||||||
|
// spawn a second image (which is what produced many chunks).
|
||||||
|
if self.state == State::Idle && !self.auto_start_used {
|
||||||
self.signal_detect_buf.extend_from_slice(&luminance);
|
self.signal_detect_buf.extend_from_slice(&luminance);
|
||||||
let window_size = INTERNAL_RATE as usize / 2;
|
let window_size = INTERNAL_RATE as usize / 2;
|
||||||
while self.signal_detect_buf.len() >= window_size {
|
while self.signal_detect_buf.len() >= window_size {
|
||||||
@@ -340,6 +367,14 @@ impl WefaxDecoder {
|
|||||||
break;
|
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.
|
// Emit progress event.
|
||||||
if self.config.emit_progress && count % PROGRESS_INTERVAL == 0 {
|
if self.config.emit_progress && count % PROGRESS_INTERVAL == 0 {
|
||||||
let line_data =
|
let line_data =
|
||||||
@@ -402,6 +437,7 @@ impl WefaxDecoder {
|
|||||||
self.signal_detect_buf.clear();
|
self.signal_detect_buf.clear();
|
||||||
self.low_corr_lines = 0;
|
self.low_corr_lines = 0;
|
||||||
self.phasing_samples = 0;
|
self.phasing_samples = 0;
|
||||||
|
self.auto_start_used = false;
|
||||||
events
|
events
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,6 +470,7 @@ impl WefaxDecoder {
|
|||||||
fn transition_to_start_detected(&mut self, ioc: u16) -> WefaxEvent {
|
fn transition_to_start_detected(&mut self, ioc: u16) -> WefaxEvent {
|
||||||
let ioc = self.config.ioc.unwrap_or(ioc);
|
let ioc = self.config.ioc.unwrap_or(ioc);
|
||||||
debug!(ioc, "WEFAX: APT start detected");
|
debug!(ioc, "WEFAX: APT start detected");
|
||||||
|
self.auto_start_used = true;
|
||||||
self.state = State::StartDetected { ioc };
|
self.state = State::StartDetected { ioc };
|
||||||
self.reception_start_ms = Some(
|
self.reception_start_ms = Some(
|
||||||
std::time::SystemTime::now()
|
std::time::SystemTime::now()
|
||||||
@@ -463,6 +500,7 @@ impl WefaxDecoder {
|
|||||||
self.image = Some(ImageAssembler::new(ppl));
|
self.image = Some(ImageAssembler::new(ppl));
|
||||||
self.tone_detector.reset();
|
self.tone_detector.reset();
|
||||||
self.low_corr_lines = 0;
|
self.low_corr_lines = 0;
|
||||||
|
self.auto_start_used = true;
|
||||||
self.state = State::Receiving { ioc, lpm };
|
self.state = State::Receiving { ioc, lpm };
|
||||||
self.state_event("Receiving", ioc, lpm)
|
self.state_event("Receiving", ioc, lpm)
|
||||||
}
|
}
|
||||||
@@ -596,4 +634,47 @@ mod tests {
|
|||||||
dec.reset();
|
dec.reset();
|
||||||
assert_eq!(dec.state, State::Idle);
|
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.
|
/// Line slicer for WEFAX image assembly.
|
||||||
pub struct LineSlicer {
|
pub struct LineSlicer {
|
||||||
/// Samples per line at the internal sample rate.
|
/// Exact (fractional) samples per line at the internal sample rate.
|
||||||
samples_per_line: usize,
|
samples_per_line: f64,
|
||||||
/// Pixels per line (IOC × π).
|
/// Pixels per line (IOC × π).
|
||||||
pixels_per_line: usize,
|
pixels_per_line: usize,
|
||||||
/// Phase offset in samples from the phasing detector.
|
/// Phase offset in samples from the phasing detector.
|
||||||
@@ -22,22 +22,37 @@ pub struct LineSlicer {
|
|||||||
buffer: Vec<f32>,
|
buffer: Vec<f32>,
|
||||||
/// Whether we have aligned to the phase offset yet.
|
/// Whether we have aligned to the phase offset yet.
|
||||||
aligned: bool,
|
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 {
|
impl LineSlicer {
|
||||||
pub fn new(lpm: u16, ioc: u16, sample_rate: u32, phase_offset: usize) -> Self {
|
pub fn new(lpm: u16, ioc: u16, sample_rate: u32, phase_offset: usize) -> Self {
|
||||||
let samples_per_line = WefaxConfig::samples_per_line(lpm, sample_rate);
|
let samples_per_line = WefaxConfig::samples_per_line_f64(lpm, sample_rate);
|
||||||
let pixels_per_line = WefaxConfig::pixels_per_line(ioc) as usize;
|
let pixels_per_line = WefaxConfig::pixels_per_line(ioc) as usize;
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
samples_per_line,
|
samples_per_line,
|
||||||
pixels_per_line,
|
pixels_per_line,
|
||||||
phase_offset,
|
phase_offset,
|
||||||
buffer: Vec::with_capacity(samples_per_line * 2),
|
buffer: Vec::with_capacity(samples_per_line as usize * 2),
|
||||||
aligned: false,
|
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.
|
/// Feed luminance samples and extract complete image lines.
|
||||||
///
|
///
|
||||||
/// Returns a vector of completed lines, each as a `Vec<u8>` of
|
/// Returns a vector of completed lines, each as a `Vec<u8>` of
|
||||||
@@ -56,12 +71,18 @@ impl LineSlicer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract complete lines (single drain at the end to avoid O(n²)).
|
// 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;
|
let mut offset = 0;
|
||||||
while offset + self.samples_per_line <= self.buffer.len() {
|
loop {
|
||||||
let line_samples = &self.buffer[offset..offset + self.samples_per_line];
|
let len = self.line_len(self.line_index);
|
||||||
let pixels = self.resample_line(line_samples);
|
if offset + len > self.buffer.len() {
|
||||||
lines.push(pixels);
|
break;
|
||||||
offset += self.samples_per_line;
|
}
|
||||||
|
let line_samples = &self.buffer[offset..offset + len];
|
||||||
|
lines.push(self.resample_line(line_samples));
|
||||||
|
offset += len;
|
||||||
|
self.line_index += 1;
|
||||||
}
|
}
|
||||||
if offset > 0 {
|
if offset > 0 {
|
||||||
self.buffer.drain(..offset);
|
self.buffer.drain(..offset);
|
||||||
@@ -77,6 +98,7 @@ impl LineSlicer {
|
|||||||
pub fn reset(&mut self) {
|
pub fn reset(&mut self) {
|
||||||
self.buffer.clear();
|
self.buffer.clear();
|
||||||
self.aligned = false;
|
self.aligned = false;
|
||||||
|
self.line_index = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resample a line's worth of luminance samples to the target pixel count
|
/// Resample a line's worth of luminance samples to the target pixel count
|
||||||
@@ -129,6 +151,29 @@ mod tests {
|
|||||||
assert!(lines[0].iter().all(|&p| p == 255));
|
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]
|
#[test]
|
||||||
fn slicer_linear_interpolation() {
|
fn slicer_linear_interpolation() {
|
||||||
let lpm = 120;
|
let lpm = 120;
|
||||||
|
|||||||
@@ -77,8 +77,12 @@ pub struct ToneDetector {
|
|||||||
|
|
||||||
impl ToneDetector {
|
impl ToneDetector {
|
||||||
pub fn new(sample_rate: u32) -> Self {
|
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 window_size = (sample_rate / 2) as usize; // ~0.5 s window
|
||||||
let min_sustain_s = 1.0; // fldigi uses 2 consecutive half-second windows
|
let min_sustain_s = 2.0;
|
||||||
let window_duration_s = window_size as f32 / sample_rate as f32;
|
let window_duration_s = window_size as f32 / sample_rate as f32;
|
||||||
let min_sustain_windows = (min_sustain_s / window_duration_s).ceil() as u32;
|
let min_sustain_windows = (min_sustain_s / window_duration_s).ceil() as u32;
|
||||||
|
|
||||||
|
|||||||
@@ -252,11 +252,17 @@ async fn async_init() -> DynResult<AppState> {
|
|||||||
|
|
||||||
// Set HTTP frontend authentication config
|
// Set HTTP frontend authentication config
|
||||||
frontend_runtime.http_auth.enabled = cfg.frontends.http.auth.enabled;
|
frontend_runtime.http_auth.enabled = cfg.frontends.http.auth.enabled;
|
||||||
frontend_runtime.http_auth.rx_passphrase = cfg.frontends.http.auth.rx_passphrase.clone();
|
frontend_runtime.http_auth.users_file = cfg.frontends.http.auth.users_file.clone();
|
||||||
frontend_runtime.http_auth.control_passphrase =
|
frontend_runtime.http_auth.bootstrap_admin_username =
|
||||||
cfg.frontends.http.auth.control_passphrase.clone();
|
cfg.frontends.http.auth.bootstrap_admin_username.clone();
|
||||||
frontend_runtime.http_auth.tx_access_control_enabled =
|
frontend_runtime.http_auth.bootstrap_admin_password =
|
||||||
cfg.frontends.http.auth.tx_access_control_enabled;
|
cfg.frontends.http.auth.bootstrap_admin_password.clone();
|
||||||
|
frontend_runtime.http_auth.bootstrap_read_enabled =
|
||||||
|
cfg.frontends.http.auth.bootstrap_read_enabled;
|
||||||
|
frontend_runtime.http_auth.bootstrap_read_username =
|
||||||
|
cfg.frontends.http.auth.bootstrap_read_username.clone();
|
||||||
|
frontend_runtime.http_auth.bootstrap_read_password =
|
||||||
|
cfg.frontends.http.auth.bootstrap_read_password.clone();
|
||||||
frontend_runtime.http_auth.session_ttl_secs = cfg.frontends.http.auth.session_ttl().as_secs();
|
frontend_runtime.http_auth.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_secure = cfg.frontends.http.auth.cookie_secure;
|
||||||
frontend_runtime.http_auth.cookie_same_site = match cfg.frontends.http.auth.cookie_same_site {
|
frontend_runtime.http_auth.cookie_same_site = match cfg.frontends.http.auth.cookie_same_site {
|
||||||
@@ -279,6 +285,10 @@ async fn async_init() -> DynResult<AppState> {
|
|||||||
.http
|
.http
|
||||||
.decode_history_retention_min_by_rig
|
.decode_history_retention_min_by_rig
|
||||||
.clone();
|
.clone();
|
||||||
|
frontend_runtime.http_ui.logbook_path = cfg.logbook.path.clone();
|
||||||
|
// The callsign the station is on the air with, which every contact is
|
||||||
|
// logged under and which the operator defaults to.
|
||||||
|
frontend_runtime.http_ui.station_callsign = cfg.general.callsign.clone();
|
||||||
|
|
||||||
// Resolve remote entries: CLI --url > [[remotes]] > legacy [remote] > error
|
// Resolve remote entries: CLI --url > [[remotes]] > legacy [remote] > error
|
||||||
let resolved_remotes: Vec<RemoteEntry> = if let Some(ref url) = cli.url {
|
let resolved_remotes: Vec<RemoteEntry> = if let Some(ref url) = cli.url {
|
||||||
|
|||||||
@@ -257,9 +257,12 @@ impl Default for DecodeHistoryContext {
|
|||||||
/// HTTP authentication configuration.
|
/// HTTP authentication configuration.
|
||||||
pub struct HttpAuthConfig {
|
pub struct HttpAuthConfig {
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
pub rx_passphrase: Option<String>,
|
pub users_file: String,
|
||||||
pub control_passphrase: Option<String>,
|
pub bootstrap_admin_username: Option<String>,
|
||||||
pub tx_access_control_enabled: bool,
|
pub bootstrap_admin_password: Option<String>,
|
||||||
|
pub bootstrap_read_enabled: bool,
|
||||||
|
pub bootstrap_read_username: String,
|
||||||
|
pub bootstrap_read_password: Option<String>,
|
||||||
pub session_ttl_secs: u64,
|
pub session_ttl_secs: u64,
|
||||||
pub cookie_secure: bool,
|
pub cookie_secure: bool,
|
||||||
pub cookie_same_site: String,
|
pub cookie_same_site: String,
|
||||||
@@ -271,9 +274,12 @@ impl Default for HttpAuthConfig {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
rx_passphrase: None,
|
users_file: "trx-http-users.json".to_string(),
|
||||||
control_passphrase: None,
|
bootstrap_admin_username: None,
|
||||||
tx_access_control_enabled: true,
|
bootstrap_admin_password: None,
|
||||||
|
bootstrap_read_enabled: true,
|
||||||
|
bootstrap_read_username: "guest".to_string(),
|
||||||
|
bootstrap_read_password: Some("guest".to_string()),
|
||||||
session_ttl_secs: 480 * 60,
|
session_ttl_secs: 480 * 60,
|
||||||
cookie_secure: false,
|
cookie_secure: false,
|
||||||
cookie_same_site: "Lax".to_string(),
|
cookie_same_site: "Lax".to_string(),
|
||||||
@@ -292,6 +298,10 @@ pub struct HttpUiConfig {
|
|||||||
pub bandplan_region: String,
|
pub bandplan_region: String,
|
||||||
pub decode_history_retention_min: u64,
|
pub decode_history_retention_min: u64,
|
||||||
pub decode_history_retention_min_by_rig: HashMap<String, u64>,
|
pub decode_history_retention_min_by_rig: HashMap<String, u64>,
|
||||||
|
/// Where the station log is kept; `None` takes the default.
|
||||||
|
pub logbook_path: Option<String>,
|
||||||
|
/// The callsign contacts are logged under, and the operator by default.
|
||||||
|
pub station_callsign: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for HttpUiConfig {
|
impl Default for HttpUiConfig {
|
||||||
@@ -305,6 +315,8 @@ impl Default for HttpUiConfig {
|
|||||||
bandplan_region: "iaru_r1".to_string(),
|
bandplan_region: "iaru_r1".to_string(),
|
||||||
decode_history_retention_min: 24 * 60,
|
decode_history_retention_min: 24 * 60,
|
||||||
decode_history_retention_min_by_rig: HashMap::new(),
|
decode_history_retention_min_by_rig: HashMap::new(),
|
||||||
|
logbook_path: None,
|
||||||
|
station_callsign: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ build = "build.rs"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
trx-core = { path = "../../../trx-core" }
|
trx-core = { path = "../../../trx-core" }
|
||||||
trx-frontend = { path = ".." }
|
trx-frontend = { path = ".." }
|
||||||
|
trx-logbook = { path = "../../trx-logbook" }
|
||||||
|
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
|
||||||
trx-protocol = { path = "../../../../src/trx-protocol" }
|
trx-protocol = { path = "../../../../src/trx-protocol" }
|
||||||
tokio = { workspace = true, features = ["full"] }
|
tokio = { workspace = true, features = ["full"] }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
@@ -26,7 +28,11 @@ flate2 = { workspace = true }
|
|||||||
brotli = "7"
|
brotli = "7"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
|
argon2 = "0.5"
|
||||||
pickledb = "0.5"
|
pickledb = "0.5"
|
||||||
dirs = "6"
|
dirs = "6"
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
ts-rs = "12.0.1"
|
ts-rs = "12.0.1"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
|
|||||||
@@ -1,3 +1,20 @@
|
|||||||
|
import {
|
||||||
|
AUTH_ADMIN_ROLES,
|
||||||
|
AUTH_ROLES,
|
||||||
|
AUTH_ROLE_LABELS,
|
||||||
|
changeOwnPassword,
|
||||||
|
createUser,
|
||||||
|
deleteUser,
|
||||||
|
fetchAuthSession,
|
||||||
|
hasAccountControls,
|
||||||
|
hasAuthRole,
|
||||||
|
listUsers,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
normalizeAuthRoles,
|
||||||
|
updateUser
|
||||||
|
} from "./chunk-PISLBJGN.js";
|
||||||
|
|
||||||
// src/webgl-renderer.ts
|
// src/webgl-renderer.ts
|
||||||
(function initTrxWebGl(global) {
|
(function initTrxWebGl(global) {
|
||||||
"use strict";
|
"use strict";
|
||||||
@@ -633,9 +650,12 @@ function elementById(id) {
|
|||||||
compact: { label: "Compact", advanced: false, audio: false, scheduler: false, preferredTab: "main" },
|
compact: { label: "Compact", advanced: false, audio: false, scheduler: false, preferredTab: "main" },
|
||||||
broadcast: { label: "Broadcast", unavailable: "Broadcast requires an enumerated WFM-capable receiver", advanced: false, audio: true, scheduler: false, preferredTab: "main", capability: "broadcast" },
|
broadcast: { label: "Broadcast", unavailable: "Broadcast requires an enumerated WFM-capable receiver", advanced: false, audio: true, scheduler: false, preferredTab: "main", capability: "broadcast" },
|
||||||
digital: { label: "Digital", unavailable: "Digital requires a compatible rig mode and an available decoder", advanced: false, audio: false, scheduler: false, preferredTab: "digital-modes", capability: "digital" },
|
digital: { label: "Digital", unavailable: "Digital requires a compatible rig mode and an available decoder", advanced: false, audio: false, scheduler: false, preferredTab: "digital-modes", capability: "digital" },
|
||||||
full: { label: "Full controls", advanced: true, audio: true, scheduler: true, preferredTab: "main" }
|
full: { label: "Full controls", advanced: true, audio: true, scheduler: true, preferredTab: "main" },
|
||||||
|
// Working the bands: the log is the task and the radio is the instrument,
|
||||||
|
// so this one opens on the logbook with the controls a keystroke away.
|
||||||
|
ham: { label: "Ham radio", unavailable: "Ham radio needs a rig that can transmit", advanced: true, audio: true, scheduler: false, preferredTab: "logbook", capability: "ham" }
|
||||||
};
|
};
|
||||||
const layoutCapabilities = { broadcast: false, digital: false };
|
const layoutCapabilities = { broadcast: false, digital: false, ham: false };
|
||||||
let activeRigId = null;
|
let activeRigId = null;
|
||||||
const layoutSections = [
|
const layoutSections = [
|
||||||
{ id: "advanced-radio-controls", key: "advanced" },
|
{ id: "advanced-radio-controls", key: "advanced" },
|
||||||
@@ -1311,60 +1331,6 @@ async function loadDecoderRegistry(onLoaded) {
|
|||||||
bridge.decoderRegistry = decoderRegistry;
|
bridge.decoderRegistry = decoderRegistry;
|
||||||
bridge.onDecoderRegistryReady = onDecoderRegistryReady;
|
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
|
// src/core/format.ts
|
||||||
function formatDuration(milliseconds) {
|
function formatDuration(milliseconds) {
|
||||||
const seconds = Math.floor(milliseconds / 1e3);
|
const seconds = Math.floor(milliseconds / 1e3);
|
||||||
@@ -1530,6 +1496,7 @@ function decodeCbor(buffer) {
|
|||||||
var TAB_ORDER = [
|
var TAB_ORDER = [
|
||||||
"main",
|
"main",
|
||||||
"bookmarks",
|
"bookmarks",
|
||||||
|
"logbook",
|
||||||
"digital-modes",
|
"digital-modes",
|
||||||
"map",
|
"map",
|
||||||
"satellites",
|
"satellites",
|
||||||
@@ -1541,6 +1508,7 @@ var TAB_ORDER = [
|
|||||||
var TAB_PATHS = {
|
var TAB_PATHS = {
|
||||||
main: "/",
|
main: "/",
|
||||||
bookmarks: "/bookmarks",
|
bookmarks: "/bookmarks",
|
||||||
|
logbook: "/logbook",
|
||||||
"digital-modes": "/digital-modes",
|
"digital-modes": "/digital-modes",
|
||||||
map: "/map",
|
map: "/map",
|
||||||
satellites: "/satellites",
|
satellites: "/satellites",
|
||||||
@@ -1764,6 +1732,7 @@ var pluginGroups = {
|
|||||||
satellites: ["/satellite-predictions.js"],
|
satellites: ["/satellite-predictions.js"],
|
||||||
statistics: ["/map-core.js"],
|
statistics: ["/map-core.js"],
|
||||||
bookmarks: ["/bookmarks.js"],
|
bookmarks: ["/bookmarks.js"],
|
||||||
|
logbook: ["/logbook.js"],
|
||||||
recorder: [],
|
recorder: [],
|
||||||
settings: ["/vchan.js", "/scheduler.js"]
|
settings: ["/vchan.js", "/scheduler.js"]
|
||||||
};
|
};
|
||||||
@@ -1788,7 +1757,7 @@ async function loadPlugins(group) {
|
|||||||
for (const path of pluginGroups[group]) await loadPlugin(path);
|
for (const path of pluginGroups[group]) await loadPlugin(path);
|
||||||
}
|
}
|
||||||
async function loadEagerPlugins() {
|
async function loadEagerPlugins() {
|
||||||
await Promise.all(["digital-modes", "bookmarks", "settings"].map(loadPlugins));
|
await Promise.all(["digital-modes", "bookmarks", "logbook", "settings"].map(loadPlugins));
|
||||||
}
|
}
|
||||||
async function loadPluginsForTab(tab) {
|
async function loadPluginsForTab(tab) {
|
||||||
await loadPlugins(tab);
|
await loadPlugins(tab);
|
||||||
@@ -1856,33 +1825,67 @@ 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");
|
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);
|
void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
|
||||||
var authRole = null;
|
var authRoles = [];
|
||||||
|
var authUsername = null;
|
||||||
var authEnabled = true;
|
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() {
|
async function checkAuthStatus() {
|
||||||
return fetchAuthSession();
|
return fetchAuthSession();
|
||||||
}
|
}
|
||||||
async function authLogin(passphrase) {
|
async function authLogin(username, password) {
|
||||||
return login(passphrase);
|
return login(username, password);
|
||||||
}
|
}
|
||||||
async function authLogout() {
|
async function authLogout() {
|
||||||
try {
|
try {
|
||||||
await logout();
|
await logout();
|
||||||
authRole = null;
|
setAuthRoles([]);
|
||||||
|
authUsername = null;
|
||||||
disconnect();
|
disconnect();
|
||||||
setDecodeHistoryOverlayVisible(false);
|
setDecodeHistoryOverlayVisible(false);
|
||||||
requiredElement("content").style.display = "none";
|
requiredElement("content").style.display = "none";
|
||||||
requiredElement("loading").style.display = "none";
|
requiredElement("loading").style.display = "none";
|
||||||
requiredElement("auth-passphrase").value = "";
|
requiredElement("auth-password").value = "";
|
||||||
updateAuthUI();
|
updateAuthUI();
|
||||||
const authStatus = await checkAuthStatus();
|
showAuthGate();
|
||||||
const allowGuest = authStatus.role === "rx";
|
|
||||||
showAuthGate(allowGuest);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Logout failed:", e);
|
console.error("Logout failed:", e);
|
||||||
showAuthError("Logout failed");
|
showAuthError("Logout failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function showAuthGate(allowGuest = false) {
|
function showAuthGate() {
|
||||||
if (!authEnabled) return;
|
if (!authEnabled) return;
|
||||||
setDecodeHistoryOverlayVisible(false);
|
setDecodeHistoryOverlayVisible(false);
|
||||||
requiredElement("loading").style.display = "none";
|
requiredElement("loading").style.display = "none";
|
||||||
@@ -1899,10 +1902,6 @@ function showAuthGate(allowGuest = false) {
|
|||||||
document.querySelectorAll(".tab-panel").forEach((panel) => {
|
document.querySelectorAll(".tab-panel").forEach((panel) => {
|
||||||
panel.style.display = "none";
|
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) => {
|
document.querySelectorAll(".tab-bar .tab").forEach((btn) => {
|
||||||
btn.classList.toggle("active", btn.dataset.tab === "main");
|
btn.classList.toggle("active", btn.dataset.tab === "main");
|
||||||
});
|
});
|
||||||
@@ -1938,20 +1937,30 @@ function updateAuthUI() {
|
|||||||
const badge = document.getElementById("auth-badge");
|
const badge = document.getElementById("auth-badge");
|
||||||
const badgeRole = document.getElementById("auth-role-badge");
|
const badgeRole = document.getElementById("auth-role-badge");
|
||||||
const headerAuthBtn2 = document.getElementById("header-auth-btn");
|
const headerAuthBtn2 = document.getElementById("header-auth-btn");
|
||||||
|
const accountTab = document.getElementById("settings-account-tab");
|
||||||
if (!authEnabled) {
|
if (!authEnabled) {
|
||||||
if (badge) badge.style.display = "none";
|
if (badge) badge.style.display = "none";
|
||||||
if (headerAuthBtn2) headerAuthBtn2.style.display = "none";
|
if (headerAuthBtn2) headerAuthBtn2.style.display = "none";
|
||||||
|
if (accountTab) accountTab.style.display = "none";
|
||||||
syncTopBarAccess();
|
syncTopBarAccess();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (authRole) {
|
if (authRoles.length > 0) {
|
||||||
|
const canManageAccount = hasAccountControls(authRoles);
|
||||||
|
if (accountTab) accountTab.style.display = canManageAccount ? "" : "none";
|
||||||
|
if (!canManageAccount && accountTab?.classList.contains("active")) {
|
||||||
|
const panel = document.getElementById("subtab-settings-account");
|
||||||
|
if (panel) panel.style.display = "none";
|
||||||
|
document.querySelector('[data-subtab="settings-scheduler"]')?.click();
|
||||||
|
}
|
||||||
if (badge) badge.style.display = "block";
|
if (badge) badge.style.display = "block";
|
||||||
if (badgeRole) badgeRole.textContent = authRole === "control" ? "Control (full access)" : "RX (read-only)";
|
if (badgeRole) badgeRole.textContent = `${authUsername || "local"} — ${authRoles.map((role) => AUTH_ROLE_LABELS[role]).join(", ")}`;
|
||||||
if (headerAuthBtn2) {
|
if (headerAuthBtn2) {
|
||||||
headerAuthBtn2.textContent = "Logout";
|
headerAuthBtn2.textContent = "Logout";
|
||||||
headerAuthBtn2.style.display = "block";
|
headerAuthBtn2.style.display = "block";
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
if (accountTab) accountTab.style.display = "none";
|
||||||
if (badge) badge.style.display = "none";
|
if (badge) badge.style.display = "none";
|
||||||
if (headerAuthBtn2) {
|
if (headerAuthBtn2) {
|
||||||
headerAuthBtn2.textContent = "Login";
|
headerAuthBtn2.textContent = "Login";
|
||||||
@@ -1961,31 +1970,34 @@ function updateAuthUI() {
|
|||||||
syncTopBarAccess();
|
syncTopBarAccess();
|
||||||
}
|
}
|
||||||
function applyAuthRestrictions() {
|
function applyAuthRestrictions() {
|
||||||
if (!authRole) return;
|
if (authRoles.length === 0) return;
|
||||||
if (authRole === "rx") {
|
if (!hasAuthRole2("transmit")) {
|
||||||
const pttBtn2 = document.getElementById("ptt-btn");
|
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 powerBtn2 = document.getElementById("power-btn");
|
||||||
const lockBtn2 = document.getElementById("lock-btn");
|
const lockBtn2 = document.getElementById("lock-btn");
|
||||||
const freqInput = document.getElementById("freq");
|
const freqInput = document.getElementById("freq");
|
||||||
const centerFreqInput = document.getElementById("center-freq");
|
const centerFreqInput = document.getElementById("center-freq");
|
||||||
const modeSelect = document.getElementById("mode");
|
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 jogUp = document.getElementById("jog-up");
|
||||||
const jogDown = document.getElementById("jog-down");
|
const jogDown = document.getElementById("jog-down");
|
||||||
const jogButtons = document.querySelectorAll(".jog-step button");
|
const jogButtons = document.querySelectorAll(".jog-step button");
|
||||||
const vfoButtons = document.querySelectorAll("#vfo-picker button");
|
const vfoButtons = document.querySelectorAll("#vfo-picker button");
|
||||||
if (pttBtn2) pttBtn2.disabled = true;
|
|
||||||
if (powerBtn2) powerBtn2.disabled = true;
|
if (powerBtn2) powerBtn2.disabled = true;
|
||||||
if (lockBtn2) lockBtn2.disabled = true;
|
if (lockBtn2) lockBtn2.disabled = true;
|
||||||
if (txAudioBtn2) txAudioBtn2.disabled = true;
|
|
||||||
if (txLimitBtn2) txLimitBtn2.disabled = true;
|
|
||||||
if (freqInput) freqInput.disabled = true;
|
if (freqInput) freqInput.disabled = true;
|
||||||
if (centerFreqInput) centerFreqInput.disabled = true;
|
if (centerFreqInput) centerFreqInput.disabled = true;
|
||||||
if (modeSelect) modeSelect.disabled = true;
|
if (modeSelect) modeSelect.disabled = true;
|
||||||
if (txLimitInput2) txLimitInput2.disabled = true;
|
|
||||||
vfoButtons.forEach((btn) => btn.disabled = true);
|
vfoButtons.forEach((btn) => btn.disabled = true);
|
||||||
const jogWheel2 = document.getElementById("jog-wheel");
|
const jogWheel2 = document.getElementById("jog-wheel");
|
||||||
if (jogUp) jogUp.disabled = true;
|
if (jogUp) jogUp.disabled = true;
|
||||||
@@ -2022,7 +2034,6 @@ function applyAuthRestrictions() {
|
|||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (txLimitRow2) txLimitRow2.style.opacity = "0.5";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function applyCapabilities(caps) {
|
function applyCapabilities(caps) {
|
||||||
@@ -2252,20 +2263,21 @@ window.applyDecodeHistoryRetention = function() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
function syncTopBarAccess() {
|
function syncTopBarAccess() {
|
||||||
const loggedOut = authEnabled && !authRole;
|
const loggedOut = authEnabled && authRoles.length === 0;
|
||||||
const tabBar = document.getElementById("tab-bar");
|
const tabBar = document.getElementById("tab-bar");
|
||||||
const rigSwitch = document.querySelector(".header-rig-switch");
|
const rigSwitch = document.querySelector(".header-rig-switch");
|
||||||
if (tabBar) tabBar.style.display = "";
|
if (tabBar) tabBar.style.display = "";
|
||||||
document.querySelectorAll(".tab-bar .tab").forEach((btn) => {
|
document.querySelectorAll(".tab-bar .tab").forEach((btn) => {
|
||||||
const isMain = btn.dataset.tab === "main";
|
const isMain = btn.dataset.tab === "main";
|
||||||
btn.style.display = !loggedOut || isMain ? "" : "none";
|
const lacksLogbookAccess = authEnabled && btn.dataset.tab === "logbook" && !hasAuthRole2("write");
|
||||||
|
btn.style.display = (!loggedOut || isMain) && !lacksLogbookAccess ? "" : "none";
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
});
|
});
|
||||||
if (rigSwitch) {
|
if (rigSwitch) {
|
||||||
rigSwitch.style.display = loggedOut ? "none" : "";
|
rigSwitch.style.display = loggedOut ? "none" : "";
|
||||||
}
|
}
|
||||||
if (headerRigSwitchSelect) {
|
if (headerRigSwitchSelect) {
|
||||||
headerRigSwitchSelect.disabled = loggedOut || authRole === "rx" || lastRigIds.length === 0;
|
headerRigSwitchSelect.disabled = loggedOut || !hasAuthRole2("control") || lastRigIds.length === 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var overviewDrawPending = false;
|
var overviewDrawPending = false;
|
||||||
@@ -2901,7 +2913,7 @@ function applyRigList(activeRigId, rigIds, displayNames) {
|
|||||||
}
|
}
|
||||||
const nextKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || "");
|
const nextKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || "");
|
||||||
const rigListChanged = prevKey !== nextKey;
|
const rigListChanged = prevKey !== nextKey;
|
||||||
const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx";
|
const disableSwitch = lastRigIds.length === 0 || !hasAuthRole2("control");
|
||||||
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
|
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
|
||||||
updateRigSubtitle(lastActiveRigId);
|
updateRigSubtitle(lastActiveRigId);
|
||||||
window.trxUi?.setActiveRig(lastActiveRigId);
|
window.trxUi?.setActiveRig(lastActiveRigId);
|
||||||
@@ -2950,7 +2962,10 @@ function refreshOperatorLayoutCapabilities() {
|
|||||||
));
|
));
|
||||||
window.trxUi?.setLayoutCapabilities({
|
window.trxUi?.setLayoutCapabilities({
|
||||||
broadcast: rigModes.some((modes) => modes.includes("WFM")),
|
broadcast: rigModes.some((modes) => modes.includes("WFM")),
|
||||||
digital: decoderModes.size > 0 && rigModes.some((modes) => modes.some((mode) => decoderModes.has(mode)))
|
digital: decoderModes.size > 0 && rigModes.some((modes) => modes.some((mode) => decoderModes.has(mode))),
|
||||||
|
// A logbook is for contacts made, so the layout built around it is offered
|
||||||
|
// where a rig can transmit.
|
||||||
|
ham: serverRigs.some((rig) => rig?.tx === true)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function showHint(msg, duration) {
|
function showHint(msg, duration) {
|
||||||
@@ -3714,10 +3729,19 @@ function visibleBandwidthSpecs(freqHz = lastFreqHz, mode = modeEl ? modeEl.value
|
|||||||
}
|
}
|
||||||
return [{ centerHz: freqHz, widthHz: currentBandwidthHz }];
|
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 : "") {
|
function sidebandDirectionForMode(mode = modeEl ? modeEl.value : "") {
|
||||||
const modeUpper = String(mode || "").toUpperCase();
|
const modeUpper = String(mode || "").toUpperCase();
|
||||||
if (modeUpper === "LSB" || modeUpper === "CWR") return -1;
|
if (modeUpper === "LSB" || modeUpper === "CWR") return -1;
|
||||||
if (modeUpper === "USB" || modeUpper === "CW" || modeUpper === "DIG") return 1;
|
if (modeUpper === "DIG") return sdrDigSidebandSupported ? digSidebandDirection() : 1;
|
||||||
|
if (modeUpper === "USB" || modeUpper === "CW") return 1;
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
function displaySpanForBandwidthSpec(spec, mode = modeEl ? modeEl.value : "") {
|
function displaySpanForBandwidthSpec(spec, mode = modeEl ? modeEl.value : "") {
|
||||||
@@ -4236,9 +4260,16 @@ function formatSignal(sUnits) {
|
|||||||
return overDb === 0 ? `${sigUnit("S")}9` : `${sigUnit("S")}9+${overDb}${sigUnit("dB")}`;
|
return overDb === 0 ? `${sigUnit("S")}9` : `${sigUnit("S")}9+${overDb}${sigUnit("dB")}`;
|
||||||
}
|
}
|
||||||
function setDisabled(disabled) {
|
function setDisabled(disabled) {
|
||||||
[freqEl, centerFreqEl, modeEl, pttBtn, powerBtn, txLimitInput, txLimitBtn, lockBtn].forEach((el) => {
|
const controlDisabled = disabled || authEnabled && !hasAuthRole2("control");
|
||||||
if (el) el.disabled = disabled;
|
const transmitDisabled = disabled || authEnabled && !hasAuthRole2("transmit");
|
||||||
|
[freqEl, centerFreqEl, modeEl, powerBtn, lockBtn].forEach((el) => {
|
||||||
|
if (el) el.disabled = controlDisabled;
|
||||||
});
|
});
|
||||||
|
[pttBtn, txLimitInput, txLimitBtn].forEach((el) => {
|
||||||
|
if (el) el.disabled = transmitDisabled;
|
||||||
|
});
|
||||||
|
const transmitAudio = document.getElementById("tx-audio-btn");
|
||||||
|
if (transmitAudio) transmitAudio.disabled = transmitDisabled || !hasWebCodecs;
|
||||||
syncModePicker();
|
syncModePicker();
|
||||||
}
|
}
|
||||||
var serverVersion = null;
|
var serverVersion = null;
|
||||||
@@ -4501,7 +4532,7 @@ function scheduleTuneLinkSync() {
|
|||||||
async function applyTuneLink(link) {
|
async function applyTuneLink(link) {
|
||||||
const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null;
|
const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null;
|
||||||
if (!wanted) return;
|
if (!wanted) return;
|
||||||
if (authRole === "rx") {
|
if (!hasAuthRole2("control")) {
|
||||||
showHint("Read-only session — link not applied", 2500);
|
showHint("Read-only session — link not applied", 2500);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -4735,6 +4766,25 @@ function render(update) {
|
|||||||
sdrNbThresholdEl.value = String(Math.round(update.filter.sdr_nb_threshold));
|
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") {
|
if (typeof update.show_sdr_gain_control === "boolean") {
|
||||||
@@ -5219,7 +5269,7 @@ async function postPath(path, options = {}) {
|
|||||||
}
|
}
|
||||||
const resp = await fetch(path, { method: "POST" });
|
const resp = await fetch(path, { method: "POST" });
|
||||||
if (authEnabled && resp.status === 401) {
|
if (authEnabled && resp.status === 401) {
|
||||||
authRole = null;
|
setAuthRoles([]);
|
||||||
if (es) es.close();
|
if (es) es.close();
|
||||||
showAuthGate();
|
showAuthGate();
|
||||||
throw new Error("Authentication required");
|
throw new Error("Authentication required");
|
||||||
@@ -5244,7 +5294,7 @@ async function switchRigFromSelect(selectEl) {
|
|||||||
showHint("No rig selected", 1500);
|
showHint("No rig selected", 1500);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (authRole === "rx") {
|
if (!hasAuthRole2("control")) {
|
||||||
showHint("Control role required", 1500);
|
showHint("Control role required", 1500);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -5858,8 +5908,13 @@ function navigateToTab(name, options = {}) {
|
|||||||
window.trxUi?.closeMobileOverlays?.();
|
window.trxUi?.closeMobileOverlays?.();
|
||||||
const leavingSatellites = _activeTab === "satellites" && name !== "satellites";
|
const leavingSatellites = _activeTab === "satellites" && name !== "satellites";
|
||||||
const { updateHistory = true, replaceHistory = false } = options;
|
const { updateHistory = true, replaceHistory = false } = options;
|
||||||
if (authEnabled && !authRole && name !== "main") {
|
if (authEnabled && authRoles.length === 0 && name !== "main") {
|
||||||
showAuthGate(false);
|
showAuthGate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (authEnabled && name === "logbook" && !hasAuthRole2("write")) {
|
||||||
|
showHint("Write role required for logbook access", 2500);
|
||||||
|
navigateToTab("main", options);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const btn = document.querySelector(`.tab-bar .tab[data-tab="${name}"]`);
|
const btn = document.querySelector(`.tab-bar .tab[data-tab="${name}"]`);
|
||||||
@@ -5972,11 +6027,11 @@ window.addEventListener("resize", () => {
|
|||||||
scheduleSpectrumLayout();
|
scheduleSpectrumLayout();
|
||||||
});
|
});
|
||||||
async function initializeApp() {
|
async function initializeApp() {
|
||||||
showAuthGate(false);
|
showAuthGate();
|
||||||
const authStatus = await checkAuthStatus();
|
const authStatus = await checkAuthStatus();
|
||||||
authEnabled = !authStatus.auth_disabled;
|
authEnabled = !authStatus.auth_disabled;
|
||||||
if (!authEnabled) {
|
if (!authEnabled) {
|
||||||
authRole = "control";
|
setAuthRoles(AUTH_ADMIN_ROLES);
|
||||||
hideAuthGate();
|
hideAuthGate();
|
||||||
updateAuthUI();
|
updateAuthUI();
|
||||||
connect();
|
connect();
|
||||||
@@ -5987,7 +6042,8 @@ async function initializeApp() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (authStatus.authenticated) {
|
if (authStatus.authenticated) {
|
||||||
authRole = authStatus.role ?? null;
|
setAuthRoles(authStatus.roles);
|
||||||
|
authUsername = authStatus.username ?? null;
|
||||||
hideAuthGate();
|
hideAuthGate();
|
||||||
updateAuthUI();
|
updateAuthUI();
|
||||||
applyAuthRestrictions();
|
applyAuthRestrictions();
|
||||||
@@ -5997,32 +6053,191 @@ async function initializeApp() {
|
|||||||
resizeHeaderSignalCanvas();
|
resizeHeaderSignalCanvas();
|
||||||
startHeaderSignalSampling();
|
startHeaderSignalSampling();
|
||||||
} else {
|
} else {
|
||||||
const allowGuest = authStatus.role === "rx";
|
showAuthGate();
|
||||||
showAuthGate(allowGuest);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var settingsUiReady = false;
|
var settingsUiReady = false;
|
||||||
function initSettingsUI() {
|
function initSettingsUI() {
|
||||||
settingsUiReady = true;
|
settingsUiReady = true;
|
||||||
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRole);
|
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRoles);
|
||||||
window.trx.modules.scheduler?.wireEvents();
|
window.trx.modules.scheduler?.wireEvents();
|
||||||
if (window.trx.modules.backgroundDecode) {
|
if (window.trx.modules.backgroundDecode) {
|
||||||
window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRole);
|
window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRoles);
|
||||||
window.trx.modules.backgroundDecode.wireEvents();
|
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) => {
|
requiredElement("auth-form").addEventListener("submit", async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const passphraseEl = requiredElement("auth-passphrase");
|
const usernameEl = requiredElement("auth-username");
|
||||||
const passphrase = passphraseEl.value;
|
const passwordEl = requiredElement("auth-password");
|
||||||
const btn = requiredElement("auth-form").querySelector("button[type=submit]");
|
const btn = requiredElement("auth-form").querySelector("button[type=submit]");
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = "Logging in...";
|
btn.textContent = "Logging in...";
|
||||||
try {
|
try {
|
||||||
const result = await authLogin(passphrase);
|
const result = await authLogin(usernameEl.value, passwordEl.value);
|
||||||
authRole = result.role ?? null;
|
setAuthRoles(result.roles);
|
||||||
passphraseEl.value = "";
|
authUsername = result.username ?? usernameEl.value;
|
||||||
|
passwordEl.value = "";
|
||||||
hideAuthGate();
|
hideAuthGate();
|
||||||
updateAuthUI();
|
updateAuthUI();
|
||||||
applyAuthRestrictions();
|
applyAuthRestrictions();
|
||||||
@@ -6032,37 +6247,22 @@ requiredElement("auth-form").addEventListener("submit", async (e) => {
|
|||||||
resizeHeaderSignalCanvas();
|
resizeHeaderSignalCanvas();
|
||||||
startHeaderSignalSampling();
|
startHeaderSignalSampling();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showAuthError("Invalid passphrase");
|
showAuthError("Invalid username or password");
|
||||||
console.error("Login error:", err);
|
console.error("Login error:", err);
|
||||||
} finally {
|
} finally {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.textContent = "Login";
|
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");
|
var headerAuthBtn = document.getElementById("header-auth-btn");
|
||||||
if (headerAuthBtn) {
|
if (headerAuthBtn) {
|
||||||
headerAuthBtn.addEventListener("click", async () => {
|
headerAuthBtn.addEventListener("click", async () => {
|
||||||
if (authRole) {
|
if (authRoles.length > 0) {
|
||||||
if (await window.trxUi.confirm({ title: "Log out?", message: "Audio and control access for this browser session will end.", confirmLabel: "Log out", danger: false })) {
|
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();
|
await authLogout();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
showAuthGate(false);
|
showAuthGate();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -6100,8 +6300,8 @@ Object.defineProperties(trxState, {
|
|||||||
authEnabled: { get() {
|
authEnabled: { get() {
|
||||||
return authEnabled;
|
return authEnabled;
|
||||||
} },
|
} },
|
||||||
authRole: { get() {
|
authRoles: { get() {
|
||||||
return authRole;
|
return authRoles;
|
||||||
} },
|
} },
|
||||||
decoderRegistry: { get() {
|
decoderRegistry: { get() {
|
||||||
return decoderRegistry;
|
return decoderRegistry;
|
||||||
@@ -6451,7 +6651,15 @@ var sdrNbEnabledEl = document.getElementById("sdr-nb-enabled");
|
|||||||
var sdrNbThresholdControlsEl = document.getElementById("sdr-nb-threshold-controls");
|
var sdrNbThresholdControlsEl = document.getElementById("sdr-nb-threshold-controls");
|
||||||
var sdrNbThresholdEl = document.getElementById("sdr-nb-threshold");
|
var sdrNbThresholdEl = document.getElementById("sdr-nb-threshold");
|
||||||
var sdrNbThresholdSetBtn = document.getElementById("sdr-nb-threshold-set");
|
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 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) => {
|
fetch("/audio", { method: "GET" }).then((r) => {
|
||||||
if (r.status === 404) audioRow.style.display = "none";
|
if (r.status === 404) audioRow.style.display = "none";
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
@@ -6821,7 +7029,7 @@ function submitSdrNbState() {
|
|||||||
const threshold = sdrNbThresholdEl ? Number.parseFloat(sdrNbThresholdEl.value) : 10;
|
const threshold = sdrNbThresholdEl ? Number.parseFloat(sdrNbThresholdEl.value) : 10;
|
||||||
if (!isFiniteNumber(threshold) || threshold < 1 || threshold > 100) return;
|
if (!isFiniteNumber(threshold) || threshold < 1 || threshold > 100) return;
|
||||||
postPath(
|
postPath(
|
||||||
`/set_sdr_noise_blanker?enabled=${enabled ? "true" : "false"}&threshold=${encodeURIComponent(threshold)}`
|
`/set_sdr_noise_blanker?enabled=${enabled ? "true" : "false"}&threshold=${encodeURIComponent(threshold)}&profile=${encodeURIComponent(sdrNbProfile)}`
|
||||||
).catch(() => {
|
).catch(() => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -6830,6 +7038,16 @@ if (sdrNbEnabledEl) {
|
|||||||
submitSdrNbState();
|
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() {
|
function submitSdrNbThreshold() {
|
||||||
if (!sdrNbThresholdEl) return;
|
if (!sdrNbThresholdEl) return;
|
||||||
const parsed = Number.parseFloat(sdrNbThresholdEl.value);
|
const parsed = Number.parseFloat(sdrNbThresholdEl.value);
|
||||||
@@ -6847,11 +7065,28 @@ if (sdrNbThresholdEl) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
function submitSdrDigSideband() {
|
||||||
|
if (!sdrDigSidebandSupported || !sdrDigSidebandEl) return;
|
||||||
|
const policy = sdrDigSidebandEl.value || "auto";
|
||||||
|
if (policy !== "auto" && policy !== "usb" && policy !== "lsb") return;
|
||||||
|
sdrDigSidebandPolicy = policy;
|
||||||
|
if (lastSpectrumData) scheduleSpectrumDraw();
|
||||||
|
postPath(`/set_sdr_dig_sideband?policy=${encodeURIComponent(policy)}`).catch(() => {
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (sdrDigSidebandEl) {
|
||||||
|
sdrDigSidebandEl.addEventListener("change", () => {
|
||||||
|
submitSdrDigSideband();
|
||||||
|
});
|
||||||
|
}
|
||||||
function updateWfmControls() {
|
function updateWfmControls() {
|
||||||
const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
|
const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
|
||||||
if (wfmControlsCol) wfmControlsCol.style.display = mode === "WFM" ? "" : "none";
|
if (wfmControlsCol) wfmControlsCol.style.display = mode === "WFM" ? "" : "none";
|
||||||
if (samControlsCol) samControlsCol.style.display = mode === "SAM" ? "" : "none";
|
if (samControlsCol) samControlsCol.style.display = mode === "SAM" ? "" : "none";
|
||||||
if (modeControlsRow) modeControlsRow.style.display = mode === "WFM" || mode === "SAM" ? "" : "none";
|
if (modeControlsRow) modeControlsRow.style.display = mode === "WFM" || mode === "SAM" ? "" : "none";
|
||||||
|
if (sdrDigSidebandWrapEl) {
|
||||||
|
sdrDigSidebandWrapEl.style.display = sdrDigSidebandSupported && mode === "DIG" ? "" : "none";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!hasWebCodecs) {
|
if (!hasWebCodecs) {
|
||||||
rxAudioBtn.disabled = true;
|
rxAudioBtn.disabled = true;
|
||||||
@@ -7173,6 +7408,10 @@ function startTxAudio() {
|
|||||||
void stopTxAudio();
|
void stopTxAudio();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (authEnabled && !hasAuthRole2("transmit")) {
|
||||||
|
audioStatus.textContent = "Transmit role required";
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!hasWebCodecs) {
|
if (!hasWebCodecs) {
|
||||||
audioStatus.textContent = "Audio requires Chrome/Edge";
|
audioStatus.textContent = "Audio requires Chrome/Edge";
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
collapseAprsDuplicates,
|
collapseAprsDuplicates,
|
||||||
normalizeAprsPacket,
|
normalizeAprsPacket,
|
||||||
renderAprsPacketRow
|
renderAprsPacketRow
|
||||||
} from "./chunk-OPEIVJGD.js";
|
} from "./chunk-REYSUJQ4.js";
|
||||||
import {
|
import {
|
||||||
forActiveRig,
|
forActiveRig,
|
||||||
isActiveRigDecode
|
isActiveRigDecode
|
||||||
@@ -139,6 +139,9 @@ function renderAprsRow(pkt, isFresh) {
|
|||||||
},
|
},
|
||||||
onCopy: (text) => {
|
onCopy: (text) => {
|
||||||
void copyAprsCoords(text);
|
void copyAprsCoords(text);
|
||||||
|
},
|
||||||
|
onLog: (call, gridsquare) => {
|
||||||
|
aprsWindow.logContact?.({ call, gridsquare: gridsquare ?? void 0, decoder: "aprs" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-4
@@ -1,3 +1,6 @@
|
|||||||
|
import {
|
||||||
|
hasAuthRole
|
||||||
|
} from "./chunk-PISLBJGN.js";
|
||||||
import {
|
import {
|
||||||
hostState
|
hostState
|
||||||
} from "./chunk-KL66PICH.js";
|
} from "./chunk-KL66PICH.js";
|
||||||
@@ -13,7 +16,7 @@ var bgdWindow = window;
|
|||||||
return d.id;
|
return d.id;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let backgroundDecodeRole = null;
|
let backgroundDecodeRoles = [];
|
||||||
let currentRigId = null;
|
let currentRigId = null;
|
||||||
let currentConfig = null;
|
let currentConfig = null;
|
||||||
let bookmarkList = [];
|
let bookmarkList = [];
|
||||||
@@ -21,8 +24,8 @@ var bgdWindow = window;
|
|||||||
let bgdDirty = false;
|
let bgdDirty = false;
|
||||||
let statusByBookmark = /* @__PURE__ */ new Map();
|
let statusByBookmark = /* @__PURE__ */ new Map();
|
||||||
let lastStatus = null;
|
let lastStatus = null;
|
||||||
function initBackgroundDecode(rigId, role) {
|
function initBackgroundDecode(rigId, roles) {
|
||||||
backgroundDecodeRole = role;
|
backgroundDecodeRoles = roles;
|
||||||
currentRigId = rigId || hostState.lastActiveRigId || null;
|
currentRigId = rigId || hostState.lastActiveRigId || null;
|
||||||
if (currentRigId) loadBackgroundDecode();
|
if (currentRigId) loadBackgroundDecode();
|
||||||
startStatusPolling();
|
startStatusPolling();
|
||||||
@@ -358,7 +361,7 @@ var bgdWindow = window;
|
|||||||
btn.title = bgdDirty ? "Apply these bookmarks to the background decoder" : "No changes to save";
|
btn.title = bgdDirty ? "Apply these bookmarks to the background decoder" : "No changes to save";
|
||||||
}
|
}
|
||||||
function isControlRole() {
|
function isControlRole() {
|
||||||
return backgroundDecodeRole === "control" || hostState.authEnabled === false;
|
return hasAuthRole(backgroundDecodeRoles, "control") || hostState.authEnabled === false;
|
||||||
}
|
}
|
||||||
function showToast(msg, isError) {
|
function showToast(msg, isError) {
|
||||||
const el = document.getElementById("background-decode-toast");
|
const el = document.getElementById("background-decode-toast");
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import {
|
||||||
|
hasAuthRole
|
||||||
|
} from "./chunk-PISLBJGN.js";
|
||||||
import {
|
import {
|
||||||
hostCore,
|
hostCore,
|
||||||
hostState
|
hostState
|
||||||
@@ -42,7 +45,7 @@ function bmEsc(str) {
|
|||||||
return d.innerHTML;
|
return d.innerHTML;
|
||||||
}
|
}
|
||||||
function bmCanControl() {
|
function bmCanControl() {
|
||||||
return !hostState.authEnabled || hostState.authRole === "control";
|
return !hostState.authEnabled || hasAuthRole(hostState.authRoles, "write");
|
||||||
}
|
}
|
||||||
function bmSyncAccess() {
|
function bmSyncAccess() {
|
||||||
const canCtrl = bmCanControl();
|
const canCtrl = bmCanControl();
|
||||||
@@ -211,6 +214,12 @@ function bmBuildDecoderCheckboxes() {
|
|||||||
container.appendChild(label);
|
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) {
|
function bmOpenForm(bm) {
|
||||||
const wrap = bmEl("bm-form-wrap");
|
const wrap = bmEl("bm-form-wrap");
|
||||||
if (!wrap) return;
|
if (!wrap) return;
|
||||||
@@ -224,7 +233,9 @@ function bmOpenForm(bm) {
|
|||||||
bmEl("bm-locator").value = bm ? bm.locator || "" : "";
|
bmEl("bm-locator").value = bm ? bm.locator || "" : "";
|
||||||
bmEl("bm-category-input").value = bm ? bm.category || "" : "";
|
bmEl("bm-category-input").value = bm ? bm.category || "" : "";
|
||||||
bmEl("bm-comment").value = bm ? bm.comment || "" : "";
|
bmEl("bm-comment").value = bm ? bm.comment || "" : "";
|
||||||
|
bmEl("bm-dig-sideband").value = bm ? bm.dig_sideband || "" : "";
|
||||||
bmWriteDecoders(bm?.decoders ?? []);
|
bmWriteDecoders(bm?.decoders ?? []);
|
||||||
|
bmSyncDigSidebandVisibility();
|
||||||
bmEl("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark";
|
bmEl("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark";
|
||||||
wrap.style.display = "flex";
|
wrap.style.display = "flex";
|
||||||
bmEl("bm-name").focus();
|
bmEl("bm-name").focus();
|
||||||
@@ -260,6 +271,7 @@ async function bmSave(e) {
|
|||||||
const category = bmEl("bm-category-input").value.trim();
|
const category = bmEl("bm-category-input").value.trim();
|
||||||
const comment = bmEl("bm-comment").value.trim();
|
const comment = bmEl("bm-comment").value.trim();
|
||||||
const decoders = bmReadDecoders();
|
const decoders = bmReadDecoders();
|
||||||
|
const dig_sideband = mode.toUpperCase() === "DIG" ? bmEl("bm-dig-sideband").value || null : null;
|
||||||
const formError = bmEl("bm-form-error");
|
const formError = bmEl("bm-form-error");
|
||||||
if (formError) formError.textContent = "";
|
if (formError) formError.textContent = "";
|
||||||
if (!name || !Number.isFinite(freq_hz) || !mode) {
|
if (!name || !Number.isFinite(freq_hz) || !mode) {
|
||||||
@@ -276,7 +288,8 @@ async function bmSave(e) {
|
|||||||
locator: locator || null,
|
locator: locator || null,
|
||||||
category,
|
category,
|
||||||
comment,
|
comment,
|
||||||
decoders
|
decoders,
|
||||||
|
dig_sideband
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
let resp;
|
let resp;
|
||||||
@@ -341,6 +354,12 @@ function bmApply(bm) {
|
|||||||
}
|
}
|
||||||
const tunePromise = (async () => {
|
const tunePromise = (async () => {
|
||||||
await bridge.trx.modules.vchan?.takeSchedulerControl();
|
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;
|
const onVirtual = await bridge.trx.modules.vchan?.interceptMode(bm.mode) ?? false;
|
||||||
if (!onVirtual) {
|
if (!onVirtual) {
|
||||||
await hostCore.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
|
await hostCore.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
|
||||||
@@ -593,6 +612,7 @@ function bmPopulateScopePicker() {
|
|||||||
bmEl("bm-form").addEventListener("submit", (event) => {
|
bmEl("bm-form").addEventListener("submit", (event) => {
|
||||||
void bmSave(event);
|
void bmSave(event);
|
||||||
});
|
});
|
||||||
|
bmEl("bm-mode").addEventListener("input", bmSyncDigSidebandVisibility);
|
||||||
bmEl("bm-form-cancel").addEventListener("click", bmCloseForm);
|
bmEl("bm-form-cancel").addEventListener("click", bmCloseForm);
|
||||||
const formWrap = bmEl("bm-form-wrap");
|
const formWrap = bmEl("bm-form-wrap");
|
||||||
if (formWrap) {
|
if (formWrap) {
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
// src/api/auth.ts
|
||||||
|
var AUTH_ROLES = ["guest", "read", "control", "transmit", "write", "administrator"];
|
||||||
|
var AUTH_ADMIN_ROLES = AUTH_ROLES.filter((role) => role !== "guest");
|
||||||
|
var AUTH_ROLE_LABELS = {
|
||||||
|
guest: "Guest",
|
||||||
|
read: "Read",
|
||||||
|
control: "Control",
|
||||||
|
transmit: "Transmit",
|
||||||
|
write: "Write",
|
||||||
|
administrator: "Administrator"
|
||||||
|
};
|
||||||
|
function isAuthRole(value) {
|
||||||
|
return typeof value === "string" && AUTH_ROLES.includes(value);
|
||||||
|
}
|
||||||
|
function normalizeAuthRoles(roles) {
|
||||||
|
return AUTH_ROLES.filter((role) => roles.includes(role));
|
||||||
|
}
|
||||||
|
function hasAuthRole(roles, required) {
|
||||||
|
return roles.includes("administrator") || roles.includes(required) || required === "read" && roles.includes("guest") || required === "read" && (roles.includes("control") || roles.includes("transmit"));
|
||||||
|
}
|
||||||
|
function hasAccountControls(roles) {
|
||||||
|
return roles.length > 0 && !roles.includes("guest");
|
||||||
|
}
|
||||||
|
function decodeRoles(value, context) {
|
||||||
|
if (!Array.isArray(value) || !value.every(isAuthRole)) {
|
||||||
|
throw new TypeError(`${context} has invalid roles`);
|
||||||
|
}
|
||||||
|
return normalizeAuthRoles(value);
|
||||||
|
}
|
||||||
|
function decodeAuthSession(value) {
|
||||||
|
if (typeof value !== "object" || value === null) {
|
||||||
|
throw new TypeError("The authentication response is malformed");
|
||||||
|
}
|
||||||
|
const session = value;
|
||||||
|
if (typeof session.authenticated !== "boolean") {
|
||||||
|
throw new TypeError("The authentication response has no authenticated flag");
|
||||||
|
}
|
||||||
|
if (session.auth_disabled !== void 0 && typeof session.auth_disabled !== "boolean") {
|
||||||
|
throw new TypeError("The authentication response has an invalid auth_disabled flag");
|
||||||
|
}
|
||||||
|
const decoded = {
|
||||||
|
authenticated: session.authenticated,
|
||||||
|
roles: decodeRoles(session.roles, "The authentication response")
|
||||||
|
};
|
||||||
|
if (session.username !== void 0) {
|
||||||
|
if (typeof session.username !== "string") throw new TypeError("The authentication response has an invalid username");
|
||||||
|
decoded.username = session.username;
|
||||||
|
}
|
||||||
|
if (session.auth_disabled !== void 0) decoded.auth_disabled = session.auth_disabled;
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
var authDisabledSession = {
|
||||||
|
authenticated: true,
|
||||||
|
roles: [...AUTH_ADMIN_ROLES],
|
||||||
|
auth_disabled: true
|
||||||
|
};
|
||||||
|
async function fetchAuthSession() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/auth/session");
|
||||||
|
if (response.status === 404) return authDisabledSession;
|
||||||
|
if (!response.ok) return { authenticated: false, roles: [] };
|
||||||
|
return decodeAuthSession(await response.json());
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Auth check failed:", error);
|
||||||
|
return { authenticated: false, roles: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function login(username, password) {
|
||||||
|
const response = await fetch("/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
});
|
||||||
|
if (response.status === 404) return authDisabledSession;
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = await response.text();
|
||||||
|
throw new Error(message || "Login failed");
|
||||||
|
}
|
||||||
|
return decodeAuthSession(await response.json());
|
||||||
|
}
|
||||||
|
async function userRequest(path, init) {
|
||||||
|
const response = await fetch(path, init);
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(payload.error || `User operation failed (${response.status})`);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
async function listUsers() {
|
||||||
|
const value = await userRequest("/auth/users").then((response) => response.json());
|
||||||
|
if (!Array.isArray(value) || !value.every((user) => {
|
||||||
|
if (typeof user !== "object" || user === null) return false;
|
||||||
|
const record = user;
|
||||||
|
return typeof record.username === "string" && typeof record.enabled === "boolean" && Array.isArray(record.roles) && record.roles.every(isAuthRole);
|
||||||
|
})) {
|
||||||
|
throw new TypeError("The user list response is malformed");
|
||||||
|
}
|
||||||
|
return value.map((user) => ({ ...user, roles: normalizeAuthRoles(user.roles) }));
|
||||||
|
}
|
||||||
|
async function createUser(username, password, roles, enabled = true) {
|
||||||
|
await userRequest("/auth/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password, roles, enabled }) });
|
||||||
|
}
|
||||||
|
async function updateUser(username, changes) {
|
||||||
|
await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(changes) });
|
||||||
|
}
|
||||||
|
async function changeOwnPassword(currentPassword, newPassword) {
|
||||||
|
await userRequest("/auth/account/password", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function deleteUser(username) {
|
||||||
|
await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
async function logout() {
|
||||||
|
const response = await fetch("/auth/logout", { method: "POST" });
|
||||||
|
if (response.status !== 404 && !response.ok) throw new Error("Logout failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AUTH_ROLES,
|
||||||
|
AUTH_ADMIN_ROLES,
|
||||||
|
AUTH_ROLE_LABELS,
|
||||||
|
normalizeAuthRoles,
|
||||||
|
hasAuthRole,
|
||||||
|
hasAccountControls,
|
||||||
|
fetchAuthSession,
|
||||||
|
login,
|
||||||
|
listUsers,
|
||||||
|
createUser,
|
||||||
|
updateUser,
|
||||||
|
changeOwnPassword,
|
||||||
|
deleteUser,
|
||||||
|
logout
|
||||||
|
};
|
||||||
+20
-1
@@ -214,6 +214,16 @@ function summarizeAprsPayload(packet) {
|
|||||||
const text = info.replace(/^[>;<?]/, "").trim();
|
const text = info.replace(/^[>;<?]/, "").trim();
|
||||||
return text.length ? text : null;
|
return text.length ? text : null;
|
||||||
}
|
}
|
||||||
|
function maidenheadForLatLon(lat, lon) {
|
||||||
|
const adjustedLon = lon + 180;
|
||||||
|
const adjustedLat = lat + 90;
|
||||||
|
const field = String.fromCharCode(65 + Math.floor(adjustedLon / 20)) + String.fromCharCode(65 + Math.floor(adjustedLat / 10));
|
||||||
|
const square = String(Math.floor(adjustedLon % 20 / 2)) + String(Math.floor(adjustedLat % 10));
|
||||||
|
const subLon = adjustedLon % 2 * 60 / 5;
|
||||||
|
const subLat = adjustedLat % 1 * 60 / 2.5;
|
||||||
|
const sub = String.fromCharCode(97 + Math.floor(subLon)) + String.fromCharCode(97 + Math.floor(subLat));
|
||||||
|
return `${field}${square}${sub}`.toUpperCase();
|
||||||
|
}
|
||||||
function renderAprsPacketRow(packet, options = {}) {
|
function renderAprsPacketRow(packet, options = {}) {
|
||||||
const row = document.createElement("details");
|
const row = document.createElement("details");
|
||||||
row.className = "aprs-packet";
|
row.className = "aprs-packet";
|
||||||
@@ -224,7 +234,7 @@ function renderAprsPacketRow(packet, options = {}) {
|
|||||||
const summary = summarizeAprsPayload(packet);
|
const summary = summarizeAprsPayload(packet);
|
||||||
const hasPosition = packet.lat != null && packet.lon != null;
|
const hasPosition = packet.lat != null && packet.lon != null;
|
||||||
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(packet.srcCall || "")}`;
|
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(packet.srcCall || "")}`;
|
||||||
row.innerHTML = `<summary class="decode-line"><span class="aprs-time">${escapeAprsHtml(time)}</span>` + (options.badge ? `<span class="aprs-badge aprs-badge-band">${escapeAprsHtml(options.badge)}</span>` : "") + renderAprsSymbolSlot(packet, escapeAprsHtml) + `<span class="aprs-call">${escapeAprsHtml(packet.srcCall ?? "")}</span><span class="aprs-badge aprs-badge-type aprs-badge-type-${category}">${escapeAprsHtml(aprsCategoryLabel(category))}</span><span class="decode-line-summary">${summary ? escapeAprsHtml(summary) : renderAprsInfo(packet)}</span>` + (packet.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC</span>') + (options.distance ? `<span class="decode-line-distance">${escapeAprsHtml(options.distance)}</span>` : "") + `</summary><div class="decode-expanded"><div class="decode-expanded-meta"><span>>${escapeAprsHtml(packet.destCall || "--")}</span><span>${escapeAprsHtml(packet.path || "no path")}</span><span>${escapeAprsHtml(aprsAgeText(packet._tsMs))}</span><span>CRC ${packet.crcOk ? "ok" : "failed"}</span>` + (hasPosition ? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${packet.lat},${packet.lon}">${packet.lat?.toFixed(5)}, ${packet.lon?.toFixed(5)}</a>` : "") + `</div><div class="decode-expanded-raw">${renderAprsInfo(packet)}</div>` + (packet.info_bytes?.length ? `<div class="decode-expanded-bytes">${escapeAprsHtml(aprsHexBytes(packet.info_bytes))}</div>` : "") + `<div class="aprs-row-actions">` + (hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${packet.lat},${packet.lon}">Map</button>` : "") + (hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${packet.lat},${packet.lon}">Copy Coords</button>` : "") + `<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a></div></div>`;
|
row.innerHTML = `<summary class="decode-line"><span class="aprs-time">${escapeAprsHtml(time)}</span>` + (options.badge ? `<span class="aprs-badge aprs-badge-band">${escapeAprsHtml(options.badge)}</span>` : "") + renderAprsSymbolSlot(packet, escapeAprsHtml) + `<span class="aprs-call">${escapeAprsHtml(packet.srcCall ?? "")}</span><span class="aprs-badge aprs-badge-type aprs-badge-type-${category}">${escapeAprsHtml(aprsCategoryLabel(category))}</span><span class="decode-line-summary">${summary ? escapeAprsHtml(summary) : renderAprsInfo(packet)}</span>` + (packet.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC</span>') + (options.distance ? `<span class="decode-line-distance">${escapeAprsHtml(options.distance)}</span>` : "") + `</summary><div class="decode-expanded"><div class="decode-expanded-meta"><span>>${escapeAprsHtml(packet.destCall || "--")}</span><span>${escapeAprsHtml(packet.path || "no path")}</span><span>${escapeAprsHtml(aprsAgeText(packet._tsMs))}</span><span>CRC ${packet.crcOk ? "ok" : "failed"}</span>` + (hasPosition ? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${packet.lat},${packet.lon}">${packet.lat?.toFixed(5)}, ${packet.lon?.toFixed(5)}</a>` : "") + `</div><div class="decode-expanded-raw">${renderAprsInfo(packet)}</div>` + (packet.info_bytes?.length ? `<div class="decode-expanded-bytes">${escapeAprsHtml(aprsHexBytes(packet.info_bytes))}</div>` : "") + `<div class="aprs-row-actions">` + (hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${packet.lat},${packet.lon}">Map</button>` : "") + (hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${packet.lat},${packet.lon}">Copy Coords</button>` : "") + `<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a>` + (options.onLog && packet.srcCall ? '<button class="aprs-inline-btn" type="button" data-aprs-log="1">Log</button>' : "") + `</div></div>`;
|
||||||
row.querySelectorAll("[data-aprs-map]").forEach((element) => {
|
row.querySelectorAll("[data-aprs-map]").forEach((element) => {
|
||||||
element.addEventListener("click", (event) => {
|
element.addEventListener("click", (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -233,6 +243,15 @@ function renderAprsPacketRow(packet, options = {}) {
|
|||||||
if (Number.isFinite(lat) && Number.isFinite(lon)) options.onMap?.(lat, lon);
|
if (Number.isFinite(lat) && Number.isFinite(lon)) options.onMap?.(lat, lon);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
const logButton = row.querySelector("[data-aprs-log]");
|
||||||
|
if (logButton) {
|
||||||
|
logButton.addEventListener("click", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const grid = packet.lat != null && packet.lon != null ? maidenheadForLatLon(packet.lat, packet.lon) : null;
|
||||||
|
options.onLog?.(packet.srcCall ?? "", grid);
|
||||||
|
});
|
||||||
|
}
|
||||||
const copyButton = row.querySelector("[data-aprs-copy]");
|
const copyButton = row.querySelector("[data-aprs-copy]");
|
||||||
if (copyButton) {
|
if (copyButton) {
|
||||||
copyButton.addEventListener("click", (event) => {
|
copyButton.addEventListener("click", (event) => {
|
||||||
+18
@@ -152,6 +152,24 @@ function initializeFtxDecoder(config) {
|
|||||||
second: "2-digit"
|
second: "2-digit"
|
||||||
});
|
});
|
||||||
row.innerHTML = `<span class="ft8-time">${time}</span><span class="ft8-snr">${snr?.toFixed(1) ?? "--"}</span><span class="ft8-dt">${delta?.toFixed(2) ?? "--"}</span><span class="ft8-freq">${frequency?.toFixed(0) ?? "--"}</span><span class="ft8-msg">${renderMessage(raw)}</span>`;
|
row.innerHTML = `<span class="ft8-time">${time}</span><span class="ft8-snr">${snr?.toFixed(1) ?? "--"}</span><span class="ft8-dt">${delta?.toFixed(2) ?? "--"}</span><span class="ft8-freq">${frequency?.toFixed(0) ?? "--"}</span><span class="ft8-msg">${renderMessage(raw)}</span>`;
|
||||||
|
const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
|
||||||
|
if (station) {
|
||||||
|
const log = document.createElement("button");
|
||||||
|
log.type = "button";
|
||||||
|
log.className = "ft8-log-btn";
|
||||||
|
log.textContent = "Log";
|
||||||
|
log.title = `Start a log entry for ${station}`;
|
||||||
|
log.addEventListener("click", (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
const details = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
|
||||||
|
bridge.logContact?.({
|
||||||
|
call: station,
|
||||||
|
gridsquare: details.find((detail) => detail.station === station)?.grid,
|
||||||
|
decoder: id
|
||||||
|
});
|
||||||
|
});
|
||||||
|
row.appendChild(log);
|
||||||
|
}
|
||||||
return row;
|
return row;
|
||||||
};
|
};
|
||||||
const render = () => {
|
const render = () => {
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
initializeFtxDecoder
|
initializeFtxDecoder
|
||||||
} from "./chunk-PJ5Q7CWJ.js";
|
} from "./chunk-WU5EWJX4.js";
|
||||||
import "./chunk-S57W63QN.js";
|
import "./chunk-S57W63QN.js";
|
||||||
import "./chunk-KL66PICH.js";
|
import "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
initializeFtxDecoder
|
initializeFtxDecoder
|
||||||
} from "./chunk-PJ5Q7CWJ.js";
|
} from "./chunk-WU5EWJX4.js";
|
||||||
import "./chunk-S57W63QN.js";
|
import "./chunk-S57W63QN.js";
|
||||||
import "./chunk-KL66PICH.js";
|
import "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {
|
|||||||
initializeFt8FamilyBar,
|
initializeFt8FamilyBar,
|
||||||
initializeFtxDecoder,
|
initializeFtxDecoder,
|
||||||
installFtxCompatibilityHelpers
|
installFtxCompatibilityHelpers
|
||||||
} from "./chunk-PJ5Q7CWJ.js";
|
} from "./chunk-WU5EWJX4.js";
|
||||||
import "./chunk-S57W63QN.js";
|
import "./chunk-S57W63QN.js";
|
||||||
import "./chunk-KL66PICH.js";
|
import "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
collapseAprsDuplicates,
|
collapseAprsDuplicates,
|
||||||
normalizeAprsPacket,
|
normalizeAprsPacket,
|
||||||
renderAprsPacketRow
|
renderAprsPacketRow
|
||||||
} from "./chunk-OPEIVJGD.js";
|
} from "./chunk-REYSUJQ4.js";
|
||||||
import {
|
import {
|
||||||
forActiveRig,
|
forActiveRig,
|
||||||
isActiveRigDecode
|
isActiveRigDecode
|
||||||
|
|||||||
@@ -0,0 +1,449 @@
|
|||||||
|
import {
|
||||||
|
hasAuthRole
|
||||||
|
} from "./chunk-PISLBJGN.js";
|
||||||
|
import {
|
||||||
|
hostCore,
|
||||||
|
hostState
|
||||||
|
} from "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
|
// src/plugins/logbook.ts
|
||||||
|
var bridge = window;
|
||||||
|
var el = (id) => document.getElementById(id);
|
||||||
|
var form = el("log-entry-form");
|
||||||
|
var callInput = el("log-call");
|
||||||
|
var freqInput = el("log-freq");
|
||||||
|
var modeInput = el("log-mode");
|
||||||
|
var rstSentInput = el("log-rst-sent");
|
||||||
|
var rstRcvdInput = el("log-rst-rcvd");
|
||||||
|
var gridInput = el("log-grid");
|
||||||
|
var nameInput = el("log-name");
|
||||||
|
var commentInput = el("log-comment");
|
||||||
|
var operatorInput = el("log-operator");
|
||||||
|
var rowsBody = el("log-rows");
|
||||||
|
var summaryEl = el("log-summary");
|
||||||
|
var workedEl = el("log-worked-before");
|
||||||
|
var clockEl = el("log-clock");
|
||||||
|
var stationCallEl = el("log-station-callsign");
|
||||||
|
var stationRigEl = el("log-station-rig-name");
|
||||||
|
var stationGridEl = el("log-station-grid");
|
||||||
|
var filterCall = el("log-filter-call");
|
||||||
|
var filterBand = el("log-filter-band");
|
||||||
|
var filterMode = el("log-filter-mode");
|
||||||
|
var importBtn = el("log-import-btn");
|
||||||
|
var importFile = el("log-import-file");
|
||||||
|
var exportLink = el("log-export-btn");
|
||||||
|
var clearBtn = el("log-clear-btn");
|
||||||
|
var saveBtn = el("log-save-btn");
|
||||||
|
var contestIdInput = el("log-contest-id");
|
||||||
|
var stxInput = el("log-stx");
|
||||||
|
var srxInput = el("log-srx");
|
||||||
|
var statisticsRows = el("log-statistics-rows");
|
||||||
|
var cabrilloContest = el("log-cbr-contest");
|
||||||
|
var cabrilloCallsign = el("log-cbr-callsign");
|
||||||
|
var cabrilloOperator = el("log-cbr-operator");
|
||||||
|
var cabrilloPower = el("log-cbr-power");
|
||||||
|
var cabrilloScore = el("log-cbr-score");
|
||||||
|
var cabrilloExport = el("log-cbr-export");
|
||||||
|
var entryStartedAt = null;
|
||||||
|
var entryRigId = null;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
async function getJson(path) {
|
||||||
|
const response = await fetch(path);
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
function formatFreq(hz) {
|
||||||
|
if (!Number.isFinite(hz) || hz <= 0) return "";
|
||||||
|
return String(Number((hz / 1e6).toFixed(6)));
|
||||||
|
}
|
||||||
|
function parseFreq(text) {
|
||||||
|
const value = Number(text.trim().replace(/\s+/g, "").replace(",", "."));
|
||||||
|
if (!Number.isFinite(value) || value <= 0) return null;
|
||||||
|
return value < 1e5 ? Math.round(value * 1e6) : Math.round(value);
|
||||||
|
}
|
||||||
|
function numberOrNull(text) {
|
||||||
|
const trimmed = (text ?? "").trim();
|
||||||
|
if (!trimmed || !/^\d+$/.test(trimmed)) return null;
|
||||||
|
const value = Number(trimmed);
|
||||||
|
return Number.isSafeInteger(value) ? value : null;
|
||||||
|
}
|
||||||
|
function utcDate(iso) {
|
||||||
|
const date = new Date(iso);
|
||||||
|
return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
function utcTime(iso) {
|
||||||
|
const date = new Date(iso);
|
||||||
|
return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(11, 19);
|
||||||
|
}
|
||||||
|
async function openEntry(seed = {}) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (hostState.lastActiveRigId) params.set("remote", hostState.lastActiveRigId);
|
||||||
|
if (seed.decoder) params.set("decoder", seed.decoder);
|
||||||
|
if (seed.call) params.set("call", seed.call);
|
||||||
|
if (seed.gridsquare) params.set("gridsquare", seed.gridsquare);
|
||||||
|
try {
|
||||||
|
const prefill = await getJson(`/api/logbook/prefill?${params.toString()}`);
|
||||||
|
applyPrefill(prefill);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("logbook prefill failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function applyPrefill(prefill) {
|
||||||
|
entryStartedAt = prefill.started_at;
|
||||||
|
entryRigId = prefill.rig_id;
|
||||||
|
entryRigName = prefill.my_rig;
|
||||||
|
if (freqInput) freqInput.value = formatFreq(prefill.freq_hz);
|
||||||
|
if (modeInput) modeInput.value = prefill.submode ?? prefill.mode;
|
||||||
|
if (callInput && prefill.call) callInput.value = prefill.call;
|
||||||
|
if (gridInput && prefill.gridsquare) gridInput.value = prefill.gridsquare;
|
||||||
|
if (stationRigEl) stationRigEl.textContent = prefill.my_rig ?? "no rig";
|
||||||
|
showClock(prefill);
|
||||||
|
updateWorkedBefore();
|
||||||
|
}
|
||||||
|
function showClock(prefill) {
|
||||||
|
if (!clockEl) return;
|
||||||
|
const time = utcTime(prefill.started_at);
|
||||||
|
const drift = Math.abs(Date.now() - prefill.epoch_ms);
|
||||||
|
clockEl.textContent = drift > 1e3 ? `${time}Z · your clock is ${(drift / 1e3).toFixed(0)}s out` : `${time}Z`;
|
||||||
|
clockEl.classList.toggle("is-adrift", drift > 1e3);
|
||||||
|
}
|
||||||
|
function resetEntry() {
|
||||||
|
for (const input of [callInput, rstSentInput, rstRcvdInput, gridInput, nameInput, commentInput, srxInput]) {
|
||||||
|
if (input) input.value = "";
|
||||||
|
}
|
||||||
|
if (workedEl) workedEl.textContent = "";
|
||||||
|
void openEntry();
|
||||||
|
}
|
||||||
|
async function submitEntry(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const call = callInput?.value.trim() ?? "";
|
||||||
|
if (!call) {
|
||||||
|
notify("A contact needs a callsign", "error");
|
||||||
|
callInput?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const freqHz = parseFreq(freqInput?.value ?? "");
|
||||||
|
if (freqHz == null) {
|
||||||
|
notify("A contact needs a frequency", "error");
|
||||||
|
freqInput?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const typedMode = (modeInput?.value ?? "").trim().toUpperCase();
|
||||||
|
const isSideband = typedMode === "USB" || typedMode === "LSB";
|
||||||
|
const body = {
|
||||||
|
// The server stamps the time; this is the one it gave when the entry
|
||||||
|
// opened, so a contact logged five minutes later keeps the time it started.
|
||||||
|
started_at: entryStartedAt,
|
||||||
|
call,
|
||||||
|
freq_hz: freqHz,
|
||||||
|
mode: isSideband ? "SSB" : typedMode,
|
||||||
|
submode: isSideband ? typedMode : null,
|
||||||
|
rst_sent: rstSentInput?.value ?? null,
|
||||||
|
rst_rcvd: rstRcvdInput?.value ?? null,
|
||||||
|
gridsquare: gridInput?.value ?? null,
|
||||||
|
name: nameInput?.value ?? null,
|
||||||
|
comment: commentInput?.value ?? null,
|
||||||
|
contest_id: contestIdInput?.value ?? null,
|
||||||
|
// The exchange is a number when it is a serial and a word when it is a
|
||||||
|
// zone or a section; both are kept, and the log writes whichever it has.
|
||||||
|
stx: numberOrNull(stxInput?.value),
|
||||||
|
stx_string: numberOrNull(stxInput?.value) == null ? stxInput?.value ?? null : null,
|
||||||
|
srx: numberOrNull(srxInput?.value),
|
||||||
|
srx_string: numberOrNull(srxInput?.value) == null ? srxInput?.value ?? null : null,
|
||||||
|
station_callsign: stationCallEl?.textContent?.trim() ?? null,
|
||||||
|
operator: operatorInput?.value ?? null,
|
||||||
|
my_gridsquare: entryGrid,
|
||||||
|
my_rig: entryRigName,
|
||||||
|
rig_id: entryRigId
|
||||||
|
};
|
||||||
|
if (saveBtn) saveBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/logbook", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(detail.error ?? `HTTP ${String(response.status)}`);
|
||||||
|
}
|
||||||
|
notify(`${call} logged`);
|
||||||
|
const sent = numberOrNull(stxInput?.value);
|
||||||
|
resetEntry();
|
||||||
|
if (stxInput && sent != null) stxInput.value = String(sent + 1);
|
||||||
|
await refreshLog();
|
||||||
|
} catch (error) {
|
||||||
|
notify(`Could not log: ${error instanceof Error ? error.message : String(error)}`, "error");
|
||||||
|
} finally {
|
||||||
|
if (saveBtn) saveBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function updateWorkedBefore() {
|
||||||
|
if (!workedEl) return;
|
||||||
|
const call = callInput?.value.trim() ?? "";
|
||||||
|
if (call.length < 3) {
|
||||||
|
workedEl.textContent = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const request = ++workedRequest;
|
||||||
|
void getJson(
|
||||||
|
`/api/logbook/worked/${encodeURIComponent(call)}`
|
||||||
|
).then((answer) => {
|
||||||
|
if (request !== workedRequest || !workedEl) return;
|
||||||
|
if (answer.worked.length === 0) {
|
||||||
|
workedEl.textContent = "Not worked before";
|
||||||
|
workedEl.classList.remove("is-worked");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const where = answer.worked.map((entry) => `${entry.band} ${entry.mode}`).join(", ");
|
||||||
|
workedEl.textContent = `Worked before: ${where}`;
|
||||||
|
workedEl.classList.add("is-worked");
|
||||||
|
}).catch(() => {
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function currentQuery() {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
const call = filterCall?.value.trim();
|
||||||
|
if (call) params.set("call", call);
|
||||||
|
if (filterBand?.value) params.set("band", filterBand.value);
|
||||||
|
if (filterMode?.value) params.set("mode", filterMode.value);
|
||||||
|
return params.toString();
|
||||||
|
}
|
||||||
|
async function refreshLog() {
|
||||||
|
try {
|
||||||
|
const query = currentQuery();
|
||||||
|
const answer = await getJson(
|
||||||
|
`/api/logbook${query ? `?${query}` : ""}`
|
||||||
|
);
|
||||||
|
qsos = answer.qsos;
|
||||||
|
renderRows();
|
||||||
|
renderFilterOptions();
|
||||||
|
if (summaryEl) {
|
||||||
|
summaryEl.textContent = query ? `${String(qsos.length)} of ${String(answer.total)} contacts` : `${String(answer.total)} contact${answer.total === 1 ? "" : "s"}`;
|
||||||
|
}
|
||||||
|
if (exportLink) exportLink.href = `/api/logbook/export.adi${query ? `?${query}` : ""}`;
|
||||||
|
await refreshStatistics();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("logbook read failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function refreshStatistics() {
|
||||||
|
if (!statisticsRows) return;
|
||||||
|
try {
|
||||||
|
const answer = await getJson("/api/logbook/statistics");
|
||||||
|
if (answer.bands.length === 0) {
|
||||||
|
statisticsRows.innerHTML = '<tr><td colspan="4" class="log-empty">Nothing worked yet.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (const band of answer.bands) {
|
||||||
|
const row = document.createElement("tr");
|
||||||
|
for (const value of [band.band, band.contacts, band.stations, band.confirmed]) {
|
||||||
|
const cell = document.createElement("td");
|
||||||
|
cell.textContent = String(value);
|
||||||
|
row.appendChild(cell);
|
||||||
|
}
|
||||||
|
fragment.appendChild(row);
|
||||||
|
}
|
||||||
|
statisticsRows.replaceChildren(fragment);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("logbook statistics failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function syncCabrilloLink() {
|
||||||
|
if (!cabrilloExport) return;
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
const contest = cabrilloContest?.value.trim();
|
||||||
|
if (contest) {
|
||||||
|
params.set("contest", contest);
|
||||||
|
}
|
||||||
|
const callsign = cabrilloCallsign?.value.trim() || (stationCallEl?.textContent?.trim() ?? "");
|
||||||
|
if (callsign) params.set("callsign", callsign);
|
||||||
|
if (cabrilloOperator?.value) params.set("category_operator", cabrilloOperator.value);
|
||||||
|
if (cabrilloPower?.value) params.set("category_power", cabrilloPower.value);
|
||||||
|
const score = numberOrNull(cabrilloScore?.value);
|
||||||
|
if (score != null) params.set("claimed_score", String(score));
|
||||||
|
const operator = operatorInput?.value.trim();
|
||||||
|
if (operator) params.set("operators", operator);
|
||||||
|
cabrilloExport.href = `/api/logbook/export.cbr?${params.toString()}`;
|
||||||
|
}
|
||||||
|
function renderRows() {
|
||||||
|
if (!rowsBody) return;
|
||||||
|
if (qsos.length === 0) {
|
||||||
|
rowsBody.innerHTML = '<tr><td colspan="10" class="log-empty">No contacts yet. Work someone and log them here, or bring a log in with Import ADIF.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (const qso of qsos) {
|
||||||
|
const row = document.createElement("tr");
|
||||||
|
row.dataset.qsoId = qso.id;
|
||||||
|
const cells = [
|
||||||
|
utcDate(qso.started_at),
|
||||||
|
utcTime(qso.started_at),
|
||||||
|
qso.call,
|
||||||
|
qso.band ?? "",
|
||||||
|
qso.submode ?? qso.mode,
|
||||||
|
qso.rst_sent ?? "",
|
||||||
|
qso.rst_rcvd ?? "",
|
||||||
|
qso.gridsquare ?? "",
|
||||||
|
qso.my_rig ?? "",
|
||||||
|
qso.confirmed ? "✓" : ""
|
||||||
|
];
|
||||||
|
for (const [index, value] of cells.entries()) {
|
||||||
|
const cell = document.createElement("td");
|
||||||
|
cell.textContent = value ?? "";
|
||||||
|
if (index === 2) cell.className = "log-cell-call";
|
||||||
|
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";
|
||||||
|
confirm.textContent = qso.confirmed ? "Unconfirm" : "Confirm";
|
||||||
|
confirm.title = qso.confirmed ? "Mark this contact as not confirmed" : "Mark this contact confirmed by QSL";
|
||||||
|
confirm.addEventListener("click", () => {
|
||||||
|
void setConfirmed(qso, !qso.confirmed);
|
||||||
|
});
|
||||||
|
actions.appendChild(confirm);
|
||||||
|
const remove = document.createElement("button");
|
||||||
|
remove.type = "button";
|
||||||
|
remove.className = "log-row-btn";
|
||||||
|
remove.textContent = "Delete";
|
||||||
|
remove.setAttribute("aria-label", `Delete the contact with ${qso.call}`);
|
||||||
|
remove.addEventListener("click", () => {
|
||||||
|
void deleteQso(qso);
|
||||||
|
});
|
||||||
|
actions.appendChild(remove);
|
||||||
|
row.appendChild(actions);
|
||||||
|
fragment.appendChild(row);
|
||||||
|
}
|
||||||
|
rowsBody.replaceChildren(fragment);
|
||||||
|
}
|
||||||
|
function renderFilterOptions() {
|
||||||
|
for (const [select, values] of [
|
||||||
|
[filterBand, [...new Set(qsos.map((q) => q.band).filter((b) => !!b))]],
|
||||||
|
[filterMode, [...new Set(qsos.map((q) => q.submode ?? q.mode).filter(Boolean))]]
|
||||||
|
]) {
|
||||||
|
if (!select) continue;
|
||||||
|
const chosen = select.value;
|
||||||
|
const known = new Set([...select.options].map((option) => option.value));
|
||||||
|
for (const value of [...values].sort()) {
|
||||||
|
if (known.has(value)) continue;
|
||||||
|
select.add(new Option(value, value));
|
||||||
|
}
|
||||||
|
select.value = chosen;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function setConfirmed(qso, confirmed) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/logbook/${encodeURIComponent(qso.id)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
...qso,
|
||||||
|
// A card is a card: this is the paper one, and an electronic
|
||||||
|
// confirmation the log already holds is left where it is.
|
||||||
|
qsl_rcvd: confirmed ? "Y" : "N"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
|
||||||
|
await refreshLog();
|
||||||
|
} catch (error) {
|
||||||
|
notify(`Could not update: ${error instanceof Error ? error.message : String(error)}`, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function deleteQso(qso) {
|
||||||
|
const confirmed = await bridge.trxUi.confirm({
|
||||||
|
title: "Delete this contact?",
|
||||||
|
message: `${qso.call} on ${qso.band ?? formatFreq(qso.freq_hz)} will be removed from the log.`,
|
||||||
|
confirmLabel: "Delete"
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/logbook/${encodeURIComponent(qso.id)}`, { method: "DELETE" });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
|
||||||
|
await refreshLog();
|
||||||
|
} catch (error) {
|
||||||
|
notify(`Could not delete: ${error instanceof Error ? error.message : String(error)}`, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function importAdif(file) {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/logbook/import", { method: "POST", body: await file.arrayBuffer() });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
|
||||||
|
const outcome = await response.json();
|
||||||
|
const parts = [`${String(outcome.added)} added`];
|
||||||
|
if (outcome.duplicate > 0) parts.push(`${String(outcome.duplicate)} already held`);
|
||||||
|
if (outcome.rejected.length > 0) parts.push(`${String(outcome.rejected.length)} not readable`);
|
||||||
|
notify(parts.join(", "));
|
||||||
|
await refreshLog();
|
||||||
|
} catch (error) {
|
||||||
|
notify(`Import failed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function renderStation() {
|
||||||
|
const callsign = hostState.ownerCallsign ?? "";
|
||||||
|
if (stationCallEl) stationCallEl.textContent = callsign || "no callsign set";
|
||||||
|
if (operatorInput && !operatorInput.value) operatorInput.value = callsign;
|
||||||
|
if (stationGridEl) {
|
||||||
|
const grid = hostState.serverLat != null && hostState.serverLon != null ? hostCore.latLonToMaidenhead(hostState.serverLat, hostState.serverLon) : "";
|
||||||
|
entryGrid = grid || null;
|
||||||
|
stationGridEl.textContent = grid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
form?.addEventListener("submit", (event) => {
|
||||||
|
void submitEntry(event);
|
||||||
|
});
|
||||||
|
clearBtn?.addEventListener("click", resetEntry);
|
||||||
|
callInput?.addEventListener("input", updateWorkedBefore);
|
||||||
|
for (const control of [filterCall, filterBand, filterMode]) {
|
||||||
|
control?.addEventListener("input", () => {
|
||||||
|
void refreshLog();
|
||||||
|
});
|
||||||
|
control?.addEventListener("change", () => {
|
||||||
|
void refreshLog();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const control of [cabrilloContest, cabrilloCallsign, cabrilloOperator, cabrilloPower, cabrilloScore]) {
|
||||||
|
control?.addEventListener("input", syncCabrilloLink);
|
||||||
|
control?.addEventListener("change", syncCabrilloLink);
|
||||||
|
}
|
||||||
|
importBtn?.addEventListener("click", () => {
|
||||||
|
importFile?.click();
|
||||||
|
});
|
||||||
|
importFile?.addEventListener("change", () => {
|
||||||
|
const file = importFile.files?.[0];
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
renderStation();
|
||||||
|
if (cabrilloCallsign && !cabrilloCallsign.value) {
|
||||||
|
cabrilloCallsign.value = stationCallEl?.textContent?.trim() ?? "";
|
||||||
|
}
|
||||||
|
syncCabrilloLink();
|
||||||
|
if (canWriteLogbook()) void openEntry();
|
||||||
|
void refreshLog();
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
aprsSymbolSprite
|
aprsSymbolSprite
|
||||||
} from "./chunk-OPEIVJGD.js";
|
} from "./chunk-REYSUJQ4.js";
|
||||||
|
|
||||||
// src/map-core.ts
|
// src/map-core.ts
|
||||||
function mapEl(id) {
|
function mapEl(id) {
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import {
|
||||||
|
hasAuthRole
|
||||||
|
} from "./chunk-PISLBJGN.js";
|
||||||
import {
|
import {
|
||||||
hostState
|
hostState
|
||||||
} from "./chunk-KL66PICH.js";
|
} from "./chunk-KL66PICH.js";
|
||||||
@@ -15,7 +18,7 @@ function schedulerOptionalEl(id) {
|
|||||||
}
|
}
|
||||||
(function() {
|
(function() {
|
||||||
"use strict";
|
"use strict";
|
||||||
let schedulerRole = null;
|
let schedulerRoles = [];
|
||||||
let currentRigId = null;
|
let currentRigId = null;
|
||||||
let currentConfig = null;
|
let currentConfig = null;
|
||||||
let currentSchedulerStatus = null;
|
let currentSchedulerStatus = null;
|
||||||
@@ -25,8 +28,8 @@ function schedulerOptionalEl(id) {
|
|||||||
let schedulerStepPending = false;
|
let schedulerStepPending = false;
|
||||||
let schEntryEditIdx = null;
|
let schEntryEditIdx = null;
|
||||||
let schedulerDirty = false;
|
let schedulerDirty = false;
|
||||||
function initScheduler(rigId, role) {
|
function initScheduler(rigId, roles) {
|
||||||
schedulerRole = role;
|
schedulerRoles = roles;
|
||||||
currentRigId = rigId || null;
|
currentRigId = rigId || null;
|
||||||
if (currentRigId) loadScheduler();
|
if (currentRigId) loadScheduler();
|
||||||
startStatusPolling();
|
startStatusPolling();
|
||||||
@@ -272,7 +275,7 @@ function schedulerOptionalEl(id) {
|
|||||||
const nextBtn = schedulerEl("scheduler-next-btn");
|
const nextBtn = schedulerEl("scheduler-next-btn");
|
||||||
if (!prevBtn || !nextBtn) return;
|
if (!prevBtn || !nextBtn) return;
|
||||||
const state = schedulerInterleaveState(currentConfig);
|
const state = schedulerInterleaveState(currentConfig);
|
||||||
const enabled = schedulerRole === "control" && !!currentRigId && !schedulerStepPending && state.activeEntries.length > 1;
|
const enabled = hasAuthRole(schedulerRoles, "control") && !!currentRigId && !schedulerStepPending && state.activeEntries.length > 1;
|
||||||
prevBtn.disabled = !enabled;
|
prevBtn.disabled = !enabled;
|
||||||
nextBtn.disabled = !enabled;
|
nextBtn.disabled = !enabled;
|
||||||
const hint = enabled ? "Select a different active scheduler entry" : "Available only when multiple scheduler entries are active";
|
const hint = enabled ? "Select a different active scheduler entry" : "Available only when multiple scheduler entries are active";
|
||||||
@@ -354,7 +357,7 @@ function schedulerOptionalEl(id) {
|
|||||||
const panel = schedulerEl("scheduler-panel");
|
const panel = schedulerEl("scheduler-panel");
|
||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
const mode = currentConfig && currentConfig.mode || "disabled";
|
const mode = currentConfig && currentConfig.mode || "disabled";
|
||||||
const isControl = schedulerRole === "control";
|
const isControl = hasAuthRole(schedulerRoles, "control");
|
||||||
setSelected("scheduler-mode-select", mode);
|
setSelected("scheduler-mode-select", mode);
|
||||||
const satEnabled = currentConfig && currentConfig.satellites && currentConfig.satellites.enabled;
|
const satEnabled = currentConfig && currentConfig.satellites && currentConfig.satellites.enabled;
|
||||||
const controlRow = document.querySelector(".scheduler-control-row");
|
const controlRow = document.querySelector(".scheduler-control-row");
|
||||||
@@ -1220,8 +1223,8 @@ function schedulerOptionalEl(id) {
|
|||||||
markDirty: markSchedulerDirty
|
markDirty: markSchedulerDirty
|
||||||
};
|
};
|
||||||
schedulerWindow.trx.modules.scheduler = schedulerService;
|
schedulerWindow.trx.modules.scheduler = schedulerService;
|
||||||
if (hostState.authRole != null) {
|
if (!hostState.authEnabled || hostState.authRoles.length > 0) {
|
||||||
initScheduler(hostState.lastActiveRigId, hostState.authRole);
|
initScheduler(hostState.lastActiveRigId, hostState.authRoles);
|
||||||
wireSchedulerEvents();
|
wireSchedulerEvents();
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
|
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
|
||||||
<symbol id="icon-home" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.8 7 8 2.9 13.2 7"/><path d="M4.3 5.9V13h7.4V5.9"/><path d="M6.8 13V9.3h2.4V13"/></symbol>
|
<symbol id="icon-home" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.8 7 8 2.9 13.2 7"/><path d="M4.3 5.9V13h7.4V5.9"/><path d="M6.8 13V9.3h2.4V13"/></symbol>
|
||||||
<symbol id="icon-bookmark" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 2h8v12l-4-2.5L4 14V2z"/></symbol>
|
<symbol id="icon-bookmark" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 2h8v12l-4-2.5L4 14V2z"/></symbol>
|
||||||
|
<symbol id="icon-logbook" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 2h8a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/><path d="M5.5 2v12"/><path d="M7.5 5.5h3M7.5 8h3M7.5 10.5h2"/></symbol>
|
||||||
<symbol id="icon-digital" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 11.5h2.6V4.5h3.2v7h3.2v-7h3.2v7H15"/></symbol>
|
<symbol id="icon-digital" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 11.5h2.6V4.5h3.2v7h3.2v-7h3.2v7H15"/></symbol>
|
||||||
<symbol id="icon-map" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2a4 4 0 0 1 4 4c0 3-4 8-4 8S4 9 4 6a4 4 0 0 1 4-4z"/><circle cx="8" cy="6" r="1.2" fill="currentColor" stroke="none"/></symbol>
|
<symbol id="icon-map" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2a4 4 0 0 1 4 4c0 3-4 8-4 8S4 9 4 6a4 4 0 0 1 4-4z"/><circle cx="8" cy="6" r="1.2" fill="currentColor" stroke="none"/></symbol>
|
||||||
<symbol id="icon-satellite" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 13.2 5.6 9.6"/><path d="M4.2 8.2a3.4 3.4 0 0 1 4.8 4.8z"/><path d="M9.2 6.6a3.2 3.2 0 0 1 2.6 2.6"/><path d="M9.8 3.6a6.2 6.2 0 0 1 5.2 5.2"/></symbol>
|
<symbol id="icon-satellite" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 13.2 5.6 9.6"/><path d="M4.2 8.2a3.4 3.4 0 0 1 4.8 4.8z"/><path d="M9.2 6.6a3.2 3.2 0 0 1 2.6 2.6"/><path d="M9.8 3.6a6.2 6.2 0 0 1 5.2 5.2"/></symbol>
|
||||||
@@ -53,6 +54,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-bookmark"/></svg>
|
<svg class="tab-icon" aria-hidden="true"><use href="#icon-bookmark"/></svg>
|
||||||
<span class="tab-label">Bookmarks</span>
|
<span class="tab-label">Bookmarks</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="tab" data-tab="logbook">
|
||||||
|
<svg class="tab-icon" aria-hidden="true"><use href="#icon-logbook"/></svg>
|
||||||
|
<span class="tab-label">Logbook</span>
|
||||||
|
</button>
|
||||||
<button class="tab" data-tab="digital-modes" aria-label="Digital modes">
|
<button class="tab" data-tab="digital-modes" aria-label="Digital modes">
|
||||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-digital"/></svg>
|
<svg class="tab-icon" aria-hidden="true"><use href="#icon-digital"/></svg>
|
||||||
<span class="tab-label">Digital modes</span>
|
<span class="tab-label">Digital modes</span>
|
||||||
@@ -118,13 +123,13 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<div id="auth-gate" class="auth-gate" style="display:none;">
|
<div id="auth-gate" class="auth-gate" style="display:none;">
|
||||||
<div class="auth-gate-head">
|
<div class="auth-gate-head">
|
||||||
<div class="auth-gate-title">Access Required</div>
|
<div class="auth-gate-title">Access Required</div>
|
||||||
<div class="auth-gate-sub">Enter passphrase to continue</div>
|
<div class="auth-gate-sub">Sign in to continue</div>
|
||||||
</div>
|
</div>
|
||||||
<form id="auth-form" class="auth-form">
|
<form id="auth-form" class="auth-form">
|
||||||
<input type="password" id="auth-passphrase" class="auth-input" placeholder="Passphrase" autocomplete="off" />
|
<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 />
|
||||||
<button type="submit" class="auth-submit">Login</button>
|
<button type="submit" class="auth-submit">Login</button>
|
||||||
</form>
|
</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-error" class="auth-error" style="display: none;"></div>
|
||||||
<div id="auth-role" class="auth-role" style="display: none;"></div>
|
<div id="auth-role" class="auth-role" style="display: none;"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -380,6 +385,23 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
</label>
|
</label>
|
||||||
<button id="sdr-nb-threshold-set" type="button" class="wfm-inline-btn">Set</button>
|
<button id="sdr-nb-threshold-set" type="button" class="wfm-inline-btn">Set</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<div class="full-row label-below-row" id="vchan-row">
|
<div class="full-row label-below-row" id="vchan-row">
|
||||||
@@ -480,6 +502,192 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="tab-logbook" class="tab-panel" style="display:none;">
|
||||||
|
<div id="logbook-panel" class="log-panel">
|
||||||
|
<!-- Station: who the contacts are logged under, and from where. Shown
|
||||||
|
once rather than typed into every entry. -->
|
||||||
|
<div class="log-station">
|
||||||
|
<div class="log-station-identity">
|
||||||
|
<span class="log-station-call" id="log-station-callsign">--</span>
|
||||||
|
<label class="log-station-operator">
|
||||||
|
<span>Operator</span>
|
||||||
|
<input type="text" id="log-operator" class="status-input" autocomplete="off"
|
||||||
|
spellcheck="false" aria-label="Operator callsign" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="log-station-rig">
|
||||||
|
<span id="log-station-rig-name">--</span>
|
||||||
|
<span id="log-station-grid" class="log-station-grid"></span>
|
||||||
|
<span id="log-clock" class="log-clock" aria-live="polite"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Entry: opens pre-filled with the six fields the radio and the clock
|
||||||
|
can answer for. Everything else is the operator's. -->
|
||||||
|
<form id="log-entry-form" class="log-entry" autocomplete="off">
|
||||||
|
<div class="log-entry-grid">
|
||||||
|
<label class="log-field log-field-call">
|
||||||
|
<span>Callsign</span>
|
||||||
|
<input type="text" id="log-call" class="status-input" required
|
||||||
|
spellcheck="false" aria-describedby="log-worked-before" />
|
||||||
|
</label>
|
||||||
|
<label class="log-field">
|
||||||
|
<span>Frequency</span>
|
||||||
|
<input type="text" id="log-freq" class="status-input" inputmode="decimal" />
|
||||||
|
</label>
|
||||||
|
<label class="log-field log-field-narrow">
|
||||||
|
<span>Mode</span>
|
||||||
|
<input type="text" id="log-mode" class="status-input" spellcheck="false" />
|
||||||
|
</label>
|
||||||
|
<label class="log-field log-field-narrow">
|
||||||
|
<span>RST sent</span>
|
||||||
|
<input type="text" id="log-rst-sent" class="status-input" spellcheck="false" />
|
||||||
|
</label>
|
||||||
|
<label class="log-field log-field-narrow">
|
||||||
|
<span>RST rcvd</span>
|
||||||
|
<input type="text" id="log-rst-rcvd" class="status-input" spellcheck="false" />
|
||||||
|
</label>
|
||||||
|
<label class="log-field log-field-narrow">
|
||||||
|
<span>Locator</span>
|
||||||
|
<input type="text" id="log-grid" class="status-input" spellcheck="false" />
|
||||||
|
</label>
|
||||||
|
<label class="log-field">
|
||||||
|
<span>Name</span>
|
||||||
|
<input type="text" id="log-name" class="status-input" />
|
||||||
|
</label>
|
||||||
|
<label class="log-field">
|
||||||
|
<span>Comment</span>
|
||||||
|
<input type="text" id="log-comment" class="status-input" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<details class="log-contest" id="log-contest-block">
|
||||||
|
<summary>Contest exchange</summary>
|
||||||
|
<div class="log-entry-grid">
|
||||||
|
<label class="log-field">
|
||||||
|
<span>Contest</span>
|
||||||
|
<input type="text" id="log-contest-id" class="status-input" spellcheck="false"
|
||||||
|
placeholder="CQ-WW-SSB" />
|
||||||
|
</label>
|
||||||
|
<label class="log-field log-field-narrow">
|
||||||
|
<span>Serial sent</span>
|
||||||
|
<input type="text" id="log-stx" class="status-input" inputmode="numeric" />
|
||||||
|
</label>
|
||||||
|
<label class="log-field log-field-narrow">
|
||||||
|
<span>Received</span>
|
||||||
|
<input type="text" id="log-srx" class="status-input" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<div class="log-entry-actions">
|
||||||
|
<span id="log-worked-before" class="log-worked" aria-live="polite"></span>
|
||||||
|
<span class="log-entry-buttons">
|
||||||
|
<button type="button" id="log-clear-btn" class="log-secondary-btn">Clear</button>
|
||||||
|
<button type="submit" id="log-save-btn" class="log-primary-btn">Log contact</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- The log itself. -->
|
||||||
|
<div class="log-list-toolbar">
|
||||||
|
<input type="text" id="log-filter-call" class="status-input" placeholder="Callsign…"
|
||||||
|
aria-label="Filter by callsign" />
|
||||||
|
<select id="log-filter-band" class="status-input" aria-label="Filter by band">
|
||||||
|
<option value="">All bands</option>
|
||||||
|
</select>
|
||||||
|
<select id="log-filter-mode" class="status-input" aria-label="Filter by mode">
|
||||||
|
<option value="">All modes</option>
|
||||||
|
</select>
|
||||||
|
<span class="log-toolbar-spacer"></span>
|
||||||
|
<a id="log-export-btn" class="log-secondary-btn" href="/api/logbook/export.adi"
|
||||||
|
download>Export ADIF</a>
|
||||||
|
<button type="button" id="log-import-btn" class="log-secondary-btn">Import ADIF</button>
|
||||||
|
<input type="file" id="log-import-file" accept=".adi,.adif,text/plain" hidden />
|
||||||
|
</div>
|
||||||
|
<div class="log-table-wrap">
|
||||||
|
<table class="log-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Date</th>
|
||||||
|
<th scope="col">Time</th>
|
||||||
|
<th scope="col">Callsign</th>
|
||||||
|
<th scope="col">Band</th>
|
||||||
|
<th scope="col">Mode</th>
|
||||||
|
<th scope="col">Sent</th>
|
||||||
|
<th scope="col">Rcvd</th>
|
||||||
|
<th scope="col">Locator</th>
|
||||||
|
<th scope="col">Rig</th>
|
||||||
|
<th scope="col">QSL</th>
|
||||||
|
<th scope="col"><span class="visually-hidden">Actions</span></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="log-rows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div id="log-summary" class="log-summary" aria-live="polite"></div>
|
||||||
|
|
||||||
|
<details class="log-report" id="log-statistics-block">
|
||||||
|
<summary>Bands worked</summary>
|
||||||
|
<div class="log-table-wrap">
|
||||||
|
<table class="log-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Band</th>
|
||||||
|
<th scope="col">Contacts</th>
|
||||||
|
<th scope="col">Stations</th>
|
||||||
|
<th scope="col">Confirmed</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="log-statistics-rows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="log-report" id="log-contest-export-block">
|
||||||
|
<summary>Contest entry (Cabrillo)</summary>
|
||||||
|
<p class="log-report-note">
|
||||||
|
Contest logs are submitted in Cabrillo and rejected in anything else.
|
||||||
|
Only the contacts of the contest named here go into the entry.
|
||||||
|
</p>
|
||||||
|
<div class="log-entry-grid">
|
||||||
|
<label class="log-field">
|
||||||
|
<span>Contest</span>
|
||||||
|
<input type="text" id="log-cbr-contest" class="status-input" spellcheck="false"
|
||||||
|
placeholder="CQ-WW-SSB" />
|
||||||
|
</label>
|
||||||
|
<label class="log-field">
|
||||||
|
<span>Callsign</span>
|
||||||
|
<input type="text" id="log-cbr-callsign" class="status-input" spellcheck="false" />
|
||||||
|
</label>
|
||||||
|
<label class="log-field">
|
||||||
|
<span>Operators</span>
|
||||||
|
<select id="log-cbr-operator" class="status-input">
|
||||||
|
<option value="SINGLE-OP">Single operator</option>
|
||||||
|
<option value="MULTI-OP">Multi operator</option>
|
||||||
|
<option value="CHECKLOG">Checklog</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="log-field">
|
||||||
|
<span>Power</span>
|
||||||
|
<select id="log-cbr-power" class="status-input">
|
||||||
|
<option value="LOW">Low</option>
|
||||||
|
<option value="HIGH">High</option>
|
||||||
|
<option value="QRP">QRP</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="log-field log-field-narrow">
|
||||||
|
<span>Claimed score</span>
|
||||||
|
<input type="text" id="log-cbr-score" class="status-input" inputmode="numeric" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="log-entry-actions">
|
||||||
|
<span class="log-entry-buttons">
|
||||||
|
<a id="log-cbr-export" class="log-secondary-btn" href="/api/logbook/export.cbr"
|
||||||
|
download>Export Cabrillo</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="tab-bookmarks" class="tab-panel" style="display:none;">
|
<div id="tab-bookmarks" class="tab-panel" style="display:none;">
|
||||||
<div class="bm-toolbar">
|
<div class="bm-toolbar">
|
||||||
<select id="bm-scope-picker" class="status-input" aria-label="Bookmark scope">
|
<select id="bm-scope-picker" class="status-input" aria-label="Bookmark scope">
|
||||||
@@ -530,6 +738,14 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<label class="bm-label">Bandwidth (Hz)
|
<label class="bm-label">Bandwidth (Hz)
|
||||||
<input type="number" id="bm-bw" class="status-input" min="0" placeholder="optional" />
|
<input type="number" id="bm-bw" class="status-input" min="0" placeholder="optional" />
|
||||||
</label>
|
</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
|
<label class="bm-label">Locator
|
||||||
<input type="text" id="bm-locator" class="status-input" maxlength="6" placeholder="e.g. JO93" />
|
<input type="text" id="bm-locator" class="status-input" maxlength="6" placeholder="e.g. JO93" />
|
||||||
</label>
|
</label>
|
||||||
@@ -1270,6 +1486,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<button class="sub-tab" data-subtab="settings-background-decode">Background Decode</button>
|
<button class="sub-tab" data-subtab="settings-background-decode">Background Decode</button>
|
||||||
<button class="sub-tab" data-subtab="settings-bandplan">Bandplan</button>
|
<button class="sub-tab" data-subtab="settings-bandplan">Bandplan</button>
|
||||||
<button class="sub-tab" data-subtab="settings-history">History</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>
|
||||||
<div id="subtab-settings-scheduler" class="sub-tab-panel">
|
<div id="subtab-settings-scheduler" class="sub-tab-panel">
|
||||||
<div id="scheduler-panel" class="sch-panel">
|
<div id="scheduler-panel" class="sch-panel">
|
||||||
@@ -1559,6 +1777,34 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
<div id="tab-about" class="tab-panel" style="display:none;">
|
<div id="tab-about" class="tab-panel" style="display:none;">
|
||||||
<h2 class="section-heading">About</h2>
|
<h2 class="section-heading">About</h2>
|
||||||
|
|||||||
@@ -196,8 +196,7 @@ body {
|
|||||||
font-size: var(--fs-base);
|
font-size: var(--fs-base);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.auth-submit,
|
.auth-submit {
|
||||||
.auth-guest {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.65rem 0.75rem;
|
padding: 0.65rem 0.75rem;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
@@ -213,16 +212,14 @@ body {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
.auth-submit:hover:not(:disabled) { background: var(--accent-green-hover); }
|
.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-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); }
|
.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; }
|
.label { color: var(--text-muted); font-size: 0.9rem; margin-bottom: 6px; display: block; }
|
||||||
#tab-main .label > span {
|
#tab-main .label > span {
|
||||||
@@ -6388,3 +6385,280 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#tab-statistics > .section-heading { margin-bottom: 0; }
|
#tab-statistics > .section-heading { margin-bottom: 0; }
|
||||||
|
|
||||||
|
/* =========================================================================
|
||||||
|
Logbook
|
||||||
|
========================================================================= */
|
||||||
|
.log-panel {
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
/* Who the contacts are logged under, and from where: shown once, not typed
|
||||||
|
into every entry. */
|
||||||
|
.log-station {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.6rem 0.85rem;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
.log-station-identity {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.85rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.log-station-call {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.log-station-operator {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.log-station-operator input {
|
||||||
|
width: 7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.log-station-rig {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.log-station-grid {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.log-clock {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
padding: 0.1rem 0.45rem;
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
background: color-mix(in srgb, var(--card-bg) 70%, transparent);
|
||||||
|
}
|
||||||
|
/* A browser clock that disagrees with the radio's is worth saying out loud:
|
||||||
|
the times in a log are the one thing that cannot be corrected later. */
|
||||||
|
.log-clock.is-adrift {
|
||||||
|
color: var(--accent-yellow, #b7791f);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.log-entry {
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.85rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
.log-entry-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(8.5rem, 1fr));
|
||||||
|
gap: 0.6rem 0.75rem;
|
||||||
|
}
|
||||||
|
.log-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.log-field input {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.log-field-call {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
.log-field-call input {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.log-field-narrow {
|
||||||
|
max-width: 10rem;
|
||||||
|
}
|
||||||
|
.log-entry-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
.log-entry-buttons {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.log-worked {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.log-worked.is-worked {
|
||||||
|
color: var(--accent-green);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.log-primary-btn {
|
||||||
|
background: var(--accent-green);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
padding: 0.4rem 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.log-secondary-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.log-secondary-btn:hover {
|
||||||
|
background: var(--card-bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.log-list-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.log-list-toolbar .status-input {
|
||||||
|
max-width: 11rem;
|
||||||
|
}
|
||||||
|
.log-toolbar-spacer {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
.log-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
.log-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.83rem;
|
||||||
|
}
|
||||||
|
.log-table th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.5rem 0.65rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.log-table td {
|
||||||
|
padding: 0.4rem 0.65rem;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--border-light) 60%, transparent);
|
||||||
|
white-space: nowrap;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.log-table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.log-table tbody tr:hover {
|
||||||
|
background: color-mix(in srgb, var(--card-bg) 60%, transparent);
|
||||||
|
}
|
||||||
|
.log-cell-call {
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-variant-numeric: normal;
|
||||||
|
}
|
||||||
|
.log-row-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.log-row-btn:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--card-bg);
|
||||||
|
}
|
||||||
|
.log-empty {
|
||||||
|
padding: 1.5rem 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
.log-summary {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
/* The ham layout works the bands: the broadcast furniture goes, and the entry
|
||||||
|
form is the thing the panel opens on. */
|
||||||
|
body[data-operator-layout="ham"] .controls-col-wfm { display: none !important; }
|
||||||
|
body[data-operator-layout="ham"] #rds-panel { display: none !important; }
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.log-station,
|
||||||
|
.log-entry-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.log-entry-buttons {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
.log-field-call {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
.log-list-toolbar .status-input {
|
||||||
|
max-width: none;
|
||||||
|
flex: 1 1 8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* Contest exchange and the reports below the log: folded away, because most
|
||||||
|
operating is not a contest and most sessions do not export one. */
|
||||||
|
.log-contest,
|
||||||
|
.log-report {
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 0.5rem 0.85rem;
|
||||||
|
}
|
||||||
|
.log-contest {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
.log-contest > summary,
|
||||||
|
.log-report > summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.log-contest[open] > summary,
|
||||||
|
.log-report[open] > summary {
|
||||||
|
margin-bottom: 0.65rem;
|
||||||
|
}
|
||||||
|
.log-report-note {
|
||||||
|
margin: 0 0 0.65rem;
|
||||||
|
max-width: 62ch;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,9 +12,13 @@ use trx_core::rig::{
|
|||||||
RigAccessMethod, RigCapabilities, RigInfo, RigRxStatus, RigStatus, RigTxStatus, RigVfo,
|
RigAccessMethod, RigCapabilities, RigInfo, RigRxStatus, RigStatus, RigTxStatus, RigVfo,
|
||||||
RigVfoEntry,
|
RigVfoEntry,
|
||||||
};
|
};
|
||||||
use trx_core::{DecoderConfig, RdsData, RigFilterState, RigMode, RigSnapshot, WfmDenoiseLevel};
|
use trx_core::{
|
||||||
|
DecoderConfig, DigSidebandPolicy, NoiseBlankerProfile, RdsData, RigFilterState, RigMode,
|
||||||
|
RigSnapshot, WfmDenoiseLevel,
|
||||||
|
};
|
||||||
use trx_frontend_http::server::api::rig::{RigListItem, RigListResponse};
|
use trx_frontend_http::server::api::rig::{RigListItem, RigListResponse};
|
||||||
use trx_frontend_http::server::api::FrontendMeta;
|
use trx_frontend_http::server::api::FrontendMeta;
|
||||||
|
use trx_frontend_http::server::auth::AuthRole;
|
||||||
use trx_protocol::{DecoderActivation, DecoderDescriptor};
|
use trx_protocol::{DecoderActivation, DecoderDescriptor};
|
||||||
use ts_rs::{Config, TS};
|
use ts_rs::{Config, TS};
|
||||||
|
|
||||||
@@ -48,6 +52,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
export!(RigStatus);
|
export!(RigStatus);
|
||||||
export!(DecoderConfig);
|
export!(DecoderConfig);
|
||||||
export!(WfmDenoiseLevel);
|
export!(WfmDenoiseLevel);
|
||||||
|
export!(DigSidebandPolicy);
|
||||||
|
export!(NoiseBlankerProfile);
|
||||||
export!(RigFilterState);
|
export!(RigFilterState);
|
||||||
export!(RdsData);
|
export!(RdsData);
|
||||||
export!(SpectrumData);
|
export!(SpectrumData);
|
||||||
@@ -56,6 +62,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
export!(RigListItem);
|
export!(RigListItem);
|
||||||
export!(RigListResponse);
|
export!(RigListResponse);
|
||||||
export!(FrontendMeta);
|
export!(FrontendMeta);
|
||||||
|
export!(AuthRole);
|
||||||
export!(DecoderActivation);
|
export!(DecoderActivation);
|
||||||
export!(DecoderDescriptor);
|
export!(DecoderDescriptor);
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ await build({
|
|||||||
"satellite-predictions": path.join(sourceDir, "plugins", "satellite-predictions.ts"),
|
"satellite-predictions": path.join(sourceDir, "plugins", "satellite-predictions.ts"),
|
||||||
vchan: path.join(sourceDir, "plugins", "vchan.ts"),
|
vchan: path.join(sourceDir, "plugins", "vchan.ts"),
|
||||||
bookmarks: path.join(sourceDir, "plugins", "bookmarks.ts"),
|
bookmarks: path.join(sourceDir, "plugins", "bookmarks.ts"),
|
||||||
|
logbook: path.join(sourceDir, "plugins", "logbook.ts"),
|
||||||
scheduler: path.join(sourceDir, "plugins", "scheduler.ts"),
|
scheduler: path.join(sourceDir, "plugins", "scheduler.ts"),
|
||||||
"map-core": path.join(sourceDir, "map-core.ts"),
|
"map-core": path.join(sourceDir, "map-core.ts"),
|
||||||
screenshot: path.join(sourceDir, "screenshot.ts"),
|
screenshot: path.join(sourceDir, "screenshot.ts"),
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
|
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
|
||||||
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
|
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
|
||||||
"test": "node --test tests/*.test.mjs",
|
"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",
|
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs && node tests/mobile-layout.mjs && node tests/satellite-predictions.mjs && node tests/background-decode.mjs && node tests/logbook.mjs && node tests/account-management.mjs && node tests/transmit-role.mjs",
|
||||||
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
|
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -2,11 +2,51 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
export type AuthRole = "rx" | "control";
|
import type { AuthRole } from "./generated.js";
|
||||||
|
|
||||||
|
export type { AuthRole };
|
||||||
|
|
||||||
|
export const AUTH_ROLES: readonly AuthRole[] = ["guest", "read", "control", "transmit", "write", "administrator"];
|
||||||
|
export const AUTH_ADMIN_ROLES: readonly AuthRole[] = AUTH_ROLES.filter(role => role !== "guest");
|
||||||
|
export const AUTH_ROLE_LABELS: Readonly<Record<AuthRole, string>> = {
|
||||||
|
guest: "Guest",
|
||||||
|
read: "Read",
|
||||||
|
control: "Control",
|
||||||
|
transmit: "Transmit",
|
||||||
|
write: "Write",
|
||||||
|
administrator: "Administrator",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isAuthRole(value: unknown): value is AuthRole {
|
||||||
|
return typeof value === "string" && (AUTH_ROLES as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeAuthRoles(roles: readonly AuthRole[]): AuthRole[] {
|
||||||
|
return AUTH_ROLES.filter(role => roles.includes(role));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasAuthRole(roles: readonly AuthRole[], required: AuthRole): boolean {
|
||||||
|
return roles.includes("administrator")
|
||||||
|
|| roles.includes(required)
|
||||||
|
|| required === "read" && roles.includes("guest")
|
||||||
|
|| required === "read" && (roles.includes("control") || roles.includes("transmit"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasAccountControls(roles: readonly AuthRole[]): boolean {
|
||||||
|
return roles.length > 0 && !roles.includes("guest");
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeRoles(value: unknown, context: string): AuthRole[] {
|
||||||
|
if (!Array.isArray(value) || !value.every(isAuthRole)) {
|
||||||
|
throw new TypeError(`${context} has invalid roles`);
|
||||||
|
}
|
||||||
|
return normalizeAuthRoles(value);
|
||||||
|
}
|
||||||
|
|
||||||
export interface AuthSession {
|
export interface AuthSession {
|
||||||
authenticated: boolean;
|
authenticated: boolean;
|
||||||
role?: AuthRole;
|
roles: AuthRole[];
|
||||||
|
username?: string;
|
||||||
auth_disabled?: boolean;
|
auth_disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,21 +58,24 @@ function decodeAuthSession(value: unknown): AuthSession {
|
|||||||
if (typeof session.authenticated !== "boolean") {
|
if (typeof session.authenticated !== "boolean") {
|
||||||
throw new TypeError("The authentication response has no authenticated flag");
|
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") {
|
if (session.auth_disabled !== undefined && typeof session.auth_disabled !== "boolean") {
|
||||||
throw new TypeError("The authentication response has an invalid auth_disabled flag");
|
throw new TypeError("The authentication response has an invalid auth_disabled flag");
|
||||||
}
|
}
|
||||||
const decoded: AuthSession = { authenticated: session.authenticated };
|
const decoded: AuthSession = {
|
||||||
if (session.role !== undefined) decoded.role = session.role;
|
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;
|
||||||
|
}
|
||||||
if (session.auth_disabled !== undefined) decoded.auth_disabled = session.auth_disabled;
|
if (session.auth_disabled !== undefined) decoded.auth_disabled = session.auth_disabled;
|
||||||
return decoded;
|
return decoded;
|
||||||
}
|
}
|
||||||
|
|
||||||
const authDisabledSession: AuthSession = {
|
const authDisabledSession: AuthSession = {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
role: "control",
|
roles: [...AUTH_ADMIN_ROLES],
|
||||||
auth_disabled: true,
|
auth_disabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -40,19 +83,19 @@ export async function fetchAuthSession(): Promise<AuthSession> {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch("/auth/session");
|
const response = await fetch("/auth/session");
|
||||||
if (response.status === 404) return authDisabledSession;
|
if (response.status === 404) return authDisabledSession;
|
||||||
if (!response.ok) return { authenticated: false };
|
if (!response.ok) return { authenticated: false, roles: [] };
|
||||||
return decodeAuthSession(await response.json());
|
return decodeAuthSession(await response.json());
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.error("Auth check failed:", error);
|
console.error("Auth check failed:", error);
|
||||||
return { authenticated: false };
|
return { authenticated: false, roles: [] };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function login(passphrase: string): Promise<AuthSession> {
|
export async function login(username: string, password: string): Promise<AuthSession> {
|
||||||
const response = await fetch("/auth/login", {
|
const response = await fetch("/auth/login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ passphrase }),
|
body: JSON.stringify({ username, password }),
|
||||||
});
|
});
|
||||||
if (response.status === 404) return authDisabledSession;
|
if (response.status === 404) return authDisabledSession;
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -62,6 +105,52 @@ export async function login(passphrase: string): Promise<AuthSession> {
|
|||||||
return decodeAuthSession(await response.json());
|
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> {
|
export async function logout(): Promise<void> {
|
||||||
const response = await fetch("/auth/logout", { method: "POST" });
|
const response = await fetch("/auth/logout", { method: "POST" });
|
||||||
if (response.status !== 404 && !response.ok) throw new Error("Logout failed");
|
if (response.status !== 404 && !response.ok) throw new Error("Logout failed");
|
||||||
|
|||||||
@@ -55,7 +55,21 @@ export type DecoderConfig = { aprs_decode_enabled: boolean, hf_aprs_decode_enabl
|
|||||||
|
|
||||||
export type WfmDenoiseLevel = "off" | "auto" | "low" | "medium" | "high";
|
export type WfmDenoiseLevel = "off" | "auto" | "low" | "medium" | "high";
|
||||||
|
|
||||||
export type RigFilterState = { bandwidth_hz: number, cw_center_hz: number, sdr_gain_db?: number | null, sdr_lna_gain_db?: number | null, sdr_agc_enabled?: boolean | null, sdr_squelch_enabled?: boolean | null, sdr_squelch_threshold_db?: number | null, sdr_nb_enabled?: boolean | null, sdr_nb_threshold?: number | null, wfm_deemphasis_us: number, wfm_stereo: boolean, wfm_stereo_detected: boolean, wfm_denoise: WfmDenoiseLevel,
|
export type DigSidebandPolicy = "auto" | "usb" | "lsb";
|
||||||
|
|
||||||
|
export type NoiseBlankerProfile = "spike" | "ignition" | "powerline" | "broadband";
|
||||||
|
|
||||||
|
export type RigFilterState = { bandwidth_hz: number, cw_center_hz: number, sdr_gain_db?: number | null, sdr_lna_gain_db?: number | null, sdr_agc_enabled?: boolean | null, sdr_squelch_enabled?: boolean | null, sdr_squelch_threshold_db?: number | null, sdr_nb_enabled?: boolean | null, sdr_nb_threshold?: number | null,
|
||||||
|
/**
|
||||||
|
* Current noise-blanker tuning profile (SDR backends only). Surfaces in the
|
||||||
|
* UI as the advanced-controls "NB profile" selector.
|
||||||
|
*/
|
||||||
|
sdr_nb_profile?: NoiseBlankerProfile | null,
|
||||||
|
/**
|
||||||
|
* Current DIG sideband policy (SDR backends only). Surfaces in the UI as
|
||||||
|
* the advanced-controls "DIG sideband" selector.
|
||||||
|
*/
|
||||||
|
sdr_dig_sideband?: DigSidebandPolicy | null, wfm_deemphasis_us: number, wfm_stereo: boolean, wfm_stereo_detected: boolean, wfm_denoise: WfmDenoiseLevel,
|
||||||
/**
|
/**
|
||||||
* Co-Channel Interference level (0–100 scale).
|
* Co-Channel Interference level (0–100 scale).
|
||||||
*/
|
*/
|
||||||
@@ -123,6 +137,8 @@ export type RigListResponse = { active_remote: string | null, rigs: Array<RigLis
|
|||||||
|
|
||||||
export type FrontendMeta = { clients: number, rigctl_clients: number, audio_clients: number, rigctl_addr: string | null, active_remote: string | null, remotes: Array<string>, owner_callsign: string | null, owner_website_url: string | null, owner_website_name: string | null, ais_vessel_url_base: string | null, show_sdr_gain_control: boolean, initial_map_zoom: number, spectrum_coverage_margin_hz: number, spectrum_usable_span_ratio: number, bandplan_enabled: boolean, bandplan_region: string, decode_history_retention_min: bigint, server_connected: boolean, };
|
export type 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 DecoderActivation = "mode_bound" | "toggle";
|
||||||
|
|
||||||
export type DecoderDescriptor = {
|
export type DecoderDescriptor = {
|
||||||
|
|||||||
@@ -21,6 +21,17 @@ import {
|
|||||||
fetchAuthSession,
|
fetchAuthSession,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
|
listUsers,
|
||||||
|
createUser,
|
||||||
|
updateUser,
|
||||||
|
deleteUser,
|
||||||
|
changeOwnPassword,
|
||||||
|
AUTH_ADMIN_ROLES,
|
||||||
|
AUTH_ROLES,
|
||||||
|
AUTH_ROLE_LABELS,
|
||||||
|
hasAccountControls,
|
||||||
|
hasAuthRole as rolesInclude,
|
||||||
|
normalizeAuthRoles,
|
||||||
} from "./api/auth.js";
|
} from "./api/auth.js";
|
||||||
import {
|
import {
|
||||||
formatByteSize as recorderFormatSize,
|
formatByteSize as recorderFormatSize,
|
||||||
@@ -189,8 +200,8 @@ interface TrxModules {
|
|||||||
reverseGeocodeLocation(lat: number, lon: number, grid: string): void;
|
reverseGeocodeLocation(lat: number, lon: number, grid: string): void;
|
||||||
bandForHz(frequencyHz: number): unknown;
|
bandForHz(frequencyHz: number): unknown;
|
||||||
};
|
};
|
||||||
scheduler?: { initialize(rigId: string | null, role: AuthRole | null): void; setRig(rigId: string | null): void; wireEvents(): void };
|
scheduler?: { initialize(rigId: string | null, roles: readonly AuthRole[]): void; setRig(rigId: string | null): void; wireEvents(): void };
|
||||||
backgroundDecode?: { initialize(rigId: string | null, role: AuthRole | null): void; setRig(rigId: string | null): void; wireEvents(): void };
|
backgroundDecode?: { initialize(rigId: string | null, roles: readonly AuthRole[]): void; setRig(rigId: string | null): void; wireEvents(): void };
|
||||||
bookmarks?: {
|
bookmarks?: {
|
||||||
readonly overlayList: readonly Bookmark[];
|
readonly overlayList: readonly Bookmark[];
|
||||||
readonly overlayRevision: number;
|
readonly overlayRevision: number;
|
||||||
@@ -226,7 +237,7 @@ interface TrxState {
|
|||||||
readonly initialMapZoom: number;
|
readonly initialMapZoom: number;
|
||||||
readonly decodeHistoryRetentionMin: number;
|
readonly decodeHistoryRetentionMin: number;
|
||||||
readonly authEnabled: boolean;
|
readonly authEnabled: boolean;
|
||||||
readonly authRole: AuthRole | null;
|
readonly authRoles: readonly AuthRole[];
|
||||||
readonly decoderRegistry: typeof decoderRegistry;
|
readonly decoderRegistry: typeof decoderRegistry;
|
||||||
readonly sseSessionId: string | null;
|
readonly sseSessionId: string | null;
|
||||||
readonly primaryRds: RdsData | null;
|
readonly primaryRds: RdsData | null;
|
||||||
@@ -265,7 +276,7 @@ interface TrxUi {
|
|||||||
confirm(options?: { title?: string; message?: string; confirmLabel?: string; danger?: boolean }): Promise<boolean>;
|
confirm(options?: { title?: string; message?: string; confirmLabel?: string; danger?: boolean }): Promise<boolean>;
|
||||||
notify(message: string, options?: { kind?: "info" | "error" | "success" | "warning"; duration?: number; action?: { label?: string; run(): void } | null }): HTMLDivElement;
|
notify(message: string, options?: { kind?: "info" | "error" | "success" | "warning"; duration?: number; action?: { label?: string; run(): void } | null }): HTMLDivElement;
|
||||||
setActiveRig(rigId: string | null): void;
|
setActiveRig(rigId: string | null): void;
|
||||||
setLayoutCapabilities(capabilities?: Partial<Record<"broadcast" | "digital", boolean>>): void;
|
setLayoutCapabilities(capabilities?: Partial<Record<"broadcast" | "digital" | "ham", boolean>>): void;
|
||||||
setButtonState(element: HTMLButtonElement | null, options?: { active?: boolean; activeLabel?: string; inactiveLabel?: string; busy?: boolean; disabled?: boolean }): void;
|
setButtonState(element: HTMLButtonElement | null, options?: { active?: boolean; activeLabel?: string; inactiveLabel?: string; busy?: boolean; disabled?: boolean }): void;
|
||||||
closeMobileOverlays?(restoreFocus?: boolean): void;
|
closeMobileOverlays?(restoreFocus?: boolean): void;
|
||||||
syncSelectedTab(container: HTMLElement | null, selected: Element): void;
|
syncSelectedTab(container: HTMLElement | null, selected: Element): void;
|
||||||
@@ -406,40 +417,76 @@ declare global {
|
|||||||
void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
|
void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
|
||||||
|
|
||||||
// --- Authentication ---
|
// --- Authentication ---
|
||||||
let authRole: AuthRole | null = null;
|
let authRoles: AuthRole[] = [];
|
||||||
|
let authUsername: string | null = null;
|
||||||
let authEnabled = true;
|
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() {
|
async function checkAuthStatus() {
|
||||||
return fetchAuthSession();
|
return fetchAuthSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function authLogin(passphrase: string) {
|
async function authLogin(username: string, password: string) {
|
||||||
return login(passphrase);
|
return login(username, password);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function authLogout() {
|
async function authLogout() {
|
||||||
try {
|
try {
|
||||||
await logout();
|
await logout();
|
||||||
authRole = null;
|
setAuthRoles([]);
|
||||||
|
authUsername = null;
|
||||||
// Disconnect and show auth gate without page reload
|
// Disconnect and show auth gate without page reload
|
||||||
disconnect();
|
disconnect();
|
||||||
setDecodeHistoryOverlayVisible(false);
|
setDecodeHistoryOverlayVisible(false);
|
||||||
requiredElement("content").style.display = "none";
|
requiredElement("content").style.display = "none";
|
||||||
requiredElement("loading").style.display = "none";
|
requiredElement("loading").style.display = "none";
|
||||||
requiredElement<HTMLInputElement>("auth-passphrase").value = "";
|
requiredElement<HTMLInputElement>("auth-password").value = "";
|
||||||
updateAuthUI();
|
updateAuthUI();
|
||||||
|
|
||||||
// Check if guest mode is available after logout
|
showAuthGate();
|
||||||
const authStatus = await checkAuthStatus();
|
|
||||||
const allowGuest = authStatus.role === "rx";
|
|
||||||
showAuthGate(allowGuest);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Logout failed:", e);
|
console.error("Logout failed:", e);
|
||||||
showAuthError("Logout failed");
|
showAuthError("Logout failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showAuthGate(allowGuest = false) {
|
function showAuthGate() {
|
||||||
if (!authEnabled) return;
|
if (!authEnabled) return;
|
||||||
setDecodeHistoryOverlayVisible(false);
|
setDecodeHistoryOverlayVisible(false);
|
||||||
requiredElement("loading").style.display = "none";
|
requiredElement("loading").style.display = "none";
|
||||||
@@ -459,12 +506,6 @@ function showAuthGate(allowGuest = false) {
|
|||||||
panel.style.display = "none";
|
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) => {
|
document.querySelectorAll<HTMLElement>(".tab-bar .tab").forEach((btn) => {
|
||||||
btn.classList.toggle("active", btn.dataset.tab === "main");
|
btn.classList.toggle("active", btn.dataset.tab === "main");
|
||||||
});
|
});
|
||||||
@@ -506,22 +547,32 @@ function updateAuthUI() {
|
|||||||
const badge = document.getElementById("auth-badge");
|
const badge = document.getElementById("auth-badge");
|
||||||
const badgeRole = document.getElementById("auth-role-badge");
|
const badgeRole = document.getElementById("auth-role-badge");
|
||||||
const headerAuthBtn = document.getElementById("header-auth-btn");
|
const headerAuthBtn = document.getElementById("header-auth-btn");
|
||||||
|
const accountTab = document.getElementById("settings-account-tab");
|
||||||
|
|
||||||
if (!authEnabled) {
|
if (!authEnabled) {
|
||||||
if (badge) badge.style.display = "none";
|
if (badge) badge.style.display = "none";
|
||||||
if (headerAuthBtn) headerAuthBtn.style.display = "none";
|
if (headerAuthBtn) headerAuthBtn.style.display = "none";
|
||||||
|
if (accountTab) accountTab.style.display = "none";
|
||||||
syncTopBarAccess();
|
syncTopBarAccess();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authRole) {
|
if (authRoles.length > 0) {
|
||||||
|
const canManageAccount = hasAccountControls(authRoles);
|
||||||
|
if (accountTab) accountTab.style.display = canManageAccount ? "" : "none";
|
||||||
|
if (!canManageAccount && accountTab?.classList.contains("active")) {
|
||||||
|
const panel = document.getElementById("subtab-settings-account");
|
||||||
|
if (panel) panel.style.display = "none";
|
||||||
|
document.querySelector<HTMLButtonElement>('[data-subtab="settings-scheduler"]')?.click();
|
||||||
|
}
|
||||||
if (badge) badge.style.display = "block";
|
if (badge) badge.style.display = "block";
|
||||||
if (badgeRole) badgeRole.textContent = authRole === "control" ? "Control (full access)" : "RX (read-only)";
|
if (badgeRole) badgeRole.textContent = `${authUsername || "local"} — ${authRoles.map(role => AUTH_ROLE_LABELS[role]).join(", ")}`;
|
||||||
if (headerAuthBtn) {
|
if (headerAuthBtn) {
|
||||||
headerAuthBtn.textContent = "Logout";
|
headerAuthBtn.textContent = "Logout";
|
||||||
headerAuthBtn.style.display = "block";
|
headerAuthBtn.style.display = "block";
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
if (accountTab) accountTab.style.display = "none";
|
||||||
if (badge) badge.style.display = "none";
|
if (badge) badge.style.display = "none";
|
||||||
if (headerAuthBtn) {
|
if (headerAuthBtn) {
|
||||||
headerAuthBtn.textContent = "Login";
|
headerAuthBtn.textContent = "Login";
|
||||||
@@ -532,37 +583,40 @@ function updateAuthUI() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyAuthRestrictions() {
|
function applyAuthRestrictions() {
|
||||||
if (!authRole) return;
|
if (authRoles.length === 0) return;
|
||||||
|
|
||||||
// Disable TX/PTT/frequency/mode/VFO controls for rx role
|
if (!hasAuthRole("transmit")) {
|
||||||
if (authRole === "rx") {
|
|
||||||
const pttBtn = document.getElementById("ptt-btn") as HTMLButtonElement | null;
|
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 powerBtn = document.getElementById("power-btn") as HTMLButtonElement | null;
|
||||||
const lockBtn = document.getElementById("lock-btn") as HTMLButtonElement | null;
|
const lockBtn = document.getElementById("lock-btn") as HTMLButtonElement | null;
|
||||||
const freqInput = document.getElementById("freq") as HTMLInputElement | null;
|
const freqInput = document.getElementById("freq") as HTMLInputElement | null;
|
||||||
const centerFreqInput = document.getElementById("center-freq") as HTMLInputElement | null;
|
const centerFreqInput = document.getElementById("center-freq") as HTMLInputElement | null;
|
||||||
const modeSelect = document.getElementById("mode") as HTMLSelectElement | 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 jogUp = document.getElementById("jog-up") as HTMLButtonElement | null;
|
||||||
const jogDown = document.getElementById("jog-down") as HTMLButtonElement | null;
|
const jogDown = document.getElementById("jog-down") as HTMLButtonElement | null;
|
||||||
const jogButtons = document.querySelectorAll<HTMLButtonElement>(".jog-step button");
|
const jogButtons = document.querySelectorAll<HTMLButtonElement>(".jog-step button");
|
||||||
const vfoButtons = document.querySelectorAll<HTMLButtonElement>("#vfo-picker button");
|
const vfoButtons = document.querySelectorAll<HTMLButtonElement>("#vfo-picker button");
|
||||||
|
|
||||||
// Disable TX buttons
|
|
||||||
if (pttBtn) pttBtn.disabled = true;
|
|
||||||
if (powerBtn) powerBtn.disabled = true;
|
if (powerBtn) powerBtn.disabled = true;
|
||||||
if (lockBtn) lockBtn.disabled = true;
|
if (lockBtn) lockBtn.disabled = true;
|
||||||
if (txAudioBtn) txAudioBtn.disabled = true;
|
|
||||||
if (txLimitBtn) txLimitBtn.disabled = true;
|
|
||||||
|
|
||||||
// Disable frequency/mode inputs
|
// Disable frequency/mode inputs
|
||||||
if (freqInput) freqInput.disabled = true;
|
if (freqInput) freqInput.disabled = true;
|
||||||
if (centerFreqInput) centerFreqInput.disabled = true;
|
if (centerFreqInput) centerFreqInput.disabled = true;
|
||||||
if (modeSelect) modeSelect.disabled = true;
|
if (modeSelect) modeSelect.disabled = true;
|
||||||
if (txLimitInput) txLimitInput.disabled = true;
|
|
||||||
|
|
||||||
// Disable VFO selector
|
// Disable VFO selector
|
||||||
vfoButtons.forEach(btn => btn.disabled = true);
|
vfoButtons.forEach(btn => btn.disabled = true);
|
||||||
@@ -606,9 +660,6 @@ function applyAuthRestrictions() {
|
|||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Hide TX-specific UI but keep controls visible (disabled)
|
|
||||||
if (txLimitRow) txLimitRow.style.opacity = "0.5";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -867,14 +918,15 @@ window.applyDecodeHistoryRetention = function() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function syncTopBarAccess() {
|
function syncTopBarAccess() {
|
||||||
const loggedOut = authEnabled && !authRole;
|
const loggedOut = authEnabled && authRoles.length === 0;
|
||||||
const tabBar = document.getElementById("tab-bar");
|
const tabBar = document.getElementById("tab-bar");
|
||||||
const rigSwitch = document.querySelector<HTMLElement>(".header-rig-switch");
|
const rigSwitch = document.querySelector<HTMLElement>(".header-rig-switch");
|
||||||
if (tabBar) tabBar.style.display = "";
|
if (tabBar) tabBar.style.display = "";
|
||||||
|
|
||||||
document.querySelectorAll<HTMLButtonElement>(".tab-bar .tab").forEach((btn) => {
|
document.querySelectorAll<HTMLButtonElement>(".tab-bar .tab").forEach((btn) => {
|
||||||
const isMain = btn.dataset.tab === "main";
|
const isMain = btn.dataset.tab === "main";
|
||||||
btn.style.display = !loggedOut || isMain ? "" : "none";
|
const lacksLogbookAccess = authEnabled && btn.dataset.tab === "logbook" && !hasAuthRole("write");
|
||||||
|
btn.style.display = (!loggedOut || isMain) && !lacksLogbookAccess ? "" : "none";
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -883,7 +935,7 @@ function syncTopBarAccess() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (headerRigSwitchSelect) {
|
if (headerRigSwitchSelect) {
|
||||||
headerRigSwitchSelect.disabled = loggedOut || authRole === "rx" || lastRigIds.length === 0;
|
headerRigSwitchSelect.disabled = loggedOut || !hasAuthRole("control") || lastRigIds.length === 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1464,7 +1516,7 @@ function applyRigList(activeRigId: string | null, rigIds: string[], displayNames
|
|||||||
}
|
}
|
||||||
const nextKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || "");
|
const nextKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || "");
|
||||||
const rigListChanged = prevKey !== nextKey;
|
const rigListChanged = prevKey !== nextKey;
|
||||||
const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx";
|
const disableSwitch = lastRigIds.length === 0 || !hasAuthRole("control");
|
||||||
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
|
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
|
||||||
updateRigSubtitle(lastActiveRigId);
|
updateRigSubtitle(lastActiveRigId);
|
||||||
window.trxUi?.setActiveRig(lastActiveRigId);
|
window.trxUi?.setActiveRig(lastActiveRigId);
|
||||||
@@ -1522,6 +1574,9 @@ function refreshOperatorLayoutCapabilities() {
|
|||||||
window.trxUi?.setLayoutCapabilities({
|
window.trxUi?.setLayoutCapabilities({
|
||||||
broadcast: rigModes.some((modes) => modes.includes("WFM")),
|
broadcast: rigModes.some((modes) => modes.includes("WFM")),
|
||||||
digital: decoderModes.size > 0 && rigModes.some((modes) => modes.some((mode) => decoderModes.has(mode))),
|
digital: decoderModes.size > 0 && rigModes.some((modes) => modes.some((mode) => decoderModes.has(mode))),
|
||||||
|
// A logbook is for contacts made, so the layout built around it is offered
|
||||||
|
// where a rig can transmit.
|
||||||
|
ham: serverRigs.some((rig) => rig?.tx === true),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2395,10 +2450,26 @@ function visibleBandwidthSpecs(freqHz: number | null = lastFreqHz, mode = modeEl
|
|||||||
return [{ centerHz: freqHz, widthHz: currentBandwidthHz }];
|
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 : "") {
|
function sidebandDirectionForMode(mode = modeEl ? modeEl.value : "") {
|
||||||
const modeUpper = String(mode || "").toUpperCase();
|
const modeUpper = String(mode || "").toUpperCase();
|
||||||
if (modeUpper === "LSB" || modeUpper === "CWR") return -1;
|
if (modeUpper === "LSB" || modeUpper === "CWR") return -1;
|
||||||
if (modeUpper === "USB" || modeUpper === "CW" || modeUpper === "DIG") return 1;
|
// On SDR the DIG sideband is configurable; resolve it. On other backends
|
||||||
|
// keep the historical upper-sideband overlay.
|
||||||
|
if (modeUpper === "DIG") return sdrDigSidebandSupported ? digSidebandDirection() : 1;
|
||||||
|
if (modeUpper === "USB" || modeUpper === "CW") return 1;
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3026,9 +3097,16 @@ function formatSignal(sUnits: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setDisabled(disabled: boolean) {
|
function setDisabled(disabled: boolean) {
|
||||||
[freqEl, centerFreqEl, modeEl, pttBtn, powerBtn, txLimitInput, txLimitBtn, lockBtn].forEach((el) => {
|
const controlDisabled = disabled || (authEnabled && !hasAuthRole("control"));
|
||||||
if (el) el.disabled = disabled;
|
const transmitDisabled = disabled || (authEnabled && !hasAuthRole("transmit"));
|
||||||
|
[freqEl, centerFreqEl, modeEl, powerBtn, lockBtn].forEach((el) => {
|
||||||
|
if (el) el.disabled = controlDisabled;
|
||||||
});
|
});
|
||||||
|
[pttBtn, txLimitInput, txLimitBtn].forEach((el) => {
|
||||||
|
if (el) el.disabled = transmitDisabled;
|
||||||
|
});
|
||||||
|
const transmitAudio = document.getElementById("tx-audio-btn") as HTMLButtonElement | null;
|
||||||
|
if (transmitAudio) transmitAudio.disabled = transmitDisabled || !hasWebCodecs;
|
||||||
syncModePicker();
|
syncModePicker();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3349,7 +3427,7 @@ function scheduleTuneLinkSync() {
|
|||||||
async function applyTuneLink(link: TuneLink) {
|
async function applyTuneLink(link: TuneLink) {
|
||||||
const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null;
|
const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null;
|
||||||
if (!wanted) return;
|
if (!wanted) return;
|
||||||
if (authRole === "rx") {
|
if (!hasAuthRole("control")) {
|
||||||
showHint("Read-only session — link not applied", 2500);
|
showHint("Read-only session — link not applied", 2500);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3618,6 +3696,26 @@ function render(update: AppUpdate) {
|
|||||||
sdrNbThresholdEl.value = String(Math.round(update.filter.sdr_nb_threshold));
|
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") {
|
if (typeof update.show_sdr_gain_control === "boolean") {
|
||||||
@@ -4159,7 +4257,7 @@ async function postPath(path: string, options: PostOptions = {}) {
|
|||||||
const resp = await fetch(path, { method: "POST" });
|
const resp = await fetch(path, { method: "POST" });
|
||||||
if (authEnabled && resp.status === 401) {
|
if (authEnabled && resp.status === 401) {
|
||||||
// Not authenticated - return to login
|
// Not authenticated - return to login
|
||||||
authRole = null;
|
setAuthRoles([]);
|
||||||
if (es) es.close();
|
if (es) es.close();
|
||||||
showAuthGate();
|
showAuthGate();
|
||||||
throw new Error("Authentication required");
|
throw new Error("Authentication required");
|
||||||
@@ -4188,7 +4286,7 @@ async function switchRigFromSelect(selectEl: HTMLSelectElement) {
|
|||||||
showHint("No rig selected", 1500);
|
showHint("No rig selected", 1500);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (authRole === "rx") {
|
if (!hasAuthRole("control")) {
|
||||||
showHint("Control role required", 1500);
|
showHint("Control role required", 1500);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -4871,8 +4969,13 @@ function navigateToTab(name: TabName, options: { updateHistory?: boolean; replac
|
|||||||
window.trxUi?.closeMobileOverlays?.();
|
window.trxUi?.closeMobileOverlays?.();
|
||||||
const leavingSatellites = _activeTab === "satellites" && name !== "satellites";
|
const leavingSatellites = _activeTab === "satellites" && name !== "satellites";
|
||||||
const { updateHistory = true, replaceHistory = false } = options;
|
const { updateHistory = true, replaceHistory = false } = options;
|
||||||
if (authEnabled && !authRole && name !== "main") {
|
if (authEnabled && authRoles.length === 0 && name !== "main") {
|
||||||
showAuthGate(false);
|
showAuthGate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (authEnabled && name === "logbook" && !hasAuthRole("write")) {
|
||||||
|
showHint("Write role required for logbook access", 2500);
|
||||||
|
navigateToTab("main", options);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const btn = document.querySelector<HTMLElement>(`.tab-bar .tab[data-tab="${name}"]`);
|
const btn = document.querySelector<HTMLElement>(`.tab-bar .tab[data-tab="${name}"]`);
|
||||||
@@ -5005,12 +5108,12 @@ window.addEventListener("resize", () => { scheduleSpectrumLayout(); });
|
|||||||
|
|
||||||
// --- Auth startup sequence ---
|
// --- Auth startup sequence ---
|
||||||
async function initializeApp() {
|
async function initializeApp() {
|
||||||
showAuthGate(false);
|
showAuthGate();
|
||||||
const authStatus = await checkAuthStatus();
|
const authStatus = await checkAuthStatus();
|
||||||
authEnabled = !authStatus.auth_disabled;
|
authEnabled = !authStatus.auth_disabled;
|
||||||
|
|
||||||
if (!authEnabled) {
|
if (!authEnabled) {
|
||||||
authRole = "control";
|
setAuthRoles(AUTH_ADMIN_ROLES);
|
||||||
hideAuthGate();
|
hideAuthGate();
|
||||||
updateAuthUI();
|
updateAuthUI();
|
||||||
connect();
|
connect();
|
||||||
@@ -5023,7 +5126,8 @@ async function initializeApp() {
|
|||||||
|
|
||||||
if (authStatus.authenticated) {
|
if (authStatus.authenticated) {
|
||||||
// User has valid session
|
// User has valid session
|
||||||
authRole = authStatus.role ?? null;
|
setAuthRoles(authStatus.roles);
|
||||||
|
authUsername = authStatus.username ?? null;
|
||||||
hideAuthGate();
|
hideAuthGate();
|
||||||
updateAuthUI();
|
updateAuthUI();
|
||||||
applyAuthRestrictions();
|
applyAuthRestrictions();
|
||||||
@@ -5033,10 +5137,7 @@ async function initializeApp() {
|
|||||||
resizeHeaderSignalCanvas();
|
resizeHeaderSignalCanvas();
|
||||||
startHeaderSignalSampling();
|
startHeaderSignalSampling();
|
||||||
} else {
|
} else {
|
||||||
// No valid session - show auth gate
|
showAuthGate();
|
||||||
// Guest button is shown if guest mode is available (role granted without auth)
|
|
||||||
const allowGuest = authStatus.role === "rx";
|
|
||||||
showAuthGate(allowGuest);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5046,28 +5147,186 @@ let settingsUiReady = false;
|
|||||||
|
|
||||||
function initSettingsUI() {
|
function initSettingsUI() {
|
||||||
settingsUiReady = true;
|
settingsUiReady = true;
|
||||||
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRole);
|
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRoles);
|
||||||
window.trx.modules.scheduler?.wireEvents();
|
window.trx.modules.scheduler?.wireEvents();
|
||||||
if (window.trx.modules.backgroundDecode) {
|
if (window.trx.modules.backgroundDecode) {
|
||||||
window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRole);
|
window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRoles);
|
||||||
window.trx.modules.backgroundDecode.wireEvents();
|
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
|
// Setup auth form
|
||||||
requiredElement<HTMLFormElement>("auth-form").addEventListener("submit", async (e) => {
|
requiredElement<HTMLFormElement>("auth-form").addEventListener("submit", async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const passphraseEl = requiredElement<HTMLInputElement>("auth-passphrase");
|
const usernameEl = requiredElement<HTMLInputElement>("auth-username");
|
||||||
const passphrase = passphraseEl.value;
|
const passwordEl = requiredElement<HTMLInputElement>("auth-password");
|
||||||
const btn = requiredElement<HTMLFormElement>("auth-form").querySelector<HTMLButtonElement>("button[type=submit]");
|
const btn = requiredElement<HTMLFormElement>("auth-form").querySelector<HTMLButtonElement>("button[type=submit]");
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = "Logging in...";
|
btn.textContent = "Logging in...";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await authLogin(passphrase);
|
const result = await authLogin(usernameEl.value, passwordEl.value);
|
||||||
authRole = result.role ?? null;
|
setAuthRoles(result.roles);
|
||||||
passphraseEl.value = "";
|
authUsername = result.username ?? usernameEl.value;
|
||||||
|
passwordEl.value = "";
|
||||||
hideAuthGate();
|
hideAuthGate();
|
||||||
updateAuthUI();
|
updateAuthUI();
|
||||||
applyAuthRestrictions();
|
applyAuthRestrictions();
|
||||||
@@ -5077,7 +5336,7 @@ requiredElement<HTMLFormElement>("auth-form").addEventListener("submit", async (
|
|||||||
resizeHeaderSignalCanvas();
|
resizeHeaderSignalCanvas();
|
||||||
startHeaderSignalSampling();
|
startHeaderSignalSampling();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showAuthError("Invalid passphrase");
|
showAuthError("Invalid username or password");
|
||||||
console.error("Login error:", err);
|
console.error("Login error:", err);
|
||||||
} finally {
|
} finally {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
@@ -5085,35 +5344,18 @@ 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)
|
// Setup header auth button (Login/Logout)
|
||||||
const headerAuthBtn = document.getElementById("header-auth-btn") as HTMLButtonElement | null;
|
const headerAuthBtn = document.getElementById("header-auth-btn") as HTMLButtonElement | null;
|
||||||
if (headerAuthBtn) {
|
if (headerAuthBtn) {
|
||||||
headerAuthBtn.addEventListener("click", async () => {
|
headerAuthBtn.addEventListener("click", async () => {
|
||||||
if (authRole) {
|
if (authRoles.length > 0) {
|
||||||
// Logged in - show logout confirmation
|
// 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 })) {
|
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();
|
await authLogout();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Not logged in - show auth gate
|
// Not logged in - show auth gate
|
||||||
showAuthGate(false);
|
showAuthGate();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -5135,7 +5377,7 @@ Object.defineProperties(trxState, {
|
|||||||
initialMapZoom: { get() { return initialMapZoom; } },
|
initialMapZoom: { get() { return initialMapZoom; } },
|
||||||
decodeHistoryRetentionMin: { get() { return decodeHistoryRetentionMin; } },
|
decodeHistoryRetentionMin: { get() { return decodeHistoryRetentionMin; } },
|
||||||
authEnabled: { get() { return authEnabled; } },
|
authEnabled: { get() { return authEnabled; } },
|
||||||
authRole: { get() { return authRole; } },
|
authRoles: { get() { return authRoles; } },
|
||||||
decoderRegistry: { get() { return decoderRegistry; } },
|
decoderRegistry: { get() { return decoderRegistry; } },
|
||||||
sseSessionId: { get() { return sseSessionId; } },
|
sseSessionId: { get() { return sseSessionId; } },
|
||||||
primaryRds: { get() { return primaryRds; } },
|
primaryRds: { get() { return primaryRds; } },
|
||||||
@@ -5404,7 +5646,20 @@ const sdrNbEnabledEl = document.getElementById("sdr-nb-enabled") as HTMLInputEle
|
|||||||
const sdrNbThresholdControlsEl = document.getElementById("sdr-nb-threshold-controls");
|
const sdrNbThresholdControlsEl = document.getElementById("sdr-nb-threshold-controls");
|
||||||
const sdrNbThresholdEl = document.getElementById("sdr-nb-threshold") as HTMLInputElement | null;
|
const sdrNbThresholdEl = document.getElementById("sdr-nb-threshold") as HTMLInputElement | null;
|
||||||
const sdrNbThresholdSetBtn = document.getElementById("sdr-nb-threshold-set") as HTMLButtonElement | 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;
|
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
|
// Hide audio row if audio is not configured on the server
|
||||||
fetch("/audio", { method: "GET" }).then((r) => {
|
fetch("/audio", { method: "GET" }).then((r) => {
|
||||||
@@ -5831,7 +6086,7 @@ function submitSdrNbState() {
|
|||||||
const threshold = sdrNbThresholdEl ? Number.parseFloat(sdrNbThresholdEl.value) : 10;
|
const threshold = sdrNbThresholdEl ? Number.parseFloat(sdrNbThresholdEl.value) : 10;
|
||||||
if (!isFiniteNumber(threshold) || threshold < 1 || threshold > 100) return;
|
if (!isFiniteNumber(threshold) || threshold < 1 || threshold > 100) return;
|
||||||
postPath(
|
postPath(
|
||||||
`/set_sdr_noise_blanker?enabled=${enabled ? "true" : "false"}&threshold=${encodeURIComponent(threshold)}`,
|
`/set_sdr_noise_blanker?enabled=${enabled ? "true" : "false"}&threshold=${encodeURIComponent(threshold)}&profile=${encodeURIComponent(sdrNbProfile)}`,
|
||||||
).catch(() => {});
|
).catch(() => {});
|
||||||
}
|
}
|
||||||
if (sdrNbEnabledEl) {
|
if (sdrNbEnabledEl) {
|
||||||
@@ -5839,6 +6094,21 @@ if (sdrNbEnabledEl) {
|
|||||||
submitSdrNbState();
|
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() {
|
function submitSdrNbThreshold() {
|
||||||
if (!sdrNbThresholdEl) return;
|
if (!sdrNbThresholdEl) return;
|
||||||
const parsed = Number.parseFloat(sdrNbThresholdEl.value);
|
const parsed = Number.parseFloat(sdrNbThresholdEl.value);
|
||||||
@@ -5856,6 +6126,21 @@ if (sdrNbThresholdEl) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
function submitSdrDigSideband() {
|
||||||
|
if (!sdrDigSidebandSupported || !sdrDigSidebandEl) return;
|
||||||
|
const policy = sdrDigSidebandEl.value || "auto";
|
||||||
|
if (policy !== "auto" && policy !== "usb" && policy !== "lsb") return;
|
||||||
|
// Optimistically move the passband overlay to the chosen sideband so the
|
||||||
|
// display responds instantly, before the server round-trip confirms it.
|
||||||
|
sdrDigSidebandPolicy = policy;
|
||||||
|
if (lastSpectrumData) scheduleSpectrumDraw();
|
||||||
|
postPath(`/set_sdr_dig_sideband?policy=${encodeURIComponent(policy)}`).catch(() => {});
|
||||||
|
}
|
||||||
|
if (sdrDigSidebandEl) {
|
||||||
|
sdrDigSidebandEl.addEventListener("change", () => {
|
||||||
|
submitSdrDigSideband();
|
||||||
|
});
|
||||||
|
}
|
||||||
function updateWfmControls() {
|
function updateWfmControls() {
|
||||||
const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
|
const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
|
||||||
if (wfmControlsCol) wfmControlsCol.style.display = mode === "WFM" ? "" : "none";
|
if (wfmControlsCol) wfmControlsCol.style.display = mode === "WFM" ? "" : "none";
|
||||||
@@ -5863,6 +6148,11 @@ function updateWfmControls() {
|
|||||||
// The row holds only these two, so it goes with them — an empty one would
|
// 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.
|
// still take a track and a gap in the tray, and draw its divider.
|
||||||
if (modeControlsRow) modeControlsRow.style.display = (mode === "WFM" || mode === "SAM") ? "" : "none";
|
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
|
// Show compatibility warning for non-Chromium browsers
|
||||||
@@ -6180,6 +6470,10 @@ function stopRxAudio() {
|
|||||||
|
|
||||||
function startTxAudio() {
|
function startTxAudio() {
|
||||||
if (txActive) { void stopTxAudio(); return; }
|
if (txActive) { void stopTxAudio(); return; }
|
||||||
|
if (authEnabled && !hasAuthRole("transmit")) {
|
||||||
|
audioStatus.textContent = "Transmit role required";
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!hasWebCodecs) {
|
if (!hasWebCodecs) {
|
||||||
audioStatus.textContent = "Audio requires Chrome/Edge";
|
audioStatus.textContent = "Audio requires Chrome/Edge";
|
||||||
return;
|
return;
|
||||||
|
|||||||
+2
-1
@@ -3,7 +3,7 @@
|
|||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
export const TAB_ORDER = [
|
export const TAB_ORDER = [
|
||||||
"main", "bookmarks", "digital-modes", "map", "satellites", "statistics", "recorder", "settings", "about",
|
"main", "bookmarks", "logbook", "digital-modes", "map", "satellites", "statistics", "recorder", "settings", "about",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type TabName = typeof TAB_ORDER[number];
|
export type TabName = typeof TAB_ORDER[number];
|
||||||
@@ -11,6 +11,7 @@ export type TabName = typeof TAB_ORDER[number];
|
|||||||
export const TAB_PATHS: Readonly<Record<TabName, string>> = {
|
export const TAB_PATHS: Readonly<Record<TabName, string>> = {
|
||||||
main: "/",
|
main: "/",
|
||||||
bookmarks: "/bookmarks",
|
bookmarks: "/bookmarks",
|
||||||
|
logbook: "/logbook",
|
||||||
"digital-modes": "/digital-modes",
|
"digital-modes": "/digital-modes",
|
||||||
map: "/map",
|
map: "/map",
|
||||||
satellites: "/satellites",
|
satellites: "/satellites",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
type PluginGroup =
|
type PluginGroup =
|
||||||
| "digital-modes" | "map-data" | "map" | "satellites" | "statistics"
|
| "digital-modes" | "map-data" | "map" | "satellites" | "statistics"
|
||||||
| "bookmarks" | "recorder" | "settings";
|
| "bookmarks" | "logbook" | "recorder" | "settings";
|
||||||
|
|
||||||
const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
|
const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
|
||||||
// AIS, VDES and the two APRS decoders have panels on this tab, so they load
|
// AIS, VDES and the two APRS decoders have panels on this tab, so they load
|
||||||
@@ -20,6 +20,7 @@ const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
|
|||||||
satellites: ["/satellite-predictions.js"],
|
satellites: ["/satellite-predictions.js"],
|
||||||
statistics: ["/map-core.js"],
|
statistics: ["/map-core.js"],
|
||||||
bookmarks: ["/bookmarks.js"],
|
bookmarks: ["/bookmarks.js"],
|
||||||
|
logbook: ["/logbook.js"],
|
||||||
recorder: [],
|
recorder: [],
|
||||||
settings: ["/vchan.js", "/scheduler.js"],
|
settings: ["/vchan.js", "/scheduler.js"],
|
||||||
};
|
};
|
||||||
@@ -47,7 +48,7 @@ async function loadPlugins(group: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function loadEagerPlugins(): Promise<void> {
|
export async function loadEagerPlugins(): Promise<void> {
|
||||||
await Promise.all(["digital-modes", "bookmarks", "settings"].map(loadPlugins));
|
await Promise.all(["digital-modes", "bookmarks", "logbook", "settings"].map(loadPlugins));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadPluginsForTab(tab: string): Promise<void> {
|
export async function loadPluginsForTab(tab: string): Promise<void> {
|
||||||
|
|||||||
@@ -324,6 +324,21 @@ export interface AprsRowOptions {
|
|||||||
onMap?: (lat: number, lon: number) => void;
|
onMap?: (lat: number, lon: number) => void;
|
||||||
/** Puts a frame's coordinates on the clipboard. */
|
/** Puts a frame's coordinates on the clipboard. */
|
||||||
onCopy?: (text: string, button: HTMLElement) => void;
|
onCopy?: (text: string, button: HTMLElement) => void;
|
||||||
|
/** Opens a log entry for the station that sent the frame. */
|
||||||
|
onLog?: (call: string, gridsquare: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A position as a six-character locator, which is what a log wants. */
|
||||||
|
function maidenheadForLatLon(lat: number, lon: number): string {
|
||||||
|
const adjustedLon = lon + 180;
|
||||||
|
const adjustedLat = lat + 90;
|
||||||
|
const field = String.fromCharCode(65 + Math.floor(adjustedLon / 20))
|
||||||
|
+ String.fromCharCode(65 + Math.floor(adjustedLat / 10));
|
||||||
|
const square = String(Math.floor((adjustedLon % 20) / 2)) + String(Math.floor(adjustedLat % 10));
|
||||||
|
const subLon = ((adjustedLon % 2) * 60) / 5;
|
||||||
|
const subLat = ((adjustedLat % 1) * 60) / 2.5;
|
||||||
|
const sub = String.fromCharCode(97 + Math.floor(subLon)) + String.fromCharCode(97 + Math.floor(subLat));
|
||||||
|
return `${field}${square}${sub}`.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderAprsPacketRow(packet: AprsPacket, options: AprsRowOptions = {}): HTMLElement {
|
export function renderAprsPacketRow(packet: AprsPacket, options: AprsRowOptions = {}): HTMLElement {
|
||||||
@@ -370,6 +385,7 @@ export function renderAprsPacketRow(packet: AprsPacket, options: AprsRowOptions
|
|||||||
(hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${packet.lat},${packet.lon}">Map</button>` : "") +
|
(hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${packet.lat},${packet.lon}">Map</button>` : "") +
|
||||||
(hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${packet.lat},${packet.lon}">Copy Coords</button>` : "") +
|
(hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${packet.lat},${packet.lon}">Copy Coords</button>` : "") +
|
||||||
`<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a>` +
|
`<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a>` +
|
||||||
|
(options.onLog && packet.srcCall ? '<button class="aprs-inline-btn" type="button" data-aprs-log="1">Log</button>' : "") +
|
||||||
`</div>` +
|
`</div>` +
|
||||||
`</div>`;
|
`</div>`;
|
||||||
|
|
||||||
@@ -381,6 +397,17 @@ export function renderAprsPacketRow(packet: AprsPacket, options: AprsRowOptions
|
|||||||
if (Number.isFinite(lat) && Number.isFinite(lon)) options.onMap?.(lat as number, lon as number);
|
if (Number.isFinite(lat) && Number.isFinite(lon)) options.onMap?.(lat as number, lon as number);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
const logButton = row.querySelector<HTMLElement>("[data-aprs-log]");
|
||||||
|
if (logButton) {
|
||||||
|
logButton.addEventListener("click", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const grid = packet.lat != null && packet.lon != null
|
||||||
|
? maidenheadForLatLon(packet.lat, packet.lon)
|
||||||
|
: null;
|
||||||
|
options.onLog?.(packet.srcCall ?? "", grid);
|
||||||
|
});
|
||||||
|
}
|
||||||
const copyButton = row.querySelector<HTMLElement>("[data-aprs-copy]");
|
const copyButton = row.querySelector<HTMLElement>("[data-aprs-copy]");
|
||||||
if (copyButton) {
|
if (copyButton) {
|
||||||
copyButton.addEventListener("click", (event) => {
|
copyButton.addEventListener("click", (event) => {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ interface AprsBridge {
|
|||||||
aprsMapAddStation?: (call: string, lat: number, lon: number, info: string, symbolTable: string | null | undefined, symbolCode: string | null | undefined, packet: AprsPacket) => void;
|
aprsMapAddStation?: (call: string, lat: number, lon: number, info: string, symbolTable: string | null | undefined, symbolCode: string | null | undefined, packet: AprsPacket) => void;
|
||||||
getDecodeRigMeta?: () => unknown;
|
getDecodeRigMeta?: () => unknown;
|
||||||
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
|
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
|
||||||
|
logContact?: (seed: { call?: string; gridsquare?: string | undefined; decoder?: string }) => void;
|
||||||
updateAprsBar?: () => void;
|
updateAprsBar?: () => void;
|
||||||
clearAprsBar?: () => void;
|
clearAprsBar?: () => void;
|
||||||
closeAprsBar?: () => void;
|
closeAprsBar?: () => void;
|
||||||
@@ -164,6 +165,9 @@ function renderAprsRow(pkt: AprsPacket, isFresh: boolean): HTMLElement {
|
|||||||
distance: aprsDistanceText(pkt),
|
distance: aprsDistanceText(pkt),
|
||||||
onMap: (lat, lon) => { aprsWindow.navigateToAprsMap?.(lat, lon); },
|
onMap: (lat, lon) => { aprsWindow.navigateToAprsMap?.(lat, lon); },
|
||||||
onCopy: (text) => { void copyAprsCoords(text); },
|
onCopy: (text) => { void copyAprsCoords(text); },
|
||||||
|
onLog: (call, gridsquare) => {
|
||||||
|
aprsWindow.logContact?.({ call, gridsquare: gridsquare ?? undefined, decoder: "aprs" });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-5
@@ -3,6 +3,7 @@
|
|||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
import { hostState } from "./host.js";
|
import { hostState } from "./host.js";
|
||||||
|
import { hasAuthRole, type AuthRole } from "../api/auth.js";
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|
||||||
@@ -44,7 +45,7 @@ interface BackgroundBridge {
|
|||||||
trx?: { modules?: { backgroundDecode?: BackgroundDecodeService } };
|
trx?: { modules?: { backgroundDecode?: BackgroundDecodeService } };
|
||||||
}
|
}
|
||||||
interface BackgroundDecodeService {
|
interface BackgroundDecodeService {
|
||||||
initialize(rigId: string | null, role: string | null): void;
|
initialize(rigId: string | null, roles: readonly AuthRole[]): void;
|
||||||
wireEvents(): void;
|
wireEvents(): void;
|
||||||
setRig(rigId: string | null): void;
|
setRig(rigId: string | null): void;
|
||||||
}
|
}
|
||||||
@@ -60,7 +61,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
.map(function (d) { return d.id; });
|
.map(function (d) { return d.id; });
|
||||||
}
|
}
|
||||||
|
|
||||||
let backgroundDecodeRole: string | null = null;
|
let backgroundDecodeRoles: readonly AuthRole[] = [];
|
||||||
let currentRigId: string | null = null;
|
let currentRigId: string | null = null;
|
||||||
let currentConfig: BackgroundDecodeConfig | null = null;
|
let currentConfig: BackgroundDecodeConfig | null = null;
|
||||||
let bookmarkList: Bookmark[] = [];
|
let bookmarkList: Bookmark[] = [];
|
||||||
@@ -70,8 +71,8 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
let statusByBookmark = new Map<string, BackgroundStatusEntry>();
|
let statusByBookmark = new Map<string, BackgroundStatusEntry>();
|
||||||
let lastStatus: BackgroundDecodeStatus | null = null;
|
let lastStatus: BackgroundDecodeStatus | null = null;
|
||||||
|
|
||||||
function initBackgroundDecode(rigId: string | null, role: string | null): void {
|
function initBackgroundDecode(rigId: string | null, roles: readonly AuthRole[]): void {
|
||||||
backgroundDecodeRole = role;
|
backgroundDecodeRoles = roles;
|
||||||
// The panel used to take whatever rig it was handed at load and wait to be
|
// 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
|
// 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
|
// next telling only came when the operator switched rigs, so the panel sat
|
||||||
@@ -468,7 +469,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isControlRole(): boolean {
|
function isControlRole(): boolean {
|
||||||
return backgroundDecodeRole === "control" || hostState.authEnabled === false;
|
return hasAuthRole(backgroundDecodeRoles, "control") || hostState.authEnabled === false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function showToast(msg: string, isError: boolean): void {
|
function showToast(msg: string, isError: boolean): void {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
import { hostCore, hostState } from "./host.js";
|
import { hostCore, hostState } from "./host.js";
|
||||||
|
import { hasAuthRole } from "../api/auth.js";
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|
||||||
@@ -18,6 +19,8 @@ interface Bookmark {
|
|||||||
category?: string | null;
|
category?: string | null;
|
||||||
comment?: string | null;
|
comment?: string | null;
|
||||||
decoders?: string[];
|
decoders?: string[];
|
||||||
|
/** DIG sideband override: "auto" | "usb" | "lsb". Empty/absent = global. */
|
||||||
|
dig_sideband?: string | null;
|
||||||
scope?: string;
|
scope?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +103,8 @@ function bmEsc(str: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function bmCanControl() {
|
function bmCanControl() {
|
||||||
return !hostState.authEnabled || hostState.authRole === "control";
|
return !hostState.authEnabled
|
||||||
|
|| hasAuthRole(hostState.authRoles, "write");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show/hide the Add Bookmark / Select All buttons based on the current auth role.
|
// Show/hide the Add Bookmark / Select All buttons based on the current auth role.
|
||||||
@@ -319,6 +323,14 @@ function bmBuildDecoderCheckboxes() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Show the DIG-sideband override selector only when the mode field is DIG. */
|
||||||
|
function bmSyncDigSidebandVisibility(): void {
|
||||||
|
const label = bmOptionalEl("bm-dig-sideband-label");
|
||||||
|
if (!label) return;
|
||||||
|
const mode = (bmEl("bm-mode").value || "").trim().toUpperCase();
|
||||||
|
label.style.display = mode === "DIG" ? "" : "none";
|
||||||
|
}
|
||||||
|
|
||||||
function bmOpenForm(bm: Bookmark | null): void {
|
function bmOpenForm(bm: Bookmark | null): void {
|
||||||
const wrap = bmEl("bm-form-wrap");
|
const wrap = bmEl("bm-form-wrap");
|
||||||
if (!wrap) return;
|
if (!wrap) return;
|
||||||
@@ -336,7 +348,9 @@ function bmOpenForm(bm: Bookmark | null): void {
|
|||||||
bmEl("bm-locator").value = bm ? (bm.locator || "") : "";
|
bmEl("bm-locator").value = bm ? (bm.locator || "") : "";
|
||||||
bmEl("bm-category-input").value = bm ? (bm.category || "") : "";
|
bmEl("bm-category-input").value = bm ? (bm.category || "") : "";
|
||||||
bmEl("bm-comment").value = bm ? (bm.comment || "") : "";
|
bmEl("bm-comment").value = bm ? (bm.comment || "") : "";
|
||||||
|
bmEl("bm-dig-sideband").value = bm ? (bm.dig_sideband || "") : "";
|
||||||
bmWriteDecoders(bm?.decoders ?? []);
|
bmWriteDecoders(bm?.decoders ?? []);
|
||||||
|
bmSyncDigSidebandVisibility();
|
||||||
bmEl("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark";
|
bmEl("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark";
|
||||||
|
|
||||||
wrap.style.display = "flex";
|
wrap.style.display = "flex";
|
||||||
@@ -381,6 +395,9 @@ async function bmSave(e: Event): Promise<void> {
|
|||||||
const category = bmEl("bm-category-input").value.trim();
|
const category = bmEl("bm-category-input").value.trim();
|
||||||
const comment = bmEl("bm-comment").value.trim();
|
const comment = bmEl("bm-comment").value.trim();
|
||||||
const decoders = bmReadDecoders();
|
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");
|
const formError = bmEl("bm-form-error");
|
||||||
if (formError) formError.textContent = "";
|
if (formError) formError.textContent = "";
|
||||||
@@ -401,6 +418,7 @@ async function bmSave(e: Event): Promise<void> {
|
|||||||
category,
|
category,
|
||||||
comment,
|
comment,
|
||||||
decoders,
|
decoders,
|
||||||
|
dig_sideband,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -475,6 +493,16 @@ function bmApply(bm: Bookmark): void {
|
|||||||
const tunePromise = (async () => {
|
const tunePromise = (async () => {
|
||||||
await bridge.trx.modules.vchan?.takeSchedulerControl();
|
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;
|
const onVirtual = await bridge.trx.modules.vchan?.interceptMode(bm.mode) ?? false;
|
||||||
if (!onVirtual) {
|
if (!onVirtual) {
|
||||||
await hostCore.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
|
await hostCore.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
|
||||||
@@ -768,6 +796,9 @@ function bmPopulateScopePicker() {
|
|||||||
// Form submit
|
// Form submit
|
||||||
bmEl("bm-form").addEventListener("submit", (event) => { void bmSave(event); });
|
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
|
// Form cancel
|
||||||
bmEl("bm-form-cancel").addEventListener("click", bmCloseForm);
|
bmEl("bm-form-cancel").addEventListener("click", bmCloseForm);
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ interface FtxBridge {
|
|||||||
updateFt8Bar?: () => void;
|
updateFt8Bar?: () => void;
|
||||||
registerFt8FamilyBarRenderer?: (decoder: FtxDecoderId, renderer: () => BarFrames) => void;
|
registerFt8FamilyBarRenderer?: (decoder: FtxDecoderId, renderer: () => BarFrames) => void;
|
||||||
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<void>;
|
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<void>;
|
||||||
|
/** Opens a log entry from a decode. Pre-fills it; never logs by itself. */
|
||||||
|
logContact?: (seed: { call?: string; gridsquare?: string | undefined; decoder?: string }) => void;
|
||||||
trxUi: ConfirmApi;
|
trxUi: ConfirmApi;
|
||||||
clearFt8Bar?: () => void;
|
clearFt8Bar?: () => void;
|
||||||
closeFt8Bar?: () => void;
|
closeFt8Bar?: () => void;
|
||||||
@@ -224,6 +226,26 @@ export function initializeFtxDecoder(config: FtxConfig): void {
|
|||||||
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
||||||
});
|
});
|
||||||
row.innerHTML = `<span class="ft8-time">${time}</span><span class="ft8-snr">${snr?.toFixed(1) ?? "--"}</span><span class="ft8-dt">${delta?.toFixed(2) ?? "--"}</span><span class="ft8-freq">${frequency?.toFixed(0) ?? "--"}</span><span class="ft8-msg">${renderMessage(raw)}</span>`;
|
row.innerHTML = `<span class="ft8-time">${time}</span><span class="ft8-snr">${snr?.toFixed(1) ?? "--"}</span><span class="ft8-dt">${delta?.toFixed(2) ?? "--"}</span><span class="ft8-freq">${frequency?.toFixed(0) ?? "--"}</span><span class="ft8-msg">${renderMessage(raw)}</span>`;
|
||||||
|
// A decode is not a contact: this opens an entry with what was heard in it
|
||||||
|
// and leaves the logging to the operator.
|
||||||
|
const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
|
||||||
|
if (station) {
|
||||||
|
const log = document.createElement("button");
|
||||||
|
log.type = "button";
|
||||||
|
log.className = "ft8-log-btn";
|
||||||
|
log.textContent = "Log";
|
||||||
|
log.title = `Start a log entry for ${station}`;
|
||||||
|
log.addEventListener("click", (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
const details = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
|
||||||
|
bridge.logContact?.({
|
||||||
|
call: station,
|
||||||
|
gridsquare: details.find((detail) => detail.station === station)?.grid,
|
||||||
|
decoder: id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
row.appendChild(log);
|
||||||
|
}
|
||||||
return row;
|
return row;
|
||||||
};
|
};
|
||||||
const render = (): void => {
|
const render = (): void => {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
// feature bundles from re-deriving it — and from drifting back to bare `window`
|
// feature bundles from re-deriving it — and from drifting back to bare `window`
|
||||||
// properties, which the module graph no longer publishes.
|
// properties, which the module graph no longer publishes.
|
||||||
|
|
||||||
|
import type { AuthRole } from "../api/auth.js";
|
||||||
|
|
||||||
export interface HostDecoderDescriptor {
|
export interface HostDecoderDescriptor {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -22,8 +24,10 @@ export interface HostDecoderDescriptor {
|
|||||||
export interface HostState {
|
export interface HostState {
|
||||||
readonly serverLat: number | null;
|
readonly serverLat: number | null;
|
||||||
readonly serverLon: number | null;
|
readonly serverLon: number | null;
|
||||||
|
/** The callsign this station is on the air with, from the client config. */
|
||||||
|
readonly ownerCallsign: string | null;
|
||||||
readonly authEnabled: boolean;
|
readonly authEnabled: boolean;
|
||||||
readonly authRole: string | null;
|
readonly authRoles: readonly AuthRole[];
|
||||||
readonly lastActiveRigId: string | null;
|
readonly lastActiveRigId: string | null;
|
||||||
readonly lastRigIds: string[];
|
readonly lastRigIds: string[];
|
||||||
readonly lastRigDisplayNames: Record<string, string>;
|
readonly lastRigDisplayNames: Record<string, string>;
|
||||||
@@ -43,6 +47,7 @@ export interface HostCore {
|
|||||||
armOptimisticFrequency(frequencyHz: number): void;
|
armOptimisticFrequency(frequencyHz: number): void;
|
||||||
escapeMapHtml(value: string): string;
|
escapeMapHtml(value: string): string;
|
||||||
haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number;
|
haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number;
|
||||||
|
latLonToMaidenhead(lat: number, lon: number): string;
|
||||||
showHint(message: string, durationMs?: number): void;
|
showHint(message: string, durationMs?: number): void;
|
||||||
formatFreqForStep(frequencyHz: number, stepHz: number): string;
|
formatFreqForStep(frequencyHz: number, stepHz: number): string;
|
||||||
refreshFreqDisplay(): void;
|
refreshFreqDisplay(): void;
|
||||||
|
|||||||
@@ -0,0 +1,577 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
// The station log: one panel that opens an entry, keeps the log, and takes
|
||||||
|
// ADIF in and out.
|
||||||
|
//
|
||||||
|
// Times come from the server, never from here. The browser may be a phone in
|
||||||
|
// another timezone with a clock nobody has checked, and a log is only worth
|
||||||
|
// keeping if the times in it are the radio's.
|
||||||
|
|
||||||
|
import { hostCore, hostState } from "./host.js";
|
||||||
|
import { hasAuthRole } from "../api/auth.js";
|
||||||
|
|
||||||
|
export {};
|
||||||
|
|
||||||
|
interface Qso {
|
||||||
|
id: string;
|
||||||
|
started_at: string;
|
||||||
|
call: string;
|
||||||
|
freq_hz: number;
|
||||||
|
band?: string | null;
|
||||||
|
mode: string;
|
||||||
|
submode?: string | null;
|
||||||
|
rst_sent?: string | null;
|
||||||
|
rst_rcvd?: string | null;
|
||||||
|
gridsquare?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
qth?: string | null;
|
||||||
|
comment?: string | null;
|
||||||
|
station_callsign?: string | null;
|
||||||
|
operator?: string | null;
|
||||||
|
my_gridsquare?: string | null;
|
||||||
|
my_rig?: string | null;
|
||||||
|
rig_id?: string | null;
|
||||||
|
contest_id?: string | null;
|
||||||
|
stx?: number | null;
|
||||||
|
srx?: number | null;
|
||||||
|
srx_string?: string | null;
|
||||||
|
qsl_rcvd?: string | null;
|
||||||
|
lotw_qsl_rcvd?: string | null;
|
||||||
|
confirmed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BandStatistics {
|
||||||
|
band: string;
|
||||||
|
contacts: number;
|
||||||
|
stations: number;
|
||||||
|
confirmed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Prefill {
|
||||||
|
started_at: string;
|
||||||
|
epoch_ms: number;
|
||||||
|
freq_hz: number;
|
||||||
|
band: string | null;
|
||||||
|
mode: string;
|
||||||
|
submode: string | null;
|
||||||
|
rig_id: string | null;
|
||||||
|
my_rig: string | null;
|
||||||
|
call: string | null;
|
||||||
|
gridsquare: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LogbookBridge {
|
||||||
|
trxUi: {
|
||||||
|
confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean>;
|
||||||
|
notify?(message: string, options?: { kind?: string }): void;
|
||||||
|
};
|
||||||
|
navigateToTab?: (name: string) => void;
|
||||||
|
/** Opens the panel with an entry started from a decode. */
|
||||||
|
logContact?: (seed: { call?: string; gridsquare?: string | undefined; decoder?: string }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bridge = window as unknown as LogbookBridge;
|
||||||
|
|
||||||
|
const el = <T extends HTMLElement>(id: string): T | null => document.getElementById(id) as T | null;
|
||||||
|
|
||||||
|
const form = el<HTMLFormElement>("log-entry-form");
|
||||||
|
const callInput = el<HTMLInputElement>("log-call");
|
||||||
|
const freqInput = el<HTMLInputElement>("log-freq");
|
||||||
|
const modeInput = el<HTMLInputElement>("log-mode");
|
||||||
|
const rstSentInput = el<HTMLInputElement>("log-rst-sent");
|
||||||
|
const rstRcvdInput = el<HTMLInputElement>("log-rst-rcvd");
|
||||||
|
const gridInput = el<HTMLInputElement>("log-grid");
|
||||||
|
const nameInput = el<HTMLInputElement>("log-name");
|
||||||
|
const commentInput = el<HTMLInputElement>("log-comment");
|
||||||
|
const operatorInput = el<HTMLInputElement>("log-operator");
|
||||||
|
const rowsBody = el<HTMLTableSectionElement>("log-rows");
|
||||||
|
const summaryEl = el("log-summary");
|
||||||
|
const workedEl = el("log-worked-before");
|
||||||
|
const clockEl = el("log-clock");
|
||||||
|
const stationCallEl = el("log-station-callsign");
|
||||||
|
const stationRigEl = el("log-station-rig-name");
|
||||||
|
const stationGridEl = el("log-station-grid");
|
||||||
|
const filterCall = el<HTMLInputElement>("log-filter-call");
|
||||||
|
const filterBand = el<HTMLSelectElement>("log-filter-band");
|
||||||
|
const filterMode = el<HTMLSelectElement>("log-filter-mode");
|
||||||
|
const importBtn = el<HTMLButtonElement>("log-import-btn");
|
||||||
|
const importFile = el<HTMLInputElement>("log-import-file");
|
||||||
|
const exportLink = el<HTMLAnchorElement>("log-export-btn");
|
||||||
|
const clearBtn = el<HTMLButtonElement>("log-clear-btn");
|
||||||
|
const saveBtn = el<HTMLButtonElement>("log-save-btn");
|
||||||
|
const contestIdInput = el<HTMLInputElement>("log-contest-id");
|
||||||
|
const stxInput = el<HTMLInputElement>("log-stx");
|
||||||
|
const srxInput = el<HTMLInputElement>("log-srx");
|
||||||
|
const statisticsRows = el<HTMLTableSectionElement>("log-statistics-rows");
|
||||||
|
const cabrilloContest = el<HTMLInputElement>("log-cbr-contest");
|
||||||
|
const cabrilloCallsign = el<HTMLInputElement>("log-cbr-callsign");
|
||||||
|
const cabrilloOperator = el<HTMLSelectElement>("log-cbr-operator");
|
||||||
|
const cabrilloPower = el<HTMLSelectElement>("log-cbr-power");
|
||||||
|
const cabrilloScore = el<HTMLInputElement>("log-cbr-score");
|
||||||
|
const cabrilloExport = el<HTMLAnchorElement>("log-cbr-export");
|
||||||
|
|
||||||
|
/** The time the open entry was started, as the server gave it. */
|
||||||
|
let entryStartedAt: string | null = null;
|
||||||
|
/** The rig the open entry belongs to, likewise. */
|
||||||
|
let entryRigId: string | null = null;
|
||||||
|
let entryRigName: string | null = null;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getJson<T>(path: string): Promise<T> {
|
||||||
|
const response = await fetch(path);
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
|
||||||
|
return await response.json() as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFreq(hz: number): string {
|
||||||
|
if (!Number.isFinite(hz) || hz <= 0) return "";
|
||||||
|
// Through Number, not a trailing-zero trim: trimming "20.000000" as a string
|
||||||
|
// walks back through the point and leaves "2".
|
||||||
|
return String(Number((hz / 1e6).toFixed(6)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the operator typed, in Hz: MHz unless it is plainly already Hz. */
|
||||||
|
function parseFreq(text: string): number | null {
|
||||||
|
const value = Number(text.trim().replace(/\s+/g, "").replace(",", "."));
|
||||||
|
if (!Number.isFinite(value) || value <= 0) return null;
|
||||||
|
return value < 100_000 ? Math.round(value * 1e6) : Math.round(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A serial, or null when the exchange is a word rather than a number. */
|
||||||
|
function numberOrNull(text: string | undefined): number | null {
|
||||||
|
const trimmed = (text ?? "").trim();
|
||||||
|
if (!trimmed || !/^\d+$/.test(trimmed)) return null;
|
||||||
|
const value = Number(trimmed);
|
||||||
|
return Number.isSafeInteger(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function utcDate(iso: string): string {
|
||||||
|
const date = new Date(iso);
|
||||||
|
return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function utcTime(iso: string): string {
|
||||||
|
const date = new Date(iso);
|
||||||
|
return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(11, 19);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The entry ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Open an entry, pre-filled with the six fields the radio and clock answer. */
|
||||||
|
async function openEntry(
|
||||||
|
seed: { call?: string; gridsquare?: string | undefined; decoder?: string } = {},
|
||||||
|
): Promise<void> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (hostState.lastActiveRigId) params.set("remote", hostState.lastActiveRigId);
|
||||||
|
if (seed.decoder) params.set("decoder", seed.decoder);
|
||||||
|
if (seed.call) params.set("call", seed.call);
|
||||||
|
if (seed.gridsquare) params.set("gridsquare", seed.gridsquare);
|
||||||
|
try {
|
||||||
|
const prefill = await getJson<Prefill>(`/api/logbook/prefill?${params.toString()}`);
|
||||||
|
applyPrefill(prefill);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error("logbook prefill failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPrefill(prefill: Prefill): void {
|
||||||
|
entryStartedAt = prefill.started_at;
|
||||||
|
entryRigId = prefill.rig_id;
|
||||||
|
entryRigName = prefill.my_rig;
|
||||||
|
if (freqInput) freqInput.value = formatFreq(prefill.freq_hz);
|
||||||
|
if (modeInput) modeInput.value = prefill.submode ?? prefill.mode;
|
||||||
|
if (callInput && prefill.call) callInput.value = prefill.call;
|
||||||
|
if (gridInput && prefill.gridsquare) gridInput.value = prefill.gridsquare;
|
||||||
|
if (stationRigEl) stationRigEl.textContent = prefill.my_rig ?? "no rig";
|
||||||
|
showClock(prefill);
|
||||||
|
updateWorkedBefore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The entry's time, and whether this browser agrees with it. */
|
||||||
|
function showClock(prefill: Prefill): void {
|
||||||
|
if (!clockEl) return;
|
||||||
|
const time = utcTime(prefill.started_at);
|
||||||
|
const drift = Math.abs(Date.now() - prefill.epoch_ms);
|
||||||
|
clockEl.textContent = drift > 1000
|
||||||
|
? `${time}Z · your clock is ${(drift / 1000).toFixed(0)}s out`
|
||||||
|
: `${time}Z`;
|
||||||
|
clockEl.classList.toggle("is-adrift", drift > 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear the entry and open a fresh one. */
|
||||||
|
function resetEntry(): void {
|
||||||
|
// The contest and the serial sent stay: they belong to the session, not to
|
||||||
|
// the contact just logged.
|
||||||
|
for (const input of [callInput, rstSentInput, rstRcvdInput, gridInput, nameInput, commentInput, srxInput]) {
|
||||||
|
if (input) input.value = "";
|
||||||
|
}
|
||||||
|
if (workedEl) workedEl.textContent = "";
|
||||||
|
void openEntry();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitEntry(event: Event): Promise<void> {
|
||||||
|
event.preventDefault();
|
||||||
|
const call = callInput?.value.trim() ?? "";
|
||||||
|
if (!call) {
|
||||||
|
notify("A contact needs a callsign", "error");
|
||||||
|
callInput?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const freqHz = parseFreq(freqInput?.value ?? "");
|
||||||
|
if (freqHz == null) {
|
||||||
|
notify("A contact needs a frequency", "error");
|
||||||
|
freqInput?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const typedMode = (modeInput?.value ?? "").trim().toUpperCase();
|
||||||
|
// USB and LSB are how a rig says SSB; the log wants the mode with the
|
||||||
|
// sideband beside it, which is what other loggers read.
|
||||||
|
const isSideband = typedMode === "USB" || typedMode === "LSB";
|
||||||
|
const body = {
|
||||||
|
// The server stamps the time; this is the one it gave when the entry
|
||||||
|
// opened, so a contact logged five minutes later keeps the time it started.
|
||||||
|
started_at: entryStartedAt,
|
||||||
|
call,
|
||||||
|
freq_hz: freqHz,
|
||||||
|
mode: isSideband ? "SSB" : typedMode,
|
||||||
|
submode: isSideband ? typedMode : null,
|
||||||
|
rst_sent: rstSentInput?.value ?? null,
|
||||||
|
rst_rcvd: rstRcvdInput?.value ?? null,
|
||||||
|
gridsquare: gridInput?.value ?? null,
|
||||||
|
name: nameInput?.value ?? null,
|
||||||
|
comment: commentInput?.value ?? null,
|
||||||
|
contest_id: contestIdInput?.value ?? null,
|
||||||
|
// The exchange is a number when it is a serial and a word when it is a
|
||||||
|
// zone or a section; both are kept, and the log writes whichever it has.
|
||||||
|
stx: numberOrNull(stxInput?.value),
|
||||||
|
stx_string: numberOrNull(stxInput?.value) == null ? stxInput?.value ?? null : null,
|
||||||
|
srx: numberOrNull(srxInput?.value),
|
||||||
|
srx_string: numberOrNull(srxInput?.value) == null ? srxInput?.value ?? null : null,
|
||||||
|
station_callsign: stationCallEl?.textContent?.trim() ?? null,
|
||||||
|
operator: operatorInput?.value ?? null,
|
||||||
|
my_gridsquare: entryGrid,
|
||||||
|
my_rig: entryRigName,
|
||||||
|
rig_id: entryRigId,
|
||||||
|
};
|
||||||
|
if (saveBtn) saveBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/logbook", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = await response.json().catch(() => ({})) as { error?: string };
|
||||||
|
throw new Error(detail.error ?? `HTTP ${String(response.status)}`);
|
||||||
|
}
|
||||||
|
notify(`${call} logged`);
|
||||||
|
// A contest runs on serials: the next one is this one plus one, so it is
|
||||||
|
// not retyped forty times an hour.
|
||||||
|
const sent = numberOrNull(stxInput?.value);
|
||||||
|
resetEntry();
|
||||||
|
if (stxInput && sent != null) stxInput.value = String(sent + 1);
|
||||||
|
await refreshLog();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
notify(`Could not log: ${error instanceof Error ? error.message : String(error)}`, "error");
|
||||||
|
} finally {
|
||||||
|
if (saveBtn) saveBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether this station has been worked, and on what. */
|
||||||
|
function updateWorkedBefore(): void {
|
||||||
|
if (!workedEl) return;
|
||||||
|
const call = callInput?.value.trim() ?? "";
|
||||||
|
if (call.length < 3) {
|
||||||
|
workedEl.textContent = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const request = ++workedRequest;
|
||||||
|
void getJson<{ worked: { band: string; mode: string }[] }>(
|
||||||
|
`/api/logbook/worked/${encodeURIComponent(call)}`,
|
||||||
|
).then((answer) => {
|
||||||
|
// A slower answer to an earlier callsign must not overwrite a later one.
|
||||||
|
if (request !== workedRequest || !workedEl) return;
|
||||||
|
if (answer.worked.length === 0) {
|
||||||
|
workedEl.textContent = "Not worked before";
|
||||||
|
workedEl.classList.remove("is-worked");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const where = answer.worked.map((entry) => `${entry.band} ${entry.mode}`).join(", ");
|
||||||
|
workedEl.textContent = `Worked before: ${where}`;
|
||||||
|
workedEl.classList.add("is-worked");
|
||||||
|
}).catch(() => { /* the log will still take the contact */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The log ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function currentQuery(): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
const call = filterCall?.value.trim();
|
||||||
|
if (call) params.set("call", call);
|
||||||
|
if (filterBand?.value) params.set("band", filterBand.value);
|
||||||
|
if (filterMode?.value) params.set("mode", filterMode.value);
|
||||||
|
return params.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshLog(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const query = currentQuery();
|
||||||
|
const answer = await getJson<{ total: number; qsos: Qso[] }>(
|
||||||
|
`/api/logbook${query ? `?${query}` : ""}`,
|
||||||
|
);
|
||||||
|
qsos = answer.qsos;
|
||||||
|
renderRows();
|
||||||
|
renderFilterOptions();
|
||||||
|
if (summaryEl) {
|
||||||
|
summaryEl.textContent = query
|
||||||
|
? `${String(qsos.length)} of ${String(answer.total)} contacts`
|
||||||
|
: `${String(answer.total)} contact${answer.total === 1 ? "" : "s"}`;
|
||||||
|
}
|
||||||
|
if (exportLink) exportLink.href = `/api/logbook/export.adi${query ? `?${query}` : ""}`;
|
||||||
|
await refreshStatistics();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error("logbook read failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What has been worked and confirmed, band by band. */
|
||||||
|
async function refreshStatistics(): Promise<void> {
|
||||||
|
if (!statisticsRows) return;
|
||||||
|
try {
|
||||||
|
const answer = await getJson<{ bands: BandStatistics[] }>("/api/logbook/statistics");
|
||||||
|
if (answer.bands.length === 0) {
|
||||||
|
statisticsRows.innerHTML = '<tr><td colspan="4" class="log-empty">Nothing worked yet.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (const band of answer.bands) {
|
||||||
|
const row = document.createElement("tr");
|
||||||
|
for (const value of [band.band, band.contacts, band.stations, band.confirmed]) {
|
||||||
|
const cell = document.createElement("td");
|
||||||
|
cell.textContent = String(value);
|
||||||
|
row.appendChild(cell);
|
||||||
|
}
|
||||||
|
fragment.appendChild(row);
|
||||||
|
}
|
||||||
|
statisticsRows.replaceChildren(fragment);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error("logbook statistics failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Cabrillo link, carrying the header the operator filled in. */
|
||||||
|
function syncCabrilloLink(): void {
|
||||||
|
if (!cabrilloExport) return;
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
const contest = cabrilloContest?.value.trim();
|
||||||
|
if (contest) {
|
||||||
|
// Only that contest's contacts belong in the entry.
|
||||||
|
params.set("contest", contest);
|
||||||
|
}
|
||||||
|
const callsign = cabrilloCallsign?.value.trim() || (stationCallEl?.textContent?.trim() ?? "");
|
||||||
|
if (callsign) params.set("callsign", callsign);
|
||||||
|
if (cabrilloOperator?.value) params.set("category_operator", cabrilloOperator.value);
|
||||||
|
if (cabrilloPower?.value) params.set("category_power", cabrilloPower.value);
|
||||||
|
const score = numberOrNull(cabrilloScore?.value);
|
||||||
|
if (score != null) params.set("claimed_score", String(score));
|
||||||
|
const operator = operatorInput?.value.trim();
|
||||||
|
if (operator) params.set("operators", operator);
|
||||||
|
cabrilloExport.href = `/api/logbook/export.cbr?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRows(): void {
|
||||||
|
if (!rowsBody) return;
|
||||||
|
if (qsos.length === 0) {
|
||||||
|
rowsBody.innerHTML =
|
||||||
|
'<tr><td colspan="10" class="log-empty">No contacts yet. Work someone and log them here,'
|
||||||
|
+ " or bring a log in with Import ADIF.</td></tr>";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (const qso of qsos) {
|
||||||
|
const row = document.createElement("tr");
|
||||||
|
row.dataset.qsoId = qso.id;
|
||||||
|
const cells: (string | null | undefined)[] = [
|
||||||
|
utcDate(qso.started_at),
|
||||||
|
utcTime(qso.started_at),
|
||||||
|
qso.call,
|
||||||
|
qso.band ?? "",
|
||||||
|
qso.submode ?? qso.mode,
|
||||||
|
qso.rst_sent ?? "",
|
||||||
|
qso.rst_rcvd ?? "",
|
||||||
|
qso.gridsquare ?? "",
|
||||||
|
qso.my_rig ?? "",
|
||||||
|
qso.confirmed ? "✓" : "",
|
||||||
|
];
|
||||||
|
for (const [index, value] of cells.entries()) {
|
||||||
|
const cell = document.createElement("td");
|
||||||
|
cell.textContent = value ?? "";
|
||||||
|
if (index === 2) cell.className = "log-cell-call";
|
||||||
|
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");
|
||||||
|
confirm.type = "button";
|
||||||
|
confirm.className = "log-row-btn";
|
||||||
|
confirm.textContent = qso.confirmed ? "Unconfirm" : "Confirm";
|
||||||
|
confirm.title = qso.confirmed
|
||||||
|
? "Mark this contact as not confirmed"
|
||||||
|
: "Mark this contact confirmed by QSL";
|
||||||
|
confirm.addEventListener("click", () => { void setConfirmed(qso, !qso.confirmed); });
|
||||||
|
actions.appendChild(confirm);
|
||||||
|
const remove = document.createElement("button");
|
||||||
|
remove.type = "button";
|
||||||
|
remove.className = "log-row-btn";
|
||||||
|
remove.textContent = "Delete";
|
||||||
|
remove.setAttribute("aria-label", `Delete the contact with ${qso.call}`);
|
||||||
|
remove.addEventListener("click", () => { void deleteQso(qso); });
|
||||||
|
actions.appendChild(remove);
|
||||||
|
row.appendChild(actions);
|
||||||
|
fragment.appendChild(row);
|
||||||
|
}
|
||||||
|
rowsBody.replaceChildren(fragment);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bands and modes the log actually holds, so the filters offer only those. */
|
||||||
|
function renderFilterOptions(): void {
|
||||||
|
for (const [select, values] of [
|
||||||
|
[filterBand, [...new Set(qsos.map((q) => q.band).filter((b): b is string => !!b))]],
|
||||||
|
[filterMode, [...new Set(qsos.map((q) => q.submode ?? q.mode).filter(Boolean))]],
|
||||||
|
] as const) {
|
||||||
|
if (!select) continue;
|
||||||
|
const chosen = select.value;
|
||||||
|
const known = new Set([...select.options].map((option) => option.value));
|
||||||
|
for (const value of [...values].sort()) {
|
||||||
|
if (known.has(value)) continue;
|
||||||
|
select.add(new Option(value, value));
|
||||||
|
}
|
||||||
|
select.value = chosen;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record, or withdraw, the other station's confirmation. */
|
||||||
|
async function setConfirmed(qso: Qso, confirmed: boolean): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/logbook/${encodeURIComponent(qso.id)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
...qso,
|
||||||
|
// A card is a card: this is the paper one, and an electronic
|
||||||
|
// confirmation the log already holds is left where it is.
|
||||||
|
qsl_rcvd: confirmed ? "Y" : "N",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
|
||||||
|
await refreshLog();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
notify(`Could not update: ${error instanceof Error ? error.message : String(error)}`, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteQso(qso: Qso): Promise<void> {
|
||||||
|
const confirmed = await bridge.trxUi.confirm({
|
||||||
|
title: "Delete this contact?",
|
||||||
|
message: `${qso.call} on ${qso.band ?? formatFreq(qso.freq_hz)} will be removed from the log.`,
|
||||||
|
confirmLabel: "Delete",
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/logbook/${encodeURIComponent(qso.id)}`, { method: "DELETE" });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
|
||||||
|
await refreshLog();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
notify(`Could not delete: ${error instanceof Error ? error.message : String(error)}`, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importAdif(file: File): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/logbook/import", { method: "POST", body: await file.arrayBuffer() });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
|
||||||
|
const outcome = await response.json() as { added: number; duplicate: number; rejected: string[] };
|
||||||
|
const parts = [`${String(outcome.added)} added`];
|
||||||
|
if (outcome.duplicate > 0) parts.push(`${String(outcome.duplicate)} already held`);
|
||||||
|
if (outcome.rejected.length > 0) parts.push(`${String(outcome.rejected.length)} not readable`);
|
||||||
|
notify(parts.join(", "));
|
||||||
|
await refreshLog();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
notify(`Import failed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Station line ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function renderStation(): void {
|
||||||
|
const callsign = hostState.ownerCallsign ?? "";
|
||||||
|
if (stationCallEl) stationCallEl.textContent = callsign || "no callsign set";
|
||||||
|
if (operatorInput && !operatorInput.value) operatorInput.value = callsign;
|
||||||
|
if (stationGridEl) {
|
||||||
|
const grid = hostState.serverLat != null && hostState.serverLon != null
|
||||||
|
? hostCore.latLonToMaidenhead(hostState.serverLat, hostState.serverLon)
|
||||||
|
: "";
|
||||||
|
entryGrid = grid || null;
|
||||||
|
stationGridEl.textContent = grid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wiring ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
form?.addEventListener("submit", (event) => { void submitEntry(event); });
|
||||||
|
clearBtn?.addEventListener("click", resetEntry);
|
||||||
|
callInput?.addEventListener("input", updateWorkedBefore);
|
||||||
|
for (const control of [filterCall, filterBand, filterMode]) {
|
||||||
|
control?.addEventListener("input", () => { void refreshLog(); });
|
||||||
|
control?.addEventListener("change", () => { void refreshLog(); });
|
||||||
|
}
|
||||||
|
for (const control of [cabrilloContest, cabrilloCallsign, cabrilloOperator, cabrilloPower, cabrilloScore]) {
|
||||||
|
control?.addEventListener("input", syncCabrilloLink);
|
||||||
|
control?.addEventListener("change", syncCabrilloLink);
|
||||||
|
}
|
||||||
|
importBtn?.addEventListener("click", () => { importFile?.click(); });
|
||||||
|
importFile?.addEventListener("change", () => {
|
||||||
|
const file = importFile.files?.[0];
|
||||||
|
if (file) void importAdif(file);
|
||||||
|
importFile.value = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 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";
|
||||||
|
}
|
||||||
|
|
||||||
|
renderStation();
|
||||||
|
if (cabrilloCallsign && !cabrilloCallsign.value) {
|
||||||
|
cabrilloCallsign.value = stationCallEl?.textContent?.trim() ?? "";
|
||||||
|
}
|
||||||
|
syncCabrilloLink();
|
||||||
|
if (canWriteLogbook()) void openEntry();
|
||||||
|
void refreshLog();
|
||||||
+2
-1
@@ -3,6 +3,7 @@
|
|||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
import type { SatelliteScheduleConfig, SatelliteSchedulerApi } from "./satellite-types.js";
|
import type { SatelliteScheduleConfig, SatelliteSchedulerApi } from "./satellite-types.js";
|
||||||
|
import type { AuthRole } from "../api/auth.js";
|
||||||
|
|
||||||
export type SchedulerMode = "disabled" | "grayline" | "time_span";
|
export type SchedulerMode = "disabled" | "grayline" | "time_span";
|
||||||
|
|
||||||
@@ -60,7 +61,7 @@ export interface SchedulerStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SchedulerService {
|
export interface SchedulerService {
|
||||||
initialize(rigId: string | null, role: string | null): void;
|
initialize(rigId: string | null, roles: readonly AuthRole[]): void;
|
||||||
destroy(): void;
|
destroy(): void;
|
||||||
setRig(rigId: string | null): void;
|
setRig(rigId: string | null): void;
|
||||||
wireEvents(): void;
|
wireEvents(): void;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
import { hostState } from "./host.js";
|
import { hostState } from "./host.js";
|
||||||
|
import { hasAuthRole, type AuthRole } from "../api/auth.js";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ScheduleEntry,
|
ScheduleEntry,
|
||||||
@@ -43,7 +44,7 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// State
|
// State
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
let schedulerRole: string | null = null;
|
let schedulerRoles: readonly AuthRole[] = [];
|
||||||
let currentRigId: string | null = null;
|
let currentRigId: string | null = null;
|
||||||
let currentConfig: SchedulerConfig | null = null;
|
let currentConfig: SchedulerConfig | null = null;
|
||||||
let currentSchedulerStatus: SchedulerStatus | null = null;
|
let currentSchedulerStatus: SchedulerStatus | null = null;
|
||||||
@@ -58,8 +59,8 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Init
|
// Init
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
function initScheduler(rigId: string | null, role: string | null): void {
|
function initScheduler(rigId: string | null, roles: readonly AuthRole[]): void {
|
||||||
schedulerRole = role;
|
schedulerRoles = roles;
|
||||||
currentRigId = rigId || null;
|
currentRigId = rigId || null;
|
||||||
if (currentRigId) loadScheduler();
|
if (currentRigId) loadScheduler();
|
||||||
startStatusPolling();
|
startStatusPolling();
|
||||||
@@ -356,7 +357,7 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
|
|||||||
if (!prevBtn || !nextBtn) return;
|
if (!prevBtn || !nextBtn) return;
|
||||||
const state = schedulerInterleaveState(currentConfig);
|
const state = schedulerInterleaveState(currentConfig);
|
||||||
const enabled =
|
const enabled =
|
||||||
schedulerRole === "control" &&
|
hasAuthRole(schedulerRoles, "control") &&
|
||||||
!!currentRigId &&
|
!!currentRigId &&
|
||||||
!schedulerStepPending &&
|
!schedulerStepPending &&
|
||||||
state.activeEntries.length > 1;
|
state.activeEntries.length > 1;
|
||||||
@@ -466,7 +467,7 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
|
|||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
|
|
||||||
const mode = (currentConfig && currentConfig.mode) || "disabled";
|
const mode = (currentConfig && currentConfig.mode) || "disabled";
|
||||||
const isControl = schedulerRole === "control";
|
const isControl = hasAuthRole(schedulerRoles, "control");
|
||||||
|
|
||||||
// Mode selector
|
// Mode selector
|
||||||
setSelected("scheduler-mode-select", mode);
|
setSelected("scheduler-mode-select", mode);
|
||||||
@@ -1574,8 +1575,8 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
|
|||||||
// When loaded eagerly, initSettingsUI() in app.js calls initScheduler();
|
// When loaded eagerly, initSettingsUI() in app.js calls initScheduler();
|
||||||
// when loaded lazily (e.g. settings tab click after boot), the app has
|
// when loaded lazily (e.g. settings tab click after boot), the app has
|
||||||
// already passed that point, so we must self-initialize here.
|
// already passed that point, so we must self-initialize here.
|
||||||
if (hostState.authRole != null) {
|
if (!hostState.authEnabled || hostState.authRoles.length > 0) {
|
||||||
initScheduler(hostState.lastActiveRigId, hostState.authRole);
|
initScheduler(hostState.lastActiveRigId, hostState.authRoles);
|
||||||
wireSchedulerEvents();
|
wireSchedulerEvents();
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ interface ButtonStateOptions {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
type TabKind = "primary" | "secondary";
|
type TabKind = "primary" | "secondary";
|
||||||
type LayoutCapability = "broadcast" | "digital";
|
type LayoutCapability = "broadcast" | "digital" | "ham";
|
||||||
type LayoutName = "compact" | "broadcast" | "digital" | "full";
|
type LayoutName = "compact" | "broadcast" | "digital" | "full" | "ham";
|
||||||
interface OperatorLayout {
|
interface OperatorLayout {
|
||||||
label: string;
|
label: string;
|
||||||
unavailable?: string;
|
unavailable?: string;
|
||||||
@@ -191,8 +191,11 @@ function elementById<T extends HTMLElement>(id: string): T {
|
|||||||
broadcast: { label: "Broadcast", unavailable: "Broadcast requires an enumerated WFM-capable receiver", advanced: false, audio: true, scheduler: false, preferredTab: "main", capability: "broadcast" },
|
broadcast: { label: "Broadcast", unavailable: "Broadcast requires an enumerated WFM-capable receiver", advanced: false, audio: true, scheduler: false, preferredTab: "main", capability: "broadcast" },
|
||||||
digital: { label: "Digital", unavailable: "Digital requires a compatible rig mode and an available decoder", advanced: false, audio: false, scheduler: false, preferredTab: "digital-modes", capability: "digital" },
|
digital: { label: "Digital", unavailable: "Digital requires a compatible rig mode and an available decoder", advanced: false, audio: false, scheduler: false, preferredTab: "digital-modes", capability: "digital" },
|
||||||
full: { label: "Full controls", advanced: true, audio: true, scheduler: true, preferredTab: "main" },
|
full: { label: "Full controls", advanced: true, audio: true, scheduler: true, preferredTab: "main" },
|
||||||
|
// Working the bands: the log is the task and the radio is the instrument,
|
||||||
|
// so this one opens on the logbook with the controls a keystroke away.
|
||||||
|
ham: { label: "Ham radio", unavailable: "Ham radio needs a rig that can transmit", advanced: true, audio: true, scheduler: false, preferredTab: "logbook", capability: "ham" },
|
||||||
};
|
};
|
||||||
const layoutCapabilities: Record<LayoutCapability, boolean> = { broadcast: false, digital: false };
|
const layoutCapabilities: Record<LayoutCapability, boolean> = { broadcast: false, digital: false, ham: false };
|
||||||
let activeRigId: string | null = null;
|
let activeRigId: string | null = null;
|
||||||
|
|
||||||
// A layout seeds the collapsible sections; it does not hold them there.
|
// A layout seeds the collapsible sections; it does not hold them there.
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { chromium } from "playwright-core";
|
||||||
|
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
|
||||||
|
|
||||||
|
/* global document */
|
||||||
|
|
||||||
|
const ALL_ROLES = ["read", "control", "transmit", "write", "administrator"];
|
||||||
|
const fixture = await startWebFixture({
|
||||||
|
authSession: {
|
||||||
|
authenticated: true,
|
||||||
|
username: "admin",
|
||||||
|
roles: ALL_ROLES,
|
||||||
|
auth_disabled: false,
|
||||||
|
},
|
||||||
|
users: [
|
||||||
|
{ username: "admin", roles: ALL_ROLES, enabled: true },
|
||||||
|
{ username: "guest", roles: ["guest"], enabled: true },
|
||||||
|
{ username: "listener", roles: ["read"], enabled: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const { browser, page, runtimeErrors } = await startBrowser(chromium);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.goto(`${fixture.origin}/settings`, { waitUntil: "domcontentloaded" });
|
||||||
|
await page.locator("#tab-settings").waitFor({ state: "visible" });
|
||||||
|
await page.locator("#settings-account-tab").waitFor({ state: "visible" });
|
||||||
|
await page.locator("#settings-users-tab").waitFor({ state: "visible" });
|
||||||
|
|
||||||
|
await page.locator("#settings-account-tab").click();
|
||||||
|
assert.equal(await page.locator("#account-password-form").isVisible(), true);
|
||||||
|
assert.equal(await page.locator("#subtab-settings-users").isVisible(), false);
|
||||||
|
|
||||||
|
await page.locator("#settings-users-tab").click();
|
||||||
|
await page.locator("#user-list").getByText("listener (disabled)").waitFor();
|
||||||
|
assert.equal(await page.locator("#user-create-form").isVisible(), true);
|
||||||
|
|
||||||
|
const state = await page.evaluate(() => {
|
||||||
|
const rows = [...document.querySelectorAll("#user-list > .sch-row")];
|
||||||
|
const rowFor = (username) => rows.find((row) => row.querySelector("strong")?.textContent.startsWith(username));
|
||||||
|
const admin = rowFor("admin");
|
||||||
|
const guest = rowFor("guest");
|
||||||
|
const listener = rowFor("listener");
|
||||||
|
const role = (row, value) => row?.querySelector(`input[value="${value}"]`);
|
||||||
|
return {
|
||||||
|
createRoles: [...document.querySelectorAll("#user-create-roles input")].map((input) => input.value),
|
||||||
|
adminEnabledLocked: admin?.querySelector('input[type="checkbox"]')?.disabled,
|
||||||
|
adminRoleLocked: role(admin, "administrator")?.disabled,
|
||||||
|
adminGuestLocked: role(admin, "guest")?.disabled,
|
||||||
|
adminRemoveLocked: admin?.querySelector("button.danger")?.disabled,
|
||||||
|
guestPasswordLocked: guest?.querySelector('input[type="password"]')?.disabled,
|
||||||
|
listenerEnabled: listener?.querySelector('input[type="checkbox"]')?.checked,
|
||||||
|
listenerRead: role(listener, "read")?.checked,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert.deepEqual(state.createRoles, ["guest", ...ALL_ROLES]);
|
||||||
|
assert.equal(state.adminEnabledLocked, true);
|
||||||
|
assert.equal(state.adminRoleLocked, true);
|
||||||
|
assert.equal(state.adminGuestLocked, true);
|
||||||
|
assert.equal(state.adminRemoveLocked, true);
|
||||||
|
assert.equal(state.guestPasswordLocked, true);
|
||||||
|
assert.equal(state.listenerEnabled, false);
|
||||||
|
assert.equal(state.listenerRead, true);
|
||||||
|
|
||||||
|
const listenerRow = page.locator("#user-list > .sch-row").filter({ hasText: "listener" });
|
||||||
|
const listenerPassword = listenerRow.locator('input[type="password"]');
|
||||||
|
await listenerRow.locator('input[value="guest"]').check();
|
||||||
|
assert.equal(await listenerPassword.isDisabled(), true);
|
||||||
|
assert.equal(await listenerRow.locator('input[value="read"]').isChecked(), true);
|
||||||
|
await listenerRow.locator('input[value="control"]').check();
|
||||||
|
assert.equal(await listenerRow.locator('input[value="guest"]').isChecked(), true);
|
||||||
|
assert.equal(await listenerPassword.isDisabled(), true);
|
||||||
|
await listenerRow.locator('input[value="guest"]').uncheck();
|
||||||
|
assert.equal(await listenerPassword.isDisabled(), false);
|
||||||
|
assert.deepEqual(runtimeErrors, []);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
await fixture.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const guestFixture = await startWebFixture({
|
||||||
|
authSession: {
|
||||||
|
authenticated: true,
|
||||||
|
username: "guest",
|
||||||
|
roles: ["guest"],
|
||||||
|
auth_disabled: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const guestBrowser = await startBrowser(chromium);
|
||||||
|
try {
|
||||||
|
await guestBrowser.page.goto(`${guestFixture.origin}/settings`, { waitUntil: "domcontentloaded" });
|
||||||
|
await guestBrowser.page.locator("#tab-settings").waitFor({ state: "visible" });
|
||||||
|
assert.equal(await guestBrowser.page.locator("#settings-account-tab").isVisible(), false);
|
||||||
|
assert.equal(await guestBrowser.page.locator("#settings-users-tab").isVisible(), false);
|
||||||
|
assert.deepEqual(guestBrowser.runtimeErrors, []);
|
||||||
|
} finally {
|
||||||
|
await guestBrowser.browser.close();
|
||||||
|
await guestFixture.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import vm from "node:vm";
|
||||||
|
import { bundleEntry } from "./bundle-entry.mjs";
|
||||||
|
|
||||||
|
const source = await bundleEntry(new URL("../src/api/auth.ts", import.meta.url), "AuthApi");
|
||||||
|
|
||||||
|
function loadAuth(fetch) {
|
||||||
|
const context = vm.createContext({ fetch, console });
|
||||||
|
new vm.Script(source).runInContext(context);
|
||||||
|
return context.AuthApi;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("role policy centralizes Guest and separates Control from Transmit", () => {
|
||||||
|
const auth = loadAuth(async () => { throw new Error("unused"); });
|
||||||
|
assert.deepEqual(Array.from(auth.AUTH_ROLES), ["guest", "read", "control", "transmit", "write", "administrator"]);
|
||||||
|
assert.equal(auth.hasAuthRole(["guest"], "read"), true);
|
||||||
|
assert.equal(auth.hasAccountControls(["guest"]), false);
|
||||||
|
assert.equal(auth.hasAccountControls(["read"]), true);
|
||||||
|
assert.equal(auth.hasAuthRole(["control"], "read"), true);
|
||||||
|
assert.equal(auth.hasAuthRole(["transmit"], "read"), true);
|
||||||
|
assert.equal(auth.hasAuthRole(["control"], "transmit"), false);
|
||||||
|
assert.equal(auth.hasAuthRole(["control"], "write"), false);
|
||||||
|
assert.equal(auth.hasAuthRole(["administrator"], "write"), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("auth responses normalize roles and require the managed-account lifecycle state", async () => {
|
||||||
|
const replies = new Map([
|
||||||
|
["/auth/session", { authenticated: true, roles: ["write", "read", "write"], username: "alice" }],
|
||||||
|
["/auth/users", [{ username: "alice", roles: ["write", "read"], enabled: false }]],
|
||||||
|
]);
|
||||||
|
const auth = loadAuth(async (url) => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => replies.get(String(url)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert.deepEqual(Array.from((await auth.fetchAuthSession()).roles), ["read", "write"]);
|
||||||
|
assert.deepEqual(Array.from((await auth.listUsers())[0].roles), ["read", "write"]);
|
||||||
|
assert.equal((await auth.listUsers())[0].enabled, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("changing a password sends current and replacement credentials", async () => {
|
||||||
|
let request;
|
||||||
|
const auth = loadAuth(async (url, init) => {
|
||||||
|
request = { url, init };
|
||||||
|
return { ok: true, status: 200, json: async () => ({}) };
|
||||||
|
});
|
||||||
|
|
||||||
|
await auth.changeOwnPassword("old-password", "new-password");
|
||||||
|
assert.equal(request.url, "/auth/account/password");
|
||||||
|
assert.equal(request.init.method, "PATCH");
|
||||||
|
assert.deepEqual(JSON.parse(request.init.body), {
|
||||||
|
current_password: "old-password",
|
||||||
|
new_password: "new-password",
|
||||||
|
});
|
||||||
|
});
|
||||||
+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));
|
const source = await bundleEntry(new URL("../src/plugins/background-decode.ts", import.meta.url));
|
||||||
new vm.Script(source).runInContext(context);
|
new vm.Script(source).runInContext(context);
|
||||||
|
|
||||||
window.trx.modules.backgroundDecode.initialize("rig/a", "control");
|
window.trx.modules.backgroundDecode.initialize("rig/a", ["administrator"]);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
assert.ok(requested.includes("/background-decode/rig%2Fa"));
|
assert.ok(requested.includes("/background-decode/rig%2Fa"));
|
||||||
assert.ok(requested.includes("/bookmarks"));
|
assert.ok(requested.includes("/bookmarks"));
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ function hostFixture(overrides = {}) {
|
|||||||
const calls = { postPath: [], setRigFrequency: [], armOptimisticFrequency: [], applyLocalTunedFrequency: [], syncBandwidthInput: [], scheduleSpectrumDraw: 0, syncModePicker: 0 };
|
const calls = { postPath: [], setRigFrequency: [], armOptimisticFrequency: [], applyLocalTunedFrequency: [], syncBandwidthInput: [], scheduleSpectrumDraw: 0, syncModePicker: 0 };
|
||||||
const state = {
|
const state = {
|
||||||
authEnabled: false,
|
authEnabled: false,
|
||||||
authRole: "control",
|
authRoles: ["read", "control", "transmit", "write", "administrator"],
|
||||||
lastActiveRigId: null,
|
lastActiveRigId: null,
|
||||||
lastRigIds: [],
|
lastRigIds: [],
|
||||||
lastRigDisplayNames: {},
|
lastRigDisplayNames: {},
|
||||||
@@ -157,7 +157,7 @@ test("bookmark controls follow the host authentication state", async () => {
|
|||||||
if (!elements.has(id)) elements.set(id, new ElementFixture());
|
if (!elements.has(id)) elements.set(id, new ElementFixture());
|
||||||
return elements.get(id);
|
return elements.get(id);
|
||||||
};
|
};
|
||||||
const { window } = hostFixture({ authEnabled: true, authRole: "rx" });
|
const { window } = hostFixture({ authEnabled: true, authRoles: ["read"] });
|
||||||
const context = vm.createContext({
|
const context = vm.createContext({
|
||||||
window,
|
window,
|
||||||
document: documentFixture(element),
|
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");
|
assert.equal(element("bm-add-btn").style.display, "none");
|
||||||
|
|
||||||
window.trx.state.authRole = "control";
|
window.trx.state.authRoles = ["read", "write"];
|
||||||
await window.trx.modules.bookmarks.fetch("");
|
await window.trx.modules.bookmarks.fetch("");
|
||||||
assert.equal(element("bm-add-btn").style.display, "");
|
assert.equal(element("bm-add-btn").style.display, "");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function createHost({ state = {}, core = {}, modules = {} } = {}) {
|
|||||||
serverLat: null,
|
serverLat: null,
|
||||||
serverLon: null,
|
serverLon: null,
|
||||||
authEnabled: false,
|
authEnabled: false,
|
||||||
authRole: "control",
|
authRoles: ["read", "control", "transmit", "write", "administrator"],
|
||||||
lastActiveRigId: null,
|
lastActiveRigId: null,
|
||||||
lastRigIds: [],
|
lastRigIds: [],
|
||||||
lastRigDisplayNames: {},
|
lastRigDisplayNames: {},
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
// The station log: the panel opens an entry pre-filled from the radio and the
|
||||||
|
// server's clock, writes a contact, keeps it, and says whether the station has
|
||||||
|
// been worked before. And the ham layout, which is offered only where a rig can
|
||||||
|
// transmit -- a log is of contacts made.
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { chromium } from "playwright-core";
|
||||||
|
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
|
||||||
|
|
||||||
|
/* global document, window, location */
|
||||||
|
|
||||||
|
const HELD = [
|
||||||
|
{
|
||||||
|
id: "q1", started_at: "2026-08-06T14:22:00Z", call: "DL3ABC", freq_hz: 14_074_000,
|
||||||
|
band: "20m", mode: "FT8", rst_sent: "-07", rst_rcvd: "-12", gridsquare: "JO31",
|
||||||
|
my_rig: "Primary fixture",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "q2", started_at: "2026-08-06T13:05:00Z", call: "SP9XYZ", freq_hz: 7_120_000,
|
||||||
|
band: "40m", mode: "SSB", submode: "LSB", rst_sent: "59", rst_rcvd: "57",
|
||||||
|
gridsquare: "JO90", my_rig: "Primary fixture",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const fixture = await startWebFixture({ spectrum: true, tx: true, logbook: [...HELD] });
|
||||||
|
const { browser, page, runtimeErrors } = await startBrowser(chromium);
|
||||||
|
|
||||||
|
const readPanel = () => page.evaluate(() => ({
|
||||||
|
rows: [...(document.getElementById("log-rows")?.children ?? [])].map((row) => ({
|
||||||
|
call: row.querySelector(".log-cell-call")?.textContent ?? "",
|
||||||
|
cells: [...row.querySelectorAll("td")].map((cell) => cell.textContent),
|
||||||
|
})),
|
||||||
|
summary: document.getElementById("log-summary")?.textContent ?? "",
|
||||||
|
freq: document.getElementById("log-freq")?.value ?? "",
|
||||||
|
mode: document.getElementById("log-mode")?.value ?? "",
|
||||||
|
call: document.getElementById("log-call")?.value ?? "",
|
||||||
|
grid: document.getElementById("log-grid")?.value ?? "",
|
||||||
|
rig: document.getElementById("log-station-rig-name")?.textContent ?? "",
|
||||||
|
clock: document.getElementById("log-clock")?.textContent ?? "",
|
||||||
|
worked: document.getElementById("log-worked-before")?.textContent ?? "",
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 950 });
|
||||||
|
await page.goto(`${fixture.origin}/logbook`, { waitUntil: "domcontentloaded" });
|
||||||
|
await page.locator("#tab-logbook").waitFor({ state: "visible" });
|
||||||
|
await page.waitForTimeout(2500);
|
||||||
|
|
||||||
|
// The log is there, newest first, with the band and mode of each contact.
|
||||||
|
const opened = await readPanel();
|
||||||
|
assert.deepEqual(opened.rows.map((row) => row.call), ["DL3ABC", "SP9XYZ"]);
|
||||||
|
assert.equal(opened.summary, "2 contacts");
|
||||||
|
// A phone contact shows the sideband it was worked on, not just "SSB".
|
||||||
|
assert.ok(opened.rows[1].cells.includes("LSB"), `the row reads ${JSON.stringify(opened.rows[1].cells)}`);
|
||||||
|
|
||||||
|
// The entry opened pre-filled: frequency and mode from the rig, the rig's
|
||||||
|
// name, and the time from the server.
|
||||||
|
assert.equal(opened.freq, "100", `the frequency pre-filled as "${opened.freq}"`);
|
||||||
|
assert.equal(opened.mode, "USB", `the mode pre-filled as "${opened.mode}"`);
|
||||||
|
assert.equal(opened.rig, "Primary fixture");
|
||||||
|
assert.match(opened.clock, /^\d{2}:\d{2}:\d{2}Z$/, `the clock reads "${opened.clock}"`);
|
||||||
|
// ...and nothing else was: a report is the operator's to give.
|
||||||
|
assert.equal(opened.call, "");
|
||||||
|
assert.equal(await page.evaluate(() => document.getElementById("log-rst-sent")?.value), "");
|
||||||
|
|
||||||
|
// Worked-before answers as the callsign is typed.
|
||||||
|
await page.locator("#log-call").fill("DL3ABC");
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
assert.match((await readPanel()).worked, /Worked before: 20m FT8/);
|
||||||
|
await page.locator("#log-call").fill("OZ1NEW");
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
assert.match((await readPanel()).worked, /Not worked before/);
|
||||||
|
|
||||||
|
// Logging writes the contact and clears the entry for the next one.
|
||||||
|
await page.locator("#log-rst-sent").fill("59");
|
||||||
|
await page.locator("#log-rst-rcvd").fill("57");
|
||||||
|
await page.locator("#log-save-btn").click();
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
const afterLogging = await readPanel();
|
||||||
|
assert.deepEqual(afterLogging.rows.map((row) => row.call), ["OZ1NEW", "DL3ABC", "SP9XYZ"]);
|
||||||
|
assert.equal(afterLogging.summary, "3 contacts");
|
||||||
|
assert.equal(afterLogging.call, "", "the entry kept the callsign after logging it");
|
||||||
|
assert.equal(afterLogging.freq, "100", "the entry did not re-open pre-filled");
|
||||||
|
|
||||||
|
// A decode row starts an entry rather than logging one: what was heard goes
|
||||||
|
// into the form, and the operator finishes it.
|
||||||
|
await page.evaluate(() => { window.navigateToTab("digital-modes"); });
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
await page.locator('#tab-digital-modes .sub-tab[data-subtab="ft8"]').click();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.trxPluginRuntime.dispatch("ft8", {
|
||||||
|
message: "CQ SP2SJG JO94", snr_db: -7, dt_s: 0.2, freq_hz: 1200,
|
||||||
|
ts_ms: Date.now(), rig_id: "rig-a",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
await page.locator("#ft8-messages .ft8-log-btn").first().click();
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
const fromDecode = await page.evaluate(() => ({
|
||||||
|
path: location.pathname,
|
||||||
|
call: document.getElementById("log-call")?.value ?? "",
|
||||||
|
grid: document.getElementById("log-grid")?.value ?? "",
|
||||||
|
mode: document.getElementById("log-mode")?.value ?? "",
|
||||||
|
rows: document.getElementById("log-rows")?.children.length ?? 0,
|
||||||
|
}));
|
||||||
|
assert.equal(fromDecode.path, "/logbook", `the Log button landed on ${fromDecode.path}`);
|
||||||
|
assert.equal(fromDecode.call, "SP2SJG", "the heard callsign did not reach the entry");
|
||||||
|
assert.equal(fromDecode.grid, "JO94", "the heard locator did not reach the entry");
|
||||||
|
// The decoder answers for the mode, because a rig in DIG cannot.
|
||||||
|
assert.equal(fromDecode.mode, "FT8", `the mode came through as "${fromDecode.mode}"`);
|
||||||
|
assert.equal(fromDecode.rows, 3, "a decode logged itself instead of opening an entry");
|
||||||
|
|
||||||
|
// The ham layout is on offer, because this rig can transmit.
|
||||||
|
const layouts = await page.evaluate(() =>
|
||||||
|
[...document.querySelectorAll("#operator-layout-select option")].map((option) => option.value));
|
||||||
|
assert.ok(layouts.includes("ham"), `the layouts are ${JSON.stringify(layouts)}`);
|
||||||
|
// Choosing it the way the picker does. The <select> itself measures zero
|
||||||
|
// wide in a headless window, so Playwright will not click it; the change
|
||||||
|
// event is what the picker is wired to either way.
|
||||||
|
await page.evaluate(() => { window.navigateToTab("main"); });
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const select = document.getElementById("operator-layout-select");
|
||||||
|
select.value = "ham";
|
||||||
|
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
const inHamLayout = await page.evaluate(() => ({
|
||||||
|
layout: document.body.dataset.operatorLayout,
|
||||||
|
path: location.pathname,
|
||||||
|
}));
|
||||||
|
assert.equal(inHamLayout.layout, "ham");
|
||||||
|
assert.equal(inHamLayout.path, "/logbook", "the ham layout opened somewhere else");
|
||||||
|
|
||||||
|
// ── Contest working ─────────────────────────────────────────────────────
|
||||||
|
// The exchange belongs to the session: the contest and the serial sent stay
|
||||||
|
// between contacts, and the serial counts on by itself.
|
||||||
|
await page.evaluate(() => { window.navigateToTab("logbook"); });
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
await page.locator("#log-contest-block > summary").click();
|
||||||
|
await page.locator("#log-contest-id").fill("CQ-WW-SSB");
|
||||||
|
await page.locator("#log-stx").fill("1");
|
||||||
|
await page.locator("#log-call").fill("DL9CON");
|
||||||
|
await page.locator("#log-srx").fill("014");
|
||||||
|
await page.locator("#log-rst-sent").fill("59");
|
||||||
|
await page.locator("#log-rst-rcvd").fill("59");
|
||||||
|
await page.locator("#log-save-btn").click();
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
const afterContest = await page.evaluate(() => ({
|
||||||
|
contest: document.getElementById("log-contest-id")?.value ?? "",
|
||||||
|
stx: document.getElementById("log-stx")?.value ?? "",
|
||||||
|
srx: document.getElementById("log-srx")?.value ?? "",
|
||||||
|
call: document.getElementById("log-call")?.value ?? "",
|
||||||
|
}));
|
||||||
|
assert.equal(afterContest.contest, "CQ-WW-SSB", "the contest was cleared with the contact");
|
||||||
|
assert.equal(afterContest.stx, "2", `the serial sent went to "${afterContest.stx}"`);
|
||||||
|
assert.equal(afterContest.srx, "", "the received exchange was kept for the next station");
|
||||||
|
assert.equal(afterContest.call, "");
|
||||||
|
|
||||||
|
// The Cabrillo link carries the header, and names the contest so that only
|
||||||
|
// its contacts are entered.
|
||||||
|
await page.locator("#log-contest-export-block > summary").click();
|
||||||
|
await page.locator("#log-cbr-contest").fill("CQ-WW-SSB");
|
||||||
|
await page.locator("#log-cbr-score").fill("4242");
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
const cabrillo = await page.evaluate(() =>
|
||||||
|
document.getElementById("log-cbr-export")?.getAttribute("href") ?? "");
|
||||||
|
assert.match(cabrillo, /contest=CQ-WW-SSB/, `the Cabrillo link reads "${cabrillo}"`);
|
||||||
|
assert.match(cabrillo, /claimed_score=4242/, `the Cabrillo link reads "${cabrillo}"`);
|
||||||
|
const entry = await page.evaluate(async (href) => {
|
||||||
|
const response = await fetch(href);
|
||||||
|
return await response.text();
|
||||||
|
}, cabrillo);
|
||||||
|
assert.match(entry, /^START-OF-LOG: 3\.0/, entry);
|
||||||
|
assert.match(entry, /DL9CON/, "the contest contact is not in the entry");
|
||||||
|
assert.ok(!entry.includes("OZ1NEW"), "a contact outside the contest was entered");
|
||||||
|
|
||||||
|
// ── Confirmations ───────────────────────────────────────────────────────
|
||||||
|
// A card arrives: the contact is marked confirmed, and the per-band report
|
||||||
|
// counts it.
|
||||||
|
const confirmRow = page.locator("#log-rows tr", { hasText: "DL9CON" }).first();
|
||||||
|
await confirmRow.getByRole("button", { name: "Confirm" }).click();
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
await page.locator("#log-statistics-block > summary").click();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
const confirmed = await page.evaluate(() => ({
|
||||||
|
marks: [...document.querySelectorAll("#log-rows tr")]
|
||||||
|
.map((row) => [...row.querySelectorAll("td")].at(-2)?.textContent ?? ""),
|
||||||
|
bands: [...document.querySelectorAll("#log-statistics-rows tr")]
|
||||||
|
.map((row) => [...row.querySelectorAll("td")].map((cell) => cell.textContent)),
|
||||||
|
}));
|
||||||
|
assert.equal(confirmed.marks.filter((mark) => mark === "✓").length, 1,
|
||||||
|
`the QSL column reads ${JSON.stringify(confirmed.marks)}`);
|
||||||
|
const twenty = confirmed.bands.find((band) => band[0] === "20m");
|
||||||
|
assert.ok(twenty, `the bands are ${JSON.stringify(confirmed.bands)}`);
|
||||||
|
assert.equal(twenty[3], "1", `20m shows ${twenty[3]} confirmed`);
|
||||||
|
|
||||||
|
assert.deepEqual(runtimeErrors, []);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
await fixture.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without a transmitter there is nothing to log, so the layout is not offered.
|
||||||
|
const listenerFixture = await startWebFixture({ spectrum: true, tx: false });
|
||||||
|
const listener = await startBrowser(chromium);
|
||||||
|
try {
|
||||||
|
await listener.page.setViewportSize({ width: 1400, height: 950 });
|
||||||
|
await listener.page.goto(`${listenerFixture.origin}/`, { waitUntil: "domcontentloaded" });
|
||||||
|
await listener.page.locator("#content").waitFor({ state: "visible" });
|
||||||
|
await listener.page.waitForTimeout(2000);
|
||||||
|
const layouts = await listener.page.evaluate(() =>
|
||||||
|
[...document.querySelectorAll("#operator-layout-select option")].map((option) => option.value));
|
||||||
|
assert.ok(!layouts.includes("ham"), `a receiver was offered the ham layout: ${JSON.stringify(layouts)}`);
|
||||||
|
} finally {
|
||||||
|
await listener.browser.close();
|
||||||
|
await listenerFixture.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("logbook tests passed");
|
||||||
@@ -11,7 +11,7 @@ import { bundleEntry } from "./bundle-entry.mjs";
|
|||||||
test("scheduler registers a typed module service without lifecycle globals", async () => {
|
test("scheduler registers a typed module service without lifecycle globals", async () => {
|
||||||
// No role known yet: the entry registers its service and waits for the
|
// No role known yet: the entry registers its service and waits for the
|
||||||
// application to drive initialization.
|
// application to drive initialization.
|
||||||
const window = { ...createHost({ state: { authRole: null } }), trxUi: { confirm: async () => true } };
|
const window = { ...createHost({ state: { authEnabled: true, authRoles: [] } }), trxUi: { confirm: async () => true } };
|
||||||
const context = vm.createContext({
|
const context = vm.createContext({
|
||||||
window,
|
window,
|
||||||
document: {
|
document: {
|
||||||
@@ -76,7 +76,7 @@ test("scheduler self-initializes for the active rig when a role is already known
|
|||||||
return elements.get(id);
|
return elements.get(id);
|
||||||
};
|
};
|
||||||
const window = {
|
const window = {
|
||||||
...createHost({ state: { authRole: "control", lastActiveRigId: "sdr" } }),
|
...createHost({ state: { authRoles: ["administrator"], lastActiveRigId: "sdr" } }),
|
||||||
trxUi: { confirm: async () => true },
|
trxUi: { confirm: async () => true },
|
||||||
};
|
};
|
||||||
const context = vm.createContext({
|
const context = vm.createContext({
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { readFile } from "node:fs/promises";
|
|||||||
const indexPath = new URL("../../assets/web/index.html", import.meta.url);
|
const indexPath = new URL("../../assets/web/index.html", import.meta.url);
|
||||||
const pluginLoaderPath = new URL("../src/plugin-loader.ts", 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 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 () => {
|
test("index loads one first-party application bootstrap", async () => {
|
||||||
const html = await readFile(indexPath, "utf8");
|
const html = await readFile(indexPath, "utf8");
|
||||||
@@ -30,6 +31,30 @@ test("startup has no remote script or stylesheet dependencies", async () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("administrator user management is a dedicated Settings sub-tab", async () => {
|
||||||
|
const [html, app] = await Promise.all([
|
||||||
|
readFile(indexPath, "utf8"),
|
||||||
|
readFile(appPath, "utf8"),
|
||||||
|
]);
|
||||||
|
assert.match(html, /data-subtab="settings-users"/);
|
||||||
|
assert.match(html, /id="subtab-settings-users" class="sub-tab-panel"/);
|
||||||
|
assert.match(app, /authEnabled && hasAuthRole\("administrator"\)/);
|
||||||
|
assert.match(app, /settings-users-tab/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("account lifecycle controls include self-service passwords and enable state", async () => {
|
||||||
|
const [html, app] = await Promise.all([
|
||||||
|
readFile(indexPath, "utf8"),
|
||||||
|
readFile(appPath, "utf8"),
|
||||||
|
]);
|
||||||
|
assert.match(html, /data-subtab="settings-account"/);
|
||||||
|
assert.match(html, /id="account-password-form"/);
|
||||||
|
assert.match(html, /id="user-create-enabled"/);
|
||||||
|
assert.match(app, /changeOwnPassword/);
|
||||||
|
assert.match(app, /hasAccountControls\(authRoles\)/);
|
||||||
|
assert.match(app, /enabledAdminCount/);
|
||||||
|
});
|
||||||
|
|
||||||
test("lazy frontend features use modules and local map symbols", async () => {
|
test("lazy frontend features use modules and local map symbols", async () => {
|
||||||
const [loader, map] = await Promise.all([
|
const [loader, map] = await Promise.all([
|
||||||
readFile(pluginLoaderPath, "utf8"),
|
readFile(pluginLoaderPath, "utf8"),
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { chromium } from "playwright-core";
|
||||||
|
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
|
||||||
|
|
||||||
|
async function controlState(page) {
|
||||||
|
return {
|
||||||
|
frequency: await page.locator("#freq").isDisabled(),
|
||||||
|
ptt: await page.locator("#ptt-btn").isDisabled(),
|
||||||
|
txAudio: await page.locator("#tx-audio-btn").isDisabled(),
|
||||||
|
txLimit: await page.locator("#tx-limit-btn").isDisabled(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inspectRole(roles) {
|
||||||
|
const fixture = await startWebFixture({
|
||||||
|
tx: true,
|
||||||
|
authSession: {
|
||||||
|
authenticated: true,
|
||||||
|
username: "operator",
|
||||||
|
roles,
|
||||||
|
auth_disabled: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const session = await startBrowser(chromium);
|
||||||
|
try {
|
||||||
|
await session.page.goto(`${fixture.origin}/`, { waitUntil: "domcontentloaded" });
|
||||||
|
await session.page.locator("#content").waitFor({ state: "visible" });
|
||||||
|
await session.page.waitForTimeout(1200);
|
||||||
|
assert.deepEqual(session.runtimeErrors, []);
|
||||||
|
return await controlState(session.page);
|
||||||
|
} finally {
|
||||||
|
await session.browser.close();
|
||||||
|
await fixture.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepEqual(await inspectRole(["control"]), {
|
||||||
|
frequency: false,
|
||||||
|
ptt: true,
|
||||||
|
txAudio: true,
|
||||||
|
txLimit: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(await inspectRole(["transmit"]), {
|
||||||
|
frequency: true,
|
||||||
|
ptt: false,
|
||||||
|
txAudio: false,
|
||||||
|
txLimit: false,
|
||||||
|
});
|
||||||
@@ -140,10 +140,13 @@ export async function startWebFixture({
|
|||||||
history = {},
|
history = {},
|
||||||
bookmarks = [],
|
bookmarks = [],
|
||||||
backgroundDecode = null,
|
backgroundDecode = null,
|
||||||
|
logbook = [],
|
||||||
bandplan = {},
|
bandplan = {},
|
||||||
bandplanEnabled = false,
|
bandplanEnabled = false,
|
||||||
bandplanUnauthorizedFirst = false,
|
bandplanUnauthorizedFirst = false,
|
||||||
satPasses = null,
|
satPasses = null,
|
||||||
|
authSession = { authenticated: true, roles: ["read", "control", "transmit", "write", "administrator"], auth_disabled: true },
|
||||||
|
users = [],
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const rigItems = ["rig-a", "rig-b"].map((remote) => ({
|
const rigItems = ["rig-a", "rig-b"].map((remote) => ({
|
||||||
remote,
|
remote,
|
||||||
@@ -231,7 +234,8 @@ export async function startWebFixture({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const jsonRoutes = new Map([
|
const jsonRoutes = new Map([
|
||||||
["/auth/session", { authenticated: true, role: "control", auth_disabled: true }],
|
["/auth/session", authSession],
|
||||||
|
["/auth/users", users],
|
||||||
["/decoders", DECODER_REGISTRY],
|
["/decoders", DECODER_REGISTRY],
|
||||||
["/rigs", rigsResponse],
|
["/rigs", rigsResponse],
|
||||||
["/status", status],
|
["/status", status],
|
||||||
@@ -279,6 +283,130 @@ export async function startWebFixture({
|
|||||||
response.writeHead(200).end();
|
response.writeHead(200).end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// The logbook: an in-memory station log, enough for the panel to open an
|
||||||
|
// entry, write it, read it back and export it.
|
||||||
|
if (url.pathname.startsWith("/api/logbook")) {
|
||||||
|
const tail = url.pathname.slice("/api/logbook".length);
|
||||||
|
if (tail === "/prefill") {
|
||||||
|
response.writeHead(200, { "content-type": "application/json" });
|
||||||
|
response.end(JSON.stringify({
|
||||||
|
started_at: new Date().toISOString(),
|
||||||
|
epoch_ms: Date.now(),
|
||||||
|
freq_hz: status.status.freq.hz,
|
||||||
|
band: "20m",
|
||||||
|
mode: url.searchParams.get("decoder") === "ft8" ? "FT8" : "SSB",
|
||||||
|
submode: url.searchParams.get("decoder") === "ft8" ? null : "USB",
|
||||||
|
rig_id: url.searchParams.get("remote") ?? "rig-a",
|
||||||
|
my_rig: "Primary fixture",
|
||||||
|
call: url.searchParams.get("call"),
|
||||||
|
gridsquare: url.searchParams.get("gridsquare"),
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (tail.startsWith("/worked/")) {
|
||||||
|
const call = decodeURIComponent(tail.slice("/worked/".length)).toUpperCase();
|
||||||
|
const worked = logbook
|
||||||
|
.filter((qso) => qso.call === call)
|
||||||
|
.map((qso) => ({ band: qso.band, mode: qso.mode }));
|
||||||
|
response.writeHead(200, { "content-type": "application/json" });
|
||||||
|
response.end(JSON.stringify({ call, worked }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (tail === "/statistics") {
|
||||||
|
const bands = new Map();
|
||||||
|
for (const qso of logbook) {
|
||||||
|
const entry = bands.get(qso.band) ?? { band: qso.band, contacts: 0, stations: 0, confirmed: 0, calls: new Set() };
|
||||||
|
entry.contacts += 1;
|
||||||
|
entry.calls.add(qso.call);
|
||||||
|
if (qso.confirmed || qso.qsl_rcvd === "Y" || qso.lotw_qsl_rcvd === "Y") entry.confirmed += 1;
|
||||||
|
bands.set(qso.band, entry);
|
||||||
|
}
|
||||||
|
const list = [...bands.values()].map(({ calls, ...rest }) => ({ ...rest, stations: calls.size }));
|
||||||
|
response.writeHead(200, { "content-type": "application/json" });
|
||||||
|
response.end(JSON.stringify({
|
||||||
|
contacts: logbook.length,
|
||||||
|
confirmed: list.reduce((total, band) => total + band.confirmed, 0),
|
||||||
|
bands: list,
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (tail === "/export.cbr") {
|
||||||
|
const contest = url.searchParams.get("contest");
|
||||||
|
const entered = contest ? logbook.filter((qso) => qso.contest_id === contest) : logbook;
|
||||||
|
response.writeHead(200, { "content-type": "text/plain" });
|
||||||
|
response.end(`START-OF-LOG: 3.0\nCONTEST: ${contest ?? ""}\n`
|
||||||
|
+ entered.map((qso) => `QSO: 14200 PH ${qso.call}\n`).join("")
|
||||||
|
+ "END-OF-LOG:\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.method === "PUT") {
|
||||||
|
const raw = await new Promise((resolve) => {
|
||||||
|
let text = "";
|
||||||
|
request.on("data", (chunk) => { text += chunk; });
|
||||||
|
request.on("end", () => resolve(text));
|
||||||
|
});
|
||||||
|
const input = JSON.parse(raw);
|
||||||
|
const id = tail.replace("/", "");
|
||||||
|
const held = logbook.find((qso) => qso.id === id);
|
||||||
|
if (held) {
|
||||||
|
Object.assign(held, input, { id, confirmed: input.qsl_rcvd === "Y" });
|
||||||
|
}
|
||||||
|
response.writeHead(200, { "content-type": "application/json" });
|
||||||
|
response.end(JSON.stringify(held ?? {}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (tail === "/export.adi") {
|
||||||
|
const body = logbook
|
||||||
|
.map((qso) => `<CALL:${qso.call.length}>${qso.call}<EOR>\n`)
|
||||||
|
.join("");
|
||||||
|
response.writeHead(200, { "content-type": "text/plain" });
|
||||||
|
response.end(body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.method === "POST" && tail === "") {
|
||||||
|
const raw = await new Promise((resolve) => {
|
||||||
|
let text = "";
|
||||||
|
request.on("data", (chunk) => { text += chunk; });
|
||||||
|
request.on("end", () => resolve(text));
|
||||||
|
});
|
||||||
|
const input = JSON.parse(raw);
|
||||||
|
const qso = {
|
||||||
|
id: `qso-${String(logbook.length + 1)}`,
|
||||||
|
started_at: input.started_at ?? new Date().toISOString(),
|
||||||
|
call: String(input.call).toUpperCase(),
|
||||||
|
freq_hz: input.freq_hz,
|
||||||
|
band: "20m",
|
||||||
|
mode: input.mode,
|
||||||
|
submode: input.submode,
|
||||||
|
rst_sent: input.rst_sent,
|
||||||
|
rst_rcvd: input.rst_rcvd,
|
||||||
|
gridsquare: input.gridsquare ? String(input.gridsquare).toUpperCase() : null,
|
||||||
|
my_rig: input.my_rig,
|
||||||
|
operator: input.operator,
|
||||||
|
contest_id: input.contest_id ?? null,
|
||||||
|
stx: input.stx ?? null,
|
||||||
|
srx: input.srx ?? null,
|
||||||
|
confirmed: input.qsl_rcvd === "Y" || input.lotw_qsl_rcvd === "Y",
|
||||||
|
};
|
||||||
|
logbook.unshift(qso);
|
||||||
|
response.writeHead(200, { "content-type": "application/json" });
|
||||||
|
response.end(JSON.stringify(qso));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.method === "DELETE") {
|
||||||
|
const id = tail.replace("/", "");
|
||||||
|
const index = logbook.findIndex((qso) => qso.id === id);
|
||||||
|
if (index >= 0) logbook.splice(index, 1);
|
||||||
|
response.writeHead(200, { "content-type": "application/json" });
|
||||||
|
response.end(JSON.stringify({ deleted: index >= 0 }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const wanted = (url.searchParams.get("call") ?? "").toUpperCase();
|
||||||
|
const matching = wanted ? logbook.filter((qso) => qso.call.includes(wanted)) : logbook;
|
||||||
|
response.writeHead(200, { "content-type": "application/json" });
|
||||||
|
response.end(JSON.stringify({ total: logbook.length, qsos: matching }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Background decode: the panel reads its config, its status, and the
|
// Background decode: the panel reads its config, its status, and the
|
||||||
// bookmark list, and writes the config back.
|
// bookmark list, and writes the config back.
|
||||||
if (url.pathname.startsWith("/background-decode/")) {
|
if (url.pathname.startsWith("/background-decode/")) {
|
||||||
|
|||||||
@@ -138,6 +138,17 @@ pub(crate) async fn statistics_index(req: HttpRequest) -> impl Responder {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[get("/logbook")]
|
||||||
|
pub(crate) async fn logbook_index(req: HttpRequest) -> impl Responder {
|
||||||
|
let c = gz_index_html();
|
||||||
|
static_asset_response(
|
||||||
|
&req,
|
||||||
|
"text/html; charset=utf-8",
|
||||||
|
c,
|
||||||
|
AssetCaching::Revalidate,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[get("/satellites")]
|
#[get("/satellites")]
|
||||||
pub(crate) async fn satellites_index(req: HttpRequest) -> impl Responder {
|
pub(crate) async fn satellites_index(req: HttpRequest) -> impl Responder {
|
||||||
let c = gz_index_html();
|
let c = gz_index_html();
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use std::sync::Arc;
|
|||||||
use actix_web::Error;
|
use actix_web::Error;
|
||||||
use actix_web::{delete, get, post, put, web, HttpRequest, HttpResponse};
|
use actix_web::{delete, get, post, put, web, HttpRequest, HttpResponse};
|
||||||
|
|
||||||
use super::{no_cache_response, request_accepts_html, require_control};
|
use super::{no_cache_response, request_accepts_html, require_write};
|
||||||
use crate::server::status;
|
use crate::server::status;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -37,6 +37,7 @@ pub struct BookmarkInput {
|
|||||||
pub comment: Option<String>,
|
pub comment: Option<String>,
|
||||||
pub category: Option<String>,
|
pub category: Option<String>,
|
||||||
pub decoders: Option<Vec<String>>,
|
pub decoders: Option<Vec<String>>,
|
||||||
|
pub dig_sideband: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A bookmark with its owning scope tag for the list response.
|
/// A bookmark with its owning scope tag for the list response.
|
||||||
@@ -88,6 +89,17 @@ fn normalize_bookmark_locator(locator: Option<String>) -> Option<String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Normalize a DIG sideband override to `auto`/`usb`/`lsb`, or `None` when
|
||||||
|
/// absent or unrecognized (leaves the current policy untouched on apply).
|
||||||
|
fn normalize_dig_sideband(value: Option<String>) -> Option<String> {
|
||||||
|
value.and_then(|v| match v.trim().to_lowercase().as_str() {
|
||||||
|
"auto" => Some("auto".to_string()),
|
||||||
|
"usb" => Some("usb".to_string()),
|
||||||
|
"lsb" => Some("lsb".to_string()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Endpoints
|
// Endpoints
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -165,7 +177,7 @@ pub async fn create_bookmark(
|
|||||||
body: web::Json<BookmarkInput>,
|
body: web::Json<BookmarkInput>,
|
||||||
auth_state: web::Data<crate::server::auth::AuthState>,
|
auth_state: web::Data<crate::server::auth::AuthState>,
|
||||||
) -> Result<HttpResponse, Error> {
|
) -> Result<HttpResponse, Error> {
|
||||||
require_control(&req, &auth_state)?;
|
require_write(&req, &auth_state)?;
|
||||||
let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
|
let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
|
||||||
if store.freq_taken(body.freq_hz, None) {
|
if store.freq_taken(body.freq_hz, None) {
|
||||||
return Err(actix_web::error::ErrorConflict(
|
return Err(actix_web::error::ErrorConflict(
|
||||||
@@ -182,6 +194,7 @@ pub async fn create_bookmark(
|
|||||||
comment: body.comment.clone().unwrap_or_default(),
|
comment: body.comment.clone().unwrap_or_default(),
|
||||||
category: body.category.clone().unwrap_or_default(),
|
category: body.category.clone().unwrap_or_default(),
|
||||||
decoders: body.decoders.clone().unwrap_or_default(),
|
decoders: body.decoders.clone().unwrap_or_default(),
|
||||||
|
dig_sideband: normalize_dig_sideband(body.dig_sideband.clone()),
|
||||||
};
|
};
|
||||||
if store.insert(&bm) {
|
if store.insert(&bm) {
|
||||||
Ok(HttpResponse::Created().json(bm))
|
Ok(HttpResponse::Created().json(bm))
|
||||||
@@ -201,7 +214,7 @@ pub async fn update_bookmark(
|
|||||||
body: web::Json<BookmarkInput>,
|
body: web::Json<BookmarkInput>,
|
||||||
auth_state: web::Data<crate::server::auth::AuthState>,
|
auth_state: web::Data<crate::server::auth::AuthState>,
|
||||||
) -> Result<HttpResponse, Error> {
|
) -> Result<HttpResponse, Error> {
|
||||||
require_control(&req, &auth_state)?;
|
require_write(&req, &auth_state)?;
|
||||||
let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
|
let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
|
||||||
let id = path.into_inner();
|
let id = path.into_inner();
|
||||||
if store.freq_taken(body.freq_hz, Some(&id)) {
|
if store.freq_taken(body.freq_hz, Some(&id)) {
|
||||||
@@ -219,6 +232,7 @@ pub async fn update_bookmark(
|
|||||||
comment: body.comment.clone().unwrap_or_default(),
|
comment: body.comment.clone().unwrap_or_default(),
|
||||||
category: body.category.clone().unwrap_or_default(),
|
category: body.category.clone().unwrap_or_default(),
|
||||||
decoders: body.decoders.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) {
|
if store.upsert(&id, &bm) {
|
||||||
Ok(HttpResponse::Ok().json(bm))
|
Ok(HttpResponse::Ok().json(bm))
|
||||||
@@ -235,7 +249,7 @@ pub async fn delete_bookmark(
|
|||||||
query: web::Query<BookmarkScopeQuery>,
|
query: web::Query<BookmarkScopeQuery>,
|
||||||
auth_state: web::Data<crate::server::auth::AuthState>,
|
auth_state: web::Data<crate::server::auth::AuthState>,
|
||||||
) -> Result<HttpResponse, Error> {
|
) -> Result<HttpResponse, Error> {
|
||||||
require_control(&req, &auth_state)?;
|
require_write(&req, &auth_state)?;
|
||||||
let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
|
let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
|
||||||
let id = path.into_inner();
|
let id = path.into_inner();
|
||||||
if store.remove(&id) {
|
if store.remove(&id) {
|
||||||
@@ -253,7 +267,7 @@ pub async fn batch_delete_bookmarks(
|
|||||||
query: web::Query<BookmarkScopeQuery>,
|
query: web::Query<BookmarkScopeQuery>,
|
||||||
auth_state: web::Data<crate::server::auth::AuthState>,
|
auth_state: web::Data<crate::server::auth::AuthState>,
|
||||||
) -> Result<HttpResponse, Error> {
|
) -> Result<HttpResponse, Error> {
|
||||||
require_control(&req, &auth_state)?;
|
require_write(&req, &auth_state)?;
|
||||||
let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
|
let store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
|
||||||
let mut deleted = 0usize;
|
let mut deleted = 0usize;
|
||||||
for id in &body.ids {
|
for id in &body.ids {
|
||||||
@@ -272,7 +286,7 @@ pub async fn batch_move_bookmarks(
|
|||||||
query: web::Query<BookmarkScopeQuery>,
|
query: web::Query<BookmarkScopeQuery>,
|
||||||
auth_state: web::Data<crate::server::auth::AuthState>,
|
auth_state: web::Data<crate::server::auth::AuthState>,
|
||||||
) -> Result<HttpResponse, Error> {
|
) -> Result<HttpResponse, Error> {
|
||||||
require_control(&req, &auth_state)?;
|
require_write(&req, &auth_state)?;
|
||||||
let from_store = resolve_bookmark_store(query.scope.as_deref(), store_map.get_ref());
|
let 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 to_store = resolve_bookmark_store(Some(body.to.as_str()), store_map.get_ref());
|
||||||
let mut moved = 0usize;
|
let mut moved = 0usize;
|
||||||
|
|||||||
@@ -0,0 +1,456 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
//! The logbook over HTTP: read, write, import, export, and the clock a contact
|
||||||
|
//! is stamped from.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use actix_web::http::header;
|
||||||
|
use actix_web::{delete, get, post, put, web, HttpRequest, HttpResponse, Responder};
|
||||||
|
use chrono::Utc;
|
||||||
|
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 crate::server::auth::AuthState;
|
||||||
|
|
||||||
|
/// What a contact looks like on the wire.
|
||||||
|
///
|
||||||
|
/// The band is sent although it is derived, because every reader of the log
|
||||||
|
/// wants it and none of them should have to carry the band plan to get it.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct QsoView {
|
||||||
|
#[serde(flatten)]
|
||||||
|
qso: Qso,
|
||||||
|
band: Option<&'static str>,
|
||||||
|
/// Whether the other station has confirmed, from whichever bureau answered.
|
||||||
|
confirmed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Qso> for QsoView {
|
||||||
|
fn from(qso: Qso) -> Self {
|
||||||
|
Self {
|
||||||
|
band: qso.band(),
|
||||||
|
confirmed: qso.is_confirmed(),
|
||||||
|
qso,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A contact as the panel sends it.
|
||||||
|
///
|
||||||
|
/// Times come from the server (`started_at` omitted means "now"), so a browser
|
||||||
|
/// with a wrong clock cannot write a wrong time into the log.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct QsoInput {
|
||||||
|
#[serde(default)]
|
||||||
|
id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
started_at: Option<chrono::DateTime<Utc>>,
|
||||||
|
#[serde(default)]
|
||||||
|
ended_at: Option<chrono::DateTime<Utc>>,
|
||||||
|
call: String,
|
||||||
|
freq_hz: u64,
|
||||||
|
mode: String,
|
||||||
|
#[serde(default)]
|
||||||
|
submode: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
rst_sent: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
rst_rcvd: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
gridsquare: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
qth: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
comment: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
tx_pwr_w: Option<f64>,
|
||||||
|
#[serde(default)]
|
||||||
|
station_callsign: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
operator: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
my_gridsquare: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
my_rig: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
rig_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
contest_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
stx: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
stx_string: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
srx: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
srx_string: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
cqz: Option<u8>,
|
||||||
|
#[serde(default)]
|
||||||
|
ituz: Option<u8>,
|
||||||
|
#[serde(default)]
|
||||||
|
qsl_sent: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
qsl_rcvd: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
lotw_qsl_sent: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
lotw_qsl_rcvd: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
eqsl_qsl_sent: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
eqsl_qsl_rcvd: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ADIF's QSL states are single letters. Anything else is refused rather than
|
||||||
|
/// written, or a log would grow states no other program can read.
|
||||||
|
fn qsl_state(value: Option<String>) -> Option<String> {
|
||||||
|
let state = blank_to_none(value)?.to_uppercase();
|
||||||
|
matches!(state.as_str(), "Y" | "N" | "R" | "I" | "Q" | "V").then_some(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn blank_to_none(value: Option<String>) -> Option<String> {
|
||||||
|
value
|
||||||
|
.map(|text| text.trim().to_string())
|
||||||
|
.filter(|text| !text.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QsoInput {
|
||||||
|
fn into_qso(self, existing: Option<Qso>) -> Result<Qso, String> {
|
||||||
|
let call = self.call.trim();
|
||||||
|
if call.is_empty() {
|
||||||
|
return Err("a contact needs a callsign".to_string());
|
||||||
|
}
|
||||||
|
if self.mode.trim().is_empty() {
|
||||||
|
return Err("a contact needs a mode".to_string());
|
||||||
|
}
|
||||||
|
let started_at = self
|
||||||
|
.started_at
|
||||||
|
.or(existing.as_ref().map(|q| q.started_at))
|
||||||
|
.unwrap_or_else(Utc::now);
|
||||||
|
let mut qso = Qso::new(
|
||||||
|
self.id
|
||||||
|
.or(existing.as_ref().map(|q| q.id.clone()))
|
||||||
|
.unwrap_or_else(trx_logbook::new_id),
|
||||||
|
started_at,
|
||||||
|
call,
|
||||||
|
self.freq_hz,
|
||||||
|
&self.mode,
|
||||||
|
);
|
||||||
|
qso.ended_at = self.ended_at;
|
||||||
|
qso.submode = blank_to_none(self.submode).map(|s| s.to_uppercase());
|
||||||
|
qso.rst_sent = blank_to_none(self.rst_sent);
|
||||||
|
qso.rst_rcvd = blank_to_none(self.rst_rcvd);
|
||||||
|
qso.gridsquare = blank_to_none(self.gridsquare).map(|g| g.to_uppercase());
|
||||||
|
qso.name = blank_to_none(self.name);
|
||||||
|
qso.qth = blank_to_none(self.qth);
|
||||||
|
qso.comment = blank_to_none(self.comment);
|
||||||
|
qso.tx_pwr_w = self.tx_pwr_w;
|
||||||
|
qso.station_callsign = blank_to_none(self.station_callsign).map(|c| c.to_uppercase());
|
||||||
|
qso.operator = blank_to_none(self.operator).map(|c| c.to_uppercase());
|
||||||
|
qso.my_gridsquare = blank_to_none(self.my_gridsquare).map(|g| g.to_uppercase());
|
||||||
|
qso.my_rig = blank_to_none(self.my_rig);
|
||||||
|
qso.rig_id = blank_to_none(self.rig_id);
|
||||||
|
qso.contest_id = blank_to_none(self.contest_id).map(|c| c.to_uppercase());
|
||||||
|
qso.stx = self.stx;
|
||||||
|
qso.stx_string = blank_to_none(self.stx_string);
|
||||||
|
qso.srx = self.srx;
|
||||||
|
qso.srx_string = blank_to_none(self.srx_string);
|
||||||
|
qso.cqz = self.cqz;
|
||||||
|
qso.ituz = self.ituz;
|
||||||
|
qso.qsl_sent = qsl_state(self.qsl_sent);
|
||||||
|
qso.qsl_rcvd = qsl_state(self.qsl_rcvd);
|
||||||
|
qso.lotw_qsl_sent = qsl_state(self.lotw_qsl_sent);
|
||||||
|
qso.lotw_qsl_rcvd = qsl_state(self.lotw_qsl_rcvd);
|
||||||
|
qso.eqsl_qsl_sent = qsl_state(self.eqsl_qsl_sent);
|
||||||
|
qso.eqsl_qsl_rcvd = qsl_state(self.eqsl_qsl_rcvd);
|
||||||
|
// Whatever an imported contact carried that this application does not
|
||||||
|
// model stays with it through an edit.
|
||||||
|
if let Some(existing) = existing {
|
||||||
|
qso.extra = existing.extra;
|
||||||
|
}
|
||||||
|
Ok(qso)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn book(logbook: &web::Data<Arc<Logbook>>) -> Arc<Logbook> {
|
||||||
|
logbook.get_ref().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The rig's name as the operator knows it, which is what belongs in the log —
|
||||||
|
/// falling back to its id, since a log entry naming nothing is worse.
|
||||||
|
fn rig_display_name(context: &trx_frontend::FrontendRuntimeContext, rig_id: &str) -> String {
|
||||||
|
context
|
||||||
|
.routing
|
||||||
|
.remote_rigs
|
||||||
|
.lock()
|
||||||
|
.ok()
|
||||||
|
.and_then(|rigs| {
|
||||||
|
rigs.iter()
|
||||||
|
.find(|rig| rig.rig_id == rig_id)
|
||||||
|
.and_then(|rig| rig.display_name.clone())
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| rig_id.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/logbook` — contacts, newest first, filtered.
|
||||||
|
#[get("/api/logbook")]
|
||||||
|
pub async fn list_qsos(
|
||||||
|
query: web::Query<LogQuery>,
|
||||||
|
logbook: web::Data<Arc<Logbook>>,
|
||||||
|
) -> impl Responder {
|
||||||
|
let query = query.into_inner();
|
||||||
|
let total = book(&logbook).count();
|
||||||
|
let qsos: Vec<QsoView> = book(&logbook)
|
||||||
|
.query(&query)
|
||||||
|
.into_iter()
|
||||||
|
.map(QsoView::from)
|
||||||
|
.collect();
|
||||||
|
HttpResponse::Ok().json(serde_json::json!({ "total": total, "qsos": qsos }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/logbook` — write a contact.
|
||||||
|
#[post("/api/logbook")]
|
||||||
|
pub async fn add_qso(
|
||||||
|
req: HttpRequest,
|
||||||
|
input: web::Json<QsoInput>,
|
||||||
|
logbook: web::Data<Arc<Logbook>>,
|
||||||
|
auth_state: web::Data<AuthState>,
|
||||||
|
) -> Result<HttpResponse, actix_web::Error> {
|
||||||
|
require_write(&req, auth_state.get_ref())?;
|
||||||
|
let qso = match input.into_inner().into_qso(None) {
|
||||||
|
Ok(qso) => qso,
|
||||||
|
Err(reason) => {
|
||||||
|
return Ok(HttpResponse::BadRequest().json(serde_json::json!({ "error": reason })))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match book(&logbook).put(qso) {
|
||||||
|
Ok(saved) => Ok(HttpResponse::Ok().json(QsoView::from(saved))),
|
||||||
|
Err(err) => Ok(HttpResponse::InternalServerError()
|
||||||
|
.json(serde_json::json!({ "error": err.to_string() }))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `PUT /api/logbook/{id}` — change a contact.
|
||||||
|
#[put("/api/logbook/{id}")]
|
||||||
|
pub async fn edit_qso(
|
||||||
|
req: HttpRequest,
|
||||||
|
path: web::Path<String>,
|
||||||
|
input: web::Json<QsoInput>,
|
||||||
|
logbook: web::Data<Arc<Logbook>>,
|
||||||
|
auth_state: web::Data<AuthState>,
|
||||||
|
) -> Result<HttpResponse, actix_web::Error> {
|
||||||
|
require_write(&req, auth_state.get_ref())?;
|
||||||
|
let id = path.into_inner();
|
||||||
|
let Some(existing) = book(&logbook).get(&id) else {
|
||||||
|
return Ok(HttpResponse::NotFound().json(serde_json::json!({ "error": "no such contact" })));
|
||||||
|
};
|
||||||
|
let mut input = input.into_inner();
|
||||||
|
input.id = Some(id);
|
||||||
|
let qso = match input.into_qso(Some(existing)) {
|
||||||
|
Ok(qso) => qso,
|
||||||
|
Err(reason) => {
|
||||||
|
return Ok(HttpResponse::BadRequest().json(serde_json::json!({ "error": reason })))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match book(&logbook).put(qso) {
|
||||||
|
Ok(saved) => Ok(HttpResponse::Ok().json(QsoView::from(saved))),
|
||||||
|
Err(err) => Ok(HttpResponse::InternalServerError()
|
||||||
|
.json(serde_json::json!({ "error": err.to_string() }))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `DELETE /api/logbook/{id}` — forget a contact.
|
||||||
|
#[delete("/api/logbook/{id}")]
|
||||||
|
pub async fn delete_qso(
|
||||||
|
req: HttpRequest,
|
||||||
|
path: web::Path<String>,
|
||||||
|
logbook: web::Data<Arc<Logbook>>,
|
||||||
|
auth_state: web::Data<AuthState>,
|
||||||
|
) -> Result<HttpResponse, actix_web::Error> {
|
||||||
|
require_write(&req, auth_state.get_ref())?;
|
||||||
|
match book(&logbook).delete(&path.into_inner()) {
|
||||||
|
Ok(true) => Ok(HttpResponse::Ok().json(serde_json::json!({ "deleted": true }))),
|
||||||
|
Ok(false) => {
|
||||||
|
Ok(HttpResponse::NotFound().json(serde_json::json!({ "error": "no such contact" })))
|
||||||
|
}
|
||||||
|
Err(err) => Ok(HttpResponse::InternalServerError()
|
||||||
|
.json(serde_json::json!({ "error": err.to_string() }))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/logbook/export.adi` — ADIF, honouring the same filters as a read.
|
||||||
|
#[get("/api/logbook/export.adi")]
|
||||||
|
pub async fn export_adi(
|
||||||
|
query: web::Query<LogQuery>,
|
||||||
|
logbook: web::Data<Arc<Logbook>>,
|
||||||
|
) -> impl Responder {
|
||||||
|
let text = book(&logbook).export_adi(&query.into_inner(), env!("CARGO_PKG_VERSION"));
|
||||||
|
let filename = format!("trx-rs-log-{}.adi", Utc::now().format("%Y%m%d"));
|
||||||
|
HttpResponse::Ok()
|
||||||
|
.insert_header((header::CONTENT_TYPE, "text/plain; charset=utf-8"))
|
||||||
|
.insert_header((
|
||||||
|
header::CONTENT_DISPOSITION,
|
||||||
|
format!("attachment; filename=\"{filename}\""),
|
||||||
|
))
|
||||||
|
.body(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/logbook/import` — take an ADIF file, skipping what is already held.
|
||||||
|
#[post("/api/logbook/import")]
|
||||||
|
pub async fn import_adi(
|
||||||
|
req: HttpRequest,
|
||||||
|
body: web::Bytes,
|
||||||
|
logbook: web::Data<Arc<Logbook>>,
|
||||||
|
auth_state: web::Data<AuthState>,
|
||||||
|
) -> Result<HttpResponse, actix_web::Error> {
|
||||||
|
require_write(&req, auth_state.get_ref())?;
|
||||||
|
match book(&logbook).import_adi(&body) {
|
||||||
|
Ok(outcome) => Ok(HttpResponse::Ok().json(outcome)),
|
||||||
|
Err(err) => Ok(HttpResponse::InternalServerError()
|
||||||
|
.json(serde_json::json!({ "error": err.to_string() }))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/logbook/export.cbr` — a contest entry, in the only format sponsors
|
||||||
|
/// take.
|
||||||
|
///
|
||||||
|
/// The header cannot be worked out from the log — how many operators, how much
|
||||||
|
/// power — so it comes as query parameters from the operator.
|
||||||
|
#[get("/api/logbook/export.cbr")]
|
||||||
|
pub async fn export_cabrillo(
|
||||||
|
query: web::Query<LogQuery>,
|
||||||
|
header: web::Query<trx_logbook::CabrilloHeader>,
|
||||||
|
logbook: web::Data<Arc<Logbook>>,
|
||||||
|
) -> impl Responder {
|
||||||
|
let text = book(&logbook).export_cabrillo(&query.into_inner(), &header.into_inner());
|
||||||
|
let filename = format!("trx-rs-contest-{}.cbr", Utc::now().format("%Y%m%d"));
|
||||||
|
HttpResponse::Ok()
|
||||||
|
.insert_header((header::CONTENT_TYPE, "text/plain; charset=utf-8"))
|
||||||
|
.insert_header((
|
||||||
|
header::CONTENT_DISPOSITION,
|
||||||
|
format!("attachment; filename=\"{filename}\""),
|
||||||
|
))
|
||||||
|
.body(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/logbook/statistics` — what has been worked and confirmed, per band.
|
||||||
|
#[get("/api/logbook/statistics")]
|
||||||
|
pub async fn statistics(logbook: web::Data<Arc<Logbook>>) -> impl Responder {
|
||||||
|
let bands = book(&logbook).band_statistics();
|
||||||
|
let contacts: usize = bands.iter().map(|band| band.contacts).sum();
|
||||||
|
let confirmed: usize = bands.iter().map(|band| band.confirmed).sum();
|
||||||
|
HttpResponse::Ok().json(serde_json::json!({
|
||||||
|
"contacts": contacts,
|
||||||
|
"confirmed": confirmed,
|
||||||
|
"bands": bands,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/logbook/worked/{call}` — which bands and modes this station has been
|
||||||
|
/// worked on.
|
||||||
|
#[get("/api/logbook/worked/{call}")]
|
||||||
|
pub async fn worked_before(
|
||||||
|
path: web::Path<String>,
|
||||||
|
logbook: web::Data<Arc<Logbook>>,
|
||||||
|
) -> impl Responder {
|
||||||
|
let call = path.into_inner();
|
||||||
|
let worked: Vec<serde_json::Value> = book(&logbook)
|
||||||
|
.worked_before(&call)
|
||||||
|
.into_iter()
|
||||||
|
.map(|(band, mode)| serde_json::json!({ "band": band, "mode": mode }))
|
||||||
|
.collect();
|
||||||
|
HttpResponse::Ok().json(serde_json::json!({
|
||||||
|
"call": trx_logbook::qso::normalize_call(&call),
|
||||||
|
"worked": worked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/logbook/now` — the clock a contact is stamped from.
|
||||||
|
///
|
||||||
|
/// The panel shows the operator when their browser disagrees with it, rather
|
||||||
|
/// than logging a time nobody expected.
|
||||||
|
#[get("/api/logbook/now")]
|
||||||
|
pub async fn server_now() -> impl Responder {
|
||||||
|
let now = Utc::now();
|
||||||
|
HttpResponse::Ok().json(serde_json::json!({
|
||||||
|
"utc": now.to_rfc3339(),
|
||||||
|
"epoch_ms": now.timestamp_millis(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/logbook/prefill` — the six fields an entry opens with.
|
||||||
|
///
|
||||||
|
/// Worked out on the server because that is where the rig state, the station
|
||||||
|
/// identity and the clock all are; the panel would otherwise have to assemble
|
||||||
|
/// them from three places and would still not have the clock.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct PrefillQuery {
|
||||||
|
/// The rig the entry is for.
|
||||||
|
#[serde(default)]
|
||||||
|
pub remote: Option<String>,
|
||||||
|
/// The decoder a decode row came from, which answers for the mode when the
|
||||||
|
/// rig only says `DIG`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub decoder: Option<String>,
|
||||||
|
/// A callsign and locator carried over from a decode row.
|
||||||
|
#[serde(default)]
|
||||||
|
pub call: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub gridsquare: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/api/logbook/prefill")]
|
||||||
|
pub async fn prefill(
|
||||||
|
query: web::Query<PrefillQuery>,
|
||||||
|
context: web::Data<Arc<trx_frontend::FrontendRuntimeContext>>,
|
||||||
|
state: web::Data<tokio::sync::watch::Receiver<trx_core::RigState>>,
|
||||||
|
) -> impl Responder {
|
||||||
|
let query = query.into_inner();
|
||||||
|
let rig_id = query
|
||||||
|
.remote
|
||||||
|
.clone()
|
||||||
|
.filter(|id| !id.is_empty())
|
||||||
|
.or_else(|| active_rig_id_from_context(context.get_ref()));
|
||||||
|
let rig_state = rig_id
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|id| context.rig_state_rx(id))
|
||||||
|
.unwrap_or_else(|| state.get_ref().clone())
|
||||||
|
.borrow()
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
let freq_hz = rig_state.status.freq.hz;
|
||||||
|
let rig_mode = format!("{:?}", rig_state.status.mode);
|
||||||
|
// The rig cannot answer for DIG: a rig in DIG is in FT8 or FT4 depending on
|
||||||
|
// which decoder is running, so the decoder is asked first when there is one.
|
||||||
|
let (mode, submode) = query
|
||||||
|
.decoder
|
||||||
|
.as_deref()
|
||||||
|
.and_then(mode_for_decoder)
|
||||||
|
.or_else(|| adif_mode_for_rig_mode(&rig_mode))
|
||||||
|
.map_or((String::new(), None), |(mode, submode)| {
|
||||||
|
(mode.to_string(), submode.map(str::to_string))
|
||||||
|
});
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
HttpResponse::Ok().json(serde_json::json!({
|
||||||
|
"started_at": now.to_rfc3339(),
|
||||||
|
"epoch_ms": now.timestamp_millis(),
|
||||||
|
"freq_hz": freq_hz,
|
||||||
|
"band": band_for_hz(freq_hz),
|
||||||
|
"mode": mode,
|
||||||
|
"submode": submode,
|
||||||
|
"rig_id": rig_id,
|
||||||
|
"my_rig": rig_id.as_deref().map(|id| rig_display_name(context.get_ref(), id)),
|
||||||
|
"call": query.call.map(|c| trx_logbook::qso::normalize_call(&c)),
|
||||||
|
"gridsquare": query.gridsquare.map(|g| g.trim().to_uppercase()),
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
mod assets;
|
mod assets;
|
||||||
mod bookmarks;
|
mod bookmarks;
|
||||||
mod decoder;
|
mod decoder;
|
||||||
|
mod logbook;
|
||||||
pub mod recorder;
|
pub mod recorder;
|
||||||
pub mod rig;
|
pub mod rig;
|
||||||
mod sse;
|
mod sse;
|
||||||
@@ -390,16 +391,20 @@ fn gz_cache_entry(src: &[u8], name: &str) -> GzCacheEntry {
|
|||||||
GzCacheEntry { gz, br, etag }
|
GzCacheEntry { gz, br, etag }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn require_control(
|
fn require_write(
|
||||||
req: &HttpRequest,
|
req: &HttpRequest,
|
||||||
auth_state: &crate::server::auth::AuthState,
|
auth_state: &crate::server::auth::AuthState,
|
||||||
) -> Result<(), actix_web::Error> {
|
) -> Result<(), actix_web::Error> {
|
||||||
if !auth_state.config.enabled {
|
if !auth_state.config.enabled
|
||||||
return Ok(());
|
|| crate::server::auth::session_grants(
|
||||||
}
|
req,
|
||||||
match crate::server::auth::get_session_role(req, auth_state) {
|
auth_state,
|
||||||
Some(crate::server::auth::AuthRole::Control) => Ok(()),
|
crate::server::auth::AuthRole::Write,
|
||||||
_ => Err(actix_web::error::ErrorForbidden("control role required")),
|
)
|
||||||
|
{
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(actix_web::error::ErrorForbidden("write role required"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -590,6 +595,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
|||||||
.service(rig::set_sdr_agc)
|
.service(rig::set_sdr_agc)
|
||||||
.service(rig::set_sdr_squelch)
|
.service(rig::set_sdr_squelch)
|
||||||
.service(rig::set_sdr_noise_blanker)
|
.service(rig::set_sdr_noise_blanker)
|
||||||
|
.service(rig::set_sdr_dig_sideband)
|
||||||
.service(rig::set_wfm_deemphasis)
|
.service(rig::set_wfm_deemphasis)
|
||||||
.service(rig::set_wfm_stereo)
|
.service(rig::set_wfm_stereo)
|
||||||
.service(rig::set_wfm_denoise)
|
.service(rig::set_wfm_denoise)
|
||||||
@@ -664,6 +670,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
|||||||
.service(assets::recorder_index)
|
.service(assets::recorder_index)
|
||||||
.service(assets::settings_index)
|
.service(assets::settings_index)
|
||||||
.service(assets::about_index)
|
.service(assets::about_index)
|
||||||
|
.service(assets::logbook_index)
|
||||||
.service(assets::satellites_index)
|
.service(assets::satellites_index)
|
||||||
.service(assets::statistics_index)
|
.service(assets::statistics_index)
|
||||||
.service(assets::bookmarks_index)
|
.service(assets::bookmarks_index)
|
||||||
@@ -706,7 +713,24 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
|||||||
// Auth endpoints
|
// Auth endpoints
|
||||||
.service(crate::server::auth::login)
|
.service(crate::server::auth::login)
|
||||||
.service(crate::server::auth::logout)
|
.service(crate::server::auth::logout)
|
||||||
.service(crate::server::auth::session_status);
|
.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)
|
||||||
|
.service(logbook::edit_qso)
|
||||||
|
.service(logbook::delete_qso)
|
||||||
|
.service(logbook::export_adi)
|
||||||
|
.service(logbook::export_cabrillo)
|
||||||
|
.service(logbook::statistics)
|
||||||
|
.service(logbook::import_adi)
|
||||||
|
.service(logbook::worked_before)
|
||||||
|
.service(logbook::server_now)
|
||||||
|
.service(logbook::prefill);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -935,6 +959,334 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Auth off: every session may write without an account.
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
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
|
||||||
|
/// ADIF export that another logger could read.
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn a_contact_written_over_http_is_kept_and_exported() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let logbook = std::sync::Arc::new(
|
||||||
|
trx_logbook::Logbook::open(&dir.path().join("logbook.jsonl")).expect("open"),
|
||||||
|
);
|
||||||
|
let app = actix_test::init_service(
|
||||||
|
App::new()
|
||||||
|
.app_data(web::Data::new(logbook.clone()))
|
||||||
|
.app_data(web::Data::new(auth_state_disabled()))
|
||||||
|
.service(logbook::add_qso)
|
||||||
|
.service(logbook::list_qsos)
|
||||||
|
.service(logbook::export_adi)
|
||||||
|
.service(logbook::worked_before),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let response = actix_test::call_service(
|
||||||
|
&app,
|
||||||
|
actix_test::TestRequest::post()
|
||||||
|
.uri("/api/logbook")
|
||||||
|
.set_json(serde_json::json!({
|
||||||
|
"call": "sp2sjg",
|
||||||
|
"freq_hz": 14_074_000_u64,
|
||||||
|
"mode": "FT8",
|
||||||
|
"rst_sent": "-07",
|
||||||
|
"gridsquare": "jo94",
|
||||||
|
}))
|
||||||
|
.to_request(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), 200);
|
||||||
|
let written: serde_json::Value = actix_test::read_body_json(response).await;
|
||||||
|
// Normalised on the way in, and the band worked out from the dial.
|
||||||
|
assert_eq!(written["call"], "SP2SJG");
|
||||||
|
assert_eq!(written["gridsquare"], "JO94");
|
||||||
|
assert_eq!(written["band"], "20m");
|
||||||
|
|
||||||
|
let listed: serde_json::Value = actix_test::call_and_read_body_json(
|
||||||
|
&app,
|
||||||
|
actix_test::TestRequest::get()
|
||||||
|
.uri("/api/logbook")
|
||||||
|
.to_request(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(listed["total"], 1);
|
||||||
|
assert_eq!(listed["qsos"][0]["call"], "SP2SJG");
|
||||||
|
|
||||||
|
let worked: serde_json::Value = actix_test::call_and_read_body_json(
|
||||||
|
&app,
|
||||||
|
actix_test::TestRequest::get()
|
||||||
|
.uri("/api/logbook/worked/SP2SJG")
|
||||||
|
.to_request(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(worked["worked"][0]["band"], "20m");
|
||||||
|
|
||||||
|
let exported = actix_test::call_and_read_body(
|
||||||
|
&app,
|
||||||
|
actix_test::TestRequest::get()
|
||||||
|
.uri("/api/logbook/export.adi")
|
||||||
|
.to_request(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let text = String::from_utf8_lossy(&exported);
|
||||||
|
assert!(text.contains("<CALL:6>SP2SJG"), "{text}");
|
||||||
|
assert!(text.contains("<BAND:3>20m"), "{text}");
|
||||||
|
assert!(text.contains("<EOR>"), "{text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A contest entry comes out in Cabrillo, holding that contest's contacts
|
||||||
|
/// and no others, with the header the operator gave.
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn a_contest_entry_is_exported_as_cabrillo() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let logbook = std::sync::Arc::new(
|
||||||
|
trx_logbook::Logbook::open(&dir.path().join("logbook.jsonl")).expect("open"),
|
||||||
|
);
|
||||||
|
let app = actix_test::init_service(
|
||||||
|
App::new()
|
||||||
|
.app_data(web::Data::new(logbook.clone()))
|
||||||
|
.app_data(web::Data::new(auth_state_disabled()))
|
||||||
|
.service(logbook::add_qso)
|
||||||
|
.service(logbook::export_cabrillo)
|
||||||
|
.service(logbook::statistics),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
for (call, contest, serial, confirmed) in [
|
||||||
|
("DL1AB", Some("CQ-WW-SSB"), 1, true),
|
||||||
|
("OZ2CD", Some("CQ-WW-SSB"), 2, false),
|
||||||
|
("SP3EF", None, 0, false),
|
||||||
|
] {
|
||||||
|
let response = actix_test::call_service(
|
||||||
|
&app,
|
||||||
|
actix_test::TestRequest::post()
|
||||||
|
.uri("/api/logbook")
|
||||||
|
.set_json(serde_json::json!({
|
||||||
|
"call": call,
|
||||||
|
"freq_hz": 14_200_000_u64,
|
||||||
|
"mode": "SSB",
|
||||||
|
"rst_sent": "59",
|
||||||
|
"rst_rcvd": "59",
|
||||||
|
"station_callsign": "SP0TRX",
|
||||||
|
"contest_id": contest,
|
||||||
|
"stx": serial,
|
||||||
|
"srx": serial,
|
||||||
|
"lotw_qsl_rcvd": if confirmed { "Y" } else { "N" },
|
||||||
|
}))
|
||||||
|
.to_request(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
let entry = actix_test::call_and_read_body(
|
||||||
|
&app,
|
||||||
|
actix_test::TestRequest::get()
|
||||||
|
.uri("/api/logbook/export.cbr?contest=CQ-WW-SSB&callsign=SP0TRX&claimed_score=42")
|
||||||
|
.to_request(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let text = String::from_utf8_lossy(&entry);
|
||||||
|
assert!(text.starts_with("START-OF-LOG: 3.0"), "{text}");
|
||||||
|
assert!(text.contains("CONTEST: CQ-WW-SSB"), "{text}");
|
||||||
|
assert!(text.contains("CLAIMED-SCORE: 42"), "{text}");
|
||||||
|
assert_eq!(
|
||||||
|
text.lines().filter(|line| line.starts_with("QSO:")).count(),
|
||||||
|
2,
|
||||||
|
"the entry holds contacts from outside the contest: {text}"
|
||||||
|
);
|
||||||
|
assert!(!text.contains("SP3EF"), "{text}");
|
||||||
|
|
||||||
|
// And the statistics count the station once per band, with the
|
||||||
|
// confirmation counted wherever it came from.
|
||||||
|
let stats: serde_json::Value = actix_test::call_and_read_body_json(
|
||||||
|
&app,
|
||||||
|
actix_test::TestRequest::get()
|
||||||
|
.uri("/api/logbook/statistics")
|
||||||
|
.to_request(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(stats["contacts"], 3);
|
||||||
|
assert_eq!(stats["confirmed"], 1);
|
||||||
|
assert_eq!(stats["bands"][0]["band"], "20m");
|
||||||
|
assert_eq!(stats["bands"][0]["stations"], 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A QSL state that is not one of ADIF's letters is dropped rather than
|
||||||
|
/// written, or the log grows states nothing else can read.
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn a_qsl_state_outside_the_enumeration_is_not_written() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let logbook = std::sync::Arc::new(
|
||||||
|
trx_logbook::Logbook::open(&dir.path().join("logbook.jsonl")).expect("open"),
|
||||||
|
);
|
||||||
|
let app = actix_test::init_service(
|
||||||
|
App::new()
|
||||||
|
.app_data(web::Data::new(logbook))
|
||||||
|
.app_data(web::Data::new(auth_state_disabled()))
|
||||||
|
.service(logbook::add_qso),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let written: serde_json::Value = actix_test::call_and_read_body_json(
|
||||||
|
&app,
|
||||||
|
actix_test::TestRequest::post()
|
||||||
|
.uri("/api/logbook")
|
||||||
|
.set_json(serde_json::json!({
|
||||||
|
"call": "SP1AA", "freq_hz": 14_074_000_u64, "mode": "FT8",
|
||||||
|
"qsl_rcvd": "maybe", "lotw_qsl_rcvd": "y",
|
||||||
|
}))
|
||||||
|
.to_request(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(written.get("qsl_rcvd").is_none(), "{written}");
|
||||||
|
// ...but a lowercase letter that is in the enumeration is taken.
|
||||||
|
assert_eq!(written["lotw_qsl_rcvd"], "Y");
|
||||||
|
assert_eq!(written["confirmed"], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A read-only session may read the log and may not write to it.
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn a_read_only_session_cannot_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(
|
||||||
|
"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))
|
||||||
|
.service(logbook::add_qso)
|
||||||
|
.service(logbook::list_qsos),
|
||||||
|
)
|
||||||
|
.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.clone(),
|
||||||
|
))
|
||||||
|
.set_json(serde_json::json!({
|
||||||
|
"call": "SP2SJG", "freq_hz": 14_074_000_u64, "mode": "FT8",
|
||||||
|
}))
|
||||||
|
.to_request(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
response.status().is_client_error(),
|
||||||
|
"a listener wrote to the log: {}",
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
|
let listed: serde_json::Value = actix_test::call_and_read_body_json(
|
||||||
|
&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]
|
||||||
|
async fn a_contact_without_a_callsign_is_refused() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let logbook = std::sync::Arc::new(
|
||||||
|
trx_logbook::Logbook::open(&dir.path().join("logbook.jsonl")).expect("open"),
|
||||||
|
);
|
||||||
|
let app = actix_test::init_service(
|
||||||
|
App::new()
|
||||||
|
.app_data(web::Data::new(logbook))
|
||||||
|
.app_data(web::Data::new(auth_state_disabled()))
|
||||||
|
.service(logbook::add_qso),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let response = actix_test::call_service(
|
||||||
|
&app,
|
||||||
|
actix_test::TestRequest::post()
|
||||||
|
.uri("/api/logbook")
|
||||||
|
.set_json(serde_json::json!({
|
||||||
|
"call": " ", "freq_hz": 14_074_000_u64, "mode": "FT8",
|
||||||
|
}))
|
||||||
|
.to_request(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), 400);
|
||||||
|
}
|
||||||
|
|
||||||
/// GET /status returns 200 with valid JSON containing rig snapshot fields.
|
/// GET /status returns 200 with valid JSON containing rig snapshot fields.
|
||||||
#[actix_web::test]
|
#[actix_web::test]
|
||||||
async fn test_status_endpoint_returns_json() {
|
async fn test_status_endpoint_returns_json() {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use trx_core::radio::freq::Freq;
|
use trx_core::radio::freq::Freq;
|
||||||
use trx_core::rig::state::WfmDenoiseLevel;
|
use trx_core::rig::state::WfmDenoiseLevel;
|
||||||
use trx_core::{RigCommand, RigRequest, RigState};
|
use trx_core::{DigSidebandPolicy, NoiseBlankerProfile, RigCommand, RigRequest, RigState};
|
||||||
use trx_frontend::{FrontendRuntimeContext, RemoteRigEntry};
|
use trx_frontend::{FrontendRuntimeContext, RemoteRigEntry};
|
||||||
use trx_protocol::parse_mode;
|
use trx_protocol::parse_mode;
|
||||||
|
|
||||||
@@ -279,6 +279,10 @@ pub async fn set_sdr_squelch(
|
|||||||
pub struct SdrNoiseBlankerQuery {
|
pub struct SdrNoiseBlankerQuery {
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
pub threshold: f64,
|
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>,
|
pub remote: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,12 +297,30 @@ pub async fn set_sdr_noise_blanker(
|
|||||||
RigCommand::SetSdrNoiseBlanker {
|
RigCommand::SetSdrNoiseBlanker {
|
||||||
enabled: q.enabled,
|
enabled: q.enabled,
|
||||||
threshold: q.threshold,
|
threshold: q.threshold,
|
||||||
|
profile: q.profile,
|
||||||
},
|
},
|
||||||
q.remote,
|
q.remote,
|
||||||
)
|
)
|
||||||
.await
|
.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
|
// WFM / SAM settings
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -737,6 +737,7 @@ pub async fn audio_ws(
|
|||||||
body: web::Payload,
|
body: web::Payload,
|
||||||
query: web::Query<AudioQuery>,
|
query: web::Query<AudioQuery>,
|
||||||
context: web::Data<Arc<FrontendRuntimeContext>>,
|
context: web::Data<Arc<FrontendRuntimeContext>>,
|
||||||
|
auth_state: web::Data<crate::server::auth::AuthState>,
|
||||||
) -> Result<HttpResponse, Error> {
|
) -> Result<HttpResponse, Error> {
|
||||||
let Some(tx_sender) = context.audio.tx.as_ref().cloned() else {
|
let Some(tx_sender) = context.audio.tx.as_ref().cloned() else {
|
||||||
return Ok(HttpResponse::NotFound().body("audio not enabled"));
|
return Ok(HttpResponse::NotFound().body("audio not enabled"));
|
||||||
@@ -746,6 +747,7 @@ pub async fn audio_ws(
|
|||||||
if !req.headers().contains_key("upgrade") {
|
if !req.headers().contains_key("upgrade") {
|
||||||
return Ok(HttpResponse::NoContent().finish());
|
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.
|
// If a channel_id is specified, subscribe to the per-channel broadcaster.
|
||||||
// The entry is created asynchronously when AUDIO_MSG_VCHAN_ALLOCATED arrives
|
// The entry is created asynchronously when AUDIO_MSG_VCHAN_ALLOCATED arrives
|
||||||
@@ -880,6 +882,16 @@ pub async fn audio_ws(
|
|||||||
msg = msg_stream.recv() => {
|
msg = msg_stream.recv() => {
|
||||||
match msg {
|
match msg {
|
||||||
Some(Ok(Message::Binary(data))) => {
|
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;
|
let _ = tx_sender.send(Bytes::from(data.to_vec())).await;
|
||||||
}
|
}
|
||||||
Some(Ok(Message::Close(_))) => break,
|
Some(Ok(Message::Close(_))) => break,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,10 @@ pub struct Bookmark {
|
|||||||
pub comment: String,
|
pub comment: String,
|
||||||
pub category: String,
|
pub category: String,
|
||||||
pub decoders: Vec<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 {
|
pub struct BookmarkStore {
|
||||||
|
|||||||
@@ -592,6 +592,21 @@ async fn apply_scheduler_target(
|
|||||||
)
|
)
|
||||||
.await?;
|
.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(
|
scheduler_send(
|
||||||
rig_tx,
|
rig_tx,
|
||||||
RigCommand::SetMode(trx_protocol::parse_mode(&bookmark.mode)),
|
RigCommand::SetMode(trx_protocol::parse_mode(&bookmark.mode)),
|
||||||
|
|||||||
@@ -106,6 +106,39 @@ async fn serve(
|
|||||||
activity_log_map.clone(),
|
activity_log_map.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The log is opened once and shared: it is one station's log, not one per
|
||||||
|
// rig or per session.
|
||||||
|
let logbook_path = context
|
||||||
|
.http_ui
|
||||||
|
.logbook_path
|
||||||
|
.clone()
|
||||||
|
.map(std::path::PathBuf::from)
|
||||||
|
.unwrap_or_else(trx_logbook::default_log_path);
|
||||||
|
let logbook = match trx_logbook::Logbook::open(&logbook_path) {
|
||||||
|
Ok(logbook) => {
|
||||||
|
tracing::info!(
|
||||||
|
"Logbook: {} contact(s) in {}",
|
||||||
|
logbook.count(),
|
||||||
|
logbook_path.display()
|
||||||
|
);
|
||||||
|
Arc::new(logbook)
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
// A log that cannot be opened must not stop the radio working, but
|
||||||
|
// it must be said loudly: contacts made now will not be kept.
|
||||||
|
tracing::error!(
|
||||||
|
"Logbook at {} could not be opened ({err}); logging is unavailable",
|
||||||
|
logbook_path.display()
|
||||||
|
);
|
||||||
|
Arc::new(
|
||||||
|
trx_logbook::Logbook::open(std::path::Path::new(
|
||||||
|
&std::env::temp_dir().join("trx-rs-logbook.jsonl"),
|
||||||
|
))
|
||||||
|
.unwrap_or_else(|e| panic!("logbook fallback in the temp directory failed: {e}")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let background_decode_path = BackgroundDecodeStore::default_path();
|
let background_decode_path = BackgroundDecodeStore::default_path();
|
||||||
let background_decode_store = Arc::new(BackgroundDecodeStore::open(&background_decode_path));
|
let background_decode_store = Arc::new(BackgroundDecodeStore::open(&background_decode_path));
|
||||||
let vchan_mgr = Arc::new(ClientChannelManager::new(
|
let vchan_mgr = Arc::new(ClientChannelManager::new(
|
||||||
@@ -164,6 +197,7 @@ async fn serve(
|
|||||||
session_rig_mgr,
|
session_rig_mgr,
|
||||||
background_decode_mgr,
|
background_decode_mgr,
|
||||||
recorder_mgr,
|
recorder_mgr,
|
||||||
|
logbook,
|
||||||
)?;
|
)?;
|
||||||
let handle = server.handle();
|
let handle = server.handle();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
@@ -192,6 +226,7 @@ fn build_server(
|
|||||||
session_rig_mgr: Arc<api::SessionRigManager>,
|
session_rig_mgr: Arc<api::SessionRigManager>,
|
||||||
background_decode_mgr: Arc<BackgroundDecodeManager>,
|
background_decode_mgr: Arc<BackgroundDecodeManager>,
|
||||||
recorder_mgr: Arc<RecorderManager>,
|
recorder_mgr: Arc<RecorderManager>,
|
||||||
|
logbook: Arc<trx_logbook::Logbook>,
|
||||||
) -> Result<Server, actix_web::Error> {
|
) -> Result<Server, actix_web::Error> {
|
||||||
let state_data = web::Data::new(state_rx);
|
let state_data = web::Data::new(state_rx);
|
||||||
let rig_tx = web::Data::new(rig_tx);
|
let rig_tx = web::Data::new(rig_tx);
|
||||||
@@ -209,6 +244,7 @@ fn build_server(
|
|||||||
let session_rig_mgr = web::Data::new(session_rig_mgr);
|
let session_rig_mgr = web::Data::new(session_rig_mgr);
|
||||||
let background_decode_mgr = web::Data::new(background_decode_mgr);
|
let background_decode_mgr = web::Data::new(background_decode_mgr);
|
||||||
let recorder_mgr = web::Data::new(recorder_mgr);
|
let recorder_mgr = web::Data::new(recorder_mgr);
|
||||||
|
let logbook = web::Data::new(logbook);
|
||||||
|
|
||||||
// Extract auth config values before moving context
|
// Extract auth config values before moving context
|
||||||
let same_site = match context.http_auth.cookie_same_site.as_str() {
|
let same_site = match context.http_auth.cookie_same_site.as_str() {
|
||||||
@@ -216,11 +252,31 @@ fn build_server(
|
|||||||
"None" => SameSite::None,
|
"None" => SameSite::None,
|
||||||
_ => SameSite::Lax, // default
|
_ => 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(
|
let auth_config = AuthConfig::new(
|
||||||
context.http_auth.enabled,
|
context.http_auth.enabled,
|
||||||
context.http_auth.rx_passphrase.clone(),
|
context.http_auth.users_file.clone().into(),
|
||||||
context.http_auth.control_passphrase.clone(),
|
bootstrap_admin,
|
||||||
context.http_auth.tx_access_control_enabled,
|
bootstrap_read,
|
||||||
Duration::from_secs(context.http_auth.session_ttl_secs),
|
Duration::from_secs(context.http_auth.session_ttl_secs),
|
||||||
context.http_auth.cookie_secure,
|
context.http_auth.cookie_secure,
|
||||||
same_site,
|
same_site,
|
||||||
@@ -237,7 +293,9 @@ fn build_server(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let context_data = web::Data::new(context);
|
let context_data = web::Data::new(context);
|
||||||
let auth_state = web::Data::new(AuthState::new(auth_config.clone()));
|
let auth_state = web::Data::new(
|
||||||
|
AuthState::new(auth_config.clone()).map_err(actix_web::error::ErrorInternalServerError)?,
|
||||||
|
);
|
||||||
|
|
||||||
// Spawn session cleanup task if auth is enabled
|
// Spawn session cleanup task if auth is enabled
|
||||||
if auth_config.enabled {
|
if auth_config.enabled {
|
||||||
@@ -267,6 +325,7 @@ fn build_server(
|
|||||||
.app_data(session_rig_mgr.clone())
|
.app_data(session_rig_mgr.clone())
|
||||||
.app_data(background_decode_mgr.clone())
|
.app_data(background_decode_mgr.clone())
|
||||||
.app_data(recorder_mgr.clone())
|
.app_data(recorder_mgr.clone())
|
||||||
|
.app_data(logbook.clone())
|
||||||
.wrap(Compress::default())
|
.wrap(Compress::default())
|
||||||
.wrap(
|
.wrap(
|
||||||
DefaultHeaders::new()
|
DefaultHeaders::new()
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "trx-logbook"
|
||||||
|
version.workspace = true
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
|
||||||
|
dirs = "6"
|
||||||
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
//! ADIF, the format every other logger reads.
|
||||||
|
//!
|
||||||
|
//! ADI is a tagged text format: `<FIELD:length>value`, where the length counts
|
||||||
|
//! the bytes of the value that follows, records end at `<EOR>`, an optional
|
||||||
|
//! header ends at `<EOH>`, and anything outside a tag is ignored — which is how
|
||||||
|
//! files carry human notes between records.
|
||||||
|
//!
|
||||||
|
//! The reader is deliberately forgiving. Files in the wild have lowercase tags,
|
||||||
|
//! CRLF line endings, no header at all, type indicators on some fields and not
|
||||||
|
//! others, and fields this application has never heard of. None of that is a
|
||||||
|
//! reason to refuse someone's log. The writer, by contrast, is strict: what
|
||||||
|
//! leaves here has to be read by software that is not.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
|
||||||
|
use crate::qso::{
|
||||||
|
adif_date, adif_time, freq_mhz_string, hz_from_mhz_string, normalize_call, parse_adif_datetime,
|
||||||
|
Qso,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Fields the [`Qso`] models itself. Everything else an import carries is kept
|
||||||
|
/// in `extra` under its own name, so a round trip does not strip it.
|
||||||
|
const MODELLED: &[&str] = &[
|
||||||
|
"CALL",
|
||||||
|
"QSO_DATE",
|
||||||
|
"TIME_ON",
|
||||||
|
"QSO_DATE_OFF",
|
||||||
|
"TIME_OFF",
|
||||||
|
"FREQ",
|
||||||
|
"BAND",
|
||||||
|
"MODE",
|
||||||
|
"SUBMODE",
|
||||||
|
"RST_SENT",
|
||||||
|
"RST_RCVD",
|
||||||
|
"GRIDSQUARE",
|
||||||
|
"NAME",
|
||||||
|
"QTH",
|
||||||
|
"COMMENT",
|
||||||
|
"TX_PWR",
|
||||||
|
"STATION_CALLSIGN",
|
||||||
|
"OPERATOR",
|
||||||
|
"MY_GRIDSQUARE",
|
||||||
|
"MY_RIG",
|
||||||
|
"CONTEST_ID",
|
||||||
|
"STX",
|
||||||
|
"STX_STRING",
|
||||||
|
"SRX",
|
||||||
|
"SRX_STRING",
|
||||||
|
"CQZ",
|
||||||
|
"ITUZ",
|
||||||
|
"QSL_SENT",
|
||||||
|
"QSL_RCVD",
|
||||||
|
"LOTW_QSL_SENT",
|
||||||
|
"LOTW_QSL_RCVD",
|
||||||
|
"EQSL_QSL_SENT",
|
||||||
|
"EQSL_QSL_RCVD",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// ADIF's QSL states are single letters; anything else in that field is a
|
||||||
|
/// misunderstanding on the writer's part and is not carried into ours.
|
||||||
|
fn qsl_state(value: Option<&str>) -> Option<String> {
|
||||||
|
let state = value?.trim().to_uppercase();
|
||||||
|
matches!(state.as_str(), "Y" | "N" | "R" | "I" | "Q" | "V").then_some(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a file gave up, and what it could not.
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub struct ParseReport {
|
||||||
|
/// Records that became a QSO.
|
||||||
|
pub qsos: Vec<Qso>,
|
||||||
|
/// Records that did not, and why — one line each, for showing the operator.
|
||||||
|
pub rejected: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One `<NAME:LEN[:TYPE]>value` field.
|
||||||
|
struct Field {
|
||||||
|
name: String,
|
||||||
|
value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the next tag starting at `from`, returning it and where it ended.
|
||||||
|
///
|
||||||
|
/// Returns `None` at the end of input or on a tag that never closes, which is
|
||||||
|
/// how a truncated file stops rather than looping.
|
||||||
|
fn next_field(bytes: &[u8], from: usize) -> Option<(Field, usize)> {
|
||||||
|
let open = bytes[from..].iter().position(|b| *b == b'<')? + from;
|
||||||
|
let close = bytes[open..].iter().position(|b| *b == b'>')? + open;
|
||||||
|
let spec = String::from_utf8_lossy(&bytes[open + 1..close]).to_string();
|
||||||
|
let mut parts = spec.split(':');
|
||||||
|
let name = parts.next().unwrap_or_default().trim().to_uppercase();
|
||||||
|
|
||||||
|
// <EOR> and <EOH> carry no length and no value.
|
||||||
|
let Some(length_text) = parts.next() else {
|
||||||
|
return Some((
|
||||||
|
Field {
|
||||||
|
name,
|
||||||
|
value: String::new(),
|
||||||
|
},
|
||||||
|
close + 1,
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let length: usize = length_text.trim().parse().unwrap_or(0);
|
||||||
|
let value_start = close + 1;
|
||||||
|
// A length that runs off the end is a truncated file, not a reason to lose
|
||||||
|
// everything before it: take what is there.
|
||||||
|
let value_end = value_start.saturating_add(length).min(bytes.len());
|
||||||
|
let value = String::from_utf8_lossy(&bytes[value_start..value_end]).to_string();
|
||||||
|
Some((Field { name, value }, value_end))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read an ADI file.
|
||||||
|
///
|
||||||
|
/// Never fails as a whole: a record that cannot be made into a QSO is reported
|
||||||
|
/// on [`ParseReport::rejected`] and the rest of the file is still read.
|
||||||
|
pub fn parse_adi(input: &[u8]) -> ParseReport {
|
||||||
|
let mut report = ParseReport::default();
|
||||||
|
let mut position = 0usize;
|
||||||
|
let mut fields: Vec<Field> = Vec::new();
|
||||||
|
let mut in_header = looks_like_header(input);
|
||||||
|
let mut record_number = 0usize;
|
||||||
|
|
||||||
|
while let Some((field, next)) = next_field(input, position) {
|
||||||
|
position = next;
|
||||||
|
match field.name.as_str() {
|
||||||
|
"EOH" => {
|
||||||
|
in_header = false;
|
||||||
|
fields.clear();
|
||||||
|
}
|
||||||
|
"EOR" => {
|
||||||
|
record_number += 1;
|
||||||
|
if in_header {
|
||||||
|
// A file with no <EOH> but an <EOR>: the header guess was
|
||||||
|
// wrong, so treat what we have as a record.
|
||||||
|
in_header = false;
|
||||||
|
}
|
||||||
|
match qso_from_fields(&fields) {
|
||||||
|
Ok(qso) => report.qsos.push(qso),
|
||||||
|
Err(reason) => report
|
||||||
|
.rejected
|
||||||
|
.push(format!("record {record_number}: {reason}")),
|
||||||
|
}
|
||||||
|
fields.clear();
|
||||||
|
}
|
||||||
|
_ => fields.push(field),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A last record without its <EOR> is still a record someone wrote.
|
||||||
|
if !fields.is_empty() && !in_header {
|
||||||
|
record_number += 1;
|
||||||
|
match qso_from_fields(&fields) {
|
||||||
|
Ok(qso) => report.qsos.push(qso),
|
||||||
|
Err(reason) => report
|
||||||
|
.rejected
|
||||||
|
.push(format!("record {record_number}: {reason}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
report
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the file opens with a header, i.e. anything before the first tag.
|
||||||
|
///
|
||||||
|
/// ADIF says a file whose first character is not `<` has a header. Plenty of
|
||||||
|
/// exporters write no header at all, so the first `<EOR>` un-guesses this.
|
||||||
|
fn looks_like_header(input: &[u8]) -> bool {
|
||||||
|
match input.iter().position(|b| !b.is_ascii_whitespace()) {
|
||||||
|
Some(first) => input[first] != b'<',
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find<'a>(fields: &'a [Field], name: &str) -> Option<&'a str> {
|
||||||
|
fields
|
||||||
|
.iter()
|
||||||
|
.find(|f| f.name == name)
|
||||||
|
.map(|f| f.value.trim())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn qso_from_fields(fields: &[Field]) -> Result<Qso, String> {
|
||||||
|
let call = find(fields, "CALL").ok_or("no CALL")?;
|
||||||
|
let date = find(fields, "QSO_DATE").ok_or("no QSO_DATE")?;
|
||||||
|
let started_at = parse_adif_datetime(date, find(fields, "TIME_ON"))
|
||||||
|
.ok_or("QSO_DATE or TIME_ON unreadable")?;
|
||||||
|
|
||||||
|
// FREQ is authoritative; BAND is the fallback for logs that record only the
|
||||||
|
// band, and its midpoint is the honest answer for "where in the band".
|
||||||
|
let freq_hz = match find(fields, "FREQ").and_then(hz_from_mhz_string) {
|
||||||
|
Some(hz) => hz,
|
||||||
|
None => find(fields, "BAND")
|
||||||
|
.and_then(band_midpoint_hz)
|
||||||
|
.ok_or("neither FREQ nor a known BAND")?,
|
||||||
|
};
|
||||||
|
let mode = find(fields, "MODE").unwrap_or("SSB").to_uppercase();
|
||||||
|
|
||||||
|
let ended_at = find(fields, "TIME_OFF").and_then(|time| {
|
||||||
|
let off_date = find(fields, "QSO_DATE_OFF").unwrap_or(date);
|
||||||
|
parse_adif_datetime(off_date, Some(time))
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut extra = BTreeMap::new();
|
||||||
|
for field in fields {
|
||||||
|
if MODELLED.contains(&field.name.as_str()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if field.value.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
extra.insert(field.name.clone(), field.value.trim().to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Qso {
|
||||||
|
id: crate::new_id(),
|
||||||
|
started_at,
|
||||||
|
ended_at,
|
||||||
|
call: normalize_call(call),
|
||||||
|
freq_hz,
|
||||||
|
mode,
|
||||||
|
submode: find(fields, "SUBMODE").map(|s| s.to_uppercase()),
|
||||||
|
rst_sent: find(fields, "RST_SENT").map(str::to_string),
|
||||||
|
rst_rcvd: find(fields, "RST_RCVD").map(str::to_string),
|
||||||
|
gridsquare: find(fields, "GRIDSQUARE").map(|g| g.to_uppercase()),
|
||||||
|
name: find(fields, "NAME").map(str::to_string),
|
||||||
|
qth: find(fields, "QTH").map(str::to_string),
|
||||||
|
comment: find(fields, "COMMENT").map(str::to_string),
|
||||||
|
tx_pwr_w: find(fields, "TX_PWR").and_then(|p| p.parse().ok()),
|
||||||
|
station_callsign: find(fields, "STATION_CALLSIGN").map(normalize_call),
|
||||||
|
operator: find(fields, "OPERATOR").map(normalize_call),
|
||||||
|
my_gridsquare: find(fields, "MY_GRIDSQUARE").map(|g| g.to_uppercase()),
|
||||||
|
my_rig: find(fields, "MY_RIG").map(str::to_string),
|
||||||
|
rig_id: None,
|
||||||
|
contest_id: find(fields, "CONTEST_ID").map(|c| c.to_uppercase()),
|
||||||
|
stx: find(fields, "STX").and_then(|value| value.parse().ok()),
|
||||||
|
stx_string: find(fields, "STX_STRING").map(str::to_string),
|
||||||
|
srx: find(fields, "SRX").and_then(|value| value.parse().ok()),
|
||||||
|
srx_string: find(fields, "SRX_STRING").map(str::to_string),
|
||||||
|
cqz: find(fields, "CQZ").and_then(|value| value.parse().ok()),
|
||||||
|
ituz: find(fields, "ITUZ").and_then(|value| value.parse().ok()),
|
||||||
|
qsl_sent: qsl_state(find(fields, "QSL_SENT")),
|
||||||
|
qsl_rcvd: qsl_state(find(fields, "QSL_RCVD")),
|
||||||
|
lotw_qsl_sent: qsl_state(find(fields, "LOTW_QSL_SENT")),
|
||||||
|
lotw_qsl_rcvd: qsl_state(find(fields, "LOTW_QSL_RCVD")),
|
||||||
|
eqsl_qsl_sent: qsl_state(find(fields, "EQSL_QSL_SENT")),
|
||||||
|
eqsl_qsl_rcvd: qsl_state(find(fields, "EQSL_QSL_RCVD")),
|
||||||
|
extra,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The middle of a named band, for logs that recorded a band and no frequency.
|
||||||
|
fn band_midpoint_hz(band: &str) -> Option<u64> {
|
||||||
|
crate::qso::band_midpoint_for_name(band)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_field(out: &mut String, name: &str, value: &str) {
|
||||||
|
if value.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
out.push_str(&format!("<{}:{}>{}", name, value.len(), value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write QSOs as an ADI file, header and all.
|
||||||
|
pub fn write_adi(qsos: &[Qso], program_version: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(qsos.len() * 256 + 256);
|
||||||
|
let now = Utc::now();
|
||||||
|
out.push_str("ADIF export from trx-rs\n");
|
||||||
|
write_field(&mut out, "ADIF_VER", "3.1.4");
|
||||||
|
write_field(&mut out, "PROGRAMID", "trx-rs");
|
||||||
|
write_field(&mut out, "PROGRAMVERSION", program_version);
|
||||||
|
write_field(
|
||||||
|
&mut out,
|
||||||
|
"CREATED_TIMESTAMP",
|
||||||
|
&format!("{} {}", adif_date(&now), adif_time(&now)),
|
||||||
|
);
|
||||||
|
out.push_str("<EOH>\n");
|
||||||
|
|
||||||
|
for qso in qsos {
|
||||||
|
write_field(&mut out, "CALL", &qso.call);
|
||||||
|
write_field(&mut out, "QSO_DATE", &adif_date(&qso.started_at));
|
||||||
|
write_field(&mut out, "TIME_ON", &adif_time(&qso.started_at));
|
||||||
|
if let Some(ended) = qso.ended_at {
|
||||||
|
write_field(&mut out, "QSO_DATE_OFF", &adif_date(&ended));
|
||||||
|
write_field(&mut out, "TIME_OFF", &adif_time(&ended));
|
||||||
|
}
|
||||||
|
write_field(&mut out, "FREQ", &freq_mhz_string(qso.freq_hz));
|
||||||
|
if let Some(band) = qso.band() {
|
||||||
|
write_field(&mut out, "BAND", band);
|
||||||
|
}
|
||||||
|
write_field(&mut out, "MODE", &qso.mode);
|
||||||
|
if let Some(submode) = &qso.submode {
|
||||||
|
write_field(&mut out, "SUBMODE", submode);
|
||||||
|
}
|
||||||
|
for (name, value) in [
|
||||||
|
("RST_SENT", &qso.rst_sent),
|
||||||
|
("RST_RCVD", &qso.rst_rcvd),
|
||||||
|
("GRIDSQUARE", &qso.gridsquare),
|
||||||
|
("NAME", &qso.name),
|
||||||
|
("QTH", &qso.qth),
|
||||||
|
("COMMENT", &qso.comment),
|
||||||
|
("STATION_CALLSIGN", &qso.station_callsign),
|
||||||
|
("OPERATOR", &qso.operator),
|
||||||
|
("MY_GRIDSQUARE", &qso.my_gridsquare),
|
||||||
|
("MY_RIG", &qso.my_rig),
|
||||||
|
("CONTEST_ID", &qso.contest_id),
|
||||||
|
("STX_STRING", &qso.stx_string),
|
||||||
|
("SRX_STRING", &qso.srx_string),
|
||||||
|
("QSL_SENT", &qso.qsl_sent),
|
||||||
|
("QSL_RCVD", &qso.qsl_rcvd),
|
||||||
|
("LOTW_QSL_SENT", &qso.lotw_qsl_sent),
|
||||||
|
("LOTW_QSL_RCVD", &qso.lotw_qsl_rcvd),
|
||||||
|
("EQSL_QSL_SENT", &qso.eqsl_qsl_sent),
|
||||||
|
("EQSL_QSL_RCVD", &qso.eqsl_qsl_rcvd),
|
||||||
|
] {
|
||||||
|
if let Some(value) = value {
|
||||||
|
write_field(&mut out, name, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (name, value) in [
|
||||||
|
("STX", qso.stx),
|
||||||
|
("SRX", qso.srx),
|
||||||
|
("CQZ", qso.cqz.map(u32::from)),
|
||||||
|
("ITUZ", qso.ituz.map(u32::from)),
|
||||||
|
] {
|
||||||
|
if let Some(value) = value {
|
||||||
|
write_field(&mut out, name, &value.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(power) = qso.tx_pwr_w {
|
||||||
|
write_field(&mut out, "TX_PWR", &format!("{power}"));
|
||||||
|
}
|
||||||
|
// Whatever another logger wrote and this one does not model leaves as
|
||||||
|
// it arrived.
|
||||||
|
for (name, value) in &qso.extra {
|
||||||
|
write_field(&mut out, name, value);
|
||||||
|
}
|
||||||
|
out.push_str("<EOR>\n");
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_written_log_reads_back_the_same() {
|
||||||
|
let mut qso = Qso::new(
|
||||||
|
"id-1".into(),
|
||||||
|
parse_adif_datetime("20260807", Some("121530")).expect("time"),
|
||||||
|
"sp2sjg",
|
||||||
|
14_074_000,
|
||||||
|
"FT8",
|
||||||
|
);
|
||||||
|
qso.rst_sent = Some("-07".into());
|
||||||
|
qso.rst_rcvd = Some("-12".into());
|
||||||
|
qso.gridsquare = Some("JO94".into());
|
||||||
|
qso.station_callsign = Some("SP0TRX".into());
|
||||||
|
qso.operator = Some("SP0TRX".into());
|
||||||
|
qso.my_gridsquare = Some("JO91".into());
|
||||||
|
qso.my_rig = Some("Shack SDR".into());
|
||||||
|
|
||||||
|
let text = write_adi(&[qso.clone()], "0.1.0");
|
||||||
|
let report = parse_adi(text.as_bytes());
|
||||||
|
assert!(report.rejected.is_empty(), "{:?}", report.rejected);
|
||||||
|
assert_eq!(report.qsos.len(), 1);
|
||||||
|
let back = &report.qsos[0];
|
||||||
|
|
||||||
|
assert_eq!(back.call, "SP2SJG");
|
||||||
|
assert_eq!(back.freq_hz, qso.freq_hz);
|
||||||
|
assert_eq!(back.mode, "FT8");
|
||||||
|
assert_eq!(back.started_at, qso.started_at);
|
||||||
|
assert_eq!(back.rst_sent, qso.rst_sent);
|
||||||
|
assert_eq!(back.gridsquare, qso.gridsquare);
|
||||||
|
assert_eq!(back.my_rig, qso.my_rig);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Files from other loggers are irregular in ways that are not errors.
|
||||||
|
#[test]
|
||||||
|
fn a_file_from_another_logger_is_read_as_it_comes() {
|
||||||
|
let text = concat!(
|
||||||
|
"Generated by SomeLogger 4.2\r\n",
|
||||||
|
"<adif_ver:5>3.1.0<programid:10>SomeLogger<eoh>\r\n",
|
||||||
|
// lowercase tags, CRLF, a type indicator, no seconds on the time
|
||||||
|
"<call:6>W1AW/4<qso_date:8>20260101<time_on:4>1200<band:3>20m",
|
||||||
|
"<mode:3>SSB<rst_sent:2>59<rst_rcvd:2>59<my_antenna:6>Dipole<eor>\r\n",
|
||||||
|
// a record with the fields in another order and a full frequency
|
||||||
|
"<qso_date:8>20260102<time_on:6>235959<call:5>DL1AB<freq:6>7.0301",
|
||||||
|
"<mode:2>CW<eor>\r\n",
|
||||||
|
);
|
||||||
|
let report = parse_adi(text.as_bytes());
|
||||||
|
assert!(report.rejected.is_empty(), "{:?}", report.rejected);
|
||||||
|
assert_eq!(report.qsos.len(), 2);
|
||||||
|
|
||||||
|
let first = &report.qsos[0];
|
||||||
|
assert_eq!(first.call, "W1AW/4");
|
||||||
|
assert_eq!(first.mode, "SSB");
|
||||||
|
// No FREQ, so the band gives the frequency, and the band comes back.
|
||||||
|
assert_eq!(first.band(), Some("20m"));
|
||||||
|
// A field we do not model is kept, and comes back out on export.
|
||||||
|
assert_eq!(
|
||||||
|
first.extra.get("MY_ANTENNA").map(String::as_str),
|
||||||
|
Some("Dipole")
|
||||||
|
);
|
||||||
|
let exported = write_adi(&report.qsos, "0.1.0");
|
||||||
|
assert!(exported.contains("<MY_ANTENNA:6>Dipole"), "{exported}");
|
||||||
|
|
||||||
|
let second = &report.qsos[1];
|
||||||
|
assert_eq!(second.call, "DL1AB");
|
||||||
|
assert_eq!(second.freq_hz, 7_030_100);
|
||||||
|
assert_eq!(adif_time(&second.started_at), "235959");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_record_that_cannot_be_a_qso_is_reported_and_the_rest_still_read() {
|
||||||
|
let text = concat!(
|
||||||
|
"<call:5>NOCAL<eor>", // no date
|
||||||
|
"<qso_date:8>20260101<time_on:4>1200<mode:3>SSB<eor>", // no call
|
||||||
|
"<call:5>SP1AB<qso_date:8>20260101<time_on:4>1200<freq:6>14.074<mode:3>FT8<eor>",
|
||||||
|
);
|
||||||
|
let report = parse_adi(text.as_bytes());
|
||||||
|
assert_eq!(report.qsos.len(), 1);
|
||||||
|
assert_eq!(report.qsos[0].call, "SP1AB");
|
||||||
|
assert_eq!(report.rejected.len(), 2);
|
||||||
|
assert!(
|
||||||
|
report.rejected[0].contains("QSO_DATE"),
|
||||||
|
"{:?}",
|
||||||
|
report.rejected
|
||||||
|
);
|
||||||
|
assert!(report.rejected[1].contains("CALL"), "{:?}", report.rejected);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The declared length is the length, even when the value looks longer:
|
||||||
|
/// ADIF values may contain `<`, so the count is the only thing that can be
|
||||||
|
/// trusted to end them.
|
||||||
|
#[test]
|
||||||
|
fn the_declared_length_wins_over_what_the_value_looks_like() {
|
||||||
|
let text = "<call:5>SP1AB<qso_date:8>20260101<time_on:4>1200<freq:6>14.074<mode:3>FT8\
|
||||||
|
<comment:11>see <notes><eor>";
|
||||||
|
let report = parse_adi(text.as_bytes());
|
||||||
|
assert_eq!(report.qsos.len(), 1);
|
||||||
|
assert_eq!(report.qsos[0].comment.as_deref(), Some("see <notes>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_truncated_file_gives_up_what_it_has() {
|
||||||
|
// The length says 20 bytes of callsign; the file ends after five.
|
||||||
|
let text = "<call:20>SP1AB";
|
||||||
|
let report = parse_adi(text.as_bytes());
|
||||||
|
// No date, so it is rejected -- but the parser returned rather than
|
||||||
|
// running off the end or looping.
|
||||||
|
assert_eq!(report.qsos.len(), 0);
|
||||||
|
assert_eq!(report.rejected.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_file_with_no_header_is_still_a_file() {
|
||||||
|
let text = "<call:5>SP1AB<qso_date:8>20260101<time_on:4>1200<freq:6>14.074<mode:3>FT8<eor>";
|
||||||
|
let report = parse_adi(text.as_bytes());
|
||||||
|
assert_eq!(report.qsos.len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
//! Cabrillo 3.0, the format contest logs are submitted in.
|
||||||
|
//!
|
||||||
|
//! ADIF cannot do this job: sponsors take Cabrillo and reject everything else.
|
||||||
|
//! It is export-only and narrower on purpose — a header naming the entrant and
|
||||||
|
//! the category, then one fixed-shape `QSO:` line per contact carrying the
|
||||||
|
//! frequency, the mode, the time, and both stations' calls, reports and
|
||||||
|
//! exchanges. Everything a log holds beyond that is left behind, which is why
|
||||||
|
//! this complements ADIF rather than replacing it.
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::qso::Qso;
|
||||||
|
|
||||||
|
/// The header of a contest entry.
|
||||||
|
///
|
||||||
|
/// Sponsors want the entrant's own answers here — how many operators, how much
|
||||||
|
/// power, which bands — and no log can work them out for itself, so they come
|
||||||
|
/// from the operator with sane defaults behind them.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct CabrilloHeader {
|
||||||
|
/// The contest's Cabrillo name, e.g. `CQ-WW-SSB`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub contest: Option<String>,
|
||||||
|
/// The callsign the entry is submitted under.
|
||||||
|
#[serde(default)]
|
||||||
|
pub callsign: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub category_operator: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub category_power: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub category_band: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub category_mode: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub claimed_score: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub operators: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub email: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub soapbox: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CabrilloHeader {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
contest: None,
|
||||||
|
callsign: None,
|
||||||
|
category_operator: Some("SINGLE-OP".to_string()),
|
||||||
|
category_power: Some("LOW".to_string()),
|
||||||
|
category_band: Some("ALL".to_string()),
|
||||||
|
category_mode: Some("MIXED".to_string()),
|
||||||
|
claimed_score: None,
|
||||||
|
operators: None,
|
||||||
|
name: None,
|
||||||
|
email: None,
|
||||||
|
soapbox: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The mode letters Cabrillo uses, which are not ADIF's names.
|
||||||
|
fn cabrillo_mode(qso: &Qso) -> &'static str {
|
||||||
|
match qso.mode.trim().to_uppercase().as_str() {
|
||||||
|
"CW" | "CWR" => "CW",
|
||||||
|
"SSB" | "USB" | "LSB" | "AM" | "FM" | "SAM" | "WFM" | "PHONE" => {
|
||||||
|
// FM has a letter of its own; the rest of phone is PH.
|
||||||
|
if matches!(qso.mode.trim().to_uppercase().as_str(), "FM" | "WFM") {
|
||||||
|
"FM"
|
||||||
|
} else {
|
||||||
|
"PH"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"RTTY" => "RY",
|
||||||
|
_ => "DG",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Band designators above 30 MHz, where Cabrillo names the band rather than
|
||||||
|
/// the frequency.
|
||||||
|
const VHF_DESIGNATORS: &[(u64, u64, &str)] = &[
|
||||||
|
(50_000_000, 54_000_000, "50"),
|
||||||
|
(70_000_000, 71_000_000, "70"),
|
||||||
|
(144_000_000, 148_000_000, "144"),
|
||||||
|
(222_000_000, 225_000_000, "222"),
|
||||||
|
(420_000_000, 450_000_000, "432"),
|
||||||
|
(902_000_000, 928_000_000, "902"),
|
||||||
|
(1_240_000_000, 1_300_000_000, "1.2G"),
|
||||||
|
(2_300_000_000, 2_450_000_000, "2.3G"),
|
||||||
|
(3_300_000_000, 3_500_000_000, "3.4G"),
|
||||||
|
(5_650_000_000, 5_925_000_000, "5.7G"),
|
||||||
|
(10_000_000_000, 10_500_000_000, "10G"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The frequency field: kilohertz below 30 MHz, a band designator above it.
|
||||||
|
fn cabrillo_frequency(hz: u64) -> String {
|
||||||
|
if hz < 30_000_000 {
|
||||||
|
return (hz / 1000).to_string();
|
||||||
|
}
|
||||||
|
VHF_DESIGNATORS
|
||||||
|
.iter()
|
||||||
|
.find(|(low, high, _)| hz >= *low && hz <= *high)
|
||||||
|
.map_or_else(
|
||||||
|
|| (hz / 1000).to_string(),
|
||||||
|
|(_, _, name)| (*name).to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn header_line(out: &mut String, key: &str, value: Option<&str>) {
|
||||||
|
if let Some(value) = value {
|
||||||
|
let value = value.trim();
|
||||||
|
if !value.is_empty() {
|
||||||
|
out.push_str(&format!("{key}: {value}\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a Cabrillo 3.0 entry for `qsos`.
|
||||||
|
///
|
||||||
|
/// The caller decides which contacts belong to the entry; this writes what it
|
||||||
|
/// is given, in the order it is given, oldest first as sponsors expect.
|
||||||
|
pub fn write_cabrillo(qsos: &[Qso], header: &CabrilloHeader) -> String {
|
||||||
|
let mut out = String::with_capacity(qsos.len() * 96 + 512);
|
||||||
|
out.push_str("START-OF-LOG: 3.0\n");
|
||||||
|
header_line(&mut out, "CREATED-BY", Some("trx-rs"));
|
||||||
|
header_line(&mut out, "CONTEST", header.contest.as_deref());
|
||||||
|
header_line(&mut out, "CALLSIGN", header.callsign.as_deref());
|
||||||
|
header_line(
|
||||||
|
&mut out,
|
||||||
|
"CATEGORY-OPERATOR",
|
||||||
|
header.category_operator.as_deref(),
|
||||||
|
);
|
||||||
|
header_line(&mut out, "CATEGORY-BAND", header.category_band.as_deref());
|
||||||
|
header_line(&mut out, "CATEGORY-MODE", header.category_mode.as_deref());
|
||||||
|
header_line(&mut out, "CATEGORY-POWER", header.category_power.as_deref());
|
||||||
|
if let Some(score) = header.claimed_score {
|
||||||
|
header_line(&mut out, "CLAIMED-SCORE", Some(&score.to_string()));
|
||||||
|
}
|
||||||
|
header_line(&mut out, "OPERATORS", header.operators.as_deref());
|
||||||
|
header_line(&mut out, "NAME", header.name.as_deref());
|
||||||
|
header_line(&mut out, "EMAIL", header.email.as_deref());
|
||||||
|
header_line(&mut out, "SOAPBOX", header.soapbox.as_deref());
|
||||||
|
|
||||||
|
// Oldest first: a contest log is read as it was worked.
|
||||||
|
let mut ordered: Vec<&Qso> = qsos.iter().collect();
|
||||||
|
ordered.sort_by_key(|qso| qso.started_at);
|
||||||
|
|
||||||
|
let station = header
|
||||||
|
.callsign
|
||||||
|
.clone()
|
||||||
|
.or_else(|| qsos.first().and_then(|qso| qso.station_callsign.clone()))
|
||||||
|
.unwrap_or_else(|| "UNKNOWN".to_string());
|
||||||
|
|
||||||
|
for qso in ordered {
|
||||||
|
let sent = qso.exchange_sent().unwrap_or_default();
|
||||||
|
let received = qso.exchange_received().unwrap_or_default();
|
||||||
|
out.push_str(&format!(
|
||||||
|
"QSO: {freq:>5} {mode:2} {date} {time} {mine:<13} {rst_sent:>3} {exch_sent:<6} {theirs:<13} {rst_rcvd:>3} {exch_rcvd:<6}\n",
|
||||||
|
freq = cabrillo_frequency(qso.freq_hz),
|
||||||
|
mode = cabrillo_mode(qso),
|
||||||
|
date = qso.started_at.format("%Y-%m-%d"),
|
||||||
|
time = qso.started_at.format("%H%M"),
|
||||||
|
mine = qso.station_callsign.as_deref().unwrap_or(&station),
|
||||||
|
rst_sent = qso.rst_sent.as_deref().unwrap_or(""),
|
||||||
|
exch_sent = sent,
|
||||||
|
theirs = qso.call,
|
||||||
|
rst_rcvd = qso.rst_rcvd.as_deref().unwrap_or(""),
|
||||||
|
exch_rcvd = received,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
out.push_str("END-OF-LOG:\n");
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::qso::parse_adif_datetime;
|
||||||
|
|
||||||
|
fn contest_qso(call: &str, hz: u64, mode: &str, time: &str, sent: u32, rcvd: u32) -> Qso {
|
||||||
|
let mut qso = Qso::new(
|
||||||
|
format!("{call}-{time}"),
|
||||||
|
parse_adif_datetime("20260807", Some(time)).expect("time"),
|
||||||
|
call,
|
||||||
|
hz,
|
||||||
|
mode,
|
||||||
|
);
|
||||||
|
qso.station_callsign = Some("SP0TRX".into());
|
||||||
|
qso.rst_sent = Some("59".into());
|
||||||
|
qso.rst_rcvd = Some("59".into());
|
||||||
|
qso.stx = Some(sent);
|
||||||
|
qso.srx = Some(rcvd);
|
||||||
|
qso.contest_id = Some("CQ-WW-SSB".into());
|
||||||
|
qso
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_entry_has_the_header_a_sponsor_expects() {
|
||||||
|
let header = CabrilloHeader {
|
||||||
|
contest: Some("CQ-WW-SSB".into()),
|
||||||
|
callsign: Some("SP0TRX".into()),
|
||||||
|
claimed_score: Some(1234),
|
||||||
|
..CabrilloHeader::default()
|
||||||
|
};
|
||||||
|
let text = write_cabrillo(
|
||||||
|
&[contest_qso("DL1AB", 14_200_000, "SSB", "120000", 1, 42)],
|
||||||
|
&header,
|
||||||
|
);
|
||||||
|
assert!(text.starts_with("START-OF-LOG: 3.0\n"), "{text}");
|
||||||
|
assert!(text.contains("CONTEST: CQ-WW-SSB\n"), "{text}");
|
||||||
|
assert!(text.contains("CALLSIGN: SP0TRX\n"), "{text}");
|
||||||
|
assert!(text.contains("CATEGORY-OPERATOR: SINGLE-OP\n"), "{text}");
|
||||||
|
assert!(text.contains("CLAIMED-SCORE: 1234\n"), "{text}");
|
||||||
|
assert!(text.trim_end().ends_with("END-OF-LOG:"), "{text}");
|
||||||
|
// A header field nobody filled in is left out, not written empty.
|
||||||
|
assert!(!text.contains("SOAPBOX:"), "{text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_qso_line_carries_both_sides_of_the_exchange() {
|
||||||
|
let text = write_cabrillo(
|
||||||
|
&[contest_qso("DL1AB", 14_200_000, "SSB", "120000", 1, 42)],
|
||||||
|
&CabrilloHeader::default(),
|
||||||
|
);
|
||||||
|
let line = text
|
||||||
|
.lines()
|
||||||
|
.find(|line| line.starts_with("QSO:"))
|
||||||
|
.expect("a QSO line");
|
||||||
|
// Frequency in kHz on HF, the phone mode letter, the date and UTC time,
|
||||||
|
// then mine, my report and my serial, then theirs.
|
||||||
|
assert!(line.contains("14200"), "{line}");
|
||||||
|
assert!(line.contains(" PH "), "{line}");
|
||||||
|
assert!(line.contains("2026-08-07 1200"), "{line}");
|
||||||
|
assert!(line.contains("SP0TRX"), "{line}");
|
||||||
|
assert!(line.contains("DL1AB"), "{line}");
|
||||||
|
assert!(line.contains("001"), "{line}");
|
||||||
|
assert!(line.contains("042"), "{line}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Above 30 MHz Cabrillo names the band rather than the frequency.
|
||||||
|
#[test]
|
||||||
|
fn vhf_and_up_are_written_as_band_designators() {
|
||||||
|
assert_eq!(cabrillo_frequency(14_200_000), "14200");
|
||||||
|
assert_eq!(cabrillo_frequency(7_030_000), "7030");
|
||||||
|
assert_eq!(cabrillo_frequency(144_300_000), "144");
|
||||||
|
assert_eq!(cabrillo_frequency(432_100_000), "432");
|
||||||
|
assert_eq!(cabrillo_frequency(1_296_000_000), "1.2G");
|
||||||
|
assert_eq!(cabrillo_frequency(50_313_000), "50");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_mode_letters_are_cabrillos_not_adifs() {
|
||||||
|
let cw = contest_qso("DL1AB", 7_030_000, "CW", "120000", 1, 1);
|
||||||
|
let phone = contest_qso("DL1AB", 14_200_000, "SSB", "120000", 1, 1);
|
||||||
|
let fm = contest_qso("DL1AB", 145_500_000, "FM", "120000", 1, 1);
|
||||||
|
let digital = contest_qso("DL1AB", 14_074_000, "FT8", "120000", 1, 1);
|
||||||
|
assert_eq!(cabrillo_mode(&cw), "CW");
|
||||||
|
assert_eq!(cabrillo_mode(&phone), "PH");
|
||||||
|
assert_eq!(cabrillo_mode(&fm), "FM");
|
||||||
|
assert_eq!(cabrillo_mode(&digital), "DG");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contacts_are_written_oldest_first_however_they_arrive() {
|
||||||
|
let text = write_cabrillo(
|
||||||
|
&[
|
||||||
|
contest_qso("LATER", 14_200_000, "SSB", "130000", 2, 2),
|
||||||
|
contest_qso("EARLIER", 14_200_000, "SSB", "120000", 1, 1),
|
||||||
|
],
|
||||||
|
&CabrilloHeader::default(),
|
||||||
|
);
|
||||||
|
let calls: Vec<&str> = text
|
||||||
|
.lines()
|
||||||
|
.filter(|line| line.starts_with("QSO:"))
|
||||||
|
.map(|line| {
|
||||||
|
if line.contains("EARLIER") {
|
||||||
|
"EARLIER"
|
||||||
|
} else {
|
||||||
|
"LATER"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(calls, vec!["EARLIER", "LATER"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A word exchange -- a zone, a section, a name -- rather than a serial.
|
||||||
|
#[test]
|
||||||
|
fn an_exchange_that_is_not_a_number_is_written_as_it_was() {
|
||||||
|
let mut qso = contest_qso("DL1AB", 14_200_000, "SSB", "120000", 1, 1);
|
||||||
|
qso.stx_string = Some("14".into());
|
||||||
|
qso.srx_string = Some("BAVARIA".into());
|
||||||
|
let text = write_cabrillo(&[qso], &CabrilloHeader::default());
|
||||||
|
let line = text.lines().find(|l| l.starts_with("QSO:")).expect("line");
|
||||||
|
assert!(line.contains("BAVARIA"), "{line}");
|
||||||
|
assert!(!line.contains("001"), "{line}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
//! Telling one contact from two.
|
||||||
|
//!
|
||||||
|
//! Two programs rarely stamp the same QSO to the same minute — one records when
|
||||||
|
//! the contact started, another when the operator finished typing — so matching
|
||||||
|
//! on an exact time duplicates half of what an import is asked to merge. The
|
||||||
|
//! key is therefore the callsign, the band and the mode, with the time compared
|
||||||
|
//! as a window.
|
||||||
|
//!
|
||||||
|
//! Two minutes is the window. It is wide enough for the disagreement between
|
||||||
|
//! loggers and narrow enough to keep a legitimate re-work: contest rules forbid
|
||||||
|
//! a second contact with the same station on the same band and mode, so a
|
||||||
|
//! repeat inside two minutes is the same contact written twice, while the same
|
||||||
|
//! station on another band an hour later is a different QSO and keys differently
|
||||||
|
//! anyway.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use chrono::Duration;
|
||||||
|
|
||||||
|
use crate::qso::Qso;
|
||||||
|
|
||||||
|
/// How far apart two records of the same contact may be stamped.
|
||||||
|
pub const MATCH_WINDOW_MINUTES: i64 = 2;
|
||||||
|
|
||||||
|
/// Modes as they are compared, not as they are written.
|
||||||
|
///
|
||||||
|
/// A log that stored `USB` and one that stored `SSB` describe the same contact;
|
||||||
|
/// without this an import from either would duplicate every phone QSO in the
|
||||||
|
/// other.
|
||||||
|
fn normalized_mode(qso: &Qso) -> String {
|
||||||
|
match qso.mode.trim().to_uppercase().as_str() {
|
||||||
|
"USB" | "LSB" | "SSB" => "SSB".to_string(),
|
||||||
|
"CWR" => "CW".to_string(),
|
||||||
|
"SAM" => "AM".to_string(),
|
||||||
|
"WFM" => "FM".to_string(),
|
||||||
|
other => other.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key(qso: &Qso) -> (String, String, String) {
|
||||||
|
(
|
||||||
|
qso.call.clone(),
|
||||||
|
qso.band().unwrap_or("").to_string(),
|
||||||
|
normalized_mode(qso),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An index over an existing log, for asking whether an incoming record is
|
||||||
|
/// already held.
|
||||||
|
pub struct DuplicateIndex {
|
||||||
|
by_key: HashMap<(String, String, String), Vec<chrono::DateTime<chrono::Utc>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DuplicateIndex {
|
||||||
|
pub fn build(existing: &[Qso]) -> Self {
|
||||||
|
let mut by_key: HashMap<_, Vec<_>> = HashMap::new();
|
||||||
|
for qso in existing {
|
||||||
|
by_key.entry(key(qso)).or_default().push(qso.started_at);
|
||||||
|
}
|
||||||
|
Self { by_key }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this contact is already in the log.
|
||||||
|
pub fn contains(&self, candidate: &Qso) -> bool {
|
||||||
|
let window = Duration::minutes(MATCH_WINDOW_MINUTES);
|
||||||
|
self.by_key.get(&key(candidate)).is_some_and(|times| {
|
||||||
|
times
|
||||||
|
.iter()
|
||||||
|
// Compared as instants, so a contact either side of
|
||||||
|
// midnight matches rather than falling into two days.
|
||||||
|
.any(|held| (*held - candidate.started_at).abs() <= window)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remember a contact, so a file that repeats itself does not import twice.
|
||||||
|
pub fn insert(&mut self, qso: &Qso) {
|
||||||
|
self.by_key
|
||||||
|
.entry(key(qso))
|
||||||
|
.or_default()
|
||||||
|
.push(qso.started_at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which bands and modes a callsign has been worked on.
|
||||||
|
pub fn worked_before(existing: &[Qso], call: &str) -> Vec<(String, String)> {
|
||||||
|
let wanted = crate::qso::normalize_call(call);
|
||||||
|
let mut seen: Vec<(String, String)> = existing
|
||||||
|
.iter()
|
||||||
|
.filter(|qso| qso.call == wanted)
|
||||||
|
.map(|qso| (qso.band().unwrap_or("").to_string(), normalized_mode(qso)))
|
||||||
|
.collect();
|
||||||
|
seen.sort();
|
||||||
|
seen.dedup();
|
||||||
|
seen
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::qso::parse_adif_datetime;
|
||||||
|
|
||||||
|
fn qso(call: &str, hz: u64, mode: &str, time: &str) -> Qso {
|
||||||
|
Qso::new(
|
||||||
|
format!("{call}-{time}"),
|
||||||
|
parse_adif_datetime("20260807", Some(time)).expect("time"),
|
||||||
|
call,
|
||||||
|
hz,
|
||||||
|
mode,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_same_contact_stamped_a_minute_apart_is_one_contact() {
|
||||||
|
let held = vec![qso("SP1AA", 14_074_000, "FT8", "120000")];
|
||||||
|
let index = DuplicateIndex::build(&held);
|
||||||
|
assert!(index.contains(&qso("SP1AA", 14_074_000, "FT8", "120100")));
|
||||||
|
assert!(index.contains(&qso("SP1AA", 14_074_000, "FT8", "115900")));
|
||||||
|
// Beyond the window it is another contact.
|
||||||
|
assert!(!index.contains(&qso("SP1AA", 14_074_000, "FT8", "120300")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn phone_matches_however_the_other_logger_spelled_it() {
|
||||||
|
let held = vec![qso("SP1AA", 14_200_000, "SSB", "120000")];
|
||||||
|
let index = DuplicateIndex::build(&held);
|
||||||
|
assert!(index.contains(&qso("SP1AA", 14_200_000, "USB", "120000")));
|
||||||
|
assert!(index.contains(&qso("SP1AA", 14_200_000, "LSB", "120030")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn another_band_or_another_mode_is_another_contact() {
|
||||||
|
let held = vec![qso("SP1AA", 14_074_000, "FT8", "120000")];
|
||||||
|
let index = DuplicateIndex::build(&held);
|
||||||
|
// Same station, same minute, 40 m: a band change during a QSO happens.
|
||||||
|
assert!(!index.contains(&qso("SP1AA", 7_074_000, "FT8", "120000")));
|
||||||
|
assert!(!index.contains(&qso("SP1AA", 14_074_000, "CW", "120000")));
|
||||||
|
assert!(!index.contains(&qso("SP2BB", 14_074_000, "FT8", "120000")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_contact_either_side_of_midnight_still_matches() {
|
||||||
|
let before = Qso::new(
|
||||||
|
"1".into(),
|
||||||
|
parse_adif_datetime("20260807", Some("235930")).expect("time"),
|
||||||
|
"SP1AA",
|
||||||
|
14_074_000,
|
||||||
|
"FT8",
|
||||||
|
);
|
||||||
|
let after = Qso::new(
|
||||||
|
"2".into(),
|
||||||
|
parse_adif_datetime("20260808", Some("000030")).expect("time"),
|
||||||
|
"SP1AA",
|
||||||
|
14_074_000,
|
||||||
|
"FT8",
|
||||||
|
);
|
||||||
|
let index = DuplicateIndex::build(&[before]);
|
||||||
|
assert!(index.contains(&after));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_file_that_repeats_itself_imports_once() {
|
||||||
|
let mut index = DuplicateIndex::build(&[]);
|
||||||
|
let first = qso("SP1AA", 14_074_000, "FT8", "120000");
|
||||||
|
assert!(!index.contains(&first));
|
||||||
|
index.insert(&first);
|
||||||
|
assert!(index.contains(&qso("SP1AA", 14_074_000, "FT8", "120030")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worked_before_answers_by_band_and_mode() {
|
||||||
|
let held = vec![
|
||||||
|
qso("SP1AA", 14_074_000, "FT8", "120000"),
|
||||||
|
qso("SP1AA", 7_030_000, "CW", "130000"),
|
||||||
|
qso("SP1AA", 14_074_000, "FT8", "140000"),
|
||||||
|
qso("SP2BB", 14_200_000, "USB", "150000"),
|
||||||
|
];
|
||||||
|
let worked = worked_before(&held, "sp1aa");
|
||||||
|
assert_eq!(
|
||||||
|
worked,
|
||||||
|
vec![
|
||||||
|
("20m".to_string(), "FT8".to_string()),
|
||||||
|
("40m".to_string(), "CW".to_string()),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert!(worked_before(&held, "SP9ZZ").is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,510 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
//! The station logbook: contacts, the file they live in, and ADIF in and out.
|
||||||
|
//!
|
||||||
|
//! One log per station rather than one per rig. Awards and uploads count the
|
||||||
|
//! callsign, not the radio — a station worked on the second rig is still worked
|
||||||
|
//! — so the rig is recorded on the contact instead of dividing the log by it.
|
||||||
|
//! Where a rig stands does follow the contact, though: `my_gridsquare` comes
|
||||||
|
//! from the rig that made it, because rigs can be in different places.
|
||||||
|
|
||||||
|
pub mod adif;
|
||||||
|
pub mod cabrillo;
|
||||||
|
pub mod dedupe;
|
||||||
|
pub mod qso;
|
||||||
|
pub mod store;
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub use cabrillo::{write_cabrillo, CabrilloHeader};
|
||||||
|
pub use dedupe::{worked_before, DuplicateIndex, MATCH_WINDOW_MINUTES};
|
||||||
|
pub use qso::Qso;
|
||||||
|
pub use store::LogStore;
|
||||||
|
|
||||||
|
/// A fresh contact identity.
|
||||||
|
pub fn new_id() -> String {
|
||||||
|
uuid::Uuid::new_v4().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the log lives when the configuration does not say.
|
||||||
|
///
|
||||||
|
/// The data directory, not the config directory the bookmarks use and not the
|
||||||
|
/// cache directory the decode logs use: a log is neither a setting nor
|
||||||
|
/// disposable, and cache directories get swept by cleaners.
|
||||||
|
pub fn default_log_path() -> PathBuf {
|
||||||
|
dirs::data_dir()
|
||||||
|
.map(|dir| dir.join("trx-rs").join("logbook.jsonl"))
|
||||||
|
.unwrap_or_else(|| PathBuf::from("logbook.jsonl"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a filtered read asks for.
|
||||||
|
#[derive(Debug, Default, Clone, Deserialize)]
|
||||||
|
pub struct LogQuery {
|
||||||
|
/// Substring of the callsign, case-insensitive.
|
||||||
|
#[serde(default)]
|
||||||
|
pub call: Option<String>,
|
||||||
|
/// Only contacts in this contest.
|
||||||
|
#[serde(default)]
|
||||||
|
pub contest: Option<String>,
|
||||||
|
/// Only contacts the other station has confirmed, or only those they have
|
||||||
|
/// not.
|
||||||
|
#[serde(default)]
|
||||||
|
pub confirmed: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub band: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub mode: Option<String>,
|
||||||
|
/// Inclusive bounds on the contact's time.
|
||||||
|
#[serde(default)]
|
||||||
|
pub from: Option<DateTime<Utc>>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub to: Option<DateTime<Utc>>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub limit: Option<usize>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub offset: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One band's worth of the log.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct BandStatistics {
|
||||||
|
pub band: String,
|
||||||
|
pub contacts: usize,
|
||||||
|
/// Distinct callsigns, which is the number an award counts.
|
||||||
|
pub stations: usize,
|
||||||
|
pub confirmed: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What an import did.
|
||||||
|
#[derive(Debug, Default, Clone, Serialize)]
|
||||||
|
pub struct ImportOutcome {
|
||||||
|
pub added: usize,
|
||||||
|
/// Already held, by callsign, band, mode and the two-minute window.
|
||||||
|
pub duplicate: usize,
|
||||||
|
/// Records that could not be read as contacts, with the reason.
|
||||||
|
pub rejected: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The logbook, shared by whoever serves it.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Logbook {
|
||||||
|
store: Arc<Mutex<LogStore>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Logbook {
|
||||||
|
pub fn open(path: &std::path::Path) -> std::io::Result<Self> {
|
||||||
|
Ok(Self {
|
||||||
|
store: Arc::new(Mutex::new(LogStore::open(path)?)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_store<T>(&self, f: impl FnOnce(&mut LogStore) -> T) -> T {
|
||||||
|
// A poisoned lock means a panic while holding it, not a corrupt log --
|
||||||
|
// the file is on disk either way -- so carry on with what is there.
|
||||||
|
let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
f(&mut store)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn path(&self) -> PathBuf {
|
||||||
|
self.with_store(|store| store.path().to_path_buf())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn count(&self) -> usize {
|
||||||
|
self.with_store(|store| store.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Contacts matching `query`, newest first.
|
||||||
|
pub fn query(&self, query: &LogQuery) -> Vec<Qso> {
|
||||||
|
self.with_store(|store| {
|
||||||
|
let call = query.call.as_deref().map(str::to_uppercase);
|
||||||
|
let band = query.band.as_deref().map(str::to_lowercase);
|
||||||
|
let mode = query.mode.as_deref().map(str::to_uppercase);
|
||||||
|
let contest = query.contest.clone();
|
||||||
|
let matching: Vec<Qso> = store
|
||||||
|
.all()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|qso| {
|
||||||
|
call.as_ref().is_none_or(|c| qso.call.contains(c.as_str()))
|
||||||
|
&& band
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|b| qso.band().is_some_and(|found| found == b))
|
||||||
|
&& mode.as_ref().is_none_or(|m| qso.mode == *m)
|
||||||
|
&& query.from.is_none_or(|from| qso.started_at >= from)
|
||||||
|
&& query.to.is_none_or(|to| qso.started_at <= to)
|
||||||
|
&& contest.as_ref().is_none_or(|wanted| {
|
||||||
|
qso.contest_id
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|held| held.eq_ignore_ascii_case(wanted))
|
||||||
|
})
|
||||||
|
&& query
|
||||||
|
.confirmed
|
||||||
|
.is_none_or(|wanted| qso.is_confirmed() == wanted)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let offset = query.offset.unwrap_or(0).min(matching.len());
|
||||||
|
let end = query
|
||||||
|
.limit
|
||||||
|
.map_or(matching.len(), |limit| (offset + limit).min(matching.len()));
|
||||||
|
matching[offset..end].to_vec()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, id: &str) -> Option<Qso> {
|
||||||
|
self.with_store(|store| store.get(id).cloned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a contact, or replace one with the same id.
|
||||||
|
pub fn put(&self, qso: Qso) -> std::io::Result<Qso> {
|
||||||
|
self.with_store(|store| store.put(qso.clone()))?;
|
||||||
|
Ok(qso)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete(&self, id: &str) -> std::io::Result<bool> {
|
||||||
|
self.with_store(|store| store.delete(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which bands and modes a callsign has been worked on.
|
||||||
|
pub fn worked_before(&self, call: &str) -> Vec<(String, String)> {
|
||||||
|
self.with_store(|store| dedupe::worked_before(&store.all(), call))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Export as Cabrillo, for submitting a contest entry.
|
||||||
|
pub fn export_cabrillo(&self, query: &LogQuery, header: &CabrilloHeader) -> String {
|
||||||
|
cabrillo::write_cabrillo(&self.query(query), header)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What has been worked, and what has been confirmed, band by band.
|
||||||
|
///
|
||||||
|
/// One confirmation counts wherever it came from: an award wants a card or
|
||||||
|
/// an electronic match, not one of each.
|
||||||
|
pub fn band_statistics(&self) -> Vec<BandStatistics> {
|
||||||
|
self.with_store(|store| {
|
||||||
|
let mut by_band: std::collections::HashMap<String, BandStatistics> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
let mut stations: std::collections::HashMap<String, std::collections::HashSet<String>> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
for qso in store.all() {
|
||||||
|
let band = qso.band().unwrap_or("other").to_string();
|
||||||
|
let entry = by_band
|
||||||
|
.entry(band.clone())
|
||||||
|
.or_insert_with(|| BandStatistics {
|
||||||
|
band: band.clone(),
|
||||||
|
contacts: 0,
|
||||||
|
stations: 0,
|
||||||
|
confirmed: 0,
|
||||||
|
});
|
||||||
|
entry.contacts += 1;
|
||||||
|
if qso.is_confirmed() {
|
||||||
|
entry.confirmed += 1;
|
||||||
|
}
|
||||||
|
stations.entry(band).or_default().insert(qso.call.clone());
|
||||||
|
}
|
||||||
|
let mut all: Vec<BandStatistics> = by_band
|
||||||
|
.into_values()
|
||||||
|
.map(|mut entry| {
|
||||||
|
entry.stations = stations
|
||||||
|
.get(&entry.band)
|
||||||
|
.map_or(0, std::collections::HashSet::len);
|
||||||
|
entry
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
// Ordered by wavelength, longest first, as a band plan reads.
|
||||||
|
all.sort_by_key(|entry| qso::band_order(&entry.band));
|
||||||
|
all
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Export as ADIF, honouring the same filters as a read.
|
||||||
|
pub fn export_adi(&self, query: &LogQuery, program_version: &str) -> String {
|
||||||
|
adif::write_adi(&self.query(query), program_version)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Import an ADIF file, skipping contacts already held.
|
||||||
|
pub fn import_adi(&self, input: &[u8]) -> std::io::Result<ImportOutcome> {
|
||||||
|
let report = adif::parse_adi(input);
|
||||||
|
let mut outcome = ImportOutcome {
|
||||||
|
rejected: report.rejected,
|
||||||
|
..ImportOutcome::default()
|
||||||
|
};
|
||||||
|
self.with_store(|store| {
|
||||||
|
let mut index = DuplicateIndex::build(&store.all());
|
||||||
|
let mut fresh = Vec::new();
|
||||||
|
for qso in report.qsos {
|
||||||
|
if index.contains(&qso) {
|
||||||
|
outcome.duplicate += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
index.insert(&qso);
|
||||||
|
fresh.push(qso);
|
||||||
|
}
|
||||||
|
outcome.added = fresh.len();
|
||||||
|
store.put_all(fresh)
|
||||||
|
})?;
|
||||||
|
Ok(outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::qso::parse_adif_datetime;
|
||||||
|
|
||||||
|
fn new_book() -> (tempfile::TempDir, Logbook) {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let book = Logbook::open(&dir.path().join("logbook.jsonl")).expect("open");
|
||||||
|
(dir, book)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn qso(call: &str, hz: u64, mode: &str, time: &str) -> Qso {
|
||||||
|
Qso::new(
|
||||||
|
new_id(),
|
||||||
|
parse_adif_datetime("20260807", Some(time)).expect("time"),
|
||||||
|
call,
|
||||||
|
hz,
|
||||||
|
mode,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_log_answers_the_filters_it_is_asked() {
|
||||||
|
let (_dir, book) = new_book();
|
||||||
|
book.put(qso("SP1AA", 14_074_000, "FT8", "120000"))
|
||||||
|
.expect("put");
|
||||||
|
book.put(qso("SP2BB", 7_030_000, "CW", "130000"))
|
||||||
|
.expect("put");
|
||||||
|
book.put(qso("DL3CC", 14_200_000, "SSB", "140000"))
|
||||||
|
.expect("put");
|
||||||
|
|
||||||
|
assert_eq!(book.count(), 3);
|
||||||
|
assert_eq!(book.query(&LogQuery::default()).len(), 3);
|
||||||
|
// Newest first.
|
||||||
|
assert_eq!(book.query(&LogQuery::default())[0].call, "DL3CC");
|
||||||
|
|
||||||
|
let by_band = LogQuery {
|
||||||
|
band: Some("20m".into()),
|
||||||
|
..LogQuery::default()
|
||||||
|
};
|
||||||
|
assert_eq!(book.query(&by_band).len(), 2);
|
||||||
|
|
||||||
|
let by_call = LogQuery {
|
||||||
|
call: Some("sp".into()),
|
||||||
|
..LogQuery::default()
|
||||||
|
};
|
||||||
|
assert_eq!(book.query(&by_call).len(), 2);
|
||||||
|
|
||||||
|
let by_mode = LogQuery {
|
||||||
|
mode: Some("cw".into()),
|
||||||
|
..LogQuery::default()
|
||||||
|
};
|
||||||
|
assert_eq!(book.query(&by_mode)[0].call, "SP2BB");
|
||||||
|
|
||||||
|
let paged = LogQuery {
|
||||||
|
limit: Some(1),
|
||||||
|
offset: Some(1),
|
||||||
|
..LogQuery::default()
|
||||||
|
};
|
||||||
|
assert_eq!(book.query(&paged).len(), 1);
|
||||||
|
// Newest first, so offset 1 is the middle contact.
|
||||||
|
assert_eq!(book.query(&paged)[0].call, "SP2BB");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_import_skips_what_is_already_held_and_says_so() {
|
||||||
|
let (_dir, book) = new_book();
|
||||||
|
book.put(qso("SP1AA", 14_074_000, "FT8", "120000"))
|
||||||
|
.expect("put");
|
||||||
|
|
||||||
|
let file = concat!(
|
||||||
|
// the one already held, a minute out, as another logger stamped it
|
||||||
|
"<call:5>SP1AA<qso_date:8>20260807<time_on:6>120100<freq:6>14.074<mode:3>FT8<eor>\n",
|
||||||
|
// a new one
|
||||||
|
"<call:5>DL2XY<qso_date:8>20260807<time_on:6>121500<freq:6>14.074<mode:3>FT8<eor>\n",
|
||||||
|
// and something that is not a contact
|
||||||
|
"<call:5>NODAT<eor>\n",
|
||||||
|
);
|
||||||
|
let outcome = book.import_adi(file.as_bytes()).expect("import");
|
||||||
|
assert_eq!(outcome.added, 1);
|
||||||
|
assert_eq!(outcome.duplicate, 1);
|
||||||
|
assert_eq!(outcome.rejected.len(), 1);
|
||||||
|
assert_eq!(book.count(), 2);
|
||||||
|
|
||||||
|
// And importing the same file again adds nothing.
|
||||||
|
let again = book.import_adi(file.as_bytes()).expect("import again");
|
||||||
|
assert_eq!(again.added, 0);
|
||||||
|
assert_eq!(again.duplicate, 2);
|
||||||
|
assert_eq!(book.count(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_export_can_be_imported_into_an_empty_log() {
|
||||||
|
let (_dir, book) = new_book();
|
||||||
|
book.put(qso("SP1AA", 14_074_000, "FT8", "120000"))
|
||||||
|
.expect("put");
|
||||||
|
book.put(qso("DL3CC", 14_200_000, "SSB", "140000"))
|
||||||
|
.expect("put");
|
||||||
|
let exported = book.export_adi(&LogQuery::default(), "0.1.0");
|
||||||
|
|
||||||
|
let (_other_dir, other) = new_book();
|
||||||
|
let outcome = other.import_adi(exported.as_bytes()).expect("import");
|
||||||
|
assert_eq!(outcome.added, 2);
|
||||||
|
assert_eq!(other.count(), 2);
|
||||||
|
assert_eq!(other.query(&LogQuery::default())[0].call, "DL3CC");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worked_before_reads_through_the_log() {
|
||||||
|
let (_dir, book) = new_book();
|
||||||
|
book.put(qso("SP1AA", 14_074_000, "FT8", "120000"))
|
||||||
|
.expect("put");
|
||||||
|
book.put(qso("SP1AA", 7_030_000, "CW", "130000"))
|
||||||
|
.expect("put");
|
||||||
|
assert_eq!(book.worked_before("sp1aa").len(), 2);
|
||||||
|
assert!(book.worked_before("SP9ZZ").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn statistics_count_stations_and_confirmations_band_by_band() {
|
||||||
|
let (_dir, book) = new_book();
|
||||||
|
let mut first = qso("SP1AA", 14_074_000, "FT8", "120000");
|
||||||
|
first.lotw_qsl_rcvd = Some("Y".into());
|
||||||
|
book.put(first).expect("put");
|
||||||
|
// The same station again on the same band: one station, two contacts.
|
||||||
|
book.put(qso("SP1AA", 14_100_000, "SSB", "121000"))
|
||||||
|
.expect("put");
|
||||||
|
let mut third = qso("DL2BB", 7_030_000, "CW", "130000");
|
||||||
|
third.qsl_rcvd = Some("Y".into());
|
||||||
|
book.put(third).expect("put");
|
||||||
|
book.put(qso("OZ3CC", 7_040_000, "CW", "131000"))
|
||||||
|
.expect("put");
|
||||||
|
|
||||||
|
let stats = book.band_statistics();
|
||||||
|
// Longest wavelength first, as a band plan reads.
|
||||||
|
assert_eq!(
|
||||||
|
stats.iter().map(|s| s.band.as_str()).collect::<Vec<_>>(),
|
||||||
|
vec!["40m", "20m"]
|
||||||
|
);
|
||||||
|
let forty = &stats[0];
|
||||||
|
assert_eq!((forty.contacts, forty.stations, forty.confirmed), (2, 2, 1));
|
||||||
|
let twenty = &stats[1];
|
||||||
|
assert_eq!(
|
||||||
|
(twenty.contacts, twenty.stations, twenty.confirmed),
|
||||||
|
(2, 1, 1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_contest_entry_holds_only_that_contest() {
|
||||||
|
let (_dir, book) = new_book();
|
||||||
|
for (call, contest, serial) in [
|
||||||
|
("SP1AA", Some("CQ-WW-SSB"), 1),
|
||||||
|
("DL2BB", Some("CQ-WW-SSB"), 2),
|
||||||
|
("OZ3CC", None, 0),
|
||||||
|
] {
|
||||||
|
let mut entry = qso(call, 14_200_000, "SSB", "120000");
|
||||||
|
entry.contest_id = contest.map(str::to_string);
|
||||||
|
entry.stx = Some(serial);
|
||||||
|
entry.station_callsign = Some("SP0TRX".into());
|
||||||
|
entry.rst_sent = Some("59".into());
|
||||||
|
entry.rst_rcvd = Some("59".into());
|
||||||
|
book.put(entry).expect("put");
|
||||||
|
}
|
||||||
|
let query = LogQuery {
|
||||||
|
contest: Some("cq-ww-ssb".into()),
|
||||||
|
..LogQuery::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
book.query(&query).len(),
|
||||||
|
2,
|
||||||
|
"the contest filter is case-sensitive"
|
||||||
|
);
|
||||||
|
|
||||||
|
let text = book.export_cabrillo(
|
||||||
|
&query,
|
||||||
|
&CabrilloHeader {
|
||||||
|
contest: Some("CQ-WW-SSB".into()),
|
||||||
|
callsign: Some("SP0TRX".into()),
|
||||||
|
..CabrilloHeader::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let lines: Vec<&str> = text
|
||||||
|
.lines()
|
||||||
|
.filter(|line| line.starts_with("QSO:"))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(lines.len(), 2, "{text}");
|
||||||
|
assert!(
|
||||||
|
!text.contains("OZ3CC"),
|
||||||
|
"a contact outside the contest was submitted"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn confirmations_come_from_whichever_bureau_answered() {
|
||||||
|
let (_dir, book) = new_book();
|
||||||
|
let mut card = qso("SP1AA", 14_074_000, "FT8", "120000");
|
||||||
|
card.qsl_rcvd = Some("Y".into());
|
||||||
|
let mut lotw = qso("DL2BB", 14_074_000, "FT8", "121000");
|
||||||
|
lotw.lotw_qsl_rcvd = Some("Y".into());
|
||||||
|
let mut requested = qso("OZ3CC", 14_074_000, "FT8", "122000");
|
||||||
|
requested.qsl_rcvd = Some("R".into());
|
||||||
|
for entry in [card, lotw, requested] {
|
||||||
|
book.put(entry).expect("put");
|
||||||
|
}
|
||||||
|
let confirmed = LogQuery {
|
||||||
|
confirmed: Some(true),
|
||||||
|
..LogQuery::default()
|
||||||
|
};
|
||||||
|
let calls: Vec<String> = book.query(&confirmed).into_iter().map(|q| q.call).collect();
|
||||||
|
assert_eq!(calls.len(), 2, "{calls:?}");
|
||||||
|
assert!(calls.contains(&"SP1AA".to_string()) && calls.contains(&"DL2BB".to_string()));
|
||||||
|
// Requested is not confirmed.
|
||||||
|
let outstanding = LogQuery {
|
||||||
|
confirmed: Some(false),
|
||||||
|
..LogQuery::default()
|
||||||
|
};
|
||||||
|
assert_eq!(book.query(&outstanding)[0].call, "OZ3CC");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_contest_and_qsl_fields_survive_a_trip_through_adif() {
|
||||||
|
let (_dir, book) = new_book();
|
||||||
|
let mut entry = qso("SP1AA", 14_200_000, "SSB", "120000");
|
||||||
|
entry.contest_id = Some("CQ-WW-SSB".into());
|
||||||
|
entry.stx = Some(7);
|
||||||
|
entry.srx_string = Some("BAVARIA".into());
|
||||||
|
entry.cqz = Some(15);
|
||||||
|
entry.qsl_sent = Some("Y".into());
|
||||||
|
entry.lotw_qsl_rcvd = Some("Y".into());
|
||||||
|
book.put(entry).expect("put");
|
||||||
|
|
||||||
|
let exported = book.export_adi(&LogQuery::default(), "0.1.0");
|
||||||
|
let (_other_dir, other) = new_book();
|
||||||
|
other.import_adi(exported.as_bytes()).expect("import");
|
||||||
|
let back = &other.query(&LogQuery::default())[0];
|
||||||
|
assert_eq!(back.contest_id.as_deref(), Some("CQ-WW-SSB"));
|
||||||
|
assert_eq!(back.stx, Some(7));
|
||||||
|
assert_eq!(back.srx_string.as_deref(), Some("BAVARIA"));
|
||||||
|
assert_eq!(back.cqz, Some(15));
|
||||||
|
assert_eq!(back.qsl_sent.as_deref(), Some("Y"));
|
||||||
|
assert!(back.is_confirmed());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_contact_can_be_changed_and_forgotten() {
|
||||||
|
let (_dir, book) = new_book();
|
||||||
|
let mut entry = qso("SP1AA", 14_074_000, "FT8", "120000");
|
||||||
|
let id = entry.id.clone();
|
||||||
|
book.put(entry.clone()).expect("put");
|
||||||
|
entry.rst_rcvd = Some("-11".into());
|
||||||
|
book.put(entry).expect("edit");
|
||||||
|
assert_eq!(book.get(&id).and_then(|q| q.rst_rcvd), Some("-11".into()));
|
||||||
|
assert!(book.delete(&id).expect("delete"));
|
||||||
|
assert!(book.get(&id).is_none());
|
||||||
|
assert_eq!(book.count(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,465 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
//! The record a contact is kept as, and the translations between what the radio
|
||||||
|
//! says and what a log file wants.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use chrono::{DateTime, Datelike, NaiveDate, NaiveDateTime, NaiveTime, Timelike, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// One contact.
|
||||||
|
///
|
||||||
|
/// The named fields are the ones the application fills, reads or filters on.
|
||||||
|
/// Everything else an imported file carried is kept in [`Qso::extra`] under its
|
||||||
|
/// ADIF field name, so a log that came from another program leaves with what it
|
||||||
|
/// arrived with rather than what this one happens to understand.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct Qso {
|
||||||
|
/// Stable identity, ours, not from the file.
|
||||||
|
pub id: String,
|
||||||
|
/// UTC, always: the log is kept in the radio's time, not a browser's.
|
||||||
|
pub started_at: DateTime<Utc>,
|
||||||
|
/// Present once the operator records the end of a contact.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub ended_at: Option<DateTime<Utc>>,
|
||||||
|
/// The station worked.
|
||||||
|
pub call: String,
|
||||||
|
/// Dial frequency in Hz. The band is derived from it rather than stored
|
||||||
|
/// twice and allowed to disagree.
|
||||||
|
pub freq_hz: u64,
|
||||||
|
/// ADIF mode, e.g. `SSB`, `CW`, `FT8`.
|
||||||
|
pub mode: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub submode: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub rst_sent: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub rst_rcvd: Option<String>,
|
||||||
|
/// The worked station's Maidenhead locator.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub gridsquare: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub name: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub qth: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub comment: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tx_pwr_w: Option<f64>,
|
||||||
|
/// The callsign this station was on the air with.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub station_callsign: Option<String>,
|
||||||
|
/// The person at the key, which is not always the station.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub operator: Option<String>,
|
||||||
|
/// Our locator *for this contact*: rigs can stand in different places.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub my_gridsquare: Option<String>,
|
||||||
|
/// The rig it was worked on, by display name (ADIF `MY_RIG`).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub my_rig: Option<String>,
|
||||||
|
/// Which rig of this client, by id — ours, not ADIF's.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub rig_id: Option<String>,
|
||||||
|
/// The contest this contact counts towards, by its Cabrillo name.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub contest_id: Option<String>,
|
||||||
|
/// Serial sent. Kept as text as well as a number, because plenty of
|
||||||
|
/// exchanges are neither — a zone, a section, a name, a power.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub stx: Option<u32>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub stx_string: Option<String>,
|
||||||
|
/// Serial received, likewise.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub srx: Option<u32>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub srx_string: Option<String>,
|
||||||
|
/// CQ and ITU zones of the station worked.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cqz: Option<u8>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub ituz: Option<u8>,
|
||||||
|
/// Paper QSL, and the two card-less bureaux, as ADIF's single letters:
|
||||||
|
/// `Y` yes, `N` no, `R` requested, `I` ignore, `Q` queued, `V` verified.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub qsl_sent: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub qsl_rcvd: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub lotw_qsl_sent: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub lotw_qsl_rcvd: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub eqsl_qsl_sent: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub eqsl_qsl_rcvd: Option<String>,
|
||||||
|
/// ADIF fields this application does not model, kept verbatim.
|
||||||
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||||
|
pub extra: BTreeMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Qso {
|
||||||
|
/// A contact with only the fields every log needs.
|
||||||
|
pub fn new(
|
||||||
|
id: String,
|
||||||
|
started_at: DateTime<Utc>,
|
||||||
|
call: &str,
|
||||||
|
freq_hz: u64,
|
||||||
|
mode: &str,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
started_at,
|
||||||
|
ended_at: None,
|
||||||
|
call: normalize_call(call),
|
||||||
|
freq_hz,
|
||||||
|
mode: mode.trim().to_uppercase(),
|
||||||
|
submode: None,
|
||||||
|
rst_sent: None,
|
||||||
|
rst_rcvd: None,
|
||||||
|
gridsquare: None,
|
||||||
|
name: None,
|
||||||
|
qth: None,
|
||||||
|
comment: None,
|
||||||
|
tx_pwr_w: None,
|
||||||
|
station_callsign: None,
|
||||||
|
operator: None,
|
||||||
|
my_gridsquare: None,
|
||||||
|
my_rig: None,
|
||||||
|
rig_id: None,
|
||||||
|
contest_id: None,
|
||||||
|
stx: None,
|
||||||
|
stx_string: None,
|
||||||
|
srx: None,
|
||||||
|
srx_string: None,
|
||||||
|
cqz: None,
|
||||||
|
ituz: None,
|
||||||
|
qsl_sent: None,
|
||||||
|
qsl_rcvd: None,
|
||||||
|
lotw_qsl_sent: None,
|
||||||
|
lotw_qsl_rcvd: None,
|
||||||
|
eqsl_qsl_sent: None,
|
||||||
|
eqsl_qsl_rcvd: None,
|
||||||
|
extra: BTreeMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ADIF band name for the frequency, if it falls in an amateur band.
|
||||||
|
pub fn band(&self) -> Option<&'static str> {
|
||||||
|
band_for_hz(self.freq_hz)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the other station has confirmed this contact, anywhere.
|
||||||
|
///
|
||||||
|
/// One confirmation is a confirmation: an award needs one card or one
|
||||||
|
/// electronic match, not all three, and a log that counted them separately
|
||||||
|
/// would tell the operator they were three contacts short of what they have.
|
||||||
|
pub fn is_confirmed(&self) -> bool {
|
||||||
|
[&self.qsl_rcvd, &self.lotw_qsl_rcvd, &self.eqsl_qsl_rcvd]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.any(|state| {
|
||||||
|
let state = state.trim().to_uppercase();
|
||||||
|
state == "Y" || state == "V"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The exchange as it goes into a contest log: what was sent, or received.
|
||||||
|
pub fn exchange_sent(&self) -> Option<String> {
|
||||||
|
self.stx_string
|
||||||
|
.clone()
|
||||||
|
.or_else(|| self.stx.map(|serial| format!("{serial:03}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn exchange_received(&self) -> Option<String> {
|
||||||
|
self.srx_string
|
||||||
|
.clone()
|
||||||
|
.or_else(|| self.srx.map(|serial| format!("{serial:03}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What two logs have to agree on to be describing the same contact.
|
||||||
|
///
|
||||||
|
/// Not the time: that is compared as a window by [`crate::dedupe`], because
|
||||||
|
/// two programs rarely stamp the same QSO to the same minute.
|
||||||
|
pub fn match_key(&self) -> (String, String, String) {
|
||||||
|
(
|
||||||
|
self.call.clone(),
|
||||||
|
self.band().unwrap_or("").to_string(),
|
||||||
|
self.mode.clone(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Callsigns are compared, sorted and deduplicated, so they are held one way:
|
||||||
|
/// upper case, without the spaces a hurried operator leaves behind.
|
||||||
|
pub fn normalize_call(call: &str) -> String {
|
||||||
|
call.trim().to_uppercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Amateur bands by their ADIF names, as frequency ranges in Hz.
|
||||||
|
///
|
||||||
|
/// Ranges are the widest any region allows, since a log records what was heard
|
||||||
|
/// rather than what a licence permits, and a QSO on a band edge must not fall
|
||||||
|
/// out of the log because one region stops earlier than another.
|
||||||
|
const BANDS: &[(&str, u64, u64)] = &[
|
||||||
|
("2190m", 135_700, 137_800),
|
||||||
|
("630m", 472_000, 479_000),
|
||||||
|
("560m", 501_000, 504_000),
|
||||||
|
("160m", 1_800_000, 2_000_000),
|
||||||
|
("80m", 3_500_000, 4_000_000),
|
||||||
|
("60m", 5_060_000, 5_450_000),
|
||||||
|
("40m", 7_000_000, 7_300_000),
|
||||||
|
("30m", 10_100_000, 10_150_000),
|
||||||
|
("20m", 14_000_000, 14_350_000),
|
||||||
|
("17m", 18_068_000, 18_168_000),
|
||||||
|
("15m", 21_000_000, 21_450_000),
|
||||||
|
("12m", 24_890_000, 24_990_000),
|
||||||
|
("10m", 28_000_000, 29_700_000),
|
||||||
|
("8m", 40_000_000, 45_000_000),
|
||||||
|
("6m", 50_000_000, 54_000_000),
|
||||||
|
("5m", 54_000_001, 69_900_000),
|
||||||
|
("4m", 70_000_000, 71_000_000),
|
||||||
|
("2m", 144_000_000, 148_000_000),
|
||||||
|
("1.25m", 222_000_000, 225_000_000),
|
||||||
|
("70cm", 420_000_000, 450_000_000),
|
||||||
|
("33cm", 902_000_000, 928_000_000),
|
||||||
|
("23cm", 1_240_000_000, 1_300_000_000),
|
||||||
|
("13cm", 2_300_000_000, 2_450_000_000),
|
||||||
|
("9cm", 3_300_000_000, 3_500_000_000),
|
||||||
|
("6cm", 5_650_000_000, 5_925_000_000),
|
||||||
|
("3cm", 10_000_000_000, 10_500_000_000),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The ADIF band a frequency falls in, or `None` outside the amateur bands —
|
||||||
|
/// a broadcast or utility frequency has no band name, and inventing one would
|
||||||
|
/// put a lie in the log.
|
||||||
|
pub fn band_for_hz(hz: u64) -> Option<&'static str> {
|
||||||
|
BANDS
|
||||||
|
.iter()
|
||||||
|
.find(|(_, low, high)| hz >= *low && hz <= *high)
|
||||||
|
.map(|(name, _, _)| *name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The middle of a named band.
|
||||||
|
///
|
||||||
|
/// A log that recorded `20m` and no frequency knows the band and not the dial;
|
||||||
|
/// the midpoint is the honest reading of that, and the band name it exports
|
||||||
|
/// comes back the same.
|
||||||
|
pub fn band_midpoint_for_name(band: &str) -> Option<u64> {
|
||||||
|
let wanted = band.trim().to_lowercase();
|
||||||
|
BANDS
|
||||||
|
.iter()
|
||||||
|
.find(|(name, _, _)| *name == wanted)
|
||||||
|
.map(|(_, low, high)| low + (high - low) / 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a band sits in the table, for ordering a report by wavelength.
|
||||||
|
///
|
||||||
|
/// Anything not in the table sorts last, which is where "other" belongs.
|
||||||
|
pub fn band_order(band: &str) -> usize {
|
||||||
|
let wanted = band.trim().to_lowercase();
|
||||||
|
BANDS
|
||||||
|
.iter()
|
||||||
|
.position(|(name, _, _)| *name == wanted)
|
||||||
|
.unwrap_or(BANDS.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The frequency in MHz, as ADIF writes it.
|
||||||
|
pub fn freq_mhz_string(hz: u64) -> String {
|
||||||
|
// Six decimals is 1 Hz, which is finer than any dial reports and is what
|
||||||
|
// other loggers write. The zeros are then dropped from the fraction only:
|
||||||
|
// trimming the whole string would turn 20.000000 into 2, since the run of
|
||||||
|
// trailing zeros reaches back through the point into the integer.
|
||||||
|
let text = format!("{:.6}", hz as f64 / 1e6);
|
||||||
|
match text.split_once('.') {
|
||||||
|
Some((whole, fraction)) => {
|
||||||
|
let fraction = fraction.trim_end_matches('0');
|
||||||
|
if fraction.is_empty() {
|
||||||
|
whole.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{whole}.{fraction}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ADIF's MHz back to Hz.
|
||||||
|
pub fn hz_from_mhz_string(text: &str) -> Option<u64> {
|
||||||
|
let mhz: f64 = text.trim().parse().ok()?;
|
||||||
|
if !mhz.is_finite() || mhz < 0.0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((mhz * 1e6).round() as u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a rig mode becomes in a log.
|
||||||
|
///
|
||||||
|
/// A rig reports what it is demodulating; a log records what the contact was
|
||||||
|
/// made on, and the two are not the same word. `DIG` is deliberately absent:
|
||||||
|
/// a rig in `DIG` is in FT8 or FT4 or something else depending on which decoder
|
||||||
|
/// is running, so the caller has to say which — see [`mode_for_decoder`].
|
||||||
|
pub fn adif_mode_for_rig_mode(rig_mode: &str) -> Option<(&'static str, Option<&'static str>)> {
|
||||||
|
match rig_mode.trim().to_uppercase().as_str() {
|
||||||
|
"USB" => Some(("SSB", Some("USB"))),
|
||||||
|
"LSB" => Some(("SSB", Some("LSB"))),
|
||||||
|
"CW" | "CWR" => Some(("CW", None)),
|
||||||
|
"AM" | "SAM" => Some(("AM", None)),
|
||||||
|
"FM" | "WFM" => Some(("FM", None)),
|
||||||
|
"PKT" => Some(("PKT", None)),
|
||||||
|
// AIS and VDES are maritime, not amateur: nothing on them is a QSO.
|
||||||
|
// DIG says nothing on its own, and Other(..) is whatever a backend
|
||||||
|
// called it, which is the operator's to confirm rather than ours to
|
||||||
|
// guess into the log.
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a decoder's contacts are logged as.
|
||||||
|
///
|
||||||
|
/// WSPR is absent on purpose: it is a beacon mode. Hearing a beacon is not a
|
||||||
|
/// contact, so a WSPR spot never opens a log entry.
|
||||||
|
pub fn mode_for_decoder(decoder_id: &str) -> Option<(&'static str, Option<&'static str>)> {
|
||||||
|
match decoder_id.trim().to_lowercase().as_str() {
|
||||||
|
"ft8" => Some(("FT8", None)),
|
||||||
|
// FT4 is a submode of MFSK in ADIF, which is how WSJT-X logs it.
|
||||||
|
"ft4" => Some(("MFSK", Some("FT4"))),
|
||||||
|
"ft2" => Some(("MFSK", Some("FT2"))),
|
||||||
|
"cw" => Some(("CW", None)),
|
||||||
|
"aprs" | "hf-aprs" | "hf_aprs" => Some(("PKT", None)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ADIF writes dates as `YYYYMMDD`, always UTC.
|
||||||
|
pub fn adif_date(at: &DateTime<Utc>) -> String {
|
||||||
|
format!("{:04}{:02}{:02}", at.year(), at.month(), at.day())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ADIF writes times as `HHMMSS`, always UTC.
|
||||||
|
pub fn adif_time(at: &DateTime<Utc>) -> String {
|
||||||
|
format!("{:02}{:02}{:02}", at.hour(), at.minute(), at.second())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ADIF's date and time back to an instant.
|
||||||
|
///
|
||||||
|
/// Times come as `HHMMSS` or `HHMM`; both are in the wild, and a missing time
|
||||||
|
/// is treated as midnight rather than rejecting the record, because a log with
|
||||||
|
/// a date and no time is still a log.
|
||||||
|
pub fn parse_adif_datetime(date: &str, time: Option<&str>) -> Option<DateTime<Utc>> {
|
||||||
|
let date = date.trim();
|
||||||
|
if date.len() != 8 || !date.bytes().all(|b| b.is_ascii_digit()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let year: i32 = date[0..4].parse().ok()?;
|
||||||
|
let month: u32 = date[4..6].parse().ok()?;
|
||||||
|
let day: u32 = date[6..8].parse().ok()?;
|
||||||
|
let date = NaiveDate::from_ymd_opt(year, month, day)?;
|
||||||
|
|
||||||
|
let time = time.map(str::trim).filter(|t| !t.is_empty());
|
||||||
|
let time = match time {
|
||||||
|
None => NaiveTime::from_hms_opt(0, 0, 0)?,
|
||||||
|
Some(text) if text.len() == 6 || text.len() == 4 => {
|
||||||
|
if !text.bytes().all(|b| b.is_ascii_digit()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let hour: u32 = text[0..2].parse().ok()?;
|
||||||
|
let minute: u32 = text[2..4].parse().ok()?;
|
||||||
|
let second: u32 = if text.len() == 6 {
|
||||||
|
text[4..6].parse().ok()?
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
NaiveTime::from_hms_opt(hour, minute, second)?
|
||||||
|
}
|
||||||
|
Some(_) => return None,
|
||||||
|
};
|
||||||
|
Some(NaiveDateTime::new(date, time).and_utc())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bands_are_named_from_the_dial() {
|
||||||
|
assert_eq!(band_for_hz(14_074_000), Some("20m"));
|
||||||
|
assert_eq!(band_for_hz(7_030_000), Some("40m"));
|
||||||
|
assert_eq!(band_for_hz(144_300_000), Some("2m"));
|
||||||
|
// A band edge is in the band: a QSO at 14.350 is a 20 m QSO.
|
||||||
|
assert_eq!(band_for_hz(14_350_000), Some("20m"));
|
||||||
|
// Broadcast and utility frequencies have no band, and must not be given
|
||||||
|
// one -- a log that says 49m would be wrong, not merely unhelpful.
|
||||||
|
assert_eq!(band_for_hz(6_075_000), None);
|
||||||
|
assert_eq!(band_for_hz(162_025_000), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frequencies_survive_the_trip_through_megahertz() {
|
||||||
|
for hz in [
|
||||||
|
14_074_000_u64,
|
||||||
|
7_030_000,
|
||||||
|
1_840_000,
|
||||||
|
432_100_000,
|
||||||
|
10_138_700,
|
||||||
|
20_000_000,
|
||||||
|
100_000_000,
|
||||||
|
] {
|
||||||
|
let text = freq_mhz_string(hz);
|
||||||
|
assert_eq!(
|
||||||
|
hz_from_mhz_string(&text),
|
||||||
|
Some(hz),
|
||||||
|
"{text} came back wrong"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(freq_mhz_string(14_074_000), "14.074");
|
||||||
|
assert_eq!(freq_mhz_string(14_000_000), "14");
|
||||||
|
// A whole number of MHz ending in a zero: trimming the string rather
|
||||||
|
// than the fraction turned 20 MHz into 2 MHz, and 100 into 1.
|
||||||
|
assert_eq!(freq_mhz_string(20_000_000), "20");
|
||||||
|
assert_eq!(freq_mhz_string(100_000_000), "100");
|
||||||
|
assert_eq!(freq_mhz_string(10_000_000), "10");
|
||||||
|
assert_eq!(freq_mhz_string(50_313_000), "50.313");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_rig_mode_is_not_an_adif_mode() {
|
||||||
|
assert_eq!(adif_mode_for_rig_mode("USB"), Some(("SSB", Some("USB"))));
|
||||||
|
assert_eq!(adif_mode_for_rig_mode("lsb"), Some(("SSB", Some("LSB"))));
|
||||||
|
assert_eq!(adif_mode_for_rig_mode("CWR"), Some(("CW", None)));
|
||||||
|
assert_eq!(adif_mode_for_rig_mode("SAM"), Some(("AM", None)));
|
||||||
|
// The rig cannot answer for DIG, and must not be allowed to guess.
|
||||||
|
assert_eq!(adif_mode_for_rig_mode("DIG"), None);
|
||||||
|
// Nor are the maritime modes contacts at all.
|
||||||
|
assert_eq!(adif_mode_for_rig_mode("AIS"), None);
|
||||||
|
assert_eq!(adif_mode_for_rig_mode("VDES"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_decoder_answers_for_the_digital_modes() {
|
||||||
|
assert_eq!(mode_for_decoder("ft8"), Some(("FT8", None)));
|
||||||
|
assert_eq!(mode_for_decoder("ft4"), Some(("MFSK", Some("FT4"))));
|
||||||
|
assert_eq!(mode_for_decoder("aprs"), Some(("PKT", None)));
|
||||||
|
// Hearing a beacon is not a contact.
|
||||||
|
assert_eq!(mode_for_decoder("wspr"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adif_times_are_read_in_both_widths_and_written_in_one() {
|
||||||
|
let with_seconds = parse_adif_datetime("20260807", Some("121530")).expect("HHMMSS");
|
||||||
|
let without = parse_adif_datetime("20260807", Some("1215")).expect("HHMM");
|
||||||
|
assert_eq!(adif_date(&with_seconds), "20260807");
|
||||||
|
assert_eq!(adif_time(&with_seconds), "121530");
|
||||||
|
assert_eq!(adif_time(&without), "121500");
|
||||||
|
// A date with no time is still a log entry.
|
||||||
|
assert!(parse_adif_datetime("20260807", None).is_some());
|
||||||
|
// Nonsense is not.
|
||||||
|
assert!(parse_adif_datetime("2026080", Some("1215")).is_none());
|
||||||
|
assert!(parse_adif_datetime("20261307", Some("1215")).is_none());
|
||||||
|
assert!(parse_adif_datetime("20260807", Some("12:15")).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
//! Where the log lives.
|
||||||
|
//!
|
||||||
|
//! One JSON Lines file, appended to and never rewritten in place. A log is the
|
||||||
|
//! one thing in this application that cannot be regenerated: a bookmark can be
|
||||||
|
//! typed again and a decode will come round again, but a contact happened once.
|
||||||
|
//! So every write is an append of a single line — atomic as far as a crash is
|
||||||
|
//! concerned, since a torn last line loses only the record being written and is
|
||||||
|
//! skipped on the next load — rather than a dump of the whole file, which at
|
||||||
|
//! forty thousand QSOs would rewrite megabytes to log one contact and lose all
|
||||||
|
//! of them if the power went halfway.
|
||||||
|
//!
|
||||||
|
//! An edit appends a new revision of the same id and a delete appends a
|
||||||
|
//! tombstone; the load keeps the last word on each id. Compaction rewrites the
|
||||||
|
//! file, through a temporary and a rename, once the superseded records outnumber
|
||||||
|
//! the live ones.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs::{create_dir_all, File, OpenOptions};
|
||||||
|
use std::io::{BufRead, BufReader, BufWriter, Write};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::qso::Qso;
|
||||||
|
|
||||||
|
/// One line of the file.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "op", rename_all = "snake_case")]
|
||||||
|
enum Entry {
|
||||||
|
/// A contact, as it stands after this line.
|
||||||
|
Qso(Box<Qso>),
|
||||||
|
/// This id is gone.
|
||||||
|
Delete { id: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compact once more than this share of the file is superseded.
|
||||||
|
const COMPACT_WHEN_STALE_RATIO: f64 = 0.5;
|
||||||
|
/// ...but not while the file is still trivially small.
|
||||||
|
const COMPACT_MIN_LINES: usize = 256;
|
||||||
|
|
||||||
|
pub struct LogStore {
|
||||||
|
path: PathBuf,
|
||||||
|
/// Live records by id. The log is small enough to hold: forty thousand
|
||||||
|
/// QSOs is a few tens of megabytes, and every read wants it in memory.
|
||||||
|
qsos: HashMap<String, Qso>,
|
||||||
|
/// Lines in the file, live or not, for deciding when to compact.
|
||||||
|
lines_written: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LogStore {
|
||||||
|
/// Open the log at `path`, creating it and its directory if need be.
|
||||||
|
///
|
||||||
|
/// A line that cannot be read is skipped and counted rather than failing the
|
||||||
|
/// open: a half-written last line from a crash must not cost the operator
|
||||||
|
/// the rest of the log.
|
||||||
|
pub fn open(path: &Path) -> std::io::Result<Self> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let mut store = Self {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
qsos: HashMap::new(),
|
||||||
|
lines_written: 0,
|
||||||
|
};
|
||||||
|
if path.exists() {
|
||||||
|
let file = File::open(path)?;
|
||||||
|
let mut damaged = 0usize;
|
||||||
|
for line in BufReader::new(file).lines() {
|
||||||
|
let line = line?;
|
||||||
|
if line.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
store.lines_written += 1;
|
||||||
|
match serde_json::from_str::<Entry>(&line) {
|
||||||
|
Ok(Entry::Qso(qso)) => {
|
||||||
|
store.qsos.insert(qso.id.clone(), *qso);
|
||||||
|
}
|
||||||
|
Ok(Entry::Delete { id }) => {
|
||||||
|
store.qsos.remove(&id);
|
||||||
|
}
|
||||||
|
Err(_) => damaged += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if damaged > 0 {
|
||||||
|
tracing::warn!(
|
||||||
|
"logbook: skipped {damaged} unreadable line(s) in {}",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(store)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn path(&self) -> &Path {
|
||||||
|
&self.path
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.qsos.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.qsos.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every contact, newest first.
|
||||||
|
pub fn all(&self) -> Vec<Qso> {
|
||||||
|
let mut all: Vec<Qso> = self.qsos.values().cloned().collect();
|
||||||
|
all.sort_by(|a, b| b.started_at.cmp(&a.started_at).then(a.id.cmp(&b.id)));
|
||||||
|
all
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, id: &str) -> Option<&Qso> {
|
||||||
|
self.qsos.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a contact, new or changed.
|
||||||
|
pub fn put(&mut self, qso: Qso) -> std::io::Result<()> {
|
||||||
|
self.append(&Entry::Qso(Box::new(qso.clone())))?;
|
||||||
|
self.qsos.insert(qso.id.clone(), qso);
|
||||||
|
self.compact_if_needed()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write several contacts, appending them in one open of the file.
|
||||||
|
pub fn put_all(&mut self, qsos: Vec<Qso>) -> std::io::Result<()> {
|
||||||
|
if qsos.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let mut file = BufWriter::new(
|
||||||
|
OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&self.path)?,
|
||||||
|
);
|
||||||
|
for qso in &qsos {
|
||||||
|
let line = serde_json::to_string(&Entry::Qso(Box::new(qso.clone())))
|
||||||
|
.map_err(std::io::Error::other)?;
|
||||||
|
writeln!(file, "{line}")?;
|
||||||
|
self.lines_written += 1;
|
||||||
|
}
|
||||||
|
file.flush()?;
|
||||||
|
for qso in qsos {
|
||||||
|
self.qsos.insert(qso.id.clone(), qso);
|
||||||
|
}
|
||||||
|
self.compact_if_needed()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget a contact. `Ok(false)` if there was nothing to forget.
|
||||||
|
pub fn delete(&mut self, id: &str) -> std::io::Result<bool> {
|
||||||
|
if !self.qsos.contains_key(id) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
self.append(&Entry::Delete { id: id.to_string() })?;
|
||||||
|
self.qsos.remove(id);
|
||||||
|
self.compact_if_needed()?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append(&mut self, entry: &Entry) -> std::io::Result<()> {
|
||||||
|
let line = serde_json::to_string(entry).map_err(std::io::Error::other)?;
|
||||||
|
let mut file = OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&self.path)?;
|
||||||
|
writeln!(file, "{line}")?;
|
||||||
|
file.flush()?;
|
||||||
|
self.lines_written += 1;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compact_if_needed(&mut self) -> std::io::Result<()> {
|
||||||
|
if self.lines_written < COMPACT_MIN_LINES {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let live = self.qsos.len();
|
||||||
|
let stale = self.lines_written.saturating_sub(live);
|
||||||
|
if (stale as f64) < (self.lines_written as f64) * COMPACT_WHEN_STALE_RATIO {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
self.compact()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rewrite the file with one line per live contact.
|
||||||
|
///
|
||||||
|
/// Through a temporary and a rename, so an interrupted compaction leaves
|
||||||
|
/// the old file whole rather than a half-written new one.
|
||||||
|
pub fn compact(&mut self) -> std::io::Result<()> {
|
||||||
|
let temporary = self.path.with_extension("jsonl.compacting");
|
||||||
|
{
|
||||||
|
let mut file = BufWriter::new(File::create(&temporary)?);
|
||||||
|
for qso in self.all() {
|
||||||
|
let line = serde_json::to_string(&Entry::Qso(Box::new(qso)))
|
||||||
|
.map_err(std::io::Error::other)?;
|
||||||
|
writeln!(file, "{line}")?;
|
||||||
|
}
|
||||||
|
file.flush()?;
|
||||||
|
}
|
||||||
|
std::fs::rename(&temporary, &self.path)?;
|
||||||
|
self.lines_written = self.qsos.len();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::qso::parse_adif_datetime;
|
||||||
|
|
||||||
|
fn qso(id: &str, call: &str, minute: u32) -> Qso {
|
||||||
|
Qso::new(
|
||||||
|
id.to_string(),
|
||||||
|
parse_adif_datetime("20260807", Some(&format!("12{minute:02}00"))).expect("time"),
|
||||||
|
call,
|
||||||
|
14_074_000,
|
||||||
|
"FT8",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_log_survives_being_closed_and_opened() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let path = dir.path().join("logbook.jsonl");
|
||||||
|
|
||||||
|
let mut store = LogStore::open(&path).expect("open");
|
||||||
|
store.put(qso("a", "SP1AA", 0)).expect("put");
|
||||||
|
store.put(qso("b", "SP2BB", 1)).expect("put");
|
||||||
|
drop(store);
|
||||||
|
|
||||||
|
let store = LogStore::open(&path).expect("reopen");
|
||||||
|
assert_eq!(store.len(), 2);
|
||||||
|
assert_eq!(store.get("a").map(|q| q.call.as_str()), Some("SP1AA"));
|
||||||
|
// Newest first.
|
||||||
|
assert_eq!(store.all()[0].call, "SP2BB");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_last_word_on_an_id_is_the_one_that_counts() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let path = dir.path().join("logbook.jsonl");
|
||||||
|
|
||||||
|
let mut store = LogStore::open(&path).expect("open");
|
||||||
|
store.put(qso("a", "SP1AA", 0)).expect("put");
|
||||||
|
let mut edited = qso("a", "SP1AA", 0);
|
||||||
|
edited.rst_sent = Some("-05".into());
|
||||||
|
store.put(edited).expect("edit");
|
||||||
|
store.put(qso("b", "SP2BB", 1)).expect("put");
|
||||||
|
assert!(store.delete("b").expect("delete"));
|
||||||
|
assert!(!store.delete("b").expect("delete again"));
|
||||||
|
drop(store);
|
||||||
|
|
||||||
|
let store = LogStore::open(&path).expect("reopen");
|
||||||
|
assert_eq!(store.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
store.get("a").and_then(|q| q.rst_sent.clone()),
|
||||||
|
Some("-05".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A crash mid-write leaves a torn line. It costs that record and nothing
|
||||||
|
/// else — the point of appending one line at a time.
|
||||||
|
#[test]
|
||||||
|
fn a_torn_last_line_costs_only_itself() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let path = dir.path().join("logbook.jsonl");
|
||||||
|
|
||||||
|
let mut store = LogStore::open(&path).expect("open");
|
||||||
|
store.put(qso("a", "SP1AA", 0)).expect("put");
|
||||||
|
store.put(qso("b", "SP2BB", 1)).expect("put");
|
||||||
|
drop(store);
|
||||||
|
|
||||||
|
let mut text = std::fs::read_to_string(&path).expect("read");
|
||||||
|
text.push_str("{\"op\":\"qso\",\"id\":\"c\",\"call\":\"SP3");
|
||||||
|
std::fs::write(&path, text).expect("write");
|
||||||
|
|
||||||
|
let store = LogStore::open(&path).expect("reopen past the tear");
|
||||||
|
assert_eq!(store.len(), 2);
|
||||||
|
assert!(store.get("a").is_some() && store.get("b").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compaction_keeps_the_log_and_shortens_the_file() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let path = dir.path().join("logbook.jsonl");
|
||||||
|
|
||||||
|
let mut store = LogStore::open(&path).expect("open");
|
||||||
|
// One live record, rewritten until compaction triggers.
|
||||||
|
for index in 0..COMPACT_MIN_LINES + 10 {
|
||||||
|
let mut entry = qso("a", "SP1AA", 0);
|
||||||
|
entry.comment = Some(format!("revision {index}"));
|
||||||
|
store.put(entry).expect("put");
|
||||||
|
}
|
||||||
|
let lines = std::fs::read_to_string(&path)
|
||||||
|
.expect("read")
|
||||||
|
.lines()
|
||||||
|
.count();
|
||||||
|
assert_eq!(store.len(), 1);
|
||||||
|
// Compacted along the way: the file holds the revisions written since,
|
||||||
|
// not the two hundred and sixty-six that were written.
|
||||||
|
assert!(
|
||||||
|
lines < COMPACT_MIN_LINES,
|
||||||
|
"the file kept {lines} lines for one contact"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store.get("a").and_then(|q| q.comment.clone()),
|
||||||
|
Some(format!("revision {}", COMPACT_MIN_LINES + 9))
|
||||||
|
);
|
||||||
|
|
||||||
|
// And the compacted file still reads.
|
||||||
|
drop(store);
|
||||||
|
let store = LogStore::open(&path).expect("reopen");
|
||||||
|
assert_eq!(store.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_import_appends_in_one_pass() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let path = dir.path().join("logbook.jsonl");
|
||||||
|
let mut store = LogStore::open(&path).expect("open");
|
||||||
|
store
|
||||||
|
.put_all(vec![qso("a", "SP1AA", 0), qso("b", "SP2BB", 1)])
|
||||||
|
.expect("put_all");
|
||||||
|
assert_eq!(store.len(), 2);
|
||||||
|
drop(store);
|
||||||
|
assert_eq!(LogStore::open(&path).expect("reopen").len(), 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
+139
-88
@@ -34,6 +34,23 @@ pub struct ClientConfig {
|
|||||||
pub remotes: Vec<RemoteEntry>,
|
pub remotes: Vec<RemoteEntry>,
|
||||||
/// Frontend configurations
|
/// Frontend configurations
|
||||||
pub frontends: FrontendsConfig,
|
pub frontends: FrontendsConfig,
|
||||||
|
/// Station logbook
|
||||||
|
pub logbook: LogbookConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Station logbook settings.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct LogbookConfig {
|
||||||
|
/// Where the log is kept.
|
||||||
|
///
|
||||||
|
/// Defaults to `logbook.jsonl` in the user's data directory — not the
|
||||||
|
/// config directory the bookmarks use, and not the cache directory the
|
||||||
|
/// decode logs use: a log of contacts is neither a setting nor disposable,
|
||||||
|
/// and cache directories are swept by cleaners. Configurable because a
|
||||||
|
/// station that keeps its log on a synced or backed-up volume has to be
|
||||||
|
/// able to say so.
|
||||||
|
pub path: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// General application settings.
|
/// General application settings.
|
||||||
@@ -251,18 +268,21 @@ impl AsRef<str> for CookieSameSite {
|
|||||||
pub struct HttpAuthConfig {
|
pub struct HttpAuthConfig {
|
||||||
/// Enable HTTP frontend authentication
|
/// Enable HTTP frontend authentication
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
/// Passphrase for read-only access (rx role)
|
/// JSON file containing the managed user database.
|
||||||
pub rx_passphrase: Option<String>,
|
pub users_file: String,
|
||||||
/// Read the rx passphrase from this file instead.
|
/// 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.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub rx_passphrase_file: Option<String>,
|
pub bootstrap_admin_password_file: Option<String>,
|
||||||
/// Passphrase for full control access (control role)
|
/// Create a Guest account when bootstrapping a new database.
|
||||||
pub control_passphrase: Option<String>,
|
pub bootstrap_read_enabled: bool,
|
||||||
/// Read the control passphrase from this file instead.
|
/// Username for the Guest bootstrap account.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
pub bootstrap_read_username: String,
|
||||||
pub control_passphrase_file: Option<String>,
|
/// Password for the Guest bootstrap account.
|
||||||
/// Enforce TX/PTT access control (hide from unauthenticated/rx users)
|
pub bootstrap_read_password: Option<String>,
|
||||||
pub tx_access_control_enabled: bool,
|
|
||||||
/// Session time-to-live in minutes
|
/// Session time-to-live in minutes
|
||||||
pub session_ttl_min: u64,
|
pub session_ttl_min: u64,
|
||||||
/// Set Secure flag on session cookie (required for HTTPS)
|
/// Set Secure flag on session cookie (required for HTTPS)
|
||||||
@@ -275,11 +295,13 @@ impl Default for HttpAuthConfig {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
rx_passphrase: None,
|
users_file: "trx-http-users.json".to_string(),
|
||||||
rx_passphrase_file: None,
|
bootstrap_admin_username: None,
|
||||||
control_passphrase: None,
|
bootstrap_admin_password: None,
|
||||||
control_passphrase_file: None,
|
bootstrap_admin_password_file: None,
|
||||||
tx_access_control_enabled: true,
|
bootstrap_read_enabled: true,
|
||||||
|
bootstrap_read_username: "guest".to_string(),
|
||||||
|
bootstrap_read_password: Some("guest".to_string()),
|
||||||
session_ttl_min: 480,
|
session_ttl_min: 480,
|
||||||
cookie_secure: false,
|
cookie_secure: false,
|
||||||
cookie_same_site: CookieSameSite::Lax,
|
cookie_same_site: CookieSameSite::Lax,
|
||||||
@@ -785,14 +807,9 @@ impl ClientConfig {
|
|||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
resolve_secret(
|
resolve_secret(
|
||||||
&mut self.frontends.http.auth.rx_passphrase,
|
&mut self.frontends.http.auth.bootstrap_admin_password,
|
||||||
&self.frontends.http.auth.rx_passphrase_file,
|
&self.frontends.http.auth.bootstrap_admin_password_file,
|
||||||
"[frontends.http.auth].rx_passphrase",
|
"[frontends.http.auth].bootstrap_admin_password",
|
||||||
)?;
|
|
||||||
resolve_secret(
|
|
||||||
&mut self.frontends.http.auth.control_passphrase,
|
|
||||||
&self.frontends.http.auth.control_passphrase_file,
|
|
||||||
"[frontends.http.auth].control_passphrase",
|
|
||||||
)?;
|
)?;
|
||||||
resolve_secret_list(
|
resolve_secret_list(
|
||||||
&mut self.frontends.http_json.auth.tokens,
|
&mut self.frontends.http_json.auth.tokens,
|
||||||
@@ -802,7 +819,7 @@ impl ClientConfig {
|
|||||||
|
|
||||||
if let Some(path) = config_path {
|
if let Some(path) = config_path {
|
||||||
if self.has_inline_secrets() {
|
if self.has_inline_secrets() {
|
||||||
crate::secrets::warn_if_group_readable(path, "tokens/passphrases");
|
crate::secrets::warn_if_group_readable(path, "tokens/passwords");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -815,10 +832,22 @@ impl ClientConfig {
|
|||||||
.remotes
|
.remotes
|
||||||
.iter()
|
.iter()
|
||||||
.any(|r| r.auth.token_file.is_none() && r.auth.token.is_some())
|
.any(|r| r.auth.token_file.is_none() && r.auth.token.is_some())
|
||||||
|| self.frontends.http.auth.rx_passphrase_file.is_none()
|
|| self
|
||||||
&& self.frontends.http.auth.rx_passphrase.is_some()
|
.frontends
|
||||||
|| self.frontends.http.auth.control_passphrase_file.is_none()
|
.http
|
||||||
&& self.frontends.http.auth.control_passphrase.is_some()
|
.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_json.auth.tokens_file.is_none()
|
|| self.frontends.http_json.auth.tokens_file.is_none()
|
||||||
&& !self.frontends.http_json.auth.tokens.is_empty()
|
&& !self.frontends.http_json.auth.tokens.is_empty()
|
||||||
}
|
}
|
||||||
@@ -888,11 +917,13 @@ impl ClientConfig {
|
|||||||
decode_history_retention_min_by_rig: HashMap::new(),
|
decode_history_retention_min_by_rig: HashMap::new(),
|
||||||
auth: HttpAuthConfig {
|
auth: HttpAuthConfig {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
rx_passphrase: Some("rx-passphrase-example".to_string()),
|
users_file: "trx-http-users.json".to_string(),
|
||||||
rx_passphrase_file: None,
|
bootstrap_admin_username: Some("admin".to_string()),
|
||||||
control_passphrase: Some("control-passphrase-example".to_string()),
|
bootstrap_admin_password: Some("change-this-password".to_string()),
|
||||||
control_passphrase_file: None,
|
bootstrap_admin_password_file: None,
|
||||||
tx_access_control_enabled: true,
|
bootstrap_read_enabled: true,
|
||||||
|
bootstrap_read_username: "guest".to_string(),
|
||||||
|
bootstrap_read_password: Some("guest".to_string()),
|
||||||
session_ttl_min: 480,
|
session_ttl_min: 480,
|
||||||
cookie_secure: false,
|
cookie_secure: false,
|
||||||
cookie_same_site: CookieSameSite::Lax,
|
cookie_same_site: CookieSameSite::Lax,
|
||||||
@@ -907,6 +938,7 @@ impl ClientConfig {
|
|||||||
http_json: HttpJsonFrontendConfig::default(),
|
http_json: HttpJsonFrontendConfig::default(),
|
||||||
audio: AudioClientConfig::default(),
|
audio: AudioClientConfig::default(),
|
||||||
},
|
},
|
||||||
|
logbook: LogbookConfig { path: None },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -930,27 +962,34 @@ fn validate_http_auth(auth: &HttpAuthConfig) -> Result<(), String> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// If enabled, require at least one passphrase
|
if auth.users_file.trim().is_empty() {
|
||||||
if auth.rx_passphrase.is_none() && auth.control_passphrase.is_none() {
|
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())
|
||||||
|
{
|
||||||
return Err(
|
return Err(
|
||||||
"[frontends.http.auth] enabled=true requires at least one passphrase \
|
"[frontends.http.auth] bootstrap administrator credentials must not be empty"
|
||||||
(rx_passphrase and/or control_passphrase)"
|
|
||||||
.to_string(),
|
.to_string(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if auth.bootstrap_read_enabled
|
||||||
// Validate passphrases are not empty strings
|
&& (auth.bootstrap_read_username.trim().is_empty()
|
||||||
if let Some(rx) = &auth.rx_passphrase {
|
|| auth
|
||||||
if rx.trim().is_empty() {
|
.bootstrap_read_password
|
||||||
return Err("[frontends.http.auth].rx_passphrase must not be empty if set".to_string());
|
.as_deref()
|
||||||
}
|
.is_none_or(str::is_empty))
|
||||||
}
|
{
|
||||||
if let Some(ctrl) = &auth.control_passphrase {
|
return Err("[frontends.http.auth] enabled bootstrap Guest account requires a non-empty username and password".to_string());
|
||||||
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
|
// Session TTL must be > 0
|
||||||
@@ -1220,42 +1259,34 @@ home-hf = "audio://10.0.0.5:4600"
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_validate_rejects_http_auth_enabled_without_passphrases() {
|
fn test_validate_accepts_http_auth_with_user_database() {
|
||||||
let mut config = ClientConfig::default();
|
let mut config = ClientConfig::default();
|
||||||
config.frontends.http.auth.enabled = true;
|
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());
|
assert!(config.validate().is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_validate_accepts_http_auth_with_rx_passphrase() {
|
fn test_validate_rejects_empty_users_file() {
|
||||||
let mut config = ClientConfig::default();
|
let mut config = ClientConfig::default();
|
||||||
config.frontends.http.auth.enabled = true;
|
config.frontends.http.auth.enabled = true;
|
||||||
config.frontends.http.auth.rx_passphrase = Some("rx-secret".to_string());
|
config.frontends.http.auth.users_file.clear();
|
||||||
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());
|
assert!(config.validate().is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1263,16 +1294,27 @@ home-hf = "audio://10.0.0.5:4600"
|
|||||||
fn test_validate_rejects_zero_session_ttl() {
|
fn test_validate_rejects_zero_session_ttl() {
|
||||||
let mut config = ClientConfig::default();
|
let mut config = ClientConfig::default();
|
||||||
config.frontends.http.auth.enabled = true;
|
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;
|
config.frontends.http.auth.session_ttl_min = 0;
|
||||||
assert!(config.validate().is_err());
|
assert!(config.validate().is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_validate_auth_disabled_ignores_passphrases() {
|
fn test_validate_allows_disabling_read_bootstrap_credentials() {
|
||||||
|
let mut config = ClientConfig::default();
|
||||||
|
config.frontends.http.auth.enabled = true;
|
||||||
|
config.frontends.http.auth.bootstrap_admin_username = Some("admin".to_string());
|
||||||
|
config.frontends.http.auth.bootstrap_admin_password = Some("secret-password".to_string());
|
||||||
|
config.frontends.http.auth.bootstrap_read_enabled = false;
|
||||||
|
config.frontends.http.auth.bootstrap_read_username.clear();
|
||||||
|
config.frontends.http.auth.bootstrap_read_password = None;
|
||||||
|
assert!(config.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_auth_disabled_ignores_user_settings() {
|
||||||
let mut config = ClientConfig::default();
|
let mut config = ClientConfig::default();
|
||||||
config.frontends.http.auth.enabled = false;
|
config.frontends.http.auth.enabled = false;
|
||||||
config.frontends.http.auth.rx_passphrase = Some("".to_string());
|
config.frontends.http.auth.users_file.clear();
|
||||||
assert!(config.validate().is_ok());
|
assert!(config.validate().is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1280,9 +1322,12 @@ home-hf = "audio://10.0.0.5:4600"
|
|||||||
fn test_http_auth_config_default() {
|
fn test_http_auth_config_default() {
|
||||||
let auth = HttpAuthConfig::default();
|
let auth = HttpAuthConfig::default();
|
||||||
assert!(!auth.enabled);
|
assert!(!auth.enabled);
|
||||||
assert!(auth.rx_passphrase.is_none());
|
assert_eq!(auth.users_file, "trx-http-users.json");
|
||||||
assert!(auth.control_passphrase.is_none());
|
assert!(auth.bootstrap_admin_username.is_none());
|
||||||
assert!(auth.tx_access_control_enabled);
|
assert!(auth.bootstrap_admin_password.is_none());
|
||||||
|
assert!(auth.bootstrap_read_enabled);
|
||||||
|
assert_eq!(auth.bootstrap_read_username, "guest");
|
||||||
|
assert_eq!(auth.bootstrap_read_password.as_deref(), Some("guest"));
|
||||||
assert_eq!(auth.session_ttl_min, 480);
|
assert_eq!(auth.session_ttl_min, 480);
|
||||||
assert!(!auth.cookie_secure);
|
assert!(!auth.cookie_secure);
|
||||||
assert!(matches!(auth.cookie_same_site, CookieSameSite::Lax));
|
assert!(matches!(auth.cookie_same_site, CookieSameSite::Lax));
|
||||||
@@ -1584,15 +1629,21 @@ spectrum_interval_ms = 0
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_passphrase_file_fills_passphrase() {
|
fn test_bootstrap_password_file_fills_password() {
|
||||||
let f = secret_file("hunter2\n");
|
let f = secret_file("hunter2\n");
|
||||||
let mut config = ClientConfig::default();
|
let mut config = ClientConfig::default();
|
||||||
config.frontends.http.auth.enabled = true;
|
config.frontends.http.auth.enabled = true;
|
||||||
config.frontends.http.auth.control_passphrase_file =
|
config.frontends.http.auth.bootstrap_admin_username = Some("admin".to_string());
|
||||||
|
config.frontends.http.auth.bootstrap_admin_password_file =
|
||||||
Some(f.path().to_str().unwrap().to_string());
|
Some(f.path().to_str().unwrap().to_string());
|
||||||
config.resolve_secrets(None).unwrap();
|
config.resolve_secrets(None).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
config.frontends.http.auth.control_passphrase.as_deref(),
|
config
|
||||||
|
.frontends
|
||||||
|
.http
|
||||||
|
.auth
|
||||||
|
.bootstrap_admin_password
|
||||||
|
.as_deref(),
|
||||||
Some("hunter2")
|
Some("hunter2")
|
||||||
);
|
);
|
||||||
assert!(config.validate().is_ok());
|
assert!(config.validate().is_ok());
|
||||||
|
|||||||
@@ -111,8 +111,8 @@ const SECTION_COMMENTS: &[(&str, &str)] = &[
|
|||||||
),
|
),
|
||||||
(
|
(
|
||||||
"trx-client.frontends.http.auth",
|
"trx-client.frontends.http.auth",
|
||||||
"Passphrase login for the web UI. rx_passphrase_file and\n\
|
"Optional user/password ACL for the web UI. Administrators manage\n\
|
||||||
control_passphrase_file keep the secrets out of this file.",
|
accounts stored in users_file.",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"trx-client.frontends.rigctl",
|
"trx-client.frontends.rigctl",
|
||||||
@@ -130,6 +130,10 @@ const SECTION_COMMENTS: &[(&str, &str)] = &[
|
|||||||
"trx-client.frontends.audio.bridge",
|
"trx-client.frontends.audio.bridge",
|
||||||
"Play RX audio on a local sound device and capture TX from one.",
|
"Play RX audio on a local sound device and capture TX from one.",
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"trx-client.logbook",
|
||||||
|
"Station log of contacts. path defaults to logbook.jsonl in the user's\n data directory; set it to keep the log on a backed-up volume.",
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Render the combined `trx-rs.toml.example` contents.
|
/// Render the combined `trx-rs.toml.example` contents.
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ use crate::shared::{check_socket_conflicts, validate_log_level, validate_tokens,
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
pub use trx_decode_log::DecodeLogsConfig;
|
pub use trx_decode_log::DecodeLogsConfig;
|
||||||
|
|
||||||
use trx_core::rig::state::RigMode;
|
use trx_core::rig::state::{DigSidebandPolicy, NoiseBlankerProfile, RigMode};
|
||||||
|
|
||||||
/// Every decoder the server knows how to run, by config name.
|
/// Every decoder the server knows how to run, by config name.
|
||||||
///
|
///
|
||||||
@@ -393,6 +393,10 @@ pub struct SdrConfig {
|
|||||||
pub squelch: SdrSquelchConfig,
|
pub squelch: SdrSquelchConfig,
|
||||||
/// Noise blanker for impulse noise suppression on IQ samples.
|
/// Noise blanker for impulse noise suppression on IQ samples.
|
||||||
pub noise_blanker: SdrNoiseBlankerConfig,
|
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).
|
/// Virtual receiver channels (at least one required when SDR backend is active).
|
||||||
pub channels: Vec<SdrChannelConfig>,
|
pub channels: Vec<SdrChannelConfig>,
|
||||||
/// Maximum number of simultaneous virtual channels (including the primary).
|
/// Maximum number of simultaneous virtual channels (including the primary).
|
||||||
@@ -433,6 +437,7 @@ impl Default for SdrConfig {
|
|||||||
gain: SdrGainConfig::default(),
|
gain: SdrGainConfig::default(),
|
||||||
squelch: SdrSquelchConfig::default(),
|
squelch: SdrSquelchConfig::default(),
|
||||||
noise_blanker: SdrNoiseBlankerConfig::default(),
|
noise_blanker: SdrNoiseBlankerConfig::default(),
|
||||||
|
dig_sideband: DigSidebandPolicy::default(),
|
||||||
channels: Vec::new(),
|
channels: Vec::new(),
|
||||||
max_virtual_channels: default_max_virtual_channels(),
|
max_virtual_channels: default_max_virtual_channels(),
|
||||||
spectrum_fft_size: default_spectrum_fft_size(),
|
spectrum_fft_size: default_spectrum_fft_size(),
|
||||||
@@ -473,8 +478,14 @@ pub struct SdrNoiseBlankerConfig {
|
|||||||
/// Enables the noise blanker.
|
/// Enables the noise blanker.
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
/// Threshold multiplier for impulse detection (typical range: 1..100).
|
/// Threshold multiplier for impulse detection (typical range: 1..100).
|
||||||
/// A sample whose magnitude exceeds threshold × running RMS is blanked.
|
/// A sample whose magnitude exceeds threshold × the tracked noise floor is
|
||||||
|
/// blanked. Lower values blank more aggressively.
|
||||||
pub threshold: f64,
|
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 {
|
impl Default for SdrNoiseBlankerConfig {
|
||||||
@@ -482,6 +493,7 @@ impl Default for SdrNoiseBlankerConfig {
|
|||||||
Self {
|
Self {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
threshold: 10.0,
|
threshold: 10.0,
|
||||||
|
profile: NoiseBlankerProfile::Spike,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ pub use rig::command::RigCommand;
|
|||||||
pub use rig::request::RigRequest;
|
pub use rig::request::RigRequest;
|
||||||
pub use rig::response::{RigError, RigResult};
|
pub use rig::response::{RigError, RigResult};
|
||||||
pub use rig::state::{
|
pub use rig::state::{
|
||||||
DecoderConfig, DecoderResetSeqs, RdsData, RigFilterState, RigMode, RigSnapshot, RigState,
|
DecoderConfig, DecoderResetSeqs, DigSidebandPolicy, NoiseBlankerProfile, RdsData,
|
||||||
WfmDenoiseLevel,
|
RigFilterState, RigMode, RigSnapshot, RigState, WfmDenoiseLevel,
|
||||||
};
|
};
|
||||||
pub use rig::AudioSource;
|
pub use rig::AudioSource;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
use crate::radio::freq::Freq;
|
use crate::radio::freq::Freq;
|
||||||
use crate::rig::state::WfmDenoiseLevel;
|
use crate::rig::state::{DigSidebandPolicy, NoiseBlankerProfile, WfmDenoiseLevel};
|
||||||
use crate::RigMode;
|
use crate::RigMode;
|
||||||
|
|
||||||
/// Internal command handled by the rig task.
|
/// Internal command handled by the rig task.
|
||||||
@@ -48,8 +48,17 @@ pub enum RigCommand {
|
|||||||
SetSdrGain(f64),
|
SetSdrGain(f64),
|
||||||
SetSdrLnaGain(f64),
|
SetSdrLnaGain(f64),
|
||||||
SetSdrAgc(bool),
|
SetSdrAgc(bool),
|
||||||
SetSdrSquelch { enabled: bool, threshold_db: f64 },
|
SetSdrSquelch {
|
||||||
SetSdrNoiseBlanker { enabled: bool, threshold: f64 },
|
enabled: bool,
|
||||||
|
threshold_db: f64,
|
||||||
|
},
|
||||||
|
SetSdrNoiseBlanker {
|
||||||
|
enabled: bool,
|
||||||
|
threshold: f64,
|
||||||
|
profile: NoiseBlankerProfile,
|
||||||
|
},
|
||||||
|
/// Set how the SDR backend resolves DIG mode to a sideband (SDR only).
|
||||||
|
SetSdrDigSideband(DigSidebandPolicy),
|
||||||
SetWfmDeemphasis(u32),
|
SetWfmDeemphasis(u32),
|
||||||
SetWfmStereo(bool),
|
SetWfmStereo(bool),
|
||||||
SetWfmDenoise(WfmDenoiseLevel),
|
SetWfmDenoise(WfmDenoiseLevel),
|
||||||
|
|||||||
@@ -470,6 +470,7 @@ pub fn command_from_rig_command(cmd: RigCommand) -> Box<dyn RigCommandHandler> {
|
|||||||
| RigCommand::SetSdrAgc(_)
|
| RigCommand::SetSdrAgc(_)
|
||||||
| RigCommand::SetSdrSquelch { .. }
|
| RigCommand::SetSdrSquelch { .. }
|
||||||
| RigCommand::SetSdrNoiseBlanker { .. }
|
| RigCommand::SetSdrNoiseBlanker { .. }
|
||||||
|
| RigCommand::SetSdrDigSideband(_)
|
||||||
| RigCommand::SetWfmDeemphasis(_)
|
| RigCommand::SetWfmDeemphasis(_)
|
||||||
| RigCommand::SetWfmStereo(_)
|
| RigCommand::SetWfmStereo(_)
|
||||||
| RigCommand::SetWfmDenoise(_)
|
| RigCommand::SetWfmDenoise(_)
|
||||||
|
|||||||
@@ -253,6 +253,7 @@ pub trait RigSdr: Send {
|
|||||||
&'a mut self,
|
&'a mut self,
|
||||||
_enabled: bool,
|
_enabled: bool,
|
||||||
_threshold: f64,
|
_threshold: f64,
|
||||||
|
_profile: crate::rig::state::NoiseBlankerProfile,
|
||||||
) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'a>> {
|
) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'a>> {
|
||||||
Box::pin(std::future::ready(Err(
|
Box::pin(std::future::ready(Err(
|
||||||
Box::new(response::RigError::not_supported("set_sdr_noise_blanker"))
|
Box::new(response::RigError::not_supported("set_sdr_noise_blanker"))
|
||||||
@@ -260,6 +261,17 @@ pub trait RigSdr: Send {
|
|||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set how DIG mode resolves to a sideband (SDR backends only).
|
||||||
|
fn set_sdr_dig_sideband<'a>(
|
||||||
|
&'a mut self,
|
||||||
|
_policy: crate::rig::state::DigSidebandPolicy,
|
||||||
|
) -> Pin<Box<dyn Future<Output = DynResult<()>> + Send + 'a>> {
|
||||||
|
Box::pin(std::future::ready(Err(
|
||||||
|
Box::new(response::RigError::not_supported("set_sdr_dig_sideband"))
|
||||||
|
as Box<dyn std::error::Error + Send + Sync>,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
fn set_wfm_stereo<'a>(
|
fn set_wfm_stereo<'a>(
|
||||||
&'a mut self,
|
&'a mut self,
|
||||||
_enabled: bool,
|
_enabled: bool,
|
||||||
|
|||||||
@@ -338,6 +338,14 @@ pub struct RigFilterState {
|
|||||||
pub sdr_nb_enabled: Option<bool>,
|
pub sdr_nb_enabled: Option<bool>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub sdr_nb_threshold: Option<f64>,
|
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")]
|
#[serde(default = "default_wfm_deemphasis_us")]
|
||||||
pub wfm_deemphasis_us: u32,
|
pub wfm_deemphasis_us: u32,
|
||||||
#[serde(default = "default_wfm_stereo")]
|
#[serde(default = "default_wfm_stereo")]
|
||||||
@@ -370,6 +378,155 @@ pub enum WfmDenoiseLevel {
|
|||||||
High,
|
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 {
|
fn default_wfm_deemphasis_us() -> u32 {
|
||||||
75
|
75
|
||||||
}
|
}
|
||||||
@@ -506,3 +663,111 @@ pub struct RigSnapshot {
|
|||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub vchan_rds: Option<Vec<VchanRdsEntry>>,
|
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,6 +343,8 @@ mod tests {
|
|||||||
sdr_squelch_threshold_db: None,
|
sdr_squelch_threshold_db: None,
|
||||||
sdr_nb_enabled: None,
|
sdr_nb_enabled: None,
|
||||||
sdr_nb_threshold: None,
|
sdr_nb_threshold: None,
|
||||||
|
sdr_nb_profile: None,
|
||||||
|
sdr_dig_sideband: None,
|
||||||
wfm_deemphasis_us: 75,
|
wfm_deemphasis_us: 75,
|
||||||
wfm_stereo: true,
|
wfm_stereo: true,
|
||||||
wfm_stereo_detected: false,
|
wfm_stereo_detected: false,
|
||||||
@@ -391,6 +393,8 @@ mod tests {
|
|||||||
sdr_squelch_threshold_db: None,
|
sdr_squelch_threshold_db: None,
|
||||||
sdr_nb_enabled: None,
|
sdr_nb_enabled: None,
|
||||||
sdr_nb_threshold: None,
|
sdr_nb_threshold: None,
|
||||||
|
sdr_nb_profile: None,
|
||||||
|
sdr_dig_sideband: None,
|
||||||
wfm_deemphasis_us: 50,
|
wfm_deemphasis_us: 50,
|
||||||
wfm_stereo: true,
|
wfm_stereo: true,
|
||||||
wfm_stereo_detected: true,
|
wfm_stereo_detected: true,
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ define_command_mapping! {
|
|||||||
SetSdrGain { gain_db } <=> SetSdrGain,
|
SetSdrGain { gain_db } <=> SetSdrGain,
|
||||||
SetSdrLnaGain { gain_db } <=> SetSdrLnaGain,
|
SetSdrLnaGain { gain_db } <=> SetSdrLnaGain,
|
||||||
SetSdrAgc { enabled } <=> SetSdrAgc,
|
SetSdrAgc { enabled } <=> SetSdrAgc,
|
||||||
|
SetSdrDigSideband { policy } <=> SetSdrDigSideband,
|
||||||
SetWfmDeemphasis { deemphasis_us } <=> SetWfmDeemphasis,
|
SetWfmDeemphasis { deemphasis_us } <=> SetWfmDeemphasis,
|
||||||
SetWfmStereo { enabled } <=> SetWfmStereo,
|
SetWfmStereo { enabled } <=> SetWfmStereo,
|
||||||
SetWfmDenoise { level } <=> SetWfmDenoise,
|
SetWfmDenoise { level } <=> SetWfmDenoise,
|
||||||
@@ -156,7 +157,7 @@ define_command_mapping! {
|
|||||||
// ── Multi-field struct passthrough ───────────────────────────────
|
// ── Multi-field struct passthrough ───────────────────────────────
|
||||||
multi:
|
multi:
|
||||||
SetSdrSquelch { enabled, threshold_db } <=> SetSdrSquelch,
|
SetSdrSquelch { enabled, threshold_db } <=> SetSdrSquelch,
|
||||||
SetSdrNoiseBlanker { enabled, threshold } <=> SetSdrNoiseBlanker;
|
SetSdrNoiseBlanker { enabled, threshold, profile } <=> SetSdrNoiseBlanker;
|
||||||
|
|
||||||
// ── Freq conversions (u64 <=> Freq) ──────────────────────────────
|
// ── Freq conversions (u64 <=> Freq) ──────────────────────────────
|
||||||
freq:
|
freq:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use trx_core::rig::state::RigSnapshot;
|
use trx_core::rig::state::RigSnapshot;
|
||||||
use trx_core::WfmDenoiseLevel;
|
use trx_core::{DigSidebandPolicy, NoiseBlankerProfile, WfmDenoiseLevel};
|
||||||
|
|
||||||
/// Command received from network clients (JSON).
|
/// Command received from network clients (JSON).
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
@@ -105,6 +105,10 @@ pub enum ClientCommand {
|
|||||||
SetSdrNoiseBlanker {
|
SetSdrNoiseBlanker {
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
threshold: f64,
|
threshold: f64,
|
||||||
|
profile: NoiseBlankerProfile,
|
||||||
|
},
|
||||||
|
SetSdrDigSideband {
|
||||||
|
policy: DigSidebandPolicy,
|
||||||
},
|
},
|
||||||
SetWfmDeemphasis {
|
SetWfmDeemphasis {
|
||||||
deemphasis_us: u32,
|
deemphasis_us: u32,
|
||||||
|
|||||||
@@ -360,6 +360,8 @@ fn build_sdr_rig_from_instance(rig_cfg: &RigInstanceConfig) -> SdrRigBuildResult
|
|||||||
max_virtual_channels: rig_cfg.sdr.max_virtual_channels,
|
max_virtual_channels: rig_cfg.sdr.max_virtual_channels,
|
||||||
nb_enabled: rig_cfg.sdr.noise_blanker.enabled,
|
nb_enabled: rig_cfg.sdr.noise_blanker.enabled,
|
||||||
nb_threshold: rig_cfg.sdr.noise_blanker.threshold,
|
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,
|
spectrum_fft_size: rig_cfg.sdr.spectrum_fft_size,
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -920,11 +922,10 @@ fn bound_sockets(cli: &Cli, cfg: &ServerConfig, rigs: &[RigInstanceConfig]) -> V
|
|||||||
"[listen]",
|
"[listen]",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let audio_ip = cli.listen.unwrap_or(cfg.audio.listen);
|
|
||||||
for rig in rigs {
|
for rig in rigs {
|
||||||
if rig.audio.enabled {
|
if rig.audio.enabled {
|
||||||
sockets.push(BoundSocket::new(
|
sockets.push(BoundSocket::new(
|
||||||
audio_ip,
|
cli.listen.unwrap_or(rig.audio.listen),
|
||||||
rig.audio.port,
|
rig.audio.port,
|
||||||
format!("rig \"{}\" [audio]", rig.id),
|
format!("rig \"{}\" [audio]", rig.id),
|
||||||
));
|
));
|
||||||
@@ -1274,9 +1275,9 @@ async fn main() -> DynResult<()> {
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Spawn audio stack.
|
// Spawn audio stack. --listen overrides every configured bind address;
|
||||||
// listen_override priority: --listen CLI flag > global [audio].listen > per-rig default.
|
// otherwise each rig keeps its own [rigs.audio].listen value.
|
||||||
let audio_listen_override = cli.listen.or(Some(cfg.audio.listen));
|
let audio_listen_override = cli.listen;
|
||||||
#[cfg(feature = "soapysdr")]
|
#[cfg(feature = "soapysdr")]
|
||||||
let audio_vchan_manager = sdr_vchan_manager.clone();
|
let audio_vchan_manager = sdr_vchan_manager.clone();
|
||||||
#[cfg(not(feature = "soapysdr"))]
|
#[cfg(not(feature = "soapysdr"))]
|
||||||
@@ -1415,6 +1416,40 @@ mod tests {
|
|||||||
assert!(parse_serial_addr(" ").is_err());
|
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]
|
#[test]
|
||||||
fn default_audio_bandwidth_for_mode_table() {
|
fn default_audio_bandwidth_for_mode_table() {
|
||||||
assert_eq!(default_audio_bandwidth_for_mode(&RigMode::USB), 3_000);
|
assert_eq!(default_audio_bandwidth_for_mode(&RigMode::USB), 3_000);
|
||||||
|
|||||||
@@ -760,9 +760,13 @@ async fn process_command(
|
|||||||
let _ = ctx.state_tx.send(ctx.state.clone());
|
let _ = ctx.state_tx.send(ctx.state.clone());
|
||||||
return snapshot_from(ctx.state);
|
return snapshot_from(ctx.state);
|
||||||
}
|
}
|
||||||
RigCommand::SetSdrNoiseBlanker { enabled, threshold } => {
|
RigCommand::SetSdrNoiseBlanker {
|
||||||
|
enabled,
|
||||||
|
threshold,
|
||||||
|
profile,
|
||||||
|
} => {
|
||||||
if let Some(sdr) = ctx.rig.as_sdr() {
|
if let Some(sdr) = ctx.rig.as_sdr() {
|
||||||
if let Err(e) = sdr.set_sdr_noise_blanker(enabled, threshold).await {
|
if let Err(e) = sdr.set_sdr_noise_blanker(enabled, threshold, profile).await {
|
||||||
return Err(RigError::communication(format!(
|
return Err(RigError::communication(format!(
|
||||||
"set_sdr_noise_blanker: {e}"
|
"set_sdr_noise_blanker: {e}"
|
||||||
)));
|
)));
|
||||||
@@ -774,6 +778,20 @@ async fn process_command(
|
|||||||
let _ = ctx.state_tx.send(ctx.state.clone());
|
let _ = ctx.state_tx.send(ctx.state.clone());
|
||||||
return snapshot_from(ctx.state);
|
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) => {
|
RigCommand::SetWfmDeemphasis(deemphasis_us) => {
|
||||||
if let Some(sdr) = ctx.rig.as_sdr() {
|
if let Some(sdr) = ctx.rig.as_sdr() {
|
||||||
if let Err(e) = sdr.set_wfm_deemphasis(deemphasis_us).await {
|
if let Err(e) = sdr.set_wfm_deemphasis(deemphasis_us).await {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
use num_complex::Complex;
|
use num_complex::Complex;
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
use trx_core::rig::state::{RdsData, RigMode, WfmDenoiseLevel};
|
use trx_core::rig::state::{NoiseBlankerProfile, RdsData, RigMode, WfmDenoiseLevel};
|
||||||
|
|
||||||
use crate::demod::{DcBlocker, Demodulator, SamDemod, SoftAgc, WfmStereoDecoder};
|
use crate::demod::{DcBlocker, Demodulator, SamDemod, SoftAgc, WfmStereoDecoder};
|
||||||
|
|
||||||
@@ -14,38 +14,174 @@ use super::{BlockFirFilterPair, IQ_BLOCK_SIZE};
|
|||||||
// Noise blanker
|
// Noise blanker
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// IQ-domain impulse noise blanker.
|
/// Temporal shape of a [`NoiseBlankerProfile`], in real time so the numbers
|
||||||
|
/// scale correctly across capture rates (they are converted to samples at
|
||||||
|
/// runtime). See [`NoiseBlanker`] for how each field is used.
|
||||||
|
struct NbShape {
|
||||||
|
/// Delay applied to the stream so the gate can begin closing *before* a
|
||||||
|
/// detected impulse reaches the output, catching its leading edge.
|
||||||
|
lookahead_us: f32,
|
||||||
|
/// Minimum time the gate is held fully closed after each detected sample.
|
||||||
|
blank_us: f32,
|
||||||
|
/// Raised-cosine gate ramp length (attack == release).
|
||||||
|
ramp_us: f32,
|
||||||
|
/// Time constant of the exponential noise-floor tracker.
|
||||||
|
ref_tc_ms: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NbShape {
|
||||||
|
const fn for_profile(p: NoiseBlankerProfile) -> Self {
|
||||||
|
match p {
|
||||||
|
// Short, sparse spikes: tight window, quick recovery, fast floor.
|
||||||
|
NoiseBlankerProfile::Spike => NbShape {
|
||||||
|
lookahead_us: 8.0,
|
||||||
|
blank_us: 24.0,
|
||||||
|
ramp_us: 5.0,
|
||||||
|
ref_tc_ms: 4.0,
|
||||||
|
},
|
||||||
|
// Ignition/PWM bursts: medium window at a high repetition rate.
|
||||||
|
NoiseBlankerProfile::Ignition => NbShape {
|
||||||
|
lookahead_us: 12.0,
|
||||||
|
blank_us: 80.0,
|
||||||
|
ramp_us: 8.0,
|
||||||
|
ref_tc_ms: 4.0,
|
||||||
|
},
|
||||||
|
// Power-line buzz: wide window, slow floor to ride out the burst.
|
||||||
|
NoiseBlankerProfile::Powerline => NbShape {
|
||||||
|
lookahead_us: 20.0,
|
||||||
|
blank_us: 200.0,
|
||||||
|
ramp_us: 12.0,
|
||||||
|
ref_tc_ms: 15.0,
|
||||||
|
},
|
||||||
|
// Dense impulse noise: widest, most aggressive gating.
|
||||||
|
NoiseBlankerProfile::Broadband => NbShape {
|
||||||
|
lookahead_us: 24.0,
|
||||||
|
blank_us: 400.0,
|
||||||
|
ramp_us: 16.0,
|
||||||
|
ref_tc_ms: 8.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Floor for the tracked noise-floor estimate, guarding against a zero divisor
|
||||||
|
/// and cold-start false triggers.
|
||||||
|
const NB_MIN_REF: f32 = 1e-12;
|
||||||
|
|
||||||
|
/// IQ-domain impulse noise blanker with look-ahead and a tapered gate.
|
||||||
///
|
///
|
||||||
/// Maintains a running RMS estimate of the IQ magnitude. When a sample's
|
/// This runs on the wide, undecimated IQ stream — the only place an impulse is
|
||||||
/// magnitude exceeds `threshold × rms`, it is replaced by linear interpolation
|
/// still short in time (narrow filtering downstream smears it into un-removable
|
||||||
/// between the last clean sample and the next clean sample (lookahead of 1).
|
/// ringing). The algorithm has four parts:
|
||||||
///
|
///
|
||||||
/// The RMS tracker uses exponential smoothing with a time constant of ~128
|
/// 1. **Noise-floor tracker** — an exponential mean-square estimate updated
|
||||||
/// samples at the IQ sample rate, fast enough to track band-noise changes
|
/// *only from clean samples* and frozen while blanking, so a burst cannot
|
||||||
/// but slow enough not to follow individual impulses.
|
/// drag the reference up and desensitise detection. Its time constant comes
|
||||||
|
/// from the profile.
|
||||||
|
/// 2. **Detection** — a sample is an impulse when its instantaneous power
|
||||||
|
/// exceeds `threshold² × noise_floor`. `threshold` is the user sensitivity
|
||||||
|
/// knob and is orthogonal to the profile.
|
||||||
|
/// 3. **Look-ahead** — the signal is delayed by `lookahead` samples so the gate
|
||||||
|
/// can start closing before the impulse reaches the output, removing the
|
||||||
|
/// leading edge instead of letting it leak through (the old blanker's main
|
||||||
|
/// failure). Each detection holds the gate shut for a profile-sized window.
|
||||||
|
/// 4. **Tapered gate** — instead of the old hard sample-and-hold (a step that
|
||||||
|
/// splattered energy right back across the passband and made the blanker
|
||||||
|
/// audibly worse on SSB/CW/data), the gain ramps smoothly 1→0→1 over
|
||||||
|
/// `ramp` samples, so blanking costs only a short, quiet notch.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct NoiseBlanker {
|
pub struct NoiseBlanker {
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
|
profile: NoiseBlankerProfile,
|
||||||
|
sample_rate: f32,
|
||||||
|
/// Detection sensitivity multiplier over the tracked noise floor (>= 1).
|
||||||
threshold: f32,
|
threshold: 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;
|
// 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,
|
||||||
|
}
|
||||||
|
|
||||||
impl NoiseBlanker {
|
impl NoiseBlanker {
|
||||||
pub fn new(enabled: bool, threshold: f32) -> Self {
|
pub fn new(cfg: NoiseBlankerConfig, sample_rate: u32) -> Self {
|
||||||
Self {
|
let mut nb = Self {
|
||||||
enabled,
|
enabled: cfg.enabled,
|
||||||
threshold: threshold.max(1.0),
|
profile: cfg.profile,
|
||||||
mean_sq: 1e-10,
|
sample_rate: sample_rate as f32,
|
||||||
last_clean: Complex::new(0.0, 0.0),
|
threshold: cfg.threshold.max(1.0),
|
||||||
|
lookahead: 0,
|
||||||
|
blank_samples: 0,
|
||||||
|
ramp_step: 1.0,
|
||||||
|
ref_alpha: 0.0,
|
||||||
|
warmup: 0,
|
||||||
|
delay: Vec::new(),
|
||||||
|
dpos: 0,
|
||||||
|
ref_sq: NB_MIN_REF,
|
||||||
|
seen: 0,
|
||||||
|
hold: 0,
|
||||||
|
gain: 1.0,
|
||||||
|
};
|
||||||
|
nb.recompute();
|
||||||
|
nb
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Recompute sample-domain parameters from the current profile and sample
|
||||||
|
/// rate, then clear streaming state so the new geometry starts clean.
|
||||||
|
fn recompute(&mut self) {
|
||||||
|
let sr = self.sample_rate.max(1.0);
|
||||||
|
let shape = NbShape::for_profile(self.profile);
|
||||||
|
let per_us = sr / 1_000_000.0;
|
||||||
|
|
||||||
|
self.lookahead = ((shape.lookahead_us * per_us).round() as usize).max(1);
|
||||||
|
self.blank_samples = ((shape.blank_us * per_us).round() as usize).max(1);
|
||||||
|
let ramp_len = (shape.ramp_us * per_us).round().max(1.0);
|
||||||
|
self.ramp_step = 1.0 / ramp_len;
|
||||||
|
let ref_tc = (shape.ref_tc_ms * 1e-3 * sr).max(1.0);
|
||||||
|
self.ref_alpha = 1.0 / ref_tc;
|
||||||
|
// Warm up over roughly one floor time constant, bounded so a huge rate
|
||||||
|
// cannot stall detection for long.
|
||||||
|
self.warmup = ref_tc.min(96_000.0) as u32;
|
||||||
|
|
||||||
|
self.delay = vec![Complex::new(0.0, 0.0); self.lookahead];
|
||||||
|
self.reset_state();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear streaming state without touching the derived parameters.
|
||||||
|
fn reset_state(&mut self) {
|
||||||
|
for s in self.delay.iter_mut() {
|
||||||
|
*s = Complex::new(0.0, 0.0);
|
||||||
|
}
|
||||||
|
self.dpos = 0;
|
||||||
|
self.ref_sq = NB_MIN_REF;
|
||||||
|
self.seen = 0;
|
||||||
|
self.hold = 0;
|
||||||
|
self.gain = 1.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_enabled(&mut self, enabled: bool) {
|
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;
|
self.enabled = enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +189,14 @@ impl NoiseBlanker {
|
|||||||
self.threshold = threshold.max(1.0);
|
self.threshold = threshold.max(1.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Process a block of IQ samples in-place, blanking impulse spikes.
|
pub fn set_profile(&mut self, profile: NoiseBlankerProfile) {
|
||||||
|
if profile != self.profile {
|
||||||
|
self.profile = profile;
|
||||||
|
self.recompute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process a block of IQ samples in-place, blanking impulse noise.
|
||||||
pub fn process(&mut self, block: &mut [Complex<f32>]) {
|
pub fn process(&mut self, block: &mut [Complex<f32>]) {
|
||||||
if !self.enabled || block.is_empty() {
|
if !self.enabled || block.is_empty() {
|
||||||
return;
|
return;
|
||||||
@@ -62,17 +205,59 @@ impl NoiseBlanker {
|
|||||||
let thresh_sq = self.threshold * self.threshold;
|
let thresh_sq = self.threshold * self.threshold;
|
||||||
|
|
||||||
for sample in block.iter_mut() {
|
for sample in block.iter_mut() {
|
||||||
let s = *sample;
|
let x = *sample;
|
||||||
let mag_sq = s.re * s.re + s.im * s.im;
|
let mag_sq = x.re * x.re + x.im * x.im;
|
||||||
|
|
||||||
if mag_sq > thresh_sq * self.mean_sq {
|
// Look-ahead: emit the delayed sample, ingest the fresh one.
|
||||||
// Impulse detected — replace with last clean sample.
|
let y = if self.lookahead == 0 {
|
||||||
*sample = self.last_clean;
|
x
|
||||||
} else {
|
} else {
|
||||||
// Clean sample — update RMS tracker.
|
let out = self.delay[self.dpos];
|
||||||
self.mean_sq += NB_ALPHA * (mag_sq - self.mean_sq);
|
self.delay[self.dpos] = x;
|
||||||
self.last_clean = s;
|
self.dpos += 1;
|
||||||
|
if self.dpos >= self.lookahead {
|
||||||
|
self.dpos = 0;
|
||||||
}
|
}
|
||||||
|
out
|
||||||
|
};
|
||||||
|
|
||||||
|
// Seed the noise floor before trusting detection.
|
||||||
|
if self.seen < self.warmup {
|
||||||
|
self.seen += 1;
|
||||||
|
self.ref_sq += self.ref_alpha * (mag_sq - self.ref_sq);
|
||||||
|
if self.ref_sq < NB_MIN_REF {
|
||||||
|
self.ref_sq = NB_MIN_REF;
|
||||||
|
}
|
||||||
|
*sample = y;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect on the fresh sample; it reaches the output `lookahead`
|
||||||
|
// samples later, by which time the gate has closed.
|
||||||
|
if mag_sq > thresh_sq * self.ref_sq {
|
||||||
|
self.hold = self.blank_samples;
|
||||||
|
} else if self.hold == 0 {
|
||||||
|
// Update the floor only from clean, passed samples.
|
||||||
|
self.ref_sq += self.ref_alpha * (mag_sq - self.ref_sq);
|
||||||
|
if self.ref_sq < NB_MIN_REF {
|
||||||
|
self.ref_sq = NB_MIN_REF;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drive the gate toward its target and apply it to the output.
|
||||||
|
let target = if self.hold > 0 {
|
||||||
|
self.hold -= 1;
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
|
if self.gain < target {
|
||||||
|
self.gain = (self.gain + self.ramp_step).min(target);
|
||||||
|
} else if self.gain > target {
|
||||||
|
self.gain = (self.gain - self.ramp_step).max(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
*sample = y * self.gain;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,6 +266,7 @@ impl NoiseBlanker {
|
|||||||
pub struct NoiseBlankerConfig {
|
pub struct NoiseBlankerConfig {
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
pub threshold: f32,
|
pub threshold: f32,
|
||||||
|
pub profile: NoiseBlankerProfile,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for NoiseBlankerConfig {
|
impl Default for NoiseBlankerConfig {
|
||||||
@@ -88,6 +274,7 @@ impl Default for NoiseBlankerConfig {
|
|||||||
Self {
|
Self {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
threshold: 10.0,
|
threshold: 10.0,
|
||||||
|
profile: NoiseBlankerProfile::Spike,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -556,7 +743,7 @@ impl ChannelDsp {
|
|||||||
processing_enabled: true,
|
processing_enabled: true,
|
||||||
force_mono_pcm,
|
force_mono_pcm,
|
||||||
squelch: VirtualSquelch::new(squelch_cfg),
|
squelch: VirtualSquelch::new(squelch_cfg),
|
||||||
noise_blanker: NoiseBlanker::new(nb_cfg.enabled, nb_cfg.threshold),
|
noise_blanker: NoiseBlanker::new(nb_cfg, sdr_sample_rate),
|
||||||
last_signal_db: -120.0,
|
last_signal_db: -120.0,
|
||||||
carrier_iq_power: 0.0,
|
carrier_iq_power: 0.0,
|
||||||
carrier_attack_alpha: Self::smeter_alphas(channel_sample_rate).0,
|
carrier_attack_alpha: Self::smeter_alphas(channel_sample_rate).0,
|
||||||
@@ -577,9 +764,15 @@ impl ChannelDsp {
|
|||||||
self.squelch.set_threshold_db(threshold_db);
|
self.squelch.set_threshold_db(threshold_db);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_noise_blanker(&mut self, enabled: bool, threshold: f32) {
|
pub fn set_noise_blanker(
|
||||||
self.noise_blanker.set_enabled(enabled);
|
&mut self,
|
||||||
|
enabled: bool,
|
||||||
|
threshold: f32,
|
||||||
|
profile: NoiseBlankerProfile,
|
||||||
|
) {
|
||||||
|
self.noise_blanker.set_profile(profile);
|
||||||
self.noise_blanker.set_threshold(threshold);
|
self.noise_blanker.set_threshold(threshold);
|
||||||
|
self.noise_blanker.set_enabled(enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_mode(&mut self, mode: &RigMode) {
|
pub fn set_mode(&mut self, mode: &RigMode) {
|
||||||
@@ -1046,30 +1239,89 @@ mod tests {
|
|||||||
assert_eq!(dsp.demodulator, Demodulator::Fm);
|
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]
|
#[test]
|
||||||
fn noise_blanker_suppresses_impulse() {
|
fn noise_blanker_suppresses_impulse() {
|
||||||
let mut nb = NoiseBlanker::new(true, 5.0);
|
let sr = 1_000_000;
|
||||||
// Feed a steady signal to establish the RMS baseline.
|
let mut nb = NoiseBlanker::new(nb_cfg(true, 5.0, NoiseBlankerProfile::Spike), sr);
|
||||||
let mut block: Vec<Complex<f32>> = (0..256).map(|_| Complex::new(0.01, 0.01)).collect();
|
nb_warm(&mut nb);
|
||||||
|
|
||||||
|
// One massive spike embedded in steady signal. With look-ahead the
|
||||||
|
// spike emerges at the output already gated, so *no* output sample may
|
||||||
|
// approach the raw spike power (200) — proving the leading edge did not
|
||||||
|
// leak through, which the old hold-based blanker allowed.
|
||||||
|
let mut block = vec![Complex::new(0.01, 0.01); 512];
|
||||||
|
block[200] = Complex::new(10.0, 10.0);
|
||||||
nb.process(&mut block);
|
nb.process(&mut block);
|
||||||
// Now inject a single massive spike at index 0.
|
|
||||||
let mut block2: Vec<Complex<f32>> = (0..256).map(|_| Complex::new(0.01, 0.01)).collect();
|
let peak = block
|
||||||
block2[0] = Complex::new(10.0, 10.0);
|
.iter()
|
||||||
nb.process(&mut block2);
|
.map(|s| s.re * s.re + s.im * s.im)
|
||||||
// The spike should have been blanked (replaced by last clean sample).
|
.fold(0.0f32, f32::max);
|
||||||
let mag = (block2[0].re * block2[0].re + block2[0].im * block2[0].im).sqrt();
|
assert!(peak < 1.0, "impulse leaked through, peak power {peak}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn noise_blanker_passes_strong_steady_signal() {
|
||||||
|
// A strong *continuous* tone is not impulse noise: the floor tracks it,
|
||||||
|
// so the blanker must leave it essentially untouched rather than gating
|
||||||
|
// a real signal.
|
||||||
|
let sr = 1_000_000;
|
||||||
|
let mut nb = NoiseBlanker::new(nb_cfg(true, 5.0, NoiseBlankerProfile::Spike), sr);
|
||||||
|
let mut warm = vec![Complex::new(0.5, 0.5); 40_000];
|
||||||
|
nb.process(&mut warm);
|
||||||
|
|
||||||
|
let mut block = vec![Complex::new(0.5, 0.5); 512];
|
||||||
|
nb.process(&mut block);
|
||||||
|
// Tail of the block is past all transients and should pass at unity.
|
||||||
|
let tail = block[511];
|
||||||
assert!(
|
assert!(
|
||||||
mag < 1.0,
|
(tail.re - 0.5).abs() < 1e-3 && (tail.im - 0.5).abs() < 1e-3,
|
||||||
"expected impulse to be blanked, got magnitude {}",
|
"steady signal was gated: {tail:?}"
|
||||||
mag
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn noise_blanker_disabled_passes_through() {
|
fn noise_blanker_disabled_passes_through() {
|
||||||
let mut nb = NoiseBlanker::new(false, 5.0);
|
let mut nb = NoiseBlanker::new(nb_cfg(false, 5.0, NoiseBlankerProfile::Spike), 1_000_000);
|
||||||
let mut block = vec![Complex::new(10.0, 10.0); 4];
|
let mut block = vec![Complex::new(10.0, 10.0); 4];
|
||||||
nb.process(&mut block);
|
nb.process(&mut block);
|
||||||
assert_eq!(block[0], Complex::new(10.0, 10.0));
|
assert_eq!(block[0], Complex::new(10.0, 10.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn noise_blanker_wider_profile_blanks_longer() {
|
||||||
|
// A wider profile must hold the gate closed for more samples than a
|
||||||
|
// narrow one on the same impulse.
|
||||||
|
let sr = 1_000_000;
|
||||||
|
let count_blanked = |profile| {
|
||||||
|
let mut nb = NoiseBlanker::new(nb_cfg(true, 5.0, profile), sr);
|
||||||
|
nb_warm(&mut nb);
|
||||||
|
let mut block = vec![Complex::new(0.01, 0.01); 2048];
|
||||||
|
block[100] = Complex::new(10.0, 10.0);
|
||||||
|
nb.process(&mut block);
|
||||||
|
block
|
||||||
|
.iter()
|
||||||
|
.filter(|s| s.re * s.re + s.im * s.im < 1e-6)
|
||||||
|
.count()
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
count_blanked(NoiseBlankerProfile::Broadband)
|
||||||
|
> count_blanked(NoiseBlankerProfile::Spike),
|
||||||
|
"broadband profile should blank a wider window than spike"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ use std::sync::atomic::Ordering;
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use trx_core::radio::freq::{Band, Freq};
|
use trx_core::radio::freq::{Band, Freq};
|
||||||
use trx_core::rig::response::RigError;
|
use trx_core::rig::response::RigError;
|
||||||
use trx_core::rig::state::{RigFilterState, SpectrumData, VchanRdsEntry, WfmDenoiseLevel};
|
use trx_core::rig::state::{
|
||||||
|
effective_demod_mode, DigSidebandPolicy, NoiseBlankerProfile, RigFilterState, SpectrumData,
|
||||||
|
VchanRdsEntry, WfmDenoiseLevel,
|
||||||
|
};
|
||||||
use trx_core::rig::{
|
use trx_core::rig::{
|
||||||
AudioSource, Rig, RigAccessMethod, RigCapabilities, RigCat, RigInfo, RigSdr, RigStatusFuture,
|
AudioSource, Rig, RigAccessMethod, RigCapabilities, RigCat, RigInfo, RigSdr, RigStatusFuture,
|
||||||
};
|
};
|
||||||
@@ -73,6 +76,10 @@ pub struct SoapySdrConfig {
|
|||||||
pub nb_enabled: bool,
|
pub nb_enabled: bool,
|
||||||
/// Noise blanker impulse threshold multiplier.
|
/// Noise blanker impulse threshold multiplier.
|
||||||
pub nb_threshold: f64,
|
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.
|
/// 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,
|
/// Fewer bins cost less DSP and put fewer bytes on the network per frame,
|
||||||
@@ -104,6 +111,8 @@ impl Default for SoapySdrConfig {
|
|||||||
max_virtual_channels: 4,
|
max_virtual_channels: 4,
|
||||||
nb_enabled: false,
|
nb_enabled: false,
|
||||||
nb_threshold: 10.0,
|
nb_threshold: 10.0,
|
||||||
|
nb_profile: NoiseBlankerProfile::Spike,
|
||||||
|
dig_sideband: DigSidebandPolicy::Auto,
|
||||||
spectrum_fft_size: 1024,
|
spectrum_fft_size: 1024,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,10 +163,17 @@ pub struct SoapySdrRig {
|
|||||||
nb_enabled: bool,
|
nb_enabled: bool,
|
||||||
/// Noise blanker impulse threshold multiplier.
|
/// Noise blanker impulse threshold multiplier.
|
||||||
nb_threshold: f64,
|
nb_threshold: f64,
|
||||||
|
/// Noise blanker tuning profile on the primary channel.
|
||||||
|
nb_profile: NoiseBlankerProfile,
|
||||||
/// Hidden AIS decoder channels (A and B) when available.
|
/// Hidden AIS decoder channels (A and B) when available.
|
||||||
ais_channel_indices: Option<(usize, usize)>,
|
ais_channel_indices: Option<(usize, usize)>,
|
||||||
/// Virtual channel manager shared with external consumers (e.g. RigHandle).
|
/// Virtual channel manager shared with external consumers (e.g. RigHandle).
|
||||||
|
/// Also owns the shared DIG-sideband policy.
|
||||||
channel_manager: Arc<vchan_impl::SdrVirtualChannelManager>,
|
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 {
|
impl SoapySdrRig {
|
||||||
@@ -200,6 +216,8 @@ impl SoapySdrRig {
|
|||||||
let max_virtual_channels = config.max_virtual_channels;
|
let max_virtual_channels = config.max_virtual_channels;
|
||||||
let nb_enabled = config.nb_enabled;
|
let nb_enabled = config.nb_enabled;
|
||||||
let nb_threshold = config.nb_threshold;
|
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;
|
let spectrum_fft_size = config.spectrum_fft_size;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"initialising SoapySDR backend (args={:?}, gain_mode={:?}, gain_db={}, max_gain_db={:?})",
|
"initialising SoapySDR backend (args={:?}, gain_mode={:?}, gain_db={}, max_gain_db={:?})",
|
||||||
@@ -298,6 +316,7 @@ impl SoapySdrRig {
|
|||||||
dsp::NoiseBlankerConfig {
|
dsp::NoiseBlankerConfig {
|
||||||
enabled: nb_enabled,
|
enabled: nb_enabled,
|
||||||
threshold: nb_threshold as f32,
|
threshold: nb_threshold as f32,
|
||||||
|
profile: nb_profile,
|
||||||
},
|
},
|
||||||
&all_channels,
|
&all_channels,
|
||||||
spectrum_fft_size,
|
spectrum_fft_size,
|
||||||
@@ -364,8 +383,16 @@ impl SoapySdrRig {
|
|||||||
pipeline.clone(),
|
pipeline.clone(),
|
||||||
fixed_slot_count,
|
fixed_slot_count,
|
||||||
max_virtual_channels,
|
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 {
|
let rig = Self {
|
||||||
info,
|
info,
|
||||||
freq: initial_freq,
|
freq: initial_freq,
|
||||||
@@ -390,9 +417,20 @@ impl SoapySdrRig {
|
|||||||
squelch_threshold_db,
|
squelch_threshold_db,
|
||||||
nb_enabled,
|
nb_enabled,
|
||||||
nb_threshold,
|
nb_threshold,
|
||||||
|
nb_profile,
|
||||||
ais_channel_indices: Some((primary_channel_count, primary_channel_count + 1)),
|
ais_channel_indices: Some((primary_channel_count, primary_channel_count + 1)),
|
||||||
channel_manager,
|
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();
|
rig.apply_ais_channel_activity();
|
||||||
Ok(rig)
|
Ok(rig)
|
||||||
}
|
}
|
||||||
@@ -448,6 +486,8 @@ impl SoapySdrRig {
|
|||||||
max_virtual_channels,
|
max_virtual_channels,
|
||||||
nb_enabled,
|
nb_enabled,
|
||||||
nb_threshold,
|
nb_threshold,
|
||||||
|
nb_profile: NoiseBlankerProfile::default(),
|
||||||
|
dig_sideband: DigSidebandPolicy::default(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -619,6 +659,21 @@ impl RigCat for SoapySdrRig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// In DIG/Auto, tuning across the 10 MHz boundary flips the
|
||||||
|
// sideband. Rebuild the demodulator only when it actually changes,
|
||||||
|
// so ordinary tuning stays glitch-free.
|
||||||
|
if self.mode == RigMode::DIG {
|
||||||
|
let effective = self.channel_manager.dig_policy().resolve(self.freq.hz);
|
||||||
|
if effective != self.applied_primary_mode {
|
||||||
|
let dsps = self.pipeline.channel_dsps.read().unwrap();
|
||||||
|
if let Some(dsp_arc) = dsps.get(self.primary_channel_idx) {
|
||||||
|
let mut dsp = dsp_arc.lock().unwrap();
|
||||||
|
dsp.set_mode(&effective);
|
||||||
|
dsp.set_filter(self.bandwidth_hz);
|
||||||
|
}
|
||||||
|
self.applied_primary_mode = effective;
|
||||||
|
}
|
||||||
|
}
|
||||||
self.update_ais_channel_offsets();
|
self.update_ais_channel_offsets();
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
@@ -632,12 +687,18 @@ impl RigCat for SoapySdrRig {
|
|||||||
tracing::debug!("SoapySdrRig: set_mode -> {:?}", mode);
|
tracing::debug!("SoapySdrRig: set_mode -> {:?}", mode);
|
||||||
self.mode = mode.clone();
|
self.mode = mode.clone();
|
||||||
self.bandwidth_hz = Self::default_bandwidth_for_mode(&mode);
|
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.
|
// Update the primary channel's demodulator in the live pipeline.
|
||||||
{
|
{
|
||||||
let dsps = self.pipeline.channel_dsps.read().unwrap();
|
let dsps = self.pipeline.channel_dsps.read().unwrap();
|
||||||
if let Some(dsp_arc) = dsps.get(self.primary_channel_idx) {
|
if let Some(dsp_arc) = dsps.get(self.primary_channel_idx) {
|
||||||
let mut dsp = dsp_arc.lock().unwrap();
|
let mut dsp = dsp_arc.lock().unwrap();
|
||||||
dsp.set_mode(&mode);
|
dsp.set_mode(&effective);
|
||||||
dsp.set_filter(self.bandwidth_hz);
|
dsp.set_filter(self.bandwidth_hz);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -933,6 +994,7 @@ impl RigSdr for SoapySdrRig {
|
|||||||
&'a mut self,
|
&'a mut self,
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
threshold: f64,
|
threshold: f64,
|
||||||
|
profile: NoiseBlankerProfile,
|
||||||
) -> Pin<Box<dyn std::future::Future<Output = DynResult<()>> + Send + 'a>> {
|
) -> Pin<Box<dyn std::future::Future<Output = DynResult<()>> + Send + 'a>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
if !threshold.is_finite() {
|
if !threshold.is_finite() {
|
||||||
@@ -943,13 +1005,38 @@ impl RigSdr for SoapySdrRig {
|
|||||||
}
|
}
|
||||||
self.nb_enabled = enabled;
|
self.nb_enabled = enabled;
|
||||||
self.nb_threshold = threshold;
|
self.nb_threshold = threshold;
|
||||||
|
self.nb_profile = profile;
|
||||||
{
|
{
|
||||||
let dsps = self.pipeline.channel_dsps.read().unwrap();
|
let dsps = self.pipeline.channel_dsps.read().unwrap();
|
||||||
if let Some(dsp_arc) = dsps.get(self.primary_channel_idx) {
|
if let Some(dsp_arc) = dsps.get(self.primary_channel_idx) {
|
||||||
dsp_arc
|
dsp_arc
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.set_noise_blanker(enabled, threshold as f32);
|
.set_noise_blanker(enabled, threshold as f32, profile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_sdr_dig_sideband<'a>(
|
||||||
|
&'a mut self,
|
||||||
|
policy: DigSidebandPolicy,
|
||||||
|
) -> Pin<Box<dyn std::future::Future<Output = DynResult<()>> + Send + 'a>> {
|
||||||
|
Box::pin(async move {
|
||||||
|
// Update the shared policy (also re-resolves DIG virtual channels).
|
||||||
|
self.channel_manager.set_dig_policy(policy);
|
||||||
|
// Re-apply to the primary channel when it is currently DIG.
|
||||||
|
if self.mode == RigMode::DIG {
|
||||||
|
let effective = policy.resolve(self.freq.hz);
|
||||||
|
if effective != self.applied_primary_mode {
|
||||||
|
let dsps = self.pipeline.channel_dsps.read().unwrap();
|
||||||
|
if let Some(dsp_arc) = dsps.get(self.primary_channel_idx) {
|
||||||
|
let mut dsp = dsp_arc.lock().unwrap();
|
||||||
|
dsp.set_mode(&effective);
|
||||||
|
dsp.set_filter(self.bandwidth_hz);
|
||||||
|
}
|
||||||
|
self.applied_primary_mode = effective;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1049,6 +1136,8 @@ impl RigSdr for SoapySdrRig {
|
|||||||
sdr_squelch_threshold_db: Some(self.squelch_threshold_db as f64),
|
sdr_squelch_threshold_db: Some(self.squelch_threshold_db as f64),
|
||||||
sdr_nb_enabled: Some(self.nb_enabled),
|
sdr_nb_enabled: Some(self.nb_enabled),
|
||||||
sdr_nb_threshold: Some(self.nb_threshold),
|
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_deemphasis_us: self.wfm_deemphasis_us,
|
||||||
wfm_stereo: self.wfm_stereo,
|
wfm_stereo: self.wfm_stereo,
|
||||||
wfm_stereo_detected,
|
wfm_stereo_detected,
|
||||||
|
|||||||
@@ -22,12 +22,12 @@
|
|||||||
//! updates every `ChannelDsp` in place and pauses out-of-span channels instead
|
//! updates every `ChannelDsp` in place and pauses out-of-span channels instead
|
||||||
//! of destroying them.
|
//! of destroying them.
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicI64, Ordering};
|
use std::sync::atomic::{AtomicI64, AtomicU8, Ordering};
|
||||||
use std::sync::{Arc, RwLock};
|
use std::sync::{Arc, RwLock};
|
||||||
|
|
||||||
use num_complex::Complex;
|
use num_complex::Complex;
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
use trx_core::rig::state::{RigMode, VchanRdsEntry};
|
use trx_core::rig::state::{effective_demod_mode, DigSidebandPolicy, RigMode, VchanRdsEntry};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::dsp::SdrPipeline;
|
use crate::dsp::SdrPipeline;
|
||||||
@@ -59,7 +59,12 @@ fn default_bandwidth_hz(mode: &RigMode) -> u32 {
|
|||||||
struct ManagedChannel {
|
struct ManagedChannel {
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
freq_hz: u64,
|
freq_hz: u64,
|
||||||
|
/// Logical mode requested by the user (e.g. `DIG`), shown in listings.
|
||||||
mode: RigMode,
|
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.
|
/// `broadcast::Sender` kept alive so new subscribers can join at any time.
|
||||||
pcm_tx: broadcast::Sender<Vec<f32>>,
|
pcm_tx: broadcast::Sender<Vec<f32>>,
|
||||||
/// IQ tap sender (kept alive; external consumers may subscribe).
|
/// IQ tap sender (kept alive; external consumers may subscribe).
|
||||||
@@ -91,6 +96,9 @@ pub struct SdrVirtualChannelManager {
|
|||||||
channels: RwLock<Vec<ManagedChannel>>,
|
channels: RwLock<Vec<ManagedChannel>>,
|
||||||
/// Fires whenever a channel is explicitly destroyed.
|
/// Fires whenever a channel is explicitly destroyed.
|
||||||
destroyed_tx: broadcast::Sender<Uuid>,
|
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 {
|
impl SdrVirtualChannelManager {
|
||||||
@@ -100,7 +108,12 @@ impl SdrVirtualChannelManager {
|
|||||||
/// - `fixed_slot_count`: number of fixed pipeline slots (primary + AIS),
|
/// - `fixed_slot_count`: number of fixed pipeline slots (primary + AIS),
|
||||||
/// i.e. the index of the first slot available for virtual channels.
|
/// i.e. the index of the first slot available for virtual channels.
|
||||||
/// - `max_total`: maximum total channels including primary (e.g. 4).
|
/// - `max_total`: maximum total channels including primary (e.g. 4).
|
||||||
pub fn new(pipeline: Arc<SdrPipeline>, fixed_slot_count: usize, max_total: usize) -> Self {
|
pub fn new(
|
||||||
|
pipeline: Arc<SdrPipeline>,
|
||||||
|
fixed_slot_count: usize,
|
||||||
|
max_total: usize,
|
||||||
|
dig_sideband: DigSidebandPolicy,
|
||||||
|
) -> Self {
|
||||||
// Seed the channel list with a synthetic primary-channel entry.
|
// Seed the channel list with a synthetic primary-channel entry.
|
||||||
// We use the first PCM sender from the pipeline (index 0).
|
// We use the first PCM sender from the pipeline (index 0).
|
||||||
let primary_pcm_tx = pipeline
|
let primary_pcm_tx = pipeline
|
||||||
@@ -118,6 +131,7 @@ impl SdrVirtualChannelManager {
|
|||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
freq_hz: 0, // actual freq kept by SoapySdrRig; manager treats ch-0 as opaque
|
freq_hz: 0, // actual freq kept by SoapySdrRig; manager treats ch-0 as opaque
|
||||||
mode: RigMode::USB,
|
mode: RigMode::USB,
|
||||||
|
applied_mode: RigMode::USB,
|
||||||
pcm_tx: primary_pcm_tx,
|
pcm_tx: primary_pcm_tx,
|
||||||
iq_tx: primary_iq_tx,
|
iq_tx: primary_iq_tx,
|
||||||
pipeline_slot: 0,
|
pipeline_slot: 0,
|
||||||
@@ -134,6 +148,7 @@ impl SdrVirtualChannelManager {
|
|||||||
max_total: max_total.max(1),
|
max_total: max_total.max(1),
|
||||||
channels: RwLock::new(vec![primary]),
|
channels: RwLock::new(vec![primary]),
|
||||||
destroyed_tx,
|
destroyed_tx,
|
||||||
|
dig_sideband: AtomicU8::new(dig_sideband.to_u8()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +156,36 @@ impl SdrVirtualChannelManager {
|
|||||||
self.destroyed_tx.clone()
|
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 {
|
fn half_span_hz(&self) -> i64 {
|
||||||
i64::from(self.pipeline.sdr_sample_rate) / 2
|
i64::from(self.pipeline.sdr_sample_rate) / 2
|
||||||
}
|
}
|
||||||
@@ -172,10 +217,13 @@ impl SdrVirtualChannelManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bandwidth follows the logical mode; the DSP demodulator follows the
|
||||||
|
// resolved sideband (DIG → USB/LSB by policy + frequency).
|
||||||
let bandwidth_hz = default_bandwidth_hz(mode);
|
let bandwidth_hz = default_bandwidth_hz(mode);
|
||||||
let (pcm_tx, iq_tx) = self
|
let applied_mode = self.resolve_mode(mode, freq_hz);
|
||||||
.pipeline
|
let (pcm_tx, iq_tx) =
|
||||||
.add_virtual_channel(if_hz as f64, mode, bandwidth_hz);
|
self.pipeline
|
||||||
|
.add_virtual_channel(if_hz as f64, &applied_mode, bandwidth_hz);
|
||||||
|
|
||||||
let pipeline_slot = self
|
let pipeline_slot = self
|
||||||
.pipeline
|
.pipeline
|
||||||
@@ -190,6 +238,7 @@ impl SdrVirtualChannelManager {
|
|||||||
id,
|
id,
|
||||||
freq_hz,
|
freq_hz,
|
||||||
mode: mode.clone(),
|
mode: mode.clone(),
|
||||||
|
applied_mode,
|
||||||
pcm_tx,
|
pcm_tx,
|
||||||
iq_tx,
|
iq_tx,
|
||||||
pipeline_slot,
|
pipeline_slot,
|
||||||
@@ -314,9 +363,18 @@ impl VirtualChannelManager for SdrVirtualChannelManager {
|
|||||||
.ok_or(VChanError::NotFound)?;
|
.ok_or(VChanError::NotFound)?;
|
||||||
|
|
||||||
ch.freq_hz = freq_hz;
|
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();
|
let dsps = self.pipeline.channel_dsps.read().unwrap();
|
||||||
if let Some(dsp_arc) = dsps.get(ch.pipeline_slot) {
|
if let Some(dsp_arc) = dsps.get(ch.pipeline_slot) {
|
||||||
dsp_arc.lock().unwrap().set_channel_if_hz(if_hz as f64);
|
let mut dsp = dsp_arc.lock().unwrap();
|
||||||
|
dsp.set_channel_if_hz(if_hz as f64);
|
||||||
|
if mode_changed {
|
||||||
|
dsp.set_mode(&effective);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -329,9 +387,11 @@ impl VirtualChannelManager for SdrVirtualChannelManager {
|
|||||||
.ok_or(VChanError::NotFound)?;
|
.ok_or(VChanError::NotFound)?;
|
||||||
|
|
||||||
ch.mode = mode.clone();
|
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();
|
let dsps = self.pipeline.channel_dsps.read().unwrap();
|
||||||
if let Some(dsp_arc) = dsps.get(ch.pipeline_slot) {
|
if let Some(dsp_arc) = dsps.get(ch.pipeline_slot) {
|
||||||
dsp_arc.lock().unwrap().set_mode(mode);
|
dsp_arc.lock().unwrap().set_mode(&effective);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -425,6 +485,7 @@ impl VirtualChannelManager for SdrVirtualChannelManager {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::demod::Demodulator;
|
||||||
use crate::dsp::{MockIqSource, NoiseBlankerConfig, SdrPipeline};
|
use crate::dsp::{MockIqSource, NoiseBlankerConfig, SdrPipeline};
|
||||||
|
|
||||||
fn make_pipeline() -> Arc<SdrPipeline> {
|
fn make_pipeline() -> Arc<SdrPipeline> {
|
||||||
@@ -446,7 +507,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn add_and_list() {
|
fn add_and_list() {
|
||||||
let p = make_pipeline();
|
let p = make_pipeline();
|
||||||
let mgr = SdrVirtualChannelManager::new(p, 1, 4);
|
let mgr = SdrVirtualChannelManager::new(p, 1, 4, DigSidebandPolicy::Auto);
|
||||||
// Set center to 14.1 MHz so that 14.074 MHz is within ±960 kHz.
|
// Set center to 14.1 MHz so that 14.074 MHz is within ±960 kHz.
|
||||||
mgr.update_center_hz(14_100_000);
|
mgr.update_center_hz(14_100_000);
|
||||||
assert_eq!(mgr.channels().len(), 1); // primary only
|
assert_eq!(mgr.channels().len(), 1); // primary only
|
||||||
@@ -462,7 +523,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn remove_virtual_channel() {
|
fn remove_virtual_channel() {
|
||||||
let p = make_pipeline();
|
let p = make_pipeline();
|
||||||
let mgr = SdrVirtualChannelManager::new(p, 1, 4);
|
let mgr = SdrVirtualChannelManager::new(p, 1, 4, DigSidebandPolicy::Auto);
|
||||||
mgr.update_center_hz(14_100_000);
|
mgr.update_center_hz(14_100_000);
|
||||||
let (id, _) = mgr.add_channel(14_074_000, &RigMode::USB).unwrap();
|
let (id, _) = mgr.add_channel(14_074_000, &RigMode::USB).unwrap();
|
||||||
mgr.remove_channel(id).unwrap();
|
mgr.remove_channel(id).unwrap();
|
||||||
@@ -472,7 +533,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn cannot_remove_primary() {
|
fn cannot_remove_primary() {
|
||||||
let p = make_pipeline();
|
let p = make_pipeline();
|
||||||
let mgr = SdrVirtualChannelManager::new(p, 1, 4);
|
let mgr = SdrVirtualChannelManager::new(p, 1, 4, DigSidebandPolicy::Auto);
|
||||||
let primary_id = mgr.channels()[0].id;
|
let primary_id = mgr.channels()[0].id;
|
||||||
let err = mgr.remove_channel(primary_id).unwrap_err();
|
let err = mgr.remove_channel(primary_id).unwrap_err();
|
||||||
assert!(matches!(err, VChanError::Permanent));
|
assert!(matches!(err, VChanError::Permanent));
|
||||||
@@ -481,7 +542,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn cap_enforced() {
|
fn cap_enforced() {
|
||||||
let p = make_pipeline();
|
let p = make_pipeline();
|
||||||
let mgr = SdrVirtualChannelManager::new(p, 1, 2); // primary + 1 virtual max
|
let mgr = SdrVirtualChannelManager::new(p, 1, 2, DigSidebandPolicy::Auto); // primary + 1 virtual max
|
||||||
mgr.update_center_hz(14_100_000);
|
mgr.update_center_hz(14_100_000);
|
||||||
mgr.add_channel(14_074_000, &RigMode::USB).unwrap();
|
mgr.add_channel(14_074_000, &RigMode::USB).unwrap();
|
||||||
let err = mgr.add_channel(14_075_000, &RigMode::USB).unwrap_err();
|
let err = mgr.add_channel(14_075_000, &RigMode::USB).unwrap_err();
|
||||||
@@ -491,7 +552,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn out_of_bandwidth() {
|
fn out_of_bandwidth() {
|
||||||
let p = make_pipeline();
|
let p = make_pipeline();
|
||||||
let mgr = SdrVirtualChannelManager::new(p, 1, 4);
|
let mgr = SdrVirtualChannelManager::new(p, 1, 4, DigSidebandPolicy::Auto);
|
||||||
// center_hz = 0, half_span = 960_000 Hz — 10 MHz is way out
|
// 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();
|
let err = mgr.add_channel(10_000_000, &RigMode::USB).unwrap_err();
|
||||||
assert!(matches!(err, VChanError::OutOfBandwidth { .. }));
|
assert!(matches!(err, VChanError::OutOfBandwidth { .. }));
|
||||||
@@ -500,7 +561,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn hidden_background_channels_are_outside_visible_cap() {
|
fn hidden_background_channels_are_outside_visible_cap() {
|
||||||
let p = make_pipeline();
|
let p = make_pipeline();
|
||||||
let mgr = SdrVirtualChannelManager::new(p, 1, 2); // primary + 1 visible max
|
let mgr = SdrVirtualChannelManager::new(p, 1, 2, DigSidebandPolicy::Auto); // primary + 1 visible max
|
||||||
mgr.update_center_hz(14_100_000);
|
mgr.update_center_hz(14_100_000);
|
||||||
|
|
||||||
mgr.add_channel(14_074_000, &RigMode::USB).unwrap();
|
mgr.add_channel(14_074_000, &RigMode::USB).unwrap();
|
||||||
@@ -513,10 +574,46 @@ mod tests {
|
|||||||
assert!(visible.iter().all(|channel| channel.id != hidden_id));
|
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]
|
#[test]
|
||||||
fn retune_keeps_virtual_channel_allocated() {
|
fn retune_keeps_virtual_channel_allocated() {
|
||||||
let p = make_pipeline();
|
let p = make_pipeline();
|
||||||
let mgr = SdrVirtualChannelManager::new(p, 1, 4);
|
let mgr = SdrVirtualChannelManager::new(p, 1, 4, DigSidebandPolicy::Auto);
|
||||||
mgr.update_center_hz(14_100_000);
|
mgr.update_center_hz(14_100_000);
|
||||||
let mut destroyed_rx = mgr.subscribe_destroyed();
|
let mut destroyed_rx = mgr.subscribe_destroyed();
|
||||||
|
|
||||||
|
|||||||
+14
-5
@@ -118,6 +118,7 @@ sample_rate = 1920000
|
|||||||
bandwidth = 1500000
|
bandwidth = 1500000
|
||||||
wfm_deemphasis_us = 50
|
wfm_deemphasis_us = 50
|
||||||
center_offset_hz = 100000
|
center_offset_hz = 100000
|
||||||
|
dig_sideband = "auto"
|
||||||
channels = []
|
channels = []
|
||||||
max_virtual_channels = 4
|
max_virtual_channels = 4
|
||||||
spectrum_fft_size = 1024
|
spectrum_fft_size = 1024
|
||||||
@@ -139,6 +140,7 @@ tail_ms = 180
|
|||||||
[trx-server.sdr.noise_blanker]
|
[trx-server.sdr.noise_blanker]
|
||||||
enabled = false
|
enabled = false
|
||||||
threshold = 10.0
|
threshold = 10.0
|
||||||
|
profile = "spike"
|
||||||
|
|
||||||
# Timeout and buffer tuning. The defaults suit most setups.
|
# Timeout and buffer tuning. The defaults suit most setups.
|
||||||
[trx-server.timeouts]
|
[trx-server.timeouts]
|
||||||
@@ -203,13 +205,16 @@ decode_history_retention_min = 1440
|
|||||||
|
|
||||||
[trx-client.frontends.http.decode_history_retention_min_by_rig]
|
[trx-client.frontends.http.decode_history_retention_min_by_rig]
|
||||||
|
|
||||||
# Passphrase login for the web UI. rx_passphrase_file and
|
# Optional user/password ACL for the web UI. Administrators manage
|
||||||
# control_passphrase_file keep the secrets out of this file.
|
# accounts stored in users_file.
|
||||||
[trx-client.frontends.http.auth]
|
[trx-client.frontends.http.auth]
|
||||||
enabled = false
|
enabled = false
|
||||||
rx_passphrase = "rx-passphrase-example"
|
users_file = "trx-http-users.json"
|
||||||
control_passphrase = "control-passphrase-example"
|
bootstrap_admin_username = "admin"
|
||||||
tx_access_control_enabled = true
|
bootstrap_admin_password = "change-this-password"
|
||||||
|
bootstrap_read_enabled = true
|
||||||
|
bootstrap_read_username = "guest"
|
||||||
|
bootstrap_read_password = "guest"
|
||||||
session_ttl_min = 480
|
session_ttl_min = 480
|
||||||
cookie_secure = false
|
cookie_secure = false
|
||||||
cookie_same_site = "Lax"
|
cookie_same_site = "Lax"
|
||||||
@@ -246,3 +251,7 @@ enabled = false
|
|||||||
bitrate_bps = 192000
|
bitrate_bps = 192000
|
||||||
rx_gain = 1.0
|
rx_gain = 1.0
|
||||||
tx_gain = 1.0
|
tx_gain = 1.0
|
||||||
|
|
||||||
|
# Station log of contacts. path defaults to logbook.jsonl in the user's
|
||||||
|
# data directory; set it to keep the log on a backed-up volume.
|
||||||
|
[trx-client.logbook]
|
||||||
|
|||||||
Reference in New Issue
Block a user