Compare commits
178
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83b453135a | ||
|
|
1794f899f3 | ||
|
|
ebe0462b45 | ||
|
|
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
|
||||
|
||||
# CI for the self-hosted, host-executor Podman runners (see container/).
|
||||
# The runner image bakes in the Rust toolchain and all build dependencies,
|
||||
# so jobs go straight to cargo — no apt/rustup setup steps (which also
|
||||
# collided on the dpkg lock when jobs ran concurrently in the same runner).
|
||||
# CI for the Docker-executor runner (VM). The lint, test and frontend jobs run
|
||||
# inside the shared trx-rs SDK image (container/Containerfile), which bakes in
|
||||
# the pinned Rust toolchain, Node.js, Chromium and all build dependencies. The
|
||||
# reuse job uses the upstream Docker action, which the Docker executor launches
|
||||
# as a sibling container.
|
||||
|
||||
name: CI
|
||||
|
||||
@@ -16,32 +17,73 @@ on:
|
||||
|
||||
env:
|
||||
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:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
container: git.haxx.space/sjg/trx-rs/sdk:latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: rustfmt
|
||||
run: cargo fmt --all -- --check
|
||||
- name: clippy
|
||||
run: cargo clippy --workspace --all-targets --all-features -- -D warnings
|
||||
- name: sccache stats
|
||||
if: always()
|
||||
run: sccache --show-stats
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
container: git.haxx.space/sjg/trx-rs/sdk:latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build
|
||||
run: cargo build --workspace --all-targets --locked
|
||||
- name: Test
|
||||
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:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: REUSE compliance
|
||||
# `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
|
||||
- uses: fsfe/reuse-action@v5
|
||||
|
||||
@@ -21,6 +21,9 @@ Cargo.lock
|
||||
coverage/
|
||||
benchmarks/
|
||||
|
||||
# Frontend dependencies
|
||||
**/node_modules/
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
|
||||
@@ -24,6 +24,12 @@ cargo test -p trx-core
|
||||
./target/release/trx-server --print-config > trx-server.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
|
||||
./target/release/trx-server --config trx-server.toml
|
||||
# or via CLI args:
|
||||
@@ -41,7 +47,8 @@ This is a Cargo workspace. All crates live under `src/`:
|
||||
src/
|
||||
trx-core/ # Core types, traits, state machine, controller (~3,500 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-server/ # Server binary: rig_task, audio pipeline, listener (~3,700 LOC)
|
||||
trx-backend/ # Backend abstraction trait + factory + dummy
|
||||
|
||||
Generated
+78
-4
@@ -2412,6 +2412,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "serde_json"
|
||||
version = "1.0.149"
|
||||
@@ -2645,6 +2655,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
@@ -3022,10 +3041,6 @@ dependencies = [
|
||||
name = "trx-app"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"toml",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
@@ -3106,6 +3121,7 @@ dependencies = [
|
||||
"toml",
|
||||
"tracing",
|
||||
"trx-app",
|
||||
"trx-config",
|
||||
"trx-core",
|
||||
"trx-frontend",
|
||||
"trx-frontend-http",
|
||||
@@ -3115,6 +3131,23 @@ dependencies = [
|
||||
"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]]
|
||||
name = "trx-configurator"
|
||||
version = "0.1.0"
|
||||
@@ -3123,13 +3156,16 @@ dependencies = [
|
||||
"dialoguer",
|
||||
"tempfile",
|
||||
"tokio-serial",
|
||||
"toml",
|
||||
"toml_edit 0.22.27",
|
||||
"trx-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "trx-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"flate2",
|
||||
"reqwest",
|
||||
"serde",
|
||||
@@ -3137,6 +3173,7 @@ dependencies = [
|
||||
"sgp4",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"ts-rs",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -3195,6 +3232,7 @@ dependencies = [
|
||||
"trx-core",
|
||||
"trx-frontend",
|
||||
"trx-protocol",
|
||||
"ts-rs",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -3238,6 +3276,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"trx-core",
|
||||
"ts-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3282,12 +3321,14 @@ dependencies = [
|
||||
"trx-app",
|
||||
"trx-aprs",
|
||||
"trx-backend",
|
||||
"trx-config",
|
||||
"trx-core",
|
||||
"trx-cw",
|
||||
"trx-decode-log",
|
||||
"trx-ftx",
|
||||
"trx-protocol",
|
||||
"trx-reporting",
|
||||
"trx-sstv",
|
||||
"trx-vdes",
|
||||
"trx-wefax",
|
||||
"trx-wspr",
|
||||
@@ -3295,6 +3336,16 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "trx-sstv"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"png",
|
||||
"tracing",
|
||||
"trx-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "trx-vdes"
|
||||
version = "0.1.0"
|
||||
@@ -3333,6 +3384,29 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "typenum"
|
||||
version = "1.20.0"
|
||||
|
||||
@@ -12,11 +12,13 @@ members = [
|
||||
"src/decoders/trx-ftx",
|
||||
"src/decoders/trx-rds",
|
||||
"src/decoders/trx-vdes",
|
||||
"src/decoders/trx-sstv",
|
||||
"src/decoders/trx-wefax",
|
||||
"src/decoders/trx-wspr",
|
||||
"src/trx-core",
|
||||
"src/trx-protocol",
|
||||
"src/trx-app",
|
||||
"src/trx-config",
|
||||
"src/trx-reporting",
|
||||
"src/trx-server",
|
||||
"src/trx-server/trx-backend",
|
||||
|
||||
@@ -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
|
||||
`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
|
||||
./target/release/trx-server --print-config > trx-server.toml
|
||||
./target/release/trx-client --print-config > trx-client.toml
|
||||
cp trx-rs.toml.example trx-rs.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
|
||||
|
||||
```bash
|
||||
@@ -107,6 +111,9 @@ Alternatively, generate example configs and edit them by hand:
|
||||
./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`).
|
||||
|
||||
## How It Works
|
||||
@@ -142,6 +149,9 @@ a unified set of frontends.
|
||||
## License
|
||||
|
||||
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
|
||||
the Leaflet AIS tracksymbol plugin under `assets/web/vendor/`) retain their
|
||||
original BSD-2-Clause license.
|
||||
bundled third-party license files. Bundled third-party components retain their
|
||||
original licenses: Leaflet is BSD-2-Clause, DSEG is OFL-1.1, and opus-decoder
|
||||
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",
|
||||
"docs/**",
|
||||
"aidocs/**",
|
||||
"container/**",
|
||||
".devcontainer/**",
|
||||
"src/decoders/trx-ftx/README.md",
|
||||
"src/decoders/trx-wxsat/README.md",
|
||||
"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-logo.png",
|
||||
"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-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"]
|
||||
SPDX-FileCopyrightText = "2020 The DSEG Authors (https://github.com/keshikan/DSEG)"
|
||||
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
|
||||
|
||||
# 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
|
||||
# actions/checkout and actions/cache) and the `reuse` tool are baked in, so CI
|
||||
# runs skip the per-run apt/rustup install cost. `sudo` is present so the
|
||||
# existing workflow's `sudo apt-get ...` / rustup steps remain valid — they
|
||||
# just become fast no-ops because everything is already installed.
|
||||
# Single source of truth for the build environment. Used two ways:
|
||||
# * CI — as the job container for the lint/test jobs (Docker executor).
|
||||
# * Dev — run locally or via .devcontainer for a reproducible toolchain.
|
||||
#
|
||||
# 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
|
||||
|
||||
ARG ACT_RUNNER_VERSION=0.2.11
|
||||
# Keep in sync with rust-toolchain.toml.
|
||||
ARG RUST_VERSION=1.97.1
|
||||
ARG NODE_MAJOR=20
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
RUSTUP_HOME=/opt/rustup \
|
||||
CARGO_HOME=/opt/cargo \
|
||||
PATH=/opt/cargo/bin:/usr/local/bin:/usr/bin:/bin
|
||||
RUSTUP_HOME=/usr/local/rustup \
|
||||
CARGO_HOME=/usr/local/cargo \
|
||||
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 \
|
||||
ca-certificates curl xz-utils git sudo pipx \
|
||||
ca-certificates curl git \
|
||||
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/*
|
||||
|
||||
# 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 - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# REUSE >= 3 (Debian's packaged reuse is too old for REUSE.toml).
|
||||
# The [charset-normalizer] extra provides an encoding-detection backend;
|
||||
# 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.
|
||||
# Pinned Rust toolchain, installed world-readable so any UID the runner or a
|
||||
# devcontainer uses can invoke cargo.
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
| sh -s -- -y --no-modify-path --profile minimal \
|
||||
--component rustfmt --component clippy \
|
||||
&& chmod -R a+rwX "$CARGO_HOME" "$RUSTUP_HOME"
|
||||
| sh -s -- -y --no-modify-path \
|
||||
--default-toolchain "${RUST_VERSION}" --profile minimal \
|
||||
--component rustfmt --component clippy \
|
||||
&& chmod -R a+rwX "$RUSTUP_HOME" "$CARGO_HOME"
|
||||
|
||||
# act_runner binary.
|
||||
RUN arch="$(dpkg --print-architecture)"; \
|
||||
case "$arch" in amd64) rarch=amd64;; arm64) rarch=arm64;; *) echo "unsupported arch $arch" >&2; exit 1;; esac; \
|
||||
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}" \
|
||||
&& chmod +x /usr/local/bin/act_runner
|
||||
# sccache — shared compilation cache. Enabled at build time via
|
||||
# RUSTC_WRAPPER (see the CI workflow and .devcontainer), not repo-wide, so
|
||||
# non-SDK builds are unaffected. musl build is static and runs anywhere.
|
||||
#
|
||||
# The release asset is per-architecture, so resolve it from `uname -m` rather
|
||||
# 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).
|
||||
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"]
|
||||
WORKDIR /work
|
||||
|
||||
+156
-81
@@ -3,116 +3,191 @@ SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
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
|
||||
containers managed by systemd (Quadlet) — one per project — instead of two
|
||||
VMs. Uses the **host executor**: workflow steps run directly inside a
|
||||
purpose-built runner image that already has the Rust toolchain and all build
|
||||
dependencies baked in, so CI runs skip the per-run install cost and no
|
||||
Docker/Podman socket is needed.
|
||||
|
||||
## Files
|
||||
A single container image that is the canonical build environment for trx-rs,
|
||||
used **both** by CI and by developers. It bakes in the pinned Rust toolchain
|
||||
(matching `rust-toolchain.toml`) and every build dependency, so the compiler
|
||||
and `clippy` are identical everywhere — no "works on my machine".
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `Containerfile` | Runner image: Debian + build deps + clang + Rust + Node + `reuse` + `act_runner`. |
|
||||
| `entrypoint.sh` | Registers on first boot (if needed), then runs the daemon. |
|
||||
| `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. |
|
||||
| `Containerfile` | The SDK image (Debian + build deps + pinned Rust + Node + git). |
|
||||
| `runner-config.example.yaml` | Example act_runner config for the CI VM (Docker executor). |
|
||||
|
||||
## Prerequisites (once per host)
|
||||
## Build and publish
|
||||
|
||||
Rootless Podman with cgroups v2 (default on modern distros). As the unprivileged
|
||||
user that will own the runners:
|
||||
Nothing in the image is architecture-specific: the base image, the Debian build
|
||||
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
|
||||
# Survive logout / start on boot without an interactive session.
|
||||
loginctl enable-linger "$USER"
|
||||
# from the repo root
|
||||
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.
|
||||
|
||||
## 1. Build the image
|
||||
**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 —
|
||||
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
|
||||
cd container
|
||||
podman build -t trx-rs-ci:latest .
|
||||
# on an x86_64 host
|
||||
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
|
||||
the token. (Org- or instance-level tokens work too if you prefer wider scope.)
|
||||
Tag with the Rust version too (e.g. `:1.97.1`) if you want reproducible pins.
|
||||
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
|
||||
mkdir -p ~/.config/containers/systemd
|
||||
cp trx-rs-runner.container project2-runner.container ~/.config/containers/systemd/
|
||||
|
||||
# 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
|
||||
docker pull git.haxx.space/sjg/trx-rs/sdk:latest
|
||||
docker run --rm git.haxx.space/sjg/trx-rs/sdk:latest sccache --version
|
||||
```
|
||||
|
||||
Once each runner shows **online** in the repo's runner list, blank out the
|
||||
`GITEA_RUNNER_REGISTRATION_TOKEN` line again (the registration is persisted in
|
||||
the `…-data` volume) and `systemctl --user daemon-reload`.
|
||||
### macOS note
|
||||
|
||||
## 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
|
||||
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:
|
||||
## Developer use
|
||||
|
||||
```yaml
|
||||
reuse:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: REUSE compliance
|
||||
run: reuse lint
|
||||
Reproducible one-off build, no local toolchain needed:
|
||||
|
||||
```bash
|
||||
podman run --rm -it -v "$PWD":/work -w /work \
|
||||
git.haxx.space/sjg/trx-rs/sdk:latest \
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
The `lint` and `test` jobs need no changes: their `sudo apt-get …` and rustup
|
||||
steps still run, but become fast no-ops because the image already has those
|
||||
packages and the toolchain. (`sudo` is included in the image for exactly this
|
||||
reason.)
|
||||
Or open the repo in the image via VS Code / JetBrains "Reopen in Container"
|
||||
(`.devcontainer/devcontainer.json` points at the same image).
|
||||
|
||||
> If you would rather keep Docker-based actions and per-run images, use the
|
||||
> **Docker executor** instead: drop the `:host` suffix from the label in
|
||||
> `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.
|
||||
Building outside the container? `rust-toolchain.toml` pins the same rustc, so
|
||||
`rustup` installs the matching toolchain automatically.
|
||||
|
||||
## Tuning
|
||||
## CI use
|
||||
|
||||
- **`capacity`** (in `config.yaml`) — concurrent jobs per runner. Rust builds
|
||||
are heavy; 1–2 is sensible when two runners share a host.
|
||||
- **`PodmanArgs=--cpus/--memory`** (in each `.container`) — hard resource caps
|
||||
so one project cannot starve the other.
|
||||
- **SELinux** — the `:Z` volume flag is already set; keep it if SELinux is
|
||||
enforcing.
|
||||
`.gitea/workflows/ci.yml` runs the `lint`, `test` and `frontend` jobs *inside*
|
||||
this image via the `container:` key, so they skip all setup and go straight to
|
||||
`cargo` and `npm`. The frontend job needs three things from the image beyond
|
||||
Rust: Node.js for the toolchain, Chromium at `/usr/bin/chromium` for the
|
||||
browser smoke test, and `cargo` — `npm run verify-generated` regenerates the
|
||||
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
|
||||
`REUSE.toml` (the other files carry inline SPDX headers):
|
||||
## Compilation cache (sccache)
|
||||
|
||||
```toml
|
||||
[[annotations]]
|
||||
path = ["container/**"]
|
||||
SPDX-FileCopyrightText = "2026 Stan Grams <sjg@haxx.space>"
|
||||
SPDX-License-Identifier = "GPL-2.0-or-later"
|
||||
The SDK image ships [`sccache`](https://github.com/mozilla/sccache). It is
|
||||
enabled via `RUSTC_WRAPPER=sccache` in CI and the devcontainer (not repo-wide,
|
||||
so plain `cargo` builds outside the SDK are unaffected).
|
||||
|
||||
- **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
|
||||
@@ -322,3 +322,255 @@ trx-configurator
|
||||
| `trx-app` | Config types and validation | Yes |
|
||||
| `serialport` | Serial port enumeration | Yes (transitive) |
|
||||
| `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 /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
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `GET` | `/logbook` | Query: filters, paging |
|
||||
| `POST` | `/logbook` | Add a QSO |
|
||||
| `PUT` | `/logbook/{id}` | Edit |
|
||||
| `DELETE` | `/logbook/{id}` | Delete |
|
||||
| `GET` | `/logbook/export.adi` | ADIF export, honouring the current filter |
|
||||
| `GET` | `/logbook/export.cbr` | Cabrillo export of a contest selection |
|
||||
| `POST` | `/logbook/import` | Import, answering with counts: added, duplicate, rejected |
|
||||
| `GET` | `/logbook/worked/{call}` | Worked-before: bands and modes |
|
||||
| `GET` | `/logbook/now` | The server's UTC clock, for stamping entries and checking the browser's |
|
||||
|
||||
Writes require the control 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
|
||||
|
||||
| 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 control role |
|
||||
| 3 | Logbook tab: entry, table, filters, import, export |
|
||||
| 4 | Ham layout, pre-filled entry from a decode row or the map, worked-before |
|
||||
| 5 | Contest exchange fields and Cabrillo export; QSL and LoTW/eQSL fields; per-band worked/confirmed statistics |
|
||||
|
||||
### 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 `control` and `rx`, with no
|
||||
notion of who is logged in.
|
||||
|
||||
**Server clock, and the log says so.** The server is the machine at the radio; the browser may
|
||||
be on a phone in another timezone with a clock nobody has checked. QSO times are UTC from the
|
||||
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.
|
||||
+232
-22
@@ -17,30 +17,61 @@ frontends.
|
||||
|
||||
## Configuration
|
||||
|
||||
Both `trx-server` and `trx-client` use TOML configuration files. Use
|
||||
`--print-config` to generate a fully commented example.
|
||||
Both `trx-server` and `trx-client` read TOML. The server takes its settings
|
||||
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
|
||||
|
||||
**trx-server** 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`
|
||||
Both binaries use the same lookup order:
|
||||
|
||||
**trx-client** lookup order:
|
||||
1. `--config <FILE>`
|
||||
2. `./trx-client.toml`
|
||||
3. `~/.config/trx-rs/client.toml`
|
||||
4. `/etc/trx-rs/client.toml`
|
||||
2. `./trx-rs.toml`
|
||||
3. `~/.config/trx-rs/trx-rs.toml`
|
||||
4. `/etc/trx-rs/trx-rs.toml`
|
||||
|
||||
CLI arguments override config file values.
|
||||
|
||||
### Environment Variables
|
||||
### Checking a Config
|
||||
|
||||
- `TRX_PLUGIN_DIRS`: additional plugin directories (path-separated), used by
|
||||
both server and client.
|
||||
`--check-config` loads the file, reports every problem it finds — unknown keys,
|
||||
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].rx_passphrase` | `rx_passphrase_file` | the passphrase |
|
||||
| `[frontends.http.auth].control_passphrase` | `control_passphrase_file` | the passphrase |
|
||||
| `[frontends.http_json.auth].tokens` | `tokens_file` | one token per line |
|
||||
|
||||
Blank lines and `#` comments are ignored in the list files. A config that holds
|
||||
credentials inline and is readable by group or others is flagged at startup.
|
||||
|
||||
### Server Options
|
||||
|
||||
@@ -96,6 +127,7 @@ CLI arguments override config file values.
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `tokens` | string[] | `[]` | Allowed auth tokens (empty = no auth) |
|
||||
| `tokens_file` | string | — | Read tokens from this file, one per line |
|
||||
|
||||
#### `[audio]`
|
||||
|
||||
@@ -121,6 +153,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 |
|
||||
| `bandwidth` | u32 | `1500000` | Hardware IF filter bandwidth in Hz |
|
||||
| `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]`
|
||||
|
||||
@@ -197,6 +236,29 @@ Notes:
|
||||
Files are appended in JSON Lines format. Supported date tokens: `%YYYY%`,
|
||||
`%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
|
||||
|
||||
Use `[[rigs]]` arrays instead of the flat `[rig]` section for multi-rig setups:
|
||||
@@ -240,12 +302,32 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
|
||||
|-------|------|---------|-------------|
|
||||
| `url` | string | — | Server address (e.g. `localhost:4530`) |
|
||||
| `poll_interval_ms` | u64 | `750` | State poll interval |
|
||||
| `spectrum_interval_ms` | u64 | `50` | Spectrum frame interval; also settable per `[[remotes]]` entry |
|
||||
|
||||
#### `[remote.auth]`
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `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]`
|
||||
|
||||
@@ -254,6 +336,31 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
|
||||
| `enabled` | bool | `true` | Enable web UI |
|
||||
| `listen` | ip | `127.0.0.1` | Bind address |
|
||||
| `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` | Require a passphrase |
|
||||
| `rx_passphrase` | string | — | Passphrase granting receive-only access |
|
||||
| `rx_passphrase_file` | string | — | Read it from this file instead |
|
||||
| `control_passphrase` | string | — | Passphrase granting full control |
|
||||
| `control_passphrase_file` | string | — | Read it from this file instead |
|
||||
| `tx_access_control_enabled` | bool | `true` | Hide TX from unauthenticated users |
|
||||
| `session_ttl_min` | u64 | `480` | Session lifetime |
|
||||
| `cookie_secure` | bool | `false` | Set Secure on the session cookie (needs HTTPS) |
|
||||
| `cookie_same_site` | string | `"Lax"` | `Strict`, `Lax`, or `None` |
|
||||
|
||||
With `enabled = true`, at least one passphrase must be set.
|
||||
|
||||
#### `[frontends.rigctl]`
|
||||
|
||||
@@ -261,7 +368,11 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
|
||||
|-------|------|---------|-------------|
|
||||
| `enabled` | bool | `false` | Enable Hamlib rigctl |
|
||||
| `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]`
|
||||
|
||||
@@ -271,13 +382,17 @@ Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1
|
||||
| `listen` | ip | `127.0.0.1` | Bind address |
|
||||
| `port` | u16 | `0` | Bind port (0 = ephemeral) |
|
||||
| `auth.tokens` | string[] | `[]` | Allowed auth tokens |
|
||||
| `auth.tokens_file` | string | — | Read tokens from this file, one per line |
|
||||
|
||||
#### `[frontends.audio]`
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `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.rx_output_device` | string | — | Local playback device |
|
||||
| `bridge.tx_input_device` | string | — | Local capture device |
|
||||
@@ -287,16 +402,111 @@ 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
|
||||
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
|
||||
|
||||
**trx-server:**
|
||||
`--config`, `--print-config`, `--rig`, `--access`, `--callsign`, `--listen`,
|
||||
`--port`. SDR options are file-only.
|
||||
`--config`, `--print-config`, `--check-config`, `--strict-config`, `--rig`,
|
||||
`--access`, `--callsign`, `--listen`, `--port`. SDR options are file-only.
|
||||
|
||||
**trx-client:**
|
||||
`--config`, `--print-config`, `--url`, `--token`, `--poll-interval`,
|
||||
`--frontend`, `--http-listen`, `--http-port`, `--rigctl-listen`,
|
||||
`--rigctl-port`, `--http-json-listen`, `--http-json-port`, `--callsign`.
|
||||
`--config`, `--print-config`, `--check-config`, `--strict-config`, `--url`,
|
||||
`--token`, `--poll-interval`, `--rig-id`, `--frontend`, `--http-listen`,
|
||||
`--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.
|
||||
|
||||
---
|
||||
|
||||
## 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 `control` role; an `rx`
|
||||
session opens the page and says the link was not applied. Links describe the
|
||||
rig's own dial — while a tab is listening to a virtual channel the address is
|
||||
left as it was, rather than publishing a frequency the rig is not on.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
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 |
|
||||
|------|------:|-----:|
|
||||
|
||||
@@ -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.
|
||||
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();
|
||||
let r = asm.correlation_with_last(&noise).expect("r");
|
||||
assert!(
|
||||
|
||||
@@ -9,9 +9,5 @@ edition = "2021"
|
||||
license = "GPL-2.0-or-later"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
tracing = { 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
|
||||
|
||||
pub mod config;
|
||||
//! Shared application helpers.
|
||||
//!
|
||||
//! Configuration types and their loader live in the `trx-config` crate.
|
||||
|
||||
pub mod logging;
|
||||
pub mod shared_config;
|
||||
pub mod util;
|
||||
|
||||
pub use config::{ConfigError, ConfigFile};
|
||||
pub use logging::init_logging;
|
||||
pub use shared_config::{validate_log_level, validate_tokens};
|
||||
pub use util::normalize_name;
|
||||
|
||||
@@ -21,6 +21,7 @@ uuid = { workspace = true }
|
||||
cpal = "0.15"
|
||||
opus = "0.3"
|
||||
trx-app = { path = "../trx-app" }
|
||||
trx-config = { path = "../trx-config" }
|
||||
trx-core = { path = "../trx-core" }
|
||||
trx-protocol = { path = "../trx-protocol" }
|
||||
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,
|
||||
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_LRPT_PROGRESS, AUDIO_MSG_RX_FRAME, AUDIO_MSG_RX_FRAME_CH, AUDIO_MSG_STREAM_INFO,
|
||||
AUDIO_MSG_TX_FRAME, AUDIO_MSG_VCHAN_ALLOCATED, AUDIO_MSG_VCHAN_BW, AUDIO_MSG_VCHAN_DESTROYED,
|
||||
AUDIO_MSG_VCHAN_FREQ, AUDIO_MSG_VCHAN_MODE, AUDIO_MSG_VCHAN_REMOVE, AUDIO_MSG_VCHAN_SUB,
|
||||
AUDIO_MSG_VCHAN_UNSUB, AUDIO_MSG_VDES_DECODE, AUDIO_MSG_WEFAX_DECODE, AUDIO_MSG_WEFAX_PROGRESS,
|
||||
AUDIO_MSG_WSPR_DECODE,
|
||||
AUDIO_MSG_LRPT_PROGRESS, AUDIO_MSG_RX_FRAME, AUDIO_MSG_RX_FRAME_CH, AUDIO_MSG_SSTV_DECODE,
|
||||
AUDIO_MSG_SSTV_PROGRESS, AUDIO_MSG_STREAM_INFO, AUDIO_MSG_TX_FRAME, AUDIO_MSG_VCHAN_ALLOCATED,
|
||||
AUDIO_MSG_VCHAN_BW, AUDIO_MSG_VCHAN_DESTROYED, AUDIO_MSG_VCHAN_FREQ, AUDIO_MSG_VCHAN_MODE,
|
||||
AUDIO_MSG_VCHAN_REMOVE, AUDIO_MSG_VCHAN_SUB, AUDIO_MSG_VCHAN_UNSUB, AUDIO_MSG_VDES_DECODE,
|
||||
AUDIO_MSG_WEFAX_DECODE, AUDIO_MSG_WEFAX_PROGRESS, AUDIO_MSG_WSPR_DECODE,
|
||||
};
|
||||
use trx_core::decode::DecodedMessage;
|
||||
use trx_frontend::VChanAudioCmd;
|
||||
@@ -533,7 +533,9 @@ async fn handle_single_rig_connection(
|
||||
| AUDIO_MSG_LRPT_IMAGE
|
||||
| AUDIO_MSG_LRPT_PROGRESS
|
||||
| AUDIO_MSG_WEFAX_DECODE
|
||||
| AUDIO_MSG_WEFAX_PROGRESS,
|
||||
| AUDIO_MSG_WEFAX_PROGRESS
|
||||
| AUDIO_MSG_SSTV_DECODE
|
||||
| AUDIO_MSG_SSTV_PROGRESS,
|
||||
payload,
|
||||
)) => {
|
||||
if let Ok(mut msg) = serde_json::from_slice::<DecodedMessage>(&payload) {
|
||||
|
||||
+5
-1214
File diff suppressed because it is too large
Load Diff
+138
-8
@@ -20,6 +20,7 @@ use tokio::task::JoinHandle;
|
||||
use tracing::{error, info};
|
||||
|
||||
use trx_app::{init_logging, normalize_name};
|
||||
use trx_config::ConfigFile;
|
||||
use trx_core::audio::AudioStreamInfo;
|
||||
|
||||
use trx_core::decode::DecodedMessage;
|
||||
@@ -50,6 +51,12 @@ struct Cli {
|
||||
/// Print example configuration and exit
|
||||
#[arg(long = "print-config")]
|
||||
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)
|
||||
#[arg(short = 'u', long = "url")]
|
||||
url: Option<String>,
|
||||
@@ -74,7 +81,7 @@ struct Cli {
|
||||
/// rigctl frontend listen address
|
||||
#[arg(long = "rigctl-listen")]
|
||||
rigctl_listen: Option<IpAddr>,
|
||||
/// rigctl frontend listen port
|
||||
/// Deprecated: ignored, use [frontends.rigctl].rig_ports
|
||||
#[arg(long = "rigctl-port")]
|
||||
rigctl_port: Option<u16>,
|
||||
/// JSON TCP frontend listen address
|
||||
@@ -109,6 +116,65 @@ async fn main() -> DynResult<()> {
|
||||
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.
|
||||
struct AppState {
|
||||
shutdown_tx: watch::Sender<bool>,
|
||||
@@ -135,20 +201,44 @@ async fn async_init() -> DynResult<AppState> {
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
let (cfg, config_path) = if let Some(ref path) = cli.config {
|
||||
let cfg = ClientConfig::load_from_file(path)?;
|
||||
(cfg, Some(path.clone()))
|
||||
let loaded = if let Some(ref path) = cli.config {
|
||||
ClientConfig::load_from_file(path)?
|
||||
} else {
|
||||
ClientConfig::load_from_default_paths()?
|
||||
};
|
||||
cfg.validate()
|
||||
.map_err(|e| format!("Invalid client configuration: {}", e))?;
|
||||
let config_path = loaded.path.clone();
|
||||
|
||||
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 {
|
||||
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
|
||||
.frontends
|
||||
@@ -198,10 +288,14 @@ async fn async_init() -> DynResult<AppState> {
|
||||
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);
|
||||
vec![RemoteEntry {
|
||||
spectrum_interval_ms: cfg.remote.spectrum_interval_ms,
|
||||
name,
|
||||
url: url.clone(),
|
||||
rig_id,
|
||||
auth: config::RemoteAuthConfig { token },
|
||||
auth: config::RemoteAuthConfig {
|
||||
token,
|
||||
token_file: None,
|
||||
},
|
||||
poll_interval_ms,
|
||||
}]
|
||||
} else {
|
||||
@@ -262,6 +356,30 @@ async fn async_init() -> DynResult<AppState> {
|
||||
.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);
|
||||
|
||||
// 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
|
||||
.callsign
|
||||
.clone()
|
||||
@@ -366,6 +484,13 @@ async fn async_init() -> DynResult<AppState> {
|
||||
.map(|e| e.poll_interval_ms)
|
||||
.min()
|
||||
.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);
|
||||
for entry in entries {
|
||||
@@ -379,6 +504,8 @@ async fn async_init() -> DynResult<AppState> {
|
||||
known_rigs: frontend_runtime.routing.remote_rigs.clone(),
|
||||
rig_states: frontend_runtime.routing.rig_states.clone(),
|
||||
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(),
|
||||
rig_spectrums: frontend_runtime.spectrum.per_rig.clone(),
|
||||
server_connected: frontend_runtime.routing.server_connected.clone(),
|
||||
@@ -527,6 +654,9 @@ async fn async_init() -> DynResult<AppState> {
|
||||
DecodedMessage::LrptProgress(_) => {}
|
||||
DecodedMessage::Wefax(_) => {}
|
||||
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_protocol::rig_command_to_client;
|
||||
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 IO_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const SPECTRUM_IO_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
const MAX_JSON_LINE_BYTES: usize = 256 * 1024;
|
||||
const MAX_CONSECUTIVE_POLL_FAILURES: u32 = 3;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RemoteEndpoint {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
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);
|
||||
// Default spectrum cadence when a config does not specify one. Both the push
|
||||
// stream and the poll fallback run at the configured rate; see
|
||||
// `[[remotes]].spectrum_interval_ms`.
|
||||
pub const DEFAULT_SPECTRUM_INTERVAL: Duration = Duration::from_millis(50);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RemoteClientConfig {
|
||||
@@ -57,6 +44,12 @@ pub struct RemoteClientConfig {
|
||||
pub selected_rig_id: Arc<Mutex<Option<String>>>,
|
||||
pub known_rigs: Arc<Mutex<Vec<RemoteRigEntry>>>,
|
||||
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.
|
||||
pub spectrum: Arc<watch::Sender<SharedSpectrum>>,
|
||||
/// 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(
|
||||
config: &RemoteClientConfig,
|
||||
stream: TcpStream,
|
||||
@@ -512,7 +599,33 @@ async fn handle_spectrum_connection(
|
||||
) -> RigResult<()> {
|
||||
let (reader, mut writer) = stream.into_split();
|
||||
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.
|
||||
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)]
|
||||
mod tests {
|
||||
#[allow(unused_imports)]
|
||||
use super::{has_short_names, resolve_server_rig_id, resolve_short_name};
|
||||
use super::{
|
||||
parse_audio_url, parse_remote_url, RemoteClientConfig, RemoteEndpoint, SharedSpectrum,
|
||||
DEFAULT_SPECTRUM_INTERVAL,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
@@ -1475,6 +1509,8 @@ mod tests {
|
||||
selected_rig_id: Arc::new(Mutex::new(None)),
|
||||
known_rigs: Arc::new(Mutex::new(Vec::new())),
|
||||
poll_interval: Duration::from_millis(100),
|
||||
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())),
|
||||
@@ -1520,6 +1556,8 @@ mod tests {
|
||||
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())),
|
||||
@@ -1535,6 +1573,130 @@ mod tests {
|
||||
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]
|
||||
fn build_envelope_translates_short_name_to_server_rig_id() {
|
||||
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()))),
|
||||
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())),
|
||||
@@ -1580,6 +1744,8 @@ mod tests {
|
||||
selected_rig_id: Arc::new(Mutex::new(None)),
|
||||
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())),
|
||||
@@ -1604,6 +1770,8 @@ mod tests {
|
||||
selected_rig_id: Arc::new(Mutex::new(None)),
|
||||
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())),
|
||||
@@ -1644,6 +1812,8 @@ mod tests {
|
||||
selected_rig_id: Arc::new(Mutex::new(None)),
|
||||
known_rigs: known_rigs.clone(),
|
||||
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())),
|
||||
@@ -1717,6 +1887,8 @@ mod tests {
|
||||
selected_rig_id,
|
||||
known_rigs,
|
||||
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())),
|
||||
|
||||
@@ -17,8 +17,8 @@ use uuid::Uuid;
|
||||
|
||||
use trx_core::audio::AudioStreamInfo;
|
||||
use trx_core::decode::{
|
||||
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, VdesMessage, WefaxMessage,
|
||||
WsprMessage,
|
||||
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, SstvMessage, VdesMessage,
|
||||
WefaxMessage, WsprMessage,
|
||||
};
|
||||
use trx_core::rig::state::{RigSnapshot, SpectrumData};
|
||||
use trx_core::{DynResult, RigRequest, RigState};
|
||||
@@ -233,6 +233,7 @@ pub struct DecodeHistoryContext {
|
||||
pub ft2: DecodeHistory<Ft8Message>,
|
||||
pub wspr: DecodeHistory<WsprMessage>,
|
||||
pub wefax: DecodeHistory<WefaxMessage>,
|
||||
pub sstv: DecodeHistory<SstvMessage>,
|
||||
}
|
||||
|
||||
impl Default for DecodeHistoryContext {
|
||||
@@ -248,6 +249,7 @@ impl Default for DecodeHistoryContext {
|
||||
ft2: Arc::new(Mutex::new(VecDeque::new())),
|
||||
wspr: Arc::new(Mutex::new(VecDeque::new())),
|
||||
wefax: Arc::new(Mutex::new(VecDeque::new())),
|
||||
sstv: Arc::new(Mutex::new(VecDeque::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,3 +29,4 @@ hex = "0.4"
|
||||
pickledb = "0.5"
|
||||
dirs = "6"
|
||||
uuid = { workspace = true }
|
||||
ts-rs = "12.0.1"
|
||||
|
||||
@@ -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,311 @@
|
||||
import {
|
||||
aprsAgeText,
|
||||
aprsPacketCategory,
|
||||
collapseAprsDuplicates,
|
||||
normalizeAprsPacket,
|
||||
renderAprsPacketRow
|
||||
} from "./chunk-OPEIVJGD.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);
|
||||
}
|
||||
});
|
||||
}
|
||||
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 === "control" || 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 === "control";
|
||||
}
|
||||
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,254 @@
|
||||
// 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 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></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 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,298 @@
|
||||
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>`;
|
||||
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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
+160
-190
@@ -1,39 +1,39 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// --- CW (Morse) Decoder Plugin (server-side decode) ---
|
||||
const cwStatusEl = document.getElementById("cw-status");
|
||||
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;
|
||||
import {
|
||||
isActiveRigDecode
|
||||
} from "./chunk-S57W63QN.js";
|
||||
import {
|
||||
hostCore
|
||||
} from "./chunk-KL66PICH.js";
|
||||
|
||||
// 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) {
|
||||
if (cwAutoInput) cwAutoInput.checked = enabled;
|
||||
if (cwWpmInput) {
|
||||
@@ -48,103 +48,78 @@ function applyCwAutoUi(enabled) {
|
||||
cwTonePickerEl.classList.toggle("is-auto", enabled);
|
||||
}
|
||||
}
|
||||
window.applyCwAutoUi = applyCwAutoUi;
|
||||
|
||||
// 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) {
|
||||
cwWindow.applyCwAutoUi = applyCwAutoUi;
|
||||
cwWindow.applyCwAutoUiFromServer = function(enabled) {
|
||||
if (cwAutoLocalOverride !== null) return;
|
||||
applyCwAutoUi(enabled);
|
||||
};
|
||||
|
||||
function cwBarFlushCurrentLine() {
|
||||
if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) {
|
||||
cwBarHistory.unshift(cwBarCurrentLine);
|
||||
function cwBarFlushCurrentLine(key) {
|
||||
const line = cwBarCurrentLines.get(key);
|
||||
cwBarCurrentLines.delete(key);
|
||||
if (line?.text.trim()) {
|
||||
cwBarHistory.unshift(line);
|
||||
if (cwBarHistory.length > 50) cwBarHistory.length = 50;
|
||||
}
|
||||
cwBarCurrentLine = null;
|
||||
}
|
||||
|
||||
function updateCwBar() {
|
||||
if (!cwBarOverlay) return;
|
||||
const mode = (document.getElementById("mode")?.value || "").toUpperCase();
|
||||
const isCw = mode === "CW" || mode === "CWR";
|
||||
const cutoffMs = Date.now() - CW_BAR_WINDOW_MS;
|
||||
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs);
|
||||
// Prepend the in-progress line so characters appear immediately
|
||||
const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent;
|
||||
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, Number(line.tsMs) || 0), 0);
|
||||
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs && isActiveRigDecode(l.rigId));
|
||||
const inProgress = [...cwBarCurrentLines.values()].filter((l) => l.text && isActiveRigDecode(l.rigId)).sort((a, b) => b.tsMs - a.tsMs);
|
||||
const liveLines = [...inProgress, ...recent];
|
||||
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0);
|
||||
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
|
||||
cwBarOverlay.style.display = "none";
|
||||
cwBarOverlay.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
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>';
|
||||
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>`;
|
||||
for (const line of liveLines.slice(0, 8)) {
|
||||
const ts = line.ts ? `<span class="aprs-bar-time">${line.ts}</span>` : "";
|
||||
const meta = [
|
||||
line.wpm ? `${line.wpm} WPM` : null,
|
||||
line.tone_hz ? `${line.tone_hz} Hz` : null,
|
||||
line.wpm ? `${String(line.wpm)} WPM` : null,
|
||||
line.tone_hz ? `${String(line.tone_hz)} Hz` : null
|
||||
].filter(Boolean).join(" · ");
|
||||
html += `<div class="aprs-bar-frame">` +
|
||||
`<div class="aprs-bar-frame-main">${ts}${escapeMapHtml(line.text)}` +
|
||||
(meta ? ` <span class="aprs-bar-time">${escapeMapHtml(meta)}</span>` : "") +
|
||||
`</div></div>`;
|
||||
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>`;
|
||||
}
|
||||
cwBarOverlay.innerHTML = html;
|
||||
cwBarOverlay.style.display = "flex";
|
||||
}
|
||||
window.updateCwBar = updateCwBar;
|
||||
window.clearCwBar = function() {
|
||||
window.resetCwHistoryView();
|
||||
cwWindow.updateCwBar = updateCwBar;
|
||||
cwWindow.clearCwBar = function() {
|
||||
resetCwHistoryView();
|
||||
};
|
||||
window.closeCwBar = function() {
|
||||
cwWindow.closeCwBar = function() {
|
||||
cwBarDismissedAtMs = Date.now();
|
||||
if (cwBarOverlay) {
|
||||
cwBarOverlay.style.display = "none";
|
||||
cwBarOverlay.innerHTML = "";
|
||||
}
|
||||
};
|
||||
|
||||
function clampCwWpm(wpm) {
|
||||
const numeric = Number(wpm);
|
||||
if (!Number.isFinite(numeric)) return 15;
|
||||
return Math.round(Math.max(CW_WPM_MIN, Math.min(CW_WPM_MAX, numeric)));
|
||||
}
|
||||
|
||||
function clampCwTone(tone) {
|
||||
const numeric = Number(tone);
|
||||
if (!Number.isFinite(numeric)) return 700;
|
||||
return Math.round(Math.max(CW_TONE_MIN_HZ, Math.min(CW_TONE_MAX_HZ, numeric)));
|
||||
}
|
||||
|
||||
function currentCwToneRange() {
|
||||
const tunedHz = Number.isFinite(window.lastFreqHz) ? Number(window.lastFreqHz) : NaN;
|
||||
const bandwidthHz = Number.isFinite(window.currentBandwidthHz) ? Number(window.currentBandwidthHz) : NaN;
|
||||
const tunedHz = Number.isFinite(cwWindow.lastFreqHz) ? Number(cwWindow.lastFreqHz) : NaN;
|
||||
const bandwidthHz = Number.isFinite(cwWindow.currentBandwidthHz) ? Number(cwWindow.currentBandwidthHz) : NaN;
|
||||
if (!Number.isFinite(tunedHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) {
|
||||
return null;
|
||||
}
|
||||
const mode = String(document.getElementById("mode")?.value || "").toUpperCase();
|
||||
const mode = (document.getElementById("mode")?.value || "").toUpperCase();
|
||||
const lowerSideband = mode === "CWR";
|
||||
const upperSideband = mode === "CW";
|
||||
if (!lowerSideband && !upperSideband) return null;
|
||||
|
||||
const toneMinHz = CW_TONE_MIN_HZ;
|
||||
const toneMaxHz = CW_TONE_MAX_HZ;
|
||||
if (toneMaxHz < toneMinHz) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
tunedHz,
|
||||
bandwidthHz,
|
||||
@@ -152,23 +127,18 @@ function currentCwToneRange() {
|
||||
toneMaxHz,
|
||||
toneSpanHz: Math.max(1, toneMaxHz - toneMinHz),
|
||||
lowerSideband,
|
||||
mode,
|
||||
mode
|
||||
};
|
||||
}
|
||||
|
||||
function cwToneToRfHz(range, toneHz) {
|
||||
if (!range) return NaN;
|
||||
return range.lowerSideband
|
||||
? range.tunedHz - toneHz
|
||||
: range.tunedHz + toneHz;
|
||||
return range.lowerSideband ? range.tunedHz - toneHz : range.tunedHz + toneHz;
|
||||
}
|
||||
|
||||
function toneClampForRange(tone, range) {
|
||||
const clamped = clampCwTone(tone);
|
||||
if (!range) return clamped;
|
||||
return Math.max(range.toneMinHz, Math.min(range.toneMaxHz, clamped));
|
||||
}
|
||||
|
||||
function ensureCwToneCanvasResolution() {
|
||||
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return false;
|
||||
const rect = cwToneCanvas.getBoundingClientRect();
|
||||
@@ -180,7 +150,6 @@ function ensureCwToneCanvasResolution() {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
return cwToneGl.ensureSize(cssWidth, cssHeight, dpr);
|
||||
}
|
||||
|
||||
function drawCwTonePicker() {
|
||||
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return;
|
||||
ensureCwToneCanvasResolution();
|
||||
@@ -188,29 +157,26 @@ function drawCwTonePicker() {
|
||||
const width = cwToneCanvas.width;
|
||||
const height = cwToneCanvas.height;
|
||||
cwToneGl.clear([0, 0, 0, 0]);
|
||||
|
||||
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) {
|
||||
const mode = String(document.getElementById("mode")?.value || "").toUpperCase();
|
||||
const mode = (document.getElementById("mode")?.value || "").toUpperCase();
|
||||
if (mode !== "CW" && mode !== "CWR") {
|
||||
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";
|
||||
}
|
||||
}
|
||||
cwToneGl.fillRect(0, 0, width, height, [130 / 255, 150 / 255, 165 / 255, 0.22]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cwToneRangeEl) {
|
||||
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 = window.lastSpectrumData.bins;
|
||||
const sampleRate = Number(window.lastSpectrumData.sample_rate);
|
||||
const centerHz = Number(window.lastSpectrumData.center_hz);
|
||||
const bins = cwWindow.lastSpectrumData.bins;
|
||||
const sampleRate = cwWindow.lastSpectrumData.sample_rate;
|
||||
const centerHz = cwWindow.lastSpectrumData.center_hz;
|
||||
const maxIdx = Math.max(1, bins.length - 1);
|
||||
const fullLoHz = centerHz - sampleRate / 2;
|
||||
const tones = new Array(width).fill(-140);
|
||||
@@ -218,11 +184,10 @@ function drawCwTonePicker() {
|
||||
const frac = width <= 1 ? 0 : x / (width - 1);
|
||||
const toneHz = range.toneMinHz + frac * range.toneSpanHz;
|
||||
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;
|
||||
tones[x] = power;
|
||||
}
|
||||
|
||||
const smoothed = new Array(width).fill(-140);
|
||||
const smoothRadius = Math.max(1, Math.round(width / 180));
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
@@ -230,12 +195,11 @@ function drawCwTonePicker() {
|
||||
let count = 0;
|
||||
for (let i = x - smoothRadius; i <= x + smoothRadius; i += 1) {
|
||||
if (i < 0 || i >= width) continue;
|
||||
sum += tones[i];
|
||||
sum += tones[i] ?? -140;
|
||||
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 q20 = sorted[Math.floor((sorted.length - 1) * 0.2)] ?? -120;
|
||||
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));
|
||||
return Math.round((1 - n) * (height - 1));
|
||||
};
|
||||
|
||||
const rootStyle = getComputedStyle(document.documentElement);
|
||||
const accent = (rootStyle.getPropertyValue("--accent-green") || "").trim() || "#00d17f";
|
||||
const parseColor = typeof window.trxParseCssColor === "function"
|
||||
? window.trxParseCssColor
|
||||
: null;
|
||||
const parseColor = typeof cwWindow.trxParseCssColor === "function" ? cwWindow.trxParseCssColor : null;
|
||||
const accentRgba = parseColor ? parseColor(accent) : [0, 0.82, 0.5, 1];
|
||||
const axisColor = [230 / 255, 235 / 255, 245 / 255, 0.15];
|
||||
|
||||
cwToneGl.fillRect(0, 0, width, height, [7 / 255, 12 / 255, 18 / 255, 0.94]);
|
||||
|
||||
const hGridCount = 4;
|
||||
const gridSegments = [];
|
||||
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);
|
||||
}
|
||||
cwToneGl.drawSegments(gridSegments, axisColor, 1);
|
||||
|
||||
const toneStep = range.toneSpanHz <= 500 ? 50 : range.toneSpanHz <= 1000 ? 100 : 200;
|
||||
const toneStep = range.toneSpanHz <= 500 ? 50 : range.toneSpanHz <= 1e3 ? 100 : 200;
|
||||
const firstTick = Math.ceil(range.toneMinHz / toneStep) * toneStep;
|
||||
const tickSegments = [];
|
||||
for (let tone = firstTick; tone <= range.toneMaxHz; tone += toneStep) {
|
||||
@@ -274,75 +232,73 @@ function drawCwTonePicker() {
|
||||
tickSegments.push(x, 0, x, height);
|
||||
}
|
||||
cwToneGl.drawSegments(tickSegments, axisColor, 1);
|
||||
|
||||
const linePoints = [];
|
||||
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.drawPolyline(linePoints, accentRgba, Math.max(1.2, (window.devicePixelRatio || 1) * 1.2));
|
||||
|
||||
const currentTone = toneClampForRange(cwToneInput ? cwToneInput.value : 700, range);
|
||||
const markerFrac = (currentTone - range.toneMinHz) / range.toneSpanHz;
|
||||
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.drawPoints([markerX, markerY], Math.max(2, Math.round(height * 0.055)), [1, 1, 1, 0.9]);
|
||||
|
||||
if (cwAutoInput?.checked) {
|
||||
cwToneGl.fillRect(0, 0, width, height, [0, 0, 0, 0.22]);
|
||||
}
|
||||
}
|
||||
|
||||
async function setCwTone(tone, { syncInput = true } = {}) {
|
||||
const range = currentCwToneRange();
|
||||
const clamped = toneClampForRange(tone, range);
|
||||
if (cwToneInput && syncInput) {
|
||||
cwToneInput.value = clamped;
|
||||
cwToneInput.value = String(clamped);
|
||||
}
|
||||
try {
|
||||
await postPath(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`);
|
||||
await hostCore.postPath(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`);
|
||||
} catch (e) {
|
||||
console.error("CW tone set failed", e);
|
||||
}
|
||||
drawCwTonePicker();
|
||||
}
|
||||
|
||||
if (cwAutoInput) {
|
||||
cwAutoInput.addEventListener("change", async () => {
|
||||
const enabled = cwAutoInput.checked;
|
||||
cwAutoLocalOverride = enabled;
|
||||
applyCwAutoUi(enabled);
|
||||
try {
|
||||
await postPath(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`);
|
||||
drawCwTonePicker();
|
||||
} catch (e) {
|
||||
console.error("CW auto toggle failed", e);
|
||||
} finally {
|
||||
cwAutoLocalOverride = null;
|
||||
}
|
||||
cwAutoInput.addEventListener("change", () => {
|
||||
void (async () => {
|
||||
const enabled = cwAutoInput.checked;
|
||||
cwAutoLocalOverride = enabled;
|
||||
applyCwAutoUi(enabled);
|
||||
try {
|
||||
await hostCore.postPath(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`);
|
||||
drawCwTonePicker();
|
||||
} catch (error) {
|
||||
console.error("CW auto toggle failed", error);
|
||||
} finally {
|
||||
cwAutoLocalOverride = null;
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
if (cwWpmInput) {
|
||||
cwWpmInput.addEventListener("change", async () => {
|
||||
if (cwAutoInput && cwAutoInput.checked) return;
|
||||
const wpm = clampCwWpm(cwWpmInput.value);
|
||||
cwWpmInput.value = wpm;
|
||||
try { await postPath(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`); }
|
||||
catch (e) { console.error("CW WPM set failed", e); }
|
||||
cwWpmInput.addEventListener("change", () => {
|
||||
void (async () => {
|
||||
if (cwAutoInput?.checked) return;
|
||||
const wpm = clampCwWpm(cwWpmInput.value);
|
||||
cwWpmInput.value = String(wpm);
|
||||
try {
|
||||
await hostCore.postPath(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`);
|
||||
} catch (error) {
|
||||
console.error("CW WPM set failed", error);
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
if (cwToneInput) {
|
||||
cwToneInput.addEventListener("change", async () => {
|
||||
if (cwAutoInput?.checked) return;
|
||||
await setCwTone(cwToneInput.value);
|
||||
cwToneInput.addEventListener("change", () => {
|
||||
if (!cwAutoInput?.checked) void setCwTone(cwToneInput.value);
|
||||
});
|
||||
}
|
||||
|
||||
if (cwToneCanvas) {
|
||||
cwToneCanvas.addEventListener("click", async (event) => {
|
||||
cwToneCanvas.addEventListener("click", (event) => {
|
||||
if (cwAutoInput?.checked) return;
|
||||
const rect = cwToneCanvas.getBoundingClientRect();
|
||||
if (rect.width <= 0) return;
|
||||
@@ -350,36 +306,34 @@ if (cwToneCanvas) {
|
||||
if (!range) return;
|
||||
const frac = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
|
||||
const tone = range.toneMinHz + frac * range.toneSpanHz;
|
||||
await setCwTone(tone);
|
||||
void setCwTone(tone);
|
||||
});
|
||||
}
|
||||
|
||||
window.resetCwHistoryView = function() {
|
||||
function resetCwHistoryView() {
|
||||
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
||||
cwLastAppendTime = 0;
|
||||
cwBarHistory = [];
|
||||
cwBarCurrentLine = null;
|
||||
cwBarCurrentLines.clear();
|
||||
updateCwBar();
|
||||
drawCwTonePicker();
|
||||
};
|
||||
|
||||
document.getElementById("settings-clear-cw-history")?.addEventListener("click", async () => {
|
||||
if (!confirm("Clear all CW decode history? This cannot be undone.")) return;
|
||||
try {
|
||||
await postPath("/clear_cw_decode");
|
||||
window.resetCwHistoryView();
|
||||
} catch (e) {
|
||||
console.error("CW history clear failed", e);
|
||||
}
|
||||
}
|
||||
document.getElementById("settings-clear-cw-history")?.addEventListener("click", () => {
|
||||
void (async () => {
|
||||
if (!await cwWindow.trxUi.confirm({ title: "Clear CW history?", message: "All stored CW decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||
try {
|
||||
await hostCore.postPath("/clear_cw_decode");
|
||||
resetCwHistoryView();
|
||||
} catch (error) {
|
||||
console.error("CW history clear failed", error);
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
// --- Server-side CW decode handler ---
|
||||
window.onServerCw = function(evt) {
|
||||
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
||||
if (evt.text && cwOutputEl) {
|
||||
// Append decoded text to output
|
||||
function onServerCw(evt) {
|
||||
const forSelectedRig = isActiveRigDecode(evt.rig_id ?? null);
|
||||
if (cwStatusEl && forSelectedRig) cwStatusEl.textContent = "Receiving";
|
||||
if (evt.text && cwOutputEl && forSelectedRig) {
|
||||
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");
|
||||
line.className = "cw-line";
|
||||
cwOutputEl.appendChild(line);
|
||||
@@ -390,37 +344,42 @@ window.onServerCw = function(evt) {
|
||||
lastLine.textContent += evt.text;
|
||||
}
|
||||
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;
|
||||
}
|
||||
// Bar history accumulation (regardless of pause state)
|
||||
if (evt.text) {
|
||||
const now = Date.now();
|
||||
const rigId = evt.rig_id ?? null;
|
||||
const key = rigId ?? "";
|
||||
if (evt.text === "\n") {
|
||||
cwBarFlushCurrentLine();
|
||||
cwBarFlushCurrentLine(key);
|
||||
} else {
|
||||
if (!cwBarCurrentLine || now - cwBarCurrentLine.lastMs > CW_BAR_LINE_GAP_MS) {
|
||||
cwBarFlushCurrentLine();
|
||||
let line = cwBarCurrentLines.get(key);
|
||||
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" });
|
||||
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;
|
||||
cwBarCurrentLine.lastMs = now;
|
||||
if (Number.isFinite(Number(evt.wpm))) cwBarCurrentLine.wpm = clampCwWpm(evt.wpm);
|
||||
if (Number.isFinite(Number(evt.tone_hz))) cwBarCurrentLine.tone_hz = Math.round(Number(evt.tone_hz));
|
||||
line.text += evt.text;
|
||||
line.lastMs = now;
|
||||
if (Number.isFinite(Number(evt.wpm))) line.wpm = clampCwWpm(evt.wpm);
|
||||
if (Number.isFinite(Number(evt.tone_hz))) line.tone_hz = Math.round(Number(evt.tone_hz));
|
||||
}
|
||||
updateCwBar();
|
||||
}
|
||||
if (cwSignalIndicator) {
|
||||
if (cwSignalIndicator && forSelectedRig) {
|
||||
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))) {
|
||||
cwWpmInput.value = clampCwWpm(evt.wpm);
|
||||
cwWpmInput.value = String(clampCwWpm(evt.wpm));
|
||||
}
|
||||
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;
|
||||
@@ -428,17 +387,28 @@ window.onServerCw = function(evt) {
|
||||
cwTonePickerRaf = null;
|
||||
drawCwTonePicker();
|
||||
});
|
||||
};
|
||||
|
||||
window.restoreCwHistory = function(events) {
|
||||
}
|
||||
function restoreCwHistory(events) {
|
||||
if (!Array.isArray(events) || events.length === 0) return;
|
||||
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
||||
for (const evt of events) {
|
||||
window.onServerCw(evt);
|
||||
onServerCw(evt);
|
||||
}
|
||||
};
|
||||
|
||||
window.refreshCwTonePicker = function refreshCwTonePicker() {
|
||||
}
|
||||
cwWindow.trxPluginRuntime.registerDecoder({
|
||||
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();
|
||||
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-PJ5Q7CWJ.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-PJ5Q7CWJ.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-PJ5Q7CWJ.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-OPEIVJGD.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);
|
||||
}
|
||||
});
|
||||
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>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
// Spectrum screenshot module (loaded on demand when user triggers screenshot).
|
||||
// Communicates with app.js core via window.trx namespace.
|
||||
(function () {
|
||||
// src/screenshot.ts
|
||||
var screenshotWindow = window;
|
||||
(function() {
|
||||
"use strict";
|
||||
const T = window.trx;
|
||||
|
||||
const T = screenshotWindow.trx;
|
||||
function isVisibleForSnapshot(el) {
|
||||
if (!el) return false;
|
||||
const style = getComputedStyle(el);
|
||||
@@ -17,7 +12,6 @@
|
||||
const rect = el.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
}
|
||||
|
||||
function drawRoundedRectPath(ctx, x, y, w, h, r) {
|
||||
const radius = Math.max(0, Math.min(r, Math.min(w, h) / 2));
|
||||
ctx.beginPath();
|
||||
@@ -32,7 +26,6 @@
|
||||
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
function drawElementChrome(ctx, el, rootRect, maxAlpha = 1) {
|
||||
if (!isVisibleForSnapshot(el)) return null;
|
||||
const rect = el.getBoundingClientRect();
|
||||
@@ -45,30 +38,29 @@
|
||||
const bg = T.cssColorToRgba(style.backgroundColor || "rgba(0,0,0,0)");
|
||||
const borderWidth = Math.max(0, parseFloat(style.borderTopWidth) || 0);
|
||||
const border = T.cssColorToRgba(style.borderTopColor || "rgba(0,0,0,0)");
|
||||
|
||||
const bgAlpha = Math.min(bg[3], maxAlpha);
|
||||
if (bgAlpha > 0.01) {
|
||||
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();
|
||||
}
|
||||
const borderAlpha = Math.min(border[3], maxAlpha);
|
||||
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));
|
||||
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();
|
||||
}
|
||||
return { x, y, w, h, style };
|
||||
}
|
||||
|
||||
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;
|
||||
let line = "";
|
||||
let lineIdx = 0;
|
||||
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) {
|
||||
line = candidate;
|
||||
continue;
|
||||
@@ -76,13 +68,12 @@
|
||||
ctx.fillText(line, x, y + lineIdx * lineHeight);
|
||||
lineIdx += 1;
|
||||
if (lineIdx >= maxLines) return;
|
||||
line = words[i];
|
||||
line = word;
|
||||
}
|
||||
if (line && lineIdx < maxLines) {
|
||||
ctx.fillText(line, x, y + lineIdx * lineHeight);
|
||||
}
|
||||
}
|
||||
|
||||
function drawElementTextBlock(ctx, el, rootRect, fallbackText = null, maxAlpha = 1) {
|
||||
const chrome = drawElementChrome(ctx, el, rootRect, maxAlpha);
|
||||
if (!chrome) return;
|
||||
@@ -91,7 +82,7 @@
|
||||
if (!clean) return;
|
||||
const style = chrome.style;
|
||||
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 padY = 4;
|
||||
const maxWidth = Math.max(20, chrome.w - padX * 2);
|
||||
@@ -110,12 +101,11 @@
|
||||
chrome.y + padY + lineCursor * lineHeight,
|
||||
maxWidth,
|
||||
lineHeight,
|
||||
maxLines - lineCursor,
|
||||
maxLines - lineCursor
|
||||
);
|
||||
lineCursor += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function drawAxisLabels(ctx, axisEl, rootRect) {
|
||||
if (!isVisibleForSnapshot(axisEl)) return;
|
||||
for (const node of axisEl.children) {
|
||||
@@ -132,7 +122,6 @@
|
||||
ctx.fillText(text, chrome.x + 4, chrome.y + chrome.h / 2);
|
||||
}
|
||||
}
|
||||
|
||||
function buildSpectrumSnapshotCanvas() {
|
||||
const rootEl = document.querySelector(".signal-visual-block");
|
||||
const spectrumPanelEl = document.getElementById("spectrum-panel");
|
||||
@@ -145,8 +134,7 @@
|
||||
try {
|
||||
if (typeof gl.flush === "function") gl.flush();
|
||||
if (typeof gl.finish === "function") gl.finish();
|
||||
} catch (_) {
|
||||
// Ignore transient WebGL state errors and capture the last good frame.
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
const rootRect = rootEl.getBoundingClientRect();
|
||||
@@ -157,11 +145,9 @@
|
||||
const ctx = out.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const bg = getComputedStyle(document.documentElement).getPropertyValue("--bg").trim() || getComputedStyle(document.body).backgroundColor || "#000";
|
||||
ctx.fillStyle = bg;
|
||||
ctx.fillRect(0, 0, rootRect.width, rootRect.height);
|
||||
|
||||
const signalOverlayCanvas = document.getElementById("signal-overlay-canvas");
|
||||
const canvases = [T.overviewCanvas, T.spectrumCanvas, signalOverlayCanvas];
|
||||
for (const canvas of canvases) {
|
||||
@@ -172,28 +158,21 @@
|
||||
rect.left - rootRect.left,
|
||||
rect.top - rootRect.top,
|
||||
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 = [
|
||||
"ais-bar-overlay",
|
||||
"vdes-bar-overlay",
|
||||
"ft8-bar-overlay",
|
||||
"aprs-bar-overlay",
|
||||
"rds-ps-overlay",
|
||||
"rds-ps-overlay"
|
||||
];
|
||||
for (const id of decoderOverlayIds) {
|
||||
const overlayEl = document.getElementById(id);
|
||||
if (!overlayEl || !isVisibleForSnapshot(overlayEl)) continue;
|
||||
drawElementTextBlock(ctx, overlayEl, rootRect, null, 0.35);
|
||||
}
|
||||
|
||||
// Spectrum axis labels and bookmark chips (includes freq bar).
|
||||
const spectrumFreqAxis = document.getElementById("spectrum-freq-axis");
|
||||
const spectrumDbAxis = document.getElementById("spectrum-db-axis");
|
||||
drawAxisLabels(ctx, spectrumFreqAxis, rootRect);
|
||||
@@ -201,10 +180,8 @@
|
||||
drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-axis"), rootRect);
|
||||
drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-side-left"), rootRect);
|
||||
drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-side-right"), rootRect);
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function clickCanvasDownload(href, fileName) {
|
||||
const a = document.createElement("a");
|
||||
a.href = href;
|
||||
@@ -213,9 +190,10 @@
|
||||
a.style.display = "none";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
requestAnimationFrame(() => a.remove());
|
||||
requestAnimationFrame(() => {
|
||||
a.remove();
|
||||
});
|
||||
}
|
||||
|
||||
function saveCanvasAsPng(canvas, fileName) {
|
||||
if (!canvas) return Promise.resolve(false);
|
||||
if (typeof canvas.toBlob === "function") {
|
||||
@@ -228,10 +206,12 @@
|
||||
}
|
||||
const url = URL.createObjectURL(blob);
|
||||
clickCanvasDownload(url, fileName);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
setTimeout(() => {
|
||||
URL.revokeObjectURL(url);
|
||||
}, 1e3);
|
||||
resolve(true);
|
||||
}, "image/png");
|
||||
} catch (_) {
|
||||
} catch {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
@@ -239,27 +219,24 @@
|
||||
try {
|
||||
clickCanvasDownload(canvas.toDataURL("image/png"), fileName);
|
||||
return Promise.resolve(true);
|
||||
} catch (_) {
|
||||
} catch {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function captureSpectrumScreenshot() {
|
||||
const snapshotCanvas = buildSpectrumSnapshotCanvas();
|
||||
if (!snapshotCanvas) {
|
||||
T.showHint("Spectrum view not ready", 1300);
|
||||
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`);
|
||||
T.showHint(saved ? "Spectrum screenshot saved" : "Spectrum screenshot failed", saved ? 1500 : 1800);
|
||||
return saved;
|
||||
}
|
||||
|
||||
// Register module API
|
||||
window.trx.screenshot = {
|
||||
screenshotWindow.trx.modules.screenshot = {
|
||||
captureSpectrumScreenshot,
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -22,8 +22,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
|
||||
<symbol id="icon-home" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.8 7 8 2.9 13.2 7"/><path d="M4.3 5.9V13h7.4V5.9"/><path d="M6.8 13V9.3h2.4V13"/></symbol>
|
||||
<symbol id="icon-bookmark" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 2h8v12l-4-2.5L4 14V2z"/></symbol>
|
||||
<symbol id="icon-signal" viewBox="0 0 16 16" fill="currentColor"><rect x="1" y="11" width="2.5" height="4" rx="0.5"/><rect x="4.75" y="8" width="2.5" height="7" rx="0.5"/><rect x="8.5" y="5" width="2.5" height="10" rx="0.5"/><rect x="12.25" y="2" width="2.5" height="13" rx="0.5"/></symbol>
|
||||
<symbol id="icon-digital" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 11.5h2.6V4.5h3.2v7h3.2v-7h3.2v7H15"/></symbol>
|
||||
<symbol id="icon-map" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2a4 4 0 0 1 4 4c0 3-4 8-4 8S4 9 4 6a4 4 0 0 1 4-4z"/><circle cx="8" cy="6" r="1.2" fill="currentColor" stroke="none"/></symbol>
|
||||
<symbol id="icon-satellite" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 13.2 5.6 9.6"/><path d="M4.2 8.2a3.4 3.4 0 0 1 4.8 4.8z"/><path d="M9.2 6.6a3.2 3.2 0 0 1 2.6 2.6"/><path d="M9.8 3.6a6.2 6.2 0 0 1 5.2 5.2"/></symbol>
|
||||
<symbol id="icon-stats" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 14h12"/><rect x="3" y="8" width="2" height="6" rx="0.4" fill="currentColor" stroke="none" opacity="0.6"/><rect x="7" y="5" width="2" height="9" rx="0.4" fill="currentColor" stroke="none" opacity="0.75"/><rect x="11" y="2" width="2" height="12" rx="0.4" fill="currentColor" stroke="none" opacity="0.9"/></symbol>
|
||||
<symbol id="icon-record" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6"/><circle cx="8" cy="8" r="2.5" fill="currentColor" stroke="none"/></symbol>
|
||||
<symbol id="icon-settings" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M9.8 3.1a2.6 2.6 0 0 0-2.2 3.9L3.4 11.2a1.2 1.2 0 1 0 1.7 1.7l4.2-4.2a2.6 2.6 0 0 0 3.9-2.2l-1.8.6-1.2-1.2z"/><path d="M10.2 5.8 12 4"/></symbol>
|
||||
@@ -43,23 +44,30 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<div class="subtitle" id="location-subtitle" style="display:none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-bar-nav">
|
||||
<div class="tab-bar-nav" aria-label="Primary navigation">
|
||||
<button class="tab active" data-tab="main">
|
||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-home"/></svg>
|
||||
<span class="tab-label">Main</span>
|
||||
<span class="tab-label">Radio</span>
|
||||
</button>
|
||||
<button class="tab" data-tab="bookmarks">
|
||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-bookmark"/></svg>
|
||||
<span class="tab-label">Bookmarks</span>
|
||||
</button>
|
||||
<button class="tab" data-tab="digital-modes">
|
||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-signal"/></svg>
|
||||
<button class="tab" data-tab="digital-modes" aria-label="Digital modes">
|
||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-digital"/></svg>
|
||||
<span class="tab-label">Digital modes</span>
|
||||
<!-- A fifth of a phone screen does not hold "Digital modes"; the
|
||||
button keeps the full name for assistive tech. -->
|
||||
<span class="tab-label-short" aria-hidden="true">Digital</span>
|
||||
</button>
|
||||
<button class="tab" data-tab="map">
|
||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-map"/></svg>
|
||||
<span class="tab-label">Map</span>
|
||||
</button>
|
||||
<button class="tab" data-tab="satellites">
|
||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-satellite"/></svg>
|
||||
<span class="tab-label">Satellites</span>
|
||||
</button>
|
||||
<button class="tab" data-tab="statistics">
|
||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-stats"/></svg>
|
||||
<span class="tab-label">Statistics</span>
|
||||
@@ -99,6 +107,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<option value="phosphor">Phosphor</option>
|
||||
</select>
|
||||
</div>
|
||||
<button id="header-share-btn" class="header-bar-btn header-share-btn" type="button" aria-label="Copy a link to this frequency" title="Copy a link to this frequency">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><path d="M6.5 9.5a2.6 2.6 0 0 0 3.9.3l2.2-2.2a2.6 2.6 0 0 0-3.7-3.7l-1 1"/><path d="M9.5 6.5a2.6 2.6 0 0 0-3.9-.3L3.4 8.4a2.6 2.6 0 0 0 3.7 3.7l1-1"/></svg>
|
||||
</button>
|
||||
<button id="theme-toggle" class="header-bar-btn" type="button" aria-label="Toggle dark or light theme">Light</button>
|
||||
<button id="header-auth-btn" class="header-bar-btn" type="button" style="display:none;" aria-label="Login or Logout">Login</button>
|
||||
</div>
|
||||
@@ -140,13 +151,18 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<div class="spectrum-wrap">
|
||||
<div id="spectrum-bookmark-axis"></div>
|
||||
<div id="spectrum-bookmark-side-left" class="spectrum-bookmark-side spectrum-bookmark-side-left" aria-hidden="true"></div>
|
||||
<canvas id="spectrum-canvas" tabindex="0" role="img" aria-label="Spectrum display"></canvas>
|
||||
<canvas id="spectrum-canvas" tabindex="0" role="img" aria-label="Spectrum display" aria-describedby="spectrum-text-summary"></canvas>
|
||||
<p id="spectrum-text-summary" class="visually-hidden">Spectrum data is waiting for the receiver.</p>
|
||||
<div id="spectrum-squelch-line" class="spectrum-squelch-line" style="display:none;" data-state="closed">
|
||||
<span class="spectrum-squelch-grip" id="spectrum-squelch-grip" role="slider" tabindex="0"
|
||||
aria-label="Squelch threshold" aria-valuemin="-120" aria-valuemax="-30" aria-valuenow="-95">SQL <span id="spectrum-squelch-label">-95</span> dB</span>
|
||||
</div>
|
||||
<div id="spectrum-zoom-indicator" aria-hidden="true"></div>
|
||||
<div id="spectrum-minimap" aria-hidden="true"><div class="minimap-view"></div></div>
|
||||
<div id="spectrum-db-axis" aria-hidden="true"></div>
|
||||
<div id="spectrum-bookmark-side-right" class="spectrum-bookmark-side spectrum-bookmark-side-right" aria-hidden="true"></div>
|
||||
<div id="spectrum-tooltip"></div>
|
||||
<canvas id="spectrum-waterfall-canvas" tabindex="0" role="img" aria-label="Waterfall display" style="display:none;"></canvas>
|
||||
<canvas id="spectrum-waterfall-canvas" tabindex="0" role="img" aria-label="Waterfall display" aria-describedby="spectrum-text-summary" style="display:none;"></canvas>
|
||||
<div id="spectrum-freq-axis">
|
||||
<button id="spectrum-center-left-btn" class="spectrum-edge-shift spectrum-edge-shift-left" type="button" aria-label="Shift spectrum center left">‹</button>
|
||||
<button id="spectrum-center-right-btn" class="spectrum-edge-shift spectrum-edge-shift-right" type="button" aria-label="Shift spectrum center right">›</button>
|
||||
@@ -155,13 +171,14 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<div id="spectrum-size-grip" title="Drag to resize spectrum height" aria-label="Resize spectrum height"></div>
|
||||
<div id="spectrum-controls">
|
||||
<div id="spectrum-bw-row">
|
||||
<label id="spectrum-bw-label">Bandwidth <input type="number" id="spectrum-bw-input" value="" step="0.1" min="0.1" /> kHz</label>
|
||||
<button id="spectrum-bw-set-btn" type="button">Set</button>
|
||||
<button id="spectrum-bw-auto-btn" type="button">Auto BW</button>
|
||||
<button id="spectrum-bw-sweet-btn" type="button">Sweet-spot</button>
|
||||
<label id="spectrum-bw-label" class="spectrum-field"><span class="spectrum-field-name">Bandwidth</span><input type="number" id="spectrum-bw-input" value="" step="0.1" min="0.1" /><span class="spectrum-field-unit">kHz</span></label>
|
||||
<button id="spectrum-bw-set-btn" class="spectrum-btn" type="button">Set</button>
|
||||
<button id="spectrum-bw-auto-btn" class="spectrum-btn" type="button">Auto BW</button>
|
||||
<button id="spectrum-bw-sweet-btn" class="spectrum-btn" type="button">Sweet-spot</button>
|
||||
</div>
|
||||
<div class="spectrum-controls-spacer" aria-hidden="true"></div>
|
||||
<div id="spectrum-level-row">
|
||||
<label class="overview-control" id="spectrum-peak-hold-label">Peak Hold
|
||||
<label class="overview-control spectrum-field" id="spectrum-peak-hold-label"><span class="spectrum-field-name">Peak Hold</span>
|
||||
<select id="overview-peak-hold" class="status-input">
|
||||
<option value="0">Off</option>
|
||||
<option value="500">0.5 s</option>
|
||||
@@ -174,10 +191,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<option value="60000">60 s</option>
|
||||
</select>
|
||||
</label>
|
||||
<label id="spectrum-floor-label">Floor <input type="number" id="spectrum-floor-input" value="-115" step="5" /> dB</label>
|
||||
<label id="spectrum-range-label">Range <input type="number" id="spectrum-range-input" value="90" step="10" min="10" /> dB</label>
|
||||
<button id="spectrum-auto-btn" type="button">Auto</button>
|
||||
<label id="spectrum-gamma-label">Contrast <input type="range" id="spectrum-gamma-input" min="0.2" max="3.0" step="0.1" value="1.0" /><span id="spectrum-gamma-value">1.0</span></label>
|
||||
<label id="spectrum-floor-label" class="spectrum-field"><span class="spectrum-field-name">Floor</span><input type="number" id="spectrum-floor-input" value="-115" step="5" /><span class="spectrum-field-unit">dB</span></label>
|
||||
<label id="spectrum-range-label" class="spectrum-field"><span class="spectrum-field-name">Range</span><input type="number" id="spectrum-range-input" value="90" step="10" min="10" /><span class="spectrum-field-unit">dB</span></label>
|
||||
<button id="spectrum-auto-btn" class="spectrum-btn" type="button">Auto</button>
|
||||
<label id="spectrum-gamma-label" class="spectrum-field"><span class="spectrum-field-name">Contrast</span><input type="range" id="spectrum-gamma-input" min="0.2" max="3.0" step="0.1" value="1.0" /><span id="spectrum-gamma-value" class="spectrum-field-value">1.0</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="spectrum-hint" class="spectrum-hint-mouse">Scroll to zoom · Ctrl+Scroll to tune · Drag to pan · Drag BW edges to resize · +/- zoom · Arrows pan · 0 reset</div>
|
||||
@@ -204,27 +221,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<div class="label"><span>Signal strength</span></div>
|
||||
</div>
|
||||
<div class="freq-field frequency-col">
|
||||
<input class="status-input" id="freq" type="text" value="--" />
|
||||
<div class="label"><span>Frequency</span></div>
|
||||
<input class="status-input" id="freq" type="text" value="--" aria-describedby="freq-label" aria-label="Tuned frequency" autocomplete="off" autocorrect="off" spellcheck="false" />
|
||||
<div class="label" id="freq-label"><span>Frequency</span></div>
|
||||
</div>
|
||||
<div class="freq-field frequency-col center-frequency-col" id="center-freq-field" style="display:none;">
|
||||
<input class="status-input" id="center-freq" type="text" value="--" />
|
||||
<div class="label"><span>Center Frequency</span></div>
|
||||
</div>
|
||||
<div class="freq-field unit-col">
|
||||
<div class="jog-step" id="jog-step">
|
||||
<button type="button" data-step="1000000">MHz</button>
|
||||
<button type="button" data-step="1000" class="active">kHz</button>
|
||||
<button type="button" data-step="1">Hz</button>
|
||||
</div>
|
||||
<div class="label"><span>Unit</span></div>
|
||||
</div>
|
||||
<div class="freq-field mult-col">
|
||||
<div class="jog-mult" id="jog-mult">
|
||||
<button type="button" data-mult="1" class="active" aria-label="Use full tune step">1x</button>
|
||||
<button type="button" data-mult="10" aria-label="Use one tenth tune step">0.1x</button>
|
||||
</div>
|
||||
<div class="label"><span>Step Scale</span></div>
|
||||
<input class="status-input" id="center-freq" type="text" value="--" aria-describedby="center-freq-label" aria-label="SDR center frequency" autocomplete="off" autocorrect="off" spellcheck="false" />
|
||||
<div class="label" id="center-freq-label"><span>Center Frequency</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -232,10 +234,14 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<div class="controls-tray-scroll">
|
||||
<div class="controls-tray">
|
||||
<div class="controls-row full-row">
|
||||
<div class="controls-col label-below-col">
|
||||
<div class="controls-col controls-col-mode label-below-col">
|
||||
<div class="label"><span>Mode</span></div>
|
||||
<div class="inline">
|
||||
<select class="status-input" id="mode"></select>
|
||||
<!-- The select stays as the mode's value: a dozen call sites and
|
||||
several plugins read #mode.value. It is hidden from sight and
|
||||
from assistive tech; the buttons beside it are the control. -->
|
||||
<select class="visually-hidden" id="mode" tabindex="-1" aria-hidden="true"></select>
|
||||
<div id="mode-picker" class="mode-picker" role="group" aria-label="Demodulation mode"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="controls-col controls-col-center">
|
||||
@@ -247,6 +253,35 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<button id="jog-up" type="button" class="jog-btn">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="controls-col controls-col-step">
|
||||
<div class="inline step-controls-inline">
|
||||
<div class="freq-field unit-col">
|
||||
<div class="jog-step" id="jog-step">
|
||||
<button type="button" data-step="1000000">MHz</button>
|
||||
<button type="button" data-step="1000" class="active">kHz</button>
|
||||
<button type="button" data-step="1">Hz</button>
|
||||
</div>
|
||||
<div class="label"><span>Unit</span></div>
|
||||
</div>
|
||||
<div class="freq-field mult-col">
|
||||
<div class="jog-mult" id="jog-mult">
|
||||
<button type="button" data-mult="1" class="active" aria-label="Use full tune step">1x</button>
|
||||
<button type="button" data-mult="10" aria-label="Use one tenth tune step">0.1x</button>
|
||||
</div>
|
||||
<div class="label"><span>Step Scale</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="controls-col controls-col-power label-below-col" id="tx-power-col">
|
||||
<div class="label"><span>Transmit / Power</span></div>
|
||||
<div class="btn-grid">
|
||||
<button id="ptt-btn" type="button" aria-pressed="false">Start TX</button>
|
||||
<button id="power-btn" type="button" aria-pressed="false">Power On</button>
|
||||
<button id="lock-btn" type="button" aria-pressed="false">Lock Tuning</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="controls-row controls-row-mode full-row" id="mode-controls-row" style="display:none">
|
||||
<div class="controls-col controls-col-wfm label-below-col" id="wfm-controls-col" style="display:none;">
|
||||
<div class="inline wfm-controls-inline">
|
||||
<label class="wfm-control">
|
||||
@@ -304,14 +339,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</div>
|
||||
<div class="label"><span>SAM</span></div>
|
||||
</div>
|
||||
<div class="controls-col controls-col-power label-below-col" id="tx-power-col">
|
||||
<div class="label"><span>Transmit / Power</span></div>
|
||||
<div class="btn-grid">
|
||||
<button id="ptt-btn" type="button">Toggle PTT</button>
|
||||
<button id="power-btn" type="button">Toggle Power</button>
|
||||
<button id="lock-btn" type="button">Lock</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="full-row label-below-row" id="vfo-row">
|
||||
<div class="label"><span>VFO</span></div>
|
||||
@@ -356,30 +383,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</div>
|
||||
</div>
|
||||
<div class="full-row label-below-row" id="vchan-row">
|
||||
<div class="label"><span>Channels / Scheduler</span></div>
|
||||
<div class="label"><span>Channels</span></div>
|
||||
<div class="channel-scheduler-controls">
|
||||
<div class="vchan-picker" id="vchan-picker"></div>
|
||||
<div class="scheduler-control-row" style="display:none">
|
||||
<div class="scheduler-release-wrap">
|
||||
<button id="scheduler-release-btn" type="button">Release to Scheduler</button>
|
||||
<div class="scheduler-step-controls">
|
||||
<button id="scheduler-prev-btn" type="button">Previous Entry</button>
|
||||
<button id="scheduler-next-btn" type="button">Next Entry</button>
|
||||
</div>
|
||||
<div id="scheduler-release-status" class="scheduler-release-status">Scheduler is controlling the rig.</div>
|
||||
<div id="scheduler-cycle-status" class="interleave-ring-wrap" style="display:none;">
|
||||
<svg class="interleave-ring" viewBox="0 0 36 36">
|
||||
<circle class="interleave-ring-bg" cx="18" cy="18" r="15.915" />
|
||||
<circle class="interleave-ring-fill" id="interleave-ring-fill" cx="18" cy="18" r="15.915"
|
||||
stroke-dasharray="100" stroke-dashoffset="100" />
|
||||
</svg>
|
||||
<div class="interleave-ring-text">
|
||||
<div class="interleave-ring-label" id="interleave-active-name">--</div>
|
||||
<div class="interleave-ring-sub" id="interleave-countdown">--</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="full-row label-below-row">
|
||||
@@ -412,20 +418,62 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<button id="tx-limit-btn" type="button">Set</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="full-row label-below-row" id="audio-row">
|
||||
<div class="label"><span>Audio</span></div>
|
||||
<div class="inline" style="gap: 0.6rem; flex-wrap: wrap; align-items: center;">
|
||||
<button id="rx-audio-btn" type="button">Play Audio</button>
|
||||
<button id="tx-audio-btn" type="button">Transmit Audio</button>
|
||||
<label class="vol-label">RX<input type="range" id="rx-vol" min="0" max="100" value="80" class="vol-slider" /><small class="vol-pct" id="rx-vol-pct">80%</small></label>
|
||||
<label class="vol-label">TX<input type="range" id="tx-vol" min="0" max="100" value="80" class="vol-slider" /><small class="vol-pct" id="tx-vol-pct">80%</small></label>
|
||||
<label class="vol-label" id="sdr-squelch-wrap" style="display:none;">SQL<input type="range" id="sdr-squelch" min="0" max="100" value="0" class="vol-slider" /><small class="vol-pct" id="sdr-squelch-pct">Open</small><button id="sdr-squelch-auto" type="button" class="sql-auto-btn" title="Set squelch to current noise level">Auto</button></label>
|
||||
<div id="audio-level">
|
||||
<div id="audio-level-fill"></div>
|
||||
<details id="audio-controls" class="advanced-radio-controls audio-controls-section">
|
||||
<summary>Audio controls</summary>
|
||||
<div class="advanced-radio-body">
|
||||
<div class="full-row" id="audio-row">
|
||||
<div class="inline" style="gap: 0.6rem; flex-wrap: wrap; align-items: center;">
|
||||
<button id="rx-audio-btn" type="button">Play Audio</button>
|
||||
<button id="tx-audio-btn" type="button">Transmit Audio</button>
|
||||
<span class="audio-group audio-volume-group">
|
||||
<span class="audio-group-label">Volume</span>
|
||||
<label class="vol-label">RX<input type="range" id="rx-vol" min="0" max="100" value="80" class="vol-slider" /><small class="vol-pct" id="rx-vol-pct">80%</small></label>
|
||||
<label class="vol-label">TX<input type="range" id="tx-vol" min="0" max="100" value="80" class="vol-slider" /><small class="vol-pct" id="tx-vol-pct">80%</small></label>
|
||||
</span>
|
||||
<span class="sql-control" id="sdr-squelch-wrap" style="display:none;">
|
||||
<button id="sdr-squelch-toggle" type="button" class="sql-toggle" aria-pressed="false" title="Turn the squelch on or off"><span class="sql-state" id="sdr-squelch-state" data-state="off" aria-hidden="true"></span>SQL</button>
|
||||
<label class="sql-db"><input type="number" id="sdr-squelch-db" min="-120" max="-30" step="1" value="-95" inputmode="numeric" aria-label="Squelch threshold in dB" /><span class="sql-db-unit">dB</span></label>
|
||||
<button id="sdr-squelch-auto" type="button" class="sql-auto-btn" title="Set the threshold just above the noise floor">Auto</button>
|
||||
</span>
|
||||
<span class="audio-group audio-level-group">
|
||||
<span class="audio-group-label">Level</span>
|
||||
<div id="audio-level">
|
||||
<div id="audio-level-fill"></div>
|
||||
</div>
|
||||
<small id="audio-status">Off</small>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<small id="audio-status" style="min-width: 60px;">Off</small>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details id="scheduler-controls" class="advanced-radio-controls scheduler-controls-section">
|
||||
<summary>Scheduler controls</summary>
|
||||
<div class="advanced-radio-body">
|
||||
<div class="scheduler-control-row" style="display:none">
|
||||
<div class="scheduler-release-wrap">
|
||||
<div class="scheduler-action-row">
|
||||
<div class="scheduler-step-controls">
|
||||
<button id="scheduler-prev-btn" type="button">Previous Entry</button>
|
||||
<button id="scheduler-next-btn" type="button">Next Entry</button>
|
||||
</div>
|
||||
<button id="scheduler-release-btn" type="button">Release to Scheduler</button>
|
||||
<div id="scheduler-cycle-status" class="interleave-ring-wrap" style="display:none;">
|
||||
<svg class="interleave-ring" viewBox="0 0 36 36">
|
||||
<circle class="interleave-ring-bg" cx="18" cy="18" r="15.915" />
|
||||
<circle class="interleave-ring-fill" id="interleave-ring-fill" cx="18" cy="18" r="15.915"
|
||||
stroke-dasharray="100" stroke-dashoffset="100" />
|
||||
</svg>
|
||||
<div class="interleave-ring-text">
|
||||
<div class="interleave-ring-label" id="interleave-active-name">--</div>
|
||||
<div class="interleave-ring-sub" id="interleave-countdown">--</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="scheduler-release-status" class="scheduler-release-status">Scheduler is controlling the rig.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -495,6 +543,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</label>
|
||||
</div>
|
||||
<div class="bm-form-actions">
|
||||
<div id="bm-form-error" class="form-error" role="alert" aria-live="polite"></div>
|
||||
<button type="submit" class="bm-save-btn">Save</button>
|
||||
<button type="button" id="bm-form-cancel">Cancel</button>
|
||||
</div>
|
||||
@@ -530,7 +579,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</div>
|
||||
</div>
|
||||
<div id="tab-digital-modes" class="tab-panel" style="display:none;">
|
||||
<div class="sub-tab-bar">
|
||||
<div class="sub-tab-bar" aria-label="Decoder views">
|
||||
<button class="sub-tab active" data-subtab="overview">Overview</button>
|
||||
<button class="sub-tab" data-subtab="ais">AIS</button>
|
||||
<button class="sub-tab" data-subtab="vdes">VDES</button>
|
||||
@@ -544,6 +593,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<button class="sub-tab" data-subtab="rds">RDS</button>
|
||||
<button class="sub-tab" data-subtab="sat">SAT</button>
|
||||
<button class="sub-tab" data-subtab="wefax">WEFAX</button>
|
||||
<button class="sub-tab" data-subtab="sstv">SSTV</button>
|
||||
</div>
|
||||
<div id="subtab-overview" class="sub-tab-panel">
|
||||
<div class="plugin-item" data-decoder="ais">
|
||||
@@ -606,6 +656,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
Decodes Meteor-M LRPT (137 MHz QPSK) weather satellite imagery.
|
||||
</div>
|
||||
</div>
|
||||
<div class="plugin-item" data-decoder="sstv">
|
||||
<strong>SSTV Decoder</strong>
|
||||
<div style="color:var(--text-muted); font-size:0.85rem; margin-top:0.2rem;">
|
||||
Slow-Scan Television — pictures in Martin, Scottie, Robot, PD and Wraase modes
|
||||
</div>
|
||||
</div>
|
||||
<div class="plugin-item" data-decoder="wefax">
|
||||
<strong>WEFAX Decoder</strong>
|
||||
<div style="color:var(--text-muted); font-size:0.85rem; margin-top:0.2rem;">
|
||||
@@ -613,6 +669,56 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="subtab-sstv" class="sub-tab-panel" style="display:none;">
|
||||
<div class="ft8-controls">
|
||||
<button id="sstv-decode-toggle-btn" type="button">Enable SSTV</button>
|
||||
<small id="sstv-status" style="color:var(--text-muted);">Idle</small>
|
||||
</div>
|
||||
<!-- View selector -->
|
||||
<div class="sat-view-bar">
|
||||
<button id="sstv-view-live" class="sat-view-btn sat-view-active" type="button">Live</button>
|
||||
<button id="sstv-view-history" class="sat-view-btn" type="button">History</button>
|
||||
</div>
|
||||
<!-- Live view -->
|
||||
<div id="sstv-live-view">
|
||||
<div style="margin:0 0 0.5rem;">
|
||||
<div style="color:var(--text-muted); font-size:0.82rem; line-height:1.5;">
|
||||
<strong>Slow-Scan Television</strong> — pictures sent as tones, in Martin,
|
||||
Scottie, Robot, PD and Wraase modes. Tune a sideband or FM signal and enable the
|
||||
decoder; the mode is read from the header the sender opens with.
|
||||
</div>
|
||||
</div>
|
||||
<div id="sstv-live-container" style="display:none; margin:0.5rem 0;">
|
||||
<div style="display:flex; align-items:center; gap:0.5rem; margin-bottom:0.3rem;">
|
||||
<strong>Receiving</strong>
|
||||
<small id="sstv-live-info" style="color:var(--text-muted);"></small>
|
||||
</div>
|
||||
<canvas id="sstv-live-canvas" width="320" height="256"
|
||||
style="width:100%; max-width:640px; image-rendering:pixelated; background:#111;"></canvas>
|
||||
</div>
|
||||
<div id="sstv-live-latest" style="margin-top:0.5rem;"></div>
|
||||
</div>
|
||||
<!-- History view -->
|
||||
<div id="sstv-history-view" style="display:none;">
|
||||
<div class="sat-history-controls">
|
||||
<input id="sstv-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. Martin, PD120)" />
|
||||
<select id="sstv-sort" class="sat-sort-select">
|
||||
<option value="newest">Newest first</option>
|
||||
<option value="oldest">Oldest first</option>
|
||||
</select>
|
||||
<button id="sstv-clear-btn" type="button" style="font-size:0.8rem;">Clear All</button>
|
||||
</div>
|
||||
<div class="sat-history-header">
|
||||
<span class="sat-col-time">Time</span>
|
||||
<span class="sat-col-type">Mode</span>
|
||||
<span class="sat-col-sat">Size</span>
|
||||
<span class="sat-col-lines">Lines</span>
|
||||
<span class="sat-col-link">Image</span>
|
||||
</div>
|
||||
<div id="sstv-history-list"></div>
|
||||
<small id="sstv-history-count" style="color:var(--text-muted);font-size:0.75rem;">No pictures yet</small>
|
||||
</div>
|
||||
</div>
|
||||
<div id="subtab-rds" class="sub-tab-panel" style="display:none;">
|
||||
<div class="rds-grid">
|
||||
<div class="rds-field"><span class="rds-label">Status</span><span id="rds-status" class="rds-value rds-no-signal">No signal</span></div>
|
||||
@@ -643,19 +749,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<input id="ais-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. MMSI, vessel, A)" />
|
||||
<small id="ais-status" style="color:var(--text-muted);">Waiting for server decode</small>
|
||||
</div>
|
||||
<div class="ais-summary">
|
||||
<div class="ais-summary-card">
|
||||
<span class="ais-summary-label">Channels</span>
|
||||
<span id="ais-channel-summary" class="ais-summary-value">A 161.975 MHz · B 162.025 MHz</span>
|
||||
</div>
|
||||
<div class="ais-summary-card">
|
||||
<span class="ais-summary-label">Tracked</span>
|
||||
<span id="ais-vessel-count" class="ais-summary-value">0 vessels</span>
|
||||
</div>
|
||||
<div class="ais-summary-card">
|
||||
<span class="ais-summary-label">Latest</span>
|
||||
<span id="ais-latest-seen" class="ais-summary-value">No traffic yet</span>
|
||||
</div>
|
||||
<div class="aprs-filter-row">
|
||||
<span class="aprs-counts">
|
||||
<span id="ais-vessel-count" class="aprs-counts-value">0 vessels</span>
|
||||
<span id="ais-latest-seen" class="aprs-counts-value">No traffic yet</span>
|
||||
<span id="ais-channel-summary" class="aprs-counts-value">A 161.975 MHz · B 162.025 MHz</span>
|
||||
</span>
|
||||
</div>
|
||||
<div id="ais-messages"></div>
|
||||
</div>
|
||||
@@ -685,20 +784,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<input id="aprs-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. SP2, beacon)" />
|
||||
<small id="aprs-status" style="color:var(--text-muted);">Waiting for server decode</small>
|
||||
</div>
|
||||
<div class="aprs-summary">
|
||||
<div class="aprs-summary-card">
|
||||
<span class="aprs-summary-label">Frames</span>
|
||||
<span id="aprs-total-count" class="aprs-summary-value">0 total</span>
|
||||
</div>
|
||||
<div class="aprs-summary-card">
|
||||
<span class="aprs-summary-label">Visible</span>
|
||||
<span id="aprs-visible-count" class="aprs-summary-value">0 shown</span>
|
||||
</div>
|
||||
<div class="aprs-summary-card">
|
||||
<span class="aprs-summary-label">Latest</span>
|
||||
<span id="aprs-latest-seen" class="aprs-summary-value">No packets yet</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="aprs-filter-row">
|
||||
<button id="aprs-type-all" class="aprs-chip active" type="button">All</button>
|
||||
<button id="aprs-type-position" class="aprs-chip" type="button">Pos</button>
|
||||
@@ -706,11 +791,15 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<button id="aprs-type-weather" class="aprs-chip" type="button">Wx</button>
|
||||
<button id="aprs-type-telemetry" class="aprs-chip" type="button">Tlm</button>
|
||||
<button id="aprs-type-other" class="aprs-chip" type="button">Other</button>
|
||||
</div>
|
||||
<div class="aprs-filter-row">
|
||||
<span class="aprs-filter-sep" aria-hidden="true"></span>
|
||||
<button id="aprs-only-pos-btn" class="aprs-chip" type="button">Pos Only</button>
|
||||
<button id="aprs-hide-crc-btn" class="aprs-chip" type="button">No CRC</button>
|
||||
<button id="aprs-collapse-dup-btn" class="aprs-chip" type="button">Dupes</button>
|
||||
<span class="aprs-counts">
|
||||
<span id="aprs-visible-count" class="aprs-counts-value">0 shown</span>
|
||||
<span id="aprs-total-count" class="aprs-counts-value">0 total</span>
|
||||
<span id="aprs-latest-seen" class="aprs-counts-value">No packets yet</span>
|
||||
</span>
|
||||
</div>
|
||||
<div id="aprs-packets"></div>
|
||||
</div>
|
||||
@@ -720,20 +809,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<input id="hf-aprs-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. SP2, beacon)" />
|
||||
<small id="hf-aprs-status" style="color:var(--text-muted);">Waiting for server decode</small>
|
||||
</div>
|
||||
<div class="aprs-summary">
|
||||
<div class="aprs-summary-card">
|
||||
<span class="aprs-summary-label">Frames</span>
|
||||
<span id="hf-aprs-total-count" class="aprs-summary-value">0 total</span>
|
||||
</div>
|
||||
<div class="aprs-summary-card">
|
||||
<span class="aprs-summary-label">Visible</span>
|
||||
<span id="hf-aprs-visible-count" class="aprs-summary-value">0 shown</span>
|
||||
</div>
|
||||
<div class="aprs-summary-card">
|
||||
<span class="aprs-summary-label">Latest</span>
|
||||
<span id="hf-aprs-latest-seen" class="aprs-summary-value">No packets yet</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="aprs-filter-row">
|
||||
<button id="hf-aprs-type-all" class="aprs-chip active" type="button">All</button>
|
||||
<button id="hf-aprs-type-position" class="aprs-chip" type="button">Pos</button>
|
||||
@@ -741,11 +816,15 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<button id="hf-aprs-type-weather" class="aprs-chip" type="button">Wx</button>
|
||||
<button id="hf-aprs-type-telemetry" class="aprs-chip" type="button">Tlm</button>
|
||||
<button id="hf-aprs-type-other" class="aprs-chip" type="button">Other</button>
|
||||
</div>
|
||||
<div class="aprs-filter-row">
|
||||
<span class="aprs-filter-sep" aria-hidden="true"></span>
|
||||
<button id="hf-aprs-only-pos-btn" class="aprs-chip" type="button">Pos Only</button>
|
||||
<button id="hf-aprs-hide-crc-btn" class="aprs-chip" type="button">No CRC</button>
|
||||
<button id="hf-aprs-collapse-dup-btn" class="aprs-chip" type="button">Dupes</button>
|
||||
<span class="aprs-counts">
|
||||
<span id="hf-aprs-visible-count" class="aprs-counts-value">0 shown</span>
|
||||
<span id="hf-aprs-total-count" class="aprs-counts-value">0 total</span>
|
||||
<span id="hf-aprs-latest-seen" class="aprs-counts-value">No packets yet</span>
|
||||
</span>
|
||||
</div>
|
||||
<div id="hf-aprs-packets"></div>
|
||||
</div>
|
||||
@@ -844,7 +923,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<div class="sat-view-bar">
|
||||
<button id="sat-view-live" class="sat-view-btn sat-view-active" type="button">Live</button>
|
||||
<button id="sat-view-history" class="sat-view-btn" type="button">History</button>
|
||||
<button id="sat-view-predictions" class="sat-view-btn" type="button">Predictions</button>
|
||||
</div>
|
||||
<!-- Live view -->
|
||||
<div id="sat-live-view">
|
||||
@@ -886,50 +964,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<div id="sat-history-list"></div>
|
||||
<small id="sat-history-count" style="color:var(--text-muted);font-size:0.75rem;">No images yet</small>
|
||||
</div>
|
||||
<!-- Predictions view -->
|
||||
<div id="sat-predictions-view" style="display:none;">
|
||||
<div class="ft8-controls">
|
||||
<input id="sat-pred-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. ISS, NOAA, Meteor)" />
|
||||
<select id="sat-pred-category" class="sat-sort-select">
|
||||
<option value="all">All</option>
|
||||
<option value="weather">Weather</option>
|
||||
<option value="amateur">Ham Radio</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
<select id="sat-pred-min-el" class="sat-sort-select">
|
||||
<option value="0">All passes</option>
|
||||
<option value="10">Min 10°</option>
|
||||
<option value="20">Min 20°</option>
|
||||
<option value="45">Min 45°</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Current passes -->
|
||||
<div id="sat-pred-current-section">
|
||||
<div class="sat-pred-section-title">Currently receivable</div>
|
||||
<div class="sat-pred-header sat-pred-header-current">
|
||||
<span class="sat-pred-col-sat">Satellite</span>
|
||||
<span class="sat-pred-col-el">Max El</span>
|
||||
<span class="sat-pred-col-time">AOS Start</span>
|
||||
<span class="sat-pred-col-time">AOS End</span>
|
||||
<span class="sat-pred-col-countdown">Time left</span>
|
||||
<span class="sat-pred-col-dir">Direction</span>
|
||||
</div>
|
||||
<div id="sat-pred-current-list"></div>
|
||||
</div>
|
||||
<!-- Upcoming passes -->
|
||||
<div id="sat-pred-upcoming-section">
|
||||
<div class="sat-pred-section-title">Upcoming passes</div>
|
||||
<div class="sat-pred-header">
|
||||
<span class="sat-pred-col-time">AOS (UTC)</span>
|
||||
<span class="sat-pred-col-sat">Satellite</span>
|
||||
<span class="sat-pred-col-el">Max El</span>
|
||||
<span class="sat-pred-col-dur">Duration</span>
|
||||
<span class="sat-pred-col-dir">Direction</span>
|
||||
</div>
|
||||
<div id="sat-pred-list"></div>
|
||||
</div>
|
||||
<small id="sat-pred-status" style="color:var(--text-muted);font-size:0.75rem;">Loading predictions…</small>
|
||||
</div>
|
||||
</div>
|
||||
<div id="subtab-wefax" class="sub-tab-panel" style="display:none;">
|
||||
<div class="ft8-controls">
|
||||
@@ -991,55 +1025,105 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<template id="tmpl-map">
|
||||
<div id="map-stage">
|
||||
<div class="map-overlay-panel">
|
||||
<div class="map-locator-filter-group">
|
||||
<span class="map-locator-filter-label">Filter by</span>
|
||||
<div id="map-locator-phase" class="map-locator-phase-row"></div>
|
||||
</div>
|
||||
<div class="map-locator-filter-group">
|
||||
<span class="map-locator-filter-label" id="map-locator-choice-label">Show</span>
|
||||
<div id="map-locator-choice-filter" class="map-locator-chip-row"></div>
|
||||
</div>
|
||||
<div class="map-locator-filter-group">
|
||||
<span class="map-locator-filter-label">Rig</span>
|
||||
<select id="map-rig-filter" class="map-history-select" aria-label="Filter by rig">
|
||||
<option value="">All</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="map-locator-filter-group">
|
||||
<span class="map-locator-filter-label">Search</span>
|
||||
<input type="text" id="map-search-filter" class="map-search-input" placeholder="Callsign, MMSI, locator, message..." />
|
||||
</div>
|
||||
<div class="map-locator-filter-group">
|
||||
<span class="map-locator-filter-label">History</span>
|
||||
<select id="map-history-limit" class="map-history-select" aria-label="Map history limit">
|
||||
<option value="15">15 min</option>
|
||||
<option value="30">30 min</option>
|
||||
<option value="60">1 hr</option>
|
||||
<option value="180">3 hrs</option>
|
||||
<option value="360">6 hrs</option>
|
||||
<option value="720">12 hrs</option>
|
||||
<option value="1440">24 hrs</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="map-locator-filter-group">
|
||||
<span class="map-locator-filter-label">Paths</span>
|
||||
<div class="map-locator-phase-row">
|
||||
<button type="button" id="map-p2p-paths-toggle" class="map-locator-phase-btn">TRX Paths On</button>
|
||||
<button type="button" id="map-contact-paths-toggle" class="map-locator-phase-btn">Contact Paths On</button>
|
||||
<span class="map-locator-empty">TRX paths on popup, directed decode paths when target locator is known</span>
|
||||
<div class="map-overlay-filters">
|
||||
<div class="map-locator-filter-group">
|
||||
<span class="map-locator-filter-label">Filter</span>
|
||||
<div id="map-locator-phase" class="map-locator-phase-row"></div>
|
||||
</div>
|
||||
<div class="map-locator-filter-group">
|
||||
<span class="map-locator-filter-label" id="map-locator-choice-label">Show</span>
|
||||
<div id="map-locator-choice-filter" class="map-locator-chip-row"></div>
|
||||
</div>
|
||||
<div class="map-locator-filter-group">
|
||||
<span class="map-locator-filter-label">Rig</span>
|
||||
<select id="map-rig-filter" class="map-history-select" aria-label="Filter by rig">
|
||||
<option value="">All</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="map-locator-filter-group">
|
||||
<span class="map-locator-filter-label">History</span>
|
||||
<select id="map-history-limit" class="map-history-select" aria-label="Map history limit">
|
||||
<option value="15">15 min</option>
|
||||
<option value="30">30 min</option>
|
||||
<option value="60">1 hr</option>
|
||||
<option value="180">3 hrs</option>
|
||||
<option value="360">6 hrs</option>
|
||||
<option value="720">12 hrs</option>
|
||||
<option value="1440">24 hrs</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="map-locator-filter-group">
|
||||
<span class="map-locator-filter-label">Paths</span>
|
||||
<div class="map-locator-phase-row">
|
||||
<button type="button" id="map-p2p-paths-toggle" class="map-locator-phase-btn" aria-pressed="true" title="TRX paths are drawn from a station popup">TRX</button>
|
||||
<button type="button" id="map-contact-paths-toggle" class="map-locator-phase-btn" aria-pressed="true" title="Directed decode paths are drawn when the target locator is known">Contact</button>
|
||||
<span class="map-paths-hint">TRX paths on popup, directed decode paths when target locator is known</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Last, so the search field takes whatever the fixed-width
|
||||
groups leave on the bar's final row rather than a sliver. -->
|
||||
<div class="map-locator-filter-group map-filter-grow">
|
||||
<span class="map-locator-filter-label">Search</span>
|
||||
<input type="text" id="map-search-filter" class="map-search-input" placeholder="Callsign, MMSI, locator, message..." />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="map-corner-controls">
|
||||
<button type="button" id="map-fullscreen-btn" class="map-fullscreen-btn">Fullscreen</button>
|
||||
<button type="button" id="map-overlay-toggle-btn" class="map-overlay-toggle-btn">Hide Filters</button>
|
||||
<div class="map-overlay-actions">
|
||||
<button type="button" id="map-fullscreen-btn" class="map-fullscreen-btn">Fullscreen</button>
|
||||
<button type="button" id="map-overlay-toggle-btn" class="map-overlay-toggle-btn">Hide Filters</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="map-band-legend" class="map-band-legend" aria-label="Band color legend"></div>
|
||||
<div id="aprs-map"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div id="tab-satellites" class="tab-panel" style="display:none;">
|
||||
<h2 class="section-heading">Satellites</h2>
|
||||
<div class="ft8-controls">
|
||||
<input id="sat-pred-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. ISS, NOAA, Meteor)" />
|
||||
<select id="sat-pred-category" class="sat-sort-select">
|
||||
<option value="all">All</option>
|
||||
<option value="weather">Weather</option>
|
||||
<option value="amateur">Ham Radio</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
<select id="sat-pred-min-el" class="sat-sort-select">
|
||||
<option value="0">All passes</option>
|
||||
<option value="10">Min 10°</option>
|
||||
<option value="20">Min 20°</option>
|
||||
<option value="45">Min 45°</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Current passes -->
|
||||
<div id="sat-pred-current-section">
|
||||
<div class="sat-pred-section-title">Currently receivable</div>
|
||||
<div class="sat-pred-header sat-pred-header-current">
|
||||
<span class="sat-pred-col-sat">Satellite</span>
|
||||
<span class="sat-pred-col-el">Max El</span>
|
||||
<span class="sat-pred-col-time">AOS Start</span>
|
||||
<span class="sat-pred-col-time">AOS End</span>
|
||||
<span class="sat-pred-col-countdown">Time left</span>
|
||||
<span class="sat-pred-col-dir">Direction</span>
|
||||
</div>
|
||||
<div id="sat-pred-current-list"></div>
|
||||
</div>
|
||||
<!-- Upcoming passes -->
|
||||
<div id="sat-pred-upcoming-section">
|
||||
<div class="sat-pred-section-title">Upcoming passes</div>
|
||||
<div class="sat-pred-header">
|
||||
<span class="sat-pred-col-time">AOS (UTC)</span>
|
||||
<span class="sat-pred-col-sat">Satellite</span>
|
||||
<span class="sat-pred-col-el">Max El</span>
|
||||
<span class="sat-pred-col-dur">Duration</span>
|
||||
<span class="sat-pred-col-dir">Direction</span>
|
||||
</div>
|
||||
<div id="sat-pred-list"></div>
|
||||
</div>
|
||||
<small id="sat-pred-status" style="color:var(--text-muted);font-size:0.75rem;">Loading predictions…</small>
|
||||
</div>
|
||||
|
||||
<div id="tab-statistics" class="tab-panel" style="display:none;">
|
||||
<h2 class="section-heading">Statistics</h2>
|
||||
<template id="tmpl-statistics">
|
||||
<div class="stats-controls">
|
||||
<div class="stats-control-group">
|
||||
@@ -1180,6 +1264,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</section>
|
||||
</div>
|
||||
<div id="tab-settings" class="tab-panel" style="display:none;">
|
||||
<h2 class="section-heading">Settings</h2>
|
||||
<div class="sub-tab-bar">
|
||||
<button class="sub-tab active" data-subtab="settings-scheduler">Scheduler</button>
|
||||
<button class="sub-tab" data-subtab="settings-background-decode">Background Decode</button>
|
||||
@@ -1382,30 +1467,31 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<div id="subtab-settings-background-decode" class="sub-tab-panel" style="display:none;">
|
||||
<div id="background-decode-panel" class="sch-panel">
|
||||
<div class="sch-toast" id="background-decode-toast" role="alert" aria-live="polite" style="display:none;"></div>
|
||||
<!-- Now Playing status card (moved to top) -->
|
||||
<div class="now-playing-card">
|
||||
<div id="background-decode-status-card" class="sch-status-card">No background decode bookmarks configured.</div>
|
||||
</div>
|
||||
<div class="sch-section">
|
||||
<div class="sch-section-title">Configuration</div>
|
||||
<div class="sch-row">
|
||||
<label class="sch-label bgd-toggle-wrap">Background decode
|
||||
<span class="bgd-toggle-row">
|
||||
<input type="checkbox" id="background-decode-enabled" />
|
||||
<span>Enable hidden background decoder channels</span>
|
||||
</span>
|
||||
<div class="sch-section-title">Background Decode</div>
|
||||
<p class="bgd-intro">
|
||||
Decodes saved bookmarks on hidden channels while you work another band.
|
||||
Needs an SDR rig, and a bookmark is only decoded while it falls inside
|
||||
the span the rig is tuned across.
|
||||
</p>
|
||||
<div class="bgd-controls">
|
||||
<label class="bgd-enable">
|
||||
<input type="checkbox" id="background-decode-enabled" />
|
||||
<span>Enabled</span>
|
||||
</label>
|
||||
<span id="bgd-span-summary" class="bgd-span" aria-live="polite"></span>
|
||||
</div>
|
||||
<div class="sch-row" style="flex-direction:column;gap:0.5rem;">
|
||||
<label class="sch-label" style="min-width:100%;">Bookmarks
|
||||
<input type="text" id="bgd-bookmark-filter" class="bgd-checklist-filter" placeholder="Filter bookmarks..." />
|
||||
</label>
|
||||
<div class="bgd-list-toolbar">
|
||||
<input type="text" id="bgd-bookmark-filter" class="bgd-checklist-filter"
|
||||
placeholder="Filter bookmarks…" aria-label="Filter bookmarks" />
|
||||
<div class="bgd-select-actions">
|
||||
<button type="button" id="bgd-select-all-btn" class="bgd-select-btn" aria-label="Select all bookmarks">Select All</button>
|
||||
<button type="button" id="bgd-deselect-all-btn" class="bgd-select-btn" aria-label="Deselect all bookmarks">Deselect All</button>
|
||||
<button type="button" id="bgd-select-all-btn" class="bgd-select-btn" aria-label="Select every bookmark">All</button>
|
||||
<button type="button" id="bgd-deselect-all-btn" class="bgd-select-btn" aria-label="Select no bookmarks">None</button>
|
||||
</div>
|
||||
<div id="bgd-bookmark-checklist" class="bgd-checklist"></div>
|
||||
</div>
|
||||
<div id="bgd-bookmark-checklist" class="bgd-checklist" role="group"
|
||||
aria-label="Bookmarks decoded in the background"></div>
|
||||
<div id="bgd-selection-summary" class="bgd-summary" aria-live="polite"></div>
|
||||
</div>
|
||||
|
||||
<div class="sch-actions">
|
||||
@@ -1475,6 +1561,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</div>
|
||||
</div>
|
||||
<div id="tab-about" class="tab-panel" style="display:none;">
|
||||
<h2 class="section-heading">About</h2>
|
||||
<div id="auth-badge" style="display:none; margin-bottom: 1rem; padding: 0.5rem; background: var(--bg-secondary); border-radius: 0.25rem; color: var(--text-muted); font-size: 0.85rem;">Authenticated as: <strong id="auth-role-badge">--</strong></div>
|
||||
<template id="tmpl-about">
|
||||
<div class="sub-tab-bar">
|
||||
@@ -1580,10 +1667,13 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</template>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="copyright">
|
||||
Built by <a href="https://www.qrzcq.com/call/SP2SJG" target="_blank" rel="noopener">SP2SJG</a> from <a href="https://haxx.space" target="_blank" rel="noopener">haxx.space</a> · <span class="gh-link-wrap"><a class="gh-link" href="https://git.haxx.space/sjg/trx-rs" target="_blank" rel="noopener" aria-label="Open trx-rs source repository"><svg class="gh-link-icon" viewBox="0 0 16 16" aria-hidden="true"><path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"></path></svg><span>trx-rs source</span></a></span> — <span id="copyright-year"></span>
|
||||
<div class="footer-meta">
|
||||
<span class="copyright">
|
||||
Built by <a href="https://www.qrzcq.com/call/SP2SJG" target="_blank" rel="noopener">SP2SJG</a> from <a href="https://haxx.space" target="_blank" rel="noopener">haxx.space</a> · © <span id="copyright-year"></span>
|
||||
</span>
|
||||
<span class="gh-link-wrap"><a class="gh-link" href="https://git.haxx.space/sjg/trx-rs" target="_blank" rel="noopener" aria-label="Open trx-rs source repository"><svg class="gh-link-icon" viewBox="0 0 16 16" aria-hidden="true"><path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"></path></svg><span>trx-rs source</span></a></span>
|
||||
</div>
|
||||
<div class="hint" id="power-hint" aria-live="polite">Connecting…</div>
|
||||
<div class="hint" id="power-hint" data-state="busy" aria-live="polite">Connecting…</div>
|
||||
</div>
|
||||
<div id="conn-lost-overlay" class="decode-history-overlay content-overlay is-hidden" aria-live="assertive" aria-atomic="true">
|
||||
<div class="decode-history-overlay-card">
|
||||
@@ -1617,57 +1707,16 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<div class="shortcut-overlay-hint">Press <kbd>F1</kbd> or <kbd>Esc</kbd> to close</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="decode-history-overlay" class="decode-history-overlay is-hidden" aria-live="polite" aria-atomic="true">
|
||||
<div class="decode-history-overlay-card">
|
||||
<div id="decode-history-overlay-title" class="decode-history-overlay-title">Loading decode history…</div>
|
||||
<div id="decode-history-overlay-sub" class="decode-history-overlay-sub">Preparing recent decodes for the UI</div>
|
||||
<div id="decode-history-overlay" class="history-progress is-hidden" role="status" aria-live="polite" aria-atomic="true">
|
||||
<div class="history-progress-text">
|
||||
<span id="decode-history-overlay-title" class="history-progress-title">Loading decode history…</span>
|
||||
<span id="decode-history-overlay-sub" class="history-progress-sub">Preparing recent decodes for the UI</span>
|
||||
</div>
|
||||
<span class="history-progress-track"><span id="decode-history-progress-bar" class="history-progress-bar"></span></span>
|
||||
</div>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/opus-decoder@0.7.11/dist/opus-decoder.min.js" charset="UTF-8"></script>
|
||||
<script defer src="/vendor/opus-decoder-0.7.11.min.js" charset="UTF-8"></script>
|
||||
<script defer src="/vendor/leaflet.js"></script>
|
||||
<script defer src="/leaflet-ais-tracksymbol.js"></script>
|
||||
<script defer src="/webgl-renderer.js"></script>
|
||||
<script defer src="/app.js"></script>
|
||||
<script>
|
||||
// Lazy plugin loader: loads plugin scripts when their tab/feature is first activated
|
||||
(function() {
|
||||
var pluginScripts = {
|
||||
'digital-modes': ['/ft8.js', '/ft4.js', '/ft2.js', '/wspr.js', '/cw.js', '/background-decode.js', '/sat.js', '/wefax.js'],
|
||||
'map-data': ['/map-core.js', '/ais.js', '/vdes.js', '/aprs.js', '/hf-aprs.js'],
|
||||
'map': ['/map-core.js', '/ais.js', '/vdes.js', '/aprs.js', '/hf-aprs.js', '/sat.js', '/sat-scheduler.js'],
|
||||
'statistics': ['/map-core.js'],
|
||||
'bookmarks': ['/bookmarks.js'],
|
||||
'recorder': [],
|
||||
'settings': ['/vchan.js', '/scheduler.js']
|
||||
};
|
||||
var loaded = new Set();
|
||||
function loadPlugins(tab) {
|
||||
var scripts = pluginScripts[tab];
|
||||
if (!scripts) return;
|
||||
scripts.forEach(function(src) {
|
||||
if (loaded.has(src)) return;
|
||||
loaded.add(src);
|
||||
var s = document.createElement('script');
|
||||
s.src = src;
|
||||
s.defer = true;
|
||||
document.body.appendChild(s);
|
||||
});
|
||||
}
|
||||
// Eager plugin loading is triggered by app.js (after window.trx is set up)
|
||||
// via window.loadEagerPlugins(). Dynamic scripts are effectively async, so
|
||||
// loading them before app.js would cause map-core.js to crash when
|
||||
// window.trx is not yet defined.
|
||||
window.loadEagerPlugins = function() {
|
||||
['digital-modes', 'map-data', 'bookmarks', 'settings'].forEach(loadPlugins);
|
||||
};
|
||||
// Load others on tab switch
|
||||
document.addEventListener('click', function(e) {
|
||||
var tab = e.target.closest('[data-tab]');
|
||||
if (tab) loadPlugins(tab.dataset.tab);
|
||||
});
|
||||
window.loadPluginsForTab = loadPlugins;
|
||||
})();
|
||||
</script>
|
||||
<!-- Template cloning is handled by navigateToTab() in app.js -->
|
||||
<script type="module" src="/app.js"></script>
|
||||
<!-- Template cloning is handled by the typed application bundle. -->
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
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) {
|
||||
let days = secs.div_euclid(86_400);
|
||||
let z = days + 719_468;
|
||||
@@ -20,6 +65,7 @@ fn utc_ymd_from_unix_secs(secs: i64) -> (i32, u32, u32) {
|
||||
}
|
||||
|
||||
fn main() {
|
||||
generate_asset_manifest();
|
||||
let secs = match SystemTime::now().duration_since(UNIX_EPOCH) {
|
||||
Ok(d) => d.as_secs() as i64,
|
||||
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(())
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
import { build } from "esbuild";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const frontendDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const sourceDir = path.join(frontendDir, "src");
|
||||
const outputDir = path.join(frontendDir, "..", "assets", "web", "generated");
|
||||
|
||||
await rm(outputDir, { recursive: true, force: true });
|
||||
|
||||
await build({
|
||||
entryPoints: {
|
||||
app: path.join(sourceDir, "bootstrap.ts"),
|
||||
ft2: path.join(sourceDir, "plugins", "ft2.ts"),
|
||||
ft4: path.join(sourceDir, "plugins", "ft4.ts"),
|
||||
wspr: path.join(sourceDir, "plugins", "wspr.ts"),
|
||||
cw: path.join(sourceDir, "plugins", "cw.ts"),
|
||||
ft8: path.join(sourceDir, "plugins", "ft8.ts"),
|
||||
vdes: path.join(sourceDir, "plugins", "vdes.ts"),
|
||||
wefax: path.join(sourceDir, "plugins", "wefax.ts"),
|
||||
sstv: path.join(sourceDir, "plugins", "sstv.ts"),
|
||||
"background-decode": path.join(sourceDir, "plugins", "background-decode.ts"),
|
||||
ais: path.join(sourceDir, "plugins", "ais.ts"),
|
||||
aprs: path.join(sourceDir, "plugins", "aprs.ts"),
|
||||
"hf-aprs": path.join(sourceDir, "plugins", "hf-aprs.ts"),
|
||||
sat: path.join(sourceDir, "plugins", "sat.ts"),
|
||||
"sat-scheduler": path.join(sourceDir, "plugins", "sat-scheduler.ts"),
|
||||
"satellite-predictions": path.join(sourceDir, "plugins", "satellite-predictions.ts"),
|
||||
vchan: path.join(sourceDir, "plugins", "vchan.ts"),
|
||||
bookmarks: path.join(sourceDir, "plugins", "bookmarks.ts"),
|
||||
scheduler: path.join(sourceDir, "plugins", "scheduler.ts"),
|
||||
"map-core": path.join(sourceDir, "map-core.ts"),
|
||||
screenshot: path.join(sourceDir, "screenshot.ts"),
|
||||
},
|
||||
outdir: outputDir,
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
splitting: true,
|
||||
entryNames: "[name]",
|
||||
chunkNames: "chunk-[hash]",
|
||||
platform: "browser",
|
||||
target: "es2022",
|
||||
sourcemap: false,
|
||||
legalComments: "inline",
|
||||
charset: "utf8",
|
||||
logLevel: "info",
|
||||
});
|
||||
|
||||
await build({
|
||||
entryPoints: [path.join(sourceDir, "decode-history-worker.ts")],
|
||||
outfile: path.join(outputDir, "decode-history-worker.js"),
|
||||
bundle: true,
|
||||
format: "iife",
|
||||
platform: "browser",
|
||||
target: "es2022",
|
||||
sourcemap: false,
|
||||
legalComments: "inline",
|
||||
charset: "utf8",
|
||||
logLevel: "info",
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
import eslint from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: [
|
||||
"../assets/web/generated/**",
|
||||
],
|
||||
},
|
||||
{
|
||||
files: ["**/*.mjs"],
|
||||
...eslint.configs.recommended,
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
},
|
||||
...tseslint.configs.recommendedTypeChecked.map((config) => ({
|
||||
...config,
|
||||
files: ["src/**/*.ts", "tests/**/*.ts"],
|
||||
})),
|
||||
{
|
||||
files: ["src/**/*.ts", "tests/**/*.ts"],
|
||||
ignores: ["src/decode-history-worker.ts"],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"no-undef": "off",
|
||||
"@typescript-eslint/no-explicit-any": "error",
|
||||
"@typescript-eslint/no-unused-vars": ["error", {
|
||||
argsIgnorePattern: "^_",
|
||||
caughtErrors: "none",
|
||||
varsIgnorePattern: "^_",
|
||||
}],
|
||||
// DOM event targets intentionally ignore listener return values. Async
|
||||
// listeners handle their own failures; keep the rule for all other
|
||||
// promise misuse sites.
|
||||
"@typescript-eslint/no-misused-promises": ["error", { checksVoidReturn: false }],
|
||||
"@typescript-eslint/restrict-template-expressions": ["error", { allowNumber: true }],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["src/decode-history-worker.ts"],
|
||||
languageOptions: {
|
||||
globals: globals.worker,
|
||||
parserOptions: {
|
||||
project: "./tsconfig.worker.json",
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"no-undef": "off",
|
||||
"@typescript-eslint/no-explicit-any": "error",
|
||||
"@typescript-eslint/restrict-template-expressions": ["error", { allowNumber: true }],
|
||||
},
|
||||
},
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user