Compare commits
182
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c3fc69542 | ||
|
|
d539ff96e5 | ||
|
|
ca5cd65c85 | ||
|
|
a9e1e86fdc | ||
|
|
76e33a91bb | ||
|
|
ee185b98bc | ||
|
|
18b2d0efe6 | ||
|
|
a19633e81f | ||
|
|
c8b6f2d536 | ||
|
|
17300170cc | ||
|
|
e41c13917d | ||
|
|
ae7df31d91 | ||
|
|
bf3bcc8a84 | ||
|
|
86dd36312e | ||
|
|
15ff686542 | ||
|
|
b78c4a4dd4 | ||
|
|
f396e8f235 | ||
|
|
46c9827e8a | ||
|
|
0fc977f19f | ||
|
|
084f629b5b | ||
|
|
bc63ded583 | ||
|
|
cfaeb6ee15 | ||
|
|
76bcce8c54 | ||
|
|
88ed3da6cc | ||
|
|
7c69e0de08 | ||
|
|
fbc4f6e398 | ||
|
|
d42ca4f030 | ||
|
|
335922fecc | ||
|
|
bede2e34fe | ||
|
|
da58a004fe | ||
|
|
77b283cb78 | ||
|
|
6284747339 | ||
|
|
14ad6e241b | ||
|
|
06971ff65c | ||
|
|
18107ce07e | ||
|
|
a0b0c0ed81 | ||
|
|
a2c630a92b | ||
|
|
09634eb851 | ||
|
|
90ab7781ad | ||
|
|
88d04253ca | ||
|
|
08005c5c07 | ||
|
|
026f816ddb | ||
|
|
d31b6f545f | ||
|
|
39f551c914 | ||
|
|
6d25ecdc11 | ||
|
|
37987b2779 | ||
|
|
67a7bace4e | ||
|
|
b48cc23d6e | ||
|
|
c1899229a0 | ||
|
|
84a99a3636 | ||
|
|
84bdf2593c | ||
|
|
c527f20a88 | ||
|
|
9f495021f0 | ||
|
|
33aa7b807c | ||
|
|
2c56d82a81 | ||
|
|
0fc2115973 | ||
|
|
2c1df75d19 | ||
|
|
aefd36c4b1 | ||
|
|
25b9c31c9b | ||
|
|
d68a84f7f9 | ||
|
|
4fa191e9b6 | ||
|
|
c073d03ffb | ||
|
|
730f129404 | ||
|
|
f95f3d0104 | ||
|
|
e70e82c8c0 | ||
|
|
23cc0db1fa | ||
|
|
657f952a2c | ||
|
|
1f256cbb68 | ||
|
|
92fbdb692c | ||
|
|
b2fbcb318d | ||
|
|
44721b579c | ||
|
|
889d00007d | ||
|
|
6f53592041 | ||
|
|
709939f60b | ||
|
|
5b7dd493d4 | ||
|
|
02fe492dbf | ||
|
|
1bc2c88e2f | ||
|
|
863a6d8fd4 | ||
|
|
b252717f6b | ||
|
|
92697b11c5 | ||
|
|
b409c57296 | ||
|
|
00191c8d7a | ||
|
|
56c363a517 | ||
|
|
5ad91b4ab6 | ||
|
|
1843522b45 | ||
|
|
41ddecf5a7 | ||
|
|
b06c37affa | ||
|
|
b4912f5879 | ||
|
|
27f2558193 | ||
|
|
3a9bf1b7ce | ||
|
|
dd5760c436 | ||
|
|
eee3630f04 | ||
|
|
b31790ff48 | ||
|
|
2f4973ed70 | ||
|
|
26b00608b2 | ||
|
|
c2455bb08c | ||
|
|
22ff1349f3 | ||
|
|
8b72573521 | ||
|
|
f64032dcbe | ||
|
|
8fd1761688 | ||
|
|
7022f20b76 | ||
|
|
0d4c657b97 | ||
|
|
23dbcac5b6 | ||
|
|
695434942f | ||
|
|
a1a8d1d1d3 | ||
|
|
ddb33b6ff3 | ||
|
|
f87289b129 | ||
|
|
812359c744 | ||
|
|
d929ad3bde | ||
|
|
8aeca613aa | ||
|
|
ee59d8efdd | ||
|
|
ddd151c1ee | ||
|
|
5136704826 | ||
|
|
e300cc343d | ||
|
|
1fb196a64e | ||
|
|
c8cc235e88 | ||
|
|
ab3fe4bbb7 | ||
|
|
40accb9697 | ||
|
|
cbca810a30 | ||
|
|
26a167cb82 | ||
|
|
3d49839ed5 | ||
|
|
26be675242 | ||
|
|
1a42d58075 | ||
|
|
844de809a4 | ||
|
|
e910e644d5 | ||
|
|
50e07e2715 | ||
|
|
82121491c5 | ||
|
|
0338c8b8d2 | ||
|
|
c7994347e9 | ||
|
|
9fef0ebc7b | ||
|
|
508cf2ba87 | ||
|
|
6a83e2e90a | ||
|
|
fc2f55bab9 | ||
|
|
4fb65971e4 | ||
|
|
021d31d780 | ||
|
|
074c67c7b9 | ||
|
|
d93f784aad | ||
|
|
ec59908e0d | ||
|
|
7251ec276d | ||
|
|
0ab0f80986 | ||
|
|
2cbcc23fec | ||
|
|
968fb3ea2d | ||
|
|
5ba3ecf59b | ||
|
|
03bdc10b02 | ||
|
|
d96624be4f | ||
|
|
a5dccd5489 | ||
|
|
42e5dc8604 | ||
|
|
e6f593b959 | ||
|
|
9ac6d7f82c | ||
|
|
a58553ba66 | ||
|
|
7442437757 | ||
|
|
bbc53d56b0 | ||
|
|
888f793eb8 | ||
|
|
860760ecfc | ||
|
|
b4ea35baf8 | ||
|
|
508ed8a8e7 | ||
|
|
d9fc623ef4 | ||
|
|
53736ef750 | ||
|
|
6566eae2c7 | ||
|
|
f3eeddff7c | ||
|
|
1540ca5b4e | ||
|
|
e449704fd2 | ||
|
|
af74430551 | ||
|
|
d07dbdc645 | ||
|
|
d07a627508 | ||
|
|
9fbf90ee91 | ||
|
|
d393b3cefc | ||
|
|
35a492ec95 | ||
|
|
b20da5c541 | ||
|
|
a0bdaa2c4b | ||
|
|
6676b66993 | ||
|
|
412651d612 | ||
|
|
d347b493d9 | ||
|
|
39e59dca96 | ||
|
|
5654520901 | ||
|
|
ff75fcc692 | ||
|
|
4728b578ae | ||
|
|
7db7fad9b0 | ||
|
|
830f7299fe | ||
|
|
061738a63b | ||
|
|
e8bd97655f | ||
|
|
7d0b36450d |
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "trx-rs SDK",
|
||||||
|
"image": "git.haxx.space/sjg/trx-rs/sdk:latest",
|
||||||
|
"workspaceFolder": "/work",
|
||||||
|
"workspaceMount": "source=${localWorkspaceFolder},target=/work,type=bind",
|
||||||
|
"mounts": [
|
||||||
|
"source=trx-rs-sccache,target=/sccache,type=volume"
|
||||||
|
],
|
||||||
|
"containerEnv": {
|
||||||
|
"RUSTC_WRAPPER": "sccache",
|
||||||
|
"CARGO_INCREMENTAL": "0",
|
||||||
|
"SCCACHE_DIR": "/sccache"
|
||||||
|
},
|
||||||
|
"customizations": {
|
||||||
|
"vscode": {
|
||||||
|
"extensions": [
|
||||||
|
"rust-lang.rust-analyzer",
|
||||||
|
"tamasfe.even-better-toml"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
-9
@@ -2,10 +2,11 @@
|
|||||||
#
|
#
|
||||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
# CI for the self-hosted, host-executor Podman runners (see container/).
|
# CI for the Docker-executor runner (VM). The lint, test and frontend jobs run
|
||||||
# The runner image bakes in the Rust toolchain and all build dependencies,
|
# inside the shared trx-rs SDK image (container/Containerfile), which bakes in
|
||||||
# so jobs go straight to cargo — no apt/rustup setup steps (which also
|
# the pinned Rust toolchain, Node.js, Chromium and all build dependencies. The
|
||||||
# collided on the dpkg lock when jobs ran concurrently in the same runner).
|
# reuse job uses the upstream Docker action, which the Docker executor launches
|
||||||
|
# as a sibling container.
|
||||||
|
|
||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
@@ -16,32 +17,73 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
|
# sccache: shared compilation cache persisted on the runner host (see the
|
||||||
|
# -v mount in runner-config.example.yaml). CARGO_INCREMENTAL=0 because
|
||||||
|
# sccache cannot cache incremental artifacts.
|
||||||
|
RUSTC_WRAPPER: sccache
|
||||||
|
CARGO_INCREMENTAL: "0"
|
||||||
|
SCCACHE_DIR: /sccache
|
||||||
|
SCCACHE_CACHE_SIZE: "20G"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
container: git.haxx.space/sjg/trx-rs/sdk:latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: rustfmt
|
- name: rustfmt
|
||||||
run: cargo fmt --all -- --check
|
run: cargo fmt --all -- --check
|
||||||
- name: clippy
|
- name: clippy
|
||||||
run: cargo clippy --workspace --all-targets --all-features -- -D warnings
|
run: cargo clippy --workspace --all-targets --all-features -- -D warnings
|
||||||
|
- name: sccache stats
|
||||||
|
if: always()
|
||||||
|
run: sccache --show-stats
|
||||||
|
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
container: git.haxx.space/sjg/trx-rs/sdk:latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Build
|
- name: Build
|
||||||
run: cargo build --workspace --all-targets --locked
|
run: cargo build --workspace --all-targets --locked
|
||||||
- name: Test
|
- name: Test
|
||||||
run: cargo test --workspace --locked
|
run: cargo test --workspace --locked
|
||||||
|
- name: sccache stats
|
||||||
|
if: always()
|
||||||
|
run: sccache --show-stats
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: git.haxx.space/sjg/trx-rs/sdk:latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: src/trx-client/trx-frontend/trx-frontend-http/frontend
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Cache npm downloads
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.npm
|
||||||
|
key: npm-${{ runner.os }}-${{ hashFiles('src/trx-client/trx-frontend/trx-frontend-http/frontend/package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
npm-${{ runner.os }}-
|
||||||
|
- name: Install locked frontend dependencies
|
||||||
|
run: npm ci
|
||||||
|
- name: Type-check
|
||||||
|
run: npm run typecheck
|
||||||
|
- name: Lint
|
||||||
|
run: npm run lint
|
||||||
|
- name: Test
|
||||||
|
run: npm test
|
||||||
|
# Chromium comes from the SDK image at the path the smoke test defaults
|
||||||
|
# to, so there is nothing to install here.
|
||||||
|
- name: Browser smoke test
|
||||||
|
run: npm run test:browser
|
||||||
|
- name: Verify generated assets
|
||||||
|
run: npm run verify-generated
|
||||||
|
|
||||||
reuse:
|
reuse:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: REUSE compliance
|
- uses: fsfe/reuse-action@v5
|
||||||
# `reuse` CLI instead of fsfe/reuse-action: the latter is a Docker
|
|
||||||
# action, which the host-executor runners cannot run. `reuse` is baked
|
|
||||||
# into the runner image (see container/Containerfile).
|
|
||||||
run: reuse lint
|
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ Cargo.lock
|
|||||||
coverage/
|
coverage/
|
||||||
benchmarks/
|
benchmarks/
|
||||||
|
|
||||||
|
# Frontend dependencies
|
||||||
|
**/node_modules/
|
||||||
|
|
||||||
# Env
|
# Env
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ cargo test -p trx-core
|
|||||||
./target/release/trx-server --print-config > trx-server.toml
|
./target/release/trx-server --print-config > trx-server.toml
|
||||||
./target/release/trx-client --print-config > trx-client.toml
|
./target/release/trx-client --print-config > trx-client.toml
|
||||||
|
|
||||||
|
# Validate a config without starting anything (reports every problem)
|
||||||
|
./target/release/trx-server --check-config --config trx-rs.toml
|
||||||
|
|
||||||
|
# Regenerate trx-rs.toml.example after changing a config struct
|
||||||
|
cargo run -p trx-config --example generate_example
|
||||||
|
|
||||||
# Run server
|
# Run server
|
||||||
./target/release/trx-server --config trx-server.toml
|
./target/release/trx-server --config trx-server.toml
|
||||||
# or via CLI args:
|
# or via CLI args:
|
||||||
@@ -41,7 +47,8 @@ This is a Cargo workspace. All crates live under `src/`:
|
|||||||
src/
|
src/
|
||||||
trx-core/ # Core types, traits, state machine, controller (~3,500 LOC)
|
trx-core/ # Core types, traits, state machine, controller (~3,500 LOC)
|
||||||
trx-protocol/ # Client↔server protocol DTOs, auth, codec, mapping (~1,100 LOC)
|
trx-protocol/ # Client↔server protocol DTOs, auth, codec, mapping (~1,100 LOC)
|
||||||
trx-app/ # Shared application helpers (config paths, logging init)
|
trx-app/ # Shared application helpers (logging init, name normalization)
|
||||||
|
trx-config/ # Client + server config structs, loader, validators (~2,500 LOC)
|
||||||
trx-reporting/ # PSKReporter UDP uplink + APRS-IS TCP uplink (~1,150 LOC)
|
trx-reporting/ # PSKReporter UDP uplink + APRS-IS TCP uplink (~1,150 LOC)
|
||||||
trx-server/ # Server binary: rig_task, audio pipeline, listener (~3,700 LOC)
|
trx-server/ # Server binary: rig_task, audio pipeline, listener (~3,700 LOC)
|
||||||
trx-backend/ # Backend abstraction trait + factory + dummy
|
trx-backend/ # Backend abstraction trait + factory + dummy
|
||||||
@@ -49,6 +56,7 @@ src/
|
|||||||
trx-backend-ft450d/ # Yaesu FT-450D ASCII CAT
|
trx-backend-ft450d/ # Yaesu FT-450D ASCII CAT
|
||||||
trx-backend-soapysdr/ # SoapySDR RX with full DSP pipeline (~5,000+ LOC)
|
trx-backend-soapysdr/ # SoapySDR RX with full DSP pipeline (~5,000+ LOC)
|
||||||
trx-client/ # Client binary: remote connection, frontend spawning (~1,500 LOC)
|
trx-client/ # Client binary: remote connection, frontend spawning (~1,500 LOC)
|
||||||
|
trx-logbook/ # Station log: QSO record, ADIF reader/writer, append-only store
|
||||||
trx-frontend/ # Frontend trait (FrontendSpawner), runtime context
|
trx-frontend/ # Frontend trait (FrontendSpawner), runtime context
|
||||||
trx-frontend-http/ # Web UI: REST API, SSE, WebSocket audio, session auth
|
trx-frontend-http/ # Web UI: REST API, SSE, WebSocket audio, session auth
|
||||||
trx-frontend-http-json/ # JSON-over-TCP control frontend
|
trx-frontend-http-json/ # JSON-over-TCP control frontend
|
||||||
|
|||||||
Generated
+187
-9
@@ -316,6 +316,18 @@ version = "1.0.102"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "argon2"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"blake2",
|
||||||
|
"cpufeatures 0.2.17",
|
||||||
|
"password-hash",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "atomic-waker"
|
name = "atomic-waker"
|
||||||
version = "1.1.2"
|
version = "1.1.2"
|
||||||
@@ -345,6 +357,12 @@ version = "0.22.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64ct"
|
||||||
|
version = "1.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bindgen"
|
name = "bindgen"
|
||||||
version = "0.66.1"
|
version = "0.66.1"
|
||||||
@@ -395,6 +413,24 @@ version = "2.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blake2"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||||
|
dependencies = [
|
||||||
|
"digest 0.10.7",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.10.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.12.0"
|
version = "0.12.0"
|
||||||
@@ -525,7 +561,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
|
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures 0.3.0",
|
||||||
"rand_core 0.10.1",
|
"rand_core 0.10.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -721,6 +757,15 @@ dependencies = [
|
|||||||
"windows",
|
"windows",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -739,6 +784,16 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crypto-common"
|
name = "crypto-common"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -799,15 +854,26 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "digest"
|
||||||
|
version = "0.10.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
|
dependencies = [
|
||||||
|
"block-buffer 0.10.4",
|
||||||
|
"crypto-common 0.1.7",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.11.2"
|
version = "0.11.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c"
|
checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer",
|
"block-buffer 0.12.0",
|
||||||
"const-oid",
|
"const-oid",
|
||||||
"crypto-common",
|
"crypto-common 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1019,6 +1085,16 @@ dependencies = [
|
|||||||
"slab",
|
"slab",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "generic-array"
|
||||||
|
version = "0.14.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -1890,6 +1966,17 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "password-hash"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "peeking_take_while"
|
name = "peeking_take_while"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
@@ -2412,6 +2499,16 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_ignored"
|
||||||
|
version = "0.1.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_json"
|
name = "serde_json"
|
||||||
version = "1.0.149"
|
version = "1.0.149"
|
||||||
@@ -2482,8 +2579,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
|
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures 0.3.0",
|
||||||
"digest",
|
"digest 0.11.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2645,6 +2742,15 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "termcolor"
|
||||||
|
version = "1.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
|
||||||
|
dependencies = [
|
||||||
|
"winapi-util",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "1.0.69"
|
version = "1.0.69"
|
||||||
@@ -3022,10 +3128,6 @@ dependencies = [
|
|||||||
name = "trx-app"
|
name = "trx-app"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dirs",
|
|
||||||
"serde",
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
"toml",
|
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
@@ -3106,6 +3208,7 @@ dependencies = [
|
|||||||
"toml",
|
"toml",
|
||||||
"tracing",
|
"tracing",
|
||||||
"trx-app",
|
"trx-app",
|
||||||
|
"trx-config",
|
||||||
"trx-core",
|
"trx-core",
|
||||||
"trx-frontend",
|
"trx-frontend",
|
||||||
"trx-frontend-http",
|
"trx-frontend-http",
|
||||||
@@ -3115,6 +3218,23 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "trx-config"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"dirs",
|
||||||
|
"serde",
|
||||||
|
"serde_ignored",
|
||||||
|
"tempfile",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"toml",
|
||||||
|
"toml_edit 0.22.27",
|
||||||
|
"tracing",
|
||||||
|
"trx-core",
|
||||||
|
"trx-decode-log",
|
||||||
|
"trx-reporting",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "trx-configurator"
|
name = "trx-configurator"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -3123,13 +3243,16 @@ dependencies = [
|
|||||||
"dialoguer",
|
"dialoguer",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio-serial",
|
"tokio-serial",
|
||||||
|
"toml",
|
||||||
"toml_edit 0.22.27",
|
"toml_edit 0.22.27",
|
||||||
|
"trx-config",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "trx-core"
|
name = "trx-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64",
|
||||||
"flate2",
|
"flate2",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -3137,6 +3260,7 @@ dependencies = [
|
|||||||
"sgp4",
|
"sgp4",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"ts-rs",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3178,9 +3302,11 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"actix-web",
|
"actix-web",
|
||||||
"actix-ws",
|
"actix-ws",
|
||||||
|
"argon2",
|
||||||
"base64",
|
"base64",
|
||||||
"brotli 7.0.0",
|
"brotli 7.0.0",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"chrono",
|
||||||
"dirs",
|
"dirs",
|
||||||
"flate2",
|
"flate2",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
@@ -3189,12 +3315,15 @@ 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",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3231,6 +3360,19 @@ dependencies = [
|
|||||||
"rustfft",
|
"rustfft",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "trx-logbook"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
|
"dirs",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
|
"tracing",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "trx-protocol"
|
name = "trx-protocol"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -3238,6 +3380,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"trx-core",
|
"trx-core",
|
||||||
|
"ts-rs",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3282,12 +3425,14 @@ dependencies = [
|
|||||||
"trx-app",
|
"trx-app",
|
||||||
"trx-aprs",
|
"trx-aprs",
|
||||||
"trx-backend",
|
"trx-backend",
|
||||||
|
"trx-config",
|
||||||
"trx-core",
|
"trx-core",
|
||||||
"trx-cw",
|
"trx-cw",
|
||||||
"trx-decode-log",
|
"trx-decode-log",
|
||||||
"trx-ftx",
|
"trx-ftx",
|
||||||
"trx-protocol",
|
"trx-protocol",
|
||||||
"trx-reporting",
|
"trx-reporting",
|
||||||
|
"trx-sstv",
|
||||||
"trx-vdes",
|
"trx-vdes",
|
||||||
"trx-wefax",
|
"trx-wefax",
|
||||||
"trx-wspr",
|
"trx-wspr",
|
||||||
@@ -3295,6 +3440,16 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "trx-sstv"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"png",
|
||||||
|
"tracing",
|
||||||
|
"trx-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "trx-vdes"
|
name = "trx-vdes"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -3333,6 +3488,29 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ts-rs"
|
||||||
|
version = "12.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"ts-rs-macros",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ts-rs-macros"
|
||||||
|
version = "12.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
"termcolor",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.20.0"
|
version = "1.20.0"
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ members = [
|
|||||||
"src/decoders/trx-ftx",
|
"src/decoders/trx-ftx",
|
||||||
"src/decoders/trx-rds",
|
"src/decoders/trx-rds",
|
||||||
"src/decoders/trx-vdes",
|
"src/decoders/trx-vdes",
|
||||||
|
"src/decoders/trx-sstv",
|
||||||
"src/decoders/trx-wefax",
|
"src/decoders/trx-wefax",
|
||||||
"src/decoders/trx-wspr",
|
"src/decoders/trx-wspr",
|
||||||
"src/trx-core",
|
"src/trx-core",
|
||||||
"src/trx-protocol",
|
"src/trx-protocol",
|
||||||
"src/trx-app",
|
"src/trx-app",
|
||||||
|
"src/trx-config",
|
||||||
"src/trx-reporting",
|
"src/trx-reporting",
|
||||||
"src/trx-server",
|
"src/trx-server",
|
||||||
"src/trx-server/trx-backend",
|
"src/trx-server/trx-backend",
|
||||||
@@ -24,6 +26,7 @@ members = [
|
|||||||
"src/trx-server/trx-backend/trx-backend-ft450d",
|
"src/trx-server/trx-backend/trx-backend-ft450d",
|
||||||
"src/trx-server/trx-backend/trx-backend-soapysdr",
|
"src/trx-server/trx-backend/trx-backend-soapysdr",
|
||||||
"src/trx-client",
|
"src/trx-client",
|
||||||
|
"src/trx-client/trx-logbook",
|
||||||
"src/trx-client/trx-frontend",
|
"src/trx-client/trx-frontend",
|
||||||
"src/trx-client/trx-frontend/trx-frontend-http",
|
"src/trx-client/trx-frontend/trx-frontend-http",
|
||||||
"src/trx-client/trx-frontend/trx-frontend-http-json",
|
"src/trx-client/trx-frontend/trx-frontend-http-json",
|
||||||
|
|||||||
@@ -0,0 +1,434 @@
|
|||||||
|
APRS symbol set (https://github.com/hessu/aprs-symbols)
|
||||||
|
=========================================================
|
||||||
|
|
||||||
|
Verbatim copy of the upstream COPYRIGHT.md, retrieved 2026-08-03 from
|
||||||
|
https://github.com/hessu/aprs-symbols. The set has no single SPDX license:
|
||||||
|
individual symbols carry different terms, summarized below. Attribution
|
||||||
|
requirement from the upstream README: "If you use this symbol set, please
|
||||||
|
provide a pointer to the source (http://github.com/hessu/aprs-symbols/)."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
Copyright and licensing information
|
||||||
|
======================================
|
||||||
|
|
||||||
|
This is a collection of vectorized symbols for use on the APRS system.
|
||||||
|
|
||||||
|
The copyright status of this collection is a bit complicated, since the
|
||||||
|
symbols come from various sources, each having different copyright owners.
|
||||||
|
|
||||||
|
Most of the vectorized symbols are loosely based on the low-resolution
|
||||||
|
"standard" bitmap symbol set as distributed by Stephen Smith, WA8LMF. That
|
||||||
|
set is used by most APRS software around the world. The low resolution of
|
||||||
|
those symbols does not allow direct vector conversion, so I've drawn new
|
||||||
|
symbols in a similar layout. The vector versions try to mimic the original
|
||||||
|
appearance and colours, with the intention of keeping the set recognizable
|
||||||
|
and familiar to existing users. In some cases the vector versions are
|
||||||
|
probably similar enough to the originals, so that they cannot be considered
|
||||||
|
"original work" by myself. In some of these cases, the originals are
|
||||||
|
probably also mimicking someone else's design.
|
||||||
|
|
||||||
|
The original symbols do not come with any information on their licensing.
|
||||||
|
They've been distributed with a lot of APRS software over time, but I don't
|
||||||
|
know who designed which symbol originally. Most likely all of them are
|
||||||
|
drawn by one of:
|
||||||
|
|
||||||
|
* Roger Barker, G4IDE, "original set provided with UI-View" (SK)
|
||||||
|
* Steve Dimse, KH4G, "U.S. customary set"
|
||||||
|
* Stephen Smith, WA8LMF
|
||||||
|
|
||||||
|
The Adobe Illustrator (.ai) file contains a copy of the original bitmaps
|
||||||
|
as a hidden layer, just for reference.
|
||||||
|
|
||||||
|
Some symbols I obtained from other sources, such as Wikipedia. In those
|
||||||
|
cases I picked SVG versions which allow commercial reuse (source known, and
|
||||||
|
the work is placed on public domain, or with a CC license which allows
|
||||||
|
adaptation and commercial reuse).
|
||||||
|
|
||||||
|
Some symbols are vectorized versions of product or brand logos. The
|
||||||
|
copyright of those is owned by the respective companies (Apple, Microsoft,
|
||||||
|
Kenwood), and each of those may have some opinions on how the logos are
|
||||||
|
used. Please check for yourself if you can use them or not.
|
||||||
|
|
||||||
|
In the list below I try to summarize the licensing status for each symbol.
|
||||||
|
|
||||||
|
|
||||||
|
Shorthand notation for common licensing status
|
||||||
|
-------------------------------------------------
|
||||||
|
|
||||||
|
* *VEC-OH7LZB* - Vectorized by OH7LZB, based on original APRS symbol set
|
||||||
|
* Source of original bitmap: http://wa8lmf.net/aprs/APRS_symbols.htm
|
||||||
|
* Original designer of individual symbol unknown at this time, but one of:
|
||||||
|
* Roger Barker, G4IDE
|
||||||
|
* Steve Dimse, KH4G
|
||||||
|
* Stephen Smith, WA8LMF
|
||||||
|
* Vectorized versions are designed to look similar
|
||||||
|
* Licensing: Unknown
|
||||||
|
* *OH7LZB* - Original vector design by Heikki Hannikainen, OH7LZB
|
||||||
|
* Different enough (by author's opinion) to make it a new original work,
|
||||||
|
instead of a copy of the old symbol
|
||||||
|
* License: CC BY-SA 2.0
|
||||||
|
* https://creativecommons.org/licenses/by-sa/2.0/
|
||||||
|
|
||||||
|
|
||||||
|
Primary table
|
||||||
|
----------------
|
||||||
|
|
||||||
|
* /! - Police station
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /# - Digipeater / Green star with D in middle
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /$ - Telephone
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /% - DX cluster
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /& - HF gateway
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /' - Small aircraft
|
||||||
|
* https://openclipart.org/detail/27182/topdown-airplane-view
|
||||||
|
* Author: Wirelizard (Brian Burger)
|
||||||
|
* With color and some other small tuning added by OH7LZB
|
||||||
|
* PD: https://openclipart.org/share
|
||||||
|
* /( - Mobile satellite station
|
||||||
|
* OH7LZB
|
||||||
|
* /) - Wheelchair, handicapped
|
||||||
|
* PD wheelchair symbol
|
||||||
|
* Vectorized from bitmap by OH7LZB
|
||||||
|
* /* - Snowmobile
|
||||||
|
* https://openclipart.org/detail/15849/snowmobile
|
||||||
|
* Author: Mystica (https://openclipart.org/user-detail/mystica)
|
||||||
|
* PD: https://openclipart.org/share
|
||||||
|
* /+ - Red Cross
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /, - Boy Scouts
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /- - House
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /. - Red X
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* // - Red dot
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /0 to /9 - Numbered circles
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Fire
|
||||||
|
* http://commons.wikimedia.org/wiki/File:FireIcon.svg
|
||||||
|
* Author: Piotr Jaworski
|
||||||
|
* PD: I, the copyright holder of this work, release this work into the public domain. This applies worldwide.
|
||||||
|
* Tent
|
||||||
|
* https://openclipart.org/detail/174933/green-tent-by-stamps-174933
|
||||||
|
* Author: stamps
|
||||||
|
* PD: https://openclipart.org/share
|
||||||
|
* Motorcycle
|
||||||
|
* http://commons.wikimedia.org/wiki/File:MUTCD_W8-15P.svg
|
||||||
|
* This file is in the public domain because it comes from the Manual on
|
||||||
|
Uniform Traffic Control Devices, sign number W8-15P, which states
|
||||||
|
specifically on page I-1 that: Any traffic control device design or
|
||||||
|
application provision contained in this Manual shall be considered to
|
||||||
|
be in the public domain. Traffic control devices contained in this
|
||||||
|
Manual shall not be protected by a patent, trademark, or copyright,
|
||||||
|
except for the Interstate Shield and any other items owned by FHWA.
|
||||||
|
* Colour version by OH7LZB
|
||||||
|
* /= - Railroad engine
|
||||||
|
* http://commons.wikimedia.org/wiki/File:Icon_train.svg
|
||||||
|
* Author: http://en.wikipedia.org/wiki/User:Richtom80
|
||||||
|
* CC-BY-SA-2.5,2.0,1.0
|
||||||
|
* /> - Car
|
||||||
|
* OH7LZB
|
||||||
|
* /? - File server
|
||||||
|
* https://openclipart.org/detail/163717/file-server-by-lyte
|
||||||
|
* Author: lyte
|
||||||
|
* PD: https://openclipart.org/share
|
||||||
|
* /@ - Hurricane predicted path
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /A - Aid station
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Mail (BBS)
|
||||||
|
* https://openclipart.org/detail/29268/yellow-mail-by-rg1024-29268
|
||||||
|
* Author: rg1024
|
||||||
|
* PD: https://openclipart.org/share
|
||||||
|
* /C - Canoe
|
||||||
|
* https://openclipart.org/detail/179047/red-canoe-by-rambo-tribble-179047
|
||||||
|
* https://openclipart.org/detail/179041/canoe-paddle-by-rambo-tribble-179041
|
||||||
|
* Author: Rambo Tribble
|
||||||
|
* PD: I, the copyright holder of this work, release this work into the public domain. This applies worldwide.
|
||||||
|
* /E - Eyeball
|
||||||
|
* http://commons.wikimedia.org/wiki/File:Blue_eye.svg
|
||||||
|
* PD: "This file is from the Open Clip Art Library, which released it explicitly into the public domain"
|
||||||
|
* PD: https://openclipart.org/share
|
||||||
|
* /F - Tractor
|
||||||
|
* https://openclipart.org/detail/191654/farm-tractor-by-tmjbeary-191654
|
||||||
|
* Author: tmjbeary
|
||||||
|
* PD: https://openclipart.org/share
|
||||||
|
* /G - Grid square, 3 by 3
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /H - Hotel
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /I - TCP/IP
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /K - School
|
||||||
|
* OH7LZB
|
||||||
|
* /L - PC user
|
||||||
|
* OH7LZB
|
||||||
|
* /M - Mac apple
|
||||||
|
* Apple
|
||||||
|
* /N - NTS
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /O - Hot air balloon
|
||||||
|
* OH7LZB
|
||||||
|
* /P - Police
|
||||||
|
* OH7LZB
|
||||||
|
* /R - RV
|
||||||
|
* OH7LZB
|
||||||
|
* /S - Space Shuttle
|
||||||
|
* https://openclipart.org/detail/814/space-shuttle-by-johnny_automatic
|
||||||
|
* PD: Published by the NASA, in "The Brain in Space"
|
||||||
|
* /T - SSTV
|
||||||
|
* https://openclipart.org/detail/48997/flat-screen-by-rg1024
|
||||||
|
* Author: rg1024
|
||||||
|
* Adjusted by OH7LZB
|
||||||
|
* PD: https://openclipart.org/share
|
||||||
|
* /U - Bus
|
||||||
|
* OH7LZB
|
||||||
|
* /V - ATV, amateur television
|
||||||
|
* https://openclipart.org/detail/48997/flat-screen-by-rg1024
|
||||||
|
* Author: rg1024
|
||||||
|
* Adjusted by OH7LZB
|
||||||
|
* PD: https://openclipart.org/share
|
||||||
|
* /W - Wx, Weather service site
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /X - Helicopter
|
||||||
|
* OH7LZB
|
||||||
|
* /Y - Sailboat
|
||||||
|
* OH7LZB
|
||||||
|
* /Z - Windows flag
|
||||||
|
* Microsoft
|
||||||
|
* /[ - Human
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /\ - DF triangle
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /] - Mailbox, post office, letter
|
||||||
|
* /^ - Large aircraft
|
||||||
|
* https://openclipart.org/detail/183204/plane-red-by-sketchartist-183204
|
||||||
|
* Author: SketchArtist
|
||||||
|
* PD: https://openclipart.org/share
|
||||||
|
* /_ - Weather station
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /` - Satellite dish
|
||||||
|
* OH7LZB
|
||||||
|
* /a - Ambulance
|
||||||
|
* OH7LZB
|
||||||
|
* /b - Bicycle
|
||||||
|
* http://commons.wikimedia.org/wiki/File:Bicycle_evolution-numbers.svg
|
||||||
|
* Author: Wikipedia user: Al2
|
||||||
|
* CC BY 3.0
|
||||||
|
* /c - Incident command post
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /d - Fire station
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /e - Horse, equestrian
|
||||||
|
* https://openclipart.org/detail/142627/horse-riding-lesson-by-olku
|
||||||
|
* Author: OlKu
|
||||||
|
* PD: https://openclipart.org/share
|
||||||
|
* /f - Fire truck
|
||||||
|
* OH7LZB
|
||||||
|
* /g - Hang glider
|
||||||
|
* OH7LZB
|
||||||
|
* /h - Hospital
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /i - IOTA, islands on the air
|
||||||
|
* http://commons.wikimedia.org/wiki/File:Palm_Island_R.svg
|
||||||
|
* PI
|
||||||
|
* /j - Jeep
|
||||||
|
* OH7LZB
|
||||||
|
* /k - Truck
|
||||||
|
* OH7LZB
|
||||||
|
* /l - Laptop
|
||||||
|
* OH7LZB
|
||||||
|
* /m - Mic-E repeater
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /n - Node, black bulls-eye
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /o - Emergency operations center
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /p - Dog(e)
|
||||||
|
* OH7LZB
|
||||||
|
* /q - Grid square, 2 by 2
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /r - Repeater tower
|
||||||
|
* OH7LZB
|
||||||
|
* /s - Ship, power boat
|
||||||
|
* OH7LZB
|
||||||
|
* /t - Truck stop
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /u - Semi-trailer truck, 18-wheeler
|
||||||
|
* OH7LZB
|
||||||
|
* /v - Van
|
||||||
|
* OH7LZB
|
||||||
|
* /w - Water station
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /x - X / Unix
|
||||||
|
* https://commons.wikimedia.org/wiki/File:X11.svg
|
||||||
|
* PD
|
||||||
|
* /y - House, yagi antenna
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* /z - Shelter
|
||||||
|
* VEC-OH7LZB
|
||||||
|
|
||||||
|
|
||||||
|
Secondary table
|
||||||
|
------------------
|
||||||
|
|
||||||
|
* Emergency
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Numbered digipeater / Green star
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Bank
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Numbered gateway / Black diamond
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Crash site
|
||||||
|
* OH7LZB
|
||||||
|
* Cloudy
|
||||||
|
* OH7LZB
|
||||||
|
* MEO
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Snowflake
|
||||||
|
* http://commons.wikimedia.org/wiki/File:Snowflake_01.svg
|
||||||
|
* Author: Wikipedia user: Amada44
|
||||||
|
* Public Domain
|
||||||
|
* Church
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Girl Scout
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Looks slightly like the common USA girl scouts logos. Should be different
|
||||||
|
enough to not infringe on "Girl Scouts of the USA" copyrights.
|
||||||
|
* Home (HF antenna)
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Unknown position
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Destination
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Numbered circle
|
||||||
|
* VEC-OH7LZB
|
||||||
|
|
||||||
|
* Petrol Station
|
||||||
|
* OH7LZB
|
||||||
|
* Hail
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Park
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Gale Flag
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Red car from above
|
||||||
|
* OH7LZB
|
||||||
|
* Info Kiosk
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Hurricane
|
||||||
|
* OH7LZB
|
||||||
|
|
||||||
|
* Numbered white box
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Snow blowing
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Coast Guard
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Drizzle
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Smoke / Chimney
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Freezing rain
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Snow Shwr
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Haze
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Rain Shower
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Lightning
|
||||||
|
* OH7LZB
|
||||||
|
* "Kenwood radio"
|
||||||
|
* Kenwood logo, vectorized
|
||||||
|
* "Lighthouse"
|
||||||
|
* CC BY-SA 2.0
|
||||||
|
* http://wiki.openstreetmap.org/wiki/File:Lighthouse.svg
|
||||||
|
* Nav Buoy
|
||||||
|
* OH7LZB
|
||||||
|
* Rocket
|
||||||
|
* http://www.clker.com/clipart-gglkuglug.html
|
||||||
|
* PD according to clker.com license
|
||||||
|
* Parking
|
||||||
|
* VEC-OH7LZB
|
||||||
|
|
||||||
|
* Earthquake, Restaurant
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Satellite
|
||||||
|
* OH7LZB
|
||||||
|
* Thunderstorm
|
||||||
|
* OH7LZB
|
||||||
|
* Sunny
|
||||||
|
* OH7LZB
|
||||||
|
* VORTAC, Numbered WXS
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Pharmacy Rx
|
||||||
|
* OH7LZB
|
||||||
|
* Wall Cloud
|
||||||
|
* OH7LZB
|
||||||
|
* Numbered plane
|
||||||
|
* https://openclipart.org/detail/183204/plane-red-by-sketchartist-183204
|
||||||
|
* Author: SketchArtist
|
||||||
|
* PD: https://openclipart.org/share
|
||||||
|
* Numbered WX Station
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Rain
|
||||||
|
* Source: http://commons.wikimedia.org/wiki/File:Heavy-rain-shower-transparent.svg
|
||||||
|
* Author: Wikipedia user: Peepo
|
||||||
|
* Public Domain
|
||||||
|
* With modifications by OH7LZB
|
||||||
|
|
||||||
|
* Numbered diamond
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Dust blowing
|
||||||
|
* NA
|
||||||
|
* Numbered civil defence
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* DX spot
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Sleet
|
||||||
|
* NA
|
||||||
|
* Funnel Cloud
|
||||||
|
* NA
|
||||||
|
* Gale
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Store
|
||||||
|
* https://openclipart.org/detail/89299/cart-medium-by-martins.bruvelis
|
||||||
|
* Author: martins.bruvelis
|
||||||
|
* Public Domain
|
||||||
|
* Adjustments by OH7LZB
|
||||||
|
* Numbered black box
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Work zone / Excavator
|
||||||
|
* Based on http://www.clker.com/clipart-292480.html PNG version
|
||||||
|
* Vectorized and colors adjusted by OH7LZB
|
||||||
|
* PD according to clker.com documentation, uploader KURSVEIAL
|
||||||
|
* SUV
|
||||||
|
* OH7LZB
|
||||||
|
* Milepost, Numbered triangle, Circle sm
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Partly cloudy
|
||||||
|
* OH7LZB
|
||||||
|
|
||||||
|
* Restrooms, Numbered boat
|
||||||
|
* VEC-OH7LZB
|
||||||
|
* Tornado (also used in Funnel cloud, Skywarn)
|
||||||
|
* https://openclipart.org/detail/104887/tornado-by-laabadon
|
||||||
|
* Author: Laabadon
|
||||||
|
* Public Domain
|
||||||
|
* Numbered truck
|
||||||
|
* OH7LZB
|
||||||
|
* Numbered van
|
||||||
|
* OH7LZB
|
||||||
|
* Flooding
|
||||||
|
* NA
|
||||||
|
* Sky warn, Numbered shelter, fog
|
||||||
|
* VEC-OH7LZB
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) <year> <copyright holders>
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -93,13 +93,17 @@ The wizard walks you through rig selection, serial port detection, audio
|
|||||||
settings, and frontend options, then writes `trx-server.toml` and
|
settings, and frontend options, then writes `trx-server.toml` and
|
||||||
`trx-client.toml`.
|
`trx-client.toml`.
|
||||||
|
|
||||||
Alternatively, generate example configs and edit them by hand:
|
Alternatively, copy `trx-rs.toml.example` — a commented example covering every
|
||||||
|
setting — and edit it by hand:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./target/release/trx-server --print-config > trx-server.toml
|
cp trx-rs.toml.example trx-rs.toml
|
||||||
./target/release/trx-client --print-config > trx-client.toml
|
./target/release/trx-server --check-config --config trx-rs.toml
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`--check-config` reports everything wrong with a config without starting
|
||||||
|
anything. `--print-config` prints the same settings without comments.
|
||||||
|
|
||||||
### 4. Run
|
### 4. Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -107,6 +111,9 @@ Alternatively, generate example configs and edit them by hand:
|
|||||||
./target/release/trx-client --config trx-client.toml
|
./target/release/trx-client --config trx-client.toml
|
||||||
```
|
```
|
||||||
|
|
||||||
|
A single `trx-rs.toml` can configure both: the server reads its `[trx-server]`
|
||||||
|
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`).
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
@@ -142,6 +149,9 @@ a unified set of frontends.
|
|||||||
## License
|
## License
|
||||||
|
|
||||||
GPL-2.0-or-later. See [`LICENSES`](LICENSES) for the full license text and
|
GPL-2.0-or-later. See [`LICENSES`](LICENSES) for the full license text and
|
||||||
bundled third-party license files. Bundled third-party components (Leaflet and
|
bundled third-party license files. Bundled third-party components retain their
|
||||||
the Leaflet AIS tracksymbol plugin under `assets/web/vendor/`) retain their
|
original licenses: Leaflet is BSD-2-Clause, DSEG is OFL-1.1, and opus-decoder
|
||||||
original BSD-2-Clause license.
|
is MIT. The APRS symbol sprites come from
|
||||||
|
[hessu/aprs-symbols](https://github.com/hessu/aprs-symbols); their per-symbol
|
||||||
|
copyright status is catalogued in
|
||||||
|
[`LICENSES/LicenseRef-APRS-Symbols.txt`](LICENSES/LicenseRef-APRS-Symbols.txt).
|
||||||
|
|||||||
+31
-1
@@ -12,13 +12,19 @@ path = [
|
|||||||
"trx-rs.toml.example",
|
"trx-rs.toml.example",
|
||||||
"docs/**",
|
"docs/**",
|
||||||
"aidocs/**",
|
"aidocs/**",
|
||||||
|
"container/**",
|
||||||
|
".devcontainer/**",
|
||||||
"src/decoders/trx-ftx/README.md",
|
"src/decoders/trx-ftx/README.md",
|
||||||
"src/decoders/trx-wxsat/README.md",
|
"src/decoders/trx-wxsat/README.md",
|
||||||
"assets/trx-logo.png",
|
"assets/trx-logo.png",
|
||||||
"src/trx-client/trx-frontend/trx-frontend-http/assets/trx-favicon.png",
|
"src/trx-client/trx-frontend/trx-frontend-http/assets/trx-favicon.png",
|
||||||
"src/trx-client/trx-frontend/trx-frontend-http/assets/trx-logo.png",
|
"src/trx-client/trx-frontend/trx-frontend-http/assets/trx-logo.png",
|
||||||
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/bandplan.json",
|
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/bandplan.json",
|
||||||
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/leaflet-ais-tracksymbol.js",
|
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/**",
|
||||||
|
"src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json",
|
||||||
|
"src/trx-client/trx-frontend/trx-frontend-http/frontend/package-lock.json",
|
||||||
|
"src/trx-client/trx-frontend/trx-frontend-http/frontend/tsconfig.json",
|
||||||
|
"src/trx-client/trx-frontend/trx-frontend-http/frontend/tsconfig.worker.json",
|
||||||
]
|
]
|
||||||
SPDX-FileCopyrightText = "2026 Stan Grams <sjg@haxx.space>"
|
SPDX-FileCopyrightText = "2026 Stan Grams <sjg@haxx.space>"
|
||||||
SPDX-License-Identifier = "GPL-2.0-or-later"
|
SPDX-License-Identifier = "GPL-2.0-or-later"
|
||||||
@@ -42,3 +48,27 @@ SPDX-License-Identifier = "BSD-2-Clause"
|
|||||||
path = ["src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/dseg14-classic-latin-400-normal.woff2"]
|
path = ["src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/dseg14-classic-latin-400-normal.woff2"]
|
||||||
SPDX-FileCopyrightText = "2020 The DSEG Authors (https://github.com/keshikan/DSEG)"
|
SPDX-FileCopyrightText = "2020 The DSEG Authors (https://github.com/keshikan/DSEG)"
|
||||||
SPDX-License-Identifier = "OFL-1.1"
|
SPDX-License-Identifier = "OFL-1.1"
|
||||||
|
|
||||||
|
# Vendored opus-decoder 0.7.11 browser build
|
||||||
|
# (https://github.com/eshaz/wasm-audio-decoders), MIT.
|
||||||
|
# SHA-256: fd73ee0a9c8a5e233c0b88234df14f46003ad89bb4ba27435bc8714db2a6dc62
|
||||||
|
[[annotations]]
|
||||||
|
path = ["src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/opus-decoder-0.7.11.min.js"]
|
||||||
|
SPDX-FileCopyrightText = "2021-2025 Ethan Halsall"
|
||||||
|
SPDX-License-Identifier = "MIT"
|
||||||
|
|
||||||
|
# Vendored APRS symbol sprites (https://github.com/hessu/aprs-symbols), rev H.
|
||||||
|
# The set has no single upstream license -- individual symbols carry different
|
||||||
|
# terms, catalogued in LICENSES/LicenseRef-APRS-Symbols.txt. Upstream asks that
|
||||||
|
# users point back to https://github.com/hessu/aprs-symbols/.
|
||||||
|
[[annotations]]
|
||||||
|
path = [
|
||||||
|
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/aprs-symbols-24-0.png",
|
||||||
|
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/aprs-symbols-24-1.png",
|
||||||
|
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/aprs-symbols-24-2.png",
|
||||||
|
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/aprs-symbols-24-0-2x.png",
|
||||||
|
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/aprs-symbols-24-1-2x.png",
|
||||||
|
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/aprs-symbols-24-2-2x.png",
|
||||||
|
]
|
||||||
|
SPDX-FileCopyrightText = "Heikki Hannikainen OH7LZB and the APRS symbol set authors (https://github.com/hessu/aprs-symbols)"
|
||||||
|
SPDX-License-Identifier = "LicenseRef-APRS-Symbols"
|
||||||
|
|||||||
+46
-39
@@ -2,60 +2,67 @@
|
|||||||
#
|
#
|
||||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
# Gitea Actions runner image for trx-rs CI (host-executor / "Pattern B").
|
# trx-rs SDK / build image.
|
||||||
#
|
#
|
||||||
# All build dependencies, the Rust toolchain, Node.js (for JS actions such as
|
# Single source of truth for the build environment. Used two ways:
|
||||||
# actions/checkout and actions/cache) and the `reuse` tool are baked in, so CI
|
# * CI — as the job container for the lint/test jobs (Docker executor).
|
||||||
# runs skip the per-run apt/rustup install cost. `sudo` is present so the
|
# * Dev — run locally or via .devcontainer for a reproducible toolchain.
|
||||||
# existing workflow's `sudo apt-get ...` / rustup steps remain valid — they
|
#
|
||||||
# just become fast no-ops because everything is already installed.
|
# Pinning the Rust version here (and in rust-toolchain.toml) means CI and every
|
||||||
|
# developer share the exact same rustc/clippy, so "works locally, fails in CI"
|
||||||
|
# cannot happen.
|
||||||
FROM docker.io/library/debian:bookworm-slim
|
FROM docker.io/library/debian:bookworm-slim
|
||||||
|
|
||||||
ARG ACT_RUNNER_VERSION=0.2.11
|
# Keep in sync with rust-toolchain.toml.
|
||||||
|
ARG RUST_VERSION=1.97.1
|
||||||
ARG NODE_MAJOR=20
|
ARG NODE_MAJOR=20
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive \
|
ENV DEBIAN_FRONTEND=noninteractive \
|
||||||
RUSTUP_HOME=/opt/rustup \
|
RUSTUP_HOME=/usr/local/rustup \
|
||||||
CARGO_HOME=/opt/cargo \
|
CARGO_HOME=/usr/local/cargo \
|
||||||
PATH=/opt/cargo/bin:/usr/local/bin:/usr/bin:/bin
|
PATH=/usr/local/cargo/bin:/usr/local/bin:/usr/bin:/bin
|
||||||
|
|
||||||
# Base tooling + trx-rs build dependencies (mirrors .gitea/workflows/ci.yml).
|
# Build dependencies (mirror README's manual instructions).
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
ca-certificates curl xz-utils git sudo pipx \
|
ca-certificates curl git \
|
||||||
build-essential pkg-config cmake clang libclang-dev \
|
build-essential pkg-config cmake clang libclang-dev \
|
||||||
libopus-dev libasound2-dev libsoapysdr-dev \
|
libopus-dev libasound2-dev libsoapysdr-dev chromium \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Node.js (JS-based actions need node in PATH under the host executor).
|
# Node.js — JS-based actions (actions/checkout, actions/cache) run *inside*
|
||||||
|
# the job container under the Docker executor, so node must be present.
|
||||||
RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \
|
RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \
|
||||||
&& apt-get install -y --no-install-recommends nodejs \
|
&& apt-get install -y --no-install-recommends nodejs \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# REUSE >= 3 (Debian's packaged reuse is too old for REUSE.toml).
|
# Pinned Rust toolchain, installed world-readable so any UID the runner or a
|
||||||
# The [charset-normalizer] extra provides an encoding-detection backend;
|
# devcontainer uses can invoke cargo.
|
||||||
# without it (and without libmagic) reuse fails to import at runtime.
|
|
||||||
RUN PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin pipx install 'reuse[charset-normalizer]'
|
|
||||||
|
|
||||||
# Rust stable with rustfmt + clippy, installed system-wide.
|
|
||||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||||
| sh -s -- -y --no-modify-path --profile minimal \
|
| sh -s -- -y --no-modify-path \
|
||||||
--component rustfmt --component clippy \
|
--default-toolchain "${RUST_VERSION}" --profile minimal \
|
||||||
&& chmod -R a+rwX "$CARGO_HOME" "$RUSTUP_HOME"
|
--component rustfmt --component clippy \
|
||||||
|
&& chmod -R a+rwX "$RUSTUP_HOME" "$CARGO_HOME"
|
||||||
|
|
||||||
# act_runner binary.
|
# sccache — shared compilation cache. Enabled at build time via
|
||||||
RUN arch="$(dpkg --print-architecture)"; \
|
# RUSTC_WRAPPER (see the CI workflow and .devcontainer), not repo-wide, so
|
||||||
case "$arch" in amd64) rarch=amd64;; arm64) rarch=arm64;; *) echo "unsupported arch $arch" >&2; exit 1;; esac; \
|
# non-SDK builds are unaffected. musl build is static and runs anywhere.
|
||||||
curl -fsSL -o /usr/local/bin/act_runner \
|
#
|
||||||
"https://gitea.com/gitea/act_runner/releases/download/v${ACT_RUNNER_VERSION}/act_runner-${ACT_RUNNER_VERSION}-linux-${rarch}" \
|
# The release asset is per-architecture, so resolve it from `uname -m` rather
|
||||||
&& chmod +x /usr/local/bin/act_runner
|
# than hardcoding one triple: everything else in this image is arch-agnostic,
|
||||||
|
# and a pinned x86_64 URL is what forces an amd64 build (and Rosetta or qemu)
|
||||||
|
# on an arm64 host. `uname -m` reflects the build platform under plain
|
||||||
|
# docker/podman build as well as buildx, unlike the BuildKit-only TARGETARCH.
|
||||||
|
ARG SCCACHE_VERSION=0.8.2
|
||||||
|
RUN set -eux; \
|
||||||
|
case "$(uname -m)" in \
|
||||||
|
x86_64) sccache_arch=x86_64 ;; \
|
||||||
|
aarch64|arm64) sccache_arch=aarch64 ;; \
|
||||||
|
*) echo "unsupported architecture for sccache: $(uname -m)" >&2; exit 1 ;; \
|
||||||
|
esac; \
|
||||||
|
sccache_dist="sccache-v${SCCACHE_VERSION}-${sccache_arch}-unknown-linux-musl"; \
|
||||||
|
curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/${sccache_dist}.tar.gz" \
|
||||||
|
| tar -xz -C /tmp; \
|
||||||
|
install -m755 "/tmp/${sccache_dist}/sccache" /usr/local/bin/sccache; \
|
||||||
|
rm -rf /tmp/sccache-*
|
||||||
|
|
||||||
# Default config template (seeded into the /data volume on first boot).
|
WORKDIR /work
|
||||||
COPY config.yaml /etc/act_runner/config.yaml
|
|
||||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
|
||||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
|
||||||
|
|
||||||
# /data holds the .runner registration, cache and workflow workspaces.
|
|
||||||
VOLUME /data
|
|
||||||
WORKDIR /data
|
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
|
||||||
|
|||||||
+156
-81
@@ -3,116 +3,191 @@ SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|||||||
SPDX-License-Identifier: GPL-2.0-or-later
|
SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# Podman-based Gitea Actions runners
|
# trx-rs SDK image
|
||||||
|
|
||||||
Run two independent Gitea Actions runners on one host as rootless Podman
|
A single container image that is the canonical build environment for trx-rs,
|
||||||
containers managed by systemd (Quadlet) — one per project — instead of two
|
used **both** by CI and by developers. It bakes in the pinned Rust toolchain
|
||||||
VMs. Uses the **host executor**: workflow steps run directly inside a
|
(matching `rust-toolchain.toml`) and every build dependency, so the compiler
|
||||||
purpose-built runner image that already has the Rust toolchain and all build
|
and `clippy` are identical everywhere — no "works on my machine".
|
||||||
dependencies baked in, so CI runs skip the per-run install cost and no
|
|
||||||
Docker/Podman socket is needed.
|
|
||||||
|
|
||||||
## Files
|
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `Containerfile` | Runner image: Debian + build deps + clang + Rust + Node + `reuse` + `act_runner`. |
|
| `Containerfile` | The SDK image (Debian + build deps + pinned Rust + Node + git). |
|
||||||
| `entrypoint.sh` | Registers on first boot (if needed), then runs the daemon. |
|
| `runner-config.example.yaml` | Example act_runner config for the CI VM (Docker executor). |
|
||||||
| `config.yaml` | act_runner config template (seeded into each runner's volume). |
|
|
||||||
| `trx-rs-runner.container` | Quadlet unit for the trx-rs runner. |
|
|
||||||
| `project2-runner.container` | Quadlet unit for the second project's runner. |
|
|
||||||
|
|
||||||
## Prerequisites (once per host)
|
## Build and publish
|
||||||
|
|
||||||
Rootless Podman with cgroups v2 (default on modern distros). As the unprivileged
|
Nothing in the image is architecture-specific: the base image, the Debian build
|
||||||
user that will own the runners:
|
dependencies, Node.js, `rustup` and the `sccache` release all resolve per
|
||||||
|
architecture, so the same `Containerfile` builds natively on x86_64 and arm64.
|
||||||
|
|
||||||
|
Single architecture — the tag then only works on the architecture you built it
|
||||||
|
on:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Survive logout / start on boot without an interactive session.
|
# from the repo root
|
||||||
loginctl enable-linger "$USER"
|
podman build -t git.haxx.space/sjg/trx-rs/sdk:latest container
|
||||||
|
podman login git.haxx.space
|
||||||
|
podman push git.haxx.space/sjg/trx-rs/sdk:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
No `podman.socket` is required for the host executor.
|
**Both architectures without emulation.** The CI runner is x86_64 and Apple
|
||||||
|
Silicon developer machines are arm64, so `:latest` has to be a manifest list —
|
||||||
## 1. Build the image
|
a single-architecture tag makes the other side fall back to Rosetta or qemu.
|
||||||
|
Build each half natively on a host of that architecture, then join them:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd container
|
# on an x86_64 host
|
||||||
podman build -t trx-rs-ci:latest .
|
podman build --platform linux/amd64 -t git.haxx.space/sjg/trx-rs/sdk:latest-amd64 container
|
||||||
|
podman push git.haxx.space/sjg/trx-rs/sdk:latest-amd64
|
||||||
|
|
||||||
|
# on an arm64 host
|
||||||
|
podman build --platform linux/arm64 -t git.haxx.space/sjg/trx-rs/sdk:latest-arm64 container
|
||||||
|
podman push git.haxx.space/sjg/trx-rs/sdk:latest-arm64
|
||||||
|
|
||||||
|
# from either, once both are pushed
|
||||||
|
podman manifest create git.haxx.space/sjg/trx-rs/sdk:latest \
|
||||||
|
git.haxx.space/sjg/trx-rs/sdk:latest-amd64 \
|
||||||
|
git.haxx.space/sjg/trx-rs/sdk:latest-arm64
|
||||||
|
podman manifest push --all git.haxx.space/sjg/trx-rs/sdk:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
## 2. Get a registration token
|
Building both from one machine is a single command
|
||||||
|
(`podman build --platform linux/amd64,linux/arm64 --manifest ...`), but the
|
||||||
|
foreign half runs under emulation and is slow — the two-host flow above is
|
||||||
|
what keeps every build native.
|
||||||
|
|
||||||
For **each** repo: *Settings → Actions → Runners → Create new Runner* and copy
|
Tag with the Rust version too (e.g. `:1.97.1`) if you want reproducible pins.
|
||||||
the token. (Org- or instance-level tokens work too if you prefer wider scope.)
|
Make the package **public** (Gitea → Packages → the image → Settings) so the CI
|
||||||
|
runner and developers can pull it without credentials. If you keep it private,
|
||||||
|
add `credentials:` under the workflow's `container:` and log the runner into the
|
||||||
|
registry.
|
||||||
|
|
||||||
## 3. Install and start the runners
|
Pushing a rebuilt image is not enough on its own: `:latest` is a moving tag, and
|
||||||
|
act_runner reuses whatever it cached the first time unless `force_pull: true` is
|
||||||
|
set (see `runner-config.example.yaml`). Without it the job log says
|
||||||
|
`Image exists? true` and the run behaves as though the image were never
|
||||||
|
rebuilt — a tool added to the `Containerfile` reads as missing from the image.
|
||||||
|
Either set `force_pull`, or refresh the VM's copy by hand:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p ~/.config/containers/systemd
|
docker pull git.haxx.space/sjg/trx-rs/sdk:latest
|
||||||
cp trx-rs-runner.container project2-runner.container ~/.config/containers/systemd/
|
docker run --rm git.haxx.space/sjg/trx-rs/sdk:latest sccache --version
|
||||||
|
|
||||||
# Paste each repo's token for the FIRST boot only:
|
|
||||||
# Environment=GITEA_RUNNER_REGISTRATION_TOKEN=xxxx…
|
|
||||||
$EDITOR ~/.config/containers/systemd/trx-rs-runner.container
|
|
||||||
$EDITOR ~/.config/containers/systemd/project2-runner.container
|
|
||||||
|
|
||||||
systemctl --user daemon-reload
|
|
||||||
systemctl --user start trx-rs-runner
|
|
||||||
systemctl --user start project2-runner
|
|
||||||
|
|
||||||
systemctl --user status trx-rs-runner
|
|
||||||
podman logs -f gitea-runner-trx-rs
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Once each runner shows **online** in the repo's runner list, blank out the
|
### macOS note
|
||||||
`GITEA_RUNNER_REGISTRATION_TOKEN` line again (the registration is persisted in
|
|
||||||
the `…-data` volume) and `systemctl --user daemon-reload`.
|
|
||||||
|
|
||||||
## Required workflow change: the `reuse` job
|
Apple's `container` CLI builds through a BuildKit helper VM that is configured
|
||||||
|
with Rosetta whether or not the target is x86_64, so `container build` fails
|
||||||
|
with *"Rosetta is not installed"* on a clean machine. That is a property of the
|
||||||
|
builder, not of this image — `container run` works natively without it. Either
|
||||||
|
install Rosetta once (`softwareupdate --install-rosetta`, after which an arm64
|
||||||
|
build still produces a native arm64 image), or build with Podman, whose arm64
|
||||||
|
BuildKit needs no emulation.
|
||||||
|
|
||||||
The host executor runs steps directly in the container and therefore **cannot
|
## Developer use
|
||||||
run Docker-based actions**. The current `reuse` job uses `fsfe/reuse-action@v5`,
|
|
||||||
which is a Docker action. `reuse` is baked into the image, so replace that job
|
|
||||||
with a plain command:
|
|
||||||
|
|
||||||
```yaml
|
Reproducible one-off build, no local toolchain needed:
|
||||||
reuse:
|
|
||||||
runs-on: ubuntu-latest
|
```bash
|
||||||
steps:
|
podman run --rm -it -v "$PWD":/work -w /work \
|
||||||
- uses: actions/checkout@v4
|
git.haxx.space/sjg/trx-rs/sdk:latest \
|
||||||
- name: REUSE compliance
|
cargo build --release
|
||||||
run: reuse lint
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The `lint` and `test` jobs need no changes: their `sudo apt-get …` and rustup
|
Or open the repo in the image via VS Code / JetBrains "Reopen in Container"
|
||||||
steps still run, but become fast no-ops because the image already has those
|
(`.devcontainer/devcontainer.json` points at the same image).
|
||||||
packages and the toolchain. (`sudo` is included in the image for exactly this
|
|
||||||
reason.)
|
|
||||||
|
|
||||||
> If you would rather keep Docker-based actions and per-run images, use the
|
Building outside the container? `rust-toolchain.toml` pins the same rustc, so
|
||||||
> **Docker executor** instead: drop the `:host` suffix from the label in
|
`rustup` installs the matching toolchain automatically.
|
||||||
> `config.yaml`, enable `systemctl --user --now enable podman.socket`, mount it
|
|
||||||
> into the container, and set `container.docker_host` to the socket path. That
|
|
||||||
> trades the baked-in speed for stronger per-job isolation.
|
|
||||||
|
|
||||||
## Tuning
|
## CI use
|
||||||
|
|
||||||
- **`capacity`** (in `config.yaml`) — concurrent jobs per runner. Rust builds
|
`.gitea/workflows/ci.yml` runs the `lint`, `test` and `frontend` jobs *inside*
|
||||||
are heavy; 1–2 is sensible when two runners share a host.
|
this image via the `container:` key, so they skip all setup and go straight to
|
||||||
- **`PodmanArgs=--cpus/--memory`** (in each `.container`) — hard resource caps
|
`cargo` and `npm`. The frontend job needs three things from the image beyond
|
||||||
so one project cannot starve the other.
|
Rust: Node.js for the toolchain, Chromium at `/usr/bin/chromium` for the
|
||||||
- **SELinux** — the `:Z` volume flag is already set; keep it if SELinux is
|
browser smoke test, and `cargo` — `npm run verify-generated` regenerates the
|
||||||
enforcing.
|
Rust wire contracts before checking for drift.
|
||||||
|
|
||||||
## Committing these files
|
The `reuse` job stays on the upstream `fsfe/reuse-action` (a Docker action the
|
||||||
|
Docker executor launches as a sibling container) — nothing REUSE-related is
|
||||||
|
baked into the SDK, and it lints the whole repository, so no job runs its own
|
||||||
|
licence check.
|
||||||
|
|
||||||
If you add this directory to a REUSE-checked repo, register the markdown in
|
## Compilation cache (sccache)
|
||||||
`REUSE.toml` (the other files carry inline SPDX headers):
|
|
||||||
|
|
||||||
```toml
|
The SDK image ships [`sccache`](https://github.com/mozilla/sccache). It is
|
||||||
[[annotations]]
|
enabled via `RUSTC_WRAPPER=sccache` in CI and the devcontainer (not repo-wide,
|
||||||
path = ["container/**"]
|
so plain `cargo` builds outside the SDK are unaffected).
|
||||||
SPDX-FileCopyrightText = "2026 Stan Grams <sjg@haxx.space>"
|
|
||||||
SPDX-License-Identifier = "GPL-2.0-or-later"
|
- **CI** persists the cache on the runner host — create the dir once:
|
||||||
|
`mkdir -p /var/cache/sccache`. It is bind-mounted into each job container at
|
||||||
|
`/sccache` (see `runner-config.example.yaml`), so cache survives across runs
|
||||||
|
and is shared between the lint/test jobs and both projects.
|
||||||
|
- **Devcontainer** uses a named volume (`trx-rs-sccache`).
|
||||||
|
- Check effectiveness with `sccache --show-stats` (the CI jobs print it).
|
||||||
|
|
||||||
|
`CARGO_INCREMENTAL=0` is set wherever sccache is on, since sccache cannot cache
|
||||||
|
incremental artifacts.
|
||||||
|
|
||||||
|
## CI runner (Alpine / OpenRC)
|
||||||
|
|
||||||
|
The runner uses the **Docker executor** (not the host executor): per-job
|
||||||
|
container isolation and standard `ubuntu-latest` semantics. `act_runner` runs
|
||||||
|
as an OpenRC service. Files provided:
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `act_runner.openrc` | OpenRC init script (`supervise-daemon`, depends on docker). |
|
||||||
|
| `act_runner.confd.example` | Per-instance `conf.d` settings for multi-runner hosts. |
|
||||||
|
|
||||||
|
**Cap the thread budget.** In a VM, pin its vCPUs to specific host threads
|
||||||
|
(libvirt/KVM):
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<vcpu placement='static'>2</vcpu>
|
||||||
|
<cputune>
|
||||||
|
<vcpupin vcpu='0' cpuset='4'/>
|
||||||
|
<vcpupin vcpu='1' cpuset='5'/>
|
||||||
|
</cputune>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
On bare metal, the `container.options: "--cpus=2"` and `capacity: 1` in
|
||||||
|
`runner-config.example.yaml` already bound each runner.
|
||||||
|
|
||||||
|
**Set it up:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Docker + a dedicated user with socket access
|
||||||
|
apk add docker docker-cli
|
||||||
|
rc-update add docker default && rc-service docker start
|
||||||
|
adduser -S -D -H -h /var/lib/act_runner act
|
||||||
|
addgroup act docker
|
||||||
|
|
||||||
|
# 2. act_runner binary (static Go build, works on musl)
|
||||||
|
# Upstream publishes per-architecture builds; pick the host's.
|
||||||
|
case "$(uname -m)" in x86_64) arch=amd64 ;; aarch64) arch=arm64 ;; esac
|
||||||
|
curl -fsSL -o /usr/local/bin/act_runner \
|
||||||
|
"https://gitea.com/gitea/act_runner/releases/download/v0.2.11/act_runner-0.2.11-linux-${arch}"
|
||||||
|
chmod +x /usr/local/bin/act_runner
|
||||||
|
|
||||||
|
# 3. Config + register one runner per project (scope keeps their jobs apart)
|
||||||
|
install -Dm644 container/runner-config.example.yaml /etc/act_runner/trx-rs.yaml
|
||||||
|
install -d -o act /var/lib/act_runner/trx-rs
|
||||||
|
su act -s /bin/sh -c 'cd /var/lib/act_runner/trx-rs && \
|
||||||
|
act_runner register --no-interactive \
|
||||||
|
--instance https://git.haxx.space --token <TOKEN> \
|
||||||
|
--name trx-rs-ci \
|
||||||
|
--labels "ubuntu-latest:docker://catthehacker/ubuntu:act-latest"'
|
||||||
|
|
||||||
|
# 4. OpenRC service (repeat the symlink+conf.d for the second project)
|
||||||
|
install -m755 container/act_runner.openrc /etc/init.d/act_runner
|
||||||
|
ln -s act_runner /etc/init.d/act_runner.trx-rs
|
||||||
|
install -m644 container/act_runner.confd.example /etc/conf.d/act_runner.trx-rs
|
||||||
|
rc-update add act_runner.trx-rs default
|
||||||
|
rc-service act_runner.trx-rs start
|
||||||
|
```
|
||||||
|
|
||||||
|
Check it with `rc-service act_runner.trx-rs status` and
|
||||||
|
`tail -f /var/log/act_runner.trx-rs.log`.
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
#
|
||||||
|
# Per-instance settings for an act_runner OpenRC service.
|
||||||
|
# Copy to /etc/conf.d/<service-name>, e.g. /etc/conf.d/act_runner.trx-rs
|
||||||
|
# (the name must match the /etc/init.d/ symlink).
|
||||||
|
|
||||||
|
# User that runs the daemon. Must be a member of the `docker` group.
|
||||||
|
runner_user="act"
|
||||||
|
|
||||||
|
# Per-instance state dir (holds the .runner registration) and config file,
|
||||||
|
# so two runners on one host stay independent.
|
||||||
|
runner_dir="/var/lib/act_runner/trx-rs"
|
||||||
|
runner_config="/etc/act_runner/trx-rs.yaml"
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
#!/sbin/openrc-run
|
||||||
|
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
#
|
||||||
|
# OpenRC service for a Gitea act_runner (Docker executor) on Alpine.
|
||||||
|
#
|
||||||
|
# Install as /etc/init.d/act_runner (chmod +x). Single instance uses
|
||||||
|
# /etc/act_runner/config.yaml. For one runner per project, symlink this script
|
||||||
|
# and add a matching conf.d file:
|
||||||
|
#
|
||||||
|
# ln -s act_runner /etc/init.d/act_runner.trx-rs
|
||||||
|
# cp container/act_runner.confd.example /etc/conf.d/act_runner.trx-rs
|
||||||
|
# $EDITOR /etc/conf.d/act_runner.trx-rs # set runner_dir / runner_config
|
||||||
|
# rc-update add act_runner.trx-rs default
|
||||||
|
# rc-service act_runner.trx-rs start
|
||||||
|
|
||||||
|
description="Gitea Actions runner"
|
||||||
|
|
||||||
|
: "${runner_user:=act}"
|
||||||
|
: "${runner_dir:=/var/lib/act_runner}"
|
||||||
|
: "${runner_config:=/etc/act_runner/config.yaml}"
|
||||||
|
|
||||||
|
command="/usr/local/bin/act_runner"
|
||||||
|
command_args="daemon --config ${runner_config}"
|
||||||
|
# No group given, so supplementary groups (incl. docker) are initialised.
|
||||||
|
command_user="${runner_user}"
|
||||||
|
directory="${runner_dir}"
|
||||||
|
|
||||||
|
supervisor="supervise-daemon"
|
||||||
|
respawn_delay=5
|
||||||
|
respawn_max=0
|
||||||
|
pidfile="/run/${RC_SVCNAME}.pid"
|
||||||
|
output_log="/var/log/${RC_SVCNAME}.log"
|
||||||
|
error_log="/var/log/${RC_SVCNAME}.log"
|
||||||
|
|
||||||
|
depend() {
|
||||||
|
need docker
|
||||||
|
use net dns
|
||||||
|
}
|
||||||
|
|
||||||
|
start_pre() {
|
||||||
|
checkpath -d -m 0750 -o "${runner_user}" "${runner_dir}"
|
||||||
|
checkpath -f -m 0640 -o "${runner_user}" "${output_log}"
|
||||||
|
}
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
#
|
|
||||||
# act_runner configuration template. Seeded into /data/config.yaml on first
|
|
||||||
# boot; edit the copy inside the volume to change settings per runner.
|
|
||||||
|
|
||||||
log:
|
|
||||||
level: info
|
|
||||||
|
|
||||||
runner:
|
|
||||||
# Registration state. Relative to the daemon's working directory (/data).
|
|
||||||
file: .runner
|
|
||||||
# Concurrent jobs this runner will pick up. Rust builds are heavy — keep this
|
|
||||||
# modest, especially if two runners share one host. The trx-rs workflow has
|
|
||||||
# three parallel jobs (lint, test, reuse); capacity 2 lets two overlap.
|
|
||||||
capacity: 2
|
|
||||||
timeout: 3h
|
|
||||||
# Map the workflow's `runs-on: ubuntu-latest` to the HOST executor, i.e. run
|
|
||||||
# steps directly inside THIS container (which already has all the toolchain).
|
|
||||||
# No Docker/Podman socket is required in this mode.
|
|
||||||
labels:
|
|
||||||
- "ubuntu-latest:host"
|
|
||||||
|
|
||||||
cache:
|
|
||||||
# Built-in actions cache server (used by actions/cache). Stored in the volume.
|
|
||||||
enabled: true
|
|
||||||
dir: "/data/cache"
|
|
||||||
|
|
||||||
host:
|
|
||||||
# Where per-job workspaces are created.
|
|
||||||
workdir_parent: /data/workflows
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
#
|
|
||||||
# Registers the runner on first boot (if no .runner state exists in /data),
|
|
||||||
# then runs the act_runner daemon. Idempotent: on subsequent boots it reuses
|
|
||||||
# the stored registration and ignores the token.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
CONFIG_FILE="${CONFIG_FILE:-/data/config.yaml}"
|
|
||||||
|
|
||||||
cd /data
|
|
||||||
|
|
||||||
# Seed the config from the image's template on first boot so it lives in the
|
|
||||||
# persistent volume and can be edited there.
|
|
||||||
if [ ! -f "$CONFIG_FILE" ]; then
|
|
||||||
cp /etc/act_runner/config.yaml "$CONFIG_FILE"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# runner.file in config.yaml is ".runner" (relative to this CWD => /data/.runner).
|
|
||||||
if [ ! -f /data/.runner ]; then
|
|
||||||
if [ -z "${GITEA_RUNNER_REGISTRATION_TOKEN:-}" ]; then
|
|
||||||
echo "ERROR: no /data/.runner registration and GITEA_RUNNER_REGISTRATION_TOKEN is empty." >&2
|
|
||||||
echo " Grab a token from the repo's Settings -> Actions -> Runners and set it" >&2
|
|
||||||
echo " in the Quadlet unit for the first boot only." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "Registering runner '${GITEA_RUNNER_NAME:-podman}' with ${GITEA_INSTANCE_URL} ..."
|
|
||||||
act_runner register --no-interactive \
|
|
||||||
--config "$CONFIG_FILE" \
|
|
||||||
--instance "${GITEA_INSTANCE_URL:?set GITEA_INSTANCE_URL}" \
|
|
||||||
--token "$GITEA_RUNNER_REGISTRATION_TOKEN" \
|
|
||||||
--name "${GITEA_RUNNER_NAME:-podman}" \
|
|
||||||
--labels "${GITEA_RUNNER_LABELS:-ubuntu-latest:host}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
exec act_runner daemon --config "$CONFIG_FILE"
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
#
|
|
||||||
# Rootless Podman Quadlet for the SECOND project's Gitea Actions runner,
|
|
||||||
# co-located on the same host as the trx-rs runner.
|
|
||||||
#
|
|
||||||
# It has its own name, its own data volume and its own registration token, so
|
|
||||||
# the two runners are fully independent. They share the `ubuntu-latest` label,
|
|
||||||
# but registration SCOPE (which repo each token came from) keeps their jobs
|
|
||||||
# separate — neither will pick up the other's work.
|
|
||||||
#
|
|
||||||
# If project 2 needs different build dependencies, build it its own image from
|
|
||||||
# an adjusted Containerfile and point Image= at that instead of reusing the
|
|
||||||
# trx-rs image below.
|
|
||||||
|
|
||||||
[Unit]
|
|
||||||
Description=Gitea Actions runner — project 2
|
|
||||||
After=network-online.target
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Container]
|
|
||||||
Image=localhost/gitea-act-runner:latest
|
|
||||||
ContainerName=gitea-runner-project2
|
|
||||||
Volume=gitea-runner-project2-data:/data:Z
|
|
||||||
|
|
||||||
Environment=CONFIG_FILE=/data/config.yaml
|
|
||||||
Environment=GITEA_INSTANCE_URL=https://git.haxx.space
|
|
||||||
Environment=GITEA_RUNNER_NAME=project2-podman
|
|
||||||
Environment=GITEA_RUNNER_LABELS=ubuntu-latest:host
|
|
||||||
Environment=GITEA_RUNNER_REGISTRATION_TOKEN=
|
|
||||||
|
|
||||||
PodmanArgs=--cpus=4.0 --memory=6g
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Restart=always
|
|
||||||
TimeoutStartSec=0
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=default.target
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
#
|
||||||
|
# Example act_runner config for the Docker-executor runner that lives in the
|
||||||
|
# CI VM. This is NOT the SDK image — it configures the runner that launches
|
||||||
|
# per-job containers (including the trx-rs SDK image referenced by the
|
||||||
|
# workflow's `container:` key). Copy to the VM and pass with
|
||||||
|
# `act_runner daemon --config`.
|
||||||
|
|
||||||
|
log:
|
||||||
|
level: info
|
||||||
|
|
||||||
|
runner:
|
||||||
|
file: .runner
|
||||||
|
# One concurrent job. With one runner per project on a 2-vCPU VM this keeps
|
||||||
|
# total CI usage at ~2 threads.
|
||||||
|
capacity: 1
|
||||||
|
timeout: 3h
|
||||||
|
# Docker executor: no ":host" suffix. Maps runs-on labels to base images
|
||||||
|
# (the workflow overrides these per job via `container:`).
|
||||||
|
labels:
|
||||||
|
- "ubuntu-latest:docker://catthehacker/ubuntu:act-latest"
|
||||||
|
|
||||||
|
cache:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
container:
|
||||||
|
# Cap every job container's CPU so CI stays within the 2-thread budget even
|
||||||
|
# if capacity is raised later. The -v mount persists the sccache cache on the
|
||||||
|
# host (create it first: `mkdir -p /var/cache/sccache`), matching SCCACHE_DIR
|
||||||
|
# in the workflow.
|
||||||
|
options: "--cpus=2 -v /var/cache/sccache:/sccache"
|
||||||
|
# act_runner rejects every bind mount unless it is listed here — the default
|
||||||
|
# is an empty allowlist, so the -v above is dropped with only a
|
||||||
|
# "[...] is not a valid volume, will be ignored" line in the job log, and
|
||||||
|
# SCCACHE_DIR then points at a directory that does not outlive the job.
|
||||||
|
valid_volumes:
|
||||||
|
- /var/cache/sccache
|
||||||
|
# Reuse the host VM's Docker network for the built-in cache/artifact server.
|
||||||
|
network: "host"
|
||||||
|
# The workflow pulls the SDK image by the moving `:latest` tag. Without this
|
||||||
|
# the runner logs "Image exists? true" and reuses whatever it cached the
|
||||||
|
# first time, so pushing a rebuilt image has no effect until someone pulls
|
||||||
|
# on the VM by hand — which looks like the image is missing a tool it in
|
||||||
|
# fact has. The extra registry round-trip per job is nothing next to a build.
|
||||||
|
force_pull: true
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
#
|
|
||||||
# Rootless Podman Quadlet for the trx-rs Gitea Actions runner.
|
|
||||||
# Install to ~/.config/containers/systemd/trx-rs-runner.container then:
|
|
||||||
# systemctl --user daemon-reload
|
|
||||||
# systemctl --user start trx-rs-runner
|
|
||||||
#
|
|
||||||
# First boot only: paste a registration token (repo Settings -> Actions ->
|
|
||||||
# Runners) into GITEA_RUNNER_REGISTRATION_TOKEN. After the runner appears
|
|
||||||
# online you can blank it again — the registration is persisted in the volume.
|
|
||||||
|
|
||||||
[Unit]
|
|
||||||
Description=Gitea Actions runner — trx-rs
|
|
||||||
After=network-online.target
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Container]
|
|
||||||
Image=localhost/trx-rs-ci:latest
|
|
||||||
ContainerName=gitea-runner-trx-rs
|
|
||||||
# Persistent state: .runner registration, cache, workspaces.
|
|
||||||
Volume=gitea-runner-trx-rs-data:/data:Z
|
|
||||||
|
|
||||||
Environment=CONFIG_FILE=/data/config.yaml
|
|
||||||
Environment=GITEA_INSTANCE_URL=https://git.haxx.space
|
|
||||||
Environment=GITEA_RUNNER_NAME=trx-rs-podman
|
|
||||||
Environment=GITEA_RUNNER_LABELS=ubuntu-latest:host
|
|
||||||
Environment=GITEA_RUNNER_REGISTRATION_TOKEN=
|
|
||||||
|
|
||||||
# Resource caps so a heavy Rust build here cannot starve the other project's
|
|
||||||
# runner on the same host. Tune to your box.
|
|
||||||
PodmanArgs=--cpus=4.0 --memory=6g
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Restart=always
|
|
||||||
# A cold Rust build can be slow; don't let systemd consider startup failed.
|
|
||||||
TimeoutStartSec=0
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=default.target
|
|
||||||
@@ -1043,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`, `rx_passphrase`, `session_ttl_secs`, `tokens` |
|
| `HttpAuthConfig` | HTTP auth settings | `enabled`, `users_file`, bootstrap admin, `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` |
|
||||||
|
|||||||
@@ -322,3 +322,269 @@ 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 taken from the session's identity: the auth roles are `admin` and `user`, with no
|
||||||
|
notion of who is logged in.
|
||||||
|
|
||||||
|
**Server clock, and the log says so.** The server is the machine at the radio; the browser may
|
||||||
|
be on a phone in another timezone with a clock nobody has checked. QSO times are UTC from the
|
||||||
|
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.
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# Spectrum Controls — Visual Rework
|
||||||
|
|
||||||
|
The strip between the spectrum and the radio controls (`#spectrum-controls`)
|
||||||
|
holds eight controls in two groups. This proposes how it should *look*.
|
||||||
|
|
||||||
|
Every control stays, in its current order, with its current name and its
|
||||||
|
current behaviour. Nothing here changes what a button does, what commits when,
|
||||||
|
or what is stored. It is a styling and layout change.
|
||||||
|
|
||||||
|
*Status: implemented. Kept as the record of what was changed and why.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What it looks like now
|
||||||
|
|
||||||
|
```
|
||||||
|
Bandwidth [ 12 ] kHz [Set] [Auto BW] [Sweet-spot] Peak Hold [2 s] Floor [-115] dB Range [90] dB [Auto] Contrast [——●——] 1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Four problems, all of them visual:
|
||||||
|
|
||||||
|
**1. Four different control heights on one line.** The bare number inputs, the
|
||||||
|
buttons, the `select` and the range slider are each sized by their own rule, so
|
||||||
|
nothing shares a baseline and the row reads as a pile rather than a strip.
|
||||||
|
|
||||||
|
**2. Units are loose text.** `kHz`, `dB`, `dB` and the contrast value `1.0` are
|
||||||
|
text nodes sitting outside the control they belong to, separated from it by a
|
||||||
|
gap the same size as the gap between unrelated controls. The eye has to work
|
||||||
|
out which number owns which unit.
|
||||||
|
|
||||||
|
**3. A quarter of the strip is a hole.** `justify-content: space-between` puts
|
||||||
|
about 250 px of nothing in the middle at 1600 px, and the two groups read as
|
||||||
|
two unrelated things because the only thing between them is emptiness.
|
||||||
|
|
||||||
|
**4. The groups stagger between 1100 and 1400 px.** The bandwidth group wraps
|
||||||
|
to two lines while the level group stays on one, so the level group floats at a
|
||||||
|
height of its own, aligned with neither line of the group beside it. This is
|
||||||
|
the worst of it, and it happens at a common window width.
|
||||||
|
|
||||||
|
Two smaller things: 24 px controls are below any touch-target guideline, and
|
||||||
|
the contrast readout has no fixed width, so the row twitches as the value
|
||||||
|
changes between `1.0` and `0.9`.
|
||||||
|
|
||||||
|
## Proposed
|
||||||
|
|
||||||
|
**One control height, units inside their field, and a rule where the clusters
|
||||||
|
meet.**
|
||||||
|
|
||||||
|
- **A field is one box.** Label, value and unit share a single bordered box —
|
||||||
|
`Bandwidth │ 12.0 │ kHz` — so a number and its unit can never be read apart.
|
||||||
|
Fields, buttons, the peak-hold select and the contrast slider are all 1.7 rem
|
||||||
|
tall, on 44 px targets under a coarse pointer.
|
||||||
|
- **A rule, not a hole.** The two clusters are separated by a thin vertical
|
||||||
|
rule with normal spacing either side. The slack goes to a flexible spacer, so
|
||||||
|
the strip is left-aligned rather than pushed apart.
|
||||||
|
- **A cluster never splits.** Each cluster is `nowrap`; the container wraps. If
|
||||||
|
a cluster does not fit on the line it drops whole to the next one,
|
||||||
|
left-aligned with the one above. No staggering, at any width.
|
||||||
|
- **Rules fall away at line starts.** A cluster that begins a line has no rule
|
||||||
|
hanging off its left edge.
|
||||||
|
- **The contrast readout gets a fixed, tabular slot**, so the row is still.
|
||||||
|
|
||||||
|
Below the existing mobile breakpoint the strip already stacks; the same field
|
||||||
|
component applies there, which is most of what makes it look deliberate.
|
||||||
|
|
||||||
|
## What this does not change
|
||||||
|
|
||||||
|
`Set` stays. `Auto BW` and `Auto` keep their names, even though they mean
|
||||||
|
different things — that is a naming question, not a styling one. Sweet-spot
|
||||||
|
stays where it is and keeps its behaviour. Nothing gains or loses persistence.
|
||||||
|
Nothing moves into a popover, and no control is hidden behind a click.
|
||||||
|
|
||||||
|
Those are all worth arguing about separately; a note of them is at the end of
|
||||||
|
this file so the arguments are not lost.
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
One pass, no behaviour touched:
|
||||||
|
|
||||||
|
1. `.spectrum-field` and `.spectrum-btn` in `style.css`, replacing the six
|
||||||
|
per-id rules (`#spectrum-bw-input`, `#spectrum-floor-input`,
|
||||||
|
`#spectrum-range-input`, `#spectrum-bw-label`, `#spectrum-floor-label`,
|
||||||
|
`#spectrum-range-label`) that currently repeat the same declarations.
|
||||||
|
2. Markup in `index.html`: the loose `kHz` / `dB` text nodes move inside their
|
||||||
|
label, which keeps every id and every event handler exactly where it is.
|
||||||
|
3. `#spectrum-controls` becomes a wrapping flex row with a spacer;
|
||||||
|
`#spectrum-bw-row` and `#spectrum-level-row` become `nowrap` clusters with a
|
||||||
|
left rule.
|
||||||
|
4. The mobile block in the media query drops the rules it no longer needs.
|
||||||
|
|
||||||
|
`app.ts` is not touched. Every id survives, so the existing handlers, the
|
||||||
|
`spectrum-layout.mjs` geometry test and the broadcast-layout highlight all keep
|
||||||
|
working.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
Extend `spectrum-layout.mjs`, which already measures this area:
|
||||||
|
|
||||||
|
- Every control in the strip shares one height, at 1600, 1200 and 900 px.
|
||||||
|
- No two clusters sit at different vertical offsets on the same line — the
|
||||||
|
staggering bug, asserted directly.
|
||||||
|
- The strip never overflows its container and never overlaps the hint line.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Noted for later, not proposed here
|
||||||
|
|
||||||
|
Behavioural observations from reading the code, kept so they are not lost:
|
||||||
|
|
||||||
|
- `Auto BW` (filter) and `Auto` (display scaling) are both called Auto, 600 px
|
||||||
|
apart.
|
||||||
|
- `Floor`, `Range` and `Contrast` are not persisted; `Peak Hold` is.
|
||||||
|
- `Auto` and `Auto BW` are one-shot: no state, nothing to turn off.
|
||||||
|
- `Sweet-spot` retunes the SDR and waits up to 1.4 s per candidate centre, with
|
||||||
|
no busy indication.
|
||||||
|
- Contrast resets on a double-click that nothing advertises.
|
||||||
+10
-11
@@ -121,13 +121,12 @@ The spectrum panel uses `<canvas>` elements (WebGL renderer optional) and offers
|
|||||||
When auth is enabled, an **auth gate** blocks the UI with:
|
When auth is enabled, an **auth gate** blocks the UI with:
|
||||||
|
|
||||||
- Title: "Access Required"
|
- Title: "Access Required"
|
||||||
- Subtitle: "Enter passphrase to continue"
|
- Subtitle: "Sign in to continue"
|
||||||
- Password input + Login button (green accent, full-width)
|
- Username and password inputs + Login button (green accent, full-width)
|
||||||
- Optional "Continue as Guest" button (shown when RX passphrase is not set)
|
|
||||||
- Error message area (red `#ff6b6b`)
|
- Error message area (red `#ff6b6b`)
|
||||||
- Role badge display
|
- Role badge display
|
||||||
|
|
||||||
Two roles: **Rx** (read-only) and **Control** (full access including TX/PTT).
|
Two roles: **User** (read-only) and **Admin** (full access including user management).
|
||||||
|
|
||||||
Session cookie: `trx_http_sid`, HttpOnly, configurable Secure and SameSite attributes.
|
Session cookie: `trx_http_sid`, HttpOnly, configurable Secure and SameSite attributes.
|
||||||
|
|
||||||
@@ -340,21 +339,21 @@ Routes are classified into three tiers:
|
|||||||
|
|
||||||
| Tier | Examples | Requirement |
|
| Tier | Examples | Requirement |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Public** | `/`, `/index.html`, `/map`, `/auth/*`, static assets | None |
|
| **Public** | `/`, `/index.html`, `/map`, login/session endpoints, static assets | None |
|
||||||
| **Read** | `/status`, `/events`, `/audio`, `/decode`, `/spectrum`, `/bookmarks` | Rx or Control role |
|
| **Read** | `/status`, `/events`, `/audio`, `/decode`, `/spectrum`, `/bookmarks` | User or Admin role |
|
||||||
| **Control** | `/set_freq`, `/set_mode`, `/set_ptt`, `/toggle_power`, all other POST | Control role only |
|
| **Control** | `/set_freq`, `/set_mode`, `/set_ptt`, `/toggle_power`, all other POST | Admin role only |
|
||||||
|
|
||||||
### 7.2 Session Management
|
### 7.2 Session Management
|
||||||
|
|
||||||
- Sessions are 128-bit random hex tokens stored in HttpOnly cookies
|
- Sessions are 128-bit random hex tokens stored in HttpOnly cookies
|
||||||
- Configurable TTL (default from TOML config)
|
- Configurable TTL (default from TOML config)
|
||||||
- Expired sessions auto-pruned on access
|
- Expired sessions auto-pruned on access
|
||||||
- Constant-time passphrase comparison to mitigate timing attacks
|
- Passwords are verified against salted Argon2id hashes
|
||||||
|
|
||||||
### 7.3 TX Access Control
|
### 7.3 User Management
|
||||||
|
|
||||||
An additional `tx_access_control_enabled` flag can restrict transmit-related actions even
|
Only administrators can list, add, update, or remove accounts. The final
|
||||||
for Control-role users, providing an extra safety layer.
|
administrator cannot be removed or demoted.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+324
-35
@@ -17,30 +17,60 @@ frontends.
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Both `trx-server` and `trx-client` use TOML configuration files. Use
|
Both `trx-server` and `trx-client` read TOML. The server takes its settings
|
||||||
`--print-config` to generate a fully commented example.
|
from the `[trx-server]` section and the client from `[trx-client]`, so one
|
||||||
|
`trx-rs.toml` can configure both — or each may live in its own file with the
|
||||||
|
section header left off.
|
||||||
|
|
||||||
|
`trx-rs.toml.example` in the repository root is a complete, commented example
|
||||||
|
generated from the config definitions themselves. `--print-config` prints the
|
||||||
|
same settings without the comments.
|
||||||
|
|
||||||
### File Locations
|
### File Locations
|
||||||
|
|
||||||
**trx-server** lookup order:
|
Both binaries use the same lookup order:
|
||||||
1. `--config <FILE>`
|
|
||||||
2. `./trx-server.toml`
|
|
||||||
3. `~/.trx-server.toml`
|
|
||||||
4. `~/.config/trx-rs/server.toml`
|
|
||||||
5. `/etc/trx-rs/server.toml`
|
|
||||||
|
|
||||||
**trx-client** lookup order:
|
|
||||||
1. `--config <FILE>`
|
1. `--config <FILE>`
|
||||||
2. `./trx-client.toml`
|
2. `./trx-rs.toml`
|
||||||
3. `~/.config/trx-rs/client.toml`
|
3. `~/.config/trx-rs/trx-rs.toml`
|
||||||
4. `/etc/trx-rs/client.toml`
|
4. `/etc/trx-rs/trx-rs.toml`
|
||||||
|
|
||||||
CLI arguments override config file values.
|
CLI arguments override config file values.
|
||||||
|
|
||||||
### Environment Variables
|
### Checking a Config
|
||||||
|
|
||||||
- `TRX_PLUGIN_DIRS`: additional plugin directories (path-separated), used by
|
`--check-config` loads the file, reports every problem it finds — unknown keys,
|
||||||
both server and client.
|
invalid values, listeners fighting over a port — and exits without starting
|
||||||
|
anything:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
trx-server --check-config --config trx-rs.toml
|
||||||
|
trx-client --check-config --config trx-rs.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
Unknown keys are warnings by default, so a config written for a newer version
|
||||||
|
still runs on an older binary. `--strict-config` makes them fatal.
|
||||||
|
|
||||||
|
`trx-configurator --check <FILE>` runs the same checks.
|
||||||
|
|
||||||
|
### Environment Variables and Secrets
|
||||||
|
|
||||||
|
Any string in the config may reference an environment variable as `${VAR}`;
|
||||||
|
an unset variable is an error rather than an empty value.
|
||||||
|
|
||||||
|
Credentials can be kept out of the config entirely by pointing at a file
|
||||||
|
instead. Every secret has a `*_file` sibling — set one or the other, never
|
||||||
|
both:
|
||||||
|
|
||||||
|
| Inline key | File key | Contents |
|
||||||
|
|------------|----------|----------|
|
||||||
|
| `[listen.auth].tokens` | `tokens_file` | one token per line |
|
||||||
|
| `[[remotes]].auth.token` | `token_file` | the token |
|
||||||
|
| `[frontends.http.auth].bootstrap_admin_password` | `bootstrap_admin_password_file` | the initial administrator password |
|
||||||
|
| `[frontends.http_json.auth].tokens` | `tokens_file` | one token per line |
|
||||||
|
|
||||||
|
Blank lines and `#` comments are ignored in the list files. A config that holds
|
||||||
|
credentials inline and is readable by group or others is flagged at startup.
|
||||||
|
|
||||||
### Server Options
|
### Server Options
|
||||||
|
|
||||||
@@ -96,6 +126,7 @@ CLI arguments override config file values.
|
|||||||
| Field | Type | Default | Description |
|
| Field | Type | Default | Description |
|
||||||
|-------|------|---------|-------------|
|
|-------|------|---------|-------------|
|
||||||
| `tokens` | string[] | `[]` | Allowed auth tokens (empty = no auth) |
|
| `tokens` | string[] | `[]` | Allowed auth tokens (empty = no auth) |
|
||||||
|
| `tokens_file` | string | — | Read tokens from this file, one per line |
|
||||||
|
|
||||||
#### `[audio]`
|
#### `[audio]`
|
||||||
|
|
||||||
@@ -121,6 +152,13 @@ When audio is enabled, at least one of `rx_enabled` or `tx_enabled` must be true
|
|||||||
| `sample_rate` | u32 | `1920000` | IQ capture rate in Hz |
|
| `sample_rate` | u32 | `1920000` | IQ capture rate in Hz |
|
||||||
| `bandwidth` | u32 | `1500000` | Hardware IF filter bandwidth in Hz |
|
| `bandwidth` | u32 | `1500000` | Hardware IF filter bandwidth in Hz |
|
||||||
| `center_offset_hz` | i64 | `100000` | Offset from dial to avoid DC spur |
|
| `center_offset_hz` | i64 | `100000` | Offset from dial to avoid DC spur |
|
||||||
|
| `spectrum_fft_size` | usize | `1024` | Spectrum FFT bins; power of two, 128–8192 |
|
||||||
|
| `spectrum_interval_ms` | u64 | `50` | How often a spectrum frame is pushed to subscribed clients |
|
||||||
|
|
||||||
|
Spectrum is the largest thing on the client connection. On a slow or
|
||||||
|
high-latency link, halving `spectrum_fft_size` halves the bytes per frame (at
|
||||||
|
half the frequency resolution) and raising `spectrum_interval_ms` sends fewer of
|
||||||
|
them; see [Spectrum over a slow link](#spectrum-over-a-slow-link).
|
||||||
|
|
||||||
#### `[sdr.gain]`
|
#### `[sdr.gain]`
|
||||||
|
|
||||||
@@ -197,6 +235,29 @@ Notes:
|
|||||||
Files are appended in JSON Lines format. Supported date tokens: `%YYYY%`,
|
Files are appended in JSON Lines format. Supported date tokens: `%YYYY%`,
|
||||||
`%MM%`, `%DD%` (UTC).
|
`%MM%`, `%DD%` (UTC).
|
||||||
|
|
||||||
|
#### `[decoders]`
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `enabled` | string[] | all decoders | Decoders to run for this rig |
|
||||||
|
| `output_dir` | string | `"$XDG_CACHE_HOME/trx-rs"` | Base directory for decoders that write images |
|
||||||
|
|
||||||
|
Valid decoder names: `aprs`, `aprs_hf`, `ais`, `cw`, `ft2`, `ft4`, `ft8`,
|
||||||
|
`lrpt`, `sstv`, `vdes`, `wefax`, `wspr` — the same names `[[sdr.channels]]`
|
||||||
|
uses. An unrecognised name is a config error.
|
||||||
|
|
||||||
|
Every decoder runs by default, which costs real CPU on a small machine. On a
|
||||||
|
station that only works digital modes, listing just what you use is worth it:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[decoders]
|
||||||
|
enabled = ["ft8", "ft4", "wspr"]
|
||||||
|
```
|
||||||
|
|
||||||
|
`sstv`, `wefax` and `lrpt` write images into a subdirectory of `output_dir`
|
||||||
|
named after the decoder. `ais` and `vdes` additionally require an SDR channel
|
||||||
|
configured to feed them.
|
||||||
|
|
||||||
#### Multi-Rig Configuration
|
#### Multi-Rig Configuration
|
||||||
|
|
||||||
Use `[[rigs]]` arrays instead of the flat `[rig]` section for multi-rig setups:
|
Use `[[rigs]]` arrays instead of the flat `[rig]` section for multi-rig setups:
|
||||||
@@ -240,12 +301,32 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
|
|||||||
|-------|------|---------|-------------|
|
|-------|------|---------|-------------|
|
||||||
| `url` | string | — | Server address (e.g. `localhost:4530`) |
|
| `url` | string | — | Server address (e.g. `localhost:4530`) |
|
||||||
| `poll_interval_ms` | u64 | `750` | State poll interval |
|
| `poll_interval_ms` | u64 | `750` | State poll interval |
|
||||||
|
| `spectrum_interval_ms` | u64 | `50` | Spectrum frame interval; also settable per `[[remotes]]` entry |
|
||||||
|
|
||||||
#### `[remote.auth]`
|
#### `[remote.auth]`
|
||||||
|
|
||||||
| Field | Type | Default | Description |
|
| Field | Type | Default | Description |
|
||||||
|-------|------|---------|-------------|
|
|-------|------|---------|-------------|
|
||||||
| `token` | string | — | Auth token (must not be empty if set) |
|
| `token` | string | — | Auth token (must not be empty if set) |
|
||||||
|
| `token_file` | string | — | Read the token from this file instead |
|
||||||
|
|
||||||
|
#### `[[remotes]]`
|
||||||
|
|
||||||
|
Preferred over the single `[remote]` section: one entry per rig, each mapping a
|
||||||
|
short name to a server and an optional server-side rig id.
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `name` | string | — | Short name used everywhere in the client |
|
||||||
|
| `url` | string | — | Server address (`host:port`) |
|
||||||
|
| `rig_id` | string | — | Rig id on a multi-rig server |
|
||||||
|
| `auth.token` | string | — | Auth token |
|
||||||
|
| `auth.token_file` | string | — | Read the token from this file instead |
|
||||||
|
| `poll_interval_ms` | u64 | `750` | State poll interval |
|
||||||
|
|
||||||
|
The `name` is the key used by `default_rig_name`, `rigctl.rig_ports`,
|
||||||
|
`audio.rig_urls`, `audio.rig_ports` and `decode_history_retention_min_by_rig`.
|
||||||
|
A name in any of those maps that no remote answers to is a config error.
|
||||||
|
|
||||||
#### `[frontends.http]`
|
#### `[frontends.http]`
|
||||||
|
|
||||||
@@ -254,6 +335,31 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
|
|||||||
| `enabled` | bool | `true` | Enable web UI |
|
| `enabled` | bool | `true` | Enable web UI |
|
||||||
| `listen` | ip | `127.0.0.1` | Bind address |
|
| `listen` | ip | `127.0.0.1` | Bind address |
|
||||||
| `port` | u16 | `8080` | Bind port |
|
| `port` | u16 | `8080` | Bind port |
|
||||||
|
| `default_rig_name` | string | — | Remote selected on startup |
|
||||||
|
| `initial_map_zoom` | u8 | `10` | Starting zoom for the APRS map |
|
||||||
|
| `show_sdr_gain_control` | bool | `true` | Expose the RF gain control |
|
||||||
|
| `bandplan_enabled` | bool | `true` | Show the bandplan strip |
|
||||||
|
| `bandplan_region` | string | `"iaru_r1"` | `iaru_r1`, `iaru_r2`, or `iaru_r3` |
|
||||||
|
| `decode_history_retention_min` | u64 | `1440` | Decode history retention |
|
||||||
|
| `decode_history_retention_min_by_rig` | table | `{}` | Per-remote retention override |
|
||||||
|
| `spectrum_coverage_margin_hz` | u32 | `50000` | Centre-retune guard margin |
|
||||||
|
| `spectrum_usable_span_ratio` | f32 | `0.92` | Usable fraction of the sampled span |
|
||||||
|
|
||||||
|
#### `[frontends.http.auth]`
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `enabled` | bool | `false` | Enable the user/password ACL |
|
||||||
|
| `users_file` | string | `"trx-http-users.json"` | Persistent managed user database |
|
||||||
|
| `bootstrap_admin_username` | string | — | First administrator, used only if the database is absent |
|
||||||
|
| `bootstrap_admin_password` | string | — | First administrator password |
|
||||||
|
| `bootstrap_admin_password_file` | string | — | Read the bootstrap password from this file instead |
|
||||||
|
| `session_ttl_min` | u64 | `480` | Session lifetime |
|
||||||
|
| `cookie_secure` | bool | `false` | Set Secure on the session cookie (needs HTTPS) |
|
||||||
|
| `cookie_same_site` | string | `"Lax"` | `Strict`, `Lax`, or `None` |
|
||||||
|
|
||||||
|
When enabling ACL for the first time, configure both bootstrap fields. After
|
||||||
|
the database exists, remove the bootstrap credentials from configuration.
|
||||||
|
|
||||||
#### `[frontends.rigctl]`
|
#### `[frontends.rigctl]`
|
||||||
|
|
||||||
@@ -261,7 +367,11 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
|
|||||||
|-------|------|---------|-------------|
|
|-------|------|---------|-------------|
|
||||||
| `enabled` | bool | `false` | Enable Hamlib rigctl |
|
| `enabled` | bool | `false` | Enable Hamlib rigctl |
|
||||||
| `listen` | ip | `127.0.0.1` | Bind address |
|
| `listen` | ip | `127.0.0.1` | Bind address |
|
||||||
| `port` | u16 | `4532` | Bind port |
|
| `rig_ports` | table | `{}` | Remote name → local port; one listener each |
|
||||||
|
|
||||||
|
One listener is started per `rig_ports` entry, each routing to its rig, so
|
||||||
|
`rig_ports` must name at least one remote when the frontend is enabled. The
|
||||||
|
older single `port` key and `--rigctl-port` are ignored.
|
||||||
|
|
||||||
#### `[frontends.http_json]`
|
#### `[frontends.http_json]`
|
||||||
|
|
||||||
@@ -271,13 +381,17 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
|
|||||||
| `listen` | ip | `127.0.0.1` | Bind address |
|
| `listen` | ip | `127.0.0.1` | Bind address |
|
||||||
| `port` | u16 | `0` | Bind port (0 = ephemeral) |
|
| `port` | u16 | `0` | Bind port (0 = ephemeral) |
|
||||||
| `auth.tokens` | string[] | `[]` | Allowed auth tokens |
|
| `auth.tokens` | string[] | `[]` | Allowed auth tokens |
|
||||||
|
| `auth.tokens_file` | string | — | Read tokens from this file, one per line |
|
||||||
|
|
||||||
#### `[frontends.audio]`
|
#### `[frontends.audio]`
|
||||||
|
|
||||||
| Field | Type | Default | Description |
|
| Field | Type | Default | Description |
|
||||||
|-------|------|---------|-------------|
|
|-------|------|---------|-------------|
|
||||||
| `enabled` | bool | `true` | Enable audio client |
|
| `enabled` | bool | `true` | Enable audio client |
|
||||||
| `server_port` | u16 | `4531` | Server audio port |
|
| `server_url` | string | — | Audio endpoint for every remote |
|
||||||
|
| `rig_urls` | table | `{}` | Remote name → audio URL (wins over `server_url`) |
|
||||||
|
| `server_port` | u16 | `4531` | Fallback port when no URL is configured |
|
||||||
|
| `rig_ports` | table | `{}` | Remote name → port; superseded by `rig_urls` |
|
||||||
| `bridge.enabled` | bool | `false` | Enable local CPAL audio bridge |
|
| `bridge.enabled` | bool | `false` | Enable local CPAL audio bridge |
|
||||||
| `bridge.rx_output_device` | string | — | Local playback device |
|
| `bridge.rx_output_device` | string | — | Local playback device |
|
||||||
| `bridge.tx_input_device` | string | — | Local capture device |
|
| `bridge.tx_input_device` | string | — | Local capture device |
|
||||||
@@ -287,68 +401,243 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
|
|||||||
The bridge is intended for WSJT-X integration via virtual audio devices (ALSA
|
The bridge is intended for WSJT-X integration via virtual audio devices (ALSA
|
||||||
loopback on Linux, BlackHole on macOS).
|
loopback on Linux, BlackHole on macOS).
|
||||||
|
|
||||||
|
### Spectrum over a slow link
|
||||||
|
|
||||||
|
Spectrum dominates the server↔client connection: everything else is a few
|
||||||
|
hundred bytes, a frame is a few kilobytes. Three things govern what it costs.
|
||||||
|
|
||||||
|
**Frames are pushed, not polled.** The client subscribes and the server sends
|
||||||
|
frames at `[sdr].spectrum_interval_ms`. Polling cost a round trip per frame, so
|
||||||
|
the rate was capped at 1/RTT — on a 200 ms link you could not exceed 5 frames a
|
||||||
|
second however often the client asked. Clients fall back to polling
|
||||||
|
automatically against a server too old to stream.
|
||||||
|
|
||||||
|
**Bins travel as whole dBFS.** They are base64-encoded `i8` on the wire, about
|
||||||
|
an eighth of the JSON array of floats they used to be, at the resolution the
|
||||||
|
display draws anyway.
|
||||||
|
|
||||||
|
**Both ends have a rate, and the slower one wins.** The server pushes no faster
|
||||||
|
than `[sdr].spectrum_interval_ms`; the client asks for no more than
|
||||||
|
`[[remotes]].spectrum_interval_ms`.
|
||||||
|
|
||||||
|
For a link that struggles, start here:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[trx-server.sdr]
|
||||||
|
spectrum_fft_size = 512 # half the bins, half the bytes
|
||||||
|
spectrum_interval_ms = 200 # 5 frames/s instead of 20
|
||||||
|
|
||||||
|
[[trx-client.remotes]]
|
||||||
|
name = "remote-site"
|
||||||
|
url = "radio.example.com:4530"
|
||||||
|
spectrum_interval_ms = 200
|
||||||
|
```
|
||||||
|
|
||||||
|
That is roughly 0.7 KB per frame at 5 frames/s — about 3.5 KB/s, against
|
||||||
|
roughly 200 KB/s for 1024 float bins at 20 frames/s.
|
||||||
|
|
||||||
### CLI Override Summary
|
### CLI Override Summary
|
||||||
|
|
||||||
**trx-server:**
|
**trx-server:**
|
||||||
`--config`, `--print-config`, `--rig`, `--access`, `--callsign`, `--listen`,
|
`--config`, `--print-config`, `--check-config`, `--strict-config`, `--rig`,
|
||||||
`--port`. SDR options are file-only.
|
`--access`, `--callsign`, `--listen`, `--port`. SDR options are file-only.
|
||||||
|
|
||||||
**trx-client:**
|
**trx-client:**
|
||||||
`--config`, `--print-config`, `--url`, `--token`, `--poll-interval`,
|
`--config`, `--print-config`, `--check-config`, `--strict-config`, `--url`,
|
||||||
`--frontend`, `--http-listen`, `--http-port`, `--rigctl-listen`,
|
`--token`, `--poll-interval`, `--rig-id`, `--frontend`, `--http-listen`,
|
||||||
`--rigctl-port`, `--http-json-listen`, `--http-json-port`, `--callsign`.
|
`--http-port`, `--rigctl-listen`, `--http-json-listen`, `--http-json-port`,
|
||||||
|
`--callsign`.
|
||||||
|
|
||||||
|
`--listen` on the server overrides the bind address of both the control
|
||||||
|
listener and every rig's audio listener.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Multiple Rigs in the Web UI
|
||||||
|
|
||||||
|
A client connected to several rigs decodes all of them at once, whichever one
|
||||||
|
is on screen. The rig picker in the header decides which rig the UI is about,
|
||||||
|
and each page answers that differently:
|
||||||
|
|
||||||
|
| Page | Shows |
|
||||||
|
|------|-------|
|
||||||
|
| Radio | The selected rig: its spectrum, its audio, and the mini decode views over the waterfall. |
|
||||||
|
| Digital Modes | The selected rig: every decoder panel, its counts and its status line. |
|
||||||
|
| Map | The whole station — every rig's positions, with the map's own rig filter to narrow it. HF APRS is a source of its own there, filtered apart from VHF APRS. |
|
||||||
|
| Statistics | The whole station, including the per-rig comparison. |
|
||||||
|
|
||||||
|
Switching rigs repaints the radio and digital modes pages for the rig now
|
||||||
|
selected. Nothing is lost by switching: the traffic other rigs heard is still
|
||||||
|
held, and switching back brings it up again. The one exception is the CW pane,
|
||||||
|
which is a single running stream of copied text rather than a list of frames,
|
||||||
|
so it starts empty on the rig you switch to.
|
||||||
|
|
||||||
|
Each browser tab keeps its own selection, so two tabs can watch two rigs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Logbook
|
||||||
|
|
||||||
|
The **Logbook** tab keeps the station's contacts and speaks ADIF, so a log can
|
||||||
|
be uploaded to LoTW, eQSL, Club Log or QRZ, or moved to another logger.
|
||||||
|
|
||||||
|
An entry opens pre-filled with six fields and no more: the frequency, mode and
|
||||||
|
name of the rig you are on, the time from the server's clock, and — when the
|
||||||
|
entry was started from a decode or a map station — that station's callsign and
|
||||||
|
locator. Signal reports, name, comment and the rest are yours to fill in; a
|
||||||
|
report in particular is never guessed, because an FT8 SNR is not what was sent.
|
||||||
|
|
||||||
|
Your own callsign and locator are not per-contact fields. They are station
|
||||||
|
identity: the callsign comes from `[trx-client.general].callsign`, the locator
|
||||||
|
from the rig's position, and both are shown once at the top of the panel. The
|
||||||
|
operator defaults to the station callsign and can be changed for the session,
|
||||||
|
which is what a multi-operator station needs.
|
||||||
|
|
||||||
|
**Times are the server's**, in UTC — the server is the machine at the radio. If
|
||||||
|
the browser's clock disagrees by more than a second the entry says so rather
|
||||||
|
than logging a time you did not expect.
|
||||||
|
|
||||||
|
**A decode is not a contact.** The decoders are receive-only, so a **Log**
|
||||||
|
button on an FT8 or APRS row opens an entry with what was heard in it and logs
|
||||||
|
nothing by itself. Digital QSOs made in WSJT-X come in through Import ADIF, the
|
||||||
|
way every other logger takes them.
|
||||||
|
|
||||||
|
| Action | What it does |
|
||||||
|
|--------|--------------|
|
||||||
|
| Log contact | Writes the entry and opens a fresh one |
|
||||||
|
| Export ADIF | Downloads the log — filtered, if a filter is set |
|
||||||
|
| Import ADIF | Reads a file, skipping contacts already held and reporting what could not be read |
|
||||||
|
| Worked before | Shows, as you type a callsign, which bands and modes it has been worked on |
|
||||||
|
|
||||||
|
Two contacts are treated as the same when the callsign, band and mode match and
|
||||||
|
the times are within two minutes: two loggers rarely stamp a QSO to the same
|
||||||
|
minute, one recording when it started and the other when it was typed.
|
||||||
|
|
||||||
|
The log is a JSON Lines file, appended one contact at a time so a crash costs at
|
||||||
|
most the contact being written. It lives in your data directory by default;
|
||||||
|
`[trx-client.logbook].path` moves it, for a station that keeps its log on a
|
||||||
|
backed-up volume.
|
||||||
|
|
||||||
|
### Contests
|
||||||
|
|
||||||
|
The **Contest exchange** block on the entry holds the contest's name and the
|
||||||
|
serials — sent and received. Both stay between contacts, because they belong to
|
||||||
|
the session rather than to the contact just logged, and the serial sent counts
|
||||||
|
on by itself so it is not retyped forty times an hour. An exchange that is a
|
||||||
|
zone, a section or a name rather than a number is kept as written.
|
||||||
|
|
||||||
|
**Contest entry (Cabrillo)** exports the entry sponsors accept. Only the
|
||||||
|
contacts of the contest named there are included. The header — operator
|
||||||
|
category, power, claimed score — cannot be worked out from a log, so it is
|
||||||
|
yours to fill in; the defaults are single operator, low power, all bands, mixed
|
||||||
|
mode.
|
||||||
|
|
||||||
|
### Confirmations
|
||||||
|
|
||||||
|
The **QSL** column shows a tick when the other station has confirmed, and the
|
||||||
|
**Confirm** button on a row records a card that has arrived. A confirmation
|
||||||
|
counts from wherever it came: a paper card, LoTW or eQSL. An award wants one of
|
||||||
|
them, not all three, so the log does not ask for all three.
|
||||||
|
|
||||||
|
**Bands worked** counts, per band, the contacts made, the distinct stations
|
||||||
|
worked, and how many of those contacts are confirmed.
|
||||||
|
|
||||||
|
### Ham radio layout
|
||||||
|
|
||||||
|
The **Ham radio** operator layout opens on the logbook with the transceiver
|
||||||
|
controls around it — the arrangement for working the bands, where logging the
|
||||||
|
contact is the task and the radio is the instrument. It is offered only where
|
||||||
|
the selected rig can transmit; a receiver has no contacts to log.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tune Links
|
||||||
|
|
||||||
|
Every page of the web UI carries what the radio is doing in its address, so the
|
||||||
|
URL in the address bar is always a link someone else can open:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://receiver.example:8080/?rig=sdr&f=14074000&mode=USB&bw=3000
|
||||||
|
```
|
||||||
|
|
||||||
|
| Parameter | Meaning |
|
||||||
|
|-----------|---------|
|
||||||
|
| `f` | Frequency. Hz by default; `7074k` and `14.074M` also work. |
|
||||||
|
| `mode` | Demodulation mode, e.g. `USB`, `CW`, `WFM`. |
|
||||||
|
| `bw` | Filter bandwidth in Hz. Ignored by rigs without filter control. |
|
||||||
|
| `rig` | Rig to select first, by id, on a multi-rig client. |
|
||||||
|
|
||||||
|
Opening such a link selects the rig, sets the mode, tunes, and applies the
|
||||||
|
bandwidth, in that order — a mode change carries its own default bandwidth, so
|
||||||
|
an explicit `bw` is applied last. Anything the rig cannot do (an unknown mode,
|
||||||
|
a frequency outside its range) is reported and the rest of the link still
|
||||||
|
applies. All four parameters are optional.
|
||||||
|
|
||||||
|
The link button in the top bar copies the current link to the clipboard. The
|
||||||
|
address bar itself is updated as you tune, using `replaceState`, so sweeping
|
||||||
|
the dial does not fill the browser's history.
|
||||||
|
|
||||||
|
Applying a link changes the radio, so it needs the `admin` role; a `user`
|
||||||
|
session opens the page and says the link was not applied. Links describe the
|
||||||
|
rig's own dial — while a tab is listening to a virtual channel the address is
|
||||||
|
left as it was, rather than publishing a frequency the rig is not on.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
The HTTP frontend supports optional passphrase-based authentication with two
|
The HTTP frontend supports an optional user/password ACL with multiple accounts and two
|
||||||
roles:
|
roles:
|
||||||
|
|
||||||
- **rx** — read-only access (monitoring, audio, decode streams)
|
- **user** — read-only access (monitoring, audio, decode streams)
|
||||||
- **control** — full access (frequency, mode, PTT, and all settings)
|
- **admin** — full radio control, settings, and user management
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[frontends.http.auth]
|
[frontends.http.auth]
|
||||||
enabled = false
|
enabled = false
|
||||||
rx_passphrase = "rx-only-passphrase"
|
users_file = "trx-http-users.json"
|
||||||
control_passphrase = "full-control-passphrase"
|
bootstrap_admin_username = "admin"
|
||||||
tx_access_control_enabled = true
|
bootstrap_admin_password = "change-this-password"
|
||||||
session_ttl_min = 480
|
session_ttl_min = 480
|
||||||
cookie_secure = false # true if served via HTTPS
|
cookie_secure = false # true if served via HTTPS
|
||||||
cookie_same_site = "Lax" # Strict|Lax|None
|
cookie_same_site = "Lax" # Strict|Lax|None
|
||||||
```
|
```
|
||||||
|
|
||||||
When `enabled = false` (the default), all auth is bypassed and the UI behaves
|
When `enabled = false` (the default), all auth is bypassed and the UI behaves
|
||||||
as before. When enabled, at least one passphrase must be set.
|
as before. When enabling it for the first time, bootstrap credentials create
|
||||||
|
the initial administrator and the Argon2id-hashed user database.
|
||||||
|
|
||||||
### Behaviour
|
### Behaviour
|
||||||
|
|
||||||
- On login, the server issues an `HttpOnly` session cookie.
|
- On login, the server issues an `HttpOnly` session cookie.
|
||||||
- Sessions are in-memory; a server restart invalidates all sessions.
|
- Sessions are in-memory; a server restart invalidates all sessions.
|
||||||
- Rate limiting is applied per IP to mitigate brute-force attempts.
|
- Rate limiting is applied per IP to mitigate brute-force attempts.
|
||||||
- When `tx_access_control_enabled = true`, TX/PTT controls are hidden and
|
- User records persist in `users_file`; passwords are stored as salted Argon2id hashes.
|
||||||
rejected for unauthenticated or `rx`-role users.
|
- `user` sessions cannot call control routes. There is no guest-access mode.
|
||||||
|
- Administrators can add/remove users and change roles/passwords in Settings.
|
||||||
|
- Removing an account or changing its password/role revokes its sessions.
|
||||||
|
|
||||||
### Routes
|
### Routes
|
||||||
|
|
||||||
| Endpoint | Method | Description |
|
| Endpoint | Method | Description |
|
||||||
|----------|--------|-------------|
|
|----------|--------|-------------|
|
||||||
| `/auth/login` | POST | Submit `{ "passphrase": "..." }` |
|
| `/auth/login` | POST | Submit `{ "username": "...", "password": "..." }` |
|
||||||
| `/auth/logout` | POST | Clear session |
|
| `/auth/logout` | POST | Clear session |
|
||||||
| `/auth/session` | GET | Check current session/role |
|
| `/auth/session` | GET | Check current session/role |
|
||||||
|
| `/auth/users` | GET/POST | List or add users (admin only) |
|
||||||
|
| `/auth/users/{username}` | PATCH/DELETE | Change password/role or remove user (admin only) |
|
||||||
|
|
||||||
Protected routes require at least `rx` role. Control routes (set frequency,
|
Protected routes require at least `user` role. Control routes (set frequency,
|
||||||
mode, PTT, etc.) require `control` role.
|
mode, PTT, etc.) require `admin` role.
|
||||||
|
|
||||||
### Frontend Flow
|
### Frontend Flow
|
||||||
|
|
||||||
1. On load, the UI calls `/auth/session`.
|
1. On load, the UI calls `/auth/session`.
|
||||||
2. If unauthenticated, a login screen is shown.
|
2. If unauthenticated, a login screen is shown.
|
||||||
3. On successful login, the normal UI loads.
|
3. On successful login, the normal UI loads.
|
||||||
4. `rx` users see a read-only interface; `control` users get full controls.
|
4. `user` accounts see a read-only interface; admins 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
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<!--
|
||||||
|
SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
|
||||||
|
SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
-->
|
||||||
|
|
||||||
|
# Frontend architecture
|
||||||
|
|
||||||
|
The HTTP frontend is a strict TypeScript application built with esbuild. Rust
|
||||||
|
embeds deterministic JavaScript output from `assets/web/generated`; Cargo does
|
||||||
|
not run Node.js or contact the network.
|
||||||
|
|
||||||
|
## Runtime graph
|
||||||
|
|
||||||
|
`frontend/src/bootstrap.ts` is the only first-party script referenced by the
|
||||||
|
HTML document. Its imports establish startup order for WebGL support, shared UI
|
||||||
|
services, decoder dispatch, the local Leaflet AIS adapter, and the application
|
||||||
|
coordinator. Leaflet and the Opus decoder remain isolated vendored scripts.
|
||||||
|
|
||||||
|
Feature entries under `frontend/src/plugins` are ESM bundles. The typed plugin
|
||||||
|
loader imports them by feature group and keeps expensive map, scheduling, and
|
||||||
|
decoder behavior lazy. Shared code is emitted as content-hashed chunks. The
|
||||||
|
decode-history worker is an independent entry compiled against Web Worker
|
||||||
|
globals.
|
||||||
|
|
||||||
|
Dependencies point from application and feature code toward `core` and `api`.
|
||||||
|
`api/generated.ts` contains Rust wire formats; `api/client.ts` and focused
|
||||||
|
parsers validate untrusted HTTP, SSE, WebSocket, and worker data before it is
|
||||||
|
used as typed application state.
|
||||||
|
|
||||||
|
## Browser host contract
|
||||||
|
|
||||||
|
Separate lazy bundles cannot share module instances with the stable application
|
||||||
|
entry, so they use three intentional host namespaces:
|
||||||
|
|
||||||
|
| Global | Purpose | Mutation policy |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `window.trx` | Application state, core services, and feature registrations | The root is frozen; lazy features may register only their documented `modules.*` service. |
|
||||||
|
| `window.trxPluginRuntime` | Typed decoder registration and message dispatch | Runtime object is installed once; plugins register lifecycle handlers through its API. |
|
||||||
|
| `window.trxUi` | Notifications, confirmations, tab accessibility, and control presentation | Installed once by `ui-core.ts`; consumers call methods but do not replace them. |
|
||||||
|
|
||||||
|
`frontend/src/plugins/host.ts` declares the typed view of `window.trx.state`
|
||||||
|
and `window.trx.core` that feature bundles consume. Feature entries import it
|
||||||
|
instead of re-deriving the contract, so a service that moves out of the
|
||||||
|
application entry is added in one place. Application state that a feature needs
|
||||||
|
belongs in `trx.state`, and shared behavior belongs in `trx.core`; a feature
|
||||||
|
that has to intercept application behavior registers a `modules.*` method the
|
||||||
|
application calls, as the virtual-channel entry does for tuning, mode,
|
||||||
|
bandwidth, and frequency display.
|
||||||
|
|
||||||
|
The WebGL adapter exposes `createTrxWebGlRenderer`, `trxParseCssColor`,
|
||||||
|
`trxHslToRgba`, and `trxClearCssColorCache` for the application bundle. Leaflet
|
||||||
|
adds `L.TrxAisTrackSymbol` and `L.trxAisTrackSymbol` to the vendored Leaflet
|
||||||
|
namespace.
|
||||||
|
|
||||||
|
The following transitional properties are explicitly part of the lazy-feature
|
||||||
|
host contract and are declared in `app.ts`: `lastSpectrumData`, `lastFreqHz`,
|
||||||
|
`currentBandwidthHz`, `ft8BaseHz`, `getDecodeHistoryRetentionMs`,
|
||||||
|
`applyDecodeHistoryRetention`, `getDecodeRigMeta`, `renderRdsOverlays`,
|
||||||
|
`buildAisVesselUrl`, `trxScheduleUiFrameJob`,
|
||||||
|
`takeSchedulerControlForDecoderDisable`, `navigateToTab`, `_syncRecorderState`,
|
||||||
|
and `refreshRdsUi`. Optional callbacks owned by lazy features are
|
||||||
|
`refreshCwTonePicker`, `updateFt8RfDisplay`, `clearSatPredictionDom`,
|
||||||
|
`syncWefaxToggle`, `updateAisBar`, `updateVdesBar`, `updateAprsBar`,
|
||||||
|
`updateFt8Bar`, `updateSatLiveState`, `applyCwAutoUi`, and
|
||||||
|
`applyCwAutoUiFromServer`.
|
||||||
|
|
||||||
|
This list is closed: new standalone mutable `window` properties are not an
|
||||||
|
accepted integration mechanism. Extend an existing typed service or introduce
|
||||||
|
an imported interface instead. Removing transitional properties as feature
|
||||||
|
boundaries become directly importable remains preferable.
|
||||||
|
|
||||||
|
## Build and verification
|
||||||
|
|
||||||
|
The generated directory is removed before every build, preventing orphaned
|
||||||
|
compatibility bundles. Stable feature entry names are allowlisted by the Rust
|
||||||
|
asset manifest; shared chunks use content hashes. The generic Rust handler
|
||||||
|
rejects unknown names and unsupported MIME types and serves embedded assets
|
||||||
|
with compression, ETags, and immutable caching.
|
||||||
|
|
||||||
|
CI installs from `package-lock.json`, caches only npm downloads, type-checks the
|
||||||
|
window and worker environments separately, lints, runs unit and DOM tests,
|
||||||
|
starts the application in Chromium, regenerates Rust contracts and bundles,
|
||||||
|
checks for drift, and runs REUSE validation after generation.
|
||||||
|
|
||||||
|
See `frontend/src/README.md` for local commands and
|
||||||
|
`docs/ts-migration-plan.md` for the migration decisions and completion gates.
|
||||||
@@ -7,7 +7,11 @@ trx-rs web frontend (`trx-frontend-http`). The frontend is a single-page
|
|||||||
application served as embedded static assets (gzip-compressed with ETag
|
application served as embedded static assets (gzip-compressed with ETag
|
||||||
caching) from the Actix-Web server.
|
caching) from the Actix-Web server.
|
||||||
|
|
||||||
## Current asset inventory
|
## Historical asset inventory
|
||||||
|
|
||||||
|
This inventory records the frontend before the TypeScript migration. The
|
||||||
|
first-party JavaScript copies listed here have since been replaced by strict
|
||||||
|
TypeScript source and deterministic output under `assets/web/generated`.
|
||||||
|
|
||||||
| File | Lines | Size |
|
| File | Lines | Size |
|
||||||
|------|------:|-----:|
|
|------|------:|-----:|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<!--
|
||||||
|
SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
|
||||||
|
SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
-->
|
||||||
|
|
||||||
|
# TypeScript migration baseline
|
||||||
|
|
||||||
|
This baseline records the browser architecture at the start of the migration.
|
||||||
|
It is intentionally historical; generated output sizes are tracked separately
|
||||||
|
by the deterministic frontend build.
|
||||||
|
|
||||||
|
## Startup order
|
||||||
|
|
||||||
|
The initial document loads these scripts in order:
|
||||||
|
|
||||||
|
1. `/vendor/opus-decoder-0.7.11.min.js`
|
||||||
|
2. `/vendor/leaflet.js`
|
||||||
|
3. `/leaflet-ais-tracksymbol.js`
|
||||||
|
4. `/webgl-renderer.js`
|
||||||
|
5. `/ui-core.js`
|
||||||
|
6. `/app.js`
|
||||||
|
|
||||||
|
Decoder and feature scripts are then loaded lazily by the inline loader in
|
||||||
|
`index.html`. The frontend smoke test locks the core ordering and rejects
|
||||||
|
remote script or stylesheet URLs.
|
||||||
|
|
||||||
|
## Source measurements
|
||||||
|
|
||||||
|
At baseline, first-party JavaScript comprised 23 files and approximately
|
||||||
|
776 KiB. The largest sources were:
|
||||||
|
|
||||||
|
| Source | Bytes |
|
||||||
|
|---|---:|
|
||||||
|
| `app.js` | 337,111 |
|
||||||
|
| `map-core.js` | 131,899 |
|
||||||
|
| `plugins/scheduler.js` | 60,883 |
|
||||||
|
| `plugins/bookmarks.js` | 30,543 |
|
||||||
|
| `plugins/sat.js` | 22,541 |
|
||||||
|
| `plugins/vchan.js` | 20,195 |
|
||||||
|
| `webgl-renderer.js` | 18,993 |
|
||||||
|
|
||||||
|
The migrated source snapshot contained 123 distinct direct `window.*`
|
||||||
|
assignments. These are compatibility callbacks, shared state, and plugin entry
|
||||||
|
points. New TypeScript code must not add to that set; the typed plugin registry
|
||||||
|
and explicit services replace it over the course of the migration.
|
||||||
|
|
||||||
|
All production scripts, stylesheets, fonts, icons, and map assets were already
|
||||||
|
served from local embedded routes. The automated startup test ensures no
|
||||||
|
remote runtime script or stylesheet dependency is introduced.
|
||||||
@@ -0,0 +1,586 @@
|
|||||||
|
<!--
|
||||||
|
SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
|
||||||
|
SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
-->
|
||||||
|
|
||||||
|
# TypeScript Migration Plan
|
||||||
|
|
||||||
|
> **Scope**: `src/trx-client/trx-frontend/trx-frontend-http/`
|
||||||
|
>
|
||||||
|
> **Status**: Complete (2026-08-01)
|
||||||
|
|
||||||
|
## Implementation result
|
||||||
|
|
||||||
|
The migration was completed on `feat/typescript-frontend-migration`. The
|
||||||
|
baseline and phased sections below are retained as the decision record; their
|
||||||
|
descriptions of JavaScript files and classic loading refer to the pre-migration
|
||||||
|
state.
|
||||||
|
|
||||||
|
Completion evidence:
|
||||||
|
|
||||||
|
- all first-party browser sources are strict `.ts` files, checked by separate
|
||||||
|
DOM and Web Worker TypeScript projects with no JavaScript compatibility mode
|
||||||
|
or suppression directives;
|
||||||
|
- `bootstrap.ts` is the single first-party HTML entry and esbuild represents
|
||||||
|
startup order, lazy feature imports, shared hashed chunks, and the worker in
|
||||||
|
its module graph;
|
||||||
|
- obsolete source and generated compatibility JavaScript was removed;
|
||||||
|
- Rust generates rig, status, capability, decoder, and flattened frontend
|
||||||
|
metadata contracts into `api/generated.ts`; runtime guards validate HTTP,
|
||||||
|
SSE, WebSocket, and worker boundaries;
|
||||||
|
- the generic embedded-asset handler serves a build-generated allowlist with
|
||||||
|
constrained MIME types, compression, ETags, immutable caching, and no file
|
||||||
|
system lookup;
|
||||||
|
- intentional browser host namespaces and transitional lazy-feature properties
|
||||||
|
are documented in `docs/frontend-architecture.md`;
|
||||||
|
- CI uses locked npm dependencies, caches npm downloads rather than
|
||||||
|
`node_modules`, runs strict type checking and linting, unit/DOM/worker tests,
|
||||||
|
Chromium startup coverage, generated-output drift checks, and REUSE after
|
||||||
|
generation;
|
||||||
|
- Cargo continues to consume committed generated assets without invoking Node
|
||||||
|
or requiring network access.
|
||||||
|
|
||||||
|
The final local gate ran `npm ci`, type checking, linting, 30 frontend tests,
|
||||||
|
the Chromium smoke flow (startup, auth gate, audio controls, rig switching, map
|
||||||
|
initialization, and navigation), generated-contract and bundle verification,
|
||||||
|
workspace formatting, Clippy with warnings denied, all-target builds, workspace
|
||||||
|
tests, and REUSE 3.3 validation.
|
||||||
|
|
||||||
|
## 1. Decision
|
||||||
|
|
||||||
|
Migrating the web frontend to TypeScript is worthwhile, provided it is used to
|
||||||
|
remove implicit contracts and global coupling. Renaming JavaScript files to
|
||||||
|
`.ts` without changing their boundaries would add tooling without delivering
|
||||||
|
the main safety benefits.
|
||||||
|
|
||||||
|
The migration must be incremental. Every intermediate commit and pull request
|
||||||
|
must leave the frontend buildable and usable.
|
||||||
|
|
||||||
|
## 2. Baseline State
|
||||||
|
|
||||||
|
The frontend currently contains roughly 21,700 lines of first-party
|
||||||
|
JavaScript. Its largest components include:
|
||||||
|
|
||||||
|
- `app.js`: approximately 8,900 lines;
|
||||||
|
- `map-core.js`: approximately 3,500 lines;
|
||||||
|
- `plugins/scheduler.js`: approximately 1,500 lines;
|
||||||
|
- `plugins/bookmarks.js`: approximately 800 lines;
|
||||||
|
- `plugins/vchan.js`: approximately 560 lines.
|
||||||
|
|
||||||
|
Important characteristics of the current architecture are:
|
||||||
|
|
||||||
|
- scripts are embedded individually into the Rust binary with `include_str!`;
|
||||||
|
- the Rust HTTP server exposes an explicit route for each asset;
|
||||||
|
- plugins are loaded dynamically as classic scripts;
|
||||||
|
- script order is significant;
|
||||||
|
- modules communicate through `window.*`, `window.trx`, callbacks, and shared
|
||||||
|
mutable state;
|
||||||
|
- the main HTML file contains the plugin loader;
|
||||||
|
- a Web Worker is loaded through a fixed URL;
|
||||||
|
- Cargo builds do not require Node.js;
|
||||||
|
- CI images contain Node.js, but CI currently runs only Rust and REUSE checks;
|
||||||
|
- Leaflet, Opus decoder, fonts, images, and other vendored assets are local.
|
||||||
|
|
||||||
|
These constraints make a big-bang rewrite unnecessarily risky.
|
||||||
|
|
||||||
|
## 3. Goals
|
||||||
|
|
||||||
|
The migration should:
|
||||||
|
|
||||||
|
1. Create checked contracts between Rust responses and browser code.
|
||||||
|
2. Replace global callbacks with explicit module interfaces.
|
||||||
|
3. Split `app.js` by responsibility.
|
||||||
|
4. Make rig, capability, decoder, scheduler, audio, and spectrum state explicit.
|
||||||
|
5. Preserve lazy loading for expensive features.
|
||||||
|
6. Add frontend type checking, linting, and automated tests to CI.
|
||||||
|
7. Keep ordinary Cargo builds independent of Node.js and network access.
|
||||||
|
8. Preserve existing browser behavior throughout the migration.
|
||||||
|
|
||||||
|
## 4. Non-Goals
|
||||||
|
|
||||||
|
The migration will not initially:
|
||||||
|
|
||||||
|
- introduce a UI framework;
|
||||||
|
- redesign the user interface;
|
||||||
|
- convert vendored JavaScript to TypeScript;
|
||||||
|
- change REST, SSE, WebSocket, or worker protocols unless required to make an
|
||||||
|
existing contract unambiguous;
|
||||||
|
- make `build.rs` install packages or download frontend dependencies;
|
||||||
|
- convert all of `app.js` in one pull request.
|
||||||
|
|
||||||
|
## 5. Target Layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
trx-frontend-http/
|
||||||
|
├── frontend/
|
||||||
|
│ ├── package.json
|
||||||
|
│ ├── package-lock.json
|
||||||
|
│ ├── tsconfig.json
|
||||||
|
│ ├── tsconfig.worker.json
|
||||||
|
│ ├── build.mjs
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── bootstrap.ts
|
||||||
|
│ │ ├── api/
|
||||||
|
│ │ │ ├── client.ts
|
||||||
|
│ │ │ └── generated.ts
|
||||||
|
│ │ ├── core/
|
||||||
|
│ │ │ ├── dom.ts
|
||||||
|
│ │ │ ├── events.ts
|
||||||
|
│ │ │ ├── settings.ts
|
||||||
|
│ │ │ └── state.ts
|
||||||
|
│ │ ├── features/
|
||||||
|
│ │ │ ├── audio/
|
||||||
|
│ │ │ ├── bookmarks/
|
||||||
|
│ │ │ ├── map/
|
||||||
|
│ │ │ ├── navigation/
|
||||||
|
│ │ │ ├── radio/
|
||||||
|
│ │ │ ├── recorder/
|
||||||
|
│ │ │ ├── scheduler/
|
||||||
|
│ │ │ └── spectrum/
|
||||||
|
│ │ ├── decoders/
|
||||||
|
│ │ ├── workers/
|
||||||
|
│ │ └── legacy/
|
||||||
|
│ │ └── global-bridge.ts
|
||||||
|
│ └── tests/
|
||||||
|
└── assets/web/
|
||||||
|
├── generated/
|
||||||
|
├── vendor/
|
||||||
|
├── index.html
|
||||||
|
├── style.css
|
||||||
|
└── themes.css
|
||||||
|
```
|
||||||
|
|
||||||
|
The exact feature directories may evolve, but dependencies should point from
|
||||||
|
features toward `core` and `api`, never from `core` back into features.
|
||||||
|
|
||||||
|
## 6. Tooling
|
||||||
|
|
||||||
|
Use TypeScript for type checking and esbuild for bundling. A framework-specific
|
||||||
|
development server is not needed.
|
||||||
|
|
||||||
|
Recommended compiler baseline:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
"useUnknownInCatchVariables": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"lib": ["ES2022", "DOM"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Worker code should use a separate configuration with `WebWorker` rather than
|
||||||
|
`DOM` globals.
|
||||||
|
|
||||||
|
The package scripts should provide at least:
|
||||||
|
|
||||||
|
```text
|
||||||
|
npm run typecheck
|
||||||
|
npm run lint
|
||||||
|
npm test
|
||||||
|
npm run build
|
||||||
|
npm run verify-generated
|
||||||
|
```
|
||||||
|
|
||||||
|
Pin dependencies in `package-lock.json`. New package and generated files must
|
||||||
|
carry or inherit valid REUSE licensing information.
|
||||||
|
|
||||||
|
## 7. Cargo and Asset Integration
|
||||||
|
|
||||||
|
Cargo must remain usable on machines without Node.js. Do not invoke `npm`
|
||||||
|
automatically from `build.rs`.
|
||||||
|
|
||||||
|
The initial integration should work as follows:
|
||||||
|
|
||||||
|
1. TypeScript and JavaScript sources live under `frontend/src`.
|
||||||
|
2. esbuild writes browser-ready output under `assets/web/generated`.
|
||||||
|
3. Generated browser output is committed to the repository.
|
||||||
|
4. Rust embeds generated output, not TypeScript source.
|
||||||
|
5. CI rebuilds the frontend and fails if committed output is stale.
|
||||||
|
|
||||||
|
During early phases, retain the existing public URLs, including:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/app.js
|
||||||
|
/ui-core.js
|
||||||
|
/map-core.js
|
||||||
|
/scheduler.js
|
||||||
|
/decode-history-worker.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Keeping these URLs stable avoids coupling the first migration phases to changes
|
||||||
|
in HTML loading, authentication rules, caching, or Rust routing.
|
||||||
|
|
||||||
|
After modules and code splitting are established, replace the explicit list of
|
||||||
|
Rust constants and handlers with an embedded generated-asset directory. The
|
||||||
|
server must still enforce an allowlist, correct MIME types, cache headers, and
|
||||||
|
path traversal protection.
|
||||||
|
|
||||||
|
## 8. Rust-to-TypeScript Contracts
|
||||||
|
|
||||||
|
Generate TypeScript definitions from Rust wire types instead of maintaining
|
||||||
|
parallel handwritten representations.
|
||||||
|
|
||||||
|
Initial candidates include:
|
||||||
|
|
||||||
|
- `RigState`, `RigInfo`, and `RigCapabilities`;
|
||||||
|
- the `/rigs` response;
|
||||||
|
- decoder registry entries;
|
||||||
|
- scheduler configuration and status;
|
||||||
|
- filter and spectrum state;
|
||||||
|
- recorder responses;
|
||||||
|
- SSE update payloads;
|
||||||
|
- decoded-message variants;
|
||||||
|
- WebSocket control and status messages.
|
||||||
|
|
||||||
|
A generator such as `ts-rs` can produce
|
||||||
|
`frontend/src/api/generated.ts`. Generated definitions should describe only
|
||||||
|
wire formats. UI state and view models should remain handwritten.
|
||||||
|
|
||||||
|
CI must regenerate these definitions and fail when the working tree changes.
|
||||||
|
Serde renames, tagged enums, optional fields, flattened values, and numeric
|
||||||
|
ranges must be checked explicitly during the initial generator integration.
|
||||||
|
|
||||||
|
TypeScript types do not validate data at runtime. Critical compatibility
|
||||||
|
boundaries should retain focused runtime checks for missing or malformed data,
|
||||||
|
particularly when clients and servers may run different versions.
|
||||||
|
|
||||||
|
## 9. Target Module Contracts
|
||||||
|
|
||||||
|
Plugins should eventually implement an explicit interface rather than assigning
|
||||||
|
callbacks to `window`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface TrxPlugin {
|
||||||
|
readonly id: string;
|
||||||
|
initialize(context: PluginContext): void | Promise<void>;
|
||||||
|
dispose?(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginContext {
|
||||||
|
api: TrxApi;
|
||||||
|
events: TrxEventBus;
|
||||||
|
navigation: NavigationService;
|
||||||
|
notifications: NotificationService;
|
||||||
|
state: ReadonlyRadioState;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
During the transition, `legacy/global-bridge.ts` may expose the minimum globals
|
||||||
|
required by unmigrated scripts. The bridge must shrink as migration progresses;
|
||||||
|
new feature code must not add new global callbacks.
|
||||||
|
|
||||||
|
State should be divided by responsibility rather than replaced by one large
|
||||||
|
typed global:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface RadioState {
|
||||||
|
activeRigId: string | null;
|
||||||
|
capabilities: RigCapabilities | null;
|
||||||
|
connection: ConnectionState;
|
||||||
|
frequencyHz: number | null;
|
||||||
|
mode: RigMode | null;
|
||||||
|
bandwidthHz: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AudioState {
|
||||||
|
rx: AudioStreamState;
|
||||||
|
tx: AudioStreamState;
|
||||||
|
volume: VolumeState;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DecoderState {
|
||||||
|
registry: DecoderDescriptor[];
|
||||||
|
status: ReadonlyMap<DecoderId, DecoderStatus>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Commands should receive a rig ID explicitly. They must not infer their target
|
||||||
|
from mutable global selection state after an asynchronous operation begins.
|
||||||
|
|
||||||
|
## 10. Migration Phases
|
||||||
|
|
||||||
|
### Phase 0: Baseline and Measurements
|
||||||
|
|
||||||
|
- Record current asset names, sizes, and load order.
|
||||||
|
- Add a browser startup smoke test.
|
||||||
|
- Record existing global symbols and plugin callbacks.
|
||||||
|
- Confirm that generated bundles do not introduce remote runtime dependencies.
|
||||||
|
|
||||||
|
**Exit criterion:** current behavior and asset loading have an automated
|
||||||
|
baseline.
|
||||||
|
|
||||||
|
### Phase 1: Tooling Scaffold
|
||||||
|
|
||||||
|
- Add the frontend package, lockfile, TypeScript configuration, and esbuild.
|
||||||
|
- Allow existing JavaScript as build input without type checking it globally.
|
||||||
|
- Produce fixed-name outputs matching current URLs.
|
||||||
|
- Add frontend commands and CI checks.
|
||||||
|
- Document local frontend development commands.
|
||||||
|
|
||||||
|
**Exit criterion:** existing JavaScript passes through the frontend build with
|
||||||
|
no runtime changes, and CI detects stale generated output.
|
||||||
|
|
||||||
|
### Phase 2: Generated API Types
|
||||||
|
|
||||||
|
- Add Rust-to-TypeScript type generation.
|
||||||
|
- Generate the first status, rig, capability, and decoder contracts.
|
||||||
|
- Introduce a typed fetch/post client and typed SSE decoding boundary.
|
||||||
|
- Keep narrow runtime guards at compatibility boundaries.
|
||||||
|
|
||||||
|
**Exit criterion:** new API consumers cannot use untyped response objects.
|
||||||
|
|
||||||
|
### Phase 3: Core Browser Services
|
||||||
|
|
||||||
|
Convert or create:
|
||||||
|
|
||||||
|
- DOM lookup helpers;
|
||||||
|
- notifications and confirmations;
|
||||||
|
- settings and per-rig preferences;
|
||||||
|
- navigation;
|
||||||
|
- the event bus;
|
||||||
|
- application state stores;
|
||||||
|
- plugin registry and loader.
|
||||||
|
|
||||||
|
Convert `ui-core.js` as the first strict TypeScript entry.
|
||||||
|
|
||||||
|
**Exit criterion:** shared UI behavior is TypeScript, tested, and does not add
|
||||||
|
new globals.
|
||||||
|
|
||||||
|
### Phase 4: Independent Leaf Modules
|
||||||
|
|
||||||
|
Convert lower-coupling code first:
|
||||||
|
|
||||||
|
1. WebGL renderer;
|
||||||
|
2. decode-history worker;
|
||||||
|
3. screenshot support;
|
||||||
|
4. FT2 and FT4;
|
||||||
|
5. WSPR;
|
||||||
|
6. CW and other decoder views.
|
||||||
|
|
||||||
|
Workers must be separate build entries.
|
||||||
|
|
||||||
|
**Exit criterion:** each converted module has typed inputs, outputs, and tests.
|
||||||
|
|
||||||
|
### Phase 5: Plugin Loading
|
||||||
|
|
||||||
|
- Move the inline loader out of `index.html`.
|
||||||
|
- Replace classic-script injection with typed dynamic imports.
|
||||||
|
- Register plugins through `TrxPlugin`.
|
||||||
|
- Preserve lazy loading by tab and feature.
|
||||||
|
- Remove the corresponding global callbacks after each plugin migrates.
|
||||||
|
|
||||||
|
**Exit criterion:** plugin dependencies and loading order are represented by the
|
||||||
|
module graph rather than implicit script order.
|
||||||
|
|
||||||
|
### Phase 6: Split the Main Application
|
||||||
|
|
||||||
|
Extract `app.js` by responsibility:
|
||||||
|
|
||||||
|
1. authentication and API transport;
|
||||||
|
2. rig enumeration and switching;
|
||||||
|
3. radio commands and capabilities;
|
||||||
|
4. audio streaming;
|
||||||
|
5. spectrum state and rendering;
|
||||||
|
6. decode history;
|
||||||
|
7. recorder;
|
||||||
|
8. keyboard shortcuts;
|
||||||
|
9. application bootstrap.
|
||||||
|
|
||||||
|
Do not split by arbitrary line ranges. Each extraction must establish an
|
||||||
|
explicit interface and remove the corresponding globals.
|
||||||
|
|
||||||
|
**Exit criterion:** the bootstrap file composes services and features but does
|
||||||
|
not contain their implementations.
|
||||||
|
|
||||||
|
### Phase 7: High-Coupling Features
|
||||||
|
|
||||||
|
Convert the remaining large features after the core contracts are stable:
|
||||||
|
|
||||||
|
- scheduler and satellite scheduler;
|
||||||
|
- bookmarks;
|
||||||
|
- virtual channels;
|
||||||
|
- map and map-backed statistics;
|
||||||
|
- remaining decoder plugins.
|
||||||
|
|
||||||
|
**Exit criterion:** no first-party classic scripts or untyped plugin callbacks
|
||||||
|
remain.
|
||||||
|
|
||||||
|
### Phase 8: Asset-Server Consolidation
|
||||||
|
|
||||||
|
- Enable code splitting and hashed chunks.
|
||||||
|
- Embed the generated asset directory in Rust.
|
||||||
|
- Serve generated assets through a generic, allowlisted handler.
|
||||||
|
- Add appropriate immutable caching for hashed output.
|
||||||
|
- Retain stable handling for HTML and version metadata.
|
||||||
|
- Remove obsolete fixed-asset constants and handlers.
|
||||||
|
|
||||||
|
**Exit criterion:** Rust no longer needs a source edit for every generated
|
||||||
|
frontend chunk.
|
||||||
|
|
||||||
|
### Phase 9: Strictness and Cleanup
|
||||||
|
|
||||||
|
- Remove `allowJs`.
|
||||||
|
- Remove the legacy global bridge.
|
||||||
|
- Enable all selected strict compiler and lint rules.
|
||||||
|
- Remove obsolete generated compatibility bundles.
|
||||||
|
- Update architecture and contributor documentation.
|
||||||
|
|
||||||
|
**Exit criterion:** all first-party frontend source is strict TypeScript and the
|
||||||
|
browser runtime exposes only intentionally documented globals.
|
||||||
|
|
||||||
|
## 11. Testing Strategy
|
||||||
|
|
||||||
|
Frontend CI should run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm ci
|
||||||
|
npm run typecheck
|
||||||
|
npm run lint
|
||||||
|
npm test
|
||||||
|
npm run build
|
||||||
|
git diff --exit-code -- assets/web/generated frontend/src/api/generated.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Testing should cover four layers:
|
||||||
|
|
||||||
|
1. **Pure unit tests** for bandwidth calculations, formatting, state changes,
|
||||||
|
capability decisions, and message routing.
|
||||||
|
2. **DOM component tests** for dialogs, tabs, workspace selection, rig
|
||||||
|
switching, and collapsible control sections.
|
||||||
|
3. **Worker tests** for decode-history pruning, batching, and message formats.
|
||||||
|
4. **Browser smoke tests** for startup, authentication gating, plugin loading,
|
||||||
|
navigation, rig switching, audio controls, and map initialization.
|
||||||
|
|
||||||
|
The existing dependency-free `ui-core` test can remain during the scaffold
|
||||||
|
phase. It should be moved to the standard TypeScript test runner once that
|
||||||
|
runner is established.
|
||||||
|
|
||||||
|
## 12. CI Changes
|
||||||
|
|
||||||
|
Add a dedicated frontend job rather than hiding frontend work inside the Cargo
|
||||||
|
jobs. The job should:
|
||||||
|
|
||||||
|
- use the Node.js version provided by the runner image;
|
||||||
|
- run with `npm ci`, never a mutable install;
|
||||||
|
- cache the npm download cache, not `node_modules`;
|
||||||
|
- type-check, lint, test, and build;
|
||||||
|
- verify generated artifacts and Rust-generated types are current;
|
||||||
|
- run REUSE validation after generated files are produced.
|
||||||
|
|
||||||
|
Rust lint and test jobs should continue to consume committed generated assets.
|
||||||
|
|
||||||
|
## 13. Pull Request Strategy
|
||||||
|
|
||||||
|
Use small, independently reversible pull requests. A recommended sequence is:
|
||||||
|
|
||||||
|
1. tooling and unchanged JavaScript build;
|
||||||
|
2. generated Rust wire types and typed API client;
|
||||||
|
3. `ui-core` conversion;
|
||||||
|
4. worker and WebGL conversion;
|
||||||
|
5. decoder plugin conversions in small groups;
|
||||||
|
6. plugin registry and dynamic imports;
|
||||||
|
7. one `app.js` responsibility per PR;
|
||||||
|
8. scheduler, bookmarks, and map conversions;
|
||||||
|
9. generic embedded asset serving;
|
||||||
|
10. removal of JavaScript compatibility mode.
|
||||||
|
|
||||||
|
Every PR should include:
|
||||||
|
|
||||||
|
- runtime behavior preserved or intentionally documented;
|
||||||
|
- frontend type checking and tests;
|
||||||
|
- strict Rust formatting and Clippy;
|
||||||
|
- generated-artifact drift verification;
|
||||||
|
- no newly introduced undocumented `window` globals;
|
||||||
|
- no remote runtime asset dependency.
|
||||||
|
|
||||||
|
## 14. Risks and Mitigations
|
||||||
|
|
||||||
|
### Superficial Conversion
|
||||||
|
|
||||||
|
**Risk:** globals receive declarations but remain coupled and mutable.
|
||||||
|
|
||||||
|
**Mitigation:** require every converted module to expose explicit inputs and
|
||||||
|
outputs and remove at least the globals it replaces.
|
||||||
|
|
||||||
|
### Cargo Becomes Dependent on Node
|
||||||
|
|
||||||
|
**Risk:** builds fail on systems without Node or network access.
|
||||||
|
|
||||||
|
**Mitigation:** commit deterministic generated output and keep npm outside
|
||||||
|
`build.rs`.
|
||||||
|
|
||||||
|
### Script-Order Regressions
|
||||||
|
|
||||||
|
**Risk:** switching to modules changes execution timing and scope.
|
||||||
|
|
||||||
|
**Mitigation:** retain current public entries initially, then replace script
|
||||||
|
loading only after the plugin registry is in place.
|
||||||
|
|
||||||
|
### Unreviewable `app.js` Rewrite
|
||||||
|
|
||||||
|
**Risk:** a large conversion is difficult to review, test, or bisect.
|
||||||
|
|
||||||
|
**Mitigation:** extract one responsibility at a time and keep bootstrap working
|
||||||
|
after every extraction.
|
||||||
|
|
||||||
|
### Rust and Browser Contracts Drift
|
||||||
|
|
||||||
|
**Risk:** TypeScript compiles against stale wire definitions.
|
||||||
|
|
||||||
|
**Mitigation:** generate types from Rust and enforce a clean working tree after
|
||||||
|
generation in CI.
|
||||||
|
|
||||||
|
### Generated Asset Noise
|
||||||
|
|
||||||
|
**Risk:** committed bundles make reviews noisy.
|
||||||
|
|
||||||
|
**Mitigation:** separate source and generated commits when useful, include
|
||||||
|
source maps, and require deterministic output.
|
||||||
|
|
||||||
|
### Bundle or Startup Regression
|
||||||
|
|
||||||
|
**Risk:** bundling loads too much code eagerly.
|
||||||
|
|
||||||
|
**Mitigation:** record the current baseline, preserve feature-level lazy loads,
|
||||||
|
and track entry/chunk sizes in CI.
|
||||||
|
|
||||||
|
## 15. Initial Proof of Concept
|
||||||
|
|
||||||
|
The first implementation PR should be deliberately limited to:
|
||||||
|
|
||||||
|
1. adding the frontend package and locked toolchain;
|
||||||
|
2. building existing JavaScript to fixed-name generated output;
|
||||||
|
3. adding frontend CI and drift checks;
|
||||||
|
4. generating initial rig/status TypeScript definitions;
|
||||||
|
5. introducing the typed API client;
|
||||||
|
6. converting `ui-core.js` to strict TypeScript;
|
||||||
|
7. preserving every existing asset URL and observable behavior.
|
||||||
|
|
||||||
|
This proof of concept is the decision gate for the remainder of the migration.
|
||||||
|
If it materially improves contract safety without making Cargo development
|
||||||
|
unwieldy, continue with the phased plan. If it does not, the repository can
|
||||||
|
retain the tooling and the converted core without committing to a full rewrite.
|
||||||
|
|
||||||
|
## 16. Completion Criteria
|
||||||
|
|
||||||
|
The migration is complete when:
|
||||||
|
|
||||||
|
- all first-party browser source is strict TypeScript;
|
||||||
|
- vendor files remain isolated and declared through narrow type adapters;
|
||||||
|
- Rust wire types generate their browser contracts;
|
||||||
|
- no feature depends on accidental script ordering;
|
||||||
|
- no undocumented mutable `window` callbacks remain;
|
||||||
|
- `app.js` has been replaced by a small typed bootstrap and feature modules;
|
||||||
|
- frontend type checking, linting, unit tests, component tests, browser smoke
|
||||||
|
tests, and generated-output checks run in CI;
|
||||||
|
- Cargo builds remain possible without Node.js or network access;
|
||||||
|
- production assets remain local, embedded, cacheable, and reproducible.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
#
|
||||||
|
# Pins the Rust toolchain for reproducible builds. Keep in sync with the
|
||||||
|
# SDK image (container/Containerfile, ARG RUST_VERSION). rustup honours this
|
||||||
|
# automatically for local builds outside the SDK container.
|
||||||
|
|
||||||
|
[toolchain]
|
||||||
|
channel = "1.97.1"
|
||||||
|
components = ["rustfmt", "clippy"]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "trx-sstv"
|
||||||
|
version.workspace = true
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
trx-core = { path = "../../trx-core" }
|
||||||
|
base64 = "0.22"
|
||||||
|
png = "0.17"
|
||||||
|
tracing = "0.1"
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
//! Encode a test card, decode it back, write both as PNGs to a directory.
|
||||||
|
//!
|
||||||
|
//! `cargo run -p trx-sstv --example round_trip_png -- /tmp/out`
|
||||||
|
|
||||||
|
use trx_sstv::encode::{encode, Frame};
|
||||||
|
use trx_sstv::mode::mode_for_vis;
|
||||||
|
use trx_sstv::{ImageCanvas, SstvConfig, SstvDecoder, SstvEvent};
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let dir = std::env::args().nth(1).unwrap_or_else(|| ".".into());
|
||||||
|
for vis in [44u8, 60, 12, 8, 95] {
|
||||||
|
let mode = mode_for_vis(vis).expect("mode in table");
|
||||||
|
let (w, h) = (usize::from(mode.width), usize::from(mode.height));
|
||||||
|
let mut rgb = vec![0u8; w * h * 3];
|
||||||
|
for y in 0..h {
|
||||||
|
for x in 0..w {
|
||||||
|
let at = (y * w + x) * 3;
|
||||||
|
let (r, g, b) = if y < h / 3 {
|
||||||
|
[
|
||||||
|
(255u8, 255u8, 255u8),
|
||||||
|
(255, 255, 0),
|
||||||
|
(0, 255, 255),
|
||||||
|
(0, 255, 0),
|
||||||
|
(255, 0, 255),
|
||||||
|
(255, 0, 0),
|
||||||
|
(0, 0, 255),
|
||||||
|
(0, 0, 0),
|
||||||
|
][x * 8 / w]
|
||||||
|
} else if y < 2 * h / 3 {
|
||||||
|
let t = (x * 255 / w) as u8;
|
||||||
|
(t, 255 - t, ((y * 255) / h) as u8)
|
||||||
|
} else {
|
||||||
|
// Diagonal stripes: a line-timing error shows up as a kink.
|
||||||
|
if ((x + y) / 16) % 2 == 0 {
|
||||||
|
(240, 240, 40)
|
||||||
|
} else {
|
||||||
|
(20, 20, 90)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
rgb[at] = r;
|
||||||
|
rgb[at + 1] = g;
|
||||||
|
rgb[at + 2] = b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let name = mode.name.replace(' ', "-");
|
||||||
|
let mut sent = ImageCanvas::new(w, h);
|
||||||
|
for y in 0..h {
|
||||||
|
sent.put_row(y, &rgb[y * w * 3..(y + 1) * w * 3]);
|
||||||
|
}
|
||||||
|
std::fs::write(
|
||||||
|
format!("{dir}/{name}-sent.png"),
|
||||||
|
sent.to_png().expect("png"),
|
||||||
|
)
|
||||||
|
.expect("write");
|
||||||
|
|
||||||
|
let frame = Frame {
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
rgb: &rgb,
|
||||||
|
};
|
||||||
|
let mut audio = encode(mode, &frame, 48_000);
|
||||||
|
audio.extend(std::iter::repeat_n(0.0, 4800));
|
||||||
|
let mut decoder = SstvDecoder::new(48_000, SstvConfig::default());
|
||||||
|
let mut got = None;
|
||||||
|
for block in audio.chunks(1024) {
|
||||||
|
for event in decoder.process_samples(block) {
|
||||||
|
if let SstvEvent::Complete(image) = event {
|
||||||
|
got = Some(image);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let image = got.expect("no image decoded");
|
||||||
|
let mut canvas = ImageCanvas::new(w, h);
|
||||||
|
for y in 0..h {
|
||||||
|
canvas.put_row(y, &image.rgb[y * w * 3..(y + 1) * w * 3]);
|
||||||
|
}
|
||||||
|
std::fs::write(
|
||||||
|
format!("{dir}/{name}-decoded.png"),
|
||||||
|
canvas.to_png().expect("png"),
|
||||||
|
)
|
||||||
|
.expect("write");
|
||||||
|
println!(
|
||||||
|
"{}: {} lines, complete={}",
|
||||||
|
mode.name, image.lines, image.complete
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
//! SSTV decoder configuration.
|
||||||
|
|
||||||
|
/// Settings for [`crate::decoder::SstvDecoder`].
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct SstvConfig {
|
||||||
|
/// VIS code of a mode to assume when no header is heard, so tuning in
|
||||||
|
/// part-way through a transmission still produces a picture. `None` means
|
||||||
|
/// wait for a header, which is the safe default: guessing wrong yields a
|
||||||
|
/// convincing image of nothing.
|
||||||
|
pub force_mode: Option<u8>,
|
||||||
|
/// Directory for saved PNGs. `None` keeps images in memory only.
|
||||||
|
pub output_dir: Option<String>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,659 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
//! The decoder: audio in, pictures out.
|
||||||
|
//!
|
||||||
|
//! Reception is a small state machine. It listens for a VIS header, and once
|
||||||
|
//! one names a mode it walks the transmission a line at a time, sampling each
|
||||||
|
//! scan at the offsets the mode's segment list gives. Every line is looked for
|
||||||
|
//! at the time the mode says it should arrive, then nudged into place by the
|
||||||
|
//! sync pulse actually found near it — a transmitter's clock and a receiver's
|
||||||
|
//! sound card never agree exactly, and over two minutes of Martin M1 an
|
||||||
|
//! uncorrected error of a few parts per million visibly shears the picture.
|
||||||
|
//!
|
||||||
|
//! Rows are emitted as they are decoded so a picture can be watched arriving,
|
||||||
|
//! which is most of the appeal of the mode.
|
||||||
|
|
||||||
|
use crate::config::SstvConfig;
|
||||||
|
use crate::demod::FreqDemod;
|
||||||
|
use crate::image::ImageCanvas;
|
||||||
|
use crate::mode::{level_from_hz, mode_for_vis, Channel, ColorModel, SstvMode};
|
||||||
|
use crate::vis::find_vis;
|
||||||
|
|
||||||
|
/// Anything below this is the sync pulse rather than picture: black is 1500 Hz
|
||||||
|
/// and sync is 1200 Hz, so the line sits between them.
|
||||||
|
const SYNC_THRESHOLD_HZ: f32 = 1350.0;
|
||||||
|
|
||||||
|
/// How much of the signal to keep while hunting for a header. A header is 940
|
||||||
|
/// ms; two seconds leaves room for one to straddle several blocks of audio.
|
||||||
|
const SEARCH_HISTORY_MS: f64 = 2000.0;
|
||||||
|
|
||||||
|
/// From the start bit to the end of the stop bit: ten 30 ms cells. A header
|
||||||
|
/// search must never retire a stretch shorter than this, or a start bit split
|
||||||
|
/// across two blocks of audio is dismissed on half a view of it.
|
||||||
|
const HEADER_SPAN_MS: f64 = 330.0;
|
||||||
|
|
||||||
|
/// How far from its predicted position a line's sync pulse is looked for.
|
||||||
|
const SYNC_SEARCH_MS: f64 = 12.0;
|
||||||
|
|
||||||
|
/// Fraction of the observed timing error applied to the next line. Damped,
|
||||||
|
/// because a sync pulse found in noise is worth less than the prediction.
|
||||||
|
const SYNC_CORRECTION: f64 = 0.45;
|
||||||
|
|
||||||
|
/// Consecutive lines with no sync pulse anywhere near the prediction before
|
||||||
|
/// the transmission is taken to have ended.
|
||||||
|
const MISSING_SYNC_LIMIT: u32 = 8;
|
||||||
|
|
||||||
|
/// What the decoder has to say.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum SstvEvent {
|
||||||
|
/// A header was decoded and reception has begun.
|
||||||
|
Started {
|
||||||
|
vis: u8,
|
||||||
|
mode: &'static str,
|
||||||
|
width: u16,
|
||||||
|
height: u16,
|
||||||
|
},
|
||||||
|
/// One image row is ready, as RGB triples.
|
||||||
|
Row { line: u16, rgb: Vec<u8> },
|
||||||
|
/// Reception finished — at the bottom of the frame, or because the signal
|
||||||
|
/// went away. Carries the picture either way.
|
||||||
|
Complete(SstvImage),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A received picture.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SstvImage {
|
||||||
|
pub vis: u8,
|
||||||
|
pub mode: &'static str,
|
||||||
|
pub width: u16,
|
||||||
|
pub height: u16,
|
||||||
|
/// Rows actually received, which is the height only if it ran to the end.
|
||||||
|
pub lines: u16,
|
||||||
|
/// Whether the whole frame arrived.
|
||||||
|
pub complete: bool,
|
||||||
|
/// RGB triples, `width * height * 3` bytes. Rows never received are grey.
|
||||||
|
pub rgb: Vec<u8>,
|
||||||
|
/// When reception started, in milliseconds since the epoch.
|
||||||
|
pub started_ms: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SstvImage {
|
||||||
|
/// The picture as a canvas, for saving or encoding.
|
||||||
|
pub fn canvas(&self) -> ImageCanvas {
|
||||||
|
let width = usize::from(self.width);
|
||||||
|
let mut canvas = ImageCanvas::new(width, usize::from(self.height));
|
||||||
|
for (y, row) in self.rgb.chunks(width * 3).enumerate() {
|
||||||
|
canvas.put_row(y, row);
|
||||||
|
}
|
||||||
|
canvas
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The picture as PNG bytes.
|
||||||
|
pub fn to_png(&self) -> Result<Vec<u8>, String> {
|
||||||
|
self.canvas().to_png()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The picture as a base64 PNG, for the journey to a client.
|
||||||
|
pub fn to_png_base64(&self) -> Result<String, String> {
|
||||||
|
self.canvas().to_png_base64()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the picture into `dir`, named for when and where it arrived.
|
||||||
|
pub fn save_png(
|
||||||
|
&self,
|
||||||
|
dir: &std::path::Path,
|
||||||
|
freq_hz: u64,
|
||||||
|
) -> Result<std::path::PathBuf, String> {
|
||||||
|
self.canvas()
|
||||||
|
.save_png(dir, freq_hz, self.mode, &stamp(self.started_ms))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `YYYYMMDDTHHMMSSZ` for a millisecond timestamp, for file names that sort.
|
||||||
|
fn stamp(ms: i64) -> String {
|
||||||
|
let secs = ms.div_euclid(1000);
|
||||||
|
let (days, rest) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
|
||||||
|
let (year, month, day) = civil_from_days(days);
|
||||||
|
let (hour, minute, second) = (rest / 3600, (rest % 3600) / 60, rest % 60);
|
||||||
|
format!("{year:04}{month:02}{day:02}T{hour:02}{minute:02}{second:02}Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Days since the Unix epoch to a calendar date (Howard Hinnant's algorithm).
|
||||||
|
fn civil_from_days(days: i64) -> (i64, u32, u32) {
|
||||||
|
let z = days + 719_468;
|
||||||
|
let era = z.div_euclid(146_097);
|
||||||
|
let doe = z.rem_euclid(146_097);
|
||||||
|
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||||
|
let y = yoe + era * 400;
|
||||||
|
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||||
|
let mp = (5 * doy + 2) / 153;
|
||||||
|
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||||
|
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
||||||
|
(if m <= 2 { y + 1 } else { y }, m, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum State {
|
||||||
|
/// Listening for a header.
|
||||||
|
Searching,
|
||||||
|
Receiving(Box<Reception>),
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Reception {
|
||||||
|
mode: &'static SstvMode,
|
||||||
|
canvas: ImageCanvas,
|
||||||
|
/// Absolute sample index at which the next transmitted line begins.
|
||||||
|
next_line: f64,
|
||||||
|
/// Next image row to write.
|
||||||
|
row: u16,
|
||||||
|
started_ms: i64,
|
||||||
|
missing_syncs: u32,
|
||||||
|
/// Set until the first line has been decoded. A VIS header ends exactly
|
||||||
|
/// where the picture begins, so the first line is already aligned — and a
|
||||||
|
/// search would find the header's own 30 ms stop bit, which is at the sync
|
||||||
|
/// frequency and sits immediately before the picture.
|
||||||
|
first_line: bool,
|
||||||
|
/// Robot 36 sends one chroma channel per line and expects the decoder to
|
||||||
|
/// carry the other over from the line before.
|
||||||
|
last_chroma_r: Option<Vec<u8>>,
|
||||||
|
last_chroma_b: Option<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SstvDecoder {
|
||||||
|
sample_rate: u32,
|
||||||
|
config: SstvConfig,
|
||||||
|
demod: FreqDemod,
|
||||||
|
/// Instantaneous frequency, one entry per audio sample. Delayed by the
|
||||||
|
/// demodulator's group delay, which is constant and so shifts the whole
|
||||||
|
/// stream — header and lines alike — without disturbing their spacing.
|
||||||
|
freqs: Vec<f32>,
|
||||||
|
/// Absolute index of `freqs[0]`, so positions survive the buffer being
|
||||||
|
/// trimmed.
|
||||||
|
base: u64,
|
||||||
|
/// Absolute index the header search has already covered.
|
||||||
|
searched_to: u64,
|
||||||
|
state: State,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SstvDecoder {
|
||||||
|
pub fn new(sample_rate: u32, config: SstvConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
sample_rate,
|
||||||
|
config,
|
||||||
|
demod: FreqDemod::new(sample_rate),
|
||||||
|
freqs: Vec::new(),
|
||||||
|
base: 0,
|
||||||
|
searched_to: 0,
|
||||||
|
state: State::Searching,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a picture is currently arriving.
|
||||||
|
pub fn is_receiving(&self) -> bool {
|
||||||
|
matches!(self.state, State::Receiving(_))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed a block of mono audio. Returns whatever it produced.
|
||||||
|
pub fn process_samples(&mut self, samples: &[f32]) -> Vec<SstvEvent> {
|
||||||
|
self.demod.process_into(samples, &mut self.freqs);
|
||||||
|
let mut events = Vec::new();
|
||||||
|
loop {
|
||||||
|
let progressed = match self.state {
|
||||||
|
State::Searching => self.try_start(&mut events),
|
||||||
|
State::Receiving(_) => self.try_line(&mut events),
|
||||||
|
};
|
||||||
|
if !progressed {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.trim();
|
||||||
|
events
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Abandon a reception in progress, returning the picture so far.
|
||||||
|
pub fn reset(&mut self) -> Vec<SstvEvent> {
|
||||||
|
let mut events = Vec::new();
|
||||||
|
if let State::Receiving(reception) = std::mem::replace(&mut self.state, State::Searching) {
|
||||||
|
events.push(SstvEvent::Complete(finish(&reception)));
|
||||||
|
}
|
||||||
|
self.demod.reset();
|
||||||
|
self.freqs.clear();
|
||||||
|
self.base = 0;
|
||||||
|
self.searched_to = 0;
|
||||||
|
events
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look for a header, or for a bare sync pulse when the mode is forced.
|
||||||
|
fn try_start(&mut self, events: &mut Vec<SstvEvent>) -> bool {
|
||||||
|
let from = self.searched_to.saturating_sub(self.base) as usize;
|
||||||
|
if from >= self.freqs.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(hit) = find_vis(&self.freqs, self.sample_rate, from) {
|
||||||
|
if let Some(mode) = mode_for_vis(hit.code) {
|
||||||
|
self.begin(mode, self.base + hit.image_start as u64, events);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// A header that parses but names a mode this decoder does not know
|
||||||
|
// is still a header: skip past it rather than finding it again.
|
||||||
|
tracing::debug!(vis = hit.code, "SSTV: unsupported mode");
|
||||||
|
self.searched_to = self.base + hit.image_start as u64;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tuning in mid-transmission means no header to find. With a mode
|
||||||
|
// named in the configuration, the first sync pulse is enough to start.
|
||||||
|
if let Some(mode) = self.config.force_mode.and_then(mode_for_vis) {
|
||||||
|
if let Some(sync) = self.find_sync(from, self.freqs.len(), mode) {
|
||||||
|
let line_start = (self.base + sync as u64) as f64
|
||||||
|
- ms_to_samples(mode.sync_offset_ms, self.sample_rate);
|
||||||
|
self.begin_at(mode, line_start.max(0.0), events);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing yet. Rewind the cursor by a header's worth of samples before
|
||||||
|
// marking the buffer searched: audio arrives in blocks of a few
|
||||||
|
// milliseconds, so the search regularly runs over a start bit that is
|
||||||
|
// only half here. Advancing past it would retire the header for good
|
||||||
|
// on the strength of a partial view of it.
|
||||||
|
let unsearchable = ms_to_samples(HEADER_SPAN_MS, self.sample_rate) as u64;
|
||||||
|
let end = self.base + self.freqs.len() as u64;
|
||||||
|
self.searched_to = self.searched_to.max(end.saturating_sub(unsearchable));
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn begin(&mut self, mode: &'static SstvMode, image_start: u64, events: &mut Vec<SstvEvent>) {
|
||||||
|
self.begin_at(mode, image_start as f64, events);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn begin_at(&mut self, mode: &'static SstvMode, line_start: f64, events: &mut Vec<SstvEvent>) {
|
||||||
|
events.push(SstvEvent::Started {
|
||||||
|
vis: mode.vis,
|
||||||
|
mode: mode.name,
|
||||||
|
width: mode.width,
|
||||||
|
height: mode.height,
|
||||||
|
});
|
||||||
|
self.state = State::Receiving(Box::new(Reception {
|
||||||
|
mode,
|
||||||
|
canvas: ImageCanvas::new(usize::from(mode.width), usize::from(mode.height)),
|
||||||
|
next_line: line_start,
|
||||||
|
row: 0,
|
||||||
|
started_ms: now_ms(),
|
||||||
|
missing_syncs: 0,
|
||||||
|
first_line: true,
|
||||||
|
last_chroma_r: None,
|
||||||
|
last_chroma_b: None,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode one transmitted line, if all of it has arrived.
|
||||||
|
fn try_line(&mut self, events: &mut Vec<SstvEvent>) -> bool {
|
||||||
|
let State::Receiving(reception) = &mut self.state else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let mode = reception.mode;
|
||||||
|
let line_samples = ms_to_samples(mode.line_ms, self.sample_rate);
|
||||||
|
let margin = ms_to_samples(SYNC_SEARCH_MS, self.sample_rate);
|
||||||
|
|
||||||
|
let start = reception.next_line;
|
||||||
|
// Enough for the line's own scans, and for the sync search to reach as
|
||||||
|
// far ahead as it looks — no further. Demanding the search margin past
|
||||||
|
// the end of every line would cost the last line of every picture,
|
||||||
|
// which is exactly where the transmission stops.
|
||||||
|
let sync_reach = start + ms_to_samples(mode.sync_offset_ms, self.sample_rate) + margin;
|
||||||
|
let end = (start + line_samples).max(sync_reach);
|
||||||
|
let available = (self.base + self.freqs.len() as u64) as f64;
|
||||||
|
if end > available {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// The line may begin before what is still buffered if the caller fed a
|
||||||
|
// huge block; nothing can be done about that but skip forward.
|
||||||
|
if start < self.base as f64 {
|
||||||
|
reception.next_line = self.base as f64;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Line up on the sync pulse near where this line is predicted to be.
|
||||||
|
let expected_sync = start + ms_to_samples(mode.sync_offset_ms, self.sample_rate);
|
||||||
|
let from = (expected_sync - margin).max(self.base as f64) as u64;
|
||||||
|
let to = (expected_sync + margin) as u64;
|
||||||
|
let searching = !matches!(&self.state, State::Receiving(r) if r.first_line);
|
||||||
|
let found = if searching {
|
||||||
|
self.find_sync_between(from, to, mode)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let State::Receiving(reception) = &mut self.state else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let start = match found {
|
||||||
|
Some(sync_at) => {
|
||||||
|
reception.missing_syncs = 0;
|
||||||
|
let error = sync_at as f64 - expected_sync;
|
||||||
|
reception.next_line += error * SYNC_CORRECTION;
|
||||||
|
reception.next_line
|
||||||
|
}
|
||||||
|
None if reception.first_line => start,
|
||||||
|
None => {
|
||||||
|
reception.missing_syncs += 1;
|
||||||
|
start
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if reception.missing_syncs >= MISSING_SYNC_LIMIT {
|
||||||
|
let image = finish(reception);
|
||||||
|
self.state = State::Searching;
|
||||||
|
self.searched_to = self.base + self.freqs.len() as u64;
|
||||||
|
events.push(SstvEvent::Complete(image));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sample every scan of the line, then colour the rows.
|
||||||
|
let mut scans: Vec<(Channel, Vec<u8>)> = Vec::new();
|
||||||
|
for (channel, offset_ms, ms) in mode.scans() {
|
||||||
|
let pixels = mode.scan_pixels(channel);
|
||||||
|
let at = start + ms_to_samples(offset_ms, self.sample_rate);
|
||||||
|
let values = sample_scan(&self.freqs, self.base, at, ms, pixels, self.sample_rate);
|
||||||
|
scans.push((channel, values));
|
||||||
|
}
|
||||||
|
|
||||||
|
let State::Receiving(reception) = &mut self.state else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let rows = compose_rows(reception, &scans);
|
||||||
|
for (offset, row) in rows.into_iter().enumerate() {
|
||||||
|
let line = reception.row + offset as u16;
|
||||||
|
reception.canvas.put_row(usize::from(line), &row);
|
||||||
|
events.push(SstvEvent::Row { line, rgb: row });
|
||||||
|
}
|
||||||
|
reception.first_line = false;
|
||||||
|
reception.row += mode.lines_per_transmission;
|
||||||
|
reception.next_line += line_samples;
|
||||||
|
|
||||||
|
if reception.row >= mode.height {
|
||||||
|
let image = finish(reception);
|
||||||
|
self.state = State::Searching;
|
||||||
|
self.searched_to = self.base + self.freqs.len() as u64;
|
||||||
|
events.push(SstvEvent::Complete(image));
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First sync pulse of about the right length in `freqs[from..to]`,
|
||||||
|
/// as an index of its leading edge.
|
||||||
|
///
|
||||||
|
/// Works on a smoothed copy of the window: a sync pulse is 1200 Hz, where
|
||||||
|
/// the raw per-sample estimate swings by ±95 Hz, so single samples cross
|
||||||
|
/// and re-cross the threshold throughout a pulse and no run is ever long
|
||||||
|
/// enough. Pixels are sampled from the raw signal, where averaging over
|
||||||
|
/// the pixel does the same job without blurring across its edges.
|
||||||
|
fn find_sync(&self, from: usize, to: usize, mode: &SstvMode) -> Option<usize> {
|
||||||
|
let want = sync_ms(mode);
|
||||||
|
let min_run = (ms_to_samples(want, self.sample_rate) * 0.6) as usize;
|
||||||
|
// A sync pulse ends. Silence and a dead carrier demodulate to near
|
||||||
|
// zero, which is below the threshold too, and without an upper bound a
|
||||||
|
// decoder left running on an empty channel finds sync everywhere and
|
||||||
|
// fills the picture with noise it invented.
|
||||||
|
let max_run = (ms_to_samples(want, self.sample_rate) * 3.0) as usize;
|
||||||
|
let to = to.min(self.freqs.len());
|
||||||
|
if from >= to {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Reach past the end of the search window by a whole pulse: a sync
|
||||||
|
// starting at the last moment the window allows still has to be
|
||||||
|
// measurable to its full length, or it is rejected for being short and
|
||||||
|
// the line it belongs to goes unaligned.
|
||||||
|
let pad = ms_to_samples(want + 2.0, self.sample_rate) as usize;
|
||||||
|
let window_from = from.saturating_sub(pad);
|
||||||
|
let window_to = (to + pad).min(self.freqs.len());
|
||||||
|
let smoothed = crate::demod::smooth(
|
||||||
|
&self.freqs[window_from..window_to],
|
||||||
|
ms_to_samples(1.0, self.sample_rate) as usize,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut i = from - window_from;
|
||||||
|
let scan_to = to - window_from;
|
||||||
|
while i < scan_to {
|
||||||
|
if smoothed[i] >= SYNC_THRESHOLD_HZ {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut run = 0;
|
||||||
|
while i + run < smoothed.len() && smoothed[i + run] < SYNC_THRESHOLD_HZ {
|
||||||
|
run += 1;
|
||||||
|
}
|
||||||
|
if run >= min_run && run <= max_run {
|
||||||
|
return Some(window_from + i);
|
||||||
|
}
|
||||||
|
i += run.max(1);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// As [`Self::find_sync`], over an absolute index range.
|
||||||
|
fn find_sync_between(&self, from: u64, to: u64, mode: &SstvMode) -> Option<u64> {
|
||||||
|
let from = from.saturating_sub(self.base) as usize;
|
||||||
|
let to = to.saturating_sub(self.base) as usize;
|
||||||
|
if from >= self.freqs.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.find_sync(from, to, mode)
|
||||||
|
.map(|at| self.base + at as u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop what is behind the decoder, so a long reception does not grow the
|
||||||
|
/// buffer without bound.
|
||||||
|
fn trim(&mut self) {
|
||||||
|
let keep_from = match &self.state {
|
||||||
|
State::Searching => {
|
||||||
|
let history = ms_to_samples(SEARCH_HISTORY_MS, self.sample_rate) as u64;
|
||||||
|
(self.base + self.freqs.len() as u64).saturating_sub(history)
|
||||||
|
}
|
||||||
|
State::Receiving(reception) => {
|
||||||
|
let margin = ms_to_samples(SYNC_SEARCH_MS * 2.0, self.sample_rate) as u64;
|
||||||
|
(reception.next_line as u64).saturating_sub(margin)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if keep_from <= self.base {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let drop = (keep_from - self.base) as usize;
|
||||||
|
if drop >= self.freqs.len() {
|
||||||
|
self.freqs.clear();
|
||||||
|
} else {
|
||||||
|
self.freqs.drain(..drop);
|
||||||
|
}
|
||||||
|
self.base = keep_from;
|
||||||
|
self.searched_to = self.searched_to.max(self.base);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sync pulse length of a mode, read out of its own segment list.
|
||||||
|
fn sync_ms(mode: &SstvMode) -> f64 {
|
||||||
|
mode.segments
|
||||||
|
.iter()
|
||||||
|
.find_map(|segment| match segment {
|
||||||
|
crate::mode::Segment::Sync(ms) => Some(*ms),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.unwrap_or(9.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Milliseconds since the epoch, for stamping a picture with when it arrived.
|
||||||
|
fn now_ms() -> i64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis() as i64
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ms_to_samples(ms: f64, sample_rate: u32) -> f64 {
|
||||||
|
ms / 1000.0 * f64::from(sample_rate)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Average the frequency across each pixel's window and turn it into a level.
|
||||||
|
///
|
||||||
|
/// The middle 60% of the window is used: a pixel's edges carry the
|
||||||
|
/// demodulator's transition from the pixel before, and including them smears
|
||||||
|
/// every edge in the picture.
|
||||||
|
fn sample_scan(
|
||||||
|
freqs: &[f32],
|
||||||
|
base: u64,
|
||||||
|
start: f64,
|
||||||
|
ms: f64,
|
||||||
|
pixels: usize,
|
||||||
|
sample_rate: u32,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let mut out = Vec::with_capacity(pixels);
|
||||||
|
let width = ms_to_samples(ms, sample_rate) / pixels as f64;
|
||||||
|
for x in 0..pixels {
|
||||||
|
let pixel_start = start + width * x as f64;
|
||||||
|
let from = (pixel_start + width * 0.2 - base as f64).max(0.0) as usize;
|
||||||
|
let to = ((pixel_start + width * 0.8 - base as f64).max(0.0) as usize).min(freqs.len());
|
||||||
|
// A pixel narrower than a sample still has to produce one.
|
||||||
|
let (from, to) = if to > from {
|
||||||
|
(from, to)
|
||||||
|
} else {
|
||||||
|
let at = (pixel_start - base as f64).max(0.0) as usize;
|
||||||
|
(
|
||||||
|
at.min(freqs.len().saturating_sub(1)),
|
||||||
|
(at + 1).min(freqs.len()),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if to <= from {
|
||||||
|
out.push(0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let window = &freqs[from..to];
|
||||||
|
let mean = window.iter().sum::<f32>() / window.len() as f32;
|
||||||
|
out.push(level_from_hz(mean));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn one line's scans into image rows.
|
||||||
|
fn compose_rows(reception: &mut Reception, scans: &[(Channel, Vec<u8>)]) -> Vec<Vec<u8>> {
|
||||||
|
let mode = reception.mode;
|
||||||
|
let width = usize::from(mode.width);
|
||||||
|
let find = |channel: Channel| scans.iter().find(|(c, _)| *c == channel).map(|(_, v)| v);
|
||||||
|
|
||||||
|
match mode.color {
|
||||||
|
ColorModel::Rgb => {
|
||||||
|
let red = find(Channel::Red);
|
||||||
|
let green = find(Channel::Green);
|
||||||
|
let blue = find(Channel::Blue);
|
||||||
|
let mut row = vec![0u8; width * 3];
|
||||||
|
for x in 0..width {
|
||||||
|
row[x * 3] = red.and_then(|c| c.get(x).copied()).unwrap_or(0);
|
||||||
|
row[x * 3 + 1] = green.and_then(|c| c.get(x).copied()).unwrap_or(0);
|
||||||
|
row[x * 3 + 2] = blue.and_then(|c| c.get(x).copied()).unwrap_or(0);
|
||||||
|
}
|
||||||
|
vec![row]
|
||||||
|
}
|
||||||
|
ColorModel::YCrCb => {
|
||||||
|
let luma = find(Channel::LumaOdd).cloned().unwrap_or_default();
|
||||||
|
let cr = find(Channel::ChromaR).cloned().unwrap_or_default();
|
||||||
|
let cb = find(Channel::ChromaB).cloned().unwrap_or_default();
|
||||||
|
vec![ycrcb_row(&luma, &cr, &cb, width)]
|
||||||
|
}
|
||||||
|
ColorModel::YCrCbAlternating => {
|
||||||
|
// This line carries one chroma channel; the other is the one from
|
||||||
|
// the line before, which is what the mode expects a decoder to do.
|
||||||
|
let luma = find(Channel::LumaOdd).cloned().unwrap_or_default();
|
||||||
|
let chroma = find(Channel::ChromaAlternating)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
if reception.row.is_multiple_of(2) {
|
||||||
|
reception.last_chroma_r = Some(chroma);
|
||||||
|
} else {
|
||||||
|
reception.last_chroma_b = Some(chroma);
|
||||||
|
}
|
||||||
|
let neutral = vec![128u8; luma.len().max(1) / 2];
|
||||||
|
let cr = reception
|
||||||
|
.last_chroma_r
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| neutral.clone());
|
||||||
|
let cb = reception.last_chroma_b.clone().unwrap_or(neutral);
|
||||||
|
vec![ycrcb_row(&luma, &cr, &cb, width)]
|
||||||
|
}
|
||||||
|
ColorModel::YCrCbPaired => {
|
||||||
|
let odd = find(Channel::LumaOdd).cloned().unwrap_or_default();
|
||||||
|
let even = find(Channel::LumaEven).cloned().unwrap_or_default();
|
||||||
|
let cr = find(Channel::ChromaR).cloned().unwrap_or_default();
|
||||||
|
let cb = find(Channel::ChromaB).cloned().unwrap_or_default();
|
||||||
|
vec![
|
||||||
|
ycrcb_row(&odd, &cr, &cb, width),
|
||||||
|
ycrcb_row(&even, &cr, &cb, width),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One RGB row from luminance and chrominance, stretching the chroma scans
|
||||||
|
/// across the width when they are narrower than it.
|
||||||
|
fn ycrcb_row(luma: &[u8], cr: &[u8], cb: &[u8], width: usize) -> Vec<u8> {
|
||||||
|
let mut row = vec![0u8; width * 3];
|
||||||
|
let pick = |channel: &[u8], x: usize| -> f32 {
|
||||||
|
if channel.is_empty() {
|
||||||
|
return 128.0;
|
||||||
|
}
|
||||||
|
let at = x * channel.len() / width.max(1);
|
||||||
|
f32::from(channel[at.min(channel.len() - 1)])
|
||||||
|
};
|
||||||
|
for x in 0..width {
|
||||||
|
let y = if luma.is_empty() {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
let at = x * luma.len() / width.max(1);
|
||||||
|
f32::from(luma[at.min(luma.len() - 1)])
|
||||||
|
};
|
||||||
|
let (r, g, b) = ycrcb_to_rgb(y, pick(cr, x), pick(cb, x));
|
||||||
|
row[x * 3] = r;
|
||||||
|
row[x * 3 + 1] = g;
|
||||||
|
row[x * 3 + 2] = b;
|
||||||
|
}
|
||||||
|
row
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The inverse of the studio-swing conversion SSTV encoders use.
|
||||||
|
fn ycrcb_to_rgb(y: f32, cr: f32, cb: f32) -> (u8, u8, u8) {
|
||||||
|
let r = 298.082 * y / 256.0 + 408.583 * cr / 256.0 - 222.921;
|
||||||
|
let g = 298.082 * y / 256.0 - 100.291 * cb / 256.0 - 208.120 * cr / 256.0 + 135.576;
|
||||||
|
let b = 298.082 * y / 256.0 + 516.412 * cb / 256.0 - 276.836;
|
||||||
|
(
|
||||||
|
r.clamp(0.0, 255.0) as u8,
|
||||||
|
g.clamp(0.0, 255.0) as u8,
|
||||||
|
b.clamp(0.0, 255.0) as u8,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish(reception: &Reception) -> SstvImage {
|
||||||
|
SstvImage {
|
||||||
|
vis: reception.mode.vis,
|
||||||
|
mode: reception.mode.name,
|
||||||
|
width: reception.mode.width,
|
||||||
|
height: reception.mode.height,
|
||||||
|
lines: reception.canvas.filled_rows() as u16,
|
||||||
|
complete: reception.row >= reception.mode.height,
|
||||||
|
rgb: reception.canvas.rgb().to_vec(),
|
||||||
|
started_ms: reception.started_ms,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn file_stamps_are_utc_and_sort_in_time_order() {
|
||||||
|
// Known instants, checked against `date -u -r <secs>`.
|
||||||
|
assert_eq!(stamp(0), "19700101T000000Z");
|
||||||
|
assert_eq!(stamp(1_000_000_000_000), "20010909T014640Z");
|
||||||
|
assert_eq!(stamp(1_770_000_000_000), "20260202T024000Z");
|
||||||
|
// Sorting the names sorts the pictures.
|
||||||
|
assert!(stamp(1_770_000_000_000) < stamp(1_770_000_001_000));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
//! Instantaneous frequency estimation.
|
||||||
|
//!
|
||||||
|
//! SSTV carries every pixel as a frequency between 1500 and 2300 Hz, and every
|
||||||
|
//! line boundary as a 1200 Hz pulse, so one measurement serves the whole
|
||||||
|
//! decoder: the frequency of the signal at each sample. A Hilbert transform
|
||||||
|
//! FIR forms the analytic signal and the phase difference between consecutive
|
||||||
|
//! samples gives the frequency.
|
||||||
|
//!
|
||||||
|
//! The same approach drives the WEFAX decoder, which maps the result straight
|
||||||
|
//! to luminance. Here the frequency itself is the output, because the VIS
|
||||||
|
//! header and the sync detector read tones far outside the pixel band.
|
||||||
|
//!
|
||||||
|
//! Block-based linear processing, per `docs/Optimization-Guidelines.md`: the
|
||||||
|
//! FIR runs over a contiguous `[tail | samples]` buffer so the inner loop is
|
||||||
|
//! straight indexing the compiler can vectorise.
|
||||||
|
|
||||||
|
use std::f32::consts::PI;
|
||||||
|
|
||||||
|
/// Taps for the Hilbert transform FIR. Odd, so the delay is a whole sample.
|
||||||
|
const HILBERT_TAPS: usize = 65;
|
||||||
|
|
||||||
|
/// Group delay of the FIR, in samples.
|
||||||
|
const HILBERT_DELAY: usize = HILBERT_TAPS / 2;
|
||||||
|
|
||||||
|
/// Taps for the input band-pass. Long enough to be worth having, short enough
|
||||||
|
/// that its delay is a couple of milliseconds.
|
||||||
|
const BANDPASS_TAPS: usize = 127;
|
||||||
|
|
||||||
|
/// The band SSTV lives in: sync at 1200 Hz, black at 1500, white at 2300.
|
||||||
|
const BAND_LOW_HZ: f32 = 900.0;
|
||||||
|
const BAND_HIGH_HZ: f32 = 2700.0;
|
||||||
|
|
||||||
|
/// Produces instantaneous frequency in Hz from real audio samples.
|
||||||
|
pub struct FreqDemod {
|
||||||
|
/// Band-pass, applied first. A phase-difference frequency detector answers
|
||||||
|
/// whatever is loudest, so hiss outside the SSTV band steers the estimate
|
||||||
|
/// even when the signal is much stronger — the picture tears rather than
|
||||||
|
/// grows grainy. Every real decoder filters to the band first.
|
||||||
|
bandpass: Vec<f32>,
|
||||||
|
bandpass_tail: Vec<f32>,
|
||||||
|
coeffs: [f32; HILBERT_TAPS],
|
||||||
|
/// The last `HILBERT_TAPS - 1` input samples, priming the next block.
|
||||||
|
tail: Vec<f32>,
|
||||||
|
prev_i: f32,
|
||||||
|
prev_q: f32,
|
||||||
|
/// `sample_rate / 2π`, the constant turning phase step into Hz.
|
||||||
|
hz_per_radian: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FreqDemod {
|
||||||
|
pub fn new(sample_rate: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
bandpass: design_bandpass_fir(sample_rate),
|
||||||
|
bandpass_tail: vec![0.0; BANDPASS_TAPS - 1],
|
||||||
|
coeffs: design_hilbert_fir(),
|
||||||
|
tail: vec![0.0; HILBERT_TAPS - 1],
|
||||||
|
prev_i: 0.0,
|
||||||
|
prev_q: 0.0,
|
||||||
|
hz_per_radian: sample_rate as f32 / (2.0 * PI),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Band-pass a block, carrying the filter's state across the seam.
|
||||||
|
fn filter(&mut self, samples: &[f32]) -> Vec<f32> {
|
||||||
|
let taps = BANDPASS_TAPS;
|
||||||
|
let tail_len = taps - 1;
|
||||||
|
let mut work = Vec::with_capacity(tail_len + samples.len());
|
||||||
|
work.extend_from_slice(&self.bandpass_tail);
|
||||||
|
work.extend_from_slice(samples);
|
||||||
|
|
||||||
|
let mut out = Vec::with_capacity(samples.len());
|
||||||
|
for i in 0..samples.len() {
|
||||||
|
let window = &work[i..i + taps];
|
||||||
|
let mut acc = 0.0f32;
|
||||||
|
for k in 0..taps {
|
||||||
|
acc += self.bandpass[k] * window[taps - 1 - k];
|
||||||
|
}
|
||||||
|
out.push(acc);
|
||||||
|
}
|
||||||
|
let work_len = work.len();
|
||||||
|
self.bandpass_tail
|
||||||
|
.copy_from_slice(&work[work_len - tail_len..]);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Frequency in Hz for each input sample, appended to `out`.
|
||||||
|
///
|
||||||
|
/// Output is delayed by the FIR's group delay, which is constant and so
|
||||||
|
/// affects only the absolute timing of the whole stream, not the spacing
|
||||||
|
/// between the events in it.
|
||||||
|
pub fn process_into(&mut self, samples: &[f32], out: &mut Vec<f32>) {
|
||||||
|
if samples.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let samples = self.filter(samples);
|
||||||
|
let samples = samples.as_slice();
|
||||||
|
let taps = HILBERT_TAPS;
|
||||||
|
let tail_len = taps - 1;
|
||||||
|
|
||||||
|
let mut work = Vec::with_capacity(tail_len + samples.len());
|
||||||
|
work.extend_from_slice(&self.tail);
|
||||||
|
work.extend_from_slice(samples);
|
||||||
|
|
||||||
|
out.reserve(samples.len());
|
||||||
|
for i in 0..samples.len() {
|
||||||
|
let window = &work[i..i + taps];
|
||||||
|
let mut q = 0.0f32;
|
||||||
|
for k in 0..taps {
|
||||||
|
q += self.coeffs[k] * window[taps - 1 - k];
|
||||||
|
}
|
||||||
|
// In phase with the quadrature output: the input, delayed by the
|
||||||
|
// FIR's own group delay.
|
||||||
|
let i_val = window[HILBERT_DELAY];
|
||||||
|
|
||||||
|
// f = |arg(z[n] · conj(z[n-1]))| · fs / 2π
|
||||||
|
let di = i_val * self.prev_i + q * self.prev_q;
|
||||||
|
let dq = q * self.prev_i - i_val * self.prev_q;
|
||||||
|
out.push(dq.atan2(di).abs() * self.hz_per_radian);
|
||||||
|
|
||||||
|
self.prev_i = i_val;
|
||||||
|
self.prev_q = q;
|
||||||
|
}
|
||||||
|
|
||||||
|
let work_len = work.len();
|
||||||
|
self.tail.copy_from_slice(&work[work_len - tail_len..]);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.bandpass_tail.fill(0.0);
|
||||||
|
self.tail.fill(0.0);
|
||||||
|
self.prev_i = 0.0;
|
||||||
|
self.prev_q = 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Boxcar mean over `window` samples, centred, returning one value per input.
|
||||||
|
///
|
||||||
|
/// The per-sample estimate ripples — badly at the low end of the band, where
|
||||||
|
/// the Hilbert approximation is weakest: a clean 1200 Hz tone reads anywhere
|
||||||
|
/// between 1110 and 1300 Hz sample to sample, though its mean is exact. Pixels
|
||||||
|
/// are averaged over their own window and so come out right regardless, but
|
||||||
|
/// anything that classifies a single sample by frequency — the VIS bits, the
|
||||||
|
/// sync pulses — has to look at a mean or it is reading the ripple.
|
||||||
|
pub fn smooth(freqs: &[f32], window: usize) -> Vec<f32> {
|
||||||
|
let window = window.max(1);
|
||||||
|
if freqs.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
// Prefix sums in f64: a minute of audio is three million samples, and a
|
||||||
|
// running f32 total drifts long before that.
|
||||||
|
let mut prefix = Vec::with_capacity(freqs.len() + 1);
|
||||||
|
prefix.push(0.0f64);
|
||||||
|
for &freq in freqs {
|
||||||
|
prefix.push(prefix[prefix.len() - 1] + f64::from(freq));
|
||||||
|
}
|
||||||
|
let half = window / 2;
|
||||||
|
(0..freqs.len())
|
||||||
|
.map(|i| {
|
||||||
|
// Shrinks at the ends rather than reaching past them.
|
||||||
|
let from = i.saturating_sub(half);
|
||||||
|
let to = (i + window - half).min(freqs.len());
|
||||||
|
((prefix[to] - prefix[from]) / (to - from) as f64) as f32
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Windowed-sinc band-pass over the SSTV band, Hamming-windowed and
|
||||||
|
/// linear-phase, so every frequency in the band is delayed alike.
|
||||||
|
fn design_bandpass_fir(sample_rate: u32) -> Vec<f32> {
|
||||||
|
let sr = sample_rate as f64;
|
||||||
|
let low = f64::from(BAND_LOW_HZ) / sr;
|
||||||
|
let high = (f64::from(BAND_HIGH_HZ) / sr).min(0.499);
|
||||||
|
let m = (BANDPASS_TAPS - 1) as f64;
|
||||||
|
let mid = m / 2.0;
|
||||||
|
let sinc = |x: f64| {
|
||||||
|
if x.abs() < 1e-9 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
(std::f64::consts::PI * x).sin() / (std::f64::consts::PI * x)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut coeffs = Vec::with_capacity(BANDPASS_TAPS);
|
||||||
|
for i in 0..BANDPASS_TAPS {
|
||||||
|
let n = i as f64 - mid;
|
||||||
|
// Difference of two low-passes is a band-pass.
|
||||||
|
let ideal = 2.0 * high * sinc(2.0 * high * n) - 2.0 * low * sinc(2.0 * low * n);
|
||||||
|
let window = 0.54 - 0.46 * (2.0 * std::f64::consts::PI * i as f64 / m).cos();
|
||||||
|
coeffs.push((ideal * window) as f32);
|
||||||
|
}
|
||||||
|
coeffs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Type III FIR approximating a 90° phase shift: h[n] = 2/(πn) for odd n,
|
||||||
|
/// Blackman-windowed. Independent of sample rate, so the decoder can run at
|
||||||
|
/// whatever rate the audio arrives in.
|
||||||
|
fn design_hilbert_fir() -> [f32; HILBERT_TAPS] {
|
||||||
|
let mut coeffs = [0.0f32; HILBERT_TAPS];
|
||||||
|
let m = (HILBERT_TAPS - 1) as f64;
|
||||||
|
let mid = m / 2.0;
|
||||||
|
for (i, coeff) in coeffs.iter_mut().enumerate() {
|
||||||
|
let n = i as f64 - mid;
|
||||||
|
let ni = n.round() as i64;
|
||||||
|
if ni != 0 && ni % 2 != 0 {
|
||||||
|
let h = 2.0 / (std::f64::consts::PI * n);
|
||||||
|
let w = 0.42 - 0.5 * (2.0 * std::f64::consts::PI * i as f64 / m).cos()
|
||||||
|
+ 0.08 * (4.0 * std::f64::consts::PI * i as f64 / m).cos();
|
||||||
|
*coeff = (h * w) as f32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
coeffs
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn tone(freq: f32, sample_rate: u32, samples: usize) -> Vec<f32> {
|
||||||
|
(0..samples)
|
||||||
|
.map(|n| (2.0 * PI * freq * n as f32 / sample_rate as f32).sin())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Measured on the settled part of the output: the first `HILBERT_TAPS`
|
||||||
|
/// samples are the filter filling up.
|
||||||
|
fn measure(freq: f32, sample_rate: u32) -> f32 {
|
||||||
|
let mut demod = FreqDemod::new(sample_rate);
|
||||||
|
let mut out = Vec::new();
|
||||||
|
demod.process_into(
|
||||||
|
&tone(freq, sample_rate, sample_rate as usize / 10),
|
||||||
|
&mut out,
|
||||||
|
);
|
||||||
|
let settled = &out[HILBERT_TAPS * 2..];
|
||||||
|
settled.iter().sum::<f32>() / settled.len() as f32
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reads_the_tones_sstv_is_made_of() {
|
||||||
|
for rate in [8000u32, 11025, 44100, 48000] {
|
||||||
|
for freq in [1200.0f32, 1500.0, 1900.0, 2300.0] {
|
||||||
|
let measured = measure(freq, rate);
|
||||||
|
assert!(
|
||||||
|
(measured - freq).abs() < 5.0,
|
||||||
|
"{rate} Hz: {freq} Hz tone measured as {measured:.1} Hz",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blocks are whatever size the audio pipeline hands over, and a tone that
|
||||||
|
/// straddles two of them must not produce a discontinuity at the seam.
|
||||||
|
#[test]
|
||||||
|
fn block_boundaries_do_not_disturb_the_estimate() {
|
||||||
|
let rate = 48_000;
|
||||||
|
let samples = tone(1900.0, rate, 9600);
|
||||||
|
let mut whole = FreqDemod::new(rate);
|
||||||
|
let mut expected = Vec::new();
|
||||||
|
whole.process_into(&samples, &mut expected);
|
||||||
|
|
||||||
|
let mut split = FreqDemod::new(rate);
|
||||||
|
let mut actual = Vec::new();
|
||||||
|
for chunk in samples.chunks(137) {
|
||||||
|
split.process_into(chunk, &mut actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(actual.len(), expected.len());
|
||||||
|
for (i, (a, b)) in actual.iter().zip(&expected).enumerate() {
|
||||||
|
assert!((a - b).abs() < 0.01, "sample {i}: {a} vs {b}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn follows_a_step_between_tones_within_a_pixel() {
|
||||||
|
let rate = 48_000;
|
||||||
|
let mut samples = tone(1500.0, rate, 4800);
|
||||||
|
samples.extend(tone(2300.0, rate, 4800));
|
||||||
|
let mut demod = FreqDemod::new(rate);
|
||||||
|
let mut out = Vec::new();
|
||||||
|
demod.process_into(&samples, &mut out);
|
||||||
|
|
||||||
|
// Well before the step it reads black; well after it, white. The step
|
||||||
|
// itself takes the filter's length to pass through.
|
||||||
|
let before = out[4800 - 200..4800 - 100].iter().sum::<f32>() / 100.0;
|
||||||
|
let after = out[4800 + 200..4800 + 300].iter().sum::<f32>() / 100.0;
|
||||||
|
assert!(
|
||||||
|
(before - 1500.0).abs() < 10.0,
|
||||||
|
"before the step: {before:.1} Hz"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(after - 2300.0).abs() < 10.0,
|
||||||
|
"after the step: {after:.1} Hz"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
//! Turning an image into an SSTV signal.
|
||||||
|
//!
|
||||||
|
//! This exists so the decoder can be held to a picture rather than to a
|
||||||
|
//! description of one: the tests encode a known image, decode the audio back
|
||||||
|
//! and compare. Nothing in the receive path uses it.
|
||||||
|
//!
|
||||||
|
//! It is written to the same mode table the decoder reads, which makes a
|
||||||
|
//! round-trip a test of the decoder and not of the timings — the timings are
|
||||||
|
//! checked separately, against the published line durations, in [`crate::mode`].
|
||||||
|
|
||||||
|
use crate::mode::{Channel, Segment, SstvMode, BLACK_HZ, SYNC_HZ, WHITE_HZ};
|
||||||
|
|
||||||
|
/// A stretch of constant tone.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct Tone {
|
||||||
|
pub hz: f32,
|
||||||
|
pub ms: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Frequency for an 8-bit level: the inverse of [`crate::mode::level_from_hz`].
|
||||||
|
pub fn hz_from_level(level: u8) -> f32 {
|
||||||
|
BLACK_HZ + (WHITE_HZ - BLACK_HZ) * f32::from(level) / 255.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tones of a VIS header announcing `vis`.
|
||||||
|
pub fn vis_tones(vis: u8) -> Vec<Tone> {
|
||||||
|
let mut tones = vec![
|
||||||
|
Tone {
|
||||||
|
hz: 1900.0,
|
||||||
|
ms: 300.0,
|
||||||
|
},
|
||||||
|
Tone {
|
||||||
|
hz: 1200.0,
|
||||||
|
ms: 10.0,
|
||||||
|
},
|
||||||
|
Tone {
|
||||||
|
hz: 1900.0,
|
||||||
|
ms: 300.0,
|
||||||
|
},
|
||||||
|
Tone {
|
||||||
|
hz: 1200.0,
|
||||||
|
ms: 30.0,
|
||||||
|
}, // start bit
|
||||||
|
];
|
||||||
|
let mut ones = 0;
|
||||||
|
for bit in 0..7 {
|
||||||
|
let set = vis & (1 << bit) != 0;
|
||||||
|
if set {
|
||||||
|
ones += 1;
|
||||||
|
}
|
||||||
|
tones.push(Tone {
|
||||||
|
hz: if set { 1100.0 } else { 1300.0 },
|
||||||
|
ms: 30.0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Even parity over the seven data bits.
|
||||||
|
tones.push(Tone {
|
||||||
|
hz: if ones % 2 == 1 { 1100.0 } else { 1300.0 },
|
||||||
|
ms: 30.0,
|
||||||
|
});
|
||||||
|
tones.push(Tone {
|
||||||
|
hz: 1200.0,
|
||||||
|
ms: 30.0,
|
||||||
|
}); // stop bit
|
||||||
|
tones
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RGB source for the encoder: `width * height * 3` bytes.
|
||||||
|
pub struct Frame<'a> {
|
||||||
|
pub width: usize,
|
||||||
|
pub height: usize,
|
||||||
|
pub rgb: &'a [u8],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Frame<'_> {
|
||||||
|
fn pixel(&self, x: usize, y: usize) -> (u8, u8, u8) {
|
||||||
|
let x = x.min(self.width.saturating_sub(1));
|
||||||
|
let y = y.min(self.height.saturating_sub(1));
|
||||||
|
let at = (y * self.width + x) * 3;
|
||||||
|
(self.rgb[at], self.rgb[at + 1], self.rgb[at + 2])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The colour components SSTV actually sends, for a pixel.
|
||||||
|
fn ycrcb(&self, x: usize, y: usize) -> (u8, u8, u8) {
|
||||||
|
let (r, g, b) = self.pixel(x, y);
|
||||||
|
let (r, g, b) = (f32::from(r), f32::from(g), f32::from(b));
|
||||||
|
let y_val = 16.0 + (0.003_906 * ((65.738 * r) + (129.057 * g) + (25.064 * b)));
|
||||||
|
let cr = 128.0 + (0.003_906 * ((112.439 * r) + (-94.154 * g) + (-18.285 * b)));
|
||||||
|
let cb = 128.0 + (0.003_906 * ((-37.945 * r) + (-74.494 * g) + (112.439 * b)));
|
||||||
|
(
|
||||||
|
y_val.clamp(0.0, 255.0) as u8,
|
||||||
|
cr.clamp(0.0, 255.0) as u8,
|
||||||
|
cb.clamp(0.0, 255.0) as u8,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode `frame` in `mode`, returning the tones of the whole transmission.
|
||||||
|
pub fn encode_tones(mode: &SstvMode, frame: &Frame<'_>) -> Vec<Tone> {
|
||||||
|
let mut tones = vis_tones(mode.vis);
|
||||||
|
let per_line = usize::from(mode.lines_per_transmission);
|
||||||
|
let transmissions = usize::from(mode.height) / per_line;
|
||||||
|
|
||||||
|
for transmission in 0..transmissions {
|
||||||
|
let top = transmission * per_line;
|
||||||
|
for segment in mode.segments {
|
||||||
|
match *segment {
|
||||||
|
Segment::Sync(ms) => tones.push(Tone { hz: SYNC_HZ, ms }),
|
||||||
|
Segment::Gap(ms) => tones.push(Tone { hz: BLACK_HZ, ms }),
|
||||||
|
Segment::Scan { channel, ms } => {
|
||||||
|
// A chroma scan carries fewer pixels than the image is
|
||||||
|
// wide, and takes proportionally less time per pixel.
|
||||||
|
let pixels = mode.scan_pixels(channel);
|
||||||
|
let pixel_ms = ms / pixels as f64;
|
||||||
|
for x in 0..pixels {
|
||||||
|
let level = channel_level(mode, frame, channel, x, top, transmission);
|
||||||
|
tones.push(Tone {
|
||||||
|
hz: hz_from_level(level),
|
||||||
|
ms: pixel_ms,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tones
|
||||||
|
}
|
||||||
|
|
||||||
|
fn channel_level(
|
||||||
|
mode: &SstvMode,
|
||||||
|
frame: &Frame<'_>,
|
||||||
|
channel: Channel,
|
||||||
|
x: usize,
|
||||||
|
top: usize,
|
||||||
|
transmission: usize,
|
||||||
|
) -> u8 {
|
||||||
|
match channel {
|
||||||
|
Channel::Red => frame.pixel(x, top).0,
|
||||||
|
Channel::Green => frame.pixel(x, top).1,
|
||||||
|
Channel::Blue => frame.pixel(x, top).2,
|
||||||
|
Channel::LumaOdd => frame.ycrcb(x, top).0,
|
||||||
|
Channel::LumaEven => frame.ycrcb(x, top + 1).0,
|
||||||
|
Channel::ChromaR => chroma(mode, frame, x, top, true),
|
||||||
|
Channel::ChromaB => chroma(mode, frame, x, top, false),
|
||||||
|
// Robot 36 sends R-Y on odd transmitted lines and B-Y on even ones.
|
||||||
|
Channel::ChromaAlternating => chroma(mode, frame, x, top, transmission.is_multiple_of(2)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Chroma scans are half the width of the image in the Robot modes, so each
|
||||||
|
/// value covers two pixels; PD averages the two image lines of the pair too.
|
||||||
|
fn chroma(mode: &SstvMode, frame: &Frame<'_>, x: usize, top: usize, want_cr: bool) -> u8 {
|
||||||
|
let scale = usize::from(mode.width) / mode.scan_pixels(Channel::ChromaR).max(1);
|
||||||
|
let x0 = x * scale;
|
||||||
|
let mut total = 0u32;
|
||||||
|
let mut count = 0u32;
|
||||||
|
let rows = usize::from(mode.lines_per_transmission);
|
||||||
|
for row in 0..rows {
|
||||||
|
for dx in 0..scale {
|
||||||
|
let (_, cr, cb) = frame.ycrcb(x0 + dx, top + row);
|
||||||
|
total += u32::from(if want_cr { cr } else { cb });
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(total / count.max(1)) as u8
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render tones to audio at `sample_rate`, with continuous phase so the
|
||||||
|
/// demodulator sees no step at a tone boundary that isn't in the signal.
|
||||||
|
pub fn render(tones: &[Tone], sample_rate: u32) -> Vec<f32> {
|
||||||
|
let sr = f64::from(sample_rate);
|
||||||
|
let mut out =
|
||||||
|
Vec::with_capacity((tones.iter().map(|t| t.ms).sum::<f64>() / 1000.0 * sr) as usize);
|
||||||
|
let mut phase = 0.0f64;
|
||||||
|
// Each tone's *end* is rounded to a sample, rather than its length: a
|
||||||
|
// pixel of 25.5 samples rounded up on its own puts a whole line 600
|
||||||
|
// samples late by the end of it, which is a timing error no receiver
|
||||||
|
// should have to chase and no transmitter would produce.
|
||||||
|
let mut elapsed_ms = 0.0f64;
|
||||||
|
let mut emitted = 0usize;
|
||||||
|
for tone in tones {
|
||||||
|
elapsed_ms += tone.ms;
|
||||||
|
let end = (elapsed_ms / 1000.0 * sr).round() as usize;
|
||||||
|
let samples = end.saturating_sub(emitted);
|
||||||
|
emitted = end;
|
||||||
|
let step = 2.0 * std::f64::consts::PI * f64::from(tone.hz) / sr;
|
||||||
|
for _ in 0..samples {
|
||||||
|
out.push(phase.sin() as f32);
|
||||||
|
phase += step;
|
||||||
|
if phase > std::f64::consts::TAU {
|
||||||
|
phase -= std::f64::consts::TAU;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode a frame straight to audio.
|
||||||
|
pub fn encode(mode: &SstvMode, frame: &Frame<'_>, sample_rate: u32) -> Vec<f32> {
|
||||||
|
render(&encode_tones(mode, frame), sample_rate)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::mode::{level_from_hz, mode_for_vis};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn levels_survive_the_trip_through_frequency() {
|
||||||
|
for level in [0u8, 1, 64, 128, 200, 255] {
|
||||||
|
assert_eq!(level_from_hz(hz_from_level(level)), level);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_transmission_lasts_as_long_as_the_mode_says() {
|
||||||
|
let mode = mode_for_vis(44).expect("Martin M1");
|
||||||
|
let rgb = vec![128u8; 320 * 256 * 3];
|
||||||
|
let frame = Frame {
|
||||||
|
width: 320,
|
||||||
|
height: 256,
|
||||||
|
rgb: &rgb,
|
||||||
|
};
|
||||||
|
let audio = encode(mode, &frame, 48_000);
|
||||||
|
// Header plus 256 lines, within a line of the published duration.
|
||||||
|
let header_s = 0.94;
|
||||||
|
let expected = header_s + mode.frame_secs();
|
||||||
|
let actual = audio.len() as f64 / 48_000.0;
|
||||||
|
assert!(
|
||||||
|
(actual - expected).abs() < mode.line_ms / 1000.0,
|
||||||
|
"encoded {actual:.2} s, expected {expected:.2} s",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
//! Assembling decoded lines into an image, and getting it out of the process.
|
||||||
|
//!
|
||||||
|
//! Rows arrive one at a time and the picture is worth looking at before it is
|
||||||
|
//! finished, so the assembler holds a full-size RGB canvas from the start and
|
||||||
|
//! fills it in. An unfinished frame is grey below the last decoded row rather
|
||||||
|
//! than black, which reads as "not here yet" instead of "received as black".
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
|
||||||
|
/// Value the canvas starts at: mid-grey, for rows not yet received.
|
||||||
|
const UNWRITTEN: u8 = 96;
|
||||||
|
|
||||||
|
pub struct ImageCanvas {
|
||||||
|
width: usize,
|
||||||
|
height: usize,
|
||||||
|
rgb: Vec<u8>,
|
||||||
|
/// Highest row index written, plus one.
|
||||||
|
filled_rows: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImageCanvas {
|
||||||
|
pub fn new(width: usize, height: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
rgb: vec![UNWRITTEN; width * height * 3],
|
||||||
|
filled_rows: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn width(&self) -> usize {
|
||||||
|
self.width
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn height(&self) -> usize {
|
||||||
|
self.height
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rows written so far.
|
||||||
|
pub fn filled_rows(&self) -> usize {
|
||||||
|
self.filled_rows
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write one row of RGB triples. Rows past the bottom of the image are
|
||||||
|
/// dropped: a transmission that runs long is not a reason to grow.
|
||||||
|
pub fn put_row(&mut self, y: usize, row: &[u8]) {
|
||||||
|
if y >= self.height {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let at = y * self.width * 3;
|
||||||
|
let take = row.len().min(self.width * 3);
|
||||||
|
self.rgb[at..at + take].copy_from_slice(&row[..take]);
|
||||||
|
self.filled_rows = self.filled_rows.max(y + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn row(&self, y: usize) -> Option<&[u8]> {
|
||||||
|
if y >= self.height {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let at = y * self.width * 3;
|
||||||
|
Some(&self.rgb[at..at + self.width * 3])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rgb(&self) -> &[u8] {
|
||||||
|
&self.rgb
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode the canvas as a PNG.
|
||||||
|
pub fn to_png(&self) -> Result<Vec<u8>, String> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
{
|
||||||
|
let mut encoder = png::Encoder::new(&mut out, self.width as u32, self.height as u32);
|
||||||
|
encoder.set_color(png::ColorType::Rgb);
|
||||||
|
encoder.set_depth(png::BitDepth::Eight);
|
||||||
|
let mut writer = encoder
|
||||||
|
.write_header()
|
||||||
|
.map_err(|e| format!("PNG header: {e}"))?;
|
||||||
|
writer
|
||||||
|
.write_image_data(&self.rgb)
|
||||||
|
.map_err(|e| format!("PNG data: {e}"))?;
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The PNG, base64-encoded for the journey to a browser.
|
||||||
|
pub fn to_png_base64(&self) -> Result<String, String> {
|
||||||
|
Ok(base64::engine::general_purpose::STANDARD.encode(self.to_png()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the PNG into `dir`, named for when and where it was received.
|
||||||
|
pub fn save_png(
|
||||||
|
&self,
|
||||||
|
dir: &Path,
|
||||||
|
freq_hz: u64,
|
||||||
|
mode_name: &str,
|
||||||
|
stamp: &str,
|
||||||
|
) -> Result<PathBuf, String> {
|
||||||
|
std::fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
|
||||||
|
let slug: String = mode_name
|
||||||
|
.chars()
|
||||||
|
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
|
||||||
|
.collect();
|
||||||
|
let path = dir.join(format!("SSTV_{stamp}_{freq_hz}_{slug}.png"));
|
||||||
|
std::fs::write(&path, self.to_png()?)
|
||||||
|
.map_err(|e| format!("write {}: {e}", path.display()))?;
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rows_land_where_they_are_put_and_the_rest_stays_unwritten() {
|
||||||
|
let mut canvas = ImageCanvas::new(4, 3);
|
||||||
|
canvas.put_row(1, &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
|
||||||
|
assert_eq!(canvas.row(1).unwrap()[0..3], [1, 2, 3]);
|
||||||
|
assert_eq!(canvas.row(0).unwrap()[0], UNWRITTEN);
|
||||||
|
assert_eq!(canvas.filled_rows(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_row_past_the_bottom_is_dropped_rather_than_growing_the_image() {
|
||||||
|
let mut canvas = ImageCanvas::new(2, 2);
|
||||||
|
canvas.put_row(9, &[1, 2, 3, 4, 5, 6]);
|
||||||
|
assert_eq!(canvas.filled_rows(), 0);
|
||||||
|
assert_eq!(canvas.rgb().len(), 2 * 2 * 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encodes_a_png_a_decoder_can_read_back() {
|
||||||
|
let mut canvas = ImageCanvas::new(2, 2);
|
||||||
|
canvas.put_row(0, &[255, 0, 0, 0, 255, 0]);
|
||||||
|
let png_bytes = canvas.to_png().expect("png");
|
||||||
|
let decoder = png::Decoder::new(png_bytes.as_slice());
|
||||||
|
let mut reader = decoder.read_info().expect("png info");
|
||||||
|
let mut buf = vec![0; reader.output_buffer_size()];
|
||||||
|
let info = reader.next_frame(&mut buf).expect("png frame");
|
||||||
|
assert_eq!((info.width, info.height), (2, 2));
|
||||||
|
assert_eq!(&buf[0..6], &[255, 0, 0, 0, 255, 0]);
|
||||||
|
assert!(!canvas.to_png_base64().expect("base64").is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
//! SSTV (Slow-Scan Television) decoder.
|
||||||
|
//!
|
||||||
|
//! Pure Rust, covering Martin, Scottie, Robot, PD and Wraase SC2-180, with the
|
||||||
|
//! mode taken from the VIS header that precedes every transmission. Rows are
|
||||||
|
//! emitted as they arrive so a picture can be watched building up.
|
||||||
|
//!
|
||||||
|
//! ```no_run
|
||||||
|
//! use trx_sstv::{SstvConfig, SstvDecoder, SstvEvent};
|
||||||
|
//!
|
||||||
|
//! let mut decoder = SstvDecoder::new(48_000, SstvConfig::default());
|
||||||
|
//! # let audio: Vec<f32> = Vec::new();
|
||||||
|
//! for event in decoder.process_samples(&audio) {
|
||||||
|
//! match event {
|
||||||
|
//! SstvEvent::Started { mode, .. } => println!("receiving {mode}"),
|
||||||
|
//! SstvEvent::Row { line, .. } => println!("line {line}"),
|
||||||
|
//! SstvEvent::Complete(image) => println!("{} lines", image.lines),
|
||||||
|
//! }
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
pub mod config;
|
||||||
|
pub mod decoder;
|
||||||
|
pub mod demod;
|
||||||
|
pub mod encode;
|
||||||
|
pub mod image;
|
||||||
|
pub mod mode;
|
||||||
|
pub mod vis;
|
||||||
|
|
||||||
|
pub use config::SstvConfig;
|
||||||
|
pub use decoder::{SstvDecoder, SstvEvent, SstvImage};
|
||||||
|
pub use image::ImageCanvas;
|
||||||
|
pub use mode::{mode_for_vis, SstvMode, MODES};
|
||||||
@@ -0,0 +1,615 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
//! SSTV mode table: what a VIS code means in timings, geometry and colour.
|
||||||
|
//!
|
||||||
|
//! Every mode transmits a line as a sequence of *segments* — a sync pulse, a
|
||||||
|
//! porch or separator at a fixed tone, and one scan per colour channel. The
|
||||||
|
//! decoder needs only the segment layout and the offset of each scan within
|
||||||
|
//! the line, so that is what a [`SstvMode`] is: a list of segments plus the
|
||||||
|
//! rules for turning the scans back into pixels.
|
||||||
|
//!
|
||||||
|
//! Timings follow the published mode specifications (JL Barber, N7CXI,
|
||||||
|
//! "Proposal for SSTV Mode Specifications", 2000), which is the same table
|
||||||
|
//! MMSSTV, QSSTV and slowrx work from.
|
||||||
|
|
||||||
|
/// Tone that marks a line boundary, in Hz.
|
||||||
|
pub const SYNC_HZ: f32 = 1200.0;
|
||||||
|
/// Tone for black, in Hz.
|
||||||
|
pub const BLACK_HZ: f32 = 1500.0;
|
||||||
|
/// Tone for white, in Hz.
|
||||||
|
pub const WHITE_HZ: f32 = 2300.0;
|
||||||
|
|
||||||
|
/// What a scan segment carries.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Channel {
|
||||||
|
Red,
|
||||||
|
Green,
|
||||||
|
Blue,
|
||||||
|
/// Luminance for the odd (first) image line of the pair.
|
||||||
|
LumaOdd,
|
||||||
|
/// Luminance for the even (second) image line of a PD pair.
|
||||||
|
LumaEven,
|
||||||
|
/// R-Y chrominance.
|
||||||
|
ChromaR,
|
||||||
|
/// B-Y chrominance.
|
||||||
|
ChromaB,
|
||||||
|
/// Robot 36 alternates R-Y and B-Y between transmitted lines: odd lines
|
||||||
|
/// carry R-Y, even lines B-Y, and each is held over both.
|
||||||
|
ChromaAlternating,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One piece of a transmitted line.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum Segment {
|
||||||
|
/// Sync pulse at [`SYNC_HZ`].
|
||||||
|
Sync(f64),
|
||||||
|
/// Porch, separator or gap at a fixed tone; the tone itself is not decoded.
|
||||||
|
Gap(f64),
|
||||||
|
/// A scan carrying pixels for one channel.
|
||||||
|
Scan { channel: Channel, ms: f64 },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Segment {
|
||||||
|
pub fn duration_ms(&self) -> f64 {
|
||||||
|
match *self {
|
||||||
|
Segment::Sync(ms) | Segment::Gap(ms) => ms,
|
||||||
|
Segment::Scan { ms, .. } => ms,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How the scans of a line become pixels.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ColorModel {
|
||||||
|
/// Scans are red, green and blue directly (Martin, Scottie, Wraase).
|
||||||
|
Rgb,
|
||||||
|
/// Y plus one alternating chroma channel per line (Robot 36).
|
||||||
|
YCrCbAlternating,
|
||||||
|
/// Y, R-Y and B-Y in every line (Robot 72).
|
||||||
|
YCrCb,
|
||||||
|
/// Two image lines per transmitted line: Y odd, R-Y, B-Y, Y even (PD).
|
||||||
|
YCrCbPaired,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A decodable SSTV mode.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SstvMode {
|
||||||
|
/// VIS code as sent in the header.
|
||||||
|
pub vis: u8,
|
||||||
|
/// Human-readable name, e.g. "Martin M1".
|
||||||
|
pub name: &'static str,
|
||||||
|
/// Pixels across.
|
||||||
|
pub width: u16,
|
||||||
|
/// Image lines in a full frame.
|
||||||
|
pub height: u16,
|
||||||
|
/// Transmitted line duration in milliseconds.
|
||||||
|
pub line_ms: f64,
|
||||||
|
/// Segments in transmission order.
|
||||||
|
pub segments: &'static [Segment],
|
||||||
|
pub color: ColorModel,
|
||||||
|
/// Image lines produced by one transmitted line (2 for PD, otherwise 1).
|
||||||
|
pub lines_per_transmission: u16,
|
||||||
|
/// Offset from the start of a line to the leading edge of its sync pulse.
|
||||||
|
/// Zero for most modes; Scottie sends the sync in the middle of the line,
|
||||||
|
/// so a line detected at its sync starts before it.
|
||||||
|
pub sync_offset_ms: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SstvMode {
|
||||||
|
/// Total of every segment, which must equal [`SstvMode::line_ms`].
|
||||||
|
pub fn segments_ms(&self) -> f64 {
|
||||||
|
self.segments.iter().map(Segment::duration_ms).sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start offset, in milliseconds from the line start, of each scan.
|
||||||
|
pub fn scans(&self) -> Vec<(Channel, f64, f64)> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut at = 0.0;
|
||||||
|
for segment in self.segments {
|
||||||
|
if let Segment::Scan { channel, ms } = *segment {
|
||||||
|
out.push((channel, at, ms));
|
||||||
|
}
|
||||||
|
at += segment.duration_ms();
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pixels carried by a scan of this channel.
|
||||||
|
///
|
||||||
|
/// Chroma is sent at half the width in the Robot modes — the eye takes
|
||||||
|
/// colour more coarsely than brightness, and the saving is what makes 36
|
||||||
|
/// seconds possible. PD sends chroma at full width and saves its time by
|
||||||
|
/// sharing one pair of chroma scans between two image lines instead.
|
||||||
|
pub fn scan_pixels(&self, channel: Channel) -> usize {
|
||||||
|
let width = usize::from(self.width);
|
||||||
|
match channel {
|
||||||
|
Channel::ChromaR | Channel::ChromaB | Channel::ChromaAlternating
|
||||||
|
if self.color != ColorModel::YCrCbPaired =>
|
||||||
|
{
|
||||||
|
width / 2
|
||||||
|
}
|
||||||
|
_ => width,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seconds a full frame takes to send.
|
||||||
|
pub fn frame_secs(&self) -> f64 {
|
||||||
|
self.line_ms * f64::from(self.height) / f64::from(self.lines_per_transmission) / 1000.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Martin — sync, porch, then green, blue, red, each followed by a separator.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const MARTIN_M1: &[Segment] = &[
|
||||||
|
Segment::Sync(4.862),
|
||||||
|
Segment::Gap(0.572),
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::Green,
|
||||||
|
ms: 146.432,
|
||||||
|
},
|
||||||
|
Segment::Gap(0.572),
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::Blue,
|
||||||
|
ms: 146.432,
|
||||||
|
},
|
||||||
|
Segment::Gap(0.572),
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::Red,
|
||||||
|
ms: 146.432,
|
||||||
|
},
|
||||||
|
Segment::Gap(0.572),
|
||||||
|
];
|
||||||
|
|
||||||
|
const MARTIN_M2: &[Segment] = &[
|
||||||
|
Segment::Sync(4.862),
|
||||||
|
Segment::Gap(0.572),
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::Green,
|
||||||
|
ms: 73.216,
|
||||||
|
},
|
||||||
|
Segment::Gap(0.572),
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::Blue,
|
||||||
|
ms: 73.216,
|
||||||
|
},
|
||||||
|
Segment::Gap(0.572),
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::Red,
|
||||||
|
ms: 73.216,
|
||||||
|
},
|
||||||
|
Segment::Gap(0.572),
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scottie — the sync pulse sits between the blue and red scans, so a line
|
||||||
|
// starts one separator before the green scan and the sync of the *previous*
|
||||||
|
// line is what marks it.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const SCOTTIE_S1: &[Segment] = &[
|
||||||
|
Segment::Gap(1.5),
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::Green,
|
||||||
|
ms: 138.240,
|
||||||
|
},
|
||||||
|
Segment::Gap(1.5),
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::Blue,
|
||||||
|
ms: 138.240,
|
||||||
|
},
|
||||||
|
Segment::Sync(9.0),
|
||||||
|
Segment::Gap(1.5),
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::Red,
|
||||||
|
ms: 138.240,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const SCOTTIE_S2: &[Segment] = &[
|
||||||
|
Segment::Gap(1.5),
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::Green,
|
||||||
|
ms: 88.064,
|
||||||
|
},
|
||||||
|
Segment::Gap(1.5),
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::Blue,
|
||||||
|
ms: 88.064,
|
||||||
|
},
|
||||||
|
Segment::Sync(9.0),
|
||||||
|
Segment::Gap(1.5),
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::Red,
|
||||||
|
ms: 88.064,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const SCOTTIE_DX: &[Segment] = &[
|
||||||
|
Segment::Gap(1.5),
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::Green,
|
||||||
|
ms: 345.6,
|
||||||
|
},
|
||||||
|
Segment::Gap(1.5),
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::Blue,
|
||||||
|
ms: 345.6,
|
||||||
|
},
|
||||||
|
Segment::Sync(9.0),
|
||||||
|
Segment::Gap(1.5),
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::Red,
|
||||||
|
ms: 345.6,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Scottie's sync arrives after the green and blue scans: 1.5 + 138.24 + 1.5 +
|
||||||
|
/// 138.24 for S1, and the equivalent for the others.
|
||||||
|
const fn scottie_sync_offset(scan_ms: f64) -> f64 {
|
||||||
|
1.5 + scan_ms + 1.5 + scan_ms
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Robot — luminance plus chrominance, the chroma scans at half the width.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ROBOT_36: &[Segment] = &[
|
||||||
|
Segment::Sync(9.0),
|
||||||
|
Segment::Gap(3.0),
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::LumaOdd,
|
||||||
|
ms: 88.0,
|
||||||
|
},
|
||||||
|
Segment::Gap(4.5),
|
||||||
|
Segment::Gap(1.5),
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::ChromaAlternating,
|
||||||
|
ms: 44.0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const ROBOT_72: &[Segment] = &[
|
||||||
|
Segment::Sync(9.0),
|
||||||
|
Segment::Gap(3.0),
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::LumaOdd,
|
||||||
|
ms: 138.0,
|
||||||
|
},
|
||||||
|
Segment::Gap(4.5),
|
||||||
|
Segment::Gap(1.5),
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::ChromaR,
|
||||||
|
ms: 69.0,
|
||||||
|
},
|
||||||
|
Segment::Gap(4.5),
|
||||||
|
Segment::Gap(1.5),
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::ChromaB,
|
||||||
|
ms: 69.0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PD — one transmitted line carries two image lines: the luminance of both,
|
||||||
|
// with a single pair of chroma scans shared between them.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
macro_rules! pd_segments {
|
||||||
|
($name:ident, $scan:expr) => {
|
||||||
|
const $name: &[Segment] = &[
|
||||||
|
Segment::Sync(20.0),
|
||||||
|
Segment::Gap(2.08),
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::LumaOdd,
|
||||||
|
ms: $scan,
|
||||||
|
},
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::ChromaR,
|
||||||
|
ms: $scan,
|
||||||
|
},
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::ChromaB,
|
||||||
|
ms: $scan,
|
||||||
|
},
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::LumaEven,
|
||||||
|
ms: $scan,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pd_segments!(PD_50, 91.520);
|
||||||
|
pd_segments!(PD_90, 170.240);
|
||||||
|
pd_segments!(PD_120, 121.600);
|
||||||
|
pd_segments!(PD_160, 195.584);
|
||||||
|
pd_segments!(PD_180, 183.040);
|
||||||
|
pd_segments!(PD_240, 244.672);
|
||||||
|
pd_segments!(PD_290, 228.800);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Wraase SC2-180 — red, green, blue in that order after one porch.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const WRAASE_SC2_180: &[Segment] = &[
|
||||||
|
Segment::Sync(5.5225),
|
||||||
|
Segment::Gap(0.5),
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::Red,
|
||||||
|
ms: 235.0,
|
||||||
|
},
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::Green,
|
||||||
|
ms: 235.0,
|
||||||
|
},
|
||||||
|
Segment::Scan {
|
||||||
|
channel: Channel::Blue,
|
||||||
|
ms: 235.0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Every mode this decoder knows, in VIS order.
|
||||||
|
pub static MODES: &[SstvMode] = &[
|
||||||
|
SstvMode {
|
||||||
|
vis: 8,
|
||||||
|
name: "Robot 36",
|
||||||
|
width: 320,
|
||||||
|
height: 240,
|
||||||
|
line_ms: 150.0,
|
||||||
|
segments: ROBOT_36,
|
||||||
|
color: ColorModel::YCrCbAlternating,
|
||||||
|
lines_per_transmission: 1,
|
||||||
|
sync_offset_ms: 0.0,
|
||||||
|
},
|
||||||
|
SstvMode {
|
||||||
|
vis: 12,
|
||||||
|
name: "Robot 72",
|
||||||
|
width: 320,
|
||||||
|
height: 240,
|
||||||
|
line_ms: 300.0,
|
||||||
|
segments: ROBOT_72,
|
||||||
|
color: ColorModel::YCrCb,
|
||||||
|
lines_per_transmission: 1,
|
||||||
|
sync_offset_ms: 0.0,
|
||||||
|
},
|
||||||
|
SstvMode {
|
||||||
|
vis: 40,
|
||||||
|
name: "Martin M2",
|
||||||
|
width: 320,
|
||||||
|
height: 256,
|
||||||
|
line_ms: 226.798,
|
||||||
|
segments: MARTIN_M2,
|
||||||
|
color: ColorModel::Rgb,
|
||||||
|
lines_per_transmission: 1,
|
||||||
|
sync_offset_ms: 0.0,
|
||||||
|
},
|
||||||
|
SstvMode {
|
||||||
|
vis: 44,
|
||||||
|
name: "Martin M1",
|
||||||
|
width: 320,
|
||||||
|
height: 256,
|
||||||
|
line_ms: 446.446,
|
||||||
|
segments: MARTIN_M1,
|
||||||
|
color: ColorModel::Rgb,
|
||||||
|
lines_per_transmission: 1,
|
||||||
|
sync_offset_ms: 0.0,
|
||||||
|
},
|
||||||
|
SstvMode {
|
||||||
|
vis: 55,
|
||||||
|
name: "Wraase SC2-180",
|
||||||
|
width: 320,
|
||||||
|
height: 256,
|
||||||
|
line_ms: 711.0225,
|
||||||
|
segments: WRAASE_SC2_180,
|
||||||
|
color: ColorModel::Rgb,
|
||||||
|
lines_per_transmission: 1,
|
||||||
|
sync_offset_ms: 0.0,
|
||||||
|
},
|
||||||
|
SstvMode {
|
||||||
|
vis: 56,
|
||||||
|
name: "Scottie S2",
|
||||||
|
width: 320,
|
||||||
|
height: 256,
|
||||||
|
line_ms: 277.692,
|
||||||
|
segments: SCOTTIE_S2,
|
||||||
|
color: ColorModel::Rgb,
|
||||||
|
lines_per_transmission: 1,
|
||||||
|
sync_offset_ms: scottie_sync_offset(88.064),
|
||||||
|
},
|
||||||
|
SstvMode {
|
||||||
|
vis: 60,
|
||||||
|
name: "Scottie S1",
|
||||||
|
width: 320,
|
||||||
|
height: 256,
|
||||||
|
line_ms: 428.22,
|
||||||
|
segments: SCOTTIE_S1,
|
||||||
|
color: ColorModel::Rgb,
|
||||||
|
lines_per_transmission: 1,
|
||||||
|
sync_offset_ms: scottie_sync_offset(138.240),
|
||||||
|
},
|
||||||
|
SstvMode {
|
||||||
|
vis: 76,
|
||||||
|
name: "Scottie DX",
|
||||||
|
width: 320,
|
||||||
|
height: 256,
|
||||||
|
line_ms: 1050.3,
|
||||||
|
segments: SCOTTIE_DX,
|
||||||
|
color: ColorModel::Rgb,
|
||||||
|
lines_per_transmission: 1,
|
||||||
|
sync_offset_ms: scottie_sync_offset(345.6),
|
||||||
|
},
|
||||||
|
SstvMode {
|
||||||
|
vis: 93,
|
||||||
|
name: "PD50",
|
||||||
|
width: 320,
|
||||||
|
height: 256,
|
||||||
|
line_ms: 388.16,
|
||||||
|
segments: PD_50,
|
||||||
|
color: ColorModel::YCrCbPaired,
|
||||||
|
lines_per_transmission: 2,
|
||||||
|
sync_offset_ms: 0.0,
|
||||||
|
},
|
||||||
|
SstvMode {
|
||||||
|
vis: 94,
|
||||||
|
name: "PD290",
|
||||||
|
width: 800,
|
||||||
|
height: 616,
|
||||||
|
line_ms: 937.28,
|
||||||
|
segments: PD_290,
|
||||||
|
color: ColorModel::YCrCbPaired,
|
||||||
|
lines_per_transmission: 2,
|
||||||
|
sync_offset_ms: 0.0,
|
||||||
|
},
|
||||||
|
SstvMode {
|
||||||
|
vis: 95,
|
||||||
|
name: "PD120",
|
||||||
|
width: 640,
|
||||||
|
height: 496,
|
||||||
|
line_ms: 508.48,
|
||||||
|
segments: PD_120,
|
||||||
|
color: ColorModel::YCrCbPaired,
|
||||||
|
lines_per_transmission: 2,
|
||||||
|
sync_offset_ms: 0.0,
|
||||||
|
},
|
||||||
|
SstvMode {
|
||||||
|
vis: 96,
|
||||||
|
name: "PD180",
|
||||||
|
width: 640,
|
||||||
|
height: 496,
|
||||||
|
line_ms: 754.24,
|
||||||
|
segments: PD_180,
|
||||||
|
color: ColorModel::YCrCbPaired,
|
||||||
|
lines_per_transmission: 2,
|
||||||
|
sync_offset_ms: 0.0,
|
||||||
|
},
|
||||||
|
SstvMode {
|
||||||
|
vis: 97,
|
||||||
|
name: "PD240",
|
||||||
|
width: 640,
|
||||||
|
height: 496,
|
||||||
|
line_ms: 1000.768,
|
||||||
|
segments: PD_240,
|
||||||
|
color: ColorModel::YCrCbPaired,
|
||||||
|
lines_per_transmission: 2,
|
||||||
|
sync_offset_ms: 0.0,
|
||||||
|
},
|
||||||
|
SstvMode {
|
||||||
|
vis: 98,
|
||||||
|
name: "PD160",
|
||||||
|
width: 512,
|
||||||
|
height: 400,
|
||||||
|
line_ms: 804.416,
|
||||||
|
segments: PD_160,
|
||||||
|
color: ColorModel::YCrCbPaired,
|
||||||
|
lines_per_transmission: 2,
|
||||||
|
sync_offset_ms: 0.0,
|
||||||
|
},
|
||||||
|
SstvMode {
|
||||||
|
vis: 99,
|
||||||
|
name: "PD90",
|
||||||
|
width: 320,
|
||||||
|
height: 256,
|
||||||
|
line_ms: 703.04,
|
||||||
|
segments: PD_90,
|
||||||
|
color: ColorModel::YCrCbPaired,
|
||||||
|
lines_per_transmission: 2,
|
||||||
|
sync_offset_ms: 0.0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Look a mode up by the VIS code that announced it.
|
||||||
|
pub fn mode_for_vis(vis: u8) -> Option<&'static SstvMode> {
|
||||||
|
MODES.iter().find(|mode| mode.vis == vis)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map an instantaneous frequency to an 8-bit level: 1500 Hz is black, 2300 Hz
|
||||||
|
/// white. Frequencies outside the band clamp rather than wrap, so a sync pulse
|
||||||
|
/// that lands inside a scan reads as black instead of as bright noise.
|
||||||
|
pub fn level_from_hz(hz: f32) -> u8 {
|
||||||
|
let level = (hz - BLACK_HZ) * (255.0 / (WHITE_HZ - BLACK_HZ));
|
||||||
|
level.clamp(0.0, 255.0).round() as u8
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// The segment list and the published line time are two statements of the
|
||||||
|
// same fact, entered by hand from the specification. If a digit is wrong in
|
||||||
|
// one it is unlikely to be wrong identically in the other.
|
||||||
|
#[test]
|
||||||
|
fn segments_add_up_to_the_published_line_time() {
|
||||||
|
for mode in MODES {
|
||||||
|
let sum = mode.segments_ms();
|
||||||
|
assert!(
|
||||||
|
(sum - mode.line_ms).abs() < 0.001,
|
||||||
|
"{}: segments total {:.4} ms, line time says {:.4} ms",
|
||||||
|
mode.name,
|
||||||
|
sum,
|
||||||
|
mode.line_ms,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vis_codes_are_unique_and_resolvable() {
|
||||||
|
for mode in MODES {
|
||||||
|
assert_eq!(mode_for_vis(mode.vis).map(|m| m.name), Some(mode.name));
|
||||||
|
}
|
||||||
|
let mut codes: Vec<u8> = MODES.iter().map(|m| m.vis).collect();
|
||||||
|
codes.sort_unstable();
|
||||||
|
let count = codes.len();
|
||||||
|
codes.dedup();
|
||||||
|
assert_eq!(codes.len(), count, "two modes claim the same VIS code");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_mode_scans_enough_channels_for_its_colour_model() {
|
||||||
|
for mode in MODES {
|
||||||
|
let scans = mode.scans();
|
||||||
|
let expected = match mode.color {
|
||||||
|
ColorModel::Rgb => 3,
|
||||||
|
ColorModel::YCrCbAlternating => 2,
|
||||||
|
ColorModel::YCrCb => 3,
|
||||||
|
ColorModel::YCrCbPaired => 4,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
scans.len(),
|
||||||
|
expected,
|
||||||
|
"{} has {} scans",
|
||||||
|
mode.name,
|
||||||
|
scans.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frame durations are what operators know these modes by — the number in
|
||||||
|
// the name is the number of seconds.
|
||||||
|
#[test]
|
||||||
|
fn frame_durations_match_the_names() {
|
||||||
|
for (vis, secs) in [(8u8, 36.0), (12, 72.0), (93, 50.0), (99, 90.0), (95, 126.0)] {
|
||||||
|
let mode = mode_for_vis(vis).expect("mode in table");
|
||||||
|
let actual = mode.frame_secs();
|
||||||
|
assert!(
|
||||||
|
(actual - secs).abs() < 1.5,
|
||||||
|
"{} takes {:.1} s, expected about {:.0} s",
|
||||||
|
mode.name,
|
||||||
|
actual,
|
||||||
|
secs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn levels_span_black_to_white_and_clamp_outside() {
|
||||||
|
assert_eq!(level_from_hz(BLACK_HZ), 0);
|
||||||
|
assert_eq!(level_from_hz(WHITE_HZ), 255);
|
||||||
|
assert_eq!(level_from_hz(1900.0), 128);
|
||||||
|
assert_eq!(level_from_hz(SYNC_HZ), 0, "a sync pulse must read as black");
|
||||||
|
assert_eq!(level_from_hz(3000.0), 255);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
//! VIS header detection.
|
||||||
|
//!
|
||||||
|
//! Every transmission announces its mode in a fixed preamble:
|
||||||
|
//!
|
||||||
|
//! | Part | Tone | Duration |
|
||||||
|
//! |------|------|----------|
|
||||||
|
//! | Leader | 1900 Hz | 300 ms |
|
||||||
|
//! | Break | 1200 Hz | 10 ms |
|
||||||
|
//! | Leader | 1900 Hz | 300 ms |
|
||||||
|
//! | Start bit | 1200 Hz | 30 ms |
|
||||||
|
//! | 7 data bits, LSB first | 1100 Hz = 1, 1300 Hz = 0 | 30 ms each |
|
||||||
|
//! | Even parity | as above | 30 ms |
|
||||||
|
//! | Stop bit | 1200 Hz | 30 ms |
|
||||||
|
//!
|
||||||
|
//! The detector looks for the start bit standing behind a leader, reads the
|
||||||
|
//! eight bits that follow, and checks the parity. Parity is the only integrity
|
||||||
|
//! check the header has, so a code that fails it is discarded rather than
|
||||||
|
//! guessed at — decoding 114 seconds of Martin M1 as Scottie DX produces a
|
||||||
|
//! convincing-looking image of nothing.
|
||||||
|
|
||||||
|
/// Tone durations, in milliseconds.
|
||||||
|
const BIT_MS: f64 = 30.0;
|
||||||
|
const LEADER_MS: f64 = 300.0;
|
||||||
|
|
||||||
|
/// How far a tone may sit from its nominal frequency and still be recognised.
|
||||||
|
/// Wide enough for a rig tuned a little off, narrow enough that 1100, 1200,
|
||||||
|
/// 1300 and 1900 Hz stay distinct.
|
||||||
|
const TONE_TOLERANCE_HZ: f32 = 60.0;
|
||||||
|
|
||||||
|
const LEADER_HZ: f32 = 1900.0;
|
||||||
|
const START_HZ: f32 = 1200.0;
|
||||||
|
const ONE_HZ: f32 = 1100.0;
|
||||||
|
const ZERO_HZ: f32 = 1300.0;
|
||||||
|
|
||||||
|
/// A VIS header found in the stream.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct VisHit {
|
||||||
|
/// The code, which names the mode.
|
||||||
|
pub code: u8,
|
||||||
|
/// Index just past the stop bit: where the image itself begins.
|
||||||
|
pub image_start: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn near(freq: f32, target: f32) -> bool {
|
||||||
|
(freq - target).abs() <= TONE_TOLERANCE_HZ
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mean frequency over the middle 60% of a bit cell, which keeps the filter's
|
||||||
|
/// transitions at either edge out of the measurement.
|
||||||
|
fn bit_frequency(freqs: &[f32], start: f64, samples_per_bit: f64) -> Option<f32> {
|
||||||
|
let from = (start + samples_per_bit * 0.2).round() as usize;
|
||||||
|
let to = (start + samples_per_bit * 0.8).round() as usize;
|
||||||
|
if to <= from || to > freqs.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let window = &freqs[from..to];
|
||||||
|
Some(window.iter().sum::<f32>() / window.len() as f32)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search `freqs` for a VIS header, starting at `from`.
|
||||||
|
///
|
||||||
|
/// Returns the first header whose parity checks out. `freqs` is instantaneous
|
||||||
|
/// frequency in Hz, one entry per audio sample.
|
||||||
|
pub fn find_vis(freqs: &[f32], sample_rate: u32, from: usize) -> Option<VisHit> {
|
||||||
|
let sr = f64::from(sample_rate);
|
||||||
|
// Header tones are 10 ms at the shortest, so a millisecond of averaging
|
||||||
|
// costs nothing and takes the demodulator's ripple — ±95 Hz at 1200 Hz —
|
||||||
|
// out of tones that are 100 Hz apart.
|
||||||
|
let smoothed = crate::demod::smooth(freqs, (sr / 1000.0).round() as usize);
|
||||||
|
let freqs = smoothed.as_slice();
|
||||||
|
let samples_per_bit = BIT_MS / 1000.0 * sr;
|
||||||
|
let leader_samples = (LEADER_MS / 1000.0 * sr) as usize;
|
||||||
|
// The start bit must be at least this long to be one.
|
||||||
|
let min_start_run = (samples_per_bit * 0.7) as usize;
|
||||||
|
|
||||||
|
// A leader has to precede the start bit. Half of one is enough evidence,
|
||||||
|
// and asking for less than the full 300 ms means the search still works on
|
||||||
|
// a buffer that begins part-way through the header.
|
||||||
|
let leader_needed = leader_samples / 2;
|
||||||
|
|
||||||
|
// The bits themselves may run to the very end of what has arrived so far;
|
||||||
|
// reading them is what decides whether there is enough, not this bound.
|
||||||
|
let mut i = from.max(leader_needed);
|
||||||
|
while i < freqs.len() {
|
||||||
|
if !near(freqs[i], START_HZ) {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Measure the run of start tone.
|
||||||
|
let mut run = 0usize;
|
||||||
|
while i + run < freqs.len() && near(freqs[i + run], START_HZ) {
|
||||||
|
run += 1;
|
||||||
|
}
|
||||||
|
if run < min_start_run {
|
||||||
|
i += run.max(1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// What came before it: the leader. Sampled rather than scanned in
|
||||||
|
// full, since only its identity matters, not its exact length.
|
||||||
|
let leader_from = i - leader_needed;
|
||||||
|
let leader_hits = freqs[leader_from..i]
|
||||||
|
.iter()
|
||||||
|
.step_by(16)
|
||||||
|
.filter(|&&f| near(f, LEADER_HZ))
|
||||||
|
.count();
|
||||||
|
let leader_total = freqs[leader_from..i].iter().step_by(16).count();
|
||||||
|
if leader_total == 0 || (leader_hits as f64) < 0.7 * leader_total as f64 {
|
||||||
|
i += run;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bits follow the start bit, which the run just measured. Use the run's
|
||||||
|
// own end rather than a nominal offset, so a start bit stretched or
|
||||||
|
// clipped by the filter does not shift every bit after it.
|
||||||
|
let bits_start = (i + run) as f64;
|
||||||
|
let mut bits = [false; 8];
|
||||||
|
let mut readable = true;
|
||||||
|
for (index, bit) in bits.iter_mut().enumerate() {
|
||||||
|
let at = bits_start + samples_per_bit * index as f64;
|
||||||
|
match bit_frequency(freqs, at, samples_per_bit) {
|
||||||
|
Some(freq) if near(freq, ONE_HZ) => *bit = true,
|
||||||
|
Some(freq) if near(freq, ZERO_HZ) => *bit = false,
|
||||||
|
_ => {
|
||||||
|
readable = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !readable {
|
||||||
|
i += run;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seven data bits, LSB first, then even parity over them.
|
||||||
|
let code = bits[..7]
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.fold(0u8, |acc, (index, &set)| acc | (u8::from(set) << index));
|
||||||
|
let ones = bits[..7].iter().filter(|&&b| b).count() + usize::from(bits[7]);
|
||||||
|
if ones % 2 != 0 {
|
||||||
|
i += run;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Past the stop bit is the image.
|
||||||
|
let image_start = (bits_start + samples_per_bit * 9.0).round() as usize;
|
||||||
|
return Some(VisHit { code, image_start });
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::encode::{vis_tones, Tone};
|
||||||
|
|
||||||
|
fn freqs_from_tones(tones: &[Tone], sample_rate: u32) -> Vec<f32> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for tone in tones {
|
||||||
|
let samples = (tone.ms / 1000.0 * f64::from(sample_rate)).round() as usize;
|
||||||
|
out.extend(std::iter::repeat_n(tone.hz, samples));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reads_every_code_the_mode_table_knows() {
|
||||||
|
for mode in crate::mode::MODES {
|
||||||
|
let freqs = freqs_from_tones(&vis_tones(mode.vis), 48_000);
|
||||||
|
let hit = find_vis(&freqs, 48_000, 0)
|
||||||
|
.unwrap_or_else(|| panic!("{} header not found", mode.name));
|
||||||
|
assert_eq!(
|
||||||
|
hit.code, mode.vis,
|
||||||
|
"{} decoded as VIS {}",
|
||||||
|
mode.name, hit.code
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_image_starts_after_the_stop_bit() {
|
||||||
|
let sample_rate = 48_000;
|
||||||
|
let freqs = freqs_from_tones(&vis_tones(44), sample_rate);
|
||||||
|
let hit = find_vis(&freqs, sample_rate, 0).expect("header");
|
||||||
|
// Header is 300 + 10 + 300 ms of leader and break, then ten 30 ms bits.
|
||||||
|
let expected = ((300.0 + 10.0 + 300.0 + 300.0) / 1000.0 * f64::from(sample_rate)) as usize;
|
||||||
|
let slack = sample_rate as usize / 100; // 10 ms
|
||||||
|
assert!(
|
||||||
|
hit.image_start.abs_diff(expected) < slack,
|
||||||
|
"image starts at {}, expected about {expected}",
|
||||||
|
hit.image_start,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_header_with_broken_parity_is_not_a_header() {
|
||||||
|
let sample_rate = 48_000;
|
||||||
|
let mut tones = vis_tones(44);
|
||||||
|
// Flip the parity bit alone: seven data bits still say Martin M1, but
|
||||||
|
// nothing now vouches for them.
|
||||||
|
let parity = tones.len() - 2;
|
||||||
|
tones[parity].hz = if tones[parity].hz == ONE_HZ {
|
||||||
|
ZERO_HZ
|
||||||
|
} else {
|
||||||
|
ONE_HZ
|
||||||
|
};
|
||||||
|
let freqs = freqs_from_tones(&tones, sample_rate);
|
||||||
|
assert_eq!(find_vis(&freqs, sample_rate, 0), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tones_without_a_leader_are_not_a_header() {
|
||||||
|
let sample_rate = 48_000;
|
||||||
|
let mut tones = vis_tones(44);
|
||||||
|
// Same bits, but the leader before them is a pixel-band tone — which is
|
||||||
|
// what a passing image looks like.
|
||||||
|
tones[0].hz = 2000.0;
|
||||||
|
tones[2].hz = 2000.0;
|
||||||
|
let freqs = freqs_from_tones(&tones, sample_rate);
|
||||||
|
assert_eq!(find_vis(&freqs, sample_rate, 0), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_header_part_way_into_the_buffer_is_still_found() {
|
||||||
|
let sample_rate = 48_000;
|
||||||
|
let mut freqs = vec![1750.0f32; sample_rate as usize]; // a second of picture
|
||||||
|
freqs.extend(freqs_from_tones(&vis_tones(60), sample_rate));
|
||||||
|
let hit = find_vis(&freqs, sample_rate, 0).expect("header");
|
||||||
|
assert_eq!(hit.code, 60);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
//! Decode what was encoded, and compare the pictures.
|
||||||
|
//!
|
||||||
|
//! A decoder for a picture format can only really be tested against a picture.
|
||||||
|
//! These tests build a test card, transmit it in each mode through the
|
||||||
|
//! encoder, and hold the decoder to what comes back — pixel by pixel, with a
|
||||||
|
//! tolerance that accounts for the round trip through frequency and, for the
|
||||||
|
//! colour modes, through a chroma channel at half the width.
|
||||||
|
//!
|
||||||
|
//! What this does not test is the timing table itself: the encoder reads the
|
||||||
|
//! same numbers as the decoder, so a wrong line duration would cancel out.
|
||||||
|
//! That is checked in `mode.rs` against the published line times instead.
|
||||||
|
|
||||||
|
use trx_sstv::encode::{encode, Frame};
|
||||||
|
use trx_sstv::mode::mode_for_vis;
|
||||||
|
use trx_sstv::{SstvConfig, SstvDecoder, SstvEvent, SstvImage};
|
||||||
|
|
||||||
|
const SAMPLE_RATE: u32 = 48_000;
|
||||||
|
|
||||||
|
/// A test card with something for every part of the decoder to get wrong:
|
||||||
|
/// vertical colour bars catch channels swapped or shifted, the horizontal
|
||||||
|
/// gradient catches a line-timing drift, and the corner blocks catch a picture
|
||||||
|
/// that arrives upside down or mirrored.
|
||||||
|
fn test_card(width: usize, height: usize) -> Vec<u8> {
|
||||||
|
let mut rgb = vec![0u8; width * height * 3];
|
||||||
|
let bars: [(u8, u8, u8); 8] = [
|
||||||
|
(255, 255, 255),
|
||||||
|
(255, 255, 0),
|
||||||
|
(0, 255, 255),
|
||||||
|
(0, 255, 0),
|
||||||
|
(255, 0, 255),
|
||||||
|
(255, 0, 0),
|
||||||
|
(0, 0, 255),
|
||||||
|
(0, 0, 0),
|
||||||
|
];
|
||||||
|
for y in 0..height {
|
||||||
|
for x in 0..width {
|
||||||
|
let at = (y * width + x) * 3;
|
||||||
|
let (r, g, b) = if y < height / 2 {
|
||||||
|
bars[x * bars.len() / width]
|
||||||
|
} else {
|
||||||
|
let ramp = (x * 255 / width.max(1)) as u8;
|
||||||
|
let down = (y * 255 / height.max(1)) as u8;
|
||||||
|
(ramp, down, 255 - ramp)
|
||||||
|
};
|
||||||
|
rgb[at] = r;
|
||||||
|
rgb[at + 1] = g;
|
||||||
|
rgb[at + 2] = b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Corner marks: red top-left, blue bottom-right.
|
||||||
|
for y in 0..height.min(8) {
|
||||||
|
for x in 0..width.min(8) {
|
||||||
|
let at = (y * width + x) * 3;
|
||||||
|
rgb[at] = 255;
|
||||||
|
rgb[at + 1] = 0;
|
||||||
|
rgb[at + 2] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for y in height.saturating_sub(8)..height {
|
||||||
|
for x in width.saturating_sub(8)..width {
|
||||||
|
let at = (y * width + x) * 3;
|
||||||
|
rgb[at] = 0;
|
||||||
|
rgb[at + 1] = 0;
|
||||||
|
rgb[at + 2] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rgb
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run audio through the decoder in blocks the size a sound card delivers.
|
||||||
|
///
|
||||||
|
/// A little silence is fed after the signal, as a receiver that keeps
|
||||||
|
/// listening supplies: the demodulator is a filter, so the last millisecond of
|
||||||
|
/// any transmission needs the samples after it before it can be read.
|
||||||
|
fn decode(audio: &[f32]) -> (Vec<SstvImage>, usize) {
|
||||||
|
let mut tail = audio.to_vec();
|
||||||
|
tail.extend(std::iter::repeat_n(0.0, SAMPLE_RATE as usize / 20));
|
||||||
|
let audio = tail.as_slice();
|
||||||
|
|
||||||
|
let mut decoder = SstvDecoder::new(SAMPLE_RATE, SstvConfig::default());
|
||||||
|
let mut images = Vec::new();
|
||||||
|
let mut rows = 0;
|
||||||
|
for block in audio.chunks(1024) {
|
||||||
|
for event in decoder.process_samples(block) {
|
||||||
|
match event {
|
||||||
|
SstvEvent::Row { .. } => rows += 1,
|
||||||
|
SstvEvent::Complete(image) => images.push(image),
|
||||||
|
SstvEvent::Started { .. } => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(images, rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mean absolute error per colour channel between two same-sized images.
|
||||||
|
fn mean_error(a: &[u8], b: &[u8]) -> f64 {
|
||||||
|
assert_eq!(a.len(), b.len());
|
||||||
|
let total: u64 = a
|
||||||
|
.iter()
|
||||||
|
.zip(b)
|
||||||
|
.map(|(x, y)| u64::from(x.abs_diff(*y)))
|
||||||
|
.sum();
|
||||||
|
total as f64 / a.len() as f64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Error over the part of the picture away from channel edges, where a decoder
|
||||||
|
/// that is a pixel out on a hard colour boundary would otherwise dominate.
|
||||||
|
fn interior_error(mode_width: usize, height: usize, sent: &[u8], got: &[u8]) -> f64 {
|
||||||
|
let mut total = 0u64;
|
||||||
|
let mut count = 0u64;
|
||||||
|
for y in 2..height.saturating_sub(2) {
|
||||||
|
for x in 4..mode_width.saturating_sub(4) {
|
||||||
|
// Skip the columns where the bars change, which is where a
|
||||||
|
// half-pixel timing difference shows up as a whole-colour error.
|
||||||
|
if x % (mode_width / 8) < 3 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let at = (y * mode_width + x) * 3;
|
||||||
|
for channel in 0..3 {
|
||||||
|
total += u64::from(sent[at + channel].abs_diff(got[at + channel]));
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
total as f64 / count.max(1) as f64
|
||||||
|
}
|
||||||
|
|
||||||
|
fn round_trip(vis: u8, tolerance: f64) {
|
||||||
|
let mode = mode_for_vis(vis).expect("mode in table");
|
||||||
|
let width = usize::from(mode.width);
|
||||||
|
let height = usize::from(mode.height);
|
||||||
|
let sent = test_card(width, height);
|
||||||
|
let frame = Frame {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
rgb: &sent,
|
||||||
|
};
|
||||||
|
let audio = encode(mode, &frame, SAMPLE_RATE);
|
||||||
|
|
||||||
|
let (images, rows) = decode(&audio);
|
||||||
|
assert_eq!(
|
||||||
|
images.len(),
|
||||||
|
1,
|
||||||
|
"{}: expected one picture, got {}",
|
||||||
|
mode.name,
|
||||||
|
images.len()
|
||||||
|
);
|
||||||
|
let image = &images[0];
|
||||||
|
assert!(
|
||||||
|
image.complete,
|
||||||
|
"{}: reception did not reach the bottom",
|
||||||
|
mode.name
|
||||||
|
);
|
||||||
|
assert_eq!(image.mode, mode.name);
|
||||||
|
assert_eq!(
|
||||||
|
image.lines, mode.height,
|
||||||
|
"{}: {} of {} lines",
|
||||||
|
mode.name, image.lines, mode.height
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rows, height,
|
||||||
|
"{}: emitted {rows} rows for {height} lines",
|
||||||
|
mode.name
|
||||||
|
);
|
||||||
|
|
||||||
|
let error = interior_error(width, height, &sent, &image.rgb);
|
||||||
|
assert!(
|
||||||
|
error < tolerance,
|
||||||
|
"{}: mean error {error:.1} levels, tolerance {tolerance:.1}",
|
||||||
|
mode.name,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn martin_m1_round_trips() {
|
||||||
|
round_trip(44, 6.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn martin_m2_round_trips() {
|
||||||
|
round_trip(40, 8.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scottie_s1_round_trips() {
|
||||||
|
round_trip(60, 6.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scottie_s2_round_trips() {
|
||||||
|
round_trip(56, 8.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wraase_sc2_180_round_trips() {
|
||||||
|
round_trip(55, 6.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The colour-difference modes lose chroma resolution by design, so the bars
|
||||||
|
// bleed into one another at their edges; the tolerance is on the interior.
|
||||||
|
#[test]
|
||||||
|
fn robot_72_round_trips() {
|
||||||
|
round_trip(12, 14.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn robot_36_round_trips() {
|
||||||
|
// One chroma channel per line, the other carried over from the line
|
||||||
|
// before, so alternate lines are a line stale in one channel.
|
||||||
|
round_trip(8, 26.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pd90_round_trips() {
|
||||||
|
round_trip(99, 14.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pd120_round_trips() {
|
||||||
|
round_trip(95, 14.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Silence before and after is the normal case — a receiver is not started at
|
||||||
|
/// the instant the transmission does.
|
||||||
|
#[test]
|
||||||
|
fn survives_silence_around_the_transmission() {
|
||||||
|
let mode = mode_for_vis(44).expect("Martin M1");
|
||||||
|
let (width, height) = (usize::from(mode.width), usize::from(mode.height));
|
||||||
|
let sent = test_card(width, height);
|
||||||
|
let frame = Frame {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
rgb: &sent,
|
||||||
|
};
|
||||||
|
let mut audio = vec![0.0f32; SAMPLE_RATE as usize * 2];
|
||||||
|
audio.extend(encode(mode, &frame, SAMPLE_RATE));
|
||||||
|
audio.extend(std::iter::repeat_n(0.0, SAMPLE_RATE as usize));
|
||||||
|
|
||||||
|
let (images, _) = decode(&audio);
|
||||||
|
assert_eq!(
|
||||||
|
images.len(),
|
||||||
|
1,
|
||||||
|
"expected one picture from a transmission in silence"
|
||||||
|
);
|
||||||
|
assert!(images[0].complete);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A transmission cut off part-way is what a fade or a shut-down transmitter
|
||||||
|
/// produces. The lines that did arrive are worth keeping.
|
||||||
|
#[test]
|
||||||
|
fn a_truncated_transmission_still_yields_its_lines() {
|
||||||
|
let mode = mode_for_vis(44).expect("Martin M1");
|
||||||
|
let (width, height) = (usize::from(mode.width), usize::from(mode.height));
|
||||||
|
let sent = test_card(width, height);
|
||||||
|
let frame = Frame {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
rgb: &sent,
|
||||||
|
};
|
||||||
|
let full = encode(mode, &frame, SAMPLE_RATE);
|
||||||
|
// Two thirds of the picture, then silence for long enough that the decoder
|
||||||
|
// stops waiting for the rest.
|
||||||
|
let mut audio = full[..full.len() * 2 / 3].to_vec();
|
||||||
|
audio.extend(std::iter::repeat_n(0.0, SAMPLE_RATE as usize * 5));
|
||||||
|
|
||||||
|
let (images, _) = decode(&audio);
|
||||||
|
assert_eq!(
|
||||||
|
images.len(),
|
||||||
|
1,
|
||||||
|
"a cut-off transmission produced no picture"
|
||||||
|
);
|
||||||
|
let image = &images[0];
|
||||||
|
assert!(
|
||||||
|
!image.complete,
|
||||||
|
"a two-thirds transmission reported as complete"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
image.lines > mode.height / 2 && image.lines < mode.height,
|
||||||
|
"{} lines of {} arrived",
|
||||||
|
image.lines,
|
||||||
|
mode.height,
|
||||||
|
);
|
||||||
|
// What did arrive is the top of the picture, and it is right.
|
||||||
|
let rows = usize::from(image.lines).saturating_sub(4);
|
||||||
|
let error = mean_error(&sent[..width * rows * 3], &image.rgb[..width * rows * 3]);
|
||||||
|
assert!(
|
||||||
|
error < 12.0,
|
||||||
|
"the lines that arrived are wrong: mean error {error:.1}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two pictures back to back: the decoder has to finish the first and pick up
|
||||||
|
/// the header of the second.
|
||||||
|
#[test]
|
||||||
|
fn decodes_a_second_transmission_after_the_first() {
|
||||||
|
let mode = mode_for_vis(40).expect("Martin M2");
|
||||||
|
let (width, height) = (usize::from(mode.width), usize::from(mode.height));
|
||||||
|
let sent = test_card(width, height);
|
||||||
|
let frame = Frame {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
rgb: &sent,
|
||||||
|
};
|
||||||
|
let one = encode(mode, &frame, SAMPLE_RATE);
|
||||||
|
|
||||||
|
let mut audio = one.clone();
|
||||||
|
audio.extend(std::iter::repeat_n(0.0, SAMPLE_RATE as usize / 2));
|
||||||
|
audio.extend(one);
|
||||||
|
|
||||||
|
let (images, _) = decode(&audio);
|
||||||
|
assert_eq!(
|
||||||
|
images.len(),
|
||||||
|
2,
|
||||||
|
"expected two pictures, got {}",
|
||||||
|
images.len()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
images.iter().all(|image| image.complete),
|
||||||
|
"a picture did not finish"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Noise on the signal is the normal condition on HF. The picture should
|
||||||
|
/// degrade, not fall apart.
|
||||||
|
#[test]
|
||||||
|
fn decodes_through_noise() {
|
||||||
|
let mode = mode_for_vis(44).expect("Martin M1");
|
||||||
|
let (width, height) = (usize::from(mode.width), usize::from(mode.height));
|
||||||
|
let sent = test_card(width, height);
|
||||||
|
let frame = Frame {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
rgb: &sent,
|
||||||
|
};
|
||||||
|
let clean = encode(mode, &frame, SAMPLE_RATE);
|
||||||
|
|
||||||
|
// Deterministic pseudo-noise at about 20 dB below the signal.
|
||||||
|
let mut seed = 0x5eed_1234u32;
|
||||||
|
let noisy: Vec<f32> = clean
|
||||||
|
.iter()
|
||||||
|
.map(|sample| {
|
||||||
|
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||||
|
let noise = (seed >> 8) as f32 / f32::from(u16::MAX) / 256.0 - 0.5;
|
||||||
|
sample + noise * 0.2
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let (images, _) = decode(&noisy);
|
||||||
|
assert_eq!(images.len(), 1, "noise cost the whole picture");
|
||||||
|
let image = &images[0];
|
||||||
|
assert!(image.complete, "noise cost the bottom of the picture");
|
||||||
|
let error = interior_error(width, height, &sent, &image.rgb);
|
||||||
|
assert!(error < 20.0, "mean error through noise {error:.1} levels");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sound card that plays the signal and the one that records it never
|
||||||
|
/// agree exactly. A part-per-thousand error is far worse than reality and the
|
||||||
|
/// picture should still stand up.
|
||||||
|
#[test]
|
||||||
|
fn tolerates_a_transmitter_clock_that_runs_fast() {
|
||||||
|
let mode = mode_for_vis(44).expect("Martin M1");
|
||||||
|
let (width, height) = (usize::from(mode.width), usize::from(mode.height));
|
||||||
|
let sent = test_card(width, height);
|
||||||
|
let frame = Frame {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
rgb: &sent,
|
||||||
|
};
|
||||||
|
// Encoding at a slightly different rate and decoding at 48 kHz is exactly
|
||||||
|
// a clock error: every duration is stretched by the same factor.
|
||||||
|
let audio = encode(mode, &frame, 48_048);
|
||||||
|
|
||||||
|
let (images, _) = decode(&audio);
|
||||||
|
assert_eq!(images.len(), 1, "a 0.1% clock error cost the picture");
|
||||||
|
let error = interior_error(width, height, &sent, &images[0].rgb);
|
||||||
|
assert!(
|
||||||
|
error < 12.0,
|
||||||
|
"mean error with a fast clock {error:.1} levels"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -274,7 +274,8 @@ mod tests {
|
|||||||
|
|
||||||
// Pseudo-random noise vs gradient — correlation should be low.
|
// Pseudo-random noise vs gradient — correlation should be low.
|
||||||
let noise: Vec<u8> = (0..256)
|
let noise: Vec<u8> = (0..256)
|
||||||
.map(|i| ((i * 1103515245 + 12345) as u32 >> 8 & 0xff) as u8)
|
.map(|i| (i as u32).wrapping_mul(1_103_515_245).wrapping_add(12_345))
|
||||||
|
.map(|value| ((value >> 8) & 0xff) as u8)
|
||||||
.collect();
|
.collect();
|
||||||
let r = asm.correlation_with_last(&noise).expect("r");
|
let r = asm.correlation_with_last(&noise).expect("r");
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -9,9 +9,5 @@ edition = "2021"
|
|||||||
license = "GPL-2.0-or-later"
|
license = "GPL-2.0-or-later"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { workspace = true }
|
|
||||||
toml = { workspace = true }
|
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
tracing-subscriber = { workspace = true }
|
tracing-subscriber = { workspace = true }
|
||||||
dirs = "6"
|
|
||||||
thiserror = "2"
|
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
use serde::de::DeserializeOwned;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
|
||||||
pub enum ConfigError {
|
|
||||||
#[error("Failed to read config file {0}: {1}")]
|
|
||||||
ReadError(PathBuf, String),
|
|
||||||
|
|
||||||
#[error("Failed to parse config file {0}: {1}")]
|
|
||||||
ParseError(PathBuf, String),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the default search paths for `trx-rs.toml`
|
|
||||||
/// (current directory → XDG config → /etc).
|
|
||||||
fn config_search_paths() -> Vec<PathBuf> {
|
|
||||||
let mut paths = vec![PathBuf::from("trx-rs.toml")];
|
|
||||||
if let Some(config_dir) = dirs::config_dir() {
|
|
||||||
paths.push(config_dir.join("trx-rs").join("trx-rs.toml"));
|
|
||||||
}
|
|
||||||
paths.push(PathBuf::from("/etc/trx-rs/trx-rs.toml"));
|
|
||||||
paths
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract and deserialize a named section from a TOML file.
|
|
||||||
///
|
|
||||||
/// Returns `Ok(Some(cfg))` when the section is present and parses cleanly,
|
|
||||||
/// `Ok(None)` when the section is absent, or `Err` on I/O / parse failure.
|
|
||||||
fn load_section_from_file<T: DeserializeOwned>(
|
|
||||||
path: &Path,
|
|
||||||
key: &str,
|
|
||||||
) -> Result<Option<T>, ConfigError> {
|
|
||||||
let content = std::fs::read_to_string(path)
|
|
||||||
.map_err(|e| ConfigError::ReadError(path.to_path_buf(), e.to_string()))?;
|
|
||||||
|
|
||||||
let table: toml::Table = toml::from_str(&content)
|
|
||||||
.map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?;
|
|
||||||
|
|
||||||
let Some(section) = table.get(key) else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Re-serialize the section then parse as T so all serde defaults apply.
|
|
||||||
let section_toml = toml::to_string(section)
|
|
||||||
.map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?;
|
|
||||||
let cfg = toml::from_str::<T>(§ion_toml)
|
|
||||||
.map_err(|e| ConfigError::ParseError(path.to_path_buf(), e.to_string()))?;
|
|
||||||
Ok(Some(cfg))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Trait for loading configuration from a `trx-rs.toml` section.
|
|
||||||
pub trait ConfigFile: Sized + Default + DeserializeOwned {
|
|
||||||
/// Section key in `trx-rs.toml` (e.g. `"trx-server"` or `"trx-client"`).
|
|
||||||
fn section_key() -> &'static str;
|
|
||||||
|
|
||||||
/// Load the section from a specific file path.
|
|
||||||
///
|
|
||||||
/// Returns an error if the file cannot be read, is not valid TOML, or
|
|
||||||
/// does not contain the expected `[<section_key>]` header.
|
|
||||||
fn load_from_file(path: &Path) -> Result<Self, ConfigError> {
|
|
||||||
load_section_from_file::<Self>(path, Self::section_key())?.ok_or_else(|| {
|
|
||||||
ConfigError::ParseError(
|
|
||||||
path.to_path_buf(),
|
|
||||||
format!("missing [{}] section", Self::section_key()),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Search default paths (`trx-rs.toml` in CWD → XDG → /etc) and load
|
|
||||||
/// the first file that contains the expected section.
|
|
||||||
///
|
|
||||||
/// Returns `(config, path_where_found)` or `(Default::default(), None)`
|
|
||||||
/// when no config file is found.
|
|
||||||
fn load_from_default_paths() -> Result<(Self, Option<PathBuf>), ConfigError> {
|
|
||||||
for path in config_search_paths() {
|
|
||||||
if path.exists() {
|
|
||||||
if let Some(cfg) = load_section_from_file::<Self>(&path, Self::section_key())? {
|
|
||||||
return Ok((cfg, Some(path)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok((Self::default(), None))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,12 +2,12 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
pub mod config;
|
//! Shared application helpers.
|
||||||
|
//!
|
||||||
|
//! Configuration types and their loader live in the `trx-config` crate.
|
||||||
|
|
||||||
pub mod logging;
|
pub mod logging;
|
||||||
pub mod shared_config;
|
|
||||||
pub mod util;
|
pub mod util;
|
||||||
|
|
||||||
pub use config::{ConfigError, ConfigFile};
|
|
||||||
pub use logging::init_logging;
|
pub use logging::init_logging;
|
||||||
pub use shared_config::{validate_log_level, validate_tokens};
|
|
||||||
pub use util::normalize_name;
|
pub use util::normalize_name;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ uuid = { workspace = true }
|
|||||||
cpal = "0.15"
|
cpal = "0.15"
|
||||||
opus = "0.3"
|
opus = "0.3"
|
||||||
trx-app = { path = "../trx-app" }
|
trx-app = { path = "../trx-app" }
|
||||||
|
trx-config = { path = "../trx-config" }
|
||||||
trx-core = { path = "../trx-core" }
|
trx-core = { path = "../trx-core" }
|
||||||
trx-protocol = { path = "../trx-protocol" }
|
trx-protocol = { path = "../trx-protocol" }
|
||||||
trx-frontend = { path = "trx-frontend" }
|
trx-frontend = { path = "trx-frontend" }
|
||||||
|
|||||||
@@ -27,11 +27,11 @@ use trx_core::audio::{
|
|||||||
write_vchan_uuid_msg, AudioStreamInfo, AUDIO_MSG_AIS_DECODE, AUDIO_MSG_APRS_DECODE,
|
write_vchan_uuid_msg, AudioStreamInfo, AUDIO_MSG_AIS_DECODE, AUDIO_MSG_APRS_DECODE,
|
||||||
AUDIO_MSG_CW_DECODE, AUDIO_MSG_FT2_DECODE, AUDIO_MSG_FT4_DECODE, AUDIO_MSG_FT8_DECODE,
|
AUDIO_MSG_CW_DECODE, AUDIO_MSG_FT2_DECODE, AUDIO_MSG_FT4_DECODE, AUDIO_MSG_FT8_DECODE,
|
||||||
AUDIO_MSG_HF_APRS_DECODE, AUDIO_MSG_HISTORY_COMPRESSED, AUDIO_MSG_LRPT_IMAGE,
|
AUDIO_MSG_HF_APRS_DECODE, AUDIO_MSG_HISTORY_COMPRESSED, AUDIO_MSG_LRPT_IMAGE,
|
||||||
AUDIO_MSG_LRPT_PROGRESS, AUDIO_MSG_RX_FRAME, AUDIO_MSG_RX_FRAME_CH, AUDIO_MSG_STREAM_INFO,
|
AUDIO_MSG_LRPT_PROGRESS, AUDIO_MSG_RX_FRAME, AUDIO_MSG_RX_FRAME_CH, AUDIO_MSG_SSTV_DECODE,
|
||||||
AUDIO_MSG_TX_FRAME, AUDIO_MSG_VCHAN_ALLOCATED, AUDIO_MSG_VCHAN_BW, AUDIO_MSG_VCHAN_DESTROYED,
|
AUDIO_MSG_SSTV_PROGRESS, AUDIO_MSG_STREAM_INFO, AUDIO_MSG_TX_FRAME, AUDIO_MSG_VCHAN_ALLOCATED,
|
||||||
AUDIO_MSG_VCHAN_FREQ, AUDIO_MSG_VCHAN_MODE, AUDIO_MSG_VCHAN_REMOVE, AUDIO_MSG_VCHAN_SUB,
|
AUDIO_MSG_VCHAN_BW, AUDIO_MSG_VCHAN_DESTROYED, AUDIO_MSG_VCHAN_FREQ, AUDIO_MSG_VCHAN_MODE,
|
||||||
AUDIO_MSG_VCHAN_UNSUB, AUDIO_MSG_VDES_DECODE, AUDIO_MSG_WEFAX_DECODE, AUDIO_MSG_WEFAX_PROGRESS,
|
AUDIO_MSG_VCHAN_REMOVE, AUDIO_MSG_VCHAN_SUB, AUDIO_MSG_VCHAN_UNSUB, AUDIO_MSG_VDES_DECODE,
|
||||||
AUDIO_MSG_WSPR_DECODE,
|
AUDIO_MSG_WEFAX_DECODE, AUDIO_MSG_WEFAX_PROGRESS, AUDIO_MSG_WSPR_DECODE,
|
||||||
};
|
};
|
||||||
use trx_core::decode::DecodedMessage;
|
use trx_core::decode::DecodedMessage;
|
||||||
use trx_frontend::VChanAudioCmd;
|
use trx_frontend::VChanAudioCmd;
|
||||||
@@ -533,7 +533,9 @@ async fn handle_single_rig_connection(
|
|||||||
| AUDIO_MSG_LRPT_IMAGE
|
| AUDIO_MSG_LRPT_IMAGE
|
||||||
| AUDIO_MSG_LRPT_PROGRESS
|
| AUDIO_MSG_LRPT_PROGRESS
|
||||||
| AUDIO_MSG_WEFAX_DECODE
|
| AUDIO_MSG_WEFAX_DECODE
|
||||||
| AUDIO_MSG_WEFAX_PROGRESS,
|
| AUDIO_MSG_WEFAX_PROGRESS
|
||||||
|
| AUDIO_MSG_SSTV_DECODE
|
||||||
|
| AUDIO_MSG_SSTV_PROGRESS,
|
||||||
payload,
|
payload,
|
||||||
)) => {
|
)) => {
|
||||||
if let Ok(mut msg) = serde_json::from_slice::<DecodedMessage>(&payload) {
|
if let Ok(mut msg) = serde_json::from_slice::<DecodedMessage>(&payload) {
|
||||||
|
|||||||
+5
-1214
File diff suppressed because it is too large
Load Diff
+147
-13
@@ -20,6 +20,7 @@ use tokio::task::JoinHandle;
|
|||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
|
|
||||||
use trx_app::{init_logging, normalize_name};
|
use trx_app::{init_logging, normalize_name};
|
||||||
|
use trx_config::ConfigFile;
|
||||||
use trx_core::audio::AudioStreamInfo;
|
use trx_core::audio::AudioStreamInfo;
|
||||||
|
|
||||||
use trx_core::decode::DecodedMessage;
|
use trx_core::decode::DecodedMessage;
|
||||||
@@ -50,6 +51,12 @@ struct Cli {
|
|||||||
/// Print example configuration and exit
|
/// Print example configuration and exit
|
||||||
#[arg(long = "print-config")]
|
#[arg(long = "print-config")]
|
||||||
print_config: bool,
|
print_config: bool,
|
||||||
|
/// Treat unknown configuration keys as a fatal error
|
||||||
|
#[arg(long = "strict-config")]
|
||||||
|
strict_config: bool,
|
||||||
|
/// Validate the configuration and exit without starting anything
|
||||||
|
#[arg(long = "check-config")]
|
||||||
|
check_config: bool,
|
||||||
/// Remote server URL (host:port)
|
/// Remote server URL (host:port)
|
||||||
#[arg(short = 'u', long = "url")]
|
#[arg(short = 'u', long = "url")]
|
||||||
url: Option<String>,
|
url: Option<String>,
|
||||||
@@ -74,7 +81,7 @@ struct Cli {
|
|||||||
/// rigctl frontend listen address
|
/// rigctl frontend listen address
|
||||||
#[arg(long = "rigctl-listen")]
|
#[arg(long = "rigctl-listen")]
|
||||||
rigctl_listen: Option<IpAddr>,
|
rigctl_listen: Option<IpAddr>,
|
||||||
/// rigctl frontend listen port
|
/// Deprecated: ignored, use [frontends.rigctl].rig_ports
|
||||||
#[arg(long = "rigctl-port")]
|
#[arg(long = "rigctl-port")]
|
||||||
rigctl_port: Option<u16>,
|
rigctl_port: Option<u16>,
|
||||||
/// JSON TCP frontend listen address
|
/// JSON TCP frontend listen address
|
||||||
@@ -109,6 +116,65 @@ async fn main() -> DynResult<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `--check-config`: report everything wrong with the configuration and exit.
|
||||||
|
///
|
||||||
|
/// Unlike startup, this reports every problem it finds rather than stopping at
|
||||||
|
/// the first, so a config can be fixed in one pass. The file is checked as
|
||||||
|
/// written, without CLI overrides.
|
||||||
|
fn check_config(loaded: &trx_config::ConfigLoad<ClientConfig>) -> DynResult<()> {
|
||||||
|
match &loaded.path {
|
||||||
|
Some(path) => println!("{}", path.display()),
|
||||||
|
None => println!("(no config file found; checking built-in defaults)"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut warnings: Vec<String> = loaded.unknown_keys.iter().map(|k| k.to_string()).collect();
|
||||||
|
warnings.extend(ClientConfig::deprecations(&loaded.present_keys));
|
||||||
|
|
||||||
|
let mut cfg = loaded.config.clone();
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
if let Err(e) = cfg.resolve_secrets(loaded.path.as_deref()) {
|
||||||
|
errors.push(e);
|
||||||
|
}
|
||||||
|
let cfg = &cfg;
|
||||||
|
let remotes = cfg.resolved_remotes();
|
||||||
|
if remotes.is_empty() {
|
||||||
|
warnings.push(
|
||||||
|
"no remotes configured; --url will be required at startup (add [[remotes]] entries)"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
errors.extend(cfg.validate_all());
|
||||||
|
if !remotes.is_empty() {
|
||||||
|
errors.extend(cfg.validate_resolved_all(&remotes));
|
||||||
|
}
|
||||||
|
|
||||||
|
for w in &warnings {
|
||||||
|
println!(" warning: {}", w);
|
||||||
|
}
|
||||||
|
for e in &errors {
|
||||||
|
println!(" error: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.is_empty() {
|
||||||
|
println!(
|
||||||
|
" OK: {} remote(s) configured: {}",
|
||||||
|
remotes.len(),
|
||||||
|
remotes
|
||||||
|
.iter()
|
||||||
|
.map(|r| r.name.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
);
|
||||||
|
if !warnings.is_empty() {
|
||||||
|
println!(" {} warning(s)", warnings.len());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("{} error(s), {} warning(s)", errors.len(), warnings.len()).into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Holds the state needed after async initialization completes.
|
/// Holds the state needed after async initialization completes.
|
||||||
struct AppState {
|
struct AppState {
|
||||||
shutdown_tx: watch::Sender<bool>,
|
shutdown_tx: watch::Sender<bool>,
|
||||||
@@ -135,20 +201,44 @@ async fn async_init() -> DynResult<AppState> {
|
|||||||
std::process::exit(0);
|
std::process::exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
let (cfg, config_path) = if let Some(ref path) = cli.config {
|
let loaded = if let Some(ref path) = cli.config {
|
||||||
let cfg = ClientConfig::load_from_file(path)?;
|
ClientConfig::load_from_file(path)?
|
||||||
(cfg, Some(path.clone()))
|
|
||||||
} else {
|
} else {
|
||||||
ClientConfig::load_from_default_paths()?
|
ClientConfig::load_from_default_paths()?
|
||||||
};
|
};
|
||||||
cfg.validate()
|
let config_path = loaded.path.clone();
|
||||||
.map_err(|e| format!("Invalid client configuration: {}", e))?;
|
|
||||||
|
|
||||||
init_logging(cfg.general.log_level.as_deref());
|
if cli.check_config {
|
||||||
|
match check_config(&loaded) {
|
||||||
|
Ok(()) => std::process::exit(0),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("{}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logging comes up before any config complaint so the warnings are visible.
|
||||||
|
init_logging(loaded.config.general.log_level.as_deref());
|
||||||
|
|
||||||
if let Some(ref path) = config_path {
|
if let Some(ref path) = config_path {
|
||||||
info!("Loaded configuration from {}", path.display());
|
info!("Loaded configuration from {}", path.display());
|
||||||
}
|
}
|
||||||
|
loaded.report_unknown_keys(cli.strict_config)?;
|
||||||
|
loaded.report_deprecations();
|
||||||
|
if cli.rigctl_port.is_some() {
|
||||||
|
tracing::warn!(
|
||||||
|
"--rigctl-port is ignored; give each rig its own listener via \
|
||||||
|
[frontends.rigctl].rig_ports"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut cfg = loaded.config;
|
||||||
|
// Secrets configured as *_file are read before validation, so everything
|
||||||
|
// downstream sees resolved values.
|
||||||
|
cfg.resolve_secrets(config_path.as_deref())?;
|
||||||
|
cfg.validate()
|
||||||
|
.map_err(|e| format!("Invalid client configuration: {}", e))?;
|
||||||
|
|
||||||
frontend_runtime.http_auth.tokens = cfg
|
frontend_runtime.http_auth.tokens = cfg
|
||||||
.frontends
|
.frontends
|
||||||
@@ -162,11 +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.rx_passphrase = cfg.frontends.http.auth.rx_passphrase.clone();
|
frontend_runtime.http_auth.users_file = cfg.frontends.http.auth.users_file.clone();
|
||||||
frontend_runtime.http_auth.control_passphrase =
|
frontend_runtime.http_auth.bootstrap_admin_username =
|
||||||
cfg.frontends.http.auth.control_passphrase.clone();
|
cfg.frontends.http.auth.bootstrap_admin_username.clone();
|
||||||
frontend_runtime.http_auth.tx_access_control_enabled =
|
frontend_runtime.http_auth.bootstrap_admin_password =
|
||||||
cfg.frontends.http.auth.tx_access_control_enabled;
|
cfg.frontends.http.auth.bootstrap_admin_password.clone();
|
||||||
frontend_runtime.http_auth.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 {
|
||||||
@@ -189,6 +279,10 @@ async fn async_init() -> DynResult<AppState> {
|
|||||||
.http
|
.http
|
||||||
.decode_history_retention_min_by_rig
|
.decode_history_retention_min_by_rig
|
||||||
.clone();
|
.clone();
|
||||||
|
frontend_runtime.http_ui.logbook_path = cfg.logbook.path.clone();
|
||||||
|
// The callsign the station is on the air with, which every contact is
|
||||||
|
// logged under and which the operator defaults to.
|
||||||
|
frontend_runtime.http_ui.station_callsign = cfg.general.callsign.clone();
|
||||||
|
|
||||||
// Resolve remote entries: CLI --url > [[remotes]] > legacy [remote] > error
|
// Resolve remote entries: CLI --url > [[remotes]] > legacy [remote] > error
|
||||||
let resolved_remotes: Vec<RemoteEntry> = if let Some(ref url) = cli.url {
|
let resolved_remotes: Vec<RemoteEntry> = if let Some(ref url) = cli.url {
|
||||||
@@ -198,10 +292,14 @@ async fn async_init() -> DynResult<AppState> {
|
|||||||
let token = cli.token.clone().or_else(|| cfg.remote.auth.token.clone());
|
let token = cli.token.clone().or_else(|| cfg.remote.auth.token.clone());
|
||||||
let poll_interval_ms = cli.poll_interval_ms.unwrap_or(cfg.remote.poll_interval_ms);
|
let poll_interval_ms = cli.poll_interval_ms.unwrap_or(cfg.remote.poll_interval_ms);
|
||||||
vec![RemoteEntry {
|
vec![RemoteEntry {
|
||||||
|
spectrum_interval_ms: cfg.remote.spectrum_interval_ms,
|
||||||
name,
|
name,
|
||||||
url: url.clone(),
|
url: url.clone(),
|
||||||
rig_id,
|
rig_id,
|
||||||
auth: config::RemoteAuthConfig { token },
|
auth: config::RemoteAuthConfig {
|
||||||
|
token,
|
||||||
|
token_file: None,
|
||||||
|
},
|
||||||
poll_interval_ms,
|
poll_interval_ms,
|
||||||
}]
|
}]
|
||||||
} else {
|
} else {
|
||||||
@@ -262,6 +360,30 @@ async fn async_init() -> DynResult<AppState> {
|
|||||||
.http_json_listen
|
.http_json_listen
|
||||||
.unwrap_or(cfg.frontends.http_json.listen);
|
.unwrap_or(cfg.frontends.http_json.listen);
|
||||||
let http_json_port = cli.http_json_port.unwrap_or(cfg.frontends.http_json.port);
|
let http_json_port = cli.http_json_port.unwrap_or(cfg.frontends.http_json.port);
|
||||||
|
|
||||||
|
// Fold the CLI overrides back into the config so validation and the
|
||||||
|
// frontends agree on what is about to be bound.
|
||||||
|
cfg.frontends.http.enabled = frontends.iter().any(|f| f == "http");
|
||||||
|
cfg.frontends.rigctl.enabled = frontends.iter().any(|f| f == "rigctl");
|
||||||
|
cfg.frontends.http_json.enabled = frontends.iter().any(|f| f == "httpjson");
|
||||||
|
cfg.frontends.http.listen = http_listen;
|
||||||
|
cfg.frontends.http.port = http_port;
|
||||||
|
cfg.frontends.rigctl.listen = rigctl_listen;
|
||||||
|
cfg.frontends.http_json.listen = http_json_listen;
|
||||||
|
cfg.frontends.http_json.port = http_json_port;
|
||||||
|
|
||||||
|
// Second validation phase: the per-rig maps are keyed by remote short name,
|
||||||
|
// so they can only be checked once the remote list is final.
|
||||||
|
if cli.url.is_none() {
|
||||||
|
cfg.validate_resolved(&resolved_remotes)
|
||||||
|
.map_err(|e| format!("Invalid client configuration: {}", e))?;
|
||||||
|
} else {
|
||||||
|
// --url replaces the configured remotes outright, so only the socket
|
||||||
|
// checks still apply.
|
||||||
|
trx_config::shared::check_socket_conflicts(&cfg.bound_sockets())
|
||||||
|
.map_err(|e| format!("Invalid client configuration: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
let callsign = cli
|
let callsign = cli
|
||||||
.callsign
|
.callsign
|
||||||
.clone()
|
.clone()
|
||||||
@@ -366,6 +488,13 @@ async fn async_init() -> DynResult<AppState> {
|
|||||||
.map(|e| e.poll_interval_ms)
|
.map(|e| e.poll_interval_ms)
|
||||||
.min()
|
.min()
|
||||||
.unwrap_or(750);
|
.unwrap_or(750);
|
||||||
|
// Entries sharing a server share its connections, so the most frequent
|
||||||
|
// request wins: whoever wants spectrum fastest sets the rate.
|
||||||
|
let spectrum_interval = entries
|
||||||
|
.iter()
|
||||||
|
.map(|e| e.spectrum_interval_ms)
|
||||||
|
.min()
|
||||||
|
.unwrap_or_else(|| remote_client::DEFAULT_SPECTRUM_INTERVAL.as_millis() as u64);
|
||||||
|
|
||||||
let (server_tx, server_rx) = mpsc::channel::<RigRequest>(RIG_TASK_CHANNEL_BUFFER);
|
let (server_tx, server_rx) = mpsc::channel::<RigRequest>(RIG_TASK_CHANNEL_BUFFER);
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
@@ -379,6 +508,8 @@ async fn async_init() -> DynResult<AppState> {
|
|||||||
known_rigs: frontend_runtime.routing.remote_rigs.clone(),
|
known_rigs: frontend_runtime.routing.remote_rigs.clone(),
|
||||||
rig_states: frontend_runtime.routing.rig_states.clone(),
|
rig_states: frontend_runtime.routing.rig_states.clone(),
|
||||||
poll_interval: Duration::from_millis(poll_interval),
|
poll_interval: Duration::from_millis(poll_interval),
|
||||||
|
spectrum_interval: Duration::from_millis(spectrum_interval),
|
||||||
|
spectrum_stream_unsupported: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
spectrum: frontend_runtime.spectrum.sender.clone(),
|
spectrum: frontend_runtime.spectrum.sender.clone(),
|
||||||
rig_spectrums: frontend_runtime.spectrum.per_rig.clone(),
|
rig_spectrums: frontend_runtime.spectrum.per_rig.clone(),
|
||||||
server_connected: frontend_runtime.routing.server_connected.clone(),
|
server_connected: frontend_runtime.routing.server_connected.clone(),
|
||||||
@@ -527,6 +658,9 @@ async fn async_init() -> DynResult<AppState> {
|
|||||||
DecodedMessage::LrptProgress(_) => {}
|
DecodedMessage::LrptProgress(_) => {}
|
||||||
DecodedMessage::Wefax(_) => {}
|
DecodedMessage::Wefax(_) => {}
|
||||||
DecodedMessage::WefaxProgress(_) => {}
|
DecodedMessage::WefaxProgress(_) => {}
|
||||||
|
// Pictures replay from their own history, not this one.
|
||||||
|
DecodedMessage::Sstv(_) => {}
|
||||||
|
DecodedMessage::SstvProgress(_) => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+275
-103
@@ -20,35 +20,22 @@ use trx_core::{RigError, RigResult};
|
|||||||
use trx_frontend::{RemoteRigEntry, SharedSpectrum};
|
use trx_frontend::{RemoteRigEntry, SharedSpectrum};
|
||||||
use trx_protocol::rig_command_to_client;
|
use trx_protocol::rig_command_to_client;
|
||||||
use trx_protocol::types::RigEntry;
|
use trx_protocol::types::RigEntry;
|
||||||
use trx_protocol::{ClientCommand, ClientEnvelope, ClientResponse, MeterUpdate};
|
use trx_protocol::{ClientCommand, ClientEnvelope, ClientResponse, MeterUpdate, SpectrumFrame};
|
||||||
|
|
||||||
|
// Endpoint parsing lives in `trx-config` so config validation and the
|
||||||
|
// connection code agree on what a URL means.
|
||||||
|
pub use trx_config::url::{parse_audio_url, parse_remote_url, RemoteEndpoint};
|
||||||
|
|
||||||
const DEFAULT_REMOTE_PORT: u16 = 4530;
|
|
||||||
const DEFAULT_AUDIO_PORT: u16 = 4531;
|
|
||||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
const IO_TIMEOUT: Duration = Duration::from_secs(15);
|
const IO_TIMEOUT: Duration = Duration::from_secs(15);
|
||||||
const SPECTRUM_IO_TIMEOUT: Duration = Duration::from_secs(3);
|
const SPECTRUM_IO_TIMEOUT: Duration = Duration::from_secs(3);
|
||||||
const MAX_JSON_LINE_BYTES: usize = 256 * 1024;
|
const MAX_JSON_LINE_BYTES: usize = 256 * 1024;
|
||||||
const MAX_CONSECUTIVE_POLL_FAILURES: u32 = 3;
|
const MAX_CONSECUTIVE_POLL_FAILURES: u32 = 3;
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
// Default spectrum cadence when a config does not specify one. Both the push
|
||||||
pub struct RemoteEndpoint {
|
// stream and the poll fallback run at the configured rate; see
|
||||||
pub host: String,
|
// `[[remotes]].spectrum_interval_ms`.
|
||||||
pub port: u16,
|
pub const DEFAULT_SPECTRUM_INTERVAL: Duration = Duration::from_millis(50);
|
||||||
}
|
|
||||||
|
|
||||||
impl RemoteEndpoint {
|
|
||||||
pub fn connect_addr(&self) -> String {
|
|
||||||
if self.host.contains(':') && !self.host.starts_with('[') {
|
|
||||||
format!("[{}]:{}", self.host, self.port)
|
|
||||||
} else {
|
|
||||||
format!("{}:{}", self.host, self.port)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep remote spectrum reasonably responsive without returning to the old
|
|
||||||
// timeout churn caused by a much tighter request cadence.
|
|
||||||
const SPECTRUM_POLL_INTERVAL: Duration = Duration::from_millis(50);
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct RemoteClientConfig {
|
pub struct RemoteClientConfig {
|
||||||
@@ -57,6 +44,12 @@ pub struct RemoteClientConfig {
|
|||||||
pub selected_rig_id: Arc<Mutex<Option<String>>>,
|
pub selected_rig_id: Arc<Mutex<Option<String>>>,
|
||||||
pub known_rigs: Arc<Mutex<Vec<RemoteRigEntry>>>,
|
pub known_rigs: Arc<Mutex<Vec<RemoteRigEntry>>>,
|
||||||
pub poll_interval: Duration,
|
pub poll_interval: Duration,
|
||||||
|
/// How often spectrum frames are wanted. Drives the poll fallback and is
|
||||||
|
/// the rate the client asks the server to push at.
|
||||||
|
pub spectrum_interval: Duration,
|
||||||
|
/// Set once a server has rejected `SubscribeSpectrum`, so later
|
||||||
|
/// connections to it go straight to polling instead of asking again.
|
||||||
|
pub spectrum_stream_unsupported: Arc<AtomicBool>,
|
||||||
/// Spectrum watch sender; spectrum task publishes here, SSE clients subscribe.
|
/// Spectrum watch sender; spectrum task publishes here, SSE clients subscribe.
|
||||||
pub spectrum: Arc<watch::Sender<SharedSpectrum>>,
|
pub spectrum: Arc<watch::Sender<SharedSpectrum>>,
|
||||||
/// Shared flag: `true` while a TCP connection to trx-server is active.
|
/// Shared flag: `true` while a TCP connection to trx-server is active.
|
||||||
@@ -505,6 +498,100 @@ async fn send_get_sat_passes_on(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What ended a spectrum stream attempt.
|
||||||
|
enum SpectrumStreamOutcome {
|
||||||
|
/// The stream ran and is over; the connection is spent.
|
||||||
|
Finished,
|
||||||
|
/// The server rejected the subscription. The connection is still usable,
|
||||||
|
/// so the caller can poll on it.
|
||||||
|
Unsupported,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subscribe to the server's spectrum push for one rig and publish frames as
|
||||||
|
/// they arrive.
|
||||||
|
///
|
||||||
|
/// The server answers either with frames or, when it is too old to know the
|
||||||
|
/// command, with an error response — which leaves the connection usable for
|
||||||
|
/// polling, so falling back costs no reconnect.
|
||||||
|
async fn run_spectrum_stream(
|
||||||
|
config: &RemoteClientConfig,
|
||||||
|
writer: &mut (impl AsyncWriteExt + Unpin),
|
||||||
|
reader: &mut (impl AsyncBufRead + Unpin),
|
||||||
|
short_name: &str,
|
||||||
|
shutdown_rx: &mut watch::Receiver<bool>,
|
||||||
|
) -> RigResult<SpectrumStreamOutcome> {
|
||||||
|
let envelope = build_envelope(
|
||||||
|
config,
|
||||||
|
ClientCommand::SubscribeSpectrum,
|
||||||
|
Some(short_name.to_string()),
|
||||||
|
);
|
||||||
|
let mut payload = serde_json::to_string(&envelope)
|
||||||
|
.map_err(|e| RigError::communication(format!("JSON serialize failed: {e}")))?;
|
||||||
|
payload.push('\n');
|
||||||
|
time::timeout(SPECTRUM_IO_TIMEOUT, writer.write_all(payload.as_bytes()))
|
||||||
|
.await
|
||||||
|
.map_err(|_| RigError::communication("spectrum subscribe write timed out".to_string()))?
|
||||||
|
.map_err(|e| RigError::communication(format!("spectrum subscribe write failed: {e}")))?;
|
||||||
|
time::timeout(SPECTRUM_IO_TIMEOUT, writer.flush())
|
||||||
|
.await
|
||||||
|
.map_err(|_| RigError::communication("spectrum subscribe flush timed out".to_string()))?
|
||||||
|
.map_err(|e| RigError::communication(format!("spectrum subscribe flush failed: {e}")))?;
|
||||||
|
|
||||||
|
// Re-check what the UI wants: switching rigs has to end this stream so the
|
||||||
|
// connection can be rebuilt for the new one.
|
||||||
|
let mut supervisor = time::interval(Duration::from_millis(500));
|
||||||
|
supervisor.tick().await;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
changed = shutdown_rx.changed() => {
|
||||||
|
match changed {
|
||||||
|
Ok(()) if *shutdown_rx.borrow() => return Ok(SpectrumStreamOutcome::Finished),
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(_) => return Ok(SpectrumStreamOutcome::Finished),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = supervisor.tick() => {
|
||||||
|
let wanted = active_spectrum_rig_ids(config);
|
||||||
|
if wanted.len() != 1 || wanted[0] != short_name {
|
||||||
|
return Ok(SpectrumStreamOutcome::Finished);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
line = read_limited_line(reader, MAX_JSON_LINE_BYTES) => {
|
||||||
|
let line = line
|
||||||
|
.map_err(|e| RigError::communication(format!("spectrum read failed: {e}")))?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
RigError::communication("spectrum connection closed".to_string())
|
||||||
|
})?;
|
||||||
|
let trimmed = line.trim_end();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match serde_json::from_str::<SpectrumFrame>(trimmed) {
|
||||||
|
Ok(frame) => publish_spectrum_frame(config, short_name, frame),
|
||||||
|
// Anything that is not a frame means the server would rather
|
||||||
|
// answer than stream: an older build rejecting the command.
|
||||||
|
Err(_) => return Ok(SpectrumStreamOutcome::Unsupported),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publish one pushed frame to the per-rig and selected-rig watch channels.
|
||||||
|
fn publish_spectrum_frame(config: &RemoteClientConfig, short_name: &str, frame: SpectrumFrame) {
|
||||||
|
if let Ok(map) = config.rig_spectrums.read() {
|
||||||
|
if let Some(tx) = map.get(short_name) {
|
||||||
|
tx.send_modify(|s| s.set(Some(frame.spectrum.clone()), frame.vchan_rds.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if selected_rig_id(config).as_deref() == Some(short_name) {
|
||||||
|
config
|
||||||
|
.spectrum
|
||||||
|
.send_modify(|s| s.set(Some(frame.spectrum), frame.vchan_rds));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn handle_spectrum_connection(
|
async fn handle_spectrum_connection(
|
||||||
config: &RemoteClientConfig,
|
config: &RemoteClientConfig,
|
||||||
stream: TcpStream,
|
stream: TcpStream,
|
||||||
@@ -512,7 +599,33 @@ async fn handle_spectrum_connection(
|
|||||||
) -> RigResult<()> {
|
) -> RigResult<()> {
|
||||||
let (reader, mut writer) = stream.into_split();
|
let (reader, mut writer) = stream.into_split();
|
||||||
let mut reader = BufReader::new(reader);
|
let mut reader = BufReader::new(reader);
|
||||||
let mut interval = time::interval(SPECTRUM_POLL_INTERVAL);
|
|
||||||
|
// Prefer the push stream: polling costs a round trip per frame, so on a
|
||||||
|
// high-latency link the frame rate is 1/RTT no matter what interval is
|
||||||
|
// configured. It only works for one rig per connection, and only against
|
||||||
|
// a server new enough to understand the command.
|
||||||
|
let streamable = active_spectrum_rig_ids(config);
|
||||||
|
if streamable.len() == 1 && !config.spectrum_stream_unsupported.load(Ordering::Relaxed) {
|
||||||
|
match run_spectrum_stream(
|
||||||
|
config,
|
||||||
|
&mut writer,
|
||||||
|
&mut reader,
|
||||||
|
&streamable[0],
|
||||||
|
shutdown_rx,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
SpectrumStreamOutcome::Finished => return Ok(()),
|
||||||
|
SpectrumStreamOutcome::Unsupported => {
|
||||||
|
info!("Server does not support spectrum streaming; falling back to polling");
|
||||||
|
config
|
||||||
|
.spectrum_stream_unsupported
|
||||||
|
.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut interval = time::interval(config.spectrum_interval);
|
||||||
// Cache the token outside the poll loop to avoid cloning it every 50ms.
|
// Cache the token outside the poll loop to avoid cloning it every 50ms.
|
||||||
let cached_token = config.token.clone();
|
let cached_token = config.token.clone();
|
||||||
|
|
||||||
@@ -1195,92 +1308,13 @@ async fn read_limited_line<R: AsyncBufRead + Unpin>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_remote_url(url: &str) -> Result<RemoteEndpoint, String> {
|
|
||||||
parse_endpoint_url(url, DEFAULT_REMOTE_PORT, "remote")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse_audio_url(url: &str) -> Result<RemoteEndpoint, String> {
|
|
||||||
parse_endpoint_url(url, DEFAULT_AUDIO_PORT, "audio")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_endpoint_url(url: &str, default_port: u16, kind: &str) -> Result<RemoteEndpoint, String> {
|
|
||||||
let trimmed = url.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
return Err(format!("{kind} url is empty"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let addr = trimmed
|
|
||||||
.strip_prefix("tcp://")
|
|
||||||
.or_else(|| trimmed.strip_prefix("http-json://"))
|
|
||||||
.or_else(|| trimmed.strip_prefix("audio://"))
|
|
||||||
.unwrap_or(trimmed);
|
|
||||||
|
|
||||||
parse_host_port(addr, default_port, kind)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_host_port(input: &str, default_port: u16, kind: &str) -> Result<RemoteEndpoint, String> {
|
|
||||||
if let Some(rest) = input.strip_prefix('[') {
|
|
||||||
let closing = rest
|
|
||||||
.find(']')
|
|
||||||
.ok_or_else(|| format!("invalid {kind} url: missing closing ']' for IPv6 host"))?;
|
|
||||||
let host = &rest[..closing];
|
|
||||||
let remainder = &rest[closing + 1..];
|
|
||||||
if host.is_empty() {
|
|
||||||
return Err(format!("invalid {kind} url: host is empty"));
|
|
||||||
}
|
|
||||||
let port = if remainder.is_empty() {
|
|
||||||
default_port
|
|
||||||
} else if let Some(port_str) = remainder.strip_prefix(':') {
|
|
||||||
parse_port(port_str, kind)?
|
|
||||||
} else {
|
|
||||||
return Err(format!("invalid {kind} url: expected ':<port>' after ']'"));
|
|
||||||
};
|
|
||||||
return Ok(RemoteEndpoint {
|
|
||||||
host: host.to_string(),
|
|
||||||
port,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if input.contains(':') {
|
|
||||||
if input.matches(':').count() > 1 {
|
|
||||||
return Err(format!(
|
|
||||||
"invalid {kind} url: IPv6 host must be bracketed like [::1]:4532"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let (host, port_str) = input
|
|
||||||
.rsplit_once(':')
|
|
||||||
.ok_or_else(|| format!("invalid {kind} url: expected host:port"))?;
|
|
||||||
if host.is_empty() {
|
|
||||||
return Err(format!("invalid {kind} url: host is empty"));
|
|
||||||
}
|
|
||||||
return Ok(RemoteEndpoint {
|
|
||||||
host: host.to_string(),
|
|
||||||
port: parse_port(port_str, kind)?,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(RemoteEndpoint {
|
|
||||||
host: input.to_string(),
|
|
||||||
port: default_port,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_port(port_str: &str, kind: &str) -> Result<u16, String> {
|
|
||||||
let port: u16 = port_str
|
|
||||||
.parse()
|
|
||||||
.map_err(|_| format!("invalid {kind} port: '{port_str}'"))?;
|
|
||||||
if port == 0 {
|
|
||||||
return Err(format!("invalid {kind} port: 0"));
|
|
||||||
}
|
|
||||||
Ok(port)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
use super::{has_short_names, resolve_server_rig_id, resolve_short_name};
|
use super::{has_short_names, resolve_server_rig_id, resolve_short_name};
|
||||||
use super::{
|
use super::{
|
||||||
parse_audio_url, parse_remote_url, RemoteClientConfig, RemoteEndpoint, SharedSpectrum,
|
parse_audio_url, parse_remote_url, RemoteClientConfig, RemoteEndpoint, SharedSpectrum,
|
||||||
|
DEFAULT_SPECTRUM_INTERVAL,
|
||||||
};
|
};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::atomic::AtomicBool;
|
use std::sync::atomic::AtomicBool;
|
||||||
@@ -1475,6 +1509,8 @@ mod tests {
|
|||||||
selected_rig_id: Arc::new(Mutex::new(None)),
|
selected_rig_id: Arc::new(Mutex::new(None)),
|
||||||
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
||||||
poll_interval: Duration::from_millis(100),
|
poll_interval: Duration::from_millis(100),
|
||||||
|
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
|
||||||
|
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
|
||||||
spectrum: Arc::new(spectrum_tx),
|
spectrum: Arc::new(spectrum_tx),
|
||||||
server_connected: Arc::new(AtomicBool::new(false)),
|
server_connected: Arc::new(AtomicBool::new(false)),
|
||||||
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
||||||
@@ -1520,6 +1556,8 @@ mod tests {
|
|||||||
selected_rig_id: Arc::new(Mutex::new(Some("sdr".to_string()))),
|
selected_rig_id: Arc::new(Mutex::new(Some("sdr".to_string()))),
|
||||||
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
||||||
poll_interval: Duration::from_millis(500),
|
poll_interval: Duration::from_millis(500),
|
||||||
|
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
|
||||||
|
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
|
||||||
spectrum: Arc::new(spectrum_tx),
|
spectrum: Arc::new(spectrum_tx),
|
||||||
server_connected: Arc::new(AtomicBool::new(false)),
|
server_connected: Arc::new(AtomicBool::new(false)),
|
||||||
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
||||||
@@ -1535,6 +1573,130 @@ mod tests {
|
|||||||
assert_eq!(envelope.rig_id.as_deref(), Some("sdr"));
|
assert_eq!(envelope.rig_id.as_deref(), Some("sdr"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn stream_test_config(spectrum_tx: watch::Sender<SharedSpectrum>) -> super::RemoteClientConfig {
|
||||||
|
super::RemoteClientConfig {
|
||||||
|
addr: "127.0.0.1:4530".to_string(),
|
||||||
|
token: None,
|
||||||
|
selected_rig_id: Arc::new(Mutex::new(Some("sdr".to_string()))),
|
||||||
|
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
||||||
|
poll_interval: Duration::from_millis(500),
|
||||||
|
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
|
||||||
|
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
|
||||||
|
spectrum: Arc::new(spectrum_tx),
|
||||||
|
server_connected: Arc::new(AtomicBool::new(false)),
|
||||||
|
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
rig_states: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
rig_spectrums: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
rig_id_to_short_name: HashMap::new(),
|
||||||
|
short_name_to_rig_id: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
sat_passes: Arc::new(RwLock::new(None)),
|
||||||
|
rig_meters: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A pushed frame reaches the watch channel the UI reads, with no request
|
||||||
|
/// from the client beyond the initial subscribe.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn spectrum_stream_publishes_pushed_frames() {
|
||||||
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
|
|
||||||
|
let (spectrum_tx, mut spectrum_rx) = watch::channel(SharedSpectrum::default());
|
||||||
|
let config = stream_test_config(spectrum_tx);
|
||||||
|
let (shutdown_tx, mut shutdown_rx) = watch::channel(false);
|
||||||
|
|
||||||
|
let (client_io, mut server_io) = tokio::io::duplex(64 * 1024);
|
||||||
|
let (client_read, mut client_write) = tokio::io::split(client_io);
|
||||||
|
let mut client_read = BufReader::new(client_read);
|
||||||
|
|
||||||
|
let task = tokio::spawn(async move {
|
||||||
|
super::run_spectrum_stream(
|
||||||
|
&config,
|
||||||
|
&mut client_write,
|
||||||
|
&mut client_read,
|
||||||
|
"sdr",
|
||||||
|
&mut shutdown_rx,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
|
||||||
|
// The server sees the subscribe, then pushes without being asked.
|
||||||
|
let mut server = BufReader::new(&mut server_io);
|
||||||
|
let mut subscribe = String::new();
|
||||||
|
server.read_line(&mut subscribe).await.expect("subscribe");
|
||||||
|
assert!(
|
||||||
|
subscribe.contains("subscribe_spectrum"),
|
||||||
|
"unexpected command: {subscribe}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let frame = trx_protocol::SpectrumFrame {
|
||||||
|
rig_id: "sdr".to_string(),
|
||||||
|
spectrum: trx_core::rig::state::SpectrumData {
|
||||||
|
bins: vec![-70.0, -30.0],
|
||||||
|
center_hz: 14_200_000,
|
||||||
|
sample_rate: 1_920_000,
|
||||||
|
rds: None,
|
||||||
|
},
|
||||||
|
vchan_rds: None,
|
||||||
|
};
|
||||||
|
let mut line = serde_json::to_string(&frame).unwrap();
|
||||||
|
line.push('\n');
|
||||||
|
server_io.write_all(line.as_bytes()).await.expect("push");
|
||||||
|
server_io.flush().await.expect("flush");
|
||||||
|
|
||||||
|
spectrum_rx.changed().await.expect("spectrum published");
|
||||||
|
let published = spectrum_rx.borrow().clone();
|
||||||
|
let spectrum = published.frame.expect("spectrum present");
|
||||||
|
assert_eq!(spectrum.center_hz, 14_200_000);
|
||||||
|
assert_eq!(spectrum.bins, vec![-70.0, -30.0]);
|
||||||
|
|
||||||
|
let _ = shutdown_tx.send(true);
|
||||||
|
let _ = task.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An older server answers the unknown command with an error instead of
|
||||||
|
/// frames. That has to read as "poll instead", not as a dead connection.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn spectrum_stream_falls_back_when_unsupported() {
|
||||||
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
|
|
||||||
|
let (spectrum_tx, _spectrum_rx) = watch::channel(SharedSpectrum::default());
|
||||||
|
let config = stream_test_config(spectrum_tx);
|
||||||
|
let (_shutdown_tx, mut shutdown_rx) = watch::channel(false);
|
||||||
|
|
||||||
|
let (client_io, mut server_io) = tokio::io::duplex(64 * 1024);
|
||||||
|
let (client_read, mut client_write) = tokio::io::split(client_io);
|
||||||
|
let mut client_read = BufReader::new(client_read);
|
||||||
|
|
||||||
|
let task = tokio::spawn(async move {
|
||||||
|
super::run_spectrum_stream(
|
||||||
|
&config,
|
||||||
|
&mut client_write,
|
||||||
|
&mut client_read,
|
||||||
|
"sdr",
|
||||||
|
&mut shutdown_rx,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut server = BufReader::new(&mut server_io);
|
||||||
|
let mut subscribe = String::new();
|
||||||
|
server.read_line(&mut subscribe).await.expect("subscribe");
|
||||||
|
|
||||||
|
server_io
|
||||||
|
.write_all(
|
||||||
|
b"{\"success\":false,\"state\":null,\"error\":\"Invalid JSON: unknown variant\"}\n",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("error response");
|
||||||
|
server_io.flush().await.expect("flush");
|
||||||
|
|
||||||
|
let outcome = task.await.expect("join").expect("stream result");
|
||||||
|
assert!(
|
||||||
|
matches!(outcome, super::SpectrumStreamOutcome::Unsupported),
|
||||||
|
"an error response should fall back to polling"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_envelope_translates_short_name_to_server_rig_id() {
|
fn build_envelope_translates_short_name_to_server_rig_id() {
|
||||||
let (spectrum_tx, _spectrum_rx) = watch::channel(SharedSpectrum::default());
|
let (spectrum_tx, _spectrum_rx) = watch::channel(SharedSpectrum::default());
|
||||||
@@ -1548,6 +1710,8 @@ mod tests {
|
|||||||
selected_rig_id: Arc::new(Mutex::new(Some("home-hf".to_string()))),
|
selected_rig_id: Arc::new(Mutex::new(Some("home-hf".to_string()))),
|
||||||
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
||||||
poll_interval: Duration::from_millis(500),
|
poll_interval: Duration::from_millis(500),
|
||||||
|
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
|
||||||
|
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
|
||||||
spectrum: Arc::new(spectrum_tx),
|
spectrum: Arc::new(spectrum_tx),
|
||||||
server_connected: Arc::new(AtomicBool::new(false)),
|
server_connected: Arc::new(AtomicBool::new(false)),
|
||||||
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
||||||
@@ -1580,6 +1744,8 @@ mod tests {
|
|||||||
selected_rig_id: Arc::new(Mutex::new(None)),
|
selected_rig_id: Arc::new(Mutex::new(None)),
|
||||||
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
||||||
poll_interval: Duration::from_millis(500),
|
poll_interval: Duration::from_millis(500),
|
||||||
|
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
|
||||||
|
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
|
||||||
spectrum: Arc::new(spectrum_tx),
|
spectrum: Arc::new(spectrum_tx),
|
||||||
server_connected: Arc::new(AtomicBool::new(false)),
|
server_connected: Arc::new(AtomicBool::new(false)),
|
||||||
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
||||||
@@ -1604,6 +1770,8 @@ mod tests {
|
|||||||
selected_rig_id: Arc::new(Mutex::new(None)),
|
selected_rig_id: Arc::new(Mutex::new(None)),
|
||||||
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
||||||
poll_interval: Duration::from_millis(500),
|
poll_interval: Duration::from_millis(500),
|
||||||
|
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
|
||||||
|
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
|
||||||
spectrum: Arc::new(spectrum_tx),
|
spectrum: Arc::new(spectrum_tx),
|
||||||
server_connected: Arc::new(AtomicBool::new(false)),
|
server_connected: Arc::new(AtomicBool::new(false)),
|
||||||
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
||||||
@@ -1644,6 +1812,8 @@ mod tests {
|
|||||||
selected_rig_id: Arc::new(Mutex::new(None)),
|
selected_rig_id: Arc::new(Mutex::new(None)),
|
||||||
known_rigs: known_rigs.clone(),
|
known_rigs: known_rigs.clone(),
|
||||||
poll_interval: Duration::from_millis(500),
|
poll_interval: Duration::from_millis(500),
|
||||||
|
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
|
||||||
|
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
|
||||||
spectrum: Arc::new(spectrum_tx),
|
spectrum: Arc::new(spectrum_tx),
|
||||||
server_connected: Arc::new(AtomicBool::new(false)),
|
server_connected: Arc::new(AtomicBool::new(false)),
|
||||||
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
||||||
@@ -1717,6 +1887,8 @@ mod tests {
|
|||||||
selected_rig_id,
|
selected_rig_id,
|
||||||
known_rigs,
|
known_rigs,
|
||||||
poll_interval: Duration::from_millis(500),
|
poll_interval: Duration::from_millis(500),
|
||||||
|
spectrum_interval: DEFAULT_SPECTRUM_INTERVAL,
|
||||||
|
spectrum_stream_unsupported: Arc::new(AtomicBool::new(false)),
|
||||||
spectrum: Arc::new(spectrum_tx),
|
spectrum: Arc::new(spectrum_tx),
|
||||||
server_connected: Arc::new(AtomicBool::new(false)),
|
server_connected: Arc::new(AtomicBool::new(false)),
|
||||||
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
rig_server_connected: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use trx_core::audio::AudioStreamInfo;
|
use trx_core::audio::AudioStreamInfo;
|
||||||
use trx_core::decode::{
|
use trx_core::decode::{
|
||||||
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, VdesMessage, WefaxMessage,
|
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, SstvMessage, VdesMessage,
|
||||||
WsprMessage,
|
WefaxMessage, WsprMessage,
|
||||||
};
|
};
|
||||||
use trx_core::rig::state::{RigSnapshot, SpectrumData};
|
use trx_core::rig::state::{RigSnapshot, SpectrumData};
|
||||||
use trx_core::{DynResult, RigRequest, RigState};
|
use trx_core::{DynResult, RigRequest, RigState};
|
||||||
@@ -233,6 +233,7 @@ pub struct DecodeHistoryContext {
|
|||||||
pub ft2: DecodeHistory<Ft8Message>,
|
pub ft2: DecodeHistory<Ft8Message>,
|
||||||
pub wspr: DecodeHistory<WsprMessage>,
|
pub wspr: DecodeHistory<WsprMessage>,
|
||||||
pub wefax: DecodeHistory<WefaxMessage>,
|
pub wefax: DecodeHistory<WefaxMessage>,
|
||||||
|
pub sstv: DecodeHistory<SstvMessage>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for DecodeHistoryContext {
|
impl Default for DecodeHistoryContext {
|
||||||
@@ -248,6 +249,7 @@ impl Default for DecodeHistoryContext {
|
|||||||
ft2: Arc::new(Mutex::new(VecDeque::new())),
|
ft2: Arc::new(Mutex::new(VecDeque::new())),
|
||||||
wspr: Arc::new(Mutex::new(VecDeque::new())),
|
wspr: Arc::new(Mutex::new(VecDeque::new())),
|
||||||
wefax: Arc::new(Mutex::new(VecDeque::new())),
|
wefax: Arc::new(Mutex::new(VecDeque::new())),
|
||||||
|
sstv: Arc::new(Mutex::new(VecDeque::new())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -255,9 +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 rx_passphrase: Option<String>,
|
pub users_file: String,
|
||||||
pub control_passphrase: Option<String>,
|
pub bootstrap_admin_username: Option<String>,
|
||||||
pub tx_access_control_enabled: bool,
|
pub bootstrap_admin_password: Option<String>,
|
||||||
pub 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,
|
||||||
@@ -269,9 +271,9 @@ impl Default for HttpAuthConfig {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
rx_passphrase: None,
|
users_file: "trx-http-users.json".to_string(),
|
||||||
control_passphrase: None,
|
bootstrap_admin_username: None,
|
||||||
tx_access_control_enabled: true,
|
bootstrap_admin_password: None,
|
||||||
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(),
|
||||||
@@ -290,6 +292,10 @@ pub struct HttpUiConfig {
|
|||||||
pub bandplan_region: String,
|
pub bandplan_region: String,
|
||||||
pub decode_history_retention_min: u64,
|
pub decode_history_retention_min: u64,
|
||||||
pub decode_history_retention_min_by_rig: HashMap<String, u64>,
|
pub decode_history_retention_min_by_rig: HashMap<String, u64>,
|
||||||
|
/// Where the station log is kept; `None` takes the default.
|
||||||
|
pub logbook_path: Option<String>,
|
||||||
|
/// The callsign contacts are logged under, and the operator by default.
|
||||||
|
pub station_callsign: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for HttpUiConfig {
|
impl Default for HttpUiConfig {
|
||||||
@@ -303,6 +309,8 @@ impl Default for HttpUiConfig {
|
|||||||
bandplan_region: "iaru_r1".to_string(),
|
bandplan_region: "iaru_r1".to_string(),
|
||||||
decode_history_retention_min: 24 * 60,
|
decode_history_retention_min: 24 * 60,
|
||||||
decode_history_retention_min_by_rig: HashMap::new(),
|
decode_history_retention_min_by_rig: HashMap::new(),
|
||||||
|
logbook_path: None,
|
||||||
|
station_callsign: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ build = "build.rs"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
trx-core = { path = "../../../trx-core" }
|
trx-core = { path = "../../../trx-core" }
|
||||||
trx-frontend = { path = ".." }
|
trx-frontend = { path = ".." }
|
||||||
|
trx-logbook = { path = "../../trx-logbook" }
|
||||||
|
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
|
||||||
trx-protocol = { path = "../../../../src/trx-protocol" }
|
trx-protocol = { path = "../../../../src/trx-protocol" }
|
||||||
tokio = { workspace = true, features = ["full"] }
|
tokio = { workspace = true, features = ["full"] }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
@@ -26,6 +28,11 @@ flate2 = { workspace = true }
|
|||||||
brotli = "7"
|
brotli = "7"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
|
argon2 = "0.5"
|
||||||
pickledb = "0.5"
|
pickledb = "0.5"
|
||||||
dirs = "6"
|
dirs = "6"
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
|
ts-rs = "12.0.1"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
|
|||||||
@@ -0,0 +1,375 @@
|
|||||||
|
import {
|
||||||
|
forActiveRig,
|
||||||
|
isActiveRigDecode
|
||||||
|
} from "./chunk-S57W63QN.js";
|
||||||
|
import {
|
||||||
|
hostCore,
|
||||||
|
hostState
|
||||||
|
} from "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
|
// src/plugins/ais.ts
|
||||||
|
var aisWindow = window;
|
||||||
|
var escapeAisHtml = (input) => hostCore.escapeMapHtml(input);
|
||||||
|
var aisStatus = document.getElementById("ais-status");
|
||||||
|
var aisMessagesEl = document.getElementById("ais-messages");
|
||||||
|
var aisFilterInput = document.getElementById("ais-filter");
|
||||||
|
var aisBarOverlay = document.getElementById("ais-bar-overlay");
|
||||||
|
var aisChannelSummaryEl = document.getElementById("ais-channel-summary");
|
||||||
|
var aisVesselCountEl = document.getElementById("ais-vessel-count");
|
||||||
|
var aisLatestSeenEl = document.getElementById("ais-latest-seen");
|
||||||
|
var AIS_BAR_WINDOW_MS = 15 * 60 * 1e3;
|
||||||
|
var AIS_DEFAULT_A_HZ = 161975e3;
|
||||||
|
var AIS_CHANNEL_SPACING_HZ = 5e4;
|
||||||
|
var aisFilterText = "";
|
||||||
|
var aisMessageHistory = [];
|
||||||
|
function currentAisHistoryRetentionMs() {
|
||||||
|
return typeof aisWindow.getDecodeHistoryRetentionMs === "function" ? aisWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3;
|
||||||
|
}
|
||||||
|
function pruneAisMessageHistory() {
|
||||||
|
const cutoffMs = Date.now() - currentAisHistoryRetentionMs();
|
||||||
|
aisMessageHistory = aisMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs);
|
||||||
|
}
|
||||||
|
function scheduleAisUi(key, job) {
|
||||||
|
if (typeof aisWindow.trxScheduleUiFrameJob === "function") {
|
||||||
|
aisWindow.trxScheduleUiFrameJob(key, job);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
job();
|
||||||
|
}
|
||||||
|
function scheduleAisHistoryRender() {
|
||||||
|
scheduleAisUi("ais-history", () => {
|
||||||
|
renderAisHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function scheduleAisBarUpdate() {
|
||||||
|
scheduleAisUi("ais-bar", () => {
|
||||||
|
updateAisBar();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function formatAisMhz(freqHz) {
|
||||||
|
return `${(freqHz / 1e6).toFixed(3)} MHz`;
|
||||||
|
}
|
||||||
|
function currentAisChannelPlan() {
|
||||||
|
const raw = (document.getElementById("freq")?.value || "").replace(/[^\d]/g, "");
|
||||||
|
const aHz = raw ? Number(raw) : AIS_DEFAULT_A_HZ;
|
||||||
|
const safeAHz = Number.isFinite(aHz) && aHz > 0 ? aHz : AIS_DEFAULT_A_HZ;
|
||||||
|
return {
|
||||||
|
aHz: safeAHz,
|
||||||
|
bHz: safeAHz + AIS_CHANNEL_SPACING_HZ
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function aisChannelInfo(channel) {
|
||||||
|
const plan = currentAisChannelPlan();
|
||||||
|
const ch = (channel ?? "").trim().toUpperCase();
|
||||||
|
if (ch === "B") {
|
||||||
|
return {
|
||||||
|
label: "AIS-B",
|
||||||
|
badgeClass: "ais-badge ais-badge-channel-b",
|
||||||
|
freqText: formatAisMhz(plan.bHz)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
label: "AIS-A",
|
||||||
|
badgeClass: "ais-badge ais-badge-channel-a",
|
||||||
|
freqText: formatAisMhz(plan.aHz)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function aisDisplayName(msg) {
|
||||||
|
return msg.vessel_name || msg.callsign || `MMSI ${msg.mmsi}`;
|
||||||
|
}
|
||||||
|
function aisDisplayNameHtml(msg) {
|
||||||
|
const label = escapeAisHtml(aisDisplayName(msg));
|
||||||
|
const url = aisWindow.buildAisVesselUrl?.(msg.mmsi) ?? null;
|
||||||
|
if (!url) return label;
|
||||||
|
return `<a class="title-link" href="${escapeAisHtml(url)}" target="_blank" rel="noopener">${label}</a>`;
|
||||||
|
}
|
||||||
|
function aisTypeLabel(type) {
|
||||||
|
switch (Number(type)) {
|
||||||
|
case 1:
|
||||||
|
case 2:
|
||||||
|
case 3:
|
||||||
|
return "Class A Position";
|
||||||
|
case 4:
|
||||||
|
return "Base Station";
|
||||||
|
case 5:
|
||||||
|
return "Static/Voyage";
|
||||||
|
case 18:
|
||||||
|
return "Class B Position";
|
||||||
|
case 19:
|
||||||
|
return "Class B Extended";
|
||||||
|
case 21:
|
||||||
|
return "Aid to Nav";
|
||||||
|
case 24:
|
||||||
|
return "Class B Static";
|
||||||
|
default:
|
||||||
|
return `Type ${type ?? "--"}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function aisAgeText(tsMs) {
|
||||||
|
if (typeof tsMs !== "number" || !Number.isFinite(tsMs)) return "just now";
|
||||||
|
const deltaMs = Math.max(0, Date.now() - tsMs);
|
||||||
|
const seconds = Math.round(deltaMs / 1e3);
|
||||||
|
if (seconds < 5) return "just now";
|
||||||
|
if (seconds < 60) return `${seconds}s ago`;
|
||||||
|
const minutes = Math.round(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.round(minutes / 60);
|
||||||
|
return `${hours}h ago`;
|
||||||
|
}
|
||||||
|
function aisMotionText(msg) {
|
||||||
|
const parts = [
|
||||||
|
msg.sog_knots != null ? `${msg.sog_knots.toFixed(1)} kn` : null,
|
||||||
|
msg.cog_deg != null ? `${msg.cog_deg.toFixed(1)}° COG` : null,
|
||||||
|
msg.heading_deg != null ? `${msg.heading_deg.toFixed(0)}° HDG` : null
|
||||||
|
].filter(Boolean);
|
||||||
|
return parts.join(" · ");
|
||||||
|
}
|
||||||
|
function aisRouteText(msg) {
|
||||||
|
return [msg.callsign, msg.destination].filter(Boolean).join(" -> ");
|
||||||
|
}
|
||||||
|
function aisDistanceText(msg) {
|
||||||
|
if (hostState.serverLat == null || hostState.serverLon == null || msg.lat == null || msg.lon == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const distKm = hostCore.haversineKm(hostState.serverLat, hostState.serverLon, msg.lat, msg.lon);
|
||||||
|
if (!Number.isFinite(distKm)) return "";
|
||||||
|
if (distKm < 1) return `${Math.round(distKm * 1e3)} m from TRX`;
|
||||||
|
return `${distKm.toFixed(1)} km from TRX`;
|
||||||
|
}
|
||||||
|
function aisLatestByVessel(messages) {
|
||||||
|
const byMmsi = /* @__PURE__ */ new Map();
|
||||||
|
for (const msg of messages) {
|
||||||
|
const key = Number.isFinite(msg.mmsi) ? String(msg.mmsi) : `${msg.channel || "?"}:${msg._tsMs || 0}`;
|
||||||
|
if (!byMmsi.has(key)) byMmsi.set(key, msg);
|
||||||
|
}
|
||||||
|
return Array.from(byMmsi.values());
|
||||||
|
}
|
||||||
|
function aisRigMessages() {
|
||||||
|
return forActiveRig(aisMessageHistory);
|
||||||
|
}
|
||||||
|
function updateAisSummary() {
|
||||||
|
const plan = currentAisChannelPlan();
|
||||||
|
if (aisChannelSummaryEl) {
|
||||||
|
aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`;
|
||||||
|
}
|
||||||
|
const rigMessages = aisRigMessages();
|
||||||
|
const vessels = aisLatestByVessel(rigMessages);
|
||||||
|
if (aisVesselCountEl) {
|
||||||
|
const count = vessels.length;
|
||||||
|
aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`;
|
||||||
|
}
|
||||||
|
if (aisLatestSeenEl) {
|
||||||
|
const latest = rigMessages[0];
|
||||||
|
if (!latest) {
|
||||||
|
aisLatestSeenEl.textContent = "No traffic yet";
|
||||||
|
} else {
|
||||||
|
const channel = aisChannelInfo(latest.channel);
|
||||||
|
aisLatestSeenEl.textContent = `${channel.label} ${aisAgeText(latest._tsMs)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function aisSummaryText(msg) {
|
||||||
|
const parts = [];
|
||||||
|
if (msg.lat != null && msg.lon != null) parts.push(`${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}`);
|
||||||
|
const motion = aisMotionText(msg);
|
||||||
|
if (motion) parts.push(motion);
|
||||||
|
const route = aisRouteText(msg);
|
||||||
|
if (route && parts.length < 2) parts.push(route);
|
||||||
|
if (!parts.length) return route || "no position reported";
|
||||||
|
return parts.join(" · ");
|
||||||
|
}
|
||||||
|
function renderAisRow(msg) {
|
||||||
|
const row = document.createElement("details");
|
||||||
|
row.className = "ais-message";
|
||||||
|
const ts = msg._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit"
|
||||||
|
});
|
||||||
|
const name = aisDisplayName(msg);
|
||||||
|
const nameHtml = aisDisplayNameHtml(msg);
|
||||||
|
const channel = aisChannelInfo(msg.channel);
|
||||||
|
const motion = aisMotionText(msg);
|
||||||
|
const route = aisRouteText(msg);
|
||||||
|
const distance = aisDistanceText(msg);
|
||||||
|
const pos = msg.lat != null && msg.lon != null ? `<a class="ais-pos-link" href="javascript:void(0)" data-ais-map="${msg.lat},${msg.lon}">${msg.lat.toFixed(5)}, ${msg.lon.toFixed(5)}</a>` : "";
|
||||||
|
const vesselUrl = aisWindow.buildAisVesselUrl?.(msg.mmsi) ?? null;
|
||||||
|
row.dataset.filterText = [
|
||||||
|
name,
|
||||||
|
msg.mmsi,
|
||||||
|
msg.channel,
|
||||||
|
channel.label,
|
||||||
|
msg.vessel_name,
|
||||||
|
msg.callsign,
|
||||||
|
msg.destination,
|
||||||
|
aisTypeLabel(msg.message_type)
|
||||||
|
].filter(Boolean).join(" ").toUpperCase();
|
||||||
|
row.innerHTML = `<summary class="decode-line"><span class="ais-time">${escapeAisHtml(ts)}</span><span class="ais-call">${nameHtml}</span><span class="ais-badge ais-badge-type">${escapeAisHtml(aisTypeLabel(msg.message_type))}</span><span class="decode-line-summary">${escapeAisHtml(aisSummaryText(msg))}</span><span class="${channel.badgeClass}">${escapeAisHtml(channel.label)}</span>` + (distance ? `<span class="decode-line-distance">${escapeAisHtml(distance)}</span>` : "") + `</summary><div class="decode-expanded"><div class="decode-expanded-meta"><span>MMSI ${escapeAisHtml(String(msg.mmsi))}</span><span>${escapeAisHtml(channel.freqText)}</span>` + (route ? `<span>${escapeAisHtml(route)}</span>` : "") + (motion ? `<span>${escapeAisHtml(motion)}</span>` : "") + `<span>${escapeAisHtml(aisAgeText(msg._tsMs))}</span>` + (pos ? `<span>${pos}</span>` : "") + `</div><div class="aprs-row-actions">` + (msg.lat != null && msg.lon != null ? `<button class="aprs-inline-btn" type="button" data-ais-map="${msg.lat},${msg.lon}">Map</button>` : "") + (vesselUrl ? `<a class="aprs-inline-btn" href="${escapeAisHtml(vesselUrl)}" target="_blank" rel="noopener">Vessel</a>` : "") + `</div></div>`;
|
||||||
|
row.querySelectorAll("[data-ais-map]").forEach((element) => {
|
||||||
|
element.addEventListener("click", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const [lat, lon] = (element.dataset.aisMap ?? "").split(",").map(Number);
|
||||||
|
if (lat == null || lon == null || !Number.isFinite(lat) || !Number.isFinite(lon)) return;
|
||||||
|
aisWindow.navigateToAprsMap?.(lat, lon);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
applyAisFilterToRow(row);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
function applyAisFilterToRow(row) {
|
||||||
|
if (!aisFilterText) {
|
||||||
|
row.style.display = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message = row.dataset.filterText || "";
|
||||||
|
row.style.display = message.includes(aisFilterText) ? "" : "none";
|
||||||
|
}
|
||||||
|
function updateAisBar() {
|
||||||
|
if (!aisBarOverlay) return;
|
||||||
|
updateAisSummary();
|
||||||
|
const isAis = (document.getElementById("mode")?.value || "").toUpperCase() === "AIS";
|
||||||
|
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
|
||||||
|
const recent = aisMessageHistory.filter(
|
||||||
|
(msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id)
|
||||||
|
);
|
||||||
|
const messages = aisLatestByVessel(recent).slice(0, 8);
|
||||||
|
if (!isAis || messages.length === 0) {
|
||||||
|
aisBarOverlay.style.display = "none";
|
||||||
|
aisBarOverlay.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let html = `<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">AIS</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearAisBar()" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();window.clearAisBar();}" aria-label="Clear AIS overlay">Clear</span></span><span class="aprs-bar-window">Last 15 minutes</span></div>`;
|
||||||
|
for (const msg of messages) {
|
||||||
|
const ts = msg._ts ? `<span class="aprs-bar-time">${msg._ts}</span>` : "";
|
||||||
|
const pin = msg.lat != null && msg.lon != null ? `<button class="aprs-bar-pin" title="${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}" onclick="window.navigateToAprsMap(${msg.lat},${msg.lon})">📍</button>` : "";
|
||||||
|
const name = `<span class="ais-call">${aisDisplayNameHtml(msg)}</span>`;
|
||||||
|
const channel = aisChannelInfo(msg.channel);
|
||||||
|
const distance = aisDistanceText(msg);
|
||||||
|
const details = [
|
||||||
|
`MMSI ${escapeAisHtml(String(msg.mmsi))}`,
|
||||||
|
escapeAisHtml(channel.label),
|
||||||
|
msg.sog_knots != null ? `${msg.sog_knots.toFixed(1)} kn` : null,
|
||||||
|
msg.cog_deg != null ? `${msg.cog_deg.toFixed(1)}°` : null,
|
||||||
|
distance ? escapeAisHtml(distance) : null,
|
||||||
|
escapeAisHtml(aisAgeText(msg._tsMs))
|
||||||
|
].filter(Boolean).join(" · ");
|
||||||
|
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}${pin}${name}: ${details}</div></div>`;
|
||||||
|
}
|
||||||
|
aisBarOverlay.innerHTML = html;
|
||||||
|
aisBarOverlay.style.display = "flex";
|
||||||
|
}
|
||||||
|
aisWindow.updateAisBar = updateAisBar;
|
||||||
|
aisWindow.clearAisBar = function() {
|
||||||
|
resetAisHistoryView();
|
||||||
|
};
|
||||||
|
function resetAisHistoryView() {
|
||||||
|
if (aisMessagesEl) aisMessagesEl.innerHTML = "";
|
||||||
|
aisMessageHistory = [];
|
||||||
|
updateAisBar();
|
||||||
|
renderAisHistory();
|
||||||
|
aisWindow.clearMapMarkersByType?.("ais");
|
||||||
|
}
|
||||||
|
function renderAisHistory() {
|
||||||
|
pruneAisMessageHistory();
|
||||||
|
if (!aisMessagesEl) {
|
||||||
|
updateAisSummary();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (const message of aisRigMessages()) {
|
||||||
|
fragment.appendChild(renderAisRow(message));
|
||||||
|
}
|
||||||
|
aisMessagesEl.replaceChildren(fragment);
|
||||||
|
updateAisSummary();
|
||||||
|
}
|
||||||
|
function addAisMessage(msg) {
|
||||||
|
const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
||||||
|
msg._tsMs = tsMs;
|
||||||
|
msg._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit"
|
||||||
|
});
|
||||||
|
aisMessageHistory.unshift(msg);
|
||||||
|
pruneAisMessageHistory();
|
||||||
|
scheduleAisBarUpdate();
|
||||||
|
scheduleAisHistoryRender();
|
||||||
|
plotAisMessage(msg);
|
||||||
|
}
|
||||||
|
function plotAisMessage(msg) {
|
||||||
|
if (msg.lat == null || msg.lon == null || !aisWindow.aisMapAddVessel) return;
|
||||||
|
aisWindow.aisMapAddVessel(msg);
|
||||||
|
}
|
||||||
|
function normalizeServerAisMessage(msg) {
|
||||||
|
return {
|
||||||
|
...msg,
|
||||||
|
rig_id: msg.rig_id || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function onServerAisBatch(messages) {
|
||||||
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
|
const normalized = [];
|
||||||
|
for (const msg of messages) {
|
||||||
|
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();
|
||||||
|
next._tsMs = tsMs;
|
||||||
|
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit"
|
||||||
|
});
|
||||||
|
plotAisMessage(next);
|
||||||
|
normalized.push(next);
|
||||||
|
}
|
||||||
|
normalized.reverse();
|
||||||
|
aisMessageHistory = normalized.concat(aisMessageHistory);
|
||||||
|
pruneAisMessageHistory();
|
||||||
|
scheduleAisBarUpdate();
|
||||||
|
scheduleAisHistoryRender();
|
||||||
|
}
|
||||||
|
function pruneAisHistoryView() {
|
||||||
|
pruneAisMessageHistory();
|
||||||
|
updateAisBar();
|
||||||
|
renderAisHistory();
|
||||||
|
}
|
||||||
|
document.getElementById("settings-clear-ais-history")?.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
if (!await aisWindow.trxUi.confirm({ title: "Clear AIS history?", message: "All stored AIS messages will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
|
try {
|
||||||
|
await hostCore.postPath("/clear_ais_decode");
|
||||||
|
resetAisHistoryView();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("AIS history clear failed", e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
if (aisFilterInput) {
|
||||||
|
aisFilterInput.addEventListener("input", () => {
|
||||||
|
aisFilterText = aisFilterInput.value.trim().toUpperCase();
|
||||||
|
renderAisHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function onServerAis(msg) {
|
||||||
|
const message = normalizeServerAisMessage(msg);
|
||||||
|
if (aisStatus && isActiveRigDecode(message.rig_id)) aisStatus.textContent = "Receiving";
|
||||||
|
addAisMessage(message);
|
||||||
|
}
|
||||||
|
updateAisSummary();
|
||||||
|
window.trxPluginRuntime.registerDecoder({
|
||||||
|
id: "ais",
|
||||||
|
onMessage: onServerAis,
|
||||||
|
onBatch: onServerAisBatch,
|
||||||
|
restore: onServerAisBatch,
|
||||||
|
reset: resetAisHistoryView,
|
||||||
|
prune: pruneAisHistoryView,
|
||||||
|
rerender: () => {
|
||||||
|
updateAisBar();
|
||||||
|
renderAisHistory();
|
||||||
|
},
|
||||||
|
// Oldest first, so vessel tracks are rebuilt in the order they happened.
|
||||||
|
syncMap: () => {
|
||||||
|
for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry);
|
||||||
|
}
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,314 @@
|
|||||||
|
import {
|
||||||
|
aprsAgeText,
|
||||||
|
aprsPacketCategory,
|
||||||
|
collapseAprsDuplicates,
|
||||||
|
normalizeAprsPacket,
|
||||||
|
renderAprsPacketRow
|
||||||
|
} from "./chunk-REYSUJQ4.js";
|
||||||
|
import {
|
||||||
|
forActiveRig,
|
||||||
|
isActiveRigDecode
|
||||||
|
} from "./chunk-S57W63QN.js";
|
||||||
|
import {
|
||||||
|
hostCore,
|
||||||
|
hostState
|
||||||
|
} from "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
|
// src/plugins/aprs.ts
|
||||||
|
var aprsWindow = window;
|
||||||
|
var escapeAprsHtml = (input) => hostCore.escapeMapHtml(input);
|
||||||
|
var showAprsHint = (message, durationMs) => {
|
||||||
|
hostCore.showHint(message, durationMs);
|
||||||
|
};
|
||||||
|
var aprsStatus = document.getElementById("aprs-status");
|
||||||
|
var aprsPacketsEl = document.getElementById("aprs-packets");
|
||||||
|
var aprsFilterInput = document.getElementById("aprs-filter");
|
||||||
|
var aprsBarOverlay = document.getElementById("aprs-bar-overlay");
|
||||||
|
var aprsOnlyPosBtn = document.getElementById("aprs-only-pos-btn");
|
||||||
|
var aprsHideCrcBtn = document.getElementById("aprs-hide-crc-btn");
|
||||||
|
var aprsCollapseDupBtn = document.getElementById("aprs-collapse-dup-btn");
|
||||||
|
var aprsTotalCountEl = document.getElementById("aprs-total-count");
|
||||||
|
var aprsVisibleCountEl = document.getElementById("aprs-visible-count");
|
||||||
|
var aprsLatestSeenEl = document.getElementById("aprs-latest-seen");
|
||||||
|
var APRS_BAR_WINDOW_MS = 15 * 60 * 1e3;
|
||||||
|
var aprsFilterText = "";
|
||||||
|
var aprsPacketHistory = [];
|
||||||
|
var aprsBarDismissedAtMs = 0;
|
||||||
|
var aprsOnlyPos = false;
|
||||||
|
var aprsHideCrc = false;
|
||||||
|
var aprsCollapseDup = false;
|
||||||
|
var aprsTypeFilter = "all";
|
||||||
|
function currentAprsHistoryRetentionMs() {
|
||||||
|
return typeof aprsWindow.getDecodeHistoryRetentionMs === "function" ? aprsWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3;
|
||||||
|
}
|
||||||
|
function pruneAprsPacketHistory() {
|
||||||
|
const cutoffMs = Date.now() - currentAprsHistoryRetentionMs();
|
||||||
|
aprsPacketHistory = aprsPacketHistory.filter((pkt) => (pkt._tsMs ?? 0) >= cutoffMs);
|
||||||
|
}
|
||||||
|
function scheduleAprsUi(key, job) {
|
||||||
|
if (typeof aprsWindow.trxScheduleUiFrameJob === "function") {
|
||||||
|
aprsWindow.trxScheduleUiFrameJob(key, job);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
job();
|
||||||
|
}
|
||||||
|
function scheduleAprsHistoryRender() {
|
||||||
|
scheduleAprsUi("aprs-history", () => {
|
||||||
|
renderAprsHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function scheduleAprsBarUpdate() {
|
||||||
|
scheduleAprsUi("aprs-bar", () => {
|
||||||
|
updateAprsBar();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function aprsDistanceText(pkt) {
|
||||||
|
if (hostState.serverLat == null || hostState.serverLon == null || pkt.lat == null || pkt.lon == null) return "";
|
||||||
|
const distKm = hostCore.haversineKm(hostState.serverLat, hostState.serverLon, pkt.lat, pkt.lon);
|
||||||
|
if (!Number.isFinite(distKm)) return "";
|
||||||
|
if (distKm < 1) return `${Math.round(distKm * 1e3)} m from TRX`;
|
||||||
|
return `${distKm.toFixed(1)} km from TRX`;
|
||||||
|
}
|
||||||
|
function aprsFilterMatch(pkt) {
|
||||||
|
if (aprsOnlyPos && (pkt.lat == null || pkt.lon == null)) return false;
|
||||||
|
if (aprsHideCrc && !pkt.crcOk) return false;
|
||||||
|
if (aprsTypeFilter !== "all" && aprsPacketCategory(pkt) !== aprsTypeFilter) return false;
|
||||||
|
if (!aprsFilterText) return true;
|
||||||
|
const haystack = [
|
||||||
|
pkt.srcCall,
|
||||||
|
pkt.destCall,
|
||||||
|
pkt.path,
|
||||||
|
pkt.info,
|
||||||
|
pkt.type,
|
||||||
|
pkt.lat != null ? pkt.lat.toFixed(4) : "",
|
||||||
|
pkt.lon != null ? pkt.lon.toFixed(4) : "",
|
||||||
|
aprsPacketCategory(pkt)
|
||||||
|
].filter(Boolean).join(" ").toUpperCase();
|
||||||
|
return haystack.includes(aprsFilterText);
|
||||||
|
}
|
||||||
|
function aprsRigPackets() {
|
||||||
|
return forActiveRig(aprsPacketHistory);
|
||||||
|
}
|
||||||
|
function aprsVisiblePackets() {
|
||||||
|
const rigPackets = aprsRigPackets();
|
||||||
|
const packets = aprsCollapseDup ? collapseAprsDuplicates(rigPackets) : rigPackets;
|
||||||
|
return packets.filter(aprsFilterMatch);
|
||||||
|
}
|
||||||
|
function updateAprsSummary() {
|
||||||
|
const rigPackets = aprsRigPackets();
|
||||||
|
const visible = aprsVisiblePackets();
|
||||||
|
if (aprsTotalCountEl) {
|
||||||
|
aprsTotalCountEl.textContent = `${rigPackets.length} total`;
|
||||||
|
}
|
||||||
|
if (aprsVisibleCountEl) {
|
||||||
|
aprsVisibleCountEl.textContent = `${visible.length} shown`;
|
||||||
|
}
|
||||||
|
if (aprsLatestSeenEl) {
|
||||||
|
const latest = rigPackets[0];
|
||||||
|
if (!latest) {
|
||||||
|
aprsLatestSeenEl.textContent = "No packets yet";
|
||||||
|
} else {
|
||||||
|
aprsLatestSeenEl.textContent = `${latest.srcCall} ${aprsAgeText(latest._tsMs)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function updateAprsChipState() {
|
||||||
|
document.querySelectorAll("[id^='aprs-type-']").forEach((btn) => {
|
||||||
|
btn.classList.toggle("active", btn.id === `aprs-type-${aprsTypeFilter}`);
|
||||||
|
});
|
||||||
|
aprsOnlyPosBtn?.classList.toggle("active", aprsOnlyPos);
|
||||||
|
aprsHideCrcBtn?.classList.toggle("active", aprsHideCrc);
|
||||||
|
aprsCollapseDupBtn?.classList.toggle("active", aprsCollapseDup);
|
||||||
|
}
|
||||||
|
async function copyAprsCoords(text) {
|
||||||
|
try {
|
||||||
|
const clipboard = Reflect.get(navigator, "clipboard");
|
||||||
|
if (!clipboard) return;
|
||||||
|
await clipboard.writeText(text);
|
||||||
|
showAprsHint("Coordinates copied", 1200);
|
||||||
|
} catch {
|
||||||
|
showAprsHint("Copy failed", 1500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function renderAprsRow(pkt, isFresh) {
|
||||||
|
return renderAprsPacketRow(pkt, {
|
||||||
|
fresh: isFresh,
|
||||||
|
distance: aprsDistanceText(pkt),
|
||||||
|
onMap: (lat, lon) => {
|
||||||
|
aprsWindow.navigateToAprsMap?.(lat, lon);
|
||||||
|
},
|
||||||
|
onCopy: (text) => {
|
||||||
|
void copyAprsCoords(text);
|
||||||
|
},
|
||||||
|
onLog: (call, gridsquare) => {
|
||||||
|
aprsWindow.logContact?.({ call, gridsquare: gridsquare ?? void 0, decoder: "aprs" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function renderAprsHistory() {
|
||||||
|
pruneAprsPacketHistory();
|
||||||
|
if (!aprsPacketsEl) {
|
||||||
|
updateAprsSummary();
|
||||||
|
updateAprsChipState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const visible = aprsVisiblePackets();
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (const [index, packet] of visible.entries()) {
|
||||||
|
fragment.appendChild(renderAprsRow(packet, index === 0));
|
||||||
|
}
|
||||||
|
aprsPacketsEl.replaceChildren(fragment);
|
||||||
|
updateAprsSummary();
|
||||||
|
updateAprsChipState();
|
||||||
|
}
|
||||||
|
function updateAprsBar() {
|
||||||
|
if (!aprsBarOverlay) return;
|
||||||
|
const isPkt = (document.getElementById("mode")?.value || "").toUpperCase() === "PKT";
|
||||||
|
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
|
||||||
|
const okFrames = aprsPacketHistory.filter(
|
||||||
|
(p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(p.rig_id)
|
||||||
|
);
|
||||||
|
const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
|
||||||
|
const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0);
|
||||||
|
if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) {
|
||||||
|
aprsBarOverlay.style.display = "none";
|
||||||
|
aprsBarOverlay.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let html = `<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">APRS</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-actions"><span class="aprs-bar-window">Last 15 minutes</span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearAprsBar()" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();window.clearAprsBar();}" aria-label="Clear APRS overlay">Clear</span></span><button class="aprs-bar-close" type="button" onclick="window.closeAprsBar()" aria-label="Close APRS overlay">×</button></span></div>`;
|
||||||
|
for (const pkt of frames) {
|
||||||
|
const ts = pkt._ts ? `<span class="aprs-bar-time">${pkt._ts}</span>` : "";
|
||||||
|
const call = `<span class="aprs-bar-call">${escapeAprsHtml(pkt.srcCall ?? "")}</span>`;
|
||||||
|
const dest = escapeAprsHtml(pkt.destCall || "");
|
||||||
|
const info = escapeAprsHtml(pkt.info || "");
|
||||||
|
const pin = pkt.lat != null && pkt.lon != null ? `<button class="aprs-bar-pin" title="${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}" onclick="window.navigateToAprsMap(${pkt.lat},${pkt.lon})">📍</button>` : "";
|
||||||
|
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}${pin}${call}>${dest}: ${info}</div></div>`;
|
||||||
|
}
|
||||||
|
aprsBarOverlay.innerHTML = html;
|
||||||
|
aprsBarOverlay.style.display = "flex";
|
||||||
|
}
|
||||||
|
aprsWindow.updateAprsBar = updateAprsBar;
|
||||||
|
aprsWindow.clearAprsBar = function() {
|
||||||
|
resetAprsHistoryView();
|
||||||
|
};
|
||||||
|
aprsWindow.closeAprsBar = function() {
|
||||||
|
aprsBarDismissedAtMs = Date.now();
|
||||||
|
if (aprsBarOverlay) {
|
||||||
|
aprsBarOverlay.style.display = "none";
|
||||||
|
aprsBarOverlay.innerHTML = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
function resetAprsHistoryView() {
|
||||||
|
if (aprsPacketsEl) aprsPacketsEl.innerHTML = "";
|
||||||
|
aprsPacketHistory = [];
|
||||||
|
updateAprsBar();
|
||||||
|
renderAprsHistory();
|
||||||
|
aprsWindow.clearMapMarkersByType?.("aprs");
|
||||||
|
}
|
||||||
|
function pruneAprsHistoryView() {
|
||||||
|
pruneAprsPacketHistory();
|
||||||
|
updateAprsBar();
|
||||||
|
renderAprsHistory();
|
||||||
|
}
|
||||||
|
function plotAprsPacket(pkt) {
|
||||||
|
if (pkt.lat == null || pkt.lon == null || !aprsWindow.aprsMapAddStation) return;
|
||||||
|
aprsWindow.aprsMapAddStation(pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "", pkt.symbolTable, pkt.symbolCode, pkt);
|
||||||
|
}
|
||||||
|
function addAprsPacket(pkt) {
|
||||||
|
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
||||||
|
pkt._tsMs = tsMs;
|
||||||
|
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
aprsPacketHistory.unshift(pkt);
|
||||||
|
pruneAprsPacketHistory();
|
||||||
|
plotAprsPacket(pkt);
|
||||||
|
if (pkt.crcOk) scheduleAprsBarUpdate();
|
||||||
|
scheduleAprsHistoryRender();
|
||||||
|
}
|
||||||
|
function normalizeServerAprsPacket(pkt) {
|
||||||
|
return normalizeAprsPacket(pkt, aprsWindow.getDecodeRigMeta?.() ?? null);
|
||||||
|
}
|
||||||
|
function onServerAprsBatch(packets) {
|
||||||
|
if (!Array.isArray(packets) || packets.length === 0) return;
|
||||||
|
const normalized = [];
|
||||||
|
let hasCrcOk = false;
|
||||||
|
for (const pkt of packets) {
|
||||||
|
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();
|
||||||
|
next._tsMs = tsMs;
|
||||||
|
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
plotAprsPacket(next);
|
||||||
|
if (next.crcOk) hasCrcOk = true;
|
||||||
|
normalized.push(next);
|
||||||
|
}
|
||||||
|
normalized.reverse();
|
||||||
|
aprsPacketHistory = normalized.concat(aprsPacketHistory);
|
||||||
|
pruneAprsPacketHistory();
|
||||||
|
if (hasCrcOk) scheduleAprsBarUpdate();
|
||||||
|
scheduleAprsHistoryRender();
|
||||||
|
}
|
||||||
|
document.getElementById("settings-clear-aprs-history")?.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
if (!await aprsWindow.trxUi.confirm({ title: "Clear APRS history?", message: "All stored APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
|
try {
|
||||||
|
await hostCore.postPath("/clear_aprs_decode");
|
||||||
|
resetAprsHistoryView();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("APRS history clear failed", e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
if (aprsOnlyPosBtn) {
|
||||||
|
aprsOnlyPosBtn.addEventListener("click", () => {
|
||||||
|
aprsOnlyPos = !aprsOnlyPos;
|
||||||
|
renderAprsHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (aprsHideCrcBtn) {
|
||||||
|
aprsHideCrcBtn.addEventListener("click", () => {
|
||||||
|
aprsHideCrc = !aprsHideCrc;
|
||||||
|
renderAprsHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (aprsCollapseDupBtn) {
|
||||||
|
aprsCollapseDupBtn.addEventListener("click", () => {
|
||||||
|
aprsCollapseDup = !aprsCollapseDup;
|
||||||
|
renderAprsHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
["all", "position", "message", "weather", "telemetry", "other"].forEach((type) => {
|
||||||
|
const btn = document.getElementById(`aprs-type-${type}`);
|
||||||
|
if (!btn) return;
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
aprsTypeFilter = type;
|
||||||
|
renderAprsHistory();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (aprsFilterInput) {
|
||||||
|
aprsFilterInput.addEventListener("input", () => {
|
||||||
|
aprsFilterText = aprsFilterInput.value.trim().toUpperCase();
|
||||||
|
renderAprsHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function onServerAprs(pkt) {
|
||||||
|
const packet = normalizeServerAprsPacket(pkt);
|
||||||
|
if (aprsStatus && isActiveRigDecode(packet.rig_id)) aprsStatus.textContent = "Receiving";
|
||||||
|
addAprsPacket(packet);
|
||||||
|
}
|
||||||
|
renderAprsHistory();
|
||||||
|
window.trxPluginRuntime.registerDecoder({
|
||||||
|
id: "aprs",
|
||||||
|
onMessage: onServerAprs,
|
||||||
|
onBatch: onServerAprsBatch,
|
||||||
|
restore: onServerAprsBatch,
|
||||||
|
reset: resetAprsHistoryView,
|
||||||
|
prune: pruneAprsHistoryView,
|
||||||
|
rerender: () => {
|
||||||
|
updateAprsBar();
|
||||||
|
renderAprsHistory();
|
||||||
|
},
|
||||||
|
// Oldest first, so station tracks are rebuilt in the order they happened.
|
||||||
|
syncMap: () => {
|
||||||
|
for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry);
|
||||||
|
}
|
||||||
|
});
|
||||||
+439
@@ -0,0 +1,439 @@
|
|||||||
|
import {
|
||||||
|
hostState
|
||||||
|
} from "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
|
// src/plugins/background-decode.ts
|
||||||
|
var bgdWindow = window;
|
||||||
|
(function() {
|
||||||
|
"use strict";
|
||||||
|
function bgdSupportedIds() {
|
||||||
|
return (bgdWindow.decoderRegistry || []).filter(function(d) {
|
||||||
|
return d.background_decode;
|
||||||
|
}).map(function(d) {
|
||||||
|
return d.id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let backgroundDecodeRole = null;
|
||||||
|
let currentRigId = null;
|
||||||
|
let currentConfig = null;
|
||||||
|
let bookmarkList = [];
|
||||||
|
let statusInterval = null;
|
||||||
|
let bgdDirty = false;
|
||||||
|
let statusByBookmark = /* @__PURE__ */ new Map();
|
||||||
|
let lastStatus = null;
|
||||||
|
function initBackgroundDecode(rigId, role) {
|
||||||
|
backgroundDecodeRole = role;
|
||||||
|
currentRigId = rigId || hostState.lastActiveRigId || null;
|
||||||
|
if (currentRigId) loadBackgroundDecode();
|
||||||
|
startStatusPolling();
|
||||||
|
}
|
||||||
|
function setBackgroundDecodeRig(rigId) {
|
||||||
|
const nextRigId = rigId || null;
|
||||||
|
if (nextRigId === currentRigId) return;
|
||||||
|
currentRigId = nextRigId;
|
||||||
|
if (!currentRigId) return;
|
||||||
|
loadBackgroundDecode();
|
||||||
|
}
|
||||||
|
function apiGetConfig(rigId) {
|
||||||
|
return fetch("/background-decode/" + encodeURIComponent(rigId)).then(function(r) {
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${String(r.status)}`);
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function apiPutConfig(rigId, config) {
|
||||||
|
return fetch("/background-decode/" + encodeURIComponent(rigId), {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(config)
|
||||||
|
}).then(function(r) {
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${String(r.status)}`);
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function apiResetConfig(rigId) {
|
||||||
|
return fetch("/background-decode/" + encodeURIComponent(rigId), {
|
||||||
|
method: "DELETE"
|
||||||
|
}).then(function(r) {
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${String(r.status)}`);
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function apiGetStatus(rigId) {
|
||||||
|
return fetch("/background-decode/" + encodeURIComponent(rigId) + "/status").then(function(r) {
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${String(r.status)}`);
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function apiGetBookmarks() {
|
||||||
|
return fetch("/bookmarks").then(function(r) {
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${String(r.status)}`);
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function loadBackgroundDecode() {
|
||||||
|
const rigId = currentRigId;
|
||||||
|
if (!rigId) return;
|
||||||
|
Promise.all([apiGetConfig(rigId), apiGetBookmarks()]).then(function([config, bookmarks]) {
|
||||||
|
currentConfig = config;
|
||||||
|
bookmarkList = Array.isArray(bookmarks) ? bookmarks : [];
|
||||||
|
renderBackgroundDecode();
|
||||||
|
clearBgdDirty();
|
||||||
|
pollBackgroundDecodeStatus();
|
||||||
|
}).catch(function(err) {
|
||||||
|
console.error("background decode load failed", err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function supportedBookmarks() {
|
||||||
|
return bookmarkList.filter(function(bookmark) {
|
||||||
|
return bookmarkDecoderKinds(bookmark).length > 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function bookmarkDecoderKinds(bookmark) {
|
||||||
|
const ids = bgdSupportedIds();
|
||||||
|
const decoders = bookmark.decoders ?? [];
|
||||||
|
const explicit = decoders.map(function(item) {
|
||||||
|
return item.trim().toLowerCase();
|
||||||
|
}).filter(function(item, index, arr) {
|
||||||
|
return ids.indexOf(item) >= 0 && arr.indexOf(item) === index;
|
||||||
|
});
|
||||||
|
if (explicit.length > 0) return explicit;
|
||||||
|
const mode = bookmark.mode.trim().toUpperCase();
|
||||||
|
return (bgdWindow.decoderRegistry || []).filter(function(d) {
|
||||||
|
return d.activation === "mode_bound" && d.background_decode && d.active_modes.indexOf(mode) >= 0;
|
||||||
|
}).map(function(d) {
|
||||||
|
return d.id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function renderBackgroundDecode() {
|
||||||
|
if (!currentConfig) {
|
||||||
|
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
||||||
|
}
|
||||||
|
setCheckbox("background-decode-enabled", currentConfig.enabled);
|
||||||
|
renderBookmarkChecklist();
|
||||||
|
const isControl = isControlRole();
|
||||||
|
const panel = document.getElementById("background-decode-panel");
|
||||||
|
if (panel) {
|
||||||
|
panel.querySelectorAll("input, select, button.sch-write").forEach(function(el) {
|
||||||
|
el.disabled = !isControl;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const saveBtn = document.getElementById("background-decode-save-btn");
|
||||||
|
const resetBtn = document.getElementById("background-decode-reset-btn");
|
||||||
|
if (saveBtn) saveBtn.style.display = isControl ? "" : "none";
|
||||||
|
if (resetBtn) resetBtn.style.display = isControl ? "" : "none";
|
||||||
|
syncSaveButton();
|
||||||
|
}
|
||||||
|
function currentFilterText() {
|
||||||
|
return document.getElementById("bgd-bookmark-filter")?.value ?? "";
|
||||||
|
}
|
||||||
|
function renderBookmarkChecklist(filterText = "") {
|
||||||
|
const container = document.getElementById("bgd-bookmark-checklist");
|
||||||
|
if (!container) return;
|
||||||
|
container.innerHTML = "";
|
||||||
|
const selectedIds = new Set(
|
||||||
|
currentConfig && Array.isArray(currentConfig.bookmark_ids) ? currentConfig.bookmark_ids : []
|
||||||
|
);
|
||||||
|
const all = supportedBookmarks();
|
||||||
|
const filter = (filterText || "").trim().toLowerCase();
|
||||||
|
const filtered = filter ? all.filter(function(bm) {
|
||||||
|
const text = (bm.name + " " + formatFreq(bm.freq_hz) + " " + bm.mode).toLowerCase();
|
||||||
|
return text.indexOf(filter) >= 0;
|
||||||
|
}) : all;
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
container.innerHTML = '<div class="bgd-checklist-empty">' + escHtml(emptyListText(all.length)) + "</div>";
|
||||||
|
renderSelectionSummary();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
filtered.forEach(function(bookmark) {
|
||||||
|
const row = document.createElement("label");
|
||||||
|
row.className = "bgd-checklist-row";
|
||||||
|
row.dataset.bmId = bookmark.id;
|
||||||
|
const decoders = bookmarkDecoderKinds(bookmark);
|
||||||
|
const selected = selectedIds.has(bookmark.id);
|
||||||
|
if (selected) row.classList.add("is-selected");
|
||||||
|
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) {
|
||||||
|
onChecklistToggle(bookmark.id, e.currentTarget.checked);
|
||||||
|
});
|
||||||
|
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) {
|
||||||
|
if (!currentConfig) {
|
||||||
|
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
||||||
|
}
|
||||||
|
if (!Array.isArray(currentConfig.bookmark_ids)) currentConfig.bookmark_ids = [];
|
||||||
|
if (checked && !currentConfig.bookmark_ids.includes(bookmarkId)) {
|
||||||
|
currentConfig.bookmark_ids.push(bookmarkId);
|
||||||
|
} else if (!checked) {
|
||||||
|
currentConfig.bookmark_ids = currentConfig.bookmark_ids.filter(function(id) {
|
||||||
|
return id !== bookmarkId;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
markBgdDirty();
|
||||||
|
}
|
||||||
|
function saveBackgroundDecode() {
|
||||||
|
const rigId = currentRigId;
|
||||||
|
if (!rigId) return;
|
||||||
|
const payload = {
|
||||||
|
remote: rigId,
|
||||||
|
enabled: document.getElementById("background-decode-enabled")?.checked ?? false,
|
||||||
|
bookmark_ids: currentConfig?.bookmark_ids.slice() ?? []
|
||||||
|
};
|
||||||
|
const btn = document.getElementById("background-decode-save-btn");
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
apiPutConfig(rigId, payload).then(function(saved) {
|
||||||
|
currentConfig = saved;
|
||||||
|
renderBackgroundDecode();
|
||||||
|
clearBgdDirty();
|
||||||
|
pollBackgroundDecodeStatus();
|
||||||
|
showToast("Background decode saved.", false);
|
||||||
|
}).catch(function(err) {
|
||||||
|
showToast(`Save failed: ${errorMessage(err)}`, true);
|
||||||
|
}).finally(function() {
|
||||||
|
syncSaveButton();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function resetBackgroundDecode() {
|
||||||
|
const rigId = currentRigId;
|
||||||
|
if (!rigId) return;
|
||||||
|
if (!await bgdWindow.trxUi.confirm({ title: "Reset background decoding?", message: "The current background decode configuration will be permanently reset.", confirmLabel: "Reset" })) return;
|
||||||
|
apiResetConfig(rigId).then(function(saved) {
|
||||||
|
currentConfig = saved;
|
||||||
|
renderBackgroundDecode();
|
||||||
|
clearBgdDirty();
|
||||||
|
pollBackgroundDecodeStatus();
|
||||||
|
showToast("Background decode reset.", false);
|
||||||
|
}).catch(function(err) {
|
||||||
|
showToast(`Reset failed: ${errorMessage(err)}`, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function startStatusPolling() {
|
||||||
|
if (statusInterval) clearInterval(statusInterval);
|
||||||
|
statusInterval = setInterval(pollBackgroundDecodeStatus, 15e3);
|
||||||
|
}
|
||||||
|
function pollBackgroundDecodeStatus() {
|
||||||
|
const rigId = currentRigId;
|
||||||
|
if (!rigId) return;
|
||||||
|
apiGetStatus(rigId).then(renderStatus).catch(function() {
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function renderStatus(status) {
|
||||||
|
lastStatus = status;
|
||||||
|
statusByBookmark = new Map(
|
||||||
|
(status.entries ?? []).filter((entry) => typeof entry.bookmark_id === "string" && entry.bookmark_id.length > 0).map((entry) => [entry.bookmark_id, entry])
|
||||||
|
);
|
||||||
|
renderSpanSummary();
|
||||||
|
renderBookmarkChecklist(currentFilterText());
|
||||||
|
}
|
||||||
|
function renderSpanSummary() {
|
||||||
|
const el = document.getElementById("bgd-span-summary");
|
||||||
|
if (!el) return;
|
||||||
|
const status = lastStatus;
|
||||||
|
if (!status) {
|
||||||
|
el.textContent = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!status.active_rig) {
|
||||||
|
el.textContent = "This rig is not the one playing audio.";
|
||||||
|
el.dataset.tone = "warn";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const centre = typeof status.center_hz === "number" && Number.isFinite(status.center_hz) ? formatFreq(status.center_hz) : null;
|
||||||
|
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.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function prettyState(state) {
|
||||||
|
switch (state) {
|
||||||
|
case "active":
|
||||||
|
return "Decoding";
|
||||||
|
case "out_of_span":
|
||||||
|
return "Out of span";
|
||||||
|
case "waiting_for_spectrum":
|
||||||
|
return "Waiting for spectrum";
|
||||||
|
case "waiting_for_user":
|
||||||
|
return "Nobody listening";
|
||||||
|
case "missing_bookmark":
|
||||||
|
return "Bookmark gone";
|
||||||
|
case "no_supported_decoders":
|
||||||
|
return "No decoder";
|
||||||
|
case "disabled":
|
||||||
|
return "Off";
|
||||||
|
case "handled_by_scheduler":
|
||||||
|
case "scheduler_has_control":
|
||||||
|
return "Scheduler has it";
|
||||||
|
case "handled_by_virtual_channel":
|
||||||
|
return "On a channel";
|
||||||
|
case "pending":
|
||||||
|
return "Not saved";
|
||||||
|
default:
|
||||||
|
return "Idle";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function setCheckbox(id, value) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.checked = value;
|
||||||
|
}
|
||||||
|
function formatFreq(hz) {
|
||||||
|
if (!Number.isFinite(hz) || hz <= 0) return "--";
|
||||||
|
if (hz >= 1e6) return (hz / 1e6).toFixed(3).replace(/\.?0+$/, "") + " MHz";
|
||||||
|
if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + " kHz";
|
||||||
|
return `${String(hz)} Hz`;
|
||||||
|
}
|
||||||
|
function escHtml(value) {
|
||||||
|
const text = typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? String(value) : "";
|
||||||
|
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
}
|
||||||
|
function errorMessage(error) {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
function markBgdDirty() {
|
||||||
|
if (bgdDirty) return;
|
||||||
|
bgdDirty = true;
|
||||||
|
syncSaveButton();
|
||||||
|
}
|
||||||
|
function clearBgdDirty() {
|
||||||
|
bgdDirty = false;
|
||||||
|
syncSaveButton();
|
||||||
|
}
|
||||||
|
function syncSaveButton() {
|
||||||
|
const btn = document.getElementById("background-decode-save-btn");
|
||||||
|
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() {
|
||||||
|
return backgroundDecodeRole === "admin" || hostState.authEnabled === false;
|
||||||
|
}
|
||||||
|
function showToast(msg, isError) {
|
||||||
|
const el = document.getElementById("background-decode-toast");
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = msg;
|
||||||
|
el.style.background = isError ? "var(--color-error, #c00)" : "var(--accent-green)";
|
||||||
|
el.style.display = "block";
|
||||||
|
setTimeout(function() {
|
||||||
|
el.style.display = "none";
|
||||||
|
}, 3e3);
|
||||||
|
}
|
||||||
|
function selectAllBookmarks() {
|
||||||
|
if (!currentConfig) {
|
||||||
|
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
||||||
|
}
|
||||||
|
const ids = supportedBookmarks().map(function(bm) {
|
||||||
|
return bm.id;
|
||||||
|
});
|
||||||
|
currentConfig.bookmark_ids = ids;
|
||||||
|
renderBookmarkChecklist(currentFilterText());
|
||||||
|
markBgdDirty();
|
||||||
|
}
|
||||||
|
function deselectAllBookmarks() {
|
||||||
|
if (!currentConfig) {
|
||||||
|
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
||||||
|
}
|
||||||
|
currentConfig.bookmark_ids = [];
|
||||||
|
renderBookmarkChecklist(currentFilterText());
|
||||||
|
markBgdDirty();
|
||||||
|
}
|
||||||
|
function wireBackgroundDecodeEvents() {
|
||||||
|
const filterInput = document.getElementById("bgd-bookmark-filter");
|
||||||
|
if (filterInput && !filterInput._wired) {
|
||||||
|
filterInput._wired = true;
|
||||||
|
filterInput.addEventListener("input", function() {
|
||||||
|
renderBookmarkChecklist(filterInput.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const enabledCb = document.getElementById("background-decode-enabled");
|
||||||
|
if (enabledCb && !enabledCb._wired) {
|
||||||
|
enabledCb._wired = true;
|
||||||
|
enabledCb.addEventListener("change", function() {
|
||||||
|
if (currentConfig) currentConfig.enabled = enabledCb.checked;
|
||||||
|
markBgdDirty();
|
||||||
|
renderBookmarkChecklist(currentFilterText());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const selectAllBtn = document.getElementById("bgd-select-all-btn");
|
||||||
|
if (selectAllBtn && !selectAllBtn._wired) {
|
||||||
|
selectAllBtn._wired = true;
|
||||||
|
selectAllBtn.addEventListener("click", selectAllBookmarks);
|
||||||
|
}
|
||||||
|
const deselectAllBtn = document.getElementById("bgd-deselect-all-btn");
|
||||||
|
if (deselectAllBtn && !deselectAllBtn._wired) {
|
||||||
|
deselectAllBtn._wired = true;
|
||||||
|
deselectAllBtn.addEventListener("click", deselectAllBookmarks);
|
||||||
|
}
|
||||||
|
const saveBtn = document.getElementById("background-decode-save-btn");
|
||||||
|
if (saveBtn && !saveBtn._wired) {
|
||||||
|
saveBtn._wired = true;
|
||||||
|
saveBtn.addEventListener("click", saveBackgroundDecode);
|
||||||
|
}
|
||||||
|
const resetBtn = document.getElementById("background-decode-reset-btn");
|
||||||
|
if (resetBtn && !resetBtn._wired) {
|
||||||
|
resetBtn._wired = true;
|
||||||
|
resetBtn.addEventListener("click", () => {
|
||||||
|
void resetBackgroundDecode();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bgdWindow.trx ??= {};
|
||||||
|
bgdWindow.trx.modules ??= {};
|
||||||
|
bgdWindow.trx.modules.backgroundDecode = {
|
||||||
|
initialize: initBackgroundDecode,
|
||||||
|
wireEvents: wireBackgroundDecodeEvents,
|
||||||
|
setRig: setBackgroundDecodeRig
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,667 @@
|
|||||||
|
import {
|
||||||
|
hostCore,
|
||||||
|
hostState
|
||||||
|
} from "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
|
// src/plugins/bookmarks.ts
|
||||||
|
var bridge = window;
|
||||||
|
function bmEl(id) {
|
||||||
|
const element = document.getElementById(id);
|
||||||
|
if (!element) throw new Error(`Missing bookmark element #${id}`);
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
function bmOptionalEl(id) {
|
||||||
|
return document.getElementById(id);
|
||||||
|
}
|
||||||
|
function errorMessage(error) {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
var bmScope = "general";
|
||||||
|
function bmScopeParam(prefix, scope) {
|
||||||
|
const sep = prefix ? "&" : "?";
|
||||||
|
return sep + "scope=" + encodeURIComponent(scope != null ? scope : bmScope);
|
||||||
|
}
|
||||||
|
var bmList = [];
|
||||||
|
var bmOverlayList = [];
|
||||||
|
var bmOverlayRevision = 0;
|
||||||
|
var bmFilteredList = [];
|
||||||
|
var bmEditScope = null;
|
||||||
|
var bmCurrentPage = 1;
|
||||||
|
var BM_PAGE_SIZE = 25;
|
||||||
|
var bmSelected = /* @__PURE__ */ new Set();
|
||||||
|
function bmFmtFreq(hz) {
|
||||||
|
if (!Number.isFinite(hz) || hz <= 0) return "--";
|
||||||
|
if (hz >= 1e9) return (hz / 1e9).toFixed(6).replace(/\.?0+$/, "") + " GHz";
|
||||||
|
if (hz >= 1e6) return (hz / 1e6).toFixed(6).replace(/\.?0+$/, "") + " MHz";
|
||||||
|
if (hz >= 1e3) return (hz / 1e3).toFixed(3).replace(/\.?0+$/, "") + " kHz";
|
||||||
|
return `${hz} Hz`;
|
||||||
|
}
|
||||||
|
function bmEsc(str) {
|
||||||
|
const d = document.createElement("div");
|
||||||
|
d.appendChild(document.createTextNode(String(str)));
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
function bmCanControl() {
|
||||||
|
return !hostState.authEnabled || hostState.authRole === "admin";
|
||||||
|
}
|
||||||
|
function bmSyncAccess() {
|
||||||
|
const canCtrl = bmCanControl();
|
||||||
|
const addBtn = bmEl("bm-add-btn");
|
||||||
|
const selectAllBtn = bmEl("bm-select-all-btn");
|
||||||
|
if (addBtn) addBtn.style.display = canCtrl ? "" : "none";
|
||||||
|
if (selectAllBtn) selectAllBtn.style.display = canCtrl ? "" : "none";
|
||||||
|
}
|
||||||
|
function bmListScope() {
|
||||||
|
return hostState.lastActiveRigId || "general";
|
||||||
|
}
|
||||||
|
async function bmFetchOverlay() {
|
||||||
|
const overlayScope = bmListScope();
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/bookmarks" + bmScopeParam(false, overlayScope));
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
|
bmOverlayList = await resp.json();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch overlay bookmarks:", e);
|
||||||
|
bmOverlayList = [];
|
||||||
|
}
|
||||||
|
bmOverlayRevision++;
|
||||||
|
if (typeof bridge.syncBookmarkMapLocators === "function") {
|
||||||
|
bridge.syncBookmarkMapLocators(bmOverlayList);
|
||||||
|
}
|
||||||
|
hostCore.scheduleSpectrumDraw();
|
||||||
|
}
|
||||||
|
async function bmFetch(categoryFilter) {
|
||||||
|
let url = "/bookmarks";
|
||||||
|
let hasQuery = false;
|
||||||
|
if (categoryFilter && categoryFilter !== "") {
|
||||||
|
url += "?category=" + encodeURIComponent(categoryFilter);
|
||||||
|
hasQuery = true;
|
||||||
|
}
|
||||||
|
url += bmScopeParam(hasQuery);
|
||||||
|
const overlayPromise = bmFetchOverlay();
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url);
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
|
bmList = await resp.json();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch bookmarks:", e);
|
||||||
|
bmList = [];
|
||||||
|
}
|
||||||
|
bmSelected.clear();
|
||||||
|
bmUpdateSelectionUi();
|
||||||
|
bmSyncAccess();
|
||||||
|
bmApplyFilters();
|
||||||
|
void bmRefreshCategoryFilter(categoryFilter);
|
||||||
|
await overlayPromise;
|
||||||
|
}
|
||||||
|
function bmApplyFilters() {
|
||||||
|
const text = (bmEl("bm-text-filter")?.value || "").trim().toLowerCase();
|
||||||
|
const modeFilter = (bmEl("bm-mode-filter")?.value || "").trim().toUpperCase();
|
||||||
|
let filtered = modeFilter ? bmList.filter((bm) => (bm.mode || "").toUpperCase() === modeFilter) : bmList;
|
||||||
|
filtered = text ? filtered.filter(
|
||||||
|
(bm) => (bm.name || "").toLowerCase().includes(text) || (bm.locator || "").toLowerCase().includes(text) || (bm.category || "").toLowerCase().includes(text) || (bm.comment || "").toLowerCase().includes(text)
|
||||||
|
) : filtered;
|
||||||
|
bmFilteredList = filtered;
|
||||||
|
bmCurrentPage = 1;
|
||||||
|
bmRender(filtered);
|
||||||
|
}
|
||||||
|
async function bmRefreshCategoryFilter(keepValue) {
|
||||||
|
const sel = bmEl("bm-category-filter");
|
||||||
|
const modeSel = bmEl("bm-mode-filter");
|
||||||
|
if (!sel && !modeSel) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/bookmarks" + bmScopeParam(false));
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const all = await resp.json();
|
||||||
|
if (sel) {
|
||||||
|
const cats = [...new Set(all.map((b) => b.category || "").filter(Boolean))].sort();
|
||||||
|
while (sel.options.length > 1) sel.remove(1);
|
||||||
|
cats.forEach((cat) => {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = cat;
|
||||||
|
opt.textContent = cat;
|
||||||
|
sel.add(opt);
|
||||||
|
});
|
||||||
|
if (keepValue && cats.includes(keepValue)) sel.value = keepValue;
|
||||||
|
}
|
||||||
|
if (modeSel) {
|
||||||
|
const keepMode = modeSel.value;
|
||||||
|
const modes = [...new Set(all.map((b) => (b.mode || "").trim().toUpperCase()).filter(Boolean))].sort();
|
||||||
|
while (modeSel.options.length > 1) modeSel.remove(1);
|
||||||
|
modes.forEach((mode) => {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = mode;
|
||||||
|
opt.textContent = mode;
|
||||||
|
modeSel.add(opt);
|
||||||
|
});
|
||||||
|
if (keepMode && modes.includes(keepMode)) modeSel.value = keepMode;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function bmRender(list) {
|
||||||
|
const tbody = bmEl("bm-tbody");
|
||||||
|
const emptyEl = bmEl("bm-empty");
|
||||||
|
const paginatorEl = bmEl("bm-paginator");
|
||||||
|
const pageSummaryEl = bmEl("bm-page-summary");
|
||||||
|
const pageIndicatorEl = bmEl("bm-page-indicator");
|
||||||
|
const prevBtn = bmEl("bm-page-prev");
|
||||||
|
const nextBtn = bmEl("bm-page-next");
|
||||||
|
if (!tbody) return;
|
||||||
|
tbody.innerHTML = "";
|
||||||
|
if (list.length === 0) {
|
||||||
|
if (emptyEl) emptyEl.style.display = "";
|
||||||
|
if (paginatorEl) paginatorEl.style.display = "none";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (emptyEl) emptyEl.style.display = "none";
|
||||||
|
const canControl = bmCanControl();
|
||||||
|
const totalPages = Math.max(1, Math.ceil(list.length / BM_PAGE_SIZE));
|
||||||
|
const page = Math.min(Math.max(bmCurrentPage, 1), totalPages);
|
||||||
|
bmCurrentPage = page;
|
||||||
|
const startIndex = (page - 1) * BM_PAGE_SIZE;
|
||||||
|
const endIndex = Math.min(startIndex + BM_PAGE_SIZE, list.length);
|
||||||
|
const pageItems = list.slice(startIndex, endIndex);
|
||||||
|
const showScope = bmScope !== "general";
|
||||||
|
pageItems.forEach((bm) => {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
tr.dataset.bmId = bm.id;
|
||||||
|
const bwCell = bm.bandwidth_hz ? bmFmtFreq(bm.bandwidth_hz) : "--";
|
||||||
|
const locatorCell = bm.locator || "--";
|
||||||
|
const catCell = bm.category || "Uncategorised";
|
||||||
|
const decoderCell = (bm.decoders || []).join(", ").toUpperCase() || "--";
|
||||||
|
const commentCell = bm.comment || "";
|
||||||
|
const checked = bmSelected.has(bm.id) ? " checked" : "";
|
||||||
|
const scopeBadge = showScope && bm.scope === "general" ? ' <span class="bm-scope-badge">G</span>' : "";
|
||||||
|
tr.innerHTML = `<td class="bm-col-sel"><input type="checkbox" class="bm-row-sel" data-bm-id="${bmEsc(bm.id)}"${checked} aria-label="Select ${bmEsc(bm.name)}" /></td><td class="bm-col-name">${bmEsc(bm.name)}${scopeBadge}</td><td class="bm-col-freq">${bmFmtFreq(bm.freq_hz)}</td><td class="bm-col-mode">${bmEsc(bm.mode)}</td><td class="bm-col-bw">${bwCell}</td><td class="bm-col-loc">${bmEsc(locatorCell)}</td><td class="bm-col-cat">${bmEsc(catCell)}</td><td class="bm-col-dec">${bmEsc(decoderCell)}</td><td class="bm-col-cmt">${bmEsc(commentCell)}</td><td class="bm-col-act"><button class="bm-tune-btn" type="button" data-bm-id="${bmEsc(bm.id)}">Tune</button>` + (canControl ? `<button class="bm-edit-btn" type="button" data-bm-id="${bmEsc(bm.id)}">Edit</button><button class="bm-del-btn" type="button" data-bm-id="${bmEsc(bm.id)}">Delete</button>` : "") + `</td>`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
bmSyncSelectAllCheckbox();
|
||||||
|
if (paginatorEl) paginatorEl.style.display = totalPages > 1 ? "flex" : "";
|
||||||
|
if (pageSummaryEl) pageSummaryEl.textContent = `Showing ${startIndex + 1}-${endIndex} of ${list.length}`;
|
||||||
|
if (pageIndicatorEl) pageIndicatorEl.textContent = `Page ${page} of ${totalPages}`;
|
||||||
|
if (prevBtn) prevBtn.disabled = page <= 1;
|
||||||
|
if (nextBtn) nextBtn.disabled = page >= totalPages;
|
||||||
|
}
|
||||||
|
function bmChangePage(delta) {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(bmFilteredList.length / BM_PAGE_SIZE));
|
||||||
|
const nextPage = Math.min(Math.max(bmCurrentPage + delta, 1), totalPages);
|
||||||
|
if (nextPage === bmCurrentPage) return;
|
||||||
|
bmCurrentPage = nextPage;
|
||||||
|
bmRender(bmFilteredList);
|
||||||
|
}
|
||||||
|
function bmReadDecoders() {
|
||||||
|
return hostState.decoderRegistry.filter((d) => d.bookmark_selectable).filter((d) => bmOptionalEl("bm-dec-" + d.id)?.checked).map((d) => d.id);
|
||||||
|
}
|
||||||
|
function bmWriteDecoders(decoders) {
|
||||||
|
const set = new Set(decoders || []);
|
||||||
|
hostState.decoderRegistry.filter((d) => d.bookmark_selectable).forEach((d) => {
|
||||||
|
const el = bmOptionalEl("bm-dec-" + d.id);
|
||||||
|
if (el) el.checked = set.has(d.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function bmBuildDecoderCheckboxes() {
|
||||||
|
const container = bmEl("bm-decoder-checkboxes");
|
||||||
|
if (!container) return;
|
||||||
|
container.innerHTML = "";
|
||||||
|
hostState.decoderRegistry.filter((d) => d.bookmark_selectable).forEach((d) => {
|
||||||
|
const label = document.createElement("label");
|
||||||
|
label.className = "bm-decoder-check";
|
||||||
|
label.innerHTML = '<input type="checkbox" id="bm-dec-' + d.id + '" value="' + d.id + '" /> ' + d.label;
|
||||||
|
container.appendChild(label);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function bmOpenForm(bm) {
|
||||||
|
const wrap = bmEl("bm-form-wrap");
|
||||||
|
if (!wrap) return;
|
||||||
|
bmEditScope = bm ? bm.scope || bmScope : null;
|
||||||
|
bmBuildDecoderCheckboxes();
|
||||||
|
bmEl("bm-id").value = bm ? bm.id : "";
|
||||||
|
bmEl("bm-name").value = bm ? bm.name : "";
|
||||||
|
bmEl("bm-freq").value = bm ? String(bm.freq_hz) : "";
|
||||||
|
bmEl("bm-mode").value = bm ? bm.mode : "";
|
||||||
|
bmEl("bm-bw").value = bm?.bandwidth_hz ? String(bm.bandwidth_hz) : "";
|
||||||
|
bmEl("bm-locator").value = bm ? bm.locator || "" : "";
|
||||||
|
bmEl("bm-category-input").value = bm ? bm.category || "" : "";
|
||||||
|
bmEl("bm-comment").value = bm ? bm.comment || "" : "";
|
||||||
|
bmWriteDecoders(bm?.decoders ?? []);
|
||||||
|
bmEl("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark";
|
||||||
|
wrap.style.display = "flex";
|
||||||
|
bmEl("bm-name").focus();
|
||||||
|
}
|
||||||
|
function bmCloseForm() {
|
||||||
|
const wrap = bmEl("bm-form-wrap");
|
||||||
|
if (wrap) wrap.style.display = "none";
|
||||||
|
}
|
||||||
|
function bmPrefillFromStatus() {
|
||||||
|
const freqHz = hostState.lastFreqHz;
|
||||||
|
if (freqHz != null && Number.isFinite(freqHz)) {
|
||||||
|
bmEl("bm-freq").value = String(Math.round(freqHz));
|
||||||
|
}
|
||||||
|
if (hostState.lastModeName) {
|
||||||
|
bmEl("bm-mode").value = hostState.lastModeName;
|
||||||
|
}
|
||||||
|
if (hostState.currentBandwidthHz > 0) {
|
||||||
|
bmEl("bm-bw").value = String(Math.round(hostState.currentBandwidthHz));
|
||||||
|
}
|
||||||
|
const activeDecoders = hostState.decoderRegistry.filter((d) => d.bookmark_selectable && d.activation === "toggle").filter((d) => bmOptionalEl(d.id + "-decode-toggle-btn")?.dataset.enabled === "true").map((d) => d.id);
|
||||||
|
bmWriteDecoders(activeDecoders);
|
||||||
|
}
|
||||||
|
async function bmSave(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const id = bmEl("bm-id").value;
|
||||||
|
const name = bmEl("bm-name").value.trim();
|
||||||
|
const freqStr = bmEl("bm-freq").value;
|
||||||
|
const freq_hz = parseInt(freqStr, 10);
|
||||||
|
const mode = bmEl("bm-mode").value.trim();
|
||||||
|
const bwStr = bmEl("bm-bw").value;
|
||||||
|
const bandwidth_hz = bwStr ? parseInt(bwStr, 10) : null;
|
||||||
|
const locator = bmEl("bm-locator").value.trim().toUpperCase();
|
||||||
|
const category = bmEl("bm-category-input").value.trim();
|
||||||
|
const comment = bmEl("bm-comment").value.trim();
|
||||||
|
const decoders = bmReadDecoders();
|
||||||
|
const formError = bmEl("bm-form-error");
|
||||||
|
if (formError) formError.textContent = "";
|
||||||
|
if (!name || !Number.isFinite(freq_hz) || !mode) {
|
||||||
|
if (formError) formError.textContent = "Enter a name, a valid frequency, and a mode.";
|
||||||
|
const invalid = !name ? bmEl("bm-name") : !Number.isFinite(freq_hz) ? bmEl("bm-freq") : bmEl("bm-mode");
|
||||||
|
invalid?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = {
|
||||||
|
name,
|
||||||
|
freq_hz,
|
||||||
|
mode,
|
||||||
|
bandwidth_hz,
|
||||||
|
locator: locator || null,
|
||||||
|
category,
|
||||||
|
comment,
|
||||||
|
decoders
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
let resp;
|
||||||
|
if (id) {
|
||||||
|
resp = await fetch("/bookmarks/" + encodeURIComponent(id) + bmScopeParam(false, bmEditScope), {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
resp = await fetch("/bookmarks" + bmScopeParam(false), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!resp.ok) {
|
||||||
|
const text = await resp.text();
|
||||||
|
if (resp.status === 409) {
|
||||||
|
throw new Error("A bookmark for that frequency already exists.");
|
||||||
|
}
|
||||||
|
throw new Error(text || `HTTP ${resp.status}`);
|
||||||
|
}
|
||||||
|
bmCloseForm();
|
||||||
|
await bmFetch(bmEl("bm-category-filter").value);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to save bookmark:", err);
|
||||||
|
if (formError) formError.textContent = "Failed to save bookmark: " + errorMessage(err);
|
||||||
|
bridge.trxUi.notify?.("Bookmark could not be saved", { kind: "error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function bmDelete(id) {
|
||||||
|
if (!await bridge.trxUi.confirm({ title: "Delete bookmark?", message: "This bookmark will be permanently removed.", confirmLabel: "Delete" })) return;
|
||||||
|
const bm = bmList.find((b) => b.id === id);
|
||||||
|
const scope = bm ? bm.scope : void 0;
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/bookmarks/" + encodeURIComponent(id) + bmScopeParam(false, scope), {
|
||||||
|
method: "DELETE"
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
|
await bmFetch(bmEl("bm-category-filter").value);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete bookmark:", err);
|
||||||
|
bridge.trxUi.notify?.("Failed to delete bookmark: " + errorMessage(err), { kind: "error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function bmApply(bm) {
|
||||||
|
try {
|
||||||
|
const modeEl = document.getElementById("mode");
|
||||||
|
if (modeEl) {
|
||||||
|
modeEl.value = (bm.mode || "").toUpperCase();
|
||||||
|
hostCore.syncModePicker();
|
||||||
|
}
|
||||||
|
if (bm.bandwidth_hz) {
|
||||||
|
hostState.currentBandwidthHz = bm.bandwidth_hz;
|
||||||
|
hostCore.syncBandwidthInput(bm.bandwidth_hz);
|
||||||
|
}
|
||||||
|
hostCore.armOptimisticFrequency(bm.freq_hz);
|
||||||
|
hostCore.applyLocalTunedFrequency(bm.freq_hz, true);
|
||||||
|
if (hostState.lastSpectrumData) {
|
||||||
|
hostCore.scheduleSpectrumDraw();
|
||||||
|
}
|
||||||
|
const tunePromise = (async () => {
|
||||||
|
await bridge.trx.modules.vchan?.takeSchedulerControl();
|
||||||
|
const onVirtual = await bridge.trx.modules.vchan?.interceptMode(bm.mode) ?? false;
|
||||||
|
if (!onVirtual) {
|
||||||
|
await hostCore.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
|
||||||
|
}
|
||||||
|
if (bm.bandwidth_hz) {
|
||||||
|
const bwHandledByVchan = await bridge.trx.modules.vchan?.interceptBandwidth(bm.bandwidth_hz) ?? false;
|
||||||
|
if (!bwHandledByVchan) {
|
||||||
|
await hostCore.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hostCore.setRigFrequency(bm.freq_hz);
|
||||||
|
})();
|
||||||
|
const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0;
|
||||||
|
const modeUp = (bm.mode || "").toUpperCase();
|
||||||
|
const allToggleDecoders = hostState.decoderRegistry.filter(
|
||||||
|
(d) => d.activation === "toggle"
|
||||||
|
);
|
||||||
|
const decoderPromise = allToggleDecoders.length ? (async () => {
|
||||||
|
let statusUrl = "/status";
|
||||||
|
const rigId = hostState.lastActiveRigId;
|
||||||
|
if (rigId) {
|
||||||
|
statusUrl += "?remote=" + encodeURIComponent(rigId);
|
||||||
|
}
|
||||||
|
const statusResp = await fetch(statusUrl);
|
||||||
|
if (!statusResp.ok) return;
|
||||||
|
const st = await statusResp.json();
|
||||||
|
const toggles = [];
|
||||||
|
for (const d of allToggleDecoders) {
|
||||||
|
const statusKey = d.id.replace(/-/g, "_") + "_decode_enabled";
|
||||||
|
const currentlyOn = !!st[statusKey];
|
||||||
|
const compatible = Array.isArray(d.active_modes) && d.active_modes.includes(modeUp);
|
||||||
|
let wanted;
|
||||||
|
if (!compatible) {
|
||||||
|
wanted = false;
|
||||||
|
} else if (hasDecoders) {
|
||||||
|
wanted = bm.decoders?.includes(d.id) ?? false;
|
||||||
|
} else {
|
||||||
|
wanted = currentlyOn;
|
||||||
|
}
|
||||||
|
if (wanted !== currentlyOn) {
|
||||||
|
toggles.push(hostCore.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (toggles.length) await Promise.all(toggles);
|
||||||
|
})() : Promise.resolve();
|
||||||
|
void Promise.all([tunePromise, decoderPromise]).catch((error) => {
|
||||||
|
console.error("Bookmark apply background error:", error);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to apply bookmark:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bridge.trx.modules.bookmarks = {
|
||||||
|
get overlayList() {
|
||||||
|
return bmOverlayList;
|
||||||
|
},
|
||||||
|
get overlayRevision() {
|
||||||
|
return bmOverlayRevision;
|
||||||
|
},
|
||||||
|
refreshOverlay: bmFetchOverlay,
|
||||||
|
invalidateColors() {
|
||||||
|
bmOverlayRevision += 1;
|
||||||
|
},
|
||||||
|
apply: bmApply,
|
||||||
|
formatFrequency: bmFmtFreq,
|
||||||
|
fetch: bmFetch,
|
||||||
|
populateScopePicker: bmPopulateScopePicker
|
||||||
|
};
|
||||||
|
function bmUpdateSelectionUi() {
|
||||||
|
const count = bmSelected.size;
|
||||||
|
const canCtrl = bmCanControl();
|
||||||
|
const visible = count > 0 && canCtrl;
|
||||||
|
const btn = bmEl("bm-del-selected-btn");
|
||||||
|
const countEl = bmEl("bm-del-selected-count");
|
||||||
|
if (btn) btn.style.display = visible ? "" : "none";
|
||||||
|
if (countEl) countEl.textContent = String(count);
|
||||||
|
const moveWrap = bmEl("bm-move-selected-wrap");
|
||||||
|
const moveCountEl = bmEl("bm-move-selected-count");
|
||||||
|
if (moveWrap) moveWrap.style.display = visible ? "" : "none";
|
||||||
|
if (moveCountEl) moveCountEl.textContent = String(count);
|
||||||
|
if (visible) bmPopulateMoveTarget();
|
||||||
|
const selectAllBtn = bmEl("bm-select-all-btn");
|
||||||
|
if (selectAllBtn && bmCanControl()) {
|
||||||
|
const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id));
|
||||||
|
selectAllBtn.textContent = allSelected ? "Deselect All" : "Select All";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function bmPopulateMoveTarget() {
|
||||||
|
const sel = bmEl("bm-move-target");
|
||||||
|
if (!sel) return;
|
||||||
|
const rigIds = hostState.lastRigIds;
|
||||||
|
const displayNames = hostState.lastRigDisplayNames;
|
||||||
|
const prev = sel.value;
|
||||||
|
sel.innerHTML = "";
|
||||||
|
if (bmScope !== "general") {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = "general";
|
||||||
|
opt.textContent = "General";
|
||||||
|
sel.appendChild(opt);
|
||||||
|
}
|
||||||
|
rigIds.forEach((id) => {
|
||||||
|
if (id === bmScope) return;
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = id;
|
||||||
|
opt.textContent = displayNames[id] || id;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
if (prev && sel.querySelector(`option[value="${CSS.escape(prev)}"]`)) {
|
||||||
|
sel.value = prev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function bmMoveSelected() {
|
||||||
|
const ids = Array.from(bmSelected);
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
const target = bmEl("bm-move-target")?.value;
|
||||||
|
if (!target) return;
|
||||||
|
const targetLabel = bmEl("bm-move-target")?.selectedOptions[0]?.textContent || target;
|
||||||
|
if (!await bridge.trxUi.confirm({
|
||||||
|
title: "Move selected bookmarks?",
|
||||||
|
message: `${ids.length} bookmark${ids.length > 1 ? "s" : ""} will move to “${targetLabel}”.`,
|
||||||
|
confirmLabel: "Move",
|
||||||
|
danger: false
|
||||||
|
})) return;
|
||||||
|
try {
|
||||||
|
const byScope = {};
|
||||||
|
for (const id of ids) {
|
||||||
|
const bm = bmList.find((b) => b.id === id);
|
||||||
|
const scope = bm?.scope || bmScope;
|
||||||
|
if (scope === target) continue;
|
||||||
|
(byScope[scope] ||= []).push(id);
|
||||||
|
}
|
||||||
|
await Promise.all(Object.entries(byScope).map(
|
||||||
|
([scope, scopeIds]) => fetch("/bookmarks/batch_move" + bmScopeParam(false, scope), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ids: scopeIds, to: target })
|
||||||
|
}).then((r) => {
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||||
|
})
|
||||||
|
));
|
||||||
|
bmSelected.clear();
|
||||||
|
bmUpdateSelectionUi();
|
||||||
|
await bmFetch(bmEl("bm-category-filter").value);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to move bookmarks:", err);
|
||||||
|
bridge.trxUi.notify?.("Failed to move bookmarks: " + errorMessage(err), { kind: "error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function bmSyncSelectAllCheckbox() {
|
||||||
|
const selectAll = bmEl("bm-select-all");
|
||||||
|
if (!selectAll) return;
|
||||||
|
const checkboxes = document.querySelectorAll(".bm-row-sel");
|
||||||
|
if (checkboxes.length === 0) {
|
||||||
|
selectAll.checked = false;
|
||||||
|
selectAll.indeterminate = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const checkedCount = Array.from(checkboxes).filter((cb) => cb.checked).length;
|
||||||
|
selectAll.checked = checkedCount === checkboxes.length;
|
||||||
|
selectAll.indeterminate = checkedCount > 0 && checkedCount < checkboxes.length;
|
||||||
|
}
|
||||||
|
async function bmDeleteSelected() {
|
||||||
|
const ids = Array.from(bmSelected);
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
if (!await bridge.trxUi.confirm({
|
||||||
|
title: "Delete selected bookmarks?",
|
||||||
|
message: `${ids.length} bookmark${ids.length > 1 ? "s" : ""} will be permanently removed.`,
|
||||||
|
confirmLabel: "Delete"
|
||||||
|
})) return;
|
||||||
|
try {
|
||||||
|
const byScope = {};
|
||||||
|
for (const id of ids) {
|
||||||
|
const bm = bmList.find((b) => b.id === id);
|
||||||
|
const scope = bm?.scope || bmScope;
|
||||||
|
(byScope[scope] ||= []).push(id);
|
||||||
|
}
|
||||||
|
await Promise.all(Object.entries(byScope).map(
|
||||||
|
([scope, scopeIds]) => fetch("/bookmarks/batch_delete" + bmScopeParam(false, scope), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ids: scopeIds })
|
||||||
|
}).then((r) => {
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||||
|
})
|
||||||
|
));
|
||||||
|
bmSelected.clear();
|
||||||
|
bmUpdateSelectionUi();
|
||||||
|
await bmFetch(bmEl("bm-category-filter").value);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete bookmarks:", err);
|
||||||
|
bridge.trxUi.notify?.("Failed to delete bookmarks: " + errorMessage(err), { kind: "error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function bmPopulateScopePicker() {
|
||||||
|
const picker = bmEl("bm-scope-picker");
|
||||||
|
if (!picker) return;
|
||||||
|
const rigIds = hostState.lastRigIds;
|
||||||
|
const displayNames = hostState.lastRigDisplayNames;
|
||||||
|
const prev = picker.value;
|
||||||
|
while (picker.options.length > 1) picker.remove(1);
|
||||||
|
rigIds.forEach((id) => {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = id;
|
||||||
|
opt.textContent = displayNames[id] || id;
|
||||||
|
picker.appendChild(opt);
|
||||||
|
});
|
||||||
|
if (prev && (prev === "general" || rigIds.includes(prev))) {
|
||||||
|
picker.value = prev;
|
||||||
|
} else {
|
||||||
|
picker.value = "general";
|
||||||
|
}
|
||||||
|
bmScope = picker.value;
|
||||||
|
}
|
||||||
|
(function initBookmarks() {
|
||||||
|
bmSyncAccess();
|
||||||
|
bmBuildDecoderCheckboxes();
|
||||||
|
hostCore.onDecoderRegistryReady(bmBuildDecoderCheckboxes);
|
||||||
|
bmPopulateScopePicker();
|
||||||
|
const scopePicker = bmEl("bm-scope-picker");
|
||||||
|
if (scopePicker) {
|
||||||
|
scopePicker.addEventListener("change", (e) => {
|
||||||
|
bmScope = e.currentTarget.value;
|
||||||
|
void bmFetch(bmEl("bm-category-filter").value || "");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.querySelector(".tab-bar")?.addEventListener("click", (e) => {
|
||||||
|
const btn = e.target instanceof Element ? e.target.closest('.tab[data-tab="bookmarks"]') : null;
|
||||||
|
if (!btn) return;
|
||||||
|
void bmFetch(bmEl("bm-category-filter").value);
|
||||||
|
});
|
||||||
|
bmEl("bm-add-btn").addEventListener("click", () => {
|
||||||
|
bmOpenForm(null);
|
||||||
|
bmPrefillFromStatus();
|
||||||
|
});
|
||||||
|
bmEl("bm-category-filter").addEventListener("change", (e) => {
|
||||||
|
void bmFetch(e.currentTarget.value);
|
||||||
|
});
|
||||||
|
bmEl("bm-mode-filter").addEventListener("change", () => {
|
||||||
|
bmApplyFilters();
|
||||||
|
});
|
||||||
|
bmEl("bm-text-filter").addEventListener("input", () => {
|
||||||
|
bmApplyFilters();
|
||||||
|
});
|
||||||
|
bmEl("bm-page-prev").addEventListener("click", () => {
|
||||||
|
bmChangePage(-1);
|
||||||
|
});
|
||||||
|
bmEl("bm-page-next").addEventListener("click", () => {
|
||||||
|
bmChangePage(1);
|
||||||
|
});
|
||||||
|
bmEl("bm-form").addEventListener("submit", (event) => {
|
||||||
|
void bmSave(event);
|
||||||
|
});
|
||||||
|
bmEl("bm-form-cancel").addEventListener("click", bmCloseForm);
|
||||||
|
const formWrap = bmEl("bm-form-wrap");
|
||||||
|
if (formWrap) {
|
||||||
|
formWrap.addEventListener("click", (e) => {
|
||||||
|
if (e.target === formWrap) bmCloseForm();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Escape" && bmEl("bm-form-wrap")?.style.display === "flex") {
|
||||||
|
bmCloseForm();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
bmEl("bm-select-all").addEventListener("change", (e) => {
|
||||||
|
const checked = e.currentTarget.checked;
|
||||||
|
document.querySelectorAll(".bm-row-sel").forEach((cb) => {
|
||||||
|
cb.checked = checked;
|
||||||
|
const id = cb.dataset.bmId;
|
||||||
|
if (!id) return;
|
||||||
|
if (checked) bmSelected.add(id);
|
||||||
|
else bmSelected.delete(id);
|
||||||
|
});
|
||||||
|
bmUpdateSelectionUi();
|
||||||
|
});
|
||||||
|
bmEl("bm-select-all-btn").addEventListener("click", () => {
|
||||||
|
const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id));
|
||||||
|
if (allSelected) {
|
||||||
|
bmSelected.clear();
|
||||||
|
} else {
|
||||||
|
bmFilteredList.forEach((bm) => bmSelected.add(bm.id));
|
||||||
|
}
|
||||||
|
document.querySelectorAll(".bm-row-sel").forEach((cb) => {
|
||||||
|
cb.checked = !!cb.dataset.bmId && bmSelected.has(cb.dataset.bmId);
|
||||||
|
});
|
||||||
|
bmSyncSelectAllCheckbox();
|
||||||
|
bmUpdateSelectionUi();
|
||||||
|
});
|
||||||
|
bmEl("bm-del-selected-btn").addEventListener("click", () => {
|
||||||
|
void bmDeleteSelected();
|
||||||
|
});
|
||||||
|
bmEl("bm-move-selected-btn").addEventListener("click", () => {
|
||||||
|
void bmMoveSelected();
|
||||||
|
});
|
||||||
|
bmEl("bm-tbody").addEventListener("click", (e) => {
|
||||||
|
void (async () => {
|
||||||
|
if (!(e.target instanceof Element)) return;
|
||||||
|
const checkbox = e.target.closest(".bm-row-sel");
|
||||||
|
if (checkbox) {
|
||||||
|
const id = checkbox.dataset.bmId;
|
||||||
|
if (!id) return;
|
||||||
|
if (checkbox.checked) bmSelected.add(id);
|
||||||
|
else bmSelected.delete(id);
|
||||||
|
bmSyncSelectAllCheckbox();
|
||||||
|
bmUpdateSelectionUi();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tuneBtn = e.target.closest(".bm-tune-btn");
|
||||||
|
const editBtn = e.target.closest(".bm-edit-btn");
|
||||||
|
const delBtn = e.target.closest(".bm-del-btn");
|
||||||
|
if (tuneBtn) {
|
||||||
|
const bm = bmList.find((b) => b.id === tuneBtn.dataset.bmId);
|
||||||
|
if (bm) bmApply(bm);
|
||||||
|
} else if (editBtn) {
|
||||||
|
const bm = bmList.find((b) => b.id === editBtn.dataset.bmId);
|
||||||
|
if (bm) bmOpenForm(bm);
|
||||||
|
} else if (delBtn) {
|
||||||
|
const id = delBtn.dataset.bmId;
|
||||||
|
if (id) await bmDelete(id);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
void bmFetch("");
|
||||||
|
})();
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// src/plugins/host.ts
|
||||||
|
var host = window;
|
||||||
|
var hostState = host.trx.state;
|
||||||
|
var hostCore = host.trx.core;
|
||||||
|
|
||||||
|
export {
|
||||||
|
hostState,
|
||||||
|
hostCore
|
||||||
|
};
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
// src/plugins/aprs-shared.ts
|
||||||
|
function escapeAprsHtml(value) {
|
||||||
|
return String(value ?? "").replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
||||||
|
}
|
||||||
|
function aprsPacketCategory(packet) {
|
||||||
|
const type = (packet.type ?? "").toLowerCase();
|
||||||
|
const info = (packet.info ?? "").toLowerCase();
|
||||||
|
if (packet.lat != null && packet.lon != null || type.includes("position")) return "position";
|
||||||
|
if (type.includes("message") || info.startsWith(":")) return "message";
|
||||||
|
if (type.includes("weather") || info.startsWith("_")) return "weather";
|
||||||
|
if (type.includes("telemetry") || info.startsWith("t#")) return "telemetry";
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
function aprsCategoryLabel(category) {
|
||||||
|
switch (category) {
|
||||||
|
case "position":
|
||||||
|
return "Position";
|
||||||
|
case "message":
|
||||||
|
return "Message";
|
||||||
|
case "weather":
|
||||||
|
return "Weather";
|
||||||
|
case "telemetry":
|
||||||
|
return "Telemetry";
|
||||||
|
default:
|
||||||
|
return "Other";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function aprsAgeText(timestampMs) {
|
||||||
|
if (typeof timestampMs !== "number" || !Number.isFinite(timestampMs)) return "just now";
|
||||||
|
const seconds = Math.round(Math.max(0, Date.now() - timestampMs) / 1e3);
|
||||||
|
if (seconds < 5) return "just now";
|
||||||
|
if (seconds < 60) return `${String(seconds)}s ago`;
|
||||||
|
const minutes = Math.round(seconds / 60);
|
||||||
|
if (minutes < 60) return `${String(minutes)}m ago`;
|
||||||
|
return `${String(Math.round(minutes / 60))}h ago`;
|
||||||
|
}
|
||||||
|
function aprsPacketSignature(packet) {
|
||||||
|
return [
|
||||||
|
packet.srcCall ?? "",
|
||||||
|
packet.destCall ?? "",
|
||||||
|
packet.path ?? "",
|
||||||
|
packet.info ?? "",
|
||||||
|
packet.type ?? "",
|
||||||
|
packet.lat?.toFixed(4) ?? "",
|
||||||
|
packet.lon?.toFixed(4) ?? ""
|
||||||
|
].join("|");
|
||||||
|
}
|
||||||
|
function collapseAprsDuplicates(packets) {
|
||||||
|
const seen = /* @__PURE__ */ new Set();
|
||||||
|
return packets.filter((packet) => {
|
||||||
|
const signature = aprsPacketSignature(packet);
|
||||||
|
if (seen.has(signature)) return false;
|
||||||
|
seen.add(signature);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function aprsHexBytes(bytes) {
|
||||||
|
if (!bytes?.length) return "--";
|
||||||
|
return bytes.map((byte) => byte.toString(16).toUpperCase().padStart(2, "0")).join(" ");
|
||||||
|
}
|
||||||
|
function renderAprsInfo(packet) {
|
||||||
|
if (packet.info_bytes?.length) {
|
||||||
|
return packet.info_bytes.map((byte) => renderAprsByte(byte)).join("");
|
||||||
|
}
|
||||||
|
return Array.from(packet.info ?? "", (character) => renderAprsCharacter(character)).join("");
|
||||||
|
}
|
||||||
|
function renderAprsByte(byte) {
|
||||||
|
return byte >= 32 && byte <= 126 ? escapeAprsCharacter(String.fromCharCode(byte)) : `<span class="aprs-byte">0x${byte.toString(16).toUpperCase().padStart(2, "0")}</span>`;
|
||||||
|
}
|
||||||
|
function renderAprsCharacter(character) {
|
||||||
|
const code = character.charCodeAt(0);
|
||||||
|
return code >= 32 && code <= 126 ? escapeAprsCharacter(character) : `<span class="aprs-byte">0x${code.toString(16).toUpperCase().padStart(2, "0")}</span>`;
|
||||||
|
}
|
||||||
|
function escapeAprsCharacter(character) {
|
||||||
|
if (character === "<") return "<";
|
||||||
|
if (character === ">") return ">";
|
||||||
|
if (character === "&") return "&";
|
||||||
|
if (character === '"') return """;
|
||||||
|
return character;
|
||||||
|
}
|
||||||
|
var APRS_SPRITE_COLUMNS = 16;
|
||||||
|
var APRS_SPRITE_CELL_PX = 24;
|
||||||
|
var APRS_SPRITE_FIRST_CODE = 33;
|
||||||
|
var APRS_SPRITE_LAST_CODE = 126;
|
||||||
|
function aprsSpriteOffset(code) {
|
||||||
|
if (code.length !== 1) return null;
|
||||||
|
const point = code.charCodeAt(0);
|
||||||
|
if (point < APRS_SPRITE_FIRST_CODE || point > APRS_SPRITE_LAST_CODE) return null;
|
||||||
|
const index = point - APRS_SPRITE_FIRST_CODE;
|
||||||
|
const column = index % APRS_SPRITE_COLUMNS;
|
||||||
|
const row = Math.floor(index / APRS_SPRITE_COLUMNS);
|
||||||
|
return `${String(-column * APRS_SPRITE_CELL_PX)}px ${String(-row * APRS_SPRITE_CELL_PX)}px`;
|
||||||
|
}
|
||||||
|
function aprsSymbolSprite(symbolTable, symbolCode) {
|
||||||
|
if (!symbolTable || !symbolCode) return null;
|
||||||
|
const symbolOffset = aprsSpriteOffset(symbolCode);
|
||||||
|
if (!symbolOffset) return null;
|
||||||
|
if (symbolTable === "/") {
|
||||||
|
return {
|
||||||
|
className: "aprs-symbol-primary",
|
||||||
|
backgroundPosition: symbolOffset,
|
||||||
|
label: `Primary APRS symbol ${symbolTable}${symbolCode}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (symbolTable === "\\") {
|
||||||
|
return {
|
||||||
|
className: "aprs-symbol-alternate",
|
||||||
|
backgroundPosition: symbolOffset,
|
||||||
|
label: `Alternate APRS symbol ${symbolTable}${symbolCode}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const overlayOffset = aprsSpriteOffset(symbolTable);
|
||||||
|
if (!overlayOffset) return null;
|
||||||
|
return {
|
||||||
|
className: "aprs-symbol-overlaid",
|
||||||
|
backgroundPosition: `${overlayOffset}, ${symbolOffset}`,
|
||||||
|
label: `Alternate APRS symbol \\${symbolCode} with overlay ${symbolTable}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function renderAprsSymbolSlot(packet, escapeHtml) {
|
||||||
|
return renderLocalAprsSymbol(packet, escapeHtml) || '<span class="aprs-symbol aprs-symbol-empty" aria-hidden="true"></span>';
|
||||||
|
}
|
||||||
|
function renderLocalAprsSymbol(packet, escapeHtml) {
|
||||||
|
if (!packet.symbolTable || !packet.symbolCode) return "";
|
||||||
|
const sprite = aprsSymbolSprite(packet.symbolTable, packet.symbolCode);
|
||||||
|
if (sprite) {
|
||||||
|
return `<span class="aprs-symbol ${sprite.className}" role="img" style="background-position:${sprite.backgroundPosition}" title="${escapeHtml(sprite.label)}" aria-label="${escapeHtml(sprite.label)}"></span>`;
|
||||||
|
}
|
||||||
|
const symbol = escapeHtml(packet.symbolCode);
|
||||||
|
const table = packet.symbolTable === "/" ? "Primary" : "Alternate";
|
||||||
|
return `<span class="aprs-symbol aprs-symbol-local" title="${table} APRS symbol ${symbol}">${symbol}</span>`;
|
||||||
|
}
|
||||||
|
function normalizeAprsPacket(packet, receiver) {
|
||||||
|
return {
|
||||||
|
rig_id: packet.rig_id || null,
|
||||||
|
receiver,
|
||||||
|
srcCall: packet.src_call ?? "",
|
||||||
|
destCall: packet.dest_call ?? "",
|
||||||
|
path: packet.path ?? "",
|
||||||
|
info: packet.info ?? "",
|
||||||
|
info_bytes: packet.info_bytes ?? [],
|
||||||
|
type: packet.packet_type ?? "",
|
||||||
|
crcOk: packet.crc_ok ?? false,
|
||||||
|
ts_ms: packet.ts_ms ?? null,
|
||||||
|
lat: packet.lat ?? null,
|
||||||
|
lon: packet.lon ?? null,
|
||||||
|
symbolTable: packet.symbol_table ?? null,
|
||||||
|
symbolCode: packet.symbol_code ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function fahrenheitToCelsius(fahrenheit) {
|
||||||
|
return Math.round((fahrenheit - 32) * 5 / 9 * 10) / 10;
|
||||||
|
}
|
||||||
|
function summarizeAprsWeather(info) {
|
||||||
|
const parts = [];
|
||||||
|
const temperature = /t(-?\d{2,3})/.exec(info);
|
||||||
|
if (temperature) parts.push(`${fahrenheitToCelsius(Number(temperature[1]))} °C`);
|
||||||
|
const wind = /(\d{3})\/(\d{3})/.exec(info) ?? /c(\d{3}).*?s(\d{3})/.exec(info);
|
||||||
|
if (wind) {
|
||||||
|
const gust = /g(\d{3})/.exec(info);
|
||||||
|
const knots = Number(wind[2]);
|
||||||
|
parts.push(`wind ${Number(wind[1])}° ${knots} kt${gust ? ` gust ${Number(gust[1])}` : ""}`);
|
||||||
|
}
|
||||||
|
const humidity = /h(\d{2})/.exec(info);
|
||||||
|
if (humidity) {
|
||||||
|
const value = Number(humidity[1]);
|
||||||
|
parts.push(`${value === 0 ? 100 : value}% RH`);
|
||||||
|
}
|
||||||
|
const pressure = /b(\d{5})/.exec(info);
|
||||||
|
if (pressure) parts.push(`${(Number(pressure[1]) / 10).toFixed(1)} hPa`);
|
||||||
|
const rain = /r(\d{3})/.exec(info);
|
||||||
|
if (rain && Number(rain[1]) > 0) parts.push(`rain ${(Number(rain[1]) / 100).toFixed(2)}"`);
|
||||||
|
return parts.length ? parts.join(" · ") : null;
|
||||||
|
}
|
||||||
|
function summarizeAprsTelemetry(info) {
|
||||||
|
const match = /^T#(\d+|MIC)((?:,-?[\d.]*)+)(?:,([01]{8}))?/.exec(info.trim());
|
||||||
|
if (!match?.[2]) return null;
|
||||||
|
const channels = match[2].split(",").filter((value) => value.length > 0);
|
||||||
|
const bits = match[3] ? ` · bits ${match[3]}` : "";
|
||||||
|
return `#${match[1]} · ${channels.join(" ")}${bits}`;
|
||||||
|
}
|
||||||
|
function summarizeAprsMessage(info) {
|
||||||
|
const match = /^:([^:]{9}):(.*)$/.exec(info);
|
||||||
|
if (!match?.[1] || match[2] == null) return null;
|
||||||
|
const addressee = match[1].trim();
|
||||||
|
const text = match[2].replace(/\{\d+\s*$/, "").trim();
|
||||||
|
return `→ ${addressee}: ${text}`;
|
||||||
|
}
|
||||||
|
function summarizeAprsCourseSpeed(info) {
|
||||||
|
const match = /(\d{3})\/(\d{3})/.exec(info);
|
||||||
|
if (!match) return null;
|
||||||
|
const knots = Number(match[2]);
|
||||||
|
if (knots === 0) return null;
|
||||||
|
return `${Number(match[1])}° ${knots} kt`;
|
||||||
|
}
|
||||||
|
function summarizeAprsPayload(packet) {
|
||||||
|
const info = packet.info ?? "";
|
||||||
|
if (!info) return null;
|
||||||
|
const category = aprsPacketCategory(packet);
|
||||||
|
if (category === "message") return summarizeAprsMessage(info);
|
||||||
|
if (category === "weather") return summarizeAprsWeather(info);
|
||||||
|
if (category === "telemetry") return summarizeAprsTelemetry(info);
|
||||||
|
if (category === "position") {
|
||||||
|
const parts = [];
|
||||||
|
if (packet.lat != null && packet.lon != null) {
|
||||||
|
parts.push(`${packet.lat.toFixed(4)}, ${packet.lon.toFixed(4)}`);
|
||||||
|
}
|
||||||
|
const courseSpeed = summarizeAprsCourseSpeed(info);
|
||||||
|
if (courseSpeed) parts.push(courseSpeed);
|
||||||
|
const comment = info.replace(/^[!=@/][^>]*[>_]?/, "").replace(/\d{3}\/\d{3}/, "").trim();
|
||||||
|
if (comment && comment.length <= 60) parts.push(comment);
|
||||||
|
return parts.length ? parts.join(" · ") : null;
|
||||||
|
}
|
||||||
|
const text = info.replace(/^[>;<?]/, "").trim();
|
||||||
|
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 = {}) {
|
||||||
|
const row = document.createElement("details");
|
||||||
|
row.className = "aprs-packet";
|
||||||
|
if (!packet.crcOk) row.classList.add("aprs-packet-crc");
|
||||||
|
if (options.fresh) row.classList.add("aprs-packet-new");
|
||||||
|
const time = packet._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
const category = aprsPacketCategory(packet);
|
||||||
|
const summary = summarizeAprsPayload(packet);
|
||||||
|
const hasPosition = packet.lat != null && packet.lon != null;
|
||||||
|
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.querySelectorAll("[data-aprs-map]").forEach((element) => {
|
||||||
|
element.addEventListener("click", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const [lat, lon] = (element.dataset.aprsMap ?? "").split(",").map(Number);
|
||||||
|
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]");
|
||||||
|
if (copyButton) {
|
||||||
|
copyButton.addEventListener("click", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
options.onCopy?.(copyButton.dataset.aprsCopy ?? "", copyButton);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
aprsPacketCategory,
|
||||||
|
aprsAgeText,
|
||||||
|
collapseAprsDuplicates,
|
||||||
|
aprsSymbolSprite,
|
||||||
|
normalizeAprsPacket,
|
||||||
|
renderAprsPacketRow
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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
|
||||||
|
};
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
import {
|
||||||
|
forActiveRig,
|
||||||
|
isActiveRigDecode
|
||||||
|
} from "./chunk-S57W63QN.js";
|
||||||
|
import {
|
||||||
|
hostCore
|
||||||
|
} from "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
|
// src/plugins/ftx-family.ts
|
||||||
|
var bridge = window;
|
||||||
|
function formatBarTime(timestampMs) {
|
||||||
|
if (!timestampMs) return "--:--:--";
|
||||||
|
return new Date(timestampMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
}
|
||||||
|
function finiteNumber(value) {
|
||||||
|
const number = typeof value === "number" ? value : Number(value);
|
||||||
|
return Number.isFinite(number) ? number : null;
|
||||||
|
}
|
||||||
|
function isAlphaNumeric(value) {
|
||||||
|
return value !== void 0 && /[A-Za-z0-9]/.test(value);
|
||||||
|
}
|
||||||
|
function isGrid(value) {
|
||||||
|
const normalized = value.trim().toUpperCase();
|
||||||
|
return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && normalized !== "RR73" && normalized !== "73" && normalized !== "RR";
|
||||||
|
}
|
||||||
|
function escapeFtxHtml(input) {
|
||||||
|
return input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
||||||
|
}
|
||||||
|
function extractFtxGrids(message) {
|
||||||
|
return [...new Set(message.toUpperCase().split(/[^A-Z0-9]+/).filter(isGrid))];
|
||||||
|
}
|
||||||
|
function tokenize(message) {
|
||||||
|
return message.toUpperCase().split(/[^A-Z0-9/]+/).filter(Boolean);
|
||||||
|
}
|
||||||
|
function isCallsign(token) {
|
||||||
|
return token !== void 0 && token.length >= 3 && token.length <= 12 && !["CQ", "DE", "QRZ", "DX"].includes(token) && !isGrid(token) && /^[A-Z0-9/]{1,5}\d[A-Z0-9/]{1,6}$/.test(token);
|
||||||
|
}
|
||||||
|
function extractFtxLocatorDetails(message) {
|
||||||
|
const tokens = tokenize(message);
|
||||||
|
const grids = extractFtxGrids(message);
|
||||||
|
const gridIndex = tokens.findIndex(isGrid);
|
||||||
|
const callsigns = tokens.slice(0, gridIndex < 0 ? tokens.length : gridIndex).filter(isCallsign);
|
||||||
|
const directed = callsigns.length >= 2 && !["CQ", "DE", "QRZ"].includes(tokens[0] ?? "");
|
||||||
|
const source = directed ? callsigns[1] ?? null : callsigns[0] ?? null;
|
||||||
|
const target = directed ? callsigns[0] ?? null : null;
|
||||||
|
return grids.map((grid) => ({ grid, station: source, source, target }));
|
||||||
|
}
|
||||||
|
function extractFtxCallsign(message) {
|
||||||
|
return extractFtxLocatorDetails(message)[0]?.station ?? tokenize(message).find(isCallsign) ?? null;
|
||||||
|
}
|
||||||
|
function renderFtxMessage(message) {
|
||||||
|
let html = "";
|
||||||
|
let index = 0;
|
||||||
|
while (index < message.length) {
|
||||||
|
if (!isAlphaNumeric(message[index])) {
|
||||||
|
html += escapeFtxHtml(message[index] ?? "");
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let end = index + 1;
|
||||||
|
while (end < message.length && isAlphaNumeric(message[end])) end += 1;
|
||||||
|
const token = message.slice(index, end);
|
||||||
|
const grid = token.toUpperCase();
|
||||||
|
html += isGrid(grid) ? `<span class="ft8-locator" data-locator-grid="${grid}" role="button" tabindex="0" aria-label="Show locator ${grid} on map">${grid}</span>` : escapeFtxHtml(token);
|
||||||
|
index = end;
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
function installFtxCompatibilityHelpers() {
|
||||||
|
bridge.renderFt8Message = renderFtxMessage;
|
||||||
|
bridge.ft8EscapeHtml = escapeFtxHtml;
|
||||||
|
bridge.ft8ExtractLocatorDetails = extractFtxLocatorDetails;
|
||||||
|
bridge.ft8ExtractAllGrids = extractFtxGrids;
|
||||||
|
bridge.ft8ExtractLikelyCallsign = extractFtxCallsign;
|
||||||
|
}
|
||||||
|
function initializeFt8FamilyBar() {
|
||||||
|
const labels = { ft8: "FT8", ft4: "FT4", ft2: "FT2" };
|
||||||
|
const builders = {};
|
||||||
|
const dismissed = { ft8: 0, ft4: 0, ft2: 0 };
|
||||||
|
const overlay = document.getElementById("ft8-bar-overlay");
|
||||||
|
let active = "ft8";
|
||||||
|
const update = () => {
|
||||||
|
if (!overlay) return;
|
||||||
|
const mode = (document.getElementById("mode")?.value ?? "").toUpperCase();
|
||||||
|
const result = builders[active]?.();
|
||||||
|
if (mode !== "DIG" && mode !== "USB" || !result || result.count === 0 || result.newestTsMs <= dismissed[active]) {
|
||||||
|
overlay.style.display = "none";
|
||||||
|
overlay.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const label = labels[active];
|
||||||
|
overlay.innerHTML = `<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">${label}</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-actions"><span class="aprs-bar-window">Last 15 minutes</span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearFt8Bar()" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();window.clearFt8Bar();}" aria-label="Clear ${label} overlay">Clear</span></span><button class="aprs-bar-close" type="button" onclick="window.closeFt8Bar()" aria-label="Close ${label} overlay">×</button></span></div>${result.html}`;
|
||||||
|
overlay.style.display = "flex";
|
||||||
|
};
|
||||||
|
bridge.registerFt8FamilyBarRenderer = (decoder, builder) => {
|
||||||
|
builders[decoder] = builder;
|
||||||
|
};
|
||||||
|
bridge.setFt8FamilyBarDecoder = (decoder) => {
|
||||||
|
active = decoder;
|
||||||
|
update();
|
||||||
|
};
|
||||||
|
bridge.updateFt8Bar = update;
|
||||||
|
bridge.clearFt8Bar = () => {
|
||||||
|
bridge.trxPluginRuntime.reset(active);
|
||||||
|
};
|
||||||
|
bridge.closeFt8Bar = () => {
|
||||||
|
dismissed[active] = Date.now();
|
||||||
|
update();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function initializeFtxDecoder(config) {
|
||||||
|
const { id, label, periodMs, periodDigits = 1 } = config;
|
||||||
|
const status = document.getElementById(`${id}-status`);
|
||||||
|
const period = document.getElementById(`${id}-period`);
|
||||||
|
const messagesElement = document.getElementById(`${id}-messages`);
|
||||||
|
const filterInput = document.getElementById(`${id}-filter`);
|
||||||
|
let filterText = "";
|
||||||
|
let history = [];
|
||||||
|
const renderMessage = (message) => {
|
||||||
|
return bridge.renderFt8Message?.(message) ?? renderFtxMessage(message);
|
||||||
|
};
|
||||||
|
const retentionMs = () => bridge.getDecodeHistoryRetentionMs?.() ?? 864e5;
|
||||||
|
const prune = () => {
|
||||||
|
const cutoff = Date.now() - retentionMs();
|
||||||
|
history = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= cutoff);
|
||||||
|
};
|
||||||
|
const schedule = (job) => {
|
||||||
|
if (bridge.trxScheduleUiFrameJob) bridge.trxScheduleUiFrameJob(`${id}-history`, job);
|
||||||
|
else job();
|
||||||
|
};
|
||||||
|
const displayFrequency = (value) => {
|
||||||
|
const raw = finiteNumber(value);
|
||||||
|
if (raw === null) return null;
|
||||||
|
const base = finiteNumber(bridge.ft8BaseHz);
|
||||||
|
return base !== null && base > 0 && raw >= 0 && raw < 1e5 ? base + raw : raw;
|
||||||
|
};
|
||||||
|
const renderRow = (message) => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "ft8-row";
|
||||||
|
const raw = message.message ?? "";
|
||||||
|
row.dataset.message = raw.toUpperCase();
|
||||||
|
row.dataset.decoder = id;
|
||||||
|
const storedFrequency = finiteNumber(message.freq_hz);
|
||||||
|
row.dataset.storedFreqHz = storedFrequency === null ? "" : String(storedFrequency);
|
||||||
|
const snr = finiteNumber(message.snr_db);
|
||||||
|
const delta = finiteNumber(message.dt_s);
|
||||||
|
const frequency = displayFrequency(message.freq_hz);
|
||||||
|
const timestamp = finiteNumber(message._tsMs ?? message.ts_ms);
|
||||||
|
const time = timestamp === null ? "--:--:--" : new Date(timestamp).toLocaleTimeString([], {
|
||||||
|
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>`;
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
const render = () => {
|
||||||
|
prune();
|
||||||
|
if (!messagesElement) return;
|
||||||
|
const rigMessages = forActiveRig(history);
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
let count = 0;
|
||||||
|
for (const message of rigMessages) {
|
||||||
|
if (count >= 200) break;
|
||||||
|
if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue;
|
||||||
|
fragment.appendChild(renderRow(message));
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
messagesElement.replaceChildren(fragment);
|
||||||
|
};
|
||||||
|
const plotLocator = (message) => {
|
||||||
|
const raw = message.message ?? "";
|
||||||
|
const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(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 frequency = displayFrequency(message.freq_hz);
|
||||||
|
bridge.mapAddLocator?.(raw, grids, id, station, {
|
||||||
|
...message,
|
||||||
|
freq_hz: frequency ?? message.freq_hz,
|
||||||
|
locator_details: locatorDetails
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const normalize = (message) => {
|
||||||
|
const frequency = displayFrequency(message.freq_hz);
|
||||||
|
plotLocator(message);
|
||||||
|
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,
|
||||||
|
ts_ms: message.ts_ms,
|
||||||
|
snr_db: message.snr_db,
|
||||||
|
dt_s: message.dt_s,
|
||||||
|
freq_hz: frequency ?? message.freq_hz,
|
||||||
|
message: message.message,
|
||||||
|
_tsMs: finiteNumber(message.ts_ms) ?? Date.now()
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const receiveBatch = (messages) => {
|
||||||
|
if (messages.length === 0) return;
|
||||||
|
if (status && messages.some((message) => isActiveRigDecode(message.rig_id))) {
|
||||||
|
status.textContent = "Receiving";
|
||||||
|
}
|
||||||
|
history = messages.map(normalize).reverse().concat(history);
|
||||||
|
prune();
|
||||||
|
bridge.setFt8FamilyBarDecoder?.(id);
|
||||||
|
bridge.updateFt8Bar?.();
|
||||||
|
schedule(render);
|
||||||
|
};
|
||||||
|
const reset = () => {
|
||||||
|
history = [];
|
||||||
|
bridge.updateFt8Bar?.();
|
||||||
|
render();
|
||||||
|
bridge.clearMapMarkersByType?.(id);
|
||||||
|
};
|
||||||
|
const barFrames = () => {
|
||||||
|
const recent = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 9e5 && isActiveRigDecode(message.rig_id)).slice(0, 8);
|
||||||
|
let html = "";
|
||||||
|
for (const message of recent) {
|
||||||
|
const timestamp = finiteNumber(message._tsMs ?? message.ts_ms);
|
||||||
|
const time = timestamp === null ? "" : `<span class="aprs-bar-time">${formatBarTime(timestamp)}</span>`;
|
||||||
|
const snr = finiteNumber(message.snr_db);
|
||||||
|
const delta = finiteNumber(message.dt_s);
|
||||||
|
const frequency = displayFrequency(message.freq_hz);
|
||||||
|
const detail = [snr === null ? "-- dB" : `${snr.toFixed(1)} dB`, delta === null ? null : `dt ${delta.toFixed(2)}`, frequency === null ? null : `${frequency.toFixed(0)} Hz`].filter((part) => part !== null).join(" · ");
|
||||||
|
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${time}<span class="aprs-bar-call">${renderMessage(message.message ?? "")}</span>${detail ? ` · ${detail}` : ""}</div></div>`;
|
||||||
|
}
|
||||||
|
return { count: recent.length, newestTsMs: recent.reduce((latest, message) => Math.max(latest, finiteNumber(message._tsMs ?? message.ts_ms) ?? 0), 0), html };
|
||||||
|
};
|
||||||
|
bridge.trxPluginRuntime.registerDecoder({
|
||||||
|
id,
|
||||||
|
onMessage: (message) => {
|
||||||
|
receiveBatch([message]);
|
||||||
|
},
|
||||||
|
onBatch: receiveBatch,
|
||||||
|
restore: receiveBatch,
|
||||||
|
prune: () => {
|
||||||
|
prune();
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
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);
|
||||||
|
const updatePeriod = () => {
|
||||||
|
if (period) period.textContent = `Next slot ${((periodMs - Date.now() % periodMs) / 1e3).toFixed(periodDigits)}s`;
|
||||||
|
};
|
||||||
|
updatePeriod();
|
||||||
|
window.setInterval(updatePeriod, 250);
|
||||||
|
filterInput?.addEventListener("input", () => {
|
||||||
|
filterText = filterInput.value.trim().toUpperCase();
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
messagesElement?.addEventListener("click", (event) => {
|
||||||
|
if (!(event.target instanceof Element)) return;
|
||||||
|
const grid = event.target.closest(".ft8-locator[data-locator-grid]")?.dataset.locatorGrid;
|
||||||
|
if (grid) {
|
||||||
|
bridge.navigateToMapLocator?.(grid.toUpperCase(), id);
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const toggle = document.getElementById(`${id}-decode-toggle-btn`);
|
||||||
|
toggle?.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await bridge.takeSchedulerControlForDecoderDisable?.(toggle);
|
||||||
|
await hostCore.postPath(`/toggle_${id}_decode`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`${label} toggle failed`, error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
document.getElementById(`settings-clear-${id}-history`)?.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
if (!await bridge.trxUi.confirm({ title: `Clear ${label} history?`, message: `All stored ${label} decodes will be permanently removed.`, confirmLabel: "Clear history" })) return;
|
||||||
|
try {
|
||||||
|
await hostCore.postPath(`/clear_${id}_decode`);
|
||||||
|
reset();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`${label} history clear failed`, error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
installFtxCompatibilityHelpers,
|
||||||
|
initializeFt8FamilyBar,
|
||||||
|
initializeFtxDecoder
|
||||||
|
};
|
||||||
+160
-190
@@ -1,39 +1,39 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
import {
|
||||||
//
|
isActiveRigDecode
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
} from "./chunk-S57W63QN.js";
|
||||||
|
import {
|
||||||
// --- CW (Morse) Decoder Plugin (server-side decode) ---
|
hostCore
|
||||||
const cwStatusEl = document.getElementById("cw-status");
|
} from "./chunk-KL66PICH.js";
|
||||||
const cwOutputEl = document.getElementById("cw-output");
|
|
||||||
const cwAutoInput = document.getElementById("cw-auto");
|
|
||||||
const cwWpmInput = document.getElementById("cw-wpm");
|
|
||||||
const cwToneInput = document.getElementById("cw-tone");
|
|
||||||
const cwSignalIndicator = document.getElementById("cw-signal-indicator");
|
|
||||||
const cwToneCanvas = document.getElementById("cw-tone-waterfall");
|
|
||||||
const cwToneGl = typeof createTrxWebGlRenderer === "function"
|
|
||||||
? createTrxWebGlRenderer(cwToneCanvas, { alpha: true })
|
|
||||||
: null;
|
|
||||||
const cwTonePickerEl = document.querySelector(".cw-tone-picker");
|
|
||||||
const cwToneRangeEl = document.getElementById("cw-tone-range");
|
|
||||||
const cwBarOverlay = document.getElementById("cw-bar-overlay");
|
|
||||||
const CW_MAX_LINES = 200;
|
|
||||||
const CW_TONE_MIN_HZ = 100;
|
|
||||||
const CW_TONE_MAX_HZ = 10_000;
|
|
||||||
const CW_WPM_MIN = 5;
|
|
||||||
const CW_WPM_MAX = 40;
|
|
||||||
const CW_BAR_WINDOW_MS = 15 * 60 * 1000;
|
|
||||||
const CW_BAR_LINE_GAP_MS = 5000;
|
|
||||||
let cwLastAppendTime = 0;
|
|
||||||
let cwTonePickerRaf = null;
|
|
||||||
let cwBarHistory = []; // [{tsMs, ts, text, wpm, tone_hz}]
|
|
||||||
let cwBarCurrentLine = null; // accumulates chars until gap/newline
|
|
||||||
let cwBarDismissedAtMs = 0;
|
|
||||||
// Tracks a user-initiated auto toggle that is in-flight (POST not yet
|
|
||||||
// acknowledged). While set, server-state updates must not override the
|
|
||||||
// checkbox so that a concurrent SSE event carrying the *old* cw_auto value
|
|
||||||
// does not immediately undo the user's choice.
|
|
||||||
let cwAutoLocalOverride = null;
|
|
||||||
|
|
||||||
|
// src/plugins/cw.ts
|
||||||
|
var cwWindow = window;
|
||||||
|
var cwStatusEl = document.getElementById("cw-status");
|
||||||
|
var cwOutputEl = document.getElementById("cw-output");
|
||||||
|
var cwAutoInput = document.getElementById("cw-auto");
|
||||||
|
var cwWpmInput = document.getElementById("cw-wpm");
|
||||||
|
var cwToneInput = document.getElementById("cw-tone");
|
||||||
|
var cwSignalIndicator = document.getElementById("cw-signal-indicator");
|
||||||
|
var cwToneCanvas = document.getElementById("cw-tone-waterfall");
|
||||||
|
var cwToneGl = cwToneCanvas && cwWindow.createTrxWebGlRenderer ? cwWindow.createTrxWebGlRenderer(cwToneCanvas, { alpha: true }) : null;
|
||||||
|
var cwTonePickerEl = document.querySelector(".cw-tone-picker");
|
||||||
|
var cwToneRangeEl = document.getElementById("cw-tone-range");
|
||||||
|
var cwBarOverlay = document.getElementById("cw-bar-overlay");
|
||||||
|
var CW_MAX_LINES = 200;
|
||||||
|
var CW_TONE_MIN_HZ = 100;
|
||||||
|
var CW_TONE_MAX_HZ = 1e4;
|
||||||
|
var CW_WPM_MIN = 5;
|
||||||
|
var CW_WPM_MAX = 40;
|
||||||
|
var CW_BAR_WINDOW_MS = 15 * 60 * 1e3;
|
||||||
|
var CW_BAR_LINE_GAP_MS = 5e3;
|
||||||
|
var cwLastAppendTime = 0;
|
||||||
|
var cwTonePickerRaf = null;
|
||||||
|
var cwBarHistory = [];
|
||||||
|
var cwBarCurrentLines = /* @__PURE__ */ new Map();
|
||||||
|
var cwBarDismissedAtMs = 0;
|
||||||
|
var cwAutoLocalOverride = null;
|
||||||
|
function escapeCwHtml(input) {
|
||||||
|
return hostCore.escapeMapHtml(input);
|
||||||
|
}
|
||||||
function applyCwAutoUi(enabled) {
|
function applyCwAutoUi(enabled) {
|
||||||
if (cwAutoInput) cwAutoInput.checked = enabled;
|
if (cwAutoInput) cwAutoInput.checked = enabled;
|
||||||
if (cwWpmInput) {
|
if (cwWpmInput) {
|
||||||
@@ -48,103 +48,78 @@ function applyCwAutoUi(enabled) {
|
|||||||
cwTonePickerEl.classList.toggle("is-auto", enabled);
|
cwTonePickerEl.classList.toggle("is-auto", enabled);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.applyCwAutoUi = applyCwAutoUi;
|
cwWindow.applyCwAutoUi = applyCwAutoUi;
|
||||||
|
cwWindow.applyCwAutoUiFromServer = function(enabled) {
|
||||||
// Called by app.js render() when a server-state snapshot arrives. Ignores
|
|
||||||
// the update while cwAutoLocalOverride is set (user change still in-flight).
|
|
||||||
window.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);
|
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs && isActiveRigDecode(l.rigId));
|
||||||
// Prepend the in-progress line so characters appear immediately
|
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, Number(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";
|
||||||
cwBarOverlay.innerHTML = "";
|
cwBarOverlay.innerHTML = "";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let html =
|
let html = `<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">CW</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-actions"><span class="aprs-bar-window">Last 15 minutes</span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearCwBar()" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();window.clearCwBar();}" aria-label="Clear CW overlay">Clear</span></span><button class="aprs-bar-close" type="button" onclick="window.closeCwBar()" aria-label="Close CW overlay">×</button></span></div>`;
|
||||||
'<div class="aprs-bar-header">' +
|
|
||||||
'<span class="aprs-bar-title"><span class="aprs-bar-title-word">CW</span><span class="aprs-bar-title-word">Live</span></span>' +
|
|
||||||
'<span class="aprs-bar-actions">' +
|
|
||||||
'<span class="aprs-bar-window">Last 15 minutes</span>' +
|
|
||||||
'<span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0"' +
|
|
||||||
' onclick="window.clearCwBar()"' +
|
|
||||||
' onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();window.clearCwBar();}"' +
|
|
||||||
' aria-label="Clear CW overlay">Clear</span></span>' +
|
|
||||||
'<button class="aprs-bar-close" type="button" onclick="window.closeCwBar()" aria-label="Close CW overlay">×</button>' +
|
|
||||||
'</span>' +
|
|
||||||
'</div>';
|
|
||||||
for (const line of liveLines.slice(0, 8)) {
|
for (const line of liveLines.slice(0, 8)) {
|
||||||
const ts = line.ts ? `<span class="aprs-bar-time">${line.ts}</span>` : "";
|
const ts = line.ts ? `<span class="aprs-bar-time">${line.ts}</span>` : "";
|
||||||
const meta = [
|
const meta = [
|
||||||
line.wpm ? `${line.wpm} WPM` : null,
|
line.wpm ? `${String(line.wpm)} WPM` : null,
|
||||||
line.tone_hz ? `${line.tone_hz} Hz` : null,
|
line.tone_hz ? `${String(line.tone_hz)} Hz` : null
|
||||||
].filter(Boolean).join(" · ");
|
].filter(Boolean).join(" · ");
|
||||||
html += `<div class="aprs-bar-frame">` +
|
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}${escapeCwHtml(line.text)}` + (meta ? ` <span class="aprs-bar-time">${escapeCwHtml(meta)}</span>` : "") + `</div></div>`;
|
||||||
`<div class="aprs-bar-frame-main">${ts}${escapeMapHtml(line.text)}` +
|
|
||||||
(meta ? ` <span class="aprs-bar-time">${escapeMapHtml(meta)}</span>` : "") +
|
|
||||||
`</div></div>`;
|
|
||||||
}
|
}
|
||||||
cwBarOverlay.innerHTML = html;
|
cwBarOverlay.innerHTML = html;
|
||||||
cwBarOverlay.style.display = "flex";
|
cwBarOverlay.style.display = "flex";
|
||||||
}
|
}
|
||||||
window.updateCwBar = updateCwBar;
|
cwWindow.updateCwBar = updateCwBar;
|
||||||
window.clearCwBar = function() {
|
cwWindow.clearCwBar = function() {
|
||||||
window.resetCwHistoryView();
|
resetCwHistoryView();
|
||||||
};
|
};
|
||||||
window.closeCwBar = function() {
|
cwWindow.closeCwBar = function() {
|
||||||
cwBarDismissedAtMs = Date.now();
|
cwBarDismissedAtMs = Date.now();
|
||||||
if (cwBarOverlay) {
|
if (cwBarOverlay) {
|
||||||
cwBarOverlay.style.display = "none";
|
cwBarOverlay.style.display = "none";
|
||||||
cwBarOverlay.innerHTML = "";
|
cwBarOverlay.innerHTML = "";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function clampCwWpm(wpm) {
|
function clampCwWpm(wpm) {
|
||||||
const numeric = Number(wpm);
|
const numeric = Number(wpm);
|
||||||
if (!Number.isFinite(numeric)) return 15;
|
if (!Number.isFinite(numeric)) return 15;
|
||||||
return Math.round(Math.max(CW_WPM_MIN, Math.min(CW_WPM_MAX, numeric)));
|
return Math.round(Math.max(CW_WPM_MIN, Math.min(CW_WPM_MAX, numeric)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampCwTone(tone) {
|
function clampCwTone(tone) {
|
||||||
const numeric = Number(tone);
|
const numeric = Number(tone);
|
||||||
if (!Number.isFinite(numeric)) return 700;
|
if (!Number.isFinite(numeric)) return 700;
|
||||||
return Math.round(Math.max(CW_TONE_MIN_HZ, Math.min(CW_TONE_MAX_HZ, numeric)));
|
return Math.round(Math.max(CW_TONE_MIN_HZ, Math.min(CW_TONE_MAX_HZ, numeric)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentCwToneRange() {
|
function currentCwToneRange() {
|
||||||
const tunedHz = Number.isFinite(window.lastFreqHz) ? Number(window.lastFreqHz) : NaN;
|
const tunedHz = Number.isFinite(cwWindow.lastFreqHz) ? Number(cwWindow.lastFreqHz) : NaN;
|
||||||
const bandwidthHz = Number.isFinite(window.currentBandwidthHz) ? Number(window.currentBandwidthHz) : NaN;
|
const bandwidthHz = Number.isFinite(cwWindow.currentBandwidthHz) ? Number(cwWindow.currentBandwidthHz) : NaN;
|
||||||
if (!Number.isFinite(tunedHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) {
|
if (!Number.isFinite(tunedHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const mode = String(document.getElementById("mode")?.value || "").toUpperCase();
|
const mode = (document.getElementById("mode")?.value || "").toUpperCase();
|
||||||
const lowerSideband = mode === "CWR";
|
const lowerSideband = mode === "CWR";
|
||||||
const upperSideband = mode === "CW";
|
const upperSideband = mode === "CW";
|
||||||
if (!lowerSideband && !upperSideband) return null;
|
if (!lowerSideband && !upperSideband) return null;
|
||||||
|
|
||||||
const toneMinHz = CW_TONE_MIN_HZ;
|
const toneMinHz = CW_TONE_MIN_HZ;
|
||||||
const toneMaxHz = CW_TONE_MAX_HZ;
|
const toneMaxHz = CW_TONE_MAX_HZ;
|
||||||
if (toneMaxHz < toneMinHz) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
tunedHz,
|
tunedHz,
|
||||||
bandwidthHz,
|
bandwidthHz,
|
||||||
@@ -152,23 +127,18 @@ function currentCwToneRange() {
|
|||||||
toneMaxHz,
|
toneMaxHz,
|
||||||
toneSpanHz: Math.max(1, toneMaxHz - toneMinHz),
|
toneSpanHz: Math.max(1, toneMaxHz - toneMinHz),
|
||||||
lowerSideband,
|
lowerSideband,
|
||||||
mode,
|
mode
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function cwToneToRfHz(range, toneHz) {
|
function cwToneToRfHz(range, toneHz) {
|
||||||
if (!range) return NaN;
|
if (!range) return NaN;
|
||||||
return range.lowerSideband
|
return range.lowerSideband ? range.tunedHz - toneHz : range.tunedHz + toneHz;
|
||||||
? range.tunedHz - toneHz
|
|
||||||
: range.tunedHz + toneHz;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toneClampForRange(tone, range) {
|
function toneClampForRange(tone, range) {
|
||||||
const clamped = clampCwTone(tone);
|
const clamped = clampCwTone(tone);
|
||||||
if (!range) return clamped;
|
if (!range) return clamped;
|
||||||
return Math.max(range.toneMinHz, Math.min(range.toneMaxHz, clamped));
|
return Math.max(range.toneMinHz, Math.min(range.toneMaxHz, clamped));
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureCwToneCanvasResolution() {
|
function ensureCwToneCanvasResolution() {
|
||||||
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return false;
|
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return false;
|
||||||
const rect = cwToneCanvas.getBoundingClientRect();
|
const rect = cwToneCanvas.getBoundingClientRect();
|
||||||
@@ -180,7 +150,6 @@ function ensureCwToneCanvasResolution() {
|
|||||||
const dpr = window.devicePixelRatio || 1;
|
const dpr = window.devicePixelRatio || 1;
|
||||||
return cwToneGl.ensureSize(cssWidth, cssHeight, dpr);
|
return cwToneGl.ensureSize(cssWidth, cssHeight, dpr);
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawCwTonePicker() {
|
function drawCwTonePicker() {
|
||||||
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return;
|
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return;
|
||||||
ensureCwToneCanvasResolution();
|
ensureCwToneCanvasResolution();
|
||||||
@@ -188,29 +157,26 @@ function drawCwTonePicker() {
|
|||||||
const width = cwToneCanvas.width;
|
const width = cwToneCanvas.width;
|
||||||
const height = cwToneCanvas.height;
|
const height = cwToneCanvas.height;
|
||||||
cwToneGl.clear([0, 0, 0, 0]);
|
cwToneGl.clear([0, 0, 0, 0]);
|
||||||
|
|
||||||
const range = currentCwToneRange();
|
const range = currentCwToneRange();
|
||||||
if (!window.lastSpectrumData || !Array.isArray(window.lastSpectrumData.bins) || !window.lastSpectrumData.bins.length || !range) {
|
if (!cwWindow.lastSpectrumData || !Array.isArray(cwWindow.lastSpectrumData.bins) || !cwWindow.lastSpectrumData.bins.length || !range) {
|
||||||
if (cwToneRangeEl) {
|
if (cwToneRangeEl) {
|
||||||
const mode = String(document.getElementById("mode")?.value || "").toUpperCase();
|
const mode = (document.getElementById("mode")?.value || "").toUpperCase();
|
||||||
if (mode !== "CW" && mode !== "CWR") {
|
if (mode !== "CW" && mode !== "CWR") {
|
||||||
cwToneRangeEl.textContent = "CW/CWR mode required";
|
cwToneRangeEl.textContent = "CW/CWR mode required";
|
||||||
} else if (!window.lastSpectrumData || !Array.isArray(window.lastSpectrumData.bins) || !window.lastSpectrumData.bins.length) {
|
} else if (!cwWindow.lastSpectrumData || !Array.isArray(cwWindow.lastSpectrumData.bins) || !cwWindow.lastSpectrumData.bins.length) {
|
||||||
cwToneRangeEl.textContent = "Waiting for spectrum";
|
cwToneRangeEl.textContent = "Waiting for spectrum";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cwToneGl.fillRect(0, 0, width, height, [130 / 255, 150 / 255, 165 / 255, 0.22]);
|
cwToneGl.fillRect(0, 0, width, height, [130 / 255, 150 / 255, 165 / 255, 0.22]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cwToneRangeEl) {
|
if (cwToneRangeEl) {
|
||||||
const side = range.lowerSideband ? "Lower side" : "Upper side";
|
const side = range.lowerSideband ? "Lower side" : "Upper side";
|
||||||
cwToneRangeEl.textContent = `Audio ${range.toneMinHz}-${range.toneMaxHz} Hz · ${side}`;
|
cwToneRangeEl.textContent = `Audio ${String(range.toneMinHz)}-${String(range.toneMaxHz)} Hz · ${side}`;
|
||||||
}
|
}
|
||||||
|
const bins = cwWindow.lastSpectrumData.bins;
|
||||||
const bins = window.lastSpectrumData.bins;
|
const sampleRate = cwWindow.lastSpectrumData.sample_rate;
|
||||||
const sampleRate = Number(window.lastSpectrumData.sample_rate);
|
const centerHz = cwWindow.lastSpectrumData.center_hz;
|
||||||
const centerHz = Number(window.lastSpectrumData.center_hz);
|
|
||||||
const maxIdx = Math.max(1, bins.length - 1);
|
const maxIdx = Math.max(1, bins.length - 1);
|
||||||
const fullLoHz = centerHz - sampleRate / 2;
|
const fullLoHz = centerHz - sampleRate / 2;
|
||||||
const tones = new Array(width).fill(-140);
|
const tones = new Array(width).fill(-140);
|
||||||
@@ -218,11 +184,10 @@ function drawCwTonePicker() {
|
|||||||
const frac = width <= 1 ? 0 : x / (width - 1);
|
const frac = width <= 1 ? 0 : x / (width - 1);
|
||||||
const toneHz = range.toneMinHz + frac * range.toneSpanHz;
|
const toneHz = range.toneMinHz + frac * range.toneSpanHz;
|
||||||
const rfHz = cwToneToRfHz(range, toneHz);
|
const rfHz = cwToneToRfHz(range, toneHz);
|
||||||
const idx = Math.max(0, Math.min(maxIdx, Math.round((((rfHz - fullLoHz) / sampleRate) * maxIdx))));
|
const idx = Math.max(0, Math.min(maxIdx, Math.round((rfHz - fullLoHz) / sampleRate * maxIdx)));
|
||||||
const power = Number.isFinite(Number(bins[idx])) ? Number(bins[idx]) : -140;
|
const power = Number.isFinite(Number(bins[idx])) ? Number(bins[idx]) : -140;
|
||||||
tones[x] = power;
|
tones[x] = power;
|
||||||
}
|
}
|
||||||
|
|
||||||
const smoothed = new Array(width).fill(-140);
|
const smoothed = new Array(width).fill(-140);
|
||||||
const smoothRadius = Math.max(1, Math.round(width / 180));
|
const smoothRadius = Math.max(1, Math.round(width / 180));
|
||||||
for (let x = 0; x < width; x += 1) {
|
for (let x = 0; x < width; x += 1) {
|
||||||
@@ -230,12 +195,11 @@ function drawCwTonePicker() {
|
|||||||
let count = 0;
|
let count = 0;
|
||||||
for (let i = x - smoothRadius; i <= x + smoothRadius; i += 1) {
|
for (let i = x - smoothRadius; i <= x + smoothRadius; i += 1) {
|
||||||
if (i < 0 || i >= width) continue;
|
if (i < 0 || i >= width) continue;
|
||||||
sum += tones[i];
|
sum += tones[i] ?? -140;
|
||||||
count += 1;
|
count += 1;
|
||||||
}
|
}
|
||||||
smoothed[x] = count > 0 ? sum / count : tones[x];
|
smoothed[x] = count > 0 ? sum / count : tones[x] ?? -140;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sorted = smoothed.slice().sort((a, b) => a - b);
|
const sorted = smoothed.slice().sort((a, b) => a - b);
|
||||||
const q20 = sorted[Math.floor((sorted.length - 1) * 0.2)] ?? -120;
|
const q20 = sorted[Math.floor((sorted.length - 1) * 0.2)] ?? -120;
|
||||||
const q95 = sorted[Math.floor((sorted.length - 1) * 0.95)] ?? -70;
|
const q95 = sorted[Math.floor((sorted.length - 1) * 0.95)] ?? -70;
|
||||||
@@ -246,26 +210,20 @@ function drawCwTonePicker() {
|
|||||||
const n = Math.max(0, Math.min(1, (db - floorDb) / dbSpan));
|
const n = Math.max(0, Math.min(1, (db - floorDb) / dbSpan));
|
||||||
return Math.round((1 - n) * (height - 1));
|
return Math.round((1 - n) * (height - 1));
|
||||||
};
|
};
|
||||||
|
|
||||||
const rootStyle = getComputedStyle(document.documentElement);
|
const rootStyle = getComputedStyle(document.documentElement);
|
||||||
const accent = (rootStyle.getPropertyValue("--accent-green") || "").trim() || "#00d17f";
|
const accent = (rootStyle.getPropertyValue("--accent-green") || "").trim() || "#00d17f";
|
||||||
const parseColor = typeof window.trxParseCssColor === "function"
|
const parseColor = typeof cwWindow.trxParseCssColor === "function" ? cwWindow.trxParseCssColor : null;
|
||||||
? window.trxParseCssColor
|
|
||||||
: null;
|
|
||||||
const accentRgba = parseColor ? parseColor(accent) : [0, 0.82, 0.5, 1];
|
const accentRgba = parseColor ? parseColor(accent) : [0, 0.82, 0.5, 1];
|
||||||
const axisColor = [230 / 255, 235 / 255, 245 / 255, 0.15];
|
const axisColor = [230 / 255, 235 / 255, 245 / 255, 0.15];
|
||||||
|
|
||||||
cwToneGl.fillRect(0, 0, width, height, [7 / 255, 12 / 255, 18 / 255, 0.94]);
|
cwToneGl.fillRect(0, 0, width, height, [7 / 255, 12 / 255, 18 / 255, 0.94]);
|
||||||
|
|
||||||
const hGridCount = 4;
|
const hGridCount = 4;
|
||||||
const gridSegments = [];
|
const gridSegments = [];
|
||||||
for (let i = 1; i <= hGridCount; i += 1) {
|
for (let i = 1; i <= hGridCount; i += 1) {
|
||||||
const y = Math.round((i / (hGridCount + 1)) * (height - 1));
|
const y = Math.round(i / (hGridCount + 1) * (height - 1));
|
||||||
gridSegments.push(0, y, width, y);
|
gridSegments.push(0, y, width, y);
|
||||||
}
|
}
|
||||||
cwToneGl.drawSegments(gridSegments, axisColor, 1);
|
cwToneGl.drawSegments(gridSegments, axisColor, 1);
|
||||||
|
const toneStep = range.toneSpanHz <= 500 ? 50 : range.toneSpanHz <= 1e3 ? 100 : 200;
|
||||||
const toneStep = range.toneSpanHz <= 500 ? 50 : range.toneSpanHz <= 1000 ? 100 : 200;
|
|
||||||
const firstTick = Math.ceil(range.toneMinHz / toneStep) * toneStep;
|
const firstTick = Math.ceil(range.toneMinHz / toneStep) * toneStep;
|
||||||
const tickSegments = [];
|
const tickSegments = [];
|
||||||
for (let tone = firstTick; tone <= range.toneMaxHz; tone += toneStep) {
|
for (let tone = firstTick; tone <= range.toneMaxHz; tone += toneStep) {
|
||||||
@@ -274,75 +232,73 @@ function drawCwTonePicker() {
|
|||||||
tickSegments.push(x, 0, x, height);
|
tickSegments.push(x, 0, x, height);
|
||||||
}
|
}
|
||||||
cwToneGl.drawSegments(tickSegments, axisColor, 1);
|
cwToneGl.drawSegments(tickSegments, axisColor, 1);
|
||||||
|
|
||||||
const linePoints = [];
|
const linePoints = [];
|
||||||
for (let x = 0; x < width; x += 1) {
|
for (let x = 0; x < width; x += 1) {
|
||||||
linePoints.push(x, yForDb(smoothed[x]));
|
linePoints.push(x, yForDb(smoothed[x] ?? -140));
|
||||||
}
|
}
|
||||||
cwToneGl.drawFilledArea(linePoints, height, [accentRgba[0], accentRgba[1], accentRgba[2], 0.24]);
|
cwToneGl.drawFilledArea(linePoints, height, [accentRgba[0], accentRgba[1], accentRgba[2], 0.24]);
|
||||||
cwToneGl.drawPolyline(linePoints, accentRgba, Math.max(1.2, (window.devicePixelRatio || 1) * 1.2));
|
cwToneGl.drawPolyline(linePoints, accentRgba, Math.max(1.2, (window.devicePixelRatio || 1) * 1.2));
|
||||||
|
|
||||||
const currentTone = toneClampForRange(cwToneInput ? cwToneInput.value : 700, range);
|
const currentTone = toneClampForRange(cwToneInput ? cwToneInput.value : 700, range);
|
||||||
const markerFrac = (currentTone - range.toneMinHz) / range.toneSpanHz;
|
const markerFrac = (currentTone - range.toneMinHz) / range.toneSpanHz;
|
||||||
const markerX = Math.max(0, Math.min(width - 1, Math.round(markerFrac * (width - 1))));
|
const markerX = Math.max(0, Math.min(width - 1, Math.round(markerFrac * (width - 1))));
|
||||||
const markerY = yForDb(smoothed[Math.max(0, Math.min(width - 1, markerX))]);
|
const markerY = yForDb(smoothed[Math.max(0, Math.min(width - 1, markerX))] ?? -140);
|
||||||
cwToneGl.drawSegments([markerX, 0, markerX, height], [1, 1, 1, 0.9], 1.5);
|
cwToneGl.drawSegments([markerX, 0, markerX, height], [1, 1, 1, 0.9], 1.5);
|
||||||
cwToneGl.drawPoints([markerX, markerY], Math.max(2, Math.round(height * 0.055)), [1, 1, 1, 0.9]);
|
cwToneGl.drawPoints([markerX, markerY], Math.max(2, Math.round(height * 0.055)), [1, 1, 1, 0.9]);
|
||||||
|
|
||||||
if (cwAutoInput?.checked) {
|
if (cwAutoInput?.checked) {
|
||||||
cwToneGl.fillRect(0, 0, width, height, [0, 0, 0, 0.22]);
|
cwToneGl.fillRect(0, 0, width, height, [0, 0, 0, 0.22]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setCwTone(tone, { syncInput = true } = {}) {
|
async function setCwTone(tone, { syncInput = true } = {}) {
|
||||||
const range = currentCwToneRange();
|
const range = currentCwToneRange();
|
||||||
const clamped = toneClampForRange(tone, range);
|
const clamped = toneClampForRange(tone, range);
|
||||||
if (cwToneInput && syncInput) {
|
if (cwToneInput && syncInput) {
|
||||||
cwToneInput.value = clamped;
|
cwToneInput.value = String(clamped);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await postPath(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`);
|
await hostCore.postPath(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("CW tone set failed", e);
|
console.error("CW tone set failed", e);
|
||||||
}
|
}
|
||||||
drawCwTonePicker();
|
drawCwTonePicker();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cwAutoInput) {
|
if (cwAutoInput) {
|
||||||
cwAutoInput.addEventListener("change", async () => {
|
cwAutoInput.addEventListener("change", () => {
|
||||||
const enabled = cwAutoInput.checked;
|
void (async () => {
|
||||||
cwAutoLocalOverride = enabled;
|
const enabled = cwAutoInput.checked;
|
||||||
applyCwAutoUi(enabled);
|
cwAutoLocalOverride = enabled;
|
||||||
try {
|
applyCwAutoUi(enabled);
|
||||||
await postPath(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`);
|
try {
|
||||||
drawCwTonePicker();
|
await hostCore.postPath(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`);
|
||||||
} catch (e) {
|
drawCwTonePicker();
|
||||||
console.error("CW auto toggle failed", e);
|
} catch (error) {
|
||||||
} finally {
|
console.error("CW auto toggle failed", error);
|
||||||
cwAutoLocalOverride = null;
|
} finally {
|
||||||
}
|
cwAutoLocalOverride = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cwWpmInput) {
|
if (cwWpmInput) {
|
||||||
cwWpmInput.addEventListener("change", async () => {
|
cwWpmInput.addEventListener("change", () => {
|
||||||
if (cwAutoInput && cwAutoInput.checked) return;
|
void (async () => {
|
||||||
const wpm = clampCwWpm(cwWpmInput.value);
|
if (cwAutoInput?.checked) return;
|
||||||
cwWpmInput.value = wpm;
|
const wpm = clampCwWpm(cwWpmInput.value);
|
||||||
try { await postPath(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`); }
|
cwWpmInput.value = String(wpm);
|
||||||
catch (e) { console.error("CW WPM set failed", e); }
|
try {
|
||||||
|
await hostCore.postPath(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("CW WPM set failed", error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cwToneInput) {
|
if (cwToneInput) {
|
||||||
cwToneInput.addEventListener("change", async () => {
|
cwToneInput.addEventListener("change", () => {
|
||||||
if (cwAutoInput?.checked) return;
|
if (!cwAutoInput?.checked) void setCwTone(cwToneInput.value);
|
||||||
await setCwTone(cwToneInput.value);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cwToneCanvas) {
|
if (cwToneCanvas) {
|
||||||
cwToneCanvas.addEventListener("click", async (event) => {
|
cwToneCanvas.addEventListener("click", (event) => {
|
||||||
if (cwAutoInput?.checked) return;
|
if (cwAutoInput?.checked) return;
|
||||||
const rect = cwToneCanvas.getBoundingClientRect();
|
const rect = cwToneCanvas.getBoundingClientRect();
|
||||||
if (rect.width <= 0) return;
|
if (rect.width <= 0) return;
|
||||||
@@ -350,36 +306,34 @@ if (cwToneCanvas) {
|
|||||||
if (!range) return;
|
if (!range) return;
|
||||||
const frac = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
|
const frac = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
|
||||||
const tone = range.toneMinHz + frac * range.toneSpanHz;
|
const tone = range.toneMinHz + frac * range.toneSpanHz;
|
||||||
await setCwTone(tone);
|
void setCwTone(tone);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
function resetCwHistoryView() {
|
||||||
window.resetCwHistoryView = function() {
|
|
||||||
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
||||||
cwLastAppendTime = 0;
|
cwLastAppendTime = 0;
|
||||||
cwBarHistory = [];
|
cwBarHistory = [];
|
||||||
cwBarCurrentLine = null;
|
cwBarCurrentLines.clear();
|
||||||
updateCwBar();
|
updateCwBar();
|
||||||
drawCwTonePicker();
|
drawCwTonePicker();
|
||||||
};
|
}
|
||||||
|
document.getElementById("settings-clear-cw-history")?.addEventListener("click", () => {
|
||||||
document.getElementById("settings-clear-cw-history")?.addEventListener("click", async () => {
|
void (async () => {
|
||||||
if (!confirm("Clear all CW decode history? This cannot be undone.")) return;
|
if (!await cwWindow.trxUi.confirm({ title: "Clear CW history?", message: "All stored CW decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
try {
|
try {
|
||||||
await postPath("/clear_cw_decode");
|
await hostCore.postPath("/clear_cw_decode");
|
||||||
window.resetCwHistoryView();
|
resetCwHistoryView();
|
||||||
} catch (e) {
|
} catch (error) {
|
||||||
console.error("CW history clear failed", e);
|
console.error("CW history clear failed", error);
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
});
|
});
|
||||||
|
function onServerCw(evt) {
|
||||||
// --- Server-side CW decode handler ---
|
const forSelectedRig = isActiveRigDecode(evt.rig_id ?? null);
|
||||||
window.onServerCw = function(evt) {
|
if (cwStatusEl && forSelectedRig) cwStatusEl.textContent = "Receiving";
|
||||||
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
if (evt.text && cwOutputEl && forSelectedRig) {
|
||||||
if (evt.text && cwOutputEl) {
|
|
||||||
// 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 > 1e4 || evt.text === "\n") {
|
||||||
const line = document.createElement("div");
|
const line = document.createElement("div");
|
||||||
line.className = "cw-line";
|
line.className = "cw-line";
|
||||||
cwOutputEl.appendChild(line);
|
cwOutputEl.appendChild(line);
|
||||||
@@ -390,37 +344,42 @@ window.onServerCw = function(evt) {
|
|||||||
lastLine.textContent += evt.text;
|
lastLine.textContent += evt.text;
|
||||||
}
|
}
|
||||||
while (cwOutputEl.children.length > CW_MAX_LINES) {
|
while (cwOutputEl.children.length > CW_MAX_LINES) {
|
||||||
cwOutputEl.removeChild(cwOutputEl.firstChild);
|
const firstChild = cwOutputEl.firstChild;
|
||||||
|
if (!firstChild) break;
|
||||||
|
cwOutputEl.removeChild(firstChild);
|
||||||
}
|
}
|
||||||
cwOutputEl.scrollTop = cwOutputEl.scrollHeight;
|
cwOutputEl.scrollTop = cwOutputEl.scrollHeight;
|
||||||
}
|
}
|
||||||
// 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();
|
cwBarFlushCurrentLine(key);
|
||||||
} else {
|
} else {
|
||||||
if (!cwBarCurrentLine || now - cwBarCurrentLine.lastMs > CW_BAR_LINE_GAP_MS) {
|
let line = cwBarCurrentLines.get(key);
|
||||||
cwBarFlushCurrentLine();
|
if (!line || now - line.lastMs > CW_BAR_LINE_GAP_MS) {
|
||||||
|
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" });
|
||||||
cwBarCurrentLine = { tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now };
|
line = { rigId, tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now };
|
||||||
|
cwBarCurrentLines.set(key, line);
|
||||||
}
|
}
|
||||||
cwBarCurrentLine.text += evt.text;
|
line.text += evt.text;
|
||||||
cwBarCurrentLine.lastMs = now;
|
line.lastMs = now;
|
||||||
if (Number.isFinite(Number(evt.wpm))) cwBarCurrentLine.wpm = clampCwWpm(evt.wpm);
|
if (Number.isFinite(Number(evt.wpm))) line.wpm = clampCwWpm(evt.wpm);
|
||||||
if (Number.isFinite(Number(evt.tone_hz))) cwBarCurrentLine.tone_hz = Math.round(Number(evt.tone_hz));
|
if (Number.isFinite(Number(evt.tone_hz))) line.tone_hz = Math.round(Number(evt.tone_hz));
|
||||||
}
|
}
|
||||||
updateCwBar();
|
updateCwBar();
|
||||||
}
|
}
|
||||||
if (cwSignalIndicator) {
|
if (cwSignalIndicator && forSelectedRig) {
|
||||||
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
|
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
|
||||||
}
|
}
|
||||||
if (!cwAutoInput || cwAutoInput.checked) {
|
if (forSelectedRig && (!cwAutoInput || cwAutoInput.checked)) {
|
||||||
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
|
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
|
||||||
cwWpmInput.value = clampCwWpm(evt.wpm);
|
cwWpmInput.value = String(clampCwWpm(evt.wpm));
|
||||||
}
|
}
|
||||||
if (cwToneInput && Number.isFinite(Number(evt.tone_hz))) {
|
if (cwToneInput && Number.isFinite(Number(evt.tone_hz))) {
|
||||||
cwToneInput.value = toneClampForRange(evt.tone_hz, currentCwToneRange());
|
cwToneInput.value = String(toneClampForRange(evt.tone_hz, currentCwToneRange()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (cwTonePickerRaf != null) return;
|
if (cwTonePickerRaf != null) return;
|
||||||
@@ -428,17 +387,28 @@ window.onServerCw = function(evt) {
|
|||||||
cwTonePickerRaf = null;
|
cwTonePickerRaf = null;
|
||||||
drawCwTonePicker();
|
drawCwTonePicker();
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
|
function restoreCwHistory(events) {
|
||||||
window.restoreCwHistory = function(events) {
|
|
||||||
if (!Array.isArray(events) || events.length === 0) return;
|
if (!Array.isArray(events) || events.length === 0) return;
|
||||||
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
||||||
for (const evt of events) {
|
for (const evt of events) {
|
||||||
window.onServerCw(evt);
|
onServerCw(evt);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
cwWindow.trxPluginRuntime.registerDecoder({
|
||||||
window.refreshCwTonePicker = function refreshCwTonePicker() {
|
id: "cw",
|
||||||
|
onMessage: onServerCw,
|
||||||
|
restore: restoreCwHistory,
|
||||||
|
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() {
|
||||||
ensureCwToneCanvasResolution();
|
ensureCwToneCanvasResolution();
|
||||||
drawCwTonePicker();
|
drawCwTonePicker();
|
||||||
};
|
};
|
||||||
+179
@@ -0,0 +1,179 @@
|
|||||||
|
"use strict";
|
||||||
|
(() => {
|
||||||
|
// src/decode-history-worker.ts
|
||||||
|
var textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null;
|
||||||
|
var HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax", "sstv"];
|
||||||
|
var workerScope = self;
|
||||||
|
function decodeCborUint(view, bytes, state, additional) {
|
||||||
|
const offset = state.offset;
|
||||||
|
if (additional < 24) return additional;
|
||||||
|
if (additional === 24) {
|
||||||
|
if (offset + 1 > bytes.length) throw new Error("CBOR payload truncated");
|
||||||
|
state.offset += 1;
|
||||||
|
return bytes[offset] ?? 0;
|
||||||
|
}
|
||||||
|
if (additional === 25) {
|
||||||
|
if (offset + 2 > bytes.length) throw new Error("CBOR payload truncated");
|
||||||
|
state.offset += 2;
|
||||||
|
return view.getUint16(offset);
|
||||||
|
}
|
||||||
|
if (additional === 26) {
|
||||||
|
if (offset + 4 > bytes.length) throw new Error("CBOR payload truncated");
|
||||||
|
state.offset += 4;
|
||||||
|
return view.getUint32(offset);
|
||||||
|
}
|
||||||
|
if (additional === 27) {
|
||||||
|
if (offset + 8 > bytes.length) throw new Error("CBOR payload truncated");
|
||||||
|
const value = view.getBigUint64(offset);
|
||||||
|
state.offset += 8;
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (!Number.isSafeInteger(numeric)) throw new Error("CBOR integer exceeds JS safe range");
|
||||||
|
return numeric;
|
||||||
|
}
|
||||||
|
throw new Error("Unsupported CBOR additional info");
|
||||||
|
}
|
||||||
|
function decodeCborFloat16(bits) {
|
||||||
|
const sign = bits & 32768 ? -1 : 1;
|
||||||
|
const exponent = bits >> 10 & 31;
|
||||||
|
const fraction = bits & 1023;
|
||||||
|
if (exponent === 0) {
|
||||||
|
return fraction === 0 ? sign * 0 : sign * Math.pow(2, -14) * (fraction / 1024);
|
||||||
|
}
|
||||||
|
if (exponent === 31) {
|
||||||
|
return fraction === 0 ? sign * Infinity : Number.NaN;
|
||||||
|
}
|
||||||
|
return sign * Math.pow(2, exponent - 15) * (1 + fraction / 1024);
|
||||||
|
}
|
||||||
|
function decodeCborItem(view, bytes, state) {
|
||||||
|
if (state.offset >= bytes.length) throw new Error("CBOR payload truncated");
|
||||||
|
const initial = bytes[state.offset++];
|
||||||
|
if (initial === void 0) throw new Error("CBOR payload truncated");
|
||||||
|
const major = initial >> 5;
|
||||||
|
const additional = initial & 31;
|
||||||
|
if (major === 0) return decodeCborUint(view, bytes, state, additional);
|
||||||
|
if (major === 1) return -1 - decodeCborUint(view, bytes, state, additional);
|
||||||
|
if (major === 2) {
|
||||||
|
const length = decodeCborUint(view, bytes, state, additional);
|
||||||
|
if (state.offset + length > bytes.length) throw new Error("CBOR payload truncated");
|
||||||
|
const chunk = bytes.slice(state.offset, state.offset + length);
|
||||||
|
state.offset += length;
|
||||||
|
return Array.from(chunk);
|
||||||
|
}
|
||||||
|
if (major === 3) {
|
||||||
|
const length = decodeCborUint(view, bytes, state, additional);
|
||||||
|
if (state.offset + length > bytes.length) throw new Error("CBOR payload truncated");
|
||||||
|
const chunk = bytes.subarray(state.offset, state.offset + length);
|
||||||
|
state.offset += length;
|
||||||
|
return textDecoder ? textDecoder.decode(chunk) : String.fromCharCode(...chunk);
|
||||||
|
}
|
||||||
|
if (major === 4) {
|
||||||
|
const length = decodeCborUint(view, bytes, state, additional);
|
||||||
|
const items = new Array(length);
|
||||||
|
for (let i = 0; i < length; i += 1) {
|
||||||
|
items[i] = decodeCborItem(view, bytes, state);
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
if (major === 5) {
|
||||||
|
const length = decodeCborUint(view, bytes, state, additional);
|
||||||
|
const value = {};
|
||||||
|
for (let i = 0; i < length; i += 1) {
|
||||||
|
const key = decodeCborItem(view, bytes, state);
|
||||||
|
const property = typeof key === "string" || typeof key === "number" || typeof key === "boolean" ? String(key) : JSON.stringify(key);
|
||||||
|
value[property] = decodeCborItem(view, bytes, state);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (major === 6) {
|
||||||
|
decodeCborUint(view, bytes, state, additional);
|
||||||
|
return decodeCborItem(view, bytes, state);
|
||||||
|
}
|
||||||
|
if (major === 7) {
|
||||||
|
if (additional === 20) return false;
|
||||||
|
if (additional === 21) return true;
|
||||||
|
if (additional === 22) return null;
|
||||||
|
if (additional === 23) return void 0;
|
||||||
|
if (additional === 25) {
|
||||||
|
if (state.offset + 2 > bytes.length) throw new Error("CBOR payload truncated");
|
||||||
|
const bits = view.getUint16(state.offset);
|
||||||
|
state.offset += 2;
|
||||||
|
return decodeCborFloat16(bits);
|
||||||
|
}
|
||||||
|
if (additional === 26) {
|
||||||
|
if (state.offset + 4 > bytes.length) throw new Error("CBOR payload truncated");
|
||||||
|
const value = view.getFloat32(state.offset);
|
||||||
|
state.offset += 4;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (additional === 27) {
|
||||||
|
if (state.offset + 8 > bytes.length) throw new Error("CBOR payload truncated");
|
||||||
|
const value = view.getFloat64(state.offset);
|
||||||
|
state.offset += 8;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("Unsupported CBOR major type");
|
||||||
|
}
|
||||||
|
function decodeCborPayload(buffer) {
|
||||||
|
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
|
||||||
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||||
|
const state = { offset: 0 };
|
||||||
|
const value = decodeCborItem(view, bytes, state);
|
||||||
|
if (state.offset !== bytes.length) {
|
||||||
|
throw new Error("Unexpected trailing bytes in decode history payload");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
function isHistory(value) {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
async function fetchAndDecodeHistory(url, batchLimit) {
|
||||||
|
workerScope.postMessage({ type: "status", phase: "fetching" });
|
||||||
|
const resp = await fetch(url, { credentials: "same-origin" });
|
||||||
|
if (!resp.ok) throw new Error(`History fetch failed: ${String(resp.status)}`);
|
||||||
|
const payload = await resp.arrayBuffer();
|
||||||
|
if (payload.byteLength === 0) {
|
||||||
|
workerScope.postMessage({ type: "start", total: 0 });
|
||||||
|
workerScope.postMessage({ type: "done", total: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
workerScope.postMessage({ type: "status", phase: "decoding" });
|
||||||
|
const history = decodeCborPayload(payload);
|
||||||
|
const total = HISTORY_GROUP_KEYS.reduce((sum, key) => {
|
||||||
|
const items = isHistory(history) && Array.isArray(history[key]) ? history[key] : [];
|
||||||
|
return sum + items.length;
|
||||||
|
}, 0);
|
||||||
|
workerScope.postMessage({ type: "start", total });
|
||||||
|
let processed = 0;
|
||||||
|
const safeLimit = Math.max(1, Math.min(2048, Number(batchLimit) || 512));
|
||||||
|
for (const kind of HISTORY_GROUP_KEYS) {
|
||||||
|
const items = isHistory(history) && Array.isArray(history[kind]) ? history[kind] : [];
|
||||||
|
if (items.length === 0) continue;
|
||||||
|
for (let index = 0; index < items.length; index += safeLimit) {
|
||||||
|
const messages = items.slice(index, index + safeLimit);
|
||||||
|
processed += messages.length;
|
||||||
|
workerScope.postMessage({
|
||||||
|
type: "group",
|
||||||
|
kind,
|
||||||
|
messages,
|
||||||
|
processed,
|
||||||
|
total
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
workerScope.postMessage({ type: "done", total });
|
||||||
|
}
|
||||||
|
function isFetchHistoryRequest(value) {
|
||||||
|
return typeof value === "object" && value !== null && "type" in value && value.type === "fetch-history";
|
||||||
|
}
|
||||||
|
workerScope.onmessage = (event) => {
|
||||||
|
const data = event.data;
|
||||||
|
if (!isFetchHistoryRequest(data)) return;
|
||||||
|
fetchAndDecodeHistory(data.url || "/decode/history", data.batchLimit).catch((error) => {
|
||||||
|
workerScope.postMessage({
|
||||||
|
type: "error",
|
||||||
|
message: error instanceof Error ? error.message : typeof error === "string" ? error : "unknown worker failure"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import {
|
||||||
|
initializeFtxDecoder
|
||||||
|
} from "./chunk-WU5EWJX4.js";
|
||||||
|
import "./chunk-S57W63QN.js";
|
||||||
|
import "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
|
// src/plugins/ft2.ts
|
||||||
|
initializeFtxDecoder({ id: "ft2", label: "FT2", periodMs: 3750 });
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import {
|
||||||
|
initializeFtxDecoder
|
||||||
|
} from "./chunk-WU5EWJX4.js";
|
||||||
|
import "./chunk-S57W63QN.js";
|
||||||
|
import "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
|
// src/plugins/ft4.ts
|
||||||
|
initializeFtxDecoder({ id: "ft4", label: "FT4", periodMs: 7500 });
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import {
|
||||||
|
initializeFt8FamilyBar,
|
||||||
|
initializeFtxDecoder,
|
||||||
|
installFtxCompatibilityHelpers
|
||||||
|
} from "./chunk-WU5EWJX4.js";
|
||||||
|
import "./chunk-S57W63QN.js";
|
||||||
|
import "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
|
// src/plugins/ft8.ts
|
||||||
|
installFtxCompatibilityHelpers();
|
||||||
|
initializeFt8FamilyBar();
|
||||||
|
initializeFtxDecoder({ id: "ft8", label: "FT8", periodMs: 15e3, periodDigits: 0 });
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import {
|
||||||
|
aprsAgeText,
|
||||||
|
aprsPacketCategory,
|
||||||
|
collapseAprsDuplicates,
|
||||||
|
normalizeAprsPacket,
|
||||||
|
renderAprsPacketRow
|
||||||
|
} from "./chunk-REYSUJQ4.js";
|
||||||
|
import {
|
||||||
|
forActiveRig,
|
||||||
|
isActiveRigDecode
|
||||||
|
} from "./chunk-S57W63QN.js";
|
||||||
|
import {
|
||||||
|
hostCore,
|
||||||
|
hostState
|
||||||
|
} from "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
|
// src/plugins/hf-aprs.ts
|
||||||
|
var hfAprsWindow = window;
|
||||||
|
var hfAprsStatus = document.getElementById("hf-aprs-status");
|
||||||
|
var hfAprsPacketsEl = document.getElementById("hf-aprs-packets");
|
||||||
|
var hfAprsFilterInput = document.getElementById("hf-aprs-filter");
|
||||||
|
var hfAprsOnlyPosBtn = document.getElementById("hf-aprs-only-pos-btn");
|
||||||
|
var hfAprsHideCrcBtn = document.getElementById("hf-aprs-hide-crc-btn");
|
||||||
|
var hfAprsCollapseDupBtn = document.getElementById("hf-aprs-collapse-dup-btn");
|
||||||
|
var hfAprsTotalCountEl = document.getElementById("hf-aprs-total-count");
|
||||||
|
var hfAprsVisibleCountEl = document.getElementById("hf-aprs-visible-count");
|
||||||
|
var hfAprsLatestSeenEl = document.getElementById("hf-aprs-latest-seen");
|
||||||
|
var hfAprsFilterText = "";
|
||||||
|
var hfAprsPacketHistory = [];
|
||||||
|
var hfAprsOnlyPos = false;
|
||||||
|
var hfAprsHideCrc = false;
|
||||||
|
var hfAprsCollapseDup = false;
|
||||||
|
var hfAprsTypeFilter = "all";
|
||||||
|
function currentHfAprsHistoryRetentionMs() {
|
||||||
|
return typeof hfAprsWindow.getDecodeHistoryRetentionMs === "function" ? hfAprsWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3;
|
||||||
|
}
|
||||||
|
function pruneHfAprsPacketHistory() {
|
||||||
|
const cutoffMs = Date.now() - currentHfAprsHistoryRetentionMs();
|
||||||
|
hfAprsPacketHistory = hfAprsPacketHistory.filter((pkt) => (pkt._tsMs ?? 0) >= cutoffMs);
|
||||||
|
}
|
||||||
|
function scheduleHfAprsHistoryRender() {
|
||||||
|
if (typeof hfAprsWindow.trxScheduleUiFrameJob === "function") {
|
||||||
|
hfAprsWindow.trxScheduleUiFrameJob("hf-aprs-history", () => {
|
||||||
|
renderHfAprsHistory();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderHfAprsHistory();
|
||||||
|
}
|
||||||
|
function hfAprsDistanceText(pkt) {
|
||||||
|
if (hostState.serverLat == null || hostState.serverLon == null || pkt.lat == null || pkt.lon == null) return "";
|
||||||
|
const distKm = hostCore.haversineKm(hostState.serverLat, hostState.serverLon, pkt.lat, pkt.lon);
|
||||||
|
if (!Number.isFinite(distKm)) return "";
|
||||||
|
if (distKm < 1) return `${Math.round(distKm * 1e3)} m from TRX`;
|
||||||
|
return `${distKm.toFixed(1)} km from TRX`;
|
||||||
|
}
|
||||||
|
function hfAprsFilterMatch(pkt) {
|
||||||
|
if (hfAprsOnlyPos && (pkt.lat == null || pkt.lon == null)) return false;
|
||||||
|
if (hfAprsHideCrc && !pkt.crcOk) return false;
|
||||||
|
if (hfAprsTypeFilter !== "all" && aprsPacketCategory(pkt) !== hfAprsTypeFilter) return false;
|
||||||
|
if (!hfAprsFilterText) return true;
|
||||||
|
const haystack = [
|
||||||
|
pkt.srcCall,
|
||||||
|
pkt.destCall,
|
||||||
|
pkt.path,
|
||||||
|
pkt.info,
|
||||||
|
pkt.type,
|
||||||
|
pkt.lat != null ? pkt.lat.toFixed(4) : "",
|
||||||
|
pkt.lon != null ? pkt.lon.toFixed(4) : "",
|
||||||
|
aprsPacketCategory(pkt)
|
||||||
|
].filter(Boolean).join(" ").toUpperCase();
|
||||||
|
return haystack.includes(hfAprsFilterText);
|
||||||
|
}
|
||||||
|
function hfAprsRigPackets() {
|
||||||
|
return forActiveRig(hfAprsPacketHistory);
|
||||||
|
}
|
||||||
|
function hfAprsVisiblePackets() {
|
||||||
|
const rigPackets = hfAprsRigPackets();
|
||||||
|
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(rigPackets) : rigPackets;
|
||||||
|
return packets.filter(hfAprsFilterMatch);
|
||||||
|
}
|
||||||
|
var collapseHfAprsDuplicates = collapseAprsDuplicates;
|
||||||
|
function updateHfAprsSummary() {
|
||||||
|
const rigPackets = hfAprsRigPackets();
|
||||||
|
const visible = hfAprsVisiblePackets();
|
||||||
|
if (hfAprsTotalCountEl) {
|
||||||
|
hfAprsTotalCountEl.textContent = `${rigPackets.length} total`;
|
||||||
|
}
|
||||||
|
if (hfAprsVisibleCountEl) {
|
||||||
|
hfAprsVisibleCountEl.textContent = `${visible.length} shown`;
|
||||||
|
}
|
||||||
|
if (hfAprsLatestSeenEl) {
|
||||||
|
const latest = rigPackets[0];
|
||||||
|
if (!latest) {
|
||||||
|
hfAprsLatestSeenEl.textContent = "No packets yet";
|
||||||
|
} else {
|
||||||
|
hfAprsLatestSeenEl.textContent = `${latest.srcCall} ${aprsAgeText(latest._tsMs)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function updateHfAprsChipState() {
|
||||||
|
document.querySelectorAll("[id^='hf-aprs-type-']").forEach((btn) => {
|
||||||
|
btn.classList.toggle("active", btn.id === `hf-aprs-type-${hfAprsTypeFilter}`);
|
||||||
|
});
|
||||||
|
hfAprsOnlyPosBtn?.classList.toggle("active", hfAprsOnlyPos);
|
||||||
|
hfAprsHideCrcBtn?.classList.toggle("active", hfAprsHideCrc);
|
||||||
|
hfAprsCollapseDupBtn?.classList.toggle("active", hfAprsCollapseDup);
|
||||||
|
}
|
||||||
|
function renderHfAprsRow(pkt, isFresh) {
|
||||||
|
return renderAprsPacketRow(pkt, {
|
||||||
|
fresh: isFresh,
|
||||||
|
badge: "HF",
|
||||||
|
distance: hfAprsDistanceText(pkt),
|
||||||
|
onMap: (lat, lon) => {
|
||||||
|
hfAprsWindow.navigateToAprsMap?.(lat, lon);
|
||||||
|
},
|
||||||
|
onCopy: (text) => {
|
||||||
|
void copyHfAprsCoords(text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function copyHfAprsCoords(text) {
|
||||||
|
try {
|
||||||
|
const clipboard = Reflect.get(navigator, "clipboard");
|
||||||
|
if (!clipboard) return;
|
||||||
|
await clipboard.writeText(text);
|
||||||
|
hostCore.showHint("Coordinates copied", 1200);
|
||||||
|
} catch {
|
||||||
|
hostCore.showHint("Copy failed", 1500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function renderHfAprsHistory() {
|
||||||
|
pruneHfAprsPacketHistory();
|
||||||
|
if (!hfAprsPacketsEl) {
|
||||||
|
updateHfAprsSummary();
|
||||||
|
updateHfAprsChipState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const visible = hfAprsVisiblePackets();
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (const [index, packet] of visible.entries()) {
|
||||||
|
fragment.appendChild(renderHfAprsRow(packet, index === 0));
|
||||||
|
}
|
||||||
|
hfAprsPacketsEl.replaceChildren(fragment);
|
||||||
|
updateHfAprsSummary();
|
||||||
|
updateHfAprsChipState();
|
||||||
|
}
|
||||||
|
function resetHfAprsHistoryView() {
|
||||||
|
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
|
||||||
|
hfAprsPacketHistory = [];
|
||||||
|
renderHfAprsHistory();
|
||||||
|
hfAprsWindow.clearMapMarkersByType?.("hf_aprs");
|
||||||
|
}
|
||||||
|
function pruneHfAprsHistoryView() {
|
||||||
|
pruneHfAprsPacketHistory();
|
||||||
|
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) {
|
||||||
|
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
||||||
|
pkt._tsMs = tsMs;
|
||||||
|
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
hfAprsPacketHistory.unshift(pkt);
|
||||||
|
pruneHfAprsPacketHistory();
|
||||||
|
plotHfAprsPacket(pkt);
|
||||||
|
scheduleHfAprsHistoryRender();
|
||||||
|
}
|
||||||
|
function normalizeServerHfAprsPacket(pkt) {
|
||||||
|
return normalizeAprsPacket(pkt, hfAprsWindow.getDecodeRigMeta?.() ?? null);
|
||||||
|
}
|
||||||
|
function onServerHfAprsBatch(packets) {
|
||||||
|
if (!Array.isArray(packets) || packets.length === 0) return;
|
||||||
|
const normalized = [];
|
||||||
|
for (const pkt of packets) {
|
||||||
|
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();
|
||||||
|
next._tsMs = tsMs;
|
||||||
|
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
plotHfAprsPacket(next);
|
||||||
|
normalized.push(next);
|
||||||
|
}
|
||||||
|
normalized.reverse();
|
||||||
|
hfAprsPacketHistory = normalized.concat(hfAprsPacketHistory);
|
||||||
|
pruneHfAprsPacketHistory();
|
||||||
|
scheduleHfAprsHistoryRender();
|
||||||
|
}
|
||||||
|
var hfAprsDecodeToggleBtn = document.getElementById("hf-aprs-decode-toggle-btn");
|
||||||
|
hfAprsDecodeToggleBtn?.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await hfAprsWindow.takeSchedulerControlForDecoderDisable?.(hfAprsDecodeToggleBtn);
|
||||||
|
await hostCore.postPath("/toggle_hf_aprs_decode");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("HF APRS toggle failed", e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
document.getElementById("settings-clear-hf-aprs-history")?.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
if (!await hfAprsWindow.trxUi.confirm({ title: "Clear HF APRS history?", message: "All stored HF APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
|
try {
|
||||||
|
await hostCore.postPath("/clear_hf_aprs_decode");
|
||||||
|
resetHfAprsHistoryView();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("HF APRS history clear failed", e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
if (hfAprsOnlyPosBtn) {
|
||||||
|
hfAprsOnlyPosBtn.addEventListener("click", () => {
|
||||||
|
hfAprsOnlyPos = !hfAprsOnlyPos;
|
||||||
|
renderHfAprsHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (hfAprsHideCrcBtn) {
|
||||||
|
hfAprsHideCrcBtn.addEventListener("click", () => {
|
||||||
|
hfAprsHideCrc = !hfAprsHideCrc;
|
||||||
|
renderHfAprsHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (hfAprsCollapseDupBtn) {
|
||||||
|
hfAprsCollapseDupBtn.addEventListener("click", () => {
|
||||||
|
hfAprsCollapseDup = !hfAprsCollapseDup;
|
||||||
|
renderHfAprsHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
["all", "position", "message", "weather", "telemetry", "other"].forEach((type) => {
|
||||||
|
const btn = document.getElementById(`hf-aprs-type-${type}`);
|
||||||
|
if (!btn) return;
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
hfAprsTypeFilter = type;
|
||||||
|
renderHfAprsHistory();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (hfAprsFilterInput) {
|
||||||
|
hfAprsFilterInput.addEventListener("input", () => {
|
||||||
|
hfAprsFilterText = hfAprsFilterInput.value.trim().toUpperCase();
|
||||||
|
renderHfAprsHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function onServerHfAprs(pkt) {
|
||||||
|
const packet = normalizeServerHfAprsPacket(pkt);
|
||||||
|
if (hfAprsStatus && isActiveRigDecode(packet.rig_id)) hfAprsStatus.textContent = "Receiving";
|
||||||
|
addHfAprsPacket(packet);
|
||||||
|
}
|
||||||
|
renderHfAprsHistory();
|
||||||
|
window.trxPluginRuntime.registerDecoder({
|
||||||
|
id: "hf_aprs",
|
||||||
|
onMessage: onServerHfAprs,
|
||||||
|
onBatch: onServerHfAprsBatch,
|
||||||
|
restore: onServerHfAprsBatch,
|
||||||
|
reset: resetHfAprsHistoryView,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
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 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");
|
||||||
|
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 = "";
|
||||||
|
});
|
||||||
|
bridge.logContact = (seed) => {
|
||||||
|
bridge.navigateToTab?.("logbook");
|
||||||
|
void openEntry(seed).then(() => callInput?.focus());
|
||||||
|
};
|
||||||
|
renderStation();
|
||||||
|
if (cabrilloCallsign && !cabrilloCallsign.value) {
|
||||||
|
cabrilloCallsign.value = stationCallEl?.textContent?.trim() ?? "";
|
||||||
|
}
|
||||||
|
syncCabrilloLink();
|
||||||
|
void openEntry();
|
||||||
|
void refreshLog();
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,263 @@
|
|||||||
|
// src/plugins/sat-scheduler.ts
|
||||||
|
var satSchedulerWindow = window;
|
||||||
|
(function() {
|
||||||
|
"use strict";
|
||||||
|
const dom = {
|
||||||
|
enabled: document.getElementById("scheduler-sat-enabled"),
|
||||||
|
pretune: document.getElementById("scheduler-sat-pretune"),
|
||||||
|
body: document.getElementById("scheduler-sat-body"),
|
||||||
|
tbody: document.getElementById("scheduler-sat-tbody"),
|
||||||
|
addBtn: document.getElementById("scheduler-sat-add-btn"),
|
||||||
|
passStatus: document.getElementById("scheduler-sat-pass-status"),
|
||||||
|
formWrap: document.getElementById("sch-sat-form-wrap"),
|
||||||
|
formTitle: document.getElementById("sch-sat-form-title"),
|
||||||
|
form: document.getElementById("sch-sat-form"),
|
||||||
|
formCancel: document.getElementById("sch-sat-form-cancel"),
|
||||||
|
preset: document.getElementById("scheduler-sat-preset"),
|
||||||
|
name: document.getElementById("scheduler-sat-name"),
|
||||||
|
norad: document.getElementById("scheduler-sat-norad"),
|
||||||
|
bookmark: document.getElementById("scheduler-sat-bookmark"),
|
||||||
|
minEl: document.getElementById("scheduler-sat-min-el"),
|
||||||
|
priority: document.getElementById("scheduler-sat-priority"),
|
||||||
|
centerHz: document.getElementById("scheduler-sat-center-hz")
|
||||||
|
};
|
||||||
|
let editIdx = null;
|
||||||
|
let eventsWired = false;
|
||||||
|
function getBridge() {
|
||||||
|
return satSchedulerWindow.trx?.modules?.scheduler ?? null;
|
||||||
|
}
|
||||||
|
function getConfig() {
|
||||||
|
const b = getBridge();
|
||||||
|
return b?.getConfig() ?? null;
|
||||||
|
}
|
||||||
|
function getStatus() {
|
||||||
|
const b = getBridge();
|
||||||
|
return b?.getStatus() ?? null;
|
||||||
|
}
|
||||||
|
function getBookmarks() {
|
||||||
|
const b = getBridge();
|
||||||
|
return b?.getBookmarks() ?? [];
|
||||||
|
}
|
||||||
|
function markDirty() {
|
||||||
|
getBridge()?.markDirty();
|
||||||
|
}
|
||||||
|
function bmName(id) {
|
||||||
|
const bm = getBookmarks().find(function(b) {
|
||||||
|
return b.id === id;
|
||||||
|
});
|
||||||
|
return bm ? bm.name : id;
|
||||||
|
}
|
||||||
|
function escHtml(s) {
|
||||||
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
}
|
||||||
|
function formatFreq(hz) {
|
||||||
|
if (hz >= 1e6) return (hz / 1e6).toFixed(3) + " MHz";
|
||||||
|
if (hz >= 1e3) return (hz / 1e3).toFixed(1) + " kHz";
|
||||||
|
return `${String(hz)} Hz`;
|
||||||
|
}
|
||||||
|
function getSatelliteEntries() {
|
||||||
|
const config = getConfig();
|
||||||
|
return config && config.satellites && Array.isArray(config.satellites.entries) ? config.satellites.entries : [];
|
||||||
|
}
|
||||||
|
function ensureSatelliteConfig() {
|
||||||
|
const config = getConfig();
|
||||||
|
if (!config) return { enabled: false, pretune_secs: 60, entries: [] };
|
||||||
|
if (!config.satellites) config.satellites = { enabled: false, pretune_secs: 60, entries: [] };
|
||||||
|
return config.satellites;
|
||||||
|
}
|
||||||
|
function collectSatelliteConfig() {
|
||||||
|
const enabled = dom.enabled ? dom.enabled.checked : false;
|
||||||
|
const pretune = dom.pretune ? parseInt(dom.pretune.value, 10) : 60;
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
pretune_secs: isNaN(pretune) || pretune < 0 ? 60 : pretune,
|
||||||
|
entries: getSatelliteEntries()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function renderSection() {
|
||||||
|
const config = getConfig();
|
||||||
|
const satCfg = config?.satellites;
|
||||||
|
const enabled = satCfg?.enabled ?? false;
|
||||||
|
if (dom.enabled) dom.enabled.checked = enabled;
|
||||||
|
if (dom.pretune) dom.pretune.value = String(satCfg?.pretune_secs ?? 60);
|
||||||
|
if (dom.body) dom.body.style.display = enabled ? "" : "none";
|
||||||
|
renderEntries();
|
||||||
|
renderPassStatus();
|
||||||
|
}
|
||||||
|
function renderEntries() {
|
||||||
|
if (!dom.tbody) return;
|
||||||
|
const entries = getSatelliteEntries();
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
entries.forEach(function(entry, idx) {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
const tdSat = document.createElement("td");
|
||||||
|
tdSat.textContent = entry.satellite || "";
|
||||||
|
tr.appendChild(tdSat);
|
||||||
|
const tdNorad = document.createElement("td");
|
||||||
|
tdNorad.textContent = String(entry.norad_id || "");
|
||||||
|
tr.appendChild(tdNorad);
|
||||||
|
const tdBm = document.createElement("td");
|
||||||
|
tdBm.textContent = bmName(entry.bookmark_id);
|
||||||
|
tr.appendChild(tdBm);
|
||||||
|
const tdEl = document.createElement("td");
|
||||||
|
tdEl.textContent = `${String(entry.min_elevation_deg)}°`;
|
||||||
|
tr.appendChild(tdEl);
|
||||||
|
const tdPrio = document.createElement("td");
|
||||||
|
tdPrio.textContent = String(entry.priority || 0);
|
||||||
|
tr.appendChild(tdPrio);
|
||||||
|
const tdActions = document.createElement("td");
|
||||||
|
const editBtn = document.createElement("button");
|
||||||
|
editBtn.className = "sch-write";
|
||||||
|
editBtn.type = "button";
|
||||||
|
editBtn.textContent = "Edit";
|
||||||
|
editBtn.addEventListener("click", function() {
|
||||||
|
openForm(entry, idx);
|
||||||
|
});
|
||||||
|
tdActions.appendChild(editBtn);
|
||||||
|
const removeBtn = document.createElement("button");
|
||||||
|
removeBtn.className = "sch-write";
|
||||||
|
removeBtn.type = "button";
|
||||||
|
removeBtn.textContent = "Remove";
|
||||||
|
removeBtn.addEventListener("click", function() {
|
||||||
|
removeEntry(idx);
|
||||||
|
});
|
||||||
|
tdActions.appendChild(removeBtn);
|
||||||
|
tr.appendChild(tdActions);
|
||||||
|
frag.appendChild(tr);
|
||||||
|
});
|
||||||
|
dom.tbody.replaceChildren(frag);
|
||||||
|
}
|
||||||
|
function renderPassStatus() {
|
||||||
|
if (!dom.passStatus) return;
|
||||||
|
const entries = getSatelliteEntries();
|
||||||
|
if (entries.length === 0) {
|
||||||
|
dom.passStatus.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const status = getStatus();
|
||||||
|
if (status && status.active_satellite) {
|
||||||
|
dom.passStatus.innerHTML = '<span class="sch-sat-active-badge">PASS ACTIVE: ' + escHtml(status.active_satellite) + "</span>";
|
||||||
|
} else {
|
||||||
|
dom.passStatus.innerHTML = '<span style="color:var(--text-muted);font-size:0.8rem;">No satellite pass active. Predictions available in the SAT tab.</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function renderBookmarkSelect(selectedId) {
|
||||||
|
const bookmarkSelect = dom.bookmark;
|
||||||
|
if (!bookmarkSelect) return;
|
||||||
|
bookmarkSelect.innerHTML = '<option value="">— none —</option>';
|
||||||
|
getBookmarks().forEach(function(bm) {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = bm.id;
|
||||||
|
opt.textContent = bm.name + " (" + formatFreq(bm.freq_hz) + " " + bm.mode + ")";
|
||||||
|
if (bm.id === selectedId) opt.selected = true;
|
||||||
|
bookmarkSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function removeEntry(idx) {
|
||||||
|
const sat = ensureSatelliteConfig();
|
||||||
|
sat.entries.splice(idx, 1);
|
||||||
|
renderEntries();
|
||||||
|
markDirty();
|
||||||
|
}
|
||||||
|
function openForm(entry, idx) {
|
||||||
|
editIdx = idx != null ? idx : null;
|
||||||
|
if (dom.formTitle) dom.formTitle.textContent = entry ? "Edit Satellite" : "Add Satellite";
|
||||||
|
if (dom.preset) dom.preset.value = "";
|
||||||
|
if (dom.name) dom.name.value = entry ? entry.satellite || "" : "";
|
||||||
|
if (dom.norad) dom.norad.value = entry ? String(entry.norad_id || "") : "";
|
||||||
|
if (dom.minEl) dom.minEl.value = String(entry?.min_elevation_deg ?? 5);
|
||||||
|
if (dom.priority) dom.priority.value = String(entry?.priority ?? 0);
|
||||||
|
if (dom.centerHz) dom.centerHz.value = entry?.center_hz ? String(entry.center_hz) : "";
|
||||||
|
renderBookmarkSelect(entry ? entry.bookmark_id : null);
|
||||||
|
if (dom.formWrap) {
|
||||||
|
dom.formWrap.style.display = "flex";
|
||||||
|
if (dom.name) dom.name.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function closeForm() {
|
||||||
|
if (dom.formWrap) dom.formWrap.style.display = "none";
|
||||||
|
editIdx = null;
|
||||||
|
}
|
||||||
|
function onFormSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const satellite = dom.name ? dom.name.value.trim() : "";
|
||||||
|
const noradId = dom.norad ? parseInt(dom.norad.value, 10) : NaN;
|
||||||
|
const bmId = dom.bookmark ? dom.bookmark.value : "";
|
||||||
|
if (!satellite) {
|
||||||
|
satSchedulerWindow.trxUi?.notify("Enter a satellite name.", { kind: "error" });
|
||||||
|
dom.name?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isNaN(noradId) || noradId <= 0) {
|
||||||
|
satSchedulerWindow.trxUi?.notify("Enter a valid NORAD catalog number.", { kind: "error" });
|
||||||
|
dom.norad?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!bmId) {
|
||||||
|
satSchedulerWindow.trxUi?.notify("Select a bookmark.", { kind: "error" });
|
||||||
|
dom.bookmark?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const minEl = dom.minEl ? parseFloat(dom.minEl.value) : 5;
|
||||||
|
const prio = dom.priority ? parseInt(dom.priority.value, 10) : 0;
|
||||||
|
const centerHzRaw = dom.centerHz ? parseInt(dom.centerHz.value, 10) : NaN;
|
||||||
|
const sat = ensureSatelliteConfig();
|
||||||
|
const existing = editIdx !== null ? sat.entries[editIdx] : void 0;
|
||||||
|
const entryData = {
|
||||||
|
id: existing?.id ?? `sat_${Date.now().toString(36)}`,
|
||||||
|
satellite,
|
||||||
|
norad_id: noradId,
|
||||||
|
bookmark_id: bmId,
|
||||||
|
min_elevation_deg: isNaN(minEl) ? 5 : minEl,
|
||||||
|
priority: isNaN(prio) ? 0 : prio,
|
||||||
|
center_hz: !isNaN(centerHzRaw) && centerHzRaw > 0 ? centerHzRaw : null,
|
||||||
|
bookmark_ids: []
|
||||||
|
};
|
||||||
|
if (editIdx !== null) {
|
||||||
|
sat.entries[editIdx] = entryData;
|
||||||
|
} else {
|
||||||
|
sat.entries.push(entryData);
|
||||||
|
}
|
||||||
|
closeForm();
|
||||||
|
renderEntries();
|
||||||
|
markDirty();
|
||||||
|
}
|
||||||
|
function onPresetChange() {
|
||||||
|
if (!dom.preset || !dom.preset.value) return;
|
||||||
|
const parts = dom.preset.value.split("|");
|
||||||
|
if (dom.name) dom.name.value = parts[0] || "";
|
||||||
|
if (dom.norad) dom.norad.value = parts[1] || "";
|
||||||
|
}
|
||||||
|
function wireEvents() {
|
||||||
|
if (eventsWired) return;
|
||||||
|
eventsWired = true;
|
||||||
|
if (dom.enabled) {
|
||||||
|
const enabledInput = dom.enabled;
|
||||||
|
dom.enabled.addEventListener("change", function() {
|
||||||
|
if (dom.body) dom.body.style.display = enabledInput.checked ? "" : "none";
|
||||||
|
markDirty();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (dom.pretune) {
|
||||||
|
dom.pretune.addEventListener("input", function() {
|
||||||
|
markDirty();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (dom.addBtn) dom.addBtn.addEventListener("click", function() {
|
||||||
|
openForm(null, null);
|
||||||
|
});
|
||||||
|
if (dom.form) dom.form.addEventListener("submit", onFormSubmit);
|
||||||
|
if (dom.formCancel) dom.formCancel.addEventListener("click", closeForm);
|
||||||
|
if (dom.preset) dom.preset.addEventListener("change", onPresetChange);
|
||||||
|
}
|
||||||
|
satSchedulerWindow.satScheduler = {
|
||||||
|
wireEvents,
|
||||||
|
renderSection,
|
||||||
|
renderPassStatus,
|
||||||
|
collectSatelliteConfig
|
||||||
|
};
|
||||||
|
if (getBridge()) {
|
||||||
|
wireEvents();
|
||||||
|
renderSection();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import {
|
||||||
|
hostCore
|
||||||
|
} from "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
|
// src/plugins/sat.ts
|
||||||
|
var satWindow = window;
|
||||||
|
var satDom = {
|
||||||
|
status: document.getElementById("sat-status"),
|
||||||
|
liveView: document.getElementById("sat-live-view"),
|
||||||
|
historyView: document.getElementById("sat-history-view"),
|
||||||
|
liveLatest: document.getElementById("sat-live-latest"),
|
||||||
|
historyList: document.getElementById("sat-history-list"),
|
||||||
|
historyCount: document.getElementById("sat-history-count"),
|
||||||
|
filterInput: document.getElementById("sat-filter"),
|
||||||
|
sortSelect: document.getElementById("sat-sort"),
|
||||||
|
typeFilter: document.getElementById("sat-type-filter"),
|
||||||
|
lrptState: document.getElementById("sat-lrpt-state"),
|
||||||
|
viewLiveBtn: document.getElementById("sat-view-live"),
|
||||||
|
viewHistoryBtn: document.getElementById("sat-view-history")
|
||||||
|
};
|
||||||
|
var satImageHistory = [];
|
||||||
|
var SAT_MAX_IMAGES = 100;
|
||||||
|
var satFilterText = "";
|
||||||
|
var satActiveView = "live";
|
||||||
|
function scheduleSatUi(key, job) {
|
||||||
|
if (typeof satWindow.trxScheduleUiFrameJob === "function") {
|
||||||
|
satWindow.trxScheduleUiFrameJob(key, job);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
job();
|
||||||
|
}
|
||||||
|
function switchSatView(view) {
|
||||||
|
satActiveView = view;
|
||||||
|
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "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.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history");
|
||||||
|
if (view === "history") renderSatHistoryTable();
|
||||||
|
}
|
||||||
|
satDom.viewLiveBtn?.addEventListener("click", () => {
|
||||||
|
switchSatView("live");
|
||||||
|
});
|
||||||
|
satDom.viewHistoryBtn?.addEventListener("click", () => {
|
||||||
|
switchSatView("history");
|
||||||
|
});
|
||||||
|
var lastSatLrptOn = null;
|
||||||
|
satWindow.updateSatLiveState = function(update) {
|
||||||
|
if (!satDom.lrptState) return;
|
||||||
|
const lrptOn = !!update.lrpt_decode_enabled;
|
||||||
|
if (lrptOn !== lastSatLrptOn) {
|
||||||
|
lastSatLrptOn = lrptOn;
|
||||||
|
satDom.lrptState.textContent = lrptOn ? "Listening" : "Idle";
|
||||||
|
satDom.lrptState.className = "sat-live-value " + (lrptOn ? "sat-state-listening" : "sat-state-idle");
|
||||||
|
if (satDom.status) {
|
||||||
|
if (lrptOn) {
|
||||||
|
satDom.status.textContent = "Decoder active — waiting for signal";
|
||||||
|
} else {
|
||||||
|
satDom.status.textContent = "Decoder idle";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
function renderSatLatestCard() {
|
||||||
|
if (!satDom.liveLatest) return;
|
||||||
|
if (satImageHistory.length === 0) {
|
||||||
|
satDom.liveLatest.innerHTML = '<div style="color:var(--text-muted);font-size:0.82rem;">No images decoded yet. Enable a decoder and wait for a satellite pass.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const img = satImageHistory[0];
|
||||||
|
if (!img) return;
|
||||||
|
const typeName = "Meteor LRPT";
|
||||||
|
const satellite = img.satellite || "";
|
||||||
|
const channels = img.channels || img.channel_a || "";
|
||||||
|
const lines = img.mcu_count || img.line_count || 0;
|
||||||
|
const unit = "MCU rows";
|
||||||
|
const ts = img._ts || "--";
|
||||||
|
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : "";
|
||||||
|
const meta = [typeName];
|
||||||
|
if (satellite) meta.push(satellite);
|
||||||
|
if (channels) meta.push(channels);
|
||||||
|
meta.push(`${lines} ${unit}`);
|
||||||
|
meta.push(`${date} ${ts}`);
|
||||||
|
let html = `<div class="sat-latest-card">`;
|
||||||
|
html += `<div class="sat-latest-title">Latest decoded image</div>`;
|
||||||
|
html += `<div class="sat-latest-meta">${meta.join(" · ")}</div>`;
|
||||||
|
if (img.path) {
|
||||||
|
html += `<a href="${img.path}" target="_blank" style="font-size:0.8rem;color:var(--accent);display:inline-block;margin-top:0.25rem;">Download PNG</a>`;
|
||||||
|
}
|
||||||
|
if (img.geo_bounds) {
|
||||||
|
html += ` <button type="button" class="sat-map-btn" onclick="window.satShowOnMap(${img.geo_bounds[0]},${img.geo_bounds[1]},${img.geo_bounds[2]},${img.geo_bounds[3]})" style="font-size:0.8rem;margin-top:0.25rem;margin-left:0.5rem;cursor:pointer;background:none;border:1px solid var(--accent);color:var(--accent);border-radius:3px;padding:1px 6px;">Show on Map</button>`;
|
||||||
|
}
|
||||||
|
html += `</div>`;
|
||||||
|
satDom.liveLatest.innerHTML = html;
|
||||||
|
}
|
||||||
|
function getSatFilteredHistory() {
|
||||||
|
let items = satImageHistory;
|
||||||
|
const typeVal = satDom.typeFilter ? satDom.typeFilter.value : "all";
|
||||||
|
if (typeVal === "lrpt") items = items.filter((i) => i._decoder === "lrpt");
|
||||||
|
if (satFilterText) {
|
||||||
|
items = items.filter((i) => {
|
||||||
|
const haystack = [
|
||||||
|
"meteor lrpt",
|
||||||
|
i.satellite || "",
|
||||||
|
i.channels || "",
|
||||||
|
i.channel_a || "",
|
||||||
|
i.channel_b || ""
|
||||||
|
].join(" ").toUpperCase();
|
||||||
|
return haystack.includes(satFilterText);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const sortVal = satDom.sortSelect ? satDom.sortSelect.value : "newest";
|
||||||
|
if (sortVal === "oldest") items = items.slice().reverse();
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
function renderSatHistoryRow(img) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "sat-history-row";
|
||||||
|
const typeName = "Meteor LRPT";
|
||||||
|
const typeClass = "sat-type-lrpt";
|
||||||
|
const ts = img._ts || "--";
|
||||||
|
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString([], { month: "short", day: "numeric" }) : "";
|
||||||
|
const satellite = img.satellite || "--";
|
||||||
|
const channels = img.channels || "--";
|
||||||
|
const lines = img.mcu_count || img.line_count || 0;
|
||||||
|
const unit = "MCU";
|
||||||
|
let link = img.path ? `<a href="${img.path}" target="_blank" style="color:var(--accent);">PNG</a>` : "--";
|
||||||
|
if (img.geo_bounds) {
|
||||||
|
link += ` <a href="javascript:void(0)" onclick="window.satShowOnMap(${img.geo_bounds[0]},${img.geo_bounds[1]},${img.geo_bounds[2]},${img.geo_bounds[3]})" style="color:var(--accent);">Map</a>`;
|
||||||
|
}
|
||||||
|
row.innerHTML = [
|
||||||
|
`<span>${date} ${ts}</span>`,
|
||||||
|
`<span class="sat-col-type ${typeClass}">${typeName}</span>`,
|
||||||
|
`<span>${satellite}</span>`,
|
||||||
|
`<span>${channels}</span>`,
|
||||||
|
`<span>${lines} ${unit}</span>`,
|
||||||
|
`<span>${link}</span>`
|
||||||
|
].join("");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
function renderSatHistoryTable() {
|
||||||
|
if (!satDom.historyList) return;
|
||||||
|
const items = getSatFilteredHistory();
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (const image of items) {
|
||||||
|
fragment.appendChild(renderSatHistoryRow(image));
|
||||||
|
}
|
||||||
|
satDom.historyList.replaceChildren(fragment);
|
||||||
|
if (satDom.historyCount) {
|
||||||
|
const total = satImageHistory.length;
|
||||||
|
const shown = items.length;
|
||||||
|
satDom.historyCount.textContent = total === 0 ? "No images yet" : shown === total ? `${total} image${total === 1 ? "" : "s"}` : `${shown} of ${total} images`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function addSatImage(img, decoder) {
|
||||||
|
const tsMs = Number.isFinite(img.ts_ms) ? Number(img.ts_ms) : Date.now();
|
||||||
|
img._tsMs = tsMs;
|
||||||
|
img._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit"
|
||||||
|
});
|
||||||
|
img._decoder = decoder;
|
||||||
|
satImageHistory.unshift(img);
|
||||||
|
if (satImageHistory.length > SAT_MAX_IMAGES) {
|
||||||
|
satImageHistory = satImageHistory.slice(0, SAT_MAX_IMAGES);
|
||||||
|
}
|
||||||
|
scheduleSatUi("sat-latest", () => {
|
||||||
|
renderSatLatestCard();
|
||||||
|
});
|
||||||
|
if (satActiveView === "history") {
|
||||||
|
scheduleSatUi("sat-history", () => {
|
||||||
|
renderSatHistoryTable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onServerLrptProgress(msg) {
|
||||||
|
if (satDom.status && (msg.mcu_count ?? 0) > 0) {
|
||||||
|
satDom.status.textContent = `Receiving — ${String(msg.mcu_count ?? 0)} MCU rows decoded`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onServerLrptImage(msg) {
|
||||||
|
if (satDom.status) satDom.status.textContent = "Image received (Meteor LRPT)";
|
||||||
|
addSatImage(msg, "lrpt");
|
||||||
|
if (msg.geo_bounds && msg.path && satWindow.addSatMapOverlay) {
|
||||||
|
satWindow.addSatMapOverlay(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function resetSatHistoryView() {
|
||||||
|
satImageHistory = [];
|
||||||
|
if (satDom.historyList) satDom.historyList.innerHTML = "";
|
||||||
|
renderSatLatestCard();
|
||||||
|
renderSatHistoryTable();
|
||||||
|
satWindow.clearSatMapOverlays?.();
|
||||||
|
}
|
||||||
|
function pruneSatHistoryView() {
|
||||||
|
renderSatHistoryTable();
|
||||||
|
renderSatLatestCard();
|
||||||
|
}
|
||||||
|
satWindow.trxPluginRuntime.registerDecoder({
|
||||||
|
id: "lrpt_image",
|
||||||
|
onMessage: onServerLrptImage,
|
||||||
|
reset: resetSatHistoryView,
|
||||||
|
prune: pruneSatHistoryView
|
||||||
|
});
|
||||||
|
satWindow.trxPluginRuntime.registerDecoder({
|
||||||
|
id: "lrpt_progress",
|
||||||
|
onMessage: onServerLrptProgress
|
||||||
|
});
|
||||||
|
var lrptDecodeToggleBtn = document.getElementById("lrpt-decode-toggle-btn");
|
||||||
|
lrptDecodeToggleBtn?.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await satWindow.takeSchedulerControlForDecoderDisable?.(lrptDecodeToggleBtn);
|
||||||
|
await hostCore.postPath("/toggle_lrpt_decode");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("LRPT toggle failed", e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
var satFilterInput = satDom.filterInput;
|
||||||
|
satFilterInput?.addEventListener("input", () => {
|
||||||
|
satFilterText = satFilterInput.value.trim().toUpperCase();
|
||||||
|
renderSatHistoryTable();
|
||||||
|
});
|
||||||
|
satDom.sortSelect?.addEventListener("change", () => {
|
||||||
|
renderSatHistoryTable();
|
||||||
|
});
|
||||||
|
satDom.typeFilter?.addEventListener("change", () => {
|
||||||
|
renderSatHistoryTable();
|
||||||
|
});
|
||||||
|
document.getElementById("settings-clear-sat-history")?.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
if (!await satWindow.trxUi.confirm({ title: "Clear satellite history?", message: "All stored satellite decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
|
try {
|
||||||
|
await hostCore.postPath("/clear_lrpt_decode");
|
||||||
|
resetSatHistoryView();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Weather satellite history clear failed", e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
satWindow.satShowOnMap = function(south, west, north, east) {
|
||||||
|
if (typeof satWindow.enableMapSourceFilter === "function") {
|
||||||
|
satWindow.enableMapSourceFilter("sat");
|
||||||
|
}
|
||||||
|
const lat = (south + north) / 2;
|
||||||
|
const lon = (west + east) / 2;
|
||||||
|
if (satWindow.navigateToAprsMap) {
|
||||||
|
satWindow.navigateToAprsMap(lat, lon);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
renderSatLatestCard();
|
||||||
|
renderSatHistoryTable();
|
||||||
+228
@@ -0,0 +1,228 @@
|
|||||||
|
// 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();
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
+26
-49
@@ -1,13 +1,8 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
// src/screenshot.ts
|
||||||
//
|
var screenshotWindow = window;
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
(function() {
|
||||||
|
|
||||||
// Spectrum screenshot module (loaded on demand when user triggers screenshot).
|
|
||||||
// Communicates with app.js core via window.trx namespace.
|
|
||||||
(function () {
|
|
||||||
"use strict";
|
"use strict";
|
||||||
const T = window.trx;
|
const T = screenshotWindow.trx;
|
||||||
|
|
||||||
function isVisibleForSnapshot(el) {
|
function isVisibleForSnapshot(el) {
|
||||||
if (!el) return false;
|
if (!el) return false;
|
||||||
const style = getComputedStyle(el);
|
const style = getComputedStyle(el);
|
||||||
@@ -17,7 +12,6 @@
|
|||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
return rect.width > 0 && rect.height > 0;
|
return rect.width > 0 && rect.height > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawRoundedRectPath(ctx, x, y, w, h, r) {
|
function drawRoundedRectPath(ctx, x, y, w, h, r) {
|
||||||
const radius = Math.max(0, Math.min(r, Math.min(w, h) / 2));
|
const radius = Math.max(0, Math.min(r, Math.min(w, h) / 2));
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
@@ -32,7 +26,6 @@
|
|||||||
ctx.quadraticCurveTo(x, y, x + radius, y);
|
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||||
ctx.closePath();
|
ctx.closePath();
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawElementChrome(ctx, el, rootRect, maxAlpha = 1) {
|
function drawElementChrome(ctx, el, rootRect, maxAlpha = 1) {
|
||||||
if (!isVisibleForSnapshot(el)) return null;
|
if (!isVisibleForSnapshot(el)) return null;
|
||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
@@ -45,30 +38,29 @@
|
|||||||
const bg = T.cssColorToRgba(style.backgroundColor || "rgba(0,0,0,0)");
|
const bg = T.cssColorToRgba(style.backgroundColor || "rgba(0,0,0,0)");
|
||||||
const borderWidth = Math.max(0, parseFloat(style.borderTopWidth) || 0);
|
const borderWidth = Math.max(0, parseFloat(style.borderTopWidth) || 0);
|
||||||
const border = T.cssColorToRgba(style.borderTopColor || "rgba(0,0,0,0)");
|
const border = T.cssColorToRgba(style.borderTopColor || "rgba(0,0,0,0)");
|
||||||
|
|
||||||
const bgAlpha = Math.min(bg[3], maxAlpha);
|
const bgAlpha = Math.min(bg[3], maxAlpha);
|
||||||
if (bgAlpha > 0.01) {
|
if (bgAlpha > 0.01) {
|
||||||
drawRoundedRectPath(ctx, x, y, w, h, radius);
|
drawRoundedRectPath(ctx, x, y, w, h, radius);
|
||||||
ctx.fillStyle = `rgba(${Math.round(bg[0])}, ${Math.round(bg[1])}, ${Math.round(bg[2])}, ${bgAlpha})`;
|
ctx.fillStyle = `rgba(${String(Math.round(bg[0]))}, ${String(Math.round(bg[1]))}, ${String(Math.round(bg[2]))}, ${String(bgAlpha)})`;
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
}
|
}
|
||||||
const borderAlpha = Math.min(border[3], maxAlpha);
|
const borderAlpha = Math.min(border[3], maxAlpha);
|
||||||
if (borderWidth > 0 && borderAlpha > 0.01) {
|
if (borderWidth > 0 && borderAlpha > 0.01) {
|
||||||
drawRoundedRectPath(ctx, x + borderWidth * 0.5, y + borderWidth * 0.5, w - borderWidth, h - borderWidth, Math.max(0, radius - borderWidth * 0.5));
|
drawRoundedRectPath(ctx, x + borderWidth * 0.5, y + borderWidth * 0.5, w - borderWidth, h - borderWidth, Math.max(0, radius - borderWidth * 0.5));
|
||||||
ctx.lineWidth = borderWidth;
|
ctx.lineWidth = borderWidth;
|
||||||
ctx.strokeStyle = `rgba(${Math.round(border[0])}, ${Math.round(border[1])}, ${Math.round(border[2])}, ${borderAlpha})`;
|
ctx.strokeStyle = `rgba(${String(Math.round(border[0]))}, ${String(Math.round(border[1]))}, ${String(Math.round(border[2]))}, ${String(borderAlpha)})`;
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
return { x, y, w, h, style };
|
return { x, y, w, h, style };
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawWrappedText(ctx, text, x, y, maxWidth, lineHeight, maxLines) {
|
function drawWrappedText(ctx, text, x, y, maxWidth, lineHeight, maxLines) {
|
||||||
const words = String(text || "").split(/\s+/).filter(Boolean);
|
const words = (text || "").split(/\s+/).filter(Boolean);
|
||||||
if (!words.length) return;
|
if (!words.length) return;
|
||||||
let line = "";
|
let line = "";
|
||||||
let lineIdx = 0;
|
let lineIdx = 0;
|
||||||
for (let i = 0; i < words.length; i += 1) {
|
for (let i = 0; i < words.length; i += 1) {
|
||||||
const candidate = line ? `${line} ${words[i]}` : words[i];
|
const word = words[i] ?? "";
|
||||||
|
const candidate = line ? `${line} ${word}` : word;
|
||||||
if (ctx.measureText(candidate).width <= maxWidth || !line) {
|
if (ctx.measureText(candidate).width <= maxWidth || !line) {
|
||||||
line = candidate;
|
line = candidate;
|
||||||
continue;
|
continue;
|
||||||
@@ -76,13 +68,12 @@
|
|||||||
ctx.fillText(line, x, y + lineIdx * lineHeight);
|
ctx.fillText(line, x, y + lineIdx * lineHeight);
|
||||||
lineIdx += 1;
|
lineIdx += 1;
|
||||||
if (lineIdx >= maxLines) return;
|
if (lineIdx >= maxLines) return;
|
||||||
line = words[i];
|
line = word;
|
||||||
}
|
}
|
||||||
if (line && lineIdx < maxLines) {
|
if (line && lineIdx < maxLines) {
|
||||||
ctx.fillText(line, x, y + lineIdx * lineHeight);
|
ctx.fillText(line, x, y + lineIdx * lineHeight);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawElementTextBlock(ctx, el, rootRect, fallbackText = null, maxAlpha = 1) {
|
function drawElementTextBlock(ctx, el, rootRect, fallbackText = null, maxAlpha = 1) {
|
||||||
const chrome = drawElementChrome(ctx, el, rootRect, maxAlpha);
|
const chrome = drawElementChrome(ctx, el, rootRect, maxAlpha);
|
||||||
if (!chrome) return;
|
if (!chrome) return;
|
||||||
@@ -91,7 +82,7 @@
|
|||||||
if (!clean) return;
|
if (!clean) return;
|
||||||
const style = chrome.style;
|
const style = chrome.style;
|
||||||
const fontSize = parseFloat(style.fontSize) || 12;
|
const fontSize = parseFloat(style.fontSize) || 12;
|
||||||
const lineHeight = (parseFloat(style.lineHeight) || fontSize * 1.25);
|
const lineHeight = parseFloat(style.lineHeight) || fontSize * 1.25;
|
||||||
const padX = 6;
|
const padX = 6;
|
||||||
const padY = 4;
|
const padY = 4;
|
||||||
const maxWidth = Math.max(20, chrome.w - padX * 2);
|
const maxWidth = Math.max(20, chrome.w - padX * 2);
|
||||||
@@ -110,12 +101,11 @@
|
|||||||
chrome.y + padY + lineCursor * lineHeight,
|
chrome.y + padY + lineCursor * lineHeight,
|
||||||
maxWidth,
|
maxWidth,
|
||||||
lineHeight,
|
lineHeight,
|
||||||
maxLines - lineCursor,
|
maxLines - lineCursor
|
||||||
);
|
);
|
||||||
lineCursor += 1;
|
lineCursor += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawAxisLabels(ctx, axisEl, rootRect) {
|
function drawAxisLabels(ctx, axisEl, rootRect) {
|
||||||
if (!isVisibleForSnapshot(axisEl)) return;
|
if (!isVisibleForSnapshot(axisEl)) return;
|
||||||
for (const node of axisEl.children) {
|
for (const node of axisEl.children) {
|
||||||
@@ -132,7 +122,6 @@
|
|||||||
ctx.fillText(text, chrome.x + 4, chrome.y + chrome.h / 2);
|
ctx.fillText(text, chrome.x + 4, chrome.y + chrome.h / 2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSpectrumSnapshotCanvas() {
|
function buildSpectrumSnapshotCanvas() {
|
||||||
const rootEl = document.querySelector(".signal-visual-block");
|
const rootEl = document.querySelector(".signal-visual-block");
|
||||||
const spectrumPanelEl = document.getElementById("spectrum-panel");
|
const spectrumPanelEl = document.getElementById("spectrum-panel");
|
||||||
@@ -145,8 +134,7 @@
|
|||||||
try {
|
try {
|
||||||
if (typeof gl.flush === "function") gl.flush();
|
if (typeof gl.flush === "function") gl.flush();
|
||||||
if (typeof gl.finish === "function") gl.finish();
|
if (typeof gl.finish === "function") gl.finish();
|
||||||
} catch (_) {
|
} catch {
|
||||||
// Ignore transient WebGL state errors and capture the last good frame.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const rootRect = rootEl.getBoundingClientRect();
|
const rootRect = rootEl.getBoundingClientRect();
|
||||||
@@ -157,11 +145,9 @@
|
|||||||
const ctx = out.getContext("2d");
|
const ctx = out.getContext("2d");
|
||||||
if (!ctx) return null;
|
if (!ctx) return null;
|
||||||
ctx.scale(dpr, dpr);
|
ctx.scale(dpr, dpr);
|
||||||
|
|
||||||
const bg = getComputedStyle(document.documentElement).getPropertyValue("--bg").trim() || getComputedStyle(document.body).backgroundColor || "#000";
|
const bg = getComputedStyle(document.documentElement).getPropertyValue("--bg").trim() || getComputedStyle(document.body).backgroundColor || "#000";
|
||||||
ctx.fillStyle = bg;
|
ctx.fillStyle = bg;
|
||||||
ctx.fillRect(0, 0, rootRect.width, rootRect.height);
|
ctx.fillRect(0, 0, rootRect.width, rootRect.height);
|
||||||
|
|
||||||
const signalOverlayCanvas = document.getElementById("signal-overlay-canvas");
|
const signalOverlayCanvas = document.getElementById("signal-overlay-canvas");
|
||||||
const canvases = [T.overviewCanvas, T.spectrumCanvas, signalOverlayCanvas];
|
const canvases = [T.overviewCanvas, T.spectrumCanvas, signalOverlayCanvas];
|
||||||
for (const canvas of canvases) {
|
for (const canvas of canvases) {
|
||||||
@@ -172,28 +158,21 @@
|
|||||||
rect.left - rootRect.left,
|
rect.left - rootRect.left,
|
||||||
rect.top - rootRect.top,
|
rect.top - rootRect.top,
|
||||||
rect.width,
|
rect.width,
|
||||||
rect.height,
|
rect.height
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decoder overlays over the signal view.
|
|
||||||
// Cap background alpha to avoid opaque blocks (backdrop-filter can't be
|
|
||||||
// replicated on canvas, so frosted-glass overlays would otherwise obscure
|
|
||||||
// the spectrum).
|
|
||||||
const decoderOverlayIds = [
|
const decoderOverlayIds = [
|
||||||
"ais-bar-overlay",
|
"ais-bar-overlay",
|
||||||
"vdes-bar-overlay",
|
"vdes-bar-overlay",
|
||||||
"ft8-bar-overlay",
|
"ft8-bar-overlay",
|
||||||
"aprs-bar-overlay",
|
"aprs-bar-overlay",
|
||||||
"rds-ps-overlay",
|
"rds-ps-overlay"
|
||||||
];
|
];
|
||||||
for (const id of decoderOverlayIds) {
|
for (const id of decoderOverlayIds) {
|
||||||
const overlayEl = document.getElementById(id);
|
const overlayEl = document.getElementById(id);
|
||||||
if (!overlayEl || !isVisibleForSnapshot(overlayEl)) continue;
|
if (!overlayEl || !isVisibleForSnapshot(overlayEl)) continue;
|
||||||
drawElementTextBlock(ctx, overlayEl, rootRect, null, 0.35);
|
drawElementTextBlock(ctx, overlayEl, rootRect, null, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spectrum axis labels and bookmark chips (includes freq bar).
|
|
||||||
const spectrumFreqAxis = document.getElementById("spectrum-freq-axis");
|
const spectrumFreqAxis = document.getElementById("spectrum-freq-axis");
|
||||||
const spectrumDbAxis = document.getElementById("spectrum-db-axis");
|
const spectrumDbAxis = document.getElementById("spectrum-db-axis");
|
||||||
drawAxisLabels(ctx, spectrumFreqAxis, rootRect);
|
drawAxisLabels(ctx, spectrumFreqAxis, rootRect);
|
||||||
@@ -201,10 +180,8 @@
|
|||||||
drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-axis"), rootRect);
|
drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-axis"), rootRect);
|
||||||
drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-side-left"), rootRect);
|
drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-side-left"), rootRect);
|
||||||
drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-side-right"), rootRect);
|
drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-side-right"), rootRect);
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function clickCanvasDownload(href, fileName) {
|
function clickCanvasDownload(href, fileName) {
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = href;
|
a.href = href;
|
||||||
@@ -213,9 +190,10 @@
|
|||||||
a.style.display = "none";
|
a.style.display = "none";
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
requestAnimationFrame(() => a.remove());
|
requestAnimationFrame(() => {
|
||||||
|
a.remove();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveCanvasAsPng(canvas, fileName) {
|
function saveCanvasAsPng(canvas, fileName) {
|
||||||
if (!canvas) return Promise.resolve(false);
|
if (!canvas) return Promise.resolve(false);
|
||||||
if (typeof canvas.toBlob === "function") {
|
if (typeof canvas.toBlob === "function") {
|
||||||
@@ -228,10 +206,12 @@
|
|||||||
}
|
}
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
clickCanvasDownload(url, fileName);
|
clickCanvasDownload(url, fileName);
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
setTimeout(() => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, 1e3);
|
||||||
resolve(true);
|
resolve(true);
|
||||||
}, "image/png");
|
}, "image/png");
|
||||||
} catch (_) {
|
} catch {
|
||||||
resolve(false);
|
resolve(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -239,27 +219,24 @@
|
|||||||
try {
|
try {
|
||||||
clickCanvasDownload(canvas.toDataURL("image/png"), fileName);
|
clickCanvasDownload(canvas.toDataURL("image/png"), fileName);
|
||||||
return Promise.resolve(true);
|
return Promise.resolve(true);
|
||||||
} catch (_) {
|
} catch {
|
||||||
return Promise.resolve(false);
|
return Promise.resolve(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function captureSpectrumScreenshot() {
|
async function captureSpectrumScreenshot() {
|
||||||
const snapshotCanvas = buildSpectrumSnapshotCanvas();
|
const snapshotCanvas = buildSpectrumSnapshotCanvas();
|
||||||
if (!snapshotCanvas) {
|
if (!snapshotCanvas) {
|
||||||
T.showHint("Spectrum view not ready", 1300);
|
T.showHint("Spectrum view not ready", 1300);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
||||||
const saved = await saveCanvasAsPng(snapshotCanvas, `trx-spectrum-${stamp}.png`);
|
const saved = await saveCanvasAsPng(snapshotCanvas, `trx-spectrum-${stamp}.png`);
|
||||||
T.showHint(saved ? "Spectrum screenshot saved" : "Spectrum screenshot failed", saved ? 1500 : 1800);
|
T.showHint(saved ? "Spectrum screenshot saved" : "Spectrum screenshot failed", saved ? 1500 : 1800);
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
screenshotWindow.trx.modules.screenshot = {
|
||||||
// Register module API
|
|
||||||
window.trx.screenshot = {
|
|
||||||
captureSpectrumScreenshot,
|
captureSpectrumScreenshot,
|
||||||
buildSpectrumSnapshotCanvas,
|
buildSpectrumSnapshotCanvas,
|
||||||
saveCanvasAsPng,
|
saveCanvasAsPng
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import {
|
||||||
|
forActiveRig,
|
||||||
|
isActiveRigDecode
|
||||||
|
} from "./chunk-S57W63QN.js";
|
||||||
|
import {
|
||||||
|
hostCore
|
||||||
|
} from "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
|
// src/plugins/sstv.ts
|
||||||
|
var sstvWindow = window;
|
||||||
|
var sstvDom = {
|
||||||
|
status: document.getElementById("sstv-status"),
|
||||||
|
liveView: document.getElementById("sstv-live-view"),
|
||||||
|
historyView: document.getElementById("sstv-history-view"),
|
||||||
|
liveContainer: document.getElementById("sstv-live-container"),
|
||||||
|
liveInfo: document.getElementById("sstv-live-info"),
|
||||||
|
liveCanvas: document.getElementById("sstv-live-canvas"),
|
||||||
|
liveLatest: document.getElementById("sstv-live-latest"),
|
||||||
|
historyList: document.getElementById("sstv-history-list"),
|
||||||
|
historyCount: document.getElementById("sstv-history-count"),
|
||||||
|
filterInput: document.getElementById("sstv-filter"),
|
||||||
|
sortSelect: document.getElementById("sstv-sort"),
|
||||||
|
toggleBtn: document.getElementById("sstv-decode-toggle-btn"),
|
||||||
|
clearBtn: document.getElementById("sstv-clear-btn"),
|
||||||
|
viewLiveBtn: document.getElementById("sstv-view-live"),
|
||||||
|
viewHistoryBtn: document.getElementById("sstv-view-history")
|
||||||
|
};
|
||||||
|
var SSTV_MAX_IMAGES = 100;
|
||||||
|
var sstvHistory = [];
|
||||||
|
var liveCtx = null;
|
||||||
|
var liveMode = "";
|
||||||
|
var liveHeight = 0;
|
||||||
|
var liveRows = 0;
|
||||||
|
var activeView = "live";
|
||||||
|
var filterText = "";
|
||||||
|
function retentionMs() {
|
||||||
|
return sstvWindow.getDecodeHistoryRetentionMs?.() ?? 24 * 60 * 60 * 1e3;
|
||||||
|
}
|
||||||
|
function pruneHistory() {
|
||||||
|
const cutoff = Date.now() - retentionMs();
|
||||||
|
sstvHistory = sstvHistory.filter((image) => (image._tsMs || 0) > cutoff);
|
||||||
|
}
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
}
|
||||||
|
function scheduleUi(key, job) {
|
||||||
|
if (typeof sstvWindow.trxScheduleUiFrameJob === "function") {
|
||||||
|
sstvWindow.trxScheduleUiFrameJob(key, job);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
job();
|
||||||
|
}
|
||||||
|
function imageUrl(image) {
|
||||||
|
if (!image.path) return null;
|
||||||
|
const filename = image.path.split(/[\\/]/).pop();
|
||||||
|
return filename ? `/sstv-images/${encodeURIComponent(filename)}` : null;
|
||||||
|
}
|
||||||
|
function decodeBase64(data) {
|
||||||
|
const binary = atob(data);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
function switchView(view) {
|
||||||
|
activeView = view;
|
||||||
|
if (sstvDom.liveView) sstvDom.liveView.style.display = view === "live" ? "" : "none";
|
||||||
|
if (sstvDom.historyView) sstvDom.historyView.style.display = view === "history" ? "" : "none";
|
||||||
|
for (const button of [sstvDom.viewLiveBtn, sstvDom.viewHistoryBtn]) {
|
||||||
|
button?.classList.remove("sat-view-active");
|
||||||
|
}
|
||||||
|
if (view === "live") sstvDom.viewLiveBtn?.classList.add("sat-view-active");
|
||||||
|
else sstvDom.viewHistoryBtn?.classList.add("sat-view-active");
|
||||||
|
if (view === "history") renderHistoryTable();
|
||||||
|
}
|
||||||
|
sstvDom.viewLiveBtn?.addEventListener("click", () => {
|
||||||
|
switchView("live");
|
||||||
|
});
|
||||||
|
sstvDom.viewHistoryBtn?.addEventListener("click", () => {
|
||||||
|
switchView("history");
|
||||||
|
});
|
||||||
|
function beginPicture(mode, width, height) {
|
||||||
|
const canvas = sstvDom.liveCanvas;
|
||||||
|
if (!canvas || width <= 0 || height <= 0) return;
|
||||||
|
liveMode = mode;
|
||||||
|
liveHeight = height;
|
||||||
|
liveRows = 0;
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
liveCtx = canvas.getContext("2d");
|
||||||
|
if (!liveCtx) return;
|
||||||
|
liveCtx.fillStyle = "#606060";
|
||||||
|
liveCtx.fillRect(0, 0, width, height);
|
||||||
|
if (sstvDom.liveContainer) sstvDom.liveContainer.style.display = "";
|
||||||
|
updateLiveInfo();
|
||||||
|
}
|
||||||
|
function updateLiveInfo() {
|
||||||
|
if (!sstvDom.liveInfo) return;
|
||||||
|
const canvas = sstvDom.liveCanvas;
|
||||||
|
const size = canvas ? `${canvas.width}×${canvas.height}` : "";
|
||||||
|
sstvDom.liveInfo.textContent = liveMode ? `${liveMode} · ${size} · line ${liveRows}${liveHeight ? ` of ${liveHeight}` : ""}` : "";
|
||||||
|
}
|
||||||
|
function paintRow(line, rgb) {
|
||||||
|
const canvas = sstvDom.liveCanvas;
|
||||||
|
if (!liveCtx || !canvas) return;
|
||||||
|
const width = canvas.width;
|
||||||
|
if (line < 0 || line >= canvas.height || rgb.length < width * 3) return;
|
||||||
|
const row = liveCtx.createImageData(width, 1);
|
||||||
|
for (let x = 0; x < width; x += 1) {
|
||||||
|
row.data[x * 4] = rgb[x * 3] ?? 0;
|
||||||
|
row.data[x * 4 + 1] = rgb[x * 3 + 1] ?? 0;
|
||||||
|
row.data[x * 4 + 2] = rgb[x * 3 + 2] ?? 0;
|
||||||
|
row.data[x * 4 + 3] = 255;
|
||||||
|
}
|
||||||
|
liveCtx.putImageData(row, 0, line);
|
||||||
|
liveRows = Math.max(liveRows, line + 1);
|
||||||
|
}
|
||||||
|
function onProgress(msg) {
|
||||||
|
if (!isActiveRigDecode(msg.rig_id)) return;
|
||||||
|
if (msg.state) {
|
||||||
|
if (sstvDom.status) sstvDom.status.textContent = msg.state;
|
||||||
|
beginPicture(msg.mode || "", msg.width || 0, msg.height || 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof msg.line !== "number" || !msg.line_data) return;
|
||||||
|
const line = msg.line;
|
||||||
|
const rgb = decodeBase64(msg.line_data);
|
||||||
|
scheduleUi(`sstv-row-${line}`, () => {
|
||||||
|
paintRow(line, rgb);
|
||||||
|
updateLiveInfo();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function onImage(msg) {
|
||||||
|
const image = { ...msg };
|
||||||
|
image._tsMs = typeof msg.ts_ms === "number" ? msg.ts_ms : Date.now();
|
||||||
|
image._ts = new Date(image._tsMs).toLocaleTimeString();
|
||||||
|
sstvHistory.push(image);
|
||||||
|
if (sstvHistory.length > SSTV_MAX_IMAGES) sstvHistory.shift();
|
||||||
|
pruneHistory();
|
||||||
|
if (!isActiveRigDecode(image.rig_id)) return;
|
||||||
|
if (sstvDom.status) {
|
||||||
|
sstvDom.status.textContent = image.complete ? `Received ${image.mode ?? "picture"}` : `Partial ${image.mode ?? "picture"} — ${image.lines ?? 0} lines`;
|
||||||
|
}
|
||||||
|
scheduleUi("sstv-latest", renderLatestCard);
|
||||||
|
if (activeView === "history") scheduleUi("sstv-history", renderHistoryTable);
|
||||||
|
}
|
||||||
|
function sstvRigImages() {
|
||||||
|
return forActiveRig(sstvHistory);
|
||||||
|
}
|
||||||
|
function renderLatestCard() {
|
||||||
|
if (!sstvDom.liveLatest) return;
|
||||||
|
const rigImages = sstvRigImages();
|
||||||
|
const latest = rigImages[rigImages.length - 1];
|
||||||
|
if (!latest) {
|
||||||
|
sstvDom.liveLatest.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = imageUrl(latest);
|
||||||
|
const lines = `${latest.lines ?? 0}${latest.height ? ` of ${latest.height}` : ""} lines`;
|
||||||
|
const state = latest.complete ? "complete" : "partial";
|
||||||
|
sstvDom.liveLatest.innerHTML = `
|
||||||
|
<div class="sat-latest-card">
|
||||||
|
<div style="display:flex; align-items:baseline; gap:0.5rem; flex-wrap:wrap;">
|
||||||
|
<strong>${escapeHtml(latest.mode ?? "SSTV")}</strong>
|
||||||
|
<small style="color:var(--text-muted);">${escapeHtml(latest._ts ?? "")} · ${lines} · ${state}</small>
|
||||||
|
</div>
|
||||||
|
${url ? `<a href="${escapeHtml(url)}" target="_blank" rel="noopener">
|
||||||
|
<img src="${escapeHtml(url)}" alt="Received ${escapeHtml(latest.mode ?? "SSTV")} picture"
|
||||||
|
style="margin-top:0.4rem; width:100%; max-width:640px; image-rendering:pixelated;" />
|
||||||
|
</a>` : ""}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
function filteredHistory() {
|
||||||
|
const rigImages = sstvRigImages();
|
||||||
|
const text = filterText.trim().toLowerCase();
|
||||||
|
const matching = text ? rigImages.filter((image) => (image.mode ?? "").toLowerCase().includes(text)) : rigImages;
|
||||||
|
const newestFirst = (sstvDom.sortSelect?.value ?? "newest") === "newest";
|
||||||
|
matching.sort((a, b) => (newestFirst ? 1 : -1) * ((b._tsMs ?? 0) - (a._tsMs ?? 0)));
|
||||||
|
return matching;
|
||||||
|
}
|
||||||
|
function renderHistoryTable() {
|
||||||
|
if (!sstvDom.historyList) return;
|
||||||
|
pruneHistory();
|
||||||
|
const rows = filteredHistory();
|
||||||
|
sstvDom.historyList.innerHTML = rows.map((image) => {
|
||||||
|
const url = imageUrl(image);
|
||||||
|
const size = image.width && image.height ? `${image.width}×${image.height}` : "--";
|
||||||
|
const lines = image.complete ? String(image.lines ?? 0) : `${image.lines ?? 0} (partial)`;
|
||||||
|
return `<div class="sat-history-row">
|
||||||
|
<span class="sat-col-time">${escapeHtml(image._ts ?? "")}</span>
|
||||||
|
<span class="sat-col-type">${escapeHtml(image.mode ?? "--")}</span>
|
||||||
|
<span class="sat-col-sat">${escapeHtml(size)}</span>
|
||||||
|
<span class="sat-col-lines">${escapeHtml(lines)}</span>
|
||||||
|
<span class="sat-col-link">${url ? `<a href="${escapeHtml(url)}" target="_blank" rel="noopener">View</a>` : "--"}</span>
|
||||||
|
</div>`;
|
||||||
|
}).join("");
|
||||||
|
if (sstvDom.historyCount) {
|
||||||
|
sstvDom.historyCount.textContent = rows.length ? `${rows.length} picture${rows.length === 1 ? "" : "s"}` : "No pictures yet";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sstvDom.filterInput?.addEventListener("input", () => {
|
||||||
|
filterText = sstvDom.filterInput?.value ?? "";
|
||||||
|
renderHistoryTable();
|
||||||
|
});
|
||||||
|
sstvDom.sortSelect?.addEventListener("change", () => {
|
||||||
|
renderHistoryTable();
|
||||||
|
});
|
||||||
|
function restoreHistory(entries) {
|
||||||
|
if (!Array.isArray(entries)) return;
|
||||||
|
for (const entry of entries) onImage(entry);
|
||||||
|
}
|
||||||
|
function resetHistoryView() {
|
||||||
|
sstvHistory = [];
|
||||||
|
liveRows = 0;
|
||||||
|
liveMode = "";
|
||||||
|
if (sstvDom.liveContainer) sstvDom.liveContainer.style.display = "none";
|
||||||
|
if (sstvDom.status) sstvDom.status.textContent = "Idle";
|
||||||
|
renderLatestCard();
|
||||||
|
renderHistoryTable();
|
||||||
|
}
|
||||||
|
sstvWindow.syncSstvToggle = function syncSstvToggle(enabled) {
|
||||||
|
const button = sstvDom.toggleBtn;
|
||||||
|
if (!button) return;
|
||||||
|
button.textContent = enabled ? "Disable SSTV" : "Enable SSTV";
|
||||||
|
button.setAttribute("aria-pressed", String(enabled));
|
||||||
|
button.classList.toggle("is-active", enabled);
|
||||||
|
};
|
||||||
|
sstvDom.toggleBtn?.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
if (sstvDom.toggleBtn) {
|
||||||
|
await sstvWindow.takeSchedulerControlForDecoderDisable?.(sstvDom.toggleBtn);
|
||||||
|
}
|
||||||
|
await hostCore.postPath("/toggle_sstv_decode");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("SSTV toggle failed", e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
sstvDom.clearBtn?.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await hostCore.postPath("/clear_sstv_decode");
|
||||||
|
resetHistoryView();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("SSTV clear failed", e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
renderLatestCard();
|
||||||
|
sstvWindow.trxPluginRuntime.registerDecoder({
|
||||||
|
id: "sstv",
|
||||||
|
onMessage: onImage,
|
||||||
|
restore: restoreHistory,
|
||||||
|
prune: renderHistoryTable,
|
||||||
|
reset: resetHistoryView,
|
||||||
|
rerender: () => {
|
||||||
|
renderLatestCard();
|
||||||
|
renderHistoryTable();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
sstvWindow.trxPluginRuntime.registerDecoder({
|
||||||
|
id: "sstv_progress",
|
||||||
|
onMessage: onProgress
|
||||||
|
});
|
||||||
@@ -0,0 +1,454 @@
|
|||||||
|
import {
|
||||||
|
hostCore,
|
||||||
|
hostState
|
||||||
|
} from "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
|
// src/plugins/vchan.ts
|
||||||
|
var vchanWindow = window;
|
||||||
|
var vchanSessionId = null;
|
||||||
|
var vchanRigId = null;
|
||||||
|
var vchanChannels = [];
|
||||||
|
var vchanActiveId = null;
|
||||||
|
var schedulerReleaseState = null;
|
||||||
|
var schedulerReleasePollTimer = null;
|
||||||
|
function vchanFmtFreq(hz) {
|
||||||
|
if (!Number.isFinite(hz) || hz <= 0) return "--";
|
||||||
|
if (hz >= 1e9) return (hz / 1e9).toFixed(4).replace(/\.?0+$/, "") + " GHz";
|
||||||
|
if (hz >= 1e6) return (hz / 1e6).toFixed(4).replace(/\.?0+$/, "") + " MHz";
|
||||||
|
if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + " kHz";
|
||||||
|
return `${String(hz)} Hz`;
|
||||||
|
}
|
||||||
|
function schedulerReleaseSummaryText(state) {
|
||||||
|
if (!state) return "Scheduler is controlling the rig.";
|
||||||
|
const connected = Number(state.connected_sessions) || 0;
|
||||||
|
const released = Number(state.released_sessions) || 0;
|
||||||
|
if (connected === 0) return "Scheduler can control the rig.";
|
||||||
|
if (state.all_released) {
|
||||||
|
return connected === 1 ? "Scheduler is controlling the rig." : `Scheduler is controlling the rig for all ${connected} users.`;
|
||||||
|
}
|
||||||
|
if (!state.current_session_released) {
|
||||||
|
const othersReleased = Math.max(released, 0);
|
||||||
|
return othersReleased > 0 ? `You are holding control. ${othersReleased} other user${othersReleased === 1 ? "" : "s"} already released it.` : "You are holding control. Release it to return control to the scheduler.";
|
||||||
|
}
|
||||||
|
const blocking = Math.max(connected - released, 0);
|
||||||
|
return blocking > 0 ? `Scheduler is waiting for ${blocking} user${blocking === 1 ? "" : "s"} to stop manual tuning.` : "Scheduler can control the rig.";
|
||||||
|
}
|
||||||
|
function vchanRenderSchedulerRelease() {
|
||||||
|
const btn = document.getElementById("scheduler-release-btn");
|
||||||
|
const status = document.getElementById("scheduler-release-status");
|
||||||
|
if (!btn || !status) return;
|
||||||
|
const currentReleased = !!(schedulerReleaseState && schedulerReleaseState.current_session_released);
|
||||||
|
btn.disabled = !vchanSessionId || currentReleased;
|
||||||
|
btn.classList.toggle("active", !currentReleased);
|
||||||
|
btn.textContent = "Release to Scheduler";
|
||||||
|
status.textContent = schedulerReleaseSummaryText(schedulerReleaseState);
|
||||||
|
}
|
||||||
|
async function vchanPollSchedulerRelease() {
|
||||||
|
if (!vchanSessionId) {
|
||||||
|
schedulerReleaseState = null;
|
||||||
|
vchanRenderSchedulerRelease();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/scheduler-control?session_id=${encodeURIComponent(vchanSessionId)}`);
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
|
schedulerReleaseState = await resp.json();
|
||||||
|
vchanRenderSchedulerRelease();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("scheduler release status failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function vchanStartSchedulerReleasePolling() {
|
||||||
|
if (schedulerReleasePollTimer) {
|
||||||
|
clearInterval(schedulerReleasePollTimer);
|
||||||
|
}
|
||||||
|
schedulerReleasePollTimer = setInterval(() => {
|
||||||
|
void vchanPollSchedulerRelease();
|
||||||
|
}, 1e4);
|
||||||
|
}
|
||||||
|
async function vchanToggleSchedulerRelease() {
|
||||||
|
if (!vchanSessionId) return;
|
||||||
|
const rigId = vchanRigId || hostState.lastActiveRigId || null;
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/scheduler-control", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ session_id: vchanSessionId, released: true, remote: rigId })
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
|
schedulerReleaseState = await resp.json();
|
||||||
|
vchanRenderSchedulerRelease();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("scheduler release toggle failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function vchanTakeSchedulerControl() {
|
||||||
|
if (!vchanSessionId) return;
|
||||||
|
if (schedulerReleaseState && !schedulerReleaseState.current_session_released) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/scheduler-control", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ session_id: vchanSessionId, released: false })
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
|
schedulerReleaseState = await resp.json();
|
||||||
|
vchanRenderSchedulerRelease();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("scheduler control takeover failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function vchanHandleSession(data) {
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(data);
|
||||||
|
vchanSessionId = d.session_id || null;
|
||||||
|
void vchanPollSchedulerRelease();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("vchan: bad session event", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function vchanHandleChannels(data) {
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(data);
|
||||||
|
vchanRigId = d.remote || null;
|
||||||
|
vchanChannels = d.channels || [];
|
||||||
|
const ids = new Set(vchanChannels.map((c) => c.id));
|
||||||
|
const primaryChannel = vchanChannels[0];
|
||||||
|
if (!vchanActiveId && primaryChannel && vchanSessionId) {
|
||||||
|
void vchanAutoJoinPrimary(primaryChannel.id);
|
||||||
|
} else if (vchanActiveId && !ids.has(vchanActiveId)) {
|
||||||
|
vchanActiveId = vchanChannels[0]?.id ?? null;
|
||||||
|
vchanReconnectAudio();
|
||||||
|
}
|
||||||
|
vchanRender();
|
||||||
|
vchanRenderSchedulerRelease();
|
||||||
|
vchanWindow.renderRdsOverlays?.();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("vchan: bad channels event", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function vchanRender() {
|
||||||
|
const picker = document.getElementById("vchan-picker");
|
||||||
|
if (!picker) return;
|
||||||
|
picker.innerHTML = "";
|
||||||
|
vchanChannels.forEach((ch) => {
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.type = "button";
|
||||||
|
btn.title = `Ch ${ch.index}: ${vchanFmtFreq(ch.freq_hz)} ${ch.mode} · ${ch.subscribers} subscriber${ch.subscribers !== 1 ? "s" : ""}`;
|
||||||
|
if (ch.id === vchanActiveId) btn.classList.add("active");
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.className = "vchan-label";
|
||||||
|
label.textContent = `${ch.index}: ${vchanFmtFreq(ch.freq_hz)} ${ch.mode}`;
|
||||||
|
btn.appendChild(label);
|
||||||
|
if (!ch.permanent) {
|
||||||
|
const del = document.createElement("span");
|
||||||
|
del.className = "vchan-del";
|
||||||
|
del.textContent = "×";
|
||||||
|
del.title = "Delete channel";
|
||||||
|
del.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void vchanDelete(ch.id);
|
||||||
|
});
|
||||||
|
btn.appendChild(del);
|
||||||
|
}
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
if (ch.id !== vchanActiveId) void vchanSubscribe(ch.id);
|
||||||
|
});
|
||||||
|
picker.appendChild(btn);
|
||||||
|
});
|
||||||
|
const addBtn = document.createElement("button");
|
||||||
|
addBtn.type = "button";
|
||||||
|
addBtn.className = "vchan-add";
|
||||||
|
addBtn.textContent = "+";
|
||||||
|
addBtn.title = "Allocate new virtual channel at current frequency";
|
||||||
|
addBtn.addEventListener("click", () => {
|
||||||
|
void vchanAllocate();
|
||||||
|
});
|
||||||
|
picker.appendChild(addBtn);
|
||||||
|
vchanSyncAccentUI();
|
||||||
|
hostCore.updateDocumentTitle(hostCore.activeChannelRds());
|
||||||
|
vchanRenderSchedulerRelease();
|
||||||
|
}
|
||||||
|
async function vchanAllocate() {
|
||||||
|
if (!vchanSessionId || !vchanRigId) return;
|
||||||
|
const freqHz = typeof hostState.lastFreqHz === "number" && hostState.lastFreqHz > 0 ? hostState.lastFreqHz : 0;
|
||||||
|
const modeEl = document.getElementById("mode");
|
||||||
|
const mode = modeEl ? modeEl.value || "USB" : "USB";
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/channels/${encodeURIComponent(vchanRigId)}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ session_id: vchanSessionId, freq_hz: freqHz, mode })
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const msg = await resp.text().catch(() => String(resp.status));
|
||||||
|
console.warn("vchan: allocate failed —", msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ch = await resp.json();
|
||||||
|
vchanActiveId = ch.id;
|
||||||
|
vchanRender();
|
||||||
|
vchanReconnectAudio();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("vchan: allocate error", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function vchanDelete(channelId) {
|
||||||
|
if (!vchanRigId) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(
|
||||||
|
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}`,
|
||||||
|
{ method: "DELETE" }
|
||||||
|
);
|
||||||
|
if (!resp.ok) {
|
||||||
|
console.warn("vchan: delete failed", resp.status);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("vchan: delete error", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function vchanAutoJoinPrimary(channelId) {
|
||||||
|
if (!vchanSessionId || !vchanRigId) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(
|
||||||
|
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}/subscribe`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ session_id: vchanSessionId })
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!resp.ok) {
|
||||||
|
console.warn("vchan: auto-join primary failed", resp.status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
vchanActiveId = channelId;
|
||||||
|
vchanRender();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("vchan: auto-join error", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function vchanSubscribe(channelId) {
|
||||||
|
if (!vchanSessionId || !vchanRigId) return;
|
||||||
|
try {
|
||||||
|
await vchanTakeSchedulerControl();
|
||||||
|
const resp = await fetch(
|
||||||
|
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}/subscribe`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ session_id: vchanSessionId })
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!resp.ok) {
|
||||||
|
console.warn("vchan: subscribe failed", resp.status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
vchanActiveId = channelId;
|
||||||
|
vchanRender();
|
||||||
|
vchanSyncModeDisplay();
|
||||||
|
vchanReconnectAudio();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("vchan: subscribe error", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function vchanReconnectAudio() {
|
||||||
|
const ch = vchanIsOnVirtual() ? vchanActiveChannel() : null;
|
||||||
|
hostState.audioChannelOverride = ch?.id ?? null;
|
||||||
|
if (!hostState.rxActive) return;
|
||||||
|
hostCore.stopRxAudio();
|
||||||
|
setTimeout(() => {
|
||||||
|
hostCore.startRxAudio();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
function vchanApplyCapabilities(caps) {
|
||||||
|
const picker = document.getElementById("vchan-picker");
|
||||||
|
if (!picker) return;
|
||||||
|
picker.style.display = caps && caps.filter_controls ? "" : "none";
|
||||||
|
vchanRenderSchedulerRelease();
|
||||||
|
}
|
||||||
|
function vchanIsOnVirtual() {
|
||||||
|
if (!vchanActiveId || vchanChannels.length === 0) return false;
|
||||||
|
return vchanActiveId !== vchanChannels[0]?.id;
|
||||||
|
}
|
||||||
|
function vchanActiveChannel() {
|
||||||
|
return vchanChannels.find((c) => c.id === vchanActiveId) || null;
|
||||||
|
}
|
||||||
|
function vchanUpdateFreqDisplay() {
|
||||||
|
const ch = vchanActiveChannel();
|
||||||
|
if (!ch) return;
|
||||||
|
const el = document.getElementById("freq");
|
||||||
|
if (!el) return;
|
||||||
|
el.value = hostCore.formatFreqForStep(ch.freq_hz, hostState.jogUnit);
|
||||||
|
}
|
||||||
|
function vchanSyncModeDisplay() {
|
||||||
|
const modeEl = document.getElementById("mode");
|
||||||
|
if (!modeEl) return;
|
||||||
|
if (vchanIsOnVirtual()) {
|
||||||
|
const ch = vchanActiveChannel();
|
||||||
|
if (ch && ch.mode) {
|
||||||
|
modeEl.value = ch.mode.toUpperCase();
|
||||||
|
hostCore.syncModePicker();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const modeUpper = (modeEl.value || "").toUpperCase();
|
||||||
|
if (typeof hostState.lastModeName === "string") {
|
||||||
|
if (modeUpper === "WFM" && hostState.lastModeName !== "WFM") {
|
||||||
|
hostCore.setJogDivisor(10);
|
||||||
|
hostCore.resetRdsDisplay();
|
||||||
|
} else if (modeUpper !== "WFM" && hostState.lastModeName === "WFM") {
|
||||||
|
hostCore.resetRdsDisplay();
|
||||||
|
}
|
||||||
|
hostState.lastModeName = modeUpper;
|
||||||
|
}
|
||||||
|
hostCore.updateWfmControls();
|
||||||
|
hostCore.updateSdrSquelchControlVisibility();
|
||||||
|
if (vchanWindow.refreshRdsUi) {
|
||||||
|
vchanWindow.refreshRdsUi();
|
||||||
|
} else {
|
||||||
|
hostCore.positionRdsPsOverlay();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function vchanSyncBwDisplay() {
|
||||||
|
if (!vchanIsOnVirtual()) return;
|
||||||
|
const ch = vchanActiveChannel();
|
||||||
|
if (!ch) return;
|
||||||
|
const bwEl = document.getElementById("spectrum-bw-input");
|
||||||
|
if (!bwEl) return;
|
||||||
|
let bwHz = ch.bandwidth_hz || 0;
|
||||||
|
if (bwHz === 0) {
|
||||||
|
bwHz = hostCore.mwDefaultsForMode(ch.mode)[0] || 0;
|
||||||
|
}
|
||||||
|
if (bwHz > 0) {
|
||||||
|
bwEl.value = (bwHz / 1e3).toFixed(3).replace(/\.?0+$/, "");
|
||||||
|
hostState.currentBandwidthHz = bwHz;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function vchanSyncAccentUI() {
|
||||||
|
const onVirtual = vchanIsOnVirtual();
|
||||||
|
const freqEl = document.getElementById("freq");
|
||||||
|
const bwEl = document.getElementById("spectrum-bw-input");
|
||||||
|
if (freqEl) freqEl.classList.toggle("vchan-ch-active", onVirtual);
|
||||||
|
if (bwEl) bwEl.classList.toggle("vchan-ch-active", onVirtual);
|
||||||
|
if (onVirtual) {
|
||||||
|
vchanUpdateFreqDisplay();
|
||||||
|
vchanSyncModeDisplay();
|
||||||
|
vchanSyncBwDisplay();
|
||||||
|
} else {
|
||||||
|
hostCore.refreshFreqDisplay();
|
||||||
|
}
|
||||||
|
hostCore.updateDocumentTitle(hostCore.activeChannelRds());
|
||||||
|
}
|
||||||
|
function vchanSetChannelFreq(freqHz) {
|
||||||
|
if (!vchanRigId || !vchanActiveId) return;
|
||||||
|
if (hostState.lastSpectrumData && hostState.lastSpectrumData.sample_rate > 0) {
|
||||||
|
const halfSpan = hostState.lastSpectrumData.sample_rate / 2;
|
||||||
|
const center = hostState.lastSpectrumData.center_hz;
|
||||||
|
if (Math.abs(freqHz - center) > halfSpan) {
|
||||||
|
hostCore.showHint(
|
||||||
|
`Out of SDR bandwidth (center ${(center / 1e6).toFixed(3)} MHz ±${(halfSpan / 1e3).toFixed(0)} kHz)`,
|
||||||
|
3e3
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void vchanTakeSchedulerControl();
|
||||||
|
void fetch(
|
||||||
|
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/freq`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ freq_hz: Math.round(freqHz) })
|
||||||
|
}
|
||||||
|
).catch((error) => {
|
||||||
|
console.error("vchan: set freq error", error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function vchanSetChannelBandwidth(bwHz) {
|
||||||
|
if (!vchanRigId || !vchanActiveId) return;
|
||||||
|
try {
|
||||||
|
await vchanTakeSchedulerControl();
|
||||||
|
const resp = await fetch(
|
||||||
|
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/bw`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ bandwidth_hz: Math.round(bwHz) })
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!resp.ok) console.warn("vchan: set bw failed", resp.status);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("vchan: set bw error", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function vchanSetChannelMode(mode) {
|
||||||
|
if (!vchanRigId || !vchanActiveId) return;
|
||||||
|
try {
|
||||||
|
await vchanTakeSchedulerControl();
|
||||||
|
const resp = await fetch(
|
||||||
|
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/mode`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ mode })
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!resp.ok) console.warn("vchan: set mode failed", resp.status);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("vchan: set mode error", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function vchanInterceptMode(mode) {
|
||||||
|
if (!vchanIsOnVirtual()) return false;
|
||||||
|
await vchanSetChannelMode(mode);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
async function vchanInterceptBandwidth(bwHz) {
|
||||||
|
if (!vchanIsOnVirtual()) return false;
|
||||||
|
await vchanSetChannelBandwidth(bwHz);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function vchanInterceptFrequency(freqHz) {
|
||||||
|
if (!vchanIsOnVirtual()) return false;
|
||||||
|
const targetHz = Math.round(freqHz);
|
||||||
|
hostCore.armOptimisticFrequency(targetHz);
|
||||||
|
hostCore.applyLocalTunedFrequency(targetHz);
|
||||||
|
vchanSetChannelFreq(freqHz);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function vchanInterceptFreqDisplay() {
|
||||||
|
if (!vchanIsOnVirtual()) return false;
|
||||||
|
vchanUpdateFreqDisplay();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
vchanWindow.trx ??= {};
|
||||||
|
vchanWindow.trx.modules ??= {};
|
||||||
|
vchanWindow.trx.modules.vchan = {
|
||||||
|
get channels() {
|
||||||
|
return vchanChannels;
|
||||||
|
},
|
||||||
|
get activeId() {
|
||||||
|
return vchanActiveId;
|
||||||
|
},
|
||||||
|
activeChannel: vchanActiveChannel,
|
||||||
|
applyCapabilities: vchanApplyCapabilities,
|
||||||
|
handleSession: vchanHandleSession,
|
||||||
|
handleChannels: vchanHandleChannels,
|
||||||
|
isOnVirtual: vchanIsOnVirtual,
|
||||||
|
interceptMode: vchanInterceptMode,
|
||||||
|
interceptBandwidth: vchanInterceptBandwidth,
|
||||||
|
interceptFrequency: vchanInterceptFrequency,
|
||||||
|
interceptFreqDisplay: vchanInterceptFreqDisplay,
|
||||||
|
takeSchedulerControl: vchanTakeSchedulerControl,
|
||||||
|
releaseToScheduler: vchanToggleSchedulerRelease
|
||||||
|
};
|
||||||
|
(function initSchedulerReleaseControl() {
|
||||||
|
const btn = document.getElementById("scheduler-release-btn");
|
||||||
|
if (btn) {
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
void vchanToggleSchedulerRelease();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
vchanStartSchedulerReleasePolling();
|
||||||
|
vchanRenderSchedulerRelease();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import {
|
||||||
|
forActiveRig,
|
||||||
|
isActiveRigDecode
|
||||||
|
} from "./chunk-S57W63QN.js";
|
||||||
|
import {
|
||||||
|
hostCore
|
||||||
|
} from "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
|
// src/plugins/vdes.ts
|
||||||
|
var vdesWindow = window;
|
||||||
|
var escapeVdesHtml = (input) => hostCore.escapeMapHtml(input);
|
||||||
|
var vdesStatus = document.getElementById("vdes-status");
|
||||||
|
var vdesMessagesEl = document.getElementById("vdes-messages");
|
||||||
|
var vdesFilterInput = document.getElementById("vdes-filter");
|
||||||
|
var vdesBarOverlay = document.getElementById("vdes-bar-overlay");
|
||||||
|
var vdesChannelSummaryEl = document.getElementById("vdes-channel-summary");
|
||||||
|
var vdesFrameCountEl = document.getElementById("vdes-frame-count");
|
||||||
|
var vdesLatestSeenEl = document.getElementById("vdes-latest-seen");
|
||||||
|
var VDES_BAR_WINDOW_MS = 15 * 60 * 1e3;
|
||||||
|
var vdesFilterText = "";
|
||||||
|
var vdesMessageHistory = [];
|
||||||
|
function currentVdesHistoryRetentionMs() {
|
||||||
|
return typeof vdesWindow.getDecodeHistoryRetentionMs === "function" ? vdesWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3;
|
||||||
|
}
|
||||||
|
function pruneVdesMessageHistory() {
|
||||||
|
const cutoffMs = Date.now() - currentVdesHistoryRetentionMs();
|
||||||
|
vdesMessageHistory = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs);
|
||||||
|
}
|
||||||
|
function scheduleVdesUi(key, job) {
|
||||||
|
if (typeof vdesWindow.trxScheduleUiFrameJob === "function") {
|
||||||
|
vdesWindow.trxScheduleUiFrameJob(key, job);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
job();
|
||||||
|
}
|
||||||
|
function scheduleVdesHistoryRender() {
|
||||||
|
scheduleVdesUi("vdes-history", () => {
|
||||||
|
renderVdesHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function scheduleVdesBarUpdate() {
|
||||||
|
scheduleVdesUi("vdes-bar", () => {
|
||||||
|
updateVdesBar();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function currentVdesCenterText() {
|
||||||
|
const raw = (document.getElementById("freq")?.value || "").replace(/[^\d]/g, "");
|
||||||
|
const hz = raw ? Number(raw) : 0;
|
||||||
|
if (!Number.isFinite(hz) || hz <= 0) return "100 kHz centered on tuned frequency";
|
||||||
|
return `100 kHz @ ${(hz / 1e6).toFixed(3)} MHz`;
|
||||||
|
}
|
||||||
|
function vdesAgeText(tsMs) {
|
||||||
|
if (typeof tsMs !== "number" || !Number.isFinite(tsMs)) return "just now";
|
||||||
|
const deltaMs = Math.max(0, Date.now() - tsMs);
|
||||||
|
const seconds = Math.round(deltaMs / 1e3);
|
||||||
|
if (seconds < 5) return "just now";
|
||||||
|
if (seconds < 60) return `${seconds}s ago`;
|
||||||
|
const minutes = Math.round(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.round(minutes / 60);
|
||||||
|
return `${hours}h ago`;
|
||||||
|
}
|
||||||
|
function vdesHexPreview(rawBytes) {
|
||||||
|
if (!Array.isArray(rawBytes) || rawBytes.length === 0) return "--";
|
||||||
|
return rawBytes.slice(0, 20).map((value) => value.toString(16).padStart(2, "0")).join(" ").toUpperCase();
|
||||||
|
}
|
||||||
|
function vdesRigMessages() {
|
||||||
|
return forActiveRig(vdesMessageHistory);
|
||||||
|
}
|
||||||
|
function updateVdesSummary() {
|
||||||
|
pruneVdesMessageHistory();
|
||||||
|
if (vdesChannelSummaryEl) {
|
||||||
|
vdesChannelSummaryEl.textContent = currentVdesCenterText();
|
||||||
|
}
|
||||||
|
const rigMessages = vdesRigMessages();
|
||||||
|
if (vdesFrameCountEl) {
|
||||||
|
const count = rigMessages.length;
|
||||||
|
vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`;
|
||||||
|
}
|
||||||
|
if (vdesLatestSeenEl) {
|
||||||
|
const latest = rigMessages[0];
|
||||||
|
vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function applyVdesFilterToRow(row) {
|
||||||
|
if (!vdesFilterText) {
|
||||||
|
row.style.display = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = row.dataset.filterText || "";
|
||||||
|
row.style.display = text.includes(vdesFilterText) ? "" : "none";
|
||||||
|
}
|
||||||
|
function renderVdesRow(msg) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "vdes-message";
|
||||||
|
const ts = msg._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit"
|
||||||
|
});
|
||||||
|
const title = msg.vessel_name || "VDES Burst";
|
||||||
|
const label = msg.callsign || "VDES";
|
||||||
|
const info = msg.destination || "";
|
||||||
|
const labelText = msg.message_label || "";
|
||||||
|
const linkText = Number.isFinite(msg.link_id) ? `LID ${msg.link_id}` : "";
|
||||||
|
const syncText = Number.isFinite(msg.sync_score) ? `Sync ${(Number(msg.sync_score) * 100).toFixed(0)}%` : "";
|
||||||
|
const phaseText = Number.isFinite(msg.phase_rotation) ? `R${Number(msg.phase_rotation)}` : "";
|
||||||
|
const fecText = msg.fec_state || "";
|
||||||
|
const srcText = Number.isFinite(msg.source_id) ? `SRC ${Number(msg.source_id)}` : "";
|
||||||
|
const dstText = Number.isFinite(msg.destination_id) ? `DST ${Number(msg.destination_id)}` : "";
|
||||||
|
const sessionText = Number.isFinite(msg.session_id) ? `S${Number(msg.session_id)}` : "";
|
||||||
|
const asmText = Number.isFinite(msg.asm_identifier) ? `ASM ${Number(msg.asm_identifier)}` : "";
|
||||||
|
const countText = Number.isFinite(msg.data_count) ? `${Number(msg.data_count)} data bits` : "";
|
||||||
|
const ackText = Number.isFinite(msg.ack_nack_mask) ? `ACK 0x${Number(msg.ack_nack_mask).toString(16).toUpperCase().padStart(4, "0")}` : "";
|
||||||
|
const cqiText = Number.isFinite(msg.channel_quality) ? `CQ ${Number(msg.channel_quality)}` : "";
|
||||||
|
const previewText = msg.payload_preview || "";
|
||||||
|
const rawHex = vdesHexPreview(msg.raw_bytes);
|
||||||
|
row.dataset.filterText = [
|
||||||
|
title,
|
||||||
|
label,
|
||||||
|
labelText,
|
||||||
|
info,
|
||||||
|
srcText,
|
||||||
|
dstText,
|
||||||
|
sessionText,
|
||||||
|
asmText,
|
||||||
|
countText,
|
||||||
|
ackText,
|
||||||
|
cqiText,
|
||||||
|
previewText,
|
||||||
|
linkText,
|
||||||
|
syncText,
|
||||||
|
phaseText,
|
||||||
|
fecText,
|
||||||
|
rawHex,
|
||||||
|
msg.message_type,
|
||||||
|
msg.bit_len
|
||||||
|
].filter(Boolean).join(" ").toUpperCase();
|
||||||
|
row.innerHTML = `<div class="vdes-row-head"><span class="vdes-time">${ts}</span><span class="vdes-call">${escapeVdesHtml(title)}</span><span class="vdes-badge">${escapeVdesHtml(label)}</span>` + (labelText ? `<span class="vdes-badge">${escapeVdesHtml(labelText)}</span>` : "") + (linkText ? `<span class="vdes-badge">${escapeVdesHtml(linkText)}</span>` : "") + (srcText ? `<span class="vdes-badge">${escapeVdesHtml(srcText)}</span>` : "") + (dstText ? `<span class="vdes-badge">${escapeVdesHtml(dstText)}</span>` : "") + (syncText ? `<span class="vdes-badge">${escapeVdesHtml(syncText)}</span>` : "") + (phaseText ? `<span class="vdes-badge">${escapeVdesHtml(phaseText)}</span>` : "") + `<span class="vdes-badge">T${escapeVdesHtml(String(msg.message_type ?? "--"))}</span></div><div class="vdes-row-meta"><span>${escapeVdesHtml(currentVdesCenterText())}</span><span>${escapeVdesHtml(`${msg.bit_len || 0} bits`)}</span>` + (sessionText ? `<span>${escapeVdesHtml(sessionText)}</span>` : "") + (asmText ? `<span>${escapeVdesHtml(asmText)}</span>` : "") + (countText ? `<span>${escapeVdesHtml(countText)}</span>` : "") + (ackText ? `<span>${escapeVdesHtml(ackText)}</span>` : "") + (cqiText ? `<span>${escapeVdesHtml(cqiText)}</span>` : "") + (info ? `<span>${escapeVdesHtml(info)}</span>` : "") + (fecText ? `<span>${escapeVdesHtml(fecText)}</span>` : "") + `<span>${escapeVdesHtml(vdesAgeText(msg._tsMs))}</span></div><div class="vdes-row-detail">` + (previewText ? `<span>${escapeVdesHtml(previewText)}</span>` : "") + (previewText ? `<span>·</span>` : "") + `<span class="vdes-raw">${escapeVdesHtml(rawHex)}</span></div>`;
|
||||||
|
applyVdesFilterToRow(row);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
function updateVdesBar() {
|
||||||
|
if (!vdesBarOverlay) return;
|
||||||
|
updateVdesSummary();
|
||||||
|
const isVdes = (document.getElementById("mode")?.value || "").toUpperCase() === "VDES";
|
||||||
|
const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS;
|
||||||
|
const messages = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id)).slice(0, 6);
|
||||||
|
if (!isVdes || messages.length === 0) {
|
||||||
|
vdesBarOverlay.style.display = "none";
|
||||||
|
vdesBarOverlay.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let html = `<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">VDES</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearVdesBar()" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();window.clearVdesBar();}" aria-label="Clear VDES overlay">Clear</span></span><span class="aprs-bar-window">Last 15 minutes</span></div>`;
|
||||||
|
for (const msg of messages) {
|
||||||
|
const ts = msg._ts ? `<span class="aprs-bar-time">${msg._ts}</span>` : "";
|
||||||
|
const label = escapeVdesHtml(msg.callsign || "VDES");
|
||||||
|
const title = escapeVdesHtml(msg.vessel_name || "Burst");
|
||||||
|
const detail = [
|
||||||
|
`${msg.bit_len || 0} bits`,
|
||||||
|
msg.message_label ? escapeVdesHtml(msg.message_label) : null,
|
||||||
|
Number.isFinite(msg.source_id) ? `src ${Number(msg.source_id)}` : null,
|
||||||
|
Number.isFinite(msg.destination_id) ? `dst ${Number(msg.destination_id)}` : null,
|
||||||
|
Number.isFinite(msg.link_id) ? `LID ${Number(msg.link_id)}` : null,
|
||||||
|
Number.isFinite(msg.asm_identifier) ? `ASM ${Number(msg.asm_identifier)}` : null,
|
||||||
|
Number.isFinite(msg.sync_score) ? `sync ${(Number(msg.sync_score) * 100).toFixed(0)}%` : null,
|
||||||
|
Number.isFinite(msg.phase_rotation) ? `rot ${Number(msg.phase_rotation)}` : null,
|
||||||
|
msg.destination ? escapeVdesHtml(msg.destination) : null,
|
||||||
|
escapeVdesHtml(vdesAgeText(msg._tsMs))
|
||||||
|
].filter(Boolean).join(" · ");
|
||||||
|
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}<span class="vdes-call">${title}</span> <span class="vdes-badge">${label}</span>: ${detail}</div></div>`;
|
||||||
|
}
|
||||||
|
vdesBarOverlay.innerHTML = html;
|
||||||
|
vdesBarOverlay.style.display = "flex";
|
||||||
|
}
|
||||||
|
vdesWindow.updateVdesBar = updateVdesBar;
|
||||||
|
vdesWindow.clearVdesBar = function() {
|
||||||
|
resetVdesHistoryView();
|
||||||
|
};
|
||||||
|
function resetVdesHistoryView() {
|
||||||
|
if (vdesMessagesEl) vdesMessagesEl.innerHTML = "";
|
||||||
|
vdesMessageHistory = [];
|
||||||
|
updateVdesBar();
|
||||||
|
renderVdesHistory();
|
||||||
|
}
|
||||||
|
function renderVdesHistory() {
|
||||||
|
pruneVdesMessageHistory();
|
||||||
|
if (!vdesMessagesEl) {
|
||||||
|
updateVdesSummary();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (const message of vdesRigMessages()) {
|
||||||
|
fragment.appendChild(renderVdesRow(message));
|
||||||
|
}
|
||||||
|
vdesMessagesEl.replaceChildren(fragment);
|
||||||
|
updateVdesSummary();
|
||||||
|
}
|
||||||
|
function addVdesMessage(msg) {
|
||||||
|
const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
||||||
|
msg._tsMs = tsMs;
|
||||||
|
msg._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit"
|
||||||
|
});
|
||||||
|
vdesMessageHistory.unshift(msg);
|
||||||
|
pruneVdesMessageHistory();
|
||||||
|
scheduleVdesBarUpdate();
|
||||||
|
scheduleVdesHistoryRender();
|
||||||
|
}
|
||||||
|
function normalizeServerVdesMessage(msg) {
|
||||||
|
return {
|
||||||
|
...msg,
|
||||||
|
rig_id: msg.rig_id || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function onServerVdesBatch(messages) {
|
||||||
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
|
const normalized = [];
|
||||||
|
for (const msg of messages) {
|
||||||
|
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();
|
||||||
|
next._tsMs = tsMs;
|
||||||
|
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit"
|
||||||
|
});
|
||||||
|
plotVdesMessage(next);
|
||||||
|
normalized.push(next);
|
||||||
|
}
|
||||||
|
normalized.reverse();
|
||||||
|
vdesMessageHistory = normalized.concat(vdesMessageHistory);
|
||||||
|
pruneVdesMessageHistory();
|
||||||
|
scheduleVdesBarUpdate();
|
||||||
|
scheduleVdesHistoryRender();
|
||||||
|
}
|
||||||
|
document.getElementById("settings-clear-vdes-history")?.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
if (!await vdesWindow.trxUi.confirm({ title: "Clear VDES history?", message: "All stored VDES decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
|
try {
|
||||||
|
await hostCore.postPath("/clear_vdes_decode");
|
||||||
|
resetVdesHistoryView();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("VDES history clear failed", e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
if (vdesFilterInput) {
|
||||||
|
vdesFilterInput.addEventListener("input", () => {
|
||||||
|
vdesFilterText = vdesFilterInput.value.trim().toUpperCase();
|
||||||
|
renderVdesHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function plotVdesMessage(msg) {
|
||||||
|
if (msg.lat == null || msg.lon == null || !vdesWindow.vdesMapAddPoint) return;
|
||||||
|
vdesWindow.vdesMapAddPoint(msg);
|
||||||
|
}
|
||||||
|
function onServerVdes(msg) {
|
||||||
|
const next = normalizeServerVdesMessage(msg);
|
||||||
|
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
|
||||||
|
addVdesMessage(next);
|
||||||
|
plotVdesMessage(next);
|
||||||
|
}
|
||||||
|
function pruneVdesHistoryView() {
|
||||||
|
pruneVdesMessageHistory();
|
||||||
|
updateVdesBar();
|
||||||
|
renderVdesHistory();
|
||||||
|
}
|
||||||
|
updateVdesSummary();
|
||||||
|
window.trxPluginRuntime.registerDecoder({
|
||||||
|
id: "vdes",
|
||||||
|
onMessage: onServerVdes,
|
||||||
|
onBatch: onServerVdesBatch,
|
||||||
|
restore: onServerVdesBatch,
|
||||||
|
reset: resetVdesHistoryView,
|
||||||
|
prune: pruneVdesHistoryView,
|
||||||
|
rerender: () => {
|
||||||
|
updateVdesBar();
|
||||||
|
renderVdesHistory();
|
||||||
|
},
|
||||||
|
// Oldest first, so tracks are rebuilt in the order they happened.
|
||||||
|
syncMap: () => {
|
||||||
|
for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
import {
|
||||||
|
forActiveRig,
|
||||||
|
isActiveRigDecode
|
||||||
|
} from "./chunk-S57W63QN.js";
|
||||||
|
import {
|
||||||
|
hostCore
|
||||||
|
} from "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
|
// src/plugins/wefax.ts
|
||||||
|
var wefaxWindow = window;
|
||||||
|
var wefaxDom = {
|
||||||
|
status: document.getElementById("wefax-status"),
|
||||||
|
liveView: document.getElementById("wefax-live-view"),
|
||||||
|
historyView: document.getElementById("wefax-history-view"),
|
||||||
|
liveContainer: document.getElementById("wefax-live-container"),
|
||||||
|
liveInfo: document.getElementById("wefax-live-info"),
|
||||||
|
liveCanvas: document.getElementById("wefax-live-canvas"),
|
||||||
|
liveLatest: document.getElementById("wefax-live-latest"),
|
||||||
|
historyList: document.getElementById("wefax-history-list"),
|
||||||
|
historyCount: document.getElementById("wefax-history-count"),
|
||||||
|
filterInput: document.getElementById("wefax-filter"),
|
||||||
|
sortSelect: document.getElementById("wefax-sort"),
|
||||||
|
toggleBtn: document.getElementById("wefax-decode-toggle-btn"),
|
||||||
|
clearBtn: document.getElementById("wefax-clear-btn"),
|
||||||
|
viewLiveBtn: document.getElementById("wefax-view-live"),
|
||||||
|
viewHistoryBtn: document.getElementById("wefax-view-history")
|
||||||
|
};
|
||||||
|
var wefaxImageHistory = [];
|
||||||
|
var WEFAX_MAX_IMAGES = 100;
|
||||||
|
var wefaxLiveCtx = null;
|
||||||
|
var wefaxLiveLineCount = 0;
|
||||||
|
var wefaxLivePixelsPerLine = 1809;
|
||||||
|
var wefaxActiveView = "live";
|
||||||
|
var wefaxFilterText = "";
|
||||||
|
function currentWefaxHistoryRetentionMs() {
|
||||||
|
return wefaxWindow.getDecodeHistoryRetentionMs?.() ?? 24 * 60 * 60 * 1e3;
|
||||||
|
}
|
||||||
|
function pruneWefaxHistory() {
|
||||||
|
const cutoff = Date.now() - currentWefaxHistoryRetentionMs();
|
||||||
|
wefaxImageHistory = wefaxImageHistory.filter(function(m) {
|
||||||
|
return (m._tsMs || 0) > cutoff;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
}
|
||||||
|
function scheduleWefaxUi(key, job) {
|
||||||
|
if (typeof wefaxWindow.trxScheduleUiFrameJob === "function") {
|
||||||
|
wefaxWindow.trxScheduleUiFrameJob(key, job);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
job();
|
||||||
|
}
|
||||||
|
function switchWefaxView(view) {
|
||||||
|
wefaxActiveView = view;
|
||||||
|
if (wefaxDom.liveView) wefaxDom.liveView.style.display = view === "live" ? "" : "none";
|
||||||
|
if (wefaxDom.historyView) wefaxDom.historyView.style.display = view === "history" ? "" : "none";
|
||||||
|
[wefaxDom.viewLiveBtn, wefaxDom.viewHistoryBtn].forEach(function(btn) {
|
||||||
|
if (btn) btn.classList.remove("sat-view-active");
|
||||||
|
});
|
||||||
|
if (view === "live" && wefaxDom.viewLiveBtn) wefaxDom.viewLiveBtn.classList.add("sat-view-active");
|
||||||
|
if (view === "history" && wefaxDom.viewHistoryBtn) wefaxDom.viewHistoryBtn.classList.add("sat-view-active");
|
||||||
|
if (view === "history") renderWefaxHistoryTable();
|
||||||
|
}
|
||||||
|
if (wefaxDom.viewLiveBtn) wefaxDom.viewLiveBtn.addEventListener("click", function() {
|
||||||
|
switchWefaxView("live");
|
||||||
|
});
|
||||||
|
if (wefaxDom.viewHistoryBtn) wefaxDom.viewHistoryBtn.addEventListener("click", function() {
|
||||||
|
switchWefaxView("history");
|
||||||
|
});
|
||||||
|
function resetLiveCanvas(pixelsPerLine) {
|
||||||
|
const canvas = wefaxDom.liveCanvas;
|
||||||
|
if (!canvas) return;
|
||||||
|
wefaxLivePixelsPerLine = pixelsPerLine;
|
||||||
|
wefaxLiveLineCount = 0;
|
||||||
|
canvas.width = pixelsPerLine;
|
||||||
|
canvas.height = 800;
|
||||||
|
wefaxLiveCtx = canvas.getContext("2d");
|
||||||
|
if (!wefaxLiveCtx) return;
|
||||||
|
wefaxLiveCtx.fillStyle = "#000";
|
||||||
|
wefaxLiveCtx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = "";
|
||||||
|
}
|
||||||
|
function paintLine(lineBytes) {
|
||||||
|
const canvas = wefaxDom.liveCanvas;
|
||||||
|
if (!wefaxLiveCtx || !canvas) return;
|
||||||
|
const y = wefaxLiveLineCount;
|
||||||
|
if (y >= canvas.height) {
|
||||||
|
const old = wefaxLiveCtx.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
|
canvas.height *= 2;
|
||||||
|
wefaxLiveCtx = canvas.getContext("2d");
|
||||||
|
if (!wefaxLiveCtx) return;
|
||||||
|
wefaxLiveCtx.putImageData(old, 0, 0);
|
||||||
|
}
|
||||||
|
const w = wefaxLivePixelsPerLine;
|
||||||
|
const imgData = wefaxLiveCtx.createImageData(w, 1);
|
||||||
|
const d = imgData.data;
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
|
const v = lineBytes[x] ?? 0;
|
||||||
|
const i = x * 4;
|
||||||
|
d[i] = v;
|
||||||
|
d[i + 1] = v;
|
||||||
|
d[i + 2] = v;
|
||||||
|
d[i + 3] = 255;
|
||||||
|
}
|
||||||
|
wefaxLiveCtx.putImageData(imgData, 0, y);
|
||||||
|
wefaxLiveLineCount++;
|
||||||
|
}
|
||||||
|
function wefaxRigImages() {
|
||||||
|
return forActiveRig(wefaxImageHistory);
|
||||||
|
}
|
||||||
|
function renderWefaxLatestCard() {
|
||||||
|
if (!wefaxDom.liveLatest) return;
|
||||||
|
const rigImages = wefaxRigImages();
|
||||||
|
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>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const img = rigImages[0];
|
||||||
|
if (!img) return;
|
||||||
|
const ts = img._ts || "--";
|
||||||
|
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : "";
|
||||||
|
const meta = [
|
||||||
|
`${String(img.ioc ?? "--")} IOC`,
|
||||||
|
`${String(img.lpm ?? "--")} LPM`,
|
||||||
|
`${String(img.line_count ?? 0)} lines`,
|
||||||
|
`${date} ${ts}`
|
||||||
|
].join(" · ");
|
||||||
|
const imgSrc = img._dataUrl ? img._dataUrl : img.path ? "/images/" + escapeHtml(img.path.split("/").pop()) : null;
|
||||||
|
let html = '<div class="sat-latest-card">';
|
||||||
|
html += '<div class="sat-latest-title">Latest decoded image</div>';
|
||||||
|
html += '<div class="sat-latest-meta">' + escapeHtml(meta) + "</div>";
|
||||||
|
if (imgSrc) {
|
||||||
|
html += '<a href="' + imgSrc + '" target="_blank" style="font-size:0.8rem;color:var(--accent);display:inline-block;margin-top:0.25rem;">View full image</a>';
|
||||||
|
}
|
||||||
|
html += "</div>";
|
||||||
|
wefaxDom.liveLatest.innerHTML = html;
|
||||||
|
}
|
||||||
|
function getWefaxFilteredHistory() {
|
||||||
|
let items = wefaxRigImages();
|
||||||
|
if (wefaxFilterText) {
|
||||||
|
items = items.filter(function(i) {
|
||||||
|
const haystack = [
|
||||||
|
String(i.ioc || ""),
|
||||||
|
String(i.lpm || ""),
|
||||||
|
String(i.line_count || "")
|
||||||
|
].join(" ").toUpperCase();
|
||||||
|
return haystack.indexOf(wefaxFilterText) >= 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const sortVal = wefaxDom.sortSelect ? wefaxDom.sortSelect.value : "newest";
|
||||||
|
if (sortVal === "oldest") items = items.slice().reverse();
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
function renderWefaxHistoryRow(img) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "sat-history-row";
|
||||||
|
const ts = img._ts || "--";
|
||||||
|
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString([], { month: "short", day: "numeric" }) : "";
|
||||||
|
const ioc = img.ioc || "--";
|
||||||
|
const lpm = img.lpm || "--";
|
||||||
|
const lines = img.line_count || 0;
|
||||||
|
const imgSrc = img._dataUrl ? img._dataUrl : img.path ? "/images/" + escapeHtml(img.path.split("/").pop()) : null;
|
||||||
|
const link = imgSrc ? '<a href="' + imgSrc + '" target="_blank" style="color:var(--accent);">View</a>' : "--";
|
||||||
|
row.innerHTML = [
|
||||||
|
"<span>" + escapeHtml(date + " " + ts) + "</span>",
|
||||||
|
"<span>" + escapeHtml(String(ioc)) + "</span>",
|
||||||
|
"<span>" + escapeHtml(String(lpm)) + "</span>",
|
||||||
|
`<span>${String(lines)}</span>`,
|
||||||
|
"<span>" + link + "</span>"
|
||||||
|
].join("");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
function renderWefaxHistoryTable() {
|
||||||
|
if (!wefaxDom.historyList) return;
|
||||||
|
pruneWefaxHistory();
|
||||||
|
const items = getWefaxFilteredHistory();
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (const item of items) {
|
||||||
|
fragment.appendChild(renderWefaxHistoryRow(item));
|
||||||
|
}
|
||||||
|
wefaxDom.historyList.replaceChildren(fragment);
|
||||||
|
if (wefaxDom.historyCount) {
|
||||||
|
const total = wefaxRigImages().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`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function addWefaxImage(msg) {
|
||||||
|
const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
||||||
|
msg._tsMs = tsMs;
|
||||||
|
msg._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit"
|
||||||
|
});
|
||||||
|
const canvas = wefaxDom.liveCanvas;
|
||||||
|
if (wefaxLiveCtx && canvas && wefaxLiveLineCount > 0) {
|
||||||
|
const trimmed = wefaxLiveCtx.getImageData(0, 0, canvas.width, wefaxLiveLineCount);
|
||||||
|
canvas.height = wefaxLiveLineCount;
|
||||||
|
wefaxLiveCtx = canvas.getContext("2d");
|
||||||
|
if (!wefaxLiveCtx) return;
|
||||||
|
wefaxLiveCtx.putImageData(trimmed, 0, 0);
|
||||||
|
try {
|
||||||
|
msg._dataUrl = canvas.toDataURL("image/png");
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wefaxImageHistory.unshift(msg);
|
||||||
|
if (wefaxImageHistory.length > WEFAX_MAX_IMAGES) {
|
||||||
|
wefaxImageHistory = wefaxImageHistory.slice(0, WEFAX_MAX_IMAGES);
|
||||||
|
}
|
||||||
|
scheduleWefaxUi("wefax-latest", renderWefaxLatestCard);
|
||||||
|
if (wefaxActiveView === "history") {
|
||||||
|
scheduleWefaxUi("wefax-history", renderWefaxHistoryTable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onServerWefaxProgress(msg) {
|
||||||
|
if (!isActiveRigDecode(msg.rig_id)) return;
|
||||||
|
if (msg.state && !msg.line_data) {
|
||||||
|
if (wefaxDom.status) {
|
||||||
|
wefaxDom.status.textContent = msg.state;
|
||||||
|
wefaxDom.status.style.color = msg.state.indexOf("Idle") === 0 ? "" : "var(--text-accent)";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((msg.line_count ?? 0) <= 1 || !wefaxLiveCtx) {
|
||||||
|
resetLiveCanvas(msg.pixels_per_line || 1809);
|
||||||
|
}
|
||||||
|
if (msg.line_data) {
|
||||||
|
const binary = atob(msg.line_data);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||||
|
paintLine(bytes);
|
||||||
|
}
|
||||||
|
if (wefaxDom.liveInfo) {
|
||||||
|
wefaxDom.liveInfo.textContent = `Line ${String(msg.line_count ?? 0)} · ${String(msg.ioc ?? "--")} IOC · ${String(msg.lpm ?? "--")} LPM`;
|
||||||
|
}
|
||||||
|
if (wefaxDom.status) {
|
||||||
|
wefaxDom.status.textContent = `Receiving — line ${String(msg.line_count ?? 0)}`;
|
||||||
|
wefaxDom.status.style.color = "var(--text-accent)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onServerWefax(msg) {
|
||||||
|
addWefaxImage(msg);
|
||||||
|
if (!isActiveRigDecode(msg.rig_id)) return;
|
||||||
|
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = "none";
|
||||||
|
if (wefaxDom.status) {
|
||||||
|
wefaxDom.status.textContent = `Complete — ${String(msg.line_count ?? 0)} lines`;
|
||||||
|
wefaxDom.status.style.color = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function restoreWefaxHistory(messages) {
|
||||||
|
if (!messages.length) return;
|
||||||
|
for (const message of messages) {
|
||||||
|
const tsMs = Number.isFinite(message.ts_ms) ? Number(message.ts_ms) : Date.now();
|
||||||
|
message._tsMs = tsMs;
|
||||||
|
message._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
wefaxImageHistory = messages.concat(wefaxImageHistory);
|
||||||
|
pruneWefaxHistory();
|
||||||
|
scheduleWefaxUi("wefax-latest", renderWefaxLatestCard);
|
||||||
|
if (wefaxActiveView === "history") {
|
||||||
|
scheduleWefaxUi("wefax-history", renderWefaxHistoryTable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function pruneWefaxHistoryView() {
|
||||||
|
pruneWefaxHistory();
|
||||||
|
renderWefaxHistoryTable();
|
||||||
|
renderWefaxLatestCard();
|
||||||
|
}
|
||||||
|
function resetWefaxHistoryView() {
|
||||||
|
wefaxImageHistory = [];
|
||||||
|
if (wefaxDom.historyList) wefaxDom.historyList.innerHTML = "";
|
||||||
|
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = "none";
|
||||||
|
wefaxLiveCtx = null;
|
||||||
|
wefaxLiveLineCount = 0;
|
||||||
|
renderWefaxLatestCard();
|
||||||
|
renderWefaxHistoryTable();
|
||||||
|
if (wefaxDom.status) {
|
||||||
|
wefaxDom.status.textContent = "Idle";
|
||||||
|
wefaxDom.status.style.color = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (wefaxDom.filterInput) {
|
||||||
|
const filterInput = wefaxDom.filterInput;
|
||||||
|
wefaxDom.filterInput.addEventListener("input", function() {
|
||||||
|
wefaxFilterText = filterInput.value.trim().toUpperCase();
|
||||||
|
scheduleWefaxUi("wefax-history", renderWefaxHistoryTable);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (wefaxDom.sortSelect) {
|
||||||
|
wefaxDom.sortSelect.addEventListener("change", function() {
|
||||||
|
scheduleWefaxUi("wefax-history", renderWefaxHistoryTable);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
wefaxWindow.syncWefaxToggle = function(enabled) {
|
||||||
|
if (!wefaxDom.toggleBtn) return;
|
||||||
|
wefaxDom.toggleBtn.dataset.enabled = enabled ? "true" : "false";
|
||||||
|
wefaxDom.toggleBtn.textContent = enabled ? "Disable WEFAX" : "Enable WEFAX";
|
||||||
|
wefaxDom.toggleBtn.style.borderColor = enabled ? "#00d17f" : "";
|
||||||
|
wefaxDom.toggleBtn.style.color = enabled ? "#00d17f" : "";
|
||||||
|
};
|
||||||
|
if (wefaxDom.toggleBtn) {
|
||||||
|
const toggleButton = wefaxDom.toggleBtn;
|
||||||
|
wefaxDom.toggleBtn.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
if (wefaxWindow.takeSchedulerControlForDecoderDisable) {
|
||||||
|
await wefaxWindow.takeSchedulerControlForDecoderDisable(toggleButton);
|
||||||
|
}
|
||||||
|
await hostCore.postPath("/toggle_wefax_decode");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("WEFAX toggle failed", e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (wefaxDom.clearBtn) {
|
||||||
|
wefaxDom.clearBtn.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await hostCore.postPath("/clear_wefax_decode");
|
||||||
|
resetWefaxHistoryView();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("WEFAX clear failed", e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
renderWefaxLatestCard();
|
||||||
|
wefaxWindow.trxPluginRuntime.registerDecoder({
|
||||||
|
id: "wefax",
|
||||||
|
onMessage: onServerWefax,
|
||||||
|
restore: restoreWefaxHistory,
|
||||||
|
prune: pruneWefaxHistoryView,
|
||||||
|
reset: resetWefaxHistoryView,
|
||||||
|
rerender: () => {
|
||||||
|
renderWefaxLatestCard();
|
||||||
|
renderWefaxHistoryTable();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
wefaxWindow.trxPluginRuntime.registerDecoder({
|
||||||
|
id: "wefax_progress",
|
||||||
|
onMessage: onServerWefaxProgress
|
||||||
|
});
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
import {
|
||||||
|
forActiveRig,
|
||||||
|
isActiveRigDecode
|
||||||
|
} from "./chunk-S57W63QN.js";
|
||||||
|
import {
|
||||||
|
hostCore
|
||||||
|
} from "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
|
// src/plugins/wspr.ts
|
||||||
|
var wsprWindow = window;
|
||||||
|
var wsprStatus = document.getElementById("wspr-status");
|
||||||
|
var wsprPeriodEl = document.getElementById("wspr-period");
|
||||||
|
var wsprMessagesEl = document.getElementById("wspr-messages");
|
||||||
|
var wsprFilterInput = document.getElementById("wspr-filter");
|
||||||
|
var WSPR_PERIOD_SECONDS = 120;
|
||||||
|
var wsprFilterText = "";
|
||||||
|
var wsprMessageHistory = [];
|
||||||
|
function finiteNumber(value) {
|
||||||
|
const number = typeof value === "number" ? value : Number(value);
|
||||||
|
return Number.isFinite(number) ? number : null;
|
||||||
|
}
|
||||||
|
function currentWsprHistoryRetentionMs() {
|
||||||
|
return typeof wsprWindow.getDecodeHistoryRetentionMs === "function" ? wsprWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3;
|
||||||
|
}
|
||||||
|
function pruneWsprMessageHistory() {
|
||||||
|
const cutoffMs = Date.now() - currentWsprHistoryRetentionMs();
|
||||||
|
wsprMessageHistory = wsprMessageHistory.filter((msg) => Number(msg._tsMs ?? msg.ts_ms) >= cutoffMs);
|
||||||
|
}
|
||||||
|
function scheduleWsprHistoryRender() {
|
||||||
|
if (typeof wsprWindow.trxScheduleUiFrameJob === "function") {
|
||||||
|
wsprWindow.trxScheduleUiFrameJob("wspr-history", () => {
|
||||||
|
renderWsprHistory();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderWsprHistory();
|
||||||
|
}
|
||||||
|
function fmtWsprTime(tsMs) {
|
||||||
|
if (!tsMs) return "--:--:--";
|
||||||
|
return new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
}
|
||||||
|
function updateWsprPeriodTimer() {
|
||||||
|
if (!wsprPeriodEl) return;
|
||||||
|
const nowSec = Math.floor(Date.now() / 1e3);
|
||||||
|
const remaining = WSPR_PERIOD_SECONDS - nowSec % WSPR_PERIOD_SECONDS;
|
||||||
|
const mm = String(Math.floor(remaining / 60)).padStart(2, "0");
|
||||||
|
const ss = String(remaining % 60).padStart(2, "0");
|
||||||
|
wsprPeriodEl.textContent = `Next slot ${mm}:${ss}`;
|
||||||
|
}
|
||||||
|
updateWsprPeriodTimer();
|
||||||
|
setInterval(updateWsprPeriodTimer, 500);
|
||||||
|
function renderWsprRow(msg) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "ft8-row";
|
||||||
|
row.dataset.decoder = "wspr";
|
||||||
|
const snr = finiteNumber(msg.snr_db);
|
||||||
|
const delta = finiteNumber(msg.dt_s);
|
||||||
|
const baseHz = finiteNumber(wsprWindow.ft8BaseHz);
|
||||||
|
const offsetHz = finiteNumber(msg.freq_hz);
|
||||||
|
const rfHz = offsetHz !== null && baseHz !== null ? baseHz + offsetHz : null;
|
||||||
|
const freq = rfHz?.toFixed(0) ?? "--";
|
||||||
|
const message = msg.message ?? "";
|
||||||
|
row.dataset.message = message.toUpperCase();
|
||||||
|
row.innerHTML = `<span class="ft8-time">${fmtWsprTime(msg.ts_ms)}</span><span class="ft8-snr">${snr?.toFixed(1) ?? "--"}</span><span class="ft8-dt">${delta?.toFixed(2) ?? "--"}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderWsprMessage(message)}</span>`;
|
||||||
|
applyWsprFilterToRow(row);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
function renderWsprHistory() {
|
||||||
|
pruneWsprMessageHistory();
|
||||||
|
if (!wsprMessagesEl) return;
|
||||||
|
const rigMessages = forActiveRig(wsprMessageHistory);
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (const message of rigMessages) {
|
||||||
|
fragment.appendChild(renderWsprRow(message));
|
||||||
|
}
|
||||||
|
wsprMessagesEl.replaceChildren(fragment);
|
||||||
|
}
|
||||||
|
function addWsprMessage(msg) {
|
||||||
|
msg._tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
||||||
|
wsprMessageHistory.unshift(msg);
|
||||||
|
pruneWsprMessageHistory();
|
||||||
|
scheduleWsprHistoryRender();
|
||||||
|
}
|
||||||
|
function normalizeServerWsprMessage(msg) {
|
||||||
|
const raw = msg.message ?? "";
|
||||||
|
const grids = extractAllGrids(raw);
|
||||||
|
const station = extractLikelyCallsign(raw);
|
||||||
|
const baseHz = finiteNumber(wsprWindow.ft8BaseHz);
|
||||||
|
const offsetHz = finiteNumber(msg.freq_hz);
|
||||||
|
const rfHz = offsetHz !== null && baseHz !== null ? baseHz + offsetHz : offsetHz;
|
||||||
|
return {
|
||||||
|
raw,
|
||||||
|
grids,
|
||||||
|
station,
|
||||||
|
rfHz,
|
||||||
|
history: {
|
||||||
|
rig_id: msg.rig_id ?? null,
|
||||||
|
_rfHz: rfHz,
|
||||||
|
receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null,
|
||||||
|
ts_ms: msg.ts_ms,
|
||||||
|
snr_db: msg.snr_db,
|
||||||
|
dt_s: msg.dt_s,
|
||||||
|
freq_hz: msg.freq_hz,
|
||||||
|
message: raw
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function onServerWsprBatch(messages) {
|
||||||
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
|
const normalized = [];
|
||||||
|
for (const msg of messages) {
|
||||||
|
const next = normalizeServerWsprMessage(msg);
|
||||||
|
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
|
||||||
|
plotWsprLocator(msg);
|
||||||
|
next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
||||||
|
normalized.push(next.history);
|
||||||
|
}
|
||||||
|
normalized.reverse();
|
||||||
|
wsprMessageHistory = normalized.concat(wsprMessageHistory);
|
||||||
|
pruneWsprMessageHistory();
|
||||||
|
scheduleWsprHistoryRender();
|
||||||
|
}
|
||||||
|
function pruneWsprHistoryView() {
|
||||||
|
pruneWsprMessageHistory();
|
||||||
|
renderWsprHistory();
|
||||||
|
}
|
||||||
|
function escapeWsprHtml(input) {
|
||||||
|
return input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
||||||
|
}
|
||||||
|
function renderWsprMessage(message) {
|
||||||
|
let out = "";
|
||||||
|
let i = 0;
|
||||||
|
while (i < message.length) {
|
||||||
|
const ch = message[i];
|
||||||
|
if (isAlphaNum(ch)) {
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < message.length && isAlphaNum(message[j])) j++;
|
||||||
|
const token = message.slice(i, j);
|
||||||
|
const grid = token.toUpperCase();
|
||||||
|
if (isMaidenheadGridToken(grid)) {
|
||||||
|
out += `<span class="ft8-locator" data-locator-grid="${grid}" role="button" tabindex="0" aria-label="Show locator ${grid} on map">${grid}</span>`;
|
||||||
|
} else {
|
||||||
|
out += escapeWsprHtml(token);
|
||||||
|
}
|
||||||
|
i = j;
|
||||||
|
} else {
|
||||||
|
out += escapeWsprHtml(ch ?? "");
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function extractAllGrids(message) {
|
||||||
|
const out = [];
|
||||||
|
const seen = /* @__PURE__ */ new Set();
|
||||||
|
const parts = message.toUpperCase().split(/[^A-Z0-9]+/);
|
||||||
|
for (const token of parts) {
|
||||||
|
if (!token) continue;
|
||||||
|
if (isMaidenheadGridToken(token) && !seen.has(token)) {
|
||||||
|
seen.add(token);
|
||||||
|
out.push(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function extractLikelyCallsign(message) {
|
||||||
|
const parts = message.toUpperCase().split(/[^A-Z0-9/]+/);
|
||||||
|
for (const token of parts) {
|
||||||
|
if (!token) continue;
|
||||||
|
if (token.length < 3 || token.length > 12) continue;
|
||||||
|
if (token === "CQ" || token === "DE" || token === "QRZ" || token === "DX") continue;
|
||||||
|
if (isMaidenheadGridToken(token)) continue;
|
||||||
|
if (/^[A-Z0-9/]{1,5}\d[A-Z0-9/]{1,6}$/.test(token)) return token;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function isFtxFarewellToken(token) {
|
||||||
|
const normalized = token.trim().toUpperCase();
|
||||||
|
return normalized === "RR73" || normalized === "73" || normalized === "RR";
|
||||||
|
}
|
||||||
|
function isMaidenheadGridToken(token) {
|
||||||
|
const normalized = token.trim().toUpperCase();
|
||||||
|
return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && !isFtxFarewellToken(normalized);
|
||||||
|
}
|
||||||
|
function isAlphaNum(ch) {
|
||||||
|
return ch !== void 0 && /[A-Za-z0-9]/.test(ch);
|
||||||
|
}
|
||||||
|
function activateWsprHistoryLocator(target) {
|
||||||
|
if (!(target instanceof Element)) return false;
|
||||||
|
const locatorEl = target.closest(".ft8-locator[data-locator-grid]");
|
||||||
|
if (!locatorEl) return false;
|
||||||
|
const grid = (locatorEl.dataset.locatorGrid || "").toUpperCase();
|
||||||
|
if (!grid) return false;
|
||||||
|
if (typeof wsprWindow.navigateToMapLocator === "function") {
|
||||||
|
wsprWindow.navigateToMapLocator(grid, "wspr");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function applyWsprFilterToRow(row) {
|
||||||
|
if (!wsprFilterText) {
|
||||||
|
row.style.display = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message = row.dataset.message || "";
|
||||||
|
row.style.display = message.includes(wsprFilterText) ? "" : "none";
|
||||||
|
}
|
||||||
|
function resetWsprHistoryView() {
|
||||||
|
if (wsprMessagesEl) wsprMessagesEl.innerHTML = "";
|
||||||
|
wsprMessageHistory = [];
|
||||||
|
renderWsprHistory();
|
||||||
|
if (wsprWindow.clearMapMarkersByType) wsprWindow.clearMapMarkersByType("wspr");
|
||||||
|
}
|
||||||
|
if (wsprFilterInput) {
|
||||||
|
wsprFilterInput.addEventListener("input", () => {
|
||||||
|
wsprFilterText = wsprFilterInput.value.trim().toUpperCase();
|
||||||
|
renderWsprHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (wsprMessagesEl) {
|
||||||
|
wsprMessagesEl.addEventListener("click", (event) => {
|
||||||
|
if (!activateWsprHistoryLocator(event.target)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
});
|
||||||
|
wsprMessagesEl.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key !== "Enter" && event.key !== " ") return;
|
||||||
|
if (!activateWsprHistoryLocator(event.target)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
var wsprDecodeToggleBtn = document.getElementById("wspr-decode-toggle-btn");
|
||||||
|
wsprDecodeToggleBtn?.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await wsprWindow.takeSchedulerControlForDecoderDisable?.(wsprDecodeToggleBtn);
|
||||||
|
await hostCore.postPath("/toggle_wspr_decode");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("WSPR toggle failed", error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
document.getElementById("settings-clear-wspr-history")?.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
if (!await wsprWindow.trxUi.confirm({ title: "Clear WSPR history?", message: "All stored WSPR decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
|
try {
|
||||||
|
await hostCore.postPath("/clear_wspr_decode");
|
||||||
|
resetWsprHistoryView();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("WSPR history clear failed", error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
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) {
|
||||||
|
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
|
||||||
|
const next = normalizeServerWsprMessage(msg);
|
||||||
|
plotWsprLocator(msg);
|
||||||
|
addWsprMessage(next.history);
|
||||||
|
}
|
||||||
|
wsprWindow.trxPluginRuntime.registerDecoder({
|
||||||
|
id: "wspr",
|
||||||
|
onMessage: onServerWspr,
|
||||||
|
onBatch: onServerWsprBatch,
|
||||||
|
restore: onServerWsprBatch,
|
||||||
|
prune: pruneWsprHistoryView,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,120 +0,0 @@
|
|||||||
(function() {
|
|
||||||
if (typeof L === "undefined") return;
|
|
||||||
|
|
||||||
function clamp(value, min, max) {
|
|
||||||
return Math.max(min, Math.min(max, value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function finiteAngle(value) {
|
|
||||||
if (!Number.isFinite(value)) return null;
|
|
||||||
const normalized = ((Number(value) % 360) + 360) % 360;
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
function svgColor(value, fallback) {
|
|
||||||
const text = String(value || fallback || "");
|
|
||||||
return text.replace(/"/g, """);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSymbolHtml(options, zoom) {
|
|
||||||
const heading = finiteAngle(options.heading);
|
|
||||||
const course = finiteAngle(options.course);
|
|
||||||
const angle = heading != null ? heading : course;
|
|
||||||
const speed = Number.isFinite(options.speed) ? Math.max(0, Number(options.speed)) : 0;
|
|
||||||
const sizeBase = Number.isFinite(options.size) ? Number(options.size) : 22;
|
|
||||||
const zoomBoost = zoom >= 12 ? 4 : zoom >= 9 ? 2 : 0;
|
|
||||||
const size = clamp(sizeBase + zoomBoost, 16, 32);
|
|
||||||
const courseLen = course != null ? clamp(size * (0.55 + Math.min(speed, 30) / 30), size * 0.55, size * 1.2) : 0;
|
|
||||||
const color = svgColor(options.color, "#ff7559");
|
|
||||||
const outline = svgColor(options.outline, "#6b2118");
|
|
||||||
|
|
||||||
const body = angle != null
|
|
||||||
? `<g transform="translate(${size / 2} ${size / 2}) rotate(${angle}) translate(${-size / 2} ${-size / 2})">` +
|
|
||||||
`<path d="M ${size * 0.5} ${size * 0.06} L ${size * 0.82} ${size * 0.78} L ${size * 0.5} ${size * 0.62} L ${size * 0.18} ${size * 0.78} Z" fill="${color}" stroke="${outline}" stroke-width="1.2" stroke-linejoin="round" />` +
|
|
||||||
`</g>`
|
|
||||||
: `<path d="M ${size * 0.5} ${size * 0.12} L ${size * 0.88} ${size * 0.5} L ${size * 0.5} ${size * 0.88} L ${size * 0.12} ${size * 0.5} Z" fill="${color}" stroke="${outline}" stroke-width="1.2" stroke-linejoin="round" />`;
|
|
||||||
|
|
||||||
const courseLine = course != null
|
|
||||||
? `<g transform="translate(${size / 2} ${size / 2}) rotate(${course})">` +
|
|
||||||
`<line x1="0" y1="${-size * 0.22}" x2="0" y2="${-(size * 0.22 + courseLen)}" stroke="${color}" stroke-width="1.4" stroke-linecap="round" opacity="0.75" />` +
|
|
||||||
`</g>`
|
|
||||||
: "";
|
|
||||||
|
|
||||||
return (
|
|
||||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" aria-hidden="true">` +
|
|
||||||
courseLine +
|
|
||||||
body +
|
|
||||||
`</svg>`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
L.TrxAisTrackSymbol = L.Marker.extend({
|
|
||||||
options: {
|
|
||||||
heading: null,
|
|
||||||
course: null,
|
|
||||||
speed: null,
|
|
||||||
color: "#ff7559",
|
|
||||||
outline: "#6b2118",
|
|
||||||
size: 22,
|
|
||||||
interactive: true,
|
|
||||||
keyboard: true,
|
|
||||||
riseOnHover: true,
|
|
||||||
},
|
|
||||||
|
|
||||||
initialize: function(latlng, options) {
|
|
||||||
const merged = L.Util.extend({}, this.options, options || {});
|
|
||||||
merged.icon = L.divIcon({
|
|
||||||
className: "trx-ais-track-symbol-icon",
|
|
||||||
html: "",
|
|
||||||
iconSize: [merged.size, merged.size],
|
|
||||||
iconAnchor: [merged.size / 2, merged.size / 2],
|
|
||||||
});
|
|
||||||
L.Marker.prototype.initialize.call(this, latlng, merged);
|
|
||||||
},
|
|
||||||
|
|
||||||
onAdd: function(map) {
|
|
||||||
L.Marker.prototype.onAdd.call(this, map);
|
|
||||||
this._refreshIcon();
|
|
||||||
this._boundZoomRefresh = this._refreshIcon.bind(this);
|
|
||||||
map.on("zoomend", this._boundZoomRefresh);
|
|
||||||
},
|
|
||||||
|
|
||||||
onRemove: function(map) {
|
|
||||||
if (this._boundZoomRefresh) {
|
|
||||||
map.off("zoomend", this._boundZoomRefresh);
|
|
||||||
this._boundZoomRefresh = null;
|
|
||||||
}
|
|
||||||
L.Marker.prototype.onRemove.call(this, map);
|
|
||||||
},
|
|
||||||
|
|
||||||
setAisState: function(next) {
|
|
||||||
if (next && typeof next === "object") {
|
|
||||||
if ("heading" in next) this.options.heading = next.heading;
|
|
||||||
if ("course" in next) this.options.course = next.course;
|
|
||||||
if ("speed" in next) this.options.speed = next.speed;
|
|
||||||
if ("color" in next) this.options.color = next.color;
|
|
||||||
if ("outline" in next) this.options.outline = next.outline;
|
|
||||||
}
|
|
||||||
this._refreshIcon();
|
|
||||||
return this;
|
|
||||||
},
|
|
||||||
|
|
||||||
_refreshIcon: function() {
|
|
||||||
if (!this._icon) return;
|
|
||||||
const zoom = this._map && typeof this._map.getZoom === "function" ? this._map.getZoom() : 0;
|
|
||||||
const html = buildSymbolHtml(this.options, zoom);
|
|
||||||
this._icon.innerHTML = html;
|
|
||||||
const sizeBase = Number.isFinite(this.options.size) ? Number(this.options.size) : 22;
|
|
||||||
const zoomBoost = zoom >= 12 ? 4 : zoom >= 9 ? 2 : 0;
|
|
||||||
const size = clamp(sizeBase + zoomBoost, 16, 32);
|
|
||||||
this._icon.style.width = `${size}px`;
|
|
||||||
this._icon.style.height = `${size}px`;
|
|
||||||
this._icon.style.marginLeft = `${-size / 2}px`;
|
|
||||||
this._icon.style.marginTop = `${-size / 2}px`;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
L.trxAisTrackSymbol = function(latlng, options) {
|
|
||||||
return new L.TrxAisTrackSymbol(latlng, options);
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
@@ -1,407 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
// --- AIS Decoder Plugin (server-side decode) ---
|
|
||||||
const aisStatus = document.getElementById("ais-status");
|
|
||||||
const aisMessagesEl = document.getElementById("ais-messages");
|
|
||||||
const aisFilterInput = document.getElementById("ais-filter");
|
|
||||||
const aisBarOverlay = document.getElementById("ais-bar-overlay");
|
|
||||||
const aisChannelSummaryEl = document.getElementById("ais-channel-summary");
|
|
||||||
const aisVesselCountEl = document.getElementById("ais-vessel-count");
|
|
||||||
const aisLatestSeenEl = document.getElementById("ais-latest-seen");
|
|
||||||
const AIS_BAR_WINDOW_MS = 15 * 60 * 1000;
|
|
||||||
const AIS_DEFAULT_A_HZ = 161_975_000;
|
|
||||||
const AIS_CHANNEL_SPACING_HZ = 50_000;
|
|
||||||
let aisFilterText = "";
|
|
||||||
let aisMessageHistory = [];
|
|
||||||
|
|
||||||
function currentAisHistoryRetentionMs() {
|
|
||||||
return typeof window.getDecodeHistoryRetentionMs === "function"
|
|
||||||
? window.getDecodeHistoryRetentionMs()
|
|
||||||
: 24 * 60 * 60 * 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pruneAisMessageHistory() {
|
|
||||||
const cutoffMs = Date.now() - currentAisHistoryRetentionMs();
|
|
||||||
aisMessageHistory = aisMessageHistory.filter((msg) => Number(msg?._tsMs) >= cutoffMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleAisUi(key, job) {
|
|
||||||
if (typeof window.trxScheduleUiFrameJob === "function") {
|
|
||||||
window.trxScheduleUiFrameJob(key, job);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
job();
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleAisHistoryRender() {
|
|
||||||
scheduleAisUi("ais-history", () => renderAisHistory());
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleAisBarUpdate() {
|
|
||||||
scheduleAisUi("ais-bar", () => updateAisBar());
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatAisMhz(freqHz) {
|
|
||||||
return `${(freqHz / 1_000_000).toFixed(3)} MHz`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentAisChannelPlan() {
|
|
||||||
const raw = (document.getElementById("freq")?.value || "").replace(/[^\d]/g, "");
|
|
||||||
const aHz = raw ? Number(raw) : AIS_DEFAULT_A_HZ;
|
|
||||||
const safeAHz = Number.isFinite(aHz) && aHz > 0 ? aHz : AIS_DEFAULT_A_HZ;
|
|
||||||
return {
|
|
||||||
aHz: safeAHz,
|
|
||||||
bHz: safeAHz + AIS_CHANNEL_SPACING_HZ,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function aisChannelInfo(channel) {
|
|
||||||
const plan = currentAisChannelPlan();
|
|
||||||
const ch = String(channel || "").trim().toUpperCase();
|
|
||||||
if (ch === "B") {
|
|
||||||
return {
|
|
||||||
label: "AIS-B",
|
|
||||||
badgeClass: "ais-badge ais-badge-channel-b",
|
|
||||||
freqText: formatAisMhz(plan.bHz),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
label: "AIS-A",
|
|
||||||
badgeClass: "ais-badge ais-badge-channel-a",
|
|
||||||
freqText: formatAisMhz(plan.aHz),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function aisDisplayName(msg) {
|
|
||||||
return msg.vessel_name || msg.callsign || `MMSI ${msg.mmsi}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function aisDisplayNameHtml(msg) {
|
|
||||||
const label = escapeMapHtml(aisDisplayName(msg));
|
|
||||||
const url = window.buildAisVesselUrl ? window.buildAisVesselUrl(msg?.mmsi) : null;
|
|
||||||
if (!url) return label;
|
|
||||||
return `<a class="title-link" href="${escapeMapHtml(url)}" target="_blank" rel="noopener">${label}</a>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function aisTypeLabel(type) {
|
|
||||||
switch (Number(type)) {
|
|
||||||
case 1:
|
|
||||||
case 2:
|
|
||||||
case 3:
|
|
||||||
return "Class A Position";
|
|
||||||
case 4:
|
|
||||||
return "Base Station";
|
|
||||||
case 5:
|
|
||||||
return "Static/Voyage";
|
|
||||||
case 18:
|
|
||||||
return "Class B Position";
|
|
||||||
case 19:
|
|
||||||
return "Class B Extended";
|
|
||||||
case 21:
|
|
||||||
return "Aid to Nav";
|
|
||||||
case 24:
|
|
||||||
return "Class B Static";
|
|
||||||
default:
|
|
||||||
return `Type ${type ?? "--"}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function aisAgeText(tsMs) {
|
|
||||||
if (!Number.isFinite(tsMs)) return "just now";
|
|
||||||
const deltaMs = Math.max(0, Date.now() - tsMs);
|
|
||||||
const seconds = Math.round(deltaMs / 1000);
|
|
||||||
if (seconds < 5) return "just now";
|
|
||||||
if (seconds < 60) return `${seconds}s ago`;
|
|
||||||
const minutes = Math.round(seconds / 60);
|
|
||||||
if (minutes < 60) return `${minutes}m ago`;
|
|
||||||
const hours = Math.round(minutes / 60);
|
|
||||||
return `${hours}h ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function aisMotionText(msg) {
|
|
||||||
const parts = [
|
|
||||||
msg.sog_knots != null ? `${Number(msg.sog_knots).toFixed(1)} kn` : null,
|
|
||||||
msg.cog_deg != null ? `${Number(msg.cog_deg).toFixed(1)}° COG` : null,
|
|
||||||
msg.heading_deg != null ? `${Number(msg.heading_deg).toFixed(0)}° HDG` : null,
|
|
||||||
].filter(Boolean);
|
|
||||||
return parts.join(" · ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function aisRouteText(msg) {
|
|
||||||
return [msg.callsign, msg.destination].filter(Boolean).join(" -> ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function aisDistanceText(msg) {
|
|
||||||
if (serverLat == null || serverLon == null || msg?.lat == null || msg?.lon == null) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
const distKm = haversineKm(serverLat, serverLon, msg.lat, msg.lon);
|
|
||||||
if (!Number.isFinite(distKm)) return "";
|
|
||||||
if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`;
|
|
||||||
return `${distKm.toFixed(1)} km from TRX`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function aisLatestByVessel(messages) {
|
|
||||||
const byMmsi = new Map();
|
|
||||||
for (const msg of messages) {
|
|
||||||
const key = Number.isFinite(msg.mmsi) ? String(msg.mmsi) : `${msg.channel || "?"}:${msg._tsMs || 0}`;
|
|
||||||
if (!byMmsi.has(key)) byMmsi.set(key, msg);
|
|
||||||
}
|
|
||||||
return Array.from(byMmsi.values());
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateAisSummary() {
|
|
||||||
const plan = currentAisChannelPlan();
|
|
||||||
if (aisChannelSummaryEl) {
|
|
||||||
aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const vessels = aisLatestByVessel(aisMessageHistory);
|
|
||||||
if (aisVesselCountEl) {
|
|
||||||
const count = vessels.length;
|
|
||||||
aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (aisLatestSeenEl) {
|
|
||||||
const latest = aisMessageHistory[0];
|
|
||||||
if (!latest) {
|
|
||||||
aisLatestSeenEl.textContent = "No traffic yet";
|
|
||||||
} else {
|
|
||||||
const channel = aisChannelInfo(latest.channel);
|
|
||||||
aisLatestSeenEl.textContent = `${channel.label} ${aisAgeText(latest._tsMs)}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderAisRow(msg) {
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = "ais-message";
|
|
||||||
const ts = msg._ts || new Date().toLocaleTimeString([], {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
second: "2-digit",
|
|
||||||
});
|
|
||||||
const name = aisDisplayName(msg);
|
|
||||||
const nameHtml = aisDisplayNameHtml(msg);
|
|
||||||
const channel = aisChannelInfo(msg.channel);
|
|
||||||
const motion = aisMotionText(msg);
|
|
||||||
const route = aisRouteText(msg);
|
|
||||||
const distance = aisDistanceText(msg);
|
|
||||||
const pos = msg.lat != null && msg.lon != null
|
|
||||||
? `<a class="ais-pos-link" href="javascript:void(0)" onclick="window.navigateToAprsMap(${msg.lat},${msg.lon})">${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}</a>`
|
|
||||||
: "";
|
|
||||||
row.dataset.filterText = [
|
|
||||||
name,
|
|
||||||
msg.mmsi,
|
|
||||||
msg.channel,
|
|
||||||
channel.label,
|
|
||||||
msg.vessel_name,
|
|
||||||
msg.callsign,
|
|
||||||
msg.destination,
|
|
||||||
aisTypeLabel(msg.message_type),
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ")
|
|
||||||
.toUpperCase();
|
|
||||||
row.innerHTML =
|
|
||||||
`<div class="ais-row-head">` +
|
|
||||||
`<span class="ais-time">${ts}</span>` +
|
|
||||||
`<span class="ais-call">${nameHtml}</span>` +
|
|
||||||
`<span class="${channel.badgeClass}">${escapeMapHtml(channel.label)}</span>` +
|
|
||||||
`<span class="ais-badge ais-badge-type">${escapeMapHtml(aisTypeLabel(msg.message_type))}</span>` +
|
|
||||||
`</div>` +
|
|
||||||
`<div class="ais-row-meta">` +
|
|
||||||
`<span>MMSI ${escapeMapHtml(String(msg.mmsi))}</span>` +
|
|
||||||
(route ? `<span class="ais-meta-text">${escapeMapHtml(route)}</span>` : "") +
|
|
||||||
`<span class="ais-meta-text">${escapeMapHtml(channel.freqText)}</span>` +
|
|
||||||
`</div>` +
|
|
||||||
`<div class="ais-row-detail">` +
|
|
||||||
(motion ? `<span>${escapeMapHtml(motion)}</span>` : `<span>No motion data</span>`) +
|
|
||||||
(distance ? `<span>${escapeMapHtml(distance)}</span>` : "") +
|
|
||||||
(pos ? `<span>${pos}</span>` : "") +
|
|
||||||
`<span>${escapeMapHtml(aisAgeText(msg._tsMs))}</span>` +
|
|
||||||
`</div>`;
|
|
||||||
applyAisFilterToRow(row);
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyAisFilterToRow(row) {
|
|
||||||
if (!aisFilterText) {
|
|
||||||
row.style.display = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const message = row.dataset.filterText || "";
|
|
||||||
row.style.display = message.includes(aisFilterText) ? "" : "none";
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyAisFilterToAll() {
|
|
||||||
if (!aisMessagesEl) return;
|
|
||||||
const rows = aisMessagesEl.querySelectorAll(".ais-message");
|
|
||||||
rows.forEach((row) => applyAisFilterToRow(row));
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateAisBar() {
|
|
||||||
if (!aisBarOverlay) return;
|
|
||||||
updateAisSummary();
|
|
||||||
|
|
||||||
const isAis = (document.getElementById("mode")?.value || "").toUpperCase() === "AIS";
|
|
||||||
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
|
|
||||||
const recent = aisMessageHistory.filter((msg) => msg._tsMs >= cutoffMs);
|
|
||||||
const messages = aisLatestByVessel(recent).slice(0, 8);
|
|
||||||
if (!isAis || messages.length === 0) {
|
|
||||||
aisBarOverlay.style.display = "none";
|
|
||||||
aisBarOverlay.innerHTML = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let html = '<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">AIS</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearAisBar()" onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();window.clearAisBar();}" aria-label="Clear AIS overlay">Clear</span></span><span class="aprs-bar-window">Last 15 minutes</span></div>';
|
|
||||||
for (const msg of messages) {
|
|
||||||
const ts = msg._ts ? `<span class="aprs-bar-time">${msg._ts}</span>` : "";
|
|
||||||
const pin = msg.lat != null && msg.lon != null
|
|
||||||
? `<button class="aprs-bar-pin" title="${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}" onclick="window.navigateToAprsMap(${msg.lat},${msg.lon})">📍</button>`
|
|
||||||
: "";
|
|
||||||
const name = `<span class="ais-call">${aisDisplayNameHtml(msg)}</span>`;
|
|
||||||
const channel = aisChannelInfo(msg.channel);
|
|
||||||
const distance = aisDistanceText(msg);
|
|
||||||
const details = [
|
|
||||||
`MMSI ${escapeMapHtml(String(msg.mmsi))}`,
|
|
||||||
escapeMapHtml(channel.label),
|
|
||||||
msg.sog_knots != null ? `${Number(msg.sog_knots).toFixed(1)} kn` : null,
|
|
||||||
msg.cog_deg != null ? `${Number(msg.cog_deg).toFixed(1)}°` : null,
|
|
||||||
distance ? escapeMapHtml(distance) : null,
|
|
||||||
escapeMapHtml(aisAgeText(msg._tsMs)),
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" · ");
|
|
||||||
html += `<div class="aprs-bar-frame">` +
|
|
||||||
`<div class="aprs-bar-frame-main">${ts}${pin}${name}: ${details}</div>` +
|
|
||||||
`</div>`;
|
|
||||||
}
|
|
||||||
aisBarOverlay.innerHTML = html;
|
|
||||||
aisBarOverlay.style.display = "flex";
|
|
||||||
}
|
|
||||||
window.updateAisBar = updateAisBar;
|
|
||||||
window.clearAisBar = function() {
|
|
||||||
window.resetAisHistoryView();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.resetAisHistoryView = function() {
|
|
||||||
if (aisMessagesEl) aisMessagesEl.innerHTML = "";
|
|
||||||
aisMessageHistory = [];
|
|
||||||
updateAisBar();
|
|
||||||
renderAisHistory();
|
|
||||||
if (window.clearMapMarkersByType) window.clearMapMarkersByType("ais");
|
|
||||||
};
|
|
||||||
|
|
||||||
function renderAisHistory() {
|
|
||||||
pruneAisMessageHistory();
|
|
||||||
if (!aisMessagesEl) {
|
|
||||||
updateAisSummary();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const fragment = document.createDocumentFragment();
|
|
||||||
for (let i = 0; i < aisMessageHistory.length; i += 1) {
|
|
||||||
fragment.appendChild(renderAisRow(aisMessageHistory[i]));
|
|
||||||
}
|
|
||||||
aisMessagesEl.replaceChildren(fragment);
|
|
||||||
updateAisSummary();
|
|
||||||
}
|
|
||||||
|
|
||||||
function addAisMessage(msg) {
|
|
||||||
const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
|
||||||
msg._tsMs = tsMs;
|
|
||||||
msg._ts = new Date(tsMs).toLocaleTimeString([], {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
second: "2-digit",
|
|
||||||
});
|
|
||||||
|
|
||||||
aisMessageHistory.unshift(msg);
|
|
||||||
pruneAisMessageHistory();
|
|
||||||
scheduleAisBarUpdate();
|
|
||||||
scheduleAisHistoryRender();
|
|
||||||
|
|
||||||
if (msg.lat != null && msg.lon != null && window.aisMapAddVessel) {
|
|
||||||
window.aisMapAddVessel(msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeServerAisMessage(msg) {
|
|
||||||
return {
|
|
||||||
rig_id: msg.rig_id || null,
|
|
||||||
channel: msg.channel,
|
|
||||||
message_type: msg.message_type,
|
|
||||||
mmsi: msg.mmsi,
|
|
||||||
lat: msg.lat,
|
|
||||||
lon: msg.lon,
|
|
||||||
sog_knots: msg.sog_knots,
|
|
||||||
cog_deg: msg.cog_deg,
|
|
||||||
heading_deg: msg.heading_deg,
|
|
||||||
vessel_name: msg.vessel_name,
|
|
||||||
callsign: msg.callsign,
|
|
||||||
destination: msg.destination,
|
|
||||||
ts_ms: msg.ts_ms,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
window.onServerAisBatch = function(messages) {
|
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
|
||||||
if (aisStatus) aisStatus.textContent = "Receiving";
|
|
||||||
const normalized = [];
|
|
||||||
for (const msg of messages) {
|
|
||||||
const next = normalizeServerAisMessage(msg);
|
|
||||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
|
||||||
next._tsMs = tsMs;
|
|
||||||
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
second: "2-digit",
|
|
||||||
});
|
|
||||||
if (next.lat != null && next.lon != null && window.aisMapAddVessel) {
|
|
||||||
window.aisMapAddVessel(next);
|
|
||||||
}
|
|
||||||
normalized.push(next);
|
|
||||||
}
|
|
||||||
normalized.reverse();
|
|
||||||
aisMessageHistory = normalized.concat(aisMessageHistory);
|
|
||||||
pruneAisMessageHistory();
|
|
||||||
scheduleAisBarUpdate();
|
|
||||||
scheduleAisHistoryRender();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.restoreAisHistory = function(messages) {
|
|
||||||
window.onServerAisBatch(messages);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.pruneAisHistoryView = function() {
|
|
||||||
pruneAisMessageHistory();
|
|
||||||
updateAisBar();
|
|
||||||
renderAisHistory();
|
|
||||||
};
|
|
||||||
|
|
||||||
document.getElementById("settings-clear-ais-history")?.addEventListener("click", async () => {
|
|
||||||
if (!confirm("Clear all AIS decode history? This cannot be undone.")) return;
|
|
||||||
try {
|
|
||||||
await postPath("/clear_ais_decode");
|
|
||||||
window.resetAisHistoryView();
|
|
||||||
} catch (e) {
|
|
||||||
console.error("AIS history clear failed", e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (aisFilterInput) {
|
|
||||||
aisFilterInput.addEventListener("input", () => {
|
|
||||||
aisFilterText = aisFilterInput.value.trim().toUpperCase();
|
|
||||||
renderAisHistory();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
window.onServerAis = function(msg) {
|
|
||||||
if (aisStatus) aisStatus.textContent = "Receiving";
|
|
||||||
addAisMessage(normalizeServerAisMessage(msg));
|
|
||||||
};
|
|
||||||
|
|
||||||
updateAisSummary();
|
|
||||||
if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("ais");
|
|
||||||
@@ -1,498 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
// --- APRS Decoder Plugin (server-side decode) ---
|
|
||||||
const aprsStatus = document.getElementById("aprs-status");
|
|
||||||
const aprsPacketsEl = document.getElementById("aprs-packets");
|
|
||||||
const aprsFilterInput = document.getElementById("aprs-filter");
|
|
||||||
const aprsBarOverlay = document.getElementById("aprs-bar-overlay");
|
|
||||||
const aprsOnlyPosBtn = document.getElementById("aprs-only-pos-btn");
|
|
||||||
const aprsHideCrcBtn = document.getElementById("aprs-hide-crc-btn");
|
|
||||||
const aprsCollapseDupBtn = document.getElementById("aprs-collapse-dup-btn");
|
|
||||||
const aprsTotalCountEl = document.getElementById("aprs-total-count");
|
|
||||||
const aprsVisibleCountEl = document.getElementById("aprs-visible-count");
|
|
||||||
const aprsLatestSeenEl = document.getElementById("aprs-latest-seen");
|
|
||||||
const APRS_BAR_WINDOW_MS = 15 * 60 * 1000;
|
|
||||||
let aprsFilterText = "";
|
|
||||||
let aprsPacketHistory = [];
|
|
||||||
let aprsBarDismissedAtMs = 0;
|
|
||||||
let aprsOnlyPos = false;
|
|
||||||
let aprsHideCrc = false;
|
|
||||||
let aprsCollapseDup = false;
|
|
||||||
let aprsTypeFilter = "all";
|
|
||||||
|
|
||||||
function currentAprsHistoryRetentionMs() {
|
|
||||||
return typeof window.getDecodeHistoryRetentionMs === "function"
|
|
||||||
? window.getDecodeHistoryRetentionMs()
|
|
||||||
: 24 * 60 * 60 * 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pruneAprsPacketHistory() {
|
|
||||||
const cutoffMs = Date.now() - currentAprsHistoryRetentionMs();
|
|
||||||
aprsPacketHistory = aprsPacketHistory.filter((pkt) => Number(pkt?._tsMs) >= cutoffMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleAprsUi(key, job) {
|
|
||||||
if (typeof window.trxScheduleUiFrameJob === "function") {
|
|
||||||
window.trxScheduleUiFrameJob(key, job);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
job();
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleAprsHistoryRender() {
|
|
||||||
scheduleAprsUi("aprs-history", () => renderAprsHistory());
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleAprsBarUpdate() {
|
|
||||||
scheduleAprsUi("aprs-bar", () => updateAprsBar());
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderAprsInfo(pkt) {
|
|
||||||
const bytes = Array.isArray(pkt.info_bytes) ? pkt.info_bytes : null;
|
|
||||||
if (bytes && bytes.length > 0) {
|
|
||||||
let out = "";
|
|
||||||
for (let i = 0; i < bytes.length; i++) {
|
|
||||||
const b = bytes[i];
|
|
||||||
if (b >= 0x20 && b <= 0x7e) {
|
|
||||||
const ch = String.fromCharCode(b);
|
|
||||||
if (ch === "<") out += "<";
|
|
||||||
else if (ch === ">") out += ">";
|
|
||||||
else if (ch === "&") out += "&";
|
|
||||||
else if (ch === '"') out += """;
|
|
||||||
else out += ch;
|
|
||||||
} else {
|
|
||||||
const hex = b.toString(16).toUpperCase().padStart(2, "0");
|
|
||||||
out += `<span class="aprs-byte">0x${hex}</span>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
const str = pkt.info || "";
|
|
||||||
let out = "";
|
|
||||||
for (let i = 0; i < str.length; i++) {
|
|
||||||
const code = str.charCodeAt(i);
|
|
||||||
if (code >= 0x20 && code <= 0x7e) {
|
|
||||||
const ch = str[i];
|
|
||||||
if (ch === "<") out += "<";
|
|
||||||
else if (ch === ">") out += ">";
|
|
||||||
else if (ch === "&") out += "&";
|
|
||||||
else if (ch === '"') out += """;
|
|
||||||
else out += ch;
|
|
||||||
} else {
|
|
||||||
const hex = code.toString(16).toUpperCase().padStart(2, "0");
|
|
||||||
out += `<span class="aprs-byte">0x${hex}</span>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function aprsPacketCategory(pkt) {
|
|
||||||
const type = String(pkt.type || "").toLowerCase();
|
|
||||||
const info = String(pkt.info || "").toLowerCase();
|
|
||||||
if (pkt.lat != null && pkt.lon != null || type.includes("position")) return "position";
|
|
||||||
if (type.includes("message") || info.startsWith(":")) return "message";
|
|
||||||
if (type.includes("weather") || info.startsWith("_")) return "weather";
|
|
||||||
if (type.includes("telemetry") || info.startsWith("t#")) return "telemetry";
|
|
||||||
return "other";
|
|
||||||
}
|
|
||||||
|
|
||||||
function aprsCategoryLabel(category) {
|
|
||||||
switch (category) {
|
|
||||||
case "position": return "Position";
|
|
||||||
case "message": return "Message";
|
|
||||||
case "weather": return "Weather";
|
|
||||||
case "telemetry": return "Telemetry";
|
|
||||||
default: return "Other";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function aprsAgeText(tsMs) {
|
|
||||||
if (!Number.isFinite(tsMs)) return "just now";
|
|
||||||
const deltaMs = Math.max(0, Date.now() - tsMs);
|
|
||||||
const seconds = Math.round(deltaMs / 1000);
|
|
||||||
if (seconds < 5) return "just now";
|
|
||||||
if (seconds < 60) return `${seconds}s ago`;
|
|
||||||
const minutes = Math.round(seconds / 60);
|
|
||||||
if (minutes < 60) return `${minutes}m ago`;
|
|
||||||
const hours = Math.round(minutes / 60);
|
|
||||||
return `${hours}h ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function aprsDistanceText(pkt) {
|
|
||||||
if (serverLat == null || serverLon == null || pkt.lat == null || pkt.lon == null) return "";
|
|
||||||
const distKm = haversineKm(serverLat, serverLon, pkt.lat, pkt.lon);
|
|
||||||
if (!Number.isFinite(distKm)) return "";
|
|
||||||
if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`;
|
|
||||||
return `${distKm.toFixed(1)} km from TRX`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function aprsPacketSignature(pkt) {
|
|
||||||
return [
|
|
||||||
pkt.srcCall || "",
|
|
||||||
pkt.destCall || "",
|
|
||||||
pkt.path || "",
|
|
||||||
pkt.info || "",
|
|
||||||
pkt.type || "",
|
|
||||||
pkt.lat != null ? pkt.lat.toFixed(4) : "",
|
|
||||||
pkt.lon != null ? pkt.lon.toFixed(4) : "",
|
|
||||||
].join("|");
|
|
||||||
}
|
|
||||||
|
|
||||||
function aprsHexBytes(bytes) {
|
|
||||||
if (!Array.isArray(bytes) || bytes.length === 0) return "--";
|
|
||||||
return bytes.map((b) => Number(b).toString(16).toUpperCase().padStart(2, "0")).join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function aprsFilterMatch(pkt) {
|
|
||||||
if (aprsOnlyPos && (pkt.lat == null || pkt.lon == null)) return false;
|
|
||||||
if (aprsHideCrc && !pkt.crcOk) return false;
|
|
||||||
if (aprsTypeFilter !== "all" && aprsPacketCategory(pkt) !== aprsTypeFilter) return false;
|
|
||||||
if (!aprsFilterText) return true;
|
|
||||||
const haystack = [
|
|
||||||
pkt.srcCall,
|
|
||||||
pkt.destCall,
|
|
||||||
pkt.path,
|
|
||||||
pkt.info,
|
|
||||||
pkt.type,
|
|
||||||
pkt.lat != null ? pkt.lat.toFixed(4) : "",
|
|
||||||
pkt.lon != null ? pkt.lon.toFixed(4) : "",
|
|
||||||
aprsPacketCategory(pkt),
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ")
|
|
||||||
.toUpperCase();
|
|
||||||
return haystack.includes(aprsFilterText);
|
|
||||||
}
|
|
||||||
|
|
||||||
function aprsVisiblePackets() {
|
|
||||||
const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory;
|
|
||||||
return packets.filter(aprsFilterMatch);
|
|
||||||
}
|
|
||||||
|
|
||||||
function collapseAprsDuplicates(packets) {
|
|
||||||
const seen = new Set();
|
|
||||||
const out = [];
|
|
||||||
for (const pkt of packets) {
|
|
||||||
const key = aprsPacketSignature(pkt);
|
|
||||||
if (seen.has(key)) continue;
|
|
||||||
seen.add(key);
|
|
||||||
out.push(pkt);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateAprsSummary() {
|
|
||||||
const visible = aprsVisiblePackets();
|
|
||||||
if (aprsTotalCountEl) {
|
|
||||||
aprsTotalCountEl.textContent = `${aprsPacketHistory.length} total`;
|
|
||||||
}
|
|
||||||
if (aprsVisibleCountEl) {
|
|
||||||
aprsVisibleCountEl.textContent = `${visible.length} shown`;
|
|
||||||
}
|
|
||||||
if (aprsLatestSeenEl) {
|
|
||||||
const latest = aprsPacketHistory[0];
|
|
||||||
if (!latest) {
|
|
||||||
aprsLatestSeenEl.textContent = "No packets yet";
|
|
||||||
} else {
|
|
||||||
aprsLatestSeenEl.textContent = `${latest.srcCall} ${aprsAgeText(latest._tsMs)}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateAprsChipState() {
|
|
||||||
document.querySelectorAll("[id^='aprs-type-']").forEach((btn) => {
|
|
||||||
btn.classList.toggle("active", btn.id === `aprs-type-${aprsTypeFilter}`);
|
|
||||||
});
|
|
||||||
aprsOnlyPosBtn?.classList.toggle("active", aprsOnlyPos);
|
|
||||||
aprsHideCrcBtn?.classList.toggle("active", aprsHideCrc);
|
|
||||||
aprsCollapseDupBtn?.classList.toggle("active", aprsCollapseDup);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderAprsRow(pkt, isFresh) {
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = "aprs-packet";
|
|
||||||
if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
|
|
||||||
if (isFresh) row.classList.add("aprs-packet-new");
|
|
||||||
|
|
||||||
const ts = pkt._ts || new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
||||||
const age = aprsAgeText(pkt._tsMs);
|
|
||||||
const category = aprsPacketCategory(pkt);
|
|
||||||
const categoryLabel = aprsCategoryLabel(category);
|
|
||||||
const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`;
|
|
||||||
const pathBadge = pkt.path ? `<span class="aprs-badge">${escapeMapHtml(pkt.path)}</span>` : "";
|
|
||||||
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
|
|
||||||
let symbolHtml = "";
|
|
||||||
if (pkt.symbolTable && pkt.symbolCode) {
|
|
||||||
const sheet = pkt.symbolTable === "/" ? 0 : 1;
|
|
||||||
const code = pkt.symbolCode.charCodeAt(0) - 33;
|
|
||||||
const col = code % 16;
|
|
||||||
const row2 = Math.floor(code / 16);
|
|
||||||
const bgX = -(col * 24);
|
|
||||||
const bgY = -(row2 * 24);
|
|
||||||
symbolHtml = `<span class="aprs-symbol" style="background-image:url('https://raw.githubusercontent.com/hessu/aprs-symbols/master/png/aprs-symbols-24-${sheet}.png');background-position:${bgX}px ${bgY}px"></span>`;
|
|
||||||
}
|
|
||||||
const posLink = pkt.lat != null && pkt.lon != null
|
|
||||||
? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${pkt.lat},${pkt.lon}">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>`
|
|
||||||
: "";
|
|
||||||
const distance = aprsDistanceText(pkt);
|
|
||||||
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`;
|
|
||||||
|
|
||||||
row.innerHTML =
|
|
||||||
`<div class="aprs-row-head">` +
|
|
||||||
`<span class="aprs-time">${ts}</span>` +
|
|
||||||
symbolHtml +
|
|
||||||
`<span class="aprs-call">${escapeMapHtml(pkt.srcCall)}</span>` +
|
|
||||||
`<span>>${escapeMapHtml(pkt.destCall || "")}</span>` +
|
|
||||||
`<span class="${categoryClass}">${escapeMapHtml(categoryLabel)}</span>` +
|
|
||||||
pathBadge +
|
|
||||||
crcBadge +
|
|
||||||
`</div>` +
|
|
||||||
`<div class="aprs-row-meta">` +
|
|
||||||
`<span class="aprs-meta-text">${escapeMapHtml(age)}</span>` +
|
|
||||||
(distance ? `<span class="aprs-meta-text">${escapeMapHtml(distance)}</span>` : "") +
|
|
||||||
`<span class="aprs-meta-text">${escapeMapHtml(pkt.type || "--")}</span>` +
|
|
||||||
`</div>` +
|
|
||||||
`<div class="aprs-row-detail">` +
|
|
||||||
`<span title="${escapeMapHtml(pkt.type || "")}">${renderAprsInfo(pkt)}</span>` +
|
|
||||||
(posLink ? `<span>${posLink}</span>` : "") +
|
|
||||||
`</div>` +
|
|
||||||
`<div class="aprs-row-actions">` +
|
|
||||||
(pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${pkt.lat},${pkt.lon}">Map</button>` : "") +
|
|
||||||
(pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${pkt.lat},${pkt.lon}">Copy Coords</button>` : "") +
|
|
||||||
`<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a>` +
|
|
||||||
`</div>` +
|
|
||||||
`<details class="aprs-details">` +
|
|
||||||
`<summary>Details</summary>` +
|
|
||||||
`<div class="aprs-details-grid">` +
|
|
||||||
`<span class="aprs-detail-label">Source</span><span class="aprs-detail-value">${escapeMapHtml(pkt.srcCall || "--")}</span>` +
|
|
||||||
`<span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeMapHtml(pkt.destCall || "--")}</span>` +
|
|
||||||
`<span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeMapHtml(pkt.type || "--")}</span>` +
|
|
||||||
`<span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeMapHtml(pkt.path || "--")}</span>` +
|
|
||||||
`<span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeMapHtml(age)}</span>` +
|
|
||||||
`<span class="aprs-detail-label">CRC</span><span class="aprs-detail-value">${pkt.crcOk ? "OK" : "Failed"}</span>` +
|
|
||||||
`<span class="aprs-detail-label">Position</span><span class="aprs-detail-value">${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}</span>` +
|
|
||||||
`<span class="aprs-detail-label">Info</span><span class="aprs-detail-value">${escapeMapHtml(pkt.info || "--")}</span>` +
|
|
||||||
`<span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeMapHtml(aprsHexBytes(pkt.info_bytes))}</span>` +
|
|
||||||
`</div>` +
|
|
||||||
`</details>`;
|
|
||||||
|
|
||||||
row.querySelectorAll("[data-aprs-map]").forEach((el) => {
|
|
||||||
el.addEventListener("click", (evt) => {
|
|
||||||
evt.preventDefault();
|
|
||||||
const raw = String(el.dataset.aprsMap || "");
|
|
||||||
const [lat, lon] = raw.split(",").map(Number);
|
|
||||||
if (window.navigateToAprsMap && Number.isFinite(lat) && Number.isFinite(lon)) {
|
|
||||||
window.navigateToAprsMap(lat, lon);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const copyBtn = row.querySelector("[data-aprs-copy]");
|
|
||||||
if (copyBtn) {
|
|
||||||
copyBtn.addEventListener("click", async () => {
|
|
||||||
const raw = String(copyBtn.dataset.aprsCopy || "");
|
|
||||||
try {
|
|
||||||
if (navigator.clipboard?.writeText) {
|
|
||||||
await navigator.clipboard.writeText(raw);
|
|
||||||
showHint("Coordinates copied", 1200);
|
|
||||||
}
|
|
||||||
} catch (_e) {
|
|
||||||
showHint("Copy failed", 1500);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderAprsHistory() {
|
|
||||||
pruneAprsPacketHistory();
|
|
||||||
if (!aprsPacketsEl) {
|
|
||||||
updateAprsSummary();
|
|
||||||
updateAprsChipState();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const visible = aprsVisiblePackets();
|
|
||||||
const fragment = document.createDocumentFragment();
|
|
||||||
for (let i = 0; i < visible.length; i++) {
|
|
||||||
fragment.appendChild(renderAprsRow(visible[i], i === 0));
|
|
||||||
}
|
|
||||||
aprsPacketsEl.replaceChildren(fragment);
|
|
||||||
updateAprsSummary();
|
|
||||||
updateAprsChipState();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateAprsBar() {
|
|
||||||
if (!aprsBarOverlay) return;
|
|
||||||
const isPkt = (document.getElementById("mode")?.value || "").toUpperCase() === "PKT";
|
|
||||||
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
|
|
||||||
const okFrames = aprsPacketHistory.filter((p) => p.crcOk && p._tsMs >= cutoffMs);
|
|
||||||
const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
|
|
||||||
const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0);
|
|
||||||
if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) {
|
|
||||||
aprsBarOverlay.style.display = "none";
|
|
||||||
aprsBarOverlay.innerHTML = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let html = '<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">APRS</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-actions"><span class="aprs-bar-window">Last 15 minutes</span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearAprsBar()" onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();window.clearAprsBar();}" aria-label="Clear APRS overlay">Clear</span></span><button class="aprs-bar-close" type="button" onclick="window.closeAprsBar()" aria-label="Close APRS overlay">×</button></span></div>';
|
|
||||||
for (const pkt of frames) {
|
|
||||||
const ts = pkt._ts ? `<span class="aprs-bar-time">${pkt._ts}</span>` : "";
|
|
||||||
const call = `<span class="aprs-bar-call">${escapeMapHtml(pkt.srcCall)}</span>`;
|
|
||||||
const dest = escapeMapHtml(pkt.destCall || "");
|
|
||||||
const info = escapeMapHtml(pkt.info || "");
|
|
||||||
const pin = pkt.lat != null && pkt.lon != null
|
|
||||||
? `<button class="aprs-bar-pin" title="${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}" onclick="window.navigateToAprsMap(${pkt.lat},${pkt.lon})">📍</button>`
|
|
||||||
: "";
|
|
||||||
html += `<div class="aprs-bar-frame">` +
|
|
||||||
`<div class="aprs-bar-frame-main">${ts}${pin}${call}>${dest}: ${info}</div>` +
|
|
||||||
`</div>`;
|
|
||||||
}
|
|
||||||
aprsBarOverlay.innerHTML = html;
|
|
||||||
aprsBarOverlay.style.display = "flex";
|
|
||||||
}
|
|
||||||
window.updateAprsBar = updateAprsBar;
|
|
||||||
window.clearAprsBar = function() {
|
|
||||||
window.resetAprsHistoryView();
|
|
||||||
};
|
|
||||||
window.closeAprsBar = function() {
|
|
||||||
aprsBarDismissedAtMs = Date.now();
|
|
||||||
if (aprsBarOverlay) {
|
|
||||||
aprsBarOverlay.style.display = "none";
|
|
||||||
aprsBarOverlay.innerHTML = "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.resetAprsHistoryView = function() {
|
|
||||||
if (aprsPacketsEl) aprsPacketsEl.innerHTML = "";
|
|
||||||
aprsPacketHistory = [];
|
|
||||||
updateAprsBar();
|
|
||||||
renderAprsHistory();
|
|
||||||
if (window.clearMapMarkersByType) window.clearMapMarkersByType("aprs");
|
|
||||||
};
|
|
||||||
|
|
||||||
window.pruneAprsHistoryView = function() {
|
|
||||||
pruneAprsPacketHistory();
|
|
||||||
updateAprsBar();
|
|
||||||
renderAprsHistory();
|
|
||||||
};
|
|
||||||
|
|
||||||
function addAprsPacket(pkt) {
|
|
||||||
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
|
||||||
pkt._tsMs = tsMs;
|
|
||||||
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
||||||
|
|
||||||
aprsPacketHistory.unshift(pkt);
|
|
||||||
pruneAprsPacketHistory();
|
|
||||||
|
|
||||||
if (pkt.lat != null && pkt.lon != null && window.aprsMapAddStation) {
|
|
||||||
window.aprsMapAddStation(pkt.srcCall, pkt.lat, pkt.lon, pkt.info, pkt.symbolTable, pkt.symbolCode, pkt);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pkt.crcOk) scheduleAprsBarUpdate();
|
|
||||||
|
|
||||||
scheduleAprsHistoryRender();
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeServerAprsPacket(pkt) {
|
|
||||||
return {
|
|
||||||
rig_id: pkt.rig_id || null,
|
|
||||||
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
|
|
||||||
srcCall: pkt.src_call,
|
|
||||||
destCall: pkt.dest_call,
|
|
||||||
path: pkt.path,
|
|
||||||
info: pkt.info,
|
|
||||||
info_bytes: pkt.info_bytes,
|
|
||||||
type: pkt.packet_type,
|
|
||||||
crcOk: pkt.crc_ok,
|
|
||||||
ts_ms: pkt.ts_ms,
|
|
||||||
lat: pkt.lat,
|
|
||||||
lon: pkt.lon,
|
|
||||||
symbolTable: pkt.symbol_table,
|
|
||||||
symbolCode: pkt.symbol_code,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
window.onServerAprsBatch = function(packets) {
|
|
||||||
if (!Array.isArray(packets) || packets.length === 0) return;
|
|
||||||
aprsStatus.textContent = "Receiving";
|
|
||||||
const normalized = [];
|
|
||||||
let hasCrcOk = false;
|
|
||||||
for (const pkt of packets) {
|
|
||||||
const next = normalizeServerAprsPacket(pkt);
|
|
||||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
|
||||||
next._tsMs = tsMs;
|
|
||||||
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
||||||
if (next.lat != null && next.lon != null && window.aprsMapAddStation) {
|
|
||||||
window.aprsMapAddStation(next.srcCall, next.lat, next.lon, next.info, next.symbolTable, next.symbolCode, next);
|
|
||||||
}
|
|
||||||
if (next.crcOk) hasCrcOk = true;
|
|
||||||
normalized.push(next);
|
|
||||||
}
|
|
||||||
normalized.reverse();
|
|
||||||
aprsPacketHistory = normalized.concat(aprsPacketHistory);
|
|
||||||
pruneAprsPacketHistory();
|
|
||||||
if (hasCrcOk) scheduleAprsBarUpdate();
|
|
||||||
scheduleAprsHistoryRender();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.restoreAprsHistory = function(packets) {
|
|
||||||
window.onServerAprsBatch(packets);
|
|
||||||
};
|
|
||||||
|
|
||||||
document.getElementById("settings-clear-aprs-history")?.addEventListener("click", async () => {
|
|
||||||
if (!confirm("Clear all APRS decode history? This cannot be undone.")) return;
|
|
||||||
try {
|
|
||||||
await postPath("/clear_aprs_decode");
|
|
||||||
window.resetAprsHistoryView();
|
|
||||||
} catch (e) {
|
|
||||||
console.error("APRS history clear failed", e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (aprsOnlyPosBtn) {
|
|
||||||
aprsOnlyPosBtn.addEventListener("click", () => {
|
|
||||||
aprsOnlyPos = !aprsOnlyPos;
|
|
||||||
renderAprsHistory();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (aprsHideCrcBtn) {
|
|
||||||
aprsHideCrcBtn.addEventListener("click", () => {
|
|
||||||
aprsHideCrc = !aprsHideCrc;
|
|
||||||
renderAprsHistory();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (aprsCollapseDupBtn) {
|
|
||||||
aprsCollapseDupBtn.addEventListener("click", () => {
|
|
||||||
aprsCollapseDup = !aprsCollapseDup;
|
|
||||||
renderAprsHistory();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
["all", "position", "message", "weather", "telemetry", "other"].forEach((type) => {
|
|
||||||
const btn = document.getElementById(`aprs-type-${type}`);
|
|
||||||
if (!btn) return;
|
|
||||||
btn.addEventListener("click", () => {
|
|
||||||
aprsTypeFilter = type;
|
|
||||||
renderAprsHistory();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (aprsFilterInput) {
|
|
||||||
aprsFilterInput.addEventListener("input", () => {
|
|
||||||
aprsFilterText = aprsFilterInput.value.trim().toUpperCase();
|
|
||||||
renderAprsHistory();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Server-side APRS decode handler ---
|
|
||||||
window.onServerAprs = function(pkt) {
|
|
||||||
aprsStatus.textContent = "Receiving";
|
|
||||||
addAprsPacket(normalizeServerAprsPacket(pkt));
|
|
||||||
};
|
|
||||||
|
|
||||||
renderAprsHistory();
|
|
||||||
if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("aprs");
|
|
||||||
-410
@@ -1,410 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
(function () {
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
function bgdSupportedIds() {
|
|
||||||
return (window.decoderRegistry || [])
|
|
||||||
.filter(function (d) { return d.background_decode; })
|
|
||||||
.map(function (d) { return d.id; });
|
|
||||||
}
|
|
||||||
|
|
||||||
let backgroundDecodeRole = null;
|
|
||||||
let currentRigId = null;
|
|
||||||
let currentConfig = null;
|
|
||||||
let bookmarkList = [];
|
|
||||||
let statusInterval = null;
|
|
||||||
let bgdDirty = false;
|
|
||||||
|
|
||||||
function initBackgroundDecode(rigId, role) {
|
|
||||||
backgroundDecodeRole = role;
|
|
||||||
currentRigId = rigId || null;
|
|
||||||
if (currentRigId) loadBackgroundDecode();
|
|
||||||
startStatusPolling();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setBackgroundDecodeRig(rigId) {
|
|
||||||
const nextRigId = rigId || null;
|
|
||||||
if (nextRigId === currentRigId) return;
|
|
||||||
currentRigId = nextRigId;
|
|
||||||
if (!currentRigId) return;
|
|
||||||
loadBackgroundDecode();
|
|
||||||
}
|
|
||||||
|
|
||||||
function apiGetConfig(rigId) {
|
|
||||||
return fetch("/background-decode/" + encodeURIComponent(rigId)).then(function (r) {
|
|
||||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
|
||||||
return r.json();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function apiPutConfig(rigId, config) {
|
|
||||||
return fetch("/background-decode/" + encodeURIComponent(rigId), {
|
|
||||||
method: "PUT",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(config),
|
|
||||||
}).then(function (r) {
|
|
||||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
|
||||||
return r.json();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function apiResetConfig(rigId) {
|
|
||||||
return fetch("/background-decode/" + encodeURIComponent(rigId), {
|
|
||||||
method: "DELETE",
|
|
||||||
}).then(function (r) {
|
|
||||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
|
||||||
return r.json();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function apiGetStatus(rigId) {
|
|
||||||
return fetch("/background-decode/" + encodeURIComponent(rigId) + "/status").then(function (r) {
|
|
||||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
|
||||||
return r.json();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function apiGetBookmarks() {
|
|
||||||
return fetch("/bookmarks").then(function (r) {
|
|
||||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
|
||||||
return r.json();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadBackgroundDecode() {
|
|
||||||
const rigId = currentRigId;
|
|
||||||
if (!rigId) return;
|
|
||||||
Promise.all([apiGetConfig(rigId), apiGetBookmarks()])
|
|
||||||
.then(function ([config, bookmarks]) {
|
|
||||||
currentConfig = config || { remote: rigId, enabled: false, bookmark_ids: [] };
|
|
||||||
bookmarkList = Array.isArray(bookmarks) ? bookmarks : [];
|
|
||||||
renderBackgroundDecode();
|
|
||||||
clearBgdDirty();
|
|
||||||
pollBackgroundDecodeStatus();
|
|
||||||
})
|
|
||||||
.catch(function (err) {
|
|
||||||
console.error("background decode load failed", err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function supportedBookmarks() {
|
|
||||||
return bookmarkList.filter(function (bookmark) {
|
|
||||||
return bookmarkDecoderKinds(bookmark).length > 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function bookmarkDecoderKinds(bookmark) {
|
|
||||||
var ids = bgdSupportedIds();
|
|
||||||
var decoders = Array.isArray(bookmark && bookmark.decoders) ? bookmark.decoders : [];
|
|
||||||
var explicit = decoders
|
|
||||||
.map(function (item) { return String(item || "").trim().toLowerCase(); })
|
|
||||||
.filter(function (item, index, arr) {
|
|
||||||
return ids.indexOf(item) >= 0 && arr.indexOf(item) === index;
|
|
||||||
});
|
|
||||||
if (explicit.length > 0) return explicit;
|
|
||||||
// Fall back: infer from mode via mode-bound entries in the registry.
|
|
||||||
var mode = String(bookmark && bookmark.mode || "").trim().toUpperCase();
|
|
||||||
return (window.decoderRegistry || [])
|
|
||||||
.filter(function (d) {
|
|
||||||
return d.activation === "mode_bound" && d.background_decode
|
|
||||||
&& d.active_modes.indexOf(mode) >= 0;
|
|
||||||
})
|
|
||||||
.map(function (d) { return d.id; });
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderBackgroundDecode() {
|
|
||||||
if (!currentConfig) {
|
|
||||||
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
|
||||||
}
|
|
||||||
setCheckbox("background-decode-enabled", !!currentConfig.enabled);
|
|
||||||
renderBookmarkChecklist();
|
|
||||||
|
|
||||||
const isControl = backgroundDecodeRole === "control" || (typeof authEnabled !== "undefined" && !authEnabled);
|
|
||||||
const panel = document.getElementById("background-decode-panel");
|
|
||||||
if (panel) {
|
|
||||||
panel.querySelectorAll("input, select, button.sch-write").forEach(function (el) {
|
|
||||||
el.disabled = !isControl;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const saveBtn = document.getElementById("background-decode-save-btn");
|
|
||||||
const resetBtn = document.getElementById("background-decode-reset-btn");
|
|
||||||
if (saveBtn) saveBtn.style.display = isControl ? "" : "none";
|
|
||||||
if (resetBtn) resetBtn.style.display = isControl ? "" : "none";
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderBookmarkChecklist(filterText) {
|
|
||||||
const container = document.getElementById("bgd-bookmark-checklist");
|
|
||||||
if (!container) return;
|
|
||||||
container.innerHTML = "";
|
|
||||||
|
|
||||||
const selectedIds = new Set(
|
|
||||||
currentConfig && Array.isArray(currentConfig.bookmark_ids) ? currentConfig.bookmark_ids : []
|
|
||||||
);
|
|
||||||
const all = supportedBookmarks();
|
|
||||||
const filter = (filterText || "").trim().toLowerCase();
|
|
||||||
|
|
||||||
const filtered = filter
|
|
||||||
? all.filter(function (bm) {
|
|
||||||
var text = (bm.name + " " + formatFreq(bm.freq_hz) + " " + bm.mode).toLowerCase();
|
|
||||||
return text.indexOf(filter) >= 0;
|
|
||||||
})
|
|
||||||
: all;
|
|
||||||
|
|
||||||
if (filtered.length === 0) {
|
|
||||||
container.innerHTML = '<div class="bgd-checklist-empty">' +
|
|
||||||
(all.length === 0 ? "No supported bookmarks available." : "No bookmarks match filter.") +
|
|
||||||
'</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
filtered.forEach(function (bookmark) {
|
|
||||||
var row = document.createElement("label");
|
|
||||||
row.className = "bgd-checklist-row";
|
|
||||||
var decoders = bookmarkDecoderKinds(bookmark);
|
|
||||||
var checked = selectedIds.has(bookmark.id) ? " checked" : "";
|
|
||||||
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.querySelector("input").addEventListener("change", function (e) {
|
|
||||||
onChecklistToggle(bookmark.id, e.target.checked);
|
|
||||||
});
|
|
||||||
container.appendChild(row);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function onChecklistToggle(bookmarkId, checked) {
|
|
||||||
if (!currentConfig) {
|
|
||||||
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
|
||||||
}
|
|
||||||
if (!Array.isArray(currentConfig.bookmark_ids)) currentConfig.bookmark_ids = [];
|
|
||||||
if (checked && !currentConfig.bookmark_ids.includes(bookmarkId)) {
|
|
||||||
currentConfig.bookmark_ids.push(bookmarkId);
|
|
||||||
} else if (!checked) {
|
|
||||||
currentConfig.bookmark_ids = currentConfig.bookmark_ids.filter(function (id) { return id !== bookmarkId; });
|
|
||||||
}
|
|
||||||
markBgdDirty();
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveBackgroundDecode() {
|
|
||||||
const rigId = currentRigId;
|
|
||||||
if (!rigId) return;
|
|
||||||
const payload = {
|
|
||||||
remote: rigId,
|
|
||||||
enabled: !!document.getElementById("background-decode-enabled").checked,
|
|
||||||
bookmark_ids: Array.isArray(currentConfig && currentConfig.bookmark_ids) ? currentConfig.bookmark_ids.slice() : [],
|
|
||||||
};
|
|
||||||
const btn = document.getElementById("background-decode-save-btn");
|
|
||||||
if (btn) btn.disabled = true;
|
|
||||||
apiPutConfig(rigId, payload)
|
|
||||||
.then(function (saved) {
|
|
||||||
currentConfig = saved;
|
|
||||||
renderBackgroundDecode();
|
|
||||||
clearBgdDirty();
|
|
||||||
pollBackgroundDecodeStatus();
|
|
||||||
showToast("Background decode saved.");
|
|
||||||
})
|
|
||||||
.catch(function (err) {
|
|
||||||
showToast("Save failed: " + err.message, true);
|
|
||||||
})
|
|
||||||
.finally(function () {
|
|
||||||
if (btn) btn.disabled = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetBackgroundDecode() {
|
|
||||||
const rigId = currentRigId;
|
|
||||||
if (!rigId) return;
|
|
||||||
if (!confirm("Reset background decode configuration? This cannot be undone.")) return;
|
|
||||||
apiResetConfig(rigId)
|
|
||||||
.then(function (saved) {
|
|
||||||
currentConfig = saved;
|
|
||||||
renderBackgroundDecode();
|
|
||||||
clearBgdDirty();
|
|
||||||
pollBackgroundDecodeStatus();
|
|
||||||
showToast("Background decode reset.");
|
|
||||||
})
|
|
||||||
.catch(function (err) {
|
|
||||||
showToast("Reset failed: " + err.message, true);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function startStatusPolling() {
|
|
||||||
if (statusInterval) clearInterval(statusInterval);
|
|
||||||
statusInterval = setInterval(pollBackgroundDecodeStatus, 15000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function pollBackgroundDecodeStatus() {
|
|
||||||
const rigId = currentRigId;
|
|
||||||
if (!rigId) return;
|
|
||||||
apiGetStatus(rigId)
|
|
||||||
.then(renderStatus)
|
|
||||||
.catch(function () {});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderStatus(status) {
|
|
||||||
const card = document.getElementById("background-decode-status-card");
|
|
||||||
if (!card) return;
|
|
||||||
const entries = Array.isArray(status && status.entries) ? status.entries : [];
|
|
||||||
if (!entries.length) {
|
|
||||||
card.textContent = "No background decode bookmarks configured.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const summary = [];
|
|
||||||
if (status.active_rig) {
|
|
||||||
if (Number.isFinite(status.center_hz)) summary.push("Center " + formatFreq(status.center_hz));
|
|
||||||
if (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.");
|
|
||||||
}
|
|
||||||
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 (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) {
|
|
||||||
switch (state) {
|
|
||||||
case "active": return "\u2713 Active";
|
|
||||||
case "out_of_span": return "\u25B3 Out of span";
|
|
||||||
case "waiting_for_spectrum": return "\u25B3 Waiting";
|
|
||||||
case "waiting_for_user": return "\u25B3 No user";
|
|
||||||
case "missing_bookmark": return "\u2717 Missing";
|
|
||||||
case "no_supported_decoders": return "\u2717 Unsupported";
|
|
||||||
case "disabled": return "\u25B3 Disabled";
|
|
||||||
case "handled_by_scheduler": return "\u25B3 Scheduler";
|
|
||||||
case "scheduler_has_control": return "\u25B3 Scheduler";
|
|
||||||
case "handled_by_virtual_channel": return "\u25B3 VChan";
|
|
||||||
default: return "\u25B3 Inactive";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setCheckbox(id, value) {
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
if (el) el.checked = !!value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatFreq(hz) {
|
|
||||||
if (!Number.isFinite(hz) || hz <= 0) return "--";
|
|
||||||
if (hz >= 1e6) return (hz / 1e6).toFixed(3).replace(/\.?0+$/, "") + " MHz";
|
|
||||||
if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + " kHz";
|
|
||||||
return hz + " Hz";
|
|
||||||
}
|
|
||||||
|
|
||||||
function escHtml(value) {
|
|
||||||
return String(value == null ? "" : value)
|
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, """);
|
|
||||||
}
|
|
||||||
|
|
||||||
function markBgdDirty() {
|
|
||||||
if (bgdDirty) return;
|
|
||||||
bgdDirty = true;
|
|
||||||
var btn = document.getElementById("background-decode-save-btn");
|
|
||||||
if (btn) btn.classList.add("sch-dirty");
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearBgdDirty() {
|
|
||||||
bgdDirty = false;
|
|
||||||
var btn = document.getElementById("background-decode-save-btn");
|
|
||||||
if (btn) btn.classList.remove("sch-dirty");
|
|
||||||
}
|
|
||||||
|
|
||||||
function showToast(msg, isError) {
|
|
||||||
const el = document.getElementById("background-decode-toast");
|
|
||||||
if (!el) return;
|
|
||||||
el.textContent = msg;
|
|
||||||
el.style.background = isError ? "var(--color-error, #c00)" : "var(--accent-green)";
|
|
||||||
el.style.display = "block";
|
|
||||||
setTimeout(function () {
|
|
||||||
el.style.display = "none";
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectAllBookmarks() {
|
|
||||||
if (!currentConfig) {
|
|
||||||
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
|
||||||
}
|
|
||||||
var ids = supportedBookmarks().map(function (bm) { return bm.id; });
|
|
||||||
currentConfig.bookmark_ids = ids;
|
|
||||||
renderBookmarkChecklist(document.getElementById("bgd-bookmark-filter")?.value);
|
|
||||||
markBgdDirty();
|
|
||||||
}
|
|
||||||
|
|
||||||
function deselectAllBookmarks() {
|
|
||||||
if (!currentConfig) {
|
|
||||||
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
|
||||||
}
|
|
||||||
currentConfig.bookmark_ids = [];
|
|
||||||
renderBookmarkChecklist(document.getElementById("bgd-bookmark-filter")?.value);
|
|
||||||
markBgdDirty();
|
|
||||||
}
|
|
||||||
|
|
||||||
function wireBackgroundDecodeEvents() {
|
|
||||||
const filterInput = document.getElementById("bgd-bookmark-filter");
|
|
||||||
if (filterInput && !filterInput._wired) {
|
|
||||||
filterInput._wired = true;
|
|
||||||
filterInput.addEventListener("input", function () {
|
|
||||||
renderBookmarkChecklist(filterInput.value);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const enabledCb = document.getElementById("background-decode-enabled");
|
|
||||||
if (enabledCb && !enabledCb._wired) {
|
|
||||||
enabledCb._wired = true;
|
|
||||||
enabledCb.addEventListener("change", function () { markBgdDirty(); });
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectAllBtn = document.getElementById("bgd-select-all-btn");
|
|
||||||
if (selectAllBtn && !selectAllBtn._wired) {
|
|
||||||
selectAllBtn._wired = true;
|
|
||||||
selectAllBtn.addEventListener("click", selectAllBookmarks);
|
|
||||||
}
|
|
||||||
|
|
||||||
const deselectAllBtn = document.getElementById("bgd-deselect-all-btn");
|
|
||||||
if (deselectAllBtn && !deselectAllBtn._wired) {
|
|
||||||
deselectAllBtn._wired = true;
|
|
||||||
deselectAllBtn.addEventListener("click", deselectAllBookmarks);
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveBtn = document.getElementById("background-decode-save-btn");
|
|
||||||
if (saveBtn && !saveBtn._wired) {
|
|
||||||
saveBtn._wired = true;
|
|
||||||
saveBtn.addEventListener("click", saveBackgroundDecode);
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetBtn = document.getElementById("background-decode-reset-btn");
|
|
||||||
if (resetBtn && !resetBtn._wired) {
|
|
||||||
resetBtn._wired = true;
|
|
||||||
resetBtn.addEventListener("click", resetBackgroundDecode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.initBackgroundDecode = initBackgroundDecode;
|
|
||||||
window.wireBackgroundDecodeEvents = wireBackgroundDecodeEvents;
|
|
||||||
window.setBackgroundDecodeRig = setBackgroundDecodeRig;
|
|
||||||
})();
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
// --- FT2 Decoder Plugin (server-side decode) ---
|
|
||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
function ft8RenderMessageFt2(message) {
|
|
||||||
if (typeof renderFt8Message === "function") return renderFt8Message(message);
|
|
||||||
if (typeof ft8EscapeHtml === "function") return ft8EscapeHtml(message);
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ft2Status = document.getElementById("ft2-status");
|
|
||||||
const ft2PeriodEl = document.getElementById("ft2-period");
|
|
||||||
const ft2MessagesEl = document.getElementById("ft2-messages");
|
|
||||||
const ft2FilterInput = document.getElementById("ft2-filter");
|
|
||||||
const FT2_PERIOD_MS = 3750;
|
|
||||||
const FT2_MAX_DOM_ROWS = 200;
|
|
||||||
let ft2FilterText = "";
|
|
||||||
let ft2MessageHistory = [];
|
|
||||||
|
|
||||||
function currentFt2HistoryRetentionMs() {
|
|
||||||
return typeof window.getDecodeHistoryRetentionMs === "function"
|
|
||||||
? window.getDecodeHistoryRetentionMs()
|
|
||||||
: 24 * 60 * 60 * 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pruneFt2MessageHistory() {
|
|
||||||
const cutoffMs = Date.now() - currentFt2HistoryRetentionMs();
|
|
||||||
ft2MessageHistory = ft2MessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleFt2Ui(key, job) {
|
|
||||||
if (typeof window.trxScheduleUiFrameJob === "function") {
|
|
||||||
window.trxScheduleUiFrameJob(key, job);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
job();
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleFt2HistoryRender() { scheduleFt2Ui("ft2-history", () => renderFt2History()); }
|
|
||||||
|
|
||||||
function normalizeFt2DisplayFreqHz(freqHz) {
|
|
||||||
const rawHz = Number(freqHz);
|
|
||||||
if (!Number.isFinite(rawHz)) return null;
|
|
||||||
const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null;
|
|
||||||
if (Number.isFinite(baseHz) && baseHz > 0 && rawHz >= 0 && rawHz < 100000) {
|
|
||||||
return baseHz + rawHz;
|
|
||||||
}
|
|
||||||
return rawHz;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateFt2PeriodTimer() {
|
|
||||||
if (!ft2PeriodEl) return;
|
|
||||||
const nowMs = Date.now();
|
|
||||||
const remaining = (FT2_PERIOD_MS - nowMs % FT2_PERIOD_MS) / 1000;
|
|
||||||
ft2PeriodEl.textContent = `Next slot ${remaining.toFixed(1)}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateFt2PeriodTimer();
|
|
||||||
setInterval(updateFt2PeriodTimer, 250);
|
|
||||||
|
|
||||||
function renderFt2Row(msg) {
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = "ft8-row";
|
|
||||||
const rawMessage = (msg.message || "").toString();
|
|
||||||
row.dataset.message = rawMessage.toUpperCase();
|
|
||||||
row.dataset.decoder = "ft2";
|
|
||||||
row.dataset.storedFreqHz = Number.isFinite(msg.freq_hz) ? String(msg.freq_hz) : "";
|
|
||||||
const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--";
|
|
||||||
const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--";
|
|
||||||
const displayFreqHz = normalizeFt2DisplayFreqHz(msg.freq_hz);
|
|
||||||
const freq = Number.isFinite(displayFreqHz) ? displayFreqHz.toFixed(0) : "--";
|
|
||||||
const renderedMessage = ft8RenderMessageFt2(rawMessage);
|
|
||||||
const tsMs = msg._tsMs ?? msg.ts_ms;
|
|
||||||
const timeStr = tsMs ? new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "--:--:--";
|
|
||||||
row.innerHTML = `<span class="ft8-time">${timeStr}</span><span class="ft8-snr">${snr}</span><span class="ft8-dt">${dt}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderedMessage}</span>`;
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderFt2History() {
|
|
||||||
pruneFt2MessageHistory();
|
|
||||||
if (!ft2MessagesEl) return;
|
|
||||||
const filter = ft2FilterText;
|
|
||||||
const fragment = document.createDocumentFragment();
|
|
||||||
let rendered = 0;
|
|
||||||
for (let i = 0; i < ft2MessageHistory.length && rendered < FT2_MAX_DOM_ROWS; i++) {
|
|
||||||
const msg = ft2MessageHistory[i];
|
|
||||||
if (filter && !(msg.message || "").toString().toUpperCase().includes(filter)) continue;
|
|
||||||
fragment.appendChild(renderFt2Row(msg));
|
|
||||||
rendered++;
|
|
||||||
}
|
|
||||||
ft2MessagesEl.replaceChildren(fragment);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addFt2Message(msg) {
|
|
||||||
msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
|
||||||
ft2MessageHistory.unshift(msg);
|
|
||||||
pruneFt2MessageHistory();
|
|
||||||
window.setFt8FamilyBarDecoder?.("ft2");
|
|
||||||
window.updateFt8Bar?.();
|
|
||||||
scheduleFt2HistoryRender();
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeServerFt2Message(msg) {
|
|
||||||
const raw = (msg.message || "").toString();
|
|
||||||
const locatorDetails = typeof ft8ExtractLocatorDetails === "function" ? ft8ExtractLocatorDetails(raw) : [];
|
|
||||||
const grids = locatorDetails.length > 0
|
|
||||||
? locatorDetails.map((d) => d.grid)
|
|
||||||
: (typeof ft8ExtractAllGrids === "function" ? ft8ExtractAllGrids(raw) : []);
|
|
||||||
const station = typeof ft8ExtractLikelyCallsign === "function" ? ft8ExtractLikelyCallsign(raw) : null;
|
|
||||||
const rfHz = normalizeFt2DisplayFreqHz(msg.freq_hz);
|
|
||||||
return {
|
|
||||||
raw, grids, station, rfHz, locatorDetails,
|
|
||||||
history: {
|
|
||||||
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
|
|
||||||
ts_ms: msg.ts_ms, snr_db: msg.snr_db, dt_s: msg.dt_s,
|
|
||||||
freq_hz: Number.isFinite(rfHz) ? rfHz : msg.freq_hz,
|
|
||||||
message: msg.message,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
window.onServerFt2Batch = function(messages) {
|
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
|
||||||
if (ft2Status) ft2Status.textContent = "Receiving";
|
|
||||||
const normalized = [];
|
|
||||||
for (const msg of messages) {
|
|
||||||
const next = normalizeServerFt2Message(msg);
|
|
||||||
if (next.grids.length > 0 && window.mapAddLocator) {
|
|
||||||
window.mapAddLocator(next.raw, next.grids, "ft2", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails });
|
|
||||||
}
|
|
||||||
next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
|
||||||
normalized.push(next.history);
|
|
||||||
}
|
|
||||||
normalized.reverse();
|
|
||||||
ft2MessageHistory = normalized.concat(ft2MessageHistory);
|
|
||||||
pruneFt2MessageHistory();
|
|
||||||
window.setFt8FamilyBarDecoder?.("ft2");
|
|
||||||
window.updateFt8Bar?.();
|
|
||||||
scheduleFt2HistoryRender();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.restoreFt2History = function(messages) { window.onServerFt2Batch(messages); };
|
|
||||||
window.pruneFt2HistoryView = function() { pruneFt2MessageHistory(); renderFt2History(); };
|
|
||||||
|
|
||||||
window.resetFt2HistoryView = function() {
|
|
||||||
if (ft2MessagesEl) ft2MessagesEl.innerHTML = "";
|
|
||||||
ft2MessageHistory = [];
|
|
||||||
window.updateFt8Bar?.();
|
|
||||||
renderFt2History();
|
|
||||||
};
|
|
||||||
|
|
||||||
function buildFt2BarFrames() {
|
|
||||||
const cutoffMs = Date.now() - 15 * 60 * 1000;
|
|
||||||
const messages = ft2MessageHistory.filter((msg) => Number(msg._tsMs ?? msg.ts_ms) >= cutoffMs).slice(0, 8);
|
|
||||||
const newestTsMs = messages.reduce((latest, msg) => Math.max(latest, Number(msg._tsMs ?? msg.ts_ms) || 0), 0);
|
|
||||||
if (messages.length === 0) {
|
|
||||||
return { count: 0, newestTsMs: 0, html: "" };
|
|
||||||
}
|
|
||||||
let html = "";
|
|
||||||
for (const msg of messages) {
|
|
||||||
const tsMs = msg._tsMs ?? msg.ts_ms;
|
|
||||||
const ts = tsMs ? `<span class="aprs-bar-time">${fmtTime(tsMs)}</span>` : "";
|
|
||||||
const snr = Number.isFinite(msg.snr_db) ? `${msg.snr_db.toFixed(1)} dB` : "-- dB";
|
|
||||||
const dt = Number.isFinite(msg.dt_s) ? `dt ${msg.dt_s.toFixed(2)}` : null;
|
|
||||||
const displayFreqHz = normalizeFt2DisplayFreqHz(msg.freq_hz);
|
|
||||||
const rf = Number.isFinite(displayFreqHz) ? `${displayFreqHz.toFixed(0)} Hz` : null;
|
|
||||||
const detail = [snr, dt, rf].filter(Boolean).join(" · ");
|
|
||||||
const text = ft8RenderMessageFt2((msg.message || "").toString());
|
|
||||||
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}<span class="aprs-bar-call">${text}</span>${detail ? ` · ${detail}` : ""}</div></div>`;
|
|
||||||
}
|
|
||||||
return { count: messages.length, newestTsMs, html };
|
|
||||||
}
|
|
||||||
window.registerFt8FamilyBarRenderer?.("ft2", buildFt2BarFrames);
|
|
||||||
|
|
||||||
if (ft2FilterInput) {
|
|
||||||
ft2FilterInput.addEventListener("input", () => {
|
|
||||||
ft2FilterText = ft2FilterInput.value.trim().toUpperCase();
|
|
||||||
renderFt2History();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const ft2DecodeToggleBtn = document.getElementById("ft2-decode-toggle-btn");
|
|
||||||
ft2DecodeToggleBtn?.addEventListener("click", async () => {
|
|
||||||
try {
|
|
||||||
await window.takeSchedulerControlForDecoderDisable?.(ft2DecodeToggleBtn);
|
|
||||||
await postPath("/toggle_ft2_decode");
|
|
||||||
} catch (e) {
|
|
||||||
console.error("FT2 toggle failed", e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById("settings-clear-ft2-history")?.addEventListener("click", async () => {
|
|
||||||
if (!confirm("Clear all FT2 decode history? This cannot be undone.")) return;
|
|
||||||
try {
|
|
||||||
await postPath("/clear_ft2_decode");
|
|
||||||
window.resetFt2HistoryView();
|
|
||||||
} catch (e) { console.error("FT2 history clear failed", e); }
|
|
||||||
});
|
|
||||||
|
|
||||||
window.onServerFt2 = function(msg) {
|
|
||||||
if (ft2Status) ft2Status.textContent = "Receiving";
|
|
||||||
const next = normalizeServerFt2Message(msg);
|
|
||||||
if (next.grids.length > 0 && window.mapAddLocator) {
|
|
||||||
window.mapAddLocator(next.raw, next.grids, "ft2", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails });
|
|
||||||
}
|
|
||||||
addFt2Message(next.history);
|
|
||||||
};
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
// --- FT4 Decoder Plugin (server-side decode) ---
|
|
||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
function ft8RenderMessage(message) {
|
|
||||||
if (typeof renderFt8Message === "function") return renderFt8Message(message);
|
|
||||||
if (typeof ft8EscapeHtml === "function") return ft8EscapeHtml(message);
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ft4Status = document.getElementById("ft4-status");
|
|
||||||
const ft4PeriodEl = document.getElementById("ft4-period");
|
|
||||||
const ft4MessagesEl = document.getElementById("ft4-messages");
|
|
||||||
const ft4FilterInput = document.getElementById("ft4-filter");
|
|
||||||
const FT4_PERIOD_MS = 7500;
|
|
||||||
const FT4_MAX_DOM_ROWS = 200;
|
|
||||||
let ft4FilterText = "";
|
|
||||||
let ft4MessageHistory = [];
|
|
||||||
|
|
||||||
function currentFt4HistoryRetentionMs() {
|
|
||||||
return typeof window.getDecodeHistoryRetentionMs === "function"
|
|
||||||
? window.getDecodeHistoryRetentionMs()
|
|
||||||
: 24 * 60 * 60 * 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pruneFt4MessageHistory() {
|
|
||||||
const cutoffMs = Date.now() - currentFt4HistoryRetentionMs();
|
|
||||||
ft4MessageHistory = ft4MessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleFt4Ui(key, job) {
|
|
||||||
if (typeof window.trxScheduleUiFrameJob === "function") {
|
|
||||||
window.trxScheduleUiFrameJob(key, job);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
job();
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleFt4HistoryRender() { scheduleFt4Ui("ft4-history", () => renderFt4History()); }
|
|
||||||
|
|
||||||
function normalizeFt4DisplayFreqHz(freqHz) {
|
|
||||||
const rawHz = Number(freqHz);
|
|
||||||
if (!Number.isFinite(rawHz)) return null;
|
|
||||||
const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null;
|
|
||||||
if (Number.isFinite(baseHz) && baseHz > 0 && rawHz >= 0 && rawHz < 100000) {
|
|
||||||
return baseHz + rawHz;
|
|
||||||
}
|
|
||||||
return rawHz;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateFt4PeriodTimer() {
|
|
||||||
if (!ft4PeriodEl) return;
|
|
||||||
const nowMs = Date.now();
|
|
||||||
const remaining = (FT4_PERIOD_MS - nowMs % FT4_PERIOD_MS) / 1000;
|
|
||||||
ft4PeriodEl.textContent = `Next slot ${remaining.toFixed(1)}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateFt4PeriodTimer();
|
|
||||||
setInterval(updateFt4PeriodTimer, 250);
|
|
||||||
|
|
||||||
function renderFt4Row(msg) {
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = "ft8-row";
|
|
||||||
const rawMessage = (msg.message || "").toString();
|
|
||||||
row.dataset.message = rawMessage.toUpperCase();
|
|
||||||
row.dataset.decoder = "ft4";
|
|
||||||
row.dataset.storedFreqHz = Number.isFinite(msg.freq_hz) ? String(msg.freq_hz) : "";
|
|
||||||
const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--";
|
|
||||||
const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--";
|
|
||||||
const displayFreqHz = normalizeFt4DisplayFreqHz(msg.freq_hz);
|
|
||||||
const freq = Number.isFinite(displayFreqHz) ? displayFreqHz.toFixed(0) : "--";
|
|
||||||
const renderedMessage = ft8RenderMessage(rawMessage);
|
|
||||||
const tsMs = msg._tsMs ?? msg.ts_ms;
|
|
||||||
const timeStr = tsMs ? new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "--:--:--";
|
|
||||||
row.innerHTML = `<span class="ft8-time">${timeStr}</span><span class="ft8-snr">${snr}</span><span class="ft8-dt">${dt}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderedMessage}</span>`;
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderFt4History() {
|
|
||||||
pruneFt4MessageHistory();
|
|
||||||
if (!ft4MessagesEl) return;
|
|
||||||
const filter = ft4FilterText;
|
|
||||||
const fragment = document.createDocumentFragment();
|
|
||||||
let rendered = 0;
|
|
||||||
for (let i = 0; i < ft4MessageHistory.length && rendered < FT4_MAX_DOM_ROWS; i++) {
|
|
||||||
const msg = ft4MessageHistory[i];
|
|
||||||
if (filter && !(msg.message || "").toString().toUpperCase().includes(filter)) continue;
|
|
||||||
fragment.appendChild(renderFt4Row(msg));
|
|
||||||
rendered++;
|
|
||||||
}
|
|
||||||
ft4MessagesEl.replaceChildren(fragment);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addFt4Message(msg) {
|
|
||||||
msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
|
||||||
ft4MessageHistory.unshift(msg);
|
|
||||||
pruneFt4MessageHistory();
|
|
||||||
window.setFt8FamilyBarDecoder?.("ft4");
|
|
||||||
window.updateFt8Bar?.();
|
|
||||||
scheduleFt4HistoryRender();
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeServerFt4Message(msg) {
|
|
||||||
const raw = (msg.message || "").toString();
|
|
||||||
const locatorDetails = typeof ft8ExtractLocatorDetails === "function" ? ft8ExtractLocatorDetails(raw) : [];
|
|
||||||
const grids = locatorDetails.length > 0
|
|
||||||
? locatorDetails.map((d) => d.grid)
|
|
||||||
: (typeof ft8ExtractAllGrids === "function" ? ft8ExtractAllGrids(raw) : []);
|
|
||||||
const station = typeof ft8ExtractLikelyCallsign === "function" ? ft8ExtractLikelyCallsign(raw) : null;
|
|
||||||
const rfHz = normalizeFt4DisplayFreqHz(msg.freq_hz);
|
|
||||||
return {
|
|
||||||
raw, grids, station, rfHz, locatorDetails,
|
|
||||||
history: {
|
|
||||||
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
|
|
||||||
ts_ms: msg.ts_ms, snr_db: msg.snr_db, dt_s: msg.dt_s,
|
|
||||||
freq_hz: Number.isFinite(rfHz) ? rfHz : msg.freq_hz,
|
|
||||||
message: msg.message,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
window.onServerFt4Batch = function(messages) {
|
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
|
||||||
if (ft4Status) ft4Status.textContent = "Receiving";
|
|
||||||
const normalized = [];
|
|
||||||
for (const msg of messages) {
|
|
||||||
const next = normalizeServerFt4Message(msg);
|
|
||||||
if (next.grids.length > 0 && window.mapAddLocator) {
|
|
||||||
window.mapAddLocator(next.raw, next.grids, "ft4", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails });
|
|
||||||
}
|
|
||||||
next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
|
||||||
normalized.push(next.history);
|
|
||||||
}
|
|
||||||
normalized.reverse();
|
|
||||||
ft4MessageHistory = normalized.concat(ft4MessageHistory);
|
|
||||||
pruneFt4MessageHistory();
|
|
||||||
window.setFt8FamilyBarDecoder?.("ft4");
|
|
||||||
window.updateFt8Bar?.();
|
|
||||||
scheduleFt4HistoryRender();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.restoreFt4History = function(messages) { window.onServerFt4Batch(messages); };
|
|
||||||
window.pruneFt4HistoryView = function() { pruneFt4MessageHistory(); renderFt4History(); };
|
|
||||||
|
|
||||||
window.resetFt4HistoryView = function() {
|
|
||||||
if (ft4MessagesEl) ft4MessagesEl.innerHTML = "";
|
|
||||||
ft4MessageHistory = [];
|
|
||||||
window.updateFt8Bar?.();
|
|
||||||
renderFt4History();
|
|
||||||
};
|
|
||||||
|
|
||||||
function buildFt4BarFrames() {
|
|
||||||
const cutoffMs = Date.now() - 15 * 60 * 1000;
|
|
||||||
const messages = ft4MessageHistory.filter((msg) => Number(msg._tsMs ?? msg.ts_ms) >= cutoffMs).slice(0, 8);
|
|
||||||
const newestTsMs = messages.reduce((latest, msg) => Math.max(latest, Number(msg._tsMs ?? msg.ts_ms) || 0), 0);
|
|
||||||
if (messages.length === 0) {
|
|
||||||
return { count: 0, newestTsMs: 0, html: "" };
|
|
||||||
}
|
|
||||||
let html = "";
|
|
||||||
for (const msg of messages) {
|
|
||||||
const tsMs = msg._tsMs ?? msg.ts_ms;
|
|
||||||
const ts = tsMs ? `<span class="aprs-bar-time">${fmtTime(tsMs)}</span>` : "";
|
|
||||||
const snr = Number.isFinite(msg.snr_db) ? `${msg.snr_db.toFixed(1)} dB` : "-- dB";
|
|
||||||
const dt = Number.isFinite(msg.dt_s) ? `dt ${msg.dt_s.toFixed(2)}` : null;
|
|
||||||
const displayFreqHz = normalizeFt4DisplayFreqHz(msg.freq_hz);
|
|
||||||
const rf = Number.isFinite(displayFreqHz) ? `${displayFreqHz.toFixed(0)} Hz` : null;
|
|
||||||
const detail = [snr, dt, rf].filter(Boolean).join(" · ");
|
|
||||||
const text = ft8RenderMessage((msg.message || "").toString());
|
|
||||||
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}<span class="aprs-bar-call">${text}</span>${detail ? ` · ${detail}` : ""}</div></div>`;
|
|
||||||
}
|
|
||||||
return { count: messages.length, newestTsMs, html };
|
|
||||||
}
|
|
||||||
window.registerFt8FamilyBarRenderer?.("ft4", buildFt4BarFrames);
|
|
||||||
|
|
||||||
if (ft4FilterInput) {
|
|
||||||
ft4FilterInput.addEventListener("input", () => {
|
|
||||||
ft4FilterText = ft4FilterInput.value.trim().toUpperCase();
|
|
||||||
renderFt4History();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const ft4DecodeToggleBtn = document.getElementById("ft4-decode-toggle-btn");
|
|
||||||
ft4DecodeToggleBtn?.addEventListener("click", async () => {
|
|
||||||
try {
|
|
||||||
await window.takeSchedulerControlForDecoderDisable?.(ft4DecodeToggleBtn);
|
|
||||||
await postPath("/toggle_ft4_decode");
|
|
||||||
} catch (e) {
|
|
||||||
console.error("FT4 toggle failed", e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById("settings-clear-ft4-history")?.addEventListener("click", async () => {
|
|
||||||
if (!confirm("Clear all FT4 decode history? This cannot be undone.")) return;
|
|
||||||
try {
|
|
||||||
await postPath("/clear_ft4_decode");
|
|
||||||
window.resetFt4HistoryView();
|
|
||||||
} catch (e) { console.error("FT4 history clear failed", e); }
|
|
||||||
});
|
|
||||||
|
|
||||||
window.onServerFt4 = function(msg) {
|
|
||||||
if (ft4Status) ft4Status.textContent = "Receiving";
|
|
||||||
const next = normalizeServerFt4Message(msg);
|
|
||||||
if (next.grids.length > 0 && window.mapAddLocator) {
|
|
||||||
window.mapAddLocator(next.raw, next.grids, "ft4", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails });
|
|
||||||
}
|
|
||||||
addFt4Message(next.history);
|
|
||||||
};
|
|
||||||
@@ -1,486 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
// --- FT8 Decoder Plugin (server-side decode) ---
|
|
||||||
const ft8Status = document.getElementById("ft8-status");
|
|
||||||
const ft8PeriodEl = document.getElementById("ft8-period");
|
|
||||||
const ft8MessagesEl = document.getElementById("ft8-messages");
|
|
||||||
const ft8FilterInput = document.getElementById("ft8-filter");
|
|
||||||
const ft8BarOverlay = document.getElementById("ft8-bar-overlay");
|
|
||||||
const FT8_BAR_WINDOW_MS = 15 * 60 * 1000;
|
|
||||||
const FT8_PERIOD_SECONDS = 15;
|
|
||||||
const FT8_MAX_DOM_ROWS = 200;
|
|
||||||
const FT8_BAR_DECODER_LABELS = {
|
|
||||||
ft8: "FT8",
|
|
||||||
ft4: "FT4",
|
|
||||||
ft2: "FT2",
|
|
||||||
};
|
|
||||||
let ft8FilterText = "";
|
|
||||||
let ft8MessageHistory = [];
|
|
||||||
let ft8BarActiveDecoder = "ft8";
|
|
||||||
const ft8BarBuilders = {};
|
|
||||||
const ft8BarDismissedAtMsByDecoder = {
|
|
||||||
ft8: 0,
|
|
||||||
ft4: 0,
|
|
||||||
ft2: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
function currentFt8HistoryRetentionMs() {
|
|
||||||
return typeof window.getDecodeHistoryRetentionMs === "function"
|
|
||||||
? window.getDecodeHistoryRetentionMs()
|
|
||||||
: 24 * 60 * 60 * 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pruneFt8MessageHistory() {
|
|
||||||
const cutoffMs = Date.now() - currentFt8HistoryRetentionMs();
|
|
||||||
ft8MessageHistory = ft8MessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleFt8Ui(key, job) {
|
|
||||||
if (typeof window.trxScheduleUiFrameJob === "function") {
|
|
||||||
window.trxScheduleUiFrameJob(key, job);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
job();
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleFt8HistoryRender() {
|
|
||||||
scheduleFt8Ui("ft8-history", () => renderFt8History());
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleFt8BarUpdate() {
|
|
||||||
scheduleFt8Ui("ft8-bar", () => updateFt8Bar());
|
|
||||||
}
|
|
||||||
|
|
||||||
window.registerFt8FamilyBarRenderer = function(decoder, builder) {
|
|
||||||
if (!FT8_BAR_DECODER_LABELS[decoder] || typeof builder !== "function") return;
|
|
||||||
ft8BarBuilders[decoder] = builder;
|
|
||||||
};
|
|
||||||
|
|
||||||
window.setFt8FamilyBarDecoder = function(decoder) {
|
|
||||||
if (!FT8_BAR_DECODER_LABELS[decoder]) return;
|
|
||||||
ft8BarActiveDecoder = decoder;
|
|
||||||
scheduleFt8BarUpdate();
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizeFt8DisplayFreqHz(freqHz) {
|
|
||||||
const rawHz = Number(freqHz);
|
|
||||||
if (!Number.isFinite(rawHz)) return null;
|
|
||||||
const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null;
|
|
||||||
if (Number.isFinite(baseHz) && baseHz > 0 && rawHz >= 0 && rawHz < 100000) {
|
|
||||||
return baseHz + rawHz;
|
|
||||||
}
|
|
||||||
return rawHz;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtTime(tsMs) {
|
|
||||||
if (!tsMs) return "--:--:--";
|
|
||||||
return new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateFt8PeriodTimer() {
|
|
||||||
if (!ft8PeriodEl) return;
|
|
||||||
const nowSec = Math.floor(Date.now() / 1000);
|
|
||||||
const remaining = FT8_PERIOD_SECONDS - (nowSec % FT8_PERIOD_SECONDS);
|
|
||||||
ft8PeriodEl.textContent = `Next slot ${String(remaining).padStart(2, "0")}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateFt8PeriodTimer();
|
|
||||||
setInterval(updateFt8PeriodTimer, 500);
|
|
||||||
|
|
||||||
function renderFt8Row(msg) {
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = "ft8-row";
|
|
||||||
const rawMessage = (msg.message || "").toString();
|
|
||||||
row.dataset.message = rawMessage.toUpperCase();
|
|
||||||
row.dataset.decoder = "ft8";
|
|
||||||
row.dataset.storedFreqHz = Number.isFinite(msg.freq_hz) ? String(msg.freq_hz) : "";
|
|
||||||
const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--";
|
|
||||||
const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--";
|
|
||||||
const displayFreqHz = normalizeFt8DisplayFreqHz(msg.freq_hz);
|
|
||||||
const freq = Number.isFinite(displayFreqHz) ? displayFreqHz.toFixed(0) : "--";
|
|
||||||
const renderedMessage = renderFt8Message(rawMessage);
|
|
||||||
row.innerHTML = `<span class="ft8-time">${fmtTime(msg.ts_ms)}</span><span class="ft8-snr">${snr}</span><span class="ft8-dt">${dt}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderedMessage}</span>`;
|
|
||||||
applyFt8FilterToRow(row);
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderFt8History() {
|
|
||||||
pruneFt8MessageHistory();
|
|
||||||
if (!ft8MessagesEl) return;
|
|
||||||
const fragment = document.createDocumentFragment();
|
|
||||||
const limit = Math.min(ft8MessageHistory.length, FT8_MAX_DOM_ROWS);
|
|
||||||
for (let i = 0; i < limit; i += 1) {
|
|
||||||
fragment.appendChild(renderFt8Row(ft8MessageHistory[i]));
|
|
||||||
}
|
|
||||||
ft8MessagesEl.replaceChildren(fragment);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addFt8Message(msg) {
|
|
||||||
msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
|
||||||
ft8MessageHistory.unshift(msg);
|
|
||||||
pruneFt8MessageHistory();
|
|
||||||
ft8BarActiveDecoder = "ft8";
|
|
||||||
scheduleFt8BarUpdate();
|
|
||||||
scheduleFt8HistoryRender();
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeServerFt8Message(msg) {
|
|
||||||
const raw = (msg.message || "").toString();
|
|
||||||
const locatorDetails = ft8ExtractLocatorDetails(raw);
|
|
||||||
const grids = locatorDetails.length > 0
|
|
||||||
? locatorDetails.map((detail) => detail.grid)
|
|
||||||
: ft8ExtractAllGrids(raw);
|
|
||||||
const station = ft8ExtractLikelyCallsign(raw);
|
|
||||||
const rfHz = normalizeFt8DisplayFreqHz(msg.freq_hz);
|
|
||||||
return {
|
|
||||||
raw,
|
|
||||||
grids,
|
|
||||||
station,
|
|
||||||
rfHz,
|
|
||||||
locatorDetails,
|
|
||||||
history: {
|
|
||||||
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
|
|
||||||
ts_ms: msg.ts_ms,
|
|
||||||
snr_db: msg.snr_db,
|
|
||||||
dt_s: msg.dt_s,
|
|
||||||
freq_hz: Number.isFinite(rfHz) ? rfHz : msg.freq_hz,
|
|
||||||
message: msg.message,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
window.onServerFt8Batch = function(messages) {
|
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
|
||||||
ft8Status.textContent = "Receiving";
|
|
||||||
const normalized = [];
|
|
||||||
for (const msg of messages) {
|
|
||||||
const next = normalizeServerFt8Message(msg);
|
|
||||||
if (next.grids.length > 0 && window.mapAddLocator) {
|
|
||||||
window.mapAddLocator(next.raw, next.grids, "ft8", next.station, {
|
|
||||||
...msg,
|
|
||||||
freq_hz: next.rfHz,
|
|
||||||
locator_details: next.locatorDetails,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
|
||||||
normalized.push(next.history);
|
|
||||||
}
|
|
||||||
normalized.reverse();
|
|
||||||
ft8MessageHistory = normalized.concat(ft8MessageHistory);
|
|
||||||
pruneFt8MessageHistory();
|
|
||||||
ft8BarActiveDecoder = "ft8";
|
|
||||||
scheduleFt8BarUpdate();
|
|
||||||
scheduleFt8HistoryRender();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.restoreFt8History = function(messages) {
|
|
||||||
window.onServerFt8Batch(messages);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.pruneFt8HistoryView = function() {
|
|
||||||
pruneFt8MessageHistory();
|
|
||||||
updateFt8Bar();
|
|
||||||
renderFt8History();
|
|
||||||
};
|
|
||||||
|
|
||||||
function ft8BarRfText(msg) {
|
|
||||||
const displayFreqHz = normalizeFt8DisplayFreqHz(msg.freq_hz);
|
|
||||||
if (!Number.isFinite(displayFreqHz)) return null;
|
|
||||||
return `${displayFreqHz.toFixed(0)} Hz`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildFt8BarFrames() {
|
|
||||||
const cutoffMs = Date.now() - FT8_BAR_WINDOW_MS;
|
|
||||||
const messages = ft8MessageHistory.filter((msg) => Number(msg.ts_ms) >= cutoffMs).slice(0, 8);
|
|
||||||
const newestTsMs = messages.reduce((latest, msg) => Math.max(latest, Number(msg.ts_ms) || 0), 0);
|
|
||||||
if (messages.length === 0) {
|
|
||||||
return { count: 0, newestTsMs: 0, html: "" };
|
|
||||||
}
|
|
||||||
let html = "";
|
|
||||||
for (const msg of messages) {
|
|
||||||
const ts = msg.ts_ms ? `<span class="aprs-bar-time">${fmtTime(msg.ts_ms)}</span>` : "";
|
|
||||||
const snr = Number.isFinite(msg.snr_db) ? `${msg.snr_db.toFixed(1)} dB` : "-- dB";
|
|
||||||
const dt = Number.isFinite(msg.dt_s) ? `dt ${msg.dt_s.toFixed(2)}` : null;
|
|
||||||
const rf = ft8BarRfText(msg);
|
|
||||||
const detail = [snr, dt, rf].filter(Boolean).join(" · ");
|
|
||||||
const text = ft8EscapeHtml((msg.message || "").toString());
|
|
||||||
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}<span class="aprs-bar-call">${text}</span>${detail ? ` · ${detail}` : ""}</div></div>`;
|
|
||||||
}
|
|
||||||
return { count: messages.length, newestTsMs, html };
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateFt8Bar() {
|
|
||||||
if (!ft8BarOverlay) return;
|
|
||||||
const modeUpper = (document.getElementById("mode")?.value || "").toUpperCase();
|
|
||||||
const isFt8Mode = modeUpper === "DIG" || modeUpper === "USB";
|
|
||||||
const decoder = ft8BarActiveDecoder;
|
|
||||||
const builder = ft8BarBuilders[decoder];
|
|
||||||
const label = FT8_BAR_DECODER_LABELS[decoder] || "FT8";
|
|
||||||
const result = typeof builder === "function" ? builder() : null;
|
|
||||||
const newestTsMs = Number(result?.newestTsMs) || 0;
|
|
||||||
if (!isFt8Mode || !result || result.count === 0 || newestTsMs <= (ft8BarDismissedAtMsByDecoder[decoder] || 0)) {
|
|
||||||
ft8BarOverlay.style.display = "none";
|
|
||||||
ft8BarOverlay.innerHTML = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ft8BarOverlay.innerHTML = `<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">${label}</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-actions"><span class="aprs-bar-window">Last 15 minutes</span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearFt8Bar()" onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();window.clearFt8Bar();}" aria-label="Clear ${label} overlay">Clear</span></span><button class="aprs-bar-close" type="button" onclick="window.closeFt8Bar()" aria-label="Close ${label} overlay">×</button></span></div>${result.html}`;
|
|
||||||
ft8BarOverlay.style.display = "flex";
|
|
||||||
}
|
|
||||||
window.updateFt8Bar = updateFt8Bar;
|
|
||||||
window.clearFt8Bar = function() {
|
|
||||||
const decoder = ft8BarActiveDecoder;
|
|
||||||
if (decoder === "ft4") {
|
|
||||||
window.resetFt4HistoryView?.();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (decoder === "ft2") {
|
|
||||||
window.resetFt2HistoryView?.();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
window.resetFt8HistoryView?.();
|
|
||||||
};
|
|
||||||
window.closeFt8Bar = function() {
|
|
||||||
ft8BarDismissedAtMsByDecoder[ft8BarActiveDecoder] = Date.now();
|
|
||||||
if (ft8BarOverlay) {
|
|
||||||
ft8BarOverlay.style.display = "none";
|
|
||||||
ft8BarOverlay.innerHTML = "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.registerFt8FamilyBarRenderer("ft8", buildFt8BarFrames);
|
|
||||||
|
|
||||||
function renderFt8Message(message) {
|
|
||||||
let out = "";
|
|
||||||
let i = 0;
|
|
||||||
while (i < message.length) {
|
|
||||||
const ch = message[i];
|
|
||||||
if (ft8IsAlphaNum(ch)) {
|
|
||||||
let j = i + 1;
|
|
||||||
while (j < message.length && ft8IsAlphaNum(message[j])) j++;
|
|
||||||
const token = message.slice(i, j);
|
|
||||||
const grid = token.toUpperCase();
|
|
||||||
if (ft8IsMaidenheadGridToken(grid)) {
|
|
||||||
out += `<span class="ft8-locator" data-locator-grid="${grid}" role="button" tabindex="0" aria-label="Show locator ${grid} on map">${grid}</span>`;
|
|
||||||
} else {
|
|
||||||
out += ft8EscapeHtml(token);
|
|
||||||
}
|
|
||||||
i = j;
|
|
||||||
} else {
|
|
||||||
out += ft8EscapeHtml(ch);
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ft8TokenizeMessage(message) {
|
|
||||||
return String(message || "")
|
|
||||||
.toUpperCase()
|
|
||||||
.split(/[^A-Z0-9/]+/)
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ft8ExtractAllGrids(message) {
|
|
||||||
const out = [];
|
|
||||||
const seen = new Set();
|
|
||||||
let i = 0;
|
|
||||||
while (i < message.length) {
|
|
||||||
if (ft8IsAlphaNum(message[i])) {
|
|
||||||
let j = i + 1;
|
|
||||||
while (j < message.length && ft8IsAlphaNum(message[j])) j++;
|
|
||||||
const token = message.slice(i, j);
|
|
||||||
const grid = token.toUpperCase();
|
|
||||||
if (ft8IsMaidenheadGridToken(grid) && !seen.has(grid)) {
|
|
||||||
seen.add(grid);
|
|
||||||
out.push(grid);
|
|
||||||
}
|
|
||||||
i = j;
|
|
||||||
} else {
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ft8ExtractLocatorDetails(message) {
|
|
||||||
const tokens = ft8TokenizeMessage(message);
|
|
||||||
const grids = ft8ExtractAllGrids(String(message || ""));
|
|
||||||
if (tokens.length === 0 || grids.length === 0) return [];
|
|
||||||
const firstGridIdx = tokens.findIndex((token) => ft8IsMaidenheadGridToken(token));
|
|
||||||
const limit = firstGridIdx >= 0 ? firstGridIdx : tokens.length;
|
|
||||||
const callsigns = [];
|
|
||||||
for (let i = 0; i < limit; i += 1) {
|
|
||||||
if (ft8IsLikelyCallsignToken(tokens[i])) callsigns.push(tokens[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
let source = null;
|
|
||||||
let target = null;
|
|
||||||
const head = tokens[0];
|
|
||||||
if (callsigns.length > 0) {
|
|
||||||
if (head === "CQ" || head === "DE" || head === "QRZ") {
|
|
||||||
source = callsigns[0];
|
|
||||||
} else if (callsigns.length >= 2) {
|
|
||||||
target = callsigns[0];
|
|
||||||
source = callsigns[1];
|
|
||||||
} else {
|
|
||||||
source = callsigns[0];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return grids.map((grid) => ({
|
|
||||||
grid,
|
|
||||||
station: source || null,
|
|
||||||
source: source || null,
|
|
||||||
target: target || null,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function ft8ExtractLikelyCallsign(message) {
|
|
||||||
const locatorDetails = ft8ExtractLocatorDetails(message);
|
|
||||||
if (locatorDetails.length > 0 && locatorDetails[0].station) {
|
|
||||||
return locatorDetails[0].station;
|
|
||||||
}
|
|
||||||
const tokens = ft8TokenizeMessage(message);
|
|
||||||
for (const token of tokens) {
|
|
||||||
if (ft8IsLikelyCallsignToken(token)) return token;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ft8IsLikelyCallsignToken(token) {
|
|
||||||
if (!token) return false;
|
|
||||||
if (token.length < 3 || token.length > 12) return false;
|
|
||||||
if (token === "CQ" || token === "DE" || token === "QRZ" || token === "DX") return false;
|
|
||||||
if (ft8IsMaidenheadGridToken(token)) return false;
|
|
||||||
return /^[A-Z0-9/]{1,5}\d[A-Z0-9/]{1,6}$/.test(token);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ft8IsFarewellToken(token) {
|
|
||||||
const normalized = String(token || "").trim().toUpperCase();
|
|
||||||
return normalized === "RR73" || normalized === "73" || normalized === "RR";
|
|
||||||
}
|
|
||||||
|
|
||||||
function ft8IsMaidenheadGridToken(token) {
|
|
||||||
const normalized = String(token || "").trim().toUpperCase();
|
|
||||||
return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && !ft8IsFarewellToken(normalized);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ft8EscapeHtml(input) {
|
|
||||||
return input
|
|
||||||
.replaceAll("&", "&")
|
|
||||||
.replaceAll("<", "<")
|
|
||||||
.replaceAll(">", ">")
|
|
||||||
.replaceAll("\"", """);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ft8IsAlphaNum(ch) {
|
|
||||||
return /[A-Za-z0-9]/.test(ch);
|
|
||||||
}
|
|
||||||
|
|
||||||
function activateFt8HistoryLocator(targetEl) {
|
|
||||||
const locatorEl = targetEl?.closest?.(".ft8-locator[data-locator-grid]");
|
|
||||||
if (!locatorEl) return false;
|
|
||||||
const grid = String(locatorEl.dataset.locatorGrid || "").toUpperCase();
|
|
||||||
if (!grid) return false;
|
|
||||||
if (typeof window.navigateToMapLocator === "function") {
|
|
||||||
window.navigateToMapLocator(grid, "ft8");
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyFt8FilterToRow(row) {
|
|
||||||
if (!ft8FilterText) {
|
|
||||||
row.style.display = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const message = row.dataset.message || "";
|
|
||||||
row.style.display = message.includes(ft8FilterText) ? "" : "none";
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyFt8FilterToAll() {
|
|
||||||
const rows = ft8MessagesEl.querySelectorAll(".ft8-row");
|
|
||||||
rows.forEach((row) => applyFt8FilterToRow(row));
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateFt8RowRf(row) {
|
|
||||||
const freqEl = row.querySelector(".ft8-freq");
|
|
||||||
if (!freqEl) return;
|
|
||||||
const storedFreqHz = row.dataset.storedFreqHz ? Number(row.dataset.storedFreqHz) : NaN;
|
|
||||||
const displayFreqHz = normalizeFt8DisplayFreqHz(storedFreqHz);
|
|
||||||
if (Number.isFinite(displayFreqHz)) {
|
|
||||||
freqEl.textContent = displayFreqHz.toFixed(0);
|
|
||||||
} else {
|
|
||||||
freqEl.textContent = "--";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.updateFt8RfDisplay = function() {
|
|
||||||
const rows = ft8MessagesEl.querySelectorAll(".ft8-row");
|
|
||||||
rows.forEach((row) => updateFt8RowRf(row));
|
|
||||||
updateFt8Bar();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.resetFt8HistoryView = function() {
|
|
||||||
ft8MessagesEl.innerHTML = "";
|
|
||||||
ft8MessageHistory = [];
|
|
||||||
updateFt8Bar();
|
|
||||||
renderFt8History();
|
|
||||||
if (window.clearMapMarkersByType) window.clearMapMarkersByType("ft8");
|
|
||||||
};
|
|
||||||
|
|
||||||
if (ft8FilterInput) {
|
|
||||||
ft8FilterInput.addEventListener("input", () => {
|
|
||||||
ft8FilterText = ft8FilterInput.value.trim().toUpperCase();
|
|
||||||
renderFt8History();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ft8MessagesEl) {
|
|
||||||
ft8MessagesEl.addEventListener("click", (event) => {
|
|
||||||
if (!activateFt8HistoryLocator(event.target)) return;
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
});
|
|
||||||
ft8MessagesEl.addEventListener("keydown", (event) => {
|
|
||||||
if (event.key !== "Enter" && event.key !== " ") return;
|
|
||||||
if (!activateFt8HistoryLocator(event.target)) return;
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const ft8DecodeToggleBtn = document.getElementById("ft8-decode-toggle-btn");
|
|
||||||
ft8DecodeToggleBtn?.addEventListener("click", async () => {
|
|
||||||
try {
|
|
||||||
await window.takeSchedulerControlForDecoderDisable?.(ft8DecodeToggleBtn);
|
|
||||||
await postPath("/toggle_ft8_decode");
|
|
||||||
} catch (e) {
|
|
||||||
console.error("FT8 toggle failed", e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById("settings-clear-ft8-history")?.addEventListener("click", async () => {
|
|
||||||
if (!confirm("Clear all FT8 decode history? This cannot be undone.")) return;
|
|
||||||
try {
|
|
||||||
await postPath("/clear_ft8_decode");
|
|
||||||
window.resetFt8HistoryView();
|
|
||||||
} catch (e) {
|
|
||||||
console.error("FT8 history clear failed", e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Server-side FT8 decode handler ---
|
|
||||||
window.onServerFt8 = function(msg) {
|
|
||||||
ft8Status.textContent = "Receiving";
|
|
||||||
const next = normalizeServerFt8Message(msg);
|
|
||||||
if (next.grids.length > 0 && window.mapAddLocator) {
|
|
||||||
window.mapAddLocator(next.raw, next.grids, "ft8", next.station, {
|
|
||||||
...msg,
|
|
||||||
freq_hz: next.rfHz,
|
|
||||||
locator_details: next.locatorDetails,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
addFt8Message(next.history);
|
|
||||||
};
|
|
||||||
@@ -1,444 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
// --- HF APRS Decoder Plugin (server-side decode, 300 baud) ---
|
|
||||||
const hfAprsStatus = document.getElementById("hf-aprs-status");
|
|
||||||
const hfAprsPacketsEl = document.getElementById("hf-aprs-packets");
|
|
||||||
const hfAprsFilterInput = document.getElementById("hf-aprs-filter");
|
|
||||||
const hfAprsOnlyPosBtn = document.getElementById("hf-aprs-only-pos-btn");
|
|
||||||
const hfAprsHideCrcBtn = document.getElementById("hf-aprs-hide-crc-btn");
|
|
||||||
const hfAprsCollapseDupBtn = document.getElementById("hf-aprs-collapse-dup-btn");
|
|
||||||
const hfAprsTotalCountEl = document.getElementById("hf-aprs-total-count");
|
|
||||||
const hfAprsVisibleCountEl = document.getElementById("hf-aprs-visible-count");
|
|
||||||
const hfAprsLatestSeenEl = document.getElementById("hf-aprs-latest-seen");
|
|
||||||
let hfAprsFilterText = "";
|
|
||||||
let hfAprsPacketHistory = [];
|
|
||||||
let hfAprsOnlyPos = false;
|
|
||||||
let hfAprsHideCrc = false;
|
|
||||||
let hfAprsCollapseDup = false;
|
|
||||||
let hfAprsTypeFilter = "all";
|
|
||||||
|
|
||||||
function currentHfAprsHistoryRetentionMs() {
|
|
||||||
return typeof window.getDecodeHistoryRetentionMs === "function"
|
|
||||||
? window.getDecodeHistoryRetentionMs()
|
|
||||||
: 24 * 60 * 60 * 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pruneHfAprsPacketHistory() {
|
|
||||||
const cutoffMs = Date.now() - currentHfAprsHistoryRetentionMs();
|
|
||||||
hfAprsPacketHistory = hfAprsPacketHistory.filter((pkt) => Number(pkt?._tsMs) >= cutoffMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleHfAprsHistoryRender() {
|
|
||||||
if (typeof window.trxScheduleUiFrameJob === "function") {
|
|
||||||
window.trxScheduleUiFrameJob("hf-aprs-history", () => renderHfAprsHistory());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
renderHfAprsHistory();
|
|
||||||
}
|
|
||||||
|
|
||||||
function hfAprsPacketCategory(pkt) {
|
|
||||||
const type = String(pkt.type || "").toLowerCase();
|
|
||||||
const info = String(pkt.info || "").toLowerCase();
|
|
||||||
if (pkt.lat != null && pkt.lon != null || type.includes("position")) return "position";
|
|
||||||
if (type.includes("message") || info.startsWith(":")) return "message";
|
|
||||||
if (type.includes("weather") || info.startsWith("_")) return "weather";
|
|
||||||
if (type.includes("telemetry") || info.startsWith("t#")) return "telemetry";
|
|
||||||
return "other";
|
|
||||||
}
|
|
||||||
|
|
||||||
function hfAprsCategoryLabel(category) {
|
|
||||||
switch (category) {
|
|
||||||
case "position": return "Position";
|
|
||||||
case "message": return "Message";
|
|
||||||
case "weather": return "Weather";
|
|
||||||
case "telemetry": return "Telemetry";
|
|
||||||
default: return "Other";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function hfAprsAgeText(tsMs) {
|
|
||||||
if (!Number.isFinite(tsMs)) return "just now";
|
|
||||||
const deltaMs = Math.max(0, Date.now() - tsMs);
|
|
||||||
const seconds = Math.round(deltaMs / 1000);
|
|
||||||
if (seconds < 5) return "just now";
|
|
||||||
if (seconds < 60) return `${seconds}s ago`;
|
|
||||||
const minutes = Math.round(seconds / 60);
|
|
||||||
if (minutes < 60) return `${minutes}m ago`;
|
|
||||||
const hours = Math.round(minutes / 60);
|
|
||||||
return `${hours}h ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hfAprsDistanceText(pkt) {
|
|
||||||
if (serverLat == null || serverLon == null || pkt.lat == null || pkt.lon == null) return "";
|
|
||||||
const distKm = haversineKm(serverLat, serverLon, pkt.lat, pkt.lon);
|
|
||||||
if (!Number.isFinite(distKm)) return "";
|
|
||||||
if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`;
|
|
||||||
return `${distKm.toFixed(1)} km from TRX`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hfAprsPacketSignature(pkt) {
|
|
||||||
return [
|
|
||||||
pkt.srcCall || "",
|
|
||||||
pkt.destCall || "",
|
|
||||||
pkt.path || "",
|
|
||||||
pkt.info || "",
|
|
||||||
pkt.type || "",
|
|
||||||
pkt.lat != null ? pkt.lat.toFixed(4) : "",
|
|
||||||
pkt.lon != null ? pkt.lon.toFixed(4) : "",
|
|
||||||
].join("|");
|
|
||||||
}
|
|
||||||
|
|
||||||
function hfAprsHexBytes(bytes) {
|
|
||||||
if (!Array.isArray(bytes) || bytes.length === 0) return "--";
|
|
||||||
return bytes.map((b) => Number(b).toString(16).toUpperCase().padStart(2, "0")).join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function hfAprsFilterMatch(pkt) {
|
|
||||||
if (hfAprsOnlyPos && (pkt.lat == null || pkt.lon == null)) return false;
|
|
||||||
if (hfAprsHideCrc && !pkt.crcOk) return false;
|
|
||||||
if (hfAprsTypeFilter !== "all" && hfAprsPacketCategory(pkt) !== hfAprsTypeFilter) return false;
|
|
||||||
if (!hfAprsFilterText) return true;
|
|
||||||
const haystack = [
|
|
||||||
pkt.srcCall,
|
|
||||||
pkt.destCall,
|
|
||||||
pkt.path,
|
|
||||||
pkt.info,
|
|
||||||
pkt.type,
|
|
||||||
pkt.lat != null ? pkt.lat.toFixed(4) : "",
|
|
||||||
pkt.lon != null ? pkt.lon.toFixed(4) : "",
|
|
||||||
hfAprsPacketCategory(pkt),
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ")
|
|
||||||
.toUpperCase();
|
|
||||||
return haystack.includes(hfAprsFilterText);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hfAprsVisiblePackets() {
|
|
||||||
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory;
|
|
||||||
return packets.filter(hfAprsFilterMatch);
|
|
||||||
}
|
|
||||||
|
|
||||||
function collapseHfAprsDuplicates(packets) {
|
|
||||||
const seen = new Set();
|
|
||||||
const out = [];
|
|
||||||
for (const pkt of packets) {
|
|
||||||
const key = hfAprsPacketSignature(pkt);
|
|
||||||
if (seen.has(key)) continue;
|
|
||||||
seen.add(key);
|
|
||||||
out.push(pkt);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateHfAprsSummary() {
|
|
||||||
const visible = hfAprsVisiblePackets();
|
|
||||||
if (hfAprsTotalCountEl) {
|
|
||||||
hfAprsTotalCountEl.textContent = `${hfAprsPacketHistory.length} total`;
|
|
||||||
}
|
|
||||||
if (hfAprsVisibleCountEl) {
|
|
||||||
hfAprsVisibleCountEl.textContent = `${visible.length} shown`;
|
|
||||||
}
|
|
||||||
if (hfAprsLatestSeenEl) {
|
|
||||||
const latest = hfAprsPacketHistory[0];
|
|
||||||
if (!latest) {
|
|
||||||
hfAprsLatestSeenEl.textContent = "No packets yet";
|
|
||||||
} else {
|
|
||||||
hfAprsLatestSeenEl.textContent = `${latest.srcCall} ${hfAprsAgeText(latest._tsMs)}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateHfAprsChipState() {
|
|
||||||
document.querySelectorAll("[id^='hf-aprs-type-']").forEach((btn) => {
|
|
||||||
btn.classList.toggle("active", btn.id === `hf-aprs-type-${hfAprsTypeFilter}`);
|
|
||||||
});
|
|
||||||
hfAprsOnlyPosBtn?.classList.toggle("active", hfAprsOnlyPos);
|
|
||||||
hfAprsHideCrcBtn?.classList.toggle("active", hfAprsHideCrc);
|
|
||||||
hfAprsCollapseDupBtn?.classList.toggle("active", hfAprsCollapseDup);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderHfAprsInfo(pkt) {
|
|
||||||
const bytes = Array.isArray(pkt.info_bytes) ? pkt.info_bytes : null;
|
|
||||||
if (bytes && bytes.length > 0) {
|
|
||||||
let out = "";
|
|
||||||
for (let i = 0; i < bytes.length; i++) {
|
|
||||||
const b = bytes[i];
|
|
||||||
if (b >= 0x20 && b <= 0x7e) {
|
|
||||||
const ch = String.fromCharCode(b);
|
|
||||||
if (ch === "<") out += "<";
|
|
||||||
else if (ch === ">") out += ">";
|
|
||||||
else if (ch === "&") out += "&";
|
|
||||||
else if (ch === '"') out += """;
|
|
||||||
else out += ch;
|
|
||||||
} else {
|
|
||||||
const hex = b.toString(16).toUpperCase().padStart(2, "0");
|
|
||||||
out += `<span class="aprs-byte">0x${hex}</span>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
const str = pkt.info || "";
|
|
||||||
let out = "";
|
|
||||||
for (let i = 0; i < str.length; i++) {
|
|
||||||
const code = str.charCodeAt(i);
|
|
||||||
if (code >= 0x20 && code <= 0x7e) {
|
|
||||||
const ch = str[i];
|
|
||||||
if (ch === "<") out += "<";
|
|
||||||
else if (ch === ">") out += ">";
|
|
||||||
else if (ch === "&") out += "&";
|
|
||||||
else if (ch === '"') out += """;
|
|
||||||
else out += ch;
|
|
||||||
} else {
|
|
||||||
const hex = code.toString(16).toUpperCase().padStart(2, "0");
|
|
||||||
out += `<span class="aprs-byte">0x${hex}</span>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderHfAprsRow(pkt, isFresh) {
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = "aprs-packet";
|
|
||||||
if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
|
|
||||||
if (isFresh) row.classList.add("aprs-packet-new");
|
|
||||||
|
|
||||||
const ts = pkt._ts || new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
||||||
const age = hfAprsAgeText(pkt._tsMs);
|
|
||||||
const category = hfAprsPacketCategory(pkt);
|
|
||||||
const categoryLabel = hfAprsCategoryLabel(category);
|
|
||||||
const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`;
|
|
||||||
const pathBadge = pkt.path ? `<span class="aprs-badge">${escapeMapHtml(pkt.path)}</span>` : "";
|
|
||||||
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
|
|
||||||
const hfBadge = '<span class="aprs-badge" style="background:var(--accent-alt,#f59e0b);color:#000">HF</span>';
|
|
||||||
let symbolHtml = "";
|
|
||||||
if (pkt.symbolTable && pkt.symbolCode) {
|
|
||||||
const sheet = pkt.symbolTable === "/" ? 0 : 1;
|
|
||||||
const code = pkt.symbolCode.charCodeAt(0) - 33;
|
|
||||||
const col = code % 16;
|
|
||||||
const row2 = Math.floor(code / 16);
|
|
||||||
const bgX = -(col * 24);
|
|
||||||
const bgY = -(row2 * 24);
|
|
||||||
symbolHtml = `<span class="aprs-symbol" style="background-image:url('https://raw.githubusercontent.com/hessu/aprs-symbols/master/png/aprs-symbols-24-${sheet}.png');background-position:${bgX}px ${bgY}px"></span>`;
|
|
||||||
}
|
|
||||||
const posLink = pkt.lat != null && pkt.lon != null
|
|
||||||
? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${pkt.lat},${pkt.lon}">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>`
|
|
||||||
: "";
|
|
||||||
const distance = hfAprsDistanceText(pkt);
|
|
||||||
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`;
|
|
||||||
|
|
||||||
row.innerHTML =
|
|
||||||
`<div class="aprs-row-head">` +
|
|
||||||
`<span class="aprs-time">${ts}</span>` +
|
|
||||||
hfBadge +
|
|
||||||
symbolHtml +
|
|
||||||
`<span class="aprs-call">${escapeMapHtml(pkt.srcCall)}</span>` +
|
|
||||||
`<span>>${escapeMapHtml(pkt.destCall || "")}</span>` +
|
|
||||||
`<span class="${categoryClass}">${escapeMapHtml(categoryLabel)}</span>` +
|
|
||||||
pathBadge +
|
|
||||||
crcBadge +
|
|
||||||
`</div>` +
|
|
||||||
`<div class="aprs-row-meta">` +
|
|
||||||
`<span class="aprs-meta-text">${escapeMapHtml(age)}</span>` +
|
|
||||||
(distance ? `<span class="aprs-meta-text">${escapeMapHtml(distance)}</span>` : "") +
|
|
||||||
`<span class="aprs-meta-text">${escapeMapHtml(pkt.type || "--")}</span>` +
|
|
||||||
`</div>` +
|
|
||||||
`<div class="aprs-row-detail">` +
|
|
||||||
`<span title="${escapeMapHtml(pkt.type || "")}">${renderHfAprsInfo(pkt)}</span>` +
|
|
||||||
(posLink ? `<span>${posLink}</span>` : "") +
|
|
||||||
`</div>` +
|
|
||||||
`<div class="aprs-row-actions">` +
|
|
||||||
(pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${pkt.lat},${pkt.lon}">Map</button>` : "") +
|
|
||||||
(pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${pkt.lat},${pkt.lon}">Copy Coords</button>` : "") +
|
|
||||||
`<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a>` +
|
|
||||||
`</div>` +
|
|
||||||
`<details class="aprs-details">` +
|
|
||||||
`<summary>Details</summary>` +
|
|
||||||
`<div class="aprs-details-grid">` +
|
|
||||||
`<span class="aprs-detail-label">Source</span><span class="aprs-detail-value">${escapeMapHtml(pkt.srcCall || "--")}</span>` +
|
|
||||||
`<span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeMapHtml(pkt.destCall || "--")}</span>` +
|
|
||||||
`<span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeMapHtml(pkt.type || "--")}</span>` +
|
|
||||||
`<span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeMapHtml(pkt.path || "--")}</span>` +
|
|
||||||
`<span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeMapHtml(age)}</span>` +
|
|
||||||
`<span class="aprs-detail-label">CRC</span><span class="aprs-detail-value">${pkt.crcOk ? "OK" : "Failed"}</span>` +
|
|
||||||
`<span class="aprs-detail-label">Position</span><span class="aprs-detail-value">${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}</span>` +
|
|
||||||
`<span class="aprs-detail-label">Info</span><span class="aprs-detail-value">${escapeMapHtml(pkt.info || "--")}</span>` +
|
|
||||||
`<span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeMapHtml(hfAprsHexBytes(pkt.info_bytes))}</span>` +
|
|
||||||
`</div>` +
|
|
||||||
`</details>`;
|
|
||||||
|
|
||||||
row.querySelectorAll("[data-aprs-map]").forEach((el) => {
|
|
||||||
el.addEventListener("click", (evt) => {
|
|
||||||
evt.preventDefault();
|
|
||||||
const raw = String(el.dataset.aprsMap || "");
|
|
||||||
const [lat, lon] = raw.split(",").map(Number);
|
|
||||||
if (window.navigateToAprsMap && Number.isFinite(lat) && Number.isFinite(lon)) {
|
|
||||||
window.navigateToAprsMap(lat, lon);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const copyBtn = row.querySelector("[data-aprs-copy]");
|
|
||||||
if (copyBtn) {
|
|
||||||
copyBtn.addEventListener("click", async () => {
|
|
||||||
const raw = String(copyBtn.dataset.aprsCopy || "");
|
|
||||||
try {
|
|
||||||
if (navigator.clipboard?.writeText) {
|
|
||||||
await navigator.clipboard.writeText(raw);
|
|
||||||
showHint("Coordinates copied", 1200);
|
|
||||||
}
|
|
||||||
} catch (_e) {
|
|
||||||
showHint("Copy failed", 1500);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderHfAprsHistory() {
|
|
||||||
pruneHfAprsPacketHistory();
|
|
||||||
if (!hfAprsPacketsEl) {
|
|
||||||
updateHfAprsSummary();
|
|
||||||
updateHfAprsChipState();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const visible = hfAprsVisiblePackets();
|
|
||||||
const fragment = document.createDocumentFragment();
|
|
||||||
for (let i = 0; i < visible.length; i++) {
|
|
||||||
fragment.appendChild(renderHfAprsRow(visible[i], i === 0));
|
|
||||||
}
|
|
||||||
hfAprsPacketsEl.replaceChildren(fragment);
|
|
||||||
updateHfAprsSummary();
|
|
||||||
updateHfAprsChipState();
|
|
||||||
}
|
|
||||||
|
|
||||||
window.resetHfAprsHistoryView = function() {
|
|
||||||
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
|
|
||||||
hfAprsPacketHistory = [];
|
|
||||||
renderHfAprsHistory();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.pruneHfAprsHistoryView = function() {
|
|
||||||
pruneHfAprsPacketHistory();
|
|
||||||
renderHfAprsHistory();
|
|
||||||
};
|
|
||||||
|
|
||||||
function addHfAprsPacket(pkt) {
|
|
||||||
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
|
||||||
pkt._tsMs = tsMs;
|
|
||||||
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
||||||
|
|
||||||
hfAprsPacketHistory.unshift(pkt);
|
|
||||||
pruneHfAprsPacketHistory();
|
|
||||||
|
|
||||||
scheduleHfAprsHistoryRender();
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeServerHfAprsPacket(pkt) {
|
|
||||||
return {
|
|
||||||
rig_id: pkt.rig_id || null,
|
|
||||||
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
|
|
||||||
srcCall: pkt.src_call,
|
|
||||||
destCall: pkt.dest_call,
|
|
||||||
path: pkt.path,
|
|
||||||
info: pkt.info,
|
|
||||||
info_bytes: pkt.info_bytes,
|
|
||||||
type: pkt.packet_type,
|
|
||||||
crcOk: pkt.crc_ok,
|
|
||||||
ts_ms: pkt.ts_ms,
|
|
||||||
lat: pkt.lat,
|
|
||||||
lon: pkt.lon,
|
|
||||||
symbolTable: pkt.symbol_table,
|
|
||||||
symbolCode: pkt.symbol_code,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
window.onServerHfAprsBatch = function(packets) {
|
|
||||||
if (!Array.isArray(packets) || packets.length === 0) return;
|
|
||||||
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
|
|
||||||
const normalized = [];
|
|
||||||
for (const pkt of packets) {
|
|
||||||
const next = normalizeServerHfAprsPacket(pkt);
|
|
||||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
|
||||||
next._tsMs = tsMs;
|
|
||||||
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
||||||
normalized.push(next);
|
|
||||||
}
|
|
||||||
normalized.reverse();
|
|
||||||
hfAprsPacketHistory = normalized.concat(hfAprsPacketHistory);
|
|
||||||
pruneHfAprsPacketHistory();
|
|
||||||
scheduleHfAprsHistoryRender();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.restoreHfAprsHistory = function(packets) {
|
|
||||||
window.onServerHfAprsBatch(packets);
|
|
||||||
};
|
|
||||||
|
|
||||||
const hfAprsDecodeToggleBtn = document.getElementById("hf-aprs-decode-toggle-btn");
|
|
||||||
hfAprsDecodeToggleBtn?.addEventListener("click", async () => {
|
|
||||||
try {
|
|
||||||
await window.takeSchedulerControlForDecoderDisable?.(hfAprsDecodeToggleBtn);
|
|
||||||
await postPath("/toggle_hf_aprs_decode");
|
|
||||||
} catch (e) {
|
|
||||||
console.error("HF APRS toggle failed", e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById("settings-clear-hf-aprs-history")?.addEventListener("click", async () => {
|
|
||||||
if (!confirm("Clear all HF APRS decode history? This cannot be undone.")) return;
|
|
||||||
try {
|
|
||||||
await postPath("/clear_hf_aprs_decode");
|
|
||||||
window.resetHfAprsHistoryView();
|
|
||||||
} catch (e) {
|
|
||||||
console.error("HF APRS history clear failed", e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hfAprsOnlyPosBtn) {
|
|
||||||
hfAprsOnlyPosBtn.addEventListener("click", () => {
|
|
||||||
hfAprsOnlyPos = !hfAprsOnlyPos;
|
|
||||||
renderHfAprsHistory();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hfAprsHideCrcBtn) {
|
|
||||||
hfAprsHideCrcBtn.addEventListener("click", () => {
|
|
||||||
hfAprsHideCrc = !hfAprsHideCrc;
|
|
||||||
renderHfAprsHistory();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hfAprsCollapseDupBtn) {
|
|
||||||
hfAprsCollapseDupBtn.addEventListener("click", () => {
|
|
||||||
hfAprsCollapseDup = !hfAprsCollapseDup;
|
|
||||||
renderHfAprsHistory();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
["all", "position", "message", "weather", "telemetry", "other"].forEach((type) => {
|
|
||||||
const btn = document.getElementById(`hf-aprs-type-${type}`);
|
|
||||||
if (!btn) return;
|
|
||||||
btn.addEventListener("click", () => {
|
|
||||||
hfAprsTypeFilter = type;
|
|
||||||
renderHfAprsHistory();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hfAprsFilterInput) {
|
|
||||||
hfAprsFilterInput.addEventListener("input", () => {
|
|
||||||
hfAprsFilterText = hfAprsFilterInput.value.trim().toUpperCase();
|
|
||||||
renderHfAprsHistory();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Server-side HF APRS decode handler ---
|
|
||||||
window.onServerHfAprs = function(pkt) {
|
|
||||||
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
|
|
||||||
addHfAprsPacket(normalizeServerHfAprsPacket(pkt));
|
|
||||||
};
|
|
||||||
|
|
||||||
renderHfAprsHistory();
|
|
||||||
if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("hf_aprs");
|
|
||||||
@@ -1,546 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
// --- SAT Plugin ---
|
|
||||||
// Live view: decoder state, latest image card
|
|
||||||
// History view: filterable table of all decoded images
|
|
||||||
// Predictions view: next 24 h passes for ham satellites
|
|
||||||
|
|
||||||
// ── DOM references (cached once) ───────────────────────────────────
|
|
||||||
const satDom = {
|
|
||||||
status: document.getElementById("sat-status"),
|
|
||||||
liveView: document.getElementById("sat-live-view"),
|
|
||||||
historyView: document.getElementById("sat-history-view"),
|
|
||||||
predictionsView: document.getElementById("sat-predictions-view"),
|
|
||||||
liveLatest: document.getElementById("sat-live-latest"),
|
|
||||||
historyList: document.getElementById("sat-history-list"),
|
|
||||||
historyCount: document.getElementById("sat-history-count"),
|
|
||||||
filterInput: document.getElementById("sat-filter"),
|
|
||||||
sortSelect: document.getElementById("sat-sort"),
|
|
||||||
typeFilter: document.getElementById("sat-type-filter"),
|
|
||||||
lrptState: document.getElementById("sat-lrpt-state"),
|
|
||||||
viewLiveBtn: document.getElementById("sat-view-live"),
|
|
||||||
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"),
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── State ───────────────────────────────────────────────────────────
|
|
||||||
let satImageHistory = [];
|
|
||||||
const SAT_MAX_IMAGES = 100;
|
|
||||||
const SAT_PRED_PAGE_SIZE = 50;
|
|
||||||
let satPredShowAll = false;
|
|
||||||
let satFilterText = "";
|
|
||||||
let satActiveView = "live"; // "live" | "history" | "predictions"
|
|
||||||
let satPredData = [];
|
|
||||||
let satPredFilterText = "";
|
|
||||||
let satPredMinEl = 0;
|
|
||||||
let satPredCategory = "all";
|
|
||||||
let satPredSatCount = 0;
|
|
||||||
let satPredCountdownTimer = null;
|
|
||||||
|
|
||||||
// ── UI scheduler helper ─────────────────────────────────────────────
|
|
||||||
function scheduleSatUi(key, job) {
|
|
||||||
if (typeof window.trxScheduleUiFrameJob === "function") {
|
|
||||||
window.trxScheduleUiFrameJob(key, job);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
job();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── View switching ──────────────────────────────────────────────────
|
|
||||||
function switchSatView(view) {
|
|
||||||
const leavingPredictions = satActiveView === "predictions" && view !== "predictions";
|
|
||||||
satActiveView = view;
|
|
||||||
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "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.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;
|
|
||||||
loadSatPredictions();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearPredictionDom() {
|
|
||||||
stopCountdownTimer();
|
|
||||||
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
|
||||||
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
|
||||||
}
|
|
||||||
window.clearSatPredictionDom = clearPredictionDom;
|
|
||||||
|
|
||||||
satDom.viewLiveBtn?.addEventListener("click", () => switchSatView("live"));
|
|
||||||
satDom.viewHistoryBtn?.addEventListener("click", () => switchSatView("history"));
|
|
||||||
satDom.viewPredBtn?.addEventListener("click", () => switchSatView("predictions"));
|
|
||||||
|
|
||||||
// ── Live view: decoder state ────────────────────────────────────────
|
|
||||||
let _lastSatLrptOn = null;
|
|
||||||
window.updateSatLiveState = function (update) {
|
|
||||||
if (!satDom.lrptState) return;
|
|
||||||
const lrptOn = !!update.lrpt_decode_enabled;
|
|
||||||
if (lrptOn !== _lastSatLrptOn) {
|
|
||||||
_lastSatLrptOn = lrptOn;
|
|
||||||
satDom.lrptState.textContent = lrptOn ? "Listening" : "Idle";
|
|
||||||
satDom.lrptState.className = "sat-live-value " + (lrptOn ? "sat-state-listening" : "sat-state-idle");
|
|
||||||
if (satDom.status) {
|
|
||||||
if (lrptOn) {
|
|
||||||
satDom.status.textContent = "Decoder active \u2014 waiting for signal";
|
|
||||||
} else {
|
|
||||||
satDom.status.textContent = "Decoder idle";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function renderSatLatestCard() {
|
|
||||||
if (!satDom.liveLatest) return;
|
|
||||||
if (satImageHistory.length === 0) {
|
|
||||||
satDom.liveLatest.innerHTML =
|
|
||||||
'<div style="color:var(--text-muted);font-size:0.82rem;">No images decoded yet. Enable a decoder and wait for a satellite pass.</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const img = satImageHistory[0];
|
|
||||||
const decoder = img._decoder || "unknown";
|
|
||||||
const typeName = "Meteor LRPT";
|
|
||||||
const satellite = img.satellite || "";
|
|
||||||
const channels = img.channels || img.channel_a || "";
|
|
||||||
const lines = img.mcu_count || img.line_count || 0;
|
|
||||||
const unit = "MCU rows";
|
|
||||||
const ts = img._ts || "--";
|
|
||||||
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : "";
|
|
||||||
|
|
||||||
let meta = [typeName];
|
|
||||||
if (satellite) meta.push(satellite);
|
|
||||||
if (channels) meta.push(channels);
|
|
||||||
meta.push(`${lines} ${unit}`);
|
|
||||||
meta.push(`${date} ${ts}`);
|
|
||||||
|
|
||||||
let html = `<div class="sat-latest-card">`;
|
|
||||||
html += `<div class="sat-latest-title">Latest decoded image</div>`;
|
|
||||||
html += `<div class="sat-latest-meta">${meta.join(" · ")}</div>`;
|
|
||||||
if (img.path) {
|
|
||||||
html += `<a href="${img.path}" target="_blank" style="font-size:0.8rem;color:var(--accent);display:inline-block;margin-top:0.25rem;">Download PNG</a>`;
|
|
||||||
}
|
|
||||||
if (img.geo_bounds) {
|
|
||||||
html += ` <button type="button" class="sat-map-btn" onclick="window.satShowOnMap(${img.geo_bounds[0]},${img.geo_bounds[1]},${img.geo_bounds[2]},${img.geo_bounds[3]})" style="font-size:0.8rem;margin-top:0.25rem;margin-left:0.5rem;cursor:pointer;background:none;border:1px solid var(--accent);color:var(--accent);border-radius:3px;padding:1px 6px;">Show on Map</button>`;
|
|
||||||
}
|
|
||||||
html += `</div>`;
|
|
||||||
satDom.liveLatest.innerHTML = html;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── History view: table ─────────────────────────────────────────────
|
|
||||||
function getSatFilteredHistory() {
|
|
||||||
let items = satImageHistory;
|
|
||||||
|
|
||||||
const typeVal = satDom.typeFilter ? satDom.typeFilter.value : "all";
|
|
||||||
if (typeVal === "lrpt") items = items.filter((i) => i._decoder === "lrpt");
|
|
||||||
|
|
||||||
if (satFilterText) {
|
|
||||||
items = items.filter((i) => {
|
|
||||||
const haystack = [
|
|
||||||
"meteor lrpt",
|
|
||||||
i.satellite || "",
|
|
||||||
i.channels || "",
|
|
||||||
i.channel_a || "",
|
|
||||||
i.channel_b || "",
|
|
||||||
].join(" ").toUpperCase();
|
|
||||||
return haystack.includes(satFilterText);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortVal = satDom.sortSelect ? satDom.sortSelect.value : "newest";
|
|
||||||
if (sortVal === "oldest") items = items.slice().reverse();
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSatHistoryRow(img) {
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = "sat-history-row";
|
|
||||||
|
|
||||||
const decoder = img._decoder || "unknown";
|
|
||||||
const typeName = "Meteor LRPT";
|
|
||||||
const typeClass = "sat-type-lrpt";
|
|
||||||
const ts = img._ts || "--";
|
|
||||||
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString([], { month: "short", day: "numeric" }) : "";
|
|
||||||
const satellite = img.satellite || "--";
|
|
||||||
const channels = img.channels || "--";
|
|
||||||
const lines = img.mcu_count || img.line_count || 0;
|
|
||||||
const unit = "MCU";
|
|
||||||
let link = img.path
|
|
||||||
? `<a href="${img.path}" target="_blank" style="color:var(--accent);">PNG</a>`
|
|
||||||
: "--";
|
|
||||||
if (img.geo_bounds) {
|
|
||||||
link += ` <a href="javascript:void(0)" onclick="window.satShowOnMap(${img.geo_bounds[0]},${img.geo_bounds[1]},${img.geo_bounds[2]},${img.geo_bounds[3]})" style="color:var(--accent);">Map</a>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
row.innerHTML = [
|
|
||||||
`<span>${date} ${ts}</span>`,
|
|
||||||
`<span class="sat-col-type ${typeClass}">${typeName}</span>`,
|
|
||||||
`<span>${satellite}</span>`,
|
|
||||||
`<span>${channels}</span>`,
|
|
||||||
`<span>${lines} ${unit}</span>`,
|
|
||||||
`<span>${link}</span>`,
|
|
||||||
].join("");
|
|
||||||
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSatHistoryTable() {
|
|
||||||
if (!satDom.historyList) return;
|
|
||||||
const items = getSatFilteredHistory();
|
|
||||||
const fragment = document.createDocumentFragment();
|
|
||||||
for (let i = 0; i < items.length; i += 1) {
|
|
||||||
fragment.appendChild(renderSatHistoryRow(items[i]));
|
|
||||||
}
|
|
||||||
satDom.historyList.replaceChildren(fragment);
|
|
||||||
|
|
||||||
if (satDom.historyCount) {
|
|
||||||
const total = satImageHistory.length;
|
|
||||||
const shown = items.length;
|
|
||||||
satDom.historyCount.textContent =
|
|
||||||
total === 0
|
|
||||||
? "No images yet"
|
|
||||||
: shown === total
|
|
||||||
? `${total} image${total === 1 ? "" : "s"}`
|
|
||||||
: `${shown} of ${total} images`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Add image to history ────────────────────────────────────────────
|
|
||||||
function addSatImage(img, decoder) {
|
|
||||||
const tsMs = Number.isFinite(img.ts_ms) ? Number(img.ts_ms) : Date.now();
|
|
||||||
img._tsMs = tsMs;
|
|
||||||
img._ts = new Date(tsMs).toLocaleTimeString([], {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
second: "2-digit",
|
|
||||||
});
|
|
||||||
img._decoder = decoder;
|
|
||||||
|
|
||||||
satImageHistory.unshift(img);
|
|
||||||
if (satImageHistory.length > SAT_MAX_IMAGES) {
|
|
||||||
satImageHistory = satImageHistory.slice(0, SAT_MAX_IMAGES);
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleSatUi("sat-latest", () => renderSatLatestCard());
|
|
||||||
if (satActiveView === "history") {
|
|
||||||
scheduleSatUi("sat-history", () => renderSatHistoryTable());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Server callbacks ────────────────────────────────────────────────
|
|
||||||
window.onServerLrptProgress = function (msg) {
|
|
||||||
if (satDom.status && msg.mcu_count > 0) {
|
|
||||||
satDom.status.textContent = "Receiving \u2014 " + msg.mcu_count + " MCU rows decoded";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.onServerLrptImage = function (msg) {
|
|
||||||
if (satDom.status) satDom.status.textContent = "Image received (Meteor LRPT)";
|
|
||||||
addSatImage(msg, "lrpt");
|
|
||||||
if (msg.geo_bounds && msg.path && window.addSatMapOverlay) {
|
|
||||||
window.addSatMapOverlay(msg);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.resetSatHistoryView = function () {
|
|
||||||
satImageHistory = [];
|
|
||||||
if (satDom.historyList) satDom.historyList.innerHTML = "";
|
|
||||||
renderSatLatestCard();
|
|
||||||
renderSatHistoryTable();
|
|
||||||
if (window.clearSatMapOverlays) window.clearSatMapOverlays();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.pruneSatHistoryView = function () {
|
|
||||||
renderSatHistoryTable();
|
|
||||||
renderSatLatestCard();
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Toggle buttons ──────────────────────────────────────────────────
|
|
||||||
const lrptDecodeToggleBtn = document.getElementById("lrpt-decode-toggle-btn");
|
|
||||||
lrptDecodeToggleBtn?.addEventListener("click", async () => {
|
|
||||||
try {
|
|
||||||
await window.takeSchedulerControlForDecoderDisable?.(lrptDecodeToggleBtn);
|
|
||||||
await postPath("/toggle_lrpt_decode");
|
|
||||||
} catch (e) {
|
|
||||||
console.error("LRPT toggle failed", e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Filter / sort event listeners ───────────────────────────────────
|
|
||||||
satDom.filterInput?.addEventListener("input", () => {
|
|
||||||
satFilterText = satDom.filterInput.value.trim().toUpperCase();
|
|
||||||
renderSatHistoryTable();
|
|
||||||
});
|
|
||||||
|
|
||||||
satDom.sortSelect?.addEventListener("change", () => renderSatHistoryTable());
|
|
||||||
satDom.typeFilter?.addEventListener("change", () => renderSatHistoryTable());
|
|
||||||
|
|
||||||
// ── Settings: clear history ─────────────────────────────────────────
|
|
||||||
document
|
|
||||||
.getElementById("settings-clear-sat-history")
|
|
||||||
?.addEventListener("click", async () => {
|
|
||||||
if (!confirm("Clear all satellite decode history? This cannot be undone.")) return;
|
|
||||||
try {
|
|
||||||
await postPath("/clear_lrpt_decode");
|
|
||||||
window.resetSatHistoryView();
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Weather satellite history clear failed", e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Predictions: helpers ────────────────────────────────────────────
|
|
||||||
function azToCardinal(deg) {
|
|
||||||
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
|
|
||||||
return dirs[Math.round(deg / 45) % 8];
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPredTime(ms) {
|
|
||||||
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) {
|
|
||||||
if (s >= 60) return `${Math.round(s / 60)} min`;
|
|
||||||
return `${s}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCountdown(ms) {
|
|
||||||
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) {
|
|
||||||
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) {
|
|
||||||
const countdownEls = container ? 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 = parseInt(el.dataset.los, 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, now) {
|
|
||||||
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) {
|
|
||||||
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() {
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
|
|
||||||
satDom.predFilter?.addEventListener("input", () => {
|
|
||||||
satPredFilterText = satDom.predFilter.value.trim().toUpperCase();
|
|
||||||
applyPredFilters();
|
|
||||||
});
|
|
||||||
|
|
||||||
satDom.predMinEl?.addEventListener("change", () => {
|
|
||||||
satPredMinEl = parseInt(satDom.predMinEl.value, 10) || 0;
|
|
||||||
applyPredFilters();
|
|
||||||
});
|
|
||||||
|
|
||||||
satDom.predCategory?.addEventListener("change", () => {
|
|
||||||
satPredCategory = satDom.predCategory.value;
|
|
||||||
applyPredFilters();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Predictions: main render ────────────────────────────────────────
|
|
||||||
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);
|
|
||||||
|
|
||||||
// ── 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() {
|
|
||||||
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();
|
|
||||||
satPredSatCount = data.satellite_count || 0;
|
|
||||||
if (data.error) {
|
|
||||||
satPredData = [];
|
|
||||||
renderSatPredictions([], data.error);
|
|
||||||
} else {
|
|
||||||
satPredData = data.passes || [];
|
|
||||||
renderSatPredictions(getFilteredPredictions());
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
renderSatPredictions([], `Failed to load predictions: ${e.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Navigate to map centered on satellite image bounds ──────────────
|
|
||||||
window.satShowOnMap = function (south, west, north, east) {
|
|
||||||
if (typeof window.enableMapSourceFilter === "function") {
|
|
||||||
window.enableMapSourceFilter("sat");
|
|
||||||
}
|
|
||||||
const lat = (south + north) / 2;
|
|
||||||
const lon = (west + east) / 2;
|
|
||||||
if (window.navigateToAprsMap) {
|
|
||||||
window.navigateToAprsMap(lat, lon);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Initial render ──────────────────────────────────────────────────
|
|
||||||
renderSatLatestCard();
|
|
||||||
renderSatHistoryTable();
|
|
||||||
@@ -1,292 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
// --- WSPR Decoder Plugin (server-side decode) ---
|
|
||||||
const wsprStatus = document.getElementById("wspr-status");
|
|
||||||
const wsprPeriodEl = document.getElementById("wspr-period");
|
|
||||||
const wsprMessagesEl = document.getElementById("wspr-messages");
|
|
||||||
const wsprFilterInput = document.getElementById("wspr-filter");
|
|
||||||
const WSPR_PERIOD_SECONDS = 120;
|
|
||||||
let wsprFilterText = "";
|
|
||||||
let wsprMessageHistory = [];
|
|
||||||
|
|
||||||
function currentWsprHistoryRetentionMs() {
|
|
||||||
return typeof window.getDecodeHistoryRetentionMs === "function"
|
|
||||||
? window.getDecodeHistoryRetentionMs()
|
|
||||||
: 24 * 60 * 60 * 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pruneWsprMessageHistory() {
|
|
||||||
const cutoffMs = Date.now() - currentWsprHistoryRetentionMs();
|
|
||||||
wsprMessageHistory = wsprMessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleWsprHistoryRender() {
|
|
||||||
if (typeof window.trxScheduleUiFrameJob === "function") {
|
|
||||||
window.trxScheduleUiFrameJob("wspr-history", () => renderWsprHistory());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
renderWsprHistory();
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtWsprTime(tsMs) {
|
|
||||||
if (!tsMs) return "--:--:--";
|
|
||||||
return new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateWsprPeriodTimer() {
|
|
||||||
if (!wsprPeriodEl) return;
|
|
||||||
const nowSec = Math.floor(Date.now() / 1000);
|
|
||||||
const remaining = WSPR_PERIOD_SECONDS - (nowSec % WSPR_PERIOD_SECONDS);
|
|
||||||
const mm = String(Math.floor(remaining / 60)).padStart(2, "0");
|
|
||||||
const ss = String(remaining % 60).padStart(2, "0");
|
|
||||||
wsprPeriodEl.textContent = `Next slot ${mm}:${ss}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateWsprPeriodTimer();
|
|
||||||
setInterval(updateWsprPeriodTimer, 500);
|
|
||||||
|
|
||||||
function renderWsprRow(msg) {
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = "ft8-row";
|
|
||||||
row.dataset.decoder = "wspr";
|
|
||||||
const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--";
|
|
||||||
const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--";
|
|
||||||
const baseHz = Number.isFinite(window.ft8BaseHz) ? window.ft8BaseHz : null;
|
|
||||||
const rfHz = Number.isFinite(msg.freq_hz) && Number.isFinite(baseHz) ? (baseHz + msg.freq_hz) : null;
|
|
||||||
const freq = Number.isFinite(rfHz) ? rfHz.toFixed(0) : "--";
|
|
||||||
const message = (msg.message || "").toString();
|
|
||||||
row.dataset.message = message.toUpperCase();
|
|
||||||
row.innerHTML = `<span class="ft8-time">${fmtWsprTime(msg.ts_ms)}</span><span class="ft8-snr">${snr}</span><span class="ft8-dt">${dt}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderWsprMessage(message)}</span>`;
|
|
||||||
applyWsprFilterToRow(row);
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderWsprHistory() {
|
|
||||||
pruneWsprMessageHistory();
|
|
||||||
if (!wsprMessagesEl) return;
|
|
||||||
const fragment = document.createDocumentFragment();
|
|
||||||
for (let i = 0; i < wsprMessageHistory.length; i += 1) {
|
|
||||||
fragment.appendChild(renderWsprRow(wsprMessageHistory[i]));
|
|
||||||
}
|
|
||||||
wsprMessagesEl.replaceChildren(fragment);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addWsprMessage(msg) {
|
|
||||||
msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
|
||||||
wsprMessageHistory.unshift(msg);
|
|
||||||
pruneWsprMessageHistory();
|
|
||||||
scheduleWsprHistoryRender();
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeServerWsprMessage(msg) {
|
|
||||||
const raw = (msg.message || "").toString();
|
|
||||||
const grids = extractAllGrids(raw);
|
|
||||||
const station = extractLikelyCallsign(raw);
|
|
||||||
const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null;
|
|
||||||
const rfHz = Number.isFinite(msg.freq_hz) && Number.isFinite(baseHz)
|
|
||||||
? (baseHz + Number(msg.freq_hz))
|
|
||||||
: (Number.isFinite(msg.freq_hz) ? Number(msg.freq_hz) : null);
|
|
||||||
return {
|
|
||||||
raw,
|
|
||||||
grids,
|
|
||||||
station,
|
|
||||||
rfHz,
|
|
||||||
history: {
|
|
||||||
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
|
|
||||||
ts_ms: msg.ts_ms,
|
|
||||||
snr_db: msg.snr_db,
|
|
||||||
dt_s: msg.dt_s,
|
|
||||||
freq_hz: msg.freq_hz,
|
|
||||||
message: raw,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
window.onServerWsprBatch = function(messages) {
|
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
|
||||||
wsprStatus.textContent = "Receiving";
|
|
||||||
const normalized = [];
|
|
||||||
for (const msg of messages) {
|
|
||||||
const next = normalizeServerWsprMessage(msg);
|
|
||||||
if (next.grids.length > 0 && window.mapAddLocator) {
|
|
||||||
window.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
|
||||||
...msg,
|
|
||||||
freq_hz: next.rfHz,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
|
||||||
normalized.push(next.history);
|
|
||||||
}
|
|
||||||
normalized.reverse();
|
|
||||||
wsprMessageHistory = normalized.concat(wsprMessageHistory);
|
|
||||||
pruneWsprMessageHistory();
|
|
||||||
scheduleWsprHistoryRender();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.restoreWsprHistory = function(messages) {
|
|
||||||
window.onServerWsprBatch(messages);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.pruneWsprHistoryView = function() {
|
|
||||||
pruneWsprMessageHistory();
|
|
||||||
renderWsprHistory();
|
|
||||||
};
|
|
||||||
|
|
||||||
function escapeWsprHtml(input) {
|
|
||||||
return input
|
|
||||||
.replaceAll("&", "&")
|
|
||||||
.replaceAll("<", "<")
|
|
||||||
.replaceAll(">", ">")
|
|
||||||
.replaceAll("\"", """);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderWsprMessage(message) {
|
|
||||||
let out = "";
|
|
||||||
let i = 0;
|
|
||||||
while (i < message.length) {
|
|
||||||
const ch = message[i];
|
|
||||||
if (isAlphaNum(ch)) {
|
|
||||||
let j = i + 1;
|
|
||||||
while (j < message.length && isAlphaNum(message[j])) j++;
|
|
||||||
const token = message.slice(i, j);
|
|
||||||
const grid = token.toUpperCase();
|
|
||||||
if (isMaidenheadGridToken(grid)) {
|
|
||||||
out += `<span class="ft8-locator" data-locator-grid="${grid}" role="button" tabindex="0" aria-label="Show locator ${grid} on map">${grid}</span>`;
|
|
||||||
} else {
|
|
||||||
out += escapeWsprHtml(token);
|
|
||||||
}
|
|
||||||
i = j;
|
|
||||||
} else {
|
|
||||||
out += escapeWsprHtml(ch);
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractAllGrids(message) {
|
|
||||||
const out = [];
|
|
||||||
const seen = new Set();
|
|
||||||
const parts = message.toUpperCase().split(/[^A-Z0-9]+/);
|
|
||||||
for (const token of parts) {
|
|
||||||
if (!token) continue;
|
|
||||||
if (isMaidenheadGridToken(token) && !seen.has(token)) {
|
|
||||||
seen.add(token);
|
|
||||||
out.push(token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractLikelyCallsign(message) {
|
|
||||||
const parts = String(message || "").toUpperCase().split(/[^A-Z0-9/]+/);
|
|
||||||
for (const token of parts) {
|
|
||||||
if (!token) continue;
|
|
||||||
if (token.length < 3 || token.length > 12) continue;
|
|
||||||
if (token === "CQ" || token === "DE" || token === "QRZ" || token === "DX") continue;
|
|
||||||
if (isMaidenheadGridToken(token)) continue;
|
|
||||||
if (/^[A-Z0-9/]{1,5}\d[A-Z0-9/]{1,6}$/.test(token)) return token;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isFtxFarewellToken(token) {
|
|
||||||
const normalized = String(token || "").trim().toUpperCase();
|
|
||||||
return normalized === "RR73" || normalized === "73" || normalized === "RR";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isMaidenheadGridToken(token) {
|
|
||||||
const normalized = String(token || "").trim().toUpperCase();
|
|
||||||
return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && !isFtxFarewellToken(normalized);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAlphaNum(ch) {
|
|
||||||
return /[A-Za-z0-9]/.test(ch);
|
|
||||||
}
|
|
||||||
|
|
||||||
function activateWsprHistoryLocator(targetEl) {
|
|
||||||
const locatorEl = targetEl?.closest?.(".ft8-locator[data-locator-grid]");
|
|
||||||
if (!locatorEl) return false;
|
|
||||||
const grid = String(locatorEl.dataset.locatorGrid || "").toUpperCase();
|
|
||||||
if (!grid) return false;
|
|
||||||
if (typeof window.navigateToMapLocator === "function") {
|
|
||||||
window.navigateToMapLocator(grid, "wspr");
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyWsprFilterToRow(row) {
|
|
||||||
if (!wsprFilterText) {
|
|
||||||
row.style.display = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const message = row.dataset.message || "";
|
|
||||||
row.style.display = message.includes(wsprFilterText) ? "" : "none";
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyWsprFilterToAll() {
|
|
||||||
const rows = wsprMessagesEl.querySelectorAll(".ft8-row");
|
|
||||||
rows.forEach((row) => applyWsprFilterToRow(row));
|
|
||||||
}
|
|
||||||
|
|
||||||
window.resetWsprHistoryView = function() {
|
|
||||||
wsprMessagesEl.innerHTML = "";
|
|
||||||
wsprMessageHistory = [];
|
|
||||||
renderWsprHistory();
|
|
||||||
if (window.clearMapMarkersByType) window.clearMapMarkersByType("wspr");
|
|
||||||
};
|
|
||||||
|
|
||||||
if (wsprFilterInput) {
|
|
||||||
wsprFilterInput.addEventListener("input", () => {
|
|
||||||
wsprFilterText = wsprFilterInput.value.trim().toUpperCase();
|
|
||||||
renderWsprHistory();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (wsprMessagesEl) {
|
|
||||||
wsprMessagesEl.addEventListener("click", (event) => {
|
|
||||||
if (!activateWsprHistoryLocator(event.target)) return;
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
});
|
|
||||||
wsprMessagesEl.addEventListener("keydown", (event) => {
|
|
||||||
if (event.key !== "Enter" && event.key !== " ") return;
|
|
||||||
if (!activateWsprHistoryLocator(event.target)) return;
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const wsprDecodeToggleBtn = document.getElementById("wspr-decode-toggle-btn");
|
|
||||||
wsprDecodeToggleBtn?.addEventListener("click", async () => {
|
|
||||||
try {
|
|
||||||
await window.takeSchedulerControlForDecoderDisable?.(wsprDecodeToggleBtn);
|
|
||||||
await postPath("/toggle_wspr_decode");
|
|
||||||
} catch (e) {
|
|
||||||
console.error("WSPR toggle failed", e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById("settings-clear-wspr-history")?.addEventListener("click", async () => {
|
|
||||||
if (!confirm("Clear all WSPR decode history? This cannot be undone.")) return;
|
|
||||||
try {
|
|
||||||
await postPath("/clear_wspr_decode");
|
|
||||||
window.resetWsprHistoryView();
|
|
||||||
} catch (e) {
|
|
||||||
console.error("WSPR history clear failed", e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
window.onServerWspr = function(msg) {
|
|
||||||
wsprStatus.textContent = "Receiving";
|
|
||||||
const next = normalizeServerWsprMessage(msg);
|
|
||||||
if (next.grids.length > 0 && window.mapAddLocator) {
|
|
||||||
window.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
|
||||||
...msg,
|
|
||||||
freq_hz: next.rfHz,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
addWsprMessage(next.history);
|
|
||||||
};
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,144 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import vm from "node:vm";
|
||||||
|
|
||||||
|
class ClassList {
|
||||||
|
constructor() { this.values = new Set(); }
|
||||||
|
add(...names) { names.forEach(name => this.values.add(name)); }
|
||||||
|
remove(...names) { names.forEach(name => this.values.delete(name)); }
|
||||||
|
contains(name) { return this.values.has(name); }
|
||||||
|
toggle(name, force) {
|
||||||
|
const enabled = force === undefined ? !this.contains(name) : force;
|
||||||
|
if (enabled) this.add(name); else this.remove(name);
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Element {
|
||||||
|
constructor(tagName, document) {
|
||||||
|
this.tagName = tagName.toUpperCase();
|
||||||
|
this.ownerDocument = document;
|
||||||
|
this.children = [];
|
||||||
|
this.dataset = {};
|
||||||
|
this.attributes = new Map();
|
||||||
|
this.classList = new ClassList();
|
||||||
|
this.listeners = new Map();
|
||||||
|
this.style = {};
|
||||||
|
this.options = [];
|
||||||
|
this.value = "";
|
||||||
|
this.textContent = "";
|
||||||
|
}
|
||||||
|
set id(value) { this._id = value; if (value) this.ownerDocument.elements.set(value, this); }
|
||||||
|
get id() { return this._id || ""; }
|
||||||
|
set className(value) { this.classList = new ClassList(); value.split(/\s+/).filter(Boolean).forEach(name => this.classList.add(name)); }
|
||||||
|
set innerHTML(value) {
|
||||||
|
this._innerHTML = value;
|
||||||
|
if (value.includes('value="confirm"')) {
|
||||||
|
const title = new Element("h2", this.ownerDocument); title.id = "ui-confirm-title";
|
||||||
|
const message = new Element("p", this.ownerDocument); message.id = "ui-confirm-message";
|
||||||
|
const confirm = new Element("button", this.ownerDocument); confirm.value = "confirm";
|
||||||
|
this.append(title, message, confirm);
|
||||||
|
this._confirmButton = confirm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
get innerHTML() { return this._innerHTML || ""; }
|
||||||
|
appendChild(child) { this.children.push(child); child.parentElement = this; return child; }
|
||||||
|
append(...children) { children.forEach(child => this.appendChild(child)); }
|
||||||
|
insertBefore(child) { return this.appendChild(child); }
|
||||||
|
remove() { if (this.parentElement) this.parentElement.children = this.parentElement.children.filter(child => child !== this); }
|
||||||
|
replaceChildren(...children) { this.children = []; this.options = []; this.append(...children); }
|
||||||
|
setAttribute(name, value) { this.attributes.set(name, String(value)); }
|
||||||
|
getAttribute(name) { return this.attributes.get(name); }
|
||||||
|
addEventListener(type, listener) { this.listeners.set(type, listener); }
|
||||||
|
dispatch(type, event = {}) { this.listeners.get(type)?.({ target: this, preventDefault() {}, ...event }); }
|
||||||
|
querySelector(selector) {
|
||||||
|
if (selector === '[value="confirm"]') return this._confirmButton || null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
querySelectorAll(selector) {
|
||||||
|
if (selector === ".tab[data-tab]") return this.children.filter(child => child.dataset.tab);
|
||||||
|
if (selector === ".sub-tab[data-subtab]") return this.children.filter(child => child.dataset.subtab);
|
||||||
|
if (selector === '[role="tab"]') return this.children.filter(child => child.getAttribute("role") === "tab");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
add(option) { this.options.push(option); if (!this.value) this.value = option.value; }
|
||||||
|
showModal() { this.open = true; }
|
||||||
|
close(value) { this.returnValue = value; this.open = false; this.dispatch("close"); }
|
||||||
|
focus() { this.focused = true; }
|
||||||
|
click() { this.clicked = true; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class DocumentFixture {
|
||||||
|
constructor() {
|
||||||
|
this.readyState = "loading";
|
||||||
|
this.elements = new Map();
|
||||||
|
this.body = new Element("body", this);
|
||||||
|
}
|
||||||
|
createElement(tagName) { return new Element(tagName, this); }
|
||||||
|
getElementById(id) { return this.elements.get(id) || null; }
|
||||||
|
querySelector() { return null; }
|
||||||
|
querySelectorAll() { return []; }
|
||||||
|
addEventListener() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const document = new DocumentFixture();
|
||||||
|
const storage = new Map();
|
||||||
|
const localStorage = {
|
||||||
|
getItem(key) { return storage.has(key) ? storage.get(key) : null; },
|
||||||
|
setItem(key, value) { storage.set(key, String(value)); },
|
||||||
|
};
|
||||||
|
const window = { document, localStorage, addEventListener() {} };
|
||||||
|
const context = vm.createContext({
|
||||||
|
window, document, localStorage,
|
||||||
|
Option: class Option { constructor(label, value) { this.label = label; this.value = value; } },
|
||||||
|
MutationObserver: class MutationObserver { observe() {} },
|
||||||
|
requestAnimationFrame(callback) { callback(); },
|
||||||
|
setTimeout() { return 1; },
|
||||||
|
clearTimeout() {},
|
||||||
|
console,
|
||||||
|
});
|
||||||
|
|
||||||
|
const source = await readFile(new URL("../ui-core.js", import.meta.url), "utf8");
|
||||||
|
new vm.Script(source, { filename: "ui-core.js" }).runInContext(context);
|
||||||
|
const ui = window.trxUi;
|
||||||
|
|
||||||
|
const toast = ui.notify("Saved", { kind: "success" });
|
||||||
|
assert.equal(toast.getAttribute("role"), "status");
|
||||||
|
assert.equal(toast.classList.contains("toast-success"), true);
|
||||||
|
assert.equal(document.getElementById("toast-region").children.length, 1);
|
||||||
|
|
||||||
|
const confirmation = ui.confirm({ title: "Delete?", message: "Permanent", confirmLabel: "Delete" });
|
||||||
|
const dialog = document.getElementById("ui-confirm-dialog");
|
||||||
|
assert.equal(dialog.open, true);
|
||||||
|
assert.equal(document.getElementById("ui-confirm-title").textContent, "Delete?");
|
||||||
|
dialog.close("confirm");
|
||||||
|
assert.equal(await confirmation, true);
|
||||||
|
|
||||||
|
const tabBar = new Element("div", document);
|
||||||
|
const firstTab = new Element("button", document); firstTab.dataset.tab = "main"; firstTab.classList.add("active");
|
||||||
|
const secondTab = new Element("button", document); secondTab.dataset.tab = "map";
|
||||||
|
tabBar.append(firstTab, secondTab);
|
||||||
|
const mainPanel = new Element("section", document); mainPanel.id = "tab-main";
|
||||||
|
const mapPanel = new Element("section", document); mapPanel.id = "tab-map";
|
||||||
|
ui.prepareTabList(tabBar, "primary");
|
||||||
|
assert.equal(firstTab.getAttribute("aria-selected"), "true");
|
||||||
|
tabBar.dispatch("keydown", { target: firstTab, key: "ArrowRight" });
|
||||||
|
assert.equal(secondTab.focused, true);
|
||||||
|
assert.equal(secondTab.clicked, true);
|
||||||
|
|
||||||
|
localStorage.setItem("trxOperatorLayout:rig-a", "broadcast");
|
||||||
|
ui.setActiveRig("rig-a");
|
||||||
|
assert.equal(document.body.dataset.operatorLayout, "compact");
|
||||||
|
ui.setLayoutCapabilities({ broadcast: true });
|
||||||
|
ui.setActiveRig("rig-a");
|
||||||
|
assert.equal(document.body.dataset.operatorLayout, "broadcast");
|
||||||
|
ui.setActiveRig("rig-b");
|
||||||
|
ui.applyLayout("digital");
|
||||||
|
assert.equal(document.body.dataset.operatorLayout, "compact");
|
||||||
|
assert.equal(localStorage.getItem("trxOperatorLayout:rig-b"), "compact");
|
||||||
|
|
||||||
|
console.log("ui-core component tests passed");
|
||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 93 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
+232
File diff suppressed because one or more lines are too long
@@ -2,8 +2,53 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
fn generate_asset_manifest() {
|
||||||
|
let manifest_dir = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").expect("manifest dir"));
|
||||||
|
let generated_dir = manifest_dir.join("assets/web/generated");
|
||||||
|
println!("cargo:rerun-if-changed={}", generated_dir.display());
|
||||||
|
|
||||||
|
let mut filenames = fs::read_dir(&generated_dir)
|
||||||
|
.expect("read generated frontend assets")
|
||||||
|
.map(|entry| entry.expect("read generated asset entry"))
|
||||||
|
.filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_file()))
|
||||||
|
.map(|entry| {
|
||||||
|
entry
|
||||||
|
.file_name()
|
||||||
|
.into_string()
|
||||||
|
.expect("UTF-8 asset filename")
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
filenames.sort();
|
||||||
|
|
||||||
|
let mut output = String::from(
|
||||||
|
"// Generated by build.rs from committed frontend output.\n\
|
||||||
|
pub const GENERATED_ASSETS: &[(&str, &[u8])] = &[\n",
|
||||||
|
);
|
||||||
|
for filename in filenames {
|
||||||
|
assert!(
|
||||||
|
filename
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte)),
|
||||||
|
"unsupported generated asset filename: {filename}"
|
||||||
|
);
|
||||||
|
writeln!(
|
||||||
|
output,
|
||||||
|
" ({filename:?}, include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/assets/web/generated/\", {filename:?}))),"
|
||||||
|
)
|
||||||
|
.expect("write asset manifest");
|
||||||
|
}
|
||||||
|
output.push_str("];\n");
|
||||||
|
|
||||||
|
let output_path =
|
||||||
|
PathBuf::from(std::env::var_os("OUT_DIR").expect("out dir")).join("generated_assets.rs");
|
||||||
|
fs::write(output_path, output).expect("write generated asset manifest");
|
||||||
|
}
|
||||||
|
|
||||||
fn utc_ymd_from_unix_secs(secs: i64) -> (i32, u32, u32) {
|
fn utc_ymd_from_unix_secs(secs: i64) -> (i32, u32, u32) {
|
||||||
let days = secs.div_euclid(86_400);
|
let days = secs.div_euclid(86_400);
|
||||||
let z = days + 719_468;
|
let z = days + 719_468;
|
||||||
@@ -20,6 +65,7 @@ fn utc_ymd_from_unix_secs(secs: i64) -> (i32, u32, u32) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
generate_asset_manifest();
|
||||||
let secs = match SystemTime::now().duration_since(UNIX_EPOCH) {
|
let secs = match SystemTime::now().duration_since(UNIX_EPOCH) {
|
||||||
Ok(d) => d.as_secs() as i64,
|
Ok(d) => d.as_secs() as i64,
|
||||||
Err(_) => 0,
|
Err(_) => 0,
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use trx_core::radio::freq::{Band, Freq};
|
||||||
|
use trx_core::rig::state::{SpectrumData, VchanRdsEntry};
|
||||||
|
use trx_core::rig::{
|
||||||
|
RigAccessMethod, RigCapabilities, RigInfo, RigRxStatus, RigStatus, RigTxStatus, RigVfo,
|
||||||
|
RigVfoEntry,
|
||||||
|
};
|
||||||
|
use trx_core::{DecoderConfig, RdsData, RigFilterState, RigMode, RigSnapshot, WfmDenoiseLevel};
|
||||||
|
use trx_frontend_http::server::api::rig::{RigListItem, RigListResponse};
|
||||||
|
use trx_frontend_http::server::api::FrontendMeta;
|
||||||
|
use trx_protocol::{DecoderActivation, DecoderDescriptor};
|
||||||
|
use ts_rs::{Config, TS};
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let mut output = format!(
|
||||||
|
"// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>\n\
|
||||||
|
//\n\
|
||||||
|
// SPDX-License-{}: GPL-2.0-or-later\n\n\
|
||||||
|
// Generated by `cargo run -p trx-frontend-http --example generate_typescript`.\n\
|
||||||
|
// Do not edit manually.\n\n",
|
||||||
|
"Identifier",
|
||||||
|
);
|
||||||
|
let config = Config::default();
|
||||||
|
|
||||||
|
macro_rules! export {
|
||||||
|
($type:ty) => {
|
||||||
|
writeln!(output, "export {}\n", <$type as TS>::decl(&config))?;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export!(Band);
|
||||||
|
export!(Freq);
|
||||||
|
export!(RigMode);
|
||||||
|
export!(RigAccessMethod);
|
||||||
|
export!(RigCapabilities);
|
||||||
|
export!(RigInfo);
|
||||||
|
export!(RigVfoEntry);
|
||||||
|
export!(RigVfo);
|
||||||
|
export!(RigTxStatus);
|
||||||
|
export!(RigRxStatus);
|
||||||
|
export!(RigStatus);
|
||||||
|
export!(DecoderConfig);
|
||||||
|
export!(WfmDenoiseLevel);
|
||||||
|
export!(RigFilterState);
|
||||||
|
export!(RdsData);
|
||||||
|
export!(SpectrumData);
|
||||||
|
export!(VchanRdsEntry);
|
||||||
|
export!(RigSnapshot);
|
||||||
|
export!(RigListItem);
|
||||||
|
export!(RigListResponse);
|
||||||
|
export!(FrontendMeta);
|
||||||
|
export!(DecoderActivation);
|
||||||
|
export!(DecoderDescriptor);
|
||||||
|
|
||||||
|
let output_path =
|
||||||
|
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("frontend/src/api/generated.ts");
|
||||||
|
fs::create_dir_all(output_path.parent().expect("generated file has a parent"))?;
|
||||||
|
fs::write(output_path, output)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user