Compare commits
1
Commits
main
..
4727233d8e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4727233d8e |
@@ -27,7 +27,7 @@ env:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
runs-on: self-hosted
|
runs-on: ubuntu-latest
|
||||||
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: self-hosted
|
runs-on: ubuntu-latest
|
||||||
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: self-hosted
|
runs-on: ubuntu-latest
|
||||||
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: self-hosted
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: fsfe/reuse-action@v5
|
- uses: fsfe/reuse-action@v5
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ 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
+5
-109
@@ -316,18 +316,6 @@ 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"
|
||||||
@@ -357,12 +345,6 @@ 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"
|
||||||
@@ -413,24 +395,6 @@ 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"
|
||||||
@@ -561,7 +525,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
|
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures 0.3.0",
|
"cpufeatures",
|
||||||
"rand_core 0.10.1",
|
"rand_core 0.10.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -757,15 +721,6 @@ 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"
|
||||||
@@ -784,16 +739,6 @@ 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"
|
||||||
@@ -854,26 +799,15 @@ 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 0.12.0",
|
"block-buffer",
|
||||||
"const-oid",
|
"const-oid",
|
||||||
"crypto-common 0.2.1",
|
"crypto-common",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1085,16 +1019,6 @@ 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"
|
||||||
@@ -1966,17 +1890,6 @@ 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"
|
||||||
@@ -2579,8 +2492,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
|
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures 0.3.0",
|
"cpufeatures",
|
||||||
"digest 0.11.2",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3302,11 +3215,9 @@ 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",
|
||||||
@@ -3315,13 +3226,11 @@ 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",
|
||||||
@@ -3360,19 +3269,6 @@ 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,7 +26,6 @@ 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,44 +116,6 @@ 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,14 +925,9 @@ main
|
|||||||
|
|
||||||
### HTTP Frontend Auth
|
### HTTP Frontend Auth
|
||||||
|
|
||||||
- Optional Argon2id-backed managed accounts with HttpOnly session cookies
|
- Optional token or HTTP Basic Auth middleware
|
||||||
- An exclusive Guest role plus composable Read, Control, Transmit, Write, and Administrator roles, with policy shared by middleware and handlers
|
- Configured in `[frontends.http.auth]`
|
||||||
- Guest sessions receive read-only station access but no account-control endpoints or panels
|
- Rate limiting supported
|
||||||
- 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
|
||||||
|
|
||||||
@@ -1048,7 +1043,7 @@ The `FrontendRuntimeContext` struct in `trx-frontend/src/lib.rs` is decomposed i
|
|||||||
|-----------|---------|------------|
|
|-----------|---------|------------|
|
||||||
| `AudioContext` | Audio streaming channels | `rx`, `tx`, `info`, `decode_rx`, `clients` |
|
| `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`, `users_file`, bootstrap admin/read accounts, `session_ttl_secs`, `tokens` |
|
| `HttpAuthConfig` | HTTP auth settings | `enabled`, `rx_passphrase`, `session_ttl_secs`, `tokens` |
|
||||||
| `HttpUiConfig` | HTTP UI display config | `show_sdr_gain_control`, `initial_map_zoom`, `spectrum_*` |
|
| `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` |
|
||||||
|
|||||||
@@ -1,287 +0,0 @@
|
|||||||
# Safe deployment
|
|
||||||
|
|
||||||
This guide deploys trx-rs on Linux as a dedicated, unprivileged user with
|
|
||||||
systemd user services. It keeps radio-device access, configuration, and runtime
|
|
||||||
data separate from an administrator's account.
|
|
||||||
|
|
||||||
The examples use `trx-rs` as the account name and `/opt/trx-rs/bin` for
|
|
||||||
root-owned executables. Adapt group names and firewall commands to your Linux
|
|
||||||
distribution.
|
|
||||||
|
|
||||||
## 1. Decide what must be reachable
|
|
||||||
|
|
||||||
Only expose listeners that another machine actually needs:
|
|
||||||
|
|
||||||
| Listener | Typical port | Recommended exposure |
|
|
||||||
| --- | ---: | --- |
|
|
||||||
| Server control | TCP 4530 | Loopback or trusted radio LAN only |
|
|
||||||
| Server audio | TCP 4531 and per-rig ports | Trusted radio LAN only |
|
|
||||||
| Client web UI | TCP 8080 or a chosen port | Loopback behind HTTPS proxy |
|
|
||||||
| Client rigctl | Per-rig TCP ports | Loopback or trusted LAN only |
|
|
||||||
| Client JSON | Configured TCP port | Loopback or trusted LAN only |
|
|
||||||
|
|
||||||
`127.0.0.1` accepts connections only from the same host. Use a specific LAN
|
|
||||||
address when possible, or `0.0.0.0` when the listener must accept connections
|
|
||||||
on every IPv4 interface. Binding a socket does not configure the host firewall.
|
|
||||||
|
|
||||||
Do not expose unauthenticated control, audio, rigctl, or JSON listeners to the
|
|
||||||
public Internet. Prefer a VPN for links between radio sites. Put the web UI
|
|
||||||
behind an HTTPS reverse proxy when it is remotely accessible.
|
|
||||||
|
|
||||||
## 2. Create the service account
|
|
||||||
|
|
||||||
Create a non-login service account with a home directory:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo useradd --create-home --shell /usr/sbin/nologin trx-rs
|
|
||||||
sudo chmod 0750 /home/trx-rs
|
|
||||||
```
|
|
||||||
|
|
||||||
Add only the hardware groups required on this host. Common group names are
|
|
||||||
`dialout` for serial devices and `audio` for sound devices:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo usermod -aG dialout,audio trx-rs
|
|
||||||
```
|
|
||||||
|
|
||||||
SDR USB access is distribution- and device-specific. Install the vendor's udev
|
|
||||||
rules or add a narrowly scoped rule for the device's USB vendor/product IDs.
|
|
||||||
Avoid making all USB devices world-writable. After reconnecting the device,
|
|
||||||
verify access as the service account:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo -u trx-rs test -r /dev/ttyUSB0
|
|
||||||
sudo -u trx-rs test -w /dev/ttyUSB0
|
|
||||||
sudo -u trx-rs SoapySDRUtil --find
|
|
||||||
```
|
|
||||||
|
|
||||||
Run only the checks relevant to the configured hardware. Group membership and
|
|
||||||
udev-rule changes normally require reconnecting the device or restarting the
|
|
||||||
service.
|
|
||||||
|
|
||||||
## 3. Build and install immutable binaries
|
|
||||||
|
|
||||||
Build from a reviewed revision as a normal development user, not as root:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/stanislawgrams/trx-rs.git
|
|
||||||
cd trx-rs
|
|
||||||
git switch --detach <reviewed-tag-or-commit>
|
|
||||||
cargo build --release -p trx-server -p trx-client -p trx-configurator
|
|
||||||
```
|
|
||||||
|
|
||||||
Install root-owned binaries into a directory the service user cannot modify:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo install -d -o root -g root -m 0755 /opt/trx-rs/bin
|
|
||||||
sudo install -o root -g root -m 0755 \
|
|
||||||
target/release/trx-server \
|
|
||||||
target/release/trx-client \
|
|
||||||
target/release/trx-configurator \
|
|
||||||
/opt/trx-rs/bin/
|
|
||||||
```
|
|
||||||
|
|
||||||
If SDR support is not needed, build `trx-server` with
|
|
||||||
`--no-default-features`. Keep the source revision and Rust toolchain used for
|
|
||||||
the build in deployment records.
|
|
||||||
|
|
||||||
## 4. Install and validate configuration
|
|
||||||
|
|
||||||
Create private configuration and state directories, then seed the example:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo install -d -o trx-rs -g trx-rs -m 0700 \
|
|
||||||
/home/trx-rs/.config/trx-rs \
|
|
||||||
/home/trx-rs/.config/systemd/user
|
|
||||||
sudo install -o trx-rs -g trx-rs -m 0600 \
|
|
||||||
trx-rs.toml.example /home/trx-rs/.config/trx-rs/trx-rs.toml
|
|
||||||
sudoedit /home/trx-rs/.config/trx-rs/trx-rs.toml
|
|
||||||
```
|
|
||||||
|
|
||||||
At minimum:
|
|
||||||
|
|
||||||
- remove unused example rigs and remotes;
|
|
||||||
- select the correct serial, TCP, or SDR device;
|
|
||||||
- give every enabled rig a unique ID and audio port;
|
|
||||||
- use `127.0.0.1` for same-host connections;
|
|
||||||
- use a LAN address or `0.0.0.0` only for deliberately remote listeners;
|
|
||||||
- enable authentication before exposing server control or the web UI;
|
|
||||||
- replace every example password and token;
|
|
||||||
- keep credential files mode `0600` and owned by `trx-rs`;
|
|
||||||
- set `cookie_secure = true` when the web UI is served through HTTPS.
|
|
||||||
|
|
||||||
For a remote server, control and each per-rig audio listener need an explicit
|
|
||||||
non-loopback address:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[trx-server.listen]
|
|
||||||
enabled = true
|
|
||||||
listen = "0.0.0.0"
|
|
||||||
port = 4530
|
|
||||||
|
|
||||||
[trx-server.listen.auth]
|
|
||||||
tokens_file = "/home/trx-rs/.config/trx-rs/server-tokens"
|
|
||||||
|
|
||||||
[[trx-server.rigs]]
|
|
||||||
id = "station-hf"
|
|
||||||
|
|
||||||
[trx-server.rigs.audio]
|
|
||||||
enabled = true
|
|
||||||
listen = "0.0.0.0"
|
|
||||||
port = 4531
|
|
||||||
```
|
|
||||||
|
|
||||||
When using several `[[trx-server.rigs]]` entries, configure audio under each
|
|
||||||
`[trx-server.rigs.audio]` section. Do not rely on the legacy flat
|
|
||||||
`[trx-server.audio]` section.
|
|
||||||
|
|
||||||
For a web UI behind a reverse proxy, keep the backend on loopback and choose an
|
|
||||||
unused port. The proxy upstream must use the same address and port:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[trx-client.frontends.http]
|
|
||||||
enabled = true
|
|
||||||
listen = "127.0.0.1"
|
|
||||||
port = 7345
|
|
||||||
|
|
||||||
[trx-client.frontends.http.auth]
|
|
||||||
enabled = true
|
|
||||||
users_file = "/home/trx-rs/.config/trx-rs/http-users.json"
|
|
||||||
cookie_secure = true
|
|
||||||
```
|
|
||||||
|
|
||||||
Validate the configuration before starting either daemon:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo -u trx-rs /opt/trx-rs/bin/trx-server \
|
|
||||||
--check-config --config /home/trx-rs/.config/trx-rs/trx-rs.toml
|
|
||||||
sudo -u trx-rs /opt/trx-rs/bin/trx-client \
|
|
||||||
--check-config --config /home/trx-rs/.config/trx-rs/trx-rs.toml
|
|
||||||
```
|
|
||||||
|
|
||||||
Treat unknown-key and deprecated-section warnings as deployment errors. They
|
|
||||||
often mean a setting is not applied where expected.
|
|
||||||
|
|
||||||
## 5. Install the systemd user services
|
|
||||||
|
|
||||||
Render the packaged units with the immutable binary directory:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sed 's|@BINDIR@|/opt/trx-rs/bin|g' packaging/systemd/trx-server.service \
|
|
||||||
| sudo tee /home/trx-rs/.config/systemd/user/trx-server.service >/dev/null
|
|
||||||
sed 's|@BINDIR@|/opt/trx-rs/bin|g' packaging/systemd/trx-client.service \
|
|
||||||
| sudo tee /home/trx-rs/.config/systemd/user/trx-client.service >/dev/null
|
|
||||||
sudo chown trx-rs:trx-rs \
|
|
||||||
/home/trx-rs/.config/systemd/user/trx-server.service \
|
|
||||||
/home/trx-rs/.config/systemd/user/trx-client.service
|
|
||||||
sudo chmod 0644 \
|
|
||||||
/home/trx-rs/.config/systemd/user/trx-server.service \
|
|
||||||
/home/trx-rs/.config/systemd/user/trx-client.service
|
|
||||||
```
|
|
||||||
|
|
||||||
Enable lingering so the user manager runs without an interactive login, then
|
|
||||||
start it:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo loginctl enable-linger trx-rs
|
|
||||||
trx_uid=$(id -u trx-rs)
|
|
||||||
sudo systemctl start "user@${trx_uid}.service"
|
|
||||||
```
|
|
||||||
|
|
||||||
Manage the user services through that user's runtime directory:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
trx_uid=$(id -u trx-rs)
|
|
||||||
sudo -u trx-rs XDG_RUNTIME_DIR="/run/user/${trx_uid}" \
|
|
||||||
systemctl --user daemon-reload
|
|
||||||
sudo -u trx-rs XDG_RUNTIME_DIR="/run/user/${trx_uid}" \
|
|
||||||
systemctl --user enable --now trx-server.service trx-client.service
|
|
||||||
```
|
|
||||||
|
|
||||||
The client unit has ordering, but not activation, on `trx-server.service`. If
|
|
||||||
both are enabled locally, the client starts after the server process is
|
|
||||||
started. If the local server is disabled because all remotes are external, the
|
|
||||||
client does not start it. The client retries remote connections; systemd cannot
|
|
||||||
order startup against a service on another host.
|
|
||||||
|
|
||||||
## 6. Firewall and reverse proxy
|
|
||||||
|
|
||||||
Allow only required ports and sources. For example, permit server control and
|
|
||||||
audio only from the trusted radio subnet, not from every interface. Exact
|
|
||||||
commands differ between nftables, firewalld, and ufw.
|
|
||||||
|
|
||||||
For the web UI, terminate TLS in a maintained reverse proxy and send traffic to
|
|
||||||
the loopback backend. Preserve WebSocket upgrade headers if the proxy requires
|
|
||||||
them. Example nginx location:
|
|
||||||
|
|
||||||
```nginx
|
|
||||||
location / {
|
|
||||||
proxy_pass http://127.0.0.1:7345;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Test the backend directly before debugging a `502 Bad Gateway` response:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl --fail --show-error http://127.0.0.1:7345/
|
|
||||||
```
|
|
||||||
|
|
||||||
A refused connection means nothing is listening at the proxy's configured
|
|
||||||
address and port. Check for port conflicts with `ss -ltnp` and confirm the
|
|
||||||
application log reports the same bind address as the proxy upstream.
|
|
||||||
|
|
||||||
## 7. Verify the deployment
|
|
||||||
|
|
||||||
```bash
|
|
||||||
trx_uid=$(id -u trx-rs)
|
|
||||||
sudo -u trx-rs XDG_RUNTIME_DIR="/run/user/${trx_uid}" \
|
|
||||||
systemctl --user status trx-server.service trx-client.service
|
|
||||||
sudo journalctl _UID="${trx_uid}" \
|
|
||||||
-u trx-server.service -u trx-client.service --since today
|
|
||||||
sudo ss -ltnp
|
|
||||||
```
|
|
||||||
|
|
||||||
Verify all of the following:
|
|
||||||
|
|
||||||
- both required services remain active without a restart loop;
|
|
||||||
- radio hardware opens successfully;
|
|
||||||
- logs show the intended control, audio, and frontend bind addresses;
|
|
||||||
- the client connects to every configured control and audio endpoint;
|
|
||||||
- only intended interfaces expose listeners;
|
|
||||||
- authentication works and anonymous access is rejected where configured;
|
|
||||||
- the HTTPS proxy returns the UI and supports live updates;
|
|
||||||
- the service account cannot write `/opt/trx-rs/bin`.
|
|
||||||
|
|
||||||
Do not rely only on `systemctl` reporting `active`: individual frontend tasks
|
|
||||||
can fail after the main client process starts. Always inspect startup logs and
|
|
||||||
probe each required endpoint.
|
|
||||||
|
|
||||||
## 8. Upgrade and roll back
|
|
||||||
|
|
||||||
Build and validate a new revision before replacing the installed executables.
|
|
||||||
Back up configuration and state first. Stop the services, install all binaries
|
|
||||||
from the same build, and restart:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
trx_uid=$(id -u trx-rs)
|
|
||||||
sudo -u trx-rs XDG_RUNTIME_DIR="/run/user/${trx_uid}" \
|
|
||||||
systemctl --user stop trx-client.service trx-server.service
|
|
||||||
sudo install -o root -g root -m 0755 \
|
|
||||||
target/release/trx-server \
|
|
||||||
target/release/trx-client \
|
|
||||||
target/release/trx-configurator \
|
|
||||||
/opt/trx-rs/bin/
|
|
||||||
sudo -u trx-rs XDG_RUNTIME_DIR="/run/user/${trx_uid}" \
|
|
||||||
systemctl --user start trx-server.service trx-client.service
|
|
||||||
```
|
|
||||||
|
|
||||||
Keep the previous binaries or package revision available for rollback. If the
|
|
||||||
new version fails, stop both services, restore the complete previous binary
|
|
||||||
set, restore configuration only if its format changed, and start the services
|
|
||||||
again.
|
|
||||||
@@ -322,269 +322,3 @@ trx-configurator
|
|||||||
| `trx-app` | Config types and validation | Yes |
|
| `trx-app` | Config types and validation | Yes |
|
||||||
| `serialport` | Serial port enumeration | Yes (transitive) |
|
| `serialport` | Serial port enumeration | Yes (transitive) |
|
||||||
| `soapysdr` | SDR device enumeration (optional) | Yes (feature-gated) |
|
| `soapysdr` | SDR device enumeration (optional) | Yes (feature-gated) |
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Logbook and Ham Radio Layout
|
|
||||||
|
|
||||||
Two halves of one feature ([#54](https://git.haxx.space/sjg/trx-rs/issues/54)): a station
|
|
||||||
logbook in a panel of its own, and an operator layout that puts a transceiver's controls
|
|
||||||
around it.
|
|
||||||
|
|
||||||
Nothing in the application records a QSO today. The map's "QSO summary" cards describe
|
|
||||||
contacts *between other stations*, reconstructed from decoded traffic; bookmarks are
|
|
||||||
frequencies, not contacts. Neither is the operator's own log.
|
|
||||||
|
|
||||||
### Requirements
|
|
||||||
|
|
||||||
| ID | Description |
|
|
||||||
|----|-------------|
|
|
||||||
| REQ-LOG-001 | The system shall record QSOs the operator makes, each holding at minimum callsign, date, time, band, frequency, mode and both signal reports. |
|
|
||||||
| REQ-LOG-002 | When starting a log entry, the system shall pre-fill exactly six fields: frequency, mode, rig name, time, callsign and locator. |
|
|
||||||
| REQ-LOG-003 | The system shall leave every other field of a log entry empty for the operator to fill. |
|
|
||||||
| REQ-LOG-004 | The system shall allow a logged QSO to be edited and deleted. |
|
|
||||||
| REQ-LOG-005 | The system shall survive a crash without losing a QSO that was recorded before it. |
|
|
||||||
| REQ-LOG-006 | The system shall list, search and filter the log by callsign, band, mode and date range. |
|
|
||||||
| REQ-LOG-007 | Where a decoded station is on screen, the system shall offer to start a log entry from it, pre-filled, without logging it unattended. |
|
|
||||||
| REQ-LOG-008 | The system shall show whether a callsign has been worked before, and on which bands. |
|
|
||||||
| REQ-FMT-001 | The system shall export the log as an ADIF 3.1.x `.adi` file. |
|
|
||||||
| REQ-FMT-002 | The system shall import ADIF `.adi` files produced by other logging software, preserving fields it does not itself use. |
|
|
||||||
| REQ-FMT-003 | When importing, the system shall identify QSOs already held and shall not duplicate them. |
|
|
||||||
| REQ-FMT-004 | The system shall export a filtered selection of the log as a Cabrillo 3.0 file for contest submission. |
|
|
||||||
| REQ-LOG-009 | The system shall stamp QSO times from the server's clock in UTC, and shall tell the operator when the browser's clock disagrees with it by more than one second. |
|
|
||||||
| REQ-LAY-001 | The system shall present the logbook in a panel of its own, reachable whatever layout is selected. |
|
|
||||||
| REQ-LAY-002 | The system shall offer a "Ham radio" operator layout presenting the transceiver controls and that panel together. |
|
|
||||||
| REQ-LAY-003 | Where the selected rig cannot transmit, the system shall not offer the ham layout. |
|
|
||||||
|
|
||||||
### A decode is not a QSO
|
|
||||||
|
|
||||||
The decoders are receive-only: FT8, CW, APRS and the rest report what was *heard*. A heard
|
|
||||||
callsign is the beginning of a log entry, not a contact, and the logbook must never write one
|
|
||||||
by itself — REQ-LOG-007 says offer and pre-fill, never auto-log. Digital QSOs made in
|
|
||||||
WSJT-X or similar arrive the way every other logger takes them: through ADIF import.
|
|
||||||
|
|
||||||
### What is pre-filled, and what is not
|
|
||||||
|
|
||||||
Six fields, and no more (REQ-LOG-002, REQ-LOG-003):
|
|
||||||
|
|
||||||
| Field | From | ADIF |
|
|
||||||
|-------|------|------|
|
|
||||||
| Frequency | the selected rig's dial | `FREQ`, with `BAND` derived from it |
|
|
||||||
| Mode | the selected rig | `MODE`, and `SUBMODE` where the mode implies one |
|
|
||||||
| Rig name | the rig's display name | `MY_RIG` |
|
|
||||||
| Time | the server's clock, UTC, when the entry opens | `QSO_DATE`, `TIME_ON` |
|
|
||||||
| Callsign | the decode row or map station the entry was started from, else empty | `CALL` |
|
|
||||||
| Locator | that station's grid, where the decode carried one, else empty | `GRIDSQUARE` |
|
|
||||||
|
|
||||||
Signal reports, power, name, QTH and the rest stay empty. A report in particular is the
|
|
||||||
operator's to give: an FT8 SNR is not what was sent, and pre-filling one would put a number in
|
|
||||||
the log that nobody exchanged.
|
|
||||||
|
|
||||||
The station's own callsign and locator are not pre-filled per entry either — they are station
|
|
||||||
identity, taken from configuration when the QSO is written (`STATION_CALLSIGN`, `OPERATOR`,
|
|
||||||
`MY_GRIDSQUARE`), and shown once at the top of the panel rather than typed into every row.
|
|
||||||
|
|
||||||
### Architecture
|
|
||||||
|
|
||||||
#### New crate: `trx-logbook`
|
|
||||||
|
|
||||||
```
|
|
||||||
src/trx-client/
|
|
||||||
trx-logbook/
|
|
||||||
src/
|
|
||||||
lib.rs # LogbookHandle: open, add, edit, delete, query, import, export
|
|
||||||
qso.rs # Qso: the record, its ADIF field mapping, band/mode helpers
|
|
||||||
adif.rs # ADI reader and writer (pure Rust, no new dependencies)
|
|
||||||
store.rs # Append-only JSON Lines file, in-memory index, compaction
|
|
||||||
dedupe.rs # Worked-before and import-collision rules
|
|
||||||
```
|
|
||||||
|
|
||||||
A library crate under `src/trx-client/`, beside `trx-frontend`, consumed by
|
|
||||||
`trx-frontend-http`. The log belongs to the station rather than to a rig or a frontend, so it
|
|
||||||
sits where any frontend can reach it.
|
|
||||||
|
|
||||||
#### Storage: append-only, not a rewritten blob
|
|
||||||
|
|
||||||
Bookmarks use `PickleDb` with `AutoDump`, which rewrites the whole file on every write. That
|
|
||||||
is right for a few dozen bookmarks and wrong for a log: a station with 40 000 QSOs would
|
|
||||||
rewrite several megabytes to log one contact, and lose the lot if the power went during the
|
|
||||||
dump.
|
|
||||||
|
|
||||||
The log is instead a JSON Lines file — the shape `trx-decode-log` already uses — appended one
|
|
||||||
record per write and read into an in-memory index at startup. An edit or a delete appends a new revision of that record's id; the load
|
|
||||||
keeps the last one, and a compaction pass rewrites the file when superseded records exceed a
|
|
||||||
threshold. Appending is O(1) and atomic per line, so a crash costs at most the line being
|
|
||||||
written.
|
|
||||||
|
|
||||||
#### Two formats, for the two things a log is asked for
|
|
||||||
|
|
||||||
The file formats were left open ("pick a well-known ham format"), so: **ADIF for interchange,
|
|
||||||
Cabrillo for contest submission.** Both are implemented in-repo, in the way this project
|
|
||||||
already implements its decoders, and neither adds a dependency.
|
|
||||||
|
|
||||||
**ADIF has to stay.** It is not one option among several — it is the only thing the ecosystem
|
|
||||||
reads. LoTW, eQSL, Club Log, QRZ.com and every other logger take ADIF and nothing else, so a
|
|
||||||
log that cannot write `.adi` cannot be uploaded, confirmed, or moved to another program. That
|
|
||||||
is a one-way door, and the interoperability is most of the point of keeping a log at all.
|
|
||||||
Nothing on disk is ADI regardless: the store is JSON Lines, and ADIF is what comes out of an
|
|
||||||
export.
|
|
||||||
|
|
||||||
ADI is a tagged text format — `<FIELD:length>value`, records ended by `<EOR>`, a header ended
|
|
||||||
by `<EOH>`, everything outside a tag ignored — small enough to implement exactly. The reader
|
|
||||||
must be lenient in the ways real files are irregular (lowercase tags, CRLF, missing header,
|
|
||||||
unknown fields, type indicators) and the writer strict. Unknown fields are carried through
|
|
||||||
import to export unchanged, so a round trip through trx-rs does not quietly strip what another
|
|
||||||
logger wrote. ADX, the XML serialisation of the same data model, is out of scope: it is part
|
|
||||||
of the standard but almost nothing reads it.
|
|
||||||
|
|
||||||
**Cabrillo is the second format, because ADIF cannot do its job.** Contest logs are submitted
|
|
||||||
to sponsors in Cabrillo 3.0 and are rejected in anything else — a header of `CALLSIGN:`,
|
|
||||||
`CONTEST:`, `CATEGORY-*` and `CLAIMED-SCORE:` lines, then one fixed-column `QSO:` line per
|
|
||||||
contact carrying frequency in kHz, a mode code (`CW`, `PH`, `FM`, `RY`, `DG`), the UTC date and
|
|
||||||
time, and both stations' calls, reports and exchanges. It is export-only and drops everything
|
|
||||||
outside the contest's exchange, which is why it complements ADIF rather than replacing it.
|
|
||||||
It arrives with the contest exchange fields in phase 5, since without a serial or a zone to
|
|
||||||
put in the exchange there is nothing for it to write.
|
|
||||||
|
|
||||||
#### Integration points
|
|
||||||
|
|
||||||
| Source | What it gives the log | How |
|
|
||||||
|--------|----------------------|-----|
|
|
||||||
| `RigState` | `FREQ`, `BAND`, `MODE`/`SUBMODE`, and the rig id a QSO was made on | watch channel already in the frontend context |
|
|
||||||
| Client config `general.callsign` | `STATION_CALLSIGN`, and the default `OPERATOR` | already surfaced as `owner_callsign` in frontend meta |
|
|
||||||
| The QSO's own rig, and its position | `MY_GRIDSQUARE` | per-rig latitude and longitude already carried in the rig list |
|
|
||||||
| Server clock | `QSO_DATE`, `TIME_ON` in UTC | new `GET /api/logbook/now`, which also feeds the browser-clock check |
|
|
||||||
| Decoder panels and map | a pre-filled entry: callsign, grid, and the report to offer | existing decode history; no new plumbing |
|
|
||||||
| `bandForHz` | `BAND` from a frequency | exists in `map-core.ts`; move to a shared module |
|
|
||||||
|
|
||||||
#### HTTP API
|
|
||||||
|
|
||||||
Under `/api/logbook`, as the recorder's endpoints are, because `/logbook` itself is the page:
|
|
||||||
the `/bookmarks` API and the bookmarks page already share a path, and whichever is registered
|
|
||||||
first wins.
|
|
||||||
|
|
||||||
| Method | Path | Purpose |
|
|
||||||
|--------|------|---------|
|
|
||||||
| `GET` | `/api/logbook` | Query: filters, paging |
|
|
||||||
| `POST` | `/api/logbook` | Add a QSO |
|
|
||||||
| `PUT` | `/api/logbook/{id}` | Edit |
|
|
||||||
| `DELETE` | `/api/logbook/{id}` | Delete |
|
|
||||||
| `GET` | `/api/logbook/export.adi` | ADIF export, honouring the current filter |
|
|
||||||
| `GET` | `/api/logbook/export.cbr` | Cabrillo export of a contest selection |
|
|
||||||
| `POST` | `/api/logbook/import` | Import, answering with counts: added, duplicate, rejected |
|
|
||||||
| `GET` | `/api/logbook/worked/{call}` | Worked-before: bands and modes |
|
|
||||||
| `GET` | `/api/logbook/statistics` | Contacts, stations and confirmations, per band |
|
|
||||||
| `GET` | `/api/logbook/now` | The server's UTC clock, for checking the browser's |
|
|
||||||
| `GET` | `/api/logbook/prefill` | The six fields an entry opens with |
|
|
||||||
|
|
||||||
Writes require the admin role, as the rig endpoints do.
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
|
|
||||||
The logbook is **its own panel**, not a strip bolted to the radio page: a `logbook` entry in
|
|
||||||
the tab order beside Bookmarks, holding the entry form, the table with the filters of
|
|
||||||
REQ-LOG-006, and import and export. It stands on its own in every layout, so a log can be kept
|
|
||||||
without adopting the ham layout, and read while another layout is selected (REQ-LAY-001).
|
|
||||||
|
|
||||||
The panel is three parts: the station line at the top (own callsign, locator, the rig a QSO
|
|
||||||
would be logged against), the entry form beneath it opening with the six pre-filled fields,
|
|
||||||
and the log itself under that, filtered as REQ-LOG-006 asks. Worked-before shows against
|
|
||||||
the callsign as it is typed.
|
|
||||||
|
|
||||||
The **ham layout** is a fifth entry in the operator layouts (`compact`, `broadcast`, `digital`,
|
|
||||||
`full`), which already gate on capability, seed the disclosure sections and persist per rig:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
ham: {
|
|
||||||
label: "Ham radio",
|
|
||||||
unavailable: "Ham radio needs a rig that can transmit",
|
|
||||||
advanced: true, audio: true, scheduler: false,
|
|
||||||
preferredTab: "logbook", capability: "ham",
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
with the `ham` capability set from `RigCapabilities.tx`. It keeps frequency, VFO, mode, filter,
|
|
||||||
PTT, power and the meters, and hides the broadcast furniture. What it adds over `full` is where
|
|
||||||
it starts: the logbook panel, with the radio controls a keystroke away rather than the other
|
|
||||||
way round — the layout an operator working the bands wants, where logging the contact is the
|
|
||||||
task and the rig is the instrument.
|
|
||||||
|
|
||||||
### Phases
|
|
||||||
|
|
||||||
All five are implemented.
|
|
||||||
|
|
||||||
| Phase | Lands |
|
|
||||||
|-------|-------|
|
|
||||||
| 1 | `trx-logbook`: `Qso`, the ADI reader and writer, round-trip tests against files from other loggers |
|
|
||||||
| 2 | Store, dedupe, and the HTTP API behind the admin role |
|
|
||||||
| 3 | Logbook tab: entry, table, filters, import, export |
|
|
||||||
| 4 | Ham layout, pre-filled entry from a decode row or the map, worked-before |
|
|
||||||
| 5 | Contest exchange fields and Cabrillo export; QSL and LoTW/eQSL fields; per-band worked/confirmed statistics |
|
|
||||||
|
|
||||||
Two decisions phase 5 settled. The Cabrillo header cannot be derived from a log — how many
|
|
||||||
operators, how much power, what the score is claimed to be — so it comes from the operator,
|
|
||||||
with `SINGLE-OP`, `LOW`, `ALL` and `MIXED` behind it. And a confirmation counts from whichever
|
|
||||||
bureau answered: an award wants one card or one electronic match, not one of each, so a log
|
|
||||||
that counted them separately would tell the operator they were short of what they have.
|
|
||||||
|
|
||||||
### Decisions
|
|
||||||
|
|
||||||
**One station log, not one per rig.** Awards and uploads are per station callsign — DXCC, WAS
|
|
||||||
and LoTW all count the callsign, not the radio — and a station worked on the second rig is
|
|
||||||
still worked. The rig is recorded on the QSO (`MY_RIG`) rather than dividing the log by it.
|
|
||||||
The station *location* fields do follow the rig, though: trx-rs rigs can be in different
|
|
||||||
places, so `MY_GRIDSQUARE` is taken from the rig that made the QSO rather than from one global
|
|
||||||
setting, which is also what LoTW's station locations expect.
|
|
||||||
|
|
||||||
**The operator is a per-QSO field, set once per session.** ADIF separates `STATION_CALLSIGN`
|
|
||||||
(the call used on the air) from `OPERATOR` (the person at the key); multi-operator stations
|
|
||||||
rotate operators through one station callsign, which is why contest loggers record it per QSO.
|
|
||||||
It is stored per QSO, defaulted from the configured callsign so a single operator never touches
|
|
||||||
it, and changed on the station line at the top of the panel where it sticks for the session.
|
|
||||||
It cannot be inferred from the session's identity: an account username need not be an operator
|
|
||||||
callsign, and operational accounts may be shared.
|
|
||||||
|
|
||||||
**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
|
|
||||||
server, and when a browser's clock disagrees by more than a second the panel says so rather
|
|
||||||
than silently logging a time the operator did not expect.
|
|
||||||
|
|
||||||
**The log file is configurable, and defaults to the user's data directory.** Bookmarks live in
|
|
||||||
the config directory because they are settings; decode logs live in the cache directory because
|
|
||||||
they are disposable. A QSO log is neither — it is irreplaceable, and cache directories are
|
|
||||||
swept by cleaners. `[logbook].path` in the client config, defaulting to
|
|
||||||
`dirs::data_dir()/trx-rs/logbook.jsonl`, so a station that keeps its log on a synced or
|
|
||||||
backed-up volume can say so.
|
|
||||||
|
|
||||||
**Import collisions: callsign, band, mode and a two-minute window.** Two loggers rarely agree
|
|
||||||
to the second on the same QSO — one records the time the contact started, another the time it
|
|
||||||
was entered — so an exact-minute key duplicates half of what it is asked to merge. Two minutes
|
|
||||||
absorbs that. It does not swallow legitimate re-works: 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
|
|
||||||
QSO. Times are compared as instants rather than date and time strings, so a QSO either side of
|
|
||||||
midnight matches. Modes are normalised before comparison, or a log that stored `SSB` would fail
|
|
||||||
to match ours that stored `USB`.
|
|
||||||
|
|
||||||
### Rig modes to ADIF modes
|
|
||||||
|
|
||||||
The rig reports what it is demodulating; ADIF wants what the contact was made on, which is not
|
|
||||||
always the same word:
|
|
||||||
|
|
||||||
| Rig mode | ADIF `MODE` | ADIF `SUBMODE` |
|
|
||||||
|----------|-------------|----------------|
|
|
||||||
| `USB`, `LSB` | `SSB` | `USB` / `LSB` |
|
|
||||||
| `CW`, `CWR` | `CW` | — |
|
|
||||||
| `AM`, `SAM` | `AM` | — |
|
|
||||||
| `FM`, `WFM` | `FM` | — |
|
|
||||||
| `PKT` | `PKT` | — |
|
|
||||||
| `DIG` | decided by the decoder in use, not by the rig | |
|
|
||||||
| `AIS`, `VDES` | none — not amateur modes, and these rigs do not log | |
|
|
||||||
| `Other(..)` | passed through when it names an ADIF mode, else left for the operator | |
|
|
||||||
|
|
||||||
`DIG` is the one that cannot come from the rig: a rig in `DIG` is in FT8, FT4 or something else
|
|
||||||
depending on which decoder is running, and an entry started from an FT8 row logs `FT8` rather
|
|
||||||
than the rig's word for it. WSPR never opens an entry at all — it is a beacon mode, and hearing
|
|
||||||
a beacon is not a contact.
|
|
||||||
|
|
||||||
The table is data in `qso.rs`, checked against the ADIF enumeration when it is written, with
|
|
||||||
anything unrecognised left to the operator rather than guessed into the log.
|
|
||||||
|
|||||||
+12
-19
@@ -121,14 +121,13 @@ The spectrum panel uses `<canvas>` elements (WebGL renderer optional) and offers
|
|||||||
When auth is enabled, an **auth gate** blocks the UI with:
|
When auth is enabled, an **auth gate** blocks the UI with:
|
||||||
|
|
||||||
- Title: "Access Required"
|
- Title: "Access Required"
|
||||||
- Subtitle: "Sign in to continue"
|
- Subtitle: "Enter passphrase to continue"
|
||||||
- Username and password inputs + Login button (green accent, full-width)
|
- Password input + Login button (green accent, full-width)
|
||||||
|
- Optional "Continue as Guest" button (shown when RX passphrase is not set)
|
||||||
- Error message area (red `#ff6b6b`)
|
- Error message area (red `#ff6b6b`)
|
||||||
- Role badge display
|
- Role badge display
|
||||||
|
|
||||||
**Guest** provides read-only station access and is exclusive. Non-Guest accounts
|
Two roles: **Rx** (read-only) and **Control** (full access including TX/PTT).
|
||||||
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.
|
||||||
|
|
||||||
@@ -337,31 +336,25 @@ 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 access tiers:
|
Routes are classified into three tiers:
|
||||||
|
|
||||||
| Tier | Examples | Requirement |
|
| Tier | Examples | Requirement |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Public** | `/`, `/index.html`, `/map`, login/session endpoints, static assets | None |
|
| **Public** | `/`, `/index.html`, `/map`, `/auth/*`, static assets | None |
|
||||||
| **Read** | `/status`, `/events`, `/audio`, `/decode`, `/spectrum`, `/bookmarks` | Guest, Read, Control, Transmit, or Administrator role |
|
| **Read** | `/status`, `/events`, `/audio`, `/decode`, `/spectrum`, `/bookmarks` | Rx or Control role |
|
||||||
| **Control** | `/set_freq`, `/set_mode`, `/toggle_power`, receive-side radio-control POST routes | Control or Administrator role |
|
| **Control** | `/set_freq`, `/set_mode`, `/set_ptt`, `/toggle_power`, all other POST | Control role only |
|
||||||
| **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
|
||||||
- Passwords are verified against salted Argon2id hashes
|
- Constant-time passphrase comparison to mitigate timing attacks
|
||||||
|
|
||||||
### 7.3 User Management
|
### 7.3 TX Access Control
|
||||||
|
|
||||||
Every authenticated non-Guest account gets a Settings > Account tab for changing
|
An additional `tx_access_control_enabled` flag can restrict transmit-related actions even
|
||||||
its own password. Guest sees neither Account nor Users and both account-control
|
for Control-role users, providing an extra safety layer.
|
||||||
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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+45
-206
@@ -66,7 +66,8 @@ 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].bootstrap_admin_password` | `bootstrap_admin_password_file` | the initial administrator password |
|
| `[frontends.http.auth].rx_passphrase` | `rx_passphrase_file` | the passphrase |
|
||||||
|
| `[frontends.http.auth].control_passphrase` | `control_passphrase_file` | the passphrase |
|
||||||
| `[frontends.http_json.auth].tokens` | `tokens_file` | one token per line |
|
| `[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
|
||||||
@@ -349,20 +350,17 @@ 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` | Enable the user/password ACL |
|
| `enabled` | bool | `false` | Require a passphrase |
|
||||||
| `users_file` | string | `"trx-http-users.json"` | Persistent managed user database |
|
| `rx_passphrase` | string | — | Passphrase granting receive-only access |
|
||||||
| `bootstrap_admin_username` | string | — | First administrator, used only if the database is absent |
|
| `rx_passphrase_file` | string | — | Read it from this file instead |
|
||||||
| `bootstrap_admin_password` | string | — | First administrator password |
|
| `control_passphrase` | string | — | Passphrase granting full control |
|
||||||
| `bootstrap_admin_password_file` | string | — | Read the bootstrap password from this file instead |
|
| `control_passphrase_file` | string | — | Read it from this file instead |
|
||||||
| `bootstrap_read_enabled` | bool | `true` | Create the default Guest account when the database is absent |
|
| `tx_access_control_enabled` | bool | `true` | Hide TX from unauthenticated users |
|
||||||
| `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` |
|
||||||
|
|
||||||
When enabling ACL for the first time, configure both bootstrap fields. After
|
With `enabled = true`, at least one passphrase must be set.
|
||||||
the database exists, remove the bootstrap credentials from configuration.
|
|
||||||
|
|
||||||
#### `[frontends.rigctl]`
|
#### `[frontends.rigctl]`
|
||||||
|
|
||||||
@@ -456,104 +454,6 @@ listener and every rig's audio listener.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Multiple Rigs in the Web UI
|
|
||||||
|
|
||||||
A client connected to several rigs decodes all of them at once, whichever one
|
|
||||||
is on screen. The rig picker in the header decides which rig the UI is about,
|
|
||||||
and each page answers that differently:
|
|
||||||
|
|
||||||
| Page | Shows |
|
|
||||||
|------|-------|
|
|
||||||
| Radio | The selected rig: its spectrum, its audio, and the mini decode views over the waterfall. |
|
|
||||||
| Digital Modes | The selected rig: every decoder panel, its counts and its status line. |
|
|
||||||
| Map | The whole station — every rig's positions, with the map's own rig filter to narrow it. HF APRS is a source of its own there, filtered apart from VHF APRS. |
|
|
||||||
| Statistics | The whole station, including the per-rig comparison. |
|
|
||||||
|
|
||||||
Switching rigs repaints the radio and digital modes pages for the rig now
|
|
||||||
selected. Nothing is lost by switching: the traffic other rigs heard is still
|
|
||||||
held, and switching back brings it up again. The one exception is the CW pane,
|
|
||||||
which is a single running stream of copied text rather than a list of frames,
|
|
||||||
so it starts empty on the rig you switch to.
|
|
||||||
|
|
||||||
Each browser tab keeps its own selection, so two tabs can watch two rigs.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Logbook
|
|
||||||
|
|
||||||
The **Logbook** tab keeps the station's contacts and speaks ADIF, so a log can
|
|
||||||
be uploaded to LoTW, eQSL, Club Log or QRZ, or moved to another logger.
|
|
||||||
|
|
||||||
An entry opens pre-filled with six fields and no more: the frequency, mode and
|
|
||||||
name of the rig you are on, the time from the server's clock, and — when the
|
|
||||||
entry was started from a decode or a map station — that station's callsign and
|
|
||||||
locator. Signal reports, name, comment and the rest are yours to fill in; a
|
|
||||||
report in particular is never guessed, because an FT8 SNR is not what was sent.
|
|
||||||
|
|
||||||
Your own callsign and locator are not per-contact fields. They are station
|
|
||||||
identity: the callsign comes from `[trx-client.general].callsign`, the locator
|
|
||||||
from the rig's position, and both are shown once at the top of the panel. The
|
|
||||||
operator defaults to the station callsign and can be changed for the session,
|
|
||||||
which is what a multi-operator station needs.
|
|
||||||
|
|
||||||
**Times are the server's**, in UTC — the server is the machine at the radio. If
|
|
||||||
the browser's clock disagrees by more than a second the entry says so rather
|
|
||||||
than logging a time you did not expect.
|
|
||||||
|
|
||||||
**A decode is not a contact.** The decoders are receive-only, so a **Log**
|
|
||||||
button on an FT8 or APRS row opens an entry with what was heard in it and logs
|
|
||||||
nothing by itself. Digital QSOs made in WSJT-X come in through Import ADIF, the
|
|
||||||
way every other logger takes them.
|
|
||||||
|
|
||||||
| Action | What it does |
|
|
||||||
|--------|--------------|
|
|
||||||
| Log contact | Writes the entry and opens a fresh one |
|
|
||||||
| Export ADIF | Downloads the log — filtered, if a filter is set |
|
|
||||||
| Import ADIF | Reads a file, skipping contacts already held and reporting what could not be read |
|
|
||||||
| Worked before | Shows, as you type a callsign, which bands and modes it has been worked on |
|
|
||||||
|
|
||||||
Two contacts are treated as the same when the callsign, band and mode match and
|
|
||||||
the times are within two minutes: two loggers rarely stamp a QSO to the same
|
|
||||||
minute, one recording when it started and the other when it was typed.
|
|
||||||
|
|
||||||
The log is a JSON Lines file, appended one contact at a time so a crash costs at
|
|
||||||
most the contact being written. It lives in your data directory by default;
|
|
||||||
`[trx-client.logbook].path` moves it, for a station that keeps its log on a
|
|
||||||
backed-up volume.
|
|
||||||
|
|
||||||
### Contests
|
|
||||||
|
|
||||||
The **Contest exchange** block on the entry holds the contest's name and the
|
|
||||||
serials — sent and received. Both stay between contacts, because they belong to
|
|
||||||
the session rather than to the contact just logged, and the serial sent counts
|
|
||||||
on by itself so it is not retyped forty times an hour. An exchange that is a
|
|
||||||
zone, a section or a name rather than a number is kept as written.
|
|
||||||
|
|
||||||
**Contest entry (Cabrillo)** exports the entry sponsors accept. Only the
|
|
||||||
contacts of the contest named there are included. The header — operator
|
|
||||||
category, power, claimed score — cannot be worked out from a log, so it is
|
|
||||||
yours to fill in; the defaults are single operator, low power, all bands, mixed
|
|
||||||
mode.
|
|
||||||
|
|
||||||
### Confirmations
|
|
||||||
|
|
||||||
The **QSL** column shows a tick when the other station has confirmed, and the
|
|
||||||
**Confirm** button on a row records a card that has arrived. A confirmation
|
|
||||||
counts from wherever it came: a paper card, LoTW or eQSL. An award wants one of
|
|
||||||
them, not all three, so the log does not ask for all three.
|
|
||||||
|
|
||||||
**Bands worked** counts, per band, the contacts made, the distinct stations
|
|
||||||
worked, and how many of those contacts are confirmed.
|
|
||||||
|
|
||||||
### Ham radio layout
|
|
||||||
|
|
||||||
The **Ham radio** operator layout opens on the logbook with the transceiver
|
|
||||||
controls around it — the arrangement for working the bands, where logging the
|
|
||||||
contact is the task and the radio is the instrument. It is offered only where
|
|
||||||
the selected rig can transmit; a receiver has no contacts to log.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tune Links
|
## 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
|
||||||
@@ -580,7 +480,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; a `Read`
|
Applying a link changes the radio, so it needs the `control` role; an `rx`
|
||||||
session opens the page and says the link was not applied. Links describe the
|
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.
|
||||||
@@ -589,72 +489,53 @@ left as it was, rather than publishing a frequency the rig is not on.
|
|||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
The HTTP frontend supports an optional user/password ACL:
|
The HTTP frontend supports optional passphrase-based authentication with two
|
||||||
|
roles:
|
||||||
|
|
||||||
- **Guest** — read-only station access with no Account or Users controls; Guest cannot be combined with another role
|
- **rx** — read-only access (monitoring, audio, decode streams)
|
||||||
- **Read** — monitoring, audio, decode streams, and bookmark reads
|
- **control** — full access (frequency, mode, PTT, and all settings)
|
||||||
- **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
|
||||||
users_file = "trx-http-users.json"
|
rx_passphrase = "rx-only-passphrase"
|
||||||
bootstrap_admin_username = "admin"
|
control_passphrase = "full-control-passphrase"
|
||||||
bootstrap_admin_password = "change-this-password"
|
tx_access_control_enabled = true
|
||||||
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 enabling it for the first time, bootstrap credentials create
|
as before. When enabled, at least one passphrase must be set.
|
||||||
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.
|
||||||
- User records persist in `users_file`; passwords are stored as salted Argon2id hashes.
|
- When `tx_access_control_enabled = true`, TX/PTT controls are hidden and
|
||||||
- Non-Guest roles are independent; for example, an account may have Read and Write without Control.
|
rejected for unauthenticated or `rx`-role users.
|
||||||
- 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 `{ "username": "...", "password": "..." }` |
|
| `/auth/login` | POST | Submit `{ "passphrase": "..." }` |
|
||||||
| `/auth/logout` | POST | Clear session |
|
| `/auth/logout` | POST | Clear session |
|
||||||
| `/auth/session` | GET | Check current session/roles |
|
| `/auth/session` | GET | Check current session/role |
|
||||||
| `/auth/account/password` | PATCH | Change a non-Guest user's password after verifying the current password |
|
|
||||||
| `/auth/users` | GET/POST | List or add users (admin only) |
|
|
||||||
| `/auth/users/{username}` | PATCH/DELETE | Change enabled state/password/roles or remove user (administrator only) |
|
|
||||||
|
|
||||||
Read routes accept Guest or require Read. Tuning and receive-side radio mutations
|
Protected routes require at least `rx` role. Control routes (set frequency,
|
||||||
require Control. PTT, transmitted audio, and TX limit changes require Transmit.
|
mode, PTT, etc.) require `control` role.
|
||||||
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. The interface enables controls according to the account's roles.
|
4. `rx` users see a read-only interface; `control` users get full controls.
|
||||||
5. If a session expires mid-use, streams stop and the login screen returns.
|
5. If a session expires mid-use, streams stop and the login screen returns.
|
||||||
|
|
||||||
### Transport Security
|
### Transport Security
|
||||||
@@ -752,30 +633,9 @@ 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 — the only point in
|
on raw IQ samples before any mixing or filtering takes place. It works by
|
||||||
the chain where an impulse is still short in time, since the narrow channel
|
tracking a running RMS level of the signal and replacing any sample whose
|
||||||
filter downstream smears it into un-removable ringing.
|
magnitude exceeds **threshold x RMS** with the last known clean sample.
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
@@ -792,7 +652,6 @@ 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]`:
|
||||||
@@ -801,30 +660,15 @@ 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 tracked noise floor. A sample whose magnitude exceeds this multiple is blanked. Lower values blank more aggressively; higher values only catch strong impulses. |
|
| `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. |
|
||||||
| `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.
|
||||||
@@ -850,44 +694,39 @@ the running average signal level.
|
|||||||
|
|
||||||
### Web UI
|
### Web UI
|
||||||
|
|
||||||
When the server reports noise-blanker support, these controls appear in the
|
When the server reports noise-blanker support, two controls appear in the
|
||||||
**SDR Settings** row of the web interface:
|
**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 sensitivity. Press Enter or click Set to apply.
|
detection threshold. Press Enter or click Set to apply.
|
||||||
- **NB profile** selector — chooses the profile (Spike / Ignition / Powerline /
|
|
||||||
Broadband). Changing it applies immediately.
|
|
||||||
|
|
||||||
The controls stay hidden until the server sends filter state containing NB
|
Both 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&profile=spike
|
POST /set_sdr_noise_blanker?enabled=true&threshold=10
|
||||||
```
|
```
|
||||||
|
|
||||||
| 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 *before* the mixer stage in the DSP pipeline,
|
The blanker runs on every IQ block (4096 samples) *before* the mixer stage in
|
||||||
one sample at a time:
|
the DSP pipeline:
|
||||||
|
|
||||||
1. Emit the sample from the look-ahead delay line and ingest the fresh one.
|
1. For each sample, compute magnitude² (`re² + im²`).
|
||||||
2. Compute the fresh sample's power (`re² + im²`) and compare it against
|
2. Compare against `threshold² × mean_sq` (the exponentially-smoothed running
|
||||||
`threshold² × noise_floor`.
|
mean of magnitude²).
|
||||||
3. If it exceeds the threshold, hold the gate closed for the profile's blank
|
3. If the sample exceeds the threshold, replace it with the previous clean
|
||||||
window; the fresh sample reaches the output a few samples later, by which
|
sample.
|
||||||
time the gate has fully ramped to zero — so the leading edge is removed.
|
4. Otherwise, update the running mean with smoothing factor α = 1/128 and store
|
||||||
4. Otherwise, update the noise-floor estimate (skipped while blanking) and let
|
the sample as the last clean value.
|
||||||
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
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
#
|
|
||||||
# systemd *user* unit for the trx-rs radio client and web frontend.
|
|
||||||
#
|
|
||||||
# Install with `script/install.sh` (which substitutes @BINDIR@ and copies this
|
|
||||||
# into ~/.config/systemd/user/), or by hand:
|
|
||||||
# sed "s|@BINDIR@|$HOME/.local/bin|" trx-client.service \
|
|
||||||
# > ~/.config/systemd/user/trx-client.service
|
|
||||||
# systemctl --user daemon-reload
|
|
||||||
# systemctl --user enable --now trx-client.service
|
|
||||||
#
|
|
||||||
# Reads the combined config at ~/.config/trx-rs/trx-rs.toml ([trx-client]
|
|
||||||
# section) and connects to the server (default 127.0.0.1:4530). The web UI
|
|
||||||
# defaults to http://127.0.0.1:8080.
|
|
||||||
|
|
||||||
[Unit]
|
|
||||||
Description=trx-rs radio client and web frontend
|
|
||||||
Documentation=https://github.com/stanislawgrams/trx-rs
|
|
||||||
# Wait for networking and, when it is enabled, the local server. After= only
|
|
||||||
# orders units already in the transaction: it deliberately does not start a
|
|
||||||
# disabled trx-server, since the client may connect to a remote server instead.
|
|
||||||
Wants=network-online.target
|
|
||||||
After=network-online.target trx-server.service
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
ExecStart=@BINDIR@/trx-client --config %h/.config/trx-rs/trx-rs.toml
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=2
|
|
||||||
# Shuts down cleanly on SIGINT (see trx-server.service for the rationale).
|
|
||||||
KillSignal=SIGINT
|
|
||||||
TimeoutStopSec=15
|
|
||||||
NoNewPrivileges=true
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=default.target
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
#
|
|
||||||
# systemd *user* unit for the trx-rs radio server.
|
|
||||||
#
|
|
||||||
# Install with `script/install.sh` (which substitutes @BINDIR@ and copies this
|
|
||||||
# into ~/.config/systemd/user/), or by hand:
|
|
||||||
# sed "s|@BINDIR@|$HOME/.local/bin|" trx-server.service \
|
|
||||||
# > ~/.config/systemd/user/trx-server.service
|
|
||||||
# systemctl --user daemon-reload
|
|
||||||
# systemctl --user enable --now trx-server.service
|
|
||||||
#
|
|
||||||
# Reads the combined config at ~/.config/trx-rs/trx-rs.toml ([trx-server]
|
|
||||||
# section). Serial (/dev/ttyUSB*) and audio access require your user to be in
|
|
||||||
# the `dialout` and `audio` groups — a user unit cannot grant them itself.
|
|
||||||
|
|
||||||
[Unit]
|
|
||||||
Description=trx-rs radio server (CAT/SDR backend)
|
|
||||||
Documentation=https://github.com/stanislawgrams/trx-rs
|
|
||||||
# The server binds its control/audio TCP listeners and may connect to networked
|
|
||||||
# rigs and reporting services, so do not start it before networking is ready.
|
|
||||||
Wants=network-online.target
|
|
||||||
After=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
ExecStart=@BINDIR@/trx-server --config %h/.config/trx-rs/trx-rs.toml
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=2
|
|
||||||
# Both binaries shut down cleanly on SIGINT (the server drains audio and exits
|
|
||||||
# 0); systemd's default SIGTERM is not handled, so signal SIGINT instead.
|
|
||||||
KillSignal=SIGINT
|
|
||||||
TimeoutStopSec=15
|
|
||||||
NoNewPrivileges=true
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=default.target
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
#
|
|
||||||
# Build trx-rs in release mode and install the binaries system-wide, seed a
|
|
||||||
# per-user config, and install systemd *user* services to run the server and
|
|
||||||
# client. Idempotent: existing config is never overwritten.
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
||||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
||||||
|
|
||||||
BINARIES=(trx-server trx-client trx-configurator)
|
|
||||||
|
|
||||||
# --- Defaults (override via flags or environment) ---------------------------
|
|
||||||
PREFIX="${PREFIX:-$HOME/.local}"
|
|
||||||
BINDIR="${BINDIR:-}" # derived from PREFIX unless set explicitly
|
|
||||||
NO_SDR=0
|
|
||||||
DO_BUILD=1
|
|
||||||
DO_SYSTEMD=1
|
|
||||||
ENABLE_NOW=0
|
|
||||||
|
|
||||||
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/trx-rs"
|
|
||||||
CONFIG_FILE="$CONFIG_DIR/trx-rs.toml"
|
|
||||||
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
cat <<EOF
|
|
||||||
Usage: script/install.sh [options]
|
|
||||||
|
|
||||||
Build trx-rs (release) and install it.
|
|
||||||
|
|
||||||
Options:
|
|
||||||
--prefix DIR Install prefix; binaries go to DIR/bin (default: \$HOME/.local)
|
|
||||||
--bindir DIR Install binaries directly to DIR (overrides --prefix)
|
|
||||||
--no-sdr Build without SoapySDR support (--no-default-features)
|
|
||||||
--no-build Skip cargo build; install existing target/release binaries
|
|
||||||
--no-systemd Do not install the systemd user services
|
|
||||||
--enable-now Enable and start the user services immediately after install
|
|
||||||
-h, --help Show this help
|
|
||||||
|
|
||||||
Environment:
|
|
||||||
PREFIX, BINDIR Same as the matching flags.
|
|
||||||
|
|
||||||
Installs:
|
|
||||||
binaries -> ${BINDIR:-$PREFIX/bin} (uses sudo if not writable)
|
|
||||||
config -> $CONFIG_FILE (from trx-rs.toml.example, only if absent)
|
|
||||||
services -> $UNIT_DIR/{trx-server,trx-client}.service
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Parse args -------------------------------------------------------------
|
|
||||||
while [ $# -gt 0 ]; do
|
|
||||||
case "$1" in
|
|
||||||
--prefix) PREFIX="$2"; shift 2 ;;
|
|
||||||
--bindir) BINDIR="$2"; shift 2 ;;
|
|
||||||
--no-sdr) NO_SDR=1; shift ;;
|
|
||||||
--no-build) DO_BUILD=0; shift ;;
|
|
||||||
--no-systemd) DO_SYSTEMD=0; shift ;;
|
|
||||||
--enable-now) ENABLE_NOW=1; shift ;;
|
|
||||||
-h|--help) usage; exit 0 ;;
|
|
||||||
*) echo "error: unknown option '$1'" >&2; usage >&2; exit 2 ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
[ -n "$BINDIR" ] || BINDIR="$PREFIX/bin"
|
|
||||||
|
|
||||||
log() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
|
||||||
warn() { printf '\033[1;33mwarning:\033[0m %s\n' "$*" >&2; }
|
|
||||||
|
|
||||||
# Return 0 (true) if writing into $1 needs elevated privileges.
|
|
||||||
need_sudo() {
|
|
||||||
local dir="$1"
|
|
||||||
while [ ! -e "$dir" ]; do dir="$(dirname "$dir")"; done
|
|
||||||
[ -w "$dir" ] && return 1 || return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Build ------------------------------------------------------------------
|
|
||||||
if [ "$DO_BUILD" -eq 1 ]; then
|
|
||||||
build_args=(build --release --manifest-path "$PROJECT_ROOT/Cargo.toml")
|
|
||||||
for b in "${BINARIES[@]}"; do build_args+=(-p "$b"); done
|
|
||||||
if [ "$NO_SDR" -eq 1 ]; then
|
|
||||||
# --no-default-features only affects trx-server; harmless elsewhere.
|
|
||||||
build_args+=(-p trx-server --no-default-features)
|
|
||||||
log "Building (release, no SDR)…"
|
|
||||||
else
|
|
||||||
log "Building (release, with SDR)…"
|
|
||||||
fi
|
|
||||||
cargo "${build_args[@]}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Install binaries -------------------------------------------------------
|
|
||||||
SUDO=""
|
|
||||||
if need_sudo "$BINDIR"; then
|
|
||||||
SUDO="sudo"
|
|
||||||
log "Installing binaries to $BINDIR (using sudo)…"
|
|
||||||
else
|
|
||||||
log "Installing binaries to $BINDIR…"
|
|
||||||
fi
|
|
||||||
|
|
||||||
for b in "${BINARIES[@]}"; do
|
|
||||||
src="$PROJECT_ROOT/target/release/$b"
|
|
||||||
if [ ! -x "$src" ]; then
|
|
||||||
echo "error: $src not found (build failed or --no-build with no prior build?)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
$SUDO install -Dm755 "$src" "$BINDIR/$b"
|
|
||||||
log "installed $BINDIR/$b"
|
|
||||||
done
|
|
||||||
|
|
||||||
# --- Seed per-user config ---------------------------------------------------
|
|
||||||
if [ -e "$CONFIG_FILE" ]; then
|
|
||||||
log "Config already present, leaving it untouched: $CONFIG_FILE"
|
|
||||||
else
|
|
||||||
mkdir -p "$CONFIG_DIR"
|
|
||||||
install -m600 "$PROJECT_ROOT/trx-rs.toml.example" "$CONFIG_FILE"
|
|
||||||
log "Seeded config from example: $CONFIG_FILE"
|
|
||||||
warn "Edit $CONFIG_FILE for your rig before starting the services."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- systemd user services --------------------------------------------------
|
|
||||||
if [ "$DO_SYSTEMD" -eq 1 ]; then
|
|
||||||
mkdir -p "$UNIT_DIR"
|
|
||||||
for unit in trx-server trx-client; do
|
|
||||||
sed "s|@BINDIR@|$BINDIR|g" \
|
|
||||||
"$PROJECT_ROOT/packaging/systemd/$unit.service" \
|
|
||||||
> "$UNIT_DIR/$unit.service"
|
|
||||||
log "installed $UNIT_DIR/$unit.service"
|
|
||||||
done
|
|
||||||
|
|
||||||
if command -v systemctl >/dev/null 2>&1; then
|
|
||||||
systemctl --user daemon-reload || warn "systemctl --user daemon-reload failed"
|
|
||||||
if [ "$ENABLE_NOW" -eq 1 ]; then
|
|
||||||
log "Enabling and starting user services…"
|
|
||||||
systemctl --user enable --now trx-server.service trx-client.service
|
|
||||||
warn "Run 'loginctl enable-linger $USER' to keep services running after logout."
|
|
||||||
else
|
|
||||||
cat <<EOF
|
|
||||||
|
|
||||||
Next steps:
|
|
||||||
1. Edit your config: \$EDITOR $CONFIG_FILE
|
|
||||||
2. Start the services: systemctl --user enable --now trx-server trx-client
|
|
||||||
3. Watch the logs: journalctl --user -u trx-server -u trx-client -f
|
|
||||||
4. Run after logout: loginctl enable-linger $USER
|
|
||||||
|
|
||||||
Web UI (default): http://127.0.0.1:8080
|
|
||||||
EOF
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
warn "systemctl not found; units copied but not reloaded."
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "Done."
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
#
|
|
||||||
# Reverse script/install.sh: stop and remove the systemd user services and the
|
|
||||||
# installed binaries. User config (~/.config/trx-rs) is left in place unless
|
|
||||||
# --purge is given.
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
BINARIES=(trx-server trx-client trx-configurator)
|
|
||||||
|
|
||||||
PREFIX="${PREFIX:-$HOME/.local}"
|
|
||||||
BINDIR="${BINDIR:-}"
|
|
||||||
PURGE=0
|
|
||||||
|
|
||||||
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/trx-rs"
|
|
||||||
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
cat <<EOF
|
|
||||||
Usage: script/uninstall.sh [options]
|
|
||||||
|
|
||||||
Options:
|
|
||||||
--prefix DIR Install prefix binaries were installed under (default: \$HOME/.local)
|
|
||||||
--bindir DIR Directory binaries were installed to (overrides --prefix)
|
|
||||||
--purge Also delete the user config directory ($CONFIG_DIR)
|
|
||||||
-h, --help Show this help
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
while [ $# -gt 0 ]; do
|
|
||||||
case "$1" in
|
|
||||||
--prefix) PREFIX="$2"; shift 2 ;;
|
|
||||||
--bindir) BINDIR="$2"; shift 2 ;;
|
|
||||||
--purge) PURGE=1; shift ;;
|
|
||||||
-h|--help) usage; exit 0 ;;
|
|
||||||
*) echo "error: unknown option '$1'" >&2; usage >&2; exit 2 ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
[ -n "$BINDIR" ] || BINDIR="$PREFIX/bin"
|
|
||||||
|
|
||||||
log() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
|
||||||
|
|
||||||
need_sudo() {
|
|
||||||
local dir="$1"
|
|
||||||
while [ ! -e "$dir" ]; do dir="$(dirname "$dir")"; done
|
|
||||||
[ -w "$dir" ] && return 1 || return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Stop and remove user services -----------------------------------------
|
|
||||||
if command -v systemctl >/dev/null 2>&1; then
|
|
||||||
log "Stopping and disabling user services…"
|
|
||||||
systemctl --user disable --now trx-client.service trx-server.service 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
for unit in trx-client trx-server; do
|
|
||||||
if [ -e "$UNIT_DIR/$unit.service" ]; then
|
|
||||||
rm -f "$UNIT_DIR/$unit.service"
|
|
||||||
log "removed $UNIT_DIR/$unit.service"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
if command -v systemctl >/dev/null 2>&1; then
|
|
||||||
systemctl --user daemon-reload || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Remove binaries --------------------------------------------------------
|
|
||||||
SUDO=""
|
|
||||||
if need_sudo "$BINDIR"; then SUDO="sudo"; fi
|
|
||||||
for b in "${BINARIES[@]}"; do
|
|
||||||
if [ -e "$BINDIR/$b" ]; then
|
|
||||||
$SUDO rm -f "$BINDIR/$b"
|
|
||||||
log "removed $BINDIR/$b"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# --- Optionally purge config ------------------------------------------------
|
|
||||||
if [ "$PURGE" -eq 1 ]; then
|
|
||||||
rm -rf "$CONFIG_DIR"
|
|
||||||
log "purged $CONFIG_DIR"
|
|
||||||
else
|
|
||||||
log "Left config in place: $CONFIG_DIR (use --purge to delete)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "Done."
|
|
||||||
@@ -45,20 +45,8 @@ impl WefaxConfig {
|
|||||||
60.0 / lpm as f32
|
60.0 / lpm as f32
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Samples per line at the internal sample rate (rounded to an integer;
|
/// Samples per line at the internal sample rate.
|
||||||
/// 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,24 +38,10 @@ 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 mark the end of a
|
/// Number of consecutive uncorrelated scan lines that trigger auto-finalize
|
||||||
/// transmission (carrier truly gone) and trigger auto-finalize. This must be
|
/// while receiving. At 120 LPM this is 15 s; at 60 LPM it's 30 s. Modelled on
|
||||||
/// long enough to ride through ordinary HF fading (QSB), which routinely
|
/// fldigi's line-to-line correlation check for automatic stop.
|
||||||
/// decorrelates adjacent lines for several seconds without the transmission
|
const LINE_CORR_NOISE_LINES: u32 = 30;
|
||||||
/// 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
|
||||||
@@ -121,12 +107,6 @@ 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).
|
||||||
@@ -155,7 +135,6 @@ 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(),
|
||||||
}
|
}
|
||||||
@@ -225,13 +204,7 @@ 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 {
|
||||||
@@ -367,14 +340,6 @@ 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 =
|
||||||
@@ -437,7 +402,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,7 +434,6 @@ 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()
|
||||||
@@ -500,7 +463,6 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -634,47 +596,4 @@ 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 {
|
||||||
/// Exact (fractional) samples per line at the internal sample rate.
|
/// Samples per line at the internal sample rate.
|
||||||
samples_per_line: f64,
|
samples_per_line: usize,
|
||||||
/// 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,37 +22,22 @@ 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_f64(lpm, sample_rate);
|
let samples_per_line = WefaxConfig::samples_per_line(lpm, sample_rate);
|
||||||
let pixels_per_line = WefaxConfig::pixels_per_line(ioc) as usize;
|
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 as usize * 2),
|
buffer: Vec::with_capacity(samples_per_line * 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
|
||||||
@@ -71,18 +56,12 @@ 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;
|
||||||
loop {
|
while offset + self.samples_per_line <= self.buffer.len() {
|
||||||
let len = self.line_len(self.line_index);
|
let line_samples = &self.buffer[offset..offset + self.samples_per_line];
|
||||||
if offset + len > self.buffer.len() {
|
let pixels = self.resample_line(line_samples);
|
||||||
break;
|
lines.push(pixels);
|
||||||
}
|
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);
|
||||||
@@ -98,7 +77,6 @@ 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
|
||||||
@@ -151,29 +129,6 @@ 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,12 +77,8 @@ 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 = 2.0;
|
let min_sustain_s = 1.0; // fldigi uses 2 consecutive half-second windows
|
||||||
let window_duration_s = window_size as f32 / sample_rate as f32;
|
let 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,17 +252,11 @@ 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.users_file = cfg.frontends.http.auth.users_file.clone();
|
frontend_runtime.http_auth.rx_passphrase = cfg.frontends.http.auth.rx_passphrase.clone();
|
||||||
frontend_runtime.http_auth.bootstrap_admin_username =
|
frontend_runtime.http_auth.control_passphrase =
|
||||||
cfg.frontends.http.auth.bootstrap_admin_username.clone();
|
cfg.frontends.http.auth.control_passphrase.clone();
|
||||||
frontend_runtime.http_auth.bootstrap_admin_password =
|
frontend_runtime.http_auth.tx_access_control_enabled =
|
||||||
cfg.frontends.http.auth.bootstrap_admin_password.clone();
|
cfg.frontends.http.auth.tx_access_control_enabled;
|
||||||
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 {
|
||||||
@@ -285,10 +279,6 @@ 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,12 +257,9 @@ impl Default for DecodeHistoryContext {
|
|||||||
/// HTTP authentication configuration.
|
/// HTTP authentication configuration.
|
||||||
pub struct HttpAuthConfig {
|
pub struct HttpAuthConfig {
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
pub users_file: String,
|
pub rx_passphrase: Option<String>,
|
||||||
pub bootstrap_admin_username: Option<String>,
|
pub control_passphrase: Option<String>,
|
||||||
pub bootstrap_admin_password: Option<String>,
|
pub tx_access_control_enabled: bool,
|
||||||
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,
|
||||||
@@ -274,12 +271,9 @@ impl Default for HttpAuthConfig {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
users_file: "trx-http-users.json".to_string(),
|
rx_passphrase: None,
|
||||||
bootstrap_admin_username: None,
|
control_passphrase: None,
|
||||||
bootstrap_admin_password: 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_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(),
|
||||||
@@ -298,10 +292,6 @@ 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 {
|
||||||
@@ -315,8 +305,6 @@ 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,8 +11,6 @@ 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"] }
|
||||||
@@ -28,11 +26,7 @@ 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,7 +1,3 @@
|
|||||||
import {
|
|
||||||
forActiveRig,
|
|
||||||
isActiveRigDecode
|
|
||||||
} from "./chunk-S57W63QN.js";
|
|
||||||
import {
|
import {
|
||||||
hostCore,
|
hostCore,
|
||||||
hostState
|
hostState
|
||||||
@@ -144,22 +140,18 @@ function aisLatestByVessel(messages) {
|
|||||||
}
|
}
|
||||||
return Array.from(byMmsi.values());
|
return Array.from(byMmsi.values());
|
||||||
}
|
}
|
||||||
function aisRigMessages() {
|
|
||||||
return forActiveRig(aisMessageHistory);
|
|
||||||
}
|
|
||||||
function updateAisSummary() {
|
function updateAisSummary() {
|
||||||
const plan = currentAisChannelPlan();
|
const plan = currentAisChannelPlan();
|
||||||
if (aisChannelSummaryEl) {
|
if (aisChannelSummaryEl) {
|
||||||
aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`;
|
aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`;
|
||||||
}
|
}
|
||||||
const rigMessages = aisRigMessages();
|
const vessels = aisLatestByVessel(aisMessageHistory);
|
||||||
const vessels = aisLatestByVessel(rigMessages);
|
|
||||||
if (aisVesselCountEl) {
|
if (aisVesselCountEl) {
|
||||||
const count = vessels.length;
|
const count = vessels.length;
|
||||||
aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`;
|
aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`;
|
||||||
}
|
}
|
||||||
if (aisLatestSeenEl) {
|
if (aisLatestSeenEl) {
|
||||||
const latest = rigMessages[0];
|
const latest = aisMessageHistory[0];
|
||||||
if (!latest) {
|
if (!latest) {
|
||||||
aisLatestSeenEl.textContent = "No traffic yet";
|
aisLatestSeenEl.textContent = "No traffic yet";
|
||||||
} else {
|
} else {
|
||||||
@@ -230,9 +222,7 @@ function updateAisBar() {
|
|||||||
updateAisSummary();
|
updateAisSummary();
|
||||||
const isAis = (document.getElementById("mode")?.value || "").toUpperCase() === "AIS";
|
const isAis = (document.getElementById("mode")?.value || "").toUpperCase() === "AIS";
|
||||||
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
|
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
|
||||||
const recent = aisMessageHistory.filter(
|
const recent = aisMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs);
|
||||||
(msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id)
|
|
||||||
);
|
|
||||||
const messages = aisLatestByVessel(recent).slice(0, 8);
|
const messages = aisLatestByVessel(recent).slice(0, 8);
|
||||||
if (!isAis || messages.length === 0) {
|
if (!isAis || messages.length === 0) {
|
||||||
aisBarOverlay.style.display = "none";
|
aisBarOverlay.style.display = "none";
|
||||||
@@ -277,7 +267,7 @@ function renderAisHistory() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
for (const message of aisRigMessages()) {
|
for (const message of aisMessageHistory) {
|
||||||
fragment.appendChild(renderAisRow(message));
|
fragment.appendChild(renderAisRow(message));
|
||||||
}
|
}
|
||||||
aisMessagesEl.replaceChildren(fragment);
|
aisMessagesEl.replaceChildren(fragment);
|
||||||
@@ -309,10 +299,10 @@ function normalizeServerAisMessage(msg) {
|
|||||||
}
|
}
|
||||||
function onServerAisBatch(messages) {
|
function onServerAisBatch(messages) {
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
|
if (aisStatus) aisStatus.textContent = "Receiving";
|
||||||
const normalized = [];
|
const normalized = [];
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const next = normalizeServerAisMessage(msg);
|
const next = normalizeServerAisMessage(msg);
|
||||||
if (aisStatus && isActiveRigDecode(next.rig_id)) aisStatus.textContent = "Receiving";
|
|
||||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
next._tsMs = tsMs;
|
next._tsMs = tsMs;
|
||||||
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
@@ -352,9 +342,8 @@ if (aisFilterInput) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
function onServerAis(msg) {
|
function onServerAis(msg) {
|
||||||
const message = normalizeServerAisMessage(msg);
|
if (aisStatus) aisStatus.textContent = "Receiving";
|
||||||
if (aisStatus && isActiveRigDecode(message.rig_id)) aisStatus.textContent = "Receiving";
|
addAisMessage(normalizeServerAisMessage(msg));
|
||||||
addAisMessage(message);
|
|
||||||
}
|
}
|
||||||
updateAisSummary();
|
updateAisSummary();
|
||||||
window.trxPluginRuntime.registerDecoder({
|
window.trxPluginRuntime.registerDecoder({
|
||||||
@@ -364,10 +353,6 @@ window.trxPluginRuntime.registerDecoder({
|
|||||||
restore: onServerAisBatch,
|
restore: onServerAisBatch,
|
||||||
reset: resetAisHistoryView,
|
reset: resetAisHistoryView,
|
||||||
prune: pruneAisHistoryView,
|
prune: pruneAisHistoryView,
|
||||||
rerender: () => {
|
|
||||||
updateAisBar();
|
|
||||||
renderAisHistory();
|
|
||||||
},
|
|
||||||
// Oldest first, so vessel tracks are rebuilt in the order they happened.
|
// Oldest first, so vessel tracks are rebuilt in the order they happened.
|
||||||
syncMap: () => {
|
syncMap: () => {
|
||||||
for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry);
|
for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry);
|
||||||
|
|||||||
@@ -1,20 +1,3 @@
|
|||||||
import {
|
|
||||||
AUTH_ADMIN_ROLES,
|
|
||||||
AUTH_ROLES,
|
|
||||||
AUTH_ROLE_LABELS,
|
|
||||||
changeOwnPassword,
|
|
||||||
createUser,
|
|
||||||
deleteUser,
|
|
||||||
fetchAuthSession,
|
|
||||||
hasAccountControls,
|
|
||||||
hasAuthRole,
|
|
||||||
listUsers,
|
|
||||||
login,
|
|
||||||
logout,
|
|
||||||
normalizeAuthRoles,
|
|
||||||
updateUser
|
|
||||||
} from "./chunk-PISLBJGN.js";
|
|
||||||
|
|
||||||
// src/webgl-renderer.ts
|
// src/webgl-renderer.ts
|
||||||
(function initTrxWebGl(global) {
|
(function initTrxWebGl(global) {
|
||||||
"use strict";
|
"use strict";
|
||||||
@@ -650,12 +633,9 @@ 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, ham: false };
|
const layoutCapabilities = { broadcast: false, digital: false };
|
||||||
let activeRigId = null;
|
let activeRigId = null;
|
||||||
const layoutSections = [
|
const layoutSections = [
|
||||||
{ id: "advanced-radio-controls", key: "advanced" },
|
{ id: "advanced-radio-controls", key: "advanced" },
|
||||||
@@ -905,7 +885,7 @@ function elementById(id) {
|
|||||||
if (restoreFocus) more.focus();
|
if (restoreFocus) more.focus();
|
||||||
};
|
};
|
||||||
api.closeMobileOverlays = closeMore;
|
api.closeMobileOverlays = closeMore;
|
||||||
["satellites", "statistics", "recorder", "settings", "about"].forEach((tabName) => {
|
["statistics", "recorder", "settings", "about"].forEach((tabName) => {
|
||||||
const source = nav.querySelector(`[data-tab="${tabName}"]`);
|
const source = nav.querySelector(`[data-tab="${tabName}"]`);
|
||||||
if (!source) return;
|
if (!source) return;
|
||||||
const item = document.createElement("button");
|
const item = document.createElement("button");
|
||||||
@@ -1085,9 +1065,6 @@ var runtime = {
|
|||||||
plugin.prune();
|
plugin.prune();
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
rerenderAll() {
|
|
||||||
for (const plugin of decoders.values()) plugin.rerender?.();
|
|
||||||
},
|
|
||||||
syncMapAll() {
|
syncMapAll() {
|
||||||
for (const plugin of decoders.values()) plugin.syncMap?.();
|
for (const plugin of decoders.values()) plugin.syncMap?.();
|
||||||
},
|
},
|
||||||
@@ -1331,6 +1308,60 @@ 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);
|
||||||
@@ -1496,10 +1527,8 @@ function decodeCbor(buffer) {
|
|||||||
var TAB_ORDER = [
|
var TAB_ORDER = [
|
||||||
"main",
|
"main",
|
||||||
"bookmarks",
|
"bookmarks",
|
||||||
"logbook",
|
|
||||||
"digital-modes",
|
"digital-modes",
|
||||||
"map",
|
"map",
|
||||||
"satellites",
|
|
||||||
"statistics",
|
"statistics",
|
||||||
"recorder",
|
"recorder",
|
||||||
"settings",
|
"settings",
|
||||||
@@ -1508,10 +1537,8 @@ 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",
|
|
||||||
statistics: "/statistics",
|
statistics: "/statistics",
|
||||||
recorder: "/recorder",
|
recorder: "/recorder",
|
||||||
settings: "/settings",
|
settings: "/settings",
|
||||||
@@ -1729,10 +1756,8 @@ var pluginGroups = {
|
|||||||
],
|
],
|
||||||
"map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
|
"map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
|
||||||
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"],
|
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.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"]
|
||||||
};
|
};
|
||||||
@@ -1757,7 +1782,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", "logbook", "settings"].map(loadPlugins));
|
await Promise.all(["digital-modes", "bookmarks", "settings"].map(loadPlugins));
|
||||||
}
|
}
|
||||||
async function loadPluginsForTab(tab) {
|
async function loadPluginsForTab(tab) {
|
||||||
await loadPlugins(tab);
|
await loadPlugins(tab);
|
||||||
@@ -1825,67 +1850,33 @@ function isVchanRdsEntry(value) {
|
|||||||
return isRecord2(value) && typeof value.id === "string" && (value.rds === void 0 || value.rds === null || isRdsData(value.rds)) && (value.signal_db === void 0 || value.signal_db === null || typeof value.signal_db === "number");
|
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 authRoles = [];
|
var authRole = null;
|
||||||
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(username, password) {
|
async function authLogin(passphrase) {
|
||||||
return login(username, password);
|
return login(passphrase);
|
||||||
}
|
}
|
||||||
async function authLogout() {
|
async function authLogout() {
|
||||||
try {
|
try {
|
||||||
await logout();
|
await logout();
|
||||||
setAuthRoles([]);
|
authRole = null;
|
||||||
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-password").value = "";
|
requiredElement("auth-passphrase").value = "";
|
||||||
updateAuthUI();
|
updateAuthUI();
|
||||||
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() {
|
function showAuthGate(allowGuest = false) {
|
||||||
if (!authEnabled) return;
|
if (!authEnabled) return;
|
||||||
setDecodeHistoryOverlayVisible(false);
|
setDecodeHistoryOverlayVisible(false);
|
||||||
requiredElement("loading").style.display = "none";
|
requiredElement("loading").style.display = "none";
|
||||||
@@ -1902,6 +1893,10 @@ function showAuthGate() {
|
|||||||
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");
|
||||||
});
|
});
|
||||||
@@ -1937,30 +1932,20 @@ 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 (authRoles.length > 0) {
|
if (authRole) {
|
||||||
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 = `${authUsername || "local"} — ${authRoles.map((role) => AUTH_ROLE_LABELS[role]).join(", ")}`;
|
if (badgeRole) badgeRole.textContent = authRole === "control" ? "Control (full access)" : "RX (read-only)";
|
||||||
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";
|
||||||
@@ -1970,34 +1955,31 @@ function updateAuthUI() {
|
|||||||
syncTopBarAccess();
|
syncTopBarAccess();
|
||||||
}
|
}
|
||||||
function applyAuthRestrictions() {
|
function applyAuthRestrictions() {
|
||||||
if (authRoles.length === 0) return;
|
if (!authRole) return;
|
||||||
if (!hasAuthRole2("transmit")) {
|
if (authRole === "rx") {
|
||||||
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;
|
||||||
@@ -2034,6 +2016,7 @@ function applyAuthRestrictions() {
|
|||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
if (txLimitRow2) txLimitRow2.style.opacity = "0.5";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function applyCapabilities(caps) {
|
function applyCapabilities(caps) {
|
||||||
@@ -2263,21 +2246,20 @@ window.applyDecodeHistoryRetention = function() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
function syncTopBarAccess() {
|
function syncTopBarAccess() {
|
||||||
const loggedOut = authEnabled && authRoles.length === 0;
|
const loggedOut = authEnabled && !authRole;
|
||||||
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";
|
||||||
const lacksLogbookAccess = authEnabled && btn.dataset.tab === "logbook" && !hasAuthRole2("write");
|
btn.style.display = !loggedOut || isMain ? "" : "none";
|
||||||
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 || !hasAuthRole2("control") || lastRigIds.length === 0;
|
headerRigSwitchSelect.disabled = loggedOut || authRole === "rx" || lastRigIds.length === 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var overviewDrawPending = false;
|
var overviewDrawPending = false;
|
||||||
@@ -2913,13 +2895,13 @@ 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 || !hasAuthRole2("control");
|
const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx";
|
||||||
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
|
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
|
||||||
updateRigSubtitle(lastActiveRigId);
|
updateRigSubtitle(lastActiveRigId);
|
||||||
window.trxUi?.setActiveRig(lastActiveRigId);
|
window.trxUi?.setActiveRig(lastActiveRigId);
|
||||||
window.trx.modules.scheduler?.setRig(lastActiveRigId);
|
|
||||||
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
|
|
||||||
if (rigListChanged) {
|
if (rigListChanged) {
|
||||||
|
window.trx.modules.scheduler?.setRig(lastActiveRigId);
|
||||||
|
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
|
||||||
window.trx.modules.bookmarks?.populateScopePicker();
|
window.trx.modules.bookmarks?.populateScopePicker();
|
||||||
void window.trx.modules.bookmarks?.fetch(document.getElementById("bm-category-filter")?.value || "");
|
void window.trx.modules.bookmarks?.fetch(document.getElementById("bm-category-filter")?.value || "");
|
||||||
}
|
}
|
||||||
@@ -2962,10 +2944,7 @@ 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) {
|
||||||
@@ -3573,13 +3552,6 @@ function positionRdsPsOverlay() {
|
|||||||
function resetRdsDisplay() {
|
function resetRdsDisplay() {
|
||||||
updateRdsPsOverlay(primaryRds);
|
updateRdsPsOverlay(primaryRds);
|
||||||
}
|
}
|
||||||
function refreshDecodeBars() {
|
|
||||||
window.updateAisBar?.();
|
|
||||||
window.updateVdesBar?.();
|
|
||||||
window.updateAprsBar?.();
|
|
||||||
window.updateFt8Bar?.();
|
|
||||||
window.updateCwBar?.();
|
|
||||||
}
|
|
||||||
function resetDecoderStateOnRigSwitch() {
|
function resetDecoderStateOnRigSwitch() {
|
||||||
primaryRds = null;
|
primaryRds = null;
|
||||||
vchanRdsById = /* @__PURE__ */ new Map();
|
vchanRdsById = /* @__PURE__ */ new Map();
|
||||||
@@ -3594,8 +3566,6 @@ function resetDecoderStateOnRigSwitch() {
|
|||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (el) el.textContent = "--";
|
if (el) el.textContent = "--";
|
||||||
});
|
});
|
||||||
refreshDecodeBars();
|
|
||||||
window.trxPluginRuntime.rerenderAll();
|
|
||||||
}
|
}
|
||||||
function resetWfmStereoIndicator() {
|
function resetWfmStereoIndicator() {
|
||||||
if (!wfmStFlagEl) return;
|
if (!wfmStFlagEl) return;
|
||||||
@@ -3729,19 +3699,10 @@ 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 === "DIG") return sdrDigSidebandSupported ? digSidebandDirection() : 1;
|
if (modeUpper === "USB" || modeUpper === "CW" || modeUpper === "DIG") return 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 : "") {
|
||||||
@@ -4260,16 +4221,9 @@ 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) {
|
||||||
const controlDisabled = disabled || authEnabled && !hasAuthRole2("control");
|
[freqEl, centerFreqEl, modeEl, pttBtn, powerBtn, txLimitInput, txLimitBtn, lockBtn].forEach((el) => {
|
||||||
const transmitDisabled = disabled || authEnabled && !hasAuthRole2("transmit");
|
if (el) el.disabled = disabled;
|
||||||
[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;
|
||||||
@@ -4532,7 +4486,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 (!hasAuthRole2("control")) {
|
if (authRole === "rx") {
|
||||||
showHint("Read-only session — link not applied", 2500);
|
showHint("Read-only session — link not applied", 2500);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -4766,25 +4720,6 @@ 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") {
|
||||||
@@ -4850,7 +4785,10 @@ function render(update) {
|
|||||||
const connText = _decodeConnectedText[d.id] || "Connected, listening for packets";
|
const connText = _decodeConnectedText[d.id] || "Connected, listening for packets";
|
||||||
setModeBoundDecodeStatus(el, d.active_modes, "Select " + d.active_modes[0] + " mode to decode", connText);
|
setModeBoundDecodeStatus(el, d.active_modes, "Select " + d.active_modes[0] + " mode to decode", connText);
|
||||||
}
|
}
|
||||||
refreshDecodeBars();
|
if (window.updateAisBar) window.updateAisBar();
|
||||||
|
if (window.updateVdesBar) window.updateVdesBar();
|
||||||
|
if (window.updateAprsBar) window.updateAprsBar();
|
||||||
|
if (window.updateFt8Bar) window.updateFt8Bar();
|
||||||
for (const d of decoderRegistry) {
|
for (const d of decoderRegistry) {
|
||||||
if (d.activation !== "toggle") continue;
|
if (d.activation !== "toggle") continue;
|
||||||
const key = d.id.replace(/-/g, "_") + "_decode_enabled";
|
const key = d.id.replace(/-/g, "_") + "_decode_enabled";
|
||||||
@@ -5269,7 +5207,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) {
|
||||||
setAuthRoles([]);
|
authRole = null;
|
||||||
if (es) es.close();
|
if (es) es.close();
|
||||||
showAuthGate();
|
showAuthGate();
|
||||||
throw new Error("Authentication required");
|
throw new Error("Authentication required");
|
||||||
@@ -5294,7 +5232,7 @@ async function switchRigFromSelect(selectEl) {
|
|||||||
showHint("No rig selected", 1500);
|
showHint("No rig selected", 1500);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!hasAuthRole2("control")) {
|
if (authRole === "rx") {
|
||||||
showHint("Control role required", 1500);
|
showHint("Control role required", 1500);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -5906,15 +5844,9 @@ function _initMapWhenReady() {
|
|||||||
}
|
}
|
||||||
function navigateToTab(name, options = {}) {
|
function navigateToTab(name, options = {}) {
|
||||||
window.trxUi?.closeMobileOverlays?.();
|
window.trxUi?.closeMobileOverlays?.();
|
||||||
const leavingSatellites = _activeTab === "satellites" && name !== "satellites";
|
|
||||||
const { updateHistory = true, replaceHistory = false } = options;
|
const { updateHistory = true, replaceHistory = false } = options;
|
||||||
if (authEnabled && authRoles.length === 0 && name !== "main") {
|
if (authEnabled && !authRole && name !== "main") {
|
||||||
showAuthGate();
|
showAuthGate(false);
|
||||||
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}"]`);
|
||||||
@@ -5941,11 +5873,7 @@ function navigateToTab(name, options = {}) {
|
|||||||
updateTabHistory(name, replaceHistory);
|
updateTabHistory(name, replaceHistory);
|
||||||
}
|
}
|
||||||
scheduleSpectrumLayout();
|
scheduleSpectrumLayout();
|
||||||
if (leavingSatellites) window.clearSatPredictionDom?.();
|
void loadPluginsForTab(name).catch((error) => {
|
||||||
void loadPluginsForTab(name).then(() => {
|
|
||||||
if (name === "satellites") window.refreshSatPredictions?.();
|
|
||||||
flushPendingDecodeStats();
|
|
||||||
}).catch((error) => {
|
|
||||||
console.error(error);
|
console.error(error);
|
||||||
});
|
});
|
||||||
if (name === "map") {
|
if (name === "map") {
|
||||||
@@ -6027,11 +5955,11 @@ window.addEventListener("resize", () => {
|
|||||||
scheduleSpectrumLayout();
|
scheduleSpectrumLayout();
|
||||||
});
|
});
|
||||||
async function initializeApp() {
|
async function initializeApp() {
|
||||||
showAuthGate();
|
showAuthGate(false);
|
||||||
const authStatus = await checkAuthStatus();
|
const authStatus = await checkAuthStatus();
|
||||||
authEnabled = !authStatus.auth_disabled;
|
authEnabled = !authStatus.auth_disabled;
|
||||||
if (!authEnabled) {
|
if (!authEnabled) {
|
||||||
setAuthRoles(AUTH_ADMIN_ROLES);
|
authRole = "control";
|
||||||
hideAuthGate();
|
hideAuthGate();
|
||||||
updateAuthUI();
|
updateAuthUI();
|
||||||
connect();
|
connect();
|
||||||
@@ -6042,8 +5970,7 @@ async function initializeApp() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (authStatus.authenticated) {
|
if (authStatus.authenticated) {
|
||||||
setAuthRoles(authStatus.roles);
|
authRole = authStatus.role ?? null;
|
||||||
authUsername = authStatus.username ?? null;
|
|
||||||
hideAuthGate();
|
hideAuthGate();
|
||||||
updateAuthUI();
|
updateAuthUI();
|
||||||
applyAuthRestrictions();
|
applyAuthRestrictions();
|
||||||
@@ -6053,191 +5980,30 @@ async function initializeApp() {
|
|||||||
resizeHeaderSignalCanvas();
|
resizeHeaderSignalCanvas();
|
||||||
startHeaderSignalSampling();
|
startHeaderSignalSampling();
|
||||||
} else {
|
} else {
|
||||||
showAuthGate();
|
const allowGuest = authStatus.role === "rx";
|
||||||
|
showAuthGate(allowGuest);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var settingsUiReady = false;
|
|
||||||
function initSettingsUI() {
|
function initSettingsUI() {
|
||||||
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, authRoles);
|
window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRole);
|
||||||
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 usernameEl = requiredElement("auth-username");
|
const passphraseEl = requiredElement("auth-passphrase");
|
||||||
const passwordEl = requiredElement("auth-password");
|
const passphrase = passphraseEl.value;
|
||||||
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(usernameEl.value, passwordEl.value);
|
const result = await authLogin(passphrase);
|
||||||
setAuthRoles(result.roles);
|
authRole = result.role ?? null;
|
||||||
authUsername = result.username ?? usernameEl.value;
|
passphraseEl.value = "";
|
||||||
passwordEl.value = "";
|
|
||||||
hideAuthGate();
|
hideAuthGate();
|
||||||
updateAuthUI();
|
updateAuthUI();
|
||||||
applyAuthRestrictions();
|
applyAuthRestrictions();
|
||||||
@@ -6247,22 +6013,37 @@ requiredElement("auth-form").addEventListener("submit", async (e) => {
|
|||||||
resizeHeaderSignalCanvas();
|
resizeHeaderSignalCanvas();
|
||||||
startHeaderSignalSampling();
|
startHeaderSignalSampling();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showAuthError("Invalid username or password");
|
showAuthError("Invalid passphrase");
|
||||||
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 (authRoles.length > 0) {
|
if (authRole) {
|
||||||
if (await window.trxUi.confirm({ title: "Log out?", message: "Audio and control access for this browser session will end.", confirmLabel: "Log out", danger: false })) {
|
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();
|
showAuthGate(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -6300,8 +6081,8 @@ Object.defineProperties(trxState, {
|
|||||||
authEnabled: { get() {
|
authEnabled: { get() {
|
||||||
return authEnabled;
|
return authEnabled;
|
||||||
} },
|
} },
|
||||||
authRoles: { get() {
|
authRole: { get() {
|
||||||
return authRoles;
|
return authRole;
|
||||||
} },
|
} },
|
||||||
decoderRegistry: { get() {
|
decoderRegistry: { get() {
|
||||||
return decoderRegistry;
|
return decoderRegistry;
|
||||||
@@ -6474,9 +6255,7 @@ Object.defineProperties(trxState, {
|
|||||||
} }
|
} }
|
||||||
});
|
});
|
||||||
window.trx = Object.freeze({ state: trxState, core: trxCore, modules: trxModules });
|
window.trx = Object.freeze({ state: trxState, core: trxCore, modules: trxModules });
|
||||||
void loadEagerPlugins().then(() => {
|
void loadEagerPlugins().catch((error) => {
|
||||||
if (settingsUiReady) initSettingsUI();
|
|
||||||
}).catch((error) => {
|
|
||||||
console.error(error);
|
console.error(error);
|
||||||
});
|
});
|
||||||
void initializeApp();
|
void initializeApp();
|
||||||
@@ -6541,6 +6320,9 @@ function _wireSubTabBar(bar) {
|
|||||||
if (window.refreshCwTonePicker) window.refreshCwTonePicker();
|
if (window.refreshCwTonePicker) window.refreshCwTonePicker();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (btn.dataset.subtab !== "sat" && typeof window.clearSatPredictionDom === "function") {
|
||||||
|
window.clearSatPredictionDom();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
document.querySelectorAll(".sub-tab-bar").forEach(_wireSubTabBar);
|
document.querySelectorAll(".sub-tab-bar").forEach(_wireSubTabBar);
|
||||||
@@ -6651,15 +6433,7 @@ 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(() => {
|
||||||
@@ -7029,7 +6803,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)}&profile=${encodeURIComponent(sdrNbProfile)}`
|
`/set_sdr_noise_blanker?enabled=${enabled ? "true" : "false"}&threshold=${encodeURIComponent(threshold)}`
|
||||||
).catch(() => {
|
).catch(() => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -7038,16 +6812,6 @@ 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);
|
||||||
@@ -7065,28 +6829,11 @@ if (sdrNbThresholdEl) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function submitSdrDigSideband() {
|
|
||||||
if (!sdrDigSidebandSupported || !sdrDigSidebandEl) return;
|
|
||||||
const policy = sdrDigSidebandEl.value || "auto";
|
|
||||||
if (policy !== "auto" && policy !== "usb" && policy !== "lsb") return;
|
|
||||||
sdrDigSidebandPolicy = policy;
|
|
||||||
if (lastSpectrumData) scheduleSpectrumDraw();
|
|
||||||
postPath(`/set_sdr_dig_sideband?policy=${encodeURIComponent(policy)}`).catch(() => {
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (sdrDigSidebandEl) {
|
|
||||||
sdrDigSidebandEl.addEventListener("change", () => {
|
|
||||||
submitSdrDigSideband();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function updateWfmControls() {
|
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;
|
||||||
@@ -7408,10 +7155,6 @@ 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;
|
||||||
@@ -7863,35 +7606,11 @@ var IMAGE_DECODE_KINDS = /* @__PURE__ */ new Set([
|
|||||||
"sstv",
|
"sstv",
|
||||||
"sstv_progress"
|
"sstv_progress"
|
||||||
]);
|
]);
|
||||||
var pendingDecodeStats = [];
|
|
||||||
var PENDING_DECODE_STATS_MAX = 5e4;
|
|
||||||
function recordDecodeStat(kind, rig, tsMs) {
|
|
||||||
const stats = window.trx.modules.map;
|
|
||||||
if (stats) {
|
|
||||||
stats.statsRecordDecode(kind, rig, tsMs);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
pendingDecodeStats.push({ kind, rig, tsMs });
|
|
||||||
if (pendingDecodeStats.length > PENDING_DECODE_STATS_MAX) {
|
|
||||||
pendingDecodeStats.splice(0, pendingDecodeStats.length - PENDING_DECODE_STATS_MAX);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function flushPendingDecodeStats() {
|
|
||||||
const stats = window.trx.modules.map;
|
|
||||||
if (!stats || pendingDecodeStats.length === 0) return;
|
|
||||||
for (const entry of pendingDecodeStats.splice(0)) {
|
|
||||||
stats.statsRecordDecode(entry.kind, entry.rig, entry.tsMs);
|
|
||||||
}
|
|
||||||
stats.scheduleStatsRender();
|
|
||||||
}
|
|
||||||
function scheduleStatsRenderIfLoaded() {
|
|
||||||
window.trx.modules.map?.scheduleStatsRender();
|
|
||||||
}
|
|
||||||
function dispatchDecodeMessage(msg, skipStats = false) {
|
function dispatchDecodeMessage(msg, skipStats = false) {
|
||||||
if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg);
|
if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg);
|
||||||
if (!skipStats && msg.type && !IMAGE_DECODE_KINDS.has(msg.type)) {
|
if (!skipStats && msg.type && !IMAGE_DECODE_KINDS.has(msg.type)) {
|
||||||
recordDecodeStat(msg.type, msg.rig_id || msg.remote || null);
|
window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
|
||||||
scheduleStatsRenderIfLoaded();
|
window.trx.modules.map?.scheduleStatsRender();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var DECODE_HISTORY_WORKER_GROUP_LIMIT = 512;
|
var DECODE_HISTORY_WORKER_GROUP_LIMIT = 512;
|
||||||
@@ -7937,9 +7656,9 @@ function restoreDecodeHistoryGroup(kind, messages) {
|
|||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
if (!IMAGE_DECODE_KINDS.has(kind)) {
|
if (!IMAGE_DECODE_KINDS.has(kind)) {
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
recordDecodeStat(kind, msg.rig_id || msg.remote || null, msg.ts_ms || void 0);
|
window.trx.modules.map?.statsRecordDecode(kind, msg.rig_id || msg.remote || null, msg.ts_ms || void 0);
|
||||||
}
|
}
|
||||||
scheduleStatsRenderIfLoaded();
|
window.trx.modules.map?.scheduleStatsRender();
|
||||||
}
|
}
|
||||||
window.trxPluginRuntime.restore(kind, messages);
|
window.trxPluginRuntime.restore(kind, messages);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,7 @@ import {
|
|||||||
collapseAprsDuplicates,
|
collapseAprsDuplicates,
|
||||||
normalizeAprsPacket,
|
normalizeAprsPacket,
|
||||||
renderAprsPacketRow
|
renderAprsPacketRow
|
||||||
} from "./chunk-REYSUJQ4.js";
|
} from "./chunk-OPEIVJGD.js";
|
||||||
import {
|
|
||||||
forActiveRig,
|
|
||||||
isActiveRigDecode
|
|
||||||
} from "./chunk-S57W63QN.js";
|
|
||||||
import {
|
import {
|
||||||
hostCore,
|
hostCore,
|
||||||
hostState
|
hostState
|
||||||
@@ -86,25 +82,20 @@ function aprsFilterMatch(pkt) {
|
|||||||
].filter(Boolean).join(" ").toUpperCase();
|
].filter(Boolean).join(" ").toUpperCase();
|
||||||
return haystack.includes(aprsFilterText);
|
return haystack.includes(aprsFilterText);
|
||||||
}
|
}
|
||||||
function aprsRigPackets() {
|
|
||||||
return forActiveRig(aprsPacketHistory);
|
|
||||||
}
|
|
||||||
function aprsVisiblePackets() {
|
function aprsVisiblePackets() {
|
||||||
const rigPackets = aprsRigPackets();
|
const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory;
|
||||||
const packets = aprsCollapseDup ? collapseAprsDuplicates(rigPackets) : rigPackets;
|
|
||||||
return packets.filter(aprsFilterMatch);
|
return packets.filter(aprsFilterMatch);
|
||||||
}
|
}
|
||||||
function updateAprsSummary() {
|
function updateAprsSummary() {
|
||||||
const rigPackets = aprsRigPackets();
|
|
||||||
const visible = aprsVisiblePackets();
|
const visible = aprsVisiblePackets();
|
||||||
if (aprsTotalCountEl) {
|
if (aprsTotalCountEl) {
|
||||||
aprsTotalCountEl.textContent = `${rigPackets.length} total`;
|
aprsTotalCountEl.textContent = `${aprsPacketHistory.length} total`;
|
||||||
}
|
}
|
||||||
if (aprsVisibleCountEl) {
|
if (aprsVisibleCountEl) {
|
||||||
aprsVisibleCountEl.textContent = `${visible.length} shown`;
|
aprsVisibleCountEl.textContent = `${visible.length} shown`;
|
||||||
}
|
}
|
||||||
if (aprsLatestSeenEl) {
|
if (aprsLatestSeenEl) {
|
||||||
const latest = rigPackets[0];
|
const latest = aprsPacketHistory[0];
|
||||||
if (!latest) {
|
if (!latest) {
|
||||||
aprsLatestSeenEl.textContent = "No packets yet";
|
aprsLatestSeenEl.textContent = "No packets yet";
|
||||||
} else {
|
} else {
|
||||||
@@ -139,9 +130,6 @@ 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" });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -165,9 +153,7 @@ function updateAprsBar() {
|
|||||||
if (!aprsBarOverlay) return;
|
if (!aprsBarOverlay) return;
|
||||||
const isPkt = (document.getElementById("mode")?.value || "").toUpperCase() === "PKT";
|
const isPkt = (document.getElementById("mode")?.value || "").toUpperCase() === "PKT";
|
||||||
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
|
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
|
||||||
const okFrames = aprsPacketHistory.filter(
|
const okFrames = aprsPacketHistory.filter((p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs);
|
||||||
(p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(p.rig_id)
|
|
||||||
);
|
|
||||||
const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
|
const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
|
||||||
const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0);
|
const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0);
|
||||||
if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) {
|
if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) {
|
||||||
@@ -229,11 +215,11 @@ function normalizeServerAprsPacket(pkt) {
|
|||||||
}
|
}
|
||||||
function onServerAprsBatch(packets) {
|
function onServerAprsBatch(packets) {
|
||||||
if (!Array.isArray(packets) || packets.length === 0) return;
|
if (!Array.isArray(packets) || packets.length === 0) return;
|
||||||
|
if (aprsStatus) aprsStatus.textContent = "Receiving";
|
||||||
const normalized = [];
|
const normalized = [];
|
||||||
let hasCrcOk = false;
|
let hasCrcOk = false;
|
||||||
for (const pkt of packets) {
|
for (const pkt of packets) {
|
||||||
const next = normalizeServerAprsPacket(pkt);
|
const next = normalizeServerAprsPacket(pkt);
|
||||||
if (aprsStatus && isActiveRigDecode(next.rig_id)) aprsStatus.textContent = "Receiving";
|
|
||||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
next._tsMs = tsMs;
|
next._tsMs = tsMs;
|
||||||
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
@@ -291,9 +277,8 @@ if (aprsFilterInput) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
function onServerAprs(pkt) {
|
function onServerAprs(pkt) {
|
||||||
const packet = normalizeServerAprsPacket(pkt);
|
if (aprsStatus) aprsStatus.textContent = "Receiving";
|
||||||
if (aprsStatus && isActiveRigDecode(packet.rig_id)) aprsStatus.textContent = "Receiving";
|
addAprsPacket(normalizeServerAprsPacket(pkt));
|
||||||
addAprsPacket(packet);
|
|
||||||
}
|
}
|
||||||
renderAprsHistory();
|
renderAprsHistory();
|
||||||
window.trxPluginRuntime.registerDecoder({
|
window.trxPluginRuntime.registerDecoder({
|
||||||
@@ -303,10 +288,6 @@ window.trxPluginRuntime.registerDecoder({
|
|||||||
restore: onServerAprsBatch,
|
restore: onServerAprsBatch,
|
||||||
reset: resetAprsHistoryView,
|
reset: resetAprsHistoryView,
|
||||||
prune: pruneAprsHistoryView,
|
prune: pruneAprsHistoryView,
|
||||||
rerender: () => {
|
|
||||||
updateAprsBar();
|
|
||||||
renderAprsHistory();
|
|
||||||
},
|
|
||||||
// Oldest first, so station tracks are rebuilt in the order they happened.
|
// Oldest first, so station tracks are rebuilt in the order they happened.
|
||||||
syncMap: () => {
|
syncMap: () => {
|
||||||
for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry);
|
for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry);
|
||||||
|
|||||||
+50
-122
@@ -1,6 +1,3 @@
|
|||||||
import {
|
|
||||||
hasAuthRole
|
|
||||||
} from "./chunk-PISLBJGN.js";
|
|
||||||
import {
|
import {
|
||||||
hostState
|
hostState
|
||||||
} from "./chunk-KL66PICH.js";
|
} from "./chunk-KL66PICH.js";
|
||||||
@@ -16,17 +13,15 @@ var bgdWindow = window;
|
|||||||
return d.id;
|
return d.id;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let backgroundDecodeRoles = [];
|
let backgroundDecodeRole = null;
|
||||||
let currentRigId = null;
|
let currentRigId = null;
|
||||||
let currentConfig = null;
|
let currentConfig = null;
|
||||||
let bookmarkList = [];
|
let bookmarkList = [];
|
||||||
let statusInterval = null;
|
let statusInterval = null;
|
||||||
let bgdDirty = false;
|
let bgdDirty = false;
|
||||||
let statusByBookmark = /* @__PURE__ */ new Map();
|
function initBackgroundDecode(rigId, role) {
|
||||||
let lastStatus = null;
|
backgroundDecodeRole = role;
|
||||||
function initBackgroundDecode(rigId, roles) {
|
currentRigId = rigId || null;
|
||||||
backgroundDecodeRoles = roles;
|
|
||||||
currentRigId = rigId || hostState.lastActiveRigId || null;
|
|
||||||
if (currentRigId) loadBackgroundDecode();
|
if (currentRigId) loadBackgroundDecode();
|
||||||
startStatusPolling();
|
startStatusPolling();
|
||||||
}
|
}
|
||||||
@@ -113,7 +108,7 @@ var bgdWindow = window;
|
|||||||
}
|
}
|
||||||
setCheckbox("background-decode-enabled", currentConfig.enabled);
|
setCheckbox("background-decode-enabled", currentConfig.enabled);
|
||||||
renderBookmarkChecklist();
|
renderBookmarkChecklist();
|
||||||
const isControl = isControlRole();
|
const isControl = backgroundDecodeRole === "control" || hostState.authEnabled === false;
|
||||||
const panel = document.getElementById("background-decode-panel");
|
const panel = document.getElementById("background-decode-panel");
|
||||||
if (panel) {
|
if (panel) {
|
||||||
panel.querySelectorAll("input, select, button.sch-write").forEach(function(el) {
|
panel.querySelectorAll("input, select, button.sch-write").forEach(function(el) {
|
||||||
@@ -124,10 +119,6 @@ var bgdWindow = window;
|
|||||||
const resetBtn = document.getElementById("background-decode-reset-btn");
|
const resetBtn = document.getElementById("background-decode-reset-btn");
|
||||||
if (saveBtn) saveBtn.style.display = isControl ? "" : "none";
|
if (saveBtn) saveBtn.style.display = isControl ? "" : "none";
|
||||||
if (resetBtn) resetBtn.style.display = isControl ? "" : "none";
|
if (resetBtn) resetBtn.style.display = isControl ? "" : "none";
|
||||||
syncSaveButton();
|
|
||||||
}
|
|
||||||
function currentFilterText() {
|
|
||||||
return document.getElementById("bgd-bookmark-filter")?.value ?? "";
|
|
||||||
}
|
}
|
||||||
function renderBookmarkChecklist(filterText = "") {
|
function renderBookmarkChecklist(filterText = "") {
|
||||||
const container = document.getElementById("bgd-bookmark-checklist");
|
const container = document.getElementById("bgd-bookmark-checklist");
|
||||||
@@ -143,49 +134,20 @@ var bgdWindow = window;
|
|||||||
return text.indexOf(filter) >= 0;
|
return text.indexOf(filter) >= 0;
|
||||||
}) : all;
|
}) : all;
|
||||||
if (filtered.length === 0) {
|
if (filtered.length === 0) {
|
||||||
container.innerHTML = '<div class="bgd-checklist-empty">' + escHtml(emptyListText(all.length)) + "</div>";
|
container.innerHTML = '<div class="bgd-checklist-empty">' + (all.length === 0 ? "No supported bookmarks available." : "No bookmarks match filter.") + "</div>";
|
||||||
renderSelectionSummary();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
filtered.forEach(function(bookmark) {
|
filtered.forEach(function(bookmark) {
|
||||||
const row = document.createElement("label");
|
const row = document.createElement("label");
|
||||||
row.className = "bgd-checklist-row";
|
row.className = "bgd-checklist-row";
|
||||||
row.dataset.bmId = bookmark.id;
|
|
||||||
const decoders = bookmarkDecoderKinds(bookmark);
|
const decoders = bookmarkDecoderKinds(bookmark);
|
||||||
const selected = selectedIds.has(bookmark.id);
|
const checked = selectedIds.has(bookmark.id) ? " checked" : "";
|
||||||
if (selected) row.classList.add("is-selected");
|
row.innerHTML = '<input type="checkbox"' + checked + ' data-bm-id="' + escHtml(bookmark.id) + '" /><span class="bgd-checklist-name">' + escHtml(bookmark.name) + '</span><span class="bgd-checklist-meta">' + escHtml(formatFreq(bookmark.freq_hz) + " " + bookmark.mode + " · " + decoders.join("/").toUpperCase()) + "</span>";
|
||||||
row.innerHTML = '<input type="checkbox"' + (selected ? " checked" : "") + ' data-bm-id="' + escHtml(bookmark.id) + '" /><span class="bgd-checklist-name">' + escHtml(bookmark.name) + '</span><span class="bgd-checklist-meta">' + escHtml(formatFreq(bookmark.freq_hz)) + '<span class="bgd-checklist-mode">' + escHtml(bookmark.mode + " · " + decoders.join("/").toUpperCase()) + "</span></span>" + stateBadgeHtml(bookmark.id, selected);
|
|
||||||
row.querySelector("input")?.addEventListener("change", function(e) {
|
row.querySelector("input")?.addEventListener("change", function(e) {
|
||||||
onChecklistToggle(bookmark.id, e.currentTarget.checked);
|
onChecklistToggle(bookmark.id, e.currentTarget.checked);
|
||||||
});
|
});
|
||||||
container.appendChild(row);
|
container.appendChild(row);
|
||||||
});
|
});
|
||||||
renderSelectionSummary();
|
|
||||||
}
|
|
||||||
function emptyListText(supportedCount) {
|
|
||||||
if (supportedCount > 0) return "No bookmark matches that filter.";
|
|
||||||
if (bookmarkList.length > 0) {
|
|
||||||
return "None of your bookmarks name a decoder that can run in the background. Give one a decoder on the Bookmarks tab to list it here.";
|
|
||||||
}
|
|
||||||
return "No bookmarks yet. Save one on the Bookmarks tab and it can be decoded here.";
|
|
||||||
}
|
|
||||||
function stateBadgeHtml(bookmarkId, selected) {
|
|
||||||
if (!selected) return '<span class="bgd-state" data-state="unselected"></span>';
|
|
||||||
const entry = statusByBookmark.get(bookmarkId);
|
|
||||||
const state = entry?.state ?? (currentConfig?.enabled ? "pending" : "disabled");
|
|
||||||
return '<span class="bgd-state" data-state="' + escHtml(state) + '" title="' + escHtml(stateHelp(state)) + '"><span class="bgd-state-dot"></span>' + escHtml(prettyState(state)) + "</span>";
|
|
||||||
}
|
|
||||||
function renderSelectionSummary() {
|
|
||||||
const el = document.getElementById("bgd-selection-summary");
|
|
||||||
if (!el) return;
|
|
||||||
const selected = currentConfig?.bookmark_ids.length ?? 0;
|
|
||||||
if (selected === 0) {
|
|
||||||
el.textContent = "Nothing selected — background decoding is idle.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const active = [...statusByBookmark.values()].filter((entry) => entry.state === "active").length;
|
|
||||||
const noun = `${String(selected)} bookmark${selected === 1 ? "" : "s"} selected`;
|
|
||||||
el.textContent = currentConfig?.enabled ? `${noun}, ${String(active)} decoding now.` : `${noun}. Switch Enabled on to start decoding them.`;
|
|
||||||
}
|
}
|
||||||
function onChecklistToggle(bookmarkId, checked) {
|
function onChecklistToggle(bookmarkId, checked) {
|
||||||
if (!currentConfig) {
|
if (!currentConfig) {
|
||||||
@@ -220,7 +182,7 @@ var bgdWindow = window;
|
|||||||
}).catch(function(err) {
|
}).catch(function(err) {
|
||||||
showToast(`Save failed: ${errorMessage(err)}`, true);
|
showToast(`Save failed: ${errorMessage(err)}`, true);
|
||||||
}).finally(function() {
|
}).finally(function() {
|
||||||
syncSaveButton();
|
if (btn) btn.disabled = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
async function resetBackgroundDecode() {
|
async function resetBackgroundDecode() {
|
||||||
@@ -248,83 +210,59 @@ var bgdWindow = window;
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
function renderStatus(status) {
|
function renderStatus(status) {
|
||||||
lastStatus = status;
|
const card = document.getElementById("background-decode-status-card");
|
||||||
statusByBookmark = new Map(
|
if (!card) return;
|
||||||
(status.entries ?? []).filter((entry) => typeof entry.bookmark_id === "string" && entry.bookmark_id.length > 0).map((entry) => [entry.bookmark_id, entry])
|
const entries = status.entries ?? [];
|
||||||
);
|
if (!entries.length) {
|
||||||
renderSpanSummary();
|
card.textContent = "No background decode bookmarks configured.";
|
||||||
renderBookmarkChecklist(currentFilterText());
|
|
||||||
}
|
|
||||||
function renderSpanSummary() {
|
|
||||||
const el = document.getElementById("bgd-span-summary");
|
|
||||||
if (!el) return;
|
|
||||||
const status = lastStatus;
|
|
||||||
if (!status) {
|
|
||||||
el.textContent = "";
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!status.active_rig) {
|
const summary = [];
|
||||||
el.textContent = "This rig is not the one playing audio.";
|
if (status.active_rig) {
|
||||||
el.dataset.tone = "warn";
|
if (typeof status.center_hz === "number" && Number.isFinite(status.center_hz)) summary.push("Center " + formatFreq(status.center_hz));
|
||||||
return;
|
if (typeof status.sample_rate === "number" && Number.isFinite(status.sample_rate) && status.sample_rate > 0) summary.push("Span ±" + formatFreq(status.sample_rate / 2));
|
||||||
}
|
} else {
|
||||||
const centre = typeof status.center_hz === "number" && Number.isFinite(status.center_hz) ? formatFreq(status.center_hz) : null;
|
summary.push("This rig is not currently selected for audio.");
|
||||||
const half = typeof status.sample_rate === "number" && Number.isFinite(status.sample_rate) && status.sample_rate > 0 ? formatFreq(status.sample_rate / 2) : null;
|
|
||||||
el.dataset.tone = "";
|
|
||||||
el.textContent = centre && half ? `Span ${centre} ±${half}` : centre ? `Centre ${centre}` : "";
|
|
||||||
}
|
|
||||||
function stateHelp(state) {
|
|
||||||
switch (state) {
|
|
||||||
case "active":
|
|
||||||
return "Decoding on a hidden channel.";
|
|
||||||
case "out_of_span":
|
|
||||||
return "Outside the span the rig is tuned across, so it cannot be heard from here.";
|
|
||||||
case "waiting_for_spectrum":
|
|
||||||
return "Waiting for the first spectrum frame from the rig.";
|
|
||||||
case "waiting_for_user":
|
|
||||||
return "Nobody is listening to this rig, so no audio is being pulled.";
|
|
||||||
case "missing_bookmark":
|
|
||||||
return "The bookmark this was selected from is gone.";
|
|
||||||
case "no_supported_decoders":
|
|
||||||
return "No decoder that runs in the background can decode this bookmark.";
|
|
||||||
case "disabled":
|
|
||||||
return "Background decoding is switched off.";
|
|
||||||
case "handled_by_scheduler":
|
|
||||||
case "scheduler_has_control":
|
|
||||||
return "The scheduler is running this bookmark instead.";
|
|
||||||
case "handled_by_virtual_channel":
|
|
||||||
return "A virtual channel is already on this frequency.";
|
|
||||||
case "pending":
|
|
||||||
return "Selected, and not started yet — save to apply.";
|
|
||||||
default:
|
|
||||||
return "Selected, but not decoding.";
|
|
||||||
}
|
}
|
||||||
|
let html = summary.length ? '<div style="margin-bottom:0.8rem;color:var(--text-muted);">' + escHtml(summary.join(" · ")) + "</div>" : "";
|
||||||
|
html += '<div class="bgd-status-list">';
|
||||||
|
entries.forEach(function(entry) {
|
||||||
|
const name = entry.bookmark_name || entry.bookmark_id || "Unknown bookmark";
|
||||||
|
const parts = [];
|
||||||
|
if (typeof entry.freq_hz === "number" && Number.isFinite(entry.freq_hz)) parts.push(formatFreq(entry.freq_hz));
|
||||||
|
if (entry.mode) parts.push(entry.mode);
|
||||||
|
if (Array.isArray(entry.decoder_kinds) && entry.decoder_kinds.length) {
|
||||||
|
parts.push(entry.decoder_kinds.join("/").toUpperCase());
|
||||||
|
}
|
||||||
|
html += '<div class="bgd-status-row"><div><div class="bgd-status-name">' + escHtml(name) + '</div><div class="bgd-status-meta">' + escHtml(parts.join(" · ")) + '</div></div><div class="bgd-status-state" data-state="' + escHtml(entry.state || "inactive") + '"><svg class="bgd-state-dot" viewBox="0 0 8 8"><circle cx="4" cy="4" r="3.5"/></svg>' + escHtml(prettyState(entry.state)) + "</div></div>";
|
||||||
|
});
|
||||||
|
html += "</div>";
|
||||||
|
card.innerHTML = html;
|
||||||
}
|
}
|
||||||
function prettyState(state) {
|
function prettyState(state) {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case "active":
|
case "active":
|
||||||
return "Decoding";
|
return "✓ Active";
|
||||||
case "out_of_span":
|
case "out_of_span":
|
||||||
return "Out of span";
|
return "△ Out of span";
|
||||||
case "waiting_for_spectrum":
|
case "waiting_for_spectrum":
|
||||||
return "Waiting for spectrum";
|
return "△ Waiting";
|
||||||
case "waiting_for_user":
|
case "waiting_for_user":
|
||||||
return "Nobody listening";
|
return "△ No user";
|
||||||
case "missing_bookmark":
|
case "missing_bookmark":
|
||||||
return "Bookmark gone";
|
return "✗ Missing";
|
||||||
case "no_supported_decoders":
|
case "no_supported_decoders":
|
||||||
return "No decoder";
|
return "✗ Unsupported";
|
||||||
case "disabled":
|
case "disabled":
|
||||||
return "Off";
|
return "△ Disabled";
|
||||||
case "handled_by_scheduler":
|
case "handled_by_scheduler":
|
||||||
|
return "△ Scheduler";
|
||||||
case "scheduler_has_control":
|
case "scheduler_has_control":
|
||||||
return "Scheduler has it";
|
return "△ Scheduler";
|
||||||
case "handled_by_virtual_channel":
|
case "handled_by_virtual_channel":
|
||||||
return "On a channel";
|
return "△ VChan";
|
||||||
case "pending":
|
|
||||||
return "Not saved";
|
|
||||||
default:
|
default:
|
||||||
return "Idle";
|
return "△ Inactive";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function setCheckbox(id, value) {
|
function setCheckbox(id, value) {
|
||||||
@@ -347,21 +285,13 @@ var bgdWindow = window;
|
|||||||
function markBgdDirty() {
|
function markBgdDirty() {
|
||||||
if (bgdDirty) return;
|
if (bgdDirty) return;
|
||||||
bgdDirty = true;
|
bgdDirty = true;
|
||||||
syncSaveButton();
|
const btn = document.getElementById("background-decode-save-btn");
|
||||||
|
if (btn) btn.classList.add("sch-dirty");
|
||||||
}
|
}
|
||||||
function clearBgdDirty() {
|
function clearBgdDirty() {
|
||||||
bgdDirty = false;
|
bgdDirty = false;
|
||||||
syncSaveButton();
|
|
||||||
}
|
|
||||||
function syncSaveButton() {
|
|
||||||
const btn = document.getElementById("background-decode-save-btn");
|
const btn = document.getElementById("background-decode-save-btn");
|
||||||
if (!btn) return;
|
if (btn) btn.classList.remove("sch-dirty");
|
||||||
btn.classList.toggle("sch-dirty", bgdDirty);
|
|
||||||
btn.disabled = !bgdDirty || !isControlRole();
|
|
||||||
btn.title = bgdDirty ? "Apply these bookmarks to the background decoder" : "No changes to save";
|
|
||||||
}
|
|
||||||
function isControlRole() {
|
|
||||||
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");
|
||||||
@@ -381,7 +311,7 @@ var bgdWindow = window;
|
|||||||
return bm.id;
|
return bm.id;
|
||||||
});
|
});
|
||||||
currentConfig.bookmark_ids = ids;
|
currentConfig.bookmark_ids = ids;
|
||||||
renderBookmarkChecklist(currentFilterText());
|
renderBookmarkChecklist(document.getElementById("bgd-bookmark-filter")?.value);
|
||||||
markBgdDirty();
|
markBgdDirty();
|
||||||
}
|
}
|
||||||
function deselectAllBookmarks() {
|
function deselectAllBookmarks() {
|
||||||
@@ -389,7 +319,7 @@ var bgdWindow = window;
|
|||||||
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
||||||
}
|
}
|
||||||
currentConfig.bookmark_ids = [];
|
currentConfig.bookmark_ids = [];
|
||||||
renderBookmarkChecklist(currentFilterText());
|
renderBookmarkChecklist(document.getElementById("bgd-bookmark-filter")?.value);
|
||||||
markBgdDirty();
|
markBgdDirty();
|
||||||
}
|
}
|
||||||
function wireBackgroundDecodeEvents() {
|
function wireBackgroundDecodeEvents() {
|
||||||
@@ -404,9 +334,7 @@ var bgdWindow = window;
|
|||||||
if (enabledCb && !enabledCb._wired) {
|
if (enabledCb && !enabledCb._wired) {
|
||||||
enabledCb._wired = true;
|
enabledCb._wired = true;
|
||||||
enabledCb.addEventListener("change", function() {
|
enabledCb.addEventListener("change", function() {
|
||||||
if (currentConfig) currentConfig.enabled = enabledCb.checked;
|
|
||||||
markBgdDirty();
|
markBgdDirty();
|
||||||
renderBookmarkChecklist(currentFilterText());
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const selectAllBtn = document.getElementById("bgd-select-all-btn");
|
const selectAllBtn = document.getElementById("bgd-select-all-btn");
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
import {
|
|
||||||
hasAuthRole
|
|
||||||
} from "./chunk-PISLBJGN.js";
|
|
||||||
import {
|
import {
|
||||||
hostCore,
|
hostCore,
|
||||||
hostState
|
hostState
|
||||||
@@ -45,7 +42,7 @@ function bmEsc(str) {
|
|||||||
return d.innerHTML;
|
return d.innerHTML;
|
||||||
}
|
}
|
||||||
function bmCanControl() {
|
function bmCanControl() {
|
||||||
return !hostState.authEnabled || hasAuthRole(hostState.authRoles, "write");
|
return !hostState.authEnabled || hostState.authRole === "control";
|
||||||
}
|
}
|
||||||
function bmSyncAccess() {
|
function bmSyncAccess() {
|
||||||
const canCtrl = bmCanControl();
|
const canCtrl = bmCanControl();
|
||||||
@@ -214,12 +211,6 @@ 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;
|
||||||
@@ -233,9 +224,7 @@ 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();
|
||||||
@@ -271,7 +260,6 @@ 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) {
|
||||||
@@ -288,8 +276,7 @@ async function bmSave(e) {
|
|||||||
locator: locator || null,
|
locator: locator || null,
|
||||||
category,
|
category,
|
||||||
comment,
|
comment,
|
||||||
decoders,
|
decoders
|
||||||
dig_sideband
|
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
let resp;
|
let resp;
|
||||||
@@ -354,12 +341,6 @@ 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));
|
||||||
@@ -612,7 +593,6 @@ 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) {
|
||||||
|
|||||||
+12
-51
@@ -1,7 +1,3 @@
|
|||||||
import {
|
|
||||||
forActiveRig,
|
|
||||||
isActiveRigDecode
|
|
||||||
} from "./chunk-S57W63QN.js";
|
|
||||||
import {
|
import {
|
||||||
hostCore
|
hostCore
|
||||||
} from "./chunk-KL66PICH.js";
|
} from "./chunk-KL66PICH.js";
|
||||||
@@ -152,33 +148,14 @@ 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 = () => {
|
||||||
prune();
|
prune();
|
||||||
if (!messagesElement) return;
|
if (!messagesElement) return;
|
||||||
const rigMessages = forActiveRig(history);
|
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const message of rigMessages) {
|
for (const message of history) {
|
||||||
if (count >= 200) break;
|
if (count >= 200) break;
|
||||||
if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue;
|
if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue;
|
||||||
fragment.appendChild(renderRow(message));
|
fragment.appendChild(renderRow(message));
|
||||||
@@ -186,26 +163,20 @@ function initializeFtxDecoder(config) {
|
|||||||
}
|
}
|
||||||
messagesElement.replaceChildren(fragment);
|
messagesElement.replaceChildren(fragment);
|
||||||
};
|
};
|
||||||
const plotLocator = (message) => {
|
const normalize = (message) => {
|
||||||
const raw = message.message ?? "";
|
const raw = message.message ?? "";
|
||||||
const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
|
const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
|
||||||
const grids = locatorDetails.length > 0 ? locatorDetails.map(({ grid }) => grid) : bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw);
|
const grids = locatorDetails.length > 0 ? locatorDetails.map(({ grid }) => grid) : bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw);
|
||||||
if (grids.length === 0) return;
|
|
||||||
const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
|
const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
|
||||||
const frequency = displayFrequency(message.freq_hz);
|
const frequency = displayFrequency(message.freq_hz);
|
||||||
bridge.mapAddLocator?.(raw, grids, id, station, {
|
if (grids.length > 0) {
|
||||||
...message,
|
bridge.mapAddLocator?.(raw, grids, id, station, {
|
||||||
freq_hz: frequency ?? message.freq_hz,
|
...message,
|
||||||
locator_details: locatorDetails
|
freq_hz: frequency ?? message.freq_hz,
|
||||||
});
|
locator_details: locatorDetails
|
||||||
};
|
});
|
||||||
const normalize = (message) => {
|
}
|
||||||
const frequency = displayFrequency(message.freq_hz);
|
|
||||||
plotLocator(message);
|
|
||||||
return {
|
return {
|
||||||
// The rig that heard it, kept so the mini view can tell a decode of the
|
|
||||||
// rig on screen from one a background rig made on another band.
|
|
||||||
rig_id: message.rig_id ?? null,
|
|
||||||
receiver: bridge.getDecodeRigMeta?.() ?? null,
|
receiver: bridge.getDecodeRigMeta?.() ?? null,
|
||||||
ts_ms: message.ts_ms,
|
ts_ms: message.ts_ms,
|
||||||
snr_db: message.snr_db,
|
snr_db: message.snr_db,
|
||||||
@@ -217,9 +188,7 @@ function initializeFtxDecoder(config) {
|
|||||||
};
|
};
|
||||||
const receiveBatch = (messages) => {
|
const receiveBatch = (messages) => {
|
||||||
if (messages.length === 0) return;
|
if (messages.length === 0) return;
|
||||||
if (status && messages.some((message) => isActiveRigDecode(message.rig_id))) {
|
if (status) status.textContent = "Receiving";
|
||||||
status.textContent = "Receiving";
|
|
||||||
}
|
|
||||||
history = messages.map(normalize).reverse().concat(history);
|
history = messages.map(normalize).reverse().concat(history);
|
||||||
prune();
|
prune();
|
||||||
bridge.setFt8FamilyBarDecoder?.(id);
|
bridge.setFt8FamilyBarDecoder?.(id);
|
||||||
@@ -233,7 +202,7 @@ function initializeFtxDecoder(config) {
|
|||||||
bridge.clearMapMarkersByType?.(id);
|
bridge.clearMapMarkersByType?.(id);
|
||||||
};
|
};
|
||||||
const barFrames = () => {
|
const barFrames = () => {
|
||||||
const recent = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 9e5 && isActiveRigDecode(message.rig_id)).slice(0, 8);
|
const recent = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 9e5).slice(0, 8);
|
||||||
let html = "";
|
let html = "";
|
||||||
for (const message of recent) {
|
for (const message of recent) {
|
||||||
const timestamp = finiteNumber(message._tsMs ?? message.ts_ms);
|
const timestamp = finiteNumber(message._tsMs ?? message.ts_ms);
|
||||||
@@ -257,15 +226,7 @@ function initializeFtxDecoder(config) {
|
|||||||
prune();
|
prune();
|
||||||
render();
|
render();
|
||||||
},
|
},
|
||||||
reset,
|
reset
|
||||||
rerender: () => {
|
|
||||||
bridge.updateFt8Bar?.();
|
|
||||||
render();
|
|
||||||
},
|
|
||||||
// Oldest first, so the map builds the grids up in the order they were heard.
|
|
||||||
syncMap: () => {
|
|
||||||
for (const message of [...history].reverse()) plotLocator(message);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
bridge.registerFt8FamilyBarRenderer?.(id, barFrames);
|
bridge.registerFt8FamilyBarRenderer?.(id, barFrames);
|
||||||
const updatePeriod = () => {
|
const updatePeriod = () => {
|
||||||
+1
-20
@@ -214,16 +214,6 @@ 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";
|
||||||
@@ -234,7 +224,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>` + (options.onLog && packet.srcCall ? '<button class="aprs-inline-btn" type="button" data-aprs-log="1">Log</button>' : "") + `</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></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();
|
||||||
@@ -243,15 +233,6 @@ 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) => {
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
// src/api/auth.ts
|
|
||||||
var AUTH_ROLES = ["guest", "read", "control", "transmit", "write", "administrator"];
|
|
||||||
var AUTH_ADMIN_ROLES = AUTH_ROLES.filter((role) => role !== "guest");
|
|
||||||
var AUTH_ROLE_LABELS = {
|
|
||||||
guest: "Guest",
|
|
||||||
read: "Read",
|
|
||||||
control: "Control",
|
|
||||||
transmit: "Transmit",
|
|
||||||
write: "Write",
|
|
||||||
administrator: "Administrator"
|
|
||||||
};
|
|
||||||
function isAuthRole(value) {
|
|
||||||
return typeof value === "string" && AUTH_ROLES.includes(value);
|
|
||||||
}
|
|
||||||
function normalizeAuthRoles(roles) {
|
|
||||||
return AUTH_ROLES.filter((role) => roles.includes(role));
|
|
||||||
}
|
|
||||||
function hasAuthRole(roles, required) {
|
|
||||||
return roles.includes("administrator") || roles.includes(required) || required === "read" && roles.includes("guest") || required === "read" && (roles.includes("control") || roles.includes("transmit"));
|
|
||||||
}
|
|
||||||
function hasAccountControls(roles) {
|
|
||||||
return roles.length > 0 && !roles.includes("guest");
|
|
||||||
}
|
|
||||||
function decodeRoles(value, context) {
|
|
||||||
if (!Array.isArray(value) || !value.every(isAuthRole)) {
|
|
||||||
throw new TypeError(`${context} has invalid roles`);
|
|
||||||
}
|
|
||||||
return normalizeAuthRoles(value);
|
|
||||||
}
|
|
||||||
function decodeAuthSession(value) {
|
|
||||||
if (typeof value !== "object" || value === null) {
|
|
||||||
throw new TypeError("The authentication response is malformed");
|
|
||||||
}
|
|
||||||
const session = value;
|
|
||||||
if (typeof session.authenticated !== "boolean") {
|
|
||||||
throw new TypeError("The authentication response has no authenticated flag");
|
|
||||||
}
|
|
||||||
if (session.auth_disabled !== void 0 && typeof session.auth_disabled !== "boolean") {
|
|
||||||
throw new TypeError("The authentication response has an invalid auth_disabled flag");
|
|
||||||
}
|
|
||||||
const decoded = {
|
|
||||||
authenticated: session.authenticated,
|
|
||||||
roles: decodeRoles(session.roles, "The authentication response")
|
|
||||||
};
|
|
||||||
if (session.username !== void 0) {
|
|
||||||
if (typeof session.username !== "string") throw new TypeError("The authentication response has an invalid username");
|
|
||||||
decoded.username = session.username;
|
|
||||||
}
|
|
||||||
if (session.auth_disabled !== void 0) decoded.auth_disabled = session.auth_disabled;
|
|
||||||
return decoded;
|
|
||||||
}
|
|
||||||
var authDisabledSession = {
|
|
||||||
authenticated: true,
|
|
||||||
roles: [...AUTH_ADMIN_ROLES],
|
|
||||||
auth_disabled: true
|
|
||||||
};
|
|
||||||
async function fetchAuthSession() {
|
|
||||||
try {
|
|
||||||
const response = await fetch("/auth/session");
|
|
||||||
if (response.status === 404) return authDisabledSession;
|
|
||||||
if (!response.ok) return { authenticated: false, roles: [] };
|
|
||||||
return decodeAuthSession(await response.json());
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Auth check failed:", error);
|
|
||||||
return { authenticated: false, roles: [] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async function login(username, password) {
|
|
||||||
const response = await fetch("/auth/login", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ username, password })
|
|
||||||
});
|
|
||||||
if (response.status === 404) return authDisabledSession;
|
|
||||||
if (!response.ok) {
|
|
||||||
const message = await response.text();
|
|
||||||
throw new Error(message || "Login failed");
|
|
||||||
}
|
|
||||||
return decodeAuthSession(await response.json());
|
|
||||||
}
|
|
||||||
async function userRequest(path, init) {
|
|
||||||
const response = await fetch(path, init);
|
|
||||||
if (!response.ok) {
|
|
||||||
const payload = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(payload.error || `User operation failed (${response.status})`);
|
|
||||||
}
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
async function listUsers() {
|
|
||||||
const value = await userRequest("/auth/users").then((response) => response.json());
|
|
||||||
if (!Array.isArray(value) || !value.every((user) => {
|
|
||||||
if (typeof user !== "object" || user === null) return false;
|
|
||||||
const record = user;
|
|
||||||
return typeof record.username === "string" && typeof record.enabled === "boolean" && Array.isArray(record.roles) && record.roles.every(isAuthRole);
|
|
||||||
})) {
|
|
||||||
throw new TypeError("The user list response is malformed");
|
|
||||||
}
|
|
||||||
return value.map((user) => ({ ...user, roles: normalizeAuthRoles(user.roles) }));
|
|
||||||
}
|
|
||||||
async function createUser(username, password, roles, enabled = true) {
|
|
||||||
await userRequest("/auth/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password, roles, enabled }) });
|
|
||||||
}
|
|
||||||
async function updateUser(username, changes) {
|
|
||||||
await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(changes) });
|
|
||||||
}
|
|
||||||
async function changeOwnPassword(currentPassword, newPassword) {
|
|
||||||
await userRequest("/auth/account/password", {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword })
|
|
||||||
});
|
|
||||||
}
|
|
||||||
async function deleteUser(username) {
|
|
||||||
await userRequest(`/auth/users/${encodeURIComponent(username)}`, { method: "DELETE" });
|
|
||||||
}
|
|
||||||
async function logout() {
|
|
||||||
const response = await fetch("/auth/logout", { method: "POST" });
|
|
||||||
if (response.status !== 404 && !response.ok) throw new Error("Logout failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
AUTH_ROLES,
|
|
||||||
AUTH_ADMIN_ROLES,
|
|
||||||
AUTH_ROLE_LABELS,
|
|
||||||
normalizeAuthRoles,
|
|
||||||
hasAuthRole,
|
|
||||||
hasAccountControls,
|
|
||||||
fetchAuthSession,
|
|
||||||
login,
|
|
||||||
listUsers,
|
|
||||||
createUser,
|
|
||||||
updateUser,
|
|
||||||
changeOwnPassword,
|
|
||||||
deleteUser,
|
|
||||||
logout
|
|
||||||
};
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import {
|
|
||||||
hostState
|
|
||||||
} from "./chunk-KL66PICH.js";
|
|
||||||
|
|
||||||
// src/plugins/active-rig.ts
|
|
||||||
function isActiveRigDecode(rigId) {
|
|
||||||
const activeRigId = hostState.lastActiveRigId;
|
|
||||||
if (!activeRigId || !rigId) return true;
|
|
||||||
return rigId === activeRigId;
|
|
||||||
}
|
|
||||||
function forActiveRig(items) {
|
|
||||||
return items.filter((item) => isActiveRigDecode(item.rig_id));
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
isActiveRigDecode,
|
|
||||||
forActiveRig
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,3 @@
|
|||||||
import {
|
|
||||||
isActiveRigDecode
|
|
||||||
} from "./chunk-S57W63QN.js";
|
|
||||||
import {
|
import {
|
||||||
hostCore
|
hostCore
|
||||||
} from "./chunk-KL66PICH.js";
|
} from "./chunk-KL66PICH.js";
|
||||||
@@ -28,7 +25,7 @@ var CW_BAR_LINE_GAP_MS = 5e3;
|
|||||||
var cwLastAppendTime = 0;
|
var cwLastAppendTime = 0;
|
||||||
var cwTonePickerRaf = null;
|
var cwTonePickerRaf = null;
|
||||||
var cwBarHistory = [];
|
var cwBarHistory = [];
|
||||||
var cwBarCurrentLines = /* @__PURE__ */ new Map();
|
var cwBarCurrentLine = null;
|
||||||
var cwBarDismissedAtMs = 0;
|
var cwBarDismissedAtMs = 0;
|
||||||
var cwAutoLocalOverride = null;
|
var cwAutoLocalOverride = null;
|
||||||
function escapeCwHtml(input) {
|
function escapeCwHtml(input) {
|
||||||
@@ -53,22 +50,20 @@ cwWindow.applyCwAutoUiFromServer = function(enabled) {
|
|||||||
if (cwAutoLocalOverride !== null) return;
|
if (cwAutoLocalOverride !== null) return;
|
||||||
applyCwAutoUi(enabled);
|
applyCwAutoUi(enabled);
|
||||||
};
|
};
|
||||||
function cwBarFlushCurrentLine(key) {
|
function cwBarFlushCurrentLine() {
|
||||||
const line = cwBarCurrentLines.get(key);
|
if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) {
|
||||||
cwBarCurrentLines.delete(key);
|
cwBarHistory.unshift(cwBarCurrentLine);
|
||||||
if (line?.text.trim()) {
|
|
||||||
cwBarHistory.unshift(line);
|
|
||||||
if (cwBarHistory.length > 50) cwBarHistory.length = 50;
|
if (cwBarHistory.length > 50) cwBarHistory.length = 50;
|
||||||
}
|
}
|
||||||
|
cwBarCurrentLine = null;
|
||||||
}
|
}
|
||||||
function updateCwBar() {
|
function updateCwBar() {
|
||||||
if (!cwBarOverlay) return;
|
if (!cwBarOverlay) return;
|
||||||
const mode = (document.getElementById("mode")?.value || "").toUpperCase();
|
const mode = (document.getElementById("mode")?.value || "").toUpperCase();
|
||||||
const isCw = mode === "CW" || mode === "CWR";
|
const isCw = mode === "CW" || mode === "CWR";
|
||||||
const cutoffMs = Date.now() - CW_BAR_WINDOW_MS;
|
const cutoffMs = Date.now() - CW_BAR_WINDOW_MS;
|
||||||
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs && isActiveRigDecode(l.rigId));
|
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs);
|
||||||
const inProgress = [...cwBarCurrentLines.values()].filter((l) => l.text && isActiveRigDecode(l.rigId)).sort((a, b) => b.tsMs - a.tsMs);
|
const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent;
|
||||||
const liveLines = [...inProgress, ...recent];
|
|
||||||
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0);
|
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0);
|
||||||
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
|
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
|
||||||
cwBarOverlay.style.display = "none";
|
cwBarOverlay.style.display = "none";
|
||||||
@@ -313,7 +308,7 @@ function resetCwHistoryView() {
|
|||||||
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
||||||
cwLastAppendTime = 0;
|
cwLastAppendTime = 0;
|
||||||
cwBarHistory = [];
|
cwBarHistory = [];
|
||||||
cwBarCurrentLines.clear();
|
cwBarCurrentLine = null;
|
||||||
updateCwBar();
|
updateCwBar();
|
||||||
drawCwTonePicker();
|
drawCwTonePicker();
|
||||||
}
|
}
|
||||||
@@ -329,9 +324,8 @@ document.getElementById("settings-clear-cw-history")?.addEventListener("click",
|
|||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
function onServerCw(evt) {
|
function onServerCw(evt) {
|
||||||
const forSelectedRig = isActiveRigDecode(evt.rig_id ?? null);
|
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
||||||
if (cwStatusEl && forSelectedRig) cwStatusEl.textContent = "Receiving";
|
if (evt.text && cwOutputEl) {
|
||||||
if (evt.text && cwOutputEl && forSelectedRig) {
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 1e4 || evt.text === "\n") {
|
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 1e4 || evt.text === "\n") {
|
||||||
const line = document.createElement("div");
|
const line = document.createElement("div");
|
||||||
@@ -352,29 +346,25 @@ function onServerCw(evt) {
|
|||||||
}
|
}
|
||||||
if (evt.text) {
|
if (evt.text) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const rigId = evt.rig_id ?? null;
|
|
||||||
const key = rigId ?? "";
|
|
||||||
if (evt.text === "\n") {
|
if (evt.text === "\n") {
|
||||||
cwBarFlushCurrentLine(key);
|
cwBarFlushCurrentLine();
|
||||||
} else {
|
} else {
|
||||||
let line = cwBarCurrentLines.get(key);
|
if (!cwBarCurrentLine || now - cwBarCurrentLine.lastMs > CW_BAR_LINE_GAP_MS) {
|
||||||
if (!line || now - line.lastMs > CW_BAR_LINE_GAP_MS) {
|
cwBarFlushCurrentLine();
|
||||||
cwBarFlushCurrentLine(key);
|
|
||||||
const ts = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
const ts = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
line = { rigId, tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now };
|
cwBarCurrentLine = { tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now };
|
||||||
cwBarCurrentLines.set(key, line);
|
|
||||||
}
|
}
|
||||||
line.text += evt.text;
|
cwBarCurrentLine.text += evt.text;
|
||||||
line.lastMs = now;
|
cwBarCurrentLine.lastMs = now;
|
||||||
if (Number.isFinite(Number(evt.wpm))) line.wpm = clampCwWpm(evt.wpm);
|
if (Number.isFinite(Number(evt.wpm))) cwBarCurrentLine.wpm = clampCwWpm(evt.wpm);
|
||||||
if (Number.isFinite(Number(evt.tone_hz))) line.tone_hz = Math.round(Number(evt.tone_hz));
|
if (Number.isFinite(Number(evt.tone_hz))) cwBarCurrentLine.tone_hz = Math.round(Number(evt.tone_hz));
|
||||||
}
|
}
|
||||||
updateCwBar();
|
updateCwBar();
|
||||||
}
|
}
|
||||||
if (cwSignalIndicator && forSelectedRig) {
|
if (cwSignalIndicator) {
|
||||||
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
|
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
|
||||||
}
|
}
|
||||||
if (forSelectedRig && (!cwAutoInput || cwAutoInput.checked)) {
|
if (!cwAutoInput || cwAutoInput.checked) {
|
||||||
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
|
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
|
||||||
cwWpmInput.value = String(clampCwWpm(evt.wpm));
|
cwWpmInput.value = String(clampCwWpm(evt.wpm));
|
||||||
}
|
}
|
||||||
@@ -399,14 +389,7 @@ cwWindow.trxPluginRuntime.registerDecoder({
|
|||||||
id: "cw",
|
id: "cw",
|
||||||
onMessage: onServerCw,
|
onMessage: onServerCw,
|
||||||
restore: restoreCwHistory,
|
restore: restoreCwHistory,
|
||||||
reset: resetCwHistoryView,
|
reset: resetCwHistoryView
|
||||||
// The copied text of a rig that is no longer on screen cannot be unpicked
|
|
||||||
// from the pane, so the switch starts the new rig's stream from empty.
|
|
||||||
rerender: () => {
|
|
||||||
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
|
||||||
cwLastAppendTime = 0;
|
|
||||||
updateCwBar();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
cwWindow.refreshCwTonePicker = function refreshCwTonePicker() {
|
cwWindow.refreshCwTonePicker = function refreshCwTonePicker() {
|
||||||
ensureCwToneCanvasResolution();
|
ensureCwToneCanvasResolution();
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
initializeFtxDecoder
|
initializeFtxDecoder
|
||||||
} from "./chunk-WU5EWJX4.js";
|
} from "./chunk-O2Y7YEVQ.js";
|
||||||
import "./chunk-S57W63QN.js";
|
|
||||||
import "./chunk-KL66PICH.js";
|
import "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
// src/plugins/ft2.ts
|
// src/plugins/ft2.ts
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
initializeFtxDecoder
|
initializeFtxDecoder
|
||||||
} from "./chunk-WU5EWJX4.js";
|
} from "./chunk-O2Y7YEVQ.js";
|
||||||
import "./chunk-S57W63QN.js";
|
|
||||||
import "./chunk-KL66PICH.js";
|
import "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
// src/plugins/ft4.ts
|
// src/plugins/ft4.ts
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import {
|
|||||||
initializeFt8FamilyBar,
|
initializeFt8FamilyBar,
|
||||||
initializeFtxDecoder,
|
initializeFtxDecoder,
|
||||||
installFtxCompatibilityHelpers
|
installFtxCompatibilityHelpers
|
||||||
} from "./chunk-WU5EWJX4.js";
|
} from "./chunk-O2Y7YEVQ.js";
|
||||||
import "./chunk-S57W63QN.js";
|
|
||||||
import "./chunk-KL66PICH.js";
|
import "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
// src/plugins/ft8.ts
|
// src/plugins/ft8.ts
|
||||||
|
|||||||
@@ -4,11 +4,7 @@ import {
|
|||||||
collapseAprsDuplicates,
|
collapseAprsDuplicates,
|
||||||
normalizeAprsPacket,
|
normalizeAprsPacket,
|
||||||
renderAprsPacketRow
|
renderAprsPacketRow
|
||||||
} from "./chunk-REYSUJQ4.js";
|
} from "./chunk-OPEIVJGD.js";
|
||||||
import {
|
|
||||||
forActiveRig,
|
|
||||||
isActiveRigDecode
|
|
||||||
} from "./chunk-S57W63QN.js";
|
|
||||||
import {
|
import {
|
||||||
hostCore,
|
hostCore,
|
||||||
hostState
|
hostState
|
||||||
@@ -71,26 +67,21 @@ function hfAprsFilterMatch(pkt) {
|
|||||||
].filter(Boolean).join(" ").toUpperCase();
|
].filter(Boolean).join(" ").toUpperCase();
|
||||||
return haystack.includes(hfAprsFilterText);
|
return haystack.includes(hfAprsFilterText);
|
||||||
}
|
}
|
||||||
function hfAprsRigPackets() {
|
|
||||||
return forActiveRig(hfAprsPacketHistory);
|
|
||||||
}
|
|
||||||
function hfAprsVisiblePackets() {
|
function hfAprsVisiblePackets() {
|
||||||
const rigPackets = hfAprsRigPackets();
|
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory;
|
||||||
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(rigPackets) : rigPackets;
|
|
||||||
return packets.filter(hfAprsFilterMatch);
|
return packets.filter(hfAprsFilterMatch);
|
||||||
}
|
}
|
||||||
var collapseHfAprsDuplicates = collapseAprsDuplicates;
|
var collapseHfAprsDuplicates = collapseAprsDuplicates;
|
||||||
function updateHfAprsSummary() {
|
function updateHfAprsSummary() {
|
||||||
const rigPackets = hfAprsRigPackets();
|
|
||||||
const visible = hfAprsVisiblePackets();
|
const visible = hfAprsVisiblePackets();
|
||||||
if (hfAprsTotalCountEl) {
|
if (hfAprsTotalCountEl) {
|
||||||
hfAprsTotalCountEl.textContent = `${rigPackets.length} total`;
|
hfAprsTotalCountEl.textContent = `${hfAprsPacketHistory.length} total`;
|
||||||
}
|
}
|
||||||
if (hfAprsVisibleCountEl) {
|
if (hfAprsVisibleCountEl) {
|
||||||
hfAprsVisibleCountEl.textContent = `${visible.length} shown`;
|
hfAprsVisibleCountEl.textContent = `${visible.length} shown`;
|
||||||
}
|
}
|
||||||
if (hfAprsLatestSeenEl) {
|
if (hfAprsLatestSeenEl) {
|
||||||
const latest = rigPackets[0];
|
const latest = hfAprsPacketHistory[0];
|
||||||
if (!latest) {
|
if (!latest) {
|
||||||
hfAprsLatestSeenEl.textContent = "No packets yet";
|
hfAprsLatestSeenEl.textContent = "No packets yet";
|
||||||
} else {
|
} else {
|
||||||
@@ -149,32 +140,17 @@ function resetHfAprsHistoryView() {
|
|||||||
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
|
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
|
||||||
hfAprsPacketHistory = [];
|
hfAprsPacketHistory = [];
|
||||||
renderHfAprsHistory();
|
renderHfAprsHistory();
|
||||||
hfAprsWindow.clearMapMarkersByType?.("hf_aprs");
|
|
||||||
}
|
}
|
||||||
function pruneHfAprsHistoryView() {
|
function pruneHfAprsHistoryView() {
|
||||||
pruneHfAprsPacketHistory();
|
pruneHfAprsPacketHistory();
|
||||||
renderHfAprsHistory();
|
renderHfAprsHistory();
|
||||||
}
|
}
|
||||||
function plotHfAprsPacket(pkt) {
|
|
||||||
if (pkt.lat == null || pkt.lon == null || !hfAprsWindow.aprsMapAddStation) return;
|
|
||||||
hfAprsWindow.aprsMapAddStation(
|
|
||||||
pkt.srcCall ?? "",
|
|
||||||
pkt.lat,
|
|
||||||
pkt.lon,
|
|
||||||
pkt.info ?? "",
|
|
||||||
pkt.symbolTable,
|
|
||||||
pkt.symbolCode,
|
|
||||||
pkt,
|
|
||||||
"hf_aprs"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
function addHfAprsPacket(pkt) {
|
function addHfAprsPacket(pkt) {
|
||||||
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
||||||
pkt._tsMs = tsMs;
|
pkt._tsMs = tsMs;
|
||||||
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
hfAprsPacketHistory.unshift(pkt);
|
hfAprsPacketHistory.unshift(pkt);
|
||||||
pruneHfAprsPacketHistory();
|
pruneHfAprsPacketHistory();
|
||||||
plotHfAprsPacket(pkt);
|
|
||||||
scheduleHfAprsHistoryRender();
|
scheduleHfAprsHistoryRender();
|
||||||
}
|
}
|
||||||
function normalizeServerHfAprsPacket(pkt) {
|
function normalizeServerHfAprsPacket(pkt) {
|
||||||
@@ -182,14 +158,13 @@ function normalizeServerHfAprsPacket(pkt) {
|
|||||||
}
|
}
|
||||||
function onServerHfAprsBatch(packets) {
|
function onServerHfAprsBatch(packets) {
|
||||||
if (!Array.isArray(packets) || packets.length === 0) return;
|
if (!Array.isArray(packets) || packets.length === 0) return;
|
||||||
|
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
|
||||||
const normalized = [];
|
const normalized = [];
|
||||||
for (const pkt of packets) {
|
for (const pkt of packets) {
|
||||||
const next = normalizeServerHfAprsPacket(pkt);
|
const next = normalizeServerHfAprsPacket(pkt);
|
||||||
if (hfAprsStatus && isActiveRigDecode(next.rig_id)) hfAprsStatus.textContent = "Receiving";
|
|
||||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
next._tsMs = tsMs;
|
next._tsMs = tsMs;
|
||||||
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
plotHfAprsPacket(next);
|
|
||||||
normalized.push(next);
|
normalized.push(next);
|
||||||
}
|
}
|
||||||
normalized.reverse();
|
normalized.reverse();
|
||||||
@@ -252,9 +227,8 @@ if (hfAprsFilterInput) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
function onServerHfAprs(pkt) {
|
function onServerHfAprs(pkt) {
|
||||||
const packet = normalizeServerHfAprsPacket(pkt);
|
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
|
||||||
if (hfAprsStatus && isActiveRigDecode(packet.rig_id)) hfAprsStatus.textContent = "Receiving";
|
addHfAprsPacket(normalizeServerHfAprsPacket(pkt));
|
||||||
addHfAprsPacket(packet);
|
|
||||||
}
|
}
|
||||||
renderHfAprsHistory();
|
renderHfAprsHistory();
|
||||||
window.trxPluginRuntime.registerDecoder({
|
window.trxPluginRuntime.registerDecoder({
|
||||||
@@ -263,10 +237,5 @@ window.trxPluginRuntime.registerDecoder({
|
|||||||
onBatch: onServerHfAprsBatch,
|
onBatch: onServerHfAprsBatch,
|
||||||
restore: onServerHfAprsBatch,
|
restore: onServerHfAprsBatch,
|
||||||
reset: resetHfAprsHistoryView,
|
reset: resetHfAprsHistoryView,
|
||||||
prune: pruneHfAprsHistoryView,
|
prune: pruneHfAprsHistoryView
|
||||||
rerender: renderHfAprsHistory,
|
|
||||||
// Oldest first, so station tracks are rebuilt in the order they happened.
|
|
||||||
syncMap: () => {
|
|
||||||
for (const entry of [...hfAprsPacketHistory].reverse()) plotHfAprsPacket(entry);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,449 +0,0 @@
|
|||||||
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-REYSUJQ4.js";
|
} from "./chunk-OPEIVJGD.js";
|
||||||
|
|
||||||
// src/map-core.ts
|
// src/map-core.ts
|
||||||
function mapEl(id) {
|
function mapEl(id) {
|
||||||
@@ -60,7 +60,7 @@ var mapWindow = window;
|
|||||||
const decodeContactPaths = /* @__PURE__ */ new Map();
|
const decodeContactPaths = /* @__PURE__ */ new Map();
|
||||||
let selectedMapQsoKey = null;
|
let selectedMapQsoKey = null;
|
||||||
const mapMarkers = /* @__PURE__ */ new Set();
|
const mapMarkers = /* @__PURE__ */ new Set();
|
||||||
const DEFAULT_MAP_SOURCE_FILTER = { ais: true, vdes: true, aprs: true, hf_aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false };
|
const DEFAULT_MAP_SOURCE_FILTER = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false };
|
||||||
const mapFilter = { ...DEFAULT_MAP_SOURCE_FILTER };
|
const mapFilter = { ...DEFAULT_MAP_SOURCE_FILTER };
|
||||||
const MAP_FILTER_ALL_KEY = "__all";
|
const MAP_FILTER_ALL_KEY = "__all";
|
||||||
const mapLocatorFilter = { phase: "band", bands: /* @__PURE__ */ new Set() };
|
const mapLocatorFilter = { phase: "band", bands: /* @__PURE__ */ new Set() };
|
||||||
@@ -128,12 +128,6 @@ var mapWindow = window;
|
|||||||
}
|
}
|
||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
function aprsEntrySource(entry) {
|
|
||||||
return entry?.type === "hf_aprs" ? "hf_aprs" : "aprs";
|
|
||||||
}
|
|
||||||
function aprsStationKey(call, source) {
|
|
||||||
return source === "aprs" ? call : `${source}:${call}`;
|
|
||||||
}
|
|
||||||
function refreshAprsTrack(call, entry) {
|
function refreshAprsTrack(call, entry) {
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) {
|
if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) {
|
||||||
@@ -155,7 +149,7 @@ var mapWindow = window;
|
|||||||
lineJoin: "round",
|
lineJoin: "round",
|
||||||
interactive: false
|
interactive: false
|
||||||
});
|
});
|
||||||
track.__trxType = aprsEntrySource(entry);
|
track.__trxType = "aprs";
|
||||||
track._aprsCall = call;
|
track._aprsCall = call;
|
||||||
entry.track = track;
|
entry.track = track;
|
||||||
}
|
}
|
||||||
@@ -387,7 +381,6 @@ var mapWindow = window;
|
|||||||
}
|
}
|
||||||
function mapSourceLabel(type) {
|
function mapSourceLabel(type) {
|
||||||
if (type === "bookmark") return "Bookmarks";
|
if (type === "bookmark") return "Bookmarks";
|
||||||
if (type === "hf_aprs") return "HF APRS";
|
|
||||||
return String(type || "").toUpperCase();
|
return String(type || "").toUpperCase();
|
||||||
}
|
}
|
||||||
function locatorFilterColor(type) {
|
function locatorFilterColor(type) {
|
||||||
@@ -403,7 +396,6 @@ var mapWindow = window;
|
|||||||
if (type === "vdes") return "#a78bfa";
|
if (type === "vdes") return "#a78bfa";
|
||||||
if (type === "sat") return "#f59e0b";
|
if (type === "sat") return "#f59e0b";
|
||||||
if (type === "aprs") return "#00d17f";
|
if (type === "aprs") return "#00d17f";
|
||||||
if (type === "hf_aprs") return "#fb7185";
|
|
||||||
return locatorFilterColor(type);
|
return locatorFilterColor(type);
|
||||||
}
|
}
|
||||||
function bandForHz(hz) {
|
function bandForHz(hz) {
|
||||||
@@ -939,8 +931,10 @@ var mapWindow = window;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const entry of stationMarkers.values()) {
|
for (const entry of stationMarkers.values()) {
|
||||||
if (!entry?.visibleInHistoryWindow) continue;
|
if (entry?.type === "aprs" && entry?.visibleInHistoryWindow) {
|
||||||
availableSources.add(aprsEntrySource(entry));
|
availableSources.add("aprs");
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const bandMap = /* @__PURE__ */ new Map();
|
const bandMap = /* @__PURE__ */ new Map();
|
||||||
for (const entry of locatorMarkers.values()) {
|
for (const entry of locatorMarkers.values()) {
|
||||||
@@ -972,7 +966,7 @@ var mapWindow = window;
|
|||||||
for (const key of Array.from(mapLocatorFilter.bands)) {
|
for (const key of Array.from(mapLocatorFilter.bands)) {
|
||||||
if (!bandMap.has(key)) mapLocatorFilter.bands.delete(key);
|
if (!bandMap.has(key)) mapLocatorFilter.bands.delete(key);
|
||||||
}
|
}
|
||||||
const sourceItems = ["ais", "vdes", "aprs", "hf_aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"].filter((key) => availableSources.has(key)).map((key) => ({
|
const sourceItems = ["ais", "vdes", "aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"].filter((key) => availableSources.has(key)).map((key) => ({
|
||||||
key,
|
key,
|
||||||
label: mapSourceLabel(key),
|
label: mapSourceLabel(key),
|
||||||
color: mapSourceColor(key),
|
color: mapSourceColor(key),
|
||||||
@@ -1034,10 +1028,9 @@ var mapWindow = window;
|
|||||||
}
|
}
|
||||||
return parts.join(" ").toLowerCase();
|
return parts.join(" ").toLowerCase();
|
||||||
}
|
}
|
||||||
if (type === "aprs" || type === "hf_aprs") {
|
if (type === "aprs") {
|
||||||
const key = marker?._aprsCall ? String(marker._aprsCall) : "";
|
const call = marker?._aprsCall ? String(marker._aprsCall) : "";
|
||||||
const entry = stationMarkers.get(key);
|
const entry = stationMarkers.get(call);
|
||||||
const call = entry?.call ?? key;
|
|
||||||
const info = entry?.info ? String(entry.info) : "";
|
const info = entry?.info ? String(entry.info) : "";
|
||||||
const pktRaw = entry?.pkt?.raw ? String(entry.pkt.raw) : "";
|
const pktRaw = entry?.pkt?.raw ? String(entry.pkt.raw) : "";
|
||||||
return `${call} ${info} ${pktRaw}`.toLowerCase();
|
return `${call} ${info} ${pktRaw}`.toLowerCase();
|
||||||
@@ -1207,10 +1200,9 @@ var mapWindow = window;
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
mapWindow.clearMapMarkersByType = function(type) {
|
mapWindow.clearMapMarkersByType = function(type) {
|
||||||
if (type === "aprs" || type === "hf_aprs") {
|
if (type === "aprs") {
|
||||||
selectedAprsTrackCall = null;
|
selectedAprsTrackCall = null;
|
||||||
stationMarkers.forEach((entry, key) => {
|
stationMarkers.forEach((entry) => {
|
||||||
if (aprsEntrySource(entry) !== type) return;
|
|
||||||
if (entry && entry.marker) {
|
if (entry && entry.marker) {
|
||||||
if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
|
if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
|
||||||
mapMarkers.delete(entry.marker);
|
mapMarkers.delete(entry.marker);
|
||||||
@@ -1219,8 +1211,8 @@ var mapWindow = window;
|
|||||||
if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap);
|
if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap);
|
||||||
mapMarkers.delete(entry.track);
|
mapMarkers.delete(entry.track);
|
||||||
}
|
}
|
||||||
stationMarkers.delete(key);
|
|
||||||
});
|
});
|
||||||
|
stationMarkers.clear();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (type === "ais") {
|
if (type === "ais") {
|
||||||
@@ -1456,15 +1448,13 @@ var mapWindow = window;
|
|||||||
if (!ll) return;
|
if (!ll) return;
|
||||||
const entry = stationMarkers.get(marker._aprsCall);
|
const entry = stationMarkers.get(marker._aprsCall);
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
const source = aprsEntrySource(entry);
|
e.popup.setContent(buildAprsPopupHtml(marker._aprsCall, ll.lat, ll.lng, entry.info || "", entry.pkt));
|
||||||
const call = entry.call ?? String(marker._aprsCall);
|
|
||||||
e.popup.setContent(buildAprsPopupHtml(call, ll.lat, ll.lng, entry.info || "", entry.pkt));
|
|
||||||
refreshAprsTrack(String(marker._aprsCall), entry);
|
refreshAprsTrack(String(marker._aprsCall), entry);
|
||||||
if (entry.track && aprsMap && mapFilter[source] && !aprsMap.hasLayer(entry.track)) {
|
if (entry.track && aprsMap && mapFilter.aprs && !aprsMap.hasLayer(entry.track)) {
|
||||||
entry.track.addTo(aprsMap);
|
entry.track.addTo(aprsMap);
|
||||||
}
|
}
|
||||||
selectedAprsTrackCall = String(marker._aprsCall);
|
selectedAprsTrackCall = String(marker._aprsCall);
|
||||||
setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor(source), "aprs-radio-path", marker.__trxRigIds);
|
setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor("aprs"), "aprs-radio-path", marker.__trxRigIds);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (marker._aisMmsi) {
|
if (marker._aisMmsi) {
|
||||||
@@ -1858,33 +1848,28 @@ var mapWindow = window;
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
function _aprsAddMarkerToMap(key, entry) {
|
function _aprsAddMarkerToMap(call, entry) {
|
||||||
if (!aprsMap || entry.lat == null || entry.lon == null) return;
|
if (!aprsMap || entry.lat == null || entry.lon == null) return;
|
||||||
refreshAprsTrack(key, entry);
|
refreshAprsTrack(call, entry);
|
||||||
const source = aprsEntrySource(entry);
|
|
||||||
const call = entry.call ?? key;
|
|
||||||
const icon = aprsSymbolIcon(entry.symbolTable ?? "", entry.symbolCode ?? "");
|
const icon = aprsSymbolIcon(entry.symbolTable ?? "", entry.symbolCode ?? "");
|
||||||
const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt);
|
const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt);
|
||||||
const color = mapSourceColor(source);
|
|
||||||
const marker = icon ? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent) : L.circleMarker([entry.lat, entry.lon], {
|
const marker = icon ? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent) : L.circleMarker([entry.lat, entry.lon], {
|
||||||
radius: 6,
|
radius: 6,
|
||||||
color,
|
color: "#00d17f",
|
||||||
fillColor: color,
|
fillColor: "#00d17f",
|
||||||
fillOpacity: 0.8
|
fillOpacity: 0.8
|
||||||
}).addTo(aprsMap).bindPopup(popupContent);
|
}).addTo(aprsMap).bindPopup(popupContent);
|
||||||
marker.__trxType = source;
|
marker.__trxType = "aprs";
|
||||||
marker.__trxRigIds = entry.rigIds || /* @__PURE__ */ new Set();
|
marker.__trxRigIds = entry.rigIds || /* @__PURE__ */ new Set();
|
||||||
marker._aprsCall = key;
|
marker._aprsCall = call;
|
||||||
entry.marker = marker;
|
entry.marker = marker;
|
||||||
mapMarkers.add(marker);
|
mapMarkers.add(marker);
|
||||||
}
|
}
|
||||||
mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt, source) {
|
mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt) {
|
||||||
const nextPoint = [lat, lon];
|
const nextPoint = [lat, lon];
|
||||||
const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now();
|
const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now();
|
||||||
const msgRigId = pkt?.rig_id || T.lastActiveRigId;
|
const msgRigId = pkt?.rig_id || T.lastActiveRigId;
|
||||||
const stationSource = source === "hf_aprs" ? "hf_aprs" : "aprs";
|
const existing = stationMarkers.get(call);
|
||||||
const key = aprsStationKey(call, stationSource);
|
|
||||||
const existing = stationMarkers.get(key);
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.pkt = pkt;
|
existing.pkt = pkt;
|
||||||
existing.lat = lat;
|
existing.lat = lat;
|
||||||
@@ -1903,8 +1888,7 @@ var mapWindow = window;
|
|||||||
} else if (prevPoint) {
|
} else if (prevPoint) {
|
||||||
prevPoint.tsMs = tsMs;
|
prevPoint.tsMs = tsMs;
|
||||||
}
|
}
|
||||||
existing.call = call;
|
pruneAprsEntry(call, existing, mapHistoryCutoffMs());
|
||||||
pruneAprsEntry(key, existing, mapHistoryCutoffMs());
|
|
||||||
if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) {
|
if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) {
|
||||||
existing.marker.setLatLng([lat, lon]);
|
existing.marker.setLatLng([lat, lon]);
|
||||||
existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt));
|
existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt));
|
||||||
@@ -1915,8 +1899,7 @@ var mapWindow = window;
|
|||||||
track: null,
|
track: null,
|
||||||
trackHistory: [{ lat, lon, tsMs }],
|
trackHistory: [{ lat, lon, tsMs }],
|
||||||
trackPoints: [nextPoint],
|
trackPoints: [nextPoint],
|
||||||
type: stationSource,
|
type: "aprs",
|
||||||
call,
|
|
||||||
pkt,
|
pkt,
|
||||||
lat,
|
lat,
|
||||||
lon,
|
lon,
|
||||||
@@ -1925,9 +1908,9 @@ var mapWindow = window;
|
|||||||
symbolCode,
|
symbolCode,
|
||||||
rigIds: new Set(msgRigId ? [msgRigId] : [])
|
rigIds: new Set(msgRigId ? [msgRigId] : [])
|
||||||
};
|
};
|
||||||
stationMarkers.set(key, entry);
|
stationMarkers.set(call, entry);
|
||||||
pruneAprsEntry(key, entry, mapHistoryCutoffMs());
|
pruneAprsEntry(call, entry, mapHistoryCutoffMs());
|
||||||
if (entry.visibleInHistoryWindow) ensureAprsMarker(key, entry);
|
if (entry.visibleInHistoryWindow) ensureAprsMarker(call, entry);
|
||||||
if (aprsMap) scheduleDecodeMapMaintenance();
|
if (aprsMap) scheduleDecodeMapMaintenance();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ var satDom = {
|
|||||||
status: document.getElementById("sat-status"),
|
status: document.getElementById("sat-status"),
|
||||||
liveView: document.getElementById("sat-live-view"),
|
liveView: document.getElementById("sat-live-view"),
|
||||||
historyView: document.getElementById("sat-history-view"),
|
historyView: document.getElementById("sat-history-view"),
|
||||||
|
predictionsView: document.getElementById("sat-predictions-view"),
|
||||||
liveLatest: document.getElementById("sat-live-latest"),
|
liveLatest: document.getElementById("sat-live-latest"),
|
||||||
historyList: document.getElementById("sat-history-list"),
|
historyList: document.getElementById("sat-history-list"),
|
||||||
historyCount: document.getElementById("sat-history-count"),
|
historyCount: document.getElementById("sat-history-count"),
|
||||||
@@ -16,12 +17,29 @@ var satDom = {
|
|||||||
typeFilter: document.getElementById("sat-type-filter"),
|
typeFilter: document.getElementById("sat-type-filter"),
|
||||||
lrptState: document.getElementById("sat-lrpt-state"),
|
lrptState: document.getElementById("sat-lrpt-state"),
|
||||||
viewLiveBtn: document.getElementById("sat-view-live"),
|
viewLiveBtn: document.getElementById("sat-view-live"),
|
||||||
viewHistoryBtn: document.getElementById("sat-view-history")
|
viewHistoryBtn: document.getElementById("sat-view-history"),
|
||||||
|
viewPredBtn: document.getElementById("sat-view-predictions"),
|
||||||
|
predFilter: document.getElementById("sat-pred-filter"),
|
||||||
|
predMinEl: document.getElementById("sat-pred-min-el"),
|
||||||
|
predCategory: document.getElementById("sat-pred-category"),
|
||||||
|
predCurrentList: document.getElementById("sat-pred-current-list"),
|
||||||
|
predUpcomingList: document.getElementById("sat-pred-list"),
|
||||||
|
predCurrentSec: document.getElementById("sat-pred-current-section"),
|
||||||
|
predUpcomingSec: document.getElementById("sat-pred-upcoming-section"),
|
||||||
|
predStatus: document.getElementById("sat-pred-status")
|
||||||
};
|
};
|
||||||
var satImageHistory = [];
|
var satImageHistory = [];
|
||||||
var SAT_MAX_IMAGES = 100;
|
var SAT_MAX_IMAGES = 100;
|
||||||
|
var SAT_PRED_PAGE_SIZE = 50;
|
||||||
|
var satPredShowAll = false;
|
||||||
var satFilterText = "";
|
var satFilterText = "";
|
||||||
var satActiveView = "live";
|
var satActiveView = "live";
|
||||||
|
var satPredData = [];
|
||||||
|
var satPredFilterText = "";
|
||||||
|
var satPredMinEl = 0;
|
||||||
|
var satPredCategory = "all";
|
||||||
|
var satPredSatCount = 0;
|
||||||
|
var satPredCountdownTimer = null;
|
||||||
function scheduleSatUi(key, job) {
|
function scheduleSatUi(key, job) {
|
||||||
if (typeof satWindow.trxScheduleUiFrameJob === "function") {
|
if (typeof satWindow.trxScheduleUiFrameJob === "function") {
|
||||||
satWindow.trxScheduleUiFrameJob(key, job);
|
satWindow.trxScheduleUiFrameJob(key, job);
|
||||||
@@ -30,19 +48,37 @@ function scheduleSatUi(key, job) {
|
|||||||
job();
|
job();
|
||||||
}
|
}
|
||||||
function switchSatView(view) {
|
function switchSatView(view) {
|
||||||
|
const leavingPredictions = satActiveView === "predictions" && view !== "predictions";
|
||||||
satActiveView = view;
|
satActiveView = view;
|
||||||
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
|
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
|
||||||
if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "none";
|
if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "none";
|
||||||
|
if (satDom.predictionsView) satDom.predictionsView.style.display = view === "predictions" ? "" : "none";
|
||||||
if (satDom.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live");
|
if (satDom.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live");
|
||||||
if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history");
|
if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history");
|
||||||
if (view === "history") renderSatHistoryTable();
|
if (satDom.viewPredBtn) satDom.viewPredBtn.classList.toggle("sat-view-active", view === "predictions");
|
||||||
|
if (leavingPredictions) clearPredictionDom();
|
||||||
|
if (view === "history") {
|
||||||
|
renderSatHistoryTable();
|
||||||
|
} else if (view === "predictions") {
|
||||||
|
satPredShowAll = false;
|
||||||
|
void loadSatPredictions();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
function clearPredictionDom() {
|
||||||
|
stopCountdownTimer();
|
||||||
|
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
||||||
|
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
||||||
|
}
|
||||||
|
satWindow.clearSatPredictionDom = clearPredictionDom;
|
||||||
satDom.viewLiveBtn?.addEventListener("click", () => {
|
satDom.viewLiveBtn?.addEventListener("click", () => {
|
||||||
switchSatView("live");
|
switchSatView("live");
|
||||||
});
|
});
|
||||||
satDom.viewHistoryBtn?.addEventListener("click", () => {
|
satDom.viewHistoryBtn?.addEventListener("click", () => {
|
||||||
switchSatView("history");
|
switchSatView("history");
|
||||||
});
|
});
|
||||||
|
satDom.viewPredBtn?.addEventListener("click", () => {
|
||||||
|
switchSatView("predictions");
|
||||||
|
});
|
||||||
var lastSatLrptOn = null;
|
var lastSatLrptOn = null;
|
||||||
satWindow.updateSatLiveState = function(update) {
|
satWindow.updateSatLiveState = function(update) {
|
||||||
if (!satDom.lrptState) return;
|
if (!satDom.lrptState) return;
|
||||||
@@ -239,6 +275,200 @@ document.getElementById("settings-clear-sat-history")?.addEventListener("click",
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
|
function azToCardinal(deg) {
|
||||||
|
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
|
||||||
|
return dirs[Math.round(deg / 45) % 8] ?? "N";
|
||||||
|
}
|
||||||
|
function formatPredTime(ms) {
|
||||||
|
const d = new Date(ms);
|
||||||
|
const now = /* @__PURE__ */ new Date();
|
||||||
|
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||||
|
const day = d.getUTCDay() !== now.getUTCDay() ? `${dayNames[d.getUTCDay()] ?? ""} ` : "";
|
||||||
|
const hh = String(d.getUTCHours()).padStart(2, "0");
|
||||||
|
const mm = String(d.getUTCMinutes()).padStart(2, "0");
|
||||||
|
return `${day}${hh}:${mm}`;
|
||||||
|
}
|
||||||
|
function formatPredDuration(s) {
|
||||||
|
if (s >= 60) return `${Math.round(s / 60)} min`;
|
||||||
|
return `${s}s`;
|
||||||
|
}
|
||||||
|
function formatCountdown(ms) {
|
||||||
|
const totalSec = Math.max(0, Math.floor(ms / 1e3));
|
||||||
|
const m = Math.floor(totalSec / 60);
|
||||||
|
const s = totalSec % 60;
|
||||||
|
return `${m}:${String(s).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
function elevationClass(deg) {
|
||||||
|
if (deg >= 45) return "sat-pred-el-high";
|
||||||
|
if (deg >= 10) return "sat-pred-el-mid";
|
||||||
|
return "sat-pred-el-low";
|
||||||
|
}
|
||||||
|
function stopCountdownTimer() {
|
||||||
|
if (satPredCountdownTimer) {
|
||||||
|
clearInterval(satPredCountdownTimer);
|
||||||
|
satPredCountdownTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function startCountdownTimer(container) {
|
||||||
|
const countdownEls = container?.querySelectorAll(".sat-pred-col-countdown") ?? [];
|
||||||
|
if (countdownEls.length === 0) return;
|
||||||
|
satPredCountdownTimer = setInterval(() => {
|
||||||
|
if (satActiveView !== "predictions") {
|
||||||
|
stopCountdownTimer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = Date.now();
|
||||||
|
let anyActive = false;
|
||||||
|
for (const el of countdownEls) {
|
||||||
|
const los = Number.parseInt(el.dataset.los ?? "0", 10);
|
||||||
|
const rem = los - n;
|
||||||
|
if (rem > 0) {
|
||||||
|
el.textContent = formatCountdown(rem);
|
||||||
|
anyActive = true;
|
||||||
|
} else {
|
||||||
|
el.textContent = "0:00";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!anyActive) {
|
||||||
|
stopCountdownTimer();
|
||||||
|
renderSatPredictions(getFilteredPredictions());
|
||||||
|
}
|
||||||
|
}, 1e3);
|
||||||
|
}
|
||||||
|
function buildCurrentPassRow(pass, now) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "sat-pred-row-current";
|
||||||
|
const dir = `${azToCardinal(pass.azimuth_aos_deg)} → ${azToCardinal(pass.azimuth_los_deg)}`;
|
||||||
|
const remaining = Math.max(0, pass.los_ms - now);
|
||||||
|
row.innerHTML = [
|
||||||
|
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
||||||
|
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}°</span>`,
|
||||||
|
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
||||||
|
`<span class="sat-pred-col-time">${formatPredTime(pass.los_ms)}</span>`,
|
||||||
|
`<span class="sat-pred-col-countdown" data-los="${pass.los_ms}">${formatCountdown(remaining)}</span>`,
|
||||||
|
`<span class="sat-pred-col-dir">${dir}</span>`
|
||||||
|
].join("");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
function buildUpcomingPassRow(pass) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "sat-pred-row";
|
||||||
|
const dir = `${azToCardinal(pass.azimuth_aos_deg)} → ${azToCardinal(pass.azimuth_los_deg)}`;
|
||||||
|
row.innerHTML = [
|
||||||
|
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
||||||
|
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
||||||
|
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}°</span>`,
|
||||||
|
`<span class="sat-pred-col-dur">${formatPredDuration(pass.duration_s)}</span>`,
|
||||||
|
`<span class="sat-pred-col-dir">${dir}</span>`
|
||||||
|
].join("");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
function getFilteredPredictions() {
|
||||||
|
let items = satPredData;
|
||||||
|
if (satPredCategory !== "all") items = items.filter((p) => p.category === satPredCategory);
|
||||||
|
if (satPredMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= satPredMinEl);
|
||||||
|
if (satPredFilterText) items = items.filter((p) => p.satellite.toUpperCase().includes(satPredFilterText));
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
function applyPredFilters() {
|
||||||
|
renderSatPredictions(getFilteredPredictions());
|
||||||
|
}
|
||||||
|
var satPredictionFilter = satDom.predFilter;
|
||||||
|
satPredictionFilter?.addEventListener("input", () => {
|
||||||
|
satPredFilterText = satPredictionFilter.value.trim().toUpperCase();
|
||||||
|
applyPredFilters();
|
||||||
|
});
|
||||||
|
var satPredictionMinElevation = satDom.predMinEl;
|
||||||
|
satPredictionMinElevation?.addEventListener("change", () => {
|
||||||
|
satPredMinEl = Number.parseInt(satPredictionMinElevation.value, 10) || 0;
|
||||||
|
applyPredFilters();
|
||||||
|
});
|
||||||
|
var satPredictionCategory = satDom.predCategory;
|
||||||
|
satPredictionCategory?.addEventListener("change", () => {
|
||||||
|
satPredCategory = satPredictionCategory.value;
|
||||||
|
applyPredFilters();
|
||||||
|
});
|
||||||
|
function renderSatPredictions(passes, error) {
|
||||||
|
stopCountdownTimer();
|
||||||
|
if (error) {
|
||||||
|
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
||||||
|
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
||||||
|
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
|
||||||
|
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
|
||||||
|
if (satDom.predStatus) satDom.predStatus.textContent = error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(passes) || passes.length === 0) {
|
||||||
|
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
||||||
|
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
||||||
|
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
|
||||||
|
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
|
||||||
|
if (satDom.predStatus) satDom.predStatus.textContent = "No passes found in the next 24 hours.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
const current = passes.filter((p) => p.aos_ms <= now && p.los_ms > now);
|
||||||
|
const upcoming = passes.filter((p) => p.aos_ms > now);
|
||||||
|
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = current.length > 0 ? "" : "none";
|
||||||
|
if (satDom.predCurrentList) {
|
||||||
|
if (current.length === 0) {
|
||||||
|
satDom.predCurrentList.innerHTML = "";
|
||||||
|
} else {
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
for (const pass of current) frag.appendChild(buildCurrentPassRow(pass, now));
|
||||||
|
satDom.predCurrentList.replaceChildren(frag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const upcomingLimit = satPredShowAll ? upcoming.length : SAT_PRED_PAGE_SIZE;
|
||||||
|
const visibleUpcoming = upcoming.slice(0, upcomingLimit);
|
||||||
|
const hiddenCount = upcoming.length - visibleUpcoming.length;
|
||||||
|
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = upcoming.length > 0 ? "" : "none";
|
||||||
|
if (satDom.predUpcomingList) {
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
for (const pass of visibleUpcoming) frag.appendChild(buildUpcomingPassRow(pass));
|
||||||
|
if (hiddenCount > 0) {
|
||||||
|
const moreRow = document.createElement("div");
|
||||||
|
moreRow.className = "sat-pred-row";
|
||||||
|
moreRow.style.cursor = "pointer";
|
||||||
|
moreRow.style.textAlign = "center";
|
||||||
|
moreRow.innerHTML = `<span style="grid-column:1/-1;color:var(--accent);font-size:0.82rem;">Show ${hiddenCount} more passes…</span>`;
|
||||||
|
moreRow.addEventListener("click", () => {
|
||||||
|
satPredShowAll = true;
|
||||||
|
renderSatPredictions(getFilteredPredictions());
|
||||||
|
});
|
||||||
|
frag.appendChild(moreRow);
|
||||||
|
}
|
||||||
|
satDom.predUpcomingList.replaceChildren(frag);
|
||||||
|
}
|
||||||
|
if (satDom.predStatus) {
|
||||||
|
let text = `${current.length} active · ${upcoming.length} upcoming · times in UTC`;
|
||||||
|
if (satPredSatCount > 0) text += ` · ${satPredSatCount} satellites tracked`;
|
||||||
|
satDom.predStatus.textContent = text;
|
||||||
|
}
|
||||||
|
if (current.length > 0 && satActiveView === "predictions") {
|
||||||
|
startCountdownTimer(satDom.predCurrentList);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function loadSatPredictions() {
|
||||||
|
if (satDom.predStatus) satDom.predStatus.textContent = "Loading predictions…";
|
||||||
|
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
||||||
|
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/sat_passes");
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
|
const data = await resp.json();
|
||||||
|
satPredSatCount = data.satellite_count || 0;
|
||||||
|
if (data.error) {
|
||||||
|
satPredData = [];
|
||||||
|
renderSatPredictions([], data.error);
|
||||||
|
} else {
|
||||||
|
satPredData = data.passes || [];
|
||||||
|
renderSatPredictions(getFilteredPredictions());
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
renderSatPredictions([], `Failed to load predictions: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
satWindow.satShowOnMap = function(south, west, north, east) {
|
satWindow.satShowOnMap = function(south, west, north, east) {
|
||||||
if (typeof satWindow.enableMapSourceFilter === "function") {
|
if (typeof satWindow.enableMapSourceFilter === "function") {
|
||||||
satWindow.enableMapSourceFilter("sat");
|
satWindow.enableMapSourceFilter("sat");
|
||||||
|
|||||||
-228
@@ -1,228 +0,0 @@
|
|||||||
// src/plugins/satellite-predictions.ts
|
|
||||||
var predWindow = window;
|
|
||||||
var dom = {
|
|
||||||
page: document.getElementById("tab-satellites"),
|
|
||||||
filter: document.getElementById("sat-pred-filter"),
|
|
||||||
minElevation: document.getElementById("sat-pred-min-el"),
|
|
||||||
category: document.getElementById("sat-pred-category"),
|
|
||||||
currentList: document.getElementById("sat-pred-current-list"),
|
|
||||||
upcomingList: document.getElementById("sat-pred-list"),
|
|
||||||
currentSection: document.getElementById("sat-pred-current-section"),
|
|
||||||
upcomingSection: document.getElementById("sat-pred-upcoming-section"),
|
|
||||||
status: document.getElementById("sat-pred-status")
|
|
||||||
};
|
|
||||||
var PAGE_SIZE = 50;
|
|
||||||
var predShowAll = false;
|
|
||||||
var predData = [];
|
|
||||||
var predFilterText = "";
|
|
||||||
var predMinEl = 0;
|
|
||||||
var predCategory = "all";
|
|
||||||
var predSatCount = 0;
|
|
||||||
var predCountdownTimer = null;
|
|
||||||
function isPageVisible() {
|
|
||||||
return !!dom.page && dom.page.style.display !== "none";
|
|
||||||
}
|
|
||||||
function azToCardinal(deg) {
|
|
||||||
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
|
|
||||||
return dirs[Math.round(deg / 45) % 8] ?? "N";
|
|
||||||
}
|
|
||||||
function formatPredTime(ms) {
|
|
||||||
const d = new Date(ms);
|
|
||||||
const now = /* @__PURE__ */ new Date();
|
|
||||||
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
||||||
const day = d.getUTCDay() !== now.getUTCDay() ? `${dayNames[d.getUTCDay()] ?? ""} ` : "";
|
|
||||||
const hh = String(d.getUTCHours()).padStart(2, "0");
|
|
||||||
const mm = String(d.getUTCMinutes()).padStart(2, "0");
|
|
||||||
return `${day}${hh}:${mm}`;
|
|
||||||
}
|
|
||||||
function formatPredDuration(s) {
|
|
||||||
if (s >= 60) return `${Math.round(s / 60)} min`;
|
|
||||||
return `${s}s`;
|
|
||||||
}
|
|
||||||
function formatCountdown(ms) {
|
|
||||||
const totalSec = Math.max(0, Math.floor(ms / 1e3));
|
|
||||||
const m = Math.floor(totalSec / 60);
|
|
||||||
const s = totalSec % 60;
|
|
||||||
return `${m}:${String(s).padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
function elevationClass(deg) {
|
|
||||||
if (deg >= 45) return "sat-pred-el-high";
|
|
||||||
if (deg >= 10) return "sat-pred-el-mid";
|
|
||||||
return "sat-pred-el-low";
|
|
||||||
}
|
|
||||||
function stopCountdownTimer() {
|
|
||||||
if (predCountdownTimer) {
|
|
||||||
clearInterval(predCountdownTimer);
|
|
||||||
predCountdownTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function startCountdownTimer(container) {
|
|
||||||
const countdownEls = container?.querySelectorAll(".sat-pred-col-countdown") ?? [];
|
|
||||||
if (countdownEls.length === 0) return;
|
|
||||||
predCountdownTimer = setInterval(() => {
|
|
||||||
if (!isPageVisible()) {
|
|
||||||
stopCountdownTimer();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const n = Date.now();
|
|
||||||
let anyActive = false;
|
|
||||||
for (const el of countdownEls) {
|
|
||||||
const los = Number.parseInt(el.dataset.los ?? "0", 10);
|
|
||||||
const rem = los - n;
|
|
||||||
if (rem > 0) {
|
|
||||||
el.textContent = formatCountdown(rem);
|
|
||||||
anyActive = true;
|
|
||||||
} else {
|
|
||||||
el.textContent = "0:00";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!anyActive) {
|
|
||||||
stopCountdownTimer();
|
|
||||||
render(filtered());
|
|
||||||
}
|
|
||||||
}, 1e3);
|
|
||||||
}
|
|
||||||
function buildCurrentPassRow(pass, now) {
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = "sat-pred-row-current";
|
|
||||||
const dir = `${azToCardinal(pass.azimuth_aos_deg)} → ${azToCardinal(pass.azimuth_los_deg)}`;
|
|
||||||
const remaining = Math.max(0, pass.los_ms - now);
|
|
||||||
row.innerHTML = [
|
|
||||||
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
|
||||||
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}°</span>`,
|
|
||||||
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
|
||||||
`<span class="sat-pred-col-time">${formatPredTime(pass.los_ms)}</span>`,
|
|
||||||
`<span class="sat-pred-col-countdown" data-los="${pass.los_ms}">${formatCountdown(remaining)}</span>`,
|
|
||||||
`<span class="sat-pred-col-dir">${dir}</span>`
|
|
||||||
].join("");
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
function buildUpcomingPassRow(pass) {
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = "sat-pred-row";
|
|
||||||
const dir = `${azToCardinal(pass.azimuth_aos_deg)} → ${azToCardinal(pass.azimuth_los_deg)}`;
|
|
||||||
row.innerHTML = [
|
|
||||||
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
|
||||||
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
|
||||||
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}°</span>`,
|
|
||||||
`<span class="sat-pred-col-dur">${formatPredDuration(pass.duration_s)}</span>`,
|
|
||||||
`<span class="sat-pred-col-dir">${dir}</span>`
|
|
||||||
].join("");
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
function filtered() {
|
|
||||||
let items = predData;
|
|
||||||
if (predCategory !== "all") items = items.filter((p) => p.category === predCategory);
|
|
||||||
if (predMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= predMinEl);
|
|
||||||
if (predFilterText) items = items.filter((p) => p.satellite.toUpperCase().includes(predFilterText));
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
function applyFilters() {
|
|
||||||
render(filtered());
|
|
||||||
}
|
|
||||||
var filterInput = dom.filter;
|
|
||||||
filterInput?.addEventListener("input", () => {
|
|
||||||
predFilterText = filterInput.value.trim().toUpperCase();
|
|
||||||
applyFilters();
|
|
||||||
});
|
|
||||||
var minElevationSelect = dom.minElevation;
|
|
||||||
minElevationSelect?.addEventListener("change", () => {
|
|
||||||
predMinEl = Number.parseInt(minElevationSelect.value, 10) || 0;
|
|
||||||
applyFilters();
|
|
||||||
});
|
|
||||||
var categorySelect = dom.category;
|
|
||||||
categorySelect?.addEventListener("change", () => {
|
|
||||||
predCategory = categorySelect.value;
|
|
||||||
applyFilters();
|
|
||||||
});
|
|
||||||
function render(passes, error) {
|
|
||||||
stopCountdownTimer();
|
|
||||||
if (error) {
|
|
||||||
if (dom.currentList) dom.currentList.innerHTML = "";
|
|
||||||
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
|
|
||||||
if (dom.currentSection) dom.currentSection.style.display = "none";
|
|
||||||
if (dom.upcomingSection) dom.upcomingSection.style.display = "none";
|
|
||||||
if (dom.status) dom.status.textContent = error;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!Array.isArray(passes) || passes.length === 0) {
|
|
||||||
if (dom.currentList) dom.currentList.innerHTML = "";
|
|
||||||
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
|
|
||||||
if (dom.currentSection) dom.currentSection.style.display = "none";
|
|
||||||
if (dom.upcomingSection) dom.upcomingSection.style.display = "none";
|
|
||||||
if (dom.status) dom.status.textContent = "No passes found in the next 24 hours.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const now = Date.now();
|
|
||||||
const current = passes.filter((p) => p.aos_ms <= now && p.los_ms > now);
|
|
||||||
const upcoming = passes.filter((p) => p.aos_ms > now);
|
|
||||||
if (dom.currentSection) dom.currentSection.style.display = current.length > 0 ? "" : "none";
|
|
||||||
if (dom.currentList) {
|
|
||||||
if (current.length === 0) {
|
|
||||||
dom.currentList.innerHTML = "";
|
|
||||||
} else {
|
|
||||||
const frag = document.createDocumentFragment();
|
|
||||||
for (const pass of current) frag.appendChild(buildCurrentPassRow(pass, now));
|
|
||||||
dom.currentList.replaceChildren(frag);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const upcomingLimit = predShowAll ? upcoming.length : PAGE_SIZE;
|
|
||||||
const visibleUpcoming = upcoming.slice(0, upcomingLimit);
|
|
||||||
const hiddenCount = upcoming.length - visibleUpcoming.length;
|
|
||||||
if (dom.upcomingSection) dom.upcomingSection.style.display = upcoming.length > 0 ? "" : "none";
|
|
||||||
if (dom.upcomingList) {
|
|
||||||
const frag = document.createDocumentFragment();
|
|
||||||
for (const pass of visibleUpcoming) frag.appendChild(buildUpcomingPassRow(pass));
|
|
||||||
if (hiddenCount > 0) {
|
|
||||||
const moreRow = document.createElement("div");
|
|
||||||
moreRow.className = "sat-pred-row";
|
|
||||||
moreRow.style.cursor = "pointer";
|
|
||||||
moreRow.style.textAlign = "center";
|
|
||||||
moreRow.innerHTML = `<span style="grid-column:1/-1;color:var(--accent);font-size:0.82rem;">Show ${hiddenCount} more passes…</span>`;
|
|
||||||
moreRow.addEventListener("click", () => {
|
|
||||||
predShowAll = true;
|
|
||||||
render(filtered());
|
|
||||||
});
|
|
||||||
frag.appendChild(moreRow);
|
|
||||||
}
|
|
||||||
dom.upcomingList.replaceChildren(frag);
|
|
||||||
}
|
|
||||||
if (dom.status) {
|
|
||||||
let text = `${current.length} active · ${upcoming.length} upcoming · times in UTC`;
|
|
||||||
if (predSatCount > 0) text += ` · ${predSatCount} satellites tracked`;
|
|
||||||
dom.status.textContent = text;
|
|
||||||
}
|
|
||||||
if (current.length > 0 && isPageVisible()) {
|
|
||||||
startCountdownTimer(dom.currentList);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async function load() {
|
|
||||||
if (dom.status) dom.status.textContent = "Loading predictions…";
|
|
||||||
if (dom.currentList) dom.currentList.innerHTML = "";
|
|
||||||
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
|
|
||||||
try {
|
|
||||||
const resp = await fetch("/sat_passes");
|
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
||||||
const data = await resp.json();
|
|
||||||
predSatCount = data.satellite_count || 0;
|
|
||||||
if (data.error) {
|
|
||||||
predData = [];
|
|
||||||
render([], data.error);
|
|
||||||
} else {
|
|
||||||
predData = data.passes || [];
|
|
||||||
render(filtered());
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
render([], `Failed to load predictions: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function clearPredictionDom() {
|
|
||||||
stopCountdownTimer();
|
|
||||||
if (dom.currentList) dom.currentList.innerHTML = "";
|
|
||||||
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
|
|
||||||
}
|
|
||||||
predWindow.clearSatPredictionDom = clearPredictionDom;
|
|
||||||
predWindow.refreshSatPredictions = function() {
|
|
||||||
predShowAll = false;
|
|
||||||
void load();
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,3 @@
|
|||||||
import {
|
|
||||||
hasAuthRole
|
|
||||||
} from "./chunk-PISLBJGN.js";
|
|
||||||
import {
|
import {
|
||||||
hostState
|
hostState
|
||||||
} from "./chunk-KL66PICH.js";
|
} from "./chunk-KL66PICH.js";
|
||||||
@@ -18,7 +15,7 @@ function schedulerOptionalEl(id) {
|
|||||||
}
|
}
|
||||||
(function() {
|
(function() {
|
||||||
"use strict";
|
"use strict";
|
||||||
let schedulerRoles = [];
|
let schedulerRole = null;
|
||||||
let currentRigId = null;
|
let currentRigId = null;
|
||||||
let currentConfig = null;
|
let currentConfig = null;
|
||||||
let currentSchedulerStatus = null;
|
let currentSchedulerStatus = null;
|
||||||
@@ -28,8 +25,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, roles) {
|
function initScheduler(rigId, role) {
|
||||||
schedulerRoles = roles;
|
schedulerRole = role;
|
||||||
currentRigId = rigId || null;
|
currentRigId = rigId || null;
|
||||||
if (currentRigId) loadScheduler();
|
if (currentRigId) loadScheduler();
|
||||||
startStatusPolling();
|
startStatusPolling();
|
||||||
@@ -275,7 +272,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 = hasAuthRole(schedulerRoles, "control") && !!currentRigId && !schedulerStepPending && state.activeEntries.length > 1;
|
const enabled = schedulerRole === "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";
|
||||||
@@ -357,7 +354,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 = hasAuthRole(schedulerRoles, "control");
|
const isControl = schedulerRole === "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");
|
||||||
@@ -1223,8 +1220,8 @@ function schedulerOptionalEl(id) {
|
|||||||
markDirty: markSchedulerDirty
|
markDirty: markSchedulerDirty
|
||||||
};
|
};
|
||||||
schedulerWindow.trx.modules.scheduler = schedulerService;
|
schedulerWindow.trx.modules.scheduler = schedulerService;
|
||||||
if (!hostState.authEnabled || hostState.authRoles.length > 0) {
|
if (hostState.authRole != null) {
|
||||||
initScheduler(hostState.lastActiveRigId, hostState.authRoles);
|
initScheduler(hostState.lastActiveRigId, hostState.authRole);
|
||||||
wireSchedulerEvents();
|
wireSchedulerEvents();
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
import {
|
|
||||||
forActiveRig,
|
|
||||||
isActiveRigDecode
|
|
||||||
} from "./chunk-S57W63QN.js";
|
|
||||||
import {
|
import {
|
||||||
hostCore
|
hostCore
|
||||||
} from "./chunk-KL66PICH.js";
|
} from "./chunk-KL66PICH.js";
|
||||||
@@ -115,7 +111,6 @@ function paintRow(line, rgb) {
|
|||||||
liveRows = Math.max(liveRows, line + 1);
|
liveRows = Math.max(liveRows, line + 1);
|
||||||
}
|
}
|
||||||
function onProgress(msg) {
|
function onProgress(msg) {
|
||||||
if (!isActiveRigDecode(msg.rig_id)) return;
|
|
||||||
if (msg.state) {
|
if (msg.state) {
|
||||||
if (sstvDom.status) sstvDom.status.textContent = msg.state;
|
if (sstvDom.status) sstvDom.status.textContent = msg.state;
|
||||||
beginPicture(msg.mode || "", msg.width || 0, msg.height || 0);
|
beginPicture(msg.mode || "", msg.width || 0, msg.height || 0);
|
||||||
@@ -136,20 +131,15 @@ function onImage(msg) {
|
|||||||
sstvHistory.push(image);
|
sstvHistory.push(image);
|
||||||
if (sstvHistory.length > SSTV_MAX_IMAGES) sstvHistory.shift();
|
if (sstvHistory.length > SSTV_MAX_IMAGES) sstvHistory.shift();
|
||||||
pruneHistory();
|
pruneHistory();
|
||||||
if (!isActiveRigDecode(image.rig_id)) return;
|
|
||||||
if (sstvDom.status) {
|
if (sstvDom.status) {
|
||||||
sstvDom.status.textContent = image.complete ? `Received ${image.mode ?? "picture"}` : `Partial ${image.mode ?? "picture"} — ${image.lines ?? 0} lines`;
|
sstvDom.status.textContent = image.complete ? `Received ${image.mode ?? "picture"}` : `Partial ${image.mode ?? "picture"} — ${image.lines ?? 0} lines`;
|
||||||
}
|
}
|
||||||
scheduleUi("sstv-latest", renderLatestCard);
|
scheduleUi("sstv-latest", renderLatestCard);
|
||||||
if (activeView === "history") scheduleUi("sstv-history", renderHistoryTable);
|
if (activeView === "history") scheduleUi("sstv-history", renderHistoryTable);
|
||||||
}
|
}
|
||||||
function sstvRigImages() {
|
|
||||||
return forActiveRig(sstvHistory);
|
|
||||||
}
|
|
||||||
function renderLatestCard() {
|
function renderLatestCard() {
|
||||||
if (!sstvDom.liveLatest) return;
|
if (!sstvDom.liveLatest) return;
|
||||||
const rigImages = sstvRigImages();
|
const latest = sstvHistory[sstvHistory.length - 1];
|
||||||
const latest = rigImages[rigImages.length - 1];
|
|
||||||
if (!latest) {
|
if (!latest) {
|
||||||
sstvDom.liveLatest.innerHTML = "";
|
sstvDom.liveLatest.innerHTML = "";
|
||||||
return;
|
return;
|
||||||
@@ -170,9 +160,8 @@ function renderLatestCard() {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
function filteredHistory() {
|
function filteredHistory() {
|
||||||
const rigImages = sstvRigImages();
|
|
||||||
const text = filterText.trim().toLowerCase();
|
const text = filterText.trim().toLowerCase();
|
||||||
const matching = text ? rigImages.filter((image) => (image.mode ?? "").toLowerCase().includes(text)) : rigImages;
|
const matching = text ? sstvHistory.filter((image) => (image.mode ?? "").toLowerCase().includes(text)) : sstvHistory.slice();
|
||||||
const newestFirst = (sstvDom.sortSelect?.value ?? "newest") === "newest";
|
const newestFirst = (sstvDom.sortSelect?.value ?? "newest") === "newest";
|
||||||
matching.sort((a, b) => (newestFirst ? 1 : -1) * ((b._tsMs ?? 0) - (a._tsMs ?? 0)));
|
matching.sort((a, b) => (newestFirst ? 1 : -1) * ((b._tsMs ?? 0) - (a._tsMs ?? 0)));
|
||||||
return matching;
|
return matching;
|
||||||
@@ -252,11 +241,7 @@ sstvWindow.trxPluginRuntime.registerDecoder({
|
|||||||
onMessage: onImage,
|
onMessage: onImage,
|
||||||
restore: restoreHistory,
|
restore: restoreHistory,
|
||||||
prune: renderHistoryTable,
|
prune: renderHistoryTable,
|
||||||
reset: resetHistoryView,
|
reset: resetHistoryView
|
||||||
rerender: () => {
|
|
||||||
renderLatestCard();
|
|
||||||
renderHistoryTable();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
sstvWindow.trxPluginRuntime.registerDecoder({
|
sstvWindow.trxPluginRuntime.registerDecoder({
|
||||||
id: "sstv_progress",
|
id: "sstv_progress",
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
import {
|
|
||||||
forActiveRig,
|
|
||||||
isActiveRigDecode
|
|
||||||
} from "./chunk-S57W63QN.js";
|
|
||||||
import {
|
import {
|
||||||
hostCore
|
hostCore
|
||||||
} from "./chunk-KL66PICH.js";
|
} from "./chunk-KL66PICH.js";
|
||||||
@@ -64,21 +60,17 @@ function vdesHexPreview(rawBytes) {
|
|||||||
if (!Array.isArray(rawBytes) || rawBytes.length === 0) return "--";
|
if (!Array.isArray(rawBytes) || rawBytes.length === 0) return "--";
|
||||||
return rawBytes.slice(0, 20).map((value) => value.toString(16).padStart(2, "0")).join(" ").toUpperCase();
|
return rawBytes.slice(0, 20).map((value) => value.toString(16).padStart(2, "0")).join(" ").toUpperCase();
|
||||||
}
|
}
|
||||||
function vdesRigMessages() {
|
|
||||||
return forActiveRig(vdesMessageHistory);
|
|
||||||
}
|
|
||||||
function updateVdesSummary() {
|
function updateVdesSummary() {
|
||||||
pruneVdesMessageHistory();
|
pruneVdesMessageHistory();
|
||||||
if (vdesChannelSummaryEl) {
|
if (vdesChannelSummaryEl) {
|
||||||
vdesChannelSummaryEl.textContent = currentVdesCenterText();
|
vdesChannelSummaryEl.textContent = currentVdesCenterText();
|
||||||
}
|
}
|
||||||
const rigMessages = vdesRigMessages();
|
|
||||||
if (vdesFrameCountEl) {
|
if (vdesFrameCountEl) {
|
||||||
const count = rigMessages.length;
|
const count = vdesMessageHistory.length;
|
||||||
vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`;
|
vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`;
|
||||||
}
|
}
|
||||||
if (vdesLatestSeenEl) {
|
if (vdesLatestSeenEl) {
|
||||||
const latest = rigMessages[0];
|
const latest = vdesMessageHistory[0];
|
||||||
vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet";
|
vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,7 +137,7 @@ function updateVdesBar() {
|
|||||||
updateVdesSummary();
|
updateVdesSummary();
|
||||||
const isVdes = (document.getElementById("mode")?.value || "").toUpperCase() === "VDES";
|
const isVdes = (document.getElementById("mode")?.value || "").toUpperCase() === "VDES";
|
||||||
const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS;
|
const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS;
|
||||||
const messages = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id)).slice(0, 6);
|
const messages = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs).slice(0, 6);
|
||||||
if (!isVdes || messages.length === 0) {
|
if (!isVdes || messages.length === 0) {
|
||||||
vdesBarOverlay.style.display = "none";
|
vdesBarOverlay.style.display = "none";
|
||||||
vdesBarOverlay.innerHTML = "";
|
vdesBarOverlay.innerHTML = "";
|
||||||
@@ -190,7 +182,7 @@ function renderVdesHistory() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
for (const message of vdesRigMessages()) {
|
for (const message of vdesMessageHistory) {
|
||||||
fragment.appendChild(renderVdesRow(message));
|
fragment.appendChild(renderVdesRow(message));
|
||||||
}
|
}
|
||||||
vdesMessagesEl.replaceChildren(fragment);
|
vdesMessagesEl.replaceChildren(fragment);
|
||||||
@@ -217,10 +209,10 @@ function normalizeServerVdesMessage(msg) {
|
|||||||
}
|
}
|
||||||
function onServerVdesBatch(messages) {
|
function onServerVdesBatch(messages) {
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
|
if (vdesStatus) vdesStatus.textContent = "Receiving";
|
||||||
const normalized = [];
|
const normalized = [];
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const next = normalizeServerVdesMessage(msg);
|
const next = normalizeServerVdesMessage(msg);
|
||||||
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
|
|
||||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
next._tsMs = tsMs;
|
next._tsMs = tsMs;
|
||||||
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
@@ -259,8 +251,8 @@ function plotVdesMessage(msg) {
|
|||||||
vdesWindow.vdesMapAddPoint(msg);
|
vdesWindow.vdesMapAddPoint(msg);
|
||||||
}
|
}
|
||||||
function onServerVdes(msg) {
|
function onServerVdes(msg) {
|
||||||
|
if (vdesStatus) vdesStatus.textContent = "Receiving";
|
||||||
const next = normalizeServerVdesMessage(msg);
|
const next = normalizeServerVdesMessage(msg);
|
||||||
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
|
|
||||||
addVdesMessage(next);
|
addVdesMessage(next);
|
||||||
plotVdesMessage(next);
|
plotVdesMessage(next);
|
||||||
}
|
}
|
||||||
@@ -277,10 +269,6 @@ window.trxPluginRuntime.registerDecoder({
|
|||||||
restore: onServerVdesBatch,
|
restore: onServerVdesBatch,
|
||||||
reset: resetVdesHistoryView,
|
reset: resetVdesHistoryView,
|
||||||
prune: pruneVdesHistoryView,
|
prune: pruneVdesHistoryView,
|
||||||
rerender: () => {
|
|
||||||
updateVdesBar();
|
|
||||||
renderVdesHistory();
|
|
||||||
},
|
|
||||||
// Oldest first, so tracks are rebuilt in the order they happened.
|
// Oldest first, so tracks are rebuilt in the order they happened.
|
||||||
syncMap: () => {
|
syncMap: () => {
|
||||||
for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry);
|
for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry);
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
import {
|
|
||||||
forActiveRig,
|
|
||||||
isActiveRigDecode
|
|
||||||
} from "./chunk-S57W63QN.js";
|
|
||||||
import {
|
import {
|
||||||
hostCore
|
hostCore
|
||||||
} from "./chunk-KL66PICH.js";
|
} from "./chunk-KL66PICH.js";
|
||||||
@@ -106,17 +102,13 @@ function paintLine(lineBytes) {
|
|||||||
wefaxLiveCtx.putImageData(imgData, 0, y);
|
wefaxLiveCtx.putImageData(imgData, 0, y);
|
||||||
wefaxLiveLineCount++;
|
wefaxLiveLineCount++;
|
||||||
}
|
}
|
||||||
function wefaxRigImages() {
|
|
||||||
return forActiveRig(wefaxImageHistory);
|
|
||||||
}
|
|
||||||
function renderWefaxLatestCard() {
|
function renderWefaxLatestCard() {
|
||||||
if (!wefaxDom.liveLatest) return;
|
if (!wefaxDom.liveLatest) return;
|
||||||
const rigImages = wefaxRigImages();
|
if (wefaxImageHistory.length === 0) {
|
||||||
if (rigImages.length === 0) {
|
|
||||||
wefaxDom.liveLatest.innerHTML = '<div style="color:var(--text-muted);font-size:0.82rem;">No images decoded yet. Enable the decoder and tune to a WEFAX station.</div>';
|
wefaxDom.liveLatest.innerHTML = '<div style="color:var(--text-muted);font-size:0.82rem;">No images decoded yet. Enable the decoder and tune to a WEFAX station.</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const img = rigImages[0];
|
const img = wefaxImageHistory[0];
|
||||||
if (!img) return;
|
if (!img) return;
|
||||||
const ts = img._ts || "--";
|
const ts = img._ts || "--";
|
||||||
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : "";
|
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : "";
|
||||||
@@ -137,7 +129,7 @@ function renderWefaxLatestCard() {
|
|||||||
wefaxDom.liveLatest.innerHTML = html;
|
wefaxDom.liveLatest.innerHTML = html;
|
||||||
}
|
}
|
||||||
function getWefaxFilteredHistory() {
|
function getWefaxFilteredHistory() {
|
||||||
let items = wefaxRigImages();
|
let items = wefaxImageHistory;
|
||||||
if (wefaxFilterText) {
|
if (wefaxFilterText) {
|
||||||
items = items.filter(function(i) {
|
items = items.filter(function(i) {
|
||||||
const haystack = [
|
const haystack = [
|
||||||
@@ -181,7 +173,7 @@ function renderWefaxHistoryTable() {
|
|||||||
}
|
}
|
||||||
wefaxDom.historyList.replaceChildren(fragment);
|
wefaxDom.historyList.replaceChildren(fragment);
|
||||||
if (wefaxDom.historyCount) {
|
if (wefaxDom.historyCount) {
|
||||||
const total = wefaxRigImages().length;
|
const total = wefaxImageHistory.length;
|
||||||
const shown = items.length;
|
const shown = items.length;
|
||||||
wefaxDom.historyCount.textContent = total === 0 ? "No images yet" : shown === total ? `${String(total)} image${total === 1 ? "" : "s"}` : `${String(shown)} of ${String(total)} images`;
|
wefaxDom.historyCount.textContent = total === 0 ? "No images yet" : shown === total ? `${String(total)} image${total === 1 ? "" : "s"}` : `${String(shown)} of ${String(total)} images`;
|
||||||
}
|
}
|
||||||
@@ -216,7 +208,6 @@ function addWefaxImage(msg) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
function onServerWefaxProgress(msg) {
|
function onServerWefaxProgress(msg) {
|
||||||
if (!isActiveRigDecode(msg.rig_id)) return;
|
|
||||||
if (msg.state && !msg.line_data) {
|
if (msg.state && !msg.line_data) {
|
||||||
if (wefaxDom.status) {
|
if (wefaxDom.status) {
|
||||||
wefaxDom.status.textContent = msg.state;
|
wefaxDom.status.textContent = msg.state;
|
||||||
@@ -243,7 +234,6 @@ function onServerWefaxProgress(msg) {
|
|||||||
}
|
}
|
||||||
function onServerWefax(msg) {
|
function onServerWefax(msg) {
|
||||||
addWefaxImage(msg);
|
addWefaxImage(msg);
|
||||||
if (!isActiveRigDecode(msg.rig_id)) return;
|
|
||||||
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = "none";
|
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = "none";
|
||||||
if (wefaxDom.status) {
|
if (wefaxDom.status) {
|
||||||
wefaxDom.status.textContent = `Complete — ${String(msg.line_count ?? 0)} lines`;
|
wefaxDom.status.textContent = `Complete — ${String(msg.line_count ?? 0)} lines`;
|
||||||
@@ -338,11 +328,7 @@ wefaxWindow.trxPluginRuntime.registerDecoder({
|
|||||||
onMessage: onServerWefax,
|
onMessage: onServerWefax,
|
||||||
restore: restoreWefaxHistory,
|
restore: restoreWefaxHistory,
|
||||||
prune: pruneWefaxHistoryView,
|
prune: pruneWefaxHistoryView,
|
||||||
reset: resetWefaxHistoryView,
|
reset: resetWefaxHistoryView
|
||||||
rerender: () => {
|
|
||||||
renderWefaxLatestCard();
|
|
||||||
renderWefaxHistoryTable();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
wefaxWindow.trxPluginRuntime.registerDecoder({
|
wefaxWindow.trxPluginRuntime.registerDecoder({
|
||||||
id: "wefax_progress",
|
id: "wefax_progress",
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
import {
|
|
||||||
forActiveRig,
|
|
||||||
isActiveRigDecode
|
|
||||||
} from "./chunk-S57W63QN.js";
|
|
||||||
import {
|
import {
|
||||||
hostCore
|
hostCore
|
||||||
} from "./chunk-KL66PICH.js";
|
} from "./chunk-KL66PICH.js";
|
||||||
@@ -68,10 +64,10 @@ function renderWsprRow(msg) {
|
|||||||
function renderWsprHistory() {
|
function renderWsprHistory() {
|
||||||
pruneWsprMessageHistory();
|
pruneWsprMessageHistory();
|
||||||
if (!wsprMessagesEl) return;
|
if (!wsprMessagesEl) return;
|
||||||
const rigMessages = forActiveRig(wsprMessageHistory);
|
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
for (const message of rigMessages) {
|
for (let i = 0; i < wsprMessageHistory.length; i += 1) {
|
||||||
fragment.appendChild(renderWsprRow(message));
|
const message = wsprMessageHistory[i];
|
||||||
|
if (message) fragment.appendChild(renderWsprRow(message));
|
||||||
}
|
}
|
||||||
wsprMessagesEl.replaceChildren(fragment);
|
wsprMessagesEl.replaceChildren(fragment);
|
||||||
}
|
}
|
||||||
@@ -94,8 +90,6 @@ function normalizeServerWsprMessage(msg) {
|
|||||||
station,
|
station,
|
||||||
rfHz,
|
rfHz,
|
||||||
history: {
|
history: {
|
||||||
rig_id: msg.rig_id ?? null,
|
|
||||||
_rfHz: rfHz,
|
|
||||||
receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null,
|
receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null,
|
||||||
ts_ms: msg.ts_ms,
|
ts_ms: msg.ts_ms,
|
||||||
snr_db: msg.snr_db,
|
snr_db: msg.snr_db,
|
||||||
@@ -107,11 +101,16 @@ function normalizeServerWsprMessage(msg) {
|
|||||||
}
|
}
|
||||||
function onServerWsprBatch(messages) {
|
function onServerWsprBatch(messages) {
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
|
if (wsprStatus) wsprStatus.textContent = "Receiving";
|
||||||
const normalized = [];
|
const normalized = [];
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const next = normalizeServerWsprMessage(msg);
|
const next = normalizeServerWsprMessage(msg);
|
||||||
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
|
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
|
||||||
plotWsprLocator(msg);
|
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
||||||
|
...msg,
|
||||||
|
...next.rfHz === null ? {} : { freq_hz: next.rfHz }
|
||||||
|
});
|
||||||
|
}
|
||||||
next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
||||||
normalized.push(next.history);
|
normalized.push(next.history);
|
||||||
}
|
}
|
||||||
@@ -251,19 +250,15 @@ document.getElementById("settings-clear-wspr-history")?.addEventListener("click"
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
function plotWsprLocator(msg) {
|
|
||||||
const next = normalizeServerWsprMessage(msg);
|
|
||||||
if (next.grids.length === 0 || !wsprWindow.mapAddLocator) return;
|
|
||||||
const rfHz = finiteNumber(msg._rfHz) ?? next.rfHz;
|
|
||||||
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
|
||||||
...msg,
|
|
||||||
...rfHz === null ? {} : { freq_hz: rfHz }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function onServerWspr(msg) {
|
function onServerWspr(msg) {
|
||||||
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
|
if (wsprStatus) wsprStatus.textContent = "Receiving";
|
||||||
const next = normalizeServerWsprMessage(msg);
|
const next = normalizeServerWsprMessage(msg);
|
||||||
plotWsprLocator(msg);
|
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
|
||||||
|
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
||||||
|
...msg,
|
||||||
|
...next.rfHz === null ? {} : { freq_hz: next.rfHz }
|
||||||
|
});
|
||||||
|
}
|
||||||
addWsprMessage(next.history);
|
addWsprMessage(next.history);
|
||||||
}
|
}
|
||||||
wsprWindow.trxPluginRuntime.registerDecoder({
|
wsprWindow.trxPluginRuntime.registerDecoder({
|
||||||
@@ -272,10 +267,5 @@ wsprWindow.trxPluginRuntime.registerDecoder({
|
|||||||
onBatch: onServerWsprBatch,
|
onBatch: onServerWsprBatch,
|
||||||
restore: onServerWsprBatch,
|
restore: onServerWsprBatch,
|
||||||
prune: pruneWsprHistoryView,
|
prune: pruneWsprHistoryView,
|
||||||
reset: resetWsprHistoryView,
|
reset: resetWsprHistoryView
|
||||||
rerender: renderWsprHistory,
|
|
||||||
// Oldest first, so the map builds the grids up in the order they were heard.
|
|
||||||
syncMap: () => {
|
|
||||||
for (const message of [...wsprMessageHistory].reverse()) plotWsprLocator(message);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,10 +22,8 @@ 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-stats" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 14h12"/><rect x="3" y="8" width="2" height="6" rx="0.4" fill="currentColor" stroke="none" opacity="0.6"/><rect x="7" y="5" width="2" height="9" rx="0.4" fill="currentColor" stroke="none" opacity="0.75"/><rect x="11" y="2" width="2" height="12" rx="0.4" fill="currentColor" stroke="none" opacity="0.9"/></symbol>
|
<symbol id="icon-stats" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 14h12"/><rect x="3" y="8" width="2" height="6" rx="0.4" fill="currentColor" stroke="none" opacity="0.6"/><rect x="7" y="5" width="2" height="9" rx="0.4" fill="currentColor" stroke="none" opacity="0.75"/><rect x="11" y="2" width="2" height="12" rx="0.4" fill="currentColor" stroke="none" opacity="0.9"/></symbol>
|
||||||
<symbol id="icon-record" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6"/><circle cx="8" cy="8" r="2.5" fill="currentColor" stroke="none"/></symbol>
|
<symbol id="icon-record" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6"/><circle cx="8" cy="8" r="2.5" fill="currentColor" stroke="none"/></symbol>
|
||||||
<symbol id="icon-settings" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M9.8 3.1a2.6 2.6 0 0 0-2.2 3.9L3.4 11.2a1.2 1.2 0 1 0 1.7 1.7l4.2-4.2a2.6 2.6 0 0 0 3.9-2.2l-1.8.6-1.2-1.2z"/><path d="M10.2 5.8 12 4"/></symbol>
|
<symbol id="icon-settings" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M9.8 3.1a2.6 2.6 0 0 0-2.2 3.9L3.4 11.2a1.2 1.2 0 1 0 1.7 1.7l4.2-4.2a2.6 2.6 0 0 0 3.9-2.2l-1.8.6-1.2-1.2z"/><path d="M10.2 5.8 12 4"/></symbol>
|
||||||
@@ -54,10 +52,6 @@ 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>
|
||||||
@@ -69,10 +63,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-map"/></svg>
|
<svg class="tab-icon" aria-hidden="true"><use href="#icon-map"/></svg>
|
||||||
<span class="tab-label">Map</span>
|
<span class="tab-label">Map</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="tab" data-tab="satellites">
|
|
||||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-satellite"/></svg>
|
|
||||||
<span class="tab-label">Satellites</span>
|
|
||||||
</button>
|
|
||||||
<button class="tab" data-tab="statistics">
|
<button class="tab" data-tab="statistics">
|
||||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-stats"/></svg>
|
<svg class="tab-icon" aria-hidden="true"><use href="#icon-stats"/></svg>
|
||||||
<span class="tab-label">Statistics</span>
|
<span class="tab-label">Statistics</span>
|
||||||
@@ -123,13 +113,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">Sign in to continue</div>
|
<div class="auth-gate-sub">Enter passphrase to continue</div>
|
||||||
</div>
|
</div>
|
||||||
<form id="auth-form" class="auth-form">
|
<form id="auth-form" class="auth-form">
|
||||||
<input type="text" id="auth-username" class="auth-input" placeholder="Username" autocomplete="username" required />
|
<input type="password" id="auth-passphrase" class="auth-input" placeholder="Passphrase" autocomplete="off" />
|
||||||
<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>
|
||||||
@@ -385,23 +375,6 @@ 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">
|
||||||
@@ -502,192 +475,6 @@ 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">
|
||||||
@@ -738,14 +525,6 @@ 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>
|
||||||
@@ -1139,6 +918,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<div class="sat-view-bar">
|
<div class="sat-view-bar">
|
||||||
<button id="sat-view-live" class="sat-view-btn sat-view-active" type="button">Live</button>
|
<button id="sat-view-live" class="sat-view-btn sat-view-active" type="button">Live</button>
|
||||||
<button id="sat-view-history" class="sat-view-btn" type="button">History</button>
|
<button id="sat-view-history" class="sat-view-btn" type="button">History</button>
|
||||||
|
<button id="sat-view-predictions" class="sat-view-btn" type="button">Predictions</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Live view -->
|
<!-- Live view -->
|
||||||
<div id="sat-live-view">
|
<div id="sat-live-view">
|
||||||
@@ -1180,6 +960,50 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<div id="sat-history-list"></div>
|
<div id="sat-history-list"></div>
|
||||||
<small id="sat-history-count" style="color:var(--text-muted);font-size:0.75rem;">No images yet</small>
|
<small id="sat-history-count" style="color:var(--text-muted);font-size:0.75rem;">No images yet</small>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Predictions view -->
|
||||||
|
<div id="sat-predictions-view" style="display:none;">
|
||||||
|
<div class="ft8-controls">
|
||||||
|
<input id="sat-pred-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. ISS, NOAA, Meteor)" />
|
||||||
|
<select id="sat-pred-category" class="sat-sort-select">
|
||||||
|
<option value="all">All</option>
|
||||||
|
<option value="weather">Weather</option>
|
||||||
|
<option value="amateur">Ham Radio</option>
|
||||||
|
<option value="other">Other</option>
|
||||||
|
</select>
|
||||||
|
<select id="sat-pred-min-el" class="sat-sort-select">
|
||||||
|
<option value="0">All passes</option>
|
||||||
|
<option value="10">Min 10°</option>
|
||||||
|
<option value="20">Min 20°</option>
|
||||||
|
<option value="45">Min 45°</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<!-- Current passes -->
|
||||||
|
<div id="sat-pred-current-section">
|
||||||
|
<div class="sat-pred-section-title">Currently receivable</div>
|
||||||
|
<div class="sat-pred-header sat-pred-header-current">
|
||||||
|
<span class="sat-pred-col-sat">Satellite</span>
|
||||||
|
<span class="sat-pred-col-el">Max El</span>
|
||||||
|
<span class="sat-pred-col-time">AOS Start</span>
|
||||||
|
<span class="sat-pred-col-time">AOS End</span>
|
||||||
|
<span class="sat-pred-col-countdown">Time left</span>
|
||||||
|
<span class="sat-pred-col-dir">Direction</span>
|
||||||
|
</div>
|
||||||
|
<div id="sat-pred-current-list"></div>
|
||||||
|
</div>
|
||||||
|
<!-- Upcoming passes -->
|
||||||
|
<div id="sat-pred-upcoming-section">
|
||||||
|
<div class="sat-pred-section-title">Upcoming passes</div>
|
||||||
|
<div class="sat-pred-header">
|
||||||
|
<span class="sat-pred-col-time">AOS (UTC)</span>
|
||||||
|
<span class="sat-pred-col-sat">Satellite</span>
|
||||||
|
<span class="sat-pred-col-el">Max El</span>
|
||||||
|
<span class="sat-pred-col-dur">Duration</span>
|
||||||
|
<span class="sat-pred-col-dir">Direction</span>
|
||||||
|
</div>
|
||||||
|
<div id="sat-pred-list"></div>
|
||||||
|
</div>
|
||||||
|
<small id="sat-pred-status" style="color:var(--text-muted);font-size:0.75rem;">Loading predictions…</small>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="subtab-wefax" class="sub-tab-panel" style="display:none;">
|
<div id="subtab-wefax" class="sub-tab-panel" style="display:none;">
|
||||||
<div class="ft8-controls">
|
<div class="ft8-controls">
|
||||||
@@ -1293,51 +1117,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div id="tab-satellites" class="tab-panel" style="display:none;">
|
|
||||||
<h2 class="section-heading">Satellites</h2>
|
|
||||||
<div class="ft8-controls">
|
|
||||||
<input id="sat-pred-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. ISS, NOAA, Meteor)" />
|
|
||||||
<select id="sat-pred-category" class="sat-sort-select">
|
|
||||||
<option value="all">All</option>
|
|
||||||
<option value="weather">Weather</option>
|
|
||||||
<option value="amateur">Ham Radio</option>
|
|
||||||
<option value="other">Other</option>
|
|
||||||
</select>
|
|
||||||
<select id="sat-pred-min-el" class="sat-sort-select">
|
|
||||||
<option value="0">All passes</option>
|
|
||||||
<option value="10">Min 10°</option>
|
|
||||||
<option value="20">Min 20°</option>
|
|
||||||
<option value="45">Min 45°</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<!-- Current passes -->
|
|
||||||
<div id="sat-pred-current-section">
|
|
||||||
<div class="sat-pred-section-title">Currently receivable</div>
|
|
||||||
<div class="sat-pred-header sat-pred-header-current">
|
|
||||||
<span class="sat-pred-col-sat">Satellite</span>
|
|
||||||
<span class="sat-pred-col-el">Max El</span>
|
|
||||||
<span class="sat-pred-col-time">AOS Start</span>
|
|
||||||
<span class="sat-pred-col-time">AOS End</span>
|
|
||||||
<span class="sat-pred-col-countdown">Time left</span>
|
|
||||||
<span class="sat-pred-col-dir">Direction</span>
|
|
||||||
</div>
|
|
||||||
<div id="sat-pred-current-list"></div>
|
|
||||||
</div>
|
|
||||||
<!-- Upcoming passes -->
|
|
||||||
<div id="sat-pred-upcoming-section">
|
|
||||||
<div class="sat-pred-section-title">Upcoming passes</div>
|
|
||||||
<div class="sat-pred-header">
|
|
||||||
<span class="sat-pred-col-time">AOS (UTC)</span>
|
|
||||||
<span class="sat-pred-col-sat">Satellite</span>
|
|
||||||
<span class="sat-pred-col-el">Max El</span>
|
|
||||||
<span class="sat-pred-col-dur">Duration</span>
|
|
||||||
<span class="sat-pred-col-dir">Direction</span>
|
|
||||||
</div>
|
|
||||||
<div id="sat-pred-list"></div>
|
|
||||||
</div>
|
|
||||||
<small id="sat-pred-status" style="color:var(--text-muted);font-size:0.75rem;">Loading predictions…</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="tab-statistics" class="tab-panel" style="display:none;">
|
<div id="tab-statistics" class="tab-panel" style="display:none;">
|
||||||
<h2 class="section-heading">Statistics</h2>
|
<h2 class="section-heading">Statistics</h2>
|
||||||
<template id="tmpl-statistics">
|
<template id="tmpl-statistics">
|
||||||
@@ -1486,8 +1265,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<button class="sub-tab" data-subtab="settings-background-decode">Background Decode</button>
|
<button class="sub-tab" data-subtab="settings-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">
|
||||||
@@ -1685,31 +1462,30 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<div id="subtab-settings-background-decode" class="sub-tab-panel" style="display:none;">
|
<div id="subtab-settings-background-decode" class="sub-tab-panel" style="display:none;">
|
||||||
<div id="background-decode-panel" class="sch-panel">
|
<div id="background-decode-panel" class="sch-panel">
|
||||||
<div class="sch-toast" id="background-decode-toast" role="alert" aria-live="polite" style="display:none;"></div>
|
<div class="sch-toast" id="background-decode-toast" role="alert" aria-live="polite" style="display:none;"></div>
|
||||||
|
<!-- Now Playing status card (moved to top) -->
|
||||||
|
<div class="now-playing-card">
|
||||||
|
<div id="background-decode-status-card" class="sch-status-card">No background decode bookmarks configured.</div>
|
||||||
|
</div>
|
||||||
<div class="sch-section">
|
<div class="sch-section">
|
||||||
<div class="sch-section-title">Background Decode</div>
|
<div class="sch-section-title">Configuration</div>
|
||||||
<p class="bgd-intro">
|
<div class="sch-row">
|
||||||
Decodes saved bookmarks on hidden channels while you work another band.
|
<label class="sch-label bgd-toggle-wrap">Background decode
|
||||||
Needs an SDR rig, and a bookmark is only decoded while it falls inside
|
<span class="bgd-toggle-row">
|
||||||
the span the rig is tuned across.
|
<input type="checkbox" id="background-decode-enabled" />
|
||||||
</p>
|
<span>Enable hidden background decoder channels</span>
|
||||||
<div class="bgd-controls">
|
</span>
|
||||||
<label class="bgd-enable">
|
|
||||||
<input type="checkbox" id="background-decode-enabled" />
|
|
||||||
<span>Enabled</span>
|
|
||||||
</label>
|
</label>
|
||||||
<span id="bgd-span-summary" class="bgd-span" aria-live="polite"></span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="bgd-list-toolbar">
|
<div class="sch-row" style="flex-direction:column;gap:0.5rem;">
|
||||||
<input type="text" id="bgd-bookmark-filter" class="bgd-checklist-filter"
|
<label class="sch-label" style="min-width:100%;">Bookmarks
|
||||||
placeholder="Filter bookmarks…" aria-label="Filter bookmarks" />
|
<input type="text" id="bgd-bookmark-filter" class="bgd-checklist-filter" placeholder="Filter bookmarks..." />
|
||||||
|
</label>
|
||||||
<div class="bgd-select-actions">
|
<div class="bgd-select-actions">
|
||||||
<button type="button" id="bgd-select-all-btn" class="bgd-select-btn" aria-label="Select every bookmark">All</button>
|
<button type="button" id="bgd-select-all-btn" class="bgd-select-btn" aria-label="Select all bookmarks">Select All</button>
|
||||||
<button type="button" id="bgd-deselect-all-btn" class="bgd-select-btn" aria-label="Select no bookmarks">None</button>
|
<button type="button" id="bgd-deselect-all-btn" class="bgd-select-btn" aria-label="Deselect all bookmarks">Deselect All</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="bgd-bookmark-checklist" class="bgd-checklist"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="bgd-bookmark-checklist" class="bgd-checklist" role="group"
|
|
||||||
aria-label="Bookmarks decoded in the background"></div>
|
|
||||||
<div id="bgd-selection-summary" class="bgd-summary" aria-live="polite"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sch-actions">
|
<div class="sch-actions">
|
||||||
@@ -1777,34 +1553,6 @@ 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,7 +196,8 @@ 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);
|
||||||
@@ -212,14 +213,16 @@ 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 {
|
||||||
@@ -3813,12 +3816,11 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
|||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
body[data-operator-layout="broadcast"] #wfm-controls-col { flex-basis: 100%; }
|
body[data-operator-layout="broadcast"] #wfm-controls-col { flex-basis: 100%; }
|
||||||
}
|
}
|
||||||
/* One navigation model at every width. Satellites, Statistics, Recorder,
|
/* One navigation model at every width. Statistics, Recorder, Settings and
|
||||||
* Settings and About are occasional destinations: they live behind More rather
|
* About are occasional destinations: they live behind More rather than
|
||||||
* than competing with the operating tabs for the row and then scrolling out of
|
* competing with the operating tabs for the row and then scrolling out of
|
||||||
* reach. The mobile layout already grouped them this way; the desktop strip
|
* reach. The mobile layout already grouped them this way; the desktop strip
|
||||||
* now matches it, which is why the tab strip no longer needs to scroll. */
|
* now matches it, which is why the tab strip no longer needs to scroll. */
|
||||||
.tab-bar-nav .tab[data-tab="satellites"],
|
|
||||||
.tab-bar-nav .tab[data-tab="statistics"],
|
.tab-bar-nav .tab[data-tab="statistics"],
|
||||||
.tab-bar-nav .tab[data-tab="recorder"],
|
.tab-bar-nav .tab[data-tab="recorder"],
|
||||||
.tab-bar-nav .tab[data-tab="settings"],
|
.tab-bar-nav .tab[data-tab="settings"],
|
||||||
@@ -4028,9 +4030,8 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
|||||||
box-shadow: inset 0 1px 0 color-mix(in srgb, #ffffff 8%, transparent);
|
box-shadow: inset 0 1px 0 color-mix(in srgb, #ffffff 8%, transparent);
|
||||||
}
|
}
|
||||||
.tab-icon, .tab-more-icon { display: block; }
|
.tab-icon, .tab-more-icon { display: block; }
|
||||||
.tab[data-tab="satellites"], .tab[data-tab="statistics"],
|
.tab[data-tab="statistics"], .tab[data-tab="recorder"],
|
||||||
.tab[data-tab="recorder"], .tab[data-tab="settings"],
|
.tab[data-tab="settings"], .tab[data-tab="about"] { display: none; }
|
||||||
.tab[data-tab="about"] { display: none; }
|
|
||||||
.mobile-more-btn { display: flex; }
|
.mobile-more-btn { display: flex; }
|
||||||
.mobile-more-menu {
|
.mobile-more-menu {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -5586,6 +5587,18 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
|||||||
#sch-sat-form-cancel:hover {
|
#sch-sat-form-cancel:hover {
|
||||||
background: var(--border);
|
background: var(--border);
|
||||||
}
|
}
|
||||||
|
.bgd-toggle-wrap {
|
||||||
|
min-width: 18rem;
|
||||||
|
flex: 1 1 20rem;
|
||||||
|
}
|
||||||
|
.bgd-toggle-row {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
min-height: var(--control-height);
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
.bgd-add-row {
|
.bgd-add-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.55rem;
|
gap: 0.55rem;
|
||||||
@@ -5609,6 +5622,48 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
}
|
}
|
||||||
|
.bgd-status-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
.bgd-status-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 0.55rem;
|
||||||
|
background: color-mix(in srgb, var(--card-bg) 74%, transparent);
|
||||||
|
}
|
||||||
|
.bgd-status-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.bgd-status-meta {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
.bgd-status-state {
|
||||||
|
align-self: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent-green);
|
||||||
|
}
|
||||||
|
.bgd-status-state[data-state="out_of_span"],
|
||||||
|
.bgd-status-state[data-state="waiting_for_spectrum"],
|
||||||
|
.bgd-status-state[data-state="waiting_for_user"],
|
||||||
|
.bgd-status-state[data-state="scheduler_has_control"],
|
||||||
|
.bgd-status-state[data-state="inactive"],
|
||||||
|
.bgd-status-state[data-state="handled_by_scheduler"],
|
||||||
|
.bgd-status-state[data-state="handled_by_virtual_channel"] {
|
||||||
|
color: var(--accent-yellow);
|
||||||
|
}
|
||||||
|
.bgd-status-state[data-state="missing_bookmark"],
|
||||||
|
.bgd-status-state[data-state="no_supported_decoders"] {
|
||||||
|
color: var(--accent-red);
|
||||||
|
}
|
||||||
/* ── "Now Playing" status card (top of scheduler & bgd panels) ──── */
|
/* ── "Now Playing" status card (top of scheduler & bgd panels) ──── */
|
||||||
.now-playing-card {
|
.now-playing-card {
|
||||||
border-left: 3px solid var(--accent-green);
|
border-left: 3px solid var(--accent-green);
|
||||||
@@ -5824,65 +5879,18 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
.bgd-intro {
|
|
||||||
margin: 0 0 0.9rem;
|
|
||||||
max-width: 62ch;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
/* The switch and what the rig can hear, on one line: the second is the reason
|
|
||||||
a selected bookmark may not be decoding, so it belongs beside the first. */
|
|
||||||
.bgd-controls {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-bottom: 0.9rem;
|
|
||||||
}
|
|
||||||
.bgd-enable {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.bgd-span {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.82rem;
|
|
||||||
}
|
|
||||||
.bgd-span[data-tone="warn"] {
|
|
||||||
color: var(--text-warn, #b7791f);
|
|
||||||
}
|
|
||||||
/* The filter and the bulk buttons sit on the list they act on, and stay on it
|
|
||||||
when the panel narrows. */
|
|
||||||
.bgd-list-toolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
.bgd-list-toolbar .bgd-checklist-filter {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.bgd-checklist {
|
.bgd-checklist {
|
||||||
max-height: 22rem;
|
max-height: 16rem;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
border: 1px solid var(--border-light);
|
border: 1px solid var(--border-light);
|
||||||
border-radius: 0.4rem;
|
border-radius: 0.4rem;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
/* One row per bookmark: pick it here, and read what it is doing here too. */
|
|
||||||
.bgd-checklist-row {
|
.bgd-checklist-row {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: auto minmax(6rem, 1fr) auto auto;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.55rem;
|
gap: 0.55rem;
|
||||||
padding: 0.5rem 0.65rem;
|
padding: 0.45rem 0.65rem;
|
||||||
border-bottom: 1px solid var(--border-light);
|
border-bottom: 1px solid var(--border-light);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
@@ -5894,83 +5902,28 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
|||||||
.bgd-checklist-row:hover {
|
.bgd-checklist-row:hover {
|
||||||
background: color-mix(in srgb, var(--card-bg) 60%, transparent);
|
background: color-mix(in srgb, var(--card-bg) 60%, transparent);
|
||||||
}
|
}
|
||||||
.bgd-checklist-row.is-selected {
|
|
||||||
background: color-mix(in srgb, var(--accent-green) 8%, transparent);
|
|
||||||
}
|
|
||||||
.bgd-checklist-row input[type="checkbox"] {
|
.bgd-checklist-row input[type="checkbox"] {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.bgd-checklist-name {
|
.bgd-checklist-name {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
.bgd-checklist-meta {
|
.bgd-checklist-meta {
|
||||||
display: inline-flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 0.4rem;
|
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
|
margin-left: auto;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
.bgd-checklist-mode {
|
|
||||||
color: var(--text-muted);
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
/* A dot and a word. The dot carries the state, so the word can stay a word. */
|
|
||||||
.bgd-state {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 0.35rem;
|
|
||||||
min-width: 9.5rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.76rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.bgd-state-dot {
|
|
||||||
width: 0.5rem;
|
|
||||||
height: 0.5rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: currentcolor;
|
|
||||||
opacity: 0.55;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.bgd-state[data-state="active"] {
|
|
||||||
color: var(--accent-green);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.bgd-state[data-state="active"] .bgd-state-dot {
|
|
||||||
opacity: 1;
|
|
||||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent-green) 22%, transparent);
|
|
||||||
}
|
|
||||||
.bgd-state[data-state="missing_bookmark"],
|
|
||||||
.bgd-state[data-state="no_supported_decoders"] {
|
|
||||||
color: var(--color-error, #c0392b);
|
|
||||||
}
|
|
||||||
.bgd-state[data-state="unselected"] {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
.bgd-checklist-empty {
|
.bgd-checklist-empty {
|
||||||
padding: 1rem 0.75rem;
|
padding: 0.75rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
line-height: 1.5;
|
|
||||||
}
|
}
|
||||||
/* What the selection adds up to, under the list it describes. */
|
/* ── Select All / Deselect All buttons ────────────────────────────── */
|
||||||
.bgd-summary {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
/* ── Select all / none buttons ─────────────────────────────────────── */
|
|
||||||
.bgd-select-actions {
|
.bgd-select-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-shrink: 0;
|
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
.bgd-select-btn {
|
.bgd-select-btn {
|
||||||
@@ -5988,6 +5941,15 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
|||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
/* ── SVG State Dot Badges ─────────────────────────────────────────── */
|
||||||
|
.bgd-state-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 4px;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.channel-scheduler-controls {
|
.channel-scheduler-controls {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -6028,7 +5990,8 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
|||||||
.bm-page-summary {
|
.bm-page-summary {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.bgd-add-row {
|
.bgd-add-row,
|
||||||
|
.bgd-status-row {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
@@ -6038,17 +6001,8 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
|||||||
.interleave-ring-label {
|
.interleave-ring-label {
|
||||||
max-width: 8rem;
|
max-width: 8rem;
|
||||||
}
|
}
|
||||||
/* Two lines rather than four columns: the name and its state stay legible
|
.bgd-checklist-meta {
|
||||||
when there is no room for the frequency beside them. */
|
margin-left: 0;
|
||||||
.bgd-checklist-row {
|
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
|
||||||
row-gap: 0.2rem;
|
|
||||||
}
|
|
||||||
.bgd-checklist-meta,
|
|
||||||
.bgd-state {
|
|
||||||
grid-column: 2;
|
|
||||||
justify-content: flex-start;
|
|
||||||
min-width: 0;
|
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6385,280 +6339,3 @@ 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,13 +12,9 @@ use trx_core::rig::{
|
|||||||
RigAccessMethod, RigCapabilities, RigInfo, RigRxStatus, RigStatus, RigTxStatus, RigVfo,
|
RigAccessMethod, RigCapabilities, RigInfo, RigRxStatus, RigStatus, RigTxStatus, RigVfo,
|
||||||
RigVfoEntry,
|
RigVfoEntry,
|
||||||
};
|
};
|
||||||
use trx_core::{
|
use trx_core::{DecoderConfig, RdsData, RigFilterState, RigMode, RigSnapshot, WfmDenoiseLevel};
|
||||||
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};
|
||||||
|
|
||||||
@@ -52,8 +48,6 @@ 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);
|
||||||
@@ -62,7 +56,6 @@ 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);
|
||||||
|
|
||||||
|
|||||||
@@ -30,10 +30,8 @@ await build({
|
|||||||
"hf-aprs": path.join(sourceDir, "plugins", "hf-aprs.ts"),
|
"hf-aprs": path.join(sourceDir, "plugins", "hf-aprs.ts"),
|
||||||
sat: path.join(sourceDir, "plugins", "sat.ts"),
|
sat: path.join(sourceDir, "plugins", "sat.ts"),
|
||||||
"sat-scheduler": path.join(sourceDir, "plugins", "sat-scheduler.ts"),
|
"sat-scheduler": path.join(sourceDir, "plugins", "sat-scheduler.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 && node tests/logbook.mjs && node tests/account-management.mjs && node tests/transmit-role.mjs",
|
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs && node tests/mobile-layout.mjs",
|
||||||
"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,51 +2,11 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
import type { AuthRole } from "./generated.js";
|
export type AuthRole = "rx" | "control";
|
||||||
|
|
||||||
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;
|
||||||
roles: AuthRole[];
|
role?: AuthRole;
|
||||||
username?: string;
|
|
||||||
auth_disabled?: boolean;
|
auth_disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,24 +18,21 @@ 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 = {
|
const decoded: AuthSession = { authenticated: session.authenticated };
|
||||||
authenticated: session.authenticated,
|
if (session.role !== undefined) decoded.role = session.role;
|
||||||
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,
|
||||||
roles: [...AUTH_ADMIN_ROLES],
|
role: "control",
|
||||||
auth_disabled: true,
|
auth_disabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -83,19 +40,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, roles: [] };
|
if (!response.ok) return { authenticated: false };
|
||||||
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, roles: [] };
|
return { authenticated: false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function login(username: string, password: string): Promise<AuthSession> {
|
export async function login(passphrase: string): Promise<AuthSession> {
|
||||||
const response = await fetch("/auth/login", {
|
const response = await fetch("/auth/login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ passphrase }),
|
||||||
});
|
});
|
||||||
if (response.status === 404) return authDisabledSession;
|
if (response.status === 404) return authDisabledSession;
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -105,52 +62,6 @@ export async function login(username: string, password: string): Promise<AuthSes
|
|||||||
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,21 +55,7 @@ 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 DigSidebandPolicy = "auto" | "usb" | "lsb";
|
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 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).
|
||||||
*/
|
*/
|
||||||
@@ -137,8 +123,6 @@ export type RigListResponse = { active_remote: string | null, rigs: Array<RigLis
|
|||||||
|
|
||||||
export type FrontendMeta = { clients: number, rigctl_clients: number, audio_clients: number, rigctl_addr: string | null, active_remote: string | null, remotes: Array<string>, owner_callsign: string | null, owner_website_url: string | null, owner_website_name: string | null, ais_vessel_url_base: string | null, show_sdr_gain_control: boolean, initial_map_zoom: number, spectrum_coverage_margin_hz: number, spectrum_usable_span_ratio: number, bandplan_enabled: boolean, bandplan_region: string, decode_history_retention_min: bigint, server_connected: boolean, };
|
export type 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,17 +21,6 @@ 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,
|
||||||
@@ -200,8 +189,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, roles: readonly AuthRole[]): void; setRig(rigId: string | null): void; wireEvents(): void };
|
scheduler?: { initialize(rigId: string | null, role: AuthRole | null): void; setRig(rigId: string | null): void; wireEvents(): void };
|
||||||
backgroundDecode?: { initialize(rigId: string | null, 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 };
|
||||||
bookmarks?: {
|
bookmarks?: {
|
||||||
readonly overlayList: readonly Bookmark[];
|
readonly overlayList: readonly Bookmark[];
|
||||||
readonly overlayRevision: number;
|
readonly overlayRevision: number;
|
||||||
@@ -237,7 +226,7 @@ interface TrxState {
|
|||||||
readonly initialMapZoom: number;
|
readonly initialMapZoom: number;
|
||||||
readonly decodeHistoryRetentionMin: number;
|
readonly decodeHistoryRetentionMin: number;
|
||||||
readonly authEnabled: boolean;
|
readonly authEnabled: boolean;
|
||||||
readonly authRoles: readonly AuthRole[];
|
readonly authRole: AuthRole | null;
|
||||||
readonly decoderRegistry: typeof decoderRegistry;
|
readonly decoderRegistry: typeof decoderRegistry;
|
||||||
readonly sseSessionId: string | null;
|
readonly sseSessionId: string | null;
|
||||||
readonly primaryRds: RdsData | null;
|
readonly primaryRds: RdsData | null;
|
||||||
@@ -276,7 +265,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" | "ham", boolean>>): void;
|
setLayoutCapabilities(capabilities?: Partial<Record<"broadcast" | "digital", 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;
|
||||||
@@ -396,14 +385,12 @@ declare global {
|
|||||||
refreshCwTonePicker?(): void;
|
refreshCwTonePicker?(): void;
|
||||||
updateFt8RfDisplay?(): void;
|
updateFt8RfDisplay?(): void;
|
||||||
clearSatPredictionDom?(): void;
|
clearSatPredictionDom?(): void;
|
||||||
refreshSatPredictions?(): void;
|
|
||||||
syncWefaxToggle?(enabled: boolean): void;
|
syncWefaxToggle?(enabled: boolean): void;
|
||||||
syncSstvToggle?(enabled: boolean): void;
|
syncSstvToggle?(enabled: boolean): void;
|
||||||
updateAisBar?(value?: number): void;
|
updateAisBar?(value?: number): void;
|
||||||
updateVdesBar?(value?: number): void;
|
updateVdesBar?(value?: number): void;
|
||||||
updateAprsBar?(value?: number): void;
|
updateAprsBar?(value?: number): void;
|
||||||
updateFt8Bar?(value?: number): void;
|
updateFt8Bar?(value?: number): void;
|
||||||
updateCwBar?(value?: number): void;
|
|
||||||
updateSatLiveState?(value: unknown): void;
|
updateSatLiveState?(value: unknown): void;
|
||||||
applyCwAutoUi?(enabled: boolean): void;
|
applyCwAutoUi?(enabled: boolean): void;
|
||||||
applyCwAutoUiFromServer?(enabled: boolean): void;
|
applyCwAutoUiFromServer?(enabled: boolean): void;
|
||||||
@@ -417,76 +404,40 @@ declare global {
|
|||||||
void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
|
void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
|
||||||
|
|
||||||
// --- Authentication ---
|
// --- Authentication ---
|
||||||
let authRoles: AuthRole[] = [];
|
let authRole: AuthRole | null = null;
|
||||||
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(username: string, password: string) {
|
async function authLogin(passphrase: string) {
|
||||||
return login(username, password);
|
return login(passphrase);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function authLogout() {
|
async function authLogout() {
|
||||||
try {
|
try {
|
||||||
await logout();
|
await logout();
|
||||||
setAuthRoles([]);
|
authRole = null;
|
||||||
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-password").value = "";
|
requiredElement<HTMLInputElement>("auth-passphrase").value = "";
|
||||||
updateAuthUI();
|
updateAuthUI();
|
||||||
|
|
||||||
showAuthGate();
|
// Check if guest mode is available after logout
|
||||||
|
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() {
|
function showAuthGate(allowGuest = false) {
|
||||||
if (!authEnabled) return;
|
if (!authEnabled) return;
|
||||||
setDecodeHistoryOverlayVisible(false);
|
setDecodeHistoryOverlayVisible(false);
|
||||||
requiredElement("loading").style.display = "none";
|
requiredElement("loading").style.display = "none";
|
||||||
@@ -506,6 +457,12 @@ function showAuthGate() {
|
|||||||
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");
|
||||||
});
|
});
|
||||||
@@ -547,32 +504,22 @@ 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 (authRoles.length > 0) {
|
if (authRole) {
|
||||||
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 = `${authUsername || "local"} — ${authRoles.map(role => AUTH_ROLE_LABELS[role]).join(", ")}`;
|
if (badgeRole) badgeRole.textContent = authRole === "control" ? "Control (full access)" : "RX (read-only)";
|
||||||
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";
|
||||||
@@ -583,40 +530,37 @@ function updateAuthUI() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyAuthRestrictions() {
|
function applyAuthRestrictions() {
|
||||||
if (authRoles.length === 0) return;
|
if (!authRole) return;
|
||||||
|
|
||||||
if (!hasAuthRole("transmit")) {
|
// Disable TX/PTT/frequency/mode/VFO controls for rx role
|
||||||
|
if (authRole === "rx") {
|
||||||
const pttBtn = document.getElementById("ptt-btn") as HTMLButtonElement | null;
|
const 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);
|
||||||
@@ -660,6 +604,9 @@ function applyAuthRestrictions() {
|
|||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Hide TX-specific UI but keep controls visible (disabled)
|
||||||
|
if (txLimitRow) txLimitRow.style.opacity = "0.5";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -918,15 +865,14 @@ window.applyDecodeHistoryRetention = function() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function syncTopBarAccess() {
|
function syncTopBarAccess() {
|
||||||
const loggedOut = authEnabled && authRoles.length === 0;
|
const loggedOut = authEnabled && !authRole;
|
||||||
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";
|
||||||
const lacksLogbookAccess = authEnabled && btn.dataset.tab === "logbook" && !hasAuthRole("write");
|
btn.style.display = !loggedOut || isMain ? "" : "none";
|
||||||
btn.style.display = (!loggedOut || isMain) && !lacksLogbookAccess ? "" : "none";
|
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -935,7 +881,7 @@ function syncTopBarAccess() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (headerRigSwitchSelect) {
|
if (headerRigSwitchSelect) {
|
||||||
headerRigSwitchSelect.disabled = loggedOut || !hasAuthRole("control") || lastRigIds.length === 0;
|
headerRigSwitchSelect.disabled = loggedOut || authRole === "rx" || lastRigIds.length === 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1516,17 +1462,13 @@ 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 || !hasAuthRole("control");
|
const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx";
|
||||||
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
|
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
|
||||||
updateRigSubtitle(lastActiveRigId);
|
updateRigSubtitle(lastActiveRigId);
|
||||||
window.trxUi?.setActiveRig(lastActiveRigId);
|
window.trxUi?.setActiveRig(lastActiveRigId);
|
||||||
// Told every time, not only when the list changes: these modules load lazily
|
|
||||||
// and can miss the one telling there was, which left them holding no rig and
|
|
||||||
// showing an empty panel until the operator switched rigs. Both ignore a rig
|
|
||||||
// they already have.
|
|
||||||
window.trx.modules.scheduler?.setRig(lastActiveRigId);
|
|
||||||
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
|
|
||||||
if (rigListChanged) {
|
if (rigListChanged) {
|
||||||
|
window.trx.modules.scheduler?.setRig(lastActiveRigId);
|
||||||
|
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
|
||||||
window.trx.modules.bookmarks?.populateScopePicker();
|
window.trx.modules.bookmarks?.populateScopePicker();
|
||||||
void window.trx.modules.bookmarks?.fetch((document.getElementById("bm-category-filter") as HTMLSelectElement | null)?.value || "");
|
void window.trx.modules.bookmarks?.fetch((document.getElementById("bm-category-filter") as HTMLSelectElement | null)?.value || "");
|
||||||
}
|
}
|
||||||
@@ -1574,9 +1516,6 @@ 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),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2267,17 +2206,6 @@ function resetRdsDisplay() {
|
|||||||
updateRdsPsOverlay(primaryRds);
|
updateRdsPsOverlay(primaryRds);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The mini views over the waterfall show only what the rig on screen heard, so
|
|
||||||
// they have to be repainted whenever that rig — or its mode — changes, not just
|
|
||||||
// when the next decode happens to arrive.
|
|
||||||
function refreshDecodeBars() {
|
|
||||||
window.updateAisBar?.();
|
|
||||||
window.updateVdesBar?.();
|
|
||||||
window.updateAprsBar?.();
|
|
||||||
window.updateFt8Bar?.();
|
|
||||||
window.updateCwBar?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetDecoderStateOnRigSwitch() {
|
function resetDecoderStateOnRigSwitch() {
|
||||||
// RDS
|
// RDS
|
||||||
primaryRds = null;
|
primaryRds = null;
|
||||||
@@ -2297,12 +2225,6 @@ function resetDecoderStateOnRigSwitch() {
|
|||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (el) el.textContent = "--";
|
if (el) el.textContent = "--";
|
||||||
});
|
});
|
||||||
|
|
||||||
// The decode stream is not rig-scoped, so the histories behind the mini views
|
|
||||||
// and the decoder panels survive the switch: repaint both for the rig now
|
|
||||||
// selected instead of leaving the outgoing rig's traffic on screen.
|
|
||||||
refreshDecodeBars();
|
|
||||||
window.trxPluginRuntime.rerenderAll();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetWfmStereoIndicator() {
|
function resetWfmStereoIndicator() {
|
||||||
@@ -2450,26 +2372,10 @@ 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;
|
||||||
// On SDR the DIG sideband is configurable; resolve it. On other backends
|
if (modeUpper === "USB" || modeUpper === "CW" || modeUpper === "DIG") return 1;
|
||||||
// keep the historical upper-sideband overlay.
|
|
||||||
if (modeUpper === "DIG") return sdrDigSidebandSupported ? digSidebandDirection() : 1;
|
|
||||||
if (modeUpper === "USB" || modeUpper === "CW") return 1;
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3097,16 +3003,9 @@ function formatSignal(sUnits: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setDisabled(disabled: boolean) {
|
function setDisabled(disabled: boolean) {
|
||||||
const controlDisabled = disabled || (authEnabled && !hasAuthRole("control"));
|
[freqEl, centerFreqEl, modeEl, pttBtn, powerBtn, txLimitInput, txLimitBtn, lockBtn].forEach((el) => {
|
||||||
const transmitDisabled = disabled || (authEnabled && !hasAuthRole("transmit"));
|
if (el) el.disabled = disabled;
|
||||||
[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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3427,7 +3326,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 (!hasAuthRole("control")) {
|
if (authRole === "rx") {
|
||||||
showHint("Read-only session — link not applied", 2500);
|
showHint("Read-only session — link not applied", 2500);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3696,26 +3595,6 @@ 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") {
|
||||||
@@ -3796,7 +3675,10 @@ function render(update: AppUpdate) {
|
|||||||
const connText = _decodeConnectedText[d.id] || "Connected, listening for packets";
|
const connText = _decodeConnectedText[d.id] || "Connected, listening for packets";
|
||||||
setModeBoundDecodeStatus(el, d.active_modes, "Select " + d.active_modes[0] + " mode to decode", connText);
|
setModeBoundDecodeStatus(el, d.active_modes, "Select " + d.active_modes[0] + " mode to decode", connText);
|
||||||
}
|
}
|
||||||
refreshDecodeBars();
|
if (window.updateAisBar) window.updateAisBar();
|
||||||
|
if (window.updateVdesBar) window.updateVdesBar();
|
||||||
|
if (window.updateAprsBar) window.updateAprsBar();
|
||||||
|
if (window.updateFt8Bar) window.updateFt8Bar();
|
||||||
// Toggle-gated decoder status: clear "Receiving" when decoder disabled or mode wrong.
|
// Toggle-gated decoder status: clear "Receiving" when decoder disabled or mode wrong.
|
||||||
for (const d of decoderRegistry) {
|
for (const d of decoderRegistry) {
|
||||||
if (d.activation !== "toggle") continue;
|
if (d.activation !== "toggle") continue;
|
||||||
@@ -4257,7 +4139,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
|
||||||
setAuthRoles([]);
|
authRole = null;
|
||||||
if (es) es.close();
|
if (es) es.close();
|
||||||
showAuthGate();
|
showAuthGate();
|
||||||
throw new Error("Authentication required");
|
throw new Error("Authentication required");
|
||||||
@@ -4286,7 +4168,7 @@ async function switchRigFromSelect(selectEl: HTMLSelectElement) {
|
|||||||
showHint("No rig selected", 1500);
|
showHint("No rig selected", 1500);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!hasAuthRole("control")) {
|
if (authRole === "rx") {
|
||||||
showHint("Control role required", 1500);
|
showHint("Control role required", 1500);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -4967,15 +4849,9 @@ function _initMapWhenReady() {
|
|||||||
|
|
||||||
function navigateToTab(name: TabName, options: { updateHistory?: boolean; replaceHistory?: boolean } = {}) {
|
function navigateToTab(name: TabName, options: { updateHistory?: boolean; replaceHistory?: boolean } = {}) {
|
||||||
window.trxUi?.closeMobileOverlays?.();
|
window.trxUi?.closeMobileOverlays?.();
|
||||||
const leavingSatellites = _activeTab === "satellites" && name !== "satellites";
|
|
||||||
const { updateHistory = true, replaceHistory = false } = options;
|
const { updateHistory = true, replaceHistory = false } = options;
|
||||||
if (authEnabled && authRoles.length === 0 && name !== "main") {
|
if (authEnabled && !authRole && name !== "main") {
|
||||||
showAuthGate();
|
showAuthGate(false);
|
||||||
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}"]`);
|
||||||
@@ -5013,15 +4889,7 @@ function navigateToTab(name: TabName, options: { updateHistory?: boolean; replac
|
|||||||
updateTabHistory(name, replaceHistory);
|
updateTabHistory(name, replaceHistory);
|
||||||
}
|
}
|
||||||
scheduleSpectrumLayout();
|
scheduleSpectrumLayout();
|
||||||
// Its countdowns tick every second, so the page stops when it is not on screen.
|
void loadPluginsForTab(name).catch((error: unknown) => { console.error(error); });
|
||||||
if (leavingSatellites) window.clearSatPredictionDom?.();
|
|
||||||
void loadPluginsForTab(name).then(() => {
|
|
||||||
// Passes go stale while the page is closed, so each visit reloads them.
|
|
||||||
// The first visit is what imports the module, which renders on its own.
|
|
||||||
if (name === "satellites") window.refreshSatPredictions?.();
|
|
||||||
// The map module owns the decode log; this is the moment it can exist.
|
|
||||||
flushPendingDecodeStats();
|
|
||||||
}).catch((error: unknown) => { console.error(error); });
|
|
||||||
if (name === "map") {
|
if (name === "map") {
|
||||||
_initMapWhenReady();
|
_initMapWhenReady();
|
||||||
}
|
}
|
||||||
@@ -5108,12 +4976,12 @@ window.addEventListener("resize", () => { scheduleSpectrumLayout(); });
|
|||||||
|
|
||||||
// --- Auth startup sequence ---
|
// --- Auth startup sequence ---
|
||||||
async function initializeApp() {
|
async function initializeApp() {
|
||||||
showAuthGate();
|
showAuthGate(false);
|
||||||
const authStatus = await checkAuthStatus();
|
const authStatus = await checkAuthStatus();
|
||||||
authEnabled = !authStatus.auth_disabled;
|
authEnabled = !authStatus.auth_disabled;
|
||||||
|
|
||||||
if (!authEnabled) {
|
if (!authEnabled) {
|
||||||
setAuthRoles(AUTH_ADMIN_ROLES);
|
authRole = "control";
|
||||||
hideAuthGate();
|
hideAuthGate();
|
||||||
updateAuthUI();
|
updateAuthUI();
|
||||||
connect();
|
connect();
|
||||||
@@ -5126,8 +4994,7 @@ async function initializeApp() {
|
|||||||
|
|
||||||
if (authStatus.authenticated) {
|
if (authStatus.authenticated) {
|
||||||
// User has valid session
|
// User has valid session
|
||||||
setAuthRoles(authStatus.roles);
|
authRole = authStatus.role ?? null;
|
||||||
authUsername = authStatus.username ?? null;
|
|
||||||
hideAuthGate();
|
hideAuthGate();
|
||||||
updateAuthUI();
|
updateAuthUI();
|
||||||
applyAuthRestrictions();
|
applyAuthRestrictions();
|
||||||
@@ -5137,196 +5004,36 @@ async function initializeApp() {
|
|||||||
resizeHeaderSignalCanvas();
|
resizeHeaderSignalCanvas();
|
||||||
startHeaderSignalSampling();
|
startHeaderSignalSampling();
|
||||||
} else {
|
} else {
|
||||||
showAuthGate();
|
// No valid session - show auth gate
|
||||||
|
// Guest button is shown if guest mode is available (role granted without auth)
|
||||||
|
const allowGuest = authStatus.role === "rx";
|
||||||
|
showAuthGate(allowGuest);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whether the session got far enough to wire the settings panels up, so the
|
|
||||||
// plugins can be wired again if they turn up after it did.
|
|
||||||
let settingsUiReady = false;
|
|
||||||
|
|
||||||
function initSettingsUI() {
|
function initSettingsUI() {
|
||||||
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, authRoles);
|
window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRole);
|
||||||
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 usernameEl = requiredElement<HTMLInputElement>("auth-username");
|
const passphraseEl = requiredElement<HTMLInputElement>("auth-passphrase");
|
||||||
const passwordEl = requiredElement<HTMLInputElement>("auth-password");
|
const passphrase = passphraseEl.value;
|
||||||
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(usernameEl.value, passwordEl.value);
|
const result = await authLogin(passphrase);
|
||||||
setAuthRoles(result.roles);
|
authRole = result.role ?? null;
|
||||||
authUsername = result.username ?? usernameEl.value;
|
passphraseEl.value = "";
|
||||||
passwordEl.value = "";
|
|
||||||
hideAuthGate();
|
hideAuthGate();
|
||||||
updateAuthUI();
|
updateAuthUI();
|
||||||
applyAuthRestrictions();
|
applyAuthRestrictions();
|
||||||
@@ -5336,7 +5043,7 @@ requiredElement<HTMLFormElement>("auth-form").addEventListener("submit", async (
|
|||||||
resizeHeaderSignalCanvas();
|
resizeHeaderSignalCanvas();
|
||||||
startHeaderSignalSampling();
|
startHeaderSignalSampling();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showAuthError("Invalid username or password");
|
showAuthError("Invalid passphrase");
|
||||||
console.error("Login error:", err);
|
console.error("Login error:", err);
|
||||||
} finally {
|
} finally {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
@@ -5344,18 +5051,35 @@ requiredElement<HTMLFormElement>("auth-form").addEventListener("submit", async (
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Setup guest button
|
||||||
|
const guestBtn = document.getElementById("auth-guest-btn") as HTMLButtonElement | null;
|
||||||
|
if (guestBtn) {
|
||||||
|
guestBtn.addEventListener("click", () => {
|
||||||
|
authRole = "rx";
|
||||||
|
requiredElement<HTMLInputElement>("auth-passphrase").value = "";
|
||||||
|
hideAuthGate();
|
||||||
|
updateAuthUI();
|
||||||
|
applyAuthRestrictions();
|
||||||
|
connect();
|
||||||
|
connectDecode();
|
||||||
|
initSettingsUI();
|
||||||
|
resizeHeaderSignalCanvas();
|
||||||
|
startHeaderSignalSampling();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Setup header auth button (Login/Logout)
|
// 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 (authRoles.length > 0) {
|
if (authRole) {
|
||||||
// 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();
|
showAuthGate(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -5377,7 +5101,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; } },
|
||||||
authRoles: { get() { return authRoles; } },
|
authRole: { get() { return authRole; } },
|
||||||
decoderRegistry: { get() { return decoderRegistry; } },
|
decoderRegistry: { get() { return decoderRegistry; } },
|
||||||
sseSessionId: { get() { return sseSessionId; } },
|
sseSessionId: { get() { return sseSessionId; } },
|
||||||
primaryRds: { get() { return primaryRds; } },
|
primaryRds: { get() { return primaryRds; } },
|
||||||
@@ -5440,15 +5164,7 @@ window.trx = Object.freeze({ state: trxState, core: trxCore, modules: trxModules
|
|||||||
|
|
||||||
// Load plugin scripts now that window.trx is populated. Dynamic scripts are
|
// Load plugin scripts now that window.trx is populated. Dynamic scripts are
|
||||||
// async so they must not be created before the namespace they depend on exists.
|
// async so they must not be created before the namespace they depend on exists.
|
||||||
//
|
void loadEagerPlugins().catch((error: unknown) => { console.error(error); });
|
||||||
// Wire the settings panels again once they are here: the session can be up
|
|
||||||
// before these modules finish importing, and the wiring at that point silently
|
|
||||||
// skipped whichever had not arrived. A panel that missed it had no rig, no
|
|
||||||
// data, and dead buttons — for the rest of the session, since nothing came
|
|
||||||
// back to it. Both entry points are safe to run twice.
|
|
||||||
void loadEagerPlugins()
|
|
||||||
.then(() => { if (settingsUiReady) initSettingsUI(); })
|
|
||||||
.catch((error: unknown) => { console.error(error); });
|
|
||||||
|
|
||||||
// Start the app
|
// Start the app
|
||||||
void initializeApp();
|
void initializeApp();
|
||||||
@@ -5525,6 +5241,10 @@ function _wireSubTabBar(bar: WiredElement) {
|
|||||||
if (window.refreshCwTonePicker) window.refreshCwTonePicker();
|
if (window.refreshCwTonePicker) window.refreshCwTonePicker();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Clear SAT prediction DOM when leaving the SAT tab to reduce node count.
|
||||||
|
if (btn.dataset.subtab !== "sat" && typeof window.clearSatPredictionDom === "function") {
|
||||||
|
window.clearSatPredictionDom();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
document.querySelectorAll<WiredElement>(".sub-tab-bar").forEach(_wireSubTabBar);
|
document.querySelectorAll<WiredElement>(".sub-tab-bar").forEach(_wireSubTabBar);
|
||||||
@@ -5646,20 +5366,7 @@ 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) => {
|
||||||
@@ -6086,7 +5793,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)}&profile=${encodeURIComponent(sdrNbProfile)}`,
|
`/set_sdr_noise_blanker?enabled=${enabled ? "true" : "false"}&threshold=${encodeURIComponent(threshold)}`,
|
||||||
).catch(() => {});
|
).catch(() => {});
|
||||||
}
|
}
|
||||||
if (sdrNbEnabledEl) {
|
if (sdrNbEnabledEl) {
|
||||||
@@ -6094,21 +5801,6 @@ 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);
|
||||||
@@ -6126,21 +5818,6 @@ if (sdrNbThresholdEl) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function submitSdrDigSideband() {
|
|
||||||
if (!sdrDigSidebandSupported || !sdrDigSidebandEl) return;
|
|
||||||
const policy = sdrDigSidebandEl.value || "auto";
|
|
||||||
if (policy !== "auto" && policy !== "usb" && policy !== "lsb") return;
|
|
||||||
// Optimistically move the passband overlay to the chosen sideband so the
|
|
||||||
// display responds instantly, before the server round-trip confirms it.
|
|
||||||
sdrDigSidebandPolicy = policy;
|
|
||||||
if (lastSpectrumData) scheduleSpectrumDraw();
|
|
||||||
postPath(`/set_sdr_dig_sideband?policy=${encodeURIComponent(policy)}`).catch(() => {});
|
|
||||||
}
|
|
||||||
if (sdrDigSidebandEl) {
|
|
||||||
sdrDigSidebandEl.addEventListener("change", () => {
|
|
||||||
submitSdrDigSideband();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function updateWfmControls() {
|
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";
|
||||||
@@ -6148,11 +5825,6 @@ 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
|
||||||
@@ -6470,10 +6142,6 @@ 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;
|
||||||
@@ -6910,49 +6578,11 @@ const IMAGE_DECODE_KINDS = new Set([
|
|||||||
"lrpt_image", "lrpt_progress", "wefax", "wefax_progress", "sstv", "sstv_progress",
|
"lrpt_image", "lrpt_progress", "wefax", "wefax_progress", "sstv", "sstv_progress",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// The decode log the Statistics page counts lives in the map module, which is
|
|
||||||
// lazy: it arrives when the Map or Statistics tab is first opened, long after
|
|
||||||
// the history replayed and the live decodes started coming. Recording into a
|
|
||||||
// module that is not there yet dropped every one of them, and nothing replayed
|
|
||||||
// them afterwards -- so the page opened empty and only filled in from decodes
|
|
||||||
// heard after it, which is why it took a reload (landing on the tab, so the
|
|
||||||
// module loads at startup) to show the whole picture. Hold them here until
|
|
||||||
// the module arrives, then hand them over.
|
|
||||||
interface PendingDecodeStat { kind: string; rig: string | null; tsMs: number | undefined }
|
|
||||||
const pendingDecodeStats: PendingDecodeStat[] = [];
|
|
||||||
// The module's own log keeps 50k entries; there is no point holding more here.
|
|
||||||
const PENDING_DECODE_STATS_MAX = 50_000;
|
|
||||||
|
|
||||||
function recordDecodeStat(kind: string, rig: string | null, tsMs?: number) {
|
|
||||||
const stats = window.trx.modules.map;
|
|
||||||
if (stats) {
|
|
||||||
stats.statsRecordDecode(kind, rig, tsMs);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
pendingDecodeStats.push({ kind, rig, tsMs });
|
|
||||||
if (pendingDecodeStats.length > PENDING_DECODE_STATS_MAX) {
|
|
||||||
pendingDecodeStats.splice(0, pendingDecodeStats.length - PENDING_DECODE_STATS_MAX);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function flushPendingDecodeStats() {
|
|
||||||
const stats = window.trx.modules.map;
|
|
||||||
if (!stats || pendingDecodeStats.length === 0) return;
|
|
||||||
for (const entry of pendingDecodeStats.splice(0)) {
|
|
||||||
stats.statsRecordDecode(entry.kind, entry.rig, entry.tsMs);
|
|
||||||
}
|
|
||||||
stats.scheduleStatsRender();
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleStatsRenderIfLoaded() {
|
|
||||||
window.trx.modules.map?.scheduleStatsRender();
|
|
||||||
}
|
|
||||||
|
|
||||||
function dispatchDecodeMessage(msg: DecodeMessage, skipStats = false) {
|
function dispatchDecodeMessage(msg: DecodeMessage, skipStats = false) {
|
||||||
if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg);
|
if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg);
|
||||||
if (!skipStats && msg.type && !IMAGE_DECODE_KINDS.has(msg.type)) {
|
if (!skipStats && msg.type && !IMAGE_DECODE_KINDS.has(msg.type)) {
|
||||||
recordDecodeStat(msg.type, msg.rig_id || msg.remote || null);
|
window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
|
||||||
scheduleStatsRenderIfLoaded();
|
window.trx.modules.map?.scheduleStatsRender();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7002,9 +6632,9 @@ function restoreDecodeHistoryGroup(kind: string, messages: DecodeMessage[]) {
|
|||||||
// Record statistics for restored history messages.
|
// Record statistics for restored history messages.
|
||||||
if (!IMAGE_DECODE_KINDS.has(kind)) {
|
if (!IMAGE_DECODE_KINDS.has(kind)) {
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
recordDecodeStat(kind, msg.rig_id || msg.remote || null, msg.ts_ms || undefined);
|
window.trx.modules.map?.statsRecordDecode(kind, msg.rig_id || msg.remote || null, msg.ts_ms || undefined);
|
||||||
}
|
}
|
||||||
scheduleStatsRenderIfLoaded();
|
window.trx.modules.map?.scheduleStatsRender();
|
||||||
}
|
}
|
||||||
window.trxPluginRuntime.restore(kind, messages);
|
window.trxPluginRuntime.restore(kind, messages);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-3
@@ -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", "logbook", "digital-modes", "map", "satellites", "statistics", "recorder", "settings", "about",
|
"main", "bookmarks", "digital-modes", "map", "statistics", "recorder", "settings", "about",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type TabName = typeof TAB_ORDER[number];
|
export type TabName = typeof TAB_ORDER[number];
|
||||||
@@ -11,10 +11,8 @@ 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",
|
|
||||||
statistics: "/statistics",
|
statistics: "/statistics",
|
||||||
recorder: "/recorder",
|
recorder: "/recorder",
|
||||||
settings: "/settings",
|
settings: "/settings",
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ function mapEl(id: string): MapElement {
|
|||||||
return element as MapElement;
|
return element as MapElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
type DecoderSource = "ais" | "vdes" | "aprs" | "hf_aprs" | "bookmark" | "ft8" | "ft4" | "ft2" | "wspr" | "sat";
|
type DecoderSource = "ais" | "vdes" | "aprs" | "bookmark" | "ft8" | "ft4" | "ft2" | "wspr" | "sat";
|
||||||
interface TrackPoint { lat: number; lon: number; tsMs: number }
|
interface TrackPoint { lat: number; lon: number; tsMs: number }
|
||||||
interface DecodeDetail {
|
interface DecodeDetail {
|
||||||
station?: string | null; source?: string | null; target?: string | null;
|
station?: string | null; source?: string | null; target?: string | null;
|
||||||
@@ -83,9 +83,6 @@ interface MapEntry {
|
|||||||
bookmarks?: Array<Record<string, unknown>>;
|
bookmarks?: Array<Record<string, unknown>>;
|
||||||
bounds?: Leaflet.LatLngBoundsExpression;
|
bounds?: Leaflet.LatLngBoundsExpression;
|
||||||
symbolTable?: string; symbolCode?: string; bandLabel?: string | null;
|
symbolTable?: string; symbolCode?: string; bandLabel?: string | null;
|
||||||
/** Callsign as heard. The map key qualifies it with the source, so that a
|
|
||||||
* station worked on both 144 MHz and 30 m keeps one marker per band. */
|
|
||||||
call?: string;
|
|
||||||
overlay?: TrxLayer | null; line?: TrxLayer | null; labelMarker?: TrxLayer | null;
|
overlay?: TrxLayer | null; line?: TrxLayer | null; labelMarker?: TrxLayer | null;
|
||||||
pathKey?: string; sourceGrid?: string; targetGrid?: string;
|
pathKey?: string; sourceGrid?: string; targetGrid?: string;
|
||||||
from?: LatLon; to?: LatLon;
|
from?: LatLon; to?: LatLon;
|
||||||
@@ -174,7 +171,7 @@ interface MapWindow {
|
|||||||
clearMapMarkersByType?(type: DecoderSource): void;
|
clearMapMarkersByType?(type: DecoderSource): void;
|
||||||
navigateToAprsMap?(lat: number, lon: number): void;
|
navigateToAprsMap?(lat: number, lon: number): void;
|
||||||
navigateToMapLocator?(grid: string, preferredType?: string | null): void;
|
navigateToMapLocator?(grid: string, preferredType?: string | null): void;
|
||||||
aprsMapAddStation?(call: string, lat: number, lon: number, info: string, symbolTable: string, symbolCode: string, packet: MapMessage, source?: "aprs" | "hf_aprs"): void;
|
aprsMapAddStation?(call: string, lat: number, lon: number, info: string, symbolTable: string, symbolCode: string, packet: MapMessage): void;
|
||||||
aisMapAddVessel?(message: MapMessage): void;
|
aisMapAddVessel?(message: MapMessage): void;
|
||||||
vdesMapAddPoint?(message: MapMessage): void;
|
vdesMapAddPoint?(message: MapMessage): void;
|
||||||
syncBookmarkMapLocators?(bookmarks: Array<Record<string, unknown>>): void;
|
syncBookmarkMapLocators?(bookmarks: Array<Record<string, unknown>>): void;
|
||||||
@@ -231,7 +228,7 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
const decodeContactPaths = new Map<string, MapEntry>();
|
const decodeContactPaths = new Map<string, MapEntry>();
|
||||||
let selectedMapQsoKey: string | null = null;
|
let selectedMapQsoKey: string | null = null;
|
||||||
const mapMarkers = new Set<TrxLayer>();
|
const mapMarkers = new Set<TrxLayer>();
|
||||||
const DEFAULT_MAP_SOURCE_FILTER: Record<MapFilterKey, boolean> = { ais: true, vdes: true, aprs: true, hf_aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false };
|
const DEFAULT_MAP_SOURCE_FILTER: Record<MapFilterKey, boolean> = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false };
|
||||||
const mapFilter: Record<MapFilterKey, boolean> = { ...DEFAULT_MAP_SOURCE_FILTER };
|
const mapFilter: Record<MapFilterKey, boolean> = { ...DEFAULT_MAP_SOURCE_FILTER };
|
||||||
/** Chip key that clears a selection rather than naming a band or a source. */
|
/** Chip key that clears a selection rather than naming a band or a source. */
|
||||||
const MAP_FILTER_ALL_KEY = "__all";
|
const MAP_FILTER_ALL_KEY = "__all";
|
||||||
@@ -305,18 +302,6 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The source a station marker belongs to, defaulting to VHF APRS for
|
|
||||||
* entries stored before HF APRS had a source of its own. */
|
|
||||||
function aprsEntrySource(entry: MapEntry | null | undefined): DecoderSource {
|
|
||||||
return entry?.type === "hf_aprs" ? "hf_aprs" : "aprs";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Map key for a station: the callsign alone on VHF, source-qualified on HF,
|
|
||||||
* so one station heard on both bands keeps a marker for each. */
|
|
||||||
function aprsStationKey(call: string, source: DecoderSource): string {
|
|
||||||
return source === "aprs" ? call : `${source}:${call}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function refreshAprsTrack(call: string, entry: MapEntry): void {
|
function refreshAprsTrack(call: string, entry: MapEntry): void {
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) {
|
if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) {
|
||||||
@@ -338,7 +323,7 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
lineJoin: "round",
|
lineJoin: "round",
|
||||||
interactive: false,
|
interactive: false,
|
||||||
}) as unknown as TrxLayer;
|
}) as unknown as TrxLayer;
|
||||||
track.__trxType = aprsEntrySource(entry);
|
track.__trxType = "aprs";
|
||||||
track._aprsCall = call;
|
track._aprsCall = call;
|
||||||
entry.track = track;
|
entry.track = track;
|
||||||
}
|
}
|
||||||
@@ -590,7 +575,6 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
|
|
||||||
function mapSourceLabel(type: DecoderSource): string {
|
function mapSourceLabel(type: DecoderSource): string {
|
||||||
if (type === "bookmark") return "Bookmarks";
|
if (type === "bookmark") return "Bookmarks";
|
||||||
if (type === "hf_aprs") return "HF APRS";
|
|
||||||
return String(type || "").toUpperCase();
|
return String(type || "").toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -610,8 +594,6 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
if (type === "vdes") return "#a78bfa";
|
if (type === "vdes") return "#a78bfa";
|
||||||
if (type === "sat") return "#f59e0b";
|
if (type === "sat") return "#f59e0b";
|
||||||
if (type === "aprs") return "#00d17f";
|
if (type === "aprs") return "#00d17f";
|
||||||
// Far enough from the VHF green to tell the two apart at a glance.
|
|
||||||
if (type === "hf_aprs") return "#fb7185";
|
|
||||||
return locatorFilterColor(type);
|
return locatorFilterColor(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1206,8 +1188,10 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const entry of stationMarkers.values()) {
|
for (const entry of stationMarkers.values()) {
|
||||||
if (!entry?.visibleInHistoryWindow) continue;
|
if (entry?.type === "aprs" && entry?.visibleInHistoryWindow) {
|
||||||
availableSources.add(aprsEntrySource(entry));
|
availableSources.add("aprs");
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const bandMap = new Map<string, FilterChip>();
|
const bandMap = new Map<string, FilterChip>();
|
||||||
for (const entry of locatorMarkers.values()) {
|
for (const entry of locatorMarkers.values()) {
|
||||||
@@ -1241,7 +1225,7 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
if (!bandMap.has(key)) mapLocatorFilter.bands.delete(key);
|
if (!bandMap.has(key)) mapLocatorFilter.bands.delete(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceItems: FilterChip[] = (["ais", "vdes", "aprs", "hf_aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"] as DecoderSource[])
|
const sourceItems: FilterChip[] = (["ais", "vdes", "aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"] as DecoderSource[])
|
||||||
.filter((key) => availableSources.has(key))
|
.filter((key) => availableSources.has(key))
|
||||||
.map((key) => ({
|
.map((key) => ({
|
||||||
key,
|
key,
|
||||||
@@ -1312,10 +1296,9 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
}
|
}
|
||||||
return parts.join(" ").toLowerCase();
|
return parts.join(" ").toLowerCase();
|
||||||
}
|
}
|
||||||
if (type === "aprs" || type === "hf_aprs") {
|
if (type === "aprs") {
|
||||||
const key = marker?._aprsCall ? String(marker._aprsCall) : "";
|
const call = marker?._aprsCall ? String(marker._aprsCall) : "";
|
||||||
const entry = stationMarkers.get(key);
|
const entry = stationMarkers.get(call);
|
||||||
const call = entry?.call ?? key;
|
|
||||||
const info = entry?.info ? String(entry.info) : "";
|
const info = entry?.info ? String(entry.info) : "";
|
||||||
const pktRaw = entry?.pkt?.raw ? String(entry.pkt.raw) : "";
|
const pktRaw = entry?.pkt?.raw ? String(entry.pkt.raw) : "";
|
||||||
return `${call} ${info} ${pktRaw}`.toLowerCase();
|
return `${call} ${info} ${pktRaw}`.toLowerCase();
|
||||||
@@ -1513,10 +1496,9 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
};
|
};
|
||||||
|
|
||||||
mapWindow.clearMapMarkersByType = function(type) {
|
mapWindow.clearMapMarkersByType = function(type) {
|
||||||
if (type === "aprs" || type === "hf_aprs") {
|
if (type === "aprs") {
|
||||||
selectedAprsTrackCall = null;
|
selectedAprsTrackCall = null;
|
||||||
stationMarkers.forEach((entry, key) => {
|
stationMarkers.forEach((entry) => {
|
||||||
if (aprsEntrySource(entry) !== type) return;
|
|
||||||
if (entry && entry.marker) {
|
if (entry && entry.marker) {
|
||||||
if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
|
if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
|
||||||
mapMarkers.delete(entry.marker);
|
mapMarkers.delete(entry.marker);
|
||||||
@@ -1525,8 +1507,8 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap);
|
if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap);
|
||||||
mapMarkers.delete(entry.track);
|
mapMarkers.delete(entry.track);
|
||||||
}
|
}
|
||||||
stationMarkers.delete(key);
|
|
||||||
});
|
});
|
||||||
|
stationMarkers.clear();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1799,15 +1781,13 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
if (!ll) return;
|
if (!ll) return;
|
||||||
const entry = stationMarkers.get(marker._aprsCall);
|
const entry = stationMarkers.get(marker._aprsCall);
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
const source = aprsEntrySource(entry);
|
e.popup.setContent(buildAprsPopupHtml(marker._aprsCall, ll.lat, ll.lng, entry.info || "", entry.pkt));
|
||||||
const call = entry.call ?? String(marker._aprsCall);
|
|
||||||
e.popup.setContent(buildAprsPopupHtml(call, ll.lat, ll.lng, entry.info || "", entry.pkt));
|
|
||||||
refreshAprsTrack(String(marker._aprsCall), entry);
|
refreshAprsTrack(String(marker._aprsCall), entry);
|
||||||
if (entry.track && aprsMap && mapFilter[source] && !aprsMap.hasLayer(entry.track)) {
|
if (entry.track && aprsMap && mapFilter.aprs && !aprsMap.hasLayer(entry.track)) {
|
||||||
entry.track.addTo(aprsMap);
|
entry.track.addTo(aprsMap);
|
||||||
}
|
}
|
||||||
selectedAprsTrackCall = String(marker._aprsCall);
|
selectedAprsTrackCall = String(marker._aprsCall);
|
||||||
setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor(source), "aprs-radio-path", marker.__trxRigIds);
|
setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor("aprs"), "aprs-radio-path", marker.__trxRigIds);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2290,33 +2270,28 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function _aprsAddMarkerToMap(key: string, entry: MapEntry): void {
|
function _aprsAddMarkerToMap(call: string, entry: MapEntry): void {
|
||||||
if (!aprsMap || entry.lat == null || entry.lon == null) return;
|
if (!aprsMap || entry.lat == null || entry.lon == null) return;
|
||||||
refreshAprsTrack(key, entry);
|
refreshAprsTrack(call, entry);
|
||||||
const source = aprsEntrySource(entry);
|
|
||||||
const call = entry.call ?? key;
|
|
||||||
const icon = aprsSymbolIcon(entry.symbolTable ?? "", entry.symbolCode ?? "");
|
const icon = aprsSymbolIcon(entry.symbolTable ?? "", entry.symbolCode ?? "");
|
||||||
const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt);
|
const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt);
|
||||||
const color = mapSourceColor(source);
|
|
||||||
const marker = (icon
|
const marker = (icon
|
||||||
? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent)
|
? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent)
|
||||||
: L.circleMarker([entry.lat, entry.lon], {
|
: L.circleMarker([entry.lat, entry.lon], {
|
||||||
radius: 6, color, fillColor: color, fillOpacity: 0.8
|
radius: 6, color: "#00d17f", fillColor: "#00d17f", fillOpacity: 0.8
|
||||||
}).addTo(aprsMap).bindPopup(popupContent)) as unknown as TrxLayer;
|
}).addTo(aprsMap).bindPopup(popupContent)) as unknown as TrxLayer;
|
||||||
marker.__trxType = source;
|
marker.__trxType = "aprs";
|
||||||
marker.__trxRigIds = entry.rigIds || new Set();
|
marker.__trxRigIds = entry.rigIds || new Set();
|
||||||
marker._aprsCall = key;
|
marker._aprsCall = call;
|
||||||
entry.marker = marker;
|
entry.marker = marker;
|
||||||
mapMarkers.add(marker);
|
mapMarkers.add(marker);
|
||||||
}
|
}
|
||||||
|
|
||||||
mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt, source) {
|
mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt) {
|
||||||
const nextPoint: Leaflet.LatLngTuple = [lat, lon];
|
const nextPoint: Leaflet.LatLngTuple = [lat, lon];
|
||||||
const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now();
|
const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now();
|
||||||
const msgRigId = pkt?.rig_id || T.lastActiveRigId;
|
const msgRigId = pkt?.rig_id || T.lastActiveRigId;
|
||||||
const stationSource: DecoderSource = source === "hf_aprs" ? "hf_aprs" : "aprs";
|
const existing = stationMarkers.get(call);
|
||||||
const key = aprsStationKey(call, stationSource);
|
|
||||||
const existing = stationMarkers.get(key);
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.pkt = pkt;
|
existing.pkt = pkt;
|
||||||
existing.lat = lat;
|
existing.lat = lat;
|
||||||
@@ -2335,8 +2310,7 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
} else if (prevPoint) {
|
} else if (prevPoint) {
|
||||||
prevPoint.tsMs = tsMs;
|
prevPoint.tsMs = tsMs;
|
||||||
}
|
}
|
||||||
existing.call = call;
|
pruneAprsEntry(call, existing, mapHistoryCutoffMs());
|
||||||
pruneAprsEntry(key, existing, mapHistoryCutoffMs());
|
|
||||||
if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) {
|
if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) {
|
||||||
existing.marker.setLatLng([lat, lon]);
|
existing.marker.setLatLng([lat, lon]);
|
||||||
existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt));
|
existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt));
|
||||||
@@ -2347,8 +2321,7 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
track: null,
|
track: null,
|
||||||
trackHistory: [{ lat, lon, tsMs }],
|
trackHistory: [{ lat, lon, tsMs }],
|
||||||
trackPoints: [nextPoint],
|
trackPoints: [nextPoint],
|
||||||
type: stationSource,
|
type: "aprs",
|
||||||
call,
|
|
||||||
pkt,
|
pkt,
|
||||||
lat,
|
lat,
|
||||||
lon,
|
lon,
|
||||||
@@ -2357,9 +2330,9 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
symbolCode,
|
symbolCode,
|
||||||
rigIds: new Set(msgRigId ? [msgRigId] : []),
|
rigIds: new Set(msgRigId ? [msgRigId] : []),
|
||||||
};
|
};
|
||||||
stationMarkers.set(key, entry);
|
stationMarkers.set(call, entry);
|
||||||
pruneAprsEntry(key, entry, mapHistoryCutoffMs());
|
pruneAprsEntry(call, entry, mapHistoryCutoffMs());
|
||||||
if (entry.visibleInHistoryWindow) ensureAprsMarker(key, entry);
|
if (entry.visibleInHistoryWindow) ensureAprsMarker(call, entry);
|
||||||
if (aprsMap) scheduleDecodeMapMaintenance();
|
if (aprsMap) scheduleDecodeMapMaintenance();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,9 +2,7 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
type PluginGroup =
|
type PluginGroup = "digital-modes" | "map-data" | "map" | "statistics" | "bookmarks" | "recorder" | "settings";
|
||||||
| "digital-modes" | "map-data" | "map" | "satellites" | "statistics"
|
|
||||||
| "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
|
||||||
@@ -17,10 +15,8 @@ const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
|
|||||||
],
|
],
|
||||||
"map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
|
"map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
|
||||||
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"],
|
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.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"],
|
||||||
};
|
};
|
||||||
@@ -48,7 +44,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", "logbook", "settings"].map(loadPlugins));
|
await Promise.all(["digital-modes", "bookmarks", "settings"].map(loadPlugins));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadPluginsForTab(tab: string): Promise<void> {
|
export async function loadPluginsForTab(tab: string): Promise<void> {
|
||||||
|
|||||||
@@ -81,7 +81,6 @@ const runtime: TrxPluginRuntime = {
|
|||||||
plugin.prune();
|
plugin.prune();
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
rerenderAll() { for (const plugin of decoders.values()) plugin.rerender?.(); },
|
|
||||||
syncMapAll() { for (const plugin of decoders.values()) plugin.syncMap?.(); },
|
syncMapAll() { for (const plugin of decoders.values()) plugin.syncMap?.(); },
|
||||||
clearQueued() { queued.clear(); },
|
clearQueued() { queued.clear(); },
|
||||||
hasDecoder: (id) => decoders.has(id),
|
hasDecoder: (id) => decoders.has(id),
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
import { hostState } from "./host.js";
|
|
||||||
|
|
||||||
// The decode stream is not rig-scoped: every rig a client is connected to sends
|
|
||||||
// its decodes to every browser, each frame naming the rig that heard it (the
|
|
||||||
// client stamps `rig_id` as it leaves the audio connection). The map wants all
|
|
||||||
// of them — it has its own filter, and plotting only one rig would empty it —
|
|
||||||
// but the radio page and the decoder panels describe one rig at a time: the one
|
|
||||||
// the operator selected, whose spectrum is on screen and whose audio is
|
|
||||||
// playing. A frame a background rig copied on another band belongs to neither
|
|
||||||
// picture, so both filter on this.
|
|
||||||
export function isActiveRigDecode(rigId: string | null | undefined): boolean {
|
|
||||||
const activeRigId = hostState.lastActiveRigId;
|
|
||||||
// Before the rig list has arrived, and for a decode that reached the browser
|
|
||||||
// without a rig of its own, there is nothing to disagree with.
|
|
||||||
if (!activeRigId || !rigId) return true;
|
|
||||||
return rigId === activeRigId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The decodes of `items` that the selected rig heard, in their original order. */
|
|
||||||
export function forActiveRig<T extends { rig_id?: string | null | undefined }>(items: T[]): T[] {
|
|
||||||
return items.filter((item) => isActiveRigDecode(item.rig_id));
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,6 @@ import { hostCore, hostState } from "./host.js";
|
|||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|
||||||
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
|
|
||||||
import type { PluginRuntimeWindow } from "./runtime-contract";
|
import type { PluginRuntimeWindow } from "./runtime-contract";
|
||||||
|
|
||||||
interface AisMessage {
|
interface AisMessage {
|
||||||
@@ -191,27 +190,20 @@ function aisLatestByVessel(messages: AisMessage[]): AisMessage[] {
|
|||||||
return Array.from(byMmsi.values());
|
return Array.from(byMmsi.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What the selected rig heard. The history holds every rig's traffic, for
|
|
||||||
* the map; the panel is about the rig the operator is working. */
|
|
||||||
function aisRigMessages(): AisMessage[] {
|
|
||||||
return forActiveRig(aisMessageHistory);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateAisSummary() {
|
function updateAisSummary() {
|
||||||
const plan = currentAisChannelPlan();
|
const plan = currentAisChannelPlan();
|
||||||
if (aisChannelSummaryEl) {
|
if (aisChannelSummaryEl) {
|
||||||
aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`;
|
aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rigMessages = aisRigMessages();
|
const vessels = aisLatestByVessel(aisMessageHistory);
|
||||||
const vessels = aisLatestByVessel(rigMessages);
|
|
||||||
if (aisVesselCountEl) {
|
if (aisVesselCountEl) {
|
||||||
const count = vessels.length;
|
const count = vessels.length;
|
||||||
aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`;
|
aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (aisLatestSeenEl) {
|
if (aisLatestSeenEl) {
|
||||||
const latest = rigMessages[0];
|
const latest = aisMessageHistory[0];
|
||||||
if (!latest) {
|
if (!latest) {
|
||||||
aisLatestSeenEl.textContent = "No traffic yet";
|
aisLatestSeenEl.textContent = "No traffic yet";
|
||||||
} else {
|
} else {
|
||||||
@@ -322,9 +314,7 @@ function updateAisBar() {
|
|||||||
|
|
||||||
const isAis = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "AIS";
|
const isAis = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "AIS";
|
||||||
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
|
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
|
||||||
const recent = aisMessageHistory.filter(
|
const recent = aisMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs);
|
||||||
(msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id),
|
|
||||||
);
|
|
||||||
const messages = aisLatestByVessel(recent).slice(0, 8);
|
const messages = aisLatestByVessel(recent).slice(0, 8);
|
||||||
if (!isAis || messages.length === 0) {
|
if (!isAis || messages.length === 0) {
|
||||||
aisBarOverlay.style.display = "none";
|
aisBarOverlay.style.display = "none";
|
||||||
@@ -378,7 +368,7 @@ function renderAisHistory() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
for (const message of aisRigMessages()) {
|
for (const message of aisMessageHistory) {
|
||||||
fragment.appendChild(renderAisRow(message));
|
fragment.appendChild(renderAisRow(message));
|
||||||
}
|
}
|
||||||
aisMessagesEl.replaceChildren(fragment);
|
aisMessagesEl.replaceChildren(fragment);
|
||||||
@@ -417,11 +407,10 @@ function normalizeServerAisMessage(msg: AisMessage): AisMessage {
|
|||||||
|
|
||||||
function onServerAisBatch(messages: AisMessage[]): void {
|
function onServerAisBatch(messages: AisMessage[]): void {
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
|
if (aisStatus) aisStatus.textContent = "Receiving";
|
||||||
const normalized: AisMessage[] = [];
|
const normalized: AisMessage[] = [];
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const next = normalizeServerAisMessage(msg);
|
const next = normalizeServerAisMessage(msg);
|
||||||
// "Receiving" is the panel's own status, and the panel is the rig's.
|
|
||||||
if (aisStatus && isActiveRigDecode(next.rig_id)) aisStatus.textContent = "Receiving";
|
|
||||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
next._tsMs = tsMs;
|
next._tsMs = tsMs;
|
||||||
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
@@ -463,9 +452,8 @@ if (aisFilterInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onServerAis(msg: AisMessage): void {
|
function onServerAis(msg: AisMessage): void {
|
||||||
const message = normalizeServerAisMessage(msg);
|
if (aisStatus) aisStatus.textContent = "Receiving";
|
||||||
if (aisStatus && isActiveRigDecode(message.rig_id)) aisStatus.textContent = "Receiving";
|
addAisMessage(normalizeServerAisMessage(msg));
|
||||||
addAisMessage(message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateAisSummary();
|
updateAisSummary();
|
||||||
@@ -476,7 +464,6 @@ updateAisSummary();
|
|||||||
restore: onServerAisBatch,
|
restore: onServerAisBatch,
|
||||||
reset: resetAisHistoryView,
|
reset: resetAisHistoryView,
|
||||||
prune: pruneAisHistoryView,
|
prune: pruneAisHistoryView,
|
||||||
rerender: () => { updateAisBar(); renderAisHistory(); },
|
|
||||||
// Oldest first, so vessel tracks are rebuilt in the order they happened.
|
// Oldest first, so vessel tracks are rebuilt in the order they happened.
|
||||||
syncMap: () => { for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry); },
|
syncMap: () => { for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry); },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -324,21 +324,6 @@ 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 {
|
||||||
@@ -385,7 +370,6 @@ 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>`;
|
||||||
|
|
||||||
@@ -397,17 +381,6 @@ 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) => {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
import { hostCore, hostState } from "./host.js";
|
import { hostCore, hostState } from "./host.js";
|
||||||
|
|
||||||
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
|
|
||||||
import {
|
import {
|
||||||
aprsAgeText,
|
aprsAgeText,
|
||||||
aprsPacketCategory,
|
aprsPacketCategory,
|
||||||
@@ -23,7 +22,6 @@ 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;
|
||||||
@@ -108,29 +106,21 @@ function aprsFilterMatch(pkt: AprsPacket): boolean {
|
|||||||
return haystack.includes(aprsFilterText);
|
return haystack.includes(aprsFilterText);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What the selected rig heard. The history holds every rig's traffic, for
|
|
||||||
* the map; the panel is about the rig the operator is working. */
|
|
||||||
function aprsRigPackets(): AprsPacket[] {
|
|
||||||
return forActiveRig(aprsPacketHistory);
|
|
||||||
}
|
|
||||||
|
|
||||||
function aprsVisiblePackets(): AprsPacket[] {
|
function aprsVisiblePackets(): AprsPacket[] {
|
||||||
const rigPackets = aprsRigPackets();
|
const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory;
|
||||||
const packets = aprsCollapseDup ? collapseAprsDuplicates(rigPackets) : rigPackets;
|
|
||||||
return packets.filter(aprsFilterMatch);
|
return packets.filter(aprsFilterMatch);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateAprsSummary() {
|
function updateAprsSummary() {
|
||||||
const rigPackets = aprsRigPackets();
|
|
||||||
const visible = aprsVisiblePackets();
|
const visible = aprsVisiblePackets();
|
||||||
if (aprsTotalCountEl) {
|
if (aprsTotalCountEl) {
|
||||||
aprsTotalCountEl.textContent = `${rigPackets.length} total`;
|
aprsTotalCountEl.textContent = `${aprsPacketHistory.length} total`;
|
||||||
}
|
}
|
||||||
if (aprsVisibleCountEl) {
|
if (aprsVisibleCountEl) {
|
||||||
aprsVisibleCountEl.textContent = `${visible.length} shown`;
|
aprsVisibleCountEl.textContent = `${visible.length} shown`;
|
||||||
}
|
}
|
||||||
if (aprsLatestSeenEl) {
|
if (aprsLatestSeenEl) {
|
||||||
const latest = rigPackets[0];
|
const latest = aprsPacketHistory[0];
|
||||||
if (!latest) {
|
if (!latest) {
|
||||||
aprsLatestSeenEl.textContent = "No packets yet";
|
aprsLatestSeenEl.textContent = "No packets yet";
|
||||||
} else {
|
} else {
|
||||||
@@ -165,9 +155,6 @@ 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" });
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,9 +179,7 @@ function updateAprsBar() {
|
|||||||
if (!aprsBarOverlay) return;
|
if (!aprsBarOverlay) return;
|
||||||
const isPkt = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "PKT";
|
const isPkt = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "PKT";
|
||||||
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
|
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
|
||||||
const okFrames = aprsPacketHistory.filter(
|
const okFrames = aprsPacketHistory.filter((p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs);
|
||||||
(p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(p.rig_id),
|
|
||||||
);
|
|
||||||
const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
|
const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
|
||||||
const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0);
|
const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0);
|
||||||
if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) {
|
if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) {
|
||||||
@@ -271,12 +256,11 @@ function normalizeServerAprsPacket(pkt: AprsPacket): AprsPacket {
|
|||||||
|
|
||||||
function onServerAprsBatch(packets: AprsPacket[]): void {
|
function onServerAprsBatch(packets: AprsPacket[]): void {
|
||||||
if (!Array.isArray(packets) || packets.length === 0) return;
|
if (!Array.isArray(packets) || packets.length === 0) return;
|
||||||
|
if (aprsStatus) aprsStatus.textContent = "Receiving";
|
||||||
const normalized: AprsPacket[] = [];
|
const normalized: AprsPacket[] = [];
|
||||||
let hasCrcOk = false;
|
let hasCrcOk = false;
|
||||||
for (const pkt of packets) {
|
for (const pkt of packets) {
|
||||||
const next = normalizeServerAprsPacket(pkt);
|
const next = normalizeServerAprsPacket(pkt);
|
||||||
// "Receiving" is the panel's own status, and the panel is the rig's.
|
|
||||||
if (aprsStatus && isActiveRigDecode(next.rig_id)) aprsStatus.textContent = "Receiving";
|
|
||||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
next._tsMs = tsMs;
|
next._tsMs = tsMs;
|
||||||
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
@@ -340,9 +324,8 @@ if (aprsFilterInput) {
|
|||||||
|
|
||||||
// --- Server-side APRS decode handler ---
|
// --- Server-side APRS decode handler ---
|
||||||
function onServerAprs(pkt: AprsPacket): void {
|
function onServerAprs(pkt: AprsPacket): void {
|
||||||
const packet = normalizeServerAprsPacket(pkt);
|
if (aprsStatus) aprsStatus.textContent = "Receiving";
|
||||||
if (aprsStatus && isActiveRigDecode(packet.rig_id)) aprsStatus.textContent = "Receiving";
|
addAprsPacket(normalizeServerAprsPacket(pkt));
|
||||||
addAprsPacket(packet);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
renderAprsHistory();
|
renderAprsHistory();
|
||||||
@@ -353,7 +336,6 @@ renderAprsHistory();
|
|||||||
restore: onServerAprsBatch,
|
restore: onServerAprsBatch,
|
||||||
reset: resetAprsHistoryView,
|
reset: resetAprsHistoryView,
|
||||||
prune: pruneAprsHistoryView,
|
prune: pruneAprsHistoryView,
|
||||||
rerender: () => { updateAprsBar(); renderAprsHistory(); },
|
|
||||||
// Oldest first, so station tracks are rebuilt in the order they happened.
|
// Oldest first, so station tracks are rebuilt in the order they happened.
|
||||||
syncMap: () => { for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry); },
|
syncMap: () => { for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry); },
|
||||||
});
|
});
|
||||||
|
|||||||
+65
-151
@@ -3,7 +3,6 @@
|
|||||||
// 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 {};
|
||||||
|
|
||||||
@@ -45,7 +44,7 @@ interface BackgroundBridge {
|
|||||||
trx?: { modules?: { backgroundDecode?: BackgroundDecodeService } };
|
trx?: { modules?: { backgroundDecode?: BackgroundDecodeService } };
|
||||||
}
|
}
|
||||||
interface BackgroundDecodeService {
|
interface BackgroundDecodeService {
|
||||||
initialize(rigId: string | null, roles: readonly AuthRole[]): void;
|
initialize(rigId: string | null, role: string | null): void;
|
||||||
wireEvents(): void;
|
wireEvents(): void;
|
||||||
setRig(rigId: string | null): void;
|
setRig(rigId: string | null): void;
|
||||||
}
|
}
|
||||||
@@ -61,23 +60,16 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
.map(function (d) { return d.id; });
|
.map(function (d) { return d.id; });
|
||||||
}
|
}
|
||||||
|
|
||||||
let backgroundDecodeRoles: readonly AuthRole[] = [];
|
let backgroundDecodeRole: string | null = null;
|
||||||
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[] = [];
|
||||||
let statusInterval: ReturnType<typeof setInterval> | null = null;
|
let statusInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
let bgdDirty = false;
|
let bgdDirty = false;
|
||||||
/** Last polled state per bookmark, so a row can say what it is doing. */
|
|
||||||
let statusByBookmark = new Map<string, BackgroundStatusEntry>();
|
|
||||||
let lastStatus: BackgroundDecodeStatus | null = null;
|
|
||||||
|
|
||||||
function initBackgroundDecode(rigId: string | null, roles: readonly AuthRole[]): void {
|
function initBackgroundDecode(rigId: string | null, role: string | null): void {
|
||||||
backgroundDecodeRoles = roles;
|
backgroundDecodeRole = role;
|
||||||
// The panel used to take whatever rig it was handed at load and wait to be
|
currentRigId = rigId || null;
|
||||||
// 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
|
|
||||||
// empty and silent. The host knows the rig; ask it.
|
|
||||||
currentRigId = rigId || hostState.lastActiveRigId || null;
|
|
||||||
if (currentRigId) loadBackgroundDecode();
|
if (currentRigId) loadBackgroundDecode();
|
||||||
startStatusPolling();
|
startStatusPolling();
|
||||||
}
|
}
|
||||||
@@ -179,7 +171,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
setCheckbox("background-decode-enabled", currentConfig.enabled);
|
setCheckbox("background-decode-enabled", currentConfig.enabled);
|
||||||
renderBookmarkChecklist();
|
renderBookmarkChecklist();
|
||||||
|
|
||||||
const isControl = isControlRole();
|
const isControl = backgroundDecodeRole === "control" || hostState.authEnabled === false;
|
||||||
const panel = document.getElementById("background-decode-panel");
|
const panel = document.getElementById("background-decode-panel");
|
||||||
if (panel) {
|
if (panel) {
|
||||||
panel.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLButtonElement>("input, select, button.sch-write").forEach(function (el) {
|
panel.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLButtonElement>("input, select, button.sch-write").forEach(function (el) {
|
||||||
@@ -190,11 +182,6 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
const resetBtn = document.getElementById("background-decode-reset-btn");
|
const resetBtn = document.getElementById("background-decode-reset-btn");
|
||||||
if (saveBtn) saveBtn.style.display = isControl ? "" : "none";
|
if (saveBtn) saveBtn.style.display = isControl ? "" : "none";
|
||||||
if (resetBtn) resetBtn.style.display = isControl ? "" : "none";
|
if (resetBtn) resetBtn.style.display = isControl ? "" : "none";
|
||||||
syncSaveButton();
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentFilterText(): string {
|
|
||||||
return (document.getElementById("bgd-bookmark-filter") as HTMLInputElement | null)?.value ?? "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderBookmarkChecklist(filterText = ""): void {
|
function renderBookmarkChecklist(filterText = ""): void {
|
||||||
@@ -216,65 +203,26 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
: all;
|
: all;
|
||||||
|
|
||||||
if (filtered.length === 0) {
|
if (filtered.length === 0) {
|
||||||
container.innerHTML = '<div class="bgd-checklist-empty">' + escHtml(emptyListText(all.length)) + "</div>";
|
container.innerHTML = '<div class="bgd-checklist-empty">' +
|
||||||
renderSelectionSummary();
|
(all.length === 0 ? "No supported bookmarks available." : "No bookmarks match filter.") +
|
||||||
|
'</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
filtered.forEach(function (bookmark) {
|
filtered.forEach(function (bookmark) {
|
||||||
const row = document.createElement("label");
|
const row = document.createElement("label");
|
||||||
row.className = "bgd-checklist-row";
|
row.className = "bgd-checklist-row";
|
||||||
row.dataset.bmId = bookmark.id;
|
|
||||||
const decoders = bookmarkDecoderKinds(bookmark);
|
const decoders = bookmarkDecoderKinds(bookmark);
|
||||||
const selected = selectedIds.has(bookmark.id);
|
const checked = selectedIds.has(bookmark.id) ? " checked" : "";
|
||||||
if (selected) row.classList.add("is-selected");
|
|
||||||
row.innerHTML =
|
row.innerHTML =
|
||||||
'<input type="checkbox"' + (selected ? " checked" : "") + ' data-bm-id="' + escHtml(bookmark.id) + '" />' +
|
'<input type="checkbox"' + checked + ' data-bm-id="' + escHtml(bookmark.id) + '" />' +
|
||||||
'<span class="bgd-checklist-name">' + escHtml(bookmark.name) + '</span>' +
|
'<span class="bgd-checklist-name">' + escHtml(bookmark.name) + '</span>' +
|
||||||
'<span class="bgd-checklist-meta">'
|
'<span class="bgd-checklist-meta">' + escHtml(formatFreq(bookmark.freq_hz) + " " + bookmark.mode + " · " + decoders.join("/").toUpperCase()) + '</span>';
|
||||||
+ escHtml(formatFreq(bookmark.freq_hz)) + '<span class="bgd-checklist-mode">'
|
|
||||||
+ escHtml(bookmark.mode + " · " + decoders.join("/").toUpperCase()) + '</span></span>'
|
|
||||||
+ stateBadgeHtml(bookmark.id, selected);
|
|
||||||
row.querySelector<HTMLInputElement>("input")?.addEventListener("change", function (e) {
|
row.querySelector<HTMLInputElement>("input")?.addEventListener("change", function (e) {
|
||||||
onChecklistToggle(bookmark.id, (e.currentTarget as HTMLInputElement).checked);
|
onChecklistToggle(bookmark.id, (e.currentTarget as HTMLInputElement).checked);
|
||||||
});
|
});
|
||||||
container.appendChild(row);
|
container.appendChild(row);
|
||||||
});
|
});
|
||||||
renderSelectionSummary();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Why the list is empty, in the terms the operator can act on. */
|
|
||||||
function emptyListText(supportedCount: number): string {
|
|
||||||
if (supportedCount > 0) return "No bookmark matches that filter.";
|
|
||||||
if (bookmarkList.length > 0) {
|
|
||||||
return "None of your bookmarks name a decoder that can run in the background."
|
|
||||||
+ " Give one a decoder on the Bookmarks tab to list it here.";
|
|
||||||
}
|
|
||||||
return "No bookmarks yet. Save one on the Bookmarks tab and it can be decoded here.";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The live state of a selected bookmark, as the row's own badge. */
|
|
||||||
function stateBadgeHtml(bookmarkId: string, selected: boolean): string {
|
|
||||||
if (!selected) return '<span class="bgd-state" data-state="unselected"></span>';
|
|
||||||
const entry = statusByBookmark.get(bookmarkId);
|
|
||||||
const state = entry?.state ?? (currentConfig?.enabled ? "pending" : "disabled");
|
|
||||||
return '<span class="bgd-state" data-state="' + escHtml(state) + '" title="' + escHtml(stateHelp(state)) + '">'
|
|
||||||
+ '<span class="bgd-state-dot"></span>' + escHtml(prettyState(state)) + "</span>";
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSelectionSummary(): void {
|
|
||||||
const el = document.getElementById("bgd-selection-summary");
|
|
||||||
if (!el) return;
|
|
||||||
const selected = currentConfig?.bookmark_ids.length ?? 0;
|
|
||||||
if (selected === 0) {
|
|
||||||
el.textContent = "Nothing selected — background decoding is idle.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const active = [...statusByBookmark.values()].filter((entry) => entry.state === "active").length;
|
|
||||||
const noun = `${String(selected)} bookmark${selected === 1 ? "" : "s"} selected`;
|
|
||||||
el.textContent = currentConfig?.enabled
|
|
||||||
? `${noun}, ${String(active)} decoding now.`
|
|
||||||
: `${noun}. Switch Enabled on to start decoding them.`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onChecklistToggle(bookmarkId: string, checked: boolean): void {
|
function onChecklistToggle(bookmarkId: string, checked: boolean): void {
|
||||||
@@ -312,7 +260,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
showToast(`Save failed: ${errorMessage(err)}`, true);
|
showToast(`Save failed: ${errorMessage(err)}`, true);
|
||||||
})
|
})
|
||||||
.finally(function () {
|
.finally(function () {
|
||||||
syncSaveButton();
|
if (btn) btn.disabled = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,77 +295,58 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderStatus(status: BackgroundDecodeStatus): void {
|
function renderStatus(status: BackgroundDecodeStatus): void {
|
||||||
lastStatus = status;
|
const card = document.getElementById("background-decode-status-card");
|
||||||
statusByBookmark = new Map(
|
if (!card) return;
|
||||||
(status.entries ?? [])
|
const entries = status.entries ?? [];
|
||||||
.filter((entry) => typeof entry.bookmark_id === "string" && entry.bookmark_id.length > 0)
|
if (!entries.length) {
|
||||||
.map((entry) => [entry.bookmark_id as string, entry]),
|
card.textContent = "No background decode bookmarks configured.";
|
||||||
);
|
|
||||||
renderSpanSummary();
|
|
||||||
// Rows carry the state, so the list repaints — keeping the filter the
|
|
||||||
// operator typed and the selection they have not saved yet.
|
|
||||||
renderBookmarkChecklist(currentFilterText());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What the rig is listening across, which is what decides whether a
|
|
||||||
* selected bookmark can be decoded at all. */
|
|
||||||
function renderSpanSummary(): void {
|
|
||||||
const el = document.getElementById("bgd-span-summary");
|
|
||||||
if (!el) return;
|
|
||||||
const status = lastStatus;
|
|
||||||
if (!status) {
|
|
||||||
el.textContent = "";
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!status.active_rig) {
|
const summary = [];
|
||||||
el.textContent = "This rig is not the one playing audio.";
|
if (status.active_rig) {
|
||||||
el.dataset.tone = "warn";
|
if (typeof status.center_hz === "number" && Number.isFinite(status.center_hz)) summary.push("Center " + formatFreq(status.center_hz));
|
||||||
return;
|
if (typeof status.sample_rate === "number" && Number.isFinite(status.sample_rate) && status.sample_rate > 0) summary.push("Span ±" + formatFreq(status.sample_rate / 2));
|
||||||
|
} else {
|
||||||
|
summary.push("This rig is not currently selected for audio.");
|
||||||
}
|
}
|
||||||
const centre = typeof status.center_hz === "number" && Number.isFinite(status.center_hz)
|
let html = summary.length ? '<div style="margin-bottom:0.8rem;color:var(--text-muted);">' + escHtml(summary.join(" · ")) + "</div>" : "";
|
||||||
? formatFreq(status.center_hz)
|
html += '<div class="bgd-status-list">';
|
||||||
: null;
|
entries.forEach(function (entry) {
|
||||||
const half = typeof status.sample_rate === "number" && Number.isFinite(status.sample_rate) && status.sample_rate > 0
|
const name = entry.bookmark_name || entry.bookmark_id || "Unknown bookmark";
|
||||||
? formatFreq(status.sample_rate / 2)
|
const parts = [];
|
||||||
: null;
|
if (typeof entry.freq_hz === "number" && Number.isFinite(entry.freq_hz)) parts.push(formatFreq(entry.freq_hz));
|
||||||
el.dataset.tone = "";
|
if (entry.mode) parts.push(entry.mode);
|
||||||
el.textContent = centre && half ? `Span ${centre} ±${half}` : centre ? `Centre ${centre}` : "";
|
if (Array.isArray(entry.decoder_kinds) && entry.decoder_kinds.length) {
|
||||||
|
parts.push(entry.decoder_kinds.join("/").toUpperCase());
|
||||||
|
}
|
||||||
|
html +=
|
||||||
|
'<div class="bgd-status-row">' +
|
||||||
|
'<div>' +
|
||||||
|
'<div class="bgd-status-name">' + escHtml(name) + '</div>' +
|
||||||
|
'<div class="bgd-status-meta">' + escHtml(parts.join(" · ")) + '</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="bgd-status-state" data-state="' + escHtml(entry.state || "inactive") + '">' +
|
||||||
|
'<svg class="bgd-state-dot" viewBox="0 0 8 8"><circle cx="4" cy="4" r="3.5"/></svg>' +
|
||||||
|
escHtml(prettyState(entry.state)) + '</div>' +
|
||||||
|
'</div>';
|
||||||
|
});
|
||||||
|
html += "</div>";
|
||||||
|
card.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The one-line reason behind a state, for the row's tooltip. */
|
|
||||||
function stateHelp(state: string | undefined): string {
|
|
||||||
switch (state) {
|
|
||||||
case "active": return "Decoding on a hidden channel.";
|
|
||||||
case "out_of_span": return "Outside the span the rig is tuned across, so it cannot be heard from here.";
|
|
||||||
case "waiting_for_spectrum": return "Waiting for the first spectrum frame from the rig.";
|
|
||||||
case "waiting_for_user": return "Nobody is listening to this rig, so no audio is being pulled.";
|
|
||||||
case "missing_bookmark": return "The bookmark this was selected from is gone.";
|
|
||||||
case "no_supported_decoders": return "No decoder that runs in the background can decode this bookmark.";
|
|
||||||
case "disabled": return "Background decoding is switched off.";
|
|
||||||
case "handled_by_scheduler":
|
|
||||||
case "scheduler_has_control": return "The scheduler is running this bookmark instead.";
|
|
||||||
case "handled_by_virtual_channel": return "A virtual channel is already on this frequency.";
|
|
||||||
case "pending": return "Selected, and not started yet — save to apply.";
|
|
||||||
default: return "Selected, but not decoding.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The dot beside the word already says whether this is running, so the
|
|
||||||
// words no longer carry a tick or a triangle of their own.
|
|
||||||
function prettyState(state: string | undefined): string {
|
function prettyState(state: string | undefined): string {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case "active": return "Decoding";
|
case "active": return "\u2713 Active";
|
||||||
case "out_of_span": return "Out of span";
|
case "out_of_span": return "\u25B3 Out of span";
|
||||||
case "waiting_for_spectrum": return "Waiting for spectrum";
|
case "waiting_for_spectrum": return "\u25B3 Waiting";
|
||||||
case "waiting_for_user": return "Nobody listening";
|
case "waiting_for_user": return "\u25B3 No user";
|
||||||
case "missing_bookmark": return "Bookmark gone";
|
case "missing_bookmark": return "\u2717 Missing";
|
||||||
case "no_supported_decoders": return "No decoder";
|
case "no_supported_decoders": return "\u2717 Unsupported";
|
||||||
case "disabled": return "Off";
|
case "disabled": return "\u25B3 Disabled";
|
||||||
case "handled_by_scheduler":
|
case "handled_by_scheduler": return "\u25B3 Scheduler";
|
||||||
case "scheduler_has_control": return "Scheduler has it";
|
case "scheduler_has_control": return "\u25B3 Scheduler";
|
||||||
case "handled_by_virtual_channel": return "On a channel";
|
case "handled_by_virtual_channel": return "\u25B3 VChan";
|
||||||
case "pending": return "Not saved";
|
default: return "\u25B3 Inactive";
|
||||||
default: return "Idle";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,25 +380,14 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
function markBgdDirty() {
|
function markBgdDirty() {
|
||||||
if (bgdDirty) return;
|
if (bgdDirty) return;
|
||||||
bgdDirty = true;
|
bgdDirty = true;
|
||||||
syncSaveButton();
|
const btn = document.getElementById("background-decode-save-btn");
|
||||||
|
if (btn) btn.classList.add("sch-dirty");
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearBgdDirty() {
|
function clearBgdDirty() {
|
||||||
bgdDirty = false;
|
bgdDirty = false;
|
||||||
syncSaveButton();
|
const btn = document.getElementById("background-decode-save-btn");
|
||||||
}
|
if (btn) btn.classList.remove("sch-dirty");
|
||||||
|
|
||||||
/** Save offers itself only when there is something to save. */
|
|
||||||
function syncSaveButton() {
|
|
||||||
const btn = document.getElementById("background-decode-save-btn") as HTMLButtonElement | null;
|
|
||||||
if (!btn) return;
|
|
||||||
btn.classList.toggle("sch-dirty", bgdDirty);
|
|
||||||
btn.disabled = !bgdDirty || !isControlRole();
|
|
||||||
btn.title = bgdDirty ? "Apply these bookmarks to the background decoder" : "No changes to save";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isControlRole(): boolean {
|
|
||||||
return hasAuthRole(backgroundDecodeRoles, "control") || hostState.authEnabled === false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showToast(msg: string, isError: boolean): void {
|
function showToast(msg: string, isError: boolean): void {
|
||||||
@@ -489,7 +407,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
}
|
}
|
||||||
const ids = supportedBookmarks().map(function (bm) { return bm.id; });
|
const ids = supportedBookmarks().map(function (bm) { return bm.id; });
|
||||||
currentConfig.bookmark_ids = ids;
|
currentConfig.bookmark_ids = ids;
|
||||||
renderBookmarkChecklist(currentFilterText());
|
renderBookmarkChecklist((document.getElementById("bgd-bookmark-filter") as HTMLInputElement | null)?.value);
|
||||||
markBgdDirty();
|
markBgdDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -498,7 +416,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
||||||
}
|
}
|
||||||
currentConfig.bookmark_ids = [];
|
currentConfig.bookmark_ids = [];
|
||||||
renderBookmarkChecklist(currentFilterText());
|
renderBookmarkChecklist((document.getElementById("bgd-bookmark-filter") as HTMLInputElement | null)?.value);
|
||||||
markBgdDirty();
|
markBgdDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,11 +432,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
const enabledCb = document.getElementById("background-decode-enabled") as (HTMLInputElement & WiredElement) | null;
|
const enabledCb = document.getElementById("background-decode-enabled") as (HTMLInputElement & WiredElement) | null;
|
||||||
if (enabledCb && !enabledCb._wired) {
|
if (enabledCb && !enabledCb._wired) {
|
||||||
enabledCb._wired = true;
|
enabledCb._wired = true;
|
||||||
enabledCb.addEventListener("change", function () {
|
enabledCb.addEventListener("change", function () { markBgdDirty(); });
|
||||||
if (currentConfig) currentConfig.enabled = enabledCb.checked;
|
|
||||||
markBgdDirty();
|
|
||||||
renderBookmarkChecklist(currentFilterText());
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectAllBtn = document.getElementById("bgd-select-all-btn") as WiredElement | null;
|
const selectAllBtn = document.getElementById("bgd-select-all-btn") as WiredElement | null;
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
// 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 {};
|
||||||
|
|
||||||
@@ -19,8 +18,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,8 +100,7 @@ function bmEsc(str: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function bmCanControl() {
|
function bmCanControl() {
|
||||||
return !hostState.authEnabled
|
return !hostState.authEnabled || hostState.authRole === "control";
|
||||||
|| 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.
|
||||||
@@ -323,14 +319,6 @@ function bmBuildDecoderCheckboxes() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Show the DIG-sideband override selector only when the mode field is DIG. */
|
|
||||||
function bmSyncDigSidebandVisibility(): void {
|
|
||||||
const label = bmOptionalEl("bm-dig-sideband-label");
|
|
||||||
if (!label) return;
|
|
||||||
const mode = (bmEl("bm-mode").value || "").trim().toUpperCase();
|
|
||||||
label.style.display = mode === "DIG" ? "" : "none";
|
|
||||||
}
|
|
||||||
|
|
||||||
function bmOpenForm(bm: Bookmark | null): void {
|
function bmOpenForm(bm: Bookmark | null): void {
|
||||||
const wrap = bmEl("bm-form-wrap");
|
const wrap = bmEl("bm-form-wrap");
|
||||||
if (!wrap) return;
|
if (!wrap) return;
|
||||||
@@ -348,9 +336,7 @@ 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";
|
||||||
@@ -395,9 +381,6 @@ 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 = "";
|
||||||
@@ -418,7 +401,6 @@ async function bmSave(e: Event): Promise<void> {
|
|||||||
category,
|
category,
|
||||||
comment,
|
comment,
|
||||||
decoders,
|
decoders,
|
||||||
dig_sideband,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -493,16 +475,6 @@ 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));
|
||||||
@@ -796,9 +768,6 @@ 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);
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
import { hostCore } from "./host.js";
|
import { hostCore } from "./host.js";
|
||||||
|
|
||||||
import { isActiveRigDecode } from "./active-rig.js";
|
|
||||||
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
@@ -21,11 +20,8 @@ interface CwRenderer {
|
|||||||
drawPoints(points: number[], size: number, color: Rgba): void;
|
drawPoints(points: number[], size: number, color: Rgba): void;
|
||||||
}
|
}
|
||||||
interface CwSpectrum { bins: number[]; sample_rate: number; center_hz: number }
|
interface CwSpectrum { bins: number[]; sample_rate: number; center_hz: number }
|
||||||
interface CwEvent { rig_id?: string | null; text?: string; wpm?: number; tone_hz?: number; signal_on?: boolean }
|
interface CwEvent { text?: string; wpm?: number; tone_hz?: number; signal_on?: boolean }
|
||||||
interface CwLine {
|
interface CwLine { tsMs: number; ts: string; text: string; wpm: number | null; tone_hz: number | null; lastMs: number }
|
||||||
rigId: string | null; tsMs: number; ts: string; text: string;
|
|
||||||
wpm: number | null; tone_hz: number | null; lastMs: number;
|
|
||||||
}
|
|
||||||
interface CwToneRange {
|
interface CwToneRange {
|
||||||
tunedHz: number; bandwidthHz: number; toneMinHz: number; toneMaxHz: number;
|
tunedHz: number; bandwidthHz: number; toneMinHz: number; toneMaxHz: number;
|
||||||
toneSpanHz: number; lowerSideband: boolean; mode: string;
|
toneSpanHz: number; lowerSideband: boolean; mode: string;
|
||||||
@@ -70,10 +66,7 @@ const CW_BAR_LINE_GAP_MS = 5000;
|
|||||||
let cwLastAppendTime = 0;
|
let cwLastAppendTime = 0;
|
||||||
let cwTonePickerRaf: number | null = null;
|
let cwTonePickerRaf: number | null = null;
|
||||||
let cwBarHistory: CwLine[] = [];
|
let cwBarHistory: CwLine[] = [];
|
||||||
// One line in progress per rig, keyed by rig id ("" for a decode that names no
|
let cwBarCurrentLine: CwLine | null = null;
|
||||||
// rig). Two rigs copying at the same time each fill their own line instead of
|
|
||||||
// braiding their characters into one.
|
|
||||||
const cwBarCurrentLines = new Map<string, CwLine>();
|
|
||||||
let cwBarDismissedAtMs = 0;
|
let cwBarDismissedAtMs = 0;
|
||||||
// Tracks a user-initiated auto toggle that is in-flight (POST not yet
|
// Tracks a user-initiated auto toggle that is in-flight (POST not yet
|
||||||
// acknowledged). While set, server-state updates must not override the
|
// acknowledged). While set, server-state updates must not override the
|
||||||
@@ -108,13 +101,12 @@ cwWindow.applyCwAutoUiFromServer = function(enabled: boolean) {
|
|||||||
applyCwAutoUi(enabled);
|
applyCwAutoUi(enabled);
|
||||||
};
|
};
|
||||||
|
|
||||||
function cwBarFlushCurrentLine(key: string): void {
|
function cwBarFlushCurrentLine(): void {
|
||||||
const line = cwBarCurrentLines.get(key);
|
if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) {
|
||||||
cwBarCurrentLines.delete(key);
|
cwBarHistory.unshift(cwBarCurrentLine);
|
||||||
if (line?.text.trim()) {
|
|
||||||
cwBarHistory.unshift(line);
|
|
||||||
if (cwBarHistory.length > 50) cwBarHistory.length = 50;
|
if (cwBarHistory.length > 50) cwBarHistory.length = 50;
|
||||||
}
|
}
|
||||||
|
cwBarCurrentLine = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateCwBar(): void {
|
function updateCwBar(): void {
|
||||||
@@ -122,12 +114,9 @@ function updateCwBar(): void {
|
|||||||
const mode = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase();
|
const mode = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase();
|
||||||
const isCw = mode === "CW" || mode === "CWR";
|
const isCw = mode === "CW" || mode === "CWR";
|
||||||
const cutoffMs = Date.now() - CW_BAR_WINDOW_MS;
|
const cutoffMs = Date.now() - CW_BAR_WINDOW_MS;
|
||||||
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs && isActiveRigDecode(l.rigId));
|
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs);
|
||||||
// Prepend the in-progress lines so characters appear immediately
|
// Prepend the in-progress line so characters appear immediately
|
||||||
const inProgress = [...cwBarCurrentLines.values()]
|
const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent;
|
||||||
.filter((l) => l.text && isActiveRigDecode(l.rigId))
|
|
||||||
.sort((a, b) => b.tsMs - a.tsMs);
|
|
||||||
const liveLines = [...inProgress, ...recent];
|
|
||||||
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0);
|
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0);
|
||||||
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
|
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
|
||||||
cwBarOverlay.style.display = "none";
|
cwBarOverlay.style.display = "none";
|
||||||
@@ -413,7 +402,7 @@ function resetCwHistoryView(): void {
|
|||||||
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
||||||
cwLastAppendTime = 0;
|
cwLastAppendTime = 0;
|
||||||
cwBarHistory = [];
|
cwBarHistory = [];
|
||||||
cwBarCurrentLines.clear();
|
cwBarCurrentLine = null;
|
||||||
updateCwBar();
|
updateCwBar();
|
||||||
drawCwTonePicker();
|
drawCwTonePicker();
|
||||||
}
|
}
|
||||||
@@ -432,13 +421,8 @@ document.getElementById("settings-clear-cw-history")?.addEventListener("click",
|
|||||||
|
|
||||||
// --- Server-side CW decode handler ---
|
// --- Server-side CW decode handler ---
|
||||||
function onServerCw(evt: CwEvent): void {
|
function onServerCw(evt: CwEvent): void {
|
||||||
// The panel is a single stream of copied text: characters from a background
|
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
||||||
// rig do not belong in it at all, and interleaving them would make both
|
if (evt.text && cwOutputEl) {
|
||||||
// rigs unreadable. The bar histories below keep every rig, each on its own
|
|
||||||
// line, and pick out the selected rig when they paint.
|
|
||||||
const forSelectedRig = isActiveRigDecode(evt.rig_id ?? null);
|
|
||||||
if (cwStatusEl && forSelectedRig) cwStatusEl.textContent = "Receiving";
|
|
||||||
if (evt.text && cwOutputEl && forSelectedRig) {
|
|
||||||
// Append decoded text to output
|
// Append decoded text to output
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 10000 || evt.text === "\n") {
|
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 10000 || evt.text === "\n") {
|
||||||
@@ -461,29 +445,25 @@ function onServerCw(evt: CwEvent): void {
|
|||||||
// Bar history accumulation (regardless of pause state)
|
// Bar history accumulation (regardless of pause state)
|
||||||
if (evt.text) {
|
if (evt.text) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const rigId = evt.rig_id ?? null;
|
|
||||||
const key = rigId ?? "";
|
|
||||||
if (evt.text === "\n") {
|
if (evt.text === "\n") {
|
||||||
cwBarFlushCurrentLine(key);
|
cwBarFlushCurrentLine();
|
||||||
} else {
|
} else {
|
||||||
let line = cwBarCurrentLines.get(key);
|
if (!cwBarCurrentLine || now - cwBarCurrentLine.lastMs > CW_BAR_LINE_GAP_MS) {
|
||||||
if (!line || now - line.lastMs > CW_BAR_LINE_GAP_MS) {
|
cwBarFlushCurrentLine();
|
||||||
cwBarFlushCurrentLine(key);
|
|
||||||
const ts = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
const ts = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
line = { rigId, tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now };
|
cwBarCurrentLine = { tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now };
|
||||||
cwBarCurrentLines.set(key, line);
|
|
||||||
}
|
}
|
||||||
line.text += evt.text;
|
cwBarCurrentLine.text += evt.text;
|
||||||
line.lastMs = now;
|
cwBarCurrentLine.lastMs = now;
|
||||||
if (Number.isFinite(Number(evt.wpm))) line.wpm = clampCwWpm(evt.wpm);
|
if (Number.isFinite(Number(evt.wpm))) cwBarCurrentLine.wpm = clampCwWpm(evt.wpm);
|
||||||
if (Number.isFinite(Number(evt.tone_hz))) line.tone_hz = Math.round(Number(evt.tone_hz));
|
if (Number.isFinite(Number(evt.tone_hz))) cwBarCurrentLine.tone_hz = Math.round(Number(evt.tone_hz));
|
||||||
}
|
}
|
||||||
updateCwBar();
|
updateCwBar();
|
||||||
}
|
}
|
||||||
if (cwSignalIndicator && forSelectedRig) {
|
if (cwSignalIndicator) {
|
||||||
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
|
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
|
||||||
}
|
}
|
||||||
if (forSelectedRig && (!cwAutoInput || cwAutoInput.checked)) {
|
if (!cwAutoInput || cwAutoInput.checked) {
|
||||||
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
|
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
|
||||||
cwWpmInput.value = String(clampCwWpm(evt.wpm));
|
cwWpmInput.value = String(clampCwWpm(evt.wpm));
|
||||||
}
|
}
|
||||||
@@ -511,13 +491,6 @@ cwWindow.trxPluginRuntime.registerDecoder({
|
|||||||
onMessage: onServerCw,
|
onMessage: onServerCw,
|
||||||
restore: restoreCwHistory,
|
restore: restoreCwHistory,
|
||||||
reset: resetCwHistoryView,
|
reset: resetCwHistoryView,
|
||||||
// The copied text of a rig that is no longer on screen cannot be unpicked
|
|
||||||
// from the pane, so the switch starts the new rig's stream from empty.
|
|
||||||
rerender: () => {
|
|
||||||
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
|
||||||
cwLastAppendTime = 0;
|
|
||||||
updateCwBar();
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
cwWindow.refreshCwTonePicker = function refreshCwTonePicker() {
|
cwWindow.refreshCwTonePicker = function refreshCwTonePicker() {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
import { hostCore } from "./host.js";
|
import { hostCore } from "./host.js";
|
||||||
|
|
||||||
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
|
|
||||||
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
||||||
|
|
||||||
export type FtxDecoderId = "ft2" | "ft4" | "ft8";
|
export type FtxDecoderId = "ft2" | "ft4" | "ft8";
|
||||||
@@ -16,7 +15,6 @@ export interface FtxMessage {
|
|||||||
snr_db?: number | undefined;
|
snr_db?: number | undefined;
|
||||||
dt_s?: number | undefined;
|
dt_s?: number | undefined;
|
||||||
freq_hz?: number | undefined;
|
freq_hz?: number | undefined;
|
||||||
rig_id?: string | null | undefined;
|
|
||||||
receiver?: unknown;
|
receiver?: unknown;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
@@ -49,8 +47,6 @@ 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;
|
||||||
@@ -226,37 +222,14 @@ 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 => {
|
||||||
prune();
|
prune();
|
||||||
if (!messagesElement) return;
|
if (!messagesElement) return;
|
||||||
// The history holds every rig's decodes, for the map; the panel is about
|
|
||||||
// the rig the operator is working.
|
|
||||||
const rigMessages = forActiveRig(history);
|
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const message of rigMessages) {
|
for (const message of history) {
|
||||||
if (count >= 200) break;
|
if (count >= 200) break;
|
||||||
if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue;
|
if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue;
|
||||||
fragment.appendChild(renderRow(message));
|
fragment.appendChild(renderRow(message));
|
||||||
@@ -264,32 +237,20 @@ export function initializeFtxDecoder(config: FtxConfig): void {
|
|||||||
}
|
}
|
||||||
messagesElement.replaceChildren(fragment);
|
messagesElement.replaceChildren(fragment);
|
||||||
};
|
};
|
||||||
/** Hands a decode's grid squares to the map, if the map module is loaded yet.
|
const normalize = (message: FtxMessage): FtxMessage => {
|
||||||
* Split out of normalize so a decode can be replayed onto a map that
|
|
||||||
* arrived later: the module is lazy, and everything decoded before it
|
|
||||||
* loaded had nowhere to go. */
|
|
||||||
const plotLocator = (message: FtxMessage): void => {
|
|
||||||
const raw = message.message ?? "";
|
const raw = message.message ?? "";
|
||||||
const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
|
const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
|
||||||
const grids = locatorDetails.length > 0
|
const grids = locatorDetails.length > 0
|
||||||
? locatorDetails.map(({ grid }) => grid)
|
? locatorDetails.map(({ grid }) => grid)
|
||||||
: bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw);
|
: bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw);
|
||||||
if (grids.length === 0) return;
|
|
||||||
const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
|
const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
|
||||||
// Already an RF frequency on a replay, an audio offset on arrival; the
|
|
||||||
// conversion only fires below 100 kHz, so it is right either way.
|
|
||||||
const frequency = displayFrequency(message.freq_hz);
|
const frequency = displayFrequency(message.freq_hz);
|
||||||
bridge.mapAddLocator?.(raw, grids, id, station, {
|
if (grids.length > 0) {
|
||||||
...message, freq_hz: frequency ?? message.freq_hz, locator_details: locatorDetails,
|
bridge.mapAddLocator?.(raw, grids, id, station, {
|
||||||
});
|
...message, freq_hz: frequency ?? message.freq_hz, locator_details: locatorDetails,
|
||||||
};
|
});
|
||||||
const normalize = (message: FtxMessage): FtxMessage => {
|
}
|
||||||
const frequency = displayFrequency(message.freq_hz);
|
|
||||||
plotLocator(message);
|
|
||||||
return {
|
return {
|
||||||
// The rig that heard it, kept so the mini view can tell a decode of the
|
|
||||||
// rig on screen from one a background rig made on another band.
|
|
||||||
rig_id: message.rig_id ?? null,
|
|
||||||
receiver: bridge.getDecodeRigMeta?.() ?? null,
|
receiver: bridge.getDecodeRigMeta?.() ?? null,
|
||||||
ts_ms: message.ts_ms,
|
ts_ms: message.ts_ms,
|
||||||
snr_db: message.snr_db,
|
snr_db: message.snr_db,
|
||||||
@@ -301,10 +262,7 @@ export function initializeFtxDecoder(config: FtxConfig): void {
|
|||||||
};
|
};
|
||||||
const receiveBatch = (messages: FtxMessage[]): void => {
|
const receiveBatch = (messages: FtxMessage[]): void => {
|
||||||
if (messages.length === 0) return;
|
if (messages.length === 0) return;
|
||||||
// "Receiving" is the panel's own status, and the panel is the rig's.
|
if (status) status.textContent = "Receiving";
|
||||||
if (status && messages.some((message) => isActiveRigDecode(message.rig_id))) {
|
|
||||||
status.textContent = "Receiving";
|
|
||||||
}
|
|
||||||
history = messages.map(normalize).reverse().concat(history);
|
history = messages.map(normalize).reverse().concat(history);
|
||||||
prune();
|
prune();
|
||||||
bridge.setFt8FamilyBarDecoder?.(id);
|
bridge.setFt8FamilyBarDecoder?.(id);
|
||||||
@@ -319,8 +277,7 @@ export function initializeFtxDecoder(config: FtxConfig): void {
|
|||||||
};
|
};
|
||||||
const barFrames = (): BarFrames => {
|
const barFrames = (): BarFrames => {
|
||||||
const recent = history
|
const recent = history
|
||||||
.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 900_000
|
.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 900_000)
|
||||||
&& isActiveRigDecode(message.rig_id))
|
|
||||||
.slice(0, 8);
|
.slice(0, 8);
|
||||||
let html = "";
|
let html = "";
|
||||||
for (const message of recent) {
|
for (const message of recent) {
|
||||||
@@ -342,9 +299,6 @@ export function initializeFtxDecoder(config: FtxConfig): void {
|
|||||||
restore: receiveBatch,
|
restore: receiveBatch,
|
||||||
prune: () => { prune(); render(); },
|
prune: () => { prune(); render(); },
|
||||||
reset,
|
reset,
|
||||||
rerender: () => { bridge.updateFt8Bar?.(); render(); },
|
|
||||||
// Oldest first, so the map builds the grids up in the order they were heard.
|
|
||||||
syncMap: () => { for (const message of [...history].reverse()) plotLocator(message); },
|
|
||||||
});
|
});
|
||||||
bridge.registerFt8FamilyBarRenderer?.(id, barFrames);
|
bridge.registerFt8FamilyBarRenderer?.(id, barFrames);
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
import { hostCore, hostState } from "./host.js";
|
import { hostCore, hostState } from "./host.js";
|
||||||
|
|
||||||
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
|
|
||||||
import {
|
import {
|
||||||
aprsAgeText,
|
aprsAgeText,
|
||||||
aprsPacketCategory,
|
aprsPacketCategory,
|
||||||
@@ -20,12 +19,6 @@ interface HfAprsBridge {
|
|||||||
getDecodeHistoryRetentionMs?: () => number;
|
getDecodeHistoryRetentionMs?: () => number;
|
||||||
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
|
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
|
||||||
navigateToAprsMap?: (lat: number, lon: number) => void;
|
navigateToAprsMap?: (lat: number, lon: number) => void;
|
||||||
aprsMapAddStation?: (
|
|
||||||
call: string, lat: number, lon: number, info: string,
|
|
||||||
symbolTable: string | null | undefined, symbolCode: string | null | undefined,
|
|
||||||
packet: AprsPacket, source: "aprs" | "hf_aprs",
|
|
||||||
) => void;
|
|
||||||
clearMapMarkersByType?: (type: string) => void;
|
|
||||||
getDecodeRigMeta?: () => unknown;
|
getDecodeRigMeta?: () => unknown;
|
||||||
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<unknown>;
|
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<unknown>;
|
||||||
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
|
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
|
||||||
@@ -97,31 +90,23 @@ function hfAprsFilterMatch(pkt: AprsPacket): boolean {
|
|||||||
return haystack.includes(hfAprsFilterText);
|
return haystack.includes(hfAprsFilterText);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What the selected rig heard. The history holds every rig's traffic, for
|
|
||||||
* the map; the panel is about the rig the operator is working. */
|
|
||||||
function hfAprsRigPackets(): AprsPacket[] {
|
|
||||||
return forActiveRig(hfAprsPacketHistory);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hfAprsVisiblePackets(): AprsPacket[] {
|
function hfAprsVisiblePackets(): AprsPacket[] {
|
||||||
const rigPackets = hfAprsRigPackets();
|
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory;
|
||||||
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(rigPackets) : rigPackets;
|
|
||||||
return packets.filter(hfAprsFilterMatch);
|
return packets.filter(hfAprsFilterMatch);
|
||||||
}
|
}
|
||||||
|
|
||||||
const collapseHfAprsDuplicates = collapseAprsDuplicates;
|
const collapseHfAprsDuplicates = collapseAprsDuplicates;
|
||||||
|
|
||||||
function updateHfAprsSummary() {
|
function updateHfAprsSummary() {
|
||||||
const rigPackets = hfAprsRigPackets();
|
|
||||||
const visible = hfAprsVisiblePackets();
|
const visible = hfAprsVisiblePackets();
|
||||||
if (hfAprsTotalCountEl) {
|
if (hfAprsTotalCountEl) {
|
||||||
hfAprsTotalCountEl.textContent = `${rigPackets.length} total`;
|
hfAprsTotalCountEl.textContent = `${hfAprsPacketHistory.length} total`;
|
||||||
}
|
}
|
||||||
if (hfAprsVisibleCountEl) {
|
if (hfAprsVisibleCountEl) {
|
||||||
hfAprsVisibleCountEl.textContent = `${visible.length} shown`;
|
hfAprsVisibleCountEl.textContent = `${visible.length} shown`;
|
||||||
}
|
}
|
||||||
if (hfAprsLatestSeenEl) {
|
if (hfAprsLatestSeenEl) {
|
||||||
const latest = rigPackets[0];
|
const latest = hfAprsPacketHistory[0];
|
||||||
if (!latest) {
|
if (!latest) {
|
||||||
hfAprsLatestSeenEl.textContent = "No packets yet";
|
hfAprsLatestSeenEl.textContent = "No packets yet";
|
||||||
} else {
|
} else {
|
||||||
@@ -183,7 +168,6 @@ function resetHfAprsHistoryView(): void {
|
|||||||
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
|
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
|
||||||
hfAprsPacketHistory = [];
|
hfAprsPacketHistory = [];
|
||||||
renderHfAprsHistory();
|
renderHfAprsHistory();
|
||||||
hfAprsWindow.clearMapMarkersByType?.("hf_aprs");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function pruneHfAprsHistoryView(): void {
|
function pruneHfAprsHistoryView(): void {
|
||||||
@@ -191,17 +175,6 @@ function pruneHfAprsHistoryView(): void {
|
|||||||
renderHfAprsHistory();
|
renderHfAprsHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Hands a positioned packet to the map, if the map module is loaded yet.
|
|
||||||
* HF traffic goes on as its own source: it is a different band and a
|
|
||||||
* different path, and the map filter offers it separately. */
|
|
||||||
function plotHfAprsPacket(pkt: AprsPacket): void {
|
|
||||||
if (pkt.lat == null || pkt.lon == null || !hfAprsWindow.aprsMapAddStation) return;
|
|
||||||
hfAprsWindow.aprsMapAddStation(
|
|
||||||
pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "",
|
|
||||||
pkt.symbolTable, pkt.symbolCode, pkt, "hf_aprs",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addHfAprsPacket(pkt: AprsPacket): void {
|
function addHfAprsPacket(pkt: AprsPacket): void {
|
||||||
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
||||||
pkt._tsMs = tsMs;
|
pkt._tsMs = tsMs;
|
||||||
@@ -210,8 +183,6 @@ function addHfAprsPacket(pkt: AprsPacket): void {
|
|||||||
hfAprsPacketHistory.unshift(pkt);
|
hfAprsPacketHistory.unshift(pkt);
|
||||||
pruneHfAprsPacketHistory();
|
pruneHfAprsPacketHistory();
|
||||||
|
|
||||||
plotHfAprsPacket(pkt);
|
|
||||||
|
|
||||||
scheduleHfAprsHistoryRender();
|
scheduleHfAprsHistoryRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,15 +192,13 @@ function normalizeServerHfAprsPacket(pkt: AprsPacket): AprsPacket {
|
|||||||
|
|
||||||
function onServerHfAprsBatch(packets: AprsPacket[]): void {
|
function onServerHfAprsBatch(packets: AprsPacket[]): void {
|
||||||
if (!Array.isArray(packets) || packets.length === 0) return;
|
if (!Array.isArray(packets) || packets.length === 0) return;
|
||||||
|
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
|
||||||
const normalized: AprsPacket[] = [];
|
const normalized: AprsPacket[] = [];
|
||||||
for (const pkt of packets) {
|
for (const pkt of packets) {
|
||||||
const next = normalizeServerHfAprsPacket(pkt);
|
const next = normalizeServerHfAprsPacket(pkt);
|
||||||
// "Receiving" is the panel's own status, and the panel is the rig's.
|
|
||||||
if (hfAprsStatus && isActiveRigDecode(next.rig_id)) hfAprsStatus.textContent = "Receiving";
|
|
||||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
next._tsMs = tsMs;
|
next._tsMs = tsMs;
|
||||||
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
plotHfAprsPacket(next);
|
|
||||||
normalized.push(next);
|
normalized.push(next);
|
||||||
}
|
}
|
||||||
normalized.reverse();
|
normalized.reverse();
|
||||||
@@ -297,9 +266,8 @@ if (hfAprsFilterInput) {
|
|||||||
|
|
||||||
// --- Server-side HF APRS decode handler ---
|
// --- Server-side HF APRS decode handler ---
|
||||||
function onServerHfAprs(pkt: AprsPacket): void {
|
function onServerHfAprs(pkt: AprsPacket): void {
|
||||||
const packet = normalizeServerHfAprsPacket(pkt);
|
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
|
||||||
if (hfAprsStatus && isActiveRigDecode(packet.rig_id)) hfAprsStatus.textContent = "Receiving";
|
addHfAprsPacket(normalizeServerHfAprsPacket(pkt));
|
||||||
addHfAprsPacket(packet);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
renderHfAprsHistory();
|
renderHfAprsHistory();
|
||||||
@@ -310,7 +278,4 @@ renderHfAprsHistory();
|
|||||||
restore: onServerHfAprsBatch,
|
restore: onServerHfAprsBatch,
|
||||||
reset: resetHfAprsHistoryView,
|
reset: resetHfAprsHistoryView,
|
||||||
prune: pruneHfAprsHistoryView,
|
prune: pruneHfAprsHistoryView,
|
||||||
rerender: renderHfAprsHistory,
|
|
||||||
// Oldest first, so station tracks are rebuilt in the order they happened.
|
|
||||||
syncMap: () => { for (const entry of [...hfAprsPacketHistory].reverse()) plotHfAprsPacket(entry); },
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,8 +11,6 @@
|
|||||||
// 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;
|
||||||
@@ -24,10 +22,8 @@ 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 authRoles: readonly AuthRole[];
|
readonly authRole: string | null;
|
||||||
readonly lastActiveRigId: string | null;
|
readonly lastActiveRigId: string | null;
|
||||||
readonly lastRigIds: string[];
|
readonly lastRigIds: string[];
|
||||||
readonly lastRigDisplayNames: Record<string, string>;
|
readonly lastRigDisplayNames: Record<string, string>;
|
||||||
@@ -47,7 +43,6 @@ 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;
|
||||||
|
|||||||
@@ -1,577 +0,0 @@
|
|||||||
// 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();
|
|
||||||
@@ -9,10 +9,6 @@ export interface DecoderPlugin<TMessage = unknown> {
|
|||||||
restore?(messages: TMessage[]): void;
|
restore?(messages: TMessage[]): void;
|
||||||
reset?(): void;
|
reset?(): void;
|
||||||
prune?(): void;
|
prune?(): void;
|
||||||
/** Repaint the plugin's panel from what it already holds. Called when the
|
|
||||||
* selected rig changes: the panels show one rig's traffic, and the decodes
|
|
||||||
* behind them arrived long before the switch. */
|
|
||||||
rerender?(): void;
|
|
||||||
/** Replay everything the plugin is holding onto the map. Called when the map
|
/** Replay everything the plugin is holding onto the map. Called when the map
|
||||||
* module attaches, which can happen long after the decodes arrived. */
|
* module attaches, which can happen long after the decodes arrived. */
|
||||||
syncMap?(): void;
|
syncMap?(): void;
|
||||||
@@ -26,7 +22,6 @@ export interface TrxPluginRuntime {
|
|||||||
reset(id: string): boolean;
|
reset(id: string): boolean;
|
||||||
resetAll(): void;
|
resetAll(): void;
|
||||||
prune(id: string): boolean;
|
prune(id: string): boolean;
|
||||||
rerenderAll(): void;
|
|
||||||
syncMapAll(): void;
|
syncMapAll(): void;
|
||||||
clearQueued(): void;
|
clearQueued(): void;
|
||||||
hasDecoder(id: string): boolean;
|
hasDecoder(id: string): boolean;
|
||||||
|
|||||||
@@ -4,12 +4,13 @@
|
|||||||
|
|
||||||
import { hostCore } from "./host.js";
|
import { hostCore } from "./host.js";
|
||||||
|
|
||||||
import type { LrptProgress, SatelliteImage, SatelliteLiveUpdate } from "./satellite-types";
|
import type { LrptProgress, SatelliteImage, SatelliteLiveUpdate, SatellitePass, SatellitePassResponse } from "./satellite-types";
|
||||||
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
||||||
|
|
||||||
type SatelliteView = "live" | "history";
|
type SatelliteView = "live" | "history" | "predictions";
|
||||||
interface SatelliteBridge {
|
interface SatelliteBridge {
|
||||||
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
|
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
|
||||||
|
clearSatPredictionDom?: () => void;
|
||||||
updateSatLiveState?: (update: SatelliteLiveUpdate) => void;
|
updateSatLiveState?: (update: SatelliteLiveUpdate) => void;
|
||||||
addSatMapOverlay?: (image: SatelliteImage) => void;
|
addSatMapOverlay?: (image: SatelliteImage) => void;
|
||||||
clearSatMapOverlays?: () => void;
|
clearSatMapOverlays?: () => void;
|
||||||
@@ -24,14 +25,14 @@ const satWindow = window as unknown as SatelliteBridge & PluginRuntimeWindow;
|
|||||||
// --- SAT Plugin ---
|
// --- SAT Plugin ---
|
||||||
// Live view: decoder state, latest image card
|
// Live view: decoder state, latest image card
|
||||||
// History view: filterable table of all decoded images
|
// History view: filterable table of all decoded images
|
||||||
//
|
// Predictions view: next 24 h passes for ham satellites
|
||||||
// Pass predictions live on their own page; see satellite-predictions.ts.
|
|
||||||
|
|
||||||
// ── DOM references (cached once) ───────────────────────────────────
|
// ── DOM references (cached once) ───────────────────────────────────
|
||||||
const satDom = {
|
const satDom = {
|
||||||
status: document.getElementById("sat-status"),
|
status: document.getElementById("sat-status"),
|
||||||
liveView: document.getElementById("sat-live-view"),
|
liveView: document.getElementById("sat-live-view"),
|
||||||
historyView: document.getElementById("sat-history-view"),
|
historyView: document.getElementById("sat-history-view"),
|
||||||
|
predictionsView: document.getElementById("sat-predictions-view"),
|
||||||
liveLatest: document.getElementById("sat-live-latest"),
|
liveLatest: document.getElementById("sat-live-latest"),
|
||||||
historyList: document.getElementById("sat-history-list"),
|
historyList: document.getElementById("sat-history-list"),
|
||||||
historyCount: document.getElementById("sat-history-count"),
|
historyCount: document.getElementById("sat-history-count"),
|
||||||
@@ -41,13 +42,30 @@ const satDom = {
|
|||||||
lrptState: document.getElementById("sat-lrpt-state"),
|
lrptState: document.getElementById("sat-lrpt-state"),
|
||||||
viewLiveBtn: document.getElementById("sat-view-live"),
|
viewLiveBtn: document.getElementById("sat-view-live"),
|
||||||
viewHistoryBtn: document.getElementById("sat-view-history"),
|
viewHistoryBtn: document.getElementById("sat-view-history"),
|
||||||
|
viewPredBtn: document.getElementById("sat-view-predictions"),
|
||||||
|
predFilter: document.getElementById("sat-pred-filter") as HTMLInputElement | null,
|
||||||
|
predMinEl: document.getElementById("sat-pred-min-el") as HTMLSelectElement | null,
|
||||||
|
predCategory: document.getElementById("sat-pred-category") as HTMLSelectElement | null,
|
||||||
|
predCurrentList: document.getElementById("sat-pred-current-list"),
|
||||||
|
predUpcomingList: document.getElementById("sat-pred-list"),
|
||||||
|
predCurrentSec: document.getElementById("sat-pred-current-section"),
|
||||||
|
predUpcomingSec: document.getElementById("sat-pred-upcoming-section"),
|
||||||
|
predStatus: document.getElementById("sat-pred-status"),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── State ───────────────────────────────────────────────────────────
|
// ── State ───────────────────────────────────────────────────────────
|
||||||
let satImageHistory: SatelliteImage[] = [];
|
let satImageHistory: SatelliteImage[] = [];
|
||||||
const SAT_MAX_IMAGES = 100;
|
const SAT_MAX_IMAGES = 100;
|
||||||
|
const SAT_PRED_PAGE_SIZE = 50;
|
||||||
|
let satPredShowAll = false;
|
||||||
let satFilterText = "";
|
let satFilterText = "";
|
||||||
let satActiveView: SatelliteView = "live";
|
let satActiveView: SatelliteView = "live";
|
||||||
|
let satPredData: SatellitePass[] = [];
|
||||||
|
let satPredFilterText = "";
|
||||||
|
let satPredMinEl = 0;
|
||||||
|
let satPredCategory = "all";
|
||||||
|
let satPredSatCount = 0;
|
||||||
|
let satPredCountdownTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
// ── UI scheduler helper ─────────────────────────────────────────────
|
// ── UI scheduler helper ─────────────────────────────────────────────
|
||||||
function scheduleSatUi(key: string, job: () => void): void {
|
function scheduleSatUi(key: string, job: () => void): void {
|
||||||
@@ -60,16 +78,33 @@ function scheduleSatUi(key: string, job: () => void): void {
|
|||||||
|
|
||||||
// ── View switching ──────────────────────────────────────────────────
|
// ── View switching ──────────────────────────────────────────────────
|
||||||
function switchSatView(view: SatelliteView): void {
|
function switchSatView(view: SatelliteView): void {
|
||||||
|
const leavingPredictions = satActiveView === "predictions" && view !== "predictions";
|
||||||
satActiveView = view;
|
satActiveView = view;
|
||||||
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
|
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
|
||||||
if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "none";
|
if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "none";
|
||||||
if (satDom.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live");
|
if (satDom.predictionsView) satDom.predictionsView.style.display = view === "predictions" ? "" : "none";
|
||||||
if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history");
|
if (satDom.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live");
|
||||||
if (view === "history") renderSatHistoryTable();
|
if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history");
|
||||||
|
if (satDom.viewPredBtn) satDom.viewPredBtn.classList.toggle("sat-view-active", view === "predictions");
|
||||||
|
if (leavingPredictions) clearPredictionDom();
|
||||||
|
if (view === "history") {
|
||||||
|
renderSatHistoryTable();
|
||||||
|
} else if (view === "predictions") {
|
||||||
|
satPredShowAll = false;
|
||||||
|
void loadSatPredictions();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearPredictionDom() {
|
||||||
|
stopCountdownTimer();
|
||||||
|
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
||||||
|
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
||||||
|
}
|
||||||
|
satWindow.clearSatPredictionDom = clearPredictionDom;
|
||||||
|
|
||||||
satDom.viewLiveBtn?.addEventListener("click", () => { switchSatView("live"); });
|
satDom.viewLiveBtn?.addEventListener("click", () => { switchSatView("live"); });
|
||||||
satDom.viewHistoryBtn?.addEventListener("click", () => { switchSatView("history"); });
|
satDom.viewHistoryBtn?.addEventListener("click", () => { switchSatView("history"); });
|
||||||
|
satDom.viewPredBtn?.addEventListener("click", () => { switchSatView("predictions"); });
|
||||||
|
|
||||||
// ── Live view: decoder state ────────────────────────────────────────
|
// ── Live view: decoder state ────────────────────────────────────────
|
||||||
let lastSatLrptOn: boolean | null = null;
|
let lastSatLrptOn: boolean | null = null;
|
||||||
@@ -300,6 +335,234 @@ document
|
|||||||
}
|
}
|
||||||
})(); });
|
})(); });
|
||||||
|
|
||||||
|
// ── Predictions: helpers ────────────────────────────────────────────
|
||||||
|
function azToCardinal(deg: number): string {
|
||||||
|
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
|
||||||
|
return dirs[Math.round(deg / 45) % 8] ?? "N";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPredTime(ms: number): string {
|
||||||
|
const d = new Date(ms);
|
||||||
|
const now = new Date();
|
||||||
|
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||||
|
const day = d.getUTCDay() !== now.getUTCDay() ? `${dayNames[d.getUTCDay()] ?? ""} ` : "";
|
||||||
|
const hh = String(d.getUTCHours()).padStart(2, "0");
|
||||||
|
const mm = String(d.getUTCMinutes()).padStart(2, "0");
|
||||||
|
return `${day}${hh}:${mm}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPredDuration(s: number): string {
|
||||||
|
if (s >= 60) return `${Math.round(s / 60)} min`;
|
||||||
|
return `${s}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCountdown(ms: number): string {
|
||||||
|
const totalSec = Math.max(0, Math.floor(ms / 1000));
|
||||||
|
const m = Math.floor(totalSec / 60);
|
||||||
|
const s = totalSec % 60;
|
||||||
|
return `${m}:${String(s).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function elevationClass(deg: number): string {
|
||||||
|
if (deg >= 45) return "sat-pred-el-high";
|
||||||
|
if (deg >= 10) return "sat-pred-el-mid";
|
||||||
|
return "sat-pred-el-low";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Predictions: countdown timer management ─────────────────────────
|
||||||
|
function stopCountdownTimer() {
|
||||||
|
if (satPredCountdownTimer) {
|
||||||
|
clearInterval(satPredCountdownTimer);
|
||||||
|
satPredCountdownTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCountdownTimer(container: HTMLElement | null): void {
|
||||||
|
const countdownEls = container?.querySelectorAll<HTMLElement>(".sat-pred-col-countdown") ?? [];
|
||||||
|
if (countdownEls.length === 0) return;
|
||||||
|
|
||||||
|
satPredCountdownTimer = setInterval(() => {
|
||||||
|
if (satActiveView !== "predictions") {
|
||||||
|
stopCountdownTimer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = Date.now();
|
||||||
|
let anyActive = false;
|
||||||
|
for (const el of countdownEls) {
|
||||||
|
const los = Number.parseInt(el.dataset.los ?? "0", 10);
|
||||||
|
const rem = los - n;
|
||||||
|
if (rem > 0) {
|
||||||
|
el.textContent = formatCountdown(rem);
|
||||||
|
anyActive = true;
|
||||||
|
} else {
|
||||||
|
el.textContent = "0:00";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!anyActive) {
|
||||||
|
stopCountdownTimer();
|
||||||
|
renderSatPredictions(getFilteredPredictions());
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Predictions: row builders ───────────────────────────────────────
|
||||||
|
function buildCurrentPassRow(pass: SatellitePass, now: number): HTMLElement {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "sat-pred-row-current";
|
||||||
|
const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`;
|
||||||
|
const remaining = Math.max(0, pass.los_ms - now);
|
||||||
|
row.innerHTML = [
|
||||||
|
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
||||||
|
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}\u00B0</span>`,
|
||||||
|
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
||||||
|
`<span class="sat-pred-col-time">${formatPredTime(pass.los_ms)}</span>`,
|
||||||
|
`<span class="sat-pred-col-countdown" data-los="${pass.los_ms}">${formatCountdown(remaining)}</span>`,
|
||||||
|
`<span class="sat-pred-col-dir">${dir}</span>`,
|
||||||
|
].join("");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUpcomingPassRow(pass: SatellitePass): HTMLElement {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "sat-pred-row";
|
||||||
|
const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`;
|
||||||
|
row.innerHTML = [
|
||||||
|
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
||||||
|
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
||||||
|
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}\u00B0</span>`,
|
||||||
|
`<span class="sat-pred-col-dur">${formatPredDuration(pass.duration_s)}</span>`,
|
||||||
|
`<span class="sat-pred-col-dir">${dir}</span>`,
|
||||||
|
].join("");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Predictions: filter state ───────────────────────────────────────
|
||||||
|
function getFilteredPredictions(): SatellitePass[] {
|
||||||
|
let items = satPredData;
|
||||||
|
if (satPredCategory !== "all") items = items.filter((p) => p.category === satPredCategory);
|
||||||
|
if (satPredMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= satPredMinEl);
|
||||||
|
if (satPredFilterText) items = items.filter((p) => p.satellite.toUpperCase().includes(satPredFilterText));
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPredFilters() {
|
||||||
|
renderSatPredictions(getFilteredPredictions());
|
||||||
|
}
|
||||||
|
|
||||||
|
const satPredictionFilter = satDom.predFilter;
|
||||||
|
satPredictionFilter?.addEventListener("input", () => {
|
||||||
|
satPredFilterText = satPredictionFilter.value.trim().toUpperCase();
|
||||||
|
applyPredFilters();
|
||||||
|
});
|
||||||
|
|
||||||
|
const satPredictionMinElevation = satDom.predMinEl;
|
||||||
|
satPredictionMinElevation?.addEventListener("change", () => {
|
||||||
|
satPredMinEl = Number.parseInt(satPredictionMinElevation.value, 10) || 0;
|
||||||
|
applyPredFilters();
|
||||||
|
});
|
||||||
|
|
||||||
|
const satPredictionCategory = satDom.predCategory;
|
||||||
|
satPredictionCategory?.addEventListener("change", () => {
|
||||||
|
satPredCategory = satPredictionCategory.value;
|
||||||
|
applyPredFilters();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Predictions: main render ────────────────────────────────────────
|
||||||
|
function renderSatPredictions(passes: SatellitePass[], error?: string): void {
|
||||||
|
stopCountdownTimer();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
||||||
|
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
||||||
|
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
|
||||||
|
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
|
||||||
|
if (satDom.predStatus) satDom.predStatus.textContent = error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(passes) || passes.length === 0) {
|
||||||
|
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
||||||
|
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
||||||
|
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
|
||||||
|
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
|
||||||
|
if (satDom.predStatus) satDom.predStatus.textContent = "No passes found in the next 24 hours.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const current = passes.filter((p) => p.aos_ms <= now && p.los_ms > now);
|
||||||
|
const upcoming = passes.filter((p) => p.aos_ms > now);
|
||||||
|
|
||||||
|
// ── Current passes ──
|
||||||
|
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = current.length > 0 ? "" : "none";
|
||||||
|
if (satDom.predCurrentList) {
|
||||||
|
if (current.length === 0) {
|
||||||
|
satDom.predCurrentList.innerHTML = "";
|
||||||
|
} else {
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
for (const pass of current) frag.appendChild(buildCurrentPassRow(pass, now));
|
||||||
|
satDom.predCurrentList.replaceChildren(frag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Upcoming passes ──
|
||||||
|
const upcomingLimit = satPredShowAll ? upcoming.length : SAT_PRED_PAGE_SIZE;
|
||||||
|
const visibleUpcoming = upcoming.slice(0, upcomingLimit);
|
||||||
|
const hiddenCount = upcoming.length - visibleUpcoming.length;
|
||||||
|
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = upcoming.length > 0 ? "" : "none";
|
||||||
|
if (satDom.predUpcomingList) {
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
for (const pass of visibleUpcoming) frag.appendChild(buildUpcomingPassRow(pass));
|
||||||
|
if (hiddenCount > 0) {
|
||||||
|
const moreRow = document.createElement("div");
|
||||||
|
moreRow.className = "sat-pred-row";
|
||||||
|
moreRow.style.cursor = "pointer";
|
||||||
|
moreRow.style.textAlign = "center";
|
||||||
|
moreRow.innerHTML = `<span style="grid-column:1/-1;color:var(--accent);font-size:0.82rem;">Show ${hiddenCount} more passes\u2026</span>`;
|
||||||
|
moreRow.addEventListener("click", () => {
|
||||||
|
satPredShowAll = true;
|
||||||
|
renderSatPredictions(getFilteredPredictions());
|
||||||
|
});
|
||||||
|
frag.appendChild(moreRow);
|
||||||
|
}
|
||||||
|
satDom.predUpcomingList.replaceChildren(frag);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Status ──
|
||||||
|
if (satDom.predStatus) {
|
||||||
|
let text = `${current.length} active \u00B7 ${upcoming.length} upcoming \u00B7 times in UTC`;
|
||||||
|
if (satPredSatCount > 0) text += ` \u00B7 ${satPredSatCount} satellites tracked`;
|
||||||
|
satDom.predStatus.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Countdown timer ──
|
||||||
|
if (current.length > 0 && satActiveView === "predictions") {
|
||||||
|
startCountdownTimer(satDom.predCurrentList);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Predictions: data loading ───────────────────────────────────────
|
||||||
|
async function loadSatPredictions(): Promise<void> {
|
||||||
|
if (satDom.predStatus) satDom.predStatus.textContent = "Loading predictions\u2026";
|
||||||
|
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
||||||
|
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/sat_passes");
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
|
const data = await resp.json() as SatellitePassResponse;
|
||||||
|
satPredSatCount = data.satellite_count || 0;
|
||||||
|
if (data.error) {
|
||||||
|
satPredData = [];
|
||||||
|
renderSatPredictions([], data.error);
|
||||||
|
} else {
|
||||||
|
satPredData = data.passes || [];
|
||||||
|
renderSatPredictions(getFilteredPredictions());
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
renderSatPredictions([], `Failed to load predictions: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Navigate to map centered on satellite image bounds ──────────────
|
// ── Navigate to map centered on satellite image bounds ──────────────
|
||||||
satWindow.satShowOnMap = function (south: number, west: number, north: number, east: number) {
|
satWindow.satShowOnMap = function (south: number, west: number, north: number, east: number) {
|
||||||
if (typeof satWindow.enableMapSourceFilter === "function") {
|
if (typeof satWindow.enableMapSourceFilter === "function") {
|
||||||
|
|||||||
-290
@@ -1,290 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
import type { SatellitePass, SatellitePassResponse } from "./satellite-types";
|
|
||||||
|
|
||||||
// --- Satellite pass predictions ---
|
|
||||||
//
|
|
||||||
// Its own page rather than a third view inside the weather-satellite decoder:
|
|
||||||
// when a bird comes over is a planning question, and it was buried under
|
|
||||||
// Digital modes beside decoders it has nothing to do with.
|
|
||||||
|
|
||||||
interface PredictionsBridge {
|
|
||||||
clearSatPredictionDom?: () => void;
|
|
||||||
refreshSatPredictions?: () => void;
|
|
||||||
}
|
|
||||||
const predWindow = window as unknown as PredictionsBridge;
|
|
||||||
|
|
||||||
// ── DOM references (cached once) ───────────────────────────────────
|
|
||||||
const dom = {
|
|
||||||
page: document.getElementById("tab-satellites"),
|
|
||||||
filter: document.getElementById("sat-pred-filter") as HTMLInputElement | null,
|
|
||||||
minElevation: document.getElementById("sat-pred-min-el") as HTMLSelectElement | null,
|
|
||||||
category: document.getElementById("sat-pred-category") as HTMLSelectElement | null,
|
|
||||||
currentList: document.getElementById("sat-pred-current-list"),
|
|
||||||
upcomingList: document.getElementById("sat-pred-list"),
|
|
||||||
currentSection: document.getElementById("sat-pred-current-section"),
|
|
||||||
upcomingSection: document.getElementById("sat-pred-upcoming-section"),
|
|
||||||
status: document.getElementById("sat-pred-status"),
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── State ───────────────────────────────────────────────────────────
|
|
||||||
const PAGE_SIZE = 50;
|
|
||||||
let predShowAll = false;
|
|
||||||
let predData: SatellitePass[] = [];
|
|
||||||
let predFilterText = "";
|
|
||||||
let predMinEl = 0;
|
|
||||||
let predCategory = "all";
|
|
||||||
let predSatCount = 0;
|
|
||||||
let predCountdownTimer: ReturnType<typeof setInterval> | null = null;
|
|
||||||
|
|
||||||
/** The countdowns tick once a second, so they stop when nobody is looking. */
|
|
||||||
function isPageVisible(): boolean {
|
|
||||||
return !!dom.page && dom.page.style.display !== "none";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Predictions: helpers ────────────────────────────────────────────
|
|
||||||
function azToCardinal(deg: number): string {
|
|
||||||
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
|
|
||||||
return dirs[Math.round(deg / 45) % 8] ?? "N";
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPredTime(ms: number): string {
|
|
||||||
const d = new Date(ms);
|
|
||||||
const now = new Date();
|
|
||||||
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
||||||
const day = d.getUTCDay() !== now.getUTCDay() ? `${dayNames[d.getUTCDay()] ?? ""} ` : "";
|
|
||||||
const hh = String(d.getUTCHours()).padStart(2, "0");
|
|
||||||
const mm = String(d.getUTCMinutes()).padStart(2, "0");
|
|
||||||
return `${day}${hh}:${mm}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPredDuration(s: number): string {
|
|
||||||
if (s >= 60) return `${Math.round(s / 60)} min`;
|
|
||||||
return `${s}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCountdown(ms: number): string {
|
|
||||||
const totalSec = Math.max(0, Math.floor(ms / 1000));
|
|
||||||
const m = Math.floor(totalSec / 60);
|
|
||||||
const s = totalSec % 60;
|
|
||||||
return `${m}:${String(s).padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function elevationClass(deg: number): string {
|
|
||||||
if (deg >= 45) return "sat-pred-el-high";
|
|
||||||
if (deg >= 10) return "sat-pred-el-mid";
|
|
||||||
return "sat-pred-el-low";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Predictions: countdown timer management ─────────────────────────
|
|
||||||
function stopCountdownTimer() {
|
|
||||||
if (predCountdownTimer) {
|
|
||||||
clearInterval(predCountdownTimer);
|
|
||||||
predCountdownTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startCountdownTimer(container: HTMLElement | null): void {
|
|
||||||
const countdownEls = container?.querySelectorAll<HTMLElement>(".sat-pred-col-countdown") ?? [];
|
|
||||||
if (countdownEls.length === 0) return;
|
|
||||||
|
|
||||||
predCountdownTimer = setInterval(() => {
|
|
||||||
if (!isPageVisible()) {
|
|
||||||
stopCountdownTimer();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const n = Date.now();
|
|
||||||
let anyActive = false;
|
|
||||||
for (const el of countdownEls) {
|
|
||||||
const los = Number.parseInt(el.dataset.los ?? "0", 10);
|
|
||||||
const rem = los - n;
|
|
||||||
if (rem > 0) {
|
|
||||||
el.textContent = formatCountdown(rem);
|
|
||||||
anyActive = true;
|
|
||||||
} else {
|
|
||||||
el.textContent = "0:00";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!anyActive) {
|
|
||||||
stopCountdownTimer();
|
|
||||||
render(filtered());
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Predictions: row builders ───────────────────────────────────────
|
|
||||||
function buildCurrentPassRow(pass: SatellitePass, now: number): HTMLElement {
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = "sat-pred-row-current";
|
|
||||||
const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`;
|
|
||||||
const remaining = Math.max(0, pass.los_ms - now);
|
|
||||||
row.innerHTML = [
|
|
||||||
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
|
||||||
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}\u00B0</span>`,
|
|
||||||
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
|
||||||
`<span class="sat-pred-col-time">${formatPredTime(pass.los_ms)}</span>`,
|
|
||||||
`<span class="sat-pred-col-countdown" data-los="${pass.los_ms}">${formatCountdown(remaining)}</span>`,
|
|
||||||
`<span class="sat-pred-col-dir">${dir}</span>`,
|
|
||||||
].join("");
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildUpcomingPassRow(pass: SatellitePass): HTMLElement {
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = "sat-pred-row";
|
|
||||||
const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`;
|
|
||||||
row.innerHTML = [
|
|
||||||
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
|
||||||
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
|
||||||
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}\u00B0</span>`,
|
|
||||||
`<span class="sat-pred-col-dur">${formatPredDuration(pass.duration_s)}</span>`,
|
|
||||||
`<span class="sat-pred-col-dir">${dir}</span>`,
|
|
||||||
].join("");
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Predictions: filter state ───────────────────────────────────────
|
|
||||||
function filtered(): SatellitePass[] {
|
|
||||||
let items = predData;
|
|
||||||
if (predCategory !== "all") items = items.filter((p) => p.category === predCategory);
|
|
||||||
if (predMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= predMinEl);
|
|
||||||
if (predFilterText) items = items.filter((p) => p.satellite.toUpperCase().includes(predFilterText));
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyFilters() {
|
|
||||||
render(filtered());
|
|
||||||
}
|
|
||||||
|
|
||||||
const filterInput = dom.filter;
|
|
||||||
filterInput?.addEventListener("input", () => {
|
|
||||||
predFilterText = filterInput.value.trim().toUpperCase();
|
|
||||||
applyFilters();
|
|
||||||
});
|
|
||||||
|
|
||||||
const minElevationSelect = dom.minElevation;
|
|
||||||
minElevationSelect?.addEventListener("change", () => {
|
|
||||||
predMinEl = Number.parseInt(minElevationSelect.value, 10) || 0;
|
|
||||||
applyFilters();
|
|
||||||
});
|
|
||||||
|
|
||||||
const categorySelect = dom.category;
|
|
||||||
categorySelect?.addEventListener("change", () => {
|
|
||||||
predCategory = categorySelect.value;
|
|
||||||
applyFilters();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Predictions: main render ────────────────────────────────────────
|
|
||||||
function render(passes: SatellitePass[], error?: string): void {
|
|
||||||
stopCountdownTimer();
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
if (dom.currentList) dom.currentList.innerHTML = "";
|
|
||||||
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
|
|
||||||
if (dom.currentSection) dom.currentSection.style.display = "none";
|
|
||||||
if (dom.upcomingSection) dom.upcomingSection.style.display = "none";
|
|
||||||
if (dom.status) dom.status.textContent = error;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Array.isArray(passes) || passes.length === 0) {
|
|
||||||
if (dom.currentList) dom.currentList.innerHTML = "";
|
|
||||||
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
|
|
||||||
if (dom.currentSection) dom.currentSection.style.display = "none";
|
|
||||||
if (dom.upcomingSection) dom.upcomingSection.style.display = "none";
|
|
||||||
if (dom.status) dom.status.textContent = "No passes found in the next 24 hours.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
const current = passes.filter((p) => p.aos_ms <= now && p.los_ms > now);
|
|
||||||
const upcoming = passes.filter((p) => p.aos_ms > now);
|
|
||||||
|
|
||||||
// ── Current passes ──
|
|
||||||
if (dom.currentSection) dom.currentSection.style.display = current.length > 0 ? "" : "none";
|
|
||||||
if (dom.currentList) {
|
|
||||||
if (current.length === 0) {
|
|
||||||
dom.currentList.innerHTML = "";
|
|
||||||
} else {
|
|
||||||
const frag = document.createDocumentFragment();
|
|
||||||
for (const pass of current) frag.appendChild(buildCurrentPassRow(pass, now));
|
|
||||||
dom.currentList.replaceChildren(frag);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Upcoming passes ──
|
|
||||||
const upcomingLimit = predShowAll ? upcoming.length : PAGE_SIZE;
|
|
||||||
const visibleUpcoming = upcoming.slice(0, upcomingLimit);
|
|
||||||
const hiddenCount = upcoming.length - visibleUpcoming.length;
|
|
||||||
if (dom.upcomingSection) dom.upcomingSection.style.display = upcoming.length > 0 ? "" : "none";
|
|
||||||
if (dom.upcomingList) {
|
|
||||||
const frag = document.createDocumentFragment();
|
|
||||||
for (const pass of visibleUpcoming) frag.appendChild(buildUpcomingPassRow(pass));
|
|
||||||
if (hiddenCount > 0) {
|
|
||||||
const moreRow = document.createElement("div");
|
|
||||||
moreRow.className = "sat-pred-row";
|
|
||||||
moreRow.style.cursor = "pointer";
|
|
||||||
moreRow.style.textAlign = "center";
|
|
||||||
moreRow.innerHTML = `<span style="grid-column:1/-1;color:var(--accent);font-size:0.82rem;">Show ${hiddenCount} more passes\u2026</span>`;
|
|
||||||
moreRow.addEventListener("click", () => {
|
|
||||||
predShowAll = true;
|
|
||||||
render(filtered());
|
|
||||||
});
|
|
||||||
frag.appendChild(moreRow);
|
|
||||||
}
|
|
||||||
dom.upcomingList.replaceChildren(frag);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Status ──
|
|
||||||
if (dom.status) {
|
|
||||||
let text = `${current.length} active \u00B7 ${upcoming.length} upcoming \u00B7 times in UTC`;
|
|
||||||
if (predSatCount > 0) text += ` \u00B7 ${predSatCount} satellites tracked`;
|
|
||||||
dom.status.textContent = text;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Countdown timer ──
|
|
||||||
if (current.length > 0 && isPageVisible()) {
|
|
||||||
startCountdownTimer(dom.currentList);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Predictions: data loading ───────────────────────────────────────
|
|
||||||
async function load(): Promise<void> {
|
|
||||||
if (dom.status) dom.status.textContent = "Loading predictions\u2026";
|
|
||||||
if (dom.currentList) dom.currentList.innerHTML = "";
|
|
||||||
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
|
|
||||||
try {
|
|
||||||
const resp = await fetch("/sat_passes");
|
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
||||||
const data = await resp.json() as SatellitePassResponse;
|
|
||||||
predSatCount = data.satellite_count || 0;
|
|
||||||
if (data.error) {
|
|
||||||
predData = [];
|
|
||||||
render([], data.error);
|
|
||||||
} else {
|
|
||||||
predData = data.passes || [];
|
|
||||||
render(filtered());
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
|
||||||
render([], `Failed to load predictions: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Page lifecycle ──────────────────────────────────────────────────
|
|
||||||
function clearPredictionDom(): void {
|
|
||||||
stopCountdownTimer();
|
|
||||||
if (dom.currentList) dom.currentList.innerHTML = "";
|
|
||||||
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
|
|
||||||
}
|
|
||||||
predWindow.clearSatPredictionDom = clearPredictionDom;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called by the router each time the Satellites page is opened, including the
|
|
||||||
* visit that imports this module — so loading is driven from one place only.
|
|
||||||
*/
|
|
||||||
predWindow.refreshSatPredictions = function () {
|
|
||||||
predShowAll = false;
|
|
||||||
void load();
|
|
||||||
};
|
|
||||||
+1
-2
@@ -3,7 +3,6 @@
|
|||||||
// 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";
|
||||||
|
|
||||||
@@ -61,7 +60,7 @@ export interface SchedulerStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SchedulerService {
|
export interface SchedulerService {
|
||||||
initialize(rigId: string | null, roles: readonly AuthRole[]): void;
|
initialize(rigId: string | null, role: string | null): void;
|
||||||
destroy(): void;
|
destroy(): void;
|
||||||
setRig(rigId: string | null): void;
|
setRig(rigId: string | null): void;
|
||||||
wireEvents(): void;
|
wireEvents(): void;
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
// 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,
|
||||||
@@ -44,7 +43,7 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// State
|
// State
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
let schedulerRoles: readonly AuthRole[] = [];
|
let schedulerRole: string | null = null;
|
||||||
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;
|
||||||
@@ -59,8 +58,8 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Init
|
// Init
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
function initScheduler(rigId: string | null, roles: readonly AuthRole[]): void {
|
function initScheduler(rigId: string | null, role: string | null): void {
|
||||||
schedulerRoles = roles;
|
schedulerRole = role;
|
||||||
currentRigId = rigId || null;
|
currentRigId = rigId || null;
|
||||||
if (currentRigId) loadScheduler();
|
if (currentRigId) loadScheduler();
|
||||||
startStatusPolling();
|
startStatusPolling();
|
||||||
@@ -357,7 +356,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 =
|
||||||
hasAuthRole(schedulerRoles, "control") &&
|
schedulerRole === "control" &&
|
||||||
!!currentRigId &&
|
!!currentRigId &&
|
||||||
!schedulerStepPending &&
|
!schedulerStepPending &&
|
||||||
state.activeEntries.length > 1;
|
state.activeEntries.length > 1;
|
||||||
@@ -467,7 +466,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 = hasAuthRole(schedulerRoles, "control");
|
const isControl = schedulerRole === "control";
|
||||||
|
|
||||||
// Mode selector
|
// Mode selector
|
||||||
setSelected("scheduler-mode-select", mode);
|
setSelected("scheduler-mode-select", mode);
|
||||||
@@ -1575,8 +1574,8 @@ function schedulerOptionalEl(id: string): SchedulerElement | null {
|
|||||||
// When loaded eagerly, initSettingsUI() in app.js calls initScheduler();
|
// When loaded 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.authEnabled || hostState.authRoles.length > 0) {
|
if (hostState.authRole != null) {
|
||||||
initScheduler(hostState.lastActiveRigId, hostState.authRoles);
|
initScheduler(hostState.lastActiveRigId, hostState.authRole);
|
||||||
wireSchedulerEvents();
|
wireSchedulerEvents();
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -14,13 +14,11 @@
|
|||||||
|
|
||||||
import { hostCore } from "./host.js";
|
import { hostCore } from "./host.js";
|
||||||
|
|
||||||
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
|
|
||||||
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|
||||||
interface SstvImage {
|
interface SstvImage {
|
||||||
rig_id?: string | null;
|
|
||||||
ts_ms?: number;
|
ts_ms?: number;
|
||||||
vis?: number;
|
vis?: number;
|
||||||
mode?: string;
|
mode?: string;
|
||||||
@@ -34,7 +32,6 @@ interface SstvImage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface SstvProgress {
|
interface SstvProgress {
|
||||||
rig_id?: string | null;
|
|
||||||
state?: string;
|
state?: string;
|
||||||
mode?: string;
|
mode?: string;
|
||||||
width?: number;
|
width?: number;
|
||||||
@@ -188,9 +185,6 @@ function paintRow(line: number, rgb: Uint8Array): void {
|
|||||||
// ── Server messages ─────────────────────────────────────────────────
|
// ── Server messages ─────────────────────────────────────────────────
|
||||||
|
|
||||||
function onProgress(msg: SstvProgress): void {
|
function onProgress(msg: SstvProgress): void {
|
||||||
// The live canvas draws one picture at a time: a background rig's rows would
|
|
||||||
// paint straight over the one being watched.
|
|
||||||
if (!isActiveRigDecode(msg.rig_id)) return;
|
|
||||||
if (msg.state) {
|
if (msg.state) {
|
||||||
if (sstvDom.status) sstvDom.status.textContent = msg.state;
|
if (sstvDom.status) sstvDom.status.textContent = msg.state;
|
||||||
// A state update carries the geometry; a row update carries only the row.
|
// A state update carries the geometry; a row update carries only the row.
|
||||||
@@ -214,9 +208,6 @@ function onImage(msg: SstvImage): void {
|
|||||||
if (sstvHistory.length > SSTV_MAX_IMAGES) sstvHistory.shift();
|
if (sstvHistory.length > SSTV_MAX_IMAGES) sstvHistory.shift();
|
||||||
pruneHistory();
|
pruneHistory();
|
||||||
|
|
||||||
// Kept in the history either way, so switching to that rig finds it there.
|
|
||||||
if (!isActiveRigDecode(image.rig_id)) return;
|
|
||||||
|
|
||||||
if (sstvDom.status) {
|
if (sstvDom.status) {
|
||||||
sstvDom.status.textContent = image.complete
|
sstvDom.status.textContent = image.complete
|
||||||
? `Received ${image.mode ?? "picture"}`
|
? `Received ${image.mode ?? "picture"}`
|
||||||
@@ -228,15 +219,9 @@ function onImage(msg: SstvImage): void {
|
|||||||
|
|
||||||
// ── Rendering ───────────────────────────────────────────────────────
|
// ── Rendering ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** The pictures the selected rig decoded, oldest first, as the history is kept. */
|
|
||||||
function sstvRigImages(): SstvImage[] {
|
|
||||||
return forActiveRig(sstvHistory);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderLatestCard(): void {
|
function renderLatestCard(): void {
|
||||||
if (!sstvDom.liveLatest) return;
|
if (!sstvDom.liveLatest) return;
|
||||||
const rigImages = sstvRigImages();
|
const latest = sstvHistory[sstvHistory.length - 1];
|
||||||
const latest = rigImages[rigImages.length - 1];
|
|
||||||
if (!latest) {
|
if (!latest) {
|
||||||
sstvDom.liveLatest.innerHTML = "";
|
sstvDom.liveLatest.innerHTML = "";
|
||||||
return;
|
return;
|
||||||
@@ -260,12 +245,10 @@ function renderLatestCard(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function filteredHistory(): SstvImage[] {
|
function filteredHistory(): SstvImage[] {
|
||||||
// Every rig's pictures are kept; the panel shows the selected rig's.
|
|
||||||
const rigImages = sstvRigImages();
|
|
||||||
const text = filterText.trim().toLowerCase();
|
const text = filterText.trim().toLowerCase();
|
||||||
const matching = text
|
const matching = text
|
||||||
? rigImages.filter((image) => (image.mode ?? "").toLowerCase().includes(text))
|
? sstvHistory.filter((image) => (image.mode ?? "").toLowerCase().includes(text))
|
||||||
: rigImages;
|
: sstvHistory.slice();
|
||||||
const newestFirst = (sstvDom.sortSelect?.value ?? "newest") === "newest";
|
const newestFirst = (sstvDom.sortSelect?.value ?? "newest") === "newest";
|
||||||
matching.sort((a, b) => (newestFirst ? 1 : -1) * ((b._tsMs ?? 0) - (a._tsMs ?? 0)));
|
matching.sort((a, b) => (newestFirst ? 1 : -1) * ((b._tsMs ?? 0) - (a._tsMs ?? 0)));
|
||||||
return matching;
|
return matching;
|
||||||
@@ -365,7 +348,6 @@ sstvWindow.trxPluginRuntime.registerDecoder({
|
|||||||
restore: restoreHistory,
|
restore: restoreHistory,
|
||||||
prune: renderHistoryTable,
|
prune: renderHistoryTable,
|
||||||
reset: resetHistoryView,
|
reset: resetHistoryView,
|
||||||
rerender: () => { renderLatestCard(); renderHistoryTable(); },
|
|
||||||
});
|
});
|
||||||
sstvWindow.trxPluginRuntime.registerDecoder({
|
sstvWindow.trxPluginRuntime.registerDecoder({
|
||||||
id: "sstv_progress",
|
id: "sstv_progress",
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { hostCore } from "./host.js";
|
|||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|
||||||
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
|
|
||||||
import type { PluginRuntimeWindow } from "./runtime-contract";
|
import type { PluginRuntimeWindow } from "./runtime-contract";
|
||||||
|
|
||||||
interface VdesMessage {
|
interface VdesMessage {
|
||||||
@@ -117,24 +116,17 @@ function vdesHexPreview(rawBytes: number[] | undefined): string {
|
|||||||
.toUpperCase();
|
.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What the selected rig heard. The history holds every rig's traffic, for
|
|
||||||
* the map; the panel is about the rig the operator is working. */
|
|
||||||
function vdesRigMessages(): VdesMessage[] {
|
|
||||||
return forActiveRig(vdesMessageHistory);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateVdesSummary() {
|
function updateVdesSummary() {
|
||||||
pruneVdesMessageHistory();
|
pruneVdesMessageHistory();
|
||||||
if (vdesChannelSummaryEl) {
|
if (vdesChannelSummaryEl) {
|
||||||
vdesChannelSummaryEl.textContent = currentVdesCenterText();
|
vdesChannelSummaryEl.textContent = currentVdesCenterText();
|
||||||
}
|
}
|
||||||
const rigMessages = vdesRigMessages();
|
|
||||||
if (vdesFrameCountEl) {
|
if (vdesFrameCountEl) {
|
||||||
const count = rigMessages.length;
|
const count = vdesMessageHistory.length;
|
||||||
vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`;
|
vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`;
|
||||||
}
|
}
|
||||||
if (vdesLatestSeenEl) {
|
if (vdesLatestSeenEl) {
|
||||||
const latest = rigMessages[0];
|
const latest = vdesMessageHistory[0];
|
||||||
vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet";
|
vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,9 +228,7 @@ function updateVdesBar() {
|
|||||||
updateVdesSummary();
|
updateVdesSummary();
|
||||||
const isVdes = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "VDES";
|
const isVdes = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "VDES";
|
||||||
const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS;
|
const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS;
|
||||||
const messages = vdesMessageHistory
|
const messages = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs).slice(0, 6);
|
||||||
.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id))
|
|
||||||
.slice(0, 6);
|
|
||||||
if (!isVdes || messages.length === 0) {
|
if (!isVdes || messages.length === 0) {
|
||||||
vdesBarOverlay.style.display = "none";
|
vdesBarOverlay.style.display = "none";
|
||||||
vdesBarOverlay.innerHTML = "";
|
vdesBarOverlay.innerHTML = "";
|
||||||
@@ -288,7 +278,7 @@ function renderVdesHistory() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
for (const message of vdesRigMessages()) {
|
for (const message of vdesMessageHistory) {
|
||||||
fragment.appendChild(renderVdesRow(message));
|
fragment.appendChild(renderVdesRow(message));
|
||||||
}
|
}
|
||||||
vdesMessagesEl.replaceChildren(fragment);
|
vdesMessagesEl.replaceChildren(fragment);
|
||||||
@@ -319,11 +309,10 @@ function normalizeServerVdesMessage(msg: VdesMessage): VdesMessage {
|
|||||||
|
|
||||||
function onServerVdesBatch(messages: VdesMessage[]): void {
|
function onServerVdesBatch(messages: VdesMessage[]): void {
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
|
if (vdesStatus) vdesStatus.textContent = "Receiving";
|
||||||
const normalized: VdesMessage[] = [];
|
const normalized: VdesMessage[] = [];
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const next = normalizeServerVdesMessage(msg);
|
const next = normalizeServerVdesMessage(msg);
|
||||||
// "Receiving" is the panel's own status, and the panel is the rig's.
|
|
||||||
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
|
|
||||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
next._tsMs = tsMs;
|
next._tsMs = tsMs;
|
||||||
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
@@ -365,8 +354,8 @@ function plotVdesMessage(msg: VdesMessage): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onServerVdes(msg: VdesMessage): void {
|
function onServerVdes(msg: VdesMessage): void {
|
||||||
|
if (vdesStatus) vdesStatus.textContent = "Receiving";
|
||||||
const next = normalizeServerVdesMessage(msg);
|
const next = normalizeServerVdesMessage(msg);
|
||||||
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
|
|
||||||
addVdesMessage(next);
|
addVdesMessage(next);
|
||||||
plotVdesMessage(next);
|
plotVdesMessage(next);
|
||||||
}
|
}
|
||||||
@@ -385,7 +374,6 @@ updateVdesSummary();
|
|||||||
restore: onServerVdesBatch,
|
restore: onServerVdesBatch,
|
||||||
reset: resetVdesHistoryView,
|
reset: resetVdesHistoryView,
|
||||||
prune: pruneVdesHistoryView,
|
prune: pruneVdesHistoryView,
|
||||||
rerender: () => { updateVdesBar(); renderVdesHistory(); },
|
|
||||||
// Oldest first, so tracks are rebuilt in the order they happened.
|
// Oldest first, so tracks are rebuilt in the order they happened.
|
||||||
syncMap: () => { for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry); },
|
syncMap: () => { for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry); },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,13 +4,11 @@
|
|||||||
|
|
||||||
import { hostCore } from "./host.js";
|
import { hostCore } from "./host.js";
|
||||||
|
|
||||||
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
|
|
||||||
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|
||||||
interface WefaxImage {
|
interface WefaxImage {
|
||||||
rig_id?: string | null;
|
|
||||||
ts_ms?: number;
|
ts_ms?: number;
|
||||||
ioc?: number;
|
ioc?: number;
|
||||||
lpm?: number;
|
lpm?: number;
|
||||||
@@ -22,7 +20,6 @@ interface WefaxImage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface WefaxProgress {
|
interface WefaxProgress {
|
||||||
rig_id?: string | null;
|
|
||||||
state?: string;
|
state?: string;
|
||||||
line_data?: string;
|
line_data?: string;
|
||||||
line_count?: number;
|
line_count?: number;
|
||||||
@@ -159,21 +156,15 @@ function paintLine(lineBytes: Uint8Array): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Live view: latest image card ────────────────────────────────────
|
// ── Live view: latest image card ────────────────────────────────────
|
||||||
/** The pictures the selected rig decoded, newest first. */
|
|
||||||
function wefaxRigImages(): WefaxImage[] {
|
|
||||||
return forActiveRig(wefaxImageHistory);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderWefaxLatestCard() {
|
function renderWefaxLatestCard() {
|
||||||
if (!wefaxDom.liveLatest) return;
|
if (!wefaxDom.liveLatest) return;
|
||||||
const rigImages = wefaxRigImages();
|
if (wefaxImageHistory.length === 0) {
|
||||||
if (rigImages.length === 0) {
|
|
||||||
wefaxDom.liveLatest.innerHTML =
|
wefaxDom.liveLatest.innerHTML =
|
||||||
'<div style="color:var(--text-muted);font-size:0.82rem;">No images decoded yet. Enable the decoder and tune to a WEFAX station.</div>';
|
'<div style="color:var(--text-muted);font-size:0.82rem;">No images decoded yet. Enable the decoder and tune to a WEFAX station.</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const img = rigImages[0];
|
const img = wefaxImageHistory[0];
|
||||||
if (!img) return;
|
if (!img) return;
|
||||||
const ts = img._ts || '--';
|
const ts = img._ts || '--';
|
||||||
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : '';
|
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : '';
|
||||||
@@ -202,8 +193,7 @@ function renderWefaxLatestCard() {
|
|||||||
|
|
||||||
// ── History view: table ─────────────────────────────────────────────
|
// ── History view: table ─────────────────────────────────────────────
|
||||||
function getWefaxFilteredHistory() {
|
function getWefaxFilteredHistory() {
|
||||||
// Every rig's pictures are kept; the panel shows the selected rig's.
|
let items = wefaxImageHistory;
|
||||||
let items = wefaxRigImages();
|
|
||||||
|
|
||||||
if (wefaxFilterText) {
|
if (wefaxFilterText) {
|
||||||
items = items.filter(function (i) {
|
items = items.filter(function (i) {
|
||||||
@@ -263,7 +253,7 @@ function renderWefaxHistoryTable() {
|
|||||||
wefaxDom.historyList.replaceChildren(fragment);
|
wefaxDom.historyList.replaceChildren(fragment);
|
||||||
|
|
||||||
if (wefaxDom.historyCount) {
|
if (wefaxDom.historyCount) {
|
||||||
const total = wefaxRigImages().length;
|
const total = wefaxImageHistory.length;
|
||||||
const shown = items.length;
|
const shown = items.length;
|
||||||
wefaxDom.historyCount.textContent =
|
wefaxDom.historyCount.textContent =
|
||||||
total === 0
|
total === 0
|
||||||
@@ -308,9 +298,6 @@ function addWefaxImage(msg: WefaxImage): void {
|
|||||||
|
|
||||||
// ── SSE event handlers (public API) ─────────────────────────────────
|
// ── SSE event handlers (public API) ─────────────────────────────────
|
||||||
function onServerWefaxProgress(msg: WefaxProgress): void {
|
function onServerWefaxProgress(msg: WefaxProgress): void {
|
||||||
// The live canvas draws one picture at a time: a background rig's lines
|
|
||||||
// would paint straight over the one being watched.
|
|
||||||
if (!isActiveRigDecode(msg.rig_id)) return;
|
|
||||||
// State-only update (no image data): show decoder state in status.
|
// State-only update (no image data): show decoder state in status.
|
||||||
if (msg.state && !msg.line_data) {
|
if (msg.state && !msg.line_data) {
|
||||||
if (wefaxDom.status) {
|
if (wefaxDom.status) {
|
||||||
@@ -344,8 +331,6 @@ function onServerWefaxProgress(msg: WefaxProgress): void {
|
|||||||
|
|
||||||
function onServerWefax(msg: WefaxImage): void {
|
function onServerWefax(msg: WefaxImage): void {
|
||||||
addWefaxImage(msg);
|
addWefaxImage(msg);
|
||||||
// Kept in the history either way, so switching to that rig finds it there.
|
|
||||||
if (!isActiveRigDecode(msg.rig_id)) return;
|
|
||||||
|
|
||||||
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = 'none';
|
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = 'none';
|
||||||
if (wefaxDom.status) {
|
if (wefaxDom.status) {
|
||||||
@@ -453,7 +438,6 @@ wefaxWindow.trxPluginRuntime.registerDecoder({
|
|||||||
restore: restoreWefaxHistory,
|
restore: restoreWefaxHistory,
|
||||||
prune: pruneWefaxHistoryView,
|
prune: pruneWefaxHistoryView,
|
||||||
reset: resetWefaxHistoryView,
|
reset: resetWefaxHistoryView,
|
||||||
rerender: () => { renderWefaxLatestCard(); renderWefaxHistoryTable(); },
|
|
||||||
});
|
});
|
||||||
wefaxWindow.trxPluginRuntime.registerDecoder({
|
wefaxWindow.trxPluginRuntime.registerDecoder({
|
||||||
id: "wefax_progress",
|
id: "wefax_progress",
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
import { hostCore } from "./host.js";
|
import { hostCore } from "./host.js";
|
||||||
|
|
||||||
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
|
|
||||||
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
@@ -16,10 +15,6 @@ interface WsprMessage {
|
|||||||
snr_db?: number | undefined;
|
snr_db?: number | undefined;
|
||||||
dt_s?: number | undefined;
|
dt_s?: number | undefined;
|
||||||
freq_hz?: number | undefined;
|
freq_hz?: number | undefined;
|
||||||
rig_id?: string | null | undefined;
|
|
||||||
/** RF frequency the spot was heard on, kept so a map replay does not
|
|
||||||
* recompute it against wherever the dial has moved to since. */
|
|
||||||
_rfHz?: number | null | undefined;
|
|
||||||
receiver?: unknown;
|
receiver?: unknown;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
@@ -107,12 +102,10 @@ function renderWsprRow(msg: WsprMessage): HTMLDivElement {
|
|||||||
function renderWsprHistory(): void {
|
function renderWsprHistory(): void {
|
||||||
pruneWsprMessageHistory();
|
pruneWsprMessageHistory();
|
||||||
if (!wsprMessagesEl) return;
|
if (!wsprMessagesEl) return;
|
||||||
// The history holds every rig's decodes, for the map; the panel is about the
|
|
||||||
// rig the operator is working.
|
|
||||||
const rigMessages = forActiveRig(wsprMessageHistory);
|
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
for (const message of rigMessages) {
|
for (let i = 0; i < wsprMessageHistory.length; i += 1) {
|
||||||
fragment.appendChild(renderWsprRow(message));
|
const message = wsprMessageHistory[i];
|
||||||
|
if (message) fragment.appendChild(renderWsprRow(message));
|
||||||
}
|
}
|
||||||
wsprMessagesEl.replaceChildren(fragment);
|
wsprMessagesEl.replaceChildren(fragment);
|
||||||
}
|
}
|
||||||
@@ -137,8 +130,6 @@ function normalizeServerWsprMessage(msg: WsprMessage): { raw: string; grids: str
|
|||||||
station,
|
station,
|
||||||
rfHz,
|
rfHz,
|
||||||
history: {
|
history: {
|
||||||
rig_id: msg.rig_id ?? null,
|
|
||||||
_rfHz: rfHz,
|
|
||||||
receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null,
|
receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null,
|
||||||
ts_ms: msg.ts_ms,
|
ts_ms: msg.ts_ms,
|
||||||
snr_db: msg.snr_db,
|
snr_db: msg.snr_db,
|
||||||
@@ -151,12 +142,16 @@ function normalizeServerWsprMessage(msg: WsprMessage): { raw: string; grids: str
|
|||||||
|
|
||||||
function onServerWsprBatch(messages: WsprMessage[]): void {
|
function onServerWsprBatch(messages: WsprMessage[]): void {
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
|
if (wsprStatus) wsprStatus.textContent = "Receiving";
|
||||||
const normalized: WsprMessage[] = [];
|
const normalized: WsprMessage[] = [];
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const next = normalizeServerWsprMessage(msg);
|
const next = normalizeServerWsprMessage(msg);
|
||||||
// "Receiving" is the panel's own status, and the panel is the rig's.
|
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
|
||||||
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
|
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
||||||
plotWsprLocator(msg);
|
...msg,
|
||||||
|
...(next.rfHz === null ? {} : { freq_hz: next.rfHz }),
|
||||||
|
});
|
||||||
|
}
|
||||||
next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
||||||
normalized.push(next.history);
|
normalized.push(next.history);
|
||||||
}
|
}
|
||||||
@@ -316,24 +311,15 @@ document.getElementById("settings-clear-wspr-history")?.addEventListener("click"
|
|||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Hands a spot's grid squares to the map, if the map module is loaded yet.
|
|
||||||
* The module is lazy, so a spot heard before it arrived has to be replayable. */
|
|
||||||
function plotWsprLocator(msg: WsprMessage): void {
|
|
||||||
const next = normalizeServerWsprMessage(msg);
|
|
||||||
if (next.grids.length === 0 || !wsprWindow.mapAddLocator) return;
|
|
||||||
// A replayed spot carries the frequency it was heard on; a fresh one has it
|
|
||||||
// worked out from the dial it just arrived against.
|
|
||||||
const rfHz = finiteNumber(msg._rfHz) ?? next.rfHz;
|
|
||||||
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
|
||||||
...msg,
|
|
||||||
...(rfHz === null ? {} : { freq_hz: rfHz }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function onServerWspr(msg: WsprMessage): void {
|
function onServerWspr(msg: WsprMessage): void {
|
||||||
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
|
if (wsprStatus) wsprStatus.textContent = "Receiving";
|
||||||
const next = normalizeServerWsprMessage(msg);
|
const next = normalizeServerWsprMessage(msg);
|
||||||
plotWsprLocator(msg);
|
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
|
||||||
|
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
||||||
|
...msg,
|
||||||
|
...(next.rfHz === null ? {} : { freq_hz: next.rfHz }),
|
||||||
|
});
|
||||||
|
}
|
||||||
addWsprMessage(next.history);
|
addWsprMessage(next.history);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,7 +330,4 @@ wsprWindow.trxPluginRuntime.registerDecoder({
|
|||||||
restore: onServerWsprBatch,
|
restore: onServerWsprBatch,
|
||||||
prune: pruneWsprHistoryView,
|
prune: pruneWsprHistoryView,
|
||||||
reset: resetWsprHistoryView,
|
reset: resetWsprHistoryView,
|
||||||
rerender: renderWsprHistory,
|
|
||||||
// Oldest first, so the map builds the grids up in the order they were heard.
|
|
||||||
syncMap: () => { for (const message of [...wsprMessageHistory].reverse()) plotWsprLocator(message); },
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ interface ButtonStateOptions {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
type TabKind = "primary" | "secondary";
|
type TabKind = "primary" | "secondary";
|
||||||
type LayoutCapability = "broadcast" | "digital" | "ham";
|
type LayoutCapability = "broadcast" | "digital";
|
||||||
type LayoutName = "compact" | "broadcast" | "digital" | "full" | "ham";
|
type LayoutName = "compact" | "broadcast" | "digital" | "full";
|
||||||
interface OperatorLayout {
|
interface OperatorLayout {
|
||||||
label: string;
|
label: string;
|
||||||
unavailable?: string;
|
unavailable?: string;
|
||||||
@@ -191,11 +191,8 @@ 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, ham: false };
|
const layoutCapabilities: Record<LayoutCapability, boolean> = { broadcast: false, digital: 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.
|
||||||
@@ -502,7 +499,7 @@ function elementById<T extends HTMLElement>(id: string): T {
|
|||||||
if (restoreFocus) more.focus();
|
if (restoreFocus) more.focus();
|
||||||
};
|
};
|
||||||
api.closeMobileOverlays = closeMore;
|
api.closeMobileOverlays = closeMore;
|
||||||
["satellites", "statistics", "recorder", "settings", "about"].forEach((tabName) => {
|
["statistics", "recorder", "settings", "about"].forEach((tabName) => {
|
||||||
const source = nav.querySelector(`[data-tab="${tabName}"]`);
|
const source = nav.querySelector(`[data-tab="${tabName}"]`);
|
||||||
if (!source) return;
|
if (!source) return;
|
||||||
const item = document.createElement("button");
|
const item = document.createElement("button");
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { chromium } from "playwright-core";
|
|
||||||
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
|
|
||||||
|
|
||||||
/* global document */
|
|
||||||
|
|
||||||
const ALL_ROLES = ["read", "control", "transmit", "write", "administrator"];
|
|
||||||
const fixture = await startWebFixture({
|
|
||||||
authSession: {
|
|
||||||
authenticated: true,
|
|
||||||
username: "admin",
|
|
||||||
roles: ALL_ROLES,
|
|
||||||
auth_disabled: false,
|
|
||||||
},
|
|
||||||
users: [
|
|
||||||
{ username: "admin", roles: ALL_ROLES, enabled: true },
|
|
||||||
{ username: "guest", roles: ["guest"], enabled: true },
|
|
||||||
{ username: "listener", roles: ["read"], enabled: false },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const { browser, page, runtimeErrors } = await startBrowser(chromium);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await page.goto(`${fixture.origin}/settings`, { waitUntil: "domcontentloaded" });
|
|
||||||
await page.locator("#tab-settings").waitFor({ state: "visible" });
|
|
||||||
await page.locator("#settings-account-tab").waitFor({ state: "visible" });
|
|
||||||
await page.locator("#settings-users-tab").waitFor({ state: "visible" });
|
|
||||||
|
|
||||||
await page.locator("#settings-account-tab").click();
|
|
||||||
assert.equal(await page.locator("#account-password-form").isVisible(), true);
|
|
||||||
assert.equal(await page.locator("#subtab-settings-users").isVisible(), false);
|
|
||||||
|
|
||||||
await page.locator("#settings-users-tab").click();
|
|
||||||
await page.locator("#user-list").getByText("listener (disabled)").waitFor();
|
|
||||||
assert.equal(await page.locator("#user-create-form").isVisible(), true);
|
|
||||||
|
|
||||||
const state = await page.evaluate(() => {
|
|
||||||
const rows = [...document.querySelectorAll("#user-list > .sch-row")];
|
|
||||||
const rowFor = (username) => rows.find((row) => row.querySelector("strong")?.textContent.startsWith(username));
|
|
||||||
const admin = rowFor("admin");
|
|
||||||
const guest = rowFor("guest");
|
|
||||||
const listener = rowFor("listener");
|
|
||||||
const role = (row, value) => row?.querySelector(`input[value="${value}"]`);
|
|
||||||
return {
|
|
||||||
createRoles: [...document.querySelectorAll("#user-create-roles input")].map((input) => input.value),
|
|
||||||
adminEnabledLocked: admin?.querySelector('input[type="checkbox"]')?.disabled,
|
|
||||||
adminRoleLocked: role(admin, "administrator")?.disabled,
|
|
||||||
adminGuestLocked: role(admin, "guest")?.disabled,
|
|
||||||
adminRemoveLocked: admin?.querySelector("button.danger")?.disabled,
|
|
||||||
guestPasswordLocked: guest?.querySelector('input[type="password"]')?.disabled,
|
|
||||||
listenerEnabled: listener?.querySelector('input[type="checkbox"]')?.checked,
|
|
||||||
listenerRead: role(listener, "read")?.checked,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
assert.deepEqual(state.createRoles, ["guest", ...ALL_ROLES]);
|
|
||||||
assert.equal(state.adminEnabledLocked, true);
|
|
||||||
assert.equal(state.adminRoleLocked, true);
|
|
||||||
assert.equal(state.adminGuestLocked, true);
|
|
||||||
assert.equal(state.adminRemoveLocked, true);
|
|
||||||
assert.equal(state.guestPasswordLocked, true);
|
|
||||||
assert.equal(state.listenerEnabled, false);
|
|
||||||
assert.equal(state.listenerRead, true);
|
|
||||||
|
|
||||||
const listenerRow = page.locator("#user-list > .sch-row").filter({ hasText: "listener" });
|
|
||||||
const listenerPassword = listenerRow.locator('input[type="password"]');
|
|
||||||
await listenerRow.locator('input[value="guest"]').check();
|
|
||||||
assert.equal(await listenerPassword.isDisabled(), true);
|
|
||||||
assert.equal(await listenerRow.locator('input[value="read"]').isChecked(), true);
|
|
||||||
await listenerRow.locator('input[value="control"]').check();
|
|
||||||
assert.equal(await listenerRow.locator('input[value="guest"]').isChecked(), true);
|
|
||||||
assert.equal(await listenerPassword.isDisabled(), true);
|
|
||||||
await listenerRow.locator('input[value="guest"]').uncheck();
|
|
||||||
assert.equal(await listenerPassword.isDisabled(), false);
|
|
||||||
assert.deepEqual(runtimeErrors, []);
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
await fixture.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
const guestFixture = await startWebFixture({
|
|
||||||
authSession: {
|
|
||||||
authenticated: true,
|
|
||||||
username: "guest",
|
|
||||||
roles: ["guest"],
|
|
||||||
auth_disabled: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const guestBrowser = await startBrowser(chromium);
|
|
||||||
try {
|
|
||||||
await guestBrowser.page.goto(`${guestFixture.origin}/settings`, { waitUntil: "domcontentloaded" });
|
|
||||||
await guestBrowser.page.locator("#tab-settings").waitFor({ state: "visible" });
|
|
||||||
assert.equal(await guestBrowser.page.locator("#settings-account-tab").isVisible(), false);
|
|
||||||
assert.equal(await guestBrowser.page.locator("#settings-users-tab").isVisible(), false);
|
|
||||||
assert.deepEqual(guestBrowser.runtimeErrors, []);
|
|
||||||
} finally {
|
|
||||||
await guestBrowser.browser.close();
|
|
||||||
await guestFixture.close();
|
|
||||||
}
|
|
||||||
@@ -1,348 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
// The radio page and the digital modes page both describe one rig: the one the
|
|
||||||
// operator selected. Every rig a client is connected to feeds the same decode
|
|
||||||
// stream, so both the mini views over the waterfall and the panels behind them
|
|
||||||
// filter on the rig that heard each decode. The map is the exception and keeps
|
|
||||||
// all of them — it has its own rig filter — which is why the histories these
|
|
||||||
// views read from are never pruned by rig.
|
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
import vm from "node:vm";
|
|
||||||
import { bundleEntry } from "./bundle-entry.mjs";
|
|
||||||
import { createHost } from "./host-fixture.mjs";
|
|
||||||
|
|
||||||
class ElementFixture {
|
|
||||||
constructor(value = "") {
|
|
||||||
this.children = [];
|
|
||||||
this.innerHTML = "";
|
|
||||||
this.textContent = "";
|
|
||||||
this.value = value;
|
|
||||||
this.style = {};
|
|
||||||
this.dataset = {};
|
|
||||||
this.classList = { add() {}, remove() {}, toggle() {} };
|
|
||||||
}
|
|
||||||
appendChild(child) { this.children.push(child); return child; }
|
|
||||||
removeChild(child) { this.children.splice(this.children.indexOf(child), 1); }
|
|
||||||
replaceChildren(...nodes) { this.children = nodes.flatMap((node) => node.children ?? [node]); }
|
|
||||||
addEventListener() {}
|
|
||||||
setAttribute() {}
|
|
||||||
querySelector() { return null; }
|
|
||||||
querySelectorAll() { return []; }
|
|
||||||
get firstChild() { return this.children[0] ?? null; }
|
|
||||||
get lastElementChild() { return this.children.at(-1) ?? null; }
|
|
||||||
get scrollHeight() { return this.children.length; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A document whose named elements exist and whose unknown ones do not. */
|
|
||||||
function createDocument(elements) {
|
|
||||||
return {
|
|
||||||
documentElement: {},
|
|
||||||
getElementById: (id) => elements.get(id) ?? null,
|
|
||||||
querySelector: () => null,
|
|
||||||
querySelectorAll: () => [],
|
|
||||||
createElement: () => new ElementFixture(),
|
|
||||||
createDocumentFragment: () => new ElementFixture(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runPlugin(entry, { window, document: doc, extras = {} }) {
|
|
||||||
const context = vm.createContext({
|
|
||||||
window,
|
|
||||||
document: doc,
|
|
||||||
navigator: {},
|
|
||||||
requestAnimationFrame(callback) { callback(); return 1; },
|
|
||||||
getComputedStyle: () => ({ getPropertyValue: () => "" }),
|
|
||||||
Date, Number, String, Math, Array, Map, Set, Reflect, console,
|
|
||||||
...extras,
|
|
||||||
});
|
|
||||||
const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url));
|
|
||||||
const source = await bundleEntry(new URL(`../src/plugins/${entry}.ts`, import.meta.url));
|
|
||||||
new vm.Script(runtime).runInContext(context);
|
|
||||||
new vm.Script(source).runInContext(context);
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
|
|
||||||
test("the APRS mini view and panel both keep to the active rig", async () => {
|
|
||||||
const overlay = new ElementFixture();
|
|
||||||
const packets = new ElementFixture();
|
|
||||||
const totals = new ElementFixture();
|
|
||||||
const elements = new Map([
|
|
||||||
["aprs-bar-overlay", overlay],
|
|
||||||
["aprs-packets", packets],
|
|
||||||
["aprs-total-count", totals],
|
|
||||||
["mode", new ElementFixture("PKT")],
|
|
||||||
]);
|
|
||||||
const window = {
|
|
||||||
...createHost({ state: { lastActiveRigId: "rig-a" } }),
|
|
||||||
trxUi: { confirm: async () => true },
|
|
||||||
aprsMapAddStation: () => {},
|
|
||||||
};
|
|
||||||
await runPlugin("aprs", { window, document: createDocument(elements) });
|
|
||||||
|
|
||||||
const frame = { dest_call: "APRS", packet_type: "position", crc_ok: true, info: "beacon" };
|
|
||||||
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP1AAA", rig_id: "rig-a" });
|
|
||||||
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP2BBB", rig_id: "rig-b" });
|
|
||||||
|
|
||||||
assert.match(overlay.innerHTML, /SP1AAA/, "the active rig's frame is missing from the mini view");
|
|
||||||
assert.doesNotMatch(overlay.innerHTML, /SP2BBB/, "a background rig's frame reached the mini view");
|
|
||||||
assert.equal(packets.children.length, 1, "the APRS panel listed a background rig's frame");
|
|
||||||
assert.equal(totals.textContent, "1 total", `the APRS panel counted "${totals.textContent}"`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the APRS mini view shows every frame until a rig is known", async () => {
|
|
||||||
const overlay = new ElementFixture();
|
|
||||||
const elements = new Map([
|
|
||||||
["aprs-bar-overlay", overlay],
|
|
||||||
["mode", new ElementFixture("PKT")],
|
|
||||||
]);
|
|
||||||
const window = {
|
|
||||||
...createHost(),
|
|
||||||
trxUi: { confirm: async () => true },
|
|
||||||
aprsMapAddStation: () => {},
|
|
||||||
};
|
|
||||||
await runPlugin("aprs", { window, document: createDocument(elements) });
|
|
||||||
|
|
||||||
const frame = { dest_call: "APRS", packet_type: "position", crc_ok: true, info: "beacon" };
|
|
||||||
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP1AAA", rig_id: "rig-a" });
|
|
||||||
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP2BBB" });
|
|
||||||
|
|
||||||
assert.match(overlay.innerHTML, /SP1AAA/);
|
|
||||||
assert.match(overlay.innerHTML, /SP2BBB/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the AIS mini view keeps to the active rig", async () => {
|
|
||||||
const overlay = new ElementFixture();
|
|
||||||
const elements = new Map([
|
|
||||||
["ais-bar-overlay", overlay],
|
|
||||||
["mode", new ElementFixture("AIS")],
|
|
||||||
]);
|
|
||||||
const window = {
|
|
||||||
...createHost({ state: { lastActiveRigId: "rig-a" } }),
|
|
||||||
trxUi: { confirm: async () => true },
|
|
||||||
aisMapAddVessel: () => {},
|
|
||||||
};
|
|
||||||
await runPlugin("ais", { window, document: createDocument(elements) });
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
window.trxPluginRuntime.dispatch("ais", { mmsi: 261000001, vessel_name: "NEARBY", channel: "A", ts_ms: now, rig_id: "rig-a" });
|
|
||||||
window.trxPluginRuntime.dispatch("ais", { mmsi: 261000002, vessel_name: "ELSEWHERE", channel: "A", ts_ms: now, rig_id: "rig-b" });
|
|
||||||
|
|
||||||
assert.match(overlay.innerHTML, /NEARBY/);
|
|
||||||
assert.doesNotMatch(overlay.innerHTML, /ELSEWHERE/, "a background rig's vessel reached the mini view");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the VDES mini view keeps to the active rig", async () => {
|
|
||||||
const overlay = new ElementFixture();
|
|
||||||
const elements = new Map([
|
|
||||||
["vdes-bar-overlay", overlay],
|
|
||||||
["mode", new ElementFixture("VDES")],
|
|
||||||
]);
|
|
||||||
const window = {
|
|
||||||
...createHost({ state: { lastActiveRigId: "rig-a" } }),
|
|
||||||
trxUi: { confirm: async () => true },
|
|
||||||
vdesMapAddPoint: () => {},
|
|
||||||
};
|
|
||||||
await runPlugin("vdes", { window, document: createDocument(elements) });
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
window.trxPluginRuntime.dispatch("vdes", { callsign: "SP1AAA", bit_len: 120, ts_ms: now, rig_id: "rig-a" });
|
|
||||||
window.trxPluginRuntime.dispatch("vdes", { callsign: "SP2BBB", bit_len: 120, ts_ms: now, rig_id: "rig-b" });
|
|
||||||
|
|
||||||
assert.match(overlay.innerHTML, /SP1AAA/);
|
|
||||||
assert.doesNotMatch(overlay.innerHTML, /SP2BBB/, "a background rig's burst reached the mini view");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the CW mini view keeps to the active rig and does not braid two rigs into a line", async () => {
|
|
||||||
const overlay = new ElementFixture();
|
|
||||||
const elements = new Map([
|
|
||||||
["cw-bar-overlay", overlay],
|
|
||||||
["cw-output", new ElementFixture()],
|
|
||||||
["mode", new ElementFixture("CW")],
|
|
||||||
]);
|
|
||||||
const window = {
|
|
||||||
...createHost({ state: { lastActiveRigId: "rig-a" } }),
|
|
||||||
trxUi: { confirm: async () => true },
|
|
||||||
addEventListener() {},
|
|
||||||
};
|
|
||||||
await runPlugin("cw", { window, document: createDocument(elements) });
|
|
||||||
|
|
||||||
window.trxPluginRuntime.dispatch("cw", { text: "CQ ", wpm: 18, tone_hz: 700, rig_id: "rig-a" });
|
|
||||||
window.trxPluginRuntime.dispatch("cw", { text: "DX ", wpm: 22, tone_hz: 600, rig_id: "rig-b" });
|
|
||||||
window.trxPluginRuntime.dispatch("cw", { text: "SP1AAA", wpm: 18, tone_hz: 700, rig_id: "rig-a" });
|
|
||||||
|
|
||||||
assert.match(overlay.innerHTML, /CQ SP1AAA/, "the active rig's line was broken up or lost");
|
|
||||||
assert.doesNotMatch(overlay.innerHTML, /DX/, "a background rig's characters reached the mini view");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the FT8 mini view keeps to the active rig", async () => {
|
|
||||||
const overlay = new ElementFixture();
|
|
||||||
const elements = new Map([
|
|
||||||
["ft8-bar-overlay", overlay],
|
|
||||||
["mode", new ElementFixture("DIG")],
|
|
||||||
]);
|
|
||||||
const window = {
|
|
||||||
...createHost({ state: { lastActiveRigId: "rig-a" } }),
|
|
||||||
ft8BaseHz: 7_074_000,
|
|
||||||
trxUi: { confirm: async () => true },
|
|
||||||
mapAddLocator: () => {},
|
|
||||||
setInterval() { return 1; },
|
|
||||||
};
|
|
||||||
await runPlugin("ft8", { window, document: createDocument(elements) });
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
window.trxPluginRuntime.dispatch("ft8", { message: "CQ SP1AAA JO91", freq_hz: 500, ts_ms: now, rig_id: "rig-a" });
|
|
||||||
window.trxPluginRuntime.dispatch("ft8", { message: "CQ SP2BBB JO94", freq_hz: 800, ts_ms: now, rig_id: "rig-b" });
|
|
||||||
|
|
||||||
assert.match(overlay.innerHTML, /SP1AAA/);
|
|
||||||
assert.doesNotMatch(overlay.innerHTML, /SP2BBB/, "a background rig's decode reached the mini view");
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── The digital modes panels ───────────────────────────────────────────────
|
|
||||||
// The same rule one page over: a panel lists the selected rig's decodes only.
|
|
||||||
|
|
||||||
test("the AIS panel lists the active rig's vessels only", async () => {
|
|
||||||
const messages = new ElementFixture();
|
|
||||||
const count = new ElementFixture();
|
|
||||||
const elements = new Map([
|
|
||||||
["ais-messages", messages],
|
|
||||||
["ais-vessel-count", count],
|
|
||||||
["mode", new ElementFixture("AIS")],
|
|
||||||
]);
|
|
||||||
const window = {
|
|
||||||
...createHost({ state: { lastActiveRigId: "rig-a" } }),
|
|
||||||
trxUi: { confirm: async () => true },
|
|
||||||
aisMapAddVessel: () => {},
|
|
||||||
};
|
|
||||||
await runPlugin("ais", { window, document: createDocument(elements) });
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
window.trxPluginRuntime.dispatch("ais", { mmsi: 261000001, vessel_name: "NEARBY", channel: "A", ts_ms: now, rig_id: "rig-a" });
|
|
||||||
window.trxPluginRuntime.dispatch("ais", { mmsi: 261000002, vessel_name: "ELSEWHERE", channel: "A", ts_ms: now, rig_id: "rig-b" });
|
|
||||||
|
|
||||||
assert.equal(messages.children.length, 1, "the AIS panel listed a background rig's vessel");
|
|
||||||
assert.equal(count.textContent, "1 vessel", `the panel counted "${count.textContent}"`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the VDES panel lists the active rig's bursts only", async () => {
|
|
||||||
const messages = new ElementFixture();
|
|
||||||
const count = new ElementFixture();
|
|
||||||
const elements = new Map([
|
|
||||||
["vdes-messages", messages],
|
|
||||||
["vdes-frame-count", count],
|
|
||||||
["mode", new ElementFixture("VDES")],
|
|
||||||
]);
|
|
||||||
const window = {
|
|
||||||
...createHost({ state: { lastActiveRigId: "rig-a" } }),
|
|
||||||
trxUi: { confirm: async () => true },
|
|
||||||
vdesMapAddPoint: () => {},
|
|
||||||
};
|
|
||||||
await runPlugin("vdes", { window, document: createDocument(elements) });
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
window.trxPluginRuntime.dispatch("vdes", { callsign: "SP1AAA", bit_len: 120, ts_ms: now, rig_id: "rig-a" });
|
|
||||||
window.trxPluginRuntime.dispatch("vdes", { callsign: "SP2BBB", bit_len: 120, ts_ms: now, rig_id: "rig-b" });
|
|
||||||
|
|
||||||
assert.equal(messages.children.length, 1, "the VDES panel listed a background rig's burst");
|
|
||||||
assert.equal(count.textContent, "1 burst", `the panel counted "${count.textContent}"`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the FT8 panel lists the active rig's decodes only", async () => {
|
|
||||||
const messages = new ElementFixture();
|
|
||||||
const elements = new Map([
|
|
||||||
["ft8-messages", messages],
|
|
||||||
["mode", new ElementFixture("DIG")],
|
|
||||||
]);
|
|
||||||
const window = {
|
|
||||||
...createHost({ state: { lastActiveRigId: "rig-a" } }),
|
|
||||||
ft8BaseHz: 7_074_000,
|
|
||||||
trxUi: { confirm: async () => true },
|
|
||||||
mapAddLocator: () => {},
|
|
||||||
setInterval() { return 1; },
|
|
||||||
};
|
|
||||||
await runPlugin("ft8", { window, document: createDocument(elements) });
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
window.trxPluginRuntime.dispatch("ft8", { message: "CQ SP1AAA JO91", freq_hz: 500, ts_ms: now, rig_id: "rig-a" });
|
|
||||||
window.trxPluginRuntime.dispatch("ft8", { message: "CQ SP2BBB JO94", freq_hz: 800, ts_ms: now, rig_id: "rig-b" });
|
|
||||||
|
|
||||||
assert.equal(messages.children.length, 1, "the FT8 panel listed a background rig's decode");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the WSPR panel lists the active rig's spots only", async () => {
|
|
||||||
const messages = new ElementFixture();
|
|
||||||
const elements = new Map([
|
|
||||||
["wspr-messages", messages],
|
|
||||||
["mode", new ElementFixture("DIG")],
|
|
||||||
]);
|
|
||||||
const window = {
|
|
||||||
...createHost({ state: { lastActiveRigId: "rig-a" } }),
|
|
||||||
ft8BaseHz: 14_095_600,
|
|
||||||
trxUi: { confirm: async () => true },
|
|
||||||
mapAddLocator: () => {},
|
|
||||||
};
|
|
||||||
await runPlugin("wspr", {
|
|
||||||
window,
|
|
||||||
document: createDocument(elements),
|
|
||||||
extras: { setInterval: () => 1 },
|
|
||||||
});
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
window.trxPluginRuntime.dispatch("wspr", { message: "SP1AAA JO91 30", freq_hz: 1_500, ts_ms: now, rig_id: "rig-a" });
|
|
||||||
window.trxPluginRuntime.dispatch("wspr", { message: "SP2BBB JO94 27", freq_hz: 1_520, ts_ms: now, rig_id: "rig-b" });
|
|
||||||
|
|
||||||
assert.equal(messages.children.length, 1, "the WSPR panel listed a background rig's spot");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the CW pane copies the active rig only", async () => {
|
|
||||||
const output = new ElementFixture();
|
|
||||||
const elements = new Map([
|
|
||||||
["cw-output", output],
|
|
||||||
["cw-bar-overlay", new ElementFixture()],
|
|
||||||
["mode", new ElementFixture("CW")],
|
|
||||||
]);
|
|
||||||
const window = {
|
|
||||||
...createHost({ state: { lastActiveRigId: "rig-a" } }),
|
|
||||||
trxUi: { confirm: async () => true },
|
|
||||||
addEventListener() {},
|
|
||||||
};
|
|
||||||
await runPlugin("cw", { window, document: createDocument(elements) });
|
|
||||||
|
|
||||||
window.trxPluginRuntime.dispatch("cw", { text: "CQ ", wpm: 18, tone_hz: 700, rig_id: "rig-a" });
|
|
||||||
window.trxPluginRuntime.dispatch("cw", { text: "XX ", wpm: 22, tone_hz: 600, rig_id: "rig-b" });
|
|
||||||
window.trxPluginRuntime.dispatch("cw", { text: "SP1AAA", wpm: 18, tone_hz: 700, rig_id: "rig-a" });
|
|
||||||
|
|
||||||
const copied = output.children.map((line) => line.textContent).join("");
|
|
||||||
assert.equal(copied, "CQ SP1AAA", `the pane copied "${copied}"`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("switching rigs repaints the panels through the runtime", async () => {
|
|
||||||
const packets = new ElementFixture();
|
|
||||||
const elements = new Map([
|
|
||||||
["aprs-packets", packets],
|
|
||||||
["aprs-bar-overlay", new ElementFixture()],
|
|
||||||
["mode", new ElementFixture("PKT")],
|
|
||||||
]);
|
|
||||||
// A mutable host: the rig picker writes lastActiveRigId, and the panels read
|
|
||||||
// it as they paint.
|
|
||||||
const host = createHost({ state: { lastActiveRigId: "rig-a" } });
|
|
||||||
let activeRigId = "rig-a";
|
|
||||||
Object.defineProperty(host.trx.state, "lastActiveRigId", { get: () => activeRigId });
|
|
||||||
const window = { ...host, trxUi: { confirm: async () => true }, aprsMapAddStation: () => {} };
|
|
||||||
await runPlugin("aprs", { window, document: createDocument(elements) });
|
|
||||||
|
|
||||||
const frame = { dest_call: "APRS", packet_type: "position", crc_ok: true, info: "beacon" };
|
|
||||||
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP1AAA", rig_id: "rig-a" });
|
|
||||||
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP2BBB", rig_id: "rig-b" });
|
|
||||||
assert.equal(packets.children.length, 1);
|
|
||||||
|
|
||||||
activeRigId = "rig-b";
|
|
||||||
window.trxPluginRuntime.rerenderAll();
|
|
||||||
assert.equal(packets.children.length, 1, "the panel did not follow the switch");
|
|
||||||
const shown = packets.children.map((row) => row.innerHTML).join("");
|
|
||||||
assert.match(shown, /SP2BBB/, "the panel still shows the rig that was switched away from");
|
|
||||||
assert.doesNotMatch(shown, /SP1AAA/);
|
|
||||||
});
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
import vm from "node:vm";
|
|
||||||
import { bundleEntry } from "./bundle-entry.mjs";
|
|
||||||
|
|
||||||
const source = await bundleEntry(new URL("../src/api/auth.ts", import.meta.url), "AuthApi");
|
|
||||||
|
|
||||||
function loadAuth(fetch) {
|
|
||||||
const context = vm.createContext({ fetch, console });
|
|
||||||
new vm.Script(source).runInContext(context);
|
|
||||||
return context.AuthApi;
|
|
||||||
}
|
|
||||||
|
|
||||||
test("role policy centralizes Guest and separates Control from Transmit", () => {
|
|
||||||
const auth = loadAuth(async () => { throw new Error("unused"); });
|
|
||||||
assert.deepEqual(Array.from(auth.AUTH_ROLES), ["guest", "read", "control", "transmit", "write", "administrator"]);
|
|
||||||
assert.equal(auth.hasAuthRole(["guest"], "read"), true);
|
|
||||||
assert.equal(auth.hasAccountControls(["guest"]), false);
|
|
||||||
assert.equal(auth.hasAccountControls(["read"]), true);
|
|
||||||
assert.equal(auth.hasAuthRole(["control"], "read"), true);
|
|
||||||
assert.equal(auth.hasAuthRole(["transmit"], "read"), true);
|
|
||||||
assert.equal(auth.hasAuthRole(["control"], "transmit"), false);
|
|
||||||
assert.equal(auth.hasAuthRole(["control"], "write"), false);
|
|
||||||
assert.equal(auth.hasAuthRole(["administrator"], "write"), true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("auth responses normalize roles and require the managed-account lifecycle state", async () => {
|
|
||||||
const replies = new Map([
|
|
||||||
["/auth/session", { authenticated: true, roles: ["write", "read", "write"], username: "alice" }],
|
|
||||||
["/auth/users", [{ username: "alice", roles: ["write", "read"], enabled: false }]],
|
|
||||||
]);
|
|
||||||
const auth = loadAuth(async (url) => ({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
json: async () => replies.get(String(url)),
|
|
||||||
}));
|
|
||||||
|
|
||||||
assert.deepEqual(Array.from((await auth.fetchAuthSession()).roles), ["read", "write"]);
|
|
||||||
assert.deepEqual(Array.from((await auth.listUsers())[0].roles), ["read", "write"]);
|
|
||||||
assert.equal((await auth.listUsers())[0].enabled, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("changing a password sends current and replacement credentials", async () => {
|
|
||||||
let request;
|
|
||||||
const auth = loadAuth(async (url, init) => {
|
|
||||||
request = { url, init };
|
|
||||||
return { ok: true, status: 200, json: async () => ({}) };
|
|
||||||
});
|
|
||||||
|
|
||||||
await auth.changeOwnPassword("old-password", "new-password");
|
|
||||||
assert.equal(request.url, "/auth/account/password");
|
|
||||||
assert.equal(request.init.method, "PATCH");
|
|
||||||
assert.deepEqual(JSON.parse(request.init.body), {
|
|
||||||
current_password: "old-password",
|
|
||||||
new_password: "new-password",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
// The background decode panel: one list that both picks the bookmarks and says
|
|
||||||
// what each one is doing. It used to be two lists of the same bookmarks — one
|
|
||||||
// to choose from, one to read state off — and it filled neither until the
|
|
||||||
// operator switched rigs, because it took whatever rig it was handed at load
|
|
||||||
// and that was nothing yet.
|
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { chromium } from "playwright-core";
|
|
||||||
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
|
|
||||||
|
|
||||||
/* global document */
|
|
||||||
|
|
||||||
const BOOKMARKS = [
|
|
||||||
{ id: "bm-ft8-20", name: "FT8 20 m", freq_hz: 14_074_000, mode: "USB", decoders: ["ft8"] },
|
|
||||||
{ id: "bm-ft8-40", name: "FT8 40 m", freq_hz: 7_074_000, mode: "USB", decoders: ["ft8"] },
|
|
||||||
{ id: "bm-ft4-20", name: "FT4 20 m", freq_hz: 14_080_000, mode: "USB", decoders: ["ft4"] },
|
|
||||||
{ id: "bm-aprs", name: "APRS 2 m", freq_hz: 144_800_000, mode: "PKT", decoders: ["aprs"] },
|
|
||||||
// Nothing that runs in the background can decode CW, so it must not be offered.
|
|
||||||
{ id: "bm-cw", name: "CW practice", freq_hz: 7_030_000, mode: "CW", decoders: ["cw"] },
|
|
||||||
];
|
|
||||||
|
|
||||||
const backgroundDecode = {
|
|
||||||
config: { remote: "rig-a", enabled: true, bookmark_ids: ["bm-ft8-20", "bm-ft8-40", "bm-aprs"] },
|
|
||||||
status: {
|
|
||||||
active_rig: true,
|
|
||||||
center_hz: 14_100_000,
|
|
||||||
sample_rate: 2_400_000,
|
|
||||||
entries: [
|
|
||||||
{ bookmark_id: "bm-ft8-20", bookmark_name: "FT8 20 m", freq_hz: 14_074_000, mode: "USB", decoder_kinds: ["ft8"], state: "active" },
|
|
||||||
{ bookmark_id: "bm-ft8-40", bookmark_name: "FT8 40 m", freq_hz: 7_074_000, mode: "USB", decoder_kinds: ["ft8"], state: "out_of_span" },
|
|
||||||
{ bookmark_id: "bm-aprs", bookmark_name: "APRS 2 m", freq_hz: 144_800_000, mode: "PKT", decoder_kinds: ["aprs"], state: "handled_by_scheduler" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const readRows = () => page.evaluate(() => {
|
|
||||||
const rows = [...document.querySelectorAll("#bgd-bookmark-checklist .bgd-checklist-row")];
|
|
||||||
return rows.map((row) => ({
|
|
||||||
name: row.querySelector(".bgd-checklist-name")?.textContent ?? "",
|
|
||||||
checked: row.querySelector("input[type=checkbox]")?.checked ?? false,
|
|
||||||
state: row.querySelector(".bgd-state")?.dataset.state ?? null,
|
|
||||||
stateText: row.querySelector(".bgd-state")?.textContent?.trim() ?? "",
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
const openPanel = async () => {
|
|
||||||
await page.goto(`${fixture.origin}/settings`, { waitUntil: "domcontentloaded" });
|
|
||||||
await page.locator("#tab-settings").waitFor({ state: "visible" });
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
await page.locator('.sub-tab[data-subtab="settings-background-decode"]').click();
|
|
||||||
await page.waitForTimeout(1200);
|
|
||||||
};
|
|
||||||
|
|
||||||
const fixture = await startWebFixture({ spectrum: true, bookmarks: BOOKMARKS, backgroundDecode });
|
|
||||||
const { browser, page, runtimeErrors } = await startBrowser(chromium);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await page.setViewportSize({ width: 1400, height: 950 });
|
|
||||||
await openPanel();
|
|
||||||
|
|
||||||
// Nothing was switched, clicked or reloaded: the panel asked the host which
|
|
||||||
// rig it is on and filled itself.
|
|
||||||
const rows = await readRows();
|
|
||||||
assert.deepEqual(rows.map((row) => row.name), ["FT8 20 m", "FT8 40 m", "FT4 20 m", "APRS 2 m"],
|
|
||||||
"the list does not hold the bookmarks a background channel can decode");
|
|
||||||
assert.deepEqual(rows.map((row) => row.checked), [true, true, false, true],
|
|
||||||
"the saved selection is not reflected in the list");
|
|
||||||
|
|
||||||
// Each row carries its own state, so there is no second list to consult.
|
|
||||||
assert.deepEqual(rows.map((row) => row.state),
|
|
||||||
["active", "out_of_span", "unselected", "handled_by_scheduler"],
|
|
||||||
"the rows do not carry the state of the bookmark they name");
|
|
||||||
assert.match(rows[0].stateText, /Decoding/);
|
|
||||||
assert.match(rows[1].stateText, /Out of span/);
|
|
||||||
|
|
||||||
// What the rig can hear, and what the selection adds up to.
|
|
||||||
const context = await page.evaluate(() => ({
|
|
||||||
span: document.getElementById("bgd-span-summary")?.textContent?.trim(),
|
|
||||||
summary: document.getElementById("bgd-selection-summary")?.textContent?.trim(),
|
|
||||||
}));
|
|
||||||
assert.match(context.span, /14\.1 MHz/, `the span reads "${context.span}"`);
|
|
||||||
assert.match(context.summary, /3 bookmarks selected, 1 decoding now\./, `the summary reads "${context.summary}"`);
|
|
||||||
|
|
||||||
// Save has nothing to do until something changes, and says so again once it
|
|
||||||
// has been done.
|
|
||||||
const saveState = () => page.evaluate(() => {
|
|
||||||
const btn = document.getElementById("background-decode-save-btn");
|
|
||||||
return { disabled: btn.disabled, dirty: btn.classList.contains("sch-dirty") };
|
|
||||||
});
|
|
||||||
assert.deepEqual(await saveState(), { disabled: true, dirty: false }, "Save offers itself with nothing to save");
|
|
||||||
|
|
||||||
await page.locator('#bgd-bookmark-checklist .bgd-checklist-row:nth-child(3) input').click();
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
assert.deepEqual(await saveState(), { disabled: false, dirty: true }, "Save stayed inert after a change");
|
|
||||||
|
|
||||||
await page.locator("#background-decode-save-btn").click();
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
assert.deepEqual(await saveState(), { disabled: true, dirty: false }, "Save stayed live after saving");
|
|
||||||
|
|
||||||
// The selection reached the server and comes back on the next load.
|
|
||||||
await openPanel();
|
|
||||||
const saved = await readRows();
|
|
||||||
assert.deepEqual(saved.map((row) => row.checked), [true, true, true, true],
|
|
||||||
"the newly selected bookmark did not survive a reload");
|
|
||||||
|
|
||||||
assert.deepEqual(runtimeErrors, []);
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
await fixture.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
// With no bookmarks at all the panel says what to do about it rather than
|
|
||||||
// showing an empty box.
|
|
||||||
const emptyFixture = await startWebFixture({ spectrum: true, bookmarks: [] });
|
|
||||||
const empty = await startBrowser(chromium);
|
|
||||||
try {
|
|
||||||
await empty.page.setViewportSize({ width: 1400, height: 950 });
|
|
||||||
await empty.page.goto(`${emptyFixture.origin}/settings`, { waitUntil: "domcontentloaded" });
|
|
||||||
await empty.page.locator("#tab-settings").waitFor({ state: "visible" });
|
|
||||||
await empty.page.waitForTimeout(2000);
|
|
||||||
await empty.page.locator('.sub-tab[data-subtab="settings-background-decode"]').click();
|
|
||||||
await empty.page.waitForTimeout(1000);
|
|
||||||
const text = await empty.page.evaluate(() =>
|
|
||||||
document.getElementById("bgd-bookmark-checklist")?.textContent?.trim() ?? "");
|
|
||||||
assert.match(text, /No bookmarks yet/, `the empty list reads "${text}"`);
|
|
||||||
const summary = await empty.page.evaluate(() =>
|
|
||||||
document.getElementById("bgd-selection-summary")?.textContent?.trim() ?? "");
|
|
||||||
assert.match(summary, /Nothing selected/, `the summary reads "${summary}"`);
|
|
||||||
} finally {
|
|
||||||
await empty.browser.close();
|
|
||||||
await emptyFixture.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("background decode panel tests passed");
|
|
||||||
+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", ["administrator"]);
|
window.trx.modules.backgroundDecode.initialize("rig/a", "control");
|
||||||
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,
|
||||||
authRoles: ["read", "control", "transmit", "write", "administrator"],
|
authRole: "control",
|
||||||
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, authRoles: ["read"] });
|
const { window } = hostFixture({ authEnabled: true, authRole: "rx" });
|
||||||
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.authRoles = ["read", "write"];
|
window.trx.state.authRole = "control";
|
||||||
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, "");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,25 +22,9 @@ const BEACON = {
|
|||||||
packet_type: "position", crc_ok: true, lat: 54.35, lon: 18.65,
|
packet_type: "position", crc_ok: true, lat: 54.35, lon: 18.65,
|
||||||
symbol_table: "/", symbol_code: ">", rig_id: "rig-a",
|
symbol_table: "/", symbol_code: ">", rig_id: "rig-a",
|
||||||
};
|
};
|
||||||
// A second rig listening in the background, on its own band. Both pages
|
|
||||||
// describe the rig on screen, so its traffic belongs in neither the panel nor
|
|
||||||
// the mini view — only on the map, which shows the whole station.
|
|
||||||
// HF APRS travels a different band and a different path from the VHF list, so
|
|
||||||
// it goes on the map under a source of its own that the filter can hide.
|
|
||||||
const HF_BEACON = {
|
|
||||||
type: "hf_aprs", src_call: "SP5HF-7", dest_call: "APRS", path: "WIDE2-2", info: "HF beacon",
|
|
||||||
packet_type: "position", crc_ok: true, lat: 50.06, lon: 19.94,
|
|
||||||
symbol_table: "/", symbol_code: ">", rig_id: "rig-a",
|
|
||||||
};
|
|
||||||
const OTHER_RIG_VESSEL = {
|
|
||||||
...VESSEL, mmsi: 244660001, vessel_name: "ELDERBERRY", callsign: "PBTY",
|
|
||||||
lat: 51.92, lon: 4.48, rig_id: "rig-b",
|
|
||||||
};
|
|
||||||
|
|
||||||
// AIS is what the mini view for vessels is gated on; the rig has to be on it.
|
// AIS is what the mini view for vessels is gated on; the rig has to be on it.
|
||||||
const fixture = await startWebFixture({
|
const fixture = await startWebFixture({ spectrum: true, decodes: [VESSEL, BEACON], mode: "AIS" });
|
||||||
spectrum: true, decodes: [VESSEL, BEACON, HF_BEACON, OTHER_RIG_VESSEL], mode: "AIS",
|
|
||||||
});
|
|
||||||
const { browser, page, runtimeErrors } = await startBrowser(chromium);
|
const { browser, page, runtimeErrors } = await startBrowser(chromium);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -57,14 +41,11 @@ try {
|
|||||||
aprs: document.getElementById("aprs-packets")?.children.length ?? 0,
|
aprs: document.getElementById("aprs-packets")?.children.length ?? 0,
|
||||||
aisStatus: document.getElementById("ais-status")?.textContent ?? "",
|
aisStatus: document.getElementById("ais-status")?.textContent ?? "",
|
||||||
aprsStatus: document.getElementById("aprs-status")?.textContent ?? "",
|
aprsStatus: document.getElementById("aprs-status")?.textContent ?? "",
|
||||||
otherRig: document.getElementById("ais-messages")?.textContent.includes("ELDERBERRY") ?? false,
|
|
||||||
aisRows: document.getElementById("ais-messages")?.children.length ?? 0,
|
|
||||||
mapLoaded: !!window.trx.modules.map,
|
mapLoaded: !!window.trx.modules.map,
|
||||||
}));
|
}));
|
||||||
assert.equal(panels.mapLoaded, false, "the map module was loaded, so this proves nothing");
|
assert.equal(panels.mapLoaded, false, "the map module was loaded, so this proves nothing");
|
||||||
assert.ok(panels.ais > 0, `the AIS panel is empty (status: ${panels.aisStatus})`);
|
assert.ok(panels.ais > 0, `the AIS panel is empty (status: ${panels.aisStatus})`);
|
||||||
assert.ok(panels.aprs > 0, `the APRS panel is empty (status: ${panels.aprsStatus})`);
|
assert.ok(panels.aprs > 0, `the APRS panel is empty (status: ${panels.aprsStatus})`);
|
||||||
assert.equal(panels.otherRig, false, "the AIS panel listed a background rig's vessel");
|
|
||||||
|
|
||||||
// The mini view rides over the waterfall on the radio page.
|
// The mini view rides over the waterfall on the radio page.
|
||||||
await page.locator('.tab[data-tab="main"]').click();
|
await page.locator('.tab[data-tab="main"]').click();
|
||||||
@@ -75,13 +56,11 @@ try {
|
|||||||
shown: getComputedStyle(bar).display !== "none",
|
shown: getComputedStyle(bar).display !== "none",
|
||||||
pins: bar.querySelectorAll(".aprs-bar-pin").length,
|
pins: bar.querySelectorAll(".aprs-bar-pin").length,
|
||||||
names: bar.textContent.includes("NEDERLAND"),
|
names: bar.textContent.includes("NEDERLAND"),
|
||||||
otherRig: bar.textContent.includes("ELDERBERRY"),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
assert.equal(miniView.shown, true, "the AIS mini view did not appear");
|
assert.equal(miniView.shown, true, "the AIS mini view did not appear");
|
||||||
assert.ok(miniView.pins > 0, "the mini view has no pin to follow");
|
assert.ok(miniView.pins > 0, "the mini view has no pin to follow");
|
||||||
assert.equal(miniView.names, true, "the mini view does not name the vessel");
|
assert.equal(miniView.names, true, "the mini view does not name the vessel");
|
||||||
assert.equal(miniView.otherRig, false, "the mini view shows a background rig's vessel");
|
|
||||||
|
|
||||||
// Following the pin: the map opens, on the vessel. This is the path that was
|
// Following the pin: the map opens, on the vessel. This is the path that was
|
||||||
// broken for every decoder — the module that owned the navigation had not
|
// broken for every decoder — the module that owned the navigation had not
|
||||||
@@ -110,41 +89,8 @@ try {
|
|||||||
: Object.keys(collection ?? {}).length);
|
: Object.keys(collection ?? {}).length);
|
||||||
return { ais: size(map?.aisMarkers), stations: size(map?.stationMarkers) };
|
return { ais: size(map?.aisMarkers), stations: size(map?.stationMarkers) };
|
||||||
});
|
});
|
||||||
|
assert.ok(markers.ais > 0, "the vessel never reached the map");
|
||||||
assert.ok(markers.stations > 0, "the APRS station never reached the map");
|
assert.ok(markers.stations > 0, "the APRS station never reached the map");
|
||||||
// Both APRS stations are on it, each under its own source: the HF one used
|
|
||||||
// to be dropped entirely, since the HF list never plotted anything.
|
|
||||||
const aprsSources = await page.evaluate(() => {
|
|
||||||
const entries = [...(window.trx.modules.map?.stationMarkers ?? new Map()).entries()];
|
|
||||||
return entries.map(([key, entry]) => `${entry?.type ?? "?"}:${entry?.call ?? key}`).sort();
|
|
||||||
});
|
|
||||||
assert.deepEqual(aprsSources, ["aprs:SP2SJG-9", "hf_aprs:SP5HF-7"],
|
|
||||||
`the map holds ${JSON.stringify(aprsSources)}`);
|
|
||||||
// And the filter offers HF APRS on a chip of its own, next to the VHF one.
|
|
||||||
await page.locator('#map-locator-phase .map-locator-phase-btn[data-phase="type"]').click();
|
|
||||||
await page.waitForTimeout(400);
|
|
||||||
const chips = await page.evaluate(() => [...document.querySelectorAll("#map-locator-choice-filter .map-locator-chip")]
|
|
||||||
.map((chip) => chip.dataset.filterKey));
|
|
||||||
assert.ok(chips.includes("aprs") && chips.includes("hf_aprs"),
|
|
||||||
`the Show chips are ${JSON.stringify(chips)}`);
|
|
||||||
|
|
||||||
// Turning that chip off takes the HF station off the map and leaves the VHF
|
|
||||||
// one on: the two are filtered apart, which is the whole point of the split.
|
|
||||||
await page.locator('#map-locator-choice-filter .map-locator-chip[data-filter-key="hf_aprs"]').click();
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
const shownAfterFilter = await page.evaluate(() => {
|
|
||||||
const map = window.trx.modules.map;
|
|
||||||
const onMap = [];
|
|
||||||
(map?.stationMarkers ?? new Map()).forEach((entry, key) => {
|
|
||||||
if (entry?.marker && map.aprsMap?.hasLayer(entry.marker)) onMap.push(entry?.call ?? key);
|
|
||||||
});
|
|
||||||
return onMap.sort();
|
|
||||||
});
|
|
||||||
assert.deepEqual(shownAfterFilter, ["SP2SJG-9"],
|
|
||||||
`hiding HF APRS left ${JSON.stringify(shownAfterFilter)} on the map`);
|
|
||||||
// The map is the whole station's view — the one place a background rig's
|
|
||||||
// traffic belongs — so both vessels are on it even though the panel and the
|
|
||||||
// mini view show only the selected rig's.
|
|
||||||
assert.equal(markers.ais, 2, `${markers.ais} of 2 vessels reached the map`);
|
|
||||||
|
|
||||||
assert.deepEqual(runtimeErrors, []);
|
assert.deepEqual(runtimeErrors, []);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -157,8 +103,6 @@ try {
|
|||||||
// left the client on its retry path and the history path untested.
|
// left the client on its retry path and the history path untested.
|
||||||
const HISTORY_AIS = 900;
|
const HISTORY_AIS = 900;
|
||||||
const HISTORY_APRS = 300;
|
const HISTORY_APRS = 300;
|
||||||
const HISTORY_FT8 = 12;
|
|
||||||
const HISTORY_WSPR = 8;
|
|
||||||
const historyFixture = await startWebFixture({
|
const historyFixture = await startWebFixture({
|
||||||
spectrum: true,
|
spectrum: true,
|
||||||
mode: "AIS",
|
mode: "AIS",
|
||||||
@@ -168,14 +112,6 @@ const historyFixture = await startWebFixture({
|
|||||||
vessel_name: `HISTORIC ${index}`, channel: "A", message_type: 1,
|
vessel_name: `HISTORIC ${index}`, channel: "A", message_type: 1,
|
||||||
rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000,
|
rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000,
|
||||||
})),
|
})),
|
||||||
ft8: Array.from({ length: HISTORY_FT8 }, (_, index) => ({
|
|
||||||
message: `CQ SP${index}ABC JO${String(index).padStart(2, "0")}`, snr_db: -7, dt_s: 0.2,
|
|
||||||
freq_hz: 1200 + index, rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000,
|
|
||||||
})),
|
|
||||||
wspr: Array.from({ length: HISTORY_WSPR }, (_, index) => ({
|
|
||||||
message: `SP${index}XYZ JN${String(index).padStart(2, "0")} 30`, snr_db: -22, dt_s: 0.5,
|
|
||||||
freq_hz: 1500 + index, rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000,
|
|
||||||
})),
|
|
||||||
aprs: Array.from({ length: HISTORY_APRS }, (_, index) => ({
|
aprs: Array.from({ length: HISTORY_APRS }, (_, index) => ({
|
||||||
src_call: `SP2SJG-${index % 15}`, dest_call: "APRS", path: "WIDE1-1",
|
src_call: `SP2SJG-${index % 15}`, dest_call: "APRS", path: "WIDE1-1",
|
||||||
info: `history ${index}`, packet_type: "position", crc_ok: true,
|
info: `history ${index}`, packet_type: "position", crc_ok: true,
|
||||||
@@ -263,27 +199,6 @@ try {
|
|||||||
assert.equal(plotted.ais, HISTORY_AIS, `${plotted.ais} of ${HISTORY_AIS} vessels reached the map`);
|
assert.equal(plotted.ais, HISTORY_AIS, `${plotted.ais} of ${HISTORY_AIS} vessels reached the map`);
|
||||||
assert.equal(plotted.stations, 15, `${plotted.stations} of 15 stations reached the map`);
|
assert.equal(plotted.stations, 15, `${plotted.stations} of 15 stations reached the map`);
|
||||||
|
|
||||||
// The Statistics page counts the same history. Its decode log lives in the
|
|
||||||
// map module, which is lazy, so every decode that arrived before the module
|
|
||||||
// did used to be recorded into nothing at all — the page opened empty and
|
|
||||||
// only filled in from decodes heard afterwards, and it took a reload landing
|
|
||||||
// on the tab (module loaded at startup, before the history) to show the lot.
|
|
||||||
await replay.page.evaluate(() => window.navigateToTab("statistics"));
|
|
||||||
await replay.page.waitForTimeout(1500);
|
|
||||||
// The counters are written with toLocaleString(), so a four-figure count
|
|
||||||
// arrives as "1,220" — whichever separator the runner's locale picks. Read
|
|
||||||
// the digits rather than the formatting.
|
|
||||||
const counted = await replay.page.evaluate(() => {
|
|
||||||
const count = (id) => Number((document.getElementById(id)?.textContent ?? "").replace(/\D/g, ""));
|
|
||||||
return { decodes: count("stats-total-decodes"), grids: count("stats-unique-grids") };
|
|
||||||
});
|
|
||||||
assert.equal(counted.decodes, HISTORY_AIS + HISTORY_APRS + HISTORY_FT8 + HISTORY_WSPR,
|
|
||||||
`the statistics counted ${counted.decodes} decodes`);
|
|
||||||
// Grid squares come from the FT8 and WSPR spots, which had no map replay of
|
|
||||||
// their own: the locators of everything heard before the map loaded were lost.
|
|
||||||
assert.equal(counted.grids, HISTORY_FT8 + HISTORY_WSPR,
|
|
||||||
`the statistics counted ${counted.grids} grid squares`);
|
|
||||||
|
|
||||||
assert.deepEqual(replay.runtimeErrors, []);
|
assert.deepEqual(replay.runtimeErrors, []);
|
||||||
} finally {
|
} finally {
|
||||||
await replay.browser.close();
|
await replay.browser.close();
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function createHost({ state = {}, core = {}, modules = {} } = {}) {
|
|||||||
serverLat: null,
|
serverLat: null,
|
||||||
serverLon: null,
|
serverLon: null,
|
||||||
authEnabled: false,
|
authEnabled: false,
|
||||||
authRoles: ["read", "control", "transmit", "write", "administrator"],
|
authRole: "control",
|
||||||
lastActiveRigId: null,
|
lastActiveRigId: null,
|
||||||
lastRigIds: [],
|
lastRigIds: [],
|
||||||
lastRigDisplayNames: {},
|
lastRigDisplayNames: {},
|
||||||
|
|||||||
@@ -1,225 +0,0 @@
|
|||||||
// 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");
|
|
||||||
-122
@@ -1,122 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
// Pass predictions used to be a third view inside the weather-satellite
|
|
||||||
// decoder, under Digital modes — a planning tool filed with the decoders it has
|
|
||||||
// nothing to do with. It is its own page now, so what this guards is that the
|
|
||||||
// page exists at its own address, renders passes, and that the decoder card no
|
|
||||||
// longer offers the view it gave up.
|
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { chromium } from "playwright-core";
|
|
||||||
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
|
|
||||||
|
|
||||||
/* global document, window, getComputedStyle */
|
|
||||||
|
|
||||||
const HOUR = 3_600_000;
|
|
||||||
const now = Date.now();
|
|
||||||
const satPasses = {
|
|
||||||
satellite_count: 2,
|
|
||||||
passes: [
|
|
||||||
// In progress right now, so the page has a countdown to run.
|
|
||||||
{
|
|
||||||
satellite: "NOAA 19", category: "weather",
|
|
||||||
aos_ms: now - 4 * 60_000, los_ms: now + 6 * 60_000,
|
|
||||||
max_elevation_deg: 62.4, duration_s: 600,
|
|
||||||
azimuth_aos_deg: 10, azimuth_los_deg: 190,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
satellite: "ISS", category: "amateur",
|
|
||||||
aos_ms: now + 2 * HOUR, los_ms: now + 2 * HOUR + 480_000,
|
|
||||||
max_elevation_deg: 18.2, duration_s: 480,
|
|
||||||
azimuth_aos_deg: 200, azimuth_los_deg: 20,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const fixture = await startWebFixture({ satPasses });
|
|
||||||
const { browser, page, runtimeErrors } = await startBrowser(chromium);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await page.setViewportSize({ width: 1500, height: 950 });
|
|
||||||
|
|
||||||
// The page is reachable at its own address, not through a decoder sub-view.
|
|
||||||
// #content is the radio panel on the main tab, so the readiness signal for a
|
|
||||||
// deep link is the destination panel itself.
|
|
||||||
await page.goto(`${fixture.origin}/satellites`, { waitUntil: "domcontentloaded" });
|
|
||||||
await page.locator("#tab-satellites").waitFor({ state: "visible" });
|
|
||||||
|
|
||||||
await page.waitForFunction(
|
|
||||||
() => (document.getElementById("sat-pred-list")?.childElementCount ?? 0) > 0,
|
|
||||||
null,
|
|
||||||
{ timeout: 5000 },
|
|
||||||
);
|
|
||||||
|
|
||||||
const rendered = await page.evaluate(() => ({
|
|
||||||
current: document.getElementById("sat-pred-current-list").textContent,
|
|
||||||
upcoming: document.getElementById("sat-pred-list").textContent,
|
|
||||||
status: document.getElementById("sat-pred-status").textContent,
|
|
||||||
activeTab: document.querySelector(".tab-bar .tab.active")?.dataset.tab,
|
|
||||||
}));
|
|
||||||
assert.match(rendered.current, /NOAA 19/, `current passes read "${rendered.current}"`);
|
|
||||||
assert.match(rendered.upcoming, /ISS/, `upcoming passes read "${rendered.upcoming}"`);
|
|
||||||
assert.match(rendered.status, /1 active/, `status reads "${rendered.status}"`);
|
|
||||||
assert.equal(rendered.activeTab, "satellites", "the Satellites tab is the active one");
|
|
||||||
|
|
||||||
// A pass in progress counts down, so the seconds have to move on their own.
|
|
||||||
const firstTick = await page.evaluate(
|
|
||||||
() => document.querySelector(".sat-pred-col-countdown[data-los]")?.textContent);
|
|
||||||
await page.waitForTimeout(1600);
|
|
||||||
const secondTick = await page.evaluate(
|
|
||||||
() => document.querySelector(".sat-pred-col-countdown[data-los]")?.textContent);
|
|
||||||
assert.notEqual(firstTick, secondTick, `the countdown sat at ${firstTick}`);
|
|
||||||
|
|
||||||
// Leaving stops the countdown rather than leaving a timer running behind a
|
|
||||||
// hidden page.
|
|
||||||
await page.evaluate(() => { window.navigateToTab("main"); });
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
const afterLeaving = await page.evaluate(() => ({
|
|
||||||
visible: document.getElementById("tab-satellites").style.display,
|
|
||||||
rows: document.getElementById("sat-pred-current-list").childElementCount,
|
|
||||||
}));
|
|
||||||
assert.equal(afterLeaving.visible, "none", "the page stayed on screen after navigating away");
|
|
||||||
assert.equal(afterLeaving.rows, 0, "the countdown rows outlived the page");
|
|
||||||
|
|
||||||
// Coming back reloads rather than showing whatever was there before.
|
|
||||||
await page.evaluate(() => { window.navigateToTab("satellites"); });
|
|
||||||
await page.waitForFunction(
|
|
||||||
() => (document.getElementById("sat-pred-list")?.childElementCount ?? 0) > 0,
|
|
||||||
null,
|
|
||||||
{ timeout: 5000 },
|
|
||||||
);
|
|
||||||
|
|
||||||
// The page is an occasional destination, so it lives behind Tools rather than
|
|
||||||
// taking a slot in the operating strip — the same treatment Statistics,
|
|
||||||
// Recorder, Settings and About get.
|
|
||||||
const nav = await page.evaluate(() => ({
|
|
||||||
inStrip: getComputedStyle(document.querySelector('.tab-bar-nav .tab[data-tab="satellites"]')).display,
|
|
||||||
inTools: !!document.querySelector('#mobile-more-menu [data-navigate-tab="satellites"]'),
|
|
||||||
}));
|
|
||||||
assert.equal(nav.inStrip, "none", "Satellites took a slot in the operating strip");
|
|
||||||
assert.ok(nav.inTools, "Satellites is not reachable from Tools");
|
|
||||||
|
|
||||||
// The decoder card keeps Live and History, and no longer offers Predictions.
|
|
||||||
await page.goto(`${fixture.origin}/digital-modes`, { waitUntil: "domcontentloaded" });
|
|
||||||
await page.locator("#tab-digital-modes").waitFor({ state: "visible" });
|
|
||||||
const satCard = await page.evaluate(() => ({
|
|
||||||
predictionsButton: !!document.getElementById("sat-view-predictions"),
|
|
||||||
predictionsView: !!document.getElementById("sat-predictions-view"),
|
|
||||||
live: !!document.getElementById("sat-view-live"),
|
|
||||||
history: !!document.getElementById("sat-view-history"),
|
|
||||||
}));
|
|
||||||
assert.equal(satCard.predictionsButton, false, "the decoder card still offers Predictions");
|
|
||||||
assert.equal(satCard.predictionsView, false, "the old predictions view is still in the page");
|
|
||||||
assert.ok(satCard.live && satCard.history, "the decoder card lost Live/History");
|
|
||||||
|
|
||||||
assert.deepEqual(runtimeErrors, [], "the page threw while showing predictions");
|
|
||||||
console.log("satellite predictions page tests passed");
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
await fixture.close();
|
|
||||||
}
|
|
||||||
@@ -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: { authEnabled: true, authRoles: [] } }), trxUi: { confirm: async () => true } };
|
const window = { ...createHost({ state: { authRole: null } }), 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: { authRoles: ["administrator"], lastActiveRigId: "sdr" } }),
|
...createHost({ state: { authRole: "control", lastActiveRigId: "sdr" } }),
|
||||||
trxUi: { confirm: async () => true },
|
trxUi: { confirm: async () => true },
|
||||||
};
|
};
|
||||||
const context = vm.createContext({
|
const context = vm.createContext({
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { readFile } from "node:fs/promises";
|
|||||||
const indexPath = new URL("../../assets/web/index.html", import.meta.url);
|
const 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");
|
||||||
@@ -31,30 +30,6 @@ test("startup has no remote script or stylesheet dependencies", async () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("administrator user management is a dedicated Settings sub-tab", async () => {
|
|
||||||
const [html, app] = await Promise.all([
|
|
||||||
readFile(indexPath, "utf8"),
|
|
||||||
readFile(appPath, "utf8"),
|
|
||||||
]);
|
|
||||||
assert.match(html, /data-subtab="settings-users"/);
|
|
||||||
assert.match(html, /id="subtab-settings-users" class="sub-tab-panel"/);
|
|
||||||
assert.match(app, /authEnabled && hasAuthRole\("administrator"\)/);
|
|
||||||
assert.match(app, /settings-users-tab/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("account lifecycle controls include self-service passwords and enable state", async () => {
|
|
||||||
const [html, app] = await Promise.all([
|
|
||||||
readFile(indexPath, "utf8"),
|
|
||||||
readFile(appPath, "utf8"),
|
|
||||||
]);
|
|
||||||
assert.match(html, /data-subtab="settings-account"/);
|
|
||||||
assert.match(html, /id="account-password-form"/);
|
|
||||||
assert.match(html, /id="user-create-enabled"/);
|
|
||||||
assert.match(app, /changeOwnPassword/);
|
|
||||||
assert.match(app, /hasAccountControls\(authRoles\)/);
|
|
||||||
assert.match(app, /enabledAdminCount/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("lazy frontend features use modules and local map symbols", async () => {
|
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"),
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { chromium } from "playwright-core";
|
|
||||||
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
|
|
||||||
|
|
||||||
async function controlState(page) {
|
|
||||||
return {
|
|
||||||
frequency: await page.locator("#freq").isDisabled(),
|
|
||||||
ptt: await page.locator("#ptt-btn").isDisabled(),
|
|
||||||
txAudio: await page.locator("#tx-audio-btn").isDisabled(),
|
|
||||||
txLimit: await page.locator("#tx-limit-btn").isDisabled(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function inspectRole(roles) {
|
|
||||||
const fixture = await startWebFixture({
|
|
||||||
tx: true,
|
|
||||||
authSession: {
|
|
||||||
authenticated: true,
|
|
||||||
username: "operator",
|
|
||||||
roles,
|
|
||||||
auth_disabled: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const session = await startBrowser(chromium);
|
|
||||||
try {
|
|
||||||
await session.page.goto(`${fixture.origin}/`, { waitUntil: "domcontentloaded" });
|
|
||||||
await session.page.locator("#content").waitFor({ state: "visible" });
|
|
||||||
await session.page.waitForTimeout(1200);
|
|
||||||
assert.deepEqual(session.runtimeErrors, []);
|
|
||||||
return await controlState(session.page);
|
|
||||||
} finally {
|
|
||||||
await session.browser.close();
|
|
||||||
await fixture.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.deepEqual(await inspectRole(["control"]), {
|
|
||||||
frequency: false,
|
|
||||||
ptt: true,
|
|
||||||
txAudio: true,
|
|
||||||
txLimit: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(await inspectRole(["transmit"]), {
|
|
||||||
frequency: true,
|
|
||||||
ptt: false,
|
|
||||||
txAudio: false,
|
|
||||||
txLimit: false,
|
|
||||||
});
|
|
||||||
@@ -27,21 +27,6 @@ const dial = () => page.evaluate(() => ({
|
|||||||
path: window.location.pathname,
|
path: window.location.pathname,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Typed, not filled. The app holds back its own refreshes of the frequency
|
|
||||||
// field from the first keystroke until Enter, so that a state update arriving
|
|
||||||
// mid-edit does not rewrite what is being typed. `fill()` sets the value
|
|
||||||
// without a keystroke, leaving the field unguarded: on a slow machine a state
|
|
||||||
// update could land between the fill and the Enter and put the old frequency
|
|
||||||
// back, and the Enter would then re-apply the frequency the radio was already
|
|
||||||
// on. Selecting first arms the guard before a single character changes.
|
|
||||||
async function tuneByHand(text) {
|
|
||||||
const field = page.locator("#freq");
|
|
||||||
await field.click();
|
|
||||||
await field.press("ControlOrMeta+a");
|
|
||||||
await field.pressSequentially(text);
|
|
||||||
await field.press("Enter");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await page.setViewportSize({ width: 1500, height: 950 });
|
await page.setViewportSize({ width: 1500, height: 950 });
|
||||||
|
|
||||||
@@ -64,7 +49,8 @@ try {
|
|||||||
|
|
||||||
// Tuning by hand rewrites the link. This is the part that makes the address
|
// Tuning by hand rewrites the link. This is the part that makes the address
|
||||||
// bar shareable at any moment rather than only at load.
|
// bar shareable at any moment rather than only at load.
|
||||||
await tuneByHand("7.040M");
|
await page.locator("#freq").fill("7.040M");
|
||||||
|
await page.locator("#freq").press("Enter");
|
||||||
await page.waitForTimeout(1500);
|
await page.waitForTimeout(1500);
|
||||||
const tuned = await dial();
|
const tuned = await dial();
|
||||||
assert.equal(tuned.freqHz, TUNED_HZ, `tuning landed on ${tuned.freqHz} Hz`);
|
assert.equal(tuned.freqHz, TUNED_HZ, `tuning landed on ${tuned.freqHz} Hz`);
|
||||||
@@ -73,7 +59,8 @@ try {
|
|||||||
|
|
||||||
// Tuning is not navigation: a swept dial must not bury the back button.
|
// Tuning is not navigation: a swept dial must not bury the back button.
|
||||||
const historyLength = await page.evaluate(() => window.history.length);
|
const historyLength = await page.evaluate(() => window.history.length);
|
||||||
await tuneByHand("7.100M");
|
await page.locator("#freq").fill("7.100M");
|
||||||
|
await page.locator("#freq").press("Enter");
|
||||||
await page.waitForTimeout(1200);
|
await page.waitForTimeout(1200);
|
||||||
assert.equal(await page.evaluate(() => window.history.length), historyLength,
|
assert.equal(await page.evaluate(() => window.history.length), historyLength,
|
||||||
"tuning pushed a history entry instead of replacing one");
|
"tuning pushed a history entry instead of replacing one");
|
||||||
|
|||||||
@@ -34,14 +34,7 @@ const DECODER_REGISTRY = [
|
|||||||
{ id: "aprs", label: "APRS", activation: "toggle", active_modes: ["FM"] },
|
{ id: "aprs", label: "APRS", activation: "toggle", active_modes: ["FM"] },
|
||||||
{ id: "hf-aprs", label: "HF APRS", activation: "toggle", active_modes: ["USB"] },
|
{ id: "hf-aprs", label: "HF APRS", activation: "toggle", active_modes: ["USB"] },
|
||||||
{ id: "vdes", label: "VDES", activation: "toggle", active_modes: ["FM"] },
|
{ id: "vdes", label: "VDES", activation: "toggle", active_modes: ["FM"] },
|
||||||
].map((decoder) => ({
|
].map((decoder) => ({ ...decoder, background_decode: false, bookmark_selectable: true }));
|
||||||
...decoder,
|
|
||||||
// Which decoders a background channel can run, as the server's own registry
|
|
||||||
// has it: the background decode panel offers a bookmark only if one of these
|
|
||||||
// can decode it, so marking them all false left that panel with nothing.
|
|
||||||
background_decode: ["ft8", "ft4", "ft2", "wspr", "ais", "aprs", "hf-aprs"].includes(decoder.id),
|
|
||||||
bookmark_selectable: true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const CONTENT_TYPES = new Map([
|
const CONTENT_TYPES = new Map([
|
||||||
[".css", "text/css; charset=utf-8"],
|
[".css", "text/css; charset=utf-8"],
|
||||||
@@ -139,14 +132,9 @@ export async function startWebFixture({
|
|||||||
mode = "FM",
|
mode = "FM",
|
||||||
history = {},
|
history = {},
|
||||||
bookmarks = [],
|
bookmarks = [],
|
||||||
backgroundDecode = null,
|
|
||||||
logbook = [],
|
|
||||||
bandplan = {},
|
bandplan = {},
|
||||||
bandplanEnabled = false,
|
bandplanEnabled = false,
|
||||||
bandplanUnauthorizedFirst = false,
|
bandplanUnauthorizedFirst = false,
|
||||||
satPasses = null,
|
|
||||||
authSession = { authenticated: true, roles: ["read", "control", "transmit", "write", "administrator"], auth_disabled: true },
|
|
||||||
users = [],
|
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const rigItems = ["rig-a", "rig-b"].map((remote) => ({
|
const rigItems = ["rig-a", "rig-b"].map((remote) => ({
|
||||||
remote,
|
remote,
|
||||||
@@ -234,8 +222,7 @@ export async function startWebFixture({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const jsonRoutes = new Map([
|
const jsonRoutes = new Map([
|
||||||
["/auth/session", authSession],
|
["/auth/session", { authenticated: true, role: "control", auth_disabled: true }],
|
||||||
["/auth/users", users],
|
|
||||||
["/decoders", DECODER_REGISTRY],
|
["/decoders", DECODER_REGISTRY],
|
||||||
["/rigs", rigsResponse],
|
["/rigs", rigsResponse],
|
||||||
["/status", status],
|
["/status", status],
|
||||||
@@ -243,7 +230,6 @@ export async function startWebFixture({
|
|||||||
["/bandplan.json", bandplan],
|
["/bandplan.json", bandplan],
|
||||||
["/api/recorder/status", []],
|
["/api/recorder/status", []],
|
||||||
["/api/recorder/files", []],
|
["/api/recorder/files", []],
|
||||||
["/sat_passes", satPasses ?? { satellite_count: 0, passes: [] }],
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Flat i8 bins: the shape does not matter, only that frames arrive so the
|
// Flat i8 bins: the shape does not matter, only that frames arrive so the
|
||||||
@@ -283,158 +269,6 @@ 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
|
|
||||||
// bookmark list, and writes the config back.
|
|
||||||
if (url.pathname.startsWith("/background-decode/")) {
|
|
||||||
const isStatus = url.pathname.endsWith("/status");
|
|
||||||
if (isStatus) {
|
|
||||||
response.writeHead(200, { "content-type": "application/json" });
|
|
||||||
response.end(JSON.stringify(backgroundDecode?.status ?? { entries: [] }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (request.method === "PUT") {
|
|
||||||
const body = await new Promise((resolve) => {
|
|
||||||
let raw = "";
|
|
||||||
request.on("data", (chunk) => { raw += chunk; });
|
|
||||||
request.on("end", () => resolve(raw));
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(body);
|
|
||||||
if (backgroundDecode) backgroundDecode.config = parsed;
|
|
||||||
} catch { /* leave the config as it was */ }
|
|
||||||
}
|
|
||||||
if (request.method === "DELETE" && backgroundDecode) {
|
|
||||||
backgroundDecode.config = { remote: "rig-a", enabled: false, bookmark_ids: [] };
|
|
||||||
}
|
|
||||||
response.writeHead(200, { "content-type": "application/json" });
|
|
||||||
response.end(JSON.stringify(backgroundDecode?.config
|
|
||||||
?? { remote: "rig-a", enabled: false, bookmark_ids: [] }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (url.pathname === "/select_rig" && request.method === "POST") {
|
if (url.pathname === "/select_rig" && request.method === "POST") {
|
||||||
const remote = url.searchParams.get("remote");
|
const remote = url.searchParams.get("remote");
|
||||||
if (remote) {
|
if (remote) {
|
||||||
|
|||||||
@@ -10,9 +10,7 @@ use actix_web::{get, HttpRequest, HttpResponse, Responder};
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
use super::{
|
use super::{gz_cache_entry, static_asset_response, GzCacheEntry, FAVICON_BYTES, LOGO_BYTES};
|
||||||
gz_cache_entry, static_asset_response, AssetCaching, GzCacheEntry, FAVICON_BYTES, LOGO_BYTES,
|
|
||||||
};
|
|
||||||
use crate::server::status;
|
use crate::server::status;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -64,111 +62,49 @@ define_gz_cache!(gz_leaflet_css, status::LEAFLET_CSS, "leaflet.css");
|
|||||||
#[get("/")]
|
#[get("/")]
|
||||||
pub(crate) async fn index(req: HttpRequest) -> impl Responder {
|
pub(crate) async fn index(req: HttpRequest) -> impl Responder {
|
||||||
let c = gz_index_html();
|
let c = gz_index_html();
|
||||||
static_asset_response(
|
static_asset_response(&req, "text/html; charset=utf-8", c)
|
||||||
&req,
|
|
||||||
"text/html; charset=utf-8",
|
|
||||||
c,
|
|
||||||
AssetCaching::Revalidate,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/map")]
|
#[get("/map")]
|
||||||
pub(crate) async fn map_index(req: HttpRequest) -> impl Responder {
|
pub(crate) async fn map_index(req: HttpRequest) -> impl Responder {
|
||||||
let c = gz_index_html();
|
let c = gz_index_html();
|
||||||
static_asset_response(
|
static_asset_response(&req, "text/html; charset=utf-8", c)
|
||||||
&req,
|
|
||||||
"text/html; charset=utf-8",
|
|
||||||
c,
|
|
||||||
AssetCaching::Revalidate,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/digital-modes")]
|
#[get("/digital-modes")]
|
||||||
pub(crate) async fn digital_modes_index(req: HttpRequest) -> impl Responder {
|
pub(crate) async fn digital_modes_index(req: HttpRequest) -> impl Responder {
|
||||||
let c = gz_index_html();
|
let c = gz_index_html();
|
||||||
static_asset_response(
|
static_asset_response(&req, "text/html; charset=utf-8", c)
|
||||||
&req,
|
|
||||||
"text/html; charset=utf-8",
|
|
||||||
c,
|
|
||||||
AssetCaching::Revalidate,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/recorder")]
|
#[get("/recorder")]
|
||||||
pub(crate) async fn recorder_index(req: HttpRequest) -> impl Responder {
|
pub(crate) async fn recorder_index(req: HttpRequest) -> impl Responder {
|
||||||
let c = gz_index_html();
|
let c = gz_index_html();
|
||||||
static_asset_response(
|
static_asset_response(&req, "text/html; charset=utf-8", c)
|
||||||
&req,
|
|
||||||
"text/html; charset=utf-8",
|
|
||||||
c,
|
|
||||||
AssetCaching::Revalidate,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/settings")]
|
#[get("/settings")]
|
||||||
pub(crate) async fn settings_index(req: HttpRequest) -> impl Responder {
|
pub(crate) async fn settings_index(req: HttpRequest) -> impl Responder {
|
||||||
let c = gz_index_html();
|
let c = gz_index_html();
|
||||||
static_asset_response(
|
static_asset_response(&req, "text/html; charset=utf-8", c)
|
||||||
&req,
|
|
||||||
"text/html; charset=utf-8",
|
|
||||||
c,
|
|
||||||
AssetCaching::Revalidate,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/about")]
|
#[get("/about")]
|
||||||
pub(crate) async fn about_index(req: HttpRequest) -> impl Responder {
|
pub(crate) async fn about_index(req: HttpRequest) -> impl Responder {
|
||||||
let c = gz_index_html();
|
let c = gz_index_html();
|
||||||
static_asset_response(
|
static_asset_response(&req, "text/html; charset=utf-8", c)
|
||||||
&req,
|
|
||||||
"text/html; charset=utf-8",
|
|
||||||
c,
|
|
||||||
AssetCaching::Revalidate,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/statistics")]
|
#[get("/statistics")]
|
||||||
pub(crate) async fn statistics_index(req: HttpRequest) -> impl Responder {
|
pub(crate) async fn statistics_index(req: HttpRequest) -> impl Responder {
|
||||||
let c = gz_index_html();
|
let c = gz_index_html();
|
||||||
static_asset_response(
|
static_asset_response(&req, "text/html; charset=utf-8", c)
|
||||||
&req,
|
|
||||||
"text/html; charset=utf-8",
|
|
||||||
c,
|
|
||||||
AssetCaching::Revalidate,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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")]
|
|
||||||
pub(crate) async fn satellites_index(req: HttpRequest) -> impl Responder {
|
|
||||||
let c = gz_index_html();
|
|
||||||
static_asset_response(
|
|
||||||
&req,
|
|
||||||
"text/html; charset=utf-8",
|
|
||||||
c,
|
|
||||||
AssetCaching::Revalidate,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/bookmarks")]
|
#[get("/bookmarks")]
|
||||||
pub(crate) async fn bookmarks_index(req: HttpRequest) -> impl Responder {
|
pub(crate) async fn bookmarks_index(req: HttpRequest) -> impl Responder {
|
||||||
let c = gz_index_html();
|
let c = gz_index_html();
|
||||||
static_asset_response(
|
static_asset_response(&req, "text/html; charset=utf-8", c)
|
||||||
&req,
|
|
||||||
"text/html; charset=utf-8",
|
|
||||||
c,
|
|
||||||
AssetCaching::Revalidate,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -206,13 +142,13 @@ pub(crate) async fn logo() -> impl Responder {
|
|||||||
#[get("/style.css")]
|
#[get("/style.css")]
|
||||||
pub(crate) async fn style_css(req: HttpRequest) -> impl Responder {
|
pub(crate) async fn style_css(req: HttpRequest) -> impl Responder {
|
||||||
let c = gz_style_css();
|
let c = gz_style_css();
|
||||||
static_asset_response(&req, "text/css; charset=utf-8", c, AssetCaching::Revalidate)
|
static_asset_response(&req, "text/css; charset=utf-8", c)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/themes.css")]
|
#[get("/themes.css")]
|
||||||
pub(crate) async fn themes_css(req: HttpRequest) -> impl Responder {
|
pub(crate) async fn themes_css(req: HttpRequest) -> impl Responder {
|
||||||
let c = gz_themes_css();
|
let c = gz_themes_css();
|
||||||
static_asset_response(&req, "text/css; charset=utf-8", c, AssetCaching::Revalidate)
|
static_asset_response(&req, "text/css; charset=utf-8", c)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generated filenames are supplied only by build.rs and resolved through this
|
// Generated filenames are supplied only by build.rs and resolved through this
|
||||||
@@ -236,22 +172,7 @@ pub(crate) async fn generated_asset(req: HttpRequest, path: web::Path<String>) -
|
|||||||
let Some(entry) = generated_asset_cache().get(filename.as_str()) else {
|
let Some(entry) = generated_asset_cache().get(filename.as_str()) else {
|
||||||
return HttpResponse::NotFound().finish();
|
return HttpResponse::NotFound().finish();
|
||||||
};
|
};
|
||||||
static_asset_response(
|
static_asset_response(&req, content_type, entry)
|
||||||
&req,
|
|
||||||
content_type,
|
|
||||||
entry,
|
|
||||||
generated_asset_caching(&filename),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// esbuild names shared chunks `chunk-<hash>.js` and leaves the entry points on
|
|
||||||
/// a fixed name, so only the chunks are safe to keep forever.
|
|
||||||
fn generated_asset_caching(filename: &str) -> AssetCaching {
|
|
||||||
if filename.starts_with("chunk-") {
|
|
||||||
AssetCaching::Immutable
|
|
||||||
} else {
|
|
||||||
AssetCaching::Revalidate
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serve a received SSTV picture out of the local cache.
|
/// Serve a received SSTV picture out of the local cache.
|
||||||
@@ -294,12 +215,7 @@ fn cached_png(decoder: &str, filename: &str) -> HttpResponse {
|
|||||||
#[get("/bandplan.json")]
|
#[get("/bandplan.json")]
|
||||||
pub(crate) async fn bandplan_json(req: HttpRequest) -> impl Responder {
|
pub(crate) async fn bandplan_json(req: HttpRequest) -> impl Responder {
|
||||||
let c = gz_bandplan_json();
|
let c = gz_bandplan_json();
|
||||||
static_asset_response(
|
static_asset_response(&req, "application/json; charset=utf-8", c)
|
||||||
&req,
|
|
||||||
"application/json; charset=utf-8",
|
|
||||||
c,
|
|
||||||
AssetCaching::Revalidate,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -317,12 +233,7 @@ pub(crate) async fn dseg14_classic_woff2() -> impl Responder {
|
|||||||
#[get("/vendor/opus-decoder-0.7.11.min.js")]
|
#[get("/vendor/opus-decoder-0.7.11.min.js")]
|
||||||
pub(crate) async fn opus_decoder_js(req: HttpRequest) -> impl Responder {
|
pub(crate) async fn opus_decoder_js(req: HttpRequest) -> impl Responder {
|
||||||
let c = gz_opus_decoder_js();
|
let c = gz_opus_decoder_js();
|
||||||
static_asset_response(
|
static_asset_response(&req, "application/javascript; charset=utf-8", c)
|
||||||
&req,
|
|
||||||
"application/javascript; charset=utf-8",
|
|
||||||
c,
|
|
||||||
AssetCaching::Immutable,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -332,18 +243,13 @@ pub(crate) async fn opus_decoder_js(req: HttpRequest) -> impl Responder {
|
|||||||
#[get("/vendor/leaflet.js")]
|
#[get("/vendor/leaflet.js")]
|
||||||
pub(crate) async fn leaflet_js(req: HttpRequest) -> impl Responder {
|
pub(crate) async fn leaflet_js(req: HttpRequest) -> impl Responder {
|
||||||
let c = gz_leaflet_js();
|
let c = gz_leaflet_js();
|
||||||
static_asset_response(
|
static_asset_response(&req, "application/javascript; charset=utf-8", c)
|
||||||
&req,
|
|
||||||
"application/javascript; charset=utf-8",
|
|
||||||
c,
|
|
||||||
AssetCaching::Revalidate,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/vendor/leaflet.css")]
|
#[get("/vendor/leaflet.css")]
|
||||||
pub(crate) async fn leaflet_css(req: HttpRequest) -> impl Responder {
|
pub(crate) async fn leaflet_css(req: HttpRequest) -> impl Responder {
|
||||||
let c = gz_leaflet_css();
|
let c = gz_leaflet_css();
|
||||||
static_asset_response(&req, "text/css; charset=utf-8", c, AssetCaching::Revalidate)
|
static_asset_response(&req, "text/css; charset=utf-8", c)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/vendor/marker-icon.png")]
|
#[get("/vendor/marker-icon.png")]
|
||||||
@@ -443,44 +349,6 @@ mod tests {
|
|||||||
assert!(!generated_asset_cache().contains_key("../app.js"));
|
assert!(!generated_asset_cache().contains_key("../app.js"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A browser that visited before an upgrade must not keep the old page.
|
|
||||||
/// index.html, the stylesheets and the entry bundles all keep their URL
|
|
||||||
/// from one build to the next, so an immutable year-long policy on them
|
|
||||||
/// leaves a client running whatever it first downloaded — a fixed layout
|
|
||||||
/// stays broken in the browser that happened to cache it, and no reload
|
|
||||||
/// short of a forced one gets the fix.
|
|
||||||
#[test]
|
|
||||||
fn assets_that_keep_their_url_across_builds_are_revalidated() {
|
|
||||||
for name in ["app.js", "aprs.js", "map-core.js"] {
|
|
||||||
assert_eq!(
|
|
||||||
generated_asset_caching(name),
|
|
||||||
AssetCaching::Revalidate,
|
|
||||||
"{name} is served from the same URL after every build"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
assert_eq!(
|
|
||||||
AssetCaching::Revalidate.header_value(),
|
|
||||||
"no-cache",
|
|
||||||
"revalidating assets must ask before they are reused"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Only the chunks carry a hash of their own bytes, so only they can be
|
|
||||||
/// kept forever.
|
|
||||||
#[test]
|
|
||||||
fn content_addressed_chunks_stay_immutable() {
|
|
||||||
let chunk = status::GENERATED_ASSETS
|
|
||||||
.iter()
|
|
||||||
.map(|(name, _)| *name)
|
|
||||||
.find(|name| name.starts_with("chunk-"))
|
|
||||||
.expect("the bundle splits into at least one shared chunk");
|
|
||||||
assert_eq!(generated_asset_caching(chunk), AssetCaching::Immutable);
|
|
||||||
assert!(
|
|
||||||
AssetCaching::Immutable.header_value().contains("immutable"),
|
|
||||||
"a hashed name is safe to keep"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn generated_asset_mime_types_are_restricted() {
|
fn generated_asset_mime_types_are_restricted() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -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_write};
|
use super::{no_cache_response, request_accepts_html, require_control};
|
||||||
use crate::server::status;
|
use crate::server::status;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -37,7 +37,6 @@ 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.
|
||||||
@@ -89,17 +88,6 @@ fn normalize_bookmark_locator(locator: Option<String>) -> Option<String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Normalize a DIG sideband override to `auto`/`usb`/`lsb`, or `None` when
|
|
||||||
/// absent or unrecognized (leaves the current policy untouched on apply).
|
|
||||||
fn normalize_dig_sideband(value: Option<String>) -> Option<String> {
|
|
||||||
value.and_then(|v| match v.trim().to_lowercase().as_str() {
|
|
||||||
"auto" => Some("auto".to_string()),
|
|
||||||
"usb" => Some("usb".to_string()),
|
|
||||||
"lsb" => Some("lsb".to_string()),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Endpoints
|
// Endpoints
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -177,7 +165,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_write(&req, &auth_state)?;
|
require_control(&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(
|
||||||
@@ -194,7 +182,6 @@ 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))
|
||||||
@@ -214,7 +201,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_write(&req, &auth_state)?;
|
require_control(&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)) {
|
||||||
@@ -232,7 +219,6 @@ 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))
|
||||||
@@ -249,7 +235,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_write(&req, &auth_state)?;
|
require_control(&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) {
|
||||||
@@ -267,7 +253,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_write(&req, &auth_state)?;
|
require_control(&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 {
|
||||||
@@ -286,7 +272,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_write(&req, &auth_state)?;
|
require_control(&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;
|
||||||
|
|||||||
@@ -1,456 +0,0 @@
|
|||||||
// 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,7 +7,6 @@
|
|||||||
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;
|
||||||
@@ -308,35 +307,10 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Pre-compressed (gzip + brotli) + ETag-aware response for immutable embedded assets.
|
/// Pre-compressed (gzip + brotli) + ETag-aware response for immutable embedded assets.
|
||||||
/// How long a browser may hold an asset before asking about it again.
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
|
||||||
pub(crate) enum AssetCaching {
|
|
||||||
/// The name carries a hash of the bytes, so a change is a new URL and the
|
|
||||||
/// old one can be kept forever.
|
|
||||||
Immutable,
|
|
||||||
/// Served from the same URL for the life of the deployment, with different
|
|
||||||
/// bytes after an upgrade: index.html, the stylesheets, the entry bundles.
|
|
||||||
/// These must be revalidated, or a browser that visited before the upgrade
|
|
||||||
/// keeps running the old page — for a year, with the ETag never consulted,
|
|
||||||
/// which is how a fixed layout stays broken in one browser and not another.
|
|
||||||
/// The ETag makes the revalidation a 304 in the usual case.
|
|
||||||
Revalidate,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AssetCaching {
|
|
||||||
pub(crate) fn header_value(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Immutable => "public, max-age=31536000, immutable",
|
|
||||||
Self::Revalidate => "no-cache",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn static_asset_response(
|
fn static_asset_response(
|
||||||
req: &HttpRequest,
|
req: &HttpRequest,
|
||||||
content_type: &'static str,
|
content_type: &'static str,
|
||||||
entry: &GzCacheEntry,
|
entry: &GzCacheEntry,
|
||||||
caching: AssetCaching,
|
|
||||||
) -> HttpResponse {
|
) -> HttpResponse {
|
||||||
let etag = &entry.etag;
|
let etag = &entry.etag;
|
||||||
// Check If-None-Match for conditional GET.
|
// Check If-None-Match for conditional GET.
|
||||||
@@ -345,7 +319,7 @@ fn static_asset_response(
|
|||||||
if val == etag || val == "*" {
|
if val == etag || val == "*" {
|
||||||
return HttpResponse::NotModified()
|
return HttpResponse::NotModified()
|
||||||
.insert_header((header::ETAG, etag.to_owned()))
|
.insert_header((header::ETAG, etag.to_owned()))
|
||||||
.insert_header((header::CACHE_CONTROL, caching.header_value()))
|
.insert_header((header::CACHE_CONTROL, "public, max-age=31536000, immutable"))
|
||||||
.finish();
|
.finish();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -365,7 +339,7 @@ fn static_asset_response(
|
|||||||
.insert_header((header::CONTENT_TYPE, content_type))
|
.insert_header((header::CONTENT_TYPE, content_type))
|
||||||
.insert_header((header::CONTENT_ENCODING, encoding))
|
.insert_header((header::CONTENT_ENCODING, encoding))
|
||||||
.insert_header((header::ETAG, etag.to_owned()))
|
.insert_header((header::ETAG, etag.to_owned()))
|
||||||
.insert_header((header::CACHE_CONTROL, caching.header_value()))
|
.insert_header((header::CACHE_CONTROL, "public, max-age=31536000, immutable"))
|
||||||
.body(Bytes::copy_from_slice(body))
|
.body(Bytes::copy_from_slice(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,20 +365,16 @@ fn gz_cache_entry(src: &[u8], name: &str) -> GzCacheEntry {
|
|||||||
GzCacheEntry { gz, br, etag }
|
GzCacheEntry { gz, br, etag }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn require_write(
|
fn require_control(
|
||||||
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 {
|
||||||
|| crate::server::auth::session_grants(
|
return Ok(());
|
||||||
req,
|
}
|
||||||
auth_state,
|
match crate::server::auth::get_session_role(req, auth_state) {
|
||||||
crate::server::auth::AuthRole::Write,
|
Some(crate::server::auth::AuthRole::Control) => Ok(()),
|
||||||
)
|
_ => Err(actix_web::error::ErrorForbidden("control role required")),
|
||||||
{
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(actix_web::error::ErrorForbidden("write role required"))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -595,7 +565,6 @@ 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)
|
||||||
@@ -670,8 +639,6 @@ 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::statistics_index)
|
.service(assets::statistics_index)
|
||||||
.service(assets::bookmarks_index)
|
.service(assets::bookmarks_index)
|
||||||
.service(assets::favicon)
|
.service(assets::favicon)
|
||||||
@@ -713,24 +680,7 @@ 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)]
|
||||||
@@ -889,404 +839,6 @@ mod tests {
|
|||||||
// Endpoint tests using actix_web::test
|
// Endpoint tests using actix_web::test
|
||||||
// ======================================================================
|
// ======================================================================
|
||||||
|
|
||||||
/// The page and the bundles it pulls in are served from the same URLs after
|
|
||||||
/// every upgrade, so the browser has to ask whether they changed. Served
|
|
||||||
/// as immutable for a year, a browser that visited once kept the old page
|
|
||||||
/// and never saw a fix again -- which is how a corrected layout stays
|
|
||||||
/// broken in one browser while every other one has it.
|
|
||||||
#[actix_web::test]
|
|
||||||
async fn documents_and_entry_bundles_are_revalidated_not_frozen() {
|
|
||||||
let app = actix_test::init_service(
|
|
||||||
App::new()
|
|
||||||
.service(assets::index)
|
|
||||||
.service(assets::style_css)
|
|
||||||
.service(assets::generated_asset),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
for path in ["/", "/style.css", "/app.js"] {
|
|
||||||
let req = actix_test::TestRequest::get().uri(path).to_request();
|
|
||||||
let resp = actix_test::call_service(&app, req).await;
|
|
||||||
assert_eq!(resp.status(), 200, "{path} should be served");
|
|
||||||
let cache_control = resp
|
|
||||||
.headers()
|
|
||||||
.get(header::CACHE_CONTROL)
|
|
||||||
.and_then(|value| value.to_str().ok())
|
|
||||||
.unwrap_or_default();
|
|
||||||
assert_eq!(cache_control, "no-cache", "{path} must be revalidated");
|
|
||||||
assert!(
|
|
||||||
resp.headers().contains_key(header::ETAG),
|
|
||||||
"{path} needs an ETag, or revalidating costs a full download"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Revalidation has to stay cheap: an unchanged asset answers 304, and the
|
|
||||||
/// policy on that answer matches the one on the body.
|
|
||||||
#[actix_web::test]
|
|
||||||
async fn an_unchanged_document_answers_not_modified() {
|
|
||||||
let app = actix_test::init_service(App::new().service(assets::style_css)).await;
|
|
||||||
|
|
||||||
let first = actix_test::call_service(
|
|
||||||
&app,
|
|
||||||
actix_test::TestRequest::get()
|
|
||||||
.uri("/style.css")
|
|
||||||
.to_request(),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
let etag = first
|
|
||||||
.headers()
|
|
||||||
.get(header::ETAG)
|
|
||||||
.and_then(|value| value.to_str().ok())
|
|
||||||
.expect("style.css carries an ETag")
|
|
||||||
.to_owned();
|
|
||||||
|
|
||||||
let conditional = actix_test::call_service(
|
|
||||||
&app,
|
|
||||||
actix_test::TestRequest::get()
|
|
||||||
.uri("/style.css")
|
|
||||||
.insert_header((header::IF_NONE_MATCH, etag))
|
|
||||||
.to_request(),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_eq!(conditional.status(), 304);
|
|
||||||
assert_eq!(
|
|
||||||
conditional
|
|
||||||
.headers()
|
|
||||||
.get(header::CACHE_CONTROL)
|
|
||||||
.and_then(|value| value.to_str().ok()),
|
|
||||||
Some("no-cache")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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::{DigSidebandPolicy, NoiseBlankerProfile, RigCommand, RigRequest, RigState};
|
use trx_core::{RigCommand, RigRequest, RigState};
|
||||||
use trx_frontend::{FrontendRuntimeContext, RemoteRigEntry};
|
use trx_frontend::{FrontendRuntimeContext, RemoteRigEntry};
|
||||||
use trx_protocol::parse_mode;
|
use trx_protocol::parse_mode;
|
||||||
|
|
||||||
@@ -279,10 +279,6 @@ 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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,30 +293,12 @@ 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,7 +737,6 @@ 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"));
|
||||||
@@ -747,7 +746,6 @@ 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
|
||||||
@@ -882,16 +880,6 @@ 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,10 +21,6 @@ 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 {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user